mirror of
https://github.com/smittix/intercept.git
synced 2026-06-13 00:03:33 -07:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8adfb3a40a | |||
| 9a9b1e9856 | |||
| 8aeb52380e | |||
| 05141b9a1b | |||
| dc0850d339 | |||
| 2bbf896e7c | |||
| faf57741a1 | |||
| fd7d01fc7d | |||
| 8ef9dca6ee | |||
| 4610804de6 | |||
| 6d8836ddfc | |||
| 17944554e6 | |||
| 47a7376632 | |||
| e00fbfddc1 | |||
| 00362bcd57 | |||
| fe42ca207c | |||
| 612e137a60 | |||
| 17913fc0e8 | |||
| 44d256179b | |||
| 3c05429041 | |||
| 6727b95596 | |||
| 08b930d6e6 | |||
| 454a373874 | |||
| 90281b1535 | |||
| e687862043 | |||
| 05412fbfc3 | |||
| aa787f0b53 |
+20
-1
@@ -1,6 +1,8 @@
|
|||||||
# Git
|
# Git & CI
|
||||||
.git
|
.git
|
||||||
.gitignore
|
.gitignore
|
||||||
|
.github
|
||||||
|
.claude
|
||||||
|
|
||||||
# Python
|
# Python
|
||||||
__pycache__
|
__pycache__
|
||||||
@@ -29,6 +31,23 @@ tests/
|
|||||||
.coverage
|
.coverage
|
||||||
htmlcov/
|
htmlcov/
|
||||||
.mypy_cache/
|
.mypy_cache/
|
||||||
|
.ruff_cache
|
||||||
|
.DS_Store
|
||||||
|
tasks/
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
*.md
|
||||||
|
|
||||||
|
# Runtime data (mounted as volume)
|
||||||
|
instance/
|
||||||
|
|
||||||
|
# data/ is a Python package — only exclude non-code files
|
||||||
|
data/*.json
|
||||||
|
data/*.csv
|
||||||
|
data/*.db
|
||||||
|
|
||||||
|
# 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,26 @@
|
|||||||
|
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 -r requirements-dev.txt
|
||||||
|
- 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-dev.txt
|
||||||
|
- name: Run tests
|
||||||
|
run: pytest --tb=short -q
|
||||||
|
continue-on-error: true
|
||||||
+101
@@ -2,6 +2,107 @@
|
|||||||
|
|
||||||
All notable changes to iNTERCEPT will be documented in this file.
|
All notable changes to iNTERCEPT will be documented in this file.
|
||||||
|
|
||||||
|
## [2.26.10] - 2026-03-14
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **APRS stop timeout and inverted SDR device status** — The APRS stop endpoint terminated two processes sequentially (up to 4s) while the frontend timed out at 2.2s, causing console errors and the SDR status panel to show stale state (active after stop, idle during use). Now releases the SDR device immediately and terminates processes in a background thread so the response returns instantly. (#194)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.26.9] - 2026-03-14
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **ADS-B bias-t support for RTL-SDR Blog V4** — When dump1090 lacks native `--enable-biast` support, the system now falls back to `rtl_biast` (from RTL-SDR Blog drivers) to enable bias-t power before starting dump1090. The Blog V4's built-in LNA requires bias-t to receive ADS-B signals. (#195)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.26.8] - 2026-03-14
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **acarsdec build failure on macOS** — `HOST_NAME_MAX` is Linux-specific (`<limits.h>`) and undefined on macOS, causing 3 compile errors in `acarsdec.c`. Now patched with `#define HOST_NAME_MAX 255` before building. Also fixed deprecated `-Ofast` flag warning on all macOS architectures (was only patched for arm64). (#187)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.26.7] - 2026-03-14
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Health check SDR detection on macOS** — `timeout` (GNU coreutils) is not available on macOS, causing `rtl_test` to silently fail and report "No RTL-SDR device found" even when one is connected. Now tries `timeout`, then `gtimeout` (Homebrew coreutils), then falls back to a background process with manual kill. (#188)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.26.6] - 2026-03-14
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Oversized branded 'i' logo on dashboards** — `.logo span { display: inline }` in dashboard CSS had higher specificity (0,1,1) than `.brand-i { display: inline-block }` (0,1,0), forcing the branded "i" SVG to render as inline which ignores width/height. Added `.logo .brand-i` selector (0,2,0) to retain `inline-block` display. (#189)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.26.5] - 2026-03-14
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Database errors crash entire UI** — `get_setting()` now catches `sqlite3.OperationalError` and returns the default value instead of propagating the exception. Previously, if the database was inaccessible (e.g. root-owned `instance/` directory from running with `sudo`), the `inject_offline_settings` context processor would crash every page render with a 500 Internal Server Error. (#190)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.26.4] - 2026-03-14
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Environment Configurator crash** — `read_env_var()` crashed with "Setup failed at line 2333" when `.env` existed but didn't contain the variable being looked up. `grep` returned exit code 1 (no match), which `pipefail` propagated and `set -e` turned into a fatal error. Fixed by appending `|| true` to the pipeline. (#191)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.26.3] - 2026-03-13
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **SatDump AVX2 crash** — SatDump now compiles with `-march=x86-64` on x86_64 platforms (Docker and `setup.sh`), preventing "Illegal instruction" crashes on CPUs without AVX2. SIMD plugins still use runtime detection for acceleration on capable hardware. (#185)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.26.2] - 2026-03-13
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Docker startup crash** — `.dockerignore` excluded the entire `data/` directory, which is now a Python package (`data.oui`, `data.patterns`, `data.satellites`). Caused `ModuleNotFoundError: No module named 'data.oui'` on container startup. Fixed by only excluding non-code files from `data/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.26.1] - 2026-03-13
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Default admin credentials** — Default `ADMIN_PASSWORD` changed from empty string to `admin`, matching the README documentation (`admin:admin`)
|
||||||
|
- **Config credential sync** — Admin password changes in `config.py` or via `INTERCEPT_ADMIN_PASSWORD` env var now sync to the database on restart, without needing to delete the DB
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.26.0] - 2026-03-13
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **SSE fanout crash** - `_run_fanout` daemon thread no longer crashes with `AttributeError: 'NoneType' object has no attribute 'get'` when source queue becomes None during interpreter shutdown
|
||||||
|
- **Branded logo FOUC** - Added inline `width`/`height` to branded "i" SVG elements across 10 templates to prevent oversized rendering before CSS loads; refresh no longer needed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|||||||
+203
-190
@@ -1,6 +1,197 @@
|
|||||||
# 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 \
|
||||||
|
&& ARCH_FLAGS=""; if [ "$(uname -m)" = "x86_64" ]; then ARCH_FLAGS="-march=x86-64"; fi \
|
||||||
|
&& cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF -DCMAKE_INSTALL_LIBDIR=lib \
|
||||||
|
-DCMAKE_C_FLAGS="$ARCH_FLAGS" \
|
||||||
|
-DCMAKE_CXX_FLAGS="$ARCH_FLAGS" .. \
|
||||||
|
&& 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 +203,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 +232,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 +250,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 .
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
# INTERCEPT
|
<p align="center">
|
||||||
|
<img src="static/images/readme-banner.svg" alt="iNTERCEPT — Signal Intelligence Platform" width="100%">
|
||||||
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/python-3.9+-blue.svg" alt="Python 3.9+">
|
<img src="https://img.shields.io/badge/python-3.9+-blue.svg" alt="Python 3.9+">
|
||||||
@@ -7,7 +9,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
Support the developer of this open-source project
|
Support the developer of this open-source project
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ Flask application and shared state.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sys
|
|
||||||
import site
|
import site
|
||||||
|
import sys
|
||||||
|
|
||||||
from utils.database import get_db
|
from utils.database import get_db
|
||||||
|
|
||||||
@@ -17,45 +17,92 @@ if not site.ENABLE_USER_SITE:
|
|||||||
if user_site and user_site not in sys.path:
|
if user_site and user_site not in sys.path:
|
||||||
sys.path.insert(0, user_site)
|
sys.path.insert(0, user_site)
|
||||||
|
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import queue
|
|
||||||
import threading
|
|
||||||
import platform
|
import platform
|
||||||
|
import queue
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from typing import Any
|
from flask import (
|
||||||
|
Flask,
|
||||||
from flask import Flask, render_template, jsonify, send_file, Response, request,redirect, url_for, flash, session, send_from_directory
|
Response,
|
||||||
|
flash,
|
||||||
|
jsonify,
|
||||||
|
redirect,
|
||||||
|
render_template,
|
||||||
|
request,
|
||||||
|
send_file,
|
||||||
|
send_from_directory,
|
||||||
|
session,
|
||||||
|
url_for,
|
||||||
|
)
|
||||||
from werkzeug.security import check_password_hash
|
from werkzeug.security import check_password_hash
|
||||||
from config import VERSION, CHANGELOG, SHARED_OBSERVER_LOCATION_ENABLED, DEFAULT_LATITUDE, DEFAULT_LONGITUDE
|
|
||||||
from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
|
from config import CHANGELOG, DEFAULT_LATITUDE, DEFAULT_LONGITUDE, SHARED_OBSERVER_LOCATION_ENABLED, VERSION
|
||||||
from utils.process import cleanup_stale_processes, cleanup_stale_dump1090
|
|
||||||
from utils.sdr import SDRFactory
|
|
||||||
from utils.cleanup import DataStore, cleanup_manager
|
from utils.cleanup import DataStore, cleanup_manager
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
MAX_AIRCRAFT_AGE_SECONDS,
|
MAX_AIRCRAFT_AGE_SECONDS,
|
||||||
MAX_WIFI_NETWORK_AGE_SECONDS,
|
|
||||||
MAX_BT_DEVICE_AGE_SECONDS,
|
MAX_BT_DEVICE_AGE_SECONDS,
|
||||||
MAX_VESSEL_AGE_SECONDS,
|
|
||||||
MAX_DSC_MESSAGE_AGE_SECONDS,
|
|
||||||
MAX_DEAUTH_ALERTS_AGE_SECONDS,
|
MAX_DEAUTH_ALERTS_AGE_SECONDS,
|
||||||
|
MAX_DSC_MESSAGE_AGE_SECONDS,
|
||||||
|
MAX_VESSEL_AGE_SECONDS,
|
||||||
|
MAX_WIFI_NETWORK_AGE_SECONDS,
|
||||||
QUEUE_MAX_SIZE,
|
QUEUE_MAX_SIZE,
|
||||||
)
|
)
|
||||||
import logging
|
from utils.dependencies import check_all_dependencies, check_tool
|
||||||
|
from utils.process import cleanup_stale_dump1090, cleanup_stale_processes
|
||||||
|
from utils.sdr import SDRFactory
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from flask_limiter import Limiter
|
from flask_limiter import Limiter
|
||||||
from flask_limiter.util import get_remote_address
|
from flask_limiter.util import get_remote_address
|
||||||
_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 contextlib
|
||||||
import time as _time
|
import time as _time
|
||||||
|
|
||||||
_app_start_time = _time.time()
|
_app_start_time = _time.time()
|
||||||
logger = logging.getLogger('intercept.database')
|
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,11 +124,21 @@ 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'
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# ERROR HANDLERS
|
# ERROR HANDLERS
|
||||||
# ============================================
|
# ============================================
|
||||||
@app.errorhandler(429)
|
@app.errorhandler(429)
|
||||||
def ratelimit_handler(e):
|
def ratelimit_handler(e):
|
||||||
@@ -106,6 +163,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
|
||||||
|
|
||||||
|
|
||||||
@@ -376,7 +439,7 @@ def require_login():
|
|||||||
# If user is not logged in and the current route is not allowed...
|
# If user is not logged in and the current route is not allowed...
|
||||||
if 'logged_in' not in session and request.endpoint not in allowed_routes:
|
if 'logged_in' not in session and request.endpoint not in allowed_routes:
|
||||||
return redirect(url_for('login'))
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
@app.route('/logout')
|
@app.route('/logout')
|
||||||
def logout():
|
def logout():
|
||||||
session.pop('logged_in', None)
|
session.pop('logged_in', None)
|
||||||
@@ -388,7 +451,7 @@ def login():
|
|||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
username = request.form.get('username')
|
username = request.form.get('username')
|
||||||
password = request.form.get('password')
|
password = request.form.get('password')
|
||||||
|
|
||||||
# Connect to DB and find user
|
# Connect to DB and find user
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
@@ -403,13 +466,13 @@ def login():
|
|||||||
session['logged_in'] = True
|
session['logged_in'] = True
|
||||||
session['username'] = username
|
session['username'] = username
|
||||||
session['role'] = user['role']
|
session['role'] = user['role']
|
||||||
|
|
||||||
logger.info(f"User '{username}' logged in successfully.")
|
logger.info(f"User '{username}' logged in successfully.")
|
||||||
return redirect(url_for('index'))
|
return redirect(url_for('index'))
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Failed login attempt for username: {username}")
|
logger.warning(f"Failed login attempt for username: {username}")
|
||||||
flash("ACCESS DENIED: INVALID CREDENTIALS", "error")
|
flash("ACCESS DENIED: INVALID CREDENTIALS", "error")
|
||||||
|
|
||||||
return render_template('login.html', version=VERSION)
|
return render_template('login.html', version=VERSION)
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
@@ -803,13 +866,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 +936,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
|
||||||
@@ -941,10 +1037,8 @@ def kill_all() -> Response:
|
|||||||
bt_process.terminate()
|
bt_process.terminate()
|
||||||
bt_process.wait(timeout=2)
|
bt_process.wait(timeout=2)
|
||||||
except Exception:
|
except Exception:
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
bt_process.kill()
|
bt_process.kill()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
bt_process = None
|
bt_process = None
|
||||||
|
|
||||||
# Reset Bluetooth v2 scanner
|
# Reset Bluetooth v2 scanner
|
||||||
@@ -1073,10 +1167,10 @@ def _init_app() -> None:
|
|||||||
# Register and start database cleanup
|
# Register and start database cleanup
|
||||||
try:
|
try:
|
||||||
from utils.database import (
|
from utils.database import (
|
||||||
|
cleanup_old_dsc_alerts,
|
||||||
|
cleanup_old_payloads,
|
||||||
cleanup_old_signal_history,
|
cleanup_old_signal_history,
|
||||||
cleanup_old_timeline_entries,
|
cleanup_old_timeline_entries,
|
||||||
cleanup_old_dsc_alerts,
|
|
||||||
cleanup_old_payloads
|
|
||||||
)
|
)
|
||||||
cleanup_manager.register_db_cleanup(cleanup_old_signal_history, interval_multiplier=1440)
|
cleanup_manager.register_db_cleanup(cleanup_old_signal_history, interval_multiplier=1440)
|
||||||
cleanup_manager.register_db_cleanup(cleanup_old_timeline_entries, interval_multiplier=1440)
|
cleanup_manager.register_db_cleanup(cleanup_old_timeline_entries, interval_multiplier=1440)
|
||||||
@@ -1104,6 +1198,7 @@ _init_app()
|
|||||||
def main() -> None:
|
def main() -> None:
|
||||||
"""Main entry point."""
|
"""Main entry point."""
|
||||||
import argparse
|
import argparse
|
||||||
|
|
||||||
import config
|
import config
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
@@ -1145,7 +1240,7 @@ def main() -> None:
|
|||||||
results = check_all_dependencies()
|
results = check_all_dependencies()
|
||||||
print("Dependency Status:")
|
print("Dependency Status:")
|
||||||
print("-" * 40)
|
print("-" * 40)
|
||||||
for mode, info in results.items():
|
for _mode, info in results.items():
|
||||||
status = "✓" if info['ready'] else "✗"
|
status = "✓" if info['ready'] else "✗"
|
||||||
print(f"\n{status} {info['name']}:")
|
print(f"\n{status} {info['name']}:")
|
||||||
for tool, tool_info in info['tools'].items():
|
for tool, tool_info in info['tools'].items():
|
||||||
|
|||||||
@@ -7,10 +7,93 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
# Application version
|
# Application version
|
||||||
VERSION = "2.24.0"
|
VERSION = "2.26.10"
|
||||||
|
|
||||||
# Changelog - latest release notes (shown on welcome screen)
|
# Changelog - latest release notes (shown on welcome screen)
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
|
{
|
||||||
|
"version": "2.26.10",
|
||||||
|
"date": "March 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Fix APRS stop timeout and inverted SDR device status",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.26.8",
|
||||||
|
"date": "March 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Fix acarsdec build failure on macOS (HOST_NAME_MAX undefined)",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.26.7",
|
||||||
|
"date": "March 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Fix health check SDR detection on macOS (timeout command not available)",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.26.6",
|
||||||
|
"date": "March 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Fix oversized branded 'i' logo on Aircraft & Vessel dashboards",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.26.5",
|
||||||
|
"date": "March 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Fix database errors crashing the entire UI — pages now degrade gracefully",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.26.4",
|
||||||
|
"date": "March 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Fix Environment Configurator crash when .env exists but variable is missing",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.26.3",
|
||||||
|
"date": "March 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Fix SatDump AVX2 crash on older CPUs — build now targets baseline x86-64",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.26.2",
|
||||||
|
"date": "March 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Fix Docker startup crash — data/ Python package was excluded by .dockerignore",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.26.1",
|
||||||
|
"date": "March 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Fix default admin credentials — now matches README (admin:admin)",
|
||||||
|
"Admin password changes in config.py / env vars now sync to DB on restart",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.26.0",
|
||||||
|
"date": "March 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Fix SSE fanout thread crash when source queue is None during shutdown",
|
||||||
|
"Fix branded 'i' logo FOUC (flash of unstyled content) on first page load",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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",
|
||||||
|
|||||||
+5
-5
@@ -1,10 +1,10 @@
|
|||||||
# Data modules for INTERCEPT
|
# Data modules for INTERCEPT
|
||||||
from .oui import OUI_DATABASE, load_oui_database, get_manufacturer
|
from .oui import OUI_DATABASE, get_manufacturer, load_oui_database
|
||||||
from .satellites import TLE_SATELLITES
|
|
||||||
from .patterns import (
|
from .patterns import (
|
||||||
AIRTAG_PREFIXES,
|
AIRTAG_PREFIXES,
|
||||||
TILE_PREFIXES,
|
|
||||||
SAMSUNG_TRACKER,
|
|
||||||
DRONE_SSID_PATTERNS,
|
|
||||||
DRONE_OUI_PREFIXES,
|
DRONE_OUI_PREFIXES,
|
||||||
|
DRONE_SSID_PATTERNS,
|
||||||
|
SAMSUNG_TRACKER,
|
||||||
|
TILE_PREFIXES,
|
||||||
)
|
)
|
||||||
|
from .satellites import TLE_SATELLITES
|
||||||
|
|||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import json
|
|
||||||
|
|
||||||
logger = logging.getLogger('intercept.oui')
|
logger = logging.getLogger('intercept.oui')
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ def load_oui_database() -> dict[str, str] | None:
|
|||||||
oui_file = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'oui_database.json')
|
oui_file = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'oui_database.json')
|
||||||
try:
|
try:
|
||||||
if os.path.exists(oui_file):
|
if os.path.exists(oui_file):
|
||||||
with open(oui_file, 'r') as f:
|
with open(oui_file) as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
# Remove comment fields
|
# Remove comment fields
|
||||||
return {k: v for k, v in data.items() if not k.startswith('_')}
|
return {k: v for k, v in data.items() if not k.startswith('_')}
|
||||||
|
|||||||
@@ -340,7 +340,7 @@ def get_frequency_risk(frequency_mhz: float) -> tuple[str, str]:
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple of (risk_level, category_name)
|
Tuple of (risk_level, category_name)
|
||||||
"""
|
"""
|
||||||
for category, ranges in SURVEILLANCE_FREQUENCIES.items():
|
for _category, ranges in SURVEILLANCE_FREQUENCIES.items():
|
||||||
for freq_range in ranges:
|
for freq_range in ranges:
|
||||||
if freq_range['start'] <= frequency_mhz <= freq_range['end']:
|
if freq_range['start'] <= frequency_mhz <= freq_range['end']:
|
||||||
return freq_range['risk'], freq_range['name']
|
return freq_range['risk'], freq_range['name']
|
||||||
@@ -378,7 +378,7 @@ def is_known_tracker(device_name: str | None, manufacturer_data: bytes | str | N
|
|||||||
"""
|
"""
|
||||||
if device_name:
|
if device_name:
|
||||||
name_lower = device_name.lower()
|
name_lower = device_name.lower()
|
||||||
for tracker_id, tracker_info in BLE_TRACKER_SIGNATURES.items():
|
for _tracker_id, tracker_info in BLE_TRACKER_SIGNATURES.items():
|
||||||
for pattern in tracker_info.get('patterns', []):
|
for pattern in tracker_info.get('patterns', []):
|
||||||
if pattern in name_lower:
|
if pattern in name_lower:
|
||||||
return tracker_info
|
return tracker_info
|
||||||
@@ -394,7 +394,7 @@ def is_known_tracker(device_name: str | None, manufacturer_data: bytes | str | N
|
|||||||
|
|
||||||
if len(mfr_bytes) >= 2:
|
if len(mfr_bytes) >= 2:
|
||||||
company_id = int.from_bytes(mfr_bytes[:2], 'little')
|
company_id = int.from_bytes(mfr_bytes[:2], 'little')
|
||||||
for tracker_id, tracker_info in BLE_TRACKER_SIGNATURES.items():
|
for _tracker_id, tracker_info in BLE_TRACKER_SIGNATURES.items():
|
||||||
if tracker_info.get('company_id') == company_id:
|
if tracker_info.get('company_id') == company_id:
|
||||||
return tracker_info
|
return tracker_info
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+4
-4
@@ -14,7 +14,7 @@
|
|||||||
<canvas id="bg-canvas"></canvas>
|
<canvas id="bg-canvas"></canvas>
|
||||||
<nav class="navbar">
|
<nav class="navbar">
|
||||||
<div class="nav-container">
|
<div class="nav-container">
|
||||||
<a href="#" class="nav-logo">iNTERCEPT</a>
|
<a href="#" class="nav-logo"><span class="brand-i"><svg viewBox="36 14 28 68" width="1em" height="1em" xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="20" r="6" fill="#00ff88"/><rect x="44" y="33" width="12" height="45" rx="2" fill="#00d4ff"/><rect x="38" y="33" width="24" height="4" rx="1" fill="#00d4ff"/><rect x="38" y="74" width="24" height="4" rx="1" fill="#00d4ff"/></svg></span>NTERCEPT</a>
|
||||||
<div class="nav-links">
|
<div class="nav-links">
|
||||||
<a href="#features">Features</a>
|
<a href="#features">Features</a>
|
||||||
<a href="#screenshots">Screenshots</a>
|
<a href="#screenshots">Screenshots</a>
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
<header class="hero">
|
<header class="hero">
|
||||||
<div class="hero-content">
|
<div class="hero-content">
|
||||||
<div class="hero-badge">Open Source SIGINT Platform</div>
|
<div class="hero-badge">Open Source SIGINT Platform</div>
|
||||||
<h1>iNTERCEPT</h1>
|
<h1><span class="brand-i"><svg viewBox="36 14 28 68" width="1em" height="1em" xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="20" r="6" fill="#00ff88"/><rect x="44" y="33" width="12" height="45" rx="2" fill="#00d4ff"/><rect x="38" y="33" width="24" height="4" rx="1" fill="#00d4ff"/><rect x="38" y="74" width="24" height="4" rx="1" fill="#00d4ff"/></svg></span>NTERCEPT</h1>
|
||||||
<p class="hero-subtitle">A unified web interface for software-defined radio tools. Monitor pagers, track aircraft, scan WiFi networks, and more — all from your browser.</p>
|
<p class="hero-subtitle">A unified web interface for software-defined radio tools. Monitor pagers, track aircraft, scan WiFi networks, and more — all from your browser.</p>
|
||||||
<div class="hero-buttons">
|
<div class="hero-buttons">
|
||||||
<a href="#installation" class="btn btn-primary">Get Started</a>
|
<a href="#installation" class="btn btn-primary">Get Started</a>
|
||||||
@@ -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">
|
||||||
@@ -435,7 +435,7 @@ docker compose --profile basic up -d --build</code></pre>
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="footer-content">
|
<div class="footer-content">
|
||||||
<div class="footer-brand">
|
<div class="footer-brand">
|
||||||
<span class="footer-logo">iNTERCEPT</span>
|
<span class="footer-logo"><span class="brand-i"><svg viewBox="36 14 28 68" width="1em" height="1em" xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="20" r="6" fill="#00ff88"/><rect x="44" y="33" width="12" height="45" rx="2" fill="#00d4ff"/><rect x="38" y="33" width="24" height="4" rx="1" fill="#00d4ff"/><rect x="38" y="74" width="24" height="4" rx="1" fill="#00d4ff"/></svg></span>NTERCEPT</span>
|
||||||
<p>Signal Intelligence Platform</p>
|
<p>Signal Intelligence Platform</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="footer-links">
|
<div class="footer-links">
|
||||||
|
|||||||
@@ -86,6 +86,21 @@ body {
|
|||||||
letter-spacing: 2px;
|
letter-spacing: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Branded "i" — inline SVG glyph matching the app logo */
|
||||||
|
.brand-i {
|
||||||
|
display: inline-block;
|
||||||
|
width: 0.55em;
|
||||||
|
height: 0.9em;
|
||||||
|
vertical-align: baseline;
|
||||||
|
position: relative;
|
||||||
|
top: 0.05em;
|
||||||
|
}
|
||||||
|
.brand-i svg {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.nav-links {
|
.nav-links {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
+4
-3
@@ -1,6 +1,8 @@
|
|||||||
"""Gunicorn configuration for INTERCEPT."""
|
"""Gunicorn configuration for INTERCEPT."""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
warnings.filterwarnings(
|
warnings.filterwarnings(
|
||||||
'ignore',
|
'ignore',
|
||||||
message='Patching more than once',
|
message='Patching more than once',
|
||||||
@@ -33,10 +35,8 @@ def post_fork(server, worker):
|
|||||||
_orig = _ForkHooks.after_fork_in_child
|
_orig = _ForkHooks.after_fork_in_child
|
||||||
|
|
||||||
def _safe_after_fork(self):
|
def _safe_after_fork(self):
|
||||||
try:
|
with contextlib.suppress(AssertionError):
|
||||||
_orig(self)
|
_orig(self)
|
||||||
except AssertionError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
_ForkHooks.after_fork_in_child = _safe_after_fork
|
_ForkHooks.after_fork_in_child = _safe_after_fork
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -53,6 +53,7 @@ def post_worker_init(worker):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
import ssl
|
import ssl
|
||||||
|
|
||||||
from gevent import get_hub
|
from gevent import get_hub
|
||||||
hub = get_hub()
|
hub = get_hub()
|
||||||
suppress = (SystemExit, ssl.SSLZeroReturnError, ssl.SSLError)
|
suppress = (SystemExit, ssl.SSLZeroReturnError, ssl.SSLError)
|
||||||
|
|||||||
@@ -16,14 +16,6 @@ Requires RTL-SDR hardware for RF modes.
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
# Check Python version early, before imports that use 3.9+ syntax
|
# Check Python version early, before imports that use 3.9+ syntax
|
||||||
if sys.version_info < (3, 9):
|
|
||||||
print(f"Error: Python 3.9 or higher is required.")
|
|
||||||
print(f"You are running Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}")
|
|
||||||
print("\nTo fix this:")
|
|
||||||
print(" - On Ubuntu/Debian: sudo apt install python3.9 (or newer)")
|
|
||||||
print(" - On macOS: brew install python@3.11")
|
|
||||||
print(" - Or use pyenv to install a newer version")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Handle --version early before other imports
|
# Handle --version early before other imports
|
||||||
if '--version' in sys.argv or '-V' in sys.argv:
|
if '--version' in sys.argv or '-V' in sys.argv:
|
||||||
|
|||||||
+32
-61
@@ -13,6 +13,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import configparser
|
import configparser
|
||||||
|
import contextlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -26,25 +27,24 @@ import sys
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
from socketserver import ThreadingMixIn
|
from socketserver import ThreadingMixIn
|
||||||
from typing import Any
|
from urllib.parse import parse_qs, urlparse
|
||||||
from urllib.parse import urlparse, parse_qs
|
|
||||||
|
|
||||||
# Add parent directory to path for imports
|
# Add parent directory to path for imports
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
# Import dependency checking from Intercept utils
|
# Import dependency checking from Intercept utils
|
||||||
try:
|
try:
|
||||||
from utils.dependencies import check_all_dependencies, check_tool, TOOL_DEPENDENCIES
|
from utils.dependencies import TOOL_DEPENDENCIES, check_all_dependencies, check_tool
|
||||||
HAS_DEPENDENCIES_MODULE = True
|
HAS_DEPENDENCIES_MODULE = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
HAS_DEPENDENCIES_MODULE = False
|
HAS_DEPENDENCIES_MODULE = False
|
||||||
|
|
||||||
# Import TSCM modules for consistent analysis (same as local mode)
|
# Import TSCM modules for consistent analysis (same as local mode)
|
||||||
try:
|
try:
|
||||||
from utils.tscm.detector import ThreatDetector
|
|
||||||
from utils.tscm.correlation import CorrelationEngine
|
from utils.tscm.correlation import CorrelationEngine
|
||||||
|
from utils.tscm.detector import ThreatDetector
|
||||||
HAS_TSCM_MODULES = True
|
HAS_TSCM_MODULES = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
HAS_TSCM_MODULES = False
|
HAS_TSCM_MODULES = False
|
||||||
@@ -53,7 +53,7 @@ except ImportError:
|
|||||||
|
|
||||||
# Import database functions for baseline support (same as local mode)
|
# Import database functions for baseline support (same as local mode)
|
||||||
try:
|
try:
|
||||||
from utils.database import get_tscm_baseline, get_active_tscm_baseline
|
from utils.database import get_active_tscm_baseline, get_tscm_baseline
|
||||||
HAS_BASELINE_DB = True
|
HAS_BASELINE_DB = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
HAS_BASELINE_DB = False
|
HAS_BASELINE_DB = False
|
||||||
@@ -143,7 +143,7 @@ class AgentConfig:
|
|||||||
|
|
||||||
# Modes section
|
# Modes section
|
||||||
if parser.has_section('modes'):
|
if parser.has_section('modes'):
|
||||||
for mode in self.modes_enabled.keys():
|
for mode in self.modes_enabled:
|
||||||
if parser.has_option('modes', mode):
|
if parser.has_option('modes', mode):
|
||||||
self.modes_enabled[mode] = parser.getboolean('modes', mode)
|
self.modes_enabled[mode] = parser.getboolean('modes', mode)
|
||||||
|
|
||||||
@@ -310,10 +310,8 @@ class ControllerPushClient(threading.Thread):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
item['attempts'] += 1
|
item['attempts'] += 1
|
||||||
if item['attempts'] < 3 and not self.stop_event.is_set():
|
if item['attempts'] < 3 and not self.stop_event.is_set():
|
||||||
try:
|
with contextlib.suppress(queue.Full):
|
||||||
self.queue.put_nowait(item)
|
self.queue.put_nowait(item)
|
||||||
except queue.Full:
|
|
||||||
pass
|
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Failed to push after {item['attempts']} attempts: {e}")
|
logger.warning(f"Failed to push after {item['attempts']} attempts: {e}")
|
||||||
finally:
|
finally:
|
||||||
@@ -795,9 +793,7 @@ class ModeManager:
|
|||||||
info['vessel_count'] = len(getattr(self, 'ais_vessels', {}))
|
info['vessel_count'] = len(getattr(self, 'ais_vessels', {}))
|
||||||
elif mode == 'aprs':
|
elif mode == 'aprs':
|
||||||
info['station_count'] = len(getattr(self, 'aprs_stations', {}))
|
info['station_count'] = len(getattr(self, 'aprs_stations', {}))
|
||||||
elif mode == 'pager':
|
elif mode == 'pager' or mode == 'acars':
|
||||||
info['message_count'] = len(self.data_snapshots.get(mode, []))
|
|
||||||
elif mode == 'acars':
|
|
||||||
info['message_count'] = len(self.data_snapshots.get(mode, []))
|
info['message_count'] = len(self.data_snapshots.get(mode, []))
|
||||||
elif mode == 'rtlamr':
|
elif mode == 'rtlamr':
|
||||||
info['reading_count'] = len(self.data_snapshots.get(mode, []))
|
info['reading_count'] = len(self.data_snapshots.get(mode, []))
|
||||||
@@ -1073,10 +1069,8 @@ class ModeManager:
|
|||||||
proc.wait(timeout=2)
|
proc.wait(timeout=2)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
proc.kill()
|
proc.kill()
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
proc.wait(timeout=1)
|
proc.wait(timeout=1)
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
except (OSError, ProcessLookupError) as e:
|
except (OSError, ProcessLookupError) as e:
|
||||||
# Process already dead or inaccessible
|
# Process already dead or inaccessible
|
||||||
logger.debug(f"Process cleanup for {mode}: {e}")
|
logger.debug(f"Process cleanup for {mode}: {e}")
|
||||||
@@ -1297,10 +1291,8 @@ class ModeManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Sensor output reader error: {e}")
|
logger.error(f"Sensor output reader error: {e}")
|
||||||
finally:
|
finally:
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
proc.wait(timeout=1)
|
proc.wait(timeout=1)
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
logger.info("Sensor output reader stopped")
|
logger.info("Sensor output reader stopped")
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
@@ -1661,16 +1653,14 @@ class ModeManager:
|
|||||||
try:
|
try:
|
||||||
from utils.validation import validate_network_interface
|
from utils.validation import validate_network_interface
|
||||||
interface = validate_network_interface(interface)
|
interface = validate_network_interface(interface)
|
||||||
except (ImportError, ValueError) as e:
|
except (ImportError, ValueError):
|
||||||
if not os.path.exists(f'/sys/class/net/{interface}'):
|
if not os.path.exists(f'/sys/class/net/{interface}'):
|
||||||
return {'status': 'error', 'message': f'Interface {interface} not found'}
|
return {'status': 'error', 'message': f'Interface {interface} not found'}
|
||||||
|
|
||||||
csv_path = '/tmp/intercept_agent_wifi'
|
csv_path = '/tmp/intercept_agent_wifi'
|
||||||
for f in [f'{csv_path}-01.csv', f'{csv_path}-01.cap', f'{csv_path}-01.gps']:
|
for f in [f'{csv_path}-01.csv', f'{csv_path}-01.cap', f'{csv_path}-01.gps']:
|
||||||
try:
|
with contextlib.suppress(OSError):
|
||||||
os.remove(f)
|
os.remove(f)
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
airodump_path = self._get_tool_path('airodump-ng')
|
airodump_path = self._get_tool_path('airodump-ng')
|
||||||
if not airodump_path:
|
if not airodump_path:
|
||||||
@@ -1931,7 +1921,7 @@ class ModeManager:
|
|||||||
logger.warning("Intercept WiFi parser not available, using fallback")
|
logger.warning("Intercept WiFi parser not available, using fallback")
|
||||||
# Fallback: simple parsing if running standalone
|
# Fallback: simple parsing if running standalone
|
||||||
try:
|
try:
|
||||||
with open(csv_path, 'r', errors='replace') as f:
|
with open(csv_path, errors='replace') as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
for section in content.split('\n\n'):
|
for section in content.split('\n\n'):
|
||||||
lines = section.strip().split('\n')
|
lines = section.strip().split('\n')
|
||||||
@@ -2303,10 +2293,8 @@ class ModeManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Pager reader error: {e}")
|
logger.error(f"Pager reader error: {e}")
|
||||||
finally:
|
finally:
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
proc.wait(timeout=1)
|
proc.wait(timeout=1)
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if 'pager_rtl' in self.processes:
|
if 'pager_rtl' in self.processes:
|
||||||
try:
|
try:
|
||||||
rtl_proc = self.processes['pager_rtl']
|
rtl_proc = self.processes['pager_rtl']
|
||||||
@@ -2491,7 +2479,7 @@ class ModeManager:
|
|||||||
|
|
||||||
sock.close()
|
sock.close()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception:
|
||||||
retry_count += 1
|
retry_count += 1
|
||||||
if retry_count >= 10:
|
if retry_count >= 10:
|
||||||
logger.error("Max AIS retries reached")
|
logger.error("Max AIS retries reached")
|
||||||
@@ -2701,10 +2689,8 @@ class ModeManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"ACARS reader error: {e}")
|
logger.error(f"ACARS reader error: {e}")
|
||||||
finally:
|
finally:
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
proc.wait(timeout=1)
|
proc.wait(timeout=1)
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
logger.info("ACARS reader stopped")
|
logger.info("ACARS reader stopped")
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
@@ -2846,10 +2832,8 @@ class ModeManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"APRS reader error: {e}")
|
logger.error(f"APRS reader error: {e}")
|
||||||
finally:
|
finally:
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
proc.wait(timeout=1)
|
proc.wait(timeout=1)
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if 'aprs_rtl' in self.processes:
|
if 'aprs_rtl' in self.processes:
|
||||||
try:
|
try:
|
||||||
rtl_proc = self.processes['aprs_rtl']
|
rtl_proc = self.processes['aprs_rtl']
|
||||||
@@ -3021,10 +3005,8 @@ class ModeManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"RTLAMR reader error: {e}")
|
logger.error(f"RTLAMR reader error: {e}")
|
||||||
finally:
|
finally:
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
proc.wait(timeout=1)
|
proc.wait(timeout=1)
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if 'rtlamr_tcp' in self.processes:
|
if 'rtlamr_tcp' in self.processes:
|
||||||
try:
|
try:
|
||||||
tcp_proc = self.processes['rtlamr_tcp']
|
tcp_proc = self.processes['rtlamr_tcp']
|
||||||
@@ -3142,10 +3124,8 @@ class ModeManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"DSC reader error: {e}")
|
logger.error(f"DSC reader error: {e}")
|
||||||
finally:
|
finally:
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
proc.wait(timeout=1)
|
proc.wait(timeout=1)
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
logger.info("DSC reader stopped")
|
logger.info("DSC reader stopped")
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
@@ -3219,13 +3199,13 @@ class ModeManager:
|
|||||||
stop_event = self.stop_events.get(mode)
|
stop_event = self.stop_events.get(mode)
|
||||||
|
|
||||||
# Import existing Intercept TSCM functions
|
# Import existing Intercept TSCM functions
|
||||||
from routes.tscm import _scan_wifi_networks, _scan_wifi_clients, _scan_bluetooth_devices, _scan_rf_signals
|
from routes.tscm import _scan_bluetooth_devices, _scan_rf_signals, _scan_wifi_clients, _scan_wifi_networks
|
||||||
logger.info("TSCM imports successful")
|
logger.info("TSCM imports successful")
|
||||||
|
|
||||||
sweep_ranges = None
|
sweep_ranges = None
|
||||||
if sweep_type:
|
if sweep_type:
|
||||||
try:
|
try:
|
||||||
from data.tscm_frequencies import get_sweep_preset, SWEEP_PRESETS
|
from data.tscm_frequencies import SWEEP_PRESETS, get_sweep_preset
|
||||||
preset = get_sweep_preset(sweep_type) or SWEEP_PRESETS.get('standard')
|
preset = get_sweep_preset(sweep_type) or SWEEP_PRESETS.get('standard')
|
||||||
sweep_ranges = preset.get('ranges') if preset else None
|
sweep_ranges = preset.get('ranges') if preset else None
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -3412,7 +3392,8 @@ class ModeManager:
|
|||||||
if scan_rf and (current_time - last_rf_scan) >= rf_scan_interval:
|
if scan_rf and (current_time - last_rf_scan) >= rf_scan_interval:
|
||||||
try:
|
try:
|
||||||
# Pass a stop check that uses our stop_event (not the module's _sweep_running)
|
# Pass a stop check that uses our stop_event (not the module's _sweep_running)
|
||||||
agent_stop_check = lambda: stop_event and stop_event.is_set()
|
def agent_stop_check():
|
||||||
|
return stop_event and stop_event.is_set()
|
||||||
rf_signals = _scan_rf_signals(
|
rf_signals = _scan_rf_signals(
|
||||||
sdr_device,
|
sdr_device,
|
||||||
stop_check=agent_stop_check,
|
stop_check=agent_stop_check,
|
||||||
@@ -3610,10 +3591,8 @@ class ModeManager:
|
|||||||
# Ensure test process is killed on any error
|
# Ensure test process is killed on any error
|
||||||
if test_proc and test_proc.poll() is None:
|
if test_proc and test_proc.poll() is None:
|
||||||
test_proc.kill()
|
test_proc.kill()
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
test_proc.wait(timeout=1)
|
test_proc.wait(timeout=1)
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return {'status': 'error', 'message': f'SDR check failed: {str(e)}'}
|
return {'status': 'error', 'message': f'SDR check failed: {str(e)}'}
|
||||||
|
|
||||||
# Initialize state
|
# Initialize state
|
||||||
@@ -3647,9 +3626,9 @@ class ModeManager:
|
|||||||
step: float, modulation: str, squelch: int,
|
step: float, modulation: str, squelch: int,
|
||||||
device: str, gain: str, dwell_time: float = 1.0):
|
device: str, gain: str, dwell_time: float = 1.0):
|
||||||
"""Scan frequency range and report signal detections."""
|
"""Scan frequency range and report signal detections."""
|
||||||
import select
|
|
||||||
import os
|
|
||||||
import fcntl
|
import fcntl
|
||||||
|
import os
|
||||||
|
import select
|
||||||
|
|
||||||
mode = 'listening_post'
|
mode = 'listening_post'
|
||||||
stop_event = self.stop_events.get(mode)
|
stop_event = self.stop_events.get(mode)
|
||||||
@@ -3709,7 +3688,7 @@ class ModeManager:
|
|||||||
signal_detected = True
|
signal_detected = True
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
except (IOError, BlockingIOError):
|
except (OSError, BlockingIOError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
proc.terminate()
|
proc.terminate()
|
||||||
@@ -4131,27 +4110,19 @@ def main():
|
|||||||
|
|
||||||
# Stop push services
|
# Stop push services
|
||||||
if data_push_loop:
|
if data_push_loop:
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
data_push_loop.stop()
|
data_push_loop.stop()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if push_client:
|
if push_client:
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
push_client.stop()
|
push_client.stop()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Stop GPS
|
# Stop GPS
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
gps_manager.stop()
|
gps_manager.stop()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Shutdown HTTP server
|
# Shutdown HTTP server
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
httpd.shutdown()
|
httpd.shutdown()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Run cleanup in background thread so signal handler returns quickly
|
# Run cleanup in background thread so signal handler returns quickly
|
||||||
cleanup_thread = threading.Thread(target=cleanup, daemon=True)
|
cleanup_thread = threading.Thread(target=cleanup, daemon=True)
|
||||||
|
|||||||
+25
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "intercept"
|
name = "intercept"
|
||||||
version = "2.24.0"
|
version = "2.26.10"
|
||||||
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
@@ -93,8 +93,32 @@ ignore = [
|
|||||||
"B008", # do not perform function calls in argument defaults
|
"B008", # do not perform function calls in argument defaults
|
||||||
"B905", # zip without explicit strict
|
"B905", # zip without explicit strict
|
||||||
"SIM108", # use ternary operator instead of if-else
|
"SIM108", # use ternary operator instead of if-else
|
||||||
|
"SIM102", # collapsible if statements
|
||||||
|
"SIM105", # use contextlib.suppress (stylistic, not a bug)
|
||||||
|
"SIM115", # use context manager for open (not always applicable)
|
||||||
|
"SIM116", # use dict instead of if/elif chain (stylistic)
|
||||||
|
"SIM117", # combine nested with statements (stylistic)
|
||||||
|
"E402", # module-level import not at top (needed for conditional imports)
|
||||||
|
"E741", # ambiguous variable name
|
||||||
|
"E721", # type comparison (use isinstance)
|
||||||
|
"E722", # bare except
|
||||||
|
"B904", # raise from within except (stylistic)
|
||||||
|
"B007", # unused loop variable (use _ prefix)
|
||||||
|
"B023", # function definition doesn't bind loop variable
|
||||||
|
"F601", # membership test with duplicate items
|
||||||
|
"F821", # undefined name (too many false positives with conditional imports)
|
||||||
|
"UP035", # deprecated typing imports
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"__init__.py" = ["F401"] # re-exports in __init__.py are intentional
|
||||||
|
"utils/bluetooth/capability_check.py" = ["F401"] # imports used for availability checking
|
||||||
|
"utils/bluetooth/fallback_scanner.py" = ["F401"] # imports used for availability checking
|
||||||
|
"utils/tscm/ble_scanner.py" = ["F401"] # imports used for availability checking
|
||||||
|
"utils/wifi/deauth_detector.py" = ["F401"] # imports used for availability checking
|
||||||
|
"routes/dsc.py" = ["F401"] # imports used for availability checking
|
||||||
|
"intercept_agent.py" = ["F401"] # conditional imports
|
||||||
|
|
||||||
[tool.ruff.lint.isort]
|
[tool.ruff.lint.isort]
|
||||||
known-first-party = ["app", "config", "routes", "utils", "data"]
|
known-first-party = ["app", "config", "routes", "utils", "data"]
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+13
-2
@@ -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
|
||||||
@@ -18,8 +24,8 @@ def register_blueprints(app):
|
|||||||
from .meshtastic import meshtastic_bp
|
from .meshtastic import meshtastic_bp
|
||||||
from .meteor_websocket import meteor_bp
|
from .meteor_websocket import meteor_bp
|
||||||
from .morse import morse_bp
|
from .morse import morse_bp
|
||||||
from .ook import ook_bp
|
|
||||||
from .offline import offline_bp
|
from .offline import offline_bp
|
||||||
|
from .ook import ook_bp
|
||||||
from .pager import pager_bp
|
from .pager import pager_bp
|
||||||
from .radiosonde import radiosonde_bp
|
from .radiosonde import radiosonde_bp
|
||||||
from .recordings import recordings_bp
|
from .recordings import recordings_bp
|
||||||
@@ -38,8 +44,8 @@ def register_blueprints(app):
|
|||||||
from .updater import updater_bp
|
from .updater import updater_bp
|
||||||
from .vdl2 import vdl2_bp
|
from .vdl2 import vdl2_bp
|
||||||
from .weather_sat import weather_sat_bp
|
from .weather_sat import weather_sat_bp
|
||||||
from .wefax import wefax_bp
|
|
||||||
from .websdr import websdr_bp
|
from .websdr import websdr_bp
|
||||||
|
from .wefax import wefax_bp
|
||||||
from .wifi import wifi_bp
|
from .wifi import wifi_bp
|
||||||
from .wifi_v2 import wifi_v2_bp
|
from .wifi_v2 import wifi_v2_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'):
|
||||||
|
|||||||
+13
-29
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import io
|
import contextlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
@@ -13,7 +13,7 @@ import subprocess
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Generator
|
from typing import Any
|
||||||
|
|
||||||
from flask import Blueprint, Response, jsonify, request
|
from flask import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
@@ -29,6 +29,7 @@ from utils.event_pipeline import process_event
|
|||||||
from utils.flight_correlator import get_flight_correlator
|
from utils.flight_correlator import get_flight_correlator
|
||||||
from utils.logging import sensor_logger as logger
|
from utils.logging import sensor_logger as logger
|
||||||
from utils.process import register_process, unregister_process
|
from utils.process import register_process, unregister_process
|
||||||
|
from utils.responses import api_error
|
||||||
from utils.sdr import SDRFactory, SDRType
|
from utils.sdr import SDRFactory, SDRType
|
||||||
from utils.sse import sse_stream_fanout
|
from utils.sse import sse_stream_fanout
|
||||||
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
||||||
@@ -142,10 +143,8 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
|
|||||||
app_module.acars_queue.put(data)
|
app_module.acars_queue.put(data)
|
||||||
|
|
||||||
# Feed flight correlator
|
# Feed flight correlator
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
get_flight_correlator().add_acars_message(data)
|
get_flight_correlator().add_acars_message(data)
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Log if enabled
|
# Log if enabled
|
||||||
if app_module.logging_enabled:
|
if app_module.logging_enabled:
|
||||||
@@ -171,10 +170,8 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
|
|||||||
process.terminate()
|
process.terminate()
|
||||||
process.wait(timeout=2)
|
process.wait(timeout=2)
|
||||||
except Exception:
|
except Exception:
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
process.kill()
|
process.kill()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
unregister_process(process)
|
unregister_process(process)
|
||||||
app_module.acars_queue.put({'type': 'status', 'status': 'stopped'})
|
app_module.acars_queue.put({'type': 'status', 'status': 'stopped'})
|
||||||
with app_module.acars_lock:
|
with app_module.acars_lock:
|
||||||
@@ -219,18 +216,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 +231,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 +240,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
|
||||||
@@ -344,7 +331,7 @@ def start_acars() -> Response:
|
|||||||
)
|
)
|
||||||
os.close(slave_fd)
|
os.close(slave_fd)
|
||||||
# Wrap master_fd as a text file for line-buffered reading
|
# Wrap master_fd as a text file for line-buffered reading
|
||||||
process.stdout = io.open(master_fd, 'r', buffering=1)
|
process.stdout = open(master_fd, buffering=1)
|
||||||
is_text_mode = True
|
is_text_mode = True
|
||||||
else:
|
else:
|
||||||
process = subprocess.Popen(
|
process = subprocess.Popen(
|
||||||
@@ -372,7 +359,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 +386,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 +396,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()
|
||||||
|
|||||||
+179
-204
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import queue
|
import queue
|
||||||
import shutil
|
import shutil
|
||||||
@@ -13,10 +13,12 @@ import subprocess
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any, Generator
|
from typing import Any
|
||||||
|
|
||||||
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_error, api_success
|
||||||
|
|
||||||
# psycopg2 is optional - only needed for PostgreSQL history persistence
|
# psycopg2 is optional - only needed for PostgreSQL history persistence
|
||||||
try:
|
try:
|
||||||
import psycopg2
|
import psycopg2
|
||||||
@@ -27,6 +29,8 @@ except ImportError:
|
|||||||
RealDictCursor = None # type: ignore
|
RealDictCursor = None # type: ignore
|
||||||
PSYCOPG2_AVAILABLE = False
|
PSYCOPG2_AVAILABLE = False
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from config import (
|
from config import (
|
||||||
ADSB_AUTO_START,
|
ADSB_AUTO_START,
|
||||||
@@ -404,18 +408,17 @@ def _get_active_session() -> dict[str, Any] | None:
|
|||||||
return None
|
return None
|
||||||
_ensure_history_schema()
|
_ensure_history_schema()
|
||||||
try:
|
try:
|
||||||
with _get_history_connection() as conn:
|
with _get_history_connection() as conn, conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
cur.execute(
|
||||||
cur.execute(
|
"""
|
||||||
"""
|
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM adsb_sessions
|
FROM adsb_sessions
|
||||||
WHERE ended_at IS NULL
|
WHERE ended_at IS NULL
|
||||||
ORDER BY started_at DESC
|
ORDER BY started_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
return cur.fetchone()
|
return cur.fetchone()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("ADS-B session lookup failed: %s", exc)
|
logger.warning("ADS-B session lookup failed: %s", exc)
|
||||||
return None
|
return None
|
||||||
@@ -434,10 +437,9 @@ def _record_session_start(
|
|||||||
return None
|
return None
|
||||||
_ensure_history_schema()
|
_ensure_history_schema()
|
||||||
try:
|
try:
|
||||||
with _get_history_connection() as conn:
|
with _get_history_connection() as conn, conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
cur.execute(
|
||||||
cur.execute(
|
"""
|
||||||
"""
|
|
||||||
INSERT INTO adsb_sessions (
|
INSERT INTO adsb_sessions (
|
||||||
device_index,
|
device_index,
|
||||||
sdr_type,
|
sdr_type,
|
||||||
@@ -449,16 +451,16 @@ def _record_session_start(
|
|||||||
VALUES (%s, %s, %s, %s, %s, %s)
|
VALUES (%s, %s, %s, %s, %s, %s)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
device_index,
|
device_index,
|
||||||
sdr_type,
|
sdr_type,
|
||||||
remote_host,
|
remote_host,
|
||||||
remote_port,
|
remote_port,
|
||||||
start_source,
|
start_source,
|
||||||
started_by,
|
started_by,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return cur.fetchone()
|
return cur.fetchone()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("ADS-B session start record failed: %s", exc)
|
logger.warning("ADS-B session start record failed: %s", exc)
|
||||||
return None
|
return None
|
||||||
@@ -469,10 +471,9 @@ def _record_session_stop(*, stop_source: str | None, stopped_by: str | None) ->
|
|||||||
return None
|
return None
|
||||||
_ensure_history_schema()
|
_ensure_history_schema()
|
||||||
try:
|
try:
|
||||||
with _get_history_connection() as conn:
|
with _get_history_connection() as conn, conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
cur.execute(
|
||||||
cur.execute(
|
"""
|
||||||
"""
|
|
||||||
UPDATE adsb_sessions
|
UPDATE adsb_sessions
|
||||||
SET ended_at = NOW(),
|
SET ended_at = NOW(),
|
||||||
stop_source = COALESCE(%s, stop_source),
|
stop_source = COALESCE(%s, stop_source),
|
||||||
@@ -480,9 +481,9 @@ def _record_session_stop(*, stop_source: str | None, stopped_by: str | None) ->
|
|||||||
WHERE ended_at IS NULL
|
WHERE ended_at IS NULL
|
||||||
RETURNING *
|
RETURNING *
|
||||||
""",
|
""",
|
||||||
(stop_source, stopped_by),
|
(stop_source, stopped_by),
|
||||||
)
|
)
|
||||||
return cur.fetchone()
|
return cur.fetchone()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("ADS-B session stop record failed: %s", exc)
|
logger.warning("ADS-B session stop record failed: %s", exc)
|
||||||
return None
|
return None
|
||||||
@@ -663,10 +664,8 @@ def parse_sbs_stream(service_addr):
|
|||||||
|
|
||||||
elif msg_type == '3' and len(parts) > 15:
|
elif msg_type == '3' and len(parts) > 15:
|
||||||
if parts[11]:
|
if parts[11]:
|
||||||
try:
|
with contextlib.suppress(ValueError, TypeError):
|
||||||
aircraft['altitude'] = int(float(parts[11]))
|
aircraft['altitude'] = int(float(parts[11]))
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
if parts[14] and parts[15]:
|
if parts[14] and parts[15]:
|
||||||
try:
|
try:
|
||||||
aircraft['lat'] = float(parts[14])
|
aircraft['lat'] = float(parts[14])
|
||||||
@@ -676,15 +675,11 @@ def parse_sbs_stream(service_addr):
|
|||||||
|
|
||||||
elif msg_type == '4' and len(parts) > 16:
|
elif msg_type == '4' and len(parts) > 16:
|
||||||
if parts[12]:
|
if parts[12]:
|
||||||
try:
|
with contextlib.suppress(ValueError, TypeError):
|
||||||
aircraft['speed'] = int(float(parts[12]))
|
aircraft['speed'] = int(float(parts[12]))
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
if parts[13]:
|
if parts[13]:
|
||||||
try:
|
with contextlib.suppress(ValueError, TypeError):
|
||||||
aircraft['heading'] = int(float(parts[13]))
|
aircraft['heading'] = int(float(parts[13]))
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
if parts[16]:
|
if parts[16]:
|
||||||
try:
|
try:
|
||||||
aircraft['vertical_rate'] = int(float(parts[16]))
|
aircraft['vertical_rate'] = int(float(parts[16]))
|
||||||
@@ -703,10 +698,8 @@ def parse_sbs_stream(service_addr):
|
|||||||
if callsign:
|
if callsign:
|
||||||
aircraft['callsign'] = callsign
|
aircraft['callsign'] = callsign
|
||||||
if parts[11]:
|
if parts[11]:
|
||||||
try:
|
with contextlib.suppress(ValueError, TypeError):
|
||||||
aircraft['altitude'] = int(float(parts[11]))
|
aircraft['altitude'] = int(float(parts[11]))
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
elif msg_type == '6' and len(parts) > 17:
|
elif msg_type == '6' and len(parts) > 17:
|
||||||
if parts[17]:
|
if parts[17]:
|
||||||
@@ -722,20 +715,14 @@ def parse_sbs_stream(service_addr):
|
|||||||
|
|
||||||
elif msg_type == '2' and len(parts) > 15:
|
elif msg_type == '2' and len(parts) > 15:
|
||||||
if parts[11]:
|
if parts[11]:
|
||||||
try:
|
with contextlib.suppress(ValueError, TypeError):
|
||||||
aircraft['altitude'] = int(float(parts[11]))
|
aircraft['altitude'] = int(float(parts[11]))
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
if parts[12]:
|
if parts[12]:
|
||||||
try:
|
with contextlib.suppress(ValueError, TypeError):
|
||||||
aircraft['speed'] = int(float(parts[12]))
|
aircraft['speed'] = int(float(parts[12]))
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
if parts[13]:
|
if parts[13]:
|
||||||
try:
|
with contextlib.suppress(ValueError, TypeError):
|
||||||
aircraft['heading'] = int(float(parts[13]))
|
aircraft['heading'] = int(float(parts[13]))
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
if parts[14] and parts[15]:
|
if parts[14] and parts[15]:
|
||||||
try:
|
try:
|
||||||
aircraft['lat'] = float(parts[14])
|
aircraft['lat'] = float(parts[14])
|
||||||
@@ -763,10 +750,8 @@ def parse_sbs_stream(service_addr):
|
|||||||
time.sleep(SBS_RECONNECT_DELAY)
|
time.sleep(SBS_RECONNECT_DELAY)
|
||||||
finally:
|
finally:
|
||||||
if sock:
|
if sock:
|
||||||
try:
|
with contextlib.suppress(OSError):
|
||||||
sock.close()
|
sock.close()
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
adsb_connected = False
|
adsb_connected = False
|
||||||
logger.info("SBS stream parser stopped")
|
logger.info("SBS stream parser stopped")
|
||||||
@@ -866,7 +851,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 +863,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 +920,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:
|
||||||
@@ -1017,10 +1002,8 @@ def start_adsb():
|
|||||||
adsb_active_sdr_type = None
|
adsb_active_sdr_type = None
|
||||||
stderr_output = ''
|
stderr_output = ''
|
||||||
if app_module.adsb_process.stderr:
|
if app_module.adsb_process.stderr:
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
stderr_output = app_module.adsb_process.stderr.read().decode('utf-8', errors='ignore').strip()
|
stderr_output = app_module.adsb_process.stderr.read().decode('utf-8', errors='ignore').strip()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Parse stderr to provide specific guidance
|
# Parse stderr to provide specific guidance
|
||||||
error_type = 'START_FAILED'
|
error_type = 'START_FAILED'
|
||||||
@@ -1122,7 +1105,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'])
|
||||||
@@ -1188,10 +1171,8 @@ def stream_adsb():
|
|||||||
try:
|
try:
|
||||||
msg = client_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
msg = client_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
process_event('adsb', msg, msg.get('type'))
|
process_event('adsb', msg, msg.get('type'))
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
@@ -1233,7 +1214,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)
|
||||||
@@ -1249,21 +1230,20 @@ def adsb_history_summary():
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with _get_history_connection() as conn:
|
with _get_history_connection() as conn, conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
cur.execute(sql, (window, window, window, window, window))
|
||||||
cur.execute(sql, (window, window, window, window, window))
|
row = cur.fetchone() or {}
|
||||||
row = cur.fetchone() or {}
|
|
||||||
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)
|
||||||
@@ -1299,26 +1279,25 @@ def adsb_history_aircraft():
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with _get_history_connection() as conn:
|
with _get_history_connection() as conn, conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
cur.execute(sql, (window, search, pattern, pattern, pattern, limit))
|
||||||
cur.execute(sql, (window, search, pattern, pattern, pattern, limit))
|
rows = cur.fetchall()
|
||||||
rows = cur.fetchall()
|
|
||||||
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)
|
||||||
@@ -1334,21 +1313,20 @@ def adsb_history_timeline():
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with _get_history_connection() as conn:
|
with _get_history_connection() as conn, conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
cur.execute(sql, (icao, window, limit))
|
||||||
cur.execute(sql, (icao, window, limit))
|
rows = cur.fetchall()
|
||||||
rows = cur.fetchall()
|
|
||||||
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()
|
||||||
@@ -1366,29 +1344,28 @@ def adsb_history_messages():
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with _get_history_connection() as conn:
|
with _get_history_connection() as conn, conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
cur.execute(sql, (window, icao, icao, limit))
|
||||||
cur.execute(sql, (window, icao, icao, limit))
|
rows = cur.fetchall()
|
||||||
rows = cur.fetchall()
|
|
||||||
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()
|
||||||
@@ -1416,92 +1393,91 @@ def adsb_history_export():
|
|||||||
]
|
]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with _get_history_connection() as conn:
|
with _get_history_connection() as conn, conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
if export_type in {'snapshots', 'all'}:
|
||||||
if export_type in {'snapshots', 'all'}:
|
snapshot_where: list[str] = []
|
||||||
snapshot_where: list[str] = []
|
snapshot_params: list[Any] = []
|
||||||
snapshot_params: list[Any] = []
|
_add_time_filter(
|
||||||
_add_time_filter(
|
where_parts=snapshot_where,
|
||||||
where_parts=snapshot_where,
|
params=snapshot_params,
|
||||||
params=snapshot_params,
|
scope=scope,
|
||||||
scope=scope,
|
timestamp_field='captured_at',
|
||||||
timestamp_field='captured_at',
|
since_minutes=since_minutes,
|
||||||
since_minutes=since_minutes,
|
start=start,
|
||||||
start=start,
|
end=end,
|
||||||
end=end,
|
)
|
||||||
)
|
if icao:
|
||||||
if icao:
|
snapshot_where.append("icao = %s")
|
||||||
snapshot_where.append("icao = %s")
|
snapshot_params.append(icao)
|
||||||
snapshot_params.append(icao)
|
if search:
|
||||||
if search:
|
snapshot_where.append("(icao ILIKE %s OR callsign ILIKE %s OR registration ILIKE %s)")
|
||||||
snapshot_where.append("(icao ILIKE %s OR callsign ILIKE %s OR registration ILIKE %s)")
|
snapshot_params.extend([pattern, pattern, pattern])
|
||||||
snapshot_params.extend([pattern, pattern, pattern])
|
|
||||||
|
|
||||||
snapshot_sql = """
|
snapshot_sql = """
|
||||||
SELECT captured_at, icao, callsign, registration, type_code, type_desc,
|
SELECT captured_at, icao, callsign, registration, type_code, type_desc,
|
||||||
altitude, speed, heading, vertical_rate, lat, lon, squawk, source_host
|
altitude, speed, heading, vertical_rate, lat, lon, squawk, source_host
|
||||||
FROM adsb_snapshots
|
FROM adsb_snapshots
|
||||||
"""
|
"""
|
||||||
if snapshot_where:
|
if snapshot_where:
|
||||||
snapshot_sql += " WHERE " + " AND ".join(snapshot_where)
|
snapshot_sql += " WHERE " + " AND ".join(snapshot_where)
|
||||||
snapshot_sql += " ORDER BY captured_at DESC"
|
snapshot_sql += " ORDER BY captured_at DESC"
|
||||||
cur.execute(snapshot_sql, tuple(snapshot_params))
|
cur.execute(snapshot_sql, tuple(snapshot_params))
|
||||||
snapshots = _filter_by_classification(cur.fetchall())
|
snapshots = _filter_by_classification(cur.fetchall())
|
||||||
|
|
||||||
if export_type in {'messages', 'all'}:
|
if export_type in {'messages', 'all'}:
|
||||||
message_where: list[str] = []
|
message_where: list[str] = []
|
||||||
message_params: list[Any] = []
|
message_params: list[Any] = []
|
||||||
_add_time_filter(
|
_add_time_filter(
|
||||||
where_parts=message_where,
|
where_parts=message_where,
|
||||||
params=message_params,
|
params=message_params,
|
||||||
scope=scope,
|
scope=scope,
|
||||||
timestamp_field='received_at',
|
timestamp_field='received_at',
|
||||||
since_minutes=since_minutes,
|
since_minutes=since_minutes,
|
||||||
start=start,
|
start=start,
|
||||||
end=end,
|
end=end,
|
||||||
)
|
)
|
||||||
if icao:
|
if icao:
|
||||||
message_where.append("icao = %s")
|
message_where.append("icao = %s")
|
||||||
message_params.append(icao)
|
message_params.append(icao)
|
||||||
if search:
|
if search:
|
||||||
message_where.append("(icao ILIKE %s OR callsign ILIKE %s)")
|
message_where.append("(icao ILIKE %s OR callsign ILIKE %s)")
|
||||||
message_params.extend([pattern, pattern])
|
message_params.extend([pattern, pattern])
|
||||||
|
|
||||||
message_sql = """
|
message_sql = """
|
||||||
SELECT received_at, msg_time, logged_time, icao, msg_type, callsign,
|
SELECT received_at, msg_time, logged_time, icao, msg_type, callsign,
|
||||||
altitude, speed, heading, vertical_rate, lat, lon, squawk,
|
altitude, speed, heading, vertical_rate, lat, lon, squawk,
|
||||||
session_id, aircraft_id, flight_id, source_host, raw_line
|
session_id, aircraft_id, flight_id, source_host, raw_line
|
||||||
FROM adsb_messages
|
FROM adsb_messages
|
||||||
"""
|
"""
|
||||||
if message_where:
|
if message_where:
|
||||||
message_sql += " WHERE " + " AND ".join(message_where)
|
message_sql += " WHERE " + " AND ".join(message_where)
|
||||||
message_sql += " ORDER BY received_at DESC"
|
message_sql += " ORDER BY received_at DESC"
|
||||||
cur.execute(message_sql, tuple(message_params))
|
cur.execute(message_sql, tuple(message_params))
|
||||||
messages = _filter_by_classification(cur.fetchall())
|
messages = _filter_by_classification(cur.fetchall())
|
||||||
|
|
||||||
if export_type in {'sessions', 'all'}:
|
if export_type in {'sessions', 'all'}:
|
||||||
session_where: list[str] = []
|
session_where: list[str] = []
|
||||||
session_params: list[Any] = []
|
session_params: list[Any] = []
|
||||||
if scope == 'custom' and start is not None and end is not None:
|
if scope == 'custom' and start is not None and end is not None:
|
||||||
session_where.append("COALESCE(ended_at, %s) >= %s AND started_at < %s")
|
session_where.append("COALESCE(ended_at, %s) >= %s AND started_at < %s")
|
||||||
session_params.extend([end, start, end])
|
session_params.extend([end, start, end])
|
||||||
elif scope == 'window':
|
elif scope == 'window':
|
||||||
session_where.append("COALESCE(ended_at, NOW()) >= NOW() - INTERVAL %s")
|
session_where.append("COALESCE(ended_at, NOW()) >= NOW() - INTERVAL %s")
|
||||||
session_params.append(f'{since_minutes} minutes')
|
session_params.append(f'{since_minutes} minutes')
|
||||||
|
|
||||||
session_sql = """
|
session_sql = """
|
||||||
SELECT id, started_at, ended_at, device_index, sdr_type, remote_host,
|
SELECT id, started_at, ended_at, device_index, sdr_type, remote_host,
|
||||||
remote_port, start_source, stop_source, started_by, stopped_by, notes
|
remote_port, start_source, stop_source, started_by, stopped_by, notes
|
||||||
FROM adsb_sessions
|
FROM adsb_sessions
|
||||||
"""
|
"""
|
||||||
if session_where:
|
if session_where:
|
||||||
session_sql += " WHERE " + " AND ".join(session_where)
|
session_sql += " WHERE " + " AND ".join(session_where)
|
||||||
session_sql += " ORDER BY started_at DESC"
|
session_sql += " ORDER BY started_at DESC"
|
||||||
cur.execute(session_sql, tuple(session_params))
|
cur.execute(session_sql, tuple(session_params))
|
||||||
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,71 +1535,70 @@ 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, conn.cursor() as cur:
|
||||||
with conn.cursor() as cur:
|
deleted = {'messages': 0, 'snapshots': 0}
|
||||||
deleted = {'messages': 0, 'snapshots': 0}
|
|
||||||
|
|
||||||
if mode == 'all':
|
if mode == 'all':
|
||||||
cur.execute("DELETE FROM adsb_messages")
|
cur.execute("DELETE FROM adsb_messages")
|
||||||
deleted['messages'] = max(0, cur.rowcount or 0)
|
deleted['messages'] = max(0, cur.rowcount or 0)
|
||||||
cur.execute("DELETE FROM adsb_snapshots")
|
cur.execute("DELETE FROM adsb_snapshots")
|
||||||
deleted['snapshots'] = max(0, cur.rowcount or 0)
|
deleted['snapshots'] = max(0, cur.rowcount or 0)
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'ok',
|
'status': 'ok',
|
||||||
'mode': 'all',
|
'mode': 'all',
|
||||||
'deleted': deleted,
|
'deleted': deleted,
|
||||||
'total_deleted': deleted['messages'] + deleted['snapshots'],
|
'total_deleted': deleted['messages'] + deleted['snapshots'],
|
||||||
})
|
})
|
||||||
|
|
||||||
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(
|
||||||
"""
|
"""
|
||||||
DELETE FROM adsb_messages
|
DELETE FROM adsb_messages
|
||||||
WHERE received_at >= %s
|
WHERE received_at >= %s
|
||||||
AND received_at < %s
|
AND received_at < %s
|
||||||
""",
|
""",
|
||||||
(start, end),
|
(start, end),
|
||||||
)
|
)
|
||||||
deleted['messages'] = max(0, cur.rowcount or 0)
|
deleted['messages'] = max(0, cur.rowcount or 0)
|
||||||
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
DELETE FROM adsb_snapshots
|
DELETE FROM adsb_snapshots
|
||||||
WHERE captured_at >= %s
|
WHERE captured_at >= %s
|
||||||
AND captured_at < %s
|
AND captured_at < %s
|
||||||
""",
|
""",
|
||||||
(start, end),
|
(start, end),
|
||||||
)
|
)
|
||||||
deleted['snapshots'] = max(0, cur.rowcount or 0)
|
deleted['snapshots'] = max(0, cur.rowcount or 0)
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'ok',
|
'status': 'ok',
|
||||||
'mode': 'range',
|
'mode': 'range',
|
||||||
'start': start.isoformat(),
|
'start': start.isoformat(),
|
||||||
'end': end.isoformat(),
|
'end': end.isoformat(),
|
||||||
'deleted': deleted,
|
'deleted': deleted,
|
||||||
'total_deleted': deleted['messages'] + deleted['snapshots'],
|
'total_deleted': deleted['messages'] + deleted['snapshots'],
|
||||||
})
|
})
|
||||||
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 +1643,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 +1676,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 +1697,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})
|
||||||
|
|||||||
+23
-36
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import queue
|
import queue
|
||||||
@@ -10,29 +11,28 @@ import socket
|
|||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from typing import Generator
|
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response, render_template
|
from flask import Blueprint, Response, jsonify, render_template, request
|
||||||
|
|
||||||
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.validation import validate_device_index, validate_gain
|
|
||||||
from utils.sse import sse_stream_fanout
|
|
||||||
from utils.event_pipeline import process_event
|
|
||||||
from utils.sdr import SDRFactory, SDRType
|
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
|
AIS_RECONNECT_DELAY,
|
||||||
|
AIS_SOCKET_TIMEOUT,
|
||||||
AIS_TCP_PORT,
|
AIS_TCP_PORT,
|
||||||
AIS_TERMINATE_TIMEOUT,
|
AIS_TERMINATE_TIMEOUT,
|
||||||
AIS_SOCKET_TIMEOUT,
|
|
||||||
AIS_RECONNECT_DELAY,
|
|
||||||
AIS_UPDATE_INTERVAL,
|
AIS_UPDATE_INTERVAL,
|
||||||
|
PROCESS_TERMINATE_TIMEOUT,
|
||||||
SOCKET_BUFFER_SIZE,
|
SOCKET_BUFFER_SIZE,
|
||||||
SSE_KEEPALIVE_INTERVAL,
|
SSE_KEEPALIVE_INTERVAL,
|
||||||
SSE_QUEUE_TIMEOUT,
|
SSE_QUEUE_TIMEOUT,
|
||||||
SOCKET_CONNECT_TIMEOUT,
|
|
||||||
PROCESS_TERMINATE_TIMEOUT,
|
|
||||||
)
|
)
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
|
from utils.logging import get_logger
|
||||||
|
from utils.responses import api_error, api_success
|
||||||
|
from utils.sdr import SDRFactory, SDRType
|
||||||
|
from utils.sse import sse_stream_fanout
|
||||||
|
from utils.validation import validate_device_index, validate_gain
|
||||||
|
|
||||||
logger = get_logger('intercept.ais')
|
logger = get_logger('intercept.ais')
|
||||||
|
|
||||||
@@ -127,13 +127,11 @@ def parse_ais_stream(port: int):
|
|||||||
for mmsi in pending_updates:
|
for mmsi in pending_updates:
|
||||||
if mmsi in app_module.ais_vessels:
|
if mmsi in app_module.ais_vessels:
|
||||||
_vessel_snap = app_module.ais_vessels[mmsi]
|
_vessel_snap = app_module.ais_vessels[mmsi]
|
||||||
try:
|
with contextlib.suppress(queue.Full):
|
||||||
app_module.ais_queue.put_nowait({
|
app_module.ais_queue.put_nowait({
|
||||||
'type': 'vessel',
|
'type': 'vessel',
|
||||||
**_vessel_snap
|
**_vessel_snap
|
||||||
})
|
})
|
||||||
except queue.Full:
|
|
||||||
pass
|
|
||||||
# Geofence check
|
# Geofence check
|
||||||
_v_lat = _vessel_snap.get('lat')
|
_v_lat = _vessel_snap.get('lat')
|
||||||
_v_lon = _vessel_snap.get('lon')
|
_v_lon = _vessel_snap.get('lon')
|
||||||
@@ -162,10 +160,8 @@ def parse_ais_stream(port: int):
|
|||||||
time.sleep(AIS_RECONNECT_DELAY)
|
time.sleep(AIS_RECONNECT_DELAY)
|
||||||
finally:
|
finally:
|
||||||
if sock:
|
if sock:
|
||||||
try:
|
with contextlib.suppress(OSError):
|
||||||
sock.close()
|
sock.close()
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
ais_connected = False
|
ais_connected = False
|
||||||
logger.info("AIS stream parser stopped")
|
logger.info("AIS stream parser stopped")
|
||||||
@@ -361,7 +357,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 +366,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 +399,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)
|
||||||
@@ -446,16 +435,14 @@ def start_ais():
|
|||||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||||
stderr_output = ''
|
stderr_output = ''
|
||||||
if app_module.ais_process.stderr:
|
if app_module.ais_process.stderr:
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
stderr_output = app_module.ais_process.stderr.read().decode('utf-8', errors='ignore').strip()
|
stderr_output = app_module.ais_process.stderr.read().decode('utf-8', errors='ignore').strip()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if stderr_output:
|
if stderr_output:
|
||||||
logger.error(f"AIS-catcher stderr:\n{stderr_output}")
|
logger.error(f"AIS-catcher stderr:\n{stderr_output}")
|
||||||
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 +462,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,17 +522,17 @@ 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:
|
||||||
for key, msg in app_module.dsc_messages.items():
|
for _key, msg in app_module.dsc_messages.items():
|
||||||
if str(msg.get('source_mmsi', '')) == mmsi:
|
if str(msg.get('source_mmsi', '')) == mmsi:
|
||||||
matches.append(dict(msg))
|
matches.append(dict(msg))
|
||||||
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')
|
||||||
|
|||||||
+11
-12
@@ -2,13 +2,12 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import queue
|
from collections.abc import Generator
|
||||||
import time
|
|
||||||
from typing import Generator
|
|
||||||
|
|
||||||
from flask import Blueprint, Response, jsonify, request
|
from flask import Blueprint, Response, request
|
||||||
|
|
||||||
from utils.alerts import get_alert_manager
|
from utils.alerts import get_alert_manager
|
||||||
|
from utils.responses import api_error, api_success
|
||||||
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 +17,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 +37,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 +46,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 +57,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'])
|
||||||
|
|||||||
+94
-128
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import csv
|
import csv
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -15,13 +16,23 @@ import tempfile
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from subprocess import PIPE, STDOUT
|
from subprocess import PIPE
|
||||||
from typing import Any, Generator, Optional
|
from typing import Any
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response
|
from flask import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
import app as app_module
|
import app as app_module
|
||||||
|
from utils.constants import (
|
||||||
|
PROCESS_START_WAIT,
|
||||||
|
PROCESS_TERMINATE_TIMEOUT,
|
||||||
|
SSE_KEEPALIVE_INTERVAL,
|
||||||
|
SSE_QUEUE_TIMEOUT,
|
||||||
|
)
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
from utils.logging import sensor_logger as logger
|
from utils.logging import sensor_logger as logger
|
||||||
|
from utils.responses import api_error, api_success
|
||||||
|
from utils.sdr import SDRFactory, SDRType
|
||||||
|
from utils.sse import sse_stream_fanout
|
||||||
from utils.validation import (
|
from utils.validation import (
|
||||||
validate_device_index,
|
validate_device_index,
|
||||||
validate_gain,
|
validate_gain,
|
||||||
@@ -29,15 +40,6 @@ from utils.validation import (
|
|||||||
validate_rtl_tcp_host,
|
validate_rtl_tcp_host,
|
||||||
validate_rtl_tcp_port,
|
validate_rtl_tcp_port,
|
||||||
)
|
)
|
||||||
from utils.sse import sse_stream_fanout
|
|
||||||
from utils.event_pipeline import process_event
|
|
||||||
from utils.sdr import SDRFactory, SDRType
|
|
||||||
from utils.constants import (
|
|
||||||
PROCESS_TERMINATE_TIMEOUT,
|
|
||||||
SSE_KEEPALIVE_INTERVAL,
|
|
||||||
SSE_QUEUE_TIMEOUT,
|
|
||||||
PROCESS_START_WAIT,
|
|
||||||
)
|
|
||||||
|
|
||||||
aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs')
|
aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs')
|
||||||
|
|
||||||
@@ -74,27 +76,27 @@ METER_MIN_INTERVAL = 0.1 # Max 10 updates/sec
|
|||||||
METER_MIN_CHANGE = 2 # Only send if level changes by at least this much
|
METER_MIN_CHANGE = 2 # Only send if level changes by at least this much
|
||||||
|
|
||||||
|
|
||||||
def find_direwolf() -> Optional[str]:
|
def find_direwolf() -> str | None:
|
||||||
"""Find direwolf binary."""
|
"""Find direwolf binary."""
|
||||||
return shutil.which('direwolf')
|
return shutil.which('direwolf')
|
||||||
|
|
||||||
|
|
||||||
def find_multimon_ng() -> Optional[str]:
|
def find_multimon_ng() -> str | None:
|
||||||
"""Find multimon-ng binary."""
|
"""Find multimon-ng binary."""
|
||||||
return shutil.which('multimon-ng')
|
return shutil.which('multimon-ng')
|
||||||
|
|
||||||
|
|
||||||
def find_rtl_fm() -> Optional[str]:
|
def find_rtl_fm() -> str | None:
|
||||||
"""Find rtl_fm binary."""
|
"""Find rtl_fm binary."""
|
||||||
return shutil.which('rtl_fm')
|
return shutil.which('rtl_fm')
|
||||||
|
|
||||||
|
|
||||||
def find_rx_fm() -> Optional[str]:
|
def find_rx_fm() -> str | None:
|
||||||
"""Find SoapySDR rx_fm binary."""
|
"""Find SoapySDR rx_fm binary."""
|
||||||
return shutil.which('rx_fm')
|
return shutil.which('rx_fm')
|
||||||
|
|
||||||
|
|
||||||
def find_rtl_power() -> Optional[str]:
|
def find_rtl_power() -> str | None:
|
||||||
"""Find rtl_power binary for spectrum scanning."""
|
"""Find rtl_power binary for spectrum scanning."""
|
||||||
return shutil.which('rtl_power')
|
return shutil.which('rtl_power')
|
||||||
|
|
||||||
@@ -141,7 +143,7 @@ def normalize_aprs_output_line(line: str) -> str:
|
|||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
def parse_aprs_packet(raw_packet: str) -> Optional[dict]:
|
def parse_aprs_packet(raw_packet: str) -> dict | None:
|
||||||
"""Parse APRS packet into structured data.
|
"""Parse APRS packet into structured data.
|
||||||
|
|
||||||
Supports all major APRS packet types:
|
Supports all major APRS packet types:
|
||||||
@@ -430,7 +432,7 @@ def parse_aprs_packet(raw_packet: str) -> Optional[dict]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def parse_position(data: str) -> Optional[dict]:
|
def parse_position(data: str) -> dict | None:
|
||||||
"""Parse APRS position data."""
|
"""Parse APRS position data."""
|
||||||
try:
|
try:
|
||||||
# Format: DDMM.mmN/DDDMM.mmW (or similar with symbols)
|
# Format: DDMM.mmN/DDDMM.mmW (or similar with symbols)
|
||||||
@@ -590,7 +592,7 @@ def parse_position(data: str) -> Optional[dict]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def parse_object(data: str) -> Optional[dict]:
|
def parse_object(data: str) -> dict | None:
|
||||||
"""Parse APRS object data.
|
"""Parse APRS object data.
|
||||||
|
|
||||||
Object format: ;OBJECTNAME*DDHHMMzPOSITION or ;OBJECTNAME_DDHHMMzPOSITION
|
Object format: ;OBJECTNAME*DDHHMMzPOSITION or ;OBJECTNAME_DDHHMMzPOSITION
|
||||||
@@ -648,7 +650,7 @@ def parse_object(data: str) -> Optional[dict]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def parse_item(data: str) -> Optional[dict]:
|
def parse_item(data: str) -> dict | None:
|
||||||
"""Parse APRS item data.
|
"""Parse APRS item data.
|
||||||
|
|
||||||
Item format: )ITEMNAME!POSITION or )ITEMNAME_POSITION
|
Item format: )ITEMNAME!POSITION or )ITEMNAME_POSITION
|
||||||
@@ -829,7 +831,7 @@ MIC_E_MESSAGE_TYPES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def parse_mic_e(dest: str, data: str) -> Optional[dict]:
|
def parse_mic_e(dest: str, data: str) -> dict | None:
|
||||||
"""Parse Mic-E encoded position from destination and data fields.
|
"""Parse Mic-E encoded position from destination and data fields.
|
||||||
|
|
||||||
Mic-E is a highly compressed format that encodes:
|
Mic-E is a highly compressed format that encodes:
|
||||||
@@ -972,7 +974,7 @@ def parse_mic_e(dest: str, data: str) -> Optional[dict]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def parse_compressed_position(data: str) -> Optional[dict]:
|
def parse_compressed_position(data: str) -> dict | None:
|
||||||
r"""Parse compressed position format (Base-91 encoding).
|
r"""Parse compressed position format (Base-91 encoding).
|
||||||
|
|
||||||
Compressed format: /YYYYXXXX$csT
|
Compressed format: /YYYYXXXX$csT
|
||||||
@@ -1056,7 +1058,7 @@ def parse_compressed_position(data: str) -> Optional[dict]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def parse_telemetry(data: str) -> Optional[dict]:
|
def parse_telemetry(data: str) -> dict | None:
|
||||||
"""Parse APRS telemetry data.
|
"""Parse APRS telemetry data.
|
||||||
|
|
||||||
Format: T#sss,aaa,aaa,aaa,aaa,aaa,bbbbbbbb
|
Format: T#sss,aaa,aaa,aaa,aaa,aaa,bbbbbbbb
|
||||||
@@ -1121,7 +1123,7 @@ def parse_telemetry(data: str) -> Optional[dict]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def parse_telemetry_definition(callsign: str, msg_type: str, content: str) -> Optional[dict]:
|
def parse_telemetry_definition(callsign: str, msg_type: str, content: str) -> dict | None:
|
||||||
"""Parse telemetry definition messages (PARM, UNIT, EQNS, BITS).
|
"""Parse telemetry definition messages (PARM, UNIT, EQNS, BITS).
|
||||||
|
|
||||||
These messages define the meaning of telemetry values for a station.
|
These messages define the meaning of telemetry values for a station.
|
||||||
@@ -1173,7 +1175,7 @@ def parse_telemetry_definition(callsign: str, msg_type: str, content: str) -> Op
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def parse_phg(data: str) -> Optional[dict]:
|
def parse_phg(data: str) -> dict | None:
|
||||||
"""Parse PHG (Power/Height/Gain/Directivity) data.
|
"""Parse PHG (Power/Height/Gain/Directivity) data.
|
||||||
|
|
||||||
Format: PHGphgd
|
Format: PHGphgd
|
||||||
@@ -1216,7 +1218,7 @@ def parse_phg(data: str) -> Optional[dict]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def parse_rng(data: str) -> Optional[dict]:
|
def parse_rng(data: str) -> dict | None:
|
||||||
"""Parse RNG (radio range) data.
|
"""Parse RNG (radio range) data.
|
||||||
|
|
||||||
Format: RNGrrrr where rrrr is range in miles.
|
Format: RNGrrrr where rrrr is range in miles.
|
||||||
@@ -1230,7 +1232,7 @@ def parse_rng(data: str) -> Optional[dict]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def parse_df_report(data: str) -> Optional[dict]:
|
def parse_df_report(data: str) -> dict | None:
|
||||||
"""Parse Direction Finding (DF) report.
|
"""Parse Direction Finding (DF) report.
|
||||||
|
|
||||||
Format: CSE/SPD/BRG/NRQ or similar patterns.
|
Format: CSE/SPD/BRG/NRQ or similar patterns.
|
||||||
@@ -1259,7 +1261,7 @@ def parse_df_report(data: str) -> Optional[dict]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def parse_timestamp(data: str) -> Optional[dict]:
|
def parse_timestamp(data: str) -> dict | None:
|
||||||
"""Parse APRS timestamp from position data.
|
"""Parse APRS timestamp from position data.
|
||||||
|
|
||||||
Formats:
|
Formats:
|
||||||
@@ -1303,7 +1305,7 @@ def parse_timestamp(data: str) -> Optional[dict]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def parse_third_party(data: str) -> Optional[dict]:
|
def parse_third_party(data: str) -> dict | None:
|
||||||
"""Parse third-party traffic (packets relayed from another network).
|
"""Parse third-party traffic (packets relayed from another network).
|
||||||
|
|
||||||
Format: }CALL>PATH:DATA (the } indicates third-party)
|
Format: }CALL>PATH:DATA (the } indicates third-party)
|
||||||
@@ -1329,7 +1331,7 @@ def parse_third_party(data: str) -> Optional[dict]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def parse_user_defined(data: str) -> Optional[dict]:
|
def parse_user_defined(data: str) -> dict | None:
|
||||||
"""Parse user-defined data format.
|
"""Parse user-defined data format.
|
||||||
|
|
||||||
Format: {UUXXXX...
|
Format: {UUXXXX...
|
||||||
@@ -1351,7 +1353,7 @@ def parse_user_defined(data: str) -> Optional[dict]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def parse_capabilities(data: str) -> Optional[dict]:
|
def parse_capabilities(data: str) -> dict | None:
|
||||||
"""Parse station capabilities response.
|
"""Parse station capabilities response.
|
||||||
|
|
||||||
Format: <capability1,capability2,...
|
Format: <capability1,capability2,...
|
||||||
@@ -1380,7 +1382,7 @@ def parse_capabilities(data: str) -> Optional[dict]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def parse_nmea(data: str) -> Optional[dict]:
|
def parse_nmea(data: str) -> dict | None:
|
||||||
"""Parse raw GPS NMEA sentences.
|
"""Parse raw GPS NMEA sentences.
|
||||||
|
|
||||||
APRS can include raw NMEA data starting with $.
|
APRS can include raw NMEA data starting with $.
|
||||||
@@ -1408,7 +1410,7 @@ def parse_nmea(data: str) -> Optional[dict]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def parse_audio_level(line: str) -> Optional[int]:
|
def parse_audio_level(line: str) -> int | None:
|
||||||
"""Parse direwolf audio level line and return normalized level (0-100).
|
"""Parse direwolf audio level line and return normalized level (0-100).
|
||||||
|
|
||||||
Direwolf outputs lines like:
|
Direwolf outputs lines like:
|
||||||
@@ -1578,10 +1580,8 @@ def stream_aprs_output(master_fd: int, rtl_process: subprocess.Popen, decoder_pr
|
|||||||
logger.error(f"APRS stream error: {e}")
|
logger.error(f"APRS stream error: {e}")
|
||||||
app_module.aprs_queue.put({'type': 'error', 'message': str(e)})
|
app_module.aprs_queue.put({'type': 'error', 'message': str(e)})
|
||||||
finally:
|
finally:
|
||||||
try:
|
with contextlib.suppress(OSError):
|
||||||
os.close(master_fd)
|
os.close(master_fd)
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
app_module.aprs_queue.put({'type': 'status', 'status': 'stopped'})
|
app_module.aprs_queue.put({'type': 'status', 'status': 'stopped'})
|
||||||
# Cleanup processes
|
# Cleanup processes
|
||||||
for proc in [rtl_process, decoder_process]:
|
for proc in [rtl_process, decoder_process]:
|
||||||
@@ -1589,10 +1589,8 @@ def stream_aprs_output(master_fd: int, rtl_process: subprocess.Popen, decoder_pr
|
|||||||
proc.terminate()
|
proc.terminate()
|
||||||
proc.wait(timeout=2)
|
proc.wait(timeout=2)
|
||||||
except Exception:
|
except Exception:
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
proc.kill()
|
proc.kill()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
# Release SDR device — only if it's still ours (not reclaimed by a new start)
|
# Release SDR device — only if it's still ours (not reclaimed by a new start)
|
||||||
if my_device is not None and aprs_active_device == my_device:
|
if my_device is not None and aprs_active_device == my_device:
|
||||||
app_module.release_sdr_device(my_device, aprs_active_sdr_type or 'rtlsdr')
|
app_module.release_sdr_device(my_device, aprs_active_sdr_type or 'rtlsdr')
|
||||||
@@ -1651,8 +1649,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 +1667,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 +1684,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 +1698,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 +1738,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 +1763,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:
|
||||||
@@ -1876,19 +1857,15 @@ def start_aprs() -> Response:
|
|||||||
if stderr_output:
|
if stderr_output:
|
||||||
error_msg += f': {stderr_output[:500]}'
|
error_msg += f': {stderr_output[:500]}'
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
try:
|
with contextlib.suppress(OSError):
|
||||||
os.close(master_fd)
|
os.close(master_fd)
|
||||||
except OSError:
|
with contextlib.suppress(Exception):
|
||||||
pass
|
|
||||||
try:
|
|
||||||
decoder_process.kill()
|
decoder_process.kill()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if aprs_active_device is not None:
|
if aprs_active_device is not None:
|
||||||
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
|
||||||
@@ -1904,19 +1881,15 @@ def start_aprs() -> Response:
|
|||||||
if error_output:
|
if error_output:
|
||||||
error_msg += f': {error_output}'
|
error_msg += f': {error_output}'
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
try:
|
with contextlib.suppress(OSError):
|
||||||
os.close(master_fd)
|
os.close(master_fd)
|
||||||
except OSError:
|
with contextlib.suppress(Exception):
|
||||||
pass
|
|
||||||
try:
|
|
||||||
rtl_process.kill()
|
rtl_process.kill()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if aprs_active_device is not None:
|
if aprs_active_device is not None:
|
||||||
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,12 +1919,18 @@ 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'])
|
||||||
def stop_aprs() -> Response:
|
def stop_aprs() -> Response:
|
||||||
"""Stop APRS decoder."""
|
"""Stop APRS decoder.
|
||||||
|
|
||||||
|
Releases the SDR device immediately so the status panel updates
|
||||||
|
without waiting for process termination. Process cleanup runs in a
|
||||||
|
background thread to avoid blocking the HTTP response (which caused
|
||||||
|
frontend timeout errors when two processes each took up to 2s to die).
|
||||||
|
"""
|
||||||
global aprs_active_device, aprs_active_sdr_type
|
global aprs_active_device, aprs_active_sdr_type
|
||||||
|
|
||||||
with app_module.aprs_lock:
|
with app_module.aprs_lock:
|
||||||
@@ -1964,11 +1943,30 @@ 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
|
|
||||||
|
|
||||||
|
# Release SDR device immediately so status panel reflects the
|
||||||
|
# change without waiting for process termination.
|
||||||
|
if aprs_active_device is not None:
|
||||||
|
app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
|
||||||
|
aprs_active_device = None
|
||||||
|
aprs_active_sdr_type = None
|
||||||
|
|
||||||
|
# Capture refs to clear before releasing the lock
|
||||||
|
master_fd = getattr(app_module, 'aprs_master_fd', None)
|
||||||
|
app_module.aprs_process = None
|
||||||
|
if hasattr(app_module, 'aprs_rtl_process'):
|
||||||
|
app_module.aprs_rtl_process = None
|
||||||
|
app_module.aprs_master_fd = None
|
||||||
|
|
||||||
|
# Terminate processes in background so the response returns fast.
|
||||||
|
# Each proc.wait() can block up to PROCESS_TERMINATE_TIMEOUT (2s),
|
||||||
|
# which previously caused the frontend 2200ms fetch to abort.
|
||||||
|
def _cleanup():
|
||||||
|
# Close PTY master fd first — this unblocks the stream thread
|
||||||
|
if master_fd is not None:
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
os.close(master_fd)
|
||||||
for proc in processes_to_stop:
|
for proc in processes_to_stop:
|
||||||
try:
|
try:
|
||||||
proc.terminate()
|
proc.terminate()
|
||||||
@@ -1978,23 +1976,7 @@ def stop_aprs() -> Response:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error stopping APRS process: {e}")
|
logger.error(f"Error stopping APRS process: {e}")
|
||||||
|
|
||||||
# Close PTY master fd
|
threading.Thread(target=_cleanup, daemon=True).start()
|
||||||
if hasattr(app_module, 'aprs_master_fd') and app_module.aprs_master_fd is not None:
|
|
||||||
try:
|
|
||||||
os.close(app_module.aprs_master_fd)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
app_module.aprs_master_fd = None
|
|
||||||
|
|
||||||
app_module.aprs_process = None
|
|
||||||
if hasattr(app_module, 'aprs_rtl_process'):
|
|
||||||
app_module.aprs_rtl_process = None
|
|
||||||
|
|
||||||
# Release SDR device
|
|
||||||
if aprs_active_device is not None:
|
|
||||||
app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
|
|
||||||
aprs_active_device = None
|
|
||||||
aprs_active_sdr_type = None
|
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
@@ -2045,10 +2027,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 +2047,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,21 +2092,15 @@ 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) as f:
|
||||||
reader = csv.reader(f)
|
reader = csv.reader(f)
|
||||||
for row in reader:
|
for row in reader:
|
||||||
if len(row) < 7:
|
if len(row) < 7:
|
||||||
@@ -2144,10 +2117,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 +2147,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 +2173,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:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import socket
|
|||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
|
|
||||||
# Try to import flask-sock
|
# Try to import flask-sock
|
||||||
@@ -16,6 +17,8 @@ except ImportError:
|
|||||||
WEBSOCKET_AVAILABLE = False
|
WEBSOCKET_AVAILABLE = False
|
||||||
Sock = None
|
Sock = None
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
|
|
||||||
logger = get_logger('intercept.audio_ws')
|
logger = get_logger('intercept.audio_ws')
|
||||||
@@ -56,10 +59,8 @@ def kill_audio_processes():
|
|||||||
audio_process.terminate()
|
audio_process.terminate()
|
||||||
audio_process.wait(timeout=0.5)
|
audio_process.wait(timeout=0.5)
|
||||||
except:
|
except:
|
||||||
try:
|
with contextlib.suppress(BaseException):
|
||||||
audio_process.kill()
|
audio_process.kill()
|
||||||
except:
|
|
||||||
pass
|
|
||||||
audio_process = None
|
audio_process = None
|
||||||
|
|
||||||
if rtl_process:
|
if rtl_process:
|
||||||
@@ -67,10 +68,8 @@ def kill_audio_processes():
|
|||||||
rtl_process.terminate()
|
rtl_process.terminate()
|
||||||
rtl_process.wait(timeout=0.5)
|
rtl_process.wait(timeout=0.5)
|
||||||
except:
|
except:
|
||||||
try:
|
with contextlib.suppress(BaseException):
|
||||||
rtl_process.kill()
|
rtl_process.kill()
|
||||||
except:
|
|
||||||
pass
|
|
||||||
rtl_process = None
|
rtl_process = None
|
||||||
|
|
||||||
time.sleep(0.3)
|
time.sleep(0.3)
|
||||||
@@ -261,16 +260,10 @@ def init_audio_websocket(app: Flask):
|
|||||||
# Complete WebSocket close handshake, then shut down the
|
# Complete WebSocket close handshake, then shut down the
|
||||||
# raw socket so Werkzeug cannot write its HTTP 200 response
|
# raw socket so Werkzeug cannot write its HTTP 200 response
|
||||||
# on top of the WebSocket stream.
|
# on top of the WebSocket stream.
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
ws.close()
|
ws.close()
|
||||||
except Exception:
|
with contextlib.suppress(Exception):
|
||||||
pass
|
|
||||||
try:
|
|
||||||
ws.sock.shutdown(socket.SHUT_RDWR)
|
ws.sock.shutdown(socket.SHUT_RDWR)
|
||||||
except Exception:
|
with contextlib.suppress(Exception):
|
||||||
pass
|
|
||||||
try:
|
|
||||||
ws.sock.close()
|
ws.sock.close()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
logger.info("WebSocket audio client disconnected")
|
logger.info("WebSocket audio client disconnected")
|
||||||
|
|||||||
+65
-61
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import fcntl
|
import contextlib
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
import pty
|
import pty
|
||||||
@@ -13,32 +12,42 @@ import select
|
|||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from typing import Any, Generator
|
from typing import Any
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response
|
from flask import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.dependencies import check_tool
|
from data.oui import OUI_DATABASE, get_manufacturer, load_oui_database
|
||||||
from utils.logging import bluetooth_logger as logger
|
from data.patterns import AIRTAG_PREFIXES, SAMSUNG_TRACKER, TILE_PREFIXES
|
||||||
from utils.sse import sse_stream_fanout
|
|
||||||
from utils.event_pipeline import process_event
|
|
||||||
from utils.validation import validate_bluetooth_interface
|
|
||||||
from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer
|
|
||||||
from data.patterns import AIRTAG_PREFIXES, TILE_PREFIXES, SAMSUNG_TRACKER
|
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
BT_TERMINATE_TIMEOUT,
|
|
||||||
SSE_KEEPALIVE_INTERVAL,
|
|
||||||
SSE_QUEUE_TIMEOUT,
|
|
||||||
SUBPROCESS_TIMEOUT_SHORT,
|
SUBPROCESS_TIMEOUT_SHORT,
|
||||||
SERVICE_ENUM_TIMEOUT,
|
|
||||||
PROCESS_START_WAIT,
|
|
||||||
BT_RESET_DELAY,
|
|
||||||
BT_ADAPTER_DOWN_WAIT,
|
|
||||||
PROCESS_TERMINATE_TIMEOUT,
|
|
||||||
)
|
)
|
||||||
|
from utils.dependencies import check_tool
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
|
from utils.logging import bluetooth_logger as logger
|
||||||
|
from utils.responses import api_error, api_success
|
||||||
|
from utils.sse import sse_stream_fanout
|
||||||
|
from utils.validation import validate_bluetooth_interface
|
||||||
|
|
||||||
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."""
|
||||||
@@ -310,10 +319,8 @@ def stream_bt_scan(process, scan_mode):
|
|||||||
except OSError:
|
except OSError:
|
||||||
break
|
break
|
||||||
|
|
||||||
try:
|
with contextlib.suppress(OSError):
|
||||||
os.close(master_fd)
|
os.close(master_fd)
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
app_module.bt_queue.put({'type': 'error', 'text': str(e)})
|
app_module.bt_queue.put({'type': 'error', 'text': str(e)})
|
||||||
@@ -331,8 +338,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 +366,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 +378,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 +420,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 +437,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 +466,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:
|
||||||
@@ -467,10 +474,8 @@ def reset_bt_adapter():
|
|||||||
app_module.bt_process.terminate()
|
app_module.bt_process.terminate()
|
||||||
app_module.bt_process.wait(timeout=2)
|
app_module.bt_process.wait(timeout=2)
|
||||||
except (subprocess.TimeoutExpired, OSError):
|
except (subprocess.TimeoutExpired, OSError):
|
||||||
try:
|
with contextlib.suppress(OSError):
|
||||||
app_module.bt_process.kill()
|
app_module.bt_process.kill()
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
app_module.bt_process = None
|
app_module.bt_process = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -489,12 +494,12 @@ def reset_bt_adapter():
|
|||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'success' if is_up else 'warning',
|
'status': 'success' if is_up else 'warning',
|
||||||
'message': f'Adapter {interface} reset' if is_up else f'Reset attempted but adapter may still be down',
|
'message': f'Adapter {interface} reset' if is_up else 'Reset attempted but adapter may still be down',
|
||||||
'is_up': is_up
|
'is_up': is_up
|
||||||
})
|
})
|
||||||
|
|
||||||
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 +509,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 +534,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 +557,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
|
||||||
|
|||||||
+10
-16
@@ -7,30 +7,27 @@ aggregation, and heuristics.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from collections.abc import Generator
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Generator
|
|
||||||
|
|
||||||
from flask import Blueprint, Response, jsonify, request, session
|
from flask import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
from utils.bluetooth import (
|
from utils.bluetooth import (
|
||||||
BluetoothScanner,
|
|
||||||
BTDeviceAggregate,
|
BTDeviceAggregate,
|
||||||
get_bluetooth_scanner,
|
|
||||||
check_capabilities,
|
check_capabilities,
|
||||||
RANGE_UNKNOWN,
|
get_bluetooth_scanner,
|
||||||
TrackerType,
|
|
||||||
TrackerConfidence,
|
|
||||||
get_tracker_engine,
|
|
||||||
)
|
)
|
||||||
from utils.database import get_db
|
from utils.database import get_db
|
||||||
from utils.sse import format_sse
|
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
|
from utils.responses import api_error
|
||||||
|
from utils.sse import format_sse
|
||||||
|
|
||||||
logger = logging.getLogger('intercept.bluetooth_v2')
|
logger = logging.getLogger('intercept.bluetooth_v2')
|
||||||
|
|
||||||
@@ -231,7 +228,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 +386,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 +526,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)
|
||||||
@@ -900,10 +897,8 @@ def stream_events():
|
|||||||
"""Generate SSE events from scanner."""
|
"""Generate SSE events from scanner."""
|
||||||
for event in scanner.stream_events(timeout=1.0):
|
for event in scanner.stream_events(timeout=1.0):
|
||||||
event_name, event_data = map_event_type(event)
|
event_name, event_data = map_event_type(event)
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
process_event('bluetooth', event_data, event_name)
|
process_event('bluetooth', event_data, event_name)
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
yield format_sse(event_data, event=event_name)
|
yield format_sse(event_data, event=event_name)
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
@@ -971,7 +966,6 @@ def get_tscm_bluetooth_snapshot(duration: int = 8) -> list[dict]:
|
|||||||
Returns:
|
Returns:
|
||||||
List of device dictionaries in TSCM format.
|
List of device dictionaries in TSCM format.
|
||||||
"""
|
"""
|
||||||
import time
|
|
||||||
import logging
|
import logging
|
||||||
logger = logging.getLogger('intercept.bluetooth_v2')
|
logger = logging.getLogger('intercept.bluetooth_v2')
|
||||||
|
|
||||||
|
|||||||
+72
-78
@@ -21,6 +21,7 @@ from utils.bt_locate import (
|
|||||||
start_locate_session,
|
start_locate_session,
|
||||||
stop_locate_session,
|
stop_locate_session,
|
||||||
)
|
)
|
||||||
|
from utils.responses import api_error
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
|
|
||||||
logger = logging.getLogger('intercept.bt_locate')
|
logger = logging.getLogger('intercept.bt_locate')
|
||||||
@@ -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({
|
||||||
|
|||||||
+171
-207
@@ -10,55 +10,60 @@ This blueprint provides:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import logging
|
||||||
import logging
|
import queue
|
||||||
import queue
|
import threading
|
||||||
import threading
|
import time
|
||||||
import time
|
from collections.abc import Generator
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Generator
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response
|
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from flask import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
|
from utils.agent_client import AgentClient, AgentConnectionError, AgentHTTPError, create_client_from_agent
|
||||||
from utils.database import (
|
from utils.database import (
|
||||||
create_agent, get_agent, get_agent_by_name, list_agents,
|
create_agent,
|
||||||
update_agent, delete_agent, store_push_payload, get_recent_payloads
|
delete_agent,
|
||||||
)
|
get_agent,
|
||||||
from utils.agent_client import (
|
get_agent_by_name,
|
||||||
AgentClient, AgentHTTPError, AgentConnectionError, create_client_from_agent
|
get_recent_payloads,
|
||||||
|
list_agents,
|
||||||
|
store_push_payload,
|
||||||
|
update_agent,
|
||||||
)
|
)
|
||||||
|
from utils.responses import api_error
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
from utils.trilateration import (
|
from utils.trilateration import (
|
||||||
DeviceLocationTracker, PathLossModel, Trilateration,
|
DeviceLocationTracker,
|
||||||
AgentObservation, estimate_location_from_observations
|
PathLossModel,
|
||||||
|
Trilateration,
|
||||||
|
estimate_location_from_observations,
|
||||||
)
|
)
|
||||||
|
|
||||||
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 +113,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 +173,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 +181,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 +217,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 +239,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 +250,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 +276,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 +291,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 +303,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 +377,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 +396,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 +406,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 +422,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 +432,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 +446,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 +472,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 +557,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 +585,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 +613,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 +631,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 +659,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'
|
||||||
@@ -735,6 +704,7 @@ def stream_all_agents():
|
|||||||
def agent_management_page():
|
def agent_management_page():
|
||||||
"""Render the agent management page."""
|
"""Render the agent management page."""
|
||||||
from flask import render_template
|
from flask import render_template
|
||||||
|
|
||||||
from config import VERSION
|
from config import VERSION
|
||||||
return render_template('agents.html', version=VERSION)
|
return render_template('agents.html', version=VERSION)
|
||||||
|
|
||||||
@@ -783,7 +753,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 +767,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 +804,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 +816,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 +868,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)
|
||||||
|
|
||||||
|
|||||||
+10
-32
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response
|
from flask import Blueprint, Response, request
|
||||||
|
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.correlation import get_correlations
|
from utils.correlation import 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.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
|
|
||||||
|
|||||||
+20
-32
@@ -6,7 +6,7 @@ distress and safety communications per ITU-R M.493.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import pty
|
import pty
|
||||||
@@ -16,36 +16,36 @@ import shutil
|
|||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from typing import Any
|
||||||
from typing import Any, Generator
|
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response
|
from flask import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
DSC_VHF_FREQUENCY_MHZ,
|
|
||||||
DSC_SAMPLE_RATE,
|
DSC_SAMPLE_RATE,
|
||||||
DSC_TERMINATE_TIMEOUT,
|
DSC_TERMINATE_TIMEOUT,
|
||||||
|
DSC_VHF_FREQUENCY_MHZ,
|
||||||
)
|
)
|
||||||
from utils.database import (
|
from utils.database import (
|
||||||
store_dsc_alert,
|
|
||||||
get_dsc_alerts,
|
|
||||||
get_dsc_alert,
|
|
||||||
acknowledge_dsc_alert,
|
acknowledge_dsc_alert,
|
||||||
|
get_dsc_alert,
|
||||||
get_dsc_alert_summary,
|
get_dsc_alert_summary,
|
||||||
|
get_dsc_alerts,
|
||||||
|
store_dsc_alert,
|
||||||
)
|
)
|
||||||
|
from utils.dependencies import get_tool_path
|
||||||
from utils.dsc.parser import parse_dsc_message
|
from utils.dsc.parser import parse_dsc_message
|
||||||
from utils.sse import sse_stream_fanout
|
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
|
from utils.process import register_process, unregister_process
|
||||||
|
from utils.responses import api_error
|
||||||
|
from utils.sdr import SDRFactory, SDRType
|
||||||
|
from utils.sse import sse_stream_fanout
|
||||||
from utils.validation import (
|
from utils.validation import (
|
||||||
validate_device_index,
|
validate_device_index,
|
||||||
validate_gain,
|
validate_gain,
|
||||||
validate_rtl_tcp_host,
|
validate_rtl_tcp_host,
|
||||||
validate_rtl_tcp_port,
|
validate_rtl_tcp_port,
|
||||||
)
|
)
|
||||||
from utils.sdr import SDRFactory, SDRType
|
|
||||||
from utils.dependencies import get_tool_path
|
|
||||||
from utils.process import register_process, unregister_process
|
|
||||||
|
|
||||||
logger = logging.getLogger('intercept.dsc')
|
logger = logging.getLogger('intercept.dsc')
|
||||||
|
|
||||||
@@ -82,8 +82,8 @@ def _check_dsc_tools() -> dict:
|
|||||||
# Check for scipy/numpy (needed for decoder)
|
# Check for scipy/numpy (needed for decoder)
|
||||||
scipy_available = False
|
scipy_available = False
|
||||||
try:
|
try:
|
||||||
import scipy
|
|
||||||
import numpy
|
import numpy
|
||||||
|
import scipy
|
||||||
scipy_available = True
|
scipy_available = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
@@ -178,10 +178,8 @@ def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> Non
|
|||||||
})
|
})
|
||||||
finally:
|
finally:
|
||||||
global dsc_active_device, dsc_active_sdr_type
|
global dsc_active_device, dsc_active_sdr_type
|
||||||
try:
|
with contextlib.suppress(OSError):
|
||||||
os.close(master_fd)
|
os.close(master_fd)
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
dsc_running = False
|
dsc_running = False
|
||||||
# Cleanup both processes
|
# Cleanup both processes
|
||||||
with app_module.dsc_lock:
|
with app_module.dsc_lock:
|
||||||
@@ -192,10 +190,8 @@ def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> Non
|
|||||||
proc.terminate()
|
proc.terminate()
|
||||||
proc.wait(timeout=2)
|
proc.wait(timeout=2)
|
||||||
except Exception:
|
except Exception:
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
proc.kill()
|
proc.kill()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
unregister_process(proc)
|
unregister_process(proc)
|
||||||
app_module.dsc_queue.put({'type': 'status', 'status': 'stopped'})
|
app_module.dsc_queue.put({'type': 'status', 'status': 'stopped'})
|
||||||
with app_module.dsc_lock:
|
with app_module.dsc_lock:
|
||||||
@@ -380,7 +376,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:
|
||||||
@@ -465,10 +461,8 @@ def start_decoding() -> Response:
|
|||||||
rtl_process.terminate()
|
rtl_process.terminate()
|
||||||
rtl_process.wait(timeout=2)
|
rtl_process.wait(timeout=2)
|
||||||
except Exception:
|
except Exception:
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
rtl_process.kill()
|
rtl_process.kill()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
# Release device on failure
|
# Release device on failure
|
||||||
if dsc_active_device is not None:
|
if dsc_active_device is not None:
|
||||||
app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
|
app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
|
||||||
@@ -484,10 +478,8 @@ def start_decoding() -> Response:
|
|||||||
rtl_process.terminate()
|
rtl_process.terminate()
|
||||||
rtl_process.wait(timeout=2)
|
rtl_process.wait(timeout=2)
|
||||||
except Exception:
|
except Exception:
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
rtl_process.kill()
|
rtl_process.kill()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
# Release device on failure
|
# Release device on failure
|
||||||
if dsc_active_device is not None:
|
if dsc_active_device is not None:
|
||||||
app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
|
app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
|
||||||
@@ -517,10 +509,8 @@ def stop_decoding() -> Response:
|
|||||||
app_module.dsc_rtl_process.terminate()
|
app_module.dsc_rtl_process.terminate()
|
||||||
app_module.dsc_rtl_process.wait(timeout=DSC_TERMINATE_TIMEOUT)
|
app_module.dsc_rtl_process.wait(timeout=DSC_TERMINATE_TIMEOUT)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
try:
|
with contextlib.suppress(OSError):
|
||||||
app_module.dsc_rtl_process.kill()
|
app_module.dsc_rtl_process.kill()
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -530,10 +520,8 @@ def stop_decoding() -> Response:
|
|||||||
app_module.dsc_process.terminate()
|
app_module.dsc_process.terminate()
|
||||||
app_module.dsc_process.wait(timeout=DSC_TERMINATE_TIMEOUT)
|
app_module.dsc_process.wait(timeout=DSC_TERMINATE_TIMEOUT)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
try:
|
with contextlib.suppress(OSError):
|
||||||
app_module.dsc_process.kill()
|
app_module.dsc_process.kill()
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
+43
-45
@@ -3,8 +3,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import queue
|
import queue
|
||||||
import time
|
|
||||||
from collections.abc import Generator
|
|
||||||
|
|
||||||
from flask import Blueprint, Response, jsonify
|
from flask import Blueprint, Response, jsonify
|
||||||
|
|
||||||
@@ -21,7 +19,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 +63,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 +205,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 +230,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 shutil
|
||||||
|
import signal
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
from utils.constants import (
|
||||||
|
PROCESS_TERMINATE_TIMEOUT,
|
||||||
|
SSE_KEEPALIVE_INTERVAL,
|
||||||
|
SSE_QUEUE_TIMEOUT,
|
||||||
|
)
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
|
from utils.logging import get_logger
|
||||||
|
from utils.sdr import SDRFactory, SDRType
|
||||||
|
from utils.sse import sse_stream_fanout
|
||||||
|
|
||||||
|
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 contextlib
|
||||||
|
|
||||||
|
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: threading.Thread | None = None
|
||||||
|
scanner_running = False
|
||||||
|
scanner_lock = threading.Lock()
|
||||||
|
scanner_paused = False
|
||||||
|
scanner_current_freq = 0.0
|
||||||
|
scanner_active_device: int | None = None
|
||||||
|
scanner_active_sdr_type: str = 'rtlsdr'
|
||||||
|
receiver_active_device: int | None = None
|
||||||
|
receiver_active_sdr_type: str = 'rtlsdr'
|
||||||
|
scanner_power_process: subprocess.Popen | None = 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: subprocess.Popen | None = None
|
||||||
|
waterfall_thread: threading.Thread | None = None
|
||||||
|
waterfall_running = False
|
||||||
|
waterfall_lock = threading.Lock()
|
||||||
|
waterfall_queue: queue.Queue = queue.Queue(maxsize=200)
|
||||||
|
waterfall_active_device: int | None = 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
|
||||||
|
with contextlib.suppress(queue.Full):
|
||||||
|
scanner_queue.put_nowait({
|
||||||
|
'type': 'log',
|
||||||
|
'entry': entry
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
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) as f:
|
||||||
|
rtl_stderr = f.read().strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
with open(ffmpeg_stderr_log) 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:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
waterfall_process.kill()
|
||||||
|
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 (
|
||||||
|
audio, # noqa: E402, F401
|
||||||
|
scanner, # noqa: E402, F401
|
||||||
|
tools, # noqa: E402, F401
|
||||||
|
waterfall, # noqa: E402, F401
|
||||||
|
)
|
||||||
@@ -0,0 +1,496 @@
|
|||||||
|
"""Audio routes for manual listening and audio streaming."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import os
|
||||||
|
import select
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
|
||||||
|
from flask import Response, jsonify, request
|
||||||
|
|
||||||
|
import routes.listening_post as _state
|
||||||
|
|
||||||
|
from . import (
|
||||||
|
_start_audio_stream,
|
||||||
|
_stop_audio_stream,
|
||||||
|
_stop_waterfall_internal,
|
||||||
|
_wav_header,
|
||||||
|
app_module,
|
||||||
|
logger,
|
||||||
|
normalize_modulation,
|
||||||
|
receiver_bp,
|
||||||
|
scanner_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 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():
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
scanner_thread_ref.join(timeout=2.0)
|
||||||
|
if scanner_proc_ref and scanner_proc_ref.poll() is None:
|
||||||
|
try:
|
||||||
|
scanner_proc_ref.terminate()
|
||||||
|
scanner_proc_ref.wait(timeout=1)
|
||||||
|
except Exception:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
scanner_proc_ref.kill()
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
subprocess.run(['pkill', '-9', 'rtl_power'], capture_output=True, timeout=0.5)
|
||||||
|
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) 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) 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,804 @@
|
|||||||
|
"""Scanner routes and implementation for frequency scanning."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import math
|
||||||
|
import queue
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from flask import Response, jsonify, request
|
||||||
|
|
||||||
|
import routes.listening_post as _state
|
||||||
|
|
||||||
|
from . import (
|
||||||
|
SSE_KEEPALIVE_INTERVAL,
|
||||||
|
SSE_QUEUE_TIMEOUT,
|
||||||
|
_rtl_fm_demod_mode,
|
||||||
|
_start_audio_stream,
|
||||||
|
_stop_audio_stream,
|
||||||
|
activity_log,
|
||||||
|
activity_log_lock,
|
||||||
|
add_activity_log,
|
||||||
|
app_module,
|
||||||
|
find_rtl_fm,
|
||||||
|
find_rtl_power,
|
||||||
|
find_rx_fm,
|
||||||
|
logger,
|
||||||
|
normalize_modulation,
|
||||||
|
process_event,
|
||||||
|
receiver_bp,
|
||||||
|
scanner_config,
|
||||||
|
scanner_lock,
|
||||||
|
scanner_queue,
|
||||||
|
sse_stream_fanout,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 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
|
||||||
|
with contextlib.suppress(queue.Full):
|
||||||
|
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']
|
||||||
|
})
|
||||||
|
|
||||||
|
# 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
|
||||||
|
with contextlib.suppress(queue.Full):
|
||||||
|
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']
|
||||||
|
})
|
||||||
|
|
||||||
|
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()
|
||||||
|
with contextlib.suppress(queue.Full):
|
||||||
|
scanner_queue.put_nowait({
|
||||||
|
'type': 'signal_skipped',
|
||||||
|
'frequency': current_freq
|
||||||
|
})
|
||||||
|
# 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()
|
||||||
|
with contextlib.suppress(queue.Full):
|
||||||
|
scanner_queue.put_nowait({
|
||||||
|
'type': 'signal_lost',
|
||||||
|
'frequency': current_freq,
|
||||||
|
'range_start': scanner_config['start_freq'],
|
||||||
|
'range_end': scanner_config['end_freq']
|
||||||
|
})
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
with contextlib.suppress(queue.Full):
|
||||||
|
scanner_queue.put_nowait({
|
||||||
|
'type': 'signal_lost',
|
||||||
|
'frequency': current_freq
|
||||||
|
})
|
||||||
|
|
||||||
|
# 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']
|
||||||
|
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')
|
||||||
|
with contextlib.suppress(queue.Full):
|
||||||
|
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']
|
||||||
|
})
|
||||||
|
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')
|
||||||
|
with contextlib.suppress(queue.Full):
|
||||||
|
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']
|
||||||
|
})
|
||||||
|
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))
|
||||||
|
with contextlib.suppress(queue.Full):
|
||||||
|
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']
|
||||||
|
})
|
||||||
|
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()})')
|
||||||
|
with contextlib.suppress(queue.Full):
|
||||||
|
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']
|
||||||
|
})
|
||||||
|
|
||||||
|
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' and (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:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
_state.scanner_power_process.kill()
|
||||||
|
_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,90 @@
|
|||||||
|
"""Tool check and signal identification routes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask import Response, jsonify, request
|
||||||
|
|
||||||
|
from . import (
|
||||||
|
find_ffmpeg,
|
||||||
|
find_rtl_fm,
|
||||||
|
find_rtl_power,
|
||||||
|
find_rx_fm,
|
||||||
|
logger,
|
||||||
|
receiver_bp,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 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,493 @@
|
|||||||
|
"""Waterfall / spectrogram routes and implementation."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import math
|
||||||
|
import queue
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from flask import Response, jsonify, request
|
||||||
|
|
||||||
|
import routes.listening_post as _state
|
||||||
|
|
||||||
|
from . import (
|
||||||
|
SSE_KEEPALIVE_INTERVAL,
|
||||||
|
SSE_QUEUE_TIMEOUT,
|
||||||
|
SDRFactory,
|
||||||
|
SDRType,
|
||||||
|
_stop_waterfall_internal,
|
||||||
|
app_module,
|
||||||
|
find_rtl_power,
|
||||||
|
logger,
|
||||||
|
process_event,
|
||||||
|
receiver_bp,
|
||||||
|
sse_stream_fanout,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 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."""
|
||||||
|
with contextlib.suppress(queue.Full):
|
||||||
|
_state.waterfall_queue.put_nowait({
|
||||||
|
'type': 'waterfall_error',
|
||||||
|
'message': message,
|
||||||
|
'timestamp': datetime.now().isoformat(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
with contextlib.suppress(queue.Empty):
|
||||||
|
_state.waterfall_queue.get_nowait()
|
||||||
|
with contextlib.suppress(queue.Full):
|
||||||
|
_state.waterfall_queue.put_nowait(msg)
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
_state.waterfall_process.kill()
|
||||||
|
_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:
|
||||||
|
with contextlib.suppress(queue.Empty):
|
||||||
|
_state.waterfall_queue.get_nowait()
|
||||||
|
with contextlib.suppress(queue.Full):
|
||||||
|
_state.waterfall_queue.put_nowait(msg)
|
||||||
|
|
||||||
|
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(),
|
||||||
|
}
|
||||||
|
with contextlib.suppress(queue.Full):
|
||||||
|
_state.waterfall_queue.put_nowait(msg)
|
||||||
|
|
||||||
|
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:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
_state.waterfall_process.kill()
|
||||||
|
_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 and 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
|
||||||
+21
-22
@@ -11,20 +11,19 @@ Supports multiple connection types:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import queue
|
import queue
|
||||||
import time
|
|
||||||
from typing import Generator
|
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response
|
from flask import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.sse import sse_stream_fanout
|
|
||||||
from utils.meshtastic import (
|
from utils.meshtastic import (
|
||||||
|
MeshtasticMessage,
|
||||||
get_meshtastic_client,
|
get_meshtastic_client,
|
||||||
|
is_meshtastic_available,
|
||||||
start_meshtastic,
|
start_meshtastic,
|
||||||
stop_meshtastic,
|
stop_meshtastic,
|
||||||
is_meshtastic_available,
|
|
||||||
MeshtasticMessage,
|
|
||||||
)
|
)
|
||||||
|
from utils.responses import api_error
|
||||||
|
from utils.sse import sse_stream_fanout
|
||||||
|
|
||||||
logger = get_logger('intercept.meshtastic')
|
logger = get_logger('intercept.meshtastic')
|
||||||
|
|
||||||
@@ -453,8 +452,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 +468,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 +1049,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_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
@@ -21,6 +21,7 @@ from utils.morse import (
|
|||||||
morse_decoder_thread,
|
morse_decoder_thread,
|
||||||
)
|
)
|
||||||
from utils.process import register_process, safe_terminate, unregister_process
|
from utils.process import register_process, safe_terminate, unregister_process
|
||||||
|
from utils.responses import api_error
|
||||||
from utils.sdr import SDRFactory, SDRType
|
from utils.sdr import SDRFactory, SDRType
|
||||||
from utils.sse import sse_stream_fanout
|
from utils.sse import sse_stream_fanout
|
||||||
from utils.validation import (
|
from utils.validation import (
|
||||||
@@ -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)
|
||||||
|
|||||||
+22
-34
@@ -2,21 +2,24 @@
|
|||||||
Offline mode routes - Asset management and settings for offline operation.
|
Offline mode routes - Asset management and settings for offline operation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request
|
|
||||||
from utils.database import get_setting, set_setting
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
from flask import Blueprint, request
|
||||||
|
|
||||||
|
from utils.database import get_setting, set_setting
|
||||||
|
from utils.responses import api_error, api_success
|
||||||
|
|
||||||
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 +67,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 +75,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 +94,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 +127,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 +139,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 +152,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
@@ -22,6 +22,7 @@ from utils.event_pipeline import process_event
|
|||||||
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
|
||||||
|
from utils.responses import api_error
|
||||||
from utils.sdr import SDRFactory, SDRType
|
from utils.sdr import SDRFactory, SDRType
|
||||||
from utils.sse import sse_stream_fanout
|
from utils.sse import sse_stream_fanout
|
||||||
from utils.validation import (
|
from utils.validation import (
|
||||||
@@ -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:
|
||||||
|
|||||||
+37
-49
@@ -2,33 +2,39 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import re
|
|
||||||
import pty
|
import pty
|
||||||
import queue
|
import queue
|
||||||
|
import re
|
||||||
import select
|
import select
|
||||||
import struct
|
import struct
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Generator
|
from typing import Any
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response
|
from flask import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.logging import pager_logger as logger
|
|
||||||
from utils.validation import (
|
|
||||||
validate_frequency, validate_device_index, validate_gain, validate_ppm,
|
|
||||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
|
||||||
)
|
|
||||||
from utils.sse import sse_stream_fanout
|
|
||||||
from utils.event_pipeline import process_event
|
|
||||||
from utils.process import safe_terminate, register_process, unregister_process
|
|
||||||
from utils.sdr import SDRFactory, SDRType, SDRValidationError
|
|
||||||
from utils.dependencies import get_tool_path
|
from utils.dependencies import get_tool_path
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
|
from utils.logging import pager_logger as logger
|
||||||
|
from utils.process import register_process, unregister_process
|
||||||
|
from utils.responses import api_error
|
||||||
|
from utils.sdr import SDRFactory, SDRType
|
||||||
|
from utils.sse import sse_stream_fanout
|
||||||
|
from utils.validation import (
|
||||||
|
validate_device_index,
|
||||||
|
validate_frequency,
|
||||||
|
validate_gain,
|
||||||
|
validate_ppm,
|
||||||
|
validate_rtl_tcp_host,
|
||||||
|
validate_rtl_tcp_port,
|
||||||
|
)
|
||||||
|
|
||||||
pager_bp = Blueprint('pager', __name__)
|
pager_bp = Blueprint('pager', __name__)
|
||||||
|
|
||||||
@@ -188,10 +194,8 @@ def audio_relay_thread(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Audio relay error: {e}")
|
logger.debug(f"Audio relay error: {e}")
|
||||||
finally:
|
finally:
|
||||||
try:
|
with contextlib.suppress(OSError):
|
||||||
multimon_stdin.close()
|
multimon_stdin.close()
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
|
def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
|
||||||
@@ -236,10 +240,8 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
|
|||||||
app_module.output_queue.put({'type': 'error', 'text': str(e)})
|
app_module.output_queue.put({'type': 'error', 'text': str(e)})
|
||||||
finally:
|
finally:
|
||||||
global pager_active_device, pager_active_sdr_type
|
global pager_active_device, pager_active_sdr_type
|
||||||
try:
|
with contextlib.suppress(OSError):
|
||||||
os.close(master_fd)
|
os.close(master_fd)
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
# Signal relay thread to stop
|
# Signal relay thread to stop
|
||||||
with app_module.process_lock:
|
with app_module.process_lock:
|
||||||
stop_relay = getattr(app_module.current_process, '_stop_relay', None)
|
stop_relay = getattr(app_module.current_process, '_stop_relay', None)
|
||||||
@@ -254,10 +256,8 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
|
|||||||
proc.terminate()
|
proc.terminate()
|
||||||
proc.wait(timeout=2)
|
proc.wait(timeout=2)
|
||||||
except Exception:
|
except Exception:
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
proc.kill()
|
proc.kill()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
unregister_process(proc)
|
unregister_process(proc)
|
||||||
app_module.output_queue.put({'type': 'status', 'text': 'stopped'})
|
app_module.output_queue.put({'type': 'status', 'text': 'stopped'})
|
||||||
with app_module.process_lock:
|
with app_module.process_lock:
|
||||||
@@ -275,7 +275,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 +286,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 +294,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 +308,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 +320,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 +356,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 +381,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)
|
||||||
@@ -457,32 +453,28 @@ def start_decoding() -> Response:
|
|||||||
rtl_process.terminate()
|
rtl_process.terminate()
|
||||||
rtl_process.wait(timeout=2)
|
rtl_process.wait(timeout=2)
|
||||||
except Exception:
|
except Exception:
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
rtl_process.kill()
|
rtl_process.kill()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
# Release device on failure
|
# Release device on failure
|
||||||
if pager_active_device is not None:
|
if pager_active_device is not None:
|
||||||
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:
|
||||||
rtl_process.terminate()
|
rtl_process.terminate()
|
||||||
rtl_process.wait(timeout=2)
|
rtl_process.wait(timeout=2)
|
||||||
except Exception:
|
except Exception:
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
rtl_process.kill()
|
rtl_process.kill()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
# Release device on failure
|
# Release device on failure
|
||||||
if pager_active_device is not None:
|
if pager_active_device is not None:
|
||||||
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'])
|
||||||
@@ -501,17 +493,13 @@ def stop_decoding() -> Response:
|
|||||||
app_module.current_process._rtl_process.terminate()
|
app_module.current_process._rtl_process.terminate()
|
||||||
app_module.current_process._rtl_process.wait(timeout=2)
|
app_module.current_process._rtl_process.wait(timeout=2)
|
||||||
except (subprocess.TimeoutExpired, OSError):
|
except (subprocess.TimeoutExpired, OSError):
|
||||||
try:
|
with contextlib.suppress(OSError):
|
||||||
app_module.current_process._rtl_process.kill()
|
app_module.current_process._rtl_process.kill()
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Close PTY master fd
|
# Close PTY master fd
|
||||||
if hasattr(app_module.current_process, '_master_fd'):
|
if hasattr(app_module.current_process, '_master_fd'):
|
||||||
try:
|
with contextlib.suppress(OSError):
|
||||||
os.close(app_module.current_process._master_fd)
|
os.close(app_module.current_process._master_fd)
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Kill multimon-ng
|
# Kill multimon-ng
|
||||||
app_module.current_process.terminate()
|
app_module.current_process.terminate()
|
||||||
@@ -562,16 +550,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})
|
||||||
|
|
||||||
|
|||||||
+31
-70
@@ -7,6 +7,7 @@ telemetry (position, altitude, temperature, humidity, pressure) on the
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import queue
|
import queue
|
||||||
@@ -31,6 +32,7 @@ from utils.constants import (
|
|||||||
)
|
)
|
||||||
from utils.gps import is_gpsd_running
|
from utils.gps import is_gpsd_running
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
|
from utils.responses import api_error, api_success
|
||||||
from utils.sdr import SDRFactory, SDRType
|
from utils.sdr import SDRFactory, SDRType
|
||||||
from utils.sse import sse_stream_fanout
|
from utils.sse import sse_stream_fanout
|
||||||
from utils.validation import (
|
from utils.validation import (
|
||||||
@@ -269,7 +271,7 @@ def _fix_data_ownership(path: str) -> None:
|
|||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
uid_int, gid_int = int(uid), int(gid)
|
uid_int, gid_int = int(uid), int(gid)
|
||||||
for dirpath, dirnames, filenames in os.walk(path):
|
for dirpath, _dirnames, filenames in os.walk(path):
|
||||||
os.chown(dirpath, uid_int, gid_int)
|
os.chown(dirpath, uid_int, gid_int)
|
||||||
for fname in filenames:
|
for fname in filenames:
|
||||||
os.chown(os.path.join(dirpath, fname), uid_int, gid_int)
|
os.chown(os.path.join(dirpath, fname), uid_int, gid_int)
|
||||||
@@ -314,18 +316,14 @@ def parse_radiosonde_udp(udp_port: int) -> None:
|
|||||||
if serial:
|
if serial:
|
||||||
with _balloons_lock:
|
with _balloons_lock:
|
||||||
radiosonde_balloons[serial] = balloon
|
radiosonde_balloons[serial] = balloon
|
||||||
try:
|
with contextlib.suppress(queue.Full):
|
||||||
app_module.radiosonde_queue.put_nowait({
|
app_module.radiosonde_queue.put_nowait({
|
||||||
'type': 'balloon',
|
'type': 'balloon',
|
||||||
**balloon,
|
**balloon,
|
||||||
})
|
})
|
||||||
except queue.Full:
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
with contextlib.suppress(OSError):
|
||||||
sock.close()
|
sock.close()
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
_udp_socket = None
|
_udp_socket = None
|
||||||
logger.info("Radiosonde UDP listener stopped")
|
logger.info("Radiosonde UDP listener stopped")
|
||||||
|
|
||||||
@@ -353,71 +351,51 @@ def _process_telemetry(msg: dict) -> dict | None:
|
|||||||
# Position
|
# Position
|
||||||
for key in ('lat', 'latitude'):
|
for key in ('lat', 'latitude'):
|
||||||
if key in msg:
|
if key in msg:
|
||||||
try:
|
with contextlib.suppress(ValueError, TypeError):
|
||||||
balloon['lat'] = float(msg[key])
|
balloon['lat'] = float(msg[key])
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
break
|
break
|
||||||
for key in ('lon', 'longitude'):
|
for key in ('lon', 'longitude'):
|
||||||
if key in msg:
|
if key in msg:
|
||||||
try:
|
with contextlib.suppress(ValueError, TypeError):
|
||||||
balloon['lon'] = float(msg[key])
|
balloon['lon'] = float(msg[key])
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
break
|
break
|
||||||
|
|
||||||
# Altitude (metres)
|
# Altitude (metres)
|
||||||
if 'alt' in msg:
|
if 'alt' in msg:
|
||||||
try:
|
with contextlib.suppress(ValueError, TypeError):
|
||||||
balloon['alt'] = float(msg['alt'])
|
balloon['alt'] = float(msg['alt'])
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Meteorological data
|
# Meteorological data
|
||||||
for field in ('temp', 'humidity', 'pressure'):
|
for field in ('temp', 'humidity', 'pressure'):
|
||||||
if field in msg:
|
if field in msg:
|
||||||
try:
|
with contextlib.suppress(ValueError, TypeError):
|
||||||
balloon[field] = float(msg[field])
|
balloon[field] = float(msg[field])
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Velocity
|
# Velocity
|
||||||
if 'vel_h' in msg:
|
if 'vel_h' in msg:
|
||||||
try:
|
with contextlib.suppress(ValueError, TypeError):
|
||||||
balloon['vel_h'] = float(msg['vel_h'])
|
balloon['vel_h'] = float(msg['vel_h'])
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
if 'vel_v' in msg:
|
if 'vel_v' in msg:
|
||||||
try:
|
with contextlib.suppress(ValueError, TypeError):
|
||||||
balloon['vel_v'] = float(msg['vel_v'])
|
balloon['vel_v'] = float(msg['vel_v'])
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
if 'heading' in msg:
|
if 'heading' in msg:
|
||||||
try:
|
with contextlib.suppress(ValueError, TypeError):
|
||||||
balloon['heading'] = float(msg['heading'])
|
balloon['heading'] = float(msg['heading'])
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# GPS satellites
|
# GPS satellites
|
||||||
if 'sats' in msg:
|
if 'sats' in msg:
|
||||||
try:
|
with contextlib.suppress(ValueError, TypeError):
|
||||||
balloon['sats'] = int(msg['sats'])
|
balloon['sats'] = int(msg['sats'])
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Battery voltage
|
# Battery voltage
|
||||||
if 'batt' in msg:
|
if 'batt' in msg:
|
||||||
try:
|
with contextlib.suppress(ValueError, TypeError):
|
||||||
balloon['batt'] = float(msg['batt'])
|
balloon['batt'] = float(msg['batt'])
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Frequency
|
# Frequency
|
||||||
if 'freq' in msg:
|
if 'freq' in msg:
|
||||||
try:
|
with contextlib.suppress(ValueError, TypeError):
|
||||||
balloon['freq'] = float(msg['freq'])
|
balloon['freq'] = float(msg['freq'])
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
balloon['last_seen'] = time.time()
|
balloon['last_seen'] = time.time()
|
||||||
return balloon
|
return balloon
|
||||||
@@ -479,10 +457,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 +466,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 +478,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 +500,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 +524,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 +542,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 +566,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)}")
|
||||||
@@ -623,12 +589,10 @@ def start_radiosonde():
|
|||||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||||
stderr_output = ''
|
stderr_output = ''
|
||||||
if app_module.radiosonde_process.stderr:
|
if app_module.radiosonde_process.stderr:
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
stderr_output = app_module.radiosonde_process.stderr.read().decode(
|
stderr_output = app_module.radiosonde_process.stderr.read().decode(
|
||||||
'utf-8', errors='ignore'
|
'utf-8', errors='ignore'
|
||||||
).strip()
|
).strip()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if stderr_output:
|
if stderr_output:
|
||||||
logger.error(f"radiosonde_auto_rx stderr:\n{stderr_output}")
|
logger.error(f"radiosonde_auto_rx stderr:\n{stderr_output}")
|
||||||
if stderr_output and (
|
if stderr_output and (
|
||||||
@@ -646,7 +610,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 +636,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'])
|
||||||
@@ -697,10 +661,8 @@ def stop_radiosonde():
|
|||||||
|
|
||||||
# Close UDP socket to unblock listener thread
|
# Close UDP socket to unblock listener thread
|
||||||
if _udp_socket:
|
if _udp_socket:
|
||||||
try:
|
with contextlib.suppress(OSError):
|
||||||
_udp_socket.close()
|
_udp_socket.close()
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
_udp_socket = None
|
_udp_socket = None
|
||||||
|
|
||||||
# Release SDR device
|
# Release SDR device
|
||||||
@@ -741,8 +703,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),
|
||||||
})
|
})
|
||||||
|
|||||||
+34
-41
@@ -5,9 +5,10 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, send_file
|
from flask import Blueprint, request, send_file
|
||||||
|
|
||||||
from utils.recording import get_recording_manager, RECORDING_ROOT
|
from utils.recording import RECORDING_ROOT, get_recording_manager
|
||||||
|
from utils.responses import api_error, api_success
|
||||||
|
|
||||||
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'],
|
||||||
|
|||||||
+20
-32
@@ -2,24 +2,23 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import json
|
import json
|
||||||
import queue
|
import queue
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Generator
|
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response
|
from flask import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.logging import sensor_logger as logger
|
|
||||||
from utils.validation import (
|
|
||||||
validate_frequency, validate_device_index, validate_gain, validate_ppm
|
|
||||||
)
|
|
||||||
from utils.sse import sse_stream_fanout
|
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
from utils.process import safe_terminate, register_process, unregister_process
|
from utils.logging import sensor_logger as logger
|
||||||
|
from utils.process import register_process, unregister_process
|
||||||
|
from utils.responses import api_error
|
||||||
|
from utils.sse import sse_stream_fanout
|
||||||
|
from utils.validation import validate_device_index, validate_frequency, validate_gain, validate_ppm
|
||||||
|
|
||||||
rtlamr_bp = Blueprint('rtlamr', __name__)
|
rtlamr_bp = Blueprint('rtlamr', __name__)
|
||||||
|
|
||||||
@@ -69,10 +68,8 @@ def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
|
|||||||
process.terminate()
|
process.terminate()
|
||||||
process.wait(timeout=2)
|
process.wait(timeout=2)
|
||||||
except Exception:
|
except Exception:
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
process.kill()
|
process.kill()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
unregister_process(process)
|
unregister_process(process)
|
||||||
# Kill companion rtl_tcp process
|
# Kill companion rtl_tcp process
|
||||||
with rtl_tcp_lock:
|
with rtl_tcp_lock:
|
||||||
@@ -81,10 +78,8 @@ def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
|
|||||||
rtl_tcp_process.terminate()
|
rtl_tcp_process.terminate()
|
||||||
rtl_tcp_process.wait(timeout=2)
|
rtl_tcp_process.wait(timeout=2)
|
||||||
except Exception:
|
except Exception:
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
rtl_tcp_process.kill()
|
rtl_tcp_process.kill()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
unregister_process(rtl_tcp_process)
|
unregister_process(rtl_tcp_process)
|
||||||
rtl_tcp_process = None
|
rtl_tcp_process = None
|
||||||
app_module.rtlamr_queue.put({'type': 'status', 'text': 'stopped'})
|
app_module.rtlamr_queue.put({'type': 'status', 'text': 'stopped'})
|
||||||
@@ -102,16 +97,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 +112,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
|
||||||
@@ -145,7 +133,7 @@ def start_rtlamr() -> Response:
|
|||||||
# Get message type (default to scm)
|
# Get message type (default to scm)
|
||||||
msgtype = data.get('msgtype', 'scm')
|
msgtype = data.get('msgtype', 'scm')
|
||||||
output_format = data.get('format', 'json')
|
output_format = data.get('format', 'json')
|
||||||
|
|
||||||
# Start rtl_tcp first
|
# Start rtl_tcp first
|
||||||
rtl_tcp_just_started = False
|
rtl_tcp_just_started = False
|
||||||
rtl_tcp_cmd_str = ''
|
rtl_tcp_cmd_str = ''
|
||||||
@@ -181,7 +169,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:
|
||||||
@@ -197,16 +185,16 @@ def start_rtlamr() -> Response:
|
|||||||
f'-format={output_format}',
|
f'-format={output_format}',
|
||||||
f'-centerfreq={int(float(freq) * 1e6)}'
|
f'-centerfreq={int(float(freq) * 1e6)}'
|
||||||
]
|
]
|
||||||
|
|
||||||
# Add filter options if provided
|
# Add filter options if provided
|
||||||
filterid = data.get('filterid')
|
filterid = data.get('filterid')
|
||||||
if filterid:
|
if filterid:
|
||||||
cmd.append(f'-filterid={filterid}')
|
cmd.append(f'-filterid={filterid}')
|
||||||
|
|
||||||
filtertype = data.get('filtertype')
|
filtertype = data.get('filtertype')
|
||||||
if filtertype:
|
if filtertype:
|
||||||
cmd.append(f'-filtertype={filtertype}')
|
cmd.append(f'-filtertype={filtertype}')
|
||||||
|
|
||||||
# Unique messages only
|
# Unique messages only
|
||||||
if data.get('unique', True):
|
if data.get('unique', True):
|
||||||
cmd.append('-unique=true')
|
cmd.append('-unique=true')
|
||||||
@@ -253,7 +241,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 +252,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'])
|
||||||
|
|||||||
+18
-22
@@ -2,29 +2,25 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import math
|
import math
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Any, Optional
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
from flask import Blueprint, jsonify, render_template, request
|
||||||
from flask import Blueprint, jsonify, request, render_template, Response
|
|
||||||
|
|
||||||
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
|
||||||
from utils.database import (
|
from utils.database import (
|
||||||
get_tracked_satellites,
|
|
||||||
add_tracked_satellite,
|
add_tracked_satellite,
|
||||||
bulk_add_tracked_satellites,
|
bulk_add_tracked_satellites,
|
||||||
update_tracked_satellite,
|
get_tracked_satellites,
|
||||||
remove_tracked_satellite,
|
remove_tracked_satellite,
|
||||||
|
update_tracked_satellite,
|
||||||
)
|
)
|
||||||
from utils.logging import satellite_logger as logger
|
from utils.logging import satellite_logger as logger
|
||||||
from utils.validation import validate_latitude, validate_longitude, validate_hours, validate_elevation
|
from utils.responses import api_error
|
||||||
|
from utils.validation import validate_elevation, validate_hours, validate_latitude, validate_longitude
|
||||||
|
|
||||||
satellite_bp = Blueprint('satellite', __name__, url_prefix='/satellite')
|
satellite_bp = Blueprint('satellite', __name__, url_prefix='/satellite')
|
||||||
|
|
||||||
@@ -86,7 +82,7 @@ def init_tle_auto_refresh():
|
|||||||
logger.info("TLE auto-refresh scheduled")
|
logger.info("TLE auto-refresh scheduled")
|
||||||
|
|
||||||
|
|
||||||
def _fetch_iss_realtime(observer_lat: Optional[float] = None, observer_lon: Optional[float] = None) -> Optional[dict]:
|
def _fetch_iss_realtime(observer_lat: float | None = None, observer_lon: float | None = None) -> dict | None:
|
||||||
"""
|
"""
|
||||||
Fetch real-time ISS position from external APIs.
|
Fetch real-time ISS position from external APIs.
|
||||||
|
|
||||||
@@ -189,8 +185,8 @@ def satellite_dashboard():
|
|||||||
def predict_passes():
|
def predict_passes():
|
||||||
"""Calculate satellite passes using skyfield."""
|
"""Calculate satellite passes using skyfield."""
|
||||||
try:
|
try:
|
||||||
from skyfield.api import wgs84, EarthSatellite
|
|
||||||
from skyfield.almanac import find_discrete
|
from skyfield.almanac import find_discrete
|
||||||
|
from skyfield.api import EarthSatellite, wgs84
|
||||||
except ImportError:
|
except ImportError:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
@@ -206,7 +202,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',
|
||||||
@@ -343,9 +339,9 @@ def predict_passes():
|
|||||||
def get_satellite_position():
|
def get_satellite_position():
|
||||||
"""Get real-time positions of satellites."""
|
"""Get real-time positions of satellites."""
|
||||||
try:
|
try:
|
||||||
from skyfield.api import wgs84, EarthSatellite
|
from skyfield.api import EarthSatellite, wgs84
|
||||||
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 +350,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 +524,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 +538,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 +579,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 +600,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 +663,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 +678,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)
|
||||||
|
|||||||
+23
-23
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
import queue
|
import queue
|
||||||
@@ -9,20 +10,25 @@ import subprocess
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Generator
|
from typing import Any
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response
|
from flask import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.logging import sensor_logger as logger
|
|
||||||
from utils.validation import (
|
|
||||||
validate_frequency, validate_device_index, validate_gain, validate_ppm,
|
|
||||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
|
||||||
)
|
|
||||||
from utils.sse import sse_stream_fanout
|
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
from utils.process import safe_terminate, register_process, unregister_process
|
from utils.logging import sensor_logger as logger
|
||||||
|
from utils.process import register_process, unregister_process
|
||||||
|
from utils.responses import api_error, api_success
|
||||||
from utils.sdr import SDRFactory, SDRType
|
from utils.sdr import SDRFactory, SDRType
|
||||||
|
from utils.sse import sse_stream_fanout
|
||||||
|
from utils.validation import (
|
||||||
|
validate_device_index,
|
||||||
|
validate_frequency,
|
||||||
|
validate_gain,
|
||||||
|
validate_ppm,
|
||||||
|
validate_rtl_tcp_host,
|
||||||
|
validate_rtl_tcp_port,
|
||||||
|
)
|
||||||
|
|
||||||
sensor_bp = Blueprint('sensor', __name__)
|
sensor_bp = Blueprint('sensor', __name__)
|
||||||
|
|
||||||
@@ -136,10 +142,8 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
|||||||
process.terminate()
|
process.terminate()
|
||||||
process.wait(timeout=2)
|
process.wait(timeout=2)
|
||||||
except Exception:
|
except Exception:
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
process.kill()
|
process.kill()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
unregister_process(process)
|
unregister_process(process)
|
||||||
app_module.sensor_queue.put({'type': 'status', 'text': 'stopped'})
|
app_module.sensor_queue.put({'type': 'status', 'text': 'stopped'})
|
||||||
with app_module.sensor_lock:
|
with app_module.sensor_lock:
|
||||||
@@ -165,7 +169,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 +180,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 +194,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 +217,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 +285,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 +346,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})
|
||||||
|
|||||||
+21
-71
@@ -6,16 +6,17 @@ import os
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response
|
from flask import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
from utils.database import (
|
from utils.database import (
|
||||||
get_setting,
|
|
||||||
set_setting,
|
|
||||||
delete_setting,
|
delete_setting,
|
||||||
get_all_settings,
|
get_all_settings,
|
||||||
get_correlations,
|
get_correlations,
|
||||||
|
get_setting,
|
||||||
|
set_setting,
|
||||||
)
|
)
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -207,7 +163,7 @@ def check_dvb_driver_status() -> Response:
|
|||||||
blacklist_contents = []
|
blacklist_contents = []
|
||||||
if blacklist_exists:
|
if blacklist_exists:
|
||||||
try:
|
try:
|
||||||
with open(BLACKLIST_FILE, 'r') as f:
|
with open(BLACKLIST_FILE) as f:
|
||||||
blacklist_contents = [line.strip() for line in f if line.strip() and not line.startswith('#')]
|
blacklist_contents = [line.strip() for line in f if line.strip() and not line.startswith('#')]
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -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
@@ -11,6 +11,7 @@ from typing import Any
|
|||||||
from flask import Blueprint, Response, jsonify, request
|
from flask import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
|
from utils.responses import api_error
|
||||||
|
|
||||||
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_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'],
|
||||||
|
|||||||
@@ -611,9 +611,9 @@ def get_station(station_id):
|
|||||||
@spy_stations_bp.route('/filters')
|
@spy_stations_bp.route('/filters')
|
||||||
def get_filters():
|
def get_filters():
|
||||||
"""Return available filter options."""
|
"""Return available filter options."""
|
||||||
types = list(set(s['type'] for s in STATIONS))
|
types = list({s['type'] for s in STATIONS})
|
||||||
countries = sorted(list(set((s['country'], s['country_code']) for s in STATIONS)))
|
countries = sorted({(s['country'], s['country_code']) for s in STATIONS})
|
||||||
modes = sorted(list(set(s['mode'].split('/')[0] for s in STATIONS)))
|
modes = sorted({s['mode'].split('/')[0] for s in STATIONS})
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
|
|||||||
+20
-18
@@ -6,22 +6,24 @@ ISS SSTV events occur during special commemorations and typically transmit on 14
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import queue
|
import queue
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response, send_file
|
from flask import Blueprint, Response, jsonify, request, send_file
|
||||||
|
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.logging import get_logger
|
|
||||||
from utils.sse import sse_stream_fanout
|
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
|
from utils.logging import get_logger
|
||||||
|
from utils.responses import api_error
|
||||||
|
from utils.sse import sse_stream_fanout
|
||||||
from utils.sstv import (
|
from utils.sstv import (
|
||||||
|
ISS_SSTV_FREQ,
|
||||||
get_sstv_decoder,
|
get_sstv_decoder,
|
||||||
is_sstv_available,
|
is_sstv_available,
|
||||||
ISS_SSTV_FREQ,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = get_logger('intercept.sstv')
|
logger = get_logger('intercept.sstv')
|
||||||
@@ -357,16 +359,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 +388,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 +416,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'])
|
||||||
@@ -519,9 +521,11 @@ def iss_schedule():
|
|||||||
return jsonify(_iss_schedule_cache)
|
return jsonify(_iss_schedule_cache)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from skyfield.api import wgs84, EarthSatellite
|
|
||||||
from skyfield.almanac import find_discrete
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from skyfield.almanac import find_discrete
|
||||||
|
from skyfield.api import EarthSatellite, wgs84
|
||||||
|
|
||||||
from data.satellites import TLE_SATELLITES
|
from data.satellites import TLE_SATELLITES
|
||||||
|
|
||||||
# Get ISS TLE
|
# Get ISS TLE
|
||||||
@@ -815,7 +819,5 @@ def decode_file():
|
|||||||
|
|
||||||
finally:
|
finally:
|
||||||
# Clean up temp file
|
# Clean up temp file
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
Path(tmp_path).unlink()
|
Path(tmp_path).unlink()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|||||||
+25
-61
@@ -6,17 +6,17 @@ frequencies used by amateur radio operators worldwide.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import queue
|
import queue
|
||||||
import time
|
|
||||||
from collections.abc import Generator
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from flask import Blueprint, Response, jsonify, request, send_file
|
from flask import Blueprint, Response, jsonify, request, send_file
|
||||||
|
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.logging import get_logger
|
|
||||||
from utils.sse import sse_stream_fanout
|
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
|
from utils.logging import get_logger
|
||||||
|
from utils.responses import api_error
|
||||||
|
from utils.sse import sse_stream_fanout
|
||||||
from utils.sstv import (
|
from utils.sstv import (
|
||||||
get_general_sstv_decoder,
|
get_general_sstv_decoder,
|
||||||
)
|
)
|
||||||
@@ -102,10 +102,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 +120,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 +128,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 +143,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 +171,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 +212,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 +232,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 +252,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 +297,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,13 +321,8 @@ 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:
|
with contextlib.suppress(Exception):
|
||||||
Path(tmp_path).unlink()
|
Path(tmp_path).unlink()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|||||||
+158
-158
@@ -6,24 +6,26 @@ signal replay/transmit, and wideband spectrum analysis.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import queue
|
import queue
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response, send_file
|
from flask import Blueprint, Response, jsonify, request, send_file
|
||||||
|
|
||||||
from utils.logging import get_logger
|
|
||||||
from utils.sse import sse_stream
|
|
||||||
from utils.subghz import get_subghz_manager
|
|
||||||
from utils.event_pipeline import process_event
|
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
SUBGHZ_FREQ_MIN_MHZ,
|
|
||||||
SUBGHZ_FREQ_MAX_MHZ,
|
SUBGHZ_FREQ_MAX_MHZ,
|
||||||
|
SUBGHZ_FREQ_MIN_MHZ,
|
||||||
SUBGHZ_LNA_GAIN_MAX,
|
SUBGHZ_LNA_GAIN_MAX,
|
||||||
SUBGHZ_VGA_GAIN_MAX,
|
|
||||||
SUBGHZ_TX_VGA_GAIN_MAX,
|
|
||||||
SUBGHZ_TX_MAX_DURATION,
|
|
||||||
SUBGHZ_SAMPLE_RATES,
|
|
||||||
SUBGHZ_PRESETS,
|
SUBGHZ_PRESETS,
|
||||||
|
SUBGHZ_SAMPLE_RATES,
|
||||||
|
SUBGHZ_TX_MAX_DURATION,
|
||||||
|
SUBGHZ_TX_VGA_GAIN_MAX,
|
||||||
|
SUBGHZ_VGA_GAIN_MAX,
|
||||||
)
|
)
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
|
from utils.logging import get_logger
|
||||||
|
from utils.responses import api_error
|
||||||
|
from utils.sse import sse_stream
|
||||||
|
from utils.subghz import get_subghz_manager
|
||||||
|
|
||||||
logger = get_logger('intercept.subghz')
|
logger = get_logger('intercept.subghz')
|
||||||
|
|
||||||
@@ -33,14 +35,12 @@ 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:
|
with contextlib.suppress(Exception):
|
||||||
process_event('subghz', event, event.get('type'))
|
process_event('subghz', event, event.get('type'))
|
||||||
except Exception:
|
try:
|
||||||
pass
|
_subghz_queue.put_nowait(event)
|
||||||
try:
|
|
||||||
_subghz_queue.put_nowait(event)
|
|
||||||
except queue.Full:
|
except queue.Full:
|
||||||
try:
|
try:
|
||||||
_subghz_queue.get_nowait()
|
_subghz_queue.get_nowait()
|
||||||
@@ -76,44 +76,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 +136,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 +186,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 +227,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 +278,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 +326,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_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,270 @@
|
|||||||
|
"""
|
||||||
|
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 (
|
||||||
|
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:
|
||||||
|
|
||||||
|
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,203 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
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 routes.tscm import _current_sweep_id
|
||||||
|
from utils.database import get_meeting_windows
|
||||||
|
from utils.tscm.advanced import generate_meeting_summary, get_timeline_manager
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
meeting = get_active_meeting_window(_current_sweep_id)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'meeting': meeting,
|
||||||
|
'is_active': meeting is not None
|
||||||
|
})
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
"""
|
||||||
|
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,
|
||||||
|
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,426 @@
|
|||||||
|
"""
|
||||||
|
TSCM Sweep Routes
|
||||||
|
|
||||||
|
Handles /sweep/*, /status, /devices, /presets/*, /feed/*,
|
||||||
|
/capabilities, and /sweep/<id>/capabilities endpoints.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from flask import Response, jsonify, request
|
||||||
|
|
||||||
|
from data.tscm_frequencies import get_all_sweep_presets, get_sweep_preset
|
||||||
|
from routes.tscm import (
|
||||||
|
_baseline_recorder,
|
||||||
|
_current_sweep_id,
|
||||||
|
_emit_event,
|
||||||
|
_start_sweep_internal,
|
||||||
|
_sweep_running,
|
||||||
|
tscm_bp,
|
||||||
|
tscm_queue,
|
||||||
|
)
|
||||||
|
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."""
|
||||||
|
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."""
|
||||||
|
|
||||||
|
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."""
|
||||||
|
|
||||||
|
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."""
|
||||||
|
|
||||||
|
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."""
|
||||||
|
|
||||||
|
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."""
|
||||||
|
|
||||||
|
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
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
from flask import Blueprint, Response, jsonify, request
|
from flask import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
|
from utils.responses import api_error
|
||||||
from utils.updater import (
|
from utils.updater import (
|
||||||
check_for_updates,
|
check_for_updates,
|
||||||
dismiss_update,
|
dismiss_update,
|
||||||
@@ -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'])
|
||||||
|
|||||||
+13
-29
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import io
|
import contextlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
@@ -13,7 +13,7 @@ import subprocess
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Generator
|
from typing import Any
|
||||||
|
|
||||||
from flask import Blueprint, Response, jsonify, request
|
from flask import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
@@ -29,6 +29,7 @@ from utils.event_pipeline import process_event
|
|||||||
from utils.flight_correlator import get_flight_correlator
|
from utils.flight_correlator import get_flight_correlator
|
||||||
from utils.logging import sensor_logger as logger
|
from utils.logging import sensor_logger as logger
|
||||||
from utils.process import register_process, unregister_process
|
from utils.process import register_process, unregister_process
|
||||||
|
from utils.responses import api_error
|
||||||
from utils.sdr import SDRFactory, SDRType
|
from utils.sdr import SDRFactory, SDRType
|
||||||
from utils.sse import sse_stream_fanout
|
from utils.sse import sse_stream_fanout
|
||||||
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
||||||
@@ -104,10 +105,8 @@ def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) ->
|
|||||||
app_module.vdl2_queue.put(data)
|
app_module.vdl2_queue.put(data)
|
||||||
|
|
||||||
# Feed flight correlator
|
# Feed flight correlator
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
get_flight_correlator().add_vdl2_message(data)
|
get_flight_correlator().add_vdl2_message(data)
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Log if enabled
|
# Log if enabled
|
||||||
if app_module.logging_enabled:
|
if app_module.logging_enabled:
|
||||||
@@ -133,10 +132,8 @@ def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) ->
|
|||||||
process.terminate()
|
process.terminate()
|
||||||
process.wait(timeout=2)
|
process.wait(timeout=2)
|
||||||
except Exception:
|
except Exception:
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
process.kill()
|
process.kill()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
unregister_process(process)
|
unregister_process(process)
|
||||||
app_module.vdl2_queue.put({'type': 'status', 'status': 'stopped'})
|
app_module.vdl2_queue.put({'type': 'status', 'status': 'stopped'})
|
||||||
with app_module.vdl2_lock:
|
with app_module.vdl2_lock:
|
||||||
@@ -181,18 +178,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 +193,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 +206,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
|
||||||
@@ -284,7 +271,7 @@ def start_vdl2() -> Response:
|
|||||||
)
|
)
|
||||||
os.close(slave_fd)
|
os.close(slave_fd)
|
||||||
# Wrap master_fd as a text file for line-buffered reading
|
# Wrap master_fd as a text file for line-buffered reading
|
||||||
process.stdout = io.open(master_fd, 'r', buffering=1)
|
process.stdout = open(master_fd, buffering=1)
|
||||||
is_text_mode = True
|
is_text_mode = True
|
||||||
else:
|
else:
|
||||||
process = subprocess.Popen(
|
process = subprocess.Popen(
|
||||||
@@ -312,7 +299,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 +326,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 +336,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()
|
||||||
|
|||||||
@@ -372,7 +372,6 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
capture_center_mhz = 0.0
|
capture_center_mhz = 0.0
|
||||||
capture_start_freq = 0.0
|
capture_start_freq = 0.0
|
||||||
capture_end_freq = 0.0
|
capture_end_freq = 0.0
|
||||||
capture_span_mhz = 0.0
|
|
||||||
# Queue for outgoing messages — only the main loop touches ws.send()
|
# Queue for outgoing messages — only the main loop touches ws.send()
|
||||||
send_queue = queue.Queue(maxsize=120)
|
send_queue = queue.Queue(maxsize=120)
|
||||||
|
|
||||||
@@ -619,7 +618,6 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
capture_center_mhz = center_freq_mhz
|
capture_center_mhz = center_freq_mhz
|
||||||
capture_start_freq = start_freq
|
capture_start_freq = start_freq
|
||||||
capture_end_freq = end_freq
|
capture_end_freq = end_freq
|
||||||
capture_span_mhz = effective_span_mhz
|
|
||||||
|
|
||||||
my_generation = _set_shared_capture_state(
|
my_generation = _set_shared_capture_state(
|
||||||
running=True,
|
running=True,
|
||||||
|
|||||||
+26
-24
@@ -8,17 +8,26 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import queue
|
import queue
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response, send_file
|
from flask import Blueprint, Response, jsonify, request, send_file
|
||||||
|
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
|
from utils.responses import api_error
|
||||||
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_elevation,
|
||||||
|
validate_gain,
|
||||||
|
validate_latitude,
|
||||||
|
validate_longitude,
|
||||||
|
validate_rtl_tcp_host,
|
||||||
|
validate_rtl_tcp_port,
|
||||||
|
)
|
||||||
from utils.weather_sat import (
|
from utils.weather_sat import (
|
||||||
|
DEFAULT_SAMPLE_RATE,
|
||||||
|
WEATHER_SATELLITES,
|
||||||
|
CaptureProgress,
|
||||||
get_weather_sat_decoder,
|
get_weather_sat_decoder,
|
||||||
is_weather_sat_available,
|
is_weather_sat_available,
|
||||||
CaptureProgress,
|
|
||||||
WEATHER_SATELLITES,
|
|
||||||
DEFAULT_SAMPLE_RATE,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = get_logger('intercept.weather_sat')
|
logger = get_logger('intercept.weather_sat')
|
||||||
@@ -174,7 +183,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 +191,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 +422,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 +449,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 +505,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))
|
||||||
@@ -619,7 +621,7 @@ def enable_schedule():
|
|||||||
gain=gain_val,
|
gain=gain_val,
|
||||||
bias_t=bool(data.get('bias_t', False)),
|
bias_t=bool(data.get('bias_t', False)),
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.exception("Failed to enable weather sat scheduler")
|
logger.exception("Failed to enable weather sat scheduler")
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
@@ -668,10 +670,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)
|
||||||
|
|||||||
+20
-30
@@ -9,9 +9,10 @@ import re
|
|||||||
import struct
|
import struct
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from flask import Blueprint, Flask, jsonify, request, Response
|
from flask import Blueprint, Flask, Response, jsonify, request
|
||||||
|
|
||||||
|
from utils.responses import api_error, api_success
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from flask_sock import Sock
|
from flask_sock import Sock
|
||||||
@@ -19,7 +20,9 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
WEBSOCKET_AVAILABLE = False
|
WEBSOCKET_AVAILABLE = False
|
||||||
|
|
||||||
from utils.kiwisdr import KiwiSDRClient, KIWI_SAMPLE_RATE, VALID_MODES, parse_host_port
|
import contextlib
|
||||||
|
|
||||||
|
from utils.kiwisdr import KIWI_SAMPLE_RATE, VALID_MODES, KiwiSDRClient, parse_host_port
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
|
|
||||||
logger = get_logger('intercept.websdr')
|
logger = get_logger('intercept.websdr')
|
||||||
@@ -36,7 +39,7 @@ _cache_timestamp: float = 0
|
|||||||
CACHE_TTL = 3600 # 1 hour
|
CACHE_TTL = 3600 # 1 hour
|
||||||
|
|
||||||
|
|
||||||
def _parse_gps_coord(coord_str: str) -> Optional[float]:
|
def _parse_gps_coord(coord_str: str) -> float | None:
|
||||||
"""Parse a GPS coordinate string like '51.5074' or '(-33.87)' into a float."""
|
"""Parse a GPS coordinate string like '51.5074' or '(-33.87)' into a float."""
|
||||||
if not coord_str:
|
if not coord_str:
|
||||||
return None
|
return None
|
||||||
@@ -68,8 +71,8 @@ KIWI_DATA_URLS = [
|
|||||||
|
|
||||||
def _fetch_kiwi_receivers() -> list[dict]:
|
def _fetch_kiwi_receivers() -> list[dict]:
|
||||||
"""Fetch the KiwiSDR receiver list from the public directory."""
|
"""Fetch the KiwiSDR receiver list from the public directory."""
|
||||||
import urllib.request
|
|
||||||
import json
|
import json
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
receivers = []
|
receivers = []
|
||||||
raw = None
|
raw = None
|
||||||
@@ -226,8 +229,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 +244,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 +266,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 +275,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 +285,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 +297,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 +307,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', ''),
|
||||||
@@ -338,7 +336,7 @@ def websdr_status() -> Response:
|
|||||||
# KIWISDR AUDIO PROXY
|
# KIWISDR AUDIO PROXY
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
_kiwi_client: Optional[KiwiSDRClient] = None
|
_kiwi_client: KiwiSDRClient | None = None
|
||||||
_kiwi_lock = threading.Lock()
|
_kiwi_lock = threading.Lock()
|
||||||
_kiwi_audio_queue: queue.Queue = queue.Queue(maxsize=200)
|
_kiwi_audio_queue: queue.Queue = queue.Queue(maxsize=200)
|
||||||
|
|
||||||
@@ -390,26 +388,18 @@ def _handle_kiwi_command(ws, cmd: str, data: dict) -> None:
|
|||||||
try:
|
try:
|
||||||
_kiwi_audio_queue.put_nowait(header + pcm_bytes)
|
_kiwi_audio_queue.put_nowait(header + pcm_bytes)
|
||||||
except queue.Full:
|
except queue.Full:
|
||||||
try:
|
with contextlib.suppress(queue.Empty):
|
||||||
_kiwi_audio_queue.get_nowait()
|
_kiwi_audio_queue.get_nowait()
|
||||||
except queue.Empty:
|
with contextlib.suppress(queue.Full):
|
||||||
pass
|
|
||||||
try:
|
|
||||||
_kiwi_audio_queue.put_nowait(header + pcm_bytes)
|
_kiwi_audio_queue.put_nowait(header + pcm_bytes)
|
||||||
except queue.Full:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def on_error(msg):
|
def on_error(msg):
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
ws.send(json.dumps({'type': 'error', 'message': msg}))
|
ws.send(json.dumps({'type': 'error', 'message': msg}))
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def on_disconnect():
|
def on_disconnect():
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
ws.send(json.dumps({'type': 'disconnected'}))
|
ws.send(json.dumps({'type': 'disconnected'}))
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
with _kiwi_lock:
|
with _kiwi_lock:
|
||||||
_kiwi_client = KiwiSDRClient(
|
_kiwi_client = KiwiSDRClient(
|
||||||
|
|||||||
+25
-71
@@ -6,12 +6,14 @@ maritime/aviation weather services worldwide.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import queue
|
import queue
|
||||||
|
|
||||||
from flask import Blueprint, Response, jsonify, request, send_file
|
from flask import Blueprint, Response, jsonify, request, send_file
|
||||||
|
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
|
from utils.responses import api_error
|
||||||
from utils.sdr import SDRType
|
from utils.sdr import SDRType
|
||||||
from utils.sse import sse_stream_fanout
|
from utils.sse import sse_stream_fanout
|
||||||
from utils.validation import validate_frequency
|
from utils.validation import validate_frequency
|
||||||
@@ -109,10 +111,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 +119,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)
|
||||||
@@ -134,10 +130,8 @@ def start_decoder():
|
|||||||
frequency_reference = str(data.get('frequency_reference', 'auto')).strip().lower()
|
frequency_reference = str(data.get('frequency_reference', 'auto')).strip().lower()
|
||||||
|
|
||||||
sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower()
|
sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower()
|
||||||
try:
|
with contextlib.suppress(ValueError):
|
||||||
sdr_type = SDRType(sdr_type_str)
|
SDRType(sdr_type_str)
|
||||||
except ValueError:
|
|
||||||
sdr_type = SDRType.RTL_SDR
|
|
||||||
if not frequency_reference:
|
if not frequency_reference:
|
||||||
frequency_reference = 'auto'
|
frequency_reference = 'auto'
|
||||||
|
|
||||||
@@ -152,34 +146,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 +194,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 +253,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 +271,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 +332,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 +365,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 +382,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 +436,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 +461,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)
|
||||||
|
|
||||||
|
|||||||
+234
-239
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import fcntl
|
import fcntl
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -11,69 +12,74 @@ import re
|
|||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from typing import Any, Generator
|
from typing import Any
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response
|
from flask import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.dependencies import check_tool, get_tool_path
|
from data.oui import get_manufacturer
|
||||||
from utils.logging import wifi_logger as logger
|
|
||||||
from utils.process import is_valid_mac, is_valid_channel
|
|
||||||
from utils.validation import validate_wifi_channel, validate_mac_address, validate_network_interface
|
|
||||||
from utils.sse import format_sse, sse_stream_fanout
|
|
||||||
from utils.event_pipeline import process_event
|
|
||||||
from data.oui import get_manufacturer
|
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
WIFI_TERMINATE_TIMEOUT,
|
|
||||||
PMKID_TERMINATE_TIMEOUT,
|
|
||||||
SSE_KEEPALIVE_INTERVAL,
|
SSE_KEEPALIVE_INTERVAL,
|
||||||
SSE_QUEUE_TIMEOUT,
|
SSE_QUEUE_TIMEOUT,
|
||||||
WIFI_CSV_PARSE_INTERVAL,
|
|
||||||
WIFI_CSV_TIMEOUT_WARNING,
|
|
||||||
SUBPROCESS_TIMEOUT_SHORT,
|
|
||||||
SUBPROCESS_TIMEOUT_MEDIUM,
|
SUBPROCESS_TIMEOUT_MEDIUM,
|
||||||
SUBPROCESS_TIMEOUT_LONG,
|
SUBPROCESS_TIMEOUT_SHORT,
|
||||||
DEAUTH_TIMEOUT,
|
|
||||||
MIN_DEAUTH_COUNT,
|
|
||||||
MAX_DEAUTH_COUNT,
|
|
||||||
DEFAULT_DEAUTH_COUNT,
|
|
||||||
PROCESS_START_WAIT,
|
|
||||||
MONITOR_MODE_DELAY,
|
|
||||||
WIFI_CAPTURE_PATH_PREFIX,
|
|
||||||
HANDSHAKE_CAPTURE_PATH_PREFIX,
|
|
||||||
PMKID_CAPTURE_PATH_PREFIX,
|
|
||||||
)
|
)
|
||||||
|
from utils.dependencies import check_tool, get_tool_path
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
|
from utils.logging import wifi_logger as logger
|
||||||
|
from utils.process import is_valid_channel, is_valid_mac
|
||||||
|
from utils.responses import api_error, api_success
|
||||||
|
from utils.sse import format_sse, sse_stream_fanout
|
||||||
|
from utils.validation import validate_network_interface, validate_wifi_channel
|
||||||
|
|
||||||
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():
|
||||||
@@ -182,9 +188,9 @@ def _get_interface_details(iface_name):
|
|||||||
# Get MAC address
|
# Get MAC address
|
||||||
try:
|
try:
|
||||||
mac_path = f'/sys/class/net/{iface_name}/address'
|
mac_path = f'/sys/class/net/{iface_name}/address'
|
||||||
with open(mac_path, 'r') as f:
|
with open(mac_path) as f:
|
||||||
details['mac'] = f.read().strip().upper()
|
details['mac'] = f.read().strip().upper()
|
||||||
except (FileNotFoundError, IOError):
|
except (OSError, FileNotFoundError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Get driver name
|
# Get driver name
|
||||||
@@ -193,7 +199,7 @@ def _get_interface_details(iface_name):
|
|||||||
if os.path.islink(driver_link):
|
if os.path.islink(driver_link):
|
||||||
driver_path = os.readlink(driver_link)
|
driver_path = os.readlink(driver_link)
|
||||||
details['driver'] = os.path.basename(driver_path)
|
details['driver'] = os.path.basename(driver_path)
|
||||||
except (FileNotFoundError, IOError, OSError):
|
except (FileNotFoundError, OSError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Try airmon-ng first for chipset info (most reliable for WiFi adapters)
|
# Try airmon-ng first for chipset info (most reliable for WiFi adapters)
|
||||||
@@ -211,11 +217,10 @@ def _get_interface_details(iface_name):
|
|||||||
break
|
break
|
||||||
# Also try space-separated format
|
# Also try space-separated format
|
||||||
parts = line.split()
|
parts = line.split()
|
||||||
if len(parts) >= 4:
|
if len(parts) >= 4 and (parts[1] == iface_name or parts[1].startswith(iface_name)):
|
||||||
if parts[1] == iface_name or parts[1].startswith(iface_name):
|
details['driver'] = parts[2]
|
||||||
details['driver'] = parts[2]
|
details['chipset'] = ' '.join(parts[3:])
|
||||||
details['chipset'] = ' '.join(parts[3:])
|
break
|
||||||
break
|
|
||||||
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
|
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -227,10 +232,10 @@ def _get_interface_details(iface_name):
|
|||||||
# Try to get USB product name
|
# Try to get USB product name
|
||||||
for usb_path in [f'{device_path}/product', f'{device_path}/../product']:
|
for usb_path in [f'{device_path}/product', f'{device_path}/../product']:
|
||||||
try:
|
try:
|
||||||
with open(usb_path, 'r') as f:
|
with open(usb_path) as f:
|
||||||
details['chipset'] = f.read().strip()
|
details['chipset'] = f.read().strip()
|
||||||
break
|
break
|
||||||
except (FileNotFoundError, IOError):
|
except (OSError, FileNotFoundError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# If no USB product, try lsusb for USB devices
|
# If no USB product, try lsusb for USB devices
|
||||||
@@ -238,7 +243,7 @@ def _get_interface_details(iface_name):
|
|||||||
try:
|
try:
|
||||||
# Get USB bus/device info
|
# Get USB bus/device info
|
||||||
uevent_path = f'{device_path}/uevent'
|
uevent_path = f'{device_path}/uevent'
|
||||||
with open(uevent_path, 'r') as f:
|
with open(uevent_path) as f:
|
||||||
for line in f:
|
for line in f:
|
||||||
if line.startswith('PRODUCT='):
|
if line.startswith('PRODUCT='):
|
||||||
# PRODUCT format: vendor/product/bcdDevice
|
# PRODUCT format: vendor/product/bcdDevice
|
||||||
@@ -261,9 +266,9 @@ def _get_interface_details(iface_name):
|
|||||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
pass
|
pass
|
||||||
break
|
break
|
||||||
except (FileNotFoundError, IOError):
|
except (OSError, FileNotFoundError):
|
||||||
pass
|
pass
|
||||||
except (FileNotFoundError, IOError, OSError):
|
except (FileNotFoundError, OSError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return details
|
return details
|
||||||
@@ -275,7 +280,7 @@ def parse_airodump_csv(csv_path):
|
|||||||
clients = {}
|
clients = {}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(csv_path, 'r', errors='replace') as f:
|
with open(csv_path, errors='replace') as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
|
|
||||||
sections = content.split('\n\n')
|
sections = content.split('\n\n')
|
||||||
@@ -455,7 +460,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 +580,16 @@ 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
|
|
||||||
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 +597,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 +610,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 +631,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 +644,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 = {}
|
||||||
@@ -670,11 +668,9 @@ def start_wifi_scan():
|
|||||||
|
|
||||||
csv_path = '/tmp/intercept_wifi'
|
csv_path = '/tmp/intercept_wifi'
|
||||||
|
|
||||||
for f in [f'/tmp/intercept_wifi-01.csv', f'/tmp/intercept_wifi-01.cap']:
|
for f in ['/tmp/intercept_wifi-01.csv', '/tmp/intercept_wifi-01.cap']:
|
||||||
try:
|
with contextlib.suppress(OSError):
|
||||||
os.remove(f)
|
os.remove(f)
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
airodump_path = get_tool_path('airodump-ng')
|
airodump_path = get_tool_path('airodump-ng')
|
||||||
cmd = [
|
cmd = [
|
||||||
@@ -685,17 +681,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 +719,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 +730,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 +764,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 +785,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 +805,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 +828,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 +862,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 +873,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 +883,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 +947,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 +982,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 +994,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})
|
||||||
@@ -1008,7 +1004,7 @@ def check_pmkid_status():
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
hash_file = capture_file.replace('.pcapng', '.22000')
|
hash_file = capture_file.replace('.pcapng', '.22000')
|
||||||
result = subprocess.run(
|
subprocess.run(
|
||||||
['hcxpcapngtool', '-o', hash_file, capture_file],
|
['hcxpcapngtool', '-o', hash_file, capture_file],
|
||||||
capture_output=True, text=True, timeout=10
|
capture_output=True, text=True, timeout=10
|
||||||
)
|
)
|
||||||
@@ -1054,23 +1050,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 +1095,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 +1113,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,33 +1127,33 @@ 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
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# V2 API Endpoints - Using unified WiFi scanner
|
# V2 API Endpoints - Using unified WiFi scanner
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
from utils.wifi.scanner import get_wifi_scanner, reset_wifi_scanner
|
from utils.wifi.scanner import get_wifi_scanner
|
||||||
|
|
||||||
|
|
||||||
@wifi_bp.route('/v2/capabilities')
|
@wifi_bp.route('/v2/capabilities')
|
||||||
@@ -1189,7 +1184,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 +1215,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 +1234,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 +1249,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 +1269,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 +1284,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 +1321,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 +1336,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 +1352,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 +1443,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 +1459,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 +1471,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 +1483,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 +1530,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 +1545,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 +1595,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 +1615,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
-58
@@ -7,25 +7,26 @@ channel analysis, hidden SSID correlation, and SSE streaming.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from collections.abc import Generator
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Generator
|
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response
|
from flask import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
from utils.wifi import (
|
from utils.event_pipeline import process_event
|
||||||
get_wifi_scanner,
|
from utils.responses import api_error
|
||||||
analyze_channels,
|
from utils.sse import format_sse
|
||||||
get_hidden_correlator,
|
from utils.validation import validate_wifi_channel
|
||||||
SCAN_MODE_QUICK,
|
from utils.wifi import (
|
||||||
SCAN_MODE_DEEP,
|
SCAN_MODE_DEEP,
|
||||||
)
|
analyze_channels,
|
||||||
from utils.sse import format_sse
|
get_hidden_correlator,
|
||||||
from utils.validation import validate_wifi_channel
|
get_wifi_scanner,
|
||||||
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,12 @@ 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:
|
with contextlib.suppress(Exception):
|
||||||
process_event('wifi', event, event.get('type'))
|
process_event('wifi', event, event.get('type'))
|
||||||
except Exception:
|
yield format_sse(event)
|
||||||
pass
|
|
||||||
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'
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ read_env_var() {
|
|||||||
local fallback="${2:-}"
|
local fallback="${2:-}"
|
||||||
if [[ -f "$SCRIPT_DIR/.env" ]]; then
|
if [[ -f "$SCRIPT_DIR/.env" ]]; then
|
||||||
local val
|
local val
|
||||||
val=$(grep -E "^${key}=" "$SCRIPT_DIR/.env" 2>/dev/null | tail -1 | cut -d'=' -f2-)
|
val=$(grep -E "^${key}=" "$SCRIPT_DIR/.env" 2>/dev/null | tail -1 | cut -d'=' -f2- || true)
|
||||||
if [[ -n "$val" ]]; then
|
if [[ -n "$val" ]]; then
|
||||||
# Strip surrounding quotes
|
# Strip surrounding quotes
|
||||||
val="${val#\"}"
|
val="${val#\"}"
|
||||||
@@ -751,9 +751,26 @@ install_acarsdec_from_source_macos() {
|
|||||||
|
|
||||||
cd "$tmp_dir/acarsdec"
|
cd "$tmp_dir/acarsdec"
|
||||||
|
|
||||||
|
# Replace deprecated -Ofast (all macOS, not just arm64)
|
||||||
|
if grep -q '\-Ofast' CMakeLists.txt 2>/dev/null; then
|
||||||
|
sed -i '' 's/-Ofast/-O3 -ffast-math/g' CMakeLists.txt
|
||||||
|
info "Patched deprecated -Ofast flag"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# macOS doesn't have -march=native on arm64
|
||||||
if [[ "$(uname -m)" == "arm64" ]]; then
|
if [[ "$(uname -m)" == "arm64" ]]; then
|
||||||
sed -i '' 's/-Ofast -march=native/-O3 -ffast-math/g' CMakeLists.txt
|
sed -i '' 's/ -march=native//g' CMakeLists.txt
|
||||||
info "Patched compiler flags for Apple Silicon (arm64)"
|
info "Removed -march=native for Apple Silicon"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# HOST_NAME_MAX is Linux-specific; macOS uses _POSIX_HOST_NAME_MAX
|
||||||
|
if grep -q 'HOST_NAME_MAX' acarsdec.c 2>/dev/null; then
|
||||||
|
sed -i '' '1i\
|
||||||
|
#ifndef HOST_NAME_MAX\
|
||||||
|
#define HOST_NAME_MAX 255\
|
||||||
|
#endif
|
||||||
|
' acarsdec.c
|
||||||
|
info "Patched HOST_NAME_MAX for macOS compatibility"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if grep -q 'pthread_tryjoin_np' rtl.c 2>/dev/null; then
|
if grep -q 'pthread_tryjoin_np' rtl.c 2>/dev/null; then
|
||||||
@@ -957,8 +974,14 @@ install_satdump_from_source_debian() {
|
|||||||
) &
|
) &
|
||||||
progress_pid=$!
|
progress_pid=$!
|
||||||
|
|
||||||
|
local arch_flags=""
|
||||||
|
if [[ "$(uname -m)" == "x86_64" ]]; then
|
||||||
|
arch_flags="-march=x86-64"
|
||||||
|
fi
|
||||||
|
|
||||||
if cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF -DCMAKE_INSTALL_LIBDIR=lib \
|
if cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF -DCMAKE_INSTALL_LIBDIR=lib \
|
||||||
-DCMAKE_CXX_FLAGS="-Wno-template-body" .. >"$build_log" 2>&1 \
|
-DCMAKE_C_FLAGS="$arch_flags" \
|
||||||
|
-DCMAKE_CXX_FLAGS="$arch_flags -Wno-template-body" .. >"$build_log" 2>&1 \
|
||||||
&& make -j "$(nproc)" >>"$build_log" 2>&1; then
|
&& make -j "$(nproc)" >>"$build_log" 2>&1; then
|
||||||
kill $progress_pid 2>/dev/null; wait $progress_pid 2>/dev/null
|
kill $progress_pid 2>/dev/null; wait $progress_pid 2>/dev/null
|
||||||
$SUDO make install >/dev/null 2>&1
|
$SUDO make install >/dev/null 2>&1
|
||||||
@@ -1485,9 +1508,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1917,7 +1941,18 @@ do_health_check() {
|
|||||||
info "SDR device detection..."
|
info "SDR device detection..."
|
||||||
if cmd_exists rtl_test; then
|
if cmd_exists rtl_test; then
|
||||||
local rtl_output
|
local rtl_output
|
||||||
rtl_output=$(timeout 3 rtl_test -d 0 2>&1 || true)
|
if cmd_exists timeout; then
|
||||||
|
rtl_output=$(timeout 3 rtl_test -d 0 2>&1 || true)
|
||||||
|
elif cmd_exists gtimeout; then
|
||||||
|
rtl_output=$(gtimeout 3 rtl_test -d 0 2>&1 || true)
|
||||||
|
else
|
||||||
|
# No timeout command (common on macOS) — run with background kill
|
||||||
|
rtl_test -d 0 > /tmp/.rtl_test_out 2>&1 & local rtl_pid=$!
|
||||||
|
sleep 2
|
||||||
|
kill "$rtl_pid" 2>/dev/null; wait "$rtl_pid" 2>/dev/null
|
||||||
|
rtl_output=$(cat /tmp/.rtl_test_out 2>/dev/null || true)
|
||||||
|
rm -f /tmp/.rtl_test_out
|
||||||
|
fi
|
||||||
if echo "$rtl_output" | grep -q "Found\|Using device"; then
|
if echo "$rtl_output" | grep -q "Found\|Using device"; then
|
||||||
ok "RTL-SDR device detected"
|
ok "RTL-SDR device detected"
|
||||||
((pass++)) || true
|
((pass++)) || true
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
+355
-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;
|
||||||
@@ -87,6 +87,25 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Branded "i" — inline SVG that matches the logo icon.
|
||||||
|
Sized to 0.9em so it sits naturally alongside text at any font-size.
|
||||||
|
Uses .logo .brand-i (0,2,0) to beat .logo span (0,1,1) in dashboard CSS
|
||||||
|
which otherwise forces display:inline and breaks width/height. */
|
||||||
|
.brand-i,
|
||||||
|
.logo .brand-i {
|
||||||
|
display: inline-block;
|
||||||
|
width: 0.55em;
|
||||||
|
height: 0.9em;
|
||||||
|
vertical-align: baseline;
|
||||||
|
position: relative;
|
||||||
|
top: 0.05em;
|
||||||
|
}
|
||||||
|
.brand-i svg {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.app-logo-tagline {
|
.app-logo-tagline {
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
@@ -129,29 +148,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 +221,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 +318,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 +415,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 +466,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 +514,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 +657,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 +706,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 +717,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 +747,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 +769,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 +800,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 +843,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 +854,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 +870,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 +893,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 +901,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 +969,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 +985,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 +1037,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 +1049,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;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user