Compare commits
164 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0eed4a2649 | |||
| 7b49c95967 | |||
| 30126b1709 | |||
| 66c7db73e2 | |||
| 07af3acb84 | |||
| b2feccdb90 | |||
| db2f46b46e | |||
| ff7c768287 | |||
| 236fbf061c | |||
| 21b0a153e8 | |||
| 35ca3f3a07 | |||
| 87f72db8ad | |||
| 93b763865b | |||
| b15b5ad9ba | |||
| 364600e545 | |||
| 23b2a2a0c0 | |||
| ef6eec3cf8 | |||
| 94f4682f2f | |||
| f407a3cb54 | |||
| c11c1200e2 | |||
| 0acbf87dde | |||
| 153336d757 | |||
| 570710c556 | |||
| de13d5ea74 | |||
| f36e528086 | |||
| 52ce930c31 | |||
| bb694c9926 | |||
| a8c77c8db3 | |||
| 3263638c57 | |||
| c30e5800df | |||
| 161e0d8ea8 | |||
| 93f68aa29d | |||
| c5ce35ff13 | |||
| 7069c8b636 | |||
| 6149427753 | |||
| 536b762f97 | |||
| b423dcedf7 | |||
| 16cd1fef2d | |||
| c94d0a642d | |||
| 135390788d | |||
| 98e4e38809 | |||
| 6d5a12a21f | |||
| fe3b3b536c | |||
| aa8a6baac4 | |||
| b0982249c3 | |||
| b3a8a69244 | |||
| 8cd1ecffc4 | |||
| 7967b71405 | |||
| cd0d5971e2 | |||
| b52b4db989 | |||
| ef5cfb4908 | |||
| ee7781ee67 | |||
| 8c5bb32ec6 | |||
| 007400d2a7 | |||
| 1f60e64217 | |||
| 69de7e4afd | |||
| 29025059af | |||
| 6229c25872 | |||
| 73ac74a9d6 | |||
| ebb1e233d8 | |||
| e719e32c73 | |||
| 46ab5fe78d | |||
| dc467aef91 | |||
| 0bc915fe1f | |||
| b7f9ad786a | |||
| 6c80521cf8 | |||
| a174884269 | |||
| f3b1865a79 | |||
| 6c99651ac9 | |||
| 0aaf888dd1 | |||
| d947ce17a3 | |||
| 97c957b70f | |||
| 82830c86ac | |||
| d8e4189100 | |||
| 6bcde56525 | |||
| 88ebe3c337 | |||
| 5f4d1b05a8 | |||
| 370c46bddb | |||
| 47b5e03bbb | |||
| 556ca59a99 | |||
| 81c5af474d | |||
| cdaee3f62f | |||
| aab4288f67 | |||
| bab49e4442 | |||
| 7608aca681 | |||
| 58907bdc4d | |||
| 8dfd92082c | |||
| e39304da90 | |||
| 31fd3f3f63 | |||
| e1ab24b36b | |||
| f5b92ddcf9 | |||
| d9ee87d4b4 | |||
| 5e83db54ac | |||
| de7b12a759 | |||
| 1236011174 | |||
| b60f2cdf81 | |||
| 0c310ab068 | |||
| a87f66cc0c | |||
| c05756357f | |||
| f4b4b5febd | |||
| 805290b17f | |||
| fecc2237b8 | |||
| 471cc1ee94 | |||
| 41ebf59964 | |||
| a5e9a3e1ce | |||
| 23689d9fe1 | |||
| 601d432fbf | |||
| a21e9c508e | |||
| 55b0c0509d | |||
| 563c6b79fa | |||
| 8d9e5f9d56 | |||
| c0f6ccaf2a | |||
| 9b3e4ec7fb | |||
| 9d45eb21a4 | |||
| bcf8fe59f5 | |||
| 5b411456c7 | |||
| 4432816934 | |||
| 5277537445 | |||
| e73ce8cd8f | |||
| 120015d133 | |||
| f85cf61019 | |||
| 41226d173a | |||
| 83244c85fe | |||
| 27dd868d97 | |||
| 45b35ea5b0 | |||
| ac8b9f82cd | |||
| 9d0e417f2a | |||
| 40369ccb7b | |||
| 61ef3f7bdd | |||
| bcb1a825d3 | |||
| 1f7a3fe664 | |||
| dcd855896e | |||
| 4778134ab6 | |||
| 300b19d1d6 | |||
| 945ae33361 | |||
| dbbcb6c5cc | |||
| 016959ad7c | |||
| 7a9599786c | |||
| fa537390c5 | |||
| bb24bdb06c | |||
| b55100d5c3 | |||
| 02cb9c751a | |||
| 8555938f52 | |||
| a2a3ea62f1 | |||
| 0d5310eb4b | |||
| 5c6bd5d65a | |||
| dcb1b4e3a6 | |||
| b5547d3fa9 | |||
| a5a2692a5f | |||
| 7a112c84be | |||
| b3b3566a27 | |||
| f77c501db6 | |||
| 68e179bfd2 | |||
| 20d9178159 | |||
| b2c32173e1 | |||
| 82a2883f82 | |||
| 1807d736b1 | |||
| f2b1839fdc | |||
| 564ef3706f | |||
| 417fa280c3 | |||
| 5077e56d76 | |||
| 3a7c429c4b | |||
| f7ccd56ec0 | |||
| 1f2a7ee523 |
@@ -8,6 +8,7 @@ env/
|
||||
venv/
|
||||
.venv/
|
||||
ENV/
|
||||
uv.lock
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
@@ -28,3 +29,8 @@ Thumbs.db
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
|
||||
# Package manager lock files & DB files
|
||||
uv.lock
|
||||
*.db
|
||||
*.sqlite3
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to iNTERCEPT will be documented in this file.
|
||||
|
||||
## [2.9.5] - 2026-01-14
|
||||
|
||||
### Added
|
||||
- **MAC-Randomization Resistant Detection** - TSCM now identifies devices using randomized MAC addresses
|
||||
- **Clickable Score Cards** - Click on threat scores to see detailed findings
|
||||
- **Device Detail Expansion** - Click-to-expand device details in TSCM results
|
||||
- **Root Privilege Check** - Warning display when running without required privileges
|
||||
- **Real-time Device Streaming** - Devices stream to dashboard during TSCM sweep
|
||||
|
||||
### Changed
|
||||
- **TSCM Correlation Engine** - Improved device correlation with comprehensive reporting
|
||||
- **Device Classification System** - Enhanced threat classification and scoring
|
||||
- **WiFi Scanning** - Improved scanning reliability and device naming
|
||||
|
||||
### Fixed
|
||||
- **RF Scanning** - Fixed scanning issues with improved status feedback
|
||||
- **TSCM Modal Readability** - Improved modal styling and close button visibility
|
||||
- **Linux Device Detection** - Added more fallback methods for device detection
|
||||
- **macOS Device Detection** - Fixed TSCM device detection on macOS
|
||||
- **Bluetooth Event Type** - Fixed device type being overwritten
|
||||
- **rtl_433 Bias-T Flag** - Corrected bias-t flag handling
|
||||
|
||||
---
|
||||
|
||||
## [2.9.0] - 2026-01-10
|
||||
|
||||
### Added
|
||||
- **Landing Page** - Animated welcome screen with logo reveal and "See the Invisible" tagline
|
||||
- **New Branding** - Redesigned logo featuring 'i' with signal wave brackets
|
||||
- **Logo Assets** - Full-size SVG logos in `/static/img/` for external use
|
||||
- **Instagram Promo** - Animated HTML promo video template in `/promo/` directory
|
||||
- **Listening Post Scanner** - Fully functional frequency scanning with signal detection
|
||||
- Scan button toggles between start/stop states
|
||||
- Signal hits logged with Listen button to tune directly
|
||||
- Proper 4-column display (Time, Frequency, Modulation, Action)
|
||||
|
||||
### Changed
|
||||
- **Rebranding** - Application renamed from "INTERCEPT" to "iNTERCEPT"
|
||||
- **Updated Tagline** - "Signal Intelligence & Counter Surveillance Platform"
|
||||
- **Setup Script** - Now installs Python packages via apt first (more reliable on Debian/Ubuntu)
|
||||
- Uses `--system-site-packages` for venv to leverage apt packages
|
||||
- Added fallback logic when pip fails
|
||||
- **Troubleshooting Docs** - Added sections for pip install issues and apt alternatives
|
||||
|
||||
### Fixed
|
||||
- **Tuning Dial Audio** - Fixed audio stopping when using tuning knob
|
||||
- Added restart prevention flags to avoid overlapping restarts
|
||||
- Increased debounce time for smoother operation
|
||||
- Added silent mode for programmatic value changes
|
||||
- **Scanner Signal Hits** - Fixed table column count and colspan
|
||||
- **Favicon** - Updated to new 'i' logo design
|
||||
|
||||
---
|
||||
|
||||
## [2.0.0] - 2026-01-06
|
||||
|
||||
### Added
|
||||
- **Listening Post Mode** - New frequency scanner with automatic signal detection
|
||||
- Scans frequency ranges and stops on detected signals
|
||||
- Real-time audio monitoring with ffmpeg integration
|
||||
- Skip button to continue scanning after signal detection
|
||||
- Configurable dwell time, squelch, and step size
|
||||
- Preset frequency bands (FM broadcast, Air band, Marine, etc.)
|
||||
- Activity log of detected signals
|
||||
- **Aircraft Dashboard Improvements**
|
||||
- Dependency warning when rtl_fm or ffmpeg not installed
|
||||
- Auto-restart audio when switching frequencies
|
||||
- Fixed toolbar overflow with custom frequency input
|
||||
- **Device Correlation** - Match WiFi and Bluetooth devices by manufacturer
|
||||
- **Settings System** - SQLite-based persistent settings storage
|
||||
- **Comprehensive Test Suite** - Added tests for routes, validation, correlation, database
|
||||
|
||||
### Changed
|
||||
- **Documentation Overhaul**
|
||||
- Simplified README with clear macOS and Debian installation steps
|
||||
- Added Docker installation option
|
||||
- Complete tool reference table in HARDWARE.md
|
||||
- Removed redundant/confusing content
|
||||
- **Setup Script Rewrite**
|
||||
- Full macOS support with Homebrew auto-installation
|
||||
- Improved Debian/Ubuntu package detection
|
||||
- Added ffmpeg to tool checks
|
||||
- Better error messages with platform-specific install commands
|
||||
- **Dockerfile Updated**
|
||||
- Added ffmpeg for Listening Post audio encoding
|
||||
- Added dump1090 with fallback for different package names
|
||||
|
||||
### Fixed
|
||||
- SoapySDR device detection for RTL-SDR and HackRF
|
||||
- Aircraft dashboard toolbar layout when using custom frequency input
|
||||
- Frequency switching now properly stops/restarts audio
|
||||
|
||||
### Technical
|
||||
- Added `utils/constants.py` for centralized configuration values
|
||||
- Added `utils/database.py` for SQLite settings storage
|
||||
- Added `utils/correlation.py` for device correlation logic
|
||||
- Added `routes/listening_post.py` for scanner endpoints
|
||||
- Added `routes/settings.py` for settings API
|
||||
- Added `routes/correlation.py` for correlation API
|
||||
|
||||
---
|
||||
|
||||
## [1.2.0] - 2026-12-29
|
||||
|
||||
### Added
|
||||
- Airspy SDR support
|
||||
- GPS coordinate persistence
|
||||
- SoapySDR device detection improvements
|
||||
|
||||
### Fixed
|
||||
- RTL-SDR and HackRF detection via SoapySDR
|
||||
|
||||
---
|
||||
|
||||
## [1.1.0] - 2026-12-18
|
||||
|
||||
### Added
|
||||
- Satellite tracking with TLE data
|
||||
- Full-screen dashboard for aircraft radar
|
||||
- Full-screen dashboard for satellite tracking
|
||||
|
||||
---
|
||||
|
||||
## [1.0.0] - 2026-12-15
|
||||
|
||||
### Initial Release
|
||||
- Pager decoding (POCSAG/FLEX)
|
||||
- 433MHz sensor decoding
|
||||
- ADS-B aircraft tracking
|
||||
- WiFi reconnaissance
|
||||
- Bluetooth scanning
|
||||
- Multi-SDR support (RTL-SDR, LimeSDR, HackRF)
|
||||
|
||||
@@ -3,22 +3,71 @@
|
||||
|
||||
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
|
||||
ffmpeg \
|
||||
# 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/*
|
||||
|
||||
# Build dump1090-fa and acarsdec from source (packages not available in slim repos)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
git \
|
||||
pkg-config \
|
||||
cmake \
|
||||
libncurses-dev \
|
||||
libsndfile1-dev \
|
||||
# Build dump1090
|
||||
&& cd /tmp \
|
||||
&& git clone --depth 1 https://github.com/flightaware/dump1090.git \
|
||||
&& cd dump1090 \
|
||||
&& make \
|
||||
&& cp dump1090 /usr/bin/dump1090-fa \
|
||||
&& ln -s /usr/bin/dump1090-fa /usr/bin/dump1090 \
|
||||
&& rm -rf /tmp/dump1090 \
|
||||
# Build acarsdec
|
||||
&& cd /tmp \
|
||||
&& git clone --depth 1 https://github.com/TLeconte/acarsdec.git \
|
||||
&& cd acarsdec \
|
||||
&& mkdir build && cd build \
|
||||
&& cmake .. -Drtl=ON \
|
||||
&& make \
|
||||
&& cp acarsdec /usr/bin/acarsdec \
|
||||
&& rm -rf /tmp/acarsdec \
|
||||
# Cleanup build tools to reduce image size
|
||||
&& apt-get remove -y \
|
||||
build-essential \
|
||||
git \
|
||||
pkg-config \
|
||||
cmake \
|
||||
libncurses-dev \
|
||||
&& apt-get autoremove -y \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements first for better caching
|
||||
@@ -28,13 +77,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"]
|
||||
|
||||
@@ -8,29 +8,78 @@
|
||||
|
||||
<p align="center">
|
||||
<strong>Signal Intelligence Platform</strong><br>
|
||||
A web-based front-end for signal intelligence tools.
|
||||
A web-based interface for software-defined radio tools.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="static/images/screenshots/screenshot2.png" alt="Screenshot">
|
||||
<img src="static/images/screenshots/logo-banner.png" alt="Screenshot">
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
- **ACARS Messaging** - Aircraft datalink messages via acarsdec
|
||||
- **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
|
||||
|
||||
---
|
||||
|
||||
## Community
|
||||
## Installation / Debian / Ubuntu / MacOS
|
||||
|
||||
```
|
||||
|
||||
**1. Clone and run:**
|
||||
```bash
|
||||
git clone https://github.com/smittix/intercept.git
|
||||
cd intercept
|
||||
./setup.sh
|
||||
sudo -E venv/bin/python intercept.py
|
||||
```
|
||||
|
||||
### 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** | Must support promiscuous (monitor) mode | ~$20-40 |
|
||||
| **Bluetooth adapter** | Device scanning (usually built-in) | - |
|
||||
| **GPS** | Any Linux supported GPS Unit | ~10 |
|
||||
|
||||
Most features work with a basic RTL-SDR dongle (RTL2832U + R820T2).
|
||||
|
||||
| :exclamation: Not using an RTL-SDR Device? |
|
||||
|-----------------------------------------------
|
||||
|Intercept supports any device that SoapySDR supports. You must however have the correct module for your device installed! For example if you have an SDRPlay device you'd need to install soapysdr-module-sdrplay.
|
||||
|
||||
| :exclamation: GPS Usage |
|
||||
|-----------------------------------------------
|
||||
|gpsd is needed for real time location. Intercept automatically checks to see if you're running gpsd in the background when any maps are rendered.
|
||||
|
||||
---
|
||||
|
||||
## Discord Server
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/z3g3NJMe">Join our Discord</a>
|
||||
@@ -38,62 +87,23 @@ 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.
|
||||
|
||||
> **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
|
||||
- [Security](docs/SECURITY.md) - Network security and best practices
|
||||
|
||||
---
|
||||
|
||||
## Disclaimer
|
||||
|
||||
**This software is for educational purposes only.**
|
||||
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.
|
||||
|
||||
**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
|
||||
|
||||
---
|
||||
@@ -112,6 +122,12 @@ Created by **smittix** - [GitHub](https://github.com/smittix)
|
||||
[multimon-ng](https://github.com/EliasOenal/multimon-ng) |
|
||||
[rtl_433](https://github.com/merbanan/rtl_433) |
|
||||
[dump1090](https://github.com/flightaware/dump1090) |
|
||||
[acarsdec](https://github.com/TLeconte/acarsdec) |
|
||||
[aircrack-ng](https://www.aircrack-ng.org/) |
|
||||
[Leaflet.js](https://leafletjs.com/) |
|
||||
[Celestrak](https://celestrak.org/)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"version": "2026-01-11_fae1348c",
|
||||
"downloaded": "2026-01-12T15:55:42.769654Z"
|
||||
}
|
||||
@@ -25,49 +25,99 @@ from typing import Any
|
||||
|
||||
from flask import Flask, render_template, jsonify, send_file, Response, request
|
||||
|
||||
from config import VERSION
|
||||
from config import VERSION, CHANGELOG
|
||||
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
|
||||
app = Flask(__name__)
|
||||
|
||||
# Disable Werkzeug debugger PIN (not needed for local development tool)
|
||||
os.environ['WERKZEUG_DEBUG_PIN'] = 'off'
|
||||
|
||||
|
||||
# ============================================
|
||||
# SECURITY HEADERS
|
||||
# ============================================
|
||||
|
||||
@app.after_request
|
||||
def add_security_headers(response):
|
||||
"""Add security headers to all responses."""
|
||||
# Prevent MIME type sniffing
|
||||
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||
# Prevent clickjacking
|
||||
response.headers['X-Frame-Options'] = 'SAMEORIGIN'
|
||||
# Enable XSS filter
|
||||
response.headers['X-XSS-Protection'] = '1; mode=block'
|
||||
# Referrer policy
|
||||
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
||||
# Permissions policy (disable unnecessary features)
|
||||
response.headers['Permissions-Policy'] = 'geolocation=(self), microphone=()'
|
||||
return response
|
||||
|
||||
|
||||
# ============================================
|
||||
# GLOBAL PROCESS MANAGEMENT
|
||||
# ============================================
|
||||
|
||||
# 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()
|
||||
|
||||
# ACARS aircraft messaging
|
||||
acars_process = None
|
||||
acars_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
acars_lock = threading.Lock()
|
||||
|
||||
# APRS amateur radio tracking
|
||||
aprs_process = None
|
||||
aprs_rtl_process = None
|
||||
aprs_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
aprs_lock = threading.Lock()
|
||||
|
||||
# TSCM (Technical Surveillance Countermeasures)
|
||||
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
tscm_lock = threading.Lock()
|
||||
|
||||
# ============================================
|
||||
# GLOBAL STATE DICTIONARIES
|
||||
# ============================================
|
||||
@@ -76,23 +126,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)
|
||||
|
||||
|
||||
# ============================================
|
||||
@@ -107,7 +164,7 @@ def index() -> str:
|
||||
'rtl_433': check_tool('rtl_433')
|
||||
}
|
||||
devices = [d.to_dict() for d in SDRFactory.detect_devices()]
|
||||
return render_template('index.html', tools=tools, devices=devices, version=VERSION)
|
||||
return render_template('index.html', tools=tools, devices=devices, version=VERSION, changelog=CHANGELOG)
|
||||
|
||||
|
||||
@app.route('/favicon.svg')
|
||||
@@ -122,6 +179,120 @@ def get_devices() -> Response:
|
||||
return jsonify([d.to_dict() for d in devices])
|
||||
|
||||
|
||||
@app.route('/devices/debug')
|
||||
def get_devices_debug() -> Response:
|
||||
"""Get detailed SDR device detection diagnostics."""
|
||||
import shutil
|
||||
|
||||
diagnostics = {
|
||||
'tools': {},
|
||||
'rtl_test': {},
|
||||
'soapy': {},
|
||||
'usb': {},
|
||||
'kernel_modules': {},
|
||||
'detected_devices': [],
|
||||
'suggestions': []
|
||||
}
|
||||
|
||||
# Check for required tools
|
||||
diagnostics['tools']['rtl_test'] = shutil.which('rtl_test') is not None
|
||||
diagnostics['tools']['SoapySDRUtil'] = shutil.which('SoapySDRUtil') is not None
|
||||
diagnostics['tools']['lsusb'] = shutil.which('lsusb') is not None
|
||||
|
||||
# Run rtl_test and capture full output
|
||||
if diagnostics['tools']['rtl_test']:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['rtl_test', '-t'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
diagnostics['rtl_test'] = {
|
||||
'returncode': result.returncode,
|
||||
'stdout': result.stdout[:2000] if result.stdout else '',
|
||||
'stderr': result.stderr[:2000] if result.stderr else ''
|
||||
}
|
||||
|
||||
# Check for common errors
|
||||
combined = (result.stdout or '') + (result.stderr or '')
|
||||
if 'No supported devices found' in combined:
|
||||
diagnostics['suggestions'].append('No RTL-SDR device detected. Check USB connection.')
|
||||
if 'usb_claim_interface error' in combined:
|
||||
diagnostics['suggestions'].append('Device busy - kernel DVB driver may have claimed it. Run: sudo modprobe -r dvb_usb_rtl28xxu')
|
||||
if 'Permission denied' in combined.lower():
|
||||
diagnostics['suggestions'].append('USB permission denied. Add udev rules or run as root.')
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
diagnostics['rtl_test'] = {'error': 'Timeout after 5 seconds'}
|
||||
except Exception as e:
|
||||
diagnostics['rtl_test'] = {'error': str(e)}
|
||||
else:
|
||||
diagnostics['suggestions'].append('rtl_test not found. Install rtl-sdr package.')
|
||||
|
||||
# Run SoapySDRUtil
|
||||
if diagnostics['tools']['SoapySDRUtil']:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['SoapySDRUtil', '--find'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
diagnostics['soapy'] = {
|
||||
'returncode': result.returncode,
|
||||
'stdout': result.stdout[:2000] if result.stdout else '',
|
||||
'stderr': result.stderr[:2000] if result.stderr else ''
|
||||
}
|
||||
except subprocess.TimeoutExpired:
|
||||
diagnostics['soapy'] = {'error': 'Timeout after 10 seconds'}
|
||||
except Exception as e:
|
||||
diagnostics['soapy'] = {'error': str(e)}
|
||||
|
||||
# Check USB devices (Linux)
|
||||
if diagnostics['tools']['lsusb']:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['lsusb'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
# Filter for common SDR vendor IDs
|
||||
sdr_vendors = ['0bda', '1d50', '1df7', '0403'] # Realtek, OpenMoko/HackRF, SDRplay, FTDI
|
||||
usb_lines = [l for l in result.stdout.split('\n')
|
||||
if any(v in l.lower() for v in sdr_vendors) or 'rtl' in l.lower() or 'sdr' in l.lower()]
|
||||
diagnostics['usb']['devices'] = usb_lines if usb_lines else ['No SDR-related USB devices found']
|
||||
except Exception as e:
|
||||
diagnostics['usb'] = {'error': str(e)}
|
||||
|
||||
# Check for loaded kernel modules that conflict (Linux)
|
||||
if platform.system() == 'Linux':
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['lsmod'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
conflicting = ['dvb_usb_rtl28xxu', 'rtl2832', 'rtl2830']
|
||||
loaded = [m for m in conflicting if m in result.stdout]
|
||||
diagnostics['kernel_modules']['conflicting_loaded'] = loaded
|
||||
if loaded:
|
||||
diagnostics['suggestions'].append(f"Conflicting kernel modules loaded: {', '.join(loaded)}. Run: sudo modprobe -r {' '.join(loaded)}")
|
||||
except Exception as e:
|
||||
diagnostics['kernel_modules'] = {'error': str(e)}
|
||||
|
||||
# Get detected devices
|
||||
devices = SDRFactory.detect_devices()
|
||||
diagnostics['detected_devices'] = [d.to_dict() for d in devices]
|
||||
|
||||
if not devices and not diagnostics['suggestions']:
|
||||
diagnostics['suggestions'].append('No devices detected. Check USB connection and driver installation.')
|
||||
|
||||
return jsonify(diagnostics)
|
||||
|
||||
|
||||
@app.route('/dependencies')
|
||||
def get_dependencies() -> Response:
|
||||
"""Get status of all tool dependencies."""
|
||||
@@ -130,15 +301,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 +331,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 +347,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 +367,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 +380,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 +401,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,15 +414,42 @@ 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),
|
||||
'acars': acars_process is not None and (acars_process.poll() is None if acars_process else False),
|
||||
'aprs': aprs_process is not None and (aprs_process.poll() is None if aprs_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."""
|
||||
global current_process, sensor_process, wifi_process, adsb_process
|
||||
global current_process, sensor_process, wifi_process, adsb_process, acars_process
|
||||
global aprs_process, aprs_rtl_process
|
||||
|
||||
# Import adsb module to reset its state
|
||||
from routes import adsb as adsb_module
|
||||
@@ -259,7 +458,7 @@ def kill_all() -> Response:
|
||||
processes_to_kill = [
|
||||
'rtl_fm', 'multimon-ng', 'rtl_433',
|
||||
'airodump-ng', 'aireplay-ng', 'airmon-ng',
|
||||
'dump1090'
|
||||
'dump1090', 'acarsdec', 'direwolf'
|
||||
]
|
||||
|
||||
for proc in processes_to_kill:
|
||||
@@ -284,6 +483,15 @@ def kill_all() -> Response:
|
||||
adsb_process = None
|
||||
adsb_module.adsb_using_service = False
|
||||
|
||||
# Reset ACARS state
|
||||
with acars_lock:
|
||||
acars_process = None
|
||||
|
||||
# Reset APRS state
|
||||
with aprs_lock:
|
||||
aprs_process = None
|
||||
aprs_rtl_process = None
|
||||
|
||||
return jsonify({'status': 'killed', 'processes': killed})
|
||||
|
||||
|
||||
@@ -336,17 +544,54 @@ def main() -> None:
|
||||
|
||||
print("=" * 50)
|
||||
print(" INTERCEPT // Signal Intelligence")
|
||||
print(" Pager / 433MHz / Aircraft / Satellite / WiFi / BT")
|
||||
print(" Pager / 433MHz / Aircraft / ACARS / Satellite / WiFi / BT")
|
||||
print("=" * 50)
|
||||
print()
|
||||
|
||||
# Check if running as root (required for WiFi monitor mode, some BT operations)
|
||||
import os
|
||||
if os.geteuid() != 0:
|
||||
print("\033[93m" + "=" * 50)
|
||||
print(" ⚠️ WARNING: Not running as root/sudo")
|
||||
print("=" * 50)
|
||||
print(" Some features require root privileges:")
|
||||
print(" - WiFi monitor mode and scanning")
|
||||
print(" - Bluetooth low-level operations")
|
||||
print(" - RTL-SDR access (on some systems)")
|
||||
print()
|
||||
print(" To run with full capabilities:")
|
||||
print(" sudo -E venv/bin/python intercept.py")
|
||||
print("=" * 50 + "\033[0m")
|
||||
print()
|
||||
# Store for API access
|
||||
app.config['RUNNING_AS_ROOT'] = False
|
||||
else:
|
||||
app.config['RUNNING_AS_ROOT'] = True
|
||||
print("Running as root - full capabilities enabled")
|
||||
print()
|
||||
|
||||
# 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)
|
||||
|
||||
# Initialize WebSocket for audio streaming
|
||||
try:
|
||||
from routes.audio_websocket import init_audio_websocket
|
||||
init_audio_websocket(app)
|
||||
print("WebSocket audio streaming enabled")
|
||||
except ImportError as e:
|
||||
print(f"WebSocket audio disabled (install flask-sock): {e}")
|
||||
|
||||
print(f"Open http://localhost:{args.port} in your browser")
|
||||
print()
|
||||
print("Press Ctrl+C to stop")
|
||||
|
||||
@@ -7,7 +7,51 @@ import os
|
||||
import sys
|
||||
|
||||
# Application version
|
||||
VERSION = "1.1.0"
|
||||
VERSION = "2.9.5"
|
||||
|
||||
# Changelog - latest release notes (shown on welcome screen)
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "2.9.5",
|
||||
"date": "January 2026",
|
||||
"highlights": [
|
||||
"Enhanced TSCM with MAC-randomization resistant detection",
|
||||
"Clickable score cards and device detail expansion",
|
||||
"RF scanning improvements with status feedback",
|
||||
"Root privilege check and warning display",
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2.9.0",
|
||||
"date": "January 2026",
|
||||
"highlights": [
|
||||
"New dropdown navigation menus for cleaner UI",
|
||||
"TSCM baseline recording now captures device data",
|
||||
"Device identity engine integration for threat detection",
|
||||
"Welcome screen with mode selection",
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2.8.0",
|
||||
"date": "December 2025",
|
||||
"highlights": [
|
||||
"Added TSCM counter-surveillance mode",
|
||||
"WiFi/Bluetooth device correlation engine",
|
||||
"Tracker detection (AirTag, Tile, SmartTag)",
|
||||
"Risk scoring and threat classification",
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2.7.0",
|
||||
"date": "November 2025",
|
||||
"highlights": [
|
||||
"Multi-SDR hardware support via SoapySDR",
|
||||
"LimeSDR, HackRF, Airspy, SDRplay support",
|
||||
"Improved aircraft database with photo lookup",
|
||||
"GPS auto-detection and integration",
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _get_env(key: str, default: str) -> str:
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
"""
|
||||
TSCM (Technical Surveillance Countermeasures) Frequency Database
|
||||
|
||||
Known surveillance device frequencies, sweep presets, and threat signatures
|
||||
for counter-surveillance operations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# =============================================================================
|
||||
# Known Surveillance Frequencies (MHz)
|
||||
# =============================================================================
|
||||
|
||||
SURVEILLANCE_FREQUENCIES = {
|
||||
'wireless_mics': [
|
||||
{'start': 49.0, 'end': 50.0, 'name': '49 MHz Wireless Mics', 'risk': 'medium'},
|
||||
{'start': 72.0, 'end': 76.0, 'name': 'VHF Low Band Mics', 'risk': 'medium'},
|
||||
{'start': 170.0, 'end': 216.0, 'name': 'VHF High Band Wireless', 'risk': 'medium'},
|
||||
{'start': 470.0, 'end': 698.0, 'name': 'UHF TV Band Wireless', 'risk': 'medium'},
|
||||
{'start': 902.0, 'end': 928.0, 'name': '900 MHz ISM Wireless', 'risk': 'high'},
|
||||
{'start': 1880.0, 'end': 1920.0, 'name': 'DECT Wireless', 'risk': 'high'},
|
||||
],
|
||||
|
||||
'wireless_cameras': [
|
||||
{'start': 900.0, 'end': 930.0, 'name': '900 MHz Video TX', 'risk': 'high'},
|
||||
{'start': 1200.0, 'end': 1300.0, 'name': '1.2 GHz Video', 'risk': 'high'},
|
||||
{'start': 2400.0, 'end': 2483.5, 'name': '2.4 GHz WiFi Cameras', 'risk': 'high'},
|
||||
{'start': 5150.0, 'end': 5850.0, 'name': '5.8 GHz Video', 'risk': 'high'},
|
||||
],
|
||||
|
||||
'gps_trackers': [
|
||||
{'start': 824.0, 'end': 849.0, 'name': 'Cellular 850 Uplink', 'risk': 'high'},
|
||||
{'start': 869.0, 'end': 894.0, 'name': 'Cellular 850 Downlink', 'risk': 'high'},
|
||||
{'start': 1710.0, 'end': 1755.0, 'name': 'AWS Uplink', 'risk': 'high'},
|
||||
{'start': 1850.0, 'end': 1910.0, 'name': 'PCS Uplink', 'risk': 'high'},
|
||||
{'start': 1930.0, 'end': 1990.0, 'name': 'PCS Downlink', 'risk': 'high'},
|
||||
],
|
||||
|
||||
'body_worn': [
|
||||
{'start': 49.0, 'end': 50.0, 'name': '49 MHz Body Wires', 'risk': 'critical'},
|
||||
{'start': 72.0, 'end': 76.0, 'name': 'VHF Low Band Wires', 'risk': 'critical'},
|
||||
{'start': 150.0, 'end': 174.0, 'name': 'VHF High Band', 'risk': 'critical'},
|
||||
{'start': 380.0, 'end': 400.0, 'name': 'TETRA Band', 'risk': 'high'},
|
||||
{'start': 406.0, 'end': 420.0, 'name': 'Federal/Government', 'risk': 'critical'},
|
||||
{'start': 450.0, 'end': 470.0, 'name': 'UHF Business Band', 'risk': 'high'},
|
||||
],
|
||||
|
||||
'common_bugs': [
|
||||
{'start': 88.0, 'end': 108.0, 'name': 'FM Broadcast Band Bugs', 'risk': 'low'},
|
||||
{'start': 140.0, 'end': 150.0, 'name': 'Low VHF Bugs', 'risk': 'high'},
|
||||
{'start': 418.0, 'end': 419.0, 'name': '418 MHz ISM', 'risk': 'medium'},
|
||||
{'start': 433.0, 'end': 434.8, 'name': '433 MHz ISM Band', 'risk': 'medium'},
|
||||
{'start': 868.0, 'end': 870.0, 'name': '868 MHz ISM (Europe)', 'risk': 'medium'},
|
||||
{'start': 315.0, 'end': 316.0, 'name': '315 MHz ISM (US)', 'risk': 'medium'},
|
||||
],
|
||||
|
||||
'ism_bands': [
|
||||
{'start': 26.96, 'end': 27.41, 'name': 'CB Radio / ISM 27 MHz', 'risk': 'low'},
|
||||
{'start': 40.66, 'end': 40.70, 'name': 'ISM 40 MHz', 'risk': 'low'},
|
||||
{'start': 315.0, 'end': 316.0, 'name': 'ISM 315 MHz (US)', 'risk': 'medium'},
|
||||
{'start': 433.05, 'end': 434.79, 'name': 'ISM 433 MHz (EU)', 'risk': 'medium'},
|
||||
{'start': 868.0, 'end': 868.6, 'name': 'ISM 868 MHz (EU)', 'risk': 'medium'},
|
||||
{'start': 902.0, 'end': 928.0, 'name': 'ISM 915 MHz (US)', 'risk': 'medium'},
|
||||
{'start': 2400.0, 'end': 2483.5, 'name': 'ISM 2.4 GHz', 'risk': 'medium'},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Sweep Presets
|
||||
# =============================================================================
|
||||
|
||||
SWEEP_PRESETS = {
|
||||
'quick': {
|
||||
'name': 'Quick Scan',
|
||||
'description': 'Fast 2-minute check of most common bug frequencies',
|
||||
'duration_seconds': 120,
|
||||
'ranges': [
|
||||
{'start': 88.0, 'end': 108.0, 'step': 0.1, 'name': 'FM Band'},
|
||||
{'start': 433.0, 'end': 435.0, 'step': 0.025, 'name': '433 MHz ISM'},
|
||||
{'start': 868.0, 'end': 870.0, 'step': 0.025, 'name': '868 MHz ISM'},
|
||||
],
|
||||
'wifi': True,
|
||||
'bluetooth': True,
|
||||
'rf': True,
|
||||
},
|
||||
|
||||
'standard': {
|
||||
'name': 'Standard Sweep',
|
||||
'description': 'Comprehensive 5-minute sweep of common surveillance bands',
|
||||
'duration_seconds': 300,
|
||||
'ranges': [
|
||||
{'start': 25.0, 'end': 50.0, 'step': 0.1, 'name': 'HF/Low VHF'},
|
||||
{'start': 88.0, 'end': 108.0, 'step': 0.1, 'name': 'FM Band'},
|
||||
{'start': 140.0, 'end': 175.0, 'step': 0.025, 'name': 'VHF'},
|
||||
{'start': 380.0, 'end': 450.0, 'step': 0.025, 'name': 'UHF Low'},
|
||||
{'start': 868.0, 'end': 930.0, 'step': 0.05, 'name': 'ISM 868/915'},
|
||||
],
|
||||
'wifi': True,
|
||||
'bluetooth': True,
|
||||
'rf': True,
|
||||
},
|
||||
|
||||
'full': {
|
||||
'name': 'Full Spectrum',
|
||||
'description': 'Complete 15-minute spectrum sweep (24 MHz - 1.7 GHz)',
|
||||
'duration_seconds': 900,
|
||||
'ranges': [
|
||||
{'start': 24.0, 'end': 1700.0, 'step': 0.1, 'name': 'Full Spectrum'},
|
||||
],
|
||||
'wifi': True,
|
||||
'bluetooth': True,
|
||||
'rf': True,
|
||||
},
|
||||
|
||||
'wireless_cameras': {
|
||||
'name': 'Wireless Cameras',
|
||||
'description': 'Focus on video transmission frequencies',
|
||||
'duration_seconds': 180,
|
||||
'ranges': [
|
||||
{'start': 900.0, 'end': 930.0, 'step': 0.1, 'name': '900 MHz Video'},
|
||||
{'start': 1200.0, 'end': 1300.0, 'step': 0.5, 'name': '1.2 GHz Video'},
|
||||
],
|
||||
'wifi': True, # WiFi cameras
|
||||
'bluetooth': False,
|
||||
'rf': True,
|
||||
},
|
||||
|
||||
'body_worn': {
|
||||
'name': 'Body-Worn Devices',
|
||||
'description': 'Detect body wires and covert transmitters',
|
||||
'duration_seconds': 240,
|
||||
'ranges': [
|
||||
{'start': 49.0, 'end': 50.0, 'step': 0.01, 'name': '49 MHz'},
|
||||
{'start': 72.0, 'end': 76.0, 'step': 0.01, 'name': 'VHF Low'},
|
||||
{'start': 150.0, 'end': 174.0, 'step': 0.0125, 'name': 'VHF High'},
|
||||
{'start': 406.0, 'end': 420.0, 'step': 0.0125, 'name': 'Federal'},
|
||||
{'start': 450.0, 'end': 470.0, 'step': 0.0125, 'name': 'UHF'},
|
||||
],
|
||||
'wifi': False,
|
||||
'bluetooth': True, # BLE bugs
|
||||
'rf': True,
|
||||
},
|
||||
|
||||
'gps_trackers': {
|
||||
'name': 'GPS Trackers',
|
||||
'description': 'Detect cellular-based GPS tracking devices',
|
||||
'duration_seconds': 180,
|
||||
'ranges': [
|
||||
{'start': 824.0, 'end': 894.0, 'step': 0.1, 'name': 'Cellular 850'},
|
||||
{'start': 1850.0, 'end': 1990.0, 'step': 0.1, 'name': 'PCS Band'},
|
||||
],
|
||||
'wifi': False,
|
||||
'bluetooth': True, # BLE trackers
|
||||
'rf': True,
|
||||
},
|
||||
|
||||
'bluetooth_only': {
|
||||
'name': 'Bluetooth/BLE Trackers',
|
||||
'description': 'Focus on BLE tracking devices (AirTag, Tile, etc.)',
|
||||
'duration_seconds': 60,
|
||||
'ranges': [],
|
||||
'wifi': False,
|
||||
'bluetooth': True,
|
||||
'rf': False,
|
||||
},
|
||||
|
||||
'wifi_only': {
|
||||
'name': 'WiFi Devices',
|
||||
'description': 'Scan for hidden WiFi cameras and access points',
|
||||
'duration_seconds': 60,
|
||||
'ranges': [],
|
||||
'wifi': True,
|
||||
'bluetooth': False,
|
||||
'rf': False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Known Tracker Signatures
|
||||
# =============================================================================
|
||||
|
||||
BLE_TRACKER_SIGNATURES = {
|
||||
'apple_airtag': {
|
||||
'name': 'Apple AirTag',
|
||||
'company_id': 0x004C,
|
||||
'patterns': ['findmy', 'airtag'],
|
||||
'risk': 'high',
|
||||
'description': 'Apple Find My network tracker',
|
||||
},
|
||||
'tile': {
|
||||
'name': 'Tile Tracker',
|
||||
'company_id': 0x00ED,
|
||||
'patterns': ['tile'],
|
||||
'oui_prefixes': ['C4:E7', 'DC:54', 'E6:43'],
|
||||
'risk': 'high',
|
||||
'description': 'Tile Bluetooth tracker',
|
||||
},
|
||||
'samsung_smarttag': {
|
||||
'name': 'Samsung SmartTag',
|
||||
'company_id': 0x0075,
|
||||
'patterns': ['smarttag', 'smartthings'],
|
||||
'risk': 'high',
|
||||
'description': 'Samsung SmartThings tracker',
|
||||
},
|
||||
'chipolo': {
|
||||
'name': 'Chipolo',
|
||||
'company_id': 0x0A09,
|
||||
'patterns': ['chipolo'],
|
||||
'risk': 'high',
|
||||
'description': 'Chipolo Bluetooth tracker',
|
||||
},
|
||||
'generic_beacon': {
|
||||
'name': 'Unknown BLE Beacon',
|
||||
'company_id': None,
|
||||
'patterns': [],
|
||||
'risk': 'medium',
|
||||
'description': 'Unidentified BLE beacon device',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Threat Classification
|
||||
# =============================================================================
|
||||
|
||||
THREAT_TYPES = {
|
||||
'new_device': {
|
||||
'name': 'New Device',
|
||||
'description': 'Device not present in baseline',
|
||||
'default_severity': 'medium',
|
||||
},
|
||||
'tracker': {
|
||||
'name': 'Tracking Device',
|
||||
'description': 'Known BLE tracker detected',
|
||||
'default_severity': 'high',
|
||||
},
|
||||
'unknown_signal': {
|
||||
'name': 'Unknown Signal',
|
||||
'description': 'Unidentified RF transmission',
|
||||
'default_severity': 'medium',
|
||||
},
|
||||
'burst_transmission': {
|
||||
'name': 'Burst Transmission',
|
||||
'description': 'Intermittent/store-and-forward signal detected',
|
||||
'default_severity': 'high',
|
||||
},
|
||||
'hidden_camera': {
|
||||
'name': 'Potential Hidden Camera',
|
||||
'description': 'WiFi camera or video transmitter detected',
|
||||
'default_severity': 'critical',
|
||||
},
|
||||
'gsm_bug': {
|
||||
'name': 'GSM/Cellular Bug',
|
||||
'description': 'Cellular transmission in non-phone device context',
|
||||
'default_severity': 'critical',
|
||||
},
|
||||
'rogue_ap': {
|
||||
'name': 'Rogue Access Point',
|
||||
'description': 'Unauthorized WiFi access point',
|
||||
'default_severity': 'high',
|
||||
},
|
||||
'anomaly': {
|
||||
'name': 'Signal Anomaly',
|
||||
'description': 'Unusual signal pattern or behavior',
|
||||
'default_severity': 'low',
|
||||
},
|
||||
}
|
||||
|
||||
SEVERITY_LEVELS = {
|
||||
'critical': {
|
||||
'level': 4,
|
||||
'color': '#ff0000',
|
||||
'description': 'Immediate action required - active surveillance likely',
|
||||
},
|
||||
'high': {
|
||||
'level': 3,
|
||||
'color': '#ff6600',
|
||||
'description': 'Strong indicator of surveillance device',
|
||||
},
|
||||
'medium': {
|
||||
'level': 2,
|
||||
'color': '#ffcc00',
|
||||
'description': 'Potential threat - requires investigation',
|
||||
},
|
||||
'low': {
|
||||
'level': 1,
|
||||
'color': '#00cc00',
|
||||
'description': 'Minor anomaly - low probability of threat',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# WiFi Camera Detection Patterns
|
||||
# =============================================================================
|
||||
|
||||
WIFI_CAMERA_PATTERNS = {
|
||||
'ssid_patterns': [
|
||||
'cam', 'camera', 'ipcam', 'webcam', 'dvr', 'nvr',
|
||||
'hikvision', 'dahua', 'reolink', 'wyze', 'ring',
|
||||
'arlo', 'nest', 'blink', 'eufy', 'yi',
|
||||
],
|
||||
'oui_manufacturers': [
|
||||
'Hikvision',
|
||||
'Dahua',
|
||||
'Axis Communications',
|
||||
'Hanwha Techwin',
|
||||
'Vivotek',
|
||||
'Ubiquiti',
|
||||
'Wyze Labs',
|
||||
'Amazon Technologies', # Ring
|
||||
'Google', # Nest
|
||||
],
|
||||
'mac_prefixes': {
|
||||
'C0:25:E9': 'TP-Link Camera',
|
||||
'A4:DA:22': 'TP-Link Camera',
|
||||
'78:8C:B5': 'TP-Link Camera',
|
||||
'D4:6E:0E': 'TP-Link Camera',
|
||||
'2C:AA:8E': 'Wyze Camera',
|
||||
'AC:CF:85': 'Hikvision',
|
||||
'54:C4:15': 'Hikvision',
|
||||
'C0:56:E3': 'Hikvision',
|
||||
'3C:EF:8C': 'Dahua',
|
||||
'A0:BD:1D': 'Dahua',
|
||||
'E4:24:6C': 'Dahua',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Utility Functions
|
||||
# =============================================================================
|
||||
|
||||
def get_frequency_risk(frequency_mhz: float) -> tuple[str, str]:
|
||||
"""
|
||||
Determine the risk level for a given frequency.
|
||||
|
||||
Returns:
|
||||
Tuple of (risk_level, category_name)
|
||||
"""
|
||||
for category, ranges in SURVEILLANCE_FREQUENCIES.items():
|
||||
for freq_range in ranges:
|
||||
if freq_range['start'] <= frequency_mhz <= freq_range['end']:
|
||||
return freq_range['risk'], freq_range['name']
|
||||
|
||||
return 'low', 'Unknown Band'
|
||||
|
||||
|
||||
def get_sweep_preset(preset_name: str) -> dict | None:
|
||||
"""Get a sweep preset by name."""
|
||||
return SWEEP_PRESETS.get(preset_name)
|
||||
|
||||
|
||||
def get_all_sweep_presets() -> dict:
|
||||
"""Get all available sweep presets."""
|
||||
return {
|
||||
name: {
|
||||
'name': preset['name'],
|
||||
'description': preset['description'],
|
||||
'duration_seconds': preset['duration_seconds'],
|
||||
}
|
||||
for name, preset in SWEEP_PRESETS.items()
|
||||
}
|
||||
|
||||
|
||||
def is_known_tracker(device_name: str | None, manufacturer_data: bytes | None = None) -> dict | None:
|
||||
"""
|
||||
Check if a BLE device matches known tracker signatures.
|
||||
|
||||
Returns:
|
||||
Tracker info dict if match found, None otherwise
|
||||
"""
|
||||
if device_name:
|
||||
name_lower = device_name.lower()
|
||||
for tracker_id, tracker_info in BLE_TRACKER_SIGNATURES.items():
|
||||
for pattern in tracker_info.get('patterns', []):
|
||||
if pattern in name_lower:
|
||||
return tracker_info
|
||||
|
||||
if manufacturer_data and len(manufacturer_data) >= 2:
|
||||
company_id = int.from_bytes(manufacturer_data[:2], 'little')
|
||||
for tracker_id, tracker_info in BLE_TRACKER_SIGNATURES.items():
|
||||
if tracker_info.get('company_id') == company_id:
|
||||
return tracker_info
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def is_potential_camera(ssid: str | None = None, mac: str | None = None, vendor: str | None = None) -> bool:
|
||||
"""Check if a WiFi device might be a hidden camera."""
|
||||
if ssid:
|
||||
ssid_lower = ssid.lower()
|
||||
for pattern in WIFI_CAMERA_PATTERNS['ssid_patterns']:
|
||||
if pattern in ssid_lower:
|
||||
return True
|
||||
|
||||
if mac:
|
||||
mac_prefix = mac[:8].upper()
|
||||
if mac_prefix in WIFI_CAMERA_PATTERNS['mac_prefixes']:
|
||||
return True
|
||||
|
||||
if vendor:
|
||||
vendor_lower = vendor.lower()
|
||||
for manufacturer in WIFI_CAMERA_PATTERNS['oui_manufacturers']:
|
||||
if manufacturer.lower() in vendor_lower:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_threat_severity(threat_type: str, context: dict | None = None) -> str:
|
||||
"""
|
||||
Determine threat severity based on type and context.
|
||||
|
||||
Args:
|
||||
threat_type: Type of threat from THREAT_TYPES
|
||||
context: Optional context dict with signal_strength, etc.
|
||||
|
||||
Returns:
|
||||
Severity level string
|
||||
"""
|
||||
threat_info = THREAT_TYPES.get(threat_type, {})
|
||||
base_severity = threat_info.get('default_severity', 'medium')
|
||||
|
||||
if context:
|
||||
# Upgrade severity based on signal strength (closer = more concerning)
|
||||
signal = context.get('signal_strength')
|
||||
if signal and signal > -50: # Very strong signal
|
||||
if base_severity == 'medium':
|
||||
return 'high'
|
||||
elif base_severity == 'high':
|
||||
return 'critical'
|
||||
|
||||
return base_severity
|
||||
@@ -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:
|
||||
@@ -31,6 +31,10 @@ Complete feature list for all modules.
|
||||
- **Emergency squawk highlighting** - visual alerts for 7500/7600/7700
|
||||
- **Aircraft details popup** - callsign, altitude, speed, heading, squawk, ICAO
|
||||
|
||||
<p align="center">
|
||||
<img src="/static/images/screenshots/screenshot_radar.png" alt="Screenshot">
|
||||
</p>
|
||||
|
||||
## Satellite Tracking
|
||||
|
||||
- **Full-screen dashboard** - dedicated popout with polar plot and ground track
|
||||
@@ -43,6 +47,13 @@ Complete feature list for all modules.
|
||||
- **Telemetry panel** - real-time azimuth, elevation, range, velocity
|
||||
- **Multiple satellite tracking** simultaneously
|
||||
|
||||
<p align="center">
|
||||
<img src="/static/images/screenshots/screenshot_sat.png" alt="Screenshot">
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="/static/images/screenshots/screenshot_sat_2.png" alt="Screenshot">
|
||||
</p>
|
||||
|
||||
## WiFi Reconnaissance
|
||||
|
||||
- **Monitor mode** management via airmon-ng
|
||||
@@ -64,13 +75,47 @@ Complete feature list for all modules.
|
||||
## Bluetooth Scanning
|
||||
|
||||
- **BLE and Classic** Bluetooth device scanning
|
||||
- **Multiple scan modes** - hcitool, bluetoothctl
|
||||
- **Multiple scan modes** - hcitool, bluetoothctl, bleak
|
||||
- **Tracker detection** - AirTag, Tile, Samsung SmartTag, Chipolo
|
||||
- **Device classification** - phones, audio, wearables, computers
|
||||
- **Manufacturer lookup** via OUI database
|
||||
- **Manufacturer lookup** via OUI database and Bluetooth Company IDs
|
||||
- **Proximity radar** visualization
|
||||
- **Device type breakdown** chart
|
||||
|
||||
## TSCM Counter-Surveillance Mode
|
||||
|
||||
Technical Surveillance Countermeasures (TSCM) screening for detecting wireless surveillance indicators.
|
||||
|
||||
### Wireless Sweep Features
|
||||
- **BLE scanning** with manufacturer data detection (AirTags, Tile, SmartTags, ESP32)
|
||||
- **WiFi scanning** for rogue APs, hidden SSIDs, camera devices
|
||||
- **RF spectrum analysis** (requires RTL-SDR) - FM bugs, ISM bands, video transmitters
|
||||
- **Cross-protocol correlation** - links devices across BLE/WiFi/RF
|
||||
- **Baseline comparison** - detect new/unknown devices vs known environment
|
||||
|
||||
### MAC-Randomization Resistant Detection
|
||||
- **Device fingerprinting** based on advertisement payloads, not MAC addresses
|
||||
- **Behavioral clustering** - groups observations into probable physical devices
|
||||
- **Session tracking** - monitors device presence windows
|
||||
- **Timing pattern analysis** - detects characteristic advertising intervals
|
||||
- **RSSI trajectory correlation** - identifies co-located devices
|
||||
|
||||
### Risk Assessment
|
||||
- **Three-tier scoring model**:
|
||||
- Informational (0-2): Known or expected devices
|
||||
- Needs Review (3-5): Unusual devices requiring assessment
|
||||
- High Interest (6+): Multiple indicators warrant investigation
|
||||
- **Risk indicators**: Stable RSSI, audio-capable, ESP32 chipsets, hidden identity, MAC rotation
|
||||
- **Audit trail** - full evidence chain for each link/flag
|
||||
- **Client-safe disclaimers** - findings are indicators, not confirmed surveillance
|
||||
|
||||
### Limitations (Documented)
|
||||
- Cannot detect non-transmitting devices
|
||||
- False positives/negatives expected
|
||||
- Results require professional verification
|
||||
- No cryptographic de-randomization
|
||||
- Passive screening only (no active probing by default)
|
||||
|
||||
## User Interface
|
||||
|
||||
- **Mode-specific header stats** - real-time badges showing key metrics per mode
|
||||
@@ -108,3 +153,4 @@ Complete feature list for all modules.
|
||||
- **GPS dongle support** - USB GPS receivers for precise observer location
|
||||
- **Disclaimer acceptance** on first use
|
||||
- **Auto-stop** when switching between modes
|
||||
|
||||
|
||||
@@ -1,91 +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
|
||||
```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 ffmpeg
|
||||
|
||||
# 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 python3-skyfield
|
||||
sudo apt install -y rtl-sdr multimon-ng rtl-433 ffmpeg
|
||||
|
||||
# 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
|
||||
@@ -97,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:
|
||||
|
||||
@@ -108,18 +92,165 @@ 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 Environment
|
||||
|
||||
### Using setup.sh (Recommended)
|
||||
```bash
|
||||
./setup.sh
|
||||
```
|
||||
|
||||
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
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Running INTERCEPT
|
||||
|
||||
After installation:
|
||||
|
||||
```bash
|
||||
sudo -E venv/bin/python intercept.py
|
||||
|
||||
# Custom port
|
||||
INTERCEPT_PORT=8080 sudo -E venv/bin/python intercept.py
|
||||
```
|
||||
|
||||
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 |
|
||||
| `ffmpeg` | ffmpeg | ffmpeg | 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 |
|
||||
| `bleak` | BLE scanning with manufacturer data (TSCM) |
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
---
|
||||
|
||||
## TSCM Mode Requirements
|
||||
|
||||
TSCM (Technical Surveillance Countermeasures) mode requires specific hardware for full functionality:
|
||||
|
||||
### BLE Scanning (Tracker Detection)
|
||||
- Any Bluetooth adapter supported by your OS
|
||||
- `bleak` Python library for manufacturer data detection
|
||||
- Detects: AirTags, Tile, SmartTags, ESP32/ESP8266 devices
|
||||
|
||||
```bash
|
||||
# Install bleak
|
||||
pip install bleak>=0.21.0
|
||||
|
||||
# Or via apt (Debian/Ubuntu)
|
||||
sudo apt install python3-bleak
|
||||
```
|
||||
|
||||
### RF Spectrum Analysis
|
||||
- **RTL-SDR dongle** (required for RF sweeps)
|
||||
- `rtl_power` command from `rtl-sdr` package
|
||||
|
||||
Frequency bands scanned:
|
||||
| Band | Frequency | Purpose |
|
||||
|------|-----------|---------|
|
||||
| FM Broadcast | 88-108 MHz | FM bugs |
|
||||
| 315 MHz ISM | 315 MHz | US wireless devices |
|
||||
| 433 MHz ISM | 433-434 MHz | EU wireless devices |
|
||||
| 868 MHz ISM | 868-869 MHz | EU IoT devices |
|
||||
| 915 MHz ISM | 902-928 MHz | US IoT devices |
|
||||
| 1.2 GHz | 1200-1300 MHz | Video transmitters |
|
||||
| 2.4 GHz ISM | 2400-2500 MHz | WiFi/BT/Video |
|
||||
|
||||
```bash
|
||||
# Linux
|
||||
sudo apt install rtl-sdr
|
||||
|
||||
# macOS
|
||||
brew install librtlsdr
|
||||
```
|
||||
|
||||
### WiFi Scanning
|
||||
- Standard WiFi adapter (managed mode for basic scanning)
|
||||
- Monitor mode capable adapter for advanced features
|
||||
- `aircrack-ng` suite for monitor mode management
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- **Bluetooth on macOS**: Uses bleak library (CoreBluetooth backend), 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
|
||||
- **TSCM on macOS**: BLE and WiFi scanning work; RF spectrum requires RTL-SDR
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# Security Considerations
|
||||
|
||||
INTERCEPT is designed as a **local signal intelligence tool** for personal use on trusted networks. This document outlines security considerations and best practices.
|
||||
|
||||
## Network Binding
|
||||
|
||||
By default, INTERCEPT binds to `0.0.0.0:5050`, making it accessible from any network interface. This is convenient for accessing the web UI from other devices on your local network, but has security implications:
|
||||
|
||||
### Recommendations
|
||||
|
||||
1. **Firewall Rules**: If you don't need remote access, configure your firewall to block external access to port 5050:
|
||||
```bash
|
||||
# Linux (iptables)
|
||||
sudo iptables -A INPUT -p tcp --dport 5050 -s 127.0.0.1 -j ACCEPT
|
||||
sudo iptables -A INPUT -p tcp --dport 5050 -j DROP
|
||||
|
||||
# macOS (pf)
|
||||
echo "block in on en0 proto tcp from any to any port 5050" | sudo pfctl -ef -
|
||||
```
|
||||
|
||||
2. **Bind to Localhost**: For local-only access, set the host environment variable:
|
||||
```bash
|
||||
export INTERCEPT_HOST=127.0.0.1
|
||||
python intercept.py
|
||||
```
|
||||
|
||||
3. **Trusted Networks Only**: Only run INTERCEPT on networks you trust. The application has no authentication mechanism.
|
||||
|
||||
## Authentication
|
||||
|
||||
INTERCEPT does **not** include authentication. This is by design for ease of use as a personal tool. If you need to expose INTERCEPT to untrusted networks:
|
||||
|
||||
1. Use a reverse proxy (nginx, Caddy) with authentication
|
||||
2. Use a VPN to access your home network
|
||||
3. Use SSH port forwarding: `ssh -L 5050:localhost:5050 your-server`
|
||||
|
||||
## Security Headers
|
||||
|
||||
INTERCEPT includes the following security headers on all responses:
|
||||
|
||||
| Header | Value | Purpose |
|
||||
|--------|-------|---------|
|
||||
| `X-Content-Type-Options` | `nosniff` | Prevent MIME type sniffing |
|
||||
| `X-Frame-Options` | `SAMEORIGIN` | Prevent clickjacking |
|
||||
| `X-XSS-Protection` | `1; mode=block` | Enable browser XSS filter |
|
||||
| `Referrer-Policy` | `strict-origin-when-cross-origin` | Control referrer information |
|
||||
| `Permissions-Policy` | `geolocation=(self), microphone=()` | Restrict browser features |
|
||||
|
||||
## Input Validation
|
||||
|
||||
All user inputs are validated before use:
|
||||
|
||||
- **Network interface names**: Validated against strict regex pattern
|
||||
- **Bluetooth interface names**: Must match `hciX` format
|
||||
- **MAC addresses**: Validated format
|
||||
- **Frequencies**: Validated range and format
|
||||
- **File paths**: Protected against directory traversal
|
||||
- **HTML output**: All user-provided content is escaped
|
||||
|
||||
## Subprocess Execution
|
||||
|
||||
INTERCEPT executes external tools (rtl_fm, airodump-ng, etc.) via subprocess. Security measures:
|
||||
|
||||
- **No shell execution**: All subprocess calls use list arguments, not shell strings
|
||||
- **Input validation**: All user-provided arguments are validated before use
|
||||
- **Process isolation**: Each tool runs in its own process with limited permissions
|
||||
|
||||
## Debug Mode
|
||||
|
||||
Debug mode is **disabled by default**. If enabled via `INTERCEPT_DEBUG=true`:
|
||||
|
||||
- The Werkzeug debugger PIN is disabled (not needed for local tool)
|
||||
- Additional logging is enabled
|
||||
- Stack traces are shown on errors
|
||||
|
||||
**Never run in debug mode on untrusted networks.**
|
||||
|
||||
## Reporting Security Issues
|
||||
|
||||
If you discover a security vulnerability, please report it by:
|
||||
|
||||
1. Opening a GitHub issue (for non-sensitive issues)
|
||||
2. Emailing the maintainer directly (for sensitive issues)
|
||||
|
||||
Please include:
|
||||
- Description of the vulnerability
|
||||
- Steps to reproduce
|
||||
- Potential impact
|
||||
- Suggested fix (if any)
|
||||
@@ -14,6 +14,37 @@ pip install -r requirements.txt
|
||||
python3 -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### pip install fails for flask or skyfield
|
||||
|
||||
On newer Debian/Ubuntu systems, pip may fail with permission errors or dependency conflicts. **Use apt instead:**
|
||||
|
||||
```bash
|
||||
# Install Python packages via apt (recommended for Debian/Ubuntu)
|
||||
sudo apt install python3-flask python3-requests python3-serial python3-skyfield
|
||||
|
||||
# Then create venv with system packages
|
||||
python3 -m venv --system-site-packages venv
|
||||
source venv/bin/activate
|
||||
sudo venv/bin/python intercept.py
|
||||
```
|
||||
|
||||
### "error: externally-managed-environment" (pip blocked)
|
||||
|
||||
This is PEP 668 protection on Ubuntu 23.04+, Debian 12+, and similar systems. Solutions:
|
||||
|
||||
```bash
|
||||
# Option 1: Use apt packages (recommended)
|
||||
sudo apt install python3-flask python3-requests python3-serial python3-skyfield
|
||||
python3 -m venv --system-site-packages venv
|
||||
source venv/bin/activate
|
||||
|
||||
# Option 2: Use pipx for isolated install
|
||||
pipx install flask
|
||||
|
||||
# Option 3: Force pip (not recommended)
|
||||
pip install --break-system-packages flask
|
||||
```
|
||||
|
||||
### "TypeError: 'type' object is not subscriptable"
|
||||
|
||||
This error occurs on Python 3.7 or 3.8. **INTERCEPT requires Python 3.9 or later.**
|
||||
@@ -33,18 +64,12 @@ pip install -r requirements.txt
|
||||
sudo venv/bin/python intercept.py
|
||||
```
|
||||
|
||||
### "externally-managed-environment" error (Ubuntu 23.04+, Debian 12+)
|
||||
### Alternative: Use the setup script
|
||||
|
||||
Modern systems use PEP 668 to protect system Python. Use a virtual environment:
|
||||
The setup script handles all installation automatically, including apt packages:
|
||||
|
||||
```bash
|
||||
# Option 1: Virtual environment (recommended)
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
sudo venv/bin/python intercept.py
|
||||
|
||||
# Option 2: Use the setup script (auto-creates venv if needed)
|
||||
chmod +x setup.sh
|
||||
./setup.sh
|
||||
```
|
||||
|
||||
@@ -101,11 +126,204 @@ Then unplug and replug your RTL-SDR.
|
||||
3. Check for other applications: `lsof | grep rtl`
|
||||
|
||||
### LimeSDR/HackRF not detected
|
||||
Ensure the correct SoapySDR module for your hardware is installed first
|
||||
|
||||
1. Verify SoapySDR is installed: `SoapySDRUtil --info`
|
||||
2. Check driver is loaded: `SoapySDRUtil --find`
|
||||
3. May need udev rules or run as root
|
||||
|
||||
### Using HackRF/Airspy/LimeSDR with ADS-B
|
||||
|
||||
For non-RTL-SDR devices, ADS-B requires `readsb` compiled with SoapySDR support (standard dump1090 won't work).
|
||||
|
||||
**Option 1: Run readsb separately and connect via Remote mode**
|
||||
|
||||
1. Start readsb with your device:
|
||||
```bash
|
||||
# HackRF
|
||||
readsb --device-type soapysdr --device driver=hackrf --net --quiet
|
||||
|
||||
# Airspy
|
||||
readsb --device-type soapysdr --device driver=airspy --net --quiet
|
||||
|
||||
# LimeSDR
|
||||
readsb --device-type soapysdr --device driver=lime --net --quiet
|
||||
```
|
||||
|
||||
2. In Intercept's ADS-B dashboard:
|
||||
- Check the **"Remote"** checkbox
|
||||
- Enter Host: `localhost` and Port: `30003`
|
||||
- Click **START**
|
||||
|
||||
3. Intercept will connect to readsb's SBS output on port 30003
|
||||
|
||||
**Option 2: Install readsb with SoapySDR support**
|
||||
|
||||
On Debian/Ubuntu:
|
||||
```bash
|
||||
# Install dependencies
|
||||
sudo apt install build-essential debhelper librtlsdr-dev pkg-config \
|
||||
libncurses5-dev libbladerf-dev libhackrf-dev liblimesuite-dev libsoapysdr-dev
|
||||
|
||||
# Clone and build
|
||||
git clone https://github.com/wiedehopf/readsb.git
|
||||
cd readsb
|
||||
dpkg-buildpackage -b --no-sign
|
||||
sudo dpkg -i ../readsb_*.deb
|
||||
```
|
||||
|
||||
### Using HackRF/Airspy with Listening Post
|
||||
|
||||
The Listening Post requires `rx_fm` from SoapySDR utilities for non-RTL-SDR devices.
|
||||
|
||||
```bash
|
||||
# Install SoapySDR utilities (includes rx_fm)
|
||||
sudo apt install soapysdr-tools
|
||||
|
||||
# Verify rx_fm is available
|
||||
which rx_fm
|
||||
```
|
||||
|
||||
If `rx_fm` is installed, select your device from the SDR dropdown in the Listening Post - HackRF, Airspy, LimeSDR, and SDRPlay are all supported.
|
||||
|
||||
### Setting up Icecast for Listening Post Audio
|
||||
|
||||
The Listening Post uses Icecast for low-latency audio streaming (2-10 second latency). Intercept will automatically start Icecast when you begin listening, but you must install and configure it first.
|
||||
|
||||
**Install Icecast:**
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo apt install icecast2
|
||||
|
||||
# macOS
|
||||
brew install icecast
|
||||
```
|
||||
|
||||
**Configure Icecast:**
|
||||
|
||||
During installation on Debian/Ubuntu, you'll be prompted to configure. Otherwise, edit `/etc/icecast2/icecast.xml`:
|
||||
|
||||
```xml
|
||||
<icecast>
|
||||
<authentication>
|
||||
<!-- Source password - used by ffmpeg to send audio -->
|
||||
<source-password>hackme</source-password>
|
||||
<!-- Admin password for web interface -->
|
||||
<admin-password>your-admin-password</admin-password>
|
||||
</authentication>
|
||||
<hostname>localhost</hostname>
|
||||
<listen-socket>
|
||||
<port>8000</port>
|
||||
</listen-socket>
|
||||
</icecast>
|
||||
```
|
||||
|
||||
**Start Icecast:**
|
||||
```bash
|
||||
# Ubuntu/Debian (as service)
|
||||
sudo systemctl enable icecast2
|
||||
sudo systemctl start icecast2
|
||||
|
||||
# Or run directly
|
||||
icecast -c /etc/icecast2/icecast.xml
|
||||
|
||||
# macOS
|
||||
brew services start icecast
|
||||
# Or: icecast -c /usr/local/etc/icecast.xml
|
||||
```
|
||||
|
||||
**Verify Icecast is running:**
|
||||
- Open http://localhost:8000 in your browser
|
||||
- You should see the Icecast status page
|
||||
|
||||
**Configure Intercept (optional):**
|
||||
|
||||
The default configuration expects Icecast on `127.0.0.1:8000` with source password `hackme` and mount point `/listen.mp3`. To change these, modify the scanner config in your API calls or update the defaults in `routes/listening_post.py`:
|
||||
|
||||
```python
|
||||
scanner_config = {
|
||||
# ... other settings ...
|
||||
'icecast_host': '127.0.0.1',
|
||||
'icecast_port': 8000,
|
||||
'icecast_mount': '/listen.mp3',
|
||||
'icecast_source_password': 'hackme',
|
||||
}
|
||||
```
|
||||
|
||||
**Troubleshooting Icecast:**
|
||||
|
||||
- **"Connection refused" errors**: Ensure Icecast is running on the configured port
|
||||
- **"Authentication failed"**: Check the source password matches between Icecast config and Intercept
|
||||
- **No audio playing**: Check Icecast status page (http://localhost:8000) to verify the mount point is active
|
||||
- **High latency**: Ensure nginx/reverse proxy isn't buffering - add `proxy_buffering off;` to nginx config
|
||||
|
||||
### Audio Streaming Issues - Detailed Debugging
|
||||
|
||||
If the Listening Post shows "Icecast mount not active" errors or audio doesn't play:
|
||||
|
||||
**1. Check the console output for errors**
|
||||
|
||||
Intercept now logs detailed error output. Look for lines starting with `[AUDIO]`:
|
||||
```
|
||||
[AUDIO] SDR errors: ... # Problems with rtl_fm/rx_fm (SDR not connected, device busy)
|
||||
[AUDIO] FFmpeg errors: ... # Problems with ffmpeg (wrong password, codec issues)
|
||||
```
|
||||
|
||||
**2. Verify SDR is connected and working**
|
||||
```bash
|
||||
# For RTL-SDR
|
||||
rtl_test -t
|
||||
|
||||
# You should see: "Found 1 device(s)"
|
||||
# If not, check USB connection and drivers
|
||||
```
|
||||
|
||||
**3. Check Icecast password (macOS Homebrew)**
|
||||
|
||||
On macOS with Homebrew, the Icecast config is at `/opt/homebrew/etc/icecast.xml`. Check the source password:
|
||||
```bash
|
||||
grep source-password /opt/homebrew/etc/icecast.xml
|
||||
```
|
||||
|
||||
If it's different from `hackme`, update it in the Listening Post Icecast config panel, or change the Icecast config and restart:
|
||||
```bash
|
||||
brew services restart icecast
|
||||
```
|
||||
|
||||
**4. Verify ffmpeg has required codecs**
|
||||
```bash
|
||||
# Check MP3 encoder is available
|
||||
ffmpeg -encoders 2>/dev/null | grep mp3
|
||||
|
||||
# Should show: libmp3lame
|
||||
# If not, reinstall ffmpeg with all codecs:
|
||||
# macOS: brew reinstall ffmpeg
|
||||
# Linux: sudo apt install ffmpeg
|
||||
```
|
||||
|
||||
**5. Test the pipeline manually**
|
||||
|
||||
Try running the audio pipeline directly to see errors:
|
||||
```bash
|
||||
# Test rtl_fm (should produce raw audio data)
|
||||
rtl_fm -M am -f 118000000 -s 24000 -r 24000 -g 40 2>&1 | head -c 1000 | xxd | head
|
||||
|
||||
# Test ffmpeg to Icecast (replace PASSWORD with your source password)
|
||||
rtl_fm -M am -f 118000000 -s 24000 -r 24000 -g 40 2>/dev/null | \
|
||||
ffmpeg -f s16le -ar 24000 -ac 1 -i pipe:0 -c:a libmp3lame -b:a 64k \
|
||||
-f mp3 -content_type audio/mpeg icecast://source:PASSWORD@127.0.0.1:8000/listen.mp3
|
||||
```
|
||||
|
||||
**6. Common error messages and solutions**
|
||||
|
||||
| Error | Cause | Solution |
|
||||
|-------|-------|----------|
|
||||
| `No supported devices found` | SDR not connected | Plug in SDR, check USB |
|
||||
| `Device or resource busy` | Another process using SDR | Click "Kill All Processes" |
|
||||
| `401 Unauthorized` | Wrong Icecast password | Check password in Icecast config |
|
||||
| `Connection refused` | Icecast not running | Start Icecast service |
|
||||
| `Encoder libmp3lame not found` | ffmpeg missing codec | Reinstall ffmpeg with codecs |
|
||||
|
||||
## WiFi Issues
|
||||
|
||||
### Monitor mode fails
|
||||
@@ -118,9 +336,7 @@ Then unplug and replug your RTL-SDR.
|
||||
|
||||
Run INTERCEPT with sudo:
|
||||
```bash
|
||||
sudo python3 intercept.py
|
||||
# Or with venv:
|
||||
sudo venv/bin/python intercept.py
|
||||
sudo -E venv/bin/python intercept.py
|
||||
```
|
||||
|
||||
### Interface not found after enabling monitor mode
|
||||
@@ -146,21 +362,6 @@ Run with sudo or add your user to the bluetooth group:
|
||||
sudo usermod -a -G bluetooth $USER
|
||||
```
|
||||
|
||||
## GPS Issues
|
||||
|
||||
### GPS dongle not detected
|
||||
|
||||
1. Install pyserial: `pip install pyserial`
|
||||
2. Check device is connected:
|
||||
- Linux: `ls /dev/ttyUSB* /dev/ttyACM*`
|
||||
- macOS: `ls /dev/tty.usb*`
|
||||
3. Add user to dialout group (Linux):
|
||||
```bash
|
||||
sudo usermod -a -G dialout $USER
|
||||
```
|
||||
4. Most GPS dongles use 9600 baud (default in INTERCEPT)
|
||||
5. GPS needs clear sky view to get a fix
|
||||
|
||||
## Decoding Issues
|
||||
|
||||
### No messages appearing (Pager mode)
|
||||
@@ -170,15 +371,20 @@ sudo usermod -a -G bluetooth $USER
|
||||
3. Check pager services are active in your area
|
||||
4. Ensure antenna is connected
|
||||
|
||||
### Cannot install dump1090 in Debian (ADS-B mode)
|
||||
|
||||
On newer Debian versions, dump1090 may not be in repositories. The recommended action is to build from source or use the setup.sh script which will do it for you.
|
||||
|
||||
### No aircraft appearing (ADS-B mode)
|
||||
|
||||
1. Verify dump1090 or readsb is installed
|
||||
1. Verify dump1090 is installed
|
||||
2. Check antenna is connected (1090 MHz antenna recommended)
|
||||
3. Ensure clear view of sky
|
||||
4. Set correct observer location for range calculations
|
||||
4. Set correct observer location for range calculations or use gpsd
|
||||
|
||||
### Satellite passes not calculating
|
||||
|
||||
1. Ensure skyfield is installed: `pip install skyfield`
|
||||
1. Ensure skyfield is installed: `apt install python3-skyfield`
|
||||
2. Check TLE data is valid and recent
|
||||
3. Verify observer location is set correctly
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ INTERCEPT can be configured via environment variables:
|
||||
| `INTERCEPT_LOG_LEVEL` | `WARNING` | Log level (DEBUG, INFO, WARNING, ERROR) |
|
||||
| `INTERCEPT_DEFAULT_GAIN` | `40` | Default RTL-SDR gain |
|
||||
|
||||
Example: `INTERCEPT_PORT=8080 sudo python3 intercept.py`
|
||||
Example: `INTERCEPT_PORT=8080 sudo -E venv/bin/python intercept.py`
|
||||
|
||||
## Command-line Options
|
||||
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" fill="#000"/>
|
||||
<path d="M50 5 L90 27.5 L90 72.5 L50 95 L10 72.5 L10 27.5 Z" stroke="#00d4ff" stroke-width="3" fill="none"/>
|
||||
<path d="M30 50 Q40 35, 50 50 Q60 65, 70 50" stroke="#00d4ff" stroke-width="4" fill="none" stroke-linecap="round"/>
|
||||
<path d="M35 50 Q42 40, 50 50 Q58 60, 65 50" stroke="#00ff88" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M40 50 Q45 45, 50 50 Q55 55, 60 50" stroke="#ffffff" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
<circle cx="50" cy="50" r="4" fill="#00d4ff"/>
|
||||
<!-- Background -->
|
||||
<rect width="100" height="100" fill="#0a0a0f"/>
|
||||
|
||||
<!-- Signal brackets - left side -->
|
||||
<path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="4" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="3.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
|
||||
<!-- Signal brackets - right side -->
|
||||
<path d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="4" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="3.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
|
||||
<!-- The 'i' letter -->
|
||||
<circle cx="50" cy="22" r="7" fill="#00ff88"/>
|
||||
<rect x="43" y="35" width="14" height="45" rx="2" fill="#00d4ff"/>
|
||||
<rect x="36" y="35" width="28" height="5" rx="1" fill="#00d4ff"/>
|
||||
<rect x="36" y="75" width="28" height="5" rx="1" fill="#00d4ff"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 639 B After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,898 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>iNTERCEPT Promo</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--cyan: #00d4ff;
|
||||
--green: #00ff88;
|
||||
--red: #ff3366;
|
||||
--purple: #a855f7;
|
||||
--orange: #ff9500;
|
||||
--bg: #0a0a0f;
|
||||
--bg-secondary: #12121a;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
/* Container maintains 9:16 aspect ratio and scales to fit */
|
||||
.video-frame {
|
||||
position: relative;
|
||||
width: min(100vw, calc(100vh * 9 / 16));
|
||||
height: min(100vh, calc(100vw * 16 / 9));
|
||||
max-width: 1080px;
|
||||
max-height: 1920px;
|
||||
background: var(--bg);
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
/* Scale font size based on container width */
|
||||
font-size: min(16px, calc(100vw * 16 / 1080));
|
||||
}
|
||||
|
||||
/* Animated background grid */
|
||||
.grid-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image:
|
||||
linear-gradient(rgba(0, 212, 255, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 212, 255, 0.03) 1px, transparent 1px);
|
||||
background-size: 30px 30px;
|
||||
animation: gridMove 20s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes gridMove {
|
||||
0% { transform: translate(0, 0); }
|
||||
100% { transform: translate(30px, 30px); }
|
||||
}
|
||||
|
||||
/* Scanning line effect */
|
||||
.scanline {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--cyan), transparent);
|
||||
animation: scan 3s linear infinite;
|
||||
opacity: 0.7;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
@keyframes scan {
|
||||
0% { top: 0; }
|
||||
100% { top: 100%; }
|
||||
}
|
||||
|
||||
/* Glowing orbs background */
|
||||
.orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(50px);
|
||||
opacity: 0.25;
|
||||
animation: orbFloat 8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.orb-1 {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
background: var(--cyan);
|
||||
top: 10%;
|
||||
left: -10%;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.orb-2 {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
background: var(--purple);
|
||||
bottom: 20%;
|
||||
right: -5%;
|
||||
animation-delay: 2s;
|
||||
}
|
||||
|
||||
.orb-3 {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
background: var(--green);
|
||||
bottom: 40%;
|
||||
left: 20%;
|
||||
animation-delay: 4s;
|
||||
}
|
||||
|
||||
@keyframes orbFloat {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
50% { transform: translate(30px, -30px) scale(1.1); }
|
||||
}
|
||||
|
||||
/* Main content container */
|
||||
.container {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* Scene management */
|
||||
.scene {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.8s ease, visibility 0.8s ease;
|
||||
}
|
||||
|
||||
.scene.active {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* Scene 1: Logo reveal */
|
||||
.logo-container {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo-svg {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
margin-bottom: 20px;
|
||||
filter: drop-shadow(0 0 40px rgba(0, 212, 255, 0.5));
|
||||
}
|
||||
|
||||
.logo-svg .signal-wave {
|
||||
opacity: 0;
|
||||
animation: signalReveal 0.5s ease forwards;
|
||||
}
|
||||
|
||||
.logo-svg .signal-wave-1 { animation-delay: 0.5s; }
|
||||
.logo-svg .signal-wave-2 { animation-delay: 0.7s; }
|
||||
.logo-svg .signal-wave-3 { animation-delay: 0.9s; }
|
||||
.logo-svg .signal-wave-4 { animation-delay: 0.5s; }
|
||||
.logo-svg .signal-wave-5 { animation-delay: 0.7s; }
|
||||
.logo-svg .signal-wave-6 { animation-delay: 0.9s; }
|
||||
|
||||
@keyframes signalReveal {
|
||||
0% { opacity: 0; transform: scale(0.8); }
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.logo-svg .logo-i {
|
||||
opacity: 0;
|
||||
animation: logoReveal 0.8s ease forwards 0.2s;
|
||||
}
|
||||
|
||||
@keyframes logoReveal {
|
||||
0% { opacity: 0; transform: translateY(20px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.logo-svg .logo-dot {
|
||||
animation: dotPulse 1.5s ease-in-out infinite 1s;
|
||||
}
|
||||
|
||||
@keyframes dotPulse {
|
||||
0%, 100% { filter: drop-shadow(0 0 5px rgba(0, 255, 136, 0.5)); }
|
||||
50% { filter: drop-shadow(0 0 25px rgba(0, 255, 136, 1)); }
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 42px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.15em;
|
||||
margin-bottom: 10px;
|
||||
opacity: 0;
|
||||
animation: titleReveal 1s ease forwards 1.2s;
|
||||
}
|
||||
|
||||
@keyframes titleReveal {
|
||||
0% { opacity: 0; transform: translateY(20px); letter-spacing: 0.3em; }
|
||||
100% { opacity: 1; transform: translateY(0); letter-spacing: 0.15em; }
|
||||
}
|
||||
|
||||
.tagline {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 18px;
|
||||
color: var(--cyan);
|
||||
letter-spacing: 0.1em;
|
||||
opacity: 0;
|
||||
animation: taglineReveal 0.8s ease forwards 1.8s;
|
||||
}
|
||||
|
||||
@keyframes taglineReveal {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin-top: 15px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
opacity: 0;
|
||||
animation: subtitleReveal 0.8s ease forwards 2.2s;
|
||||
}
|
||||
|
||||
@keyframes subtitleReveal {
|
||||
0% { opacity: 0; transform: translateY(20px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Scene 2: Features */
|
||||
.features-scene {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 24px;
|
||||
color: var(--cyan);
|
||||
margin-bottom: 30px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.feature-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 15px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(0, 212, 255, 0.2);
|
||||
border-radius: 12px;
|
||||
padding: 15px;
|
||||
text-align: center;
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
animation: featureReveal 0.6s ease forwards;
|
||||
}
|
||||
|
||||
.feature-card:nth-child(1) { animation-delay: 0.2s; }
|
||||
.feature-card:nth-child(2) { animation-delay: 0.4s; }
|
||||
.feature-card:nth-child(3) { animation-delay: 0.6s; }
|
||||
.feature-card:nth-child(4) { animation-delay: 0.8s; }
|
||||
|
||||
@keyframes featureReveal {
|
||||
0% { opacity: 0; transform: translateY(20px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
font-size: 36px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.feature-name {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.feature-desc {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
/* Scene 3: Modes showcase */
|
||||
.modes-scene {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mode-showcase {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mode-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-left: 3px solid var(--cyan);
|
||||
padding: 10px 15px;
|
||||
border-radius: 0 8px 8px 0;
|
||||
opacity: 0;
|
||||
transform: translateX(-30px);
|
||||
animation: modeSlide 0.5s ease forwards;
|
||||
}
|
||||
|
||||
.mode-item:nth-child(1) { animation-delay: 0.1s; border-color: var(--cyan); }
|
||||
.mode-item:nth-child(2) { animation-delay: 0.2s; border-color: var(--green); }
|
||||
.mode-item:nth-child(3) { animation-delay: 0.3s; border-color: var(--purple); }
|
||||
.mode-item:nth-child(4) { animation-delay: 0.4s; border-color: var(--orange); }
|
||||
.mode-item:nth-child(5) { animation-delay: 0.5s; border-color: var(--red); }
|
||||
.mode-item:nth-child(6) { animation-delay: 0.6s; border-color: #00ffcc; }
|
||||
.mode-item:nth-child(7) { animation-delay: 0.7s; border-color: #ff66cc; }
|
||||
|
||||
@keyframes modeSlide {
|
||||
0% { opacity: 0; transform: translateX(-30px); }
|
||||
100% { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
.mode-icon {
|
||||
font-size: 22px;
|
||||
width: 35px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mode-info {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.mode-name {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.mode-desc {
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Scene 4: UI Preview */
|
||||
.ui-scene {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ui-preview {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 0 60px rgba(0, 212, 255, 0.2);
|
||||
}
|
||||
|
||||
.ui-header {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
padding: 10px 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid rgba(0, 212, 255, 0.2);
|
||||
}
|
||||
|
||||
.ui-logo-small {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.ui-title {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ui-body {
|
||||
padding: 12px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ui-card {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.ui-card-header {
|
||||
font-size: 8px;
|
||||
color: var(--cyan);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.ui-stat {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.ui-stat.cyan { color: var(--cyan); }
|
||||
.ui-stat.orange { color: var(--orange); }
|
||||
|
||||
.ui-console {
|
||||
grid-column: span 3;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 9px;
|
||||
text-align: left;
|
||||
border: 1px solid rgba(0, 212, 255, 0.1);
|
||||
}
|
||||
|
||||
.console-line {
|
||||
margin-bottom: 4px;
|
||||
opacity: 0;
|
||||
animation: consoleLine 0.3s ease forwards;
|
||||
}
|
||||
|
||||
.console-line:nth-child(1) { animation-delay: 0.5s; }
|
||||
.console-line:nth-child(2) { animation-delay: 0.8s; }
|
||||
.console-line:nth-child(3) { animation-delay: 1.1s; }
|
||||
.console-line:nth-child(4) { animation-delay: 1.4s; }
|
||||
.console-line:nth-child(5) { animation-delay: 1.7s; }
|
||||
|
||||
@keyframes consoleLine {
|
||||
0% { opacity: 0; transform: translateX(-10px); }
|
||||
100% { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
.console-time { color: #666; }
|
||||
.console-type { color: var(--cyan); }
|
||||
.console-msg { color: var(--green); }
|
||||
.console-freq { color: var(--orange); }
|
||||
|
||||
/* Scene 5: CTA */
|
||||
.cta-scene {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cta-logo {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
margin-bottom: 20px;
|
||||
animation: ctaLogoPulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes ctaLogoPulse {
|
||||
0%, 100% { filter: drop-shadow(0 0 20px rgba(0, 212, 255, 0.5)); transform: scale(1); }
|
||||
50% { filter: drop-shadow(0 0 40px rgba(0, 212, 255, 0.8)); transform: scale(1.05); }
|
||||
}
|
||||
|
||||
.cta-title {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.cta-tagline {
|
||||
font-size: 18px;
|
||||
color: var(--cyan);
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.cta-btn {
|
||||
display: inline-block;
|
||||
padding: 12px 30px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
background: var(--cyan);
|
||||
border-radius: 30px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
animation: ctaBtnPulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes ctaBtnPulse {
|
||||
0%, 100% { box-shadow: 0 0 20px rgba(0, 212, 255, 0.5); }
|
||||
50% { box-shadow: 0 0 40px rgba(0, 212, 255, 0.8); }
|
||||
}
|
||||
|
||||
.cta-url {
|
||||
margin-top: 20px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Typing cursor effect */
|
||||
.typing-cursor {
|
||||
display: inline-block;
|
||||
width: 3px;
|
||||
height: 1em;
|
||||
background: var(--cyan);
|
||||
margin-left: 5px;
|
||||
animation: blink 0.8s infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* Progress bar */
|
||||
.progress-bar {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.progress-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-dot.active {
|
||||
background: var(--cyan);
|
||||
box-shadow: 0 0 10px var(--cyan);
|
||||
}
|
||||
|
||||
/* Decorative elements */
|
||||
.corner-decoration {
|
||||
position: absolute;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||
}
|
||||
|
||||
.corner-tl {
|
||||
top: 15px;
|
||||
left: 15px;
|
||||
border-right: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.corner-tr {
|
||||
top: 15px;
|
||||
right: 15px;
|
||||
border-left: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.corner-bl {
|
||||
bottom: 50px;
|
||||
left: 15px;
|
||||
border-right: none;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.corner-br {
|
||||
bottom: 50px;
|
||||
right: 15px;
|
||||
border-left: none;
|
||||
border-top: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="video-frame">
|
||||
<!-- Background elements -->
|
||||
<div class="grid-bg"></div>
|
||||
<div class="scanline"></div>
|
||||
<div class="orb orb-1"></div>
|
||||
<div class="orb orb-2"></div>
|
||||
<div class="orb orb-3"></div>
|
||||
|
||||
<!-- Corner decorations -->
|
||||
<div class="corner-decoration corner-tl"></div>
|
||||
<div class="corner-decoration corner-tr"></div>
|
||||
<div class="corner-decoration corner-bl"></div>
|
||||
<div class="corner-decoration corner-br"></div>
|
||||
|
||||
<!-- Scene 1: Logo Reveal -->
|
||||
<div class="scene active" id="scene1">
|
||||
<div class="logo-container">
|
||||
<svg class="logo-svg" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Signal brackets - left side -->
|
||||
<path class="signal-wave signal-wave-1" d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path class="signal-wave signal-wave-2" d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path class="signal-wave signal-wave-3" d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
<!-- Signal brackets - right side -->
|
||||
<path class="signal-wave signal-wave-4" d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path class="signal-wave signal-wave-5" d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path class="signal-wave signal-wave-6" d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
<!-- The 'i' letter -->
|
||||
<g class="logo-i">
|
||||
<circle class="logo-dot" cx="50" cy="22" r="6" fill="#00ff88"/>
|
||||
<rect x="44" y="35" width="12" height="45" rx="2" fill="#00d4ff"/>
|
||||
<rect x="38" y="35" width="24" height="4" rx="1" fill="#00d4ff"/>
|
||||
<rect x="38" y="76" width="24" height="4" rx="1" fill="#00d4ff"/>
|
||||
</g>
|
||||
</svg>
|
||||
<h1 class="title">iNTERCEPT</h1>
|
||||
<p class="tagline">// See the Invisible</p>
|
||||
<p class="subtitle">Signal Intelligence & Counter Surveillance</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scene 2: Features Grid -->
|
||||
<div class="scene" id="scene2">
|
||||
<div class="features-scene">
|
||||
<h2 class="feature-title">Capabilities</h2>
|
||||
<div class="feature-grid">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📡</div>
|
||||
<div class="feature-name">SDR Scanning</div>
|
||||
<div class="feature-desc">Multi-band reception</div>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🔐</div>
|
||||
<div class="feature-name">Decryption</div>
|
||||
<div class="feature-desc">Signal analysis</div>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🛰️</div>
|
||||
<div class="feature-name">Tracking</div>
|
||||
<div class="feature-desc">Real-time monitoring</div>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🔍</div>
|
||||
<div class="feature-name">Detection</div>
|
||||
<div class="feature-desc">Counter surveillance</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scene 3: Modes List -->
|
||||
<div class="scene" id="scene3">
|
||||
<div class="modes-scene">
|
||||
<div class="mode-showcase">
|
||||
<div class="mode-item">
|
||||
<div class="mode-icon">📟</div>
|
||||
<div class="mode-info">
|
||||
<div class="mode-name">PAGER</div>
|
||||
<div class="mode-desc">POCSAG & FLEX decoding</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mode-item">
|
||||
<div class="mode-icon">✈️</div>
|
||||
<div class="mode-info">
|
||||
<div class="mode-name">ADS-B</div>
|
||||
<div class="mode-desc">Aircraft tracking</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mode-item">
|
||||
<div class="mode-icon">📻</div>
|
||||
<div class="mode-info">
|
||||
<div class="mode-name">LISTENING POST</div>
|
||||
<div class="mode-desc">RF monitoring & scanning</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mode-item">
|
||||
<div class="mode-icon">📶</div>
|
||||
<div class="mode-info">
|
||||
<div class="mode-name">WiFi</div>
|
||||
<div class="mode-desc">Network reconnaissance</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mode-item">
|
||||
<div class="mode-icon">🔵</div>
|
||||
<div class="mode-info">
|
||||
<div class="mode-name">BLUETOOTH</div>
|
||||
<div class="mode-desc">Device & tracker detection</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mode-item">
|
||||
<div class="mode-icon">🌡️</div>
|
||||
<div class="mode-info">
|
||||
<div class="mode-name">SENSORS</div>
|
||||
<div class="mode-desc">433MHz IoT decoding</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mode-item">
|
||||
<div class="mode-icon">🛰️</div>
|
||||
<div class="mode-info">
|
||||
<div class="mode-name">SATELLITE</div>
|
||||
<div class="mode-desc">Pass prediction & tracking</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scene 4: UI Preview -->
|
||||
<div class="scene" id="scene4">
|
||||
<div class="ui-scene">
|
||||
<div class="ui-preview">
|
||||
<div class="ui-header">
|
||||
<svg class="ui-logo-small" viewBox="0 0 100 100" fill="none">
|
||||
<path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
<path d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
<circle cx="50" cy="22" r="6" fill="#00ff88"/>
|
||||
<rect x="44" y="35" width="12" height="45" rx="2" fill="#00d4ff"/>
|
||||
<rect x="38" y="35" width="24" height="4" rx="1" fill="#00d4ff"/>
|
||||
<rect x="38" y="76" width="24" height="4" rx="1" fill="#00d4ff"/>
|
||||
</svg>
|
||||
<span class="ui-title">iNTERCEPT</span>
|
||||
</div>
|
||||
<div class="ui-body">
|
||||
<div class="ui-card">
|
||||
<div class="ui-card-header">Messages</div>
|
||||
<div class="ui-stat">2,847</div>
|
||||
</div>
|
||||
<div class="ui-card">
|
||||
<div class="ui-card-header">Aircraft</div>
|
||||
<div class="ui-stat cyan">42</div>
|
||||
</div>
|
||||
<div class="ui-card">
|
||||
<div class="ui-card-header">Devices</div>
|
||||
<div class="ui-stat orange">156</div>
|
||||
</div>
|
||||
<div class="ui-console">
|
||||
<div class="console-line">
|
||||
<span class="console-time">[14:32:07]</span>
|
||||
<span class="console-type"> POCSAG </span>
|
||||
<span class="console-msg">Signal intercepted</span>
|
||||
<span class="console-freq"> 153.350 MHz</span>
|
||||
</div>
|
||||
<div class="console-line">
|
||||
<span class="console-time">[14:32:09]</span>
|
||||
<span class="console-type"> ADS-B </span>
|
||||
<span class="console-msg">Aircraft detected: BA284</span>
|
||||
<span class="console-freq"> FL350</span>
|
||||
</div>
|
||||
<div class="console-line">
|
||||
<span class="console-time">[14:32:11]</span>
|
||||
<span class="console-type"> BT </span>
|
||||
<span class="console-msg">AirTag detected nearby</span>
|
||||
<span class="console-freq"> -42 dBm</span>
|
||||
</div>
|
||||
<div class="console-line">
|
||||
<span class="console-time">[14:32:14]</span>
|
||||
<span class="console-type"> SENSOR </span>
|
||||
<span class="console-msg">Temperature: 22.4C</span>
|
||||
<span class="console-freq"> 433.92 MHz</span>
|
||||
</div>
|
||||
<div class="console-line">
|
||||
<span class="console-time">[14:32:16]</span>
|
||||
<span class="console-type"> SCAN </span>
|
||||
<span class="console-msg">Signal found</span>
|
||||
<span class="console-freq"> 145.500 MHz</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scene 5: CTA -->
|
||||
<div class="scene" id="scene5">
|
||||
<div class="cta-scene">
|
||||
<svg class="cta-logo" viewBox="0 0 100 100" fill="none">
|
||||
<path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
<path d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
<circle cx="50" cy="22" r="6" fill="#00ff88"/>
|
||||
<rect x="44" y="35" width="12" height="45" rx="2" fill="#00d4ff"/>
|
||||
<rect x="38" y="35" width="24" height="4" rx="1" fill="#00d4ff"/>
|
||||
<rect x="38" y="76" width="24" height="4" rx="1" fill="#00d4ff"/>
|
||||
</svg>
|
||||
<h2 class="cta-title">iNTERCEPT</h2>
|
||||
<p class="cta-tagline">See the Invisible</p>
|
||||
<div class="cta-btn">Open Source</div>
|
||||
<p class="cta-url">github.com/yourrepo/intercept</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress dots -->
|
||||
<div class="progress-bar">
|
||||
<div class="progress-dot active" data-scene="1"></div>
|
||||
<div class="progress-dot" data-scene="2"></div>
|
||||
<div class="progress-dot" data-scene="3"></div>
|
||||
<div class="progress-dot" data-scene="4"></div>
|
||||
<div class="progress-dot" data-scene="5"></div>
|
||||
</div>
|
||||
</div><!-- end video-frame -->
|
||||
|
||||
<script>
|
||||
// Scene timing (in milliseconds)
|
||||
const sceneTiming = [
|
||||
{ scene: 1, duration: 4000 }, // Logo reveal
|
||||
{ scene: 2, duration: 4000 }, // Features
|
||||
{ scene: 3, duration: 5000 }, // Modes
|
||||
{ scene: 4, duration: 5000 }, // UI Preview
|
||||
{ scene: 5, duration: 4000 }, // CTA
|
||||
];
|
||||
|
||||
let currentScene = 0;
|
||||
|
||||
function showScene(index) {
|
||||
// Hide all scenes
|
||||
document.querySelectorAll('.scene').forEach(s => s.classList.remove('active'));
|
||||
document.querySelectorAll('.progress-dot').forEach(d => d.classList.remove('active'));
|
||||
|
||||
// Show current scene
|
||||
const scene = document.getElementById(`scene${index + 1}`);
|
||||
if (scene) {
|
||||
scene.classList.add('active');
|
||||
document.querySelector(`.progress-dot[data-scene="${index + 1}"]`).classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
function nextScene() {
|
||||
currentScene++;
|
||||
if (currentScene >= sceneTiming.length) {
|
||||
currentScene = 0; // Loop back to start
|
||||
}
|
||||
showScene(currentScene);
|
||||
setTimeout(nextScene, sceneTiming[currentScene].duration);
|
||||
}
|
||||
|
||||
// Start the animation sequence
|
||||
setTimeout(nextScene, sceneTiming[0].duration);
|
||||
|
||||
// Keyboard controls for manual navigation
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'ArrowRight') {
|
||||
currentScene = (currentScene + 1) % sceneTiming.length;
|
||||
showScene(currentScene);
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
currentScene = (currentScene - 1 + sceneTiming.length) % sceneTiming.length;
|
||||
showScene(currentScene);
|
||||
} else if (e.key === ' ') {
|
||||
// Spacebar to pause/resume could be added here
|
||||
}
|
||||
});
|
||||
|
||||
// Click on progress dots to jump to scene
|
||||
document.querySelectorAll('.progress-dot').forEach(dot => {
|
||||
dot.addEventListener('click', () => {
|
||||
currentScene = parseInt(dot.dataset.scene) - 1;
|
||||
showScene(currentScene);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "intercept"
|
||||
version = "1.0.0"
|
||||
version = "2.9.5"
|
||||
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
@@ -28,12 +28,19 @@ classifiers = [
|
||||
dependencies = [
|
||||
"flask>=2.0.0",
|
||||
"skyfield>=1.45",
|
||||
"pyserial>=3.5",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/smittix/intercept"
|
||||
Repository = "https://github.com/smittix/intercept"
|
||||
Issues = "https://github.com/smittix/intercept/issues"
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=7.0.0",
|
||||
"pytest-cov>=4.0.0",
|
||||
"pytest-mock>=3.15.1",
|
||||
"ruff>=0.1.0",
|
||||
"black>=23.0.0",
|
||||
"mypy>=1.0.0",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
# Testing
|
||||
pytest>=7.0.0
|
||||
pytest-cov>=4.0.0
|
||||
pytest-mock>=3.15.1
|
||||
|
||||
# Code quality
|
||||
ruff>=0.1.0
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Core dependencies
|
||||
flask>=2.0.0
|
||||
requests>=2.28.0
|
||||
|
||||
# BLE scanning with manufacturer data detection (optional - for TSCM)
|
||||
bleak>=0.21.0
|
||||
|
||||
# Satellite tracking (optional - only needed for satellite features)
|
||||
skyfield>=1.45
|
||||
@@ -13,3 +17,4 @@ pyserial>=3.5
|
||||
# ruff>=0.1.0
|
||||
# black>=23.0.0
|
||||
# mypy>=1.0.0
|
||||
flask-sock
|
||||
|
||||
@@ -7,13 +7,30 @@ def register_blueprints(app):
|
||||
from .wifi import wifi_bp
|
||||
from .bluetooth import bluetooth_bp
|
||||
from .adsb import adsb_bp
|
||||
from .acars import acars_bp
|
||||
from .aprs import aprs_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
|
||||
from .tscm import tscm_bp, init_tscm_state
|
||||
|
||||
app.register_blueprint(pager_bp)
|
||||
app.register_blueprint(sensor_bp)
|
||||
app.register_blueprint(wifi_bp)
|
||||
app.register_blueprint(bluetooth_bp)
|
||||
app.register_blueprint(adsb_bp)
|
||||
app.register_blueprint(acars_bp)
|
||||
app.register_blueprint(aprs_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)
|
||||
app.register_blueprint(tscm_bp)
|
||||
|
||||
# Initialize TSCM state with queue and lock from app
|
||||
import app as app_module
|
||||
if hasattr(app_module, 'tscm_queue') and hasattr(app_module, 'tscm_lock'):
|
||||
init_tscm_state(app_module.tscm_queue, app_module.tscm_lock)
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
"""ACARS aircraft messaging routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import pty
|
||||
import queue
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
import app as app_module
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
||||
from utils.sse import format_sse
|
||||
from utils.constants import (
|
||||
PROCESS_TERMINATE_TIMEOUT,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
PROCESS_START_WAIT,
|
||||
)
|
||||
|
||||
acars_bp = Blueprint('acars', __name__, url_prefix='/acars')
|
||||
|
||||
# Default VHF ACARS frequencies (MHz) - common worldwide
|
||||
DEFAULT_ACARS_FREQUENCIES = [
|
||||
'131.550', # Primary worldwide
|
||||
'130.025', # Secondary USA/Canada
|
||||
'129.125', # USA
|
||||
'131.525', # Europe
|
||||
'131.725', # Europe secondary
|
||||
]
|
||||
|
||||
# Message counter for statistics
|
||||
acars_message_count = 0
|
||||
acars_last_message_time = None
|
||||
|
||||
|
||||
def find_acarsdec():
|
||||
"""Find acarsdec binary."""
|
||||
return shutil.which('acarsdec')
|
||||
|
||||
|
||||
def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -> None:
|
||||
"""Stream acarsdec JSON output to queue."""
|
||||
global acars_message_count, acars_last_message_time
|
||||
|
||||
try:
|
||||
app_module.acars_queue.put({'type': 'status', 'status': 'started'})
|
||||
|
||||
# Use appropriate sentinel based on mode (text mode for pty on macOS)
|
||||
sentinel = '' if is_text_mode else b''
|
||||
for line in iter(process.stdout.readline, sentinel):
|
||||
if is_text_mode:
|
||||
line = line.strip()
|
||||
else:
|
||||
line = line.decode('utf-8', errors='replace').strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
# acarsdec -o 4 outputs JSON, one message per line
|
||||
data = json.loads(line)
|
||||
|
||||
# Add our metadata
|
||||
data['type'] = 'acars'
|
||||
data['timestamp'] = datetime.utcnow().isoformat() + 'Z'
|
||||
|
||||
# Update stats
|
||||
acars_message_count += 1
|
||||
acars_last_message_time = time.time()
|
||||
|
||||
app_module.acars_queue.put(data)
|
||||
|
||||
# Log if enabled
|
||||
if app_module.logging_enabled:
|
||||
try:
|
||||
with open(app_module.log_file_path, 'a') as f:
|
||||
ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
f.write(f"{ts} | ACARS | {json.dumps(data)}\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except json.JSONDecodeError:
|
||||
# Not JSON - could be status message
|
||||
if line:
|
||||
logger.debug(f"acarsdec non-JSON: {line[:100]}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"ACARS stream error: {e}")
|
||||
app_module.acars_queue.put({'type': 'error', 'message': str(e)})
|
||||
finally:
|
||||
app_module.acars_queue.put({'type': 'status', 'status': 'stopped'})
|
||||
with app_module.acars_lock:
|
||||
app_module.acars_process = None
|
||||
|
||||
|
||||
@acars_bp.route('/tools')
|
||||
def check_acars_tools() -> Response:
|
||||
"""Check for ACARS decoding tools."""
|
||||
has_acarsdec = find_acarsdec() is not None
|
||||
|
||||
return jsonify({
|
||||
'acarsdec': has_acarsdec,
|
||||
'ready': has_acarsdec
|
||||
})
|
||||
|
||||
|
||||
@acars_bp.route('/status')
|
||||
def acars_status() -> Response:
|
||||
"""Get ACARS decoder status."""
|
||||
running = False
|
||||
if app_module.acars_process:
|
||||
running = app_module.acars_process.poll() is None
|
||||
|
||||
return jsonify({
|
||||
'running': running,
|
||||
'message_count': acars_message_count,
|
||||
'last_message_time': acars_last_message_time,
|
||||
'queue_size': app_module.acars_queue.qsize()
|
||||
})
|
||||
|
||||
|
||||
@acars_bp.route('/start', methods=['POST'])
|
||||
def start_acars() -> Response:
|
||||
"""Start ACARS decoder."""
|
||||
global acars_message_count, acars_last_message_time
|
||||
|
||||
with app_module.acars_lock:
|
||||
if app_module.acars_process and app_module.acars_process.poll() is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'ACARS decoder already running'
|
||||
}), 409
|
||||
|
||||
# Check for acarsdec
|
||||
acarsdec_path = find_acarsdec()
|
||||
if not acarsdec_path:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'acarsdec not found. Install with: sudo apt install acarsdec'
|
||||
}), 400
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
# Validate inputs
|
||||
try:
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
gain = validate_gain(data.get('gain', '40'))
|
||||
ppm = validate_ppm(data.get('ppm', '0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
# Get frequencies - use provided or defaults
|
||||
frequencies = data.get('frequencies', DEFAULT_ACARS_FREQUENCIES)
|
||||
if isinstance(frequencies, str):
|
||||
frequencies = [f.strip() for f in frequencies.split(',')]
|
||||
|
||||
# Clear queue
|
||||
while not app_module.acars_queue.empty():
|
||||
try:
|
||||
app_module.acars_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Reset stats
|
||||
acars_message_count = 0
|
||||
acars_last_message_time = None
|
||||
|
||||
# Build acarsdec command
|
||||
# acarsdec -o 4 -g <gain> -p <ppm> -r <device> <freq1> <freq2> ...
|
||||
# Note: -o 4 is JSON stdout, gain/ppm must come BEFORE -r
|
||||
cmd = [
|
||||
acarsdec_path,
|
||||
'-o', '4', # JSON output to stdout
|
||||
]
|
||||
|
||||
# Add gain if not auto (must be before -r)
|
||||
if gain and str(gain) != '0':
|
||||
cmd.extend(['-g', str(gain)])
|
||||
|
||||
# Add PPM correction if specified (must be before -r)
|
||||
if ppm and str(ppm) != '0':
|
||||
cmd.extend(['-p', str(ppm)])
|
||||
|
||||
# Add device and frequencies (-r takes device, remaining args are frequencies)
|
||||
cmd.extend(['-r', str(device)])
|
||||
cmd.extend(frequencies)
|
||||
|
||||
logger.info(f"Starting ACARS decoder: {' '.join(cmd)}")
|
||||
|
||||
try:
|
||||
is_text_mode = False
|
||||
|
||||
# On macOS, use pty to avoid stdout buffering issues
|
||||
if platform.system() == 'Darwin':
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=slave_fd,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True
|
||||
)
|
||||
os.close(slave_fd)
|
||||
# Wrap master_fd as a text file for line-buffered reading
|
||||
process.stdout = io.open(master_fd, 'r', buffering=1)
|
||||
is_text_mode = True
|
||||
else:
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True
|
||||
)
|
||||
|
||||
# Wait briefly to check if process started
|
||||
time.sleep(PROCESS_START_WAIT)
|
||||
|
||||
if process.poll() is not None:
|
||||
# Process died
|
||||
stderr = ''
|
||||
if process.stderr:
|
||||
stderr = process.stderr.read().decode('utf-8', errors='replace')
|
||||
error_msg = f'acarsdec failed to start'
|
||||
if stderr:
|
||||
error_msg += f': {stderr[:200]}'
|
||||
logger.error(error_msg)
|
||||
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||
|
||||
app_module.acars_process = process
|
||||
|
||||
# Start output streaming thread
|
||||
thread = threading.Thread(
|
||||
target=stream_acars_output,
|
||||
args=(process, is_text_mode),
|
||||
daemon=True
|
||||
)
|
||||
thread.start()
|
||||
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'frequencies': frequencies,
|
||||
'device': device,
|
||||
'gain': gain
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start ACARS decoder: {e}")
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
|
||||
@acars_bp.route('/stop', methods=['POST'])
|
||||
def stop_acars() -> Response:
|
||||
"""Stop ACARS decoder."""
|
||||
with app_module.acars_lock:
|
||||
if not app_module.acars_process:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'ACARS decoder not running'
|
||||
}), 400
|
||||
|
||||
try:
|
||||
app_module.acars_process.terminate()
|
||||
app_module.acars_process.wait(timeout=PROCESS_TERMINATE_TIMEOUT)
|
||||
except subprocess.TimeoutExpired:
|
||||
app_module.acars_process.kill()
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping ACARS: {e}")
|
||||
|
||||
app_module.acars_process = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@acars_bp.route('/stream')
|
||||
def stream_acars() -> Response:
|
||||
"""SSE stream for ACARS messages."""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.acars_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
|
||||
|
||||
|
||||
@acars_bp.route('/frequencies')
|
||||
def get_frequencies() -> Response:
|
||||
"""Get default ACARS frequencies."""
|
||||
return jsonify({
|
||||
'default': DEFAULT_ACARS_FREQUENCIES,
|
||||
'regions': {
|
||||
'north_america': ['129.125', '130.025', '130.450', '131.550'],
|
||||
'europe': ['131.525', '131.725', '131.550'],
|
||||
'asia_pacific': ['131.550', '131.450'],
|
||||
}
|
||||
})
|
||||
@@ -22,6 +22,20 @@ 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,
|
||||
)
|
||||
from utils import aircraft_db
|
||||
|
||||
adsb_bp = Blueprint('adsb', __name__, url_prefix='/adsb')
|
||||
|
||||
@@ -30,6 +44,14 @@ adsb_using_service = False
|
||||
adsb_connected = False
|
||||
adsb_messages_received = 0
|
||||
adsb_last_message_time = None
|
||||
adsb_bytes_received = 0
|
||||
adsb_lines_received = 0
|
||||
|
||||
# Track ICAOs already looked up in aircraft database (avoid repeated lookups)
|
||||
_looked_up_icaos: set[str] = set()
|
||||
|
||||
# Load aircraft database at module init
|
||||
aircraft_db.load_database()
|
||||
|
||||
# Common installation paths for dump1090 (when not in PATH)
|
||||
DUMP1090_PATHS = [
|
||||
@@ -63,22 +85,22 @@ 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."""
|
||||
global adsb_using_service, adsb_connected, adsb_messages_received, adsb_last_message_time
|
||||
"""Parse SBS format data from dump1090 SBS port."""
|
||||
global adsb_using_service, adsb_connected, adsb_messages_received, adsb_last_message_time, adsb_bytes_received, adsb_lines_received
|
||||
|
||||
host, port = service_addr.split(':')
|
||||
port = int(port)
|
||||
@@ -90,7 +112,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")
|
||||
@@ -98,12 +120,16 @@ def parse_sbs_stream(service_addr):
|
||||
buffer = ""
|
||||
last_update = time.time()
|
||||
pending_updates = set()
|
||||
adsb_bytes_received = 0
|
||||
adsb_lines_received = 0
|
||||
|
||||
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:
|
||||
logger.warning("SBS connection closed (no data)")
|
||||
break
|
||||
adsb_bytes_received += len(data)
|
||||
buffer += data
|
||||
|
||||
while '\n' in buffer:
|
||||
@@ -112,8 +138,15 @@ def parse_sbs_stream(service_addr):
|
||||
if not line:
|
||||
continue
|
||||
|
||||
adsb_lines_received += 1
|
||||
# Log first few lines for debugging
|
||||
if adsb_lines_received <= 3:
|
||||
logger.info(f"SBS line {adsb_lines_received}: {line[:100]}")
|
||||
|
||||
parts = line.split(',')
|
||||
if len(parts) < 11 or parts[0] != 'MSG':
|
||||
if adsb_lines_received <= 5:
|
||||
logger.debug(f"Skipping non-MSG line: {line[:50]}")
|
||||
continue
|
||||
|
||||
msg_type = parts[1]
|
||||
@@ -121,7 +154,19 @@ 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}
|
||||
|
||||
# Look up aircraft type from database (once per ICAO)
|
||||
if icao not in _looked_up_icaos:
|
||||
_looked_up_icaos.add(icao)
|
||||
db_info = aircraft_db.lookup(icao)
|
||||
if db_info:
|
||||
if db_info['registration']:
|
||||
aircraft['registration'] = db_info['registration']
|
||||
if db_info['type_code']:
|
||||
aircraft['type_code'] = db_info['type_code']
|
||||
if db_info['type_desc']:
|
||||
aircraft['type_desc'] = db_info['type_desc']
|
||||
|
||||
if msg_type == '1' and len(parts) > 10:
|
||||
callsign = parts[10].strip()
|
||||
@@ -141,7 +186,7 @@ def parse_sbs_stream(service_addr):
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
elif msg_type == '4' and len(parts) > 13:
|
||||
elif msg_type == '4' and len(parts) > 16:
|
||||
if parts[12]:
|
||||
try:
|
||||
aircraft['speed'] = int(float(parts[12]))
|
||||
@@ -152,6 +197,11 @@ def parse_sbs_stream(service_addr):
|
||||
aircraft['heading'] = int(float(parts[13]))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if parts[16]:
|
||||
try:
|
||||
aircraft['vertical_rate'] = int(float(parts[16]))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
elif msg_type == '5' and len(parts) > 11:
|
||||
if parts[10]:
|
||||
@@ -168,13 +218,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 +239,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")
|
||||
@@ -200,25 +250,52 @@ def parse_sbs_stream(service_addr):
|
||||
|
||||
@adsb_bp.route('/tools')
|
||||
def check_adsb_tools():
|
||||
"""Check for ADS-B decoding tools."""
|
||||
"""Check for ADS-B decoding tools and hardware."""
|
||||
# Check available decoders
|
||||
has_dump1090 = find_dump1090() is not None
|
||||
has_readsb = shutil.which('readsb') is not None
|
||||
has_rtl_adsb = shutil.which('rtl_adsb') is not None
|
||||
|
||||
# Check what SDR hardware is detected
|
||||
devices = SDRFactory.detect_devices()
|
||||
has_rtlsdr = any(d.sdr_type == SDRType.RTL_SDR for d in devices)
|
||||
has_soapy_sdr = any(d.sdr_type in (SDRType.HACKRF, SDRType.LIME_SDR, SDRType.AIRSPY) for d in devices)
|
||||
soapy_types = [d.sdr_type.value for d in devices if d.sdr_type in (SDRType.HACKRF, SDRType.LIME_SDR, SDRType.AIRSPY)]
|
||||
|
||||
# Determine if readsb is needed but missing
|
||||
needs_readsb = has_soapy_sdr and not has_readsb
|
||||
|
||||
return jsonify({
|
||||
'dump1090': find_dump1090() is not None,
|
||||
'rtl_adsb': shutil.which('rtl_adsb') is not None
|
||||
'dump1090': has_dump1090,
|
||||
'readsb': has_readsb,
|
||||
'rtl_adsb': has_rtl_adsb,
|
||||
'has_rtlsdr': has_rtlsdr,
|
||||
'has_soapy_sdr': has_soapy_sdr,
|
||||
'soapy_types': soapy_types,
|
||||
'needs_readsb': needs_readsb
|
||||
})
|
||||
|
||||
|
||||
@adsb_bp.route('/status')
|
||||
def adsb_status():
|
||||
"""Get ADS-B tracking status for debugging."""
|
||||
# Check if dump1090 process is still running
|
||||
dump1090_running = False
|
||||
if app_module.adsb_process:
|
||||
dump1090_running = app_module.adsb_process.poll() is None
|
||||
|
||||
return jsonify({
|
||||
'tracking_active': adsb_using_service,
|
||||
'connected_to_sbs': adsb_connected,
|
||||
'messages_received': adsb_messages_received,
|
||||
'bytes_received': adsb_bytes_received,
|
||||
'lines_received': adsb_lines_received,
|
||||
'last_message_time': adsb_last_message_time,
|
||||
'aircraft_count': len(app_module.adsb_aircraft),
|
||||
'aircraft': dict(app_module.adsb_aircraft), # Full aircraft data
|
||||
'queue_size': app_module.adsb_queue.qsize(),
|
||||
'dump1090_path': find_dump1090(),
|
||||
'dump1090_running': dump1090_running,
|
||||
'port_30003_open': check_dump1090_service() is not None
|
||||
})
|
||||
|
||||
@@ -291,9 +368,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
|
||||
@@ -301,9 +381,11 @@ def start_adsb():
|
||||
builder = SDRFactory.get_builder(sdr_type)
|
||||
|
||||
# Build ADS-B decoder command
|
||||
bias_t = data.get('bias_t', False)
|
||||
cmd = builder.build_adsb_command(
|
||||
device=sdr_device,
|
||||
gain=float(gain)
|
||||
gain=float(gain),
|
||||
bias_t=bias_t
|
||||
)
|
||||
|
||||
# For RTL-SDR, ensure we use the found dump1090 path
|
||||
@@ -314,16 +396,32 @@ def start_adsb():
|
||||
app_module.adsb_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
|
||||
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.'})
|
||||
# Process exited - try to get error message
|
||||
stderr_output = ''
|
||||
if app_module.adsb_process.stderr:
|
||||
try:
|
||||
stderr_output = app_module.adsb_process.stderr.read().decode('utf-8', errors='ignore').strip()
|
||||
except Exception:
|
||||
pass
|
||||
if sdr_type == SDRType.RTL_SDR:
|
||||
error_msg = 'dump1090 failed to start. Check RTL-SDR device permissions or if another process is using it.'
|
||||
if stderr_output:
|
||||
error_msg += f' Error: {stderr_output[:200]}'
|
||||
return jsonify({'status': 'error', 'message': error_msg})
|
||||
else:
|
||||
error_msg = f'ADS-B decoder failed to start for {sdr_type.value}. Ensure readsb is installed with SoapySDR support and the device is connected.'
|
||||
if stderr_output:
|
||||
error_msg += f' Error: {stderr_output[:200]}'
|
||||
return jsonify({'status': 'error', 'message': error_msg})
|
||||
|
||||
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 +438,14 @@ 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()
|
||||
_looked_up_icaos.clear()
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@@ -355,16 +454,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
|
||||
|
||||
@@ -378,3 +476,74 @@ def stream_adsb():
|
||||
def adsb_dashboard():
|
||||
"""Popout ADS-B dashboard."""
|
||||
return render_template('adsb_dashboard.html')
|
||||
|
||||
|
||||
# ============================================
|
||||
# AIRCRAFT DATABASE MANAGEMENT
|
||||
# ============================================
|
||||
|
||||
@adsb_bp.route('/aircraft-db/status')
|
||||
def aircraft_db_status():
|
||||
"""Get aircraft database status."""
|
||||
return jsonify(aircraft_db.get_db_status())
|
||||
|
||||
|
||||
@adsb_bp.route('/aircraft-db/check-updates')
|
||||
def aircraft_db_check_updates():
|
||||
"""Check for aircraft database updates."""
|
||||
result = aircraft_db.check_for_updates()
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@adsb_bp.route('/aircraft-db/download', methods=['POST'])
|
||||
def aircraft_db_download():
|
||||
"""Download/update aircraft database."""
|
||||
global _looked_up_icaos
|
||||
result = aircraft_db.download_database()
|
||||
if result.get('success'):
|
||||
# Clear lookup cache so new data is used
|
||||
_looked_up_icaos.clear()
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@adsb_bp.route('/aircraft-db/delete', methods=['POST'])
|
||||
def aircraft_db_delete():
|
||||
"""Delete aircraft database."""
|
||||
result = aircraft_db.delete_database()
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@adsb_bp.route('/aircraft-photo/<registration>')
|
||||
def aircraft_photo(registration: str):
|
||||
"""Fetch aircraft photo from Planespotters.net API."""
|
||||
import requests
|
||||
|
||||
# Validate registration format (alphanumeric with dashes)
|
||||
if not registration or not all(c.isalnum() or c == '-' for c in registration):
|
||||
return jsonify({'error': 'Invalid registration'}), 400
|
||||
|
||||
try:
|
||||
# Planespotters.net public API
|
||||
url = f'https://api.planespotters.net/pub/photos/reg/{registration}'
|
||||
resp = requests.get(url, timeout=5, headers={
|
||||
'User-Agent': 'INTERCEPT-ADS-B/1.0'
|
||||
})
|
||||
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
if data.get('photos') and len(data['photos']) > 0:
|
||||
photo = data['photos'][0]
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'thumbnail': photo.get('thumbnail_large', {}).get('src'),
|
||||
'link': photo.get('link'),
|
||||
'photographer': photo.get('photographer')
|
||||
})
|
||||
|
||||
return jsonify({'success': False, 'error': 'No photo found'})
|
||||
|
||||
except requests.Timeout:
|
||||
return jsonify({'success': False, 'error': 'Request timeout'}), 504
|
||||
except Exception as e:
|
||||
logger.debug(f"Error fetching aircraft photo: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@@ -0,0 +1,561 @@
|
||||
"""APRS amateur radio position reporting routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import queue
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Generator, Optional
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
import app as app_module
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
||||
from utils.sse import format_sse
|
||||
from utils.constants import (
|
||||
PROCESS_TERMINATE_TIMEOUT,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
PROCESS_START_WAIT,
|
||||
)
|
||||
|
||||
aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs')
|
||||
|
||||
# APRS frequencies by region (MHz)
|
||||
APRS_FREQUENCIES = {
|
||||
'north_america': '144.390',
|
||||
'europe': '144.800',
|
||||
'australia': '145.175',
|
||||
'new_zealand': '144.575',
|
||||
'argentina': '144.930',
|
||||
'brazil': '145.570',
|
||||
'japan': '144.640',
|
||||
'china': '144.640',
|
||||
}
|
||||
|
||||
# Statistics
|
||||
aprs_packet_count = 0
|
||||
aprs_station_count = 0
|
||||
aprs_last_packet_time = None
|
||||
aprs_stations = {} # callsign -> station data
|
||||
|
||||
|
||||
def find_direwolf() -> Optional[str]:
|
||||
"""Find direwolf binary."""
|
||||
return shutil.which('direwolf')
|
||||
|
||||
|
||||
def find_multimon_ng() -> Optional[str]:
|
||||
"""Find multimon-ng binary."""
|
||||
return shutil.which('multimon-ng')
|
||||
|
||||
|
||||
def find_rtl_fm() -> Optional[str]:
|
||||
"""Find rtl_fm binary."""
|
||||
return shutil.which('rtl_fm')
|
||||
|
||||
|
||||
def parse_aprs_packet(raw_packet: str) -> Optional[dict]:
|
||||
"""Parse APRS packet into structured data."""
|
||||
try:
|
||||
# Basic APRS packet format: CALLSIGN>PATH:DATA
|
||||
# Example: N0CALL-9>APRS,TCPIP*:@092345z4903.50N/07201.75W_090/000g005t077
|
||||
|
||||
match = re.match(r'^([A-Z0-9-]+)>([^:]+):(.+)$', raw_packet, re.IGNORECASE)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
callsign = match.group(1).upper()
|
||||
path = match.group(2)
|
||||
data = match.group(3)
|
||||
|
||||
packet = {
|
||||
'type': 'aprs',
|
||||
'callsign': callsign,
|
||||
'path': path,
|
||||
'raw': raw_packet,
|
||||
'timestamp': datetime.utcnow().isoformat() + 'Z',
|
||||
}
|
||||
|
||||
# Determine packet type and parse accordingly
|
||||
if data.startswith('!') or data.startswith('='):
|
||||
# Position without timestamp
|
||||
packet['packet_type'] = 'position'
|
||||
pos = parse_position(data[1:])
|
||||
if pos:
|
||||
packet.update(pos)
|
||||
|
||||
elif data.startswith('/') or data.startswith('@'):
|
||||
# Position with timestamp
|
||||
packet['packet_type'] = 'position'
|
||||
# Skip timestamp (7 chars) and parse position
|
||||
if len(data) > 8:
|
||||
pos = parse_position(data[8:])
|
||||
if pos:
|
||||
packet.update(pos)
|
||||
|
||||
elif data.startswith('>'):
|
||||
# Status message
|
||||
packet['packet_type'] = 'status'
|
||||
packet['status'] = data[1:]
|
||||
|
||||
elif data.startswith(':'):
|
||||
# Message
|
||||
packet['packet_type'] = 'message'
|
||||
msg_match = re.match(r'^:([A-Z0-9 -]{9}):(.*)$', data, re.IGNORECASE)
|
||||
if msg_match:
|
||||
packet['addressee'] = msg_match.group(1).strip()
|
||||
packet['message'] = msg_match.group(2)
|
||||
|
||||
elif data.startswith('_'):
|
||||
# Weather report (Positionless)
|
||||
packet['packet_type'] = 'weather'
|
||||
packet['weather'] = parse_weather(data)
|
||||
|
||||
elif data.startswith(';'):
|
||||
# Object
|
||||
packet['packet_type'] = 'object'
|
||||
|
||||
elif data.startswith(')'):
|
||||
# Item
|
||||
packet['packet_type'] = 'item'
|
||||
|
||||
elif data.startswith('T'):
|
||||
# Telemetry
|
||||
packet['packet_type'] = 'telemetry'
|
||||
|
||||
else:
|
||||
packet['packet_type'] = 'other'
|
||||
packet['data'] = data
|
||||
|
||||
return packet
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse APRS packet: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def parse_position(data: str) -> Optional[dict]:
|
||||
"""Parse APRS position data."""
|
||||
try:
|
||||
# Format: DDMM.mmN/DDDMM.mmW (or similar with symbols)
|
||||
# Example: 4903.50N/07201.75W
|
||||
|
||||
pos_match = re.match(
|
||||
r'^(\d{2})(\d{2}\.\d+)([NS])(.)(\d{3})(\d{2}\.\d+)([EW])(.)?',
|
||||
data
|
||||
)
|
||||
|
||||
if pos_match:
|
||||
lat_deg = int(pos_match.group(1))
|
||||
lat_min = float(pos_match.group(2))
|
||||
lat_dir = pos_match.group(3)
|
||||
symbol_table = pos_match.group(4)
|
||||
lon_deg = int(pos_match.group(5))
|
||||
lon_min = float(pos_match.group(6))
|
||||
lon_dir = pos_match.group(7)
|
||||
symbol_code = pos_match.group(8) or ''
|
||||
|
||||
lat = lat_deg + lat_min / 60.0
|
||||
if lat_dir == 'S':
|
||||
lat = -lat
|
||||
|
||||
lon = lon_deg + lon_min / 60.0
|
||||
if lon_dir == 'W':
|
||||
lon = -lon
|
||||
|
||||
result = {
|
||||
'lat': round(lat, 6),
|
||||
'lon': round(lon, 6),
|
||||
'symbol': symbol_table + symbol_code,
|
||||
}
|
||||
|
||||
# Parse additional data after position (course/speed, altitude, etc.)
|
||||
remaining = data[18:] if len(data) > 18 else ''
|
||||
|
||||
# Course/Speed: CCC/SSS
|
||||
cs_match = re.search(r'(\d{3})/(\d{3})', remaining)
|
||||
if cs_match:
|
||||
result['course'] = int(cs_match.group(1))
|
||||
result['speed'] = int(cs_match.group(2)) # knots
|
||||
|
||||
# Altitude: /A=NNNNNN
|
||||
alt_match = re.search(r'/A=(-?\d+)', remaining)
|
||||
if alt_match:
|
||||
result['altitude'] = int(alt_match.group(1)) # feet
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse position: {e}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def parse_weather(data: str) -> dict:
|
||||
"""Parse APRS weather data."""
|
||||
weather = {}
|
||||
|
||||
# Wind direction: cCCC
|
||||
match = re.search(r'c(\d{3})', data)
|
||||
if match:
|
||||
weather['wind_direction'] = int(match.group(1))
|
||||
|
||||
# Wind speed: sSSS (mph)
|
||||
match = re.search(r's(\d{3})', data)
|
||||
if match:
|
||||
weather['wind_speed'] = int(match.group(1))
|
||||
|
||||
# Wind gust: gGGG (mph)
|
||||
match = re.search(r'g(\d{3})', data)
|
||||
if match:
|
||||
weather['wind_gust'] = int(match.group(1))
|
||||
|
||||
# Temperature: tTTT (Fahrenheit)
|
||||
match = re.search(r't(-?\d{2,3})', data)
|
||||
if match:
|
||||
weather['temperature'] = int(match.group(1))
|
||||
|
||||
# Rain last hour: rRRR (hundredths of inch)
|
||||
match = re.search(r'r(\d{3})', data)
|
||||
if match:
|
||||
weather['rain_1h'] = int(match.group(1)) / 100.0
|
||||
|
||||
# Rain last 24h: pPPP
|
||||
match = re.search(r'p(\d{3})', data)
|
||||
if match:
|
||||
weather['rain_24h'] = int(match.group(1)) / 100.0
|
||||
|
||||
# Humidity: hHH (%)
|
||||
match = re.search(r'h(\d{2})', data)
|
||||
if match:
|
||||
h = int(match.group(1))
|
||||
weather['humidity'] = 100 if h == 0 else h
|
||||
|
||||
# Barometric pressure: bBBBBB (tenths of millibars)
|
||||
match = re.search(r'b(\d{5})', data)
|
||||
if match:
|
||||
weather['pressure'] = int(match.group(1)) / 10.0
|
||||
|
||||
return weather
|
||||
|
||||
|
||||
def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subprocess.Popen) -> None:
|
||||
"""Stream decoded APRS packets to queue."""
|
||||
global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations
|
||||
|
||||
try:
|
||||
app_module.aprs_queue.put({'type': 'status', 'status': 'started'})
|
||||
|
||||
for line in iter(decoder_process.stdout.readline, b''):
|
||||
line = line.decode('utf-8', errors='replace').strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# direwolf outputs decoded packets, multimon-ng outputs "AFSK1200: ..."
|
||||
if line.startswith('AFSK1200:'):
|
||||
line = line[9:].strip()
|
||||
|
||||
# Skip non-packet lines
|
||||
if '>' not in line or ':' not in line:
|
||||
continue
|
||||
|
||||
packet = parse_aprs_packet(line)
|
||||
if packet:
|
||||
aprs_packet_count += 1
|
||||
aprs_last_packet_time = time.time()
|
||||
|
||||
# Track unique stations
|
||||
callsign = packet.get('callsign')
|
||||
if callsign and callsign not in aprs_stations:
|
||||
aprs_station_count += 1
|
||||
|
||||
# Update station data
|
||||
if callsign:
|
||||
aprs_stations[callsign] = {
|
||||
'callsign': callsign,
|
||||
'lat': packet.get('lat'),
|
||||
'lon': packet.get('lon'),
|
||||
'symbol': packet.get('symbol'),
|
||||
'last_seen': packet.get('timestamp'),
|
||||
'packet_type': packet.get('packet_type'),
|
||||
}
|
||||
|
||||
app_module.aprs_queue.put(packet)
|
||||
|
||||
# Log if enabled
|
||||
if app_module.logging_enabled:
|
||||
try:
|
||||
with open(app_module.log_file_path, 'a') as f:
|
||||
ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
f.write(f"{ts} | APRS | {json.dumps(packet)}\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"APRS stream error: {e}")
|
||||
app_module.aprs_queue.put({'type': 'error', 'message': str(e)})
|
||||
finally:
|
||||
app_module.aprs_queue.put({'type': 'status', 'status': 'stopped'})
|
||||
# Cleanup processes
|
||||
for proc in [rtl_process, decoder_process]:
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@aprs_bp.route('/tools')
|
||||
def check_aprs_tools() -> Response:
|
||||
"""Check for APRS decoding tools."""
|
||||
has_rtl_fm = find_rtl_fm() is not None
|
||||
has_direwolf = find_direwolf() is not None
|
||||
has_multimon = find_multimon_ng() is not None
|
||||
|
||||
return jsonify({
|
||||
'rtl_fm': has_rtl_fm,
|
||||
'direwolf': has_direwolf,
|
||||
'multimon_ng': has_multimon,
|
||||
'ready': has_rtl_fm and (has_direwolf or has_multimon),
|
||||
'decoder': 'direwolf' if has_direwolf else ('multimon-ng' if has_multimon else None)
|
||||
})
|
||||
|
||||
|
||||
@aprs_bp.route('/status')
|
||||
def aprs_status() -> Response:
|
||||
"""Get APRS decoder status."""
|
||||
running = False
|
||||
if app_module.aprs_process:
|
||||
running = app_module.aprs_process.poll() is None
|
||||
|
||||
return jsonify({
|
||||
'running': running,
|
||||
'packet_count': aprs_packet_count,
|
||||
'station_count': aprs_station_count,
|
||||
'last_packet_time': aprs_last_packet_time,
|
||||
'queue_size': app_module.aprs_queue.qsize()
|
||||
})
|
||||
|
||||
|
||||
@aprs_bp.route('/stations')
|
||||
def get_stations() -> Response:
|
||||
"""Get all tracked APRS stations."""
|
||||
return jsonify({
|
||||
'stations': list(aprs_stations.values()),
|
||||
'count': len(aprs_stations)
|
||||
})
|
||||
|
||||
|
||||
@aprs_bp.route('/start', methods=['POST'])
|
||||
def start_aprs() -> Response:
|
||||
"""Start APRS decoder."""
|
||||
global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations
|
||||
|
||||
with app_module.aprs_lock:
|
||||
if app_module.aprs_process and app_module.aprs_process.poll() is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'APRS decoder already running'
|
||||
}), 409
|
||||
|
||||
# Check for required tools
|
||||
rtl_fm_path = find_rtl_fm()
|
||||
if not rtl_fm_path:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'rtl_fm not found. Install with: sudo apt install rtl-sdr'
|
||||
}), 400
|
||||
|
||||
# Check for decoder (prefer direwolf, fallback to multimon-ng)
|
||||
direwolf_path = find_direwolf()
|
||||
multimon_path = find_multimon_ng()
|
||||
|
||||
if not direwolf_path and not multimon_path:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'No APRS decoder found. Install direwolf or multimon-ng'
|
||||
}), 400
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
# Validate inputs
|
||||
try:
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
gain = validate_gain(data.get('gain', '40'))
|
||||
ppm = validate_ppm(data.get('ppm', '0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
# Get frequency for region
|
||||
region = data.get('region', 'north_america')
|
||||
frequency = APRS_FREQUENCIES.get(region, '144.390')
|
||||
|
||||
# Allow custom frequency override
|
||||
if data.get('frequency'):
|
||||
frequency = data.get('frequency')
|
||||
|
||||
# Clear queue and reset stats
|
||||
while not app_module.aprs_queue.empty():
|
||||
try:
|
||||
app_module.aprs_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
aprs_packet_count = 0
|
||||
aprs_station_count = 0
|
||||
aprs_last_packet_time = None
|
||||
aprs_stations = {}
|
||||
|
||||
# Build rtl_fm command
|
||||
freq_hz = f"{float(frequency)}M"
|
||||
rtl_cmd = [
|
||||
rtl_fm_path,
|
||||
'-f', freq_hz,
|
||||
'-s', '22050', # Sample rate for AFSK1200
|
||||
'-d', str(device),
|
||||
]
|
||||
|
||||
if gain and str(gain) != '0':
|
||||
rtl_cmd.extend(['-g', str(gain)])
|
||||
if ppm and str(ppm) != '0':
|
||||
rtl_cmd.extend(['-p', str(ppm)])
|
||||
|
||||
# Build decoder command
|
||||
if direwolf_path:
|
||||
decoder_cmd = [direwolf_path, '-r', '22050', '-D', '1', '-']
|
||||
decoder_name = 'direwolf'
|
||||
else:
|
||||
decoder_cmd = [multimon_path, '-t', 'raw', '-a', 'AFSK1200', '-']
|
||||
decoder_name = 'multimon-ng'
|
||||
|
||||
logger.info(f"Starting APRS decoder: {' '.join(rtl_cmd)} | {' '.join(decoder_cmd)}")
|
||||
|
||||
try:
|
||||
# Start rtl_fm
|
||||
rtl_process = subprocess.Popen(
|
||||
rtl_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True
|
||||
)
|
||||
|
||||
# Start decoder with rtl_fm output
|
||||
decoder_process = subprocess.Popen(
|
||||
decoder_cmd,
|
||||
stdin=rtl_process.stdout,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True
|
||||
)
|
||||
|
||||
# Allow rtl_fm stdout to be consumed by decoder
|
||||
rtl_process.stdout.close()
|
||||
|
||||
# Wait briefly to check if processes started
|
||||
time.sleep(PROCESS_START_WAIT)
|
||||
|
||||
if rtl_process.poll() is not None:
|
||||
stderr = rtl_process.stderr.read().decode('utf-8', errors='replace') if rtl_process.stderr else ''
|
||||
error_msg = f'rtl_fm failed to start'
|
||||
if stderr:
|
||||
error_msg += f': {stderr[:200]}'
|
||||
logger.error(error_msg)
|
||||
decoder_process.kill()
|
||||
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||
|
||||
# Store reference to decoder process (for status checks)
|
||||
app_module.aprs_process = decoder_process
|
||||
app_module.aprs_rtl_process = rtl_process
|
||||
|
||||
# Start output streaming thread
|
||||
thread = threading.Thread(
|
||||
target=stream_aprs_output,
|
||||
args=(rtl_process, decoder_process),
|
||||
daemon=True
|
||||
)
|
||||
thread.start()
|
||||
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'frequency': frequency,
|
||||
'region': region,
|
||||
'device': device,
|
||||
'decoder': decoder_name
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start APRS decoder: {e}")
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
|
||||
@aprs_bp.route('/stop', methods=['POST'])
|
||||
def stop_aprs() -> Response:
|
||||
"""Stop APRS decoder."""
|
||||
with app_module.aprs_lock:
|
||||
processes_to_stop = []
|
||||
|
||||
if hasattr(app_module, 'aprs_rtl_process') and app_module.aprs_rtl_process:
|
||||
processes_to_stop.append(app_module.aprs_rtl_process)
|
||||
|
||||
if app_module.aprs_process:
|
||||
processes_to_stop.append(app_module.aprs_process)
|
||||
|
||||
if not processes_to_stop:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'APRS decoder not running'
|
||||
}), 400
|
||||
|
||||
for proc in processes_to_stop:
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=PROCESS_TERMINATE_TIMEOUT)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping APRS process: {e}")
|
||||
|
||||
app_module.aprs_process = None
|
||||
if hasattr(app_module, 'aprs_rtl_process'):
|
||||
app_module.aprs_rtl_process = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@aprs_bp.route('/stream')
|
||||
def stream_aprs() -> Response:
|
||||
"""SSE stream for APRS packets."""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.aprs_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
|
||||
|
||||
|
||||
@aprs_bp.route('/frequencies')
|
||||
def get_frequencies() -> Response:
|
||||
"""Get APRS frequencies by region."""
|
||||
return jsonify(APRS_FREQUENCIES)
|
||||
@@ -0,0 +1,256 @@
|
||||
"""WebSocket-based audio streaming for SDR."""
|
||||
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import shutil
|
||||
import json
|
||||
from flask import Flask
|
||||
|
||||
# Try to import flask-sock
|
||||
try:
|
||||
from flask_sock import Sock
|
||||
WEBSOCKET_AVAILABLE = True
|
||||
except ImportError:
|
||||
WEBSOCKET_AVAILABLE = False
|
||||
Sock = None
|
||||
|
||||
from utils.logging import get_logger
|
||||
|
||||
logger = get_logger('intercept.audio_ws')
|
||||
|
||||
# Global state
|
||||
audio_process = None
|
||||
rtl_process = None
|
||||
process_lock = threading.Lock()
|
||||
current_config = {
|
||||
'frequency': 118.0,
|
||||
'modulation': 'am',
|
||||
'squelch': 0,
|
||||
'gain': 40,
|
||||
'device': 0
|
||||
}
|
||||
|
||||
|
||||
def find_rtl_fm():
|
||||
return shutil.which('rtl_fm')
|
||||
|
||||
|
||||
def find_ffmpeg():
|
||||
return shutil.which('ffmpeg')
|
||||
|
||||
|
||||
def kill_audio_processes():
|
||||
"""Kill any running audio processes."""
|
||||
global audio_process, rtl_process
|
||||
|
||||
if audio_process:
|
||||
try:
|
||||
audio_process.terminate()
|
||||
audio_process.wait(timeout=0.5)
|
||||
except:
|
||||
try:
|
||||
audio_process.kill()
|
||||
except:
|
||||
pass
|
||||
audio_process = None
|
||||
|
||||
if rtl_process:
|
||||
try:
|
||||
rtl_process.terminate()
|
||||
rtl_process.wait(timeout=0.5)
|
||||
except:
|
||||
try:
|
||||
rtl_process.kill()
|
||||
except:
|
||||
pass
|
||||
rtl_process = None
|
||||
|
||||
# Kill any orphaned processes
|
||||
try:
|
||||
subprocess.run(['pkill', '-9', '-f', 'rtl_fm'], capture_output=True, timeout=1)
|
||||
except:
|
||||
pass
|
||||
|
||||
time.sleep(0.3)
|
||||
|
||||
|
||||
def start_audio_stream(config):
|
||||
"""Start rtl_fm + ffmpeg pipeline, return the ffmpeg process."""
|
||||
global audio_process, rtl_process, current_config
|
||||
|
||||
kill_audio_processes()
|
||||
|
||||
rtl_fm = find_rtl_fm()
|
||||
ffmpeg = find_ffmpeg()
|
||||
|
||||
if not rtl_fm or not ffmpeg:
|
||||
logger.error("rtl_fm or ffmpeg not found")
|
||||
return None
|
||||
|
||||
current_config.update(config)
|
||||
|
||||
freq = config.get('frequency', 118.0)
|
||||
mod = config.get('modulation', 'am')
|
||||
squelch = config.get('squelch', 0)
|
||||
gain = config.get('gain', 40)
|
||||
device = config.get('device', 0)
|
||||
|
||||
# Sample rates based on modulation
|
||||
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
|
||||
|
||||
freq_hz = int(freq * 1e6)
|
||||
|
||||
rtl_cmd = [
|
||||
rtl_fm,
|
||||
'-M', mod,
|
||||
'-f', str(freq_hz),
|
||||
'-s', str(sample_rate),
|
||||
'-r', str(resample_rate),
|
||||
'-g', str(gain),
|
||||
'-d', str(device),
|
||||
'-l', str(squelch),
|
||||
]
|
||||
|
||||
# Encode to MP3 for browser compatibility
|
||||
ffmpeg_cmd = [
|
||||
ffmpeg,
|
||||
'-hide_banner',
|
||||
'-loglevel', 'error',
|
||||
'-f', 's16le',
|
||||
'-ar', str(resample_rate),
|
||||
'-ac', '1',
|
||||
'-i', 'pipe:0',
|
||||
'-acodec', 'libmp3lame',
|
||||
'-b:a', '128k',
|
||||
'-f', 'mp3',
|
||||
'-flush_packets', '1',
|
||||
'pipe:1'
|
||||
]
|
||||
|
||||
try:
|
||||
logger.info(f"Starting rtl_fm: {freq} MHz, {mod}")
|
||||
rtl_process = subprocess.Popen(
|
||||
rtl_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL
|
||||
)
|
||||
|
||||
audio_process = subprocess.Popen(
|
||||
ffmpeg_cmd,
|
||||
stdin=rtl_process.stdout,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
bufsize=0
|
||||
)
|
||||
|
||||
rtl_process.stdout.close()
|
||||
|
||||
# Check processes started
|
||||
time.sleep(0.2)
|
||||
if rtl_process.poll() is not None or audio_process.poll() is not None:
|
||||
logger.error("Audio process failed to start")
|
||||
kill_audio_processes()
|
||||
return None
|
||||
|
||||
return audio_process
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start audio: {e}")
|
||||
kill_audio_processes()
|
||||
return None
|
||||
|
||||
|
||||
def init_audio_websocket(app: Flask):
|
||||
"""Initialize WebSocket audio streaming."""
|
||||
if not WEBSOCKET_AVAILABLE:
|
||||
logger.warning("flask-sock not installed, WebSocket audio disabled")
|
||||
return
|
||||
|
||||
sock = Sock(app)
|
||||
|
||||
@sock.route('/ws/audio')
|
||||
def audio_stream(ws):
|
||||
"""WebSocket endpoint for audio streaming."""
|
||||
logger.info("WebSocket audio client connected")
|
||||
|
||||
proc = None
|
||||
streaming = False
|
||||
|
||||
try:
|
||||
while True:
|
||||
# Check for messages from client (non-blocking with timeout)
|
||||
try:
|
||||
msg = ws.receive(timeout=0.01)
|
||||
if msg:
|
||||
data = json.loads(msg)
|
||||
cmd = data.get('cmd')
|
||||
|
||||
if cmd == 'start':
|
||||
config = data.get('config', {})
|
||||
logger.info(f"Starting audio: {config}")
|
||||
with process_lock:
|
||||
proc = start_audio_stream(config)
|
||||
if proc:
|
||||
streaming = True
|
||||
ws.send(json.dumps({'status': 'started'}))
|
||||
else:
|
||||
ws.send(json.dumps({'status': 'error', 'message': 'Failed to start'}))
|
||||
|
||||
elif cmd == 'stop':
|
||||
logger.info("Stopping audio")
|
||||
streaming = False
|
||||
with process_lock:
|
||||
kill_audio_processes()
|
||||
proc = None
|
||||
ws.send(json.dumps({'status': 'stopped'}))
|
||||
|
||||
elif cmd == 'tune':
|
||||
# Change frequency/modulation - restart stream
|
||||
config = data.get('config', {})
|
||||
logger.info(f"Retuning: {config}")
|
||||
with process_lock:
|
||||
proc = start_audio_stream(config)
|
||||
if proc:
|
||||
streaming = True
|
||||
ws.send(json.dumps({'status': 'tuned'}))
|
||||
else:
|
||||
streaming = False
|
||||
ws.send(json.dumps({'status': 'error', 'message': 'Failed to tune'}))
|
||||
|
||||
except TimeoutError:
|
||||
pass
|
||||
except Exception as e:
|
||||
if "timed out" not in str(e).lower():
|
||||
logger.error(f"WebSocket receive error: {e}")
|
||||
|
||||
# Stream audio data if active
|
||||
if streaming and proc and proc.poll() is None:
|
||||
try:
|
||||
chunk = proc.stdout.read(4096)
|
||||
if chunk:
|
||||
ws.send(chunk)
|
||||
except Exception as e:
|
||||
logger.error(f"Audio read error: {e}")
|
||||
streaming = False
|
||||
elif streaming:
|
||||
# Process died
|
||||
streaming = False
|
||||
ws.send(json.dumps({'status': 'error', 'message': 'Audio process died'}))
|
||||
else:
|
||||
time.sleep(0.01)
|
||||
|
||||
except Exception as e:
|
||||
logger.info(f"WebSocket closed: {e}")
|
||||
finally:
|
||||
with process_lock:
|
||||
kill_audio_processes()
|
||||
logger.info("WebSocket audio client disconnected")
|
||||
@@ -21,8 +21,20 @@ import app as app_module
|
||||
from utils.dependencies import check_tool
|
||||
from utils.logging import bluetooth_logger as logger
|
||||
from utils.sse import format_sse
|
||||
from utils.validation import validate_bluetooth_interface
|
||||
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')
|
||||
|
||||
@@ -32,42 +44,76 @@ def classify_bt_device(name, device_class, services, manufacturer=None):
|
||||
name_lower = (name or '').lower()
|
||||
mfr_lower = (manufacturer or '').lower()
|
||||
|
||||
# Audio devices - check name patterns first
|
||||
audio_patterns = [
|
||||
'airpod', 'earbud', 'headphone', 'headset', 'speaker', 'audio', 'beats', 'bose',
|
||||
'jbl', 'sony wh', 'sony wf', 'sennheiser', 'jabra', 'soundcore', 'anker', 'buds',
|
||||
'earphone', 'pod', 'soundbar', 'skullcandy', 'marshall', 'b&o', 'bang', 'olufsen'
|
||||
'earphone', 'pod', 'soundbar', 'skullcandy', 'marshall', 'b&o', 'bang', 'olufsen',
|
||||
'powerbeats', 'soundlink', 'soundsport', 'quietcomfort', 'qc35', 'qc45', 'nc700',
|
||||
'wh-1000', 'wf-1000', 'linkbuds', 'freebuds', 'galaxy buds', 'pixel buds',
|
||||
'echo dot', 'homepod', 'sonos', 'ue boom', 'flip', 'charge', 'xtreme', 'pulse'
|
||||
]
|
||||
if any(x in name_lower for x in audio_patterns):
|
||||
return 'audio'
|
||||
|
||||
# Wearables
|
||||
wearable_patterns = [
|
||||
'watch', 'band', 'fitbit', 'garmin', 'mi band', 'miband', 'amazfit',
|
||||
'galaxy watch', 'gear', 'versa', 'sense', 'charge', 'inspire'
|
||||
'galaxy watch', 'gear', 'versa', 'sense', 'charge', 'inspire', 'fenix',
|
||||
'forerunner', 'venu', 'vivoactive', 'instinct', 'apple watch', 'gt 2', 'gt2'
|
||||
]
|
||||
if any(x in name_lower for x in wearable_patterns):
|
||||
return 'wearable'
|
||||
|
||||
# Phones - check name patterns
|
||||
phone_patterns = [
|
||||
'iphone', 'galaxy', 'pixel', 'phone', 'android', 'oneplus', 'huawei', 'xiaomi'
|
||||
'iphone', 'galaxy', 'pixel', 'phone', 'android', 'oneplus', 'huawei', 'xiaomi',
|
||||
'redmi', 'poco', 'realme', 'oppo', 'vivo', 'motorola', 'nokia', 'lg-', 'sm-',
|
||||
'moto g', 'moto e', 'note', 'ultra', 'pro max', 's21', 's22', 's23', 's24'
|
||||
]
|
||||
if any(x in name_lower for x in phone_patterns):
|
||||
return 'phone'
|
||||
|
||||
tracker_patterns = ['airtag', 'tile', 'smarttag', 'chipolo', 'find my']
|
||||
# Trackers
|
||||
tracker_patterns = ['airtag', 'tile', 'smarttag', 'chipolo', 'find my', 'findmy']
|
||||
if any(x in name_lower for x in tracker_patterns):
|
||||
return 'tracker'
|
||||
|
||||
input_patterns = ['keyboard', 'mouse', 'controller', 'gamepad', 'remote']
|
||||
# Input devices
|
||||
input_patterns = ['keyboard', 'mouse', 'controller', 'gamepad', 'remote', 'trackpad',
|
||||
'magic keyboard', 'magic mouse', 'magic trackpad', 'mx master', 'mx keys',
|
||||
'logitech k', 'logitech m', 'razer', 'dualshock', 'dualsense', 'xbox']
|
||||
if any(x in name_lower for x in input_patterns):
|
||||
return 'input'
|
||||
|
||||
if mfr_lower in ['bose', 'jbl', 'sony', 'sennheiser', 'jabra', 'beats']:
|
||||
# Computers/laptops
|
||||
computer_patterns = ['macbook', 'imac', 'mac pro', 'mac mini', 'dell', 'hp ', 'lenovo',
|
||||
'thinkpad', 'surface', 'chromebook', 'laptop', 'desktop', 'pc']
|
||||
if any(x in name_lower for x in computer_patterns):
|
||||
return 'computer'
|
||||
|
||||
# Check manufacturer for device type inference
|
||||
audio_manufacturers = ['bose', 'jbl', 'sony', 'sennheiser', 'jabra', 'beats',
|
||||
'bang & olufsen', 'audio-technica', 'skullcandy', 'anker', 'plantronics']
|
||||
if mfr_lower in audio_manufacturers:
|
||||
return 'audio'
|
||||
if mfr_lower in ['fitbit', 'garmin']:
|
||||
|
||||
wearable_manufacturers = ['fitbit', 'garmin']
|
||||
if mfr_lower in wearable_manufacturers:
|
||||
return 'wearable'
|
||||
|
||||
if mfr_lower == 'tile':
|
||||
return 'tracker'
|
||||
|
||||
phone_manufacturers = ['samsung', 'xiaomi', 'huawei', 'oneplus', 'google', 'oppo', 'vivo', 'realme']
|
||||
if mfr_lower in phone_manufacturers:
|
||||
return 'phone'
|
||||
|
||||
computer_manufacturers = ['dell', 'hp', 'lenovo', 'microsoft', 'intel']
|
||||
if mfr_lower in computer_manufacturers:
|
||||
return 'computer'
|
||||
|
||||
# Check device class if available
|
||||
if device_class:
|
||||
major_class = (device_class >> 8) & 0x1F
|
||||
if major_class == 1:
|
||||
@@ -113,7 +159,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 +173,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({
|
||||
@@ -203,18 +253,43 @@ def stream_bt_scan(process, scan_mode):
|
||||
line = re.sub(r'\r', '', line)
|
||||
|
||||
if 'Device' in line:
|
||||
# Check for RSSI update: [CHG] Device XX:XX:XX RSSI: -65
|
||||
rssi_match = re.search(r'([0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}).*RSSI:\s*(-?\d+)', line)
|
||||
if rssi_match:
|
||||
mac = rssi_match.group(1).upper()
|
||||
rssi = int(rssi_match.group(2))
|
||||
if mac in app_module.bt_devices:
|
||||
app_module.bt_devices[mac]['rssi'] = rssi
|
||||
app_module.bt_devices[mac]['last_seen'] = time.time()
|
||||
# Send RSSI update
|
||||
app_module.bt_queue.put({
|
||||
**app_module.bt_devices[mac],
|
||||
'type': 'device',
|
||||
'device_type': app_module.bt_devices[mac].get('type', 'other'),
|
||||
'action': 'update',
|
||||
})
|
||||
continue
|
||||
|
||||
# Check for new device: [NEW] Device XX:XX:XX Name
|
||||
match = re.search(r'([0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2})\s*(.*)', line)
|
||||
if match:
|
||||
mac = match.group(1).upper()
|
||||
name = match.group(2).strip()
|
||||
|
||||
# Extract RSSI from name if present
|
||||
rssi_in_name = re.search(r'RSSI:\s*(-?\d+)', name)
|
||||
initial_rssi = int(rssi_in_name.group(1)) if rssi_in_name else None
|
||||
|
||||
# Remove "RSSI: -XX" from name
|
||||
name = re.sub(r'\s*RSSI:\s*-?\d+\s*', '', name).strip()
|
||||
|
||||
manufacturer = get_manufacturer(mac)
|
||||
device = {
|
||||
'mac': mac,
|
||||
'name': name or '[Unknown]',
|
||||
'manufacturer': manufacturer,
|
||||
'type': classify_bt_device(name, None, None, manufacturer),
|
||||
'rssi': None,
|
||||
'rssi': initial_rssi,
|
||||
'last_seen': time.time()
|
||||
}
|
||||
|
||||
@@ -289,9 +364,14 @@ def start_bt_scan():
|
||||
|
||||
data = request.json
|
||||
scan_mode = data.get('mode', 'hcitool')
|
||||
interface = data.get('interface', 'hci0')
|
||||
scan_ble = data.get('scan_ble', True)
|
||||
|
||||
# Validate Bluetooth interface name
|
||||
try:
|
||||
interface = validate_bluetooth_interface(data.get('interface', 'hci0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
app_module.bt_interface = interface
|
||||
app_module.bt_devices = {}
|
||||
|
||||
@@ -373,7 +453,12 @@ def stop_bt_scan():
|
||||
def reset_bt_adapter():
|
||||
"""Reset Bluetooth adapter."""
|
||||
data = request.json
|
||||
interface = data.get('interface', 'hci0')
|
||||
|
||||
# Validate Bluetooth interface name
|
||||
try:
|
||||
interface = validate_bluetooth_interface(data.get('interface', 'hci0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
with app_module.bt_lock:
|
||||
if app_module.bt_process:
|
||||
|
||||
@@ -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
|
||||
@@ -1,9 +1,8 @@
|
||||
"""GPS dongle routes for USB GPS device support."""
|
||||
"""GPS routes for gpsd daemon support."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from typing import Generator
|
||||
|
||||
@@ -12,10 +11,8 @@ from flask import Blueprint, jsonify, request, Response
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import format_sse
|
||||
from utils.gps import (
|
||||
detect_gps_devices,
|
||||
is_serial_available,
|
||||
get_gps_reader,
|
||||
start_gps,
|
||||
start_gpsd,
|
||||
stop_gps,
|
||||
get_current_position,
|
||||
GPSPosition,
|
||||
@@ -42,65 +39,42 @@ def _position_callback(position: GPSPosition) -> None:
|
||||
pass
|
||||
|
||||
|
||||
@gps_bp.route('/available')
|
||||
def check_gps_available():
|
||||
"""Check if GPS dongle support is available."""
|
||||
return jsonify({
|
||||
'available': is_serial_available(),
|
||||
'message': None if is_serial_available() else 'pyserial not installed - run: pip install pyserial'
|
||||
})
|
||||
@gps_bp.route('/auto-connect', methods=['POST'])
|
||||
def auto_connect_gps():
|
||||
"""
|
||||
Automatically connect to gpsd if available.
|
||||
|
||||
|
||||
@gps_bp.route('/devices')
|
||||
def list_gps_devices():
|
||||
"""List available GPS serial devices."""
|
||||
if not is_serial_available():
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'pyserial not installed'
|
||||
}), 503
|
||||
|
||||
devices = detect_gps_devices()
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'devices': devices
|
||||
})
|
||||
|
||||
|
||||
@gps_bp.route('/start', methods=['POST'])
|
||||
def start_gps_reader():
|
||||
"""Start GPS reader on specified device."""
|
||||
if not is_serial_available():
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'pyserial not installed'
|
||||
}), 503
|
||||
Called on page load to seamlessly enable GPS if gpsd is running.
|
||||
Returns current status if already connected.
|
||||
"""
|
||||
import socket
|
||||
|
||||
# Check if already running
|
||||
reader = get_gps_reader()
|
||||
if reader and reader.is_running:
|
||||
position = reader.position
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'GPS reader already running'
|
||||
}), 409
|
||||
'status': 'connected',
|
||||
'source': 'gpsd',
|
||||
'has_fix': position is not None,
|
||||
'position': position.to_dict() if position else None
|
||||
})
|
||||
|
||||
data = request.json or {}
|
||||
device_path = data.get('device')
|
||||
baudrate = data.get('baudrate', 9600)
|
||||
# Try to connect to gpsd on localhost:2947
|
||||
host = 'localhost'
|
||||
port = 2947
|
||||
|
||||
if not device_path:
|
||||
# First check if gpsd is reachable
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(1.0)
|
||||
sock.connect((host, port))
|
||||
sock.close()
|
||||
except Exception:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Device path required'
|
||||
}), 400
|
||||
|
||||
# Validate baudrate
|
||||
valid_baudrates = [4800, 9600, 19200, 38400, 57600, 115200]
|
||||
if baudrate not in valid_baudrates:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Invalid baudrate. Valid options: {valid_baudrates}'
|
||||
}), 400
|
||||
'status': 'unavailable',
|
||||
'message': 'gpsd not running'
|
||||
})
|
||||
|
||||
# Clear the queue
|
||||
while not _gps_queue.empty():
|
||||
@@ -109,32 +83,26 @@ def start_gps_reader():
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Start the GPS reader
|
||||
success = start_gps(device_path, baudrate)
|
||||
# Start the gpsd client
|
||||
success = start_gpsd(host, port, callback=_position_callback)
|
||||
|
||||
if success:
|
||||
# Register callback for SSE streaming
|
||||
reader = get_gps_reader()
|
||||
if reader:
|
||||
reader.add_callback(_position_callback)
|
||||
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'device': device_path,
|
||||
'baudrate': baudrate
|
||||
'status': 'connected',
|
||||
'source': 'gpsd',
|
||||
'has_fix': False,
|
||||
'position': None
|
||||
})
|
||||
else:
|
||||
reader = get_gps_reader()
|
||||
error = reader.error if reader else 'Unknown error'
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Failed to start GPS reader: {error}'
|
||||
}), 500
|
||||
'status': 'unavailable',
|
||||
'message': 'Failed to connect to gpsd'
|
||||
})
|
||||
|
||||
|
||||
@gps_bp.route('/stop', methods=['POST'])
|
||||
def stop_gps_reader():
|
||||
"""Stop GPS reader."""
|
||||
"""Stop GPS client."""
|
||||
reader = get_gps_reader()
|
||||
if reader:
|
||||
reader.remove_callback(_position_callback)
|
||||
@@ -146,7 +114,7 @@ def stop_gps_reader():
|
||||
|
||||
@gps_bp.route('/status')
|
||||
def get_gps_status():
|
||||
"""Get current GPS reader status."""
|
||||
"""Get current GPS client status."""
|
||||
reader = get_gps_reader()
|
||||
|
||||
if not reader:
|
||||
@@ -155,7 +123,7 @@ def get_gps_status():
|
||||
'device': None,
|
||||
'position': None,
|
||||
'error': None,
|
||||
'message': 'GPS reader not started'
|
||||
'message': 'GPS client not started'
|
||||
})
|
||||
|
||||
position = reader.position
|
||||
@@ -184,7 +152,7 @@ def get_position():
|
||||
if not reader or not reader.is_running:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'GPS reader not running'
|
||||
'message': 'GPS client not running'
|
||||
}), 400
|
||||
else:
|
||||
return jsonify({
|
||||
@@ -195,20 +163,22 @@ def get_position():
|
||||
|
||||
@gps_bp.route('/debug')
|
||||
def debug_gps():
|
||||
"""Debug endpoint showing GPS reader state."""
|
||||
"""Debug endpoint showing GPS client state."""
|
||||
reader = get_gps_reader()
|
||||
|
||||
if not reader:
|
||||
return jsonify({
|
||||
'reader': None,
|
||||
'message': 'No GPS reader initialized'
|
||||
'message': 'No GPS client initialized'
|
||||
})
|
||||
|
||||
position = reader.position
|
||||
return jsonify({
|
||||
'running': reader.is_running,
|
||||
'source': 'gpsd',
|
||||
'device': reader.device_path,
|
||||
'baudrate': reader.baudrate,
|
||||
'host': reader.host,
|
||||
'port': reader.port,
|
||||
'has_position': position is not None,
|
||||
'position': position.to_dict() if position else None,
|
||||
'last_update': reader.last_update.isoformat() if reader.last_update else None,
|
||||
|
||||
@@ -0,0 +1,895 @@
|
||||
"""Listening Post routes for radio monitoring and frequency scanning."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import select
|
||||
import signal
|
||||
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,
|
||||
)
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
|
||||
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,
|
||||
'bias_t': False, # Bias-T power for external LNA
|
||||
'sdr_type': 'rtlsdr', # SDR type: rtlsdr, hackrf, airspy, limesdr, sdrplay
|
||||
}
|
||||
|
||||
# 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_rx_fm() -> str | None:
|
||||
"""Find rx_fm binary (SoapySDR FM demodulator for HackRF/Airspy/LimeSDR)."""
|
||||
return shutil.which('rx_fm')
|
||||
|
||||
|
||||
def find_ffmpeg() -> str | None:
|
||||
"""Find ffmpeg for audio encoding."""
|
||||
return shutil.which('ffmpeg')
|
||||
|
||||
|
||||
|
||||
|
||||
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
|
||||
|
||||
try:
|
||||
while scanner_running:
|
||||
# Check if paused
|
||||
if scanner_paused:
|
||||
time.sleep(0.1)
|
||||
continue
|
||||
|
||||
# Read config values on each iteration (allows live updates)
|
||||
step_mhz = scanner_config['step'] / 1000.0
|
||||
squelch = scanner_config['squelch']
|
||||
mod = scanner_config['modulation']
|
||||
gain = scanner_config['gain']
|
||||
device = scanner_config['device']
|
||||
|
||||
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)
|
||||
|
||||
# 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(gain),
|
||||
'-d', str(device),
|
||||
]
|
||||
# Add bias-t flag if enabled (for external LNA power)
|
||||
if scanner_config.get('bias_t', False):
|
||||
rtl_cmd.append('-T')
|
||||
|
||||
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 = 500
|
||||
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
|
||||
|
||||
# Threshold based on squelch setting
|
||||
# Lower squelch = more sensitive (lower threshold)
|
||||
# squelch 0 = very sensitive, squelch 100 = only strong signals
|
||||
if mod == 'wfm':
|
||||
# WFM: threshold 500-10000 based on squelch
|
||||
threshold = 500 + (squelch * 95)
|
||||
else:
|
||||
# AM/NFM: threshold 300-6500 based on squelch
|
||||
threshold = 300 + (squelch * 62)
|
||||
|
||||
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()
|
||||
|
||||
ffmpeg_path = find_ffmpeg()
|
||||
if not ffmpeg_path:
|
||||
logger.error("ffmpeg not found")
|
||||
return
|
||||
|
||||
# Determine SDR type and build appropriate command
|
||||
sdr_type_str = scanner_config.get('sdr_type', 'rtlsdr')
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
|
||||
# Set sample rates based on modulation
|
||||
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
|
||||
|
||||
# Build the SDR command based on device type
|
||||
if sdr_type == SDRType.RTL_SDR:
|
||||
# Use rtl_fm for RTL-SDR devices
|
||||
rtl_fm_path = find_rtl_fm()
|
||||
if not rtl_fm_path:
|
||||
logger.error("rtl_fm not found")
|
||||
return
|
||||
|
||||
freq_hz = int(frequency * 1e6)
|
||||
sdr_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']),
|
||||
]
|
||||
if scanner_config.get('bias_t', False):
|
||||
sdr_cmd.append('-T')
|
||||
else:
|
||||
# Use SDR abstraction layer for HackRF, Airspy, LimeSDR, SDRPlay
|
||||
rx_fm_path = find_rx_fm()
|
||||
if not rx_fm_path:
|
||||
logger.error(f"rx_fm not found - required for {sdr_type.value}. Install SoapySDR utilities.")
|
||||
return
|
||||
|
||||
# Create device and get command builder
|
||||
device = SDRFactory.create_default_device(sdr_type, index=scanner_config['device'])
|
||||
builder = SDRFactory.get_builder(sdr_type)
|
||||
|
||||
# Build FM demod command
|
||||
sdr_cmd = builder.build_fm_demod_command(
|
||||
device=device,
|
||||
frequency_mhz=frequency,
|
||||
sample_rate=resample_rate,
|
||||
gain=float(scanner_config['gain']),
|
||||
modulation=modulation,
|
||||
squelch=scanner_config['squelch'],
|
||||
bias_t=scanner_config.get('bias_t', False)
|
||||
)
|
||||
# Ensure we use the found rx_fm path
|
||||
sdr_cmd[0] = rx_fm_path
|
||||
|
||||
encoder_cmd = [
|
||||
ffmpeg_path,
|
||||
'-hide_banner',
|
||||
'-loglevel', 'error',
|
||||
'-f', 's16le',
|
||||
'-ar', str(resample_rate),
|
||||
'-ac', '1',
|
||||
'-i', 'pipe:0',
|
||||
'-acodec', 'libmp3lame',
|
||||
'-b:a', '128k',
|
||||
'-ar', '44100',
|
||||
'-f', 'mp3',
|
||||
'pipe:1'
|
||||
]
|
||||
|
||||
try:
|
||||
# Use shell pipe for reliable streaming (Python subprocess piping can be unreliable)
|
||||
shell_cmd = f"{' '.join(sdr_cmd)} 2>/dev/null | {' '.join(encoder_cmd)}"
|
||||
logger.info(f"Starting audio pipeline: {shell_cmd}")
|
||||
|
||||
audio_rtl_process = None # Not used in shell mode
|
||||
audio_process = subprocess.Popen(
|
||||
shell_cmd,
|
||||
shell=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
bufsize=0,
|
||||
start_new_session=True # Create new process group for clean shutdown
|
||||
)
|
||||
|
||||
# Brief delay to check if process started successfully
|
||||
time.sleep(0.3)
|
||||
|
||||
if audio_process.poll() is not None:
|
||||
stderr = audio_process.stderr.read().decode() if audio_process.stderr else ''
|
||||
logger.error(f"Audio pipeline exited immediately: {stderr}")
|
||||
return
|
||||
|
||||
audio_running = True
|
||||
audio_frequency = frequency
|
||||
audio_modulation = modulation
|
||||
logger.info(f"Audio stream started: {frequency} MHz ({modulation}) via {sdr_type.value}")
|
||||
|
||||
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
|
||||
|
||||
# Set flag first to stop any streaming
|
||||
audio_running = False
|
||||
audio_frequency = 0.0
|
||||
|
||||
# Kill the shell process and its children
|
||||
if audio_process:
|
||||
try:
|
||||
# Kill entire process group (rtl_fm, ffmpeg, shell)
|
||||
try:
|
||||
os.killpg(os.getpgid(audio_process.pid), signal.SIGKILL)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
audio_process.kill()
|
||||
audio_process.wait(timeout=0.5)
|
||||
except:
|
||||
pass
|
||||
|
||||
audio_process = None
|
||||
audio_rtl_process = None
|
||||
|
||||
# Kill any orphaned rtl_fm and ffmpeg processes
|
||||
try:
|
||||
subprocess.run(['pkill', '-9', 'rtl_fm'], capture_output=True, timeout=0.5)
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
subprocess.run(['pkill', '-9', '-f', 'ffmpeg.*pipe:0'], capture_output=True, timeout=0.5)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Pause for SDR device to be released (important for frequency/modulation changes)
|
||||
time.sleep(0.7)
|
||||
|
||||
|
||||
# ============================================
|
||||
# API ENDPOINTS
|
||||
# ============================================
|
||||
|
||||
@listening_post_bp.route('/tools')
|
||||
def check_tools() -> Response:
|
||||
"""Check for required tools."""
|
||||
rtl_fm = find_rtl_fm()
|
||||
rx_fm = find_rx_fm()
|
||||
ffmpeg = find_ffmpeg()
|
||||
|
||||
# Determine which SDR types are supported
|
||||
supported_sdr_types = []
|
||||
if rtl_fm:
|
||||
supported_sdr_types.append('rtlsdr')
|
||||
if rx_fm:
|
||||
# rx_fm from SoapySDR supports these types
|
||||
supported_sdr_types.extend(['hackrf', 'airspy', 'limesdr', 'sdrplay'])
|
||||
|
||||
return jsonify({
|
||||
'rtl_fm': rtl_fm is not None,
|
||||
'rx_fm': rx_fm is not None,
|
||||
'ffmpeg': ffmpeg is not None,
|
||||
'available': (rtl_fm is not None or rx_fm is not None) and ffmpeg is not None,
|
||||
'supported_sdr_types': supported_sdr_types
|
||||
})
|
||||
|
||||
|
||||
@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))
|
||||
scanner_config['bias_t'] = bool(data.get('bias_t', False))
|
||||
scanner_config['sdr_type'] = str(data.get('sdr_type', 'rtlsdr')).lower()
|
||||
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 based on SDR type
|
||||
sdr_type = scanner_config['sdr_type']
|
||||
if sdr_type == 'rtlsdr':
|
||||
if not find_rtl_fm():
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'rtl_fm not found. Install rtl-sdr tools.'
|
||||
}), 503
|
||||
else:
|
||||
if not find_rx_fm():
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'rx_fm not found. Install SoapySDR utilities for {sdr_type}.'
|
||||
}), 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/config', methods=['POST'])
|
||||
def update_scanner_config() -> Response:
|
||||
"""Update scanner config while running (step, squelch, gain, dwell)."""
|
||||
data = request.json or {}
|
||||
|
||||
updated = []
|
||||
|
||||
if 'step' in data:
|
||||
scanner_config['step'] = float(data['step'])
|
||||
updated.append(f"step={data['step']}kHz")
|
||||
|
||||
if 'squelch' in data:
|
||||
scanner_config['squelch'] = int(data['squelch'])
|
||||
updated.append(f"squelch={data['squelch']}")
|
||||
|
||||
if 'gain' in data:
|
||||
scanner_config['gain'] = int(data['gain'])
|
||||
updated.append(f"gain={data['gain']}")
|
||||
|
||||
if 'dwell_time' in data:
|
||||
scanner_config['dwell_time'] = int(data['dwell_time'])
|
||||
updated.append(f"dwell={data['dwell_time']}s")
|
||||
|
||||
if 'modulation' in data:
|
||||
scanner_config['modulation'] = str(data['modulation']).lower()
|
||||
updated.append(f"mod={data['modulation']}")
|
||||
|
||||
if updated:
|
||||
logger.info(f"Scanner config updated: {', '.join(updated)}")
|
||||
|
||||
return jsonify({
|
||||
'status': 'updated',
|
||||
'config': scanner_config
|
||||
})
|
||||
|
||||
|
||||
@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
|
||||
|
||||
logger.info("Audio start request received")
|
||||
|
||||
# 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))
|
||||
sdr_type = str(data.get('sdr_type', 'rtlsdr')).lower()
|
||||
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
|
||||
|
||||
valid_sdr_types = ['rtlsdr', 'hackrf', 'airspy', 'limesdr', 'sdrplay']
|
||||
if sdr_type not in valid_sdr_types:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Invalid sdr_type. Use: {", ".join(valid_sdr_types)}'
|
||||
}), 400
|
||||
|
||||
# Update config for audio
|
||||
scanner_config['squelch'] = squelch
|
||||
scanner_config['gain'] = gain
|
||||
scanner_config['device'] = device
|
||||
scanner_config['sdr_type'] = sdr_type
|
||||
|
||||
_start_audio_stream(frequency, modulation)
|
||||
|
||||
if audio_running:
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'frequency': frequency,
|
||||
'modulation': modulation
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to start audio. Check SDR device.'
|
||||
}), 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."""
|
||||
# Wait for audio to be ready (up to 2 seconds for modulation/squelch changes)
|
||||
for _ in range(40):
|
||||
if audio_running and audio_process:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
|
||||
if not audio_running or not audio_process:
|
||||
return Response(b'', mimetype='audio/mpeg', status=204)
|
||||
|
||||
def generate():
|
||||
try:
|
||||
while audio_running and audio_process and audio_process.poll() is None:
|
||||
# Use select to avoid blocking forever
|
||||
ready, _, _ = select.select([audio_process.stdout], [], [], 2.0)
|
||||
if ready:
|
||||
chunk = audio_process.stdout.read(4096)
|
||||
if chunk:
|
||||
yield chunk
|
||||
else:
|
||||
break
|
||||
except GeneratorExit:
|
||||
pass
|
||||
except:
|
||||
pass
|
||||
|
||||
return Response(
|
||||
generate(),
|
||||
mimetype='audio/mpeg',
|
||||
headers={
|
||||
'Content-Type': 'audio/mpeg',
|
||||
'Cache-Control': 'no-cache, no-store',
|
||||
'X-Accel-Buffering': 'no',
|
||||
'Transfer-Encoding': 'chunked',
|
||||
}
|
||||
)
|
||||
@@ -25,6 +25,7 @@ from utils.validation import (
|
||||
from utils.sse import format_sse
|
||||
from utils.process import safe_terminate, register_process
|
||||
from utils.sdr import SDRFactory, SDRType, SDRValidationError
|
||||
from utils.dependencies import get_tool_path
|
||||
|
||||
pager_bp = Blueprint('pager', __name__)
|
||||
|
||||
@@ -233,6 +234,7 @@ def start_decoding() -> Response:
|
||||
builder = SDRFactory.get_builder(sdr_device.sdr_type)
|
||||
|
||||
# Build FM demodulation command
|
||||
bias_t = data.get('bias_t', False)
|
||||
rtl_cmd = builder.build_fm_demod_command(
|
||||
device=sdr_device,
|
||||
frequency_mhz=freq,
|
||||
@@ -240,10 +242,14 @@ def start_decoding() -> Response:
|
||||
gain=float(gain) if gain and gain != '0' else None,
|
||||
ppm=int(ppm) if ppm and ppm != '0' else None,
|
||||
modulation='fm',
|
||||
squelch=squelch if squelch and squelch != 0 else None
|
||||
squelch=squelch if squelch and squelch != 0 else None,
|
||||
bias_t=bias_t
|
||||
)
|
||||
|
||||
multimon_cmd = ['multimon-ng', '-t', 'raw'] + decoders + ['-f', 'alpha', '-']
|
||||
multimon_path = get_tool_path('multimon-ng')
|
||||
if not multimon_path:
|
||||
return jsonify({'status': 'error', 'message': 'multimon-ng not found'}), 400
|
||||
multimon_cmd = [multimon_path, '-t', 'raw'] + decoders + ['-f', 'alpha', '-']
|
||||
|
||||
full_cmd = ' '.join(rtl_cmd) + ' | ' + ' '.join(multimon_cmd)
|
||||
logger.info(f"Running: {full_cmd}")
|
||||
|
||||
@@ -114,11 +114,13 @@ def start_sensor() -> Response:
|
||||
builder = SDRFactory.get_builder(sdr_device.sdr_type)
|
||||
|
||||
# Build ISM band decoder command
|
||||
bias_t = data.get('bias_t', False)
|
||||
cmd = builder.build_ism_command(
|
||||
device=sdr_device,
|
||||
frequency_mhz=freq,
|
||||
gain=float(gain) if gain and gain != 0 else None,
|
||||
ppm=int(ppm) if ppm and ppm != 0 else None
|
||||
ppm=int(ppm) if ppm and ppm != 0 else None,
|
||||
bias_t=bias_t
|
||||
)
|
||||
|
||||
full_cmd = ' '.join(cmd)
|
||||
@@ -128,8 +130,7 @@ def start_sensor() -> Response:
|
||||
app_module.sensor_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
bufsize=1
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
|
||||
# Start output thread
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
"""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_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('/<key>', 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('/<key>', 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('/<key>', 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
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 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
|
||||
@@ -16,12 +16,32 @@ from typing import Any, Generator
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
import app as app_module
|
||||
from utils.dependencies import check_tool
|
||||
from utils.dependencies import check_tool, get_tool_path
|
||||
from utils.logging import wifi_logger as logger
|
||||
from utils.process import is_valid_mac, is_valid_channel
|
||||
from utils.validation import validate_wifi_channel, validate_mac_address
|
||||
from utils.validation import validate_wifi_channel, validate_mac_address, validate_network_interface
|
||||
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()
|
||||
@@ -77,33 +105,144 @@ def detect_wifi_interfaces():
|
||||
current_iface = line.split()[1]
|
||||
elif current_iface and 'type' in line:
|
||||
iface_type = line.split()[-1]
|
||||
interfaces.append({
|
||||
iface_info = {
|
||||
'name': current_iface,
|
||||
'type': iface_type,
|
||||
'monitor_capable': True,
|
||||
'status': 'up'
|
||||
})
|
||||
'status': 'up',
|
||||
'driver': '',
|
||||
'chipset': '',
|
||||
'mac': ''
|
||||
}
|
||||
# Get additional interface details
|
||||
iface_info.update(_get_interface_details(current_iface))
|
||||
interfaces.append(iface_info)
|
||||
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]
|
||||
interfaces.append({
|
||||
iface_info = {
|
||||
'name': iface,
|
||||
'type': 'managed',
|
||||
'monitor_capable': True,
|
||||
'status': 'up'
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
'status': 'up',
|
||||
'driver': '',
|
||||
'chipset': '',
|
||||
'mac': ''
|
||||
}
|
||||
iface_info.update(_get_interface_details(iface))
|
||||
interfaces.append(iface_info)
|
||||
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
|
||||
|
||||
|
||||
def _get_interface_details(iface_name):
|
||||
"""Get additional details about a WiFi interface (driver, chipset, MAC)."""
|
||||
import os
|
||||
details = {'driver': '', 'chipset': '', 'mac': ''}
|
||||
|
||||
# Get MAC address
|
||||
try:
|
||||
mac_path = f'/sys/class/net/{iface_name}/address'
|
||||
with open(mac_path, 'r') as f:
|
||||
details['mac'] = f.read().strip().upper()
|
||||
except (FileNotFoundError, IOError):
|
||||
pass
|
||||
|
||||
# Get driver name
|
||||
try:
|
||||
driver_link = f'/sys/class/net/{iface_name}/device/driver'
|
||||
if os.path.islink(driver_link):
|
||||
driver_path = os.readlink(driver_link)
|
||||
details['driver'] = os.path.basename(driver_path)
|
||||
except (FileNotFoundError, IOError, OSError):
|
||||
pass
|
||||
|
||||
# Try airmon-ng first for chipset info (most reliable for WiFi adapters)
|
||||
try:
|
||||
result = subprocess.run(['airmon-ng'], capture_output=True, text=True, timeout=5)
|
||||
for line in result.stdout.split('\n'):
|
||||
# airmon-ng output format: PHY Interface Driver Chipset
|
||||
parts = line.split('\t')
|
||||
if len(parts) >= 4:
|
||||
if parts[1].strip() == iface_name or parts[1].strip().startswith(iface_name):
|
||||
if parts[2].strip():
|
||||
details['driver'] = parts[2].strip()
|
||||
if parts[3].strip():
|
||||
details['chipset'] = parts[3].strip()
|
||||
break
|
||||
# Also try space-separated format
|
||||
parts = line.split()
|
||||
if len(parts) >= 4:
|
||||
if parts[1] == iface_name or parts[1].startswith(iface_name):
|
||||
details['driver'] = parts[2]
|
||||
details['chipset'] = ' '.join(parts[3:])
|
||||
break
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
|
||||
pass
|
||||
|
||||
# Fallback: Get chipset info from USB or PCI sysfs
|
||||
if not details['chipset']:
|
||||
try:
|
||||
device_path = f'/sys/class/net/{iface_name}/device'
|
||||
if os.path.exists(device_path):
|
||||
# Try to get USB product name
|
||||
for usb_path in [f'{device_path}/product', f'{device_path}/../product']:
|
||||
try:
|
||||
with open(usb_path, 'r') as f:
|
||||
details['chipset'] = f.read().strip()
|
||||
break
|
||||
except (FileNotFoundError, IOError):
|
||||
pass
|
||||
|
||||
# If no USB product, try lsusb for USB devices
|
||||
if not details['chipset']:
|
||||
try:
|
||||
# Get USB bus/device info
|
||||
uevent_path = f'{device_path}/uevent'
|
||||
with open(uevent_path, 'r') as f:
|
||||
for line in f:
|
||||
if line.startswith('PRODUCT='):
|
||||
# PRODUCT format: vendor/product/bcdDevice
|
||||
product = line.split('=')[1].strip()
|
||||
parts = product.split('/')
|
||||
if len(parts) >= 2:
|
||||
vid = parts[0].zfill(4)
|
||||
pid = parts[1].zfill(4)
|
||||
# Try lsusb to get device name
|
||||
try:
|
||||
lsusb = subprocess.run(
|
||||
['lsusb', '-d', f'{vid}:{pid}'],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if lsusb.stdout:
|
||||
# Format: Bus XXX Device YYY: ID vid:pid Name
|
||||
usb_parts = lsusb.stdout.split(f'{vid}:{pid}')
|
||||
if len(usb_parts) > 1:
|
||||
details['chipset'] = usb_parts[1].strip()
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
break
|
||||
except (FileNotFoundError, IOError):
|
||||
pass
|
||||
except (FileNotFoundError, IOError, OSError):
|
||||
pass
|
||||
|
||||
return details
|
||||
|
||||
|
||||
def parse_airodump_csv(csv_path):
|
||||
"""Parse airodump-ng CSV output file."""
|
||||
networks = {}
|
||||
@@ -220,6 +359,20 @@ def stream_airodump_output(process, csv_path):
|
||||
'action': 'new',
|
||||
**client
|
||||
})
|
||||
else:
|
||||
# Send update if probes changed or signal changed significantly
|
||||
old_client = app_module.wifi_clients[mac]
|
||||
old_probes = old_client.get('probes', '')
|
||||
new_probes = client.get('probes', '')
|
||||
old_power = int(old_client.get('power', -100) or -100)
|
||||
new_power = int(client.get('power', -100) or -100)
|
||||
|
||||
if new_probes != old_probes or abs(new_power - old_power) >= 5:
|
||||
app_module.wifi_queue.put({
|
||||
'type': 'client',
|
||||
'action': 'update',
|
||||
**client
|
||||
})
|
||||
|
||||
app_module.wifi_networks = networks
|
||||
app_module.wifi_clients = clients
|
||||
@@ -270,11 +423,13 @@ def get_wifi_interfaces():
|
||||
def toggle_monitor_mode():
|
||||
"""Enable or disable monitor mode on an interface."""
|
||||
data = request.json
|
||||
interface = data.get('interface')
|
||||
action = data.get('action', 'start')
|
||||
|
||||
if not interface:
|
||||
return jsonify({'status': 'error', 'message': 'No interface specified'})
|
||||
# Validate interface name to prevent command injection
|
||||
try:
|
||||
interface = validate_network_interface(data.get('interface'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
if action == 'start':
|
||||
if check_tool('airmon-ng'):
|
||||
@@ -312,10 +467,11 @@ def toggle_monitor_mode():
|
||||
interfaces_before = get_wireless_interfaces()
|
||||
|
||||
kill_processes = data.get('kill_processes', False)
|
||||
airmon_path = get_tool_path('airmon-ng')
|
||||
if kill_processes:
|
||||
subprocess.run(['airmon-ng', 'check', 'kill'], capture_output=True, timeout=10)
|
||||
subprocess.run([airmon_path, 'check', 'kill'], capture_output=True, timeout=10)
|
||||
|
||||
result = subprocess.run(['airmon-ng', 'start', interface],
|
||||
result = subprocess.run([airmon_path, 'start', interface],
|
||||
capture_output=True, text=True, timeout=15)
|
||||
|
||||
output = result.stdout + result.stderr
|
||||
@@ -372,8 +528,35 @@ def toggle_monitor_mode():
|
||||
if not monitor_iface:
|
||||
monitor_iface = interface + 'mon'
|
||||
|
||||
# Verify the interface actually exists
|
||||
def interface_exists(iface_name):
|
||||
return os.path.exists(f'/sys/class/net/{iface_name}')
|
||||
|
||||
if not interface_exists(monitor_iface):
|
||||
# Try common naming patterns
|
||||
candidates = [
|
||||
interface + 'mon',
|
||||
interface.replace('wlan', 'wlan') + 'mon',
|
||||
'wlan0mon', 'wlan1mon',
|
||||
interface # Maybe it stayed the same but in monitor mode
|
||||
]
|
||||
for candidate in candidates:
|
||||
if interface_exists(candidate):
|
||||
monitor_iface = candidate
|
||||
break
|
||||
else:
|
||||
# List all wireless interfaces to help debug
|
||||
all_wireless = [f for f in os.listdir('/sys/class/net')
|
||||
if os.path.exists(f'/sys/class/net/{f}/wireless') or 'mon' in f or f.startswith('wl')]
|
||||
logger.error(f"Monitor interface not found. Tried: {monitor_iface}. Available: {all_wireless}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Monitor interface not created. airmon-ng output: {output[:500]}. Available interfaces: {all_wireless}'
|
||||
})
|
||||
|
||||
app_module.wifi_monitor_interface = monitor_iface
|
||||
app_module.wifi_queue.put({'type': 'info', 'text': f'Monitor mode enabled on {app_module.wifi_monitor_interface}'})
|
||||
logger.info(f"Monitor mode enabled on {monitor_iface}")
|
||||
return jsonify({'status': 'success', 'monitor_interface': app_module.wifi_monitor_interface})
|
||||
|
||||
except Exception as e:
|
||||
@@ -396,7 +579,8 @@ def toggle_monitor_mode():
|
||||
else: # stop
|
||||
if check_tool('airmon-ng'):
|
||||
try:
|
||||
subprocess.run(['airmon-ng', 'stop', app_module.wifi_monitor_interface or interface],
|
||||
airmon_path = get_tool_path('airmon-ng')
|
||||
subprocess.run([airmon_path, 'stop', app_module.wifi_monitor_interface or interface],
|
||||
capture_output=True, text=True, timeout=15)
|
||||
app_module.wifi_monitor_interface = None
|
||||
return jsonify({'status': 'success', 'message': 'Monitor mode disabled'})
|
||||
@@ -423,13 +607,31 @@ def start_wifi_scan():
|
||||
return jsonify({'status': 'error', 'message': 'Scan already running'})
|
||||
|
||||
data = request.json
|
||||
interface = data.get('interface') or app_module.wifi_monitor_interface
|
||||
channel = data.get('channel')
|
||||
band = data.get('band', 'abg')
|
||||
|
||||
# Use provided interface or fall back to stored monitor interface
|
||||
interface = data.get('interface')
|
||||
if interface:
|
||||
try:
|
||||
interface = validate_network_interface(interface)
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
else:
|
||||
interface = app_module.wifi_monitor_interface
|
||||
|
||||
if not interface:
|
||||
return jsonify({'status': 'error', 'message': 'No monitor interface available.'})
|
||||
|
||||
# Verify interface exists
|
||||
if not os.path.exists(f'/sys/class/net/{interface}'):
|
||||
all_wireless = [f for f in os.listdir('/sys/class/net')
|
||||
if os.path.exists(f'/sys/class/net/{f}/wireless') or 'mon' in f or f.startswith('wl')]
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Interface "{interface}" does not exist. Available: {all_wireless}'
|
||||
})
|
||||
|
||||
app_module.wifi_networks = {}
|
||||
app_module.wifi_clients = {}
|
||||
|
||||
@@ -447,8 +649,9 @@ def start_wifi_scan():
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
airodump_path = get_tool_path('airodump-ng')
|
||||
cmd = [
|
||||
'airodump-ng',
|
||||
airodump_path,
|
||||
'-w', csv_path,
|
||||
'--output-format', 'csv,pcap',
|
||||
'--band', band,
|
||||
@@ -479,11 +682,12 @@ def start_wifi_scan():
|
||||
error_msg = re.sub(r'\x1b\[[0-9;]*m', '', error_msg)
|
||||
|
||||
if 'No such device' in error_msg or 'No such interface' in error_msg:
|
||||
error_msg = f'Interface "{interface}" not found.'
|
||||
error_msg = f'Interface "{interface}" not found. Make sure monitor mode is enabled.'
|
||||
elif 'Operation not permitted' in error_msg:
|
||||
error_msg = 'Permission denied. Try running with sudo.'
|
||||
|
||||
return jsonify({'status': 'error', 'message': error_msg})
|
||||
logger.error(f"airodump-ng failed for interface '{interface}': {error_msg}")
|
||||
return jsonify({'status': 'error', 'message': error_msg, 'interface': interface})
|
||||
|
||||
thread = threading.Thread(target=stream_airodump_output, args=(app_module.wifi_process, csv_path))
|
||||
thread.daemon = True
|
||||
@@ -521,7 +725,16 @@ def send_deauth():
|
||||
target_bssid = data.get('bssid')
|
||||
target_client = data.get('client', 'FF:FF:FF:FF:FF:FF')
|
||||
count = data.get('count', 5)
|
||||
interface = data.get('interface') or app_module.wifi_monitor_interface
|
||||
|
||||
# Validate interface
|
||||
interface = data.get('interface')
|
||||
if interface:
|
||||
try:
|
||||
interface = validate_network_interface(interface)
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
else:
|
||||
interface = app_module.wifi_monitor_interface
|
||||
|
||||
if not target_bssid:
|
||||
return jsonify({'status': 'error', 'message': 'Target BSSID required'})
|
||||
@@ -546,8 +759,9 @@ def send_deauth():
|
||||
return jsonify({'status': 'error', 'message': 'aireplay-ng not found'})
|
||||
|
||||
try:
|
||||
aireplay_path = get_tool_path('aireplay-ng')
|
||||
cmd = [
|
||||
'aireplay-ng',
|
||||
aireplay_path,
|
||||
'--deauth', str(count),
|
||||
'-a', target_bssid,
|
||||
'-c', target_client,
|
||||
@@ -575,7 +789,16 @@ def capture_handshake():
|
||||
data = request.json
|
||||
target_bssid = data.get('bssid')
|
||||
channel = data.get('channel')
|
||||
interface = data.get('interface') or app_module.wifi_monitor_interface
|
||||
|
||||
# Validate interface
|
||||
interface = data.get('interface')
|
||||
if interface:
|
||||
try:
|
||||
interface = validate_network_interface(interface)
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
else:
|
||||
interface = app_module.wifi_monitor_interface
|
||||
|
||||
if not target_bssid or not channel:
|
||||
return jsonify({'status': 'error', 'message': 'BSSID and channel required'})
|
||||
@@ -592,8 +815,9 @@ def capture_handshake():
|
||||
|
||||
capture_path = f'/tmp/intercept_handshake_{target_bssid.replace(":", "")}'
|
||||
|
||||
airodump_path = get_tool_path('airodump-ng')
|
||||
cmd = [
|
||||
'airodump-ng',
|
||||
airodump_path,
|
||||
'-c', str(channel),
|
||||
'--bssid', target_bssid,
|
||||
'-w', capture_path,
|
||||
@@ -631,14 +855,16 @@ def check_handshake_status():
|
||||
|
||||
try:
|
||||
if target_bssid and is_valid_mac(target_bssid):
|
||||
result = subprocess.run(
|
||||
['aircrack-ng', '-a', '2', '-b', target_bssid, capture_file],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
output = result.stdout + result.stderr
|
||||
if '1 handshake' in output or ('handshake' in output.lower() and 'wpa' in output.lower()):
|
||||
if '0 handshake' not in output:
|
||||
handshake_found = True
|
||||
aircrack_path = get_tool_path('aircrack-ng')
|
||||
if aircrack_path:
|
||||
result = subprocess.run(
|
||||
[aircrack_path, '-a', '2', '-b', target_bssid, capture_file],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
output = result.stdout + result.stderr
|
||||
if '1 handshake' in output or ('handshake' in output.lower() and 'wpa' in output.lower()):
|
||||
if '0 handshake' not in output:
|
||||
handshake_found = True
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
except Exception as e:
|
||||
@@ -661,7 +887,16 @@ def capture_pmkid():
|
||||
data = request.json
|
||||
target_bssid = data.get('bssid')
|
||||
channel = data.get('channel')
|
||||
interface = data.get('interface') or app_module.wifi_monitor_interface
|
||||
|
||||
# Validate interface
|
||||
interface = data.get('interface')
|
||||
if interface:
|
||||
try:
|
||||
interface = validate_network_interface(interface)
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
else:
|
||||
interface = app_module.wifi_monitor_interface
|
||||
|
||||
if not target_bssid:
|
||||
return jsonify({'status': 'error', 'message': 'BSSID required'})
|
||||
@@ -752,6 +987,83 @@ def stop_pmkid():
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@wifi_bp.route('/handshake/crack', methods=['POST'])
|
||||
def crack_handshake():
|
||||
"""Crack a captured handshake using aircrack-ng."""
|
||||
data = request.json
|
||||
capture_file = data.get('capture_file', '')
|
||||
target_bssid = data.get('bssid', '')
|
||||
wordlist = data.get('wordlist', '')
|
||||
|
||||
# Validate paths to prevent path traversal
|
||||
if not capture_file.startswith('/tmp/intercept_handshake_') or '..' in capture_file:
|
||||
return jsonify({'status': 'error', 'message': 'Invalid capture file path'}), 400
|
||||
|
||||
if '..' in wordlist:
|
||||
return jsonify({'status': 'error', 'message': 'Invalid wordlist path'}), 400
|
||||
|
||||
if not os.path.exists(capture_file):
|
||||
return jsonify({'status': 'error', 'message': 'Capture file not found'}), 404
|
||||
|
||||
if not os.path.exists(wordlist):
|
||||
return jsonify({'status': 'error', 'message': 'Wordlist file not found'}), 404
|
||||
|
||||
if target_bssid and not is_valid_mac(target_bssid):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid BSSID format'}), 400
|
||||
|
||||
aircrack_path = get_tool_path('aircrack-ng')
|
||||
if not aircrack_path:
|
||||
return jsonify({'status': 'error', 'message': 'aircrack-ng not found'}), 500
|
||||
|
||||
try:
|
||||
cmd = [aircrack_path, '-a', '2', '-w', wordlist]
|
||||
if target_bssid:
|
||||
cmd.extend(['-b', target_bssid])
|
||||
cmd.append(capture_file)
|
||||
|
||||
logger.info(f"Starting aircrack-ng: {' '.join(cmd)}")
|
||||
|
||||
# Run aircrack-ng with a timeout (this could take a while)
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300 # 5 minute timeout
|
||||
)
|
||||
|
||||
output = result.stdout + result.stderr
|
||||
|
||||
# Check if password was found
|
||||
# Aircrack-ng outputs "KEY FOUND! [ password ]" when successful
|
||||
if 'KEY FOUND!' in output:
|
||||
# Extract the password
|
||||
import re
|
||||
match = re.search(r'KEY FOUND!\s*\[\s*(.+?)\s*\]', output)
|
||||
if match:
|
||||
password = match.group(1)
|
||||
logger.info(f"Password cracked for {target_bssid}: {password}")
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'password': password,
|
||||
'bssid': target_bssid
|
||||
})
|
||||
|
||||
# Password not found
|
||||
return jsonify({
|
||||
'status': 'not_found',
|
||||
'message': 'Password not in wordlist'
|
||||
})
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return jsonify({
|
||||
'status': 'timeout',
|
||||
'message': 'Cracking timed out after 5 minutes. Try a smaller wordlist or use hashcat.'
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Crack error: {e}")
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
|
||||
@wifi_bp.route('/networks')
|
||||
def get_wifi_networks():
|
||||
"""Get current list of discovered networks."""
|
||||
|
||||
@@ -1,18 +1,60 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# INTERCEPT Setup Script
|
||||
# Installs Python dependencies and checks for external tools
|
||||
#
|
||||
#!/usr/bin/env bash
|
||||
# INTERCEPT Setup Script (best-effort installs, hard-fail verification)
|
||||
|
||||
set -e
|
||||
# ---- Force bash even if launched with sh ----
|
||||
if [ -z "${BASH_VERSION:-}" ]; then
|
||||
echo "[x] This script must be run with bash (not sh)."
|
||||
echo " Run: bash $0"
|
||||
exec bash "$0" "$@"
|
||||
fi
|
||||
|
||||
# Colors for output
|
||||
set -Eeuo pipefail
|
||||
|
||||
# Ensure admin paths are searchable (many tools live here)
|
||||
export PATH="/usr/local/sbin:/usr/sbin:/sbin:/opt/homebrew/sbin:/opt/homebrew/bin:$PATH"
|
||||
|
||||
# ----------------------------
|
||||
# Pretty output
|
||||
# ----------------------------
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
NC='\033[0m'
|
||||
|
||||
info() { echo -e "${BLUE}[*]${NC} $*"; }
|
||||
ok() { echo -e "${GREEN}[✓]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
|
||||
fail() { echo -e "${RED}[x]${NC} $*"; }
|
||||
|
||||
# ----------------------------
|
||||
# Progress tracking
|
||||
# ----------------------------
|
||||
CURRENT_STEP=0
|
||||
TOTAL_STEPS=0
|
||||
|
||||
progress() {
|
||||
local msg="$1"
|
||||
((CURRENT_STEP++)) || true
|
||||
local pct=$((CURRENT_STEP * 100 / TOTAL_STEPS))
|
||||
local filled=$((pct / 5))
|
||||
local empty=$((20 - filled))
|
||||
local bar=$(printf '█%.0s' $(seq 1 $filled 2>/dev/null) || true)
|
||||
bar+=$(printf '░%.0s' $(seq 1 $empty 2>/dev/null) || true)
|
||||
echo -e "${BLUE}[${CURRENT_STEP}/${TOTAL_STEPS}]${NC} ${bar} ${pct}% - ${msg}"
|
||||
}
|
||||
|
||||
on_error() {
|
||||
local line="$1"
|
||||
local cmd="${2:-unknown}"
|
||||
fail "Setup failed at line ${line}: ${cmd}"
|
||||
exit 1
|
||||
}
|
||||
trap 'on_error $LINENO "$BASH_COMMAND"' ERR
|
||||
|
||||
# ----------------------------
|
||||
# Banner
|
||||
# ----------------------------
|
||||
echo -e "${BLUE}"
|
||||
echo " ___ _ _ _____ _____ ____ ____ _____ ____ _____ "
|
||||
echo " |_ _| \\ | |_ _| ____| _ \\ / ___| ____| _ \\_ _|"
|
||||
@@ -20,268 +62,558 @@ echo " | || \\| | | | | _| | |_) | | | _| | |_) || | "
|
||||
echo " | || |\\ | | | | |___| _ <| |___| |___| __/ | | "
|
||||
echo " |___|_| \\_| |_| |_____|_| \\_\\\\____|_____|_| |_| "
|
||||
echo -e "${NC}"
|
||||
echo "Signal Intelligence Platform - Setup Script"
|
||||
echo "INTERCEPT - Setup Script"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
echo
|
||||
|
||||
# ----------------------------
|
||||
# Helpers
|
||||
# ----------------------------
|
||||
cmd_exists() {
|
||||
local c="$1"
|
||||
command -v "$c" >/dev/null 2>&1 && return 0
|
||||
[[ -x "/usr/sbin/$c" || -x "/sbin/$c" || -x "/usr/local/sbin/$c" || -x "/opt/homebrew/sbin/$c" ]] && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
have_any() {
|
||||
local c
|
||||
for c in "$@"; do
|
||||
cmd_exists "$c" && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
need_sudo() {
|
||||
if [[ "$(id -u)" -eq 0 ]]; then
|
||||
SUDO=""
|
||||
ok "Running as root"
|
||||
else
|
||||
if cmd_exists sudo; then
|
||||
SUDO="sudo"
|
||||
else
|
||||
fail "sudo is not installed and you're not root."
|
||||
echo "Either run as root or install sudo first."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Detect OS
|
||||
detect_os() {
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
OS="macos"
|
||||
PKG_MANAGER="brew"
|
||||
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)"
|
||||
if [[ "${OSTYPE:-}" == "darwin"* ]]; then
|
||||
OS="macos"
|
||||
elif [[ -f /etc/debian_version ]]; then
|
||||
OS="debian"
|
||||
else
|
||||
OS="unknown"
|
||||
fi
|
||||
info "Detected OS: ${OS}"
|
||||
[[ "$OS" != "unknown" ]] || { fail "Unsupported OS (macOS + Debian/Ubuntu only)."; exit 1; }
|
||||
}
|
||||
|
||||
# Check if a command exists
|
||||
check_cmd() {
|
||||
command -v "$1" &> /dev/null
|
||||
# ----------------------------
|
||||
# Required tool checks (with alternates)
|
||||
# ----------------------------
|
||||
missing_required=()
|
||||
|
||||
check_required() {
|
||||
local label="$1"; shift
|
||||
local desc="$1"; shift
|
||||
|
||||
if have_any "$@"; then
|
||||
ok "${label} - ${desc}"
|
||||
else
|
||||
warn "${label} - ${desc} (missing, required)"
|
||||
missing_required+=("$label")
|
||||
fi
|
||||
}
|
||||
|
||||
# Install 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"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check Python version (need 3.9+)
|
||||
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:"
|
||||
echo " Ubuntu/Debian: sudo apt install python3.11"
|
||||
echo " macOS: brew install python@3.11"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if we're in a virtual environment
|
||||
if [ -n "$VIRTUAL_ENV" ]; then
|
||||
echo "Using virtual environment: $VIRTUAL_ENV"
|
||||
pip install -r requirements.txt
|
||||
elif [ -d "venv" ]; then
|
||||
echo "Found existing venv, activating..."
|
||||
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..."
|
||||
if python3 -m pip install -r requirements.txt 2>/dev/null; then
|
||||
echo -e "${GREEN}Python dependencies installed successfully${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..."
|
||||
python3 -m venv venv
|
||||
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"
|
||||
echo " sudo venv/bin/python intercept.py"
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}Python dependencies installed successfully${NC}"
|
||||
}
|
||||
|
||||
# Check external tools
|
||||
check_tools() {
|
||||
echo ""
|
||||
echo -e "${BLUE}[2/3] Checking external tools...${NC}"
|
||||
echo ""
|
||||
info "Checking required tools..."
|
||||
missing_required=()
|
||||
|
||||
MISSING_TOOLS=()
|
||||
echo
|
||||
info "Core SDR:"
|
||||
check_required "rtl_fm" "RTL-SDR FM demodulator" rtl_fm
|
||||
check_required "rtl_test" "RTL-SDR device detection" rtl_test
|
||||
check_required "multimon-ng" "Pager decoder" multimon-ng
|
||||
check_required "rtl_433" "433MHz sensor decoder" rtl_433 rtl433
|
||||
check_required "dump1090" "ADS-B decoder" dump1090
|
||||
check_required "acarsdec" "ACARS decoder" acarsdec
|
||||
|
||||
# Core SDR tools
|
||||
echo "Core SDR Tools:"
|
||||
check_tool "rtl_fm" "RTL-SDR FM demodulator"
|
||||
check_tool "rtl_test" "RTL-SDR device detection"
|
||||
check_tool "multimon-ng" "Pager decoder"
|
||||
check_tool "rtl_433" "433MHz sensor decoder"
|
||||
check_tool "dump1090" "ADS-B decoder"
|
||||
echo
|
||||
info "GPS:"
|
||||
check_required "gpsd" "GPS daemon" gpsd
|
||||
|
||||
echo ""
|
||||
echo "Additional SDR Hardware (optional):"
|
||||
check_tool "SoapySDRUtil" "SoapySDR (for LimeSDR/HackRF)"
|
||||
check_tool "LimeUtil" "LimeSDR tools"
|
||||
check_tool "hackrf_info" "HackRF tools"
|
||||
echo
|
||||
info "Audio:"
|
||||
check_required "ffmpeg" "Audio encoder/decoder" ffmpeg
|
||||
|
||||
echo ""
|
||||
echo "WiFi Tools:"
|
||||
check_tool "airmon-ng" "WiFi monitor mode"
|
||||
check_tool "airodump-ng" "WiFi scanner"
|
||||
echo
|
||||
info "WiFi:"
|
||||
check_required "airmon-ng" "Monitor mode helper" airmon-ng
|
||||
check_required "airodump-ng" "WiFi scanner" airodump-ng
|
||||
check_required "aireplay-ng" "Injection/deauth" aireplay-ng
|
||||
check_required "hcxdumptool" "PMKID capture" hcxdumptool
|
||||
check_required "hcxpcapngtool" "PMKID/pcapng conversion" hcxpcapngtool
|
||||
|
||||
echo ""
|
||||
echo "Bluetooth Tools:"
|
||||
check_tool "bluetoothctl" "Bluetooth controller"
|
||||
check_tool "hcitool" "Bluetooth HCI tool"
|
||||
echo
|
||||
info "Bluetooth:"
|
||||
check_required "bluetoothctl" "Bluetooth controller CLI" bluetoothctl
|
||||
check_required "hcitool" "Bluetooth scan utility" hcitool
|
||||
check_required "hciconfig" "Bluetooth adapter config" hciconfig
|
||||
|
||||
if [ ${#MISSING_TOOLS[@]} -gt 0 ]; then
|
||||
echo ""
|
||||
echo -e "${YELLOW}Some tools are missing. See installation instructions below.${NC}"
|
||||
fi
|
||||
echo
|
||||
info "SoapySDR:"
|
||||
check_required "SoapySDRUtil" "SoapySDR CLI utility" SoapySDRUtil
|
||||
echo
|
||||
}
|
||||
|
||||
check_tool() {
|
||||
local cmd=$1
|
||||
local desc=$2
|
||||
if check_cmd "$cmd"; then
|
||||
echo -e " ${GREEN}✓${NC} $cmd - $desc"
|
||||
# ----------------------------
|
||||
# Python venv + deps
|
||||
# ----------------------------
|
||||
check_python_version() {
|
||||
if ! cmd_exists python3; then
|
||||
fail "python3 not found."
|
||||
[[ "$OS" == "macos" ]] && echo "Install with: brew install python"
|
||||
[[ "$OS" == "debian" ]] && echo "Install with: sudo apt-get install python3"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local ver
|
||||
ver="$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')"
|
||||
info "Python version: ${ver}"
|
||||
|
||||
python3 - <<'PY'
|
||||
import sys
|
||||
raise SystemExit(0 if sys.version_info >= (3,9) else 1)
|
||||
PY
|
||||
ok "Python version OK (>= 3.9)"
|
||||
}
|
||||
|
||||
install_python_deps() {
|
||||
progress "Setting up Python environment"
|
||||
check_python_version
|
||||
|
||||
if [[ ! -f requirements.txt ]]; then
|
||||
warn "requirements.txt not found; skipping Python dependency install."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# On Debian/Ubuntu, try apt packages first as they're more reliable
|
||||
if [[ "$OS" == "debian" ]]; then
|
||||
info "Installing Python packages via apt (more reliable on Debian/Ubuntu)..."
|
||||
$SUDO apt-get install -y python3-flask python3-requests python3-serial >/dev/null 2>&1 || true
|
||||
|
||||
# skyfield may not be available in all distros, try apt first then pip
|
||||
if ! $SUDO apt-get install -y python3-skyfield >/dev/null 2>&1; then
|
||||
warn "python3-skyfield not in apt, will try pip later"
|
||||
fi
|
||||
ok "Installed available Python packages via apt"
|
||||
fi
|
||||
|
||||
if [[ ! -d venv ]]; then
|
||||
python3 -m venv --system-site-packages venv
|
||||
ok "Created venv/ (with system site-packages)"
|
||||
else
|
||||
ok "Using existing venv/"
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
source venv/bin/activate
|
||||
|
||||
python -m pip install --upgrade pip setuptools wheel >/dev/null 2>&1 || true
|
||||
ok "Upgraded pip tooling"
|
||||
|
||||
progress "Installing Python dependencies"
|
||||
# Try pip install, but don't fail if apt packages already satisfied deps
|
||||
if ! python -m pip install -r requirements.txt 2>/dev/null; then
|
||||
warn "Some pip packages failed - checking if apt packages cover them..."
|
||||
# Verify critical packages are available
|
||||
python -c "import flask; import requests" 2>/dev/null || {
|
||||
fail "Critical Python packages (flask, requests) not installed"
|
||||
echo "Try: sudo apt install python3-flask python3-requests"
|
||||
exit 1
|
||||
}
|
||||
ok "Core Python dependencies available"
|
||||
else
|
||||
ok "Python dependencies installed"
|
||||
fi
|
||||
echo
|
||||
}
|
||||
|
||||
# ----------------------------
|
||||
# macOS install (Homebrew)
|
||||
# ----------------------------
|
||||
ensure_brew() {
|
||||
cmd_exists brew && return 0
|
||||
warn "Homebrew not found. Installing Homebrew..."
|
||||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||
|
||||
if [[ -x /opt/homebrew/bin/brew ]]; then
|
||||
eval "$(/opt/homebrew/bin/brew shellenv)"
|
||||
elif [[ -x /usr/local/bin/brew ]]; then
|
||||
eval "$(/usr/local/bin/brew shellenv)"
|
||||
fi
|
||||
|
||||
cmd_exists brew || { fail "Homebrew install failed. Install manually then re-run."; exit 1; }
|
||||
}
|
||||
|
||||
brew_install() {
|
||||
local pkg="$1"
|
||||
if brew list --formula "$pkg" >/dev/null 2>&1; then
|
||||
ok "brew: ${pkg} already installed"
|
||||
return 0
|
||||
fi
|
||||
info "brew: installing ${pkg}..."
|
||||
if brew install "$pkg" 2>&1; then
|
||||
ok "brew: installed ${pkg}"
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
install_multimon_ng_from_source_macos() {
|
||||
info "multimon-ng not available via Homebrew. Building from source..."
|
||||
|
||||
# Ensure build dependencies are installed
|
||||
brew_install cmake
|
||||
brew_install libsndfile
|
||||
|
||||
(
|
||||
tmp_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
|
||||
info "Cloning multimon-ng..."
|
||||
git clone --depth 1 https://github.com/EliasOewornal/multimon-ng.git "$tmp_dir/multimon-ng" >/dev/null 2>&1 \
|
||||
|| { fail "Failed to clone multimon-ng"; exit 1; }
|
||||
|
||||
cd "$tmp_dir/multimon-ng"
|
||||
info "Compiling multimon-ng..."
|
||||
mkdir -p build && cd build
|
||||
cmake .. >/dev/null 2>&1 || { fail "cmake failed for multimon-ng"; exit 1; }
|
||||
make >/dev/null 2>&1 || { fail "make failed for multimon-ng"; exit 1; }
|
||||
|
||||
# Install to /usr/local/bin (no sudo needed on Homebrew systems typically)
|
||||
if [[ -w /usr/local/bin ]]; then
|
||||
install -m 0755 multimon-ng /usr/local/bin/multimon-ng
|
||||
else
|
||||
echo -e " ${RED}✗${NC} $cmd - $desc ${YELLOW}(not found)${NC}"
|
||||
MISSING_TOOLS+=("$cmd")
|
||||
sudo install -m 0755 multimon-ng /usr/local/bin/multimon-ng
|
||||
fi
|
||||
ok "multimon-ng installed successfully from source"
|
||||
)
|
||||
}
|
||||
|
||||
# Show installation instructions
|
||||
show_install_instructions() {
|
||||
echo ""
|
||||
echo -e "${BLUE}[3/3] Installation instructions for missing tools${NC}"
|
||||
echo ""
|
||||
install_macos_packages() {
|
||||
TOTAL_STEPS=13
|
||||
CURRENT_STEP=0
|
||||
|
||||
if [ ${#MISSING_TOOLS[@]} -eq 0 ]; then
|
||||
echo -e "${GREEN}All tools are installed!${NC}"
|
||||
return
|
||||
progress "Checking Homebrew"
|
||||
ensure_brew
|
||||
|
||||
progress "Installing RTL-SDR libraries"
|
||||
brew_install librtlsdr
|
||||
|
||||
progress "Installing multimon-ng"
|
||||
# multimon-ng is not in Homebrew core, so build from source
|
||||
if ! cmd_exists multimon-ng; then
|
||||
install_multimon_ng_from_source_macos
|
||||
else
|
||||
ok "multimon-ng already installed"
|
||||
fi
|
||||
|
||||
progress "Installing ffmpeg"
|
||||
brew_install ffmpeg
|
||||
|
||||
progress "Installing rtl_433"
|
||||
brew_install rtl_433
|
||||
|
||||
progress "Installing dump1090"
|
||||
(brew_install dump1090-mutability) || warn "dump1090 not available via Homebrew"
|
||||
|
||||
progress "Installing acarsdec"
|
||||
(brew_install acarsdec) || warn "acarsdec not available via Homebrew"
|
||||
|
||||
progress "Installing aircrack-ng"
|
||||
brew_install aircrack-ng
|
||||
|
||||
progress "Installing hcxtools"
|
||||
brew_install hcxtools
|
||||
|
||||
progress "Installing SoapySDR"
|
||||
brew_install soapysdr
|
||||
|
||||
progress "Installing gpsd"
|
||||
brew_install gpsd
|
||||
|
||||
warn "macOS note: hcitool/hciconfig are Linux (BlueZ) utilities and often unavailable on macOS."
|
||||
info "TSCM BLE scanning uses bleak library (installed via pip) for manufacturer data detection."
|
||||
echo
|
||||
}
|
||||
|
||||
# ----------------------------
|
||||
# Debian/Ubuntu install (APT)
|
||||
# ----------------------------
|
||||
apt_install() {
|
||||
local pkgs="$*"
|
||||
local output
|
||||
local ret=0
|
||||
output=$($SUDO apt-get install -y --no-install-recommends "$@" 2>&1) || ret=$?
|
||||
if [[ $ret -ne 0 ]]; then
|
||||
fail "Failed to install: $pkgs"
|
||||
echo "$output" | tail -10
|
||||
fail "Try running: sudo apt-get update && sudo apt-get install -y $pkgs"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
apt_try_install_any() {
|
||||
local p
|
||||
for p in "$@"; do
|
||||
if $SUDO apt-get install -y --no-install-recommends "$p" >/dev/null 2>&1; then
|
||||
ok "apt: installed ${p}"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
install_dump1090_from_source_debian() {
|
||||
info "dump1090 not available via APT. Building from source (required)..."
|
||||
|
||||
apt_install build-essential git pkg-config \
|
||||
librtlsdr-dev libusb-1.0-0-dev \
|
||||
libncurses-dev tcl-dev python3-dev
|
||||
|
||||
# Run in subshell to isolate EXIT trap
|
||||
(
|
||||
tmp_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
|
||||
info "Cloning FlightAware dump1090..."
|
||||
git clone --depth 1 https://github.com/flightaware/dump1090.git "$tmp_dir/dump1090" >/dev/null 2>&1 \
|
||||
|| { fail "Failed to clone FlightAware dump1090"; exit 1; }
|
||||
|
||||
cd "$tmp_dir/dump1090"
|
||||
info "Compiling FlightAware dump1090..."
|
||||
if make BLADERF=no RTLSDR=yes >/dev/null 2>&1; then
|
||||
$SUDO install -m 0755 dump1090 /usr/local/bin/dump1090
|
||||
ok "dump1090 installed successfully (FlightAware)."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Run the following commands to install missing tools:"
|
||||
echo ""
|
||||
warn "FlightAware build failed. Falling back to antirez/dump1090..."
|
||||
rm -rf "$tmp_dir/dump1090"
|
||||
git clone --depth 1 https://github.com/antirez/dump1090.git "$tmp_dir/dump1090" >/dev/null 2>&1 \
|
||||
|| { fail "Failed to clone antirez dump1090"; exit 1; }
|
||||
|
||||
cd "$tmp_dir/dump1090"
|
||||
info "Compiling antirez dump1090..."
|
||||
make >/dev/null 2>&1 || { fail "Failed to build dump1090 from source (required)."; exit 1; }
|
||||
|
||||
$SUDO install -m 0755 dump1090 /usr/local/bin/dump1090
|
||||
ok "dump1090 installed successfully (antirez)."
|
||||
)
|
||||
}
|
||||
|
||||
install_acarsdec_from_source_debian() {
|
||||
info "acarsdec not available via APT. Building from source..."
|
||||
|
||||
apt_install build-essential git cmake \
|
||||
librtlsdr-dev libusb-1.0-0-dev libsndfile1-dev
|
||||
|
||||
# Run in subshell to isolate EXIT trap
|
||||
(
|
||||
tmp_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
|
||||
info "Cloning acarsdec..."
|
||||
git clone --depth 1 https://github.com/TLeconte/acarsdec.git "$tmp_dir/acarsdec" >/dev/null 2>&1 \
|
||||
|| { warn "Failed to clone acarsdec"; exit 1; }
|
||||
|
||||
cd "$tmp_dir/acarsdec"
|
||||
mkdir -p build && cd build
|
||||
|
||||
info "Compiling acarsdec..."
|
||||
if cmake .. -Drtl=ON >/dev/null 2>&1 && make >/dev/null 2>&1; then
|
||||
$SUDO install -m 0755 acarsdec /usr/local/bin/acarsdec
|
||||
ok "acarsdec installed successfully."
|
||||
else
|
||||
warn "Failed to build acarsdec from source. ACARS decoding will not be available."
|
||||
fi
|
||||
)
|
||||
}
|
||||
|
||||
setup_udev_rules_debian() {
|
||||
[[ -d /etc/udev/rules.d ]] || { warn "udev not found; skipping RTL-SDR udev rules."; return 0; }
|
||||
|
||||
local rules_file="/etc/udev/rules.d/20-rtlsdr.rules"
|
||||
[[ -f "$rules_file" ]] && { ok "RTL-SDR udev rules already present: $rules_file"; return 0; }
|
||||
|
||||
info "Installing RTL-SDR udev rules..."
|
||||
$SUDO tee "$rules_file" >/dev/null <<'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 || true
|
||||
$SUDO udevadm trigger || true
|
||||
ok "udev rules installed. Unplug/replug your RTL-SDR if connected."
|
||||
echo
|
||||
}
|
||||
|
||||
blacklist_kernel_drivers_debian() {
|
||||
local blacklist_file="/etc/modprobe.d/blacklist-rtlsdr.conf"
|
||||
|
||||
if [[ -f "$blacklist_file" ]]; then
|
||||
ok "RTL-SDR kernel driver blacklist already present"
|
||||
return 0
|
||||
fi
|
||||
|
||||
info "Blacklisting conflicting DVB kernel drivers..."
|
||||
$SUDO tee "$blacklist_file" >/dev/null <<'EOF'
|
||||
# Blacklist DVB-T drivers to allow rtl-sdr to access RTL2832U devices
|
||||
blacklist dvb_usb_rtl28xxu
|
||||
blacklist rtl2832
|
||||
blacklist rtl2830
|
||||
blacklist r820t
|
||||
EOF
|
||||
|
||||
# Unload modules if currently loaded
|
||||
for mod in dvb_usb_rtl28xxu rtl2832 rtl2830 r820t; do
|
||||
if lsmod | grep -q "^$mod"; then
|
||||
$SUDO modprobe -r "$mod" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
ok "Kernel drivers blacklisted. Unplug/replug your RTL-SDR if connected."
|
||||
echo
|
||||
}
|
||||
|
||||
install_debian_packages() {
|
||||
need_sudo
|
||||
|
||||
# Suppress needrestart prompts (Ubuntu Server 22.04+)
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
export NEEDRESTART_MODE=a
|
||||
|
||||
TOTAL_STEPS=17
|
||||
CURRENT_STEP=0
|
||||
|
||||
progress "Updating APT package lists"
|
||||
$SUDO apt-get update -y >/dev/null
|
||||
|
||||
progress "Installing RTL-SDR"
|
||||
apt_install rtl-sdr
|
||||
|
||||
progress "Installing multimon-ng"
|
||||
apt_install multimon-ng
|
||||
|
||||
progress "Installing ffmpeg"
|
||||
apt_install ffmpeg
|
||||
|
||||
progress "Installing rtl_433"
|
||||
apt_try_install_any rtl-433 rtl433 || warn "rtl-433 not available"
|
||||
|
||||
progress "Installing aircrack-ng"
|
||||
apt_install aircrack-ng || true
|
||||
|
||||
progress "Installing hcxdumptool"
|
||||
apt_install hcxdumptool || true
|
||||
|
||||
progress "Installing hcxtools"
|
||||
apt_install hcxtools || true
|
||||
|
||||
progress "Installing Bluetooth tools"
|
||||
apt_install bluez bluetooth || true
|
||||
|
||||
progress "Installing SoapySDR"
|
||||
# Exclude xtrx-dkms - its kernel module fails to build on newer kernels (6.14+)
|
||||
# and causes apt to hang. Most users don't have XTRX hardware anyway.
|
||||
apt_install soapysdr-tools xtrx-dkms- || true
|
||||
|
||||
progress "Installing gpsd"
|
||||
apt_install gpsd gpsd-clients || true
|
||||
|
||||
progress "Installing Python packages"
|
||||
apt_install python3-venv python3-pip || true
|
||||
# Install Python packages via apt (more reliable than pip on modern Debian/Ubuntu)
|
||||
$SUDO apt-get install -y python3-flask python3-requests python3-serial >/dev/null 2>&1 || true
|
||||
$SUDO apt-get install -y python3-skyfield >/dev/null 2>&1 || true
|
||||
# bleak for BLE scanning with manufacturer data (TSCM mode)
|
||||
$SUDO apt-get install -y python3-bleak >/dev/null 2>&1 || true
|
||||
|
||||
progress "Installing dump1090"
|
||||
if ! cmd_exists dump1090 && ! cmd_exists dump1090-mutability; then
|
||||
#export DEBIAN_FRONTEND=noninteractive
|
||||
apt_try_install_any dump1090-fa dump1090-mutability dump1090 || true
|
||||
fi
|
||||
if ! cmd_exists dump1090; then
|
||||
if cmd_exists dump1090-mutability; then
|
||||
$SUDO ln -s $(which dump1090-mutability) /usr/local/sbin/dump1090
|
||||
fi
|
||||
fi
|
||||
cmd_exists dump1090 || install_dump1090_from_source_debian
|
||||
|
||||
progress "Installing acarsdec"
|
||||
if ! cmd_exists acarsdec; then
|
||||
apt_install acarsdec || true
|
||||
fi
|
||||
cmd_exists acarsdec || install_acarsdec_from_source_debian
|
||||
|
||||
progress "Configuring udev rules"
|
||||
setup_udev_rules_debian
|
||||
|
||||
progress "Blacklisting conflicting kernel drivers"
|
||||
blacklist_kernel_drivers_debian
|
||||
}
|
||||
|
||||
# ----------------------------
|
||||
# Final summary / hard fail
|
||||
# ----------------------------
|
||||
final_summary_and_hard_fail() {
|
||||
check_tools
|
||||
|
||||
echo "============================================"
|
||||
echo
|
||||
echo "To start INTERCEPT:"
|
||||
echo " sudo -E venv/bin/python intercept.py"
|
||||
echo
|
||||
echo "Then open http://localhost:5050 in your browser"
|
||||
echo
|
||||
echo "============================================"
|
||||
|
||||
if [[ "${#missing_required[@]}" -eq 0 ]]; then
|
||||
ok "All REQUIRED tools are installed."
|
||||
else
|
||||
fail "Missing REQUIRED tools:"
|
||||
for t in "${missing_required[@]}"; do echo " - $t"; done
|
||||
echo
|
||||
if [[ "$OS" == "macos" ]]; then
|
||||
echo -e "${YELLOW}macOS (Homebrew):${NC}"
|
||||
echo ""
|
||||
|
||||
# Check if Homebrew is installed
|
||||
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 dump1090-mutability"
|
||||
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"
|
||||
|
||||
warn "macOS note: bluetoothctl/hcitool/hciconfig are Linux (BlueZ) tools and unavailable on macOS."
|
||||
warn "Bluetooth functionality will be limited. Other features should work."
|
||||
else
|
||||
echo "Please install the following tools manually:"
|
||||
for tool in "${MISSING_TOOLS[@]}"; do
|
||||
echo " - $tool"
|
||||
done
|
||||
fail "Exiting because required tools are missing."
|
||||
exit 1
|
||||
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
|
||||
install_python_deps
|
||||
check_tools
|
||||
show_install_instructions
|
||||
setup_udev_rules
|
||||
detect_os
|
||||
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo -e "${GREEN}Setup complete!${NC}"
|
||||
echo ""
|
||||
echo "To start INTERCEPT:"
|
||||
if [ -d "venv" ]; then
|
||||
echo " source venv/bin/activate"
|
||||
echo " sudo venv/bin/python intercept.py"
|
||||
else
|
||||
echo " sudo python3 intercept.py"
|
||||
fi
|
||||
echo ""
|
||||
echo "Then open http://localhost:5050 in your browser"
|
||||
echo ""
|
||||
if [[ "$OS" == "macos" ]]; then
|
||||
install_macos_packages
|
||||
else
|
||||
install_debian_packages
|
||||
fi
|
||||
|
||||
install_python_deps
|
||||
final_summary_and_hard_fail
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -182,17 +185,148 @@ body {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 340px;
|
||||
grid-template-columns: auto 1fr 300px;
|
||||
grid-template-rows: 1fr auto;
|
||||
gap: 0;
|
||||
height: calc(100vh - 60px);
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
/* ACARS sidebar (left of map) - Collapsible */
|
||||
.acars-sidebar {
|
||||
background: var(--bg-panel);
|
||||
border-right: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.acars-collapse-btn {
|
||||
width: 28px;
|
||||
min-width: 28px;
|
||||
background: var(--bg-card);
|
||||
border: none;
|
||||
border-left: 1px solid var(--border-color);
|
||||
color: var(--accent-cyan);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 8px 0;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.acars-collapse-btn:hover {
|
||||
background: rgba(74, 158, 255, 0.2);
|
||||
}
|
||||
|
||||
.acars-collapse-label {
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: mixed;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.acars-sidebar.collapsed .acars-collapse-label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.acars-sidebar:not(.collapsed) .acars-collapse-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#acarsCollapseIcon {
|
||||
font-size: 10px;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.acars-sidebar.collapsed #acarsCollapseIcon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.acars-sidebar-content {
|
||||
width: 250px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
transition: width 0.3s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.acars-sidebar.collapsed .acars-sidebar-content {
|
||||
width: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.acars-sidebar .panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.acars-sidebar .panel::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.acars-sidebar .acars-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.acars-sidebar .acars-btn {
|
||||
background: var(--accent-green);
|
||||
border: none;
|
||||
color: #fff;
|
||||
padding: 6px 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.acars-sidebar .acars-btn:hover {
|
||||
background: #1db954;
|
||||
box-shadow: 0 0 10px rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
.acars-sidebar .acars-btn.active {
|
||||
background: var(--accent-red);
|
||||
}
|
||||
|
||||
.acars-sidebar .acars-btn.active:hover {
|
||||
background: #dc2626;
|
||||
box-shadow: 0 0 10px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.acars-message-item {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 10px;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.acars-message-item:hover {
|
||||
background: rgba(74, 158, 255, 0.05);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(-5px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* 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 +338,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,8 +359,14 @@ body {
|
||||
.panel-indicator {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--accent-green);
|
||||
background: var(--text-dim);
|
||||
border-radius: 50%;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.panel-indicator.active {
|
||||
background: var(--accent-green);
|
||||
opacity: 1;
|
||||
animation: blink 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@@ -256,7 +396,7 @@ body {
|
||||
|
||||
/* Main display container (map + radar scope) */
|
||||
.main-display {
|
||||
grid-column: 1;
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
position: relative;
|
||||
}
|
||||
@@ -296,11 +436,11 @@ body {
|
||||
|
||||
/* Right sidebar */
|
||||
.sidebar {
|
||||
grid-column: 2;
|
||||
grid-column: 3;
|
||||
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 +450,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,20 +470,20 @@ 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);
|
||||
}
|
||||
|
||||
/* Selected aircraft panel */
|
||||
.selected-aircraft {
|
||||
flex-shrink: 0;
|
||||
max-height: 280px;
|
||||
max-height: 480px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -351,12 +491,24 @@ body {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
#aircraftPhotoContainer {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
#aircraftPhotoContainer img {
|
||||
max-height: 140px;
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||
}
|
||||
|
||||
.selected-callsign {
|
||||
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 +523,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 {
|
||||
@@ -403,8 +555,9 @@ body {
|
||||
}
|
||||
|
||||
.aircraft-item {
|
||||
position: relative;
|
||||
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 +566,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 +587,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 +628,28 @@ body {
|
||||
grid-row: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 10px 20px;
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px;
|
||||
padding: 8px 15px;
|
||||
background: var(--bg-panel);
|
||||
border-top: 1px solid rgba(0, 255, 136, 0.3);
|
||||
border-top: 1px solid rgba(74, 158, 255, 0.3);
|
||||
font-size: 11px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.controls-bar label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.controls-bar select,
|
||||
.controls-bar input[type="text"],
|
||||
.controls-bar input[type="number"] {
|
||||
padding: 3px 5px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
@@ -497,15 +668,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 +685,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 +702,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: none;
|
||||
background: var(--accent-green);
|
||||
color: #fff;
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
@@ -546,28 +717,27 @@ body {
|
||||
}
|
||||
|
||||
.start-btn:hover {
|
||||
background: var(--accent-green);
|
||||
color: var(--bg-dark);
|
||||
box-shadow: 0 0 20px rgba(0, 255, 136, 0.3);
|
||||
background: #1db954;
|
||||
box-shadow: 0 0 20px rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
.start-btn.active {
|
||||
background: var(--accent-red);
|
||||
border-color: var(--accent-red);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.start-btn.active:hover {
|
||||
box-shadow: 0 0 20px rgba(255, 68, 68, 0.3);
|
||||
background: #dc2626;
|
||||
box-shadow: 0 0 20px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
/* 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 +748,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 +775,7 @@ body {
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--accent-green);
|
||||
background: var(--accent-cyan);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
@@ -617,8 +792,20 @@ body {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1000px) {
|
||||
/* Responsive - medium screens (hide ACARS sidebar, keep main sidebar) */
|
||||
@media (max-width: 1200px) {
|
||||
.dashboard {
|
||||
grid-template-columns: 1fr 300px;
|
||||
grid-template-rows: 1fr auto;
|
||||
}
|
||||
|
||||
.acars-sidebar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive - small screens (single column) */
|
||||
@media (max-width: 900px) {
|
||||
.dashboard {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr auto auto;
|
||||
@@ -628,11 +815,15 @@ body {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.acars-sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
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 +831,165 @@ body {
|
||||
grid-row: 3;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
/* Airband Audio Controls */
|
||||
.airband-divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: var(--accent-cyan);
|
||||
opacity: 0.4;
|
||||
margin: 0 5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.airband-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.airband-btn {
|
||||
padding: 6px 12px;
|
||||
background: var(--accent-green);
|
||||
border: none;
|
||||
color: #fff;
|
||||
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: #1db954;
|
||||
box-shadow: 0 0 10px rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
.airband-btn.active {
|
||||
background: var(--accent-red);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.airband-btn.active:hover {
|
||||
background: #dc2626;
|
||||
box-shadow: 0 0 10px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
/* GPS Indicator */
|
||||
.gps-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
border: 1px solid #22c55e;
|
||||
border-radius: 12px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #22c55e;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.gps-indicator .gps-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: #22c55e;
|
||||
border-radius: 50%;
|
||||
animation: gps-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes gps-pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.5; transform: scale(0.8); }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -586,13 +589,14 @@ body {
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
background: var(--accent-cyan);
|
||||
color: var(--bg-dark);
|
||||
background: var(--accent-green);
|
||||
color: #fff;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.btn.primary:hover {
|
||||
box-shadow: 0 0 25px rgba(0, 212, 255, 0.5);
|
||||
background: #1db954;
|
||||
box-shadow: 0 0 25px rgba(34, 197, 94, 0.5);
|
||||
}
|
||||
|
||||
/* Leaflet dark theme overrides */
|
||||
@@ -600,10 +604,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 {
|
||||
@@ -684,4 +693,36 @@ body {
|
||||
.controls-bar {
|
||||
grid-row: 4;
|
||||
}
|
||||
}
|
||||
|
||||
/* Embedded Mode Styles */
|
||||
body.embedded {
|
||||
background: transparent;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
body.embedded .header {
|
||||
background: rgba(10, 12, 16, 0.95);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
body.embedded .header .logo {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
body.embedded .header .logo span {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
body.embedded .dashboard {
|
||||
padding: 10px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
body.embedded .panel {
|
||||
background: rgba(15, 18, 24, 0.95);
|
||||
}
|
||||
|
||||
body.embedded .controls-bar {
|
||||
padding: 10px 15px;
|
||||
}
|
||||
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 4.8 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="512" height="512" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- iNTERCEPT Logo - Signal Intelligence Platform (Dark Background Version) -->
|
||||
|
||||
<!-- Dark background -->
|
||||
<rect width="100" height="100" fill="#0a0a0f"/>
|
||||
|
||||
<!-- Subtle grid pattern -->
|
||||
<defs>
|
||||
<pattern id="grid" width="10" height="10" patternUnits="userSpaceOnUse">
|
||||
<path d="M 10 0 L 0 0 0 10" fill="none" stroke="#1a1a2e" stroke-width="0.5"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100" height="100" fill="url(#grid)"/>
|
||||
|
||||
<!-- Outer glow effect -->
|
||||
<defs>
|
||||
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="2" result="coloredBlur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<g filter="url(#glow)">
|
||||
<!-- Signal brackets - left side (signal waves emanating) -->
|
||||
<path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
|
||||
<!-- Signal brackets - right side (signal waves emanating) -->
|
||||
<path d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
|
||||
<!-- The 'i' letter - center element -->
|
||||
<!-- dot of i (green accent - represents active signal) -->
|
||||
<circle cx="50" cy="22" r="6" fill="#00ff88"/>
|
||||
|
||||
<!-- stem of i with styled terminals -->
|
||||
<rect x="44" y="35" width="12" height="45" rx="2" fill="#00d4ff"/>
|
||||
|
||||
<!-- top terminal bar -->
|
||||
<rect x="38" y="35" width="24" height="4" rx="1" fill="#00d4ff"/>
|
||||
|
||||
<!-- bottom terminal bar -->
|
||||
<rect x="38" y="76" width="24" height="4" rx="1" fill="#00d4ff"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="512" height="512" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- iNTERCEPT Logo - Signal Intelligence Platform -->
|
||||
|
||||
<!-- Signal brackets - left side (signal waves emanating) -->
|
||||
<path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
|
||||
<!-- Signal brackets - right side (signal waves emanating) -->
|
||||
<path d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
|
||||
<!-- The 'i' letter - center element -->
|
||||
<!-- dot of i (green accent - represents active signal) -->
|
||||
<circle cx="50" cy="22" r="6" fill="#00ff88"/>
|
||||
|
||||
<!-- stem of i with styled terminals -->
|
||||
<rect x="44" y="35" width="12" height="45" rx="2" fill="#00d4ff"/>
|
||||
|
||||
<!-- top terminal bar -->
|
||||
<rect x="38" y="35" width="24" height="4" rx="1" fill="#00d4ff"/>
|
||||
|
||||
<!-- bottom terminal bar -->
|
||||
<rect x="38" y="76" width="24" height="4" rx="1" fill="#00d4ff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Intercept - Radio Knob Component
|
||||
* Interactive rotary knob control with drag-to-rotate
|
||||
*/
|
||||
|
||||
class RadioKnob {
|
||||
constructor(element, options = {}) {
|
||||
this.element = element;
|
||||
this.value = parseFloat(element.dataset.value) || 0;
|
||||
this.min = parseFloat(element.dataset.min) || 0;
|
||||
this.max = parseFloat(element.dataset.max) || 100;
|
||||
this.step = parseFloat(element.dataset.step) || 1;
|
||||
this.rotation = this.valueToRotation(this.value);
|
||||
this.isDragging = false;
|
||||
this.startY = 0;
|
||||
this.startRotation = 0;
|
||||
this.sensitivity = options.sensitivity || 1.5;
|
||||
this.onChange = options.onChange || null;
|
||||
|
||||
this.bindEvents();
|
||||
this.updateVisual();
|
||||
}
|
||||
|
||||
valueToRotation(value) {
|
||||
const range = this.max - this.min;
|
||||
const normalized = (value - this.min) / range;
|
||||
return normalized * 270 - 135; // -135 to +135 degrees
|
||||
}
|
||||
|
||||
rotationToValue(rotation) {
|
||||
const normalized = (rotation + 135) / 270;
|
||||
let value = this.min + normalized * (this.max - this.min);
|
||||
|
||||
// Snap to step
|
||||
value = Math.round(value / this.step) * this.step;
|
||||
return Math.max(this.min, Math.min(this.max, value));
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// Mouse events
|
||||
this.element.addEventListener('mousedown', (e) => this.startDrag(e));
|
||||
document.addEventListener('mousemove', (e) => this.drag(e));
|
||||
document.addEventListener('mouseup', () => this.endDrag());
|
||||
|
||||
// Touch support
|
||||
this.element.addEventListener('touchstart', (e) => {
|
||||
e.preventDefault();
|
||||
this.startDrag(e.touches[0]);
|
||||
}, { passive: false });
|
||||
document.addEventListener('touchmove', (e) => {
|
||||
if (this.isDragging) {
|
||||
e.preventDefault();
|
||||
this.drag(e.touches[0]);
|
||||
}
|
||||
}, { passive: false });
|
||||
document.addEventListener('touchend', () => this.endDrag());
|
||||
|
||||
// Scroll wheel support
|
||||
this.element.addEventListener('wheel', (e) => this.handleWheel(e), { passive: false });
|
||||
|
||||
// Double-click to reset
|
||||
this.element.addEventListener('dblclick', () => this.reset());
|
||||
}
|
||||
|
||||
startDrag(e) {
|
||||
this.isDragging = true;
|
||||
this.startY = e.clientY;
|
||||
this.startRotation = this.rotation;
|
||||
this.element.style.cursor = 'grabbing';
|
||||
this.element.classList.add('active');
|
||||
|
||||
// Play click sound if available
|
||||
if (typeof playClickSound === 'function') {
|
||||
playClickSound();
|
||||
}
|
||||
}
|
||||
|
||||
drag(e) {
|
||||
if (!this.isDragging) return;
|
||||
|
||||
const deltaY = this.startY - e.clientY;
|
||||
let newRotation = this.startRotation + deltaY * this.sensitivity;
|
||||
|
||||
// Clamp rotation
|
||||
newRotation = Math.max(-135, Math.min(135, newRotation));
|
||||
|
||||
this.rotation = newRotation;
|
||||
this.value = this.rotationToValue(this.rotation);
|
||||
this.updateVisual();
|
||||
this.dispatchChange();
|
||||
}
|
||||
|
||||
endDrag() {
|
||||
if (!this.isDragging) return;
|
||||
this.isDragging = false;
|
||||
this.element.style.cursor = 'grab';
|
||||
this.element.classList.remove('active');
|
||||
}
|
||||
|
||||
handleWheel(e) {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -this.step : this.step;
|
||||
const multiplier = e.shiftKey ? 5 : 1; // Faster with shift key
|
||||
this.setValue(this.value + delta * multiplier);
|
||||
|
||||
// Play click sound if available
|
||||
if (typeof playClickSound === 'function') {
|
||||
playClickSound();
|
||||
}
|
||||
}
|
||||
|
||||
setValue(value, silent = false) {
|
||||
this.value = Math.max(this.min, Math.min(this.max, value));
|
||||
this.rotation = this.valueToRotation(this.value);
|
||||
this.updateVisual();
|
||||
if (!silent) {
|
||||
this.dispatchChange();
|
||||
}
|
||||
}
|
||||
|
||||
getValue() {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
reset() {
|
||||
const defaultValue = parseFloat(this.element.dataset.default) ||
|
||||
(this.min + this.max) / 2;
|
||||
this.setValue(defaultValue);
|
||||
}
|
||||
|
||||
updateVisual() {
|
||||
this.element.style.transform = `rotate(${this.rotation}deg)`;
|
||||
|
||||
// Update associated value display
|
||||
const valueDisplayId = this.element.id.replace('Knob', 'Value');
|
||||
const valueDisplay = document.getElementById(valueDisplayId);
|
||||
if (valueDisplay) {
|
||||
valueDisplay.textContent = Math.round(this.value);
|
||||
}
|
||||
|
||||
// Update data attribute
|
||||
this.element.dataset.value = this.value;
|
||||
}
|
||||
|
||||
dispatchChange() {
|
||||
// Custom callback
|
||||
if (this.onChange) {
|
||||
this.onChange(this.value, this);
|
||||
}
|
||||
|
||||
// Custom event
|
||||
this.element.dispatchEvent(new CustomEvent('knobchange', {
|
||||
detail: { value: this.value, knob: this },
|
||||
bubbles: true
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tuning Dial - Larger rotary control for frequency tuning
|
||||
*/
|
||||
class TuningDial extends RadioKnob {
|
||||
constructor(element, options = {}) {
|
||||
super(element, {
|
||||
sensitivity: options.sensitivity || 0.8,
|
||||
...options
|
||||
});
|
||||
|
||||
this.fineStep = options.fineStep || 0.025;
|
||||
this.coarseStep = options.coarseStep || 0.2;
|
||||
}
|
||||
|
||||
handleWheel(e) {
|
||||
e.preventDefault();
|
||||
const step = e.shiftKey ? this.fineStep : this.coarseStep;
|
||||
const delta = e.deltaY > 0 ? -step : step;
|
||||
this.setValue(this.value + delta);
|
||||
}
|
||||
|
||||
// Override to not round to step for smooth tuning
|
||||
rotationToValue(rotation) {
|
||||
const normalized = (rotation + 135) / 270;
|
||||
let value = this.min + normalized * (this.max - this.min);
|
||||
return Math.max(this.min, Math.min(this.max, value));
|
||||
}
|
||||
|
||||
updateVisual() {
|
||||
this.element.style.transform = `rotate(${this.rotation}deg)`;
|
||||
|
||||
// Update associated value display with decimals
|
||||
const valueDisplayId = this.element.id.replace('Dial', 'Value');
|
||||
const valueDisplay = document.getElementById(valueDisplayId);
|
||||
if (valueDisplay) {
|
||||
valueDisplay.textContent = this.value.toFixed(3);
|
||||
}
|
||||
|
||||
this.element.dataset.value = this.value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize all radio knobs on the page
|
||||
*/
|
||||
function initRadioKnobs() {
|
||||
// Initialize standard knobs
|
||||
document.querySelectorAll('.radio-knob').forEach(element => {
|
||||
if (!element._knob) {
|
||||
element._knob = new RadioKnob(element);
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize tuning dials
|
||||
document.querySelectorAll('.tuning-dial').forEach(element => {
|
||||
if (!element._dial) {
|
||||
element._dial = new TuningDial(element);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-initialize on DOM ready
|
||||
document.addEventListener('DOMContentLoaded', initRadioKnobs);
|
||||
|
||||
// Export for use in modules
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = { RadioKnob, TuningDial, initRadioKnobs };
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
/**
|
||||
* Intercept - Core Application Logic
|
||||
* Global state, mode switching, and shared functionality
|
||||
*/
|
||||
|
||||
// ============== GLOBAL STATE ==============
|
||||
|
||||
// Mode state flags
|
||||
let eventSource = null;
|
||||
let isRunning = false;
|
||||
let isSensorRunning = false;
|
||||
let isAdsbRunning = false;
|
||||
let isWifiRunning = false;
|
||||
let isBtRunning = false;
|
||||
let currentMode = 'pager';
|
||||
|
||||
// Message counters
|
||||
let msgCount = 0;
|
||||
let pocsagCount = 0;
|
||||
let flexCount = 0;
|
||||
let sensorCount = 0;
|
||||
let filteredCount = 0;
|
||||
|
||||
// Device list (populated from server via Jinja2)
|
||||
let deviceList = [];
|
||||
|
||||
// Auto-scroll setting
|
||||
let autoScroll = localStorage.getItem('autoScroll') !== 'false';
|
||||
|
||||
// Mute setting
|
||||
let muted = localStorage.getItem('audioMuted') === 'true';
|
||||
|
||||
// Observer location (load from localStorage or default to London)
|
||||
let observerLocation = (function() {
|
||||
const saved = localStorage.getItem('observerLocation');
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (parsed.lat && parsed.lon) return parsed;
|
||||
} catch (e) {}
|
||||
}
|
||||
return { lat: 51.5074, lon: -0.1278 };
|
||||
})();
|
||||
|
||||
// Message storage for export
|
||||
let allMessages = [];
|
||||
|
||||
// Track unique sensor devices
|
||||
let uniqueDevices = new Set();
|
||||
|
||||
// SDR device usage tracking
|
||||
let sdrDeviceUsage = {};
|
||||
|
||||
// ============== DISCLAIMER HANDLING ==============
|
||||
|
||||
function checkDisclaimer() {
|
||||
const accepted = localStorage.getItem('disclaimerAccepted');
|
||||
if (accepted === 'true') {
|
||||
document.getElementById('disclaimerModal').classList.add('disclaimer-hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function acceptDisclaimer() {
|
||||
localStorage.setItem('disclaimerAccepted', 'true');
|
||||
document.getElementById('disclaimerModal').classList.add('disclaimer-hidden');
|
||||
}
|
||||
|
||||
function declineDisclaimer() {
|
||||
document.getElementById('disclaimerModal').classList.add('disclaimer-hidden');
|
||||
document.getElementById('rejectionPage').classList.remove('disclaimer-hidden');
|
||||
}
|
||||
|
||||
// ============== HEADER CLOCK ==============
|
||||
|
||||
function updateHeaderClock() {
|
||||
const now = new Date();
|
||||
const utc = now.toISOString().substring(11, 19);
|
||||
document.getElementById('headerUtcTime').textContent = utc;
|
||||
}
|
||||
|
||||
// ============== HEADER STATS SYNC ==============
|
||||
|
||||
function syncHeaderStats() {
|
||||
// Pager stats
|
||||
document.getElementById('headerMsgCount').textContent = msgCount;
|
||||
document.getElementById('headerPocsagCount').textContent = pocsagCount;
|
||||
document.getElementById('headerFlexCount').textContent = flexCount;
|
||||
|
||||
// Sensor stats
|
||||
document.getElementById('headerSensorCount').textContent = document.getElementById('sensorCount')?.textContent || '0';
|
||||
document.getElementById('headerDeviceTypeCount').textContent = document.getElementById('deviceCount')?.textContent || '0';
|
||||
|
||||
// WiFi stats
|
||||
document.getElementById('headerApCount').textContent = document.getElementById('apCount')?.textContent || '0';
|
||||
document.getElementById('headerClientCount').textContent = document.getElementById('clientCount')?.textContent || '0';
|
||||
document.getElementById('headerHandshakeCount').textContent = document.getElementById('handshakeCount')?.textContent || '0';
|
||||
document.getElementById('headerDroneCount').textContent = document.getElementById('droneCount')?.textContent || '0';
|
||||
|
||||
// Bluetooth stats
|
||||
document.getElementById('headerBtDeviceCount').textContent = document.getElementById('btDeviceCount')?.textContent || '0';
|
||||
document.getElementById('headerBtBeaconCount').textContent = document.getElementById('btBeaconCount')?.textContent || '0';
|
||||
|
||||
// Aircraft stats
|
||||
document.getElementById('headerAircraftCount').textContent = document.getElementById('aircraftCount')?.textContent || '0';
|
||||
document.getElementById('headerAdsbMsgCount').textContent = document.getElementById('adsbMsgCount')?.textContent || '0';
|
||||
document.getElementById('headerIcaoCount').textContent = document.getElementById('icaoCount')?.textContent || '0';
|
||||
|
||||
// Satellite stats
|
||||
document.getElementById('headerPassCount').textContent = document.getElementById('passCount')?.textContent || '0';
|
||||
}
|
||||
|
||||
// ============== MODE SWITCHING ==============
|
||||
|
||||
function switchMode(mode) {
|
||||
// Stop any running scans when switching modes
|
||||
if (isRunning && typeof stopDecoding === 'function') stopDecoding();
|
||||
if (isSensorRunning && typeof stopSensorDecoding === 'function') stopSensorDecoding();
|
||||
if (isWifiRunning && typeof stopWifiScan === 'function') stopWifiScan();
|
||||
if (isBtRunning && typeof stopBtScan === 'function') stopBtScan();
|
||||
if (isAdsbRunning && typeof stopAdsbScan === 'function') stopAdsbScan();
|
||||
|
||||
currentMode = mode;
|
||||
|
||||
// Remove active from all nav buttons, then add to the correct one
|
||||
document.querySelectorAll('.mode-nav-btn').forEach(btn => btn.classList.remove('active'));
|
||||
const modeMap = {
|
||||
'pager': 'pager', 'sensor': '433', 'aircraft': 'aircraft',
|
||||
'satellite': 'satellite', 'wifi': 'wifi', 'bluetooth': 'bluetooth',
|
||||
'listening': 'listening'
|
||||
};
|
||||
document.querySelectorAll('.mode-nav-btn').forEach(btn => {
|
||||
const label = btn.querySelector('.nav-label');
|
||||
if (label && label.textContent.toLowerCase().includes(modeMap[mode])) {
|
||||
btn.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle mode content visibility
|
||||
document.getElementById('pagerMode').classList.toggle('active', mode === 'pager');
|
||||
document.getElementById('sensorMode').classList.toggle('active', mode === 'sensor');
|
||||
document.getElementById('aircraftMode').classList.toggle('active', mode === 'aircraft');
|
||||
document.getElementById('satelliteMode').classList.toggle('active', mode === 'satellite');
|
||||
document.getElementById('wifiMode').classList.toggle('active', mode === 'wifi');
|
||||
document.getElementById('bluetoothMode').classList.toggle('active', mode === 'bluetooth');
|
||||
document.getElementById('listeningPostMode').classList.toggle('active', mode === 'listening');
|
||||
|
||||
// Toggle stats visibility
|
||||
document.getElementById('pagerStats').style.display = mode === 'pager' ? 'flex' : 'none';
|
||||
document.getElementById('sensorStats').style.display = mode === 'sensor' ? 'flex' : 'none';
|
||||
document.getElementById('aircraftStats').style.display = mode === 'aircraft' ? 'flex' : 'none';
|
||||
document.getElementById('satelliteStats').style.display = mode === 'satellite' ? 'flex' : 'none';
|
||||
document.getElementById('wifiStats').style.display = mode === 'wifi' ? 'flex' : 'none';
|
||||
document.getElementById('btStats').style.display = mode === 'bluetooth' ? 'flex' : 'none';
|
||||
|
||||
// Hide signal meter - individual panels show signal strength where needed
|
||||
document.getElementById('signalMeter').style.display = 'none';
|
||||
|
||||
// Update header stats groups
|
||||
document.getElementById('headerPagerStats').classList.toggle('active', mode === 'pager');
|
||||
document.getElementById('headerSensorStats').classList.toggle('active', mode === 'sensor');
|
||||
document.getElementById('headerAircraftStats').classList.toggle('active', mode === 'aircraft');
|
||||
document.getElementById('headerSatelliteStats').classList.toggle('active', mode === 'satellite');
|
||||
document.getElementById('headerWifiStats').classList.toggle('active', mode === 'wifi');
|
||||
document.getElementById('headerBtStats').classList.toggle('active', mode === 'bluetooth');
|
||||
|
||||
// Show/hide dashboard buttons in nav bar
|
||||
document.getElementById('adsbDashboardBtn').style.display = mode === 'aircraft' ? 'inline-flex' : 'none';
|
||||
document.getElementById('satelliteDashboardBtn').style.display = mode === 'satellite' ? 'inline-flex' : 'none';
|
||||
|
||||
// Update active mode indicator
|
||||
const modeNames = {
|
||||
'pager': 'PAGER',
|
||||
'sensor': '433MHZ',
|
||||
'aircraft': 'AIRCRAFT',
|
||||
'satellite': 'SATELLITE',
|
||||
'wifi': 'WIFI',
|
||||
'bluetooth': 'BLUETOOTH',
|
||||
'listening': 'LISTENING POST'
|
||||
};
|
||||
document.getElementById('activeModeIndicator').innerHTML = '<span class="pulse-dot"></span>' + modeNames[mode];
|
||||
|
||||
// Toggle layout containers
|
||||
document.getElementById('wifiLayoutContainer').style.display = mode === 'wifi' ? 'flex' : 'none';
|
||||
document.getElementById('btLayoutContainer').style.display = mode === 'bluetooth' ? 'flex' : 'none';
|
||||
|
||||
// Respect the "Show Radar Display" checkbox for aircraft mode
|
||||
const showRadar = document.getElementById('adsbEnableMap')?.checked;
|
||||
document.getElementById('aircraftVisuals').style.display = (mode === 'aircraft' && showRadar) ? 'grid' : 'none';
|
||||
document.getElementById('satelliteVisuals').style.display = mode === 'satellite' ? 'block' : 'none';
|
||||
document.getElementById('listeningPostVisuals').style.display = mode === 'listening' ? 'grid' : 'none';
|
||||
|
||||
// Update output panel title based on mode
|
||||
const titles = {
|
||||
'pager': 'Pager Decoder',
|
||||
'sensor': '433MHz Sensor Monitor',
|
||||
'aircraft': 'ADS-B Aircraft Tracker',
|
||||
'satellite': 'Satellite Monitor',
|
||||
'wifi': 'WiFi Scanner',
|
||||
'bluetooth': 'Bluetooth Scanner',
|
||||
'listening': 'Listening Post'
|
||||
};
|
||||
document.getElementById('outputTitle').textContent = titles[mode] || 'Signal Monitor';
|
||||
|
||||
// Show/hide Device Intelligence for modes that use it
|
||||
const reconBtn = document.getElementById('reconBtn');
|
||||
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
|
||||
if (mode === 'satellite' || mode === 'aircraft' || mode === 'listening') {
|
||||
document.getElementById('reconPanel').style.display = 'none';
|
||||
if (reconBtn) reconBtn.style.display = 'none';
|
||||
if (intelBtn) intelBtn.style.display = 'none';
|
||||
} else {
|
||||
if (reconBtn) reconBtn.style.display = 'inline-block';
|
||||
if (intelBtn) intelBtn.style.display = 'inline-block';
|
||||
if (typeof reconEnabled !== 'undefined' && reconEnabled) {
|
||||
document.getElementById('reconPanel').style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// Show RTL-SDR device section for modes that use it
|
||||
document.getElementById('rtlDeviceSection').style.display =
|
||||
(mode === 'pager' || mode === 'sensor' || mode === 'aircraft' || mode === 'listening') ? 'block' : 'none';
|
||||
|
||||
// Toggle mode-specific tool status displays
|
||||
document.getElementById('toolStatusPager').style.display = (mode === 'pager') ? 'grid' : 'none';
|
||||
document.getElementById('toolStatusSensor').style.display = (mode === 'sensor') ? 'grid' : 'none';
|
||||
document.getElementById('toolStatusAircraft').style.display = (mode === 'aircraft') ? 'grid' : 'none';
|
||||
|
||||
// Hide waterfall and output console for modes with their own visualizations
|
||||
document.querySelector('.waterfall-container').style.display =
|
||||
(mode === 'satellite' || mode === 'listening' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth') ? 'none' : 'block';
|
||||
document.getElementById('output').style.display =
|
||||
(mode === 'satellite' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth') ? 'none' : 'block';
|
||||
document.querySelector('.status-bar').style.display = (mode === 'satellite') ? 'none' : 'flex';
|
||||
|
||||
// Load interfaces and initialize visualizations when switching modes
|
||||
if (mode === 'wifi') {
|
||||
if (typeof refreshWifiInterfaces === 'function') refreshWifiInterfaces();
|
||||
if (typeof initRadar === 'function') initRadar();
|
||||
if (typeof initWatchList === 'function') initWatchList();
|
||||
} else if (mode === 'bluetooth') {
|
||||
if (typeof refreshBtInterfaces === 'function') refreshBtInterfaces();
|
||||
if (typeof initBtRadar === 'function') initBtRadar();
|
||||
} else if (mode === 'aircraft') {
|
||||
if (typeof checkAdsbTools === 'function') checkAdsbTools();
|
||||
if (typeof initAircraftRadar === 'function') initAircraftRadar();
|
||||
} else if (mode === 'satellite') {
|
||||
if (typeof initPolarPlot === 'function') initPolarPlot();
|
||||
if (typeof initSatelliteList === 'function') initSatelliteList();
|
||||
} else if (mode === 'listening') {
|
||||
if (typeof checkScannerTools === 'function') checkScannerTools();
|
||||
if (typeof checkAudioTools === 'function') checkAudioTools();
|
||||
if (typeof populateScannerDeviceSelect === 'function') populateScannerDeviceSelect();
|
||||
if (typeof populateAudioDeviceSelect === 'function') populateAudioDeviceSelect();
|
||||
}
|
||||
}
|
||||
|
||||
// ============== SECTION COLLAPSE ==============
|
||||
|
||||
function toggleSection(el) {
|
||||
el.closest('.section').classList.toggle('collapsed');
|
||||
}
|
||||
|
||||
// ============== THEME MANAGEMENT ==============
|
||||
|
||||
function toggleTheme() {
|
||||
const html = document.documentElement;
|
||||
const currentTheme = html.getAttribute('data-theme');
|
||||
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
|
||||
html.setAttribute('data-theme', newTheme);
|
||||
localStorage.setItem('theme', newTheme);
|
||||
|
||||
// Update button text
|
||||
const btn = document.getElementById('themeToggle');
|
||||
if (btn) {
|
||||
btn.textContent = newTheme === 'light' ? '🌙' : '☀️';
|
||||
}
|
||||
}
|
||||
|
||||
function loadTheme() {
|
||||
const savedTheme = localStorage.getItem('theme') || 'dark';
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
const btn = document.getElementById('themeToggle');
|
||||
if (btn) {
|
||||
btn.textContent = savedTheme === 'light' ? '🌙' : '☀️';
|
||||
}
|
||||
}
|
||||
|
||||
// ============== AUTO-SCROLL ==============
|
||||
|
||||
function toggleAutoScroll() {
|
||||
autoScroll = !autoScroll;
|
||||
localStorage.setItem('autoScroll', autoScroll);
|
||||
updateAutoScrollButton();
|
||||
}
|
||||
|
||||
function updateAutoScrollButton() {
|
||||
const btn = document.getElementById('autoScrollBtn');
|
||||
if (btn) {
|
||||
btn.innerHTML = autoScroll ? '⬇ AUTO-SCROLL ON' : '⬇ AUTO-SCROLL OFF';
|
||||
btn.classList.toggle('active', autoScroll);
|
||||
}
|
||||
}
|
||||
|
||||
// ============== SDR DEVICE MANAGEMENT ==============
|
||||
|
||||
function getSelectedDevice() {
|
||||
return document.getElementById('deviceSelect').value;
|
||||
}
|
||||
|
||||
function getSelectedSDRType() {
|
||||
return document.getElementById('sdrTypeSelect').value;
|
||||
}
|
||||
|
||||
function reserveDevice(deviceIndex, modeId) {
|
||||
sdrDeviceUsage[modeId] = deviceIndex;
|
||||
}
|
||||
|
||||
function releaseDevice(modeId) {
|
||||
delete sdrDeviceUsage[modeId];
|
||||
}
|
||||
|
||||
function checkDeviceAvailability(requestingMode) {
|
||||
const selectedDevice = parseInt(getSelectedDevice());
|
||||
for (const [mode, device] of Object.entries(sdrDeviceUsage)) {
|
||||
if (mode !== requestingMode && device === selectedDevice) {
|
||||
alert(`Device ${selectedDevice} is currently in use by ${mode} mode. Please select a different device or stop the other scan first.`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============== BIAS-T SETTINGS ==============
|
||||
|
||||
function saveBiasTSetting() {
|
||||
const enabled = document.getElementById('biasT')?.checked || false;
|
||||
localStorage.setItem('biasTEnabled', enabled);
|
||||
}
|
||||
|
||||
function getBiasTEnabled() {
|
||||
return document.getElementById('biasT')?.checked || false;
|
||||
}
|
||||
|
||||
function loadBiasTSetting() {
|
||||
const saved = localStorage.getItem('biasTEnabled');
|
||||
if (saved === 'true') {
|
||||
const checkbox = document.getElementById('biasT');
|
||||
if (checkbox) checkbox.checked = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ============== REMOTE SDR ==============
|
||||
|
||||
function toggleRemoteSDR() {
|
||||
const useRemote = document.getElementById('useRemoteSDR').checked;
|
||||
const configDiv = document.getElementById('remoteSDRConfig');
|
||||
const localControls = document.querySelectorAll('#sdrTypeSelect, #deviceSelect');
|
||||
|
||||
if (useRemote) {
|
||||
configDiv.style.display = 'block';
|
||||
localControls.forEach(el => el.disabled = true);
|
||||
} else {
|
||||
configDiv.style.display = 'none';
|
||||
localControls.forEach(el => el.disabled = false);
|
||||
}
|
||||
}
|
||||
|
||||
function getRemoteSDRConfig() {
|
||||
const useRemote = document.getElementById('useRemoteSDR')?.checked;
|
||||
if (!useRemote) return null;
|
||||
|
||||
const host = document.getElementById('rtlTcpHost')?.value || 'localhost';
|
||||
const port = parseInt(document.getElementById('rtlTcpPort')?.value || '1234');
|
||||
|
||||
if (!host || isNaN(port)) {
|
||||
alert('Please enter valid rtl_tcp host and port');
|
||||
return false;
|
||||
}
|
||||
|
||||
return { host, port };
|
||||
}
|
||||
|
||||
// ============== OUTPUT DISPLAY ==============
|
||||
|
||||
function showInfo(text) {
|
||||
const output = document.getElementById('output');
|
||||
if (!output) return;
|
||||
|
||||
const placeholder = output.querySelector('.placeholder');
|
||||
if (placeholder) placeholder.remove();
|
||||
|
||||
const infoEl = document.createElement('div');
|
||||
infoEl.className = 'info-msg';
|
||||
infoEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #0a0a0a; border: 1px solid #1a1a1a; border-left: 2px solid #00d4ff; font-family: "JetBrains Mono", monospace; font-size: 11px; color: #888; word-break: break-all;';
|
||||
infoEl.textContent = text;
|
||||
output.insertBefore(infoEl, output.firstChild);
|
||||
}
|
||||
|
||||
function showError(text) {
|
||||
const output = document.getElementById('output');
|
||||
if (!output) return;
|
||||
|
||||
const placeholder = output.querySelector('.placeholder');
|
||||
if (placeholder) placeholder.remove();
|
||||
|
||||
const errorEl = document.createElement('div');
|
||||
errorEl.className = 'error-msg';
|
||||
errorEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #1a0a0a; border: 1px solid #2a1a1a; border-left: 2px solid #ff3366; font-family: "JetBrains Mono", monospace; font-size: 11px; color: #ff6688; word-break: break-all;';
|
||||
errorEl.textContent = '⚠ ' + text;
|
||||
output.insertBefore(errorEl, output.firstChild);
|
||||
}
|
||||
|
||||
// ============== OBSERVER LOCATION ==============
|
||||
|
||||
function saveObserverLocation() {
|
||||
const lat = parseFloat(document.getElementById('adsbObsLat')?.value || document.getElementById('obsLat')?.value);
|
||||
const lon = parseFloat(document.getElementById('adsbObsLon')?.value || document.getElementById('obsLon')?.value);
|
||||
|
||||
if (!isNaN(lat) && !isNaN(lon)) {
|
||||
observerLocation = { lat, lon };
|
||||
localStorage.setItem('observerLocation', JSON.stringify(observerLocation));
|
||||
|
||||
// Sync both input sets
|
||||
const adsbLat = document.getElementById('adsbObsLat');
|
||||
const adsbLon = document.getElementById('adsbObsLon');
|
||||
const satLat = document.getElementById('obsLat');
|
||||
const satLon = document.getElementById('obsLon');
|
||||
|
||||
if (adsbLat) adsbLat.value = lat.toFixed(4);
|
||||
if (adsbLon) adsbLon.value = lon.toFixed(4);
|
||||
if (satLat) satLat.value = lat.toFixed(4);
|
||||
if (satLon) satLon.value = lon.toFixed(4);
|
||||
}
|
||||
}
|
||||
|
||||
function useGeolocation() {
|
||||
if ('geolocation' in navigator) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
const lat = position.coords.latitude;
|
||||
const lon = position.coords.longitude;
|
||||
|
||||
observerLocation = { lat, lon };
|
||||
localStorage.setItem('observerLocation', JSON.stringify(observerLocation));
|
||||
|
||||
// Update all input fields
|
||||
const adsbLat = document.getElementById('adsbObsLat');
|
||||
const adsbLon = document.getElementById('adsbObsLon');
|
||||
const satLat = document.getElementById('obsLat');
|
||||
const satLon = document.getElementById('obsLon');
|
||||
|
||||
if (adsbLat) adsbLat.value = lat.toFixed(4);
|
||||
if (adsbLon) adsbLon.value = lon.toFixed(4);
|
||||
if (satLat) satLat.value = lat.toFixed(4);
|
||||
if (satLon) satLon.value = lon.toFixed(4);
|
||||
|
||||
showInfo(`Location set to ${lat.toFixed(4)}, ${lon.toFixed(4)}`);
|
||||
},
|
||||
(error) => {
|
||||
showError('Geolocation failed: ' + error.message);
|
||||
}
|
||||
);
|
||||
} else {
|
||||
showError('Geolocation not supported by browser');
|
||||
}
|
||||
}
|
||||
|
||||
// ============== EXPORT FUNCTIONS ==============
|
||||
|
||||
function exportCSV() {
|
||||
if (allMessages.length === 0) {
|
||||
alert('No messages to export');
|
||||
return;
|
||||
}
|
||||
const headers = ['Timestamp', 'Protocol', 'Address', 'Function', 'Type', 'Message'];
|
||||
const csv = [headers.join(',')];
|
||||
allMessages.forEach(msg => {
|
||||
const row = [
|
||||
msg.timestamp || '',
|
||||
msg.protocol || '',
|
||||
msg.address || '',
|
||||
msg.function || '',
|
||||
msg.msg_type || '',
|
||||
'"' + (msg.message || '').replace(/"/g, '""') + '"'
|
||||
];
|
||||
csv.push(row.join(','));
|
||||
});
|
||||
downloadFile(csv.join('\n'), 'intercept_messages.csv', 'text/csv');
|
||||
}
|
||||
|
||||
function exportJSON() {
|
||||
if (allMessages.length === 0) {
|
||||
alert('No messages to export');
|
||||
return;
|
||||
}
|
||||
downloadFile(JSON.stringify(allMessages, null, 2), 'intercept_messages.json', 'application/json');
|
||||
}
|
||||
|
||||
// ============== INITIALIZATION ==============
|
||||
|
||||
function initApp() {
|
||||
// Check disclaimer
|
||||
checkDisclaimer();
|
||||
|
||||
// Load theme
|
||||
loadTheme();
|
||||
|
||||
// Start clock
|
||||
updateHeaderClock();
|
||||
setInterval(updateHeaderClock, 1000);
|
||||
|
||||
// Start stats sync
|
||||
setInterval(syncHeaderStats, 500);
|
||||
|
||||
// Load bias-T setting
|
||||
loadBiasTSetting();
|
||||
|
||||
// Initialize observer location inputs
|
||||
const adsbLatInput = document.getElementById('adsbObsLat');
|
||||
const adsbLonInput = document.getElementById('adsbObsLon');
|
||||
const obsLatInput = document.getElementById('obsLat');
|
||||
const obsLonInput = document.getElementById('obsLon');
|
||||
if (adsbLatInput) adsbLatInput.value = observerLocation.lat.toFixed(4);
|
||||
if (adsbLonInput) adsbLonInput.value = observerLocation.lon.toFixed(4);
|
||||
if (obsLatInput) obsLatInput.value = observerLocation.lat.toFixed(4);
|
||||
if (obsLonInput) obsLonInput.value = observerLocation.lon.toFixed(4);
|
||||
|
||||
// Update UI state
|
||||
updateAutoScrollButton();
|
||||
|
||||
// Make sections collapsible
|
||||
document.querySelectorAll('.section h3').forEach(h3 => {
|
||||
h3.addEventListener('click', function() {
|
||||
this.parentElement.classList.toggle('collapsed');
|
||||
});
|
||||
});
|
||||
|
||||
// Collapse all sections by default (except SDR Device which is first)
|
||||
document.querySelectorAll('.section').forEach((section, index) => {
|
||||
if (index > 0) {
|
||||
section.classList.add('collapsed');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Run initialization when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', initApp);
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* Intercept - Audio System
|
||||
* Web Audio API alerts, notifications, and sound effects
|
||||
*/
|
||||
|
||||
// ============== AUDIO STATE ==============
|
||||
|
||||
let audioContext = null;
|
||||
let audioMuted = localStorage.getItem('audioMuted') === 'true';
|
||||
let notificationsEnabled = false;
|
||||
|
||||
// ============== AUDIO CONTEXT ==============
|
||||
|
||||
/**
|
||||
* Initialize the Web Audio API context
|
||||
* Must be called after user interaction due to browser autoplay policies
|
||||
*/
|
||||
function initAudio() {
|
||||
if (!audioContext) {
|
||||
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
}
|
||||
return audioContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create the audio context
|
||||
* @returns {AudioContext}
|
||||
*/
|
||||
function getAudioContext() {
|
||||
if (!audioContext) {
|
||||
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
}
|
||||
return audioContext;
|
||||
}
|
||||
|
||||
// ============== ALERT SOUNDS ==============
|
||||
|
||||
/**
|
||||
* Play a basic alert beep
|
||||
* Used for message received notifications
|
||||
*/
|
||||
function playAlert() {
|
||||
if (audioMuted || !audioContext) return;
|
||||
|
||||
try {
|
||||
const oscillator = audioContext.createOscillator();
|
||||
const gainNode = audioContext.createGain();
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(audioContext.destination);
|
||||
oscillator.frequency.value = 880;
|
||||
oscillator.type = 'sine';
|
||||
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.2);
|
||||
oscillator.start(audioContext.currentTime);
|
||||
oscillator.stop(audioContext.currentTime + 0.2);
|
||||
} catch (e) {
|
||||
console.warn('Audio alert failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play alert sound by type
|
||||
* @param {string} type - 'emergency', 'military', 'warning', 'info'
|
||||
*/
|
||||
function playAlertSound(type) {
|
||||
if (audioMuted) return;
|
||||
|
||||
try {
|
||||
const ctx = getAudioContext();
|
||||
const oscillator = ctx.createOscillator();
|
||||
const gainNode = ctx.createGain();
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(ctx.destination);
|
||||
|
||||
switch (type) {
|
||||
case 'emergency':
|
||||
// Urgent two-tone alert for emergencies
|
||||
oscillator.frequency.setValueAtTime(880, ctx.currentTime);
|
||||
oscillator.frequency.setValueAtTime(660, ctx.currentTime + 0.15);
|
||||
oscillator.frequency.setValueAtTime(880, ctx.currentTime + 0.3);
|
||||
gainNode.gain.setValueAtTime(0.3, ctx.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.5);
|
||||
oscillator.start(ctx.currentTime);
|
||||
oscillator.stop(ctx.currentTime + 0.5);
|
||||
break;
|
||||
|
||||
case 'military':
|
||||
// Single tone for military aircraft detection
|
||||
oscillator.frequency.setValueAtTime(523, ctx.currentTime);
|
||||
gainNode.gain.setValueAtTime(0.2, ctx.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3);
|
||||
oscillator.start(ctx.currentTime);
|
||||
oscillator.stop(ctx.currentTime + 0.3);
|
||||
break;
|
||||
|
||||
case 'warning':
|
||||
// Warning tone (descending)
|
||||
oscillator.frequency.setValueAtTime(660, ctx.currentTime);
|
||||
oscillator.frequency.exponentialRampToValueAtTime(440, ctx.currentTime + 0.3);
|
||||
gainNode.gain.setValueAtTime(0.25, ctx.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3);
|
||||
oscillator.start(ctx.currentTime);
|
||||
oscillator.stop(ctx.currentTime + 0.3);
|
||||
break;
|
||||
|
||||
case 'info':
|
||||
default:
|
||||
// Simple info tone
|
||||
oscillator.frequency.setValueAtTime(440, ctx.currentTime);
|
||||
gainNode.gain.setValueAtTime(0.15, ctx.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.15);
|
||||
oscillator.start(ctx.currentTime);
|
||||
oscillator.stop(ctx.currentTime + 0.15);
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Audio alert failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play scanner signal detected sound
|
||||
* A distinctive ascending tone for radio scanner
|
||||
*/
|
||||
function playSignalDetectedSound() {
|
||||
if (audioMuted) return;
|
||||
|
||||
try {
|
||||
const ctx = getAudioContext();
|
||||
const oscillator = ctx.createOscillator();
|
||||
const gainNode = ctx.createGain();
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(ctx.destination);
|
||||
|
||||
// Ascending tone
|
||||
oscillator.frequency.setValueAtTime(400, ctx.currentTime);
|
||||
oscillator.frequency.exponentialRampToValueAtTime(800, ctx.currentTime + 0.15);
|
||||
oscillator.type = 'sine';
|
||||
|
||||
gainNode.gain.setValueAtTime(0.2, ctx.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.2);
|
||||
|
||||
oscillator.start(ctx.currentTime);
|
||||
oscillator.stop(ctx.currentTime + 0.2);
|
||||
} catch (e) {
|
||||
console.warn('Signal detected sound failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a click sound for UI feedback
|
||||
*/
|
||||
function playClickSound() {
|
||||
if (audioMuted) return;
|
||||
|
||||
try {
|
||||
const ctx = getAudioContext();
|
||||
const oscillator = ctx.createOscillator();
|
||||
const gainNode = ctx.createGain();
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(ctx.destination);
|
||||
|
||||
oscillator.frequency.value = 1000;
|
||||
oscillator.type = 'square';
|
||||
|
||||
gainNode.gain.setValueAtTime(0.1, ctx.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.05);
|
||||
|
||||
oscillator.start(ctx.currentTime);
|
||||
oscillator.stop(ctx.currentTime + 0.05);
|
||||
} catch (e) {
|
||||
console.warn('Click sound failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ============== MUTE CONTROL ==============
|
||||
|
||||
/**
|
||||
* Toggle mute state
|
||||
*/
|
||||
function toggleMute() {
|
||||
audioMuted = !audioMuted;
|
||||
localStorage.setItem('audioMuted', audioMuted);
|
||||
updateMuteButton();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set mute state
|
||||
* @param {boolean} muted - Whether audio should be muted
|
||||
*/
|
||||
function setMuted(muted) {
|
||||
audioMuted = muted;
|
||||
localStorage.setItem('audioMuted', audioMuted);
|
||||
updateMuteButton();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current mute state
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isMuted() {
|
||||
return audioMuted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update mute button UI
|
||||
*/
|
||||
function updateMuteButton() {
|
||||
const btn = document.getElementById('muteBtn');
|
||||
if (btn) {
|
||||
btn.innerHTML = audioMuted ? '🔇 UNMUTE' : '🔊 MUTE';
|
||||
btn.classList.toggle('muted', audioMuted);
|
||||
}
|
||||
}
|
||||
|
||||
// ============== DESKTOP NOTIFICATIONS ==============
|
||||
|
||||
/**
|
||||
* Request notification permission from user
|
||||
*/
|
||||
function requestNotificationPermission() {
|
||||
if ('Notification' in window && Notification.permission === 'default') {
|
||||
Notification.requestPermission().then(permission => {
|
||||
notificationsEnabled = permission === 'granted';
|
||||
if (notificationsEnabled && typeof showInfo === 'function') {
|
||||
showInfo('🔔 Desktop notifications enabled');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a desktop notification
|
||||
* @param {string} title - Notification title
|
||||
* @param {string} body - Notification body
|
||||
*/
|
||||
function showNotification(title, body) {
|
||||
if (notificationsEnabled && document.hidden) {
|
||||
new Notification(title, {
|
||||
body: body,
|
||||
icon: '/favicon.ico',
|
||||
tag: 'intercept-' + Date.now()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ============== INITIALIZATION ==============
|
||||
|
||||
/**
|
||||
* Initialize audio system
|
||||
* Should be called on first user interaction
|
||||
*/
|
||||
function initAudioSystem() {
|
||||
// Initialize audio context
|
||||
initAudio();
|
||||
|
||||
// Update mute button state
|
||||
updateMuteButton();
|
||||
|
||||
// Check notification permission
|
||||
if ('Notification' in window) {
|
||||
if (Notification.permission === 'granted') {
|
||||
notificationsEnabled = true;
|
||||
} else if (Notification.permission === 'default') {
|
||||
// Will request on first interaction
|
||||
document.addEventListener('click', function requestOnce() {
|
||||
requestNotificationPermission();
|
||||
document.removeEventListener('click', requestOnce);
|
||||
}, { once: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on first user interaction (required for Web Audio API)
|
||||
document.addEventListener('click', function initOnInteraction() {
|
||||
initAudio();
|
||||
document.removeEventListener('click', initOnInteraction);
|
||||
}, { once: true });
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* Intercept - Core Utility Functions
|
||||
* Pure utility functions with no DOM dependencies
|
||||
*/
|
||||
|
||||
// ============== HTML ESCAPING ==============
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
* @param {string} text - Text to escape
|
||||
* @returns {string} Escaped HTML
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape text for use in HTML attributes (especially onclick handlers)
|
||||
* @param {string} text - Text to escape
|
||||
* @returns {string} Escaped attribute value
|
||||
*/
|
||||
function escapeAttr(text) {
|
||||
if (text === null || text === undefined) return '';
|
||||
var s = String(text);
|
||||
s = s.replace(/&/g, '&');
|
||||
s = s.replace(/'/g, ''');
|
||||
s = s.replace(/"/g, '"');
|
||||
s = s.replace(/</g, '<');
|
||||
s = s.replace(/>/g, '>');
|
||||
return s;
|
||||
}
|
||||
|
||||
// ============== VALIDATION ==============
|
||||
|
||||
/**
|
||||
* Validate MAC address format (XX:XX:XX:XX:XX:XX)
|
||||
* @param {string} mac - MAC address to validate
|
||||
* @returns {boolean} True if valid
|
||||
*/
|
||||
function isValidMac(mac) {
|
||||
return /^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/.test(mac);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate WiFi channel (1-200 covers all bands)
|
||||
* @param {string|number} ch - Channel number
|
||||
* @returns {boolean} True if valid
|
||||
*/
|
||||
function isValidChannel(ch) {
|
||||
const num = parseInt(ch, 10);
|
||||
return !isNaN(num) && num >= 1 && num <= 200;
|
||||
}
|
||||
|
||||
// ============== TIME FORMATTING ==============
|
||||
|
||||
/**
|
||||
* Get relative time string from timestamp
|
||||
* @param {string} timestamp - Time string in HH:MM:SS format
|
||||
* @returns {string} Relative time like "5s ago", "2m ago"
|
||||
*/
|
||||
function getRelativeTime(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
const now = new Date();
|
||||
const parts = timestamp.split(':');
|
||||
const msgTime = new Date();
|
||||
msgTime.setHours(parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2]));
|
||||
|
||||
const diff = Math.floor((now - msgTime) / 1000);
|
||||
if (diff < 5) return 'just now';
|
||||
if (diff < 60) return diff + 's ago';
|
||||
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format UTC time string
|
||||
* @param {Date} date - Date object
|
||||
* @returns {string} UTC time in HH:MM:SS format
|
||||
*/
|
||||
function formatUtcTime(date) {
|
||||
return date.toISOString().substring(11, 19);
|
||||
}
|
||||
|
||||
// ============== DISTANCE CALCULATIONS ==============
|
||||
|
||||
/**
|
||||
* Calculate distance between two points in nautical miles
|
||||
* Uses Haversine formula
|
||||
* @param {number} lat1 - Latitude of first point
|
||||
* @param {number} lon1 - Longitude of first point
|
||||
* @param {number} lat2 - Latitude of second point
|
||||
* @param {number} lon2 - Longitude of second point
|
||||
* @returns {number} Distance in nautical miles
|
||||
*/
|
||||
function calculateDistanceNm(lat1, lon1, lat2, lon2) {
|
||||
const R = 3440.065; // Earth radius in nautical miles
|
||||
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||
const dLon = (lon2 - lon1) * Math.PI / 180;
|
||||
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
|
||||
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
||||
Math.sin(dLon/2) * Math.sin(dLon/2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
|
||||
return R * c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate distance between two points in kilometers
|
||||
* @param {number} lat1 - Latitude of first point
|
||||
* @param {number} lon1 - Longitude of first point
|
||||
* @param {number} lat2 - Latitude of second point
|
||||
* @param {number} lon2 - Longitude of second point
|
||||
* @returns {number} Distance in kilometers
|
||||
*/
|
||||
function calculateDistanceKm(lat1, lon1, lat2, lon2) {
|
||||
const R = 6371; // Earth radius in kilometers
|
||||
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||
const dLon = (lon2 - lon1) * Math.PI / 180;
|
||||
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
|
||||
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
||||
Math.sin(dLon/2) * Math.sin(dLon/2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
|
||||
return R * c;
|
||||
}
|
||||
|
||||
// ============== FILE OPERATIONS ==============
|
||||
|
||||
/**
|
||||
* Download content as a file
|
||||
* @param {string} content - File content
|
||||
* @param {string} filename - Name for the downloaded file
|
||||
* @param {string} type - MIME type
|
||||
*/
|
||||
function downloadFile(content, filename, type) {
|
||||
const blob = new Blob([content], { type });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// ============== FREQUENCY FORMATTING ==============
|
||||
|
||||
/**
|
||||
* Format frequency value with proper units
|
||||
* @param {number} freqMhz - Frequency in MHz
|
||||
* @param {number} decimals - Number of decimal places (default 3)
|
||||
* @returns {string} Formatted frequency string
|
||||
*/
|
||||
function formatFrequency(freqMhz, decimals = 3) {
|
||||
return freqMhz.toFixed(decimals) + ' MHz';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse frequency string to MHz
|
||||
* @param {string} freqStr - Frequency string (e.g., "118.0", "118.0 MHz")
|
||||
* @returns {number} Frequency in MHz
|
||||
*/
|
||||
function parseFrequency(freqStr) {
|
||||
return parseFloat(freqStr.replace(/[^\d.-]/g, ''));
|
||||
}
|
||||
|
||||
// ============== LOCAL STORAGE HELPERS ==============
|
||||
|
||||
/**
|
||||
* Get item from localStorage with JSON parsing
|
||||
* @param {string} key - Storage key
|
||||
* @param {*} defaultValue - Default value if key doesn't exist
|
||||
* @returns {*} Parsed value or default
|
||||
*/
|
||||
function getStorageItem(key, defaultValue = null) {
|
||||
const saved = localStorage.getItem(key);
|
||||
if (saved === null) return defaultValue;
|
||||
try {
|
||||
return JSON.parse(saved);
|
||||
} catch (e) {
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set item in localStorage with JSON stringification
|
||||
* @param {string} key - Storage key
|
||||
* @param {*} value - Value to store
|
||||
*/
|
||||
function setStorageItem(key, value) {
|
||||
if (typeof value === 'object') {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} else {
|
||||
localStorage.setItem(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
// ============== ARRAY/OBJECT UTILITIES ==============
|
||||
|
||||
/**
|
||||
* Debounce function execution
|
||||
* @param {Function} func - Function to debounce
|
||||
* @param {number} wait - Wait time in milliseconds
|
||||
* @returns {Function} Debounced function
|
||||
*/
|
||||
function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Throttle function execution
|
||||
* @param {Function} func - Function to throttle
|
||||
* @param {number} limit - Time limit in milliseconds
|
||||
* @returns {Function} Throttled function
|
||||
*/
|
||||
function throttle(func, limit) {
|
||||
let inThrottle;
|
||||
return function executedFunction(...args) {
|
||||
if (!inThrottle) {
|
||||
func(...args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => inThrottle = false, limit);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ============== NUMBER FORMATTING ==============
|
||||
|
||||
/**
|
||||
* Format large numbers with K/M suffixes
|
||||
* @param {number} num - Number to format
|
||||
* @returns {string} Formatted string
|
||||
*/
|
||||
function formatNumber(num) {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + 'M';
|
||||
}
|
||||
if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'K';
|
||||
}
|
||||
return num.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp a number between min and max
|
||||
* @param {number} num - Number to clamp
|
||||
* @param {number} min - Minimum value
|
||||
* @param {number} max - Maximum value
|
||||
* @returns {number} Clamped value
|
||||
*/
|
||||
function clamp(num, min, max) {
|
||||
return Math.min(Math.max(num, min), max);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a value from one range to another
|
||||
* @param {number} value - Value to map
|
||||
* @param {number} inMin - Input range minimum
|
||||
* @param {number} inMax - Input range maximum
|
||||
* @param {number} outMin - Output range minimum
|
||||
* @param {number} outMax - Output range maximum
|
||||
* @returns {number} Mapped value
|
||||
*/
|
||||
function mapRange(value, inMin, inMax, outMin, outMax) {
|
||||
return (value - inMin) * (outMax - outMin) / (inMax - inMin) + outMin;
|
||||
}
|
||||
@@ -3,8 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SATELLITE COMMAND // INTERCEPT</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;700;900&family=Rajdhani:wght@300;400;500;600;700&family=JetBrains+Mono:wght@300;400;500&display=swap" rel="stylesheet">
|
||||
<title>SATELLITE COMMAND // iNTERCEPT - See the Invisible</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/satellite_dashboard.css') }}">
|
||||
@@ -16,7 +16,7 @@
|
||||
<header class="header">
|
||||
<div class="logo">
|
||||
SATELLITE COMMAND
|
||||
<span>// INTERCEPT</span>
|
||||
<span>// iNTERCEPT - See the Invisible</span>
|
||||
</div>
|
||||
<div class="stats-badges">
|
||||
<div class="stat-badge">
|
||||
@@ -183,6 +183,10 @@
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// Check if embedded mode
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const isEmbedded = urlParams.get('embedded') === 'true';
|
||||
|
||||
// Dashboard state
|
||||
let passes = [];
|
||||
let selectedPass = null;
|
||||
@@ -223,7 +227,29 @@
|
||||
calculatePasses();
|
||||
}
|
||||
|
||||
function setupEmbeddedMode() {
|
||||
if (isEmbedded) {
|
||||
// Hide back link when embedded
|
||||
const backLink = document.querySelector('.back-link');
|
||||
if (backLink) backLink.style.display = 'none';
|
||||
|
||||
// Add embedded class to body for CSS adjustments
|
||||
document.body.classList.add('embedded');
|
||||
|
||||
// Compact the header slightly
|
||||
const header = document.querySelector('.header');
|
||||
if (header) header.style.padding = '10px 20px';
|
||||
|
||||
// Hide decorative elements
|
||||
const gridBg = document.querySelector('.grid-bg');
|
||||
const scanline = document.querySelector('.scanline');
|
||||
if (gridBg) gridBg.style.display = 'none';
|
||||
if (scanline) scanline.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
setupEmbeddedMode();
|
||||
initGroundMap();
|
||||
updateClock();
|
||||
setInterval(updateClock, 1000);
|
||||
@@ -247,8 +273,8 @@
|
||||
worldCopyJump: true
|
||||
});
|
||||
|
||||
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(groundMap);
|
||||
}
|
||||
|
||||
@@ -361,7 +387,7 @@
|
||||
|
||||
const cx = canvas.width / 2;
|
||||
const cy = canvas.height / 2;
|
||||
const radius = Math.min(cx, cy) - 40;
|
||||
const radius = Math.max(10, Math.min(cx, cy) - 40);
|
||||
|
||||
ctx.fillStyle = '#0a0a0f';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
@@ -720,7 +746,7 @@
|
||||
|
||||
const cx = canvas.width / 2;
|
||||
const cy = canvas.height / 2;
|
||||
const radius = Math.min(cx, cy) - 40;
|
||||
const radius = Math.max(10, Math.min(cx, cy) - 40);
|
||||
|
||||
ctx.fillStyle = '#0a0a0f';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
@@ -818,7 +844,7 @@
|
||||
|
||||
const cx = canvas.width / 2;
|
||||
const cy = canvas.height / 2;
|
||||
const radius = Math.min(cx, cy) - 40;
|
||||
const radius = Math.max(10, Math.min(cx, cy) - 40);
|
||||
|
||||
if (el > -5) {
|
||||
const posEl = Math.max(0, el);
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import pytest
|
||||
import json
|
||||
import subprocess
|
||||
from unittest.mock import MagicMock, patch
|
||||
from flask import Flask
|
||||
from routes.bluetooth import bluetooth_bp, classify_bt_device, detect_tracker
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_app_module(mocker):
|
||||
mock_app = mocker.patch("routes.bluetooth.app_module")
|
||||
mock_app.bt_devices = {}
|
||||
mock_app.bt_beacons = {}
|
||||
mock_app.bt_services = {}
|
||||
mock_app.bt_queue = MagicMock()
|
||||
mock_app.bt_lock = MagicMock()
|
||||
mock_app.bt_process = None
|
||||
mock_app.bt_interface = "hci0"
|
||||
return mock_app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(bluetooth_bp)
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
return app.test_client()
|
||||
|
||||
|
||||
def test_classify_bt_device_by_name():
|
||||
"""Test classification based on common naming patterns."""
|
||||
assert classify_bt_device("Sony WH-1000XM4", None, None) == "audio"
|
||||
assert classify_bt_device("iPhone 15", None, None) == "phone"
|
||||
assert classify_bt_device("Garmin Fenix", None, None) == "wearable"
|
||||
assert classify_bt_device("Microsoft Mouse", None, None) == "input"
|
||||
assert classify_bt_device("AirTag", None, None) == "tracker"
|
||||
assert classify_bt_device("Generic Device", None, None) == "other"
|
||||
|
||||
|
||||
def test_classify_bt_device_by_class():
|
||||
"""Test classification based on Bluetooth Class of Device (CoD)."""
|
||||
assert classify_bt_device(None, 0x0100, None) == "computer"
|
||||
assert classify_bt_device(None, 0x0200, None) == "phone"
|
||||
assert classify_bt_device(None, 0x0400, None) == "audio"
|
||||
|
||||
|
||||
def test_detect_tracker_by_mac():
|
||||
"""Test tracker detection using MAC OUI prefixes."""
|
||||
# Assuming 'FF:FF:FF' is a mock prefix in patterns for testing
|
||||
with patch("routes.bluetooth.TILE_PREFIXES", ["FF:FF"]):
|
||||
result = detect_tracker("FF:FF:00:11:22:33", "Unknown")
|
||||
assert result["type"] == "tile"
|
||||
|
||||
|
||||
def test_detect_tracker_by_name():
|
||||
"""Test tracker detection using name strings."""
|
||||
result = detect_tracker("00:11:22:33:44:55", "My AirTag")
|
||||
assert result["type"] == "airtag"
|
||||
assert result["risk"] == "high"
|
||||
|
||||
|
||||
# --- Route Tests ---
|
||||
|
||||
|
||||
def test_get_interfaces_route(client, mocker):
|
||||
"""Test the /interfaces endpoint with mocked system output."""
|
||||
mock_run = mocker.patch("subprocess.run")
|
||||
# Mocking hciconfig output for a Linux system
|
||||
mock_run.return_value = MagicMock(
|
||||
stdout="hci0:\tType: Primary Bus: USB\n\tBD Address: 00:11:22:33:44:55 ACL MTU: 1021:8 SCO MTU: 64:1\n\tUP RUNNING\n"
|
||||
)
|
||||
mocker.patch("platform.system", return_value="Linux")
|
||||
mocker.patch("routes.bluetooth.check_tool", return_value=True)
|
||||
|
||||
response = client.get("/bt/interfaces")
|
||||
data = response.get_json()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert data["interfaces"][0]["name"] == "hci0"
|
||||
assert data["interfaces"][0]["status"] == "up"
|
||||
assert data["tools"]["hcitool"] is True
|
||||
|
||||
|
||||
def test_stop_scan_route(client, mock_app_module):
|
||||
"""Test stopping a running scan process."""
|
||||
mock_process = MagicMock()
|
||||
mock_app_module.bt_process = mock_process
|
||||
|
||||
response = client.post("/bt/scan/stop")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["status"] == "stopped"
|
||||
mock_process.terminate.assert_called_once()
|
||||
|
||||
|
||||
def test_enum_services_error_no_mac(client):
|
||||
"""Test service enumeration validation."""
|
||||
response = client.post("/bt/enum", json={})
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["status"] == "error"
|
||||
|
||||
|
||||
def test_get_devices_route(client, mock_app_module):
|
||||
"""Test retrieving the current device list from memory."""
|
||||
mock_app_module.bt_devices = {"00:11:22:33:44:55": {"mac": "00:11:22:33:44:55", "name": "Test Device"}}
|
||||
|
||||
response = client.get("/bt/devices")
|
||||
data = response.get_json()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert len(data["devices"]) == 1
|
||||
assert data["devices"][0]["name"] == "Test Device"
|
||||
|
||||
|
||||
def test_reload_oui_route(client, mocker):
|
||||
"""Test the OUI database reload functionality."""
|
||||
mocker.patch("routes.bluetooth.load_oui_database", return_value={"001122": "Test Corp"})
|
||||
|
||||
response = client.post("/bt/reload-oui")
|
||||
data = response.get_json()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert data["status"] == "success"
|
||||
assert data["entries"] > 0
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
"""Tests for device correlation engine."""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
|
||||
class TestDeviceCorrelator:
|
||||
"""Tests for DeviceCorrelator class."""
|
||||
|
||||
def test_correlate_same_oui(self):
|
||||
"""Test correlation detects same OUI."""
|
||||
from utils.correlation import DeviceCorrelator
|
||||
|
||||
correlator = DeviceCorrelator(time_window_seconds=60)
|
||||
|
||||
wifi_devices = {
|
||||
'AA:BB:CC:11:22:33': {
|
||||
'first_seen': datetime.now(),
|
||||
'last_seen': datetime.now(),
|
||||
'essid': 'TestNetwork',
|
||||
'power': -65
|
||||
}
|
||||
}
|
||||
|
||||
bt_devices = {
|
||||
'AA:BB:CC:44:55:66': {
|
||||
'first_seen': datetime.now(),
|
||||
'last_seen': datetime.now(),
|
||||
'name': 'TestPhone',
|
||||
'rssi': -60
|
||||
}
|
||||
}
|
||||
|
||||
correlations = correlator.correlate(wifi_devices, bt_devices)
|
||||
|
||||
assert len(correlations) >= 1
|
||||
assert correlations[0]['wifi_mac'] == 'AA:BB:CC:11:22:33'
|
||||
assert correlations[0]['bt_mac'] == 'AA:BB:CC:44:55:66'
|
||||
assert correlations[0]['confidence'] > 0
|
||||
|
||||
def test_correlate_timing(self):
|
||||
"""Test correlation considers timing."""
|
||||
from utils.correlation import DeviceCorrelator
|
||||
|
||||
correlator = DeviceCorrelator(time_window_seconds=30)
|
||||
now = datetime.now()
|
||||
|
||||
# Devices appearing at the same time
|
||||
wifi_devices = {
|
||||
'11:22:33:44:55:66': {
|
||||
'first_seen': now,
|
||||
'last_seen': now,
|
||||
'essid': 'Network1'
|
||||
}
|
||||
}
|
||||
|
||||
bt_devices = {
|
||||
'77:88:99:AA:BB:CC': {
|
||||
'first_seen': now,
|
||||
'last_seen': now,
|
||||
'name': 'Device1'
|
||||
}
|
||||
}
|
||||
|
||||
correlations = correlator.correlate(wifi_devices, bt_devices)
|
||||
|
||||
# Should have some confidence from timing correlation
|
||||
if correlations:
|
||||
assert correlations[0]['confidence'] > 0
|
||||
|
||||
def test_correlate_no_overlap(self):
|
||||
"""Test no correlation when devices don't overlap."""
|
||||
from utils.correlation import DeviceCorrelator
|
||||
|
||||
correlator = DeviceCorrelator(
|
||||
time_window_seconds=30,
|
||||
min_confidence=0.6
|
||||
)
|
||||
|
||||
now = datetime.now()
|
||||
old = now - timedelta(hours=1)
|
||||
|
||||
wifi_devices = {
|
||||
'11:22:33:44:55:66': {
|
||||
'first_seen': old,
|
||||
'last_seen': old,
|
||||
'essid': 'OldNetwork'
|
||||
}
|
||||
}
|
||||
|
||||
bt_devices = {
|
||||
'77:88:99:AA:BB:CC': {
|
||||
'first_seen': now,
|
||||
'last_seen': now,
|
||||
'name': 'NewDevice'
|
||||
}
|
||||
}
|
||||
|
||||
correlations = correlator.correlate(wifi_devices, bt_devices)
|
||||
|
||||
# With high min_confidence and no OUI match, should be empty
|
||||
assert len(correlations) == 0
|
||||
|
||||
def test_correlate_manufacturer_match(self):
|
||||
"""Test correlation boosts confidence for same manufacturer."""
|
||||
from utils.correlation import DeviceCorrelator
|
||||
|
||||
correlator = DeviceCorrelator(time_window_seconds=60)
|
||||
now = datetime.now()
|
||||
|
||||
wifi_devices = {
|
||||
'11:22:33:44:55:66': {
|
||||
'first_seen': now,
|
||||
'last_seen': now,
|
||||
'manufacturer': 'Apple',
|
||||
'essid': 'Network'
|
||||
}
|
||||
}
|
||||
|
||||
bt_devices = {
|
||||
'77:88:99:AA:BB:CC': {
|
||||
'first_seen': now,
|
||||
'last_seen': now,
|
||||
'manufacturer': 'Apple',
|
||||
'name': 'iPhone'
|
||||
}
|
||||
}
|
||||
|
||||
correlations = correlator.correlate(wifi_devices, bt_devices)
|
||||
|
||||
# Should have correlation with bonus for manufacturer match
|
||||
assert len(correlations) >= 1
|
||||
|
||||
def test_correlate_empty_inputs(self):
|
||||
"""Test correlation handles empty inputs."""
|
||||
from utils.correlation import DeviceCorrelator
|
||||
|
||||
correlator = DeviceCorrelator()
|
||||
|
||||
# Empty WiFi
|
||||
assert correlator.correlate({}, {'AA:BB:CC:DD:EE:FF': {}}) == []
|
||||
|
||||
# Empty Bluetooth
|
||||
assert correlator.correlate({'AA:BB:CC:DD:EE:FF': {}}, {}) == []
|
||||
|
||||
# Both empty
|
||||
assert correlator.correlate({}, {}) == []
|
||||
|
||||
def test_correlate_sorting(self):
|
||||
"""Test correlations are sorted by confidence."""
|
||||
from utils.correlation import DeviceCorrelator
|
||||
|
||||
correlator = DeviceCorrelator(
|
||||
time_window_seconds=60,
|
||||
min_confidence=0.0
|
||||
)
|
||||
now = datetime.now()
|
||||
|
||||
wifi_devices = {
|
||||
'AA:BB:CC:11:11:11': {
|
||||
'first_seen': now,
|
||||
'last_seen': now,
|
||||
'manufacturer': 'Apple'
|
||||
},
|
||||
'11:22:33:44:55:66': {
|
||||
'first_seen': now,
|
||||
'last_seen': now
|
||||
}
|
||||
}
|
||||
|
||||
bt_devices = {
|
||||
'AA:BB:CC:22:22:22': {
|
||||
'first_seen': now,
|
||||
'last_seen': now,
|
||||
'manufacturer': 'Apple'
|
||||
},
|
||||
'77:88:99:AA:BB:CC': {
|
||||
'first_seen': now,
|
||||
'last_seen': now
|
||||
}
|
||||
}
|
||||
|
||||
correlations = correlator.correlate(wifi_devices, bt_devices)
|
||||
|
||||
if len(correlations) >= 2:
|
||||
# Should be sorted by confidence (highest first)
|
||||
assert correlations[0]['confidence'] >= correlations[1]['confidence']
|
||||
|
||||
|
||||
class TestGetCorrelations:
|
||||
"""Tests for get_correlations function."""
|
||||
|
||||
@patch('utils.correlation.correlator')
|
||||
@patch('utils.correlation.db_get_correlations')
|
||||
def test_get_correlations_live(self, mock_db, mock_correlator):
|
||||
"""Test get_correlations with live data."""
|
||||
from utils.correlation import get_correlations
|
||||
|
||||
mock_correlator.correlate.return_value = [
|
||||
{
|
||||
'wifi_mac': 'AA:AA:AA:AA:AA:AA',
|
||||
'bt_mac': 'BB:BB:BB:BB:BB:BB',
|
||||
'confidence': 0.8
|
||||
}
|
||||
]
|
||||
mock_db.return_value = []
|
||||
|
||||
wifi = {'AA:AA:AA:AA:AA:AA': {}}
|
||||
bt = {'BB:BB:BB:BB:BB:BB': {}}
|
||||
|
||||
results = get_correlations(
|
||||
wifi_devices=wifi,
|
||||
bt_devices=bt,
|
||||
include_historical=False
|
||||
)
|
||||
|
||||
assert len(results) == 1
|
||||
mock_correlator.correlate.assert_called_once()
|
||||
|
||||
@patch('utils.correlation.correlator')
|
||||
@patch('utils.correlation.db_get_correlations')
|
||||
def test_get_correlations_historical(self, mock_db, mock_correlator):
|
||||
"""Test get_correlations includes historical data."""
|
||||
from utils.correlation import get_correlations
|
||||
|
||||
mock_correlator.correlate.return_value = []
|
||||
mock_db.return_value = [
|
||||
{
|
||||
'wifi_mac': 'CC:CC:CC:CC:CC:CC',
|
||||
'bt_mac': 'DD:DD:DD:DD:DD:DD',
|
||||
'confidence': 0.7,
|
||||
'first_seen': '2024-01-01',
|
||||
'last_seen': '2024-01-02'
|
||||
}
|
||||
]
|
||||
|
||||
results = get_correlations(
|
||||
wifi_devices={},
|
||||
bt_devices={},
|
||||
include_historical=True
|
||||
)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0]['wifi_mac'] == 'CC:CC:CC:CC:CC:CC'
|
||||
|
||||
@patch('utils.correlation.correlator')
|
||||
@patch('utils.correlation.db_get_correlations')
|
||||
def test_get_correlations_deduplication(self, mock_db, mock_correlator):
|
||||
"""Test get_correlations deduplicates live and historical."""
|
||||
from utils.correlation import get_correlations
|
||||
|
||||
# Same correlation from both sources
|
||||
mock_correlator.correlate.return_value = [
|
||||
{
|
||||
'wifi_mac': 'AA:AA:AA:AA:AA:AA',
|
||||
'bt_mac': 'BB:BB:BB:BB:BB:BB',
|
||||
'confidence': 0.8
|
||||
}
|
||||
]
|
||||
mock_db.return_value = [
|
||||
{
|
||||
'wifi_mac': 'AA:AA:AA:AA:AA:AA',
|
||||
'bt_mac': 'BB:BB:BB:BB:BB:BB',
|
||||
'confidence': 0.7,
|
||||
'first_seen': '2024-01-01',
|
||||
'last_seen': '2024-01-02'
|
||||
}
|
||||
]
|
||||
|
||||
wifi = {'AA:AA:AA:AA:AA:AA': {}}
|
||||
bt = {'BB:BB:BB:BB:BB:BB': {}}
|
||||
|
||||
results = get_correlations(
|
||||
wifi_devices=wifi,
|
||||
bt_devices=bt,
|
||||
include_historical=True
|
||||
)
|
||||
|
||||
# Should deduplicate - only one entry for the same device pair
|
||||
matching = [r for r in results
|
||||
if r['wifi_mac'] == 'AA:AA:AA:AA:AA:AA']
|
||||
assert len(matching) == 1
|
||||
|
||||
|
||||
class TestCorrelationReason:
|
||||
"""Tests for correlation reason generation."""
|
||||
|
||||
def test_reason_same_oui(self):
|
||||
"""Test reason includes OUI match."""
|
||||
from utils.correlation import DeviceCorrelator
|
||||
|
||||
correlator = DeviceCorrelator()
|
||||
now = datetime.now()
|
||||
|
||||
wifi_devices = {
|
||||
'AA:BB:CC:11:22:33': {
|
||||
'first_seen': now,
|
||||
'last_seen': now
|
||||
}
|
||||
}
|
||||
|
||||
bt_devices = {
|
||||
'AA:BB:CC:44:55:66': {
|
||||
'first_seen': now,
|
||||
'last_seen': now
|
||||
}
|
||||
}
|
||||
|
||||
correlations = correlator.correlate(wifi_devices, bt_devices)
|
||||
|
||||
if correlations:
|
||||
assert 'OUI' in correlations[0]['reason'] or 'same' in correlations[0]['reason'].lower()
|
||||
|
||||
def test_reason_timing(self):
|
||||
"""Test reason includes timing information."""
|
||||
from utils.correlation import DeviceCorrelator
|
||||
|
||||
correlator = DeviceCorrelator(time_window_seconds=60)
|
||||
now = datetime.now()
|
||||
|
||||
wifi_devices = {
|
||||
'11:22:33:44:55:66': {
|
||||
'first_seen': now,
|
||||
'last_seen': now
|
||||
}
|
||||
}
|
||||
|
||||
bt_devices = {
|
||||
'77:88:99:AA:BB:CC': {
|
||||
'first_seen': now + timedelta(seconds=5),
|
||||
'last_seen': now + timedelta(seconds=5)
|
||||
}
|
||||
}
|
||||
|
||||
correlations = correlator.correlate(wifi_devices, bt_devices)
|
||||
|
||||
# If correlation found, should mention timing
|
||||
if correlations and correlations[0]['confidence'] > 0.3:
|
||||
assert 'appeared' in correlations[0]['reason'] or 'timing' in correlations[0]['reason']
|
||||
@@ -0,0 +1,256 @@
|
||||
"""Tests for database utilities."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
# Need to patch DB_PATH before importing database module
|
||||
@pytest.fixture(autouse=True)
|
||||
def temp_db():
|
||||
"""Use a temporary database for each test."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
test_db_path = Path(tmpdir) / 'test_intercept.db'
|
||||
test_db_dir = Path(tmpdir)
|
||||
|
||||
with patch('utils.database.DB_PATH', test_db_path), \
|
||||
patch('utils.database.DB_DIR', test_db_dir):
|
||||
# Import after patching
|
||||
from utils.database import init_db, close_db
|
||||
|
||||
init_db()
|
||||
yield test_db_path
|
||||
close_db()
|
||||
|
||||
|
||||
class TestSettingsCRUD:
|
||||
"""Tests for settings CRUD operations."""
|
||||
|
||||
def test_set_and_get_string(self, temp_db):
|
||||
"""Test setting and getting string values."""
|
||||
from utils.database import set_setting, get_setting
|
||||
|
||||
set_setting('test_key', 'test_value')
|
||||
assert get_setting('test_key') == 'test_value'
|
||||
|
||||
def test_set_and_get_int(self, temp_db):
|
||||
"""Test setting and getting integer values."""
|
||||
from utils.database import set_setting, get_setting
|
||||
|
||||
set_setting('int_key', 42)
|
||||
result = get_setting('int_key')
|
||||
assert result == 42
|
||||
assert isinstance(result, int)
|
||||
|
||||
def test_set_and_get_float(self, temp_db):
|
||||
"""Test setting and getting float values."""
|
||||
from utils.database import set_setting, get_setting
|
||||
|
||||
set_setting('float_key', 3.14)
|
||||
result = get_setting('float_key')
|
||||
assert result == 3.14
|
||||
assert isinstance(result, float)
|
||||
|
||||
def test_set_and_get_bool(self, temp_db):
|
||||
"""Test setting and getting boolean values."""
|
||||
from utils.database import set_setting, get_setting
|
||||
|
||||
set_setting('bool_true', True)
|
||||
set_setting('bool_false', False)
|
||||
|
||||
assert get_setting('bool_true') is True
|
||||
assert get_setting('bool_false') is False
|
||||
|
||||
def test_set_and_get_dict(self, temp_db):
|
||||
"""Test setting and getting dictionary values."""
|
||||
from utils.database import set_setting, get_setting
|
||||
|
||||
test_dict = {'name': 'test', 'value': 123, 'nested': {'a': 1}}
|
||||
set_setting('dict_key', test_dict)
|
||||
result = get_setting('dict_key')
|
||||
|
||||
assert result == test_dict
|
||||
assert result['nested']['a'] == 1
|
||||
|
||||
def test_set_and_get_list(self, temp_db):
|
||||
"""Test setting and getting list values."""
|
||||
from utils.database import set_setting, get_setting
|
||||
|
||||
test_list = [1, 2, 3, 'four', {'five': 5}]
|
||||
set_setting('list_key', test_list)
|
||||
result = get_setting('list_key')
|
||||
|
||||
assert result == test_list
|
||||
|
||||
def test_get_nonexistent_key(self, temp_db):
|
||||
"""Test getting a key that doesn't exist."""
|
||||
from utils.database import get_setting
|
||||
|
||||
assert get_setting('nonexistent') is None
|
||||
assert get_setting('nonexistent', 'default') == 'default'
|
||||
|
||||
def test_update_existing_setting(self, temp_db):
|
||||
"""Test updating an existing setting."""
|
||||
from utils.database import set_setting, get_setting
|
||||
|
||||
set_setting('update_key', 'original')
|
||||
assert get_setting('update_key') == 'original'
|
||||
|
||||
set_setting('update_key', 'updated')
|
||||
assert get_setting('update_key') == 'updated'
|
||||
|
||||
def test_delete_setting(self, temp_db):
|
||||
"""Test deleting a setting."""
|
||||
from utils.database import set_setting, get_setting, delete_setting
|
||||
|
||||
set_setting('delete_key', 'value')
|
||||
assert get_setting('delete_key') == 'value'
|
||||
|
||||
result = delete_setting('delete_key')
|
||||
assert result is True
|
||||
assert get_setting('delete_key') is None
|
||||
|
||||
def test_delete_nonexistent_setting(self, temp_db):
|
||||
"""Test deleting a setting that doesn't exist."""
|
||||
from utils.database import delete_setting
|
||||
|
||||
result = delete_setting('nonexistent_key')
|
||||
assert result is False
|
||||
|
||||
def test_get_all_settings(self, temp_db):
|
||||
"""Test getting all settings."""
|
||||
from utils.database import set_setting, get_all_settings
|
||||
|
||||
set_setting('key1', 'value1')
|
||||
set_setting('key2', 42)
|
||||
set_setting('key3', True)
|
||||
|
||||
all_settings = get_all_settings()
|
||||
|
||||
assert 'key1' in all_settings
|
||||
assert all_settings['key1'] == 'value1'
|
||||
assert all_settings['key2'] == 42
|
||||
assert all_settings['key3'] is True
|
||||
|
||||
|
||||
class TestSignalHistory:
|
||||
"""Tests for signal history operations."""
|
||||
|
||||
def test_add_and_get_signal_reading(self, temp_db):
|
||||
"""Test adding and retrieving signal readings."""
|
||||
from utils.database import add_signal_reading, get_signal_history
|
||||
|
||||
add_signal_reading('wifi', 'AA:BB:CC:DD:EE:FF', -65)
|
||||
add_signal_reading('wifi', 'AA:BB:CC:DD:EE:FF', -62)
|
||||
add_signal_reading('wifi', 'AA:BB:CC:DD:EE:FF', -70)
|
||||
|
||||
history = get_signal_history('wifi', 'AA:BB:CC:DD:EE:FF')
|
||||
|
||||
assert len(history) == 3
|
||||
# Results should be in chronological order
|
||||
assert history[0]['signal'] == -65
|
||||
assert history[1]['signal'] == -62
|
||||
assert history[2]['signal'] == -70
|
||||
|
||||
def test_signal_history_with_metadata(self, temp_db):
|
||||
"""Test signal readings with metadata."""
|
||||
from utils.database import add_signal_reading, get_signal_history
|
||||
|
||||
metadata = {'channel': 6, 'ssid': 'TestNetwork'}
|
||||
add_signal_reading('wifi', 'AA:BB:CC:DD:EE:FF', -65, metadata)
|
||||
|
||||
history = get_signal_history('wifi', 'AA:BB:CC:DD:EE:FF')
|
||||
|
||||
assert len(history) == 1
|
||||
assert history[0]['metadata'] == metadata
|
||||
|
||||
def test_signal_history_limit(self, temp_db):
|
||||
"""Test signal history respects limit parameter."""
|
||||
from utils.database import add_signal_reading, get_signal_history
|
||||
|
||||
for i in range(10):
|
||||
add_signal_reading('wifi', 'AA:BB:CC:DD:EE:FF', -60 - i)
|
||||
|
||||
history = get_signal_history('wifi', 'AA:BB:CC:DD:EE:FF', limit=5)
|
||||
assert len(history) == 5
|
||||
|
||||
def test_signal_history_different_devices(self, temp_db):
|
||||
"""Test signal history isolates different devices."""
|
||||
from utils.database import add_signal_reading, get_signal_history
|
||||
|
||||
add_signal_reading('wifi', 'AA:AA:AA:AA:AA:AA', -65)
|
||||
add_signal_reading('wifi', 'BB:BB:BB:BB:BB:BB', -70)
|
||||
|
||||
history_a = get_signal_history('wifi', 'AA:AA:AA:AA:AA:AA')
|
||||
history_b = get_signal_history('wifi', 'BB:BB:BB:BB:BB:BB')
|
||||
|
||||
assert len(history_a) == 1
|
||||
assert len(history_b) == 1
|
||||
assert history_a[0]['signal'] == -65
|
||||
assert history_b[0]['signal'] == -70
|
||||
|
||||
def test_cleanup_old_signal_history(self, temp_db):
|
||||
"""Test cleanup of old signal history."""
|
||||
from utils.database import add_signal_reading, cleanup_old_signal_history
|
||||
|
||||
add_signal_reading('wifi', 'AA:BB:CC:DD:EE:FF', -65)
|
||||
|
||||
# Cleanup with 0 hours should remove everything
|
||||
deleted = cleanup_old_signal_history(max_age_hours=0)
|
||||
# Note: This may or may not delete depending on timing
|
||||
assert isinstance(deleted, int)
|
||||
|
||||
|
||||
class TestDeviceCorrelations:
|
||||
"""Tests for device correlation operations."""
|
||||
|
||||
def test_add_and_get_correlation(self, temp_db):
|
||||
"""Test adding and retrieving correlations."""
|
||||
from utils.database import add_correlation, get_correlations
|
||||
|
||||
add_correlation(
|
||||
wifi_mac='AA:AA:AA:AA:AA:AA',
|
||||
bt_mac='BB:BB:BB:BB:BB:BB',
|
||||
confidence=0.85,
|
||||
metadata={'reason': 'timing'}
|
||||
)
|
||||
|
||||
correlations = get_correlations(min_confidence=0.5)
|
||||
|
||||
assert len(correlations) >= 1
|
||||
found = next(
|
||||
(c for c in correlations
|
||||
if c['wifi_mac'] == 'AA:AA:AA:AA:AA:AA'),
|
||||
None
|
||||
)
|
||||
assert found is not None
|
||||
assert found['bt_mac'] == 'BB:BB:BB:BB:BB:BB'
|
||||
assert found['confidence'] == 0.85
|
||||
|
||||
def test_correlation_confidence_filter(self, temp_db):
|
||||
"""Test correlation filtering by confidence."""
|
||||
from utils.database import add_correlation, get_correlations
|
||||
|
||||
add_correlation('AA:AA:AA:AA:AA:AA', 'BB:BB:BB:BB:BB:BB', 0.9)
|
||||
add_correlation('CC:CC:CC:CC:CC:CC', 'DD:DD:DD:DD:DD:DD', 0.4)
|
||||
|
||||
high_confidence = get_correlations(min_confidence=0.7)
|
||||
all_confidence = get_correlations(min_confidence=0.3)
|
||||
|
||||
assert len(high_confidence) == 1
|
||||
assert len(all_confidence) == 2
|
||||
|
||||
def test_correlation_upsert(self, temp_db):
|
||||
"""Test that correlations are updated on conflict."""
|
||||
from utils.database import add_correlation, get_correlations
|
||||
|
||||
add_correlation('AA:AA:AA:AA:AA:AA', 'BB:BB:BB:BB:BB:BB', 0.5)
|
||||
add_correlation('AA:AA:AA:AA:AA:AA', 'BB:BB:BB:BB:BB:BB', 0.9)
|
||||
|
||||
correlations = get_correlations(min_confidence=0.0)
|
||||
|
||||
matching = [c for c in correlations
|
||||
if c['wifi_mac'] == 'AA:AA:AA:AA:AA:AA']
|
||||
assert len(matching) == 1
|
||||
assert matching[0]['confidence'] == 0.9
|
||||
@@ -0,0 +1,376 @@
|
||||
"""Tests for Flask routes and API endpoints."""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def app():
|
||||
"""Create application for testing."""
|
||||
import app as app_module
|
||||
from routes import register_blueprints
|
||||
from utils.database import init_db
|
||||
|
||||
app_module.app.config['TESTING'] = True
|
||||
|
||||
# Initialize database for settings tests
|
||||
init_db()
|
||||
|
||||
# Register blueprints only if not already registered (normally done in main())
|
||||
# Check if any blueprint is already registered to avoid re-registration
|
||||
if 'pager' not in app_module.app.blueprints:
|
||||
register_blueprints(app_module.app)
|
||||
|
||||
return app_module.app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
"""Create test client."""
|
||||
return app.test_client()
|
||||
|
||||
|
||||
class TestHealthEndpoint:
|
||||
"""Tests for health check endpoint."""
|
||||
|
||||
def test_health_check(self, client):
|
||||
"""Test health endpoint returns expected data."""
|
||||
response = client.get('/health')
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'healthy'
|
||||
assert 'version' in data
|
||||
assert 'uptime_seconds' in data
|
||||
assert 'processes' in data
|
||||
assert 'data' in data
|
||||
|
||||
def test_health_process_status(self, client):
|
||||
"""Test health endpoint reports process status."""
|
||||
response = client.get('/health')
|
||||
data = json.loads(response.data)
|
||||
|
||||
processes = data['processes']
|
||||
assert 'pager' in processes
|
||||
assert 'sensor' in processes
|
||||
assert 'adsb' in processes
|
||||
assert 'wifi' in processes
|
||||
assert 'bluetooth' in processes
|
||||
|
||||
|
||||
class TestDevicesEndpoint:
|
||||
"""Tests for devices endpoint."""
|
||||
|
||||
def test_get_devices(self, client):
|
||||
"""Test getting device list."""
|
||||
response = client.get('/devices')
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert isinstance(data, list)
|
||||
|
||||
@patch('app.SDRFactory.detect_devices')
|
||||
def test_devices_returns_list(self, mock_detect, client):
|
||||
"""Test devices endpoint returns list format."""
|
||||
mock_device = MagicMock()
|
||||
mock_device.to_dict.return_value = {
|
||||
'index': 0,
|
||||
'name': 'Test RTL-SDR',
|
||||
'sdr_type': 'rtlsdr'
|
||||
}
|
||||
mock_detect.return_value = [mock_device]
|
||||
|
||||
response = client.get('/devices')
|
||||
data = json.loads(response.data)
|
||||
|
||||
assert len(data) == 1
|
||||
assert data[0]['name'] == 'Test RTL-SDR'
|
||||
|
||||
|
||||
class TestDependenciesEndpoint:
|
||||
"""Tests for dependencies endpoint."""
|
||||
|
||||
def test_get_dependencies(self, client):
|
||||
"""Test getting dependency status."""
|
||||
response = client.get('/dependencies')
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert 'os' in data
|
||||
assert 'pkg_manager' in data
|
||||
assert 'modes' in data
|
||||
|
||||
|
||||
class TestSettingsEndpoints:
|
||||
"""Tests for settings API endpoints."""
|
||||
|
||||
def test_get_settings(self, client):
|
||||
"""Test getting all settings."""
|
||||
response = client.get('/settings')
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert 'settings' in data
|
||||
|
||||
def test_save_settings(self, client):
|
||||
"""Test saving settings."""
|
||||
response = client.post(
|
||||
'/settings',
|
||||
data=json.dumps({'test_key': 'test_value'}),
|
||||
content_type='application/json'
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert 'test_key' in data['saved']
|
||||
|
||||
def test_save_empty_settings(self, client):
|
||||
"""Test saving empty settings returns error."""
|
||||
response = client.post(
|
||||
'/settings',
|
||||
data=json.dumps({}),
|
||||
content_type='application/json'
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_get_single_setting(self, client):
|
||||
"""Test getting a single setting."""
|
||||
# First save a setting
|
||||
client.post(
|
||||
'/settings',
|
||||
data=json.dumps({'my_setting': 'my_value'}),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
# Then retrieve it
|
||||
response = client.get('/settings/my_setting')
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert data['value'] == 'my_value'
|
||||
|
||||
def test_get_nonexistent_setting(self, client):
|
||||
"""Test getting a setting that doesn't exist."""
|
||||
response = client.get('/settings/nonexistent_key_xyz')
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_update_setting(self, client):
|
||||
"""Test updating a setting via PUT."""
|
||||
response = client.put(
|
||||
'/settings/update_test',
|
||||
data=json.dumps({'value': 'updated_value'}),
|
||||
content_type='application/json'
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert data['value'] == 'updated_value'
|
||||
|
||||
def test_delete_setting(self, client):
|
||||
"""Test deleting a setting."""
|
||||
# First create a setting
|
||||
client.post(
|
||||
'/settings',
|
||||
data=json.dumps({'delete_me': 'value'}),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
# Then delete it
|
||||
response = client.delete('/settings/delete_me')
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert data['deleted'] is True
|
||||
|
||||
|
||||
class TestCorrelationEndpoints:
|
||||
"""Tests for correlation API endpoints."""
|
||||
|
||||
def test_get_correlations(self, client):
|
||||
"""Test getting device correlations."""
|
||||
response = client.get('/correlation')
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert 'correlations' in data
|
||||
assert 'wifi_count' in data
|
||||
assert 'bt_count' in data
|
||||
|
||||
def test_correlations_with_confidence_filter(self, client):
|
||||
"""Test correlation endpoint respects confidence filter."""
|
||||
response = client.get('/correlation?min_confidence=0.8')
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
|
||||
|
||||
class TestListeningPostEndpoints:
|
||||
"""Tests for listening post endpoints."""
|
||||
|
||||
def test_tools_check(self, client):
|
||||
"""Test listening post tools availability check."""
|
||||
response = client.get('/listening/tools')
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert 'rtl_fm' in data
|
||||
assert 'available' in data
|
||||
|
||||
def test_scanner_status(self, client):
|
||||
"""Test scanner status endpoint."""
|
||||
response = client.get('/listening/scanner/status')
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert 'running' in data
|
||||
assert 'paused' in data
|
||||
assert 'current_freq' in data
|
||||
|
||||
def test_presets(self, client):
|
||||
"""Test scanner presets endpoint."""
|
||||
response = client.get('/listening/presets')
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert 'presets' in data
|
||||
assert len(data['presets']) > 0
|
||||
|
||||
# Check preset structure
|
||||
preset = data['presets'][0]
|
||||
assert 'name' in preset
|
||||
assert 'start' in preset
|
||||
assert 'end' in preset
|
||||
assert 'mod' in preset
|
||||
|
||||
def test_scanner_stop_when_not_running(self, client):
|
||||
"""Test stopping scanner when not running."""
|
||||
response = client.post('/listening/scanner/stop')
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'stopped'
|
||||
|
||||
def test_activity_log(self, client):
|
||||
"""Test getting activity log."""
|
||||
response = client.get('/listening/scanner/log')
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert 'log' in data
|
||||
assert 'total' in data
|
||||
|
||||
def test_scanner_skip_when_not_running(self, client):
|
||||
"""Test skip signal when scanner not running returns error."""
|
||||
response = client.post('/listening/scanner/skip')
|
||||
assert response.status_code == 400
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'error'
|
||||
|
||||
|
||||
class TestAudioEndpoints:
|
||||
"""Tests for audio demodulation endpoints."""
|
||||
|
||||
def test_audio_status(self, client):
|
||||
"""Test audio status endpoint."""
|
||||
response = client.get('/listening/audio/status')
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert 'running' in data
|
||||
assert 'frequency' in data
|
||||
assert 'modulation' in data
|
||||
|
||||
def test_audio_stop_when_not_running(self, client):
|
||||
"""Test stopping audio when not running."""
|
||||
response = client.post('/listening/audio/stop')
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'stopped'
|
||||
|
||||
def test_audio_start_missing_frequency(self, client):
|
||||
"""Test starting audio without frequency returns error."""
|
||||
response = client.post(
|
||||
'/listening/audio/start',
|
||||
data=json.dumps({}),
|
||||
content_type='application/json'
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'error'
|
||||
assert 'frequency' in data['message'].lower()
|
||||
|
||||
def test_audio_start_invalid_modulation(self, client):
|
||||
"""Test starting audio with invalid modulation returns error."""
|
||||
response = client.post(
|
||||
'/listening/audio/start',
|
||||
data=json.dumps({
|
||||
'frequency': 98.1,
|
||||
'modulation': 'invalid_mode'
|
||||
}),
|
||||
content_type='application/json'
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'error'
|
||||
assert 'modulation' in data['message'].lower()
|
||||
|
||||
def test_audio_stream_when_not_running(self, client):
|
||||
"""Test audio stream when not running returns error."""
|
||||
response = client.get('/listening/audio/stream')
|
||||
assert response.status_code == 400
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'error'
|
||||
|
||||
|
||||
class TestExportEndpoints:
|
||||
"""Tests for data export endpoints."""
|
||||
|
||||
def test_export_aircraft_json(self, client):
|
||||
"""Test exporting aircraft data as JSON."""
|
||||
response = client.get('/export/aircraft?format=json')
|
||||
assert response.status_code == 200
|
||||
assert response.content_type == 'application/json'
|
||||
|
||||
def test_export_aircraft_csv(self, client):
|
||||
"""Test exporting aircraft data as CSV."""
|
||||
response = client.get('/export/aircraft?format=csv')
|
||||
assert response.status_code == 200
|
||||
assert 'text/csv' in response.content_type
|
||||
|
||||
def test_export_wifi_json(self, client):
|
||||
"""Test exporting WiFi data as JSON."""
|
||||
response = client.get('/export/wifi?format=json')
|
||||
assert response.status_code == 200
|
||||
assert response.content_type == 'application/json'
|
||||
|
||||
def test_export_wifi_csv(self, client):
|
||||
"""Test exporting WiFi data as CSV."""
|
||||
response = client.get('/export/wifi?format=csv')
|
||||
assert response.status_code == 200
|
||||
assert 'text/csv' in response.content_type
|
||||
|
||||
def test_export_bluetooth_json(self, client):
|
||||
"""Test exporting Bluetooth data as JSON."""
|
||||
response = client.get('/export/bluetooth?format=json')
|
||||
assert response.status_code == 200
|
||||
assert response.content_type == 'application/json'
|
||||
|
||||
def test_export_bluetooth_csv(self, client):
|
||||
"""Test exporting Bluetooth data as CSV."""
|
||||
response = client.get('/export/bluetooth?format=csv')
|
||||
assert response.status_code == 200
|
||||
assert 'text/csv' in response.content_type
|
||||
@@ -0,0 +1,83 @@
|
||||
import pytest
|
||||
import json
|
||||
from unittest.mock import patch, MagicMock
|
||||
from flask import Flask
|
||||
from routes.satellite import satellite_bp
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(satellite_bp)
|
||||
app.config['TESTING'] = True
|
||||
return app
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
return app.test_client()
|
||||
|
||||
def test_predict_passes_invalid_coords(client):
|
||||
"""Verify that invalid coordinates return a 400 error."""
|
||||
payload = {
|
||||
"latitude": 150.0, # Invalid (>90)
|
||||
"longitude": -0.1278
|
||||
}
|
||||
response = client.post('/satellite/predict', json=payload)
|
||||
assert response.status_code == 400
|
||||
assert response.json['status'] == 'error'
|
||||
|
||||
def test_fetch_celestrak_invalid_category(client):
|
||||
"""Verify that an unauthorized category is rejected."""
|
||||
response = client.get('/satellite/celestrak/category_fake')
|
||||
# The code returns 200 but includes an error message in the JSON body
|
||||
assert response.status_code == 200
|
||||
assert response.json['status'] == 'error'
|
||||
assert 'Invalid category' in response.json['message']
|
||||
|
||||
# Mocking Tests (External Calls and Skyfield)
|
||||
@patch('urllib.request.urlopen')
|
||||
def test_update_tle_success(mock_urlopen, client):
|
||||
"""Simulate a successful response from CelesTrak."""
|
||||
mock_content = (
|
||||
"ISS (ZARYA)\n"
|
||||
"1 25544U 98067A 23321.52083333 .00016717 00000-0 30171-3 0 9992\n"
|
||||
"2 25544 51.6416 20.4567 0004561 45.3212 67.8912 15.49876543123456\n"
|
||||
).encode('utf-8')
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = mock_content
|
||||
mock_response.__enter__.return_value = mock_response
|
||||
mock_urlopen.return_value = mock_response
|
||||
|
||||
response = client.post('/satellite/update-tle')
|
||||
assert response.status_code == 200
|
||||
assert response.json['status'] == 'success'
|
||||
assert 'ISS' in response.json['updated']
|
||||
|
||||
@patch('skyfield.api.load')
|
||||
def test_get_satellite_position_skyfield_error(mock_load, client):
|
||||
"""Test behavior when Skyfield fails or data is missing."""
|
||||
# Force the timescale load to fail
|
||||
mock_load.side_effect = Exception("Skyfield error")
|
||||
|
||||
payload = {
|
||||
"latitude": 51.5,
|
||||
"longitude": -0.1,
|
||||
"satellites": ["ISS"]
|
||||
}
|
||||
response = client.post('/satellite/position', json=payload)
|
||||
# Should return success but an empty positions list due to internal try-except
|
||||
assert response.status_code == 200
|
||||
assert response.json['positions'] == []
|
||||
|
||||
# Logic Integration Test (Simulating prediction)
|
||||
def test_predict_passes_empty_cache(client):
|
||||
"""Verify that if the satellite is not in cache, no passes are returned."""
|
||||
payload = {
|
||||
"latitude": 51.5,
|
||||
"longitude": -0.1,
|
||||
"satellites": ["SATELLITE_NON_EXISTENT"]
|
||||
}
|
||||
response = client.post('/satellite/predict', json=payload)
|
||||
assert response.status_code == 200
|
||||
assert len(response.json['passes']) == 0
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Comprehensive tests for validation utilities."""
|
||||
|
||||
import pytest
|
||||
from utils.validation import (
|
||||
validate_frequency,
|
||||
validate_gain,
|
||||
validate_device_index,
|
||||
validate_rtl_tcp_host,
|
||||
validate_rtl_tcp_port,
|
||||
)
|
||||
|
||||
|
||||
class TestFrequencyValidation:
|
||||
"""Tests for frequency validation."""
|
||||
|
||||
def test_valid_frequencies(self):
|
||||
"""Test valid frequency values."""
|
||||
assert validate_frequency('152.0') == '152.0'
|
||||
assert validate_frequency(152.0) == '152.0'
|
||||
assert validate_frequency('1090') == '1090'
|
||||
assert validate_frequency(433.92) == '433.92'
|
||||
|
||||
def test_frequency_range(self):
|
||||
"""Test frequency range limits."""
|
||||
# RTL-SDR typical range: 24MHz - 1766MHz
|
||||
assert validate_frequency('24') == '24'
|
||||
assert validate_frequency('1700') == '1700'
|
||||
|
||||
def test_invalid_frequencies(self):
|
||||
"""Test invalid frequency values."""
|
||||
with pytest.raises(ValueError):
|
||||
validate_frequency('')
|
||||
with pytest.raises(ValueError):
|
||||
validate_frequency('abc')
|
||||
with pytest.raises(ValueError):
|
||||
validate_frequency(-100)
|
||||
with pytest.raises(ValueError):
|
||||
validate_frequency(0)
|
||||
|
||||
|
||||
class TestGainValidation:
|
||||
"""Tests for gain validation."""
|
||||
|
||||
def test_valid_gains(self):
|
||||
"""Test valid gain values."""
|
||||
assert validate_gain('0') == '0'
|
||||
assert validate_gain('40') == '40'
|
||||
assert validate_gain(49.6) == '49.6'
|
||||
assert validate_gain('auto') == 'auto'
|
||||
|
||||
def test_invalid_gains(self):
|
||||
"""Test invalid gain values."""
|
||||
with pytest.raises(ValueError):
|
||||
validate_gain(-10)
|
||||
with pytest.raises(ValueError):
|
||||
validate_gain(100)
|
||||
with pytest.raises(ValueError):
|
||||
validate_gain('invalid')
|
||||
|
||||
|
||||
class TestDeviceIndexValidation:
|
||||
"""Tests for device index validation."""
|
||||
|
||||
def test_valid_indices(self):
|
||||
"""Test valid device indices."""
|
||||
assert validate_device_index('0') == '0'
|
||||
assert validate_device_index(0) == '0'
|
||||
assert validate_device_index('1') == '1'
|
||||
assert validate_device_index(3) == '3'
|
||||
|
||||
def test_invalid_indices(self):
|
||||
"""Test invalid device indices."""
|
||||
with pytest.raises(ValueError):
|
||||
validate_device_index(-1)
|
||||
with pytest.raises(ValueError):
|
||||
validate_device_index('abc')
|
||||
with pytest.raises(ValueError):
|
||||
validate_device_index(100)
|
||||
|
||||
|
||||
class TestRtlTcpHostValidation:
|
||||
"""Tests for RTL-TCP host validation."""
|
||||
|
||||
def test_valid_hosts(self):
|
||||
"""Test valid host values."""
|
||||
assert validate_rtl_tcp_host('localhost') == 'localhost'
|
||||
assert validate_rtl_tcp_host('127.0.0.1') == '127.0.0.1'
|
||||
assert validate_rtl_tcp_host('192.168.1.1') == '192.168.1.1'
|
||||
assert validate_rtl_tcp_host('server.example.com') == 'server.example.com'
|
||||
|
||||
def test_invalid_hosts(self):
|
||||
"""Test invalid host values."""
|
||||
with pytest.raises(ValueError):
|
||||
validate_rtl_tcp_host('')
|
||||
with pytest.raises(ValueError):
|
||||
validate_rtl_tcp_host('invalid host with spaces')
|
||||
with pytest.raises(ValueError):
|
||||
validate_rtl_tcp_host('host;rm -rf /')
|
||||
|
||||
|
||||
class TestRtlTcpPortValidation:
|
||||
"""Tests for RTL-TCP port validation."""
|
||||
|
||||
def test_valid_ports(self):
|
||||
"""Test valid port values."""
|
||||
assert validate_rtl_tcp_port(1234) == 1234
|
||||
assert validate_rtl_tcp_port('1234') == 1234
|
||||
assert validate_rtl_tcp_port(30003) == 30003
|
||||
assert validate_rtl_tcp_port(65535) == 65535
|
||||
|
||||
def test_invalid_ports(self):
|
||||
"""Test invalid port values."""
|
||||
with pytest.raises(ValueError):
|
||||
validate_rtl_tcp_port(0)
|
||||
with pytest.raises(ValueError):
|
||||
validate_rtl_tcp_port(-1)
|
||||
with pytest.raises(ValueError):
|
||||
validate_rtl_tcp_port(70000)
|
||||
with pytest.raises(ValueError):
|
||||
validate_rtl_tcp_port('abc')
|
||||
@@ -0,0 +1,221 @@
|
||||
import pytest
|
||||
import sys
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch, mock_open
|
||||
from flask import Flask
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||
from routes.wifi import wifi_bp, parse_airodump_csv
|
||||
|
||||
@pytest.fixture
|
||||
def mock_app_module(mocker):
|
||||
"""Mock the app_module imported inside routes.wifi."""
|
||||
mock = mocker.patch("routes.wifi.app_module")
|
||||
mock.wifi_lock = MagicMock()
|
||||
mock.wifi_process = None
|
||||
mock.wifi_monitor_interface = None
|
||||
mock.wifi_queue = MagicMock()
|
||||
mock.wifi_networks = {}
|
||||
mock_app_module.wifi_clients = {}
|
||||
return mock
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(wifi_bp)
|
||||
return app
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
return app.test_client()
|
||||
|
||||
def test_parse_airodump_csv(mocker):
|
||||
"""Test parsing logic for airodump CSV format."""
|
||||
csv_content = (
|
||||
"BSSID, First time seen, Last time seen, channel, Speed, Privacy, Cipher, Authentication, Power, # beacons, # IV, LAN IP, ID-length, ESSID, Key\n"
|
||||
"AA:BB:CC:DD:EE:FF, 2023-01-01, 2023-01-01, 6, 54, WPA2, CCMP, PSK, -50, 10, 5, 0.0.0.0, 7, MyWiFi, \n"
|
||||
"\n"
|
||||
"Station MAC, First time seen, Last time seen, Power, # packets, BSSID, Probes\n"
|
||||
"11:22:33:44:55:66, 2023-01-01, 2023-01-01, -60, 20, AA:BB:CC:DD:EE:FF, MyWiFi\n"
|
||||
)
|
||||
|
||||
with patch("builtins.open", mock_open(read_data=csv_content)):
|
||||
mocker.patch("routes.wifi.get_manufacturer", return_value="Apple")
|
||||
networks, clients = parse_airodump_csv("dummy.csv")
|
||||
|
||||
assert "AA:BB:CC:DD:EE:FF" in networks
|
||||
assert networks["AA:BB:CC:DD:EE:FF"]["essid"] == "MyWiFi"
|
||||
assert "11:22:33:44:55:66" in clients
|
||||
assert clients["11:22:33:44:55:66"]["vendor"] == "Apple"
|
||||
|
||||
### --- ROUTE TESTS --- ###
|
||||
|
||||
def test_get_interfaces(client, mocker):
|
||||
"""Test the /interfaces endpoint."""
|
||||
mocker.patch("routes.wifi.detect_wifi_interfaces", return_value=[{'name': 'wlan0', 'type': 'managed'}])
|
||||
mocker.patch("routes.wifi.check_tool", return_value=True)
|
||||
|
||||
response = client.get('/wifi/interfaces')
|
||||
data = response.get_json()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert len(data['interfaces']) == 1
|
||||
assert data['tools']['airmon'] is True
|
||||
|
||||
def test_toggle_monitor_start_success(client, mocker):
|
||||
"""Test enabling monitor mode via airmon-ng."""
|
||||
mocker.patch("routes.wifi.validate_network_interface", return_value="wlan0")
|
||||
mocker.patch("routes.wifi.check_tool", return_value=True)
|
||||
mock_run = mocker.patch("routes.wifi.subprocess.run")
|
||||
mock_run.return_value = MagicMock(stdout="enabled on [phy0]wlan0mon", stderr="", returncode=0)
|
||||
|
||||
with patch("os.path.exists", return_value=True):
|
||||
response = client.post('/wifi/monitor', json={'action': 'start', 'interface': 'wlan0'})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()['status'] == 'success'
|
||||
assert response.get_json()['monitor_interface'] == 'wlan0mon'
|
||||
|
||||
def test_start_scan_already_running(client, mock_app_module):
|
||||
"""Test that we can't start a scan if one is already active."""
|
||||
mock_app_module.wifi_process = MagicMock()
|
||||
|
||||
response = client.post('/wifi/scan/start', json={'interface': 'wlan0mon'})
|
||||
data = response.get_json()
|
||||
assert data['status'] == 'error'
|
||||
assert 'already running' in data['message']
|
||||
|
||||
def test_start_scan_execution(client, mock_app_module, mocker):
|
||||
"""Test the full command construction of airodump-ng."""
|
||||
mock_app_module.wifi_process = None
|
||||
mocker.patch("os.path.exists", return_value=True)
|
||||
mocker.patch("routes.wifi.get_tool_path", return_value="/usr/bin/airodump-ng")
|
||||
|
||||
mock_popen = mocker.patch("routes.wifi.subprocess.Popen")
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.poll.return_value = None
|
||||
mock_popen.return_value = mock_proc
|
||||
|
||||
payload = {'interface': 'wlan0mon', 'channel': 6, 'band': 'g'}
|
||||
response = client.post('/wifi/scan/start', json=payload)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()['status'] == 'started'
|
||||
|
||||
args, _ = mock_popen.call_args
|
||||
cmd = args[0]
|
||||
assert "-c" in cmd and "6" in cmd
|
||||
assert "wlan0mon" in cmd
|
||||
|
||||
def test_stop_scan(client, mock_app_module):
|
||||
"""Test terminating the scanning process."""
|
||||
mock_proc = MagicMock()
|
||||
mock_app_module.wifi_process = mock_proc
|
||||
|
||||
response = client.post('/wifi/scan/stop')
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()['status'] == 'stopped'
|
||||
mock_proc.terminate.assert_called_once()
|
||||
|
||||
def test_send_deauth_success(client, mock_app_module, mocker):
|
||||
"""Verify deauth command construction and execution."""
|
||||
mocker.patch("routes.wifi.check_tool", return_value=True)
|
||||
mocker.patch("routes.wifi.get_tool_path", return_value="/usr/bin/aireplay-ng")
|
||||
mock_run = mocker.patch("routes.wifi.subprocess.run")
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
|
||||
payload = {
|
||||
'bssid': 'AA:BB:CC:DD:EE:FF',
|
||||
'count': 10,
|
||||
'interface': 'wlan0mon'
|
||||
}
|
||||
response = client.post('/wifi/deauth', json=payload)
|
||||
|
||||
assert response.status_code == 200
|
||||
args, _ = mock_run.call_args
|
||||
cmd = args[0]
|
||||
assert "--deauth" in cmd
|
||||
assert "10" in cmd
|
||||
assert "AA:BB:CC:DD:EE:FF" in cmd
|
||||
|
||||
### --- HANDSHAKE TESTS --- ###
|
||||
|
||||
def test_capture_handshake_start(client, mock_app_module, mocker):
|
||||
"""Test starting airodump-ng for handshake capture."""
|
||||
mock_app_module.wifi_process = None
|
||||
mocker.patch("routes.wifi.get_tool_path", return_value="/usr/bin/airodump-ng")
|
||||
mock_popen = mocker.patch("routes.wifi.subprocess.Popen")
|
||||
|
||||
payload = {'bssid': 'AA:BB:CC:DD:EE:FF', 'channel': '6', 'interface': 'wlan0mon'}
|
||||
response = client.post('/wifi/handshake/capture', json=payload)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'capture_file' in response.get_json()
|
||||
assert mock_popen.called
|
||||
|
||||
def test_check_handshake_status_found(client, mocker):
|
||||
"""Verify detection of 'KEY FOUND' in aircrack output."""
|
||||
mocker.patch("os.path.exists", return_value=True)
|
||||
mocker.patch("os.path.getsize", return_value=1024)
|
||||
mocker.patch("routes.wifi.get_tool_path", return_value="aircrack-ng")
|
||||
|
||||
mock_run = mocker.patch("routes.wifi.subprocess.run")
|
||||
mock_run.return_value = MagicMock(stdout="WPA (1 handshake)", stderr="", returncode=0)
|
||||
|
||||
payload = {'file': '/tmp/intercept_handshake_test.cap', 'bssid': 'AA:BB:CC:DD:EE:FF'}
|
||||
response = client.post('/wifi/handshake/status', json=payload)
|
||||
|
||||
assert response.get_json()['handshake_found'] is True
|
||||
|
||||
### --- PMKID TESTS --- ###
|
||||
|
||||
def test_capture_pmkid_path_traversal_prevention(client):
|
||||
"""Ensure the status check rejects invalid paths."""
|
||||
payload = {'file': '/etc/passwd'} # Malicious path
|
||||
response = client.post('/wifi/pmkid/status', json=payload)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()['status'] == 'error'
|
||||
assert 'Invalid capture file path' in response.get_json()['message']
|
||||
|
||||
### --- CRACKING TESTS --- ###
|
||||
|
||||
def test_crack_handshake_success(client, mocker):
|
||||
"""Test successful password extraction using Regex."""
|
||||
mocker.patch("os.path.exists", return_value=True)
|
||||
mocker.patch("routes.wifi.get_tool_path", return_value="aircrack-ng")
|
||||
|
||||
mock_run = mocker.patch("routes.wifi.subprocess.run")
|
||||
# Simulate the actual aircrack-ng success output
|
||||
mock_run.return_value = MagicMock(
|
||||
stdout="KEY FOUND! [ secret123 ]",
|
||||
stderr="",
|
||||
returncode=0
|
||||
)
|
||||
|
||||
payload = {
|
||||
'capture_file': '/tmp/intercept_handshake_test.cap',
|
||||
'wordlist': '/home/user/passwords.txt',
|
||||
'bssid': 'AA:BB:CC:DD:EE:FF'
|
||||
}
|
||||
response = client.post('/wifi/handshake/crack', json=payload)
|
||||
|
||||
data = response.get_json()
|
||||
assert data['status'] == 'success'
|
||||
assert data['password'] == 'secret123'
|
||||
|
||||
### --- DATA FETCHING TESTS --- ###
|
||||
|
||||
def test_get_wifi_networks(client, mock_app_module):
|
||||
"""Test that the networks endpoint correctly formats internal data."""
|
||||
mock_app_module.wifi_networks = {
|
||||
'AA:BB:CC:DD:EE:FF': {'essid': 'Home-WiFi', 'bssid': 'AA:BB:CC:DD:EE:FF'}
|
||||
}
|
||||
mock_app_module.wifi_handshakes = ['AA:BB:CC:DD:EE:FF']
|
||||
|
||||
response = client.get('/wifi/networks')
|
||||
data = response.get_json()
|
||||
|
||||
assert len(data['networks']) == 1
|
||||
assert data['networks'][0]['essid'] == 'Home-WiFi'
|
||||
assert 'AA:BB:CC:DD:EE:FF' in data['handshakes']
|
||||
@@ -0,0 +1,268 @@
|
||||
"""Aircraft database for ICAO hex to type/registration lookup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from urllib.request import urlopen, Request
|
||||
from urllib.error import URLError
|
||||
|
||||
logger = logging.getLogger('intercept.aircraft_db')
|
||||
|
||||
# Database file location (project root)
|
||||
DB_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DB_FILE = os.path.join(DB_DIR, 'aircraft_db.json')
|
||||
DB_META_FILE = os.path.join(DB_DIR, 'aircraft_db_meta.json')
|
||||
|
||||
# Mictronics database URLs (raw GitHub)
|
||||
AIRCRAFT_DB_URL = 'https://raw.githubusercontent.com/Mictronics/readsb-protobuf/dev/webapp/src/db/aircrafts.json'
|
||||
TYPES_DB_URL = 'https://raw.githubusercontent.com/Mictronics/readsb-protobuf/dev/webapp/src/db/types.json'
|
||||
GITHUB_API_URL = 'https://api.github.com/repos/Mictronics/readsb-protobuf/commits?path=webapp/src/db/aircrafts.json&per_page=1'
|
||||
|
||||
# In-memory cache
|
||||
_aircraft_cache: dict[str, dict[str, str]] = {}
|
||||
_types_cache: dict[str, str] = {}
|
||||
_cache_lock = threading.Lock()
|
||||
_db_loaded = False
|
||||
_db_version: str | None = None
|
||||
_update_available: bool = False
|
||||
_latest_version: str | None = None
|
||||
|
||||
|
||||
def get_db_status() -> dict[str, Any]:
|
||||
"""Get current database status."""
|
||||
exists = os.path.exists(DB_FILE)
|
||||
meta = _load_meta()
|
||||
|
||||
return {
|
||||
'installed': exists,
|
||||
'version': meta.get('version') if meta else None,
|
||||
'downloaded': meta.get('downloaded') if meta else None,
|
||||
'aircraft_count': len(_aircraft_cache) if _db_loaded else 0,
|
||||
'update_available': _update_available,
|
||||
'latest_version': _latest_version,
|
||||
}
|
||||
|
||||
|
||||
def _load_meta() -> dict[str, Any] | None:
|
||||
"""Load database metadata."""
|
||||
try:
|
||||
if os.path.exists(DB_META_FILE):
|
||||
with open(DB_META_FILE, 'r') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error loading aircraft db meta: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _save_meta(version: str) -> None:
|
||||
"""Save database metadata."""
|
||||
try:
|
||||
meta = {
|
||||
'version': version,
|
||||
'downloaded': datetime.utcnow().isoformat() + 'Z',
|
||||
}
|
||||
with open(DB_META_FILE, 'w') as f:
|
||||
json.dump(meta, f, indent=2)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error saving aircraft db meta: {e}")
|
||||
|
||||
|
||||
def load_database() -> bool:
|
||||
"""Load aircraft database into memory. Returns True if successful."""
|
||||
global _aircraft_cache, _types_cache, _db_loaded, _db_version
|
||||
|
||||
if not os.path.exists(DB_FILE):
|
||||
logger.info("Aircraft database not installed")
|
||||
return False
|
||||
|
||||
try:
|
||||
with _cache_lock:
|
||||
with open(DB_FILE, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
_aircraft_cache = data.get('aircraft', {})
|
||||
_types_cache = data.get('types', {})
|
||||
_db_loaded = True
|
||||
|
||||
meta = _load_meta()
|
||||
_db_version = meta.get('version') if meta else 'unknown'
|
||||
|
||||
logger.info(f"Loaded aircraft database: {len(_aircraft_cache)} aircraft, {len(_types_cache)} types")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading aircraft database: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def lookup(icao: str) -> dict[str, str] | None:
|
||||
"""
|
||||
Look up aircraft by ICAO hex code.
|
||||
|
||||
Returns dict with keys: registration, type_code, type_desc
|
||||
Or None if not found.
|
||||
"""
|
||||
if not _db_loaded:
|
||||
return None
|
||||
|
||||
icao_upper = icao.upper()
|
||||
|
||||
with _cache_lock:
|
||||
aircraft = _aircraft_cache.get(icao_upper)
|
||||
if not aircraft:
|
||||
return None
|
||||
|
||||
# Database format is array: [registration, type_code, flags, ...]
|
||||
# Handle both list format (from Mictronics) and dict format (legacy)
|
||||
if isinstance(aircraft, list):
|
||||
reg = aircraft[0] if len(aircraft) > 0 else ''
|
||||
type_code = aircraft[1] if len(aircraft) > 1 else ''
|
||||
else:
|
||||
# Dict format fallback
|
||||
reg = aircraft.get('r', '')
|
||||
type_code = aircraft.get('t', '')
|
||||
|
||||
# Look up type description
|
||||
type_desc = ''
|
||||
if type_code and type_code in _types_cache:
|
||||
type_desc = _types_cache[type_code]
|
||||
|
||||
return {
|
||||
'registration': reg,
|
||||
'type_code': type_code,
|
||||
'type_desc': type_desc,
|
||||
}
|
||||
|
||||
|
||||
def check_for_updates() -> dict[str, Any]:
|
||||
"""
|
||||
Check GitHub for database updates.
|
||||
Returns status dict with update_available flag.
|
||||
"""
|
||||
global _update_available, _latest_version
|
||||
|
||||
try:
|
||||
req = Request(GITHUB_API_URL, headers={'User-Agent': 'Intercept-SIGINT'})
|
||||
with urlopen(req, timeout=10) as response:
|
||||
commits = json.loads(response.read().decode('utf-8'))
|
||||
|
||||
if commits and len(commits) > 0:
|
||||
latest_sha = commits[0]['sha'][:8]
|
||||
latest_date = commits[0]['commit']['committer']['date']
|
||||
_latest_version = f"{latest_date[:10]}_{latest_sha}"
|
||||
|
||||
meta = _load_meta()
|
||||
current_version = meta.get('version') if meta else None
|
||||
|
||||
_update_available = current_version != _latest_version
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'current_version': current_version,
|
||||
'latest_version': _latest_version,
|
||||
'update_available': _update_available,
|
||||
}
|
||||
except URLError as e:
|
||||
logger.warning(f"Failed to check for updates: {e}")
|
||||
return {'success': False, 'error': str(e)}
|
||||
except Exception as e:
|
||||
logger.warning(f"Error checking for updates: {e}")
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
return {'success': False, 'error': 'Unknown error'}
|
||||
|
||||
|
||||
def download_database(progress_callback=None) -> dict[str, Any]:
|
||||
"""
|
||||
Download latest aircraft database from Mictronics repo.
|
||||
Returns status dict.
|
||||
"""
|
||||
global _update_available
|
||||
|
||||
try:
|
||||
if progress_callback:
|
||||
progress_callback('Downloading aircraft database...')
|
||||
|
||||
# Download aircraft database
|
||||
req = Request(AIRCRAFT_DB_URL, headers={'User-Agent': 'Intercept-SIGINT'})
|
||||
with urlopen(req, timeout=60) as response:
|
||||
aircraft_data = json.loads(response.read().decode('utf-8'))
|
||||
|
||||
if progress_callback:
|
||||
progress_callback('Downloading type codes...')
|
||||
|
||||
# Download types database
|
||||
req = Request(TYPES_DB_URL, headers={'User-Agent': 'Intercept-SIGINT'})
|
||||
with urlopen(req, timeout=30) as response:
|
||||
types_data = json.loads(response.read().decode('utf-8'))
|
||||
|
||||
if progress_callback:
|
||||
progress_callback('Processing database...')
|
||||
|
||||
# Combine into single file
|
||||
combined = {
|
||||
'aircraft': aircraft_data,
|
||||
'types': types_data,
|
||||
}
|
||||
|
||||
# Save to file
|
||||
with open(DB_FILE, 'w') as f:
|
||||
json.dump(combined, f, separators=(',', ':')) # Compact JSON
|
||||
|
||||
# Get version from GitHub
|
||||
version = datetime.utcnow().strftime('%Y-%m-%d')
|
||||
try:
|
||||
req = Request(GITHUB_API_URL, headers={'User-Agent': 'Intercept-SIGINT'})
|
||||
with urlopen(req, timeout=10) as response:
|
||||
commits = json.loads(response.read().decode('utf-8'))
|
||||
if commits:
|
||||
sha = commits[0]['sha'][:8]
|
||||
date = commits[0]['commit']['committer']['date'][:10]
|
||||
version = f"{date}_{sha}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_save_meta(version)
|
||||
_update_available = False
|
||||
|
||||
# Reload into memory
|
||||
load_database()
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': f'Downloaded {len(aircraft_data)} aircraft, {len(types_data)} types',
|
||||
'version': version,
|
||||
}
|
||||
|
||||
except URLError as e:
|
||||
logger.error(f"Download failed: {e}")
|
||||
return {'success': False, 'error': f'Download failed: {e}'}
|
||||
except Exception as e:
|
||||
logger.error(f"Error downloading database: {e}")
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
|
||||
def delete_database() -> dict[str, Any]:
|
||||
"""Delete local database files."""
|
||||
global _aircraft_cache, _types_cache, _db_loaded, _db_version
|
||||
|
||||
try:
|
||||
with _cache_lock:
|
||||
_aircraft_cache = {}
|
||||
_types_cache = {}
|
||||
_db_loaded = False
|
||||
_db_version = None
|
||||
|
||||
if os.path.exists(DB_FILE):
|
||||
os.remove(DB_FILE)
|
||||
if os.path.exists(DB_META_FILE):
|
||||
os.remove(DB_META_FILE)
|
||||
|
||||
return {'success': True, 'message': 'Database deleted'}
|
||||
except Exception as e:
|
||||
return {'success': False, 'error': str(e)}
|
||||
@@ -99,6 +99,23 @@ class DataStore:
|
||||
with self._lock:
|
||||
return key in self.data
|
||||
|
||||
def __getitem__(self, key: str) -> Any:
|
||||
"""Get an entry using subscript notation."""
|
||||
with self._lock:
|
||||
return self.data[key]
|
||||
|
||||
def __setitem__(self, key: str, value: Any) -> None:
|
||||
"""Set an entry using subscript notation."""
|
||||
with self._lock:
|
||||
self.data[key] = value
|
||||
self.timestamps[key] = time.time()
|
||||
|
||||
def __delitem__(self, key: str) -> None:
|
||||
"""Delete an entry using subscript notation."""
|
||||
with self._lock:
|
||||
del self.data[key]
|
||||
del self.timestamps[key]
|
||||
|
||||
def cleanup(self) -> int:
|
||||
"""
|
||||
Remove entries older than max_age.
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
"""
|
||||
INTERCEPT - Constants and Magic Numbers
|
||||
|
||||
Centralized location for all hardcoded values used throughout the application.
|
||||
This improves maintainability and makes the codebase self-documenting.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# =============================================================================
|
||||
# NETWORK PORTS
|
||||
# =============================================================================
|
||||
|
||||
# ADS-B SBS data output port (dump1090 default)
|
||||
ADSB_SBS_PORT = 30003
|
||||
|
||||
# GPS daemon port (gpsd default)
|
||||
GPSD_PORT = 2947
|
||||
|
||||
# RTL-TCP server port (rtl_tcp default)
|
||||
RTL_TCP_PORT = 1234
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PROCESS TIMEOUTS (seconds)
|
||||
# =============================================================================
|
||||
|
||||
# General process termination timeout
|
||||
PROCESS_TERMINATE_TIMEOUT = 2
|
||||
|
||||
# ADS-B process termination (dump1090 needs longer)
|
||||
ADSB_TERMINATE_TIMEOUT = 5
|
||||
|
||||
# WiFi process termination (airodump-ng)
|
||||
WIFI_TERMINATE_TIMEOUT = 3
|
||||
|
||||
# Bluetooth process termination
|
||||
BT_TERMINATE_TIMEOUT = 3
|
||||
|
||||
# PMKID process termination
|
||||
PMKID_TERMINATE_TIMEOUT = 5
|
||||
|
||||
# Socket connection timeout
|
||||
SOCKET_CONNECT_TIMEOUT = 2
|
||||
|
||||
# SBS stream socket timeout
|
||||
SBS_SOCKET_TIMEOUT = 5
|
||||
|
||||
# Subprocess command timeout (short operations)
|
||||
SUBPROCESS_TIMEOUT_SHORT = 5
|
||||
|
||||
# Subprocess command timeout (medium operations)
|
||||
SUBPROCESS_TIMEOUT_MEDIUM = 10
|
||||
|
||||
# Subprocess command timeout (long operations like airmon-ng)
|
||||
SUBPROCESS_TIMEOUT_LONG = 15
|
||||
|
||||
# External HTTP request timeout (TLE fetching, etc.)
|
||||
HTTP_REQUEST_TIMEOUT = 10
|
||||
|
||||
# Deauth command timeout
|
||||
DEAUTH_TIMEOUT = 30
|
||||
|
||||
# Service enumeration timeout (sdptool browse)
|
||||
SERVICE_ENUM_TIMEOUT = 30
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SSE (Server-Sent Events) SETTINGS
|
||||
# =============================================================================
|
||||
|
||||
# Keepalive interval for SSE streams (seconds)
|
||||
SSE_KEEPALIVE_INTERVAL = 30.0
|
||||
|
||||
# Queue get timeout for SSE generators (seconds)
|
||||
SSE_QUEUE_TIMEOUT = 1.0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DATA RETENTION / CLEANUP (seconds)
|
||||
# =============================================================================
|
||||
|
||||
# Maximum age for aircraft data before cleanup
|
||||
MAX_AIRCRAFT_AGE_SECONDS = 300 # 5 minutes
|
||||
|
||||
# Maximum age for WiFi network data before cleanup
|
||||
MAX_WIFI_NETWORK_AGE_SECONDS = 600 # 10 minutes
|
||||
|
||||
# Maximum age for Bluetooth device data before cleanup
|
||||
MAX_BT_DEVICE_AGE_SECONDS = 300 # 5 minutes
|
||||
|
||||
# ADS-B queue batch update interval
|
||||
ADSB_UPDATE_INTERVAL = 1.0 # seconds
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# QUEUE LIMITS
|
||||
# =============================================================================
|
||||
|
||||
# Maximum queue size for all data queues
|
||||
QUEUE_MAX_SIZE = 1000
|
||||
|
||||
# GPS queue size (smaller, more frequent updates)
|
||||
GPS_QUEUE_MAX_SIZE = 100
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DATA PARSING
|
||||
# =============================================================================
|
||||
|
||||
# WiFi CSV parse interval (seconds)
|
||||
WIFI_CSV_PARSE_INTERVAL = 2.0
|
||||
|
||||
# Minimum time before warning about no CSV data
|
||||
WIFI_CSV_TIMEOUT_WARNING = 5.0
|
||||
|
||||
# Socket receive buffer size
|
||||
SOCKET_BUFFER_SIZE = 4096
|
||||
|
||||
# PTY read buffer size
|
||||
PTY_BUFFER_SIZE = 1024
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# EXTERNAL SERVICE LIMITS
|
||||
# =============================================================================
|
||||
|
||||
# Maximum response size for external HTTP requests (bytes)
|
||||
MAX_HTTP_RESPONSE_SIZE = 1024 * 1024 # 1 MB
|
||||
|
||||
# Deauth packet count limits
|
||||
MIN_DEAUTH_COUNT = 1
|
||||
MAX_DEAUTH_COUNT = 100
|
||||
DEFAULT_DEAUTH_COUNT = 5
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# VALIDATION LIMITS
|
||||
# =============================================================================
|
||||
|
||||
# Squelch range
|
||||
MIN_SQUELCH = 0
|
||||
MAX_SQUELCH = 1000
|
||||
|
||||
# Valid GPS baudrates
|
||||
VALID_GPS_BAUDRATES = [4800, 9600, 19200, 38400, 57600, 115200]
|
||||
|
||||
# Port range
|
||||
MIN_PORT = 1
|
||||
MAX_PORT = 65535
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SATELLITE TRACKING
|
||||
# =============================================================================
|
||||
|
||||
# Default observer location (London)
|
||||
DEFAULT_LATITUDE = 51.5074
|
||||
DEFAULT_LONGITUDE = -0.1278
|
||||
|
||||
# Allowed TLE hosts for security
|
||||
ALLOWED_TLE_HOSTS = [
|
||||
'celestrak.org',
|
||||
'celestrak.com',
|
||||
'www.celestrak.org',
|
||||
'www.celestrak.com'
|
||||
]
|
||||
|
||||
# Earth radius (km) - WGS84 mean
|
||||
EARTH_RADIUS_KM = 6371
|
||||
|
||||
# Trajectory calculation points
|
||||
TRAJECTORY_POINTS = 30
|
||||
GROUND_TRACK_POINTS = 60
|
||||
ORBIT_TRACK_RANGE_MINUTES = 45
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SLEEP/DELAY TIMES (seconds)
|
||||
# =============================================================================
|
||||
|
||||
# Wait after starting process before checking status
|
||||
PROCESS_START_WAIT = 0.5
|
||||
|
||||
# Wait after dump1090 start before connecting
|
||||
DUMP1090_START_WAIT = 3.0
|
||||
|
||||
# Delay between monitor mode operations
|
||||
MONITOR_MODE_DELAY = 1.0
|
||||
|
||||
# Bluetooth adapter reset delays
|
||||
BT_RESET_DELAY = 0.5
|
||||
BT_ADAPTER_DOWN_WAIT = 1.0
|
||||
|
||||
# SBS reconnection delay on error
|
||||
SBS_RECONNECT_DELAY = 2.0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# FILE PATHS
|
||||
# =============================================================================
|
||||
|
||||
# Default pager log file
|
||||
DEFAULT_PAGER_LOG_FILE = 'pager_messages.log'
|
||||
|
||||
# WiFi capture temp path prefix
|
||||
WIFI_CAPTURE_PATH_PREFIX = '/tmp/intercept_wifi'
|
||||
|
||||
# Handshake capture path prefix
|
||||
HANDSHAKE_CAPTURE_PATH_PREFIX = '/tmp/intercept_handshake_'
|
||||
|
||||
# PMKID capture path prefix
|
||||
PMKID_CAPTURE_PATH_PREFIX = '/tmp/intercept_pmkid_'
|
||||
@@ -0,0 +1,313 @@
|
||||
"""
|
||||
Device correlation engine for matching WiFi and Bluetooth devices.
|
||||
|
||||
Uses timing-based correlation to identify when WiFi and Bluetooth
|
||||
signals likely belong to the same physical device.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from utils.database import add_correlation, get_correlations as db_get_correlations
|
||||
|
||||
logger = logging.getLogger('intercept.correlation')
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceObservation:
|
||||
"""A single observation of a device."""
|
||||
mac: str
|
||||
first_seen: datetime
|
||||
last_seen: datetime
|
||||
rssi: int | None = None
|
||||
name: str | None = None
|
||||
manufacturer: str | None = None
|
||||
|
||||
|
||||
class DeviceCorrelator:
|
||||
"""
|
||||
Correlates WiFi and Bluetooth devices based on timing patterns.
|
||||
|
||||
Devices are considered potentially correlated if:
|
||||
1. They appear within a short time window of each other
|
||||
2. They have similar signal strength patterns (optional)
|
||||
3. They share the same OUI/manufacturer (bonus confidence)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
time_window_seconds: int = 30,
|
||||
min_confidence: float = 0.5,
|
||||
rssi_threshold: int = 20
|
||||
):
|
||||
"""
|
||||
Initialize correlator.
|
||||
|
||||
Args:
|
||||
time_window_seconds: Max time difference for correlation (default 30s)
|
||||
min_confidence: Minimum confidence score to report (default 0.5)
|
||||
rssi_threshold: Max RSSI difference for signal-based correlation
|
||||
"""
|
||||
self.time_window = timedelta(seconds=time_window_seconds)
|
||||
self.min_confidence = min_confidence
|
||||
self.rssi_threshold = rssi_threshold
|
||||
|
||||
def correlate(
|
||||
self,
|
||||
wifi_devices: dict[str, dict[str, Any]],
|
||||
bt_devices: dict[str, dict[str, Any]]
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Find correlations between WiFi and Bluetooth devices.
|
||||
|
||||
Args:
|
||||
wifi_devices: Dict of WiFi devices keyed by MAC
|
||||
bt_devices: Dict of Bluetooth devices keyed by MAC
|
||||
|
||||
Returns:
|
||||
List of correlation results with confidence scores
|
||||
"""
|
||||
correlations = []
|
||||
|
||||
for wifi_mac, wifi_data in wifi_devices.items():
|
||||
wifi_obs = self._to_observation(wifi_mac, wifi_data, 'wifi')
|
||||
if not wifi_obs:
|
||||
continue
|
||||
|
||||
for bt_mac, bt_data in bt_devices.items():
|
||||
bt_obs = self._to_observation(bt_mac, bt_data, 'bluetooth')
|
||||
if not bt_obs:
|
||||
continue
|
||||
|
||||
confidence = self._calculate_confidence(wifi_obs, bt_obs)
|
||||
|
||||
if confidence >= self.min_confidence:
|
||||
correlations.append({
|
||||
'wifi_mac': wifi_mac,
|
||||
'wifi_name': wifi_obs.name,
|
||||
'bt_mac': bt_mac,
|
||||
'bt_name': bt_obs.name,
|
||||
'confidence': round(confidence, 2),
|
||||
'reason': self._get_correlation_reason(wifi_obs, bt_obs)
|
||||
})
|
||||
|
||||
# Persist high-confidence correlations
|
||||
if confidence >= 0.7:
|
||||
try:
|
||||
add_correlation(
|
||||
wifi_mac=wifi_mac,
|
||||
bt_mac=bt_mac,
|
||||
confidence=confidence,
|
||||
metadata={
|
||||
'wifi_name': wifi_obs.name,
|
||||
'bt_name': bt_obs.name
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to persist correlation: {e}")
|
||||
|
||||
# Sort by confidence (highest first)
|
||||
correlations.sort(key=lambda x: x['confidence'], reverse=True)
|
||||
|
||||
return correlations
|
||||
|
||||
def _to_observation(
|
||||
self,
|
||||
mac: str,
|
||||
data: dict[str, Any],
|
||||
device_type: str
|
||||
) -> DeviceObservation | None:
|
||||
"""Convert device dict to observation."""
|
||||
try:
|
||||
# Handle different timestamp formats
|
||||
first_seen = data.get('first_seen') or data.get('firstSeen')
|
||||
last_seen = data.get('last_seen') or data.get('lastSeen')
|
||||
|
||||
if isinstance(first_seen, str):
|
||||
first_seen = datetime.fromisoformat(first_seen.replace('Z', '+00:00'))
|
||||
elif isinstance(first_seen, (int, float)):
|
||||
first_seen = datetime.fromtimestamp(first_seen / 1000)
|
||||
else:
|
||||
first_seen = datetime.now()
|
||||
|
||||
if isinstance(last_seen, str):
|
||||
last_seen = datetime.fromisoformat(last_seen.replace('Z', '+00:00'))
|
||||
elif isinstance(last_seen, (int, float)):
|
||||
last_seen = datetime.fromtimestamp(last_seen / 1000)
|
||||
else:
|
||||
last_seen = datetime.now()
|
||||
|
||||
# Get RSSI (different field names)
|
||||
rssi = data.get('rssi') or data.get('power') or data.get('signal')
|
||||
if rssi is not None:
|
||||
rssi = int(rssi)
|
||||
|
||||
# Get name
|
||||
name = data.get('name') or data.get('essid') or data.get('ssid')
|
||||
|
||||
# Get manufacturer
|
||||
manufacturer = data.get('manufacturer') or data.get('vendor')
|
||||
|
||||
return DeviceObservation(
|
||||
mac=mac,
|
||||
first_seen=first_seen,
|
||||
last_seen=last_seen,
|
||||
rssi=rssi,
|
||||
name=name,
|
||||
manufacturer=manufacturer
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse device {mac}: {e}")
|
||||
return None
|
||||
|
||||
def _calculate_confidence(
|
||||
self,
|
||||
wifi: DeviceObservation,
|
||||
bt: DeviceObservation
|
||||
) -> float:
|
||||
"""
|
||||
Calculate correlation confidence score.
|
||||
|
||||
Score components:
|
||||
- Timing overlap: 0.0-0.5 (primary factor)
|
||||
- Same manufacturer: +0.2
|
||||
- Similar RSSI: +0.1
|
||||
- Both named: +0.1
|
||||
|
||||
Returns:
|
||||
Confidence score 0.0-1.0
|
||||
"""
|
||||
confidence = 0.0
|
||||
|
||||
# Timing correlation (most important)
|
||||
time_diff = abs((wifi.first_seen - bt.first_seen).total_seconds())
|
||||
if time_diff <= self.time_window.total_seconds():
|
||||
# Linear decay from 0.5 to 0.0 as time difference increases
|
||||
timing_score = 0.5 * (1 - time_diff / self.time_window.total_seconds())
|
||||
confidence += timing_score
|
||||
else:
|
||||
# Check if observation windows overlap at all
|
||||
wifi_end = wifi.last_seen
|
||||
bt_end = bt.last_seen
|
||||
|
||||
# If observation periods overlap
|
||||
if wifi.first_seen <= bt_end and bt.first_seen <= wifi_end:
|
||||
confidence += 0.25 # Partial credit for overlapping presence
|
||||
|
||||
# Manufacturer match
|
||||
if wifi.manufacturer and bt.manufacturer:
|
||||
wifi_mfg = wifi.manufacturer.lower()
|
||||
bt_mfg = bt.manufacturer.lower()
|
||||
if wifi_mfg == bt_mfg:
|
||||
confidence += 0.2
|
||||
elif wifi_mfg[:5] == bt_mfg[:5]: # Partial match
|
||||
confidence += 0.1
|
||||
|
||||
# OUI match (first 3 octets of MAC)
|
||||
wifi_oui = wifi.mac[:8].upper()
|
||||
bt_oui = bt.mac[:8].upper()
|
||||
if wifi_oui == bt_oui:
|
||||
confidence += 0.15
|
||||
|
||||
# RSSI similarity
|
||||
if wifi.rssi is not None and bt.rssi is not None:
|
||||
rssi_diff = abs(wifi.rssi - bt.rssi)
|
||||
if rssi_diff <= self.rssi_threshold:
|
||||
rssi_score = 0.1 * (1 - rssi_diff / self.rssi_threshold)
|
||||
confidence += rssi_score
|
||||
|
||||
# Both have names (suggests user device)
|
||||
if wifi.name and bt.name:
|
||||
confidence += 0.05
|
||||
|
||||
return min(confidence, 1.0)
|
||||
|
||||
def _get_correlation_reason(
|
||||
self,
|
||||
wifi: DeviceObservation,
|
||||
bt: DeviceObservation
|
||||
) -> str:
|
||||
"""Generate human-readable reason for correlation."""
|
||||
reasons = []
|
||||
|
||||
time_diff = abs((wifi.first_seen - bt.first_seen).total_seconds())
|
||||
if time_diff <= self.time_window.total_seconds():
|
||||
reasons.append(f"appeared within {int(time_diff)}s")
|
||||
|
||||
wifi_oui = wifi.mac[:8].upper()
|
||||
bt_oui = bt.mac[:8].upper()
|
||||
if wifi_oui == bt_oui:
|
||||
reasons.append("same OUI")
|
||||
|
||||
if wifi.manufacturer and bt.manufacturer:
|
||||
if wifi.manufacturer.lower() == bt.manufacturer.lower():
|
||||
reasons.append(f"same manufacturer ({wifi.manufacturer})")
|
||||
|
||||
if wifi.rssi is not None and bt.rssi is not None:
|
||||
rssi_diff = abs(wifi.rssi - bt.rssi)
|
||||
if rssi_diff <= self.rssi_threshold:
|
||||
reasons.append("similar signal strength")
|
||||
|
||||
return "; ".join(reasons) if reasons else "timing overlap"
|
||||
|
||||
|
||||
# Global correlator instance
|
||||
correlator = DeviceCorrelator()
|
||||
|
||||
|
||||
def get_correlations(
|
||||
wifi_devices: dict[str, dict] | None = None,
|
||||
bt_devices: dict[str, dict] | None = None,
|
||||
min_confidence: float = 0.5,
|
||||
include_historical: bool = True
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Get device correlations.
|
||||
|
||||
Args:
|
||||
wifi_devices: Current WiFi devices (or None to use only historical)
|
||||
bt_devices: Current Bluetooth devices (or None to use only historical)
|
||||
min_confidence: Minimum confidence threshold
|
||||
include_historical: Include correlations from database
|
||||
|
||||
Returns:
|
||||
List of correlations sorted by confidence
|
||||
"""
|
||||
results = []
|
||||
|
||||
# Get live correlations
|
||||
if wifi_devices and bt_devices:
|
||||
correlator.min_confidence = min_confidence
|
||||
results.extend(correlator.correlate(wifi_devices, bt_devices))
|
||||
|
||||
# Get historical correlations from database
|
||||
if include_historical:
|
||||
try:
|
||||
historical = db_get_correlations(min_confidence)
|
||||
for h in historical:
|
||||
# Avoid duplicates
|
||||
existing = next(
|
||||
(r for r in results
|
||||
if r['wifi_mac'] == h['wifi_mac'] and r['bt_mac'] == h['bt_mac']),
|
||||
None
|
||||
)
|
||||
if not existing:
|
||||
results.append({
|
||||
'wifi_mac': h['wifi_mac'],
|
||||
'bt_mac': h['bt_mac'],
|
||||
'confidence': h['confidence'],
|
||||
'reason': 'historical correlation',
|
||||
'first_seen': h['first_seen'],
|
||||
'last_seen': h['last_seen']
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to get historical correlations: {e}")
|
||||
|
||||
# Sort by confidence
|
||||
results.sort(key=lambda x: x['confidence'], reverse=True)
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,795 @@
|
||||
"""
|
||||
SQLite database utilities for persistent settings storage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger('intercept.database')
|
||||
|
||||
# Database file location
|
||||
DB_DIR = Path(__file__).parent.parent / 'instance'
|
||||
DB_PATH = DB_DIR / 'intercept.db'
|
||||
|
||||
# Thread-local storage for connections
|
||||
_local = threading.local()
|
||||
|
||||
|
||||
def get_db_path() -> Path:
|
||||
"""Get the database file path, creating directory if needed."""
|
||||
DB_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return DB_PATH
|
||||
|
||||
|
||||
def get_connection() -> sqlite3.Connection:
|
||||
"""Get a thread-local database connection."""
|
||||
if not hasattr(_local, 'connection') or _local.connection is None:
|
||||
db_path = get_db_path()
|
||||
_local.connection = sqlite3.connect(str(db_path), check_same_thread=False)
|
||||
_local.connection.row_factory = sqlite3.Row
|
||||
# Enable foreign keys
|
||||
_local.connection.execute('PRAGMA foreign_keys = ON')
|
||||
return _local.connection
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_db():
|
||||
"""Context manager for database operations."""
|
||||
conn = get_connection()
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
"""Initialize the database schema."""
|
||||
db_path = get_db_path()
|
||||
logger.info(f"Initializing database at {db_path}")
|
||||
|
||||
with get_db() as conn:
|
||||
# Settings table for key-value storage
|
||||
conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
value_type TEXT DEFAULT 'string',
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
# Signal history table for graphs
|
||||
conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS signal_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mode TEXT NOT NULL,
|
||||
device_id TEXT NOT NULL,
|
||||
signal_strength REAL,
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
metadata TEXT
|
||||
)
|
||||
''')
|
||||
|
||||
# Create index for faster queries
|
||||
conn.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_signal_history_mode_device
|
||||
ON signal_history(mode, device_id, timestamp)
|
||||
''')
|
||||
|
||||
# Device correlation table
|
||||
conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS device_correlations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
wifi_mac TEXT,
|
||||
bt_mac TEXT,
|
||||
confidence REAL,
|
||||
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
metadata TEXT,
|
||||
UNIQUE(wifi_mac, bt_mac)
|
||||
)
|
||||
''')
|
||||
|
||||
# =====================================================================
|
||||
# TSCM (Technical Surveillance Countermeasures) Tables
|
||||
# =====================================================================
|
||||
|
||||
# TSCM Baselines - Environment snapshots for comparison
|
||||
conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS tscm_baselines (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
location TEXT,
|
||||
description TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
wifi_networks TEXT,
|
||||
bt_devices TEXT,
|
||||
rf_frequencies TEXT,
|
||||
gps_coords TEXT,
|
||||
is_active BOOLEAN DEFAULT 0
|
||||
)
|
||||
''')
|
||||
|
||||
# TSCM Sweeps - Individual sweep sessions
|
||||
conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS tscm_sweeps (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
baseline_id INTEGER,
|
||||
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at TIMESTAMP,
|
||||
status TEXT DEFAULT 'running',
|
||||
sweep_type TEXT,
|
||||
wifi_enabled BOOLEAN DEFAULT 1,
|
||||
bt_enabled BOOLEAN DEFAULT 1,
|
||||
rf_enabled BOOLEAN DEFAULT 1,
|
||||
results TEXT,
|
||||
anomalies TEXT,
|
||||
threats_found INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (baseline_id) REFERENCES tscm_baselines(id)
|
||||
)
|
||||
''')
|
||||
|
||||
# TSCM Threats - Detected threats/anomalies
|
||||
conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS tscm_threats (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sweep_id INTEGER,
|
||||
detected_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
threat_type TEXT NOT NULL,
|
||||
severity TEXT DEFAULT 'medium',
|
||||
source TEXT,
|
||||
identifier TEXT,
|
||||
name TEXT,
|
||||
signal_strength INTEGER,
|
||||
frequency REAL,
|
||||
details TEXT,
|
||||
acknowledged BOOLEAN DEFAULT 0,
|
||||
notes TEXT,
|
||||
gps_coords TEXT,
|
||||
FOREIGN KEY (sweep_id) REFERENCES tscm_sweeps(id)
|
||||
)
|
||||
''')
|
||||
|
||||
# TSCM Scheduled Sweeps
|
||||
conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS tscm_schedules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
baseline_id INTEGER,
|
||||
zone_name TEXT,
|
||||
cron_expression TEXT,
|
||||
sweep_type TEXT DEFAULT 'standard',
|
||||
enabled BOOLEAN DEFAULT 1,
|
||||
last_run TIMESTAMP,
|
||||
next_run TIMESTAMP,
|
||||
notify_on_threat BOOLEAN DEFAULT 1,
|
||||
notify_email TEXT,
|
||||
FOREIGN KEY (baseline_id) REFERENCES tscm_baselines(id)
|
||||
)
|
||||
''')
|
||||
|
||||
# TSCM indexes for performance
|
||||
conn.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_tscm_threats_sweep
|
||||
ON tscm_threats(sweep_id)
|
||||
''')
|
||||
|
||||
conn.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_tscm_threats_severity
|
||||
ON tscm_threats(severity, detected_at)
|
||||
''')
|
||||
|
||||
conn.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_tscm_sweeps_baseline
|
||||
ON tscm_sweeps(baseline_id)
|
||||
''')
|
||||
|
||||
logger.info("Database initialized successfully")
|
||||
|
||||
|
||||
def close_db() -> None:
|
||||
"""Close the thread-local database connection."""
|
||||
if hasattr(_local, 'connection') and _local.connection is not None:
|
||||
_local.connection.close()
|
||||
_local.connection = None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Settings Functions
|
||||
# =============================================================================
|
||||
|
||||
def get_setting(key: str, default: Any = None) -> Any:
|
||||
"""
|
||||
Get a setting value by key.
|
||||
|
||||
Args:
|
||||
key: Setting key
|
||||
default: Default value if not found
|
||||
|
||||
Returns:
|
||||
Setting value (auto-converted from JSON for complex types)
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute(
|
||||
'SELECT value, value_type FROM settings WHERE key = ?',
|
||||
(key,)
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row is None:
|
||||
return default
|
||||
|
||||
value, value_type = row['value'], row['value_type']
|
||||
|
||||
# Convert based on type
|
||||
if value_type == 'json':
|
||||
try:
|
||||
return json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return default
|
||||
elif value_type == 'int':
|
||||
return int(value)
|
||||
elif value_type == 'float':
|
||||
return float(value)
|
||||
elif value_type == 'bool':
|
||||
return value.lower() in ('true', '1', 'yes')
|
||||
else:
|
||||
return value
|
||||
|
||||
|
||||
def set_setting(key: str, value: Any) -> None:
|
||||
"""
|
||||
Set a setting value.
|
||||
|
||||
Args:
|
||||
key: Setting key
|
||||
value: Setting value (will be JSON-encoded for complex types)
|
||||
"""
|
||||
# Determine value type and string representation
|
||||
if isinstance(value, bool):
|
||||
value_type = 'bool'
|
||||
str_value = 'true' if value else 'false'
|
||||
elif isinstance(value, int):
|
||||
value_type = 'int'
|
||||
str_value = str(value)
|
||||
elif isinstance(value, float):
|
||||
value_type = 'float'
|
||||
str_value = str(value)
|
||||
elif isinstance(value, (dict, list)):
|
||||
value_type = 'json'
|
||||
str_value = json.dumps(value)
|
||||
else:
|
||||
value_type = 'string'
|
||||
str_value = str(value)
|
||||
|
||||
with get_db() as conn:
|
||||
conn.execute('''
|
||||
INSERT INTO settings (key, value, value_type, updated_at)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
value_type = excluded.value_type,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
''', (key, str_value, value_type))
|
||||
|
||||
|
||||
def delete_setting(key: str) -> bool:
|
||||
"""
|
||||
Delete a setting.
|
||||
|
||||
Args:
|
||||
key: Setting key
|
||||
|
||||
Returns:
|
||||
True if setting was deleted, False if not found
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute('DELETE FROM settings WHERE key = ?', (key,))
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
def get_all_settings() -> dict[str, Any]:
|
||||
"""Get all settings as a dictionary."""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute('SELECT key, value, value_type FROM settings')
|
||||
settings = {}
|
||||
|
||||
for row in cursor:
|
||||
key, value, value_type = row['key'], row['value'], row['value_type']
|
||||
|
||||
if value_type == 'json':
|
||||
try:
|
||||
settings[key] = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
settings[key] = value
|
||||
elif value_type == 'int':
|
||||
settings[key] = int(value)
|
||||
elif value_type == 'float':
|
||||
settings[key] = float(value)
|
||||
elif value_type == 'bool':
|
||||
settings[key] = value.lower() in ('true', '1', 'yes')
|
||||
else:
|
||||
settings[key] = value
|
||||
|
||||
return settings
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Signal History Functions
|
||||
# =============================================================================
|
||||
|
||||
def add_signal_reading(
|
||||
mode: str,
|
||||
device_id: str,
|
||||
signal_strength: float,
|
||||
metadata: dict | None = None
|
||||
) -> None:
|
||||
"""Add a signal strength reading."""
|
||||
with get_db() as conn:
|
||||
conn.execute('''
|
||||
INSERT INTO signal_history (mode, device_id, signal_strength, metadata)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', (mode, device_id, signal_strength, json.dumps(metadata) if metadata else None))
|
||||
|
||||
|
||||
def get_signal_history(
|
||||
mode: str,
|
||||
device_id: str,
|
||||
limit: int = 100,
|
||||
since_minutes: int = 60
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Get signal history for a device.
|
||||
|
||||
Args:
|
||||
mode: Mode (wifi, bluetooth, adsb, etc.)
|
||||
device_id: Device identifier (MAC, ICAO, etc.)
|
||||
limit: Maximum number of readings
|
||||
since_minutes: Only get readings from last N minutes
|
||||
|
||||
Returns:
|
||||
List of signal readings with timestamp
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute('''
|
||||
SELECT signal_strength, timestamp, metadata
|
||||
FROM signal_history
|
||||
WHERE mode = ? AND device_id = ?
|
||||
AND timestamp > datetime('now', ?)
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
''', (mode, device_id, f'-{since_minutes} minutes', limit))
|
||||
|
||||
results = []
|
||||
for row in cursor:
|
||||
results.append({
|
||||
'signal': row['signal_strength'],
|
||||
'timestamp': row['timestamp'],
|
||||
'metadata': json.loads(row['metadata']) if row['metadata'] else None
|
||||
})
|
||||
|
||||
return list(reversed(results)) # Return in chronological order
|
||||
|
||||
|
||||
def cleanup_old_signal_history(max_age_hours: int = 24) -> int:
|
||||
"""
|
||||
Remove old signal history entries.
|
||||
|
||||
Args:
|
||||
max_age_hours: Maximum age in hours
|
||||
|
||||
Returns:
|
||||
Number of deleted entries
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute('''
|
||||
DELETE FROM signal_history
|
||||
WHERE timestamp < datetime('now', ?)
|
||||
''', (f'-{max_age_hours} hours',))
|
||||
return cursor.rowcount
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Device Correlation Functions
|
||||
# =============================================================================
|
||||
|
||||
def add_correlation(
|
||||
wifi_mac: str,
|
||||
bt_mac: str,
|
||||
confidence: float,
|
||||
metadata: dict | None = None
|
||||
) -> None:
|
||||
"""Add or update a device correlation."""
|
||||
with get_db() as conn:
|
||||
conn.execute('''
|
||||
INSERT INTO device_correlations (wifi_mac, bt_mac, confidence, metadata, last_seen)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(wifi_mac, bt_mac) DO UPDATE SET
|
||||
confidence = excluded.confidence,
|
||||
last_seen = CURRENT_TIMESTAMP,
|
||||
metadata = excluded.metadata
|
||||
''', (wifi_mac, bt_mac, confidence, json.dumps(metadata) if metadata else None))
|
||||
|
||||
|
||||
def get_correlations(min_confidence: float = 0.5) -> list[dict]:
|
||||
"""Get all device correlations above minimum confidence."""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute('''
|
||||
SELECT wifi_mac, bt_mac, confidence, first_seen, last_seen, metadata
|
||||
FROM device_correlations
|
||||
WHERE confidence >= ?
|
||||
ORDER BY confidence DESC
|
||||
''', (min_confidence,))
|
||||
|
||||
results = []
|
||||
for row in cursor:
|
||||
results.append({
|
||||
'wifi_mac': row['wifi_mac'],
|
||||
'bt_mac': row['bt_mac'],
|
||||
'confidence': row['confidence'],
|
||||
'first_seen': row['first_seen'],
|
||||
'last_seen': row['last_seen'],
|
||||
'metadata': json.loads(row['metadata']) if row['metadata'] else None
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TSCM Functions
|
||||
# =============================================================================
|
||||
|
||||
def create_tscm_baseline(
|
||||
name: str,
|
||||
location: str | None = None,
|
||||
description: str | None = None,
|
||||
wifi_networks: list | None = None,
|
||||
bt_devices: list | None = None,
|
||||
rf_frequencies: list | None = None,
|
||||
gps_coords: dict | None = None
|
||||
) -> int:
|
||||
"""
|
||||
Create a new TSCM baseline.
|
||||
|
||||
Returns:
|
||||
The ID of the created baseline
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute('''
|
||||
INSERT INTO tscm_baselines
|
||||
(name, location, description, wifi_networks, bt_devices, rf_frequencies, gps_coords)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
''', (
|
||||
name,
|
||||
location,
|
||||
description,
|
||||
json.dumps(wifi_networks) if wifi_networks else None,
|
||||
json.dumps(bt_devices) if bt_devices else None,
|
||||
json.dumps(rf_frequencies) if rf_frequencies else None,
|
||||
json.dumps(gps_coords) if gps_coords else None
|
||||
))
|
||||
return cursor.lastrowid
|
||||
|
||||
|
||||
def get_tscm_baseline(baseline_id: int) -> dict | None:
|
||||
"""Get a specific TSCM baseline by ID."""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute('''
|
||||
SELECT * FROM tscm_baselines WHERE id = ?
|
||||
''', (baseline_id,))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
return {
|
||||
'id': row['id'],
|
||||
'name': row['name'],
|
||||
'location': row['location'],
|
||||
'description': row['description'],
|
||||
'created_at': row['created_at'],
|
||||
'wifi_networks': json.loads(row['wifi_networks']) if row['wifi_networks'] else [],
|
||||
'bt_devices': json.loads(row['bt_devices']) if row['bt_devices'] else [],
|
||||
'rf_frequencies': json.loads(row['rf_frequencies']) if row['rf_frequencies'] else [],
|
||||
'gps_coords': json.loads(row['gps_coords']) if row['gps_coords'] else None,
|
||||
'is_active': bool(row['is_active'])
|
||||
}
|
||||
|
||||
|
||||
def get_all_tscm_baselines() -> list[dict]:
|
||||
"""Get all TSCM baselines."""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute('''
|
||||
SELECT id, name, location, description, created_at, is_active
|
||||
FROM tscm_baselines
|
||||
ORDER BY created_at DESC
|
||||
''')
|
||||
|
||||
return [dict(row) for row in cursor]
|
||||
|
||||
|
||||
def get_active_tscm_baseline() -> dict | None:
|
||||
"""Get the currently active TSCM baseline."""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute('''
|
||||
SELECT * FROM tscm_baselines WHERE is_active = 1 LIMIT 1
|
||||
''')
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
return get_tscm_baseline(row['id'])
|
||||
|
||||
|
||||
def set_active_tscm_baseline(baseline_id: int) -> bool:
|
||||
"""Set a baseline as active (deactivates others)."""
|
||||
with get_db() as conn:
|
||||
# Deactivate all
|
||||
conn.execute('UPDATE tscm_baselines SET is_active = 0')
|
||||
# Activate selected
|
||||
cursor = conn.execute(
|
||||
'UPDATE tscm_baselines SET is_active = 1 WHERE id = ?',
|
||||
(baseline_id,)
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
def update_tscm_baseline(
|
||||
baseline_id: int,
|
||||
wifi_networks: list | None = None,
|
||||
bt_devices: list | None = None,
|
||||
rf_frequencies: list | None = None
|
||||
) -> bool:
|
||||
"""Update baseline device lists."""
|
||||
updates = []
|
||||
params = []
|
||||
|
||||
if wifi_networks is not None:
|
||||
updates.append('wifi_networks = ?')
|
||||
params.append(json.dumps(wifi_networks))
|
||||
if bt_devices is not None:
|
||||
updates.append('bt_devices = ?')
|
||||
params.append(json.dumps(bt_devices))
|
||||
if rf_frequencies is not None:
|
||||
updates.append('rf_frequencies = ?')
|
||||
params.append(json.dumps(rf_frequencies))
|
||||
|
||||
if not updates:
|
||||
return False
|
||||
|
||||
params.append(baseline_id)
|
||||
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute(
|
||||
f'UPDATE tscm_baselines SET {", ".join(updates)} WHERE id = ?',
|
||||
params
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
def delete_tscm_baseline(baseline_id: int) -> bool:
|
||||
"""Delete a TSCM baseline."""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute(
|
||||
'DELETE FROM tscm_baselines WHERE id = ?',
|
||||
(baseline_id,)
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
def create_tscm_sweep(
|
||||
sweep_type: str,
|
||||
baseline_id: int | None = None,
|
||||
wifi_enabled: bool = True,
|
||||
bt_enabled: bool = True,
|
||||
rf_enabled: bool = True
|
||||
) -> int:
|
||||
"""
|
||||
Create a new TSCM sweep session.
|
||||
|
||||
Returns:
|
||||
The ID of the created sweep
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute('''
|
||||
INSERT INTO tscm_sweeps
|
||||
(baseline_id, sweep_type, wifi_enabled, bt_enabled, rf_enabled)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
''', (baseline_id, sweep_type, wifi_enabled, bt_enabled, rf_enabled))
|
||||
return cursor.lastrowid
|
||||
|
||||
|
||||
def update_tscm_sweep(
|
||||
sweep_id: int,
|
||||
status: str | None = None,
|
||||
results: dict | None = None,
|
||||
anomalies: list | None = None,
|
||||
threats_found: int | None = None,
|
||||
completed: bool = False
|
||||
) -> bool:
|
||||
"""Update a TSCM sweep."""
|
||||
updates = []
|
||||
params = []
|
||||
|
||||
if status is not None:
|
||||
updates.append('status = ?')
|
||||
params.append(status)
|
||||
if results is not None:
|
||||
updates.append('results = ?')
|
||||
params.append(json.dumps(results))
|
||||
if anomalies is not None:
|
||||
updates.append('anomalies = ?')
|
||||
params.append(json.dumps(anomalies))
|
||||
if threats_found is not None:
|
||||
updates.append('threats_found = ?')
|
||||
params.append(threats_found)
|
||||
if completed:
|
||||
updates.append('completed_at = CURRENT_TIMESTAMP')
|
||||
|
||||
if not updates:
|
||||
return False
|
||||
|
||||
params.append(sweep_id)
|
||||
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute(
|
||||
f'UPDATE tscm_sweeps SET {", ".join(updates)} WHERE id = ?',
|
||||
params
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
def get_tscm_sweep(sweep_id: int) -> dict | None:
|
||||
"""Get a specific TSCM sweep by ID."""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute('SELECT * FROM tscm_sweeps WHERE id = ?', (sweep_id,))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
return {
|
||||
'id': row['id'],
|
||||
'baseline_id': row['baseline_id'],
|
||||
'started_at': row['started_at'],
|
||||
'completed_at': row['completed_at'],
|
||||
'status': row['status'],
|
||||
'sweep_type': row['sweep_type'],
|
||||
'wifi_enabled': bool(row['wifi_enabled']),
|
||||
'bt_enabled': bool(row['bt_enabled']),
|
||||
'rf_enabled': bool(row['rf_enabled']),
|
||||
'results': json.loads(row['results']) if row['results'] else None,
|
||||
'anomalies': json.loads(row['anomalies']) if row['anomalies'] else [],
|
||||
'threats_found': row['threats_found']
|
||||
}
|
||||
|
||||
|
||||
def add_tscm_threat(
|
||||
sweep_id: int,
|
||||
threat_type: str,
|
||||
severity: str,
|
||||
source: str,
|
||||
identifier: str,
|
||||
name: str | None = None,
|
||||
signal_strength: int | None = None,
|
||||
frequency: float | None = None,
|
||||
details: dict | None = None,
|
||||
gps_coords: dict | None = None
|
||||
) -> int:
|
||||
"""
|
||||
Add a detected threat to a TSCM sweep.
|
||||
|
||||
Returns:
|
||||
The ID of the created threat
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute('''
|
||||
INSERT INTO tscm_threats
|
||||
(sweep_id, threat_type, severity, source, identifier, name,
|
||||
signal_strength, frequency, details, gps_coords)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
''', (
|
||||
sweep_id, threat_type, severity, source, identifier, name,
|
||||
signal_strength, frequency,
|
||||
json.dumps(details) if details else None,
|
||||
json.dumps(gps_coords) if gps_coords else None
|
||||
))
|
||||
return cursor.lastrowid
|
||||
|
||||
|
||||
def get_tscm_threats(
|
||||
sweep_id: int | None = None,
|
||||
severity: str | None = None,
|
||||
acknowledged: bool | None = None,
|
||||
limit: int = 100
|
||||
) -> list[dict]:
|
||||
"""Get TSCM threats with optional filters."""
|
||||
conditions = []
|
||||
params = []
|
||||
|
||||
if sweep_id is not None:
|
||||
conditions.append('sweep_id = ?')
|
||||
params.append(sweep_id)
|
||||
if severity is not None:
|
||||
conditions.append('severity = ?')
|
||||
params.append(severity)
|
||||
if acknowledged is not None:
|
||||
conditions.append('acknowledged = ?')
|
||||
params.append(1 if acknowledged else 0)
|
||||
|
||||
where_clause = f'WHERE {" AND ".join(conditions)}' if conditions else ''
|
||||
params.append(limit)
|
||||
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute(f'''
|
||||
SELECT * FROM tscm_threats
|
||||
{where_clause}
|
||||
ORDER BY detected_at DESC
|
||||
LIMIT ?
|
||||
''', params)
|
||||
|
||||
results = []
|
||||
for row in cursor:
|
||||
results.append({
|
||||
'id': row['id'],
|
||||
'sweep_id': row['sweep_id'],
|
||||
'detected_at': row['detected_at'],
|
||||
'threat_type': row['threat_type'],
|
||||
'severity': row['severity'],
|
||||
'source': row['source'],
|
||||
'identifier': row['identifier'],
|
||||
'name': row['name'],
|
||||
'signal_strength': row['signal_strength'],
|
||||
'frequency': row['frequency'],
|
||||
'details': json.loads(row['details']) if row['details'] else None,
|
||||
'acknowledged': bool(row['acknowledged']),
|
||||
'notes': row['notes'],
|
||||
'gps_coords': json.loads(row['gps_coords']) if row['gps_coords'] else None
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def acknowledge_tscm_threat(threat_id: int, notes: str | None = None) -> bool:
|
||||
"""Acknowledge a TSCM threat."""
|
||||
with get_db() as conn:
|
||||
if notes:
|
||||
cursor = conn.execute(
|
||||
'UPDATE tscm_threats SET acknowledged = 1, notes = ? WHERE id = ?',
|
||||
(notes, threat_id)
|
||||
)
|
||||
else:
|
||||
cursor = conn.execute(
|
||||
'UPDATE tscm_threats SET acknowledged = 1 WHERE id = ?',
|
||||
(threat_id,)
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
def get_tscm_threat_summary() -> dict:
|
||||
"""Get summary counts of threats by severity."""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute('''
|
||||
SELECT severity, COUNT(*) as count
|
||||
FROM tscm_threats
|
||||
WHERE acknowledged = 0
|
||||
GROUP BY severity
|
||||
''')
|
||||
|
||||
summary = {'critical': 0, 'high': 0, 'medium': 0, 'low': 0, 'total': 0}
|
||||
for row in cursor:
|
||||
summary[row['severity']] = row['count']
|
||||
summary['total'] += row['count']
|
||||
|
||||
return summary
|
||||
@@ -1,15 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger('intercept.dependencies')
|
||||
|
||||
# Additional paths to search for tools (e.g., /usr/sbin on Debian)
|
||||
EXTRA_TOOL_PATHS = ['/usr/sbin', '/sbin']
|
||||
|
||||
|
||||
def check_tool(name: str) -> bool:
|
||||
"""Check if a tool is installed."""
|
||||
return shutil.which(name) is not None
|
||||
return get_tool_path(name) is not None
|
||||
|
||||
|
||||
def get_tool_path(name: str) -> str | None:
|
||||
"""Get the full path to a tool, checking standard PATH and extra locations."""
|
||||
# First check standard PATH
|
||||
path = shutil.which(name)
|
||||
if path:
|
||||
return path
|
||||
|
||||
# Check additional paths (e.g., /usr/sbin for aircrack-ng on Debian)
|
||||
for extra_path in EXTRA_TOOL_PATHS:
|
||||
full_path = os.path.join(extra_path, name)
|
||||
if os.path.isfile(full_path) and os.access(full_path, os.X_OK):
|
||||
return full_path
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# Comprehensive tool dependency definitions
|
||||
@@ -32,7 +52,7 @@ TOOL_DEPENDENCIES = {
|
||||
'install': {
|
||||
'apt': 'sudo apt install multimon-ng',
|
||||
'brew': 'brew install multimon-ng',
|
||||
'manual': 'https://github.com/EliasOewornal/multimon-ng'
|
||||
'manual': 'https://github.com/EliasOenal/multimon-ng'
|
||||
}
|
||||
},
|
||||
'rtl_test': {
|
||||
@@ -175,6 +195,43 @@ TOOL_DEPENDENCIES = {
|
||||
}
|
||||
}
|
||||
},
|
||||
'acars': {
|
||||
'name': 'Aircraft Messaging (ACARS)',
|
||||
'tools': {
|
||||
'acarsdec': {
|
||||
'required': True,
|
||||
'description': 'ACARS VHF decoder',
|
||||
'install': {
|
||||
'apt': 'Run ./setup.sh (builds from source)',
|
||||
'brew': 'Run ./setup.sh (builds from source)',
|
||||
'manual': 'https://github.com/TLeconte/acarsdec'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'aprs': {
|
||||
'name': 'APRS Tracking',
|
||||
'tools': {
|
||||
'direwolf': {
|
||||
'required': False,
|
||||
'description': 'APRS/packet radio decoder (preferred)',
|
||||
'install': {
|
||||
'apt': 'sudo apt install direwolf',
|
||||
'brew': 'brew install direwolf',
|
||||
'manual': 'https://github.com/wb2osz/direwolf'
|
||||
}
|
||||
},
|
||||
'multimon-ng': {
|
||||
'required': False,
|
||||
'description': 'Alternative AFSK1200 decoder',
|
||||
'install': {
|
||||
'apt': 'sudo apt install multimon-ng',
|
||||
'brew': 'brew install multimon-ng',
|
||||
'manual': 'https://github.com/EliasOenal/multimon-ng'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'satellite': {
|
||||
'name': 'Satellite Tracking',
|
||||
'tools': {
|
||||
@@ -254,6 +311,56 @@ TOOL_DEPENDENCIES = {
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'tscm': {
|
||||
'name': 'TSCM Counter-Surveillance',
|
||||
'tools': {
|
||||
'rtl_power': {
|
||||
'required': False,
|
||||
'description': 'Wideband spectrum sweep for RF analysis',
|
||||
'install': {
|
||||
'apt': 'sudo apt install rtl-sdr',
|
||||
'brew': 'brew install librtlsdr',
|
||||
'manual': 'https://osmocom.org/projects/rtl-sdr/wiki'
|
||||
}
|
||||
},
|
||||
'rtl_fm': {
|
||||
'required': True,
|
||||
'description': 'RF signal demodulation',
|
||||
'install': {
|
||||
'apt': 'sudo apt install rtl-sdr',
|
||||
'brew': 'brew install librtlsdr',
|
||||
'manual': 'https://osmocom.org/projects/rtl-sdr/wiki'
|
||||
}
|
||||
},
|
||||
'rtl_433': {
|
||||
'required': False,
|
||||
'description': 'ISM band device decoding',
|
||||
'install': {
|
||||
'apt': 'sudo apt install rtl-433',
|
||||
'brew': 'brew install rtl_433',
|
||||
'manual': 'https://github.com/merbanan/rtl_433'
|
||||
}
|
||||
},
|
||||
'airmon-ng': {
|
||||
'required': False,
|
||||
'description': 'WiFi monitor mode for network scanning',
|
||||
'install': {
|
||||
'apt': 'sudo apt install aircrack-ng',
|
||||
'brew': 'Not available on macOS',
|
||||
'manual': 'https://aircrack-ng.org'
|
||||
}
|
||||
},
|
||||
'bluetoothctl': {
|
||||
'required': False,
|
||||
'description': 'Bluetooth device scanning',
|
||||
'install': {
|
||||
'apt': 'sudo apt install bluez',
|
||||
'brew': 'Not available on macOS (use native)',
|
||||
'manual': 'http://www.bluez.org'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
"""
|
||||
GPS dongle support for INTERCEPT.
|
||||
GPS support for INTERCEPT via gpsd daemon.
|
||||
|
||||
Provides detection and reading of USB GPS dongles via serial port.
|
||||
Parses NMEA sentences to extract location data.
|
||||
Provides GPS location data by connecting to the gpsd daemon.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import glob
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
@@ -19,14 +15,6 @@ from typing import Optional, Callable
|
||||
|
||||
logger = logging.getLogger('intercept.gps')
|
||||
|
||||
# Try to import serial, but don't fail if not available
|
||||
try:
|
||||
import serial
|
||||
SERIAL_AVAILABLE = True
|
||||
except ImportError:
|
||||
SERIAL_AVAILABLE = False
|
||||
logger.warning("pyserial not installed - GPS dongle support disabled")
|
||||
|
||||
|
||||
@dataclass
|
||||
class GPSPosition:
|
||||
@@ -34,10 +22,10 @@ class GPSPosition:
|
||||
latitude: float
|
||||
longitude: float
|
||||
altitude: Optional[float] = None
|
||||
speed: Optional[float] = None # knots
|
||||
speed: Optional[float] = None # m/s
|
||||
heading: Optional[float] = None # degrees
|
||||
satellites: Optional[int] = None
|
||||
fix_quality: int = 0 # 0=invalid, 1=GPS, 2=DGPS
|
||||
fix_quality: int = 0 # 0=unknown, 1=no fix, 2=2D fix, 3=3D fix
|
||||
timestamp: Optional[datetime] = None
|
||||
device: Optional[str] = None
|
||||
|
||||
@@ -56,258 +44,29 @@ class GPSPosition:
|
||||
}
|
||||
|
||||
|
||||
def detect_gps_devices() -> list[dict]:
|
||||
class GPSDClient:
|
||||
"""
|
||||
Detect potential GPS serial devices.
|
||||
Connects to gpsd daemon for GPS data.
|
||||
|
||||
Returns a list of device info dictionaries.
|
||||
"""
|
||||
devices = []
|
||||
|
||||
# Common GPS device patterns by platform
|
||||
patterns = []
|
||||
|
||||
if os.name == 'posix':
|
||||
# Linux
|
||||
patterns.extend([
|
||||
'/dev/ttyUSB*', # USB serial adapters
|
||||
'/dev/ttyACM*', # USB CDC ACM devices (many GPS)
|
||||
'/dev/gps*', # gpsd symlinks
|
||||
])
|
||||
# macOS
|
||||
patterns.extend([
|
||||
'/dev/tty.usbserial*',
|
||||
'/dev/tty.usbmodem*',
|
||||
'/dev/cu.usbserial*',
|
||||
'/dev/cu.usbmodem*',
|
||||
])
|
||||
|
||||
for pattern in patterns:
|
||||
for path in glob.glob(pattern):
|
||||
# Try to get device info
|
||||
device_info = {
|
||||
'path': path,
|
||||
'name': os.path.basename(path),
|
||||
'type': 'serial',
|
||||
}
|
||||
|
||||
# Check if it's readable
|
||||
if os.access(path, os.R_OK):
|
||||
device_info['accessible'] = True
|
||||
else:
|
||||
device_info['accessible'] = False
|
||||
device_info['error'] = 'Permission denied'
|
||||
|
||||
devices.append(device_info)
|
||||
|
||||
return devices
|
||||
|
||||
|
||||
def parse_nmea_coordinate(coord: str, direction: str) -> Optional[float]:
|
||||
"""
|
||||
Parse NMEA coordinate format to decimal degrees.
|
||||
|
||||
NMEA format: DDDMM.MMMM or DDMM.MMMM
|
||||
"""
|
||||
if not coord or not direction:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Find the decimal point
|
||||
dot_pos = coord.index('.')
|
||||
|
||||
# Degrees are everything before the last 2 digits before decimal
|
||||
degrees = int(coord[:dot_pos - 2])
|
||||
minutes = float(coord[dot_pos - 2:])
|
||||
|
||||
result = degrees + (minutes / 60.0)
|
||||
|
||||
# Apply direction
|
||||
if direction in ('S', 'W'):
|
||||
result = -result
|
||||
|
||||
return result
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
def parse_gga(parts: list[str]) -> Optional[GPSPosition]:
|
||||
"""
|
||||
Parse GPGGA/GNGGA sentence (Global Positioning System Fix Data).
|
||||
|
||||
Format: $GPGGA,time,lat,N/S,lon,E/W,quality,satellites,hdop,altitude,M,...
|
||||
"""
|
||||
if len(parts) < 10:
|
||||
return None
|
||||
|
||||
try:
|
||||
fix_quality = int(parts[6]) if parts[6] else 0
|
||||
|
||||
# No fix
|
||||
if fix_quality == 0:
|
||||
return None
|
||||
|
||||
lat = parse_nmea_coordinate(parts[2], parts[3])
|
||||
lon = parse_nmea_coordinate(parts[4], parts[5])
|
||||
|
||||
if lat is None or lon is None:
|
||||
return None
|
||||
|
||||
# Parse optional fields
|
||||
satellites = int(parts[7]) if parts[7] else None
|
||||
altitude = float(parts[9]) if parts[9] else None
|
||||
|
||||
# Parse time (HHMMSS.sss)
|
||||
timestamp = None
|
||||
if parts[1]:
|
||||
try:
|
||||
time_str = parts[1].split('.')[0]
|
||||
if len(time_str) >= 6:
|
||||
now = datetime.utcnow()
|
||||
timestamp = now.replace(
|
||||
hour=int(time_str[0:2]),
|
||||
minute=int(time_str[2:4]),
|
||||
second=int(time_str[4:6]),
|
||||
microsecond=0
|
||||
)
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
return GPSPosition(
|
||||
latitude=lat,
|
||||
longitude=lon,
|
||||
altitude=altitude,
|
||||
satellites=satellites,
|
||||
fix_quality=fix_quality,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.debug(f"GGA parse error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def parse_rmc(parts: list[str]) -> Optional[GPSPosition]:
|
||||
"""
|
||||
Parse GPRMC/GNRMC sentence (Recommended Minimum).
|
||||
|
||||
Format: $GPRMC,time,status,lat,N/S,lon,E/W,speed,heading,date,...
|
||||
"""
|
||||
if len(parts) < 8:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Check status (A=active/valid, V=void/invalid)
|
||||
if parts[2] != 'A':
|
||||
return None
|
||||
|
||||
lat = parse_nmea_coordinate(parts[3], parts[4])
|
||||
lon = parse_nmea_coordinate(parts[5], parts[6])
|
||||
|
||||
if lat is None or lon is None:
|
||||
return None
|
||||
|
||||
# Parse optional fields
|
||||
speed = float(parts[7]) if parts[7] else None # knots
|
||||
heading = float(parts[8]) if len(parts) > 8 and parts[8] else None
|
||||
|
||||
# Parse timestamp
|
||||
timestamp = None
|
||||
if parts[1] and len(parts) > 9 and parts[9]:
|
||||
try:
|
||||
time_str = parts[1].split('.')[0]
|
||||
date_str = parts[9]
|
||||
if len(time_str) >= 6 and len(date_str) >= 6:
|
||||
timestamp = datetime(
|
||||
year=2000 + int(date_str[4:6]),
|
||||
month=int(date_str[2:4]),
|
||||
day=int(date_str[0:2]),
|
||||
hour=int(time_str[0:2]),
|
||||
minute=int(time_str[2:4]),
|
||||
second=int(time_str[4:6]),
|
||||
)
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
return GPSPosition(
|
||||
latitude=lat,
|
||||
longitude=lon,
|
||||
speed=speed,
|
||||
heading=heading,
|
||||
timestamp=timestamp,
|
||||
fix_quality=1, # RMC with A status means valid fix
|
||||
)
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.debug(f"RMC parse error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def parse_nmea_sentence(sentence: str) -> Optional[GPSPosition]:
|
||||
"""
|
||||
Parse an NMEA sentence and extract position data.
|
||||
|
||||
Supports: GGA, RMC sentences (with GP, GN, GL prefixes)
|
||||
"""
|
||||
sentence = sentence.strip()
|
||||
|
||||
# Validate checksum if present
|
||||
if '*' in sentence:
|
||||
data, checksum = sentence.rsplit('*', 1)
|
||||
if data.startswith('$'):
|
||||
data = data[1:]
|
||||
|
||||
# Calculate checksum
|
||||
calc_checksum = 0
|
||||
for char in data:
|
||||
calc_checksum ^= ord(char)
|
||||
|
||||
try:
|
||||
if int(checksum, 16) != calc_checksum:
|
||||
logger.debug(f"Checksum mismatch: {sentence}")
|
||||
return None
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Remove $ prefix if present
|
||||
if sentence.startswith('$'):
|
||||
sentence = sentence[1:]
|
||||
|
||||
# Remove checksum for parsing
|
||||
if '*' in sentence:
|
||||
sentence = sentence.split('*')[0]
|
||||
|
||||
parts = sentence.split(',')
|
||||
if not parts:
|
||||
return None
|
||||
|
||||
msg_type = parts[0]
|
||||
|
||||
# Handle various NMEA talker IDs (GP=GPS, GN=GNSS, GL=GLONASS, GA=Galileo)
|
||||
if msg_type.endswith('GGA'):
|
||||
return parse_gga(parts)
|
||||
elif msg_type.endswith('RMC'):
|
||||
return parse_rmc(parts)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class GPSReader:
|
||||
"""
|
||||
Reads GPS data from a serial device.
|
||||
|
||||
Runs in a background thread and maintains current position.
|
||||
gpsd provides a unified interface for GPS devices and handles
|
||||
device management, making it ideal when gpsd is already running.
|
||||
"""
|
||||
|
||||
def __init__(self, device_path: str, baudrate: int = 9600):
|
||||
self.device_path = device_path
|
||||
self.baudrate = baudrate
|
||||
DEFAULT_HOST = 'localhost'
|
||||
DEFAULT_PORT = 2947
|
||||
|
||||
def __init__(self, host: str = DEFAULT_HOST, port: int = DEFAULT_PORT):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self._position: Optional[GPSPosition] = None
|
||||
self._lock = threading.Lock()
|
||||
self._running = False
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._serial: Optional['serial.Serial'] = None
|
||||
self._socket: Optional['socket.socket'] = None
|
||||
self._last_update: Optional[datetime] = None
|
||||
self._error: Optional[str] = None
|
||||
self._callbacks: list[Callable[[GPSPosition], None]] = []
|
||||
self._device: Optional[str] = None
|
||||
|
||||
@property
|
||||
def position(self) -> Optional[GPSPosition]:
|
||||
@@ -317,7 +76,7 @@ class GPSReader:
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
"""Check if the reader is running."""
|
||||
"""Check if the client is running."""
|
||||
return self._running
|
||||
|
||||
@property
|
||||
@@ -332,6 +91,11 @@ class GPSReader:
|
||||
with self._lock:
|
||||
return self._error
|
||||
|
||||
@property
|
||||
def device_path(self) -> str:
|
||||
"""Return gpsd connection info."""
|
||||
return f"gpsd://{self.host}:{self.port}"
|
||||
|
||||
def add_callback(self, callback: Callable[[GPSPosition], None]) -> None:
|
||||
"""Add a callback to be called on position updates."""
|
||||
self._callbacks.append(callback)
|
||||
@@ -342,109 +106,162 @@ class GPSReader:
|
||||
self._callbacks.remove(callback)
|
||||
|
||||
def start(self) -> bool:
|
||||
"""Start reading GPS data in a background thread."""
|
||||
if not SERIAL_AVAILABLE:
|
||||
self._error = "pyserial not installed"
|
||||
return False
|
||||
"""Start receiving GPS data from gpsd."""
|
||||
import socket
|
||||
|
||||
if self._running:
|
||||
return True
|
||||
|
||||
try:
|
||||
self._serial = serial.Serial(
|
||||
self.device_path,
|
||||
baudrate=self.baudrate,
|
||||
timeout=1.0
|
||||
)
|
||||
self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
self._socket.settimeout(5.0)
|
||||
self._socket.connect((self.host, self.port))
|
||||
|
||||
# Enable JSON watch mode
|
||||
watch_cmd = '?WATCH={"enable":true,"json":true}\n'
|
||||
self._socket.send(watch_cmd.encode('ascii'))
|
||||
|
||||
self._running = True
|
||||
self._error = None
|
||||
|
||||
self._thread = threading.Thread(target=self._read_loop, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
logger.info(f"Started GPS reader on {self.device_path}")
|
||||
logger.info(f"Connected to gpsd at {self.host}:{self.port}")
|
||||
print(f"[GPS] Connected to gpsd at {self.host}:{self.port}", flush=True)
|
||||
return True
|
||||
|
||||
except serial.SerialException as e:
|
||||
except Exception as e:
|
||||
self._error = str(e)
|
||||
logger.error(f"Failed to open GPS device {self.device_path}: {e}")
|
||||
logger.error(f"Failed to connect to gpsd at {self.host}:{self.port}: {e}")
|
||||
if self._socket:
|
||||
try:
|
||||
self._socket.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._socket = None
|
||||
return False
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop reading GPS data."""
|
||||
"""Stop receiving GPS data."""
|
||||
self._running = False
|
||||
|
||||
if self._serial:
|
||||
if self._socket:
|
||||
try:
|
||||
self._serial.close()
|
||||
# Disable watch mode
|
||||
self._socket.send(b'?WATCH={"enable":false}\n')
|
||||
self._socket.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._serial = None
|
||||
self._socket = None
|
||||
|
||||
if self._thread:
|
||||
self._thread.join(timeout=2.0)
|
||||
self._thread = None
|
||||
|
||||
logger.info(f"Stopped GPS reader on {self.device_path}")
|
||||
logger.info(f"Disconnected from gpsd at {self.host}:{self.port}")
|
||||
|
||||
def _read_loop(self) -> None:
|
||||
"""Background thread loop for reading GPS data."""
|
||||
"""Background thread loop for reading gpsd data."""
|
||||
import json
|
||||
import socket
|
||||
|
||||
buffer = ""
|
||||
sentence_count = 0
|
||||
bytes_read = 0
|
||||
message_count = 0
|
||||
|
||||
print(f"[GPS] Read loop started on {self.device_path} at {self.baudrate} baud", flush=True)
|
||||
print(f"[GPS] gpsd read loop started", flush=True)
|
||||
|
||||
while self._running and self._serial:
|
||||
while self._running and self._socket:
|
||||
try:
|
||||
# Read available data
|
||||
waiting = self._serial.in_waiting
|
||||
if waiting:
|
||||
data = self._serial.read(waiting)
|
||||
bytes_read += len(data)
|
||||
if bytes_read <= 500 or bytes_read % 1000 == 0:
|
||||
print(f"[GPS] Read {len(data)} bytes (total: {bytes_read})", flush=True)
|
||||
buffer += data.decode('ascii', errors='ignore')
|
||||
self._socket.settimeout(1.0)
|
||||
data = self._socket.recv(4096)
|
||||
|
||||
# Process complete lines
|
||||
while '\n' in buffer:
|
||||
line, buffer = buffer.split('\n', 1)
|
||||
line = line.strip()
|
||||
if not data:
|
||||
logger.warning("gpsd connection closed")
|
||||
with self._lock:
|
||||
self._error = "Connection closed by gpsd"
|
||||
break
|
||||
|
||||
if line.startswith('$'):
|
||||
sentence_count += 1
|
||||
# Log first few sentences and periodically after that
|
||||
if sentence_count <= 10 or sentence_count % 50 == 0:
|
||||
print(f"[GPS] NMEA [{sentence_count}]: {line[:70]}", flush=True)
|
||||
buffer += data.decode('ascii', errors='ignore')
|
||||
|
||||
position = parse_nmea_sentence(line)
|
||||
if position:
|
||||
print(f"[GPS] FIX: {position.latitude:.6f}, {position.longitude:.6f} (sats: {position.satellites}, quality: {position.fix_quality})", flush=True)
|
||||
position.device = self.device_path
|
||||
self._update_position(position)
|
||||
else:
|
||||
time.sleep(0.1)
|
||||
# Process complete JSON lines
|
||||
while '\n' in buffer:
|
||||
line, buffer = buffer.split('\n', 1)
|
||||
line = line.strip()
|
||||
|
||||
except serial.SerialException as e:
|
||||
logger.error(f"GPS read error: {e}")
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
msg = json.loads(line)
|
||||
msg_class = msg.get('class', '')
|
||||
|
||||
message_count += 1
|
||||
if message_count <= 5 or message_count % 20 == 0:
|
||||
print(f"[GPS] gpsd msg [{message_count}]: {msg_class}", flush=True)
|
||||
|
||||
if msg_class == 'TPV':
|
||||
self._handle_tpv(msg)
|
||||
elif msg_class == 'DEVICES':
|
||||
# Track connected device
|
||||
devices = msg.get('devices', [])
|
||||
if devices:
|
||||
self._device = devices[0].get('path', 'unknown')
|
||||
print(f"[GPS] gpsd device: {self._device}", flush=True)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
logger.debug(f"Invalid JSON from gpsd: {line[:50]}")
|
||||
|
||||
except socket.timeout:
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f"gpsd read error: {e}")
|
||||
with self._lock:
|
||||
self._error = str(e)
|
||||
break
|
||||
except Exception as e:
|
||||
logger.debug(f"GPS parse error: {e}")
|
||||
|
||||
def _handle_tpv(self, msg: dict) -> None:
|
||||
"""Handle TPV (Time-Position-Velocity) message from gpsd."""
|
||||
# mode: 0=unknown, 1=no fix, 2=2D fix, 3=3D fix
|
||||
mode = msg.get('mode', 0)
|
||||
|
||||
if mode < 2:
|
||||
# No fix yet
|
||||
return
|
||||
|
||||
lat = msg.get('lat')
|
||||
lon = msg.get('lon')
|
||||
|
||||
if lat is None or lon is None:
|
||||
return
|
||||
|
||||
# Parse timestamp
|
||||
timestamp = None
|
||||
time_str = msg.get('time')
|
||||
if time_str:
|
||||
try:
|
||||
# gpsd uses ISO format: 2024-01-01T12:00:00.000Z
|
||||
timestamp = datetime.fromisoformat(time_str.replace('Z', '+00:00'))
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
|
||||
position = GPSPosition(
|
||||
latitude=lat,
|
||||
longitude=lon,
|
||||
altitude=msg.get('alt'),
|
||||
speed=msg.get('speed'), # m/s in gpsd
|
||||
heading=msg.get('track'),
|
||||
fix_quality=mode,
|
||||
timestamp=timestamp,
|
||||
device=self._device or f"gpsd://{self.host}:{self.port}",
|
||||
)
|
||||
|
||||
print(f"[GPS] gpsd FIX: {lat:.6f}, {lon:.6f} (mode: {mode})", flush=True)
|
||||
self._update_position(position)
|
||||
|
||||
def _update_position(self, position: GPSPosition) -> None:
|
||||
"""Update the current position and notify callbacks."""
|
||||
with self._lock:
|
||||
# Merge data from different sentence types
|
||||
if self._position:
|
||||
# Keep altitude from GGA if RMC doesn't have it
|
||||
if position.altitude is None and self._position.altitude:
|
||||
position.altitude = self._position.altitude
|
||||
# Keep satellites from GGA
|
||||
if position.satellites is None and self._position.satellites:
|
||||
position.satellites = self._position.satellites
|
||||
|
||||
self._position = position
|
||||
self._last_update = datetime.utcnow()
|
||||
self._error = None
|
||||
@@ -457,57 +274,59 @@ class GPSReader:
|
||||
logger.error(f"GPS callback error: {e}")
|
||||
|
||||
|
||||
# Global GPS reader instance
|
||||
_gps_reader: Optional[GPSReader] = None
|
||||
# Global GPS client instance
|
||||
_gps_client: Optional[GPSDClient] = None
|
||||
_gps_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_gps_reader() -> Optional[GPSReader]:
|
||||
"""Get the global GPS reader instance."""
|
||||
def get_gps_reader() -> Optional[GPSDClient]:
|
||||
"""Get the global GPS client instance."""
|
||||
with _gps_lock:
|
||||
return _gps_reader
|
||||
return _gps_client
|
||||
|
||||
|
||||
def start_gps(device_path: str, baudrate: int = 9600) -> bool:
|
||||
def start_gpsd(host: str = 'localhost', port: int = 2947,
|
||||
callback: Optional[Callable[[GPSPosition], None]] = None) -> bool:
|
||||
"""
|
||||
Start the global GPS reader.
|
||||
Start the global GPS client connected to gpsd.
|
||||
|
||||
Args:
|
||||
device_path: Path to the GPS serial device
|
||||
baudrate: Serial baudrate (default 9600)
|
||||
host: gpsd host (default localhost)
|
||||
port: gpsd port (default 2947)
|
||||
callback: Optional callback for position updates
|
||||
|
||||
Returns:
|
||||
True if started successfully
|
||||
"""
|
||||
global _gps_reader
|
||||
global _gps_client
|
||||
|
||||
with _gps_lock:
|
||||
# Stop existing reader if any
|
||||
if _gps_reader:
|
||||
_gps_reader.stop()
|
||||
# Stop existing client if any
|
||||
if _gps_client:
|
||||
_gps_client.stop()
|
||||
|
||||
_gps_reader = GPSReader(device_path, baudrate)
|
||||
return _gps_reader.start()
|
||||
_gps_client = GPSDClient(host, port)
|
||||
|
||||
# Register callback BEFORE starting to avoid race condition
|
||||
if callback:
|
||||
_gps_client.add_callback(callback)
|
||||
|
||||
return _gps_client.start()
|
||||
|
||||
|
||||
def stop_gps() -> None:
|
||||
"""Stop the global GPS reader."""
|
||||
global _gps_reader
|
||||
"""Stop the global GPS client."""
|
||||
global _gps_client
|
||||
|
||||
with _gps_lock:
|
||||
if _gps_reader:
|
||||
_gps_reader.stop()
|
||||
_gps_reader = None
|
||||
if _gps_client:
|
||||
_gps_client.stop()
|
||||
_gps_client = None
|
||||
|
||||
|
||||
def get_current_position() -> Optional[GPSPosition]:
|
||||
"""Get the current GPS position from the global reader."""
|
||||
reader = get_gps_reader()
|
||||
if reader:
|
||||
return reader.position
|
||||
"""Get the current GPS position from the global client."""
|
||||
client = get_gps_reader()
|
||||
if client:
|
||||
return client.position
|
||||
return None
|
||||
|
||||
|
||||
def is_serial_available() -> bool:
|
||||
"""Check if pyserial is available."""
|
||||
return SERIAL_AVAILABLE
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
"""
|
||||
Process health monitoring and auto-restart functionality.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Callable, Dict, Optional, Any
|
||||
|
||||
logger = logging.getLogger('intercept.process_monitor')
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcessInfo:
|
||||
"""Information about a monitored process."""
|
||||
name: str
|
||||
process: Any # subprocess.Popen
|
||||
started_at: datetime = field(default_factory=datetime.now)
|
||||
restart_count: int = 0
|
||||
last_restart: Optional[datetime] = None
|
||||
restart_callback: Optional[Callable] = None
|
||||
max_restarts: int = 3
|
||||
backoff_seconds: float = 5.0
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class ProcessMonitor:
|
||||
"""
|
||||
Monitor and auto-restart processes.
|
||||
|
||||
Usage:
|
||||
monitor = ProcessMonitor()
|
||||
monitor.register('pager', process, restart_callback=start_pager)
|
||||
monitor.start()
|
||||
"""
|
||||
|
||||
def __init__(self, check_interval: float = 5.0):
|
||||
self.processes: Dict[str, ProcessInfo] = {}
|
||||
self.check_interval = check_interval
|
||||
self._running = False
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def register(
|
||||
self,
|
||||
name: str,
|
||||
process: Any,
|
||||
restart_callback: Optional[Callable] = None,
|
||||
max_restarts: int = 3,
|
||||
backoff_seconds: float = 5.0
|
||||
) -> None:
|
||||
"""
|
||||
Register a process for monitoring.
|
||||
|
||||
Args:
|
||||
name: Unique name for the process
|
||||
process: The subprocess.Popen object
|
||||
restart_callback: Function to call to restart the process
|
||||
max_restarts: Maximum number of automatic restarts
|
||||
backoff_seconds: Base backoff time between restarts
|
||||
"""
|
||||
with self._lock:
|
||||
self.processes[name] = ProcessInfo(
|
||||
name=name,
|
||||
process=process,
|
||||
restart_callback=restart_callback,
|
||||
max_restarts=max_restarts,
|
||||
backoff_seconds=backoff_seconds
|
||||
)
|
||||
logger.info(f"Registered process for monitoring: {name}")
|
||||
|
||||
def unregister(self, name: str) -> None:
|
||||
"""Remove a process from monitoring."""
|
||||
with self._lock:
|
||||
if name in self.processes:
|
||||
del self.processes[name]
|
||||
logger.info(f"Unregistered process: {name}")
|
||||
|
||||
def update_process(self, name: str, process: Any) -> None:
|
||||
"""Update the process object for a registered name."""
|
||||
with self._lock:
|
||||
if name in self.processes:
|
||||
self.processes[name].process = process
|
||||
self.processes[name].started_at = datetime.now()
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the monitoring thread."""
|
||||
if self._running:
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._monitor_loop, daemon=True)
|
||||
self._thread.start()
|
||||
logger.info("Process monitor started")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the monitoring thread."""
|
||||
self._running = False
|
||||
if self._thread:
|
||||
self._thread.join(timeout=self.check_interval + 1)
|
||||
logger.info("Process monitor stopped")
|
||||
|
||||
def _monitor_loop(self) -> None:
|
||||
"""Main monitoring loop."""
|
||||
while self._running:
|
||||
self._check_all_processes()
|
||||
time.sleep(self.check_interval)
|
||||
|
||||
def _check_all_processes(self) -> None:
|
||||
"""Check health of all registered processes."""
|
||||
with self._lock:
|
||||
for name, info in list(self.processes.items()):
|
||||
if not info.enabled:
|
||||
continue
|
||||
|
||||
if info.process is None:
|
||||
continue
|
||||
|
||||
# Check if process has terminated
|
||||
return_code = info.process.poll()
|
||||
if return_code is not None:
|
||||
logger.warning(
|
||||
f"Process '{name}' terminated with code {return_code}"
|
||||
)
|
||||
self._handle_crash(name, info)
|
||||
|
||||
def _handle_crash(self, name: str, info: ProcessInfo) -> None:
|
||||
"""Handle a crashed process."""
|
||||
if info.restart_callback is None:
|
||||
logger.info(f"No restart callback for '{name}', skipping auto-restart")
|
||||
return
|
||||
|
||||
if info.restart_count >= info.max_restarts:
|
||||
logger.error(
|
||||
f"Process '{name}' exceeded max restarts ({info.max_restarts}), "
|
||||
"disabling auto-restart"
|
||||
)
|
||||
info.enabled = False
|
||||
return
|
||||
|
||||
# Calculate backoff with exponential increase
|
||||
backoff = info.backoff_seconds * (2 ** info.restart_count)
|
||||
logger.info(
|
||||
f"Attempting to restart '{name}' in {backoff:.1f}s "
|
||||
f"(attempt {info.restart_count + 1}/{info.max_restarts})"
|
||||
)
|
||||
|
||||
# Wait for backoff period
|
||||
time.sleep(backoff)
|
||||
|
||||
# Attempt restart
|
||||
try:
|
||||
info.restart_callback()
|
||||
info.restart_count += 1
|
||||
info.last_restart = datetime.now()
|
||||
logger.info(f"Successfully restarted '{name}'")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to restart '{name}': {e}")
|
||||
info.restart_count += 1
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get status of all monitored processes.
|
||||
|
||||
Returns:
|
||||
Dict with process status information
|
||||
"""
|
||||
with self._lock:
|
||||
status = {}
|
||||
for name, info in self.processes.items():
|
||||
is_running = (
|
||||
info.process is not None and
|
||||
info.process.poll() is None
|
||||
)
|
||||
status[name] = {
|
||||
'running': is_running,
|
||||
'started_at': info.started_at.isoformat() if info.started_at else None,
|
||||
'restart_count': info.restart_count,
|
||||
'last_restart': info.last_restart.isoformat() if info.last_restart else None,
|
||||
'auto_restart_enabled': info.enabled,
|
||||
'return_code': info.process.poll() if info.process else None
|
||||
}
|
||||
return status
|
||||
|
||||
def reset_restart_count(self, name: str) -> None:
|
||||
"""Reset the restart count for a process (e.g., after manual restart)."""
|
||||
with self._lock:
|
||||
if name in self.processes:
|
||||
self.processes[name].restart_count = 0
|
||||
self.processes[name].enabled = True
|
||||
|
||||
def is_healthy(self) -> bool:
|
||||
"""Check if all processes are healthy."""
|
||||
with self._lock:
|
||||
for info in self.processes.values():
|
||||
if info.process is not None and info.process.poll() is not None:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# Global monitor instance
|
||||
process_monitor = ProcessMonitor()
|
||||
@@ -30,6 +30,8 @@ from .detection import detect_all_devices
|
||||
from .rtlsdr import RTLSDRCommandBuilder
|
||||
from .limesdr import LimeSDRCommandBuilder
|
||||
from .hackrf import HackRFCommandBuilder
|
||||
from .airspy import AirspyCommandBuilder
|
||||
from .sdrplay import SDRPlayCommandBuilder
|
||||
from .validation import (
|
||||
SDRValidationError,
|
||||
validate_frequency,
|
||||
@@ -49,6 +51,8 @@ class SDRFactory:
|
||||
SDRType.RTL_SDR: RTLSDRCommandBuilder,
|
||||
SDRType.LIME_SDR: LimeSDRCommandBuilder,
|
||||
SDRType.HACKRF: HackRFCommandBuilder,
|
||||
SDRType.AIRSPY: AirspyCommandBuilder,
|
||||
SDRType.SDRPLAY: SDRPlayCommandBuilder,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -214,6 +218,8 @@ __all__ = [
|
||||
'RTLSDRCommandBuilder',
|
||||
'LimeSDRCommandBuilder',
|
||||
'HackRFCommandBuilder',
|
||||
'AirspyCommandBuilder',
|
||||
'SDRPlayCommandBuilder',
|
||||
# Validation
|
||||
'SDRValidationError',
|
||||
'validate_frequency',
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"""
|
||||
Airspy command builder implementation.
|
||||
|
||||
Uses SoapySDR-based tools for FM demodulation and signal capture.
|
||||
Airspy R2/Mini supports 24 MHz to 1.8 GHz frequency range.
|
||||
Airspy HF+ supports 9 kHz - 31 MHz and 60-260 MHz.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
|
||||
|
||||
|
||||
class AirspyCommandBuilder(CommandBuilder):
|
||||
"""Airspy command builder using SoapySDR tools."""
|
||||
|
||||
# Airspy R2/Mini capabilities (most common)
|
||||
# HF+ has different range but same interface
|
||||
CAPABILITIES = SDRCapabilities(
|
||||
sdr_type=SDRType.AIRSPY,
|
||||
freq_min_mhz=24.0, # 24 MHz (HF+ goes lower)
|
||||
freq_max_mhz=1800.0, # 1.8 GHz
|
||||
gain_min=0.0,
|
||||
gain_max=45.0, # LNA (0-15) + Mixer (0-15) + VGA (0-15)
|
||||
sample_rates=[2500000, 3000000, 6000000, 10000000],
|
||||
supports_bias_t=True,
|
||||
supports_ppm=False, # Airspy has TCXO, no PPM needed
|
||||
tx_capable=False
|
||||
)
|
||||
|
||||
def _build_device_string(self, device: SDRDevice) -> str:
|
||||
"""Build SoapySDR device string for Airspy."""
|
||||
driver = device.driver if device.driver in ('airspy', 'airspyhf') else 'airspy'
|
||||
if device.serial and device.serial != 'N/A':
|
||||
return f'driver={driver},serial={device.serial}'
|
||||
return f'driver={driver}'
|
||||
|
||||
def _format_gain(self, gain: float) -> str:
|
||||
"""
|
||||
Format gain string for Airspy.
|
||||
|
||||
Airspy has three gain stages:
|
||||
- LNA: 0-15 dB
|
||||
- Mixer: 0-15 dB
|
||||
- VGA: 0-15 dB
|
||||
|
||||
This distributes the requested gain across stages.
|
||||
"""
|
||||
if gain <= 15:
|
||||
return f'LNA={int(gain)},MIX=0,VGA=0'
|
||||
elif gain <= 30:
|
||||
return f'LNA=15,MIX={int(gain - 15)},VGA=0'
|
||||
else:
|
||||
vga = min(15, int(gain - 30))
|
||||
return f'LNA=15,MIX=15,VGA={vga}'
|
||||
|
||||
def build_fm_demod_command(
|
||||
self,
|
||||
device: SDRDevice,
|
||||
frequency_mhz: float,
|
||||
sample_rate: int = 22050,
|
||||
gain: Optional[float] = None,
|
||||
ppm: Optional[int] = None,
|
||||
modulation: str = "fm",
|
||||
squelch: Optional[int] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build SoapySDR rx_fm command for FM demodulation.
|
||||
|
||||
For pager decoding with Airspy.
|
||||
"""
|
||||
device_str = self._build_device_string(device)
|
||||
|
||||
cmd = [
|
||||
'rx_fm',
|
||||
'-d', device_str,
|
||||
'-f', f'{frequency_mhz}M',
|
||||
'-M', modulation,
|
||||
'-s', str(sample_rate),
|
||||
]
|
||||
|
||||
if gain is not None and gain > 0:
|
||||
cmd.extend(['-g', self._format_gain(gain)])
|
||||
|
||||
if squelch is not None and squelch > 0:
|
||||
cmd.extend(['-l', str(squelch)])
|
||||
|
||||
if bias_t:
|
||||
cmd.extend(['-T'])
|
||||
|
||||
# Output to stdout
|
||||
cmd.append('-')
|
||||
|
||||
return cmd
|
||||
|
||||
def build_adsb_command(
|
||||
self,
|
||||
device: SDRDevice,
|
||||
gain: Optional[float] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build dump1090/readsb command with SoapySDR support for ADS-B decoding.
|
||||
|
||||
Uses readsb which has better SoapySDR support.
|
||||
"""
|
||||
device_str = self._build_device_string(device)
|
||||
|
||||
cmd = [
|
||||
'readsb',
|
||||
'--net',
|
||||
'--device-type', 'soapysdr',
|
||||
'--device', device_str,
|
||||
'--quiet'
|
||||
]
|
||||
|
||||
if gain is not None:
|
||||
cmd.extend(['--gain', str(int(gain))])
|
||||
|
||||
if bias_t:
|
||||
cmd.extend(['--enable-bias-t'])
|
||||
|
||||
return cmd
|
||||
|
||||
def build_ism_command(
|
||||
self,
|
||||
device: SDRDevice,
|
||||
frequency_mhz: float = 433.92,
|
||||
gain: Optional[float] = None,
|
||||
ppm: Optional[int] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build rtl_433 command with SoapySDR support for ISM band decoding.
|
||||
|
||||
rtl_433 has native SoapySDR support via -d flag.
|
||||
"""
|
||||
device_str = self._build_device_string(device)
|
||||
|
||||
cmd = [
|
||||
'rtl_433',
|
||||
'-d', device_str,
|
||||
'-f', f'{frequency_mhz}M',
|
||||
'-F', 'json'
|
||||
]
|
||||
|
||||
if gain is not None and gain > 0:
|
||||
cmd.extend(['-g', str(int(gain))])
|
||||
|
||||
if bias_t:
|
||||
cmd.extend(['-T'])
|
||||
|
||||
return cmd
|
||||
|
||||
def get_capabilities(self) -> SDRCapabilities:
|
||||
"""Return Airspy capabilities."""
|
||||
return self.CAPABILITIES
|
||||
|
||||
@classmethod
|
||||
def get_sdr_type(cls) -> SDRType:
|
||||
"""Return SDR type."""
|
||||
return SDRType.AIRSPY
|
||||
@@ -18,6 +18,8 @@ class SDRType(Enum):
|
||||
RTL_SDR = "rtlsdr"
|
||||
LIME_SDR = "limesdr"
|
||||
HACKRF = "hackrf"
|
||||
AIRSPY = "airspy"
|
||||
SDRPLAY = "sdrplay"
|
||||
# Future support
|
||||
# USRP = "usrp"
|
||||
# BLADE_RF = "bladerf"
|
||||
@@ -92,7 +94,8 @@ class CommandBuilder(ABC):
|
||||
gain: Optional[float] = None,
|
||||
ppm: Optional[int] = None,
|
||||
modulation: str = "fm",
|
||||
squelch: Optional[int] = None
|
||||
squelch: Optional[int] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build FM demodulation command (for pager decoding).
|
||||
@@ -105,6 +108,7 @@ class CommandBuilder(ABC):
|
||||
ppm: PPM frequency correction
|
||||
modulation: Modulation type (fm, am, etc.)
|
||||
squelch: Squelch level
|
||||
bias_t: Enable bias-T power (for active antennas)
|
||||
|
||||
Returns:
|
||||
Command as list of strings for subprocess
|
||||
@@ -115,7 +119,8 @@ class CommandBuilder(ABC):
|
||||
def build_adsb_command(
|
||||
self,
|
||||
device: SDRDevice,
|
||||
gain: Optional[float] = None
|
||||
gain: Optional[float] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build ADS-B decoder command.
|
||||
@@ -123,6 +128,7 @@ class CommandBuilder(ABC):
|
||||
Args:
|
||||
device: The SDR device to use
|
||||
gain: Gain in dB (None for auto)
|
||||
bias_t: Enable bias-T power (for active antennas)
|
||||
|
||||
Returns:
|
||||
Command as list of strings for subprocess
|
||||
@@ -135,7 +141,8 @@ class CommandBuilder(ABC):
|
||||
device: SDRDevice,
|
||||
frequency_mhz: float = 433.92,
|
||||
gain: Optional[float] = None,
|
||||
ppm: Optional[int] = None
|
||||
ppm: Optional[int] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build ISM band decoder command (433MHz sensors).
|
||||
@@ -145,6 +152,7 @@ class CommandBuilder(ABC):
|
||||
frequency_mhz: Center frequency in MHz (default 433.92)
|
||||
gain: Gain in dB (None for auto)
|
||||
ppm: PPM frequency correction
|
||||
bias_t: Enable bias-T power (for active antennas)
|
||||
|
||||
Returns:
|
||||
Command as list of strings for subprocess
|
||||
|
||||
@@ -28,11 +28,15 @@ def _get_capabilities_for_type(sdr_type: SDRType) -> SDRCapabilities:
|
||||
from .rtlsdr import RTLSDRCommandBuilder
|
||||
from .limesdr import LimeSDRCommandBuilder
|
||||
from .hackrf import HackRFCommandBuilder
|
||||
from .airspy import AirspyCommandBuilder
|
||||
from .sdrplay import SDRPlayCommandBuilder
|
||||
|
||||
builders = {
|
||||
SDRType.RTL_SDR: RTLSDRCommandBuilder,
|
||||
SDRType.LIME_SDR: LimeSDRCommandBuilder,
|
||||
SDRType.HACKRF: HackRFCommandBuilder,
|
||||
SDRType.AIRSPY: AirspyCommandBuilder,
|
||||
SDRType.SDRPLAY: SDRPlayCommandBuilder,
|
||||
}
|
||||
|
||||
builder_class = builders.get(sdr_type)
|
||||
@@ -60,6 +64,9 @@ def _driver_to_sdr_type(driver: str) -> Optional[SDRType]:
|
||||
'lime': SDRType.LIME_SDR,
|
||||
'limesdr': SDRType.LIME_SDR,
|
||||
'hackrf': SDRType.HACKRF,
|
||||
'airspy': SDRType.AIRSPY,
|
||||
'airspyhf': SDRType.AIRSPY, # Airspy HF+ uses same builder
|
||||
'sdrplay': SDRType.SDRPLAY,
|
||||
# Future support
|
||||
# 'uhd': SDRType.USRP,
|
||||
# 'bladerf': SDRType.BLADE_RF,
|
||||
@@ -140,23 +147,35 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
|
||||
return devices
|
||||
|
||||
|
||||
def detect_soapy_devices() -> list[SDRDevice]:
|
||||
def _find_soapy_util() -> str | None:
|
||||
"""Find SoapySDR utility command (name varies by distribution)."""
|
||||
# Try different command names used across distributions
|
||||
for cmd in ['SoapySDRUtil', 'soapy_sdr_util', 'soapysdr-util']:
|
||||
if _check_tool(cmd):
|
||||
return cmd
|
||||
return None
|
||||
|
||||
|
||||
def detect_soapy_devices(skip_types: Optional[set[SDRType]] = None) -> list[SDRDevice]:
|
||||
"""
|
||||
Detect SDR devices via SoapySDR.
|
||||
|
||||
This detects LimeSDR, HackRF, USRP, BladeRF, and other SoapySDR-compatible
|
||||
devices. RTL-SDR devices may also appear here but we prefer the native
|
||||
detection for those.
|
||||
This detects LimeSDR, HackRF, Airspy, and other SoapySDR-compatible devices.
|
||||
|
||||
Args:
|
||||
skip_types: Set of SDRType values to skip (e.g., if already found via native detection)
|
||||
"""
|
||||
devices: list[SDRDevice] = []
|
||||
skip_types = skip_types or set()
|
||||
|
||||
if not _check_tool('SoapySDRUtil'):
|
||||
logger.debug("SoapySDRUtil not found, skipping SoapySDR detection")
|
||||
soapy_cmd = _find_soapy_util()
|
||||
if not soapy_cmd:
|
||||
logger.debug("SoapySDR utility not found, skipping SoapySDR detection")
|
||||
return devices
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['SoapySDRUtil', '--find'],
|
||||
[soapy_cmd, '--find'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
@@ -177,7 +196,7 @@ def detect_soapy_devices() -> list[SDRDevice]:
|
||||
# Start of new device block
|
||||
if line.startswith('Found device'):
|
||||
if current_device.get('driver'):
|
||||
_add_soapy_device(devices, current_device, device_counts)
|
||||
_add_soapy_device(devices, current_device, device_counts, skip_types)
|
||||
current_device = {}
|
||||
continue
|
||||
|
||||
@@ -190,7 +209,7 @@ def detect_soapy_devices() -> list[SDRDevice]:
|
||||
|
||||
# Don't forget the last device
|
||||
if current_device.get('driver'):
|
||||
_add_soapy_device(devices, current_device, device_counts)
|
||||
_add_soapy_device(devices, current_device, device_counts, skip_types)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("SoapySDRUtil timed out")
|
||||
@@ -203,7 +222,8 @@ def detect_soapy_devices() -> list[SDRDevice]:
|
||||
def _add_soapy_device(
|
||||
devices: list[SDRDevice],
|
||||
device_info: dict,
|
||||
device_counts: dict[SDRType, int]
|
||||
device_counts: dict[SDRType, int],
|
||||
skip_types: set[SDRType]
|
||||
) -> None:
|
||||
"""Add a device from SoapySDR detection to the list."""
|
||||
driver = device_info.get('driver', '').lower()
|
||||
@@ -213,8 +233,9 @@ def _add_soapy_device(
|
||||
logger.debug(f"Unknown SoapySDR driver: {driver}")
|
||||
return
|
||||
|
||||
# Skip RTL-SDR devices from SoapySDR (we use native detection)
|
||||
if sdr_type == SDRType.RTL_SDR:
|
||||
# Skip device types that were already found via native detection
|
||||
if sdr_type in skip_types:
|
||||
logger.debug(f"Skipping {driver} from SoapySDR (already found via native detection)")
|
||||
return
|
||||
|
||||
# Track device index per type
|
||||
@@ -294,19 +315,24 @@ def detect_all_devices() -> list[SDRDevice]:
|
||||
Returns a unified list of SDRDevice objects sorted by type and index.
|
||||
"""
|
||||
devices: list[SDRDevice] = []
|
||||
skip_in_soapy: set[SDRType] = set()
|
||||
|
||||
# RTL-SDR via native tool (primary method)
|
||||
devices.extend(detect_rtlsdr_devices())
|
||||
rtlsdr_devices = detect_rtlsdr_devices()
|
||||
devices.extend(rtlsdr_devices)
|
||||
if rtlsdr_devices:
|
||||
skip_in_soapy.add(SDRType.RTL_SDR)
|
||||
|
||||
# SoapySDR devices (LimeSDR, HackRF, etc.)
|
||||
soapy_devices = detect_soapy_devices()
|
||||
# Native HackRF detection (primary method)
|
||||
hackrf_devices = detect_hackrf_devices()
|
||||
devices.extend(hackrf_devices)
|
||||
if hackrf_devices:
|
||||
skip_in_soapy.add(SDRType.HACKRF)
|
||||
|
||||
# SoapySDR devices (LimeSDR, Airspy, and fallback for HackRF/RTL-SDR if native failed)
|
||||
soapy_devices = detect_soapy_devices(skip_types=skip_in_soapy)
|
||||
devices.extend(soapy_devices)
|
||||
|
||||
# Native HackRF detection (fallback if SoapySDR didn't find it)
|
||||
hackrf_from_soapy = any(d.sdr_type == SDRType.HACKRF for d in soapy_devices)
|
||||
if not hackrf_from_soapy:
|
||||
devices.extend(detect_hackrf_devices())
|
||||
|
||||
# Sort by type name, then index
|
||||
devices.sort(key=lambda d: (d.sdr_type.value, d.index))
|
||||
|
||||
|
||||
@@ -60,7 +60,8 @@ class HackRFCommandBuilder(CommandBuilder):
|
||||
gain: Optional[float] = None,
|
||||
ppm: Optional[int] = None,
|
||||
modulation: str = "fm",
|
||||
squelch: Optional[int] = None
|
||||
squelch: Optional[int] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build SoapySDR rx_fm command for FM demodulation.
|
||||
@@ -84,6 +85,9 @@ class HackRFCommandBuilder(CommandBuilder):
|
||||
if squelch is not None and squelch > 0:
|
||||
cmd.extend(['-l', str(squelch)])
|
||||
|
||||
if bias_t:
|
||||
cmd.extend(['-T'])
|
||||
|
||||
# Output to stdout
|
||||
cmd.append('-')
|
||||
|
||||
@@ -92,7 +96,8 @@ class HackRFCommandBuilder(CommandBuilder):
|
||||
def build_adsb_command(
|
||||
self,
|
||||
device: SDRDevice,
|
||||
gain: Optional[float] = None
|
||||
gain: Optional[float] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build dump1090/readsb command with SoapySDR support for ADS-B decoding.
|
||||
@@ -112,6 +117,9 @@ class HackRFCommandBuilder(CommandBuilder):
|
||||
if gain is not None:
|
||||
cmd.extend(['--gain', str(int(gain))])
|
||||
|
||||
if bias_t:
|
||||
cmd.extend(['--enable-bias-t'])
|
||||
|
||||
return cmd
|
||||
|
||||
def build_ism_command(
|
||||
@@ -119,14 +127,21 @@ class HackRFCommandBuilder(CommandBuilder):
|
||||
device: SDRDevice,
|
||||
frequency_mhz: float = 433.92,
|
||||
gain: Optional[float] = None,
|
||||
ppm: Optional[int] = None
|
||||
ppm: Optional[int] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build rtl_433 command with SoapySDR support for ISM band decoding.
|
||||
|
||||
rtl_433 has native SoapySDR support via -d flag.
|
||||
|
||||
Note: rtl_433's -T flag is for timeout, NOT bias-t.
|
||||
For SoapySDR devices, bias-t is passed as a device setting.
|
||||
"""
|
||||
# Build device string with optional bias-t setting
|
||||
device_str = self._build_device_string(device)
|
||||
if bias_t:
|
||||
device_str = f'{device_str},bias_t=1'
|
||||
|
||||
cmd = [
|
||||
'rtl_433',
|
||||
|
||||
@@ -41,12 +41,14 @@ class LimeSDRCommandBuilder(CommandBuilder):
|
||||
gain: Optional[float] = None,
|
||||
ppm: Optional[int] = None,
|
||||
modulation: str = "fm",
|
||||
squelch: Optional[int] = None
|
||||
squelch: Optional[int] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build SoapySDR rx_fm command for FM demodulation.
|
||||
|
||||
For pager decoding with LimeSDR.
|
||||
Note: LimeSDR does not support bias-T, parameter is ignored.
|
||||
"""
|
||||
device_str = self._build_device_string(device)
|
||||
|
||||
@@ -73,13 +75,15 @@ class LimeSDRCommandBuilder(CommandBuilder):
|
||||
def build_adsb_command(
|
||||
self,
|
||||
device: SDRDevice,
|
||||
gain: Optional[float] = None
|
||||
gain: Optional[float] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build dump1090 command with SoapySDR support for ADS-B decoding.
|
||||
|
||||
Uses dump1090 compiled with SoapySDR support, or readsb as alternative.
|
||||
Note: Requires dump1090 with SoapySDR support or readsb.
|
||||
Note: LimeSDR does not support bias-T, parameter is ignored.
|
||||
"""
|
||||
device_str = self._build_device_string(device)
|
||||
|
||||
@@ -102,12 +106,14 @@ class LimeSDRCommandBuilder(CommandBuilder):
|
||||
device: SDRDevice,
|
||||
frequency_mhz: float = 433.92,
|
||||
gain: Optional[float] = None,
|
||||
ppm: Optional[int] = None
|
||||
ppm: Optional[int] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build rtl_433 command with SoapySDR support for ISM band decoding.
|
||||
|
||||
rtl_433 has native SoapySDR support via -d flag.
|
||||
Note: LimeSDR does not support bias-T, parameter is ignored.
|
||||
"""
|
||||
device_str = self._build_device_string(device)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
from typing import Optional
|
||||
|
||||
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
|
||||
from utils.dependencies import get_tool_path
|
||||
|
||||
|
||||
class RTLSDRCommandBuilder(CommandBuilder):
|
||||
@@ -45,15 +46,17 @@ class RTLSDRCommandBuilder(CommandBuilder):
|
||||
gain: Optional[float] = None,
|
||||
ppm: Optional[int] = None,
|
||||
modulation: str = "fm",
|
||||
squelch: Optional[int] = None
|
||||
squelch: Optional[int] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build rtl_fm command for FM demodulation.
|
||||
|
||||
Used for pager decoding. Supports local devices and rtl_tcp connections.
|
||||
"""
|
||||
rtl_fm_path = get_tool_path('rtl_fm') or 'rtl_fm'
|
||||
cmd = [
|
||||
'rtl_fm',
|
||||
rtl_fm_path,
|
||||
'-d', self._get_device_arg(device),
|
||||
'-f', f'{frequency_mhz}M',
|
||||
'-M', modulation,
|
||||
@@ -69,6 +72,9 @@ class RTLSDRCommandBuilder(CommandBuilder):
|
||||
if squelch is not None and squelch > 0:
|
||||
cmd.extend(['-l', str(squelch)])
|
||||
|
||||
if bias_t:
|
||||
cmd.extend(['-T'])
|
||||
|
||||
# Output to stdout for piping
|
||||
cmd.append('-')
|
||||
|
||||
@@ -77,7 +83,8 @@ class RTLSDRCommandBuilder(CommandBuilder):
|
||||
def build_adsb_command(
|
||||
self,
|
||||
device: SDRDevice,
|
||||
gain: Optional[float] = None
|
||||
gain: Optional[float] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build dump1090 command for ADS-B decoding.
|
||||
@@ -94,8 +101,9 @@ class RTLSDRCommandBuilder(CommandBuilder):
|
||||
"connect to its SBS output (port 30003)."
|
||||
)
|
||||
|
||||
dump1090_path = get_tool_path('dump1090') or 'dump1090'
|
||||
cmd = [
|
||||
'dump1090',
|
||||
dump1090_path,
|
||||
'--net',
|
||||
'--device-index', str(device.index),
|
||||
'--quiet'
|
||||
@@ -104,6 +112,9 @@ class RTLSDRCommandBuilder(CommandBuilder):
|
||||
if gain is not None:
|
||||
cmd.extend(['--gain', str(int(gain))])
|
||||
|
||||
if bias_t:
|
||||
cmd.extend(['--enable-bias-t'])
|
||||
|
||||
return cmd
|
||||
|
||||
def build_ism_command(
|
||||
@@ -111,16 +122,29 @@ class RTLSDRCommandBuilder(CommandBuilder):
|
||||
device: SDRDevice,
|
||||
frequency_mhz: float = 433.92,
|
||||
gain: Optional[float] = None,
|
||||
ppm: Optional[int] = None
|
||||
ppm: Optional[int] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build rtl_433 command for ISM band sensor decoding.
|
||||
|
||||
Outputs JSON for easy parsing. Supports local devices and rtl_tcp connections.
|
||||
|
||||
Note: rtl_433's -T flag is for timeout, NOT bias-t.
|
||||
Bias-t is enabled via the device string suffix :biast=1
|
||||
"""
|
||||
rtl_433_path = get_tool_path('rtl_433') or 'rtl_433'
|
||||
|
||||
# Build device argument with optional bias-t suffix
|
||||
# rtl_433 uses :biast=1 suffix on device string, not -T flag
|
||||
# (-T is timeout in rtl_433)
|
||||
device_arg = self._get_device_arg(device)
|
||||
if bias_t:
|
||||
device_arg = f'{device_arg}:biast=1'
|
||||
|
||||
cmd = [
|
||||
'rtl_433',
|
||||
'-d', self._get_device_arg(device),
|
||||
rtl_433_path,
|
||||
'-d', device_arg,
|
||||
'-f', f'{frequency_mhz}M',
|
||||
'-F', 'json'
|
||||
]
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
SDRPlay command builder implementation.
|
||||
|
||||
Uses SoapySDR-based tools for FM demodulation and signal capture.
|
||||
SDRPlay RSP devices support 1 kHz to 2 GHz frequency range.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
|
||||
|
||||
|
||||
class SDRPlayCommandBuilder(CommandBuilder):
|
||||
"""SDRPlay command builder using SoapySDR tools."""
|
||||
|
||||
# SDRPlay RSP capabilities (RSPdx, RSP1A, RSPduo, etc.)
|
||||
CAPABILITIES = SDRCapabilities(
|
||||
sdr_type=SDRType.SDRPLAY,
|
||||
freq_min_mhz=0.001, # 1 kHz
|
||||
freq_max_mhz=2000.0, # 2 GHz
|
||||
gain_min=0.0,
|
||||
gain_max=59.0, # IFGR range
|
||||
sample_rates=[62500, 96000, 125000, 192000, 250000, 384000, 500000, 1000000, 2000000],
|
||||
supports_bias_t=True,
|
||||
supports_ppm=False, # SDRPlay has TCXO, no PPM needed
|
||||
tx_capable=False
|
||||
)
|
||||
|
||||
def _build_device_string(self, device: SDRDevice) -> str:
|
||||
"""Build SoapySDR device string for SDRPlay."""
|
||||
if device.serial and device.serial != 'N/A':
|
||||
return f'driver=sdrplay,serial={device.serial}'
|
||||
return 'driver=sdrplay'
|
||||
|
||||
def build_fm_demod_command(
|
||||
self,
|
||||
device: SDRDevice,
|
||||
frequency_mhz: float,
|
||||
sample_rate: int = 22050,
|
||||
gain: Optional[float] = None,
|
||||
ppm: Optional[int] = None,
|
||||
modulation: str = "fm",
|
||||
squelch: Optional[int] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build SoapySDR rx_fm command for FM demodulation.
|
||||
|
||||
For pager decoding with SDRPlay.
|
||||
"""
|
||||
device_str = self._build_device_string(device)
|
||||
|
||||
cmd = [
|
||||
'rx_fm',
|
||||
'-d', device_str,
|
||||
'-f', f'{frequency_mhz}M',
|
||||
'-M', modulation,
|
||||
'-s', str(sample_rate),
|
||||
]
|
||||
|
||||
if gain is not None and gain > 0:
|
||||
cmd.extend(['-g', f'IFGR={int(gain)}'])
|
||||
|
||||
if squelch is not None and squelch > 0:
|
||||
cmd.extend(['-l', str(squelch)])
|
||||
|
||||
if bias_t:
|
||||
cmd.extend(['-T'])
|
||||
|
||||
# Output to stdout
|
||||
cmd.append('-')
|
||||
|
||||
return cmd
|
||||
|
||||
def build_adsb_command(
|
||||
self,
|
||||
device: SDRDevice,
|
||||
gain: Optional[float] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build dump1090/readsb command with SoapySDR support for ADS-B decoding.
|
||||
|
||||
Uses readsb which has better SoapySDR support.
|
||||
"""
|
||||
device_str = self._build_device_string(device)
|
||||
|
||||
cmd = [
|
||||
'readsb',
|
||||
'--net',
|
||||
'--device-type', 'soapysdr',
|
||||
'--device', device_str,
|
||||
'--quiet'
|
||||
]
|
||||
|
||||
if gain is not None:
|
||||
cmd.extend(['--gain', str(int(gain))])
|
||||
|
||||
if bias_t:
|
||||
cmd.extend(['--enable-bias-t'])
|
||||
|
||||
return cmd
|
||||
|
||||
def build_ism_command(
|
||||
self,
|
||||
device: SDRDevice,
|
||||
frequency_mhz: float = 433.92,
|
||||
gain: Optional[float] = None,
|
||||
ppm: Optional[int] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build rtl_433 command with SoapySDR support for ISM band decoding.
|
||||
|
||||
rtl_433 has native SoapySDR support via -d flag.
|
||||
"""
|
||||
device_str = self._build_device_string(device)
|
||||
|
||||
cmd = [
|
||||
'rtl_433',
|
||||
'-d', device_str,
|
||||
'-f', f'{frequency_mhz}M',
|
||||
'-F', 'json'
|
||||
]
|
||||
|
||||
if gain is not None and gain > 0:
|
||||
cmd.extend(['-g', str(int(gain))])
|
||||
|
||||
if bias_t:
|
||||
cmd.extend(['-T'])
|
||||
|
||||
return cmd
|
||||
|
||||
def get_capabilities(self) -> SDRCapabilities:
|
||||
"""Return SDRPlay capabilities."""
|
||||
return self.CAPABILITIES
|
||||
|
||||
@classmethod
|
||||
def get_sdr_type(cls) -> SDRType:
|
||||
"""Return SDR type."""
|
||||
return SDRType.SDRPLAY
|
||||
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
TSCM (Technical Surveillance Countermeasures) Utilities Package
|
||||
|
||||
Provides baseline recording, threat detection, correlation analysis,
|
||||
BLE scanning, and MAC-randomization resistant device identity tools
|
||||
for counter-surveillance operations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = ['detector', 'baseline', 'correlation', 'ble_scanner', 'device_identity']
|
||||
@@ -0,0 +1,388 @@
|
||||
"""
|
||||
TSCM Baseline Recording and Comparison
|
||||
|
||||
Records environment "fingerprints" and compares current scans
|
||||
against baselines to detect new or anomalous devices.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from utils.database import (
|
||||
create_tscm_baseline,
|
||||
get_active_tscm_baseline,
|
||||
get_tscm_baseline,
|
||||
update_tscm_baseline,
|
||||
)
|
||||
|
||||
logger = logging.getLogger('intercept.tscm.baseline')
|
||||
|
||||
|
||||
class BaselineRecorder:
|
||||
"""
|
||||
Records and manages TSCM environment baselines.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.recording = False
|
||||
self.current_baseline_id: int | None = None
|
||||
self.wifi_networks: dict[str, dict] = {} # BSSID -> network info
|
||||
self.bt_devices: dict[str, dict] = {} # MAC -> device info
|
||||
self.rf_frequencies: dict[float, dict] = {} # Frequency -> signal info
|
||||
|
||||
def start_recording(
|
||||
self,
|
||||
name: str,
|
||||
location: str | None = None,
|
||||
description: str | None = None
|
||||
) -> int:
|
||||
"""
|
||||
Start recording a new baseline.
|
||||
|
||||
Args:
|
||||
name: Baseline name
|
||||
location: Optional location description
|
||||
description: Optional description
|
||||
|
||||
Returns:
|
||||
Baseline ID
|
||||
"""
|
||||
self.recording = True
|
||||
self.wifi_networks = {}
|
||||
self.bt_devices = {}
|
||||
self.rf_frequencies = {}
|
||||
|
||||
# Create baseline in database
|
||||
self.current_baseline_id = create_tscm_baseline(
|
||||
name=name,
|
||||
location=location,
|
||||
description=description
|
||||
)
|
||||
|
||||
logger.info(f"Started baseline recording: {name} (ID: {self.current_baseline_id})")
|
||||
return self.current_baseline_id
|
||||
|
||||
def stop_recording(self) -> dict:
|
||||
"""
|
||||
Stop recording and finalize baseline.
|
||||
|
||||
Returns:
|
||||
Final baseline summary
|
||||
"""
|
||||
if not self.recording or not self.current_baseline_id:
|
||||
return {'error': 'Not recording'}
|
||||
|
||||
self.recording = False
|
||||
|
||||
# Convert to lists for storage
|
||||
wifi_list = list(self.wifi_networks.values())
|
||||
bt_list = list(self.bt_devices.values())
|
||||
rf_list = list(self.rf_frequencies.values())
|
||||
|
||||
# Update database
|
||||
update_tscm_baseline(
|
||||
self.current_baseline_id,
|
||||
wifi_networks=wifi_list,
|
||||
bt_devices=bt_list,
|
||||
rf_frequencies=rf_list
|
||||
)
|
||||
|
||||
summary = {
|
||||
'baseline_id': self.current_baseline_id,
|
||||
'wifi_count': len(wifi_list),
|
||||
'bt_count': len(bt_list),
|
||||
'rf_count': len(rf_list),
|
||||
}
|
||||
|
||||
logger.info(
|
||||
f"Baseline recording complete: {summary['wifi_count']} WiFi, "
|
||||
f"{summary['bt_count']} BT, {summary['rf_count']} RF"
|
||||
)
|
||||
|
||||
baseline_id = self.current_baseline_id
|
||||
self.current_baseline_id = None
|
||||
|
||||
return summary
|
||||
|
||||
def add_wifi_device(self, device: dict) -> None:
|
||||
"""Add a WiFi device to the current baseline."""
|
||||
if not self.recording:
|
||||
return
|
||||
|
||||
mac = device.get('bssid', device.get('mac', '')).upper()
|
||||
if not mac:
|
||||
return
|
||||
|
||||
# Update or add device
|
||||
if mac in self.wifi_networks:
|
||||
# Update with latest info
|
||||
self.wifi_networks[mac].update({
|
||||
'last_seen': datetime.now().isoformat(),
|
||||
'power': device.get('power', self.wifi_networks[mac].get('power')),
|
||||
})
|
||||
else:
|
||||
self.wifi_networks[mac] = {
|
||||
'bssid': mac,
|
||||
'essid': device.get('essid', device.get('ssid', '')),
|
||||
'channel': device.get('channel'),
|
||||
'power': device.get('power', device.get('signal')),
|
||||
'vendor': device.get('vendor', ''),
|
||||
'encryption': device.get('privacy', device.get('encryption', '')),
|
||||
'first_seen': datetime.now().isoformat(),
|
||||
'last_seen': datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
def add_bt_device(self, device: dict) -> None:
|
||||
"""Add a Bluetooth device to the current baseline."""
|
||||
if not self.recording:
|
||||
return
|
||||
|
||||
mac = device.get('mac', device.get('address', '')).upper()
|
||||
if not mac:
|
||||
return
|
||||
|
||||
if mac in self.bt_devices:
|
||||
self.bt_devices[mac].update({
|
||||
'last_seen': datetime.now().isoformat(),
|
||||
'rssi': device.get('rssi', self.bt_devices[mac].get('rssi')),
|
||||
})
|
||||
else:
|
||||
self.bt_devices[mac] = {
|
||||
'mac': mac,
|
||||
'name': device.get('name', ''),
|
||||
'rssi': device.get('rssi', device.get('signal')),
|
||||
'manufacturer': device.get('manufacturer', ''),
|
||||
'type': device.get('type', ''),
|
||||
'first_seen': datetime.now().isoformat(),
|
||||
'last_seen': datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
def add_rf_signal(self, signal: dict) -> None:
|
||||
"""Add an RF signal to the current baseline."""
|
||||
if not self.recording:
|
||||
return
|
||||
|
||||
frequency = signal.get('frequency')
|
||||
if not frequency:
|
||||
return
|
||||
|
||||
# Round to 0.1 MHz for grouping
|
||||
freq_key = round(frequency, 1)
|
||||
|
||||
if freq_key in self.rf_frequencies:
|
||||
existing = self.rf_frequencies[freq_key]
|
||||
existing['last_seen'] = datetime.now().isoformat()
|
||||
existing['hit_count'] = existing.get('hit_count', 1) + 1
|
||||
# Update max signal level
|
||||
new_level = signal.get('level', signal.get('power', -100))
|
||||
if new_level > existing.get('max_level', -100):
|
||||
existing['max_level'] = new_level
|
||||
else:
|
||||
self.rf_frequencies[freq_key] = {
|
||||
'frequency': freq_key,
|
||||
'level': signal.get('level', signal.get('power')),
|
||||
'max_level': signal.get('level', signal.get('power', -100)),
|
||||
'modulation': signal.get('modulation', ''),
|
||||
'first_seen': datetime.now().isoformat(),
|
||||
'last_seen': datetime.now().isoformat(),
|
||||
'hit_count': 1,
|
||||
}
|
||||
|
||||
def get_recording_status(self) -> dict:
|
||||
"""Get current recording status and counts."""
|
||||
return {
|
||||
'recording': self.recording,
|
||||
'baseline_id': self.current_baseline_id,
|
||||
'wifi_count': len(self.wifi_networks),
|
||||
'bt_count': len(self.bt_devices),
|
||||
'rf_count': len(self.rf_frequencies),
|
||||
}
|
||||
|
||||
|
||||
class BaselineComparator:
|
||||
"""
|
||||
Compares current scan results against a baseline.
|
||||
"""
|
||||
|
||||
def __init__(self, baseline: dict):
|
||||
"""
|
||||
Initialize comparator with a baseline.
|
||||
|
||||
Args:
|
||||
baseline: Baseline dict from database
|
||||
"""
|
||||
self.baseline = baseline
|
||||
self.baseline_wifi = {
|
||||
d.get('bssid', d.get('mac', '')).upper(): d
|
||||
for d in baseline.get('wifi_networks', [])
|
||||
if d.get('bssid') or d.get('mac')
|
||||
}
|
||||
self.baseline_bt = {
|
||||
d.get('mac', d.get('address', '')).upper(): d
|
||||
for d in baseline.get('bt_devices', [])
|
||||
if d.get('mac') or d.get('address')
|
||||
}
|
||||
self.baseline_rf = {
|
||||
round(d.get('frequency', 0), 1): d
|
||||
for d in baseline.get('rf_frequencies', [])
|
||||
if d.get('frequency')
|
||||
}
|
||||
|
||||
def compare_wifi(self, current_devices: list[dict]) -> dict:
|
||||
"""
|
||||
Compare current WiFi devices against baseline.
|
||||
|
||||
Returns:
|
||||
Dict with new, missing, and matching devices
|
||||
"""
|
||||
current_macs = {
|
||||
d.get('bssid', d.get('mac', '')).upper(): d
|
||||
for d in current_devices
|
||||
if d.get('bssid') or d.get('mac')
|
||||
}
|
||||
|
||||
new_devices = []
|
||||
missing_devices = []
|
||||
matching_devices = []
|
||||
|
||||
# Find new devices
|
||||
for mac, device in current_macs.items():
|
||||
if mac not in self.baseline_wifi:
|
||||
new_devices.append(device)
|
||||
else:
|
||||
matching_devices.append(device)
|
||||
|
||||
# Find missing devices
|
||||
for mac, device in self.baseline_wifi.items():
|
||||
if mac not in current_macs:
|
||||
missing_devices.append(device)
|
||||
|
||||
return {
|
||||
'new': new_devices,
|
||||
'missing': missing_devices,
|
||||
'matching': matching_devices,
|
||||
'new_count': len(new_devices),
|
||||
'missing_count': len(missing_devices),
|
||||
'matching_count': len(matching_devices),
|
||||
}
|
||||
|
||||
def compare_bluetooth(self, current_devices: list[dict]) -> dict:
|
||||
"""Compare current Bluetooth devices against baseline."""
|
||||
current_macs = {
|
||||
d.get('mac', d.get('address', '')).upper(): d
|
||||
for d in current_devices
|
||||
if d.get('mac') or d.get('address')
|
||||
}
|
||||
|
||||
new_devices = []
|
||||
missing_devices = []
|
||||
matching_devices = []
|
||||
|
||||
for mac, device in current_macs.items():
|
||||
if mac not in self.baseline_bt:
|
||||
new_devices.append(device)
|
||||
else:
|
||||
matching_devices.append(device)
|
||||
|
||||
for mac, device in self.baseline_bt.items():
|
||||
if mac not in current_macs:
|
||||
missing_devices.append(device)
|
||||
|
||||
return {
|
||||
'new': new_devices,
|
||||
'missing': missing_devices,
|
||||
'matching': matching_devices,
|
||||
'new_count': len(new_devices),
|
||||
'missing_count': len(missing_devices),
|
||||
'matching_count': len(matching_devices),
|
||||
}
|
||||
|
||||
def compare_rf(self, current_signals: list[dict]) -> dict:
|
||||
"""Compare current RF signals against baseline."""
|
||||
current_freqs = {
|
||||
round(s.get('frequency', 0), 1): s
|
||||
for s in current_signals
|
||||
if s.get('frequency')
|
||||
}
|
||||
|
||||
new_signals = []
|
||||
missing_signals = []
|
||||
matching_signals = []
|
||||
|
||||
for freq, signal in current_freqs.items():
|
||||
if freq not in self.baseline_rf:
|
||||
new_signals.append(signal)
|
||||
else:
|
||||
matching_signals.append(signal)
|
||||
|
||||
for freq, signal in self.baseline_rf.items():
|
||||
if freq not in current_freqs:
|
||||
missing_signals.append(signal)
|
||||
|
||||
return {
|
||||
'new': new_signals,
|
||||
'missing': missing_signals,
|
||||
'matching': matching_signals,
|
||||
'new_count': len(new_signals),
|
||||
'missing_count': len(missing_signals),
|
||||
'matching_count': len(matching_signals),
|
||||
}
|
||||
|
||||
def compare_all(
|
||||
self,
|
||||
wifi_devices: list[dict] | None = None,
|
||||
bt_devices: list[dict] | None = None,
|
||||
rf_signals: list[dict] | None = None
|
||||
) -> dict:
|
||||
"""
|
||||
Compare all current data against baseline.
|
||||
|
||||
Returns:
|
||||
Dict with comparison results for each category
|
||||
"""
|
||||
results = {
|
||||
'wifi': None,
|
||||
'bluetooth': None,
|
||||
'rf': None,
|
||||
'total_new': 0,
|
||||
'total_missing': 0,
|
||||
}
|
||||
|
||||
if wifi_devices is not None:
|
||||
results['wifi'] = self.compare_wifi(wifi_devices)
|
||||
results['total_new'] += results['wifi']['new_count']
|
||||
results['total_missing'] += results['wifi']['missing_count']
|
||||
|
||||
if bt_devices is not None:
|
||||
results['bluetooth'] = self.compare_bluetooth(bt_devices)
|
||||
results['total_new'] += results['bluetooth']['new_count']
|
||||
results['total_missing'] += results['bluetooth']['missing_count']
|
||||
|
||||
if rf_signals is not None:
|
||||
results['rf'] = self.compare_rf(rf_signals)
|
||||
results['total_new'] += results['rf']['new_count']
|
||||
results['total_missing'] += results['rf']['missing_count']
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def get_comparison_for_active_baseline(
|
||||
wifi_devices: list[dict] | None = None,
|
||||
bt_devices: list[dict] | None = None,
|
||||
rf_signals: list[dict] | None = None
|
||||
) -> dict | None:
|
||||
"""
|
||||
Convenience function to compare against the active baseline.
|
||||
|
||||
Returns:
|
||||
Comparison results or None if no active baseline
|
||||
"""
|
||||
baseline = get_active_tscm_baseline()
|
||||
if not baseline:
|
||||
return None
|
||||
|
||||
comparator = BaselineComparator(baseline)
|
||||
return comparator.compare_all(wifi_devices, bt_devices, rf_signals)
|
||||
@@ -0,0 +1,476 @@
|
||||
"""
|
||||
BLE Scanner for TSCM
|
||||
|
||||
Cross-platform BLE scanning with manufacturer data detection.
|
||||
Supports macOS and Linux using the bleak library with fallback to system tools.
|
||||
|
||||
Detects:
|
||||
- Apple AirTags (company ID 0x004C)
|
||||
- Tile trackers
|
||||
- Samsung SmartTags
|
||||
- ESP32/ESP8266 devices (Espressif, company ID 0x02E5)
|
||||
- Generic BLE devices with suspicious characteristics
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import platform
|
||||
import re
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger('intercept.tscm.ble')
|
||||
|
||||
# Manufacturer company IDs (Bluetooth SIG assigned)
|
||||
COMPANY_IDS = {
|
||||
0x004C: 'Apple',
|
||||
0x02E5: 'Espressif',
|
||||
0x0059: 'Nordic Semiconductor',
|
||||
0x000D: 'Texas Instruments',
|
||||
0x0075: 'Samsung',
|
||||
0x00E0: 'Google',
|
||||
0x0006: 'Microsoft',
|
||||
0x01DA: 'Tile',
|
||||
}
|
||||
|
||||
# Known tracker signatures
|
||||
TRACKER_SIGNATURES = {
|
||||
# Apple AirTag detection patterns
|
||||
'airtag': {
|
||||
'company_id': 0x004C,
|
||||
'data_patterns': [
|
||||
b'\x12\x19', # AirTag/Find My advertisement prefix
|
||||
b'\x07\x19', # Offline Finding
|
||||
],
|
||||
'name_patterns': ['airtag', 'findmy', 'find my'],
|
||||
},
|
||||
# Tile tracker
|
||||
'tile': {
|
||||
'company_id': 0x01DA,
|
||||
'name_patterns': ['tile'],
|
||||
},
|
||||
# Samsung SmartTag
|
||||
'smarttag': {
|
||||
'company_id': 0x0075,
|
||||
'name_patterns': ['smarttag', 'smart tag', 'galaxy smart'],
|
||||
},
|
||||
# ESP32/ESP8266
|
||||
'espressif': {
|
||||
'company_id': 0x02E5,
|
||||
'name_patterns': ['esp32', 'esp8266', 'espressif'],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class BLEDevice:
|
||||
"""Represents a detected BLE device with full advertisement data."""
|
||||
mac: str
|
||||
name: Optional[str] = None
|
||||
rssi: Optional[int] = None
|
||||
manufacturer_id: Optional[int] = None
|
||||
manufacturer_name: Optional[str] = None
|
||||
manufacturer_data: bytes = field(default_factory=bytes)
|
||||
service_uuids: list = field(default_factory=list)
|
||||
tx_power: Optional[int] = None
|
||||
is_connectable: bool = True
|
||||
|
||||
# Detection flags
|
||||
is_airtag: bool = False
|
||||
is_tile: bool = False
|
||||
is_smarttag: bool = False
|
||||
is_espressif: bool = False
|
||||
is_tracker: bool = False
|
||||
tracker_type: Optional[str] = None
|
||||
|
||||
first_seen: datetime = field(default_factory=datetime.now)
|
||||
last_seen: datetime = field(default_factory=datetime.now)
|
||||
detection_count: int = 1
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
'mac': self.mac,
|
||||
'name': self.name or 'Unknown',
|
||||
'rssi': self.rssi,
|
||||
'manufacturer_id': self.manufacturer_id,
|
||||
'manufacturer_name': self.manufacturer_name,
|
||||
'service_uuids': self.service_uuids,
|
||||
'tx_power': self.tx_power,
|
||||
'is_connectable': self.is_connectable,
|
||||
'is_airtag': self.is_airtag,
|
||||
'is_tile': self.is_tile,
|
||||
'is_smarttag': self.is_smarttag,
|
||||
'is_espressif': self.is_espressif,
|
||||
'is_tracker': self.is_tracker,
|
||||
'tracker_type': self.tracker_type,
|
||||
'detection_count': self.detection_count,
|
||||
'type': 'ble',
|
||||
}
|
||||
|
||||
|
||||
class BLEScanner:
|
||||
"""
|
||||
Cross-platform BLE scanner with manufacturer data detection.
|
||||
|
||||
Uses bleak library for proper BLE scanning, with fallback to
|
||||
system tools (hcitool/btmgmt on Linux, system_profiler on macOS).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.devices: dict[str, BLEDevice] = {}
|
||||
self._bleak_available = self._check_bleak()
|
||||
self._scanning = False
|
||||
|
||||
def _check_bleak(self) -> bool:
|
||||
"""Check if bleak library is available."""
|
||||
try:
|
||||
import bleak
|
||||
return True
|
||||
except ImportError:
|
||||
logger.warning("bleak library not available - using fallback scanning")
|
||||
return False
|
||||
|
||||
async def scan_async(self, duration: int = 10) -> list[BLEDevice]:
|
||||
"""
|
||||
Perform async BLE scan using bleak.
|
||||
|
||||
Args:
|
||||
duration: Scan duration in seconds
|
||||
|
||||
Returns:
|
||||
List of detected BLE devices
|
||||
"""
|
||||
if not self._bleak_available:
|
||||
# Use synchronous fallback
|
||||
return self._scan_fallback(duration)
|
||||
|
||||
try:
|
||||
from bleak import BleakScanner
|
||||
from bleak.backends.device import BLEDevice as BleakDevice
|
||||
from bleak.backends.scanner import AdvertisementData
|
||||
|
||||
detected = {}
|
||||
|
||||
def detection_callback(device: BleakDevice, adv_data: AdvertisementData):
|
||||
"""Callback for each detected device."""
|
||||
mac = device.address.upper()
|
||||
|
||||
if mac in detected:
|
||||
# Update existing device
|
||||
detected[mac].rssi = adv_data.rssi
|
||||
detected[mac].last_seen = datetime.now()
|
||||
detected[mac].detection_count += 1
|
||||
else:
|
||||
# Create new device entry
|
||||
ble_device = BLEDevice(
|
||||
mac=mac,
|
||||
name=adv_data.local_name or device.name,
|
||||
rssi=adv_data.rssi,
|
||||
service_uuids=list(adv_data.service_uuids) if adv_data.service_uuids else [],
|
||||
tx_power=adv_data.tx_power,
|
||||
)
|
||||
|
||||
# Parse manufacturer data
|
||||
if adv_data.manufacturer_data:
|
||||
for company_id, data in adv_data.manufacturer_data.items():
|
||||
ble_device.manufacturer_id = company_id
|
||||
ble_device.manufacturer_name = COMPANY_IDS.get(company_id, f'Unknown ({hex(company_id)})')
|
||||
ble_device.manufacturer_data = bytes(data)
|
||||
|
||||
# Check for known trackers
|
||||
self._identify_tracker(ble_device, company_id, data)
|
||||
|
||||
# Also check name patterns
|
||||
self._check_name_patterns(ble_device)
|
||||
|
||||
detected[mac] = ble_device
|
||||
|
||||
logger.info(f"Starting BLE scan with bleak (duration={duration}s)")
|
||||
|
||||
scanner = BleakScanner(detection_callback=detection_callback)
|
||||
await scanner.start()
|
||||
await asyncio.sleep(duration)
|
||||
await scanner.stop()
|
||||
|
||||
# Update internal device list
|
||||
for mac, device in detected.items():
|
||||
if mac in self.devices:
|
||||
self.devices[mac].rssi = device.rssi
|
||||
self.devices[mac].last_seen = device.last_seen
|
||||
self.devices[mac].detection_count += 1
|
||||
else:
|
||||
self.devices[mac] = device
|
||||
|
||||
logger.info(f"BLE scan complete: {len(detected)} devices found")
|
||||
return list(detected.values())
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Bleak scan failed: {e}")
|
||||
return self._scan_fallback(duration)
|
||||
|
||||
def scan(self, duration: int = 10) -> list[BLEDevice]:
|
||||
"""
|
||||
Synchronous wrapper for BLE scanning.
|
||||
|
||||
Args:
|
||||
duration: Scan duration in seconds
|
||||
|
||||
Returns:
|
||||
List of detected BLE devices
|
||||
"""
|
||||
if self._bleak_available:
|
||||
try:
|
||||
# Try to get existing event loop
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
# We're in an async context, can't use run()
|
||||
future = asyncio.ensure_future(self.scan_async(duration))
|
||||
return asyncio.get_event_loop().run_until_complete(future)
|
||||
except RuntimeError:
|
||||
# No running loop, create one
|
||||
return asyncio.run(self.scan_async(duration))
|
||||
except Exception as e:
|
||||
logger.error(f"Async scan failed: {e}")
|
||||
return self._scan_fallback(duration)
|
||||
else:
|
||||
return self._scan_fallback(duration)
|
||||
|
||||
def _identify_tracker(self, device: BLEDevice, company_id: int, data: bytes):
|
||||
"""Identify if device is a known tracker type."""
|
||||
|
||||
# Apple AirTag detection
|
||||
if company_id == 0x004C: # Apple
|
||||
# Check for Find My / AirTag advertisement patterns
|
||||
if len(data) >= 2:
|
||||
# AirTag advertisements have specific byte patterns
|
||||
if data[0] == 0x12 and data[1] == 0x19:
|
||||
device.is_airtag = True
|
||||
device.is_tracker = True
|
||||
device.tracker_type = 'AirTag'
|
||||
logger.info(f"AirTag detected: {device.mac}")
|
||||
elif data[0] == 0x07: # Offline Finding
|
||||
device.is_airtag = True
|
||||
device.is_tracker = True
|
||||
device.tracker_type = 'AirTag (Offline)'
|
||||
logger.info(f"AirTag (offline mode) detected: {device.mac}")
|
||||
|
||||
# Tile tracker
|
||||
elif company_id == 0x01DA: # Tile
|
||||
device.is_tile = True
|
||||
device.is_tracker = True
|
||||
device.tracker_type = 'Tile'
|
||||
logger.info(f"Tile tracker detected: {device.mac}")
|
||||
|
||||
# Samsung SmartTag
|
||||
elif company_id == 0x0075: # Samsung
|
||||
# Check if it's specifically a SmartTag
|
||||
device.is_smarttag = True
|
||||
device.is_tracker = True
|
||||
device.tracker_type = 'SmartTag'
|
||||
logger.info(f"Samsung SmartTag detected: {device.mac}")
|
||||
|
||||
# Espressif (ESP32/ESP8266)
|
||||
elif company_id == 0x02E5: # Espressif
|
||||
device.is_espressif = True
|
||||
device.tracker_type = 'ESP32/ESP8266'
|
||||
logger.info(f"ESP32/ESP8266 device detected: {device.mac}")
|
||||
|
||||
def _check_name_patterns(self, device: BLEDevice):
|
||||
"""Check device name for tracker patterns."""
|
||||
if not device.name:
|
||||
return
|
||||
|
||||
name_lower = device.name.lower()
|
||||
|
||||
# Check each tracker type
|
||||
for tracker_type, sig in TRACKER_SIGNATURES.items():
|
||||
patterns = sig.get('name_patterns', [])
|
||||
for pattern in patterns:
|
||||
if pattern in name_lower:
|
||||
if tracker_type == 'airtag':
|
||||
device.is_airtag = True
|
||||
device.is_tracker = True
|
||||
device.tracker_type = 'AirTag'
|
||||
elif tracker_type == 'tile':
|
||||
device.is_tile = True
|
||||
device.is_tracker = True
|
||||
device.tracker_type = 'Tile'
|
||||
elif tracker_type == 'smarttag':
|
||||
device.is_smarttag = True
|
||||
device.is_tracker = True
|
||||
device.tracker_type = 'SmartTag'
|
||||
elif tracker_type == 'espressif':
|
||||
device.is_espressif = True
|
||||
device.tracker_type = 'ESP32/ESP8266'
|
||||
|
||||
logger.info(f"Tracker identified by name: {device.name} -> {tracker_type}")
|
||||
return
|
||||
|
||||
def _scan_fallback(self, duration: int = 10) -> list[BLEDevice]:
|
||||
"""
|
||||
Fallback scanning using system tools when bleak is unavailable.
|
||||
Works on both macOS and Linux.
|
||||
"""
|
||||
system = platform.system()
|
||||
|
||||
if system == 'Darwin':
|
||||
return self._scan_macos(duration)
|
||||
else:
|
||||
return self._scan_linux(duration)
|
||||
|
||||
def _scan_macos(self, duration: int = 10) -> list[BLEDevice]:
|
||||
"""Fallback BLE scanning on macOS using system_profiler."""
|
||||
devices = []
|
||||
|
||||
try:
|
||||
import json
|
||||
result = subprocess.run(
|
||||
['system_profiler', 'SPBluetoothDataType', '-json'],
|
||||
capture_output=True, text=True, timeout=15
|
||||
)
|
||||
data = json.loads(result.stdout)
|
||||
bt_data = data.get('SPBluetoothDataType', [{}])[0]
|
||||
|
||||
# Get connected/paired devices
|
||||
for section in ['device_connected', 'device_title']:
|
||||
section_data = bt_data.get(section, {})
|
||||
if isinstance(section_data, dict):
|
||||
for name, info in section_data.items():
|
||||
if isinstance(info, dict):
|
||||
mac = info.get('device_address', '').upper()
|
||||
if mac:
|
||||
device = BLEDevice(
|
||||
mac=mac,
|
||||
name=name,
|
||||
)
|
||||
# Check name patterns
|
||||
self._check_name_patterns(device)
|
||||
devices.append(device)
|
||||
|
||||
logger.info(f"macOS fallback scan found {len(devices)} devices")
|
||||
except Exception as e:
|
||||
logger.error(f"macOS fallback scan failed: {e}")
|
||||
|
||||
return devices
|
||||
|
||||
def _scan_linux(self, duration: int = 10) -> list[BLEDevice]:
|
||||
"""Fallback BLE scanning on Linux using bluetoothctl/btmgmt."""
|
||||
import shutil
|
||||
|
||||
devices = []
|
||||
seen_macs = set()
|
||||
|
||||
# Method 1: Try btmgmt for BLE devices
|
||||
if shutil.which('btmgmt'):
|
||||
try:
|
||||
logger.info("Trying btmgmt find...")
|
||||
result = subprocess.run(
|
||||
['btmgmt', 'find'],
|
||||
capture_output=True, text=True, timeout=duration + 5
|
||||
)
|
||||
|
||||
for line in result.stdout.split('\n'):
|
||||
if 'dev_found' in line.lower() or ('type' in line.lower() and ':' in line):
|
||||
mac_match = re.search(
|
||||
r'([0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:'
|
||||
r'[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2})',
|
||||
line
|
||||
)
|
||||
if mac_match:
|
||||
mac = mac_match.group(1).upper()
|
||||
if mac not in seen_macs:
|
||||
seen_macs.add(mac)
|
||||
name_match = re.search(r'name\s+(.+?)(?:\s|$)', line, re.I)
|
||||
name = name_match.group(1) if name_match else None
|
||||
|
||||
device = BLEDevice(mac=mac, name=name)
|
||||
self._check_name_patterns(device)
|
||||
devices.append(device)
|
||||
|
||||
logger.info(f"btmgmt found {len(devices)} devices")
|
||||
except Exception as e:
|
||||
logger.warning(f"btmgmt failed: {e}")
|
||||
|
||||
# Method 2: Try hcitool lescan
|
||||
if not devices and shutil.which('hcitool'):
|
||||
try:
|
||||
logger.info("Trying hcitool lescan...")
|
||||
# Start lescan in background
|
||||
process = subprocess.Popen(
|
||||
['hcitool', 'lescan', '--duplicates'],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True
|
||||
)
|
||||
|
||||
import time
|
||||
time.sleep(duration)
|
||||
process.terminate()
|
||||
|
||||
stdout, _ = process.communicate(timeout=2)
|
||||
|
||||
for line in stdout.split('\n'):
|
||||
mac_match = re.search(
|
||||
r'([0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:'
|
||||
r'[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2})',
|
||||
line
|
||||
)
|
||||
if mac_match:
|
||||
mac = mac_match.group(1).upper()
|
||||
if mac not in seen_macs:
|
||||
seen_macs.add(mac)
|
||||
# Extract name (comes after MAC)
|
||||
parts = line.strip().split()
|
||||
name = ' '.join(parts[1:]) if len(parts) > 1 else None
|
||||
|
||||
device = BLEDevice(mac=mac, name=name if name != '(unknown)' else None)
|
||||
self._check_name_patterns(device)
|
||||
devices.append(device)
|
||||
|
||||
logger.info(f"hcitool lescan found {len(devices)} devices")
|
||||
except Exception as e:
|
||||
logger.warning(f"hcitool lescan failed: {e}")
|
||||
|
||||
return devices
|
||||
|
||||
def get_trackers(self) -> list[BLEDevice]:
|
||||
"""Get all detected tracker devices."""
|
||||
return [d for d in self.devices.values() if d.is_tracker]
|
||||
|
||||
def get_espressif_devices(self) -> list[BLEDevice]:
|
||||
"""Get all detected ESP32/ESP8266 devices."""
|
||||
return [d for d in self.devices.values() if d.is_espressif]
|
||||
|
||||
def clear(self):
|
||||
"""Clear all detected devices."""
|
||||
self.devices.clear()
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_scanner: Optional[BLEScanner] = None
|
||||
|
||||
|
||||
def get_ble_scanner() -> BLEScanner:
|
||||
"""Get the global BLE scanner instance."""
|
||||
global _scanner
|
||||
if _scanner is None:
|
||||
_scanner = BLEScanner()
|
||||
return _scanner
|
||||
|
||||
|
||||
def scan_ble_devices(duration: int = 10) -> list[dict]:
|
||||
"""
|
||||
Convenience function to scan for BLE devices.
|
||||
|
||||
Args:
|
||||
duration: Scan duration in seconds
|
||||
|
||||
Returns:
|
||||
List of device dictionaries
|
||||
"""
|
||||
scanner = get_ble_scanner()
|
||||
devices = scanner.scan(duration)
|
||||
return [d.to_dict() for d in devices]
|
||||
@@ -0,0 +1,959 @@
|
||||
"""
|
||||
TSCM Cross-Protocol Correlation Engine
|
||||
|
||||
Correlates Bluetooth, Wi-Fi, and RF indicators to detect potential surveillance activity.
|
||||
Implements scoring model for risk assessment and provides actionable intelligence.
|
||||
|
||||
DISCLAIMER: This system performs wireless and RF surveillance screening.
|
||||
Findings indicate anomalies and indicators, not confirmed surveillance devices.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger('intercept.tscm.correlation')
|
||||
|
||||
|
||||
class RiskLevel(Enum):
|
||||
"""Risk classification levels."""
|
||||
INFORMATIONAL = 'informational' # Score 0-2
|
||||
NEEDS_REVIEW = 'review' # Score 3-5
|
||||
HIGH_INTEREST = 'high_interest' # Score 6+
|
||||
|
||||
|
||||
class IndicatorType(Enum):
|
||||
"""Types of risk indicators."""
|
||||
UNKNOWN_DEVICE = 'unknown_device'
|
||||
AUDIO_CAPABLE = 'audio_capable'
|
||||
PERSISTENT = 'persistent'
|
||||
MEETING_CORRELATED = 'meeting_correlated'
|
||||
CROSS_PROTOCOL = 'cross_protocol'
|
||||
HIDDEN_IDENTITY = 'hidden_identity'
|
||||
ROGUE_AP = 'rogue_ap'
|
||||
BURST_TRANSMISSION = 'burst_transmission'
|
||||
STABLE_RSSI = 'stable_rssi'
|
||||
HIGH_FREQ_ADVERTISING = 'high_freq_advertising'
|
||||
MAC_ROTATION = 'mac_rotation'
|
||||
NARROWBAND_SIGNAL = 'narrowband_signal'
|
||||
ALWAYS_ON_CARRIER = 'always_on_carrier'
|
||||
# Tracker-specific indicators
|
||||
KNOWN_TRACKER = 'known_tracker'
|
||||
AIRTAG_DETECTED = 'airtag_detected'
|
||||
TILE_DETECTED = 'tile_detected'
|
||||
SMARTTAG_DETECTED = 'smarttag_detected'
|
||||
ESP32_DEVICE = 'esp32_device'
|
||||
GENERIC_CHIPSET = 'generic_chipset'
|
||||
|
||||
|
||||
# Scoring weights for each indicator
|
||||
INDICATOR_SCORES = {
|
||||
IndicatorType.UNKNOWN_DEVICE: 1,
|
||||
IndicatorType.AUDIO_CAPABLE: 2,
|
||||
IndicatorType.PERSISTENT: 2,
|
||||
IndicatorType.MEETING_CORRELATED: 2,
|
||||
IndicatorType.CROSS_PROTOCOL: 3,
|
||||
IndicatorType.HIDDEN_IDENTITY: 2,
|
||||
IndicatorType.ROGUE_AP: 3,
|
||||
IndicatorType.BURST_TRANSMISSION: 2,
|
||||
IndicatorType.STABLE_RSSI: 1,
|
||||
IndicatorType.HIGH_FREQ_ADVERTISING: 1,
|
||||
IndicatorType.MAC_ROTATION: 1,
|
||||
IndicatorType.NARROWBAND_SIGNAL: 2,
|
||||
IndicatorType.ALWAYS_ON_CARRIER: 2,
|
||||
# Tracker scores - higher for covert tracking devices
|
||||
IndicatorType.KNOWN_TRACKER: 3,
|
||||
IndicatorType.AIRTAG_DETECTED: 3,
|
||||
IndicatorType.TILE_DETECTED: 2,
|
||||
IndicatorType.SMARTTAG_DETECTED: 2,
|
||||
IndicatorType.ESP32_DEVICE: 2,
|
||||
IndicatorType.GENERIC_CHIPSET: 1,
|
||||
}
|
||||
|
||||
|
||||
# Known tracker device signatures
|
||||
TRACKER_SIGNATURES = {
|
||||
# Apple AirTag - OUI prefixes
|
||||
'airtag_oui': ['4C:E6:76', '7C:04:D0', 'DC:A4:CA', 'F0:B3:EC'],
|
||||
# Tile trackers
|
||||
'tile_oui': ['D0:03:DF', 'EC:2E:4E'],
|
||||
# Samsung SmartTag
|
||||
'smarttag_oui': ['8C:71:F8', 'CC:2D:83', 'F0:5C:D5'],
|
||||
# ESP32/ESP8266 Espressif chipsets
|
||||
'espressif_oui': ['24:0A:C4', '24:6F:28', '24:62:AB', '30:AE:A4',
|
||||
'3C:61:05', '3C:71:BF', '40:F5:20', '48:3F:DA',
|
||||
'4C:11:AE', '54:43:B2', '58:BF:25', '5C:CF:7F',
|
||||
'60:01:94', '68:C6:3A', '7C:9E:BD', '84:0D:8E',
|
||||
'84:CC:A8', '84:F3:EB', '8C:AA:B5', '90:38:0C',
|
||||
'94:B5:55', '98:CD:AC', 'A4:7B:9D', 'A4:CF:12',
|
||||
'AC:67:B2', 'B4:E6:2D', 'BC:DD:C2', 'C4:4F:33',
|
||||
'C8:2B:96', 'CC:50:E3', 'D8:A0:1D', 'DC:4F:22',
|
||||
'E0:98:06', 'E8:68:E7', 'EC:FA:BC', 'F4:CF:A2'],
|
||||
# Generic/suspicious chipset vendors (potential covert devices)
|
||||
'generic_chipset_oui': [
|
||||
'00:1A:7D', # cyber-blue(HK)
|
||||
'00:25:00', # Apple (but generic BLE)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Indicator:
|
||||
"""A single risk indicator."""
|
||||
type: IndicatorType
|
||||
description: str
|
||||
score: int
|
||||
details: dict = field(default_factory=dict)
|
||||
timestamp: datetime = field(default_factory=datetime.now)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceProfile:
|
||||
"""Complete profile for a detected device."""
|
||||
# Identity
|
||||
identifier: str # MAC, BSSID, or frequency
|
||||
protocol: str # 'bluetooth', 'wifi', 'rf'
|
||||
|
||||
# Device info
|
||||
name: Optional[str] = None
|
||||
manufacturer: Optional[str] = None
|
||||
device_type: Optional[str] = None
|
||||
|
||||
# Bluetooth-specific
|
||||
services: list[str] = field(default_factory=list)
|
||||
company_id: Optional[int] = None
|
||||
advertising_interval: Optional[int] = None
|
||||
|
||||
# Wi-Fi-specific
|
||||
ssid: Optional[str] = None
|
||||
channel: Optional[int] = None
|
||||
encryption: Optional[str] = None
|
||||
beacon_interval: Optional[int] = None
|
||||
is_hidden: bool = False
|
||||
|
||||
# RF-specific
|
||||
frequency: Optional[float] = None
|
||||
bandwidth: Optional[float] = None
|
||||
modulation: Optional[str] = None
|
||||
|
||||
# Common measurements
|
||||
rssi_samples: list[tuple[datetime, int]] = field(default_factory=list)
|
||||
first_seen: Optional[datetime] = None
|
||||
last_seen: Optional[datetime] = None
|
||||
detection_count: int = 0
|
||||
|
||||
# Behavioral analysis
|
||||
indicators: list[Indicator] = field(default_factory=list)
|
||||
total_score: int = 0
|
||||
risk_level: RiskLevel = RiskLevel.INFORMATIONAL
|
||||
|
||||
# Correlation
|
||||
correlated_devices: list[str] = field(default_factory=list)
|
||||
|
||||
# Output
|
||||
confidence: float = 0.0
|
||||
recommended_action: str = 'monitor'
|
||||
|
||||
def add_rssi_sample(self, rssi: int) -> None:
|
||||
"""Add an RSSI sample with timestamp."""
|
||||
self.rssi_samples.append((datetime.now(), rssi))
|
||||
# Keep last 100 samples
|
||||
if len(self.rssi_samples) > 100:
|
||||
self.rssi_samples = self.rssi_samples[-100:]
|
||||
|
||||
def get_rssi_stability(self) -> float:
|
||||
"""Calculate RSSI stability (0-1, higher = more stable)."""
|
||||
if len(self.rssi_samples) < 3:
|
||||
return 0.0
|
||||
values = [r for _, r in self.rssi_samples[-20:]]
|
||||
if not values:
|
||||
return 0.0
|
||||
avg = sum(values) / len(values)
|
||||
variance = sum((v - avg) ** 2 for v in values) / len(values)
|
||||
# Convert variance to stability score (lower variance = higher stability)
|
||||
# Variance of ~0 = 1.0, variance of 100+ = ~0
|
||||
return max(0, 1 - (variance / 100))
|
||||
|
||||
def add_indicator(self, indicator_type: IndicatorType, description: str,
|
||||
details: dict = None) -> None:
|
||||
"""Add a risk indicator and update score."""
|
||||
score = INDICATOR_SCORES.get(indicator_type, 1)
|
||||
self.indicators.append(Indicator(
|
||||
type=indicator_type,
|
||||
description=description,
|
||||
score=score,
|
||||
details=details or {}
|
||||
))
|
||||
self._recalculate_score()
|
||||
|
||||
def _recalculate_score(self) -> None:
|
||||
"""Recalculate total score and risk level."""
|
||||
self.total_score = sum(i.score for i in self.indicators)
|
||||
|
||||
if self.total_score >= 6:
|
||||
self.risk_level = RiskLevel.HIGH_INTEREST
|
||||
self.recommended_action = 'investigate'
|
||||
elif self.total_score >= 3:
|
||||
self.risk_level = RiskLevel.NEEDS_REVIEW
|
||||
self.recommended_action = 'review'
|
||||
else:
|
||||
self.risk_level = RiskLevel.INFORMATIONAL
|
||||
self.recommended_action = 'monitor'
|
||||
|
||||
# Calculate confidence based on number and quality of indicators
|
||||
indicator_count = len(self.indicators)
|
||||
self.confidence = min(1.0, (indicator_count * 0.15) + (self.total_score * 0.05))
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
'identifier': self.identifier,
|
||||
'protocol': self.protocol,
|
||||
'name': self.name,
|
||||
'manufacturer': self.manufacturer,
|
||||
'device_type': self.device_type,
|
||||
'ssid': self.ssid,
|
||||
'frequency': self.frequency,
|
||||
'first_seen': self.first_seen.isoformat() if self.first_seen else None,
|
||||
'last_seen': self.last_seen.isoformat() if self.last_seen else None,
|
||||
'detection_count': self.detection_count,
|
||||
'rssi_current': self.rssi_samples[-1][1] if self.rssi_samples else None,
|
||||
'rssi_stability': self.get_rssi_stability(),
|
||||
'indicators': [
|
||||
{
|
||||
'type': i.type.value,
|
||||
'description': i.description,
|
||||
'score': i.score,
|
||||
}
|
||||
for i in self.indicators
|
||||
],
|
||||
'total_score': self.total_score,
|
||||
'risk_level': self.risk_level.value,
|
||||
'confidence': round(self.confidence, 2),
|
||||
'recommended_action': self.recommended_action,
|
||||
'correlated_devices': self.correlated_devices,
|
||||
}
|
||||
|
||||
|
||||
# Known audio-capable BLE service UUIDs
|
||||
AUDIO_SERVICE_UUIDS = [
|
||||
'0000110b-0000-1000-8000-00805f9b34fb', # A2DP Sink
|
||||
'0000110a-0000-1000-8000-00805f9b34fb', # A2DP Source
|
||||
'0000111e-0000-1000-8000-00805f9b34fb', # Handsfree
|
||||
'0000111f-0000-1000-8000-00805f9b34fb', # Handsfree Audio Gateway
|
||||
'00001108-0000-1000-8000-00805f9b34fb', # Headset
|
||||
'00001203-0000-1000-8000-00805f9b34fb', # Generic Audio
|
||||
]
|
||||
|
||||
# Generic chipset vendors (often used in covert devices)
|
||||
GENERIC_CHIPSET_VENDORS = [
|
||||
'espressif',
|
||||
'nordic',
|
||||
'texas instruments',
|
||||
'silicon labs',
|
||||
'realtek',
|
||||
'mediatek',
|
||||
'qualcomm',
|
||||
'broadcom',
|
||||
'cypress',
|
||||
'dialog',
|
||||
]
|
||||
|
||||
# Suspicious frequency ranges for RF
|
||||
SUSPICIOUS_RF_BANDS = [
|
||||
{'start': 136, 'end': 174, 'name': 'VHF', 'risk': 'high'},
|
||||
{'start': 400, 'end': 470, 'name': 'UHF', 'risk': 'high'},
|
||||
{'start': 315, 'end': 316, 'name': '315 MHz ISM', 'risk': 'medium'},
|
||||
{'start': 433, 'end': 435, 'name': '433 MHz ISM', 'risk': 'medium'},
|
||||
{'start': 868, 'end': 870, 'name': '868 MHz ISM', 'risk': 'medium'},
|
||||
{'start': 902, 'end': 928, 'name': '915 MHz ISM', 'risk': 'medium'},
|
||||
]
|
||||
|
||||
|
||||
class CorrelationEngine:
|
||||
"""
|
||||
Cross-protocol correlation engine for TSCM analysis.
|
||||
|
||||
Correlates Bluetooth, Wi-Fi, and RF indicators to identify
|
||||
potential surveillance activity patterns.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.device_profiles: dict[str, DeviceProfile] = {}
|
||||
self.meeting_windows: list[tuple[datetime, datetime]] = []
|
||||
self.correlation_window = timedelta(minutes=5)
|
||||
|
||||
def start_meeting_window(self) -> None:
|
||||
"""Mark the start of a sensitive period (meeting)."""
|
||||
self.meeting_windows.append((datetime.now(), None))
|
||||
logger.info("Meeting window started")
|
||||
|
||||
def end_meeting_window(self) -> None:
|
||||
"""Mark the end of a sensitive period."""
|
||||
if self.meeting_windows and self.meeting_windows[-1][1] is None:
|
||||
start = self.meeting_windows[-1][0]
|
||||
self.meeting_windows[-1] = (start, datetime.now())
|
||||
logger.info("Meeting window ended")
|
||||
|
||||
def is_during_meeting(self, timestamp: datetime = None) -> bool:
|
||||
"""Check if timestamp falls within a meeting window."""
|
||||
ts = timestamp or datetime.now()
|
||||
for start, end in self.meeting_windows:
|
||||
if end is None:
|
||||
if ts >= start:
|
||||
return True
|
||||
elif start <= ts <= end:
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_or_create_profile(self, identifier: str, protocol: str) -> DeviceProfile:
|
||||
"""Get existing profile or create new one."""
|
||||
key = f"{protocol}:{identifier}"
|
||||
if key not in self.device_profiles:
|
||||
self.device_profiles[key] = DeviceProfile(
|
||||
identifier=identifier,
|
||||
protocol=protocol,
|
||||
first_seen=datetime.now()
|
||||
)
|
||||
profile = self.device_profiles[key]
|
||||
profile.last_seen = datetime.now()
|
||||
profile.detection_count += 1
|
||||
return profile
|
||||
|
||||
def analyze_bluetooth_device(self, device: dict) -> DeviceProfile:
|
||||
"""
|
||||
Analyze a Bluetooth device for suspicious indicators.
|
||||
|
||||
Args:
|
||||
device: Dict with mac, name, rssi, services, manufacturer, etc.
|
||||
|
||||
Returns:
|
||||
DeviceProfile with risk assessment
|
||||
"""
|
||||
mac = device.get('mac', device.get('address', '')).upper()
|
||||
profile = self.get_or_create_profile(mac, 'bluetooth')
|
||||
|
||||
# Update profile data
|
||||
profile.name = device.get('name') or profile.name
|
||||
profile.manufacturer = device.get('manufacturer') or profile.manufacturer
|
||||
profile.device_type = device.get('type') or profile.device_type
|
||||
profile.services = device.get('services', []) or profile.services
|
||||
profile.company_id = device.get('company_id') or profile.company_id
|
||||
profile.advertising_interval = device.get('advertising_interval') or profile.advertising_interval
|
||||
|
||||
# Add RSSI sample
|
||||
rssi = device.get('rssi', device.get('signal'))
|
||||
if rssi:
|
||||
try:
|
||||
profile.add_rssi_sample(int(rssi))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Clear previous indicators for fresh analysis
|
||||
profile.indicators = []
|
||||
|
||||
# === Detection Logic ===
|
||||
|
||||
# 1. Unknown manufacturer or generic chipset
|
||||
if not profile.manufacturer:
|
||||
profile.add_indicator(
|
||||
IndicatorType.UNKNOWN_DEVICE,
|
||||
'Unknown manufacturer',
|
||||
{'manufacturer': None}
|
||||
)
|
||||
elif any(v in profile.manufacturer.lower() for v in GENERIC_CHIPSET_VENDORS):
|
||||
profile.add_indicator(
|
||||
IndicatorType.UNKNOWN_DEVICE,
|
||||
f'Generic chipset vendor: {profile.manufacturer}',
|
||||
{'manufacturer': profile.manufacturer}
|
||||
)
|
||||
|
||||
# 2. No human-readable name
|
||||
if not profile.name or profile.name in ['Unknown', '', 'N/A']:
|
||||
profile.add_indicator(
|
||||
IndicatorType.HIDDEN_IDENTITY,
|
||||
'No device name advertised',
|
||||
{'name': profile.name}
|
||||
)
|
||||
|
||||
# 3. Audio-capable services
|
||||
if profile.services:
|
||||
audio_services = [s for s in profile.services
|
||||
if s.lower() in [u.lower() for u in AUDIO_SERVICE_UUIDS]]
|
||||
if audio_services:
|
||||
profile.add_indicator(
|
||||
IndicatorType.AUDIO_CAPABLE,
|
||||
'Audio-capable BLE services detected',
|
||||
{'services': audio_services}
|
||||
)
|
||||
|
||||
# Check name for audio keywords
|
||||
if profile.name:
|
||||
audio_keywords = ['headphone', 'headset', 'earphone', 'speaker',
|
||||
'mic', 'audio', 'airpod', 'buds', 'jabra', 'bose']
|
||||
if any(k in profile.name.lower() for k in audio_keywords):
|
||||
profile.add_indicator(
|
||||
IndicatorType.AUDIO_CAPABLE,
|
||||
f'Audio device name: {profile.name}',
|
||||
{'name': profile.name}
|
||||
)
|
||||
|
||||
# 4. High-frequency advertising (< 100ms interval is suspicious)
|
||||
if profile.advertising_interval and profile.advertising_interval < 100:
|
||||
profile.add_indicator(
|
||||
IndicatorType.HIGH_FREQ_ADVERTISING,
|
||||
f'High advertising frequency: {profile.advertising_interval}ms',
|
||||
{'interval': profile.advertising_interval}
|
||||
)
|
||||
|
||||
# 5. Persistent presence
|
||||
if profile.detection_count >= 3:
|
||||
profile.add_indicator(
|
||||
IndicatorType.PERSISTENT,
|
||||
f'Persistent device ({profile.detection_count} detections)',
|
||||
{'count': profile.detection_count}
|
||||
)
|
||||
|
||||
# 6. Stable RSSI (suggests fixed placement)
|
||||
rssi_stability = profile.get_rssi_stability()
|
||||
if rssi_stability > 0.7 and len(profile.rssi_samples) >= 5:
|
||||
profile.add_indicator(
|
||||
IndicatorType.STABLE_RSSI,
|
||||
f'Stable signal strength (stability: {rssi_stability:.0%})',
|
||||
{'stability': rssi_stability}
|
||||
)
|
||||
|
||||
# 7. Meeting correlation
|
||||
if self.is_during_meeting():
|
||||
profile.add_indicator(
|
||||
IndicatorType.MEETING_CORRELATED,
|
||||
'Detected during sensitive period',
|
||||
{'during_meeting': True}
|
||||
)
|
||||
|
||||
# 8. MAC rotation pattern (random MAC prefix)
|
||||
if mac and mac[1] in ['2', '6', 'A', 'E', 'a', 'e']:
|
||||
profile.add_indicator(
|
||||
IndicatorType.MAC_ROTATION,
|
||||
'Random/rotating MAC address detected',
|
||||
{'mac': mac}
|
||||
)
|
||||
|
||||
# 9. Known tracker detection (AirTag, Tile, SmartTag, ESP32)
|
||||
mac_prefix = mac[:8] if len(mac) >= 8 else ''
|
||||
tracker_detected = False
|
||||
|
||||
# Check for tracker flags from BLE scanner (manufacturer ID detection)
|
||||
if device.get('is_airtag'):
|
||||
profile.add_indicator(
|
||||
IndicatorType.AIRTAG_DETECTED,
|
||||
'Apple AirTag detected via manufacturer data',
|
||||
{'mac': mac, 'tracker_type': 'AirTag'}
|
||||
)
|
||||
profile.device_type = device.get('tracker_type', 'AirTag')
|
||||
tracker_detected = True
|
||||
|
||||
if device.get('is_tile'):
|
||||
profile.add_indicator(
|
||||
IndicatorType.TILE_DETECTED,
|
||||
'Tile tracker detected via manufacturer data',
|
||||
{'mac': mac, 'tracker_type': 'Tile'}
|
||||
)
|
||||
profile.device_type = 'Tile Tracker'
|
||||
tracker_detected = True
|
||||
|
||||
if device.get('is_smarttag'):
|
||||
profile.add_indicator(
|
||||
IndicatorType.SMARTTAG_DETECTED,
|
||||
'Samsung SmartTag detected via manufacturer data',
|
||||
{'mac': mac, 'tracker_type': 'SmartTag'}
|
||||
)
|
||||
profile.device_type = 'Samsung SmartTag'
|
||||
tracker_detected = True
|
||||
|
||||
if device.get('is_espressif'):
|
||||
profile.add_indicator(
|
||||
IndicatorType.ESP32_DEVICE,
|
||||
'ESP32/ESP8266 detected via Espressif manufacturer ID',
|
||||
{'mac': mac, 'chipset': 'Espressif'}
|
||||
)
|
||||
profile.manufacturer = 'Espressif'
|
||||
profile.device_type = device.get('tracker_type', 'ESP32/ESP8266')
|
||||
tracker_detected = True
|
||||
|
||||
# Check manufacturer_id directly
|
||||
mfg_id = device.get('manufacturer_id')
|
||||
if mfg_id:
|
||||
if mfg_id == 0x004C and not device.get('is_airtag'):
|
||||
# Apple device - could be AirTag
|
||||
profile.manufacturer = 'Apple'
|
||||
elif mfg_id == 0x02E5 and not device.get('is_espressif'):
|
||||
# Espressif device
|
||||
profile.add_indicator(
|
||||
IndicatorType.ESP32_DEVICE,
|
||||
'ESP32/ESP8266 detected via manufacturer ID',
|
||||
{'mac': mac, 'manufacturer_id': mfg_id}
|
||||
)
|
||||
profile.manufacturer = 'Espressif'
|
||||
tracker_detected = True
|
||||
|
||||
# Fallback: Check for Apple AirTag by OUI
|
||||
if not tracker_detected and mac_prefix in TRACKER_SIGNATURES.get('airtag_oui', []):
|
||||
profile.add_indicator(
|
||||
IndicatorType.AIRTAG_DETECTED,
|
||||
'Apple AirTag detected - potential tracking device',
|
||||
{'mac': mac, 'tracker_type': 'AirTag'}
|
||||
)
|
||||
profile.device_type = 'AirTag'
|
||||
tracker_detected = True
|
||||
|
||||
# Check for Tile tracker
|
||||
if mac_prefix in TRACKER_SIGNATURES.get('tile_oui', []):
|
||||
profile.add_indicator(
|
||||
IndicatorType.TILE_DETECTED,
|
||||
'Tile tracker detected',
|
||||
{'mac': mac, 'tracker_type': 'Tile'}
|
||||
)
|
||||
profile.device_type = 'Tile Tracker'
|
||||
tracker_detected = True
|
||||
|
||||
# Check for Samsung SmartTag
|
||||
if mac_prefix in TRACKER_SIGNATURES.get('smarttag_oui', []):
|
||||
profile.add_indicator(
|
||||
IndicatorType.SMARTTAG_DETECTED,
|
||||
'Samsung SmartTag detected',
|
||||
{'mac': mac, 'tracker_type': 'SmartTag'}
|
||||
)
|
||||
profile.device_type = 'Samsung SmartTag'
|
||||
tracker_detected = True
|
||||
|
||||
# Check for ESP32/ESP8266 devices
|
||||
if mac_prefix in TRACKER_SIGNATURES.get('espressif_oui', []):
|
||||
profile.add_indicator(
|
||||
IndicatorType.ESP32_DEVICE,
|
||||
'ESP32/ESP8266 device detected - programmable hardware',
|
||||
{'mac': mac, 'chipset': 'Espressif'}
|
||||
)
|
||||
profile.manufacturer = 'Espressif'
|
||||
tracker_detected = True
|
||||
|
||||
# Check for generic/suspicious chipsets
|
||||
if mac_prefix in TRACKER_SIGNATURES.get('generic_chipset_oui', []):
|
||||
profile.add_indicator(
|
||||
IndicatorType.GENERIC_CHIPSET,
|
||||
'Generic chipset vendor - often used in covert devices',
|
||||
{'mac': mac}
|
||||
)
|
||||
tracker_detected = True
|
||||
|
||||
# If any tracker detected, add general tracker indicator
|
||||
if tracker_detected:
|
||||
profile.add_indicator(
|
||||
IndicatorType.KNOWN_TRACKER,
|
||||
'Known tracking device signature detected',
|
||||
{'mac': mac}
|
||||
)
|
||||
|
||||
# Also check name for tracker keywords
|
||||
if profile.name:
|
||||
name_lower = profile.name.lower()
|
||||
if 'airtag' in name_lower or 'findmy' in name_lower:
|
||||
profile.add_indicator(
|
||||
IndicatorType.AIRTAG_DETECTED,
|
||||
f'AirTag identified by name: {profile.name}',
|
||||
{'name': profile.name}
|
||||
)
|
||||
profile.device_type = 'AirTag'
|
||||
elif 'tile' in name_lower:
|
||||
profile.add_indicator(
|
||||
IndicatorType.TILE_DETECTED,
|
||||
f'Tile tracker identified by name: {profile.name}',
|
||||
{'name': profile.name}
|
||||
)
|
||||
profile.device_type = 'Tile Tracker'
|
||||
elif 'smarttag' in name_lower:
|
||||
profile.add_indicator(
|
||||
IndicatorType.SMARTTAG_DETECTED,
|
||||
f'SmartTag identified by name: {profile.name}',
|
||||
{'name': profile.name}
|
||||
)
|
||||
profile.device_type = 'Samsung SmartTag'
|
||||
|
||||
return profile
|
||||
|
||||
def analyze_wifi_device(self, device: dict) -> DeviceProfile:
|
||||
"""
|
||||
Analyze a Wi-Fi device/AP for suspicious indicators.
|
||||
|
||||
Args:
|
||||
device: Dict with bssid, ssid, channel, rssi, encryption, etc.
|
||||
|
||||
Returns:
|
||||
DeviceProfile with risk assessment
|
||||
"""
|
||||
bssid = device.get('bssid', device.get('mac', '')).upper()
|
||||
profile = self.get_or_create_profile(bssid, 'wifi')
|
||||
|
||||
# Update profile data
|
||||
ssid = device.get('ssid', device.get('essid', ''))
|
||||
profile.ssid = ssid if ssid else profile.ssid
|
||||
profile.name = ssid or f'Hidden Network ({bssid[-8:]})'
|
||||
profile.channel = device.get('channel') or profile.channel
|
||||
profile.encryption = device.get('encryption', device.get('privacy')) or profile.encryption
|
||||
profile.beacon_interval = device.get('beacon_interval') or profile.beacon_interval
|
||||
profile.is_hidden = not ssid or ssid in ['', 'Hidden', '[Hidden]']
|
||||
|
||||
# Extract manufacturer from OUI
|
||||
if bssid and len(bssid) >= 8:
|
||||
profile.manufacturer = device.get('vendor') or profile.manufacturer
|
||||
|
||||
# Add RSSI sample
|
||||
rssi = device.get('rssi', device.get('power', device.get('signal')))
|
||||
if rssi:
|
||||
try:
|
||||
profile.add_rssi_sample(int(rssi))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Clear previous indicators
|
||||
profile.indicators = []
|
||||
|
||||
# === Detection Logic ===
|
||||
|
||||
# 1. Hidden or unnamed SSID
|
||||
if profile.is_hidden:
|
||||
profile.add_indicator(
|
||||
IndicatorType.HIDDEN_IDENTITY,
|
||||
'Hidden or empty SSID',
|
||||
{'ssid': ssid}
|
||||
)
|
||||
|
||||
# 2. BSSID not in authorized list (would need baseline)
|
||||
# For now, mark as unknown if no manufacturer
|
||||
if not profile.manufacturer:
|
||||
profile.add_indicator(
|
||||
IndicatorType.UNKNOWN_DEVICE,
|
||||
'Unknown AP manufacturer',
|
||||
{'bssid': bssid}
|
||||
)
|
||||
|
||||
# 3. Consumer device OUI in restricted environment
|
||||
consumer_ouis = ['tp-link', 'netgear', 'd-link', 'linksys', 'asus']
|
||||
if profile.manufacturer and any(c in profile.manufacturer.lower() for c in consumer_ouis):
|
||||
profile.add_indicator(
|
||||
IndicatorType.ROGUE_AP,
|
||||
f'Consumer-grade AP detected: {profile.manufacturer}',
|
||||
{'manufacturer': profile.manufacturer}
|
||||
)
|
||||
|
||||
# 4. Camera device patterns
|
||||
camera_keywords = ['cam', 'camera', 'ipcam', 'dvr', 'nvr', 'wyze',
|
||||
'ring', 'arlo', 'nest', 'blink', 'eufy', 'yi']
|
||||
if ssid and any(k in ssid.lower() for k in camera_keywords):
|
||||
profile.add_indicator(
|
||||
IndicatorType.AUDIO_CAPABLE, # Cameras often have mics
|
||||
f'Potential camera device: {ssid}',
|
||||
{'ssid': ssid}
|
||||
)
|
||||
|
||||
# 5. Persistent presence
|
||||
if profile.detection_count >= 3:
|
||||
profile.add_indicator(
|
||||
IndicatorType.PERSISTENT,
|
||||
f'Persistent AP ({profile.detection_count} detections)',
|
||||
{'count': profile.detection_count}
|
||||
)
|
||||
|
||||
# 6. Stable RSSI (fixed placement)
|
||||
rssi_stability = profile.get_rssi_stability()
|
||||
if rssi_stability > 0.7 and len(profile.rssi_samples) >= 5:
|
||||
profile.add_indicator(
|
||||
IndicatorType.STABLE_RSSI,
|
||||
f'Stable signal (stability: {rssi_stability:.0%})',
|
||||
{'stability': rssi_stability}
|
||||
)
|
||||
|
||||
# 7. Meeting correlation
|
||||
if self.is_during_meeting():
|
||||
profile.add_indicator(
|
||||
IndicatorType.MEETING_CORRELATED,
|
||||
'Detected during sensitive period',
|
||||
{'during_meeting': True}
|
||||
)
|
||||
|
||||
# 8. Strong hidden AP (very suspicious)
|
||||
if profile.is_hidden and profile.rssi_samples:
|
||||
latest_rssi = profile.rssi_samples[-1][1]
|
||||
if latest_rssi > -50:
|
||||
profile.add_indicator(
|
||||
IndicatorType.ROGUE_AP,
|
||||
f'Strong hidden AP (RSSI: {latest_rssi} dBm)',
|
||||
{'rssi': latest_rssi}
|
||||
)
|
||||
|
||||
return profile
|
||||
|
||||
def analyze_rf_signal(self, signal: dict) -> DeviceProfile:
|
||||
"""
|
||||
Analyze an RF signal for suspicious indicators.
|
||||
|
||||
Args:
|
||||
signal: Dict with frequency, power, bandwidth, modulation, etc.
|
||||
|
||||
Returns:
|
||||
DeviceProfile with risk assessment
|
||||
"""
|
||||
frequency = signal.get('frequency', 0)
|
||||
freq_key = f"{frequency:.3f}"
|
||||
profile = self.get_or_create_profile(freq_key, 'rf')
|
||||
|
||||
# Update profile data
|
||||
profile.frequency = frequency
|
||||
profile.name = f'{frequency:.3f} MHz'
|
||||
profile.bandwidth = signal.get('bandwidth') or profile.bandwidth
|
||||
profile.modulation = signal.get('modulation') or profile.modulation
|
||||
|
||||
# Add power sample
|
||||
power = signal.get('power', signal.get('level'))
|
||||
if power:
|
||||
try:
|
||||
profile.add_rssi_sample(int(float(power)))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Clear previous indicators
|
||||
profile.indicators = []
|
||||
|
||||
# === Detection Logic ===
|
||||
|
||||
# 1. Determine frequency band risk
|
||||
band_info = None
|
||||
for band in SUSPICIOUS_RF_BANDS:
|
||||
if band['start'] <= frequency <= band['end']:
|
||||
band_info = band
|
||||
break
|
||||
|
||||
if band_info:
|
||||
if band_info['risk'] == 'high':
|
||||
profile.add_indicator(
|
||||
IndicatorType.NARROWBAND_SIGNAL,
|
||||
f"Signal in high-risk band: {band_info['name']}",
|
||||
{'band': band_info['name'], 'frequency': frequency}
|
||||
)
|
||||
else:
|
||||
profile.add_indicator(
|
||||
IndicatorType.UNKNOWN_DEVICE,
|
||||
f"Signal in ISM band: {band_info['name']}",
|
||||
{'band': band_info['name'], 'frequency': frequency}
|
||||
)
|
||||
|
||||
# 2. Narrowband FM/AM (potential bug)
|
||||
if profile.modulation and profile.modulation.lower() in ['fm', 'nfm', 'am']:
|
||||
profile.add_indicator(
|
||||
IndicatorType.NARROWBAND_SIGNAL,
|
||||
f'Narrowband {profile.modulation.upper()} signal',
|
||||
{'modulation': profile.modulation}
|
||||
)
|
||||
|
||||
# 3. Persistent/always-on carrier
|
||||
if profile.detection_count >= 2:
|
||||
profile.add_indicator(
|
||||
IndicatorType.ALWAYS_ON_CARRIER,
|
||||
f'Persistent carrier ({profile.detection_count} detections)',
|
||||
{'count': profile.detection_count}
|
||||
)
|
||||
|
||||
# 4. Strong signal (close proximity)
|
||||
if profile.rssi_samples:
|
||||
latest_power = profile.rssi_samples[-1][1]
|
||||
if latest_power > -40:
|
||||
profile.add_indicator(
|
||||
IndicatorType.STABLE_RSSI,
|
||||
f'Strong signal suggesting close proximity ({latest_power} dBm)',
|
||||
{'power': latest_power}
|
||||
)
|
||||
|
||||
# 5. Meeting correlation
|
||||
if self.is_during_meeting():
|
||||
profile.add_indicator(
|
||||
IndicatorType.MEETING_CORRELATED,
|
||||
'Signal detected during sensitive period',
|
||||
{'during_meeting': True}
|
||||
)
|
||||
|
||||
return profile
|
||||
|
||||
def correlate_devices(self) -> list[dict]:
|
||||
"""
|
||||
Perform cross-protocol correlation analysis.
|
||||
|
||||
Identifies devices across protocols that may be related.
|
||||
|
||||
Returns:
|
||||
List of correlation findings
|
||||
"""
|
||||
correlations = []
|
||||
now = datetime.now()
|
||||
|
||||
# Get recent devices by protocol
|
||||
bt_devices = [p for p in self.device_profiles.values()
|
||||
if p.protocol == 'bluetooth' and
|
||||
p.last_seen and (now - p.last_seen) < self.correlation_window]
|
||||
wifi_devices = [p for p in self.device_profiles.values()
|
||||
if p.protocol == 'wifi' and
|
||||
p.last_seen and (now - p.last_seen) < self.correlation_window]
|
||||
rf_signals = [p for p in self.device_profiles.values()
|
||||
if p.protocol == 'rf' and
|
||||
p.last_seen and (now - p.last_seen) < self.correlation_window]
|
||||
|
||||
# Correlation 1: BLE audio device + RF narrowband signal
|
||||
audio_bt = [p for p in bt_devices
|
||||
if any(i.type == IndicatorType.AUDIO_CAPABLE for i in p.indicators)]
|
||||
narrowband_rf = [p for p in rf_signals
|
||||
if any(i.type == IndicatorType.NARROWBAND_SIGNAL for i in p.indicators)]
|
||||
|
||||
for bt in audio_bt:
|
||||
for rf in narrowband_rf:
|
||||
correlation = {
|
||||
'type': 'bt_audio_rf_narrowband',
|
||||
'description': 'Audio-capable BLE device detected alongside narrowband RF signal',
|
||||
'devices': [bt.identifier, rf.identifier],
|
||||
'protocols': ['bluetooth', 'rf'],
|
||||
'score_boost': 3,
|
||||
'significance': 'high',
|
||||
}
|
||||
correlations.append(correlation)
|
||||
|
||||
# Add cross-protocol indicator to both
|
||||
bt.add_indicator(
|
||||
IndicatorType.CROSS_PROTOCOL,
|
||||
f'Correlated with RF signal at {rf.frequency:.3f} MHz',
|
||||
{'correlated_device': rf.identifier}
|
||||
)
|
||||
rf.add_indicator(
|
||||
IndicatorType.CROSS_PROTOCOL,
|
||||
f'Correlated with BLE device {bt.identifier}',
|
||||
{'correlated_device': bt.identifier}
|
||||
)
|
||||
bt.correlated_devices.append(rf.identifier)
|
||||
rf.correlated_devices.append(bt.identifier)
|
||||
|
||||
# Correlation 2: Rogue WiFi AP + RF burst activity
|
||||
rogue_aps = [p for p in wifi_devices
|
||||
if any(i.type == IndicatorType.ROGUE_AP for i in p.indicators)]
|
||||
rf_bursts = [p for p in rf_signals
|
||||
if any(i.type in [IndicatorType.BURST_TRANSMISSION,
|
||||
IndicatorType.ALWAYS_ON_CARRIER] for i in p.indicators)]
|
||||
|
||||
for ap in rogue_aps:
|
||||
for rf in rf_bursts:
|
||||
correlation = {
|
||||
'type': 'rogue_ap_rf_burst',
|
||||
'description': 'Rogue AP detected alongside RF transmission',
|
||||
'devices': [ap.identifier, rf.identifier],
|
||||
'protocols': ['wifi', 'rf'],
|
||||
'score_boost': 3,
|
||||
'significance': 'high',
|
||||
}
|
||||
correlations.append(correlation)
|
||||
|
||||
ap.add_indicator(
|
||||
IndicatorType.CROSS_PROTOCOL,
|
||||
f'Correlated with RF at {rf.frequency:.3f} MHz',
|
||||
{'correlated_device': rf.identifier}
|
||||
)
|
||||
rf.add_indicator(
|
||||
IndicatorType.CROSS_PROTOCOL,
|
||||
f'Correlated with AP {ap.ssid or ap.identifier}',
|
||||
{'correlated_device': ap.identifier}
|
||||
)
|
||||
|
||||
# Correlation 3: Same vendor BLE + WiFi
|
||||
for bt in bt_devices:
|
||||
if bt.manufacturer:
|
||||
for wifi in wifi_devices:
|
||||
if wifi.manufacturer and bt.manufacturer.lower() in wifi.manufacturer.lower():
|
||||
correlation = {
|
||||
'type': 'same_vendor_bt_wifi',
|
||||
'description': f'Same vendor ({bt.manufacturer}) on BLE and WiFi',
|
||||
'devices': [bt.identifier, wifi.identifier],
|
||||
'protocols': ['bluetooth', 'wifi'],
|
||||
'score_boost': 2,
|
||||
'significance': 'medium',
|
||||
}
|
||||
correlations.append(correlation)
|
||||
|
||||
return correlations
|
||||
|
||||
def get_high_interest_devices(self) -> list[DeviceProfile]:
|
||||
"""Get all devices classified as high interest."""
|
||||
return [p for p in self.device_profiles.values()
|
||||
if p.risk_level == RiskLevel.HIGH_INTEREST]
|
||||
|
||||
def get_all_findings(self) -> dict:
|
||||
"""
|
||||
Get comprehensive findings report.
|
||||
|
||||
Returns:
|
||||
Dict with all device profiles, correlations, and summary
|
||||
"""
|
||||
correlations = self.correlate_devices()
|
||||
|
||||
devices_by_risk = {
|
||||
'high_interest': [],
|
||||
'needs_review': [],
|
||||
'informational': [],
|
||||
}
|
||||
|
||||
for profile in self.device_profiles.values():
|
||||
devices_by_risk[profile.risk_level.value].append(profile.to_dict())
|
||||
|
||||
return {
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'summary': {
|
||||
'total_devices': len(self.device_profiles),
|
||||
'high_interest': len(devices_by_risk['high_interest']),
|
||||
'needs_review': len(devices_by_risk['needs_review']),
|
||||
'informational': len(devices_by_risk['informational']),
|
||||
'correlations_found': len(correlations),
|
||||
},
|
||||
'devices': devices_by_risk,
|
||||
'correlations': correlations,
|
||||
'disclaimer': (
|
||||
"This system performs wireless and RF surveillance screening. "
|
||||
"Findings indicate anomalies and indicators, not confirmed surveillance devices."
|
||||
),
|
||||
}
|
||||
|
||||
def clear_old_profiles(self, max_age_hours: int = 24) -> int:
|
||||
"""Remove profiles older than specified age."""
|
||||
cutoff = datetime.now() - timedelta(hours=max_age_hours)
|
||||
old_keys = [
|
||||
k for k, v in self.device_profiles.items()
|
||||
if v.last_seen and v.last_seen < cutoff
|
||||
]
|
||||
for key in old_keys:
|
||||
del self.device_profiles[key]
|
||||
return len(old_keys)
|
||||
|
||||
|
||||
# Global correlation engine instance
|
||||
_correlation_engine: CorrelationEngine | None = None
|
||||
|
||||
|
||||
def get_correlation_engine() -> CorrelationEngine:
|
||||
"""Get or create the global correlation engine."""
|
||||
global _correlation_engine
|
||||
if _correlation_engine is None:
|
||||
_correlation_engine = CorrelationEngine()
|
||||
return _correlation_engine
|
||||
|
||||
|
||||
def reset_correlation_engine() -> None:
|
||||
"""Reset the global correlation engine."""
|
||||
global _correlation_engine
|
||||
_correlation_engine = CorrelationEngine()
|
||||
@@ -0,0 +1,564 @@
|
||||
"""
|
||||
TSCM Threat Detection Engine
|
||||
|
||||
Analyzes WiFi, Bluetooth, and RF data to identify potential surveillance devices
|
||||
and classify threats based on known patterns and baseline comparison.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from data.tscm_frequencies import (
|
||||
BLE_TRACKER_SIGNATURES,
|
||||
THREAT_TYPES,
|
||||
WIFI_CAMERA_PATTERNS,
|
||||
get_frequency_risk,
|
||||
get_threat_severity,
|
||||
is_known_tracker,
|
||||
is_potential_camera,
|
||||
)
|
||||
|
||||
logger = logging.getLogger('intercept.tscm.detector')
|
||||
|
||||
# Classification levels for TSCM devices
|
||||
CLASSIFICATION_LEVELS = {
|
||||
'informational': {
|
||||
'color': '#00cc00', # Green
|
||||
'label': 'Informational',
|
||||
'description': 'Known device, expected infrastructure, or background noise',
|
||||
},
|
||||
'review': {
|
||||
'color': '#ffcc00', # Yellow
|
||||
'label': 'Needs Review',
|
||||
'description': 'Unknown device requiring investigation',
|
||||
},
|
||||
'high_interest': {
|
||||
'color': '#ff3333', # Red
|
||||
'label': 'High Interest',
|
||||
'description': 'Suspicious device requiring immediate attention',
|
||||
},
|
||||
}
|
||||
|
||||
# BLE device types that can transmit audio (potential bugs)
|
||||
AUDIO_CAPABLE_BLE_NAMES = [
|
||||
'headphone', 'headset', 'earphone', 'earbud', 'speaker',
|
||||
'audio', 'mic', 'microphone', 'airpod', 'buds',
|
||||
'jabra', 'bose', 'sony wf', 'sony wh', 'beats',
|
||||
'jbl', 'soundcore', 'anker', 'skullcandy',
|
||||
]
|
||||
|
||||
# Device history for tracking repeat detections across scans
|
||||
_device_history: dict[str, list[datetime]] = {}
|
||||
_history_window_hours = 24 # Consider detections within 24 hours
|
||||
|
||||
|
||||
def _record_device_seen(identifier: str) -> int:
|
||||
"""Record a device sighting and return count of times seen."""
|
||||
now = datetime.now()
|
||||
if identifier not in _device_history:
|
||||
_device_history[identifier] = []
|
||||
|
||||
# Clean old entries
|
||||
cutoff = now.timestamp() - (_history_window_hours * 3600)
|
||||
_device_history[identifier] = [
|
||||
dt for dt in _device_history[identifier]
|
||||
if dt.timestamp() > cutoff
|
||||
]
|
||||
|
||||
_device_history[identifier].append(now)
|
||||
return len(_device_history[identifier])
|
||||
|
||||
|
||||
def _is_audio_capable_ble(name: str | None, device_type: str | None = None) -> bool:
|
||||
"""Check if a BLE device might be audio-capable."""
|
||||
if name:
|
||||
name_lower = name.lower()
|
||||
for pattern in AUDIO_CAPABLE_BLE_NAMES:
|
||||
if pattern in name_lower:
|
||||
return True
|
||||
if device_type:
|
||||
type_lower = device_type.lower()
|
||||
if any(t in type_lower for t in ['audio', 'headset', 'headphone', 'speaker']):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class ThreatDetector:
|
||||
"""
|
||||
Analyzes scan results to detect potential surveillance threats.
|
||||
"""
|
||||
|
||||
def __init__(self, baseline: dict | None = None):
|
||||
"""
|
||||
Initialize the threat detector.
|
||||
|
||||
Args:
|
||||
baseline: Optional baseline dict containing expected devices
|
||||
"""
|
||||
self.baseline = baseline
|
||||
self.baseline_wifi_macs = set()
|
||||
self.baseline_bt_macs = set()
|
||||
self.baseline_rf_freqs = set()
|
||||
|
||||
if baseline:
|
||||
self._load_baseline(baseline)
|
||||
|
||||
def _load_baseline(self, baseline: dict) -> None:
|
||||
"""Load baseline device identifiers for comparison."""
|
||||
# WiFi networks and clients
|
||||
for network in baseline.get('wifi_networks', []):
|
||||
if 'bssid' in network:
|
||||
self.baseline_wifi_macs.add(network['bssid'].upper())
|
||||
if 'clients' in network:
|
||||
for client in network['clients']:
|
||||
if 'mac' in client:
|
||||
self.baseline_wifi_macs.add(client['mac'].upper())
|
||||
|
||||
# Bluetooth devices
|
||||
for device in baseline.get('bt_devices', []):
|
||||
if 'mac' in device:
|
||||
self.baseline_bt_macs.add(device['mac'].upper())
|
||||
|
||||
# RF frequencies (rounded to nearest 0.1 MHz)
|
||||
for freq in baseline.get('rf_frequencies', []):
|
||||
if isinstance(freq, dict):
|
||||
self.baseline_rf_freqs.add(round(freq.get('frequency', 0), 1))
|
||||
else:
|
||||
self.baseline_rf_freqs.add(round(freq, 1))
|
||||
|
||||
logger.info(
|
||||
f"Loaded baseline: {len(self.baseline_wifi_macs)} WiFi, "
|
||||
f"{len(self.baseline_bt_macs)} BT, {len(self.baseline_rf_freqs)} RF"
|
||||
)
|
||||
|
||||
def classify_wifi_device(self, device: dict) -> dict:
|
||||
"""
|
||||
Classify a WiFi device into informational/review/high_interest.
|
||||
|
||||
Returns:
|
||||
Dict with 'classification', 'reasons', and metadata
|
||||
"""
|
||||
mac = device.get('bssid', device.get('mac', '')).upper()
|
||||
ssid = device.get('essid', device.get('ssid', ''))
|
||||
signal = device.get('power', device.get('signal', -100))
|
||||
|
||||
reasons = []
|
||||
classification = 'informational'
|
||||
|
||||
# Track repeat detections
|
||||
times_seen = _record_device_seen(f'wifi:{mac}') if mac else 1
|
||||
|
||||
# Check if in baseline (known device)
|
||||
in_baseline = mac in self.baseline_wifi_macs if self.baseline else False
|
||||
|
||||
if in_baseline:
|
||||
reasons.append('Known device in baseline')
|
||||
classification = 'informational'
|
||||
else:
|
||||
# New/unknown device
|
||||
reasons.append('New WiFi access point')
|
||||
classification = 'review'
|
||||
|
||||
# Check for suspicious patterns -> high interest
|
||||
if is_potential_camera(ssid=ssid, mac=mac):
|
||||
reasons.append('Matches camera device patterns')
|
||||
classification = 'high_interest'
|
||||
|
||||
if not ssid and signal and int(signal) > -60:
|
||||
reasons.append('Hidden SSID with strong signal')
|
||||
classification = 'high_interest'
|
||||
|
||||
# Repeat detections across scans
|
||||
if times_seen >= 3:
|
||||
reasons.append(f'Repeat detection ({times_seen} times)')
|
||||
if classification != 'high_interest':
|
||||
classification = 'high_interest'
|
||||
|
||||
return {
|
||||
'classification': classification,
|
||||
'reasons': reasons,
|
||||
'in_baseline': in_baseline,
|
||||
'times_seen': times_seen,
|
||||
}
|
||||
|
||||
def classify_bt_device(self, device: dict) -> dict:
|
||||
"""
|
||||
Classify a Bluetooth device into informational/review/high_interest.
|
||||
|
||||
Returns:
|
||||
Dict with 'classification', 'reasons', and metadata
|
||||
"""
|
||||
mac = device.get('mac', device.get('address', '')).upper()
|
||||
name = device.get('name', '')
|
||||
rssi = device.get('rssi', device.get('signal', -100))
|
||||
device_type = device.get('type', '')
|
||||
manufacturer_data = device.get('manufacturer_data')
|
||||
|
||||
reasons = []
|
||||
classification = 'informational'
|
||||
tracker_info = None
|
||||
|
||||
# Track repeat detections
|
||||
times_seen = _record_device_seen(f'bt:{mac}') if mac else 1
|
||||
|
||||
# Check if in baseline (known device)
|
||||
in_baseline = mac in self.baseline_bt_macs if self.baseline else False
|
||||
|
||||
# Check for trackers (do this early for all devices)
|
||||
tracker_info = is_known_tracker(name, manufacturer_data)
|
||||
|
||||
if in_baseline:
|
||||
reasons.append('Known device in baseline')
|
||||
classification = 'informational'
|
||||
else:
|
||||
# New/unknown BLE device
|
||||
if not name or name == 'Unknown':
|
||||
reasons.append('Unknown BLE device')
|
||||
classification = 'review'
|
||||
else:
|
||||
reasons.append('New Bluetooth device')
|
||||
classification = 'review'
|
||||
|
||||
# Check for trackers -> high interest
|
||||
if tracker_info:
|
||||
reasons.append(f"Known tracker: {tracker_info.get('name', 'Unknown')}")
|
||||
classification = 'high_interest'
|
||||
|
||||
# Check for audio-capable devices -> high interest
|
||||
if _is_audio_capable_ble(name, device_type):
|
||||
reasons.append('Audio-capable BLE device')
|
||||
classification = 'high_interest'
|
||||
|
||||
# Strong signal from unknown device
|
||||
if rssi and int(rssi) > -50 and not name:
|
||||
reasons.append('Strong signal from unnamed device')
|
||||
classification = 'high_interest'
|
||||
|
||||
# Repeat detections across scans
|
||||
if times_seen >= 3:
|
||||
reasons.append(f'Repeat detection ({times_seen} times)')
|
||||
if classification != 'high_interest':
|
||||
classification = 'high_interest'
|
||||
|
||||
return {
|
||||
'classification': classification,
|
||||
'reasons': reasons,
|
||||
'in_baseline': in_baseline,
|
||||
'times_seen': times_seen,
|
||||
'is_tracker': tracker_info is not None,
|
||||
'is_audio_capable': _is_audio_capable_ble(name, device_type),
|
||||
}
|
||||
|
||||
def classify_rf_signal(self, signal: dict) -> dict:
|
||||
"""
|
||||
Classify an RF signal into informational/review/high_interest.
|
||||
|
||||
Returns:
|
||||
Dict with 'classification', 'reasons', and metadata
|
||||
"""
|
||||
frequency = signal.get('frequency', 0)
|
||||
power = signal.get('power', signal.get('level', -100))
|
||||
band = signal.get('band', '')
|
||||
|
||||
reasons = []
|
||||
classification = 'informational'
|
||||
freq_rounded = round(frequency, 1)
|
||||
|
||||
# Track repeat detections
|
||||
times_seen = _record_device_seen(f'rf:{freq_rounded}')
|
||||
|
||||
# Check if in baseline (known frequency)
|
||||
in_baseline = freq_rounded in self.baseline_rf_freqs if self.baseline else False
|
||||
|
||||
# Get frequency risk info
|
||||
risk, band_name = get_frequency_risk(frequency)
|
||||
|
||||
if in_baseline:
|
||||
reasons.append('Known frequency in baseline')
|
||||
classification = 'informational'
|
||||
else:
|
||||
# New/unidentified RF carrier
|
||||
reasons.append(f'Unidentified RF carrier in {band_name}')
|
||||
|
||||
if risk == 'low':
|
||||
reasons.append('Background RF noise band')
|
||||
classification = 'review'
|
||||
elif risk == 'medium':
|
||||
reasons.append('ISM band signal')
|
||||
classification = 'review'
|
||||
elif risk in ['high', 'critical']:
|
||||
reasons.append(f'High-risk surveillance band: {band_name}')
|
||||
classification = 'high_interest'
|
||||
|
||||
# Strong persistent signal
|
||||
if power and float(power) > -40:
|
||||
reasons.append('Strong persistent transmitter')
|
||||
classification = 'high_interest'
|
||||
|
||||
# Repeat detections (persistent transmitter)
|
||||
if times_seen >= 2:
|
||||
reasons.append(f'Persistent transmitter ({times_seen} detections)')
|
||||
classification = 'high_interest'
|
||||
|
||||
return {
|
||||
'classification': classification,
|
||||
'reasons': reasons,
|
||||
'in_baseline': in_baseline,
|
||||
'times_seen': times_seen,
|
||||
'risk_level': risk,
|
||||
'band_name': band_name,
|
||||
}
|
||||
|
||||
def analyze_wifi_device(self, device: dict) -> dict | None:
|
||||
"""
|
||||
Analyze a WiFi device for threats.
|
||||
|
||||
Args:
|
||||
device: WiFi device dict with bssid, essid, etc.
|
||||
|
||||
Returns:
|
||||
Threat dict if threat detected, None otherwise
|
||||
"""
|
||||
mac = device.get('bssid', device.get('mac', '')).upper()
|
||||
ssid = device.get('essid', device.get('ssid', ''))
|
||||
vendor = device.get('vendor', '')
|
||||
signal = device.get('power', device.get('signal', -100))
|
||||
|
||||
threats = []
|
||||
|
||||
# Check if new device (not in baseline)
|
||||
if self.baseline and mac and mac not in self.baseline_wifi_macs:
|
||||
threats.append({
|
||||
'type': 'new_device',
|
||||
'severity': get_threat_severity('new_device', {'signal_strength': signal}),
|
||||
'reason': 'Device not present in baseline',
|
||||
})
|
||||
|
||||
# Check for hidden camera patterns
|
||||
if is_potential_camera(ssid=ssid, mac=mac, vendor=vendor):
|
||||
threats.append({
|
||||
'type': 'hidden_camera',
|
||||
'severity': get_threat_severity('hidden_camera', {'signal_strength': signal}),
|
||||
'reason': 'Device matches WiFi camera patterns',
|
||||
})
|
||||
|
||||
# Check for hidden SSID with strong signal
|
||||
if not ssid and signal and signal > -60:
|
||||
threats.append({
|
||||
'type': 'anomaly',
|
||||
'severity': 'medium',
|
||||
'reason': 'Hidden SSID with strong signal',
|
||||
})
|
||||
|
||||
if not threats:
|
||||
return None
|
||||
|
||||
# Return highest severity threat
|
||||
threats.sort(key=lambda t: ['low', 'medium', 'high', 'critical'].index(t['severity']), reverse=True)
|
||||
|
||||
return {
|
||||
'threat_type': threats[0]['type'],
|
||||
'severity': threats[0]['severity'],
|
||||
'source': 'wifi',
|
||||
'identifier': mac,
|
||||
'name': ssid or 'Hidden Network',
|
||||
'signal_strength': signal,
|
||||
'details': {
|
||||
'all_threats': threats,
|
||||
'vendor': vendor,
|
||||
'ssid': ssid,
|
||||
}
|
||||
}
|
||||
|
||||
def analyze_bt_device(self, device: dict) -> dict | None:
|
||||
"""
|
||||
Analyze a Bluetooth device for threats.
|
||||
|
||||
Args:
|
||||
device: BT device dict with mac, name, rssi, etc.
|
||||
|
||||
Returns:
|
||||
Threat dict if threat detected, None otherwise
|
||||
"""
|
||||
mac = device.get('mac', device.get('address', '')).upper()
|
||||
name = device.get('name', '')
|
||||
rssi = device.get('rssi', device.get('signal', -100))
|
||||
manufacturer = device.get('manufacturer', '')
|
||||
device_type = device.get('type', '')
|
||||
manufacturer_data = device.get('manufacturer_data')
|
||||
|
||||
threats = []
|
||||
|
||||
# Check if new device (not in baseline)
|
||||
if self.baseline and mac and mac not in self.baseline_bt_macs:
|
||||
threats.append({
|
||||
'type': 'new_device',
|
||||
'severity': get_threat_severity('new_device', {'signal_strength': rssi}),
|
||||
'reason': 'Device not present in baseline',
|
||||
})
|
||||
|
||||
# Check for known trackers
|
||||
tracker_info = is_known_tracker(name, manufacturer_data)
|
||||
if tracker_info:
|
||||
threats.append({
|
||||
'type': 'tracker',
|
||||
'severity': tracker_info.get('risk', 'high'),
|
||||
'reason': f"Known tracker detected: {tracker_info.get('name', 'Unknown')}",
|
||||
'tracker_type': tracker_info.get('name'),
|
||||
})
|
||||
|
||||
# Check for suspicious BLE beacons (unnamed, persistent)
|
||||
if not name and rssi and rssi > -70:
|
||||
threats.append({
|
||||
'type': 'anomaly',
|
||||
'severity': 'medium',
|
||||
'reason': 'Unnamed BLE device with strong signal',
|
||||
})
|
||||
|
||||
if not threats:
|
||||
return None
|
||||
|
||||
# Return highest severity threat
|
||||
threats.sort(key=lambda t: ['low', 'medium', 'high', 'critical'].index(t['severity']), reverse=True)
|
||||
|
||||
return {
|
||||
'threat_type': threats[0]['type'],
|
||||
'severity': threats[0]['severity'],
|
||||
'source': 'bluetooth',
|
||||
'identifier': mac,
|
||||
'name': name or 'Unknown BLE Device',
|
||||
'signal_strength': rssi,
|
||||
'details': {
|
||||
'all_threats': threats,
|
||||
'manufacturer': manufacturer,
|
||||
'device_type': device_type,
|
||||
}
|
||||
}
|
||||
|
||||
def analyze_rf_signal(self, signal: dict) -> dict | None:
|
||||
"""
|
||||
Analyze an RF signal for threats.
|
||||
|
||||
Args:
|
||||
signal: RF signal dict with frequency, level, etc.
|
||||
|
||||
Returns:
|
||||
Threat dict if threat detected, None otherwise
|
||||
"""
|
||||
frequency = signal.get('frequency', 0)
|
||||
level = signal.get('level', signal.get('power', -100))
|
||||
modulation = signal.get('modulation', '')
|
||||
|
||||
if not frequency:
|
||||
return None
|
||||
|
||||
threats = []
|
||||
freq_rounded = round(frequency, 1)
|
||||
|
||||
# Check if new frequency (not in baseline)
|
||||
if self.baseline and freq_rounded not in self.baseline_rf_freqs:
|
||||
risk, band_name = get_frequency_risk(frequency)
|
||||
threats.append({
|
||||
'type': 'unknown_signal',
|
||||
'severity': risk,
|
||||
'reason': f'New signal in {band_name}',
|
||||
})
|
||||
|
||||
# Check frequency risk even without baseline
|
||||
risk, band_name = get_frequency_risk(frequency)
|
||||
if risk in ['high', 'critical']:
|
||||
threats.append({
|
||||
'type': 'unknown_signal',
|
||||
'severity': risk,
|
||||
'reason': f'Signal in high-risk band: {band_name}',
|
||||
})
|
||||
|
||||
if not threats:
|
||||
return None
|
||||
|
||||
# Return highest severity threat
|
||||
threats.sort(key=lambda t: ['low', 'medium', 'high', 'critical'].index(t['severity']), reverse=True)
|
||||
|
||||
return {
|
||||
'threat_type': threats[0]['type'],
|
||||
'severity': threats[0]['severity'],
|
||||
'source': 'rf',
|
||||
'identifier': f'{frequency:.3f} MHz',
|
||||
'name': f'RF Signal @ {frequency:.3f} MHz',
|
||||
'signal_strength': level,
|
||||
'frequency': frequency,
|
||||
'details': {
|
||||
'all_threats': threats,
|
||||
'modulation': modulation,
|
||||
'band_name': band_name,
|
||||
}
|
||||
}
|
||||
|
||||
def analyze_all(
|
||||
self,
|
||||
wifi_devices: list[dict] | None = None,
|
||||
bt_devices: list[dict] | None = None,
|
||||
rf_signals: list[dict] | None = None
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Analyze all provided devices and signals for threats.
|
||||
|
||||
Returns:
|
||||
List of detected threats sorted by severity
|
||||
"""
|
||||
threats = []
|
||||
|
||||
if wifi_devices:
|
||||
for device in wifi_devices:
|
||||
threat = self.analyze_wifi_device(device)
|
||||
if threat:
|
||||
threats.append(threat)
|
||||
|
||||
if bt_devices:
|
||||
for device in bt_devices:
|
||||
threat = self.analyze_bt_device(device)
|
||||
if threat:
|
||||
threats.append(threat)
|
||||
|
||||
if rf_signals:
|
||||
for signal in rf_signals:
|
||||
threat = self.analyze_rf_signal(signal)
|
||||
if threat:
|
||||
threats.append(threat)
|
||||
|
||||
# Sort by severity (critical first)
|
||||
severity_order = {'critical': 0, 'high': 1, 'medium': 2, 'low': 3}
|
||||
threats.sort(key=lambda t: severity_order.get(t.get('severity', 'low'), 3))
|
||||
|
||||
return threats
|
||||
|
||||
|
||||
def classify_device_threat(
|
||||
source: str,
|
||||
device: dict,
|
||||
baseline: dict | None = None
|
||||
) -> dict | None:
|
||||
"""
|
||||
Convenience function to classify a single device.
|
||||
|
||||
Args:
|
||||
source: Device source ('wifi', 'bluetooth', 'rf')
|
||||
device: Device data dict
|
||||
baseline: Optional baseline for comparison
|
||||
|
||||
Returns:
|
||||
Threat dict if threat detected, None otherwise
|
||||
"""
|
||||
detector = ThreatDetector(baseline)
|
||||
|
||||
if source == 'wifi':
|
||||
return detector.analyze_wifi_device(device)
|
||||
elif source == 'bluetooth':
|
||||
return detector.analyze_bt_device(device)
|
||||
elif source == 'rf':
|
||||
return detector.analyze_rf_signal(device)
|
||||
|
||||
return None
|
||||
@@ -195,3 +195,64 @@ def sanitize_device_name(name: str | None) -> str:
|
||||
return ''
|
||||
# Escape HTML and limit length
|
||||
return escape_html(str(name)[:64])
|
||||
|
||||
|
||||
def validate_network_interface(name: Any) -> str:
|
||||
"""
|
||||
Validate network interface name to prevent command injection.
|
||||
|
||||
Interface names must:
|
||||
- Start with a letter
|
||||
- Contain only alphanumeric, underscore, or hyphen
|
||||
- Be 1-15 characters long (Linux IFNAMSIZ limit)
|
||||
|
||||
Args:
|
||||
name: Interface name to validate
|
||||
|
||||
Returns:
|
||||
Validated interface name
|
||||
|
||||
Raises:
|
||||
ValueError: If interface name is invalid
|
||||
"""
|
||||
if not name or not isinstance(name, str):
|
||||
raise ValueError("Interface name is required")
|
||||
|
||||
name = name.strip()
|
||||
|
||||
if not name:
|
||||
raise ValueError("Interface name cannot be empty")
|
||||
|
||||
if len(name) > 15:
|
||||
raise ValueError(f"Interface name too long (max 15 chars): {name}")
|
||||
|
||||
# Must start with letter, contain only alphanumeric/underscore/hyphen
|
||||
if not re.match(r'^[a-zA-Z][a-zA-Z0-9_-]*$', name):
|
||||
raise ValueError(f"Invalid interface name: {name}")
|
||||
|
||||
return name
|
||||
|
||||
|
||||
def validate_bluetooth_interface(name: Any) -> str:
|
||||
"""
|
||||
Validate Bluetooth interface name (hciX format).
|
||||
|
||||
Args:
|
||||
name: Interface name to validate
|
||||
|
||||
Returns:
|
||||
Validated interface name
|
||||
|
||||
Raises:
|
||||
ValueError: If interface name is invalid
|
||||
"""
|
||||
if not name or not isinstance(name, str):
|
||||
raise ValueError("Bluetooth interface name is required")
|
||||
|
||||
name = name.strip()
|
||||
|
||||
# Must be hciX format where X is a number 0-255
|
||||
if not re.match(r'^hci([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$', name):
|
||||
raise ValueError(f"Invalid Bluetooth interface name (expected hciX): {name}")
|
||||
|
||||
return name
|
||||
|
||||