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
|
.git
|
||||||
.gitignore
|
.gitignore
|
||||||
|
.github
|
||||||
|
.claude
|
||||||
|
|
||||||
# Python
|
# Python
|
||||||
__pycache__
|
__pycache__
|
||||||
@@ -29,6 +31,19 @@ tests/
|
|||||||
.coverage
|
.coverage
|
||||||
htmlcov/
|
htmlcov/
|
||||||
.mypy_cache/
|
.mypy_cache/
|
||||||
|
.ruff_cache
|
||||||
|
.DS_Store
|
||||||
|
tasks/
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
*.md
|
||||||
|
|
||||||
|
# Runtime data (mounted as volume)
|
||||||
|
instance/
|
||||||
|
data/
|
||||||
|
|
||||||
|
# Build scripts
|
||||||
|
build-multiarch.sh
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
@@ -6,6 +6,13 @@
|
|||||||
# Container timezone (e.g. America/New_York, Europe/London, Australia/Sydney)
|
# Container timezone (e.g. America/New_York, Europe/London, Australia/Sydney)
|
||||||
TZ=UTC
|
TZ=UTC
|
||||||
|
|
||||||
|
# Flask secret key (auto-generated if not set)
|
||||||
|
# INTERCEPT_SECRET_KEY=your-secret-key-here
|
||||||
|
|
||||||
|
# Admin credentials (password auto-generated on first run if not set)
|
||||||
|
# INTERCEPT_ADMIN_USERNAME=admin
|
||||||
|
# INTERCEPT_ADMIN_PASSWORD=your-password-here
|
||||||
|
|
||||||
# Postgres password (default: intercept)
|
# Postgres password (default: intercept)
|
||||||
INTERCEPT_ADSB_DB_PASSWORD=intercept
|
INTERCEPT_ADSB_DB_PASSWORD=intercept
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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
|
## [2.24.0] - 2026-03-10
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+200
-190
@@ -1,6 +1,194 @@
|
|||||||
# INTERCEPT - Signal Intelligence Platform
|
# INTERCEPT - Signal Intelligence Platform
|
||||||
# Docker container for running the web interface
|
# Docker container for running the web interface
|
||||||
|
# Multi-stage build: builder compiles tools, runtime keeps only what's needed
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# Stage 1: Builder — compile all tools from source
|
||||||
|
###############################################################################
|
||||||
|
FROM python:3.11-slim AS builder
|
||||||
|
|
||||||
|
WORKDIR /tmp/build
|
||||||
|
|
||||||
|
# Install ALL build dependencies
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
git \
|
||||||
|
pkg-config \
|
||||||
|
cmake \
|
||||||
|
librtlsdr-dev \
|
||||||
|
libusb-1.0-0-dev \
|
||||||
|
libncurses-dev \
|
||||||
|
libsndfile1-dev \
|
||||||
|
libgtk-3-dev \
|
||||||
|
libasound2-dev \
|
||||||
|
libsoapysdr-dev \
|
||||||
|
libhackrf-dev \
|
||||||
|
liblimesuite-dev \
|
||||||
|
libfftw3-dev \
|
||||||
|
libpng-dev \
|
||||||
|
libtiff-dev \
|
||||||
|
libjemalloc-dev \
|
||||||
|
libvolk-dev \
|
||||||
|
libnng-dev \
|
||||||
|
libzstd-dev \
|
||||||
|
libsqlite3-dev \
|
||||||
|
libcurl4-openssl-dev \
|
||||||
|
zlib1g-dev \
|
||||||
|
libzmq3-dev \
|
||||||
|
libpulse-dev \
|
||||||
|
libfftw3-bin \
|
||||||
|
liblapack-dev \
|
||||||
|
libglib2.0-dev \
|
||||||
|
libxml2-dev \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Create staging directory for all built artifacts
|
||||||
|
RUN mkdir -p /staging/usr/bin /staging/usr/local/bin /staging/usr/local/lib /staging/opt
|
||||||
|
|
||||||
|
# Build dump1090
|
||||||
|
RUN cd /tmp \
|
||||||
|
&& git clone --depth 1 https://github.com/flightaware/dump1090.git \
|
||||||
|
&& cd dump1090 \
|
||||||
|
&& sed -i 's/-Werror//g' Makefile \
|
||||||
|
&& make BLADERF=no RTLSDR=yes \
|
||||||
|
&& cp dump1090 /staging/usr/bin/dump1090-fa \
|
||||||
|
&& ln -s /usr/bin/dump1090-fa /staging/usr/bin/dump1090 \
|
||||||
|
&& rm -rf /tmp/dump1090
|
||||||
|
|
||||||
|
# Build AIS-catcher
|
||||||
|
RUN cd /tmp \
|
||||||
|
&& git clone https://github.com/jvde-github/AIS-catcher.git \
|
||||||
|
&& cd AIS-catcher \
|
||||||
|
&& mkdir build && cd build \
|
||||||
|
&& cmake .. \
|
||||||
|
&& make \
|
||||||
|
&& cp AIS-catcher /staging/usr/bin/AIS-catcher \
|
||||||
|
&& rm -rf /tmp/AIS-catcher
|
||||||
|
|
||||||
|
# Build readsb
|
||||||
|
RUN cd /tmp \
|
||||||
|
&& git clone --depth 1 https://github.com/wiedehopf/readsb.git \
|
||||||
|
&& cd readsb \
|
||||||
|
&& make BLADERF=no PLUTOSDR=no SOAPYSDR=yes \
|
||||||
|
&& cp readsb /staging/usr/bin/readsb \
|
||||||
|
&& rm -rf /tmp/readsb
|
||||||
|
|
||||||
|
# Build rx_tools
|
||||||
|
RUN cd /tmp \
|
||||||
|
&& git clone https://github.com/rxseger/rx_tools.git \
|
||||||
|
&& cd rx_tools \
|
||||||
|
&& mkdir build && cd build \
|
||||||
|
&& cmake .. \
|
||||||
|
&& make \
|
||||||
|
&& DESTDIR=/staging make install \
|
||||||
|
&& rm -rf /tmp/rx_tools
|
||||||
|
|
||||||
|
# Build acarsdec
|
||||||
|
RUN cd /tmp \
|
||||||
|
&& git clone --depth 1 https://github.com/TLeconte/acarsdec.git \
|
||||||
|
&& cd acarsdec \
|
||||||
|
&& mkdir build && cd build \
|
||||||
|
&& cmake .. -Drtl=ON -DCMAKE_POLICY_VERSION_MINIMUM=3.5 \
|
||||||
|
&& make \
|
||||||
|
&& cp acarsdec /staging/usr/bin/acarsdec \
|
||||||
|
&& rm -rf /tmp/acarsdec
|
||||||
|
|
||||||
|
# Build libacars (required by dumpvdl2)
|
||||||
|
RUN cd /tmp \
|
||||||
|
&& git clone --depth 1 https://github.com/szpajder/libacars.git \
|
||||||
|
&& cd libacars \
|
||||||
|
&& mkdir build && cd build \
|
||||||
|
&& cmake .. \
|
||||||
|
&& make \
|
||||||
|
&& make install \
|
||||||
|
&& ldconfig \
|
||||||
|
&& cp -a /usr/local/lib/libacars* /staging/usr/local/lib/ \
|
||||||
|
&& rm -rf /tmp/libacars
|
||||||
|
|
||||||
|
# Build dumpvdl2 (VDL2 aircraft datalink decoder)
|
||||||
|
RUN cd /tmp \
|
||||||
|
&& git clone --depth 1 https://github.com/szpajder/dumpvdl2.git \
|
||||||
|
&& cd dumpvdl2 \
|
||||||
|
&& mkdir build && cd build \
|
||||||
|
&& cmake .. \
|
||||||
|
&& make \
|
||||||
|
&& cp src/dumpvdl2 /staging/usr/bin/dumpvdl2 \
|
||||||
|
&& rm -rf /tmp/dumpvdl2
|
||||||
|
|
||||||
|
# Build slowrx (SSTV decoder) — pinned to known-good commit
|
||||||
|
RUN cd /tmp \
|
||||||
|
&& git clone https://github.com/windytan/slowrx.git \
|
||||||
|
&& cd slowrx \
|
||||||
|
&& git checkout ca6d7012 \
|
||||||
|
&& make \
|
||||||
|
&& install -m 0755 slowrx /staging/usr/local/bin/slowrx \
|
||||||
|
&& rm -rf /tmp/slowrx
|
||||||
|
|
||||||
|
# Build SatDump (weather satellite decoder - NOAA APT & Meteor LRPT) — pinned to v1.2.2
|
||||||
|
RUN cd /tmp \
|
||||||
|
&& git clone --depth 1 --branch 1.2.2 https://github.com/SatDump/SatDump.git \
|
||||||
|
&& cd SatDump \
|
||||||
|
&& mkdir build && cd build \
|
||||||
|
&& cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF -DCMAKE_INSTALL_LIBDIR=lib .. \
|
||||||
|
&& make -j$(nproc) \
|
||||||
|
&& make install \
|
||||||
|
&& ldconfig \
|
||||||
|
# Ensure SatDump plugins are in the expected path (handles multiarch differences)
|
||||||
|
&& mkdir -p /usr/local/lib/satdump/plugins \
|
||||||
|
&& if [ -z "$(ls /usr/local/lib/satdump/plugins/*.so 2>/dev/null)" ]; then \
|
||||||
|
for dir in /usr/local/lib/*/satdump/plugins /usr/lib/*/satdump/plugins /usr/lib/satdump/plugins; do \
|
||||||
|
if [ -d "$dir" ] && [ -n "$(ls "$dir"/*.so 2>/dev/null)" ]; then \
|
||||||
|
ln -sf "$dir"/*.so /usr/local/lib/satdump/plugins/; \
|
||||||
|
break; \
|
||||||
|
fi; \
|
||||||
|
done; \
|
||||||
|
fi \
|
||||||
|
# Copy SatDump install artifacts to staging
|
||||||
|
&& cp -a /usr/local/bin/satdump /staging/usr/local/bin/ 2>/dev/null || true \
|
||||||
|
&& cp -a /usr/local/lib/libsatdump* /staging/usr/local/lib/ 2>/dev/null || true \
|
||||||
|
&& cp -a /usr/local/lib/satdump /staging/usr/local/lib/ 2>/dev/null || true \
|
||||||
|
&& cp -a /usr/local/share/satdump /staging/usr/local/share/ 2>/dev/null; mkdir -p /staging/usr/local/share \
|
||||||
|
&& cp -a /usr/local/share/satdump /staging/usr/local/share/ 2>/dev/null || true \
|
||||||
|
&& rm -rf /tmp/SatDump
|
||||||
|
|
||||||
|
# Build hackrf CLI tools from source — avoids libhackrf0 version conflict
|
||||||
|
# between the 'hackrf' apt package and soapysdr-module-hackrf's newer libhackrf0
|
||||||
|
RUN cd /tmp \
|
||||||
|
&& git clone --depth 1 https://github.com/greatscottgadgets/hackrf.git \
|
||||||
|
&& cd hackrf/host \
|
||||||
|
&& mkdir build && cd build \
|
||||||
|
&& cmake .. \
|
||||||
|
&& make \
|
||||||
|
&& make install \
|
||||||
|
&& ldconfig \
|
||||||
|
&& cp -a /usr/local/bin/hackrf_* /staging/usr/local/bin/ 2>/dev/null || true \
|
||||||
|
&& cp -a /usr/local/lib/libhackrf* /staging/usr/local/lib/ 2>/dev/null || true \
|
||||||
|
&& rm -rf /tmp/hackrf
|
||||||
|
|
||||||
|
# Install radiosonde_auto_rx (weather balloon decoder)
|
||||||
|
RUN cd /tmp \
|
||||||
|
&& git clone --depth 1 https://github.com/projecthorus/radiosonde_auto_rx.git \
|
||||||
|
&& cd radiosonde_auto_rx/auto_rx \
|
||||||
|
&& pip install --no-cache-dir -r requirements.txt semver \
|
||||||
|
&& bash build.sh \
|
||||||
|
&& mkdir -p /staging/opt/radiosonde_auto_rx/auto_rx \
|
||||||
|
&& cp -r . /staging/opt/radiosonde_auto_rx/auto_rx/ \
|
||||||
|
&& chmod +x /staging/opt/radiosonde_auto_rx/auto_rx/auto_rx.py \
|
||||||
|
&& rm -rf /tmp/radiosonde_auto_rx
|
||||||
|
|
||||||
|
# Build rtlamr (utility meter decoder - requires Go)
|
||||||
|
RUN cd /tmp \
|
||||||
|
&& curl -fsSL "https://go.dev/dl/go1.22.5.linux-$(dpkg --print-architecture).tar.gz" | tar -C /usr/local -xz \
|
||||||
|
&& export PATH="$PATH:/usr/local/go/bin" \
|
||||||
|
&& export GOPATH=/tmp/gopath \
|
||||||
|
&& go install github.com/bemasher/rtlamr@latest \
|
||||||
|
&& cp /tmp/gopath/bin/rtlamr /staging/usr/bin/rtlamr \
|
||||||
|
&& rm -rf /usr/local/go /tmp/gopath
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# Stage 2: Runtime — lean image with only runtime dependencies
|
||||||
|
###############################################################################
|
||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
|
|
||||||
LABEL maintainer="INTERCEPT Project"
|
LABEL maintainer="INTERCEPT Project"
|
||||||
@@ -12,12 +200,10 @@ WORKDIR /app
|
|||||||
# Pre-accept tshark non-root capture prompt for non-interactive install
|
# Pre-accept tshark non-root capture prompt for non-interactive install
|
||||||
RUN echo 'wireshark-common wireshark-common/install-setuid boolean true' | debconf-set-selections
|
RUN echo 'wireshark-common wireshark-common/install-setuid boolean true' | debconf-set-selections
|
||||||
|
|
||||||
# Install system dependencies for SDR tools
|
# Install ONLY runtime dependencies (no -dev packages, no build tools)
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
# RTL-SDR tools
|
# RTL-SDR tools
|
||||||
rtl-sdr \
|
rtl-sdr \
|
||||||
librtlsdr-dev \
|
|
||||||
libusb-1.0-0-dev \
|
|
||||||
# 433MHz decoder
|
# 433MHz decoder
|
||||||
rtl-433 \
|
rtl-433 \
|
||||||
# Pager decoder
|
# Pager decoder
|
||||||
@@ -43,7 +229,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
# GPS support
|
# GPS support
|
||||||
gpsd \
|
gpsd \
|
||||||
gpsd-clients \
|
gpsd-clients \
|
||||||
# Utilities
|
|
||||||
# APRS
|
# APRS
|
||||||
direwolf \
|
direwolf \
|
||||||
# WiFi Extra
|
# WiFi Extra
|
||||||
@@ -62,192 +247,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
procps \
|
procps \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Build dump1090-fa and acarsdec from source (packages not available in slim repos)
|
# Copy compiled binaries and libraries from builder stage
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
COPY --from=builder /staging/usr/bin/ /usr/bin/
|
||||||
build-essential \
|
COPY --from=builder /staging/usr/local/bin/ /usr/local/bin/
|
||||||
git \
|
COPY --from=builder /staging/usr/local/lib/ /usr/local/lib/
|
||||||
pkg-config \
|
COPY --from=builder /staging/opt/ /opt/
|
||||||
cmake \
|
|
||||||
libncurses-dev \
|
# Copy radiosonde Python dependencies installed during builder stage
|
||||||
libsndfile1-dev \
|
COPY --from=builder /usr/local/lib/python3.11/site-packages/ /usr/local/lib/python3.11/site-packages/
|
||||||
# GTK is required for slowrx (SSTV decoder GUI dependency).
|
|
||||||
# Note: slowrx is kept for backwards compatibility, but the pure Python
|
# Refresh shared library cache for custom-built libraries
|
||||||
# SSTV decoder in utils/sstv/ is now the primary implementation.
|
RUN ldconfig
|
||||||
# GTK can be removed if slowrx is deprecated in future releases.
|
|
||||||
libgtk-3-dev \
|
|
||||||
libasound2-dev \
|
|
||||||
libsoapysdr-dev \
|
|
||||||
libhackrf-dev \
|
|
||||||
liblimesuite-dev \
|
|
||||||
libfftw3-dev \
|
|
||||||
libpng-dev \
|
|
||||||
libtiff-dev \
|
|
||||||
libjemalloc-dev \
|
|
||||||
libvolk-dev \
|
|
||||||
libnng-dev \
|
|
||||||
libzstd-dev \
|
|
||||||
libsqlite3-dev \
|
|
||||||
libcurl4-openssl-dev \
|
|
||||||
zlib1g-dev \
|
|
||||||
libzmq3-dev \
|
|
||||||
libpulse-dev \
|
|
||||||
libfftw3-bin \
|
|
||||||
liblapack-dev \
|
|
||||||
libglib2.0-dev \
|
|
||||||
libxml2-dev \
|
|
||||||
# Build dump1090
|
|
||||||
&& cd /tmp \
|
|
||||||
&& git clone --depth 1 https://github.com/flightaware/dump1090.git \
|
|
||||||
&& cd dump1090 \
|
|
||||||
&& sed -i 's/-Werror//g' Makefile \
|
|
||||||
&& make BLADERF=no RTLSDR=yes \
|
|
||||||
&& cp dump1090 /usr/bin/dump1090-fa \
|
|
||||||
&& ln -s /usr/bin/dump1090-fa /usr/bin/dump1090 \
|
|
||||||
&& rm -rf /tmp/dump1090 \
|
|
||||||
# Build AIS-catcher
|
|
||||||
&& cd /tmp \
|
|
||||||
&& git clone https://github.com/jvde-github/AIS-catcher.git \
|
|
||||||
&& cd AIS-catcher \
|
|
||||||
&& mkdir build && cd build \
|
|
||||||
&& cmake .. \
|
|
||||||
&& make \
|
|
||||||
&& cp AIS-catcher /usr/bin/AIS-catcher \
|
|
||||||
&& cd /tmp \
|
|
||||||
&& rm -rf /tmp/AIS-catcher \
|
|
||||||
# Build readsb
|
|
||||||
&& cd /tmp \
|
|
||||||
&& git clone --depth 1 https://github.com/wiedehopf/readsb.git \
|
|
||||||
&& cd readsb \
|
|
||||||
&& make BLADERF=no PLUTOSDR=no SOAPYSDR=yes \
|
|
||||||
&& cp readsb /usr/bin/readsb \
|
|
||||||
&& cd /tmp \
|
|
||||||
&& rm -rf /tmp/readsb \
|
|
||||||
# Build rx_tools
|
|
||||||
&& cd /tmp \
|
|
||||||
&& git clone https://github.com/rxseger/rx_tools.git \
|
|
||||||
&& cd rx_tools \
|
|
||||||
&& mkdir build && cd build \
|
|
||||||
&& cmake .. \
|
|
||||||
&& make \
|
|
||||||
&& make install \
|
|
||||||
&& cd /tmp \
|
|
||||||
&& rm -rf /tmp/rx_tools \
|
|
||||||
# Build acarsdec
|
|
||||||
&& cd /tmp \
|
|
||||||
&& git clone --depth 1 https://github.com/TLeconte/acarsdec.git \
|
|
||||||
&& cd acarsdec \
|
|
||||||
&& mkdir build && cd build \
|
|
||||||
&& cmake .. -Drtl=ON -DCMAKE_POLICY_VERSION_MINIMUM=3.5 \
|
|
||||||
&& make \
|
|
||||||
&& cp acarsdec /usr/bin/acarsdec \
|
|
||||||
&& rm -rf /tmp/acarsdec \
|
|
||||||
# Build libacars (required by dumpvdl2)
|
|
||||||
&& cd /tmp \
|
|
||||||
&& git clone --depth 1 https://github.com/szpajder/libacars.git \
|
|
||||||
&& cd libacars \
|
|
||||||
&& mkdir build && cd build \
|
|
||||||
&& cmake .. \
|
|
||||||
&& make \
|
|
||||||
&& make install \
|
|
||||||
&& ldconfig \
|
|
||||||
&& rm -rf /tmp/libacars \
|
|
||||||
# Build dumpvdl2 (VDL2 aircraft datalink decoder)
|
|
||||||
&& cd /tmp \
|
|
||||||
&& git clone --depth 1 https://github.com/szpajder/dumpvdl2.git \
|
|
||||||
&& cd dumpvdl2 \
|
|
||||||
&& mkdir build && cd build \
|
|
||||||
&& cmake .. \
|
|
||||||
&& make \
|
|
||||||
&& cp src/dumpvdl2 /usr/bin/dumpvdl2 \
|
|
||||||
&& rm -rf /tmp/dumpvdl2 \
|
|
||||||
# Build slowrx (SSTV decoder) — pinned to known-good commit
|
|
||||||
&& cd /tmp \
|
|
||||||
&& git clone https://github.com/windytan/slowrx.git \
|
|
||||||
&& cd slowrx \
|
|
||||||
&& git checkout ca6d7012 \
|
|
||||||
&& make \
|
|
||||||
&& install -m 0755 slowrx /usr/local/bin/slowrx \
|
|
||||||
&& rm -rf /tmp/slowrx \
|
|
||||||
# Build SatDump (weather satellite decoder - NOAA APT & Meteor LRPT) — pinned to v1.2.2
|
|
||||||
&& cd /tmp \
|
|
||||||
&& git clone --depth 1 --branch 1.2.2 https://github.com/SatDump/SatDump.git \
|
|
||||||
&& cd SatDump \
|
|
||||||
&& mkdir build && cd build \
|
|
||||||
&& cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF -DCMAKE_INSTALL_LIBDIR=lib .. \
|
|
||||||
&& make -j$(nproc) \
|
|
||||||
&& make install \
|
|
||||||
&& ldconfig \
|
|
||||||
# Ensure SatDump plugins are in the expected path (handles multiarch differences)
|
|
||||||
&& mkdir -p /usr/local/lib/satdump/plugins \
|
|
||||||
&& if [ -z "$(ls /usr/local/lib/satdump/plugins/*.so 2>/dev/null)" ]; then \
|
|
||||||
for dir in /usr/local/lib/*/satdump/plugins /usr/lib/*/satdump/plugins /usr/lib/satdump/plugins; do \
|
|
||||||
if [ -d "$dir" ] && [ -n "$(ls "$dir"/*.so 2>/dev/null)" ]; then \
|
|
||||||
ln -sf "$dir"/*.so /usr/local/lib/satdump/plugins/; \
|
|
||||||
break; \
|
|
||||||
fi; \
|
|
||||||
done; \
|
|
||||||
fi \
|
|
||||||
&& cd /tmp \
|
|
||||||
&& rm -rf /tmp/SatDump \
|
|
||||||
# Build hackrf CLI tools from source — avoids libhackrf0 version conflict
|
|
||||||
# between the 'hackrf' apt package and soapysdr-module-hackrf's newer libhackrf0
|
|
||||||
&& cd /tmp \
|
|
||||||
&& git clone --depth 1 https://github.com/greatscottgadgets/hackrf.git \
|
|
||||||
&& cd hackrf/host \
|
|
||||||
&& mkdir build && cd build \
|
|
||||||
&& cmake .. \
|
|
||||||
&& make \
|
|
||||||
&& make install \
|
|
||||||
&& ldconfig \
|
|
||||||
&& rm -rf /tmp/hackrf \
|
|
||||||
# Install radiosonde_auto_rx (weather balloon decoder)
|
|
||||||
&& cd /tmp \
|
|
||||||
&& git clone --depth 1 https://github.com/projecthorus/radiosonde_auto_rx.git \
|
|
||||||
&& cd radiosonde_auto_rx/auto_rx \
|
|
||||||
&& pip install --no-cache-dir -r requirements.txt semver \
|
|
||||||
&& bash build.sh \
|
|
||||||
&& mkdir -p /opt/radiosonde_auto_rx/auto_rx \
|
|
||||||
&& cp -r . /opt/radiosonde_auto_rx/auto_rx/ \
|
|
||||||
&& chmod +x /opt/radiosonde_auto_rx/auto_rx/auto_rx.py \
|
|
||||||
&& cd /tmp \
|
|
||||||
&& rm -rf /tmp/radiosonde_auto_rx \
|
|
||||||
# Build rtlamr (utility meter decoder - requires Go)
|
|
||||||
&& cd /tmp \
|
|
||||||
&& curl -fsSL "https://go.dev/dl/go1.22.5.linux-$(dpkg --print-architecture).tar.gz" | tar -C /usr/local -xz \
|
|
||||||
&& export PATH="$PATH:/usr/local/go/bin" \
|
|
||||||
&& export GOPATH=/tmp/gopath \
|
|
||||||
&& go install github.com/bemasher/rtlamr@latest \
|
|
||||||
&& cp /tmp/gopath/bin/rtlamr /usr/bin/rtlamr \
|
|
||||||
&& rm -rf /usr/local/go /tmp/gopath \
|
|
||||||
# Cleanup build tools to reduce image size
|
|
||||||
# libgtk-3-dev is explicitly removed; runtime GTK libs remain for slowrx
|
|
||||||
&& apt-get remove -y \
|
|
||||||
build-essential \
|
|
||||||
git \
|
|
||||||
pkg-config \
|
|
||||||
cmake \
|
|
||||||
libncurses-dev \
|
|
||||||
libsndfile1-dev \
|
|
||||||
libgtk-3-dev \
|
|
||||||
libasound2-dev \
|
|
||||||
libpng-dev \
|
|
||||||
libtiff-dev \
|
|
||||||
libjemalloc-dev \
|
|
||||||
libvolk-dev \
|
|
||||||
libnng-dev \
|
|
||||||
libzstd-dev \
|
|
||||||
libsoapysdr-dev \
|
|
||||||
libhackrf-dev \
|
|
||||||
liblimesuite-dev \
|
|
||||||
libsqlite3-dev \
|
|
||||||
libcurl4-openssl-dev \
|
|
||||||
zlib1g-dev \
|
|
||||||
libzmq3-dev \
|
|
||||||
libpulse-dev \
|
|
||||||
libfftw3-dev \
|
|
||||||
liblapack-dev \
|
|
||||||
&& apt-get autoremove -y \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Copy requirements first for better caching
|
# Copy requirements first for better caching
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import queue
|
|||||||
import threading
|
import threading
|
||||||
import platform
|
import platform
|
||||||
import subprocess
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -48,6 +49,16 @@ try:
|
|||||||
_has_limiter = True
|
_has_limiter = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
_has_limiter = False
|
_has_limiter = False
|
||||||
|
try:
|
||||||
|
from flask_compress import Compress
|
||||||
|
_has_compress = True
|
||||||
|
except ImportError:
|
||||||
|
_has_compress = False
|
||||||
|
try:
|
||||||
|
from flask_wtf.csrf import CSRFProtect
|
||||||
|
_has_csrf = True
|
||||||
|
except ImportError:
|
||||||
|
_has_csrf = False
|
||||||
# Track application start time for uptime calculation
|
# Track application start time for uptime calculation
|
||||||
import time as _time
|
import time as _time
|
||||||
_app_start_time = _time.time()
|
_app_start_time = _time.time()
|
||||||
@@ -55,7 +66,29 @@ logger = logging.getLogger('intercept.database')
|
|||||||
|
|
||||||
# Create Flask app
|
# Create Flask app
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.secret_key = "signals_intelligence_secret" # Required for flash messages
|
def _load_or_generate_secret_key():
|
||||||
|
"""Load secret key from env var or instance file, generating if needed."""
|
||||||
|
env_key = os.environ.get('INTERCEPT_SECRET_KEY')
|
||||||
|
if env_key:
|
||||||
|
return env_key
|
||||||
|
key_path = Path('instance/secret.key')
|
||||||
|
if key_path.exists():
|
||||||
|
return key_path.read_text().strip()
|
||||||
|
key_path.parent.mkdir(exist_ok=True)
|
||||||
|
key = os.urandom(32).hex()
|
||||||
|
key_path.write_text(key)
|
||||||
|
return key
|
||||||
|
|
||||||
|
app.secret_key = _load_or_generate_secret_key()
|
||||||
|
|
||||||
|
# Set up HTTP compression (gzip/brotli for HTML, CSS, JS, JSON)
|
||||||
|
if _has_compress:
|
||||||
|
Compress(app)
|
||||||
|
else:
|
||||||
|
logging.getLogger('intercept').warning(
|
||||||
|
"flask-compress not installed – HTTP compression disabled. "
|
||||||
|
"Install with: pip install flask-compress"
|
||||||
|
)
|
||||||
|
|
||||||
# Set up rate limiting
|
# Set up rate limiting
|
||||||
if _has_limiter:
|
if _has_limiter:
|
||||||
@@ -77,6 +110,16 @@ else:
|
|||||||
return decorator
|
return decorator
|
||||||
limiter = _NoopLimiter()
|
limiter = _NoopLimiter()
|
||||||
|
|
||||||
|
# Set up CSRF protection
|
||||||
|
if _has_csrf:
|
||||||
|
csrf = CSRFProtect(app)
|
||||||
|
else:
|
||||||
|
logging.getLogger('intercept').warning(
|
||||||
|
"flask-wtf not installed – CSRF protection disabled. "
|
||||||
|
"Install with: pip install flask-wtf"
|
||||||
|
)
|
||||||
|
csrf = None
|
||||||
|
|
||||||
# Disable Werkzeug debugger PIN (not needed for local development tool)
|
# Disable Werkzeug debugger PIN (not needed for local development tool)
|
||||||
os.environ['WERKZEUG_DEBUG_PIN'] = 'off'
|
os.environ['WERKZEUG_DEBUG_PIN'] = 'off'
|
||||||
|
|
||||||
@@ -106,6 +149,12 @@ def add_security_headers(response):
|
|||||||
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
||||||
# Permissions policy (disable unnecessary features)
|
# Permissions policy (disable unnecessary features)
|
||||||
response.headers['Permissions-Policy'] = 'geolocation=(self), microphone=()'
|
response.headers['Permissions-Policy'] = 'geolocation=(self), microphone=()'
|
||||||
|
# Cache-Control for static assets
|
||||||
|
if request.path.startswith('/static/'):
|
||||||
|
if '/vendor/' in request.path:
|
||||||
|
response.headers['Cache-Control'] = 'public, max-age=604800' # 7 days for vendored libs
|
||||||
|
else:
|
||||||
|
response.headers['Cache-Control'] = 'public, max-age=86400' # 24h for app assets
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@@ -803,13 +852,43 @@ def _get_wifi_health() -> tuple[bool, int, int]:
|
|||||||
@app.route('/health')
|
@app.route('/health')
|
||||||
def health_check() -> Response:
|
def health_check() -> Response:
|
||||||
"""Health check endpoint for monitoring."""
|
"""Health check endpoint for monitoring."""
|
||||||
|
import platform
|
||||||
import time
|
import time
|
||||||
bt_active, bt_device_count = _get_bluetooth_health()
|
bt_active, bt_device_count = _get_bluetooth_health()
|
||||||
wifi_active, wifi_network_count, wifi_client_count = _get_wifi_health()
|
wifi_active, wifi_network_count, wifi_client_count = _get_wifi_health()
|
||||||
return jsonify({
|
|
||||||
'status': 'healthy',
|
# Database health check
|
||||||
|
db_ok = True
|
||||||
|
try:
|
||||||
|
from utils.database import get_connection
|
||||||
|
get_connection().execute('SELECT 1')
|
||||||
|
except Exception:
|
||||||
|
db_ok = False
|
||||||
|
|
||||||
|
# SDR device count (cached, non-blocking)
|
||||||
|
sdr_count = 0
|
||||||
|
try:
|
||||||
|
from utils.sdr.detection import get_cached_devices
|
||||||
|
cached = get_cached_devices()
|
||||||
|
if cached is not None:
|
||||||
|
sdr_count = len(cached)
|
||||||
|
except (ImportError, Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
overall_status = 'healthy' if db_ok else 'degraded'
|
||||||
|
status_code = 200 if db_ok else 503
|
||||||
|
|
||||||
|
response = jsonify({
|
||||||
|
'status': overall_status,
|
||||||
'version': VERSION,
|
'version': VERSION,
|
||||||
'uptime_seconds': round(time.time() - _app_start_time, 2),
|
'uptime_seconds': round(time.time() - _app_start_time, 2),
|
||||||
|
'system': {
|
||||||
|
'python_version': platform.python_version(),
|
||||||
|
'platform': platform.platform(),
|
||||||
|
},
|
||||||
|
'database': db_ok,
|
||||||
|
'sdr_devices': sdr_count,
|
||||||
|
'rate_limiting': _has_limiter,
|
||||||
'processes': {
|
'processes': {
|
||||||
'pager': current_process is not None and (current_process.poll() is None if current_process else False),
|
'pager': current_process is not None and (current_process.poll() is None if current_process else False),
|
||||||
'sensor': sensor_process is not None and (sensor_process.poll() is None if sensor_process else False),
|
'sensor': sensor_process is not None and (sensor_process.poll() is None if sensor_process else False),
|
||||||
@@ -843,9 +922,12 @@ def health_check() -> Response:
|
|||||||
'dsc_messages_count': len(dsc_messages),
|
'dsc_messages_count': len(dsc_messages),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
response.status_code = status_code
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@app.route('/killall', methods=['POST'])
|
@app.route('/killall', methods=['POST'])
|
||||||
|
@(csrf.exempt if csrf else lambda f: f)
|
||||||
def kill_all() -> Response:
|
def kill_all() -> Response:
|
||||||
"""Kill all decoder, WiFi, and Bluetooth processes."""
|
"""Kill all decoder, WiFi, and Bluetooth processes."""
|
||||||
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
|
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
|
||||||
|
|||||||
@@ -7,10 +7,21 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
# Application version
|
# Application version
|
||||||
VERSION = "2.24.0"
|
VERSION = "2.25.0"
|
||||||
|
|
||||||
# Changelog - latest release notes (shown on welcome screen)
|
# Changelog - latest release notes (shown on welcome screen)
|
||||||
CHANGELOG = [
|
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",
|
"version": "2.24.0",
|
||||||
"date": "March 2026",
|
"date": "March 2026",
|
||||||
@@ -399,7 +410,7 @@ ALERT_WEBHOOK_TIMEOUT = _get_env_int('ALERT_WEBHOOK_TIMEOUT', 5)
|
|||||||
|
|
||||||
# Admin credentials
|
# Admin credentials
|
||||||
ADMIN_USERNAME = _get_env('ADMIN_USERNAME', 'admin')
|
ADMIN_USERNAME = _get_env('ADMIN_USERNAME', 'admin')
|
||||||
ADMIN_PASSWORD = _get_env('ADMIN_PASSWORD', 'admin')
|
ADMIN_PASSWORD = _get_env('ADMIN_PASSWORD', '')
|
||||||
|
|
||||||
|
|
||||||
def configure_logging() -> None:
|
def configure_logging() -> None:
|
||||||
|
|||||||
@@ -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
|
- **Mode-specific header stats** - real-time badges showing key metrics per mode
|
||||||
- **UTC clock** - always visible in header for time-critical operations
|
- **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
|
- **Active mode indicator** - shows current mode with pulse animation
|
||||||
- **Collapsible sections** - click any header to collapse/expand
|
- **Collapsible sections** - click any header to collapse/expand
|
||||||
- **Panel styling** - gradient backgrounds with indicator dots
|
- **Panel styling** - gradient backgrounds with indicator dots
|
||||||
|
|||||||
+1
-1
@@ -36,7 +36,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="hero-stats">
|
<div class="hero-stats">
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<span class="stat-value">30+</span>
|
<span class="stat-value">34</span>
|
||||||
<span class="stat-label">Modes</span>
|
<span class="stat-label">Modes</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Core dependencies
|
# Core dependencies
|
||||||
flask>=3.0.0
|
flask>=3.0.0
|
||||||
|
flask-wtf>=1.2.0
|
||||||
|
flask-compress>=1.15
|
||||||
flask-limiter>=2.5.4
|
flask-limiter>=2.5.4
|
||||||
requests>=2.28.0
|
requests>=2.28.0
|
||||||
Werkzeug>=3.1.5
|
Werkzeug>=3.1.5
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
# Routes package - registers all blueprints with the Flask app
|
# Routes package - registers all blueprints with the Flask app
|
||||||
|
|
||||||
|
|
||||||
def register_blueprints(app):
|
def register_blueprints(app):
|
||||||
"""Register all route blueprints with the Flask app."""
|
"""Register all route blueprints with the Flask app."""
|
||||||
|
# Import CSRF to exempt API blueprints (they use JSON, not form tokens)
|
||||||
|
try:
|
||||||
|
from app import csrf as _csrf
|
||||||
|
except ImportError:
|
||||||
|
_csrf = None
|
||||||
from .acars import acars_bp
|
from .acars import acars_bp
|
||||||
from .adsb import adsb_bp
|
from .adsb import adsb_bp
|
||||||
from .ais import ais_bp
|
from .ais import ais_bp
|
||||||
@@ -84,6 +90,11 @@ def register_blueprints(app):
|
|||||||
app.register_blueprint(system_bp) # System health monitoring
|
app.register_blueprint(system_bp) # System health monitoring
|
||||||
app.register_blueprint(ook_bp) # Generic OOK signal decoder
|
app.register_blueprint(ook_bp) # Generic OOK signal decoder
|
||||||
|
|
||||||
|
# Exempt all API blueprints from CSRF (they use JSON, not form tokens)
|
||||||
|
if _csrf:
|
||||||
|
for bp in app.blueprints.values():
|
||||||
|
_csrf.exempt(bp)
|
||||||
|
|
||||||
# Initialize TSCM state with queue and lock from app
|
# Initialize TSCM state with queue and lock from app
|
||||||
import app as app_module
|
import app as app_module
|
||||||
if hasattr(app_module, 'tscm_queue') and hasattr(app_module, 'tscm_lock'):
|
if hasattr(app_module, 'tscm_queue') and hasattr(app_module, 'tscm_lock'):
|
||||||
|
|||||||
+8
-20
@@ -17,6 +17,7 @@ from typing import Any, Generator
|
|||||||
|
|
||||||
from flask import Blueprint, Response, jsonify, request
|
from flask import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
|
from utils.responses import api_success, api_error
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.acars_translator import translate_message
|
from utils.acars_translator import translate_message
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
@@ -219,18 +220,12 @@ def start_acars() -> Response:
|
|||||||
|
|
||||||
with app_module.acars_lock:
|
with app_module.acars_lock:
|
||||||
if app_module.acars_process and app_module.acars_process.poll() is None:
|
if app_module.acars_process and app_module.acars_process.poll() is None:
|
||||||
return jsonify({
|
return api_error('ACARS decoder already running', 409)
|
||||||
'status': 'error',
|
|
||||||
'message': 'ACARS decoder already running'
|
|
||||||
}), 409
|
|
||||||
|
|
||||||
# Check for acarsdec
|
# Check for acarsdec
|
||||||
acarsdec_path = find_acarsdec()
|
acarsdec_path = find_acarsdec()
|
||||||
if not acarsdec_path:
|
if not acarsdec_path:
|
||||||
return jsonify({
|
return api_error('acarsdec not found. Install with: sudo apt install acarsdec', 400)
|
||||||
'status': 'error',
|
|
||||||
'message': 'acarsdec not found. Install with: sudo apt install acarsdec'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
|
|
||||||
@@ -240,7 +235,7 @@ def start_acars() -> Response:
|
|||||||
gain = validate_gain(data.get('gain', '40'))
|
gain = validate_gain(data.get('gain', '40'))
|
||||||
ppm = validate_ppm(data.get('ppm', '0'))
|
ppm = validate_ppm(data.get('ppm', '0'))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return api_error(str(e), 400)
|
||||||
|
|
||||||
# Resolve SDR type for device selection
|
# Resolve SDR type for device selection
|
||||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||||
@@ -249,11 +244,7 @@ def start_acars() -> Response:
|
|||||||
device_int = int(device)
|
device_int = int(device)
|
||||||
error = app_module.claim_sdr_device(device_int, 'acars', sdr_type_str)
|
error = app_module.claim_sdr_device(device_int, 'acars', sdr_type_str)
|
||||||
if error:
|
if error:
|
||||||
return jsonify({
|
return api_error(error, 409, error_type='DEVICE_BUSY')
|
||||||
'status': 'error',
|
|
||||||
'error_type': 'DEVICE_BUSY',
|
|
||||||
'message': error
|
|
||||||
}), 409
|
|
||||||
|
|
||||||
acars_active_device = device_int
|
acars_active_device = device_int
|
||||||
acars_active_sdr_type = sdr_type_str
|
acars_active_sdr_type = sdr_type_str
|
||||||
@@ -372,7 +363,7 @@ def start_acars() -> Response:
|
|||||||
if stderr:
|
if stderr:
|
||||||
error_msg += f': {stderr[:500]}'
|
error_msg += f': {stderr[:500]}'
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
return jsonify({'status': 'error', 'message': error_msg}), 500
|
return api_error(error_msg, 500)
|
||||||
|
|
||||||
app_module.acars_process = process
|
app_module.acars_process = process
|
||||||
register_process(process)
|
register_process(process)
|
||||||
@@ -399,7 +390,7 @@ def start_acars() -> Response:
|
|||||||
acars_active_device = None
|
acars_active_device = None
|
||||||
acars_active_sdr_type = None
|
acars_active_sdr_type = None
|
||||||
logger.error(f"Failed to start ACARS decoder: {e}")
|
logger.error(f"Failed to start ACARS decoder: {e}")
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
return api_error(str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
@acars_bp.route('/stop', methods=['POST'])
|
@acars_bp.route('/stop', methods=['POST'])
|
||||||
@@ -409,10 +400,7 @@ def stop_acars() -> Response:
|
|||||||
|
|
||||||
with app_module.acars_lock:
|
with app_module.acars_lock:
|
||||||
if not app_module.acars_process:
|
if not app_module.acars_process:
|
||||||
return jsonify({
|
return api_error('ACARS decoder not running', 400)
|
||||||
'status': 'error',
|
|
||||||
'message': 'ACARS decoder not running'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
app_module.acars_process.terminate()
|
app_module.acars_process.terminate()
|
||||||
|
|||||||
+29
-27
@@ -17,6 +17,8 @@ from typing import Any, Generator
|
|||||||
|
|
||||||
from flask import Blueprint, Response, jsonify, make_response, render_template, request
|
from flask import Blueprint, Response, jsonify, make_response, render_template, request
|
||||||
|
|
||||||
|
from utils.responses import api_success, api_error
|
||||||
|
|
||||||
# psycopg2 is optional - only needed for PostgreSQL history persistence
|
# psycopg2 is optional - only needed for PostgreSQL history persistence
|
||||||
try:
|
try:
|
||||||
import psycopg2
|
import psycopg2
|
||||||
@@ -866,7 +868,7 @@ def start_adsb():
|
|||||||
gain = int(validate_gain(data.get('gain', '40')))
|
gain = int(validate_gain(data.get('gain', '40')))
|
||||||
device = validate_device_index(data.get('device', '0'))
|
device = validate_device_index(data.get('device', '0'))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return api_error(str(e), 400)
|
||||||
|
|
||||||
# Check for remote SBS connection (e.g., remote dump1090)
|
# Check for remote SBS connection (e.g., remote dump1090)
|
||||||
remote_sbs_host = data.get('remote_sbs_host')
|
remote_sbs_host = data.get('remote_sbs_host')
|
||||||
@@ -878,7 +880,7 @@ def start_adsb():
|
|||||||
remote_sbs_host = validate_rtl_tcp_host(remote_sbs_host)
|
remote_sbs_host = validate_rtl_tcp_host(remote_sbs_host)
|
||||||
remote_sbs_port = validate_rtl_tcp_port(remote_sbs_port)
|
remote_sbs_port = validate_rtl_tcp_port(remote_sbs_port)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return api_error(str(e), 400)
|
||||||
|
|
||||||
remote_addr = f"{remote_sbs_host}:{remote_sbs_port}"
|
remote_addr = f"{remote_sbs_host}:{remote_sbs_port}"
|
||||||
logger.info(f"Connecting to remote dump1090 SBS at {remote_addr}")
|
logger.info(f"Connecting to remote dump1090 SBS at {remote_addr}")
|
||||||
@@ -935,12 +937,12 @@ def start_adsb():
|
|||||||
if sdr_type == SDRType.RTL_SDR:
|
if sdr_type == SDRType.RTL_SDR:
|
||||||
dump1090_path = find_dump1090()
|
dump1090_path = find_dump1090()
|
||||||
if not dump1090_path:
|
if not dump1090_path:
|
||||||
return jsonify({'status': 'error', 'message': 'dump1090 not found. Install dump1090/dump1090-fa or ensure it is in /usr/local/bin/'})
|
return api_error('dump1090 not found. Install dump1090/dump1090-fa or ensure it is in /usr/local/bin/')
|
||||||
else:
|
else:
|
||||||
# For LimeSDR/HackRF, check for readsb (dump1090 with SoapySDR support)
|
# For LimeSDR/HackRF, check for readsb (dump1090 with SoapySDR support)
|
||||||
dump1090_path = shutil.which('readsb') or find_dump1090()
|
dump1090_path = shutil.which('readsb') or find_dump1090()
|
||||||
if not dump1090_path:
|
if not dump1090_path:
|
||||||
return jsonify({'status': 'error', 'message': f'readsb or dump1090 not found for {sdr_type.value}. Install readsb with SoapySDR support.'})
|
return api_error(f'readsb or dump1090 not found for {sdr_type.value}. Install readsb with SoapySDR support.')
|
||||||
|
|
||||||
# Kill any stale app-started process (use process group to ensure full cleanup)
|
# Kill any stale app-started process (use process group to ensure full cleanup)
|
||||||
if app_module.adsb_process:
|
if app_module.adsb_process:
|
||||||
@@ -1122,7 +1124,7 @@ def start_adsb():
|
|||||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||||
adsb_active_device = None
|
adsb_active_device = None
|
||||||
adsb_active_sdr_type = None
|
adsb_active_sdr_type = None
|
||||||
return jsonify({'status': 'error', 'message': str(e)})
|
return api_error(str(e))
|
||||||
|
|
||||||
|
|
||||||
@adsb_bp.route('/stop', methods=['POST'])
|
@adsb_bp.route('/stop', methods=['POST'])
|
||||||
@@ -1233,7 +1235,7 @@ def adsb_history():
|
|||||||
def adsb_history_summary():
|
def adsb_history_summary():
|
||||||
"""Summary stats for ADS-B history window."""
|
"""Summary stats for ADS-B history window."""
|
||||||
if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE:
|
if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE:
|
||||||
return jsonify({'error': 'ADS-B history is disabled'}), 503
|
return api_error('ADS-B history is disabled', 503)
|
||||||
_ensure_history_schema()
|
_ensure_history_schema()
|
||||||
|
|
||||||
since_minutes = _parse_int_param(request.args.get('since_minutes'), 1440, 1, 10080)
|
since_minutes = _parse_int_param(request.args.get('since_minutes'), 1440, 1, 10080)
|
||||||
@@ -1256,14 +1258,14 @@ def adsb_history_summary():
|
|||||||
return jsonify(row)
|
return jsonify(row)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("ADS-B history summary failed: %s", exc)
|
logger.warning("ADS-B history summary failed: %s", exc)
|
||||||
return jsonify({'error': 'History database unavailable'}), 503
|
return api_error('History database unavailable', 503)
|
||||||
|
|
||||||
|
|
||||||
@adsb_bp.route('/history/aircraft')
|
@adsb_bp.route('/history/aircraft')
|
||||||
def adsb_history_aircraft():
|
def adsb_history_aircraft():
|
||||||
"""List latest aircraft snapshots for a time window."""
|
"""List latest aircraft snapshots for a time window."""
|
||||||
if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE:
|
if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE:
|
||||||
return jsonify({'error': 'ADS-B history is disabled'}), 503
|
return api_error('ADS-B history is disabled', 503)
|
||||||
_ensure_history_schema()
|
_ensure_history_schema()
|
||||||
|
|
||||||
since_minutes = _parse_int_param(request.args.get('since_minutes'), 1440, 1, 10080)
|
since_minutes = _parse_int_param(request.args.get('since_minutes'), 1440, 1, 10080)
|
||||||
@@ -1306,19 +1308,19 @@ def adsb_history_aircraft():
|
|||||||
return jsonify({'aircraft': rows, 'count': len(rows)})
|
return jsonify({'aircraft': rows, 'count': len(rows)})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("ADS-B history aircraft query failed: %s", exc)
|
logger.warning("ADS-B history aircraft query failed: %s", exc)
|
||||||
return jsonify({'error': 'History database unavailable'}), 503
|
return api_error('History database unavailable', 503)
|
||||||
|
|
||||||
|
|
||||||
@adsb_bp.route('/history/timeline')
|
@adsb_bp.route('/history/timeline')
|
||||||
def adsb_history_timeline():
|
def adsb_history_timeline():
|
||||||
"""Timeline snapshots for a specific aircraft."""
|
"""Timeline snapshots for a specific aircraft."""
|
||||||
if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE:
|
if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE:
|
||||||
return jsonify({'error': 'ADS-B history is disabled'}), 503
|
return api_error('ADS-B history is disabled', 503)
|
||||||
_ensure_history_schema()
|
_ensure_history_schema()
|
||||||
|
|
||||||
icao = (request.args.get('icao') or '').strip().upper()
|
icao = (request.args.get('icao') or '').strip().upper()
|
||||||
if not icao:
|
if not icao:
|
||||||
return jsonify({'error': 'icao is required'}), 400
|
return api_error('icao is required', 400)
|
||||||
|
|
||||||
since_minutes = _parse_int_param(request.args.get('since_minutes'), 1440, 1, 10080)
|
since_minutes = _parse_int_param(request.args.get('since_minutes'), 1440, 1, 10080)
|
||||||
limit = _parse_int_param(request.args.get('limit'), 2000, 1, 20000)
|
limit = _parse_int_param(request.args.get('limit'), 2000, 1, 20000)
|
||||||
@@ -1341,14 +1343,14 @@ def adsb_history_timeline():
|
|||||||
return jsonify({'icao': icao, 'timeline': rows, 'count': len(rows)})
|
return jsonify({'icao': icao, 'timeline': rows, 'count': len(rows)})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("ADS-B history timeline query failed: %s", exc)
|
logger.warning("ADS-B history timeline query failed: %s", exc)
|
||||||
return jsonify({'error': 'History database unavailable'}), 503
|
return api_error('History database unavailable', 503)
|
||||||
|
|
||||||
|
|
||||||
@adsb_bp.route('/history/messages')
|
@adsb_bp.route('/history/messages')
|
||||||
def adsb_history_messages():
|
def adsb_history_messages():
|
||||||
"""Raw message history for a specific aircraft."""
|
"""Raw message history for a specific aircraft."""
|
||||||
if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE:
|
if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE:
|
||||||
return jsonify({'error': 'ADS-B history is disabled'}), 503
|
return api_error('ADS-B history is disabled', 503)
|
||||||
_ensure_history_schema()
|
_ensure_history_schema()
|
||||||
|
|
||||||
icao = (request.args.get('icao') or '').strip().upper()
|
icao = (request.args.get('icao') or '').strip().upper()
|
||||||
@@ -1373,22 +1375,22 @@ def adsb_history_messages():
|
|||||||
return jsonify({'icao': icao, 'messages': rows, 'count': len(rows)})
|
return jsonify({'icao': icao, 'messages': rows, 'count': len(rows)})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("ADS-B history message query failed: %s", exc)
|
logger.warning("ADS-B history message query failed: %s", exc)
|
||||||
return jsonify({'error': 'History database unavailable'}), 503
|
return api_error('History database unavailable', 503)
|
||||||
|
|
||||||
|
|
||||||
@adsb_bp.route('/history/export')
|
@adsb_bp.route('/history/export')
|
||||||
def adsb_history_export():
|
def adsb_history_export():
|
||||||
"""Export ADS-B history data in CSV or JSON format."""
|
"""Export ADS-B history data in CSV or JSON format."""
|
||||||
if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE:
|
if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE:
|
||||||
return jsonify({'error': 'ADS-B history is disabled'}), 503
|
return api_error('ADS-B history is disabled', 503)
|
||||||
_ensure_history_schema()
|
_ensure_history_schema()
|
||||||
|
|
||||||
export_format = str(request.args.get('format') or 'csv').strip().lower()
|
export_format = str(request.args.get('format') or 'csv').strip().lower()
|
||||||
export_type = str(request.args.get('type') or 'all').strip().lower()
|
export_type = str(request.args.get('type') or 'all').strip().lower()
|
||||||
if export_format not in {'csv', 'json'}:
|
if export_format not in {'csv', 'json'}:
|
||||||
return jsonify({'error': 'format must be csv or json'}), 400
|
return api_error('format must be csv or json', 400)
|
||||||
if export_type not in {'messages', 'snapshots', 'sessions', 'all'}:
|
if export_type not in {'messages', 'snapshots', 'sessions', 'all'}:
|
||||||
return jsonify({'error': 'type must be messages, snapshots, sessions, or all'}), 400
|
return api_error('type must be messages, snapshots, sessions, or all', 400)
|
||||||
|
|
||||||
scope, since_minutes, start, end = _parse_export_scope(request.args)
|
scope, since_minutes, start, end = _parse_export_scope(request.args)
|
||||||
icao = (request.args.get('icao') or '').strip().upper()
|
icao = (request.args.get('icao') or '').strip().upper()
|
||||||
@@ -1501,7 +1503,7 @@ def adsb_history_export():
|
|||||||
sessions = cur.fetchall()
|
sessions = cur.fetchall()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("ADS-B history export failed: %s", exc)
|
logger.warning("ADS-B history export failed: %s", exc)
|
||||||
return jsonify({'error': 'History database unavailable'}), 503
|
return api_error('History database unavailable', 503)
|
||||||
|
|
||||||
exported_at = datetime.now(timezone.utc).isoformat()
|
exported_at = datetime.now(timezone.utc).isoformat()
|
||||||
timestamp = datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')
|
timestamp = datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')
|
||||||
@@ -1559,13 +1561,13 @@ def adsb_history_export():
|
|||||||
def adsb_history_prune():
|
def adsb_history_prune():
|
||||||
"""Delete ADS-B history for a selected time range or entire dataset."""
|
"""Delete ADS-B history for a selected time range or entire dataset."""
|
||||||
if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE:
|
if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE:
|
||||||
return jsonify({'error': 'ADS-B history is disabled'}), 503
|
return api_error('ADS-B history is disabled', 503)
|
||||||
_ensure_history_schema()
|
_ensure_history_schema()
|
||||||
|
|
||||||
payload = request.get_json(silent=True) or {}
|
payload = request.get_json(silent=True) or {}
|
||||||
mode = str(payload.get('mode') or 'range').strip().lower()
|
mode = str(payload.get('mode') or 'range').strip().lower()
|
||||||
if mode not in {'range', 'all'}:
|
if mode not in {'range', 'all'}:
|
||||||
return jsonify({'error': 'mode must be range or all'}), 400
|
return api_error('mode must be range or all', 400)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with _get_history_connection() as conn:
|
with _get_history_connection() as conn:
|
||||||
@@ -1587,11 +1589,11 @@ def adsb_history_prune():
|
|||||||
start = _parse_iso_datetime(payload.get('start'))
|
start = _parse_iso_datetime(payload.get('start'))
|
||||||
end = _parse_iso_datetime(payload.get('end'))
|
end = _parse_iso_datetime(payload.get('end'))
|
||||||
if start is None or end is None:
|
if start is None or end is None:
|
||||||
return jsonify({'error': 'start and end ISO datetime values are required'}), 400
|
return api_error('start and end ISO datetime values are required', 400)
|
||||||
if end <= start:
|
if end <= start:
|
||||||
return jsonify({'error': 'end must be after start'}), 400
|
return api_error('end must be after start', 400)
|
||||||
if end - start > timedelta(days=31):
|
if end - start > timedelta(days=31):
|
||||||
return jsonify({'error': 'range cannot exceed 31 days'}), 400
|
return api_error('range cannot exceed 31 days', 400)
|
||||||
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
@@ -1623,7 +1625,7 @@ def adsb_history_prune():
|
|||||||
})
|
})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("ADS-B history prune failed: %s", exc)
|
logger.warning("ADS-B history prune failed: %s", exc)
|
||||||
return jsonify({'error': 'History database unavailable'}), 503
|
return api_error('History database unavailable', 503)
|
||||||
|
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -1668,7 +1670,7 @@ def aircraft_photo(registration: str):
|
|||||||
|
|
||||||
# Validate registration format (alphanumeric with dashes)
|
# Validate registration format (alphanumeric with dashes)
|
||||||
if not registration or not all(c.isalnum() or c == '-' for c in registration):
|
if not registration or not all(c.isalnum() or c == '-' for c in registration):
|
||||||
return jsonify({'error': 'Invalid registration'}), 400
|
return api_error('Invalid registration', 400)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Planespotters.net public API
|
# Planespotters.net public API
|
||||||
@@ -1701,7 +1703,7 @@ def aircraft_photo(registration: str):
|
|||||||
def get_aircraft_messages(icao: str):
|
def get_aircraft_messages(icao: str):
|
||||||
"""Get correlated ACARS/VDL2 messages for an aircraft."""
|
"""Get correlated ACARS/VDL2 messages for an aircraft."""
|
||||||
if not icao or not all(c in '0123456789ABCDEFabcdef' for c in icao):
|
if not icao or not all(c in '0123456789ABCDEFabcdef' for c in icao):
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid ICAO'}), 400
|
return api_error('Invalid ICAO', 400)
|
||||||
|
|
||||||
aircraft = app_module.adsb_aircraft.get(icao.upper())
|
aircraft = app_module.adsb_aircraft.get(icao.upper())
|
||||||
callsign = aircraft.get('callsign') if aircraft else None
|
callsign = aircraft.get('callsign') if aircraft else None
|
||||||
@@ -1722,4 +1724,4 @@ def get_aircraft_messages(icao: str):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return jsonify({'status': 'success', 'icao': icao.upper(), **messages})
|
return api_success(data={'icao': icao.upper(), **messages})
|
||||||
|
|||||||
+9
-15
@@ -14,6 +14,7 @@ from typing import Generator
|
|||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response, render_template
|
from flask import Blueprint, jsonify, request, Response, render_template
|
||||||
|
|
||||||
|
from utils.responses import api_success, api_error
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from config import SHARED_OBSERVER_LOCATION_ENABLED
|
from config import SHARED_OBSERVER_LOCATION_ENABLED
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
@@ -361,7 +362,7 @@ def start_ais():
|
|||||||
|
|
||||||
with app_module.ais_lock:
|
with app_module.ais_lock:
|
||||||
if ais_running:
|
if ais_running:
|
||||||
return jsonify({'status': 'already_running', 'message': 'AIS tracking already active'}), 409
|
return api_error('AIS tracking already active', 409)
|
||||||
|
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
|
|
||||||
@@ -370,15 +371,12 @@ def start_ais():
|
|||||||
gain = int(validate_gain(data.get('gain', '40')))
|
gain = int(validate_gain(data.get('gain', '40')))
|
||||||
device = validate_device_index(data.get('device', '0'))
|
device = validate_device_index(data.get('device', '0'))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return api_error(str(e), 400)
|
||||||
|
|
||||||
# Find AIS-catcher
|
# Find AIS-catcher
|
||||||
ais_catcher_path = find_ais_catcher()
|
ais_catcher_path = find_ais_catcher()
|
||||||
if not ais_catcher_path:
|
if not ais_catcher_path:
|
||||||
return jsonify({
|
return api_error('AIS-catcher not found. Install from https://github.com/jvde-github/AIS-catcher/releases', 400)
|
||||||
'status': 'error',
|
|
||||||
'message': 'AIS-catcher not found. Install from https://github.com/jvde-github/AIS-catcher/releases'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
# Get SDR type from request
|
# Get SDR type from request
|
||||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||||
@@ -406,11 +404,7 @@ def start_ais():
|
|||||||
device_int = int(device)
|
device_int = int(device)
|
||||||
error = app_module.claim_sdr_device(device_int, 'ais', sdr_type_str)
|
error = app_module.claim_sdr_device(device_int, 'ais', sdr_type_str)
|
||||||
if error:
|
if error:
|
||||||
return jsonify({
|
return api_error(error, 409, error_type='DEVICE_BUSY')
|
||||||
'status': 'error',
|
|
||||||
'error_type': 'DEVICE_BUSY',
|
|
||||||
'message': error
|
|
||||||
}), 409
|
|
||||||
|
|
||||||
# Build command using SDR abstraction
|
# Build command using SDR abstraction
|
||||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||||
@@ -455,7 +449,7 @@ def start_ais():
|
|||||||
error_msg = 'AIS-catcher failed to start. Check SDR device connection.'
|
error_msg = 'AIS-catcher failed to start. Check SDR device connection.'
|
||||||
if stderr_output:
|
if stderr_output:
|
||||||
error_msg += f' Error: {stderr_output[:500]}'
|
error_msg += f' Error: {stderr_output[:500]}'
|
||||||
return jsonify({'status': 'error', 'message': error_msg}), 500
|
return api_error(error_msg, 500)
|
||||||
|
|
||||||
ais_running = True
|
ais_running = True
|
||||||
ais_active_device = device
|
ais_active_device = device
|
||||||
@@ -475,7 +469,7 @@ def start_ais():
|
|||||||
# Release device on failure
|
# Release device on failure
|
||||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||||
logger.error(f"Failed to start AIS-catcher: {e}")
|
logger.error(f"Failed to start AIS-catcher: {e}")
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
return api_error(str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
@ais_bp.route('/stop', methods=['POST'])
|
@ais_bp.route('/stop', methods=['POST'])
|
||||||
@@ -535,7 +529,7 @@ def stream_ais():
|
|||||||
def get_vessel_dsc(mmsi: str):
|
def get_vessel_dsc(mmsi: str):
|
||||||
"""Get DSC messages associated with a vessel MMSI."""
|
"""Get DSC messages associated with a vessel MMSI."""
|
||||||
if not mmsi or not mmsi.isdigit():
|
if not mmsi or not mmsi.isdigit():
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid MMSI'}), 400
|
return api_error('Invalid MMSI', 400)
|
||||||
|
|
||||||
matches = []
|
matches = []
|
||||||
try:
|
try:
|
||||||
@@ -545,7 +539,7 @@ def get_vessel_dsc(mmsi: str):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return jsonify({'status': 'success', 'mmsi': mmsi, 'dsc_messages': matches})
|
return api_success(data={'mmsi': mmsi, 'dsc_messages': matches})
|
||||||
|
|
||||||
|
|
||||||
@ais_bp.route('/dashboard')
|
@ais_bp.route('/dashboard')
|
||||||
|
|||||||
+9
-8
@@ -9,6 +9,7 @@ from typing import Generator
|
|||||||
from flask import Blueprint, Response, jsonify, request
|
from flask import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
from utils.alerts import get_alert_manager
|
from utils.alerts import get_alert_manager
|
||||||
|
from utils.responses import api_success, api_error
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
|
|
||||||
alerts_bp = Blueprint('alerts', __name__, url_prefix='/alerts')
|
alerts_bp = Blueprint('alerts', __name__, url_prefix='/alerts')
|
||||||
@@ -18,18 +19,18 @@ alerts_bp = Blueprint('alerts', __name__, url_prefix='/alerts')
|
|||||||
def list_rules():
|
def list_rules():
|
||||||
manager = get_alert_manager()
|
manager = get_alert_manager()
|
||||||
include_disabled = request.args.get('all') in ('1', 'true', 'yes')
|
include_disabled = request.args.get('all') in ('1', 'true', 'yes')
|
||||||
return jsonify({'status': 'success', 'rules': manager.list_rules(include_disabled=include_disabled)})
|
return api_success(data={'rules': manager.list_rules(include_disabled=include_disabled)})
|
||||||
|
|
||||||
|
|
||||||
@alerts_bp.route('/rules', methods=['POST'])
|
@alerts_bp.route('/rules', methods=['POST'])
|
||||||
def create_rule():
|
def create_rule():
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
if not isinstance(data.get('match', {}), dict):
|
if not isinstance(data.get('match', {}), dict):
|
||||||
return jsonify({'status': 'error', 'message': 'match must be a JSON object'}), 400
|
return api_error('match must be a JSON object', 400)
|
||||||
|
|
||||||
manager = get_alert_manager()
|
manager = get_alert_manager()
|
||||||
rule_id = manager.add_rule(data)
|
rule_id = manager.add_rule(data)
|
||||||
return jsonify({'status': 'success', 'rule_id': rule_id})
|
return api_success(data={'rule_id': rule_id})
|
||||||
|
|
||||||
|
|
||||||
@alerts_bp.route('/rules/<int:rule_id>', methods=['PUT', 'PATCH'])
|
@alerts_bp.route('/rules/<int:rule_id>', methods=['PUT', 'PATCH'])
|
||||||
@@ -38,8 +39,8 @@ def update_rule(rule_id: int):
|
|||||||
manager = get_alert_manager()
|
manager = get_alert_manager()
|
||||||
ok = manager.update_rule(rule_id, data)
|
ok = manager.update_rule(rule_id, data)
|
||||||
if not ok:
|
if not ok:
|
||||||
return jsonify({'status': 'error', 'message': 'Rule not found or no changes'}), 404
|
return api_error('Rule not found or no changes', 404)
|
||||||
return jsonify({'status': 'success'})
|
return api_success()
|
||||||
|
|
||||||
|
|
||||||
@alerts_bp.route('/rules/<int:rule_id>', methods=['DELETE'])
|
@alerts_bp.route('/rules/<int:rule_id>', methods=['DELETE'])
|
||||||
@@ -47,8 +48,8 @@ def delete_rule(rule_id: int):
|
|||||||
manager = get_alert_manager()
|
manager = get_alert_manager()
|
||||||
ok = manager.delete_rule(rule_id)
|
ok = manager.delete_rule(rule_id)
|
||||||
if not ok:
|
if not ok:
|
||||||
return jsonify({'status': 'error', 'message': 'Rule not found'}), 404
|
return api_error('Rule not found', 404)
|
||||||
return jsonify({'status': 'success'})
|
return api_success()
|
||||||
|
|
||||||
|
|
||||||
@alerts_bp.route('/events', methods=['GET'])
|
@alerts_bp.route('/events', methods=['GET'])
|
||||||
@@ -58,7 +59,7 @@ def list_events():
|
|||||||
mode = request.args.get('mode')
|
mode = request.args.get('mode')
|
||||||
severity = request.args.get('severity')
|
severity = request.args.get('severity')
|
||||||
events = manager.list_events(limit=limit, mode=mode, severity=severity)
|
events = manager.list_events(limit=limit, mode=mode, severity=severity)
|
||||||
return jsonify({'status': 'success', 'events': events})
|
return api_success(data={'events': events})
|
||||||
|
|
||||||
|
|
||||||
@alerts_bp.route('/stream', methods=['GET'])
|
@alerts_bp.route('/stream', methods=['GET'])
|
||||||
|
|||||||
+22
-57
@@ -20,6 +20,7 @@ from typing import Any, Generator, Optional
|
|||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
|
from utils.responses import api_success, api_error
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.logging import sensor_logger as logger
|
from utils.logging import sensor_logger as logger
|
||||||
from utils.validation import (
|
from utils.validation import (
|
||||||
@@ -1651,8 +1652,7 @@ def aprs_data() -> Response:
|
|||||||
if app_module.aprs_process:
|
if app_module.aprs_process:
|
||||||
running = app_module.aprs_process.poll() is None
|
running = app_module.aprs_process.poll() is None
|
||||||
|
|
||||||
return jsonify({
|
return api_success(data={
|
||||||
'status': 'success',
|
|
||||||
'running': running,
|
'running': running,
|
||||||
'stations': list(aprs_stations.values()),
|
'stations': list(aprs_stations.values()),
|
||||||
'count': len(aprs_stations),
|
'count': len(aprs_stations),
|
||||||
@@ -1670,20 +1670,14 @@ def start_aprs() -> Response:
|
|||||||
|
|
||||||
with app_module.aprs_lock:
|
with app_module.aprs_lock:
|
||||||
if app_module.aprs_process and app_module.aprs_process.poll() is None:
|
if app_module.aprs_process and app_module.aprs_process.poll() is None:
|
||||||
return jsonify({
|
return api_error('APRS decoder already running', 409)
|
||||||
'status': 'error',
|
|
||||||
'message': 'APRS decoder already running'
|
|
||||||
}), 409
|
|
||||||
|
|
||||||
# Check for decoder (prefer direwolf, fallback to multimon-ng)
|
# Check for decoder (prefer direwolf, fallback to multimon-ng)
|
||||||
direwolf_path = find_direwolf()
|
direwolf_path = find_direwolf()
|
||||||
multimon_path = find_multimon_ng()
|
multimon_path = find_multimon_ng()
|
||||||
|
|
||||||
if not direwolf_path and not multimon_path:
|
if not direwolf_path and not multimon_path:
|
||||||
return jsonify({
|
return api_error('No APRS decoder found. Install direwolf or multimon-ng', 400)
|
||||||
'status': 'error',
|
|
||||||
'message': 'No APRS decoder found. Install direwolf or multimon-ng'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
|
|
||||||
@@ -1693,7 +1687,7 @@ def start_aprs() -> Response:
|
|||||||
gain = validate_gain(data.get('gain', '40'))
|
gain = validate_gain(data.get('gain', '40'))
|
||||||
ppm = validate_ppm(data.get('ppm', '0'))
|
ppm = validate_ppm(data.get('ppm', '0'))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return api_error(str(e), 400)
|
||||||
|
|
||||||
# Check for rtl_tcp (remote SDR) connection
|
# Check for rtl_tcp (remote SDR) connection
|
||||||
rtl_tcp_host = data.get('rtl_tcp_host')
|
rtl_tcp_host = data.get('rtl_tcp_host')
|
||||||
@@ -1707,26 +1701,16 @@ def start_aprs() -> Response:
|
|||||||
|
|
||||||
if sdr_type == SDRType.RTL_SDR:
|
if sdr_type == SDRType.RTL_SDR:
|
||||||
if find_rtl_fm() is None:
|
if find_rtl_fm() is None:
|
||||||
return jsonify({
|
return api_error('rtl_fm not found. Install with: sudo apt install rtl-sdr', 400)
|
||||||
'status': 'error',
|
|
||||||
'message': 'rtl_fm not found. Install with: sudo apt install rtl-sdr'
|
|
||||||
}), 400
|
|
||||||
else:
|
else:
|
||||||
if find_rx_fm() is None:
|
if find_rx_fm() is None:
|
||||||
return jsonify({
|
return api_error(f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.', 400)
|
||||||
'status': 'error',
|
|
||||||
'message': f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
# Reserve SDR device to prevent conflicts (skip for remote rtl_tcp)
|
# Reserve SDR device to prevent conflicts (skip for remote rtl_tcp)
|
||||||
if not rtl_tcp_host:
|
if not rtl_tcp_host:
|
||||||
error = app_module.claim_sdr_device(device, 'aprs', sdr_type_str)
|
error = app_module.claim_sdr_device(device, 'aprs', sdr_type_str)
|
||||||
if error:
|
if error:
|
||||||
return jsonify({
|
return api_error(error, 409, error_type='DEVICE_BUSY')
|
||||||
'status': 'error',
|
|
||||||
'error_type': 'DEVICE_BUSY',
|
|
||||||
'message': error
|
|
||||||
}), 409
|
|
||||||
aprs_active_device = device
|
aprs_active_device = device
|
||||||
aprs_active_sdr_type = sdr_type_str
|
aprs_active_sdr_type = sdr_type_str
|
||||||
|
|
||||||
@@ -1757,7 +1741,7 @@ def start_aprs() -> Response:
|
|||||||
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
|
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
|
||||||
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
|
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return api_error(str(e), 400)
|
||||||
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
|
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
|
||||||
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
|
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
|
||||||
else:
|
else:
|
||||||
@@ -1782,7 +1766,7 @@ def start_aprs() -> Response:
|
|||||||
app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
|
app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
|
||||||
aprs_active_device = None
|
aprs_active_device = None
|
||||||
aprs_active_sdr_type = None
|
aprs_active_sdr_type = None
|
||||||
return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500
|
return api_error(f'Failed to build SDR command: {e}', 500)
|
||||||
|
|
||||||
# Build decoder command
|
# Build decoder command
|
||||||
if direwolf_path:
|
if direwolf_path:
|
||||||
@@ -1888,7 +1872,7 @@ def start_aprs() -> Response:
|
|||||||
app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
|
app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
|
||||||
aprs_active_device = None
|
aprs_active_device = None
|
||||||
aprs_active_sdr_type = None
|
aprs_active_sdr_type = None
|
||||||
return jsonify({'status': 'error', 'message': error_msg}), 500
|
return api_error(error_msg, 500)
|
||||||
|
|
||||||
if decoder_process.poll() is not None:
|
if decoder_process.poll() is not None:
|
||||||
# Decoder exited early - capture any output from PTY
|
# Decoder exited early - capture any output from PTY
|
||||||
@@ -1916,7 +1900,7 @@ def start_aprs() -> Response:
|
|||||||
app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
|
app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
|
||||||
aprs_active_device = None
|
aprs_active_device = None
|
||||||
aprs_active_sdr_type = None
|
aprs_active_sdr_type = None
|
||||||
return jsonify({'status': 'error', 'message': error_msg}), 500
|
return api_error(error_msg, 500)
|
||||||
|
|
||||||
# Store references for status checks and cleanup
|
# Store references for status checks and cleanup
|
||||||
app_module.aprs_process = decoder_process
|
app_module.aprs_process = decoder_process
|
||||||
@@ -1946,7 +1930,7 @@ def start_aprs() -> Response:
|
|||||||
app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
|
app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
|
||||||
aprs_active_device = None
|
aprs_active_device = None
|
||||||
aprs_active_sdr_type = None
|
aprs_active_sdr_type = None
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
return api_error(str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
@aprs_bp.route('/stop', methods=['POST'])
|
@aprs_bp.route('/stop', methods=['POST'])
|
||||||
@@ -1964,10 +1948,7 @@ def stop_aprs() -> Response:
|
|||||||
processes_to_stop.append(app_module.aprs_process)
|
processes_to_stop.append(app_module.aprs_process)
|
||||||
|
|
||||||
if not processes_to_stop:
|
if not processes_to_stop:
|
||||||
return jsonify({
|
return api_error('APRS decoder not running', 400)
|
||||||
'status': 'error',
|
|
||||||
'message': 'APRS decoder not running'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
for proc in processes_to_stop:
|
for proc in processes_to_stop:
|
||||||
try:
|
try:
|
||||||
@@ -2045,10 +2026,7 @@ def scan_aprs_spectrum() -> Response:
|
|||||||
"""
|
"""
|
||||||
rtl_power_path = find_rtl_power()
|
rtl_power_path = find_rtl_power()
|
||||||
if not rtl_power_path:
|
if not rtl_power_path:
|
||||||
return jsonify({
|
return api_error('rtl_power not found. Install with: sudo apt install rtl-sdr', 400)
|
||||||
'status': 'error',
|
|
||||||
'message': 'rtl_power not found. Install with: sudo apt install rtl-sdr'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
# Get parameters from JSON body or query args
|
# Get parameters from JSON body or query args
|
||||||
if request.is_json:
|
if request.is_json:
|
||||||
@@ -2068,7 +2046,7 @@ def scan_aprs_spectrum() -> Response:
|
|||||||
gain = validate_gain(gain)
|
gain = validate_gain(gain)
|
||||||
duration = min(max(int(duration), 5), 60) # Clamp 5-60 seconds
|
duration = min(max(int(duration), 5), 60) # Clamp 5-60 seconds
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return api_error(str(e), 400)
|
||||||
|
|
||||||
# Get center frequency
|
# Get center frequency
|
||||||
if frequency:
|
if frequency:
|
||||||
@@ -2113,18 +2091,12 @@ def scan_aprs_spectrum() -> Response:
|
|||||||
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
error_msg = result.stderr[:200] if result.stderr else f'Exit code {result.returncode}'
|
error_msg = result.stderr[:200] if result.stderr else f'Exit code {result.returncode}'
|
||||||
return jsonify({
|
return api_error(f'rtl_power failed: {error_msg}', 500)
|
||||||
'status': 'error',
|
|
||||||
'message': f'rtl_power failed: {error_msg}'
|
|
||||||
}), 500
|
|
||||||
|
|
||||||
# Parse rtl_power CSV output
|
# Parse rtl_power CSV output
|
||||||
# Format: date, time, start_hz, end_hz, step_hz, samples, db1, db2, db3, ...
|
# Format: date, time, start_hz, end_hz, step_hz, samples, db1, db2, db3, ...
|
||||||
if not os.path.exists(tmp_file):
|
if not os.path.exists(tmp_file):
|
||||||
return jsonify({
|
return api_error('rtl_power did not produce output file', 500)
|
||||||
'status': 'error',
|
|
||||||
'message': 'rtl_power did not produce output file'
|
|
||||||
}), 500
|
|
||||||
|
|
||||||
bins = []
|
bins = []
|
||||||
with open(tmp_file, 'r') as f:
|
with open(tmp_file, 'r') as f:
|
||||||
@@ -2144,10 +2116,7 @@ def scan_aprs_spectrum() -> Response:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if not bins:
|
if not bins:
|
||||||
return jsonify({
|
return api_error('No spectrum data collected. Check SDR connection and antenna.', 500)
|
||||||
'status': 'error',
|
|
||||||
'message': 'No spectrum data collected. Check SDR connection and antenna.'
|
|
||||||
}), 500
|
|
||||||
|
|
||||||
# Calculate statistics
|
# Calculate statistics
|
||||||
db_values = [b['db'] for b in bins]
|
db_values = [b['db'] for b in bins]
|
||||||
@@ -2177,8 +2146,7 @@ def scan_aprs_spectrum() -> Response:
|
|||||||
else:
|
else:
|
||||||
advice = "Good signal detected. Decoding should work well."
|
advice = "Good signal detected. Decoding should work well."
|
||||||
|
|
||||||
return jsonify({
|
return api_success(data={
|
||||||
'status': 'success',
|
|
||||||
'scan_params': {
|
'scan_params': {
|
||||||
'center_freq_mhz': center_freq_mhz,
|
'center_freq_mhz': center_freq_mhz,
|
||||||
'start_freq_mhz': start_freq_mhz,
|
'start_freq_mhz': start_freq_mhz,
|
||||||
@@ -2204,13 +2172,10 @@ def scan_aprs_spectrum() -> Response:
|
|||||||
})
|
})
|
||||||
|
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
return jsonify({
|
return api_error(f'Spectrum scan timed out after {duration + 15} seconds', 500)
|
||||||
'status': 'error',
|
|
||||||
'message': f'Spectrum scan timed out after {duration + 15} seconds'
|
|
||||||
}), 500
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Spectrum scan error: {e}")
|
logger.error(f"Spectrum scan error: {e}")
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
return api_error(str(e), 500)
|
||||||
finally:
|
finally:
|
||||||
# Cleanup temp file
|
# Cleanup temp file
|
||||||
try:
|
try:
|
||||||
|
|||||||
+57
-40
@@ -17,12 +17,13 @@ from typing import Any, Generator
|
|||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
|
from utils.responses import api_success, api_error
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.dependencies import check_tool
|
from utils.dependencies import check_tool
|
||||||
from utils.logging import bluetooth_logger as logger
|
from utils.logging import bluetooth_logger as logger
|
||||||
from utils.sse import sse_stream_fanout
|
from utils.sse import sse_stream_fanout
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
from utils.validation import validate_bluetooth_interface
|
from utils.validation import validate_bluetooth_interface
|
||||||
from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer
|
from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer
|
||||||
from data.patterns import AIRTAG_PREFIXES, TILE_PREFIXES, SAMSUNG_TRACKER
|
from data.patterns import AIRTAG_PREFIXES, TILE_PREFIXES, SAMSUNG_TRACKER
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
@@ -39,6 +40,23 @@ from utils.constants import (
|
|||||||
|
|
||||||
bluetooth_bp = Blueprint('bluetooth', __name__, url_prefix='/bt')
|
bluetooth_bp = Blueprint('bluetooth', __name__, url_prefix='/bt')
|
||||||
|
|
||||||
|
# --- v1 deprecation ---
|
||||||
|
# These endpoints are deprecated in favor of /api/bluetooth/*.
|
||||||
|
# Frontend still uses v1, so they remain active.
|
||||||
|
# Migration: switch frontend to v2 endpoints, then remove this file.
|
||||||
|
_v1_deprecation_logged = set()
|
||||||
|
|
||||||
|
|
||||||
|
@bluetooth_bp.after_request
|
||||||
|
def _add_deprecation_header(response):
|
||||||
|
"""Add X-Deprecated header to all v1 Bluetooth responses."""
|
||||||
|
response.headers['X-Deprecated'] = 'Use /api/bluetooth/* endpoints instead'
|
||||||
|
endpoint = request.endpoint or ''
|
||||||
|
if endpoint not in _v1_deprecation_logged:
|
||||||
|
_v1_deprecation_logged.add(endpoint)
|
||||||
|
logger.warning(f"Deprecated v1 Bluetooth endpoint called: {request.path} — migrate to /api/bluetooth/*")
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
def classify_bt_device(name, device_class, services, manufacturer=None):
|
def classify_bt_device(name, device_class, services, manufacturer=None):
|
||||||
"""Classify Bluetooth device type based on available info."""
|
"""Classify Bluetooth device type based on available info."""
|
||||||
@@ -331,8 +349,8 @@ def reload_oui_database_route():
|
|||||||
if new_db:
|
if new_db:
|
||||||
OUI_DATABASE.clear()
|
OUI_DATABASE.clear()
|
||||||
OUI_DATABASE.update(new_db)
|
OUI_DATABASE.update(new_db)
|
||||||
return jsonify({'status': 'success', 'entries': len(OUI_DATABASE)})
|
return api_success(data={'entries': len(OUI_DATABASE)})
|
||||||
return jsonify({'status': 'error', 'message': 'Could not load oui_database.json'})
|
return api_error('Could not load oui_database.json')
|
||||||
|
|
||||||
|
|
||||||
@bluetooth_bp.route('/interfaces')
|
@bluetooth_bp.route('/interfaces')
|
||||||
@@ -359,7 +377,7 @@ def start_bt_scan():
|
|||||||
with app_module.bt_lock:
|
with app_module.bt_lock:
|
||||||
if app_module.bt_process:
|
if app_module.bt_process:
|
||||||
if app_module.bt_process.poll() is None:
|
if app_module.bt_process.poll() is None:
|
||||||
return jsonify({'status': 'error', 'message': 'Scan already running'})
|
return api_error('Scan already running')
|
||||||
else:
|
else:
|
||||||
app_module.bt_process = None
|
app_module.bt_process = None
|
||||||
|
|
||||||
@@ -371,7 +389,7 @@ def start_bt_scan():
|
|||||||
try:
|
try:
|
||||||
interface = validate_bluetooth_interface(data.get('interface', 'hci0'))
|
interface = validate_bluetooth_interface(data.get('interface', 'hci0'))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return api_error(str(e), 400)
|
||||||
|
|
||||||
app_module.bt_interface = interface
|
app_module.bt_interface = interface
|
||||||
app_module.bt_devices = {}
|
app_module.bt_devices = {}
|
||||||
@@ -413,14 +431,14 @@ def start_bt_scan():
|
|||||||
os.write(master_fd, b'scan on\n')
|
os.write(master_fd, b'scan on\n')
|
||||||
|
|
||||||
else:
|
else:
|
||||||
return jsonify({'status': 'error', 'message': f'Unknown scan mode: {scan_mode}'})
|
return api_error(f'Unknown scan mode: {scan_mode}')
|
||||||
|
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
|
|
||||||
if app_module.bt_process.poll() is not None:
|
if app_module.bt_process.poll() is not None:
|
||||||
stderr_output = app_module.bt_process.stderr.read().decode('utf-8', errors='replace').strip()
|
stderr_output = app_module.bt_process.stderr.read().decode('utf-8', errors='replace').strip()
|
||||||
app_module.bt_process = None
|
app_module.bt_process = None
|
||||||
return jsonify({'status': 'error', 'message': stderr_output or 'Process failed to start'})
|
return api_error(stderr_output or 'Process failed to start')
|
||||||
|
|
||||||
thread = threading.Thread(target=stream_bt_scan, args=(app_module.bt_process, scan_mode))
|
thread = threading.Thread(target=stream_bt_scan, args=(app_module.bt_process, scan_mode))
|
||||||
thread.daemon = True
|
thread.daemon = True
|
||||||
@@ -430,9 +448,9 @@ def start_bt_scan():
|
|||||||
return jsonify({'status': 'started', 'mode': scan_mode, 'interface': interface})
|
return jsonify({'status': 'started', 'mode': scan_mode, 'interface': interface})
|
||||||
|
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'})
|
return api_error(f'Tool not found: {e.filename}')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)})
|
return api_error(str(e))
|
||||||
|
|
||||||
|
|
||||||
@bluetooth_bp.route('/scan/stop', methods=['POST'])
|
@bluetooth_bp.route('/scan/stop', methods=['POST'])
|
||||||
@@ -459,7 +477,7 @@ def reset_bt_adapter():
|
|||||||
try:
|
try:
|
||||||
interface = validate_bluetooth_interface(data.get('interface', 'hci0'))
|
interface = validate_bluetooth_interface(data.get('interface', 'hci0'))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return api_error(str(e), 400)
|
||||||
|
|
||||||
with app_module.bt_lock:
|
with app_module.bt_lock:
|
||||||
if app_module.bt_process:
|
if app_module.bt_process:
|
||||||
@@ -494,7 +512,7 @@ def reset_bt_adapter():
|
|||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)})
|
return api_error(str(e))
|
||||||
|
|
||||||
|
|
||||||
@bluetooth_bp.route('/enum', methods=['POST'])
|
@bluetooth_bp.route('/enum', methods=['POST'])
|
||||||
@@ -504,7 +522,7 @@ def enum_bt_services():
|
|||||||
target_mac = data.get('mac')
|
target_mac = data.get('mac')
|
||||||
|
|
||||||
if not target_mac:
|
if not target_mac:
|
||||||
return jsonify({'status': 'error', 'message': 'Target MAC required'})
|
return api_error('Target MAC required')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
@@ -529,18 +547,17 @@ def enum_bt_services():
|
|||||||
|
|
||||||
app_module.bt_services[target_mac] = services
|
app_module.bt_services[target_mac] = services
|
||||||
|
|
||||||
return jsonify({
|
return api_success(data={
|
||||||
'status': 'success',
|
|
||||||
'mac': target_mac,
|
'mac': target_mac,
|
||||||
'services': services
|
'services': services
|
||||||
})
|
})
|
||||||
|
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
return jsonify({'status': 'error', 'message': 'Connection timed out'})
|
return api_error('Connection timed out')
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return jsonify({'status': 'error', 'message': 'sdptool not found'})
|
return api_error('sdptool not found')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)})
|
return api_error(str(e))
|
||||||
|
|
||||||
|
|
||||||
@bluetooth_bp.route('/devices')
|
@bluetooth_bp.route('/devices')
|
||||||
@@ -553,23 +570,23 @@ def get_bt_devices():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@bluetooth_bp.route('/stream')
|
@bluetooth_bp.route('/stream')
|
||||||
def stream_bt():
|
def stream_bt():
|
||||||
"""SSE stream for Bluetooth events."""
|
"""SSE stream for Bluetooth events."""
|
||||||
def _on_msg(msg: dict[str, Any]) -> None:
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
process_event('bluetooth', msg, msg.get('type'))
|
process_event('bluetooth', msg, msg.get('type'))
|
||||||
|
|
||||||
response = Response(
|
response = Response(
|
||||||
sse_stream_fanout(
|
sse_stream_fanout(
|
||||||
source_queue=app_module.bt_queue,
|
source_queue=app_module.bt_queue,
|
||||||
channel_key='bluetooth',
|
channel_key='bluetooth',
|
||||||
timeout=1.0,
|
timeout=1.0,
|
||||||
keepalive_interval=30.0,
|
keepalive_interval=30.0,
|
||||||
on_message=_on_msg,
|
on_message=_on_msg,
|
||||||
),
|
),
|
||||||
mimetype='text/event-stream',
|
mimetype='text/event-stream',
|
||||||
)
|
)
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
response.headers['Connection'] = 'keep-alive'
|
response.headers['Connection'] = 'keep-alive'
|
||||||
return response
|
return response
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from utils.bluetooth import (
|
|||||||
get_tracker_engine,
|
get_tracker_engine,
|
||||||
)
|
)
|
||||||
from utils.database import get_db
|
from utils.database import get_db
|
||||||
|
from utils.responses import api_success, api_error
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
|
|
||||||
@@ -231,7 +232,7 @@ def start_scan():
|
|||||||
# Validate mode
|
# Validate mode
|
||||||
valid_modes = ('auto', 'dbus', 'bleak', 'hcitool', 'bluetoothctl', 'ubertooth')
|
valid_modes = ('auto', 'dbus', 'bleak', 'hcitool', 'bluetoothctl', 'ubertooth')
|
||||||
if mode not in valid_modes:
|
if mode not in valid_modes:
|
||||||
return jsonify({'error': f'Invalid mode. Must be one of: {valid_modes}'}), 400
|
return api_error(f'Invalid mode. Must be one of: {valid_modes}', 400)
|
||||||
|
|
||||||
# Get scanner instance
|
# Get scanner instance
|
||||||
scanner = get_bluetooth_scanner(adapter_id)
|
scanner = get_bluetooth_scanner(adapter_id)
|
||||||
@@ -389,7 +390,7 @@ def get_device(device_id: str):
|
|||||||
device = scanner.get_device(device_id)
|
device = scanner.get_device(device_id)
|
||||||
|
|
||||||
if not device:
|
if not device:
|
||||||
return jsonify({'error': 'Device not found'}), 404
|
return api_error('Device not found', 404)
|
||||||
|
|
||||||
return jsonify(device.to_dict())
|
return jsonify(device.to_dict())
|
||||||
|
|
||||||
@@ -529,7 +530,7 @@ def get_tracker_detail(device_id: str):
|
|||||||
device = scanner.get_device(device_id)
|
device = scanner.get_device(device_id)
|
||||||
|
|
||||||
if not device:
|
if not device:
|
||||||
return jsonify({'error': 'Device not found'}), 404
|
return api_error('Device not found', 404)
|
||||||
|
|
||||||
# Get RSSI history for timeline
|
# Get RSSI history for timeline
|
||||||
rssi_history = device.get_rssi_history(max_points=100)
|
rssi_history = device.get_rssi_history(max_points=100)
|
||||||
|
|||||||
+72
-78
@@ -12,6 +12,7 @@ from collections.abc import Generator
|
|||||||
|
|
||||||
from flask import Blueprint, Response, jsonify, request
|
from flask import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
|
from utils.responses import api_success, api_error
|
||||||
from utils.bluetooth.irk_extractor import get_paired_irks
|
from utils.bluetooth.irk_extractor import get_paired_irks
|
||||||
from utils.bt_locate import (
|
from utils.bt_locate import (
|
||||||
Environment,
|
Environment,
|
||||||
@@ -33,18 +34,18 @@ def start_session():
|
|||||||
"""
|
"""
|
||||||
Start a locate session.
|
Start a locate session.
|
||||||
|
|
||||||
Request JSON:
|
Request JSON:
|
||||||
- mac_address: Target MAC address (optional)
|
- mac_address: Target MAC address (optional)
|
||||||
- name_pattern: Target name substring (optional)
|
- name_pattern: Target name substring (optional)
|
||||||
- irk_hex: Identity Resolving Key hex string (optional)
|
- irk_hex: Identity Resolving Key hex string (optional)
|
||||||
- device_id: Device ID from Bluetooth scanner (optional)
|
- device_id: Device ID from Bluetooth scanner (optional)
|
||||||
- device_key: Stable device key from Bluetooth scanner (optional)
|
- device_key: Stable device key from Bluetooth scanner (optional)
|
||||||
- fingerprint_id: Payload fingerprint ID from Bluetooth scanner (optional)
|
- fingerprint_id: Payload fingerprint ID from Bluetooth scanner (optional)
|
||||||
- known_name: Hand-off device name (optional)
|
- known_name: Hand-off device name (optional)
|
||||||
- known_manufacturer: Hand-off manufacturer (optional)
|
- known_manufacturer: Hand-off manufacturer (optional)
|
||||||
- last_known_rssi: Hand-off last RSSI (optional)
|
- last_known_rssi: Hand-off last RSSI (optional)
|
||||||
- environment: 'FREE_SPACE', 'OUTDOOR', 'INDOOR', 'CUSTOM' (default: OUTDOOR)
|
- environment: 'FREE_SPACE', 'OUTDOOR', 'INDOOR', 'CUSTOM' (default: OUTDOOR)
|
||||||
- custom_exponent: Path loss exponent for CUSTOM environment (optional)
|
- custom_exponent: Path loss exponent for CUSTOM environment (optional)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
JSON with session status.
|
JSON with session status.
|
||||||
@@ -52,47 +53,46 @@ def start_session():
|
|||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
|
|
||||||
# Build target
|
# Build target
|
||||||
target = LocateTarget(
|
target = LocateTarget(
|
||||||
mac_address=data.get('mac_address'),
|
mac_address=data.get('mac_address'),
|
||||||
name_pattern=data.get('name_pattern'),
|
name_pattern=data.get('name_pattern'),
|
||||||
irk_hex=data.get('irk_hex'),
|
irk_hex=data.get('irk_hex'),
|
||||||
device_id=data.get('device_id'),
|
device_id=data.get('device_id'),
|
||||||
device_key=data.get('device_key'),
|
device_key=data.get('device_key'),
|
||||||
fingerprint_id=data.get('fingerprint_id'),
|
fingerprint_id=data.get('fingerprint_id'),
|
||||||
known_name=data.get('known_name'),
|
known_name=data.get('known_name'),
|
||||||
known_manufacturer=data.get('known_manufacturer'),
|
known_manufacturer=data.get('known_manufacturer'),
|
||||||
last_known_rssi=data.get('last_known_rssi'),
|
last_known_rssi=data.get('last_known_rssi'),
|
||||||
)
|
)
|
||||||
|
|
||||||
# At least one identifier required
|
# At least one identifier required
|
||||||
if not any([
|
if not any([
|
||||||
target.mac_address,
|
target.mac_address,
|
||||||
target.name_pattern,
|
target.name_pattern,
|
||||||
target.irk_hex,
|
target.irk_hex,
|
||||||
target.device_id,
|
target.device_id,
|
||||||
target.device_key,
|
target.device_key,
|
||||||
target.fingerprint_id,
|
target.fingerprint_id,
|
||||||
]):
|
]):
|
||||||
return jsonify({
|
return api_error(
|
||||||
'error': (
|
'At least one target identifier required '
|
||||||
'At least one target identifier required '
|
'(mac_address, name_pattern, irk_hex, device_id, device_key, or fingerprint_id)',
|
||||||
'(mac_address, name_pattern, irk_hex, device_id, device_key, or fingerprint_id)'
|
400
|
||||||
)
|
)
|
||||||
}), 400
|
|
||||||
|
|
||||||
# Parse environment
|
# Parse environment
|
||||||
env_str = data.get('environment', 'OUTDOOR').upper()
|
env_str = data.get('environment', 'OUTDOOR').upper()
|
||||||
try:
|
try:
|
||||||
environment = Environment[env_str]
|
environment = Environment[env_str]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
return jsonify({'error': f'Invalid environment: {env_str}'}), 400
|
return api_error(f'Invalid environment: {env_str}', 400)
|
||||||
|
|
||||||
custom_exponent = data.get('custom_exponent')
|
custom_exponent = data.get('custom_exponent')
|
||||||
if custom_exponent is not None:
|
if custom_exponent is not None:
|
||||||
try:
|
try:
|
||||||
custom_exponent = float(custom_exponent)
|
custom_exponent = float(custom_exponent)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return jsonify({'error': 'custom_exponent must be a number'}), 400
|
return api_error('custom_exponent must be a number', 400)
|
||||||
|
|
||||||
# Fallback coordinates when GPS is unavailable (from user settings)
|
# Fallback coordinates when GPS is unavailable (from user settings)
|
||||||
fallback_lat = None
|
fallback_lat = None
|
||||||
@@ -109,27 +109,21 @@ def start_session():
|
|||||||
f"env={environment.name}, fallback=({fallback_lat}, {fallback_lon})"
|
f"env={environment.name}, fallback=({fallback_lat}, {fallback_lon})"
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
session = start_locate_session(
|
session = start_locate_session(
|
||||||
target, environment, custom_exponent, fallback_lat, fallback_lon
|
target, environment, custom_exponent, fallback_lat, fallback_lon
|
||||||
)
|
)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
logger.warning(f"Unable to start BT Locate session: {exc}")
|
logger.warning(f"Unable to start BT Locate session: {exc}")
|
||||||
return jsonify({
|
return api_error('Bluetooth scanner could not be started. Check adapter permissions/capabilities.', 503)
|
||||||
'status': 'error',
|
except Exception as exc:
|
||||||
'error': 'Bluetooth scanner could not be started. Check adapter permissions/capabilities.',
|
logger.exception(f"Unexpected error starting BT Locate session: {exc}")
|
||||||
}), 503
|
return api_error('Failed to start locate session', 500)
|
||||||
except Exception as exc:
|
|
||||||
logger.exception(f"Unexpected error starting BT Locate session: {exc}")
|
return jsonify({
|
||||||
return jsonify({
|
'status': 'started',
|
||||||
'status': 'error',
|
'session': session.get_status(),
|
||||||
'error': 'Failed to start locate session',
|
})
|
||||||
}), 500
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'started',
|
|
||||||
'session': session.get_status(),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@bt_locate_bp.route('/stop', methods=['POST'])
|
@bt_locate_bp.route('/stop', methods=['POST'])
|
||||||
@@ -143,18 +137,18 @@ def stop_session():
|
|||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
@bt_locate_bp.route('/status', methods=['GET'])
|
@bt_locate_bp.route('/status', methods=['GET'])
|
||||||
def get_status():
|
def get_status():
|
||||||
"""Get locate session status."""
|
"""Get locate session status."""
|
||||||
session = get_locate_session()
|
session = get_locate_session()
|
||||||
if not session:
|
if not session:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'active': False,
|
'active': False,
|
||||||
'target': None,
|
'target': None,
|
||||||
})
|
})
|
||||||
|
|
||||||
include_debug = str(request.args.get('debug', '')).lower() in ('1', 'true', 'yes')
|
include_debug = str(request.args.get('debug', '')).lower() in ('1', 'true', 'yes')
|
||||||
return jsonify(session.get_status(include_debug=include_debug))
|
return jsonify(session.get_status(include_debug=include_debug))
|
||||||
|
|
||||||
|
|
||||||
@bt_locate_bp.route('/trail', methods=['GET'])
|
@bt_locate_bp.route('/trail', methods=['GET'])
|
||||||
@@ -216,15 +210,15 @@ def test_resolve_rpa():
|
|||||||
address = data.get('address', '')
|
address = data.get('address', '')
|
||||||
|
|
||||||
if not irk_hex or not address:
|
if not irk_hex or not address:
|
||||||
return jsonify({'error': 'irk_hex and address are required'}), 400
|
return api_error('irk_hex and address are required', 400)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
irk = bytes.fromhex(irk_hex)
|
irk = bytes.fromhex(irk_hex)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return jsonify({'error': 'Invalid IRK hex string'}), 400
|
return api_error('Invalid IRK hex string', 400)
|
||||||
|
|
||||||
if len(irk) != 16:
|
if len(irk) != 16:
|
||||||
return jsonify({'error': 'IRK must be exactly 16 bytes (32 hex characters)'}), 400
|
return api_error('IRK must be exactly 16 bytes (32 hex characters)', 400)
|
||||||
|
|
||||||
result = resolve_rpa(irk, address)
|
result = resolve_rpa(irk, address)
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -239,14 +233,14 @@ def set_environment():
|
|||||||
"""Update the environment on the active session."""
|
"""Update the environment on the active session."""
|
||||||
session = get_locate_session()
|
session = get_locate_session()
|
||||||
if not session:
|
if not session:
|
||||||
return jsonify({'error': 'no active session'}), 400
|
return api_error('no active session', 400)
|
||||||
|
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
env_str = data.get('environment', '').upper()
|
env_str = data.get('environment', '').upper()
|
||||||
try:
|
try:
|
||||||
environment = Environment[env_str]
|
environment = Environment[env_str]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
return jsonify({'error': f'Invalid environment: {env_str}'}), 400
|
return api_error(f'Invalid environment: {env_str}', 400)
|
||||||
|
|
||||||
custom_exponent = data.get('custom_exponent')
|
custom_exponent = data.get('custom_exponent')
|
||||||
if custom_exponent is not None:
|
if custom_exponent is not None:
|
||||||
@@ -268,11 +262,11 @@ def debug_matching():
|
|||||||
"""Debug endpoint showing scanner devices and match results."""
|
"""Debug endpoint showing scanner devices and match results."""
|
||||||
session = get_locate_session()
|
session = get_locate_session()
|
||||||
if not session:
|
if not session:
|
||||||
return jsonify({'error': 'no session'})
|
return api_error('no session')
|
||||||
|
|
||||||
scanner = session._scanner
|
scanner = session._scanner
|
||||||
if not scanner:
|
if not scanner:
|
||||||
return jsonify({'error': 'no scanner'})
|
return api_error('no scanner')
|
||||||
|
|
||||||
devices = scanner.get_devices(max_age_seconds=30)
|
devices = scanner.get_devices(max_age_seconds=30)
|
||||||
return jsonify({
|
return jsonify({
|
||||||
|
|||||||
+159
-200
@@ -10,18 +10,19 @@ This blueprint provides:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import queue
|
import queue
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Generator
|
from typing import Generator
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response
|
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
|
from utils.responses import api_success, api_error
|
||||||
from utils.database import (
|
from utils.database import (
|
||||||
create_agent, get_agent, get_agent_by_name, list_agents,
|
create_agent, get_agent, get_agent_by_name, list_agents,
|
||||||
update_agent, delete_agent, store_push_payload, get_recent_payloads
|
update_agent, delete_agent, store_push_payload, get_recent_payloads
|
||||||
@@ -37,28 +38,28 @@ from utils.trilateration import (
|
|||||||
|
|
||||||
logger = logging.getLogger('intercept.controller')
|
logger = logging.getLogger('intercept.controller')
|
||||||
|
|
||||||
controller_bp = Blueprint('controller', __name__, url_prefix='/controller')
|
controller_bp = Blueprint('controller', __name__, url_prefix='/controller')
|
||||||
|
|
||||||
# Multi-agent SSE fanout state (per-client queues).
|
# Multi-agent SSE fanout state (per-client queues).
|
||||||
_agent_stream_subscribers: set[queue.Queue] = set()
|
_agent_stream_subscribers: set[queue.Queue] = set()
|
||||||
_agent_stream_subscribers_lock = threading.Lock()
|
_agent_stream_subscribers_lock = threading.Lock()
|
||||||
_AGENT_STREAM_CLIENT_QUEUE_SIZE = 500
|
_AGENT_STREAM_CLIENT_QUEUE_SIZE = 500
|
||||||
|
|
||||||
|
|
||||||
def _broadcast_agent_data(payload: dict) -> None:
|
def _broadcast_agent_data(payload: dict) -> None:
|
||||||
"""Fan out an ingested payload to all active /controller/stream/all clients."""
|
"""Fan out an ingested payload to all active /controller/stream/all clients."""
|
||||||
with _agent_stream_subscribers_lock:
|
with _agent_stream_subscribers_lock:
|
||||||
subscribers = tuple(_agent_stream_subscribers)
|
subscribers = tuple(_agent_stream_subscribers)
|
||||||
|
|
||||||
for subscriber in subscribers:
|
for subscriber in subscribers:
|
||||||
try:
|
try:
|
||||||
subscriber.put_nowait(payload)
|
subscriber.put_nowait(payload)
|
||||||
except queue.Full:
|
except queue.Full:
|
||||||
try:
|
try:
|
||||||
subscriber.get_nowait()
|
subscriber.get_nowait()
|
||||||
subscriber.put_nowait(payload)
|
subscriber.put_nowait(payload)
|
||||||
except (queue.Empty, queue.Full):
|
except (queue.Empty, queue.Full):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -108,28 +109,25 @@ def register_agent():
|
|||||||
base_url = data.get('base_url', '').strip()
|
base_url = data.get('base_url', '').strip()
|
||||||
|
|
||||||
if not name:
|
if not name:
|
||||||
return jsonify({'status': 'error', 'message': 'Agent name is required'}), 400
|
return api_error('Agent name is required', 400)
|
||||||
if not base_url:
|
if not base_url:
|
||||||
return jsonify({'status': 'error', 'message': 'Base URL is required'}), 400
|
return api_error('Base URL is required', 400)
|
||||||
|
|
||||||
# Validate URL format
|
# Validate URL format
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
try:
|
try:
|
||||||
parsed = urlparse(base_url)
|
parsed = urlparse(base_url)
|
||||||
if parsed.scheme not in ('http', 'https'):
|
if parsed.scheme not in ('http', 'https'):
|
||||||
return jsonify({'status': 'error', 'message': 'URL must start with http:// or https://'}), 400
|
return api_error('URL must start with http:// or https://', 400)
|
||||||
if not parsed.netloc:
|
if not parsed.netloc:
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid URL format'}), 400
|
return api_error('Invalid URL format', 400)
|
||||||
except Exception:
|
except Exception:
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid URL format'}), 400
|
return api_error('Invalid URL format', 400)
|
||||||
|
|
||||||
# Check if agent already exists
|
# Check if agent already exists
|
||||||
existing = get_agent_by_name(name)
|
existing = get_agent_by_name(name)
|
||||||
if existing:
|
if existing:
|
||||||
return jsonify({
|
return api_error(f'Agent with name "{name}" already exists', 409)
|
||||||
'status': 'error',
|
|
||||||
'message': f'Agent with name "{name}" already exists'
|
|
||||||
}), 409
|
|
||||||
|
|
||||||
# Try to connect and get capabilities
|
# Try to connect and get capabilities
|
||||||
api_key = data.get('api_key', '').strip() or None
|
api_key = data.get('api_key', '').strip() or None
|
||||||
@@ -171,7 +169,7 @@ def register_agent():
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Failed to create agent")
|
logger.exception("Failed to create agent")
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
return api_error(str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
@controller_bp.route('/agents/<int:agent_id>', methods=['GET'])
|
@controller_bp.route('/agents/<int:agent_id>', methods=['GET'])
|
||||||
@@ -179,7 +177,7 @@ def get_agent_detail(agent_id: int):
|
|||||||
"""Get details of a specific agent."""
|
"""Get details of a specific agent."""
|
||||||
agent = get_agent(agent_id)
|
agent = get_agent(agent_id)
|
||||||
if not agent:
|
if not agent:
|
||||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
return api_error('Agent not found', 404)
|
||||||
|
|
||||||
# Optionally refresh from agent
|
# Optionally refresh from agent
|
||||||
refresh = request.args.get('refresh', 'false').lower() == 'true'
|
refresh = request.args.get('refresh', 'false').lower() == 'true'
|
||||||
@@ -215,7 +213,7 @@ def update_agent_detail(agent_id: int):
|
|||||||
"""Update an agent's details."""
|
"""Update an agent's details."""
|
||||||
agent = get_agent(agent_id)
|
agent = get_agent(agent_id)
|
||||||
if not agent:
|
if not agent:
|
||||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
return api_error('Agent not found', 404)
|
||||||
|
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
|
|
||||||
@@ -237,7 +235,7 @@ def remove_agent(agent_id: int):
|
|||||||
"""Delete an agent."""
|
"""Delete an agent."""
|
||||||
agent = get_agent(agent_id)
|
agent = get_agent(agent_id)
|
||||||
if not agent:
|
if not agent:
|
||||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
return api_error('Agent not found', 404)
|
||||||
|
|
||||||
delete_agent(agent_id)
|
delete_agent(agent_id)
|
||||||
return jsonify({'status': 'success', 'message': 'Agent deleted'})
|
return jsonify({'status': 'success', 'message': 'Agent deleted'})
|
||||||
@@ -248,7 +246,7 @@ def refresh_agent_metadata(agent_id: int):
|
|||||||
"""Refresh an agent's capabilities and status."""
|
"""Refresh an agent's capabilities and status."""
|
||||||
agent = get_agent(agent_id)
|
agent = get_agent(agent_id)
|
||||||
if not agent:
|
if not agent:
|
||||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
return api_error('Agent not found', 404)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
client = create_client_from_agent(agent)
|
client = create_client_from_agent(agent)
|
||||||
@@ -274,16 +272,10 @@ def refresh_agent_metadata(agent_id: int):
|
|||||||
'metadata': metadata
|
'metadata': metadata
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
return jsonify({
|
return api_error('Agent is not reachable', 503)
|
||||||
'status': 'error',
|
|
||||||
'message': 'Agent is not reachable'
|
|
||||||
}), 503
|
|
||||||
|
|
||||||
except (AgentHTTPError, AgentConnectionError) as e:
|
except (AgentHTTPError, AgentConnectionError) as e:
|
||||||
return jsonify({
|
return api_error(f'Failed to reach agent: {e}', 503)
|
||||||
'status': 'error',
|
|
||||||
'message': f'Failed to reach agent: {e}'
|
|
||||||
}), 503
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -295,7 +287,7 @@ def get_agent_status(agent_id: int):
|
|||||||
"""Get an agent's current status including running modes."""
|
"""Get an agent's current status including running modes."""
|
||||||
agent = get_agent(agent_id)
|
agent = get_agent(agent_id)
|
||||||
if not agent:
|
if not agent:
|
||||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
return api_error('Agent not found', 404)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
client = create_client_from_agent(agent)
|
client = create_client_from_agent(agent)
|
||||||
@@ -307,10 +299,7 @@ def get_agent_status(agent_id: int):
|
|||||||
'agent_status': status
|
'agent_status': status
|
||||||
})
|
})
|
||||||
except (AgentHTTPError, AgentConnectionError) as e:
|
except (AgentHTTPError, AgentConnectionError) as e:
|
||||||
return jsonify({
|
return api_error(f'Failed to reach agent: {e}', 503)
|
||||||
'status': 'error',
|
|
||||||
'message': f'Failed to reach agent: {e}'
|
|
||||||
}), 503
|
|
||||||
|
|
||||||
|
|
||||||
@controller_bp.route('/agents/health', methods=['GET'])
|
@controller_bp.route('/agents/health', methods=['GET'])
|
||||||
@@ -384,7 +373,7 @@ def proxy_start_mode(agent_id: int, mode: str):
|
|||||||
"""Start a mode on a remote agent."""
|
"""Start a mode on a remote agent."""
|
||||||
agent = get_agent(agent_id)
|
agent = get_agent(agent_id)
|
||||||
if not agent:
|
if not agent:
|
||||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
return api_error('Agent not found', 404)
|
||||||
|
|
||||||
params = request.json or {}
|
params = request.json or {}
|
||||||
|
|
||||||
@@ -403,15 +392,9 @@ def proxy_start_mode(agent_id: int, mode: str):
|
|||||||
})
|
})
|
||||||
|
|
||||||
except AgentConnectionError as e:
|
except AgentConnectionError as e:
|
||||||
return jsonify({
|
return api_error(f'Cannot connect to agent: {e}', 503)
|
||||||
'status': 'error',
|
|
||||||
'message': f'Cannot connect to agent: {e}'
|
|
||||||
}), 503
|
|
||||||
except AgentHTTPError as e:
|
except AgentHTTPError as e:
|
||||||
return jsonify({
|
return api_error(f'Agent error: {e}', 502)
|
||||||
'status': 'error',
|
|
||||||
'message': f'Agent error: {e}'
|
|
||||||
}), 502
|
|
||||||
|
|
||||||
|
|
||||||
@controller_bp.route('/agents/<int:agent_id>/<mode>/stop', methods=['POST'])
|
@controller_bp.route('/agents/<int:agent_id>/<mode>/stop', methods=['POST'])
|
||||||
@@ -419,7 +402,7 @@ def proxy_stop_mode(agent_id: int, mode: str):
|
|||||||
"""Stop a mode on a remote agent."""
|
"""Stop a mode on a remote agent."""
|
||||||
agent = get_agent(agent_id)
|
agent = get_agent(agent_id)
|
||||||
if not agent:
|
if not agent:
|
||||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
return api_error('Agent not found', 404)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
client = create_client_from_agent(agent)
|
client = create_client_from_agent(agent)
|
||||||
@@ -435,15 +418,9 @@ def proxy_stop_mode(agent_id: int, mode: str):
|
|||||||
})
|
})
|
||||||
|
|
||||||
except AgentConnectionError as e:
|
except AgentConnectionError as e:
|
||||||
return jsonify({
|
return api_error(f'Cannot connect to agent: {e}', 503)
|
||||||
'status': 'error',
|
|
||||||
'message': f'Cannot connect to agent: {e}'
|
|
||||||
}), 503
|
|
||||||
except AgentHTTPError as e:
|
except AgentHTTPError as e:
|
||||||
return jsonify({
|
return api_error(f'Agent error: {e}', 502)
|
||||||
'status': 'error',
|
|
||||||
'message': f'Agent error: {e}'
|
|
||||||
}), 502
|
|
||||||
|
|
||||||
|
|
||||||
@controller_bp.route('/agents/<int:agent_id>/<mode>/status', methods=['GET'])
|
@controller_bp.route('/agents/<int:agent_id>/<mode>/status', methods=['GET'])
|
||||||
@@ -451,7 +428,7 @@ def proxy_mode_status(agent_id: int, mode: str):
|
|||||||
"""Get mode status from a remote agent."""
|
"""Get mode status from a remote agent."""
|
||||||
agent = get_agent(agent_id)
|
agent = get_agent(agent_id)
|
||||||
if not agent:
|
if not agent:
|
||||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
return api_error('Agent not found', 404)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
client = create_client_from_agent(agent)
|
client = create_client_from_agent(agent)
|
||||||
@@ -465,18 +442,15 @@ def proxy_mode_status(agent_id: int, mode: str):
|
|||||||
})
|
})
|
||||||
|
|
||||||
except (AgentHTTPError, AgentConnectionError) as e:
|
except (AgentHTTPError, AgentConnectionError) as e:
|
||||||
return jsonify({
|
return api_error(f'Agent error: {e}', 502)
|
||||||
'status': 'error',
|
|
||||||
'message': f'Agent error: {e}'
|
|
||||||
}), 502
|
|
||||||
|
|
||||||
|
|
||||||
@controller_bp.route('/agents/<int:agent_id>/<mode>/data', methods=['GET'])
|
@controller_bp.route('/agents/<int:agent_id>/<mode>/data', methods=['GET'])
|
||||||
def proxy_mode_data(agent_id: int, mode: str):
|
def proxy_mode_data(agent_id: int, mode: str):
|
||||||
"""Get current data from a remote agent."""
|
"""Get current data from a remote agent."""
|
||||||
agent = get_agent(agent_id)
|
agent = get_agent(agent_id)
|
||||||
if not agent:
|
if not agent:
|
||||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
return api_error('Agent not found', 404)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
client = create_client_from_agent(agent)
|
client = create_client_from_agent(agent)
|
||||||
@@ -494,60 +468,57 @@ def proxy_mode_data(agent_id: int, mode: str):
|
|||||||
'data': result
|
'data': result
|
||||||
})
|
})
|
||||||
|
|
||||||
except (AgentHTTPError, AgentConnectionError) as e:
|
except (AgentHTTPError, AgentConnectionError) as e:
|
||||||
return jsonify({
|
return api_error(f'Agent error: {e}', 502)
|
||||||
'status': 'error',
|
|
||||||
'message': f'Agent error: {e}'
|
|
||||||
}), 502
|
@controller_bp.route('/agents/<int:agent_id>/<mode>/stream')
|
||||||
|
def proxy_mode_stream(agent_id: int, mode: str):
|
||||||
|
"""Proxy SSE stream from a remote agent."""
|
||||||
@controller_bp.route('/agents/<int:agent_id>/<mode>/stream')
|
agent = get_agent(agent_id)
|
||||||
def proxy_mode_stream(agent_id: int, mode: str):
|
if not agent:
|
||||||
"""Proxy SSE stream from a remote agent."""
|
return api_error('Agent not found', 404)
|
||||||
agent = get_agent(agent_id)
|
|
||||||
if not agent:
|
client = create_client_from_agent(agent)
|
||||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
query = request.query_string.decode('utf-8')
|
||||||
|
url = f"{client.base_url}/{mode}/stream"
|
||||||
client = create_client_from_agent(agent)
|
if query:
|
||||||
query = request.query_string.decode('utf-8')
|
url = f"{url}?{query}"
|
||||||
url = f"{client.base_url}/{mode}/stream"
|
|
||||||
if query:
|
headers = {'Accept': 'text/event-stream'}
|
||||||
url = f"{url}?{query}"
|
if agent.get('api_key'):
|
||||||
|
headers['X-API-Key'] = agent['api_key']
|
||||||
headers = {'Accept': 'text/event-stream'}
|
|
||||||
if agent.get('api_key'):
|
def generate() -> Generator[str, None, None]:
|
||||||
headers['X-API-Key'] = agent['api_key']
|
try:
|
||||||
|
with requests.get(url, headers=headers, stream=True, timeout=(5, 3600)) as resp:
|
||||||
def generate() -> Generator[str, None, None]:
|
resp.raise_for_status()
|
||||||
try:
|
for chunk in resp.iter_content(chunk_size=1024):
|
||||||
with requests.get(url, headers=headers, stream=True, timeout=(5, 3600)) as resp:
|
if not chunk:
|
||||||
resp.raise_for_status()
|
continue
|
||||||
for chunk in resp.iter_content(chunk_size=1024):
|
yield chunk.decode('utf-8', errors='ignore')
|
||||||
if not chunk:
|
except Exception as e:
|
||||||
continue
|
logger.error(f"SSE proxy error for agent {agent_id}/{mode}: {e}")
|
||||||
yield chunk.decode('utf-8', errors='ignore')
|
yield format_sse({
|
||||||
except Exception as e:
|
'type': 'error',
|
||||||
logger.error(f"SSE proxy error for agent {agent_id}/{mode}: {e}")
|
'message': str(e),
|
||||||
yield format_sse({
|
'agent_id': agent_id,
|
||||||
'type': 'error',
|
'mode': mode,
|
||||||
'message': str(e),
|
})
|
||||||
'agent_id': agent_id,
|
|
||||||
'mode': mode,
|
response = Response(generate(), mimetype='text/event-stream')
|
||||||
})
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
response = Response(generate(), mimetype='text/event-stream')
|
response.headers['Connection'] = 'keep-alive'
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
return response
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
|
||||||
response.headers['Connection'] = 'keep-alive'
|
|
||||||
return response
|
@controller_bp.route('/agents/<int:agent_id>/wifi/monitor', methods=['POST'])
|
||||||
|
def proxy_wifi_monitor(agent_id: int):
|
||||||
|
"""Toggle monitor mode on a remote agent's WiFi interface."""
|
||||||
@controller_bp.route('/agents/<int:agent_id>/wifi/monitor', methods=['POST'])
|
agent = get_agent(agent_id)
|
||||||
def proxy_wifi_monitor(agent_id: int):
|
if not agent:
|
||||||
"""Toggle monitor mode on a remote agent's WiFi interface."""
|
return api_error('Agent not found', 404)
|
||||||
agent = get_agent(agent_id)
|
|
||||||
if not agent:
|
|
||||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
|
||||||
|
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
|
|
||||||
@@ -582,15 +553,9 @@ def proxy_wifi_monitor(agent_id: int):
|
|||||||
})
|
})
|
||||||
|
|
||||||
except AgentConnectionError as e:
|
except AgentConnectionError as e:
|
||||||
return jsonify({
|
return api_error(f'Cannot connect to agent: {e}', 503)
|
||||||
'status': 'error',
|
|
||||||
'message': f'Cannot connect to agent: {e}'
|
|
||||||
}), 503
|
|
||||||
except AgentHTTPError as e:
|
except AgentHTTPError as e:
|
||||||
return jsonify({
|
return api_error(f'Agent error: {e}', 502)
|
||||||
'status': 'error',
|
|
||||||
'message': f'Agent error: {e}'
|
|
||||||
}), 502
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -616,23 +581,23 @@ def ingest_push_data():
|
|||||||
"""
|
"""
|
||||||
data = request.json
|
data = request.json
|
||||||
if not data:
|
if not data:
|
||||||
return jsonify({'status': 'error', 'message': 'No data provided'}), 400
|
return api_error('No data provided', 400)
|
||||||
|
|
||||||
agent_name = data.get('agent_name')
|
agent_name = data.get('agent_name')
|
||||||
if not agent_name:
|
if not agent_name:
|
||||||
return jsonify({'status': 'error', 'message': 'agent_name required'}), 400
|
return api_error('agent_name required', 400)
|
||||||
|
|
||||||
# Find agent
|
# Find agent
|
||||||
agent = get_agent_by_name(agent_name)
|
agent = get_agent_by_name(agent_name)
|
||||||
if not agent:
|
if not agent:
|
||||||
return jsonify({'status': 'error', 'message': 'Unknown agent'}), 401
|
return api_error('Unknown agent', 401)
|
||||||
|
|
||||||
# Validate API key if configured
|
# Validate API key if configured
|
||||||
if agent.get('api_key'):
|
if agent.get('api_key'):
|
||||||
provided_key = request.headers.get('X-API-Key', '')
|
provided_key = request.headers.get('X-API-Key', '')
|
||||||
if provided_key != agent['api_key']:
|
if provided_key != agent['api_key']:
|
||||||
logger.warning(f"Invalid API key from agent {agent_name}")
|
logger.warning(f"Invalid API key from agent {agent_name}")
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid API key'}), 401
|
return api_error('Invalid API key', 401)
|
||||||
|
|
||||||
# Store payload
|
# Store payload
|
||||||
try:
|
try:
|
||||||
@@ -644,16 +609,16 @@ def ingest_push_data():
|
|||||||
received_at=data.get('received_at')
|
received_at=data.get('received_at')
|
||||||
)
|
)
|
||||||
|
|
||||||
# Emit to SSE stream (fanout to all connected clients)
|
# Emit to SSE stream (fanout to all connected clients)
|
||||||
_broadcast_agent_data({
|
_broadcast_agent_data({
|
||||||
'type': 'agent_data',
|
'type': 'agent_data',
|
||||||
'agent_id': agent['id'],
|
'agent_id': agent['id'],
|
||||||
'agent_name': agent_name,
|
'agent_name': agent_name,
|
||||||
'scan_type': data.get('scan_type'),
|
'scan_type': data.get('scan_type'),
|
||||||
'interface': data.get('interface'),
|
'interface': data.get('interface'),
|
||||||
'payload': data.get('payload'),
|
'payload': data.get('payload'),
|
||||||
'received_at': data.get('received_at') or datetime.now(timezone.utc).isoformat()
|
'received_at': data.get('received_at') or datetime.now(timezone.utc).isoformat()
|
||||||
})
|
})
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'accepted',
|
'status': 'accepted',
|
||||||
@@ -662,7 +627,7 @@ def ingest_push_data():
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Failed to store push payload")
|
logger.exception("Failed to store push payload")
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
return api_error(str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
@controller_bp.route('/api/payloads', methods=['GET'])
|
@controller_bp.route('/api/payloads', methods=['GET'])
|
||||||
@@ -690,35 +655,35 @@ def get_payloads():
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@controller_bp.route('/stream/all')
|
@controller_bp.route('/stream/all')
|
||||||
def stream_all_agents():
|
def stream_all_agents():
|
||||||
"""
|
"""
|
||||||
Combined SSE stream for data from all agents.
|
Combined SSE stream for data from all agents.
|
||||||
|
|
||||||
This endpoint streams push data as it arrives from agents.
|
This endpoint streams push data as it arrives from agents.
|
||||||
Each message is tagged with agent_id and agent_name.
|
Each message is tagged with agent_id and agent_name.
|
||||||
"""
|
"""
|
||||||
client_queue: queue.Queue = queue.Queue(maxsize=_AGENT_STREAM_CLIENT_QUEUE_SIZE)
|
client_queue: queue.Queue = queue.Queue(maxsize=_AGENT_STREAM_CLIENT_QUEUE_SIZE)
|
||||||
with _agent_stream_subscribers_lock:
|
with _agent_stream_subscribers_lock:
|
||||||
_agent_stream_subscribers.add(client_queue)
|
_agent_stream_subscribers.add(client_queue)
|
||||||
|
|
||||||
def generate() -> Generator[str, None, None]:
|
def generate() -> Generator[str, None, None]:
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
keepalive_interval = 30.0
|
keepalive_interval = 30.0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
msg = client_queue.get(timeout=1.0)
|
msg = client_queue.get(timeout=1.0)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if now - last_keepalive >= keepalive_interval:
|
if now - last_keepalive >= keepalive_interval:
|
||||||
yield format_sse({'type': 'keepalive'})
|
yield format_sse({'type': 'keepalive'})
|
||||||
last_keepalive = now
|
last_keepalive = now
|
||||||
finally:
|
finally:
|
||||||
with _agent_stream_subscribers_lock:
|
with _agent_stream_subscribers_lock:
|
||||||
_agent_stream_subscribers.discard(client_queue)
|
_agent_stream_subscribers.discard(client_queue)
|
||||||
|
|
||||||
response = Response(generate(), mimetype='text/event-stream')
|
response = Response(generate(), mimetype='text/event-stream')
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
@@ -783,7 +748,7 @@ def add_location_observation():
|
|||||||
required = ['device_id', 'agent_name', 'agent_lat', 'agent_lon', 'rssi']
|
required = ['device_id', 'agent_name', 'agent_lat', 'agent_lon', 'rssi']
|
||||||
for field in required:
|
for field in required:
|
||||||
if field not in data:
|
if field not in data:
|
||||||
return jsonify({'status': 'error', 'message': f'Missing required field: {field}'}), 400
|
return api_error(f'Missing required field: {field}', 400)
|
||||||
|
|
||||||
# Look up agent GPS from database if not provided
|
# Look up agent GPS from database if not provided
|
||||||
agent_lat = data.get('agent_lat')
|
agent_lat = data.get('agent_lat')
|
||||||
@@ -797,10 +762,7 @@ def add_location_observation():
|
|||||||
agent_lon = coords.get('lon') or coords.get('longitude')
|
agent_lon = coords.get('lon') or coords.get('longitude')
|
||||||
|
|
||||||
if agent_lat is None or agent_lon is None:
|
if agent_lat is None or agent_lon is None:
|
||||||
return jsonify({
|
return api_error('Agent GPS coordinates required', 400)
|
||||||
'status': 'error',
|
|
||||||
'message': 'Agent GPS coordinates required'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
estimate = device_tracker.add_observation(
|
estimate = device_tracker.add_observation(
|
||||||
device_id=data['device_id'],
|
device_id=data['device_id'],
|
||||||
@@ -837,10 +799,7 @@ def estimate_location():
|
|||||||
|
|
||||||
observations = data.get('observations', [])
|
observations = data.get('observations', [])
|
||||||
if len(observations) < 2:
|
if len(observations) < 2:
|
||||||
return jsonify({
|
return api_error('At least 2 observations required', 400)
|
||||||
'status': 'error',
|
|
||||||
'message': 'At least 2 observations required'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
environment = data.get('environment', 'outdoor')
|
environment = data.get('environment', 'outdoor')
|
||||||
|
|
||||||
@@ -852,7 +811,7 @@ def estimate_location():
|
|||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Location estimation failed")
|
logger.exception("Location estimation failed")
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
return api_error(str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
@controller_bp.route('/api/location/<device_id>', methods=['GET'])
|
@controller_bp.route('/api/location/<device_id>', methods=['GET'])
|
||||||
@@ -904,7 +863,7 @@ def get_devices_near():
|
|||||||
lon = float(request.args.get('lon', 0))
|
lon = float(request.args.get('lon', 0))
|
||||||
radius = float(request.args.get('radius', 100))
|
radius = float(request.args.get('radius', 100))
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid coordinates'}), 400
|
return api_error('Invalid coordinates', 400)
|
||||||
|
|
||||||
results = device_tracker.get_devices_near(lat, lon, radius)
|
results = device_tracker.get_devices_near(lat, lon, radius)
|
||||||
|
|
||||||
|
|||||||
+9
-31
@@ -6,6 +6,7 @@ from flask import Blueprint, jsonify, request, Response
|
|||||||
|
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.correlation import get_correlations
|
from utils.correlation import get_correlations
|
||||||
|
from utils.responses import api_success, api_error
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
|
|
||||||
logger = get_logger('intercept.correlation')
|
logger = get_logger('intercept.correlation')
|
||||||
@@ -39,18 +40,14 @@ def get_device_correlations() -> Response:
|
|||||||
include_historical=include_historical
|
include_historical=include_historical
|
||||||
)
|
)
|
||||||
|
|
||||||
return jsonify({
|
return api_success(data={
|
||||||
'status': 'success',
|
|
||||||
'correlations': correlations,
|
'correlations': correlations,
|
||||||
'wifi_count': len(wifi_devices),
|
'wifi_count': len(wifi_devices),
|
||||||
'bt_count': len(bt_devices)
|
'bt_count': len(bt_devices)
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error calculating correlations: {e}")
|
logger.error(f"Error calculating correlations: {e}")
|
||||||
return jsonify({
|
return api_error(str(e), 500)
|
||||||
'status': 'error',
|
|
||||||
'message': str(e)
|
|
||||||
}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@correlation_bp.route('/analyze', methods=['POST'])
|
@correlation_bp.route('/analyze', methods=['POST'])
|
||||||
@@ -67,10 +64,7 @@ def analyze_correlation() -> Response:
|
|||||||
bt_mac = data.get('bt_mac')
|
bt_mac = data.get('bt_mac')
|
||||||
|
|
||||||
if not wifi_mac or not bt_mac:
|
if not wifi_mac or not bt_mac:
|
||||||
return jsonify({
|
return api_error('wifi_mac and bt_mac are required', 400)
|
||||||
'status': 'error',
|
|
||||||
'message': 'wifi_mac and bt_mac are required'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get device data
|
# Get device data
|
||||||
@@ -81,16 +75,10 @@ def analyze_correlation() -> Response:
|
|||||||
bt_device = app_module.bt_devices.get(bt_mac)
|
bt_device = app_module.bt_devices.get(bt_mac)
|
||||||
|
|
||||||
if not wifi_device:
|
if not wifi_device:
|
||||||
return jsonify({
|
return api_error(f'WiFi device {wifi_mac} not found', 404)
|
||||||
'status': 'error',
|
|
||||||
'message': f'WiFi device {wifi_mac} not found'
|
|
||||||
}), 404
|
|
||||||
|
|
||||||
if not bt_device:
|
if not bt_device:
|
||||||
return jsonify({
|
return api_error(f'Bluetooth device {bt_mac} not found', 404)
|
||||||
'status': 'error',
|
|
||||||
'message': f'Bluetooth device {bt_mac} not found'
|
|
||||||
}), 404
|
|
||||||
|
|
||||||
# Calculate correlation for this specific pair
|
# Calculate correlation for this specific pair
|
||||||
correlations = get_correlations(
|
correlations = get_correlations(
|
||||||
@@ -101,19 +89,9 @@ def analyze_correlation() -> Response:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if correlations:
|
if correlations:
|
||||||
return jsonify({
|
return api_success(data={'correlation': correlations[0]})
|
||||||
'status': 'success',
|
|
||||||
'correlation': correlations[0]
|
|
||||||
})
|
|
||||||
else:
|
else:
|
||||||
return jsonify({
|
return api_success(data={'correlation': None}, message='No correlation detected between these devices')
|
||||||
'status': 'success',
|
|
||||||
'correlation': None,
|
|
||||||
'message': 'No correlation detected between these devices'
|
|
||||||
})
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error analyzing correlation: {e}")
|
logger.error(f"Error analyzing correlation: {e}")
|
||||||
return jsonify({
|
return api_error(str(e), 500)
|
||||||
'status': 'error',
|
|
||||||
'message': str(e)
|
|
||||||
}), 500
|
|
||||||
|
|||||||
+2
-1
@@ -21,6 +21,7 @@ from typing import Any, Generator
|
|||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
|
from utils.responses import api_success, api_error
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
DSC_VHF_FREQUENCY_MHZ,
|
DSC_VHF_FREQUENCY_MHZ,
|
||||||
@@ -380,7 +381,7 @@ def start_decoding() -> Response:
|
|||||||
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
|
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
|
||||||
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
|
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return api_error(str(e), 400)
|
||||||
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
|
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
|
||||||
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
|
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
|
||||||
else:
|
else:
|
||||||
|
|||||||
+44
-43
@@ -8,6 +8,7 @@ from collections.abc import Generator
|
|||||||
|
|
||||||
from flask import Blueprint, Response, jsonify
|
from flask import Blueprint, Response, jsonify
|
||||||
|
|
||||||
|
from utils.responses import api_success, api_error
|
||||||
from utils.gps import (
|
from utils.gps import (
|
||||||
GPSPosition,
|
GPSPosition,
|
||||||
GPSSkyData,
|
GPSSkyData,
|
||||||
@@ -21,7 +22,7 @@ from utils.gps import (
|
|||||||
stop_gpsd_daemon,
|
stop_gpsd_daemon,
|
||||||
)
|
)
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.sse import sse_stream_fanout
|
from utils.sse import sse_stream_fanout
|
||||||
|
|
||||||
logger = get_logger('intercept.gps')
|
logger = get_logger('intercept.gps')
|
||||||
|
|
||||||
@@ -65,17 +66,17 @@ def auto_connect_gps():
|
|||||||
If gpsd is not running, attempts to detect GPS devices and start gpsd.
|
If gpsd is not running, attempts to detect GPS devices and start gpsd.
|
||||||
Returns current status if already connected.
|
Returns current status if already connected.
|
||||||
"""
|
"""
|
||||||
# Check if already running
|
# Check if already running
|
||||||
reader = get_gps_reader()
|
reader = get_gps_reader()
|
||||||
if reader and reader.is_running:
|
if reader and reader.is_running:
|
||||||
# Ensure stream callbacks are attached for this process.
|
# Ensure stream callbacks are attached for this process.
|
||||||
reader.add_callback(_position_callback)
|
reader.add_callback(_position_callback)
|
||||||
reader.add_sky_callback(_sky_callback)
|
reader.add_sky_callback(_sky_callback)
|
||||||
position = reader.position
|
position = reader.position
|
||||||
sky = reader.sky
|
sky = reader.sky
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'connected',
|
'status': 'connected',
|
||||||
'source': 'gpsd',
|
'source': 'gpsd',
|
||||||
'has_fix': position is not None,
|
'has_fix': position is not None,
|
||||||
'position': position.to_dict() if position else None,
|
'position': position.to_dict() if position else None,
|
||||||
'sky': sky.to_dict() if sky else None,
|
'sky': sky.to_dict() if sky else None,
|
||||||
@@ -207,22 +208,22 @@ def get_position():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@gps_bp.route('/satellites')
|
@gps_bp.route('/satellites')
|
||||||
def get_satellites():
|
def get_satellites():
|
||||||
"""Get current satellite sky view data."""
|
"""Get current satellite sky view data."""
|
||||||
reader = get_gps_reader()
|
reader = get_gps_reader()
|
||||||
|
|
||||||
if not reader or not reader.is_running:
|
if not reader or not reader.is_running:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'waiting',
|
'status': 'waiting',
|
||||||
'running': False,
|
'running': False,
|
||||||
'message': 'GPS client not running'
|
'message': 'GPS client not running'
|
||||||
})
|
})
|
||||||
|
|
||||||
sky = reader.sky
|
sky = reader.sky
|
||||||
if sky:
|
if sky:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'ok',
|
'status': 'ok',
|
||||||
'sky': sky.to_dict()
|
'sky': sky.to_dict()
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
@@ -232,19 +233,19 @@ def get_satellites():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@gps_bp.route('/stream')
|
@gps_bp.route('/stream')
|
||||||
def stream_gps():
|
def stream_gps():
|
||||||
"""SSE stream of GPS position and sky updates."""
|
"""SSE stream of GPS position and sky updates."""
|
||||||
response = Response(
|
response = Response(
|
||||||
sse_stream_fanout(
|
sse_stream_fanout(
|
||||||
source_queue=_gps_queue,
|
source_queue=_gps_queue,
|
||||||
channel_key='gps',
|
channel_key='gps',
|
||||||
timeout=1.0,
|
timeout=1.0,
|
||||||
keepalive_interval=30.0,
|
keepalive_interval=30.0,
|
||||||
),
|
),
|
||||||
mimetype='text/event-stream',
|
mimetype='text/event-stream',
|
||||||
)
|
)
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
response.headers['Connection'] = 'keep-alive'
|
response.headers['Connection'] = 'keep-alive'
|
||||||
return response
|
return response
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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 flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
|
from utils.responses import api_success, api_error
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.sse import sse_stream_fanout
|
from utils.sse import sse_stream_fanout
|
||||||
from utils.meshtastic import (
|
from utils.meshtastic import (
|
||||||
get_meshtastic_client,
|
get_meshtastic_client,
|
||||||
start_meshtastic,
|
start_meshtastic,
|
||||||
@@ -453,8 +454,8 @@ def get_messages():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@meshtastic_bp.route('/stream')
|
@meshtastic_bp.route('/stream')
|
||||||
def stream_messages():
|
def stream_messages():
|
||||||
"""
|
"""
|
||||||
SSE stream of Meshtastic messages.
|
SSE stream of Meshtastic messages.
|
||||||
|
|
||||||
@@ -469,18 +470,18 @@ def stream_messages():
|
|||||||
Returns:
|
Returns:
|
||||||
SSE stream (text/event-stream)
|
SSE stream (text/event-stream)
|
||||||
"""
|
"""
|
||||||
response = Response(
|
response = Response(
|
||||||
sse_stream_fanout(
|
sse_stream_fanout(
|
||||||
source_queue=_mesh_queue,
|
source_queue=_mesh_queue,
|
||||||
channel_key='meshtastic',
|
channel_key='meshtastic',
|
||||||
timeout=1.0,
|
timeout=1.0,
|
||||||
keepalive_interval=30.0,
|
keepalive_interval=30.0,
|
||||||
),
|
),
|
||||||
mimetype='text/event-stream',
|
mimetype='text/event-stream',
|
||||||
)
|
)
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
response.headers['Connection'] = 'keep-alive'
|
response.headers['Connection'] = 'keep-alive'
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@@ -1050,11 +1051,11 @@ def request_store_forward():
|
|||||||
def mesh_topology():
|
def mesh_topology():
|
||||||
"""Return mesh network topology graph."""
|
"""Return mesh network topology graph."""
|
||||||
if not is_meshtastic_available():
|
if not is_meshtastic_available():
|
||||||
return jsonify({'status': 'error', 'message': 'Meshtastic SDK not installed'}), 400
|
return api_error('Meshtastic SDK not installed', 400)
|
||||||
|
|
||||||
client = get_meshtastic_client()
|
client = get_meshtastic_client()
|
||||||
if not client or not client.is_running:
|
if not client or not client.is_running:
|
||||||
return jsonify({'status': 'error', 'message': 'Not connected'}), 400
|
return api_error('Not connected', 400)
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ from typing import Any
|
|||||||
|
|
||||||
from flask import Blueprint, Flask, Response, jsonify, request
|
from flask import Blueprint, Flask, Response, jsonify, request
|
||||||
|
|
||||||
|
from utils.responses import api_success, api_error
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from flask_sock import Sock
|
from flask_sock import Sock
|
||||||
WEBSOCKET_AVAILABLE = True
|
WEBSOCKET_AVAILABLE = True
|
||||||
@@ -170,7 +172,7 @@ def meteor_events_export():
|
|||||||
"""Export events as CSV or JSON."""
|
"""Export events as CSV or JSON."""
|
||||||
detector = _detector
|
detector = _detector
|
||||||
if not detector:
|
if not detector:
|
||||||
return jsonify({'error': 'No active session'}), 400
|
return api_error('No active session', 400)
|
||||||
|
|
||||||
fmt = request.args.get('format', 'json').lower()
|
fmt = request.args.get('format', 'json').lower()
|
||||||
if fmt == 'csv':
|
if fmt == 'csv':
|
||||||
|
|||||||
+12
-11
@@ -13,6 +13,7 @@ from typing import Any
|
|||||||
|
|
||||||
from flask import Blueprint, Response, jsonify, request
|
from flask import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
|
from utils.responses import api_success, api_error
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
from utils.logging import sensor_logger as logger
|
from utils.logging import sensor_logger as logger
|
||||||
@@ -252,7 +253,7 @@ def start_morse() -> Response:
|
|||||||
try:
|
try:
|
||||||
detect_mode = _validate_detect_mode(data.get('detect_mode', 'goertzel'))
|
detect_mode = _validate_detect_mode(data.get('detect_mode', 'goertzel'))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return api_error(str(e), 400)
|
||||||
|
|
||||||
freq_max = 1766.0 if detect_mode == 'envelope' else 30.0
|
freq_max = 1766.0 if detect_mode == 'envelope' else 30.0
|
||||||
try:
|
try:
|
||||||
@@ -261,7 +262,7 @@ def start_morse() -> Response:
|
|||||||
ppm = validate_ppm(data.get('ppm', '0'))
|
ppm = validate_ppm(data.get('ppm', '0'))
|
||||||
device = validate_device_index(data.get('device', '0'))
|
device = validate_device_index(data.get('device', '0'))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return api_error(str(e), 400)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
tone_freq = _validate_tone_freq(data.get('tone_freq', '700'))
|
tone_freq = _validate_tone_freq(data.get('tone_freq', '700'))
|
||||||
@@ -277,7 +278,7 @@ def start_morse() -> Response:
|
|||||||
tone_lock = _bool_value(data.get('tone_lock', False), False)
|
tone_lock = _bool_value(data.get('tone_lock', False), False)
|
||||||
wpm_lock = _bool_value(data.get('wpm_lock', False), False)
|
wpm_lock = _bool_value(data.get('wpm_lock', False), False)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return api_error(str(e), 400)
|
||||||
|
|
||||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||||
|
|
||||||
@@ -335,7 +336,7 @@ def start_morse() -> Response:
|
|||||||
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
|
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
|
||||||
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
|
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return api_error(str(e), 400)
|
||||||
network_sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
|
network_sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
|
||||||
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
|
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
|
||||||
|
|
||||||
@@ -696,7 +697,7 @@ def start_morse() -> Response:
|
|||||||
morse_last_error = msg
|
morse_last_error = msg
|
||||||
_set_state(MORSE_ERROR, msg)
|
_set_state(MORSE_ERROR, msg)
|
||||||
_set_state(MORSE_IDLE, 'Idle')
|
_set_state(MORSE_IDLE, 'Idle')
|
||||||
return jsonify({'status': 'error', 'message': msg}), 500
|
return api_error(msg, 500)
|
||||||
|
|
||||||
with app_module.morse_lock:
|
with app_module.morse_lock:
|
||||||
app_module.morse_process = active_rtl_process
|
app_module.morse_process = active_rtl_process
|
||||||
@@ -740,7 +741,7 @@ def start_morse() -> Response:
|
|||||||
morse_last_error = f'Tool not found: {e.filename}'
|
morse_last_error = f'Tool not found: {e.filename}'
|
||||||
_set_state(MORSE_ERROR, morse_last_error)
|
_set_state(MORSE_ERROR, morse_last_error)
|
||||||
_set_state(MORSE_IDLE, 'Idle')
|
_set_state(MORSE_IDLE, 'Idle')
|
||||||
return jsonify({'status': 'error', 'message': morse_last_error}), 400
|
return api_error(morse_last_error, 400)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_cleanup_attempt(
|
_cleanup_attempt(
|
||||||
@@ -758,7 +759,7 @@ def start_morse() -> Response:
|
|||||||
morse_last_error = str(e)
|
morse_last_error = str(e)
|
||||||
_set_state(MORSE_ERROR, morse_last_error)
|
_set_state(MORSE_ERROR, morse_last_error)
|
||||||
_set_state(MORSE_IDLE, 'Idle')
|
_set_state(MORSE_IDLE, 'Idle')
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
return api_error(str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
@morse_bp.route('/morse/stop', methods=['POST'])
|
@morse_bp.route('/morse/stop', methods=['POST'])
|
||||||
@@ -908,11 +909,11 @@ def calibrate_morse() -> Response:
|
|||||||
def decode_morse_file() -> Response:
|
def decode_morse_file() -> Response:
|
||||||
"""Decode Morse from an uploaded WAV file."""
|
"""Decode Morse from an uploaded WAV file."""
|
||||||
if 'audio' not in request.files:
|
if 'audio' not in request.files:
|
||||||
return jsonify({'status': 'error', 'message': 'No audio file provided'}), 400
|
return api_error('No audio file provided', 400)
|
||||||
|
|
||||||
audio_file = request.files['audio']
|
audio_file = request.files['audio']
|
||||||
if not audio_file.filename:
|
if not audio_file.filename:
|
||||||
return jsonify({'status': 'error', 'message': 'No file selected'}), 400
|
return api_error('No file selected', 400)
|
||||||
|
|
||||||
# Parse optional tuning/decoder parameters from form fields.
|
# Parse optional tuning/decoder parameters from form fields.
|
||||||
form = request.form or {}
|
form = request.form or {}
|
||||||
@@ -930,7 +931,7 @@ def decode_morse_file() -> Response:
|
|||||||
tone_lock = _bool_value(form.get('tone_lock', 'false'), False)
|
tone_lock = _bool_value(form.get('tone_lock', 'false'), False)
|
||||||
wpm_lock = _bool_value(form.get('wpm_lock', 'false'), False)
|
wpm_lock = _bool_value(form.get('wpm_lock', 'false'), False)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return api_error(str(e), 400)
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp:
|
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp:
|
||||||
audio_file.save(tmp.name)
|
audio_file.save(tmp.name)
|
||||||
@@ -968,7 +969,7 @@ def decode_morse_file() -> Response:
|
|||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'Morse decode-file error: {e}')
|
logger.error(f'Morse decode-file error: {e}')
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
return api_error(str(e), 500)
|
||||||
finally:
|
finally:
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
tmp_path.unlink(missing_ok=True)
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
|||||||
+18
-32
@@ -4,19 +4,20 @@ Offline mode routes - Asset management and settings for offline operation.
|
|||||||
|
|
||||||
from flask import Blueprint, jsonify, request
|
from flask import Blueprint, jsonify, request
|
||||||
from utils.database import get_setting, set_setting
|
from utils.database import get_setting, set_setting
|
||||||
|
from utils.responses import api_success, api_error
|
||||||
import os
|
import os
|
||||||
|
|
||||||
offline_bp = Blueprint('offline', __name__, url_prefix='/offline')
|
offline_bp = Blueprint('offline', __name__, url_prefix='/offline')
|
||||||
|
|
||||||
# Default offline settings
|
# Default offline settings
|
||||||
OFFLINE_DEFAULTS = {
|
OFFLINE_DEFAULTS = {
|
||||||
'offline.enabled': False,
|
'offline.enabled': False,
|
||||||
# Default to bundled assets/fonts to avoid third-party CDN privacy blocks.
|
# Default to bundled assets/fonts to avoid third-party CDN privacy blocks.
|
||||||
'offline.assets_source': 'local',
|
'offline.assets_source': 'local',
|
||||||
'offline.fonts_source': 'local',
|
'offline.fonts_source': 'local',
|
||||||
'offline.tile_provider': 'cartodb_dark_cyan',
|
'offline.tile_provider': 'cartodb_dark_cyan',
|
||||||
'offline.tile_server_url': ''
|
'offline.tile_server_url': ''
|
||||||
}
|
}
|
||||||
|
|
||||||
# Asset paths to check
|
# Asset paths to check
|
||||||
ASSET_PATHS = {
|
ASSET_PATHS = {
|
||||||
@@ -64,10 +65,7 @@ def get_offline_settings():
|
|||||||
def get_settings():
|
def get_settings():
|
||||||
"""Get current offline settings."""
|
"""Get current offline settings."""
|
||||||
settings = get_offline_settings()
|
settings = get_offline_settings()
|
||||||
return jsonify({
|
return api_success(data={'settings': settings})
|
||||||
'status': 'success',
|
|
||||||
'settings': settings
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@offline_bp.route('/settings', methods=['POST'])
|
@offline_bp.route('/settings', methods=['POST'])
|
||||||
@@ -75,14 +73,14 @@ def save_setting():
|
|||||||
"""Save an offline setting."""
|
"""Save an offline setting."""
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
if not data or 'key' not in data or 'value' not in data:
|
if not data or 'key' not in data or 'value' not in data:
|
||||||
return jsonify({'status': 'error', 'message': 'Missing key or value'}), 400
|
return api_error('Missing key or value', 400)
|
||||||
|
|
||||||
key = data['key']
|
key = data['key']
|
||||||
value = data['value']
|
value = data['value']
|
||||||
|
|
||||||
# Validate key is an allowed setting
|
# Validate key is an allowed setting
|
||||||
if key not in OFFLINE_DEFAULTS:
|
if key not in OFFLINE_DEFAULTS:
|
||||||
return jsonify({'status': 'error', 'message': f'Unknown setting: {key}'}), 400
|
return api_error(f'Unknown setting: {key}', 400)
|
||||||
|
|
||||||
# Validate value type matches default
|
# Validate value type matches default
|
||||||
default_type = type(OFFLINE_DEFAULTS[key])
|
default_type = type(OFFLINE_DEFAULTS[key])
|
||||||
@@ -94,18 +92,11 @@ def save_setting():
|
|||||||
else:
|
else:
|
||||||
value = default_type(value)
|
value = default_type(value)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return jsonify({
|
return api_error(f'Invalid value type for {key}', 400)
|
||||||
'status': 'error',
|
|
||||||
'message': f'Invalid value type for {key}'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
set_setting(key, value)
|
set_setting(key, value)
|
||||||
|
|
||||||
return jsonify({
|
return api_success(data={'key': key, 'value': value})
|
||||||
'status': 'success',
|
|
||||||
'key': key,
|
|
||||||
'value': value
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@offline_bp.route('/status', methods=['GET'])
|
@offline_bp.route('/status', methods=['GET'])
|
||||||
@@ -134,8 +125,7 @@ def get_status():
|
|||||||
if not available:
|
if not available:
|
||||||
all_available = False
|
all_available = False
|
||||||
|
|
||||||
return jsonify({
|
return api_success(data={
|
||||||
'status': 'success',
|
|
||||||
'all_available': all_available,
|
'all_available': all_available,
|
||||||
'assets': results,
|
'assets': results,
|
||||||
'offline_enabled': get_setting('offline.enabled', False)
|
'offline_enabled': get_setting('offline.enabled', False)
|
||||||
@@ -147,11 +137,11 @@ def check_asset():
|
|||||||
"""Check if a specific asset file exists."""
|
"""Check if a specific asset file exists."""
|
||||||
path = request.args.get('path', '')
|
path = request.args.get('path', '')
|
||||||
if not path:
|
if not path:
|
||||||
return jsonify({'status': 'error', 'message': 'Missing path parameter'}), 400
|
return api_error('Missing path parameter', 400)
|
||||||
|
|
||||||
# Security: only allow checking within static/vendor
|
# Security: only allow checking within static/vendor
|
||||||
if not path.startswith('/static/vendor/'):
|
if not path.startswith('/static/vendor/'):
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid path'}), 400
|
return api_error('Invalid path', 400)
|
||||||
|
|
||||||
# Remove leading slash and construct full path
|
# Remove leading slash and construct full path
|
||||||
relative_path = path.lstrip('/')
|
relative_path = path.lstrip('/')
|
||||||
@@ -160,8 +150,4 @@ def check_asset():
|
|||||||
|
|
||||||
exists = os.path.exists(full_path)
|
exists = os.path.exists(full_path)
|
||||||
|
|
||||||
return jsonify({
|
return api_success(data={'path': path, 'exists': exists})
|
||||||
'status': 'success',
|
|
||||||
'path': path,
|
|
||||||
'exists': exists
|
|
||||||
})
|
|
||||||
|
|||||||
+11
-14
@@ -19,6 +19,7 @@ from flask import Blueprint, Response, jsonify, request
|
|||||||
|
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
|
from utils.responses import api_success, api_error
|
||||||
from utils.logging import sensor_logger as logger
|
from utils.logging import sensor_logger as logger
|
||||||
from utils.ook import ook_parser_thread
|
from utils.ook import ook_parser_thread
|
||||||
from utils.process import register_process, safe_terminate, unregister_process
|
from utils.process import register_process, safe_terminate, unregister_process
|
||||||
@@ -69,7 +70,7 @@ def start_ook() -> Response:
|
|||||||
if app_module.ook_process.poll() is not None:
|
if app_module.ook_process.poll() is not None:
|
||||||
cleanup_ook(emit_status=False)
|
cleanup_ook(emit_status=False)
|
||||||
else:
|
else:
|
||||||
return jsonify({'status': 'error', 'message': 'OOK decoder already running'}), 409
|
return api_error('OOK decoder already running', 409)
|
||||||
|
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
|
|
||||||
@@ -79,12 +80,12 @@ def start_ook() -> Response:
|
|||||||
ppm = validate_ppm(data.get('ppm', '0'))
|
ppm = validate_ppm(data.get('ppm', '0'))
|
||||||
device = validate_device_index(data.get('device', '0'))
|
device = validate_device_index(data.get('device', '0'))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return api_error(str(e), 400)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
encoding = _validate_encoding(data.get('encoding', 'pwm'))
|
encoding = _validate_encoding(data.get('encoding', 'pwm'))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return api_error(str(e), 400)
|
||||||
|
|
||||||
# OOK flex decoder timing parameters (server-side range validation)
|
# OOK flex decoder timing parameters (server-side range validation)
|
||||||
try:
|
try:
|
||||||
@@ -95,11 +96,11 @@ def start_ook() -> Response:
|
|||||||
tolerance = validate_positive_int(data.get('tolerance', 150), 'tolerance', max_val=50000)
|
tolerance = validate_positive_int(data.get('tolerance', 150), 'tolerance', max_val=50000)
|
||||||
min_bits = validate_positive_int(data.get('min_bits', 8), 'min_bits', max_val=4096)
|
min_bits = validate_positive_int(data.get('min_bits', 8), 'min_bits', max_val=4096)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': f'Invalid timing parameter: {e}'}), 400
|
return api_error(f'Invalid timing parameter: {e}', 400)
|
||||||
if min_bits < 1:
|
if min_bits < 1:
|
||||||
return jsonify({'status': 'error', 'message': 'min_bits must be >= 1'}), 400
|
return api_error('min_bits must be >= 1', 400)
|
||||||
if short_pulse < 1 or long_pulse < 1:
|
if short_pulse < 1 or long_pulse < 1:
|
||||||
return jsonify({'status': 'error', 'message': 'Pulse widths must be >= 1'}), 400
|
return api_error('Pulse widths must be >= 1', 400)
|
||||||
deduplicate = bool(data.get('deduplicate', False))
|
deduplicate = bool(data.get('deduplicate', False))
|
||||||
|
|
||||||
# Parse SDR type early — needed for device claim
|
# Parse SDR type early — needed for device claim
|
||||||
@@ -117,11 +118,7 @@ def start_ook() -> Response:
|
|||||||
device_int = int(device)
|
device_int = int(device)
|
||||||
error = app_module.claim_sdr_device(device_int, 'ook', sdr_type_str)
|
error = app_module.claim_sdr_device(device_int, 'ook', sdr_type_str)
|
||||||
if error:
|
if error:
|
||||||
return jsonify({
|
return api_error(error, 409, error_type='DEVICE_BUSY')
|
||||||
'status': 'error',
|
|
||||||
'error_type': 'DEVICE_BUSY',
|
|
||||||
'message': error,
|
|
||||||
}), 409
|
|
||||||
ook_active_device = device_int
|
ook_active_device = device_int
|
||||||
ook_active_sdr_type = sdr_type_str
|
ook_active_sdr_type = sdr_type_str
|
||||||
|
|
||||||
@@ -136,7 +133,7 @@ def start_ook() -> Response:
|
|||||||
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
|
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
|
||||||
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
|
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return api_error(str(e), 400)
|
||||||
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
|
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
|
||||||
logger.info(f'Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}')
|
logger.info(f'Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}')
|
||||||
else:
|
else:
|
||||||
@@ -237,7 +234,7 @@ def start_ook() -> Response:
|
|||||||
app_module.release_sdr_device(ook_active_device, ook_active_sdr_type or 'rtlsdr')
|
app_module.release_sdr_device(ook_active_device, ook_active_sdr_type or 'rtlsdr')
|
||||||
ook_active_device = None
|
ook_active_device = None
|
||||||
ook_active_sdr_type = None
|
ook_active_sdr_type = None
|
||||||
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'}), 400
|
return api_error(f'Tool not found: {e.filename}', 400)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
try:
|
try:
|
||||||
@@ -251,7 +248,7 @@ def start_ook() -> Response:
|
|||||||
app_module.release_sdr_device(ook_active_device, ook_active_sdr_type or 'rtlsdr')
|
app_module.release_sdr_device(ook_active_device, ook_active_sdr_type or 'rtlsdr')
|
||||||
ook_active_device = None
|
ook_active_device = None
|
||||||
ook_active_sdr_type = None
|
ook_active_sdr_type = None
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
return api_error(str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
def _close_pipe(pipe_obj) -> None:
|
def _close_pipe(pipe_obj) -> None:
|
||||||
|
|||||||
+13
-16
@@ -18,6 +18,7 @@ from typing import Any, Generator
|
|||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
|
from utils.responses import api_success, api_error
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.logging import pager_logger as logger
|
from utils.logging import pager_logger as logger
|
||||||
from utils.validation import (
|
from utils.validation import (
|
||||||
@@ -275,7 +276,7 @@ def start_decoding() -> Response:
|
|||||||
|
|
||||||
with app_module.process_lock:
|
with app_module.process_lock:
|
||||||
if app_module.current_process:
|
if app_module.current_process:
|
||||||
return jsonify({'status': 'error', 'message': 'Already running'}), 409
|
return api_error('Already running', 409)
|
||||||
|
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
|
|
||||||
@@ -286,7 +287,7 @@ def start_decoding() -> Response:
|
|||||||
ppm = validate_ppm(data.get('ppm', '0'))
|
ppm = validate_ppm(data.get('ppm', '0'))
|
||||||
device = validate_device_index(data.get('device', '0'))
|
device = validate_device_index(data.get('device', '0'))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return api_error(str(e), 400)
|
||||||
|
|
||||||
squelch = data.get('squelch', '0')
|
squelch = data.get('squelch', '0')
|
||||||
try:
|
try:
|
||||||
@@ -294,7 +295,7 @@ def start_decoding() -> Response:
|
|||||||
if not 0 <= squelch <= 1000:
|
if not 0 <= squelch <= 1000:
|
||||||
raise ValueError("Squelch must be between 0 and 1000")
|
raise ValueError("Squelch must be between 0 and 1000")
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid squelch value'}), 400
|
return api_error('Invalid squelch value', 400)
|
||||||
|
|
||||||
# Check for rtl_tcp (remote SDR) connection
|
# Check for rtl_tcp (remote SDR) connection
|
||||||
rtl_tcp_host = data.get('rtl_tcp_host')
|
rtl_tcp_host = data.get('rtl_tcp_host')
|
||||||
@@ -308,11 +309,7 @@ def start_decoding() -> Response:
|
|||||||
device_int = int(device)
|
device_int = int(device)
|
||||||
error = app_module.claim_sdr_device(device_int, 'pager', sdr_type_str)
|
error = app_module.claim_sdr_device(device_int, 'pager', sdr_type_str)
|
||||||
if error:
|
if error:
|
||||||
return jsonify({
|
return api_error(error, 409, error_type='DEVICE_BUSY')
|
||||||
'status': 'error',
|
|
||||||
'error_type': 'DEVICE_BUSY',
|
|
||||||
'message': error
|
|
||||||
}), 409
|
|
||||||
pager_active_device = device_int
|
pager_active_device = device_int
|
||||||
pager_active_sdr_type = sdr_type_str
|
pager_active_sdr_type = sdr_type_str
|
||||||
|
|
||||||
@@ -324,7 +321,7 @@ def start_decoding() -> Response:
|
|||||||
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
|
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
|
||||||
pager_active_device = None
|
pager_active_device = None
|
||||||
pager_active_sdr_type = None
|
pager_active_sdr_type = None
|
||||||
return jsonify({'status': 'error', 'message': 'Protocols must be a list'}), 400
|
return api_error('Protocols must be a list', 400)
|
||||||
protocols = [p for p in protocols if p in valid_protocols]
|
protocols = [p for p in protocols if p in valid_protocols]
|
||||||
if not protocols:
|
if not protocols:
|
||||||
protocols = valid_protocols
|
protocols = valid_protocols
|
||||||
@@ -360,7 +357,7 @@ def start_decoding() -> Response:
|
|||||||
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
|
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
|
||||||
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
|
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return api_error(str(e), 400)
|
||||||
|
|
||||||
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
|
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
|
||||||
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
|
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
|
||||||
@@ -385,7 +382,7 @@ def start_decoding() -> Response:
|
|||||||
|
|
||||||
multimon_path = get_tool_path('multimon-ng')
|
multimon_path = get_tool_path('multimon-ng')
|
||||||
if not multimon_path:
|
if not multimon_path:
|
||||||
return jsonify({'status': 'error', 'message': 'multimon-ng not found'}), 400
|
return api_error('multimon-ng not found', 400)
|
||||||
multimon_cmd = [multimon_path, '-t', 'raw'] + decoders + ['-f', 'alpha', '-']
|
multimon_cmd = [multimon_path, '-t', 'raw'] + decoders + ['-f', 'alpha', '-']
|
||||||
|
|
||||||
full_cmd = ' '.join(rtl_cmd) + ' | ' + ' '.join(multimon_cmd)
|
full_cmd = ' '.join(rtl_cmd) + ' | ' + ' '.join(multimon_cmd)
|
||||||
@@ -466,7 +463,7 @@ def start_decoding() -> Response:
|
|||||||
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
|
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
|
||||||
pager_active_device = None
|
pager_active_device = None
|
||||||
pager_active_sdr_type = None
|
pager_active_sdr_type = None
|
||||||
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'})
|
return api_error(f'Tool not found: {e.filename}')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Kill orphaned rtl_fm process if it was started
|
# Kill orphaned rtl_fm process if it was started
|
||||||
try:
|
try:
|
||||||
@@ -482,7 +479,7 @@ def start_decoding() -> Response:
|
|||||||
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
|
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
|
||||||
pager_active_device = None
|
pager_active_device = None
|
||||||
pager_active_sdr_type = None
|
pager_active_sdr_type = None
|
||||||
return jsonify({'status': 'error', 'message': str(e)})
|
return api_error(str(e))
|
||||||
|
|
||||||
|
|
||||||
@pager_bp.route('/stop', methods=['POST'])
|
@pager_bp.route('/stop', methods=['POST'])
|
||||||
@@ -562,16 +559,16 @@ def toggle_logging() -> Response:
|
|||||||
is_in_logs = str(requested_path).startswith(str(logs_dir))
|
is_in_logs = str(requested_path).startswith(str(logs_dir))
|
||||||
|
|
||||||
if not (is_in_cwd or is_in_logs):
|
if not (is_in_cwd or is_in_logs):
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid log file path'}), 400
|
return api_error('Invalid log file path', 400)
|
||||||
|
|
||||||
# Ensure it's not a directory
|
# Ensure it's not a directory
|
||||||
if requested_path.is_dir():
|
if requested_path.is_dir():
|
||||||
return jsonify({'status': 'error', 'message': 'Log file path must be a file, not a directory'}), 400
|
return api_error('Log file path must be a file, not a directory', 400)
|
||||||
|
|
||||||
app_module.log_file_path = str(requested_path)
|
app_module.log_file_path = str(requested_path)
|
||||||
except (ValueError, OSError) as e:
|
except (ValueError, OSError) as e:
|
||||||
logger.warning(f"Invalid log file path: {e}")
|
logger.warning(f"Invalid log file path: {e}")
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid log file path'}), 400
|
return api_error('Invalid log file path', 400)
|
||||||
|
|
||||||
return jsonify({'logging': app_module.logging_enabled, 'log_file': app_module.log_file_path})
|
return jsonify({'logging': app_module.logging_enabled, 'log_file': app_module.log_file_path})
|
||||||
|
|
||||||
|
|||||||
+15
-27
@@ -20,6 +20,7 @@ from typing import Any
|
|||||||
|
|
||||||
from flask import Blueprint, Response, jsonify, request
|
from flask import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
|
from utils.responses import api_success, api_error
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
MAX_RADIOSONDE_AGE_SECONDS,
|
MAX_RADIOSONDE_AGE_SECONDS,
|
||||||
@@ -479,10 +480,7 @@ def start_radiosonde():
|
|||||||
|
|
||||||
with app_module.radiosonde_lock:
|
with app_module.radiosonde_lock:
|
||||||
if radiosonde_running:
|
if radiosonde_running:
|
||||||
return jsonify({
|
return api_error('Radiosonde tracking already active', 409)
|
||||||
'status': 'already_running',
|
|
||||||
'message': 'Radiosonde tracking already active',
|
|
||||||
}), 409
|
|
||||||
|
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
|
|
||||||
@@ -491,7 +489,7 @@ def start_radiosonde():
|
|||||||
gain = float(validate_gain(data.get('gain', '40')))
|
gain = float(validate_gain(data.get('gain', '40')))
|
||||||
device = validate_device_index(data.get('device', '0'))
|
device = validate_device_index(data.get('device', '0'))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return api_error(str(e), 400)
|
||||||
|
|
||||||
freq_min = data.get('freq_min', 400.0)
|
freq_min = data.get('freq_min', 400.0)
|
||||||
freq_max = data.get('freq_max', 406.0)
|
freq_max = data.get('freq_max', 406.0)
|
||||||
@@ -503,7 +501,7 @@ def start_radiosonde():
|
|||||||
if freq_min >= freq_max:
|
if freq_min >= freq_max:
|
||||||
raise ValueError("Min frequency must be less than max")
|
raise ValueError("Min frequency must be less than max")
|
||||||
except (ValueError, TypeError) as e:
|
except (ValueError, TypeError) as e:
|
||||||
return jsonify({'status': 'error', 'message': f'Invalid frequency range: {e}'}), 400
|
return api_error(f'Invalid frequency range: {e}', 400)
|
||||||
|
|
||||||
bias_t = data.get('bias_t', False)
|
bias_t = data.get('bias_t', False)
|
||||||
ppm = int(data.get('ppm', 0))
|
ppm = int(data.get('ppm', 0))
|
||||||
@@ -525,10 +523,7 @@ def start_radiosonde():
|
|||||||
# Find auto_rx
|
# Find auto_rx
|
||||||
auto_rx_path = find_auto_rx()
|
auto_rx_path = find_auto_rx()
|
||||||
if not auto_rx_path:
|
if not auto_rx_path:
|
||||||
return jsonify({
|
return api_error('radiosonde_auto_rx not found. Install from https://github.com/projecthorus/radiosonde_auto_rx', 400)
|
||||||
'status': 'error',
|
|
||||||
'message': 'radiosonde_auto_rx not found. Install from https://github.com/projecthorus/radiosonde_auto_rx',
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
# Get SDR type
|
# Get SDR type
|
||||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||||
@@ -552,11 +547,7 @@ def start_radiosonde():
|
|||||||
device_int = int(device)
|
device_int = int(device)
|
||||||
error = app_module.claim_sdr_device(device_int, 'radiosonde', sdr_type_str)
|
error = app_module.claim_sdr_device(device_int, 'radiosonde', sdr_type_str)
|
||||||
if error:
|
if error:
|
||||||
return jsonify({
|
return api_error(error, 409, error_type='DEVICE_BUSY')
|
||||||
'status': 'error',
|
|
||||||
'error_type': 'DEVICE_BUSY',
|
|
||||||
'message': error,
|
|
||||||
}), 409
|
|
||||||
|
|
||||||
# Generate config
|
# Generate config
|
||||||
try:
|
try:
|
||||||
@@ -574,7 +565,7 @@ def start_radiosonde():
|
|||||||
except (OSError, RuntimeError) as e:
|
except (OSError, RuntimeError) as e:
|
||||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||||
logger.error(f"Failed to generate radiosonde config: {e}")
|
logger.error(f"Failed to generate radiosonde config: {e}")
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
return api_error(str(e), 500)
|
||||||
|
|
||||||
# Build command - auto_rx -c expects the path to station.cfg
|
# Build command - auto_rx -c expects the path to station.cfg
|
||||||
cfg_abs = os.path.abspath(cfg_path)
|
cfg_abs = os.path.abspath(cfg_path)
|
||||||
@@ -598,13 +589,11 @@ def start_radiosonde():
|
|||||||
dep_error = dep_check.stderr.decode('utf-8', errors='ignore').strip()
|
dep_error = dep_check.stderr.decode('utf-8', errors='ignore').strip()
|
||||||
logger.error(f"radiosonde_auto_rx dependency check failed:\n{dep_error}")
|
logger.error(f"radiosonde_auto_rx dependency check failed:\n{dep_error}")
|
||||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||||
return jsonify({
|
return api_error(
|
||||||
'status': 'error',
|
'radiosonde_auto_rx dependencies not satisfied. '
|
||||||
'message': (
|
f'Re-run setup.sh to install. Error: {dep_error[:500]}',
|
||||||
'radiosonde_auto_rx dependencies not satisfied. '
|
500,
|
||||||
f'Re-run setup.sh to install. Error: {dep_error[:500]}'
|
)
|
||||||
),
|
|
||||||
}), 500
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info(f"Starting radiosonde_auto_rx: {' '.join(cmd)}")
|
logger.info(f"Starting radiosonde_auto_rx: {' '.join(cmd)}")
|
||||||
@@ -646,7 +635,7 @@ def start_radiosonde():
|
|||||||
)
|
)
|
||||||
if stderr_output:
|
if stderr_output:
|
||||||
error_msg += f' Error: {stderr_output[:500]}'
|
error_msg += f' Error: {stderr_output[:500]}'
|
||||||
return jsonify({'status': 'error', 'message': error_msg}), 500
|
return api_error(error_msg, 500)
|
||||||
|
|
||||||
radiosonde_running = True
|
radiosonde_running = True
|
||||||
radiosonde_active_device = device_int
|
radiosonde_active_device = device_int
|
||||||
@@ -672,7 +661,7 @@ def start_radiosonde():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||||
logger.error(f"Failed to start radiosonde_auto_rx: {e}")
|
logger.error(f"Failed to start radiosonde_auto_rx: {e}")
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
return api_error(str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
@radiosonde_bp.route('/stop', methods=['POST'])
|
@radiosonde_bp.route('/stop', methods=['POST'])
|
||||||
@@ -741,8 +730,7 @@ def stream_radiosonde():
|
|||||||
def get_balloons():
|
def get_balloons():
|
||||||
"""Get current balloon data."""
|
"""Get current balloon data."""
|
||||||
with _balloons_lock:
|
with _balloons_lock:
|
||||||
return jsonify({
|
return api_success(data={
|
||||||
'status': 'success',
|
|
||||||
'count': len(radiosonde_balloons),
|
'count': len(radiosonde_balloons),
|
||||||
'balloons': dict(radiosonde_balloons),
|
'balloons': dict(radiosonde_balloons),
|
||||||
})
|
})
|
||||||
|
|||||||
+32
-39
@@ -8,6 +8,7 @@ from pathlib import Path
|
|||||||
from flask import Blueprint, jsonify, request, send_file
|
from flask import Blueprint, jsonify, request, send_file
|
||||||
|
|
||||||
from utils.recording import get_recording_manager, RECORDING_ROOT
|
from utils.recording import get_recording_manager, RECORDING_ROOT
|
||||||
|
from utils.responses import api_success, api_error
|
||||||
|
|
||||||
recordings_bp = Blueprint('recordings', __name__, url_prefix='/recordings')
|
recordings_bp = Blueprint('recordings', __name__, url_prefix='/recordings')
|
||||||
|
|
||||||
@@ -17,7 +18,7 @@ def start_recording():
|
|||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
mode = (data.get('mode') or '').strip()
|
mode = (data.get('mode') or '').strip()
|
||||||
if not mode:
|
if not mode:
|
||||||
return jsonify({'status': 'error', 'message': 'mode is required'}), 400
|
return api_error('mode is required', 400)
|
||||||
|
|
||||||
label = data.get('label')
|
label = data.get('label')
|
||||||
metadata = data.get('metadata') if isinstance(data.get('metadata'), dict) else {}
|
metadata = data.get('metadata') if isinstance(data.get('metadata'), dict) else {}
|
||||||
@@ -25,16 +26,13 @@ def start_recording():
|
|||||||
manager = get_recording_manager()
|
manager = get_recording_manager()
|
||||||
session = manager.start_recording(mode=mode, label=label, metadata=metadata)
|
session = manager.start_recording(mode=mode, label=label, metadata=metadata)
|
||||||
|
|
||||||
return jsonify({
|
return api_success(data={'session': {
|
||||||
'status': 'success',
|
'id': session.id,
|
||||||
'session': {
|
'mode': session.mode,
|
||||||
'id': session.id,
|
'label': session.label,
|
||||||
'mode': session.mode,
|
'started_at': session.started_at.isoformat(),
|
||||||
'label': session.label,
|
'file_path': str(session.file_path),
|
||||||
'started_at': session.started_at.isoformat(),
|
}})
|
||||||
'file_path': str(session.file_path),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@recordings_bp.route('/stop', methods=['POST'])
|
@recordings_bp.route('/stop', methods=['POST'])
|
||||||
@@ -46,29 +44,25 @@ def stop_recording():
|
|||||||
manager = get_recording_manager()
|
manager = get_recording_manager()
|
||||||
session = manager.stop_recording(mode=mode, session_id=session_id)
|
session = manager.stop_recording(mode=mode, session_id=session_id)
|
||||||
if not session:
|
if not session:
|
||||||
return jsonify({'status': 'error', 'message': 'No active recording found'}), 404
|
return api_error('No active recording found', 404)
|
||||||
|
|
||||||
return jsonify({
|
return api_success(data={'session': {
|
||||||
'status': 'success',
|
'id': session.id,
|
||||||
'session': {
|
'mode': session.mode,
|
||||||
'id': session.id,
|
'label': session.label,
|
||||||
'mode': session.mode,
|
'started_at': session.started_at.isoformat(),
|
||||||
'label': session.label,
|
'stopped_at': session.stopped_at.isoformat() if session.stopped_at else None,
|
||||||
'started_at': session.started_at.isoformat(),
|
'event_count': session.event_count,
|
||||||
'stopped_at': session.stopped_at.isoformat() if session.stopped_at else None,
|
'size_bytes': session.size_bytes,
|
||||||
'event_count': session.event_count,
|
'file_path': str(session.file_path),
|
||||||
'size_bytes': session.size_bytes,
|
}})
|
||||||
'file_path': str(session.file_path),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@recordings_bp.route('', methods=['GET'])
|
@recordings_bp.route('', methods=['GET'])
|
||||||
def list_recordings():
|
def list_recordings():
|
||||||
manager = get_recording_manager()
|
manager = get_recording_manager()
|
||||||
limit = request.args.get('limit', default=50, type=int)
|
limit = request.args.get('limit', default=50, type=int)
|
||||||
return jsonify({
|
return api_success(data={
|
||||||
'status': 'success',
|
|
||||||
'recordings': manager.list_recordings(limit=limit),
|
'recordings': manager.list_recordings(limit=limit),
|
||||||
'active': manager.get_active(),
|
'active': manager.get_active(),
|
||||||
})
|
})
|
||||||
@@ -79,8 +73,8 @@ def get_recording(session_id: str):
|
|||||||
manager = get_recording_manager()
|
manager = get_recording_manager()
|
||||||
rec = manager.get_recording(session_id)
|
rec = manager.get_recording(session_id)
|
||||||
if not rec:
|
if not rec:
|
||||||
return jsonify({'status': 'error', 'message': 'Recording not found'}), 404
|
return api_error('Recording not found', 404)
|
||||||
return jsonify({'status': 'success', 'recording': rec})
|
return api_success(data={'recording': rec})
|
||||||
|
|
||||||
|
|
||||||
@recordings_bp.route('/<session_id>/download', methods=['GET'])
|
@recordings_bp.route('/<session_id>/download', methods=['GET'])
|
||||||
@@ -88,19 +82,19 @@ def download_recording(session_id: str):
|
|||||||
manager = get_recording_manager()
|
manager = get_recording_manager()
|
||||||
rec = manager.get_recording(session_id)
|
rec = manager.get_recording(session_id)
|
||||||
if not rec:
|
if not rec:
|
||||||
return jsonify({'status': 'error', 'message': 'Recording not found'}), 404
|
return api_error('Recording not found', 404)
|
||||||
|
|
||||||
file_path = Path(rec['file_path'])
|
file_path = Path(rec['file_path'])
|
||||||
try:
|
try:
|
||||||
resolved_root = RECORDING_ROOT.resolve()
|
resolved_root = RECORDING_ROOT.resolve()
|
||||||
resolved_file = file_path.resolve()
|
resolved_file = file_path.resolve()
|
||||||
if resolved_root not in resolved_file.parents:
|
if resolved_root not in resolved_file.parents:
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400
|
return api_error('Invalid recording path', 400)
|
||||||
except Exception:
|
except Exception:
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400
|
return api_error('Invalid recording path', 400)
|
||||||
|
|
||||||
if not file_path.exists():
|
if not file_path.exists():
|
||||||
return jsonify({'status': 'error', 'message': 'Recording file missing'}), 404
|
return api_error('Recording file missing', 404)
|
||||||
|
|
||||||
return send_file(
|
return send_file(
|
||||||
file_path,
|
file_path,
|
||||||
@@ -116,19 +110,19 @@ def get_recording_events(session_id: str):
|
|||||||
manager = get_recording_manager()
|
manager = get_recording_manager()
|
||||||
rec = manager.get_recording(session_id)
|
rec = manager.get_recording(session_id)
|
||||||
if not rec:
|
if not rec:
|
||||||
return jsonify({'status': 'error', 'message': 'Recording not found'}), 404
|
return api_error('Recording not found', 404)
|
||||||
|
|
||||||
file_path = Path(rec['file_path'])
|
file_path = Path(rec['file_path'])
|
||||||
try:
|
try:
|
||||||
resolved_root = RECORDING_ROOT.resolve()
|
resolved_root = RECORDING_ROOT.resolve()
|
||||||
resolved_file = file_path.resolve()
|
resolved_file = file_path.resolve()
|
||||||
if resolved_root not in resolved_file.parents:
|
if resolved_root not in resolved_file.parents:
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400
|
return api_error('Invalid recording path', 400)
|
||||||
except Exception:
|
except Exception:
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400
|
return api_error('Invalid recording path', 400)
|
||||||
|
|
||||||
if not file_path.exists():
|
if not file_path.exists():
|
||||||
return jsonify({'status': 'error', 'message': 'Recording file missing'}), 404
|
return api_error('Recording file missing', 404)
|
||||||
|
|
||||||
limit = max(1, min(5000, request.args.get('limit', default=500, type=int)))
|
limit = max(1, min(5000, request.args.get('limit', default=500, type=int)))
|
||||||
offset = max(0, request.args.get('offset', default=0, type=int))
|
offset = max(0, request.args.get('offset', default=0, type=int))
|
||||||
@@ -150,8 +144,7 @@ def get_recording_events(session_id: str):
|
|||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
return jsonify({
|
return api_success(data={
|
||||||
'status': 'success',
|
|
||||||
'recording': {
|
'recording': {
|
||||||
'id': rec['id'],
|
'id': rec['id'],
|
||||||
'mode': rec['mode'],
|
'mode': rec['mode'],
|
||||||
|
|||||||
+8
-14
@@ -12,6 +12,7 @@ from typing import Generator
|
|||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
|
from utils.responses import api_success, api_error
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.logging import sensor_logger as logger
|
from utils.logging import sensor_logger as logger
|
||||||
from utils.validation import (
|
from utils.validation import (
|
||||||
@@ -102,16 +103,13 @@ def start_rtlamr() -> Response:
|
|||||||
|
|
||||||
with app_module.rtlamr_lock:
|
with app_module.rtlamr_lock:
|
||||||
if app_module.rtlamr_process:
|
if app_module.rtlamr_process:
|
||||||
return jsonify({'status': 'error', 'message': 'RTLAMR already running'}), 409
|
return api_error('RTLAMR already running', 409)
|
||||||
|
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||||
|
|
||||||
if sdr_type_str != 'rtlsdr':
|
if sdr_type_str != 'rtlsdr':
|
||||||
return jsonify({
|
return api_error(f'{sdr_type_str.replace("_", " ").title()} is not yet supported for this mode. Please use an RTL-SDR device.', 400)
|
||||||
'status': 'error',
|
|
||||||
'message': f'{sdr_type_str.replace("_", " ").title()} is not yet supported for this mode. Please use an RTL-SDR device.'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
# Validate inputs
|
# Validate inputs
|
||||||
try:
|
try:
|
||||||
@@ -120,17 +118,13 @@ def start_rtlamr() -> Response:
|
|||||||
ppm = validate_ppm(data.get('ppm', '0'))
|
ppm = validate_ppm(data.get('ppm', '0'))
|
||||||
device = validate_device_index(data.get('device', '0'))
|
device = validate_device_index(data.get('device', '0'))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return api_error(str(e), 400)
|
||||||
|
|
||||||
# Check if device is available
|
# Check if device is available
|
||||||
device_int = int(device)
|
device_int = int(device)
|
||||||
error = app_module.claim_sdr_device(device_int, 'rtlamr', sdr_type_str)
|
error = app_module.claim_sdr_device(device_int, 'rtlamr', sdr_type_str)
|
||||||
if error:
|
if error:
|
||||||
return jsonify({
|
return api_error(error, 409, error_type='DEVICE_BUSY')
|
||||||
'status': 'error',
|
|
||||||
'error_type': 'DEVICE_BUSY',
|
|
||||||
'message': error
|
|
||||||
}), 409
|
|
||||||
|
|
||||||
rtlamr_active_device = device_int
|
rtlamr_active_device = device_int
|
||||||
rtlamr_active_sdr_type = sdr_type_str
|
rtlamr_active_sdr_type = sdr_type_str
|
||||||
@@ -181,7 +175,7 @@ def start_rtlamr() -> Response:
|
|||||||
if rtlamr_active_device is not None:
|
if rtlamr_active_device is not None:
|
||||||
app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
|
app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
|
||||||
rtlamr_active_device = None
|
rtlamr_active_device = None
|
||||||
return jsonify({'status': 'error', 'message': f'Failed to start rtl_tcp: {e}'}), 500
|
return api_error(f'Failed to start rtl_tcp: {e}', 500)
|
||||||
|
|
||||||
# Wait for rtl_tcp to start outside lock
|
# Wait for rtl_tcp to start outside lock
|
||||||
if rtl_tcp_just_started:
|
if rtl_tcp_just_started:
|
||||||
@@ -253,7 +247,7 @@ def start_rtlamr() -> Response:
|
|||||||
if rtlamr_active_device is not None:
|
if rtlamr_active_device is not None:
|
||||||
app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
|
app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
|
||||||
rtlamr_active_device = None
|
rtlamr_active_device = None
|
||||||
return jsonify({'status': 'error', 'message': 'rtlamr not found. Install from https://github.com/bemasher/rtlamr'})
|
return api_error('rtlamr not found. Install from https://github.com/bemasher/rtlamr')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# If rtlamr fails, clean up rtl_tcp and release device
|
# If rtlamr fails, clean up rtl_tcp and release device
|
||||||
with rtl_tcp_lock:
|
with rtl_tcp_lock:
|
||||||
@@ -264,7 +258,7 @@ def start_rtlamr() -> Response:
|
|||||||
if rtlamr_active_device is not None:
|
if rtlamr_active_device is not None:
|
||||||
app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
|
app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
|
||||||
rtlamr_active_device = None
|
rtlamr_active_device = None
|
||||||
return jsonify({'status': 'error', 'message': str(e)})
|
return api_error(str(e))
|
||||||
|
|
||||||
|
|
||||||
@rtlamr_bp.route('/stop_rtlamr', methods=['POST'])
|
@rtlamr_bp.route('/stop_rtlamr', methods=['POST'])
|
||||||
|
|||||||
+11
-10
@@ -13,6 +13,7 @@ import requests
|
|||||||
|
|
||||||
from flask import Blueprint, jsonify, request, render_template, Response
|
from flask import Blueprint, jsonify, request, render_template, Response
|
||||||
|
|
||||||
|
from utils.responses import api_success, api_error
|
||||||
from config import SHARED_OBSERVER_LOCATION_ENABLED
|
from config import SHARED_OBSERVER_LOCATION_ENABLED
|
||||||
|
|
||||||
from data.satellites import TLE_SATELLITES
|
from data.satellites import TLE_SATELLITES
|
||||||
@@ -206,7 +207,7 @@ def predict_passes():
|
|||||||
hours = validate_hours(data.get('hours', 24))
|
hours = validate_hours(data.get('hours', 24))
|
||||||
min_el = validate_elevation(data.get('minEl', 10))
|
min_el = validate_elevation(data.get('minEl', 10))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return api_error(str(e), 400)
|
||||||
|
|
||||||
norad_to_name = {
|
norad_to_name = {
|
||||||
25544: 'ISS',
|
25544: 'ISS',
|
||||||
@@ -345,7 +346,7 @@ def get_satellite_position():
|
|||||||
try:
|
try:
|
||||||
from skyfield.api import wgs84, EarthSatellite
|
from skyfield.api import wgs84, EarthSatellite
|
||||||
except ImportError:
|
except ImportError:
|
||||||
return jsonify({'status': 'error', 'message': 'skyfield not installed'}), 503
|
return api_error('skyfield not installed', 503)
|
||||||
|
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
|
|
||||||
@@ -354,7 +355,7 @@ def get_satellite_position():
|
|||||||
lat = validate_latitude(data.get('latitude', data.get('lat', 51.5074)))
|
lat = validate_latitude(data.get('latitude', data.get('lat', 51.5074)))
|
||||||
lon = validate_longitude(data.get('longitude', data.get('lon', -0.1278)))
|
lon = validate_longitude(data.get('longitude', data.get('lon', -0.1278)))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return api_error(str(e), 400)
|
||||||
|
|
||||||
sat_input = data.get('satellites', [])
|
sat_input = data.get('satellites', [])
|
||||||
include_track = bool(data.get('includeTrack', True))
|
include_track = bool(data.get('includeTrack', True))
|
||||||
@@ -528,7 +529,7 @@ def update_tle():
|
|||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error updating TLE data: {e}")
|
logger.error(f"Error updating TLE data: {e}")
|
||||||
return jsonify({'status': 'error', 'message': 'TLE update failed'})
|
return api_error('TLE update failed')
|
||||||
|
|
||||||
|
|
||||||
@satellite_bp.route('/celestrak/<category>')
|
@satellite_bp.route('/celestrak/<category>')
|
||||||
@@ -542,7 +543,7 @@ def fetch_celestrak(category):
|
|||||||
]
|
]
|
||||||
|
|
||||||
if category not in valid_categories:
|
if category not in valid_categories:
|
||||||
return jsonify({'status': 'error', 'message': f'Invalid category. Valid: {valid_categories}'})
|
return api_error(f'Invalid category. Valid: {valid_categories}')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
url = f'https://celestrak.org/NORAD/elements/gp.php?GROUP={category}&FORMAT=tle'
|
url = f'https://celestrak.org/NORAD/elements/gp.php?GROUP={category}&FORMAT=tle'
|
||||||
@@ -583,7 +584,7 @@ def fetch_celestrak(category):
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching CelesTrak data: {e}")
|
logger.error(f"Error fetching CelesTrak data: {e}")
|
||||||
return jsonify({'status': 'error', 'message': 'Failed to fetch satellite data'})
|
return api_error('Failed to fetch satellite data')
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -604,7 +605,7 @@ def add_tracked_satellites_endpoint():
|
|||||||
global _tle_cache
|
global _tle_cache
|
||||||
data = request.get_json(silent=True)
|
data = request.get_json(silent=True)
|
||||||
if not data:
|
if not data:
|
||||||
return jsonify({'status': 'error', 'message': 'No data provided'}), 400
|
return api_error('No data provided', 400)
|
||||||
|
|
||||||
# Accept a single satellite dict or a list
|
# Accept a single satellite dict or a list
|
||||||
sat_list = data if isinstance(data, list) else [data]
|
sat_list = data if isinstance(data, list) else [data]
|
||||||
@@ -667,12 +668,12 @@ def update_tracked_satellite_endpoint(norad_id):
|
|||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
enabled = data.get('enabled')
|
enabled = data.get('enabled')
|
||||||
if enabled is None:
|
if enabled is None:
|
||||||
return jsonify({'status': 'error', 'message': 'Missing enabled field'}), 400
|
return api_error('Missing enabled field', 400)
|
||||||
|
|
||||||
ok = update_tracked_satellite(str(norad_id), bool(enabled))
|
ok = update_tracked_satellite(str(norad_id), bool(enabled))
|
||||||
if ok:
|
if ok:
|
||||||
return jsonify({'status': 'success'})
|
return jsonify({'status': 'success'})
|
||||||
return jsonify({'status': 'error', 'message': 'Satellite not found'}), 404
|
return api_error('Satellite not found', 404)
|
||||||
|
|
||||||
|
|
||||||
@satellite_bp.route('/tracked/<norad_id>', methods=['DELETE'])
|
@satellite_bp.route('/tracked/<norad_id>', methods=['DELETE'])
|
||||||
@@ -682,4 +683,4 @@ def delete_tracked_satellite_endpoint(norad_id):
|
|||||||
if ok:
|
if ok:
|
||||||
return jsonify({'status': 'success', 'message': msg})
|
return jsonify({'status': 'success', 'message': msg})
|
||||||
status_code = 403 if 'builtin' in msg.lower() else 404
|
status_code = 403 if 'builtin' in msg.lower() else 404
|
||||||
return jsonify({'status': 'error', 'message': msg}), status_code
|
return api_error(msg, status_code)
|
||||||
|
|||||||
+8
-11
@@ -13,6 +13,7 @@ from typing import Any, Generator
|
|||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
|
from utils.responses import api_success, api_error
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.logging import sensor_logger as logger
|
from utils.logging import sensor_logger as logger
|
||||||
from utils.validation import (
|
from utils.validation import (
|
||||||
@@ -165,7 +166,7 @@ def start_sensor() -> Response:
|
|||||||
|
|
||||||
with app_module.sensor_lock:
|
with app_module.sensor_lock:
|
||||||
if app_module.sensor_process:
|
if app_module.sensor_process:
|
||||||
return jsonify({'status': 'error', 'message': 'Sensor already running'}), 409
|
return api_error('Sensor already running', 409)
|
||||||
|
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
|
|
||||||
@@ -176,7 +177,7 @@ def start_sensor() -> Response:
|
|||||||
ppm = validate_ppm(data.get('ppm', '0'))
|
ppm = validate_ppm(data.get('ppm', '0'))
|
||||||
device = validate_device_index(data.get('device', '0'))
|
device = validate_device_index(data.get('device', '0'))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return api_error(str(e), 400)
|
||||||
|
|
||||||
# Check for rtl_tcp (remote SDR) connection
|
# Check for rtl_tcp (remote SDR) connection
|
||||||
rtl_tcp_host = data.get('rtl_tcp_host')
|
rtl_tcp_host = data.get('rtl_tcp_host')
|
||||||
@@ -190,11 +191,7 @@ def start_sensor() -> Response:
|
|||||||
device_int = int(device)
|
device_int = int(device)
|
||||||
error = app_module.claim_sdr_device(device_int, 'sensor', sdr_type_str)
|
error = app_module.claim_sdr_device(device_int, 'sensor', sdr_type_str)
|
||||||
if error:
|
if error:
|
||||||
return jsonify({
|
return api_error(error, 409, error_type='DEVICE_BUSY')
|
||||||
'status': 'error',
|
|
||||||
'error_type': 'DEVICE_BUSY',
|
|
||||||
'message': error
|
|
||||||
}), 409
|
|
||||||
sensor_active_device = device_int
|
sensor_active_device = device_int
|
||||||
sensor_active_sdr_type = sdr_type_str
|
sensor_active_sdr_type = sdr_type_str
|
||||||
|
|
||||||
@@ -217,7 +214,7 @@ def start_sensor() -> Response:
|
|||||||
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
|
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
|
||||||
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
|
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return api_error(str(e), 400)
|
||||||
|
|
||||||
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
|
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
|
||||||
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
|
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
|
||||||
@@ -285,14 +282,14 @@ def start_sensor() -> Response:
|
|||||||
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
|
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
|
||||||
sensor_active_device = None
|
sensor_active_device = None
|
||||||
sensor_active_sdr_type = None
|
sensor_active_sdr_type = None
|
||||||
return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'})
|
return api_error('rtl_433 not found. Install with: brew install rtl_433')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Release device on failure
|
# Release device on failure
|
||||||
if sensor_active_device is not None:
|
if sensor_active_device is not None:
|
||||||
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
|
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
|
||||||
sensor_active_device = None
|
sensor_active_device = None
|
||||||
sensor_active_sdr_type = None
|
sensor_active_sdr_type = None
|
||||||
return jsonify({'status': 'error', 'message': str(e)})
|
return api_error(str(e))
|
||||||
|
|
||||||
|
|
||||||
@sensor_bp.route('/stop_sensor', methods=['POST'])
|
@sensor_bp.route('/stop_sensor', methods=['POST'])
|
||||||
@@ -346,4 +343,4 @@ def get_rssi_history() -> Response:
|
|||||||
result = {}
|
result = {}
|
||||||
for key, entries in sensor_rssi_history.items():
|
for key, entries in sensor_rssi_history.items():
|
||||||
result[key] = [{'t': round(t, 1), 'rssi': rssi} for t, rssi in entries]
|
result[key] = [{'t': round(t, 1), 'rssi': rssi} for t, rssi in entries]
|
||||||
return jsonify({'status': 'success', 'devices': result})
|
return api_success(data={'devices': result})
|
||||||
|
|||||||
+17
-67
@@ -16,6 +16,7 @@ from utils.database import (
|
|||||||
get_correlations,
|
get_correlations,
|
||||||
)
|
)
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
|
from utils.responses import api_error, api_success
|
||||||
|
|
||||||
logger = get_logger('intercept.settings')
|
logger = get_logger('intercept.settings')
|
||||||
|
|
||||||
@@ -27,16 +28,10 @@ def get_settings() -> Response:
|
|||||||
"""Get all settings."""
|
"""Get all settings."""
|
||||||
try:
|
try:
|
||||||
settings = get_all_settings()
|
settings = get_all_settings()
|
||||||
return jsonify({
|
return api_success(data={'settings': settings})
|
||||||
'status': 'success',
|
|
||||||
'settings': settings
|
|
||||||
})
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting settings: {e}")
|
logger.error(f"Error getting settings: {e}")
|
||||||
return jsonify({
|
return api_error(str(e), 500)
|
||||||
'status': 'error',
|
|
||||||
'message': str(e)
|
|
||||||
}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@settings_bp.route('', methods=['POST'])
|
@settings_bp.route('', methods=['POST'])
|
||||||
@@ -45,10 +40,7 @@ def save_settings() -> Response:
|
|||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
|
|
||||||
if not data:
|
if not data:
|
||||||
return jsonify({
|
return api_error('No settings provided', 400)
|
||||||
'status': 'error',
|
|
||||||
'message': 'No settings provided'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
saved = []
|
saved = []
|
||||||
@@ -60,16 +52,10 @@ def save_settings() -> Response:
|
|||||||
set_setting(key, value)
|
set_setting(key, value)
|
||||||
saved.append(key)
|
saved.append(key)
|
||||||
|
|
||||||
return jsonify({
|
return api_success(data={'saved': saved})
|
||||||
'status': 'success',
|
|
||||||
'saved': saved
|
|
||||||
})
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error saving settings: {e}")
|
logger.error(f"Error saving settings: {e}")
|
||||||
return jsonify({
|
return api_error(str(e), 500)
|
||||||
'status': 'error',
|
|
||||||
'message': str(e)
|
|
||||||
}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@settings_bp.route('/<key>', methods=['GET'])
|
@settings_bp.route('/<key>', methods=['GET'])
|
||||||
@@ -83,17 +69,10 @@ def get_single_setting(key: str) -> Response:
|
|||||||
'key': key
|
'key': key
|
||||||
}), 404
|
}), 404
|
||||||
|
|
||||||
return jsonify({
|
return api_success(data={'key': key, 'value': value})
|
||||||
'status': 'success',
|
|
||||||
'key': key,
|
|
||||||
'value': value
|
|
||||||
})
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting setting {key}: {e}")
|
logger.error(f"Error getting setting {key}: {e}")
|
||||||
return jsonify({
|
return api_error(str(e), 500)
|
||||||
'status': 'error',
|
|
||||||
'message': str(e)
|
|
||||||
}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@settings_bp.route('/<key>', methods=['PUT'])
|
@settings_bp.route('/<key>', methods=['PUT'])
|
||||||
@@ -103,24 +82,14 @@ def update_single_setting(key: str) -> Response:
|
|||||||
value = data.get('value')
|
value = data.get('value')
|
||||||
|
|
||||||
if value is None and 'value' not in data:
|
if value is None and 'value' not in data:
|
||||||
return jsonify({
|
return api_error('Value is required', 400)
|
||||||
'status': 'error',
|
|
||||||
'message': 'Value is required'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
set_setting(key, value)
|
set_setting(key, value)
|
||||||
return jsonify({
|
return api_success(data={'key': key, 'value': value})
|
||||||
'status': 'success',
|
|
||||||
'key': key,
|
|
||||||
'value': value
|
|
||||||
})
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error updating setting {key}: {e}")
|
logger.error(f"Error updating setting {key}: {e}")
|
||||||
return jsonify({
|
return api_error(str(e), 500)
|
||||||
'status': 'error',
|
|
||||||
'message': str(e)
|
|
||||||
}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@settings_bp.route('/<key>', methods=['DELETE'])
|
@settings_bp.route('/<key>', methods=['DELETE'])
|
||||||
@@ -129,11 +98,7 @@ def delete_single_setting(key: str) -> Response:
|
|||||||
try:
|
try:
|
||||||
deleted = delete_setting(key)
|
deleted = delete_setting(key)
|
||||||
if deleted:
|
if deleted:
|
||||||
return jsonify({
|
return api_success(data={'key': key, 'deleted': True})
|
||||||
'status': 'success',
|
|
||||||
'key': key,
|
|
||||||
'deleted': True
|
|
||||||
})
|
|
||||||
else:
|
else:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'not_found',
|
'status': 'not_found',
|
||||||
@@ -141,10 +106,7 @@ def delete_single_setting(key: str) -> Response:
|
|||||||
}), 404
|
}), 404
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error deleting setting {key}: {e}")
|
logger.error(f"Error deleting setting {key}: {e}")
|
||||||
return jsonify({
|
return api_error(str(e), 500)
|
||||||
'status': 'error',
|
|
||||||
'message': str(e)
|
|
||||||
}), 500
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -158,16 +120,10 @@ def get_device_correlations() -> Response:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
correlations = get_correlations(min_confidence)
|
correlations = get_correlations(min_confidence)
|
||||||
return jsonify({
|
return api_success(data={'correlations': correlations})
|
||||||
'status': 'success',
|
|
||||||
'correlations': correlations
|
|
||||||
})
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting correlations: {e}")
|
logger.error(f"Error getting correlations: {e}")
|
||||||
return jsonify({
|
return api_error(str(e), 500)
|
||||||
'status': 'error',
|
|
||||||
'message': str(e)
|
|
||||||
}), 500
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -229,17 +185,11 @@ def check_dvb_driver_status() -> Response:
|
|||||||
def blacklist_dvb_drivers() -> Response:
|
def blacklist_dvb_drivers() -> Response:
|
||||||
"""Blacklist DVB kernel drivers to prevent them from claiming RTL-SDR devices."""
|
"""Blacklist DVB kernel drivers to prevent them from claiming RTL-SDR devices."""
|
||||||
if sys.platform != 'linux':
|
if sys.platform != 'linux':
|
||||||
return jsonify({
|
return api_error('This feature is only available on Linux', 400)
|
||||||
'status': 'error',
|
|
||||||
'message': 'This feature is only available on Linux'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
# Check if we have permission (need to be running as root or with sudo)
|
# Check if we have permission (need to be running as root or with sudo)
|
||||||
if os.geteuid() != 0:
|
if os.geteuid() != 0:
|
||||||
return jsonify({
|
return api_error('Root privileges required. Run the app with sudo or manually run: sudo modprobe -r dvb_usb_rtl28xxu rtl2832_sdr rtl2832 r820t', 403)
|
||||||
'status': 'error',
|
|
||||||
'message': 'Root privileges required. Run the app with sudo or manually run: sudo modprobe -r dvb_usb_rtl28xxu rtl2832_sdr rtl2832 r820t'
|
|
||||||
}), 403
|
|
||||||
|
|
||||||
errors = []
|
errors = []
|
||||||
successes = []
|
successes = []
|
||||||
|
|||||||
+5
-4
@@ -10,6 +10,7 @@ from typing import Any
|
|||||||
|
|
||||||
from flask import Blueprint, Response, jsonify, request
|
from flask import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
|
from utils.responses import api_success, api_error
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
|
|
||||||
logger = get_logger('intercept.signalid')
|
logger = get_logger('intercept.signalid')
|
||||||
@@ -294,15 +295,15 @@ def sigidwiki_lookup() -> Response:
|
|||||||
|
|
||||||
freq_raw = payload.get('frequency_mhz')
|
freq_raw = payload.get('frequency_mhz')
|
||||||
if freq_raw is None:
|
if freq_raw is None:
|
||||||
return jsonify({'status': 'error', 'message': 'frequency_mhz is required'}), 400
|
return api_error('frequency_mhz is required', 400)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
frequency_mhz = float(freq_raw)
|
frequency_mhz = float(freq_raw)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid frequency_mhz'}), 400
|
return api_error('Invalid frequency_mhz', 400)
|
||||||
|
|
||||||
if frequency_mhz <= 0:
|
if frequency_mhz <= 0:
|
||||||
return jsonify({'status': 'error', 'message': 'frequency_mhz must be positive'}), 400
|
return api_error('frequency_mhz must be positive', 400)
|
||||||
|
|
||||||
modulation = str(payload.get('modulation') or '').strip().upper()
|
modulation = str(payload.get('modulation') or '').strip().upper()
|
||||||
if modulation and len(modulation) > 16:
|
if modulation and len(modulation) > 16:
|
||||||
@@ -331,7 +332,7 @@ def sigidwiki_lookup() -> Response:
|
|||||||
lookup = _lookup_sigidwiki_matches(frequency_mhz, modulation, limit)
|
lookup = _lookup_sigidwiki_matches(frequency_mhz, modulation, limit)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error('SigID lookup failed: %s', exc)
|
logger.error('SigID lookup failed: %s', exc)
|
||||||
return jsonify({'status': 'error', 'message': 'SigID lookup failed'}), 502
|
return api_error('SigID lookup failed', 502)
|
||||||
|
|
||||||
response_payload = {
|
response_payload = {
|
||||||
'matches': lookup.get('matches', []),
|
'matches': lookup.get('matches', []),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from typing import Any
|
|||||||
from flask import Blueprint, Response, jsonify
|
from flask import Blueprint, Response, jsonify
|
||||||
|
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
|
from utils.responses import api_success, api_error
|
||||||
|
|
||||||
logger = get_logger('intercept.space_weather')
|
logger = get_logger('intercept.space_weather')
|
||||||
|
|
||||||
@@ -289,7 +290,7 @@ def get_image(key: str):
|
|||||||
"""Proxy and cache whitelisted space weather images."""
|
"""Proxy and cache whitelisted space weather images."""
|
||||||
entry = IMAGE_WHITELIST.get(key)
|
entry = IMAGE_WHITELIST.get(key)
|
||||||
if not entry:
|
if not entry:
|
||||||
return jsonify({'error': 'Unknown image key'}), 404
|
return api_error('Unknown image key', 404)
|
||||||
|
|
||||||
cache_key = f'img_{key}'
|
cache_key = f'img_{key}'
|
||||||
cached = _cache_get(cache_key)
|
cached = _cache_get(cache_key)
|
||||||
@@ -299,7 +300,7 @@ def get_image(key: str):
|
|||||||
|
|
||||||
img_data = _fetch_bytes(entry['url'])
|
img_data = _fetch_bytes(entry['url'])
|
||||||
if img_data is None:
|
if img_data is None:
|
||||||
return jsonify({'error': 'Failed to fetch image'}), 502
|
return api_error('Failed to fetch image', 502)
|
||||||
|
|
||||||
_cache_set(cache_key, img_data, TTL_IMAGE)
|
_cache_set(cache_key, img_data, TTL_IMAGE)
|
||||||
return Response(img_data, content_type=entry['content_type'],
|
return Response(img_data, content_type=entry['content_type'],
|
||||||
|
|||||||
+10
-9
@@ -14,6 +14,7 @@ from typing import Any
|
|||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response, send_file
|
from flask import Blueprint, jsonify, request, Response, send_file
|
||||||
|
|
||||||
|
from utils.responses import api_success, api_error
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.sse import sse_stream_fanout
|
from utils.sse import sse_stream_fanout
|
||||||
@@ -357,16 +358,16 @@ def get_image(filename: str):
|
|||||||
|
|
||||||
# Security: only allow alphanumeric filenames with .png extension
|
# Security: only allow alphanumeric filenames with .png extension
|
||||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
return api_error('Invalid filename', 400)
|
||||||
|
|
||||||
if not filename.endswith('.png'):
|
if not filename.endswith('.png'):
|
||||||
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
|
return api_error('Only PNG files supported', 400)
|
||||||
|
|
||||||
# Find image in decoder's output directory
|
# Find image in decoder's output directory
|
||||||
image_path = decoder._output_dir / filename
|
image_path = decoder._output_dir / filename
|
||||||
|
|
||||||
if not image_path.exists():
|
if not image_path.exists():
|
||||||
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
return api_error('Image not found', 404)
|
||||||
|
|
||||||
return send_file(image_path, mimetype='image/png')
|
return send_file(image_path, mimetype='image/png')
|
||||||
|
|
||||||
@@ -386,15 +387,15 @@ def download_image(filename: str):
|
|||||||
|
|
||||||
# Security: only allow alphanumeric filenames with .png extension
|
# Security: only allow alphanumeric filenames with .png extension
|
||||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
return api_error('Invalid filename', 400)
|
||||||
|
|
||||||
if not filename.endswith('.png'):
|
if not filename.endswith('.png'):
|
||||||
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
|
return api_error('Only PNG files supported', 400)
|
||||||
|
|
||||||
image_path = decoder._output_dir / filename
|
image_path = decoder._output_dir / filename
|
||||||
|
|
||||||
if not image_path.exists():
|
if not image_path.exists():
|
||||||
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
return api_error('Image not found', 404)
|
||||||
|
|
||||||
return send_file(image_path, mimetype='image/png', as_attachment=True, download_name=filename)
|
return send_file(image_path, mimetype='image/png', as_attachment=True, download_name=filename)
|
||||||
|
|
||||||
@@ -414,15 +415,15 @@ def delete_image(filename: str):
|
|||||||
|
|
||||||
# Security: only allow alphanumeric filenames with .png extension
|
# Security: only allow alphanumeric filenames with .png extension
|
||||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
return api_error('Invalid filename', 400)
|
||||||
|
|
||||||
if not filename.endswith('.png'):
|
if not filename.endswith('.png'):
|
||||||
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
|
return api_error('Only PNG files supported', 400)
|
||||||
|
|
||||||
if decoder.delete_image(filename):
|
if decoder.delete_image(filename):
|
||||||
return jsonify({'status': 'ok'})
|
return jsonify({'status': 'ok'})
|
||||||
else:
|
else:
|
||||||
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
return api_error('Image not found', 404)
|
||||||
|
|
||||||
|
|
||||||
@sstv_bp.route('/images', methods=['DELETE'])
|
@sstv_bp.route('/images', methods=['DELETE'])
|
||||||
|
|||||||
+21
-54
@@ -13,6 +13,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from flask import Blueprint, Response, jsonify, request, send_file
|
from flask import Blueprint, Response, jsonify, request, send_file
|
||||||
|
|
||||||
|
from utils.responses import api_success, api_error
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.sse import sse_stream_fanout
|
from utils.sse import sse_stream_fanout
|
||||||
@@ -102,10 +103,7 @@ def start_decoder():
|
|||||||
decoder = get_general_sstv_decoder()
|
decoder = get_general_sstv_decoder()
|
||||||
|
|
||||||
if decoder.decoder_available is None:
|
if decoder.decoder_available is None:
|
||||||
return jsonify({
|
return api_error('SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow', 400)
|
||||||
'status': 'error',
|
|
||||||
'message': 'SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow',
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
if decoder.is_running:
|
if decoder.is_running:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -123,10 +121,7 @@ def start_decoder():
|
|||||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||||
|
|
||||||
if sdr_type_str != 'rtlsdr':
|
if sdr_type_str != 'rtlsdr':
|
||||||
return jsonify({
|
return api_error(f'{sdr_type_str.replace("_", " ").title()} is not yet supported for this mode. Please use an RTL-SDR device.', 400)
|
||||||
'status': 'error',
|
|
||||||
'message': f'{sdr_type_str.replace("_", " ").title()} is not yet supported for this mode. Please use an RTL-SDR device.'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
frequency = data.get('frequency')
|
frequency = data.get('frequency')
|
||||||
modulation = data.get('modulation')
|
modulation = data.get('modulation')
|
||||||
@@ -134,23 +129,14 @@ def start_decoder():
|
|||||||
|
|
||||||
# Validate frequency
|
# Validate frequency
|
||||||
if frequency is None:
|
if frequency is None:
|
||||||
return jsonify({
|
return api_error('Frequency is required', 400)
|
||||||
'status': 'error',
|
|
||||||
'message': 'Frequency is required',
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
frequency = float(frequency)
|
frequency = float(frequency)
|
||||||
if not (1 <= frequency <= 500):
|
if not (1 <= frequency <= 500):
|
||||||
return jsonify({
|
return api_error('Frequency must be between 1-500 MHz (HF requires upconverter for RTL-SDR)', 400)
|
||||||
'status': 'error',
|
|
||||||
'message': 'Frequency must be between 1-500 MHz (HF requires upconverter for RTL-SDR)',
|
|
||||||
}), 400
|
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return jsonify({
|
return api_error('Invalid frequency', 400)
|
||||||
'status': 'error',
|
|
||||||
'message': 'Invalid frequency',
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
# Auto-detect modulation from frequency table if not specified
|
# Auto-detect modulation from frequency table if not specified
|
||||||
if not modulation:
|
if not modulation:
|
||||||
@@ -158,21 +144,14 @@ def start_decoder():
|
|||||||
|
|
||||||
# Validate modulation
|
# Validate modulation
|
||||||
if modulation not in ('fm', 'usb', 'lsb'):
|
if modulation not in ('fm', 'usb', 'lsb'):
|
||||||
return jsonify({
|
return api_error('Modulation must be fm, usb, or lsb', 400)
|
||||||
'status': 'error',
|
|
||||||
'message': 'Modulation must be fm, usb, or lsb',
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
# Claim SDR device
|
# Claim SDR device
|
||||||
global _sstv_general_active_device, _sstv_general_active_sdr_type
|
global _sstv_general_active_device, _sstv_general_active_sdr_type
|
||||||
device_int = int(device_index)
|
device_int = int(device_index)
|
||||||
error = app_module.claim_sdr_device(device_int, 'sstv_general', sdr_type_str)
|
error = app_module.claim_sdr_device(device_int, 'sstv_general', sdr_type_str)
|
||||||
if error:
|
if error:
|
||||||
return jsonify({
|
return api_error(error, 409, error_type='DEVICE_BUSY')
|
||||||
'status': 'error',
|
|
||||||
'error_type': 'DEVICE_BUSY',
|
|
||||||
'message': error,
|
|
||||||
}), 409
|
|
||||||
|
|
||||||
# Set callback and start
|
# Set callback and start
|
||||||
decoder.set_callback(_progress_callback)
|
decoder.set_callback(_progress_callback)
|
||||||
@@ -193,10 +172,7 @@ def start_decoder():
|
|||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||||
return jsonify({
|
return api_error('Failed to start decoder', 500)
|
||||||
'status': 'error',
|
|
||||||
'message': 'Failed to start decoder',
|
|
||||||
}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@sstv_general_bp.route('/stop', methods=['POST'])
|
@sstv_general_bp.route('/stop', methods=['POST'])
|
||||||
@@ -237,15 +213,15 @@ def get_image(filename: str):
|
|||||||
|
|
||||||
# Security: only allow alphanumeric filenames with .png extension
|
# Security: only allow alphanumeric filenames with .png extension
|
||||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
return api_error('Invalid filename', 400)
|
||||||
|
|
||||||
if not filename.endswith('.png'):
|
if not filename.endswith('.png'):
|
||||||
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
|
return api_error('Only PNG files supported', 400)
|
||||||
|
|
||||||
image_path = decoder._output_dir / filename
|
image_path = decoder._output_dir / filename
|
||||||
|
|
||||||
if not image_path.exists():
|
if not image_path.exists():
|
||||||
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
return api_error('Image not found', 404)
|
||||||
|
|
||||||
return send_file(image_path, mimetype='image/png')
|
return send_file(image_path, mimetype='image/png')
|
||||||
|
|
||||||
@@ -257,15 +233,15 @@ def download_image(filename: str):
|
|||||||
|
|
||||||
# Security: only allow alphanumeric filenames with .png extension
|
# Security: only allow alphanumeric filenames with .png extension
|
||||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
return api_error('Invalid filename', 400)
|
||||||
|
|
||||||
if not filename.endswith('.png'):
|
if not filename.endswith('.png'):
|
||||||
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
|
return api_error('Only PNG files supported', 400)
|
||||||
|
|
||||||
image_path = decoder._output_dir / filename
|
image_path = decoder._output_dir / filename
|
||||||
|
|
||||||
if not image_path.exists():
|
if not image_path.exists():
|
||||||
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
return api_error('Image not found', 404)
|
||||||
|
|
||||||
return send_file(image_path, mimetype='image/png', as_attachment=True, download_name=filename)
|
return send_file(image_path, mimetype='image/png', as_attachment=True, download_name=filename)
|
||||||
|
|
||||||
@@ -277,15 +253,15 @@ def delete_image(filename: str):
|
|||||||
|
|
||||||
# Security: only allow alphanumeric filenames with .png extension
|
# Security: only allow alphanumeric filenames with .png extension
|
||||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
return api_error('Invalid filename', 400)
|
||||||
|
|
||||||
if not filename.endswith('.png'):
|
if not filename.endswith('.png'):
|
||||||
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
|
return api_error('Only PNG files supported', 400)
|
||||||
|
|
||||||
if decoder.delete_image(filename):
|
if decoder.delete_image(filename):
|
||||||
return jsonify({'status': 'ok'})
|
return jsonify({'status': 'ok'})
|
||||||
else:
|
else:
|
||||||
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
return api_error('Image not found', 404)
|
||||||
|
|
||||||
|
|
||||||
@sstv_general_bp.route('/images', methods=['DELETE'])
|
@sstv_general_bp.route('/images', methods=['DELETE'])
|
||||||
@@ -322,18 +298,12 @@ def stream_progress():
|
|||||||
def decode_file():
|
def decode_file():
|
||||||
"""Decode SSTV from an uploaded audio file."""
|
"""Decode SSTV from an uploaded audio file."""
|
||||||
if 'audio' not in request.files:
|
if 'audio' not in request.files:
|
||||||
return jsonify({
|
return api_error('No audio file provided', 400)
|
||||||
'status': 'error',
|
|
||||||
'message': 'No audio file provided',
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
audio_file = request.files['audio']
|
audio_file = request.files['audio']
|
||||||
|
|
||||||
if not audio_file.filename:
|
if not audio_file.filename:
|
||||||
return jsonify({
|
return api_error('No file selected', 400)
|
||||||
'status': 'error',
|
|
||||||
'message': 'No file selected',
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
import tempfile
|
import tempfile
|
||||||
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp:
|
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp:
|
||||||
@@ -352,10 +322,7 @@ def decode_file():
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error decoding file: {e}")
|
logger.error(f"Error decoding file: {e}")
|
||||||
return jsonify({
|
return api_error(str(e), 500)
|
||||||
'status': 'error',
|
|
||||||
'message': str(e),
|
|
||||||
}), 500
|
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
|
|||||||
+153
-152
@@ -10,10 +10,11 @@ import queue
|
|||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response, send_file
|
from flask import Blueprint, jsonify, request, Response, send_file
|
||||||
|
|
||||||
from utils.logging import get_logger
|
from utils.responses import api_success, api_error
|
||||||
from utils.sse import sse_stream
|
from utils.logging import get_logger
|
||||||
from utils.subghz import get_subghz_manager
|
from utils.sse import sse_stream
|
||||||
from utils.event_pipeline import process_event
|
from utils.subghz import get_subghz_manager
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
SUBGHZ_FREQ_MIN_MHZ,
|
SUBGHZ_FREQ_MIN_MHZ,
|
||||||
SUBGHZ_FREQ_MAX_MHZ,
|
SUBGHZ_FREQ_MAX_MHZ,
|
||||||
@@ -33,14 +34,14 @@ subghz_bp = Blueprint('subghz', __name__, url_prefix='/subghz')
|
|||||||
_subghz_queue: queue.Queue = queue.Queue(maxsize=200)
|
_subghz_queue: queue.Queue = queue.Queue(maxsize=200)
|
||||||
|
|
||||||
|
|
||||||
def _event_callback(event: dict) -> None:
|
def _event_callback(event: dict) -> None:
|
||||||
"""Forward SubGhzManager events to the SSE queue."""
|
"""Forward SubGhzManager events to the SSE queue."""
|
||||||
try:
|
try:
|
||||||
process_event('subghz', event, event.get('type'))
|
process_event('subghz', event, event.get('type'))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
_subghz_queue.put_nowait(event)
|
_subghz_queue.put_nowait(event)
|
||||||
except queue.Full:
|
except queue.Full:
|
||||||
try:
|
try:
|
||||||
_subghz_queue.get_nowait()
|
_subghz_queue.get_nowait()
|
||||||
@@ -76,44 +77,44 @@ def _validate_serial(data: dict) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _validate_int(data: dict, key: str, default: int, min_val: int, max_val: int) -> int:
|
def _validate_int(data: dict, key: str, default: int, min_val: int, max_val: int) -> int:
|
||||||
"""Validate integer parameter with bounds clamping."""
|
"""Validate integer parameter with bounds clamping."""
|
||||||
try:
|
try:
|
||||||
val = int(data.get(key, default))
|
val = int(data.get(key, default))
|
||||||
return max(min_val, min(max_val, val))
|
return max(min_val, min(max_val, val))
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return default
|
return default
|
||||||
|
|
||||||
|
|
||||||
def _validate_decode_profile(data: dict, default: str = 'weather') -> str:
|
def _validate_decode_profile(data: dict, default: str = 'weather') -> str:
|
||||||
profile = data.get('decode_profile', default)
|
profile = data.get('decode_profile', default)
|
||||||
if not isinstance(profile, str):
|
if not isinstance(profile, str):
|
||||||
return default
|
return default
|
||||||
profile = profile.strip().lower()
|
profile = profile.strip().lower()
|
||||||
if profile in {'weather', 'all'}:
|
if profile in {'weather', 'all'}:
|
||||||
return profile
|
return profile
|
||||||
return default
|
return default
|
||||||
|
|
||||||
|
|
||||||
def _validate_optional_float(data: dict, key: str) -> tuple[float | None, str | None]:
|
def _validate_optional_float(data: dict, key: str) -> tuple[float | None, str | None]:
|
||||||
raw = data.get(key)
|
raw = data.get(key)
|
||||||
if raw is None or raw == '':
|
if raw is None or raw == '':
|
||||||
return None, None
|
return None, None
|
||||||
try:
|
try:
|
||||||
return float(raw), None
|
return float(raw), None
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return None, f'Invalid {key}'
|
return None, f'Invalid {key}'
|
||||||
|
|
||||||
|
|
||||||
def _validate_bool(data: dict, key: str, default: bool = False) -> bool:
|
def _validate_bool(data: dict, key: str, default: bool = False) -> bool:
|
||||||
raw = data.get(key, default)
|
raw = data.get(key, default)
|
||||||
if isinstance(raw, bool):
|
if isinstance(raw, bool):
|
||||||
return raw
|
return raw
|
||||||
if isinstance(raw, (int, float)):
|
if isinstance(raw, (int, float)):
|
||||||
return bool(raw)
|
return bool(raw)
|
||||||
if isinstance(raw, str):
|
if isinstance(raw, str):
|
||||||
return raw.strip().lower() in {'1', 'true', 'yes', 'on', 'enabled'}
|
return raw.strip().lower() in {'1', 'true', 'yes', 'on', 'enabled'}
|
||||||
return default
|
return default
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -136,34 +137,34 @@ def get_presets():
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
@subghz_bp.route('/receive/start', methods=['POST'])
|
@subghz_bp.route('/receive/start', methods=['POST'])
|
||||||
def start_receive():
|
def start_receive():
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
|
|
||||||
freq_hz, err = _validate_frequency_hz(data)
|
freq_hz, err = _validate_frequency_hz(data)
|
||||||
if err:
|
if err:
|
||||||
return jsonify({'status': 'error', 'message': err}), 400
|
return api_error(err, 400)
|
||||||
|
|
||||||
sample_rate = _validate_int(data, 'sample_rate', 2000000, 2000000, 20000000)
|
sample_rate = _validate_int(data, 'sample_rate', 2000000, 2000000, 20000000)
|
||||||
lna_gain = _validate_int(data, 'lna_gain', 32, 0, SUBGHZ_LNA_GAIN_MAX)
|
lna_gain = _validate_int(data, 'lna_gain', 32, 0, SUBGHZ_LNA_GAIN_MAX)
|
||||||
vga_gain = _validate_int(data, 'vga_gain', 20, 0, SUBGHZ_VGA_GAIN_MAX)
|
vga_gain = _validate_int(data, 'vga_gain', 20, 0, SUBGHZ_VGA_GAIN_MAX)
|
||||||
trigger_enabled = _validate_bool(data, 'trigger_enabled', False)
|
trigger_enabled = _validate_bool(data, 'trigger_enabled', False)
|
||||||
trigger_pre_ms = _validate_int(data, 'trigger_pre_ms', 350, 50, 5000)
|
trigger_pre_ms = _validate_int(data, 'trigger_pre_ms', 350, 50, 5000)
|
||||||
trigger_post_ms = _validate_int(data, 'trigger_post_ms', 700, 100, 10000)
|
trigger_post_ms = _validate_int(data, 'trigger_post_ms', 700, 100, 10000)
|
||||||
device_serial = _validate_serial(data)
|
device_serial = _validate_serial(data)
|
||||||
|
|
||||||
manager = get_subghz_manager()
|
manager = get_subghz_manager()
|
||||||
manager.set_callback(_event_callback)
|
manager.set_callback(_event_callback)
|
||||||
|
|
||||||
result = manager.start_receive(
|
result = manager.start_receive(
|
||||||
frequency_hz=freq_hz,
|
frequency_hz=freq_hz,
|
||||||
sample_rate=sample_rate,
|
sample_rate=sample_rate,
|
||||||
lna_gain=lna_gain,
|
lna_gain=lna_gain,
|
||||||
vga_gain=vga_gain,
|
vga_gain=vga_gain,
|
||||||
trigger_enabled=trigger_enabled,
|
trigger_enabled=trigger_enabled,
|
||||||
trigger_pre_ms=trigger_pre_ms,
|
trigger_pre_ms=trigger_pre_ms,
|
||||||
trigger_post_ms=trigger_post_ms,
|
trigger_post_ms=trigger_post_ms,
|
||||||
device_serial=device_serial,
|
device_serial=device_serial,
|
||||||
)
|
)
|
||||||
|
|
||||||
status_code = 200 if result.get('status') != 'error' else 409
|
status_code = 200 if result.get('status') != 'error' else 409
|
||||||
return jsonify(result), status_code
|
return jsonify(result), status_code
|
||||||
@@ -186,25 +187,25 @@ def start_decode():
|
|||||||
|
|
||||||
freq_hz, err = _validate_frequency_hz(data)
|
freq_hz, err = _validate_frequency_hz(data)
|
||||||
if err:
|
if err:
|
||||||
return jsonify({'status': 'error', 'message': err}), 400
|
return api_error(err, 400)
|
||||||
|
|
||||||
sample_rate = _validate_int(data, 'sample_rate', 2000000, 2000000, 20000000)
|
sample_rate = _validate_int(data, 'sample_rate', 2000000, 2000000, 20000000)
|
||||||
lna_gain = _validate_int(data, 'lna_gain', 32, 0, SUBGHZ_LNA_GAIN_MAX)
|
lna_gain = _validate_int(data, 'lna_gain', 32, 0, SUBGHZ_LNA_GAIN_MAX)
|
||||||
vga_gain = _validate_int(data, 'vga_gain', 20, 0, SUBGHZ_VGA_GAIN_MAX)
|
vga_gain = _validate_int(data, 'vga_gain', 20, 0, SUBGHZ_VGA_GAIN_MAX)
|
||||||
decode_profile = _validate_decode_profile(data)
|
decode_profile = _validate_decode_profile(data)
|
||||||
device_serial = _validate_serial(data)
|
device_serial = _validate_serial(data)
|
||||||
|
|
||||||
manager = get_subghz_manager()
|
manager = get_subghz_manager()
|
||||||
manager.set_callback(_event_callback)
|
manager.set_callback(_event_callback)
|
||||||
|
|
||||||
result = manager.start_decode(
|
result = manager.start_decode(
|
||||||
frequency_hz=freq_hz,
|
frequency_hz=freq_hz,
|
||||||
sample_rate=sample_rate,
|
sample_rate=sample_rate,
|
||||||
lna_gain=lna_gain,
|
lna_gain=lna_gain,
|
||||||
vga_gain=vga_gain,
|
vga_gain=vga_gain,
|
||||||
decode_profile=decode_profile,
|
decode_profile=decode_profile,
|
||||||
device_serial=device_serial,
|
device_serial=device_serial,
|
||||||
)
|
)
|
||||||
|
|
||||||
status_code = 200 if result.get('status') != 'error' else 409
|
status_code = 200 if result.get('status') != 'error' else 409
|
||||||
return jsonify(result), status_code
|
return jsonify(result), status_code
|
||||||
@@ -227,33 +228,33 @@ def start_transmit():
|
|||||||
|
|
||||||
capture_id = data.get('capture_id')
|
capture_id = data.get('capture_id')
|
||||||
if not capture_id or not isinstance(capture_id, str):
|
if not capture_id or not isinstance(capture_id, str):
|
||||||
return jsonify({'status': 'error', 'message': 'capture_id is required'}), 400
|
return api_error('capture_id is required', 400)
|
||||||
|
|
||||||
# Sanitize capture_id
|
# Sanitize capture_id
|
||||||
if not capture_id.isalnum():
|
if not capture_id.isalnum():
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
|
return api_error('Invalid capture_id', 400)
|
||||||
|
|
||||||
tx_gain = _validate_int(data, 'tx_gain', 20, 0, SUBGHZ_TX_VGA_GAIN_MAX)
|
tx_gain = _validate_int(data, 'tx_gain', 20, 0, SUBGHZ_TX_VGA_GAIN_MAX)
|
||||||
max_duration = _validate_int(data, 'max_duration', 10, 1, SUBGHZ_TX_MAX_DURATION)
|
max_duration = _validate_int(data, 'max_duration', 10, 1, SUBGHZ_TX_MAX_DURATION)
|
||||||
start_seconds, start_err = _validate_optional_float(data, 'start_seconds')
|
start_seconds, start_err = _validate_optional_float(data, 'start_seconds')
|
||||||
if start_err:
|
if start_err:
|
||||||
return jsonify({'status': 'error', 'message': start_err}), 400
|
return api_error(start_err, 400)
|
||||||
duration_seconds, duration_err = _validate_optional_float(data, 'duration_seconds')
|
duration_seconds, duration_err = _validate_optional_float(data, 'duration_seconds')
|
||||||
if duration_err:
|
if duration_err:
|
||||||
return jsonify({'status': 'error', 'message': duration_err}), 400
|
return api_error(duration_err, 400)
|
||||||
device_serial = _validate_serial(data)
|
device_serial = _validate_serial(data)
|
||||||
|
|
||||||
manager = get_subghz_manager()
|
manager = get_subghz_manager()
|
||||||
manager.set_callback(_event_callback)
|
manager.set_callback(_event_callback)
|
||||||
|
|
||||||
result = manager.transmit(
|
result = manager.transmit(
|
||||||
capture_id=capture_id,
|
capture_id=capture_id,
|
||||||
tx_gain=tx_gain,
|
tx_gain=tx_gain,
|
||||||
max_duration=max_duration,
|
max_duration=max_duration,
|
||||||
start_seconds=start_seconds,
|
start_seconds=start_seconds,
|
||||||
duration_seconds=duration_seconds,
|
duration_seconds=duration_seconds,
|
||||||
device_serial=device_serial,
|
device_serial=device_serial,
|
||||||
)
|
)
|
||||||
|
|
||||||
status_code = 200 if result.get('status') != 'error' else 400
|
status_code = 200 if result.get('status') != 'error' else 400
|
||||||
return jsonify(result), status_code
|
return jsonify(result), status_code
|
||||||
@@ -278,11 +279,11 @@ def start_sweep():
|
|||||||
freq_start = float(data.get('freq_start_mhz', 300))
|
freq_start = float(data.get('freq_start_mhz', 300))
|
||||||
freq_end = float(data.get('freq_end_mhz', 928))
|
freq_end = float(data.get('freq_end_mhz', 928))
|
||||||
if freq_start >= freq_end:
|
if freq_start >= freq_end:
|
||||||
return jsonify({'status': 'error', 'message': 'freq_start must be less than freq_end'}), 400
|
return api_error('freq_start must be less than freq_end', 400)
|
||||||
if freq_start < SUBGHZ_FREQ_MIN_MHZ or freq_end > SUBGHZ_FREQ_MAX_MHZ:
|
if freq_start < SUBGHZ_FREQ_MIN_MHZ or freq_end > SUBGHZ_FREQ_MAX_MHZ:
|
||||||
return jsonify({'status': 'error', 'message': f'Frequency range: {SUBGHZ_FREQ_MIN_MHZ}-{SUBGHZ_FREQ_MAX_MHZ} MHz'}), 400
|
return api_error(f'Frequency range: {SUBGHZ_FREQ_MIN_MHZ}-{SUBGHZ_FREQ_MAX_MHZ} MHz', 400)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid frequency range'}), 400
|
return api_error('Invalid frequency range', 400)
|
||||||
|
|
||||||
bin_width = _validate_int(data, 'bin_width', 100000, 10000, 5000000)
|
bin_width = _validate_int(data, 'bin_width', 100000, 10000, 5000000)
|
||||||
device_serial = _validate_serial(data)
|
device_serial = _validate_serial(data)
|
||||||
@@ -326,94 +327,94 @@ def list_captures():
|
|||||||
@subghz_bp.route('/captures/<capture_id>')
|
@subghz_bp.route('/captures/<capture_id>')
|
||||||
def get_capture(capture_id: str):
|
def get_capture(capture_id: str):
|
||||||
if not capture_id.isalnum():
|
if not capture_id.isalnum():
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
|
return api_error('Invalid capture_id', 400)
|
||||||
|
|
||||||
manager = get_subghz_manager()
|
manager = get_subghz_manager()
|
||||||
capture = manager.get_capture(capture_id)
|
capture = manager.get_capture(capture_id)
|
||||||
if not capture:
|
if not capture:
|
||||||
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
|
return api_error('Capture not found', 404)
|
||||||
|
|
||||||
return jsonify({'status': 'ok', 'capture': capture.to_dict()})
|
return jsonify({'status': 'ok', 'capture': capture.to_dict()})
|
||||||
|
|
||||||
|
|
||||||
@subghz_bp.route('/captures/<capture_id>/download')
|
@subghz_bp.route('/captures/<capture_id>/download')
|
||||||
def download_capture(capture_id: str):
|
def download_capture(capture_id: str):
|
||||||
if not capture_id.isalnum():
|
if not capture_id.isalnum():
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
|
return api_error('Invalid capture_id', 400)
|
||||||
|
|
||||||
manager = get_subghz_manager()
|
manager = get_subghz_manager()
|
||||||
path = manager.get_capture_path(capture_id)
|
path = manager.get_capture_path(capture_id)
|
||||||
if not path:
|
if not path:
|
||||||
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
|
return api_error('Capture not found', 404)
|
||||||
|
|
||||||
return send_file(
|
return send_file(
|
||||||
path,
|
path,
|
||||||
mimetype='application/octet-stream',
|
mimetype='application/octet-stream',
|
||||||
as_attachment=True,
|
as_attachment=True,
|
||||||
download_name=path.name,
|
download_name=path.name,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@subghz_bp.route('/captures/<capture_id>/trim', methods=['POST'])
|
@subghz_bp.route('/captures/<capture_id>/trim', methods=['POST'])
|
||||||
def trim_capture(capture_id: str):
|
def trim_capture(capture_id: str):
|
||||||
if not capture_id.isalnum():
|
if not capture_id.isalnum():
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
|
return api_error('Invalid capture_id', 400)
|
||||||
|
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
start_seconds, start_err = _validate_optional_float(data, 'start_seconds')
|
start_seconds, start_err = _validate_optional_float(data, 'start_seconds')
|
||||||
if start_err:
|
if start_err:
|
||||||
return jsonify({'status': 'error', 'message': start_err}), 400
|
return api_error(start_err, 400)
|
||||||
duration_seconds, duration_err = _validate_optional_float(data, 'duration_seconds')
|
duration_seconds, duration_err = _validate_optional_float(data, 'duration_seconds')
|
||||||
if duration_err:
|
if duration_err:
|
||||||
return jsonify({'status': 'error', 'message': duration_err}), 400
|
return api_error(duration_err, 400)
|
||||||
|
|
||||||
label = data.get('label', '')
|
label = data.get('label', '')
|
||||||
if label is None:
|
if label is None:
|
||||||
label = ''
|
label = ''
|
||||||
if not isinstance(label, str) or len(label) > 100:
|
if not isinstance(label, str) or len(label) > 100:
|
||||||
return jsonify({'status': 'error', 'message': 'Label must be a string (max 100 chars)'}), 400
|
return api_error('Label must be a string (max 100 chars)', 400)
|
||||||
|
|
||||||
manager = get_subghz_manager()
|
manager = get_subghz_manager()
|
||||||
result = manager.trim_capture(
|
result = manager.trim_capture(
|
||||||
capture_id=capture_id,
|
capture_id=capture_id,
|
||||||
start_seconds=start_seconds,
|
start_seconds=start_seconds,
|
||||||
duration_seconds=duration_seconds,
|
duration_seconds=duration_seconds,
|
||||||
label=label,
|
label=label,
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.get('status') == 'ok':
|
if result.get('status') == 'ok':
|
||||||
return jsonify(result), 200
|
return jsonify(result), 200
|
||||||
message = str(result.get('message') or 'Trim failed')
|
message = str(result.get('message') or 'Trim failed')
|
||||||
status_code = 404 if 'not found' in message.lower() else 400
|
status_code = 404 if 'not found' in message.lower() else 400
|
||||||
return jsonify(result), status_code
|
return jsonify(result), status_code
|
||||||
|
|
||||||
|
|
||||||
@subghz_bp.route('/captures/<capture_id>', methods=['DELETE'])
|
@subghz_bp.route('/captures/<capture_id>', methods=['DELETE'])
|
||||||
def delete_capture(capture_id: str):
|
def delete_capture(capture_id: str):
|
||||||
if not capture_id.isalnum():
|
if not capture_id.isalnum():
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
|
return api_error('Invalid capture_id', 400)
|
||||||
|
|
||||||
manager = get_subghz_manager()
|
manager = get_subghz_manager()
|
||||||
if manager.delete_capture(capture_id):
|
if manager.delete_capture(capture_id):
|
||||||
return jsonify({'status': 'deleted', 'id': capture_id})
|
return jsonify({'status': 'deleted', 'id': capture_id})
|
||||||
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
|
return api_error('Capture not found', 404)
|
||||||
|
|
||||||
|
|
||||||
@subghz_bp.route('/captures/<capture_id>', methods=['PATCH'])
|
@subghz_bp.route('/captures/<capture_id>', methods=['PATCH'])
|
||||||
def update_capture(capture_id: str):
|
def update_capture(capture_id: str):
|
||||||
if not capture_id.isalnum():
|
if not capture_id.isalnum():
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
|
return api_error('Invalid capture_id', 400)
|
||||||
|
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
label = data.get('label', '')
|
label = data.get('label', '')
|
||||||
|
|
||||||
if not isinstance(label, str) or len(label) > 100:
|
if not isinstance(label, str) or len(label) > 100:
|
||||||
return jsonify({'status': 'error', 'message': 'Label must be a string (max 100 chars)'}), 400
|
return api_error('Label must be a string (max 100 chars)', 400)
|
||||||
|
|
||||||
manager = get_subghz_manager()
|
manager = get_subghz_manager()
|
||||||
if manager.update_capture_label(capture_id, label):
|
if manager.update_capture_label(capture_id, label):
|
||||||
return jsonify({'status': 'updated', 'id': capture_id, 'label': label})
|
return jsonify({'status': 'updated', 'id': capture_id, 'label': label})
|
||||||
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
|
return api_error('Capture not found', 404)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
+4
-3
@@ -22,6 +22,7 @@ from flask import Blueprint, Response, jsonify, request
|
|||||||
|
|
||||||
from utils.constants import SSE_KEEPALIVE_INTERVAL, SSE_QUEUE_TIMEOUT
|
from utils.constants import SSE_KEEPALIVE_INTERVAL, SSE_QUEUE_TIMEOUT
|
||||||
from utils.logging import sensor_logger as logger
|
from utils.logging import sensor_logger as logger
|
||||||
|
from utils.responses import api_success, api_error
|
||||||
from utils.sse import sse_stream_fanout
|
from utils.sse import sse_stream_fanout
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -549,10 +550,10 @@ def get_weather() -> Response:
|
|||||||
lat, lon = loc.get('lat'), loc.get('lon')
|
lat, lon = loc.get('lat'), loc.get('lon')
|
||||||
|
|
||||||
if lat is None or lon is None:
|
if lat is None or lon is None:
|
||||||
return jsonify({'error': 'No location available'})
|
return api_error('No location available')
|
||||||
|
|
||||||
if _requests is None:
|
if _requests is None:
|
||||||
return jsonify({'error': 'requests library not available'})
|
return api_error('requests library not available')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp = _requests.get(
|
resp = _requests.get(
|
||||||
@@ -580,4 +581,4 @@ def get_weather() -> Response:
|
|||||||
return jsonify(weather)
|
return jsonify(weather)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug('Weather fetch failed: %s', exc)
|
logger.debug('Weather fetch failed: %s', exc)
|
||||||
return jsonify({'error': str(exc)})
|
return api_error(str(exc))
|
||||||
|
|||||||
+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 flask import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
|
from utils.responses import api_success, api_error
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.updater import (
|
from utils.updater import (
|
||||||
check_for_updates,
|
check_for_updates,
|
||||||
@@ -39,10 +40,7 @@ def check_updates() -> Response:
|
|||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error checking for updates: {e}")
|
logger.error(f"Error checking for updates: {e}")
|
||||||
return jsonify({
|
return api_error(str(e), 500)
|
||||||
'success': False,
|
|
||||||
'error': str(e)
|
|
||||||
}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@updater_bp.route('/status', methods=['GET'])
|
@updater_bp.route('/status', methods=['GET'])
|
||||||
@@ -61,10 +59,7 @@ def update_status() -> Response:
|
|||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting update status: {e}")
|
logger.error(f"Error getting update status: {e}")
|
||||||
return jsonify({
|
return api_error(str(e), 500)
|
||||||
'success': False,
|
|
||||||
'error': str(e)
|
|
||||||
}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@updater_bp.route('/update', methods=['POST'])
|
@updater_bp.route('/update', methods=['POST'])
|
||||||
@@ -100,10 +95,7 @@ def do_update() -> Response:
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error performing update: {e}")
|
logger.error(f"Error performing update: {e}")
|
||||||
return jsonify({
|
return api_error(str(e), 500)
|
||||||
'success': False,
|
|
||||||
'error': str(e)
|
|
||||||
}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@updater_bp.route('/dismiss', methods=['POST'])
|
@updater_bp.route('/dismiss', methods=['POST'])
|
||||||
@@ -124,20 +116,14 @@ def dismiss_notification() -> Response:
|
|||||||
version = data.get('version')
|
version = data.get('version')
|
||||||
|
|
||||||
if not version:
|
if not version:
|
||||||
return jsonify({
|
return api_error('Version is required', 400)
|
||||||
'success': False,
|
|
||||||
'error': 'Version is required'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = dismiss_update(version)
|
result = dismiss_update(version)
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error dismissing update: {e}")
|
logger.error(f"Error dismissing update: {e}")
|
||||||
return jsonify({
|
return api_error(str(e), 500)
|
||||||
'success': False,
|
|
||||||
'error': str(e)
|
|
||||||
}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@updater_bp.route('/restart', methods=['POST'])
|
@updater_bp.route('/restart', methods=['POST'])
|
||||||
|
|||||||
+8
-20
@@ -18,6 +18,7 @@ from typing import Any, Generator
|
|||||||
from flask import Blueprint, Response, jsonify, request
|
from flask import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
import app as app_module
|
import app as app_module
|
||||||
|
from utils.responses import api_success, api_error
|
||||||
from utils.acars_translator import translate_message
|
from utils.acars_translator import translate_message
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
PROCESS_START_WAIT,
|
PROCESS_START_WAIT,
|
||||||
@@ -181,18 +182,12 @@ def start_vdl2() -> Response:
|
|||||||
|
|
||||||
with app_module.vdl2_lock:
|
with app_module.vdl2_lock:
|
||||||
if app_module.vdl2_process and app_module.vdl2_process.poll() is None:
|
if app_module.vdl2_process and app_module.vdl2_process.poll() is None:
|
||||||
return jsonify({
|
return api_error('VDL2 decoder already running', 409)
|
||||||
'status': 'error',
|
|
||||||
'message': 'VDL2 decoder already running'
|
|
||||||
}), 409
|
|
||||||
|
|
||||||
# Check for dumpvdl2
|
# Check for dumpvdl2
|
||||||
dumpvdl2_path = find_dumpvdl2()
|
dumpvdl2_path = find_dumpvdl2()
|
||||||
if not dumpvdl2_path:
|
if not dumpvdl2_path:
|
||||||
return jsonify({
|
return api_error('dumpvdl2 not found. Install from: https://github.com/szpajder/dumpvdl2', 400)
|
||||||
'status': 'error',
|
|
||||||
'message': 'dumpvdl2 not found. Install from: https://github.com/szpajder/dumpvdl2'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
|
|
||||||
@@ -202,7 +197,7 @@ def start_vdl2() -> Response:
|
|||||||
gain = validate_gain(data.get('gain', '40'))
|
gain = validate_gain(data.get('gain', '40'))
|
||||||
ppm = validate_ppm(data.get('ppm', '0'))
|
ppm = validate_ppm(data.get('ppm', '0'))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return api_error(str(e), 400)
|
||||||
|
|
||||||
# Resolve SDR type for device selection
|
# Resolve SDR type for device selection
|
||||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||||
@@ -215,11 +210,7 @@ def start_vdl2() -> Response:
|
|||||||
device_int = int(device)
|
device_int = int(device)
|
||||||
error = app_module.claim_sdr_device(device_int, 'vdl2', sdr_type_str)
|
error = app_module.claim_sdr_device(device_int, 'vdl2', sdr_type_str)
|
||||||
if error:
|
if error:
|
||||||
return jsonify({
|
return api_error(error, 409, error_type='DEVICE_BUSY')
|
||||||
'status': 'error',
|
|
||||||
'error_type': 'DEVICE_BUSY',
|
|
||||||
'message': error
|
|
||||||
}), 409
|
|
||||||
|
|
||||||
vdl2_active_device = device_int
|
vdl2_active_device = device_int
|
||||||
vdl2_active_sdr_type = sdr_type_str
|
vdl2_active_sdr_type = sdr_type_str
|
||||||
@@ -312,7 +303,7 @@ def start_vdl2() -> Response:
|
|||||||
if stderr:
|
if stderr:
|
||||||
error_msg += f': {stderr[:500]}'
|
error_msg += f': {stderr[:500]}'
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
return jsonify({'status': 'error', 'message': error_msg}), 500
|
return api_error(error_msg, 500)
|
||||||
|
|
||||||
app_module.vdl2_process = process
|
app_module.vdl2_process = process
|
||||||
register_process(process)
|
register_process(process)
|
||||||
@@ -339,7 +330,7 @@ def start_vdl2() -> Response:
|
|||||||
vdl2_active_device = None
|
vdl2_active_device = None
|
||||||
vdl2_active_sdr_type = None
|
vdl2_active_sdr_type = None
|
||||||
logger.error(f"Failed to start VDL2 decoder: {e}")
|
logger.error(f"Failed to start VDL2 decoder: {e}")
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
return api_error(str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
@vdl2_bp.route('/stop', methods=['POST'])
|
@vdl2_bp.route('/stop', methods=['POST'])
|
||||||
@@ -349,10 +340,7 @@ def stop_vdl2() -> Response:
|
|||||||
|
|
||||||
with app_module.vdl2_lock:
|
with app_module.vdl2_lock:
|
||||||
if not app_module.vdl2_process:
|
if not app_module.vdl2_process:
|
||||||
return jsonify({
|
return api_error('VDL2 decoder not running', 400)
|
||||||
'status': 'error',
|
|
||||||
'message': 'VDL2 decoder not running'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
app_module.vdl2_process.terminate()
|
app_module.vdl2_process.terminate()
|
||||||
|
|||||||
+12
-18
@@ -10,6 +10,7 @@ import queue
|
|||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response, send_file
|
from flask import Blueprint, jsonify, request, Response, send_file
|
||||||
|
|
||||||
|
from utils.responses import api_success, api_error
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.sse import sse_stream
|
from utils.sse import sse_stream
|
||||||
from utils.validation import validate_device_index, validate_gain, validate_latitude, validate_longitude, validate_elevation, validate_rtl_tcp_host, validate_rtl_tcp_port
|
from utils.validation import validate_device_index, validate_gain, validate_latitude, validate_longitude, validate_elevation, validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||||
@@ -174,7 +175,7 @@ def start_capture():
|
|||||||
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
|
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
|
||||||
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
|
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return api_error(str(e), 400)
|
||||||
|
|
||||||
# Claim SDR device (skip for remote rtl_tcp)
|
# Claim SDR device (skip for remote rtl_tcp)
|
||||||
if not rtl_tcp_host:
|
if not rtl_tcp_host:
|
||||||
@@ -182,11 +183,7 @@ def start_capture():
|
|||||||
import app as app_module
|
import app as app_module
|
||||||
error = app_module.claim_sdr_device(device_index, 'weather_sat', sdr_type_str)
|
error = app_module.claim_sdr_device(device_index, 'weather_sat', sdr_type_str)
|
||||||
if error:
|
if error:
|
||||||
return jsonify({
|
return api_error(error, 409, error_type='DEVICE_BUSY')
|
||||||
'status': 'error',
|
|
||||||
'error_type': 'DEVICE_BUSY',
|
|
||||||
'message': error,
|
|
||||||
}), 409
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -417,15 +414,15 @@ def get_image(filename: str):
|
|||||||
|
|
||||||
# Security: only allow safe filenames
|
# Security: only allow safe filenames
|
||||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
return api_error('Invalid filename', 400)
|
||||||
|
|
||||||
if not (filename.endswith('.png') or filename.endswith('.jpg') or filename.endswith('.jpeg')):
|
if not (filename.endswith('.png') or filename.endswith('.jpg') or filename.endswith('.jpeg')):
|
||||||
return jsonify({'status': 'error', 'message': 'Only PNG/JPG files supported'}), 400
|
return api_error('Only PNG/JPG files supported', 400)
|
||||||
|
|
||||||
image_path = decoder._output_dir / filename
|
image_path = decoder._output_dir / filename
|
||||||
|
|
||||||
if not image_path.exists():
|
if not image_path.exists():
|
||||||
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
return api_error('Image not found', 404)
|
||||||
|
|
||||||
mimetype = 'image/png' if filename.endswith('.png') else 'image/jpeg'
|
mimetype = 'image/png' if filename.endswith('.png') else 'image/jpeg'
|
||||||
return send_file(image_path, mimetype=mimetype)
|
return send_file(image_path, mimetype=mimetype)
|
||||||
@@ -444,12 +441,12 @@ def delete_image(filename: str):
|
|||||||
decoder = get_weather_sat_decoder()
|
decoder = get_weather_sat_decoder()
|
||||||
|
|
||||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
return api_error('Invalid filename', 400)
|
||||||
|
|
||||||
if decoder.delete_image(filename):
|
if decoder.delete_image(filename):
|
||||||
return jsonify({'status': 'deleted', 'filename': filename})
|
return jsonify({'status': 'deleted', 'filename': filename})
|
||||||
else:
|
else:
|
||||||
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
return api_error('Image not found', 404)
|
||||||
|
|
||||||
|
|
||||||
@weather_sat_bp.route('/images', methods=['DELETE'])
|
@weather_sat_bp.route('/images', methods=['DELETE'])
|
||||||
@@ -500,17 +497,14 @@ def get_passes():
|
|||||||
raw_lon = request.args.get('longitude')
|
raw_lon = request.args.get('longitude')
|
||||||
|
|
||||||
if raw_lat is None or raw_lon is None:
|
if raw_lat is None or raw_lon is None:
|
||||||
return jsonify({
|
return api_error('latitude and longitude parameters required', 400)
|
||||||
'status': 'error',
|
|
||||||
'message': 'latitude and longitude parameters required'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
lat = validate_latitude(raw_lat)
|
lat = validate_latitude(raw_lat)
|
||||||
lon = validate_longitude(raw_lon)
|
lon = validate_longitude(raw_lon)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logger.warning('Invalid coordinates in get_passes: %s', e)
|
logger.warning('Invalid coordinates in get_passes: %s', e)
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid coordinates'}), 400
|
return api_error('Invalid coordinates', 400)
|
||||||
|
|
||||||
hours = max(1, min(request.args.get('hours', 24, type=int), 72))
|
hours = max(1, min(request.args.get('hours', 24, type=int), 72))
|
||||||
min_elevation = max(0, min(request.args.get('min_elevation', 15, type=float), 90))
|
min_elevation = max(0, min(request.args.get('min_elevation', 15, type=float), 90))
|
||||||
@@ -668,10 +662,10 @@ def skip_pass(pass_id: str):
|
|||||||
from utils.weather_sat_scheduler import get_weather_sat_scheduler
|
from utils.weather_sat_scheduler import get_weather_sat_scheduler
|
||||||
|
|
||||||
if not pass_id.replace('_', '').replace('-', '').isalnum():
|
if not pass_id.replace('_', '').replace('-', '').isalnum():
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid pass ID'}), 400
|
return api_error('Invalid pass ID', 400)
|
||||||
|
|
||||||
scheduler = get_weather_sat_scheduler()
|
scheduler = get_weather_sat_scheduler()
|
||||||
if scheduler.skip_pass(pass_id):
|
if scheduler.skip_pass(pass_id):
|
||||||
return jsonify({'status': 'skipped', 'pass_id': pass_id})
|
return jsonify({'status': 'skipped', 'pass_id': pass_id})
|
||||||
else:
|
else:
|
||||||
return jsonify({'status': 'error', 'message': 'Pass not found or already processed'}), 404
|
return api_error('Pass not found or already processed', 404)
|
||||||
|
|||||||
+9
-12
@@ -13,6 +13,8 @@ from typing import Optional
|
|||||||
|
|
||||||
from flask import Blueprint, Flask, jsonify, request, Response
|
from flask import Blueprint, Flask, jsonify, request, Response
|
||||||
|
|
||||||
|
from utils.responses import api_success, api_error
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from flask_sock import Sock
|
from flask_sock import Sock
|
||||||
WEBSOCKET_AVAILABLE = True
|
WEBSOCKET_AVAILABLE = True
|
||||||
@@ -226,8 +228,7 @@ def list_receivers() -> Response:
|
|||||||
if r.get('freq_lo', 0) <= freq_khz <= r.get('freq_hi', 30000)
|
if r.get('freq_lo', 0) <= freq_khz <= r.get('freq_hi', 30000)
|
||||||
]
|
]
|
||||||
|
|
||||||
return jsonify({
|
return api_success(data={
|
||||||
'status': 'success',
|
|
||||||
'receivers': filtered[:100],
|
'receivers': filtered[:100],
|
||||||
'total': len(filtered),
|
'total': len(filtered),
|
||||||
'cached_total': len(receivers),
|
'cached_total': len(receivers),
|
||||||
@@ -242,7 +243,7 @@ def nearest_receivers() -> Response:
|
|||||||
freq_khz = request.args.get('freq_khz', type=float)
|
freq_khz = request.args.get('freq_khz', type=float)
|
||||||
|
|
||||||
if lat is None or lon is None:
|
if lat is None or lon is None:
|
||||||
return jsonify({'status': 'error', 'message': 'lat and lon are required'}), 400
|
return api_error('lat and lon are required', 400)
|
||||||
|
|
||||||
receivers = get_receivers()
|
receivers = get_receivers()
|
||||||
|
|
||||||
@@ -264,10 +265,7 @@ def nearest_receivers() -> Response:
|
|||||||
|
|
||||||
with_distance.sort(key=lambda x: x['distance_km'])
|
with_distance.sort(key=lambda x: x['distance_km'])
|
||||||
|
|
||||||
return jsonify({
|
return api_success(data={'receivers': with_distance[:10]})
|
||||||
'status': 'success',
|
|
||||||
'receivers': with_distance[:10],
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@websdr_bp.route('/spy-station/<station_id>/receivers')
|
@websdr_bp.route('/spy-station/<station_id>/receivers')
|
||||||
@@ -276,7 +274,7 @@ def spy_station_receivers(station_id: str) -> Response:
|
|||||||
try:
|
try:
|
||||||
from routes.spy_stations import STATIONS
|
from routes.spy_stations import STATIONS
|
||||||
except ImportError:
|
except ImportError:
|
||||||
return jsonify({'status': 'error', 'message': 'Spy stations module not available'}), 503
|
return api_error('Spy stations module not available', 503)
|
||||||
|
|
||||||
# Find the station
|
# Find the station
|
||||||
station = None
|
station = None
|
||||||
@@ -286,7 +284,7 @@ def spy_station_receivers(station_id: str) -> Response:
|
|||||||
break
|
break
|
||||||
|
|
||||||
if not station:
|
if not station:
|
||||||
return jsonify({'status': 'error', 'message': 'Station not found'}), 404
|
return api_error('Station not found', 404)
|
||||||
|
|
||||||
# Get primary frequency
|
# Get primary frequency
|
||||||
freq_khz = None
|
freq_khz = None
|
||||||
@@ -298,7 +296,7 @@ def spy_station_receivers(station_id: str) -> Response:
|
|||||||
freq_khz = station['frequencies'][0].get('freq_khz')
|
freq_khz = station['frequencies'][0].get('freq_khz')
|
||||||
|
|
||||||
if freq_khz is None:
|
if freq_khz is None:
|
||||||
return jsonify({'status': 'error', 'message': 'No frequency found for station'}), 404
|
return api_error('No frequency found for station', 404)
|
||||||
|
|
||||||
receivers = get_receivers()
|
receivers = get_receivers()
|
||||||
|
|
||||||
@@ -308,8 +306,7 @@ def spy_station_receivers(station_id: str) -> Response:
|
|||||||
if r.get('freq_lo', 0) <= freq_khz <= r.get('freq_hi', 30000) and r.get('available', True)
|
if r.get('freq_lo', 0) <= freq_khz <= r.get('freq_hi', 30000) and r.get('available', True)
|
||||||
]
|
]
|
||||||
|
|
||||||
return jsonify({
|
return api_success(data={
|
||||||
'status': 'success',
|
|
||||||
'station': {
|
'station': {
|
||||||
'id': station['id'],
|
'id': station['id'],
|
||||||
'name': station.get('name', ''),
|
'name': station.get('name', ''),
|
||||||
|
|||||||
+22
-67
@@ -10,6 +10,7 @@ import queue
|
|||||||
|
|
||||||
from flask import Blueprint, Response, jsonify, request, send_file
|
from flask import Blueprint, Response, jsonify, request, send_file
|
||||||
|
|
||||||
|
from utils.responses import api_success, api_error
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.sdr import SDRType
|
from utils.sdr import SDRType
|
||||||
@@ -109,10 +110,7 @@ def start_decoder():
|
|||||||
# Validate frequency (required)
|
# Validate frequency (required)
|
||||||
frequency_khz = data.get('frequency_khz')
|
frequency_khz = data.get('frequency_khz')
|
||||||
if frequency_khz is None:
|
if frequency_khz is None:
|
||||||
return jsonify({
|
return api_error('frequency_khz is required', 400)
|
||||||
'status': 'error',
|
|
||||||
'message': 'frequency_khz is required',
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
frequency_khz = float(frequency_khz)
|
frequency_khz = float(frequency_khz)
|
||||||
@@ -120,10 +118,7 @@ def start_decoder():
|
|||||||
freq_mhz = frequency_khz / 1000.0
|
freq_mhz = frequency_khz / 1000.0
|
||||||
validate_frequency(freq_mhz, min_mhz=2.0, max_mhz=30.0)
|
validate_frequency(freq_mhz, min_mhz=2.0, max_mhz=30.0)
|
||||||
except (TypeError, ValueError) as e:
|
except (TypeError, ValueError) as e:
|
||||||
return jsonify({
|
return api_error(f'Invalid frequency: {e}', 400)
|
||||||
'status': 'error',
|
|
||||||
'message': f'Invalid frequency: {e}',
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
station = str(data.get('station', '')).strip()
|
station = str(data.get('station', '')).strip()
|
||||||
device_index = data.get('device', 0)
|
device_index = data.get('device', 0)
|
||||||
@@ -152,34 +147,21 @@ def start_decoder():
|
|||||||
tuned_mhz = tuned_frequency_khz / 1000.0
|
tuned_mhz = tuned_frequency_khz / 1000.0
|
||||||
validate_frequency(tuned_mhz, min_mhz=2.0, max_mhz=30.0)
|
validate_frequency(tuned_mhz, min_mhz=2.0, max_mhz=30.0)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({
|
return api_error(f'Invalid frequency settings: {e}', 400)
|
||||||
'status': 'error',
|
|
||||||
'message': f'Invalid frequency settings: {e}',
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
# Validate IOC and LPM
|
# Validate IOC and LPM
|
||||||
if ioc not in (288, 576):
|
if ioc not in (288, 576):
|
||||||
return jsonify({
|
return api_error('IOC must be 288 or 576', 400)
|
||||||
'status': 'error',
|
|
||||||
'message': 'IOC must be 288 or 576',
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
if lpm not in (60, 120):
|
if lpm not in (60, 120):
|
||||||
return jsonify({
|
return api_error('LPM must be 60 or 120', 400)
|
||||||
'status': 'error',
|
|
||||||
'message': 'LPM must be 60 or 120',
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
# Claim SDR device
|
# Claim SDR device
|
||||||
global wefax_active_device, wefax_active_sdr_type
|
global wefax_active_device, wefax_active_sdr_type
|
||||||
device_int = int(device_index)
|
device_int = int(device_index)
|
||||||
error = app_module.claim_sdr_device(device_int, 'wefax', sdr_type_str)
|
error = app_module.claim_sdr_device(device_int, 'wefax', sdr_type_str)
|
||||||
if error:
|
if error:
|
||||||
return jsonify({
|
return api_error(error, 409, error_type='DEVICE_BUSY')
|
||||||
'status': 'error',
|
|
||||||
'error_type': 'DEVICE_BUSY',
|
|
||||||
'message': error,
|
|
||||||
}), 409
|
|
||||||
|
|
||||||
# Set callback and start
|
# Set callback and start
|
||||||
decoder.set_callback(_progress_callback)
|
decoder.set_callback(_progress_callback)
|
||||||
@@ -213,10 +195,7 @@ def start_decoder():
|
|||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||||
return jsonify({
|
return api_error('Failed to start decoder', 500)
|
||||||
'status': 'error',
|
|
||||||
'message': 'Failed to start decoder',
|
|
||||||
}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@wefax_bp.route('/stop', methods=['POST'])
|
@wefax_bp.route('/stop', methods=['POST'])
|
||||||
@@ -275,14 +254,14 @@ def get_image(filename: str):
|
|||||||
decoder = get_wefax_decoder()
|
decoder = get_wefax_decoder()
|
||||||
|
|
||||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
return api_error('Invalid filename', 400)
|
||||||
|
|
||||||
if not filename.endswith('.png'):
|
if not filename.endswith('.png'):
|
||||||
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
|
return api_error('Only PNG files supported', 400)
|
||||||
|
|
||||||
image_path = decoder._output_dir / filename
|
image_path = decoder._output_dir / filename
|
||||||
if not image_path.exists():
|
if not image_path.exists():
|
||||||
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
return api_error('Image not found', 404)
|
||||||
|
|
||||||
return send_file(image_path, mimetype='image/png')
|
return send_file(image_path, mimetype='image/png')
|
||||||
|
|
||||||
@@ -293,15 +272,15 @@ def delete_image(filename: str):
|
|||||||
decoder = get_wefax_decoder()
|
decoder = get_wefax_decoder()
|
||||||
|
|
||||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
return api_error('Invalid filename', 400)
|
||||||
|
|
||||||
if not filename.endswith('.png'):
|
if not filename.endswith('.png'):
|
||||||
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
|
return api_error('Only PNG files supported', 400)
|
||||||
|
|
||||||
if decoder.delete_image(filename):
|
if decoder.delete_image(filename):
|
||||||
return jsonify({'status': 'ok'})
|
return jsonify({'status': 'ok'})
|
||||||
else:
|
else:
|
||||||
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
return api_error('Image not found', 404)
|
||||||
|
|
||||||
|
|
||||||
@wefax_bp.route('/images', methods=['DELETE'])
|
@wefax_bp.route('/images', methods=['DELETE'])
|
||||||
@@ -354,27 +333,18 @@ def enable_schedule():
|
|||||||
|
|
||||||
station = str(data.get('station', '')).strip()
|
station = str(data.get('station', '')).strip()
|
||||||
if not station:
|
if not station:
|
||||||
return jsonify({
|
return api_error('station is required', 400)
|
||||||
'status': 'error',
|
|
||||||
'message': 'station is required',
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
frequency_khz = data.get('frequency_khz')
|
frequency_khz = data.get('frequency_khz')
|
||||||
if frequency_khz is None:
|
if frequency_khz is None:
|
||||||
return jsonify({
|
return api_error('frequency_khz is required', 400)
|
||||||
'status': 'error',
|
|
||||||
'message': 'frequency_khz is required',
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
frequency_khz = float(frequency_khz)
|
frequency_khz = float(frequency_khz)
|
||||||
freq_mhz = frequency_khz / 1000.0
|
freq_mhz = frequency_khz / 1000.0
|
||||||
validate_frequency(freq_mhz, min_mhz=2.0, max_mhz=30.0)
|
validate_frequency(freq_mhz, min_mhz=2.0, max_mhz=30.0)
|
||||||
except (TypeError, ValueError) as e:
|
except (TypeError, ValueError) as e:
|
||||||
return jsonify({
|
return api_error(f'Invalid frequency: {e}', 400)
|
||||||
'status': 'error',
|
|
||||||
'message': f'Invalid frequency: {e}',
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
device = int(data.get('device', 0))
|
device = int(data.get('device', 0))
|
||||||
gain = float(data.get('gain', 40.0))
|
gain = float(data.get('gain', 40.0))
|
||||||
@@ -396,10 +366,7 @@ def enable_schedule():
|
|||||||
tuned_mhz = tuned_frequency_khz / 1000.0
|
tuned_mhz = tuned_frequency_khz / 1000.0
|
||||||
validate_frequency(tuned_mhz, min_mhz=2.0, max_mhz=30.0)
|
validate_frequency(tuned_mhz, min_mhz=2.0, max_mhz=30.0)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({
|
return api_error(f'Invalid frequency settings: {e}', 400)
|
||||||
'status': 'error',
|
|
||||||
'message': f'Invalid frequency settings: {e}',
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
scheduler = get_wefax_scheduler()
|
scheduler = get_wefax_scheduler()
|
||||||
scheduler.set_callbacks(_progress_callback, _scheduler_event_callback)
|
scheduler.set_callbacks(_progress_callback, _scheduler_event_callback)
|
||||||
@@ -416,10 +383,7 @@ def enable_schedule():
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to enable WeFax scheduler")
|
logger.exception("Failed to enable WeFax scheduler")
|
||||||
return jsonify({
|
return api_error('Failed to enable scheduler', 500)
|
||||||
'status': 'error',
|
|
||||||
'message': 'Failed to enable scheduler',
|
|
||||||
}), 500
|
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'ok',
|
'status': 'ok',
|
||||||
@@ -473,19 +437,13 @@ def skip_broadcast(broadcast_id: str):
|
|||||||
from utils.wefax_scheduler import get_wefax_scheduler
|
from utils.wefax_scheduler import get_wefax_scheduler
|
||||||
|
|
||||||
if not broadcast_id.replace('_', '').replace('-', '').isalnum():
|
if not broadcast_id.replace('_', '').replace('-', '').isalnum():
|
||||||
return jsonify({
|
return api_error('Invalid broadcast ID', 400)
|
||||||
'status': 'error',
|
|
||||||
'message': 'Invalid broadcast ID',
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
scheduler = get_wefax_scheduler()
|
scheduler = get_wefax_scheduler()
|
||||||
if scheduler.skip_broadcast(broadcast_id):
|
if scheduler.skip_broadcast(broadcast_id):
|
||||||
return jsonify({'status': 'skipped', 'broadcast_id': broadcast_id})
|
return jsonify({'status': 'skipped', 'broadcast_id': broadcast_id})
|
||||||
else:
|
else:
|
||||||
return jsonify({
|
return api_error('Broadcast not found or already processed', 404)
|
||||||
'status': 'error',
|
|
||||||
'message': 'Broadcast not found or already processed',
|
|
||||||
}), 404
|
|
||||||
|
|
||||||
|
|
||||||
@wefax_bp.route('/stations')
|
@wefax_bp.route('/stations')
|
||||||
@@ -504,10 +462,7 @@ def station_detail(callsign: str):
|
|||||||
"""Get station detail including current schedule info."""
|
"""Get station detail including current schedule info."""
|
||||||
station = get_station(callsign)
|
station = get_station(callsign)
|
||||||
if not station:
|
if not station:
|
||||||
return jsonify({
|
return api_error(f'Station {callsign} not found', 404)
|
||||||
'status': 'error',
|
|
||||||
'message': f'Station {callsign} not found',
|
|
||||||
}), 404
|
|
||||||
|
|
||||||
current = get_current_broadcasts(callsign)
|
current = get_current_broadcasts(callsign)
|
||||||
|
|
||||||
|
|||||||
+212
-200
@@ -15,14 +15,15 @@ from typing import Any, Generator
|
|||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
|
from utils.responses import api_success, api_error
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.dependencies import check_tool, get_tool_path
|
from utils.dependencies import check_tool, get_tool_path
|
||||||
from utils.logging import wifi_logger as logger
|
from utils.logging import wifi_logger as logger
|
||||||
from utils.process import is_valid_mac, is_valid_channel
|
from utils.process import is_valid_mac, is_valid_channel
|
||||||
from utils.validation import validate_wifi_channel, validate_mac_address, validate_network_interface
|
from utils.validation import validate_wifi_channel, validate_mac_address, validate_network_interface
|
||||||
from utils.sse import format_sse, sse_stream_fanout
|
from utils.sse import format_sse, sse_stream_fanout
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
from data.oui import get_manufacturer
|
from data.oui import get_manufacturer
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
WIFI_TERMINATE_TIMEOUT,
|
WIFI_TERMINATE_TIMEOUT,
|
||||||
PMKID_TERMINATE_TIMEOUT,
|
PMKID_TERMINATE_TIMEOUT,
|
||||||
@@ -46,34 +47,52 @@ from utils.constants import (
|
|||||||
|
|
||||||
wifi_bp = Blueprint('wifi', __name__, url_prefix='/wifi')
|
wifi_bp = Blueprint('wifi', __name__, url_prefix='/wifi')
|
||||||
|
|
||||||
|
# --- v1 deprecation ---
|
||||||
|
# These endpoints are deprecated in favor of /wifi/v2/*.
|
||||||
|
# Frontend still uses v1, so they remain active.
|
||||||
|
# Migration: switch frontend to v2 endpoints, then remove this file.
|
||||||
|
_v1_deprecation_logged = set()
|
||||||
|
|
||||||
|
|
||||||
|
@wifi_bp.after_request
|
||||||
|
def _add_deprecation_header(response):
|
||||||
|
"""Add X-Deprecated header to all v1 WiFi responses."""
|
||||||
|
response.headers['X-Deprecated'] = 'Use /wifi/v2/* endpoints instead'
|
||||||
|
endpoint = request.endpoint or ''
|
||||||
|
if endpoint not in _v1_deprecation_logged:
|
||||||
|
_v1_deprecation_logged.add(endpoint)
|
||||||
|
logger.warning(f"Deprecated v1 WiFi endpoint called: {request.path} — migrate to /wifi/v2/*")
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
# PMKID process state
|
# PMKID process state
|
||||||
pmkid_process = None
|
pmkid_process = None
|
||||||
pmkid_lock = threading.Lock()
|
pmkid_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def _parse_channel_list(raw_channels: Any) -> list[int] | None:
|
def _parse_channel_list(raw_channels: Any) -> list[int] | None:
|
||||||
"""Parse a channel list from string/list input."""
|
"""Parse a channel list from string/list input."""
|
||||||
if raw_channels in (None, '', []):
|
if raw_channels in (None, '', []):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if isinstance(raw_channels, str):
|
if isinstance(raw_channels, str):
|
||||||
parts = [p.strip() for p in re.split(r'[\s,]+', raw_channels) if p.strip()]
|
parts = [p.strip() for p in re.split(r'[\s,]+', raw_channels) if p.strip()]
|
||||||
elif isinstance(raw_channels, (list, tuple, set)):
|
elif isinstance(raw_channels, (list, tuple, set)):
|
||||||
parts = list(raw_channels)
|
parts = list(raw_channels)
|
||||||
else:
|
else:
|
||||||
parts = [raw_channels]
|
parts = [raw_channels]
|
||||||
|
|
||||||
channels: list[int] = []
|
channels: list[int] = []
|
||||||
seen = set()
|
seen = set()
|
||||||
for part in parts:
|
for part in parts:
|
||||||
if part in (None, ''):
|
if part in (None, ''):
|
||||||
continue
|
continue
|
||||||
ch = validate_wifi_channel(part)
|
ch = validate_wifi_channel(part)
|
||||||
if ch not in seen:
|
if ch not in seen:
|
||||||
channels.append(ch)
|
channels.append(ch)
|
||||||
seen.add(ch)
|
seen.add(ch)
|
||||||
|
|
||||||
return channels or None
|
return channels or None
|
||||||
|
|
||||||
|
|
||||||
def detect_wifi_interfaces():
|
def detect_wifi_interfaces():
|
||||||
@@ -455,7 +474,7 @@ def toggle_monitor_mode():
|
|||||||
try:
|
try:
|
||||||
interface = validate_network_interface(data.get('interface'))
|
interface = validate_network_interface(data.get('interface'))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return api_error(str(e), 400)
|
||||||
|
|
||||||
if action == 'start':
|
if action == 'start':
|
||||||
if check_tool('airmon-ng'):
|
if check_tool('airmon-ng'):
|
||||||
@@ -575,20 +594,17 @@ def toggle_monitor_mode():
|
|||||||
all_wireless = [f for f in os.listdir('/sys/class/net')
|
all_wireless = [f for f in os.listdir('/sys/class/net')
|
||||||
if os.path.exists(f'/sys/class/net/{f}/wireless') or 'mon' in f or f.startswith('wl')]
|
if os.path.exists(f'/sys/class/net/{f}/wireless') or 'mon' in f or f.startswith('wl')]
|
||||||
logger.error(f"Monitor interface not found. Tried: {monitor_iface}. Available: {all_wireless}")
|
logger.error(f"Monitor interface not found. Tried: {monitor_iface}. Available: {all_wireless}")
|
||||||
return jsonify({
|
return api_error(f'Monitor interface not created. airmon-ng output: {output[:500]}. Available interfaces: {all_wireless}')
|
||||||
'status': 'error',
|
|
||||||
'message': f'Monitor interface not created. airmon-ng output: {output[:500]}. Available interfaces: {all_wireless}'
|
|
||||||
})
|
|
||||||
|
|
||||||
app_module.wifi_monitor_interface = monitor_iface
|
app_module.wifi_monitor_interface = monitor_iface
|
||||||
app_module.wifi_queue.put({'type': 'info', 'text': f'Monitor mode enabled on {app_module.wifi_monitor_interface}'})
|
app_module.wifi_queue.put({'type': 'info', 'text': f'Monitor mode enabled on {app_module.wifi_monitor_interface}'})
|
||||||
logger.info(f"Monitor mode enabled on {monitor_iface}")
|
logger.info(f"Monitor mode enabled on {monitor_iface}")
|
||||||
return jsonify({'status': 'success', 'monitor_interface': app_module.wifi_monitor_interface})
|
return api_success(data={'monitor_interface': app_module.wifi_monitor_interface})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import traceback
|
import traceback
|
||||||
logger.error(f"Error enabling monitor mode: {e}", exc_info=True)
|
logger.error(f"Error enabling monitor mode: {e}", exc_info=True)
|
||||||
return jsonify({'status': 'error', 'message': str(e)})
|
return api_error(str(e))
|
||||||
|
|
||||||
elif check_tool('iw'):
|
elif check_tool('iw'):
|
||||||
try:
|
try:
|
||||||
@@ -596,11 +612,11 @@ def toggle_monitor_mode():
|
|||||||
subprocess.run(['iw', interface, 'set', 'monitor', 'control'], capture_output=True)
|
subprocess.run(['iw', interface, 'set', 'monitor', 'control'], capture_output=True)
|
||||||
subprocess.run(['ip', 'link', 'set', interface, 'up'], capture_output=True)
|
subprocess.run(['ip', 'link', 'set', interface, 'up'], capture_output=True)
|
||||||
app_module.wifi_monitor_interface = interface
|
app_module.wifi_monitor_interface = interface
|
||||||
return jsonify({'status': 'success', 'monitor_interface': interface})
|
return api_success(data={'monitor_interface': interface})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)})
|
return api_error(str(e))
|
||||||
else:
|
else:
|
||||||
return jsonify({'status': 'error', 'message': 'No monitor mode tools available.'})
|
return api_error('No monitor mode tools available.')
|
||||||
|
|
||||||
else: # stop
|
else: # stop
|
||||||
if check_tool('airmon-ng'):
|
if check_tool('airmon-ng'):
|
||||||
@@ -609,20 +625,20 @@ def toggle_monitor_mode():
|
|||||||
subprocess.run([airmon_path, 'stop', app_module.wifi_monitor_interface or interface],
|
subprocess.run([airmon_path, 'stop', app_module.wifi_monitor_interface or interface],
|
||||||
capture_output=True, text=True, timeout=15)
|
capture_output=True, text=True, timeout=15)
|
||||||
app_module.wifi_monitor_interface = None
|
app_module.wifi_monitor_interface = None
|
||||||
return jsonify({'status': 'success', 'message': 'Monitor mode disabled'})
|
return api_success(message='Monitor mode disabled')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)})
|
return api_error(str(e))
|
||||||
elif check_tool('iw'):
|
elif check_tool('iw'):
|
||||||
try:
|
try:
|
||||||
subprocess.run(['ip', 'link', 'set', interface, 'down'], capture_output=True)
|
subprocess.run(['ip', 'link', 'set', interface, 'down'], capture_output=True)
|
||||||
subprocess.run(['iw', interface, 'set', 'type', 'managed'], capture_output=True)
|
subprocess.run(['iw', interface, 'set', 'type', 'managed'], capture_output=True)
|
||||||
subprocess.run(['ip', 'link', 'set', interface, 'up'], capture_output=True)
|
subprocess.run(['ip', 'link', 'set', interface, 'up'], capture_output=True)
|
||||||
app_module.wifi_monitor_interface = None
|
app_module.wifi_monitor_interface = None
|
||||||
return jsonify({'status': 'success', 'message': 'Monitor mode disabled'})
|
return api_success(message='Monitor mode disabled')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)})
|
return api_error(str(e))
|
||||||
|
|
||||||
return jsonify({'status': 'error', 'message': 'Unknown action'})
|
return api_error('Unknown action')
|
||||||
|
|
||||||
|
|
||||||
@wifi_bp.route('/scan/start', methods=['POST'])
|
@wifi_bp.route('/scan/start', methods=['POST'])
|
||||||
@@ -630,12 +646,12 @@ def start_wifi_scan():
|
|||||||
"""Start WiFi scanning with airodump-ng."""
|
"""Start WiFi scanning with airodump-ng."""
|
||||||
with app_module.wifi_lock:
|
with app_module.wifi_lock:
|
||||||
if app_module.wifi_process:
|
if app_module.wifi_process:
|
||||||
return jsonify({'status': 'error', 'message': 'Scan already running'})
|
return api_error('Scan already running')
|
||||||
|
|
||||||
data = request.json
|
data = request.json
|
||||||
channel = data.get('channel')
|
channel = data.get('channel')
|
||||||
channels = data.get('channels')
|
channels = data.get('channels')
|
||||||
band = data.get('band', 'abg')
|
band = data.get('band', 'abg')
|
||||||
|
|
||||||
# Use provided interface or fall back to stored monitor interface
|
# Use provided interface or fall back to stored monitor interface
|
||||||
interface = data.get('interface')
|
interface = data.get('interface')
|
||||||
@@ -643,21 +659,18 @@ def start_wifi_scan():
|
|||||||
try:
|
try:
|
||||||
interface = validate_network_interface(interface)
|
interface = validate_network_interface(interface)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return api_error(str(e), 400)
|
||||||
else:
|
else:
|
||||||
interface = app_module.wifi_monitor_interface
|
interface = app_module.wifi_monitor_interface
|
||||||
|
|
||||||
if not interface:
|
if not interface:
|
||||||
return jsonify({'status': 'error', 'message': 'No monitor interface available.'})
|
return api_error('No monitor interface available.')
|
||||||
|
|
||||||
# Verify interface exists
|
# Verify interface exists
|
||||||
if not os.path.exists(f'/sys/class/net/{interface}'):
|
if not os.path.exists(f'/sys/class/net/{interface}'):
|
||||||
all_wireless = [f for f in os.listdir('/sys/class/net')
|
all_wireless = [f for f in os.listdir('/sys/class/net')
|
||||||
if os.path.exists(f'/sys/class/net/{f}/wireless') or 'mon' in f or f.startswith('wl')]
|
if os.path.exists(f'/sys/class/net/{f}/wireless') or 'mon' in f or f.startswith('wl')]
|
||||||
return jsonify({
|
return api_error(f'Interface "{interface}" does not exist. Available: {all_wireless}')
|
||||||
'status': 'error',
|
|
||||||
'message': f'Interface "{interface}" does not exist. Available: {all_wireless}'
|
|
||||||
})
|
|
||||||
|
|
||||||
app_module.wifi_networks = {}
|
app_module.wifi_networks = {}
|
||||||
app_module.wifi_clients = {}
|
app_module.wifi_clients = {}
|
||||||
@@ -685,17 +698,17 @@ def start_wifi_scan():
|
|||||||
interface
|
interface
|
||||||
]
|
]
|
||||||
|
|
||||||
channel_list = None
|
channel_list = None
|
||||||
if channels:
|
if channels:
|
||||||
try:
|
try:
|
||||||
channel_list = _parse_channel_list(channels)
|
channel_list = _parse_channel_list(channels)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return api_error(str(e), 400)
|
||||||
|
|
||||||
if channel_list:
|
if channel_list:
|
||||||
cmd.extend(['-c', ','.join(str(c) for c in channel_list)])
|
cmd.extend(['-c', ','.join(str(c) for c in channel_list)])
|
||||||
elif channel:
|
elif channel:
|
||||||
cmd.extend(['-c', str(channel)])
|
cmd.extend(['-c', str(channel)])
|
||||||
|
|
||||||
logger.info(f"Running: {' '.join(cmd)}")
|
logger.info(f"Running: {' '.join(cmd)}")
|
||||||
|
|
||||||
@@ -723,7 +736,7 @@ def start_wifi_scan():
|
|||||||
error_msg = 'Permission denied. Try running with sudo.'
|
error_msg = 'Permission denied. Try running with sudo.'
|
||||||
|
|
||||||
logger.error(f"airodump-ng failed for interface '{interface}': {error_msg}")
|
logger.error(f"airodump-ng failed for interface '{interface}': {error_msg}")
|
||||||
return jsonify({'status': 'error', 'message': error_msg, 'interface': interface})
|
return api_error(error_msg)
|
||||||
|
|
||||||
thread = threading.Thread(target=stream_airodump_output, args=(app_module.wifi_process, csv_path))
|
thread = threading.Thread(target=stream_airodump_output, args=(app_module.wifi_process, csv_path))
|
||||||
thread.daemon = True
|
thread.daemon = True
|
||||||
@@ -734,9 +747,9 @@ def start_wifi_scan():
|
|||||||
return jsonify({'status': 'started', 'interface': interface})
|
return jsonify({'status': 'started', 'interface': interface})
|
||||||
|
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return jsonify({'status': 'error', 'message': 'airodump-ng not found.'})
|
return api_error('airodump-ng not found.')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)})
|
return api_error(str(e))
|
||||||
|
|
||||||
|
|
||||||
@wifi_bp.route('/scan/stop', methods=['POST'])
|
@wifi_bp.route('/scan/stop', methods=['POST'])
|
||||||
@@ -768,18 +781,18 @@ def send_deauth():
|
|||||||
try:
|
try:
|
||||||
interface = validate_network_interface(interface)
|
interface = validate_network_interface(interface)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return api_error(str(e), 400)
|
||||||
else:
|
else:
|
||||||
interface = app_module.wifi_monitor_interface
|
interface = app_module.wifi_monitor_interface
|
||||||
|
|
||||||
if not target_bssid:
|
if not target_bssid:
|
||||||
return jsonify({'status': 'error', 'message': 'Target BSSID required'})
|
return api_error('Target BSSID required')
|
||||||
|
|
||||||
if not is_valid_mac(target_bssid):
|
if not is_valid_mac(target_bssid):
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid BSSID format'})
|
return api_error('Invalid BSSID format')
|
||||||
|
|
||||||
if not is_valid_mac(target_client):
|
if not is_valid_mac(target_client):
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid client MAC format'})
|
return api_error('Invalid client MAC format')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
count = int(count)
|
count = int(count)
|
||||||
@@ -789,10 +802,10 @@ def send_deauth():
|
|||||||
count = 5
|
count = 5
|
||||||
|
|
||||||
if not interface:
|
if not interface:
|
||||||
return jsonify({'status': 'error', 'message': 'No monitor interface'})
|
return api_error('No monitor interface')
|
||||||
|
|
||||||
if not check_tool('aireplay-ng'):
|
if not check_tool('aireplay-ng'):
|
||||||
return jsonify({'status': 'error', 'message': 'aireplay-ng not found'})
|
return api_error('aireplay-ng not found')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
aireplay_path = get_tool_path('aireplay-ng')
|
aireplay_path = get_tool_path('aireplay-ng')
|
||||||
@@ -809,14 +822,14 @@ def send_deauth():
|
|||||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||||
|
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
return jsonify({'status': 'success', 'message': f'Sent {count} deauth packets'})
|
return api_success(message=f'Sent {count} deauth packets')
|
||||||
else:
|
else:
|
||||||
return jsonify({'status': 'error', 'message': result.stderr})
|
return api_error(result.stderr)
|
||||||
|
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
return jsonify({'status': 'success', 'message': 'Deauth sent (timed out)'})
|
return api_success(message='Deauth sent (timed out)')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)})
|
return api_error(str(e))
|
||||||
|
|
||||||
|
|
||||||
@wifi_bp.route('/handshake/capture', methods=['POST'])
|
@wifi_bp.route('/handshake/capture', methods=['POST'])
|
||||||
@@ -832,22 +845,22 @@ def capture_handshake():
|
|||||||
try:
|
try:
|
||||||
interface = validate_network_interface(interface)
|
interface = validate_network_interface(interface)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return api_error(str(e), 400)
|
||||||
else:
|
else:
|
||||||
interface = app_module.wifi_monitor_interface
|
interface = app_module.wifi_monitor_interface
|
||||||
|
|
||||||
if not target_bssid or not channel:
|
if not target_bssid or not channel:
|
||||||
return jsonify({'status': 'error', 'message': 'BSSID and channel required'})
|
return api_error('BSSID and channel required')
|
||||||
|
|
||||||
if not is_valid_mac(target_bssid):
|
if not is_valid_mac(target_bssid):
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid BSSID format'})
|
return api_error('Invalid BSSID format')
|
||||||
|
|
||||||
if not is_valid_channel(channel):
|
if not is_valid_channel(channel):
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid channel'})
|
return api_error('Invalid channel')
|
||||||
|
|
||||||
with app_module.wifi_lock:
|
with app_module.wifi_lock:
|
||||||
if app_module.wifi_process:
|
if app_module.wifi_process:
|
||||||
return jsonify({'status': 'error', 'message': 'Scan already running.'})
|
return api_error('Scan already running.')
|
||||||
|
|
||||||
capture_path = f'/tmp/intercept_handshake_{target_bssid.replace(":", "")}'
|
capture_path = f'/tmp/intercept_handshake_{target_bssid.replace(":", "")}'
|
||||||
|
|
||||||
@@ -866,7 +879,7 @@ def capture_handshake():
|
|||||||
app_module.wifi_queue.put({'type': 'info', 'text': f'Capturing handshakes for {target_bssid}'})
|
app_module.wifi_queue.put({'type': 'info', 'text': f'Capturing handshakes for {target_bssid}'})
|
||||||
return jsonify({'status': 'started', 'capture_file': capture_path + '-01.cap'})
|
return jsonify({'status': 'started', 'capture_file': capture_path + '-01.cap'})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)})
|
return api_error(str(e))
|
||||||
|
|
||||||
|
|
||||||
@wifi_bp.route('/handshake/status', methods=['POST'])
|
@wifi_bp.route('/handshake/status', methods=['POST'])
|
||||||
@@ -877,7 +890,7 @@ def check_handshake_status():
|
|||||||
target_bssid = data.get('bssid', '')
|
target_bssid = data.get('bssid', '')
|
||||||
|
|
||||||
if not capture_file.startswith('/tmp/intercept_handshake_') or '..' in capture_file:
|
if not capture_file.startswith('/tmp/intercept_handshake_') or '..' in capture_file:
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid capture file path'})
|
return api_error('Invalid capture file path')
|
||||||
|
|
||||||
if not os.path.exists(capture_file):
|
if not os.path.exists(capture_file):
|
||||||
with app_module.wifi_lock:
|
with app_module.wifi_lock:
|
||||||
@@ -887,53 +900,53 @@ def check_handshake_status():
|
|||||||
return jsonify({'status': 'stopped', 'file_exists': False, 'handshake_found': False})
|
return jsonify({'status': 'stopped', 'file_exists': False, 'handshake_found': False})
|
||||||
|
|
||||||
file_size = os.path.getsize(capture_file)
|
file_size = os.path.getsize(capture_file)
|
||||||
handshake_found = False
|
handshake_found = False
|
||||||
handshake_valid: bool | None = None
|
handshake_valid: bool | None = None
|
||||||
handshake_checked = False
|
handshake_checked = False
|
||||||
handshake_reason: str | None = None
|
handshake_reason: str | None = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if target_bssid and is_valid_mac(target_bssid):
|
if target_bssid and is_valid_mac(target_bssid):
|
||||||
aircrack_path = get_tool_path('aircrack-ng')
|
aircrack_path = get_tool_path('aircrack-ng')
|
||||||
if aircrack_path:
|
if aircrack_path:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[aircrack_path, '-a', '2', '-b', target_bssid, capture_file],
|
[aircrack_path, '-a', '2', '-b', target_bssid, capture_file],
|
||||||
capture_output=True, text=True, timeout=10
|
capture_output=True, text=True, timeout=10
|
||||||
)
|
)
|
||||||
output = result.stdout + result.stderr
|
output = result.stdout + result.stderr
|
||||||
output_lower = output.lower()
|
output_lower = output.lower()
|
||||||
handshake_checked = True
|
handshake_checked = True
|
||||||
|
|
||||||
if 'no valid wpa handshakes found' in output_lower:
|
if 'no valid wpa handshakes found' in output_lower:
|
||||||
handshake_valid = False
|
handshake_valid = False
|
||||||
handshake_reason = 'No valid WPA handshake found'
|
handshake_reason = 'No valid WPA handshake found'
|
||||||
elif '0 handshake' in output_lower:
|
elif '0 handshake' in output_lower:
|
||||||
handshake_valid = False
|
handshake_valid = False
|
||||||
elif '1 handshake' in output_lower or ('handshake' in output_lower and 'wpa' in output_lower):
|
elif '1 handshake' in output_lower or ('handshake' in output_lower and 'wpa' in output_lower):
|
||||||
handshake_valid = True
|
handshake_valid = True
|
||||||
else:
|
else:
|
||||||
handshake_valid = False
|
handshake_valid = False
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error checking handshake: {e}")
|
logger.error(f"Error checking handshake: {e}")
|
||||||
|
|
||||||
if handshake_valid:
|
if handshake_valid:
|
||||||
handshake_found = True
|
handshake_found = True
|
||||||
normalized_bssid = target_bssid.upper() if target_bssid else None
|
normalized_bssid = target_bssid.upper() if target_bssid else None
|
||||||
if normalized_bssid and normalized_bssid not in app_module.wifi_handshakes:
|
if normalized_bssid and normalized_bssid not in app_module.wifi_handshakes:
|
||||||
app_module.wifi_handshakes.append(normalized_bssid)
|
app_module.wifi_handshakes.append(normalized_bssid)
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'running' if app_module.wifi_process and app_module.wifi_process.poll() is None else 'stopped',
|
'status': 'running' if app_module.wifi_process and app_module.wifi_process.poll() is None else 'stopped',
|
||||||
'file_exists': True,
|
'file_exists': True,
|
||||||
'file_size': file_size,
|
'file_size': file_size,
|
||||||
'file': capture_file,
|
'file': capture_file,
|
||||||
'handshake_found': handshake_found,
|
'handshake_found': handshake_found,
|
||||||
'handshake_valid': handshake_valid,
|
'handshake_valid': handshake_valid,
|
||||||
'handshake_checked': handshake_checked,
|
'handshake_checked': handshake_checked,
|
||||||
'handshake_reason': handshake_reason
|
'handshake_reason': handshake_reason
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@wifi_bp.route('/pmkid/capture', methods=['POST'])
|
@wifi_bp.route('/pmkid/capture', methods=['POST'])
|
||||||
@@ -951,19 +964,19 @@ def capture_pmkid():
|
|||||||
try:
|
try:
|
||||||
interface = validate_network_interface(interface)
|
interface = validate_network_interface(interface)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return api_error(str(e), 400)
|
||||||
else:
|
else:
|
||||||
interface = app_module.wifi_monitor_interface
|
interface = app_module.wifi_monitor_interface
|
||||||
|
|
||||||
if not target_bssid:
|
if not target_bssid:
|
||||||
return jsonify({'status': 'error', 'message': 'BSSID required'})
|
return api_error('BSSID required')
|
||||||
|
|
||||||
if not is_valid_mac(target_bssid):
|
if not is_valid_mac(target_bssid):
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid BSSID format'})
|
return api_error('Invalid BSSID format')
|
||||||
|
|
||||||
with pmkid_lock:
|
with pmkid_lock:
|
||||||
if pmkid_process and pmkid_process.poll() is None:
|
if pmkid_process and pmkid_process.poll() is None:
|
||||||
return jsonify({'status': 'error', 'message': 'PMKID capture already running'})
|
return api_error('PMKID capture already running')
|
||||||
|
|
||||||
capture_path = f'/tmp/intercept_pmkid_{target_bssid.replace(":", "")}.pcapng'
|
capture_path = f'/tmp/intercept_pmkid_{target_bssid.replace(":", "")}.pcapng'
|
||||||
filter_file = f'/tmp/pmkid_filter_{target_bssid.replace(":", "")}'
|
filter_file = f'/tmp/pmkid_filter_{target_bssid.replace(":", "")}'
|
||||||
@@ -986,9 +999,9 @@ def capture_pmkid():
|
|||||||
pmkid_process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
pmkid_process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
return jsonify({'status': 'started', 'file': capture_path})
|
return jsonify({'status': 'started', 'file': capture_path})
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return jsonify({'status': 'error', 'message': 'hcxdumptool not found.'})
|
return api_error('hcxdumptool not found.')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)})
|
return api_error(str(e))
|
||||||
|
|
||||||
|
|
||||||
@wifi_bp.route('/pmkid/status', methods=['POST'])
|
@wifi_bp.route('/pmkid/status', methods=['POST'])
|
||||||
@@ -998,7 +1011,7 @@ def check_pmkid_status():
|
|||||||
capture_file = data.get('file', '')
|
capture_file = data.get('file', '')
|
||||||
|
|
||||||
if not capture_file.startswith('/tmp/intercept_pmkid_') or '..' in capture_file:
|
if not capture_file.startswith('/tmp/intercept_pmkid_') or '..' in capture_file:
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid capture file path'})
|
return api_error('Invalid capture file path')
|
||||||
|
|
||||||
if not os.path.exists(capture_file):
|
if not os.path.exists(capture_file):
|
||||||
return jsonify({'pmkid_found': False, 'file_exists': False})
|
return jsonify({'pmkid_found': False, 'file_exists': False})
|
||||||
@@ -1054,23 +1067,23 @@ def crack_handshake():
|
|||||||
|
|
||||||
# Validate paths to prevent path traversal
|
# Validate paths to prevent path traversal
|
||||||
if not capture_file.startswith('/tmp/intercept_handshake_') or '..' in capture_file:
|
if not capture_file.startswith('/tmp/intercept_handshake_') or '..' in capture_file:
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid capture file path'}), 400
|
return api_error('Invalid capture file path', 400)
|
||||||
|
|
||||||
if '..' in wordlist:
|
if '..' in wordlist:
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid wordlist path'}), 400
|
return api_error('Invalid wordlist path', 400)
|
||||||
|
|
||||||
if not os.path.exists(capture_file):
|
if not os.path.exists(capture_file):
|
||||||
return jsonify({'status': 'error', 'message': 'Capture file not found'}), 404
|
return api_error('Capture file not found', 404)
|
||||||
|
|
||||||
if not os.path.exists(wordlist):
|
if not os.path.exists(wordlist):
|
||||||
return jsonify({'status': 'error', 'message': 'Wordlist file not found'}), 404
|
return api_error('Wordlist file not found', 404)
|
||||||
|
|
||||||
if target_bssid and not is_valid_mac(target_bssid):
|
if target_bssid and not is_valid_mac(target_bssid):
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid BSSID format'}), 400
|
return api_error('Invalid BSSID format', 400)
|
||||||
|
|
||||||
aircrack_path = get_tool_path('aircrack-ng')
|
aircrack_path = get_tool_path('aircrack-ng')
|
||||||
if not aircrack_path:
|
if not aircrack_path:
|
||||||
return jsonify({'status': 'error', 'message': 'aircrack-ng not found'}), 500
|
return api_error('aircrack-ng not found', 500)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cmd = [aircrack_path, '-a', '2', '-w', wordlist]
|
cmd = [aircrack_path, '-a', '2', '-w', wordlist]
|
||||||
@@ -1099,8 +1112,7 @@ def crack_handshake():
|
|||||||
if match:
|
if match:
|
||||||
password = match.group(1)
|
password = match.group(1)
|
||||||
logger.info(f"Password cracked for {target_bssid}: {password}")
|
logger.info(f"Password cracked for {target_bssid}: {password}")
|
||||||
return jsonify({
|
return api_success(data={
|
||||||
'status': 'success',
|
|
||||||
'password': password,
|
'password': password,
|
||||||
'bssid': target_bssid
|
'bssid': target_bssid
|
||||||
})
|
})
|
||||||
@@ -1118,7 +1130,7 @@ def crack_handshake():
|
|||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Crack error: {e}")
|
logger.error(f"Crack error: {e}")
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
return api_error(str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
@wifi_bp.route('/networks')
|
@wifi_bp.route('/networks')
|
||||||
@@ -1132,26 +1144,26 @@ def get_wifi_networks():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@wifi_bp.route('/stream')
|
@wifi_bp.route('/stream')
|
||||||
def stream_wifi():
|
def stream_wifi():
|
||||||
"""SSE stream for WiFi events."""
|
"""SSE stream for WiFi events."""
|
||||||
def _on_msg(msg: dict[str, Any]) -> None:
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
process_event('wifi', msg, msg.get('type'))
|
process_event('wifi', msg, msg.get('type'))
|
||||||
|
|
||||||
response = Response(
|
response = Response(
|
||||||
sse_stream_fanout(
|
sse_stream_fanout(
|
||||||
source_queue=app_module.wifi_queue,
|
source_queue=app_module.wifi_queue,
|
||||||
channel_key='wifi',
|
channel_key='wifi',
|
||||||
timeout=1.0,
|
timeout=1.0,
|
||||||
keepalive_interval=30.0,
|
keepalive_interval=30.0,
|
||||||
on_message=_on_msg,
|
on_message=_on_msg,
|
||||||
),
|
),
|
||||||
mimetype='text/event-stream',
|
mimetype='text/event-stream',
|
||||||
)
|
)
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
response.headers['Connection'] = 'keep-alive'
|
response.headers['Connection'] = 'keep-alive'
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -1189,7 +1201,7 @@ def get_v2_capabilities():
|
|||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Error checking capabilities")
|
logger.exception("Error checking capabilities")
|
||||||
return jsonify({'error': str(e)}), 500
|
return api_error(str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
@wifi_bp.route('/v2/scan/quick', methods=['POST'])
|
@wifi_bp.route('/v2/scan/quick', methods=['POST'])
|
||||||
@@ -1220,7 +1232,7 @@ def v2_quick_scan():
|
|||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Error in quick scan")
|
logger.exception("Error in quick scan")
|
||||||
return jsonify({'error': str(e)}), 500
|
return api_error(str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
@wifi_bp.route('/v2/scan/start', methods=['POST'])
|
@wifi_bp.route('/v2/scan/start', methods=['POST'])
|
||||||
@@ -1239,10 +1251,10 @@ def v2_start_scan():
|
|||||||
return jsonify({'status': 'started'})
|
return jsonify({'status': 'started'})
|
||||||
else:
|
else:
|
||||||
status = scanner.get_status()
|
status = scanner.get_status()
|
||||||
return jsonify({'error': status.error or 'Failed to start scan'}), 400
|
return api_error(status.error or 'Failed to start scan', 400)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Error starting deep scan")
|
logger.exception("Error starting deep scan")
|
||||||
return jsonify({'error': str(e)}), 500
|
return api_error(str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
@wifi_bp.route('/v2/scan/stop', methods=['POST'])
|
@wifi_bp.route('/v2/scan/stop', methods=['POST'])
|
||||||
@@ -1254,7 +1266,7 @@ def v2_stop_scan():
|
|||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Error stopping scan")
|
logger.exception("Error stopping scan")
|
||||||
return jsonify({'error': str(e)}), 500
|
return api_error(str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
@wifi_bp.route('/v2/scan/status')
|
@wifi_bp.route('/v2/scan/status')
|
||||||
@@ -1274,7 +1286,7 @@ def v2_scan_status():
|
|||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Error getting scan status")
|
logger.exception("Error getting scan status")
|
||||||
return jsonify({'error': str(e)}), 500
|
return api_error(str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
@wifi_bp.route('/v2/networks')
|
@wifi_bp.route('/v2/networks')
|
||||||
@@ -1289,7 +1301,7 @@ def v2_get_networks():
|
|||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Error getting networks")
|
logger.exception("Error getting networks")
|
||||||
return jsonify({'error': str(e)}), 500
|
return api_error(str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
@wifi_bp.route('/v2/clients')
|
@wifi_bp.route('/v2/clients')
|
||||||
@@ -1326,7 +1338,7 @@ def v2_get_clients():
|
|||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Error getting clients")
|
logger.exception("Error getting clients")
|
||||||
return jsonify({'error': str(e)}), 500
|
return api_error(str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
@wifi_bp.route('/v2/probes')
|
@wifi_bp.route('/v2/probes')
|
||||||
@@ -1341,7 +1353,7 @@ def v2_get_probes():
|
|||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Error getting probes")
|
logger.exception("Error getting probes")
|
||||||
return jsonify({'error': str(e)}), 500
|
return api_error(str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
@wifi_bp.route('/v2/channels')
|
@wifi_bp.route('/v2/channels')
|
||||||
@@ -1357,7 +1369,7 @@ def v2_get_channels():
|
|||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Error getting channel stats")
|
logger.exception("Error getting channel stats")
|
||||||
return jsonify({'error': str(e)}), 500
|
return api_error(str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
@wifi_bp.route('/v2/stream')
|
@wifi_bp.route('/v2/stream')
|
||||||
@@ -1448,11 +1460,11 @@ def v2_export():
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
else:
|
else:
|
||||||
return jsonify({'error': f'Unknown format: {format_type}'}), 400
|
return api_error(f'Unknown format: {format_type}', 400)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Error exporting data")
|
logger.exception("Error exporting data")
|
||||||
return jsonify({'error': str(e)}), 500
|
return api_error(str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
@wifi_bp.route('/v2/baseline/set', methods=['POST'])
|
@wifi_bp.route('/v2/baseline/set', methods=['POST'])
|
||||||
@@ -1464,7 +1476,7 @@ def v2_set_baseline():
|
|||||||
return jsonify({'status': 'baseline_set', 'count': len(scanner._baseline_networks)})
|
return jsonify({'status': 'baseline_set', 'count': len(scanner._baseline_networks)})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Error setting baseline")
|
logger.exception("Error setting baseline")
|
||||||
return jsonify({'error': str(e)}), 500
|
return api_error(str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
@wifi_bp.route('/v2/baseline/clear', methods=['POST'])
|
@wifi_bp.route('/v2/baseline/clear', methods=['POST'])
|
||||||
@@ -1476,7 +1488,7 @@ def v2_clear_baseline():
|
|||||||
return jsonify({'status': 'baseline_cleared'})
|
return jsonify({'status': 'baseline_cleared'})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Error clearing baseline")
|
logger.exception("Error clearing baseline")
|
||||||
return jsonify({'error': str(e)}), 500
|
return api_error(str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
@wifi_bp.route('/v2/clear', methods=['POST'])
|
@wifi_bp.route('/v2/clear', methods=['POST'])
|
||||||
@@ -1488,7 +1500,7 @@ def v2_clear_data():
|
|||||||
return jsonify({'status': 'cleared'})
|
return jsonify({'status': 'cleared'})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Error clearing data")
|
logger.exception("Error clearing data")
|
||||||
return jsonify({'error': str(e)}), 500
|
return api_error(str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -1535,11 +1547,11 @@ def v2_deauth_status():
|
|||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Error getting deauth status")
|
logger.exception("Error getting deauth status")
|
||||||
return jsonify({'error': str(e)}), 500
|
return api_error(str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
@wifi_bp.route('/v2/deauth/stream')
|
@wifi_bp.route('/v2/deauth/stream')
|
||||||
def v2_deauth_stream():
|
def v2_deauth_stream():
|
||||||
"""
|
"""
|
||||||
SSE stream for real-time deauth alerts.
|
SSE stream for real-time deauth alerts.
|
||||||
|
|
||||||
@@ -1550,18 +1562,18 @@ def v2_deauth_stream():
|
|||||||
- deauth_error: An error occurred
|
- deauth_error: An error occurred
|
||||||
- keepalive: Periodic keepalive
|
- keepalive: Periodic keepalive
|
||||||
"""
|
"""
|
||||||
response = Response(
|
response = Response(
|
||||||
sse_stream_fanout(
|
sse_stream_fanout(
|
||||||
source_queue=app_module.deauth_detector_queue,
|
source_queue=app_module.deauth_detector_queue,
|
||||||
channel_key='wifi_deauth',
|
channel_key='wifi_deauth',
|
||||||
timeout=SSE_QUEUE_TIMEOUT,
|
timeout=SSE_QUEUE_TIMEOUT,
|
||||||
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||||
),
|
),
|
||||||
mimetype='text/event-stream',
|
mimetype='text/event-stream',
|
||||||
)
|
)
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
response.headers['Connection'] = 'keep-alive'
|
response.headers['Connection'] = 'keep-alive'
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@@ -1600,7 +1612,7 @@ def v2_deauth_alerts():
|
|||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Error getting deauth alerts")
|
logger.exception("Error getting deauth alerts")
|
||||||
return jsonify({'error': str(e)}), 500
|
return api_error(str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
@wifi_bp.route('/v2/deauth/clear', methods=['POST'])
|
@wifi_bp.route('/v2/deauth/clear', methods=['POST'])
|
||||||
@@ -1620,4 +1632,4 @@ def v2_deauth_clear():
|
|||||||
return jsonify({'status': 'cleared'})
|
return jsonify({'status': 'cleared'})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Error clearing deauth alerts")
|
logger.exception("Error clearing deauth alerts")
|
||||||
return jsonify({'error': str(e)}), 500
|
return api_error(str(e), 500)
|
||||||
|
|||||||
+54
-56
@@ -16,16 +16,17 @@ from typing import Generator
|
|||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
from utils.wifi import (
|
from utils.wifi import (
|
||||||
get_wifi_scanner,
|
get_wifi_scanner,
|
||||||
analyze_channels,
|
analyze_channels,
|
||||||
get_hidden_correlator,
|
get_hidden_correlator,
|
||||||
SCAN_MODE_QUICK,
|
SCAN_MODE_QUICK,
|
||||||
SCAN_MODE_DEEP,
|
SCAN_MODE_DEEP,
|
||||||
)
|
)
|
||||||
from utils.sse import format_sse
|
from utils.responses import api_success, api_error
|
||||||
from utils.validation import validate_wifi_channel
|
from utils.sse import format_sse
|
||||||
from utils.event_pipeline import process_event
|
from utils.validation import validate_wifi_channel
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -87,44 +88,44 @@ def start_deep_scan():
|
|||||||
|
|
||||||
Requires monitor mode interface and root privileges.
|
Requires monitor mode interface and root privileges.
|
||||||
|
|
||||||
Request body:
|
Request body:
|
||||||
interface: Monitor mode interface (e.g., 'wlan0mon')
|
interface: Monitor mode interface (e.g., 'wlan0mon')
|
||||||
band: Band to scan ('2.4', '5', 'all')
|
band: Band to scan ('2.4', '5', 'all')
|
||||||
channel: Optional specific channel to monitor
|
channel: Optional specific channel to monitor
|
||||||
channels: Optional list or comma-separated channels to monitor
|
channels: Optional list or comma-separated channels to monitor
|
||||||
"""
|
"""
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
interface = data.get('interface')
|
interface = data.get('interface')
|
||||||
band = data.get('band', 'all')
|
band = data.get('band', 'all')
|
||||||
channel = data.get('channel')
|
channel = data.get('channel')
|
||||||
channels = data.get('channels')
|
channels = data.get('channels')
|
||||||
|
|
||||||
channel_list = None
|
channel_list = None
|
||||||
if channels:
|
if channels:
|
||||||
if isinstance(channels, str):
|
if isinstance(channels, str):
|
||||||
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
|
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
|
||||||
elif isinstance(channels, (list, tuple, set)):
|
elif isinstance(channels, (list, tuple, set)):
|
||||||
channel_list = list(channels)
|
channel_list = list(channels)
|
||||||
else:
|
else:
|
||||||
channel_list = [channels]
|
channel_list = [channels]
|
||||||
try:
|
try:
|
||||||
channel_list = [validate_wifi_channel(c) for c in channel_list]
|
channel_list = [validate_wifi_channel(c) for c in channel_list]
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return jsonify({'error': 'Invalid channels'}), 400
|
return api_error('Invalid channels', 400)
|
||||||
|
|
||||||
if channel:
|
if channel:
|
||||||
try:
|
try:
|
||||||
channel = validate_wifi_channel(channel)
|
channel = validate_wifi_channel(channel)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return jsonify({'error': 'Invalid channel'}), 400
|
return api_error('Invalid channel', 400)
|
||||||
|
|
||||||
scanner = get_wifi_scanner()
|
scanner = get_wifi_scanner()
|
||||||
success = scanner.start_deep_scan(
|
success = scanner.start_deep_scan(
|
||||||
interface=interface,
|
interface=interface,
|
||||||
band=band,
|
band=band,
|
||||||
channel=channel,
|
channel=channel,
|
||||||
channels=channel_list,
|
channels=channel_list,
|
||||||
)
|
)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -133,10 +134,7 @@ def start_deep_scan():
|
|||||||
'interface': interface or scanner._capabilities.monitor_interface,
|
'interface': interface or scanner._capabilities.monitor_interface,
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
return jsonify({
|
return api_error(scanner._status.error or 'Scan failed', 400)
|
||||||
'status': 'error',
|
|
||||||
'error': scanner._status.error,
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
|
|
||||||
@wifi_v2_bp.route('/scan/stop', methods=['POST'])
|
@wifi_v2_bp.route('/scan/stop', methods=['POST'])
|
||||||
@@ -235,7 +233,7 @@ def get_network(bssid):
|
|||||||
if network:
|
if network:
|
||||||
return jsonify(network.to_dict())
|
return jsonify(network.to_dict())
|
||||||
else:
|
else:
|
||||||
return jsonify({'error': 'Network not found'}), 404
|
return api_error('Network not found', 404)
|
||||||
|
|
||||||
|
|
||||||
@wifi_v2_bp.route('/clients', methods=['GET'])
|
@wifi_v2_bp.route('/clients', methods=['GET'])
|
||||||
@@ -282,7 +280,7 @@ def get_client(mac):
|
|||||||
if client:
|
if client:
|
||||||
return jsonify(client.to_dict())
|
return jsonify(client.to_dict())
|
||||||
else:
|
else:
|
||||||
return jsonify({'error': 'Client not found'}), 404
|
return api_error('Client not found', 404)
|
||||||
|
|
||||||
|
|
||||||
@wifi_v2_bp.route('/probes', methods=['GET'])
|
@wifi_v2_bp.route('/probes', methods=['GET'])
|
||||||
@@ -406,14 +404,14 @@ def event_stream():
|
|||||||
- keepalive: Periodic keepalive
|
- keepalive: Periodic keepalive
|
||||||
"""
|
"""
|
||||||
def generate() -> Generator[str, None, None]:
|
def generate() -> Generator[str, None, None]:
|
||||||
scanner = get_wifi_scanner()
|
scanner = get_wifi_scanner()
|
||||||
|
|
||||||
for event in scanner.get_event_stream():
|
for event in scanner.get_event_stream():
|
||||||
try:
|
try:
|
||||||
process_event('wifi', event, event.get('type'))
|
process_event('wifi', event, event.get('type'))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
yield format_sse(event)
|
yield format_sse(event)
|
||||||
|
|
||||||
response = Response(generate(), mimetype='text/event-stream')
|
response = Response(generate(), mimetype='text/event-stream')
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
|
|||||||
@@ -1485,9 +1485,10 @@ install_tool_acarsdec() {
|
|||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
if ! cmd_exists acarsdec; then
|
if ! cmd_exists acarsdec; then
|
||||||
apt_install acarsdec || true
|
install_acarsdec_from_source_debian
|
||||||
|
else
|
||||||
|
ok "acarsdec already installed"
|
||||||
fi
|
fi
|
||||||
cmd_exists acarsdec || install_acarsdec_from_source_debian
|
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,11 @@ done
|
|||||||
export INTERCEPT_HOST="$HOST"
|
export INTERCEPT_HOST="$HOST"
|
||||||
export INTERCEPT_PORT="$PORT"
|
export INTERCEPT_PORT="$PORT"
|
||||||
|
|
||||||
|
# ── macOS: allow fork() after ObjC initialisation (gunicorn + gevent) ────
|
||||||
|
if [[ "$(uname)" == "Darwin" ]]; then
|
||||||
|
export OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES
|
||||||
|
fi
|
||||||
|
|
||||||
# ── Fix ownership of user data dirs when run via sudo ────────────────────────
|
# ── Fix ownership of user data dirs when run via sudo ────────────────────────
|
||||||
# When invoked via sudo the server process runs as root, so every file it
|
# When invoked via sudo the server process runs as root, so every file it
|
||||||
# creates (configs, logs, database) ends up owned by root. On the *next*
|
# creates (configs, logs, database) ends up owned by root. On the *next*
|
||||||
@@ -152,7 +157,17 @@ fi
|
|||||||
|
|
||||||
# ── Resolve LAN address for display ──────────────────────────────────────────
|
# ── Resolve LAN address for display ──────────────────────────────────────────
|
||||||
if [[ "$HOST" == "0.0.0.0" ]]; then
|
if [[ "$HOST" == "0.0.0.0" ]]; then
|
||||||
LAN_IP=$(hostname -I 2>/dev/null | awk '{print $1}')
|
LAN_IP=$(hostname -I 2>/dev/null | awk '{print $1}' || true)
|
||||||
|
# hostname -I on macOS fails or returns empty — try macOS methods
|
||||||
|
if [[ -z "$LAN_IP" ]]; then
|
||||||
|
LAN_IP=$(ipconfig getifaddr en0 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
if [[ -z "$LAN_IP" ]]; then
|
||||||
|
LAN_IP=$(ipconfig getifaddr en1 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
if [[ -z "$LAN_IP" ]]; then
|
||||||
|
LAN_IP=$(ifconfig 2>/dev/null | grep "inet " | grep -v 127.0.0.1 | head -1 | awk '{print $2}' || true)
|
||||||
|
fi
|
||||||
LAN_IP="${LAN_IP:-localhost}"
|
LAN_IP="${LAN_IP:-localhost}"
|
||||||
else
|
else
|
||||||
LAN_IP="$HOST"
|
LAN_IP="$HOST"
|
||||||
|
|||||||
@@ -2449,7 +2449,7 @@ body {
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 480px) {
|
||||||
.squawk-item {
|
.squawk-item {
|
||||||
grid-template-columns: 45px 80px 1fr;
|
grid-template-columns: 45px 80px 1fr;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|||||||
@@ -684,7 +684,7 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 768px) {
|
||||||
.controls {
|
.controls {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
|||||||
@@ -522,7 +522,7 @@
|
|||||||
/* ============================================
|
/* ============================================
|
||||||
RESPONSIVE ADJUSTMENTS
|
RESPONSIVE ADJUSTMENTS
|
||||||
============================================ */
|
============================================ */
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 480px) {
|
||||||
.device-signal-row {
|
.device-signal-row {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
@@ -841,7 +841,7 @@
|
|||||||
/* ============================================
|
/* ============================================
|
||||||
RESPONSIVE MODAL
|
RESPONSIVE MODAL
|
||||||
============================================ */
|
============================================ */
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 480px) {
|
||||||
.modal-signal-stats {
|
.modal-signal-stats {
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1128,7 +1128,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive adjustments for aggregated meters */
|
/* Responsive adjustments for aggregated meters */
|
||||||
@media (max-width: 500px) {
|
@media (max-width: 480px) {
|
||||||
.meter-aggregated-grid {
|
.meter-aggregated-grid {
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
grid-template-rows: auto auto;
|
grid-template-rows: auto auto;
|
||||||
@@ -1922,7 +1922,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive adjustments */
|
/* Responsive adjustments */
|
||||||
@media (max-width: 500px) {
|
@media (max-width: 480px) {
|
||||||
.signal-details-modal-content {
|
.signal-details-modal-content {
|
||||||
width: 95%;
|
width: 95%;
|
||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
|
|||||||
@@ -429,7 +429,7 @@
|
|||||||
border-color: rgba(31, 95, 168, 0.45);
|
border-color: rgba(31, 95, 168, 0.45);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 920px) {
|
@media (max-width: 1023px) {
|
||||||
.run-state-strip {
|
.run-state-strip {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
@@ -440,7 +440,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 768px) {
|
||||||
.command-palette-overlay {
|
.command-palette-overlay {
|
||||||
padding: 8vh 10px 0;
|
padding: 8vh 10px 0;
|
||||||
}
|
}
|
||||||
|
|||||||
+79
-76
@@ -21,36 +21,36 @@ html {
|
|||||||
tab-size: 4;
|
tab-size: 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: var(--text-base);
|
font-size: var(--text-base);
|
||||||
line-height: var(--leading-normal);
|
line-height: var(--leading-normal);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
background-color: var(--bg-primary);
|
background-color: var(--bg-primary);
|
||||||
background-image:
|
background-image:
|
||||||
radial-gradient(1200px 620px at 8% -12%, var(--ambient-top-left), transparent 62%),
|
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(980px 560px at 92% -16%, var(--ambient-top-right), transparent 64%),
|
||||||
radial-gradient(900px 520px at 50% 126%, var(--ambient-bottom), transparent 68%),
|
radial-gradient(900px 520px at 50% 126%, var(--ambient-bottom), transparent 68%),
|
||||||
var(--noise-image),
|
var(--noise-image),
|
||||||
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
||||||
linear-gradient(90deg, 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-size: auto, auto, auto, 40px 40px, 48px 48px, 48px 48px;
|
||||||
background-attachment: fixed;
|
background-attachment: fixed;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
TYPOGRAPHY
|
TYPOGRAPHY
|
||||||
============================================ */
|
============================================ */
|
||||||
h1, h2, h3, h4, h5, h6 {
|
h1, h2, h3, h4, h5, h6 {
|
||||||
font-weight: var(--font-semibold);
|
font-weight: var(--font-semibold);
|
||||||
line-height: var(--leading-tight);
|
line-height: var(--leading-tight);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 { font-size: var(--text-4xl); }
|
h1 { font-size: var(--text-4xl); }
|
||||||
h2 { font-size: var(--text-3xl); }
|
h2 { font-size: var(--text-3xl); }
|
||||||
@@ -91,20 +91,23 @@ code, kbd, pre, samp {
|
|||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
}
|
}
|
||||||
|
|
||||||
code {
|
code {
|
||||||
background: var(--bg-elevated);
|
background: var(--bg-elevated);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
}
|
overflow-x: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
pre {
|
pre {
|
||||||
background: var(--bg-elevated);
|
background: var(--bg-elevated);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
pre code {
|
pre code {
|
||||||
background: none;
|
background: none;
|
||||||
@@ -135,38 +138,38 @@ button:disabled {
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
input,
|
input,
|
||||||
select,
|
select,
|
||||||
textarea {
|
textarea {
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
input:focus,
|
input:focus,
|
||||||
select:focus,
|
select:focus,
|
||||||
textarea:focus {
|
textarea:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--accent-cyan);
|
border-color: var(--accent-cyan);
|
||||||
box-shadow: 0 0 0 2px var(--accent-cyan-dim);
|
box-shadow: 0 0 0 2px var(--accent-cyan-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
input::placeholder,
|
input::placeholder,
|
||||||
textarea::placeholder {
|
textarea::placeholder {
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
appearance: none;
|
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-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-repeat: no-repeat;
|
||||||
background-position: right 8px center;
|
background-position: right 8px center;
|
||||||
padding-right: 28px;
|
padding-right: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="checkbox"],
|
input[type="checkbox"],
|
||||||
input[type="radio"] {
|
input[type="radio"] {
|
||||||
@@ -201,18 +204,18 @@ td {
|
|||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
th {
|
th {
|
||||||
font-weight: var(--font-semibold);
|
font-weight: var(--font-semibold);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
tr:hover td {
|
tr:hover td {
|
||||||
background: var(--bg-elevated);
|
background: var(--bg-elevated);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
LISTS
|
LISTS
|
||||||
|
|||||||
@@ -80,8 +80,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger:hover:not(:disabled) {
|
.btn-danger:hover:not(:disabled) {
|
||||||
background: #dc2626;
|
background: var(--accent-red-hover);
|
||||||
border-color: #dc2626;
|
border-color: var(--accent-red-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger-outline {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--accent-red);
|
||||||
|
border-color: var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger-outline:hover:not(:disabled) {
|
||||||
|
background: var(--accent-red-dim);
|
||||||
|
color: var(--accent-red);
|
||||||
|
border-color: var(--accent-red);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-success {
|
.btn-success {
|
||||||
@@ -91,8 +103,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-success:hover:not(:disabled) {
|
.btn-success:hover:not(:disabled) {
|
||||||
background: #16a34a;
|
background: var(--accent-green-hover);
|
||||||
border-color: #16a34a;
|
border-color: var(--accent-green-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Button sizes */
|
/* Button sizes */
|
||||||
@@ -415,6 +427,28 @@
|
|||||||
to { transform: rotate(360deg); }
|
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 */
|
||||||
.loading-overlay {
|
.loading-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -855,3 +889,266 @@ textarea:focus {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
filter: grayscale(30%);
|
filter: grayscale(30%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
MODAL CLOSE BUTTON
|
||||||
|
============================================ */
|
||||||
|
.modal-close-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
.modal-close-btn:hover { background: var(--bg-elevated); color: var(--text-primary); }
|
||||||
|
.modal-close-btn:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 2px; }
|
||||||
|
|
||||||
|
/* Aliases for existing modal-specific close classes */
|
||||||
|
.settings-close,
|
||||||
|
.help-close,
|
||||||
|
.wifi-detail-close,
|
||||||
|
.bt-modal-close,
|
||||||
|
.tscm-modal-close,
|
||||||
|
.signal-details-modal-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
.settings-close:hover,
|
||||||
|
.help-close:hover,
|
||||||
|
.wifi-detail-close:hover,
|
||||||
|
.bt-modal-close:hover,
|
||||||
|
.tscm-modal-close:hover,
|
||||||
|
.signal-details-modal-close:hover { background: var(--bg-elevated); color: var(--text-primary); }
|
||||||
|
.settings-close:focus-visible,
|
||||||
|
.help-close:focus-visible,
|
||||||
|
.wifi-detail-close:focus-visible,
|
||||||
|
.bt-modal-close:focus-visible,
|
||||||
|
.tscm-modal-close:focus-visible,
|
||||||
|
.signal-details-modal-close:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 2px; }
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
CONFIRMATION MODAL
|
||||||
|
============================================ */
|
||||||
|
.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
|
GLOBAL HEADER
|
||||||
============================================ */
|
============================================ */
|
||||||
.app-header {
|
.app-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
height: var(--header-height);
|
height: var(--header-height);
|
||||||
padding: 0 var(--space-4);
|
padding: 0 var(--space-4);
|
||||||
background: linear-gradient(180deg, var(--bg-elevated) 0%, var(--bg-secondary) 100%);
|
background: linear-gradient(180deg, var(--bg-elevated) 0%, var(--bg-secondary) 100%);
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: var(--z-sticky);
|
z-index: var(--z-sticky);
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-header::after {
|
.app-header::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
height: 2px;
|
height: 2px;
|
||||||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-header-left {
|
.app-header-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -129,29 +129,29 @@
|
|||||||
/* ============================================
|
/* ============================================
|
||||||
GLOBAL NAVIGATION
|
GLOBAL NAVIGATION
|
||||||
============================================ */
|
============================================ */
|
||||||
.app-nav {
|
.app-nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
padding: 0 var(--space-4);
|
padding: 0 var(--space-4);
|
||||||
height: var(--nav-height);
|
height: var(--nav-height);
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-nav::after {
|
.app-nav::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-nav::-webkit-scrollbar {
|
.app-nav::-webkit-scrollbar {
|
||||||
height: 0;
|
height: 0;
|
||||||
@@ -202,14 +202,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Dropdown menu */
|
/* Dropdown menu */
|
||||||
.nav-dropdown-menu {
|
.nav-dropdown-menu {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 100%;
|
top: 100%;
|
||||||
left: 0;
|
left: 0;
|
||||||
min-width: 180px;
|
min-width: 180px;
|
||||||
background: var(--bg-elevated);
|
background: var(--bg-elevated);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
padding: var(--space-1);
|
padding: var(--space-1);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -299,27 +299,27 @@
|
|||||||
/* ============================================
|
/* ============================================
|
||||||
MOBILE NAVIGATION
|
MOBILE NAVIGATION
|
||||||
============================================ */
|
============================================ */
|
||||||
.mobile-nav {
|
.mobile-nav {
|
||||||
display: none;
|
display: none;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-nav::after {
|
.mobile-nav::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-nav::-webkit-scrollbar {
|
.mobile-nav::-webkit-scrollbar {
|
||||||
height: 0;
|
height: 0;
|
||||||
@@ -396,13 +396,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Sidebar */
|
/* Sidebar */
|
||||||
.app-sidebar {
|
.app-sidebar {
|
||||||
width: var(--sidebar-width);
|
width: var(--sidebar-width);
|
||||||
background: linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-tertiary) 100%);
|
background: linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-tertiary) 100%);
|
||||||
border-right: 1px solid var(--border-color);
|
border-right: 1px solid var(--border-color);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-section {
|
.sidebar-section {
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
@@ -447,28 +447,28 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-header {
|
.dashboard-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: var(--space-2) var(--space-4);
|
padding: var(--space-2) var(--space-4);
|
||||||
background: linear-gradient(180deg, var(--bg-elevated) 0%, var(--bg-secondary) 100%);
|
background: linear-gradient(180deg, var(--bg-elevated) 0%, var(--bg-secondary) 100%);
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-header::after {
|
.dashboard-header::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
height: 2px;
|
height: 2px;
|
||||||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-header-logo {
|
.dashboard-header-logo {
|
||||||
font-size: var(--text-lg);
|
font-size: var(--text-lg);
|
||||||
@@ -495,10 +495,10 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-sidebar {
|
.dashboard-sidebar {
|
||||||
width: 320px;
|
width: 320px;
|
||||||
background: linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-tertiary) 100%);
|
background: linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-tertiary) 100%);
|
||||||
border-left: 1px solid var(--border-color);
|
border-left: 1px solid var(--border-color);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -638,27 +638,32 @@
|
|||||||
Used by nav.html partial across all pages
|
Used by nav.html partial across all pages
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
||||||
|
/* NAVIGATION
|
||||||
|
Mode nav bar, dropdowns, utilities, theme/effects toggles
|
||||||
|
============================================ */
|
||||||
|
|
||||||
/* Mode Navigation Bar */
|
/* Mode Navigation Bar */
|
||||||
.mode-nav {
|
.mode-nav {
|
||||||
display: none;
|
display: none;
|
||||||
background: var(--bg-secondary) !important; /* Explicit color - forced to ensure consistency */
|
background: linear-gradient(180deg, rgba(17, 22, 32, 0.92), rgba(15, 20, 28, 0.88));
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 100;
|
z-index: var(--z-sticky);
|
||||||
}
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
.mode-nav::after {
|
|
||||||
content: '';
|
.mode-nav::after {
|
||||||
position: absolute;
|
content: '';
|
||||||
left: 0;
|
position: absolute;
|
||||||
right: 0;
|
left: 0;
|
||||||
bottom: 0;
|
right: 0;
|
||||||
height: 1px;
|
bottom: 0;
|
||||||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
height: 1px;
|
||||||
opacity: 0.5;
|
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||||
pointer-events: none;
|
opacity: 0.5;
|
||||||
}
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
.mode-nav {
|
.mode-nav {
|
||||||
@@ -682,6 +687,7 @@
|
|||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mode-nav-divider {
|
.mode-nav-divider {
|
||||||
@@ -692,33 +698,27 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mode-nav-btn {
|
.mode-nav-btn {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 8px 14px;
|
padding: 8px 14px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-lg);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s ease;
|
transition: all var(--transition-fast);
|
||||||
}
|
text-decoration: none;
|
||||||
|
|
||||||
.mode-nav-btn .nav-icon {
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mode-nav-btn .nav-icon svg {
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mode-nav-btn .nav-label {
|
.mode-nav-btn .nav-label {
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.08em;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mode-nav-btn:hover {
|
.mode-nav-btn:hover {
|
||||||
@@ -728,13 +728,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mode-nav-btn.active {
|
.mode-nav-btn.active {
|
||||||
background: var(--accent-cyan);
|
background: var(--bg-elevated);
|
||||||
color: var(--bg-primary);
|
color: var(--text-primary);
|
||||||
border-color: var(--accent-cyan);
|
border-color: var(--accent-cyan);
|
||||||
|
box-shadow: inset 0 -2px 0 var(--accent-cyan);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mode-nav-btn.active .nav-icon {
|
.mode-nav-btn.active .nav-icon {
|
||||||
filter: brightness(0);
|
color: var(--accent-cyan);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mode-nav-actions {
|
.mode-nav-actions {
|
||||||
@@ -749,29 +750,29 @@
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 8px 14px;
|
padding: 8px 14px;
|
||||||
background: var(--bg-elevated);
|
background: var(--bg-elevated);
|
||||||
border: 1px solid var(--accent-cyan);
|
border: 1px solid var(--border-light);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-lg);
|
||||||
color: var(--accent-cyan);
|
color: var(--text-primary);
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s ease;
|
transition: all var(--transition-fast);
|
||||||
}
|
|
||||||
|
|
||||||
.nav-action-btn .nav-icon {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-action-btn .nav-label {
|
.nav-action-btn .nav-label {
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.08em;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-action-btn:hover {
|
.nav-action-btn:hover {
|
||||||
background: var(--accent-cyan);
|
background: var(--bg-tertiary);
|
||||||
color: var(--bg-primary);
|
color: var(--text-primary);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dropdown Navigation */
|
/* Dropdown Navigation */
|
||||||
@@ -780,19 +781,41 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mode-nav-dropdown-btn {
|
.mode-nav-dropdown-btn {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 8px 14px;
|
padding: 8px 14px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-lg);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
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 {
|
.mode-nav-dropdown-btn:hover {
|
||||||
@@ -801,31 +824,6 @@
|
|||||||
border-color: var(--border-color);
|
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 {
|
.mode-nav-dropdown.open .mode-nav-dropdown-btn {
|
||||||
background: var(--bg-elevated);
|
background: var(--bg-elevated);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -837,13 +835,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mode-nav-dropdown.has-active .mode-nav-dropdown-btn {
|
.mode-nav-dropdown.has-active .mode-nav-dropdown-btn {
|
||||||
background: var(--accent-cyan);
|
background: var(--bg-elevated);
|
||||||
color: var(--bg-primary);
|
color: var(--text-primary);
|
||||||
border-color: var(--accent-cyan);
|
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 {
|
.mode-nav-dropdown.has-active .mode-nav-dropdown-btn .nav-icon {
|
||||||
filter: brightness(0);
|
color: var(--accent-cyan);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mode-nav-dropdown-menu {
|
.mode-nav-dropdown-menu {
|
||||||
@@ -852,16 +851,17 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
min-width: 180px;
|
min-width: 180px;
|
||||||
background: var(--bg-secondary);
|
background: var(--surface-glass);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-xl);
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
box-shadow: var(--shadow-lg);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
transform: translateY(-8px);
|
transform: translateY(-8px);
|
||||||
transition: all 0.15s ease;
|
transition: all var(--transition-fast);
|
||||||
z-index: 1000;
|
z-index: var(--z-dropdown);
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mode-nav-dropdown.open .mode-nav-dropdown-menu {
|
.mode-nav-dropdown.open .mode-nav-dropdown-menu {
|
||||||
@@ -874,8 +874,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-lg);
|
||||||
margin: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mode-nav-dropdown-menu .mode-nav-btn:hover {
|
.mode-nav-dropdown-menu .mode-nav-btn:hover {
|
||||||
@@ -883,8 +882,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mode-nav-dropdown-menu .mode-nav-btn.active {
|
.mode-nav-dropdown-menu .mode-nav-btn.active {
|
||||||
background: var(--accent-cyan);
|
background: var(--bg-elevated);
|
||||||
color: var(--bg-primary);
|
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) */
|
/* Nav Bar Utilities (clock, theme, tools) */
|
||||||
@@ -941,15 +950,15 @@
|
|||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
min-width: 28px;
|
min-width: 28px;
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-lg);
|
||||||
background: transparent;
|
background: var(--bg-elevated);
|
||||||
border: 1px solid transparent;
|
border: 1px solid var(--border-color);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s ease;
|
transition: all var(--transition-fast);
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -957,27 +966,36 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-tool-btn:hover {
|
.nav-tool-btn:hover {
|
||||||
background: var(--bg-elevated);
|
background: var(--bg-tertiary);
|
||||||
border-color: var(--border-color);
|
border-color: var(--accent-cyan);
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Nav tool button SVG sizing */
|
||||||
.nav-tool-btn svg {
|
.nav-tool-btn svg {
|
||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
|
stroke: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tool-btn .icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-tool-btn .icon svg {
|
.nav-tool-btn .icon svg {
|
||||||
width: 14px;
|
width: 14px;
|
||||||
height: 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-sun,
|
||||||
.nav-tool-btn .icon-moon {
|
.nav-tool-btn .icon-moon {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
transition: opacity 0.2s, transform 0.2s;
|
transition: opacity 0.2s, transform 0.2s;
|
||||||
font-size: 14px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-tool-btn .icon-sun {
|
.nav-tool-btn .icon-sun {
|
||||||
@@ -1000,7 +1018,7 @@
|
|||||||
transform: rotate(90deg);
|
transform: rotate(90deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Effects toggle icon states */
|
/* Effects/animations toggle icon states */
|
||||||
.nav-tool-btn .icon-effects-off {
|
.nav-tool-btn .icon-effects-off {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -1012,3 +1030,114 @@
|
|||||||
[data-animations="off"] .nav-tool-btn .icon-effects-off {
|
[data-animations="off"] .nav-tool-btn .icon-effects-off {
|
||||||
display: flex;
|
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-dim: rgba(74, 163, 255, 0.16);
|
||||||
--accent-cyan-hover: #6bb3ff;
|
--accent-cyan-hover: #6bb3ff;
|
||||||
--accent-green: #38c180;
|
--accent-green: #38c180;
|
||||||
|
--accent-green-hover: #16a34a;
|
||||||
--accent-green-dim: rgba(56, 193, 128, 0.18);
|
--accent-green-dim: rgba(56, 193, 128, 0.18);
|
||||||
--accent-red: #e25d5d;
|
--accent-red: #e25d5d;
|
||||||
|
--accent-red-hover: #dc2626;
|
||||||
--accent-red-dim: rgba(226, 93, 93, 0.16);
|
--accent-red-dim: rgba(226, 93, 93, 0.16);
|
||||||
--accent-orange: #d6a85e;
|
--accent-orange: #d6a85e;
|
||||||
--accent-orange-dim: rgba(214, 168, 94, 0.16);
|
--accent-orange-dim: rgba(214, 168, 94, 0.16);
|
||||||
@@ -96,7 +98,7 @@
|
|||||||
TYPOGRAPHY
|
TYPOGRAPHY
|
||||||
============================================ */
|
============================================ */
|
||||||
--font-sans: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
--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 */
|
/* Font sizes */
|
||||||
--text-xs: 10px;
|
--text-xs: 10px;
|
||||||
@@ -158,7 +160,7 @@
|
|||||||
/* ============================================
|
/* ============================================
|
||||||
LAYOUT
|
LAYOUT
|
||||||
============================================ */
|
============================================ */
|
||||||
--header-height: 60px;
|
--header-height: 48px;
|
||||||
--nav-height: 44px;
|
--nav-height: 44px;
|
||||||
--sidebar-width: 280px;
|
--sidebar-width: 280px;
|
||||||
--stats-strip-height: 36px;
|
--stats-strip-height: 36px;
|
||||||
@@ -189,8 +191,10 @@
|
|||||||
--accent-cyan-dim: rgba(31, 95, 168, 0.12);
|
--accent-cyan-dim: rgba(31, 95, 168, 0.12);
|
||||||
--accent-cyan-hover: #2c73bf;
|
--accent-cyan-hover: #2c73bf;
|
||||||
--accent-green: #1f8a57;
|
--accent-green: #1f8a57;
|
||||||
|
--accent-green-hover: #167a4a;
|
||||||
--accent-green-dim: rgba(31, 138, 87, 0.12);
|
--accent-green-dim: rgba(31, 138, 87, 0.12);
|
||||||
--accent-red: #c74444;
|
--accent-red: #c74444;
|
||||||
|
--accent-red-hover: #b33a3a;
|
||||||
--accent-red-dim: rgba(199, 68, 68, 0.12);
|
--accent-red-dim: rgba(199, 68, 68, 0.12);
|
||||||
--accent-orange: #b5863a;
|
--accent-orange: #b5863a;
|
||||||
--accent-orange-dim: rgba(181, 134, 58, 0.12);
|
--accent-orange-dim: rgba(181, 134, 58, 0.12);
|
||||||
@@ -220,8 +224,8 @@
|
|||||||
|
|
||||||
--text-primary: #122034;
|
--text-primary: #122034;
|
||||||
--text-secondary: #3a4a5f;
|
--text-secondary: #3a4a5f;
|
||||||
--text-dim: #6b7c93;
|
--text-dim: #566a7f;
|
||||||
--text-muted: #aab6c8;
|
--text-muted: #7a8a9e;
|
||||||
--text-inverse: #f4f7fb;
|
--text-inverse: #f4f7fb;
|
||||||
|
|
||||||
--border-color: #d1d9e6;
|
--border-color: #d1d9e6;
|
||||||
|
|||||||
@@ -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 {
|
.welcome-settings-btn {
|
||||||
position: absolute;
|
|
||||||
top: 12px;
|
|
||||||
right: 12px;
|
|
||||||
z-index: 2;
|
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -223,6 +219,8 @@ body {
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: var(--text-dim, rgba(255, 255, 255, 0.3));
|
color: var(--text-dim, rgba(255, 255, 255, 0.3));
|
||||||
transition: color 0.2s, background 0.2s;
|
transition: color 0.2s, background 0.2s;
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.welcome-settings-btn:hover {
|
.welcome-settings-btn:hover {
|
||||||
@@ -251,10 +249,9 @@ body {
|
|||||||
.welcome-header {
|
.welcome-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
gap: 14px;
|
||||||
gap: 20px;
|
margin-bottom: 24px;
|
||||||
margin-bottom: 30px;
|
padding-bottom: 16px;
|
||||||
padding-bottom: 20px;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,38 +297,37 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.welcome-title-block {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.welcome-title {
|
.welcome-title {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 2.5rem;
|
font-size: 1.6rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
letter-spacing: 0.2em;
|
letter-spacing: 0.2em;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
text-shadow: 0 0 20px rgba(0, 212, 255, 0.3);
|
text-shadow: 0 0 20px rgba(0, 212, 255, 0.3);
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.welcome-tagline {
|
.welcome-tagline {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 0.9rem;
|
font-size: 0.8rem;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
letter-spacing: 0.15em;
|
letter-spacing: 0.1em;
|
||||||
margin: 4px 0 0 0;
|
margin: 0;
|
||||||
|
opacity: 0.7;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.welcome-version {
|
.welcome-version {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 0.65rem;
|
font-size: 0.6rem;
|
||||||
color: var(--bg-primary);
|
color: var(--bg-primary);
|
||||||
background: var(--accent-cyan);
|
background: var(--accent-cyan);
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
margin-top: 8px;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Welcome Content Grid */
|
/* Welcome Content Grid */
|
||||||
@@ -572,6 +568,21 @@ body {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.welcome-footer-credit {
|
||||||
|
display: inline-block;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-decoration: none;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
margin-top: 6px;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-footer-credit:hover {
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
/* Welcome Scanline */
|
/* Welcome Scanline */
|
||||||
.welcome-scanline {
|
.welcome-scanline {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -606,12 +617,9 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.welcome-header {
|
.welcome-header {
|
||||||
flex-direction: column;
|
flex-wrap: wrap;
|
||||||
text-align: center;
|
justify-content: center;
|
||||||
}
|
gap: 8px 14px;
|
||||||
|
|
||||||
.welcome-title-block {
|
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mode-grid {
|
.mode-grid {
|
||||||
@@ -639,12 +647,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.welcome-header {
|
.welcome-header {
|
||||||
flex-direction: row;
|
flex-wrap: nowrap;
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.welcome-title-block {
|
|
||||||
text-align: left;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mode-grid-compact {
|
.mode-grid-compact {
|
||||||
@@ -664,28 +667,27 @@ body {
|
|||||||
|
|
||||||
header {
|
header {
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
padding: 10px 12px;
|
padding: 0 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-start;
|
justify-content: space-between;
|
||||||
gap: 10px;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: 52px;
|
height: 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
.header-left {
|
||||||
header {
|
display: flex;
|
||||||
justify-content: center;
|
align-items: center;
|
||||||
padding: 12px 20px;
|
gap: 10px;
|
||||||
}
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
.header-right {
|
||||||
header {
|
display: flex;
|
||||||
text-align: center;
|
align-items: center;
|
||||||
display: block;
|
gap: 12px;
|
||||||
}
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
header::before {
|
header::before {
|
||||||
@@ -709,14 +711,13 @@ header h1 {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.15em;
|
letter-spacing: 0.15em;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: inline;
|
white-space: nowrap;
|
||||||
vertical-align: middle;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
display: inline-block;
|
display: flex;
|
||||||
vertical-align: middle;
|
align-items: center;
|
||||||
margin-right: 8px;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo svg {
|
.logo svg {
|
||||||
@@ -917,7 +918,7 @@ header h1 {
|
|||||||
left: 0;
|
left: 0;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
min-width: 180px;
|
min-width: 180px;
|
||||||
background: var(--bg-secondary);
|
background: #101823;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||||
@@ -1088,19 +1089,7 @@ header h1 {
|
|||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
header p {
|
/* subtitle removed — compact header */
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 11px;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
margin: 4px 0 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
header p.subtitle {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin: 0 0 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
header h1 .tagline {
|
header h1 .tagline {
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
@@ -1204,15 +1193,14 @@ header h1 .tagline {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 4px 12px;
|
padding: 4px 14px;
|
||||||
background: var(--accent-cyan);
|
background: var(--accent-cyan);
|
||||||
color: var(--bg-primary);
|
color: var(--bg-primary);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 10px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
margin-left: 10px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.active-mode-indicator .pulse-dot {
|
.active-mode-indicator .pulse-dot {
|
||||||
@@ -1631,10 +1619,9 @@ header h1 .tagline {
|
|||||||
|
|
||||||
.section.collapsed h3 {
|
.section.collapsed h3 {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
margin-bottom: 0 !important;
|
margin: 0 !important;
|
||||||
min-height: 0 !important;
|
min-height: 0 !important;
|
||||||
padding-top: 10px !important;
|
padding: 10px 12px !important;
|
||||||
padding-bottom: 10px !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.section.collapsed h3::after {
|
.section.collapsed h3::after {
|
||||||
@@ -1643,7 +1630,7 @@ header h1 .tagline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.section.collapsed {
|
.section.collapsed {
|
||||||
padding-bottom: 0 !important;
|
padding: 0 !important;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2433,6 +2420,19 @@ header h1 .tagline {
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mode-content h3 {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.mode-content {
|
.mode-content {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -2739,7 +2739,7 @@ header h1 .tagline {
|
|||||||
gap: 15px;
|
gap: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1023px) {
|
||||||
.pass-predictor {
|
.pass-predictor {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@@ -4090,13 +4090,13 @@ header h1 .tagline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* WiFi Responsive */
|
/* WiFi Responsive */
|
||||||
@media (max-width: 1400px) {
|
@media (max-width: 1280px) {
|
||||||
.wifi-main-content {
|
.wifi-main-content {
|
||||||
grid-template-columns: minmax(280px, 1fr) 240px 240px;
|
grid-template-columns: minmax(280px, 1fr) 240px 240px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 1280px) {
|
||||||
.wifi-layout-container {
|
.wifi-layout-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -5415,7 +5415,7 @@ header h1 .tagline {
|
|||||||
background: var(--bg-secondary, #1a1a2e);
|
background: var(--bg-secondary, #1a1a2e);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 1280px) {
|
||||||
.bt-layout-container {
|
.bt-layout-container {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex: 1;
|
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);
|
box-shadow: var(--visual-glow-soft), inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-menu {
|
||||||
|
background: linear-gradient(180deg, #162130 0%, #0e1621 100%);
|
||||||
|
border-color: rgba(74, 163, 255, 0.22);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(74, 163, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
.run-state-strip {
|
.run-state-strip {
|
||||||
margin: 8px var(--top-rail-gutter) 0;
|
margin: 8px var(--top-rail-gutter) 0;
|
||||||
border-color: rgba(74, 163, 255, 0.3);
|
border-color: rgba(74, 163, 255, 0.3);
|
||||||
@@ -7312,7 +7318,7 @@ body[data-mode="tscm"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.section h3 {
|
.section h3 {
|
||||||
background: linear-gradient(180deg, rgba(28, 44, 63, 0.88) 0%, rgba(20, 31, 44, 0.9) 100%);
|
background: linear-gradient(180deg, #1c2c3f 0%, #141f2c 100%);
|
||||||
border-bottom-color: rgba(74, 163, 255, 0.2);
|
border-bottom-color: rgba(74, 163, 255, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7419,6 +7425,10 @@ body[data-mode="tscm"] {
|
|||||||
box-shadow: 0 10px 24px rgba(18, 40, 66, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.7);
|
box-shadow: 0 10px 24px rgba(18, 40, 66, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .mode-nav-dropdown-menu {
|
||||||
|
background: #e9eef5;
|
||||||
|
}
|
||||||
|
|
||||||
[data-theme="light"] .run-state-strip {
|
[data-theme="light"] .run-state-strip {
|
||||||
background: linear-gradient(180deg, rgba(245, 248, 253, 0.97) 0%, rgba(238, 243, 250, 0.98) 100%);
|
background: linear-gradient(180deg, rgba(245, 248, 253, 0.97) 0%, rgba(238, 243, 250, 0.98) 100%);
|
||||||
border-color: rgba(31, 95, 168, 0.18);
|
border-color: rgba(31, 95, 168, 0.18);
|
||||||
@@ -7445,7 +7455,7 @@ body[data-mode="tscm"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="light"] .section h3 {
|
[data-theme="light"] .section h3 {
|
||||||
background: linear-gradient(180deg, rgba(235, 241, 250, 0.92) 0%, rgba(228, 236, 248, 0.94) 100%);
|
background: linear-gradient(180deg, #ebf1fa 0%, #e4ecf8 100%);
|
||||||
border-bottom-color: rgba(31, 95, 168, 0.14);
|
border-bottom-color: rgba(31, 95, 168, 0.14);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -513,7 +513,7 @@
|
|||||||
RESPONSIVE — stack HUD vertically on narrow
|
RESPONSIVE — stack HUD vertically on narrow
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 1023px) {
|
||||||
.btl-hud {
|
.btl-hud {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|||||||
@@ -1378,7 +1378,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive traceroute path */
|
/* Responsive traceroute path */
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 480px) {
|
||||||
.mesh-traceroute-path {
|
.mesh-traceroute-path {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -451,7 +451,7 @@
|
|||||||
|
|
||||||
/* ── Responsive ── */
|
/* ── Responsive ── */
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 1023px) {
|
||||||
.ms-stats-strip {
|
.ms-stats-strip {
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
}
|
}
|
||||||
@@ -460,7 +460,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 480px) {
|
||||||
.ms-stats-strip {
|
.ms-stats-strip {
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@
|
|||||||
|
|
||||||
.ook-warning {
|
.ook-warning {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #ffaa00;
|
color: var(--accent-orange);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -221,7 +221,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive: stack cards on narrow screens */
|
/* Responsive: stack cards on narrow screens */
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 480px) {
|
||||||
.radiosonde-card {
|
.radiosonde-card {
|
||||||
flex: 1 1 100%;
|
flex: 1 1 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
|||||||
@@ -408,7 +408,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Small tablet / large phone (640px) */
|
/* Small tablet / large phone (640px) */
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 768px) {
|
||||||
.spy-station-footer {
|
.spy-station-footer {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|||||||
@@ -1582,13 +1582,13 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 1280px) {
|
||||||
.subghz-rx-info-grid {
|
.subghz-rx-info-grid {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 1023px) {
|
||||||
.subghz-decode-layout {
|
.subghz-decode-layout {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
+87
-87
@@ -22,13 +22,13 @@
|
|||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
margin-top: 2px;
|
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.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.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.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); }
|
.threat-card.low.active { background: rgba(0,255,136,0.2); }
|
||||||
|
|
||||||
/* TSCM Dashboard */
|
/* TSCM Dashboard */
|
||||||
@@ -105,26 +105,26 @@
|
|||||||
background: rgba(74,158,255,0.1);
|
background: rgba(74,158,255,0.1);
|
||||||
}
|
}
|
||||||
.tscm-device-item.new {
|
.tscm-device-item.new {
|
||||||
border-left-color: #ff9933;
|
border-left-color: var(--severity-high);
|
||||||
animation: pulse-glow 2s infinite;
|
animation: pulse-glow 2s infinite;
|
||||||
}
|
}
|
||||||
.tscm-device-item.threat {
|
.tscm-device-item.threat {
|
||||||
border-left-color: #ff3366;
|
border-left-color: var(--severity-critical);
|
||||||
}
|
}
|
||||||
.tscm-device-item.baseline {
|
.tscm-device-item.baseline {
|
||||||
border-left-color: #00ff88;
|
border-left-color: var(--neon-green);
|
||||||
}
|
}
|
||||||
/* Classification colors */
|
/* Classification colors */
|
||||||
.tscm-device-item.classification-green {
|
.tscm-device-item.classification-green {
|
||||||
border-left-color: #00cc00;
|
border-left-color: var(--accent-green);
|
||||||
background: rgba(0, 204, 0, 0.1);
|
background: rgba(0, 204, 0, 0.1);
|
||||||
}
|
}
|
||||||
.tscm-device-item.classification-yellow {
|
.tscm-device-item.classification-yellow {
|
||||||
border-left-color: #ffcc00;
|
border-left-color: var(--severity-medium);
|
||||||
background: rgba(255, 204, 0, 0.1);
|
background: rgba(255, 204, 0, 0.1);
|
||||||
}
|
}
|
||||||
.tscm-device-item.classification-red {
|
.tscm-device-item.classification-red {
|
||||||
border-left-color: #ff3333;
|
border-left-color: var(--accent-red);
|
||||||
background: rgba(255, 51, 51, 0.15);
|
background: rgba(255, 51, 51, 0.15);
|
||||||
animation: pulse-glow 2s infinite;
|
animation: pulse-glow 2s infinite;
|
||||||
}
|
}
|
||||||
@@ -182,7 +182,7 @@
|
|||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
.tscm-action-btn:hover {
|
.tscm-action-btn:hover {
|
||||||
background: #2ecc71;
|
background: var(--accent-green-hover);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
.tscm-device-reasons {
|
.tscm-device-reasons {
|
||||||
@@ -202,7 +202,7 @@
|
|||||||
padding: 1px 4px;
|
padding: 1px 4px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
background: rgba(255, 51, 102, 0.2);
|
background: rgba(255, 51, 102, 0.2);
|
||||||
color: #ff3366;
|
color: var(--severity-critical);
|
||||||
border: 1px solid rgba(255, 51, 102, 0.4);
|
border: 1px solid rgba(255, 51, 102, 0.4);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.4px;
|
letter-spacing: 0.4px;
|
||||||
@@ -213,7 +213,7 @@
|
|||||||
padding: 1px 4px;
|
padding: 1px 4px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
background: rgba(74, 158, 255, 0.2);
|
background: rgba(74, 158, 255, 0.2);
|
||||||
color: #4a9eff;
|
color: var(--accent-cyan);
|
||||||
border: 1px solid rgba(74, 158, 255, 0.4);
|
border: 1px solid rgba(74, 158, 255, 0.4);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.4px;
|
letter-spacing: 0.4px;
|
||||||
@@ -224,7 +224,7 @@
|
|||||||
padding: 1px 4px;
|
padding: 1px 4px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
background: rgba(0, 255, 136, 0.2);
|
background: rgba(0, 255, 136, 0.2);
|
||||||
color: #00ff88;
|
color: var(--neon-green);
|
||||||
border: 1px solid rgba(0, 255, 136, 0.4);
|
border: 1px solid rgba(0, 255, 136, 0.4);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.4px;
|
letter-spacing: 0.4px;
|
||||||
@@ -268,20 +268,20 @@
|
|||||||
}
|
}
|
||||||
.score-badge.score-low {
|
.score-badge.score-low {
|
||||||
background: rgba(0, 204, 0, 0.2);
|
background: rgba(0, 204, 0, 0.2);
|
||||||
color: #00cc00;
|
color: var(--accent-green);
|
||||||
}
|
}
|
||||||
.score-badge.score-medium {
|
.score-badge.score-medium {
|
||||||
background: rgba(255, 204, 0, 0.2);
|
background: rgba(255, 204, 0, 0.2);
|
||||||
color: #ffcc00;
|
color: var(--severity-medium);
|
||||||
}
|
}
|
||||||
.score-badge.score-high {
|
.score-badge.score-high {
|
||||||
background: rgba(255, 51, 51, 0.2);
|
background: rgba(255, 51, 51, 0.2);
|
||||||
color: #ff3333;
|
color: var(--accent-red);
|
||||||
}
|
}
|
||||||
.tscm-action {
|
.tscm-action {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: #ff9933;
|
color: var(--severity-high);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
@@ -290,12 +290,12 @@
|
|||||||
padding: 12px;
|
padding: 12px;
|
||||||
background: rgba(255, 153, 51, 0.1);
|
background: rgba(255, 153, 51, 0.1);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 1px solid #ff9933;
|
border: 1px solid var(--severity-high);
|
||||||
}
|
}
|
||||||
.tscm-correlations h4 {
|
.tscm-correlations h4 {
|
||||||
margin: 0 0 8px 0;
|
margin: 0 0 8px 0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #ff9933;
|
color: var(--severity-high);
|
||||||
}
|
}
|
||||||
.correlation-item {
|
.correlation-item {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
@@ -332,9 +332,9 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
.summary-stat.high-interest .count { color: #ff3333; }
|
.summary-stat.high-interest .count { color: var(--accent-red); }
|
||||||
.summary-stat.needs-review .count { color: #ffcc00; }
|
.summary-stat.needs-review .count { color: var(--severity-medium); }
|
||||||
.summary-stat.informational .count { color: #00cc00; }
|
.summary-stat.informational .count { color: var(--accent-green); }
|
||||||
.tscm-assessment {
|
.tscm-assessment {
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
margin: 12px 0;
|
margin: 12px 0;
|
||||||
@@ -343,18 +343,18 @@
|
|||||||
}
|
}
|
||||||
.tscm-assessment.high-interest {
|
.tscm-assessment.high-interest {
|
||||||
background: rgba(255, 51, 51, 0.15);
|
background: rgba(255, 51, 51, 0.15);
|
||||||
border: 1px solid #ff3333;
|
border: 1px solid var(--accent-red);
|
||||||
color: #ff3333;
|
color: var(--accent-red);
|
||||||
}
|
}
|
||||||
.tscm-assessment.needs-review {
|
.tscm-assessment.needs-review {
|
||||||
background: rgba(255, 204, 0, 0.15);
|
background: rgba(255, 204, 0, 0.15);
|
||||||
border: 1px solid #ffcc00;
|
border: 1px solid var(--severity-medium);
|
||||||
color: #ffcc00;
|
color: var(--severity-medium);
|
||||||
}
|
}
|
||||||
.tscm-assessment.informational {
|
.tscm-assessment.informational {
|
||||||
background: rgba(0, 204, 0, 0.15);
|
background: rgba(0, 204, 0, 0.15);
|
||||||
border: 1px solid #00cc00;
|
border: 1px solid var(--accent-green);
|
||||||
color: #00cc00;
|
color: var(--accent-green);
|
||||||
}
|
}
|
||||||
.tscm-disclaimer {
|
.tscm-disclaimer {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
@@ -452,16 +452,16 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
border: 3px solid;
|
border: 3px solid;
|
||||||
}
|
}
|
||||||
.score-circle.high { border-color: #ff3333; background: rgba(255, 51, 51, 0.1); }
|
.score-circle.high { border-color: var(--accent-red); background: rgba(255, 51, 51, 0.1); }
|
||||||
.score-circle.medium { border-color: #ffcc00; background: rgba(255, 204, 0, 0.1); }
|
.score-circle.medium { border-color: var(--severity-medium); background: rgba(255, 204, 0, 0.1); }
|
||||||
.score-circle.low { border-color: #00cc00; background: rgba(0, 204, 0, 0.1); }
|
.score-circle.low { border-color: var(--accent-green); background: rgba(0, 204, 0, 0.1); }
|
||||||
.score-circle .score-value {
|
.score-circle .score-value {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
.score-circle.high .score-value { color: #ff3333; }
|
.score-circle.high .score-value { color: var(--accent-red); }
|
||||||
.score-circle.medium .score-value { color: #ffcc00; }
|
.score-circle.medium .score-value { color: var(--severity-medium); }
|
||||||
.score-circle.low .score-value { color: #00cc00; }
|
.score-circle.low .score-value { color: var(--accent-green); }
|
||||||
.score-circle .score-label {
|
.score-circle .score-label {
|
||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -521,7 +521,7 @@
|
|||||||
}
|
}
|
||||||
.indicator-type {
|
.indicator-type {
|
||||||
background: rgba(255, 153, 51, 0.2);
|
background: rgba(255, 153, 51, 0.2);
|
||||||
color: #ff9933;
|
color: var(--severity-high);
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
@@ -550,7 +550,7 @@
|
|||||||
.tscm-threat-action {
|
.tscm-threat-action {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: #ff9933;
|
color: var(--severity-high);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
@@ -606,7 +606,7 @@
|
|||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
background: rgba(74, 158, 255, 0.2);
|
background: rgba(74, 158, 255, 0.2);
|
||||||
color: #4a9eff;
|
color: var(--accent-cyan);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
@@ -614,7 +614,7 @@
|
|||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
background: rgba(255, 153, 51, 0.2);
|
background: rgba(255, 153, 51, 0.2);
|
||||||
color: #ff9933;
|
color: var(--severity-high);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
.correlation-detail-item {
|
.correlation-detail-item {
|
||||||
@@ -634,10 +634,10 @@
|
|||||||
background: rgba(0,0,0,0.2);
|
background: rgba(0,0,0,0.2);
|
||||||
border: 1px solid;
|
border: 1px solid;
|
||||||
}
|
}
|
||||||
.tscm-threat-item.critical { border-color: #ff3366; background: rgba(255,51,102,0.1); }
|
.tscm-threat-item.critical { border-color: var(--severity-critical); background: rgba(255,51,102,0.1); }
|
||||||
.tscm-threat-item.high { border-color: #ff9933; background: rgba(255,153,51,0.1); }
|
.tscm-threat-item.high { border-color: var(--severity-high); background: rgba(255,153,51,0.1); }
|
||||||
.tscm-threat-item.medium { border-color: #ffcc00; background: rgba(255,204,0,0.1); }
|
.tscm-threat-item.medium { border-color: var(--severity-medium); background: rgba(255,204,0,0.1); }
|
||||||
.tscm-threat-item.low { border-color: #00ff88; background: rgba(0,255,136,0.1); }
|
.tscm-threat-item.low { border-color: var(--severity-low); background: rgba(0,255,136,0.1); }
|
||||||
.tscm-threat-header {
|
.tscm-threat-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -807,7 +807,7 @@
|
|||||||
.meeting-pulse {
|
.meeting-pulse {
|
||||||
width: 10px;
|
width: 10px;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
background: #ff3366;
|
background: var(--severity-critical);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: pulse-dot 1.5s ease-in-out infinite;
|
animation: pulse-dot 1.5s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
@@ -819,7 +819,7 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
color: #ff3366;
|
color: var(--severity-critical);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
.meeting-info {
|
.meeting-info {
|
||||||
@@ -865,15 +865,15 @@
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
.cap-status.available { color: #00cc00; }
|
.cap-status.available { color: var(--accent-green); }
|
||||||
.cap-status.limited { color: #ffcc00; }
|
.cap-status.limited { color: var(--severity-medium); }
|
||||||
.cap-status.unavailable { color: #ff3333; }
|
.cap-status.unavailable { color: var(--accent-red); }
|
||||||
.cap-limitations {
|
.cap-limitations {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
color: #ff9933;
|
color: var(--severity-high);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
.cap-warn {
|
.cap-warn {
|
||||||
@@ -907,15 +907,15 @@
|
|||||||
}
|
}
|
||||||
.health-badge.healthy {
|
.health-badge.healthy {
|
||||||
background: rgba(0, 204, 0, 0.2);
|
background: rgba(0, 204, 0, 0.2);
|
||||||
color: #00cc00;
|
color: var(--accent-green);
|
||||||
}
|
}
|
||||||
.health-badge.noisy {
|
.health-badge.noisy {
|
||||||
background: rgba(255, 204, 0, 0.2);
|
background: rgba(255, 204, 0, 0.2);
|
||||||
color: #ffcc00;
|
color: var(--severity-medium);
|
||||||
}
|
}
|
||||||
.health-badge.stale {
|
.health-badge.stale {
|
||||||
background: rgba(255, 51, 51, 0.2);
|
background: rgba(255, 51, 51, 0.2);
|
||||||
color: #ff3333;
|
color: var(--accent-red);
|
||||||
}
|
}
|
||||||
.health-age {
|
.health-age {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -998,9 +998,9 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border-left: 3px solid var(--border-color);
|
border-left: 3px solid var(--border-color);
|
||||||
}
|
}
|
||||||
.cap-detail-item.available { border-left-color: #00cc00; }
|
.cap-detail-item.available { border-left-color: var(--accent-green); }
|
||||||
.cap-detail-item.limited { border-left-color: #ffcc00; }
|
.cap-detail-item.limited { border-left-color: var(--severity-medium); }
|
||||||
.cap-detail-item.unavailable { border-left-color: #ff3333; }
|
.cap-detail-item.unavailable { border-left-color: var(--accent-red); }
|
||||||
.cap-detail-header {
|
.cap-detail-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -1016,9 +1016,9 @@
|
|||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
.cap-detail-status.available { background: rgba(0, 204, 0, 0.2); color: #00cc00; }
|
.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: #ffcc00; }
|
.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: #ff3333; }
|
.cap-detail-status.unavailable { background: rgba(255, 51, 51, 0.2); color: var(--accent-red); }
|
||||||
.cap-detail-limits {
|
.cap-detail-limits {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -1034,7 +1034,7 @@
|
|||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
background: rgba(0, 0, 0, 0.2);
|
background: rgba(0, 0, 0, 0.2);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border-left: 3px solid #00cc00;
|
border-left: 3px solid var(--accent-green);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1064,7 +1064,7 @@
|
|||||||
}
|
}
|
||||||
.known-device-btn.remove {
|
.known-device-btn.remove {
|
||||||
background: rgba(255, 51, 51, 0.2);
|
background: rgba(255, 51, 51, 0.2);
|
||||||
color: #ff3333;
|
color: var(--accent-red);
|
||||||
}
|
}
|
||||||
.known-device-btn.remove:hover {
|
.known-device-btn.remove:hover {
|
||||||
background: rgba(255, 51, 51, 0.4);
|
background: rgba(255, 51, 51, 0.4);
|
||||||
@@ -1083,9 +1083,9 @@
|
|||||||
.case-item:hover {
|
.case-item:hover {
|
||||||
background: rgba(74, 158, 255, 0.1);
|
background: rgba(74, 158, 255, 0.1);
|
||||||
}
|
}
|
||||||
.case-item.priority-high { border-left-color: #ff3333; }
|
.case-item.priority-high { border-left-color: var(--accent-red); }
|
||||||
.case-item.priority-normal { border-left-color: #4a9eff; }
|
.case-item.priority-normal { border-left-color: var(--accent-cyan); }
|
||||||
.case-item.priority-low { border-left-color: #00cc00; }
|
.case-item.priority-low { border-left-color: var(--accent-green); }
|
||||||
.case-header {
|
.case-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -1102,8 +1102,8 @@
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
.case-status.open { background: rgba(0, 204, 0, 0.2); color: #00cc00; }
|
.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: #888; }
|
.case-status.closed { background: rgba(128, 128, 128, 0.2); color: var(--text-secondary); }
|
||||||
.case-meta {
|
.case-meta {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -1117,7 +1117,7 @@
|
|||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
background: rgba(0, 0, 0, 0.2);
|
background: rgba(0, 0, 0, 0.2);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border-left: 3px solid #ff9933;
|
border-left: 3px solid var(--severity-high);
|
||||||
}
|
}
|
||||||
.playbook-header {
|
.playbook-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1135,9 +1135,9 @@
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
.playbook-risk.high_interest { background: rgba(255, 51, 51, 0.2); color: #ff3333; }
|
.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: #ffcc00; }
|
.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: #00cc00; }
|
.playbook-risk.informational { background: rgba(0, 204, 0, 0.2); color: var(--accent-green); }
|
||||||
.playbook-desc {
|
.playbook-desc {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
@@ -1153,7 +1153,7 @@
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
.playbook-step-num {
|
.playbook-step-num {
|
||||||
color: #ff9933;
|
color: var(--severity-high);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-right: 6px;
|
margin-right: 6px;
|
||||||
}
|
}
|
||||||
@@ -1223,19 +1223,19 @@
|
|||||||
}
|
}
|
||||||
.proximity-badge.very_close {
|
.proximity-badge.very_close {
|
||||||
background: rgba(255, 51, 51, 0.2);
|
background: rgba(255, 51, 51, 0.2);
|
||||||
color: #ff3333;
|
color: var(--accent-red);
|
||||||
}
|
}
|
||||||
.proximity-badge.close {
|
.proximity-badge.close {
|
||||||
background: rgba(255, 153, 51, 0.2);
|
background: rgba(255, 153, 51, 0.2);
|
||||||
color: #ff9933;
|
color: var(--severity-high);
|
||||||
}
|
}
|
||||||
.proximity-badge.moderate {
|
.proximity-badge.moderate {
|
||||||
background: rgba(255, 204, 0, 0.2);
|
background: rgba(255, 204, 0, 0.2);
|
||||||
color: #ffcc00;
|
color: var(--severity-medium);
|
||||||
}
|
}
|
||||||
.proximity-badge.far {
|
.proximity-badge.far {
|
||||||
background: rgba(0, 204, 0, 0.2);
|
background: rgba(0, 204, 0, 0.2);
|
||||||
color: #00cc00;
|
color: var(--accent-green);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Add to Known Device Button */
|
/* Add to Known Device Button */
|
||||||
@@ -1243,7 +1243,7 @@
|
|||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
background: rgba(0, 204, 0, 0.2);
|
background: rgba(0, 204, 0, 0.2);
|
||||||
color: #00cc00;
|
color: var(--accent-green);
|
||||||
border: 1px solid rgba(0, 204, 0, 0.3);
|
border: 1px solid rgba(0, 204, 0, 0.3);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -1307,15 +1307,15 @@
|
|||||||
/* Modal Header Classification Colors */
|
/* Modal Header Classification Colors */
|
||||||
.device-detail-header.classification-cyan {
|
.device-detail-header.classification-cyan {
|
||||||
background: linear-gradient(135deg, rgba(0, 204, 255, 0.2) 0%, rgba(0, 150, 200, 0.1) 100%);
|
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 {
|
.device-detail-header.classification-orange {
|
||||||
background: linear-gradient(135deg, rgba(255, 153, 51, 0.2) 0%, rgba(200, 120, 40, 0.1) 100%);
|
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 {
|
.device-detail-header.classification-green {
|
||||||
background: linear-gradient(135deg, rgba(0, 204, 0, 0.2) 0%, rgba(0, 150, 0, 0.1) 100%);
|
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 */
|
/* Playbook Enhancements */
|
||||||
@@ -1330,7 +1330,7 @@
|
|||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
background: rgba(255, 153, 51, 0.2);
|
background: rgba(255, 153, 51, 0.2);
|
||||||
color: #ff9933;
|
color: var(--severity-high);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
@@ -1360,7 +1360,7 @@
|
|||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
background: rgba(74, 158, 255, 0.2);
|
background: rgba(74, 158, 255, 0.2);
|
||||||
color: #4a9eff;
|
color: var(--accent-cyan);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
}
|
}
|
||||||
@@ -1404,7 +1404,7 @@
|
|||||||
|
|
||||||
/* Recording State */
|
/* Recording State */
|
||||||
.icon-recording {
|
.icon-recording {
|
||||||
color: #ff3366;
|
color: var(--severity-critical);
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-recording.active svg {
|
.icon-recording.active svg {
|
||||||
@@ -1418,11 +1418,11 @@
|
|||||||
|
|
||||||
/* Anomaly Indicator */
|
/* Anomaly Indicator */
|
||||||
.icon-anomaly {
|
.icon-anomaly {
|
||||||
color: #ff9933;
|
color: var(--severity-high);
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-anomaly.critical {
|
.icon-anomaly.critical {
|
||||||
color: #ff3366;
|
color: var(--severity-critical);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Export Icon */
|
/* Export Icon */
|
||||||
@@ -1508,7 +1508,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.recording-status.active {
|
.recording-status.active {
|
||||||
color: #ff3366;
|
color: var(--severity-critical);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1526,12 +1526,12 @@
|
|||||||
|
|
||||||
.anomaly-flag.needs-review {
|
.anomaly-flag.needs-review {
|
||||||
background: rgba(255, 153, 51, 0.2);
|
background: rgba(255, 153, 51, 0.2);
|
||||||
color: #ff9933;
|
color: var(--severity-high);
|
||||||
}
|
}
|
||||||
|
|
||||||
.anomaly-flag.high-interest {
|
.anomaly-flag.high-interest {
|
||||||
background: rgba(255, 51, 51, 0.2);
|
background: rgba(255, 51, 51, 0.2);
|
||||||
color: #ff3333;
|
color: var(--accent-red);
|
||||||
}
|
}
|
||||||
|
|
||||||
.anomaly-flag .icon {
|
.anomaly-flag .icon {
|
||||||
@@ -1639,7 +1639,7 @@
|
|||||||
}
|
}
|
||||||
.tscm-summary-risk {
|
.tscm-summary-risk {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: #ff9933;
|
color: var(--severity-high);
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -763,7 +763,7 @@
|
|||||||
border: 1px solid rgba(74, 163, 255, 0.22);
|
border: 1px solid rgba(74, 163, 255, 0.22);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1023px) {
|
||||||
.wf-monitor-strip {
|
.wf-monitor-strip {
|
||||||
grid-template-columns: repeat(2, minmax(220px, 1fr));
|
grid-template-columns: repeat(2, minmax(220px, 1fr));
|
||||||
grid-auto-rows: minmax(70px, auto);
|
grid-auto-rows: minmax(70px, auto);
|
||||||
@@ -778,7 +778,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 768px) {
|
||||||
.wf-headline {
|
.wf-headline {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
|||||||
@@ -32,12 +32,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.wxsat-strip-dot.capturing {
|
.wxsat-strip-dot.capturing {
|
||||||
background: #00ff88;
|
background: var(--neon-green);
|
||||||
animation: wxsat-pulse 1.5s ease-in-out infinite;
|
animation: wxsat-pulse 1.5s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wxsat-strip-dot.decoding {
|
.wxsat-strip-dot.decoding {
|
||||||
background: #00d4ff;
|
background: var(--accent-cyan);
|
||||||
animation: wxsat-pulse 0.8s ease-in-out infinite;
|
animation: wxsat-pulse 0.8s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,8 +70,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.wxsat-strip-btn.stop {
|
.wxsat-strip-btn.stop {
|
||||||
border-color: #ff4444;
|
border-color: var(--accent-red);
|
||||||
color: #ff4444;
|
color: var(--accent-red);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wxsat-strip-btn.stop:hover {
|
.wxsat-strip-btn.stop:hover {
|
||||||
@@ -124,7 +124,7 @@
|
|||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
accent-color: #00ff88;
|
accent-color: var(--neon-green);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wxsat-schedule-toggle input:checked + .wxsat-toggle-label {
|
.wxsat-schedule-toggle input:checked + .wxsat-toggle-label {
|
||||||
@@ -207,12 +207,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.wxsat-countdown-box.imminent {
|
.wxsat-countdown-box.imminent {
|
||||||
border-color: #ffbb00;
|
border-color: var(--accent-yellow);
|
||||||
box-shadow: 0 0 8px rgba(255, 187, 0, 0.2);
|
box-shadow: 0 0 8px rgba(255, 187, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wxsat-countdown-box.active {
|
.wxsat-countdown-box.active {
|
||||||
border-color: #00ff88;
|
border-color: var(--neon-green);
|
||||||
box-shadow: 0 0 8px rgba(0, 255, 136, 0.3);
|
box-shadow: 0 0 8px rgba(0, 255, 136, 0.3);
|
||||||
animation: wxsat-glow 1.5s ease-in-out infinite;
|
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.apt { background: rgba(0, 212, 255, 0.6); }
|
||||||
.wxsat-timeline-pass.lrpt { background: rgba(0, 255, 136, 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 {
|
.wxsat-timeline-cursor {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 2px;
|
top: 2px;
|
||||||
width: 2px;
|
width: 2px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
background: #ff4444;
|
background: var(--accent-red);
|
||||||
border-radius: 1px;
|
border-radius: 1px;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
@@ -375,7 +375,7 @@
|
|||||||
|
|
||||||
.wxsat-pass-card.active,
|
.wxsat-pass-card.active,
|
||||||
.wxsat-pass-card.selected {
|
.wxsat-pass-card.selected {
|
||||||
border-color: #00ff88;
|
border-color: var(--neon-green);
|
||||||
background: rgba(0, 255, 136, 0.05);
|
background: rgba(0, 255, 136, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,7 +385,7 @@
|
|||||||
padding: 1px 4px;
|
padding: 1px 4px;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
background: rgba(255, 187, 0, 0.15);
|
background: rgba(255, 187, 0, 0.15);
|
||||||
color: #ffbb00;
|
color: var(--accent-yellow);
|
||||||
margin-left: 6px;
|
margin-left: 6px;
|
||||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -414,12 +414,12 @@
|
|||||||
|
|
||||||
.wxsat-pass-mode.apt {
|
.wxsat-pass-mode.apt {
|
||||||
background: rgba(0, 212, 255, 0.15);
|
background: rgba(0, 212, 255, 0.15);
|
||||||
color: #00d4ff;
|
color: var(--accent-cyan);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wxsat-pass-mode.lrpt {
|
.wxsat-pass-mode.lrpt {
|
||||||
background: rgba(0, 255, 136, 0.15);
|
background: rgba(0, 255, 136, 0.15);
|
||||||
color: #00ff88;
|
color: var(--neon-green);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wxsat-pass-details {
|
.wxsat-pass-details {
|
||||||
@@ -450,17 +450,17 @@
|
|||||||
|
|
||||||
.wxsat-pass-quality.excellent {
|
.wxsat-pass-quality.excellent {
|
||||||
background: rgba(0, 255, 136, 0.15);
|
background: rgba(0, 255, 136, 0.15);
|
||||||
color: #00ff88;
|
color: var(--neon-green);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wxsat-pass-quality.good {
|
.wxsat-pass-quality.good {
|
||||||
background: rgba(0, 212, 255, 0.15);
|
background: rgba(0, 212, 255, 0.15);
|
||||||
color: #00d4ff;
|
color: var(--accent-cyan);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wxsat-pass-quality.fair {
|
.wxsat-pass-quality.fair {
|
||||||
background: rgba(255, 187, 0, 0.15);
|
background: rgba(255, 187, 0, 0.15);
|
||||||
color: #ffbb00;
|
color: var(--accent-yellow);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Center Panel (Polar + Map) ===== */
|
/* ===== Center Panel (Polar + Map) ===== */
|
||||||
@@ -900,7 +900,7 @@
|
|||||||
|
|
||||||
.wxsat-modal-btn.delete:hover {
|
.wxsat-modal-btn.delete:hover {
|
||||||
background: rgba(255, 68, 68, 0.9);
|
background: rgba(255, 68, 68, 0.9);
|
||||||
border-color: #ff4444;
|
border-color: var(--accent-red);
|
||||||
color: var(--text-inverse);
|
color: var(--text-inverse);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -920,12 +920,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.wxsat-gallery-clear-btn:hover {
|
.wxsat-gallery-clear-btn:hover {
|
||||||
color: #ff4444;
|
color: var(--accent-red);
|
||||||
background: rgba(255, 68, 68, 0.1);
|
background: rgba(255, 68, 68, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Responsive ===== */
|
/* ===== Responsive ===== */
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1023px) {
|
||||||
.wxsat-content {
|
.wxsat-content {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@@ -1041,8 +1041,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.wxsat-phase-step.active {
|
.wxsat-phase-step.active {
|
||||||
color: #00ff88;
|
color: var(--neon-green);
|
||||||
border-color: #00ff88;
|
border-color: var(--neon-green);
|
||||||
background: rgba(0, 255, 136, 0.1);
|
background: rgba(0, 255, 136, 0.1);
|
||||||
box-shadow: 0 0 8px rgba(0, 255, 136, 0.2);
|
box-shadow: 0 0 8px rgba(0, 255, 136, 0.2);
|
||||||
}
|
}
|
||||||
@@ -1055,8 +1055,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.wxsat-phase-step.error {
|
.wxsat-phase-step.error {
|
||||||
color: #ff4444;
|
color: var(--accent-red);
|
||||||
border-color: #ff4444;
|
border-color: var(--accent-red);
|
||||||
background: rgba(255, 68, 68, 0.1);
|
background: rgba(255, 68, 68, 0.1);
|
||||||
box-shadow: 0 0 8px rgba(255, 68, 68, 0.2);
|
box-shadow: 0 0 8px rgba(255, 68, 68, 0.2);
|
||||||
}
|
}
|
||||||
@@ -1115,8 +1115,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.wxsat-console-entry.wxsat-log-signal {
|
.wxsat-console-entry.wxsat-log-signal {
|
||||||
border-left-color: #00ff88;
|
border-left-color: var(--neon-green);
|
||||||
color: #00ff88;
|
color: var(--neon-green);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wxsat-console-entry.wxsat-log-progress {
|
.wxsat-console-entry.wxsat-log-progress {
|
||||||
@@ -1125,18 +1125,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.wxsat-console-entry.wxsat-log-save {
|
.wxsat-console-entry.wxsat-log-save {
|
||||||
border-left-color: #ffbb00;
|
border-left-color: var(--accent-yellow);
|
||||||
color: #ffbb00;
|
color: var(--accent-yellow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wxsat-console-entry.wxsat-log-error {
|
.wxsat-console-entry.wxsat-log-error {
|
||||||
border-left-color: #ff4444;
|
border-left-color: var(--accent-red);
|
||||||
color: #ff4444;
|
color: var(--accent-red);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wxsat-console-entry.wxsat-log-warning {
|
.wxsat-console-entry.wxsat-log-warning {
|
||||||
border-left-color: #ff8800;
|
border-left-color: var(--neon-orange);
|
||||||
color: #ff8800;
|
color: var(--neon-orange);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wxsat-console-entry.wxsat-log-debug {
|
.wxsat-console-entry.wxsat-log-debug {
|
||||||
|
|||||||
+32
-32
@@ -41,15 +41,15 @@
|
|||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #444;
|
background: var(--text-muted);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wefax-strip-dot.scanning { background: #ffaa00; animation: wefax-pulse 1.5s ease-in-out infinite; }
|
.wefax-strip-dot.scanning { background: var(--accent-orange); 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.phasing { background: var(--accent-yellow); 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.receiving { background: var(--accent-green); animation: wefax-pulse 1s ease-in-out infinite; }
|
||||||
.wefax-strip-dot.complete { background: #00cc66; }
|
.wefax-strip-dot.complete { background: var(--accent-green); }
|
||||||
.wefax-strip-dot.error { background: #f44; }
|
.wefax-strip-dot.error { background: var(--accent-red); }
|
||||||
|
|
||||||
@keyframes wefax-pulse {
|
@keyframes wefax-pulse {
|
||||||
0%, 100% { opacity: 1; }
|
0%, 100% { opacity: 1; }
|
||||||
@@ -81,17 +81,17 @@
|
|||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wefax-strip-btn.start { color: #ffaa00; border-color: #ffaa0044; }
|
.wefax-strip-btn.start { color: var(--accent-orange); border-color: #ffaa0044; }
|
||||||
.wefax-strip-btn.start:hover { background: #ffaa0015; border-color: #ffaa00; }
|
.wefax-strip-btn.start:hover { background: #ffaa0015; border-color: var(--accent-orange); }
|
||||||
.wefax-strip-btn.start.wefax-strip-btn-error {
|
.wefax-strip-btn.start.wefax-strip-btn-error {
|
||||||
border-color: #ffaa00;
|
border-color: var(--accent-orange);
|
||||||
color: #ffaa00;
|
color: var(--accent-orange);
|
||||||
box-shadow: 0 0 8px rgba(255, 170, 0, 0.3);
|
box-shadow: 0 0 8px rgba(255, 170, 0, 0.3);
|
||||||
animation: wefax-pulse 0.6s ease-in-out 3;
|
animation: wefax-pulse 0.6s ease-in-out 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wefax-strip-btn.stop { color: #f44; border-color: #f4444444; }
|
.wefax-strip-btn.stop { color: var(--accent-red); border-color: #f4444444; }
|
||||||
.wefax-strip-btn.stop:hover { background: #f4441a; border-color: #f44; }
|
.wefax-strip-btn.stop:hover { background: #f4441a; border-color: var(--accent-red); }
|
||||||
|
|
||||||
.wefax-strip-divider {
|
.wefax-strip-divider {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
@@ -114,7 +114,7 @@
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wefax-strip-value.accent-amber { color: #ffaa00; }
|
.wefax-strip-value.accent-amber { color: var(--accent-orange); }
|
||||||
|
|
||||||
.wefax-strip-label {
|
.wefax-strip-label {
|
||||||
font-family: var(--font-mono, monospace);
|
font-family: var(--font-mono, monospace);
|
||||||
@@ -141,11 +141,11 @@
|
|||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
accent-color: #ffaa00;
|
accent-color: var(--accent-orange);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wefax-schedule-toggle input:checked + span {
|
.wefax-schedule-toggle input:checked + span {
|
||||||
color: #ffaa00;
|
color: var(--accent-orange);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Visuals Container --- */
|
/* --- Visuals Container --- */
|
||||||
@@ -185,7 +185,7 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
color: #ffaa00;
|
color: var(--accent-orange);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wefax-schedule-list {
|
.wefax-schedule-list {
|
||||||
@@ -209,7 +209,7 @@
|
|||||||
|
|
||||||
.wefax-schedule-entry.active {
|
.wefax-schedule-entry.active {
|
||||||
background: #ffaa0010;
|
background: #ffaa0010;
|
||||||
border-left: 3px solid #ffaa00;
|
border-left: 3px solid var(--accent-orange);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wefax-schedule-entry.upcoming {
|
.wefax-schedule-entry.upcoming {
|
||||||
@@ -221,7 +221,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.wefax-schedule-time {
|
.wefax-schedule-time {
|
||||||
color: #ffaa00;
|
color: var(--accent-orange);
|
||||||
min-width: 45px;
|
min-width: 45px;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
@@ -241,7 +241,7 @@
|
|||||||
|
|
||||||
.wefax-schedule-badge.live {
|
.wefax-schedule-badge.live {
|
||||||
background: #ffaa0030;
|
background: #ffaa0030;
|
||||||
color: #ffaa00;
|
color: var(--accent-orange);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,7 +279,7 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
color: #ffaa00;
|
color: var(--accent-orange);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wefax-live-content {
|
.wefax-live-content {
|
||||||
@@ -298,7 +298,7 @@
|
|||||||
.wefax-idle-state svg {
|
.wefax-idle-state svg {
|
||||||
width: 48px;
|
width: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
color: #ffaa0033;
|
color: rgba(214, 168, 94, 0.2);
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,7 +341,7 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
color: #ffaa00;
|
color: var(--accent-orange);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wefax-gallery-controls {
|
.wefax-gallery-controls {
|
||||||
@@ -370,8 +370,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.wefax-gallery-clear-btn:hover {
|
.wefax-gallery-clear-btn:hover {
|
||||||
border-color: #f44;
|
border-color: var(--accent-red);
|
||||||
color: #f44;
|
color: var(--accent-red);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wefax-gallery-grid {
|
.wefax-gallery-grid {
|
||||||
@@ -442,7 +442,7 @@
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
border: none;
|
border: none;
|
||||||
background: rgba(0, 0, 0, 0.7);
|
background: rgba(0, 0, 0, 0.7);
|
||||||
color: #ccc;
|
color: var(--text-secondary);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -451,8 +451,8 @@
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wefax-gallery-action:hover { color: #fff; }
|
.wefax-gallery-action:hover { color: var(--text-primary); }
|
||||||
.wefax-gallery-action.delete:hover { color: #f44; }
|
.wefax-gallery-action.delete:hover { color: var(--accent-red); }
|
||||||
|
|
||||||
/* --- Countdown Bar + Timeline --- */
|
/* --- Countdown Bar + Timeline --- */
|
||||||
.wefax-countdown-bar {
|
.wefax-countdown-bar {
|
||||||
@@ -490,12 +490,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.wefax-countdown-box.imminent {
|
.wefax-countdown-box.imminent {
|
||||||
border-color: #ffaa00;
|
border-color: var(--accent-orange);
|
||||||
box-shadow: 0 0 8px rgba(255, 170, 0, 0.2);
|
box-shadow: 0 0 8px rgba(255, 170, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wefax-countdown-box.active {
|
.wefax-countdown-box.active {
|
||||||
border-color: #ffaa00;
|
border-color: var(--accent-orange);
|
||||||
box-shadow: 0 0 8px rgba(255, 170, 0, 0.3);
|
box-shadow: 0 0 8px rgba(255, 170, 0, 0.3);
|
||||||
animation: wefax-glow 1.5s ease-in-out infinite;
|
animation: wefax-glow 1.5s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
@@ -530,7 +530,7 @@
|
|||||||
.wefax-countdown-content {
|
.wefax-countdown-content {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #ffaa00;
|
color: var(--accent-orange);
|
||||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -576,7 +576,7 @@
|
|||||||
|
|
||||||
.wefax-timeline-broadcast.active {
|
.wefax-timeline-broadcast.active {
|
||||||
background: rgba(255, 170, 0, 0.85);
|
background: rgba(255, 170, 0, 0.85);
|
||||||
border: 1px solid #ffaa00;
|
border: 1px solid var(--accent-orange);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wefax-timeline-cursor {
|
.wefax-timeline-cursor {
|
||||||
@@ -584,7 +584,7 @@
|
|||||||
top: 2px;
|
top: 2px;
|
||||||
width: 2px;
|
width: 2px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
background: #ff4444;
|
background: var(--accent-red);
|
||||||
border-radius: 1px;
|
border-radius: 1px;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -361,7 +361,7 @@
|
|||||||
RESPONSIVE
|
RESPONSIVE
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 1023px) {
|
||||||
.wfl-rssi-display {
|
.wfl-rssi-display {
|
||||||
font-size: 48px;
|
font-size: 48px;
|
||||||
}
|
}
|
||||||
|
|||||||
+144
-140
@@ -25,20 +25,20 @@
|
|||||||
--font-2xl: clamp(24px, 6vw, 40px);
|
--font-2xl: clamp(24px, 6vw, 40px);
|
||||||
|
|
||||||
/* Header height for calculations */
|
/* Header height for calculations */
|
||||||
--header-height: 52px;
|
--header-height: 48px;
|
||||||
--nav-height: 44px;
|
--nav-height: 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
:root {
|
:root {
|
||||||
--header-height: 60px;
|
--header-height: 48px;
|
||||||
--nav-height: 48px;
|
--nav-height: 48px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
:root {
|
:root {
|
||||||
--header-height: 96px;
|
--header-height: 48px;
|
||||||
--nav-height: 0px;
|
--nav-height: 0px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -424,30 +424,30 @@
|
|||||||
/* ============== MOBILE LAYOUT FIXES ============== */
|
/* ============== MOBILE LAYOUT FIXES ============== */
|
||||||
@media (max-width: 1023px) {
|
@media (max-width: 1023px) {
|
||||||
/* Fix main content to allow scrolling on mobile */
|
/* Fix main content to allow scrolling on mobile */
|
||||||
.main-content {
|
.app-shell .main-content {
|
||||||
height: auto !important;
|
height: auto;
|
||||||
min-height: calc(100dvh - var(--header-height) - var(--nav-height));
|
min-height: calc(100dvh - var(--header-height) - var(--nav-height));
|
||||||
overflow-y: auto !important;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
.app-shell .sidebar {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.output-panel {
|
.app-shell .output-panel {
|
||||||
min-height: 58vh;
|
min-height: 58vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.output-header {
|
.app-shell .output-header {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-controls {
|
.app-shell .header-controls {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
@@ -455,20 +455,21 @@
|
|||||||
padding-bottom: 2px;
|
padding-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-controls .stats {
|
.app-shell .header-controls .stats {
|
||||||
min-width: max-content;
|
min-width: max-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Container should not clip content */
|
/* Container should not clip content */
|
||||||
.container {
|
.app-shell .container {
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
height: auto;
|
height: auto;
|
||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Layout containers need to stack vertically on mobile */
|
/* Layout containers need to stack vertically on mobile */
|
||||||
.wifi-layout-container,
|
/* overrides inline style - JS sets display via style attribute */
|
||||||
.bt-layout-container {
|
.app-shell .wifi-layout-container,
|
||||||
|
.app-shell .bt-layout-container {
|
||||||
flex-direction: column !important;
|
flex-direction: column !important;
|
||||||
height: auto !important;
|
height: auto !important;
|
||||||
max-height: none !important;
|
max-height: none !important;
|
||||||
@@ -478,126 +479,128 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Visual panels should be scrollable, not clipped */
|
/* Visual panels should be scrollable, not clipped */
|
||||||
.wifi-visuals,
|
.app-shell .wifi-visuals,
|
||||||
.bt-visuals-column {
|
.app-shell .bt-visuals-column {
|
||||||
max-height: none !important;
|
max-height: none;
|
||||||
overflow: visible !important;
|
overflow: visible;
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Device lists should have reasonable height on mobile */
|
/* Device lists should have reasonable height on mobile */
|
||||||
.wifi-device-list,
|
.app-shell .wifi-device-list,
|
||||||
.bt-device-list {
|
.app-shell .bt-device-list {
|
||||||
max-height: 400px;
|
max-height: 400px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Visual panels should stack in single column on mobile when visible */
|
/* Visual panels should stack in single column on mobile when visible */
|
||||||
.wifi-visuals,
|
.app-shell .wifi-visuals,
|
||||||
.bt-visuals-column {
|
.app-shell .bt-visuals-column {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Only apply flex when aircraft visuals are shown (via JS setting display: grid) */
|
/* Stack aircraft visuals vertically on mobile when active */
|
||||||
#aircraftVisuals[style*="grid"] {
|
#aircraftVisuals.active {
|
||||||
display: flex !important;
|
display: flex;
|
||||||
flex-direction: column !important;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* APRS visuals - only when visible */
|
/* APRS visuals stack vertically on mobile */
|
||||||
#aprsVisuals[style*="flex"] {
|
.app-shell #aprsVisuals {
|
||||||
flex-direction: column !important;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wifi-visual-panel {
|
.app-shell .wifi-visual-panel {
|
||||||
grid-column: auto !important;
|
grid-column: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bt-main-area {
|
.app-shell .bt-main-area {
|
||||||
flex-direction: column !important;
|
flex-direction: column;
|
||||||
min-height: auto !important;
|
min-height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bt-side-panels {
|
.app-shell .bt-side-panels {
|
||||||
width: 100% !important;
|
width: 100%;
|
||||||
flex-direction: column !important;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bt-detail-grid {
|
.app-shell .bt-detail-grid {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.bt-row-secondary {
|
.app-shell .bt-row-secondary {
|
||||||
padding-left: 0 !important;
|
padding-left: 0;
|
||||||
white-space: normal !important;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bt-row-actions {
|
.app-shell .bt-row-actions {
|
||||||
padding-left: 0 !important;
|
padding-left: 0;
|
||||||
justify-content: flex-start !important;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bt-list-summary {
|
.app-shell .bt-list-summary {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============== MOBILE MAP FIXES ============== */
|
/* ============== MOBILE MAP FIXES ============== */
|
||||||
@media (max-width: 1023px) {
|
@media (max-width: 1023px) {
|
||||||
/* Aircraft map container needs explicit height on mobile */
|
/* Aircraft map container needs explicit height on mobile */
|
||||||
.aircraft-map-container {
|
.app-shell .aircraft-map-container {
|
||||||
height: 300px !important;
|
height: 300px;
|
||||||
min-height: 300px !important;
|
min-height: 300px;
|
||||||
width: 100% !important;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
#aircraftMap {
|
.app-shell #aircraftMap {
|
||||||
height: 100% !important;
|
height: 100%;
|
||||||
width: 100% !important;
|
width: 100%;
|
||||||
min-height: 250px;
|
min-height: 250px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* APRS map container */
|
/* APRS map container */
|
||||||
#aprsMap {
|
.app-shell #aprsMap {
|
||||||
min-height: 300px !important;
|
min-height: 300px;
|
||||||
height: 300px !important;
|
height: 300px;
|
||||||
width: 100% !important;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Satellite embed */
|
/* Satellite embed */
|
||||||
.satellite-dashboard-embed {
|
.app-shell .satellite-dashboard-embed {
|
||||||
height: 400px !important;
|
height: 400px;
|
||||||
min-height: 400px !important;
|
min-height: 400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Map panels should be full width */
|
/* Map panels should be full width */
|
||||||
|
/* overrides inline style - HTML sets grid-column via style attribute */
|
||||||
.wifi-visual-panel[style*="grid-column: span 2"] {
|
.wifi-visual-panel[style*="grid-column: span 2"] {
|
||||||
grid-column: auto !important;
|
grid-column: auto !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Make map container full width when it has ACARS sidebar */
|
/* 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"] {
|
.wifi-visual-panel[style*="display: flex"][style*="gap: 0"] {
|
||||||
flex-direction: column !important;
|
flex-direction: column !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ACARS sidebar should be below map on mobile */
|
/* ACARS sidebar should be below map on mobile */
|
||||||
.main-acars-sidebar {
|
.app-shell .main-acars-sidebar {
|
||||||
width: 100% !important;
|
width: 100%;
|
||||||
max-width: none !important;
|
max-width: none;
|
||||||
border-left: none !important;
|
border-left: none;
|
||||||
border-top: 1px solid var(--border-color, #1f2937) !important;
|
border-top: 1px solid var(--border-color, #1f2937);
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-acars-sidebar.collapsed {
|
.app-shell .main-acars-sidebar.collapsed {
|
||||||
width: 100% !important;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-acars-content {
|
.app-shell .main-acars-content {
|
||||||
max-height: 200px !important;
|
max-height: 200px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -611,55 +614,56 @@
|
|||||||
touch-action: manipulation;
|
touch-action: manipulation;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaflet-control-zoom a {
|
.app-shell .leaflet-container .leaflet-control-zoom a {
|
||||||
min-width: var(--touch-min, 44px) !important;
|
min-width: var(--touch-min, 44px);
|
||||||
min-height: var(--touch-min, 44px) !important;
|
min-height: var(--touch-min, 44px);
|
||||||
line-height: var(--touch-min, 44px) !important;
|
line-height: var(--touch-min, 44px);
|
||||||
font-size: 18px !important;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============== MOBILE HEADER STATS ============== */
|
/* ============== MOBILE HEADER STATS ============== */
|
||||||
@media (max-width: 1023px) {
|
@media (max-width: 1023px) {
|
||||||
.header-stats {
|
.app-shell .header-stats {
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Simplify header on mobile */
|
|
||||||
header h1 {
|
|
||||||
font-size: 16px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
header h1 .tagline,
|
|
||||||
header h1 .version-badge {
|
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
header .subtitle {
|
/* Simplify header on mobile */
|
||||||
font-size: 10px !important;
|
.app-shell header h1 {
|
||||||
white-space: nowrap;
|
font-size: 16px;
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
header .logo svg {
|
.app-shell header h1 .tagline {
|
||||||
width: 30px !important;
|
display: none;
|
||||||
height: 30px !important;
|
}
|
||||||
|
|
||||||
|
.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 ============== */
|
/* ============== MOBILE MODE PANELS ============== */
|
||||||
@media (max-width: 1023px) {
|
@media (max-width: 1023px) {
|
||||||
/* Mode panel grids should be single column */
|
/* Mode panel grids should be single column */
|
||||||
.data-grid,
|
.app-shell .data-grid,
|
||||||
.stats-grid,
|
.app-shell .stats-grid,
|
||||||
.sensor-grid {
|
.app-shell .sensor-grid {
|
||||||
grid-template-columns: 1fr !important;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Section headers should be easier to tap */
|
/* Section headers should be easier to tap */
|
||||||
.section h3 {
|
.app-shell .section h3 {
|
||||||
min-height: var(--touch-min);
|
min-height: var(--touch-min);
|
||||||
padding: 12px !important;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tables need horizontal scroll */
|
/* Tables need horizontal scroll */
|
||||||
@@ -682,85 +686,85 @@
|
|||||||
|
|
||||||
/* ============== WELCOME PAGE MOBILE ============== */
|
/* ============== WELCOME PAGE MOBILE ============== */
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.welcome-container {
|
.app-shell .welcome-container {
|
||||||
padding: 15px !important;
|
padding: 15px;
|
||||||
max-width: 100% !important;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.welcome-header {
|
.app-shell .welcome-header {
|
||||||
flex-direction: column;
|
flex-wrap: wrap;
|
||||||
text-align: center;
|
justify-content: center;
|
||||||
gap: 10px;
|
gap: 6px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.welcome-logo svg {
|
.app-shell .welcome-logo svg {
|
||||||
width: 50px;
|
width: 40px;
|
||||||
height: 50px;
|
height: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.welcome-title {
|
.app-shell .welcome-title {
|
||||||
font-size: 24px !important;
|
font-size: 1.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.welcome-content {
|
.app-shell .welcome-content {
|
||||||
grid-template-columns: 1fr !important;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mode-grid {
|
.app-shell .mode-grid {
|
||||||
grid-template-columns: repeat(2, 1fr) !important;
|
grid-template-columns: repeat(2, 1fr);
|
||||||
gap: 8px !important;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mode-card {
|
.app-shell .mode-card {
|
||||||
padding: 12px 8px !important;
|
padding: 12px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mode-icon {
|
.app-shell .mode-icon {
|
||||||
font-size: 20px !important;
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mode-name {
|
.app-shell .mode-name {
|
||||||
font-size: 11px !important;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mode-desc {
|
.app-shell .mode-desc {
|
||||||
font-size: 9px !important;
|
font-size: 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.changelog-release {
|
.app-shell .changelog-release {
|
||||||
padding: 10px !important;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============== TSCM MODE MOBILE ============== */
|
/* ============== TSCM MODE MOBILE ============== */
|
||||||
@media (max-width: 1023px) {
|
@media (max-width: 1023px) {
|
||||||
.tscm-layout {
|
.app-shell .tscm-layout {
|
||||||
flex-direction: column !important;
|
flex-direction: column;
|
||||||
height: auto !important;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tscm-spectrum-panel,
|
.app-shell .tscm-spectrum-panel,
|
||||||
.tscm-detection-panel {
|
.app-shell .tscm-detection-panel {
|
||||||
width: 100% !important;
|
width: 100%;
|
||||||
max-width: none !important;
|
max-width: none;
|
||||||
height: auto !important;
|
height: auto;
|
||||||
min-height: 300px;
|
min-height: 300px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============== LISTENING POST MOBILE ============== */
|
/* ============== LISTENING POST MOBILE ============== */
|
||||||
@media (max-width: 1023px) {
|
@media (max-width: 1023px) {
|
||||||
.radio-controls-section {
|
.app-shell .radio-controls-section {
|
||||||
flex-direction: column !important;
|
flex-direction: column;
|
||||||
gap: 15px;
|
gap: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.knobs-row {
|
.app-shell .knobs-row {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.radio-module-box {
|
.app-shell .radio-module-box {
|
||||||
width: 100% !important;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile header adjustments */
|
/* Mobile header adjustments */
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 768px) {
|
||||||
.header {
|
.header {
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -709,7 +709,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive */
|
/* Responsive */
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 1280px) {
|
||||||
.dashboard {
|
.dashboard {
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
grid-template-rows: 1fr auto auto;
|
grid-template-rows: 1fr auto auto;
|
||||||
@@ -745,7 +745,7 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 768px) {
|
||||||
.dashboard {
|
.dashboard {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -528,13 +528,13 @@ html.map-cyber-enabled .leaflet-container::after {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive */
|
/* Responsive */
|
||||||
@media (max-width: 960px) {
|
@media (max-width: 1023px) {
|
||||||
.settings-tabs {
|
.settings-tabs {
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 768px) {
|
||||||
.settings-modal.active {
|
.settings-modal.active {
|
||||||
padding: 20px 10px;
|
padding: 20px 10px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -485,7 +485,7 @@ async function syncLocalModeStates() {
|
|||||||
*/
|
*/
|
||||||
function showAgentModeWarnings(runningModes, modesDetail = {}) {
|
function showAgentModeWarnings(runningModes, modesDetail = {}) {
|
||||||
// SDR modes that can't run simultaneously on same device
|
// 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));
|
const runningSdrModes = runningModes.filter(m => sdrModes.includes(m));
|
||||||
|
|
||||||
let warning = document.getElementById('agentModeWarning');
|
let warning = document.getElementById('agentModeWarning');
|
||||||
@@ -613,7 +613,7 @@ function checkAgentAudioMode(modeToStart) {
|
|||||||
* @param {string} modeToStart - Mode to start
|
* @param {string} modeToStart - Mode to start
|
||||||
* @param {number} deviceToUse - Device index to use (optional, for smarter conflict detection)
|
* @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
|
if (currentAgent === 'local') return true; // No conflict checking for local
|
||||||
|
|
||||||
// First check if this is an audio mode
|
// First check if this is an audio mode
|
||||||
@@ -621,7 +621,7 @@ function checkAgentModeConflict(modeToStart, deviceToUse = null) {
|
|||||||
return false;
|
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 we're trying to start an SDR mode
|
||||||
if (sdrModes.includes(modeToStart)) {
|
if (sdrModes.includes(modeToStart)) {
|
||||||
@@ -648,11 +648,12 @@ function checkAgentModeConflict(modeToStart, deviceToUse = null) {
|
|||||||
return detail ? `${m} (SDR ${detail.device})` : m;
|
return detail ? `${m} (SDR ${detail.device})` : m;
|
||||||
}).join(', ');
|
}).join(', ');
|
||||||
|
|
||||||
const proceed = confirm(
|
const proceed = await AppFeedback.confirmAction({
|
||||||
`The agent's SDR device is currently running: ${modeList}\n\n` +
|
title: 'SDR Device Conflict',
|
||||||
`Starting ${modeToStart} on the same device will fail.\n\n` +
|
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?`,
|
||||||
`Do you want to stop the conflicting mode(s) first?`
|
confirmLabel: 'Stop & Continue',
|
||||||
);
|
confirmClass: 'btn-danger'
|
||||||
|
});
|
||||||
|
|
||||||
if (proceed) {
|
if (proceed) {
|
||||||
// Stop conflicting modes
|
// Stop conflicting modes
|
||||||
|
|||||||
@@ -269,8 +269,14 @@ const AlertCenter = (function() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteRule(ruleId) {
|
async function deleteRule(ruleId) {
|
||||||
if (!confirm('Delete this alert rule?')) return;
|
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' })
|
fetch(`/alerts/rules/${ruleId}`, { method: 'DELETE' })
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
|
|||||||
+31
-36
@@ -120,19 +120,19 @@ function switchMode(mode) {
|
|||||||
document.getElementById('spystationsMode')?.classList.toggle('active', mode === 'spystations');
|
document.getElementById('spystationsMode')?.classList.toggle('active', mode === 'spystations');
|
||||||
document.getElementById('meshtasticMode')?.classList.toggle('active', mode === 'meshtastic');
|
document.getElementById('meshtasticMode')?.classList.toggle('active', mode === 'meshtastic');
|
||||||
|
|
||||||
// Toggle stats visibility
|
// Toggle stats visibility via class
|
||||||
document.getElementById('pagerStats').style.display = mode === 'pager' ? 'flex' : 'none';
|
document.getElementById('pagerStats')?.classList.toggle('active', mode === 'pager');
|
||||||
document.getElementById('sensorStats').style.display = mode === 'sensor' ? 'flex' : 'none';
|
document.getElementById('sensorStats')?.classList.toggle('active', mode === 'sensor');
|
||||||
document.getElementById('aircraftStats').style.display = mode === 'aircraft' ? 'flex' : 'none';
|
document.getElementById('aircraftStats')?.classList.toggle('active', mode === 'aircraft');
|
||||||
document.getElementById('satelliteStats').style.display = mode === 'satellite' ? 'flex' : 'none';
|
document.getElementById('satelliteStats')?.classList.toggle('active', mode === 'satellite');
|
||||||
document.getElementById('wifiStats').style.display = mode === 'wifi' ? 'flex' : 'none';
|
document.getElementById('wifiStats')?.classList.toggle('active', mode === 'wifi');
|
||||||
|
|
||||||
// Hide signal meter - individual panels show signal strength where needed
|
// Hide signal meter
|
||||||
document.getElementById('signalMeter').style.display = 'none';
|
document.getElementById('signalMeter')?.classList.remove('active');
|
||||||
|
|
||||||
// Show/hide dashboard buttons in nav bar
|
// Show/hide dashboard buttons in nav bar
|
||||||
document.getElementById('adsbDashboardBtn').style.display = mode === 'aircraft' ? 'inline-flex' : 'none';
|
document.getElementById('adsbDashboardBtn')?.classList.toggle('active', mode === 'aircraft');
|
||||||
document.getElementById('satelliteDashboardBtn').style.display = mode === 'satellite' ? 'inline-flex' : 'none';
|
document.getElementById('satelliteDashboardBtn')?.classList.toggle('active', mode === 'satellite');
|
||||||
|
|
||||||
// Update active mode indicator
|
// Update active mode indicator
|
||||||
const modeNames = {
|
const modeNames = {
|
||||||
@@ -156,14 +156,14 @@ function switchMode(mode) {
|
|||||||
window.closeMobileDrawer();
|
window.closeMobileDrawer();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggle layout containers
|
// Toggle layout containers via class
|
||||||
document.getElementById('wifiLayoutContainer').style.display = mode === 'wifi' ? 'flex' : 'none';
|
document.getElementById('wifiLayoutContainer')?.classList.toggle('active', mode === 'wifi');
|
||||||
document.getElementById('btLayoutContainer').style.display = mode === 'bluetooth' ? 'flex' : 'none';
|
document.getElementById('btLayoutContainer')?.classList.toggle('active', mode === 'bluetooth');
|
||||||
|
|
||||||
// Respect the "Show Radar Display" checkbox for aircraft mode
|
// Respect the "Show Radar Display" checkbox for aircraft mode
|
||||||
const showRadar = document.getElementById('adsbEnableMap')?.checked;
|
const showRadar = document.getElementById('adsbEnableMap')?.checked;
|
||||||
document.getElementById('aircraftVisuals').style.display = (mode === 'aircraft' && showRadar) ? 'grid' : 'none';
|
document.getElementById('aircraftVisuals')?.classList.toggle('active', mode === 'aircraft' && showRadar);
|
||||||
document.getElementById('satelliteVisuals').style.display = mode === 'satellite' ? 'block' : 'none';
|
document.getElementById('satelliteVisuals')?.classList.toggle('active', mode === 'satellite');
|
||||||
|
|
||||||
// Update output panel title based on mode
|
// Update output panel title based on mode
|
||||||
const titles = {
|
const titles = {
|
||||||
@@ -178,35 +178,30 @@ function switchMode(mode) {
|
|||||||
document.getElementById('outputTitle').textContent = titles[mode] || 'Signal Monitor';
|
document.getElementById('outputTitle').textContent = titles[mode] || 'Signal Monitor';
|
||||||
|
|
||||||
// Show/hide Device Intelligence for modes that use it
|
// Show/hide Device Intelligence for modes that use it
|
||||||
|
const hideRecon = (mode === 'satellite' || mode === 'aircraft');
|
||||||
const reconBtn = document.getElementById('reconBtn');
|
const reconBtn = document.getElementById('reconBtn');
|
||||||
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
|
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
|
||||||
if (mode === 'satellite' || mode === 'aircraft') {
|
document.getElementById('reconPanel')?.classList.toggle('active', !hideRecon && typeof reconEnabled !== 'undefined' && reconEnabled);
|
||||||
document.getElementById('reconPanel').style.display = 'none';
|
if (reconBtn) reconBtn.classList.toggle('hidden', hideRecon);
|
||||||
if (reconBtn) reconBtn.style.display = 'none';
|
if (intelBtn) intelBtn.classList.toggle('hidden', hideRecon);
|
||||||
if (intelBtn) intelBtn.style.display = 'none';
|
|
||||||
} else {
|
|
||||||
if (reconBtn) reconBtn.style.display = 'inline-block';
|
|
||||||
if (intelBtn) intelBtn.style.display = 'inline-block';
|
|
||||||
if (typeof reconEnabled !== 'undefined' && reconEnabled) {
|
|
||||||
document.getElementById('reconPanel').style.display = 'block';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show RTL-SDR device section for modes that use it
|
// Show RTL-SDR device section for modes that use it
|
||||||
document.getElementById('rtlDeviceSection').style.display =
|
const showRtl = (mode === 'pager' || mode === 'sensor' || mode === 'aircraft');
|
||||||
(mode === 'pager' || mode === 'sensor' || mode === 'aircraft') ? 'block' : 'none';
|
document.getElementById('rtlDeviceSection')?.classList.toggle('active', showRtl);
|
||||||
|
|
||||||
// Toggle mode-specific tool status displays
|
// Toggle mode-specific tool status displays
|
||||||
document.getElementById('toolStatusPager').style.display = (mode === 'pager') ? 'grid' : 'none';
|
document.getElementById('toolStatusPager')?.classList.toggle('active', mode === 'pager');
|
||||||
document.getElementById('toolStatusSensor').style.display = (mode === 'sensor') ? 'grid' : 'none';
|
document.getElementById('toolStatusSensor')?.classList.toggle('active', mode === 'sensor');
|
||||||
document.getElementById('toolStatusAircraft').style.display = (mode === 'aircraft') ? 'grid' : 'none';
|
document.getElementById('toolStatusAircraft')?.classList.toggle('active', mode === 'aircraft');
|
||||||
|
|
||||||
// Hide waterfall and output console for modes with their own visualizations
|
// Hide waterfall and output console for modes with their own visualizations
|
||||||
document.querySelector('.waterfall-container').style.display =
|
const fullVisualModes = ['satellite', 'aircraft', 'wifi', 'bluetooth', 'meshtastic', 'aprs', 'tscm', 'spystations'];
|
||||||
(mode === 'satellite' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
|
const hideConsole = fullVisualModes.includes(mode);
|
||||||
document.getElementById('output').style.display =
|
document.querySelector('.waterfall-container')?.classList.toggle('active', !hideConsole);
|
||||||
(mode === 'satellite' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
|
document.getElementById('output')?.classList.toggle('active', !hideConsole);
|
||||||
document.querySelector('.status-bar').style.display = (mode === 'satellite' || mode === 'tscm' || mode === 'meshtastic' || mode === 'aprs' || mode === 'spystations') ? 'none' : 'flex';
|
|
||||||
|
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
|
// Load interfaces and initialize visualizations when switching modes
|
||||||
if (mode === 'wifi') {
|
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 stackEl = null;
|
||||||
let nextToastId = 1;
|
let nextToastId = 1;
|
||||||
|
const TOAST_MAX = 5;
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
ensureStack();
|
ensureStack();
|
||||||
@@ -17,6 +18,8 @@ const AppFeedback = (function() {
|
|||||||
stackEl = document.createElement('div');
|
stackEl = document.createElement('div');
|
||||||
stackEl.id = 'appToastStack';
|
stackEl.id = 'appToastStack';
|
||||||
stackEl.className = 'app-toast-stack';
|
stackEl.className = 'app-toast-stack';
|
||||||
|
stackEl.setAttribute('aria-live', 'assertive');
|
||||||
|
stackEl.setAttribute('role', 'alert');
|
||||||
document.body.appendChild(stackEl);
|
document.body.appendChild(stackEl);
|
||||||
}
|
}
|
||||||
return stackEl;
|
return stackEl;
|
||||||
@@ -64,7 +67,14 @@ const AppFeedback = (function() {
|
|||||||
root.appendChild(actionsEl);
|
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) {
|
if (durationMs > 0) {
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
@@ -240,6 +250,151 @@ const AppFeedback = (function() {
|
|||||||
return text.includes('permission') || text.includes('denied') || text.includes('dependency') || text.includes('tool');
|
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 {
|
return {
|
||||||
init,
|
init,
|
||||||
toast,
|
toast,
|
||||||
@@ -249,6 +404,9 @@ const AppFeedback = (function() {
|
|||||||
isOffline,
|
isOffline,
|
||||||
isTransientNetworkError,
|
isTransientNetworkError,
|
||||||
isTransientOrOffline,
|
isTransientOrOffline,
|
||||||
|
withLoadingButton,
|
||||||
|
confirmAction,
|
||||||
|
enableListKeyNav,
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
@@ -75,12 +75,12 @@ const BluetoothMode = (function() {
|
|||||||
/**
|
/**
|
||||||
* Check for agent mode conflicts before starting scan.
|
* Check for agent mode conflicts before starting scan.
|
||||||
*/
|
*/
|
||||||
function checkAgentConflicts() {
|
async function checkAgentConflicts() {
|
||||||
if (typeof currentAgent === 'undefined' || currentAgent === 'local') {
|
if (typeof currentAgent === 'undefined' || currentAgent === 'local') {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (typeof checkAgentModeConflict === 'function') {
|
if (typeof checkAgentModeConflict === 'function') {
|
||||||
return checkAgentModeConflict('bluetooth');
|
return await checkAgentModeConflict('bluetooth');
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -883,7 +883,7 @@ const BluetoothMode = (function() {
|
|||||||
|
|
||||||
async function startScan() {
|
async function startScan() {
|
||||||
// Check for agent mode conflicts
|
// Check for agent mode conflicts
|
||||||
if (!checkAgentConflicts()) {
|
if (!await checkAgentConflicts()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -895,6 +895,7 @@ const BluetoothMode = (function() {
|
|||||||
|
|
||||||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||||||
|
|
||||||
|
if (startBtn) startBtn.classList.add('btn-loading');
|
||||||
try {
|
try {
|
||||||
let response;
|
let response;
|
||||||
if (isAgentMode) {
|
if (isAgentMode) {
|
||||||
@@ -940,7 +941,11 @@ const BluetoothMode = (function() {
|
|||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to start scan:', 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) {
|
} catch (err) {
|
||||||
console.error('Failed to stop scan:', err);
|
console.error('Failed to stop scan:', err);
|
||||||
|
reportActionableError('Stop Bluetooth Scan', err);
|
||||||
} finally {
|
} finally {
|
||||||
if (timeoutId) {
|
if (timeoutId) {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
@@ -1537,6 +1543,9 @@ const BluetoothMode = (function() {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to set baseline:', err);
|
console.error('Failed to set baseline:', err);
|
||||||
|
reportActionableError('Set Baseline', err, {
|
||||||
|
onRetry: () => setBaseline()
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1552,6 +1561,9 @@ const BluetoothMode = (function() {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to clear baseline:', err);
|
console.error('Failed to clear baseline:', err);
|
||||||
|
reportActionableError('Clear Baseline', err, {
|
||||||
|
onRetry: () => clearBaseline()
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1729,21 +1741,34 @@ const BluetoothMode = (function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function doLocateHandoff(device) {
|
function doLocateHandoff(device) {
|
||||||
console.log('[BT] doLocateHandoff, BtLocate defined:', typeof BtLocate !== 'undefined');
|
const payload = {
|
||||||
|
device_id: device.device_id,
|
||||||
|
device_key: device.device_key || null,
|
||||||
|
mac_address: device.address,
|
||||||
|
address_type: device.address_type || null,
|
||||||
|
irk_hex: device.irk_hex || null,
|
||||||
|
known_name: device.name || null,
|
||||||
|
known_manufacturer: device.manufacturer_name || null,
|
||||||
|
last_known_rssi: device.rssi_current,
|
||||||
|
tx_power: device.tx_power || null,
|
||||||
|
appearance_name: device.appearance_name || null,
|
||||||
|
fingerprint_id: device.fingerprint_id || device.fingerprint?.id || null,
|
||||||
|
mac_cluster_count: device.mac_cluster_count || 0
|
||||||
|
};
|
||||||
|
|
||||||
|
// If BtLocate is already loaded, hand off directly
|
||||||
if (typeof BtLocate !== 'undefined') {
|
if (typeof BtLocate !== 'undefined') {
|
||||||
BtLocate.handoff({
|
BtLocate.handoff(payload);
|
||||||
device_id: device.device_id,
|
return;
|
||||||
device_key: device.device_key || null,
|
}
|
||||||
mac_address: device.address,
|
|
||||||
address_type: device.address_type || null,
|
// Switch to bt_locate mode first — this loads the script, styles,
|
||||||
irk_hex: device.irk_hex || null,
|
// and initializes the module. Then hand off the device data.
|
||||||
known_name: device.name || null,
|
if (typeof switchMode === 'function') {
|
||||||
known_manufacturer: device.manufacturer_name || null,
|
switchMode('bt_locate').then(function() {
|
||||||
last_known_rssi: device.rssi_current,
|
if (typeof BtLocate !== 'undefined') {
|
||||||
tx_power: device.tx_power || null,
|
BtLocate.handoff(payload);
|
||||||
appearance_name: device.appearance_name || null,
|
}
|
||||||
fingerprint_id: device.fingerprint_id || device.fingerprint?.id || null,
|
|
||||||
mac_cluster_count: device.mac_cluster_count || 0
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,19 +110,27 @@ const Meshtastic = (function() {
|
|||||||
meshMap = L.map('meshMap').setView([defaultLat, defaultLon], 4);
|
meshMap = L.map('meshMap').setView([defaultLat, defaultLon], 4);
|
||||||
window.meshMap = meshMap;
|
window.meshMap = meshMap;
|
||||||
|
|
||||||
// Use settings manager for tile layer (allows runtime changes)
|
// Add fallback tiles immediately so the map is visible instantly
|
||||||
|
const fallbackTiles = L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '© <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') {
|
if (typeof Settings !== 'undefined') {
|
||||||
// Wait for settings to load from server before applying tiles
|
try {
|
||||||
await Settings.init();
|
await Promise.race([
|
||||||
Settings.createTileLayer().addTo(meshMap);
|
Settings.init(),
|
||||||
Settings.registerMap(meshMap);
|
new Promise((_, reject) => setTimeout(() => reject(new Error('Settings timeout')), 5000))
|
||||||
} else {
|
]);
|
||||||
L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
|
meshMap.removeLayer(fallbackTiles);
|
||||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
Settings.createTileLayer().addTo(meshMap);
|
||||||
maxZoom: 19,
|
Settings.registerMap(meshMap);
|
||||||
subdomains: 'abcd',
|
} catch (e) {
|
||||||
className: 'tile-layer-cyan'
|
console.warn('Meshtastic: Settings init failed/timed out, using fallback tiles:', e);
|
||||||
}).addTo(meshMap);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle resize
|
// Handle resize
|
||||||
@@ -266,8 +274,10 @@ const Meshtastic = (function() {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to start Meshtastic:', err);
|
console.error('Failed to start Meshtastic:', err);
|
||||||
|
reportActionableError('Start Meshtastic', err, {
|
||||||
|
onRetry: () => start()
|
||||||
|
});
|
||||||
updateStatusIndicator('disconnected', 'Connection error');
|
updateStatusIndicator('disconnected', 'Connection error');
|
||||||
showStatusMessage('Connection error: ' + err.message, 'error');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,6 +293,7 @@ const Meshtastic = (function() {
|
|||||||
showNotification('Meshtastic', 'Disconnected');
|
showNotification('Meshtastic', 'Disconnected');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to stop Meshtastic:', err);
|
console.error('Failed to stop Meshtastic:', err);
|
||||||
|
reportActionableError('Stop Meshtastic', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -589,7 +600,9 @@ const Meshtastic = (function() {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to configure channel:', 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) {
|
} catch (err) {
|
||||||
console.error('Failed to send message:', err);
|
console.error('Failed to send message:', err);
|
||||||
|
reportActionableError('Send Message', err, {
|
||||||
|
onRetry: () => sendMessage()
|
||||||
|
});
|
||||||
optimisticMsg._failed = true;
|
optimisticMsg._failed = true;
|
||||||
updatePendingMessage(optimisticMsg, true);
|
updatePendingMessage(optimisticMsg, true);
|
||||||
if (typeof showNotification === 'function') {
|
|
||||||
showNotification('Meshtastic', 'Send error: ' + err.message);
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
if (sendBtn) {
|
if (sendBtn) {
|
||||||
sendBtn.disabled = false;
|
sendBtn.disabled = false;
|
||||||
@@ -1382,6 +1395,9 @@ const Meshtastic = (function() {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Traceroute error:', err);
|
console.error('Traceroute error:', err);
|
||||||
|
reportActionableError('Send Traceroute', err, {
|
||||||
|
onRetry: () => sendTraceroute(destination)
|
||||||
|
});
|
||||||
showTracerouteModal(destination, { error: err.message }, false);
|
showTracerouteModal(destination, { error: err.message }, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1564,7 +1580,9 @@ const Meshtastic = (function() {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Position request error:', 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) {
|
} catch (err) {
|
||||||
console.error('Range test error:', 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');
|
showNotification('Meshtastic', 'Range test stopped');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error stopping range test:', err);
|
console.error('Error stopping range test:', err);
|
||||||
|
reportActionableError('Stop Range Test', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2243,7 +2264,9 @@ const Meshtastic = (function() {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('S&F request error:', 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 = '';
|
input.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function removePreset(freq) {
|
async function removePreset(freq) {
|
||||||
if (!confirm('Remove preset ' + freq + ' MHz?')) return;
|
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; });
|
var presets = loadPresets().filter(function (p) { return p !== freq; });
|
||||||
savePresets(presets);
|
savePresets(presets);
|
||||||
renderPresets();
|
renderPresets();
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetPresets() {
|
async function resetPresets() {
|
||||||
if (!confirm('Reset to default presets?')) return;
|
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());
|
savePresets(DEFAULT_FREQ_PRESETS.slice());
|
||||||
renderPresets();
|
renderPresets();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -280,18 +280,19 @@ const SpyStations = (function() {
|
|||||||
showNotification('Tuning to ' + stationName, formatFrequency(freqKhz) + ' (' + tuneMode.toUpperCase() + ')');
|
showNotification('Tuning to ' + stationName, formatFrequency(freqKhz) + ' (' + tuneMode.toUpperCase() + ')');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Switch to spectrum waterfall mode and tune after mode init.
|
// Switch to spectrum waterfall mode and tune after init completes.
|
||||||
if (typeof switchMode === 'function') {
|
const doTune = () => {
|
||||||
switchMode('waterfall');
|
|
||||||
} else if (typeof selectMode === 'function') {
|
|
||||||
selectMode('waterfall');
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
if (typeof Waterfall !== 'undefined' && typeof Waterfall.quickTune === 'function') {
|
if (typeof Waterfall !== 'undefined' && typeof Waterfall.quickTune === 'function') {
|
||||||
Waterfall.quickTune(freqMhz, tuneMode);
|
Waterfall.quickTune(freqMhz, tuneMode);
|
||||||
}
|
}
|
||||||
}, 220);
|
};
|
||||||
|
|
||||||
|
if (typeof switchMode === 'function') {
|
||||||
|
switchMode('waterfall').then(doTune);
|
||||||
|
} else if (typeof selectMode === 'function') {
|
||||||
|
selectMode('waterfall');
|
||||||
|
setTimeout(doTune, 300);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -802,7 +802,13 @@ const SSTVGeneral = (function() {
|
|||||||
* Delete a single image
|
* Delete a single image
|
||||||
*/
|
*/
|
||||||
async function deleteImage(filename) {
|
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 {
|
try {
|
||||||
const response = await fetch(`/sstv-general/images/${encodeURIComponent(filename)}`, { method: 'DELETE' });
|
const response = await fetch(`/sstv-general/images/${encodeURIComponent(filename)}`, { method: 'DELETE' });
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -822,7 +828,13 @@ const SSTVGeneral = (function() {
|
|||||||
* Delete all images
|
* Delete all images
|
||||||
*/
|
*/
|
||||||
async function deleteAllImages() {
|
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 {
|
try {
|
||||||
const response = await fetch('/sstv-general/images', { method: 'DELETE' });
|
const response = await fetch('/sstv-general/images', { method: 'DELETE' });
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|||||||
+38
-14
@@ -215,18 +215,25 @@ const SSTV = (function() {
|
|||||||
});
|
});
|
||||||
window.issMap = issMap;
|
window.issMap = issMap;
|
||||||
|
|
||||||
// Add tile layer using settings manager if available
|
// Add fallback tiles immediately so the map is visible instantly
|
||||||
|
const fallbackTiles = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||||
|
maxZoom: 19,
|
||||||
|
className: 'tile-layer-cyan'
|
||||||
|
}).addTo(issMap);
|
||||||
|
|
||||||
|
// Upgrade tiles in background via Settings (with timeout fallback)
|
||||||
if (typeof Settings !== 'undefined') {
|
if (typeof Settings !== 'undefined') {
|
||||||
// Wait for settings to load from server before applying tiles
|
try {
|
||||||
await Settings.init();
|
await Promise.race([
|
||||||
Settings.createTileLayer().addTo(issMap);
|
Settings.init(),
|
||||||
Settings.registerMap(issMap);
|
new Promise((_, reject) => setTimeout(() => reject(new Error('Settings timeout')), 5000))
|
||||||
} else {
|
]);
|
||||||
// Fallback to dark theme tiles
|
issMap.removeLayer(fallbackTiles);
|
||||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
Settings.createTileLayer().addTo(issMap);
|
||||||
maxZoom: 19,
|
Settings.registerMap(issMap);
|
||||||
className: 'tile-layer-cyan'
|
} catch (e) {
|
||||||
}).addTo(issMap);
|
console.warn('SSTV: Settings init failed/timed out, using fallback tiles:', e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create ISS icon
|
// Create ISS icon
|
||||||
@@ -606,8 +613,10 @@ const SSTV = (function() {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to start SSTV:', err);
|
console.error('Failed to start SSTV:', err);
|
||||||
|
reportActionableError('Start SSTV', err, {
|
||||||
|
onRetry: () => start()
|
||||||
|
});
|
||||||
updateStatusUI('idle', 'Error');
|
updateStatusUI('idle', 'Error');
|
||||||
showStatusMessage('Connection error: ' + err.message, 'error');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -626,6 +635,7 @@ const SSTV = (function() {
|
|||||||
showNotification('SSTV', 'Decoder stopped');
|
showNotification('SSTV', 'Decoder stopped');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to stop SSTV:', err);
|
console.error('Failed to stop SSTV:', err);
|
||||||
|
reportActionableError('Stop SSTV', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1297,7 +1307,13 @@ const SSTV = (function() {
|
|||||||
* Delete a single image
|
* Delete a single image
|
||||||
*/
|
*/
|
||||||
async function deleteImage(filename) {
|
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 {
|
try {
|
||||||
const response = await fetch(`/sstv/images/${encodeURIComponent(filename)}`, { method: 'DELETE' });
|
const response = await fetch(`/sstv/images/${encodeURIComponent(filename)}`, { method: 'DELETE' });
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -1310,6 +1326,7 @@ const SSTV = (function() {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to delete image:', err);
|
console.error('Failed to delete image:', err);
|
||||||
|
reportActionableError('Delete Image', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1317,7 +1334,13 @@ const SSTV = (function() {
|
|||||||
* Delete all images
|
* Delete all images
|
||||||
*/
|
*/
|
||||||
async function deleteAllImages() {
|
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 {
|
try {
|
||||||
const response = await fetch('/sstv/images', { method: 'DELETE' });
|
const response = await fetch('/sstv/images', { method: 'DELETE' });
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -1329,6 +1352,7 @@ const SSTV = (function() {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to delete images:', 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