mirror of
https://github.com/smittix/intercept.git
synced 2026-06-13 08:13:32 -07:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 454a373874 | |||
| 90281b1535 | |||
| e687862043 | |||
| 05412fbfc3 | |||
| aa787f0b53 |
+16
-1
@@ -1,6 +1,8 @@
|
||||
# Git
|
||||
# Git & CI
|
||||
.git
|
||||
.gitignore
|
||||
.github
|
||||
.claude
|
||||
|
||||
# Python
|
||||
__pycache__
|
||||
@@ -29,6 +31,19 @@ tests/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.mypy_cache/
|
||||
.ruff_cache
|
||||
.DS_Store
|
||||
tasks/
|
||||
|
||||
# Documentation
|
||||
*.md
|
||||
|
||||
# Runtime data (mounted as volume)
|
||||
instance/
|
||||
data/
|
||||
|
||||
# Build scripts
|
||||
build-multiarch.sh
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
@@ -6,6 +6,13 @@
|
||||
# Container timezone (e.g. America/New_York, Europe/London, Australia/Sydney)
|
||||
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)
|
||||
INTERCEPT_ADSB_DB_PASSWORD=intercept
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -2,6 +2,28 @@
|
||||
|
||||
All notable changes to iNTERCEPT will be documented in this file.
|
||||
|
||||
## [2.25.0] - 2026-03-12
|
||||
|
||||
### Added
|
||||
- **SSEManager** - Centralized SSE connection management with exponential backoff reconnection and visual connection status indicator
|
||||
- **Loading button states** - `withLoadingButton()` utility for async action buttons across all modes
|
||||
- **Actionable error reporting** - `reportActionableError()` added to 5 mode JS files for user-friendly error messages
|
||||
- **Destructive action confirmation modals** - Custom modal system replacing 25 native `confirm()` calls
|
||||
|
||||
### Changed
|
||||
- **Accessibility improvements** - aria-labels on interactive elements, form label associations, keyboard-navigable lists
|
||||
- **CSS variable adoption** - Replaced hardcoded hex colors with CSS custom properties across 16+ files
|
||||
- **Inline style extraction** - `classList.toggle()` replaces inline `display` manipulation throughout codebase
|
||||
- **Merged `global-nav.css` into `layout.css`** - Consolidated navigation styles
|
||||
- **Reduced `!important` usage** - Responsive.css `!important` count reduced from 71 to 8
|
||||
- **Standardized breakpoints** - Unified to 480/768/1024/1280px across all responsive styles
|
||||
- **Mobile UX polish** - Improved touch targets, code overflow handling, and responsive layouts
|
||||
|
||||
### Fixed
|
||||
- Deep-linked mode scripts now wait for body parse before executing, preventing initialization failures
|
||||
|
||||
---
|
||||
|
||||
## [2.24.0] - 2026-03-10
|
||||
|
||||
### Added
|
||||
|
||||
+200
-190
@@ -1,6 +1,194 @@
|
||||
# INTERCEPT - Signal Intelligence Platform
|
||||
# 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
|
||||
|
||||
LABEL maintainer="INTERCEPT Project"
|
||||
@@ -12,12 +200,10 @@ WORKDIR /app
|
||||
# Pre-accept tshark non-root capture prompt for non-interactive install
|
||||
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 \
|
||||
# RTL-SDR tools
|
||||
rtl-sdr \
|
||||
librtlsdr-dev \
|
||||
libusb-1.0-0-dev \
|
||||
# 433MHz decoder
|
||||
rtl-433 \
|
||||
# Pager decoder
|
||||
@@ -43,7 +229,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# GPS support
|
||||
gpsd \
|
||||
gpsd-clients \
|
||||
# Utilities
|
||||
# APRS
|
||||
direwolf \
|
||||
# WiFi Extra
|
||||
@@ -62,192 +247,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
procps \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Build dump1090-fa and acarsdec from source (packages not available in slim repos)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
git \
|
||||
pkg-config \
|
||||
cmake \
|
||||
libncurses-dev \
|
||||
libsndfile1-dev \
|
||||
# GTK is required for slowrx (SSTV decoder GUI dependency).
|
||||
# Note: slowrx is kept for backwards compatibility, but the pure Python
|
||||
# SSTV decoder in utils/sstv/ is now the primary implementation.
|
||||
# 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 compiled binaries and libraries from builder stage
|
||||
COPY --from=builder /staging/usr/bin/ /usr/bin/
|
||||
COPY --from=builder /staging/usr/local/bin/ /usr/local/bin/
|
||||
COPY --from=builder /staging/usr/local/lib/ /usr/local/lib/
|
||||
COPY --from=builder /staging/opt/ /opt/
|
||||
|
||||
# Copy radiosonde Python dependencies installed during builder stage
|
||||
COPY --from=builder /usr/local/lib/python3.11/site-packages/ /usr/local/lib/python3.11/site-packages/
|
||||
|
||||
# Refresh shared library cache for custom-built libraries
|
||||
RUN ldconfig
|
||||
|
||||
# Copy requirements first for better caching
|
||||
COPY requirements.txt .
|
||||
|
||||
@@ -22,6 +22,7 @@ import queue
|
||||
import threading
|
||||
import platform
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from typing import Any
|
||||
|
||||
@@ -48,6 +49,16 @@ try:
|
||||
_has_limiter = True
|
||||
except ImportError:
|
||||
_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
|
||||
import time as _time
|
||||
_app_start_time = _time.time()
|
||||
@@ -55,7 +66,29 @@ logger = logging.getLogger('intercept.database')
|
||||
|
||||
# Create Flask app
|
||||
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
|
||||
if _has_limiter:
|
||||
@@ -77,6 +110,16 @@ else:
|
||||
return decorator
|
||||
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)
|
||||
os.environ['WERKZEUG_DEBUG_PIN'] = 'off'
|
||||
|
||||
@@ -106,6 +149,12 @@ def add_security_headers(response):
|
||||
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
||||
# Permissions policy (disable unnecessary features)
|
||||
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
|
||||
|
||||
|
||||
@@ -803,13 +852,43 @@ def _get_wifi_health() -> tuple[bool, int, int]:
|
||||
@app.route('/health')
|
||||
def health_check() -> Response:
|
||||
"""Health check endpoint for monitoring."""
|
||||
import platform
|
||||
import time
|
||||
bt_active, bt_device_count = _get_bluetooth_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,
|
||||
'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': {
|
||||
'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),
|
||||
@@ -843,9 +922,12 @@ def health_check() -> Response:
|
||||
'dsc_messages_count': len(dsc_messages),
|
||||
}
|
||||
})
|
||||
response.status_code = status_code
|
||||
return response
|
||||
|
||||
|
||||
@app.route('/killall', methods=['POST'])
|
||||
@(csrf.exempt if csrf else lambda f: f)
|
||||
def kill_all() -> Response:
|
||||
"""Kill all decoder, WiFi, and Bluetooth processes."""
|
||||
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
|
||||
|
||||
@@ -7,10 +7,21 @@ import os
|
||||
import sys
|
||||
|
||||
# Application version
|
||||
VERSION = "2.24.0"
|
||||
VERSION = "2.25.0"
|
||||
|
||||
# Changelog - latest release notes (shown on welcome screen)
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "2.25.0",
|
||||
"date": "March 2026",
|
||||
"highlights": [
|
||||
"UI/UX overhaul — SSEManager with exponential backoff and connection status indicator",
|
||||
"Accessibility improvements — aria-labels, form label associations, keyboard list navigation",
|
||||
"Destructive action confirmation modals replace native confirm() dialogs",
|
||||
"CSS variable adoption, inline style extraction, and reduced !important usage",
|
||||
"Loading button states, actionable error reporting, and mobile UX polish",
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2.24.0",
|
||||
"date": "March 2026",
|
||||
@@ -399,7 +410,7 @@ ALERT_WEBHOOK_TIMEOUT = _get_env_int('ALERT_WEBHOOK_TIMEOUT', 5)
|
||||
|
||||
# Admin credentials
|
||||
ADMIN_USERNAME = _get_env('ADMIN_USERNAME', 'admin')
|
||||
ADMIN_PASSWORD = _get_env('ADMIN_PASSWORD', 'admin')
|
||||
ADMIN_PASSWORD = _get_env('ADMIN_PASSWORD', '')
|
||||
|
||||
|
||||
def configure_logging() -> None:
|
||||
|
||||
@@ -438,6 +438,8 @@ Deploy lightweight sensor nodes across multiple locations and aggregate data to
|
||||
|
||||
- **Mode-specific header stats** - real-time badges showing key metrics per mode
|
||||
- **UTC clock** - always visible in header for time-critical operations
|
||||
- **SSE connection status indicator** - real-time connection state with SSEManager and exponential backoff reconnection
|
||||
- **Accessibility** - aria-labels, form label associations, keyboard list navigation, and destructive action confirmation modals
|
||||
- **Active mode indicator** - shows current mode with pulse animation
|
||||
- **Collapsible sections** - click any header to collapse/expand
|
||||
- **Panel styling** - gradient backgrounds with indicator dots
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@
|
||||
</div>
|
||||
<div class="hero-stats">
|
||||
<div class="stat">
|
||||
<span class="stat-value">30+</span>
|
||||
<span class="stat-value">34</span>
|
||||
<span class="stat-label">Modes</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Core dependencies
|
||||
flask>=3.0.0
|
||||
flask-wtf>=1.2.0
|
||||
flask-compress>=1.15
|
||||
flask-limiter>=2.5.4
|
||||
requests>=2.28.0
|
||||
Werkzeug>=3.1.5
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
# Routes package - registers all blueprints with the Flask app
|
||||
|
||||
|
||||
def register_blueprints(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 .adsb import adsb_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(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
|
||||
import app as app_module
|
||||
if hasattr(app_module, 'tscm_queue') and hasattr(app_module, 'tscm_lock'):
|
||||
|
||||
+8
-20
@@ -17,6 +17,7 @@ from typing import Any, Generator
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
from utils.responses import api_success, api_error
|
||||
import app as app_module
|
||||
from utils.acars_translator import translate_message
|
||||
from utils.constants import (
|
||||
@@ -219,18 +220,12 @@ def start_acars() -> Response:
|
||||
|
||||
with app_module.acars_lock:
|
||||
if app_module.acars_process and app_module.acars_process.poll() is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'ACARS decoder already running'
|
||||
}), 409
|
||||
return api_error('ACARS decoder already running', 409)
|
||||
|
||||
# Check for acarsdec
|
||||
acarsdec_path = find_acarsdec()
|
||||
if not acarsdec_path:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'acarsdec not found. Install with: sudo apt install acarsdec'
|
||||
}), 400
|
||||
return api_error('acarsdec not found. Install with: sudo apt install acarsdec', 400)
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
@@ -240,7 +235,7 @@ def start_acars() -> Response:
|
||||
gain = validate_gain(data.get('gain', '40'))
|
||||
ppm = validate_ppm(data.get('ppm', '0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
return api_error(str(e), 400)
|
||||
|
||||
# Resolve SDR type for device selection
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
@@ -249,11 +244,7 @@ def start_acars() -> Response:
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'acars', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
return api_error(error, 409, error_type='DEVICE_BUSY')
|
||||
|
||||
acars_active_device = device_int
|
||||
acars_active_sdr_type = sdr_type_str
|
||||
@@ -372,7 +363,7 @@ def start_acars() -> Response:
|
||||
if stderr:
|
||||
error_msg += f': {stderr[:500]}'
|
||||
logger.error(error_msg)
|
||||
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||
return api_error(error_msg, 500)
|
||||
|
||||
app_module.acars_process = process
|
||||
register_process(process)
|
||||
@@ -399,7 +390,7 @@ def start_acars() -> Response:
|
||||
acars_active_device = None
|
||||
acars_active_sdr_type = None
|
||||
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'])
|
||||
@@ -409,10 +400,7 @@ def stop_acars() -> Response:
|
||||
|
||||
with app_module.acars_lock:
|
||||
if not app_module.acars_process:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'ACARS decoder not running'
|
||||
}), 400
|
||||
return api_error('ACARS decoder not running', 400)
|
||||
|
||||
try:
|
||||
app_module.acars_process.terminate()
|
||||
|
||||
+29
-27
@@ -17,6 +17,8 @@ from typing import Any, Generator
|
||||
|
||||
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
|
||||
try:
|
||||
import psycopg2
|
||||
@@ -866,7 +868,7 @@ def start_adsb():
|
||||
gain = int(validate_gain(data.get('gain', '40')))
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
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)
|
||||
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_port = validate_rtl_tcp_port(remote_sbs_port)
|
||||
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}"
|
||||
logger.info(f"Connecting to remote dump1090 SBS at {remote_addr}")
|
||||
@@ -935,12 +937,12 @@ def start_adsb():
|
||||
if sdr_type == SDRType.RTL_SDR:
|
||||
dump1090_path = find_dump1090()
|
||||
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:
|
||||
# For LimeSDR/HackRF, check for readsb (dump1090 with SoapySDR support)
|
||||
dump1090_path = shutil.which('readsb') or find_dump1090()
|
||||
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)
|
||||
if app_module.adsb_process:
|
||||
@@ -1122,7 +1124,7 @@ def start_adsb():
|
||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||
adsb_active_device = None
|
||||
adsb_active_sdr_type = None
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
return api_error(str(e))
|
||||
|
||||
|
||||
@adsb_bp.route('/stop', methods=['POST'])
|
||||
@@ -1233,7 +1235,7 @@ def adsb_history():
|
||||
def adsb_history_summary():
|
||||
"""Summary stats for ADS-B history window."""
|
||||
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()
|
||||
|
||||
since_minutes = _parse_int_param(request.args.get('since_minutes'), 1440, 1, 10080)
|
||||
@@ -1256,14 +1258,14 @@ def adsb_history_summary():
|
||||
return jsonify(row)
|
||||
except Exception as 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')
|
||||
def adsb_history_aircraft():
|
||||
"""List latest aircraft snapshots for a time window."""
|
||||
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()
|
||||
|
||||
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)})
|
||||
except Exception as 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')
|
||||
def adsb_history_timeline():
|
||||
"""Timeline snapshots for a specific aircraft."""
|
||||
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()
|
||||
|
||||
icao = (request.args.get('icao') or '').strip().upper()
|
||||
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)
|
||||
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)})
|
||||
except Exception as 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')
|
||||
def adsb_history_messages():
|
||||
"""Raw message history for a specific aircraft."""
|
||||
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()
|
||||
|
||||
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)})
|
||||
except Exception as 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')
|
||||
def adsb_history_export():
|
||||
"""Export ADS-B history data in CSV or JSON format."""
|
||||
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()
|
||||
|
||||
export_format = str(request.args.get('format') or 'csv').strip().lower()
|
||||
export_type = str(request.args.get('type') or 'all').strip().lower()
|
||||
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'}:
|
||||
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)
|
||||
icao = (request.args.get('icao') or '').strip().upper()
|
||||
@@ -1501,7 +1503,7 @@ def adsb_history_export():
|
||||
sessions = cur.fetchall()
|
||||
except Exception as 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()
|
||||
timestamp = datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')
|
||||
@@ -1559,13 +1561,13 @@ def adsb_history_export():
|
||||
def adsb_history_prune():
|
||||
"""Delete ADS-B history for a selected time range or entire dataset."""
|
||||
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()
|
||||
|
||||
payload = request.get_json(silent=True) or {}
|
||||
mode = str(payload.get('mode') or 'range').strip().lower()
|
||||
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:
|
||||
with _get_history_connection() as conn:
|
||||
@@ -1587,11 +1589,11 @@ def adsb_history_prune():
|
||||
start = _parse_iso_datetime(payload.get('start'))
|
||||
end = _parse_iso_datetime(payload.get('end'))
|
||||
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:
|
||||
return jsonify({'error': 'end must be after start'}), 400
|
||||
return api_error('end must be after start', 400)
|
||||
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(
|
||||
"""
|
||||
@@ -1623,7 +1625,7 @@ def adsb_history_prune():
|
||||
})
|
||||
except Exception as 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)
|
||||
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:
|
||||
# Planespotters.net public API
|
||||
@@ -1701,7 +1703,7 @@ def aircraft_photo(registration: str):
|
||||
def get_aircraft_messages(icao: str):
|
||||
"""Get correlated ACARS/VDL2 messages for an aircraft."""
|
||||
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())
|
||||
callsign = aircraft.get('callsign') if aircraft else None
|
||||
@@ -1722,4 +1724,4 @@ def get_aircraft_messages(icao: str):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return jsonify({'status': 'success', 'icao': icao.upper(), **messages})
|
||||
return api_success(data={'icao': icao.upper(), **messages})
|
||||
|
||||
+9
-15
@@ -14,6 +14,7 @@ from typing import Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response, render_template
|
||||
|
||||
from utils.responses import api_success, api_error
|
||||
import app as app_module
|
||||
from config import SHARED_OBSERVER_LOCATION_ENABLED
|
||||
from utils.logging import get_logger
|
||||
@@ -361,7 +362,7 @@ def start_ais():
|
||||
|
||||
with app_module.ais_lock:
|
||||
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 {}
|
||||
|
||||
@@ -370,15 +371,12 @@ def start_ais():
|
||||
gain = int(validate_gain(data.get('gain', '40')))
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
return api_error(str(e), 400)
|
||||
|
||||
# Find AIS-catcher
|
||||
ais_catcher_path = find_ais_catcher()
|
||||
if not ais_catcher_path:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'AIS-catcher not found. Install from https://github.com/jvde-github/AIS-catcher/releases'
|
||||
}), 400
|
||||
return api_error('AIS-catcher not found. Install from https://github.com/jvde-github/AIS-catcher/releases', 400)
|
||||
|
||||
# Get SDR type from request
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
@@ -406,11 +404,7 @@ def start_ais():
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'ais', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
return api_error(error, 409, error_type='DEVICE_BUSY')
|
||||
|
||||
# Build command using SDR abstraction
|
||||
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.'
|
||||
if stderr_output:
|
||||
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_active_device = device
|
||||
@@ -475,7 +469,7 @@ def start_ais():
|
||||
# Release device on failure
|
||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||
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'])
|
||||
@@ -535,7 +529,7 @@ def stream_ais():
|
||||
def get_vessel_dsc(mmsi: str):
|
||||
"""Get DSC messages associated with a vessel MMSI."""
|
||||
if not mmsi or not mmsi.isdigit():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid MMSI'}), 400
|
||||
return api_error('Invalid MMSI', 400)
|
||||
|
||||
matches = []
|
||||
try:
|
||||
@@ -545,7 +539,7 @@ def get_vessel_dsc(mmsi: str):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return jsonify({'status': 'success', 'mmsi': mmsi, 'dsc_messages': matches})
|
||||
return api_success(data={'mmsi': mmsi, 'dsc_messages': matches})
|
||||
|
||||
|
||||
@ais_bp.route('/dashboard')
|
||||
|
||||
+9
-8
@@ -9,6 +9,7 @@ from typing import Generator
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
from utils.alerts import get_alert_manager
|
||||
from utils.responses import api_success, api_error
|
||||
from utils.sse import format_sse
|
||||
|
||||
alerts_bp = Blueprint('alerts', __name__, url_prefix='/alerts')
|
||||
@@ -18,18 +19,18 @@ alerts_bp = Blueprint('alerts', __name__, url_prefix='/alerts')
|
||||
def list_rules():
|
||||
manager = get_alert_manager()
|
||||
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'])
|
||||
def create_rule():
|
||||
data = request.get_json() or {}
|
||||
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()
|
||||
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'])
|
||||
@@ -38,8 +39,8 @@ def update_rule(rule_id: int):
|
||||
manager = get_alert_manager()
|
||||
ok = manager.update_rule(rule_id, data)
|
||||
if not ok:
|
||||
return jsonify({'status': 'error', 'message': 'Rule not found or no changes'}), 404
|
||||
return jsonify({'status': 'success'})
|
||||
return api_error('Rule not found or no changes', 404)
|
||||
return api_success()
|
||||
|
||||
|
||||
@alerts_bp.route('/rules/<int:rule_id>', methods=['DELETE'])
|
||||
@@ -47,8 +48,8 @@ def delete_rule(rule_id: int):
|
||||
manager = get_alert_manager()
|
||||
ok = manager.delete_rule(rule_id)
|
||||
if not ok:
|
||||
return jsonify({'status': 'error', 'message': 'Rule not found'}), 404
|
||||
return jsonify({'status': 'success'})
|
||||
return api_error('Rule not found', 404)
|
||||
return api_success()
|
||||
|
||||
|
||||
@alerts_bp.route('/events', methods=['GET'])
|
||||
@@ -58,7 +59,7 @@ def list_events():
|
||||
mode = request.args.get('mode')
|
||||
severity = request.args.get('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'])
|
||||
|
||||
+22
-57
@@ -20,6 +20,7 @@ from typing import Any, Generator, Optional
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
from utils.responses import api_success, api_error
|
||||
import app as app_module
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.validation import (
|
||||
@@ -1651,8 +1652,7 @@ def aprs_data() -> Response:
|
||||
if app_module.aprs_process:
|
||||
running = app_module.aprs_process.poll() is None
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
return api_success(data={
|
||||
'running': running,
|
||||
'stations': list(aprs_stations.values()),
|
||||
'count': len(aprs_stations),
|
||||
@@ -1670,20 +1670,14 @@ def start_aprs() -> Response:
|
||||
|
||||
with app_module.aprs_lock:
|
||||
if app_module.aprs_process and app_module.aprs_process.poll() is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'APRS decoder already running'
|
||||
}), 409
|
||||
return api_error('APRS decoder already running', 409)
|
||||
|
||||
# Check for decoder (prefer direwolf, fallback to multimon-ng)
|
||||
direwolf_path = find_direwolf()
|
||||
multimon_path = find_multimon_ng()
|
||||
|
||||
if not direwolf_path and not multimon_path:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'No APRS decoder found. Install direwolf or multimon-ng'
|
||||
}), 400
|
||||
return api_error('No APRS decoder found. Install direwolf or multimon-ng', 400)
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
@@ -1693,7 +1687,7 @@ def start_aprs() -> Response:
|
||||
gain = validate_gain(data.get('gain', '40'))
|
||||
ppm = validate_ppm(data.get('ppm', '0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
return api_error(str(e), 400)
|
||||
|
||||
# Check for rtl_tcp (remote SDR) connection
|
||||
rtl_tcp_host = data.get('rtl_tcp_host')
|
||||
@@ -1707,26 +1701,16 @@ def start_aprs() -> Response:
|
||||
|
||||
if sdr_type == SDRType.RTL_SDR:
|
||||
if find_rtl_fm() is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'rtl_fm not found. Install with: sudo apt install rtl-sdr'
|
||||
}), 400
|
||||
return api_error('rtl_fm not found. Install with: sudo apt install rtl-sdr', 400)
|
||||
else:
|
||||
if find_rx_fm() is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.'
|
||||
}), 400
|
||||
return api_error(f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.', 400)
|
||||
|
||||
# Reserve SDR device to prevent conflicts (skip for remote rtl_tcp)
|
||||
if not rtl_tcp_host:
|
||||
error = app_module.claim_sdr_device(device, 'aprs', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
return api_error(error, 409, error_type='DEVICE_BUSY')
|
||||
aprs_active_device = device
|
||||
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_port = validate_rtl_tcp_port(rtl_tcp_port)
|
||||
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)
|
||||
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
|
||||
else:
|
||||
@@ -1782,7 +1766,7 @@ def start_aprs() -> Response:
|
||||
app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
|
||||
aprs_active_device = 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
|
||||
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')
|
||||
aprs_active_device = 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:
|
||||
# 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')
|
||||
aprs_active_device = 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
|
||||
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')
|
||||
aprs_active_device = 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'])
|
||||
@@ -1964,10 +1948,7 @@ def stop_aprs() -> Response:
|
||||
processes_to_stop.append(app_module.aprs_process)
|
||||
|
||||
if not processes_to_stop:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'APRS decoder not running'
|
||||
}), 400
|
||||
return api_error('APRS decoder not running', 400)
|
||||
|
||||
for proc in processes_to_stop:
|
||||
try:
|
||||
@@ -2045,10 +2026,7 @@ def scan_aprs_spectrum() -> Response:
|
||||
"""
|
||||
rtl_power_path = find_rtl_power()
|
||||
if not rtl_power_path:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'rtl_power not found. Install with: sudo apt install rtl-sdr'
|
||||
}), 400
|
||||
return api_error('rtl_power not found. Install with: sudo apt install rtl-sdr', 400)
|
||||
|
||||
# Get parameters from JSON body or query args
|
||||
if request.is_json:
|
||||
@@ -2068,7 +2046,7 @@ def scan_aprs_spectrum() -> Response:
|
||||
gain = validate_gain(gain)
|
||||
duration = min(max(int(duration), 5), 60) # Clamp 5-60 seconds
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
return api_error(str(e), 400)
|
||||
|
||||
# Get center frequency
|
||||
if frequency:
|
||||
@@ -2113,18 +2091,12 @@ def scan_aprs_spectrum() -> Response:
|
||||
|
||||
if result.returncode != 0:
|
||||
error_msg = result.stderr[:200] if result.stderr else f'Exit code {result.returncode}'
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'rtl_power failed: {error_msg}'
|
||||
}), 500
|
||||
return api_error(f'rtl_power failed: {error_msg}', 500)
|
||||
|
||||
# Parse rtl_power CSV output
|
||||
# Format: date, time, start_hz, end_hz, step_hz, samples, db1, db2, db3, ...
|
||||
if not os.path.exists(tmp_file):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'rtl_power did not produce output file'
|
||||
}), 500
|
||||
return api_error('rtl_power did not produce output file', 500)
|
||||
|
||||
bins = []
|
||||
with open(tmp_file, 'r') as f:
|
||||
@@ -2144,10 +2116,7 @@ def scan_aprs_spectrum() -> Response:
|
||||
continue
|
||||
|
||||
if not bins:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'No spectrum data collected. Check SDR connection and antenna.'
|
||||
}), 500
|
||||
return api_error('No spectrum data collected. Check SDR connection and antenna.', 500)
|
||||
|
||||
# Calculate statistics
|
||||
db_values = [b['db'] for b in bins]
|
||||
@@ -2177,8 +2146,7 @@ def scan_aprs_spectrum() -> Response:
|
||||
else:
|
||||
advice = "Good signal detected. Decoding should work well."
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
return api_success(data={
|
||||
'scan_params': {
|
||||
'center_freq_mhz': center_freq_mhz,
|
||||
'start_freq_mhz': start_freq_mhz,
|
||||
@@ -2204,13 +2172,10 @@ def scan_aprs_spectrum() -> Response:
|
||||
})
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Spectrum scan timed out after {duration + 15} seconds'
|
||||
}), 500
|
||||
return api_error(f'Spectrum scan timed out after {duration + 15} seconds', 500)
|
||||
except Exception as e:
|
||||
logger.error(f"Spectrum scan error: {e}")
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
return api_error(str(e), 500)
|
||||
finally:
|
||||
# Cleanup temp file
|
||||
try:
|
||||
|
||||
+57
-40
@@ -17,12 +17,13 @@ from typing import Any, Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
from utils.responses import api_success, api_error
|
||||
import app as app_module
|
||||
from utils.dependencies import check_tool
|
||||
from utils.logging import bluetooth_logger as logger
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.validation import validate_bluetooth_interface
|
||||
from utils.dependencies import check_tool
|
||||
from utils.logging import bluetooth_logger as logger
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.validation import validate_bluetooth_interface
|
||||
from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer
|
||||
from data.patterns import AIRTAG_PREFIXES, TILE_PREFIXES, SAMSUNG_TRACKER
|
||||
from utils.constants import (
|
||||
@@ -39,6 +40,23 @@ from utils.constants import (
|
||||
|
||||
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):
|
||||
"""Classify Bluetooth device type based on available info."""
|
||||
@@ -331,8 +349,8 @@ def reload_oui_database_route():
|
||||
if new_db:
|
||||
OUI_DATABASE.clear()
|
||||
OUI_DATABASE.update(new_db)
|
||||
return jsonify({'status': 'success', 'entries': len(OUI_DATABASE)})
|
||||
return jsonify({'status': 'error', 'message': 'Could not load oui_database.json'})
|
||||
return api_success(data={'entries': len(OUI_DATABASE)})
|
||||
return api_error('Could not load oui_database.json')
|
||||
|
||||
|
||||
@bluetooth_bp.route('/interfaces')
|
||||
@@ -359,7 +377,7 @@ def start_bt_scan():
|
||||
with app_module.bt_lock:
|
||||
if app_module.bt_process:
|
||||
if app_module.bt_process.poll() is None:
|
||||
return jsonify({'status': 'error', 'message': 'Scan already running'})
|
||||
return api_error('Scan already running')
|
||||
else:
|
||||
app_module.bt_process = None
|
||||
|
||||
@@ -371,7 +389,7 @@ def start_bt_scan():
|
||||
try:
|
||||
interface = validate_bluetooth_interface(data.get('interface', 'hci0'))
|
||||
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_devices = {}
|
||||
@@ -413,14 +431,14 @@ def start_bt_scan():
|
||||
os.write(master_fd, b'scan on\n')
|
||||
|
||||
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)
|
||||
|
||||
if app_module.bt_process.poll() is not None:
|
||||
stderr_output = app_module.bt_process.stderr.read().decode('utf-8', errors='replace').strip()
|
||||
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.daemon = True
|
||||
@@ -430,9 +448,9 @@ def start_bt_scan():
|
||||
return jsonify({'status': 'started', 'mode': scan_mode, 'interface': interface})
|
||||
|
||||
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:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
return api_error(str(e))
|
||||
|
||||
|
||||
@bluetooth_bp.route('/scan/stop', methods=['POST'])
|
||||
@@ -459,7 +477,7 @@ def reset_bt_adapter():
|
||||
try:
|
||||
interface = validate_bluetooth_interface(data.get('interface', 'hci0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
return api_error(str(e), 400)
|
||||
|
||||
with app_module.bt_lock:
|
||||
if app_module.bt_process:
|
||||
@@ -494,7 +512,7 @@ def reset_bt_adapter():
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
return api_error(str(e))
|
||||
|
||||
|
||||
@bluetooth_bp.route('/enum', methods=['POST'])
|
||||
@@ -504,7 +522,7 @@ def enum_bt_services():
|
||||
target_mac = data.get('mac')
|
||||
|
||||
if not target_mac:
|
||||
return jsonify({'status': 'error', 'message': 'Target MAC required'})
|
||||
return api_error('Target MAC required')
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
@@ -529,18 +547,17 @@ def enum_bt_services():
|
||||
|
||||
app_module.bt_services[target_mac] = services
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
return api_success(data={
|
||||
'mac': target_mac,
|
||||
'services': services
|
||||
})
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return jsonify({'status': 'error', 'message': 'Connection timed out'})
|
||||
return api_error('Connection timed out')
|
||||
except FileNotFoundError:
|
||||
return jsonify({'status': 'error', 'message': 'sdptool not found'})
|
||||
return api_error('sdptool not found')
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
return api_error(str(e))
|
||||
|
||||
|
||||
@bluetooth_bp.route('/devices')
|
||||
@@ -553,23 +570,23 @@ def get_bt_devices():
|
||||
})
|
||||
|
||||
|
||||
@bluetooth_bp.route('/stream')
|
||||
def stream_bt():
|
||||
"""SSE stream for Bluetooth events."""
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('bluetooth', msg, msg.get('type'))
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.bt_queue,
|
||||
channel_key='bluetooth',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
on_message=_on_msg,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
@bluetooth_bp.route('/stream')
|
||||
def stream_bt():
|
||||
"""SSE stream for Bluetooth events."""
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('bluetooth', msg, msg.get('type'))
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.bt_queue,
|
||||
channel_key='bluetooth',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
on_message=_on_msg,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
@@ -29,6 +29,7 @@ from utils.bluetooth import (
|
||||
get_tracker_engine,
|
||||
)
|
||||
from utils.database import get_db
|
||||
from utils.responses import api_success, api_error
|
||||
from utils.sse import format_sse
|
||||
from utils.event_pipeline import process_event
|
||||
|
||||
@@ -231,7 +232,7 @@ def start_scan():
|
||||
# Validate mode
|
||||
valid_modes = ('auto', 'dbus', 'bleak', 'hcitool', 'bluetoothctl', 'ubertooth')
|
||||
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
|
||||
scanner = get_bluetooth_scanner(adapter_id)
|
||||
@@ -389,7 +390,7 @@ def get_device(device_id: str):
|
||||
device = scanner.get_device(device_id)
|
||||
|
||||
if not device:
|
||||
return jsonify({'error': 'Device not found'}), 404
|
||||
return api_error('Device not found', 404)
|
||||
|
||||
return jsonify(device.to_dict())
|
||||
|
||||
@@ -529,7 +530,7 @@ def get_tracker_detail(device_id: str):
|
||||
device = scanner.get_device(device_id)
|
||||
|
||||
if not device:
|
||||
return jsonify({'error': 'Device not found'}), 404
|
||||
return api_error('Device not found', 404)
|
||||
|
||||
# Get RSSI history for timeline
|
||||
rssi_history = device.get_rssi_history(max_points=100)
|
||||
|
||||
+72
-78
@@ -12,6 +12,7 @@ from collections.abc import Generator
|
||||
|
||||
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.bt_locate import (
|
||||
Environment,
|
||||
@@ -33,18 +34,18 @@ def start_session():
|
||||
"""
|
||||
Start a locate session.
|
||||
|
||||
Request JSON:
|
||||
- mac_address: Target MAC address (optional)
|
||||
- name_pattern: Target name substring (optional)
|
||||
- irk_hex: Identity Resolving Key hex string (optional)
|
||||
- device_id: Device ID from Bluetooth scanner (optional)
|
||||
- device_key: Stable device key from Bluetooth scanner (optional)
|
||||
- fingerprint_id: Payload fingerprint ID from Bluetooth scanner (optional)
|
||||
- known_name: Hand-off device name (optional)
|
||||
- known_manufacturer: Hand-off manufacturer (optional)
|
||||
- last_known_rssi: Hand-off last RSSI (optional)
|
||||
- environment: 'FREE_SPACE', 'OUTDOOR', 'INDOOR', 'CUSTOM' (default: OUTDOOR)
|
||||
- custom_exponent: Path loss exponent for CUSTOM environment (optional)
|
||||
Request JSON:
|
||||
- mac_address: Target MAC address (optional)
|
||||
- name_pattern: Target name substring (optional)
|
||||
- irk_hex: Identity Resolving Key hex string (optional)
|
||||
- device_id: Device ID from Bluetooth scanner (optional)
|
||||
- device_key: Stable device key from Bluetooth scanner (optional)
|
||||
- fingerprint_id: Payload fingerprint ID from Bluetooth scanner (optional)
|
||||
- known_name: Hand-off device name (optional)
|
||||
- known_manufacturer: Hand-off manufacturer (optional)
|
||||
- last_known_rssi: Hand-off last RSSI (optional)
|
||||
- environment: 'FREE_SPACE', 'OUTDOOR', 'INDOOR', 'CUSTOM' (default: OUTDOOR)
|
||||
- custom_exponent: Path loss exponent for CUSTOM environment (optional)
|
||||
|
||||
Returns:
|
||||
JSON with session status.
|
||||
@@ -52,47 +53,46 @@ def start_session():
|
||||
data = request.get_json() or {}
|
||||
|
||||
# Build target
|
||||
target = LocateTarget(
|
||||
mac_address=data.get('mac_address'),
|
||||
name_pattern=data.get('name_pattern'),
|
||||
irk_hex=data.get('irk_hex'),
|
||||
device_id=data.get('device_id'),
|
||||
device_key=data.get('device_key'),
|
||||
fingerprint_id=data.get('fingerprint_id'),
|
||||
known_name=data.get('known_name'),
|
||||
known_manufacturer=data.get('known_manufacturer'),
|
||||
last_known_rssi=data.get('last_known_rssi'),
|
||||
)
|
||||
target = LocateTarget(
|
||||
mac_address=data.get('mac_address'),
|
||||
name_pattern=data.get('name_pattern'),
|
||||
irk_hex=data.get('irk_hex'),
|
||||
device_id=data.get('device_id'),
|
||||
device_key=data.get('device_key'),
|
||||
fingerprint_id=data.get('fingerprint_id'),
|
||||
known_name=data.get('known_name'),
|
||||
known_manufacturer=data.get('known_manufacturer'),
|
||||
last_known_rssi=data.get('last_known_rssi'),
|
||||
)
|
||||
|
||||
# At least one identifier required
|
||||
if not any([
|
||||
target.mac_address,
|
||||
target.name_pattern,
|
||||
target.irk_hex,
|
||||
target.device_id,
|
||||
target.device_key,
|
||||
target.fingerprint_id,
|
||||
]):
|
||||
return jsonify({
|
||||
'error': (
|
||||
'At least one target identifier required '
|
||||
'(mac_address, name_pattern, irk_hex, device_id, device_key, or fingerprint_id)'
|
||||
)
|
||||
}), 400
|
||||
if not any([
|
||||
target.mac_address,
|
||||
target.name_pattern,
|
||||
target.irk_hex,
|
||||
target.device_id,
|
||||
target.device_key,
|
||||
target.fingerprint_id,
|
||||
]):
|
||||
return api_error(
|
||||
'At least one target identifier required '
|
||||
'(mac_address, name_pattern, irk_hex, device_id, device_key, or fingerprint_id)',
|
||||
400
|
||||
)
|
||||
|
||||
# Parse environment
|
||||
env_str = data.get('environment', 'OUTDOOR').upper()
|
||||
try:
|
||||
environment = Environment[env_str]
|
||||
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')
|
||||
if custom_exponent is not None:
|
||||
try:
|
||||
custom_exponent = float(custom_exponent)
|
||||
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_lat = None
|
||||
@@ -109,27 +109,21 @@ def start_session():
|
||||
f"env={environment.name}, fallback=({fallback_lat}, {fallback_lon})"
|
||||
)
|
||||
|
||||
try:
|
||||
session = start_locate_session(
|
||||
target, environment, custom_exponent, fallback_lat, fallback_lon
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
logger.warning(f"Unable to start BT Locate session: {exc}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error': 'Bluetooth scanner could not be started. Check adapter permissions/capabilities.',
|
||||
}), 503
|
||||
except Exception as exc:
|
||||
logger.exception(f"Unexpected error starting BT Locate session: {exc}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error': 'Failed to start locate session',
|
||||
}), 500
|
||||
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'session': session.get_status(),
|
||||
})
|
||||
try:
|
||||
session = start_locate_session(
|
||||
target, environment, custom_exponent, fallback_lat, fallback_lon
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
logger.warning(f"Unable to start BT Locate session: {exc}")
|
||||
return api_error('Bluetooth scanner could not be started. Check adapter permissions/capabilities.', 503)
|
||||
except Exception as exc:
|
||||
logger.exception(f"Unexpected error starting BT Locate session: {exc}")
|
||||
return api_error('Failed to start locate session', 500)
|
||||
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'session': session.get_status(),
|
||||
})
|
||||
|
||||
|
||||
@bt_locate_bp.route('/stop', methods=['POST'])
|
||||
@@ -143,18 +137,18 @@ def stop_session():
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@bt_locate_bp.route('/status', methods=['GET'])
|
||||
def get_status():
|
||||
"""Get locate session status."""
|
||||
session = get_locate_session()
|
||||
if not session:
|
||||
@bt_locate_bp.route('/status', methods=['GET'])
|
||||
def get_status():
|
||||
"""Get locate session status."""
|
||||
session = get_locate_session()
|
||||
if not session:
|
||||
return jsonify({
|
||||
'active': False,
|
||||
'target': None,
|
||||
})
|
||||
|
||||
include_debug = str(request.args.get('debug', '')).lower() in ('1', 'true', 'yes')
|
||||
return jsonify(session.get_status(include_debug=include_debug))
|
||||
'active': False,
|
||||
'target': None,
|
||||
})
|
||||
|
||||
include_debug = str(request.args.get('debug', '')).lower() in ('1', 'true', 'yes')
|
||||
return jsonify(session.get_status(include_debug=include_debug))
|
||||
|
||||
|
||||
@bt_locate_bp.route('/trail', methods=['GET'])
|
||||
@@ -216,15 +210,15 @@ def test_resolve_rpa():
|
||||
address = data.get('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:
|
||||
irk = bytes.fromhex(irk_hex)
|
||||
except ValueError:
|
||||
return jsonify({'error': 'Invalid IRK hex string'}), 400
|
||||
return api_error('Invalid IRK hex string', 400)
|
||||
|
||||
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)
|
||||
return jsonify({
|
||||
@@ -239,14 +233,14 @@ def set_environment():
|
||||
"""Update the environment on the active session."""
|
||||
session = get_locate_session()
|
||||
if not session:
|
||||
return jsonify({'error': 'no active session'}), 400
|
||||
return api_error('no active session', 400)
|
||||
|
||||
data = request.get_json() or {}
|
||||
env_str = data.get('environment', '').upper()
|
||||
try:
|
||||
environment = Environment[env_str]
|
||||
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')
|
||||
if custom_exponent is not None:
|
||||
@@ -268,11 +262,11 @@ def debug_matching():
|
||||
"""Debug endpoint showing scanner devices and match results."""
|
||||
session = get_locate_session()
|
||||
if not session:
|
||||
return jsonify({'error': 'no session'})
|
||||
return api_error('no session')
|
||||
|
||||
scanner = session._scanner
|
||||
if not scanner:
|
||||
return jsonify({'error': 'no scanner'})
|
||||
return api_error('no scanner')
|
||||
|
||||
devices = scanner.get_devices(max_age_seconds=30)
|
||||
return jsonify({
|
||||
|
||||
+159
-200
@@ -10,18 +10,19 @@ This blueprint provides:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Generator
|
||||
|
||||
import requests
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Generator
|
||||
|
||||
import requests
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
from utils.responses import api_success, api_error
|
||||
from utils.database import (
|
||||
create_agent, get_agent, get_agent_by_name, list_agents,
|
||||
update_agent, delete_agent, store_push_payload, get_recent_payloads
|
||||
@@ -37,28 +38,28 @@ from utils.trilateration import (
|
||||
|
||||
logger = logging.getLogger('intercept.controller')
|
||||
|
||||
controller_bp = Blueprint('controller', __name__, url_prefix='/controller')
|
||||
|
||||
# Multi-agent SSE fanout state (per-client queues).
|
||||
_agent_stream_subscribers: set[queue.Queue] = set()
|
||||
_agent_stream_subscribers_lock = threading.Lock()
|
||||
_AGENT_STREAM_CLIENT_QUEUE_SIZE = 500
|
||||
|
||||
|
||||
def _broadcast_agent_data(payload: dict) -> None:
|
||||
"""Fan out an ingested payload to all active /controller/stream/all clients."""
|
||||
with _agent_stream_subscribers_lock:
|
||||
subscribers = tuple(_agent_stream_subscribers)
|
||||
|
||||
for subscriber in subscribers:
|
||||
try:
|
||||
subscriber.put_nowait(payload)
|
||||
except queue.Full:
|
||||
try:
|
||||
subscriber.get_nowait()
|
||||
subscriber.put_nowait(payload)
|
||||
except (queue.Empty, queue.Full):
|
||||
continue
|
||||
controller_bp = Blueprint('controller', __name__, url_prefix='/controller')
|
||||
|
||||
# Multi-agent SSE fanout state (per-client queues).
|
||||
_agent_stream_subscribers: set[queue.Queue] = set()
|
||||
_agent_stream_subscribers_lock = threading.Lock()
|
||||
_AGENT_STREAM_CLIENT_QUEUE_SIZE = 500
|
||||
|
||||
|
||||
def _broadcast_agent_data(payload: dict) -> None:
|
||||
"""Fan out an ingested payload to all active /controller/stream/all clients."""
|
||||
with _agent_stream_subscribers_lock:
|
||||
subscribers = tuple(_agent_stream_subscribers)
|
||||
|
||||
for subscriber in subscribers:
|
||||
try:
|
||||
subscriber.put_nowait(payload)
|
||||
except queue.Full:
|
||||
try:
|
||||
subscriber.get_nowait()
|
||||
subscriber.put_nowait(payload)
|
||||
except (queue.Empty, queue.Full):
|
||||
continue
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -108,28 +109,25 @@ def register_agent():
|
||||
base_url = data.get('base_url', '').strip()
|
||||
|
||||
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:
|
||||
return jsonify({'status': 'error', 'message': 'Base URL is required'}), 400
|
||||
return api_error('Base URL is required', 400)
|
||||
|
||||
# Validate URL format
|
||||
from urllib.parse import urlparse
|
||||
try:
|
||||
parsed = urlparse(base_url)
|
||||
if parsed.scheme not in ('http', 'https'):
|
||||
return jsonify({'status': 'error', 'message': 'URL must start with http:// or https://'}), 400
|
||||
return api_error('URL must start with http:// or https://', 400)
|
||||
if not parsed.netloc:
|
||||
return jsonify({'status': 'error', 'message': 'Invalid URL format'}), 400
|
||||
return api_error('Invalid URL format', 400)
|
||||
except Exception:
|
||||
return jsonify({'status': 'error', 'message': 'Invalid URL format'}), 400
|
||||
return api_error('Invalid URL format', 400)
|
||||
|
||||
# Check if agent already exists
|
||||
existing = get_agent_by_name(name)
|
||||
if existing:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Agent with name "{name}" already exists'
|
||||
}), 409
|
||||
return api_error(f'Agent with name "{name}" already exists', 409)
|
||||
|
||||
# Try to connect and get capabilities
|
||||
api_key = data.get('api_key', '').strip() or None
|
||||
@@ -171,7 +169,7 @@ def register_agent():
|
||||
|
||||
except Exception as e:
|
||||
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'])
|
||||
@@ -179,7 +177,7 @@ def get_agent_detail(agent_id: int):
|
||||
"""Get details of a specific agent."""
|
||||
agent = get_agent(agent_id)
|
||||
if not agent:
|
||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||
return api_error('Agent not found', 404)
|
||||
|
||||
# Optionally refresh from agent
|
||||
refresh = request.args.get('refresh', 'false').lower() == 'true'
|
||||
@@ -215,7 +213,7 @@ def update_agent_detail(agent_id: int):
|
||||
"""Update an agent's details."""
|
||||
agent = get_agent(agent_id)
|
||||
if not agent:
|
||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||
return api_error('Agent not found', 404)
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
@@ -237,7 +235,7 @@ def remove_agent(agent_id: int):
|
||||
"""Delete an agent."""
|
||||
agent = get_agent(agent_id)
|
||||
if not agent:
|
||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||
return api_error('Agent not found', 404)
|
||||
|
||||
delete_agent(agent_id)
|
||||
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."""
|
||||
agent = get_agent(agent_id)
|
||||
if not agent:
|
||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||
return api_error('Agent not found', 404)
|
||||
|
||||
try:
|
||||
client = create_client_from_agent(agent)
|
||||
@@ -274,16 +272,10 @@ def refresh_agent_metadata(agent_id: int):
|
||||
'metadata': metadata
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Agent is not reachable'
|
||||
}), 503
|
||||
return api_error('Agent is not reachable', 503)
|
||||
|
||||
except (AgentHTTPError, AgentConnectionError) as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Failed to reach agent: {e}'
|
||||
}), 503
|
||||
return api_error(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."""
|
||||
agent = get_agent(agent_id)
|
||||
if not agent:
|
||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||
return api_error('Agent not found', 404)
|
||||
|
||||
try:
|
||||
client = create_client_from_agent(agent)
|
||||
@@ -307,10 +299,7 @@ def get_agent_status(agent_id: int):
|
||||
'agent_status': status
|
||||
})
|
||||
except (AgentHTTPError, AgentConnectionError) as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Failed to reach agent: {e}'
|
||||
}), 503
|
||||
return api_error(f'Failed to reach agent: {e}', 503)
|
||||
|
||||
|
||||
@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."""
|
||||
agent = get_agent(agent_id)
|
||||
if not agent:
|
||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||
return api_error('Agent not found', 404)
|
||||
|
||||
params = request.json or {}
|
||||
|
||||
@@ -403,15 +392,9 @@ def proxy_start_mode(agent_id: int, mode: str):
|
||||
})
|
||||
|
||||
except AgentConnectionError as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Cannot connect to agent: {e}'
|
||||
}), 503
|
||||
return api_error(f'Cannot connect to agent: {e}', 503)
|
||||
except AgentHTTPError as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Agent error: {e}'
|
||||
}), 502
|
||||
return api_error(f'Agent error: {e}', 502)
|
||||
|
||||
|
||||
@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."""
|
||||
agent = get_agent(agent_id)
|
||||
if not agent:
|
||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||
return api_error('Agent not found', 404)
|
||||
|
||||
try:
|
||||
client = create_client_from_agent(agent)
|
||||
@@ -435,15 +418,9 @@ def proxy_stop_mode(agent_id: int, mode: str):
|
||||
})
|
||||
|
||||
except AgentConnectionError as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Cannot connect to agent: {e}'
|
||||
}), 503
|
||||
return api_error(f'Cannot connect to agent: {e}', 503)
|
||||
except AgentHTTPError as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Agent error: {e}'
|
||||
}), 502
|
||||
return api_error(f'Agent error: {e}', 502)
|
||||
|
||||
|
||||
@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."""
|
||||
agent = get_agent(agent_id)
|
||||
if not agent:
|
||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||
return api_error('Agent not found', 404)
|
||||
|
||||
try:
|
||||
client = create_client_from_agent(agent)
|
||||
@@ -465,18 +442,15 @@ def proxy_mode_status(agent_id: int, mode: str):
|
||||
})
|
||||
|
||||
except (AgentHTTPError, AgentConnectionError) as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Agent error: {e}'
|
||||
}), 502
|
||||
return api_error(f'Agent error: {e}', 502)
|
||||
|
||||
|
||||
@controller_bp.route('/agents/<int:agent_id>/<mode>/data', methods=['GET'])
|
||||
def proxy_mode_data(agent_id: int, mode: str):
|
||||
"""Get current data from a remote agent."""
|
||||
agent = get_agent(agent_id)
|
||||
if not agent:
|
||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||
@controller_bp.route('/agents/<int:agent_id>/<mode>/data', methods=['GET'])
|
||||
def proxy_mode_data(agent_id: int, mode: str):
|
||||
"""Get current data from a remote agent."""
|
||||
agent = get_agent(agent_id)
|
||||
if not agent:
|
||||
return api_error('Agent not found', 404)
|
||||
|
||||
try:
|
||||
client = create_client_from_agent(agent)
|
||||
@@ -494,60 +468,57 @@ def proxy_mode_data(agent_id: int, mode: str):
|
||||
'data': result
|
||||
})
|
||||
|
||||
except (AgentHTTPError, AgentConnectionError) as e:
|
||||
return jsonify({
|
||||
'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."""
|
||||
agent = get_agent(agent_id)
|
||||
if not agent:
|
||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||
|
||||
client = create_client_from_agent(agent)
|
||||
query = request.query_string.decode('utf-8')
|
||||
url = f"{client.base_url}/{mode}/stream"
|
||||
if query:
|
||||
url = f"{url}?{query}"
|
||||
|
||||
headers = {'Accept': 'text/event-stream'}
|
||||
if agent.get('api_key'):
|
||||
headers['X-API-Key'] = agent['api_key']
|
||||
|
||||
def generate() -> Generator[str, None, None]:
|
||||
try:
|
||||
with requests.get(url, headers=headers, stream=True, timeout=(5, 3600)) as resp:
|
||||
resp.raise_for_status()
|
||||
for chunk in resp.iter_content(chunk_size=1024):
|
||||
if not chunk:
|
||||
continue
|
||||
yield chunk.decode('utf-8', errors='ignore')
|
||||
except Exception as e:
|
||||
logger.error(f"SSE proxy error for agent {agent_id}/{mode}: {e}")
|
||||
yield format_sse({
|
||||
'type': 'error',
|
||||
'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.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."""
|
||||
agent = get_agent(agent_id)
|
||||
if not agent:
|
||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||
except (AgentHTTPError, AgentConnectionError) as e:
|
||||
return api_error(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."""
|
||||
agent = get_agent(agent_id)
|
||||
if not agent:
|
||||
return api_error('Agent not found', 404)
|
||||
|
||||
client = create_client_from_agent(agent)
|
||||
query = request.query_string.decode('utf-8')
|
||||
url = f"{client.base_url}/{mode}/stream"
|
||||
if query:
|
||||
url = f"{url}?{query}"
|
||||
|
||||
headers = {'Accept': 'text/event-stream'}
|
||||
if agent.get('api_key'):
|
||||
headers['X-API-Key'] = agent['api_key']
|
||||
|
||||
def generate() -> Generator[str, None, None]:
|
||||
try:
|
||||
with requests.get(url, headers=headers, stream=True, timeout=(5, 3600)) as resp:
|
||||
resp.raise_for_status()
|
||||
for chunk in resp.iter_content(chunk_size=1024):
|
||||
if not chunk:
|
||||
continue
|
||||
yield chunk.decode('utf-8', errors='ignore')
|
||||
except Exception as e:
|
||||
logger.error(f"SSE proxy error for agent {agent_id}/{mode}: {e}")
|
||||
yield format_sse({
|
||||
'type': 'error',
|
||||
'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.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."""
|
||||
agent = get_agent(agent_id)
|
||||
if not agent:
|
||||
return api_error('Agent not found', 404)
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
@@ -582,15 +553,9 @@ def proxy_wifi_monitor(agent_id: int):
|
||||
})
|
||||
|
||||
except AgentConnectionError as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Cannot connect to agent: {e}'
|
||||
}), 503
|
||||
return api_error(f'Cannot connect to agent: {e}', 503)
|
||||
except AgentHTTPError as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Agent error: {e}'
|
||||
}), 502
|
||||
return api_error(f'Agent error: {e}', 502)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -616,23 +581,23 @@ def ingest_push_data():
|
||||
"""
|
||||
data = request.json
|
||||
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')
|
||||
if not agent_name:
|
||||
return jsonify({'status': 'error', 'message': 'agent_name required'}), 400
|
||||
return api_error('agent_name required', 400)
|
||||
|
||||
# Find agent
|
||||
agent = get_agent_by_name(agent_name)
|
||||
if not agent:
|
||||
return jsonify({'status': 'error', 'message': 'Unknown agent'}), 401
|
||||
return api_error('Unknown agent', 401)
|
||||
|
||||
# Validate API key if configured
|
||||
if agent.get('api_key'):
|
||||
provided_key = request.headers.get('X-API-Key', '')
|
||||
if provided_key != agent['api_key']:
|
||||
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
|
||||
try:
|
||||
@@ -644,16 +609,16 @@ def ingest_push_data():
|
||||
received_at=data.get('received_at')
|
||||
)
|
||||
|
||||
# Emit to SSE stream (fanout to all connected clients)
|
||||
_broadcast_agent_data({
|
||||
'type': 'agent_data',
|
||||
'agent_id': agent['id'],
|
||||
'agent_name': agent_name,
|
||||
'scan_type': data.get('scan_type'),
|
||||
'interface': data.get('interface'),
|
||||
'payload': data.get('payload'),
|
||||
'received_at': data.get('received_at') or datetime.now(timezone.utc).isoformat()
|
||||
})
|
||||
# Emit to SSE stream (fanout to all connected clients)
|
||||
_broadcast_agent_data({
|
||||
'type': 'agent_data',
|
||||
'agent_id': agent['id'],
|
||||
'agent_name': agent_name,
|
||||
'scan_type': data.get('scan_type'),
|
||||
'interface': data.get('interface'),
|
||||
'payload': data.get('payload'),
|
||||
'received_at': data.get('received_at') or datetime.now(timezone.utc).isoformat()
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'status': 'accepted',
|
||||
@@ -662,7 +627,7 @@ def ingest_push_data():
|
||||
|
||||
except Exception as e:
|
||||
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'])
|
||||
@@ -690,35 +655,35 @@ def get_payloads():
|
||||
# =============================================================================
|
||||
|
||||
@controller_bp.route('/stream/all')
|
||||
def stream_all_agents():
|
||||
def stream_all_agents():
|
||||
"""
|
||||
Combined SSE stream for data from all agents.
|
||||
|
||||
This endpoint streams push data as it arrives from agents.
|
||||
Each message is tagged with agent_id and agent_name.
|
||||
"""
|
||||
client_queue: queue.Queue = queue.Queue(maxsize=_AGENT_STREAM_CLIENT_QUEUE_SIZE)
|
||||
with _agent_stream_subscribers_lock:
|
||||
_agent_stream_subscribers.add(client_queue)
|
||||
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
msg = client_queue.get(timeout=1.0)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= keepalive_interval:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
finally:
|
||||
with _agent_stream_subscribers_lock:
|
||||
_agent_stream_subscribers.discard(client_queue)
|
||||
client_queue: queue.Queue = queue.Queue(maxsize=_AGENT_STREAM_CLIENT_QUEUE_SIZE)
|
||||
with _agent_stream_subscribers_lock:
|
||||
_agent_stream_subscribers.add(client_queue)
|
||||
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
msg = client_queue.get(timeout=1.0)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= keepalive_interval:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
finally:
|
||||
with _agent_stream_subscribers_lock:
|
||||
_agent_stream_subscribers.discard(client_queue)
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
@@ -783,7 +748,7 @@ def add_location_observation():
|
||||
required = ['device_id', 'agent_name', 'agent_lat', 'agent_lon', 'rssi']
|
||||
for field in required:
|
||||
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
|
||||
agent_lat = data.get('agent_lat')
|
||||
@@ -797,10 +762,7 @@ def add_location_observation():
|
||||
agent_lon = coords.get('lon') or coords.get('longitude')
|
||||
|
||||
if agent_lat is None or agent_lon is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Agent GPS coordinates required'
|
||||
}), 400
|
||||
return api_error('Agent GPS coordinates required', 400)
|
||||
|
||||
estimate = device_tracker.add_observation(
|
||||
device_id=data['device_id'],
|
||||
@@ -837,10 +799,7 @@ def estimate_location():
|
||||
|
||||
observations = data.get('observations', [])
|
||||
if len(observations) < 2:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'At least 2 observations required'
|
||||
}), 400
|
||||
return api_error('At least 2 observations required', 400)
|
||||
|
||||
environment = data.get('environment', 'outdoor')
|
||||
|
||||
@@ -852,7 +811,7 @@ def estimate_location():
|
||||
})
|
||||
except Exception as e:
|
||||
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'])
|
||||
@@ -904,7 +863,7 @@ def get_devices_near():
|
||||
lon = float(request.args.get('lon', 0))
|
||||
radius = float(request.args.get('radius', 100))
|
||||
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)
|
||||
|
||||
|
||||
+9
-31
@@ -6,6 +6,7 @@ from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
import app as app_module
|
||||
from utils.correlation import get_correlations
|
||||
from utils.responses import api_success, api_error
|
||||
from utils.logging import get_logger
|
||||
|
||||
logger = get_logger('intercept.correlation')
|
||||
@@ -39,18 +40,14 @@ def get_device_correlations() -> Response:
|
||||
include_historical=include_historical
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
return api_success(data={
|
||||
'correlations': correlations,
|
||||
'wifi_count': len(wifi_devices),
|
||||
'bt_count': len(bt_devices)
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error calculating correlations: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@correlation_bp.route('/analyze', methods=['POST'])
|
||||
@@ -67,10 +64,7 @@ def analyze_correlation() -> Response:
|
||||
bt_mac = data.get('bt_mac')
|
||||
|
||||
if not wifi_mac or not bt_mac:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'wifi_mac and bt_mac are required'
|
||||
}), 400
|
||||
return api_error('wifi_mac and bt_mac are required', 400)
|
||||
|
||||
try:
|
||||
# Get device data
|
||||
@@ -81,16 +75,10 @@ def analyze_correlation() -> Response:
|
||||
bt_device = app_module.bt_devices.get(bt_mac)
|
||||
|
||||
if not wifi_device:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'WiFi device {wifi_mac} not found'
|
||||
}), 404
|
||||
return api_error(f'WiFi device {wifi_mac} not found', 404)
|
||||
|
||||
if not bt_device:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Bluetooth device {bt_mac} not found'
|
||||
}), 404
|
||||
return api_error(f'Bluetooth device {bt_mac} not found', 404)
|
||||
|
||||
# Calculate correlation for this specific pair
|
||||
correlations = get_correlations(
|
||||
@@ -101,19 +89,9 @@ def analyze_correlation() -> Response:
|
||||
)
|
||||
|
||||
if correlations:
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'correlation': correlations[0]
|
||||
})
|
||||
return api_success(data={'correlation': correlations[0]})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'correlation': None,
|
||||
'message': 'No correlation detected between these devices'
|
||||
})
|
||||
return api_success(data={'correlation': None}, message='No correlation detected between these devices')
|
||||
except Exception as e:
|
||||
logger.error(f"Error analyzing correlation: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
+2
-1
@@ -21,6 +21,7 @@ from typing import Any, Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
from utils.responses import api_success, api_error
|
||||
import app as app_module
|
||||
from utils.constants import (
|
||||
DSC_VHF_FREQUENCY_MHZ,
|
||||
@@ -380,7 +381,7 @@ def start_decoding() -> Response:
|
||||
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
|
||||
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
|
||||
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)
|
||||
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
|
||||
else:
|
||||
|
||||
+44
-43
@@ -8,6 +8,7 @@ from collections.abc import Generator
|
||||
|
||||
from flask import Blueprint, Response, jsonify
|
||||
|
||||
from utils.responses import api_success, api_error
|
||||
from utils.gps import (
|
||||
GPSPosition,
|
||||
GPSSkyData,
|
||||
@@ -21,7 +22,7 @@ from utils.gps import (
|
||||
stop_gpsd_daemon,
|
||||
)
|
||||
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')
|
||||
|
||||
@@ -65,17 +66,17 @@ def auto_connect_gps():
|
||||
If gpsd is not running, attempts to detect GPS devices and start gpsd.
|
||||
Returns current status if already connected.
|
||||
"""
|
||||
# Check if already running
|
||||
reader = get_gps_reader()
|
||||
if reader and reader.is_running:
|
||||
# Ensure stream callbacks are attached for this process.
|
||||
reader.add_callback(_position_callback)
|
||||
reader.add_sky_callback(_sky_callback)
|
||||
position = reader.position
|
||||
sky = reader.sky
|
||||
return jsonify({
|
||||
'status': 'connected',
|
||||
'source': 'gpsd',
|
||||
# Check if already running
|
||||
reader = get_gps_reader()
|
||||
if reader and reader.is_running:
|
||||
# Ensure stream callbacks are attached for this process.
|
||||
reader.add_callback(_position_callback)
|
||||
reader.add_sky_callback(_sky_callback)
|
||||
position = reader.position
|
||||
sky = reader.sky
|
||||
return jsonify({
|
||||
'status': 'connected',
|
||||
'source': 'gpsd',
|
||||
'has_fix': position is not None,
|
||||
'position': position.to_dict() if position else None,
|
||||
'sky': sky.to_dict() if sky else None,
|
||||
@@ -207,22 +208,22 @@ def get_position():
|
||||
})
|
||||
|
||||
|
||||
@gps_bp.route('/satellites')
|
||||
def get_satellites():
|
||||
"""Get current satellite sky view data."""
|
||||
reader = get_gps_reader()
|
||||
|
||||
if not reader or not reader.is_running:
|
||||
return jsonify({
|
||||
'status': 'waiting',
|
||||
'running': False,
|
||||
'message': 'GPS client not running'
|
||||
})
|
||||
|
||||
sky = reader.sky
|
||||
if sky:
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
@gps_bp.route('/satellites')
|
||||
def get_satellites():
|
||||
"""Get current satellite sky view data."""
|
||||
reader = get_gps_reader()
|
||||
|
||||
if not reader or not reader.is_running:
|
||||
return jsonify({
|
||||
'status': 'waiting',
|
||||
'running': False,
|
||||
'message': 'GPS client not running'
|
||||
})
|
||||
|
||||
sky = reader.sky
|
||||
if sky:
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'sky': sky.to_dict()
|
||||
})
|
||||
else:
|
||||
@@ -232,19 +233,19 @@ def get_satellites():
|
||||
})
|
||||
|
||||
|
||||
@gps_bp.route('/stream')
|
||||
def stream_gps():
|
||||
"""SSE stream of GPS position and sky updates."""
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=_gps_queue,
|
||||
channel_key='gps',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
@gps_bp.route('/stream')
|
||||
def stream_gps():
|
||||
"""SSE stream of GPS position and sky updates."""
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=_gps_queue,
|
||||
channel_key='gps',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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',
|
||||
}
|
||||
)
|
||||
@@ -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})
|
||||
@@ -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
|
||||
@@ -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
@@ -16,8 +16,9 @@ from typing import Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
from utils.responses import api_success, api_error
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.meshtastic import (
|
||||
get_meshtastic_client,
|
||||
start_meshtastic,
|
||||
@@ -453,8 +454,8 @@ def get_messages():
|
||||
})
|
||||
|
||||
|
||||
@meshtastic_bp.route('/stream')
|
||||
def stream_messages():
|
||||
@meshtastic_bp.route('/stream')
|
||||
def stream_messages():
|
||||
"""
|
||||
SSE stream of Meshtastic messages.
|
||||
|
||||
@@ -469,18 +470,18 @@ def stream_messages():
|
||||
Returns:
|
||||
SSE stream (text/event-stream)
|
||||
"""
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=_mesh_queue,
|
||||
channel_key='meshtastic',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=_mesh_queue,
|
||||
channel_key='meshtastic',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
|
||||
@@ -1050,11 +1051,11 @@ def request_store_forward():
|
||||
def mesh_topology():
|
||||
"""Return mesh network topology graph."""
|
||||
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()
|
||||
if not client or not client.is_running:
|
||||
return jsonify({'status': 'error', 'message': 'Not connected'}), 400
|
||||
return api_error('Not connected', 400)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
|
||||
@@ -20,6 +20,8 @@ from typing import Any
|
||||
|
||||
from flask import Blueprint, Flask, Response, jsonify, request
|
||||
|
||||
from utils.responses import api_success, api_error
|
||||
|
||||
try:
|
||||
from flask_sock import Sock
|
||||
WEBSOCKET_AVAILABLE = True
|
||||
@@ -170,7 +172,7 @@ def meteor_events_export():
|
||||
"""Export events as CSV or JSON."""
|
||||
detector = _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()
|
||||
if fmt == 'csv':
|
||||
|
||||
+12
-11
@@ -13,6 +13,7 @@ from typing import Any
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
from utils.responses import api_success, api_error
|
||||
import app as app_module
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.logging import sensor_logger as logger
|
||||
@@ -252,7 +253,7 @@ def start_morse() -> Response:
|
||||
try:
|
||||
detect_mode = _validate_detect_mode(data.get('detect_mode', 'goertzel'))
|
||||
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
|
||||
try:
|
||||
@@ -261,7 +262,7 @@ def start_morse() -> Response:
|
||||
ppm = validate_ppm(data.get('ppm', '0'))
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
return api_error(str(e), 400)
|
||||
|
||||
try:
|
||||
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)
|
||||
wpm_lock = _bool_value(data.get('wpm_lock', False), False)
|
||||
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')
|
||||
|
||||
@@ -335,7 +336,7 @@ def start_morse() -> Response:
|
||||
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
|
||||
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
|
||||
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)
|
||||
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
|
||||
_set_state(MORSE_ERROR, msg)
|
||||
_set_state(MORSE_IDLE, 'Idle')
|
||||
return jsonify({'status': 'error', 'message': msg}), 500
|
||||
return api_error(msg, 500)
|
||||
|
||||
with app_module.morse_lock:
|
||||
app_module.morse_process = active_rtl_process
|
||||
@@ -740,7 +741,7 @@ def start_morse() -> Response:
|
||||
morse_last_error = f'Tool not found: {e.filename}'
|
||||
_set_state(MORSE_ERROR, morse_last_error)
|
||||
_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:
|
||||
_cleanup_attempt(
|
||||
@@ -758,7 +759,7 @@ def start_morse() -> Response:
|
||||
morse_last_error = str(e)
|
||||
_set_state(MORSE_ERROR, morse_last_error)
|
||||
_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'])
|
||||
@@ -908,11 +909,11 @@ def calibrate_morse() -> Response:
|
||||
def decode_morse_file() -> Response:
|
||||
"""Decode Morse from an uploaded WAV file."""
|
||||
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']
|
||||
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.
|
||||
form = request.form or {}
|
||||
@@ -930,7 +931,7 @@ def decode_morse_file() -> Response:
|
||||
tone_lock = _bool_value(form.get('tone_lock', 'false'), False)
|
||||
wpm_lock = _bool_value(form.get('wpm_lock', 'false'), False)
|
||||
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:
|
||||
audio_file.save(tmp.name)
|
||||
@@ -968,7 +969,7 @@ def decode_morse_file() -> Response:
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f'Morse decode-file error: {e}')
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
return api_error(str(e), 500)
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
+18
-32
@@ -4,19 +4,20 @@ Offline mode routes - Asset management and settings for offline operation.
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
from utils.database import get_setting, set_setting
|
||||
from utils.responses import api_success, api_error
|
||||
import os
|
||||
|
||||
offline_bp = Blueprint('offline', __name__, url_prefix='/offline')
|
||||
|
||||
# Default offline settings
|
||||
OFFLINE_DEFAULTS = {
|
||||
'offline.enabled': False,
|
||||
# Default to bundled assets/fonts to avoid third-party CDN privacy blocks.
|
||||
'offline.assets_source': 'local',
|
||||
'offline.fonts_source': 'local',
|
||||
'offline.tile_provider': 'cartodb_dark_cyan',
|
||||
'offline.tile_server_url': ''
|
||||
}
|
||||
OFFLINE_DEFAULTS = {
|
||||
'offline.enabled': False,
|
||||
# Default to bundled assets/fonts to avoid third-party CDN privacy blocks.
|
||||
'offline.assets_source': 'local',
|
||||
'offline.fonts_source': 'local',
|
||||
'offline.tile_provider': 'cartodb_dark_cyan',
|
||||
'offline.tile_server_url': ''
|
||||
}
|
||||
|
||||
# Asset paths to check
|
||||
ASSET_PATHS = {
|
||||
@@ -64,10 +65,7 @@ def get_offline_settings():
|
||||
def get_settings():
|
||||
"""Get current offline settings."""
|
||||
settings = get_offline_settings()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'settings': settings
|
||||
})
|
||||
return api_success(data={'settings': settings})
|
||||
|
||||
|
||||
@offline_bp.route('/settings', methods=['POST'])
|
||||
@@ -75,14 +73,14 @@ def save_setting():
|
||||
"""Save an offline setting."""
|
||||
data = request.get_json()
|
||||
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']
|
||||
value = data['value']
|
||||
|
||||
# Validate key is an allowed setting
|
||||
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
|
||||
default_type = type(OFFLINE_DEFAULTS[key])
|
||||
@@ -94,18 +92,11 @@ def save_setting():
|
||||
else:
|
||||
value = default_type(value)
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Invalid value type for {key}'
|
||||
}), 400
|
||||
return api_error(f'Invalid value type for {key}', 400)
|
||||
|
||||
set_setting(key, value)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'key': key,
|
||||
'value': value
|
||||
})
|
||||
return api_success(data={'key': key, 'value': value})
|
||||
|
||||
|
||||
@offline_bp.route('/status', methods=['GET'])
|
||||
@@ -134,8 +125,7 @@ def get_status():
|
||||
if not available:
|
||||
all_available = False
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
return api_success(data={
|
||||
'all_available': all_available,
|
||||
'assets': results,
|
||||
'offline_enabled': get_setting('offline.enabled', False)
|
||||
@@ -147,11 +137,11 @@ def check_asset():
|
||||
"""Check if a specific asset file exists."""
|
||||
path = request.args.get('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
|
||||
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
|
||||
relative_path = path.lstrip('/')
|
||||
@@ -160,8 +150,4 @@ def check_asset():
|
||||
|
||||
exists = os.path.exists(full_path)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'path': path,
|
||||
'exists': exists
|
||||
})
|
||||
return api_success(data={'path': path, 'exists': exists})
|
||||
|
||||
+11
-14
@@ -19,6 +19,7 @@ from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
import app as app_module
|
||||
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.ook import ook_parser_thread
|
||||
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:
|
||||
cleanup_ook(emit_status=False)
|
||||
else:
|
||||
return jsonify({'status': 'error', 'message': 'OOK decoder already running'}), 409
|
||||
return api_error('OOK decoder already running', 409)
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
@@ -79,12 +80,12 @@ def start_ook() -> Response:
|
||||
ppm = validate_ppm(data.get('ppm', '0'))
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
return api_error(str(e), 400)
|
||||
|
||||
try:
|
||||
encoding = _validate_encoding(data.get('encoding', 'pwm'))
|
||||
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)
|
||||
try:
|
||||
@@ -95,11 +96,11 @@ def start_ook() -> Response:
|
||||
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)
|
||||
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:
|
||||
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:
|
||||
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))
|
||||
|
||||
# Parse SDR type early — needed for device claim
|
||||
@@ -117,11 +118,7 @@ def start_ook() -> Response:
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'ook', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error,
|
||||
}), 409
|
||||
return api_error(error, 409, error_type='DEVICE_BUSY')
|
||||
ook_active_device = device_int
|
||||
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_port = validate_rtl_tcp_port(rtl_tcp_port)
|
||||
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)
|
||||
logger.info(f'Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}')
|
||||
else:
|
||||
@@ -237,7 +234,7 @@ def start_ook() -> Response:
|
||||
app_module.release_sdr_device(ook_active_device, ook_active_sdr_type or 'rtlsdr')
|
||||
ook_active_device = 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:
|
||||
try:
|
||||
@@ -251,7 +248,7 @@ def start_ook() -> Response:
|
||||
app_module.release_sdr_device(ook_active_device, ook_active_sdr_type or 'rtlsdr')
|
||||
ook_active_device = 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:
|
||||
|
||||
+13
-16
@@ -18,6 +18,7 @@ from typing import Any, Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
from utils.responses import api_success, api_error
|
||||
import app as app_module
|
||||
from utils.logging import pager_logger as logger
|
||||
from utils.validation import (
|
||||
@@ -275,7 +276,7 @@ def start_decoding() -> Response:
|
||||
|
||||
with app_module.process_lock:
|
||||
if app_module.current_process:
|
||||
return jsonify({'status': 'error', 'message': 'Already running'}), 409
|
||||
return api_error('Already running', 409)
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
@@ -286,7 +287,7 @@ def start_decoding() -> Response:
|
||||
ppm = validate_ppm(data.get('ppm', '0'))
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
return api_error(str(e), 400)
|
||||
|
||||
squelch = data.get('squelch', '0')
|
||||
try:
|
||||
@@ -294,7 +295,7 @@ def start_decoding() -> Response:
|
||||
if not 0 <= squelch <= 1000:
|
||||
raise ValueError("Squelch must be between 0 and 1000")
|
||||
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
|
||||
rtl_tcp_host = data.get('rtl_tcp_host')
|
||||
@@ -308,11 +309,7 @@ def start_decoding() -> Response:
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'pager', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
return api_error(error, 409, error_type='DEVICE_BUSY')
|
||||
pager_active_device = device_int
|
||||
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')
|
||||
pager_active_device = 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]
|
||||
if not protocols:
|
||||
protocols = valid_protocols
|
||||
@@ -360,7 +357,7 @@ def start_decoding() -> Response:
|
||||
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
|
||||
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
|
||||
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)
|
||||
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')
|
||||
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', '-']
|
||||
|
||||
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')
|
||||
pager_active_device = 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:
|
||||
# Kill orphaned rtl_fm process if it was started
|
||||
try:
|
||||
@@ -482,7 +479,7 @@ def start_decoding() -> Response:
|
||||
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
|
||||
pager_active_device = None
|
||||
pager_active_sdr_type = None
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
return api_error(str(e))
|
||||
|
||||
|
||||
@pager_bp.route('/stop', methods=['POST'])
|
||||
@@ -562,16 +559,16 @@ def toggle_logging() -> Response:
|
||||
is_in_logs = str(requested_path).startswith(str(logs_dir))
|
||||
|
||||
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
|
||||
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)
|
||||
except (ValueError, OSError) as 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})
|
||||
|
||||
|
||||
+15
-27
@@ -20,6 +20,7 @@ from typing import Any
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
from utils.responses import api_success, api_error
|
||||
import app as app_module
|
||||
from utils.constants import (
|
||||
MAX_RADIOSONDE_AGE_SECONDS,
|
||||
@@ -479,10 +480,7 @@ def start_radiosonde():
|
||||
|
||||
with app_module.radiosonde_lock:
|
||||
if radiosonde_running:
|
||||
return jsonify({
|
||||
'status': 'already_running',
|
||||
'message': 'Radiosonde tracking already active',
|
||||
}), 409
|
||||
return api_error('Radiosonde tracking already active', 409)
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
@@ -491,7 +489,7 @@ def start_radiosonde():
|
||||
gain = float(validate_gain(data.get('gain', '40')))
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
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_max = data.get('freq_max', 406.0)
|
||||
@@ -503,7 +501,7 @@ def start_radiosonde():
|
||||
if freq_min >= freq_max:
|
||||
raise ValueError("Min frequency must be less than max")
|
||||
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)
|
||||
ppm = int(data.get('ppm', 0))
|
||||
@@ -525,10 +523,7 @@ def start_radiosonde():
|
||||
# Find auto_rx
|
||||
auto_rx_path = find_auto_rx()
|
||||
if not auto_rx_path:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'radiosonde_auto_rx not found. Install from https://github.com/projecthorus/radiosonde_auto_rx',
|
||||
}), 400
|
||||
return api_error('radiosonde_auto_rx not found. Install from https://github.com/projecthorus/radiosonde_auto_rx', 400)
|
||||
|
||||
# Get SDR type
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
@@ -552,11 +547,7 @@ def start_radiosonde():
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'radiosonde', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error,
|
||||
}), 409
|
||||
return api_error(error, 409, error_type='DEVICE_BUSY')
|
||||
|
||||
# Generate config
|
||||
try:
|
||||
@@ -574,7 +565,7 @@ def start_radiosonde():
|
||||
except (OSError, RuntimeError) as e:
|
||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||
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
|
||||
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()
|
||||
logger.error(f"radiosonde_auto_rx dependency check failed:\n{dep_error}")
|
||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': (
|
||||
'radiosonde_auto_rx dependencies not satisfied. '
|
||||
f'Re-run setup.sh to install. Error: {dep_error[:500]}'
|
||||
),
|
||||
}), 500
|
||||
return api_error(
|
||||
'radiosonde_auto_rx dependencies not satisfied. '
|
||||
f'Re-run setup.sh to install. Error: {dep_error[:500]}',
|
||||
500,
|
||||
)
|
||||
|
||||
try:
|
||||
logger.info(f"Starting radiosonde_auto_rx: {' '.join(cmd)}")
|
||||
@@ -646,7 +635,7 @@ def start_radiosonde():
|
||||
)
|
||||
if stderr_output:
|
||||
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_active_device = device_int
|
||||
@@ -672,7 +661,7 @@ def start_radiosonde():
|
||||
except Exception as e:
|
||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||
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'])
|
||||
@@ -741,8 +730,7 @@ def stream_radiosonde():
|
||||
def get_balloons():
|
||||
"""Get current balloon data."""
|
||||
with _balloons_lock:
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
return api_success(data={
|
||||
'count': len(radiosonde_balloons),
|
||||
'balloons': dict(radiosonde_balloons),
|
||||
})
|
||||
|
||||
+32
-39
@@ -8,6 +8,7 @@ from pathlib import Path
|
||||
from flask import Blueprint, jsonify, request, send_file
|
||||
|
||||
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')
|
||||
|
||||
@@ -17,7 +18,7 @@ def start_recording():
|
||||
data = request.get_json() or {}
|
||||
mode = (data.get('mode') or '').strip()
|
||||
if not mode:
|
||||
return jsonify({'status': 'error', 'message': 'mode is required'}), 400
|
||||
return api_error('mode is required', 400)
|
||||
|
||||
label = data.get('label')
|
||||
metadata = data.get('metadata') if isinstance(data.get('metadata'), dict) else {}
|
||||
@@ -25,16 +26,13 @@ def start_recording():
|
||||
manager = get_recording_manager()
|
||||
session = manager.start_recording(mode=mode, label=label, metadata=metadata)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'session': {
|
||||
'id': session.id,
|
||||
'mode': session.mode,
|
||||
'label': session.label,
|
||||
'started_at': session.started_at.isoformat(),
|
||||
'file_path': str(session.file_path),
|
||||
}
|
||||
})
|
||||
return api_success(data={'session': {
|
||||
'id': session.id,
|
||||
'mode': session.mode,
|
||||
'label': session.label,
|
||||
'started_at': session.started_at.isoformat(),
|
||||
'file_path': str(session.file_path),
|
||||
}})
|
||||
|
||||
|
||||
@recordings_bp.route('/stop', methods=['POST'])
|
||||
@@ -46,29 +44,25 @@ def stop_recording():
|
||||
manager = get_recording_manager()
|
||||
session = manager.stop_recording(mode=mode, session_id=session_id)
|
||||
if not session:
|
||||
return jsonify({'status': 'error', 'message': 'No active recording found'}), 404
|
||||
return api_error('No active recording found', 404)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'session': {
|
||||
'id': session.id,
|
||||
'mode': session.mode,
|
||||
'label': session.label,
|
||||
'started_at': session.started_at.isoformat(),
|
||||
'stopped_at': session.stopped_at.isoformat() if session.stopped_at else None,
|
||||
'event_count': session.event_count,
|
||||
'size_bytes': session.size_bytes,
|
||||
'file_path': str(session.file_path),
|
||||
}
|
||||
})
|
||||
return api_success(data={'session': {
|
||||
'id': session.id,
|
||||
'mode': session.mode,
|
||||
'label': session.label,
|
||||
'started_at': session.started_at.isoformat(),
|
||||
'stopped_at': session.stopped_at.isoformat() if session.stopped_at else None,
|
||||
'event_count': session.event_count,
|
||||
'size_bytes': session.size_bytes,
|
||||
'file_path': str(session.file_path),
|
||||
}})
|
||||
|
||||
|
||||
@recordings_bp.route('', methods=['GET'])
|
||||
def list_recordings():
|
||||
manager = get_recording_manager()
|
||||
limit = request.args.get('limit', default=50, type=int)
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
return api_success(data={
|
||||
'recordings': manager.list_recordings(limit=limit),
|
||||
'active': manager.get_active(),
|
||||
})
|
||||
@@ -79,8 +73,8 @@ def get_recording(session_id: str):
|
||||
manager = get_recording_manager()
|
||||
rec = manager.get_recording(session_id)
|
||||
if not rec:
|
||||
return jsonify({'status': 'error', 'message': 'Recording not found'}), 404
|
||||
return jsonify({'status': 'success', 'recording': rec})
|
||||
return api_error('Recording not found', 404)
|
||||
return api_success(data={'recording': rec})
|
||||
|
||||
|
||||
@recordings_bp.route('/<session_id>/download', methods=['GET'])
|
||||
@@ -88,19 +82,19 @@ def download_recording(session_id: str):
|
||||
manager = get_recording_manager()
|
||||
rec = manager.get_recording(session_id)
|
||||
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'])
|
||||
try:
|
||||
resolved_root = RECORDING_ROOT.resolve()
|
||||
resolved_file = file_path.resolve()
|
||||
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:
|
||||
return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400
|
||||
return api_error('Invalid recording path', 400)
|
||||
|
||||
if not file_path.exists():
|
||||
return jsonify({'status': 'error', 'message': 'Recording file missing'}), 404
|
||||
return api_error('Recording file missing', 404)
|
||||
|
||||
return send_file(
|
||||
file_path,
|
||||
@@ -116,19 +110,19 @@ def get_recording_events(session_id: str):
|
||||
manager = get_recording_manager()
|
||||
rec = manager.get_recording(session_id)
|
||||
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'])
|
||||
try:
|
||||
resolved_root = RECORDING_ROOT.resolve()
|
||||
resolved_file = file_path.resolve()
|
||||
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:
|
||||
return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400
|
||||
return api_error('Invalid recording path', 400)
|
||||
|
||||
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)))
|
||||
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:
|
||||
continue
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
return api_success(data={
|
||||
'recording': {
|
||||
'id': rec['id'],
|
||||
'mode': rec['mode'],
|
||||
|
||||
+8
-14
@@ -12,6 +12,7 @@ from typing import Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
from utils.responses import api_success, api_error
|
||||
import app as app_module
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.validation import (
|
||||
@@ -102,16 +103,13 @@ def start_rtlamr() -> Response:
|
||||
|
||||
with app_module.rtlamr_lock:
|
||||
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 {}
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
|
||||
if sdr_type_str != 'rtlsdr':
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'{sdr_type_str.replace("_", " ").title()} is not yet supported for this mode. Please use an RTL-SDR device.'
|
||||
}), 400
|
||||
return api_error(f'{sdr_type_str.replace("_", " ").title()} is not yet supported for this mode. Please use an RTL-SDR device.', 400)
|
||||
|
||||
# Validate inputs
|
||||
try:
|
||||
@@ -120,17 +118,13 @@ def start_rtlamr() -> Response:
|
||||
ppm = validate_ppm(data.get('ppm', '0'))
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
return api_error(str(e), 400)
|
||||
|
||||
# Check if device is available
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'rtlamr', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
return api_error(error, 409, error_type='DEVICE_BUSY')
|
||||
|
||||
rtlamr_active_device = device_int
|
||||
rtlamr_active_sdr_type = sdr_type_str
|
||||
@@ -181,7 +175,7 @@ def start_rtlamr() -> Response:
|
||||
if rtlamr_active_device is not None:
|
||||
app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
|
||||
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
|
||||
if rtl_tcp_just_started:
|
||||
@@ -253,7 +247,7 @@ def start_rtlamr() -> Response:
|
||||
if rtlamr_active_device is not None:
|
||||
app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
|
||||
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:
|
||||
# If rtlamr fails, clean up rtl_tcp and release device
|
||||
with rtl_tcp_lock:
|
||||
@@ -264,7 +258,7 @@ def start_rtlamr() -> Response:
|
||||
if rtlamr_active_device is not None:
|
||||
app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
|
||||
rtlamr_active_device = None
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
return api_error(str(e))
|
||||
|
||||
|
||||
@rtlamr_bp.route('/stop_rtlamr', methods=['POST'])
|
||||
|
||||
+11
-10
@@ -13,6 +13,7 @@ import requests
|
||||
|
||||
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 data.satellites import TLE_SATELLITES
|
||||
@@ -206,7 +207,7 @@ def predict_passes():
|
||||
hours = validate_hours(data.get('hours', 24))
|
||||
min_el = validate_elevation(data.get('minEl', 10))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
return api_error(str(e), 400)
|
||||
|
||||
norad_to_name = {
|
||||
25544: 'ISS',
|
||||
@@ -345,7 +346,7 @@ def get_satellite_position():
|
||||
try:
|
||||
from skyfield.api import wgs84, EarthSatellite
|
||||
except ImportError:
|
||||
return jsonify({'status': 'error', 'message': 'skyfield not installed'}), 503
|
||||
return api_error('skyfield not installed', 503)
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
@@ -354,7 +355,7 @@ def get_satellite_position():
|
||||
lat = validate_latitude(data.get('latitude', data.get('lat', 51.5074)))
|
||||
lon = validate_longitude(data.get('longitude', data.get('lon', -0.1278)))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
return api_error(str(e), 400)
|
||||
|
||||
sat_input = data.get('satellites', [])
|
||||
include_track = bool(data.get('includeTrack', True))
|
||||
@@ -528,7 +529,7 @@ def update_tle():
|
||||
})
|
||||
except Exception as 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>')
|
||||
@@ -542,7 +543,7 @@ def fetch_celestrak(category):
|
||||
]
|
||||
|
||||
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:
|
||||
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:
|
||||
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
|
||||
data = request.get_json(silent=True)
|
||||
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
|
||||
sat_list = data if isinstance(data, list) else [data]
|
||||
@@ -667,12 +668,12 @@ def update_tracked_satellite_endpoint(norad_id):
|
||||
data = request.json or {}
|
||||
enabled = data.get('enabled')
|
||||
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))
|
||||
if ok:
|
||||
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'])
|
||||
@@ -682,4 +683,4 @@ def delete_tracked_satellite_endpoint(norad_id):
|
||||
if ok:
|
||||
return jsonify({'status': 'success', 'message': msg})
|
||||
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
@@ -13,6 +13,7 @@ from typing import Any, Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
from utils.responses import api_success, api_error
|
||||
import app as app_module
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.validation import (
|
||||
@@ -165,7 +166,7 @@ def start_sensor() -> Response:
|
||||
|
||||
with app_module.sensor_lock:
|
||||
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 {}
|
||||
|
||||
@@ -176,7 +177,7 @@ def start_sensor() -> Response:
|
||||
ppm = validate_ppm(data.get('ppm', '0'))
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
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
|
||||
rtl_tcp_host = data.get('rtl_tcp_host')
|
||||
@@ -190,11 +191,7 @@ def start_sensor() -> Response:
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'sensor', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
return api_error(error, 409, error_type='DEVICE_BUSY')
|
||||
sensor_active_device = device_int
|
||||
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_port = validate_rtl_tcp_port(rtl_tcp_port)
|
||||
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)
|
||||
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')
|
||||
sensor_active_device = 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:
|
||||
# Release device on failure
|
||||
if sensor_active_device is not None:
|
||||
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
|
||||
sensor_active_device = 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'])
|
||||
@@ -346,4 +343,4 @@ def get_rssi_history() -> Response:
|
||||
result = {}
|
||||
for key, entries in sensor_rssi_history.items():
|
||||
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
@@ -16,6 +16,7 @@ from utils.database import (
|
||||
get_correlations,
|
||||
)
|
||||
from utils.logging import get_logger
|
||||
from utils.responses import api_error, api_success
|
||||
|
||||
logger = get_logger('intercept.settings')
|
||||
|
||||
@@ -27,16 +28,10 @@ def get_settings() -> Response:
|
||||
"""Get all settings."""
|
||||
try:
|
||||
settings = get_all_settings()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'settings': settings
|
||||
})
|
||||
return api_success(data={'settings': settings})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting settings: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@settings_bp.route('', methods=['POST'])
|
||||
@@ -45,10 +40,7 @@ def save_settings() -> Response:
|
||||
data = request.json or {}
|
||||
|
||||
if not data:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'No settings provided'
|
||||
}), 400
|
||||
return api_error('No settings provided', 400)
|
||||
|
||||
try:
|
||||
saved = []
|
||||
@@ -60,16 +52,10 @@ def save_settings() -> Response:
|
||||
set_setting(key, value)
|
||||
saved.append(key)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'saved': saved
|
||||
})
|
||||
return api_success(data={'saved': saved})
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving settings: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@settings_bp.route('/<key>', methods=['GET'])
|
||||
@@ -83,17 +69,10 @@ def get_single_setting(key: str) -> Response:
|
||||
'key': key
|
||||
}), 404
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'key': key,
|
||||
'value': value
|
||||
})
|
||||
return api_success(data={'key': key, 'value': value})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting setting {key}: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@settings_bp.route('/<key>', methods=['PUT'])
|
||||
@@ -103,24 +82,14 @@ def update_single_setting(key: str) -> Response:
|
||||
value = data.get('value')
|
||||
|
||||
if value is None and 'value' not in data:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Value is required'
|
||||
}), 400
|
||||
return api_error('Value is required', 400)
|
||||
|
||||
try:
|
||||
set_setting(key, value)
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'key': key,
|
||||
'value': value
|
||||
})
|
||||
return api_success(data={'key': key, 'value': value})
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating setting {key}: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@settings_bp.route('/<key>', methods=['DELETE'])
|
||||
@@ -129,11 +98,7 @@ def delete_single_setting(key: str) -> Response:
|
||||
try:
|
||||
deleted = delete_setting(key)
|
||||
if deleted:
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'key': key,
|
||||
'deleted': True
|
||||
})
|
||||
return api_success(data={'key': key, 'deleted': True})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'not_found',
|
||||
@@ -141,10 +106,7 @@ def delete_single_setting(key: str) -> Response:
|
||||
}), 404
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting setting {key}: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -158,16 +120,10 @@ def get_device_correlations() -> Response:
|
||||
|
||||
try:
|
||||
correlations = get_correlations(min_confidence)
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'correlations': correlations
|
||||
})
|
||||
return api_success(data={'correlations': correlations})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting correlations: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -229,17 +185,11 @@ def check_dvb_driver_status() -> Response:
|
||||
def blacklist_dvb_drivers() -> Response:
|
||||
"""Blacklist DVB kernel drivers to prevent them from claiming RTL-SDR devices."""
|
||||
if sys.platform != 'linux':
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'This feature is only available on Linux'
|
||||
}), 400
|
||||
return api_error('This feature is only available on Linux', 400)
|
||||
|
||||
# Check if we have permission (need to be running as root or with sudo)
|
||||
if os.geteuid() != 0:
|
||||
return jsonify({
|
||||
'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
|
||||
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)
|
||||
|
||||
errors = []
|
||||
successes = []
|
||||
|
||||
+5
-4
@@ -10,6 +10,7 @@ from typing import Any
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
from utils.responses import api_success, api_error
|
||||
from utils.logging import get_logger
|
||||
|
||||
logger = get_logger('intercept.signalid')
|
||||
@@ -294,15 +295,15 @@ def sigidwiki_lookup() -> Response:
|
||||
|
||||
freq_raw = payload.get('frequency_mhz')
|
||||
if freq_raw is None:
|
||||
return jsonify({'status': 'error', 'message': 'frequency_mhz is required'}), 400
|
||||
return api_error('frequency_mhz is required', 400)
|
||||
|
||||
try:
|
||||
frequency_mhz = float(freq_raw)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid frequency_mhz'}), 400
|
||||
return api_error('Invalid frequency_mhz', 400)
|
||||
|
||||
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()
|
||||
if modulation and len(modulation) > 16:
|
||||
@@ -331,7 +332,7 @@ def sigidwiki_lookup() -> Response:
|
||||
lookup = _lookup_sigidwiki_matches(frequency_mhz, modulation, limit)
|
||||
except Exception as 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 = {
|
||||
'matches': lookup.get('matches', []),
|
||||
|
||||
@@ -13,6 +13,7 @@ from typing import Any
|
||||
from flask import Blueprint, Response, jsonify
|
||||
|
||||
from utils.logging import get_logger
|
||||
from utils.responses import api_success, api_error
|
||||
|
||||
logger = get_logger('intercept.space_weather')
|
||||
|
||||
@@ -289,7 +290,7 @@ def get_image(key: str):
|
||||
"""Proxy and cache whitelisted space weather images."""
|
||||
entry = IMAGE_WHITELIST.get(key)
|
||||
if not entry:
|
||||
return jsonify({'error': 'Unknown image key'}), 404
|
||||
return api_error('Unknown image key', 404)
|
||||
|
||||
cache_key = f'img_{key}'
|
||||
cached = _cache_get(cache_key)
|
||||
@@ -299,7 +300,7 @@ def get_image(key: str):
|
||||
|
||||
img_data = _fetch_bytes(entry['url'])
|
||||
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)
|
||||
return Response(img_data, content_type=entry['content_type'],
|
||||
|
||||
+10
-9
@@ -14,6 +14,7 @@ from typing import Any
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response, send_file
|
||||
|
||||
from utils.responses import api_success, api_error
|
||||
import app as app_module
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import sse_stream_fanout
|
||||
@@ -357,16 +358,16 @@ def get_image(filename: str):
|
||||
|
||||
# Security: only allow alphanumeric filenames with .png extension
|
||||
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'):
|
||||
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
|
||||
image_path = decoder._output_dir / filename
|
||||
|
||||
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')
|
||||
|
||||
@@ -386,15 +387,15 @@ def download_image(filename: str):
|
||||
|
||||
# Security: only allow alphanumeric filenames with .png extension
|
||||
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'):
|
||||
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
|
||||
return api_error('Only PNG files supported', 400)
|
||||
|
||||
image_path = decoder._output_dir / filename
|
||||
|
||||
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)
|
||||
|
||||
@@ -414,15 +415,15 @@ def delete_image(filename: str):
|
||||
|
||||
# Security: only allow alphanumeric filenames with .png extension
|
||||
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'):
|
||||
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
|
||||
return api_error('Only PNG files supported', 400)
|
||||
|
||||
if decoder.delete_image(filename):
|
||||
return jsonify({'status': 'ok'})
|
||||
else:
|
||||
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||
return api_error('Image not found', 404)
|
||||
|
||||
|
||||
@sstv_bp.route('/images', methods=['DELETE'])
|
||||
|
||||
+21
-54
@@ -13,6 +13,7 @@ from pathlib import Path
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request, send_file
|
||||
|
||||
from utils.responses import api_success, api_error
|
||||
import app as app_module
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import sse_stream_fanout
|
||||
@@ -102,10 +103,7 @@ def start_decoder():
|
||||
decoder = get_general_sstv_decoder()
|
||||
|
||||
if decoder.decoder_available is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow',
|
||||
}), 400
|
||||
return api_error('SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow', 400)
|
||||
|
||||
if decoder.is_running:
|
||||
return jsonify({
|
||||
@@ -123,10 +121,7 @@ def start_decoder():
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
|
||||
if sdr_type_str != 'rtlsdr':
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'{sdr_type_str.replace("_", " ").title()} is not yet supported for this mode. Please use an RTL-SDR device.'
|
||||
}), 400
|
||||
return api_error(f'{sdr_type_str.replace("_", " ").title()} is not yet supported for this mode. Please use an RTL-SDR device.', 400)
|
||||
|
||||
frequency = data.get('frequency')
|
||||
modulation = data.get('modulation')
|
||||
@@ -134,23 +129,14 @@ def start_decoder():
|
||||
|
||||
# Validate frequency
|
||||
if frequency is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Frequency is required',
|
||||
}), 400
|
||||
return api_error('Frequency is required', 400)
|
||||
|
||||
try:
|
||||
frequency = float(frequency)
|
||||
if not (1 <= frequency <= 500):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Frequency must be between 1-500 MHz (HF requires upconverter for RTL-SDR)',
|
||||
}), 400
|
||||
return api_error('Frequency must be between 1-500 MHz (HF requires upconverter for RTL-SDR)', 400)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Invalid frequency',
|
||||
}), 400
|
||||
return api_error('Invalid frequency', 400)
|
||||
|
||||
# Auto-detect modulation from frequency table if not specified
|
||||
if not modulation:
|
||||
@@ -158,21 +144,14 @@ def start_decoder():
|
||||
|
||||
# Validate modulation
|
||||
if modulation not in ('fm', 'usb', 'lsb'):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Modulation must be fm, usb, or lsb',
|
||||
}), 400
|
||||
return api_error('Modulation must be fm, usb, or lsb', 400)
|
||||
|
||||
# Claim SDR device
|
||||
global _sstv_general_active_device, _sstv_general_active_sdr_type
|
||||
device_int = int(device_index)
|
||||
error = app_module.claim_sdr_device(device_int, 'sstv_general', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error,
|
||||
}), 409
|
||||
return api_error(error, 409, error_type='DEVICE_BUSY')
|
||||
|
||||
# Set callback and start
|
||||
decoder.set_callback(_progress_callback)
|
||||
@@ -193,10 +172,7 @@ def start_decoder():
|
||||
})
|
||||
else:
|
||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to start decoder',
|
||||
}), 500
|
||||
return api_error('Failed to start decoder', 500)
|
||||
|
||||
|
||||
@sstv_general_bp.route('/stop', methods=['POST'])
|
||||
@@ -237,15 +213,15 @@ def get_image(filename: str):
|
||||
|
||||
# Security: only allow alphanumeric filenames with .png extension
|
||||
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'):
|
||||
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
|
||||
return api_error('Only PNG files supported', 400)
|
||||
|
||||
image_path = decoder._output_dir / filename
|
||||
|
||||
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')
|
||||
|
||||
@@ -257,15 +233,15 @@ def download_image(filename: str):
|
||||
|
||||
# Security: only allow alphanumeric filenames with .png extension
|
||||
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'):
|
||||
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
|
||||
return api_error('Only PNG files supported', 400)
|
||||
|
||||
image_path = decoder._output_dir / filename
|
||||
|
||||
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)
|
||||
|
||||
@@ -277,15 +253,15 @@ def delete_image(filename: str):
|
||||
|
||||
# Security: only allow alphanumeric filenames with .png extension
|
||||
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'):
|
||||
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
|
||||
return api_error('Only PNG files supported', 400)
|
||||
|
||||
if decoder.delete_image(filename):
|
||||
return jsonify({'status': 'ok'})
|
||||
else:
|
||||
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||
return api_error('Image not found', 404)
|
||||
|
||||
|
||||
@sstv_general_bp.route('/images', methods=['DELETE'])
|
||||
@@ -322,18 +298,12 @@ def stream_progress():
|
||||
def decode_file():
|
||||
"""Decode SSTV from an uploaded audio file."""
|
||||
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']
|
||||
|
||||
if not audio_file.filename:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'No file selected',
|
||||
}), 400
|
||||
return api_error('No file selected', 400)
|
||||
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp:
|
||||
@@ -352,10 +322,7 @@ def decode_file():
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error decoding file: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e),
|
||||
}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
finally:
|
||||
try:
|
||||
|
||||
+153
-152
@@ -10,10 +10,11 @@ import queue
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response, send_file
|
||||
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import sse_stream
|
||||
from utils.subghz import get_subghz_manager
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.responses import api_success, api_error
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import sse_stream
|
||||
from utils.subghz import get_subghz_manager
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.constants import (
|
||||
SUBGHZ_FREQ_MIN_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)
|
||||
|
||||
|
||||
def _event_callback(event: dict) -> None:
|
||||
"""Forward SubGhzManager events to the SSE queue."""
|
||||
try:
|
||||
process_event('subghz', event, event.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
_subghz_queue.put_nowait(event)
|
||||
def _event_callback(event: dict) -> None:
|
||||
"""Forward SubGhzManager events to the SSE queue."""
|
||||
try:
|
||||
process_event('subghz', event, event.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
_subghz_queue.put_nowait(event)
|
||||
except queue.Full:
|
||||
try:
|
||||
_subghz_queue.get_nowait()
|
||||
@@ -76,44 +77,44 @@ def _validate_serial(data: dict) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _validate_int(data: dict, key: str, default: int, min_val: int, max_val: int) -> int:
|
||||
"""Validate integer parameter with bounds clamping."""
|
||||
def _validate_int(data: dict, key: str, default: int, min_val: int, max_val: int) -> int:
|
||||
"""Validate integer parameter with bounds clamping."""
|
||||
try:
|
||||
val = int(data.get(key, default))
|
||||
return max(min_val, min(max_val, val))
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
|
||||
def _validate_decode_profile(data: dict, default: str = 'weather') -> str:
|
||||
profile = data.get('decode_profile', default)
|
||||
if not isinstance(profile, str):
|
||||
return default
|
||||
profile = profile.strip().lower()
|
||||
if profile in {'weather', 'all'}:
|
||||
return profile
|
||||
return default
|
||||
|
||||
|
||||
def _validate_optional_float(data: dict, key: str) -> tuple[float | None, str | None]:
|
||||
raw = data.get(key)
|
||||
if raw is None or raw == '':
|
||||
return None, None
|
||||
try:
|
||||
return float(raw), None
|
||||
except (ValueError, TypeError):
|
||||
return None, f'Invalid {key}'
|
||||
|
||||
|
||||
def _validate_bool(data: dict, key: str, default: bool = False) -> bool:
|
||||
raw = data.get(key, default)
|
||||
if isinstance(raw, bool):
|
||||
return raw
|
||||
if isinstance(raw, (int, float)):
|
||||
return bool(raw)
|
||||
if isinstance(raw, str):
|
||||
return raw.strip().lower() in {'1', 'true', 'yes', 'on', 'enabled'}
|
||||
return default
|
||||
return default
|
||||
|
||||
|
||||
def _validate_decode_profile(data: dict, default: str = 'weather') -> str:
|
||||
profile = data.get('decode_profile', default)
|
||||
if not isinstance(profile, str):
|
||||
return default
|
||||
profile = profile.strip().lower()
|
||||
if profile in {'weather', 'all'}:
|
||||
return profile
|
||||
return default
|
||||
|
||||
|
||||
def _validate_optional_float(data: dict, key: str) -> tuple[float | None, str | None]:
|
||||
raw = data.get(key)
|
||||
if raw is None or raw == '':
|
||||
return None, None
|
||||
try:
|
||||
return float(raw), None
|
||||
except (ValueError, TypeError):
|
||||
return None, f'Invalid {key}'
|
||||
|
||||
|
||||
def _validate_bool(data: dict, key: str, default: bool = False) -> bool:
|
||||
raw = data.get(key, default)
|
||||
if isinstance(raw, bool):
|
||||
return raw
|
||||
if isinstance(raw, (int, float)):
|
||||
return bool(raw)
|
||||
if isinstance(raw, str):
|
||||
return raw.strip().lower() in {'1', 'true', 'yes', 'on', 'enabled'}
|
||||
return default
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -136,34 +137,34 @@ def get_presets():
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@subghz_bp.route('/receive/start', methods=['POST'])
|
||||
def start_receive():
|
||||
def start_receive():
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
freq_hz, err = _validate_frequency_hz(data)
|
||||
if err:
|
||||
return jsonify({'status': 'error', 'message': err}), 400
|
||||
return api_error(err, 400)
|
||||
|
||||
sample_rate = _validate_int(data, 'sample_rate', 2000000, 2000000, 20000000)
|
||||
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)
|
||||
trigger_enabled = _validate_bool(data, 'trigger_enabled', False)
|
||||
trigger_pre_ms = _validate_int(data, 'trigger_pre_ms', 350, 50, 5000)
|
||||
trigger_post_ms = _validate_int(data, 'trigger_post_ms', 700, 100, 10000)
|
||||
device_serial = _validate_serial(data)
|
||||
sample_rate = _validate_int(data, 'sample_rate', 2000000, 2000000, 20000000)
|
||||
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)
|
||||
trigger_enabled = _validate_bool(data, 'trigger_enabled', False)
|
||||
trigger_pre_ms = _validate_int(data, 'trigger_pre_ms', 350, 50, 5000)
|
||||
trigger_post_ms = _validate_int(data, 'trigger_post_ms', 700, 100, 10000)
|
||||
device_serial = _validate_serial(data)
|
||||
|
||||
manager = get_subghz_manager()
|
||||
manager.set_callback(_event_callback)
|
||||
|
||||
result = manager.start_receive(
|
||||
frequency_hz=freq_hz,
|
||||
sample_rate=sample_rate,
|
||||
lna_gain=lna_gain,
|
||||
vga_gain=vga_gain,
|
||||
trigger_enabled=trigger_enabled,
|
||||
trigger_pre_ms=trigger_pre_ms,
|
||||
trigger_post_ms=trigger_post_ms,
|
||||
device_serial=device_serial,
|
||||
)
|
||||
result = manager.start_receive(
|
||||
frequency_hz=freq_hz,
|
||||
sample_rate=sample_rate,
|
||||
lna_gain=lna_gain,
|
||||
vga_gain=vga_gain,
|
||||
trigger_enabled=trigger_enabled,
|
||||
trigger_pre_ms=trigger_pre_ms,
|
||||
trigger_post_ms=trigger_post_ms,
|
||||
device_serial=device_serial,
|
||||
)
|
||||
|
||||
status_code = 200 if result.get('status') != 'error' else 409
|
||||
return jsonify(result), status_code
|
||||
@@ -186,25 +187,25 @@ def start_decode():
|
||||
|
||||
freq_hz, err = _validate_frequency_hz(data)
|
||||
if err:
|
||||
return jsonify({'status': 'error', 'message': err}), 400
|
||||
return api_error(err, 400)
|
||||
|
||||
sample_rate = _validate_int(data, 'sample_rate', 2000000, 2000000, 20000000)
|
||||
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)
|
||||
decode_profile = _validate_decode_profile(data)
|
||||
device_serial = _validate_serial(data)
|
||||
sample_rate = _validate_int(data, 'sample_rate', 2000000, 2000000, 20000000)
|
||||
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)
|
||||
decode_profile = _validate_decode_profile(data)
|
||||
device_serial = _validate_serial(data)
|
||||
|
||||
manager = get_subghz_manager()
|
||||
manager.set_callback(_event_callback)
|
||||
|
||||
result = manager.start_decode(
|
||||
frequency_hz=freq_hz,
|
||||
sample_rate=sample_rate,
|
||||
lna_gain=lna_gain,
|
||||
vga_gain=vga_gain,
|
||||
decode_profile=decode_profile,
|
||||
device_serial=device_serial,
|
||||
)
|
||||
sample_rate=sample_rate,
|
||||
lna_gain=lna_gain,
|
||||
vga_gain=vga_gain,
|
||||
decode_profile=decode_profile,
|
||||
device_serial=device_serial,
|
||||
)
|
||||
|
||||
status_code = 200 if result.get('status') != 'error' else 409
|
||||
return jsonify(result), status_code
|
||||
@@ -227,33 +228,33 @@ def start_transmit():
|
||||
|
||||
capture_id = data.get('capture_id')
|
||||
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
|
||||
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)
|
||||
max_duration = _validate_int(data, 'max_duration', 10, 1, SUBGHZ_TX_MAX_DURATION)
|
||||
start_seconds, start_err = _validate_optional_float(data, 'start_seconds')
|
||||
if start_err:
|
||||
return jsonify({'status': 'error', 'message': start_err}), 400
|
||||
duration_seconds, duration_err = _validate_optional_float(data, 'duration_seconds')
|
||||
if duration_err:
|
||||
return jsonify({'status': 'error', 'message': duration_err}), 400
|
||||
device_serial = _validate_serial(data)
|
||||
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)
|
||||
start_seconds, start_err = _validate_optional_float(data, 'start_seconds')
|
||||
if start_err:
|
||||
return api_error(start_err, 400)
|
||||
duration_seconds, duration_err = _validate_optional_float(data, 'duration_seconds')
|
||||
if duration_err:
|
||||
return api_error(duration_err, 400)
|
||||
device_serial = _validate_serial(data)
|
||||
|
||||
manager = get_subghz_manager()
|
||||
manager.set_callback(_event_callback)
|
||||
|
||||
result = manager.transmit(
|
||||
capture_id=capture_id,
|
||||
tx_gain=tx_gain,
|
||||
max_duration=max_duration,
|
||||
start_seconds=start_seconds,
|
||||
duration_seconds=duration_seconds,
|
||||
device_serial=device_serial,
|
||||
)
|
||||
capture_id=capture_id,
|
||||
tx_gain=tx_gain,
|
||||
max_duration=max_duration,
|
||||
start_seconds=start_seconds,
|
||||
duration_seconds=duration_seconds,
|
||||
device_serial=device_serial,
|
||||
)
|
||||
|
||||
status_code = 200 if result.get('status') != 'error' else 400
|
||||
return jsonify(result), status_code
|
||||
@@ -278,11 +279,11 @@ def start_sweep():
|
||||
freq_start = float(data.get('freq_start_mhz', 300))
|
||||
freq_end = float(data.get('freq_end_mhz', 928))
|
||||
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:
|
||||
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):
|
||||
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)
|
||||
device_serial = _validate_serial(data)
|
||||
@@ -326,94 +327,94 @@ def list_captures():
|
||||
@subghz_bp.route('/captures/<capture_id>')
|
||||
def get_capture(capture_id: str):
|
||||
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()
|
||||
capture = manager.get_capture(capture_id)
|
||||
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()})
|
||||
|
||||
|
||||
@subghz_bp.route('/captures/<capture_id>/download')
|
||||
def download_capture(capture_id: str):
|
||||
if not capture_id.isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
|
||||
@subghz_bp.route('/captures/<capture_id>/download')
|
||||
def download_capture(capture_id: str):
|
||||
if not capture_id.isalnum():
|
||||
return api_error('Invalid capture_id', 400)
|
||||
|
||||
manager = get_subghz_manager()
|
||||
path = manager.get_capture_path(capture_id)
|
||||
if not path:
|
||||
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
|
||||
return api_error('Capture not found', 404)
|
||||
|
||||
return send_file(
|
||||
path,
|
||||
mimetype='application/octet-stream',
|
||||
as_attachment=True,
|
||||
download_name=path.name,
|
||||
)
|
||||
|
||||
|
||||
@subghz_bp.route('/captures/<capture_id>/trim', methods=['POST'])
|
||||
def trim_capture(capture_id: str):
|
||||
if not capture_id.isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
start_seconds, start_err = _validate_optional_float(data, 'start_seconds')
|
||||
if start_err:
|
||||
return jsonify({'status': 'error', 'message': start_err}), 400
|
||||
duration_seconds, duration_err = _validate_optional_float(data, 'duration_seconds')
|
||||
if duration_err:
|
||||
return jsonify({'status': 'error', 'message': duration_err}), 400
|
||||
|
||||
label = data.get('label', '')
|
||||
if label is None:
|
||||
label = ''
|
||||
if not isinstance(label, str) or len(label) > 100:
|
||||
return jsonify({'status': 'error', 'message': 'Label must be a string (max 100 chars)'}), 400
|
||||
|
||||
manager = get_subghz_manager()
|
||||
result = manager.trim_capture(
|
||||
capture_id=capture_id,
|
||||
start_seconds=start_seconds,
|
||||
duration_seconds=duration_seconds,
|
||||
label=label,
|
||||
)
|
||||
|
||||
if result.get('status') == 'ok':
|
||||
return jsonify(result), 200
|
||||
message = str(result.get('message') or 'Trim failed')
|
||||
status_code = 404 if 'not found' in message.lower() else 400
|
||||
return jsonify(result), status_code
|
||||
|
||||
|
||||
@subghz_bp.route('/captures/<capture_id>', methods=['DELETE'])
|
||||
def delete_capture(capture_id: str):
|
||||
if not capture_id.isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
|
||||
as_attachment=True,
|
||||
download_name=path.name,
|
||||
)
|
||||
|
||||
|
||||
@subghz_bp.route('/captures/<capture_id>/trim', methods=['POST'])
|
||||
def trim_capture(capture_id: str):
|
||||
if not capture_id.isalnum():
|
||||
return api_error('Invalid capture_id', 400)
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
start_seconds, start_err = _validate_optional_float(data, 'start_seconds')
|
||||
if start_err:
|
||||
return api_error(start_err, 400)
|
||||
duration_seconds, duration_err = _validate_optional_float(data, 'duration_seconds')
|
||||
if duration_err:
|
||||
return api_error(duration_err, 400)
|
||||
|
||||
label = data.get('label', '')
|
||||
if label is None:
|
||||
label = ''
|
||||
if not isinstance(label, str) or len(label) > 100:
|
||||
return api_error('Label must be a string (max 100 chars)', 400)
|
||||
|
||||
manager = get_subghz_manager()
|
||||
result = manager.trim_capture(
|
||||
capture_id=capture_id,
|
||||
start_seconds=start_seconds,
|
||||
duration_seconds=duration_seconds,
|
||||
label=label,
|
||||
)
|
||||
|
||||
if result.get('status') == 'ok':
|
||||
return jsonify(result), 200
|
||||
message = str(result.get('message') or 'Trim failed')
|
||||
status_code = 404 if 'not found' in message.lower() else 400
|
||||
return jsonify(result), status_code
|
||||
|
||||
|
||||
@subghz_bp.route('/captures/<capture_id>', methods=['DELETE'])
|
||||
def delete_capture(capture_id: str):
|
||||
if not capture_id.isalnum():
|
||||
return api_error('Invalid capture_id', 400)
|
||||
|
||||
manager = get_subghz_manager()
|
||||
if manager.delete_capture(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'])
|
||||
def update_capture(capture_id: str):
|
||||
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 {}
|
||||
label = data.get('label', '')
|
||||
|
||||
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()
|
||||
if manager.update_capture_label(capture_id, 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
@@ -22,6 +22,7 @@ from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
from utils.constants import SSE_KEEPALIVE_INTERVAL, SSE_QUEUE_TIMEOUT
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.responses import api_success, api_error
|
||||
from utils.sse import sse_stream_fanout
|
||||
|
||||
try:
|
||||
@@ -549,10 +550,10 @@ def get_weather() -> Response:
|
||||
lat, lon = loc.get('lat'), loc.get('lon')
|
||||
|
||||
if lat is None or lon is None:
|
||||
return jsonify({'error': 'No location available'})
|
||||
return api_error('No location available')
|
||||
|
||||
if _requests is None:
|
||||
return jsonify({'error': 'requests library not available'})
|
||||
return api_error('requests library not available')
|
||||
|
||||
try:
|
||||
resp = _requests.get(
|
||||
@@ -580,4 +581,4 @@ def get_weather() -> Response:
|
||||
return jsonify(weather)
|
||||
except Exception as exc:
|
||||
logger.debug('Weather fetch failed: %s', exc)
|
||||
return jsonify({'error': str(exc)})
|
||||
return api_error(str(exc))
|
||||
|
||||
+1824
-3985
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
from utils.responses import api_success, api_error
|
||||
from utils.logging import get_logger
|
||||
from utils.updater import (
|
||||
check_for_updates,
|
||||
@@ -39,10 +40,7 @@ def check_updates() -> Response:
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking for updates: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@updater_bp.route('/status', methods=['GET'])
|
||||
@@ -61,10 +59,7 @@ def update_status() -> Response:
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting update status: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@updater_bp.route('/update', methods=['POST'])
|
||||
@@ -100,10 +95,7 @@ def do_update() -> Response:
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error performing update: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@updater_bp.route('/dismiss', methods=['POST'])
|
||||
@@ -124,20 +116,14 @@ def dismiss_notification() -> Response:
|
||||
version = data.get('version')
|
||||
|
||||
if not version:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Version is required'
|
||||
}), 400
|
||||
return api_error('Version is required', 400)
|
||||
|
||||
try:
|
||||
result = dismiss_update(version)
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.error(f"Error dismissing update: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@updater_bp.route('/restart', methods=['POST'])
|
||||
|
||||
+8
-20
@@ -18,6 +18,7 @@ from typing import Any, Generator
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
import app as app_module
|
||||
from utils.responses import api_success, api_error
|
||||
from utils.acars_translator import translate_message
|
||||
from utils.constants import (
|
||||
PROCESS_START_WAIT,
|
||||
@@ -181,18 +182,12 @@ def start_vdl2() -> Response:
|
||||
|
||||
with app_module.vdl2_lock:
|
||||
if app_module.vdl2_process and app_module.vdl2_process.poll() is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'VDL2 decoder already running'
|
||||
}), 409
|
||||
return api_error('VDL2 decoder already running', 409)
|
||||
|
||||
# Check for dumpvdl2
|
||||
dumpvdl2_path = find_dumpvdl2()
|
||||
if not dumpvdl2_path:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'dumpvdl2 not found. Install from: https://github.com/szpajder/dumpvdl2'
|
||||
}), 400
|
||||
return api_error('dumpvdl2 not found. Install from: https://github.com/szpajder/dumpvdl2', 400)
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
@@ -202,7 +197,7 @@ def start_vdl2() -> Response:
|
||||
gain = validate_gain(data.get('gain', '40'))
|
||||
ppm = validate_ppm(data.get('ppm', '0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
return api_error(str(e), 400)
|
||||
|
||||
# Resolve SDR type for device selection
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
@@ -215,11 +210,7 @@ def start_vdl2() -> Response:
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'vdl2', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
return api_error(error, 409, error_type='DEVICE_BUSY')
|
||||
|
||||
vdl2_active_device = device_int
|
||||
vdl2_active_sdr_type = sdr_type_str
|
||||
@@ -312,7 +303,7 @@ def start_vdl2() -> Response:
|
||||
if stderr:
|
||||
error_msg += f': {stderr[:500]}'
|
||||
logger.error(error_msg)
|
||||
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||
return api_error(error_msg, 500)
|
||||
|
||||
app_module.vdl2_process = process
|
||||
register_process(process)
|
||||
@@ -339,7 +330,7 @@ def start_vdl2() -> Response:
|
||||
vdl2_active_device = None
|
||||
vdl2_active_sdr_type = None
|
||||
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'])
|
||||
@@ -349,10 +340,7 @@ def stop_vdl2() -> Response:
|
||||
|
||||
with app_module.vdl2_lock:
|
||||
if not app_module.vdl2_process:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'VDL2 decoder not running'
|
||||
}), 400
|
||||
return api_error('VDL2 decoder not running', 400)
|
||||
|
||||
try:
|
||||
app_module.vdl2_process.terminate()
|
||||
|
||||
+12
-18
@@ -10,6 +10,7 @@ import queue
|
||||
|
||||
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.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
|
||||
@@ -174,7 +175,7 @@ def start_capture():
|
||||
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
|
||||
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
|
||||
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)
|
||||
if not rtl_tcp_host:
|
||||
@@ -182,11 +183,7 @@ def start_capture():
|
||||
import app as app_module
|
||||
error = app_module.claim_sdr_device(device_index, 'weather_sat', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error,
|
||||
}), 409
|
||||
return api_error(error, 409, error_type='DEVICE_BUSY')
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
@@ -417,15 +414,15 @@ def get_image(filename: str):
|
||||
|
||||
# Security: only allow safe filenames
|
||||
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')):
|
||||
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
|
||||
|
||||
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'
|
||||
return send_file(image_path, mimetype=mimetype)
|
||||
@@ -444,12 +441,12 @@ def delete_image(filename: str):
|
||||
decoder = get_weather_sat_decoder()
|
||||
|
||||
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):
|
||||
return jsonify({'status': 'deleted', 'filename': filename})
|
||||
else:
|
||||
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||
return api_error('Image not found', 404)
|
||||
|
||||
|
||||
@weather_sat_bp.route('/images', methods=['DELETE'])
|
||||
@@ -500,17 +497,14 @@ def get_passes():
|
||||
raw_lon = request.args.get('longitude')
|
||||
|
||||
if raw_lat is None or raw_lon is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'latitude and longitude parameters required'
|
||||
}), 400
|
||||
return api_error('latitude and longitude parameters required', 400)
|
||||
|
||||
try:
|
||||
lat = validate_latitude(raw_lat)
|
||||
lon = validate_longitude(raw_lon)
|
||||
except ValueError as 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))
|
||||
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
|
||||
|
||||
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()
|
||||
if scheduler.skip_pass(pass_id):
|
||||
return jsonify({'status': 'skipped', 'pass_id': pass_id})
|
||||
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
@@ -13,6 +13,8 @@ from typing import Optional
|
||||
|
||||
from flask import Blueprint, Flask, jsonify, request, Response
|
||||
|
||||
from utils.responses import api_success, api_error
|
||||
|
||||
try:
|
||||
from flask_sock import Sock
|
||||
WEBSOCKET_AVAILABLE = True
|
||||
@@ -226,8 +228,7 @@ def list_receivers() -> Response:
|
||||
if r.get('freq_lo', 0) <= freq_khz <= r.get('freq_hi', 30000)
|
||||
]
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
return api_success(data={
|
||||
'receivers': filtered[:100],
|
||||
'total': len(filtered),
|
||||
'cached_total': len(receivers),
|
||||
@@ -242,7 +243,7 @@ def nearest_receivers() -> Response:
|
||||
freq_khz = request.args.get('freq_khz', type=float)
|
||||
|
||||
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()
|
||||
|
||||
@@ -264,10 +265,7 @@ def nearest_receivers() -> Response:
|
||||
|
||||
with_distance.sort(key=lambda x: x['distance_km'])
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'receivers': with_distance[:10],
|
||||
})
|
||||
return api_success(data={'receivers': with_distance[:10]})
|
||||
|
||||
|
||||
@websdr_bp.route('/spy-station/<station_id>/receivers')
|
||||
@@ -276,7 +274,7 @@ def spy_station_receivers(station_id: str) -> Response:
|
||||
try:
|
||||
from routes.spy_stations import STATIONS
|
||||
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
|
||||
station = None
|
||||
@@ -286,7 +284,7 @@ def spy_station_receivers(station_id: str) -> Response:
|
||||
break
|
||||
|
||||
if not station:
|
||||
return jsonify({'status': 'error', 'message': 'Station not found'}), 404
|
||||
return api_error('Station not found', 404)
|
||||
|
||||
# Get primary frequency
|
||||
freq_khz = None
|
||||
@@ -298,7 +296,7 @@ def spy_station_receivers(station_id: str) -> Response:
|
||||
freq_khz = station['frequencies'][0].get('freq_khz')
|
||||
|
||||
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()
|
||||
|
||||
@@ -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)
|
||||
]
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
return api_success(data={
|
||||
'station': {
|
||||
'id': station['id'],
|
||||
'name': station.get('name', ''),
|
||||
|
||||
+22
-67
@@ -10,6 +10,7 @@ import queue
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request, send_file
|
||||
|
||||
from utils.responses import api_success, api_error
|
||||
import app as app_module
|
||||
from utils.logging import get_logger
|
||||
from utils.sdr import SDRType
|
||||
@@ -109,10 +110,7 @@ def start_decoder():
|
||||
# Validate frequency (required)
|
||||
frequency_khz = data.get('frequency_khz')
|
||||
if frequency_khz is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'frequency_khz is required',
|
||||
}), 400
|
||||
return api_error('frequency_khz is required', 400)
|
||||
|
||||
try:
|
||||
frequency_khz = float(frequency_khz)
|
||||
@@ -120,10 +118,7 @@ def start_decoder():
|
||||
freq_mhz = frequency_khz / 1000.0
|
||||
validate_frequency(freq_mhz, min_mhz=2.0, max_mhz=30.0)
|
||||
except (TypeError, ValueError) as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Invalid frequency: {e}',
|
||||
}), 400
|
||||
return api_error(f'Invalid frequency: {e}', 400)
|
||||
|
||||
station = str(data.get('station', '')).strip()
|
||||
device_index = data.get('device', 0)
|
||||
@@ -152,34 +147,21 @@ def start_decoder():
|
||||
tuned_mhz = tuned_frequency_khz / 1000.0
|
||||
validate_frequency(tuned_mhz, min_mhz=2.0, max_mhz=30.0)
|
||||
except ValueError as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Invalid frequency settings: {e}',
|
||||
}), 400
|
||||
return api_error(f'Invalid frequency settings: {e}', 400)
|
||||
|
||||
# Validate IOC and LPM
|
||||
if ioc not in (288, 576):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'IOC must be 288 or 576',
|
||||
}), 400
|
||||
return api_error('IOC must be 288 or 576', 400)
|
||||
|
||||
if lpm not in (60, 120):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'LPM must be 60 or 120',
|
||||
}), 400
|
||||
return api_error('LPM must be 60 or 120', 400)
|
||||
|
||||
# Claim SDR device
|
||||
global wefax_active_device, wefax_active_sdr_type
|
||||
device_int = int(device_index)
|
||||
error = app_module.claim_sdr_device(device_int, 'wefax', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error,
|
||||
}), 409
|
||||
return api_error(error, 409, error_type='DEVICE_BUSY')
|
||||
|
||||
# Set callback and start
|
||||
decoder.set_callback(_progress_callback)
|
||||
@@ -213,10 +195,7 @@ def start_decoder():
|
||||
})
|
||||
else:
|
||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to start decoder',
|
||||
}), 500
|
||||
return api_error('Failed to start decoder', 500)
|
||||
|
||||
|
||||
@wefax_bp.route('/stop', methods=['POST'])
|
||||
@@ -275,14 +254,14 @@ def get_image(filename: str):
|
||||
decoder = get_wefax_decoder()
|
||||
|
||||
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'):
|
||||
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
|
||||
return api_error('Only PNG files supported', 400)
|
||||
|
||||
image_path = decoder._output_dir / filename
|
||||
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')
|
||||
|
||||
@@ -293,15 +272,15 @@ def delete_image(filename: str):
|
||||
decoder = get_wefax_decoder()
|
||||
|
||||
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'):
|
||||
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
|
||||
return api_error('Only PNG files supported', 400)
|
||||
|
||||
if decoder.delete_image(filename):
|
||||
return jsonify({'status': 'ok'})
|
||||
else:
|
||||
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||
return api_error('Image not found', 404)
|
||||
|
||||
|
||||
@wefax_bp.route('/images', methods=['DELETE'])
|
||||
@@ -354,27 +333,18 @@ def enable_schedule():
|
||||
|
||||
station = str(data.get('station', '')).strip()
|
||||
if not station:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'station is required',
|
||||
}), 400
|
||||
return api_error('station is required', 400)
|
||||
|
||||
frequency_khz = data.get('frequency_khz')
|
||||
if frequency_khz is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'frequency_khz is required',
|
||||
}), 400
|
||||
return api_error('frequency_khz is required', 400)
|
||||
|
||||
try:
|
||||
frequency_khz = float(frequency_khz)
|
||||
freq_mhz = frequency_khz / 1000.0
|
||||
validate_frequency(freq_mhz, min_mhz=2.0, max_mhz=30.0)
|
||||
except (TypeError, ValueError) as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Invalid frequency: {e}',
|
||||
}), 400
|
||||
return api_error(f'Invalid frequency: {e}', 400)
|
||||
|
||||
device = int(data.get('device', 0))
|
||||
gain = float(data.get('gain', 40.0))
|
||||
@@ -396,10 +366,7 @@ def enable_schedule():
|
||||
tuned_mhz = tuned_frequency_khz / 1000.0
|
||||
validate_frequency(tuned_mhz, min_mhz=2.0, max_mhz=30.0)
|
||||
except ValueError as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Invalid frequency settings: {e}',
|
||||
}), 400
|
||||
return api_error(f'Invalid frequency settings: {e}', 400)
|
||||
|
||||
scheduler = get_wefax_scheduler()
|
||||
scheduler.set_callbacks(_progress_callback, _scheduler_event_callback)
|
||||
@@ -416,10 +383,7 @@ def enable_schedule():
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to enable WeFax scheduler")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to enable scheduler',
|
||||
}), 500
|
||||
return api_error('Failed to enable scheduler', 500)
|
||||
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
@@ -473,19 +437,13 @@ def skip_broadcast(broadcast_id: str):
|
||||
from utils.wefax_scheduler import get_wefax_scheduler
|
||||
|
||||
if not broadcast_id.replace('_', '').replace('-', '').isalnum():
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Invalid broadcast ID',
|
||||
}), 400
|
||||
return api_error('Invalid broadcast ID', 400)
|
||||
|
||||
scheduler = get_wefax_scheduler()
|
||||
if scheduler.skip_broadcast(broadcast_id):
|
||||
return jsonify({'status': 'skipped', 'broadcast_id': broadcast_id})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Broadcast not found or already processed',
|
||||
}), 404
|
||||
return api_error('Broadcast not found or already processed', 404)
|
||||
|
||||
|
||||
@wefax_bp.route('/stations')
|
||||
@@ -504,10 +462,7 @@ def station_detail(callsign: str):
|
||||
"""Get station detail including current schedule info."""
|
||||
station = get_station(callsign)
|
||||
if not station:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Station {callsign} not found',
|
||||
}), 404
|
||||
return api_error(f'Station {callsign} not found', 404)
|
||||
|
||||
current = get_current_broadcasts(callsign)
|
||||
|
||||
|
||||
+212
-200
@@ -15,14 +15,15 @@ from typing import Any, Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
from utils.responses import api_success, api_error
|
||||
import app as app_module
|
||||
from utils.dependencies import check_tool, get_tool_path
|
||||
from utils.logging import wifi_logger as logger
|
||||
from utils.process import is_valid_mac, is_valid_channel
|
||||
from utils.validation import validate_wifi_channel, validate_mac_address, validate_network_interface
|
||||
from utils.sse import format_sse, sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from data.oui import get_manufacturer
|
||||
from utils.logging import wifi_logger as logger
|
||||
from utils.process import is_valid_mac, is_valid_channel
|
||||
from utils.validation import validate_wifi_channel, validate_mac_address, validate_network_interface
|
||||
from utils.sse import format_sse, sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from data.oui import get_manufacturer
|
||||
from utils.constants import (
|
||||
WIFI_TERMINATE_TIMEOUT,
|
||||
PMKID_TERMINATE_TIMEOUT,
|
||||
@@ -46,34 +47,52 @@ from utils.constants import (
|
||||
|
||||
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 = None
|
||||
pmkid_lock = threading.Lock()
|
||||
|
||||
|
||||
def _parse_channel_list(raw_channels: Any) -> list[int] | None:
|
||||
"""Parse a channel list from string/list input."""
|
||||
if raw_channels in (None, '', []):
|
||||
return None
|
||||
|
||||
if isinstance(raw_channels, str):
|
||||
parts = [p.strip() for p in re.split(r'[\s,]+', raw_channels) if p.strip()]
|
||||
elif isinstance(raw_channels, (list, tuple, set)):
|
||||
parts = list(raw_channels)
|
||||
else:
|
||||
parts = [raw_channels]
|
||||
|
||||
channels: list[int] = []
|
||||
seen = set()
|
||||
for part in parts:
|
||||
if part in (None, ''):
|
||||
continue
|
||||
ch = validate_wifi_channel(part)
|
||||
if ch not in seen:
|
||||
channels.append(ch)
|
||||
seen.add(ch)
|
||||
|
||||
return channels or None
|
||||
pmkid_process = None
|
||||
pmkid_lock = threading.Lock()
|
||||
|
||||
|
||||
def _parse_channel_list(raw_channels: Any) -> list[int] | None:
|
||||
"""Parse a channel list from string/list input."""
|
||||
if raw_channels in (None, '', []):
|
||||
return None
|
||||
|
||||
if isinstance(raw_channels, str):
|
||||
parts = [p.strip() for p in re.split(r'[\s,]+', raw_channels) if p.strip()]
|
||||
elif isinstance(raw_channels, (list, tuple, set)):
|
||||
parts = list(raw_channels)
|
||||
else:
|
||||
parts = [raw_channels]
|
||||
|
||||
channels: list[int] = []
|
||||
seen = set()
|
||||
for part in parts:
|
||||
if part in (None, ''):
|
||||
continue
|
||||
ch = validate_wifi_channel(part)
|
||||
if ch not in seen:
|
||||
channels.append(ch)
|
||||
seen.add(ch)
|
||||
|
||||
return channels or None
|
||||
|
||||
|
||||
def detect_wifi_interfaces():
|
||||
@@ -455,7 +474,7 @@ def toggle_monitor_mode():
|
||||
try:
|
||||
interface = validate_network_interface(data.get('interface'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
return api_error(str(e), 400)
|
||||
|
||||
if action == 'start':
|
||||
if check_tool('airmon-ng'):
|
||||
@@ -575,20 +594,17 @@ def toggle_monitor_mode():
|
||||
all_wireless = [f for f in os.listdir('/sys/class/net')
|
||||
if os.path.exists(f'/sys/class/net/{f}/wireless') or 'mon' in f or f.startswith('wl')]
|
||||
logger.error(f"Monitor interface not found. Tried: {monitor_iface}. Available: {all_wireless}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Monitor interface not created. airmon-ng output: {output[:500]}. Available interfaces: {all_wireless}'
|
||||
})
|
||||
return api_error(f'Monitor interface not created. airmon-ng output: {output[:500]}. Available interfaces: {all_wireless}')
|
||||
|
||||
app_module.wifi_monitor_interface = monitor_iface
|
||||
app_module.wifi_queue.put({'type': 'info', 'text': f'Monitor mode enabled on {app_module.wifi_monitor_interface}'})
|
||||
logger.info(f"Monitor mode enabled on {monitor_iface}")
|
||||
return jsonify({'status': 'success', 'monitor_interface': app_module.wifi_monitor_interface})
|
||||
return api_success(data={'monitor_interface': app_module.wifi_monitor_interface})
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
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'):
|
||||
try:
|
||||
@@ -596,11 +612,11 @@ def toggle_monitor_mode():
|
||||
subprocess.run(['iw', interface, 'set', 'monitor', 'control'], capture_output=True)
|
||||
subprocess.run(['ip', 'link', 'set', interface, 'up'], capture_output=True)
|
||||
app_module.wifi_monitor_interface = interface
|
||||
return jsonify({'status': 'success', 'monitor_interface': interface})
|
||||
return api_success(data={'monitor_interface': interface})
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
return api_error(str(e))
|
||||
else:
|
||||
return jsonify({'status': 'error', 'message': 'No monitor mode tools available.'})
|
||||
return api_error('No monitor mode tools available.')
|
||||
|
||||
else: # stop
|
||||
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],
|
||||
capture_output=True, text=True, timeout=15)
|
||||
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:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
return api_error(str(e))
|
||||
elif check_tool('iw'):
|
||||
try:
|
||||
subprocess.run(['ip', 'link', 'set', interface, 'down'], capture_output=True)
|
||||
subprocess.run(['iw', interface, 'set', 'type', 'managed'], capture_output=True)
|
||||
subprocess.run(['ip', 'link', 'set', interface, 'up'], capture_output=True)
|
||||
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:
|
||||
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'])
|
||||
@@ -630,12 +646,12 @@ def start_wifi_scan():
|
||||
"""Start WiFi scanning with airodump-ng."""
|
||||
with app_module.wifi_lock:
|
||||
if app_module.wifi_process:
|
||||
return jsonify({'status': 'error', 'message': 'Scan already running'})
|
||||
return api_error('Scan already running')
|
||||
|
||||
data = request.json
|
||||
channel = data.get('channel')
|
||||
channels = data.get('channels')
|
||||
band = data.get('band', 'abg')
|
||||
channel = data.get('channel')
|
||||
channels = data.get('channels')
|
||||
band = data.get('band', 'abg')
|
||||
|
||||
# Use provided interface or fall back to stored monitor interface
|
||||
interface = data.get('interface')
|
||||
@@ -643,21 +659,18 @@ def start_wifi_scan():
|
||||
try:
|
||||
interface = validate_network_interface(interface)
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
return api_error(str(e), 400)
|
||||
else:
|
||||
interface = app_module.wifi_monitor_interface
|
||||
|
||||
if not interface:
|
||||
return jsonify({'status': 'error', 'message': 'No monitor interface available.'})
|
||||
return api_error('No monitor interface available.')
|
||||
|
||||
# Verify interface exists
|
||||
if not os.path.exists(f'/sys/class/net/{interface}'):
|
||||
all_wireless = [f for f in os.listdir('/sys/class/net')
|
||||
if os.path.exists(f'/sys/class/net/{f}/wireless') or 'mon' in f or f.startswith('wl')]
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Interface "{interface}" does not exist. Available: {all_wireless}'
|
||||
})
|
||||
return api_error(f'Interface "{interface}" does not exist. Available: {all_wireless}')
|
||||
|
||||
app_module.wifi_networks = {}
|
||||
app_module.wifi_clients = {}
|
||||
@@ -685,17 +698,17 @@ def start_wifi_scan():
|
||||
interface
|
||||
]
|
||||
|
||||
channel_list = None
|
||||
if channels:
|
||||
try:
|
||||
channel_list = _parse_channel_list(channels)
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
if channel_list:
|
||||
cmd.extend(['-c', ','.join(str(c) for c in channel_list)])
|
||||
elif channel:
|
||||
cmd.extend(['-c', str(channel)])
|
||||
channel_list = None
|
||||
if channels:
|
||||
try:
|
||||
channel_list = _parse_channel_list(channels)
|
||||
except ValueError as e:
|
||||
return api_error(str(e), 400)
|
||||
|
||||
if channel_list:
|
||||
cmd.extend(['-c', ','.join(str(c) for c in channel_list)])
|
||||
elif channel:
|
||||
cmd.extend(['-c', str(channel)])
|
||||
|
||||
logger.info(f"Running: {' '.join(cmd)}")
|
||||
|
||||
@@ -723,7 +736,7 @@ def start_wifi_scan():
|
||||
error_msg = 'Permission denied. Try running with sudo.'
|
||||
|
||||
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.daemon = True
|
||||
@@ -734,9 +747,9 @@ def start_wifi_scan():
|
||||
return jsonify({'status': 'started', 'interface': interface})
|
||||
|
||||
except FileNotFoundError:
|
||||
return jsonify({'status': 'error', 'message': 'airodump-ng not found.'})
|
||||
return api_error('airodump-ng not found.')
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
return api_error(str(e))
|
||||
|
||||
|
||||
@wifi_bp.route('/scan/stop', methods=['POST'])
|
||||
@@ -768,18 +781,18 @@ def send_deauth():
|
||||
try:
|
||||
interface = validate_network_interface(interface)
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
return api_error(str(e), 400)
|
||||
else:
|
||||
interface = app_module.wifi_monitor_interface
|
||||
|
||||
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):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid BSSID format'})
|
||||
return api_error('Invalid BSSID format')
|
||||
|
||||
if not is_valid_mac(target_client):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid client MAC format'})
|
||||
return api_error('Invalid client MAC format')
|
||||
|
||||
try:
|
||||
count = int(count)
|
||||
@@ -789,10 +802,10 @@ def send_deauth():
|
||||
count = 5
|
||||
|
||||
if not interface:
|
||||
return jsonify({'status': 'error', 'message': 'No monitor interface'})
|
||||
return api_error('No monitor interface')
|
||||
|
||||
if not check_tool('aireplay-ng'):
|
||||
return jsonify({'status': 'error', 'message': 'aireplay-ng not found'})
|
||||
return api_error('aireplay-ng not found')
|
||||
|
||||
try:
|
||||
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)
|
||||
|
||||
if result.returncode == 0:
|
||||
return jsonify({'status': 'success', 'message': f'Sent {count} deauth packets'})
|
||||
return api_success(message=f'Sent {count} deauth packets')
|
||||
else:
|
||||
return jsonify({'status': 'error', 'message': result.stderr})
|
||||
return api_error(result.stderr)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return jsonify({'status': 'success', 'message': 'Deauth sent (timed out)'})
|
||||
return api_success(message='Deauth sent (timed out)')
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
return api_error(str(e))
|
||||
|
||||
|
||||
@wifi_bp.route('/handshake/capture', methods=['POST'])
|
||||
@@ -832,22 +845,22 @@ def capture_handshake():
|
||||
try:
|
||||
interface = validate_network_interface(interface)
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
return api_error(str(e), 400)
|
||||
else:
|
||||
interface = app_module.wifi_monitor_interface
|
||||
|
||||
if not target_bssid or not channel:
|
||||
return jsonify({'status': 'error', 'message': 'BSSID and channel required'})
|
||||
return api_error('BSSID and channel required')
|
||||
|
||||
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):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid channel'})
|
||||
return api_error('Invalid channel')
|
||||
|
||||
with app_module.wifi_lock:
|
||||
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(":", "")}'
|
||||
|
||||
@@ -866,7 +879,7 @@ def capture_handshake():
|
||||
app_module.wifi_queue.put({'type': 'info', 'text': f'Capturing handshakes for {target_bssid}'})
|
||||
return jsonify({'status': 'started', 'capture_file': capture_path + '-01.cap'})
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
return api_error(str(e))
|
||||
|
||||
|
||||
@wifi_bp.route('/handshake/status', methods=['POST'])
|
||||
@@ -877,7 +890,7 @@ def check_handshake_status():
|
||||
target_bssid = data.get('bssid', '')
|
||||
|
||||
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):
|
||||
with app_module.wifi_lock:
|
||||
@@ -887,53 +900,53 @@ def check_handshake_status():
|
||||
return jsonify({'status': 'stopped', 'file_exists': False, 'handshake_found': False})
|
||||
|
||||
file_size = os.path.getsize(capture_file)
|
||||
handshake_found = False
|
||||
handshake_valid: bool | None = None
|
||||
handshake_checked = False
|
||||
handshake_reason: str | None = None
|
||||
handshake_found = False
|
||||
handshake_valid: bool | None = None
|
||||
handshake_checked = False
|
||||
handshake_reason: str | None = None
|
||||
|
||||
try:
|
||||
if target_bssid and is_valid_mac(target_bssid):
|
||||
aircrack_path = get_tool_path('aircrack-ng')
|
||||
if aircrack_path:
|
||||
result = subprocess.run(
|
||||
[aircrack_path, '-a', '2', '-b', target_bssid, capture_file],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
output = result.stdout + result.stderr
|
||||
output_lower = output.lower()
|
||||
handshake_checked = True
|
||||
|
||||
if 'no valid wpa handshakes found' in output_lower:
|
||||
handshake_valid = False
|
||||
handshake_reason = 'No valid WPA handshake found'
|
||||
elif '0 handshake' in output_lower:
|
||||
handshake_valid = False
|
||||
elif '1 handshake' in output_lower or ('handshake' in output_lower and 'wpa' in output_lower):
|
||||
handshake_valid = True
|
||||
else:
|
||||
handshake_valid = False
|
||||
if target_bssid and is_valid_mac(target_bssid):
|
||||
aircrack_path = get_tool_path('aircrack-ng')
|
||||
if aircrack_path:
|
||||
result = subprocess.run(
|
||||
[aircrack_path, '-a', '2', '-b', target_bssid, capture_file],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
output = result.stdout + result.stderr
|
||||
output_lower = output.lower()
|
||||
handshake_checked = True
|
||||
|
||||
if 'no valid wpa handshakes found' in output_lower:
|
||||
handshake_valid = False
|
||||
handshake_reason = 'No valid WPA handshake found'
|
||||
elif '0 handshake' in output_lower:
|
||||
handshake_valid = False
|
||||
elif '1 handshake' in output_lower or ('handshake' in output_lower and 'wpa' in output_lower):
|
||||
handshake_valid = True
|
||||
else:
|
||||
handshake_valid = False
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking handshake: {e}")
|
||||
|
||||
if handshake_valid:
|
||||
handshake_found = True
|
||||
normalized_bssid = target_bssid.upper() if target_bssid else None
|
||||
if normalized_bssid and normalized_bssid not in app_module.wifi_handshakes:
|
||||
app_module.wifi_handshakes.append(normalized_bssid)
|
||||
|
||||
return jsonify({
|
||||
'status': 'running' if app_module.wifi_process and app_module.wifi_process.poll() is None else 'stopped',
|
||||
'file_exists': True,
|
||||
'file_size': file_size,
|
||||
'file': capture_file,
|
||||
'handshake_found': handshake_found,
|
||||
'handshake_valid': handshake_valid,
|
||||
'handshake_checked': handshake_checked,
|
||||
'handshake_reason': handshake_reason
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking handshake: {e}")
|
||||
|
||||
if handshake_valid:
|
||||
handshake_found = True
|
||||
normalized_bssid = target_bssid.upper() if target_bssid else None
|
||||
if normalized_bssid and normalized_bssid not in app_module.wifi_handshakes:
|
||||
app_module.wifi_handshakes.append(normalized_bssid)
|
||||
|
||||
return jsonify({
|
||||
'status': 'running' if app_module.wifi_process and app_module.wifi_process.poll() is None else 'stopped',
|
||||
'file_exists': True,
|
||||
'file_size': file_size,
|
||||
'file': capture_file,
|
||||
'handshake_found': handshake_found,
|
||||
'handshake_valid': handshake_valid,
|
||||
'handshake_checked': handshake_checked,
|
||||
'handshake_reason': handshake_reason
|
||||
})
|
||||
|
||||
|
||||
@wifi_bp.route('/pmkid/capture', methods=['POST'])
|
||||
@@ -951,19 +964,19 @@ def capture_pmkid():
|
||||
try:
|
||||
interface = validate_network_interface(interface)
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
return api_error(str(e), 400)
|
||||
else:
|
||||
interface = app_module.wifi_monitor_interface
|
||||
|
||||
if not target_bssid:
|
||||
return jsonify({'status': 'error', 'message': 'BSSID required'})
|
||||
return api_error('BSSID required')
|
||||
|
||||
if not is_valid_mac(target_bssid):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid BSSID format'})
|
||||
return api_error('Invalid BSSID format')
|
||||
|
||||
with pmkid_lock:
|
||||
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'
|
||||
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)
|
||||
return jsonify({'status': 'started', 'file': capture_path})
|
||||
except FileNotFoundError:
|
||||
return jsonify({'status': 'error', 'message': 'hcxdumptool not found.'})
|
||||
return api_error('hcxdumptool not found.')
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
return api_error(str(e))
|
||||
|
||||
|
||||
@wifi_bp.route('/pmkid/status', methods=['POST'])
|
||||
@@ -998,7 +1011,7 @@ def check_pmkid_status():
|
||||
capture_file = data.get('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):
|
||||
return jsonify({'pmkid_found': False, 'file_exists': False})
|
||||
@@ -1054,23 +1067,23 @@ def crack_handshake():
|
||||
|
||||
# Validate paths to prevent path traversal
|
||||
if not capture_file.startswith('/tmp/intercept_handshake_') or '..' in capture_file:
|
||||
return jsonify({'status': 'error', 'message': 'Invalid capture file path'}), 400
|
||||
return api_error('Invalid capture file path', 400)
|
||||
|
||||
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):
|
||||
return jsonify({'status': 'error', 'message': 'Capture file not found'}), 404
|
||||
return api_error('Capture file not found', 404)
|
||||
|
||||
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):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid BSSID format'}), 400
|
||||
return api_error('Invalid BSSID format', 400)
|
||||
|
||||
aircrack_path = get_tool_path('aircrack-ng')
|
||||
if not aircrack_path:
|
||||
return jsonify({'status': 'error', 'message': 'aircrack-ng not found'}), 500
|
||||
return api_error('aircrack-ng not found', 500)
|
||||
|
||||
try:
|
||||
cmd = [aircrack_path, '-a', '2', '-w', wordlist]
|
||||
@@ -1099,8 +1112,7 @@ def crack_handshake():
|
||||
if match:
|
||||
password = match.group(1)
|
||||
logger.info(f"Password cracked for {target_bssid}: {password}")
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
return api_success(data={
|
||||
'password': password,
|
||||
'bssid': target_bssid
|
||||
})
|
||||
@@ -1118,7 +1130,7 @@ def crack_handshake():
|
||||
})
|
||||
except Exception as 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')
|
||||
@@ -1132,26 +1144,26 @@ def get_wifi_networks():
|
||||
})
|
||||
|
||||
|
||||
@wifi_bp.route('/stream')
|
||||
def stream_wifi():
|
||||
"""SSE stream for WiFi events."""
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('wifi', msg, msg.get('type'))
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.wifi_queue,
|
||||
channel_key='wifi',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
on_message=_on_msg,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
@wifi_bp.route('/stream')
|
||||
def stream_wifi():
|
||||
"""SSE stream for WiFi events."""
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('wifi', msg, msg.get('type'))
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.wifi_queue,
|
||||
channel_key='wifi',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
on_message=_on_msg,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -1189,7 +1201,7 @@ def get_v2_capabilities():
|
||||
})
|
||||
except Exception as e:
|
||||
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'])
|
||||
@@ -1220,7 +1232,7 @@ def v2_quick_scan():
|
||||
})
|
||||
except Exception as e:
|
||||
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'])
|
||||
@@ -1239,10 +1251,10 @@ def v2_start_scan():
|
||||
return jsonify({'status': 'started'})
|
||||
else:
|
||||
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:
|
||||
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'])
|
||||
@@ -1254,7 +1266,7 @@ def v2_stop_scan():
|
||||
return jsonify({'status': 'stopped'})
|
||||
except Exception as e:
|
||||
logger.exception("Error stopping scan")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/scan/status')
|
||||
@@ -1274,7 +1286,7 @@ def v2_scan_status():
|
||||
})
|
||||
except Exception as e:
|
||||
logger.exception("Error getting scan status")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/networks')
|
||||
@@ -1289,7 +1301,7 @@ def v2_get_networks():
|
||||
})
|
||||
except Exception as e:
|
||||
logger.exception("Error getting networks")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/clients')
|
||||
@@ -1326,7 +1338,7 @@ def v2_get_clients():
|
||||
})
|
||||
except Exception as e:
|
||||
logger.exception("Error getting clients")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/probes')
|
||||
@@ -1341,7 +1353,7 @@ def v2_get_probes():
|
||||
})
|
||||
except Exception as e:
|
||||
logger.exception("Error getting probes")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/channels')
|
||||
@@ -1357,7 +1369,7 @@ def v2_get_channels():
|
||||
})
|
||||
except Exception as e:
|
||||
logger.exception("Error getting channel stats")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/stream')
|
||||
@@ -1448,11 +1460,11 @@ def v2_export():
|
||||
return response
|
||||
|
||||
else:
|
||||
return jsonify({'error': f'Unknown format: {format_type}'}), 400
|
||||
return api_error(f'Unknown format: {format_type}', 400)
|
||||
|
||||
except Exception as e:
|
||||
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'])
|
||||
@@ -1464,7 +1476,7 @@ def v2_set_baseline():
|
||||
return jsonify({'status': 'baseline_set', 'count': len(scanner._baseline_networks)})
|
||||
except Exception as e:
|
||||
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'])
|
||||
@@ -1476,7 +1488,7 @@ def v2_clear_baseline():
|
||||
return jsonify({'status': 'baseline_cleared'})
|
||||
except Exception as e:
|
||||
logger.exception("Error clearing baseline")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/clear', methods=['POST'])
|
||||
@@ -1488,7 +1500,7 @@ def v2_clear_data():
|
||||
return jsonify({'status': 'cleared'})
|
||||
except Exception as e:
|
||||
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:
|
||||
logger.exception("Error getting deauth status")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/deauth/stream')
|
||||
def v2_deauth_stream():
|
||||
@wifi_bp.route('/v2/deauth/stream')
|
||||
def v2_deauth_stream():
|
||||
"""
|
||||
SSE stream for real-time deauth alerts.
|
||||
|
||||
@@ -1550,18 +1562,18 @@ def v2_deauth_stream():
|
||||
- deauth_error: An error occurred
|
||||
- keepalive: Periodic keepalive
|
||||
"""
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.deauth_detector_queue,
|
||||
channel_key='wifi_deauth',
|
||||
timeout=SSE_QUEUE_TIMEOUT,
|
||||
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.deauth_detector_queue,
|
||||
channel_key='wifi_deauth',
|
||||
timeout=SSE_QUEUE_TIMEOUT,
|
||||
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
|
||||
@@ -1600,7 +1612,7 @@ def v2_deauth_alerts():
|
||||
})
|
||||
except Exception as e:
|
||||
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'])
|
||||
@@ -1620,4 +1632,4 @@ def v2_deauth_clear():
|
||||
return jsonify({'status': 'cleared'})
|
||||
except Exception as e:
|
||||
logger.exception("Error clearing deauth alerts")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
+54
-56
@@ -16,16 +16,17 @@ from typing import Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
from utils.wifi import (
|
||||
get_wifi_scanner,
|
||||
analyze_channels,
|
||||
get_hidden_correlator,
|
||||
SCAN_MODE_QUICK,
|
||||
SCAN_MODE_DEEP,
|
||||
)
|
||||
from utils.sse import format_sse
|
||||
from utils.validation import validate_wifi_channel
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.wifi import (
|
||||
get_wifi_scanner,
|
||||
analyze_channels,
|
||||
get_hidden_correlator,
|
||||
SCAN_MODE_QUICK,
|
||||
SCAN_MODE_DEEP,
|
||||
)
|
||||
from utils.responses import api_success, api_error
|
||||
from utils.sse import format_sse
|
||||
from utils.validation import validate_wifi_channel
|
||||
from utils.event_pipeline import process_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -87,44 +88,44 @@ def start_deep_scan():
|
||||
|
||||
Requires monitor mode interface and root privileges.
|
||||
|
||||
Request body:
|
||||
interface: Monitor mode interface (e.g., 'wlan0mon')
|
||||
band: Band to scan ('2.4', '5', 'all')
|
||||
channel: Optional specific channel to monitor
|
||||
channels: Optional list or comma-separated channels to monitor
|
||||
Request body:
|
||||
interface: Monitor mode interface (e.g., 'wlan0mon')
|
||||
band: Band to scan ('2.4', '5', 'all')
|
||||
channel: Optional specific channel to monitor
|
||||
channels: Optional list or comma-separated channels to monitor
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
interface = data.get('interface')
|
||||
band = data.get('band', 'all')
|
||||
channel = data.get('channel')
|
||||
channels = data.get('channels')
|
||||
|
||||
channel_list = None
|
||||
if channels:
|
||||
if isinstance(channels, str):
|
||||
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
|
||||
elif isinstance(channels, (list, tuple, set)):
|
||||
channel_list = list(channels)
|
||||
else:
|
||||
channel_list = [channels]
|
||||
try:
|
||||
channel_list = [validate_wifi_channel(c) for c in channel_list]
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({'error': 'Invalid channels'}), 400
|
||||
|
||||
if channel:
|
||||
try:
|
||||
channel = validate_wifi_channel(channel)
|
||||
except ValueError:
|
||||
return jsonify({'error': 'Invalid channel'}), 400
|
||||
channel = data.get('channel')
|
||||
channels = data.get('channels')
|
||||
|
||||
channel_list = None
|
||||
if channels:
|
||||
if isinstance(channels, str):
|
||||
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
|
||||
elif isinstance(channels, (list, tuple, set)):
|
||||
channel_list = list(channels)
|
||||
else:
|
||||
channel_list = [channels]
|
||||
try:
|
||||
channel_list = [validate_wifi_channel(c) for c in channel_list]
|
||||
except (TypeError, ValueError):
|
||||
return api_error('Invalid channels', 400)
|
||||
|
||||
if channel:
|
||||
try:
|
||||
channel = validate_wifi_channel(channel)
|
||||
except ValueError:
|
||||
return api_error('Invalid channel', 400)
|
||||
|
||||
scanner = get_wifi_scanner()
|
||||
success = scanner.start_deep_scan(
|
||||
interface=interface,
|
||||
band=band,
|
||||
channel=channel,
|
||||
channels=channel_list,
|
||||
)
|
||||
success = scanner.start_deep_scan(
|
||||
interface=interface,
|
||||
band=band,
|
||||
channel=channel,
|
||||
channels=channel_list,
|
||||
)
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
@@ -133,10 +134,7 @@ def start_deep_scan():
|
||||
'interface': interface or scanner._capabilities.monitor_interface,
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error': scanner._status.error,
|
||||
}), 400
|
||||
return api_error(scanner._status.error or 'Scan failed', 400)
|
||||
|
||||
|
||||
@wifi_v2_bp.route('/scan/stop', methods=['POST'])
|
||||
@@ -235,7 +233,7 @@ def get_network(bssid):
|
||||
if network:
|
||||
return jsonify(network.to_dict())
|
||||
else:
|
||||
return jsonify({'error': 'Network not found'}), 404
|
||||
return api_error('Network not found', 404)
|
||||
|
||||
|
||||
@wifi_v2_bp.route('/clients', methods=['GET'])
|
||||
@@ -282,7 +280,7 @@ def get_client(mac):
|
||||
if client:
|
||||
return jsonify(client.to_dict())
|
||||
else:
|
||||
return jsonify({'error': 'Client not found'}), 404
|
||||
return api_error('Client not found', 404)
|
||||
|
||||
|
||||
@wifi_v2_bp.route('/probes', methods=['GET'])
|
||||
@@ -406,14 +404,14 @@ def event_stream():
|
||||
- keepalive: Periodic keepalive
|
||||
"""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
scanner = get_wifi_scanner()
|
||||
|
||||
for event in scanner.get_event_stream():
|
||||
try:
|
||||
process_event('wifi', event, event.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
yield format_sse(event)
|
||||
scanner = get_wifi_scanner()
|
||||
|
||||
for event in scanner.get_event_stream():
|
||||
try:
|
||||
process_event('wifi', event, event.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
yield format_sse(event)
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
|
||||
@@ -1485,9 +1485,10 @@ install_tool_acarsdec() {
|
||||
fi
|
||||
else
|
||||
if ! cmd_exists acarsdec; then
|
||||
apt_install acarsdec || true
|
||||
install_acarsdec_from_source_debian
|
||||
else
|
||||
ok "acarsdec already installed"
|
||||
fi
|
||||
cmd_exists acarsdec || install_acarsdec_from_source_debian
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
@@ -86,6 +86,11 @@ done
|
||||
export INTERCEPT_HOST="$HOST"
|
||||
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 ────────────────────────
|
||||
# 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*
|
||||
@@ -152,7 +157,17 @@ fi
|
||||
|
||||
# ── Resolve LAN address for display ──────────────────────────────────────────
|
||||
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}"
|
||||
else
|
||||
LAN_IP="$HOST"
|
||||
|
||||
@@ -2449,7 +2449,7 @@ body {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
@media (max-width: 480px) {
|
||||
.squawk-item {
|
||||
grid-template-columns: 45px 80px 1fr;
|
||||
gap: 8px;
|
||||
|
||||
@@ -684,7 +684,7 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
@media (max-width: 768px) {
|
||||
.controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
@@ -522,7 +522,7 @@
|
||||
/* ============================================
|
||||
RESPONSIVE ADJUSTMENTS
|
||||
============================================ */
|
||||
@media (max-width: 600px) {
|
||||
@media (max-width: 480px) {
|
||||
.device-signal-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
@@ -841,7 +841,7 @@
|
||||
/* ============================================
|
||||
RESPONSIVE MODAL
|
||||
============================================ */
|
||||
@media (max-width: 600px) {
|
||||
@media (max-width: 480px) {
|
||||
.modal-signal-stats {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@@ -1128,7 +1128,7 @@
|
||||
}
|
||||
|
||||
/* Responsive adjustments for aggregated meters */
|
||||
@media (max-width: 500px) {
|
||||
@media (max-width: 480px) {
|
||||
.meter-aggregated-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: auto auto;
|
||||
@@ -1922,7 +1922,7 @@
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 500px) {
|
||||
@media (max-width: 480px) {
|
||||
.signal-details-modal-content {
|
||||
width: 95%;
|
||||
max-height: 90vh;
|
||||
|
||||
@@ -429,7 +429,7 @@
|
||||
border-color: rgba(31, 95, 168, 0.45);
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
@media (max-width: 1023px) {
|
||||
.run-state-strip {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
@@ -440,7 +440,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
@media (max-width: 768px) {
|
||||
.command-palette-overlay {
|
||||
padding: 8vh 10px 0;
|
||||
}
|
||||
|
||||
+79
-76
@@ -21,36 +21,36 @@ html {
|
||||
tab-size: 4;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-base);
|
||||
line-height: var(--leading-normal);
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-primary);
|
||||
background-image:
|
||||
radial-gradient(1200px 620px at 8% -12%, var(--ambient-top-left), transparent 62%),
|
||||
radial-gradient(980px 560px at 92% -16%, var(--ambient-top-right), transparent 64%),
|
||||
radial-gradient(900px 520px at 50% 126%, var(--ambient-bottom), transparent 68%),
|
||||
var(--noise-image),
|
||||
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
||||
background-size: auto, auto, auto, 40px 40px, 48px 48px, 48px 48px;
|
||||
background-attachment: fixed;
|
||||
min-height: 100vh;
|
||||
font-variant-numeric: tabular-nums;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-base);
|
||||
line-height: var(--leading-normal);
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-primary);
|
||||
background-image:
|
||||
radial-gradient(1200px 620px at 8% -12%, var(--ambient-top-left), transparent 62%),
|
||||
radial-gradient(980px 560px at 92% -16%, var(--ambient-top-right), transparent 64%),
|
||||
radial-gradient(900px 520px at 50% 126%, var(--ambient-bottom), transparent 68%),
|
||||
var(--noise-image),
|
||||
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
||||
background-size: auto, auto, auto, 40px 40px, 48px 48px, 48px 48px;
|
||||
background-attachment: fixed;
|
||||
min-height: 100vh;
|
||||
font-variant-numeric: tabular-nums;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
TYPOGRAPHY
|
||||
============================================ */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-weight: var(--font-semibold);
|
||||
line-height: var(--leading-tight);
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-weight: var(--font-semibold);
|
||||
line-height: var(--leading-tight);
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
h1 { font-size: var(--text-4xl); }
|
||||
h2 { font-size: var(--text-3xl); }
|
||||
@@ -91,20 +91,23 @@ code, kbd, pre, samp {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
code {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
code {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
overflow-x: auto;
|
||||
}
|
||||
pre {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
pre code {
|
||||
background: none;
|
||||
@@ -135,38 +138,38 @@ button:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
color: var(--text-primary);
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
color: var(--text-primary);
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-cyan);
|
||||
box-shadow: 0 0 0 2px var(--accent-cyan-dim);
|
||||
}
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-cyan);
|
||||
box-shadow: 0 0 0 2px var(--accent-cyan-dim);
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
select {
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239fb0c7' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 8px center;
|
||||
padding-right: 28px;
|
||||
}
|
||||
select {
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239fb0c7' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 8px center;
|
||||
padding-right: 28px;
|
||||
}
|
||||
|
||||
input[type="checkbox"],
|
||||
input[type="radio"] {
|
||||
@@ -201,18 +204,18 @@ td {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-tertiary);
|
||||
text-transform: uppercase;
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
tr:hover td {
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
th {
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-tertiary);
|
||||
text-transform: uppercase;
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
tr:hover td {
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
LISTS
|
||||
|
||||
@@ -80,8 +80,20 @@
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: #dc2626;
|
||||
border-color: #dc2626;
|
||||
background: 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 {
|
||||
@@ -91,8 +103,8 @@
|
||||
}
|
||||
|
||||
.btn-success:hover:not(:disabled) {
|
||||
background: #16a34a;
|
||||
border-color: #16a34a;
|
||||
background: var(--accent-green-hover);
|
||||
border-color: var(--accent-green-hover);
|
||||
}
|
||||
|
||||
/* Button sizes */
|
||||
@@ -415,6 +427,28 @@
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Button loading state */
|
||||
.btn-loading {
|
||||
position: relative;
|
||||
color: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.btn-loading::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
margin-top: -7px;
|
||||
margin-left: -7px;
|
||||
border: 2px solid var(--border-color);
|
||||
border-top-color: var(--accent-cyan);
|
||||
border-radius: var(--radius-full);
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
/* Loading overlay */
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
@@ -855,3 +889,266 @@ textarea:focus {
|
||||
cursor: not-allowed;
|
||||
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
|
||||
============================================ */
|
||||
.confirm-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(2px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: var(--z-modal);
|
||||
animation: fadeIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
.confirm-modal {
|
||||
background: var(--surface-panel-gradient);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-6);
|
||||
min-width: 320px;
|
||||
max-width: 440px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.confirm-modal-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.confirm-modal-message {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
line-height: var(--leading-normal);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.confirm-modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
MODE SECTION TRANSITIONS
|
||||
============================================ */
|
||||
.mode-section {
|
||||
display: none;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.mode-section.active {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
SWITCHMODE TOGGLE CLASSES
|
||||
Elements hidden by default, shown via .active
|
||||
============================================ */
|
||||
|
||||
/* Stats sections in header (pager, sensor, wifi, satellite, aircraft) */
|
||||
#pagerStats,
|
||||
#sensorStats,
|
||||
#aircraftStats,
|
||||
#wifiStats,
|
||||
#satelliteStats {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#pagerStats.active,
|
||||
#sensorStats.active,
|
||||
#aircraftStats.active,
|
||||
#wifiStats.active,
|
||||
#satelliteStats.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Signal meter */
|
||||
#signalMeter {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#signalMeter.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Dashboard buttons in nav */
|
||||
#adsbDashboardBtn,
|
||||
#satelliteDashboardBtn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#adsbDashboardBtn.active,
|
||||
#satelliteDashboardBtn.active {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/* Layout containers (wifi, bluetooth) */
|
||||
.wifi-layout-container,
|
||||
.bt-layout-container {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wifi-layout-container.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.bt-layout-container.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Visuals containers */
|
||||
#aircraftVisuals {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#aircraftVisuals.active {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
#satelliteVisuals {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#satelliteVisuals.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* RTL-SDR device section */
|
||||
#rtlDeviceSection {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#rtlDeviceSection.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Tool status sections */
|
||||
.tool-status-section {
|
||||
display: none;
|
||||
grid-template-columns: auto auto;
|
||||
gap: 4px 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tool-status-section.active {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
/* Output console and status bar — visible when mode is active, hidden for full-visual modes.
|
||||
switchMode() adds/removes .active; hidden by default until a mode is selected. */
|
||||
.app-shell #output:not(.active) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-shell .status-bar:not(.active) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Recon panel — controlled by switchMode */
|
||||
.app-shell #reconPanel:not(.active) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
UTILITY CLASSES
|
||||
============================================ */
|
||||
.hidden { display: none; }
|
||||
.d-flex { display: flex; }
|
||||
.d-grid { display: grid; }
|
||||
.gap-2 { gap: var(--space-2); }
|
||||
.gap-4 { gap: var(--space-4); }
|
||||
.text-center { text-align: center; }
|
||||
.w-full { width: 100%; }
|
||||
|
||||
/* Keyboard focus indicator for list items */
|
||||
.keyboard-focused {
|
||||
outline: 2px solid var(--accent-cyan);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* Overflow indicator */
|
||||
.list-overflow-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-dim);
|
||||
border-top: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.list-overflow-indicator .btn {
|
||||
font-size: var(--text-xs);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
}
|
||||
|
||||
+336
-207
@@ -22,31 +22,31 @@
|
||||
/* ============================================
|
||||
GLOBAL HEADER
|
||||
============================================ */
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: var(--header-height);
|
||||
padding: 0 var(--space-4);
|
||||
background: linear-gradient(180deg, var(--bg-elevated) 0%, var(--bg-secondary) 100%);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: var(--z-sticky);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.app-header::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: var(--header-height);
|
||||
padding: 0 var(--space-4);
|
||||
background: linear-gradient(180deg, var(--bg-elevated) 0%, var(--bg-secondary) 100%);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: var(--z-sticky);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.app-header::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.app-header-left {
|
||||
display: flex;
|
||||
@@ -129,29 +129,29 @@
|
||||
/* ============================================
|
||||
GLOBAL NAVIGATION
|
||||
============================================ */
|
||||
.app-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 0 var(--space-4);
|
||||
height: var(--nav-height);
|
||||
gap: var(--space-1);
|
||||
overflow-x: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.app-nav::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
.app-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 0 var(--space-4);
|
||||
height: var(--nav-height);
|
||||
gap: var(--space-1);
|
||||
overflow-x: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.app-nav::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.app-nav::-webkit-scrollbar {
|
||||
height: 0;
|
||||
@@ -202,14 +202,14 @@
|
||||
}
|
||||
|
||||
/* Dropdown menu */
|
||||
.nav-dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
min-width: 180px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
.nav-dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
min-width: 180px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: var(--space-1);
|
||||
opacity: 0;
|
||||
@@ -299,27 +299,27 @@
|
||||
/* ============================================
|
||||
MOBILE NAVIGATION
|
||||
============================================ */
|
||||
.mobile-nav {
|
||||
display: none;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
overflow-x: auto;
|
||||
gap: var(--space-2);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mobile-nav::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
}
|
||||
.mobile-nav {
|
||||
display: none;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
overflow-x: auto;
|
||||
gap: var(--space-2);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mobile-nav::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mobile-nav::-webkit-scrollbar {
|
||||
height: 0;
|
||||
@@ -396,13 +396,13 @@
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.app-sidebar {
|
||||
width: var(--sidebar-width);
|
||||
background: linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-tertiary) 100%);
|
||||
border-right: 1px solid var(--border-color);
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.app-sidebar {
|
||||
width: var(--sidebar-width);
|
||||
background: linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-tertiary) 100%);
|
||||
border-right: 1px solid var(--border-color);
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
padding: var(--space-4);
|
||||
@@ -447,28 +447,28 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: linear-gradient(180deg, var(--bg-elevated) 0%, var(--bg-secondary) 100%);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dashboard-header::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
}
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: linear-gradient(180deg, var(--bg-elevated) 0%, var(--bg-secondary) 100%);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dashboard-header::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dashboard-header-logo {
|
||||
font-size: var(--text-lg);
|
||||
@@ -495,10 +495,10 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dashboard-sidebar {
|
||||
width: 320px;
|
||||
background: linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-tertiary) 100%);
|
||||
border-left: 1px solid var(--border-color);
|
||||
.dashboard-sidebar {
|
||||
width: 320px;
|
||||
background: linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-tertiary) 100%);
|
||||
border-left: 1px solid var(--border-color);
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -638,27 +638,32 @@
|
||||
Used by nav.html partial across all pages
|
||||
============================================ */
|
||||
|
||||
/* NAVIGATION
|
||||
Mode nav bar, dropdowns, utilities, theme/effects toggles
|
||||
============================================ */
|
||||
|
||||
/* Mode Navigation Bar */
|
||||
.mode-nav {
|
||||
display: none;
|
||||
background: var(--bg-secondary) !important; /* Explicit color - forced to ensure consistency */
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 0 20px;
|
||||
position: relative;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.mode-nav::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
.mode-nav {
|
||||
display: none;
|
||||
background: linear-gradient(180deg, rgba(17, 22, 32, 0.92), rgba(15, 20, 28, 0.88));
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 0 20px;
|
||||
position: relative;
|
||||
z-index: var(--z-sticky);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.mode-nav::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.mode-nav {
|
||||
@@ -682,6 +687,7 @@
|
||||
letter-spacing: 1px;
|
||||
margin-right: 8px;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.mode-nav-divider {
|
||||
@@ -692,33 +698,27 @@
|
||||
}
|
||||
|
||||
.mode-nav-btn {
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.mode-nav-btn .nav-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.mode-nav-btn .nav-icon svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
transition: all var(--transition-fast);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.mode-nav-btn .nav-label {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
letter-spacing: 0.08em;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.mode-nav-btn:hover {
|
||||
@@ -728,13 +728,14 @@
|
||||
}
|
||||
|
||||
.mode-nav-btn.active {
|
||||
background: var(--accent-cyan);
|
||||
color: var(--bg-primary);
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--accent-cyan);
|
||||
box-shadow: inset 0 -2px 0 var(--accent-cyan);
|
||||
}
|
||||
|
||||
.mode-nav-btn.active .nav-icon {
|
||||
filter: brightness(0);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.mode-nav-actions {
|
||||
@@ -749,29 +750,29 @@
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--accent-cyan);
|
||||
border-radius: 4px;
|
||||
color: var(--accent-cyan);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.nav-action-btn .nav-icon {
|
||||
font-size: 12px;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.nav-action-btn .nav-label {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
letter-spacing: 0.08em;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.nav-action-btn:hover {
|
||||
background: var(--accent-cyan);
|
||||
color: var(--bg-primary);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
box-shadow: var(--shadow-md);
|
||||
border-color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* Dropdown Navigation */
|
||||
@@ -780,19 +781,41 @@
|
||||
}
|
||||
|
||||
.mode-nav-dropdown-btn {
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.mode-nav-dropdown-btn .nav-label {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.mode-nav-dropdown-btn .dropdown-arrow {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-left: 4px;
|
||||
transition: transform 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mode-nav-dropdown-btn .dropdown-arrow svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.mode-nav-dropdown-btn:hover {
|
||||
@@ -801,31 +824,6 @@
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.mode-nav-dropdown-btn .nav-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.mode-nav-dropdown-btn .nav-icon svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.mode-nav-dropdown-btn .nav-label {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.mode-nav-dropdown-btn .dropdown-arrow {
|
||||
font-size: 8px;
|
||||
margin-left: 4px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.mode-nav-dropdown-btn .dropdown-arrow svg {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.mode-nav-dropdown.open .mode-nav-dropdown-btn {
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-primary);
|
||||
@@ -837,13 +835,14 @@
|
||||
}
|
||||
|
||||
.mode-nav-dropdown.has-active .mode-nav-dropdown-btn {
|
||||
background: var(--accent-cyan);
|
||||
color: var(--bg-primary);
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--accent-cyan);
|
||||
box-shadow: inset 0 -2px 0 var(--accent-cyan);
|
||||
}
|
||||
|
||||
.mode-nav-dropdown.has-active .mode-nav-dropdown-btn .nav-icon {
|
||||
filter: brightness(0);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.mode-nav-dropdown-menu {
|
||||
@@ -852,16 +851,17 @@
|
||||
left: 0;
|
||||
margin-top: 4px;
|
||||
min-width: 180px;
|
||||
background: var(--bg-secondary);
|
||||
background: var(--surface-glass);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-lg);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(-8px);
|
||||
transition: all 0.15s ease;
|
||||
z-index: 1000;
|
||||
transition: all var(--transition-fast);
|
||||
z-index: var(--z-dropdown);
|
||||
padding: 6px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.mode-nav-dropdown.open .mode-nav-dropdown-menu {
|
||||
@@ -874,8 +874,7 @@
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
padding: 10px 12px;
|
||||
border-radius: 4px;
|
||||
margin: 0;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.mode-nav-dropdown-menu .mode-nav-btn:hover {
|
||||
@@ -883,8 +882,18 @@
|
||||
}
|
||||
|
||||
.mode-nav-dropdown-menu .mode-nav-btn.active {
|
||||
background: var(--accent-cyan);
|
||||
color: var(--bg-primary);
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-primary);
|
||||
box-shadow: inset 0 -2px 0 var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* Focus-visible states for nav elements */
|
||||
.mode-nav-btn:focus-visible,
|
||||
.mode-nav-dropdown-btn:focus-visible,
|
||||
.nav-action-btn:focus-visible,
|
||||
.nav-tool-btn:focus-visible {
|
||||
outline: 2px solid var(--accent-cyan);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Nav Bar Utilities (clock, theme, tools) */
|
||||
@@ -941,15 +950,15 @@
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
min-width: 28px;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
display: flex;
|
||||
transition: all var(--transition-fast);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
@@ -957,27 +966,36 @@
|
||||
}
|
||||
|
||||
.nav-tool-btn:hover {
|
||||
background: var(--bg-elevated);
|
||||
border-color: var(--border-color);
|
||||
background: var(--bg-tertiary);
|
||||
border-color: var(--accent-cyan);
|
||||
color: var(--accent-cyan);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
/* Nav tool button SVG sizing */
|
||||
.nav-tool-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
stroke: currentColor;
|
||||
}
|
||||
|
||||
.nav-tool-btn .icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-tool-btn .icon svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
stroke: currentColor;
|
||||
}
|
||||
|
||||
/* Theme toggle icon states in nav bar */
|
||||
/* Theme toggle icon states */
|
||||
.nav-tool-btn .icon-sun,
|
||||
.nav-tool-btn .icon-moon {
|
||||
position: absolute;
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.nav-tool-btn .icon-sun {
|
||||
@@ -1000,7 +1018,7 @@
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* Effects toggle icon states */
|
||||
/* Effects/animations toggle icon states */
|
||||
.nav-tool-btn .icon-effects-off {
|
||||
display: none;
|
||||
}
|
||||
@@ -1012,3 +1030,114 @@
|
||||
[data-animations="off"] .nav-tool-btn .icon-effects-off {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Dashboard Button in Nav */
|
||||
a.nav-dashboard-btn,
|
||||
a.nav-dashboard-btn:link,
|
||||
a.nav-dashboard-btn:visited {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a.nav-dashboard-btn:hover {
|
||||
background: var(--bg-tertiary);
|
||||
border-color: var(--accent-cyan);
|
||||
color: var(--accent-cyan);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.nav-dashboard-btn .icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.nav-dashboard-btn .icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
stroke: currentColor;
|
||||
}
|
||||
|
||||
.nav-dashboard-btn .nav-label {
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* ---- Light theme nav overrides ---- */
|
||||
[data-theme="light"] .mode-nav {
|
||||
background: linear-gradient(180deg, rgba(240, 244, 250, 0.97) 0%, rgba(232, 238, 247, 0.95) 100%);
|
||||
}
|
||||
|
||||
[data-theme="light"] .mode-nav-btn:hover {
|
||||
background: rgba(220, 230, 244, 0.8);
|
||||
}
|
||||
|
||||
[data-theme="light"] .mode-nav-btn.active {
|
||||
background: rgba(220, 230, 244, 0.9);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
[data-theme="light"] .mode-nav-dropdown-btn:hover,
|
||||
[data-theme="light"] .mode-nav-dropdown.open .mode-nav-dropdown-btn,
|
||||
[data-theme="light"] .mode-nav-dropdown.has-active .mode-nav-dropdown-btn {
|
||||
background: rgba(220, 230, 244, 0.9);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
[data-theme="light"] .mode-nav-dropdown-menu {
|
||||
background: rgba(248, 250, 253, 0.99);
|
||||
box-shadow: 0 16px 36px rgba(18, 40, 66, 0.15);
|
||||
}
|
||||
|
||||
[data-theme="light"] .mode-nav-dropdown-menu .mode-nav-btn:hover {
|
||||
background: rgba(220, 230, 244, 0.85);
|
||||
}
|
||||
|
||||
[data-theme="light"] .mode-nav-dropdown-menu .mode-nav-btn.active {
|
||||
background: rgba(220, 230, 244, 0.95);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
[data-theme="light"] .nav-tool-btn {
|
||||
background: rgba(235, 241, 250, 0.7);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
[data-theme="light"] .nav-tool-btn:hover {
|
||||
background: rgba(220, 230, 244, 0.9);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
[data-theme="light"] .nav-action-btn {
|
||||
background: rgba(235, 241, 250, 0.85);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
[data-theme="light"] .nav-action-btn:hover {
|
||||
background: rgba(220, 230, 244, 0.95);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
[data-theme="light"] a.nav-dashboard-btn,
|
||||
[data-theme="light"] a.nav-dashboard-btn:link,
|
||||
[data-theme="light"] a.nav-dashboard-btn:visited {
|
||||
background: rgba(235, 241, 250, 0.7);
|
||||
border-color: var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
[data-theme="light"] a.nav-dashboard-btn:hover {
|
||||
background: rgba(220, 230, 244, 0.9);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
@@ -31,8 +31,10 @@
|
||||
--accent-cyan-dim: rgba(74, 163, 255, 0.16);
|
||||
--accent-cyan-hover: #6bb3ff;
|
||||
--accent-green: #38c180;
|
||||
--accent-green-hover: #16a34a;
|
||||
--accent-green-dim: rgba(56, 193, 128, 0.18);
|
||||
--accent-red: #e25d5d;
|
||||
--accent-red-hover: #dc2626;
|
||||
--accent-red-dim: rgba(226, 93, 93, 0.16);
|
||||
--accent-orange: #d6a85e;
|
||||
--accent-orange-dim: rgba(214, 168, 94, 0.16);
|
||||
@@ -96,7 +98,7 @@
|
||||
TYPOGRAPHY
|
||||
============================================ */
|
||||
--font-sans: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
--font-mono: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', Consolas, monospace;
|
||||
|
||||
/* Font sizes */
|
||||
--text-xs: 10px;
|
||||
@@ -158,7 +160,7 @@
|
||||
/* ============================================
|
||||
LAYOUT
|
||||
============================================ */
|
||||
--header-height: 60px;
|
||||
--header-height: 48px;
|
||||
--nav-height: 44px;
|
||||
--sidebar-width: 280px;
|
||||
--stats-strip-height: 36px;
|
||||
@@ -189,8 +191,10 @@
|
||||
--accent-cyan-dim: rgba(31, 95, 168, 0.12);
|
||||
--accent-cyan-hover: #2c73bf;
|
||||
--accent-green: #1f8a57;
|
||||
--accent-green-hover: #167a4a;
|
||||
--accent-green-dim: rgba(31, 138, 87, 0.12);
|
||||
--accent-red: #c74444;
|
||||
--accent-red-hover: #b33a3a;
|
||||
--accent-red-dim: rgba(199, 68, 68, 0.12);
|
||||
--accent-orange: #b5863a;
|
||||
--accent-orange-dim: rgba(181, 134, 58, 0.12);
|
||||
@@ -220,8 +224,8 @@
|
||||
|
||||
--text-primary: #122034;
|
||||
--text-secondary: #3a4a5f;
|
||||
--text-dim: #6b7c93;
|
||||
--text-muted: #aab6c8;
|
||||
--text-dim: #566a7f;
|
||||
--text-muted: #7a8a9e;
|
||||
--text-inverse: #f4f7fb;
|
||||
|
||||
--border-color: #d1d9e6;
|
||||
|
||||
@@ -1,507 +0,0 @@
|
||||
/* ============================================
|
||||
Global Navigation Styles
|
||||
Shared across all pages using nav.html
|
||||
============================================ */
|
||||
|
||||
/* Icon base (kept lightweight for nav usage) */
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.icon--sm {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
/* Mode Navigation Bar */
|
||||
.mode-nav {
|
||||
display: none;
|
||||
background: linear-gradient(180deg, rgba(17, 22, 32, 0.92), rgba(15, 20, 28, 0.88));
|
||||
border-bottom: 1px solid var(--border-color, #202833);
|
||||
padding: 0 20px;
|
||||
position: relative;
|
||||
z-index: 1100;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.mode-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
.mode-nav-label {
|
||||
font-size: 9px;
|
||||
color: var(--text-secondary, #b7c1cf);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-right: 8px;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.mode-nav-divider {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: var(--border-color, #202833);
|
||||
margin: 0 12px;
|
||||
}
|
||||
|
||||
.mode-nav-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary, #b7c1cf);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.mode-nav-btn .nav-label {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.mode-nav-btn:hover {
|
||||
background: rgba(27, 36, 51, 0.8);
|
||||
color: var(--text-primary, #e7ebf2);
|
||||
border-color: var(--border-color, #202833);
|
||||
}
|
||||
|
||||
.mode-nav-btn.active {
|
||||
background: rgba(27, 36, 51, 0.9);
|
||||
color: var(--text-primary, #e7ebf2);
|
||||
border-color: var(--accent-cyan, #4d7dbf);
|
||||
box-shadow: inset 0 -2px 0 var(--accent-cyan, #4d7dbf);
|
||||
}
|
||||
|
||||
.mode-nav-btn.active .nav-icon {
|
||||
color: var(--accent-cyan, #4d7dbf);
|
||||
}
|
||||
|
||||
.mode-nav-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.nav-action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
background: rgba(24, 31, 44, 0.85);
|
||||
border: 1px solid var(--border-light, #2b3645);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary, #e7ebf2);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.nav-action-btn .nav-label {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.nav-action-btn:hover {
|
||||
background: rgba(27, 36, 51, 0.95);
|
||||
color: var(--text-primary, #e7ebf2);
|
||||
box-shadow: 0 8px 16px rgba(5, 9, 15, 0.35);
|
||||
border-color: var(--accent-cyan, #4d7dbf);
|
||||
}
|
||||
|
||||
/* Dropdown Navigation */
|
||||
.mode-nav-dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mode-nav-dropdown-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary, #b7c1cf);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.mode-nav-dropdown-btn .nav-label {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.mode-nav-dropdown-btn .dropdown-arrow {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-left: 4px;
|
||||
transition: transform 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mode-nav-dropdown-btn .dropdown-arrow svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.mode-nav-dropdown-btn:hover {
|
||||
background: rgba(27, 36, 51, 0.8);
|
||||
color: var(--text-primary, #e7ebf2);
|
||||
border-color: var(--border-color, #202833);
|
||||
}
|
||||
|
||||
.mode-nav-dropdown.open .mode-nav-dropdown-btn {
|
||||
background: rgba(27, 36, 51, 0.9);
|
||||
color: var(--text-primary, #e7ebf2);
|
||||
border-color: var(--border-color, #202833);
|
||||
}
|
||||
|
||||
.mode-nav-dropdown.open .dropdown-arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.mode-nav-dropdown.has-active .mode-nav-dropdown-btn {
|
||||
background: rgba(27, 36, 51, 0.9);
|
||||
color: var(--text-primary, #e7ebf2);
|
||||
border-color: var(--accent-cyan, #4d7dbf);
|
||||
box-shadow: inset 0 -2px 0 var(--accent-cyan, #4d7dbf);
|
||||
}
|
||||
|
||||
.mode-nav-dropdown.has-active .mode-nav-dropdown-btn .nav-icon {
|
||||
color: var(--accent-cyan, #4d7dbf);
|
||||
}
|
||||
|
||||
.mode-nav-dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
margin-top: 4px;
|
||||
min-width: 180px;
|
||||
background: rgba(16, 22, 32, 0.98);
|
||||
border: 1px solid var(--border-color, #202833);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 16px 36px rgba(5, 9, 15, 0.55);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(-8px);
|
||||
transition: all 0.15s ease;
|
||||
z-index: 1000;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.mode-nav-dropdown.open .mode-nav-dropdown-menu {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.mode-nav-dropdown-menu .mode-nav-btn {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.mode-nav-dropdown-menu .mode-nav-btn:hover {
|
||||
background: rgba(27, 36, 51, 0.85);
|
||||
}
|
||||
|
||||
.mode-nav-dropdown-menu .mode-nav-btn.active {
|
||||
background: rgba(27, 36, 51, 0.95);
|
||||
color: var(--text-primary, #e7ebf2);
|
||||
box-shadow: inset 0 -2px 0 var(--accent-cyan, #4d7dbf);
|
||||
}
|
||||
|
||||
/* Nav Bar Utilities */
|
||||
.nav-utilities {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.nav-utilities {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-clock {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-clock .utc-label {
|
||||
font-size: 9px;
|
||||
color: var(--text-dim, #8a97a8);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.nav-clock .utc-time {
|
||||
color: var(--accent-cyan, #4d7dbf);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: var(--border-color, #202833);
|
||||
}
|
||||
|
||||
.nav-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-tool-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
min-width: 28px;
|
||||
border-radius: 6px;
|
||||
background: rgba(20, 33, 53, 0.6);
|
||||
border: 1px solid rgba(77, 125, 191, 0.12);
|
||||
color: var(--text-secondary, #b7c1cf);
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-tool-btn:hover {
|
||||
background: rgba(27, 36, 51, 0.9);
|
||||
border-color: var(--accent-cyan, #4d7dbf);
|
||||
color: var(--accent-cyan, #4d7dbf);
|
||||
box-shadow: 0 6px 14px rgba(5, 9, 15, 0.35);
|
||||
}
|
||||
|
||||
/* Position relative needed for absolute positioned icon children */
|
||||
.nav-tool-btn {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mode-nav-btn:focus-visible,
|
||||
.mode-nav-dropdown-btn:focus-visible,
|
||||
.nav-action-btn:focus-visible,
|
||||
.nav-tool-btn:focus-visible {
|
||||
outline: 2px solid var(--accent-cyan, #4d7dbf);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Nav tool button SVG sizing and styling */
|
||||
.nav-tool-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
stroke: currentColor;
|
||||
}
|
||||
|
||||
.nav-tool-btn .icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-tool-btn .icon svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
stroke: currentColor;
|
||||
}
|
||||
|
||||
/* Theme toggle icon states */
|
||||
.nav-tool-btn .icon-sun,
|
||||
.nav-tool-btn .icon-moon {
|
||||
position: absolute;
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.nav-tool-btn .icon-sun {
|
||||
opacity: 0;
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.nav-tool-btn .icon-moon {
|
||||
opacity: 1;
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
[data-theme="light"] .nav-tool-btn .icon-sun {
|
||||
opacity: 1;
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
[data-theme="light"] .nav-tool-btn .icon-moon {
|
||||
opacity: 0;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* ---- Light theme overrides ---- */
|
||||
[data-theme="light"] .mode-nav {
|
||||
background: linear-gradient(180deg, rgba(240, 244, 250, 0.97) 0%, rgba(232, 238, 247, 0.95) 100%);
|
||||
}
|
||||
|
||||
[data-theme="light"] .mode-nav-btn:hover {
|
||||
background: rgba(220, 230, 244, 0.8);
|
||||
}
|
||||
|
||||
[data-theme="light"] .mode-nav-btn.active {
|
||||
background: rgba(220, 230, 244, 0.9);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
[data-theme="light"] .mode-nav-dropdown-btn:hover,
|
||||
[data-theme="light"] .mode-nav-dropdown.open .mode-nav-dropdown-btn,
|
||||
[data-theme="light"] .mode-nav-dropdown.has-active .mode-nav-dropdown-btn {
|
||||
background: rgba(220, 230, 244, 0.9);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
[data-theme="light"] .mode-nav-dropdown-menu {
|
||||
background: rgba(248, 250, 253, 0.99);
|
||||
box-shadow: 0 16px 36px rgba(18, 40, 66, 0.15);
|
||||
}
|
||||
|
||||
[data-theme="light"] .mode-nav-dropdown-menu .mode-nav-btn:hover {
|
||||
background: rgba(220, 230, 244, 0.85);
|
||||
}
|
||||
|
||||
[data-theme="light"] .mode-nav-dropdown-menu .mode-nav-btn.active {
|
||||
background: rgba(220, 230, 244, 0.95);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
[data-theme="light"] .nav-tool-btn {
|
||||
background: rgba(235, 241, 250, 0.7);
|
||||
border-color: rgba(31, 95, 168, 0.12);
|
||||
}
|
||||
|
||||
[data-theme="light"] .nav-tool-btn:hover {
|
||||
background: rgba(220, 230, 244, 0.9);
|
||||
box-shadow: 0 4px 10px rgba(18, 40, 66, 0.1);
|
||||
}
|
||||
|
||||
[data-theme="light"] .nav-action-btn {
|
||||
background: rgba(235, 241, 250, 0.85);
|
||||
border-color: rgba(31, 95, 168, 0.14);
|
||||
}
|
||||
|
||||
[data-theme="light"] .nav-action-btn:hover {
|
||||
background: rgba(220, 230, 244, 0.95);
|
||||
box-shadow: 0 6px 14px rgba(18, 40, 66, 0.1);
|
||||
}
|
||||
|
||||
[data-theme="light"] a.nav-dashboard-btn,
|
||||
[data-theme="light"] a.nav-dashboard-btn:link,
|
||||
[data-theme="light"] a.nav-dashboard-btn:visited {
|
||||
background: rgba(235, 241, 250, 0.7) !important;
|
||||
border-color: rgba(31, 95, 168, 0.12) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
[data-theme="light"] a.nav-dashboard-btn:hover {
|
||||
background: rgba(220, 230, 244, 0.9) !important;
|
||||
box-shadow: 0 4px 10px rgba(18, 40, 66, 0.1);
|
||||
}
|
||||
|
||||
/* Effects/animations toggle icon states */
|
||||
.nav-tool-btn .icon-effects-off {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-animations="off"] .nav-tool-btn .icon-effects-on {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-animations="off"] .nav-tool-btn .icon-effects-off {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Main Dashboard Button in Nav */
|
||||
a.nav-dashboard-btn,
|
||||
a.nav-dashboard-btn:link,
|
||||
a.nav-dashboard-btn:visited {
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
background: rgba(20, 33, 53, 0.6) !important;
|
||||
border: 1px solid rgba(77, 125, 191, 0.12) !important;
|
||||
color: #b7c1cf !important;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
a.nav-dashboard-btn:hover {
|
||||
background: rgba(27, 36, 51, 0.9) !important;
|
||||
border-color: #4d7dbf !important;
|
||||
color: #4d7dbf !important;
|
||||
box-shadow: 0 6px 14px rgba(5, 9, 15, 0.35);
|
||||
}
|
||||
|
||||
.nav-dashboard-btn .icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.nav-dashboard-btn .icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
stroke: currentColor;
|
||||
}
|
||||
|
||||
.nav-dashboard-btn .nav-label {
|
||||
font-family: var(--font-mono, 'Roboto Condensed', 'Arial Narrow', sans-serif);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
+87
-77
@@ -212,10 +212,6 @@ body {
|
||||
}
|
||||
|
||||
.welcome-settings-btn {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 2;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
@@ -223,6 +219,8 @@ body {
|
||||
border-radius: 6px;
|
||||
color: var(--text-dim, rgba(255, 255, 255, 0.3));
|
||||
transition: color 0.2s, background 0.2s;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.welcome-settings-btn:hover {
|
||||
@@ -251,10 +249,9 @@ body {
|
||||
.welcome-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 20px;
|
||||
gap: 14px;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
@@ -300,38 +297,37 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
.welcome-title-block {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.welcome-title {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 2.5rem;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.2em;
|
||||
margin: 0;
|
||||
text-shadow: 0 0 20px rgba(0, 212, 255, 0.3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.welcome-tagline {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--accent-cyan);
|
||||
letter-spacing: 0.15em;
|
||||
margin: 4px 0 0 0;
|
||||
letter-spacing: 0.1em;
|
||||
margin: 0;
|
||||
opacity: 0.7;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.welcome-version {
|
||||
display: inline-block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.65rem;
|
||||
font-size: 0.6rem;
|
||||
color: var(--bg-primary);
|
||||
background: var(--accent-cyan);
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
letter-spacing: 0.05em;
|
||||
margin-top: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Welcome Content Grid */
|
||||
@@ -572,6 +568,21 @@ body {
|
||||
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 {
|
||||
position: absolute;
|
||||
@@ -606,12 +617,9 @@ body {
|
||||
}
|
||||
|
||||
.welcome-header {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.welcome-title-block {
|
||||
text-align: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 8px 14px;
|
||||
}
|
||||
|
||||
.mode-grid {
|
||||
@@ -639,12 +647,7 @@ body {
|
||||
}
|
||||
|
||||
.welcome-header {
|
||||
flex-direction: row;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.welcome-title-block {
|
||||
text-align: left;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.mode-grid-compact {
|
||||
@@ -664,28 +667,27 @@ body {
|
||||
|
||||
header {
|
||||
background: var(--bg-secondary);
|
||||
padding: 10px 12px;
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
position: relative;
|
||||
min-height: 52px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
header {
|
||||
justify-content: center;
|
||||
padding: 12px 20px;
|
||||
}
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
header {
|
||||
text-align: center;
|
||||
display: block;
|
||||
}
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
header::before {
|
||||
@@ -709,14 +711,13 @@ header h1 {
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.15em;
|
||||
margin: 0;
|
||||
display: inline;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin-right: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logo svg {
|
||||
@@ -917,7 +918,7 @@ header h1 {
|
||||
left: 0;
|
||||
margin-top: 4px;
|
||||
min-width: 180px;
|
||||
background: var(--bg-secondary);
|
||||
background: #101823;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
@@ -1088,19 +1089,7 @@ header h1 {
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
header p {
|
||||
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;
|
||||
}
|
||||
/* subtitle removed — compact header */
|
||||
|
||||
header h1 .tagline {
|
||||
font-weight: 400;
|
||||
@@ -1204,15 +1193,14 @@ header h1 .tagline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
padding: 4px 14px;
|
||||
background: var(--accent-cyan);
|
||||
color: var(--bg-primary);
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.active-mode-indicator .pulse-dot {
|
||||
@@ -1631,10 +1619,9 @@ header h1 .tagline {
|
||||
|
||||
.section.collapsed h3 {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0 !important;
|
||||
margin: 0 !important;
|
||||
min-height: 0 !important;
|
||||
padding-top: 10px !important;
|
||||
padding-bottom: 10px !important;
|
||||
padding: 10px 12px !important;
|
||||
}
|
||||
|
||||
.section.collapsed h3::after {
|
||||
@@ -1643,7 +1630,7 @@ header h1 .tagline {
|
||||
}
|
||||
|
||||
.section.collapsed {
|
||||
padding-bottom: 0 !important;
|
||||
padding: 0 !important;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@@ -2433,6 +2420,19 @@ header h1 .tagline {
|
||||
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 {
|
||||
display: none;
|
||||
}
|
||||
@@ -2739,7 +2739,7 @@ header h1 .tagline {
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
@media (max-width: 1023px) {
|
||||
.pass-predictor {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -4090,13 +4090,13 @@ header h1 .tagline {
|
||||
}
|
||||
|
||||
/* WiFi Responsive */
|
||||
@media (max-width: 1400px) {
|
||||
@media (max-width: 1280px) {
|
||||
.wifi-main-content {
|
||||
grid-template-columns: minmax(280px, 1fr) 240px 240px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
@media (max-width: 1280px) {
|
||||
.wifi-layout-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -5415,7 +5415,7 @@ header h1 .tagline {
|
||||
background: var(--bg-secondary, #1a1a2e);
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
@media (max-width: 1280px) {
|
||||
.bt-layout-container {
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
@@ -7261,6 +7261,12 @@ body[data-mode="tscm"] {
|
||||
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 {
|
||||
margin: 8px var(--top-rail-gutter) 0;
|
||||
border-color: rgba(74, 163, 255, 0.3);
|
||||
@@ -7312,7 +7318,7 @@ body[data-mode="tscm"] {
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
[data-theme="light"] .mode-nav-dropdown-menu {
|
||||
background: #e9eef5;
|
||||
}
|
||||
|
||||
[data-theme="light"] .run-state-strip {
|
||||
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);
|
||||
@@ -7445,7 +7455,7 @@ body[data-mode="tscm"] {
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
|
||||
@@ -513,7 +513,7 @@
|
||||
RESPONSIVE — stack HUD vertically on narrow
|
||||
============================================ */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
@media (max-width: 1023px) {
|
||||
.btl-hud {
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
|
||||
@@ -1378,7 +1378,7 @@
|
||||
}
|
||||
|
||||
/* Responsive traceroute path */
|
||||
@media (max-width: 600px) {
|
||||
@media (max-width: 480px) {
|
||||
.mesh-traceroute-path {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@@ -451,7 +451,7 @@
|
||||
|
||||
/* ── Responsive ── */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
@media (max-width: 1023px) {
|
||||
.ms-stats-strip {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
@@ -460,7 +460,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
@media (max-width: 480px) {
|
||||
.ms-stats-strip {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
|
||||
.ook-warning {
|
||||
font-size: 11px;
|
||||
color: #ffaa00;
|
||||
color: var(--accent-orange);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
|
||||
@@ -221,7 +221,7 @@
|
||||
}
|
||||
|
||||
/* Responsive: stack cards on narrow screens */
|
||||
@media (max-width: 600px) {
|
||||
@media (max-width: 480px) {
|
||||
.radiosonde-card {
|
||||
flex: 1 1 100%;
|
||||
max-width: 100%;
|
||||
|
||||
@@ -408,7 +408,7 @@
|
||||
}
|
||||
|
||||
/* Small tablet / large phone (640px) */
|
||||
@media (max-width: 640px) {
|
||||
@media (max-width: 768px) {
|
||||
.spy-station-footer {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
@@ -1582,13 +1582,13 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
@media (max-width: 1280px) {
|
||||
.subghz-rx-info-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
@media (max-width: 1023px) {
|
||||
.subghz-decode-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
+87
-87
@@ -22,13 +22,13 @@
|
||||
opacity: 0.7;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.threat-card.critical { border-color: #ff3366; color: #ff3366; }
|
||||
.threat-card.critical { border-color: var(--severity-critical); color: var(--severity-critical); }
|
||||
.threat-card.critical.active { background: rgba(255,51,102,0.2); }
|
||||
.threat-card.high { border-color: #ff9933; color: #ff9933; }
|
||||
.threat-card.high { border-color: var(--severity-high); color: var(--severity-high); }
|
||||
.threat-card.high.active { background: rgba(255,153,51,0.2); }
|
||||
.threat-card.medium { border-color: #ffcc00; color: #ffcc00; }
|
||||
.threat-card.medium { border-color: var(--severity-medium); color: var(--severity-medium); }
|
||||
.threat-card.medium.active { background: rgba(255,204,0,0.2); }
|
||||
.threat-card.low { border-color: #00ff88; color: #00ff88; }
|
||||
.threat-card.low { border-color: var(--severity-low); color: var(--severity-low); }
|
||||
.threat-card.low.active { background: rgba(0,255,136,0.2); }
|
||||
|
||||
/* TSCM Dashboard */
|
||||
@@ -105,26 +105,26 @@
|
||||
background: rgba(74,158,255,0.1);
|
||||
}
|
||||
.tscm-device-item.new {
|
||||
border-left-color: #ff9933;
|
||||
border-left-color: var(--severity-high);
|
||||
animation: pulse-glow 2s infinite;
|
||||
}
|
||||
.tscm-device-item.threat {
|
||||
border-left-color: #ff3366;
|
||||
border-left-color: var(--severity-critical);
|
||||
}
|
||||
.tscm-device-item.baseline {
|
||||
border-left-color: #00ff88;
|
||||
border-left-color: var(--neon-green);
|
||||
}
|
||||
/* Classification colors */
|
||||
.tscm-device-item.classification-green {
|
||||
border-left-color: #00cc00;
|
||||
border-left-color: var(--accent-green);
|
||||
background: rgba(0, 204, 0, 0.1);
|
||||
}
|
||||
.tscm-device-item.classification-yellow {
|
||||
border-left-color: #ffcc00;
|
||||
border-left-color: var(--severity-medium);
|
||||
background: rgba(255, 204, 0, 0.1);
|
||||
}
|
||||
.tscm-device-item.classification-red {
|
||||
border-left-color: #ff3333;
|
||||
border-left-color: var(--accent-red);
|
||||
background: rgba(255, 51, 51, 0.15);
|
||||
animation: pulse-glow 2s infinite;
|
||||
}
|
||||
@@ -182,7 +182,7 @@
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.tscm-action-btn:hover {
|
||||
background: #2ecc71;
|
||||
background: var(--accent-green-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.tscm-device-reasons {
|
||||
@@ -202,7 +202,7 @@
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 51, 102, 0.2);
|
||||
color: #ff3366;
|
||||
color: var(--severity-critical);
|
||||
border: 1px solid rgba(255, 51, 102, 0.4);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
@@ -213,7 +213,7 @@
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
background: rgba(74, 158, 255, 0.2);
|
||||
color: #4a9eff;
|
||||
color: var(--accent-cyan);
|
||||
border: 1px solid rgba(74, 158, 255, 0.4);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
@@ -224,7 +224,7 @@
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
background: rgba(0, 255, 136, 0.2);
|
||||
color: #00ff88;
|
||||
color: var(--neon-green);
|
||||
border: 1px solid rgba(0, 255, 136, 0.4);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
@@ -268,20 +268,20 @@
|
||||
}
|
||||
.score-badge.score-low {
|
||||
background: rgba(0, 204, 0, 0.2);
|
||||
color: #00cc00;
|
||||
color: var(--accent-green);
|
||||
}
|
||||
.score-badge.score-medium {
|
||||
background: rgba(255, 204, 0, 0.2);
|
||||
color: #ffcc00;
|
||||
color: var(--severity-medium);
|
||||
}
|
||||
.score-badge.score-high {
|
||||
background: rgba(255, 51, 51, 0.2);
|
||||
color: #ff3333;
|
||||
color: var(--accent-red);
|
||||
}
|
||||
.tscm-action {
|
||||
margin-top: 4px;
|
||||
font-size: 10px;
|
||||
color: #ff9933;
|
||||
color: var(--severity-high);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
@@ -290,12 +290,12 @@
|
||||
padding: 12px;
|
||||
background: rgba(255, 153, 51, 0.1);
|
||||
border-radius: 6px;
|
||||
border: 1px solid #ff9933;
|
||||
border: 1px solid var(--severity-high);
|
||||
}
|
||||
.tscm-correlations h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 12px;
|
||||
color: #ff9933;
|
||||
color: var(--severity-high);
|
||||
}
|
||||
.correlation-item {
|
||||
padding: 8px;
|
||||
@@ -332,9 +332,9 @@
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.summary-stat.high-interest .count { color: #ff3333; }
|
||||
.summary-stat.needs-review .count { color: #ffcc00; }
|
||||
.summary-stat.informational .count { color: #00cc00; }
|
||||
.summary-stat.high-interest .count { color: var(--accent-red); }
|
||||
.summary-stat.needs-review .count { color: var(--severity-medium); }
|
||||
.summary-stat.informational .count { color: var(--accent-green); }
|
||||
.tscm-assessment {
|
||||
padding: 10px 14px;
|
||||
margin: 12px 0;
|
||||
@@ -343,18 +343,18 @@
|
||||
}
|
||||
.tscm-assessment.high-interest {
|
||||
background: rgba(255, 51, 51, 0.15);
|
||||
border: 1px solid #ff3333;
|
||||
color: #ff3333;
|
||||
border: 1px solid var(--accent-red);
|
||||
color: var(--accent-red);
|
||||
}
|
||||
.tscm-assessment.needs-review {
|
||||
background: rgba(255, 204, 0, 0.15);
|
||||
border: 1px solid #ffcc00;
|
||||
color: #ffcc00;
|
||||
border: 1px solid var(--severity-medium);
|
||||
color: var(--severity-medium);
|
||||
}
|
||||
.tscm-assessment.informational {
|
||||
background: rgba(0, 204, 0, 0.15);
|
||||
border: 1px solid #00cc00;
|
||||
color: #00cc00;
|
||||
border: 1px solid var(--accent-green);
|
||||
color: var(--accent-green);
|
||||
}
|
||||
.tscm-disclaimer {
|
||||
font-size: 10px;
|
||||
@@ -452,16 +452,16 @@
|
||||
justify-content: center;
|
||||
border: 3px solid;
|
||||
}
|
||||
.score-circle.high { border-color: #ff3333; background: rgba(255, 51, 51, 0.1); }
|
||||
.score-circle.medium { border-color: #ffcc00; background: rgba(255, 204, 0, 0.1); }
|
||||
.score-circle.low { border-color: #00cc00; background: rgba(0, 204, 0, 0.1); }
|
||||
.score-circle.high { border-color: var(--accent-red); background: rgba(255, 51, 51, 0.1); }
|
||||
.score-circle.medium { border-color: var(--severity-medium); background: rgba(255, 204, 0, 0.1); }
|
||||
.score-circle.low { border-color: var(--accent-green); background: rgba(0, 204, 0, 0.1); }
|
||||
.score-circle .score-value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.score-circle.high .score-value { color: #ff3333; }
|
||||
.score-circle.medium .score-value { color: #ffcc00; }
|
||||
.score-circle.low .score-value { color: #00cc00; }
|
||||
.score-circle.high .score-value { color: var(--accent-red); }
|
||||
.score-circle.medium .score-value { color: var(--severity-medium); }
|
||||
.score-circle.low .score-value { color: var(--accent-green); }
|
||||
.score-circle .score-label {
|
||||
font-size: 8px;
|
||||
color: var(--text-muted);
|
||||
@@ -521,7 +521,7 @@
|
||||
}
|
||||
.indicator-type {
|
||||
background: rgba(255, 153, 51, 0.2);
|
||||
color: #ff9933;
|
||||
color: var(--severity-high);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
@@ -550,7 +550,7 @@
|
||||
.tscm-threat-action {
|
||||
margin-top: 6px;
|
||||
font-size: 10px;
|
||||
color: #ff9933;
|
||||
color: var(--severity-high);
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -606,7 +606,7 @@
|
||||
font-size: 9px;
|
||||
padding: 2px 6px;
|
||||
background: rgba(74, 158, 255, 0.2);
|
||||
color: #4a9eff;
|
||||
color: var(--accent-cyan);
|
||||
border-radius: 3px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
@@ -614,7 +614,7 @@
|
||||
font-size: 9px;
|
||||
padding: 2px 6px;
|
||||
background: rgba(255, 153, 51, 0.2);
|
||||
color: #ff9933;
|
||||
color: var(--severity-high);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.correlation-detail-item {
|
||||
@@ -634,10 +634,10 @@
|
||||
background: rgba(0,0,0,0.2);
|
||||
border: 1px solid;
|
||||
}
|
||||
.tscm-threat-item.critical { border-color: #ff3366; background: rgba(255,51,102,0.1); }
|
||||
.tscm-threat-item.high { border-color: #ff9933; background: rgba(255,153,51,0.1); }
|
||||
.tscm-threat-item.medium { border-color: #ffcc00; background: rgba(255,204,0,0.1); }
|
||||
.tscm-threat-item.low { border-color: #00ff88; background: rgba(0,255,136,0.1); }
|
||||
.tscm-threat-item.critical { border-color: var(--severity-critical); background: rgba(255,51,102,0.1); }
|
||||
.tscm-threat-item.high { border-color: var(--severity-high); background: rgba(255,153,51,0.1); }
|
||||
.tscm-threat-item.medium { border-color: var(--severity-medium); background: rgba(255,204,0,0.1); }
|
||||
.tscm-threat-item.low { border-color: var(--severity-low); background: rgba(0,255,136,0.1); }
|
||||
.tscm-threat-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -807,7 +807,7 @@
|
||||
.meeting-pulse {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #ff3366;
|
||||
background: var(--severity-critical);
|
||||
border-radius: 50%;
|
||||
animation: pulse-dot 1.5s ease-in-out infinite;
|
||||
}
|
||||
@@ -819,7 +819,7 @@
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
color: #ff3366;
|
||||
color: var(--severity-critical);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.meeting-info {
|
||||
@@ -865,15 +865,15 @@
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.cap-status.available { color: #00cc00; }
|
||||
.cap-status.limited { color: #ffcc00; }
|
||||
.cap-status.unavailable { color: #ff3333; }
|
||||
.cap-status.available { color: var(--accent-green); }
|
||||
.cap-status.limited { color: var(--severity-medium); }
|
||||
.cap-status.unavailable { color: var(--accent-red); }
|
||||
.cap-limitations {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: #ff9933;
|
||||
color: var(--severity-high);
|
||||
font-size: 10px;
|
||||
}
|
||||
.cap-warn {
|
||||
@@ -907,15 +907,15 @@
|
||||
}
|
||||
.health-badge.healthy {
|
||||
background: rgba(0, 204, 0, 0.2);
|
||||
color: #00cc00;
|
||||
color: var(--accent-green);
|
||||
}
|
||||
.health-badge.noisy {
|
||||
background: rgba(255, 204, 0, 0.2);
|
||||
color: #ffcc00;
|
||||
color: var(--severity-medium);
|
||||
}
|
||||
.health-badge.stale {
|
||||
background: rgba(255, 51, 51, 0.2);
|
||||
color: #ff3333;
|
||||
color: var(--accent-red);
|
||||
}
|
||||
.health-age {
|
||||
color: var(--text-muted);
|
||||
@@ -998,9 +998,9 @@
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid var(--border-color);
|
||||
}
|
||||
.cap-detail-item.available { border-left-color: #00cc00; }
|
||||
.cap-detail-item.limited { border-left-color: #ffcc00; }
|
||||
.cap-detail-item.unavailable { border-left-color: #ff3333; }
|
||||
.cap-detail-item.available { border-left-color: var(--accent-green); }
|
||||
.cap-detail-item.limited { border-left-color: var(--severity-medium); }
|
||||
.cap-detail-item.unavailable { border-left-color: var(--accent-red); }
|
||||
.cap-detail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -1016,9 +1016,9 @@
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.cap-detail-status.available { background: rgba(0, 204, 0, 0.2); color: #00cc00; }
|
||||
.cap-detail-status.limited { background: rgba(255, 204, 0, 0.2); color: #ffcc00; }
|
||||
.cap-detail-status.unavailable { background: rgba(255, 51, 51, 0.2); color: #ff3333; }
|
||||
.cap-detail-status.available { background: rgba(0, 204, 0, 0.2); color: var(--accent-green); }
|
||||
.cap-detail-status.limited { background: rgba(255, 204, 0, 0.2); color: var(--severity-medium); }
|
||||
.cap-detail-status.unavailable { background: rgba(255, 51, 51, 0.2); color: var(--accent-red); }
|
||||
.cap-detail-limits {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
@@ -1034,7 +1034,7 @@
|
||||
margin-bottom: 6px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid #00cc00;
|
||||
border-left: 3px solid var(--accent-green);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
@@ -1064,7 +1064,7 @@
|
||||
}
|
||||
.known-device-btn.remove {
|
||||
background: rgba(255, 51, 51, 0.2);
|
||||
color: #ff3333;
|
||||
color: var(--accent-red);
|
||||
}
|
||||
.known-device-btn.remove:hover {
|
||||
background: rgba(255, 51, 51, 0.4);
|
||||
@@ -1083,9 +1083,9 @@
|
||||
.case-item:hover {
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
}
|
||||
.case-item.priority-high { border-left-color: #ff3333; }
|
||||
.case-item.priority-normal { border-left-color: #4a9eff; }
|
||||
.case-item.priority-low { border-left-color: #00cc00; }
|
||||
.case-item.priority-high { border-left-color: var(--accent-red); }
|
||||
.case-item.priority-normal { border-left-color: var(--accent-cyan); }
|
||||
.case-item.priority-low { border-left-color: var(--accent-green); }
|
||||
.case-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -1102,8 +1102,8 @@
|
||||
border-radius: 3px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.case-status.open { background: rgba(0, 204, 0, 0.2); color: #00cc00; }
|
||||
.case-status.closed { background: rgba(128, 128, 128, 0.2); color: #888; }
|
||||
.case-status.open { background: rgba(0, 204, 0, 0.2); color: var(--accent-green); }
|
||||
.case-status.closed { background: rgba(128, 128, 128, 0.2); color: var(--text-secondary); }
|
||||
.case-meta {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
@@ -1117,7 +1117,7 @@
|
||||
margin-bottom: 8px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 6px;
|
||||
border-left: 3px solid #ff9933;
|
||||
border-left: 3px solid var(--severity-high);
|
||||
}
|
||||
.playbook-header {
|
||||
display: flex;
|
||||
@@ -1135,9 +1135,9 @@
|
||||
border-radius: 3px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.playbook-risk.high_interest { background: rgba(255, 51, 51, 0.2); color: #ff3333; }
|
||||
.playbook-risk.needs_review { background: rgba(255, 204, 0, 0.2); color: #ffcc00; }
|
||||
.playbook-risk.informational { background: rgba(0, 204, 0, 0.2); color: #00cc00; }
|
||||
.playbook-risk.high_interest { background: rgba(255, 51, 51, 0.2); color: var(--accent-red); }
|
||||
.playbook-risk.needs_review { background: rgba(255, 204, 0, 0.2); color: var(--severity-medium); }
|
||||
.playbook-risk.informational { background: rgba(0, 204, 0, 0.2); color: var(--accent-green); }
|
||||
.playbook-desc {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
@@ -1153,7 +1153,7 @@
|
||||
border-radius: 3px;
|
||||
}
|
||||
.playbook-step-num {
|
||||
color: #ff9933;
|
||||
color: var(--severity-high);
|
||||
font-weight: 600;
|
||||
margin-right: 6px;
|
||||
}
|
||||
@@ -1223,19 +1223,19 @@
|
||||
}
|
||||
.proximity-badge.very_close {
|
||||
background: rgba(255, 51, 51, 0.2);
|
||||
color: #ff3333;
|
||||
color: var(--accent-red);
|
||||
}
|
||||
.proximity-badge.close {
|
||||
background: rgba(255, 153, 51, 0.2);
|
||||
color: #ff9933;
|
||||
color: var(--severity-high);
|
||||
}
|
||||
.proximity-badge.moderate {
|
||||
background: rgba(255, 204, 0, 0.2);
|
||||
color: #ffcc00;
|
||||
color: var(--severity-medium);
|
||||
}
|
||||
.proximity-badge.far {
|
||||
background: rgba(0, 204, 0, 0.2);
|
||||
color: #00cc00;
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
/* Add to Known Device Button */
|
||||
@@ -1243,7 +1243,7 @@
|
||||
padding: 4px 8px;
|
||||
font-size: 10px;
|
||||
background: rgba(0, 204, 0, 0.2);
|
||||
color: #00cc00;
|
||||
color: var(--accent-green);
|
||||
border: 1px solid rgba(0, 204, 0, 0.3);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
@@ -1307,15 +1307,15 @@
|
||||
/* Modal Header Classification Colors */
|
||||
.device-detail-header.classification-cyan {
|
||||
background: linear-gradient(135deg, rgba(0, 204, 255, 0.2) 0%, rgba(0, 150, 200, 0.1) 100%);
|
||||
border-bottom: 2px solid #00ccff;
|
||||
border-bottom: 2px solid var(--accent-cyan);
|
||||
}
|
||||
.device-detail-header.classification-orange {
|
||||
background: linear-gradient(135deg, rgba(255, 153, 51, 0.2) 0%, rgba(200, 120, 40, 0.1) 100%);
|
||||
border-bottom: 2px solid #ff9933;
|
||||
border-bottom: 2px solid var(--severity-high);
|
||||
}
|
||||
.device-detail-header.classification-green {
|
||||
background: linear-gradient(135deg, rgba(0, 204, 0, 0.2) 0%, rgba(0, 150, 0, 0.1) 100%);
|
||||
border-bottom: 2px solid #00cc00;
|
||||
border-bottom: 2px solid var(--accent-green);
|
||||
}
|
||||
|
||||
/* Playbook Enhancements */
|
||||
@@ -1330,7 +1330,7 @@
|
||||
font-size: 9px;
|
||||
padding: 2px 6px;
|
||||
background: rgba(255, 153, 51, 0.2);
|
||||
color: #ff9933;
|
||||
color: var(--severity-high);
|
||||
border-radius: 3px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
@@ -1360,7 +1360,7 @@
|
||||
font-size: 9px;
|
||||
padding: 2px 6px;
|
||||
background: rgba(74, 158, 255, 0.2);
|
||||
color: #4a9eff;
|
||||
color: var(--accent-cyan);
|
||||
border-radius: 3px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
@@ -1404,7 +1404,7 @@
|
||||
|
||||
/* Recording State */
|
||||
.icon-recording {
|
||||
color: #ff3366;
|
||||
color: var(--severity-critical);
|
||||
}
|
||||
|
||||
.icon-recording.active svg {
|
||||
@@ -1418,11 +1418,11 @@
|
||||
|
||||
/* Anomaly Indicator */
|
||||
.icon-anomaly {
|
||||
color: #ff9933;
|
||||
color: var(--severity-high);
|
||||
}
|
||||
|
||||
.icon-anomaly.critical {
|
||||
color: #ff3366;
|
||||
color: var(--severity-critical);
|
||||
}
|
||||
|
||||
/* Export Icon */
|
||||
@@ -1508,7 +1508,7 @@
|
||||
}
|
||||
|
||||
.recording-status.active {
|
||||
color: #ff3366;
|
||||
color: var(--severity-critical);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -1526,12 +1526,12 @@
|
||||
|
||||
.anomaly-flag.needs-review {
|
||||
background: rgba(255, 153, 51, 0.2);
|
||||
color: #ff9933;
|
||||
color: var(--severity-high);
|
||||
}
|
||||
|
||||
.anomaly-flag.high-interest {
|
||||
background: rgba(255, 51, 51, 0.2);
|
||||
color: #ff3333;
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
.anomaly-flag .icon {
|
||||
@@ -1639,7 +1639,7 @@
|
||||
}
|
||||
.tscm-summary-risk {
|
||||
font-size: 10px;
|
||||
color: #ff9933;
|
||||
color: var(--severity-high);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
|
||||
@@ -763,7 +763,7 @@
|
||||
border: 1px solid rgba(74, 163, 255, 0.22);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
@media (max-width: 1023px) {
|
||||
.wf-monitor-strip {
|
||||
grid-template-columns: repeat(2, minmax(220px, 1fr));
|
||||
grid-auto-rows: minmax(70px, auto);
|
||||
@@ -778,7 +778,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
@media (max-width: 768px) {
|
||||
.wf-headline {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
@@ -32,12 +32,12 @@
|
||||
}
|
||||
|
||||
.wxsat-strip-dot.capturing {
|
||||
background: #00ff88;
|
||||
background: var(--neon-green);
|
||||
animation: wxsat-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.wxsat-strip-dot.decoding {
|
||||
background: #00d4ff;
|
||||
background: var(--accent-cyan);
|
||||
animation: wxsat-pulse 0.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@@ -70,8 +70,8 @@
|
||||
}
|
||||
|
||||
.wxsat-strip-btn.stop {
|
||||
border-color: #ff4444;
|
||||
color: #ff4444;
|
||||
border-color: var(--accent-red);
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
.wxsat-strip-btn.stop:hover {
|
||||
@@ -124,7 +124,7 @@
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
cursor: pointer;
|
||||
accent-color: #00ff88;
|
||||
accent-color: var(--neon-green);
|
||||
}
|
||||
|
||||
.wxsat-schedule-toggle input:checked + .wxsat-toggle-label {
|
||||
@@ -207,12 +207,12 @@
|
||||
}
|
||||
|
||||
.wxsat-countdown-box.imminent {
|
||||
border-color: #ffbb00;
|
||||
border-color: var(--accent-yellow);
|
||||
box-shadow: 0 0 8px rgba(255, 187, 0, 0.2);
|
||||
}
|
||||
|
||||
.wxsat-countdown-box.active {
|
||||
border-color: #00ff88;
|
||||
border-color: var(--neon-green);
|
||||
box-shadow: 0 0 8px rgba(0, 255, 136, 0.3);
|
||||
animation: wxsat-glow 1.5s ease-in-out infinite;
|
||||
}
|
||||
@@ -293,14 +293,14 @@
|
||||
|
||||
.wxsat-timeline-pass.apt { background: rgba(0, 212, 255, 0.6); }
|
||||
.wxsat-timeline-pass.lrpt { background: rgba(0, 255, 136, 0.6); }
|
||||
.wxsat-timeline-pass.scheduled { border: 1px solid #ffbb00; }
|
||||
.wxsat-timeline-pass.scheduled { border: 1px solid var(--accent-yellow); }
|
||||
|
||||
.wxsat-timeline-cursor {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
width: 2px;
|
||||
height: 20px;
|
||||
background: #ff4444;
|
||||
background: var(--accent-red);
|
||||
border-radius: 1px;
|
||||
z-index: 2;
|
||||
}
|
||||
@@ -375,7 +375,7 @@
|
||||
|
||||
.wxsat-pass-card.active,
|
||||
.wxsat-pass-card.selected {
|
||||
border-color: #00ff88;
|
||||
border-color: var(--neon-green);
|
||||
background: rgba(0, 255, 136, 0.05);
|
||||
}
|
||||
|
||||
@@ -385,7 +385,7 @@
|
||||
padding: 1px 4px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 187, 0, 0.15);
|
||||
color: #ffbb00;
|
||||
color: var(--accent-yellow);
|
||||
margin-left: 6px;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
text-transform: uppercase;
|
||||
@@ -414,12 +414,12 @@
|
||||
|
||||
.wxsat-pass-mode.apt {
|
||||
background: rgba(0, 212, 255, 0.15);
|
||||
color: #00d4ff;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.wxsat-pass-mode.lrpt {
|
||||
background: rgba(0, 255, 136, 0.15);
|
||||
color: #00ff88;
|
||||
color: var(--neon-green);
|
||||
}
|
||||
|
||||
.wxsat-pass-details {
|
||||
@@ -450,17 +450,17 @@
|
||||
|
||||
.wxsat-pass-quality.excellent {
|
||||
background: rgba(0, 255, 136, 0.15);
|
||||
color: #00ff88;
|
||||
color: var(--neon-green);
|
||||
}
|
||||
|
||||
.wxsat-pass-quality.good {
|
||||
background: rgba(0, 212, 255, 0.15);
|
||||
color: #00d4ff;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.wxsat-pass-quality.fair {
|
||||
background: rgba(255, 187, 0, 0.15);
|
||||
color: #ffbb00;
|
||||
color: var(--accent-yellow);
|
||||
}
|
||||
|
||||
/* ===== Center Panel (Polar + Map) ===== */
|
||||
@@ -900,7 +900,7 @@
|
||||
|
||||
.wxsat-modal-btn.delete:hover {
|
||||
background: rgba(255, 68, 68, 0.9);
|
||||
border-color: #ff4444;
|
||||
border-color: var(--accent-red);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
@@ -920,12 +920,12 @@
|
||||
}
|
||||
|
||||
.wxsat-gallery-clear-btn:hover {
|
||||
color: #ff4444;
|
||||
color: var(--accent-red);
|
||||
background: rgba(255, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
/* ===== Responsive ===== */
|
||||
@media (max-width: 1100px) {
|
||||
@media (max-width: 1023px) {
|
||||
.wxsat-content {
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -1041,8 +1041,8 @@
|
||||
}
|
||||
|
||||
.wxsat-phase-step.active {
|
||||
color: #00ff88;
|
||||
border-color: #00ff88;
|
||||
color: var(--neon-green);
|
||||
border-color: var(--neon-green);
|
||||
background: rgba(0, 255, 136, 0.1);
|
||||
box-shadow: 0 0 8px rgba(0, 255, 136, 0.2);
|
||||
}
|
||||
@@ -1055,8 +1055,8 @@
|
||||
}
|
||||
|
||||
.wxsat-phase-step.error {
|
||||
color: #ff4444;
|
||||
border-color: #ff4444;
|
||||
color: var(--accent-red);
|
||||
border-color: var(--accent-red);
|
||||
background: rgba(255, 68, 68, 0.1);
|
||||
box-shadow: 0 0 8px rgba(255, 68, 68, 0.2);
|
||||
}
|
||||
@@ -1115,8 +1115,8 @@
|
||||
}
|
||||
|
||||
.wxsat-console-entry.wxsat-log-signal {
|
||||
border-left-color: #00ff88;
|
||||
color: #00ff88;
|
||||
border-left-color: var(--neon-green);
|
||||
color: var(--neon-green);
|
||||
}
|
||||
|
||||
.wxsat-console-entry.wxsat-log-progress {
|
||||
@@ -1125,18 +1125,18 @@
|
||||
}
|
||||
|
||||
.wxsat-console-entry.wxsat-log-save {
|
||||
border-left-color: #ffbb00;
|
||||
color: #ffbb00;
|
||||
border-left-color: var(--accent-yellow);
|
||||
color: var(--accent-yellow);
|
||||
}
|
||||
|
||||
.wxsat-console-entry.wxsat-log-error {
|
||||
border-left-color: #ff4444;
|
||||
color: #ff4444;
|
||||
border-left-color: var(--accent-red);
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
.wxsat-console-entry.wxsat-log-warning {
|
||||
border-left-color: #ff8800;
|
||||
color: #ff8800;
|
||||
border-left-color: var(--neon-orange);
|
||||
color: var(--neon-orange);
|
||||
}
|
||||
|
||||
.wxsat-console-entry.wxsat-log-debug {
|
||||
|
||||
+32
-32
@@ -41,15 +41,15 @@
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #444;
|
||||
background: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wefax-strip-dot.scanning { background: #ffaa00; animation: wefax-pulse 1.5s ease-in-out infinite; }
|
||||
.wefax-strip-dot.phasing { background: #ffcc44; animation: wefax-pulse 0.8s ease-in-out infinite; }
|
||||
.wefax-strip-dot.receiving { background: #00cc66; animation: wefax-pulse 1s ease-in-out infinite; }
|
||||
.wefax-strip-dot.complete { background: #00cc66; }
|
||||
.wefax-strip-dot.error { background: #f44; }
|
||||
.wefax-strip-dot.scanning { background: var(--accent-orange); animation: wefax-pulse 1.5s ease-in-out infinite; }
|
||||
.wefax-strip-dot.phasing { background: var(--accent-yellow); animation: wefax-pulse 0.8s ease-in-out infinite; }
|
||||
.wefax-strip-dot.receiving { background: var(--accent-green); animation: wefax-pulse 1s ease-in-out infinite; }
|
||||
.wefax-strip-dot.complete { background: var(--accent-green); }
|
||||
.wefax-strip-dot.error { background: var(--accent-red); }
|
||||
|
||||
@keyframes wefax-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
@@ -81,17 +81,17 @@
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.wefax-strip-btn.start { color: #ffaa00; border-color: #ffaa0044; }
|
||||
.wefax-strip-btn.start:hover { background: #ffaa0015; border-color: #ffaa00; }
|
||||
.wefax-strip-btn.start { color: var(--accent-orange); border-color: #ffaa0044; }
|
||||
.wefax-strip-btn.start:hover { background: #ffaa0015; border-color: var(--accent-orange); }
|
||||
.wefax-strip-btn.start.wefax-strip-btn-error {
|
||||
border-color: #ffaa00;
|
||||
color: #ffaa00;
|
||||
border-color: var(--accent-orange);
|
||||
color: var(--accent-orange);
|
||||
box-shadow: 0 0 8px rgba(255, 170, 0, 0.3);
|
||||
animation: wefax-pulse 0.6s ease-in-out 3;
|
||||
}
|
||||
|
||||
.wefax-strip-btn.stop { color: #f44; border-color: #f4444444; }
|
||||
.wefax-strip-btn.stop:hover { background: #f4441a; border-color: #f44; }
|
||||
.wefax-strip-btn.stop { color: var(--accent-red); border-color: #f4444444; }
|
||||
.wefax-strip-btn.stop:hover { background: #f4441a; border-color: var(--accent-red); }
|
||||
|
||||
.wefax-strip-divider {
|
||||
width: 1px;
|
||||
@@ -114,7 +114,7 @@
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.wefax-strip-value.accent-amber { color: #ffaa00; }
|
||||
.wefax-strip-value.accent-amber { color: var(--accent-orange); }
|
||||
|
||||
.wefax-strip-label {
|
||||
font-family: var(--font-mono, monospace);
|
||||
@@ -141,11 +141,11 @@
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
cursor: pointer;
|
||||
accent-color: #ffaa00;
|
||||
accent-color: var(--accent-orange);
|
||||
}
|
||||
|
||||
.wefax-schedule-toggle input:checked + span {
|
||||
color: #ffaa00;
|
||||
color: var(--accent-orange);
|
||||
}
|
||||
|
||||
/* --- Visuals Container --- */
|
||||
@@ -185,7 +185,7 @@
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: #ffaa00;
|
||||
color: var(--accent-orange);
|
||||
}
|
||||
|
||||
.wefax-schedule-list {
|
||||
@@ -209,7 +209,7 @@
|
||||
|
||||
.wefax-schedule-entry.active {
|
||||
background: #ffaa0010;
|
||||
border-left: 3px solid #ffaa00;
|
||||
border-left: 3px solid var(--accent-orange);
|
||||
}
|
||||
|
||||
.wefax-schedule-entry.upcoming {
|
||||
@@ -221,7 +221,7 @@
|
||||
}
|
||||
|
||||
.wefax-schedule-time {
|
||||
color: #ffaa00;
|
||||
color: var(--accent-orange);
|
||||
min-width: 45px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@@ -241,7 +241,7 @@
|
||||
|
||||
.wefax-schedule-badge.live {
|
||||
background: #ffaa0030;
|
||||
color: #ffaa00;
|
||||
color: var(--accent-orange);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -279,7 +279,7 @@
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: #ffaa00;
|
||||
color: var(--accent-orange);
|
||||
}
|
||||
|
||||
.wefax-live-content {
|
||||
@@ -298,7 +298,7 @@
|
||||
.wefax-idle-state svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: #ffaa0033;
|
||||
color: rgba(214, 168, 94, 0.2);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@@ -341,7 +341,7 @@
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: #ffaa00;
|
||||
color: var(--accent-orange);
|
||||
}
|
||||
|
||||
.wefax-gallery-controls {
|
||||
@@ -370,8 +370,8 @@
|
||||
}
|
||||
|
||||
.wefax-gallery-clear-btn:hover {
|
||||
border-color: #f44;
|
||||
color: #f44;
|
||||
border-color: var(--accent-red);
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
.wefax-gallery-grid {
|
||||
@@ -442,7 +442,7 @@
|
||||
border-radius: 3px;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: #ccc;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
@@ -451,8 +451,8 @@
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.wefax-gallery-action:hover { color: #fff; }
|
||||
.wefax-gallery-action.delete:hover { color: #f44; }
|
||||
.wefax-gallery-action:hover { color: var(--text-primary); }
|
||||
.wefax-gallery-action.delete:hover { color: var(--accent-red); }
|
||||
|
||||
/* --- Countdown Bar + Timeline --- */
|
||||
.wefax-countdown-bar {
|
||||
@@ -490,12 +490,12 @@
|
||||
}
|
||||
|
||||
.wefax-countdown-box.imminent {
|
||||
border-color: #ffaa00;
|
||||
border-color: var(--accent-orange);
|
||||
box-shadow: 0 0 8px rgba(255, 170, 0, 0.2);
|
||||
}
|
||||
|
||||
.wefax-countdown-box.active {
|
||||
border-color: #ffaa00;
|
||||
border-color: var(--accent-orange);
|
||||
box-shadow: 0 0 8px rgba(255, 170, 0, 0.3);
|
||||
animation: wefax-glow 1.5s ease-in-out infinite;
|
||||
}
|
||||
@@ -530,7 +530,7 @@
|
||||
.wefax-countdown-content {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #ffaa00;
|
||||
color: var(--accent-orange);
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
}
|
||||
|
||||
@@ -576,7 +576,7 @@
|
||||
|
||||
.wefax-timeline-broadcast.active {
|
||||
background: rgba(255, 170, 0, 0.85);
|
||||
border: 1px solid #ffaa00;
|
||||
border: 1px solid var(--accent-orange);
|
||||
}
|
||||
|
||||
.wefax-timeline-cursor {
|
||||
@@ -584,7 +584,7 @@
|
||||
top: 2px;
|
||||
width: 2px;
|
||||
height: 20px;
|
||||
background: #ff4444;
|
||||
background: var(--accent-red);
|
||||
border-radius: 1px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
@@ -361,7 +361,7 @@
|
||||
RESPONSIVE
|
||||
============================================ */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
@media (max-width: 1023px) {
|
||||
.wfl-rssi-display {
|
||||
font-size: 48px;
|
||||
}
|
||||
|
||||
+144
-140
@@ -25,20 +25,20 @@
|
||||
--font-2xl: clamp(24px, 6vw, 40px);
|
||||
|
||||
/* Header height for calculations */
|
||||
--header-height: 52px;
|
||||
--header-height: 48px;
|
||||
--nav-height: 44px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
:root {
|
||||
--header-height: 60px;
|
||||
--header-height: 48px;
|
||||
--nav-height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
:root {
|
||||
--header-height: 96px;
|
||||
--header-height: 48px;
|
||||
--nav-height: 0px;
|
||||
}
|
||||
}
|
||||
@@ -424,30 +424,30 @@
|
||||
/* ============== MOBILE LAYOUT FIXES ============== */
|
||||
@media (max-width: 1023px) {
|
||||
/* Fix main content to allow scrolling on mobile */
|
||||
.main-content {
|
||||
height: auto !important;
|
||||
.app-shell .main-content {
|
||||
height: auto;
|
||||
min-height: calc(100dvh - var(--header-height) - var(--nav-height));
|
||||
overflow-y: auto !important;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
.app-shell .sidebar {
|
||||
padding: 10px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.output-panel {
|
||||
.app-shell .output-panel {
|
||||
min-height: 58vh;
|
||||
}
|
||||
|
||||
.output-header {
|
||||
.app-shell .output-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
.app-shell .header-controls {
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
@@ -455,20 +455,21 @@
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.header-controls .stats {
|
||||
.app-shell .header-controls .stats {
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
/* Container should not clip content */
|
||||
.container {
|
||||
.app-shell .container {
|
||||
overflow: visible;
|
||||
height: auto;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
/* Layout containers need to stack vertically on mobile */
|
||||
.wifi-layout-container,
|
||||
.bt-layout-container {
|
||||
/* overrides inline style - JS sets display via style attribute */
|
||||
.app-shell .wifi-layout-container,
|
||||
.app-shell .bt-layout-container {
|
||||
flex-direction: column !important;
|
||||
height: auto !important;
|
||||
max-height: none !important;
|
||||
@@ -478,126 +479,128 @@
|
||||
}
|
||||
|
||||
/* Visual panels should be scrollable, not clipped */
|
||||
.wifi-visuals,
|
||||
.bt-visuals-column {
|
||||
max-height: none !important;
|
||||
overflow: visible !important;
|
||||
.app-shell .wifi-visuals,
|
||||
.app-shell .bt-visuals-column {
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
/* Device lists should have reasonable height on mobile */
|
||||
.wifi-device-list,
|
||||
.bt-device-list {
|
||||
.app-shell .wifi-device-list,
|
||||
.app-shell .bt-device-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Visual panels should stack in single column on mobile when visible */
|
||||
.wifi-visuals,
|
||||
.bt-visuals-column {
|
||||
.app-shell .wifi-visuals,
|
||||
.app-shell .bt-visuals-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Only apply flex when aircraft visuals are shown (via JS setting display: grid) */
|
||||
#aircraftVisuals[style*="grid"] {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
/* Stack aircraft visuals vertically on mobile when active */
|
||||
#aircraftVisuals.active {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* APRS visuals - only when visible */
|
||||
#aprsVisuals[style*="flex"] {
|
||||
flex-direction: column !important;
|
||||
/* APRS visuals stack vertically on mobile */
|
||||
.app-shell #aprsVisuals {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wifi-visual-panel {
|
||||
grid-column: auto !important;
|
||||
.app-shell .wifi-visual-panel {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.bt-main-area {
|
||||
flex-direction: column !important;
|
||||
min-height: auto !important;
|
||||
.app-shell .bt-main-area {
|
||||
flex-direction: column;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.bt-side-panels {
|
||||
width: 100% !important;
|
||||
flex-direction: column !important;
|
||||
.app-shell .bt-side-panels {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.bt-detail-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
|
||||
.app-shell .bt-detail-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.bt-row-secondary {
|
||||
padding-left: 0 !important;
|
||||
white-space: normal !important;
|
||||
.app-shell .bt-row-secondary {
|
||||
padding-left: 0;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.bt-row-actions {
|
||||
padding-left: 0 !important;
|
||||
justify-content: flex-start !important;
|
||||
.app-shell .bt-row-actions {
|
||||
padding-left: 0;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.bt-list-summary {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
|
||||
.app-shell .bt-list-summary {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
/* ============== MOBILE MAP FIXES ============== */
|
||||
@media (max-width: 1023px) {
|
||||
/* Aircraft map container needs explicit height on mobile */
|
||||
.aircraft-map-container {
|
||||
height: 300px !important;
|
||||
min-height: 300px !important;
|
||||
width: 100% !important;
|
||||
.app-shell .aircraft-map-container {
|
||||
height: 300px;
|
||||
min-height: 300px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#aircraftMap {
|
||||
height: 100% !important;
|
||||
width: 100% !important;
|
||||
.app-shell #aircraftMap {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
min-height: 250px;
|
||||
}
|
||||
|
||||
/* APRS map container */
|
||||
#aprsMap {
|
||||
min-height: 300px !important;
|
||||
height: 300px !important;
|
||||
width: 100% !important;
|
||||
.app-shell #aprsMap {
|
||||
min-height: 300px;
|
||||
height: 300px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Satellite embed */
|
||||
.satellite-dashboard-embed {
|
||||
height: 400px !important;
|
||||
min-height: 400px !important;
|
||||
.app-shell .satellite-dashboard-embed {
|
||||
height: 400px;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
/* Map panels should be full width */
|
||||
/* overrides inline style - HTML sets grid-column via style attribute */
|
||||
.wifi-visual-panel[style*="grid-column: span 2"] {
|
||||
grid-column: auto !important;
|
||||
}
|
||||
|
||||
/* Make map container full width when it has ACARS sidebar */
|
||||
/* overrides inline style - HTML sets flex-direction via style attribute */
|
||||
.wifi-visual-panel[style*="display: flex"][style*="gap: 0"] {
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
/* ACARS sidebar should be below map on mobile */
|
||||
.main-acars-sidebar {
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
border-left: none !important;
|
||||
border-top: 1px solid var(--border-color, #1f2937) !important;
|
||||
.app-shell .main-acars-sidebar {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--border-color, #1f2937);
|
||||
}
|
||||
|
||||
.main-acars-sidebar.collapsed {
|
||||
width: 100% !important;
|
||||
.app-shell .main-acars-sidebar.collapsed {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.main-acars-content {
|
||||
max-height: 200px !important;
|
||||
.app-shell .main-acars-content {
|
||||
max-height: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -611,55 +614,56 @@
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.leaflet-control-zoom a {
|
||||
min-width: var(--touch-min, 44px) !important;
|
||||
min-height: var(--touch-min, 44px) !important;
|
||||
line-height: var(--touch-min, 44px) !important;
|
||||
font-size: 18px !important;
|
||||
.app-shell .leaflet-container .leaflet-control-zoom a {
|
||||
min-width: var(--touch-min, 44px);
|
||||
min-height: var(--touch-min, 44px);
|
||||
line-height: var(--touch-min, 44px);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* ============== MOBILE HEADER STATS ============== */
|
||||
@media (max-width: 1023px) {
|
||||
.header-stats {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Simplify header on mobile */
|
||||
header h1 {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
header h1 .tagline,
|
||||
header h1 .version-badge {
|
||||
.app-shell .header-stats {
|
||||
display: none;
|
||||
}
|
||||
|
||||
header .subtitle {
|
||||
font-size: 10px !important;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
/* Simplify header on mobile */
|
||||
.app-shell header h1 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
header .logo svg {
|
||||
width: 30px !important;
|
||||
height: 30px !important;
|
||||
.app-shell header h1 .tagline {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-shell header .version-badge {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-shell header .active-mode-indicator {
|
||||
font-size: 9px;
|
||||
padding: 3px 8px;
|
||||
}
|
||||
|
||||
.app-shell header .logo svg {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============== MOBILE MODE PANELS ============== */
|
||||
@media (max-width: 1023px) {
|
||||
/* Mode panel grids should be single column */
|
||||
.data-grid,
|
||||
.stats-grid,
|
||||
.sensor-grid {
|
||||
grid-template-columns: 1fr !important;
|
||||
.app-shell .data-grid,
|
||||
.app-shell .stats-grid,
|
||||
.app-shell .sensor-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* Section headers should be easier to tap */
|
||||
.section h3 {
|
||||
.app-shell .section h3 {
|
||||
min-height: var(--touch-min);
|
||||
padding: 12px !important;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* Tables need horizontal scroll */
|
||||
@@ -682,85 +686,85 @@
|
||||
|
||||
/* ============== WELCOME PAGE MOBILE ============== */
|
||||
@media (max-width: 767px) {
|
||||
.welcome-container {
|
||||
padding: 15px !important;
|
||||
max-width: 100% !important;
|
||||
.app-shell .welcome-container {
|
||||
padding: 15px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.welcome-header {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
gap: 10px;
|
||||
.app-shell .welcome-header {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 6px 10px;
|
||||
}
|
||||
|
||||
.welcome-logo svg {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
.app-shell .welcome-logo svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.welcome-title {
|
||||
font-size: 24px !important;
|
||||
.app-shell .welcome-title {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.welcome-content {
|
||||
grid-template-columns: 1fr !important;
|
||||
.app-shell .welcome-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.mode-grid {
|
||||
grid-template-columns: repeat(2, 1fr) !important;
|
||||
gap: 8px !important;
|
||||
.app-shell .mode-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mode-card {
|
||||
padding: 12px 8px !important;
|
||||
.app-shell .mode-card {
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
.mode-icon {
|
||||
font-size: 20px !important;
|
||||
.app-shell .mode-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.mode-name {
|
||||
font-size: 11px !important;
|
||||
.app-shell .mode-name {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.mode-desc {
|
||||
font-size: 9px !important;
|
||||
.app-shell .mode-desc {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.changelog-release {
|
||||
padding: 10px !important;
|
||||
.app-shell .changelog-release {
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============== TSCM MODE MOBILE ============== */
|
||||
@media (max-width: 1023px) {
|
||||
.tscm-layout {
|
||||
flex-direction: column !important;
|
||||
height: auto !important;
|
||||
.app-shell .tscm-layout {
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.tscm-spectrum-panel,
|
||||
.tscm-detection-panel {
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
height: auto !important;
|
||||
.app-shell .tscm-spectrum-panel,
|
||||
.app-shell .tscm-detection-panel {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
height: auto;
|
||||
min-height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============== LISTENING POST MOBILE ============== */
|
||||
@media (max-width: 1023px) {
|
||||
.radio-controls-section {
|
||||
flex-direction: column !important;
|
||||
.app-shell .radio-controls-section {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.knobs-row {
|
||||
.app-shell .knobs-row {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.radio-module-box {
|
||||
width: 100% !important;
|
||||
.app-shell .radio-module-box {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ body {
|
||||
}
|
||||
|
||||
/* Mobile header adjustments */
|
||||
@media (max-width: 800px) {
|
||||
@media (max-width: 768px) {
|
||||
.header {
|
||||
padding: 10px 12px;
|
||||
flex-wrap: wrap;
|
||||
@@ -709,7 +709,7 @@ body {
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1200px) {
|
||||
@media (max-width: 1280px) {
|
||||
.dashboard {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr auto auto;
|
||||
@@ -745,7 +745,7 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
@media (max-width: 768px) {
|
||||
.dashboard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -528,13 +528,13 @@ html.map-cyber-enabled .leaflet-container::after {
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 960px) {
|
||||
@media (max-width: 1023px) {
|
||||
.settings-tabs {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
@media (max-width: 768px) {
|
||||
.settings-modal.active {
|
||||
padding: 20px 10px;
|
||||
}
|
||||
|
||||
@@ -485,7 +485,7 @@ async function syncLocalModeStates() {
|
||||
*/
|
||||
function showAgentModeWarnings(runningModes, modesDetail = {}) {
|
||||
// SDR modes that can't run simultaneously on same device
|
||||
const sdrModes = ['sensor', 'pager', 'adsb', 'ais', 'acars', 'vdl2', 'aprs', 'rtlamr', 'listening_post', 'tscm', 'dsc'];
|
||||
const sdrModes = ['sensor', 'pager', 'adsb', 'ais', 'acars', 'vdl2', 'aprs', 'rtlamr', 'listening_post', 'tscm', 'dsc'];
|
||||
const runningSdrModes = runningModes.filter(m => sdrModes.includes(m));
|
||||
|
||||
let warning = document.getElementById('agentModeWarning');
|
||||
@@ -613,7 +613,7 @@ function checkAgentAudioMode(modeToStart) {
|
||||
* @param {string} modeToStart - Mode to start
|
||||
* @param {number} deviceToUse - Device index to use (optional, for smarter conflict detection)
|
||||
*/
|
||||
function checkAgentModeConflict(modeToStart, deviceToUse = null) {
|
||||
async function checkAgentModeConflict(modeToStart, deviceToUse = null) {
|
||||
if (currentAgent === 'local') return true; // No conflict checking for local
|
||||
|
||||
// First check if this is an audio mode
|
||||
@@ -621,7 +621,7 @@ function checkAgentModeConflict(modeToStart, deviceToUse = null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sdrModes = ['sensor', 'pager', 'adsb', 'ais', 'acars', 'vdl2', 'aprs', 'rtlamr', 'listening_post', 'tscm', 'dsc'];
|
||||
const sdrModes = ['sensor', 'pager', 'adsb', 'ais', 'acars', 'vdl2', 'aprs', 'rtlamr', 'listening_post', 'tscm', 'dsc'];
|
||||
|
||||
// If we're trying to start an SDR mode
|
||||
if (sdrModes.includes(modeToStart)) {
|
||||
@@ -648,11 +648,12 @@ function checkAgentModeConflict(modeToStart, deviceToUse = null) {
|
||||
return detail ? `${m} (SDR ${detail.device})` : m;
|
||||
}).join(', ');
|
||||
|
||||
const proceed = confirm(
|
||||
`The agent's SDR device is currently running: ${modeList}\n\n` +
|
||||
`Starting ${modeToStart} on the same device will fail.\n\n` +
|
||||
`Do you want to stop the conflicting mode(s) first?`
|
||||
);
|
||||
const proceed = await AppFeedback.confirmAction({
|
||||
title: 'SDR Device Conflict',
|
||||
message: `The agent's SDR device is currently running: ${modeList}. Starting ${modeToStart} on the same device will fail. Do you want to stop the conflicting mode(s) first?`,
|
||||
confirmLabel: 'Stop & Continue',
|
||||
confirmClass: 'btn-danger'
|
||||
});
|
||||
|
||||
if (proceed) {
|
||||
// Stop conflicting modes
|
||||
|
||||
@@ -269,8 +269,14 @@ const AlertCenter = (function() {
|
||||
});
|
||||
}
|
||||
|
||||
function deleteRule(ruleId) {
|
||||
if (!confirm('Delete this alert rule?')) return;
|
||||
async function deleteRule(ruleId) {
|
||||
const confirmed = await AppFeedback.confirmAction({
|
||||
title: 'Delete Alert Rule',
|
||||
message: 'Delete this alert rule?',
|
||||
confirmLabel: 'Delete',
|
||||
confirmClass: 'btn-danger'
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
fetch(`/alerts/rules/${ruleId}`, { method: 'DELETE' })
|
||||
.then((r) => r.json())
|
||||
|
||||
+31
-36
@@ -120,19 +120,19 @@ function switchMode(mode) {
|
||||
document.getElementById('spystationsMode')?.classList.toggle('active', mode === 'spystations');
|
||||
document.getElementById('meshtasticMode')?.classList.toggle('active', mode === 'meshtastic');
|
||||
|
||||
// Toggle stats visibility
|
||||
document.getElementById('pagerStats').style.display = mode === 'pager' ? 'flex' : 'none';
|
||||
document.getElementById('sensorStats').style.display = mode === 'sensor' ? 'flex' : 'none';
|
||||
document.getElementById('aircraftStats').style.display = mode === 'aircraft' ? 'flex' : 'none';
|
||||
document.getElementById('satelliteStats').style.display = mode === 'satellite' ? 'flex' : 'none';
|
||||
document.getElementById('wifiStats').style.display = mode === 'wifi' ? 'flex' : 'none';
|
||||
// Toggle stats visibility via class
|
||||
document.getElementById('pagerStats')?.classList.toggle('active', mode === 'pager');
|
||||
document.getElementById('sensorStats')?.classList.toggle('active', mode === 'sensor');
|
||||
document.getElementById('aircraftStats')?.classList.toggle('active', mode === 'aircraft');
|
||||
document.getElementById('satelliteStats')?.classList.toggle('active', mode === 'satellite');
|
||||
document.getElementById('wifiStats')?.classList.toggle('active', mode === 'wifi');
|
||||
|
||||
// Hide signal meter - individual panels show signal strength where needed
|
||||
document.getElementById('signalMeter').style.display = 'none';
|
||||
// Hide signal meter
|
||||
document.getElementById('signalMeter')?.classList.remove('active');
|
||||
|
||||
// Show/hide dashboard buttons in nav bar
|
||||
document.getElementById('adsbDashboardBtn').style.display = mode === 'aircraft' ? 'inline-flex' : 'none';
|
||||
document.getElementById('satelliteDashboardBtn').style.display = mode === 'satellite' ? 'inline-flex' : 'none';
|
||||
document.getElementById('adsbDashboardBtn')?.classList.toggle('active', mode === 'aircraft');
|
||||
document.getElementById('satelliteDashboardBtn')?.classList.toggle('active', mode === 'satellite');
|
||||
|
||||
// Update active mode indicator
|
||||
const modeNames = {
|
||||
@@ -156,14 +156,14 @@ function switchMode(mode) {
|
||||
window.closeMobileDrawer();
|
||||
}
|
||||
|
||||
// Toggle layout containers
|
||||
document.getElementById('wifiLayoutContainer').style.display = mode === 'wifi' ? 'flex' : 'none';
|
||||
document.getElementById('btLayoutContainer').style.display = mode === 'bluetooth' ? 'flex' : 'none';
|
||||
// Toggle layout containers via class
|
||||
document.getElementById('wifiLayoutContainer')?.classList.toggle('active', mode === 'wifi');
|
||||
document.getElementById('btLayoutContainer')?.classList.toggle('active', mode === 'bluetooth');
|
||||
|
||||
// Respect the "Show Radar Display" checkbox for aircraft mode
|
||||
const showRadar = document.getElementById('adsbEnableMap')?.checked;
|
||||
document.getElementById('aircraftVisuals').style.display = (mode === 'aircraft' && showRadar) ? 'grid' : 'none';
|
||||
document.getElementById('satelliteVisuals').style.display = mode === 'satellite' ? 'block' : 'none';
|
||||
document.getElementById('aircraftVisuals')?.classList.toggle('active', mode === 'aircraft' && showRadar);
|
||||
document.getElementById('satelliteVisuals')?.classList.toggle('active', mode === 'satellite');
|
||||
|
||||
// Update output panel title based on mode
|
||||
const titles = {
|
||||
@@ -178,35 +178,30 @@ function switchMode(mode) {
|
||||
document.getElementById('outputTitle').textContent = titles[mode] || 'Signal Monitor';
|
||||
|
||||
// Show/hide Device Intelligence for modes that use it
|
||||
const hideRecon = (mode === 'satellite' || mode === 'aircraft');
|
||||
const reconBtn = document.getElementById('reconBtn');
|
||||
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
|
||||
if (mode === 'satellite' || mode === 'aircraft') {
|
||||
document.getElementById('reconPanel').style.display = 'none';
|
||||
if (reconBtn) reconBtn.style.display = 'none';
|
||||
if (intelBtn) intelBtn.style.display = 'none';
|
||||
} else {
|
||||
if (reconBtn) reconBtn.style.display = 'inline-block';
|
||||
if (intelBtn) intelBtn.style.display = 'inline-block';
|
||||
if (typeof reconEnabled !== 'undefined' && reconEnabled) {
|
||||
document.getElementById('reconPanel').style.display = 'block';
|
||||
}
|
||||
}
|
||||
document.getElementById('reconPanel')?.classList.toggle('active', !hideRecon && typeof reconEnabled !== 'undefined' && reconEnabled);
|
||||
if (reconBtn) reconBtn.classList.toggle('hidden', hideRecon);
|
||||
if (intelBtn) intelBtn.classList.toggle('hidden', hideRecon);
|
||||
|
||||
// Show RTL-SDR device section for modes that use it
|
||||
document.getElementById('rtlDeviceSection').style.display =
|
||||
(mode === 'pager' || mode === 'sensor' || mode === 'aircraft') ? 'block' : 'none';
|
||||
const showRtl = (mode === 'pager' || mode === 'sensor' || mode === 'aircraft');
|
||||
document.getElementById('rtlDeviceSection')?.classList.toggle('active', showRtl);
|
||||
|
||||
// Toggle mode-specific tool status displays
|
||||
document.getElementById('toolStatusPager').style.display = (mode === 'pager') ? 'grid' : 'none';
|
||||
document.getElementById('toolStatusSensor').style.display = (mode === 'sensor') ? 'grid' : 'none';
|
||||
document.getElementById('toolStatusAircraft').style.display = (mode === 'aircraft') ? 'grid' : 'none';
|
||||
document.getElementById('toolStatusPager')?.classList.toggle('active', mode === 'pager');
|
||||
document.getElementById('toolStatusSensor')?.classList.toggle('active', mode === 'sensor');
|
||||
document.getElementById('toolStatusAircraft')?.classList.toggle('active', mode === 'aircraft');
|
||||
|
||||
// Hide waterfall and output console for modes with their own visualizations
|
||||
document.querySelector('.waterfall-container').style.display =
|
||||
(mode === 'satellite' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
|
||||
document.getElementById('output').style.display =
|
||||
(mode === 'satellite' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
|
||||
document.querySelector('.status-bar').style.display = (mode === 'satellite' || mode === 'tscm' || mode === 'meshtastic' || mode === 'aprs' || mode === 'spystations') ? 'none' : 'flex';
|
||||
const fullVisualModes = ['satellite', 'aircraft', 'wifi', 'bluetooth', 'meshtastic', 'aprs', 'tscm', 'spystations'];
|
||||
const hideConsole = fullVisualModes.includes(mode);
|
||||
document.querySelector('.waterfall-container')?.classList.toggle('active', !hideConsole);
|
||||
document.getElementById('output')?.classList.toggle('active', !hideConsole);
|
||||
|
||||
const hideStatusBar = ['satellite', 'tscm', 'meshtastic', 'aprs', 'spystations'].includes(mode);
|
||||
document.querySelector('.status-bar')?.classList.toggle('active', !hideStatusBar);
|
||||
|
||||
// Load interfaces and initialize visualizations when switching modes
|
||||
if (mode === 'wifi') {
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* SSEManager - Centralized Server-Sent Events connection manager
|
||||
* Handles connection lifecycle, reconnection with exponential backoff,
|
||||
* visibility-based pause/resume, and state change notifications.
|
||||
*/
|
||||
const SSEManager = (function() {
|
||||
'use strict';
|
||||
|
||||
const STATES = {
|
||||
CONNECTING: 'connecting',
|
||||
OPEN: 'open',
|
||||
RECONNECTING: 'reconnecting',
|
||||
CLOSED: 'closed',
|
||||
ERROR: 'error',
|
||||
};
|
||||
|
||||
const BACKOFF_INITIAL = 1000;
|
||||
const BACKOFF_MAX = 30000;
|
||||
const BACKOFF_MULTIPLIER = 2;
|
||||
|
||||
/** @type {Map<string, ConnectionEntry>} */
|
||||
const connections = new Map();
|
||||
|
||||
/**
|
||||
* @typedef {Object} ConnectionEntry
|
||||
* @property {string} key
|
||||
* @property {string} url
|
||||
* @property {EventSource|null} source
|
||||
* @property {string} state
|
||||
* @property {number} backoff
|
||||
* @property {number|null} retryTimer
|
||||
* @property {boolean} intentionallyClosed
|
||||
* @property {Function|null} onMessage
|
||||
* @property {Function|null} onStateChange
|
||||
*/
|
||||
|
||||
function connect(key, url, options) {
|
||||
const opts = options || {};
|
||||
|
||||
// Disconnect existing connection for this key
|
||||
if (connections.has(key)) {
|
||||
disconnect(key);
|
||||
}
|
||||
|
||||
const entry = {
|
||||
key: key,
|
||||
url: url,
|
||||
source: null,
|
||||
state: STATES.CLOSED,
|
||||
backoff: BACKOFF_INITIAL,
|
||||
retryTimer: null,
|
||||
intentionallyClosed: false,
|
||||
onMessage: typeof opts.onMessage === 'function' ? opts.onMessage : null,
|
||||
onStateChange: typeof opts.onStateChange === 'function' ? opts.onStateChange : null,
|
||||
};
|
||||
|
||||
connections.set(key, entry);
|
||||
openConnection(entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
function openConnection(entry) {
|
||||
if (entry.intentionallyClosed) return;
|
||||
|
||||
setState(entry, entry.state === STATES.CLOSED ? STATES.CONNECTING : STATES.RECONNECTING);
|
||||
|
||||
try {
|
||||
const source = new EventSource(entry.url);
|
||||
entry.source = source;
|
||||
|
||||
source.onopen = function() {
|
||||
entry.backoff = BACKOFF_INITIAL;
|
||||
setState(entry, STATES.OPEN);
|
||||
};
|
||||
|
||||
source.onmessage = function(event) {
|
||||
if (entry.onMessage) {
|
||||
try {
|
||||
entry.onMessage(event);
|
||||
} catch (err) {
|
||||
console.debug('[SSEManager] onMessage error for ' + entry.key + ':', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
source.onerror = function() {
|
||||
// EventSource fires error on close and connection loss
|
||||
if (entry.intentionallyClosed) return;
|
||||
|
||||
closeSource(entry);
|
||||
setState(entry, STATES.ERROR);
|
||||
scheduleReconnect(entry);
|
||||
};
|
||||
} catch (err) {
|
||||
setState(entry, STATES.ERROR);
|
||||
scheduleReconnect(entry);
|
||||
}
|
||||
}
|
||||
|
||||
function closeSource(entry) {
|
||||
if (entry.source) {
|
||||
entry.source.onopen = null;
|
||||
entry.source.onmessage = null;
|
||||
entry.source.onerror = null;
|
||||
try { entry.source.close(); } catch (e) { /* ignore */ }
|
||||
entry.source = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReconnect(entry) {
|
||||
if (entry.intentionallyClosed) return;
|
||||
if (entry.retryTimer) return;
|
||||
|
||||
// Pause reconnection when tab is hidden
|
||||
if (document.hidden) {
|
||||
setState(entry, STATES.RECONNECTING);
|
||||
return;
|
||||
}
|
||||
|
||||
const delay = entry.backoff;
|
||||
entry.backoff = Math.min(entry.backoff * BACKOFF_MULTIPLIER, BACKOFF_MAX);
|
||||
|
||||
setState(entry, STATES.RECONNECTING);
|
||||
|
||||
entry.retryTimer = window.setTimeout(function() {
|
||||
entry.retryTimer = null;
|
||||
if (!entry.intentionallyClosed) {
|
||||
openConnection(entry);
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
|
||||
function disconnect(key) {
|
||||
const entry = connections.get(key);
|
||||
if (!entry) return;
|
||||
|
||||
entry.intentionallyClosed = true;
|
||||
|
||||
if (entry.retryTimer) {
|
||||
clearTimeout(entry.retryTimer);
|
||||
entry.retryTimer = null;
|
||||
}
|
||||
|
||||
closeSource(entry);
|
||||
setState(entry, STATES.CLOSED);
|
||||
connections.delete(key);
|
||||
}
|
||||
|
||||
function disconnectAll() {
|
||||
for (const key of Array.from(connections.keys())) {
|
||||
disconnect(key);
|
||||
}
|
||||
}
|
||||
|
||||
function getState(key) {
|
||||
const entry = connections.get(key);
|
||||
return entry ? entry.state : STATES.CLOSED;
|
||||
}
|
||||
|
||||
function getActiveKeys() {
|
||||
const keys = [];
|
||||
connections.forEach(function(entry, key) {
|
||||
if (entry.state === STATES.OPEN) {
|
||||
keys.push(key);
|
||||
}
|
||||
});
|
||||
return keys;
|
||||
}
|
||||
|
||||
function setState(entry, newState) {
|
||||
if (entry.state === newState) return;
|
||||
const oldState = entry.state;
|
||||
entry.state = newState;
|
||||
|
||||
if (entry.onStateChange) {
|
||||
try {
|
||||
entry.onStateChange(newState, oldState, entry.key);
|
||||
} catch (err) {
|
||||
console.debug('[SSEManager] onStateChange error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Update global indicator
|
||||
updateGlobalIndicator();
|
||||
}
|
||||
|
||||
// --- Global SSE Status Indicator ---
|
||||
|
||||
function updateGlobalIndicator() {
|
||||
const dot = document.getElementById('sseStatusDot');
|
||||
if (!dot) return;
|
||||
|
||||
let hasOpen = false;
|
||||
let hasReconnecting = false;
|
||||
let hasError = false;
|
||||
|
||||
connections.forEach(function(entry) {
|
||||
if (entry.state === STATES.OPEN) hasOpen = true;
|
||||
else if (entry.state === STATES.RECONNECTING || entry.state === STATES.CONNECTING) hasReconnecting = true;
|
||||
else if (entry.state === STATES.ERROR) hasError = true;
|
||||
});
|
||||
|
||||
// Remove all state classes
|
||||
dot.classList.remove('online', 'warning', 'error', 'inactive');
|
||||
|
||||
if (connections.size === 0) {
|
||||
dot.classList.add('inactive');
|
||||
dot.setAttribute('data-tooltip', 'No active streams');
|
||||
} else if (hasError && !hasOpen) {
|
||||
dot.classList.add('error');
|
||||
dot.setAttribute('data-tooltip', 'Stream connection error');
|
||||
} else if (hasReconnecting) {
|
||||
dot.classList.add('warning');
|
||||
dot.setAttribute('data-tooltip', 'Reconnecting...');
|
||||
} else if (hasOpen) {
|
||||
dot.classList.add('online');
|
||||
dot.setAttribute('data-tooltip', 'Streams connected');
|
||||
} else {
|
||||
dot.classList.add('inactive');
|
||||
dot.setAttribute('data-tooltip', 'Streams idle');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Visibility API: pause/resume reconnection ---
|
||||
|
||||
document.addEventListener('visibilitychange', function() {
|
||||
if (document.hidden) return;
|
||||
|
||||
// Tab became visible — reconnect any entries that were waiting
|
||||
connections.forEach(function(entry) {
|
||||
if (!entry.intentionallyClosed && !entry.source && !entry.retryTimer) {
|
||||
openConnection(entry);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
STATES: STATES,
|
||||
connect: connect,
|
||||
disconnect: disconnect,
|
||||
disconnectAll: disconnectAll,
|
||||
getState: getState,
|
||||
getActiveKeys: getActiveKeys,
|
||||
};
|
||||
})();
|
||||
@@ -3,6 +3,7 @@ const AppFeedback = (function() {
|
||||
|
||||
let stackEl = null;
|
||||
let nextToastId = 1;
|
||||
const TOAST_MAX = 5;
|
||||
|
||||
function init() {
|
||||
ensureStack();
|
||||
@@ -17,6 +18,8 @@ const AppFeedback = (function() {
|
||||
stackEl = document.createElement('div');
|
||||
stackEl.id = 'appToastStack';
|
||||
stackEl.className = 'app-toast-stack';
|
||||
stackEl.setAttribute('aria-live', 'assertive');
|
||||
stackEl.setAttribute('role', 'alert');
|
||||
document.body.appendChild(stackEl);
|
||||
}
|
||||
return stackEl;
|
||||
@@ -64,7 +67,14 @@ const AppFeedback = (function() {
|
||||
root.appendChild(actionsEl);
|
||||
}
|
||||
|
||||
ensureStack().appendChild(root);
|
||||
const stack = ensureStack();
|
||||
|
||||
// Enforce toast cap — remove oldest when exceeded
|
||||
while (stack.children.length >= TOAST_MAX) {
|
||||
stack.removeChild(stack.firstChild);
|
||||
}
|
||||
|
||||
stack.appendChild(root);
|
||||
|
||||
if (durationMs > 0) {
|
||||
window.setTimeout(() => {
|
||||
@@ -240,6 +250,151 @@ const AppFeedback = (function() {
|
||||
return text.includes('permission') || text.includes('denied') || text.includes('dependency') || text.includes('tool');
|
||||
}
|
||||
|
||||
// --- Button loading state ---
|
||||
|
||||
function withLoadingButton(btn, asyncFn) {
|
||||
if (!btn || btn.disabled) return Promise.resolve();
|
||||
|
||||
const originalText = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.classList.add('btn-loading');
|
||||
|
||||
return Promise.resolve()
|
||||
.then(function() { return asyncFn(); })
|
||||
.then(function(result) {
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('btn-loading');
|
||||
btn.textContent = originalText;
|
||||
return result;
|
||||
})
|
||||
.catch(function(err) {
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('btn-loading');
|
||||
btn.textContent = originalText;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
// --- Confirmation modal ---
|
||||
|
||||
function confirmAction(options) {
|
||||
var opts = options || {};
|
||||
var title = opts.title || 'Confirm Action';
|
||||
var message = opts.message || 'Are you sure?';
|
||||
var confirmLabel = opts.confirmLabel || 'Confirm';
|
||||
var confirmClass = opts.confirmClass || 'btn-danger';
|
||||
|
||||
return new Promise(function(resolve) {
|
||||
// Create backdrop
|
||||
var backdrop = document.createElement('div');
|
||||
backdrop.className = 'confirm-modal-backdrop';
|
||||
|
||||
var modal = document.createElement('div');
|
||||
modal.className = 'confirm-modal';
|
||||
modal.setAttribute('role', 'dialog');
|
||||
modal.setAttribute('aria-modal', 'true');
|
||||
modal.setAttribute('aria-labelledby', 'confirm-modal-title');
|
||||
|
||||
var titleEl = document.createElement('div');
|
||||
titleEl.className = 'confirm-modal-title';
|
||||
titleEl.id = 'confirm-modal-title';
|
||||
titleEl.textContent = title;
|
||||
modal.appendChild(titleEl);
|
||||
|
||||
var msgEl = document.createElement('div');
|
||||
msgEl.className = 'confirm-modal-message';
|
||||
msgEl.textContent = message;
|
||||
modal.appendChild(msgEl);
|
||||
|
||||
var actions = document.createElement('div');
|
||||
actions.className = 'confirm-modal-actions';
|
||||
|
||||
var cancelBtn = document.createElement('button');
|
||||
cancelBtn.type = 'button';
|
||||
cancelBtn.className = 'btn btn-ghost';
|
||||
cancelBtn.textContent = 'Cancel';
|
||||
|
||||
var confirmBtn = document.createElement('button');
|
||||
confirmBtn.type = 'button';
|
||||
confirmBtn.className = 'btn ' + confirmClass;
|
||||
confirmBtn.textContent = confirmLabel;
|
||||
|
||||
actions.appendChild(cancelBtn);
|
||||
actions.appendChild(confirmBtn);
|
||||
modal.appendChild(actions);
|
||||
backdrop.appendChild(modal);
|
||||
document.body.appendChild(backdrop);
|
||||
|
||||
// Focus confirm button
|
||||
confirmBtn.focus();
|
||||
|
||||
function cleanup(result) {
|
||||
backdrop.remove();
|
||||
document.removeEventListener('keydown', onKey);
|
||||
resolve(result);
|
||||
}
|
||||
|
||||
function onKey(e) {
|
||||
if (e.key === 'Escape') cleanup(false);
|
||||
if (e.key === 'Enter') cleanup(true);
|
||||
}
|
||||
|
||||
cancelBtn.addEventListener('click', function() { cleanup(false); });
|
||||
confirmBtn.addEventListener('click', function() { cleanup(true); });
|
||||
backdrop.addEventListener('click', function(e) {
|
||||
if (e.target === backdrop) cleanup(false);
|
||||
});
|
||||
document.addEventListener('keydown', onKey);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Keyboard navigation for lists ---
|
||||
|
||||
function enableListKeyNav(container, itemSelector) {
|
||||
if (!container) return;
|
||||
|
||||
container.setAttribute('role', 'listbox');
|
||||
container.setAttribute('tabindex', '0');
|
||||
|
||||
container.addEventListener('keydown', function(e) {
|
||||
var items = container.querySelectorAll(itemSelector);
|
||||
if (!items.length) return;
|
||||
|
||||
var current = container.querySelector(itemSelector + '[aria-selected="true"]');
|
||||
var idx = current ? Array.prototype.indexOf.call(items, current) : -1;
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
var next = Math.min(idx + 1, items.length - 1);
|
||||
selectItem(items, next);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
var prev = Math.max(idx - 1, 0);
|
||||
selectItem(items, prev);
|
||||
} else if (e.key === 'Enter' && current) {
|
||||
e.preventDefault();
|
||||
current.click();
|
||||
} else if (e.key === 'Escape' && current) {
|
||||
e.preventDefault();
|
||||
current.setAttribute('aria-selected', 'false');
|
||||
current.classList.remove('keyboard-focused');
|
||||
}
|
||||
});
|
||||
|
||||
function selectItem(items, index) {
|
||||
items.forEach(function(item) {
|
||||
item.setAttribute('aria-selected', 'false');
|
||||
item.classList.remove('keyboard-focused');
|
||||
});
|
||||
var target = items[index];
|
||||
if (target) {
|
||||
target.setAttribute('aria-selected', 'true');
|
||||
target.classList.add('keyboard-focused');
|
||||
target.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
init,
|
||||
toast,
|
||||
@@ -249,6 +404,9 @@ const AppFeedback = (function() {
|
||||
isOffline,
|
||||
isTransientNetworkError,
|
||||
isTransientOrOffline,
|
||||
withLoadingButton,
|
||||
confirmAction,
|
||||
enableListKeyNav,
|
||||
};
|
||||
})();
|
||||
|
||||
|
||||
@@ -75,12 +75,12 @@ const BluetoothMode = (function() {
|
||||
/**
|
||||
* Check for agent mode conflicts before starting scan.
|
||||
*/
|
||||
function checkAgentConflicts() {
|
||||
async function checkAgentConflicts() {
|
||||
if (typeof currentAgent === 'undefined' || currentAgent === 'local') {
|
||||
return true;
|
||||
}
|
||||
if (typeof checkAgentModeConflict === 'function') {
|
||||
return checkAgentModeConflict('bluetooth');
|
||||
return await checkAgentModeConflict('bluetooth');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -883,7 +883,7 @@ const BluetoothMode = (function() {
|
||||
|
||||
async function startScan() {
|
||||
// Check for agent mode conflicts
|
||||
if (!checkAgentConflicts()) {
|
||||
if (!await checkAgentConflicts()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -895,6 +895,7 @@ const BluetoothMode = (function() {
|
||||
|
||||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||||
|
||||
if (startBtn) startBtn.classList.add('btn-loading');
|
||||
try {
|
||||
let response;
|
||||
if (isAgentMode) {
|
||||
@@ -940,7 +941,11 @@ const BluetoothMode = (function() {
|
||||
|
||||
} catch (err) {
|
||||
console.error('Failed to start scan:', err);
|
||||
showErrorMessage('Failed to start scan: ' + err.message);
|
||||
reportActionableError('Start Bluetooth Scan', err, {
|
||||
onRetry: () => startScan()
|
||||
});
|
||||
} finally {
|
||||
if (startBtn) startBtn.classList.remove('btn-loading');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -968,6 +973,7 @@ const BluetoothMode = (function() {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to stop scan:', err);
|
||||
reportActionableError('Stop Bluetooth Scan', err);
|
||||
} finally {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
@@ -1537,6 +1543,9 @@ const BluetoothMode = (function() {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to set baseline:', err);
|
||||
reportActionableError('Set Baseline', err, {
|
||||
onRetry: () => setBaseline()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1552,6 +1561,9 @@ const BluetoothMode = (function() {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to clear baseline:', err);
|
||||
reportActionableError('Clear Baseline', err, {
|
||||
onRetry: () => clearBaseline()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1729,21 +1741,34 @@ const BluetoothMode = (function() {
|
||||
}
|
||||
|
||||
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') {
|
||||
BtLocate.handoff({
|
||||
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
|
||||
BtLocate.handoff(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
// Switch to bt_locate mode first — this loads the script, styles,
|
||||
// and initializes the module. Then hand off the device data.
|
||||
if (typeof switchMode === 'function') {
|
||||
switchMode('bt_locate').then(function() {
|
||||
if (typeof BtLocate !== 'undefined') {
|
||||
BtLocate.handoff(payload);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,19 +110,27 @@ const Meshtastic = (function() {
|
||||
meshMap = L.map('meshMap').setView([defaultLat, defaultLon], 4);
|
||||
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: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <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') {
|
||||
// Wait for settings to load from server before applying tiles
|
||||
await Settings.init();
|
||||
Settings.createTileLayer().addTo(meshMap);
|
||||
Settings.registerMap(meshMap);
|
||||
} else {
|
||||
L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||||
maxZoom: 19,
|
||||
subdomains: 'abcd',
|
||||
className: 'tile-layer-cyan'
|
||||
}).addTo(meshMap);
|
||||
try {
|
||||
await Promise.race([
|
||||
Settings.init(),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('Settings timeout')), 5000))
|
||||
]);
|
||||
meshMap.removeLayer(fallbackTiles);
|
||||
Settings.createTileLayer().addTo(meshMap);
|
||||
Settings.registerMap(meshMap);
|
||||
} catch (e) {
|
||||
console.warn('Meshtastic: Settings init failed/timed out, using fallback tiles:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle resize
|
||||
@@ -266,8 +274,10 @@ const Meshtastic = (function() {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to start Meshtastic:', err);
|
||||
reportActionableError('Start Meshtastic', err, {
|
||||
onRetry: () => start()
|
||||
});
|
||||
updateStatusIndicator('disconnected', 'Connection error');
|
||||
showStatusMessage('Connection error: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,6 +293,7 @@ const Meshtastic = (function() {
|
||||
showNotification('Meshtastic', 'Disconnected');
|
||||
} catch (err) {
|
||||
console.error('Failed to stop Meshtastic:', err);
|
||||
reportActionableError('Stop Meshtastic', err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -589,7 +600,9 @@ const Meshtastic = (function() {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to configure channel:', err);
|
||||
showStatusMessage('Error configuring channel: ' + err.message, 'error');
|
||||
reportActionableError('Configure Channel', err, {
|
||||
onRetry: () => saveChannel()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1246,11 +1259,11 @@ const Meshtastic = (function() {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to send message:', err);
|
||||
reportActionableError('Send Message', err, {
|
||||
onRetry: () => sendMessage()
|
||||
});
|
||||
optimisticMsg._failed = true;
|
||||
updatePendingMessage(optimisticMsg, true);
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('Meshtastic', 'Send error: ' + err.message);
|
||||
}
|
||||
} finally {
|
||||
if (sendBtn) {
|
||||
sendBtn.disabled = false;
|
||||
@@ -1382,6 +1395,9 @@ const Meshtastic = (function() {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Traceroute error:', err);
|
||||
reportActionableError('Send Traceroute', err, {
|
||||
onRetry: () => sendTraceroute(destination)
|
||||
});
|
||||
showTracerouteModal(destination, { error: err.message }, false);
|
||||
}
|
||||
}
|
||||
@@ -1564,7 +1580,9 @@ const Meshtastic = (function() {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Position request error:', err);
|
||||
showStatusMessage('Error requesting position: ' + err.message, 'error');
|
||||
reportActionableError('Request Position', err, {
|
||||
onRetry: () => requestPosition(nodeId)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2085,7 +2103,9 @@ const Meshtastic = (function() {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Range test error:', err);
|
||||
showStatusMessage('Error starting range test: ' + err.message, 'error');
|
||||
reportActionableError('Start Range Test', err, {
|
||||
onRetry: () => startRangeTest()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2099,6 +2119,7 @@ const Meshtastic = (function() {
|
||||
showNotification('Meshtastic', 'Range test stopped');
|
||||
} catch (err) {
|
||||
console.error('Error stopping range test:', err);
|
||||
reportActionableError('Stop Range Test', err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2243,7 +2264,9 @@ const Meshtastic = (function() {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('S&F request error:', err);
|
||||
showStatusMessage('Error: ' + err.message, 'error');
|
||||
reportActionableError('Request Store & Forward History', err, {
|
||||
onRetry: () => requestStoreForwardHistory()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+16
-4
@@ -498,15 +498,27 @@ var OokMode = (function () {
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
function removePreset(freq) {
|
||||
if (!confirm('Remove preset ' + freq + ' MHz?')) return;
|
||||
async function removePreset(freq) {
|
||||
const confirmed = await AppFeedback.confirmAction({
|
||||
title: 'Remove Preset',
|
||||
message: 'Remove preset ' + freq + ' MHz?',
|
||||
confirmLabel: 'Remove',
|
||||
confirmClass: 'btn-danger'
|
||||
});
|
||||
if (!confirmed) return;
|
||||
var presets = loadPresets().filter(function (p) { return p !== freq; });
|
||||
savePresets(presets);
|
||||
renderPresets();
|
||||
}
|
||||
|
||||
function resetPresets() {
|
||||
if (!confirm('Reset to default presets?')) return;
|
||||
async function resetPresets() {
|
||||
const confirmed = await AppFeedback.confirmAction({
|
||||
title: 'Reset Presets',
|
||||
message: 'Reset to default presets?',
|
||||
confirmLabel: 'Reset',
|
||||
confirmClass: 'btn-danger'
|
||||
});
|
||||
if (!confirmed) return;
|
||||
savePresets(DEFAULT_FREQ_PRESETS.slice());
|
||||
renderPresets();
|
||||
}
|
||||
|
||||
@@ -280,18 +280,19 @@ const SpyStations = (function() {
|
||||
showNotification('Tuning to ' + stationName, formatFrequency(freqKhz) + ' (' + tuneMode.toUpperCase() + ')');
|
||||
}
|
||||
|
||||
// Switch to spectrum waterfall mode and tune after mode init.
|
||||
if (typeof switchMode === 'function') {
|
||||
switchMode('waterfall');
|
||||
} else if (typeof selectMode === 'function') {
|
||||
selectMode('waterfall');
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
// Switch to spectrum waterfall mode and tune after init completes.
|
||||
const doTune = () => {
|
||||
if (typeof Waterfall !== 'undefined' && typeof Waterfall.quickTune === 'function') {
|
||||
Waterfall.quickTune(freqMhz, tuneMode);
|
||||
}
|
||||
}, 220);
|
||||
};
|
||||
|
||||
if (typeof switchMode === 'function') {
|
||||
switchMode('waterfall').then(doTune);
|
||||
} else if (typeof selectMode === 'function') {
|
||||
selectMode('waterfall');
|
||||
setTimeout(doTune, 300);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -802,7 +802,13 @@ const SSTVGeneral = (function() {
|
||||
* Delete a single image
|
||||
*/
|
||||
async function deleteImage(filename) {
|
||||
if (!confirm('Delete this image?')) return;
|
||||
const confirmed = await AppFeedback.confirmAction({
|
||||
title: 'Delete Image',
|
||||
message: 'Delete this image? This cannot be undone.',
|
||||
confirmLabel: 'Delete',
|
||||
confirmClass: 'btn-danger'
|
||||
});
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
const response = await fetch(`/sstv-general/images/${encodeURIComponent(filename)}`, { method: 'DELETE' });
|
||||
const data = await response.json();
|
||||
@@ -822,7 +828,13 @@ const SSTVGeneral = (function() {
|
||||
* Delete all images
|
||||
*/
|
||||
async function deleteAllImages() {
|
||||
if (!confirm('Delete all decoded images?')) return;
|
||||
const confirmed = await AppFeedback.confirmAction({
|
||||
title: 'Delete All Images',
|
||||
message: 'Delete all decoded images? This cannot be undone.',
|
||||
confirmLabel: 'Delete All',
|
||||
confirmClass: 'btn-danger'
|
||||
});
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
const response = await fetch('/sstv-general/images', { method: 'DELETE' });
|
||||
const data = await response.json();
|
||||
|
||||
+38
-14
@@ -215,18 +215,25 @@ const SSTV = (function() {
|
||||
});
|
||||
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') {
|
||||
// Wait for settings to load from server before applying tiles
|
||||
await Settings.init();
|
||||
Settings.createTileLayer().addTo(issMap);
|
||||
Settings.registerMap(issMap);
|
||||
} else {
|
||||
// Fallback to dark theme tiles
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
maxZoom: 19,
|
||||
className: 'tile-layer-cyan'
|
||||
}).addTo(issMap);
|
||||
try {
|
||||
await Promise.race([
|
||||
Settings.init(),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('Settings timeout')), 5000))
|
||||
]);
|
||||
issMap.removeLayer(fallbackTiles);
|
||||
Settings.createTileLayer().addTo(issMap);
|
||||
Settings.registerMap(issMap);
|
||||
} catch (e) {
|
||||
console.warn('SSTV: Settings init failed/timed out, using fallback tiles:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Create ISS icon
|
||||
@@ -606,8 +613,10 @@ const SSTV = (function() {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to start SSTV:', err);
|
||||
reportActionableError('Start SSTV', err, {
|
||||
onRetry: () => start()
|
||||
});
|
||||
updateStatusUI('idle', 'Error');
|
||||
showStatusMessage('Connection error: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -626,6 +635,7 @@ const SSTV = (function() {
|
||||
showNotification('SSTV', 'Decoder stopped');
|
||||
} catch (err) {
|
||||
console.error('Failed to stop SSTV:', err);
|
||||
reportActionableError('Stop SSTV', err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1297,7 +1307,13 @@ const SSTV = (function() {
|
||||
* Delete a single image
|
||||
*/
|
||||
async function deleteImage(filename) {
|
||||
if (!confirm('Delete this image?')) return;
|
||||
const confirmed = await AppFeedback.confirmAction({
|
||||
title: 'Delete Image',
|
||||
message: 'Delete this image? This cannot be undone.',
|
||||
confirmLabel: 'Delete',
|
||||
confirmClass: 'btn-danger'
|
||||
});
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
const response = await fetch(`/sstv/images/${encodeURIComponent(filename)}`, { method: 'DELETE' });
|
||||
const data = await response.json();
|
||||
@@ -1310,6 +1326,7 @@ const SSTV = (function() {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete image:', err);
|
||||
reportActionableError('Delete Image', err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1317,7 +1334,13 @@ const SSTV = (function() {
|
||||
* Delete all images
|
||||
*/
|
||||
async function deleteAllImages() {
|
||||
if (!confirm('Delete all decoded images?')) return;
|
||||
const confirmed = await AppFeedback.confirmAction({
|
||||
title: 'Delete All Images',
|
||||
message: 'Delete all decoded images? This cannot be undone.',
|
||||
confirmLabel: 'Delete All',
|
||||
confirmClass: 'btn-danger'
|
||||
});
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
const response = await fetch('/sstv/images', { method: 'DELETE' });
|
||||
const data = await response.json();
|
||||
@@ -1329,6 +1352,7 @@ const SSTV = (function() {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete images:', err);
|
||||
reportActionableError('Delete All Images', err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user