fix(modes): deep-linked mode scripts fail when body not yet parsed

ensureModeScript() used document.body.appendChild() to load lazy mode
scripts, but the preload for ?mode= query params runs in <head> before
<body> exists, causing all deep-linked modes to silently fail.

Also fix cross-mode handoffs (BT→BT Locate, WiFi→WiFi Locate,
Spy Stations→Waterfall) that assumed target module was already loaded.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-03-12 20:49:08 +00:00
parent 7c425dbd99
commit ba631811a6
87 changed files with 9128 additions and 8368 deletions
+16 -1
View File
@@ -1,6 +1,8 @@
# Git # Git & CI
.git .git
.gitignore .gitignore
.github
.claude
# Python # Python
__pycache__ __pycache__
@@ -29,6 +31,19 @@ tests/
.coverage .coverage
htmlcov/ htmlcov/
.mypy_cache/ .mypy_cache/
.ruff_cache
.DS_Store
tasks/
# Documentation
*.md
# Runtime data (mounted as volume)
instance/
data/
# Build scripts
build-multiarch.sh
# Logs # Logs
*.log *.log
+7
View File
@@ -6,6 +6,13 @@
# Container timezone (e.g. America/New_York, Europe/London, Australia/Sydney) # Container timezone (e.g. America/New_York, Europe/London, Australia/Sydney)
TZ=UTC TZ=UTC
# Flask secret key (auto-generated if not set)
# INTERCEPT_SECRET_KEY=your-secret-key-here
# Admin credentials (password auto-generated on first run if not set)
# INTERCEPT_ADMIN_USERNAME=admin
# INTERCEPT_ADMIN_PASSWORD=your-password-here
# Postgres password (default: intercept) # Postgres password (default: intercept)
INTERCEPT_ADSB_DB_PASSWORD=intercept INTERCEPT_ADSB_DB_PASSWORD=intercept
+25
View File
@@ -0,0 +1,25 @@
name: CI
on: [push, pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- run: pip install ruff
- run: ruff check .
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- run: pip install -r requirements.txt
- run: pip install pytest
- run: pytest --tb=short -q
+200 -190
View File
@@ -1,6 +1,194 @@
# INTERCEPT - Signal Intelligence Platform # INTERCEPT - Signal Intelligence Platform
# Docker container for running the web interface # Docker container for running the web interface
# Multi-stage build: builder compiles tools, runtime keeps only what's needed
###############################################################################
# Stage 1: Builder — compile all tools from source
###############################################################################
FROM python:3.11-slim AS builder
WORKDIR /tmp/build
# Install ALL build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
git \
pkg-config \
cmake \
librtlsdr-dev \
libusb-1.0-0-dev \
libncurses-dev \
libsndfile1-dev \
libgtk-3-dev \
libasound2-dev \
libsoapysdr-dev \
libhackrf-dev \
liblimesuite-dev \
libfftw3-dev \
libpng-dev \
libtiff-dev \
libjemalloc-dev \
libvolk-dev \
libnng-dev \
libzstd-dev \
libsqlite3-dev \
libcurl4-openssl-dev \
zlib1g-dev \
libzmq3-dev \
libpulse-dev \
libfftw3-bin \
liblapack-dev \
libglib2.0-dev \
libxml2-dev \
curl \
&& rm -rf /var/lib/apt/lists/*
# Create staging directory for all built artifacts
RUN mkdir -p /staging/usr/bin /staging/usr/local/bin /staging/usr/local/lib /staging/opt
# Build dump1090
RUN cd /tmp \
&& git clone --depth 1 https://github.com/flightaware/dump1090.git \
&& cd dump1090 \
&& sed -i 's/-Werror//g' Makefile \
&& make BLADERF=no RTLSDR=yes \
&& cp dump1090 /staging/usr/bin/dump1090-fa \
&& ln -s /usr/bin/dump1090-fa /staging/usr/bin/dump1090 \
&& rm -rf /tmp/dump1090
# Build AIS-catcher
RUN cd /tmp \
&& git clone https://github.com/jvde-github/AIS-catcher.git \
&& cd AIS-catcher \
&& mkdir build && cd build \
&& cmake .. \
&& make \
&& cp AIS-catcher /staging/usr/bin/AIS-catcher \
&& rm -rf /tmp/AIS-catcher
# Build readsb
RUN cd /tmp \
&& git clone --depth 1 https://github.com/wiedehopf/readsb.git \
&& cd readsb \
&& make BLADERF=no PLUTOSDR=no SOAPYSDR=yes \
&& cp readsb /staging/usr/bin/readsb \
&& rm -rf /tmp/readsb
# Build rx_tools
RUN cd /tmp \
&& git clone https://github.com/rxseger/rx_tools.git \
&& cd rx_tools \
&& mkdir build && cd build \
&& cmake .. \
&& make \
&& DESTDIR=/staging make install \
&& rm -rf /tmp/rx_tools
# Build acarsdec
RUN cd /tmp \
&& git clone --depth 1 https://github.com/TLeconte/acarsdec.git \
&& cd acarsdec \
&& mkdir build && cd build \
&& cmake .. -Drtl=ON -DCMAKE_POLICY_VERSION_MINIMUM=3.5 \
&& make \
&& cp acarsdec /staging/usr/bin/acarsdec \
&& rm -rf /tmp/acarsdec
# Build libacars (required by dumpvdl2)
RUN cd /tmp \
&& git clone --depth 1 https://github.com/szpajder/libacars.git \
&& cd libacars \
&& mkdir build && cd build \
&& cmake .. \
&& make \
&& make install \
&& ldconfig \
&& cp -a /usr/local/lib/libacars* /staging/usr/local/lib/ \
&& rm -rf /tmp/libacars
# Build dumpvdl2 (VDL2 aircraft datalink decoder)
RUN cd /tmp \
&& git clone --depth 1 https://github.com/szpajder/dumpvdl2.git \
&& cd dumpvdl2 \
&& mkdir build && cd build \
&& cmake .. \
&& make \
&& cp src/dumpvdl2 /staging/usr/bin/dumpvdl2 \
&& rm -rf /tmp/dumpvdl2
# Build slowrx (SSTV decoder) — pinned to known-good commit
RUN cd /tmp \
&& git clone https://github.com/windytan/slowrx.git \
&& cd slowrx \
&& git checkout ca6d7012 \
&& make \
&& install -m 0755 slowrx /staging/usr/local/bin/slowrx \
&& rm -rf /tmp/slowrx
# Build SatDump (weather satellite decoder - NOAA APT & Meteor LRPT) — pinned to v1.2.2
RUN cd /tmp \
&& git clone --depth 1 --branch 1.2.2 https://github.com/SatDump/SatDump.git \
&& cd SatDump \
&& mkdir build && cd build \
&& cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF -DCMAKE_INSTALL_LIBDIR=lib .. \
&& make -j$(nproc) \
&& make install \
&& ldconfig \
# Ensure SatDump plugins are in the expected path (handles multiarch differences)
&& mkdir -p /usr/local/lib/satdump/plugins \
&& if [ -z "$(ls /usr/local/lib/satdump/plugins/*.so 2>/dev/null)" ]; then \
for dir in /usr/local/lib/*/satdump/plugins /usr/lib/*/satdump/plugins /usr/lib/satdump/plugins; do \
if [ -d "$dir" ] && [ -n "$(ls "$dir"/*.so 2>/dev/null)" ]; then \
ln -sf "$dir"/*.so /usr/local/lib/satdump/plugins/; \
break; \
fi; \
done; \
fi \
# Copy SatDump install artifacts to staging
&& cp -a /usr/local/bin/satdump /staging/usr/local/bin/ 2>/dev/null || true \
&& cp -a /usr/local/lib/libsatdump* /staging/usr/local/lib/ 2>/dev/null || true \
&& cp -a /usr/local/lib/satdump /staging/usr/local/lib/ 2>/dev/null || true \
&& cp -a /usr/local/share/satdump /staging/usr/local/share/ 2>/dev/null; mkdir -p /staging/usr/local/share \
&& cp -a /usr/local/share/satdump /staging/usr/local/share/ 2>/dev/null || true \
&& rm -rf /tmp/SatDump
# Build hackrf CLI tools from source — avoids libhackrf0 version conflict
# between the 'hackrf' apt package and soapysdr-module-hackrf's newer libhackrf0
RUN cd /tmp \
&& git clone --depth 1 https://github.com/greatscottgadgets/hackrf.git \
&& cd hackrf/host \
&& mkdir build && cd build \
&& cmake .. \
&& make \
&& make install \
&& ldconfig \
&& cp -a /usr/local/bin/hackrf_* /staging/usr/local/bin/ 2>/dev/null || true \
&& cp -a /usr/local/lib/libhackrf* /staging/usr/local/lib/ 2>/dev/null || true \
&& rm -rf /tmp/hackrf
# Install radiosonde_auto_rx (weather balloon decoder)
RUN cd /tmp \
&& git clone --depth 1 https://github.com/projecthorus/radiosonde_auto_rx.git \
&& cd radiosonde_auto_rx/auto_rx \
&& pip install --no-cache-dir -r requirements.txt semver \
&& bash build.sh \
&& mkdir -p /staging/opt/radiosonde_auto_rx/auto_rx \
&& cp -r . /staging/opt/radiosonde_auto_rx/auto_rx/ \
&& chmod +x /staging/opt/radiosonde_auto_rx/auto_rx/auto_rx.py \
&& rm -rf /tmp/radiosonde_auto_rx
# Build rtlamr (utility meter decoder - requires Go)
RUN cd /tmp \
&& curl -fsSL "https://go.dev/dl/go1.22.5.linux-$(dpkg --print-architecture).tar.gz" | tar -C /usr/local -xz \
&& export PATH="$PATH:/usr/local/go/bin" \
&& export GOPATH=/tmp/gopath \
&& go install github.com/bemasher/rtlamr@latest \
&& cp /tmp/gopath/bin/rtlamr /staging/usr/bin/rtlamr \
&& rm -rf /usr/local/go /tmp/gopath
###############################################################################
# Stage 2: Runtime — lean image with only runtime dependencies
###############################################################################
FROM python:3.11-slim FROM python:3.11-slim
LABEL maintainer="INTERCEPT Project" LABEL maintainer="INTERCEPT Project"
@@ -12,12 +200,10 @@ WORKDIR /app
# Pre-accept tshark non-root capture prompt for non-interactive install # Pre-accept tshark non-root capture prompt for non-interactive install
RUN echo 'wireshark-common wireshark-common/install-setuid boolean true' | debconf-set-selections RUN echo 'wireshark-common wireshark-common/install-setuid boolean true' | debconf-set-selections
# Install system dependencies for SDR tools # Install ONLY runtime dependencies (no -dev packages, no build tools)
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
# RTL-SDR tools # RTL-SDR tools
rtl-sdr \ rtl-sdr \
librtlsdr-dev \
libusb-1.0-0-dev \
# 433MHz decoder # 433MHz decoder
rtl-433 \ rtl-433 \
# Pager decoder # Pager decoder
@@ -43,7 +229,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# GPS support # GPS support
gpsd \ gpsd \
gpsd-clients \ gpsd-clients \
# Utilities
# APRS # APRS
direwolf \ direwolf \
# WiFi Extra # WiFi Extra
@@ -62,192 +247,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
procps \ procps \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Build dump1090-fa and acarsdec from source (packages not available in slim repos) # Copy compiled binaries and libraries from builder stage
RUN apt-get update && apt-get install -y --no-install-recommends \ COPY --from=builder /staging/usr/bin/ /usr/bin/
build-essential \ COPY --from=builder /staging/usr/local/bin/ /usr/local/bin/
git \ COPY --from=builder /staging/usr/local/lib/ /usr/local/lib/
pkg-config \ COPY --from=builder /staging/opt/ /opt/
cmake \
libncurses-dev \ # Copy radiosonde Python dependencies installed during builder stage
libsndfile1-dev \ COPY --from=builder /usr/local/lib/python3.11/site-packages/ /usr/local/lib/python3.11/site-packages/
# GTK is required for slowrx (SSTV decoder GUI dependency).
# Note: slowrx is kept for backwards compatibility, but the pure Python # Refresh shared library cache for custom-built libraries
# SSTV decoder in utils/sstv/ is now the primary implementation. RUN ldconfig
# GTK can be removed if slowrx is deprecated in future releases.
libgtk-3-dev \
libasound2-dev \
libsoapysdr-dev \
libhackrf-dev \
liblimesuite-dev \
libfftw3-dev \
libpng-dev \
libtiff-dev \
libjemalloc-dev \
libvolk-dev \
libnng-dev \
libzstd-dev \
libsqlite3-dev \
libcurl4-openssl-dev \
zlib1g-dev \
libzmq3-dev \
libpulse-dev \
libfftw3-bin \
liblapack-dev \
libglib2.0-dev \
libxml2-dev \
# Build dump1090
&& cd /tmp \
&& git clone --depth 1 https://github.com/flightaware/dump1090.git \
&& cd dump1090 \
&& sed -i 's/-Werror//g' Makefile \
&& make BLADERF=no RTLSDR=yes \
&& cp dump1090 /usr/bin/dump1090-fa \
&& ln -s /usr/bin/dump1090-fa /usr/bin/dump1090 \
&& rm -rf /tmp/dump1090 \
# Build AIS-catcher
&& cd /tmp \
&& git clone https://github.com/jvde-github/AIS-catcher.git \
&& cd AIS-catcher \
&& mkdir build && cd build \
&& cmake .. \
&& make \
&& cp AIS-catcher /usr/bin/AIS-catcher \
&& cd /tmp \
&& rm -rf /tmp/AIS-catcher \
# Build readsb
&& cd /tmp \
&& git clone --depth 1 https://github.com/wiedehopf/readsb.git \
&& cd readsb \
&& make BLADERF=no PLUTOSDR=no SOAPYSDR=yes \
&& cp readsb /usr/bin/readsb \
&& cd /tmp \
&& rm -rf /tmp/readsb \
# Build rx_tools
&& cd /tmp \
&& git clone https://github.com/rxseger/rx_tools.git \
&& cd rx_tools \
&& mkdir build && cd build \
&& cmake .. \
&& make \
&& make install \
&& cd /tmp \
&& rm -rf /tmp/rx_tools \
# Build acarsdec
&& cd /tmp \
&& git clone --depth 1 https://github.com/TLeconte/acarsdec.git \
&& cd acarsdec \
&& mkdir build && cd build \
&& cmake .. -Drtl=ON -DCMAKE_POLICY_VERSION_MINIMUM=3.5 \
&& make \
&& cp acarsdec /usr/bin/acarsdec \
&& rm -rf /tmp/acarsdec \
# Build libacars (required by dumpvdl2)
&& cd /tmp \
&& git clone --depth 1 https://github.com/szpajder/libacars.git \
&& cd libacars \
&& mkdir build && cd build \
&& cmake .. \
&& make \
&& make install \
&& ldconfig \
&& rm -rf /tmp/libacars \
# Build dumpvdl2 (VDL2 aircraft datalink decoder)
&& cd /tmp \
&& git clone --depth 1 https://github.com/szpajder/dumpvdl2.git \
&& cd dumpvdl2 \
&& mkdir build && cd build \
&& cmake .. \
&& make \
&& cp src/dumpvdl2 /usr/bin/dumpvdl2 \
&& rm -rf /tmp/dumpvdl2 \
# Build slowrx (SSTV decoder) — pinned to known-good commit
&& cd /tmp \
&& git clone https://github.com/windytan/slowrx.git \
&& cd slowrx \
&& git checkout ca6d7012 \
&& make \
&& install -m 0755 slowrx /usr/local/bin/slowrx \
&& rm -rf /tmp/slowrx \
# Build SatDump (weather satellite decoder - NOAA APT & Meteor LRPT) — pinned to v1.2.2
&& cd /tmp \
&& git clone --depth 1 --branch 1.2.2 https://github.com/SatDump/SatDump.git \
&& cd SatDump \
&& mkdir build && cd build \
&& cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF -DCMAKE_INSTALL_LIBDIR=lib .. \
&& make -j$(nproc) \
&& make install \
&& ldconfig \
# Ensure SatDump plugins are in the expected path (handles multiarch differences)
&& mkdir -p /usr/local/lib/satdump/plugins \
&& if [ -z "$(ls /usr/local/lib/satdump/plugins/*.so 2>/dev/null)" ]; then \
for dir in /usr/local/lib/*/satdump/plugins /usr/lib/*/satdump/plugins /usr/lib/satdump/plugins; do \
if [ -d "$dir" ] && [ -n "$(ls "$dir"/*.so 2>/dev/null)" ]; then \
ln -sf "$dir"/*.so /usr/local/lib/satdump/plugins/; \
break; \
fi; \
done; \
fi \
&& cd /tmp \
&& rm -rf /tmp/SatDump \
# Build hackrf CLI tools from source — avoids libhackrf0 version conflict
# between the 'hackrf' apt package and soapysdr-module-hackrf's newer libhackrf0
&& cd /tmp \
&& git clone --depth 1 https://github.com/greatscottgadgets/hackrf.git \
&& cd hackrf/host \
&& mkdir build && cd build \
&& cmake .. \
&& make \
&& make install \
&& ldconfig \
&& rm -rf /tmp/hackrf \
# Install radiosonde_auto_rx (weather balloon decoder)
&& cd /tmp \
&& git clone --depth 1 https://github.com/projecthorus/radiosonde_auto_rx.git \
&& cd radiosonde_auto_rx/auto_rx \
&& pip install --no-cache-dir -r requirements.txt semver \
&& bash build.sh \
&& mkdir -p /opt/radiosonde_auto_rx/auto_rx \
&& cp -r . /opt/radiosonde_auto_rx/auto_rx/ \
&& chmod +x /opt/radiosonde_auto_rx/auto_rx/auto_rx.py \
&& cd /tmp \
&& rm -rf /tmp/radiosonde_auto_rx \
# Build rtlamr (utility meter decoder - requires Go)
&& cd /tmp \
&& curl -fsSL "https://go.dev/dl/go1.22.5.linux-$(dpkg --print-architecture).tar.gz" | tar -C /usr/local -xz \
&& export PATH="$PATH:/usr/local/go/bin" \
&& export GOPATH=/tmp/gopath \
&& go install github.com/bemasher/rtlamr@latest \
&& cp /tmp/gopath/bin/rtlamr /usr/bin/rtlamr \
&& rm -rf /usr/local/go /tmp/gopath \
# Cleanup build tools to reduce image size
# libgtk-3-dev is explicitly removed; runtime GTK libs remain for slowrx
&& apt-get remove -y \
build-essential \
git \
pkg-config \
cmake \
libncurses-dev \
libsndfile1-dev \
libgtk-3-dev \
libasound2-dev \
libpng-dev \
libtiff-dev \
libjemalloc-dev \
libvolk-dev \
libnng-dev \
libzstd-dev \
libsoapysdr-dev \
libhackrf-dev \
liblimesuite-dev \
libsqlite3-dev \
libcurl4-openssl-dev \
zlib1g-dev \
libzmq3-dev \
libpulse-dev \
libfftw3-dev \
liblapack-dev \
&& apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements first for better caching # Copy requirements first for better caching
COPY requirements.txt . COPY requirements.txt .
+85 -3
View File
@@ -22,6 +22,7 @@ import queue
import threading import threading
import platform import platform
import subprocess import subprocess
from pathlib import Path
from typing import Any from typing import Any
@@ -48,6 +49,16 @@ try:
_has_limiter = True _has_limiter = True
except ImportError: except ImportError:
_has_limiter = False _has_limiter = False
try:
from flask_compress import Compress
_has_compress = True
except ImportError:
_has_compress = False
try:
from flask_wtf.csrf import CSRFProtect
_has_csrf = True
except ImportError:
_has_csrf = False
# Track application start time for uptime calculation # Track application start time for uptime calculation
import time as _time import time as _time
_app_start_time = _time.time() _app_start_time = _time.time()
@@ -55,7 +66,29 @@ logger = logging.getLogger('intercept.database')
# Create Flask app # Create Flask app
app = Flask(__name__) app = Flask(__name__)
app.secret_key = "signals_intelligence_secret" # Required for flash messages def _load_or_generate_secret_key():
"""Load secret key from env var or instance file, generating if needed."""
env_key = os.environ.get('INTERCEPT_SECRET_KEY')
if env_key:
return env_key
key_path = Path('instance/secret.key')
if key_path.exists():
return key_path.read_text().strip()
key_path.parent.mkdir(exist_ok=True)
key = os.urandom(32).hex()
key_path.write_text(key)
return key
app.secret_key = _load_or_generate_secret_key()
# Set up HTTP compression (gzip/brotli for HTML, CSS, JS, JSON)
if _has_compress:
Compress(app)
else:
logging.getLogger('intercept').warning(
"flask-compress not installed HTTP compression disabled. "
"Install with: pip install flask-compress"
)
# Set up rate limiting # Set up rate limiting
if _has_limiter: if _has_limiter:
@@ -77,6 +110,16 @@ else:
return decorator return decorator
limiter = _NoopLimiter() limiter = _NoopLimiter()
# Set up CSRF protection
if _has_csrf:
csrf = CSRFProtect(app)
else:
logging.getLogger('intercept').warning(
"flask-wtf not installed CSRF protection disabled. "
"Install with: pip install flask-wtf"
)
csrf = None
# Disable Werkzeug debugger PIN (not needed for local development tool) # Disable Werkzeug debugger PIN (not needed for local development tool)
os.environ['WERKZEUG_DEBUG_PIN'] = 'off' os.environ['WERKZEUG_DEBUG_PIN'] = 'off'
@@ -106,6 +149,12 @@ def add_security_headers(response):
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin' response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
# Permissions policy (disable unnecessary features) # Permissions policy (disable unnecessary features)
response.headers['Permissions-Policy'] = 'geolocation=(self), microphone=()' response.headers['Permissions-Policy'] = 'geolocation=(self), microphone=()'
# Cache-Control for static assets
if request.path.startswith('/static/'):
if '/vendor/' in request.path:
response.headers['Cache-Control'] = 'public, max-age=604800' # 7 days for vendored libs
else:
response.headers['Cache-Control'] = 'public, max-age=86400' # 24h for app assets
return response return response
@@ -803,13 +852,43 @@ def _get_wifi_health() -> tuple[bool, int, int]:
@app.route('/health') @app.route('/health')
def health_check() -> Response: def health_check() -> Response:
"""Health check endpoint for monitoring.""" """Health check endpoint for monitoring."""
import platform
import time import time
bt_active, bt_device_count = _get_bluetooth_health() bt_active, bt_device_count = _get_bluetooth_health()
wifi_active, wifi_network_count, wifi_client_count = _get_wifi_health() wifi_active, wifi_network_count, wifi_client_count = _get_wifi_health()
return jsonify({
'status': 'healthy', # Database health check
db_ok = True
try:
from utils.database import get_connection
get_connection().execute('SELECT 1')
except Exception:
db_ok = False
# SDR device count (cached, non-blocking)
sdr_count = 0
try:
from utils.sdr.detection import get_cached_devices
cached = get_cached_devices()
if cached is not None:
sdr_count = len(cached)
except (ImportError, Exception):
pass
overall_status = 'healthy' if db_ok else 'degraded'
status_code = 200 if db_ok else 503
response = jsonify({
'status': overall_status,
'version': VERSION, 'version': VERSION,
'uptime_seconds': round(time.time() - _app_start_time, 2), 'uptime_seconds': round(time.time() - _app_start_time, 2),
'system': {
'python_version': platform.python_version(),
'platform': platform.platform(),
},
'database': db_ok,
'sdr_devices': sdr_count,
'rate_limiting': _has_limiter,
'processes': { 'processes': {
'pager': current_process is not None and (current_process.poll() is None if current_process else False), '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), 'sensor': sensor_process is not None and (sensor_process.poll() is None if sensor_process else False),
@@ -843,9 +922,12 @@ def health_check() -> Response:
'dsc_messages_count': len(dsc_messages), 'dsc_messages_count': len(dsc_messages),
} }
}) })
response.status_code = status_code
return response
@app.route('/killall', methods=['POST']) @app.route('/killall', methods=['POST'])
@(csrf.exempt if csrf else lambda f: f)
def kill_all() -> Response: def kill_all() -> Response:
"""Kill all decoder, WiFi, and Bluetooth processes.""" """Kill all decoder, WiFi, and Bluetooth processes."""
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
+1 -1
View File
@@ -399,7 +399,7 @@ ALERT_WEBHOOK_TIMEOUT = _get_env_int('ALERT_WEBHOOK_TIMEOUT', 5)
# Admin credentials # Admin credentials
ADMIN_USERNAME = _get_env('ADMIN_USERNAME', 'admin') ADMIN_USERNAME = _get_env('ADMIN_USERNAME', 'admin')
ADMIN_PASSWORD = _get_env('ADMIN_PASSWORD', 'admin') ADMIN_PASSWORD = _get_env('ADMIN_PASSWORD', '')
def configure_logging() -> None: def configure_logging() -> None:
+2
View File
@@ -1,5 +1,7 @@
# Core dependencies # Core dependencies
flask>=3.0.0 flask>=3.0.0
flask-wtf>=1.2.0
flask-compress>=1.15
flask-limiter>=2.5.4 flask-limiter>=2.5.4
requests>=2.28.0 requests>=2.28.0
Werkzeug>=3.1.5 Werkzeug>=3.1.5
+11
View File
@@ -1,7 +1,13 @@
# Routes package - registers all blueprints with the Flask app # Routes package - registers all blueprints with the Flask app
def register_blueprints(app): def register_blueprints(app):
"""Register all route blueprints with the Flask app.""" """Register all route blueprints with the Flask app."""
# Import CSRF to exempt API blueprints (they use JSON, not form tokens)
try:
from app import csrf as _csrf
except ImportError:
_csrf = None
from .acars import acars_bp from .acars import acars_bp
from .adsb import adsb_bp from .adsb import adsb_bp
from .ais import ais_bp from .ais import ais_bp
@@ -84,6 +90,11 @@ def register_blueprints(app):
app.register_blueprint(system_bp) # System health monitoring app.register_blueprint(system_bp) # System health monitoring
app.register_blueprint(ook_bp) # Generic OOK signal decoder app.register_blueprint(ook_bp) # Generic OOK signal decoder
# Exempt all API blueprints from CSRF (they use JSON, not form tokens)
if _csrf:
for bp in app.blueprints.values():
_csrf.exempt(bp)
# Initialize TSCM state with queue and lock from app # Initialize TSCM state with queue and lock from app
import app as app_module import app as app_module
if hasattr(app_module, 'tscm_queue') and hasattr(app_module, 'tscm_lock'): if hasattr(app_module, 'tscm_queue') and hasattr(app_module, 'tscm_lock'):
+8 -20
View File
@@ -17,6 +17,7 @@ from typing import Any, Generator
from flask import Blueprint, Response, jsonify, request from flask import Blueprint, Response, jsonify, request
from utils.responses import api_success, api_error
import app as app_module import app as app_module
from utils.acars_translator import translate_message from utils.acars_translator import translate_message
from utils.constants import ( from utils.constants import (
@@ -219,18 +220,12 @@ def start_acars() -> Response:
with app_module.acars_lock: with app_module.acars_lock:
if app_module.acars_process and app_module.acars_process.poll() is None: if app_module.acars_process and app_module.acars_process.poll() is None:
return jsonify({ return api_error('ACARS decoder already running', 409)
'status': 'error',
'message': 'ACARS decoder already running'
}), 409
# Check for acarsdec # Check for acarsdec
acarsdec_path = find_acarsdec() acarsdec_path = find_acarsdec()
if not acarsdec_path: if not acarsdec_path:
return jsonify({ return api_error('acarsdec not found. Install with: sudo apt install acarsdec', 400)
'status': 'error',
'message': 'acarsdec not found. Install with: sudo apt install acarsdec'
}), 400
data = request.json or {} data = request.json or {}
@@ -240,7 +235,7 @@ def start_acars() -> Response:
gain = validate_gain(data.get('gain', '40')) gain = validate_gain(data.get('gain', '40'))
ppm = validate_ppm(data.get('ppm', '0')) ppm = validate_ppm(data.get('ppm', '0'))
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
# Resolve SDR type for device selection # Resolve SDR type for device selection
sdr_type_str = data.get('sdr_type', 'rtlsdr') sdr_type_str = data.get('sdr_type', 'rtlsdr')
@@ -249,11 +244,7 @@ def start_acars() -> Response:
device_int = int(device) device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'acars', sdr_type_str) error = app_module.claim_sdr_device(device_int, 'acars', sdr_type_str)
if error: if error:
return jsonify({ return api_error(error, 409, error_type='DEVICE_BUSY')
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
acars_active_device = device_int acars_active_device = device_int
acars_active_sdr_type = sdr_type_str acars_active_sdr_type = sdr_type_str
@@ -372,7 +363,7 @@ def start_acars() -> Response:
if stderr: if stderr:
error_msg += f': {stderr[:500]}' error_msg += f': {stderr[:500]}'
logger.error(error_msg) logger.error(error_msg)
return jsonify({'status': 'error', 'message': error_msg}), 500 return api_error(error_msg, 500)
app_module.acars_process = process app_module.acars_process = process
register_process(process) register_process(process)
@@ -399,7 +390,7 @@ def start_acars() -> Response:
acars_active_device = None acars_active_device = None
acars_active_sdr_type = None acars_active_sdr_type = None
logger.error(f"Failed to start ACARS decoder: {e}") logger.error(f"Failed to start ACARS decoder: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500 return api_error(str(e), 500)
@acars_bp.route('/stop', methods=['POST']) @acars_bp.route('/stop', methods=['POST'])
@@ -409,10 +400,7 @@ def stop_acars() -> Response:
with app_module.acars_lock: with app_module.acars_lock:
if not app_module.acars_process: if not app_module.acars_process:
return jsonify({ return api_error('ACARS decoder not running', 400)
'status': 'error',
'message': 'ACARS decoder not running'
}), 400
try: try:
app_module.acars_process.terminate() app_module.acars_process.terminate()
+29 -27
View File
@@ -17,6 +17,8 @@ from typing import Any, Generator
from flask import Blueprint, Response, jsonify, make_response, render_template, request from flask import Blueprint, Response, jsonify, make_response, render_template, request
from utils.responses import api_success, api_error
# psycopg2 is optional - only needed for PostgreSQL history persistence # psycopg2 is optional - only needed for PostgreSQL history persistence
try: try:
import psycopg2 import psycopg2
@@ -866,7 +868,7 @@ def start_adsb():
gain = int(validate_gain(data.get('gain', '40'))) gain = int(validate_gain(data.get('gain', '40')))
device = validate_device_index(data.get('device', '0')) device = validate_device_index(data.get('device', '0'))
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
# Check for remote SBS connection (e.g., remote dump1090) # Check for remote SBS connection (e.g., remote dump1090)
remote_sbs_host = data.get('remote_sbs_host') remote_sbs_host = data.get('remote_sbs_host')
@@ -878,7 +880,7 @@ def start_adsb():
remote_sbs_host = validate_rtl_tcp_host(remote_sbs_host) remote_sbs_host = validate_rtl_tcp_host(remote_sbs_host)
remote_sbs_port = validate_rtl_tcp_port(remote_sbs_port) remote_sbs_port = validate_rtl_tcp_port(remote_sbs_port)
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
remote_addr = f"{remote_sbs_host}:{remote_sbs_port}" remote_addr = f"{remote_sbs_host}:{remote_sbs_port}"
logger.info(f"Connecting to remote dump1090 SBS at {remote_addr}") logger.info(f"Connecting to remote dump1090 SBS at {remote_addr}")
@@ -935,12 +937,12 @@ def start_adsb():
if sdr_type == SDRType.RTL_SDR: if sdr_type == SDRType.RTL_SDR:
dump1090_path = find_dump1090() dump1090_path = find_dump1090()
if not dump1090_path: if not dump1090_path:
return jsonify({'status': 'error', 'message': 'dump1090 not found. Install dump1090/dump1090-fa or ensure it is in /usr/local/bin/'}) return api_error('dump1090 not found. Install dump1090/dump1090-fa or ensure it is in /usr/local/bin/')
else: else:
# For LimeSDR/HackRF, check for readsb (dump1090 with SoapySDR support) # For LimeSDR/HackRF, check for readsb (dump1090 with SoapySDR support)
dump1090_path = shutil.which('readsb') or find_dump1090() dump1090_path = shutil.which('readsb') or find_dump1090()
if not dump1090_path: if not dump1090_path:
return jsonify({'status': 'error', 'message': f'readsb or dump1090 not found for {sdr_type.value}. Install readsb with SoapySDR support.'}) return api_error(f'readsb or dump1090 not found for {sdr_type.value}. Install readsb with SoapySDR support.')
# Kill any stale app-started process (use process group to ensure full cleanup) # Kill any stale app-started process (use process group to ensure full cleanup)
if app_module.adsb_process: if app_module.adsb_process:
@@ -1122,7 +1124,7 @@ def start_adsb():
app_module.release_sdr_device(device_int, sdr_type_str) app_module.release_sdr_device(device_int, sdr_type_str)
adsb_active_device = None adsb_active_device = None
adsb_active_sdr_type = None adsb_active_sdr_type = None
return jsonify({'status': 'error', 'message': str(e)}) return api_error(str(e))
@adsb_bp.route('/stop', methods=['POST']) @adsb_bp.route('/stop', methods=['POST'])
@@ -1233,7 +1235,7 @@ def adsb_history():
def adsb_history_summary(): def adsb_history_summary():
"""Summary stats for ADS-B history window.""" """Summary stats for ADS-B history window."""
if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE: if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE:
return jsonify({'error': 'ADS-B history is disabled'}), 503 return api_error('ADS-B history is disabled', 503)
_ensure_history_schema() _ensure_history_schema()
since_minutes = _parse_int_param(request.args.get('since_minutes'), 1440, 1, 10080) since_minutes = _parse_int_param(request.args.get('since_minutes'), 1440, 1, 10080)
@@ -1256,14 +1258,14 @@ def adsb_history_summary():
return jsonify(row) return jsonify(row)
except Exception as exc: except Exception as exc:
logger.warning("ADS-B history summary failed: %s", exc) logger.warning("ADS-B history summary failed: %s", exc)
return jsonify({'error': 'History database unavailable'}), 503 return api_error('History database unavailable', 503)
@adsb_bp.route('/history/aircraft') @adsb_bp.route('/history/aircraft')
def adsb_history_aircraft(): def adsb_history_aircraft():
"""List latest aircraft snapshots for a time window.""" """List latest aircraft snapshots for a time window."""
if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE: if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE:
return jsonify({'error': 'ADS-B history is disabled'}), 503 return api_error('ADS-B history is disabled', 503)
_ensure_history_schema() _ensure_history_schema()
since_minutes = _parse_int_param(request.args.get('since_minutes'), 1440, 1, 10080) since_minutes = _parse_int_param(request.args.get('since_minutes'), 1440, 1, 10080)
@@ -1306,19 +1308,19 @@ def adsb_history_aircraft():
return jsonify({'aircraft': rows, 'count': len(rows)}) return jsonify({'aircraft': rows, 'count': len(rows)})
except Exception as exc: except Exception as exc:
logger.warning("ADS-B history aircraft query failed: %s", exc) logger.warning("ADS-B history aircraft query failed: %s", exc)
return jsonify({'error': 'History database unavailable'}), 503 return api_error('History database unavailable', 503)
@adsb_bp.route('/history/timeline') @adsb_bp.route('/history/timeline')
def adsb_history_timeline(): def adsb_history_timeline():
"""Timeline snapshots for a specific aircraft.""" """Timeline snapshots for a specific aircraft."""
if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE: if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE:
return jsonify({'error': 'ADS-B history is disabled'}), 503 return api_error('ADS-B history is disabled', 503)
_ensure_history_schema() _ensure_history_schema()
icao = (request.args.get('icao') or '').strip().upper() icao = (request.args.get('icao') or '').strip().upper()
if not icao: if not icao:
return jsonify({'error': 'icao is required'}), 400 return api_error('icao is required', 400)
since_minutes = _parse_int_param(request.args.get('since_minutes'), 1440, 1, 10080) since_minutes = _parse_int_param(request.args.get('since_minutes'), 1440, 1, 10080)
limit = _parse_int_param(request.args.get('limit'), 2000, 1, 20000) limit = _parse_int_param(request.args.get('limit'), 2000, 1, 20000)
@@ -1341,14 +1343,14 @@ def adsb_history_timeline():
return jsonify({'icao': icao, 'timeline': rows, 'count': len(rows)}) return jsonify({'icao': icao, 'timeline': rows, 'count': len(rows)})
except Exception as exc: except Exception as exc:
logger.warning("ADS-B history timeline query failed: %s", exc) logger.warning("ADS-B history timeline query failed: %s", exc)
return jsonify({'error': 'History database unavailable'}), 503 return api_error('History database unavailable', 503)
@adsb_bp.route('/history/messages') @adsb_bp.route('/history/messages')
def adsb_history_messages(): def adsb_history_messages():
"""Raw message history for a specific aircraft.""" """Raw message history for a specific aircraft."""
if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE: if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE:
return jsonify({'error': 'ADS-B history is disabled'}), 503 return api_error('ADS-B history is disabled', 503)
_ensure_history_schema() _ensure_history_schema()
icao = (request.args.get('icao') or '').strip().upper() icao = (request.args.get('icao') or '').strip().upper()
@@ -1373,22 +1375,22 @@ def adsb_history_messages():
return jsonify({'icao': icao, 'messages': rows, 'count': len(rows)}) return jsonify({'icao': icao, 'messages': rows, 'count': len(rows)})
except Exception as exc: except Exception as exc:
logger.warning("ADS-B history message query failed: %s", exc) logger.warning("ADS-B history message query failed: %s", exc)
return jsonify({'error': 'History database unavailable'}), 503 return api_error('History database unavailable', 503)
@adsb_bp.route('/history/export') @adsb_bp.route('/history/export')
def adsb_history_export(): def adsb_history_export():
"""Export ADS-B history data in CSV or JSON format.""" """Export ADS-B history data in CSV or JSON format."""
if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE: if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE:
return jsonify({'error': 'ADS-B history is disabled'}), 503 return api_error('ADS-B history is disabled', 503)
_ensure_history_schema() _ensure_history_schema()
export_format = str(request.args.get('format') or 'csv').strip().lower() export_format = str(request.args.get('format') or 'csv').strip().lower()
export_type = str(request.args.get('type') or 'all').strip().lower() export_type = str(request.args.get('type') or 'all').strip().lower()
if export_format not in {'csv', 'json'}: if export_format not in {'csv', 'json'}:
return jsonify({'error': 'format must be csv or json'}), 400 return api_error('format must be csv or json', 400)
if export_type not in {'messages', 'snapshots', 'sessions', 'all'}: if export_type not in {'messages', 'snapshots', 'sessions', 'all'}:
return jsonify({'error': 'type must be messages, snapshots, sessions, or all'}), 400 return api_error('type must be messages, snapshots, sessions, or all', 400)
scope, since_minutes, start, end = _parse_export_scope(request.args) scope, since_minutes, start, end = _parse_export_scope(request.args)
icao = (request.args.get('icao') or '').strip().upper() icao = (request.args.get('icao') or '').strip().upper()
@@ -1501,7 +1503,7 @@ def adsb_history_export():
sessions = cur.fetchall() sessions = cur.fetchall()
except Exception as exc: except Exception as exc:
logger.warning("ADS-B history export failed: %s", exc) logger.warning("ADS-B history export failed: %s", exc)
return jsonify({'error': 'History database unavailable'}), 503 return api_error('History database unavailable', 503)
exported_at = datetime.now(timezone.utc).isoformat() exported_at = datetime.now(timezone.utc).isoformat()
timestamp = datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S') timestamp = datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')
@@ -1559,13 +1561,13 @@ def adsb_history_export():
def adsb_history_prune(): def adsb_history_prune():
"""Delete ADS-B history for a selected time range or entire dataset.""" """Delete ADS-B history for a selected time range or entire dataset."""
if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE: if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE:
return jsonify({'error': 'ADS-B history is disabled'}), 503 return api_error('ADS-B history is disabled', 503)
_ensure_history_schema() _ensure_history_schema()
payload = request.get_json(silent=True) or {} payload = request.get_json(silent=True) or {}
mode = str(payload.get('mode') or 'range').strip().lower() mode = str(payload.get('mode') or 'range').strip().lower()
if mode not in {'range', 'all'}: if mode not in {'range', 'all'}:
return jsonify({'error': 'mode must be range or all'}), 400 return api_error('mode must be range or all', 400)
try: try:
with _get_history_connection() as conn: with _get_history_connection() as conn:
@@ -1587,11 +1589,11 @@ def adsb_history_prune():
start = _parse_iso_datetime(payload.get('start')) start = _parse_iso_datetime(payload.get('start'))
end = _parse_iso_datetime(payload.get('end')) end = _parse_iso_datetime(payload.get('end'))
if start is None or end is None: if start is None or end is None:
return jsonify({'error': 'start and end ISO datetime values are required'}), 400 return api_error('start and end ISO datetime values are required', 400)
if end <= start: if end <= start:
return jsonify({'error': 'end must be after start'}), 400 return api_error('end must be after start', 400)
if end - start > timedelta(days=31): if end - start > timedelta(days=31):
return jsonify({'error': 'range cannot exceed 31 days'}), 400 return api_error('range cannot exceed 31 days', 400)
cur.execute( cur.execute(
""" """
@@ -1623,7 +1625,7 @@ def adsb_history_prune():
}) })
except Exception as exc: except Exception as exc:
logger.warning("ADS-B history prune failed: %s", exc) logger.warning("ADS-B history prune failed: %s", exc)
return jsonify({'error': 'History database unavailable'}), 503 return api_error('History database unavailable', 503)
# ============================================ # ============================================
@@ -1668,7 +1670,7 @@ def aircraft_photo(registration: str):
# Validate registration format (alphanumeric with dashes) # Validate registration format (alphanumeric with dashes)
if not registration or not all(c.isalnum() or c == '-' for c in registration): if not registration or not all(c.isalnum() or c == '-' for c in registration):
return jsonify({'error': 'Invalid registration'}), 400 return api_error('Invalid registration', 400)
try: try:
# Planespotters.net public API # Planespotters.net public API
@@ -1701,7 +1703,7 @@ def aircraft_photo(registration: str):
def get_aircraft_messages(icao: str): def get_aircraft_messages(icao: str):
"""Get correlated ACARS/VDL2 messages for an aircraft.""" """Get correlated ACARS/VDL2 messages for an aircraft."""
if not icao or not all(c in '0123456789ABCDEFabcdef' for c in icao): if not icao or not all(c in '0123456789ABCDEFabcdef' for c in icao):
return jsonify({'status': 'error', 'message': 'Invalid ICAO'}), 400 return api_error('Invalid ICAO', 400)
aircraft = app_module.adsb_aircraft.get(icao.upper()) aircraft = app_module.adsb_aircraft.get(icao.upper())
callsign = aircraft.get('callsign') if aircraft else None callsign = aircraft.get('callsign') if aircraft else None
@@ -1722,4 +1724,4 @@ def get_aircraft_messages(icao: str):
except Exception: except Exception:
pass pass
return jsonify({'status': 'success', 'icao': icao.upper(), **messages}) return api_success(data={'icao': icao.upper(), **messages})
+9 -15
View File
@@ -14,6 +14,7 @@ from typing import Generator
from flask import Blueprint, jsonify, request, Response, render_template from flask import Blueprint, jsonify, request, Response, render_template
from utils.responses import api_success, api_error
import app as app_module import app as app_module
from config import SHARED_OBSERVER_LOCATION_ENABLED from config import SHARED_OBSERVER_LOCATION_ENABLED
from utils.logging import get_logger from utils.logging import get_logger
@@ -361,7 +362,7 @@ def start_ais():
with app_module.ais_lock: with app_module.ais_lock:
if ais_running: if ais_running:
return jsonify({'status': 'already_running', 'message': 'AIS tracking already active'}), 409 return api_error('AIS tracking already active', 409)
data = request.json or {} data = request.json or {}
@@ -370,15 +371,12 @@ def start_ais():
gain = int(validate_gain(data.get('gain', '40'))) gain = int(validate_gain(data.get('gain', '40')))
device = validate_device_index(data.get('device', '0')) device = validate_device_index(data.get('device', '0'))
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
# Find AIS-catcher # Find AIS-catcher
ais_catcher_path = find_ais_catcher() ais_catcher_path = find_ais_catcher()
if not ais_catcher_path: if not ais_catcher_path:
return jsonify({ return api_error('AIS-catcher not found. Install from https://github.com/jvde-github/AIS-catcher/releases', 400)
'status': 'error',
'message': 'AIS-catcher not found. Install from https://github.com/jvde-github/AIS-catcher/releases'
}), 400
# Get SDR type from request # Get SDR type from request
sdr_type_str = data.get('sdr_type', 'rtlsdr') sdr_type_str = data.get('sdr_type', 'rtlsdr')
@@ -406,11 +404,7 @@ def start_ais():
device_int = int(device) device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'ais', sdr_type_str) error = app_module.claim_sdr_device(device_int, 'ais', sdr_type_str)
if error: if error:
return jsonify({ return api_error(error, 409, error_type='DEVICE_BUSY')
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
# Build command using SDR abstraction # Build command using SDR abstraction
sdr_device = SDRFactory.create_default_device(sdr_type, index=device) sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
@@ -455,7 +449,7 @@ def start_ais():
error_msg = 'AIS-catcher failed to start. Check SDR device connection.' error_msg = 'AIS-catcher failed to start. Check SDR device connection.'
if stderr_output: if stderr_output:
error_msg += f' Error: {stderr_output[:500]}' error_msg += f' Error: {stderr_output[:500]}'
return jsonify({'status': 'error', 'message': error_msg}), 500 return api_error(error_msg, 500)
ais_running = True ais_running = True
ais_active_device = device ais_active_device = device
@@ -475,7 +469,7 @@ def start_ais():
# Release device on failure # Release device on failure
app_module.release_sdr_device(device_int, sdr_type_str) app_module.release_sdr_device(device_int, sdr_type_str)
logger.error(f"Failed to start AIS-catcher: {e}") logger.error(f"Failed to start AIS-catcher: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500 return api_error(str(e), 500)
@ais_bp.route('/stop', methods=['POST']) @ais_bp.route('/stop', methods=['POST'])
@@ -535,7 +529,7 @@ def stream_ais():
def get_vessel_dsc(mmsi: str): def get_vessel_dsc(mmsi: str):
"""Get DSC messages associated with a vessel MMSI.""" """Get DSC messages associated with a vessel MMSI."""
if not mmsi or not mmsi.isdigit(): if not mmsi or not mmsi.isdigit():
return jsonify({'status': 'error', 'message': 'Invalid MMSI'}), 400 return api_error('Invalid MMSI', 400)
matches = [] matches = []
try: try:
@@ -545,7 +539,7 @@ def get_vessel_dsc(mmsi: str):
except Exception: except Exception:
pass pass
return jsonify({'status': 'success', 'mmsi': mmsi, 'dsc_messages': matches}) return api_success(data={'mmsi': mmsi, 'dsc_messages': matches})
@ais_bp.route('/dashboard') @ais_bp.route('/dashboard')
+9 -8
View File
@@ -9,6 +9,7 @@ from typing import Generator
from flask import Blueprint, Response, jsonify, request from flask import Blueprint, Response, jsonify, request
from utils.alerts import get_alert_manager from utils.alerts import get_alert_manager
from utils.responses import api_success, api_error
from utils.sse import format_sse from utils.sse import format_sse
alerts_bp = Blueprint('alerts', __name__, url_prefix='/alerts') alerts_bp = Blueprint('alerts', __name__, url_prefix='/alerts')
@@ -18,18 +19,18 @@ alerts_bp = Blueprint('alerts', __name__, url_prefix='/alerts')
def list_rules(): def list_rules():
manager = get_alert_manager() manager = get_alert_manager()
include_disabled = request.args.get('all') in ('1', 'true', 'yes') include_disabled = request.args.get('all') in ('1', 'true', 'yes')
return jsonify({'status': 'success', 'rules': manager.list_rules(include_disabled=include_disabled)}) return api_success(data={'rules': manager.list_rules(include_disabled=include_disabled)})
@alerts_bp.route('/rules', methods=['POST']) @alerts_bp.route('/rules', methods=['POST'])
def create_rule(): def create_rule():
data = request.get_json() or {} data = request.get_json() or {}
if not isinstance(data.get('match', {}), dict): if not isinstance(data.get('match', {}), dict):
return jsonify({'status': 'error', 'message': 'match must be a JSON object'}), 400 return api_error('match must be a JSON object', 400)
manager = get_alert_manager() manager = get_alert_manager()
rule_id = manager.add_rule(data) rule_id = manager.add_rule(data)
return jsonify({'status': 'success', 'rule_id': rule_id}) return api_success(data={'rule_id': rule_id})
@alerts_bp.route('/rules/<int:rule_id>', methods=['PUT', 'PATCH']) @alerts_bp.route('/rules/<int:rule_id>', methods=['PUT', 'PATCH'])
@@ -38,8 +39,8 @@ def update_rule(rule_id: int):
manager = get_alert_manager() manager = get_alert_manager()
ok = manager.update_rule(rule_id, data) ok = manager.update_rule(rule_id, data)
if not ok: if not ok:
return jsonify({'status': 'error', 'message': 'Rule not found or no changes'}), 404 return api_error('Rule not found or no changes', 404)
return jsonify({'status': 'success'}) return api_success()
@alerts_bp.route('/rules/<int:rule_id>', methods=['DELETE']) @alerts_bp.route('/rules/<int:rule_id>', methods=['DELETE'])
@@ -47,8 +48,8 @@ def delete_rule(rule_id: int):
manager = get_alert_manager() manager = get_alert_manager()
ok = manager.delete_rule(rule_id) ok = manager.delete_rule(rule_id)
if not ok: if not ok:
return jsonify({'status': 'error', 'message': 'Rule not found'}), 404 return api_error('Rule not found', 404)
return jsonify({'status': 'success'}) return api_success()
@alerts_bp.route('/events', methods=['GET']) @alerts_bp.route('/events', methods=['GET'])
@@ -58,7 +59,7 @@ def list_events():
mode = request.args.get('mode') mode = request.args.get('mode')
severity = request.args.get('severity') severity = request.args.get('severity')
events = manager.list_events(limit=limit, mode=mode, severity=severity) events = manager.list_events(limit=limit, mode=mode, severity=severity)
return jsonify({'status': 'success', 'events': events}) return api_success(data={'events': events})
@alerts_bp.route('/stream', methods=['GET']) @alerts_bp.route('/stream', methods=['GET'])
+22 -57
View File
@@ -20,6 +20,7 @@ from typing import Any, Generator, Optional
from flask import Blueprint, jsonify, request, Response from flask import Blueprint, jsonify, request, Response
from utils.responses import api_success, api_error
import app as app_module import app as app_module
from utils.logging import sensor_logger as logger from utils.logging import sensor_logger as logger
from utils.validation import ( from utils.validation import (
@@ -1651,8 +1652,7 @@ def aprs_data() -> Response:
if app_module.aprs_process: if app_module.aprs_process:
running = app_module.aprs_process.poll() is None running = app_module.aprs_process.poll() is None
return jsonify({ return api_success(data={
'status': 'success',
'running': running, 'running': running,
'stations': list(aprs_stations.values()), 'stations': list(aprs_stations.values()),
'count': len(aprs_stations), 'count': len(aprs_stations),
@@ -1670,20 +1670,14 @@ def start_aprs() -> Response:
with app_module.aprs_lock: with app_module.aprs_lock:
if app_module.aprs_process and app_module.aprs_process.poll() is None: if app_module.aprs_process and app_module.aprs_process.poll() is None:
return jsonify({ return api_error('APRS decoder already running', 409)
'status': 'error',
'message': 'APRS decoder already running'
}), 409
# Check for decoder (prefer direwolf, fallback to multimon-ng) # Check for decoder (prefer direwolf, fallback to multimon-ng)
direwolf_path = find_direwolf() direwolf_path = find_direwolf()
multimon_path = find_multimon_ng() multimon_path = find_multimon_ng()
if not direwolf_path and not multimon_path: if not direwolf_path and not multimon_path:
return jsonify({ return api_error('No APRS decoder found. Install direwolf or multimon-ng', 400)
'status': 'error',
'message': 'No APRS decoder found. Install direwolf or multimon-ng'
}), 400
data = request.json or {} data = request.json or {}
@@ -1693,7 +1687,7 @@ def start_aprs() -> Response:
gain = validate_gain(data.get('gain', '40')) gain = validate_gain(data.get('gain', '40'))
ppm = validate_ppm(data.get('ppm', '0')) ppm = validate_ppm(data.get('ppm', '0'))
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
# Check for rtl_tcp (remote SDR) connection # Check for rtl_tcp (remote SDR) connection
rtl_tcp_host = data.get('rtl_tcp_host') rtl_tcp_host = data.get('rtl_tcp_host')
@@ -1707,26 +1701,16 @@ def start_aprs() -> Response:
if sdr_type == SDRType.RTL_SDR: if sdr_type == SDRType.RTL_SDR:
if find_rtl_fm() is None: if find_rtl_fm() is None:
return jsonify({ return api_error('rtl_fm not found. Install with: sudo apt install rtl-sdr', 400)
'status': 'error',
'message': 'rtl_fm not found. Install with: sudo apt install rtl-sdr'
}), 400
else: else:
if find_rx_fm() is None: if find_rx_fm() is None:
return jsonify({ return api_error(f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.', 400)
'status': 'error',
'message': f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.'
}), 400
# Reserve SDR device to prevent conflicts (skip for remote rtl_tcp) # Reserve SDR device to prevent conflicts (skip for remote rtl_tcp)
if not rtl_tcp_host: if not rtl_tcp_host:
error = app_module.claim_sdr_device(device, 'aprs', sdr_type_str) error = app_module.claim_sdr_device(device, 'aprs', sdr_type_str)
if error: if error:
return jsonify({ return api_error(error, 409, error_type='DEVICE_BUSY')
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
aprs_active_device = device aprs_active_device = device
aprs_active_sdr_type = sdr_type_str aprs_active_sdr_type = sdr_type_str
@@ -1757,7 +1741,7 @@ def start_aprs() -> Response:
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host) rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port) rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port) sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}") logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
else: else:
@@ -1782,7 +1766,7 @@ def start_aprs() -> Response:
app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
aprs_active_device = None aprs_active_device = None
aprs_active_sdr_type = None aprs_active_sdr_type = None
return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500 return api_error(f'Failed to build SDR command: {e}', 500)
# Build decoder command # Build decoder command
if direwolf_path: if direwolf_path:
@@ -1888,7 +1872,7 @@ def start_aprs() -> Response:
app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
aprs_active_device = None aprs_active_device = None
aprs_active_sdr_type = None aprs_active_sdr_type = None
return jsonify({'status': 'error', 'message': error_msg}), 500 return api_error(error_msg, 500)
if decoder_process.poll() is not None: if decoder_process.poll() is not None:
# Decoder exited early - capture any output from PTY # Decoder exited early - capture any output from PTY
@@ -1916,7 +1900,7 @@ def start_aprs() -> Response:
app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
aprs_active_device = None aprs_active_device = None
aprs_active_sdr_type = None aprs_active_sdr_type = None
return jsonify({'status': 'error', 'message': error_msg}), 500 return api_error(error_msg, 500)
# Store references for status checks and cleanup # Store references for status checks and cleanup
app_module.aprs_process = decoder_process app_module.aprs_process = decoder_process
@@ -1946,7 +1930,7 @@ def start_aprs() -> Response:
app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
aprs_active_device = None aprs_active_device = None
aprs_active_sdr_type = None aprs_active_sdr_type = None
return jsonify({'status': 'error', 'message': str(e)}), 500 return api_error(str(e), 500)
@aprs_bp.route('/stop', methods=['POST']) @aprs_bp.route('/stop', methods=['POST'])
@@ -1964,10 +1948,7 @@ def stop_aprs() -> Response:
processes_to_stop.append(app_module.aprs_process) processes_to_stop.append(app_module.aprs_process)
if not processes_to_stop: if not processes_to_stop:
return jsonify({ return api_error('APRS decoder not running', 400)
'status': 'error',
'message': 'APRS decoder not running'
}), 400
for proc in processes_to_stop: for proc in processes_to_stop:
try: try:
@@ -2045,10 +2026,7 @@ def scan_aprs_spectrum() -> Response:
""" """
rtl_power_path = find_rtl_power() rtl_power_path = find_rtl_power()
if not rtl_power_path: if not rtl_power_path:
return jsonify({ return api_error('rtl_power not found. Install with: sudo apt install rtl-sdr', 400)
'status': 'error',
'message': 'rtl_power not found. Install with: sudo apt install rtl-sdr'
}), 400
# Get parameters from JSON body or query args # Get parameters from JSON body or query args
if request.is_json: if request.is_json:
@@ -2068,7 +2046,7 @@ def scan_aprs_spectrum() -> Response:
gain = validate_gain(gain) gain = validate_gain(gain)
duration = min(max(int(duration), 5), 60) # Clamp 5-60 seconds duration = min(max(int(duration), 5), 60) # Clamp 5-60 seconds
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
# Get center frequency # Get center frequency
if frequency: if frequency:
@@ -2113,18 +2091,12 @@ def scan_aprs_spectrum() -> Response:
if result.returncode != 0: if result.returncode != 0:
error_msg = result.stderr[:200] if result.stderr else f'Exit code {result.returncode}' error_msg = result.stderr[:200] if result.stderr else f'Exit code {result.returncode}'
return jsonify({ return api_error(f'rtl_power failed: {error_msg}', 500)
'status': 'error',
'message': f'rtl_power failed: {error_msg}'
}), 500
# Parse rtl_power CSV output # Parse rtl_power CSV output
# Format: date, time, start_hz, end_hz, step_hz, samples, db1, db2, db3, ... # Format: date, time, start_hz, end_hz, step_hz, samples, db1, db2, db3, ...
if not os.path.exists(tmp_file): if not os.path.exists(tmp_file):
return jsonify({ return api_error('rtl_power did not produce output file', 500)
'status': 'error',
'message': 'rtl_power did not produce output file'
}), 500
bins = [] bins = []
with open(tmp_file, 'r') as f: with open(tmp_file, 'r') as f:
@@ -2144,10 +2116,7 @@ def scan_aprs_spectrum() -> Response:
continue continue
if not bins: if not bins:
return jsonify({ return api_error('No spectrum data collected. Check SDR connection and antenna.', 500)
'status': 'error',
'message': 'No spectrum data collected. Check SDR connection and antenna.'
}), 500
# Calculate statistics # Calculate statistics
db_values = [b['db'] for b in bins] db_values = [b['db'] for b in bins]
@@ -2177,8 +2146,7 @@ def scan_aprs_spectrum() -> Response:
else: else:
advice = "Good signal detected. Decoding should work well." advice = "Good signal detected. Decoding should work well."
return jsonify({ return api_success(data={
'status': 'success',
'scan_params': { 'scan_params': {
'center_freq_mhz': center_freq_mhz, 'center_freq_mhz': center_freq_mhz,
'start_freq_mhz': start_freq_mhz, 'start_freq_mhz': start_freq_mhz,
@@ -2204,13 +2172,10 @@ def scan_aprs_spectrum() -> Response:
}) })
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
return jsonify({ return api_error(f'Spectrum scan timed out after {duration + 15} seconds', 500)
'status': 'error',
'message': f'Spectrum scan timed out after {duration + 15} seconds'
}), 500
except Exception as e: except Exception as e:
logger.error(f"Spectrum scan error: {e}") logger.error(f"Spectrum scan error: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500 return api_error(str(e), 500)
finally: finally:
# Cleanup temp file # Cleanup temp file
try: try:
+57 -40
View File
@@ -17,12 +17,13 @@ from typing import Any, Generator
from flask import Blueprint, jsonify, request, Response from flask import Blueprint, jsonify, request, Response
from utils.responses import api_success, api_error
import app as app_module import app as app_module
from utils.dependencies import check_tool from utils.dependencies import check_tool
from utils.logging import bluetooth_logger as logger from utils.logging import bluetooth_logger as logger
from utils.sse import sse_stream_fanout from utils.sse import sse_stream_fanout
from utils.event_pipeline import process_event from utils.event_pipeline import process_event
from utils.validation import validate_bluetooth_interface from utils.validation import validate_bluetooth_interface
from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer
from data.patterns import AIRTAG_PREFIXES, TILE_PREFIXES, SAMSUNG_TRACKER from data.patterns import AIRTAG_PREFIXES, TILE_PREFIXES, SAMSUNG_TRACKER
from utils.constants import ( from utils.constants import (
@@ -39,6 +40,23 @@ from utils.constants import (
bluetooth_bp = Blueprint('bluetooth', __name__, url_prefix='/bt') bluetooth_bp = Blueprint('bluetooth', __name__, url_prefix='/bt')
# --- v1 deprecation ---
# These endpoints are deprecated in favor of /api/bluetooth/*.
# Frontend still uses v1, so they remain active.
# Migration: switch frontend to v2 endpoints, then remove this file.
_v1_deprecation_logged = set()
@bluetooth_bp.after_request
def _add_deprecation_header(response):
"""Add X-Deprecated header to all v1 Bluetooth responses."""
response.headers['X-Deprecated'] = 'Use /api/bluetooth/* endpoints instead'
endpoint = request.endpoint or ''
if endpoint not in _v1_deprecation_logged:
_v1_deprecation_logged.add(endpoint)
logger.warning(f"Deprecated v1 Bluetooth endpoint called: {request.path} — migrate to /api/bluetooth/*")
return response
def classify_bt_device(name, device_class, services, manufacturer=None): def classify_bt_device(name, device_class, services, manufacturer=None):
"""Classify Bluetooth device type based on available info.""" """Classify Bluetooth device type based on available info."""
@@ -331,8 +349,8 @@ def reload_oui_database_route():
if new_db: if new_db:
OUI_DATABASE.clear() OUI_DATABASE.clear()
OUI_DATABASE.update(new_db) OUI_DATABASE.update(new_db)
return jsonify({'status': 'success', 'entries': len(OUI_DATABASE)}) return api_success(data={'entries': len(OUI_DATABASE)})
return jsonify({'status': 'error', 'message': 'Could not load oui_database.json'}) return api_error('Could not load oui_database.json')
@bluetooth_bp.route('/interfaces') @bluetooth_bp.route('/interfaces')
@@ -359,7 +377,7 @@ def start_bt_scan():
with app_module.bt_lock: with app_module.bt_lock:
if app_module.bt_process: if app_module.bt_process:
if app_module.bt_process.poll() is None: if app_module.bt_process.poll() is None:
return jsonify({'status': 'error', 'message': 'Scan already running'}) return api_error('Scan already running')
else: else:
app_module.bt_process = None app_module.bt_process = None
@@ -371,7 +389,7 @@ def start_bt_scan():
try: try:
interface = validate_bluetooth_interface(data.get('interface', 'hci0')) interface = validate_bluetooth_interface(data.get('interface', 'hci0'))
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
app_module.bt_interface = interface app_module.bt_interface = interface
app_module.bt_devices = {} app_module.bt_devices = {}
@@ -413,14 +431,14 @@ def start_bt_scan():
os.write(master_fd, b'scan on\n') os.write(master_fd, b'scan on\n')
else: else:
return jsonify({'status': 'error', 'message': f'Unknown scan mode: {scan_mode}'}) return api_error(f'Unknown scan mode: {scan_mode}')
time.sleep(0.5) time.sleep(0.5)
if app_module.bt_process.poll() is not None: if app_module.bt_process.poll() is not None:
stderr_output = app_module.bt_process.stderr.read().decode('utf-8', errors='replace').strip() stderr_output = app_module.bt_process.stderr.read().decode('utf-8', errors='replace').strip()
app_module.bt_process = None app_module.bt_process = None
return jsonify({'status': 'error', 'message': stderr_output or 'Process failed to start'}) return api_error(stderr_output or 'Process failed to start')
thread = threading.Thread(target=stream_bt_scan, args=(app_module.bt_process, scan_mode)) thread = threading.Thread(target=stream_bt_scan, args=(app_module.bt_process, scan_mode))
thread.daemon = True thread.daemon = True
@@ -430,9 +448,9 @@ def start_bt_scan():
return jsonify({'status': 'started', 'mode': scan_mode, 'interface': interface}) return jsonify({'status': 'started', 'mode': scan_mode, 'interface': interface})
except FileNotFoundError as e: except FileNotFoundError as e:
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'}) return api_error(f'Tool not found: {e.filename}')
except Exception as e: except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}) return api_error(str(e))
@bluetooth_bp.route('/scan/stop', methods=['POST']) @bluetooth_bp.route('/scan/stop', methods=['POST'])
@@ -459,7 +477,7 @@ def reset_bt_adapter():
try: try:
interface = validate_bluetooth_interface(data.get('interface', 'hci0')) interface = validate_bluetooth_interface(data.get('interface', 'hci0'))
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
with app_module.bt_lock: with app_module.bt_lock:
if app_module.bt_process: if app_module.bt_process:
@@ -494,7 +512,7 @@ def reset_bt_adapter():
}) })
except Exception as e: except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}) return api_error(str(e))
@bluetooth_bp.route('/enum', methods=['POST']) @bluetooth_bp.route('/enum', methods=['POST'])
@@ -504,7 +522,7 @@ def enum_bt_services():
target_mac = data.get('mac') target_mac = data.get('mac')
if not target_mac: if not target_mac:
return jsonify({'status': 'error', 'message': 'Target MAC required'}) return api_error('Target MAC required')
try: try:
result = subprocess.run( result = subprocess.run(
@@ -529,18 +547,17 @@ def enum_bt_services():
app_module.bt_services[target_mac] = services app_module.bt_services[target_mac] = services
return jsonify({ return api_success(data={
'status': 'success',
'mac': target_mac, 'mac': target_mac,
'services': services 'services': services
}) })
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
return jsonify({'status': 'error', 'message': 'Connection timed out'}) return api_error('Connection timed out')
except FileNotFoundError: except FileNotFoundError:
return jsonify({'status': 'error', 'message': 'sdptool not found'}) return api_error('sdptool not found')
except Exception as e: except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}) return api_error(str(e))
@bluetooth_bp.route('/devices') @bluetooth_bp.route('/devices')
@@ -553,23 +570,23 @@ def get_bt_devices():
}) })
@bluetooth_bp.route('/stream') @bluetooth_bp.route('/stream')
def stream_bt(): def stream_bt():
"""SSE stream for Bluetooth events.""" """SSE stream for Bluetooth events."""
def _on_msg(msg: dict[str, Any]) -> None: def _on_msg(msg: dict[str, Any]) -> None:
process_event('bluetooth', msg, msg.get('type')) process_event('bluetooth', msg, msg.get('type'))
response = Response( response = Response(
sse_stream_fanout( sse_stream_fanout(
source_queue=app_module.bt_queue, source_queue=app_module.bt_queue,
channel_key='bluetooth', channel_key='bluetooth',
timeout=1.0, timeout=1.0,
keepalive_interval=30.0, keepalive_interval=30.0,
on_message=_on_msg, on_message=_on_msg,
), ),
mimetype='text/event-stream', mimetype='text/event-stream',
) )
response.headers['Cache-Control'] = 'no-cache' response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no' response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive' response.headers['Connection'] = 'keep-alive'
return response return response
+4 -3
View File
@@ -29,6 +29,7 @@ from utils.bluetooth import (
get_tracker_engine, get_tracker_engine,
) )
from utils.database import get_db from utils.database import get_db
from utils.responses import api_success, api_error
from utils.sse import format_sse from utils.sse import format_sse
from utils.event_pipeline import process_event from utils.event_pipeline import process_event
@@ -231,7 +232,7 @@ def start_scan():
# Validate mode # Validate mode
valid_modes = ('auto', 'dbus', 'bleak', 'hcitool', 'bluetoothctl', 'ubertooth') valid_modes = ('auto', 'dbus', 'bleak', 'hcitool', 'bluetoothctl', 'ubertooth')
if mode not in valid_modes: if mode not in valid_modes:
return jsonify({'error': f'Invalid mode. Must be one of: {valid_modes}'}), 400 return api_error(f'Invalid mode. Must be one of: {valid_modes}', 400)
# Get scanner instance # Get scanner instance
scanner = get_bluetooth_scanner(adapter_id) scanner = get_bluetooth_scanner(adapter_id)
@@ -389,7 +390,7 @@ def get_device(device_id: str):
device = scanner.get_device(device_id) device = scanner.get_device(device_id)
if not device: if not device:
return jsonify({'error': 'Device not found'}), 404 return api_error('Device not found', 404)
return jsonify(device.to_dict()) return jsonify(device.to_dict())
@@ -529,7 +530,7 @@ def get_tracker_detail(device_id: str):
device = scanner.get_device(device_id) device = scanner.get_device(device_id)
if not device: if not device:
return jsonify({'error': 'Device not found'}), 404 return api_error('Device not found', 404)
# Get RSSI history for timeline # Get RSSI history for timeline
rssi_history = device.get_rssi_history(max_points=100) rssi_history = device.get_rssi_history(max_points=100)
+72 -78
View File
@@ -12,6 +12,7 @@ from collections.abc import Generator
from flask import Blueprint, Response, jsonify, request from flask import Blueprint, Response, jsonify, request
from utils.responses import api_success, api_error
from utils.bluetooth.irk_extractor import get_paired_irks from utils.bluetooth.irk_extractor import get_paired_irks
from utils.bt_locate import ( from utils.bt_locate import (
Environment, Environment,
@@ -33,18 +34,18 @@ def start_session():
""" """
Start a locate session. Start a locate session.
Request JSON: Request JSON:
- mac_address: Target MAC address (optional) - mac_address: Target MAC address (optional)
- name_pattern: Target name substring (optional) - name_pattern: Target name substring (optional)
- irk_hex: Identity Resolving Key hex string (optional) - irk_hex: Identity Resolving Key hex string (optional)
- device_id: Device ID from Bluetooth scanner (optional) - device_id: Device ID from Bluetooth scanner (optional)
- device_key: Stable device key from Bluetooth scanner (optional) - device_key: Stable device key from Bluetooth scanner (optional)
- fingerprint_id: Payload fingerprint ID from Bluetooth scanner (optional) - fingerprint_id: Payload fingerprint ID from Bluetooth scanner (optional)
- known_name: Hand-off device name (optional) - known_name: Hand-off device name (optional)
- known_manufacturer: Hand-off manufacturer (optional) - known_manufacturer: Hand-off manufacturer (optional)
- last_known_rssi: Hand-off last RSSI (optional) - last_known_rssi: Hand-off last RSSI (optional)
- environment: 'FREE_SPACE', 'OUTDOOR', 'INDOOR', 'CUSTOM' (default: OUTDOOR) - environment: 'FREE_SPACE', 'OUTDOOR', 'INDOOR', 'CUSTOM' (default: OUTDOOR)
- custom_exponent: Path loss exponent for CUSTOM environment (optional) - custom_exponent: Path loss exponent for CUSTOM environment (optional)
Returns: Returns:
JSON with session status. JSON with session status.
@@ -52,47 +53,46 @@ def start_session():
data = request.get_json() or {} data = request.get_json() or {}
# Build target # Build target
target = LocateTarget( target = LocateTarget(
mac_address=data.get('mac_address'), mac_address=data.get('mac_address'),
name_pattern=data.get('name_pattern'), name_pattern=data.get('name_pattern'),
irk_hex=data.get('irk_hex'), irk_hex=data.get('irk_hex'),
device_id=data.get('device_id'), device_id=data.get('device_id'),
device_key=data.get('device_key'), device_key=data.get('device_key'),
fingerprint_id=data.get('fingerprint_id'), fingerprint_id=data.get('fingerprint_id'),
known_name=data.get('known_name'), known_name=data.get('known_name'),
known_manufacturer=data.get('known_manufacturer'), known_manufacturer=data.get('known_manufacturer'),
last_known_rssi=data.get('last_known_rssi'), last_known_rssi=data.get('last_known_rssi'),
) )
# At least one identifier required # At least one identifier required
if not any([ if not any([
target.mac_address, target.mac_address,
target.name_pattern, target.name_pattern,
target.irk_hex, target.irk_hex,
target.device_id, target.device_id,
target.device_key, target.device_key,
target.fingerprint_id, target.fingerprint_id,
]): ]):
return jsonify({ return api_error(
'error': ( 'At least one target identifier required '
'At least one target identifier required ' '(mac_address, name_pattern, irk_hex, device_id, device_key, or fingerprint_id)',
'(mac_address, name_pattern, irk_hex, device_id, device_key, or fingerprint_id)' 400
) )
}), 400
# Parse environment # Parse environment
env_str = data.get('environment', 'OUTDOOR').upper() env_str = data.get('environment', 'OUTDOOR').upper()
try: try:
environment = Environment[env_str] environment = Environment[env_str]
except KeyError: except KeyError:
return jsonify({'error': f'Invalid environment: {env_str}'}), 400 return api_error(f'Invalid environment: {env_str}', 400)
custom_exponent = data.get('custom_exponent') custom_exponent = data.get('custom_exponent')
if custom_exponent is not None: if custom_exponent is not None:
try: try:
custom_exponent = float(custom_exponent) custom_exponent = float(custom_exponent)
except (ValueError, TypeError): except (ValueError, TypeError):
return jsonify({'error': 'custom_exponent must be a number'}), 400 return api_error('custom_exponent must be a number', 400)
# Fallback coordinates when GPS is unavailable (from user settings) # Fallback coordinates when GPS is unavailable (from user settings)
fallback_lat = None fallback_lat = None
@@ -109,27 +109,21 @@ def start_session():
f"env={environment.name}, fallback=({fallback_lat}, {fallback_lon})" f"env={environment.name}, fallback=({fallback_lat}, {fallback_lon})"
) )
try: try:
session = start_locate_session( session = start_locate_session(
target, environment, custom_exponent, fallback_lat, fallback_lon target, environment, custom_exponent, fallback_lat, fallback_lon
) )
except RuntimeError as exc: except RuntimeError as exc:
logger.warning(f"Unable to start BT Locate session: {exc}") logger.warning(f"Unable to start BT Locate session: {exc}")
return jsonify({ return api_error('Bluetooth scanner could not be started. Check adapter permissions/capabilities.', 503)
'status': 'error', except Exception as exc:
'error': 'Bluetooth scanner could not be started. Check adapter permissions/capabilities.', logger.exception(f"Unexpected error starting BT Locate session: {exc}")
}), 503 return api_error('Failed to start locate session', 500)
except Exception as exc:
logger.exception(f"Unexpected error starting BT Locate session: {exc}") return jsonify({
return jsonify({ 'status': 'started',
'status': 'error', 'session': session.get_status(),
'error': 'Failed to start locate session', })
}), 500
return jsonify({
'status': 'started',
'session': session.get_status(),
})
@bt_locate_bp.route('/stop', methods=['POST']) @bt_locate_bp.route('/stop', methods=['POST'])
@@ -143,18 +137,18 @@ def stop_session():
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
@bt_locate_bp.route('/status', methods=['GET']) @bt_locate_bp.route('/status', methods=['GET'])
def get_status(): def get_status():
"""Get locate session status.""" """Get locate session status."""
session = get_locate_session() session = get_locate_session()
if not session: if not session:
return jsonify({ return jsonify({
'active': False, 'active': False,
'target': None, 'target': None,
}) })
include_debug = str(request.args.get('debug', '')).lower() in ('1', 'true', 'yes') include_debug = str(request.args.get('debug', '')).lower() in ('1', 'true', 'yes')
return jsonify(session.get_status(include_debug=include_debug)) return jsonify(session.get_status(include_debug=include_debug))
@bt_locate_bp.route('/trail', methods=['GET']) @bt_locate_bp.route('/trail', methods=['GET'])
@@ -216,15 +210,15 @@ def test_resolve_rpa():
address = data.get('address', '') address = data.get('address', '')
if not irk_hex or not address: if not irk_hex or not address:
return jsonify({'error': 'irk_hex and address are required'}), 400 return api_error('irk_hex and address are required', 400)
try: try:
irk = bytes.fromhex(irk_hex) irk = bytes.fromhex(irk_hex)
except ValueError: except ValueError:
return jsonify({'error': 'Invalid IRK hex string'}), 400 return api_error('Invalid IRK hex string', 400)
if len(irk) != 16: if len(irk) != 16:
return jsonify({'error': 'IRK must be exactly 16 bytes (32 hex characters)'}), 400 return api_error('IRK must be exactly 16 bytes (32 hex characters)', 400)
result = resolve_rpa(irk, address) result = resolve_rpa(irk, address)
return jsonify({ return jsonify({
@@ -239,14 +233,14 @@ def set_environment():
"""Update the environment on the active session.""" """Update the environment on the active session."""
session = get_locate_session() session = get_locate_session()
if not session: if not session:
return jsonify({'error': 'no active session'}), 400 return api_error('no active session', 400)
data = request.get_json() or {} data = request.get_json() or {}
env_str = data.get('environment', '').upper() env_str = data.get('environment', '').upper()
try: try:
environment = Environment[env_str] environment = Environment[env_str]
except KeyError: except KeyError:
return jsonify({'error': f'Invalid environment: {env_str}'}), 400 return api_error(f'Invalid environment: {env_str}', 400)
custom_exponent = data.get('custom_exponent') custom_exponent = data.get('custom_exponent')
if custom_exponent is not None: if custom_exponent is not None:
@@ -268,11 +262,11 @@ def debug_matching():
"""Debug endpoint showing scanner devices and match results.""" """Debug endpoint showing scanner devices and match results."""
session = get_locate_session() session = get_locate_session()
if not session: if not session:
return jsonify({'error': 'no session'}) return api_error('no session')
scanner = session._scanner scanner = session._scanner
if not scanner: if not scanner:
return jsonify({'error': 'no scanner'}) return api_error('no scanner')
devices = scanner.get_devices(max_age_seconds=30) devices = scanner.get_devices(max_age_seconds=30)
return jsonify({ return jsonify({
+159 -200
View File
@@ -10,18 +10,19 @@ This blueprint provides:
from __future__ import annotations from __future__ import annotations
import json import json
import logging import logging
import queue import queue
import threading import threading
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Generator from typing import Generator
import requests
from flask import Blueprint, jsonify, request, Response
import requests
from flask import Blueprint, jsonify, request, Response
from utils.responses import api_success, api_error
from utils.database import ( from utils.database import (
create_agent, get_agent, get_agent_by_name, list_agents, create_agent, get_agent, get_agent_by_name, list_agents,
update_agent, delete_agent, store_push_payload, get_recent_payloads update_agent, delete_agent, store_push_payload, get_recent_payloads
@@ -37,28 +38,28 @@ from utils.trilateration import (
logger = logging.getLogger('intercept.controller') logger = logging.getLogger('intercept.controller')
controller_bp = Blueprint('controller', __name__, url_prefix='/controller') controller_bp = Blueprint('controller', __name__, url_prefix='/controller')
# Multi-agent SSE fanout state (per-client queues). # Multi-agent SSE fanout state (per-client queues).
_agent_stream_subscribers: set[queue.Queue] = set() _agent_stream_subscribers: set[queue.Queue] = set()
_agent_stream_subscribers_lock = threading.Lock() _agent_stream_subscribers_lock = threading.Lock()
_AGENT_STREAM_CLIENT_QUEUE_SIZE = 500 _AGENT_STREAM_CLIENT_QUEUE_SIZE = 500
def _broadcast_agent_data(payload: dict) -> None: def _broadcast_agent_data(payload: dict) -> None:
"""Fan out an ingested payload to all active /controller/stream/all clients.""" """Fan out an ingested payload to all active /controller/stream/all clients."""
with _agent_stream_subscribers_lock: with _agent_stream_subscribers_lock:
subscribers = tuple(_agent_stream_subscribers) subscribers = tuple(_agent_stream_subscribers)
for subscriber in subscribers: for subscriber in subscribers:
try: try:
subscriber.put_nowait(payload) subscriber.put_nowait(payload)
except queue.Full: except queue.Full:
try: try:
subscriber.get_nowait() subscriber.get_nowait()
subscriber.put_nowait(payload) subscriber.put_nowait(payload)
except (queue.Empty, queue.Full): except (queue.Empty, queue.Full):
continue continue
# ============================================================================= # =============================================================================
@@ -108,28 +109,25 @@ def register_agent():
base_url = data.get('base_url', '').strip() base_url = data.get('base_url', '').strip()
if not name: if not name:
return jsonify({'status': 'error', 'message': 'Agent name is required'}), 400 return api_error('Agent name is required', 400)
if not base_url: if not base_url:
return jsonify({'status': 'error', 'message': 'Base URL is required'}), 400 return api_error('Base URL is required', 400)
# Validate URL format # Validate URL format
from urllib.parse import urlparse from urllib.parse import urlparse
try: try:
parsed = urlparse(base_url) parsed = urlparse(base_url)
if parsed.scheme not in ('http', 'https'): if parsed.scheme not in ('http', 'https'):
return jsonify({'status': 'error', 'message': 'URL must start with http:// or https://'}), 400 return api_error('URL must start with http:// or https://', 400)
if not parsed.netloc: if not parsed.netloc:
return jsonify({'status': 'error', 'message': 'Invalid URL format'}), 400 return api_error('Invalid URL format', 400)
except Exception: except Exception:
return jsonify({'status': 'error', 'message': 'Invalid URL format'}), 400 return api_error('Invalid URL format', 400)
# Check if agent already exists # Check if agent already exists
existing = get_agent_by_name(name) existing = get_agent_by_name(name)
if existing: if existing:
return jsonify({ return api_error(f'Agent with name "{name}" already exists', 409)
'status': 'error',
'message': f'Agent with name "{name}" already exists'
}), 409
# Try to connect and get capabilities # Try to connect and get capabilities
api_key = data.get('api_key', '').strip() or None api_key = data.get('api_key', '').strip() or None
@@ -171,7 +169,7 @@ def register_agent():
except Exception as e: except Exception as e:
logger.exception("Failed to create agent") logger.exception("Failed to create agent")
return jsonify({'status': 'error', 'message': str(e)}), 500 return api_error(str(e), 500)
@controller_bp.route('/agents/<int:agent_id>', methods=['GET']) @controller_bp.route('/agents/<int:agent_id>', methods=['GET'])
@@ -179,7 +177,7 @@ def get_agent_detail(agent_id: int):
"""Get details of a specific agent.""" """Get details of a specific agent."""
agent = get_agent(agent_id) agent = get_agent(agent_id)
if not agent: if not agent:
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404 return api_error('Agent not found', 404)
# Optionally refresh from agent # Optionally refresh from agent
refresh = request.args.get('refresh', 'false').lower() == 'true' refresh = request.args.get('refresh', 'false').lower() == 'true'
@@ -215,7 +213,7 @@ def update_agent_detail(agent_id: int):
"""Update an agent's details.""" """Update an agent's details."""
agent = get_agent(agent_id) agent = get_agent(agent_id)
if not agent: if not agent:
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404 return api_error('Agent not found', 404)
data = request.json or {} data = request.json or {}
@@ -237,7 +235,7 @@ def remove_agent(agent_id: int):
"""Delete an agent.""" """Delete an agent."""
agent = get_agent(agent_id) agent = get_agent(agent_id)
if not agent: if not agent:
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404 return api_error('Agent not found', 404)
delete_agent(agent_id) delete_agent(agent_id)
return jsonify({'status': 'success', 'message': 'Agent deleted'}) return jsonify({'status': 'success', 'message': 'Agent deleted'})
@@ -248,7 +246,7 @@ def refresh_agent_metadata(agent_id: int):
"""Refresh an agent's capabilities and status.""" """Refresh an agent's capabilities and status."""
agent = get_agent(agent_id) agent = get_agent(agent_id)
if not agent: if not agent:
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404 return api_error('Agent not found', 404)
try: try:
client = create_client_from_agent(agent) client = create_client_from_agent(agent)
@@ -274,16 +272,10 @@ def refresh_agent_metadata(agent_id: int):
'metadata': metadata 'metadata': metadata
}) })
else: else:
return jsonify({ return api_error('Agent is not reachable', 503)
'status': 'error',
'message': 'Agent is not reachable'
}), 503
except (AgentHTTPError, AgentConnectionError) as e: except (AgentHTTPError, AgentConnectionError) as e:
return jsonify({ return api_error(f'Failed to reach agent: {e}', 503)
'status': 'error',
'message': f'Failed to reach agent: {e}'
}), 503
# ============================================================================= # =============================================================================
@@ -295,7 +287,7 @@ def get_agent_status(agent_id: int):
"""Get an agent's current status including running modes.""" """Get an agent's current status including running modes."""
agent = get_agent(agent_id) agent = get_agent(agent_id)
if not agent: if not agent:
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404 return api_error('Agent not found', 404)
try: try:
client = create_client_from_agent(agent) client = create_client_from_agent(agent)
@@ -307,10 +299,7 @@ def get_agent_status(agent_id: int):
'agent_status': status 'agent_status': status
}) })
except (AgentHTTPError, AgentConnectionError) as e: except (AgentHTTPError, AgentConnectionError) as e:
return jsonify({ return api_error(f'Failed to reach agent: {e}', 503)
'status': 'error',
'message': f'Failed to reach agent: {e}'
}), 503
@controller_bp.route('/agents/health', methods=['GET']) @controller_bp.route('/agents/health', methods=['GET'])
@@ -384,7 +373,7 @@ def proxy_start_mode(agent_id: int, mode: str):
"""Start a mode on a remote agent.""" """Start a mode on a remote agent."""
agent = get_agent(agent_id) agent = get_agent(agent_id)
if not agent: if not agent:
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404 return api_error('Agent not found', 404)
params = request.json or {} params = request.json or {}
@@ -403,15 +392,9 @@ def proxy_start_mode(agent_id: int, mode: str):
}) })
except AgentConnectionError as e: except AgentConnectionError as e:
return jsonify({ return api_error(f'Cannot connect to agent: {e}', 503)
'status': 'error',
'message': f'Cannot connect to agent: {e}'
}), 503
except AgentHTTPError as e: except AgentHTTPError as e:
return jsonify({ return api_error(f'Agent error: {e}', 502)
'status': 'error',
'message': f'Agent error: {e}'
}), 502
@controller_bp.route('/agents/<int:agent_id>/<mode>/stop', methods=['POST']) @controller_bp.route('/agents/<int:agent_id>/<mode>/stop', methods=['POST'])
@@ -419,7 +402,7 @@ def proxy_stop_mode(agent_id: int, mode: str):
"""Stop a mode on a remote agent.""" """Stop a mode on a remote agent."""
agent = get_agent(agent_id) agent = get_agent(agent_id)
if not agent: if not agent:
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404 return api_error('Agent not found', 404)
try: try:
client = create_client_from_agent(agent) client = create_client_from_agent(agent)
@@ -435,15 +418,9 @@ def proxy_stop_mode(agent_id: int, mode: str):
}) })
except AgentConnectionError as e: except AgentConnectionError as e:
return jsonify({ return api_error(f'Cannot connect to agent: {e}', 503)
'status': 'error',
'message': f'Cannot connect to agent: {e}'
}), 503
except AgentHTTPError as e: except AgentHTTPError as e:
return jsonify({ return api_error(f'Agent error: {e}', 502)
'status': 'error',
'message': f'Agent error: {e}'
}), 502
@controller_bp.route('/agents/<int:agent_id>/<mode>/status', methods=['GET']) @controller_bp.route('/agents/<int:agent_id>/<mode>/status', methods=['GET'])
@@ -451,7 +428,7 @@ def proxy_mode_status(agent_id: int, mode: str):
"""Get mode status from a remote agent.""" """Get mode status from a remote agent."""
agent = get_agent(agent_id) agent = get_agent(agent_id)
if not agent: if not agent:
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404 return api_error('Agent not found', 404)
try: try:
client = create_client_from_agent(agent) client = create_client_from_agent(agent)
@@ -465,18 +442,15 @@ def proxy_mode_status(agent_id: int, mode: str):
}) })
except (AgentHTTPError, AgentConnectionError) as e: except (AgentHTTPError, AgentConnectionError) as e:
return jsonify({ return api_error(f'Agent error: {e}', 502)
'status': 'error',
'message': f'Agent error: {e}'
}), 502
@controller_bp.route('/agents/<int:agent_id>/<mode>/data', methods=['GET']) @controller_bp.route('/agents/<int:agent_id>/<mode>/data', methods=['GET'])
def proxy_mode_data(agent_id: int, mode: str): def proxy_mode_data(agent_id: int, mode: str):
"""Get current data from a remote agent.""" """Get current data from a remote agent."""
agent = get_agent(agent_id) agent = get_agent(agent_id)
if not agent: if not agent:
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404 return api_error('Agent not found', 404)
try: try:
client = create_client_from_agent(agent) client = create_client_from_agent(agent)
@@ -494,60 +468,57 @@ def proxy_mode_data(agent_id: int, mode: str):
'data': result 'data': result
}) })
except (AgentHTTPError, AgentConnectionError) as e: except (AgentHTTPError, AgentConnectionError) as e:
return jsonify({ return api_error(f'Agent error: {e}', 502)
'status': 'error',
'message': f'Agent error: {e}'
}), 502 @controller_bp.route('/agents/<int:agent_id>/<mode>/stream')
def proxy_mode_stream(agent_id: int, mode: str):
"""Proxy SSE stream from a remote agent."""
@controller_bp.route('/agents/<int:agent_id>/<mode>/stream') agent = get_agent(agent_id)
def proxy_mode_stream(agent_id: int, mode: str): if not agent:
"""Proxy SSE stream from a remote agent.""" return api_error('Agent not found', 404)
agent = get_agent(agent_id)
if not agent: client = create_client_from_agent(agent)
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404 query = request.query_string.decode('utf-8')
url = f"{client.base_url}/{mode}/stream"
client = create_client_from_agent(agent) if query:
query = request.query_string.decode('utf-8') url = f"{url}?{query}"
url = f"{client.base_url}/{mode}/stream"
if query: headers = {'Accept': 'text/event-stream'}
url = f"{url}?{query}" if agent.get('api_key'):
headers['X-API-Key'] = agent['api_key']
headers = {'Accept': 'text/event-stream'}
if agent.get('api_key'): def generate() -> Generator[str, None, None]:
headers['X-API-Key'] = agent['api_key'] try:
with requests.get(url, headers=headers, stream=True, timeout=(5, 3600)) as resp:
def generate() -> Generator[str, None, None]: resp.raise_for_status()
try: for chunk in resp.iter_content(chunk_size=1024):
with requests.get(url, headers=headers, stream=True, timeout=(5, 3600)) as resp: if not chunk:
resp.raise_for_status() continue
for chunk in resp.iter_content(chunk_size=1024): yield chunk.decode('utf-8', errors='ignore')
if not chunk: except Exception as e:
continue logger.error(f"SSE proxy error for agent {agent_id}/{mode}: {e}")
yield chunk.decode('utf-8', errors='ignore') yield format_sse({
except Exception as e: 'type': 'error',
logger.error(f"SSE proxy error for agent {agent_id}/{mode}: {e}") 'message': str(e),
yield format_sse({ 'agent_id': agent_id,
'type': 'error', 'mode': mode,
'message': str(e), })
'agent_id': agent_id,
'mode': mode, response = Response(generate(), mimetype='text/event-stream')
}) response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response = Response(generate(), mimetype='text/event-stream') response.headers['Connection'] = 'keep-alive'
response.headers['Cache-Control'] = 'no-cache' return response
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response @controller_bp.route('/agents/<int:agent_id>/wifi/monitor', methods=['POST'])
def proxy_wifi_monitor(agent_id: int):
"""Toggle monitor mode on a remote agent's WiFi interface."""
@controller_bp.route('/agents/<int:agent_id>/wifi/monitor', methods=['POST']) agent = get_agent(agent_id)
def proxy_wifi_monitor(agent_id: int): if not agent:
"""Toggle monitor mode on a remote agent's WiFi interface.""" return api_error('Agent not found', 404)
agent = get_agent(agent_id)
if not agent:
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
data = request.json or {} data = request.json or {}
@@ -582,15 +553,9 @@ def proxy_wifi_monitor(agent_id: int):
}) })
except AgentConnectionError as e: except AgentConnectionError as e:
return jsonify({ return api_error(f'Cannot connect to agent: {e}', 503)
'status': 'error',
'message': f'Cannot connect to agent: {e}'
}), 503
except AgentHTTPError as e: except AgentHTTPError as e:
return jsonify({ return api_error(f'Agent error: {e}', 502)
'status': 'error',
'message': f'Agent error: {e}'
}), 502
# ============================================================================= # =============================================================================
@@ -616,23 +581,23 @@ def ingest_push_data():
""" """
data = request.json data = request.json
if not data: if not data:
return jsonify({'status': 'error', 'message': 'No data provided'}), 400 return api_error('No data provided', 400)
agent_name = data.get('agent_name') agent_name = data.get('agent_name')
if not agent_name: if not agent_name:
return jsonify({'status': 'error', 'message': 'agent_name required'}), 400 return api_error('agent_name required', 400)
# Find agent # Find agent
agent = get_agent_by_name(agent_name) agent = get_agent_by_name(agent_name)
if not agent: if not agent:
return jsonify({'status': 'error', 'message': 'Unknown agent'}), 401 return api_error('Unknown agent', 401)
# Validate API key if configured # Validate API key if configured
if agent.get('api_key'): if agent.get('api_key'):
provided_key = request.headers.get('X-API-Key', '') provided_key = request.headers.get('X-API-Key', '')
if provided_key != agent['api_key']: if provided_key != agent['api_key']:
logger.warning(f"Invalid API key from agent {agent_name}") logger.warning(f"Invalid API key from agent {agent_name}")
return jsonify({'status': 'error', 'message': 'Invalid API key'}), 401 return api_error('Invalid API key', 401)
# Store payload # Store payload
try: try:
@@ -644,16 +609,16 @@ def ingest_push_data():
received_at=data.get('received_at') received_at=data.get('received_at')
) )
# Emit to SSE stream (fanout to all connected clients) # Emit to SSE stream (fanout to all connected clients)
_broadcast_agent_data({ _broadcast_agent_data({
'type': 'agent_data', 'type': 'agent_data',
'agent_id': agent['id'], 'agent_id': agent['id'],
'agent_name': agent_name, 'agent_name': agent_name,
'scan_type': data.get('scan_type'), 'scan_type': data.get('scan_type'),
'interface': data.get('interface'), 'interface': data.get('interface'),
'payload': data.get('payload'), 'payload': data.get('payload'),
'received_at': data.get('received_at') or datetime.now(timezone.utc).isoformat() 'received_at': data.get('received_at') or datetime.now(timezone.utc).isoformat()
}) })
return jsonify({ return jsonify({
'status': 'accepted', 'status': 'accepted',
@@ -662,7 +627,7 @@ def ingest_push_data():
except Exception as e: except Exception as e:
logger.exception("Failed to store push payload") logger.exception("Failed to store push payload")
return jsonify({'status': 'error', 'message': str(e)}), 500 return api_error(str(e), 500)
@controller_bp.route('/api/payloads', methods=['GET']) @controller_bp.route('/api/payloads', methods=['GET'])
@@ -690,35 +655,35 @@ def get_payloads():
# ============================================================================= # =============================================================================
@controller_bp.route('/stream/all') @controller_bp.route('/stream/all')
def stream_all_agents(): def stream_all_agents():
""" """
Combined SSE stream for data from all agents. Combined SSE stream for data from all agents.
This endpoint streams push data as it arrives from agents. This endpoint streams push data as it arrives from agents.
Each message is tagged with agent_id and agent_name. Each message is tagged with agent_id and agent_name.
""" """
client_queue: queue.Queue = queue.Queue(maxsize=_AGENT_STREAM_CLIENT_QUEUE_SIZE) client_queue: queue.Queue = queue.Queue(maxsize=_AGENT_STREAM_CLIENT_QUEUE_SIZE)
with _agent_stream_subscribers_lock: with _agent_stream_subscribers_lock:
_agent_stream_subscribers.add(client_queue) _agent_stream_subscribers.add(client_queue)
def generate() -> Generator[str, None, None]: def generate() -> Generator[str, None, None]:
last_keepalive = time.time() last_keepalive = time.time()
keepalive_interval = 30.0 keepalive_interval = 30.0
try: try:
while True: while True:
try: try:
msg = client_queue.get(timeout=1.0) msg = client_queue.get(timeout=1.0)
last_keepalive = time.time() last_keepalive = time.time()
yield format_sse(msg) yield format_sse(msg)
except queue.Empty: except queue.Empty:
now = time.time() now = time.time()
if now - last_keepalive >= keepalive_interval: if now - last_keepalive >= keepalive_interval:
yield format_sse({'type': 'keepalive'}) yield format_sse({'type': 'keepalive'})
last_keepalive = now last_keepalive = now
finally: finally:
with _agent_stream_subscribers_lock: with _agent_stream_subscribers_lock:
_agent_stream_subscribers.discard(client_queue) _agent_stream_subscribers.discard(client_queue)
response = Response(generate(), mimetype='text/event-stream') response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache' response.headers['Cache-Control'] = 'no-cache'
@@ -783,7 +748,7 @@ def add_location_observation():
required = ['device_id', 'agent_name', 'agent_lat', 'agent_lon', 'rssi'] required = ['device_id', 'agent_name', 'agent_lat', 'agent_lon', 'rssi']
for field in required: for field in required:
if field not in data: if field not in data:
return jsonify({'status': 'error', 'message': f'Missing required field: {field}'}), 400 return api_error(f'Missing required field: {field}', 400)
# Look up agent GPS from database if not provided # Look up agent GPS from database if not provided
agent_lat = data.get('agent_lat') agent_lat = data.get('agent_lat')
@@ -797,10 +762,7 @@ def add_location_observation():
agent_lon = coords.get('lon') or coords.get('longitude') agent_lon = coords.get('lon') or coords.get('longitude')
if agent_lat is None or agent_lon is None: if agent_lat is None or agent_lon is None:
return jsonify({ return api_error('Agent GPS coordinates required', 400)
'status': 'error',
'message': 'Agent GPS coordinates required'
}), 400
estimate = device_tracker.add_observation( estimate = device_tracker.add_observation(
device_id=data['device_id'], device_id=data['device_id'],
@@ -837,10 +799,7 @@ def estimate_location():
observations = data.get('observations', []) observations = data.get('observations', [])
if len(observations) < 2: if len(observations) < 2:
return jsonify({ return api_error('At least 2 observations required', 400)
'status': 'error',
'message': 'At least 2 observations required'
}), 400
environment = data.get('environment', 'outdoor') environment = data.get('environment', 'outdoor')
@@ -852,7 +811,7 @@ def estimate_location():
}) })
except Exception as e: except Exception as e:
logger.exception("Location estimation failed") logger.exception("Location estimation failed")
return jsonify({'status': 'error', 'message': str(e)}), 500 return api_error(str(e), 500)
@controller_bp.route('/api/location/<device_id>', methods=['GET']) @controller_bp.route('/api/location/<device_id>', methods=['GET'])
@@ -904,7 +863,7 @@ def get_devices_near():
lon = float(request.args.get('lon', 0)) lon = float(request.args.get('lon', 0))
radius = float(request.args.get('radius', 100)) radius = float(request.args.get('radius', 100))
except (ValueError, TypeError): except (ValueError, TypeError):
return jsonify({'status': 'error', 'message': 'Invalid coordinates'}), 400 return api_error('Invalid coordinates', 400)
results = device_tracker.get_devices_near(lat, lon, radius) results = device_tracker.get_devices_near(lat, lon, radius)
+9 -31
View File
@@ -6,6 +6,7 @@ from flask import Blueprint, jsonify, request, Response
import app as app_module import app as app_module
from utils.correlation import get_correlations from utils.correlation import get_correlations
from utils.responses import api_success, api_error
from utils.logging import get_logger from utils.logging import get_logger
logger = get_logger('intercept.correlation') logger = get_logger('intercept.correlation')
@@ -39,18 +40,14 @@ def get_device_correlations() -> Response:
include_historical=include_historical include_historical=include_historical
) )
return jsonify({ return api_success(data={
'status': 'success',
'correlations': correlations, 'correlations': correlations,
'wifi_count': len(wifi_devices), 'wifi_count': len(wifi_devices),
'bt_count': len(bt_devices) 'bt_count': len(bt_devices)
}) })
except Exception as e: except Exception as e:
logger.error(f"Error calculating correlations: {e}") logger.error(f"Error calculating correlations: {e}")
return jsonify({ return api_error(str(e), 500)
'status': 'error',
'message': str(e)
}), 500
@correlation_bp.route('/analyze', methods=['POST']) @correlation_bp.route('/analyze', methods=['POST'])
@@ -67,10 +64,7 @@ def analyze_correlation() -> Response:
bt_mac = data.get('bt_mac') bt_mac = data.get('bt_mac')
if not wifi_mac or not bt_mac: if not wifi_mac or not bt_mac:
return jsonify({ return api_error('wifi_mac and bt_mac are required', 400)
'status': 'error',
'message': 'wifi_mac and bt_mac are required'
}), 400
try: try:
# Get device data # Get device data
@@ -81,16 +75,10 @@ def analyze_correlation() -> Response:
bt_device = app_module.bt_devices.get(bt_mac) bt_device = app_module.bt_devices.get(bt_mac)
if not wifi_device: if not wifi_device:
return jsonify({ return api_error(f'WiFi device {wifi_mac} not found', 404)
'status': 'error',
'message': f'WiFi device {wifi_mac} not found'
}), 404
if not bt_device: if not bt_device:
return jsonify({ return api_error(f'Bluetooth device {bt_mac} not found', 404)
'status': 'error',
'message': f'Bluetooth device {bt_mac} not found'
}), 404
# Calculate correlation for this specific pair # Calculate correlation for this specific pair
correlations = get_correlations( correlations = get_correlations(
@@ -101,19 +89,9 @@ def analyze_correlation() -> Response:
) )
if correlations: if correlations:
return jsonify({ return api_success(data={'correlation': correlations[0]})
'status': 'success',
'correlation': correlations[0]
})
else: else:
return jsonify({ return api_success(data={'correlation': None}, message='No correlation detected between these devices')
'status': 'success',
'correlation': None,
'message': 'No correlation detected between these devices'
})
except Exception as e: except Exception as e:
logger.error(f"Error analyzing correlation: {e}") logger.error(f"Error analyzing correlation: {e}")
return jsonify({ return api_error(str(e), 500)
'status': 'error',
'message': str(e)
}), 500
+2 -1
View File
@@ -21,6 +21,7 @@ from typing import Any, Generator
from flask import Blueprint, jsonify, request, Response from flask import Blueprint, jsonify, request, Response
from utils.responses import api_success, api_error
import app as app_module import app as app_module
from utils.constants import ( from utils.constants import (
DSC_VHF_FREQUENCY_MHZ, DSC_VHF_FREQUENCY_MHZ,
@@ -380,7 +381,7 @@ def start_decoding() -> Response:
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host) rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port) rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port) sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}") logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
else: else:
+44 -43
View File
@@ -8,6 +8,7 @@ from collections.abc import Generator
from flask import Blueprint, Response, jsonify from flask import Blueprint, Response, jsonify
from utils.responses import api_success, api_error
from utils.gps import ( from utils.gps import (
GPSPosition, GPSPosition,
GPSSkyData, GPSSkyData,
@@ -21,7 +22,7 @@ from utils.gps import (
stop_gpsd_daemon, stop_gpsd_daemon,
) )
from utils.logging import get_logger from utils.logging import get_logger
from utils.sse import sse_stream_fanout from utils.sse import sse_stream_fanout
logger = get_logger('intercept.gps') logger = get_logger('intercept.gps')
@@ -65,17 +66,17 @@ def auto_connect_gps():
If gpsd is not running, attempts to detect GPS devices and start gpsd. If gpsd is not running, attempts to detect GPS devices and start gpsd.
Returns current status if already connected. Returns current status if already connected.
""" """
# Check if already running # Check if already running
reader = get_gps_reader() reader = get_gps_reader()
if reader and reader.is_running: if reader and reader.is_running:
# Ensure stream callbacks are attached for this process. # Ensure stream callbacks are attached for this process.
reader.add_callback(_position_callback) reader.add_callback(_position_callback)
reader.add_sky_callback(_sky_callback) reader.add_sky_callback(_sky_callback)
position = reader.position position = reader.position
sky = reader.sky sky = reader.sky
return jsonify({ return jsonify({
'status': 'connected', 'status': 'connected',
'source': 'gpsd', 'source': 'gpsd',
'has_fix': position is not None, 'has_fix': position is not None,
'position': position.to_dict() if position else None, 'position': position.to_dict() if position else None,
'sky': sky.to_dict() if sky else None, 'sky': sky.to_dict() if sky else None,
@@ -207,22 +208,22 @@ def get_position():
}) })
@gps_bp.route('/satellites') @gps_bp.route('/satellites')
def get_satellites(): def get_satellites():
"""Get current satellite sky view data.""" """Get current satellite sky view data."""
reader = get_gps_reader() reader = get_gps_reader()
if not reader or not reader.is_running: if not reader or not reader.is_running:
return jsonify({ return jsonify({
'status': 'waiting', 'status': 'waiting',
'running': False, 'running': False,
'message': 'GPS client not running' 'message': 'GPS client not running'
}) })
sky = reader.sky sky = reader.sky
if sky: if sky:
return jsonify({ return jsonify({
'status': 'ok', 'status': 'ok',
'sky': sky.to_dict() 'sky': sky.to_dict()
}) })
else: else:
@@ -232,19 +233,19 @@ def get_satellites():
}) })
@gps_bp.route('/stream') @gps_bp.route('/stream')
def stream_gps(): def stream_gps():
"""SSE stream of GPS position and sky updates.""" """SSE stream of GPS position and sky updates."""
response = Response( response = Response(
sse_stream_fanout( sse_stream_fanout(
source_queue=_gps_queue, source_queue=_gps_queue,
channel_key='gps', channel_key='gps',
timeout=1.0, timeout=1.0,
keepalive_interval=30.0, keepalive_interval=30.0,
), ),
mimetype='text/event-stream', mimetype='text/event-stream',
) )
response.headers['Cache-Control'] = 'no-cache' response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no' response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive' response.headers['Connection'] = 'keep-alive'
return response return response
File diff suppressed because it is too large Load Diff
+523
View File
@@ -0,0 +1,523 @@
"""Receiver routes for radio monitoring and frequency scanning.
This package splits the listening post into sub-modules:
scanner - /scanner/*, /presets routes
audio - /audio/* routes
waterfall - /waterfall/* routes
tools - /tools, /signal/guess routes
"""
from __future__ import annotations
import os
import queue
import signal
import shutil
import struct
import subprocess
import threading
import time
from datetime import datetime
from typing import Dict, List, Optional
from flask import Blueprint
from utils.logging import get_logger
from utils.sse import sse_stream_fanout
from utils.event_pipeline import process_event
from utils.constants import (
SSE_QUEUE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL,
PROCESS_TERMINATE_TIMEOUT,
)
from utils.sdr import SDRFactory, SDRType
logger = get_logger('intercept.receiver')
receiver_bp = Blueprint('receiver', __name__, url_prefix='/receiver')
# Deferred import to avoid circular import at module load time.
# app.py -> register_blueprints -> from .listening_post import receiver_bp
# must find receiver_bp already defined (above) before this import runs.
import app as app_module # noqa: E402
# ============================================
# GLOBAL STATE
# ============================================
# Audio demodulation state
audio_process = None
audio_rtl_process = None
audio_lock = threading.Lock()
audio_start_lock = threading.Lock()
audio_running = False
audio_frequency = 0.0
audio_modulation = 'fm'
audio_source = 'process'
audio_start_token = 0
# Scanner state
scanner_thread: Optional[threading.Thread] = None
scanner_running = False
scanner_lock = threading.Lock()
scanner_paused = False
scanner_current_freq = 0.0
scanner_active_device: Optional[int] = None
scanner_active_sdr_type: str = 'rtlsdr'
receiver_active_device: Optional[int] = None
receiver_active_sdr_type: str = 'rtlsdr'
scanner_power_process: Optional[subprocess.Popen] = None
scanner_config = {
'start_freq': 88.0,
'end_freq': 108.0,
'step': 0.1,
'modulation': 'wfm',
'squelch': 0,
'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
'scan_method': 'power', # power (rtl_power) or classic (rtl_fm hop)
'snr_threshold': 8,
}
# 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)
# Flag to trigger skip from API
scanner_skip_signal = False
# Waterfall / spectrogram state
waterfall_process: Optional[subprocess.Popen] = None
waterfall_thread: Optional[threading.Thread] = None
waterfall_running = False
waterfall_lock = threading.Lock()
waterfall_queue: queue.Queue = queue.Queue(maxsize=200)
waterfall_active_device: Optional[int] = None
waterfall_active_sdr_type: str = 'rtlsdr'
waterfall_config = {
'start_freq': 88.0,
'end_freq': 108.0,
'bin_size': 10000,
'gain': 40,
'device': 0,
'max_bins': 1024,
'interval': 0.4,
}
# ============================================
# HELPER FUNCTIONS (shared across sub-modules)
# ============================================
VALID_MODULATIONS = ['fm', 'wfm', 'am', 'usb', 'lsb']
def find_rtl_fm() -> str | None:
"""Find rtl_fm binary."""
return shutil.which('rtl_fm')
def find_rtl_power() -> str | None:
"""Find rtl_power binary."""
return shutil.which('rtl_power')
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 normalize_modulation(value: str) -> str:
"""Normalize and validate modulation string."""
mod = str(value or '').lower().strip()
if mod not in VALID_MODULATIONS:
raise ValueError(f'Invalid modulation. Use: {", ".join(VALID_MODULATIONS)}')
return mod
def _rtl_fm_demod_mode(modulation: str) -> str:
"""Map UI modulation names to rtl_fm demod tokens."""
mod = str(modulation or '').lower().strip()
return 'wbfm' if mod == 'wfm' else mod
def _wav_header(sample_rate: int = 48000, bits_per_sample: int = 16, channels: int = 1) -> bytes:
"""Create a streaming WAV header with unknown data length."""
bytes_per_sample = bits_per_sample // 8
byte_rate = sample_rate * channels * bytes_per_sample
block_align = channels * bytes_per_sample
return (
b'RIFF'
+ struct.pack('<I', 0xFFFFFFFF)
+ b'WAVE'
+ b'fmt '
+ struct.pack('<IHHIIHH', 16, 1, channels, sample_rate, byte_rate, block_align, bits_per_sample)
+ b'data'
+ struct.pack('<I', 0xFFFFFFFF)
)
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
def _start_audio_stream(
frequency: float,
modulation: str,
*,
device: int | None = None,
sdr_type: str | None = None,
gain: int | None = None,
squelch: int | None = None,
bias_t: bool | None = None,
):
"""Start audio streaming at given frequency."""
global audio_process, audio_rtl_process, audio_running, audio_frequency, audio_modulation
# Stop existing stream and snapshot config under lock
with audio_lock:
_stop_audio_stream_internal()
ffmpeg_path = find_ffmpeg()
if not ffmpeg_path:
logger.error("ffmpeg not found")
return
# Snapshot runtime tuning config so the spawned demod command cannot
# drift if shared scanner_config changes while startup is in-flight.
device_index = int(device if device is not None else scanner_config.get('device', 0))
gain_value = int(gain if gain is not None else scanner_config.get('gain', 40))
squelch_value = int(squelch if squelch is not None else scanner_config.get('squelch', 0))
bias_t_enabled = bool(scanner_config.get('bias_t', False) if bias_t is None else bias_t)
sdr_type_str = str(sdr_type if sdr_type is not None else scanner_config.get('sdr_type', 'rtlsdr')).lower()
# Build commands outside lock (no blocking I/O, just command construction)
try:
resolved_sdr_type = SDRType(sdr_type_str)
except ValueError:
resolved_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 resolved_sdr_type == SDRType.RTL_SDR:
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', _rtl_fm_demod_mode(modulation),
'-f', str(freq_hz),
'-s', str(sample_rate),
'-r', str(resample_rate),
'-g', str(gain_value),
'-d', str(device_index),
'-l', str(squelch_value),
]
if bias_t_enabled:
sdr_cmd.append('-T')
else:
rx_fm_path = find_rx_fm()
if not rx_fm_path:
logger.error(f"rx_fm not found - required for {resolved_sdr_type.value}. Install SoapySDR utilities.")
return
sdr_device = SDRFactory.create_default_device(resolved_sdr_type, index=device_index)
builder = SDRFactory.get_builder(resolved_sdr_type)
sdr_cmd = builder.build_fm_demod_command(
device=sdr_device,
frequency_mhz=frequency,
sample_rate=resample_rate,
gain=float(gain_value),
modulation=modulation,
squelch=squelch_value,
bias_t=bias_t_enabled,
)
sdr_cmd[0] = rx_fm_path
encoder_cmd = [
ffmpeg_path,
'-hide_banner',
'-loglevel', 'error',
'-fflags', 'nobuffer',
'-flags', 'low_delay',
'-probesize', '32',
'-analyzeduration', '0',
'-f', 's16le',
'-ar', str(resample_rate),
'-ac', '1',
'-i', 'pipe:0',
'-acodec', 'pcm_s16le',
'-ar', '44100',
'-f', 'wav',
'pipe:1'
]
# Retry loop outside lock — spawning + health check sleeps don't block
# other operations. audio_start_lock already serializes callers.
try:
rtl_stderr_log = '/tmp/rtl_fm_stderr.log'
ffmpeg_stderr_log = '/tmp/ffmpeg_stderr.log'
logger.info(f"Starting audio: {frequency} MHz, mod={modulation}, device={device_index}")
new_rtl_proc = None
new_audio_proc = None
max_attempts = 3
for attempt in range(max_attempts):
new_rtl_proc = None
new_audio_proc = None
rtl_err_handle = None
ffmpeg_err_handle = None
try:
rtl_err_handle = open(rtl_stderr_log, 'w')
ffmpeg_err_handle = open(ffmpeg_stderr_log, 'w')
new_rtl_proc = subprocess.Popen(
sdr_cmd,
stdout=subprocess.PIPE,
stderr=rtl_err_handle,
bufsize=0,
start_new_session=True
)
new_audio_proc = subprocess.Popen(
encoder_cmd,
stdin=new_rtl_proc.stdout,
stdout=subprocess.PIPE,
stderr=ffmpeg_err_handle,
bufsize=0,
start_new_session=True
)
if new_rtl_proc.stdout:
new_rtl_proc.stdout.close()
finally:
if rtl_err_handle:
rtl_err_handle.close()
if ffmpeg_err_handle:
ffmpeg_err_handle.close()
# Brief delay to check if process started successfully
time.sleep(0.3)
if (new_rtl_proc and new_rtl_proc.poll() is not None) or (
new_audio_proc and new_audio_proc.poll() is not None
):
rtl_stderr = ''
ffmpeg_stderr = ''
try:
with open(rtl_stderr_log, 'r') as f:
rtl_stderr = f.read().strip()
except Exception:
pass
try:
with open(ffmpeg_stderr_log, 'r') as f:
ffmpeg_stderr = f.read().strip()
except Exception:
pass
if 'usb_claim_interface' in rtl_stderr and attempt < max_attempts - 1:
logger.warning(f"USB device busy (attempt {attempt + 1}/{max_attempts}), waiting for release...")
if new_audio_proc:
try:
new_audio_proc.terminate()
new_audio_proc.wait(timeout=0.5)
except Exception:
pass
if new_rtl_proc:
try:
new_rtl_proc.terminate()
new_rtl_proc.wait(timeout=0.5)
except Exception:
pass
time.sleep(1.0)
continue
if new_audio_proc and new_audio_proc.poll() is None:
try:
new_audio_proc.terminate()
new_audio_proc.wait(timeout=0.5)
except Exception:
pass
if new_rtl_proc and new_rtl_proc.poll() is None:
try:
new_rtl_proc.terminate()
new_rtl_proc.wait(timeout=0.5)
except Exception:
pass
new_audio_proc = None
new_rtl_proc = None
logger.error(
f"Audio pipeline exited immediately. rtl_fm stderr: {rtl_stderr}, ffmpeg stderr: {ffmpeg_stderr}"
)
return
# Pipeline started successfully
break
# Verify pipeline is still alive, then install under lock
if (
not new_audio_proc
or not new_rtl_proc
or new_audio_proc.poll() is not None
or new_rtl_proc.poll() is not None
):
logger.warning("Audio pipeline did not remain alive after startup")
# Clean up failed processes
if new_audio_proc:
try:
new_audio_proc.terminate()
new_audio_proc.wait(timeout=0.5)
except Exception:
pass
if new_rtl_proc:
try:
new_rtl_proc.terminate()
new_rtl_proc.wait(timeout=0.5)
except Exception:
pass
return
# Install processes under lock
with audio_lock:
audio_rtl_process = new_rtl_proc
audio_process = new_audio_proc
audio_running = True
audio_frequency = frequency
audio_modulation = modulation
logger.info(f"Audio stream started: {frequency} MHz ({modulation}) via {resolved_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, audio_source
# Set flag first to stop any streaming
audio_running = False
audio_frequency = 0.0
previous_source = audio_source
audio_source = 'process'
if previous_source == 'waterfall':
try:
from routes.waterfall_websocket import stop_shared_monitor_from_capture
stop_shared_monitor_from_capture()
except Exception:
pass
had_processes = audio_process is not None or audio_rtl_process is not None
# Kill the pipeline processes and their groups
if audio_process:
try:
# Kill entire process group (SDR demod + ffmpeg)
try:
os.killpg(os.getpgid(audio_process.pid), signal.SIGKILL)
except (ProcessLookupError, PermissionError):
audio_process.kill()
audio_process.wait(timeout=0.5)
except Exception:
pass
if audio_rtl_process:
try:
try:
os.killpg(os.getpgid(audio_rtl_process.pid), signal.SIGKILL)
except (ProcessLookupError, PermissionError):
audio_rtl_process.kill()
audio_rtl_process.wait(timeout=0.5)
except Exception:
pass
audio_process = None
audio_rtl_process = None
# Brief pause for SDR device USB interface to be released by kernel.
# The _start_audio_stream retry loop handles longer contention windows
# so only a minimal delay is needed here.
if had_processes:
time.sleep(0.15)
def _stop_waterfall_internal() -> None:
"""Stop the waterfall display and release resources."""
global waterfall_running, waterfall_process, waterfall_active_device, waterfall_active_sdr_type
waterfall_running = False
if waterfall_process and waterfall_process.poll() is None:
try:
waterfall_process.terminate()
waterfall_process.wait(timeout=1)
except Exception:
try:
waterfall_process.kill()
except Exception:
pass
waterfall_process = None
if waterfall_active_device is not None:
app_module.release_sdr_device(waterfall_active_device, waterfall_active_sdr_type)
waterfall_active_device = None
waterfall_active_sdr_type = 'rtlsdr'
# ============================================
# Import sub-modules to register routes on receiver_bp
# ============================================
from . import scanner # noqa: E402, F401
from . import audio # noqa: E402, F401
from . import waterfall # noqa: E402, F401
from . import tools # noqa: E402, F401
+502
View File
@@ -0,0 +1,502 @@
"""Audio routes for manual listening and audio streaming."""
from __future__ import annotations
import os
import select
import subprocess
import time
from typing import Any
from flask import jsonify, request, Response
from . import (
receiver_bp,
logger,
app_module,
scanner_config,
_wav_header,
_start_audio_stream,
_stop_audio_stream,
_stop_waterfall_internal,
normalize_modulation,
)
import routes.listening_post as _state
# ============================================
# MANUAL AUDIO ENDPOINTS (for direct listening)
# ============================================
@receiver_bp.route('/audio/start', methods=['POST'])
def start_audio() -> Response:
"""Start audio at specific frequency (manual mode)."""
data = request.json or {}
try:
frequency = float(data.get('frequency', 0))
modulation = normalize_modulation(data.get('modulation', 'wfm'))
squelch = int(data['squelch']) if data.get('squelch') is not None else 0
gain = int(data['gain']) if data.get('gain') is not None else 40
device = int(data['device']) if data.get('device') is not None else 0
sdr_type = str(data.get('sdr_type', 'rtlsdr')).lower()
request_token_raw = data.get('request_token')
request_token = int(request_token_raw) if request_token_raw is not None else None
bias_t_raw = data.get('bias_t', scanner_config.get('bias_t', False))
if isinstance(bias_t_raw, str):
bias_t = bias_t_raw.strip().lower() in {'1', 'true', 'yes', 'on'}
else:
bias_t = bool(bias_t_raw)
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_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
with _state.audio_start_lock:
if request_token is not None:
if request_token < _state.audio_start_token:
return jsonify({
'status': 'stale',
'message': 'Superseded audio start request',
'source': _state.audio_source,
'superseded': True,
'current_token': _state.audio_start_token,
}), 409
_state.audio_start_token = request_token
else:
_state.audio_start_token += 1
request_token = _state.audio_start_token
# Grab scanner refs inside lock, signal stop, clear state
need_scanner_teardown = False
scanner_thread_ref = None
scanner_proc_ref = None
if _state.scanner_running:
_state.scanner_running = False
if _state.scanner_active_device is not None:
app_module.release_sdr_device(_state.scanner_active_device, _state.scanner_active_sdr_type)
_state.scanner_active_device = None
_state.scanner_active_sdr_type = 'rtlsdr'
scanner_thread_ref = _state.scanner_thread
scanner_proc_ref = _state.scanner_power_process
_state.scanner_power_process = None
need_scanner_teardown = True
# Update config for audio
scanner_config['squelch'] = squelch
scanner_config['gain'] = gain
scanner_config['device'] = device
scanner_config['sdr_type'] = sdr_type
scanner_config['bias_t'] = bias_t
# Scanner teardown outside lock (blocking: thread join, process wait, pkill, sleep)
if need_scanner_teardown:
if scanner_thread_ref and scanner_thread_ref.is_alive():
try:
scanner_thread_ref.join(timeout=2.0)
except Exception:
pass
if scanner_proc_ref and scanner_proc_ref.poll() is None:
try:
scanner_proc_ref.terminate()
scanner_proc_ref.wait(timeout=1)
except Exception:
try:
scanner_proc_ref.kill()
except Exception:
pass
try:
subprocess.run(['pkill', '-9', 'rtl_power'], capture_output=True, timeout=0.5)
except Exception:
pass
time.sleep(0.5)
# Re-acquire lock for waterfall check and device claim
with _state.audio_start_lock:
# Preferred path: when waterfall WebSocket is active on the same SDR,
# derive monitor audio from that IQ stream instead of spawning rtl_fm.
try:
from routes.waterfall_websocket import (
get_shared_capture_status,
start_shared_monitor_from_capture,
)
shared = get_shared_capture_status()
if shared.get('running') and shared.get('device') == device:
_stop_audio_stream()
ok, msg = start_shared_monitor_from_capture(
device=device,
frequency_mhz=frequency,
modulation=modulation,
squelch=squelch,
)
if ok:
_state.audio_running = True
_state.audio_frequency = frequency
_state.audio_modulation = modulation
_state.audio_source = 'waterfall'
# Shared monitor uses the waterfall's existing SDR claim.
if _state.receiver_active_device is not None:
app_module.release_sdr_device(_state.receiver_active_device, _state.receiver_active_sdr_type)
_state.receiver_active_device = None
_state.receiver_active_sdr_type = 'rtlsdr'
return jsonify({
'status': 'started',
'frequency': frequency,
'modulation': modulation,
'source': 'waterfall',
'request_token': request_token,
})
logger.warning(f"Shared waterfall monitor unavailable: {msg}")
except Exception as e:
logger.debug(f"Shared waterfall monitor probe failed: {e}")
# Stop waterfall if it's using the same SDR (SSE path)
if _state.waterfall_running and _state.waterfall_active_device == device:
_stop_waterfall_internal()
time.sleep(0.2)
# Claim device for listening audio. The WebSocket waterfall handler
# may still be tearing down its IQ capture process (thread join +
# safe_terminate can take several seconds), so we retry with back-off
# to give the USB device time to be fully released.
if _state.receiver_active_device is None or _state.receiver_active_device != device:
if _state.receiver_active_device is not None:
app_module.release_sdr_device(_state.receiver_active_device, _state.receiver_active_sdr_type)
_state.receiver_active_device = None
_state.receiver_active_sdr_type = 'rtlsdr'
error = None
max_claim_attempts = 6
for attempt in range(max_claim_attempts):
error = app_module.claim_sdr_device(device, 'receiver', sdr_type)
if not error:
break
if attempt < max_claim_attempts - 1:
logger.debug(
f"Device claim attempt {attempt + 1}/{max_claim_attempts} "
f"failed, retrying in 0.5s: {error}"
)
time.sleep(0.5)
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
_state.receiver_active_device = device
_state.receiver_active_sdr_type = sdr_type
_start_audio_stream(
frequency,
modulation,
device=device,
sdr_type=sdr_type,
gain=gain,
squelch=squelch,
bias_t=bias_t,
)
if _state.audio_running:
_state.audio_source = 'process'
return jsonify({
'status': 'started',
'frequency': _state.audio_frequency,
'modulation': _state.audio_modulation,
'source': 'process',
'request_token': request_token,
})
# Avoid leaving a stale device claim after startup failure.
if _state.receiver_active_device is not None:
app_module.release_sdr_device(_state.receiver_active_device, _state.receiver_active_sdr_type)
_state.receiver_active_device = None
_state.receiver_active_sdr_type = 'rtlsdr'
start_error = ''
for log_path in ('/tmp/rtl_fm_stderr.log', '/tmp/ffmpeg_stderr.log'):
try:
with open(log_path, 'r') as handle:
content = handle.read().strip()
if content:
start_error = content.splitlines()[-1]
break
except Exception:
continue
message = 'Failed to start audio. Check SDR device.'
if start_error:
message = f'Failed to start audio: {start_error}'
return jsonify({
'status': 'error',
'message': message
}), 500
@receiver_bp.route('/audio/stop', methods=['POST'])
def stop_audio() -> Response:
"""Stop audio."""
_stop_audio_stream()
if _state.receiver_active_device is not None:
app_module.release_sdr_device(_state.receiver_active_device, _state.receiver_active_sdr_type)
_state.receiver_active_device = None
_state.receiver_active_sdr_type = 'rtlsdr'
return jsonify({'status': 'stopped'})
@receiver_bp.route('/audio/status')
def audio_status() -> Response:
"""Get audio status."""
running = _state.audio_running
if _state.audio_source == 'waterfall':
try:
from routes.waterfall_websocket import get_shared_capture_status
shared = get_shared_capture_status()
running = bool(shared.get('running') and shared.get('monitor_enabled'))
except Exception:
running = False
return jsonify({
'running': running,
'frequency': _state.audio_frequency,
'modulation': _state.audio_modulation,
'source': _state.audio_source,
})
@receiver_bp.route('/audio/debug')
def audio_debug() -> Response:
"""Get audio debug status and recent stderr logs."""
rtl_log_path = '/tmp/rtl_fm_stderr.log'
ffmpeg_log_path = '/tmp/ffmpeg_stderr.log'
sample_path = '/tmp/audio_probe.bin'
def _read_log(path: str) -> str:
try:
with open(path, 'r') as handle:
return handle.read().strip()
except Exception:
return ''
shared = {}
if _state.audio_source == 'waterfall':
try:
from routes.waterfall_websocket import get_shared_capture_status
shared = get_shared_capture_status()
except Exception:
shared = {}
return jsonify({
'running': _state.audio_running,
'frequency': _state.audio_frequency,
'modulation': _state.audio_modulation,
'source': _state.audio_source,
'sdr_type': scanner_config.get('sdr_type', 'rtlsdr'),
'device': scanner_config.get('device', 0),
'gain': scanner_config.get('gain', 0),
'squelch': scanner_config.get('squelch', 0),
'audio_process_alive': bool(_state.audio_process and _state.audio_process.poll() is None),
'shared_capture': shared,
'rtl_fm_stderr': _read_log(rtl_log_path),
'ffmpeg_stderr': _read_log(ffmpeg_log_path),
'audio_probe_bytes': os.path.getsize(sample_path) if os.path.exists(sample_path) else 0,
})
@receiver_bp.route('/audio/probe')
def audio_probe() -> Response:
"""Grab a small chunk of audio bytes from the pipeline for debugging."""
if _state.audio_source == 'waterfall':
try:
from routes.waterfall_websocket import read_shared_monitor_audio_chunk
data = read_shared_monitor_audio_chunk(timeout=2.0)
if not data:
return jsonify({'status': 'error', 'message': 'no shared audio data available'}), 504
sample_path = '/tmp/audio_probe.bin'
with open(sample_path, 'wb') as handle:
handle.write(data)
return jsonify({'status': 'ok', 'bytes': len(data), 'source': 'waterfall'})
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}), 500
if not _state.audio_process or not _state.audio_process.stdout:
return jsonify({'status': 'error', 'message': 'audio process not running'}), 400
sample_path = '/tmp/audio_probe.bin'
size = 0
try:
ready, _, _ = select.select([_state.audio_process.stdout], [], [], 2.0)
if not ready:
return jsonify({'status': 'error', 'message': 'no data available'}), 504
data = _state.audio_process.stdout.read(4096)
if not data:
return jsonify({'status': 'error', 'message': 'no data read'}), 504
with open(sample_path, 'wb') as handle:
handle.write(data)
size = len(data)
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}), 500
return jsonify({'status': 'ok', 'bytes': size})
@receiver_bp.route('/audio/stream')
def stream_audio() -> Response:
"""Stream WAV audio."""
request_token_raw = request.args.get('request_token')
request_token = None
if request_token_raw is not None:
try:
request_token = int(request_token_raw)
except (ValueError, TypeError):
request_token = None
if request_token is not None and request_token < _state.audio_start_token:
return Response(b'', mimetype='audio/wav', status=204)
if _state.audio_source == 'waterfall':
for _ in range(40):
if _state.audio_running:
break
time.sleep(0.05)
if not _state.audio_running:
return Response(b'', mimetype='audio/wav', status=204)
def generate_shared():
try:
from routes.waterfall_websocket import (
get_shared_capture_status,
read_shared_monitor_audio_chunk,
)
except Exception:
return
# Browser expects an immediate WAV header.
yield _wav_header(sample_rate=48000)
inactive_since: float | None = None
while _state.audio_running and _state.audio_source == 'waterfall':
if request_token is not None and request_token < _state.audio_start_token:
break
chunk = read_shared_monitor_audio_chunk(timeout=1.0)
if chunk:
inactive_since = None
yield chunk
continue
shared = get_shared_capture_status()
if shared.get('running') and shared.get('monitor_enabled'):
inactive_since = None
continue
if inactive_since is None:
inactive_since = time.monotonic()
continue
if (time.monotonic() - inactive_since) < 4.0:
continue
if not shared.get('running') or not shared.get('monitor_enabled'):
_state.audio_running = False
_state.audio_source = 'process'
break
return Response(
generate_shared(),
mimetype='audio/wav',
headers={
'Content-Type': 'audio/wav',
'Cache-Control': 'no-cache, no-store',
'X-Accel-Buffering': 'no',
'Transfer-Encoding': 'chunked',
}
)
# Wait for audio process to be ready (up to 2 seconds).
for _ in range(40):
if _state.audio_running and _state.audio_process:
break
time.sleep(0.05)
if not _state.audio_running or not _state.audio_process:
return Response(b'', mimetype='audio/wav', status=204)
def generate():
# Capture local reference to avoid race condition with stop
proc = _state.audio_process
if not proc or not proc.stdout:
return
try:
# Drain stale audio that accumulated in the pipe buffer
# between pipeline start and stream connection. Keep the
# first chunk (contains WAV header) and discard the rest
# so the browser starts close to real-time.
header_chunk = None
while True:
ready, _, _ = select.select([proc.stdout], [], [], 0)
if not ready:
break
chunk = proc.stdout.read(8192)
if not chunk:
break
if header_chunk is None:
header_chunk = chunk
if header_chunk:
yield header_chunk
# Stream real-time audio
first_chunk_deadline = time.time() + 20.0
warned_wait = False
while _state.audio_running and proc.poll() is None:
if request_token is not None and request_token < _state.audio_start_token:
break
# Use select to avoid blocking forever
ready, _, _ = select.select([proc.stdout], [], [], 2.0)
if ready:
chunk = proc.stdout.read(8192)
if chunk:
warned_wait = False
yield chunk
else:
break
else:
# Keep connection open while demodulator settles.
if time.time() > first_chunk_deadline:
if not warned_wait:
logger.warning("Audio stream still waiting for first chunk")
warned_wait = True
continue
# Timeout - check if process died
if proc.poll() is not None:
break
except GeneratorExit:
pass
except Exception as e:
logger.error(f"Audio stream error: {e}")
return Response(
generate(),
mimetype='audio/wav',
headers={
'Content-Type': 'audio/wav',
'Cache-Control': 'no-cache, no-store',
'X-Accel-Buffering': 'no',
'Transfer-Encoding': 'chunked',
}
)
+824
View File
@@ -0,0 +1,824 @@
"""Scanner routes and implementation for frequency scanning."""
from __future__ import annotations
import math
import queue
import struct
import subprocess
import threading
import time
from typing import Any
from flask import jsonify, request, Response
from . import (
receiver_bp,
logger,
app_module,
scanner_queue,
scanner_config,
scanner_lock,
activity_log,
activity_log_lock,
add_activity_log,
find_rtl_fm,
find_rtl_power,
find_rx_fm,
normalize_modulation,
_rtl_fm_demod_mode,
_start_audio_stream,
_stop_audio_stream,
process_event,
sse_stream_fanout,
SSE_QUEUE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL,
)
import routes.listening_post as _state
# ============================================
# SCANNER IMPLEMENTATION
# ============================================
def scanner_loop():
"""Main scanner loop - scans frequencies looking for signals."""
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')
_state.scanner_running = False
return
current_freq = scanner_config['start_freq']
last_signal_time = 0
signal_detected = False
try:
while _state.scanner_running:
# Check if paused
if _state.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']
_state.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,
'range_start': scanner_config['start_freq'],
'range_end': scanner_config['end_freq']
})
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', _rtl_fm_demod_mode(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 _state.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:
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)
min_threshold = 1500
else:
# AM/NFM: threshold 300-6500 based on squelch
threshold = 300 + (squelch * 62)
min_threshold = 900
effective_threshold = max(threshold, min_threshold)
audio_detected = rms > effective_threshold
# Send level info to clients
try:
scanner_queue.put_nowait({
'type': 'scan_update',
'frequency': current_freq,
'level': int(rms),
'threshold': int(effective_threshold) if 'effective_threshold' in dir() else 0,
'detected': audio_detected,
'range_start': scanner_config['start_freq'],
'range_end': scanner_config['end_freq']
})
except queue.Full:
pass
if audio_detected and _state.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:
snr_db = round(10 * math.log10(rms / effective_threshold), 1) if rms > 0 and effective_threshold > 0 else 0.0
scanner_queue.put_nowait({
'type': 'signal_found',
'frequency': current_freq,
'modulation': mod,
'audio_streaming': True,
'level': int(rms),
'threshold': int(effective_threshold),
'snr': snr_db,
'range_start': scanner_config['start_freq'],
'range_end': scanner_config['end_freq']
})
except queue.Full:
pass
# Check for skip signal
if _state.scanner_skip_signal:
_state.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 _state.scanner_running:
if _state.scanner_skip_signal:
break
time.sleep(0.2)
last_signal_time = time.time()
# After dwell, move on to keep scanning
if _state.scanner_running and not _state.scanner_skip_signal:
signal_detected = False
_stop_audio_stream()
try:
scanner_queue.put_nowait({
'type': 'signal_lost',
'frequency': current_freq,
'range_start': scanner_config['start_freq'],
'range_end': scanner_config['end_freq']
})
except queue.Full:
pass
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'])
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:
_state.scanner_running = False
_stop_audio_stream()
add_activity_log('scanner_stop', _state.scanner_current_freq, 'Scanner stopped')
logger.info("Scanner thread stopped")
def scanner_loop_power():
"""Power sweep scanner using rtl_power to detect peaks."""
logger.info("Power sweep scanner thread started")
add_activity_log('scanner_start', scanner_config['start_freq'],
f"Power sweep {scanner_config['start_freq']}-{scanner_config['end_freq']} MHz")
rtl_power_path = find_rtl_power()
if not rtl_power_path:
logger.error("rtl_power not found")
add_activity_log('error', 0, 'rtl_power not found')
_state.scanner_running = False
return
try:
while _state.scanner_running:
if _state.scanner_paused:
time.sleep(0.1)
continue
start_mhz = scanner_config['start_freq']
end_mhz = scanner_config['end_freq']
step_khz = scanner_config['step']
gain = scanner_config['gain']
device = scanner_config['device']
squelch = scanner_config['squelch']
mod = scanner_config['modulation']
# Configure sweep
bin_hz = max(1000, int(step_khz * 1000))
start_hz = int(start_mhz * 1e6)
end_hz = int(end_mhz * 1e6)
# Integration time per sweep (seconds)
integration = max(0.3, min(1.0, scanner_config.get('scan_delay', 0.5)))
cmd = [
rtl_power_path,
'-f', f'{start_hz}:{end_hz}:{bin_hz}',
'-i', f'{integration}',
'-1',
'-g', str(gain),
'-d', str(device),
]
try:
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
_state.scanner_power_process = proc
stdout, _ = proc.communicate(timeout=15)
except subprocess.TimeoutExpired:
proc.kill()
stdout = b''
finally:
_state.scanner_power_process = None
if not _state.scanner_running:
break
if not stdout:
add_activity_log('error', start_mhz, 'Power sweep produced no data')
try:
scanner_queue.put_nowait({
'type': 'scan_update',
'frequency': end_mhz,
'level': 0,
'threshold': int(float(scanner_config.get('snr_threshold', 12)) * 100),
'detected': False,
'range_start': scanner_config['start_freq'],
'range_end': scanner_config['end_freq']
})
except queue.Full:
pass
time.sleep(0.2)
continue
lines = stdout.decode(errors='ignore').splitlines()
segments = []
for line in lines:
if not line or line.startswith('#'):
continue
parts = [p.strip() for p in line.split(',')]
# Find start_hz token
start_idx = None
for i, tok in enumerate(parts):
try:
val = float(tok)
except ValueError:
continue
if val > 1e5:
start_idx = i
break
if start_idx is None or len(parts) < start_idx + 6:
continue
try:
sweep_start = float(parts[start_idx])
sweep_end = float(parts[start_idx + 1])
sweep_bin = float(parts[start_idx + 2])
raw_values = []
for v in parts[start_idx + 3:]:
try:
raw_values.append(float(v))
except ValueError:
continue
# rtl_power may include a samples field before the power list
if raw_values and raw_values[0] >= 0 and any(val < 0 for val in raw_values[1:]):
raw_values = raw_values[1:]
bin_values = raw_values
except ValueError:
continue
if not bin_values:
continue
segments.append((sweep_start, sweep_end, sweep_bin, bin_values))
if not segments:
add_activity_log('error', start_mhz, 'Power sweep bins missing')
try:
scanner_queue.put_nowait({
'type': 'scan_update',
'frequency': end_mhz,
'level': 0,
'threshold': int(float(scanner_config.get('snr_threshold', 12)) * 100),
'detected': False,
'range_start': scanner_config['start_freq'],
'range_end': scanner_config['end_freq']
})
except queue.Full:
pass
time.sleep(0.2)
continue
# Process segments in ascending frequency order to avoid backtracking in UI
segments.sort(key=lambda s: s[0])
total_bins = sum(len(seg[3]) for seg in segments)
if total_bins <= 0:
time.sleep(0.2)
continue
segment_offset = 0
for sweep_start, sweep_end, sweep_bin, bin_values in segments:
# Noise floor (median)
sorted_vals = sorted(bin_values)
mid = len(sorted_vals) // 2
noise_floor = sorted_vals[mid]
# SNR threshold (dB)
snr_threshold = float(scanner_config.get('snr_threshold', 12))
# Emit progress updates (throttled)
emit_stride = max(1, len(bin_values) // 60)
for idx, val in enumerate(bin_values):
if idx % emit_stride != 0 and idx != len(bin_values) - 1:
continue
freq_hz = sweep_start + sweep_bin * idx
_state.scanner_current_freq = freq_hz / 1e6
snr = val - noise_floor
level = int(max(0, snr) * 100)
threshold = int(snr_threshold * 100)
progress = min(1.0, (segment_offset + idx) / max(1, total_bins - 1))
try:
scanner_queue.put_nowait({
'type': 'scan_update',
'frequency': _state.scanner_current_freq,
'level': level,
'threshold': threshold,
'detected': snr >= snr_threshold,
'progress': progress,
'range_start': scanner_config['start_freq'],
'range_end': scanner_config['end_freq']
})
except queue.Full:
pass
segment_offset += len(bin_values)
# Detect peaks (clusters above threshold)
peaks = []
in_cluster = False
peak_idx = None
peak_val = None
for idx, val in enumerate(bin_values):
snr = val - noise_floor
if snr >= snr_threshold:
if not in_cluster:
in_cluster = True
peak_idx = idx
peak_val = val
else:
if val > peak_val:
peak_val = val
peak_idx = idx
else:
if in_cluster and peak_idx is not None:
peaks.append((peak_idx, peak_val))
in_cluster = False
peak_idx = None
peak_val = None
if in_cluster and peak_idx is not None:
peaks.append((peak_idx, peak_val))
for idx, val in peaks:
freq_hz = sweep_start + sweep_bin * (idx + 0.5)
freq_mhz = freq_hz / 1e6
snr = val - noise_floor
level = int(max(0, snr) * 100)
threshold = int(snr_threshold * 100)
add_activity_log('signal_found', freq_mhz,
f'Peak detected at {freq_mhz:.3f} MHz ({mod.upper()})')
try:
scanner_queue.put_nowait({
'type': 'signal_found',
'frequency': freq_mhz,
'modulation': mod,
'audio_streaming': False,
'level': level,
'threshold': threshold,
'snr': round(snr, 1),
'range_start': scanner_config['start_freq'],
'range_end': scanner_config['end_freq']
})
except queue.Full:
pass
add_activity_log('scan_cycle', start_mhz, 'Power sweep complete')
time.sleep(max(0.1, scanner_config.get('scan_delay', 0.5)))
except Exception as e:
logger.error(f"Power sweep scanner error: {e}")
finally:
_state.scanner_running = False
add_activity_log('scanner_stop', _state.scanner_current_freq, 'Scanner stopped')
logger.info("Power sweep scanner thread stopped")
# ============================================
# SCANNER API ENDPOINTS
# ============================================
@receiver_bp.route('/scanner/start', methods=['POST'])
def start_scanner() -> Response:
"""Start the frequency scanner."""
with scanner_lock:
if _state.scanner_running:
return jsonify({
'status': 'error',
'message': 'Scanner already running'
}), 409
# Clear stale queue entries so UI updates immediately
try:
while True:
scanner_queue.get_nowait()
except queue.Empty:
pass
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'] = normalize_modulation(data.get('modulation', 'wfm'))
scanner_config['squelch'] = int(data.get('squelch', 0))
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()
scanner_config['scan_method'] = str(data.get('scan_method', '')).lower().strip()
if data.get('snr_threshold') is not None:
scanner_config['snr_threshold'] = float(data.get('snr_threshold'))
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
# Decide scan method
if not scanner_config['scan_method']:
scanner_config['scan_method'] = 'power' if find_rtl_power() else 'classic'
sdr_type = scanner_config['sdr_type']
# Power scan only supports RTL-SDR for now
if scanner_config['scan_method'] == 'power':
if sdr_type != 'rtlsdr' or not find_rtl_power():
scanner_config['scan_method'] = 'classic'
# Check tools based on chosen method
if scanner_config['scan_method'] == 'power':
if not find_rtl_power():
return jsonify({
'status': 'error',
'message': 'rtl_power not found. Install rtl-sdr tools.'
}), 503
# Release listening device if active
if _state.receiver_active_device is not None:
app_module.release_sdr_device(_state.receiver_active_device, _state.receiver_active_sdr_type)
_state.receiver_active_device = None
_state.receiver_active_sdr_type = 'rtlsdr'
# Claim device for scanner
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner', scanner_config['sdr_type'])
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
_state.scanner_active_device = scanner_config['device']
_state.scanner_active_sdr_type = scanner_config['sdr_type']
_state.scanner_running = True
_state.scanner_thread = threading.Thread(target=scanner_loop_power, daemon=True)
_state.scanner_thread.start()
else:
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
if _state.receiver_active_device is not None:
app_module.release_sdr_device(_state.receiver_active_device, _state.receiver_active_sdr_type)
_state.receiver_active_device = None
_state.receiver_active_sdr_type = 'rtlsdr'
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner', scanner_config['sdr_type'])
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
_state.scanner_active_device = scanner_config['device']
_state.scanner_active_sdr_type = scanner_config['sdr_type']
_state.scanner_running = True
_state.scanner_thread = threading.Thread(target=scanner_loop, daemon=True)
_state.scanner_thread.start()
return jsonify({
'status': 'started',
'config': scanner_config
})
@receiver_bp.route('/scanner/stop', methods=['POST'])
def stop_scanner() -> Response:
"""Stop the frequency scanner."""
_state.scanner_running = False
_stop_audio_stream()
if _state.scanner_power_process and _state.scanner_power_process.poll() is None:
try:
_state.scanner_power_process.terminate()
_state.scanner_power_process.wait(timeout=1)
except Exception:
try:
_state.scanner_power_process.kill()
except Exception:
pass
_state.scanner_power_process = None
if _state.scanner_active_device is not None:
app_module.release_sdr_device(_state.scanner_active_device, _state.scanner_active_sdr_type)
_state.scanner_active_device = None
_state.scanner_active_sdr_type = 'rtlsdr'
return jsonify({'status': 'stopped'})
@receiver_bp.route('/scanner/pause', methods=['POST'])
def pause_scanner() -> Response:
"""Pause/resume the scanner."""
_state.scanner_paused = not _state.scanner_paused
if _state.scanner_paused:
add_activity_log('scanner_pause', _state.scanner_current_freq, 'Scanner paused')
else:
add_activity_log('scanner_resume', _state.scanner_current_freq, 'Scanner resumed')
return jsonify({
'status': 'paused' if _state.scanner_paused else 'resumed',
'paused': _state.scanner_paused
})
@receiver_bp.route('/scanner/skip', methods=['POST'])
def skip_signal() -> Response:
"""Skip current signal and continue scanning."""
if not _state.scanner_running:
return jsonify({
'status': 'error',
'message': 'Scanner not running'
}), 400
_state.scanner_skip_signal = True
add_activity_log('signal_skip', _state.scanner_current_freq, f'Skipped signal at {_state.scanner_current_freq:.3f} MHz')
return jsonify({
'status': 'skipped',
'frequency': _state.scanner_current_freq
})
@receiver_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:
try:
scanner_config['modulation'] = normalize_modulation(data['modulation'])
updated.append(f"mod={data['modulation']}")
except (ValueError, TypeError) as e:
return jsonify({
'status': 'error',
'message': str(e)
}), 400
if updated:
logger.info(f"Scanner config updated: {', '.join(updated)}")
return jsonify({
'status': 'updated',
'config': scanner_config
})
@receiver_bp.route('/scanner/status')
def scanner_status() -> Response:
"""Get scanner status."""
return jsonify({
'running': _state.scanner_running,
'paused': _state.scanner_paused,
'current_freq': _state.scanner_current_freq,
'config': scanner_config,
'audio_streaming': _state.audio_running,
'audio_frequency': _state.audio_frequency
})
@receiver_bp.route('/scanner/stream')
def stream_scanner_events() -> Response:
"""SSE stream for scanner events."""
def _on_msg(msg: dict[str, Any]) -> None:
process_event('receiver_scanner', msg, msg.get('type'))
response = Response(
sse_stream_fanout(
source_queue=scanner_queue,
channel_key='receiver_scanner',
timeout=SSE_QUEUE_TIMEOUT,
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
on_message=_on_msg,
),
mimetype='text/event-stream',
)
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
return response
@receiver_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)
})
@receiver_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'})
@receiver_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})
+91
View File
@@ -0,0 +1,91 @@
"""Tool check and signal identification routes."""
from __future__ import annotations
from flask import jsonify, request, Response
from . import (
receiver_bp,
logger,
find_rtl_fm,
find_rtl_power,
find_rx_fm,
find_ffmpeg,
)
# ============================================
# TOOL CHECK ENDPOINT
# ============================================
@receiver_bp.route('/tools')
def check_tools() -> Response:
"""Check for required tools."""
rtl_fm = find_rtl_fm()
rtl_power = find_rtl_power()
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,
'rtl_power': rtl_power 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
})
# ============================================
# SIGNAL IDENTIFICATION ENDPOINT
# ============================================
@receiver_bp.route('/signal/guess', methods=['POST'])
def guess_signal() -> Response:
"""Identify a signal based on frequency, modulation, and other parameters."""
data = request.json or {}
freq_mhz = data.get('frequency_mhz')
if freq_mhz is None:
return jsonify({'status': 'error', 'message': 'frequency_mhz is required'}), 400
try:
freq_mhz = float(freq_mhz)
except (ValueError, TypeError):
return jsonify({'status': 'error', 'message': 'Invalid frequency_mhz'}), 400
if freq_mhz <= 0:
return jsonify({'status': 'error', 'message': 'frequency_mhz must be positive'}), 400
frequency_hz = int(freq_mhz * 1e6)
modulation = data.get('modulation')
bandwidth_hz = data.get('bandwidth_hz')
if bandwidth_hz is not None:
try:
bandwidth_hz = int(bandwidth_hz)
except (ValueError, TypeError):
bandwidth_hz = None
region = data.get('region', 'UK/EU')
try:
from utils.signal_guess import guess_signal_type_dict
result = guess_signal_type_dict(
frequency_hz=frequency_hz,
modulation=modulation,
bandwidth_hz=bandwidth_hz,
region=region,
)
return jsonify({'status': 'ok', **result})
except Exception as e:
logger.error(f"Signal guess error: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500
+509
View File
@@ -0,0 +1,509 @@
"""Waterfall / spectrogram routes and implementation."""
from __future__ import annotations
import math
import queue
import struct
import subprocess
import threading
import time
from datetime import datetime
from typing import Any
from flask import jsonify, request, Response
from . import (
receiver_bp,
logger,
app_module,
_stop_waterfall_internal,
process_event,
sse_stream_fanout,
SSE_QUEUE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL,
find_rtl_power,
SDRFactory,
SDRType,
)
import routes.listening_post as _state
# ============================================
# WATERFALL HELPER FUNCTIONS
# ============================================
def _parse_rtl_power_line(line: str) -> tuple[str | None, float | None, float | None, list[float]]:
"""Parse a single rtl_power CSV line into bins."""
if not line or line.startswith('#'):
return None, None, None, []
parts = [p.strip() for p in line.split(',')]
if len(parts) < 6:
return None, None, None, []
# Timestamp in first two fields (YYYY-MM-DD, HH:MM:SS)
timestamp = f"{parts[0]} {parts[1]}" if len(parts) >= 2 else parts[0]
start_idx = None
for i, tok in enumerate(parts):
try:
val = float(tok)
except ValueError:
continue
if val > 1e5:
start_idx = i
break
if start_idx is None or len(parts) < start_idx + 4:
return timestamp, None, None, []
try:
seg_start = float(parts[start_idx])
seg_end = float(parts[start_idx + 1])
raw_values = []
for v in parts[start_idx + 3:]:
try:
raw_values.append(float(v))
except ValueError:
continue
if raw_values and raw_values[0] >= 0 and any(val < 0 for val in raw_values[1:]):
raw_values = raw_values[1:]
return timestamp, seg_start, seg_end, raw_values
except ValueError:
return timestamp, None, None, []
def _queue_waterfall_error(message: str) -> None:
"""Push an error message onto the waterfall SSE queue."""
try:
_state.waterfall_queue.put_nowait({
'type': 'waterfall_error',
'message': message,
'timestamp': datetime.now().isoformat(),
})
except queue.Full:
pass
def _downsample_bins(values: list[float], target: int) -> list[float]:
"""Downsample bins to a target length using simple averaging."""
if target <= 0 or len(values) <= target:
return values
out: list[float] = []
step = len(values) / target
for i in range(target):
start = int(i * step)
end = int((i + 1) * step)
if end <= start:
end = min(start + 1, len(values))
chunk = values[start:end]
if not chunk:
continue
out.append(sum(chunk) / len(chunk))
return out
# ============================================
# WATERFALL LOOP IMPLEMENTATIONS
# ============================================
def _waterfall_loop():
"""Continuous waterfall sweep loop emitting FFT data."""
sdr_type_str = _state.waterfall_config.get('sdr_type', 'rtlsdr')
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
if sdr_type == SDRType.RTL_SDR:
_waterfall_loop_rtl_power()
else:
_waterfall_loop_iq(sdr_type)
def _waterfall_loop_iq(sdr_type: SDRType):
"""Waterfall loop using rx_sdr IQ capture + FFT for HackRF/SoapySDR devices."""
start_freq = _state.waterfall_config['start_freq']
end_freq = _state.waterfall_config['end_freq']
gain = _state.waterfall_config['gain']
device = _state.waterfall_config['device']
interval = float(_state.waterfall_config.get('interval', 0.4))
# Use center frequency and sample rate to cover the requested span
center_mhz = (start_freq + end_freq) / 2.0
span_hz = (end_freq - start_freq) * 1e6
# Pick a sample rate that covers the span (minimum 2 MHz for HackRF)
sample_rate = max(2000000, int(span_hz))
# Cap to sensible maximum
sample_rate = min(sample_rate, 20000000)
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
builder = SDRFactory.get_builder(sdr_type)
cmd = builder.build_iq_capture_command(
device=sdr_device,
frequency_mhz=center_mhz,
sample_rate=sample_rate,
gain=float(gain),
)
fft_size = min(int(_state.waterfall_config.get('max_bins') or 1024), 4096)
try:
_state.waterfall_process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
# Detect immediate startup failures
time.sleep(0.35)
if _state.waterfall_process.poll() is not None:
stderr_text = ''
try:
if _state.waterfall_process.stderr:
stderr_text = _state.waterfall_process.stderr.read().decode('utf-8', errors='ignore').strip()
except Exception:
stderr_text = ''
msg = stderr_text or f'IQ capture exited early (code {_state.waterfall_process.returncode})'
logger.error(f"Waterfall startup failed: {msg}")
_queue_waterfall_error(msg)
return
if not _state.waterfall_process.stdout:
_queue_waterfall_error('IQ capture stdout unavailable')
return
# Read IQ samples and compute FFT
# CU8 format: interleaved unsigned 8-bit I/Q pairs
bytes_per_sample = 2 # 1 byte I + 1 byte Q
chunk_bytes = fft_size * bytes_per_sample
received_any = False
while _state.waterfall_running:
raw = _state.waterfall_process.stdout.read(chunk_bytes)
if not raw or len(raw) < chunk_bytes:
if _state.waterfall_process.poll() is not None:
break
continue
received_any = True
# Convert CU8 to complex float: center at 127.5
iq = struct.unpack(f'{fft_size * 2}B', raw)
# Compute power spectrum via FFT
real_parts = [(iq[i * 2] - 127.5) / 127.5 for i in range(fft_size)]
imag_parts = [(iq[i * 2 + 1] - 127.5) / 127.5 for i in range(fft_size)]
bins: list[float] = []
try:
# Try numpy if available for efficient FFT
import numpy as np
samples = np.array(real_parts, dtype=np.float32) + 1j * np.array(imag_parts, dtype=np.float32)
# Apply Hann window
window = np.hanning(fft_size)
samples *= window
spectrum = np.fft.fftshift(np.fft.fft(samples))
power_db = 10.0 * np.log10(np.abs(spectrum) ** 2 + 1e-10)
bins = power_db.tolist()
except ImportError:
# Fallback: compute magnitude without full FFT
# Just report raw magnitudes per sample as approximate power
for i in range(fft_size):
mag = math.sqrt(real_parts[i] ** 2 + imag_parts[i] ** 2)
power = 10.0 * math.log10(mag ** 2 + 1e-10)
bins.append(power)
max_bins = int(_state.waterfall_config.get('max_bins') or 0)
if max_bins > 0 and len(bins) > max_bins:
bins = _downsample_bins(bins, max_bins)
msg = {
'type': 'waterfall_sweep',
'start_freq': start_freq,
'end_freq': end_freq,
'bins': bins,
'timestamp': datetime.now().isoformat(),
}
try:
_state.waterfall_queue.put_nowait(msg)
except queue.Full:
try:
_state.waterfall_queue.get_nowait()
except queue.Empty:
pass
try:
_state.waterfall_queue.put_nowait(msg)
except queue.Full:
pass
# Throttle to respect interval
time.sleep(interval)
if _state.waterfall_running and not received_any:
_queue_waterfall_error(f'No IQ data received from {sdr_type.value}')
except Exception as e:
logger.error(f"Waterfall IQ loop error: {e}")
_queue_waterfall_error(f"Waterfall loop error: {e}")
finally:
_state.waterfall_running = False
if _state.waterfall_process and _state.waterfall_process.poll() is None:
try:
_state.waterfall_process.terminate()
_state.waterfall_process.wait(timeout=1)
except Exception:
try:
_state.waterfall_process.kill()
except Exception:
pass
_state.waterfall_process = None
logger.info("Waterfall IQ loop stopped")
def _waterfall_loop_rtl_power():
"""Continuous rtl_power sweep loop emitting waterfall data."""
rtl_power_path = find_rtl_power()
if not rtl_power_path:
logger.error("rtl_power not found for waterfall")
_queue_waterfall_error('rtl_power not found')
_state.waterfall_running = False
return
start_hz = int(_state.waterfall_config['start_freq'] * 1e6)
end_hz = int(_state.waterfall_config['end_freq'] * 1e6)
bin_hz = int(_state.waterfall_config['bin_size'])
gain = _state.waterfall_config['gain']
device = _state.waterfall_config['device']
interval = float(_state.waterfall_config.get('interval', 0.4))
cmd = [
rtl_power_path,
'-f', f'{start_hz}:{end_hz}:{bin_hz}',
'-i', str(interval),
'-g', str(gain),
'-d', str(device),
]
try:
_state.waterfall_process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=1,
text=True,
)
# Detect immediate startup failures (e.g. device busy / no device).
time.sleep(0.35)
if _state.waterfall_process.poll() is not None:
stderr_text = ''
try:
if _state.waterfall_process.stderr:
stderr_text = _state.waterfall_process.stderr.read().strip()
except Exception:
stderr_text = ''
msg = stderr_text or f'rtl_power exited early (code {_state.waterfall_process.returncode})'
logger.error(f"Waterfall startup failed: {msg}")
_queue_waterfall_error(msg)
return
current_ts = None
all_bins: list[float] = []
sweep_start_hz = start_hz
sweep_end_hz = end_hz
received_any = False
if not _state.waterfall_process.stdout:
_queue_waterfall_error('rtl_power stdout unavailable')
return
for line in _state.waterfall_process.stdout:
if not _state.waterfall_running:
break
ts, seg_start, seg_end, bins = _parse_rtl_power_line(line)
if ts is None or not bins:
continue
received_any = True
if current_ts is None:
current_ts = ts
if ts != current_ts and all_bins:
max_bins = int(_state.waterfall_config.get('max_bins') or 0)
bins_to_send = all_bins
if max_bins > 0 and len(bins_to_send) > max_bins:
bins_to_send = _downsample_bins(bins_to_send, max_bins)
msg = {
'type': 'waterfall_sweep',
'start_freq': sweep_start_hz / 1e6,
'end_freq': sweep_end_hz / 1e6,
'bins': bins_to_send,
'timestamp': datetime.now().isoformat(),
}
try:
_state.waterfall_queue.put_nowait(msg)
except queue.Full:
try:
_state.waterfall_queue.get_nowait()
except queue.Empty:
pass
try:
_state.waterfall_queue.put_nowait(msg)
except queue.Full:
pass
all_bins = []
sweep_start_hz = start_hz
sweep_end_hz = end_hz
current_ts = ts
all_bins.extend(bins)
if seg_start is not None:
sweep_start_hz = min(sweep_start_hz, seg_start)
if seg_end is not None:
sweep_end_hz = max(sweep_end_hz, seg_end)
# Flush any remaining bins
if all_bins and _state.waterfall_running:
max_bins = int(_state.waterfall_config.get('max_bins') or 0)
bins_to_send = all_bins
if max_bins > 0 and len(bins_to_send) > max_bins:
bins_to_send = _downsample_bins(bins_to_send, max_bins)
msg = {
'type': 'waterfall_sweep',
'start_freq': sweep_start_hz / 1e6,
'end_freq': sweep_end_hz / 1e6,
'bins': bins_to_send,
'timestamp': datetime.now().isoformat(),
}
try:
_state.waterfall_queue.put_nowait(msg)
except queue.Full:
pass
if _state.waterfall_running and not received_any:
_queue_waterfall_error('No waterfall FFT data received from rtl_power')
except Exception as e:
logger.error(f"Waterfall loop error: {e}")
_queue_waterfall_error(f"Waterfall loop error: {e}")
finally:
_state.waterfall_running = False
if _state.waterfall_process and _state.waterfall_process.poll() is None:
try:
_state.waterfall_process.terminate()
_state.waterfall_process.wait(timeout=1)
except Exception:
try:
_state.waterfall_process.kill()
except Exception:
pass
_state.waterfall_process = None
logger.info("Waterfall loop stopped")
# ============================================
# WATERFALL API ENDPOINTS
# ============================================
@receiver_bp.route('/waterfall/start', methods=['POST'])
def start_waterfall() -> Response:
"""Start the waterfall/spectrogram display."""
with _state.waterfall_lock:
if _state.waterfall_running:
return jsonify({
'status': 'started',
'already_running': True,
'message': 'Waterfall already running',
'config': _state.waterfall_config,
})
data = request.json or {}
# Determine SDR type
sdr_type_str = data.get('sdr_type', 'rtlsdr')
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
sdr_type_str = sdr_type.value
# RTL-SDR uses rtl_power; other types use rx_sdr via IQ capture
if sdr_type == SDRType.RTL_SDR:
if not find_rtl_power():
return jsonify({'status': 'error', 'message': 'rtl_power not found'}), 503
try:
_state.waterfall_config['start_freq'] = float(data.get('start_freq', 88.0))
_state.waterfall_config['end_freq'] = float(data.get('end_freq', 108.0))
_state.waterfall_config['bin_size'] = int(data.get('bin_size', 10000))
_state.waterfall_config['gain'] = int(data.get('gain', 40))
_state.waterfall_config['device'] = int(data.get('device', 0))
_state.waterfall_config['sdr_type'] = sdr_type_str
if data.get('interval') is not None:
interval = float(data.get('interval', _state.waterfall_config['interval']))
if interval < 0.1 or interval > 5:
return jsonify({'status': 'error', 'message': 'interval must be between 0.1 and 5 seconds'}), 400
_state.waterfall_config['interval'] = interval
if data.get('max_bins') is not None:
max_bins = int(data.get('max_bins', _state.waterfall_config['max_bins']))
if max_bins < 64 or max_bins > 4096:
return jsonify({'status': 'error', 'message': 'max_bins must be between 64 and 4096'}), 400
_state.waterfall_config['max_bins'] = max_bins
except (ValueError, TypeError) as e:
return jsonify({'status': 'error', 'message': f'Invalid parameter: {e}'}), 400
if _state.waterfall_config['start_freq'] >= _state.waterfall_config['end_freq']:
return jsonify({'status': 'error', 'message': 'start_freq must be less than end_freq'}), 400
# Clear stale queue
try:
while True:
_state.waterfall_queue.get_nowait()
except queue.Empty:
pass
# Claim SDR device
error = app_module.claim_sdr_device(_state.waterfall_config['device'], 'waterfall', sdr_type_str)
if error:
return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409
_state.waterfall_active_device = _state.waterfall_config['device']
_state.waterfall_active_sdr_type = sdr_type_str
_state.waterfall_running = True
_state.waterfall_thread = threading.Thread(target=_waterfall_loop, daemon=True)
_state.waterfall_thread.start()
return jsonify({'status': 'started', 'config': _state.waterfall_config})
@receiver_bp.route('/waterfall/stop', methods=['POST'])
def stop_waterfall() -> Response:
"""Stop the waterfall display."""
_stop_waterfall_internal()
return jsonify({'status': 'stopped'})
@receiver_bp.route('/waterfall/stream')
def stream_waterfall() -> Response:
"""SSE stream for waterfall data."""
def _on_msg(msg: dict[str, Any]) -> None:
process_event('waterfall', msg, msg.get('type'))
response = Response(
sse_stream_fanout(
source_queue=_state.waterfall_queue,
channel_key='receiver_waterfall',
timeout=SSE_QUEUE_TIMEOUT,
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
on_message=_on_msg,
),
mimetype='text/event-stream',
)
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
return response
+18 -17
View File
@@ -16,8 +16,9 @@ from typing import Generator
from flask import Blueprint, jsonify, request, Response from flask import Blueprint, jsonify, request, Response
from utils.responses import api_success, api_error
from utils.logging import get_logger from utils.logging import get_logger
from utils.sse import sse_stream_fanout from utils.sse import sse_stream_fanout
from utils.meshtastic import ( from utils.meshtastic import (
get_meshtastic_client, get_meshtastic_client,
start_meshtastic, start_meshtastic,
@@ -453,8 +454,8 @@ def get_messages():
}) })
@meshtastic_bp.route('/stream') @meshtastic_bp.route('/stream')
def stream_messages(): def stream_messages():
""" """
SSE stream of Meshtastic messages. SSE stream of Meshtastic messages.
@@ -469,18 +470,18 @@ def stream_messages():
Returns: Returns:
SSE stream (text/event-stream) SSE stream (text/event-stream)
""" """
response = Response( response = Response(
sse_stream_fanout( sse_stream_fanout(
source_queue=_mesh_queue, source_queue=_mesh_queue,
channel_key='meshtastic', channel_key='meshtastic',
timeout=1.0, timeout=1.0,
keepalive_interval=30.0, keepalive_interval=30.0,
), ),
mimetype='text/event-stream', mimetype='text/event-stream',
) )
response.headers['Cache-Control'] = 'no-cache' response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no' response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive' response.headers['Connection'] = 'keep-alive'
return response return response
@@ -1050,11 +1051,11 @@ def request_store_forward():
def mesh_topology(): def mesh_topology():
"""Return mesh network topology graph.""" """Return mesh network topology graph."""
if not is_meshtastic_available(): if not is_meshtastic_available():
return jsonify({'status': 'error', 'message': 'Meshtastic SDK not installed'}), 400 return api_error('Meshtastic SDK not installed', 400)
client = get_meshtastic_client() client = get_meshtastic_client()
if not client or not client.is_running: if not client or not client.is_running:
return jsonify({'status': 'error', 'message': 'Not connected'}), 400 return api_error('Not connected', 400)
return jsonify({ return jsonify({
'status': 'success', 'status': 'success',
+3 -1
View File
@@ -20,6 +20,8 @@ from typing import Any
from flask import Blueprint, Flask, Response, jsonify, request from flask import Blueprint, Flask, Response, jsonify, request
from utils.responses import api_success, api_error
try: try:
from flask_sock import Sock from flask_sock import Sock
WEBSOCKET_AVAILABLE = True WEBSOCKET_AVAILABLE = True
@@ -170,7 +172,7 @@ def meteor_events_export():
"""Export events as CSV or JSON.""" """Export events as CSV or JSON."""
detector = _detector detector = _detector
if not detector: if not detector:
return jsonify({'error': 'No active session'}), 400 return api_error('No active session', 400)
fmt = request.args.get('format', 'json').lower() fmt = request.args.get('format', 'json').lower()
if fmt == 'csv': if fmt == 'csv':
+12 -11
View File
@@ -13,6 +13,7 @@ from typing import Any
from flask import Blueprint, Response, jsonify, request from flask import Blueprint, Response, jsonify, request
from utils.responses import api_success, api_error
import app as app_module import app as app_module
from utils.event_pipeline import process_event from utils.event_pipeline import process_event
from utils.logging import sensor_logger as logger from utils.logging import sensor_logger as logger
@@ -252,7 +253,7 @@ def start_morse() -> Response:
try: try:
detect_mode = _validate_detect_mode(data.get('detect_mode', 'goertzel')) detect_mode = _validate_detect_mode(data.get('detect_mode', 'goertzel'))
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
freq_max = 1766.0 if detect_mode == 'envelope' else 30.0 freq_max = 1766.0 if detect_mode == 'envelope' else 30.0
try: try:
@@ -261,7 +262,7 @@ def start_morse() -> Response:
ppm = validate_ppm(data.get('ppm', '0')) ppm = validate_ppm(data.get('ppm', '0'))
device = validate_device_index(data.get('device', '0')) device = validate_device_index(data.get('device', '0'))
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
try: try:
tone_freq = _validate_tone_freq(data.get('tone_freq', '700')) tone_freq = _validate_tone_freq(data.get('tone_freq', '700'))
@@ -277,7 +278,7 @@ def start_morse() -> Response:
tone_lock = _bool_value(data.get('tone_lock', False), False) tone_lock = _bool_value(data.get('tone_lock', False), False)
wpm_lock = _bool_value(data.get('wpm_lock', False), False) wpm_lock = _bool_value(data.get('wpm_lock', False), False)
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
sdr_type_str = data.get('sdr_type', 'rtlsdr') sdr_type_str = data.get('sdr_type', 'rtlsdr')
@@ -335,7 +336,7 @@ def start_morse() -> Response:
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host) rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port) rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
network_sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port) network_sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}") logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
@@ -696,7 +697,7 @@ def start_morse() -> Response:
morse_last_error = msg morse_last_error = msg
_set_state(MORSE_ERROR, msg) _set_state(MORSE_ERROR, msg)
_set_state(MORSE_IDLE, 'Idle') _set_state(MORSE_IDLE, 'Idle')
return jsonify({'status': 'error', 'message': msg}), 500 return api_error(msg, 500)
with app_module.morse_lock: with app_module.morse_lock:
app_module.morse_process = active_rtl_process app_module.morse_process = active_rtl_process
@@ -740,7 +741,7 @@ def start_morse() -> Response:
morse_last_error = f'Tool not found: {e.filename}' morse_last_error = f'Tool not found: {e.filename}'
_set_state(MORSE_ERROR, morse_last_error) _set_state(MORSE_ERROR, morse_last_error)
_set_state(MORSE_IDLE, 'Idle') _set_state(MORSE_IDLE, 'Idle')
return jsonify({'status': 'error', 'message': morse_last_error}), 400 return api_error(morse_last_error, 400)
except Exception as e: except Exception as e:
_cleanup_attempt( _cleanup_attempt(
@@ -758,7 +759,7 @@ def start_morse() -> Response:
morse_last_error = str(e) morse_last_error = str(e)
_set_state(MORSE_ERROR, morse_last_error) _set_state(MORSE_ERROR, morse_last_error)
_set_state(MORSE_IDLE, 'Idle') _set_state(MORSE_IDLE, 'Idle')
return jsonify({'status': 'error', 'message': str(e)}), 500 return api_error(str(e), 500)
@morse_bp.route('/morse/stop', methods=['POST']) @morse_bp.route('/morse/stop', methods=['POST'])
@@ -908,11 +909,11 @@ def calibrate_morse() -> Response:
def decode_morse_file() -> Response: def decode_morse_file() -> Response:
"""Decode Morse from an uploaded WAV file.""" """Decode Morse from an uploaded WAV file."""
if 'audio' not in request.files: if 'audio' not in request.files:
return jsonify({'status': 'error', 'message': 'No audio file provided'}), 400 return api_error('No audio file provided', 400)
audio_file = request.files['audio'] audio_file = request.files['audio']
if not audio_file.filename: if not audio_file.filename:
return jsonify({'status': 'error', 'message': 'No file selected'}), 400 return api_error('No file selected', 400)
# Parse optional tuning/decoder parameters from form fields. # Parse optional tuning/decoder parameters from form fields.
form = request.form or {} form = request.form or {}
@@ -930,7 +931,7 @@ def decode_morse_file() -> Response:
tone_lock = _bool_value(form.get('tone_lock', 'false'), False) tone_lock = _bool_value(form.get('tone_lock', 'false'), False)
wpm_lock = _bool_value(form.get('wpm_lock', 'false'), False) wpm_lock = _bool_value(form.get('wpm_lock', 'false'), False)
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp: with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp:
audio_file.save(tmp.name) audio_file.save(tmp.name)
@@ -968,7 +969,7 @@ def decode_morse_file() -> Response:
}) })
except Exception as e: except Exception as e:
logger.error(f'Morse decode-file error: {e}') logger.error(f'Morse decode-file error: {e}')
return jsonify({'status': 'error', 'message': str(e)}), 500 return api_error(str(e), 500)
finally: finally:
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
tmp_path.unlink(missing_ok=True) tmp_path.unlink(missing_ok=True)
+18 -32
View File
@@ -4,19 +4,20 @@ Offline mode routes - Asset management and settings for offline operation.
from flask import Blueprint, jsonify, request from flask import Blueprint, jsonify, request
from utils.database import get_setting, set_setting from utils.database import get_setting, set_setting
from utils.responses import api_success, api_error
import os import os
offline_bp = Blueprint('offline', __name__, url_prefix='/offline') offline_bp = Blueprint('offline', __name__, url_prefix='/offline')
# Default offline settings # Default offline settings
OFFLINE_DEFAULTS = { OFFLINE_DEFAULTS = {
'offline.enabled': False, 'offline.enabled': False,
# Default to bundled assets/fonts to avoid third-party CDN privacy blocks. # Default to bundled assets/fonts to avoid third-party CDN privacy blocks.
'offline.assets_source': 'local', 'offline.assets_source': 'local',
'offline.fonts_source': 'local', 'offline.fonts_source': 'local',
'offline.tile_provider': 'cartodb_dark_cyan', 'offline.tile_provider': 'cartodb_dark_cyan',
'offline.tile_server_url': '' 'offline.tile_server_url': ''
} }
# Asset paths to check # Asset paths to check
ASSET_PATHS = { ASSET_PATHS = {
@@ -64,10 +65,7 @@ def get_offline_settings():
def get_settings(): def get_settings():
"""Get current offline settings.""" """Get current offline settings."""
settings = get_offline_settings() settings = get_offline_settings()
return jsonify({ return api_success(data={'settings': settings})
'status': 'success',
'settings': settings
})
@offline_bp.route('/settings', methods=['POST']) @offline_bp.route('/settings', methods=['POST'])
@@ -75,14 +73,14 @@ def save_setting():
"""Save an offline setting.""" """Save an offline setting."""
data = request.get_json() data = request.get_json()
if not data or 'key' not in data or 'value' not in data: if not data or 'key' not in data or 'value' not in data:
return jsonify({'status': 'error', 'message': 'Missing key or value'}), 400 return api_error('Missing key or value', 400)
key = data['key'] key = data['key']
value = data['value'] value = data['value']
# Validate key is an allowed setting # Validate key is an allowed setting
if key not in OFFLINE_DEFAULTS: if key not in OFFLINE_DEFAULTS:
return jsonify({'status': 'error', 'message': f'Unknown setting: {key}'}), 400 return api_error(f'Unknown setting: {key}', 400)
# Validate value type matches default # Validate value type matches default
default_type = type(OFFLINE_DEFAULTS[key]) default_type = type(OFFLINE_DEFAULTS[key])
@@ -94,18 +92,11 @@ def save_setting():
else: else:
value = default_type(value) value = default_type(value)
except (ValueError, TypeError): except (ValueError, TypeError):
return jsonify({ return api_error(f'Invalid value type for {key}', 400)
'status': 'error',
'message': f'Invalid value type for {key}'
}), 400
set_setting(key, value) set_setting(key, value)
return jsonify({ return api_success(data={'key': key, 'value': value})
'status': 'success',
'key': key,
'value': value
})
@offline_bp.route('/status', methods=['GET']) @offline_bp.route('/status', methods=['GET'])
@@ -134,8 +125,7 @@ def get_status():
if not available: if not available:
all_available = False all_available = False
return jsonify({ return api_success(data={
'status': 'success',
'all_available': all_available, 'all_available': all_available,
'assets': results, 'assets': results,
'offline_enabled': get_setting('offline.enabled', False) 'offline_enabled': get_setting('offline.enabled', False)
@@ -147,11 +137,11 @@ def check_asset():
"""Check if a specific asset file exists.""" """Check if a specific asset file exists."""
path = request.args.get('path', '') path = request.args.get('path', '')
if not path: if not path:
return jsonify({'status': 'error', 'message': 'Missing path parameter'}), 400 return api_error('Missing path parameter', 400)
# Security: only allow checking within static/vendor # Security: only allow checking within static/vendor
if not path.startswith('/static/vendor/'): if not path.startswith('/static/vendor/'):
return jsonify({'status': 'error', 'message': 'Invalid path'}), 400 return api_error('Invalid path', 400)
# Remove leading slash and construct full path # Remove leading slash and construct full path
relative_path = path.lstrip('/') relative_path = path.lstrip('/')
@@ -160,8 +150,4 @@ def check_asset():
exists = os.path.exists(full_path) exists = os.path.exists(full_path)
return jsonify({ return api_success(data={'path': path, 'exists': exists})
'status': 'success',
'path': path,
'exists': exists
})
+11 -14
View File
@@ -19,6 +19,7 @@ from flask import Blueprint, Response, jsonify, request
import app as app_module import app as app_module
from utils.event_pipeline import process_event from utils.event_pipeline import process_event
from utils.responses import api_success, api_error
from utils.logging import sensor_logger as logger from utils.logging import sensor_logger as logger
from utils.ook import ook_parser_thread from utils.ook import ook_parser_thread
from utils.process import register_process, safe_terminate, unregister_process from utils.process import register_process, safe_terminate, unregister_process
@@ -69,7 +70,7 @@ def start_ook() -> Response:
if app_module.ook_process.poll() is not None: if app_module.ook_process.poll() is not None:
cleanup_ook(emit_status=False) cleanup_ook(emit_status=False)
else: else:
return jsonify({'status': 'error', 'message': 'OOK decoder already running'}), 409 return api_error('OOK decoder already running', 409)
data = request.json or {} data = request.json or {}
@@ -79,12 +80,12 @@ def start_ook() -> Response:
ppm = validate_ppm(data.get('ppm', '0')) ppm = validate_ppm(data.get('ppm', '0'))
device = validate_device_index(data.get('device', '0')) device = validate_device_index(data.get('device', '0'))
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
try: try:
encoding = _validate_encoding(data.get('encoding', 'pwm')) encoding = _validate_encoding(data.get('encoding', 'pwm'))
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
# OOK flex decoder timing parameters (server-side range validation) # OOK flex decoder timing parameters (server-side range validation)
try: try:
@@ -95,11 +96,11 @@ def start_ook() -> Response:
tolerance = validate_positive_int(data.get('tolerance', 150), 'tolerance', max_val=50000) tolerance = validate_positive_int(data.get('tolerance', 150), 'tolerance', max_val=50000)
min_bits = validate_positive_int(data.get('min_bits', 8), 'min_bits', max_val=4096) min_bits = validate_positive_int(data.get('min_bits', 8), 'min_bits', max_val=4096)
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': f'Invalid timing parameter: {e}'}), 400 return api_error(f'Invalid timing parameter: {e}', 400)
if min_bits < 1: if min_bits < 1:
return jsonify({'status': 'error', 'message': 'min_bits must be >= 1'}), 400 return api_error('min_bits must be >= 1', 400)
if short_pulse < 1 or long_pulse < 1: if short_pulse < 1 or long_pulse < 1:
return jsonify({'status': 'error', 'message': 'Pulse widths must be >= 1'}), 400 return api_error('Pulse widths must be >= 1', 400)
deduplicate = bool(data.get('deduplicate', False)) deduplicate = bool(data.get('deduplicate', False))
# Parse SDR type early — needed for device claim # Parse SDR type early — needed for device claim
@@ -117,11 +118,7 @@ def start_ook() -> Response:
device_int = int(device) device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'ook', sdr_type_str) error = app_module.claim_sdr_device(device_int, 'ook', sdr_type_str)
if error: if error:
return jsonify({ return api_error(error, 409, error_type='DEVICE_BUSY')
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error,
}), 409
ook_active_device = device_int ook_active_device = device_int
ook_active_sdr_type = sdr_type_str ook_active_sdr_type = sdr_type_str
@@ -136,7 +133,7 @@ def start_ook() -> Response:
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host) rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port) rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port) sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
logger.info(f'Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}') logger.info(f'Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}')
else: else:
@@ -237,7 +234,7 @@ def start_ook() -> Response:
app_module.release_sdr_device(ook_active_device, ook_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(ook_active_device, ook_active_sdr_type or 'rtlsdr')
ook_active_device = None ook_active_device = None
ook_active_sdr_type = None ook_active_sdr_type = None
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'}), 400 return api_error(f'Tool not found: {e.filename}', 400)
except Exception as e: except Exception as e:
try: try:
@@ -251,7 +248,7 @@ def start_ook() -> Response:
app_module.release_sdr_device(ook_active_device, ook_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(ook_active_device, ook_active_sdr_type or 'rtlsdr')
ook_active_device = None ook_active_device = None
ook_active_sdr_type = None ook_active_sdr_type = None
return jsonify({'status': 'error', 'message': str(e)}), 500 return api_error(str(e), 500)
def _close_pipe(pipe_obj) -> None: def _close_pipe(pipe_obj) -> None:
+13 -16
View File
@@ -18,6 +18,7 @@ from typing import Any, Generator
from flask import Blueprint, jsonify, request, Response from flask import Blueprint, jsonify, request, Response
from utils.responses import api_success, api_error
import app as app_module import app as app_module
from utils.logging import pager_logger as logger from utils.logging import pager_logger as logger
from utils.validation import ( from utils.validation import (
@@ -275,7 +276,7 @@ def start_decoding() -> Response:
with app_module.process_lock: with app_module.process_lock:
if app_module.current_process: if app_module.current_process:
return jsonify({'status': 'error', 'message': 'Already running'}), 409 return api_error('Already running', 409)
data = request.json or {} data = request.json or {}
@@ -286,7 +287,7 @@ def start_decoding() -> Response:
ppm = validate_ppm(data.get('ppm', '0')) ppm = validate_ppm(data.get('ppm', '0'))
device = validate_device_index(data.get('device', '0')) device = validate_device_index(data.get('device', '0'))
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
squelch = data.get('squelch', '0') squelch = data.get('squelch', '0')
try: try:
@@ -294,7 +295,7 @@ def start_decoding() -> Response:
if not 0 <= squelch <= 1000: if not 0 <= squelch <= 1000:
raise ValueError("Squelch must be between 0 and 1000") raise ValueError("Squelch must be between 0 and 1000")
except (ValueError, TypeError): except (ValueError, TypeError):
return jsonify({'status': 'error', 'message': 'Invalid squelch value'}), 400 return api_error('Invalid squelch value', 400)
# Check for rtl_tcp (remote SDR) connection # Check for rtl_tcp (remote SDR) connection
rtl_tcp_host = data.get('rtl_tcp_host') rtl_tcp_host = data.get('rtl_tcp_host')
@@ -308,11 +309,7 @@ def start_decoding() -> Response:
device_int = int(device) device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'pager', sdr_type_str) error = app_module.claim_sdr_device(device_int, 'pager', sdr_type_str)
if error: if error:
return jsonify({ return api_error(error, 409, error_type='DEVICE_BUSY')
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
pager_active_device = device_int pager_active_device = device_int
pager_active_sdr_type = sdr_type_str pager_active_sdr_type = sdr_type_str
@@ -324,7 +321,7 @@ def start_decoding() -> Response:
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
pager_active_device = None pager_active_device = None
pager_active_sdr_type = None pager_active_sdr_type = None
return jsonify({'status': 'error', 'message': 'Protocols must be a list'}), 400 return api_error('Protocols must be a list', 400)
protocols = [p for p in protocols if p in valid_protocols] protocols = [p for p in protocols if p in valid_protocols]
if not protocols: if not protocols:
protocols = valid_protocols protocols = valid_protocols
@@ -360,7 +357,7 @@ def start_decoding() -> Response:
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host) rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port) rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port) sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}") logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
@@ -385,7 +382,7 @@ def start_decoding() -> Response:
multimon_path = get_tool_path('multimon-ng') multimon_path = get_tool_path('multimon-ng')
if not multimon_path: if not multimon_path:
return jsonify({'status': 'error', 'message': 'multimon-ng not found'}), 400 return api_error('multimon-ng not found', 400)
multimon_cmd = [multimon_path, '-t', 'raw'] + decoders + ['-f', 'alpha', '-'] multimon_cmd = [multimon_path, '-t', 'raw'] + decoders + ['-f', 'alpha', '-']
full_cmd = ' '.join(rtl_cmd) + ' | ' + ' '.join(multimon_cmd) full_cmd = ' '.join(rtl_cmd) + ' | ' + ' '.join(multimon_cmd)
@@ -466,7 +463,7 @@ def start_decoding() -> Response:
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
pager_active_device = None pager_active_device = None
pager_active_sdr_type = None pager_active_sdr_type = None
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'}) return api_error(f'Tool not found: {e.filename}')
except Exception as e: except Exception as e:
# Kill orphaned rtl_fm process if it was started # Kill orphaned rtl_fm process if it was started
try: try:
@@ -482,7 +479,7 @@ def start_decoding() -> Response:
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
pager_active_device = None pager_active_device = None
pager_active_sdr_type = None pager_active_sdr_type = None
return jsonify({'status': 'error', 'message': str(e)}) return api_error(str(e))
@pager_bp.route('/stop', methods=['POST']) @pager_bp.route('/stop', methods=['POST'])
@@ -562,16 +559,16 @@ def toggle_logging() -> Response:
is_in_logs = str(requested_path).startswith(str(logs_dir)) is_in_logs = str(requested_path).startswith(str(logs_dir))
if not (is_in_cwd or is_in_logs): if not (is_in_cwd or is_in_logs):
return jsonify({'status': 'error', 'message': 'Invalid log file path'}), 400 return api_error('Invalid log file path', 400)
# Ensure it's not a directory # Ensure it's not a directory
if requested_path.is_dir(): if requested_path.is_dir():
return jsonify({'status': 'error', 'message': 'Log file path must be a file, not a directory'}), 400 return api_error('Log file path must be a file, not a directory', 400)
app_module.log_file_path = str(requested_path) app_module.log_file_path = str(requested_path)
except (ValueError, OSError) as e: except (ValueError, OSError) as e:
logger.warning(f"Invalid log file path: {e}") logger.warning(f"Invalid log file path: {e}")
return jsonify({'status': 'error', 'message': 'Invalid log file path'}), 400 return api_error('Invalid log file path', 400)
return jsonify({'logging': app_module.logging_enabled, 'log_file': app_module.log_file_path}) return jsonify({'logging': app_module.logging_enabled, 'log_file': app_module.log_file_path})
+15 -27
View File
@@ -20,6 +20,7 @@ from typing import Any
from flask import Blueprint, Response, jsonify, request from flask import Blueprint, Response, jsonify, request
from utils.responses import api_success, api_error
import app as app_module import app as app_module
from utils.constants import ( from utils.constants import (
MAX_RADIOSONDE_AGE_SECONDS, MAX_RADIOSONDE_AGE_SECONDS,
@@ -479,10 +480,7 @@ def start_radiosonde():
with app_module.radiosonde_lock: with app_module.radiosonde_lock:
if radiosonde_running: if radiosonde_running:
return jsonify({ return api_error('Radiosonde tracking already active', 409)
'status': 'already_running',
'message': 'Radiosonde tracking already active',
}), 409
data = request.json or {} data = request.json or {}
@@ -491,7 +489,7 @@ def start_radiosonde():
gain = float(validate_gain(data.get('gain', '40'))) gain = float(validate_gain(data.get('gain', '40')))
device = validate_device_index(data.get('device', '0')) device = validate_device_index(data.get('device', '0'))
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
freq_min = data.get('freq_min', 400.0) freq_min = data.get('freq_min', 400.0)
freq_max = data.get('freq_max', 406.0) freq_max = data.get('freq_max', 406.0)
@@ -503,7 +501,7 @@ def start_radiosonde():
if freq_min >= freq_max: if freq_min >= freq_max:
raise ValueError("Min frequency must be less than max") raise ValueError("Min frequency must be less than max")
except (ValueError, TypeError) as e: except (ValueError, TypeError) as e:
return jsonify({'status': 'error', 'message': f'Invalid frequency range: {e}'}), 400 return api_error(f'Invalid frequency range: {e}', 400)
bias_t = data.get('bias_t', False) bias_t = data.get('bias_t', False)
ppm = int(data.get('ppm', 0)) ppm = int(data.get('ppm', 0))
@@ -525,10 +523,7 @@ def start_radiosonde():
# Find auto_rx # Find auto_rx
auto_rx_path = find_auto_rx() auto_rx_path = find_auto_rx()
if not auto_rx_path: if not auto_rx_path:
return jsonify({ return api_error('radiosonde_auto_rx not found. Install from https://github.com/projecthorus/radiosonde_auto_rx', 400)
'status': 'error',
'message': 'radiosonde_auto_rx not found. Install from https://github.com/projecthorus/radiosonde_auto_rx',
}), 400
# Get SDR type # Get SDR type
sdr_type_str = data.get('sdr_type', 'rtlsdr') sdr_type_str = data.get('sdr_type', 'rtlsdr')
@@ -552,11 +547,7 @@ def start_radiosonde():
device_int = int(device) device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'radiosonde', sdr_type_str) error = app_module.claim_sdr_device(device_int, 'radiosonde', sdr_type_str)
if error: if error:
return jsonify({ return api_error(error, 409, error_type='DEVICE_BUSY')
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error,
}), 409
# Generate config # Generate config
try: try:
@@ -574,7 +565,7 @@ def start_radiosonde():
except (OSError, RuntimeError) as e: except (OSError, RuntimeError) as e:
app_module.release_sdr_device(device_int, sdr_type_str) app_module.release_sdr_device(device_int, sdr_type_str)
logger.error(f"Failed to generate radiosonde config: {e}") logger.error(f"Failed to generate radiosonde config: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500 return api_error(str(e), 500)
# Build command - auto_rx -c expects the path to station.cfg # Build command - auto_rx -c expects the path to station.cfg
cfg_abs = os.path.abspath(cfg_path) cfg_abs = os.path.abspath(cfg_path)
@@ -598,13 +589,11 @@ def start_radiosonde():
dep_error = dep_check.stderr.decode('utf-8', errors='ignore').strip() dep_error = dep_check.stderr.decode('utf-8', errors='ignore').strip()
logger.error(f"radiosonde_auto_rx dependency check failed:\n{dep_error}") logger.error(f"radiosonde_auto_rx dependency check failed:\n{dep_error}")
app_module.release_sdr_device(device_int, sdr_type_str) app_module.release_sdr_device(device_int, sdr_type_str)
return jsonify({ return api_error(
'status': 'error', 'radiosonde_auto_rx dependencies not satisfied. '
'message': ( f'Re-run setup.sh to install. Error: {dep_error[:500]}',
'radiosonde_auto_rx dependencies not satisfied. ' 500,
f'Re-run setup.sh to install. Error: {dep_error[:500]}' )
),
}), 500
try: try:
logger.info(f"Starting radiosonde_auto_rx: {' '.join(cmd)}") logger.info(f"Starting radiosonde_auto_rx: {' '.join(cmd)}")
@@ -646,7 +635,7 @@ def start_radiosonde():
) )
if stderr_output: if stderr_output:
error_msg += f' Error: {stderr_output[:500]}' error_msg += f' Error: {stderr_output[:500]}'
return jsonify({'status': 'error', 'message': error_msg}), 500 return api_error(error_msg, 500)
radiosonde_running = True radiosonde_running = True
radiosonde_active_device = device_int radiosonde_active_device = device_int
@@ -672,7 +661,7 @@ def start_radiosonde():
except Exception as e: except Exception as e:
app_module.release_sdr_device(device_int, sdr_type_str) app_module.release_sdr_device(device_int, sdr_type_str)
logger.error(f"Failed to start radiosonde_auto_rx: {e}") logger.error(f"Failed to start radiosonde_auto_rx: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500 return api_error(str(e), 500)
@radiosonde_bp.route('/stop', methods=['POST']) @radiosonde_bp.route('/stop', methods=['POST'])
@@ -741,8 +730,7 @@ def stream_radiosonde():
def get_balloons(): def get_balloons():
"""Get current balloon data.""" """Get current balloon data."""
with _balloons_lock: with _balloons_lock:
return jsonify({ return api_success(data={
'status': 'success',
'count': len(radiosonde_balloons), 'count': len(radiosonde_balloons),
'balloons': dict(radiosonde_balloons), 'balloons': dict(radiosonde_balloons),
}) })
+32 -39
View File
@@ -8,6 +8,7 @@ from pathlib import Path
from flask import Blueprint, jsonify, request, send_file from flask import Blueprint, jsonify, request, send_file
from utils.recording import get_recording_manager, RECORDING_ROOT from utils.recording import get_recording_manager, RECORDING_ROOT
from utils.responses import api_success, api_error
recordings_bp = Blueprint('recordings', __name__, url_prefix='/recordings') recordings_bp = Blueprint('recordings', __name__, url_prefix='/recordings')
@@ -17,7 +18,7 @@ def start_recording():
data = request.get_json() or {} data = request.get_json() or {}
mode = (data.get('mode') or '').strip() mode = (data.get('mode') or '').strip()
if not mode: if not mode:
return jsonify({'status': 'error', 'message': 'mode is required'}), 400 return api_error('mode is required', 400)
label = data.get('label') label = data.get('label')
metadata = data.get('metadata') if isinstance(data.get('metadata'), dict) else {} metadata = data.get('metadata') if isinstance(data.get('metadata'), dict) else {}
@@ -25,16 +26,13 @@ def start_recording():
manager = get_recording_manager() manager = get_recording_manager()
session = manager.start_recording(mode=mode, label=label, metadata=metadata) session = manager.start_recording(mode=mode, label=label, metadata=metadata)
return jsonify({ return api_success(data={'session': {
'status': 'success', 'id': session.id,
'session': { 'mode': session.mode,
'id': session.id, 'label': session.label,
'mode': session.mode, 'started_at': session.started_at.isoformat(),
'label': session.label, 'file_path': str(session.file_path),
'started_at': session.started_at.isoformat(), }})
'file_path': str(session.file_path),
}
})
@recordings_bp.route('/stop', methods=['POST']) @recordings_bp.route('/stop', methods=['POST'])
@@ -46,29 +44,25 @@ def stop_recording():
manager = get_recording_manager() manager = get_recording_manager()
session = manager.stop_recording(mode=mode, session_id=session_id) session = manager.stop_recording(mode=mode, session_id=session_id)
if not session: if not session:
return jsonify({'status': 'error', 'message': 'No active recording found'}), 404 return api_error('No active recording found', 404)
return jsonify({ return api_success(data={'session': {
'status': 'success', 'id': session.id,
'session': { 'mode': session.mode,
'id': session.id, 'label': session.label,
'mode': session.mode, 'started_at': session.started_at.isoformat(),
'label': session.label, 'stopped_at': session.stopped_at.isoformat() if session.stopped_at else None,
'started_at': session.started_at.isoformat(), 'event_count': session.event_count,
'stopped_at': session.stopped_at.isoformat() if session.stopped_at else None, 'size_bytes': session.size_bytes,
'event_count': session.event_count, 'file_path': str(session.file_path),
'size_bytes': session.size_bytes, }})
'file_path': str(session.file_path),
}
})
@recordings_bp.route('', methods=['GET']) @recordings_bp.route('', methods=['GET'])
def list_recordings(): def list_recordings():
manager = get_recording_manager() manager = get_recording_manager()
limit = request.args.get('limit', default=50, type=int) limit = request.args.get('limit', default=50, type=int)
return jsonify({ return api_success(data={
'status': 'success',
'recordings': manager.list_recordings(limit=limit), 'recordings': manager.list_recordings(limit=limit),
'active': manager.get_active(), 'active': manager.get_active(),
}) })
@@ -79,8 +73,8 @@ def get_recording(session_id: str):
manager = get_recording_manager() manager = get_recording_manager()
rec = manager.get_recording(session_id) rec = manager.get_recording(session_id)
if not rec: if not rec:
return jsonify({'status': 'error', 'message': 'Recording not found'}), 404 return api_error('Recording not found', 404)
return jsonify({'status': 'success', 'recording': rec}) return api_success(data={'recording': rec})
@recordings_bp.route('/<session_id>/download', methods=['GET']) @recordings_bp.route('/<session_id>/download', methods=['GET'])
@@ -88,19 +82,19 @@ def download_recording(session_id: str):
manager = get_recording_manager() manager = get_recording_manager()
rec = manager.get_recording(session_id) rec = manager.get_recording(session_id)
if not rec: if not rec:
return jsonify({'status': 'error', 'message': 'Recording not found'}), 404 return api_error('Recording not found', 404)
file_path = Path(rec['file_path']) file_path = Path(rec['file_path'])
try: try:
resolved_root = RECORDING_ROOT.resolve() resolved_root = RECORDING_ROOT.resolve()
resolved_file = file_path.resolve() resolved_file = file_path.resolve()
if resolved_root not in resolved_file.parents: if resolved_root not in resolved_file.parents:
return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400 return api_error('Invalid recording path', 400)
except Exception: except Exception:
return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400 return api_error('Invalid recording path', 400)
if not file_path.exists(): if not file_path.exists():
return jsonify({'status': 'error', 'message': 'Recording file missing'}), 404 return api_error('Recording file missing', 404)
return send_file( return send_file(
file_path, file_path,
@@ -116,19 +110,19 @@ def get_recording_events(session_id: str):
manager = get_recording_manager() manager = get_recording_manager()
rec = manager.get_recording(session_id) rec = manager.get_recording(session_id)
if not rec: if not rec:
return jsonify({'status': 'error', 'message': 'Recording not found'}), 404 return api_error('Recording not found', 404)
file_path = Path(rec['file_path']) file_path = Path(rec['file_path'])
try: try:
resolved_root = RECORDING_ROOT.resolve() resolved_root = RECORDING_ROOT.resolve()
resolved_file = file_path.resolve() resolved_file = file_path.resolve()
if resolved_root not in resolved_file.parents: if resolved_root not in resolved_file.parents:
return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400 return api_error('Invalid recording path', 400)
except Exception: except Exception:
return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400 return api_error('Invalid recording path', 400)
if not file_path.exists(): if not file_path.exists():
return jsonify({'status': 'error', 'message': 'Recording file missing'}), 404 return api_error('Recording file missing', 404)
limit = max(1, min(5000, request.args.get('limit', default=500, type=int))) limit = max(1, min(5000, request.args.get('limit', default=500, type=int)))
offset = max(0, request.args.get('offset', default=0, type=int)) offset = max(0, request.args.get('offset', default=0, type=int))
@@ -150,8 +144,7 @@ def get_recording_events(session_id: str):
except json.JSONDecodeError: except json.JSONDecodeError:
continue continue
return jsonify({ return api_success(data={
'status': 'success',
'recording': { 'recording': {
'id': rec['id'], 'id': rec['id'],
'mode': rec['mode'], 'mode': rec['mode'],
+8 -14
View File
@@ -12,6 +12,7 @@ from typing import Generator
from flask import Blueprint, jsonify, request, Response from flask import Blueprint, jsonify, request, Response
from utils.responses import api_success, api_error
import app as app_module import app as app_module
from utils.logging import sensor_logger as logger from utils.logging import sensor_logger as logger
from utils.validation import ( from utils.validation import (
@@ -102,16 +103,13 @@ def start_rtlamr() -> Response:
with app_module.rtlamr_lock: with app_module.rtlamr_lock:
if app_module.rtlamr_process: if app_module.rtlamr_process:
return jsonify({'status': 'error', 'message': 'RTLAMR already running'}), 409 return api_error('RTLAMR already running', 409)
data = request.json or {} data = request.json or {}
sdr_type_str = data.get('sdr_type', 'rtlsdr') sdr_type_str = data.get('sdr_type', 'rtlsdr')
if sdr_type_str != 'rtlsdr': if sdr_type_str != 'rtlsdr':
return jsonify({ return api_error(f'{sdr_type_str.replace("_", " ").title()} is not yet supported for this mode. Please use an RTL-SDR device.', 400)
'status': 'error',
'message': f'{sdr_type_str.replace("_", " ").title()} is not yet supported for this mode. Please use an RTL-SDR device.'
}), 400
# Validate inputs # Validate inputs
try: try:
@@ -120,17 +118,13 @@ def start_rtlamr() -> Response:
ppm = validate_ppm(data.get('ppm', '0')) ppm = validate_ppm(data.get('ppm', '0'))
device = validate_device_index(data.get('device', '0')) device = validate_device_index(data.get('device', '0'))
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
# Check if device is available # Check if device is available
device_int = int(device) device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'rtlamr', sdr_type_str) error = app_module.claim_sdr_device(device_int, 'rtlamr', sdr_type_str)
if error: if error:
return jsonify({ return api_error(error, 409, error_type='DEVICE_BUSY')
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
rtlamr_active_device = device_int rtlamr_active_device = device_int
rtlamr_active_sdr_type = sdr_type_str rtlamr_active_sdr_type = sdr_type_str
@@ -181,7 +175,7 @@ def start_rtlamr() -> Response:
if rtlamr_active_device is not None: if rtlamr_active_device is not None:
app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type) app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
rtlamr_active_device = None rtlamr_active_device = None
return jsonify({'status': 'error', 'message': f'Failed to start rtl_tcp: {e}'}), 500 return api_error(f'Failed to start rtl_tcp: {e}', 500)
# Wait for rtl_tcp to start outside lock # Wait for rtl_tcp to start outside lock
if rtl_tcp_just_started: if rtl_tcp_just_started:
@@ -253,7 +247,7 @@ def start_rtlamr() -> Response:
if rtlamr_active_device is not None: if rtlamr_active_device is not None:
app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type) app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
rtlamr_active_device = None rtlamr_active_device = None
return jsonify({'status': 'error', 'message': 'rtlamr not found. Install from https://github.com/bemasher/rtlamr'}) return api_error('rtlamr not found. Install from https://github.com/bemasher/rtlamr')
except Exception as e: except Exception as e:
# If rtlamr fails, clean up rtl_tcp and release device # If rtlamr fails, clean up rtl_tcp and release device
with rtl_tcp_lock: with rtl_tcp_lock:
@@ -264,7 +258,7 @@ def start_rtlamr() -> Response:
if rtlamr_active_device is not None: if rtlamr_active_device is not None:
app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type) app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
rtlamr_active_device = None rtlamr_active_device = None
return jsonify({'status': 'error', 'message': str(e)}) return api_error(str(e))
@rtlamr_bp.route('/stop_rtlamr', methods=['POST']) @rtlamr_bp.route('/stop_rtlamr', methods=['POST'])
+11 -10
View File
@@ -13,6 +13,7 @@ import requests
from flask import Blueprint, jsonify, request, render_template, Response from flask import Blueprint, jsonify, request, render_template, Response
from utils.responses import api_success, api_error
from config import SHARED_OBSERVER_LOCATION_ENABLED from config import SHARED_OBSERVER_LOCATION_ENABLED
from data.satellites import TLE_SATELLITES from data.satellites import TLE_SATELLITES
@@ -206,7 +207,7 @@ def predict_passes():
hours = validate_hours(data.get('hours', 24)) hours = validate_hours(data.get('hours', 24))
min_el = validate_elevation(data.get('minEl', 10)) min_el = validate_elevation(data.get('minEl', 10))
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
norad_to_name = { norad_to_name = {
25544: 'ISS', 25544: 'ISS',
@@ -345,7 +346,7 @@ def get_satellite_position():
try: try:
from skyfield.api import wgs84, EarthSatellite from skyfield.api import wgs84, EarthSatellite
except ImportError: except ImportError:
return jsonify({'status': 'error', 'message': 'skyfield not installed'}), 503 return api_error('skyfield not installed', 503)
data = request.json or {} data = request.json or {}
@@ -354,7 +355,7 @@ def get_satellite_position():
lat = validate_latitude(data.get('latitude', data.get('lat', 51.5074))) lat = validate_latitude(data.get('latitude', data.get('lat', 51.5074)))
lon = validate_longitude(data.get('longitude', data.get('lon', -0.1278))) lon = validate_longitude(data.get('longitude', data.get('lon', -0.1278)))
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
sat_input = data.get('satellites', []) sat_input = data.get('satellites', [])
include_track = bool(data.get('includeTrack', True)) include_track = bool(data.get('includeTrack', True))
@@ -528,7 +529,7 @@ def update_tle():
}) })
except Exception as e: except Exception as e:
logger.error(f"Error updating TLE data: {e}") logger.error(f"Error updating TLE data: {e}")
return jsonify({'status': 'error', 'message': 'TLE update failed'}) return api_error('TLE update failed')
@satellite_bp.route('/celestrak/<category>') @satellite_bp.route('/celestrak/<category>')
@@ -542,7 +543,7 @@ def fetch_celestrak(category):
] ]
if category not in valid_categories: if category not in valid_categories:
return jsonify({'status': 'error', 'message': f'Invalid category. Valid: {valid_categories}'}) return api_error(f'Invalid category. Valid: {valid_categories}')
try: try:
url = f'https://celestrak.org/NORAD/elements/gp.php?GROUP={category}&FORMAT=tle' url = f'https://celestrak.org/NORAD/elements/gp.php?GROUP={category}&FORMAT=tle'
@@ -583,7 +584,7 @@ def fetch_celestrak(category):
except Exception as e: except Exception as e:
logger.error(f"Error fetching CelesTrak data: {e}") logger.error(f"Error fetching CelesTrak data: {e}")
return jsonify({'status': 'error', 'message': 'Failed to fetch satellite data'}) return api_error('Failed to fetch satellite data')
# ============================================================================= # =============================================================================
@@ -604,7 +605,7 @@ def add_tracked_satellites_endpoint():
global _tle_cache global _tle_cache
data = request.get_json(silent=True) data = request.get_json(silent=True)
if not data: if not data:
return jsonify({'status': 'error', 'message': 'No data provided'}), 400 return api_error('No data provided', 400)
# Accept a single satellite dict or a list # Accept a single satellite dict or a list
sat_list = data if isinstance(data, list) else [data] sat_list = data if isinstance(data, list) else [data]
@@ -667,12 +668,12 @@ def update_tracked_satellite_endpoint(norad_id):
data = request.json or {} data = request.json or {}
enabled = data.get('enabled') enabled = data.get('enabled')
if enabled is None: if enabled is None:
return jsonify({'status': 'error', 'message': 'Missing enabled field'}), 400 return api_error('Missing enabled field', 400)
ok = update_tracked_satellite(str(norad_id), bool(enabled)) ok = update_tracked_satellite(str(norad_id), bool(enabled))
if ok: if ok:
return jsonify({'status': 'success'}) return jsonify({'status': 'success'})
return jsonify({'status': 'error', 'message': 'Satellite not found'}), 404 return api_error('Satellite not found', 404)
@satellite_bp.route('/tracked/<norad_id>', methods=['DELETE']) @satellite_bp.route('/tracked/<norad_id>', methods=['DELETE'])
@@ -682,4 +683,4 @@ def delete_tracked_satellite_endpoint(norad_id):
if ok: if ok:
return jsonify({'status': 'success', 'message': msg}) return jsonify({'status': 'success', 'message': msg})
status_code = 403 if 'builtin' in msg.lower() else 404 status_code = 403 if 'builtin' in msg.lower() else 404
return jsonify({'status': 'error', 'message': msg}), status_code return api_error(msg, status_code)
+8 -11
View File
@@ -13,6 +13,7 @@ from typing import Any, Generator
from flask import Blueprint, jsonify, request, Response from flask import Blueprint, jsonify, request, Response
from utils.responses import api_success, api_error
import app as app_module import app as app_module
from utils.logging import sensor_logger as logger from utils.logging import sensor_logger as logger
from utils.validation import ( from utils.validation import (
@@ -165,7 +166,7 @@ def start_sensor() -> Response:
with app_module.sensor_lock: with app_module.sensor_lock:
if app_module.sensor_process: if app_module.sensor_process:
return jsonify({'status': 'error', 'message': 'Sensor already running'}), 409 return api_error('Sensor already running', 409)
data = request.json or {} data = request.json or {}
@@ -176,7 +177,7 @@ def start_sensor() -> Response:
ppm = validate_ppm(data.get('ppm', '0')) ppm = validate_ppm(data.get('ppm', '0'))
device = validate_device_index(data.get('device', '0')) device = validate_device_index(data.get('device', '0'))
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
# Check for rtl_tcp (remote SDR) connection # Check for rtl_tcp (remote SDR) connection
rtl_tcp_host = data.get('rtl_tcp_host') rtl_tcp_host = data.get('rtl_tcp_host')
@@ -190,11 +191,7 @@ def start_sensor() -> Response:
device_int = int(device) device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'sensor', sdr_type_str) error = app_module.claim_sdr_device(device_int, 'sensor', sdr_type_str)
if error: if error:
return jsonify({ return api_error(error, 409, error_type='DEVICE_BUSY')
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
sensor_active_device = device_int sensor_active_device = device_int
sensor_active_sdr_type = sdr_type_str sensor_active_sdr_type = sdr_type_str
@@ -217,7 +214,7 @@ def start_sensor() -> Response:
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host) rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port) rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port) sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}") logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
@@ -285,14 +282,14 @@ def start_sensor() -> Response:
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
sensor_active_device = None sensor_active_device = None
sensor_active_sdr_type = None sensor_active_sdr_type = None
return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'}) return api_error('rtl_433 not found. Install with: brew install rtl_433')
except Exception as e: except Exception as e:
# Release device on failure # Release device on failure
if sensor_active_device is not None: if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
sensor_active_device = None sensor_active_device = None
sensor_active_sdr_type = None sensor_active_sdr_type = None
return jsonify({'status': 'error', 'message': str(e)}) return api_error(str(e))
@sensor_bp.route('/stop_sensor', methods=['POST']) @sensor_bp.route('/stop_sensor', methods=['POST'])
@@ -346,4 +343,4 @@ def get_rssi_history() -> Response:
result = {} result = {}
for key, entries in sensor_rssi_history.items(): for key, entries in sensor_rssi_history.items():
result[key] = [{'t': round(t, 1), 'rssi': rssi} for t, rssi in entries] result[key] = [{'t': round(t, 1), 'rssi': rssi} for t, rssi in entries]
return jsonify({'status': 'success', 'devices': result}) return api_success(data={'devices': result})
+17 -67
View File
@@ -16,6 +16,7 @@ from utils.database import (
get_correlations, get_correlations,
) )
from utils.logging import get_logger from utils.logging import get_logger
from utils.responses import api_error, api_success
logger = get_logger('intercept.settings') logger = get_logger('intercept.settings')
@@ -27,16 +28,10 @@ def get_settings() -> Response:
"""Get all settings.""" """Get all settings."""
try: try:
settings = get_all_settings() settings = get_all_settings()
return jsonify({ return api_success(data={'settings': settings})
'status': 'success',
'settings': settings
})
except Exception as e: except Exception as e:
logger.error(f"Error getting settings: {e}") logger.error(f"Error getting settings: {e}")
return jsonify({ return api_error(str(e), 500)
'status': 'error',
'message': str(e)
}), 500
@settings_bp.route('', methods=['POST']) @settings_bp.route('', methods=['POST'])
@@ -45,10 +40,7 @@ def save_settings() -> Response:
data = request.json or {} data = request.json or {}
if not data: if not data:
return jsonify({ return api_error('No settings provided', 400)
'status': 'error',
'message': 'No settings provided'
}), 400
try: try:
saved = [] saved = []
@@ -60,16 +52,10 @@ def save_settings() -> Response:
set_setting(key, value) set_setting(key, value)
saved.append(key) saved.append(key)
return jsonify({ return api_success(data={'saved': saved})
'status': 'success',
'saved': saved
})
except Exception as e: except Exception as e:
logger.error(f"Error saving settings: {e}") logger.error(f"Error saving settings: {e}")
return jsonify({ return api_error(str(e), 500)
'status': 'error',
'message': str(e)
}), 500
@settings_bp.route('/<key>', methods=['GET']) @settings_bp.route('/<key>', methods=['GET'])
@@ -83,17 +69,10 @@ def get_single_setting(key: str) -> Response:
'key': key 'key': key
}), 404 }), 404
return jsonify({ return api_success(data={'key': key, 'value': value})
'status': 'success',
'key': key,
'value': value
})
except Exception as e: except Exception as e:
logger.error(f"Error getting setting {key}: {e}") logger.error(f"Error getting setting {key}: {e}")
return jsonify({ return api_error(str(e), 500)
'status': 'error',
'message': str(e)
}), 500
@settings_bp.route('/<key>', methods=['PUT']) @settings_bp.route('/<key>', methods=['PUT'])
@@ -103,24 +82,14 @@ def update_single_setting(key: str) -> Response:
value = data.get('value') value = data.get('value')
if value is None and 'value' not in data: if value is None and 'value' not in data:
return jsonify({ return api_error('Value is required', 400)
'status': 'error',
'message': 'Value is required'
}), 400
try: try:
set_setting(key, value) set_setting(key, value)
return jsonify({ return api_success(data={'key': key, 'value': value})
'status': 'success',
'key': key,
'value': value
})
except Exception as e: except Exception as e:
logger.error(f"Error updating setting {key}: {e}") logger.error(f"Error updating setting {key}: {e}")
return jsonify({ return api_error(str(e), 500)
'status': 'error',
'message': str(e)
}), 500
@settings_bp.route('/<key>', methods=['DELETE']) @settings_bp.route('/<key>', methods=['DELETE'])
@@ -129,11 +98,7 @@ def delete_single_setting(key: str) -> Response:
try: try:
deleted = delete_setting(key) deleted = delete_setting(key)
if deleted: if deleted:
return jsonify({ return api_success(data={'key': key, 'deleted': True})
'status': 'success',
'key': key,
'deleted': True
})
else: else:
return jsonify({ return jsonify({
'status': 'not_found', 'status': 'not_found',
@@ -141,10 +106,7 @@ def delete_single_setting(key: str) -> Response:
}), 404 }), 404
except Exception as e: except Exception as e:
logger.error(f"Error deleting setting {key}: {e}") logger.error(f"Error deleting setting {key}: {e}")
return jsonify({ return api_error(str(e), 500)
'status': 'error',
'message': str(e)
}), 500
# ============================================================================= # =============================================================================
@@ -158,16 +120,10 @@ def get_device_correlations() -> Response:
try: try:
correlations = get_correlations(min_confidence) correlations = get_correlations(min_confidence)
return jsonify({ return api_success(data={'correlations': correlations})
'status': 'success',
'correlations': correlations
})
except Exception as e: except Exception as e:
logger.error(f"Error getting correlations: {e}") logger.error(f"Error getting correlations: {e}")
return jsonify({ return api_error(str(e), 500)
'status': 'error',
'message': str(e)
}), 500
# ============================================================================= # =============================================================================
@@ -229,17 +185,11 @@ def check_dvb_driver_status() -> Response:
def blacklist_dvb_drivers() -> Response: def blacklist_dvb_drivers() -> Response:
"""Blacklist DVB kernel drivers to prevent them from claiming RTL-SDR devices.""" """Blacklist DVB kernel drivers to prevent them from claiming RTL-SDR devices."""
if sys.platform != 'linux': if sys.platform != 'linux':
return jsonify({ return api_error('This feature is only available on Linux', 400)
'status': 'error',
'message': 'This feature is only available on Linux'
}), 400
# Check if we have permission (need to be running as root or with sudo) # Check if we have permission (need to be running as root or with sudo)
if os.geteuid() != 0: if os.geteuid() != 0:
return jsonify({ return api_error('Root privileges required. Run the app with sudo or manually run: sudo modprobe -r dvb_usb_rtl28xxu rtl2832_sdr rtl2832 r820t', 403)
'status': 'error',
'message': 'Root privileges required. Run the app with sudo or manually run: sudo modprobe -r dvb_usb_rtl28xxu rtl2832_sdr rtl2832 r820t'
}), 403
errors = [] errors = []
successes = [] successes = []
+5 -4
View File
@@ -10,6 +10,7 @@ from typing import Any
from flask import Blueprint, Response, jsonify, request from flask import Blueprint, Response, jsonify, request
from utils.responses import api_success, api_error
from utils.logging import get_logger from utils.logging import get_logger
logger = get_logger('intercept.signalid') logger = get_logger('intercept.signalid')
@@ -294,15 +295,15 @@ def sigidwiki_lookup() -> Response:
freq_raw = payload.get('frequency_mhz') freq_raw = payload.get('frequency_mhz')
if freq_raw is None: if freq_raw is None:
return jsonify({'status': 'error', 'message': 'frequency_mhz is required'}), 400 return api_error('frequency_mhz is required', 400)
try: try:
frequency_mhz = float(freq_raw) frequency_mhz = float(freq_raw)
except (TypeError, ValueError): except (TypeError, ValueError):
return jsonify({'status': 'error', 'message': 'Invalid frequency_mhz'}), 400 return api_error('Invalid frequency_mhz', 400)
if frequency_mhz <= 0: if frequency_mhz <= 0:
return jsonify({'status': 'error', 'message': 'frequency_mhz must be positive'}), 400 return api_error('frequency_mhz must be positive', 400)
modulation = str(payload.get('modulation') or '').strip().upper() modulation = str(payload.get('modulation') or '').strip().upper()
if modulation and len(modulation) > 16: if modulation and len(modulation) > 16:
@@ -331,7 +332,7 @@ def sigidwiki_lookup() -> Response:
lookup = _lookup_sigidwiki_matches(frequency_mhz, modulation, limit) lookup = _lookup_sigidwiki_matches(frequency_mhz, modulation, limit)
except Exception as exc: except Exception as exc:
logger.error('SigID lookup failed: %s', exc) logger.error('SigID lookup failed: %s', exc)
return jsonify({'status': 'error', 'message': 'SigID lookup failed'}), 502 return api_error('SigID lookup failed', 502)
response_payload = { response_payload = {
'matches': lookup.get('matches', []), 'matches': lookup.get('matches', []),
+3 -2
View File
@@ -13,6 +13,7 @@ from typing import Any
from flask import Blueprint, Response, jsonify from flask import Blueprint, Response, jsonify
from utils.logging import get_logger from utils.logging import get_logger
from utils.responses import api_success, api_error
logger = get_logger('intercept.space_weather') logger = get_logger('intercept.space_weather')
@@ -289,7 +290,7 @@ def get_image(key: str):
"""Proxy and cache whitelisted space weather images.""" """Proxy and cache whitelisted space weather images."""
entry = IMAGE_WHITELIST.get(key) entry = IMAGE_WHITELIST.get(key)
if not entry: if not entry:
return jsonify({'error': 'Unknown image key'}), 404 return api_error('Unknown image key', 404)
cache_key = f'img_{key}' cache_key = f'img_{key}'
cached = _cache_get(cache_key) cached = _cache_get(cache_key)
@@ -299,7 +300,7 @@ def get_image(key: str):
img_data = _fetch_bytes(entry['url']) img_data = _fetch_bytes(entry['url'])
if img_data is None: if img_data is None:
return jsonify({'error': 'Failed to fetch image'}), 502 return api_error('Failed to fetch image', 502)
_cache_set(cache_key, img_data, TTL_IMAGE) _cache_set(cache_key, img_data, TTL_IMAGE)
return Response(img_data, content_type=entry['content_type'], return Response(img_data, content_type=entry['content_type'],
+10 -9
View File
@@ -14,6 +14,7 @@ from typing import Any
from flask import Blueprint, jsonify, request, Response, send_file from flask import Blueprint, jsonify, request, Response, send_file
from utils.responses import api_success, api_error
import app as app_module import app as app_module
from utils.logging import get_logger from utils.logging import get_logger
from utils.sse import sse_stream_fanout from utils.sse import sse_stream_fanout
@@ -357,16 +358,16 @@ def get_image(filename: str):
# Security: only allow alphanumeric filenames with .png extension # Security: only allow alphanumeric filenames with .png extension
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum(): if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400 return api_error('Invalid filename', 400)
if not filename.endswith('.png'): if not filename.endswith('.png'):
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400 return api_error('Only PNG files supported', 400)
# Find image in decoder's output directory # Find image in decoder's output directory
image_path = decoder._output_dir / filename image_path = decoder._output_dir / filename
if not image_path.exists(): if not image_path.exists():
return jsonify({'status': 'error', 'message': 'Image not found'}), 404 return api_error('Image not found', 404)
return send_file(image_path, mimetype='image/png') return send_file(image_path, mimetype='image/png')
@@ -386,15 +387,15 @@ def download_image(filename: str):
# Security: only allow alphanumeric filenames with .png extension # Security: only allow alphanumeric filenames with .png extension
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum(): if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400 return api_error('Invalid filename', 400)
if not filename.endswith('.png'): if not filename.endswith('.png'):
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400 return api_error('Only PNG files supported', 400)
image_path = decoder._output_dir / filename image_path = decoder._output_dir / filename
if not image_path.exists(): if not image_path.exists():
return jsonify({'status': 'error', 'message': 'Image not found'}), 404 return api_error('Image not found', 404)
return send_file(image_path, mimetype='image/png', as_attachment=True, download_name=filename) return send_file(image_path, mimetype='image/png', as_attachment=True, download_name=filename)
@@ -414,15 +415,15 @@ def delete_image(filename: str):
# Security: only allow alphanumeric filenames with .png extension # Security: only allow alphanumeric filenames with .png extension
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum(): if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400 return api_error('Invalid filename', 400)
if not filename.endswith('.png'): if not filename.endswith('.png'):
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400 return api_error('Only PNG files supported', 400)
if decoder.delete_image(filename): if decoder.delete_image(filename):
return jsonify({'status': 'ok'}) return jsonify({'status': 'ok'})
else: else:
return jsonify({'status': 'error', 'message': 'Image not found'}), 404 return api_error('Image not found', 404)
@sstv_bp.route('/images', methods=['DELETE']) @sstv_bp.route('/images', methods=['DELETE'])
+21 -54
View File
@@ -13,6 +13,7 @@ from pathlib import Path
from flask import Blueprint, Response, jsonify, request, send_file from flask import Blueprint, Response, jsonify, request, send_file
from utils.responses import api_success, api_error
import app as app_module import app as app_module
from utils.logging import get_logger from utils.logging import get_logger
from utils.sse import sse_stream_fanout from utils.sse import sse_stream_fanout
@@ -102,10 +103,7 @@ def start_decoder():
decoder = get_general_sstv_decoder() decoder = get_general_sstv_decoder()
if decoder.decoder_available is None: if decoder.decoder_available is None:
return jsonify({ return api_error('SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow', 400)
'status': 'error',
'message': 'SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow',
}), 400
if decoder.is_running: if decoder.is_running:
return jsonify({ return jsonify({
@@ -123,10 +121,7 @@ def start_decoder():
sdr_type_str = data.get('sdr_type', 'rtlsdr') sdr_type_str = data.get('sdr_type', 'rtlsdr')
if sdr_type_str != 'rtlsdr': if sdr_type_str != 'rtlsdr':
return jsonify({ return api_error(f'{sdr_type_str.replace("_", " ").title()} is not yet supported for this mode. Please use an RTL-SDR device.', 400)
'status': 'error',
'message': f'{sdr_type_str.replace("_", " ").title()} is not yet supported for this mode. Please use an RTL-SDR device.'
}), 400
frequency = data.get('frequency') frequency = data.get('frequency')
modulation = data.get('modulation') modulation = data.get('modulation')
@@ -134,23 +129,14 @@ def start_decoder():
# Validate frequency # Validate frequency
if frequency is None: if frequency is None:
return jsonify({ return api_error('Frequency is required', 400)
'status': 'error',
'message': 'Frequency is required',
}), 400
try: try:
frequency = float(frequency) frequency = float(frequency)
if not (1 <= frequency <= 500): if not (1 <= frequency <= 500):
return jsonify({ return api_error('Frequency must be between 1-500 MHz (HF requires upconverter for RTL-SDR)', 400)
'status': 'error',
'message': 'Frequency must be between 1-500 MHz (HF requires upconverter for RTL-SDR)',
}), 400
except (TypeError, ValueError): except (TypeError, ValueError):
return jsonify({ return api_error('Invalid frequency', 400)
'status': 'error',
'message': 'Invalid frequency',
}), 400
# Auto-detect modulation from frequency table if not specified # Auto-detect modulation from frequency table if not specified
if not modulation: if not modulation:
@@ -158,21 +144,14 @@ def start_decoder():
# Validate modulation # Validate modulation
if modulation not in ('fm', 'usb', 'lsb'): if modulation not in ('fm', 'usb', 'lsb'):
return jsonify({ return api_error('Modulation must be fm, usb, or lsb', 400)
'status': 'error',
'message': 'Modulation must be fm, usb, or lsb',
}), 400
# Claim SDR device # Claim SDR device
global _sstv_general_active_device, _sstv_general_active_sdr_type global _sstv_general_active_device, _sstv_general_active_sdr_type
device_int = int(device_index) device_int = int(device_index)
error = app_module.claim_sdr_device(device_int, 'sstv_general', sdr_type_str) error = app_module.claim_sdr_device(device_int, 'sstv_general', sdr_type_str)
if error: if error:
return jsonify({ return api_error(error, 409, error_type='DEVICE_BUSY')
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error,
}), 409
# Set callback and start # Set callback and start
decoder.set_callback(_progress_callback) decoder.set_callback(_progress_callback)
@@ -193,10 +172,7 @@ def start_decoder():
}) })
else: else:
app_module.release_sdr_device(device_int, sdr_type_str) app_module.release_sdr_device(device_int, sdr_type_str)
return jsonify({ return api_error('Failed to start decoder', 500)
'status': 'error',
'message': 'Failed to start decoder',
}), 500
@sstv_general_bp.route('/stop', methods=['POST']) @sstv_general_bp.route('/stop', methods=['POST'])
@@ -237,15 +213,15 @@ def get_image(filename: str):
# Security: only allow alphanumeric filenames with .png extension # Security: only allow alphanumeric filenames with .png extension
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum(): if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400 return api_error('Invalid filename', 400)
if not filename.endswith('.png'): if not filename.endswith('.png'):
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400 return api_error('Only PNG files supported', 400)
image_path = decoder._output_dir / filename image_path = decoder._output_dir / filename
if not image_path.exists(): if not image_path.exists():
return jsonify({'status': 'error', 'message': 'Image not found'}), 404 return api_error('Image not found', 404)
return send_file(image_path, mimetype='image/png') return send_file(image_path, mimetype='image/png')
@@ -257,15 +233,15 @@ def download_image(filename: str):
# Security: only allow alphanumeric filenames with .png extension # Security: only allow alphanumeric filenames with .png extension
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum(): if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400 return api_error('Invalid filename', 400)
if not filename.endswith('.png'): if not filename.endswith('.png'):
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400 return api_error('Only PNG files supported', 400)
image_path = decoder._output_dir / filename image_path = decoder._output_dir / filename
if not image_path.exists(): if not image_path.exists():
return jsonify({'status': 'error', 'message': 'Image not found'}), 404 return api_error('Image not found', 404)
return send_file(image_path, mimetype='image/png', as_attachment=True, download_name=filename) return send_file(image_path, mimetype='image/png', as_attachment=True, download_name=filename)
@@ -277,15 +253,15 @@ def delete_image(filename: str):
# Security: only allow alphanumeric filenames with .png extension # Security: only allow alphanumeric filenames with .png extension
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum(): if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400 return api_error('Invalid filename', 400)
if not filename.endswith('.png'): if not filename.endswith('.png'):
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400 return api_error('Only PNG files supported', 400)
if decoder.delete_image(filename): if decoder.delete_image(filename):
return jsonify({'status': 'ok'}) return jsonify({'status': 'ok'})
else: else:
return jsonify({'status': 'error', 'message': 'Image not found'}), 404 return api_error('Image not found', 404)
@sstv_general_bp.route('/images', methods=['DELETE']) @sstv_general_bp.route('/images', methods=['DELETE'])
@@ -322,18 +298,12 @@ def stream_progress():
def decode_file(): def decode_file():
"""Decode SSTV from an uploaded audio file.""" """Decode SSTV from an uploaded audio file."""
if 'audio' not in request.files: if 'audio' not in request.files:
return jsonify({ return api_error('No audio file provided', 400)
'status': 'error',
'message': 'No audio file provided',
}), 400
audio_file = request.files['audio'] audio_file = request.files['audio']
if not audio_file.filename: if not audio_file.filename:
return jsonify({ return api_error('No file selected', 400)
'status': 'error',
'message': 'No file selected',
}), 400
import tempfile import tempfile
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp: with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp:
@@ -352,10 +322,7 @@ def decode_file():
except Exception as e: except Exception as e:
logger.error(f"Error decoding file: {e}") logger.error(f"Error decoding file: {e}")
return jsonify({ return api_error(str(e), 500)
'status': 'error',
'message': str(e),
}), 500
finally: finally:
try: try:
+153 -152
View File
@@ -10,10 +10,11 @@ import queue
from flask import Blueprint, jsonify, request, Response, send_file from flask import Blueprint, jsonify, request, Response, send_file
from utils.logging import get_logger from utils.responses import api_success, api_error
from utils.sse import sse_stream from utils.logging import get_logger
from utils.subghz import get_subghz_manager from utils.sse import sse_stream
from utils.event_pipeline import process_event from utils.subghz import get_subghz_manager
from utils.event_pipeline import process_event
from utils.constants import ( from utils.constants import (
SUBGHZ_FREQ_MIN_MHZ, SUBGHZ_FREQ_MIN_MHZ,
SUBGHZ_FREQ_MAX_MHZ, SUBGHZ_FREQ_MAX_MHZ,
@@ -33,14 +34,14 @@ subghz_bp = Blueprint('subghz', __name__, url_prefix='/subghz')
_subghz_queue: queue.Queue = queue.Queue(maxsize=200) _subghz_queue: queue.Queue = queue.Queue(maxsize=200)
def _event_callback(event: dict) -> None: def _event_callback(event: dict) -> None:
"""Forward SubGhzManager events to the SSE queue.""" """Forward SubGhzManager events to the SSE queue."""
try: try:
process_event('subghz', event, event.get('type')) process_event('subghz', event, event.get('type'))
except Exception: except Exception:
pass pass
try: try:
_subghz_queue.put_nowait(event) _subghz_queue.put_nowait(event)
except queue.Full: except queue.Full:
try: try:
_subghz_queue.get_nowait() _subghz_queue.get_nowait()
@@ -76,44 +77,44 @@ def _validate_serial(data: dict) -> str | None:
return None return None
def _validate_int(data: dict, key: str, default: int, min_val: int, max_val: int) -> int: def _validate_int(data: dict, key: str, default: int, min_val: int, max_val: int) -> int:
"""Validate integer parameter with bounds clamping.""" """Validate integer parameter with bounds clamping."""
try: try:
val = int(data.get(key, default)) val = int(data.get(key, default))
return max(min_val, min(max_val, val)) return max(min_val, min(max_val, val))
except (ValueError, TypeError): except (ValueError, TypeError):
return default return default
def _validate_decode_profile(data: dict, default: str = 'weather') -> str: def _validate_decode_profile(data: dict, default: str = 'weather') -> str:
profile = data.get('decode_profile', default) profile = data.get('decode_profile', default)
if not isinstance(profile, str): if not isinstance(profile, str):
return default return default
profile = profile.strip().lower() profile = profile.strip().lower()
if profile in {'weather', 'all'}: if profile in {'weather', 'all'}:
return profile return profile
return default return default
def _validate_optional_float(data: dict, key: str) -> tuple[float | None, str | None]: def _validate_optional_float(data: dict, key: str) -> tuple[float | None, str | None]:
raw = data.get(key) raw = data.get(key)
if raw is None or raw == '': if raw is None or raw == '':
return None, None return None, None
try: try:
return float(raw), None return float(raw), None
except (ValueError, TypeError): except (ValueError, TypeError):
return None, f'Invalid {key}' return None, f'Invalid {key}'
def _validate_bool(data: dict, key: str, default: bool = False) -> bool: def _validate_bool(data: dict, key: str, default: bool = False) -> bool:
raw = data.get(key, default) raw = data.get(key, default)
if isinstance(raw, bool): if isinstance(raw, bool):
return raw return raw
if isinstance(raw, (int, float)): if isinstance(raw, (int, float)):
return bool(raw) return bool(raw)
if isinstance(raw, str): if isinstance(raw, str):
return raw.strip().lower() in {'1', 'true', 'yes', 'on', 'enabled'} return raw.strip().lower() in {'1', 'true', 'yes', 'on', 'enabled'}
return default return default
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -136,34 +137,34 @@ def get_presets():
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@subghz_bp.route('/receive/start', methods=['POST']) @subghz_bp.route('/receive/start', methods=['POST'])
def start_receive(): def start_receive():
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
freq_hz, err = _validate_frequency_hz(data) freq_hz, err = _validate_frequency_hz(data)
if err: if err:
return jsonify({'status': 'error', 'message': err}), 400 return api_error(err, 400)
sample_rate = _validate_int(data, 'sample_rate', 2000000, 2000000, 20000000) sample_rate = _validate_int(data, 'sample_rate', 2000000, 2000000, 20000000)
lna_gain = _validate_int(data, 'lna_gain', 32, 0, SUBGHZ_LNA_GAIN_MAX) lna_gain = _validate_int(data, 'lna_gain', 32, 0, SUBGHZ_LNA_GAIN_MAX)
vga_gain = _validate_int(data, 'vga_gain', 20, 0, SUBGHZ_VGA_GAIN_MAX) vga_gain = _validate_int(data, 'vga_gain', 20, 0, SUBGHZ_VGA_GAIN_MAX)
trigger_enabled = _validate_bool(data, 'trigger_enabled', False) trigger_enabled = _validate_bool(data, 'trigger_enabled', False)
trigger_pre_ms = _validate_int(data, 'trigger_pre_ms', 350, 50, 5000) trigger_pre_ms = _validate_int(data, 'trigger_pre_ms', 350, 50, 5000)
trigger_post_ms = _validate_int(data, 'trigger_post_ms', 700, 100, 10000) trigger_post_ms = _validate_int(data, 'trigger_post_ms', 700, 100, 10000)
device_serial = _validate_serial(data) device_serial = _validate_serial(data)
manager = get_subghz_manager() manager = get_subghz_manager()
manager.set_callback(_event_callback) manager.set_callback(_event_callback)
result = manager.start_receive( result = manager.start_receive(
frequency_hz=freq_hz, frequency_hz=freq_hz,
sample_rate=sample_rate, sample_rate=sample_rate,
lna_gain=lna_gain, lna_gain=lna_gain,
vga_gain=vga_gain, vga_gain=vga_gain,
trigger_enabled=trigger_enabled, trigger_enabled=trigger_enabled,
trigger_pre_ms=trigger_pre_ms, trigger_pre_ms=trigger_pre_ms,
trigger_post_ms=trigger_post_ms, trigger_post_ms=trigger_post_ms,
device_serial=device_serial, device_serial=device_serial,
) )
status_code = 200 if result.get('status') != 'error' else 409 status_code = 200 if result.get('status') != 'error' else 409
return jsonify(result), status_code return jsonify(result), status_code
@@ -186,25 +187,25 @@ def start_decode():
freq_hz, err = _validate_frequency_hz(data) freq_hz, err = _validate_frequency_hz(data)
if err: if err:
return jsonify({'status': 'error', 'message': err}), 400 return api_error(err, 400)
sample_rate = _validate_int(data, 'sample_rate', 2000000, 2000000, 20000000) sample_rate = _validate_int(data, 'sample_rate', 2000000, 2000000, 20000000)
lna_gain = _validate_int(data, 'lna_gain', 32, 0, SUBGHZ_LNA_GAIN_MAX) lna_gain = _validate_int(data, 'lna_gain', 32, 0, SUBGHZ_LNA_GAIN_MAX)
vga_gain = _validate_int(data, 'vga_gain', 20, 0, SUBGHZ_VGA_GAIN_MAX) vga_gain = _validate_int(data, 'vga_gain', 20, 0, SUBGHZ_VGA_GAIN_MAX)
decode_profile = _validate_decode_profile(data) decode_profile = _validate_decode_profile(data)
device_serial = _validate_serial(data) device_serial = _validate_serial(data)
manager = get_subghz_manager() manager = get_subghz_manager()
manager.set_callback(_event_callback) manager.set_callback(_event_callback)
result = manager.start_decode( result = manager.start_decode(
frequency_hz=freq_hz, frequency_hz=freq_hz,
sample_rate=sample_rate, sample_rate=sample_rate,
lna_gain=lna_gain, lna_gain=lna_gain,
vga_gain=vga_gain, vga_gain=vga_gain,
decode_profile=decode_profile, decode_profile=decode_profile,
device_serial=device_serial, device_serial=device_serial,
) )
status_code = 200 if result.get('status') != 'error' else 409 status_code = 200 if result.get('status') != 'error' else 409
return jsonify(result), status_code return jsonify(result), status_code
@@ -227,33 +228,33 @@ def start_transmit():
capture_id = data.get('capture_id') capture_id = data.get('capture_id')
if not capture_id or not isinstance(capture_id, str): if not capture_id or not isinstance(capture_id, str):
return jsonify({'status': 'error', 'message': 'capture_id is required'}), 400 return api_error('capture_id is required', 400)
# Sanitize capture_id # Sanitize capture_id
if not capture_id.isalnum(): if not capture_id.isalnum():
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400 return api_error('Invalid capture_id', 400)
tx_gain = _validate_int(data, 'tx_gain', 20, 0, SUBGHZ_TX_VGA_GAIN_MAX) tx_gain = _validate_int(data, 'tx_gain', 20, 0, SUBGHZ_TX_VGA_GAIN_MAX)
max_duration = _validate_int(data, 'max_duration', 10, 1, SUBGHZ_TX_MAX_DURATION) max_duration = _validate_int(data, 'max_duration', 10, 1, SUBGHZ_TX_MAX_DURATION)
start_seconds, start_err = _validate_optional_float(data, 'start_seconds') start_seconds, start_err = _validate_optional_float(data, 'start_seconds')
if start_err: if start_err:
return jsonify({'status': 'error', 'message': start_err}), 400 return api_error(start_err, 400)
duration_seconds, duration_err = _validate_optional_float(data, 'duration_seconds') duration_seconds, duration_err = _validate_optional_float(data, 'duration_seconds')
if duration_err: if duration_err:
return jsonify({'status': 'error', 'message': duration_err}), 400 return api_error(duration_err, 400)
device_serial = _validate_serial(data) device_serial = _validate_serial(data)
manager = get_subghz_manager() manager = get_subghz_manager()
manager.set_callback(_event_callback) manager.set_callback(_event_callback)
result = manager.transmit( result = manager.transmit(
capture_id=capture_id, capture_id=capture_id,
tx_gain=tx_gain, tx_gain=tx_gain,
max_duration=max_duration, max_duration=max_duration,
start_seconds=start_seconds, start_seconds=start_seconds,
duration_seconds=duration_seconds, duration_seconds=duration_seconds,
device_serial=device_serial, device_serial=device_serial,
) )
status_code = 200 if result.get('status') != 'error' else 400 status_code = 200 if result.get('status') != 'error' else 400
return jsonify(result), status_code return jsonify(result), status_code
@@ -278,11 +279,11 @@ def start_sweep():
freq_start = float(data.get('freq_start_mhz', 300)) freq_start = float(data.get('freq_start_mhz', 300))
freq_end = float(data.get('freq_end_mhz', 928)) freq_end = float(data.get('freq_end_mhz', 928))
if freq_start >= freq_end: if freq_start >= freq_end:
return jsonify({'status': 'error', 'message': 'freq_start must be less than freq_end'}), 400 return api_error('freq_start must be less than freq_end', 400)
if freq_start < SUBGHZ_FREQ_MIN_MHZ or freq_end > SUBGHZ_FREQ_MAX_MHZ: if freq_start < SUBGHZ_FREQ_MIN_MHZ or freq_end > SUBGHZ_FREQ_MAX_MHZ:
return jsonify({'status': 'error', 'message': f'Frequency range: {SUBGHZ_FREQ_MIN_MHZ}-{SUBGHZ_FREQ_MAX_MHZ} MHz'}), 400 return api_error(f'Frequency range: {SUBGHZ_FREQ_MIN_MHZ}-{SUBGHZ_FREQ_MAX_MHZ} MHz', 400)
except (ValueError, TypeError): except (ValueError, TypeError):
return jsonify({'status': 'error', 'message': 'Invalid frequency range'}), 400 return api_error('Invalid frequency range', 400)
bin_width = _validate_int(data, 'bin_width', 100000, 10000, 5000000) bin_width = _validate_int(data, 'bin_width', 100000, 10000, 5000000)
device_serial = _validate_serial(data) device_serial = _validate_serial(data)
@@ -326,94 +327,94 @@ def list_captures():
@subghz_bp.route('/captures/<capture_id>') @subghz_bp.route('/captures/<capture_id>')
def get_capture(capture_id: str): def get_capture(capture_id: str):
if not capture_id.isalnum(): if not capture_id.isalnum():
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400 return api_error('Invalid capture_id', 400)
manager = get_subghz_manager() manager = get_subghz_manager()
capture = manager.get_capture(capture_id) capture = manager.get_capture(capture_id)
if not capture: if not capture:
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404 return api_error('Capture not found', 404)
return jsonify({'status': 'ok', 'capture': capture.to_dict()}) return jsonify({'status': 'ok', 'capture': capture.to_dict()})
@subghz_bp.route('/captures/<capture_id>/download') @subghz_bp.route('/captures/<capture_id>/download')
def download_capture(capture_id: str): def download_capture(capture_id: str):
if not capture_id.isalnum(): if not capture_id.isalnum():
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400 return api_error('Invalid capture_id', 400)
manager = get_subghz_manager() manager = get_subghz_manager()
path = manager.get_capture_path(capture_id) path = manager.get_capture_path(capture_id)
if not path: if not path:
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404 return api_error('Capture not found', 404)
return send_file( return send_file(
path, path,
mimetype='application/octet-stream', mimetype='application/octet-stream',
as_attachment=True, as_attachment=True,
download_name=path.name, download_name=path.name,
) )
@subghz_bp.route('/captures/<capture_id>/trim', methods=['POST']) @subghz_bp.route('/captures/<capture_id>/trim', methods=['POST'])
def trim_capture(capture_id: str): def trim_capture(capture_id: str):
if not capture_id.isalnum(): if not capture_id.isalnum():
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400 return api_error('Invalid capture_id', 400)
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
start_seconds, start_err = _validate_optional_float(data, 'start_seconds') start_seconds, start_err = _validate_optional_float(data, 'start_seconds')
if start_err: if start_err:
return jsonify({'status': 'error', 'message': start_err}), 400 return api_error(start_err, 400)
duration_seconds, duration_err = _validate_optional_float(data, 'duration_seconds') duration_seconds, duration_err = _validate_optional_float(data, 'duration_seconds')
if duration_err: if duration_err:
return jsonify({'status': 'error', 'message': duration_err}), 400 return api_error(duration_err, 400)
label = data.get('label', '') label = data.get('label', '')
if label is None: if label is None:
label = '' label = ''
if not isinstance(label, str) or len(label) > 100: if not isinstance(label, str) or len(label) > 100:
return jsonify({'status': 'error', 'message': 'Label must be a string (max 100 chars)'}), 400 return api_error('Label must be a string (max 100 chars)', 400)
manager = get_subghz_manager() manager = get_subghz_manager()
result = manager.trim_capture( result = manager.trim_capture(
capture_id=capture_id, capture_id=capture_id,
start_seconds=start_seconds, start_seconds=start_seconds,
duration_seconds=duration_seconds, duration_seconds=duration_seconds,
label=label, label=label,
) )
if result.get('status') == 'ok': if result.get('status') == 'ok':
return jsonify(result), 200 return jsonify(result), 200
message = str(result.get('message') or 'Trim failed') message = str(result.get('message') or 'Trim failed')
status_code = 404 if 'not found' in message.lower() else 400 status_code = 404 if 'not found' in message.lower() else 400
return jsonify(result), status_code return jsonify(result), status_code
@subghz_bp.route('/captures/<capture_id>', methods=['DELETE']) @subghz_bp.route('/captures/<capture_id>', methods=['DELETE'])
def delete_capture(capture_id: str): def delete_capture(capture_id: str):
if not capture_id.isalnum(): if not capture_id.isalnum():
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400 return api_error('Invalid capture_id', 400)
manager = get_subghz_manager() manager = get_subghz_manager()
if manager.delete_capture(capture_id): if manager.delete_capture(capture_id):
return jsonify({'status': 'deleted', 'id': capture_id}) return jsonify({'status': 'deleted', 'id': capture_id})
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404 return api_error('Capture not found', 404)
@subghz_bp.route('/captures/<capture_id>', methods=['PATCH']) @subghz_bp.route('/captures/<capture_id>', methods=['PATCH'])
def update_capture(capture_id: str): def update_capture(capture_id: str):
if not capture_id.isalnum(): if not capture_id.isalnum():
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400 return api_error('Invalid capture_id', 400)
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
label = data.get('label', '') label = data.get('label', '')
if not isinstance(label, str) or len(label) > 100: if not isinstance(label, str) or len(label) > 100:
return jsonify({'status': 'error', 'message': 'Label must be a string (max 100 chars)'}), 400 return api_error('Label must be a string (max 100 chars)', 400)
manager = get_subghz_manager() manager = get_subghz_manager()
if manager.update_capture_label(capture_id, label): if manager.update_capture_label(capture_id, label):
return jsonify({'status': 'updated', 'id': capture_id, 'label': label}) return jsonify({'status': 'updated', 'id': capture_id, 'label': label})
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404 return api_error('Capture not found', 404)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
+4 -3
View File
@@ -22,6 +22,7 @@ from flask import Blueprint, Response, jsonify, request
from utils.constants import SSE_KEEPALIVE_INTERVAL, SSE_QUEUE_TIMEOUT from utils.constants import SSE_KEEPALIVE_INTERVAL, SSE_QUEUE_TIMEOUT
from utils.logging import sensor_logger as logger from utils.logging import sensor_logger as logger
from utils.responses import api_success, api_error
from utils.sse import sse_stream_fanout from utils.sse import sse_stream_fanout
try: try:
@@ -549,10 +550,10 @@ def get_weather() -> Response:
lat, lon = loc.get('lat'), loc.get('lon') lat, lon = loc.get('lat'), loc.get('lon')
if lat is None or lon is None: if lat is None or lon is None:
return jsonify({'error': 'No location available'}) return api_error('No location available')
if _requests is None: if _requests is None:
return jsonify({'error': 'requests library not available'}) return api_error('requests library not available')
try: try:
resp = _requests.get( resp = _requests.get(
@@ -580,4 +581,4 @@ def get_weather() -> Response:
return jsonify(weather) return jsonify(weather)
except Exception as exc: except Exception as exc:
logger.debug('Weather fetch failed: %s', exc) logger.debug('Weather fetch failed: %s', exc)
return jsonify({'error': str(exc)}) return api_error(str(exc))
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+272
View File
@@ -0,0 +1,272 @@
"""
TSCM Baseline Routes
Handles /baseline/*, /baselines endpoints.
"""
from __future__ import annotations
import json
import logging
from datetime import datetime
from flask import jsonify, request
from routes.tscm import (
_baseline_recorder,
tscm_bp,
)
from utils.database import (
delete_tscm_baseline,
get_active_tscm_baseline,
get_all_tscm_baselines,
get_tscm_baseline,
get_tscm_sweep,
set_active_tscm_baseline,
)
from utils.tscm.baseline import (
BaselineComparator,
get_comparison_for_active_baseline,
)
logger = logging.getLogger('intercept.tscm')
@tscm_bp.route('/baseline/record', methods=['POST'])
def record_baseline():
"""Start recording a new baseline."""
data = request.get_json() or {}
name = data.get('name', f'Baseline {datetime.now().strftime("%Y-%m-%d %H:%M")}')
location = data.get('location')
description = data.get('description')
baseline_id = _baseline_recorder.start_recording(name, location, description)
return jsonify({
'status': 'success',
'message': 'Baseline recording started',
'baseline_id': baseline_id
})
@tscm_bp.route('/baseline/stop', methods=['POST'])
def stop_baseline():
"""Stop baseline recording."""
result = _baseline_recorder.stop_recording()
if 'error' in result:
return jsonify({'status': 'error', 'message': result['error']})
return jsonify({
'status': 'success',
'message': 'Baseline recording complete',
**result
})
@tscm_bp.route('/baseline/status')
def baseline_status():
"""Get baseline recording status."""
return jsonify(_baseline_recorder.get_recording_status())
@tscm_bp.route('/baselines')
def list_baselines():
"""List all baselines."""
baselines = get_all_tscm_baselines()
return jsonify({'status': 'success', 'baselines': baselines})
@tscm_bp.route('/baseline/<int:baseline_id>')
def get_baseline(baseline_id: int):
"""Get a specific baseline."""
baseline = get_tscm_baseline(baseline_id)
if not baseline:
return jsonify({'status': 'error', 'message': 'Baseline not found'}), 404
return jsonify({'status': 'success', 'baseline': baseline})
@tscm_bp.route('/baseline/<int:baseline_id>/activate', methods=['POST'])
def activate_baseline(baseline_id: int):
"""Set a baseline as active."""
success = set_active_tscm_baseline(baseline_id)
if not success:
return jsonify({'status': 'error', 'message': 'Baseline not found'}), 404
return jsonify({'status': 'success', 'message': 'Baseline activated'})
@tscm_bp.route('/baseline/<int:baseline_id>', methods=['DELETE'])
def remove_baseline(baseline_id: int):
"""Delete a baseline."""
success = delete_tscm_baseline(baseline_id)
if not success:
return jsonify({'status': 'error', 'message': 'Baseline not found'}), 404
return jsonify({'status': 'success', 'message': 'Baseline deleted'})
@tscm_bp.route('/baseline/active')
def get_active_baseline():
"""Get the currently active baseline."""
baseline = get_active_tscm_baseline()
if not baseline:
return jsonify({'status': 'success', 'baseline': None})
return jsonify({'status': 'success', 'baseline': baseline})
@tscm_bp.route('/baseline/compare', methods=['POST'])
def compare_against_baseline():
"""
Compare provided device data against the active baseline.
Expects JSON body with:
- wifi_devices: list of WiFi devices (optional)
- wifi_clients: list of WiFi clients (optional)
- bt_devices: list of Bluetooth devices (optional)
- rf_signals: list of RF signals (optional)
Returns comparison showing new, missing, and matching devices.
"""
data = request.get_json() or {}
wifi_devices = data.get('wifi_devices')
wifi_clients = data.get('wifi_clients')
bt_devices = data.get('bt_devices')
rf_signals = data.get('rf_signals')
# Use the convenience function that gets active baseline
comparison = get_comparison_for_active_baseline(
wifi_devices=wifi_devices,
wifi_clients=wifi_clients,
bt_devices=bt_devices,
rf_signals=rf_signals
)
if comparison is None:
return jsonify({
'status': 'error',
'message': 'No active baseline set'
}), 400
return jsonify({
'status': 'success',
'comparison': comparison
})
# =============================================================================
# Baseline Diff & Health Endpoints
# =============================================================================
@tscm_bp.route('/baseline/diff/<int:baseline_id>/<int:sweep_id>')
def get_baseline_diff(baseline_id: int, sweep_id: int):
"""
Get comprehensive diff between a baseline and a sweep.
Shows new devices, missing devices, changed characteristics,
and baseline health assessment.
"""
try:
from utils.tscm.advanced import calculate_baseline_diff
baseline = get_tscm_baseline(baseline_id)
if not baseline:
return jsonify({'status': 'error', 'message': 'Baseline not found'}), 404
sweep = get_tscm_sweep(sweep_id)
if not sweep:
return jsonify({'status': 'error', 'message': 'Sweep not found'}), 404
# Get current devices from sweep results
results = sweep.get('results', {})
if isinstance(results, str):
results = json.loads(results)
current_wifi = results.get('wifi_devices', [])
current_wifi_clients = results.get('wifi_clients', [])
current_bt = results.get('bt_devices', [])
current_rf = results.get('rf_signals', [])
diff = calculate_baseline_diff(
baseline=baseline,
current_wifi=current_wifi,
current_wifi_clients=current_wifi_clients,
current_bt=current_bt,
current_rf=current_rf,
sweep_id=sweep_id
)
return jsonify({
'status': 'success',
'diff': diff.to_dict()
})
except Exception as e:
logger.error(f"Get baseline diff error: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500
@tscm_bp.route('/baseline/<int:baseline_id>/health')
def get_baseline_health(baseline_id: int):
"""Get health assessment for a baseline."""
try:
from utils.tscm.advanced import BaselineHealth
baseline = get_tscm_baseline(baseline_id)
if not baseline:
return jsonify({'status': 'error', 'message': 'Baseline not found'}), 404
# Calculate age
created_at = baseline.get('created_at')
age_hours = 0
if created_at:
if isinstance(created_at, str):
created = datetime.fromisoformat(created_at.replace('Z', '+00:00'))
age_hours = (datetime.now() - created.replace(tzinfo=None)).total_seconds() / 3600
elif isinstance(created_at, datetime):
age_hours = (datetime.now() - created_at).total_seconds() / 3600
# Count devices
total_devices = (
len(baseline.get('wifi_networks', [])) +
len(baseline.get('bt_devices', [])) +
len(baseline.get('rf_frequencies', []))
)
# Determine health
health = 'healthy'
score = 1.0
reasons = []
if age_hours > 168:
health = 'stale'
score = 0.3
reasons.append(f'Baseline is {age_hours:.0f} hours old (over 1 week)')
elif age_hours > 72:
health = 'noisy'
score = 0.6
reasons.append(f'Baseline is {age_hours:.0f} hours old (over 3 days)')
if total_devices < 3:
score -= 0.2
reasons.append(f'Baseline has few devices ({total_devices})')
if health == 'healthy':
health = 'noisy'
return jsonify({
'status': 'success',
'health': {
'status': health,
'score': round(max(0, score), 2),
'age_hours': round(age_hours, 1),
'total_devices': total_devices,
'reasons': reasons,
}
})
except Exception as e:
logger.error(f"Get baseline health error: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500
+149
View File
@@ -0,0 +1,149 @@
"""
TSCM Case Management Routes
Handles /cases/* endpoints.
"""
from __future__ import annotations
import logging
from flask import jsonify, request
from routes.tscm import tscm_bp
logger = logging.getLogger('intercept.tscm')
@tscm_bp.route('/cases', methods=['GET'])
def list_cases():
"""List all TSCM cases."""
from utils.database import get_all_tscm_cases
status = request.args.get('status')
limit = request.args.get('limit', 50, type=int)
cases = get_all_tscm_cases(status=status, limit=limit)
return jsonify({
'status': 'success',
'count': len(cases),
'cases': cases
})
@tscm_bp.route('/cases', methods=['POST'])
def create_case():
"""Create a new TSCM case."""
from utils.database import create_tscm_case
data = request.get_json() or {}
name = data.get('name')
if not name:
return jsonify({'status': 'error', 'message': 'name is required'}), 400
case_id = create_tscm_case(
name=name,
description=data.get('description'),
location=data.get('location'),
priority=data.get('priority', 'normal'),
created_by=data.get('created_by'),
metadata=data.get('metadata')
)
return jsonify({
'status': 'success',
'message': 'Case created',
'case_id': case_id
})
@tscm_bp.route('/cases/<int:case_id>', methods=['GET'])
def get_case(case_id: int):
"""Get a TSCM case with all linked sweeps, threats, and notes."""
from utils.database import get_tscm_case
case = get_tscm_case(case_id)
if not case:
return jsonify({'status': 'error', 'message': 'Case not found'}), 404
return jsonify({
'status': 'success',
'case': case
})
@tscm_bp.route('/cases/<int:case_id>', methods=['PUT'])
def update_case(case_id: int):
"""Update a TSCM case."""
from utils.database import update_tscm_case
data = request.get_json() or {}
success = update_tscm_case(
case_id=case_id,
status=data.get('status'),
priority=data.get('priority'),
assigned_to=data.get('assigned_to'),
notes=data.get('notes')
)
if not success:
return jsonify({'status': 'error', 'message': 'Case not found'}), 404
return jsonify({
'status': 'success',
'message': 'Case updated'
})
@tscm_bp.route('/cases/<int:case_id>/sweeps/<int:sweep_id>', methods=['POST'])
def link_sweep_to_case(case_id: int, sweep_id: int):
"""Link a sweep to a case."""
from utils.database import add_sweep_to_case
success = add_sweep_to_case(case_id, sweep_id)
return jsonify({
'status': 'success' if success else 'error',
'message': 'Sweep linked to case' if success else 'Already linked or not found'
})
@tscm_bp.route('/cases/<int:case_id>/threats/<int:threat_id>', methods=['POST'])
def link_threat_to_case(case_id: int, threat_id: int):
"""Link a threat to a case."""
from utils.database import add_threat_to_case
success = add_threat_to_case(case_id, threat_id)
return jsonify({
'status': 'success' if success else 'error',
'message': 'Threat linked to case' if success else 'Already linked or not found'
})
@tscm_bp.route('/cases/<int:case_id>/notes', methods=['POST'])
def add_note_to_case(case_id: int):
"""Add a note to a case."""
from utils.database import add_case_note
data = request.get_json() or {}
content = data.get('content')
if not content:
return jsonify({'status': 'error', 'message': 'content is required'}), 400
note_id = add_case_note(
case_id=case_id,
content=content,
note_type=data.get('note_type', 'general'),
created_by=data.get('created_by')
)
return jsonify({
'status': 'success',
'message': 'Note added',
'note_id': note_id
})
+205
View File
@@ -0,0 +1,205 @@
"""
TSCM Meeting Window Routes
Handles /meeting/* endpoints for time correlation during sensitive periods.
"""
from __future__ import annotations
import logging
from datetime import datetime
from flask import jsonify, request
from routes.tscm import (
_current_sweep_id,
_emit_event,
tscm_bp,
)
from utils.tscm.correlation import get_correlation_engine
logger = logging.getLogger('intercept.tscm')
@tscm_bp.route('/meeting/start', methods=['POST'])
def start_meeting():
"""
Mark the start of a sensitive period (meeting, briefing, etc.).
Devices detected during this window will receive additional scoring
for meeting-correlated activity.
"""
correlation = get_correlation_engine()
correlation.start_meeting_window()
_emit_event('meeting_started', {
'timestamp': datetime.now().isoformat(),
'message': 'Sensitive period monitoring active'
})
return jsonify({
'status': 'success',
'message': 'Meeting window started - devices detected now will be flagged'
})
@tscm_bp.route('/meeting/end', methods=['POST'])
def end_meeting():
"""Mark the end of a sensitive period."""
correlation = get_correlation_engine()
correlation.end_meeting_window()
_emit_event('meeting_ended', {
'timestamp': datetime.now().isoformat()
})
return jsonify({
'status': 'success',
'message': 'Meeting window ended'
})
@tscm_bp.route('/meeting/status')
def meeting_status():
"""Check if currently in a meeting window."""
correlation = get_correlation_engine()
in_meeting = correlation.is_during_meeting()
return jsonify({
'status': 'success',
'in_meeting': in_meeting,
'windows': [
{
'start': start.isoformat(),
'end': end.isoformat() if end else None
}
for start, end in correlation.meeting_windows
]
})
# =============================================================================
# Meeting Window Enhanced Endpoints
# =============================================================================
@tscm_bp.route('/meeting/start-tracked', methods=['POST'])
def start_tracked_meeting():
"""
Start a tracked meeting window with database persistence.
Tracks devices first seen during meeting and behavior changes.
"""
from utils.database import start_meeting_window
from utils.tscm.advanced import get_timeline_manager
from routes.tscm import _current_sweep_id
data = request.get_json() or {}
meeting_id = start_meeting_window(
sweep_id=_current_sweep_id,
name=data.get('name'),
location=data.get('location'),
notes=data.get('notes')
)
# Start meeting in correlation engine
correlation = get_correlation_engine()
correlation.start_meeting_window()
# Start in timeline manager
manager = get_timeline_manager()
manager.start_meeting_window()
_emit_event('meeting_started', {
'meeting_id': meeting_id,
'timestamp': datetime.now().isoformat(),
'name': data.get('name'),
})
return jsonify({
'status': 'success',
'message': 'Tracked meeting window started',
'meeting_id': meeting_id
})
@tscm_bp.route('/meeting/<int:meeting_id>/end', methods=['POST'])
def end_tracked_meeting(meeting_id: int):
"""End a tracked meeting window."""
from utils.database import end_meeting_window
from utils.tscm.advanced import get_timeline_manager
success = end_meeting_window(meeting_id)
if not success:
return jsonify({'status': 'error', 'message': 'Meeting not found or already ended'}), 404
# End in correlation engine
correlation = get_correlation_engine()
correlation.end_meeting_window()
# End in timeline manager
manager = get_timeline_manager()
manager.end_meeting_window()
_emit_event('meeting_ended', {
'meeting_id': meeting_id,
'timestamp': datetime.now().isoformat()
})
return jsonify({
'status': 'success',
'message': 'Meeting window ended'
})
@tscm_bp.route('/meeting/<int:meeting_id>/summary')
def get_meeting_summary_endpoint(meeting_id: int):
"""Get detailed summary of device activity during a meeting."""
try:
from utils.database import get_meeting_windows
from utils.tscm.advanced import generate_meeting_summary, get_timeline_manager
from routes.tscm import _current_sweep_id
# Get meeting window
windows = get_meeting_windows(_current_sweep_id or 0)
meeting = None
for w in windows:
if w.get('id') == meeting_id:
meeting = w
break
if not meeting:
return jsonify({'status': 'error', 'message': 'Meeting not found'}), 404
# Get timelines and profiles
manager = get_timeline_manager()
timelines = manager.get_all_timelines()
correlation = get_correlation_engine()
profiles = [p.to_dict() for p in correlation.device_profiles.values()]
summary = generate_meeting_summary(meeting, timelines, profiles)
return jsonify({
'status': 'success',
'summary': summary.to_dict()
})
except Exception as e:
logger.error(f"Get meeting summary error: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500
@tscm_bp.route('/meeting/active')
def get_active_meeting():
"""Get currently active meeting window."""
from utils.database import get_active_meeting_window
from routes.tscm import _current_sweep_id
meeting = get_active_meeting_window(_current_sweep_id)
return jsonify({
'status': 'success',
'meeting': meeting,
'is_active': meeting is not None
})
+186
View File
@@ -0,0 +1,186 @@
"""
TSCM Schedule Routes
Handles /schedules/* endpoints for automated sweep scheduling.
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import Any
from flask import jsonify, request
from routes.tscm import (
_get_schedule_timezone,
_next_run_from_cron,
_start_sweep_internal,
_sweep_running,
tscm_bp,
)
from utils.database import (
create_tscm_schedule,
delete_tscm_schedule,
get_all_tscm_schedules,
get_tscm_schedule,
update_tscm_schedule,
)
logger = logging.getLogger('intercept.tscm')
@tscm_bp.route('/schedules', methods=['GET'])
def list_schedules():
"""List all TSCM sweep schedules."""
enabled_param = request.args.get('enabled')
enabled = None
if enabled_param is not None:
enabled = enabled_param.lower() in ('1', 'true', 'yes')
schedules = get_all_tscm_schedules(enabled=enabled, limit=200)
return jsonify({
'status': 'success',
'count': len(schedules),
'schedules': schedules,
})
@tscm_bp.route('/schedules', methods=['POST'])
def create_schedule():
"""Create a new sweep schedule."""
data = request.get_json() or {}
name = (data.get('name') or '').strip()
cron_expression = (data.get('cron_expression') or '').strip()
sweep_type = data.get('sweep_type', 'standard')
baseline_id = data.get('baseline_id')
zone_name = data.get('zone_name')
enabled = bool(data.get('enabled', True))
notify_on_threat = bool(data.get('notify_on_threat', True))
notify_email = data.get('notify_email')
if not name:
return jsonify({'status': 'error', 'message': 'Schedule name required'}), 400
if not cron_expression:
return jsonify({'status': 'error', 'message': 'cron_expression required'}), 400
next_run = None
if enabled:
try:
tz = _get_schedule_timezone(zone_name)
next_local = _next_run_from_cron(cron_expression, datetime.now(tz))
next_run = next_local.astimezone(timezone.utc).isoformat() if next_local else None
except Exception as e:
return jsonify({'status': 'error', 'message': f'Invalid cron: {e}'}), 400
schedule_id = create_tscm_schedule(
name=name,
cron_expression=cron_expression,
sweep_type=sweep_type,
baseline_id=baseline_id,
zone_name=zone_name,
enabled=enabled,
notify_on_threat=notify_on_threat,
notify_email=notify_email,
next_run=next_run,
)
schedule = get_tscm_schedule(schedule_id)
return jsonify({
'status': 'success',
'message': 'Schedule created',
'schedule': schedule
})
@tscm_bp.route('/schedules/<int:schedule_id>', methods=['PUT', 'PATCH'])
def update_schedule(schedule_id: int):
"""Update a sweep schedule."""
schedule = get_tscm_schedule(schedule_id)
if not schedule:
return jsonify({'status': 'error', 'message': 'Schedule not found'}), 404
data = request.get_json() or {}
updates: dict[str, Any] = {}
for key in ('name', 'cron_expression', 'sweep_type', 'baseline_id', 'zone_name', 'notify_email'):
if key in data:
updates[key] = data[key]
if 'baseline_id' in updates and updates['baseline_id'] in ('', None):
updates['baseline_id'] = None
if 'enabled' in data:
updates['enabled'] = 1 if data['enabled'] else 0
if 'notify_on_threat' in data:
updates['notify_on_threat'] = 1 if data['notify_on_threat'] else 0
# Recalculate next_run when cron/zone/enabled changes
if any(k in updates for k in ('cron_expression', 'zone_name', 'enabled')):
if updates.get('enabled', schedule.get('enabled', 1)):
cron_expr = updates.get('cron_expression', schedule.get('cron_expression', ''))
zone_name = updates.get('zone_name', schedule.get('zone_name'))
try:
tz = _get_schedule_timezone(zone_name)
next_local = _next_run_from_cron(cron_expr, datetime.now(tz))
updates['next_run'] = next_local.astimezone(timezone.utc).isoformat() if next_local else None
except Exception as e:
return jsonify({'status': 'error', 'message': f'Invalid cron: {e}'}), 400
else:
updates['next_run'] = None
if not updates:
return jsonify({'status': 'error', 'message': 'No updates provided'}), 400
update_tscm_schedule(schedule_id, **updates)
schedule = get_tscm_schedule(schedule_id)
return jsonify({'status': 'success', 'schedule': schedule})
@tscm_bp.route('/schedules/<int:schedule_id>', methods=['DELETE'])
def delete_schedule(schedule_id: int):
"""Delete a sweep schedule."""
success = delete_tscm_schedule(schedule_id)
if not success:
return jsonify({'status': 'error', 'message': 'Schedule not found'}), 404
return jsonify({'status': 'success', 'message': 'Schedule deleted'})
@tscm_bp.route('/schedules/<int:schedule_id>/run', methods=['POST'])
def run_schedule_now(schedule_id: int):
"""Trigger a scheduled sweep immediately."""
schedule = get_tscm_schedule(schedule_id)
if not schedule:
return jsonify({'status': 'error', 'message': 'Schedule not found'}), 404
result = _start_sweep_internal(
sweep_type=schedule.get('sweep_type') or 'standard',
baseline_id=schedule.get('baseline_id'),
wifi_enabled=True,
bt_enabled=True,
rf_enabled=True,
wifi_interface='',
bt_interface='',
sdr_device=None,
verbose_results=False,
)
if result.get('status') != 'success':
status_code = result.pop('http_status', 400)
return jsonify(result), status_code
# Update schedule run timestamps
cron_expr = schedule.get('cron_expression') or ''
tz = _get_schedule_timezone(schedule.get('zone_name'))
now_utc = datetime.now(timezone.utc)
try:
next_local = _next_run_from_cron(cron_expr, datetime.now(tz))
except Exception:
next_local = None
update_tscm_schedule(
schedule_id,
last_run=now_utc.isoformat(),
next_run=next_local.astimezone(timezone.utc).isoformat() if next_local else None,
)
return jsonify(result)
+434
View File
@@ -0,0 +1,434 @@
"""
TSCM Sweep Routes
Handles /sweep/*, /status, /devices, /presets/*, /feed/*,
/capabilities, and /sweep/<id>/capabilities endpoints.
"""
from __future__ import annotations
import json
import logging
import os
import platform
import re
import shutil
import subprocess
from typing import Any
from flask import Response, jsonify, request
from routes.tscm import (
_current_sweep_id,
_emit_event,
_start_sweep_internal,
_sweep_running,
tscm_bp,
tscm_queue,
_baseline_recorder,
)
from data.tscm_frequencies import get_all_sweep_presets, get_sweep_preset
from utils.database import get_tscm_sweep, update_tscm_sweep
from utils.event_pipeline import process_event
from utils.sse import sse_stream_fanout
logger = logging.getLogger('intercept.tscm')
@tscm_bp.route('/status')
def tscm_status():
"""Check if any TSCM operation is currently running."""
from routes.tscm import _sweep_running
return jsonify({'running': _sweep_running})
@tscm_bp.route('/sweep/start', methods=['POST'])
def start_sweep():
"""Start a TSCM sweep."""
data = request.get_json() or {}
sweep_type = data.get('sweep_type', 'standard')
baseline_id = data.get('baseline_id')
if baseline_id in ('', None):
baseline_id = None
wifi_enabled = data.get('wifi', True)
bt_enabled = data.get('bluetooth', True)
rf_enabled = data.get('rf', True)
verbose_results = bool(data.get('verbose_results', False))
# Get interface selections
wifi_interface = data.get('wifi_interface', '')
bt_interface = data.get('bt_interface', '')
sdr_device = data.get('sdr_device')
result = _start_sweep_internal(
sweep_type=sweep_type,
baseline_id=baseline_id,
wifi_enabled=wifi_enabled,
bt_enabled=bt_enabled,
rf_enabled=rf_enabled,
wifi_interface=wifi_interface,
bt_interface=bt_interface,
sdr_device=sdr_device,
verbose_results=verbose_results,
)
http_status = result.pop('http_status', 200)
return jsonify(result), http_status
@tscm_bp.route('/sweep/stop', methods=['POST'])
def stop_sweep():
"""Stop the current TSCM sweep."""
import routes.tscm as _tscm_pkg
if not _tscm_pkg._sweep_running:
return jsonify({'status': 'error', 'message': 'No sweep running'})
_tscm_pkg._sweep_running = False
if _tscm_pkg._current_sweep_id:
update_tscm_sweep(_tscm_pkg._current_sweep_id, status='aborted', completed=True)
_emit_event('sweep_stopped', {'reason': 'user_requested'})
logger.info("TSCM sweep stopped by user")
return jsonify({'status': 'success', 'message': 'Sweep stopped'})
@tscm_bp.route('/sweep/status')
def sweep_status():
"""Get current sweep status."""
from routes.tscm import _sweep_running, _current_sweep_id
status = {
'running': _sweep_running,
'sweep_id': _current_sweep_id,
}
if _current_sweep_id:
sweep = get_tscm_sweep(_current_sweep_id)
if sweep:
status['sweep'] = sweep
return jsonify(status)
@tscm_bp.route('/sweep/stream')
def sweep_stream():
"""SSE stream for real-time sweep updates."""
from routes.tscm import tscm_queue
def _on_msg(msg: dict[str, Any]) -> None:
process_event('tscm', msg, msg.get('type'))
return Response(
sse_stream_fanout(
source_queue=tscm_queue,
channel_key='tscm',
timeout=1.0,
keepalive_interval=30.0,
on_message=_on_msg,
),
mimetype='text/event-stream',
headers={
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no'
}
)
@tscm_bp.route('/devices')
def get_tscm_devices():
"""Get available scanning devices for TSCM sweeps."""
devices = {
'wifi_interfaces': [],
'bt_adapters': [],
'sdr_devices': []
}
# Detect WiFi interfaces
if platform.system() == 'Darwin': # macOS
try:
result = subprocess.run(
['networksetup', '-listallhardwareports'],
capture_output=True, text=True, timeout=5
)
lines = result.stdout.split('\n')
for i, line in enumerate(lines):
if 'Wi-Fi' in line or 'AirPort' in line:
# Get the hardware port name (e.g., "Wi-Fi")
port_name = line.replace('Hardware Port:', '').strip()
for j in range(i + 1, min(i + 3, len(lines))):
if 'Device:' in lines[j]:
device = lines[j].split('Device:')[1].strip()
devices['wifi_interfaces'].append({
'name': device,
'display_name': f'{port_name} ({device})',
'type': 'internal',
'monitor_capable': False
})
break
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
pass
else: # Linux
try:
result = subprocess.run(
['iw', 'dev'],
capture_output=True, text=True, timeout=5
)
current_iface = None
for line in result.stdout.split('\n'):
line = line.strip()
if line.startswith('Interface'):
current_iface = line.split()[1]
elif current_iface and 'type' in line:
iface_type = line.split()[-1]
devices['wifi_interfaces'].append({
'name': current_iface,
'display_name': f'Wireless ({current_iface}) - {iface_type}',
'type': iface_type,
'monitor_capable': True
})
current_iface = None
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
# Fall back to iwconfig
try:
result = subprocess.run(
['iwconfig'],
capture_output=True, text=True, timeout=5
)
for line in result.stdout.split('\n'):
if 'IEEE 802.11' in line:
iface = line.split()[0]
devices['wifi_interfaces'].append({
'name': iface,
'display_name': f'Wireless ({iface})',
'type': 'managed',
'monitor_capable': True
})
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
pass
# Detect Bluetooth adapters
if platform.system() == 'Linux':
try:
result = subprocess.run(
['hciconfig'],
capture_output=True, text=True, timeout=5
)
blocks = re.split(r'(?=^hci\d+:)', result.stdout, flags=re.MULTILINE)
for idx, block in enumerate(blocks):
if block.strip():
first_line = block.split('\n')[0]
match = re.match(r'(hci\d+):', first_line)
if match:
iface_name = match.group(1)
is_up = 'UP RUNNING' in block or '\tUP ' in block
devices['bt_adapters'].append({
'name': iface_name,
'display_name': f'Bluetooth Adapter ({iface_name})',
'type': 'hci',
'status': 'up' if is_up else 'down'
})
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
# Try bluetoothctl as fallback
try:
result = subprocess.run(
['bluetoothctl', 'list'],
capture_output=True, text=True, timeout=5
)
for line in result.stdout.split('\n'):
if 'Controller' in line:
# Format: Controller XX:XX:XX:XX:XX:XX Name
parts = line.split()
if len(parts) >= 3:
addr = parts[1]
name = ' '.join(parts[2:]) if len(parts) > 2 else 'Bluetooth'
devices['bt_adapters'].append({
'name': addr,
'display_name': f'{name} ({addr[-8:]})',
'type': 'controller',
'status': 'available'
})
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
pass
elif platform.system() == 'Darwin':
# macOS has built-in Bluetooth - get more info via system_profiler
try:
result = subprocess.run(
['system_profiler', 'SPBluetoothDataType'],
capture_output=True, text=True, timeout=10
)
# Extract controller info
bt_name = 'Built-in Bluetooth'
bt_addr = ''
for line in result.stdout.split('\n'):
if 'Address:' in line:
bt_addr = line.split('Address:')[1].strip()
break
devices['bt_adapters'].append({
'name': 'default',
'display_name': f'{bt_name}' + (f' ({bt_addr[-8:]})' if bt_addr else ''),
'type': 'macos',
'status': 'available'
})
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
devices['bt_adapters'].append({
'name': 'default',
'display_name': 'Built-in Bluetooth',
'type': 'macos',
'status': 'available'
})
# Detect SDR devices
try:
from utils.sdr import SDRFactory
sdr_list = SDRFactory.detect_devices()
for sdr in sdr_list:
# SDRDevice is a dataclass with attributes, not a dict
sdr_type_name = sdr.sdr_type.value if hasattr(sdr.sdr_type, 'value') else str(sdr.sdr_type)
# Create a friendly display name
display_name = sdr.name
if sdr.serial and sdr.serial not in ('N/A', 'Unknown'):
display_name = f'{sdr.name} (SN: {sdr.serial[-8:]})'
devices['sdr_devices'].append({
'index': sdr.index,
'name': sdr.name,
'display_name': display_name,
'type': sdr_type_name,
'serial': sdr.serial,
'driver': sdr.driver
})
except ImportError:
logger.debug("SDR module not available")
except Exception as e:
logger.warning(f"Error detecting SDR devices: {e}")
# Check if running as root
from flask import current_app
running_as_root = current_app.config.get('RUNNING_AS_ROOT', os.geteuid() == 0)
warnings = []
if not running_as_root:
warnings.append({
'type': 'privileges',
'message': 'Not running as root. WiFi monitor mode and some Bluetooth features require sudo.',
'action': 'Run with: sudo -E venv/bin/python intercept.py'
})
return jsonify({
'status': 'success',
'devices': devices,
'running_as_root': running_as_root,
'warnings': warnings
})
# =============================================================================
# Preset Endpoints
# =============================================================================
@tscm_bp.route('/presets')
def list_presets():
"""List available sweep presets."""
presets = get_all_sweep_presets()
return jsonify({'status': 'success', 'presets': presets})
@tscm_bp.route('/presets/<preset_name>')
def get_preset(preset_name: str):
"""Get details for a specific preset."""
preset = get_sweep_preset(preset_name)
if not preset:
return jsonify({'status': 'error', 'message': 'Preset not found'}), 404
return jsonify({'status': 'success', 'preset': preset})
# =============================================================================
# Data Feed Endpoints (for adding data during sweeps/baselines)
# =============================================================================
@tscm_bp.route('/feed/wifi', methods=['POST'])
def feed_wifi():
"""Feed WiFi device data for baseline recording."""
from routes.tscm import _baseline_recorder
data = request.get_json()
if data:
if data.get('is_client'):
_baseline_recorder.add_wifi_client(data)
else:
_baseline_recorder.add_wifi_device(data)
return jsonify({'status': 'success'})
@tscm_bp.route('/feed/bluetooth', methods=['POST'])
def feed_bluetooth():
"""Feed Bluetooth device data for baseline recording."""
from routes.tscm import _baseline_recorder
data = request.get_json()
if data:
_baseline_recorder.add_bt_device(data)
return jsonify({'status': 'success'})
@tscm_bp.route('/feed/rf', methods=['POST'])
def feed_rf():
"""Feed RF signal data for baseline recording."""
from routes.tscm import _baseline_recorder
data = request.get_json()
if data:
_baseline_recorder.add_rf_signal(data)
return jsonify({'status': 'success'})
# =============================================================================
# Capabilities & Coverage Endpoints
# =============================================================================
@tscm_bp.route('/capabilities')
def get_capabilities():
"""
Get current system capabilities for TSCM sweeping.
Returns what the system CAN and CANNOT detect based on OS,
privileges, adapters, and SDR hardware.
"""
try:
from utils.tscm.advanced import detect_sweep_capabilities
wifi_interface = request.args.get('wifi_interface', '')
bt_adapter = request.args.get('bt_adapter', '')
caps = detect_sweep_capabilities(
wifi_interface=wifi_interface,
bt_adapter=bt_adapter
)
return jsonify({
'status': 'success',
'capabilities': caps.to_dict()
})
except Exception as e:
logger.error(f"Get capabilities error: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500
@tscm_bp.route('/sweep/<int:sweep_id>/capabilities')
def get_sweep_stored_capabilities(sweep_id: int):
"""Get stored capabilities for a specific sweep."""
from utils.database import get_sweep_capabilities
caps = get_sweep_capabilities(sweep_id)
if not caps:
return jsonify({'status': 'error', 'message': 'No capabilities stored for this sweep'}), 404
return jsonify({
'status': 'success',
'capabilities': caps
})
+6 -20
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
from flask import Blueprint, Response, jsonify, request from flask import Blueprint, Response, jsonify, request
from utils.responses import api_success, api_error
from utils.logging import get_logger from utils.logging import get_logger
from utils.updater import ( from utils.updater import (
check_for_updates, check_for_updates,
@@ -39,10 +40,7 @@ def check_updates() -> Response:
return jsonify(result) return jsonify(result)
except Exception as e: except Exception as e:
logger.error(f"Error checking for updates: {e}") logger.error(f"Error checking for updates: {e}")
return jsonify({ return api_error(str(e), 500)
'success': False,
'error': str(e)
}), 500
@updater_bp.route('/status', methods=['GET']) @updater_bp.route('/status', methods=['GET'])
@@ -61,10 +59,7 @@ def update_status() -> Response:
return jsonify(result) return jsonify(result)
except Exception as e: except Exception as e:
logger.error(f"Error getting update status: {e}") logger.error(f"Error getting update status: {e}")
return jsonify({ return api_error(str(e), 500)
'success': False,
'error': str(e)
}), 500
@updater_bp.route('/update', methods=['POST']) @updater_bp.route('/update', methods=['POST'])
@@ -100,10 +95,7 @@ def do_update() -> Response:
except Exception as e: except Exception as e:
logger.error(f"Error performing update: {e}") logger.error(f"Error performing update: {e}")
return jsonify({ return api_error(str(e), 500)
'success': False,
'error': str(e)
}), 500
@updater_bp.route('/dismiss', methods=['POST']) @updater_bp.route('/dismiss', methods=['POST'])
@@ -124,20 +116,14 @@ def dismiss_notification() -> Response:
version = data.get('version') version = data.get('version')
if not version: if not version:
return jsonify({ return api_error('Version is required', 400)
'success': False,
'error': 'Version is required'
}), 400
try: try:
result = dismiss_update(version) result = dismiss_update(version)
return jsonify(result) return jsonify(result)
except Exception as e: except Exception as e:
logger.error(f"Error dismissing update: {e}") logger.error(f"Error dismissing update: {e}")
return jsonify({ return api_error(str(e), 500)
'success': False,
'error': str(e)
}), 500
@updater_bp.route('/restart', methods=['POST']) @updater_bp.route('/restart', methods=['POST'])
+8 -20
View File
@@ -18,6 +18,7 @@ from typing import Any, Generator
from flask import Blueprint, Response, jsonify, request from flask import Blueprint, Response, jsonify, request
import app as app_module import app as app_module
from utils.responses import api_success, api_error
from utils.acars_translator import translate_message from utils.acars_translator import translate_message
from utils.constants import ( from utils.constants import (
PROCESS_START_WAIT, PROCESS_START_WAIT,
@@ -181,18 +182,12 @@ def start_vdl2() -> Response:
with app_module.vdl2_lock: with app_module.vdl2_lock:
if app_module.vdl2_process and app_module.vdl2_process.poll() is None: if app_module.vdl2_process and app_module.vdl2_process.poll() is None:
return jsonify({ return api_error('VDL2 decoder already running', 409)
'status': 'error',
'message': 'VDL2 decoder already running'
}), 409
# Check for dumpvdl2 # Check for dumpvdl2
dumpvdl2_path = find_dumpvdl2() dumpvdl2_path = find_dumpvdl2()
if not dumpvdl2_path: if not dumpvdl2_path:
return jsonify({ return api_error('dumpvdl2 not found. Install from: https://github.com/szpajder/dumpvdl2', 400)
'status': 'error',
'message': 'dumpvdl2 not found. Install from: https://github.com/szpajder/dumpvdl2'
}), 400
data = request.json or {} data = request.json or {}
@@ -202,7 +197,7 @@ def start_vdl2() -> Response:
gain = validate_gain(data.get('gain', '40')) gain = validate_gain(data.get('gain', '40'))
ppm = validate_ppm(data.get('ppm', '0')) ppm = validate_ppm(data.get('ppm', '0'))
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
# Resolve SDR type for device selection # Resolve SDR type for device selection
sdr_type_str = data.get('sdr_type', 'rtlsdr') sdr_type_str = data.get('sdr_type', 'rtlsdr')
@@ -215,11 +210,7 @@ def start_vdl2() -> Response:
device_int = int(device) device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'vdl2', sdr_type_str) error = app_module.claim_sdr_device(device_int, 'vdl2', sdr_type_str)
if error: if error:
return jsonify({ return api_error(error, 409, error_type='DEVICE_BUSY')
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
vdl2_active_device = device_int vdl2_active_device = device_int
vdl2_active_sdr_type = sdr_type_str vdl2_active_sdr_type = sdr_type_str
@@ -312,7 +303,7 @@ def start_vdl2() -> Response:
if stderr: if stderr:
error_msg += f': {stderr[:500]}' error_msg += f': {stderr[:500]}'
logger.error(error_msg) logger.error(error_msg)
return jsonify({'status': 'error', 'message': error_msg}), 500 return api_error(error_msg, 500)
app_module.vdl2_process = process app_module.vdl2_process = process
register_process(process) register_process(process)
@@ -339,7 +330,7 @@ def start_vdl2() -> Response:
vdl2_active_device = None vdl2_active_device = None
vdl2_active_sdr_type = None vdl2_active_sdr_type = None
logger.error(f"Failed to start VDL2 decoder: {e}") logger.error(f"Failed to start VDL2 decoder: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500 return api_error(str(e), 500)
@vdl2_bp.route('/stop', methods=['POST']) @vdl2_bp.route('/stop', methods=['POST'])
@@ -349,10 +340,7 @@ def stop_vdl2() -> Response:
with app_module.vdl2_lock: with app_module.vdl2_lock:
if not app_module.vdl2_process: if not app_module.vdl2_process:
return jsonify({ return api_error('VDL2 decoder not running', 400)
'status': 'error',
'message': 'VDL2 decoder not running'
}), 400
try: try:
app_module.vdl2_process.terminate() app_module.vdl2_process.terminate()
+12 -18
View File
@@ -10,6 +10,7 @@ import queue
from flask import Blueprint, jsonify, request, Response, send_file from flask import Blueprint, jsonify, request, Response, send_file
from utils.responses import api_success, api_error
from utils.logging import get_logger from utils.logging import get_logger
from utils.sse import sse_stream from utils.sse import sse_stream
from utils.validation import validate_device_index, validate_gain, validate_latitude, validate_longitude, validate_elevation, validate_rtl_tcp_host, validate_rtl_tcp_port from utils.validation import validate_device_index, validate_gain, validate_latitude, validate_longitude, validate_elevation, validate_rtl_tcp_host, validate_rtl_tcp_port
@@ -174,7 +175,7 @@ def start_capture():
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host) rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port) rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
# Claim SDR device (skip for remote rtl_tcp) # Claim SDR device (skip for remote rtl_tcp)
if not rtl_tcp_host: if not rtl_tcp_host:
@@ -182,11 +183,7 @@ def start_capture():
import app as app_module import app as app_module
error = app_module.claim_sdr_device(device_index, 'weather_sat', sdr_type_str) error = app_module.claim_sdr_device(device_index, 'weather_sat', sdr_type_str)
if error: if error:
return jsonify({ return api_error(error, 409, error_type='DEVICE_BUSY')
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error,
}), 409
except ImportError: except ImportError:
pass pass
@@ -417,15 +414,15 @@ def get_image(filename: str):
# Security: only allow safe filenames # Security: only allow safe filenames
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum(): if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400 return api_error('Invalid filename', 400)
if not (filename.endswith('.png') or filename.endswith('.jpg') or filename.endswith('.jpeg')): if not (filename.endswith('.png') or filename.endswith('.jpg') or filename.endswith('.jpeg')):
return jsonify({'status': 'error', 'message': 'Only PNG/JPG files supported'}), 400 return api_error('Only PNG/JPG files supported', 400)
image_path = decoder._output_dir / filename image_path = decoder._output_dir / filename
if not image_path.exists(): if not image_path.exists():
return jsonify({'status': 'error', 'message': 'Image not found'}), 404 return api_error('Image not found', 404)
mimetype = 'image/png' if filename.endswith('.png') else 'image/jpeg' mimetype = 'image/png' if filename.endswith('.png') else 'image/jpeg'
return send_file(image_path, mimetype=mimetype) return send_file(image_path, mimetype=mimetype)
@@ -444,12 +441,12 @@ def delete_image(filename: str):
decoder = get_weather_sat_decoder() decoder = get_weather_sat_decoder()
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum(): if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400 return api_error('Invalid filename', 400)
if decoder.delete_image(filename): if decoder.delete_image(filename):
return jsonify({'status': 'deleted', 'filename': filename}) return jsonify({'status': 'deleted', 'filename': filename})
else: else:
return jsonify({'status': 'error', 'message': 'Image not found'}), 404 return api_error('Image not found', 404)
@weather_sat_bp.route('/images', methods=['DELETE']) @weather_sat_bp.route('/images', methods=['DELETE'])
@@ -500,17 +497,14 @@ def get_passes():
raw_lon = request.args.get('longitude') raw_lon = request.args.get('longitude')
if raw_lat is None or raw_lon is None: if raw_lat is None or raw_lon is None:
return jsonify({ return api_error('latitude and longitude parameters required', 400)
'status': 'error',
'message': 'latitude and longitude parameters required'
}), 400
try: try:
lat = validate_latitude(raw_lat) lat = validate_latitude(raw_lat)
lon = validate_longitude(raw_lon) lon = validate_longitude(raw_lon)
except ValueError as e: except ValueError as e:
logger.warning('Invalid coordinates in get_passes: %s', e) logger.warning('Invalid coordinates in get_passes: %s', e)
return jsonify({'status': 'error', 'message': 'Invalid coordinates'}), 400 return api_error('Invalid coordinates', 400)
hours = max(1, min(request.args.get('hours', 24, type=int), 72)) hours = max(1, min(request.args.get('hours', 24, type=int), 72))
min_elevation = max(0, min(request.args.get('min_elevation', 15, type=float), 90)) min_elevation = max(0, min(request.args.get('min_elevation', 15, type=float), 90))
@@ -668,10 +662,10 @@ def skip_pass(pass_id: str):
from utils.weather_sat_scheduler import get_weather_sat_scheduler from utils.weather_sat_scheduler import get_weather_sat_scheduler
if not pass_id.replace('_', '').replace('-', '').isalnum(): if not pass_id.replace('_', '').replace('-', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid pass ID'}), 400 return api_error('Invalid pass ID', 400)
scheduler = get_weather_sat_scheduler() scheduler = get_weather_sat_scheduler()
if scheduler.skip_pass(pass_id): if scheduler.skip_pass(pass_id):
return jsonify({'status': 'skipped', 'pass_id': pass_id}) return jsonify({'status': 'skipped', 'pass_id': pass_id})
else: else:
return jsonify({'status': 'error', 'message': 'Pass not found or already processed'}), 404 return api_error('Pass not found or already processed', 404)
+9 -12
View File
@@ -13,6 +13,8 @@ from typing import Optional
from flask import Blueprint, Flask, jsonify, request, Response from flask import Blueprint, Flask, jsonify, request, Response
from utils.responses import api_success, api_error
try: try:
from flask_sock import Sock from flask_sock import Sock
WEBSOCKET_AVAILABLE = True WEBSOCKET_AVAILABLE = True
@@ -226,8 +228,7 @@ def list_receivers() -> Response:
if r.get('freq_lo', 0) <= freq_khz <= r.get('freq_hi', 30000) if r.get('freq_lo', 0) <= freq_khz <= r.get('freq_hi', 30000)
] ]
return jsonify({ return api_success(data={
'status': 'success',
'receivers': filtered[:100], 'receivers': filtered[:100],
'total': len(filtered), 'total': len(filtered),
'cached_total': len(receivers), 'cached_total': len(receivers),
@@ -242,7 +243,7 @@ def nearest_receivers() -> Response:
freq_khz = request.args.get('freq_khz', type=float) freq_khz = request.args.get('freq_khz', type=float)
if lat is None or lon is None: if lat is None or lon is None:
return jsonify({'status': 'error', 'message': 'lat and lon are required'}), 400 return api_error('lat and lon are required', 400)
receivers = get_receivers() receivers = get_receivers()
@@ -264,10 +265,7 @@ def nearest_receivers() -> Response:
with_distance.sort(key=lambda x: x['distance_km']) with_distance.sort(key=lambda x: x['distance_km'])
return jsonify({ return api_success(data={'receivers': with_distance[:10]})
'status': 'success',
'receivers': with_distance[:10],
})
@websdr_bp.route('/spy-station/<station_id>/receivers') @websdr_bp.route('/spy-station/<station_id>/receivers')
@@ -276,7 +274,7 @@ def spy_station_receivers(station_id: str) -> Response:
try: try:
from routes.spy_stations import STATIONS from routes.spy_stations import STATIONS
except ImportError: except ImportError:
return jsonify({'status': 'error', 'message': 'Spy stations module not available'}), 503 return api_error('Spy stations module not available', 503)
# Find the station # Find the station
station = None station = None
@@ -286,7 +284,7 @@ def spy_station_receivers(station_id: str) -> Response:
break break
if not station: if not station:
return jsonify({'status': 'error', 'message': 'Station not found'}), 404 return api_error('Station not found', 404)
# Get primary frequency # Get primary frequency
freq_khz = None freq_khz = None
@@ -298,7 +296,7 @@ def spy_station_receivers(station_id: str) -> Response:
freq_khz = station['frequencies'][0].get('freq_khz') freq_khz = station['frequencies'][0].get('freq_khz')
if freq_khz is None: if freq_khz is None:
return jsonify({'status': 'error', 'message': 'No frequency found for station'}), 404 return api_error('No frequency found for station', 404)
receivers = get_receivers() receivers = get_receivers()
@@ -308,8 +306,7 @@ def spy_station_receivers(station_id: str) -> Response:
if r.get('freq_lo', 0) <= freq_khz <= r.get('freq_hi', 30000) and r.get('available', True) if r.get('freq_lo', 0) <= freq_khz <= r.get('freq_hi', 30000) and r.get('available', True)
] ]
return jsonify({ return api_success(data={
'status': 'success',
'station': { 'station': {
'id': station['id'], 'id': station['id'],
'name': station.get('name', ''), 'name': station.get('name', ''),
+22 -67
View File
@@ -10,6 +10,7 @@ import queue
from flask import Blueprint, Response, jsonify, request, send_file from flask import Blueprint, Response, jsonify, request, send_file
from utils.responses import api_success, api_error
import app as app_module import app as app_module
from utils.logging import get_logger from utils.logging import get_logger
from utils.sdr import SDRType from utils.sdr import SDRType
@@ -109,10 +110,7 @@ def start_decoder():
# Validate frequency (required) # Validate frequency (required)
frequency_khz = data.get('frequency_khz') frequency_khz = data.get('frequency_khz')
if frequency_khz is None: if frequency_khz is None:
return jsonify({ return api_error('frequency_khz is required', 400)
'status': 'error',
'message': 'frequency_khz is required',
}), 400
try: try:
frequency_khz = float(frequency_khz) frequency_khz = float(frequency_khz)
@@ -120,10 +118,7 @@ def start_decoder():
freq_mhz = frequency_khz / 1000.0 freq_mhz = frequency_khz / 1000.0
validate_frequency(freq_mhz, min_mhz=2.0, max_mhz=30.0) validate_frequency(freq_mhz, min_mhz=2.0, max_mhz=30.0)
except (TypeError, ValueError) as e: except (TypeError, ValueError) as e:
return jsonify({ return api_error(f'Invalid frequency: {e}', 400)
'status': 'error',
'message': f'Invalid frequency: {e}',
}), 400
station = str(data.get('station', '')).strip() station = str(data.get('station', '')).strip()
device_index = data.get('device', 0) device_index = data.get('device', 0)
@@ -152,34 +147,21 @@ def start_decoder():
tuned_mhz = tuned_frequency_khz / 1000.0 tuned_mhz = tuned_frequency_khz / 1000.0
validate_frequency(tuned_mhz, min_mhz=2.0, max_mhz=30.0) validate_frequency(tuned_mhz, min_mhz=2.0, max_mhz=30.0)
except ValueError as e: except ValueError as e:
return jsonify({ return api_error(f'Invalid frequency settings: {e}', 400)
'status': 'error',
'message': f'Invalid frequency settings: {e}',
}), 400
# Validate IOC and LPM # Validate IOC and LPM
if ioc not in (288, 576): if ioc not in (288, 576):
return jsonify({ return api_error('IOC must be 288 or 576', 400)
'status': 'error',
'message': 'IOC must be 288 or 576',
}), 400
if lpm not in (60, 120): if lpm not in (60, 120):
return jsonify({ return api_error('LPM must be 60 or 120', 400)
'status': 'error',
'message': 'LPM must be 60 or 120',
}), 400
# Claim SDR device # Claim SDR device
global wefax_active_device, wefax_active_sdr_type global wefax_active_device, wefax_active_sdr_type
device_int = int(device_index) device_int = int(device_index)
error = app_module.claim_sdr_device(device_int, 'wefax', sdr_type_str) error = app_module.claim_sdr_device(device_int, 'wefax', sdr_type_str)
if error: if error:
return jsonify({ return api_error(error, 409, error_type='DEVICE_BUSY')
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error,
}), 409
# Set callback and start # Set callback and start
decoder.set_callback(_progress_callback) decoder.set_callback(_progress_callback)
@@ -213,10 +195,7 @@ def start_decoder():
}) })
else: else:
app_module.release_sdr_device(device_int, sdr_type_str) app_module.release_sdr_device(device_int, sdr_type_str)
return jsonify({ return api_error('Failed to start decoder', 500)
'status': 'error',
'message': 'Failed to start decoder',
}), 500
@wefax_bp.route('/stop', methods=['POST']) @wefax_bp.route('/stop', methods=['POST'])
@@ -275,14 +254,14 @@ def get_image(filename: str):
decoder = get_wefax_decoder() decoder = get_wefax_decoder()
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum(): if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400 return api_error('Invalid filename', 400)
if not filename.endswith('.png'): if not filename.endswith('.png'):
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400 return api_error('Only PNG files supported', 400)
image_path = decoder._output_dir / filename image_path = decoder._output_dir / filename
if not image_path.exists(): if not image_path.exists():
return jsonify({'status': 'error', 'message': 'Image not found'}), 404 return api_error('Image not found', 404)
return send_file(image_path, mimetype='image/png') return send_file(image_path, mimetype='image/png')
@@ -293,15 +272,15 @@ def delete_image(filename: str):
decoder = get_wefax_decoder() decoder = get_wefax_decoder()
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum(): if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400 return api_error('Invalid filename', 400)
if not filename.endswith('.png'): if not filename.endswith('.png'):
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400 return api_error('Only PNG files supported', 400)
if decoder.delete_image(filename): if decoder.delete_image(filename):
return jsonify({'status': 'ok'}) return jsonify({'status': 'ok'})
else: else:
return jsonify({'status': 'error', 'message': 'Image not found'}), 404 return api_error('Image not found', 404)
@wefax_bp.route('/images', methods=['DELETE']) @wefax_bp.route('/images', methods=['DELETE'])
@@ -354,27 +333,18 @@ def enable_schedule():
station = str(data.get('station', '')).strip() station = str(data.get('station', '')).strip()
if not station: if not station:
return jsonify({ return api_error('station is required', 400)
'status': 'error',
'message': 'station is required',
}), 400
frequency_khz = data.get('frequency_khz') frequency_khz = data.get('frequency_khz')
if frequency_khz is None: if frequency_khz is None:
return jsonify({ return api_error('frequency_khz is required', 400)
'status': 'error',
'message': 'frequency_khz is required',
}), 400
try: try:
frequency_khz = float(frequency_khz) frequency_khz = float(frequency_khz)
freq_mhz = frequency_khz / 1000.0 freq_mhz = frequency_khz / 1000.0
validate_frequency(freq_mhz, min_mhz=2.0, max_mhz=30.0) validate_frequency(freq_mhz, min_mhz=2.0, max_mhz=30.0)
except (TypeError, ValueError) as e: except (TypeError, ValueError) as e:
return jsonify({ return api_error(f'Invalid frequency: {e}', 400)
'status': 'error',
'message': f'Invalid frequency: {e}',
}), 400
device = int(data.get('device', 0)) device = int(data.get('device', 0))
gain = float(data.get('gain', 40.0)) gain = float(data.get('gain', 40.0))
@@ -396,10 +366,7 @@ def enable_schedule():
tuned_mhz = tuned_frequency_khz / 1000.0 tuned_mhz = tuned_frequency_khz / 1000.0
validate_frequency(tuned_mhz, min_mhz=2.0, max_mhz=30.0) validate_frequency(tuned_mhz, min_mhz=2.0, max_mhz=30.0)
except ValueError as e: except ValueError as e:
return jsonify({ return api_error(f'Invalid frequency settings: {e}', 400)
'status': 'error',
'message': f'Invalid frequency settings: {e}',
}), 400
scheduler = get_wefax_scheduler() scheduler = get_wefax_scheduler()
scheduler.set_callbacks(_progress_callback, _scheduler_event_callback) scheduler.set_callbacks(_progress_callback, _scheduler_event_callback)
@@ -416,10 +383,7 @@ def enable_schedule():
) )
except Exception: except Exception:
logger.exception("Failed to enable WeFax scheduler") logger.exception("Failed to enable WeFax scheduler")
return jsonify({ return api_error('Failed to enable scheduler', 500)
'status': 'error',
'message': 'Failed to enable scheduler',
}), 500
return jsonify({ return jsonify({
'status': 'ok', 'status': 'ok',
@@ -473,19 +437,13 @@ def skip_broadcast(broadcast_id: str):
from utils.wefax_scheduler import get_wefax_scheduler from utils.wefax_scheduler import get_wefax_scheduler
if not broadcast_id.replace('_', '').replace('-', '').isalnum(): if not broadcast_id.replace('_', '').replace('-', '').isalnum():
return jsonify({ return api_error('Invalid broadcast ID', 400)
'status': 'error',
'message': 'Invalid broadcast ID',
}), 400
scheduler = get_wefax_scheduler() scheduler = get_wefax_scheduler()
if scheduler.skip_broadcast(broadcast_id): if scheduler.skip_broadcast(broadcast_id):
return jsonify({'status': 'skipped', 'broadcast_id': broadcast_id}) return jsonify({'status': 'skipped', 'broadcast_id': broadcast_id})
else: else:
return jsonify({ return api_error('Broadcast not found or already processed', 404)
'status': 'error',
'message': 'Broadcast not found or already processed',
}), 404
@wefax_bp.route('/stations') @wefax_bp.route('/stations')
@@ -504,10 +462,7 @@ def station_detail(callsign: str):
"""Get station detail including current schedule info.""" """Get station detail including current schedule info."""
station = get_station(callsign) station = get_station(callsign)
if not station: if not station:
return jsonify({ return api_error(f'Station {callsign} not found', 404)
'status': 'error',
'message': f'Station {callsign} not found',
}), 404
current = get_current_broadcasts(callsign) current = get_current_broadcasts(callsign)
+212 -200
View File
@@ -15,14 +15,15 @@ from typing import Any, Generator
from flask import Blueprint, jsonify, request, Response from flask import Blueprint, jsonify, request, Response
from utils.responses import api_success, api_error
import app as app_module import app as app_module
from utils.dependencies import check_tool, get_tool_path from utils.dependencies import check_tool, get_tool_path
from utils.logging import wifi_logger as logger from utils.logging import wifi_logger as logger
from utils.process import is_valid_mac, is_valid_channel from utils.process import is_valid_mac, is_valid_channel
from utils.validation import validate_wifi_channel, validate_mac_address, validate_network_interface from utils.validation import validate_wifi_channel, validate_mac_address, validate_network_interface
from utils.sse import format_sse, sse_stream_fanout from utils.sse import format_sse, sse_stream_fanout
from utils.event_pipeline import process_event from utils.event_pipeline import process_event
from data.oui import get_manufacturer from data.oui import get_manufacturer
from utils.constants import ( from utils.constants import (
WIFI_TERMINATE_TIMEOUT, WIFI_TERMINATE_TIMEOUT,
PMKID_TERMINATE_TIMEOUT, PMKID_TERMINATE_TIMEOUT,
@@ -46,34 +47,52 @@ from utils.constants import (
wifi_bp = Blueprint('wifi', __name__, url_prefix='/wifi') wifi_bp = Blueprint('wifi', __name__, url_prefix='/wifi')
# --- v1 deprecation ---
# These endpoints are deprecated in favor of /wifi/v2/*.
# Frontend still uses v1, so they remain active.
# Migration: switch frontend to v2 endpoints, then remove this file.
_v1_deprecation_logged = set()
@wifi_bp.after_request
def _add_deprecation_header(response):
"""Add X-Deprecated header to all v1 WiFi responses."""
response.headers['X-Deprecated'] = 'Use /wifi/v2/* endpoints instead'
endpoint = request.endpoint or ''
if endpoint not in _v1_deprecation_logged:
_v1_deprecation_logged.add(endpoint)
logger.warning(f"Deprecated v1 WiFi endpoint called: {request.path} — migrate to /wifi/v2/*")
return response
# PMKID process state # PMKID process state
pmkid_process = None pmkid_process = None
pmkid_lock = threading.Lock() pmkid_lock = threading.Lock()
def _parse_channel_list(raw_channels: Any) -> list[int] | None: def _parse_channel_list(raw_channels: Any) -> list[int] | None:
"""Parse a channel list from string/list input.""" """Parse a channel list from string/list input."""
if raw_channels in (None, '', []): if raw_channels in (None, '', []):
return None return None
if isinstance(raw_channels, str): if isinstance(raw_channels, str):
parts = [p.strip() for p in re.split(r'[\s,]+', raw_channels) if p.strip()] parts = [p.strip() for p in re.split(r'[\s,]+', raw_channels) if p.strip()]
elif isinstance(raw_channels, (list, tuple, set)): elif isinstance(raw_channels, (list, tuple, set)):
parts = list(raw_channels) parts = list(raw_channels)
else: else:
parts = [raw_channels] parts = [raw_channels]
channels: list[int] = [] channels: list[int] = []
seen = set() seen = set()
for part in parts: for part in parts:
if part in (None, ''): if part in (None, ''):
continue continue
ch = validate_wifi_channel(part) ch = validate_wifi_channel(part)
if ch not in seen: if ch not in seen:
channels.append(ch) channels.append(ch)
seen.add(ch) seen.add(ch)
return channels or None return channels or None
def detect_wifi_interfaces(): def detect_wifi_interfaces():
@@ -455,7 +474,7 @@ def toggle_monitor_mode():
try: try:
interface = validate_network_interface(data.get('interface')) interface = validate_network_interface(data.get('interface'))
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
if action == 'start': if action == 'start':
if check_tool('airmon-ng'): if check_tool('airmon-ng'):
@@ -575,20 +594,17 @@ def toggle_monitor_mode():
all_wireless = [f for f in os.listdir('/sys/class/net') 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')] 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}") logger.error(f"Monitor interface not found. Tried: {monitor_iface}. Available: {all_wireless}")
return jsonify({ return api_error(f'Monitor interface not created. airmon-ng output: {output[:500]}. Available interfaces: {all_wireless}')
'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_monitor_interface = monitor_iface
app_module.wifi_queue.put({'type': 'info', 'text': f'Monitor mode enabled on {app_module.wifi_monitor_interface}'}) 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}") logger.info(f"Monitor mode enabled on {monitor_iface}")
return jsonify({'status': 'success', 'monitor_interface': app_module.wifi_monitor_interface}) return api_success(data={'monitor_interface': app_module.wifi_monitor_interface})
except Exception as e: except Exception as e:
import traceback import traceback
logger.error(f"Error enabling monitor mode: {e}", exc_info=True) logger.error(f"Error enabling monitor mode: {e}", exc_info=True)
return jsonify({'status': 'error', 'message': str(e)}) return api_error(str(e))
elif check_tool('iw'): elif check_tool('iw'):
try: try:
@@ -596,11 +612,11 @@ def toggle_monitor_mode():
subprocess.run(['iw', interface, 'set', 'monitor', 'control'], capture_output=True) subprocess.run(['iw', interface, 'set', 'monitor', 'control'], capture_output=True)
subprocess.run(['ip', 'link', 'set', interface, 'up'], capture_output=True) subprocess.run(['ip', 'link', 'set', interface, 'up'], capture_output=True)
app_module.wifi_monitor_interface = interface app_module.wifi_monitor_interface = interface
return jsonify({'status': 'success', 'monitor_interface': interface}) return api_success(data={'monitor_interface': interface})
except Exception as e: except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}) return api_error(str(e))
else: else:
return jsonify({'status': 'error', 'message': 'No monitor mode tools available.'}) return api_error('No monitor mode tools available.')
else: # stop else: # stop
if check_tool('airmon-ng'): if check_tool('airmon-ng'):
@@ -609,20 +625,20 @@ def toggle_monitor_mode():
subprocess.run([airmon_path, 'stop', app_module.wifi_monitor_interface or interface], subprocess.run([airmon_path, 'stop', app_module.wifi_monitor_interface or interface],
capture_output=True, text=True, timeout=15) capture_output=True, text=True, timeout=15)
app_module.wifi_monitor_interface = None app_module.wifi_monitor_interface = None
return jsonify({'status': 'success', 'message': 'Monitor mode disabled'}) return api_success(message='Monitor mode disabled')
except Exception as e: except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}) return api_error(str(e))
elif check_tool('iw'): elif check_tool('iw'):
try: try:
subprocess.run(['ip', 'link', 'set', interface, 'down'], capture_output=True) subprocess.run(['ip', 'link', 'set', interface, 'down'], capture_output=True)
subprocess.run(['iw', interface, 'set', 'type', 'managed'], capture_output=True) subprocess.run(['iw', interface, 'set', 'type', 'managed'], capture_output=True)
subprocess.run(['ip', 'link', 'set', interface, 'up'], capture_output=True) subprocess.run(['ip', 'link', 'set', interface, 'up'], capture_output=True)
app_module.wifi_monitor_interface = None app_module.wifi_monitor_interface = None
return jsonify({'status': 'success', 'message': 'Monitor mode disabled'}) return api_success(message='Monitor mode disabled')
except Exception as e: except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}) return api_error(str(e))
return jsonify({'status': 'error', 'message': 'Unknown action'}) return api_error('Unknown action')
@wifi_bp.route('/scan/start', methods=['POST']) @wifi_bp.route('/scan/start', methods=['POST'])
@@ -630,12 +646,12 @@ def start_wifi_scan():
"""Start WiFi scanning with airodump-ng.""" """Start WiFi scanning with airodump-ng."""
with app_module.wifi_lock: with app_module.wifi_lock:
if app_module.wifi_process: if app_module.wifi_process:
return jsonify({'status': 'error', 'message': 'Scan already running'}) return api_error('Scan already running')
data = request.json data = request.json
channel = data.get('channel') channel = data.get('channel')
channels = data.get('channels') channels = data.get('channels')
band = data.get('band', 'abg') band = data.get('band', 'abg')
# Use provided interface or fall back to stored monitor interface # Use provided interface or fall back to stored monitor interface
interface = data.get('interface') interface = data.get('interface')
@@ -643,21 +659,18 @@ def start_wifi_scan():
try: try:
interface = validate_network_interface(interface) interface = validate_network_interface(interface)
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
else: else:
interface = app_module.wifi_monitor_interface interface = app_module.wifi_monitor_interface
if not interface: if not interface:
return jsonify({'status': 'error', 'message': 'No monitor interface available.'}) return api_error('No monitor interface available.')
# Verify interface exists # Verify interface exists
if not os.path.exists(f'/sys/class/net/{interface}'): if not os.path.exists(f'/sys/class/net/{interface}'):
all_wireless = [f for f in os.listdir('/sys/class/net') 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')] if os.path.exists(f'/sys/class/net/{f}/wireless') or 'mon' in f or f.startswith('wl')]
return jsonify({ return api_error(f'Interface "{interface}" does not exist. Available: {all_wireless}')
'status': 'error',
'message': f'Interface "{interface}" does not exist. Available: {all_wireless}'
})
app_module.wifi_networks = {} app_module.wifi_networks = {}
app_module.wifi_clients = {} app_module.wifi_clients = {}
@@ -685,17 +698,17 @@ def start_wifi_scan():
interface interface
] ]
channel_list = None channel_list = None
if channels: if channels:
try: try:
channel_list = _parse_channel_list(channels) channel_list = _parse_channel_list(channels)
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
if channel_list: if channel_list:
cmd.extend(['-c', ','.join(str(c) for c in channel_list)]) cmd.extend(['-c', ','.join(str(c) for c in channel_list)])
elif channel: elif channel:
cmd.extend(['-c', str(channel)]) cmd.extend(['-c', str(channel)])
logger.info(f"Running: {' '.join(cmd)}") logger.info(f"Running: {' '.join(cmd)}")
@@ -723,7 +736,7 @@ def start_wifi_scan():
error_msg = 'Permission denied. Try running with sudo.' error_msg = 'Permission denied. Try running with sudo.'
logger.error(f"airodump-ng failed for interface '{interface}': {error_msg}") logger.error(f"airodump-ng failed for interface '{interface}': {error_msg}")
return jsonify({'status': 'error', 'message': error_msg, 'interface': interface}) return api_error(error_msg)
thread = threading.Thread(target=stream_airodump_output, args=(app_module.wifi_process, csv_path)) thread = threading.Thread(target=stream_airodump_output, args=(app_module.wifi_process, csv_path))
thread.daemon = True thread.daemon = True
@@ -734,9 +747,9 @@ def start_wifi_scan():
return jsonify({'status': 'started', 'interface': interface}) return jsonify({'status': 'started', 'interface': interface})
except FileNotFoundError: except FileNotFoundError:
return jsonify({'status': 'error', 'message': 'airodump-ng not found.'}) return api_error('airodump-ng not found.')
except Exception as e: except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}) return api_error(str(e))
@wifi_bp.route('/scan/stop', methods=['POST']) @wifi_bp.route('/scan/stop', methods=['POST'])
@@ -768,18 +781,18 @@ def send_deauth():
try: try:
interface = validate_network_interface(interface) interface = validate_network_interface(interface)
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
else: else:
interface = app_module.wifi_monitor_interface interface = app_module.wifi_monitor_interface
if not target_bssid: if not target_bssid:
return jsonify({'status': 'error', 'message': 'Target BSSID required'}) return api_error('Target BSSID required')
if not is_valid_mac(target_bssid): if not is_valid_mac(target_bssid):
return jsonify({'status': 'error', 'message': 'Invalid BSSID format'}) return api_error('Invalid BSSID format')
if not is_valid_mac(target_client): if not is_valid_mac(target_client):
return jsonify({'status': 'error', 'message': 'Invalid client MAC format'}) return api_error('Invalid client MAC format')
try: try:
count = int(count) count = int(count)
@@ -789,10 +802,10 @@ def send_deauth():
count = 5 count = 5
if not interface: if not interface:
return jsonify({'status': 'error', 'message': 'No monitor interface'}) return api_error('No monitor interface')
if not check_tool('aireplay-ng'): if not check_tool('aireplay-ng'):
return jsonify({'status': 'error', 'message': 'aireplay-ng not found'}) return api_error('aireplay-ng not found')
try: try:
aireplay_path = get_tool_path('aireplay-ng') aireplay_path = get_tool_path('aireplay-ng')
@@ -809,14 +822,14 @@ def send_deauth():
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if result.returncode == 0: if result.returncode == 0:
return jsonify({'status': 'success', 'message': f'Sent {count} deauth packets'}) return api_success(message=f'Sent {count} deauth packets')
else: else:
return jsonify({'status': 'error', 'message': result.stderr}) return api_error(result.stderr)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
return jsonify({'status': 'success', 'message': 'Deauth sent (timed out)'}) return api_success(message='Deauth sent (timed out)')
except Exception as e: except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}) return api_error(str(e))
@wifi_bp.route('/handshake/capture', methods=['POST']) @wifi_bp.route('/handshake/capture', methods=['POST'])
@@ -832,22 +845,22 @@ def capture_handshake():
try: try:
interface = validate_network_interface(interface) interface = validate_network_interface(interface)
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
else: else:
interface = app_module.wifi_monitor_interface interface = app_module.wifi_monitor_interface
if not target_bssid or not channel: if not target_bssid or not channel:
return jsonify({'status': 'error', 'message': 'BSSID and channel required'}) return api_error('BSSID and channel required')
if not is_valid_mac(target_bssid): if not is_valid_mac(target_bssid):
return jsonify({'status': 'error', 'message': 'Invalid BSSID format'}) return api_error('Invalid BSSID format')
if not is_valid_channel(channel): if not is_valid_channel(channel):
return jsonify({'status': 'error', 'message': 'Invalid channel'}) return api_error('Invalid channel')
with app_module.wifi_lock: with app_module.wifi_lock:
if app_module.wifi_process: if app_module.wifi_process:
return jsonify({'status': 'error', 'message': 'Scan already running.'}) return api_error('Scan already running.')
capture_path = f'/tmp/intercept_handshake_{target_bssid.replace(":", "")}' capture_path = f'/tmp/intercept_handshake_{target_bssid.replace(":", "")}'
@@ -866,7 +879,7 @@ def capture_handshake():
app_module.wifi_queue.put({'type': 'info', 'text': f'Capturing handshakes for {target_bssid}'}) app_module.wifi_queue.put({'type': 'info', 'text': f'Capturing handshakes for {target_bssid}'})
return jsonify({'status': 'started', 'capture_file': capture_path + '-01.cap'}) return jsonify({'status': 'started', 'capture_file': capture_path + '-01.cap'})
except Exception as e: except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}) return api_error(str(e))
@wifi_bp.route('/handshake/status', methods=['POST']) @wifi_bp.route('/handshake/status', methods=['POST'])
@@ -877,7 +890,7 @@ def check_handshake_status():
target_bssid = data.get('bssid', '') target_bssid = data.get('bssid', '')
if not capture_file.startswith('/tmp/intercept_handshake_') or '..' in capture_file: if not capture_file.startswith('/tmp/intercept_handshake_') or '..' in capture_file:
return jsonify({'status': 'error', 'message': 'Invalid capture file path'}) return api_error('Invalid capture file path')
if not os.path.exists(capture_file): if not os.path.exists(capture_file):
with app_module.wifi_lock: with app_module.wifi_lock:
@@ -887,53 +900,53 @@ def check_handshake_status():
return jsonify({'status': 'stopped', 'file_exists': False, 'handshake_found': False}) return jsonify({'status': 'stopped', 'file_exists': False, 'handshake_found': False})
file_size = os.path.getsize(capture_file) file_size = os.path.getsize(capture_file)
handshake_found = False handshake_found = False
handshake_valid: bool | None = None handshake_valid: bool | None = None
handshake_checked = False handshake_checked = False
handshake_reason: str | None = None handshake_reason: str | None = None
try: try:
if target_bssid and is_valid_mac(target_bssid): if target_bssid and is_valid_mac(target_bssid):
aircrack_path = get_tool_path('aircrack-ng') aircrack_path = get_tool_path('aircrack-ng')
if aircrack_path: if aircrack_path:
result = subprocess.run( result = subprocess.run(
[aircrack_path, '-a', '2', '-b', target_bssid, capture_file], [aircrack_path, '-a', '2', '-b', target_bssid, capture_file],
capture_output=True, text=True, timeout=10 capture_output=True, text=True, timeout=10
) )
output = result.stdout + result.stderr output = result.stdout + result.stderr
output_lower = output.lower() output_lower = output.lower()
handshake_checked = True handshake_checked = True
if 'no valid wpa handshakes found' in output_lower: if 'no valid wpa handshakes found' in output_lower:
handshake_valid = False handshake_valid = False
handshake_reason = 'No valid WPA handshake found' handshake_reason = 'No valid WPA handshake found'
elif '0 handshake' in output_lower: elif '0 handshake' in output_lower:
handshake_valid = False handshake_valid = False
elif '1 handshake' in output_lower or ('handshake' in output_lower and 'wpa' in output_lower): elif '1 handshake' in output_lower or ('handshake' in output_lower and 'wpa' in output_lower):
handshake_valid = True handshake_valid = True
else: else:
handshake_valid = False handshake_valid = False
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
pass pass
except Exception as e: except Exception as e:
logger.error(f"Error checking handshake: {e}") logger.error(f"Error checking handshake: {e}")
if handshake_valid: if handshake_valid:
handshake_found = True handshake_found = True
normalized_bssid = target_bssid.upper() if target_bssid else None normalized_bssid = target_bssid.upper() if target_bssid else None
if normalized_bssid and normalized_bssid not in app_module.wifi_handshakes: if normalized_bssid and normalized_bssid not in app_module.wifi_handshakes:
app_module.wifi_handshakes.append(normalized_bssid) app_module.wifi_handshakes.append(normalized_bssid)
return jsonify({ return jsonify({
'status': 'running' if app_module.wifi_process and app_module.wifi_process.poll() is None else 'stopped', 'status': 'running' if app_module.wifi_process and app_module.wifi_process.poll() is None else 'stopped',
'file_exists': True, 'file_exists': True,
'file_size': file_size, 'file_size': file_size,
'file': capture_file, 'file': capture_file,
'handshake_found': handshake_found, 'handshake_found': handshake_found,
'handshake_valid': handshake_valid, 'handshake_valid': handshake_valid,
'handshake_checked': handshake_checked, 'handshake_checked': handshake_checked,
'handshake_reason': handshake_reason 'handshake_reason': handshake_reason
}) })
@wifi_bp.route('/pmkid/capture', methods=['POST']) @wifi_bp.route('/pmkid/capture', methods=['POST'])
@@ -951,19 +964,19 @@ def capture_pmkid():
try: try:
interface = validate_network_interface(interface) interface = validate_network_interface(interface)
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
else: else:
interface = app_module.wifi_monitor_interface interface = app_module.wifi_monitor_interface
if not target_bssid: if not target_bssid:
return jsonify({'status': 'error', 'message': 'BSSID required'}) return api_error('BSSID required')
if not is_valid_mac(target_bssid): if not is_valid_mac(target_bssid):
return jsonify({'status': 'error', 'message': 'Invalid BSSID format'}) return api_error('Invalid BSSID format')
with pmkid_lock: with pmkid_lock:
if pmkid_process and pmkid_process.poll() is None: if pmkid_process and pmkid_process.poll() is None:
return jsonify({'status': 'error', 'message': 'PMKID capture already running'}) return api_error('PMKID capture already running')
capture_path = f'/tmp/intercept_pmkid_{target_bssid.replace(":", "")}.pcapng' capture_path = f'/tmp/intercept_pmkid_{target_bssid.replace(":", "")}.pcapng'
filter_file = f'/tmp/pmkid_filter_{target_bssid.replace(":", "")}' filter_file = f'/tmp/pmkid_filter_{target_bssid.replace(":", "")}'
@@ -986,9 +999,9 @@ def capture_pmkid():
pmkid_process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) pmkid_process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
return jsonify({'status': 'started', 'file': capture_path}) return jsonify({'status': 'started', 'file': capture_path})
except FileNotFoundError: except FileNotFoundError:
return jsonify({'status': 'error', 'message': 'hcxdumptool not found.'}) return api_error('hcxdumptool not found.')
except Exception as e: except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}) return api_error(str(e))
@wifi_bp.route('/pmkid/status', methods=['POST']) @wifi_bp.route('/pmkid/status', methods=['POST'])
@@ -998,7 +1011,7 @@ def check_pmkid_status():
capture_file = data.get('file', '') capture_file = data.get('file', '')
if not capture_file.startswith('/tmp/intercept_pmkid_') or '..' in capture_file: if not capture_file.startswith('/tmp/intercept_pmkid_') or '..' in capture_file:
return jsonify({'status': 'error', 'message': 'Invalid capture file path'}) return api_error('Invalid capture file path')
if not os.path.exists(capture_file): if not os.path.exists(capture_file):
return jsonify({'pmkid_found': False, 'file_exists': False}) return jsonify({'pmkid_found': False, 'file_exists': False})
@@ -1054,23 +1067,23 @@ def crack_handshake():
# Validate paths to prevent path traversal # Validate paths to prevent path traversal
if not capture_file.startswith('/tmp/intercept_handshake_') or '..' in capture_file: if not capture_file.startswith('/tmp/intercept_handshake_') or '..' in capture_file:
return jsonify({'status': 'error', 'message': 'Invalid capture file path'}), 400 return api_error('Invalid capture file path', 400)
if '..' in wordlist: if '..' in wordlist:
return jsonify({'status': 'error', 'message': 'Invalid wordlist path'}), 400 return api_error('Invalid wordlist path', 400)
if not os.path.exists(capture_file): if not os.path.exists(capture_file):
return jsonify({'status': 'error', 'message': 'Capture file not found'}), 404 return api_error('Capture file not found', 404)
if not os.path.exists(wordlist): if not os.path.exists(wordlist):
return jsonify({'status': 'error', 'message': 'Wordlist file not found'}), 404 return api_error('Wordlist file not found', 404)
if target_bssid and not is_valid_mac(target_bssid): if target_bssid and not is_valid_mac(target_bssid):
return jsonify({'status': 'error', 'message': 'Invalid BSSID format'}), 400 return api_error('Invalid BSSID format', 400)
aircrack_path = get_tool_path('aircrack-ng') aircrack_path = get_tool_path('aircrack-ng')
if not aircrack_path: if not aircrack_path:
return jsonify({'status': 'error', 'message': 'aircrack-ng not found'}), 500 return api_error('aircrack-ng not found', 500)
try: try:
cmd = [aircrack_path, '-a', '2', '-w', wordlist] cmd = [aircrack_path, '-a', '2', '-w', wordlist]
@@ -1099,8 +1112,7 @@ def crack_handshake():
if match: if match:
password = match.group(1) password = match.group(1)
logger.info(f"Password cracked for {target_bssid}: {password}") logger.info(f"Password cracked for {target_bssid}: {password}")
return jsonify({ return api_success(data={
'status': 'success',
'password': password, 'password': password,
'bssid': target_bssid 'bssid': target_bssid
}) })
@@ -1118,7 +1130,7 @@ def crack_handshake():
}) })
except Exception as e: except Exception as e:
logger.error(f"Crack error: {e}") logger.error(f"Crack error: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500 return api_error(str(e), 500)
@wifi_bp.route('/networks') @wifi_bp.route('/networks')
@@ -1132,26 +1144,26 @@ def get_wifi_networks():
}) })
@wifi_bp.route('/stream') @wifi_bp.route('/stream')
def stream_wifi(): def stream_wifi():
"""SSE stream for WiFi events.""" """SSE stream for WiFi events."""
def _on_msg(msg: dict[str, Any]) -> None: def _on_msg(msg: dict[str, Any]) -> None:
process_event('wifi', msg, msg.get('type')) process_event('wifi', msg, msg.get('type'))
response = Response( response = Response(
sse_stream_fanout( sse_stream_fanout(
source_queue=app_module.wifi_queue, source_queue=app_module.wifi_queue,
channel_key='wifi', channel_key='wifi',
timeout=1.0, timeout=1.0,
keepalive_interval=30.0, keepalive_interval=30.0,
on_message=_on_msg, on_message=_on_msg,
), ),
mimetype='text/event-stream', mimetype='text/event-stream',
) )
response.headers['Cache-Control'] = 'no-cache' response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no' response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive' response.headers['Connection'] = 'keep-alive'
return response return response
# ============================================================================= # =============================================================================
@@ -1189,7 +1201,7 @@ def get_v2_capabilities():
}) })
except Exception as e: except Exception as e:
logger.exception("Error checking capabilities") logger.exception("Error checking capabilities")
return jsonify({'error': str(e)}), 500 return api_error(str(e), 500)
@wifi_bp.route('/v2/scan/quick', methods=['POST']) @wifi_bp.route('/v2/scan/quick', methods=['POST'])
@@ -1220,7 +1232,7 @@ def v2_quick_scan():
}) })
except Exception as e: except Exception as e:
logger.exception("Error in quick scan") logger.exception("Error in quick scan")
return jsonify({'error': str(e)}), 500 return api_error(str(e), 500)
@wifi_bp.route('/v2/scan/start', methods=['POST']) @wifi_bp.route('/v2/scan/start', methods=['POST'])
@@ -1239,10 +1251,10 @@ def v2_start_scan():
return jsonify({'status': 'started'}) return jsonify({'status': 'started'})
else: else:
status = scanner.get_status() status = scanner.get_status()
return jsonify({'error': status.error or 'Failed to start scan'}), 400 return api_error(status.error or 'Failed to start scan', 400)
except Exception as e: except Exception as e:
logger.exception("Error starting deep scan") logger.exception("Error starting deep scan")
return jsonify({'error': str(e)}), 500 return api_error(str(e), 500)
@wifi_bp.route('/v2/scan/stop', methods=['POST']) @wifi_bp.route('/v2/scan/stop', methods=['POST'])
@@ -1254,7 +1266,7 @@ def v2_stop_scan():
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
except Exception as e: except Exception as e:
logger.exception("Error stopping scan") logger.exception("Error stopping scan")
return jsonify({'error': str(e)}), 500 return api_error(str(e), 500)
@wifi_bp.route('/v2/scan/status') @wifi_bp.route('/v2/scan/status')
@@ -1274,7 +1286,7 @@ def v2_scan_status():
}) })
except Exception as e: except Exception as e:
logger.exception("Error getting scan status") logger.exception("Error getting scan status")
return jsonify({'error': str(e)}), 500 return api_error(str(e), 500)
@wifi_bp.route('/v2/networks') @wifi_bp.route('/v2/networks')
@@ -1289,7 +1301,7 @@ def v2_get_networks():
}) })
except Exception as e: except Exception as e:
logger.exception("Error getting networks") logger.exception("Error getting networks")
return jsonify({'error': str(e)}), 500 return api_error(str(e), 500)
@wifi_bp.route('/v2/clients') @wifi_bp.route('/v2/clients')
@@ -1326,7 +1338,7 @@ def v2_get_clients():
}) })
except Exception as e: except Exception as e:
logger.exception("Error getting clients") logger.exception("Error getting clients")
return jsonify({'error': str(e)}), 500 return api_error(str(e), 500)
@wifi_bp.route('/v2/probes') @wifi_bp.route('/v2/probes')
@@ -1341,7 +1353,7 @@ def v2_get_probes():
}) })
except Exception as e: except Exception as e:
logger.exception("Error getting probes") logger.exception("Error getting probes")
return jsonify({'error': str(e)}), 500 return api_error(str(e), 500)
@wifi_bp.route('/v2/channels') @wifi_bp.route('/v2/channels')
@@ -1357,7 +1369,7 @@ def v2_get_channels():
}) })
except Exception as e: except Exception as e:
logger.exception("Error getting channel stats") logger.exception("Error getting channel stats")
return jsonify({'error': str(e)}), 500 return api_error(str(e), 500)
@wifi_bp.route('/v2/stream') @wifi_bp.route('/v2/stream')
@@ -1448,11 +1460,11 @@ def v2_export():
return response return response
else: else:
return jsonify({'error': f'Unknown format: {format_type}'}), 400 return api_error(f'Unknown format: {format_type}', 400)
except Exception as e: except Exception as e:
logger.exception("Error exporting data") logger.exception("Error exporting data")
return jsonify({'error': str(e)}), 500 return api_error(str(e), 500)
@wifi_bp.route('/v2/baseline/set', methods=['POST']) @wifi_bp.route('/v2/baseline/set', methods=['POST'])
@@ -1464,7 +1476,7 @@ def v2_set_baseline():
return jsonify({'status': 'baseline_set', 'count': len(scanner._baseline_networks)}) return jsonify({'status': 'baseline_set', 'count': len(scanner._baseline_networks)})
except Exception as e: except Exception as e:
logger.exception("Error setting baseline") logger.exception("Error setting baseline")
return jsonify({'error': str(e)}), 500 return api_error(str(e), 500)
@wifi_bp.route('/v2/baseline/clear', methods=['POST']) @wifi_bp.route('/v2/baseline/clear', methods=['POST'])
@@ -1476,7 +1488,7 @@ def v2_clear_baseline():
return jsonify({'status': 'baseline_cleared'}) return jsonify({'status': 'baseline_cleared'})
except Exception as e: except Exception as e:
logger.exception("Error clearing baseline") logger.exception("Error clearing baseline")
return jsonify({'error': str(e)}), 500 return api_error(str(e), 500)
@wifi_bp.route('/v2/clear', methods=['POST']) @wifi_bp.route('/v2/clear', methods=['POST'])
@@ -1488,7 +1500,7 @@ def v2_clear_data():
return jsonify({'status': 'cleared'}) return jsonify({'status': 'cleared'})
except Exception as e: except Exception as e:
logger.exception("Error clearing data") logger.exception("Error clearing data")
return jsonify({'error': str(e)}), 500 return api_error(str(e), 500)
# ============================================================================= # =============================================================================
@@ -1535,11 +1547,11 @@ def v2_deauth_status():
}) })
except Exception as e: except Exception as e:
logger.exception("Error getting deauth status") logger.exception("Error getting deauth status")
return jsonify({'error': str(e)}), 500 return api_error(str(e), 500)
@wifi_bp.route('/v2/deauth/stream') @wifi_bp.route('/v2/deauth/stream')
def v2_deauth_stream(): def v2_deauth_stream():
""" """
SSE stream for real-time deauth alerts. SSE stream for real-time deauth alerts.
@@ -1550,18 +1562,18 @@ def v2_deauth_stream():
- deauth_error: An error occurred - deauth_error: An error occurred
- keepalive: Periodic keepalive - keepalive: Periodic keepalive
""" """
response = Response( response = Response(
sse_stream_fanout( sse_stream_fanout(
source_queue=app_module.deauth_detector_queue, source_queue=app_module.deauth_detector_queue,
channel_key='wifi_deauth', channel_key='wifi_deauth',
timeout=SSE_QUEUE_TIMEOUT, timeout=SSE_QUEUE_TIMEOUT,
keepalive_interval=SSE_KEEPALIVE_INTERVAL, keepalive_interval=SSE_KEEPALIVE_INTERVAL,
), ),
mimetype='text/event-stream', mimetype='text/event-stream',
) )
response.headers['Cache-Control'] = 'no-cache' response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no' response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive' response.headers['Connection'] = 'keep-alive'
return response return response
@@ -1600,7 +1612,7 @@ def v2_deauth_alerts():
}) })
except Exception as e: except Exception as e:
logger.exception("Error getting deauth alerts") logger.exception("Error getting deauth alerts")
return jsonify({'error': str(e)}), 500 return api_error(str(e), 500)
@wifi_bp.route('/v2/deauth/clear', methods=['POST']) @wifi_bp.route('/v2/deauth/clear', methods=['POST'])
@@ -1620,4 +1632,4 @@ def v2_deauth_clear():
return jsonify({'status': 'cleared'}) return jsonify({'status': 'cleared'})
except Exception as e: except Exception as e:
logger.exception("Error clearing deauth alerts") logger.exception("Error clearing deauth alerts")
return jsonify({'error': str(e)}), 500 return api_error(str(e), 500)
+54 -56
View File
@@ -16,16 +16,17 @@ from typing import Generator
from flask import Blueprint, jsonify, request, Response from flask import Blueprint, jsonify, request, Response
from utils.wifi import ( from utils.wifi import (
get_wifi_scanner, get_wifi_scanner,
analyze_channels, analyze_channels,
get_hidden_correlator, get_hidden_correlator,
SCAN_MODE_QUICK, SCAN_MODE_QUICK,
SCAN_MODE_DEEP, SCAN_MODE_DEEP,
) )
from utils.sse import format_sse from utils.responses import api_success, api_error
from utils.validation import validate_wifi_channel from utils.sse import format_sse
from utils.event_pipeline import process_event from utils.validation import validate_wifi_channel
from utils.event_pipeline import process_event
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -87,44 +88,44 @@ def start_deep_scan():
Requires monitor mode interface and root privileges. Requires monitor mode interface and root privileges.
Request body: Request body:
interface: Monitor mode interface (e.g., 'wlan0mon') interface: Monitor mode interface (e.g., 'wlan0mon')
band: Band to scan ('2.4', '5', 'all') band: Band to scan ('2.4', '5', 'all')
channel: Optional specific channel to monitor channel: Optional specific channel to monitor
channels: Optional list or comma-separated channels to monitor channels: Optional list or comma-separated channels to monitor
""" """
data = request.get_json() or {} data = request.get_json() or {}
interface = data.get('interface') interface = data.get('interface')
band = data.get('band', 'all') band = data.get('band', 'all')
channel = data.get('channel') channel = data.get('channel')
channels = data.get('channels') channels = data.get('channels')
channel_list = None channel_list = None
if channels: if channels:
if isinstance(channels, str): if isinstance(channels, str):
channel_list = [c.strip() for c in channels.split(',') if c.strip()] channel_list = [c.strip() for c in channels.split(',') if c.strip()]
elif isinstance(channels, (list, tuple, set)): elif isinstance(channels, (list, tuple, set)):
channel_list = list(channels) channel_list = list(channels)
else: else:
channel_list = [channels] channel_list = [channels]
try: try:
channel_list = [validate_wifi_channel(c) for c in channel_list] channel_list = [validate_wifi_channel(c) for c in channel_list]
except (TypeError, ValueError): except (TypeError, ValueError):
return jsonify({'error': 'Invalid channels'}), 400 return api_error('Invalid channels', 400)
if channel: if channel:
try: try:
channel = validate_wifi_channel(channel) channel = validate_wifi_channel(channel)
except ValueError: except ValueError:
return jsonify({'error': 'Invalid channel'}), 400 return api_error('Invalid channel', 400)
scanner = get_wifi_scanner() scanner = get_wifi_scanner()
success = scanner.start_deep_scan( success = scanner.start_deep_scan(
interface=interface, interface=interface,
band=band, band=band,
channel=channel, channel=channel,
channels=channel_list, channels=channel_list,
) )
if success: if success:
return jsonify({ return jsonify({
@@ -133,10 +134,7 @@ def start_deep_scan():
'interface': interface or scanner._capabilities.monitor_interface, 'interface': interface or scanner._capabilities.monitor_interface,
}) })
else: else:
return jsonify({ return api_error(scanner._status.error or 'Scan failed', 400)
'status': 'error',
'error': scanner._status.error,
}), 400
@wifi_v2_bp.route('/scan/stop', methods=['POST']) @wifi_v2_bp.route('/scan/stop', methods=['POST'])
@@ -235,7 +233,7 @@ def get_network(bssid):
if network: if network:
return jsonify(network.to_dict()) return jsonify(network.to_dict())
else: else:
return jsonify({'error': 'Network not found'}), 404 return api_error('Network not found', 404)
@wifi_v2_bp.route('/clients', methods=['GET']) @wifi_v2_bp.route('/clients', methods=['GET'])
@@ -282,7 +280,7 @@ def get_client(mac):
if client: if client:
return jsonify(client.to_dict()) return jsonify(client.to_dict())
else: else:
return jsonify({'error': 'Client not found'}), 404 return api_error('Client not found', 404)
@wifi_v2_bp.route('/probes', methods=['GET']) @wifi_v2_bp.route('/probes', methods=['GET'])
@@ -406,14 +404,14 @@ def event_stream():
- keepalive: Periodic keepalive - keepalive: Periodic keepalive
""" """
def generate() -> Generator[str, None, None]: def generate() -> Generator[str, None, None]:
scanner = get_wifi_scanner() scanner = get_wifi_scanner()
for event in scanner.get_event_stream(): for event in scanner.get_event_stream():
try: try:
process_event('wifi', event, event.get('type')) process_event('wifi', event, event.get('type'))
except Exception: except Exception:
pass pass
yield format_sse(event) yield format_sse(event)
response = Response(generate(), mimetype='text/event-stream') response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache' response.headers['Cache-Control'] = 'no-cache'
+16 -1
View File
@@ -86,6 +86,11 @@ done
export INTERCEPT_HOST="$HOST" export INTERCEPT_HOST="$HOST"
export INTERCEPT_PORT="$PORT" export INTERCEPT_PORT="$PORT"
# ── macOS: allow fork() after ObjC initialisation (gunicorn + gevent) ────
if [[ "$(uname)" == "Darwin" ]]; then
export OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES
fi
# ── Fix ownership of user data dirs when run via sudo ──────────────────────── # ── Fix ownership of user data dirs when run via sudo ────────────────────────
# When invoked via sudo the server process runs as root, so every file it # When invoked via sudo the server process runs as root, so every file it
# creates (configs, logs, database) ends up owned by root. On the *next* # creates (configs, logs, database) ends up owned by root. On the *next*
@@ -152,7 +157,17 @@ fi
# ── Resolve LAN address for display ────────────────────────────────────────── # ── Resolve LAN address for display ──────────────────────────────────────────
if [[ "$HOST" == "0.0.0.0" ]]; then if [[ "$HOST" == "0.0.0.0" ]]; then
LAN_IP=$(hostname -I 2>/dev/null | awk '{print $1}') LAN_IP=$(hostname -I 2>/dev/null | awk '{print $1}' || true)
# hostname -I on macOS fails or returns empty — try macOS methods
if [[ -z "$LAN_IP" ]]; then
LAN_IP=$(ipconfig getifaddr en0 2>/dev/null || true)
fi
if [[ -z "$LAN_IP" ]]; then
LAN_IP=$(ipconfig getifaddr en1 2>/dev/null || true)
fi
if [[ -z "$LAN_IP" ]]; then
LAN_IP=$(ifconfig 2>/dev/null | grep "inet " | grep -v 127.0.0.1 | head -1 | awk '{print $2}' || true)
fi
LAN_IP="${LAN_IP:-localhost}" LAN_IP="${LAN_IP:-localhost}"
else else
LAN_IP="$HOST" LAN_IP="$HOST"
+73
View File
@@ -84,6 +84,18 @@
border-color: var(--accent-red-hover); border-color: var(--accent-red-hover);
} }
.btn-danger-outline {
background: transparent;
color: var(--accent-red);
border-color: var(--accent-red);
}
.btn-danger-outline:hover:not(:disabled) {
background: var(--accent-red-dim);
color: var(--accent-red);
border-color: var(--accent-red);
}
.btn-success { .btn-success {
background: var(--accent-green); background: var(--accent-green);
color: var(--text-inverse); color: var(--text-inverse);
@@ -878,6 +890,67 @@ textarea:focus {
filter: grayscale(30%); filter: grayscale(30%);
} }
/* ============================================
MODAL CLOSE BUTTON
============================================ */
.modal-close-btn {
position: absolute;
top: 8px;
right: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: transparent;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
color: var(--text-secondary);
cursor: pointer;
font-size: 16px;
line-height: 1;
transition: all var(--transition-fast);
}
.modal-close-btn:hover { background: var(--bg-elevated); color: var(--text-primary); }
.modal-close-btn:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 2px; }
/* Aliases for existing modal-specific close classes */
.settings-close,
.help-close,
.wifi-detail-close,
.bt-modal-close,
.tscm-modal-close,
.signal-details-modal-close {
position: absolute;
top: 8px;
right: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: transparent;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
color: var(--text-secondary);
cursor: pointer;
font-size: 16px;
line-height: 1;
transition: all var(--transition-fast);
}
.settings-close:hover,
.help-close:hover,
.wifi-detail-close:hover,
.bt-modal-close:hover,
.tscm-modal-close:hover,
.signal-details-modal-close:hover { background: var(--bg-elevated); color: var(--text-primary); }
.settings-close:focus-visible,
.help-close:focus-visible,
.wifi-detail-close:focus-visible,
.bt-modal-close:focus-visible,
.tscm-modal-close:focus-visible,
.signal-details-modal-close:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 2px; }
/* ============================================ /* ============================================
CONFIRMATION MODAL CONFIRMATION MODAL
============================================ */ ============================================ */
+3 -3
View File
@@ -160,7 +160,7 @@
/* ============================================ /* ============================================
LAYOUT LAYOUT
============================================ */ ============================================ */
--header-height: 60px; --header-height: 48px;
--nav-height: 44px; --nav-height: 44px;
--sidebar-width: 280px; --sidebar-width: 280px;
--stats-strip-height: 36px; --stats-strip-height: 36px;
@@ -224,8 +224,8 @@
--text-primary: #122034; --text-primary: #122034;
--text-secondary: #3a4a5f; --text-secondary: #3a4a5f;
--text-dim: #6b7c93; --text-dim: #566a7f;
--text-muted: #aab6c8; --text-muted: #7a8a9e;
--text-inverse: #f4f7fb; --text-inverse: #f4f7fb;
--border-color: #d1d9e6; --border-color: #d1d9e6;
+83 -73
View File
@@ -212,10 +212,6 @@ body {
} }
.welcome-settings-btn { .welcome-settings-btn {
position: absolute;
top: 12px;
right: 12px;
z-index: 2;
background: none; background: none;
border: none; border: none;
cursor: pointer; cursor: pointer;
@@ -223,6 +219,8 @@ body {
border-radius: 6px; border-radius: 6px;
color: var(--text-dim, rgba(255, 255, 255, 0.3)); color: var(--text-dim, rgba(255, 255, 255, 0.3));
transition: color 0.2s, background 0.2s; transition: color 0.2s, background 0.2s;
margin-left: auto;
flex-shrink: 0;
} }
.welcome-settings-btn:hover { .welcome-settings-btn:hover {
@@ -251,10 +249,9 @@ body {
.welcome-header { .welcome-header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; gap: 14px;
gap: 20px; margin-bottom: 24px;
margin-bottom: 30px; padding-bottom: 16px;
padding-bottom: 20px;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
} }
@@ -300,38 +297,37 @@ body {
} }
} }
.welcome-title-block {
text-align: left;
}
.welcome-title { .welcome-title {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 2.5rem; font-size: 1.6rem;
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
letter-spacing: 0.2em; letter-spacing: 0.2em;
margin: 0; margin: 0;
text-shadow: 0 0 20px rgba(0, 212, 255, 0.3); text-shadow: 0 0 20px rgba(0, 212, 255, 0.3);
white-space: nowrap;
} }
.welcome-tagline { .welcome-tagline {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 0.9rem; font-size: 0.8rem;
color: var(--accent-cyan); color: var(--accent-cyan);
letter-spacing: 0.15em; letter-spacing: 0.1em;
margin: 4px 0 0 0; margin: 0;
opacity: 0.7;
white-space: nowrap;
} }
.welcome-version { .welcome-version {
display: inline-block; display: inline-block;
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 0.65rem; font-size: 0.6rem;
color: var(--bg-primary); color: var(--bg-primary);
background: var(--accent-cyan); background: var(--accent-cyan);
padding: 2px 8px; padding: 2px 8px;
border-radius: 3px; border-radius: 3px;
letter-spacing: 0.05em; letter-spacing: 0.05em;
margin-top: 8px; flex-shrink: 0;
} }
/* Welcome Content Grid */ /* Welcome Content Grid */
@@ -572,6 +568,21 @@ body {
margin: 0; margin: 0;
} }
.welcome-footer-credit {
display: inline-block;
font-family: var(--font-mono);
font-size: 0.65rem;
color: var(--text-dim);
text-decoration: none;
letter-spacing: 0.08em;
margin-top: 6px;
transition: color 0.2s;
}
.welcome-footer-credit:hover {
color: var(--accent-cyan);
}
/* Welcome Scanline */ /* Welcome Scanline */
.welcome-scanline { .welcome-scanline {
position: absolute; position: absolute;
@@ -606,12 +617,9 @@ body {
} }
.welcome-header { .welcome-header {
flex-direction: column; flex-wrap: wrap;
text-align: center; justify-content: center;
} gap: 8px 14px;
.welcome-title-block {
text-align: center;
} }
.mode-grid { .mode-grid {
@@ -639,12 +647,7 @@ body {
} }
.welcome-header { .welcome-header {
flex-direction: row; flex-wrap: nowrap;
text-align: left;
}
.welcome-title-block {
text-align: left;
} }
.mode-grid-compact { .mode-grid-compact {
@@ -664,28 +667,27 @@ body {
header { header {
background: var(--bg-secondary); background: var(--bg-secondary);
padding: 10px 12px; padding: 0 16px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-start; justify-content: space-between;
gap: 10px;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
position: relative; position: relative;
min-height: 52px; height: 48px;
} }
@media (min-width: 768px) { .header-left {
header { display: flex;
justify-content: center; align-items: center;
padding: 12px 20px; gap: 10px;
} min-width: 0;
} }
@media (min-width: 1024px) { .header-right {
header { display: flex;
text-align: center; align-items: center;
display: block; gap: 12px;
} flex-shrink: 0;
} }
header::before { header::before {
@@ -709,14 +711,13 @@ header h1 {
font-weight: 600; font-weight: 600;
letter-spacing: 0.15em; letter-spacing: 0.15em;
margin: 0; margin: 0;
display: inline; white-space: nowrap;
vertical-align: middle;
} }
.logo { .logo {
display: inline-block; display: flex;
vertical-align: middle; align-items: center;
margin-right: 8px; flex-shrink: 0;
} }
.logo svg { .logo svg {
@@ -917,7 +918,7 @@ header h1 {
left: 0; left: 0;
margin-top: 4px; margin-top: 4px;
min-width: 180px; min-width: 180px;
background: var(--bg-secondary); background: #101823;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 6px; border-radius: 6px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
@@ -1088,19 +1089,7 @@ header h1 {
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
} }
header p { /* subtitle removed — compact header */
color: var(--text-secondary);
font-size: 11px;
letter-spacing: 0.1em;
text-transform: uppercase;
margin: 4px 0 8px 0;
}
header p.subtitle {
font-size: 10px;
color: var(--text-secondary);
margin: 0 0 8px 0;
}
header h1 .tagline { header h1 .tagline {
font-weight: 400; font-weight: 400;
@@ -1204,15 +1193,14 @@ header h1 .tagline {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding: 4px 12px; padding: 4px 14px;
background: var(--accent-cyan); background: var(--accent-cyan);
color: var(--bg-primary); color: var(--bg-primary);
border-radius: 4px; border-radius: 4px;
font-size: 10px; font-size: 11px;
font-weight: 600; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 1px; letter-spacing: 1px;
margin-left: 10px;
} }
.active-mode-indicator .pulse-dot { .active-mode-indicator .pulse-dot {
@@ -1631,10 +1619,9 @@ header h1 .tagline {
.section.collapsed h3 { .section.collapsed h3 {
border-bottom: none; border-bottom: none;
margin-bottom: 0 !important; margin: 0 !important;
min-height: 0 !important; min-height: 0 !important;
padding-top: 10px !important; padding: 10px 12px !important;
padding-bottom: 10px !important;
} }
.section.collapsed h3::after { .section.collapsed h3::after {
@@ -1643,7 +1630,7 @@ header h1 .tagline {
} }
.section.collapsed { .section.collapsed {
padding-bottom: 0 !important; padding: 0 !important;
min-height: 0; min-height: 0;
} }
@@ -2433,6 +2420,19 @@ header h1 .tagline {
font-style: italic; font-style: italic;
} }
.mode-content h3 {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-secondary);
margin: 0 0 10px 0;
padding: 0;
display: flex;
align-items: center;
gap: 6px;
}
.mode-content { .mode-content {
display: none; display: none;
} }
@@ -7261,6 +7261,12 @@ body[data-mode="tscm"] {
box-shadow: var(--visual-glow-soft), inset 0 1px 0 rgba(255, 255, 255, 0.03); box-shadow: var(--visual-glow-soft), inset 0 1px 0 rgba(255, 255, 255, 0.03);
} }
.mode-nav-dropdown-menu {
background: linear-gradient(180deg, #162130 0%, #0e1621 100%);
border-color: rgba(74, 163, 255, 0.22);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(74, 163, 255, 0.1);
}
.run-state-strip { .run-state-strip {
margin: 8px var(--top-rail-gutter) 0; margin: 8px var(--top-rail-gutter) 0;
border-color: rgba(74, 163, 255, 0.3); border-color: rgba(74, 163, 255, 0.3);
@@ -7312,7 +7318,7 @@ body[data-mode="tscm"] {
} }
.section h3 { .section h3 {
background: linear-gradient(180deg, rgba(28, 44, 63, 0.88) 0%, rgba(20, 31, 44, 0.9) 100%); background: linear-gradient(180deg, #1c2c3f 0%, #141f2c 100%);
border-bottom-color: rgba(74, 163, 255, 0.2); border-bottom-color: rgba(74, 163, 255, 0.2);
} }
@@ -7419,6 +7425,10 @@ body[data-mode="tscm"] {
box-shadow: 0 10px 24px rgba(18, 40, 66, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.7); box-shadow: 0 10px 24px rgba(18, 40, 66, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.7);
} }
[data-theme="light"] .mode-nav-dropdown-menu {
background: #e9eef5;
}
[data-theme="light"] .run-state-strip { [data-theme="light"] .run-state-strip {
background: linear-gradient(180deg, rgba(245, 248, 253, 0.97) 0%, rgba(238, 243, 250, 0.98) 100%); background: linear-gradient(180deg, rgba(245, 248, 253, 0.97) 0%, rgba(238, 243, 250, 0.98) 100%);
border-color: rgba(31, 95, 168, 0.18); border-color: rgba(31, 95, 168, 0.18);
@@ -7445,7 +7455,7 @@ body[data-mode="tscm"] {
} }
[data-theme="light"] .section h3 { [data-theme="light"] .section h3 {
background: linear-gradient(180deg, rgba(235, 241, 250, 0.92) 0%, rgba(228, 236, 248, 0.94) 100%); background: linear-gradient(180deg, #ebf1fa 0%, #e4ecf8 100%);
border-bottom-color: rgba(31, 95, 168, 0.14); border-bottom-color: rgba(31, 95, 168, 0.14);
} }
+17 -16
View File
@@ -25,20 +25,20 @@
--font-2xl: clamp(24px, 6vw, 40px); --font-2xl: clamp(24px, 6vw, 40px);
/* Header height for calculations */ /* Header height for calculations */
--header-height: 52px; --header-height: 48px;
--nav-height: 44px; --nav-height: 44px;
} }
@media (min-width: 768px) { @media (min-width: 768px) {
:root { :root {
--header-height: 60px; --header-height: 48px;
--nav-height: 48px; --nav-height: 48px;
} }
} }
@media (min-width: 1024px) { @media (min-width: 1024px) {
:root { :root {
--header-height: 96px; --header-height: 48px;
--nav-height: 0px; --nav-height: 0px;
} }
} }
@@ -632,16 +632,17 @@
font-size: 16px; font-size: 16px;
} }
.app-shell header h1 .tagline, .app-shell header h1 .tagline {
.app-shell header h1 .version-badge {
display: none; display: none;
} }
.app-shell header .subtitle { .app-shell header .version-badge {
font-size: 10px; display: none;
white-space: nowrap; }
overflow: hidden;
text-overflow: ellipsis; .app-shell header .active-mode-indicator {
font-size: 9px;
padding: 3px 8px;
} }
.app-shell header .logo svg { .app-shell header .logo svg {
@@ -691,18 +692,18 @@
} }
.app-shell .welcome-header { .app-shell .welcome-header {
flex-direction: column; flex-wrap: wrap;
text-align: center; justify-content: center;
gap: 10px; gap: 6px 10px;
} }
.app-shell .welcome-logo svg { .app-shell .welcome-logo svg {
width: 50px; width: 40px;
height: 50px; height: 40px;
} }
.app-shell .welcome-title { .app-shell .welcome-title {
font-size: 24px; font-size: 1.2rem;
} }
.app-shell .welcome-content { .app-shell .welcome-content {
+30 -14
View File
@@ -895,6 +895,7 @@ const BluetoothMode = (function() {
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
if (startBtn) startBtn.classList.add('btn-loading');
try { try {
let response; let response;
if (isAgentMode) { if (isAgentMode) {
@@ -943,6 +944,8 @@ const BluetoothMode = (function() {
reportActionableError('Start Bluetooth Scan', err, { reportActionableError('Start Bluetooth Scan', err, {
onRetry: () => startScan() onRetry: () => startScan()
}); });
} finally {
if (startBtn) startBtn.classList.remove('btn-loading');
} }
} }
@@ -1738,21 +1741,34 @@ const BluetoothMode = (function() {
} }
function doLocateHandoff(device) { function doLocateHandoff(device) {
console.log('[BT] doLocateHandoff, BtLocate defined:', typeof BtLocate !== 'undefined'); const payload = {
device_id: device.device_id,
device_key: device.device_key || null,
mac_address: device.address,
address_type: device.address_type || null,
irk_hex: device.irk_hex || null,
known_name: device.name || null,
known_manufacturer: device.manufacturer_name || null,
last_known_rssi: device.rssi_current,
tx_power: device.tx_power || null,
appearance_name: device.appearance_name || null,
fingerprint_id: device.fingerprint_id || device.fingerprint?.id || null,
mac_cluster_count: device.mac_cluster_count || 0
};
// If BtLocate is already loaded, hand off directly
if (typeof BtLocate !== 'undefined') { if (typeof BtLocate !== 'undefined') {
BtLocate.handoff({ BtLocate.handoff(payload);
device_id: device.device_id, return;
device_key: device.device_key || null, }
mac_address: device.address,
address_type: device.address_type || null, // Switch to bt_locate mode first — this loads the script, styles,
irk_hex: device.irk_hex || null, // and initializes the module. Then hand off the device data.
known_name: device.name || null, if (typeof switchMode === 'function') {
known_manufacturer: device.manufacturer_name || null, switchMode('bt_locate').then(function() {
last_known_rssi: device.rssi_current, if (typeof BtLocate !== 'undefined') {
tx_power: device.tx_power || null, BtLocate.handoff(payload);
appearance_name: device.appearance_name || null, }
fingerprint_id: device.fingerprint_id || device.fingerprint?.id || null,
mac_cluster_count: device.mac_cluster_count || 0
}); });
} }
} }
+20 -12
View File
@@ -110,19 +110,27 @@ const Meshtastic = (function() {
meshMap = L.map('meshMap').setView([defaultLat, defaultLon], 4); meshMap = L.map('meshMap').setView([defaultLat, defaultLon], 4);
window.meshMap = meshMap; window.meshMap = meshMap;
// Use settings manager for tile layer (allows runtime changes) // Add fallback tiles immediately so the map is visible instantly
const fallbackTiles = L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
maxZoom: 19,
subdomains: 'abcd',
className: 'tile-layer-cyan'
}).addTo(meshMap);
// Upgrade tiles in background via Settings (with timeout fallback)
if (typeof Settings !== 'undefined') { if (typeof Settings !== 'undefined') {
// Wait for settings to load from server before applying tiles try {
await Settings.init(); await Promise.race([
Settings.createTileLayer().addTo(meshMap); Settings.init(),
Settings.registerMap(meshMap); new Promise((_, reject) => setTimeout(() => reject(new Error('Settings timeout')), 5000))
} else { ]);
L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', { meshMap.removeLayer(fallbackTiles);
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>', Settings.createTileLayer().addTo(meshMap);
maxZoom: 19, Settings.registerMap(meshMap);
subdomains: 'abcd', } catch (e) {
className: 'tile-layer-cyan' console.warn('Meshtastic: Settings init failed/timed out, using fallback tiles:', e);
}).addTo(meshMap); }
} }
// Handle resize // Handle resize
+10 -9
View File
@@ -280,18 +280,19 @@ const SpyStations = (function() {
showNotification('Tuning to ' + stationName, formatFrequency(freqKhz) + ' (' + tuneMode.toUpperCase() + ')'); showNotification('Tuning to ' + stationName, formatFrequency(freqKhz) + ' (' + tuneMode.toUpperCase() + ')');
} }
// Switch to spectrum waterfall mode and tune after mode init. // Switch to spectrum waterfall mode and tune after init completes.
if (typeof switchMode === 'function') { const doTune = () => {
switchMode('waterfall');
} else if (typeof selectMode === 'function') {
selectMode('waterfall');
}
setTimeout(() => {
if (typeof Waterfall !== 'undefined' && typeof Waterfall.quickTune === 'function') { if (typeof Waterfall !== 'undefined' && typeof Waterfall.quickTune === 'function') {
Waterfall.quickTune(freqMhz, tuneMode); Waterfall.quickTune(freqMhz, tuneMode);
} }
}, 220); };
if (typeof switchMode === 'function') {
switchMode('waterfall').then(doTune);
} else if (typeof selectMode === 'function') {
selectMode('waterfall');
setTimeout(doTune, 300);
}
} }
/** /**
+18 -11
View File
@@ -215,18 +215,25 @@ const SSTV = (function() {
}); });
window.issMap = issMap; window.issMap = issMap;
// Add tile layer using settings manager if available // Add fallback tiles immediately so the map is visible instantly
const fallbackTiles = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
maxZoom: 19,
className: 'tile-layer-cyan'
}).addTo(issMap);
// Upgrade tiles in background via Settings (with timeout fallback)
if (typeof Settings !== 'undefined') { if (typeof Settings !== 'undefined') {
// Wait for settings to load from server before applying tiles try {
await Settings.init(); await Promise.race([
Settings.createTileLayer().addTo(issMap); Settings.init(),
Settings.registerMap(issMap); new Promise((_, reject) => setTimeout(() => reject(new Error('Settings timeout')), 5000))
} else { ]);
// Fallback to dark theme tiles issMap.removeLayer(fallbackTiles);
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { Settings.createTileLayer().addTo(issMap);
maxZoom: 19, Settings.registerMap(issMap);
className: 'tile-layer-cyan' } catch (e) {
}).addTo(issMap); console.warn('SSTV: Settings init failed/timed out, using fallback tiles:', e);
}
} }
// Create ISS icon // Create ISS icon
+41 -11
View File
@@ -252,6 +252,8 @@ const WeatherSat = (function() {
addConsoleEntry('Starting capture...', 'info'); addConsoleEntry('Starting capture...', 'info');
updateStatusUI('connecting', 'Starting...'); updateStatusUI('connecting', 'Starting...');
const startBtn = document.getElementById('weatherSatStartBtn');
if (startBtn) startBtn.classList.add('btn-loading');
try { try {
const config = { const config = {
satellite, satellite,
@@ -295,6 +297,8 @@ const WeatherSat = (function() {
onRetry: () => start() onRetry: () => start()
}); });
updateStatusUI('idle', 'Error'); updateStatusUI('idle', 'Error');
} finally {
if (startBtn) startBtn.classList.remove('btn-loading');
} }
} }
@@ -445,6 +449,8 @@ const WeatherSat = (function() {
}; };
eventSource.onerror = () => { eventSource.onerror = () => {
// Close the failed connection first to avoid leaking it
stopStream();
setTimeout(() => { setTimeout(() => {
if (isRunning || schedulerEnabled) startStream(); if (isRunning || schedulerEnabled) startStream();
}, 3000); }, 3000);
@@ -887,18 +893,28 @@ const WeatherSat = (function() {
preferCanvas: true, preferCanvas: true,
}); });
// Add fallback tiles immediately so the map is visible instantly
const fallbackTiles = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
subdomains: 'abcd',
maxZoom: 18,
noWrap: false,
crossOrigin: true,
className: 'tile-layer-cyan',
}).addTo(groundMap);
// Upgrade tiles in background via Settings (with timeout fallback)
if (typeof Settings !== 'undefined' && Settings.createTileLayer) { if (typeof Settings !== 'undefined' && Settings.createTileLayer) {
await Settings.init(); try {
Settings.createTileLayer().addTo(groundMap); await Promise.race([
Settings.registerMap(groundMap); Settings.init(),
} else { new Promise((_, reject) => setTimeout(() => reject(new Error('Settings timeout')), 5000))
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { ]);
subdomains: 'abcd', groundMap.removeLayer(fallbackTiles);
maxZoom: 18, Settings.createTileLayer().addTo(groundMap);
noWrap: false, Settings.registerMap(groundMap);
crossOrigin: true, } catch (e) {
className: 'tile-layer-cyan', console.warn('WeatherSat: Settings init failed/timed out, using fallback tiles:', e);
}).addTo(groundMap); }
} }
groundGridLayer = L.layerGroup().addTo(groundMap); groundGridLayer = L.layerGroup().addTo(groundMap);
@@ -1874,10 +1890,24 @@ const WeatherSat = (function() {
} }
} }
/**
* Unconditionally tear down the SSE stream on mode switch so we don't
* leak browser connections. The server-side capture/scheduler keeps
* running independently the stream will reconnect on next init().
*/
function destroy() {
if (countdownInterval) {
clearInterval(countdownInterval);
countdownInterval = null;
}
stopStream();
}
// Public API // Public API
return { return {
init, init,
suspend, suspend,
destroy,
start, start,
stop, stop,
startPass, startPass,
+20 -10
View File
@@ -314,17 +314,27 @@ async function initWebsdrLeaflet(mapEl) {
maxBoundsViscosity: 1.0, maxBoundsViscosity: 1.0,
}); });
// Add fallback tiles immediately so the map is visible instantly
const fallbackTiles = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; OpenStreetMap contributors &copy; CARTO',
subdomains: 'abcd',
maxZoom: 19,
className: 'tile-layer-cyan',
}).addTo(websdrMap);
// Upgrade tiles in background via Settings (with timeout fallback)
if (typeof Settings !== 'undefined' && Settings.createTileLayer) { if (typeof Settings !== 'undefined' && Settings.createTileLayer) {
await Settings.init(); try {
Settings.createTileLayer().addTo(websdrMap); await Promise.race([
Settings.registerMap(websdrMap); Settings.init(),
} else { new Promise((_, reject) => setTimeout(() => reject(new Error('Settings timeout')), 5000))
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { ]);
attribution: '&copy; OpenStreetMap contributors &copy; CARTO', websdrMap.removeLayer(fallbackTiles);
subdomains: 'abcd', Settings.createTileLayer().addTo(websdrMap);
maxZoom: 19, Settings.registerMap(websdrMap);
className: 'tile-layer-cyan', } catch (e) {
}).addTo(websdrMap); console.warn('WebSDR: Settings init failed/timed out, using fallback tiles:', e);
}
} }
mapEl.style.background = '#1a1d29'; mapEl.style.background = '#1a1d29';
+33 -1
View File
@@ -247,6 +247,8 @@ const WiFiMode = (function() {
// ========================================================================== // ==========================================================================
async function checkCapabilities() { async function checkCapabilities() {
const capBtn = document.getElementById('wifiQuickScanBtn');
if (capBtn) capBtn.classList.add('btn-loading');
try { try {
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
let response; let response;
@@ -291,6 +293,8 @@ const WiFiMode = (function() {
} catch (error) { } catch (error) {
console.error('[WiFiMode] Capability check failed:', error); console.error('[WiFiMode] Capability check failed:', error);
showCapabilityError('Failed to check WiFi capabilities'); showCapabilityError('Failed to check WiFi capabilities');
} finally {
if (capBtn) capBtn.classList.remove('btn-loading');
} }
} }
@@ -386,18 +390,40 @@ const WiFiMode = (function() {
if (elements.scanModeDeep) { if (elements.scanModeDeep) {
elements.scanModeDeep.addEventListener('click', () => setScanMode('deep')); elements.scanModeDeep.addEventListener('click', () => setScanMode('deep'));
} }
// Arrow key navigation between tabs
const tabContainer = document.querySelector('.wifi-scan-mode-tabs');
if (tabContainer) {
tabContainer.addEventListener('keydown', (e) => {
const tabs = Array.from(tabContainer.querySelectorAll('[role="tab"]'));
const idx = tabs.indexOf(document.activeElement);
if (idx === -1) return;
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault();
const next = tabs[(idx + 1) % tabs.length];
next.focus();
next.click();
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault();
const prev = tabs[(idx - 1 + tabs.length) % tabs.length];
prev.focus();
prev.click();
}
});
}
listenersBound.scanTabs = true; listenersBound.scanTabs = true;
} }
function setScanMode(mode) { function setScanMode(mode) {
scanMode = mode; scanMode = mode;
// Update tab UI // Update tab UI and ARIA states
if (elements.scanModeQuick) { if (elements.scanModeQuick) {
elements.scanModeQuick.classList.toggle('active', mode === 'quick'); elements.scanModeQuick.classList.toggle('active', mode === 'quick');
elements.scanModeQuick.setAttribute('aria-selected', mode === 'quick' ? 'true' : 'false');
} }
if (elements.scanModeDeep) { if (elements.scanModeDeep) {
elements.scanModeDeep.classList.toggle('active', mode === 'deep'); elements.scanModeDeep.classList.toggle('active', mode === 'deep');
elements.scanModeDeep.setAttribute('aria-selected', mode === 'deep' ? 'true' : 'false');
} }
console.log('[WiFiMode] Scan mode set to:', mode); console.log('[WiFiMode] Scan mode set to:', mode);
@@ -416,6 +442,7 @@ const WiFiMode = (function() {
} }
console.log('[WiFiMode] Starting quick scan...'); console.log('[WiFiMode] Starting quick scan...');
if (elements.quickScanBtn) elements.quickScanBtn.classList.add('btn-loading');
setScanning(true, 'quick'); setScanning(true, 'quick');
try { try {
@@ -496,6 +523,8 @@ const WiFiMode = (function() {
console.error('[WiFiMode] Quick scan error:', error); console.error('[WiFiMode] Quick scan error:', error);
showError(error.message + '. Try using Deep Scan instead.'); showError(error.message + '. Try using Deep Scan instead.');
setScanning(false); setScanning(false);
} finally {
if (elements.quickScanBtn) elements.quickScanBtn.classList.remove('btn-loading');
} }
} }
@@ -508,6 +537,7 @@ const WiFiMode = (function() {
} }
console.log('[WiFiMode] Starting deep scan...'); console.log('[WiFiMode] Starting deep scan...');
if (elements.deepScanBtn) elements.deepScanBtn.classList.add('btn-loading');
setScanning(true, 'deep'); setScanning(true, 'deep');
try { try {
@@ -569,6 +599,8 @@ const WiFiMode = (function() {
console.error('[WiFiMode] Deep scan error:', error); console.error('[WiFiMode] Deep scan error:', error);
showError(error.message); showError(error.message);
setScanning(false); setScanning(false);
} finally {
if (elements.deepScanBtn) elements.deepScanBtn.classList.remove('btn-loading');
} }
} }
+52 -18
View File
@@ -4,32 +4,45 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AIRCRAFT RADAR // INTERCEPT - See the Invisible</title> <title>AIRCRAFT RADAR // INTERCEPT - See the Invisible</title>
<!-- Preconnect hints -->
{% if offline_settings.assets_source != 'local' %}
<link rel="preconnect" href="https://unpkg.com" crossorigin>
{% endif %}
{% if offline_settings.fonts_source != 'local' %}
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
{% endif %}
<link rel="preconnect" href="https://cartodb-basemaps-a.global.ssl.fastly.net" crossorigin>
<!-- Fonts - Conditional CDN/Local loading --> <!-- Fonts - Conditional CDN/Local loading -->
{% if offline_settings.fonts_source == 'local' %} {% if offline_settings.fonts_source == 'local' %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
{% else %} {% else %}
<link href="https://fonts.googleapis.com/css2?family=Roboto+Condensed:wght@300;400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Roboto+Condensed:wght@300;400;500;600;700&display=swap" rel="stylesheet">
{% endif %} {% endif %}
<!-- Leaflet.js - Conditional CDN/Local loading --> <!-- Leaflet CSS -->
{% if offline_settings.assets_source == 'local' %} {% if offline_settings.assets_source == 'local' %}
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.css') }}">
<script src="{{ url_for('static', filename='vendor/leaflet/leaflet.js') }}"></script>
{% else %} {% else %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" /> <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>
{% endif %} {% endif %}
<!-- Core CSS variables --> <!-- Core CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/variables.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/core/variables.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/layout.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/core/layout.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}?v={{ version }}&r=maptheme17"> <link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}?v={{ version }}&r=maptheme17">
<link rel="stylesheet" href="{{ url_for('static', filename='css/help-modal.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/help-modal.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/adsb_dashboard.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/adsb_dashboard.css') }}">
<!-- Deferred scripts -->
<script> <script>
window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }}; window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }};
window.INTERCEPT_ADSB_AUTO_START = {{ adsb_auto_start | tojson }}; window.INTERCEPT_ADSB_AUTO_START = {{ adsb_auto_start | tojson }};
</script> </script>
<script src="{{ url_for('static', filename='js/core/observer-location.js') }}"></script> {% if offline_settings.assets_source == 'local' %}
<script defer src="{{ url_for('static', filename='vendor/leaflet/leaflet.js') }}"></script>
{% else %}
<script defer src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
{% endif %}
<script defer src="{{ url_for('static', filename='js/core/observer-location.js') }}"></script>
</head> </head>
<body> <body>
<div class="radar-bg"></div> <div class="radar-bg"></div>
@@ -1716,6 +1729,12 @@ ACARS: ${r.statistics.acarsMessages} messages`;
// ============================================ // ============================================
// INITIALIZATION // INITIALIZATION
// ============================================ // ============================================
// Clean up SSE connections on page unload to prevent orphaned streams
window.addEventListener('pagehide', function() {
if (eventSource) { eventSource.close(); eventSource = null; }
if (gpsEventSource) { gpsEventSource.close(); gpsEventSource = null; }
});
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
// Initialize observer location input fields from saved location // Initialize observer location input fields from saved location
const obsLatInput = document.getElementById('obsLat'); const obsLatInput = document.getElementById('obsLat');
@@ -2078,6 +2097,10 @@ sudo make install</code>
} }
async function initMap() { async function initMap() {
// Guard against double initialization (e.g. bfcache restore)
const container = document.getElementById('radarMap');
if (!container || container._leaflet_id) return;
radarMap = L.map('radarMap', { radarMap = L.map('radarMap', {
center: [observerLocation.lat, observerLocation.lon], center: [observerLocation.lat, observerLocation.lon],
zoom: 7, zoom: 7,
@@ -2087,19 +2110,14 @@ sudo make install</code>
// Use settings manager for tile layer (allows runtime changes) // Use settings manager for tile layer (allows runtime changes)
window.radarMap = radarMap; window.radarMap = radarMap;
if (typeof Settings !== 'undefined') {
// Wait for settings to load from server before applying tiles // Add fallback tiles immediately so the map is never blank
await Settings.init(); const fallbackTiles = L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
Settings.createTileLayer().addTo(radarMap); attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
Settings.registerMap(radarMap); maxZoom: 19,
} else { subdomains: 'abcd',
L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', { className: 'tile-layer-cyan'
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>', }).addTo(radarMap);
maxZoom: 19,
subdomains: 'abcd',
className: 'tile-layer-cyan'
}).addTo(radarMap);
}
// Draw range rings after map is ready // Draw range rings after map is ready
setTimeout(() => drawRangeRings(), 100); setTimeout(() => drawRangeRings(), 100);
@@ -2113,6 +2131,21 @@ sudo make install</code>
setTimeout(() => { setTimeout(() => {
if (radarMap) radarMap.invalidateSize(); if (radarMap) radarMap.invalidateSize();
}, 500); }, 500);
// Upgrade tiles via Settings in the background (non-blocking)
if (typeof Settings !== 'undefined') {
try {
await Promise.race([
Settings.init(),
new Promise((_, reject) => setTimeout(() => reject(new Error('Settings timeout')), 5000))
]);
radarMap.removeLayer(fallbackTiles);
Settings.createTileLayer().addTo(radarMap);
Settings.registerMap(radarMap);
} catch (e) {
console.warn('Settings init failed/timed out, using fallback tiles:', e);
}
}
} }
// Handle window resize for map (especially important on mobile) // Handle window resize for map (especially important on mobile)
@@ -5917,5 +5950,6 @@ sudo make install</code>
} }
} }
</script> </script>
</body> </body>
</html> </html>
+52 -17
View File
@@ -4,31 +4,44 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VESSEL RADAR // INTERCEPT - See the Invisible</title> <title>VESSEL RADAR // INTERCEPT - See the Invisible</title>
<!-- Preconnect hints -->
{% if offline_settings.assets_source != 'local' %}
<link rel="preconnect" href="https://unpkg.com" crossorigin>
{% endif %}
{% if offline_settings.fonts_source != 'local' %}
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
{% endif %}
<link rel="preconnect" href="https://cartodb-basemaps-a.global.ssl.fastly.net" crossorigin>
<!-- Fonts - Conditional CDN/Local loading --> <!-- Fonts - Conditional CDN/Local loading -->
{% if offline_settings.fonts_source == 'local' %} {% if offline_settings.fonts_source == 'local' %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
{% else %} {% else %}
<link href="https://fonts.googleapis.com/css2?family=Roboto+Condensed:wght@300;400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Roboto+Condensed:wght@300;400;500;600;700&display=swap" rel="stylesheet">
{% endif %} {% endif %}
<!-- Leaflet.js - Conditional CDN/Local loading --> <!-- Leaflet CSS -->
{% if offline_settings.assets_source == 'local' %} {% if offline_settings.assets_source == 'local' %}
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.css') }}">
<script src="{{ url_for('static', filename='vendor/leaflet/leaflet.js') }}"></script>
{% else %} {% else %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" /> <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>
{% endif %} {% endif %}
<!-- Core CSS variables --> <!-- Core CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/variables.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/core/variables.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/ais_dashboard.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/ais_dashboard.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/layout.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/core/layout.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}?v={{ version }}&r=maptheme17"> <link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}?v={{ version }}&r=maptheme17">
<link rel="stylesheet" href="{{ url_for('static', filename='css/help-modal.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/help-modal.css') }}">
<!-- Deferred scripts -->
<script> <script>
window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }}; window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }};
</script> </script>
<script src="{{ url_for('static', filename='js/core/observer-location.js') }}"></script> {% if offline_settings.assets_source == 'local' %}
<script defer src="{{ url_for('static', filename='vendor/leaflet/leaflet.js') }}"></script>
{% else %}
<script defer src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
{% endif %}
<script defer src="{{ url_for('static', filename='js/core/observer-location.js') }}"></script>
</head> </head>
<body> <body>
<!-- Radar background effects --> <!-- Radar background effects -->
@@ -393,6 +406,10 @@
// Initialize map // Initialize map
async function initMap() { async function initMap() {
// Guard against double initialization (e.g. bfcache restore)
const container = document.getElementById('vesselMap');
if (!container || container._leaflet_id) return;
if (observerLocation) { if (observerLocation) {
document.getElementById('obsLat').value = observerLocation.lat; document.getElementById('obsLat').value = observerLocation.lat;
document.getElementById('obsLon').value = observerLocation.lon; document.getElementById('obsLon').value = observerLocation.lon;
@@ -406,18 +423,29 @@
// Use settings manager for tile layer (allows runtime changes) // Use settings manager for tile layer (allows runtime changes)
window.vesselMap = vesselMap; window.vesselMap = vesselMap;
// Add fallback tile layer immediately so the map is never blank
const fallbackTiles = L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
maxZoom: 19,
subdomains: 'abcd',
className: 'tile-layer-cyan'
}).addTo(vesselMap);
// Then try to upgrade tiles via Settings (non-blocking)
if (typeof Settings !== 'undefined') { if (typeof Settings !== 'undefined') {
// Wait for settings to load from server before applying tiles try {
await Settings.init(); await Promise.race([
Settings.createTileLayer().addTo(vesselMap); Settings.init(),
Settings.registerMap(vesselMap); new Promise((_, reject) => setTimeout(() => reject(new Error('Settings timeout')), 5000))
} else { ]);
L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', { vesselMap.removeLayer(fallbackTiles);
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>', Settings.createTileLayer().addTo(vesselMap);
maxZoom: 19, Settings.registerMap(vesselMap);
subdomains: 'abcd', } catch (e) {
className: 'tile-layer-cyan' console.warn('Settings init failed/timed out, using fallback tiles:', e);
}).addTo(vesselMap); // fallback tiles already added above
}
} }
// Add observer marker // Add observer marker
@@ -547,7 +575,7 @@
} }
} }
function startTracking() { async function startTracking() {
const device = document.getElementById('aisDeviceSelect').value; const device = document.getElementById('aisDeviceSelect').value;
const gain = document.getElementById('aisGain').value; const gain = document.getElementById('aisGain').value;
@@ -1502,6 +1530,13 @@
// Auto-connect to gpsd if available // Auto-connect to gpsd if available
autoConnectGps(); autoConnectGps();
}); });
// Clean up SSE connections on page unload to prevent orphaned streams
window.addEventListener('pagehide', function() {
if (eventSource) { eventSource.close(); eventSource = null; }
if (dscEventSource) { dscEventSource.close(); dscEventSource = null; }
if (gpsEventSource) { gpsEventSource.close(); gpsEventSource = null; }
});
</script> </script>
<!-- Agent styles --> <!-- Agent styles -->
+202 -120
View File
@@ -11,6 +11,16 @@
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png"> <link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
<!-- Preconnect hints for CDN domains -->
{% if offline_settings.assets_source != 'local' %}
<link rel="preconnect" href="https://unpkg.com" crossorigin>
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
{% endif %}
{% if offline_settings.fonts_source != 'local' %}
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
{% endif %}
<link rel="preconnect" href="https://cartodb-basemaps-a.global.ssl.fastly.net" crossorigin>
<!-- Disclaimer gate - must accept before seeing welcome page --> <!-- Disclaimer gate - must accept before seeing welcome page -->
<script> <script>
// Check BEFORE page renders - if disclaimer not accepted, hide welcome page // Check BEFORE page renders - if disclaimer not accepted, hide welcome page
@@ -29,31 +39,19 @@
window.INTERCEPT_DEFAULT_LAT = {{ default_latitude | tojson }}; window.INTERCEPT_DEFAULT_LAT = {{ default_latitude | tojson }};
window.INTERCEPT_DEFAULT_LON = {{ default_longitude | tojson }}; window.INTERCEPT_DEFAULT_LON = {{ default_longitude | tojson }};
</script> </script>
<script src="{{ url_for('static', filename='js/core/observer-location.js') }}"></script>
<!-- Fonts - Conditional CDN/Local loading --> <!-- Fonts - Conditional CDN/Local loading -->
{% if offline_settings.fonts_source == 'local' %} {% if offline_settings.fonts_source == 'local' %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
{% else %} {% else %}
<link href="https://fonts.googleapis.com/css2?family=Roboto+Condensed:wght@300;400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Roboto+Condensed:wght@300;400;500;600;700&display=swap" rel="stylesheet">
{% endif %} {% endif %}
<!-- Leaflet.js for APRS map - Conditional CDN/Local loading --> <!-- Leaflet CSS -->
{% if offline_settings.assets_source == 'local' %} {% if offline_settings.assets_source == 'local' %}
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.css') }}">
<script src="{{ url_for('static', filename='vendor/leaflet/leaflet.js') }}"></script>
<script src="{{ url_for('static', filename='vendor/leaflet-heat/leaflet-heat.js') }}"></script>
{% else %} {% else %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="" /> <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
<script src="https://cdn.jsdelivr.net/npm/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
{% endif %} {% endif %}
<!-- Chart.js for signal strength graphs - Conditional CDN/Local loading --> <!-- Core CSS -->
{% if offline_settings.assets_source == 'local' %}
<script src="{{ url_for('static', filename='vendor/chartjs/chart.umd.min.js') }}"></script>
{% else %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
{% endif %}
<!-- Chart.js date adapter for time-scale axes -->
<script src="{{ url_for('static', filename='vendor/chartjs/chartjs-adapter-date-fns.bundle.min.js') }}"></script>
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/variables.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/core/variables.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/layout.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/core/layout.css') }}">
@@ -70,6 +68,21 @@
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/signal-waveform.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/components/signal-waveform.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/components.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/core/components.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/waterfall.css') }}?v={{ version }}&r=wfdeck19"> <link rel="stylesheet" href="{{ url_for('static', filename='css/modes/waterfall.css') }}?v={{ version }}&r=wfdeck19">
<!-- Deferred scripts - Leaflet, Chart.js, observer-location -->
<script defer src="{{ url_for('static', filename='js/core/observer-location.js') }}"></script>
{% if offline_settings.assets_source == 'local' %}
<script defer src="{{ url_for('static', filename='vendor/leaflet/leaflet.js') }}"></script>
<script defer src="{{ url_for('static', filename='vendor/leaflet-heat/leaflet-heat.js') }}"></script>
{% else %}
<script defer src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
<script defer src="https://cdn.jsdelivr.net/npm/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
{% endif %}
{% if offline_settings.assets_source == 'local' %}
<script defer src="{{ url_for('static', filename='vendor/chartjs/chart.umd.min.js') }}"></script>
{% else %}
<script defer src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
{% endif %}
<script defer src="{{ url_for('static', filename='vendor/chartjs/chartjs-adapter-date-fns.bundle.min.js') }}"></script>
<script> <script>
window.INTERCEPT_MODE_STYLE_MAP = { window.INTERCEPT_MODE_STYLE_MAP = {
aprs: "{{ url_for('static', filename='css/modes/aprs.css') }}", aprs: "{{ url_for('static', filename='css/modes/aprs.css') }}",
@@ -172,6 +185,61 @@
} }
})(); })();
</script> </script>
<script>
window.INTERCEPT_MODE_SCRIPT_MAP = {
bluetooth: "{{ url_for('static', filename='js/modes/bluetooth.js') }}?v={{ version }}&r=btlocate2",
wifi: "{{ url_for('static', filename='js/modes/wifi.js') }}",
spystations: "{{ url_for('static', filename='js/modes/spy-stations.js') }}",
meshtastic: "{{ url_for('static', filename='js/modes/meshtastic.js') }}",
sstv: "{{ url_for('static', filename='js/modes/sstv.js') }}",
weathersat: "{{ url_for('static', filename='js/modes/weather-satellite.js') }}",
sstv_general: "{{ url_for('static', filename='js/modes/sstv-general.js') }}",
gps: "{{ url_for('static', filename='js/modes/gps.js') }}",
websdr: "{{ url_for('static', filename='js/modes/websdr.js') }}",
subghz: "{{ url_for('static', filename='js/modes/subghz.js') }}?v={{ version }}&r=subghz_layout9",
bt_locate: "{{ url_for('static', filename='js/modes/bt_locate.js') }}?v={{ version }}&r=btlocate4",
wifi_locate: "{{ url_for('static', filename='js/modes/wifi_locate.js') }}?v={{ version }}&r=wflocate1",
wefax: "{{ url_for('static', filename='js/modes/wefax.js') }}",
morse: "{{ url_for('static', filename='js/modes/morse.js') }}?v={{ version }}&r=morse_iq12",
ook: "{{ url_for('static', filename='js/modes/ook.js') }}?v={{ version }}&r=ook2",
spaceweather: "{{ url_for('static', filename='js/modes/space-weather.js') }}",
system: "{{ url_for('static', filename='js/modes/system.js') }}",
meteor: "{{ url_for('static', filename='js/modes/meteor.js') }}",
waterfall: "{{ url_for('static', filename='js/modes/waterfall.js') }}?v={{ version }}&r=wfdeck21"
};
window.INTERCEPT_MODE_SCRIPT_LOADED = {};
window.INTERCEPT_MODE_SCRIPT_PROMISES = {};
window.ensureModeScript = function(mode) {
var src = window.INTERCEPT_MODE_SCRIPT_MAP ? window.INTERCEPT_MODE_SCRIPT_MAP[mode] : null;
if (!src) return Promise.resolve();
if (window.INTERCEPT_MODE_SCRIPT_LOADED[src]) return Promise.resolve();
if (window.INTERCEPT_MODE_SCRIPT_PROMISES[src]) return window.INTERCEPT_MODE_SCRIPT_PROMISES[src];
var promise = new Promise(function(resolve, reject) {
var script = document.createElement('script');
script.src = src;
script.dataset.modeScript = mode;
script.onload = function() {
window.INTERCEPT_MODE_SCRIPT_LOADED[src] = true;
delete window.INTERCEPT_MODE_SCRIPT_PROMISES[src];
resolve();
};
script.onerror = function() {
delete window.INTERCEPT_MODE_SCRIPT_PROMISES[src];
reject(new Error('failed to load mode script: ' + mode));
};
(document.body || document.head).appendChild(script);
});
window.INTERCEPT_MODE_SCRIPT_PROMISES[src] = promise;
return promise;
};
// Preload script for deep-linked mode
(function preloadQueryModeScript() {
var queryMode = new URLSearchParams(window.location.search).get('mode');
var mode = queryMode === 'listening' ? 'waterfall' : queryMode;
if (!mode) return;
window.ensureModeScript(mode).catch(function() {});
})();
</script>
</head> </head>
<body data-mode="pager"> <body data-mode="pager">
@@ -203,16 +271,10 @@
</svg> </svg>
</div> </div>
<div class="welcome-container"> <div class="welcome-container">
<button type="button" class="welcome-settings-btn" onclick="showSettings()" title="Settings" aria-label="Open settings">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</button>
<!-- Header Section --> <!-- Header Section -->
<div class="welcome-header"> <div class="welcome-header">
<div class="welcome-logo"> <div class="welcome-logo">
<svg width="80" height="80" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="60" height="60" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="3" fill="none" <path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="3" fill="none"
stroke-linecap="round" opacity="0.5" class="signal-wave signal-wave-1" /> stroke-linecap="round" opacity="0.5" class="signal-wave signal-wave-1" />
<path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="2.5" fill="none" <path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="2.5" fill="none"
@@ -231,11 +293,15 @@
<rect x="38" y="76" width="24" height="4" rx="1" fill="#00d4ff" /> <rect x="38" y="76" width="24" height="4" rx="1" fill="#00d4ff" />
</svg> </svg>
</div> </div>
<div class="welcome-title-block"> <h1 class="welcome-title">iNTERCEPT</h1>
<h1 class="welcome-title">iNTERCEPT</h1> <p class="welcome-tagline">// See the Invisible</p>
<p class="welcome-tagline">// See the Invisible</p> <span class="welcome-version">v{{ version }}</span>
<span class="welcome-version">v{{ version }}</span> <button type="button" class="welcome-settings-btn" onclick="showSettings()" title="Settings" aria-label="Open settings">
</div> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</button>
</div> </div>
<!-- Main Content Grid --> <!-- Main Content Grid -->
@@ -407,6 +473,7 @@
<!-- Footer --> <!-- Footer -->
<div class="welcome-footer"> <div class="welcome-footer">
<p>Signal Intelligence & Counter Surveillance Platform</p> <p>Signal Intelligence & Counter Surveillance Platform</p>
<a href="https://www.smittix.net" target="_blank" rel="noopener noreferrer" class="welcome-footer-credit">By Smittix</a>
</div> </div>
</div> </div>
<div class="welcome-scanline"></div> <div class="welcome-scanline"></div>
@@ -490,42 +557,44 @@
</div> </div>
</div> </div>
<header> <header>
<!-- Hamburger Menu Button (Mobile) --> <div class="header-left">
<button class="hamburger-btn" id="hamburgerBtn" aria-label="Toggle navigation menu"> <!-- Hamburger Menu Button (Mobile) -->
<span></span> <button class="hamburger-btn" id="hamburgerBtn" aria-label="Toggle navigation menu">
<span></span> <span></span>
<span></span> <span></span>
</button> <span></span>
<a href="https://smittix.github.io/intercept" target="_blank" rel="noopener noreferrer" class="logo"> </button>
<svg width="50" height="50" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg"> <a href="https://smittix.github.io/intercept" target="_blank" rel="noopener noreferrer" class="logo">
<!-- Signal brackets - left side --> <svg width="50" height="50" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" <!-- Signal brackets - left side -->
opacity="0.5" /> <path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round"
<path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.5" />
opacity="0.7" /> <path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round"
<path d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round" /> opacity="0.7" />
<!-- Signal brackets - right side --> <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" <!-- Signal brackets - right side -->
opacity="0.5" /> <path d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round"
<path d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.5" />
opacity="0.7" /> <path d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round"
<path d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round" /> opacity="0.7" />
<!-- The 'i' letter --> <path d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round" />
<!-- dot of i --> <!-- The 'i' letter -->
<circle cx="50" cy="22" r="6" fill="#00ff88" /> <!-- dot of i -->
<!-- stem of i with styled terminals --> <circle cx="50" cy="22" r="6" fill="#00ff88" />
<rect x="44" y="35" width="12" height="45" rx="2" fill="#00d4ff" /> <!-- stem of i with styled terminals -->
<!-- top terminal bar --> <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" /> <!-- top terminal bar -->
<!-- bottom terminal bar --> <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" /> <!-- bottom terminal bar -->
</svg> <rect x="38" y="76" width="24" height="4" rx="1" fill="#00d4ff" />
</a> </svg>
<h1>iNTERCEPT <span class="tagline">// See the Invisible</span> <span class="version-badge">v{{ version </a>
}}</span></h1> <h1>iNTERCEPT <span class="tagline">// See the Invisible</span></h1>
<p class="subtitle">Signal Intelligence & Counter Surveillance Platform <span class="active-mode-indicator" </div>
id="activeModeIndicator"><span class="pulse-dot"></span>PAGER</span></p> <div class="header-right">
<span class="active-mode-indicator" id="activeModeIndicator"><span class="pulse-dot"></span>PAGER</span>
<span class="version-badge">v{{ version }}</span>
</div>
</header> </header>
<div id="runStateStrip" class="run-state-strip" aria-live="polite"> <div id="runStateStrip" class="run-state-strip" aria-live="polite">
@@ -894,7 +963,7 @@
<span class="wifi-detail-essid" id="wifiDetailEssid">Network Name</span> <span class="wifi-detail-essid" id="wifiDetailEssid">Network Name</span>
<span class="wifi-detail-bssid" id="wifiDetailBssid">00:00:00:00:00:00</span> <span class="wifi-detail-bssid" id="wifiDetailBssid">00:00:00:00:00:00</span>
</div> </div>
<button class="wfl-locate-btn" onclick="WiFiLocate.handoff({bssid: document.getElementById('wifiDetailBssid')?.textContent, ssid: document.getElementById('wifiDetailEssid')?.textContent})" title="Locate this AP"> <button class="wfl-locate-btn" onclick="(function(){ var p={bssid: document.getElementById('wifiDetailBssid')?.textContent, ssid: document.getElementById('wifiDetailEssid')?.textContent}; if(typeof WiFiLocate!=='undefined'){WiFiLocate.handoff(p);return;} if(typeof switchMode==='function'){switchMode('wifi_locate').then(function(){if(typeof WiFiLocate!=='undefined')WiFiLocate.handoff(p);});} })()" title="Locate this AP">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>
Locate Locate
</button> </button>
@@ -1346,7 +1415,7 @@
</div> </div>
<!-- Satellite Dashboard (Embedded) --> <!-- Satellite Dashboard (Embedded) -->
<div id="satelliteVisuals" class="satellite-dashboard-embed"> <div id="satelliteVisuals" class="satellite-dashboard-embed" style="display: none;">
<iframe id="satelliteDashboardFrame" src="/satellite/dashboard?embedded=true" frameborder="0" <iframe id="satelliteDashboardFrame" src="/satellite/dashboard?embedded=true" frameborder="0"
style="width: 100%; height: 100%; min-height: 700px; border: none; border-radius: 8px;" style="width: 100%; height: 100%; min-height: 700px; border: none; border-radius: 8px;"
allowfullscreen> allowfullscreen>
@@ -3420,30 +3489,12 @@
<script src="{{ url_for('static', filename='js/components/proximity-radar.js') }}"></script> <script src="{{ url_for('static', filename='js/components/proximity-radar.js') }}"></script>
<script src="{{ url_for('static', filename='js/components/timeline-heatmap.js') }}"></script> <script src="{{ url_for('static', filename='js/components/timeline-heatmap.js') }}"></script>
<script src="{{ url_for('static', filename='js/components/signal-waveform.js') }}"></script> <script src="{{ url_for('static', filename='js/components/signal-waveform.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/bluetooth.js') }}?v={{ version }}&r=btlocate1"></script> <!-- Mode scripts are lazy-loaded via ensureModeScript() in switchMode() -->
<!-- WiFi v2 components --> <!-- WiFi v2 components (eagerly loaded — shared component) -->
<script src="{{ url_for('static', filename='js/components/channel-chart.js') }}"></script> <script src="{{ url_for('static', filename='js/components/channel-chart.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/wifi.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/spy-stations.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/meshtastic.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/sstv.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/weather-satellite.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/sstv-general.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/gps.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/websdr.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/subghz.js') }}?v={{ version }}&r=subghz_layout9"></script>
<script src="{{ url_for('static', filename='js/modes/bt_locate.js') }}?v={{ version }}&r=btlocate4"></script>
<script src="{{ url_for('static', filename='js/modes/wifi_locate.js') }}?v={{ version }}&r=wflocate1"></script>
<script src="{{ url_for('static', filename='js/modes/wefax.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/morse.js') }}?v={{ version }}&r=morse_iq12"></script>
<script src="{{ url_for('static', filename='js/modes/ook.js') }}?v={{ version }}&r=ook2"></script>
<script src="{{ url_for('static', filename='js/modes/space-weather.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/system.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/meteor.js') }}"></script>
<script src="{{ url_for('static', filename='js/core/voice-alerts.js') }}?v={{ version }}&r=voicefix2"></script> <script src="{{ url_for('static', filename='js/core/voice-alerts.js') }}?v={{ version }}&r=voicefix2"></script>
<script src="{{ url_for('static', filename='js/core/keyboard-shortcuts.js') }}"></script> <script src="{{ url_for('static', filename='js/core/keyboard-shortcuts.js') }}"></script>
<script src="{{ url_for('static', filename='js/core/cheat-sheets.js') }}"></script> <script src="{{ url_for('static', filename='js/core/cheat-sheets.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/waterfall.js') }}?v={{ version }}&r=wfdeck21"></script>
<script> <script>
// ============================================ // ============================================
@@ -4131,10 +4182,13 @@
// Used by both switchMode() and dashboard navigation cleanup. // Used by both switchMode() and dashboard navigation cleanup.
function getModuleDestroyFn(mode) { function getModuleDestroyFn(mode) {
const moduleDestroyMap = { const moduleDestroyMap = {
pager: () => { if (eventSource) { eventSource.close(); eventSource = null; } },
sensor: () => { if (eventSource) { eventSource.close(); eventSource = null; } },
rtlamr: () => { if (eventSource) { eventSource.close(); eventSource = null; } },
subghz: () => typeof SubGhz !== 'undefined' && SubGhz.destroy(), subghz: () => typeof SubGhz !== 'undefined' && SubGhz.destroy(),
morse: () => typeof MorseMode !== 'undefined' && MorseMode.destroy?.(), morse: () => typeof MorseMode !== 'undefined' && MorseMode.destroy?.(),
spaceweather: () => typeof SpaceWeather !== 'undefined' && SpaceWeather.destroy?.(), spaceweather: () => typeof SpaceWeather !== 'undefined' && SpaceWeather.destroy?.(),
weathersat: () => typeof WeatherSat !== 'undefined' && WeatherSat.suspend?.(), weathersat: () => typeof WeatherSat !== 'undefined' && WeatherSat.destroy?.(),
wefax: () => typeof WeFax !== 'undefined' && WeFax.destroy?.(), wefax: () => typeof WeFax !== 'undefined' && WeFax.destroy?.(),
system: () => typeof SystemHealth !== 'undefined' && SystemHealth.destroy?.(), system: () => typeof SystemHealth !== 'undefined' && SystemHealth.destroy?.(),
waterfall: () => typeof Waterfall !== 'undefined' && Waterfall.destroy?.(), waterfall: () => typeof Waterfall !== 'undefined' && Waterfall.destroy?.(),
@@ -4152,6 +4206,8 @@
acars: () => { if (acarsMainEventSource) { acarsMainEventSource.close(); acarsMainEventSource = null; } }, acars: () => { if (acarsMainEventSource) { acarsMainEventSource.close(); acarsMainEventSource = null; } },
vdl2: () => { if (vdl2MainEventSource) { vdl2MainEventSource.close(); vdl2MainEventSource = null; } }, vdl2: () => { if (vdl2MainEventSource) { vdl2MainEventSource.close(); vdl2MainEventSource = null; } },
radiosonde: () => { if (radiosondeEventSource) { radiosondeEventSource.close(); radiosondeEventSource = null; } }, radiosonde: () => { if (radiosondeEventSource) { radiosondeEventSource.close(); radiosondeEventSource = null; } },
aprs: () => { if (aprsEventSource) { aprsEventSource.close(); aprsEventSource = null; } },
tscm: () => { if (tscmEventSource) { tscmEventSource.close(); tscmEventSource = null; } },
meteor: () => typeof MeteorScatter !== 'undefined' && MeteorScatter.destroy?.(), meteor: () => typeof MeteorScatter !== 'undefined' && MeteorScatter.destroy?.(),
ook: () => typeof OokMode !== 'undefined' && OokMode.destroy?.(), ook: () => typeof OokMode !== 'undefined' && OokMode.destroy?.(),
}; };
@@ -4338,6 +4394,11 @@
console.warn(`[ModeSwitch] style load failed for ${mode}: ${err?.message || err}`); console.warn(`[ModeSwitch] style load failed for ${mode}: ${err?.message || err}`);
}) })
: Promise.resolve(); : Promise.resolve();
const scriptReadyPromise = (typeof window.ensureModeScript === 'function')
? Promise.resolve(window.ensureModeScript(mode)).catch((err) => {
console.warn(`[ModeSwitch] script load failed for ${mode}: ${err?.message || err}`);
})
: Promise.resolve();
// Only stop local scans if in local mode (not agent mode) // Only stop local scans if in local mode (not agent mode)
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
const stopPhaseStartMs = performance.now(); const stopPhaseStartMs = performance.now();
@@ -4391,6 +4452,7 @@
} }
const stopPhaseMs = Math.round(performance.now() - stopPhaseStartMs); const stopPhaseMs = Math.round(performance.now() - stopPhaseStartMs);
await styleReadyPromise; await styleReadyPromise;
await scriptReadyPromise;
// Generic module cleanup — destroy previous mode's timers, SSE, etc. // Generic module cleanup — destroy previous mode's timers, SSE, etc.
if (previousMode && previousMode !== mode) { if (previousMode && previousMode !== mode) {
@@ -4471,7 +4533,8 @@
document.getElementById('headerWifiStats')?.classList.toggle('active', mode === 'wifi'); document.getElementById('headerWifiStats')?.classList.toggle('active', mode === 'wifi');
// Show/hide dashboard buttons in nav bar // Show/hide dashboard buttons in nav bar
document.getElementById('satelliteDashboardBtn')?.classList.toggle('active', mode === 'satellite'); const satelliteDashboardBtn = document.getElementById('satelliteDashboardBtn');
if (satelliteDashboardBtn) satelliteDashboardBtn.style.display = mode === 'satellite' ? 'inline-flex' : 'none';
// Update active mode indicator // Update active mode indicator
const modeMeta = modeCatalog[mode] || {}; const modeMeta = modeCatalog[mode] || {};
@@ -4499,7 +4562,7 @@
const systemVisuals = document.getElementById('systemVisuals'); const systemVisuals = document.getElementById('systemVisuals');
if (wifiLayoutContainer) wifiLayoutContainer.classList.toggle('active', mode === 'wifi'); if (wifiLayoutContainer) wifiLayoutContainer.classList.toggle('active', mode === 'wifi');
if (btLayoutContainer) btLayoutContainer.classList.toggle('active', mode === 'bluetooth'); if (btLayoutContainer) btLayoutContainer.classList.toggle('active', mode === 'bluetooth');
if (satelliteVisuals) satelliteVisuals.classList.toggle('active', mode === 'satellite'); if (satelliteVisuals) satelliteVisuals.style.display = mode === 'satellite' ? 'block' : 'none';
const satFrame = document.getElementById('satelliteDashboardFrame'); const satFrame = document.getElementById('satelliteDashboardFrame');
if (satFrame && satFrame.contentWindow) { if (satFrame && satFrame.contentWindow) {
satFrame.contentWindow.postMessage({type: 'satellite-visibility', visible: mode === 'satellite'}, '*'); satFrame.contentWindow.postMessage({type: 'satellite-visibility', visible: mode === 'satellite'}, '*');
@@ -4523,6 +4586,11 @@
if (meteorVisuals) meteorVisuals.style.display = mode === 'meteor' ? 'flex' : 'none'; if (meteorVisuals) meteorVisuals.style.display = mode === 'meteor' ? 'flex' : 'none';
if (systemVisuals) systemVisuals.style.display = mode === 'system' ? 'flex' : 'none'; if (systemVisuals) systemVisuals.style.display = mode === 'system' ? 'flex' : 'none';
// Hide the signal feed output for modes that have their own visuals
const outputEl = document.getElementById('output');
const modesWithVisuals = ['satellite', 'sstv', 'weathersat', 'sstv_general', 'wefax', 'aprs', 'wifi', 'bluetooth', 'tscm', 'spystations', 'meshtastic', 'websdr', 'subghz', 'spaceweather', 'bt_locate', 'wifi_locate', 'waterfall', 'morse', 'meteor', 'system', 'ook', 'radiosonde', 'gps'];
if (outputEl) outputEl.style.display = modesWithVisuals.includes(mode) ? 'none' : 'block';
// Prevent Leaflet heatmap redraws on hidden BT Locate map containers. // Prevent Leaflet heatmap redraws on hidden BT Locate map containers.
if (typeof BtLocate !== 'undefined' && BtLocate.setActiveMode) { if (typeof BtLocate !== 'undefined' && BtLocate.setActiveMode) {
BtLocate.setActiveMode(mode === 'bt_locate'); BtLocate.setActiveMode(mode === 'bt_locate');
@@ -4579,9 +4647,9 @@
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]'); const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
const reconPanel = document.getElementById('reconPanel'); const reconPanel = document.getElementById('reconPanel');
const hideRecon = ['satellite', 'sstv', 'weathersat', 'sstv_general', 'wefax', 'gps', 'aprs', 'tscm', 'spystations', 'meshtastic', 'websdr', 'subghz', 'spaceweather', 'waterfall', 'meteor', 'system'].includes(mode); const hideRecon = ['satellite', 'sstv', 'weathersat', 'sstv_general', 'wefax', 'gps', 'aprs', 'tscm', 'spystations', 'meshtastic', 'websdr', 'subghz', 'spaceweather', 'waterfall', 'meteor', 'system'].includes(mode);
if (reconPanel) reconPanel.classList.toggle('active', !hideRecon && reconEnabled); if (reconPanel) reconPanel.style.display = (!hideRecon && reconEnabled) ? 'block' : 'none';
if (reconBtn) reconBtn.classList.toggle('hidden', hideRecon); if (reconBtn) reconBtn.style.display = hideRecon ? 'none' : 'inline-block';
if (intelBtn) intelBtn.classList.toggle('hidden', hideRecon); if (intelBtn) intelBtn.style.display = hideRecon ? 'none' : 'inline-block';
// Show agent selector for modes that support remote agents // Show agent selector for modes that support remote agents
const agentSection = document.getElementById('agentSection'); const agentSection = document.getElementById('agentSection');
@@ -4630,11 +4698,9 @@
document.getElementById('toolStatusSensor')?.classList.toggle('active', mode === 'sensor'); document.getElementById('toolStatusSensor')?.classList.toggle('active', mode === 'sensor');
// Hide output console for modes with their own visualizations // Hide output console for modes with their own visualizations
const fullVisualModes = ['satellite', 'sstv', 'weathersat', 'sstv_general', 'wefax', 'aprs', 'wifi', 'bluetooth', 'tscm', 'spystations', 'meshtastic', 'websdr', 'subghz', 'spaceweather', 'bt_locate', 'waterfall', 'morse', 'meteor', 'system', 'ook'];
const hideConsole = fullVisualModes.includes(mode);
document.getElementById('output')?.classList.toggle('active', !hideConsole);
const hideStatusBar = ['satellite', 'websdr', 'subghz', 'spaceweather', 'waterfall', 'morse', 'meteor', 'system'].includes(mode); const hideStatusBar = ['satellite', 'websdr', 'subghz', 'spaceweather', 'waterfall', 'morse', 'meteor', 'system'].includes(mode);
document.querySelector('.status-bar')?.classList.toggle('active', !hideStatusBar); const statusBar = document.querySelector('.status-bar');
if (statusBar) statusBar.style.display = hideStatusBar ? 'none' : 'flex';
// Restore sidebar when leaving Meshtastic mode (user may have collapsed it) // Restore sidebar when leaving Meshtastic mode (user may have collapsed it)
if (mode !== 'meshtastic') { if (mode !== 'meshtastic') {
@@ -7263,7 +7329,7 @@
function toggleRecon() { function toggleRecon() {
reconEnabled = !reconEnabled; reconEnabled = !reconEnabled;
localStorage.setItem('reconEnabled', reconEnabled); localStorage.setItem('reconEnabled', reconEnabled);
document.getElementById('reconPanel')?.classList.toggle('active', reconEnabled); document.getElementById('reconPanel').style.display = reconEnabled ? 'block' : 'none';
document.getElementById('reconBtn')?.classList.toggle('active', reconEnabled); document.getElementById('reconBtn')?.classList.toggle('active', reconEnabled);
// Populate recon display if enabled and we have data // Populate recon display if enabled and we have data
@@ -7275,7 +7341,7 @@
} }
// Initialize recon state // Initialize recon state
document.getElementById('reconPanel')?.classList.toggle('active', reconEnabled); document.getElementById('reconPanel').style.display = reconEnabled ? 'block' : 'none';
if (reconEnabled) { if (reconEnabled) {
document.getElementById('reconBtn')?.classList.add('active'); document.getElementById('reconBtn')?.classList.add('active');
} }
@@ -9915,19 +9981,27 @@
aprsMap = L.map('aprsMap').setView([initialLat, initialLon], initialZoom); aprsMap = L.map('aprsMap').setView([initialLat, initialLon], initialZoom);
window.aprsMap = aprsMap; window.aprsMap = aprsMap;
// Use settings manager for tile layer (allows runtime changes) // Add fallback tiles immediately so the map is visible instantly
const fallbackTiles = L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
maxZoom: 19,
subdomains: 'abcd',
className: 'tile-layer-cyan'
}).addTo(aprsMap);
// Upgrade tiles in background via Settings (with timeout fallback)
if (typeof Settings !== 'undefined') { if (typeof Settings !== 'undefined') {
// Wait for settings to load from server before applying tiles try {
await Settings.init(); await Promise.race([
Settings.createTileLayer().addTo(aprsMap); Settings.init(),
Settings.registerMap(aprsMap); new Promise((_, reject) => setTimeout(() => reject(new Error('Settings timeout')), 5000))
} else { ]);
L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', { aprsMap.removeLayer(fallbackTiles);
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>', Settings.createTileLayer().addTo(aprsMap);
maxZoom: 19, Settings.registerMap(aprsMap);
subdomains: 'abcd', } catch (e) {
className: 'tile-layer-cyan' console.warn('APRS: Settings init failed/timed out, using fallback tiles:', e);
}).addTo(aprsMap); }
} }
// Add user marker if GPS position is already available // Add user marker if GPS position is already available
@@ -10995,19 +11069,27 @@
}); });
window.groundTrackMap = groundTrackMap; window.groundTrackMap = groundTrackMap;
// Use settings manager for tile layer (allows runtime changes) // Add fallback tiles immediately so the map is visible instantly
const fallbackTiles = L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
maxZoom: 19,
subdomains: 'abcd',
className: 'tile-layer-cyan'
}).addTo(groundTrackMap);
// Upgrade tiles in background via Settings (with timeout fallback)
if (typeof Settings !== 'undefined') { if (typeof Settings !== 'undefined') {
// Wait for settings to load from server before applying tiles try {
await Settings.init(); await Promise.race([
Settings.createTileLayer().addTo(groundTrackMap); Settings.init(),
Settings.registerMap(groundTrackMap); new Promise((_, reject) => setTimeout(() => reject(new Error('Settings timeout')), 5000))
} else { ]);
L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', { groundTrackMap.removeLayer(fallbackTiles);
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>', Settings.createTileLayer().addTo(groundTrackMap);
maxZoom: 19, Settings.registerMap(groundTrackMap);
subdomains: 'abcd', } catch (e) {
className: 'tile-layer-cyan' console.warn('Ground track: Settings init failed/timed out, using fallback tiles:', e);
}).addTo(groundTrackMap); }
} }
// Add observer marker // Add observer marker
+1
View File
@@ -80,6 +80,7 @@
</div> </div>
<form method="POST"> <form method="POST">
{% if csrf_token is defined %}<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />{% endif %}
<input type="text" name="username" placeholder="OPERATOR ID" class="form-input" required autofocus autocomplete="off" /> <input type="text" name="username" placeholder="OPERATOR ID" class="form-input" required autofocus autocomplete="off" />
<input type="password" name="password" placeholder="ENCRYPTION KEY" class="form-input" required /> <input type="password" name="password" placeholder="ENCRYPTION KEY" class="form-input" required />
+4 -4
View File
@@ -3,11 +3,11 @@
<!-- Scan Mode Tabs --> <!-- Scan Mode Tabs -->
<div class="section"> <div class="section">
<h3>Signal Source</h3> <h3>Signal Source</h3>
<div class="wifi-scan-mode-tabs" style="display: flex; gap: 4px;"> <div class="wifi-scan-mode-tabs" role="tablist" aria-label="Scan mode" style="display: flex; gap: 4px;">
<button id="wifiScanModeQuick" class="wifi-mode-tab active" style="flex: 1; padding: 8px; font-size: 11px; background: var(--accent-green); color: #000; border: none; border-radius: 4px; cursor: pointer;"> <button id="wifiScanModeQuick" class="wifi-mode-tab active" role="tab" aria-selected="true" style="flex: 1; padding: 8px; font-size: 11px; background: var(--accent-green); color: #000; border: none; border-radius: 4px; cursor: pointer;">
Quick Scan Quick Scan
</button> </button>
<button id="wifiScanModeDeep" class="wifi-mode-tab" style="flex: 1; padding: 8px; font-size: 11px; background: var(--bg-tertiary); color: #888; border: 1px solid var(--border-color); border-radius: 4px; cursor: pointer;"> <button id="wifiScanModeDeep" class="wifi-mode-tab" role="tab" aria-selected="false" style="flex: 1; padding: 8px; font-size: 11px; background: var(--bg-tertiary); color: #888; border: 1px solid var(--border-color); border-radius: 4px; cursor: pointer;">
Deep Scan Deep Scan
</button> </button>
</div> </div>
@@ -121,7 +121,7 @@
<label for="deauthCount">Deauth Count</label> <label for="deauthCount">Deauth Count</label>
<input type="text" id="deauthCount" value="5" placeholder="5"> <input type="text" id="deauthCount" value="5" placeholder="5">
</div> </div>
<button class="preset-btn" onclick="sendDeauth()" style="width: 100%; border-color: var(--accent-red); color: var(--accent-red);"> <button class="preset-btn btn-danger-outline" onclick="sendDeauth()" style="width: 100%;">
Send Deauth Send Deauth
</button> </button>
</div> </div>
+78 -10
View File
@@ -55,7 +55,7 @@
<nav class="mode-nav" id="mainNav"> <nav class="mode-nav" id="mainNav">
{# Signals Group #} {# Signals Group #}
<div class="mode-nav-dropdown" data-group="signals"> <div class="mode-nav-dropdown" data-group="signals">
<button type="button" class="mode-nav-dropdown-btn"{% if is_index_page %} onclick="toggleNavDropdown('signals')"{% endif %}> <button type="button" class="mode-nav-dropdown-btn" aria-expanded="false"{% if is_index_page %} onclick="toggleNavDropdown('signals')"{% endif %}>
<span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h4l3-8 3 16 3-8h4"/><path d="M22 12h-1"/><path d="M1 12h1"/></svg></span> <span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h4l3-8 3 16 3-8h4"/><path d="M22 12h-1"/><path d="M1 12h1"/></svg></span>
<span class="nav-label">Signals</span> <span class="nav-label">Signals</span>
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span> <span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
@@ -74,7 +74,7 @@
{# Tracking Group #} {# Tracking Group #}
<div class="mode-nav-dropdown" data-group="tracking"> <div class="mode-nav-dropdown" data-group="tracking">
<button type="button" class="mode-nav-dropdown-btn"{% if is_index_page %} onclick="toggleNavDropdown('tracking')"{% endif %}> <button type="button" class="mode-nav-dropdown-btn" aria-expanded="false"{% if is_index_page %} onclick="toggleNavDropdown('tracking')"{% endif %}>
<span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg></span> <span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg></span>
<span class="nav-label">Tracking</span> <span class="nav-label">Tracking</span>
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span> <span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
@@ -91,7 +91,7 @@
{# Space Group #} {# Space Group #}
<div class="mode-nav-dropdown" data-group="space"> <div class="mode-nav-dropdown" data-group="space">
<button type="button" class="mode-nav-dropdown-btn"{% if is_index_page %} onclick="toggleNavDropdown('space')"{% endif %}> <button type="button" class="mode-nav-dropdown-btn" aria-expanded="false"{% if is_index_page %} onclick="toggleNavDropdown('space')"{% endif %}>
<span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/></svg></span> <span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/></svg></span>
<span class="nav-label">Space</span> <span class="nav-label">Space</span>
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span> <span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
@@ -114,7 +114,7 @@
{# Wireless Group #} {# Wireless Group #}
<div class="mode-nav-dropdown" data-group="wireless"> <div class="mode-nav-dropdown" data-group="wireless">
<button type="button" class="mode-nav-dropdown-btn"{% if is_index_page %} onclick="toggleNavDropdown('wireless')"{% endif %}> <button type="button" class="mode-nav-dropdown-btn" aria-expanded="false"{% if is_index_page %} onclick="toggleNavDropdown('wireless')"{% endif %}>
<span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor" stroke="none"/></svg></span> <span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor" stroke="none"/></svg></span>
<span class="nav-label">Wireless</span> <span class="nav-label">Wireless</span>
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span> <span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
@@ -131,7 +131,7 @@
{# Intel Group #} {# Intel Group #}
<div class="mode-nav-dropdown" data-group="intel"> <div class="mode-nav-dropdown" data-group="intel">
<button type="button" class="mode-nav-dropdown-btn"{% if is_index_page %} onclick="toggleNavDropdown('intel')"{% endif %}> <button type="button" class="mode-nav-dropdown-btn" aria-expanded="false"{% if is_index_page %} onclick="toggleNavDropdown('intel')"{% endif %}>
<span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></span> <span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></span>
<span class="nav-label">Intel</span> <span class="nav-label">Intel</span>
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span> <span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
@@ -146,7 +146,7 @@
{# System Group #} {# System Group #}
<div class="mode-nav-dropdown" data-group="system"> <div class="mode-nav-dropdown" data-group="system">
<button type="button" class="mode-nav-dropdown-btn"{% if is_index_page %} onclick="toggleNavDropdown('system')"{% endif %}> <button type="button" class="mode-nav-dropdown-btn" aria-expanded="false"{% if is_index_page %} onclick="toggleNavDropdown('system')"{% endif %}>
<span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg></span> <span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg></span>
<span class="nav-label">System</span> <span class="nav-label">System</span>
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span> <span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
@@ -159,7 +159,7 @@
{# Dynamic dashboard button (shown when in satellite mode) #} {# Dynamic dashboard button (shown when in satellite mode) #}
<div class="mode-nav-actions"> <div class="mode-nav-actions">
<a href="/satellite/dashboard" target="_blank" class="nav-action-btn" id="satelliteDashboardBtn"> <a href="/satellite/dashboard" target="_blank" class="nav-action-btn" id="satelliteDashboardBtn" style="display: none;">
<span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg></span> <span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg></span>
<span class="nav-label">Full Dashboard</span> <span class="nav-label">Full Dashboard</span>
</a> </a>
@@ -362,16 +362,84 @@
// Close other dropdowns // Close other dropdowns
document.querySelectorAll('.mode-nav-dropdown.open').forEach(d => { document.querySelectorAll('.mode-nav-dropdown.open').forEach(d => {
if (d !== dropdown) d.classList.remove('open'); if (d !== dropdown) {
d.classList.remove('open');
const btn = d.querySelector('.mode-nav-dropdown-btn');
if (btn) btn.setAttribute('aria-expanded', 'false');
}
}); });
dropdown.classList.toggle('open'); const isOpen = dropdown.classList.toggle('open');
const btn = dropdown.querySelector('.mode-nav-dropdown-btn');
if (btn) btn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
// Focus first menu item when opening
if (isOpen) {
const firstItem = dropdown.querySelector('.mode-nav-dropdown-menu button, .mode-nav-dropdown-menu a');
if (firstItem) firstItem.focus();
}
}; };
// Close dropdowns when clicking outside // Close dropdowns when clicking outside
document.addEventListener('click', function(e) { document.addEventListener('click', function(e) {
if (!e.target.closest('.mode-nav-dropdown')) { if (!e.target.closest('.mode-nav-dropdown')) {
document.querySelectorAll('.mode-nav-dropdown.open').forEach(d => d.classList.remove('open')); document.querySelectorAll('.mode-nav-dropdown.open').forEach(d => {
d.classList.remove('open');
const btn = d.querySelector('.mode-nav-dropdown-btn');
if (btn) btn.setAttribute('aria-expanded', 'false');
});
}
});
// Keyboard support for dropdown buttons and menu items
document.addEventListener('keydown', function(e) {
const dropdown = e.target.closest('.mode-nav-dropdown');
if (!dropdown) return;
const btn = dropdown.querySelector('.mode-nav-dropdown-btn');
const menu = dropdown.querySelector('.mode-nav-dropdown-menu');
if (!menu) return;
const items = Array.from(menu.querySelectorAll('button, a'));
if (e.target === btn || e.target.closest('.mode-nav-dropdown-btn')) {
// On the dropdown trigger button
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
const group = dropdown.getAttribute('data-group');
if (group) toggleNavDropdown(group);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
if (!dropdown.classList.contains('open')) {
const group = dropdown.getAttribute('data-group');
if (group) toggleNavDropdown(group);
}
if (items.length) items[0].focus();
} else if (e.key === 'Escape') {
dropdown.classList.remove('open');
if (btn) {
btn.setAttribute('aria-expanded', 'false');
btn.focus();
}
}
} else if (items.includes(e.target)) {
// Inside the dropdown menu
const idx = items.indexOf(e.target);
if (e.key === 'ArrowDown') {
e.preventDefault();
items[(idx + 1) % items.length].focus();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
items[(idx - 1 + items.length) % items.length].focus();
} else if (e.key === 'Escape') {
e.preventDefault();
dropdown.classList.remove('open');
if (btn) {
btn.setAttribute('aria-expanded', 'false');
btn.focus();
}
} else if (e.key === 'Enter' || e.key === ' ') {
// Default behavior (click) is fine for links/buttons
}
} }
}); });
} }
+21 -12
View File
@@ -503,20 +503,29 @@
worldCopyJump: true worldCopyJump: true
}); });
// Use settings manager for tile layer (allows runtime changes)
window.groundMap = groundMap; window.groundMap = groundMap;
// Add fallback tiles immediately so the map is visible instantly
const fallbackTiles = L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
maxZoom: 19,
subdomains: 'abcd',
className: 'tile-layer-cyan'
}).addTo(groundMap);
// Upgrade tiles in background via Settings (with timeout fallback)
if (typeof Settings !== 'undefined') { if (typeof Settings !== 'undefined') {
// Wait for settings to load from server before applying tiles try {
await Settings.init(); await Promise.race([
Settings.createTileLayer().addTo(groundMap); Settings.init(),
Settings.registerMap(groundMap); new Promise((_, reject) => setTimeout(() => reject(new Error('Settings timeout')), 5000))
} else { ]);
L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', { groundMap.removeLayer(fallbackTiles);
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>', Settings.createTileLayer().addTo(groundMap);
maxZoom: 19, Settings.registerMap(groundMap);
subdomains: 'abcd', } catch (e) {
className: 'tile-layer-cyan' console.warn('Satellite: Settings init failed/timed out, using fallback tiles:', e);
}).addTo(groundMap); }
} }
const lat = parseFloat(document.getElementById('obsLat')?.value); const lat = parseFloat(document.getElementById('obsLat')?.value);
+112
View File
@@ -1,5 +1,8 @@
"""Pytest configuration and fixtures.""" """Pytest configuration and fixtures."""
import sqlite3
from unittest.mock import MagicMock, patch
import pytest import pytest
from app import app as flask_app from app import app as flask_app
from routes import register_blueprints from routes import register_blueprints
@@ -9,6 +12,8 @@ from routes import register_blueprints
def app(): def app():
"""Create application for testing.""" """Create application for testing."""
flask_app.config['TESTING'] = True flask_app.config['TESTING'] = True
# Disable CSRF for tests
flask_app.config['WTF_CSRF_ENABLED'] = False
# Register blueprints only if not already registered # Register blueprints only if not already registered
if 'pager' not in flask_app.blueprints: if 'pager' not in flask_app.blueprints:
register_blueprints(flask_app) register_blueprints(flask_app)
@@ -19,3 +24,110 @@ def app():
def client(app): def client(app):
"""Create test client.""" """Create test client."""
return app.test_client() return app.test_client()
@pytest.fixture
def mock_subprocess():
"""Patch subprocess.Popen and subprocess.run with configurable returns.
Usage:
def test_example(mock_subprocess):
mock_subprocess['run'].return_value.stdout = 'output'
mock_subprocess['run'].return_value.returncode = 0
"""
with patch('subprocess.Popen') as mock_popen, \
patch('subprocess.run') as mock_run:
mock_process = MagicMock()
mock_process.poll.return_value = None
mock_process.stdout = MagicMock()
mock_process.stderr = MagicMock()
mock_process.pid = 12345
mock_popen.return_value = mock_process
mock_run.return_value = MagicMock(
returncode=0, stdout='', stderr=''
)
yield {
'popen': mock_popen,
'process': mock_process,
'run': mock_run,
}
@pytest.fixture
def mock_sdr_device():
"""Return a mock SDRDevice with configurable type and index.
Usage:
def test_example(mock_sdr_device):
device = mock_sdr_device(device_type='rtlsdr', index=0)
"""
def _factory(device_type='rtlsdr', index=0):
device = MagicMock()
device.device_type = device_type
device.device_index = index
device.name = f'Mock {device_type} #{index}'
device.is_available.return_value = True
device.build_command.return_value = ['rtl_fm', '-f', '100M']
return device
return _factory
@pytest.fixture
def mock_app_state():
"""Patch common app module attributes for route tests.
Provides mock process, queue, and lock objects on the app module.
"""
import app as app_module
import queue
mock_process = MagicMock()
mock_process.poll.return_value = None
mock_queue = queue.Queue()
mock_lock = MagicMock()
patches = {
'current_process': mock_process,
'pager_queue': mock_queue,
'pager_lock': mock_lock,
}
originals = {}
for attr, value in patches.items():
originals[attr] = getattr(app_module, attr, None)
setattr(app_module, attr, value)
yield {
'process': mock_process,
'queue': mock_queue,
'lock': mock_lock,
'module': app_module,
}
for attr, orig in originals.items():
if orig is None:
try:
delattr(app_module, attr)
except AttributeError:
pass
else:
setattr(app_module, attr, orig)
@pytest.fixture
def mock_check_tool():
"""Patch check_tool() to return True for all tools."""
with patch('utils.dependencies.check_tool', return_value=True) as mock:
yield mock
@pytest.fixture
def test_db(tmp_path):
"""Provide an isolated in-memory SQLite database for tests."""
db_path = tmp_path / 'test.db'
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
conn.execute('PRAGMA journal_mode = WAL')
yield conn
conn.close()
+1 -2
View File
@@ -29,8 +29,7 @@ def test_predict_passes_invalid_coords(client):
def test_fetch_celestrak_invalid_category(client): def test_fetch_celestrak_invalid_category(client):
"""Verify that an unauthorized category is rejected.""" """Verify that an unauthorized category is rejected."""
response = client.get('/satellite/celestrak/category_fake') response = client.get('/satellite/celestrak/category_fake')
# The code returns 200 but includes an error message in the JSON body assert response.status_code == 400
assert response.status_code == 200
assert response.json['status'] == 'error' assert response.json['status'] == 'error'
assert 'Invalid category' in response.json['message'] assert 'Invalid category' in response.json['message']
+2 -2
View File
@@ -173,8 +173,8 @@ def test_capture_pmkid_path_traversal_prevention(client):
"""Ensure the status check rejects invalid paths.""" """Ensure the status check rejects invalid paths."""
payload = {'file': '/etc/passwd'} # Malicious path payload = {'file': '/etc/passwd'} # Malicious path
response = client.post('/wifi/pmkid/status', json=payload) response = client.post('/wifi/pmkid/status', json=payload)
assert response.status_code == 200 assert response.status_code == 400
assert response.get_json()['status'] == 'error' assert response.get_json()['status'] == 'error'
assert 'Invalid capture file path' in response.get_json()['message'] assert 'Invalid capture file path' in response.get_json()['message']
+18 -5
View File
@@ -40,6 +40,8 @@ def get_connection() -> sqlite3.Connection:
_local.connection.row_factory = sqlite3.Row _local.connection.row_factory = sqlite3.Row
# Enable foreign keys # Enable foreign keys
_local.connection.execute('PRAGMA foreign_keys = ON') _local.connection.execute('PRAGMA foreign_keys = ON')
# Use WAL mode for better concurrent read/write performance
_local.connection.execute('PRAGMA journal_mode = WAL')
except sqlite3.OperationalError as e: except sqlite3.OperationalError as e:
logger.error( logger.error(
f"Cannot open database at {db_path}: {e}. " f"Cannot open database at {db_path}: {e}. "
@@ -254,12 +256,23 @@ def init_db() -> None:
cursor = conn.execute('SELECT COUNT(*) FROM users') cursor = conn.execute('SELECT COUNT(*) FROM users')
if cursor.fetchone()[0] == 0: if cursor.fetchone()[0] == 0:
from config import ADMIN_USERNAME, ADMIN_PASSWORD from config import ADMIN_USERNAME, ADMIN_PASSWORD
import secrets as _secrets
admin_password = ADMIN_PASSWORD
if not admin_password:
admin_password = _secrets.token_urlsafe(16)
logger.warning(f"Generated admin password: {admin_password}")
logger.warning("Set INTERCEPT_ADMIN_PASSWORD env var to use a fixed password.")
try:
pw_path = Path('instance/.initial_password')
pw_path.parent.mkdir(parents=True, exist_ok=True)
pw_path.write_text(f"{ADMIN_USERNAME}:{admin_password}\n")
except OSError as e:
logger.warning(f"Could not write initial password file: {e}")
logger.info(f"Creating default admin user: {ADMIN_USERNAME}") logger.info(f"Creating default admin user: {ADMIN_USERNAME}")
hashed_pw = generate_password_hash(admin_password)
# Password hashing
hashed_pw = generate_password_hash(ADMIN_PASSWORD)
conn.execute(''' conn.execute('''
INSERT INTO users (username, password_hash, role) INSERT INTO users (username, password_hash, role)
VALUES (?, ?, ?) VALUES (?, ?, ?)
+9 -1
View File
@@ -45,7 +45,7 @@ def close_process_pipes(process: subprocess.Popen) -> None:
def cleanup_all_processes() -> None: def cleanup_all_processes() -> None:
"""Clean up all registered processes on exit.""" """Clean up all registered processes and flush DataStores on exit."""
logger.info("Cleaning up all spawned processes...") logger.info("Cleaning up all spawned processes...")
with _process_lock: with _process_lock:
for process in _spawned_processes: for process in _spawned_processes:
@@ -60,6 +60,14 @@ def cleanup_all_processes() -> None:
close_process_pipes(process) close_process_pipes(process)
_spawned_processes.clear() _spawned_processes.clear()
# Stop DataStore cleanup timers and run final cleanup
try:
from utils.cleanup import cleanup_manager
cleanup_manager.cleanup_now()
cleanup_manager.stop()
except Exception as e:
logger.warning(f"Error during DataStore cleanup: {e}")
def safe_terminate(process: subprocess.Popen | None, timeout: float = 2.0) -> bool: def safe_terminate(process: subprocess.Popen | None, timeout: float = 2.0) -> bool:
""" """
+37
View File
@@ -0,0 +1,37 @@
"""Standardized API response helpers.
Use these in new or modified routes for consistent JSON responses.
Existing routes are NOT being refactored to avoid unnecessary churn.
"""
from flask import jsonify
def api_success(data=None, message=None, status_code=200):
"""Return a success JSON response.
Args:
data: Optional dict of additional fields merged into the response.
message: Optional human-readable message.
status_code: HTTP status code (default 200).
"""
payload = {'status': 'success'}
if message:
payload['message'] = message
if data:
payload.update(data)
return jsonify(payload), status_code
def api_error(message, status_code=400, error_type=None):
"""Return an error JSON response.
Args:
message: Human-readable error message.
status_code: HTTP status code (default 400).
error_type: Optional machine-readable error category (e.g. 'DEVICE_BUSY').
"""
payload = {'status': 'error', 'message': message}
if error_type:
payload['error_type'] = error_type
return jsonify(payload), status_code
+10
View File
@@ -37,6 +37,7 @@ class SDRCapabilities:
supports_bias_t: bool = False # Bias-T support supports_bias_t: bool = False # Bias-T support
supports_ppm: bool = True # PPM correction support supports_ppm: bool = True # PPM correction support
tx_capable: bool = False # Can transmit tx_capable: bool = False # Can transmit
supports_iq_capture: bool = False # Raw I/Q sample capture
@dataclass @dataclass
@@ -217,6 +218,15 @@ class CommandBuilder(ABC):
Raises: Raises:
NotImplementedError: If the SDR type does not support I/Q capture. NotImplementedError: If the SDR type does not support I/Q capture.
""" """
if not device.capabilities.supports_iq_capture:
supported = ', '.join(
t.value for t in SDRType
if t == SDRType.RTL_SDR # known IQ-capable types
)
raise ValueError(
f"{device.sdr_type.value} does not support raw I/Q capture. "
f"Supported devices: {supported}"
)
raise NotImplementedError( raise NotImplementedError(
f"{self.__class__.__name__} does not support raw I/Q capture" f"{self.__class__.__name__} does not support raw I/Q capture"
) )
+97 -83
View File
@@ -6,15 +6,15 @@ Detects RTL-SDR devices via rtl_test and other SDR hardware via SoapySDR.
from __future__ import annotations from __future__ import annotations
import logging import logging
import re import re
import subprocess import subprocess
import time import time
from typing import Optional from typing import Optional
from utils.dependencies import get_tool_path from utils.dependencies import get_tool_path
from .base import SDRCapabilities, SDRDevice, SDRType from .base import SDRCapabilities, SDRDevice, SDRType
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -44,7 +44,7 @@ def _hackrf_probe_blocked() -> bool:
return False return False
def _get_capabilities_for_type(sdr_type: SDRType) -> SDRCapabilities: def _get_capabilities_for_type(sdr_type: SDRType) -> SDRCapabilities:
"""Get default capabilities for an SDR type.""" """Get default capabilities for an SDR type."""
# Import here to avoid circular imports # Import here to avoid circular imports
from .rtlsdr import RTLSDRCommandBuilder from .rtlsdr import RTLSDRCommandBuilder
@@ -96,7 +96,7 @@ def _driver_to_sdr_type(driver: str) -> Optional[SDRType]:
return mapping.get(driver.lower()) return mapping.get(driver.lower())
def detect_rtlsdr_devices() -> list[SDRDevice]: def detect_rtlsdr_devices() -> list[SDRDevice]:
""" """
Detect RTL-SDR devices using rtl_test. Detect RTL-SDR devices using rtl_test.
@@ -105,10 +105,10 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
""" """
devices: list[SDRDevice] = [] devices: list[SDRDevice] = []
rtl_test_path = get_tool_path('rtl_test') rtl_test_path = get_tool_path('rtl_test')
if not rtl_test_path: if not rtl_test_path:
logger.debug("rtl_test not found, skipping RTL-SDR detection") logger.debug("rtl_test not found, skipping RTL-SDR detection")
return devices return devices
try: try:
import os import os
@@ -119,15 +119,19 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
lib_paths = ['/usr/local/lib', '/opt/homebrew/lib'] lib_paths = ['/usr/local/lib', '/opt/homebrew/lib']
current_ld = env.get('DYLD_LIBRARY_PATH', '') current_ld = env.get('DYLD_LIBRARY_PATH', '')
env['DYLD_LIBRARY_PATH'] = ':'.join(lib_paths + [current_ld] if current_ld else lib_paths) env['DYLD_LIBRARY_PATH'] = ':'.join(lib_paths + [current_ld] if current_ld else lib_paths)
result = subprocess.run( try:
[rtl_test_path, '-t'], result = subprocess.run(
capture_output=True, [rtl_test_path, '-t'],
text=True, capture_output=True,
encoding='utf-8', text=True,
errors='replace', encoding='utf-8',
timeout=5, errors='replace',
env=env timeout=5,
) env=env
)
except subprocess.TimeoutExpired:
logger.warning("rtl_test timed out after 5s")
return []
output = result.stderr + result.stdout output = result.stderr + result.stdout
# Parse device info from rtl_test output # Parse device info from rtl_test output
@@ -173,14 +177,14 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
return devices return devices
def _find_soapy_util() -> str | None: def _find_soapy_util() -> str | None:
"""Find SoapySDR utility command (name varies by distribution).""" """Find SoapySDR utility command (name varies by distribution)."""
# Try different command names used across distributions # Try different command names used across distributions
for cmd in ['SoapySDRUtil', 'soapy_sdr_util', 'soapysdr-util']: for cmd in ['SoapySDRUtil', 'soapy_sdr_util', 'soapysdr-util']:
tool_path = get_tool_path(cmd) tool_path = get_tool_path(cmd)
if tool_path: if tool_path:
return tool_path return tool_path
return None return None
def _get_soapy_env() -> dict: def _get_soapy_env() -> dict:
@@ -322,7 +326,7 @@ def _add_soapy_device(
)) ))
def detect_hackrf_devices() -> list[SDRDevice]: def detect_hackrf_devices() -> list[SDRDevice]:
""" """
Detect HackRF devices using native hackrf_info tool. Detect HackRF devices using native hackrf_info tool.
@@ -341,46 +345,46 @@ def detect_hackrf_devices() -> list[SDRDevice]:
devices: list[SDRDevice] = [] devices: list[SDRDevice] = []
hackrf_info_path = get_tool_path('hackrf_info') hackrf_info_path = get_tool_path('hackrf_info')
if not hackrf_info_path: if not hackrf_info_path:
_hackrf_cache = devices _hackrf_cache = devices
_hackrf_cache_ts = now _hackrf_cache_ts = now
return devices return devices
try: try:
result = subprocess.run( result = subprocess.run(
[hackrf_info_path], [hackrf_info_path],
capture_output=True, capture_output=True,
text=True, text=True,
timeout=5 timeout=5
) )
# Combine stdout + stderr: newer firmware may print to stderr, # Combine stdout + stderr: newer firmware may print to stderr,
# and hackrf_info may exit non-zero when device is briefly busy # and hackrf_info may exit non-zero when device is briefly busy
# but still output valid info. # but still output valid info.
output = f"{result.stdout or ''}\n{result.stderr or ''}" output = f"{result.stdout or ''}\n{result.stderr or ''}"
# Parse hackrf_info output # Parse hackrf_info output
# Extract board name from "Board ID Number: X (Name)" and serial # Extract board name from "Board ID Number: X (Name)" and serial
from .hackrf import HackRFCommandBuilder from .hackrf import HackRFCommandBuilder
serial_pattern = re.compile( serial_pattern = re.compile(
r'^\s*Serial\s+number:\s*(.+)$', r'^\s*Serial\s+number:\s*(.+)$',
re.IGNORECASE | re.MULTILINE, re.IGNORECASE | re.MULTILINE,
) )
board_pattern = re.compile( board_pattern = re.compile(
r'Board\s+ID\s+Number:\s*\d+\s*\(([^)]+)\)', r'Board\s+ID\s+Number:\s*\d+\s*\(([^)]+)\)',
re.IGNORECASE, re.IGNORECASE,
) )
serials_found = [] serials_found = []
for raw in serial_pattern.findall(output): for raw in serial_pattern.findall(output):
# Normalise legacy formats like "0x1234 5678" to plain hex. # Normalise legacy formats like "0x1234 5678" to plain hex.
serial = re.sub(r'0x', '', raw, flags=re.IGNORECASE) serial = re.sub(r'0x', '', raw, flags=re.IGNORECASE)
serial = re.sub(r'[^0-9A-Fa-f]', '', serial) serial = re.sub(r'[^0-9A-Fa-f]', '', serial)
if serial: if serial:
serials_found.append(serial) serials_found.append(serial)
boards_found = board_pattern.findall(output) boards_found = board_pattern.findall(output)
for i, serial in enumerate(serials_found): for i, serial in enumerate(serials_found):
board_name = boards_found[i] if i < len(boards_found) else 'HackRF' board_name = boards_found[i] if i < len(boards_found) else 'HackRF'
@@ -394,11 +398,11 @@ def detect_hackrf_devices() -> list[SDRDevice]:
)) ))
# Fallback: check if any HackRF found without serial # Fallback: check if any HackRF found without serial
if not devices and re.search(r'Found\s+HackRF', output, re.IGNORECASE): if not devices and re.search(r'Found\s+HackRF', output, re.IGNORECASE):
board_match = board_pattern.search(output) board_match = board_pattern.search(output)
board_name = board_match.group(1) if board_match else 'HackRF' board_name = board_match.group(1) if board_match else 'HackRF'
devices.append(SDRDevice( devices.append(SDRDevice(
sdr_type=SDRType.HACKRF, sdr_type=SDRType.HACKRF,
index=0, index=0,
name=board_name, name=board_name,
serial='Unknown', serial='Unknown',
@@ -414,7 +418,7 @@ def detect_hackrf_devices() -> list[SDRDevice]:
return devices return devices
def probe_rtlsdr_device(device_index: int) -> str | None: def probe_rtlsdr_device(device_index: int) -> str | None:
"""Probe whether an RTL-SDR device is available at the USB level. """Probe whether an RTL-SDR device is available at the USB level.
Runs a quick ``rtl_test`` invocation targeting a single device to Runs a quick ``rtl_test`` invocation targeting a single device to
@@ -428,11 +432,11 @@ def probe_rtlsdr_device(device_index: int) -> str | None:
An error message string if the device cannot be opened, An error message string if the device cannot be opened,
or ``None`` if the device is available. or ``None`` if the device is available.
""" """
rtl_test_path = get_tool_path('rtl_test') rtl_test_path = get_tool_path('rtl_test')
if not rtl_test_path: if not rtl_test_path:
# Can't probe without rtl_test — let the caller proceed and # Can't probe without rtl_test — let the caller proceed and
# surface errors from the actual decoder process instead. # surface errors from the actual decoder process instead.
return None return None
try: try:
import os import os
@@ -449,11 +453,11 @@ def probe_rtlsdr_device(device_index: int) -> str | None:
# Use Popen with early termination instead of run() with full timeout. # Use Popen with early termination instead of run() with full timeout.
# rtl_test prints device info to stderr quickly, then keeps running # rtl_test prints device info to stderr quickly, then keeps running
# its test loop. We kill it as soon as we see success or failure. # its test loop. We kill it as soon as we see success or failure.
proc = subprocess.Popen( proc = subprocess.Popen(
[rtl_test_path, '-d', str(device_index), '-t'], [rtl_test_path, '-d', str(device_index), '-t'],
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, text=True,
env=env, env=env,
) )
@@ -572,6 +576,16 @@ def detect_all_devices(force: bool = False) -> list[SDRDevice]:
return devices return devices
def get_cached_devices() -> list[SDRDevice] | None:
"""Return the cached device list without probing hardware.
Returns None if no cached data is available (never probed).
"""
if _all_devices_cache_ts == 0.0:
return None
return list(_all_devices_cache)
def invalidate_device_cache() -> None: def invalidate_device_cache() -> None:
"""Clear the all-devices cache so the next call re-probes hardware.""" """Clear the all-devices cache so the next call re-probes hardware."""
global _all_devices_cache, _all_devices_cache_ts global _all_devices_cache, _all_devices_cache_ts
+2 -1
View File
@@ -87,7 +87,8 @@ class RTLSDRCommandBuilder(CommandBuilder):
sample_rates=[250000, 1024000, 1800000, 2048000, 2400000], sample_rates=[250000, 1024000, 1800000, 2048000, 2400000],
supports_bias_t=True, supports_bias_t=True,
supports_ppm=True, supports_ppm=True,
tx_capable=False tx_capable=False,
supports_iq_capture=True
) )
def _get_device_arg(self, device: SDRDevice) -> str: def _get_device_arg(self, device: SDRDevice) -> str:
+14 -7
View File
@@ -6,6 +6,7 @@ Shared prediction logic used by both the API endpoint and the auto-scheduler.
from __future__ import annotations from __future__ import annotations
import datetime import datetime
import time
from typing import Any from typing import Any
from utils.logging import get_logger from utils.logging import get_logger
@@ -63,14 +64,20 @@ def predict_passes(
from data.satellites import TLE_SATELLITES from data.satellites import TLE_SATELLITES
# Use live TLE cache from satellite module if available (refreshed from CelesTrak) # Use live TLE cache from satellite module if available (refreshed from CelesTrak).
# Cache the reference locally so repeated calls don't re-import each time.
tle_source = TLE_SATELLITES tle_source = TLE_SATELLITES
try: if not hasattr(predict_passes, '_tle_ref') or \
from routes.satellite import _tle_cache (time.time() - getattr(predict_passes, '_tle_ref_ts', 0)) > 3600:
if _tle_cache: try:
tle_source = _tle_cache from routes.satellite import _tle_cache
except ImportError: if _tle_cache:
pass predict_passes._tle_ref = _tle_cache
predict_passes._tle_ref_ts = time.time()
except ImportError:
pass
if hasattr(predict_passes, '_tle_ref') and predict_passes._tle_ref:
tle_source = predict_passes._tle_ref
ts = _get_timescale() ts = _get_timescale()
observer = wgs84.latlon(lat, lon) observer = wgs84.latlon(lat, lon)