mirror of
https://github.com/smittix/intercept.git
synced 2026-06-13 00:03:33 -07:00
Compare commits
293 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b9c4ebebd | |||
| 7ed039564b | |||
| 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 | |||
| ab033b35d3 | |||
| e383575c80 | |||
| fd12d11fab | |||
| 0fbb446209 | |||
| 4ea64bd7ef | |||
| 7d9a220230 | |||
| 0afa15bb16 | |||
| d66ab01d34 | |||
| 91989a0216 | |||
| 7b4ad20805 | |||
| a1b0616ee6 | |||
| a146a21285 | |||
| 87a5715f30 | |||
| 52a28167c9 | |||
| 1403d49049 | |||
| 9090b415cc | |||
| 3f1606c38f | |||
| 18db66bce3 | |||
| 10077eee60 | |||
| 14568f8cc7 | |||
| 93fb694e25 | |||
| cde24642ac | |||
| b4757b1589 | |||
| f771100a4c | |||
| 0c3ccac21c | |||
| 4c282bb055 | |||
| 4741124d94 | |||
| 9afd99bf7c | |||
| fef54e5276 | |||
| f62c9871c4 | |||
| 0e03b84260 | |||
| f73f3466fd | |||
| 8d91c200a5 | |||
| 9bf75a069e | |||
| ec62cd9083 | |||
| 302b150c36 | |||
| cf022ed1c0 | |||
| d3326409bf | |||
| 3de5e68e68 | |||
| 325dafacbc | |||
| 2f5f429e83 | |||
| fb4482fac7 | |||
| 32f04d4ed8 | |||
| 38644bced6 | |||
| f3d475d53a | |||
| 195c224189 | |||
| f07ec23da9 | |||
| 4b64862eb4 | |||
| eea44f9a6b | |||
| de3f972aa2 | |||
| 6a334c61df | |||
| 63994ec1d4 | |||
| 6011d6fb41 | |||
| 845629ea46 | |||
| 7311dd10ab | |||
| e2e92b6b38 | |||
| 5534493bd1 | |||
| 86fa6326e9 | |||
| be70d2e43b | |||
| e89a0ef486 | |||
| bcf447fe4e | |||
| 90b455aa6c | |||
| f8e5d61fa9 | |||
| bd67195238 | |||
| d78ab5cc2c | |||
| d087780d9f | |||
| 8379f42ec3 | |||
| ff9961b846 | |||
| 5e99d19165 | |||
| 0df412c014 | |||
| e756a00cc9 | |||
| c35131462e | |||
| bad637591a | |||
| 910b69594d | |||
| a154601e86 | |||
| bdeb32e723 | |||
| 2de592f798 | |||
| b5c3d71247 | |||
| c1339b6c65 | |||
| 153aacba03 | |||
| bcbadac995 | |||
| a6e62f4674 | |||
| 77255e015d | |||
| 6cbe94cf20 | |||
| cdf10e1d6a | |||
| a22244a041 | |||
| 9371fccd62 | |||
| b4b6fdc0fc | |||
| 9e3fcb8edd | |||
| 2c7909e502 | |||
| 003c4d62cf | |||
| 10e4804e0a | |||
| 05edfb93dc | |||
| e5006a9896 | |||
| 7d1fcfe895 | |||
| c6e8602184 | |||
| 29873fb3c0 | |||
| 4f096c6c01 | |||
| 9e911e845f | |||
| 377519fd95 | |||
| fb064a22fb | |||
| 7af6d45ca1 | |||
| 54987e4c8d | |||
| 7683a925df | |||
| 824514d922 | |||
| 79a0dae04b | |||
| e176438934 | |||
| 3254d82d11 | |||
| 24d50c921e | |||
| db2f3fc8e5 | |||
| 952736c127 | |||
| 997dac3b9f | |||
| 3f6fa5ba28 | |||
| 5b06c57565 | |||
| 5aa68a49c6 | |||
| 0d13638d70 | |||
| f9dc54cc3b | |||
| f679433ac0 | |||
| 4b31474080 | |||
| f72b43c6bf | |||
| 0a90010c1f | |||
| 81a8f24e27 | |||
| 4712616339 | |||
| 1cfeb193c7 | |||
| 69b402f872 | |||
| deb7e2d15d | |||
| 645b3b8632 | |||
| ee81eb44cd | |||
| fd3552e725 | |||
| 818d9c9f90 | |||
| dc0775f7df | |||
| c0fb22124b | |||
| 97b10b3ac9 | |||
| be522d4dfe | |||
| 33a360b483 | |||
| 2e1b9b27be | |||
| d6fe1123b4 | |||
| 5fcfa2f72f | |||
| 24d1777e63 | |||
| 794dd693cf | |||
| 0cadf07985 | |||
| bb263ce1b0 | |||
| 23d592af1d | |||
| ababa63856 | |||
| fdffb8e88e | |||
| 98642e43c7 | |||
| 8cb7edf41e | |||
| 64f0e687a0 | |||
| 6a54bc8cf3 | |||
| b32d30b789 | |||
| d3b737c19b | |||
| 146bca4b37 | |||
| e3cf9daaed | |||
| 81e5f5479f | |||
| a5eefc712a | |||
| a50d200af4 | |||
| 99db7f1faf | |||
| 4560ec1800 | |||
| d92146d678 | |||
| 70e4bc557b | |||
| c1dd615e11 | |||
| 63cc1647fb | |||
| d9228fb05a | |||
| 806bc1397a | |||
| 7560691fbb | |||
| 8eb4ff41e2 | |||
| 286ab53d26 | |||
| 5d90c308a9 | |||
| 9622a00ea1 | |||
| 7c9ef9b895 | |||
| bfae73cabf | |||
| c0c066904c | |||
| 2eea28da05 | |||
| df84c42b8b | |||
| 860db12200 | |||
| 0bf8341b6c | |||
| 2ec458aa14 | |||
| 5f583e5718 | |||
| deea80e32c | |||
| 37f0197f9a | |||
| dc7c05b03f | |||
| 8a46293e5c | |||
| 935b7a4d9d | |||
| a50f77629c | |||
| ecdc060d81 | |||
| ee9356c358 | |||
| 7fdf162f1e | |||
| 56514a839f | |||
| dbf76a4e84 | |||
| 3f7430d114 | |||
| f3158cbb69 | |||
| 2202e3ed98 | |||
| 844e57e239 | |||
| 5b6df923fc | |||
| 9724ec57f9 | |||
| 2d92243341 | |||
| 6ec15461af | |||
| 2c76039f2c | |||
| c4bde6c707 | |||
| 6384e39576 | |||
| 5edfe1797c | |||
| 4bf452d462 | |||
| f6b0edaf5a | |||
| 18efed891a | |||
| 60a3ae225f | |||
| afd3d34f43 | |||
| 0344862a0c | |||
| 43e6d4a1b8 | |||
| 53c65febed | |||
| cec8bccb03 | |||
| 6c20b3d23f | |||
| 53f54af871 | |||
| caa4357870 | |||
| 3e608c62a0 | |||
| 0afa25e57c | |||
| b3af44652f | |||
| 67321adade | |||
| 6894e626a9 | |||
| 9745215038 | |||
| b72a2f1092 | |||
| 2da8dca167 | |||
| 085a6177f9 | |||
| 01abcac8f2 | |||
| 2a5f537381 | |||
| 07b5b72878 | |||
| 1a1a398962 | |||
| b7d90e8e5e | |||
| 55c38522a4 | |||
| d9b528f3d3 | |||
| 9cd7f1c0c8 | |||
| a350c82893 | |||
| 6a690abf82 | |||
| e19a990b64 | |||
| 975a95e1b0 | |||
| 2af238aed5 | |||
| e81a409234 | |||
| 1c76671ed7 | |||
| 9ece4d658d | |||
| 739b0b136e | |||
| 199ff4b47c | |||
| 65e5552c7d | |||
| a5452fa1b1 | |||
| 889c08691f | |||
| 0a4a0689a0 | |||
| 0daee74cf0 | |||
| 2e6bb8882f | |||
| 365333d425 | |||
| 367048e853 | |||
| 406ca28304 | |||
| f889c53d92 | |||
| b0af1d16d2 | |||
| 4e67b77714 | |||
| b1993847b5 | |||
| cde79f4619 | |||
| cc271819ad | |||
| 8cd64ce3ca | |||
| 1c681b6777 | |||
| ab064b4c91 | |||
| 26ecd3dd93 | |||
| c2405bfe14 | |||
| 01409cfdea | |||
| 130f58d9cc | |||
| 15d5cb2272 | |||
| d28d8cb9ef |
@@ -0,0 +1,42 @@
|
||||
## Workflow Orchestration
|
||||
### 1. Plan Node Default
|
||||
- Enter plan mode for ANY non-trivial task (3+ steps or architectural decisions)
|
||||
- If something goes sideways, STOP and re-plan immediately - don't keep pushing
|
||||
- Use plan mode for verification steps, not just building
|
||||
- Write detailed specs upfront to reduce ambiguity
|
||||
### 2. Subagent Strategy
|
||||
- Use subagents liberally to keep main context window clean
|
||||
- Offload research, exploration, and parallel analysis to subagents
|
||||
- For complex problems, throw more compute at it via subagents
|
||||
- One tack per subagent for focused execution
|
||||
### 3. Self-Improvement Loop
|
||||
- After ANY correction from the user: update 'tasks/lessons.md" with the pattern
|
||||
- Write rules for yourself that prevent the same mistake
|
||||
- Ruthlessly iterate on these lessons until mistake rate drops
|
||||
- Review lessons at session start for relevant project
|
||||
### 4. Verification Before Done
|
||||
- Never mark a task complete without proving it works
|
||||
- Diff behavior between main and your changes when relevant
|
||||
- Ask yourself: "Would a staff engineer approve this?"
|
||||
- Run tests, check logs, demonstrate correctness
|
||||
### 5. Demand Elegance (Balanced)
|
||||
- For non-trivial changes: pause and ask "is there a more elegant way?"
|
||||
- If a fix feels hacky: "Knowing everything I know now, implement the elegant solution"
|
||||
- Skip this for simple, obvious fixes - don't over-engineer
|
||||
-Challenge your own work before presenting it
|
||||
### 6. Autonomous Bug Fizing
|
||||
- When given a bug report: just fix it. Don't ask for hand-holding
|
||||
- Point at logs, errors, failing tests - then resolve them
|
||||
- Zero context switching required from the user
|
||||
- Go fix failing CI tests without being told how
|
||||
## Task Management
|
||||
1. **Plan First**: Write plan to "tasks/todo.md" with checkable items
|
||||
2. **Verify Plan**: Check in before starting implementation
|
||||
3. **Track Progress**: Mark items complete as you go
|
||||
4. **Explain Changes**: High-level summary at each step
|
||||
5. **Document Results**: Add review section to 'tasks/todo.md"
|
||||
6. **Capture Lessons**: Update 'tasks/lessons.md' after corrections
|
||||
## Core Principles
|
||||
- **Simplicity First**: Make every change as simple as possible. Impact minimal code.
|
||||
- **No Laziness**: Find root causes. No temporary fixes. Senior developer standards.
|
||||
- **Minimat Impact**: Changes should only touch what's necessary. Avoid introducing bugs.
|
||||
+20
-1
@@ -1,6 +1,8 @@
|
||||
# Git
|
||||
# Git & CI
|
||||
.git
|
||||
.gitignore
|
||||
.github
|
||||
.claude
|
||||
|
||||
# Python
|
||||
__pycache__
|
||||
@@ -29,6 +31,23 @@ tests/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.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
|
||||
*.log
|
||||
|
||||
+39
-2
@@ -1,2 +1,39 @@
|
||||
# Uncomment and set to use external storage for ADS-B history
|
||||
# PGDATA_PATH=/mnt/external/intercept/pgdata
|
||||
# =============================================================================
|
||||
# INTERCEPT CONTROLLER (.env)
|
||||
# =============================================================================
|
||||
# Copy to .env and edit for your setup
|
||||
|
||||
# Container timezone (e.g. America/New_York, Europe/London, Australia/Sydney)
|
||||
TZ=UTC
|
||||
|
||||
# Flask secret key (auto-generated if not set)
|
||||
# INTERCEPT_SECRET_KEY=your-secret-key-here
|
||||
|
||||
# Admin credentials (password auto-generated on first run if not set)
|
||||
# INTERCEPT_ADMIN_USERNAME=admin
|
||||
# INTERCEPT_ADMIN_PASSWORD=your-password-here
|
||||
|
||||
# Postgres password (default: intercept)
|
||||
INTERCEPT_ADSB_DB_PASSWORD=intercept
|
||||
|
||||
# Auto-start ADS-B when dashboard loads
|
||||
INTERCEPT_ADSB_AUTO_START=false
|
||||
|
||||
# Share observer location across all modules
|
||||
INTERCEPT_SHARED_OBSERVER_LOCATION=true
|
||||
|
||||
# Observer coordinates (uncomment and set to skip GPS prompt)
|
||||
# INTERCEPT_DEFAULT_LAT=40.7128
|
||||
# INTERCEPT_DEFAULT_LON=-74.0060
|
||||
|
||||
# =============================================================================
|
||||
# AGENT SETTINGS (for docker-compose.agent.yml on remote Pis)
|
||||
# =============================================================================
|
||||
|
||||
# Agent identity
|
||||
AGENT_NAME=sdr-agent-1
|
||||
AGENT_PORT=8020
|
||||
|
||||
# Controller connection (IP of the machine running docker-compose.yml)
|
||||
CONTROLLER_URL=http://192.168.1.100:5050
|
||||
AGENT_API_KEY=changeme
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Force LF line endings for files that must run on Linux (Docker)
|
||||
*.sh text eol=lf
|
||||
Dockerfile text eol=lf
|
||||
@@ -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
|
||||
+6
-4
@@ -18,10 +18,6 @@ pager_messages.log
|
||||
downloads/
|
||||
pgdata/
|
||||
|
||||
# Local data
|
||||
downloads/
|
||||
pgdata/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
@@ -58,6 +54,9 @@ intercept_agent_*.cfg
|
||||
# Weather satellite runtime data (decoded images, samples, SatDump output)
|
||||
data/weather_sat/
|
||||
|
||||
# Radiosonde runtime data (station config, logs)
|
||||
data/radiosonde/
|
||||
|
||||
# SDR capture files (large IQ recordings)
|
||||
data/subghz/captures/
|
||||
|
||||
@@ -65,3 +64,6 @@ data/subghz/captures/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Local utility scripts
|
||||
reset-sdr.*
|
||||
|
||||
+178
-1
@@ -2,13 +2,190 @@
|
||||
|
||||
All notable changes to iNTERCEPT will be documented in this file.
|
||||
|
||||
## [2.26.11] - 2026-03-14
|
||||
|
||||
### Fixed
|
||||
- **APRS map ignores configured observer position** — The APRS map always fell back to the centre of the US (39.8°N, 98.6°W) when no live GPS fix was available, ignoring the observer position configured in `.env` (`INTERCEPT_DEFAULT_LAT` / `INTERCEPT_DEFAULT_LON`). Now seeds the APRS user location from the shared observer location on page load, so the map centres correctly and distance calculations work. (#193)
|
||||
|
||||
---
|
||||
|
||||
## [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
|
||||
|
||||
### Added
|
||||
- **WiFi Locate Mode** - Locate WiFi access points by BSSID with real-time signal meter, distance estimation, RSSI chart, and audio proximity tones. Hand-off from WiFi detail drawer, environment presets (Free Space/Outdoor/Indoor), and signal-lost detection.
|
||||
|
||||
### Changed
|
||||
- Mobile navigation bar reorganized into labeled groups (SIG, TRK, SPC, WIFI, INTEL, SYS) for better usability
|
||||
- flask-limiter made optional — rate limiting degrades gracefully if package is missing
|
||||
|
||||
### Fixed
|
||||
- Radiosonde setup missing `semver` Python dependency — `setup.sh` now explicitly installs it alongside `requirements.txt`
|
||||
|
||||
## [2.23.0] - 2026-02-27
|
||||
|
||||
### Added
|
||||
- **Radiosonde Weather Balloon Tracking** - 400-406 MHz reception via radiosonde_auto_rx with telemetry, map, and station distance tracking
|
||||
- **CW/Morse Code Decoder** - Custom Goertzel tone detection with OOK/AM envelope detection mode for ISM bands
|
||||
- **WeFax (Weather Fax) Decoder** - HF weather fax reception with auto-scheduler, broadcast timeline, and image gallery
|
||||
- **System Health Monitoring** - Telemetry dashboard with process monitoring and system metrics
|
||||
- **HTTPS Support** - TLS via `INTERCEPT_HTTPS` configuration
|
||||
- **ADS-B Voice Alerts** - Text-to-speech notifications for military and emergency aircraft detections
|
||||
- **HackRF TSCM RF Scan** - HackRF support added to TSCM counter-surveillance RF sweep
|
||||
- **Multi-SDR WeFax** - Multiple SDR hardware support for WeFax decoder
|
||||
- **Tool Path Overrides** - `INTERCEPT_*_PATH` environment variables for custom tool locations
|
||||
- **Homebrew Tool Detection** - Native path detection for Apple Silicon Homebrew installations
|
||||
- **Production Server** - `start.sh` with gunicorn + gevent for concurrent SSE/WebSocket handling — eliminates multi-client page load delays
|
||||
|
||||
### Changed
|
||||
- Morse decoder rebuilt with custom Goertzel decoder, replacing multimon-ng dependency
|
||||
- GPS mode upgraded to textured 3D globe visualization
|
||||
- Destroy lifecycle added to all mode modules to prevent resource leaks
|
||||
- Docker container now uses gunicorn + gevent by default via `start.sh`
|
||||
|
||||
### Fixed
|
||||
- ADS-B device release leak and startup performance regression
|
||||
- ADS-B probe incorrectly treating "No devices found" as success
|
||||
- USB claim race condition after SDR probe
|
||||
- SDR device registry collision when multiple SDR types present
|
||||
- APRS 15-minute startup delay caused by pipe buffering
|
||||
- APRS map centering at [0,0] when GPS unavailable
|
||||
- DSC decoder ITU-R M.493 compliance issues
|
||||
- Weather satellite 0dB SNR — increased sample rate for Meteor LRPT
|
||||
- SSE fanout backlog causing delayed updates across all modes
|
||||
- SSE reconnect packet loss during client reconnection
|
||||
- Waterfall monitor tuning race conditions
|
||||
- Mode FOUC (flash of unstyled content) on initial navigation
|
||||
- Various Morse decoder stability and lifecycle fixes
|
||||
|
||||
---
|
||||
|
||||
## [2.22.3] - 2026-02-23
|
||||
|
||||
### Fixed
|
||||
- Waterfall control panel rendered as unstyled text for up to 20 seconds on first visit — CSS is now loaded eagerly with the rest of the page assets
|
||||
- WebSDR globe failed to render on first page load — initialization now waits for a layout frame before mounting the WebGL renderer, ensuring the container has non-zero dimensions
|
||||
- Waterfall monitor audio took minutes to start — `_waitForPlayback` now only reports success on actual audio playback (`playing`/`timeupdate`), not from the WAV header alone (`loadeddata`/`canplay`)
|
||||
- Waterfall monitor could not be stopped — `stopMonitor()` now pauses audio and updates the UI immediately instead of waiting for the backend stop request (which blocked for 1+ seconds during SDR process cleanup)
|
||||
- Stopping the waterfall no longer shows a stale "WebSocket closed before ready" message — the `onclose` handler now detects intentional closes
|
||||
|
||||
---
|
||||
|
||||
## [2.22.1] - 2026-02-23
|
||||
|
||||
### Fixed
|
||||
- PWA install prompt not appearing — manifest now includes required PNG icons (192×192, 512×512)
|
||||
- Apple touch icon updated to PNG for iOS Safari compatibility
|
||||
- Service worker cache bumped to bust stale cached assets
|
||||
|
||||
---
|
||||
|
||||
## [2.22.0] - 2026-02-23
|
||||
|
||||
### Added
|
||||
- **Waterfall Receiver Overhaul** - WebSocket-based I/Q streaming with server-side FFT, click-to-tune, zoom controls, and auto-scaling
|
||||
- **Voice Alerts** - Configurable text-to-speech event notifications across modes
|
||||
- **Signal Fingerprinting** - RF device identification and pattern analysis mode
|
||||
- **RF Heatmap** - Geographic signal density visualization with Leaflet heatmap overlay
|
||||
- **SignalID** - Automatic signal classification via SigIDWiki API integration
|
||||
- **PWA Support** - Installable web app with service worker caching and manifest
|
||||
- **Real-time Signal Scope** - Live signal visualization for pager, sensor, and SSTV modes
|
||||
|
||||
@@ -25,15 +25,25 @@ docker compose --profile basic up -d --build
|
||||
|
||||
### Local Setup (Alternative)
|
||||
```bash
|
||||
# Initial setup (installs dependencies and configures SDR tools)
|
||||
# First-time setup (interactive wizard with install profiles)
|
||||
./setup.sh
|
||||
|
||||
# Run the application (requires sudo for SDR/network access)
|
||||
# Or headless full install
|
||||
./setup.sh --non-interactive
|
||||
|
||||
# Or install specific profiles
|
||||
./setup.sh --profile=core,weather
|
||||
|
||||
# Run with production server (gunicorn + gevent, handles concurrent SSE/WebSocket)
|
||||
sudo ./start.sh
|
||||
|
||||
# Or for quick local dev (Flask dev server)
|
||||
sudo -E venv/bin/python intercept.py
|
||||
|
||||
# Or activate venv first
|
||||
source venv/bin/activate
|
||||
sudo -E python intercept.py
|
||||
# Other setup utilities
|
||||
./setup.sh --health-check # Verify installation
|
||||
./setup.sh --postgres-setup # Set up ADS-B history database
|
||||
./setup.sh --menu # Force interactive menu
|
||||
```
|
||||
|
||||
### Testing
|
||||
@@ -69,8 +79,10 @@ mypy .
|
||||
## Architecture
|
||||
|
||||
### Entry Points
|
||||
- `intercept.py` - Main entry point script
|
||||
- `app.py` - Flask application initialization, global state management, process lifecycle, SSE streaming infrastructure
|
||||
- `setup.sh` - Menu-driven installer with profile system (wizard, health check, PostgreSQL setup, env configurator, update, uninstall). Sources `.env` on startup via `start.sh`.
|
||||
- `start.sh` - Production startup script (gunicorn + gevent auto-detection, CLI flags, HTTPS, `.env` sourcing, fallback to Flask dev server)
|
||||
- `intercept.py` - Direct Flask dev server entry point (quick local development)
|
||||
- `app.py` - Flask application initialization, global state management, process lifecycle, SSE streaming infrastructure, conditional gevent monkey-patch
|
||||
|
||||
### Route Blueprints (routes/)
|
||||
Each signal type has its own Flask blueprint:
|
||||
@@ -121,7 +133,7 @@ Each signal type has its own Flask blueprint:
|
||||
|
||||
### Key Patterns
|
||||
|
||||
**Server-Sent Events (SSE)**: All real-time features stream via SSE endpoints (`/stream_pager`, `/stream_sensor`, etc.). Pattern uses `queue.Queue` with timeout and keepalive messages.
|
||||
**Server-Sent Events (SSE)**: All real-time features stream via SSE endpoints (`/stream_pager`, `/stream_sensor`, etc.). Pattern uses `queue.Queue` with timeout and keepalive messages. Under gunicorn + gevent, each SSE connection is a lightweight greenlet instead of an OS thread.
|
||||
|
||||
**Process Management**: External decoders run as subprocesses with output threads feeding queues. Use `safe_terminate()` for cleanup. Global locks prevent race conditions.
|
||||
|
||||
@@ -152,7 +164,7 @@ Each signal type has its own Flask blueprint:
|
||||
- **Mode Integration**: Each mode needs entries in `index.html` at ~12 points: CSS include, welcome card, partial include, visuals container, JS include, `validModes` set, `modeGroups` map, classList toggle, `modeNames`, visuals display toggle, titles, and init call in `switchMode()`
|
||||
|
||||
### Docker
|
||||
- `Dockerfile` - Single-stage build with all SDR tools compiled from source (dump1090, AIS-catcher, slowrx, SatDump, etc.)
|
||||
- `Dockerfile` - Single-stage build with all SDR tools compiled from source (dump1090, AIS-catcher, slowrx, SatDump, etc.). CMD runs `start.sh` (gunicorn + gevent)
|
||||
- `docker-compose.yml` - Two profiles: `basic` (standalone) and `history` (with Postgres for ADS-B)
|
||||
- `build-multiarch.sh` - Multi-arch build script for amd64 + arm64 (RPi5)
|
||||
- Data persisted via `./data:/app/data` volume mount
|
||||
|
||||
+209
-181
@@ -1,6 +1,197 @@
|
||||
# INTERCEPT - Signal Intelligence Platform
|
||||
# Docker container for running the web interface
|
||||
# Multi-stage build: builder compiles tools, runtime keeps only what's needed
|
||||
|
||||
###############################################################################
|
||||
# Stage 1: Builder — compile all tools from source
|
||||
###############################################################################
|
||||
FROM python:3.11-slim AS builder
|
||||
|
||||
WORKDIR /tmp/build
|
||||
|
||||
# Install ALL build dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
git \
|
||||
pkg-config \
|
||||
cmake \
|
||||
librtlsdr-dev \
|
||||
libusb-1.0-0-dev \
|
||||
libncurses-dev \
|
||||
libsndfile1-dev \
|
||||
libgtk-3-dev \
|
||||
libasound2-dev \
|
||||
libsoapysdr-dev \
|
||||
libhackrf-dev \
|
||||
liblimesuite-dev \
|
||||
libfftw3-dev \
|
||||
libpng-dev \
|
||||
libtiff-dev \
|
||||
libjemalloc-dev \
|
||||
libvolk-dev \
|
||||
libnng-dev \
|
||||
libzstd-dev \
|
||||
libsqlite3-dev \
|
||||
libcurl4-openssl-dev \
|
||||
zlib1g-dev \
|
||||
libzmq3-dev \
|
||||
libpulse-dev \
|
||||
libfftw3-bin \
|
||||
liblapack-dev \
|
||||
libglib2.0-dev \
|
||||
libxml2-dev \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create staging directory for all built artifacts
|
||||
RUN mkdir -p /staging/usr/bin /staging/usr/local/bin /staging/usr/local/lib /staging/opt
|
||||
|
||||
# Build dump1090
|
||||
RUN cd /tmp \
|
||||
&& git clone --depth 1 https://github.com/flightaware/dump1090.git \
|
||||
&& cd dump1090 \
|
||||
&& sed -i 's/-Werror//g' Makefile \
|
||||
&& make BLADERF=no RTLSDR=yes \
|
||||
&& cp dump1090 /staging/usr/bin/dump1090-fa \
|
||||
&& ln -s /usr/bin/dump1090-fa /staging/usr/bin/dump1090 \
|
||||
&& rm -rf /tmp/dump1090
|
||||
|
||||
# Build AIS-catcher
|
||||
RUN cd /tmp \
|
||||
&& git clone https://github.com/jvde-github/AIS-catcher.git \
|
||||
&& cd AIS-catcher \
|
||||
&& mkdir build && cd build \
|
||||
&& cmake .. \
|
||||
&& make \
|
||||
&& cp AIS-catcher /staging/usr/bin/AIS-catcher \
|
||||
&& rm -rf /tmp/AIS-catcher
|
||||
|
||||
# Build readsb
|
||||
RUN cd /tmp \
|
||||
&& git clone --depth 1 https://github.com/wiedehopf/readsb.git \
|
||||
&& cd readsb \
|
||||
&& make BLADERF=no PLUTOSDR=no SOAPYSDR=yes \
|
||||
&& cp readsb /staging/usr/bin/readsb \
|
||||
&& rm -rf /tmp/readsb
|
||||
|
||||
# Build rx_tools
|
||||
RUN cd /tmp \
|
||||
&& git clone https://github.com/rxseger/rx_tools.git \
|
||||
&& cd rx_tools \
|
||||
&& mkdir build && cd build \
|
||||
&& cmake .. \
|
||||
&& make \
|
||||
&& DESTDIR=/staging make install \
|
||||
&& rm -rf /tmp/rx_tools
|
||||
|
||||
# Build acarsdec
|
||||
RUN cd /tmp \
|
||||
&& git clone --depth 1 https://github.com/TLeconte/acarsdec.git \
|
||||
&& cd acarsdec \
|
||||
&& mkdir build && cd build \
|
||||
&& cmake .. -Drtl=ON -DCMAKE_POLICY_VERSION_MINIMUM=3.5 \
|
||||
&& make \
|
||||
&& cp acarsdec /staging/usr/bin/acarsdec \
|
||||
&& rm -rf /tmp/acarsdec
|
||||
|
||||
# Build libacars (required by dumpvdl2)
|
||||
RUN cd /tmp \
|
||||
&& git clone --depth 1 https://github.com/szpajder/libacars.git \
|
||||
&& cd libacars \
|
||||
&& mkdir build && cd build \
|
||||
&& cmake .. \
|
||||
&& make \
|
||||
&& make install \
|
||||
&& ldconfig \
|
||||
&& cp -a /usr/local/lib/libacars* /staging/usr/local/lib/ \
|
||||
&& rm -rf /tmp/libacars
|
||||
|
||||
# Build dumpvdl2 (VDL2 aircraft datalink decoder)
|
||||
RUN cd /tmp \
|
||||
&& git clone --depth 1 https://github.com/szpajder/dumpvdl2.git \
|
||||
&& cd dumpvdl2 \
|
||||
&& mkdir build && cd build \
|
||||
&& cmake .. \
|
||||
&& make \
|
||||
&& cp src/dumpvdl2 /staging/usr/bin/dumpvdl2 \
|
||||
&& rm -rf /tmp/dumpvdl2
|
||||
|
||||
# Build slowrx (SSTV decoder) — pinned to known-good commit
|
||||
RUN cd /tmp \
|
||||
&& git clone https://github.com/windytan/slowrx.git \
|
||||
&& cd slowrx \
|
||||
&& git checkout ca6d7012 \
|
||||
&& make \
|
||||
&& install -m 0755 slowrx /staging/usr/local/bin/slowrx \
|
||||
&& rm -rf /tmp/slowrx
|
||||
|
||||
# Build SatDump (weather satellite decoder - NOAA APT & Meteor LRPT) — pinned to v1.2.2
|
||||
RUN cd /tmp \
|
||||
&& git clone --depth 1 --branch 1.2.2 https://github.com/SatDump/SatDump.git \
|
||||
&& cd SatDump \
|
||||
&& mkdir build && cd build \
|
||||
&& 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
|
||||
|
||||
LABEL maintainer="INTERCEPT Project"
|
||||
@@ -12,12 +203,10 @@ WORKDIR /app
|
||||
# Pre-accept tshark non-root capture prompt for non-interactive install
|
||||
RUN echo 'wireshark-common wireshark-common/install-setuid boolean true' | debconf-set-selections
|
||||
|
||||
# Install system dependencies for SDR tools
|
||||
# Install ONLY runtime dependencies (no -dev packages, no build tools)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# RTL-SDR tools
|
||||
rtl-sdr \
|
||||
librtlsdr-dev \
|
||||
libusb-1.0-0-dev \
|
||||
# 433MHz decoder
|
||||
rtl-433 \
|
||||
# Pager decoder
|
||||
@@ -43,7 +232,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# GPS support
|
||||
gpsd \
|
||||
gpsd-clients \
|
||||
# Utilities
|
||||
# APRS
|
||||
direwolf \
|
||||
# WiFi Extra
|
||||
@@ -62,181 +250,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
procps \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Build dump1090-fa and acarsdec from source (packages not available in slim repos)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
git \
|
||||
pkg-config \
|
||||
cmake \
|
||||
libncurses-dev \
|
||||
libsndfile1-dev \
|
||||
# GTK is required for slowrx (SSTV decoder GUI dependency).
|
||||
# Note: slowrx is kept for backwards compatibility, but the pure Python
|
||||
# SSTV decoder in utils/sstv/ is now the primary implementation.
|
||||
# GTK can be removed if slowrx is deprecated in future releases.
|
||||
libgtk-3-dev \
|
||||
libasound2-dev \
|
||||
libsoapysdr-dev \
|
||||
libhackrf-dev \
|
||||
liblimesuite-dev \
|
||||
libfftw3-dev \
|
||||
libpng-dev \
|
||||
libtiff-dev \
|
||||
libjemalloc-dev \
|
||||
libvolk-dev \
|
||||
libnng-dev \
|
||||
libzstd-dev \
|
||||
libsqlite3-dev \
|
||||
libcurl4-openssl-dev \
|
||||
zlib1g-dev \
|
||||
libzmq3-dev \
|
||||
libpulse-dev \
|
||||
libfftw3-dev \
|
||||
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 \
|
||||
# Build rtlamr (utility meter decoder - requires Go)
|
||||
&& cd /tmp \
|
||||
&& curl -fsSL "https://go.dev/dl/go1.22.5.linux-$(dpkg --print-architecture).tar.gz" | tar -C /usr/local -xz \
|
||||
&& export PATH="$PATH:/usr/local/go/bin" \
|
||||
&& export GOPATH=/tmp/gopath \
|
||||
&& go install github.com/bemasher/rtlamr@latest \
|
||||
&& cp /tmp/gopath/bin/rtlamr /usr/bin/rtlamr \
|
||||
&& rm -rf /usr/local/go /tmp/gopath \
|
||||
# Cleanup build tools to reduce image size
|
||||
# libgtk-3-dev is explicitly removed; runtime GTK libs remain for slowrx
|
||||
&& apt-get remove -y \
|
||||
build-essential \
|
||||
git \
|
||||
pkg-config \
|
||||
cmake \
|
||||
libncurses-dev \
|
||||
libsndfile1-dev \
|
||||
libgtk-3-dev \
|
||||
libasound2-dev \
|
||||
libpng-dev \
|
||||
libtiff-dev \
|
||||
libjemalloc-dev \
|
||||
libvolk-dev \
|
||||
libnng-dev \
|
||||
libzstd-dev \
|
||||
libsoapysdr-dev \
|
||||
libhackrf-dev \
|
||||
liblimesuite-dev \
|
||||
libsqlite3-dev \
|
||||
libcurl4-openssl-dev \
|
||||
zlib1g-dev \
|
||||
libzmq3-dev \
|
||||
libpulse-dev \
|
||||
libfftw3-dev \
|
||||
liblapack-dev \
|
||||
&& apt-get autoremove -y \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# Copy compiled binaries and libraries from builder stage
|
||||
COPY --from=builder /staging/usr/bin/ /usr/bin/
|
||||
COPY --from=builder /staging/usr/local/bin/ /usr/local/bin/
|
||||
COPY --from=builder /staging/usr/local/lib/ /usr/local/lib/
|
||||
COPY --from=builder /staging/opt/ /opt/
|
||||
|
||||
# Copy radiosonde Python dependencies installed during builder stage
|
||||
COPY --from=builder /usr/local/lib/python3.11/site-packages/ /usr/local/lib/python3.11/site-packages/
|
||||
|
||||
# Refresh shared library cache for custom-built libraries
|
||||
RUN ldconfig
|
||||
|
||||
# Copy requirements first for better caching
|
||||
COPY requirements.txt .
|
||||
@@ -245,11 +269,15 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Strip Windows CRLF from shell scripts (git autocrlf can re-introduce them)
|
||||
RUN find . -name '*.sh' -exec sed -i 's/\r$//' {} +
|
||||
|
||||
# Create data directory for persistence
|
||||
RUN mkdir -p /app/data /app/data/weather_sat
|
||||
RUN mkdir -p /app/data /app/data/weather_sat /app/data/radiosonde/logs
|
||||
|
||||
# Expose web interface port
|
||||
EXPOSE 5050
|
||||
EXPOSE 5443
|
||||
|
||||
# Environment variables with defaults
|
||||
ENV INTERCEPT_HOST=0.0.0.0 \
|
||||
@@ -262,4 +290,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -sf http://localhost:5050/health || exit 1
|
||||
|
||||
# Run the application
|
||||
CMD ["python", "intercept.py"]
|
||||
CMD ["/bin/bash", "start.sh"]
|
||||
|
||||
@@ -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">
|
||||
<img src="https://img.shields.io/badge/python-3.9+-blue.svg" alt="Python 3.9+">
|
||||
@@ -7,7 +9,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
Support the developer of this open-source project
|
||||
Support the developer of this open-source project
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -45,6 +47,7 @@ Support the developer of this open-source project
|
||||
- **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng
|
||||
- **Bluetooth Scanning** - Device discovery and tracker detection (with Ubertooth support)
|
||||
- **BT Locate** - SAR Bluetooth device location with GPS-tagged signal trail mapping and proximity alerts
|
||||
- **WiFi Locate** - Locate WiFi access points by BSSID with real-time signal meter, distance estimation, and proximity audio
|
||||
- **GPS** - Real-time GPS position tracking with live map, speed, altitude, and satellite info
|
||||
- **TSCM** - Counter-surveillance with RF baseline comparison and threat detection
|
||||
- **Meshtastic** - LoRa mesh network integration
|
||||
@@ -55,14 +58,85 @@ Support the developer of this open-source project
|
||||
|
||||
---
|
||||
|
||||
## Installation / Debian / Ubuntu / MacOS
|
||||
## CW / Morse Decoder Notes
|
||||
|
||||
Live backend:
|
||||
- Uses `rtl_fm` piped into `multimon-ng` (`MORSE_CW`) for real-time decode.
|
||||
|
||||
Recommended baseline settings:
|
||||
- **Tone**: `700 Hz`
|
||||
- **Bandwidth**: `200 Hz` (use `100 Hz` for crowded bands, `400 Hz` for drifting signals)
|
||||
- **Threshold Mode**: `Auto`
|
||||
- **WPM Mode**: `Auto`
|
||||
|
||||
Auto Tone Track behavior:
|
||||
- Continuously measures nearby tone energy around the configured CW pitch.
|
||||
- Steers the detector toward the strongest valid CW tone when signal-to-noise is sufficient.
|
||||
- Use **Hold Tone Lock** to freeze tracking once the desired signal is centered.
|
||||
|
||||
Troubleshooting (no decode / noisy decode):
|
||||
- Confirm demod path is **USB/CW-compatible** and frequency is tuned correctly.
|
||||
- If multiple SDRs are connected and the selected one has no PCM output, Morse startup now auto-tries other detected SDR devices and reports the active device/serial in status logs.
|
||||
- Match **tone** and **bandwidth** to the actual sidetone/pitch.
|
||||
- Try **Threshold Auto** first; if needed, switch to manual threshold and recalibrate.
|
||||
- Use **Reset/Calibrate** after major frequency or band condition changes.
|
||||
- Raise **Minimum Signal Gate** to suppress random noise keying.
|
||||
|
||||
---
|
||||
|
||||
## Installation / Debian / Ubuntu / macOS
|
||||
|
||||
### Quick Start
|
||||
|
||||
**1. Clone and run:**
|
||||
```bash
|
||||
git clone https://github.com/smittix/intercept.git
|
||||
cd intercept
|
||||
./setup.sh
|
||||
sudo -E venv/bin/python intercept.py
|
||||
./setup.sh # Interactive menu (first run launches setup wizard)
|
||||
sudo ./start.sh
|
||||
```
|
||||
|
||||
On first run, `setup.sh` launches a **guided wizard** that detects your OS, lets you choose install profiles, sets up the Python environment, and optionally configures environment variables and PostgreSQL.
|
||||
|
||||
On subsequent runs, it opens an **interactive menu**:
|
||||
|
||||
```
|
||||
INTERCEPT Setup Menu
|
||||
════════════════════════════════════════
|
||||
1) Install / Add Modules
|
||||
2) System Health Check
|
||||
3) Database Setup (ADS-B History)
|
||||
4) Update Tools
|
||||
5) Environment Configurator
|
||||
6) Uninstall / Cleanup
|
||||
7) View Status
|
||||
0) Exit
|
||||
```
|
||||
|
||||
> **Production vs Dev server:** `start.sh` auto-detects gunicorn + gevent and runs a production server with cooperative greenlets — handles multiple SSE/WebSocket clients without blocking. Falls back to Flask dev server if gunicorn is not installed. For quick local development, you can still use `sudo -E venv/bin/python intercept.py` directly.
|
||||
|
||||
### Install Profiles
|
||||
|
||||
Choose what to install during the wizard or via menu option 1:
|
||||
|
||||
| # | Profile | Tools |
|
||||
|---|---------|-------|
|
||||
| 1 | Core SIGINT | rtl_sdr, multimon-ng, rtl_433, dump1090, acarsdec, dumpvdl2, ffmpeg, gpsd |
|
||||
| 2 | Maritime & Radio | AIS-catcher, direwolf |
|
||||
| 3 | Weather & Space | SatDump, radiosonde_auto_rx |
|
||||
| 4 | RF Security | aircrack-ng, HackRF, BlueZ, hcxtools, Ubertooth, SoapySDR |
|
||||
| 5 | Full SIGINT | All of the above |
|
||||
| 6 | Custom | Per-tool checklist |
|
||||
|
||||
Multiple profiles can be combined (e.g. enter `1 3` for Core + Weather).
|
||||
|
||||
### CLI Flags
|
||||
|
||||
```bash
|
||||
./setup.sh --non-interactive # Headless full install (same as legacy behavior)
|
||||
./setup.sh --profile=core,weather # Install specific profiles
|
||||
./setup.sh --health-check # Check system health and exit
|
||||
./setup.sh --postgres-setup # Run PostgreSQL setup and exit
|
||||
./setup.sh --menu # Force interactive menu
|
||||
```
|
||||
|
||||
### Docker
|
||||
@@ -114,16 +188,40 @@ INTERCEPT_IMAGE=ghcr.io/youruser/intercept:latest
|
||||
docker compose --profile basic up -d
|
||||
```
|
||||
|
||||
### ADS-B History (Optional)
|
||||
### Environment Configuration
|
||||
|
||||
The ADS-B history feature persists aircraft messages to Postgres for long-term analysis.
|
||||
Use the **Environment Configurator** (menu option 5) to interactively set any `INTERCEPT_*` variable. Settings are saved to a `.env` file that `start.sh` sources automatically on startup.
|
||||
|
||||
You can also create or edit `.env` manually:
|
||||
|
||||
```bash
|
||||
# .env (auto-loaded by start.sh)
|
||||
INTERCEPT_PORT=5050
|
||||
INTERCEPT_ADSB_AUTO_START=true
|
||||
INTERCEPT_DEFAULT_LAT=51.5074
|
||||
INTERCEPT_DEFAULT_LON=-0.1278
|
||||
```
|
||||
|
||||
### ADS-B History (Optional)
|
||||
|
||||
The ADS-B history feature persists aircraft messages to PostgreSQL for long-term analysis.
|
||||
|
||||
**Automated setup (local install):**
|
||||
|
||||
```bash
|
||||
./setup.sh --postgres-setup
|
||||
# Or use menu option 3: Database Setup
|
||||
```
|
||||
|
||||
This will install PostgreSQL if needed, create the database/user/tables, and write the connection settings to `.env`.
|
||||
|
||||
**Docker:**
|
||||
|
||||
```bash
|
||||
# Start with ADS-B history and Postgres
|
||||
docker compose --profile history up -d
|
||||
```
|
||||
|
||||
Set the following environment variables (for example in a `.env` file):
|
||||
Set the following environment variables (in `.env`):
|
||||
|
||||
```bash
|
||||
INTERCEPT_ADSB_HISTORY_ENABLED=true
|
||||
@@ -134,30 +232,6 @@ INTERCEPT_ADSB_DB_USER=intercept
|
||||
INTERCEPT_ADSB_DB_PASSWORD=intercept
|
||||
```
|
||||
|
||||
### Other ADS-B Settings
|
||||
|
||||
Set these as environment variables for either local installs or Docker:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `INTERCEPT_ADSB_AUTO_START` | `false` | Auto-start ADS-B tracking when the dashboard loads |
|
||||
| `INTERCEPT_SHARED_OBSERVER_LOCATION` | `true` | Share observer location across ADS-B/AIS/SSTV/Satellite modules |
|
||||
|
||||
**Local install example**
|
||||
|
||||
```bash
|
||||
INTERCEPT_ADSB_AUTO_START=true \
|
||||
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
|
||||
sudo -E venv/bin/python intercept.py
|
||||
```
|
||||
|
||||
**Docker example (.env)**
|
||||
|
||||
```bash
|
||||
INTERCEPT_ADSB_AUTO_START=true
|
||||
INTERCEPT_SHARED_OBSERVER_LOCATION=false
|
||||
```
|
||||
|
||||
To store Postgres data on external storage, set `PGDATA_PATH` (defaults to `./pgdata`):
|
||||
|
||||
```bash
|
||||
@@ -166,9 +240,20 @@ PGDATA_PATH=/mnt/usbpi1/intercept/pgdata
|
||||
|
||||
Then open **/adsb/history** for the reporting dashboard.
|
||||
|
||||
### System Health Check
|
||||
|
||||
Verify your installation is complete and working:
|
||||
|
||||
```bash
|
||||
./setup.sh --health-check
|
||||
# Or use menu option 2
|
||||
```
|
||||
|
||||
Checks installed tools, SDR devices, port availability, permissions, Python venv, `.env` configuration, and PostgreSQL connectivity.
|
||||
|
||||
### Open the Interface
|
||||
|
||||
After starting, open **http://localhost:5050** in your browser. The username and password is <b>admin</b>:<b>admin</b>
|
||||
After starting, open **http://localhost:5050** in your browser. The username and password is <b>admin</b>:<b>admin</b>
|
||||
|
||||
The credentials can be changed in the ADMIN_USERNAME & ADMIN_PASSWORD variables in config.py
|
||||
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"version": "2026-02-15_ae16bb62",
|
||||
"downloaded": "2026-02-20T00:29:06.228007Z"
|
||||
"version": "2026-02-22_17194a71",
|
||||
"downloaded": "2026-02-27T10:41:04.872620Z"
|
||||
}
|
||||
@@ -6,8 +6,8 @@ Flask application and shared state.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import site
|
||||
import sys
|
||||
|
||||
from utils.database import get_db
|
||||
|
||||
@@ -17,54 +17,128 @@ if not site.ENABLE_USER_SITE:
|
||||
if user_site and user_site not in sys.path:
|
||||
sys.path.insert(0, user_site)
|
||||
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import threading
|
||||
import platform
|
||||
import queue
|
||||
import subprocess
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from typing import Any
|
||||
|
||||
from flask import Flask, render_template, jsonify, send_file, Response, request,redirect, url_for, flash, session, send_from_directory
|
||||
from flask import (
|
||||
Flask,
|
||||
Response,
|
||||
flash,
|
||||
jsonify,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
send_file,
|
||||
send_from_directory,
|
||||
session,
|
||||
url_for,
|
||||
)
|
||||
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 utils.process import cleanup_stale_processes, cleanup_stale_dump1090
|
||||
from utils.sdr import SDRFactory
|
||||
|
||||
from config import CHANGELOG, DEFAULT_LATITUDE, DEFAULT_LONGITUDE, SHARED_OBSERVER_LOCATION_ENABLED, VERSION
|
||||
from utils.cleanup import DataStore, cleanup_manager
|
||||
from utils.constants import (
|
||||
MAX_AIRCRAFT_AGE_SECONDS,
|
||||
MAX_WIFI_NETWORK_AGE_SECONDS,
|
||||
MAX_BT_DEVICE_AGE_SECONDS,
|
||||
MAX_VESSEL_AGE_SECONDS,
|
||||
MAX_DSC_MESSAGE_AGE_SECONDS,
|
||||
MAX_DEAUTH_ALERTS_AGE_SECONDS,
|
||||
MAX_DSC_MESSAGE_AGE_SECONDS,
|
||||
MAX_VESSEL_AGE_SECONDS,
|
||||
MAX_WIFI_NETWORK_AGE_SECONDS,
|
||||
QUEUE_MAX_SIZE,
|
||||
)
|
||||
import logging
|
||||
from flask_limiter import Limiter
|
||||
from flask_limiter.util import get_remote_address
|
||||
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:
|
||||
from flask_limiter import Limiter
|
||||
from flask_limiter.util import get_remote_address
|
||||
_has_limiter = True
|
||||
except ImportError:
|
||||
_has_limiter = False
|
||||
try:
|
||||
from flask_compress import Compress
|
||||
_has_compress = True
|
||||
except ImportError:
|
||||
_has_compress = False
|
||||
try:
|
||||
from flask_wtf.csrf import CSRFProtect
|
||||
_has_csrf = True
|
||||
except ImportError:
|
||||
_has_csrf = False
|
||||
# Track application start time for uptime calculation
|
||||
import contextlib
|
||||
import time as _time
|
||||
|
||||
_app_start_time = _time.time()
|
||||
logger = logging.getLogger('intercept.database')
|
||||
|
||||
# Create Flask app
|
||||
app = Flask(__name__)
|
||||
app.secret_key = "signals_intelligence_secret" # Required for flash messages
|
||||
def _load_or_generate_secret_key():
|
||||
"""Load secret key from env var or instance file, generating if needed."""
|
||||
env_key = os.environ.get('INTERCEPT_SECRET_KEY')
|
||||
if env_key:
|
||||
return env_key
|
||||
key_path = Path('instance/secret.key')
|
||||
if key_path.exists():
|
||||
return key_path.read_text().strip()
|
||||
key_path.parent.mkdir(exist_ok=True)
|
||||
key = os.urandom(32).hex()
|
||||
key_path.write_text(key)
|
||||
return key
|
||||
|
||||
app.secret_key = _load_or_generate_secret_key()
|
||||
|
||||
# Set up HTTP compression (gzip/brotli for HTML, CSS, JS, JSON)
|
||||
if _has_compress:
|
||||
Compress(app)
|
||||
else:
|
||||
logging.getLogger('intercept').warning(
|
||||
"flask-compress not installed – HTTP compression disabled. "
|
||||
"Install with: pip install flask-compress"
|
||||
)
|
||||
|
||||
# Set up rate limiting
|
||||
limiter = Limiter(
|
||||
key_func=get_remote_address, # Identifies the user by their IP
|
||||
app=app,
|
||||
storage_uri="memory://", # Use RAM memory (change to redis:// etc. for distributed setups)
|
||||
)
|
||||
if _has_limiter:
|
||||
limiter = Limiter(
|
||||
key_func=get_remote_address,
|
||||
app=app,
|
||||
storage_uri="memory://",
|
||||
)
|
||||
else:
|
||||
logging.getLogger('intercept').warning(
|
||||
"flask-limiter not installed – rate limiting disabled. "
|
||||
"Install with: pip install flask-limiter"
|
||||
)
|
||||
class _NoopLimiter:
|
||||
"""Stub so @limiter.limit() decorators are silently ignored."""
|
||||
def limit(self, *a, **kw):
|
||||
def decorator(f):
|
||||
return f
|
||||
return decorator
|
||||
limiter = _NoopLimiter()
|
||||
|
||||
# Set up CSRF protection
|
||||
if _has_csrf:
|
||||
csrf = CSRFProtect(app)
|
||||
else:
|
||||
logging.getLogger('intercept').warning(
|
||||
"flask-wtf not installed – CSRF protection disabled. "
|
||||
"Install with: pip install flask-wtf"
|
||||
)
|
||||
csrf = None
|
||||
|
||||
# Disable Werkzeug debugger PIN (not needed for local development tool)
|
||||
os.environ['WERKZEUG_DEBUG_PIN'] = 'off'
|
||||
|
||||
# ============================================
|
||||
# ERROR HANDLERS
|
||||
# ERROR HANDLERS
|
||||
# ============================================
|
||||
@app.errorhandler(429)
|
||||
def ratelimit_handler(e):
|
||||
@@ -89,6 +163,12 @@ def add_security_headers(response):
|
||||
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
||||
# Permissions policy (disable unnecessary features)
|
||||
response.headers['Permissions-Policy'] = 'geolocation=(self), microphone=()'
|
||||
# Cache-Control for static assets
|
||||
if request.path.startswith('/static/'):
|
||||
if '/vendor/' in request.path:
|
||||
response.headers['Cache-Control'] = 'public, max-age=604800' # 7 days for vendored libs
|
||||
else:
|
||||
response.headers['Cache-Control'] = 'public, max-age=86400' # 24h for app assets
|
||||
return response
|
||||
|
||||
|
||||
@@ -198,6 +278,26 @@ tscm_lock = threading.Lock()
|
||||
subghz_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
subghz_lock = threading.Lock()
|
||||
|
||||
# Radiosonde weather balloon tracking
|
||||
radiosonde_process = None
|
||||
radiosonde_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
radiosonde_lock = threading.Lock()
|
||||
|
||||
# CW/Morse code decoder
|
||||
morse_process = None
|
||||
morse_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
morse_lock = threading.Lock()
|
||||
|
||||
# Meteor scatter detection
|
||||
meteor_process = None
|
||||
meteor_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
meteor_lock = threading.Lock()
|
||||
|
||||
# Generic OOK signal decoder
|
||||
ook_process = None
|
||||
ook_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
ook_lock = threading.Lock()
|
||||
|
||||
# Deauth Attack Detection
|
||||
deauth_detector = None
|
||||
deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
@@ -252,12 +352,12 @@ cleanup_manager.register(deauth_alerts)
|
||||
# SDR DEVICE REGISTRY
|
||||
# ============================================
|
||||
# Tracks which mode is using which SDR device to prevent conflicts
|
||||
# Key: device_index (int), Value: mode_name (str)
|
||||
sdr_device_registry: dict[int, str] = {}
|
||||
# Key: "sdr_type:device_index" (str), Value: mode_name (str)
|
||||
sdr_device_registry: dict[str, str] = {}
|
||||
sdr_device_registry_lock = threading.Lock()
|
||||
|
||||
|
||||
def claim_sdr_device(device_index: int, mode_name: str) -> str | None:
|
||||
def claim_sdr_device(device_index: int, mode_name: str, sdr_type: str = 'rtlsdr') -> str | None:
|
||||
"""Claim an SDR device for a mode.
|
||||
|
||||
Checks the in-app registry first, then probes the USB device to
|
||||
@@ -267,43 +367,48 @@ def claim_sdr_device(device_index: int, mode_name: str) -> str | None:
|
||||
Args:
|
||||
device_index: The SDR device index to claim
|
||||
mode_name: Name of the mode claiming the device (e.g., 'sensor', 'rtlamr')
|
||||
sdr_type: SDR type string (e.g., 'rtlsdr', 'hackrf', 'limesdr')
|
||||
|
||||
Returns:
|
||||
Error message if device is in use, None if successfully claimed
|
||||
"""
|
||||
key = f"{sdr_type}:{device_index}"
|
||||
with sdr_device_registry_lock:
|
||||
if device_index in sdr_device_registry:
|
||||
in_use_by = sdr_device_registry[device_index]
|
||||
return f'SDR device {device_index} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.'
|
||||
if key in sdr_device_registry:
|
||||
in_use_by = sdr_device_registry[key]
|
||||
return f'SDR device {sdr_type}:{device_index} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.'
|
||||
|
||||
# Probe the USB device to catch external processes holding the handle
|
||||
try:
|
||||
from utils.sdr.detection import probe_rtlsdr_device
|
||||
usb_error = probe_rtlsdr_device(device_index)
|
||||
if usb_error:
|
||||
return usb_error
|
||||
except Exception:
|
||||
pass # If probe fails, let the caller proceed normally
|
||||
if sdr_type == 'rtlsdr':
|
||||
try:
|
||||
from utils.sdr.detection import probe_rtlsdr_device
|
||||
usb_error = probe_rtlsdr_device(device_index)
|
||||
if usb_error:
|
||||
return usb_error
|
||||
except Exception:
|
||||
pass # If probe fails, let the caller proceed normally
|
||||
|
||||
sdr_device_registry[device_index] = mode_name
|
||||
sdr_device_registry[key] = mode_name
|
||||
return None
|
||||
|
||||
|
||||
def release_sdr_device(device_index: int) -> None:
|
||||
def release_sdr_device(device_index: int, sdr_type: str = 'rtlsdr') -> None:
|
||||
"""Release an SDR device from the registry.
|
||||
|
||||
Args:
|
||||
device_index: The SDR device index to release
|
||||
sdr_type: SDR type string (e.g., 'rtlsdr', 'hackrf', 'limesdr')
|
||||
"""
|
||||
key = f"{sdr_type}:{device_index}"
|
||||
with sdr_device_registry_lock:
|
||||
sdr_device_registry.pop(device_index, None)
|
||||
sdr_device_registry.pop(key, None)
|
||||
|
||||
|
||||
def get_sdr_device_status() -> dict[int, str]:
|
||||
def get_sdr_device_status() -> dict[str, str]:
|
||||
"""Get current SDR device allocations.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping device indices to mode names
|
||||
Dictionary mapping 'sdr_type:device_index' keys to mode names
|
||||
"""
|
||||
with sdr_device_registry_lock:
|
||||
return dict(sdr_device_registry)
|
||||
@@ -334,7 +439,7 @@ def require_login():
|
||||
# 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:
|
||||
return redirect(url_for('login'))
|
||||
|
||||
|
||||
@app.route('/logout')
|
||||
def logout():
|
||||
session.pop('logged_in', None)
|
||||
@@ -346,7 +451,7 @@ def login():
|
||||
if request.method == 'POST':
|
||||
username = request.form.get('username')
|
||||
password = request.form.get('password')
|
||||
|
||||
|
||||
# Connect to DB and find user
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute(
|
||||
@@ -361,13 +466,13 @@ def login():
|
||||
session['logged_in'] = True
|
||||
session['username'] = username
|
||||
session['role'] = user['role']
|
||||
|
||||
|
||||
logger.info(f"User '{username}' logged in successfully.")
|
||||
return redirect(url_for('index'))
|
||||
else:
|
||||
logger.warning(f"Failed login attempt for username: {username}")
|
||||
flash("ACCESS DENIED: INVALID CREDENTIALS", "error")
|
||||
|
||||
|
||||
return render_template('login.html', version=VERSION)
|
||||
|
||||
@app.route('/')
|
||||
@@ -424,8 +529,9 @@ def get_devices_status() -> Response:
|
||||
result = []
|
||||
for device in devices:
|
||||
d = device.to_dict()
|
||||
d['in_use'] = device.index in registry
|
||||
d['used_by'] = registry.get(device.index)
|
||||
key = f"{device.sdr_type.value}:{device.index}"
|
||||
d['in_use'] = key in registry
|
||||
d['used_by'] = registry.get(key)
|
||||
result.append(d)
|
||||
|
||||
return jsonify(result)
|
||||
@@ -680,6 +786,29 @@ def _get_subghz_active() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _get_singleton_running(module_path: str, getter_name: str, attr: str) -> bool:
|
||||
"""Safely check if a singleton-based mode is running without creating instances."""
|
||||
try:
|
||||
import importlib
|
||||
mod = importlib.import_module(module_path)
|
||||
getter = getattr(mod, getter_name)
|
||||
instance = getter()
|
||||
if instance is None:
|
||||
return False
|
||||
return bool(getattr(instance, attr, False))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _get_tscm_active() -> bool:
|
||||
"""Check if a TSCM sweep is running."""
|
||||
try:
|
||||
from routes.tscm import _sweep_running
|
||||
return bool(_sweep_running)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _get_bluetooth_health() -> tuple[bool, int]:
|
||||
"""Return Bluetooth active state and best-effort device count."""
|
||||
legacy_running = bt_process is not None and (bt_process.poll() is None if bt_process else False)
|
||||
@@ -737,13 +866,43 @@ def _get_wifi_health() -> tuple[bool, int, int]:
|
||||
@app.route('/health')
|
||||
def health_check() -> Response:
|
||||
"""Health check endpoint for monitoring."""
|
||||
import platform
|
||||
import time
|
||||
bt_active, bt_device_count = _get_bluetooth_health()
|
||||
wifi_active, wifi_network_count, wifi_client_count = _get_wifi_health()
|
||||
return jsonify({
|
||||
'status': 'healthy',
|
||||
|
||||
# Database health check
|
||||
db_ok = True
|
||||
try:
|
||||
from utils.database import get_connection
|
||||
get_connection().execute('SELECT 1')
|
||||
except Exception:
|
||||
db_ok = False
|
||||
|
||||
# SDR device count (cached, non-blocking)
|
||||
sdr_count = 0
|
||||
try:
|
||||
from utils.sdr.detection import get_cached_devices
|
||||
cached = get_cached_devices()
|
||||
if cached is not None:
|
||||
sdr_count = len(cached)
|
||||
except (ImportError, Exception):
|
||||
pass
|
||||
|
||||
overall_status = 'healthy' if db_ok else 'degraded'
|
||||
status_code = 200 if db_ok else 503
|
||||
|
||||
response = jsonify({
|
||||
'status': overall_status,
|
||||
'version': VERSION,
|
||||
'uptime_seconds': round(time.time() - _app_start_time, 2),
|
||||
'system': {
|
||||
'python_version': platform.python_version(),
|
||||
'platform': platform.platform(),
|
||||
},
|
||||
'database': db_ok,
|
||||
'sdr_devices': sdr_count,
|
||||
'rate_limiting': _has_limiter,
|
||||
'processes': {
|
||||
'pager': current_process is not None and (current_process.poll() is None if current_process else False),
|
||||
'sensor': sensor_process is not None and (sensor_process.poll() is None if sensor_process else False),
|
||||
@@ -755,7 +914,18 @@ def health_check() -> Response:
|
||||
'wifi': wifi_active,
|
||||
'bluetooth': bt_active,
|
||||
'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False),
|
||||
'radiosonde': radiosonde_process is not None and (radiosonde_process.poll() is None if radiosonde_process else False),
|
||||
'morse': morse_process is not None and (morse_process.poll() is None if morse_process else False),
|
||||
'subghz': _get_subghz_active(),
|
||||
'rtlamr': rtlamr_process is not None and (rtlamr_process.poll() is None if rtlamr_process else False),
|
||||
'meshtastic': _get_singleton_running('utils.meshtastic', 'get_meshtastic_client', 'is_running'),
|
||||
'sstv': _get_singleton_running('utils.sstv', 'get_sstv_decoder', 'is_running'),
|
||||
'weathersat': _get_singleton_running('utils.weather_sat', 'get_weather_sat_decoder', 'is_running'),
|
||||
'wefax': _get_singleton_running('utils.wefax', 'get_wefax_decoder', 'is_running'),
|
||||
'sstv_general': _get_singleton_running('utils.sstv', 'get_general_sstv_decoder', 'is_running'),
|
||||
'tscm': _get_tscm_active(),
|
||||
'gps': _get_singleton_running('utils.gps', 'get_gps_reader', 'is_running'),
|
||||
'bt_locate': _get_singleton_running('utils.bt_locate', 'get_locate_session', 'is_active'),
|
||||
},
|
||||
'data': {
|
||||
'aircraft_count': len(adsb_aircraft),
|
||||
@@ -766,18 +936,22 @@ def health_check() -> Response:
|
||||
'dsc_messages_count': len(dsc_messages),
|
||||
}
|
||||
})
|
||||
response.status_code = status_code
|
||||
return response
|
||||
|
||||
|
||||
@app.route('/killall', methods=['POST'])
|
||||
@(csrf.exempt if csrf else lambda f: f)
|
||||
def kill_all() -> Response:
|
||||
"""Kill all decoder, WiFi, and Bluetooth processes."""
|
||||
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
|
||||
global vdl2_process
|
||||
global vdl2_process, morse_process, radiosonde_process, ook_process
|
||||
global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_process
|
||||
|
||||
# Import adsb and ais modules to reset their state
|
||||
# Import modules to reset their state
|
||||
from routes import adsb as adsb_module
|
||||
from routes import ais as ais_module
|
||||
from routes import radiosonde as radiosonde_module
|
||||
from utils.bluetooth import reset_bluetooth_scanner
|
||||
|
||||
killed = []
|
||||
@@ -787,7 +961,8 @@ def kill_all() -> Response:
|
||||
'dump1090', 'acarsdec', 'dumpvdl2', 'direwolf', 'AIS-catcher',
|
||||
'hcitool', 'bluetoothctl', 'satdump',
|
||||
'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg',
|
||||
'hackrf_transfer', 'hackrf_sweep'
|
||||
'hackrf_transfer', 'hackrf_sweep',
|
||||
'auto_rx'
|
||||
]
|
||||
|
||||
for proc in processes_to_kill:
|
||||
@@ -817,6 +992,11 @@ def kill_all() -> Response:
|
||||
ais_process = None
|
||||
ais_module.ais_running = False
|
||||
|
||||
# Reset Radiosonde state
|
||||
with radiosonde_lock:
|
||||
radiosonde_process = None
|
||||
radiosonde_module.radiosonde_running = False
|
||||
|
||||
# Reset ACARS state
|
||||
with acars_lock:
|
||||
acars_process = None
|
||||
@@ -825,6 +1005,21 @@ def kill_all() -> Response:
|
||||
with vdl2_lock:
|
||||
vdl2_process = None
|
||||
|
||||
# Reset Morse state
|
||||
with morse_lock:
|
||||
morse_process = None
|
||||
|
||||
# Reset OOK state (full cleanup: parser thread, pipes, SDR release)
|
||||
with ook_lock:
|
||||
try:
|
||||
from routes.ook import cleanup_ook
|
||||
cleanup_ook(emit_status=False)
|
||||
except Exception:
|
||||
if ook_process:
|
||||
safe_terminate(ook_process)
|
||||
unregister_process(ook_process)
|
||||
ook_process = None
|
||||
|
||||
# Reset APRS state
|
||||
with aprs_lock:
|
||||
aprs_process = None
|
||||
@@ -842,10 +1037,8 @@ def kill_all() -> Response:
|
||||
bt_process.terminate()
|
||||
bt_process.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
bt_process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
bt_process = None
|
||||
|
||||
# Reset Bluetooth v2 scanner
|
||||
@@ -869,9 +1062,143 @@ def kill_all() -> Response:
|
||||
return jsonify({'status': 'killed', 'processes': killed})
|
||||
|
||||
|
||||
def _ensure_self_signed_cert(cert_dir: str) -> tuple:
|
||||
"""Generate a self-signed certificate if one doesn't already exist.
|
||||
|
||||
Returns (cert_path, key_path) tuple.
|
||||
"""
|
||||
cert_path = os.path.join(cert_dir, 'intercept.crt')
|
||||
key_path = os.path.join(cert_dir, 'intercept.key')
|
||||
|
||||
if os.path.exists(cert_path) and os.path.exists(key_path):
|
||||
print(f"Using existing SSL certificate: {cert_path}")
|
||||
return cert_path, key_path
|
||||
|
||||
os.makedirs(cert_dir, exist_ok=True)
|
||||
print("Generating self-signed SSL certificate...")
|
||||
|
||||
import subprocess
|
||||
result = subprocess.run([
|
||||
'openssl', 'req', '-x509', '-newkey', 'rsa:2048',
|
||||
'-keyout', key_path, '-out', cert_path,
|
||||
'-days', '365', '-nodes',
|
||||
'-subj', '/CN=intercept/O=INTERCEPT/C=US',
|
||||
], capture_output=True, text=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"Failed to generate SSL certificate: {result.stderr}")
|
||||
|
||||
print(f"SSL certificate generated: {cert_path}")
|
||||
return cert_path, key_path
|
||||
|
||||
|
||||
_app_initialized = False
|
||||
|
||||
|
||||
def _init_app() -> None:
|
||||
"""Initialize blueprints, database, and websockets.
|
||||
|
||||
Safe to call multiple times — subsequent calls are no-ops.
|
||||
Called automatically at module level for gunicorn, and also
|
||||
from main() for the Flask dev server path.
|
||||
|
||||
Heavy/network operations (TLE updates, process cleanup) are
|
||||
deferred to a background thread so the worker can serve
|
||||
requests immediately.
|
||||
"""
|
||||
global _app_initialized
|
||||
if _app_initialized:
|
||||
return
|
||||
_app_initialized = True
|
||||
|
||||
import os
|
||||
|
||||
# Initialize database for settings storage
|
||||
from utils.database import init_db
|
||||
init_db()
|
||||
|
||||
# Register blueprints (essential — without these, all routes 404)
|
||||
from routes import register_blueprints
|
||||
register_blueprints(app)
|
||||
|
||||
# Initialize WebSocket for audio streaming
|
||||
try:
|
||||
from routes.audio_websocket import init_audio_websocket
|
||||
init_audio_websocket(app)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Initialize KiwiSDR WebSocket audio proxy
|
||||
try:
|
||||
from routes.websdr import init_websdr_audio
|
||||
init_websdr_audio(app)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Initialize WebSocket for waterfall streaming
|
||||
try:
|
||||
from routes.waterfall_websocket import init_waterfall_websocket
|
||||
init_waterfall_websocket(app)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Initialize WebSocket for meteor scatter monitoring
|
||||
try:
|
||||
from routes.meteor_websocket import init_meteor_websocket
|
||||
init_meteor_websocket(app)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Defer heavy/network operations so the worker can serve requests immediately
|
||||
import threading
|
||||
|
||||
def _deferred_init():
|
||||
"""Run heavy initialization after a short delay."""
|
||||
import time
|
||||
time.sleep(1) # Let the worker start serving first
|
||||
|
||||
# Clean up stale processes from previous runs
|
||||
try:
|
||||
cleanup_stale_processes()
|
||||
cleanup_stale_dump1090()
|
||||
except Exception as e:
|
||||
logger.warning(f"Stale process cleanup failed: {e}")
|
||||
|
||||
# Register and start database cleanup
|
||||
try:
|
||||
from utils.database import (
|
||||
cleanup_old_dsc_alerts,
|
||||
cleanup_old_payloads,
|
||||
cleanup_old_signal_history,
|
||||
cleanup_old_timeline_entries,
|
||||
)
|
||||
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_dsc_alerts, interval_multiplier=1440)
|
||||
cleanup_manager.register_db_cleanup(cleanup_old_payloads, interval_multiplier=1440)
|
||||
cleanup_manager.start()
|
||||
except Exception as e:
|
||||
logger.warning(f"Cleanup manager init failed: {e}")
|
||||
|
||||
# Initialize TLE auto-refresh (must be after blueprint registration)
|
||||
try:
|
||||
from routes.satellite import init_tle_auto_refresh
|
||||
if not os.environ.get('TESTING'):
|
||||
init_tle_auto_refresh()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to initialize TLE auto-refresh: {e}")
|
||||
|
||||
threading.Thread(target=_deferred_init, daemon=True).start()
|
||||
|
||||
|
||||
# Auto-initialize when imported (e.g. by gunicorn)
|
||||
_init_app()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Main entry point."""
|
||||
import argparse
|
||||
|
||||
import config
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
@@ -895,6 +1222,12 @@ def main() -> None:
|
||||
default=config.DEBUG,
|
||||
help='Enable debug mode'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--https',
|
||||
action='store_true',
|
||||
default=config.HTTPS,
|
||||
help='Enable HTTPS with self-signed certificate'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--check-deps',
|
||||
action='store_true',
|
||||
@@ -907,7 +1240,7 @@ def main() -> None:
|
||||
results = check_all_dependencies()
|
||||
print("Dependency Status:")
|
||||
print("-" * 40)
|
||||
for mode, info in results.items():
|
||||
for _mode, info in results.items():
|
||||
status = "✓" if info['ready'] else "✗"
|
||||
print(f"\n{status} {info['name']}:")
|
||||
for tool, tool_info in info['tools'].items():
|
||||
@@ -944,83 +1277,21 @@ def main() -> None:
|
||||
print("Running as root - full capabilities enabled")
|
||||
print()
|
||||
|
||||
# Clean up any stale processes from previous runs
|
||||
cleanup_stale_processes()
|
||||
cleanup_stale_dump1090()
|
||||
# Ensure app is initialized (no-op if already done by module-level call)
|
||||
_init_app()
|
||||
|
||||
# Initialize database for settings storage
|
||||
from utils.database import init_db
|
||||
init_db()
|
||||
# Configure SSL if HTTPS is enabled
|
||||
ssl_context = None
|
||||
if args.https:
|
||||
cert_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data', 'certs')
|
||||
if config.SSL_CERT and config.SSL_KEY:
|
||||
ssl_context = (config.SSL_CERT, config.SSL_KEY)
|
||||
print(f"Using provided SSL certificate: {config.SSL_CERT}")
|
||||
else:
|
||||
ssl_context = _ensure_self_signed_cert(cert_dir)
|
||||
|
||||
# Register database cleanup functions
|
||||
from utils.database import (
|
||||
cleanup_old_signal_history,
|
||||
cleanup_old_timeline_entries,
|
||||
cleanup_old_dsc_alerts,
|
||||
cleanup_old_payloads
|
||||
)
|
||||
cleanup_manager.register_db_cleanup(cleanup_old_signal_history, interval_multiplier=1440) # Every 24 hours
|
||||
cleanup_manager.register_db_cleanup(cleanup_old_timeline_entries, interval_multiplier=1440) # Every 24 hours
|
||||
cleanup_manager.register_db_cleanup(cleanup_old_dsc_alerts, interval_multiplier=1440) # Every 24 hours
|
||||
cleanup_manager.register_db_cleanup(cleanup_old_payloads, interval_multiplier=1440) # Every 24 hours
|
||||
|
||||
# Start automatic cleanup of stale data entries
|
||||
cleanup_manager.start()
|
||||
|
||||
# Register blueprints
|
||||
from routes import register_blueprints
|
||||
register_blueprints(app)
|
||||
|
||||
# Initialize TLE auto-refresh (must be after blueprint registration)
|
||||
try:
|
||||
from routes.satellite import init_tle_auto_refresh
|
||||
import os
|
||||
if not os.environ.get('TESTING'):
|
||||
init_tle_auto_refresh()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to initialize TLE auto-refresh: {e}")
|
||||
|
||||
# Update TLE data in background thread (non-blocking)
|
||||
def update_tle_background():
|
||||
try:
|
||||
from routes.satellite import refresh_tle_data
|
||||
print("Updating satellite TLE data from CelesTrak...")
|
||||
updated = refresh_tle_data()
|
||||
if updated:
|
||||
print(f"TLE data updated for: {', '.join(updated)}")
|
||||
else:
|
||||
print("TLE update: No satellites updated (may be offline)")
|
||||
except Exception as e:
|
||||
print(f"TLE update failed (will use cached data): {e}")
|
||||
|
||||
tle_thread = threading.Thread(target=update_tle_background, daemon=True)
|
||||
tle_thread.start()
|
||||
|
||||
# Initialize WebSocket for audio streaming
|
||||
try:
|
||||
from routes.audio_websocket import init_audio_websocket
|
||||
init_audio_websocket(app)
|
||||
print("WebSocket audio streaming enabled")
|
||||
except ImportError as e:
|
||||
print(f"WebSocket audio disabled (install flask-sock): {e}")
|
||||
|
||||
# Initialize KiwiSDR WebSocket audio proxy
|
||||
try:
|
||||
from routes.websdr import init_websdr_audio
|
||||
init_websdr_audio(app)
|
||||
print("KiwiSDR audio proxy enabled")
|
||||
except ImportError as e:
|
||||
print(f"KiwiSDR audio proxy disabled: {e}")
|
||||
|
||||
# Initialize WebSocket for waterfall streaming
|
||||
try:
|
||||
from routes.waterfall_websocket import init_waterfall_websocket
|
||||
init_waterfall_websocket(app)
|
||||
print("WebSocket waterfall streaming enabled")
|
||||
except ImportError as e:
|
||||
print(f"WebSocket waterfall disabled: {e}")
|
||||
|
||||
print(f"Open http://localhost:{args.port} in your browser")
|
||||
protocol = 'https' if ssl_context else 'http'
|
||||
print(f"Open {protocol}://localhost:{args.port} in your browser")
|
||||
print()
|
||||
print("Press Ctrl+C to stop")
|
||||
print()
|
||||
@@ -1032,4 +1303,5 @@ def main() -> None:
|
||||
debug=args.debug,
|
||||
threaded=True,
|
||||
load_dotenv=False,
|
||||
ssl_context=ssl_context,
|
||||
)
|
||||
|
||||
+139
-139
@@ -1,139 +1,139 @@
|
||||
#!/bin/bash
|
||||
# INTERCEPT - Multi-architecture Docker image builder
|
||||
#
|
||||
# Builds for both linux/amd64 and linux/arm64 using Docker buildx.
|
||||
# Run this on your x64 machine to cross-compile the arm64 image
|
||||
# instead of building natively on the RPi5.
|
||||
#
|
||||
# Prerequisites (one-time setup):
|
||||
# docker run --privileged --rm tonistiigi/binfmt --install all
|
||||
# docker buildx create --name intercept-builder --use --bootstrap
|
||||
#
|
||||
# Usage:
|
||||
# ./build-multiarch.sh # Build both platforms, load locally
|
||||
# ./build-multiarch.sh --push # Build and push to registry
|
||||
# ./build-multiarch.sh --arm64-only # Build arm64 only (for RPi)
|
||||
# REGISTRY=ghcr.io/user ./build-multiarch.sh --push
|
||||
#
|
||||
# Environment variables:
|
||||
# REGISTRY - Container registry (default: docker.io/library)
|
||||
# IMAGE_NAME - Image name (default: intercept)
|
||||
# IMAGE_TAG - Image tag (default: latest)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Configuration
|
||||
REGISTRY="${REGISTRY:-}"
|
||||
IMAGE_NAME="${IMAGE_NAME:-intercept}"
|
||||
IMAGE_TAG="${IMAGE_TAG:-latest}"
|
||||
BUILDER_NAME="intercept-builder"
|
||||
PLATFORMS="linux/amd64,linux/arm64"
|
||||
|
||||
# Parse arguments
|
||||
PUSH=false
|
||||
LOAD=false
|
||||
ARM64_ONLY=false
|
||||
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
--push) PUSH=true ;;
|
||||
--load) LOAD=true ;;
|
||||
--arm64-only)
|
||||
ARM64_ONLY=true
|
||||
PLATFORMS="linux/arm64"
|
||||
;;
|
||||
--amd64-only)
|
||||
PLATFORMS="linux/amd64"
|
||||
;;
|
||||
--help|-h)
|
||||
echo "Usage: $0 [--push] [--load] [--arm64-only] [--amd64-only]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --push Push to container registry"
|
||||
echo " --load Load into local Docker (single platform only)"
|
||||
echo " --arm64-only Build arm64 only (for RPi5 deployment)"
|
||||
echo " --amd64-only Build amd64 only"
|
||||
echo ""
|
||||
echo "Environment variables:"
|
||||
echo " REGISTRY Container registry (e.g. ghcr.io/username)"
|
||||
echo " IMAGE_NAME Image name (default: intercept)"
|
||||
echo " IMAGE_TAG Image tag (default: latest)"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 --push # Build both, push"
|
||||
echo " REGISTRY=ghcr.io/myuser $0 --push # Push to GHCR"
|
||||
echo " $0 --arm64-only --load # Build arm64, load locally"
|
||||
echo " $0 --arm64-only --push && ssh rpi docker pull # Build + deploy to RPi"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $arg"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Build full image reference
|
||||
if [ -n "$REGISTRY" ]; then
|
||||
FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}"
|
||||
else
|
||||
FULL_IMAGE="${IMAGE_NAME}:${IMAGE_TAG}"
|
||||
fi
|
||||
|
||||
echo "============================================"
|
||||
echo " INTERCEPT Multi-Architecture Builder"
|
||||
echo "============================================"
|
||||
echo " Image: ${FULL_IMAGE}"
|
||||
echo " Platforms: ${PLATFORMS}"
|
||||
echo " Push: ${PUSH}"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
|
||||
# Check if buildx builder exists, create if not
|
||||
if ! docker buildx inspect "$BUILDER_NAME" >/dev/null 2>&1; then
|
||||
echo "Creating buildx builder: ${BUILDER_NAME}"
|
||||
docker buildx create --name "$BUILDER_NAME" --use --bootstrap
|
||||
|
||||
# Check for QEMU support
|
||||
if ! docker run --rm --privileged tonistiigi/binfmt --install all >/dev/null 2>&1; then
|
||||
echo "WARNING: QEMU binfmt setup may have failed."
|
||||
echo "Run: docker run --privileged --rm tonistiigi/binfmt --install all"
|
||||
fi
|
||||
else
|
||||
docker buildx use "$BUILDER_NAME"
|
||||
fi
|
||||
|
||||
# Build command
|
||||
BUILD_CMD="docker buildx build --platform ${PLATFORMS} --tag ${FULL_IMAGE}"
|
||||
|
||||
if [ "$PUSH" = true ]; then
|
||||
BUILD_CMD="${BUILD_CMD} --push"
|
||||
echo "Will push to: ${FULL_IMAGE}"
|
||||
elif [ "$LOAD" = true ]; then
|
||||
# --load only works with single platform
|
||||
if echo "$PLATFORMS" | grep -q ","; then
|
||||
echo "ERROR: --load only works with a single platform."
|
||||
echo "Use --arm64-only or --amd64-only with --load."
|
||||
exit 1
|
||||
fi
|
||||
BUILD_CMD="${BUILD_CMD} --load"
|
||||
echo "Will load into local Docker"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Building..."
|
||||
echo "Command: ${BUILD_CMD} ."
|
||||
echo ""
|
||||
|
||||
$BUILD_CMD .
|
||||
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo " Build complete!"
|
||||
if [ "$PUSH" = true ]; then
|
||||
echo " Image pushed to: ${FULL_IMAGE}"
|
||||
echo ""
|
||||
echo " Pull on RPi5:"
|
||||
echo " docker pull ${FULL_IMAGE}"
|
||||
fi
|
||||
echo "============================================"
|
||||
#!/bin/bash
|
||||
# INTERCEPT - Multi-architecture Docker image builder
|
||||
#
|
||||
# Builds for both linux/amd64 and linux/arm64 using Docker buildx.
|
||||
# Run this on your x64 machine to cross-compile the arm64 image
|
||||
# instead of building natively on the RPi5.
|
||||
#
|
||||
# Prerequisites (one-time setup):
|
||||
# docker run --privileged --rm tonistiigi/binfmt --install all
|
||||
# docker buildx create --name intercept-builder --use --bootstrap
|
||||
#
|
||||
# Usage:
|
||||
# ./build-multiarch.sh # Build both platforms, load locally
|
||||
# ./build-multiarch.sh --push # Build and push to registry
|
||||
# ./build-multiarch.sh --arm64-only # Build arm64 only (for RPi)
|
||||
# REGISTRY=ghcr.io/user ./build-multiarch.sh --push
|
||||
#
|
||||
# Environment variables:
|
||||
# REGISTRY - Container registry (default: docker.io/library)
|
||||
# IMAGE_NAME - Image name (default: intercept)
|
||||
# IMAGE_TAG - Image tag (default: latest)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Configuration
|
||||
REGISTRY="${REGISTRY:-}"
|
||||
IMAGE_NAME="${IMAGE_NAME:-intercept}"
|
||||
IMAGE_TAG="${IMAGE_TAG:-latest}"
|
||||
BUILDER_NAME="intercept-builder"
|
||||
PLATFORMS="linux/amd64,linux/arm64"
|
||||
|
||||
# Parse arguments
|
||||
PUSH=false
|
||||
LOAD=false
|
||||
ARM64_ONLY=false
|
||||
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
--push) PUSH=true ;;
|
||||
--load) LOAD=true ;;
|
||||
--arm64-only)
|
||||
ARM64_ONLY=true
|
||||
PLATFORMS="linux/arm64"
|
||||
;;
|
||||
--amd64-only)
|
||||
PLATFORMS="linux/amd64"
|
||||
;;
|
||||
--help|-h)
|
||||
echo "Usage: $0 [--push] [--load] [--arm64-only] [--amd64-only]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --push Push to container registry"
|
||||
echo " --load Load into local Docker (single platform only)"
|
||||
echo " --arm64-only Build arm64 only (for RPi5 deployment)"
|
||||
echo " --amd64-only Build amd64 only"
|
||||
echo ""
|
||||
echo "Environment variables:"
|
||||
echo " REGISTRY Container registry (e.g. ghcr.io/username)"
|
||||
echo " IMAGE_NAME Image name (default: intercept)"
|
||||
echo " IMAGE_TAG Image tag (default: latest)"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 --push # Build both, push"
|
||||
echo " REGISTRY=ghcr.io/myuser $0 --push # Push to GHCR"
|
||||
echo " $0 --arm64-only --load # Build arm64, load locally"
|
||||
echo " $0 --arm64-only --push && ssh rpi docker pull # Build + deploy to RPi"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $arg"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Build full image reference
|
||||
if [ -n "$REGISTRY" ]; then
|
||||
FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}"
|
||||
else
|
||||
FULL_IMAGE="${IMAGE_NAME}:${IMAGE_TAG}"
|
||||
fi
|
||||
|
||||
echo "============================================"
|
||||
echo " INTERCEPT Multi-Architecture Builder"
|
||||
echo "============================================"
|
||||
echo " Image: ${FULL_IMAGE}"
|
||||
echo " Platforms: ${PLATFORMS}"
|
||||
echo " Push: ${PUSH}"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
|
||||
# Check if buildx builder exists, create if not
|
||||
if ! docker buildx inspect "$BUILDER_NAME" >/dev/null 2>&1; then
|
||||
echo "Creating buildx builder: ${BUILDER_NAME}"
|
||||
docker buildx create --name "$BUILDER_NAME" --use --bootstrap
|
||||
|
||||
# Check for QEMU support
|
||||
if ! docker run --rm --privileged tonistiigi/binfmt --install all >/dev/null 2>&1; then
|
||||
echo "WARNING: QEMU binfmt setup may have failed."
|
||||
echo "Run: docker run --privileged --rm tonistiigi/binfmt --install all"
|
||||
fi
|
||||
else
|
||||
docker buildx use "$BUILDER_NAME"
|
||||
fi
|
||||
|
||||
# Build command
|
||||
BUILD_CMD="docker buildx build --platform ${PLATFORMS} --tag ${FULL_IMAGE}"
|
||||
|
||||
if [ "$PUSH" = true ]; then
|
||||
BUILD_CMD="${BUILD_CMD} --push"
|
||||
echo "Will push to: ${FULL_IMAGE}"
|
||||
elif [ "$LOAD" = true ]; then
|
||||
# --load only works with single platform
|
||||
if echo "$PLATFORMS" | grep -q ","; then
|
||||
echo "ERROR: --load only works with a single platform."
|
||||
echo "Use --arm64-only or --amd64-only with --load."
|
||||
exit 1
|
||||
fi
|
||||
BUILD_CMD="${BUILD_CMD} --load"
|
||||
echo "Will load into local Docker"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Building..."
|
||||
echo "Command: ${BUILD_CMD} ."
|
||||
echo ""
|
||||
|
||||
$BUILD_CMD .
|
||||
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo " Build complete!"
|
||||
if [ "$PUSH" = true ]; then
|
||||
echo " Image pushed to: ${FULL_IMAGE}"
|
||||
echo ""
|
||||
echo " Pull on RPi5:"
|
||||
echo " docker pull ${FULL_IMAGE}"
|
||||
fi
|
||||
echo "============================================"
|
||||
|
||||
@@ -7,18 +7,144 @@ import os
|
||||
import sys
|
||||
|
||||
# Application version
|
||||
VERSION = "2.22.0"
|
||||
VERSION = "2.26.12"
|
||||
|
||||
# Changelog - latest release notes (shown on welcome screen)
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "2.22.0",
|
||||
"version": "2.26.12",
|
||||
"date": "March 2026",
|
||||
"highlights": [
|
||||
"AIS and ADS-B dashboards now use configured observer position from .env",
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2.26.11",
|
||||
"date": "March 2026",
|
||||
"highlights": [
|
||||
"APRS map now centres on configured observer position from .env",
|
||||
]
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"date": "March 2026",
|
||||
"highlights": [
|
||||
"WiFi Locate mode — locate access points by BSSID with real-time signal meter, distance estimation, RSSI chart, and audio proximity tones",
|
||||
"Mobile navigation reorganized into labeled groups for better usability",
|
||||
"flask-limiter made optional for graceful degradation",
|
||||
"Radiosonde setup fix — missing semver dependency",
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2.23.0",
|
||||
"date": "February 2026",
|
||||
"highlights": [
|
||||
"Radiosonde weather balloon tracking mode with telemetry, map, and station distance",
|
||||
"CW/Morse code decoder with Goertzel tone detection and OOK envelope mode",
|
||||
"WeFax (Weather Fax) decoder with auto-scheduler and broadcast timeline",
|
||||
"System Health monitoring mode with telemetry dashboard",
|
||||
"HTTPS support, HackRF TSCM RF scan, ADS-B voice alerts",
|
||||
"Production server (start.sh) with gunicorn + gevent for concurrent multi-client support",
|
||||
"Multi-SDR support for WeFax, tool path overrides, native Homebrew detection",
|
||||
"GPS mode upgraded to textured 3D globe",
|
||||
"Destroy lifecycle added to all mode modules to prevent resource leaks",
|
||||
"Dozens of bug fixes across ADS-B, APRS, SSE, Morse, waterfall, and more",
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2.22.3",
|
||||
"date": "February 2026",
|
||||
"highlights": [
|
||||
"Waterfall control panel no longer shows as unstyled text on first visit",
|
||||
"WebSDR globe renders correctly on first page load without requiring a refresh",
|
||||
"Waterfall monitor audio no longer takes minutes to start — playback detection now waits for real audio data instead of just the WAV header",
|
||||
"Waterfall monitor stop is now instant — audio pauses and UI updates immediately instead of waiting for backend cleanup",
|
||||
"Stopping the waterfall no longer shows a stale 'WebSocket closed before ready' message",
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2.22.1",
|
||||
"date": "February 2026",
|
||||
"highlights": [
|
||||
"Waterfall receiver overhaul: WebSocket I/Q streaming with server-side FFT, click-to-tune, and zoom controls",
|
||||
"Voice alerts for configurable event notifications across modes",
|
||||
"Signal fingerprinting mode for RF device identification and pattern analysis",
|
||||
"RF Heatmap for geographic signal density visualization",
|
||||
"SignalID integration via SigIDWiki API for automatic signal classification",
|
||||
"PWA support: installable web app with service worker and manifest",
|
||||
"Mode stop responsiveness improvements with faster timeout handling",
|
||||
@@ -270,6 +396,11 @@ PORT = _get_env_int('PORT', 5050)
|
||||
DEBUG = _get_env_bool('DEBUG', False)
|
||||
THREADED = _get_env_bool('THREADED', True)
|
||||
|
||||
# HTTPS / SSL settings
|
||||
HTTPS = _get_env_bool('HTTPS', False)
|
||||
SSL_CERT = _get_env('SSL_CERT', '')
|
||||
SSL_KEY = _get_env('SSL_KEY', '')
|
||||
|
||||
# Default RTL-SDR settings
|
||||
DEFAULT_GAIN = _get_env('DEFAULT_GAIN', '40')
|
||||
DEFAULT_DEVICE = _get_env('DEFAULT_DEVICE', '0')
|
||||
@@ -316,12 +447,20 @@ SATELLITE_ORBIT_MINUTES = _get_env_int('SATELLITE_ORBIT_MINUTES', 45)
|
||||
|
||||
# Weather satellite settings
|
||||
WEATHER_SAT_DEFAULT_GAIN = _get_env_float('WEATHER_SAT_GAIN', 40.0)
|
||||
WEATHER_SAT_SAMPLE_RATE = _get_env_int('WEATHER_SAT_SAMPLE_RATE', 1000000)
|
||||
WEATHER_SAT_SAMPLE_RATE = _get_env_int('WEATHER_SAT_SAMPLE_RATE', 2400000)
|
||||
WEATHER_SAT_MIN_ELEVATION = _get_env_float('WEATHER_SAT_MIN_ELEVATION', 15.0)
|
||||
WEATHER_SAT_PREDICTION_HOURS = _get_env_int('WEATHER_SAT_PREDICTION_HOURS', 24)
|
||||
WEATHER_SAT_SCHEDULE_REFRESH_MINUTES = _get_env_int('WEATHER_SAT_SCHEDULE_REFRESH_MINUTES', 30)
|
||||
WEATHER_SAT_CAPTURE_BUFFER_SECONDS = _get_env_int('WEATHER_SAT_CAPTURE_BUFFER_SECONDS', 30)
|
||||
|
||||
# WeFax (Weather Fax) settings
|
||||
WEFAX_DEFAULT_GAIN = _get_env_float('WEFAX_GAIN', 40.0)
|
||||
WEFAX_SAMPLE_RATE = _get_env_int('WEFAX_SAMPLE_RATE', 22050)
|
||||
WEFAX_DEFAULT_IOC = _get_env_int('WEFAX_IOC', 576)
|
||||
WEFAX_DEFAULT_LPM = _get_env_int('WEFAX_LPM', 120)
|
||||
WEFAX_SCHEDULE_REFRESH_MINUTES = _get_env_int('WEFAX_SCHEDULE_REFRESH_MINUTES', 30)
|
||||
WEFAX_CAPTURE_BUFFER_SECONDS = _get_env_int('WEFAX_CAPTURE_BUFFER_SECONDS', 30)
|
||||
|
||||
# SubGHz transceiver settings (HackRF)
|
||||
SUBGHZ_DEFAULT_FREQUENCY = _get_env_float('SUBGHZ_FREQUENCY', 433.92)
|
||||
SUBGHZ_DEFAULT_SAMPLE_RATE = _get_env_int('SUBGHZ_SAMPLE_RATE', 2000000)
|
||||
@@ -332,6 +471,12 @@ SUBGHZ_MAX_TX_DURATION = _get_env_int('SUBGHZ_MAX_TX_DURATION', 10)
|
||||
SUBGHZ_SWEEP_START_MHZ = _get_env_float('SUBGHZ_SWEEP_START', 300.0)
|
||||
SUBGHZ_SWEEP_END_MHZ = _get_env_float('SUBGHZ_SWEEP_END', 928.0)
|
||||
|
||||
# Radiosonde settings
|
||||
RADIOSONDE_FREQ_MIN = _get_env_float('RADIOSONDE_FREQ_MIN', 400.0)
|
||||
RADIOSONDE_FREQ_MAX = _get_env_float('RADIOSONDE_FREQ_MAX', 406.0)
|
||||
RADIOSONDE_DEFAULT_GAIN = _get_env_float('RADIOSONDE_GAIN', 40.0)
|
||||
RADIOSONDE_UDP_PORT = _get_env_int('RADIOSONDE_UDP_PORT', 55673)
|
||||
|
||||
# Update checking
|
||||
GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept')
|
||||
UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True)
|
||||
|
||||
+5
-5
@@ -1,10 +1,10 @@
|
||||
# Data modules for INTERCEPT
|
||||
from .oui import OUI_DATABASE, load_oui_database, get_manufacturer
|
||||
from .satellites import TLE_SATELLITES
|
||||
from .oui import OUI_DATABASE, get_manufacturer, load_oui_database
|
||||
from .patterns import (
|
||||
AIRTAG_PREFIXES,
|
||||
TILE_PREFIXES,
|
||||
SAMSUNG_TRACKER,
|
||||
DRONE_SSID_PATTERNS,
|
||||
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
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import json
|
||||
|
||||
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')
|
||||
try:
|
||||
if os.path.exists(oui_file):
|
||||
with open(oui_file, 'r') as f:
|
||||
with open(oui_file) as f:
|
||||
data = json.load(f)
|
||||
# Remove comment fields
|
||||
return {k: v for k, v in data.items() if not k.startswith('_')}
|
||||
|
||||
@@ -26,4 +26,7 @@ TLE_SATELLITES = {
|
||||
'METEOR-M2-3': ('METEOR-M2 3',
|
||||
'1 57166U 23091A 25028.81539352 .00000157 00000+0 94432-4 0 9993',
|
||||
'2 57166 98.7690 91.9652 0001790 107.4859 252.6519 14.23646028 77844'),
|
||||
'METEOR-M2-4': ('METEOR-M2 4',
|
||||
'1 59051U 24039A 26061.19281216 .00000032 00000+0 34037-4 0 9998',
|
||||
'2 59051 98.6892 21.9068 0008025 115.2158 244.9852 14.22415711104050'),
|
||||
}
|
||||
|
||||
@@ -340,7 +340,7 @@ def get_frequency_risk(frequency_mhz: float) -> tuple[str, str]:
|
||||
Returns:
|
||||
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:
|
||||
if freq_range['start'] <= frequency_mhz <= freq_range['end']:
|
||||
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:
|
||||
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', []):
|
||||
if pattern in name_lower:
|
||||
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:
|
||||
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:
|
||||
return tracker_info
|
||||
|
||||
|
||||
@@ -0,0 +1,733 @@
|
||||
{
|
||||
"stations": [
|
||||
{
|
||||
"name": "USCG Kodiak",
|
||||
"callsign": "NOJ",
|
||||
"country": "US",
|
||||
"city": "Kodiak, AK",
|
||||
"coordinates": [57.78, -152.50],
|
||||
"frequencies": [
|
||||
{"khz": 2054, "description": "Night"},
|
||||
{"khz": 4298, "description": "Primary"},
|
||||
{"khz": 8459, "description": "Day"},
|
||||
{"khz": 12412.5, "description": "Extended"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "03:40", "duration_min": 148, "content": "Chart Series 1"},
|
||||
{"utc": "09:50", "duration_min": 138, "content": "Chart Series 2"},
|
||||
{"utc": "15:40", "duration_min": 148, "content": "Chart Series 3"},
|
||||
{"utc": "21:50", "duration_min": 98, "content": "Chart Series 4"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "USCG Boston",
|
||||
"callsign": "NMF",
|
||||
"country": "US",
|
||||
"city": "Boston, MA",
|
||||
"coordinates": [42.36, -71.04],
|
||||
"frequencies": [
|
||||
{"khz": 4235, "description": "Night"},
|
||||
{"khz": 6340.5, "description": "Primary"},
|
||||
{"khz": 9110, "description": "Day"},
|
||||
{"khz": 12750, "description": "Extended"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "02:30", "duration_min": 20, "content": "Wind/Wave Analysis"},
|
||||
{"utc": "04:38", "duration_min": 20, "content": "Sea State Analysis"},
|
||||
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "09:30", "duration_min": 20, "content": "48-Hour Surface Prog"},
|
||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "14:00", "duration_min": 20, "content": "24-Hour Surface Prog"},
|
||||
{"utc": "16:00", "duration_min": 20, "content": "Sea State Analysis"},
|
||||
{"utc": "18:10", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "22:00", "duration_min": 20, "content": "Satellite Image"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "USCG New Orleans",
|
||||
"callsign": "NMG",
|
||||
"country": "US",
|
||||
"city": "New Orleans, LA",
|
||||
"coordinates": [29.95, -90.07],
|
||||
"frequencies": [
|
||||
{"khz": 4317.9, "description": "Night"},
|
||||
{"khz": 8503.9, "description": "Primary"},
|
||||
{"khz": 12789.9, "description": "Day"},
|
||||
{"khz": 17146.4, "description": "Extended"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "03:00", "duration_min": 20, "content": "24-Hour Surface Prog"},
|
||||
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "09:00", "duration_min": 20, "content": "Sea State Analysis"},
|
||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "15:00", "duration_min": 20, "content": "48-Hour Surface Prog"},
|
||||
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "21:00", "duration_min": 20, "content": "Tropical Analysis"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "USCG Pt. Reyes",
|
||||
"callsign": "NMC",
|
||||
"country": "US",
|
||||
"city": "Pt. Reyes, CA",
|
||||
"coordinates": [38.07, -122.97],
|
||||
"frequencies": [
|
||||
{"khz": 4346, "description": "Night"},
|
||||
{"khz": 8682, "description": "Primary"},
|
||||
{"khz": 12786, "description": "Day"},
|
||||
{"khz": 17151.2, "description": "Extended"},
|
||||
{"khz": 22527, "description": "DX"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "01:40", "duration_min": 20, "content": "Wind/Wave Analysis"},
|
||||
{"utc": "06:55", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "11:20", "duration_min": 20, "content": "48-Hour Surface Prog"},
|
||||
{"utc": "14:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "18:40", "duration_min": 20, "content": "Sea State Analysis"},
|
||||
{"utc": "23:20", "duration_min": 20, "content": "Satellite Image"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "USCG Honolulu",
|
||||
"callsign": "KVM70",
|
||||
"country": "US",
|
||||
"city": "Honolulu, HI",
|
||||
"coordinates": [21.31, -157.86],
|
||||
"frequencies": [
|
||||
{"khz": 9982.5, "description": "Primary"},
|
||||
{"khz": 11090, "description": "Day"},
|
||||
{"khz": 16135, "description": "Extended"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "05:19", "duration_min": 20, "content": "Surface Prog"},
|
||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "17:19", "duration_min": 20, "content": "Sea State Analysis"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "RN Northwood",
|
||||
"callsign": "GYA",
|
||||
"country": "GB",
|
||||
"city": "Northwood, London",
|
||||
"coordinates": [51.63, -0.42],
|
||||
"frequencies": [
|
||||
{"khz": 2618.5, "description": "Night"},
|
||||
{"khz": 3280.5, "description": "Night Alt"},
|
||||
{"khz": 4610, "description": "Primary"},
|
||||
{"khz": 6834, "description": "Day Alt"},
|
||||
{"khz": 8040, "description": "Day"},
|
||||
{"khz": 11086.5, "description": "Extended"},
|
||||
{"khz": 12390, "description": "Persian Gulf"},
|
||||
{"khz": 18261, "description": "DX"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "03:30", "duration_min": 20, "content": "24-Hour Surface Prog"},
|
||||
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "08:00", "duration_min": 20, "content": "Sea State Forecast"},
|
||||
{"utc": "09:30", "duration_min": 20, "content": "Extended Forecast"},
|
||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "15:30", "duration_min": 20, "content": "48-Hour Surface Prog"},
|
||||
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "19:00", "duration_min": 20, "content": "Wave Period Forecast"},
|
||||
{"utc": "21:30", "duration_min": 20, "content": "Extended Forecast"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "DWD Hamburg/Pinneberg",
|
||||
"callsign": "DDH",
|
||||
"country": "DE",
|
||||
"city": "Pinneberg",
|
||||
"coordinates": [53.66, 9.80],
|
||||
"frequencies": [
|
||||
{"khz": 3855, "description": "Night (DDH3, 10kW)"},
|
||||
{"khz": 7880, "description": "Primary (DDK3, 20kW)"},
|
||||
{"khz": 13882.5, "description": "Day (DDK6, 20kW)"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "04:30", "duration_min": 20, "content": "Surface Analysis N. Atlantic"},
|
||||
{"utc": "07:15", "duration_min": 20, "content": "Surface Prog"},
|
||||
{"utc": "09:30", "duration_min": 20, "content": "Surface Analysis Europe"},
|
||||
{"utc": "10:07", "duration_min": 20, "content": "Sea State North Sea"},
|
||||
{"utc": "13:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "15:20", "duration_min": 20, "content": "Extended Prog"},
|
||||
{"utc": "15:40", "duration_min": 20, "content": "Sea Ice Chart"},
|
||||
{"utc": "16:30", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "21:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "21:15", "duration_min": 20, "content": "Surface Prog"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "JMA Tokyo",
|
||||
"callsign": "JMH",
|
||||
"country": "JP",
|
||||
"city": "Tokyo",
|
||||
"coordinates": [35.69, 139.69],
|
||||
"frequencies": [
|
||||
{"khz": 3622.5, "description": "Night"},
|
||||
{"khz": 7795, "description": "Primary"},
|
||||
{"khz": 13988.5, "description": "Day"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "01:30", "duration_min": 20, "content": "24-Hour Prog"},
|
||||
{"utc": "03:00", "duration_min": 20, "content": "Satellite Image"},
|
||||
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "07:30", "duration_min": 20, "content": "Wave Analysis"},
|
||||
{"utc": "09:00", "duration_min": 20, "content": "Satellite Image"},
|
||||
{"utc": "10:19", "duration_min": 20, "content": "Tropical Cyclone Info"},
|
||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "15:00", "duration_min": 20, "content": "Satellite Image"},
|
||||
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "21:00", "duration_min": 20, "content": "48-Hour Prog"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Kyodo News Tokyo",
|
||||
"callsign": "JJC",
|
||||
"country": "JP",
|
||||
"city": "Tokyo",
|
||||
"coordinates": [35.69, 139.69],
|
||||
"frequencies": [
|
||||
{"khz": 4316, "description": "Night"},
|
||||
{"khz": 8467.5, "description": "Primary"},
|
||||
{"khz": 12745.5, "description": "Day"},
|
||||
{"khz": 16971, "description": "Extended"},
|
||||
{"khz": 17069.6, "description": "DX"},
|
||||
{"khz": 22542, "description": "DX 2"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "00:00", "duration_min": 20, "content": "Press Photo/News Fax"},
|
||||
{"utc": "04:00", "duration_min": 20, "content": "Press Photo/News Fax"},
|
||||
{"utc": "08:00", "duration_min": 20, "content": "Press Photo/News Fax"},
|
||||
{"utc": "12:00", "duration_min": 20, "content": "Press Photo/News Fax"},
|
||||
{"utc": "16:00", "duration_min": 20, "content": "Press Photo/News Fax"},
|
||||
{"utc": "20:00", "duration_min": 20, "content": "Press Photo/News Fax"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Kagoshima Fisheries",
|
||||
"callsign": "JFX",
|
||||
"country": "JP",
|
||||
"city": "Kagoshima",
|
||||
"coordinates": [31.60, 130.56],
|
||||
"frequencies": [
|
||||
{"khz": 4274, "description": "Night"},
|
||||
{"khz": 8658, "description": "Primary"},
|
||||
{"khz": 13074, "description": "Day"},
|
||||
{"khz": 16907.5, "description": "Extended"},
|
||||
{"khz": 22559.6, "description": "DX"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "00:00", "duration_min": 20, "content": "Sea Surface Temp"},
|
||||
{"utc": "04:00", "duration_min": 20, "content": "Fishing Forecast"},
|
||||
{"utc": "08:00", "duration_min": 20, "content": "Sea Surface Temp"},
|
||||
{"utc": "12:00", "duration_min": 20, "content": "Current Chart"},
|
||||
{"utc": "16:00", "duration_min": 20, "content": "Fishing Forecast"},
|
||||
{"utc": "20:00", "duration_min": 20, "content": "Sea Surface Temp"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "KMA Seoul",
|
||||
"callsign": "HLL2",
|
||||
"country": "KR",
|
||||
"city": "Seoul",
|
||||
"coordinates": [37.57, 126.98],
|
||||
"frequencies": [
|
||||
{"khz": 3585, "description": "Night"},
|
||||
{"khz": 5857.5, "description": "Primary"},
|
||||
{"khz": 7433.5, "description": "Day"},
|
||||
{"khz": 9165, "description": "Extended"},
|
||||
{"khz": 13570, "description": "DX"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "03:00", "duration_min": 20, "content": "24-Hour Prog"},
|
||||
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "09:00", "duration_min": 20, "content": "Satellite Image"},
|
||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "15:00", "duration_min": 20, "content": "Sea State Analysis"},
|
||||
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "21:00", "duration_min": 20, "content": "48-Hour Prog"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Taipei Met",
|
||||
"callsign": "BMF",
|
||||
"country": "TW",
|
||||
"city": "Taipei",
|
||||
"coordinates": [25.03, 121.57],
|
||||
"frequencies": [
|
||||
{"khz": 4616, "description": "Primary"},
|
||||
{"khz": 8140, "description": "Day"},
|
||||
{"khz": 13900, "description": "Extended"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Bangkok Met",
|
||||
"callsign": "HSW64",
|
||||
"country": "TH",
|
||||
"city": "Bangkok",
|
||||
"coordinates": [13.76, 100.50],
|
||||
"frequencies": [
|
||||
{"khz": 7396.8, "description": "Primary"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Shanghai Met",
|
||||
"callsign": "XSG",
|
||||
"country": "CN",
|
||||
"city": "Shanghai",
|
||||
"coordinates": [31.23, 121.47],
|
||||
"frequencies": [
|
||||
{"khz": 4170, "description": "Night"},
|
||||
{"khz": 8302, "description": "Primary"},
|
||||
{"khz": 12382, "description": "Day"},
|
||||
{"khz": 16559, "description": "Extended"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Guangzhou Radio",
|
||||
"callsign": "XSQ",
|
||||
"country": "CN",
|
||||
"city": "Guangzhou",
|
||||
"coordinates": [23.13, 113.26],
|
||||
"frequencies": [
|
||||
{"khz": 4199.8, "description": "Night"},
|
||||
{"khz": 8412.5, "description": "Primary"},
|
||||
{"khz": 12629.3, "description": "Day"},
|
||||
{"khz": 16826.3, "description": "Extended"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Singapore Met",
|
||||
"callsign": "9VF",
|
||||
"country": "SG",
|
||||
"city": "Singapore",
|
||||
"coordinates": [1.35, 103.82],
|
||||
"frequencies": [
|
||||
{"khz": 16035, "description": "Primary"},
|
||||
{"khz": 17430, "description": "Alternate"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "New Delhi Met",
|
||||
"callsign": "ATP",
|
||||
"country": "IN",
|
||||
"city": "New Delhi",
|
||||
"coordinates": [28.61, 77.21],
|
||||
"frequencies": [
|
||||
{"khz": 7405, "description": "Night"},
|
||||
{"khz": 14842, "description": "Day"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Murmansk Met",
|
||||
"callsign": "RBW",
|
||||
"country": "RU",
|
||||
"city": "Murmansk",
|
||||
"coordinates": [68.97, 33.09],
|
||||
"frequencies": [
|
||||
{"khz": 6445.5, "description": "Night"},
|
||||
{"khz": 7907, "description": "Primary"},
|
||||
{"khz": 8444, "description": "Day"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "07:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "08:00", "duration_min": 20, "content": "Ice Chart"},
|
||||
{"utc": "14:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "14:30", "duration_min": 20, "content": "Surface Prog"},
|
||||
{"utc": "20:00", "duration_min": 20, "content": "Surface Analysis"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "St. Petersburg Met",
|
||||
"callsign": "RDD78",
|
||||
"country": "RU",
|
||||
"city": "St. Petersburg",
|
||||
"coordinates": [59.93, 30.32],
|
||||
"frequencies": [
|
||||
{"khz": 2640, "description": "Night"},
|
||||
{"khz": 4212, "description": "Primary"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Athens Met",
|
||||
"callsign": "SVJ4",
|
||||
"country": "GR",
|
||||
"city": "Athens",
|
||||
"coordinates": [37.97, 23.73],
|
||||
"frequencies": [
|
||||
{"khz": 4482.9, "description": "Night"},
|
||||
{"khz": 8106.9, "description": "Primary"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis Med"},
|
||||
{"utc": "09:00", "duration_min": 20, "content": "Surface Prog"},
|
||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis Med"},
|
||||
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis Med"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Charleville Met",
|
||||
"callsign": "VMC",
|
||||
"country": "AU",
|
||||
"city": "Charleville, QLD",
|
||||
"coordinates": [-26.41, 146.24],
|
||||
"frequencies": [
|
||||
{"khz": 2628, "description": "Night"},
|
||||
{"khz": 5100, "description": "Primary"},
|
||||
{"khz": 11030, "description": "Day"},
|
||||
{"khz": 13920, "description": "Extended"},
|
||||
{"khz": 20469, "description": "DX"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "00:00", "duration_min": 20, "content": "MSLP Analysis"},
|
||||
{"utc": "03:00", "duration_min": 20, "content": "Prognosis"},
|
||||
{"utc": "06:00", "duration_min": 20, "content": "MSLP Analysis"},
|
||||
{"utc": "09:00", "duration_min": 20, "content": "Sea/Swell Chart"},
|
||||
{"utc": "12:00", "duration_min": 20, "content": "MSLP Analysis"},
|
||||
{"utc": "18:00", "duration_min": 20, "content": "MSLP Analysis"},
|
||||
{"utc": "19:00", "duration_min": 20, "content": "Prognosis"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Wiluna Met",
|
||||
"callsign": "VMW",
|
||||
"country": "AU",
|
||||
"city": "Wiluna, WA",
|
||||
"coordinates": [-26.59, 120.23],
|
||||
"frequencies": [
|
||||
{"khz": 5755, "description": "Night"},
|
||||
{"khz": 7535, "description": "Primary"},
|
||||
{"khz": 10555, "description": "Day"},
|
||||
{"khz": 15615, "description": "Extended"},
|
||||
{"khz": 18060, "description": "DX"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "00:00", "duration_min": 20, "content": "MSLP Analysis"},
|
||||
{"utc": "06:00", "duration_min": 20, "content": "MSLP Analysis"},
|
||||
{"utc": "11:00", "duration_min": 20, "content": "Prognosis"},
|
||||
{"utc": "18:00", "duration_min": 20, "content": "MSLP Analysis"},
|
||||
{"utc": "21:00", "duration_min": 20, "content": "Sea/Swell Chart"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "NZ MetService",
|
||||
"callsign": "ZKLF",
|
||||
"country": "NZ",
|
||||
"city": "Auckland",
|
||||
"coordinates": [-36.85, 174.76],
|
||||
"frequencies": [
|
||||
{"khz": 3247.4, "description": "Night"},
|
||||
{"khz": 5807, "description": "Primary"},
|
||||
{"khz": 9459, "description": "Day"},
|
||||
{"khz": 13550.5, "description": "Extended"},
|
||||
{"khz": 16340.1, "description": "DX"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "CFH Halifax",
|
||||
"callsign": "CFH",
|
||||
"country": "CA",
|
||||
"city": "Halifax, NS",
|
||||
"coordinates": [44.65, -63.57],
|
||||
"frequencies": [
|
||||
{"khz": 4271, "description": "Night"},
|
||||
{"khz": 6496.4, "description": "Primary"},
|
||||
{"khz": 10536, "description": "Day"},
|
||||
{"khz": 13510, "description": "Extended"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "03:00", "duration_min": 20, "content": "Surface Prog"},
|
||||
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "22:22", "duration_min": 20, "content": "Ice Chart"},
|
||||
{"utc": "23:01", "duration_min": 20, "content": "Surface Analysis"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "CCG Iqaluit",
|
||||
"callsign": "VFF",
|
||||
"country": "CA",
|
||||
"city": "Iqaluit, NU",
|
||||
"coordinates": [63.75, -68.52],
|
||||
"frequencies": [
|
||||
{"khz": 3253, "description": "Night"},
|
||||
{"khz": 7710, "description": "Day"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "00:10", "duration_min": 20, "content": "Ice Chart"},
|
||||
{"utc": "05:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "07:00", "duration_min": 20, "content": "Ice Chart"},
|
||||
{"utc": "10:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "11:00", "duration_min": 20, "content": "Ice Chart"},
|
||||
{"utc": "21:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "23:30", "duration_min": 20, "content": "Ice Chart"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "CCG Inuvik",
|
||||
"callsign": "VFA",
|
||||
"country": "CA",
|
||||
"city": "Inuvik, NT",
|
||||
"coordinates": [68.36, -133.72],
|
||||
"frequencies": [
|
||||
{"khz": 4292, "description": "Night"},
|
||||
{"khz": 8457.8, "description": "Primary"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "02:00", "duration_min": 20, "content": "Ice Chart"},
|
||||
{"utc": "16:30", "duration_min": 20, "content": "Ice Chart"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "CCG Sydney",
|
||||
"callsign": "VCO",
|
||||
"country": "CA",
|
||||
"city": "Sydney, NS",
|
||||
"coordinates": [46.14, -60.19],
|
||||
"frequencies": [
|
||||
{"khz": 4416, "description": "Night"},
|
||||
{"khz": 6915.1, "description": "Primary"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "11:21", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "11:42", "duration_min": 20, "content": "Surface Prog"},
|
||||
{"utc": "17:41", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "22:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "23:31", "duration_min": 20, "content": "Surface Prog"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Cape Naval",
|
||||
"callsign": "ZSJ",
|
||||
"country": "ZA",
|
||||
"city": "Cape Town",
|
||||
"coordinates": [-33.92, 18.42],
|
||||
"frequencies": [
|
||||
{"khz": 4014, "description": "Night"},
|
||||
{"khz": 7508, "description": "Primary"},
|
||||
{"khz": 13538, "description": "Day"},
|
||||
{"khz": 18238, "description": "Extended"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "04:30", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "05:00", "duration_min": 20, "content": "Sea State"},
|
||||
{"utc": "06:30", "duration_min": 20, "content": "Surface Prog"},
|
||||
{"utc": "07:30", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "08:00", "duration_min": 20, "content": "Satellite Image"},
|
||||
{"utc": "10:30", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "11:00", "duration_min": 20, "content": "Sea State"},
|
||||
{"utc": "15:30", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "15:40", "duration_min": 20, "content": "Surface Prog"},
|
||||
{"utc": "22:30", "duration_min": 20, "content": "Surface Analysis"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Valparaiso Naval",
|
||||
"callsign": "CBV",
|
||||
"country": "CL",
|
||||
"city": "Valparaiso",
|
||||
"coordinates": [-33.05, -71.62],
|
||||
"frequencies": [
|
||||
{"khz": 4228, "description": "Night"},
|
||||
{"khz": 8677, "description": "Primary"},
|
||||
{"khz": 17146.4, "description": "Day"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "11:15", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "11:30", "duration_min": 20, "content": "Surface Prog"},
|
||||
{"utc": "16:30", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "16:45", "duration_min": 20, "content": "Sea State"},
|
||||
{"utc": "19:15", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "19:30", "duration_min": 20, "content": "Surface Prog"},
|
||||
{"utc": "22:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "23:10", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "23:25", "duration_min": 20, "content": "Sea State"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Magallanes Naval",
|
||||
"callsign": "CBM",
|
||||
"country": "CL",
|
||||
"city": "Punta Arenas",
|
||||
"coordinates": [-53.16, -70.91],
|
||||
"frequencies": [
|
||||
{"khz": 4322, "description": "Night"},
|
||||
{"khz": 8696, "description": "Primary"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "01:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "13:00", "duration_min": 20, "content": "Surface Analysis"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Rio de Janeiro Naval",
|
||||
"callsign": "PWZ33",
|
||||
"country": "BR",
|
||||
"city": "Rio de Janeiro",
|
||||
"coordinates": [-22.91, -43.17],
|
||||
"frequencies": [
|
||||
{"khz": 12665, "description": "Primary"},
|
||||
{"khz": 16978, "description": "Day"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "07:45", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "16:30", "duration_min": 20, "content": "Surface Analysis"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Dakar Met",
|
||||
"callsign": "6VU",
|
||||
"country": "SN",
|
||||
"city": "Dakar",
|
||||
"coordinates": [14.69, -17.44],
|
||||
"frequencies": [
|
||||
{"khz": 13667.5, "description": "Primary"},
|
||||
{"khz": 19750, "description": "Day"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Misaki Fisheries",
|
||||
"callsign": "JFC",
|
||||
"country": "JP",
|
||||
"city": "Miura",
|
||||
"coordinates": [35.14, 139.62],
|
||||
"frequencies": [
|
||||
{"khz": 8616, "description": "Primary"},
|
||||
{"khz": 13074, "description": "Day"},
|
||||
{"khz": 17231, "description": "Extended"}
|
||||
],
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"schedule": [
|
||||
{"utc": "00:00", "duration_min": 20, "content": "Sea Surface Temp"},
|
||||
{"utc": "06:00", "duration_min": 20, "content": "Current Chart"},
|
||||
{"utc": "12:00", "duration_min": 20, "content": "Fishing Forecast"},
|
||||
{"utc": "18:00", "duration_min": 20, "content": "Sea Surface Temp"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
# INTERCEPT - Signal Intelligence Platform
|
||||
# Docker Compose configuration for easy deployment
|
||||
#
|
||||
# Uses gunicorn + gevent production server via start.sh (handles concurrent SSE/WebSocket)
|
||||
#
|
||||
# Basic usage (build locally):
|
||||
# docker compose --profile basic up -d --build
|
||||
#
|
||||
@@ -18,6 +20,8 @@ services:
|
||||
container_name: intercept
|
||||
ports:
|
||||
- "5050:5050"
|
||||
# Uncomment for HTTPS support (set INTERCEPT_HTTPS=true below)
|
||||
# - "5443:5443"
|
||||
# Privileged mode required for USB SDR device access
|
||||
privileged: true
|
||||
# USB device mapping for all USB devices
|
||||
@@ -29,9 +33,13 @@ services:
|
||||
# Optional: mount logs directory
|
||||
# - ./logs:/app/logs
|
||||
environment:
|
||||
- TZ=${TZ:-UTC}
|
||||
- INTERCEPT_HOST=0.0.0.0
|
||||
- INTERCEPT_PORT=5050
|
||||
- INTERCEPT_LOG_LEVEL=INFO
|
||||
# HTTPS support (auto-generates self-signed cert)
|
||||
# - INTERCEPT_HTTPS=true
|
||||
# - INTERCEPT_PORT=5443
|
||||
# ADS-B history is disabled by default
|
||||
# To enable, use: docker compose --profile history up -d
|
||||
# - INTERCEPT_ADSB_HISTORY_ENABLED=true
|
||||
@@ -70,6 +78,8 @@ services:
|
||||
- adsb_db
|
||||
ports:
|
||||
- "5050:5050"
|
||||
# Uncomment for HTTPS support (set INTERCEPT_HTTPS=true below)
|
||||
# - "5443:5443"
|
||||
# Privileged mode required for USB SDR device access
|
||||
privileged: true
|
||||
# USB device mapping for all USB devices
|
||||
@@ -78,9 +88,13 @@ services:
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
- TZ=${TZ:-UTC}
|
||||
- INTERCEPT_HOST=0.0.0.0
|
||||
- INTERCEPT_PORT=5050
|
||||
- INTERCEPT_LOG_LEVEL=INFO
|
||||
# HTTPS support (auto-generates self-signed cert)
|
||||
# - INTERCEPT_HTTPS=true
|
||||
# - INTERCEPT_PORT=5443
|
||||
- INTERCEPT_ADSB_HISTORY_ENABLED=true
|
||||
- INTERCEPT_ADSB_DB_HOST=adsb_db
|
||||
- INTERCEPT_ADSB_DB_PORT=5432
|
||||
@@ -108,6 +122,7 @@ services:
|
||||
profiles:
|
||||
- history
|
||||
environment:
|
||||
- TZ=${TZ:-UTC}
|
||||
- POSTGRES_DB=intercept_adsb
|
||||
- POSTGRES_USER=intercept
|
||||
- POSTGRES_PASSWORD=intercept
|
||||
|
||||
@@ -38,8 +38,8 @@ The controller is the main Intercept application:
|
||||
|
||||
```bash
|
||||
cd intercept
|
||||
python app.py
|
||||
# Runs on http://localhost:5050
|
||||
./setup.sh # First-time setup (choose install profiles)
|
||||
sudo ./start.sh # Production server on http://localhost:5050
|
||||
```
|
||||
|
||||
### 2. Configure an Agent
|
||||
|
||||
+74
-2
@@ -100,11 +100,30 @@ Digital Selective Calling (DSC) monitoring on the international maritime distres
|
||||
- **CSV/JSON export** - export captured messages for offline analysis
|
||||
- **Integrated with ADS-B dashboard** - VDL2 messages linked to aircraft tracking
|
||||
|
||||
## CW/Morse Code Decoder
|
||||
|
||||
- **Custom Goertzel tone detection** for CW (continuous wave) Morse decoding
|
||||
- **OOK/AM envelope detection** mode for on-off keying signals in ISM bands
|
||||
- **HF frequency presets** for amateur CW bands (160m-10m)
|
||||
- **ISM band presets** for OOK envelope mode (315 MHz, 433 MHz, 868 MHz, 915 MHz)
|
||||
- **Real-time character and word output** with WPM estimation
|
||||
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
|
||||
|
||||
## WeFax (Weather Fax)
|
||||
|
||||
- **HF weather fax reception** from marine and meteorological broadcast stations
|
||||
- **Broadcast timeline** with scheduled transmission times by station
|
||||
- **Auto-scheduler** for unattended capture of scheduled broadcasts
|
||||
- **Image gallery** with timestamped decoded weather charts
|
||||
- **Station presets** for major WeFax broadcasters worldwide
|
||||
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
|
||||
|
||||
## Listening Post
|
||||
|
||||
- **Wideband frequency scanning** via rtl_power sweep with SNR filtering
|
||||
- **Real-time audio monitoring** with FM and SSB demodulation
|
||||
- **Cross-module frequency routing** from scanner to decoders
|
||||
- **Waterfall spectrum display** for visual signal identification
|
||||
- **Customizable frequency presets** and band bookmarks
|
||||
- **Multi-SDR support** - RTL-SDR, LimeSDR, HackRF, Airspy, SDRplay
|
||||
|
||||
@@ -170,6 +189,16 @@ Digital Selective Calling (DSC) monitoring on the international maritime distres
|
||||
- **Auto-refresh** - 5-minute polling with manual refresh option
|
||||
- **No SDR required** - Data fetched from NOAA SWPC, NASA SDO, and HamQSL public APIs
|
||||
|
||||
## Radiosonde Weather Balloon Tracking
|
||||
|
||||
- **400-406 MHz reception** via radiosonde_auto_rx for weather balloon telemetry
|
||||
- **Frequency presets** for common radiosonde bands
|
||||
- **Real-time telemetry** - altitude, temperature, humidity, pressure, GPS position
|
||||
- **Interactive map** with balloon trajectory and burst point prediction
|
||||
- **Station location** with configurable observer position
|
||||
- **Distance tracking** - real-time distance-to-balloon calculation
|
||||
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
|
||||
|
||||
## Satellite Tracking
|
||||
|
||||
- **Full-screen dashboard** - dedicated popout with polar plot and ground track
|
||||
@@ -247,6 +276,34 @@ Search and rescue Bluetooth device location with GPS-tagged signal trail mapping
|
||||
- Bluetooth adapter (built-in or USB)
|
||||
- GPS receiver (optional, falls back to manual coordinates)
|
||||
|
||||
## WiFi Locate
|
||||
|
||||
Locate a WiFi access point by BSSID using real-time signal strength tracking.
|
||||
|
||||
### Core Features
|
||||
- **Target by BSSID** - Enter any MAC address or hand off from the WiFi scanner
|
||||
- **Real-time signal meter** - Large dBm display with color-coded strength (good/medium/weak)
|
||||
- **20-segment signal bar** - Visual proximity indicator with red/yellow/green segments
|
||||
- **RSSI history chart** - Canvas sparkline showing signal trend over time
|
||||
- **Distance estimation** - Log-distance path loss model with configurable environment presets
|
||||
- **Audio proximity alerts** - Web Audio API tones that increase in pitch and frequency as signal strengthens
|
||||
- **Signal lost detection** - 30-second timeout with visual overlay when target disappears
|
||||
- **Hand-off from WiFi mode** - One-click transfer from WiFi detail drawer to WiFi Locate
|
||||
- **Stats tracking** - Current, min, max, and average RSSI across session
|
||||
|
||||
### Environment Presets
|
||||
- **Open Field** (n=2.0) - Free space path loss
|
||||
- **Outdoor** (n=2.8) - Typical outdoor environment (default)
|
||||
- **Indoor** (n=3.5) - Indoor with walls and obstacles
|
||||
|
||||
### Mode Transition
|
||||
- WiFi scan is preserved when switching between WiFi and WiFi Locate modes
|
||||
- Deep scan auto-starts if not already running
|
||||
|
||||
### Requirements
|
||||
- WiFi adapter capable of monitor mode
|
||||
- aircrack-ng suite for deep scanning
|
||||
|
||||
## GPS Mode
|
||||
|
||||
Real-time GPS position tracking with live map visualization.
|
||||
@@ -270,7 +327,7 @@ Technical Surveillance Countermeasures (TSCM) screening for detecting wireless s
|
||||
### Wireless Sweep Features
|
||||
- **BLE scanning** with manufacturer data detection (AirTags, Tile, SmartTags, ESP32)
|
||||
- **WiFi scanning** for rogue APs, hidden SSIDs, camera devices
|
||||
- **RF spectrum analysis** (requires RTL-SDR) - FM bugs, ISM bands, video transmitters
|
||||
- **RF spectrum analysis** (RTL-SDR or HackRF) - FM bugs, ISM bands, video transmitters
|
||||
- **Cross-protocol correlation** - links devices across BLE/WiFi/RF
|
||||
- **Baseline comparison** - detect new/unknown devices vs known environment
|
||||
|
||||
@@ -369,10 +426,20 @@ Deploy lightweight sensor nodes across multiple locations and aggregate data to
|
||||
- **Redundancy** - Multiple nodes for reliable coverage
|
||||
- **Triangulation** - Use multiple GPS-enabled agents for signal location
|
||||
|
||||
## System Health
|
||||
|
||||
- **Telemetry dashboard** with real-time system metrics
|
||||
- **Process monitoring** for all running SDR tools and decoders
|
||||
- **CPU, memory, and disk usage** tracking
|
||||
- **SDR device status** overview
|
||||
- **No SDR required** - monitors system health independently
|
||||
|
||||
## User Interface
|
||||
|
||||
- **Mode-specific header stats** - real-time badges showing key metrics per mode
|
||||
- **UTC clock** - always visible in header for time-critical operations
|
||||
- **SSE connection status indicator** - real-time connection state with SSEManager and exponential backoff reconnection
|
||||
- **Accessibility** - aria-labels, form label associations, keyboard list navigation, and destructive action confirmation modals
|
||||
- **Active mode indicator** - shows current mode with pulse animation
|
||||
- **Collapsible sections** - click any header to collapse/expand
|
||||
- **Panel styling** - gradient backgrounds with indicator dots
|
||||
@@ -429,14 +496,19 @@ The settings modal shows availability status for each bundled asset:
|
||||
## General
|
||||
|
||||
- **Web-based interface** - no desktop app needed
|
||||
- **Production server** - gunicorn + gevent via `start.sh` for concurrent SSE/WebSocket handling (falls back to Flask dev server)
|
||||
- **Live message streaming** via Server-Sent Events (SSE)
|
||||
- **Audio alerts** with mute toggle
|
||||
- **Message export** to CSV/JSON
|
||||
- **Signal activity meter** and waterfall display
|
||||
- **Message logging** to file with timestamps
|
||||
- **Multi-SDR hardware support** - RTL-SDR, LimeSDR, HackRF
|
||||
- **HTTPS support** via `INTERCEPT_HTTPS` configuration for secure deployments
|
||||
- **Voice alerts** for configurable event notifications across modes
|
||||
- **Multi-SDR hardware support** - RTL-SDR, LimeSDR, HackRF, Airspy, SDRplay
|
||||
- **Automatic device detection** across all supported hardware
|
||||
- **Hardware-specific validation** - frequency/gain ranges per device type
|
||||
- **Tool path overrides** via `INTERCEPT_*_PATH` environment variables
|
||||
- **Native Homebrew detection** for Apple Silicon tool paths
|
||||
- **Configurable gain and PPM correction**
|
||||
- **Device intelligence** dashboard with tracking
|
||||
- **GPS dongle support** - USB GPS receivers for precise observer location
|
||||
|
||||
+172
-9
@@ -14,7 +14,39 @@ INTERCEPT automatically detects connected devices.
|
||||
|
||||
## Quick Install
|
||||
|
||||
### macOS (Homebrew)
|
||||
### Recommended: Use the Setup Script
|
||||
|
||||
The setup script provides an interactive menu with install profiles for selective installation:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/smittix/intercept.git
|
||||
cd intercept
|
||||
./setup.sh
|
||||
```
|
||||
|
||||
On first run, a guided wizard walks you through profile selection:
|
||||
|
||||
| Profile | What it installs |
|
||||
|---------|-----------------|
|
||||
| Core SIGINT | rtl_sdr, multimon-ng, rtl_433, dump1090, acarsdec, dumpvdl2, ffmpeg, gpsd |
|
||||
| Maritime & Radio | AIS-catcher, direwolf |
|
||||
| Weather & Space | SatDump, radiosonde_auto_rx |
|
||||
| RF Security | aircrack-ng, HackRF, BlueZ, hcxtools, Ubertooth, SoapySDR |
|
||||
| Full SIGINT | All of the above |
|
||||
|
||||
For headless/CI installs:
|
||||
```bash
|
||||
./setup.sh --non-interactive # Install everything
|
||||
./setup.sh --profile=core,maritime # Install specific profiles
|
||||
```
|
||||
|
||||
After installation, use the menu to manage your setup:
|
||||
```bash
|
||||
./setup.sh # Opens interactive menu
|
||||
./setup.sh --health-check # Verify installation
|
||||
```
|
||||
|
||||
### Manual Install: macOS (Homebrew)
|
||||
|
||||
```bash
|
||||
# Install Homebrew if needed
|
||||
@@ -36,7 +68,7 @@ brew install soapysdr limesuite soapylms7
|
||||
brew install hackrf soapyhackrf
|
||||
```
|
||||
|
||||
### Debian / Ubuntu / Raspberry Pi OS
|
||||
### Manual Install: Debian / Ubuntu / Raspberry Pi OS
|
||||
|
||||
```bash
|
||||
# Update package lists
|
||||
@@ -94,6 +126,126 @@ sudo modprobe -r dvb_usb_rtl28xxu
|
||||
|
||||
---
|
||||
|
||||
## Multiple RTL-SDR Dongles
|
||||
|
||||
If you're running two (or more) RTL-SDR dongles on the same machine, they ship with the same default serial number so Linux can't tell them apart reliably. Follow these steps to give each a unique identity.
|
||||
|
||||
### Step 1: Blacklist the DVB-T driver
|
||||
|
||||
Already covered above, but make sure this is done first — the kernel's DVB driver will grab the dongles before librtlsdr can:
|
||||
|
||||
```bash
|
||||
echo "blacklist dvb_usb_rtl28xxu" | sudo tee /etc/modprobe.d/blacklist-rtl.conf
|
||||
sudo modprobe -r dvb_usb_rtl28xxu
|
||||
```
|
||||
|
||||
### Step 2: Burn unique serial numbers
|
||||
|
||||
Each dongle has an EEPROM that stores a serial number. By default they're all `00000001`. You need to give each one a unique serial.
|
||||
|
||||
**Plug in only the first dongle**, then:
|
||||
|
||||
```bash
|
||||
rtl_eeprom -d 0 -s 00000001
|
||||
```
|
||||
|
||||
**Unplug it, plug in the second dongle**, then:
|
||||
|
||||
```bash
|
||||
rtl_eeprom -d 0 -s 00000002
|
||||
```
|
||||
|
||||
> Pick any 8-digit hex serials you like. The `-d 0` means "device index 0" (the only one plugged in).
|
||||
|
||||
Unplug and replug both dongles after writing.
|
||||
|
||||
### Step 3: Verify
|
||||
|
||||
With both plugged in:
|
||||
|
||||
```bash
|
||||
rtl_test -t
|
||||
```
|
||||
|
||||
You should see:
|
||||
|
||||
```
|
||||
0: Realtek, RTL2838UHIDIR, SN: 00000001
|
||||
1: Realtek, RTL2838UHIDIR, SN: 00000002
|
||||
```
|
||||
|
||||
**Tip:** If you don't know which physical dongle has which serial, unplug one and run `rtl_test -t` — the one still detected is the one still plugged in.
|
||||
|
||||
### Step 4: Udev rules with stable symlinks
|
||||
|
||||
Create rules that give each dongle a persistent name based on its serial:
|
||||
|
||||
```bash
|
||||
sudo bash -c 'cat > /etc/udev/rules.d/20-rtlsdr.rules << EOF
|
||||
# RTL-SDR dongles - permissions and stable symlinks by serial
|
||||
SUBSYSTEM=="usb", ATTR{idVendor}=="0bda", ATTR{idProduct}=="2838", MODE="0666"
|
||||
SUBSYSTEM=="usb", ATTR{idVendor}=="0bda", ATTR{idProduct}=="2832", MODE="0666"
|
||||
|
||||
# Symlinks by serial — change names/serials to match your hardware
|
||||
SUBSYSTEM=="usb", ATTR{idVendor}=="0bda", ATTRS{serial}=="00000001", SYMLINK+="sdr-dongle1"
|
||||
SUBSYSTEM=="usb", ATTR{idVendor}=="0bda", ATTRS{serial}=="00000002", SYMLINK+="sdr-dongle2"
|
||||
EOF'
|
||||
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger
|
||||
```
|
||||
|
||||
After replugging, you'll have `/dev/sdr-dongle1` and `/dev/sdr-dongle2`.
|
||||
|
||||
### Step 5: USB power (Raspberry Pi)
|
||||
|
||||
Two dongles can draw more current than the Pi allows by default:
|
||||
|
||||
```bash
|
||||
# In /boot/firmware/config.txt, add:
|
||||
usb_max_current_enable=1
|
||||
```
|
||||
|
||||
Disable USB autosuspend so dongles don't get powered off:
|
||||
|
||||
```bash
|
||||
# In /etc/default/grub or kernel cmdline, add:
|
||||
usbcore.autosuspend=-1
|
||||
```
|
||||
|
||||
Or via udev:
|
||||
|
||||
```bash
|
||||
echo 'ACTION=="add", SUBSYSTEM=="usb", ATTR{power/autosuspend}="-1"' | \
|
||||
sudo tee /etc/udev/rules.d/50-usb-autosuspend.rules
|
||||
```
|
||||
|
||||
### Step 6: Docker access
|
||||
|
||||
Your `docker-compose.yml` needs privileged mode and USB passthrough:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
intercept:
|
||||
privileged: true
|
||||
volumes:
|
||||
- /dev/bus/usb:/dev/bus/usb
|
||||
```
|
||||
|
||||
INTERCEPT auto-detects both dongles inside the container via `rtl_test -t` and addresses them by device index (`-d 0`, `-d 1`).
|
||||
|
||||
### Quick reference
|
||||
|
||||
| Step | What | Why |
|
||||
|------|------|-----|
|
||||
| Blacklist DVB | `/etc/modprobe.d/blacklist-rtl.conf` | Kernel won't steal the dongles |
|
||||
| Burn serials | `rtl_eeprom -d 0 -s <serial>` | Unique identity per dongle |
|
||||
| Udev rules | `/etc/udev/rules.d/20-rtlsdr.rules` | Permissions + stable `/dev/sdr-*` names |
|
||||
| USB power | `config.txt` + autosuspend off | Enough current for two dongles on a Pi |
|
||||
| Docker | `privileged: true` + USB volume | Container sees both dongles |
|
||||
|
||||
---
|
||||
|
||||
## Verify Installation
|
||||
|
||||
### Check dependencies
|
||||
@@ -119,11 +271,19 @@ SoapySDRUtil --find
|
||||
./setup.sh
|
||||
```
|
||||
|
||||
This automatically:
|
||||
- Detects your OS
|
||||
- Creates a virtual environment if needed (for PEP 668 systems)
|
||||
- Installs Python dependencies
|
||||
- Checks for required tools
|
||||
The setup wizard automatically:
|
||||
- Detects your OS (macOS, Debian/Ubuntu, DragonOS)
|
||||
- Lets you choose install profiles (Core, Maritime, Weather, Security, Full, Custom)
|
||||
- Creates a virtual environment with system site-packages
|
||||
- Installs Python dependencies (core + optional)
|
||||
- Runs a health check to verify everything works
|
||||
|
||||
After initial setup, use the menu to manage your environment:
|
||||
- **Install / Add Modules** — add tools you didn't install initially
|
||||
- **System Health Check** — verify all tools and dependencies
|
||||
- **Environment Configurator** — set `INTERCEPT_*` variables interactively
|
||||
- **Update Tools** — rebuild source-built tools (dump1090, SatDump, etc.)
|
||||
- **View Status** — see what's installed at a glance
|
||||
|
||||
### Manual setup
|
||||
```bash
|
||||
@@ -139,10 +299,13 @@ pip install -r requirements.txt
|
||||
After installation:
|
||||
|
||||
```bash
|
||||
sudo -E venv/bin/python intercept.py
|
||||
sudo ./start.sh
|
||||
|
||||
# Custom port
|
||||
INTERCEPT_PORT=8080 sudo -E venv/bin/python intercept.py
|
||||
sudo ./start.sh -p 8080
|
||||
|
||||
# HTTPS
|
||||
sudo ./start.sh --https
|
||||
```
|
||||
|
||||
Open **http://localhost:5050** in your browser.
|
||||
|
||||
+2
-3
@@ -18,10 +18,9 @@ By default, INTERCEPT binds to `0.0.0.0:5050`, making it accessible from any net
|
||||
echo "block in on en0 proto tcp from any to any port 5050" | sudo pfctl -ef -
|
||||
```
|
||||
|
||||
2. **Bind to Localhost**: For local-only access, set the host environment variable:
|
||||
2. **Bind to Localhost**: For local-only access, set the host or use the CLI flag:
|
||||
```bash
|
||||
export INTERCEPT_HOST=127.0.0.1
|
||||
python intercept.py
|
||||
sudo ./start.sh -H 127.0.0.1
|
||||
```
|
||||
|
||||
3. **Trusted Networks Only**: Only run INTERCEPT on networks you trust. The application has no authentication mechanism.
|
||||
|
||||
+17
-7
@@ -25,7 +25,7 @@ sudo apt install python3-flask python3-requests python3-serial python3-skyfield
|
||||
# Then create venv with system packages
|
||||
python3 -m venv --system-site-packages venv
|
||||
source venv/bin/activate
|
||||
sudo venv/bin/python intercept.py
|
||||
sudo ./start.sh
|
||||
```
|
||||
|
||||
### "error: externally-managed-environment" (pip blocked)
|
||||
@@ -61,18 +61,21 @@ sudo apt install python3.11 python3.11-venv python3-pip
|
||||
python3.11 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
sudo venv/bin/python intercept.py
|
||||
sudo ./start.sh
|
||||
```
|
||||
|
||||
### Alternative: Use the setup script
|
||||
|
||||
The setup script handles all installation automatically, including apt packages:
|
||||
The setup script handles all installation automatically, including apt packages and source builds:
|
||||
|
||||
```bash
|
||||
chmod +x setup.sh
|
||||
./setup.sh
|
||||
./setup.sh # Interactive wizard (first run) or menu
|
||||
./setup.sh --non-interactive # Headless full install
|
||||
./setup.sh --health-check # Diagnose installation issues
|
||||
```
|
||||
|
||||
The setup menu also includes a **System Health Check** (option 2) that verifies all tools, SDR devices, ports, permissions, and Python packages — useful for diagnosing installation problems.
|
||||
|
||||
### "pip: command not found"
|
||||
|
||||
```bash
|
||||
@@ -336,7 +339,7 @@ rtl_fm -M am -f 118000000 -s 24000 -r 24000 -g 40 2>/dev/null | \
|
||||
|
||||
Run INTERCEPT with sudo:
|
||||
```bash
|
||||
sudo -E venv/bin/python intercept.py
|
||||
sudo ./start.sh
|
||||
```
|
||||
|
||||
### Interface not found after enabling monitor mode
|
||||
@@ -373,7 +376,14 @@ sudo usermod -a -G bluetooth $USER
|
||||
|
||||
### Cannot install dump1090 in Debian (ADS-B mode)
|
||||
|
||||
On newer Debian versions, dump1090 may not be in repositories. The recommended action is to build from source or use the setup.sh script which will do it for you.
|
||||
On newer Debian versions, dump1090 may not be in repositories. Use the setup script which builds it from source automatically:
|
||||
|
||||
```bash
|
||||
./setup.sh # Select Core SIGINT profile, or
|
||||
./setup.sh --profile=core # Install core tools including dump1090
|
||||
```
|
||||
|
||||
The setup menu's **Install / Add Modules** option also lets you install dump1090 individually via the Custom tool checklist.
|
||||
|
||||
### No aircraft appearing (ADS-B mode)
|
||||
|
||||
|
||||
+3
-2
@@ -212,15 +212,16 @@ Extended base for full-screen dashboards (maps, visualizations).
|
||||
| `websdr` | WebSDR |
|
||||
| `subghz` | Sub-GHz analyzer |
|
||||
| `bt_locate` | BT Locate |
|
||||
| `wifi_locate` | WiFi Locate |
|
||||
| `analytics` | Analytics dashboard |
|
||||
| `spaceweather` | Space weather |
|
||||
### Navigation Groups
|
||||
### Navigation Groups
|
||||
|
||||
The navigation is organized into groups:
|
||||
- **Signals**: Pager, 433MHz, Meters, Listening Post, SubGHz
|
||||
- **Tracking**: Aircraft, Vessels, APRS, GPS
|
||||
- **Space**: Satellite, ISS SSTV, Weather Sat, HF SSTV, Space Weather
|
||||
- **Wireless**: WiFi, Bluetooth, BT Locate, Meshtastic
|
||||
- **Wireless**: WiFi, Bluetooth, BT Locate, WiFi Locate, Meshtastic
|
||||
- **Intel**: TSCM, Analytics, Spy Stations, WebSDR
|
||||
|
||||
---
|
||||
|
||||
+53
-2
@@ -172,7 +172,7 @@ Set the following environment variables (Docker recommended):
|
||||
```bash
|
||||
INTERCEPT_ADSB_AUTO_START=true \
|
||||
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
|
||||
sudo -E venv/bin/python intercept.py
|
||||
sudo ./start.sh
|
||||
```
|
||||
|
||||
**Docker example (.env)**
|
||||
@@ -377,6 +377,39 @@ Digital Selective Calling monitoring runs alongside AIS:
|
||||
- The RSSI chart shows signal trend over time — use it to determine if you're getting closer
|
||||
- Clear the trail when starting a new search area
|
||||
|
||||
## WiFi Locate Mode
|
||||
|
||||
1. **Set Target** - Enter a BSSID (MAC address) in AA:BB:CC:DD:EE:FF format, or hand off from WiFi mode
|
||||
2. **Choose Environment** - Select the RF environment preset:
|
||||
- **Open Field** (n=2.0) - Best for open areas with line-of-sight
|
||||
- **Outdoor** (n=2.8) - Default, works well in most outdoor settings
|
||||
- **Indoor** (n=3.5) - For buildings with walls and obstacles
|
||||
3. **Start Locate** - Click "Start Locate" to begin tracking
|
||||
4. **Monitor Signal** - The HUD shows:
|
||||
- Large dBm reading with color coding (green/yellow/red)
|
||||
- 20-segment signal bar for quick visual reference
|
||||
- Estimated distance based on path loss model
|
||||
- RSSI history chart for trend analysis
|
||||
- Current/min/max/average statistics
|
||||
5. **Follow the Signal** - Move towards stronger signal (higher RSSI / closer distance)
|
||||
6. **Audio Alerts** - Enable audio for proximity tones that speed up as signal strengthens
|
||||
|
||||
### Hand-off from WiFi Mode
|
||||
|
||||
1. Open WiFi scanning mode and start a deep scan
|
||||
2. Click any network to open the detail drawer
|
||||
3. Click the "Locate" button in the drawer header
|
||||
4. WiFi Locate opens with the BSSID and SSID pre-filled
|
||||
5. Click "Start Locate" to begin tracking
|
||||
|
||||
### Tips
|
||||
|
||||
- Deep scan is required for continuous RSSI updates — WiFi Locate auto-starts it if needed
|
||||
- The WiFi scan is preserved when switching between WiFi and WiFi Locate modes
|
||||
- Signal lost overlay appears after 30 seconds without an update from the target
|
||||
- The distance estimate is approximate — environment preset significantly affects accuracy
|
||||
- Indoor environments with walls attenuate signal more than open field
|
||||
|
||||
## GPS Mode
|
||||
|
||||
1. **Start GPS** - Click "Start" to connect to gpsd and begin position tracking
|
||||
@@ -518,10 +551,28 @@ INTERCEPT can be configured via environment variables:
|
||||
| `INTERCEPT_LOG_LEVEL` | `WARNING` | Log level (DEBUG, INFO, WARNING, ERROR) |
|
||||
| `INTERCEPT_DEFAULT_GAIN` | `40` | Default RTL-SDR gain |
|
||||
|
||||
Example: `INTERCEPT_PORT=8080 sudo -E venv/bin/python intercept.py`
|
||||
Example: `INTERCEPT_PORT=8080 sudo ./start.sh`
|
||||
|
||||
## Command-line Options
|
||||
|
||||
### Production server (recommended)
|
||||
|
||||
```
|
||||
sudo ./start.sh --help
|
||||
|
||||
-p, --port PORT Port to listen on (default: 5050)
|
||||
-H, --host HOST Host to bind to (default: 0.0.0.0)
|
||||
-d, --debug Run in debug mode (Flask dev server)
|
||||
--https Enable HTTPS with self-signed certificate
|
||||
--check-deps Check dependencies and exit
|
||||
```
|
||||
|
||||
> **Note:** `sudo` is required for SDR hardware access, WiFi monitor mode, and Bluetooth low-level operations.
|
||||
|
||||
`start.sh` auto-detects gunicorn + gevent and runs a production WSGI server with cooperative greenlets — this handles multiple SSE streams and WebSocket connections concurrently without blocking. Falls back to the Flask dev server if gunicorn is not installed.
|
||||
|
||||
### Development server
|
||||
|
||||
```
|
||||
python3 intercept.py --help
|
||||
|
||||
|
||||
+33
-7
@@ -14,7 +14,7 @@
|
||||
<canvas id="bg-canvas"></canvas>
|
||||
<nav class="navbar">
|
||||
<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">
|
||||
<a href="#features">Features</a>
|
||||
<a href="#screenshots">Screenshots</a>
|
||||
@@ -28,7 +28,7 @@
|
||||
<header class="hero">
|
||||
<div class="hero-content">
|
||||
<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>
|
||||
<div class="hero-buttons">
|
||||
<a href="#installation" class="btn btn-primary">Get Started</a>
|
||||
@@ -36,7 +36,7 @@
|
||||
</div>
|
||||
<div class="hero-stats">
|
||||
<div class="stat">
|
||||
<span class="stat-value">25+</span>
|
||||
<span class="stat-value">34</span>
|
||||
<span class="stat-label">Modes</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
@@ -92,6 +92,11 @@
|
||||
<h3>Listening Post</h3>
|
||||
<p>Frequency scanner with real-time audio monitoring, fine-tuning controls, and customizable frequency presets.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="signals">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h2"/><path d="M8 12h1"/><path d="M11 12h2"/><path d="M15 12h1"/><path d="M18 12h2"/><circle cx="6" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="18" cy="12" r="1"/><path d="M4 8h16"/><path d="M4 16h16"/></svg></div>
|
||||
<h3>CW/Morse Decoder</h3>
|
||||
<p>Morse code decoding with custom Goertzel tone detection for CW and OOK/AM envelope detection for ISM band signals.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="intel">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></div>
|
||||
<h3>WebSDR</h3>
|
||||
@@ -152,11 +157,21 @@
|
||||
<h3>HF SSTV</h3>
|
||||
<p>Terrestrial SSTV on shortwave frequencies. Decode amateur radio image transmissions across HF, VHF, and UHF bands.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="space">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 15h18"/><path d="M3 9h18"/><path d="M6 3v18"/><path d="M18 3v18"/><path d="M9 6h6"/></svg></div>
|
||||
<h3>WeFax</h3>
|
||||
<p>HF weather fax decoder with broadcast timeline, auto-scheduler, and image gallery for marine weather charts.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="tracking">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/><path d="M12 8l4 4-4 4"/></svg></div>
|
||||
<h3>GPS Tracking</h3>
|
||||
<p>Real-time GPS position tracking with live map, speed, heading, altitude, satellite info, and track recording.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="tracking">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v6"/><path d="M12 22v-6"/><circle cx="12" cy="12" r="4"/><path d="M8 12H2"/><path d="M22 12h-6"/><path d="M12 8a20 20 0 0 1 0 8"/><path d="M7 4l2 3"/><path d="M17 20l-2-3"/></svg></div>
|
||||
<h3>Radiosonde</h3>
|
||||
<p>Weather balloon tracking on 400-406 MHz via radiosonde_auto_rx. Real-time telemetry, trajectory map, and station distance.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="space">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg></div>
|
||||
<h3>Space Weather</h3>
|
||||
@@ -177,6 +192,11 @@
|
||||
<h3>BT Locate</h3>
|
||||
<p>SAR Bluetooth device location with GPS-tagged signal trail mapping, IRK-based RPA resolution, and proximity audio alerts.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="wireless">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor" stroke="none"/><circle cx="12" cy="10" r="2"/><path d="M12 14v-2"/></svg></div>
|
||||
<h3>WiFi Locate</h3>
|
||||
<p>Locate WiFi access points by BSSID with real-time signal meter, distance estimation, RSSI chart, and audio proximity tones.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="intel">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s-8-4.5-8-11.8A8 8 0 0 1 12 2a8 8 0 0 1 8 8.2c0 7.3-8 11.8-8 11.8z"/><circle cx="12" cy="10" r="3"/><path d="M12 2v3"/><path d="M4.93 4.93l2.12 2.12"/><path d="M20 12h-3"/></svg></div>
|
||||
<h3>TSCM</h3>
|
||||
@@ -197,6 +217,11 @@
|
||||
<h3>Offline Mode</h3>
|
||||
<p>Run without internet using bundled assets. Choose from multiple map tile providers or use your own local tile server.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="platform">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg></div>
|
||||
<h3>System Health</h3>
|
||||
<p>Real-time telemetry dashboard with process monitoring, system metrics, and SDR device status overview.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="carousel-arrow carousel-arrow-right" aria-label="Scroll right">›</button>
|
||||
</div>
|
||||
@@ -310,10 +335,10 @@
|
||||
<div class="code-block">
|
||||
<pre><code>git clone https://github.com/smittix/intercept.git
|
||||
cd intercept
|
||||
./setup.sh
|
||||
sudo -E venv/bin/python intercept.py</code></pre>
|
||||
./setup.sh # Interactive wizard with install profiles
|
||||
sudo ./start.sh</code></pre>
|
||||
</div>
|
||||
<p class="install-note">Requires Python 3.9+ and RTL-SDR drivers</p>
|
||||
<p class="install-note">Menu-driven setup: choose Core, Maritime, Weather, Security, or Full SIGINT profiles. Headless mode: <code>./setup.sh --non-interactive</code></p>
|
||||
</div>
|
||||
|
||||
<div class="install-card">
|
||||
@@ -330,6 +355,7 @@ docker compose --profile basic up -d --build</code></pre>
|
||||
<div class="post-install">
|
||||
<p>After starting, open <code>http://localhost:5050</code> in your browser.</p>
|
||||
<p>Default credentials: <code>admin</code> / <code>admin</code></p>
|
||||
<p>Run <code>./setup.sh --health-check</code> to verify your installation, or use menu option 2 for a full system health check.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -409,7 +435,7 @@ docker compose --profile basic up -d --build</code></pre>
|
||||
<div class="container">
|
||||
<div class="footer-content">
|
||||
<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>
|
||||
</div>
|
||||
<div class="footer-links">
|
||||
|
||||
@@ -86,6 +86,21 @@ body {
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
# Download sample NOAA APT recordings for testing the weather satellite
|
||||
# test-decode feature. These are FM-demodulated audio WAV files.
|
||||
#
|
||||
# Usage:
|
||||
# ./download-weather-sat-samples.sh
|
||||
# docker exec intercept /app/download-weather-sat-samples.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SAMPLE_DIR="$(dirname "$0")/data/weather_sat/samples"
|
||||
mkdir -p "$SAMPLE_DIR"
|
||||
|
||||
echo "Downloading NOAA APT sample files to $SAMPLE_DIR ..."
|
||||
|
||||
# Full satellite pass recorded over Argentina (NOAA, 11025 Hz mono WAV)
|
||||
# Source: https://github.com/martinber/noaa-apt
|
||||
if [ ! -f "$SAMPLE_DIR/noaa_apt_argentina.wav" ]; then
|
||||
echo " -> noaa_apt_argentina.wav (18 MB) ..."
|
||||
curl -fSL -o "$SAMPLE_DIR/noaa_apt_argentina.wav" \
|
||||
"https://noaa-apt.mbernardi.com.ar/examples/argentina.wav"
|
||||
else
|
||||
echo " -> noaa_apt_argentina.wav (already exists)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Done. Test decode with:"
|
||||
echo " Satellite: NOAA-18"
|
||||
echo " File path: data/weather_sat/samples/noaa_apt_argentina.wav"
|
||||
echo " Sample rate: 11025 Hz"
|
||||
#!/usr/bin/env bash
|
||||
# Download sample NOAA APT recordings for testing the weather satellite
|
||||
# test-decode feature. These are FM-demodulated audio WAV files.
|
||||
#
|
||||
# Usage:
|
||||
# ./download-weather-sat-samples.sh
|
||||
# docker exec intercept /app/download-weather-sat-samples.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SAMPLE_DIR="$(dirname "$0")/data/weather_sat/samples"
|
||||
mkdir -p "$SAMPLE_DIR"
|
||||
|
||||
echo "Downloading NOAA APT sample files to $SAMPLE_DIR ..."
|
||||
|
||||
# Full satellite pass recorded over Argentina (NOAA, 11025 Hz mono WAV)
|
||||
# Source: https://github.com/martinber/noaa-apt
|
||||
if [ ! -f "$SAMPLE_DIR/noaa_apt_argentina.wav" ]; then
|
||||
echo " -> noaa_apt_argentina.wav (18 MB) ..."
|
||||
curl -fSL -o "$SAMPLE_DIR/noaa_apt_argentina.wav" \
|
||||
"https://noaa-apt.mbernardi.com.ar/examples/argentina.wav"
|
||||
else
|
||||
echo " -> noaa_apt_argentina.wav (already exists)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Done. Test decode with:"
|
||||
echo " Satellite: NOAA-18"
|
||||
echo " File path: data/weather_sat/samples/noaa_apt_argentina.wav"
|
||||
echo " Sample rate: 11025 Hz"
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Gunicorn configuration for INTERCEPT."""
|
||||
|
||||
import contextlib
|
||||
import warnings
|
||||
|
||||
warnings.filterwarnings(
|
||||
'ignore',
|
||||
message='Patching more than once',
|
||||
category=DeprecationWarning,
|
||||
)
|
||||
|
||||
|
||||
def post_fork(server, worker):
|
||||
"""Apply gevent monkey-patching immediately after fork.
|
||||
|
||||
Gunicorn's built-in gevent worker is supposed to handle this, but on
|
||||
some platforms (notably Raspberry Pi / ARM) the worker deadlocks during
|
||||
its own init_process() before it gets to patch. Doing it here — right
|
||||
after fork, before any worker initialisation — avoids the race.
|
||||
|
||||
Gunicorn's gevent worker will call patch_all() again in init_process();
|
||||
the duplicate call is harmless (gevent unions the flags) and the
|
||||
MonkeyPatchWarning is suppressed above.
|
||||
"""
|
||||
try:
|
||||
from gevent import monkey
|
||||
monkey.patch_all()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Silence the spurious AssertionError in gevent's fork hooks that fires
|
||||
# when subprocesses fork after a double monkey-patch.
|
||||
try:
|
||||
from gevent.threading import _ForkHooks
|
||||
_orig = _ForkHooks.after_fork_in_child
|
||||
|
||||
def _safe_after_fork(self):
|
||||
with contextlib.suppress(AssertionError):
|
||||
_orig(self)
|
||||
|
||||
_ForkHooks.after_fork_in_child = _safe_after_fork
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def post_worker_init(worker):
|
||||
"""Suppress noisy SystemExit tracebacks during gevent worker shutdown.
|
||||
|
||||
When gunicorn receives SIGINT, the gevent worker's handle_quit()
|
||||
calls sys.exit(0) inside a greenlet. Gevent treats SystemExit as
|
||||
an error by default and prints a traceback. Adding it to NOT_ERROR
|
||||
silences this harmless noise.
|
||||
"""
|
||||
try:
|
||||
import ssl
|
||||
|
||||
from gevent import get_hub
|
||||
hub = get_hub()
|
||||
suppress = (SystemExit, ssl.SSLZeroReturnError, ssl.SSLError)
|
||||
for exc in suppress:
|
||||
if exc not in hub.NOT_ERROR:
|
||||
hub.NOT_ERROR = hub.NOT_ERROR + (exc,)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -16,14 +16,6 @@ Requires RTL-SDR hardware for RF modes.
|
||||
import sys
|
||||
|
||||
# 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
|
||||
if '--version' in sys.argv or '-V' in sys.argv:
|
||||
|
||||
+48
-66
@@ -13,6 +13,7 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import configparser
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -26,25 +27,24 @@ import sys
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from socketserver import ThreadingMixIn
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
# Add parent directory to path for imports
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# Import dependency checking from Intercept utils
|
||||
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
|
||||
except ImportError:
|
||||
HAS_DEPENDENCIES_MODULE = False
|
||||
|
||||
# Import TSCM modules for consistent analysis (same as local mode)
|
||||
try:
|
||||
from utils.tscm.detector import ThreatDetector
|
||||
from utils.tscm.correlation import CorrelationEngine
|
||||
from utils.tscm.detector import ThreatDetector
|
||||
HAS_TSCM_MODULES = True
|
||||
except ImportError:
|
||||
HAS_TSCM_MODULES = False
|
||||
@@ -53,7 +53,7 @@ except ImportError:
|
||||
|
||||
# Import database functions for baseline support (same as local mode)
|
||||
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
|
||||
except ImportError:
|
||||
HAS_BASELINE_DB = False
|
||||
@@ -143,7 +143,7 @@ class AgentConfig:
|
||||
|
||||
# Modes section
|
||||
if parser.has_section('modes'):
|
||||
for mode in self.modes_enabled.keys():
|
||||
for mode in self.modes_enabled:
|
||||
if parser.has_option('modes', mode):
|
||||
self.modes_enabled[mode] = parser.getboolean('modes', mode)
|
||||
|
||||
@@ -310,10 +310,8 @@ class ControllerPushClient(threading.Thread):
|
||||
except Exception as e:
|
||||
item['attempts'] += 1
|
||||
if item['attempts'] < 3 and not self.stop_event.is_set():
|
||||
try:
|
||||
with contextlib.suppress(queue.Full):
|
||||
self.queue.put_nowait(item)
|
||||
except queue.Full:
|
||||
pass
|
||||
else:
|
||||
logger.warning(f"Failed to push after {item['attempts']} attempts: {e}")
|
||||
finally:
|
||||
@@ -795,9 +793,7 @@ class ModeManager:
|
||||
info['vessel_count'] = len(getattr(self, 'ais_vessels', {}))
|
||||
elif mode == 'aprs':
|
||||
info['station_count'] = len(getattr(self, 'aprs_stations', {}))
|
||||
elif mode == 'pager':
|
||||
info['message_count'] = len(self.data_snapshots.get(mode, []))
|
||||
elif mode == 'acars':
|
||||
elif mode == 'pager' or mode == 'acars':
|
||||
info['message_count'] = len(self.data_snapshots.get(mode, []))
|
||||
elif mode == 'rtlamr':
|
||||
info['reading_count'] = len(self.data_snapshots.get(mode, []))
|
||||
@@ -1073,10 +1069,8 @@ class ModeManager:
|
||||
proc.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
proc.wait(timeout=1)
|
||||
except Exception:
|
||||
pass
|
||||
except (OSError, ProcessLookupError) as e:
|
||||
# Process already dead or inaccessible
|
||||
logger.debug(f"Process cleanup for {mode}: {e}")
|
||||
@@ -1297,10 +1291,8 @@ class ModeManager:
|
||||
except Exception as e:
|
||||
logger.error(f"Sensor output reader error: {e}")
|
||||
finally:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
proc.wait(timeout=1)
|
||||
except Exception:
|
||||
pass
|
||||
logger.info("Sensor output reader stopped")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -1661,16 +1653,14 @@ class ModeManager:
|
||||
try:
|
||||
from utils.validation import validate_network_interface
|
||||
interface = validate_network_interface(interface)
|
||||
except (ImportError, ValueError) as e:
|
||||
except (ImportError, ValueError):
|
||||
if not os.path.exists(f'/sys/class/net/{interface}'):
|
||||
return {'status': 'error', 'message': f'Interface {interface} not found'}
|
||||
|
||||
csv_path = '/tmp/intercept_agent_wifi'
|
||||
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)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
airodump_path = self._get_tool_path('airodump-ng')
|
||||
if not airodump_path:
|
||||
@@ -1931,7 +1921,7 @@ class ModeManager:
|
||||
logger.warning("Intercept WiFi parser not available, using fallback")
|
||||
# Fallback: simple parsing if running standalone
|
||||
try:
|
||||
with open(csv_path, 'r', errors='replace') as f:
|
||||
with open(csv_path, errors='replace') as f:
|
||||
content = f.read()
|
||||
for section in content.split('\n\n'):
|
||||
lines = section.strip().split('\n')
|
||||
@@ -2303,10 +2293,8 @@ class ModeManager:
|
||||
except Exception as e:
|
||||
logger.error(f"Pager reader error: {e}")
|
||||
finally:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
proc.wait(timeout=1)
|
||||
except Exception:
|
||||
pass
|
||||
if 'pager_rtl' in self.processes:
|
||||
try:
|
||||
rtl_proc = self.processes['pager_rtl']
|
||||
@@ -2491,7 +2479,7 @@ class ModeManager:
|
||||
|
||||
sock.close()
|
||||
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
retry_count += 1
|
||||
if retry_count >= 10:
|
||||
logger.error("Max AIS retries reached")
|
||||
@@ -2701,10 +2689,8 @@ class ModeManager:
|
||||
except Exception as e:
|
||||
logger.error(f"ACARS reader error: {e}")
|
||||
finally:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
proc.wait(timeout=1)
|
||||
except Exception:
|
||||
pass
|
||||
logger.info("ACARS reader stopped")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -2846,10 +2832,8 @@ class ModeManager:
|
||||
except Exception as e:
|
||||
logger.error(f"APRS reader error: {e}")
|
||||
finally:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
proc.wait(timeout=1)
|
||||
except Exception:
|
||||
pass
|
||||
if 'aprs_rtl' in self.processes:
|
||||
try:
|
||||
rtl_proc = self.processes['aprs_rtl']
|
||||
@@ -2860,11 +2844,22 @@ class ModeManager:
|
||||
pass
|
||||
logger.info("APRS reader stopped")
|
||||
|
||||
def _parse_aprs_packet(self, line: str) -> dict | None:
|
||||
"""Parse APRS packet from direwolf or multimon-ng."""
|
||||
match = re.match(r'([A-Z0-9-]+)>([^:]+):(.+)', line)
|
||||
if not match:
|
||||
return None
|
||||
def _parse_aprs_packet(self, line: str) -> dict | None:
|
||||
"""Parse APRS packet from direwolf or multimon-ng."""
|
||||
if not line:
|
||||
return None
|
||||
|
||||
# Normalize common decoder prefixes before parsing.
|
||||
# multimon-ng: "AFSK1200: ..."
|
||||
# direwolf: "[0.4] ...", "[0L] ..."
|
||||
line = line.strip()
|
||||
if line.startswith('AFSK1200:'):
|
||||
line = line[9:].strip()
|
||||
line = re.sub(r'^(?:\[[^\]]+\]\s*)+', '', line)
|
||||
|
||||
match = re.match(r'([A-Z0-9-]+)>([^:]+):(.+)', line)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
callsign = match.group(1)
|
||||
path = match.group(2)
|
||||
@@ -3010,10 +3005,8 @@ class ModeManager:
|
||||
except Exception as e:
|
||||
logger.error(f"RTLAMR reader error: {e}")
|
||||
finally:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
proc.wait(timeout=1)
|
||||
except Exception:
|
||||
pass
|
||||
if 'rtlamr_tcp' in self.processes:
|
||||
try:
|
||||
tcp_proc = self.processes['rtlamr_tcp']
|
||||
@@ -3131,10 +3124,8 @@ class ModeManager:
|
||||
except Exception as e:
|
||||
logger.error(f"DSC reader error: {e}")
|
||||
finally:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
proc.wait(timeout=1)
|
||||
except Exception:
|
||||
pass
|
||||
logger.info("DSC reader stopped")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -3208,13 +3199,13 @@ class ModeManager:
|
||||
stop_event = self.stop_events.get(mode)
|
||||
|
||||
# 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")
|
||||
|
||||
sweep_ranges = None
|
||||
if sweep_type:
|
||||
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')
|
||||
sweep_ranges = preset.get('ranges') if preset else None
|
||||
except Exception:
|
||||
@@ -3401,7 +3392,8 @@ class ModeManager:
|
||||
if scan_rf and (current_time - last_rf_scan) >= rf_scan_interval:
|
||||
try:
|
||||
# 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(
|
||||
sdr_device,
|
||||
stop_check=agent_stop_check,
|
||||
@@ -3599,10 +3591,8 @@ class ModeManager:
|
||||
# Ensure test process is killed on any error
|
||||
if test_proc and test_proc.poll() is None:
|
||||
test_proc.kill()
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
test_proc.wait(timeout=1)
|
||||
except Exception:
|
||||
pass
|
||||
return {'status': 'error', 'message': f'SDR check failed: {str(e)}'}
|
||||
|
||||
# Initialize state
|
||||
@@ -3636,9 +3626,9 @@ class ModeManager:
|
||||
step: float, modulation: str, squelch: int,
|
||||
device: str, gain: str, dwell_time: float = 1.0):
|
||||
"""Scan frequency range and report signal detections."""
|
||||
import select
|
||||
import os
|
||||
import fcntl
|
||||
import os
|
||||
import select
|
||||
|
||||
mode = 'listening_post'
|
||||
stop_event = self.stop_events.get(mode)
|
||||
@@ -3698,7 +3688,7 @@ class ModeManager:
|
||||
signal_detected = True
|
||||
except Exception:
|
||||
pass
|
||||
except (IOError, BlockingIOError):
|
||||
except (OSError, BlockingIOError):
|
||||
pass
|
||||
|
||||
proc.terminate()
|
||||
@@ -4120,27 +4110,19 @@ def main():
|
||||
|
||||
# Stop push services
|
||||
if data_push_loop:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
data_push_loop.stop()
|
||||
except Exception:
|
||||
pass
|
||||
if push_client:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
push_client.stop()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Stop GPS
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
gps_manager.stop()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Shutdown HTTP server
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
httpd.shutdown()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Run cleanup in background thread so signal handler returns quickly
|
||||
cleanup_thread = threading.Thread(target=cleanup, daemon=True)
|
||||
|
||||
+25
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "intercept"
|
||||
version = "2.21.1"
|
||||
version = "2.26.11"
|
||||
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
@@ -93,8 +93,32 @@ ignore = [
|
||||
"B008", # do not perform function calls in argument defaults
|
||||
"B905", # zip without explicit strict
|
||||
"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]
|
||||
known-first-party = ["app", "config", "routes", "utils", "data"]
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Core dependencies
|
||||
flask>=3.0.0
|
||||
flask-wtf>=1.2.0
|
||||
flask-compress>=1.15
|
||||
flask-limiter>=2.5.4
|
||||
requests>=2.28.0
|
||||
Werkzeug>=3.1.5
|
||||
@@ -44,3 +46,10 @@ cryptography>=41.0.0
|
||||
# WebSocket support for in-app audio streaming (KiwiSDR, Listening Post)
|
||||
flask-sock
|
||||
websocket-client>=1.6.0
|
||||
|
||||
# System health monitoring (optional - graceful fallback if unavailable)
|
||||
psutil>=5.9.0
|
||||
|
||||
# Production WSGI server (optional - falls back to Flask dev server)
|
||||
gunicorn>=21.2.0
|
||||
gevent>=23.9.0
|
||||
|
||||
+28
-5
@@ -1,7 +1,13 @@
|
||||
# Routes package - registers all blueprints with the Flask app
|
||||
|
||||
|
||||
def register_blueprints(app):
|
||||
"""Register all route blueprints with the Flask app."""
|
||||
# Import CSRF to exempt API blueprints (they use JSON, not form tokens)
|
||||
try:
|
||||
from app import csrf as _csrf
|
||||
except ImportError:
|
||||
_csrf = None
|
||||
from .acars import acars_bp
|
||||
from .adsb import adsb_bp
|
||||
from .ais import ais_bp
|
||||
@@ -14,10 +20,14 @@ def register_blueprints(app):
|
||||
from .correlation import correlation_bp
|
||||
from .dsc import dsc_bp
|
||||
from .gps import gps_bp
|
||||
from .listening_post import receiver_bp
|
||||
from .listening_post import receiver_bp
|
||||
from .meshtastic import meshtastic_bp
|
||||
from .meteor_websocket import meteor_bp
|
||||
from .morse import morse_bp
|
||||
from .offline import offline_bp
|
||||
from .ook import ook_bp
|
||||
from .pager import pager_bp
|
||||
from .radiosonde import radiosonde_bp
|
||||
from .recordings import recordings_bp
|
||||
from .rtlamr import rtlamr_bp
|
||||
from .satellite import satellite_bp
|
||||
@@ -29,11 +39,13 @@ def register_blueprints(app):
|
||||
from .sstv import sstv_bp
|
||||
from .sstv_general import sstv_general_bp
|
||||
from .subghz import subghz_bp
|
||||
from .system import system_bp
|
||||
from .tscm import init_tscm_state, tscm_bp
|
||||
from .updater import updater_bp
|
||||
from .vdl2 import vdl2_bp
|
||||
from .weather_sat import weather_sat_bp
|
||||
from .websdr import websdr_bp
|
||||
from .wefax import wefax_bp
|
||||
from .wifi import wifi_bp
|
||||
from .wifi_v2 import wifi_v2_bp
|
||||
|
||||
@@ -54,7 +66,7 @@ def register_blueprints(app):
|
||||
app.register_blueprint(gps_bp)
|
||||
app.register_blueprint(settings_bp)
|
||||
app.register_blueprint(correlation_bp)
|
||||
app.register_blueprint(receiver_bp)
|
||||
app.register_blueprint(receiver_bp)
|
||||
app.register_blueprint(meshtastic_bp)
|
||||
app.register_blueprint(tscm_bp)
|
||||
app.register_blueprint(spy_stations_bp)
|
||||
@@ -68,9 +80,20 @@ def register_blueprints(app):
|
||||
app.register_blueprint(alerts_bp) # Cross-mode alerts
|
||||
app.register_blueprint(recordings_bp) # Session recordings
|
||||
app.register_blueprint(subghz_bp) # SubGHz transceiver (HackRF)
|
||||
app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking
|
||||
app.register_blueprint(space_weather_bp) # Space weather monitoring
|
||||
app.register_blueprint(signalid_bp) # External signal ID enrichment
|
||||
app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking
|
||||
app.register_blueprint(space_weather_bp) # Space weather monitoring
|
||||
app.register_blueprint(signalid_bp) # External signal ID enrichment
|
||||
app.register_blueprint(wefax_bp) # WeFax HF weather fax decoder
|
||||
app.register_blueprint(meteor_bp) # Meteor scatter detection
|
||||
app.register_blueprint(morse_bp) # CW/Morse code decoder
|
||||
app.register_blueprint(radiosonde_bp) # Radiosonde weather balloon tracking
|
||||
app.register_blueprint(system_bp) # System health monitoring
|
||||
app.register_blueprint(ook_bp) # Generic OOK signal decoder
|
||||
|
||||
# Exempt all API blueprints from CSRF (they use JSON, not form tokens)
|
||||
if _csrf:
|
||||
for bp in app.blueprints.values():
|
||||
_csrf.exempt(bp)
|
||||
|
||||
# Initialize TSCM state with queue and lock from app
|
||||
import app as app_module
|
||||
|
||||
+97
-72
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
@@ -13,30 +13,36 @@ import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Generator
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
import app as app_module
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.acars_translator import translate_message
|
||||
from utils.constants import (
|
||||
PROCESS_START_WAIT,
|
||||
PROCESS_TERMINATE_TIMEOUT,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
PROCESS_START_WAIT,
|
||||
)
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.flight_correlator import get_flight_correlator
|
||||
from utils.logging import sensor_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_gain, validate_ppm
|
||||
|
||||
acars_bp = Blueprint('acars', __name__, url_prefix='/acars')
|
||||
|
||||
# Default VHF ACARS frequencies (MHz) - common worldwide
|
||||
# Default VHF ACARS frequencies (MHz) - North America primary
|
||||
DEFAULT_ACARS_FREQUENCIES = [
|
||||
'131.725', # North America
|
||||
'131.825', # North America
|
||||
'131.550', # Primary worldwide / North America
|
||||
'130.025', # North America secondary
|
||||
'129.125', # North America tertiary
|
||||
'131.725', # North America (major US carriers)
|
||||
'131.825', # North America (major US carriers)
|
||||
]
|
||||
|
||||
# Message counter for statistics
|
||||
@@ -45,6 +51,7 @@ acars_last_message_time = None
|
||||
|
||||
# Track which device is being used
|
||||
acars_active_device: int | None = None
|
||||
acars_active_sdr_type: str | None = None
|
||||
|
||||
|
||||
def find_acarsdec():
|
||||
@@ -120,6 +127,15 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
|
||||
data['type'] = 'acars'
|
||||
data['timestamp'] = datetime.utcnow().isoformat() + 'Z'
|
||||
|
||||
# Enrich with translated label and parsed fields
|
||||
try:
|
||||
translation = translate_message(data)
|
||||
data['label_description'] = translation['label_description']
|
||||
data['message_type'] = translation['message_type']
|
||||
data['parsed'] = translation['parsed']
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Update stats
|
||||
acars_message_count += 1
|
||||
acars_last_message_time = time.time()
|
||||
@@ -127,11 +143,8 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
|
||||
app_module.acars_queue.put(data)
|
||||
|
||||
# Feed flight correlator
|
||||
try:
|
||||
from utils.flight_correlator import get_flight_correlator
|
||||
with contextlib.suppress(Exception):
|
||||
get_flight_correlator().add_acars_message(data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Log if enabled
|
||||
if app_module.logging_enabled:
|
||||
@@ -151,24 +164,23 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
|
||||
logger.error(f"ACARS stream error: {e}")
|
||||
app_module.acars_queue.put({'type': 'error', 'message': str(e)})
|
||||
finally:
|
||||
global acars_active_device
|
||||
global acars_active_device, acars_active_sdr_type
|
||||
# Ensure process is terminated
|
||||
try:
|
||||
process.terminate()
|
||||
process.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
unregister_process(process)
|
||||
app_module.acars_queue.put({'type': 'status', 'status': 'stopped'})
|
||||
with app_module.acars_lock:
|
||||
app_module.acars_process = None
|
||||
# Release SDR device
|
||||
if acars_active_device is not None:
|
||||
app_module.release_sdr_device(acars_active_device)
|
||||
app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr')
|
||||
acars_active_device = None
|
||||
acars_active_sdr_type = None
|
||||
|
||||
|
||||
@acars_bp.route('/tools')
|
||||
@@ -200,22 +212,16 @@ def acars_status() -> Response:
|
||||
@acars_bp.route('/start', methods=['POST'])
|
||||
def start_acars() -> Response:
|
||||
"""Start ACARS decoder."""
|
||||
global acars_message_count, acars_last_message_time, acars_active_device
|
||||
global acars_message_count, acars_last_message_time, acars_active_device, acars_active_sdr_type
|
||||
|
||||
with app_module.acars_lock:
|
||||
if app_module.acars_process and app_module.acars_process.poll() is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'ACARS decoder already running'
|
||||
}), 409
|
||||
return api_error('ACARS decoder already running', 409)
|
||||
|
||||
# Check for acarsdec
|
||||
acarsdec_path = find_acarsdec()
|
||||
if not acarsdec_path:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'acarsdec not found. Install with: sudo apt install acarsdec'
|
||||
}), 400
|
||||
return api_error('acarsdec not found. Install with: sudo apt install acarsdec', 400)
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
@@ -225,19 +231,19 @@ def start_acars() -> Response:
|
||||
gain = validate_gain(data.get('gain', '40'))
|
||||
ppm = validate_ppm(data.get('ppm', '0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
return api_error(str(e), 400)
|
||||
|
||||
# Resolve SDR type for device selection
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
|
||||
# Check if device is available
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'acars')
|
||||
error = app_module.claim_sdr_device(device_int, 'acars', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
return api_error(error, 409, error_type='DEVICE_BUSY')
|
||||
|
||||
acars_active_device = device_int
|
||||
acars_active_sdr_type = sdr_type_str
|
||||
|
||||
# Get frequencies - use provided or defaults
|
||||
frequencies = data.get('frequencies', DEFAULT_ACARS_FREQUENCIES)
|
||||
@@ -255,8 +261,6 @@ def start_acars() -> Response:
|
||||
acars_message_count = 0
|
||||
acars_last_message_time = None
|
||||
|
||||
# Resolve SDR type for device selection
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
@@ -327,7 +331,7 @@ def start_acars() -> Response:
|
||||
)
|
||||
os.close(slave_fd)
|
||||
# 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
|
||||
else:
|
||||
process = subprocess.Popen(
|
||||
@@ -343,16 +347,19 @@ def start_acars() -> Response:
|
||||
if process.poll() is not None:
|
||||
# Process died - release device
|
||||
if acars_active_device is not None:
|
||||
app_module.release_sdr_device(acars_active_device)
|
||||
app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr')
|
||||
acars_active_device = None
|
||||
acars_active_sdr_type = None
|
||||
stderr = ''
|
||||
if process.stderr:
|
||||
stderr = process.stderr.read().decode('utf-8', errors='replace')
|
||||
error_msg = f'acarsdec failed to start'
|
||||
if stderr:
|
||||
error_msg += f': {stderr[:200]}'
|
||||
logger.error(f"acarsdec stderr:\n{stderr}")
|
||||
error_msg = 'acarsdec failed to start'
|
||||
if stderr:
|
||||
error_msg += f': {stderr[:500]}'
|
||||
logger.error(error_msg)
|
||||
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||
return api_error(error_msg, 500)
|
||||
|
||||
app_module.acars_process = process
|
||||
register_process(process)
|
||||
@@ -375,23 +382,21 @@ def start_acars() -> Response:
|
||||
except Exception as e:
|
||||
# Release device on failure
|
||||
if acars_active_device is not None:
|
||||
app_module.release_sdr_device(acars_active_device)
|
||||
app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr')
|
||||
acars_active_device = None
|
||||
acars_active_sdr_type = None
|
||||
logger.error(f"Failed to start ACARS decoder: {e}")
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@acars_bp.route('/stop', methods=['POST'])
|
||||
def stop_acars() -> Response:
|
||||
"""Stop ACARS decoder."""
|
||||
global acars_active_device
|
||||
global acars_active_device, acars_active_sdr_type
|
||||
|
||||
with app_module.acars_lock:
|
||||
if not app_module.acars_process:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'ACARS decoder not running'
|
||||
}), 400
|
||||
return api_error('ACARS decoder not running', 400)
|
||||
|
||||
try:
|
||||
app_module.acars_process.terminate()
|
||||
@@ -405,31 +410,51 @@ def stop_acars() -> Response:
|
||||
|
||||
# Release device from registry
|
||||
if acars_active_device is not None:
|
||||
app_module.release_sdr_device(acars_active_device)
|
||||
app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr')
|
||||
acars_active_device = None
|
||||
acars_active_sdr_type = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@acars_bp.route('/stream')
|
||||
def stream_acars() -> Response:
|
||||
"""SSE stream for ACARS messages."""
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('acars', msg, msg.get('type'))
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.acars_queue,
|
||||
channel_key='acars',
|
||||
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
|
||||
@acars_bp.route('/stream')
|
||||
def stream_acars() -> Response:
|
||||
"""SSE stream for ACARS messages."""
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('acars', msg, msg.get('type'))
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.acars_queue,
|
||||
channel_key='acars',
|
||||
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
|
||||
|
||||
|
||||
@acars_bp.route('/messages')
|
||||
def get_acars_messages() -> Response:
|
||||
"""Get recent ACARS messages from correlator (for history reload)."""
|
||||
limit = request.args.get('limit', 50, type=int)
|
||||
limit = max(1, min(limit, 200))
|
||||
msgs = get_flight_correlator().get_recent_messages('acars', limit)
|
||||
return jsonify(msgs)
|
||||
|
||||
|
||||
@acars_bp.route('/clear', methods=['POST'])
|
||||
def clear_acars_messages() -> Response:
|
||||
"""Clear stored ACARS messages and reset counter."""
|
||||
global acars_message_count, acars_last_message_time
|
||||
get_flight_correlator().clear_acars()
|
||||
acars_message_count = 0
|
||||
acars_last_message_time = None
|
||||
return jsonify({'status': 'cleared'})
|
||||
|
||||
|
||||
@acars_bp.route('/frequencies')
|
||||
@@ -438,7 +463,7 @@ def get_frequencies() -> Response:
|
||||
return jsonify({
|
||||
'default': DEFAULT_ACARS_FREQUENCIES,
|
||||
'regions': {
|
||||
'north_america': ['131.725', '131.825'],
|
||||
'north_america': ['131.550', '130.025', '129.125', '131.725', '131.825'],
|
||||
'europe': ['131.525', '131.725', '131.550'],
|
||||
'asia_pacific': ['131.550', '131.450'],
|
||||
}
|
||||
|
||||
+607
-133
File diff suppressed because it is too large
Load Diff
+42
-42
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
@@ -10,29 +11,28 @@ import socket
|
||||
import subprocess
|
||||
import threading
|
||||
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
|
||||
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 config import DEFAULT_LATITUDE, DEFAULT_LONGITUDE, SHARED_OBSERVER_LOCATION_ENABLED
|
||||
from utils.constants import (
|
||||
AIS_RECONNECT_DELAY,
|
||||
AIS_SOCKET_TIMEOUT,
|
||||
AIS_TCP_PORT,
|
||||
AIS_TERMINATE_TIMEOUT,
|
||||
AIS_SOCKET_TIMEOUT,
|
||||
AIS_RECONNECT_DELAY,
|
||||
AIS_UPDATE_INTERVAL,
|
||||
PROCESS_TERMINATE_TIMEOUT,
|
||||
SOCKET_BUFFER_SIZE,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
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')
|
||||
|
||||
@@ -44,6 +44,7 @@ ais_connected = False
|
||||
ais_messages_received = 0
|
||||
ais_last_message_time = None
|
||||
ais_active_device = None
|
||||
ais_active_sdr_type: str | None = None
|
||||
_ais_error_logged = True
|
||||
|
||||
# Common installation paths for AIS-catcher
|
||||
@@ -79,6 +80,7 @@ def parse_ais_stream(port: int):
|
||||
_ais_error_logged = True
|
||||
|
||||
while ais_running:
|
||||
sock = None
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(AIS_SOCKET_TIMEOUT)
|
||||
@@ -125,13 +127,11 @@ def parse_ais_stream(port: int):
|
||||
for mmsi in pending_updates:
|
||||
if mmsi in app_module.ais_vessels:
|
||||
_vessel_snap = app_module.ais_vessels[mmsi]
|
||||
try:
|
||||
with contextlib.suppress(queue.Full):
|
||||
app_module.ais_queue.put_nowait({
|
||||
'type': 'vessel',
|
||||
**_vessel_snap
|
||||
})
|
||||
except queue.Full:
|
||||
pass
|
||||
# Geofence check
|
||||
_v_lat = _vessel_snap.get('lat')
|
||||
_v_lon = _vessel_snap.get('lon')
|
||||
@@ -151,7 +151,6 @@ def parse_ais_stream(port: int):
|
||||
except socket.timeout:
|
||||
continue
|
||||
|
||||
sock.close()
|
||||
ais_connected = False
|
||||
except OSError as e:
|
||||
ais_connected = False
|
||||
@@ -159,6 +158,10 @@ def parse_ais_stream(port: int):
|
||||
logger.warning(f"AIS connection error: {e}, reconnecting...")
|
||||
_ais_error_logged = True
|
||||
time.sleep(AIS_RECONNECT_DELAY)
|
||||
finally:
|
||||
if sock:
|
||||
with contextlib.suppress(OSError):
|
||||
sock.close()
|
||||
|
||||
ais_connected = False
|
||||
logger.info("AIS stream parser stopped")
|
||||
@@ -350,11 +353,11 @@ def ais_status():
|
||||
@ais_bp.route('/start', methods=['POST'])
|
||||
def start_ais():
|
||||
"""Start AIS tracking."""
|
||||
global ais_running, ais_active_device
|
||||
global ais_running, ais_active_device, ais_active_sdr_type
|
||||
|
||||
with app_module.ais_lock:
|
||||
if ais_running:
|
||||
return jsonify({'status': 'already_running', 'message': 'AIS tracking already active'}), 409
|
||||
return api_error('AIS tracking already active', 409)
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
@@ -363,15 +366,12 @@ def start_ais():
|
||||
gain = int(validate_gain(data.get('gain', '40')))
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
return api_error(str(e), 400)
|
||||
|
||||
# Find AIS-catcher
|
||||
ais_catcher_path = find_ais_catcher()
|
||||
if not ais_catcher_path:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'AIS-catcher not found. Install from https://github.com/jvde-github/AIS-catcher/releases'
|
||||
}), 400
|
||||
return api_error('AIS-catcher not found. Install from https://github.com/jvde-github/AIS-catcher/releases', 400)
|
||||
|
||||
# Get SDR type from request
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
@@ -397,13 +397,9 @@ def start_ais():
|
||||
|
||||
# Check if device is available
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'ais')
|
||||
error = app_module.claim_sdr_device(device_int, 'ais', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
return api_error(error, 409, error_type='DEVICE_BUSY')
|
||||
|
||||
# Build command using SDR abstraction
|
||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||
@@ -436,20 +432,21 @@ def start_ais():
|
||||
|
||||
if app_module.ais_process.poll() is not None:
|
||||
# Release device on failure
|
||||
app_module.release_sdr_device(device_int)
|
||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||
stderr_output = ''
|
||||
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()
|
||||
except Exception:
|
||||
pass
|
||||
if stderr_output:
|
||||
logger.error(f"AIS-catcher stderr:\n{stderr_output}")
|
||||
error_msg = 'AIS-catcher failed to start. Check SDR device connection.'
|
||||
if stderr_output:
|
||||
error_msg += f' Error: {stderr_output[:200]}'
|
||||
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||
error_msg += f' Error: {stderr_output[:500]}'
|
||||
return api_error(error_msg, 500)
|
||||
|
||||
ais_running = True
|
||||
ais_active_device = device
|
||||
ais_active_sdr_type = sdr_type_str
|
||||
|
||||
# Start TCP parser thread
|
||||
thread = threading.Thread(target=parse_ais_stream, args=(tcp_port,), daemon=True)
|
||||
@@ -463,15 +460,15 @@ def start_ais():
|
||||
})
|
||||
except Exception as e:
|
||||
# Release device on failure
|
||||
app_module.release_sdr_device(device_int)
|
||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||
logger.error(f"Failed to start AIS-catcher: {e}")
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@ais_bp.route('/stop', methods=['POST'])
|
||||
def stop_ais():
|
||||
"""Stop AIS tracking."""
|
||||
global ais_running, ais_active_device
|
||||
global ais_running, ais_active_device, ais_active_sdr_type
|
||||
|
||||
with app_module.ais_lock:
|
||||
if app_module.ais_process:
|
||||
@@ -490,10 +487,11 @@ def stop_ais():
|
||||
|
||||
# Release device from registry
|
||||
if ais_active_device is not None:
|
||||
app_module.release_sdr_device(ais_active_device)
|
||||
app_module.release_sdr_device(ais_active_device, ais_active_sdr_type or 'rtlsdr')
|
||||
|
||||
ais_running = False
|
||||
ais_active_device = None
|
||||
ais_active_sdr_type = None
|
||||
|
||||
app_module.ais_vessels.clear()
|
||||
return jsonify({'status': 'stopped'})
|
||||
@@ -524,17 +522,17 @@ def stream_ais():
|
||||
def get_vessel_dsc(mmsi: str):
|
||||
"""Get DSC messages associated with a vessel MMSI."""
|
||||
if not mmsi or not mmsi.isdigit():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid MMSI'}), 400
|
||||
return api_error('Invalid MMSI', 400)
|
||||
|
||||
matches = []
|
||||
try:
|
||||
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:
|
||||
matches.append(dict(msg))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return jsonify({'status': 'success', 'mmsi': mmsi, 'dsc_messages': matches})
|
||||
return api_success(data={'mmsi': mmsi, 'dsc_messages': matches})
|
||||
|
||||
|
||||
@ais_bp.route('/dashboard')
|
||||
@@ -544,5 +542,7 @@ def ais_dashboard():
|
||||
return render_template(
|
||||
'ais_dashboard.html',
|
||||
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
||||
default_latitude=DEFAULT_LATITUDE,
|
||||
default_longitude=DEFAULT_LONGITUDE,
|
||||
embedded=embedded,
|
||||
)
|
||||
|
||||
+11
-12
@@ -2,13 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
import time
|
||||
from typing import Generator
|
||||
from collections.abc import Generator
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
from flask import Blueprint, Response, request
|
||||
|
||||
from utils.alerts import get_alert_manager
|
||||
from utils.responses import api_error, api_success
|
||||
from utils.sse import format_sse
|
||||
|
||||
alerts_bp = Blueprint('alerts', __name__, url_prefix='/alerts')
|
||||
@@ -18,18 +17,18 @@ alerts_bp = Blueprint('alerts', __name__, url_prefix='/alerts')
|
||||
def list_rules():
|
||||
manager = get_alert_manager()
|
||||
include_disabled = request.args.get('all') in ('1', 'true', 'yes')
|
||||
return jsonify({'status': 'success', 'rules': manager.list_rules(include_disabled=include_disabled)})
|
||||
return api_success(data={'rules': manager.list_rules(include_disabled=include_disabled)})
|
||||
|
||||
|
||||
@alerts_bp.route('/rules', methods=['POST'])
|
||||
def create_rule():
|
||||
data = request.get_json() or {}
|
||||
if not isinstance(data.get('match', {}), dict):
|
||||
return jsonify({'status': 'error', 'message': 'match must be a JSON object'}), 400
|
||||
return api_error('match must be a JSON object', 400)
|
||||
|
||||
manager = get_alert_manager()
|
||||
rule_id = manager.add_rule(data)
|
||||
return jsonify({'status': 'success', 'rule_id': rule_id})
|
||||
return api_success(data={'rule_id': rule_id})
|
||||
|
||||
|
||||
@alerts_bp.route('/rules/<int:rule_id>', methods=['PUT', 'PATCH'])
|
||||
@@ -38,8 +37,8 @@ def update_rule(rule_id: int):
|
||||
manager = get_alert_manager()
|
||||
ok = manager.update_rule(rule_id, data)
|
||||
if not ok:
|
||||
return jsonify({'status': 'error', 'message': 'Rule not found or no changes'}), 404
|
||||
return jsonify({'status': 'success'})
|
||||
return api_error('Rule not found or no changes', 404)
|
||||
return api_success()
|
||||
|
||||
|
||||
@alerts_bp.route('/rules/<int:rule_id>', methods=['DELETE'])
|
||||
@@ -47,8 +46,8 @@ def delete_rule(rule_id: int):
|
||||
manager = get_alert_manager()
|
||||
ok = manager.delete_rule(rule_id)
|
||||
if not ok:
|
||||
return jsonify({'status': 'error', 'message': 'Rule not found'}), 404
|
||||
return jsonify({'status': 'success'})
|
||||
return api_error('Rule not found', 404)
|
||||
return api_success()
|
||||
|
||||
|
||||
@alerts_bp.route('/events', methods=['GET'])
|
||||
@@ -58,7 +57,7 @@ def list_events():
|
||||
mode = request.args.get('mode')
|
||||
severity = request.args.get('severity')
|
||||
events = manager.list_events(limit=limit, mode=mode, severity=severity)
|
||||
return jsonify({'status': 'success', 'events': events})
|
||||
return api_success(data={'events': events})
|
||||
|
||||
|
||||
@alerts_bp.route('/stream', methods=['GET'])
|
||||
|
||||
+429
-227
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
from flask import Flask
|
||||
|
||||
# Try to import flask-sock
|
||||
@@ -16,6 +17,8 @@ except ImportError:
|
||||
WEBSOCKET_AVAILABLE = False
|
||||
Sock = None
|
||||
|
||||
import contextlib
|
||||
|
||||
from utils.logging import get_logger
|
||||
|
||||
logger = get_logger('intercept.audio_ws')
|
||||
@@ -56,10 +59,8 @@ def kill_audio_processes():
|
||||
audio_process.terminate()
|
||||
audio_process.wait(timeout=0.5)
|
||||
except:
|
||||
try:
|
||||
with contextlib.suppress(BaseException):
|
||||
audio_process.kill()
|
||||
except:
|
||||
pass
|
||||
audio_process = None
|
||||
|
||||
if rtl_process:
|
||||
@@ -67,10 +68,8 @@ def kill_audio_processes():
|
||||
rtl_process.terminate()
|
||||
rtl_process.wait(timeout=0.5)
|
||||
except:
|
||||
try:
|
||||
with contextlib.suppress(BaseException):
|
||||
rtl_process.kill()
|
||||
except:
|
||||
pass
|
||||
rtl_process = None
|
||||
|
||||
time.sleep(0.3)
|
||||
@@ -261,16 +260,10 @@ def init_audio_websocket(app: Flask):
|
||||
# Complete WebSocket close handshake, then shut down the
|
||||
# raw socket so Werkzeug cannot write its HTTP 200 response
|
||||
# on top of the WebSocket stream.
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
ws.sock.shutdown(socket.SHUT_RDWR)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
ws.sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
logger.info("WebSocket audio client disconnected")
|
||||
|
||||
+65
-61
@@ -2,8 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import contextlib
|
||||
import os
|
||||
import platform
|
||||
import pty
|
||||
@@ -13,32 +12,42 @@ import select
|
||||
import subprocess
|
||||
import threading
|
||||
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
|
||||
from utils.dependencies import check_tool
|
||||
from utils.logging import bluetooth_logger as logger
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.validation import validate_bluetooth_interface
|
||||
from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer
|
||||
from data.patterns import AIRTAG_PREFIXES, TILE_PREFIXES, SAMSUNG_TRACKER
|
||||
from data.oui import OUI_DATABASE, get_manufacturer, load_oui_database
|
||||
from data.patterns import AIRTAG_PREFIXES, SAMSUNG_TRACKER, TILE_PREFIXES
|
||||
from utils.constants import (
|
||||
BT_TERMINATE_TIMEOUT,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
SUBPROCESS_TIMEOUT_SHORT,
|
||||
SERVICE_ENUM_TIMEOUT,
|
||||
PROCESS_START_WAIT,
|
||||
BT_RESET_DELAY,
|
||||
BT_ADAPTER_DOWN_WAIT,
|
||||
PROCESS_TERMINATE_TIMEOUT,
|
||||
)
|
||||
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')
|
||||
|
||||
# --- v1 deprecation ---
|
||||
# These endpoints are deprecated in favor of /api/bluetooth/*.
|
||||
# Frontend still uses v1, so they remain active.
|
||||
# Migration: switch frontend to v2 endpoints, then remove this file.
|
||||
_v1_deprecation_logged = set()
|
||||
|
||||
|
||||
@bluetooth_bp.after_request
|
||||
def _add_deprecation_header(response):
|
||||
"""Add X-Deprecated header to all v1 Bluetooth responses."""
|
||||
response.headers['X-Deprecated'] = 'Use /api/bluetooth/* endpoints instead'
|
||||
endpoint = request.endpoint or ''
|
||||
if endpoint not in _v1_deprecation_logged:
|
||||
_v1_deprecation_logged.add(endpoint)
|
||||
logger.warning(f"Deprecated v1 Bluetooth endpoint called: {request.path} — migrate to /api/bluetooth/*")
|
||||
return response
|
||||
|
||||
|
||||
def classify_bt_device(name, device_class, services, manufacturer=None):
|
||||
"""Classify Bluetooth device type based on available info."""
|
||||
@@ -310,10 +319,8 @@ def stream_bt_scan(process, scan_mode):
|
||||
except OSError:
|
||||
break
|
||||
|
||||
try:
|
||||
with contextlib.suppress(OSError):
|
||||
os.close(master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
app_module.bt_queue.put({'type': 'error', 'text': str(e)})
|
||||
@@ -331,8 +338,8 @@ def reload_oui_database_route():
|
||||
if new_db:
|
||||
OUI_DATABASE.clear()
|
||||
OUI_DATABASE.update(new_db)
|
||||
return jsonify({'status': 'success', 'entries': len(OUI_DATABASE)})
|
||||
return jsonify({'status': 'error', 'message': 'Could not load oui_database.json'})
|
||||
return api_success(data={'entries': len(OUI_DATABASE)})
|
||||
return api_error('Could not load oui_database.json')
|
||||
|
||||
|
||||
@bluetooth_bp.route('/interfaces')
|
||||
@@ -359,7 +366,7 @@ def start_bt_scan():
|
||||
with app_module.bt_lock:
|
||||
if app_module.bt_process:
|
||||
if app_module.bt_process.poll() is None:
|
||||
return jsonify({'status': 'error', 'message': 'Scan already running'})
|
||||
return api_error('Scan already running')
|
||||
else:
|
||||
app_module.bt_process = None
|
||||
|
||||
@@ -371,7 +378,7 @@ def start_bt_scan():
|
||||
try:
|
||||
interface = validate_bluetooth_interface(data.get('interface', 'hci0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
return api_error(str(e), 400)
|
||||
|
||||
app_module.bt_interface = interface
|
||||
app_module.bt_devices = {}
|
||||
@@ -413,14 +420,14 @@ def start_bt_scan():
|
||||
os.write(master_fd, b'scan on\n')
|
||||
|
||||
else:
|
||||
return jsonify({'status': 'error', 'message': f'Unknown scan mode: {scan_mode}'})
|
||||
return api_error(f'Unknown scan mode: {scan_mode}')
|
||||
|
||||
time.sleep(0.5)
|
||||
|
||||
if app_module.bt_process.poll() is not None:
|
||||
stderr_output = app_module.bt_process.stderr.read().decode('utf-8', errors='replace').strip()
|
||||
app_module.bt_process = None
|
||||
return jsonify({'status': 'error', 'message': stderr_output or 'Process failed to start'})
|
||||
return api_error(stderr_output or 'Process failed to start')
|
||||
|
||||
thread = threading.Thread(target=stream_bt_scan, args=(app_module.bt_process, scan_mode))
|
||||
thread.daemon = True
|
||||
@@ -430,9 +437,9 @@ def start_bt_scan():
|
||||
return jsonify({'status': 'started', 'mode': scan_mode, 'interface': interface})
|
||||
|
||||
except FileNotFoundError as e:
|
||||
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'})
|
||||
return api_error(f'Tool not found: {e.filename}')
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
return api_error(str(e))
|
||||
|
||||
|
||||
@bluetooth_bp.route('/scan/stop', methods=['POST'])
|
||||
@@ -459,7 +466,7 @@ def reset_bt_adapter():
|
||||
try:
|
||||
interface = validate_bluetooth_interface(data.get('interface', 'hci0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
return api_error(str(e), 400)
|
||||
|
||||
with app_module.bt_lock:
|
||||
if app_module.bt_process:
|
||||
@@ -467,10 +474,8 @@ def reset_bt_adapter():
|
||||
app_module.bt_process.terminate()
|
||||
app_module.bt_process.wait(timeout=2)
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
try:
|
||||
with contextlib.suppress(OSError):
|
||||
app_module.bt_process.kill()
|
||||
except OSError:
|
||||
pass
|
||||
app_module.bt_process = None
|
||||
|
||||
try:
|
||||
@@ -489,12 +494,12 @@ def reset_bt_adapter():
|
||||
|
||||
return jsonify({
|
||||
'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
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
return api_error(str(e))
|
||||
|
||||
|
||||
@bluetooth_bp.route('/enum', methods=['POST'])
|
||||
@@ -504,7 +509,7 @@ def enum_bt_services():
|
||||
target_mac = data.get('mac')
|
||||
|
||||
if not target_mac:
|
||||
return jsonify({'status': 'error', 'message': 'Target MAC required'})
|
||||
return api_error('Target MAC required')
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
@@ -529,18 +534,17 @@ def enum_bt_services():
|
||||
|
||||
app_module.bt_services[target_mac] = services
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
return api_success(data={
|
||||
'mac': target_mac,
|
||||
'services': services
|
||||
})
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return jsonify({'status': 'error', 'message': 'Connection timed out'})
|
||||
return api_error('Connection timed out')
|
||||
except FileNotFoundError:
|
||||
return jsonify({'status': 'error', 'message': 'sdptool not found'})
|
||||
return api_error('sdptool not found')
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
return api_error(str(e))
|
||||
|
||||
|
||||
@bluetooth_bp.route('/devices')
|
||||
@@ -553,23 +557,23 @@ def get_bt_devices():
|
||||
})
|
||||
|
||||
|
||||
@bluetooth_bp.route('/stream')
|
||||
def stream_bt():
|
||||
"""SSE stream for Bluetooth events."""
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('bluetooth', msg, msg.get('type'))
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.bt_queue,
|
||||
channel_key='bluetooth',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
on_message=_on_msg,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
@bluetooth_bp.route('/stream')
|
||||
def stream_bt():
|
||||
"""SSE stream for Bluetooth events."""
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('bluetooth', msg, msg.get('type'))
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.bt_queue,
|
||||
channel_key='bluetooth',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
on_message=_on_msg,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
+11
-17
@@ -7,30 +7,27 @@ aggregation, and heuristics.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Generator
|
||||
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 (
|
||||
BluetoothScanner,
|
||||
BTDeviceAggregate,
|
||||
get_bluetooth_scanner,
|
||||
check_capabilities,
|
||||
RANGE_UNKNOWN,
|
||||
TrackerType,
|
||||
TrackerConfidence,
|
||||
get_tracker_engine,
|
||||
get_bluetooth_scanner,
|
||||
)
|
||||
from utils.database import get_db
|
||||
from utils.sse import format_sse
|
||||
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')
|
||||
|
||||
@@ -231,7 +228,7 @@ def start_scan():
|
||||
# Validate mode
|
||||
valid_modes = ('auto', 'dbus', 'bleak', 'hcitool', 'bluetoothctl', 'ubertooth')
|
||||
if mode not in valid_modes:
|
||||
return jsonify({'error': f'Invalid mode. Must be one of: {valid_modes}'}), 400
|
||||
return api_error(f'Invalid mode. Must be one of: {valid_modes}', 400)
|
||||
|
||||
# Get scanner instance
|
||||
scanner = get_bluetooth_scanner(adapter_id)
|
||||
@@ -261,7 +258,7 @@ def start_scan():
|
||||
# Check if already scanning
|
||||
if scanner.is_scanning:
|
||||
return jsonify({
|
||||
'status': 'already_running',
|
||||
'status': 'already_scanning',
|
||||
'scan_status': scanner.get_status().to_dict()
|
||||
})
|
||||
|
||||
@@ -389,7 +386,7 @@ def get_device(device_id: str):
|
||||
device = scanner.get_device(device_id)
|
||||
|
||||
if not device:
|
||||
return jsonify({'error': 'Device not found'}), 404
|
||||
return api_error('Device not found', 404)
|
||||
|
||||
return jsonify(device.to_dict())
|
||||
|
||||
@@ -529,7 +526,7 @@ def get_tracker_detail(device_id: str):
|
||||
device = scanner.get_device(device_id)
|
||||
|
||||
if not device:
|
||||
return jsonify({'error': 'Device not found'}), 404
|
||||
return api_error('Device not found', 404)
|
||||
|
||||
# Get RSSI history for timeline
|
||||
rssi_history = device.get_rssi_history(max_points=100)
|
||||
@@ -900,10 +897,8 @@ def stream_events():
|
||||
"""Generate SSE events from scanner."""
|
||||
for event in scanner.stream_events(timeout=1.0):
|
||||
event_name, event_data = map_event_type(event)
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
process_event('bluetooth', event_data, event_name)
|
||||
except Exception:
|
||||
pass
|
||||
yield format_sse(event_data, event=event_name)
|
||||
|
||||
return Response(
|
||||
@@ -971,7 +966,6 @@ def get_tscm_bluetooth_snapshot(duration: int = 8) -> list[dict]:
|
||||
Returns:
|
||||
List of device dictionaries in TSCM format.
|
||||
"""
|
||||
import time
|
||||
import logging
|
||||
logger = logging.getLogger('intercept.bluetooth_v2')
|
||||
|
||||
|
||||
+72
-78
@@ -21,6 +21,7 @@ from utils.bt_locate import (
|
||||
start_locate_session,
|
||||
stop_locate_session,
|
||||
)
|
||||
from utils.responses import api_error
|
||||
from utils.sse import format_sse
|
||||
|
||||
logger = logging.getLogger('intercept.bt_locate')
|
||||
@@ -33,18 +34,18 @@ def start_session():
|
||||
"""
|
||||
Start a locate session.
|
||||
|
||||
Request JSON:
|
||||
- mac_address: Target MAC address (optional)
|
||||
- name_pattern: Target name substring (optional)
|
||||
- irk_hex: Identity Resolving Key hex string (optional)
|
||||
- device_id: Device ID from Bluetooth scanner (optional)
|
||||
- device_key: Stable device key from Bluetooth scanner (optional)
|
||||
- fingerprint_id: Payload fingerprint ID from Bluetooth scanner (optional)
|
||||
- known_name: Hand-off device name (optional)
|
||||
- known_manufacturer: Hand-off manufacturer (optional)
|
||||
- last_known_rssi: Hand-off last RSSI (optional)
|
||||
- environment: 'FREE_SPACE', 'OUTDOOR', 'INDOOR', 'CUSTOM' (default: OUTDOOR)
|
||||
- custom_exponent: Path loss exponent for CUSTOM environment (optional)
|
||||
Request JSON:
|
||||
- mac_address: Target MAC address (optional)
|
||||
- name_pattern: Target name substring (optional)
|
||||
- irk_hex: Identity Resolving Key hex string (optional)
|
||||
- device_id: Device ID from Bluetooth scanner (optional)
|
||||
- device_key: Stable device key from Bluetooth scanner (optional)
|
||||
- fingerprint_id: Payload fingerprint ID from Bluetooth scanner (optional)
|
||||
- known_name: Hand-off device name (optional)
|
||||
- known_manufacturer: Hand-off manufacturer (optional)
|
||||
- last_known_rssi: Hand-off last RSSI (optional)
|
||||
- environment: 'FREE_SPACE', 'OUTDOOR', 'INDOOR', 'CUSTOM' (default: OUTDOOR)
|
||||
- custom_exponent: Path loss exponent for CUSTOM environment (optional)
|
||||
|
||||
Returns:
|
||||
JSON with session status.
|
||||
@@ -52,47 +53,46 @@ def start_session():
|
||||
data = request.get_json() or {}
|
||||
|
||||
# Build target
|
||||
target = LocateTarget(
|
||||
mac_address=data.get('mac_address'),
|
||||
name_pattern=data.get('name_pattern'),
|
||||
irk_hex=data.get('irk_hex'),
|
||||
device_id=data.get('device_id'),
|
||||
device_key=data.get('device_key'),
|
||||
fingerprint_id=data.get('fingerprint_id'),
|
||||
known_name=data.get('known_name'),
|
||||
known_manufacturer=data.get('known_manufacturer'),
|
||||
last_known_rssi=data.get('last_known_rssi'),
|
||||
)
|
||||
target = LocateTarget(
|
||||
mac_address=data.get('mac_address'),
|
||||
name_pattern=data.get('name_pattern'),
|
||||
irk_hex=data.get('irk_hex'),
|
||||
device_id=data.get('device_id'),
|
||||
device_key=data.get('device_key'),
|
||||
fingerprint_id=data.get('fingerprint_id'),
|
||||
known_name=data.get('known_name'),
|
||||
known_manufacturer=data.get('known_manufacturer'),
|
||||
last_known_rssi=data.get('last_known_rssi'),
|
||||
)
|
||||
|
||||
# At least one identifier required
|
||||
if not any([
|
||||
target.mac_address,
|
||||
target.name_pattern,
|
||||
target.irk_hex,
|
||||
target.device_id,
|
||||
target.device_key,
|
||||
target.fingerprint_id,
|
||||
]):
|
||||
return jsonify({
|
||||
'error': (
|
||||
'At least one target identifier required '
|
||||
'(mac_address, name_pattern, irk_hex, device_id, device_key, or fingerprint_id)'
|
||||
)
|
||||
}), 400
|
||||
if not any([
|
||||
target.mac_address,
|
||||
target.name_pattern,
|
||||
target.irk_hex,
|
||||
target.device_id,
|
||||
target.device_key,
|
||||
target.fingerprint_id,
|
||||
]):
|
||||
return api_error(
|
||||
'At least one target identifier required '
|
||||
'(mac_address, name_pattern, irk_hex, device_id, device_key, or fingerprint_id)',
|
||||
400
|
||||
)
|
||||
|
||||
# Parse environment
|
||||
env_str = data.get('environment', 'OUTDOOR').upper()
|
||||
try:
|
||||
environment = Environment[env_str]
|
||||
except KeyError:
|
||||
return jsonify({'error': f'Invalid environment: {env_str}'}), 400
|
||||
return api_error(f'Invalid environment: {env_str}', 400)
|
||||
|
||||
custom_exponent = data.get('custom_exponent')
|
||||
if custom_exponent is not None:
|
||||
try:
|
||||
custom_exponent = float(custom_exponent)
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({'error': 'custom_exponent must be a number'}), 400
|
||||
return api_error('custom_exponent must be a number', 400)
|
||||
|
||||
# Fallback coordinates when GPS is unavailable (from user settings)
|
||||
fallback_lat = None
|
||||
@@ -109,27 +109,21 @@ def start_session():
|
||||
f"env={environment.name}, fallback=({fallback_lat}, {fallback_lon})"
|
||||
)
|
||||
|
||||
try:
|
||||
session = start_locate_session(
|
||||
target, environment, custom_exponent, fallback_lat, fallback_lon
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
logger.warning(f"Unable to start BT Locate session: {exc}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error': 'Bluetooth scanner could not be started. Check adapter permissions/capabilities.',
|
||||
}), 503
|
||||
except Exception as exc:
|
||||
logger.exception(f"Unexpected error starting BT Locate session: {exc}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error': 'Failed to start locate session',
|
||||
}), 500
|
||||
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'session': session.get_status(),
|
||||
})
|
||||
try:
|
||||
session = start_locate_session(
|
||||
target, environment, custom_exponent, fallback_lat, fallback_lon
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
logger.warning(f"Unable to start BT Locate session: {exc}")
|
||||
return api_error('Bluetooth scanner could not be started. Check adapter permissions/capabilities.', 503)
|
||||
except Exception as exc:
|
||||
logger.exception(f"Unexpected error starting BT Locate session: {exc}")
|
||||
return api_error('Failed to start locate session', 500)
|
||||
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'session': session.get_status(),
|
||||
})
|
||||
|
||||
|
||||
@bt_locate_bp.route('/stop', methods=['POST'])
|
||||
@@ -143,18 +137,18 @@ def stop_session():
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@bt_locate_bp.route('/status', methods=['GET'])
|
||||
def get_status():
|
||||
"""Get locate session status."""
|
||||
session = get_locate_session()
|
||||
if not session:
|
||||
@bt_locate_bp.route('/status', methods=['GET'])
|
||||
def get_status():
|
||||
"""Get locate session status."""
|
||||
session = get_locate_session()
|
||||
if not session:
|
||||
return jsonify({
|
||||
'active': False,
|
||||
'target': None,
|
||||
})
|
||||
|
||||
include_debug = str(request.args.get('debug', '')).lower() in ('1', 'true', 'yes')
|
||||
return jsonify(session.get_status(include_debug=include_debug))
|
||||
'active': False,
|
||||
'target': None,
|
||||
})
|
||||
|
||||
include_debug = str(request.args.get('debug', '')).lower() in ('1', 'true', 'yes')
|
||||
return jsonify(session.get_status(include_debug=include_debug))
|
||||
|
||||
|
||||
@bt_locate_bp.route('/trail', methods=['GET'])
|
||||
@@ -216,15 +210,15 @@ def test_resolve_rpa():
|
||||
address = data.get('address', '')
|
||||
|
||||
if not irk_hex or not address:
|
||||
return jsonify({'error': 'irk_hex and address are required'}), 400
|
||||
return api_error('irk_hex and address are required', 400)
|
||||
|
||||
try:
|
||||
irk = bytes.fromhex(irk_hex)
|
||||
except ValueError:
|
||||
return jsonify({'error': 'Invalid IRK hex string'}), 400
|
||||
return api_error('Invalid IRK hex string', 400)
|
||||
|
||||
if len(irk) != 16:
|
||||
return jsonify({'error': 'IRK must be exactly 16 bytes (32 hex characters)'}), 400
|
||||
return api_error('IRK must be exactly 16 bytes (32 hex characters)', 400)
|
||||
|
||||
result = resolve_rpa(irk, address)
|
||||
return jsonify({
|
||||
@@ -239,14 +233,14 @@ def set_environment():
|
||||
"""Update the environment on the active session."""
|
||||
session = get_locate_session()
|
||||
if not session:
|
||||
return jsonify({'error': 'no active session'}), 400
|
||||
return api_error('no active session', 400)
|
||||
|
||||
data = request.get_json() or {}
|
||||
env_str = data.get('environment', '').upper()
|
||||
try:
|
||||
environment = Environment[env_str]
|
||||
except KeyError:
|
||||
return jsonify({'error': f'Invalid environment: {env_str}'}), 400
|
||||
return api_error(f'Invalid environment: {env_str}', 400)
|
||||
|
||||
custom_exponent = data.get('custom_exponent')
|
||||
if custom_exponent is not None:
|
||||
@@ -268,11 +262,11 @@ def debug_matching():
|
||||
"""Debug endpoint showing scanner devices and match results."""
|
||||
session = get_locate_session()
|
||||
if not session:
|
||||
return jsonify({'error': 'no session'})
|
||||
return api_error('no session')
|
||||
|
||||
scanner = session._scanner
|
||||
if not scanner:
|
||||
return jsonify({'error': 'no scanner'})
|
||||
return api_error('no scanner')
|
||||
|
||||
devices = scanner.get_devices(max_age_seconds=30)
|
||||
return jsonify({
|
||||
|
||||
+171
-207
@@ -10,55 +10,60 @@ This blueprint provides:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Generator
|
||||
|
||||
import requests
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Generator
|
||||
from datetime import datetime, timezone
|
||||
|
||||
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 (
|
||||
create_agent, get_agent, get_agent_by_name, list_agents,
|
||||
update_agent, delete_agent, store_push_payload, get_recent_payloads
|
||||
)
|
||||
from utils.agent_client import (
|
||||
AgentClient, AgentHTTPError, AgentConnectionError, create_client_from_agent
|
||||
create_agent,
|
||||
delete_agent,
|
||||
get_agent,
|
||||
get_agent_by_name,
|
||||
get_recent_payloads,
|
||||
list_agents,
|
||||
store_push_payload,
|
||||
update_agent,
|
||||
)
|
||||
from utils.responses import api_error
|
||||
from utils.sse import format_sse
|
||||
from utils.trilateration import (
|
||||
DeviceLocationTracker, PathLossModel, Trilateration,
|
||||
AgentObservation, estimate_location_from_observations
|
||||
DeviceLocationTracker,
|
||||
PathLossModel,
|
||||
Trilateration,
|
||||
estimate_location_from_observations,
|
||||
)
|
||||
|
||||
logger = logging.getLogger('intercept.controller')
|
||||
|
||||
controller_bp = Blueprint('controller', __name__, url_prefix='/controller')
|
||||
|
||||
# Multi-agent SSE fanout state (per-client queues).
|
||||
_agent_stream_subscribers: set[queue.Queue] = set()
|
||||
_agent_stream_subscribers_lock = threading.Lock()
|
||||
_AGENT_STREAM_CLIENT_QUEUE_SIZE = 500
|
||||
|
||||
|
||||
def _broadcast_agent_data(payload: dict) -> None:
|
||||
"""Fan out an ingested payload to all active /controller/stream/all clients."""
|
||||
with _agent_stream_subscribers_lock:
|
||||
subscribers = tuple(_agent_stream_subscribers)
|
||||
|
||||
for subscriber in subscribers:
|
||||
try:
|
||||
subscriber.put_nowait(payload)
|
||||
except queue.Full:
|
||||
try:
|
||||
subscriber.get_nowait()
|
||||
subscriber.put_nowait(payload)
|
||||
except (queue.Empty, queue.Full):
|
||||
continue
|
||||
controller_bp = Blueprint('controller', __name__, url_prefix='/controller')
|
||||
|
||||
# Multi-agent SSE fanout state (per-client queues).
|
||||
_agent_stream_subscribers: set[queue.Queue] = set()
|
||||
_agent_stream_subscribers_lock = threading.Lock()
|
||||
_AGENT_STREAM_CLIENT_QUEUE_SIZE = 500
|
||||
|
||||
|
||||
def _broadcast_agent_data(payload: dict) -> None:
|
||||
"""Fan out an ingested payload to all active /controller/stream/all clients."""
|
||||
with _agent_stream_subscribers_lock:
|
||||
subscribers = tuple(_agent_stream_subscribers)
|
||||
|
||||
for subscriber in subscribers:
|
||||
try:
|
||||
subscriber.put_nowait(payload)
|
||||
except queue.Full:
|
||||
try:
|
||||
subscriber.get_nowait()
|
||||
subscriber.put_nowait(payload)
|
||||
except (queue.Empty, queue.Full):
|
||||
continue
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -108,28 +113,25 @@ def register_agent():
|
||||
base_url = data.get('base_url', '').strip()
|
||||
|
||||
if not name:
|
||||
return jsonify({'status': 'error', 'message': 'Agent name is required'}), 400
|
||||
return api_error('Agent name is required', 400)
|
||||
if not base_url:
|
||||
return jsonify({'status': 'error', 'message': 'Base URL is required'}), 400
|
||||
return api_error('Base URL is required', 400)
|
||||
|
||||
# Validate URL format
|
||||
from urllib.parse import urlparse
|
||||
try:
|
||||
parsed = urlparse(base_url)
|
||||
if parsed.scheme not in ('http', 'https'):
|
||||
return jsonify({'status': 'error', 'message': 'URL must start with http:// or https://'}), 400
|
||||
return api_error('URL must start with http:// or https://', 400)
|
||||
if not parsed.netloc:
|
||||
return jsonify({'status': 'error', 'message': 'Invalid URL format'}), 400
|
||||
return api_error('Invalid URL format', 400)
|
||||
except Exception:
|
||||
return jsonify({'status': 'error', 'message': 'Invalid URL format'}), 400
|
||||
return api_error('Invalid URL format', 400)
|
||||
|
||||
# Check if agent already exists
|
||||
existing = get_agent_by_name(name)
|
||||
if existing:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Agent with name "{name}" already exists'
|
||||
}), 409
|
||||
return api_error(f'Agent with name "{name}" already exists', 409)
|
||||
|
||||
# Try to connect and get capabilities
|
||||
api_key = data.get('api_key', '').strip() or None
|
||||
@@ -171,7 +173,7 @@ def register_agent():
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Failed to create agent")
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@controller_bp.route('/agents/<int:agent_id>', methods=['GET'])
|
||||
@@ -179,7 +181,7 @@ def get_agent_detail(agent_id: int):
|
||||
"""Get details of a specific agent."""
|
||||
agent = get_agent(agent_id)
|
||||
if not agent:
|
||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||
return api_error('Agent not found', 404)
|
||||
|
||||
# Optionally refresh from agent
|
||||
refresh = request.args.get('refresh', 'false').lower() == 'true'
|
||||
@@ -215,7 +217,7 @@ def update_agent_detail(agent_id: int):
|
||||
"""Update an agent's details."""
|
||||
agent = get_agent(agent_id)
|
||||
if not agent:
|
||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||
return api_error('Agent not found', 404)
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
@@ -237,7 +239,7 @@ def remove_agent(agent_id: int):
|
||||
"""Delete an agent."""
|
||||
agent = get_agent(agent_id)
|
||||
if not agent:
|
||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||
return api_error('Agent not found', 404)
|
||||
|
||||
delete_agent(agent_id)
|
||||
return jsonify({'status': 'success', 'message': 'Agent deleted'})
|
||||
@@ -248,7 +250,7 @@ def refresh_agent_metadata(agent_id: int):
|
||||
"""Refresh an agent's capabilities and status."""
|
||||
agent = get_agent(agent_id)
|
||||
if not agent:
|
||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||
return api_error('Agent not found', 404)
|
||||
|
||||
try:
|
||||
client = create_client_from_agent(agent)
|
||||
@@ -274,16 +276,10 @@ def refresh_agent_metadata(agent_id: int):
|
||||
'metadata': metadata
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Agent is not reachable'
|
||||
}), 503
|
||||
return api_error('Agent is not reachable', 503)
|
||||
|
||||
except (AgentHTTPError, AgentConnectionError) as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Failed to reach agent: {e}'
|
||||
}), 503
|
||||
return api_error(f'Failed to reach agent: {e}', 503)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -295,7 +291,7 @@ def get_agent_status(agent_id: int):
|
||||
"""Get an agent's current status including running modes."""
|
||||
agent = get_agent(agent_id)
|
||||
if not agent:
|
||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||
return api_error('Agent not found', 404)
|
||||
|
||||
try:
|
||||
client = create_client_from_agent(agent)
|
||||
@@ -307,10 +303,7 @@ def get_agent_status(agent_id: int):
|
||||
'agent_status': status
|
||||
})
|
||||
except (AgentHTTPError, AgentConnectionError) as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Failed to reach agent: {e}'
|
||||
}), 503
|
||||
return api_error(f'Failed to reach agent: {e}', 503)
|
||||
|
||||
|
||||
@controller_bp.route('/agents/health', methods=['GET'])
|
||||
@@ -384,7 +377,7 @@ def proxy_start_mode(agent_id: int, mode: str):
|
||||
"""Start a mode on a remote agent."""
|
||||
agent = get_agent(agent_id)
|
||||
if not agent:
|
||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||
return api_error('Agent not found', 404)
|
||||
|
||||
params = request.json or {}
|
||||
|
||||
@@ -403,15 +396,9 @@ def proxy_start_mode(agent_id: int, mode: str):
|
||||
})
|
||||
|
||||
except AgentConnectionError as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Cannot connect to agent: {e}'
|
||||
}), 503
|
||||
return api_error(f'Cannot connect to agent: {e}', 503)
|
||||
except AgentHTTPError as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Agent error: {e}'
|
||||
}), 502
|
||||
return api_error(f'Agent error: {e}', 502)
|
||||
|
||||
|
||||
@controller_bp.route('/agents/<int:agent_id>/<mode>/stop', methods=['POST'])
|
||||
@@ -419,7 +406,7 @@ def proxy_stop_mode(agent_id: int, mode: str):
|
||||
"""Stop a mode on a remote agent."""
|
||||
agent = get_agent(agent_id)
|
||||
if not agent:
|
||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||
return api_error('Agent not found', 404)
|
||||
|
||||
try:
|
||||
client = create_client_from_agent(agent)
|
||||
@@ -435,15 +422,9 @@ def proxy_stop_mode(agent_id: int, mode: str):
|
||||
})
|
||||
|
||||
except AgentConnectionError as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Cannot connect to agent: {e}'
|
||||
}), 503
|
||||
return api_error(f'Cannot connect to agent: {e}', 503)
|
||||
except AgentHTTPError as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Agent error: {e}'
|
||||
}), 502
|
||||
return api_error(f'Agent error: {e}', 502)
|
||||
|
||||
|
||||
@controller_bp.route('/agents/<int:agent_id>/<mode>/status', methods=['GET'])
|
||||
@@ -451,7 +432,7 @@ def proxy_mode_status(agent_id: int, mode: str):
|
||||
"""Get mode status from a remote agent."""
|
||||
agent = get_agent(agent_id)
|
||||
if not agent:
|
||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||
return api_error('Agent not found', 404)
|
||||
|
||||
try:
|
||||
client = create_client_from_agent(agent)
|
||||
@@ -465,18 +446,15 @@ def proxy_mode_status(agent_id: int, mode: str):
|
||||
})
|
||||
|
||||
except (AgentHTTPError, AgentConnectionError) as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Agent error: {e}'
|
||||
}), 502
|
||||
return api_error(f'Agent error: {e}', 502)
|
||||
|
||||
|
||||
@controller_bp.route('/agents/<int:agent_id>/<mode>/data', methods=['GET'])
|
||||
def proxy_mode_data(agent_id: int, mode: str):
|
||||
"""Get current data from a remote agent."""
|
||||
agent = get_agent(agent_id)
|
||||
if not agent:
|
||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||
@controller_bp.route('/agents/<int:agent_id>/<mode>/data', methods=['GET'])
|
||||
def proxy_mode_data(agent_id: int, mode: str):
|
||||
"""Get current data from a remote agent."""
|
||||
agent = get_agent(agent_id)
|
||||
if not agent:
|
||||
return api_error('Agent not found', 404)
|
||||
|
||||
try:
|
||||
client = create_client_from_agent(agent)
|
||||
@@ -494,60 +472,57 @@ def proxy_mode_data(agent_id: int, mode: str):
|
||||
'data': result
|
||||
})
|
||||
|
||||
except (AgentHTTPError, AgentConnectionError) as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Agent error: {e}'
|
||||
}), 502
|
||||
|
||||
|
||||
@controller_bp.route('/agents/<int:agent_id>/<mode>/stream')
|
||||
def proxy_mode_stream(agent_id: int, mode: str):
|
||||
"""Proxy SSE stream from a remote agent."""
|
||||
agent = get_agent(agent_id)
|
||||
if not agent:
|
||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||
|
||||
client = create_client_from_agent(agent)
|
||||
query = request.query_string.decode('utf-8')
|
||||
url = f"{client.base_url}/{mode}/stream"
|
||||
if query:
|
||||
url = f"{url}?{query}"
|
||||
|
||||
headers = {'Accept': 'text/event-stream'}
|
||||
if agent.get('api_key'):
|
||||
headers['X-API-Key'] = agent['api_key']
|
||||
|
||||
def generate() -> Generator[str, None, None]:
|
||||
try:
|
||||
with requests.get(url, headers=headers, stream=True, timeout=(5, 3600)) as resp:
|
||||
resp.raise_for_status()
|
||||
for chunk in resp.iter_content(chunk_size=1024):
|
||||
if not chunk:
|
||||
continue
|
||||
yield chunk.decode('utf-8', errors='ignore')
|
||||
except Exception as e:
|
||||
logger.error(f"SSE proxy error for agent {agent_id}/{mode}: {e}")
|
||||
yield format_sse({
|
||||
'type': 'error',
|
||||
'message': str(e),
|
||||
'agent_id': agent_id,
|
||||
'mode': mode,
|
||||
})
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
|
||||
@controller_bp.route('/agents/<int:agent_id>/wifi/monitor', methods=['POST'])
|
||||
def proxy_wifi_monitor(agent_id: int):
|
||||
"""Toggle monitor mode on a remote agent's WiFi interface."""
|
||||
agent = get_agent(agent_id)
|
||||
if not agent:
|
||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||
except (AgentHTTPError, AgentConnectionError) as e:
|
||||
return api_error(f'Agent error: {e}', 502)
|
||||
|
||||
|
||||
@controller_bp.route('/agents/<int:agent_id>/<mode>/stream')
|
||||
def proxy_mode_stream(agent_id: int, mode: str):
|
||||
"""Proxy SSE stream from a remote agent."""
|
||||
agent = get_agent(agent_id)
|
||||
if not agent:
|
||||
return api_error('Agent not found', 404)
|
||||
|
||||
client = create_client_from_agent(agent)
|
||||
query = request.query_string.decode('utf-8')
|
||||
url = f"{client.base_url}/{mode}/stream"
|
||||
if query:
|
||||
url = f"{url}?{query}"
|
||||
|
||||
headers = {'Accept': 'text/event-stream'}
|
||||
if agent.get('api_key'):
|
||||
headers['X-API-Key'] = agent['api_key']
|
||||
|
||||
def generate() -> Generator[str, None, None]:
|
||||
try:
|
||||
with requests.get(url, headers=headers, stream=True, timeout=(5, 3600)) as resp:
|
||||
resp.raise_for_status()
|
||||
for chunk in resp.iter_content(chunk_size=1024):
|
||||
if not chunk:
|
||||
continue
|
||||
yield chunk.decode('utf-8', errors='ignore')
|
||||
except Exception as e:
|
||||
logger.error(f"SSE proxy error for agent {agent_id}/{mode}: {e}")
|
||||
yield format_sse({
|
||||
'type': 'error',
|
||||
'message': str(e),
|
||||
'agent_id': agent_id,
|
||||
'mode': mode,
|
||||
})
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
|
||||
@controller_bp.route('/agents/<int:agent_id>/wifi/monitor', methods=['POST'])
|
||||
def proxy_wifi_monitor(agent_id: int):
|
||||
"""Toggle monitor mode on a remote agent's WiFi interface."""
|
||||
agent = get_agent(agent_id)
|
||||
if not agent:
|
||||
return api_error('Agent not found', 404)
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
@@ -582,15 +557,9 @@ def proxy_wifi_monitor(agent_id: int):
|
||||
})
|
||||
|
||||
except AgentConnectionError as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Cannot connect to agent: {e}'
|
||||
}), 503
|
||||
return api_error(f'Cannot connect to agent: {e}', 503)
|
||||
except AgentHTTPError as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Agent error: {e}'
|
||||
}), 502
|
||||
return api_error(f'Agent error: {e}', 502)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -616,23 +585,23 @@ def ingest_push_data():
|
||||
"""
|
||||
data = request.json
|
||||
if not data:
|
||||
return jsonify({'status': 'error', 'message': 'No data provided'}), 400
|
||||
return api_error('No data provided', 400)
|
||||
|
||||
agent_name = data.get('agent_name')
|
||||
if not agent_name:
|
||||
return jsonify({'status': 'error', 'message': 'agent_name required'}), 400
|
||||
return api_error('agent_name required', 400)
|
||||
|
||||
# Find agent
|
||||
agent = get_agent_by_name(agent_name)
|
||||
if not agent:
|
||||
return jsonify({'status': 'error', 'message': 'Unknown agent'}), 401
|
||||
return api_error('Unknown agent', 401)
|
||||
|
||||
# Validate API key if configured
|
||||
if agent.get('api_key'):
|
||||
provided_key = request.headers.get('X-API-Key', '')
|
||||
if provided_key != agent['api_key']:
|
||||
logger.warning(f"Invalid API key from agent {agent_name}")
|
||||
return jsonify({'status': 'error', 'message': 'Invalid API key'}), 401
|
||||
return api_error('Invalid API key', 401)
|
||||
|
||||
# Store payload
|
||||
try:
|
||||
@@ -644,16 +613,16 @@ def ingest_push_data():
|
||||
received_at=data.get('received_at')
|
||||
)
|
||||
|
||||
# Emit to SSE stream (fanout to all connected clients)
|
||||
_broadcast_agent_data({
|
||||
'type': 'agent_data',
|
||||
'agent_id': agent['id'],
|
||||
'agent_name': agent_name,
|
||||
'scan_type': data.get('scan_type'),
|
||||
'interface': data.get('interface'),
|
||||
'payload': data.get('payload'),
|
||||
'received_at': data.get('received_at') or datetime.now(timezone.utc).isoformat()
|
||||
})
|
||||
# Emit to SSE stream (fanout to all connected clients)
|
||||
_broadcast_agent_data({
|
||||
'type': 'agent_data',
|
||||
'agent_id': agent['id'],
|
||||
'agent_name': agent_name,
|
||||
'scan_type': data.get('scan_type'),
|
||||
'interface': data.get('interface'),
|
||||
'payload': data.get('payload'),
|
||||
'received_at': data.get('received_at') or datetime.now(timezone.utc).isoformat()
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'status': 'accepted',
|
||||
@@ -662,7 +631,7 @@ def ingest_push_data():
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Failed to store push payload")
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@controller_bp.route('/api/payloads', methods=['GET'])
|
||||
@@ -690,35 +659,35 @@ def get_payloads():
|
||||
# =============================================================================
|
||||
|
||||
@controller_bp.route('/stream/all')
|
||||
def stream_all_agents():
|
||||
def stream_all_agents():
|
||||
"""
|
||||
Combined SSE stream for data from all agents.
|
||||
|
||||
This endpoint streams push data as it arrives from agents.
|
||||
Each message is tagged with agent_id and agent_name.
|
||||
"""
|
||||
client_queue: queue.Queue = queue.Queue(maxsize=_AGENT_STREAM_CLIENT_QUEUE_SIZE)
|
||||
with _agent_stream_subscribers_lock:
|
||||
_agent_stream_subscribers.add(client_queue)
|
||||
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
msg = client_queue.get(timeout=1.0)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= keepalive_interval:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
finally:
|
||||
with _agent_stream_subscribers_lock:
|
||||
_agent_stream_subscribers.discard(client_queue)
|
||||
client_queue: queue.Queue = queue.Queue(maxsize=_AGENT_STREAM_CLIENT_QUEUE_SIZE)
|
||||
with _agent_stream_subscribers_lock:
|
||||
_agent_stream_subscribers.add(client_queue)
|
||||
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
msg = client_queue.get(timeout=1.0)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= keepalive_interval:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
finally:
|
||||
with _agent_stream_subscribers_lock:
|
||||
_agent_stream_subscribers.discard(client_queue)
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
@@ -735,6 +704,7 @@ def stream_all_agents():
|
||||
def agent_management_page():
|
||||
"""Render the agent management page."""
|
||||
from flask import render_template
|
||||
|
||||
from config import 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']
|
||||
for field in required:
|
||||
if field not in data:
|
||||
return jsonify({'status': 'error', 'message': f'Missing required field: {field}'}), 400
|
||||
return api_error(f'Missing required field: {field}', 400)
|
||||
|
||||
# Look up agent GPS from database if not provided
|
||||
agent_lat = data.get('agent_lat')
|
||||
@@ -797,10 +767,7 @@ def add_location_observation():
|
||||
agent_lon = coords.get('lon') or coords.get('longitude')
|
||||
|
||||
if agent_lat is None or agent_lon is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Agent GPS coordinates required'
|
||||
}), 400
|
||||
return api_error('Agent GPS coordinates required', 400)
|
||||
|
||||
estimate = device_tracker.add_observation(
|
||||
device_id=data['device_id'],
|
||||
@@ -837,10 +804,7 @@ def estimate_location():
|
||||
|
||||
observations = data.get('observations', [])
|
||||
if len(observations) < 2:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'At least 2 observations required'
|
||||
}), 400
|
||||
return api_error('At least 2 observations required', 400)
|
||||
|
||||
environment = data.get('environment', 'outdoor')
|
||||
|
||||
@@ -852,7 +816,7 @@ def estimate_location():
|
||||
})
|
||||
except Exception as e:
|
||||
logger.exception("Location estimation failed")
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@controller_bp.route('/api/location/<device_id>', methods=['GET'])
|
||||
@@ -904,7 +868,7 @@ def get_devices_near():
|
||||
lon = float(request.args.get('lon', 0))
|
||||
radius = float(request.args.get('radius', 100))
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid coordinates'}), 400
|
||||
return api_error('Invalid coordinates', 400)
|
||||
|
||||
results = device_tracker.get_devices_near(lat, lon, radius)
|
||||
|
||||
|
||||
+10
-32
@@ -2,11 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
from flask import Blueprint, Response, request
|
||||
|
||||
import app as app_module
|
||||
from utils.correlation import get_correlations
|
||||
from utils.logging import get_logger
|
||||
from utils.responses import api_error, api_success
|
||||
|
||||
logger = get_logger('intercept.correlation')
|
||||
|
||||
@@ -39,18 +40,14 @@ def get_device_correlations() -> Response:
|
||||
include_historical=include_historical
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
return api_success(data={
|
||||
'correlations': correlations,
|
||||
'wifi_count': len(wifi_devices),
|
||||
'bt_count': len(bt_devices)
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error calculating correlations: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@correlation_bp.route('/analyze', methods=['POST'])
|
||||
@@ -67,10 +64,7 @@ def analyze_correlation() -> Response:
|
||||
bt_mac = data.get('bt_mac')
|
||||
|
||||
if not wifi_mac or not bt_mac:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'wifi_mac and bt_mac are required'
|
||||
}), 400
|
||||
return api_error('wifi_mac and bt_mac are required', 400)
|
||||
|
||||
try:
|
||||
# Get device data
|
||||
@@ -81,16 +75,10 @@ def analyze_correlation() -> Response:
|
||||
bt_device = app_module.bt_devices.get(bt_mac)
|
||||
|
||||
if not wifi_device:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'WiFi device {wifi_mac} not found'
|
||||
}), 404
|
||||
return api_error(f'WiFi device {wifi_mac} not found', 404)
|
||||
|
||||
if not bt_device:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Bluetooth device {bt_mac} not found'
|
||||
}), 404
|
||||
return api_error(f'Bluetooth device {bt_mac} not found', 404)
|
||||
|
||||
# Calculate correlation for this specific pair
|
||||
correlations = get_correlations(
|
||||
@@ -101,19 +89,9 @@ def analyze_correlation() -> Response:
|
||||
)
|
||||
|
||||
if correlations:
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'correlation': correlations[0]
|
||||
})
|
||||
return api_success(data={'correlation': correlations[0]})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'correlation': None,
|
||||
'message': 'No correlation detected between these devices'
|
||||
})
|
||||
return api_success(data={'correlation': None}, message='No correlation detected between these devices')
|
||||
except Exception as e:
|
||||
logger.error(f"Error analyzing correlation: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
+85
-63
@@ -6,7 +6,7 @@ distress and safety communications per ITU-R M.493.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import pty
|
||||
@@ -16,31 +16,36 @@ import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
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
|
||||
from utils.constants import (
|
||||
DSC_VHF_FREQUENCY_MHZ,
|
||||
DSC_SAMPLE_RATE,
|
||||
DSC_TERMINATE_TIMEOUT,
|
||||
DSC_VHF_FREQUENCY_MHZ,
|
||||
)
|
||||
from utils.database import (
|
||||
store_dsc_alert,
|
||||
get_dsc_alerts,
|
||||
get_dsc_alert,
|
||||
acknowledge_dsc_alert,
|
||||
get_dsc_alert,
|
||||
get_dsc_alert_summary,
|
||||
get_dsc_alerts,
|
||||
store_dsc_alert,
|
||||
)
|
||||
from utils.dsc.parser import parse_dsc_message
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.validation import validate_device_index, validate_gain
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.dependencies import get_tool_path
|
||||
from utils.dsc.parser import parse_dsc_message
|
||||
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 (
|
||||
validate_device_index,
|
||||
validate_gain,
|
||||
validate_rtl_tcp_host,
|
||||
validate_rtl_tcp_port,
|
||||
)
|
||||
|
||||
logger = logging.getLogger('intercept.dsc')
|
||||
|
||||
@@ -51,6 +56,7 @@ dsc_running = False
|
||||
|
||||
# Track which device is being used
|
||||
dsc_active_device: int | None = None
|
||||
dsc_active_sdr_type: str | None = None
|
||||
|
||||
|
||||
def _get_dsc_decoder_path() -> str | None:
|
||||
@@ -76,8 +82,8 @@ def _check_dsc_tools() -> dict:
|
||||
# Check for scipy/numpy (needed for decoder)
|
||||
scipy_available = False
|
||||
try:
|
||||
import scipy
|
||||
import numpy
|
||||
import scipy
|
||||
scipy_available = True
|
||||
except ImportError:
|
||||
pass
|
||||
@@ -171,11 +177,9 @@ def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> Non
|
||||
'error': str(e)
|
||||
})
|
||||
finally:
|
||||
global dsc_active_device
|
||||
try:
|
||||
global dsc_active_device, dsc_active_sdr_type
|
||||
with contextlib.suppress(OSError):
|
||||
os.close(master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
dsc_running = False
|
||||
# Cleanup both processes
|
||||
with app_module.dsc_lock:
|
||||
@@ -186,10 +190,8 @@ def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> Non
|
||||
proc.terminate()
|
||||
proc.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
unregister_process(proc)
|
||||
app_module.dsc_queue.put({'type': 'status', 'status': 'stopped'})
|
||||
with app_module.dsc_lock:
|
||||
@@ -197,8 +199,9 @@ def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> Non
|
||||
app_module.dsc_rtl_process = None
|
||||
# Release SDR device
|
||||
if dsc_active_device is not None:
|
||||
app_module.release_sdr_device(dsc_active_device)
|
||||
app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
|
||||
dsc_active_device = None
|
||||
dsc_active_sdr_type = None
|
||||
|
||||
|
||||
def _store_critical_alert(msg: dict) -> None:
|
||||
@@ -331,18 +334,32 @@ def start_decoding() -> Response:
|
||||
'message': str(e)
|
||||
}), 400
|
||||
|
||||
# Check if device is available using centralized registry
|
||||
global dsc_active_device
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'dsc')
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
# Get SDR type from request
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
|
||||
dsc_active_device = device_int
|
||||
# Check for rtl_tcp (remote SDR) connection
|
||||
rtl_tcp_host = data.get('rtl_tcp_host')
|
||||
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
||||
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
|
||||
# Check if device is available using centralized registry (skip for remote rtl_tcp)
|
||||
global dsc_active_device, dsc_active_sdr_type
|
||||
if not rtl_tcp_host:
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'dsc', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
|
||||
dsc_active_device = device_int
|
||||
dsc_active_sdr_type = sdr_type_str
|
||||
|
||||
# Clear queue
|
||||
while not app_module.dsc_queue.empty():
|
||||
@@ -351,22 +368,32 @@ def start_decoding() -> Response:
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Build rtl_fm command
|
||||
rtl_fm_path = tools['rtl_fm']['path']
|
||||
# Build rtl_fm command via SDR abstraction layer
|
||||
decoder_path = tools['dsc_decoder']['path']
|
||||
|
||||
# rtl_fm command for DSC decoding
|
||||
# DSC uses narrow FM at 156.525 MHz with 48kHz sample rate
|
||||
rtl_cmd = [
|
||||
rtl_fm_path,
|
||||
'-f', f'{DSC_VHF_FREQUENCY_MHZ}M',
|
||||
'-s', str(DSC_SAMPLE_RATE),
|
||||
'-d', str(device),
|
||||
'-g', str(gain),
|
||||
'-M', 'fm', # FM demodulation
|
||||
'-l', '0', # No squelch for DSC
|
||||
'-E', 'dc' # DC blocking filter
|
||||
]
|
||||
if rtl_tcp_host:
|
||||
try:
|
||||
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
|
||||
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
|
||||
except ValueError as e:
|
||||
return api_error(str(e), 400)
|
||||
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
|
||||
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
|
||||
else:
|
||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=int(device))
|
||||
|
||||
builder = SDRFactory.get_builder(sdr_device.sdr_type)
|
||||
rtl_cmd = list(builder.build_fm_demod_command(
|
||||
device=sdr_device,
|
||||
frequency_mhz=DSC_VHF_FREQUENCY_MHZ,
|
||||
sample_rate=DSC_SAMPLE_RATE,
|
||||
gain=float(gain) if gain and str(gain) != '0' else None,
|
||||
modulation='fm',
|
||||
squelch=0,
|
||||
))
|
||||
# Ensure trailing '-' for stdin piping and add DC blocking filter
|
||||
if rtl_cmd and rtl_cmd[-1] == '-':
|
||||
rtl_cmd = rtl_cmd[:-1] + ['-E', 'dc', '-']
|
||||
|
||||
# Decoder command
|
||||
decoder_cmd = [decoder_path]
|
||||
@@ -434,14 +461,13 @@ def start_decoding() -> Response:
|
||||
rtl_process.terminate()
|
||||
rtl_process.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
rtl_process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
# Release device on failure
|
||||
if dsc_active_device is not None:
|
||||
app_module.release_sdr_device(dsc_active_device)
|
||||
app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
|
||||
dsc_active_device = None
|
||||
dsc_active_sdr_type = None
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Tool not found: {e.filename}'
|
||||
@@ -452,14 +478,13 @@ def start_decoding() -> Response:
|
||||
rtl_process.terminate()
|
||||
rtl_process.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
rtl_process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
# Release device on failure
|
||||
if dsc_active_device is not None:
|
||||
app_module.release_sdr_device(dsc_active_device)
|
||||
app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
|
||||
dsc_active_device = None
|
||||
dsc_active_sdr_type = None
|
||||
logger.error(f"Failed to start DSC decoder: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
@@ -470,7 +495,7 @@ def start_decoding() -> Response:
|
||||
@dsc_bp.route('/stop', methods=['POST'])
|
||||
def stop_decoding() -> Response:
|
||||
"""Stop DSC decoder."""
|
||||
global dsc_running, dsc_active_device
|
||||
global dsc_running, dsc_active_device, dsc_active_sdr_type
|
||||
|
||||
with app_module.dsc_lock:
|
||||
if not app_module.dsc_process:
|
||||
@@ -484,10 +509,8 @@ def stop_decoding() -> Response:
|
||||
app_module.dsc_rtl_process.terminate()
|
||||
app_module.dsc_rtl_process.wait(timeout=DSC_TERMINATE_TIMEOUT)
|
||||
except subprocess.TimeoutExpired:
|
||||
try:
|
||||
with contextlib.suppress(OSError):
|
||||
app_module.dsc_rtl_process.kill()
|
||||
except OSError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
@@ -497,10 +520,8 @@ def stop_decoding() -> Response:
|
||||
app_module.dsc_process.terminate()
|
||||
app_module.dsc_process.wait(timeout=DSC_TERMINATE_TIMEOUT)
|
||||
except subprocess.TimeoutExpired:
|
||||
try:
|
||||
with contextlib.suppress(OSError):
|
||||
app_module.dsc_process.kill()
|
||||
except OSError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
@@ -509,8 +530,9 @@ def stop_decoding() -> Response:
|
||||
|
||||
# Release device from registry
|
||||
if dsc_active_device is not None:
|
||||
app_module.release_sdr_device(dsc_active_device)
|
||||
app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
|
||||
dsc_active_device = None
|
||||
dsc_active_sdr_type = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
+22
-20
@@ -3,8 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
import time
|
||||
from collections.abc import Generator
|
||||
|
||||
from flask import Blueprint, Response, jsonify
|
||||
|
||||
@@ -21,7 +19,7 @@ from utils.gps import (
|
||||
stop_gpsd_daemon,
|
||||
)
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.sse import sse_stream_fanout
|
||||
|
||||
logger = get_logger('intercept.gps')
|
||||
|
||||
@@ -68,6 +66,9 @@ def auto_connect_gps():
|
||||
# Check if already running
|
||||
reader = get_gps_reader()
|
||||
if reader and reader.is_running:
|
||||
# Ensure stream callbacks are attached for this process.
|
||||
reader.add_callback(_position_callback)
|
||||
reader.add_sky_callback(_sky_callback)
|
||||
position = reader.position
|
||||
sky = reader.sky
|
||||
return jsonify({
|
||||
@@ -211,9 +212,10 @@ def get_satellites():
|
||||
|
||||
if not reader or not reader.is_running:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'status': 'waiting',
|
||||
'running': False,
|
||||
'message': 'GPS client not running'
|
||||
}), 400
|
||||
})
|
||||
|
||||
sky = reader.sky
|
||||
if sky:
|
||||
@@ -228,19 +230,19 @@ def get_satellites():
|
||||
})
|
||||
|
||||
|
||||
@gps_bp.route('/stream')
|
||||
def stream_gps():
|
||||
"""SSE stream of GPS position and sky updates."""
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=_gps_queue,
|
||||
channel_key='gps',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
@gps_bp.route('/stream')
|
||||
def stream_gps():
|
||||
"""SSE stream of GPS position and sky updates."""
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=_gps_queue,
|
||||
channel_key='gps',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,523 @@
|
||||
"""Receiver routes for radio monitoring and frequency scanning.
|
||||
|
||||
This package splits the listening post into sub-modules:
|
||||
scanner - /scanner/*, /presets routes
|
||||
audio - /audio/* routes
|
||||
waterfall - /waterfall/* routes
|
||||
tools - /tools, /signal/guess routes
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import queue
|
||||
import 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
|
||||
|
||||
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.sse import sse_stream_fanout
|
||||
from utils.meshtastic import (
|
||||
MeshtasticMessage,
|
||||
get_meshtastic_client,
|
||||
is_meshtastic_available,
|
||||
start_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')
|
||||
|
||||
@@ -453,8 +452,8 @@ def get_messages():
|
||||
})
|
||||
|
||||
|
||||
@meshtastic_bp.route('/stream')
|
||||
def stream_messages():
|
||||
@meshtastic_bp.route('/stream')
|
||||
def stream_messages():
|
||||
"""
|
||||
SSE stream of Meshtastic messages.
|
||||
|
||||
@@ -469,18 +468,18 @@ def stream_messages():
|
||||
Returns:
|
||||
SSE stream (text/event-stream)
|
||||
"""
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=_mesh_queue,
|
||||
channel_key='meshtastic',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=_mesh_queue,
|
||||
channel_key='meshtastic',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
|
||||
@@ -1050,11 +1049,11 @@ def request_store_forward():
|
||||
def mesh_topology():
|
||||
"""Return mesh network topology graph."""
|
||||
if not is_meshtastic_available():
|
||||
return jsonify({'status': 'error', 'message': 'Meshtastic SDK not installed'}), 400
|
||||
return api_error('Meshtastic SDK not installed', 400)
|
||||
|
||||
client = get_meshtastic_client()
|
||||
if not client or not client.is_running:
|
||||
return jsonify({'status': 'error', 'message': 'Not connected'}), 400
|
||||
return api_error('Not connected', 400)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
|
||||
@@ -0,0 +1,599 @@
|
||||
"""WebSocket-based meteor scatter monitoring with waterfall display and ping detection.
|
||||
|
||||
Provides:
|
||||
- WebSocket at /ws/meteor for binary waterfall frames (reuses waterfall_fft pipeline)
|
||||
- SSE at /meteor/stream for detection events and stats
|
||||
- REST endpoints for status, events, and export
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import queue
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from contextlib import suppress
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, Flask, Response, jsonify, request
|
||||
|
||||
from utils.responses import api_error
|
||||
|
||||
try:
|
||||
from flask_sock import Sock
|
||||
WEBSOCKET_AVAILABLE = True
|
||||
except ImportError:
|
||||
WEBSOCKET_AVAILABLE = False
|
||||
Sock = None
|
||||
|
||||
from utils.logging import get_logger
|
||||
from utils.meteor_detector import MeteorDetector
|
||||
from utils.process import register_process, safe_terminate, unregister_process
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.sdr.base import SDRCapabilities, SDRDevice
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.validation import validate_device_index, validate_frequency, validate_gain
|
||||
from utils.waterfall_fft import (
|
||||
build_binary_frame,
|
||||
compute_power_spectrum,
|
||||
cu8_to_complex,
|
||||
quantize_to_uint8,
|
||||
)
|
||||
|
||||
logger = get_logger('intercept.meteor')
|
||||
|
||||
# Module-level shared state
|
||||
_state_lock = threading.Lock()
|
||||
_state: dict[str, Any] = {
|
||||
'running': False,
|
||||
'device': None,
|
||||
'frequency_mhz': 0.0,
|
||||
'sample_rate': 0,
|
||||
}
|
||||
_detector: MeteorDetector | None = None
|
||||
_sse_queue: queue.Queue = queue.Queue(maxsize=500)
|
||||
|
||||
# Maximum bandwidth per SDR type (Hz)
|
||||
MAX_BANDWIDTH = {
|
||||
SDRType.RTL_SDR: 2400000,
|
||||
SDRType.HACKRF: 20000000,
|
||||
SDRType.LIME_SDR: 20000000,
|
||||
SDRType.AIRSPY: 10000000,
|
||||
SDRType.SDRPLAY: 2000000,
|
||||
}
|
||||
|
||||
|
||||
def _push_sse(data: dict[str, Any]) -> None:
|
||||
"""Push a message to the SSE queue, dropping oldest if full."""
|
||||
try:
|
||||
_sse_queue.put_nowait(data)
|
||||
except queue.Full:
|
||||
try:
|
||||
_sse_queue.get_nowait()
|
||||
_sse_queue.put_nowait(data)
|
||||
except (queue.Empty, queue.Full):
|
||||
pass
|
||||
|
||||
|
||||
def _resolve_sdr_type(sdr_type_str: str) -> SDRType:
|
||||
mapping = {
|
||||
'rtlsdr': SDRType.RTL_SDR,
|
||||
'rtl_sdr': SDRType.RTL_SDR,
|
||||
'hackrf': SDRType.HACKRF,
|
||||
'limesdr': SDRType.LIME_SDR,
|
||||
'airspy': SDRType.AIRSPY,
|
||||
'sdrplay': SDRType.SDRPLAY,
|
||||
}
|
||||
return mapping.get(sdr_type_str.lower(), SDRType.RTL_SDR)
|
||||
|
||||
|
||||
def _build_dummy_device(device_index: int, sdr_type: SDRType) -> SDRDevice:
|
||||
builder = SDRFactory.get_builder(sdr_type)
|
||||
caps = builder.get_capabilities()
|
||||
return SDRDevice(
|
||||
sdr_type=sdr_type,
|
||||
index=device_index,
|
||||
name=f'{sdr_type.value}-{device_index}',
|
||||
serial='N/A',
|
||||
driver=sdr_type.value,
|
||||
capabilities=caps,
|
||||
)
|
||||
|
||||
|
||||
def _pick_sample_rate(span_hz: int, caps: SDRCapabilities, sdr_type: SDRType) -> int:
|
||||
valid_rates = sorted({int(r) for r in caps.sample_rates if int(r) > 0})
|
||||
if valid_rates:
|
||||
return min(valid_rates, key=lambda rate: abs(rate - span_hz))
|
||||
max_bw = MAX_BANDWIDTH.get(sdr_type, 2400000)
|
||||
return max(62500, min(span_hz, max_bw))
|
||||
|
||||
|
||||
# ── Blueprint for REST/SSE endpoints ──
|
||||
|
||||
meteor_bp = Blueprint('meteor', __name__, url_prefix='/meteor')
|
||||
|
||||
|
||||
@meteor_bp.route('/status')
|
||||
def meteor_status():
|
||||
"""Return current meteor monitoring status."""
|
||||
with _state_lock:
|
||||
running = _state['running']
|
||||
freq = _state['frequency_mhz']
|
||||
device = _state['device']
|
||||
sr = _state['sample_rate']
|
||||
|
||||
detector = _detector
|
||||
stats = None
|
||||
if detector:
|
||||
stats = detector._build_stats(time.time())
|
||||
|
||||
return jsonify({
|
||||
'running': running,
|
||||
'frequency_mhz': freq,
|
||||
'device': device,
|
||||
'sample_rate': sr,
|
||||
'stats': stats,
|
||||
})
|
||||
|
||||
|
||||
@meteor_bp.route('/stream')
|
||||
def meteor_stream():
|
||||
"""SSE endpoint for meteor detection events and stats."""
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=_sse_queue,
|
||||
channel_key='meteor',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
|
||||
@meteor_bp.route('/events')
|
||||
def meteor_events():
|
||||
"""Return detected events as JSON."""
|
||||
detector = _detector
|
||||
if not detector:
|
||||
return jsonify({'events': []})
|
||||
limit = request.args.get('limit', 500, type=int)
|
||||
return jsonify({'events': detector.get_events(limit=limit)})
|
||||
|
||||
|
||||
@meteor_bp.route('/events/export')
|
||||
def meteor_events_export():
|
||||
"""Export events as CSV or JSON."""
|
||||
detector = _detector
|
||||
if not detector:
|
||||
return api_error('No active session', 400)
|
||||
|
||||
fmt = request.args.get('format', 'json').lower()
|
||||
if fmt == 'csv':
|
||||
csv_data = detector.export_events_csv()
|
||||
return Response(
|
||||
csv_data,
|
||||
mimetype='text/csv',
|
||||
headers={'Content-Disposition': 'attachment; filename=meteor_events.csv'},
|
||||
)
|
||||
else:
|
||||
json_data = detector.export_events_json()
|
||||
return Response(
|
||||
json_data,
|
||||
mimetype='application/json',
|
||||
headers={'Content-Disposition': 'attachment; filename=meteor_events.json'},
|
||||
)
|
||||
|
||||
|
||||
@meteor_bp.route('/events/clear', methods=['POST'])
|
||||
def meteor_events_clear():
|
||||
"""Clear all detected events."""
|
||||
detector = _detector
|
||||
if not detector:
|
||||
return jsonify({'cleared': 0})
|
||||
count = detector.clear_events()
|
||||
return jsonify({'cleared': count})
|
||||
|
||||
|
||||
# ── WebSocket handler ──
|
||||
|
||||
def init_meteor_websocket(app: Flask):
|
||||
"""Initialize WebSocket meteor scatter streaming."""
|
||||
global _detector
|
||||
|
||||
if not WEBSOCKET_AVAILABLE:
|
||||
logger.warning("flask-sock not installed, WebSocket meteor disabled")
|
||||
return
|
||||
|
||||
sock = Sock(app)
|
||||
|
||||
@sock.route('/ws/meteor')
|
||||
def meteor_stream_ws(ws):
|
||||
"""WebSocket endpoint for meteor scatter waterfall + detection."""
|
||||
global _detector
|
||||
logger.info("WebSocket meteor client connected")
|
||||
|
||||
import app as app_module
|
||||
|
||||
iq_process = None
|
||||
reader_thread = None
|
||||
stop_event = threading.Event()
|
||||
claimed_device = None
|
||||
claimed_sdr_type = 'rtlsdr'
|
||||
send_queue: queue.Queue = queue.Queue(maxsize=120)
|
||||
|
||||
try:
|
||||
while True:
|
||||
# Drain send queue
|
||||
while True:
|
||||
try:
|
||||
outgoing = send_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
try:
|
||||
ws.send(outgoing)
|
||||
except Exception:
|
||||
stop_event.set()
|
||||
break
|
||||
|
||||
try:
|
||||
msg = ws.receive(timeout=0.01)
|
||||
except Exception as e:
|
||||
err = str(e).lower()
|
||||
if "closed" in err:
|
||||
break
|
||||
if "timed out" not in err:
|
||||
logger.error(f"WebSocket receive error: {e}")
|
||||
continue
|
||||
|
||||
if msg is None:
|
||||
if not ws.connected:
|
||||
break
|
||||
if stop_event.is_set():
|
||||
break
|
||||
continue
|
||||
|
||||
try:
|
||||
data = json.loads(msg)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
|
||||
cmd = data.get('cmd')
|
||||
|
||||
if cmd == 'start':
|
||||
# Stop any existing capture
|
||||
was_restarting = iq_process is not None
|
||||
stop_event.set()
|
||||
if reader_thread and reader_thread.is_alive():
|
||||
reader_thread.join(timeout=2)
|
||||
if iq_process:
|
||||
safe_terminate(iq_process)
|
||||
unregister_process(iq_process)
|
||||
iq_process = None
|
||||
if claimed_device is not None:
|
||||
app_module.release_sdr_device(claimed_device, claimed_sdr_type)
|
||||
claimed_device = None
|
||||
with _state_lock:
|
||||
_state['running'] = False
|
||||
stop_event.clear()
|
||||
while not send_queue.empty():
|
||||
try:
|
||||
send_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
if was_restarting:
|
||||
time.sleep(0.5)
|
||||
|
||||
# Parse config
|
||||
try:
|
||||
frequency_mhz = float(data.get('frequency_mhz', 143.05))
|
||||
validate_frequency(frequency_mhz)
|
||||
gain_raw = data.get('gain')
|
||||
if gain_raw is None or str(gain_raw).lower() == 'auto':
|
||||
gain = None
|
||||
else:
|
||||
gain = validate_gain(float(gain_raw))
|
||||
device_index = validate_device_index(int(data.get('device', 0)))
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
sample_rate_req = int(data.get('sample_rate', 250000))
|
||||
fft_size = int(data.get('fft_size', 1024))
|
||||
fps = int(data.get('fps', 20))
|
||||
avg_count = int(data.get('avg_count', 4))
|
||||
ppm = data.get('ppm')
|
||||
if ppm is not None:
|
||||
ppm = int(ppm)
|
||||
bias_t = bool(data.get('bias_t', False))
|
||||
|
||||
# Detection settings
|
||||
snr_threshold = float(data.get('snr_threshold', 6.0))
|
||||
min_duration = float(data.get('min_duration_ms', 50.0))
|
||||
cooldown = float(data.get('cooldown_ms', 200.0))
|
||||
freq_drift = float(data.get('freq_drift_tolerance_hz', 500.0))
|
||||
except (TypeError, ValueError) as exc:
|
||||
ws.send(json.dumps({
|
||||
'status': 'error',
|
||||
'message': f'Invalid configuration: {exc}',
|
||||
}))
|
||||
continue
|
||||
|
||||
# Clamp values
|
||||
fft_size = max(256, min(4096, fft_size))
|
||||
fps = max(5, min(30, fps))
|
||||
avg_count = max(1, min(16, avg_count))
|
||||
|
||||
# Resolve SDR type and sample rate
|
||||
sdr_type = _resolve_sdr_type(sdr_type_str)
|
||||
builder = SDRFactory.get_builder(sdr_type)
|
||||
caps = builder.get_capabilities()
|
||||
sample_rate = _pick_sample_rate(sample_rate_req, caps, sdr_type)
|
||||
|
||||
# Compute frequency range
|
||||
span_mhz = sample_rate / 1e6
|
||||
start_freq = frequency_mhz - span_mhz / 2
|
||||
end_freq = frequency_mhz + span_mhz / 2
|
||||
|
||||
# Claim SDR device
|
||||
max_claim_attempts = 4 if was_restarting else 1
|
||||
claim_err = None
|
||||
for _attempt in range(max_claim_attempts):
|
||||
claim_err = app_module.claim_sdr_device(device_index, 'meteor', sdr_type_str)
|
||||
if not claim_err:
|
||||
break
|
||||
if _attempt < max_claim_attempts - 1:
|
||||
time.sleep(0.4)
|
||||
if claim_err:
|
||||
ws.send(json.dumps({
|
||||
'status': 'error',
|
||||
'message': claim_err,
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
}))
|
||||
continue
|
||||
claimed_device = device_index
|
||||
claimed_sdr_type = sdr_type_str
|
||||
|
||||
# Build I/Q capture command
|
||||
try:
|
||||
device = _build_dummy_device(device_index, sdr_type)
|
||||
iq_cmd = builder.build_iq_capture_command(
|
||||
device=device,
|
||||
frequency_mhz=frequency_mhz,
|
||||
sample_rate=sample_rate,
|
||||
gain=gain,
|
||||
ppm=ppm,
|
||||
bias_t=bias_t,
|
||||
)
|
||||
except NotImplementedError as e:
|
||||
app_module.release_sdr_device(device_index, sdr_type_str)
|
||||
claimed_device = None
|
||||
ws.send(json.dumps({'status': 'error', 'message': str(e)}))
|
||||
continue
|
||||
|
||||
# Check binary exists
|
||||
if not shutil.which(iq_cmd[0]):
|
||||
app_module.release_sdr_device(device_index, sdr_type_str)
|
||||
claimed_device = None
|
||||
ws.send(json.dumps({
|
||||
'status': 'error',
|
||||
'message': f'Required tool "{iq_cmd[0]}" not found.',
|
||||
}))
|
||||
continue
|
||||
|
||||
# Spawn I/Q capture
|
||||
max_attempts = 3 if was_restarting else 1
|
||||
try:
|
||||
for attempt in range(max_attempts):
|
||||
logger.info(
|
||||
f"Starting meteor I/Q capture: {frequency_mhz:.6f} MHz, "
|
||||
f"sr={sample_rate}, fft={fft_size}"
|
||||
)
|
||||
iq_process = subprocess.Popen(
|
||||
iq_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
bufsize=0,
|
||||
)
|
||||
register_process(iq_process)
|
||||
|
||||
time.sleep(0.3)
|
||||
if iq_process.poll() is not None:
|
||||
stderr_out = ''
|
||||
if iq_process.stderr:
|
||||
with suppress(Exception):
|
||||
stderr_out = iq_process.stderr.read().decode('utf-8', errors='replace').strip()
|
||||
unregister_process(iq_process)
|
||||
iq_process = None
|
||||
if attempt < max_attempts - 1:
|
||||
time.sleep(0.5)
|
||||
continue
|
||||
detail = f": {stderr_out}" if stderr_out else ""
|
||||
raise RuntimeError(f"I/Q process exited immediately{detail}")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start meteor I/Q capture: {e}")
|
||||
if iq_process:
|
||||
safe_terminate(iq_process)
|
||||
unregister_process(iq_process)
|
||||
iq_process = None
|
||||
app_module.release_sdr_device(device_index, sdr_type_str)
|
||||
claimed_device = None
|
||||
ws.send(json.dumps({
|
||||
'status': 'error',
|
||||
'message': f'Failed to start I/Q capture: {e}',
|
||||
}))
|
||||
continue
|
||||
|
||||
# Initialize detector
|
||||
_detector = MeteorDetector(
|
||||
snr_threshold_db=snr_threshold,
|
||||
min_duration_ms=min_duration,
|
||||
cooldown_ms=cooldown,
|
||||
freq_drift_tolerance_hz=freq_drift,
|
||||
)
|
||||
|
||||
with _state_lock:
|
||||
_state['running'] = True
|
||||
_state['device'] = device_index
|
||||
_state['frequency_mhz'] = frequency_mhz
|
||||
_state['sample_rate'] = sample_rate
|
||||
|
||||
# Send confirmation
|
||||
ws.send(json.dumps({
|
||||
'status': 'started',
|
||||
'frequency_mhz': frequency_mhz,
|
||||
'start_freq': start_freq,
|
||||
'end_freq': end_freq,
|
||||
'fft_size': fft_size,
|
||||
'sample_rate': sample_rate,
|
||||
'span_mhz': span_mhz,
|
||||
}))
|
||||
|
||||
# Start FFT reader + detection thread
|
||||
def fft_reader(
|
||||
proc, _send_q, stop_evt, detector,
|
||||
_fft_size, _avg_count, _fps, _sample_rate,
|
||||
_start_freq, _end_freq, _freq_mhz,
|
||||
):
|
||||
required_fft_samples = _fft_size * _avg_count
|
||||
timeslice_samples = max(required_fft_samples, int(_sample_rate / max(1, _fps)))
|
||||
bytes_per_frame = timeslice_samples * 2
|
||||
frame_interval = 1.0 / _fps
|
||||
start_freq_hz = _start_freq * 1e6
|
||||
end_freq_hz = _end_freq * 1e6
|
||||
last_stats_push = 0.0
|
||||
|
||||
try:
|
||||
while not stop_evt.is_set():
|
||||
if proc.poll() is not None:
|
||||
break
|
||||
|
||||
frame_start = time.monotonic()
|
||||
|
||||
# Read raw I/Q
|
||||
raw = b''
|
||||
remaining = bytes_per_frame
|
||||
while remaining > 0 and not stop_evt.is_set():
|
||||
chunk = proc.stdout.read(min(remaining, 65536))
|
||||
if not chunk:
|
||||
break
|
||||
raw += chunk
|
||||
remaining -= len(chunk)
|
||||
|
||||
if len(raw) < _fft_size * 2:
|
||||
break
|
||||
|
||||
# FFT pipeline
|
||||
samples = cu8_to_complex(raw)
|
||||
fft_samples = samples[-required_fft_samples:] if len(samples) > required_fft_samples else samples
|
||||
power_db = compute_power_spectrum(
|
||||
fft_samples,
|
||||
fft_size=_fft_size,
|
||||
avg_count=_avg_count,
|
||||
)
|
||||
quantized = quantize_to_uint8(power_db)
|
||||
frame = build_binary_frame(_start_freq, _end_freq, quantized)
|
||||
|
||||
# Send waterfall frame via WS
|
||||
with suppress(queue.Full):
|
||||
_send_q.put_nowait(frame)
|
||||
|
||||
# Run detection on raw dB spectrum
|
||||
now = time.time()
|
||||
stats, event = detector.process_frame(
|
||||
power_db, start_freq_hz, end_freq_hz, now,
|
||||
)
|
||||
|
||||
# Push event immediately via SSE
|
||||
if event:
|
||||
_push_sse({
|
||||
'type': 'event',
|
||||
'event': event.to_dict(),
|
||||
})
|
||||
# Also send as JSON via WS for immediate UI update
|
||||
event_msg = json.dumps({
|
||||
'type': 'detection',
|
||||
'event': event.to_dict(),
|
||||
})
|
||||
with suppress(queue.Full):
|
||||
_send_q.put_nowait(event_msg)
|
||||
|
||||
# Push stats every ~1s via SSE
|
||||
if now - last_stats_push >= 1.0:
|
||||
_push_sse(stats)
|
||||
last_stats_push = now
|
||||
|
||||
# Pace to target FPS
|
||||
elapsed = time.monotonic() - frame_start
|
||||
sleep_time = frame_interval - elapsed
|
||||
if sleep_time > 0:
|
||||
stop_evt.wait(sleep_time)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Meteor FFT reader stopped: {e}")
|
||||
|
||||
reader_thread = threading.Thread(
|
||||
target=fft_reader,
|
||||
args=(
|
||||
iq_process, send_queue, stop_event, _detector,
|
||||
fft_size, avg_count, fps, sample_rate,
|
||||
start_freq, end_freq, frequency_mhz,
|
||||
),
|
||||
daemon=True,
|
||||
)
|
||||
reader_thread.start()
|
||||
|
||||
elif cmd == 'update_threshold':
|
||||
detector = _detector
|
||||
if detector:
|
||||
detector.update_settings(
|
||||
snr_threshold_db=data.get('snr_threshold'),
|
||||
min_duration_ms=data.get('min_duration_ms'),
|
||||
cooldown_ms=data.get('cooldown_ms'),
|
||||
freq_drift_tolerance_hz=data.get('freq_drift_tolerance_hz'),
|
||||
)
|
||||
ws.send(json.dumps({'status': 'threshold_updated'}))
|
||||
|
||||
elif cmd == 'stop':
|
||||
stop_event.set()
|
||||
if reader_thread and reader_thread.is_alive():
|
||||
reader_thread.join(timeout=2)
|
||||
reader_thread = None
|
||||
if iq_process:
|
||||
safe_terminate(iq_process)
|
||||
unregister_process(iq_process)
|
||||
iq_process = None
|
||||
if claimed_device is not None:
|
||||
app_module.release_sdr_device(claimed_device, claimed_sdr_type)
|
||||
claimed_device = None
|
||||
with _state_lock:
|
||||
_state['running'] = False
|
||||
_state['device'] = None
|
||||
stop_event.clear()
|
||||
ws.send(json.dumps({'status': 'stopped'}))
|
||||
|
||||
except Exception as e:
|
||||
logger.info(f"WebSocket meteor closed: {e}")
|
||||
finally:
|
||||
stop_event.set()
|
||||
if reader_thread and reader_thread.is_alive():
|
||||
reader_thread.join(timeout=2)
|
||||
if iq_process:
|
||||
safe_terminate(iq_process)
|
||||
unregister_process(iq_process)
|
||||
if claimed_device is not None:
|
||||
app_module.release_sdr_device(claimed_device, claimed_sdr_type)
|
||||
with _state_lock:
|
||||
_state['running'] = False
|
||||
_state['device'] = None
|
||||
with suppress(Exception):
|
||||
ws.close()
|
||||
with suppress(Exception):
|
||||
ws.sock.shutdown(socket.SHUT_RDWR)
|
||||
with suppress(Exception):
|
||||
ws.sock.close()
|
||||
logger.info("WebSocket meteor client disconnected")
|
||||
+1017
File diff suppressed because it is too large
Load Diff
+22
-34
@@ -2,21 +2,24 @@
|
||||
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
|
||||
|
||||
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')
|
||||
|
||||
# Default offline settings
|
||||
OFFLINE_DEFAULTS = {
|
||||
'offline.enabled': False,
|
||||
# Default to bundled assets/fonts to avoid third-party CDN privacy blocks.
|
||||
'offline.assets_source': 'local',
|
||||
'offline.fonts_source': 'local',
|
||||
'offline.tile_provider': 'cartodb_dark_cyan',
|
||||
'offline.tile_server_url': ''
|
||||
}
|
||||
OFFLINE_DEFAULTS = {
|
||||
'offline.enabled': False,
|
||||
# Default to bundled assets/fonts to avoid third-party CDN privacy blocks.
|
||||
'offline.assets_source': 'local',
|
||||
'offline.fonts_source': 'local',
|
||||
'offline.tile_provider': 'cartodb_dark_cyan',
|
||||
'offline.tile_server_url': ''
|
||||
}
|
||||
|
||||
# Asset paths to check
|
||||
ASSET_PATHS = {
|
||||
@@ -64,10 +67,7 @@ def get_offline_settings():
|
||||
def get_settings():
|
||||
"""Get current offline settings."""
|
||||
settings = get_offline_settings()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'settings': settings
|
||||
})
|
||||
return api_success(data={'settings': settings})
|
||||
|
||||
|
||||
@offline_bp.route('/settings', methods=['POST'])
|
||||
@@ -75,14 +75,14 @@ def save_setting():
|
||||
"""Save an offline setting."""
|
||||
data = request.get_json()
|
||||
if not data or 'key' not in data or 'value' not in data:
|
||||
return jsonify({'status': 'error', 'message': 'Missing key or value'}), 400
|
||||
return api_error('Missing key or value', 400)
|
||||
|
||||
key = data['key']
|
||||
value = data['value']
|
||||
|
||||
# Validate key is an allowed setting
|
||||
if key not in OFFLINE_DEFAULTS:
|
||||
return jsonify({'status': 'error', 'message': f'Unknown setting: {key}'}), 400
|
||||
return api_error(f'Unknown setting: {key}', 400)
|
||||
|
||||
# Validate value type matches default
|
||||
default_type = type(OFFLINE_DEFAULTS[key])
|
||||
@@ -94,18 +94,11 @@ def save_setting():
|
||||
else:
|
||||
value = default_type(value)
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Invalid value type for {key}'
|
||||
}), 400
|
||||
return api_error(f'Invalid value type for {key}', 400)
|
||||
|
||||
set_setting(key, value)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'key': key,
|
||||
'value': value
|
||||
})
|
||||
return api_success(data={'key': key, 'value': value})
|
||||
|
||||
|
||||
@offline_bp.route('/status', methods=['GET'])
|
||||
@@ -134,8 +127,7 @@ def get_status():
|
||||
if not available:
|
||||
all_available = False
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
return api_success(data={
|
||||
'all_available': all_available,
|
||||
'assets': results,
|
||||
'offline_enabled': get_setting('offline.enabled', False)
|
||||
@@ -147,11 +139,11 @@ def check_asset():
|
||||
"""Check if a specific asset file exists."""
|
||||
path = request.args.get('path', '')
|
||||
if not path:
|
||||
return jsonify({'status': 'error', 'message': 'Missing path parameter'}), 400
|
||||
return api_error('Missing path parameter', 400)
|
||||
|
||||
# Security: only allow checking within static/vendor
|
||||
if not path.startswith('/static/vendor/'):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid path'}), 400
|
||||
return api_error('Invalid path', 400)
|
||||
|
||||
# Remove leading slash and construct full path
|
||||
relative_path = path.lstrip('/')
|
||||
@@ -160,8 +152,4 @@ def check_asset():
|
||||
|
||||
exists = os.path.exists(full_path)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'path': path,
|
||||
'exists': exists
|
||||
})
|
||||
return api_success(data={'path': path, 'exists': exists})
|
||||
|
||||
+353
@@ -0,0 +1,353 @@
|
||||
"""Generic OOK signal decoder routes.
|
||||
|
||||
Captures raw OOK frames using rtl_433's flex decoder and streams decoded
|
||||
bit/hex data to the browser for live ASCII interpretation. Supports
|
||||
PWM, PPM, and Manchester modulation with fully configurable pulse timing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import queue
|
||||
import signal
|
||||
import subprocess
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
import app as app_module
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.ook import ook_parser_thread
|
||||
from utils.process import register_process, safe_terminate, unregister_process
|
||||
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_positive_int,
|
||||
validate_ppm,
|
||||
validate_rtl_tcp_host,
|
||||
validate_rtl_tcp_port,
|
||||
)
|
||||
|
||||
ook_bp = Blueprint('ook', __name__)
|
||||
|
||||
# Track which device / SDR type is being used
|
||||
ook_active_device: int | None = None
|
||||
ook_active_sdr_type: str | None = None
|
||||
|
||||
# Parser thread state (avoids monkey-patching subprocess.Popen)
|
||||
_ook_stop_event: threading.Event | None = None
|
||||
_ook_parser_thread: threading.Thread | None = None
|
||||
|
||||
# Supported modulation schemes → rtl_433 flex decoder modulation string
|
||||
_MODULATION_MAP = {
|
||||
'pwm': 'OOK_PWM',
|
||||
'ppm': 'OOK_PPM',
|
||||
'manchester': 'OOK_MC_ZEROBIT',
|
||||
}
|
||||
|
||||
|
||||
def _validate_encoding(value: Any) -> str:
|
||||
enc = str(value).lower().strip()
|
||||
if enc not in _MODULATION_MAP:
|
||||
raise ValueError(f"encoding must be one of: {', '.join(_MODULATION_MAP)}")
|
||||
return enc
|
||||
|
||||
|
||||
@ook_bp.route('/ook/start', methods=['POST'])
|
||||
def start_ook() -> Response:
|
||||
global ook_active_device, ook_active_sdr_type, _ook_stop_event, _ook_parser_thread
|
||||
|
||||
with app_module.ook_lock:
|
||||
if app_module.ook_process:
|
||||
# If the process exited/crashed, clean up stale state and allow restart
|
||||
if app_module.ook_process.poll() is not None:
|
||||
cleanup_ook(emit_status=False)
|
||||
else:
|
||||
return api_error('OOK decoder already running', 409)
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
try:
|
||||
freq = validate_frequency(data.get('frequency', '433.920'))
|
||||
gain = validate_gain(data.get('gain', '0'))
|
||||
ppm = validate_ppm(data.get('ppm', '0'))
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
except ValueError as e:
|
||||
return api_error(str(e), 400)
|
||||
|
||||
try:
|
||||
encoding = _validate_encoding(data.get('encoding', 'pwm'))
|
||||
except ValueError as e:
|
||||
return api_error(str(e), 400)
|
||||
|
||||
# OOK flex decoder timing parameters (server-side range validation)
|
||||
try:
|
||||
short_pulse = validate_positive_int(data.get('short_pulse', 300), 'short_pulse', max_val=100000)
|
||||
long_pulse = validate_positive_int(data.get('long_pulse', 600), 'long_pulse', max_val=100000)
|
||||
reset_limit = validate_positive_int(data.get('reset_limit', 8000), 'reset_limit', max_val=1000000)
|
||||
gap_limit = validate_positive_int(data.get('gap_limit', 5000), 'gap_limit', max_val=1000000)
|
||||
tolerance = validate_positive_int(data.get('tolerance', 150), 'tolerance', max_val=50000)
|
||||
min_bits = validate_positive_int(data.get('min_bits', 8), 'min_bits', max_val=4096)
|
||||
except ValueError as e:
|
||||
return api_error(f'Invalid timing parameter: {e}', 400)
|
||||
if min_bits < 1:
|
||||
return api_error('min_bits must be >= 1', 400)
|
||||
if short_pulse < 1 or long_pulse < 1:
|
||||
return api_error('Pulse widths must be >= 1', 400)
|
||||
deduplicate = bool(data.get('deduplicate', False))
|
||||
|
||||
# Parse SDR type early — needed for device claim
|
||||
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 = 'rtlsdr'
|
||||
|
||||
rtl_tcp_host = data.get('rtl_tcp_host') or None
|
||||
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
||||
|
||||
if not rtl_tcp_host:
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'ook', sdr_type_str)
|
||||
if error:
|
||||
return api_error(error, 409, error_type='DEVICE_BUSY')
|
||||
ook_active_device = device_int
|
||||
ook_active_sdr_type = sdr_type_str
|
||||
|
||||
while not app_module.ook_queue.empty():
|
||||
try:
|
||||
app_module.ook_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
if rtl_tcp_host:
|
||||
try:
|
||||
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
|
||||
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
|
||||
except ValueError as e:
|
||||
return api_error(str(e), 400)
|
||||
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
|
||||
logger.info(f'Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}')
|
||||
else:
|
||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||
|
||||
builder = SDRFactory.get_builder(sdr_device.sdr_type)
|
||||
bias_t = data.get('bias_t', False)
|
||||
|
||||
# Build base ISM command then replace protocol flags with flex decoder
|
||||
cmd = builder.build_ism_command(
|
||||
device=sdr_device,
|
||||
frequency_mhz=freq,
|
||||
gain=float(gain) if gain and gain != 0 else None,
|
||||
ppm=int(ppm) if ppm and ppm != 0 else None,
|
||||
bias_t=bias_t,
|
||||
)
|
||||
|
||||
modulation = _MODULATION_MAP[encoding]
|
||||
flex_spec = (
|
||||
f'n=ook,m={modulation},'
|
||||
f's={short_pulse},l={long_pulse},'
|
||||
f'r={reset_limit},g={gap_limit},'
|
||||
f't={tolerance},bits>={min_bits}'
|
||||
)
|
||||
|
||||
# Strip any existing -R flags from the base command
|
||||
filtered_cmd: list[str] = []
|
||||
skip_next = False
|
||||
for arg in cmd:
|
||||
if skip_next:
|
||||
skip_next = False
|
||||
continue
|
||||
if arg == '-R':
|
||||
skip_next = True
|
||||
continue
|
||||
filtered_cmd.append(arg)
|
||||
|
||||
filtered_cmd.extend(['-M', 'level', '-R', '0', '-X', flex_spec])
|
||||
|
||||
full_cmd = ' '.join(filtered_cmd)
|
||||
logger.info(f'OOK decoder running: {full_cmd}')
|
||||
|
||||
try:
|
||||
rtl_process = subprocess.Popen(
|
||||
filtered_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True,
|
||||
)
|
||||
register_process(rtl_process)
|
||||
|
||||
_stderr_noise = ('bitbuffer_add_bit', 'row count limit')
|
||||
|
||||
def monitor_stderr() -> None:
|
||||
for line in rtl_process.stderr:
|
||||
err_text = line.decode('utf-8', errors='replace').strip()
|
||||
if err_text and not any(n in err_text for n in _stderr_noise):
|
||||
logger.debug(f'[rtl_433/ook] {err_text}')
|
||||
|
||||
stderr_thread = threading.Thread(target=monitor_stderr)
|
||||
stderr_thread.daemon = True
|
||||
stderr_thread.start()
|
||||
|
||||
stop_event = threading.Event()
|
||||
parser_thread = threading.Thread(
|
||||
target=ook_parser_thread,
|
||||
args=(
|
||||
rtl_process.stdout,
|
||||
app_module.ook_queue,
|
||||
stop_event,
|
||||
encoding,
|
||||
deduplicate,
|
||||
),
|
||||
)
|
||||
parser_thread.daemon = True
|
||||
parser_thread.start()
|
||||
|
||||
app_module.ook_process = rtl_process
|
||||
_ook_stop_event = stop_event
|
||||
_ook_parser_thread = parser_thread
|
||||
|
||||
try:
|
||||
app_module.ook_queue.put_nowait({'type': 'status', 'text': 'started'})
|
||||
except queue.Full:
|
||||
logger.warning("OOK 'started' status dropped — queue full")
|
||||
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'command': full_cmd,
|
||||
'encoding': encoding,
|
||||
'modulation': modulation,
|
||||
'flex_spec': flex_spec,
|
||||
'deduplicate': deduplicate,
|
||||
})
|
||||
|
||||
except FileNotFoundError as e:
|
||||
if ook_active_device is not None:
|
||||
app_module.release_sdr_device(ook_active_device, ook_active_sdr_type or 'rtlsdr')
|
||||
ook_active_device = None
|
||||
ook_active_sdr_type = None
|
||||
return api_error(f'Tool not found: {e.filename}', 400)
|
||||
|
||||
except Exception as e:
|
||||
try:
|
||||
rtl_process.terminate()
|
||||
rtl_process.wait(timeout=2)
|
||||
except Exception:
|
||||
with contextlib.suppress(Exception):
|
||||
rtl_process.kill()
|
||||
unregister_process(rtl_process)
|
||||
if ook_active_device is not None:
|
||||
app_module.release_sdr_device(ook_active_device, ook_active_sdr_type or 'rtlsdr')
|
||||
ook_active_device = None
|
||||
ook_active_sdr_type = None
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
def _close_pipe(pipe_obj) -> None:
|
||||
"""Close a subprocess pipe, suppressing errors."""
|
||||
if pipe_obj is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
pipe_obj.close()
|
||||
|
||||
|
||||
def cleanup_ook(*, emit_status: bool = True) -> None:
|
||||
"""Full OOK cleanup: stop parser, terminate process, release SDR device.
|
||||
|
||||
Safe to call from ``stop_ook()`` and ``kill_all()``. Caller must hold
|
||||
``app_module.ook_lock``.
|
||||
"""
|
||||
global ook_active_device, ook_active_sdr_type, _ook_stop_event, _ook_parser_thread
|
||||
|
||||
proc = app_module.ook_process
|
||||
if not proc:
|
||||
return
|
||||
|
||||
# Signal parser thread to stop
|
||||
if _ook_stop_event:
|
||||
_ook_stop_event.set()
|
||||
|
||||
# Close pipes so parser thread unblocks from readline()
|
||||
_close_pipe(getattr(proc, 'stdout', None))
|
||||
_close_pipe(getattr(proc, 'stderr', None))
|
||||
|
||||
# Kill the entire process group so child processes are cleaned up
|
||||
try:
|
||||
pgid = os.getpgid(proc.pid)
|
||||
os.killpg(pgid, signal.SIGTERM)
|
||||
try:
|
||||
proc.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(pgid, signal.SIGKILL)
|
||||
proc.wait(timeout=3)
|
||||
except (ProcessLookupError, OSError):
|
||||
# Process already dead — fall back to normal terminate
|
||||
safe_terminate(proc)
|
||||
unregister_process(proc)
|
||||
app_module.ook_process = None
|
||||
|
||||
# Join parser thread with timeout
|
||||
if _ook_parser_thread:
|
||||
_ook_parser_thread.join(timeout=0.5)
|
||||
|
||||
_ook_stop_event = None
|
||||
_ook_parser_thread = None
|
||||
|
||||
if ook_active_device is not None:
|
||||
app_module.release_sdr_device(ook_active_device, ook_active_sdr_type or 'rtlsdr')
|
||||
ook_active_device = None
|
||||
ook_active_sdr_type = None
|
||||
|
||||
if emit_status:
|
||||
try:
|
||||
app_module.ook_queue.put_nowait({'type': 'status', 'text': 'stopped'})
|
||||
except queue.Full:
|
||||
logger.warning("OOK 'stopped' status dropped — queue full")
|
||||
|
||||
|
||||
@ook_bp.route('/ook/stop', methods=['POST'])
|
||||
def stop_ook() -> Response:
|
||||
with app_module.ook_lock:
|
||||
if app_module.ook_process:
|
||||
cleanup_ook()
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
return jsonify({'status': 'not_running'})
|
||||
|
||||
|
||||
@ook_bp.route('/ook/status')
|
||||
def ook_status() -> Response:
|
||||
with app_module.ook_lock:
|
||||
running = (
|
||||
app_module.ook_process is not None
|
||||
and app_module.ook_process.poll() is None
|
||||
)
|
||||
return jsonify({'running': running})
|
||||
|
||||
|
||||
@ook_bp.route('/ook/stream')
|
||||
def ook_stream() -> Response:
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('ook', msg, msg.get('type'))
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.ook_queue,
|
||||
channel_key='ook',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
on_message=_on_msg,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
+134
-123
@@ -2,38 +2,45 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import math
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import pty
|
||||
import queue
|
||||
import re
|
||||
import select
|
||||
import struct
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
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
|
||||
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.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__)
|
||||
|
||||
# Track which device is being used
|
||||
pager_active_device: int | None = None
|
||||
pager_active_sdr_type: str | None = None
|
||||
|
||||
|
||||
def parse_multimon_output(line: str) -> dict[str, str] | None:
|
||||
@@ -54,6 +61,20 @@ def parse_multimon_output(line: str) -> dict[str, str] | None:
|
||||
'message': pocsag_match.group(5).strip() or '[No Message]'
|
||||
}
|
||||
|
||||
# POCSAG parsing - other content types (catch-all for non-Alpha/Numeric labels)
|
||||
pocsag_other_match = re.match(
|
||||
r'(POCSAG\d+):\s*Address:\s*(\d+)\s+Function:\s*(\d+)\s+(\w+):\s*(.*)',
|
||||
line
|
||||
)
|
||||
if pocsag_other_match:
|
||||
return {
|
||||
'protocol': pocsag_other_match.group(1),
|
||||
'address': pocsag_other_match.group(2),
|
||||
'function': pocsag_other_match.group(3),
|
||||
'msg_type': pocsag_other_match.group(4),
|
||||
'message': pocsag_other_match.group(5).strip() or '[No Message]'
|
||||
}
|
||||
|
||||
# POCSAG parsing - address only (no message content)
|
||||
pocsag_addr_match = re.match(
|
||||
r'(POCSAG\d+):\s*Address:\s*(\d+)\s+Function:\s*(\d+)\s*$',
|
||||
@@ -96,7 +117,7 @@ def parse_multimon_output(line: str) -> dict[str, str] | None:
|
||||
return None
|
||||
|
||||
|
||||
def log_message(msg: dict[str, Any]) -> None:
|
||||
def log_message(msg: dict[str, Any]) -> None:
|
||||
"""Log a message to file if logging is enabled."""
|
||||
if not app_module.logging_enabled:
|
||||
return
|
||||
@@ -104,39 +125,39 @@ def log_message(msg: dict[str, Any]) -> None:
|
||||
with open(app_module.log_file_path, 'a') as f:
|
||||
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
f.write(f"{timestamp} | {msg.get('protocol', 'UNKNOWN')} | {msg.get('address', '')} | {msg.get('message', '')}\n")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to log message: {e}")
|
||||
|
||||
|
||||
def _encode_scope_waveform(samples: tuple[int, ...], window_size: int = 256) -> list[int]:
|
||||
"""Compress recent PCM samples into a signed 8-bit waveform for SSE."""
|
||||
if not samples:
|
||||
return []
|
||||
|
||||
window = samples[-window_size:] if len(samples) > window_size else samples
|
||||
waveform: list[int] = []
|
||||
for sample in window:
|
||||
# Convert int16 PCM to int8 range for lightweight transport.
|
||||
packed = int(round(sample / 256))
|
||||
waveform.append(max(-127, min(127, packed)))
|
||||
return waveform
|
||||
|
||||
|
||||
def audio_relay_thread(
|
||||
rtl_stdout,
|
||||
multimon_stdin,
|
||||
output_queue: queue.Queue,
|
||||
stop_event: threading.Event,
|
||||
) -> None:
|
||||
"""Relay audio from rtl_fm to multimon-ng while computing signal levels.
|
||||
|
||||
Reads raw 16-bit LE PCM from *rtl_stdout*, writes every chunk straight
|
||||
through to *multimon_stdin*, and every ~100 ms pushes an RMS / peak scope
|
||||
event plus a compact waveform sample onto *output_queue*.
|
||||
"""
|
||||
CHUNK = 4096 # bytes – 2048 samples at 16-bit mono
|
||||
INTERVAL = 0.1 # seconds between scope updates
|
||||
last_scope = time.monotonic()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to log message: {e}")
|
||||
|
||||
|
||||
def _encode_scope_waveform(samples: tuple[int, ...], window_size: int = 256) -> list[int]:
|
||||
"""Compress recent PCM samples into a signed 8-bit waveform for SSE."""
|
||||
if not samples:
|
||||
return []
|
||||
|
||||
window = samples[-window_size:] if len(samples) > window_size else samples
|
||||
waveform: list[int] = []
|
||||
for sample in window:
|
||||
# Convert int16 PCM to int8 range for lightweight transport.
|
||||
packed = int(round(sample / 256))
|
||||
waveform.append(max(-127, min(127, packed)))
|
||||
return waveform
|
||||
|
||||
|
||||
def audio_relay_thread(
|
||||
rtl_stdout,
|
||||
multimon_stdin,
|
||||
output_queue: queue.Queue,
|
||||
stop_event: threading.Event,
|
||||
) -> None:
|
||||
"""Relay audio from rtl_fm to multimon-ng while computing signal levels.
|
||||
|
||||
Reads raw 16-bit LE PCM from *rtl_stdout*, writes every chunk straight
|
||||
through to *multimon_stdin*, and every ~100 ms pushes an RMS / peak scope
|
||||
event plus a compact waveform sample onto *output_queue*.
|
||||
"""
|
||||
CHUNK = 4096 # bytes – 2048 samples at 16-bit mono
|
||||
INTERVAL = 0.1 # seconds between scope updates
|
||||
last_scope = time.monotonic()
|
||||
|
||||
try:
|
||||
while not stop_event.is_set():
|
||||
@@ -160,23 +181,21 @@ def audio_relay_thread(
|
||||
if n_samples == 0:
|
||||
continue
|
||||
samples = struct.unpack(f'<{n_samples}h', data[:n_samples * 2])
|
||||
peak = max(abs(s) for s in samples)
|
||||
rms = int(math.sqrt(sum(s * s for s in samples) / n_samples))
|
||||
output_queue.put_nowait({
|
||||
'type': 'scope',
|
||||
'rms': rms,
|
||||
'peak': peak,
|
||||
'waveform': _encode_scope_waveform(samples),
|
||||
})
|
||||
except (struct.error, ValueError, queue.Full):
|
||||
pass
|
||||
peak = max(abs(s) for s in samples)
|
||||
rms = int(math.sqrt(sum(s * s for s in samples) / n_samples))
|
||||
output_queue.put_nowait({
|
||||
'type': 'scope',
|
||||
'rms': rms,
|
||||
'peak': peak,
|
||||
'waveform': _encode_scope_waveform(samples),
|
||||
})
|
||||
except (struct.error, ValueError, queue.Full):
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug(f"Audio relay error: {e}")
|
||||
finally:
|
||||
try:
|
||||
with contextlib.suppress(OSError):
|
||||
multimon_stdin.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
|
||||
@@ -220,11 +239,9 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
|
||||
except Exception as e:
|
||||
app_module.output_queue.put({'type': 'error', 'text': str(e)})
|
||||
finally:
|
||||
global pager_active_device
|
||||
try:
|
||||
global pager_active_device, pager_active_sdr_type
|
||||
with contextlib.suppress(OSError):
|
||||
os.close(master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
# Signal relay thread to stop
|
||||
with app_module.process_lock:
|
||||
stop_relay = getattr(app_module.current_process, '_stop_relay', None)
|
||||
@@ -239,27 +256,26 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
unregister_process(proc)
|
||||
app_module.output_queue.put({'type': 'status', 'text': 'stopped'})
|
||||
with app_module.process_lock:
|
||||
app_module.current_process = None
|
||||
# Release SDR device
|
||||
if pager_active_device is not None:
|
||||
app_module.release_sdr_device(pager_active_device)
|
||||
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
|
||||
pager_active_device = None
|
||||
pager_active_sdr_type = None
|
||||
|
||||
|
||||
@pager_bp.route('/start', methods=['POST'])
|
||||
def start_decoding() -> Response:
|
||||
global pager_active_device
|
||||
global pager_active_device, pager_active_sdr_type
|
||||
|
||||
with app_module.process_lock:
|
||||
if app_module.current_process:
|
||||
return jsonify({'status': 'error', 'message': 'Already running'}), 409
|
||||
return api_error('Already running', 409)
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
@@ -270,7 +286,7 @@ def start_decoding() -> Response:
|
||||
ppm = validate_ppm(data.get('ppm', '0'))
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
return api_error(str(e), 400)
|
||||
|
||||
squelch = data.get('squelch', '0')
|
||||
try:
|
||||
@@ -278,32 +294,33 @@ def start_decoding() -> Response:
|
||||
if not 0 <= squelch <= 1000:
|
||||
raise ValueError("Squelch must be between 0 and 1000")
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid squelch value'}), 400
|
||||
return api_error('Invalid squelch value', 400)
|
||||
|
||||
# Check for rtl_tcp (remote SDR) connection
|
||||
rtl_tcp_host = data.get('rtl_tcp_host')
|
||||
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
||||
|
||||
# Get SDR type early so we can pass it to claim/release
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
|
||||
# Claim local device if not using remote rtl_tcp
|
||||
if not rtl_tcp_host:
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'pager')
|
||||
error = app_module.claim_sdr_device(device_int, 'pager', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
return api_error(error, 409, error_type='DEVICE_BUSY')
|
||||
pager_active_device = device_int
|
||||
pager_active_sdr_type = sdr_type_str
|
||||
|
||||
# Validate protocols
|
||||
valid_protocols = ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX']
|
||||
protocols = data.get('protocols', valid_protocols)
|
||||
if not isinstance(protocols, list):
|
||||
if pager_active_device is not None:
|
||||
app_module.release_sdr_device(pager_active_device)
|
||||
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
|
||||
pager_active_device = None
|
||||
return jsonify({'status': 'error', 'message': 'Protocols must be a list'}), 400
|
||||
pager_active_sdr_type = None
|
||||
return api_error('Protocols must be a list', 400)
|
||||
protocols = [p for p in protocols if p in valid_protocols]
|
||||
if not protocols:
|
||||
protocols = valid_protocols
|
||||
@@ -327,8 +344,7 @@ def start_decoding() -> Response:
|
||||
elif proto == 'FLEX':
|
||||
decoders.extend(['-a', 'FLEX'])
|
||||
|
||||
# Get SDR type and build command via abstraction layer
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
# Build command via SDR abstraction layer
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
@@ -340,7 +356,7 @@ def start_decoding() -> Response:
|
||||
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
|
||||
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
return api_error(str(e), 400)
|
||||
|
||||
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
|
||||
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
|
||||
@@ -365,7 +381,7 @@ def start_decoding() -> Response:
|
||||
|
||||
multimon_path = get_tool_path('multimon-ng')
|
||||
if not multimon_path:
|
||||
return jsonify({'status': 'error', 'message': 'multimon-ng not found'}), 400
|
||||
return api_error('multimon-ng not found', 400)
|
||||
multimon_cmd = [multimon_path, '-t', 'raw'] + decoders + ['-f', 'alpha', '-']
|
||||
|
||||
full_cmd = ' '.join(rtl_cmd) + ' | ' + ' '.join(multimon_cmd)
|
||||
@@ -437,35 +453,33 @@ def start_decoding() -> Response:
|
||||
rtl_process.terminate()
|
||||
rtl_process.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
rtl_process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
# Release device on failure
|
||||
if pager_active_device is not None:
|
||||
app_module.release_sdr_device(pager_active_device)
|
||||
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
|
||||
pager_active_device = None
|
||||
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'})
|
||||
pager_active_sdr_type = None
|
||||
return api_error(f'Tool not found: {e.filename}')
|
||||
except Exception as e:
|
||||
# Kill orphaned rtl_fm process if it was started
|
||||
try:
|
||||
rtl_process.terminate()
|
||||
rtl_process.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
rtl_process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
# Release device on failure
|
||||
if pager_active_device is not None:
|
||||
app_module.release_sdr_device(pager_active_device)
|
||||
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
|
||||
pager_active_device = None
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
pager_active_sdr_type = None
|
||||
return api_error(str(e))
|
||||
|
||||
|
||||
@pager_bp.route('/stop', methods=['POST'])
|
||||
def stop_decoding() -> Response:
|
||||
global pager_active_device
|
||||
global pager_active_device, pager_active_sdr_type
|
||||
|
||||
with app_module.process_lock:
|
||||
if app_module.current_process:
|
||||
@@ -479,17 +493,13 @@ def stop_decoding() -> Response:
|
||||
app_module.current_process._rtl_process.terminate()
|
||||
app_module.current_process._rtl_process.wait(timeout=2)
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
try:
|
||||
with contextlib.suppress(OSError):
|
||||
app_module.current_process._rtl_process.kill()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Close PTY master fd
|
||||
if hasattr(app_module.current_process, '_master_fd'):
|
||||
try:
|
||||
with contextlib.suppress(OSError):
|
||||
os.close(app_module.current_process._master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Kill multimon-ng
|
||||
app_module.current_process.terminate()
|
||||
@@ -502,8 +512,9 @@ def stop_decoding() -> Response:
|
||||
|
||||
# Release device from registry
|
||||
if pager_active_device is not None:
|
||||
app_module.release_sdr_device(pager_active_device)
|
||||
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
|
||||
pager_active_device = None
|
||||
pager_active_sdr_type = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
@@ -539,36 +550,36 @@ def toggle_logging() -> Response:
|
||||
is_in_logs = str(requested_path).startswith(str(logs_dir))
|
||||
|
||||
if not (is_in_cwd or is_in_logs):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid log file path'}), 400
|
||||
return api_error('Invalid log file path', 400)
|
||||
|
||||
# Ensure it's not a directory
|
||||
if requested_path.is_dir():
|
||||
return jsonify({'status': 'error', 'message': 'Log file path must be a file, not a directory'}), 400
|
||||
return api_error('Log file path must be a file, not a directory', 400)
|
||||
|
||||
app_module.log_file_path = str(requested_path)
|
||||
except (ValueError, OSError) as e:
|
||||
logger.warning(f"Invalid log file path: {e}")
|
||||
return jsonify({'status': 'error', 'message': 'Invalid log file path'}), 400
|
||||
return api_error('Invalid log file path', 400)
|
||||
|
||||
return jsonify({'logging': app_module.logging_enabled, 'log_file': app_module.log_file_path})
|
||||
|
||||
|
||||
@pager_bp.route('/stream')
|
||||
def stream() -> Response:
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('pager', msg, msg.get('type'))
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.output_queue,
|
||||
channel_key='pager',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
on_message=_on_msg,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
@pager_bp.route('/stream')
|
||||
def stream() -> Response:
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('pager', msg, msg.get('type'))
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.output_queue,
|
||||
channel_key='pager',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
on_message=_on_msg,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
@@ -0,0 +1,709 @@
|
||||
"""Radiosonde weather balloon tracking routes.
|
||||
|
||||
Uses radiosonde_auto_rx to automatically scan for and decode radiosonde
|
||||
telemetry (position, altitude, temperature, humidity, pressure) on the
|
||||
400-406 MHz band. Telemetry arrives as JSON over UDP.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
import app as app_module
|
||||
from utils.constants import (
|
||||
MAX_RADIOSONDE_AGE_SECONDS,
|
||||
PROCESS_TERMINATE_TIMEOUT,
|
||||
RADIOSONDE_TERMINATE_TIMEOUT,
|
||||
RADIOSONDE_UDP_PORT,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
)
|
||||
from utils.gps import is_gpsd_running
|
||||
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,
|
||||
validate_latitude,
|
||||
validate_longitude,
|
||||
)
|
||||
|
||||
logger = get_logger('intercept.radiosonde')
|
||||
|
||||
radiosonde_bp = Blueprint('radiosonde', __name__, url_prefix='/radiosonde')
|
||||
|
||||
# Track radiosonde state
|
||||
radiosonde_running = False
|
||||
radiosonde_active_device: int | None = None
|
||||
radiosonde_active_sdr_type: str | None = None
|
||||
|
||||
# Active balloon data: serial -> telemetry dict
|
||||
radiosonde_balloons: dict[str, dict[str, Any]] = {}
|
||||
_balloons_lock = threading.Lock()
|
||||
|
||||
# UDP listener socket reference (so /stop can close it)
|
||||
_udp_socket: socket.socket | None = None
|
||||
|
||||
# Common installation paths for radiosonde_auto_rx
|
||||
AUTO_RX_PATHS = [
|
||||
'/opt/radiosonde_auto_rx/auto_rx/auto_rx.py',
|
||||
'/usr/local/bin/radiosonde_auto_rx',
|
||||
'/opt/auto_rx/auto_rx.py',
|
||||
]
|
||||
|
||||
|
||||
def find_auto_rx() -> str | None:
|
||||
"""Find radiosonde_auto_rx script/binary."""
|
||||
# Check PATH first
|
||||
path = shutil.which('radiosonde_auto_rx')
|
||||
if path:
|
||||
return path
|
||||
# Check common locations
|
||||
for p in AUTO_RX_PATHS:
|
||||
if os.path.isfile(p) and os.access(p, os.X_OK):
|
||||
return p
|
||||
# Check for Python script (not executable but runnable)
|
||||
for p in AUTO_RX_PATHS:
|
||||
if os.path.isfile(p):
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def generate_station_cfg(
|
||||
freq_min: float = 400.0,
|
||||
freq_max: float = 406.0,
|
||||
gain: float = 40.0,
|
||||
device_index: int = 0,
|
||||
ppm: int = 0,
|
||||
bias_t: bool = False,
|
||||
udp_port: int = RADIOSONDE_UDP_PORT,
|
||||
latitude: float = 0.0,
|
||||
longitude: float = 0.0,
|
||||
station_alt: float = 0.0,
|
||||
gpsd_enabled: bool = False,
|
||||
) -> str:
|
||||
"""Generate a station.cfg for radiosonde_auto_rx and return the file path."""
|
||||
cfg_dir = os.path.abspath(os.path.join('data', 'radiosonde'))
|
||||
log_dir = os.path.join(cfg_dir, 'logs')
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
cfg_path = os.path.join(cfg_dir, 'station.cfg')
|
||||
|
||||
# Full station.cfg based on radiosonde_auto_rx v1.8+ example config.
|
||||
# All sections and keys included to avoid missing-key crashes.
|
||||
cfg = f"""# Auto-generated by INTERCEPT for radiosonde_auto_rx
|
||||
|
||||
[sdr]
|
||||
sdr_type = RTLSDR
|
||||
sdr_quantity = 1
|
||||
sdr_hostname = localhost
|
||||
sdr_port = 5555
|
||||
|
||||
[sdr_1]
|
||||
device_idx = {device_index}
|
||||
ppm = {ppm}
|
||||
gain = {gain}
|
||||
bias = {str(bias_t)}
|
||||
|
||||
[search_params]
|
||||
min_freq = {freq_min}
|
||||
max_freq = {freq_max}
|
||||
rx_timeout = 180
|
||||
only_scan = []
|
||||
never_scan = []
|
||||
always_scan = []
|
||||
always_decode = []
|
||||
|
||||
[location]
|
||||
station_lat = {latitude}
|
||||
station_lon = {longitude}
|
||||
station_alt = {station_alt}
|
||||
gpsd_enabled = {str(gpsd_enabled)}
|
||||
gpsd_host = localhost
|
||||
gpsd_port = 2947
|
||||
|
||||
[habitat]
|
||||
uploader_callsign = INTERCEPT
|
||||
upload_listener_position = False
|
||||
uploader_antenna = unknown
|
||||
|
||||
[sondehub]
|
||||
sondehub_enabled = False
|
||||
sondehub_upload_rate = 15
|
||||
sondehub_contact_email = none@none.com
|
||||
|
||||
[aprs]
|
||||
aprs_enabled = False
|
||||
aprs_user = N0CALL
|
||||
aprs_pass = 00000
|
||||
upload_rate = 30
|
||||
aprs_server = radiosondy.info
|
||||
aprs_port = 14580
|
||||
station_beacon_enabled = False
|
||||
station_beacon_rate = 30
|
||||
station_beacon_comment = radiosonde_auto_rx
|
||||
station_beacon_icon = /`
|
||||
aprs_object_id = <id>
|
||||
aprs_use_custom_object_id = False
|
||||
aprs_custom_comment = <type> <freq>
|
||||
|
||||
[oziplotter]
|
||||
ozi_enabled = False
|
||||
ozi_update_rate = 5
|
||||
ozi_host = 127.0.0.1
|
||||
ozi_port = 8942
|
||||
payload_summary_enabled = True
|
||||
payload_summary_host = 127.0.0.1
|
||||
payload_summary_port = {udp_port}
|
||||
|
||||
[email]
|
||||
email_enabled = False
|
||||
launch_notifications = True
|
||||
landing_notifications = True
|
||||
encrypted_sonde_notifications = True
|
||||
landing_range_threshold = 30
|
||||
landing_altitude_threshold = 1000
|
||||
error_notifications = False
|
||||
smtp_server = localhost
|
||||
smtp_port = 25
|
||||
smtp_authentication = None
|
||||
smtp_login = None
|
||||
smtp_password = None
|
||||
from = sonde@localhost
|
||||
to = none@none.com
|
||||
subject = Sonde launch detected
|
||||
|
||||
[rotator]
|
||||
rotator_enabled = False
|
||||
update_rate = 30
|
||||
rotation_threshold = 5.0
|
||||
rotator_hostname = 127.0.0.1
|
||||
rotator_port = 4533
|
||||
rotator_homing_enabled = False
|
||||
rotator_homing_delay = 10
|
||||
rotator_home_azimuth = 0.0
|
||||
rotator_home_elevation = 0.0
|
||||
azimuth_only = False
|
||||
|
||||
[logging]
|
||||
per_sonde_log = True
|
||||
save_system_log = False
|
||||
enable_debug_logging = False
|
||||
save_cal_data = False
|
||||
|
||||
[web]
|
||||
web_host = 127.0.0.1
|
||||
web_port = 0
|
||||
archive_age = 120
|
||||
web_control = False
|
||||
web_password = none
|
||||
kml_refresh_rate = 10
|
||||
|
||||
[debugging]
|
||||
save_detection_audio = False
|
||||
save_decode_audio = False
|
||||
save_decode_iq = False
|
||||
save_raw_hex = False
|
||||
|
||||
[advanced]
|
||||
search_step = 800
|
||||
snr_threshold = 10
|
||||
max_peaks = 10
|
||||
min_distance = 1000
|
||||
scan_dwell_time = 20
|
||||
detect_dwell_time = 5
|
||||
scan_delay = 10
|
||||
quantization = 10000
|
||||
decoder_spacing_limit = 15000
|
||||
temporary_block_time = 120
|
||||
max_async_scan_workers = 4
|
||||
synchronous_upload = True
|
||||
payload_id_valid = 3
|
||||
sdr_fm_path = rtl_fm
|
||||
sdr_power_path = rtl_power
|
||||
ss_iq_path = ./ss_iq
|
||||
ss_power_path = ./ss_power
|
||||
|
||||
[filtering]
|
||||
max_altitude = 50000
|
||||
max_radius_km = 1000
|
||||
min_radius_km = 0
|
||||
radius_temporary_block = False
|
||||
sonde_time_threshold = 3
|
||||
"""
|
||||
|
||||
try:
|
||||
with open(cfg_path, 'w') as f:
|
||||
f.write(cfg)
|
||||
except OSError as e:
|
||||
logger.error(f"Cannot write station.cfg to {cfg_path}: {e}")
|
||||
raise RuntimeError(
|
||||
f"Cannot write radiosonde config to {cfg_path}: {e}. "
|
||||
f"Fix permissions with: sudo chown -R $(whoami) {cfg_dir}"
|
||||
) from e
|
||||
|
||||
# When running as root via sudo, fix ownership so next non-root run
|
||||
# can still read/write these files.
|
||||
_fix_data_ownership(cfg_dir)
|
||||
|
||||
logger.info(f"Generated station.cfg at {cfg_path}")
|
||||
return cfg_path
|
||||
|
||||
|
||||
def _fix_data_ownership(path: str) -> None:
|
||||
"""Recursively chown a path to the real (non-root) user when running via sudo."""
|
||||
uid = os.environ.get('INTERCEPT_SUDO_UID')
|
||||
gid = os.environ.get('INTERCEPT_SUDO_GID')
|
||||
if uid is None or gid is None:
|
||||
return
|
||||
try:
|
||||
uid_int, gid_int = int(uid), int(gid)
|
||||
for dirpath, _dirnames, filenames in os.walk(path):
|
||||
os.chown(dirpath, uid_int, gid_int)
|
||||
for fname in filenames:
|
||||
os.chown(os.path.join(dirpath, fname), uid_int, gid_int)
|
||||
except OSError as e:
|
||||
logger.warning(f"Could not fix ownership of {path}: {e}")
|
||||
|
||||
|
||||
def parse_radiosonde_udp(udp_port: int) -> None:
|
||||
"""Thread function: listen for radiosonde_auto_rx UDP JSON telemetry."""
|
||||
global radiosonde_running, _udp_socket
|
||||
|
||||
logger.info(f"Radiosonde UDP listener started on port {udp_port}")
|
||||
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.bind(('0.0.0.0', udp_port))
|
||||
sock.settimeout(2.0)
|
||||
_udp_socket = sock
|
||||
except OSError as e:
|
||||
logger.error(f"Failed to bind UDP port {udp_port}: {e}")
|
||||
return
|
||||
|
||||
while radiosonde_running:
|
||||
try:
|
||||
data, _addr = sock.recvfrom(4096)
|
||||
except socket.timeout:
|
||||
# Clean up stale balloons
|
||||
_cleanup_stale_balloons()
|
||||
continue
|
||||
except OSError:
|
||||
break
|
||||
|
||||
try:
|
||||
msg = json.loads(data.decode('utf-8', errors='ignore'))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
balloon = _process_telemetry(msg)
|
||||
if balloon:
|
||||
serial = balloon.get('id', '')
|
||||
if serial:
|
||||
with _balloons_lock:
|
||||
radiosonde_balloons[serial] = balloon
|
||||
with contextlib.suppress(queue.Full):
|
||||
app_module.radiosonde_queue.put_nowait({
|
||||
'type': 'balloon',
|
||||
**balloon,
|
||||
})
|
||||
|
||||
with contextlib.suppress(OSError):
|
||||
sock.close()
|
||||
_udp_socket = None
|
||||
logger.info("Radiosonde UDP listener stopped")
|
||||
|
||||
|
||||
def _process_telemetry(msg: dict) -> dict | None:
|
||||
"""Extract relevant fields from a radiosonde_auto_rx UDP telemetry packet."""
|
||||
# auto_rx broadcasts packets with a 'type' field
|
||||
# Telemetry packets have type 'payload_summary' or individual sonde data
|
||||
serial = msg.get('id') or msg.get('serial')
|
||||
if not serial:
|
||||
return None
|
||||
|
||||
balloon: dict[str, Any] = {'id': str(serial)}
|
||||
|
||||
# Sonde type (RS41, RS92, DFM, M10, etc.) — prefer subtype if available
|
||||
if 'subtype' in msg:
|
||||
balloon['sonde_type'] = msg['subtype']
|
||||
elif 'type' in msg:
|
||||
balloon['sonde_type'] = msg['type']
|
||||
|
||||
# Timestamp
|
||||
if 'datetime' in msg:
|
||||
balloon['datetime'] = msg['datetime']
|
||||
|
||||
# Position
|
||||
for key in ('lat', 'latitude'):
|
||||
if key in msg:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
balloon['lat'] = float(msg[key])
|
||||
break
|
||||
for key in ('lon', 'longitude'):
|
||||
if key in msg:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
balloon['lon'] = float(msg[key])
|
||||
break
|
||||
|
||||
# Altitude (metres)
|
||||
if 'alt' in msg:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
balloon['alt'] = float(msg['alt'])
|
||||
|
||||
# Meteorological data
|
||||
for field in ('temp', 'humidity', 'pressure'):
|
||||
if field in msg:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
balloon[field] = float(msg[field])
|
||||
|
||||
# Velocity
|
||||
if 'vel_h' in msg:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
balloon['vel_h'] = float(msg['vel_h'])
|
||||
if 'vel_v' in msg:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
balloon['vel_v'] = float(msg['vel_v'])
|
||||
if 'heading' in msg:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
balloon['heading'] = float(msg['heading'])
|
||||
|
||||
# GPS satellites
|
||||
if 'sats' in msg:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
balloon['sats'] = int(msg['sats'])
|
||||
|
||||
# Battery voltage
|
||||
if 'batt' in msg:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
balloon['batt'] = float(msg['batt'])
|
||||
|
||||
# Frequency
|
||||
if 'freq' in msg:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
balloon['freq'] = float(msg['freq'])
|
||||
|
||||
balloon['last_seen'] = time.time()
|
||||
return balloon
|
||||
|
||||
|
||||
def _cleanup_stale_balloons() -> None:
|
||||
"""Remove balloons not seen within the retention window."""
|
||||
now = time.time()
|
||||
with _balloons_lock:
|
||||
stale = [
|
||||
k for k, v in radiosonde_balloons.items()
|
||||
if now - v.get('last_seen', 0) > MAX_RADIOSONDE_AGE_SECONDS
|
||||
]
|
||||
for k in stale:
|
||||
del radiosonde_balloons[k]
|
||||
|
||||
|
||||
@radiosonde_bp.route('/tools')
|
||||
def check_tools():
|
||||
"""Check for radiosonde decoding tools and hardware."""
|
||||
auto_rx_path = find_auto_rx()
|
||||
devices = SDRFactory.detect_devices()
|
||||
has_rtlsdr = any(d.sdr_type == SDRType.RTL_SDR for d in devices)
|
||||
|
||||
return jsonify({
|
||||
'auto_rx': auto_rx_path is not None,
|
||||
'auto_rx_path': auto_rx_path,
|
||||
'has_rtlsdr': has_rtlsdr,
|
||||
'device_count': len(devices),
|
||||
})
|
||||
|
||||
|
||||
@radiosonde_bp.route('/status')
|
||||
def radiosonde_status():
|
||||
"""Get radiosonde tracking status."""
|
||||
process_running = False
|
||||
if app_module.radiosonde_process:
|
||||
process_running = app_module.radiosonde_process.poll() is None
|
||||
|
||||
with _balloons_lock:
|
||||
balloon_count = len(radiosonde_balloons)
|
||||
balloons_snapshot = dict(radiosonde_balloons)
|
||||
|
||||
return jsonify({
|
||||
'tracking_active': radiosonde_running,
|
||||
'active_device': radiosonde_active_device,
|
||||
'balloon_count': balloon_count,
|
||||
'balloons': balloons_snapshot,
|
||||
'queue_size': app_module.radiosonde_queue.qsize(),
|
||||
'auto_rx_path': find_auto_rx(),
|
||||
'process_running': process_running,
|
||||
})
|
||||
|
||||
|
||||
@radiosonde_bp.route('/start', methods=['POST'])
|
||||
def start_radiosonde():
|
||||
"""Start radiosonde tracking."""
|
||||
global radiosonde_running, radiosonde_active_device, radiosonde_active_sdr_type
|
||||
|
||||
with app_module.radiosonde_lock:
|
||||
if radiosonde_running:
|
||||
return api_error('Radiosonde tracking already active', 409)
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
# Validate inputs
|
||||
try:
|
||||
gain = float(validate_gain(data.get('gain', '40')))
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
except ValueError as e:
|
||||
return api_error(str(e), 400)
|
||||
|
||||
freq_min = data.get('freq_min', 400.0)
|
||||
freq_max = data.get('freq_max', 406.0)
|
||||
try:
|
||||
freq_min = float(freq_min)
|
||||
freq_max = float(freq_max)
|
||||
if not (380.0 <= freq_min <= 410.0) or not (380.0 <= freq_max <= 410.0):
|
||||
raise ValueError("Frequency out of range")
|
||||
if freq_min >= freq_max:
|
||||
raise ValueError("Min frequency must be less than max")
|
||||
except (ValueError, TypeError) as e:
|
||||
return api_error(f'Invalid frequency range: {e}', 400)
|
||||
|
||||
bias_t = data.get('bias_t', False)
|
||||
ppm = int(data.get('ppm', 0))
|
||||
|
||||
# Validate optional location
|
||||
latitude = 0.0
|
||||
longitude = 0.0
|
||||
if data.get('latitude') is not None and data.get('longitude') is not None:
|
||||
try:
|
||||
latitude = validate_latitude(data['latitude'])
|
||||
longitude = validate_longitude(data['longitude'])
|
||||
except ValueError:
|
||||
latitude = 0.0
|
||||
longitude = 0.0
|
||||
|
||||
# Check if gpsd is available for live position updates
|
||||
gpsd_enabled = is_gpsd_running()
|
||||
|
||||
# Find auto_rx
|
||||
auto_rx_path = find_auto_rx()
|
||||
if not auto_rx_path:
|
||||
return api_error('radiosonde_auto_rx not found. Install from https://github.com/projecthorus/radiosonde_auto_rx', 400)
|
||||
|
||||
# Get SDR type
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
|
||||
# Kill any existing process
|
||||
if app_module.radiosonde_process:
|
||||
try:
|
||||
pgid = os.getpgid(app_module.radiosonde_process.pid)
|
||||
os.killpg(pgid, 15)
|
||||
app_module.radiosonde_process.wait(timeout=PROCESS_TERMINATE_TIMEOUT)
|
||||
except (subprocess.TimeoutExpired, ProcessLookupError, OSError):
|
||||
try:
|
||||
pgid = os.getpgid(app_module.radiosonde_process.pid)
|
||||
os.killpg(pgid, 9)
|
||||
except (ProcessLookupError, OSError):
|
||||
pass
|
||||
app_module.radiosonde_process = None
|
||||
logger.info("Killed existing radiosonde process")
|
||||
|
||||
# Claim SDR device
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'radiosonde', sdr_type_str)
|
||||
if error:
|
||||
return api_error(error, 409, error_type='DEVICE_BUSY')
|
||||
|
||||
# Generate config
|
||||
try:
|
||||
cfg_path = generate_station_cfg(
|
||||
freq_min=freq_min,
|
||||
freq_max=freq_max,
|
||||
gain=gain,
|
||||
device_index=device_int,
|
||||
ppm=ppm,
|
||||
bias_t=bias_t,
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
gpsd_enabled=gpsd_enabled,
|
||||
)
|
||||
except (OSError, RuntimeError) as e:
|
||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||
logger.error(f"Failed to generate radiosonde config: {e}")
|
||||
return api_error(str(e), 500)
|
||||
|
||||
# Build command - auto_rx -c expects the path to station.cfg
|
||||
cfg_abs = os.path.abspath(cfg_path)
|
||||
if auto_rx_path.endswith('.py'):
|
||||
cmd = [sys.executable, auto_rx_path, '-c', cfg_abs]
|
||||
else:
|
||||
cmd = [auto_rx_path, '-c', cfg_abs]
|
||||
|
||||
# Set cwd to the auto_rx directory so 'from autorx.scan import ...' works
|
||||
auto_rx_dir = os.path.dirname(os.path.abspath(auto_rx_path))
|
||||
|
||||
# Quick dependency check before launching the full process
|
||||
if auto_rx_path.endswith('.py'):
|
||||
dep_check = subprocess.run(
|
||||
[sys.executable, '-c', 'import autorx.scan'],
|
||||
cwd=auto_rx_dir,
|
||||
capture_output=True,
|
||||
timeout=10,
|
||||
)
|
||||
if dep_check.returncode != 0:
|
||||
dep_error = dep_check.stderr.decode('utf-8', errors='ignore').strip()
|
||||
logger.error(f"radiosonde_auto_rx dependency check failed:\n{dep_error}")
|
||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||
return api_error(
|
||||
'radiosonde_auto_rx dependencies not satisfied. '
|
||||
f'Re-run setup.sh to install. Error: {dep_error[:500]}',
|
||||
500,
|
||||
)
|
||||
|
||||
try:
|
||||
logger.info(f"Starting radiosonde_auto_rx: {' '.join(cmd)}")
|
||||
app_module.radiosonde_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True,
|
||||
cwd=auto_rx_dir,
|
||||
)
|
||||
|
||||
# Wait briefly for process to start
|
||||
time.sleep(2.0)
|
||||
|
||||
if app_module.radiosonde_process.poll() is not None:
|
||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||
stderr_output = ''
|
||||
if app_module.radiosonde_process.stderr:
|
||||
with contextlib.suppress(Exception):
|
||||
stderr_output = app_module.radiosonde_process.stderr.read().decode(
|
||||
'utf-8', errors='ignore'
|
||||
).strip()
|
||||
if stderr_output:
|
||||
logger.error(f"radiosonde_auto_rx stderr:\n{stderr_output}")
|
||||
if stderr_output and (
|
||||
'ImportError' in stderr_output
|
||||
or 'ModuleNotFoundError' in stderr_output
|
||||
):
|
||||
error_msg = (
|
||||
'radiosonde_auto_rx failed to start due to missing Python '
|
||||
'dependencies. Re-run setup.sh or reinstall radiosonde_auto_rx.'
|
||||
)
|
||||
else:
|
||||
error_msg = (
|
||||
'radiosonde_auto_rx failed to start. '
|
||||
'Check SDR device connection.'
|
||||
)
|
||||
if stderr_output:
|
||||
error_msg += f' Error: {stderr_output[:500]}'
|
||||
return api_error(error_msg, 500)
|
||||
|
||||
radiosonde_running = True
|
||||
radiosonde_active_device = device_int
|
||||
radiosonde_active_sdr_type = sdr_type_str
|
||||
|
||||
# Clear stale data
|
||||
with _balloons_lock:
|
||||
radiosonde_balloons.clear()
|
||||
|
||||
# Start UDP listener thread
|
||||
udp_thread = threading.Thread(
|
||||
target=parse_radiosonde_udp,
|
||||
args=(RADIOSONDE_UDP_PORT,),
|
||||
daemon=True,
|
||||
)
|
||||
udp_thread.start()
|
||||
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'message': 'Radiosonde tracking started',
|
||||
'device': device,
|
||||
})
|
||||
except Exception as e:
|
||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||
logger.error(f"Failed to start radiosonde_auto_rx: {e}")
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@radiosonde_bp.route('/stop', methods=['POST'])
|
||||
def stop_radiosonde():
|
||||
"""Stop radiosonde tracking."""
|
||||
global radiosonde_running, radiosonde_active_device, radiosonde_active_sdr_type, _udp_socket
|
||||
|
||||
with app_module.radiosonde_lock:
|
||||
if app_module.radiosonde_process:
|
||||
try:
|
||||
pgid = os.getpgid(app_module.radiosonde_process.pid)
|
||||
os.killpg(pgid, 15)
|
||||
app_module.radiosonde_process.wait(timeout=RADIOSONDE_TERMINATE_TIMEOUT)
|
||||
except (subprocess.TimeoutExpired, ProcessLookupError, OSError):
|
||||
try:
|
||||
pgid = os.getpgid(app_module.radiosonde_process.pid)
|
||||
os.killpg(pgid, 9)
|
||||
except (ProcessLookupError, OSError):
|
||||
pass
|
||||
app_module.radiosonde_process = None
|
||||
logger.info("Radiosonde process stopped")
|
||||
|
||||
# Close UDP socket to unblock listener thread
|
||||
if _udp_socket:
|
||||
with contextlib.suppress(OSError):
|
||||
_udp_socket.close()
|
||||
_udp_socket = None
|
||||
|
||||
# Release SDR device
|
||||
if radiosonde_active_device is not None:
|
||||
app_module.release_sdr_device(
|
||||
radiosonde_active_device,
|
||||
radiosonde_active_sdr_type or 'rtlsdr',
|
||||
)
|
||||
|
||||
radiosonde_running = False
|
||||
radiosonde_active_device = None
|
||||
radiosonde_active_sdr_type = None
|
||||
|
||||
with _balloons_lock:
|
||||
radiosonde_balloons.clear()
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@radiosonde_bp.route('/stream')
|
||||
def stream_radiosonde():
|
||||
"""SSE stream for radiosonde telemetry."""
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.radiosonde_queue,
|
||||
channel_key='radiosonde',
|
||||
timeout=SSE_QUEUE_TIMEOUT,
|
||||
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
return response
|
||||
|
||||
|
||||
@radiosonde_bp.route('/balloons')
|
||||
def get_balloons():
|
||||
"""Get current balloon data."""
|
||||
with _balloons_lock:
|
||||
return api_success(data={
|
||||
'count': len(radiosonde_balloons),
|
||||
'balloons': dict(radiosonde_balloons),
|
||||
})
|
||||
+34
-41
@@ -5,9 +5,10 @@ from __future__ import annotations
|
||||
import json
|
||||
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')
|
||||
|
||||
@@ -17,7 +18,7 @@ def start_recording():
|
||||
data = request.get_json() or {}
|
||||
mode = (data.get('mode') or '').strip()
|
||||
if not mode:
|
||||
return jsonify({'status': 'error', 'message': 'mode is required'}), 400
|
||||
return api_error('mode is required', 400)
|
||||
|
||||
label = data.get('label')
|
||||
metadata = data.get('metadata') if isinstance(data.get('metadata'), dict) else {}
|
||||
@@ -25,16 +26,13 @@ def start_recording():
|
||||
manager = get_recording_manager()
|
||||
session = manager.start_recording(mode=mode, label=label, metadata=metadata)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'session': {
|
||||
'id': session.id,
|
||||
'mode': session.mode,
|
||||
'label': session.label,
|
||||
'started_at': session.started_at.isoformat(),
|
||||
'file_path': str(session.file_path),
|
||||
}
|
||||
})
|
||||
return api_success(data={'session': {
|
||||
'id': session.id,
|
||||
'mode': session.mode,
|
||||
'label': session.label,
|
||||
'started_at': session.started_at.isoformat(),
|
||||
'file_path': str(session.file_path),
|
||||
}})
|
||||
|
||||
|
||||
@recordings_bp.route('/stop', methods=['POST'])
|
||||
@@ -46,29 +44,25 @@ def stop_recording():
|
||||
manager = get_recording_manager()
|
||||
session = manager.stop_recording(mode=mode, session_id=session_id)
|
||||
if not session:
|
||||
return jsonify({'status': 'error', 'message': 'No active recording found'}), 404
|
||||
return api_error('No active recording found', 404)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'session': {
|
||||
'id': session.id,
|
||||
'mode': session.mode,
|
||||
'label': session.label,
|
||||
'started_at': session.started_at.isoformat(),
|
||||
'stopped_at': session.stopped_at.isoformat() if session.stopped_at else None,
|
||||
'event_count': session.event_count,
|
||||
'size_bytes': session.size_bytes,
|
||||
'file_path': str(session.file_path),
|
||||
}
|
||||
})
|
||||
return api_success(data={'session': {
|
||||
'id': session.id,
|
||||
'mode': session.mode,
|
||||
'label': session.label,
|
||||
'started_at': session.started_at.isoformat(),
|
||||
'stopped_at': session.stopped_at.isoformat() if session.stopped_at else None,
|
||||
'event_count': session.event_count,
|
||||
'size_bytes': session.size_bytes,
|
||||
'file_path': str(session.file_path),
|
||||
}})
|
||||
|
||||
|
||||
@recordings_bp.route('', methods=['GET'])
|
||||
def list_recordings():
|
||||
manager = get_recording_manager()
|
||||
limit = request.args.get('limit', default=50, type=int)
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
return api_success(data={
|
||||
'recordings': manager.list_recordings(limit=limit),
|
||||
'active': manager.get_active(),
|
||||
})
|
||||
@@ -79,8 +73,8 @@ def get_recording(session_id: str):
|
||||
manager = get_recording_manager()
|
||||
rec = manager.get_recording(session_id)
|
||||
if not rec:
|
||||
return jsonify({'status': 'error', 'message': 'Recording not found'}), 404
|
||||
return jsonify({'status': 'success', 'recording': rec})
|
||||
return api_error('Recording not found', 404)
|
||||
return api_success(data={'recording': rec})
|
||||
|
||||
|
||||
@recordings_bp.route('/<session_id>/download', methods=['GET'])
|
||||
@@ -88,19 +82,19 @@ def download_recording(session_id: str):
|
||||
manager = get_recording_manager()
|
||||
rec = manager.get_recording(session_id)
|
||||
if not rec:
|
||||
return jsonify({'status': 'error', 'message': 'Recording not found'}), 404
|
||||
return api_error('Recording not found', 404)
|
||||
|
||||
file_path = Path(rec['file_path'])
|
||||
try:
|
||||
resolved_root = RECORDING_ROOT.resolve()
|
||||
resolved_file = file_path.resolve()
|
||||
if resolved_root not in resolved_file.parents:
|
||||
return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400
|
||||
return api_error('Invalid recording path', 400)
|
||||
except Exception:
|
||||
return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400
|
||||
return api_error('Invalid recording path', 400)
|
||||
|
||||
if not file_path.exists():
|
||||
return jsonify({'status': 'error', 'message': 'Recording file missing'}), 404
|
||||
return api_error('Recording file missing', 404)
|
||||
|
||||
return send_file(
|
||||
file_path,
|
||||
@@ -116,19 +110,19 @@ def get_recording_events(session_id: str):
|
||||
manager = get_recording_manager()
|
||||
rec = manager.get_recording(session_id)
|
||||
if not rec:
|
||||
return jsonify({'status': 'error', 'message': 'Recording not found'}), 404
|
||||
return api_error('Recording not found', 404)
|
||||
|
||||
file_path = Path(rec['file_path'])
|
||||
try:
|
||||
resolved_root = RECORDING_ROOT.resolve()
|
||||
resolved_file = file_path.resolve()
|
||||
if resolved_root not in resolved_file.parents:
|
||||
return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400
|
||||
return api_error('Invalid recording path', 400)
|
||||
except Exception:
|
||||
return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400
|
||||
return api_error('Invalid recording path', 400)
|
||||
|
||||
if not file_path.exists():
|
||||
return jsonify({'status': 'error', 'message': 'Recording file missing'}), 404
|
||||
return api_error('Recording file missing', 404)
|
||||
|
||||
limit = max(1, min(5000, request.args.get('limit', default=500, type=int)))
|
||||
offset = max(0, request.args.get('offset', default=0, type=int))
|
||||
@@ -150,8 +144,7 @@ def get_recording_events(session_id: str):
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
return api_success(data={
|
||||
'recording': {
|
||||
'id': rec['id'],
|
||||
'mode': rec['mode'],
|
||||
|
||||
+68
-58
@@ -2,24 +2,23 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import queue
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
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
|
||||
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.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__)
|
||||
|
||||
@@ -29,6 +28,7 @@ rtl_tcp_lock = threading.Lock()
|
||||
|
||||
# Track which device is being used
|
||||
rtlamr_active_device: int | None = None
|
||||
rtlamr_active_sdr_type: str = 'rtlsdr'
|
||||
|
||||
|
||||
def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
|
||||
@@ -62,16 +62,14 @@ def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
|
||||
except Exception as e:
|
||||
app_module.rtlamr_queue.put({'type': 'error', 'text': str(e)})
|
||||
finally:
|
||||
global rtl_tcp_process, rtlamr_active_device
|
||||
global rtl_tcp_process, rtlamr_active_device, rtlamr_active_sdr_type
|
||||
# Ensure rtlamr process is terminated
|
||||
try:
|
||||
process.terminate()
|
||||
process.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
unregister_process(process)
|
||||
# Kill companion rtl_tcp process
|
||||
with rtl_tcp_lock:
|
||||
@@ -80,10 +78,8 @@ def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
|
||||
rtl_tcp_process.terminate()
|
||||
rtl_tcp_process.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
rtl_tcp_process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
unregister_process(rtl_tcp_process)
|
||||
rtl_tcp_process = None
|
||||
app_module.rtlamr_queue.put({'type': 'status', 'text': 'stopped'})
|
||||
@@ -91,19 +87,23 @@ def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
|
||||
app_module.rtlamr_process = None
|
||||
# Release SDR device
|
||||
if rtlamr_active_device is not None:
|
||||
app_module.release_sdr_device(rtlamr_active_device)
|
||||
app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
|
||||
rtlamr_active_device = None
|
||||
|
||||
|
||||
@rtlamr_bp.route('/start_rtlamr', methods=['POST'])
|
||||
def start_rtlamr() -> Response:
|
||||
global rtl_tcp_process, rtlamr_active_device
|
||||
global rtl_tcp_process, rtlamr_active_device, rtlamr_active_sdr_type
|
||||
|
||||
with app_module.rtlamr_lock:
|
||||
if app_module.rtlamr_process:
|
||||
return jsonify({'status': 'error', 'message': 'RTLAMR already running'}), 409
|
||||
return api_error('RTLAMR already running', 409)
|
||||
|
||||
data = request.json or {}
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
|
||||
if sdr_type_str != 'rtlsdr':
|
||||
return api_error(f'{sdr_type_str.replace("_", " ").title()} is not yet supported for this mode. Please use an RTL-SDR device.', 400)
|
||||
|
||||
# Validate inputs
|
||||
try:
|
||||
@@ -112,19 +112,16 @@ def start_rtlamr() -> Response:
|
||||
ppm = validate_ppm(data.get('ppm', '0'))
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
return api_error(str(e), 400)
|
||||
|
||||
# Check if device is available
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'rtlamr')
|
||||
error = app_module.claim_sdr_device(device_int, 'rtlamr', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
return api_error(error, 409, error_type='DEVICE_BUSY')
|
||||
|
||||
rtlamr_active_device = device_int
|
||||
rtlamr_active_sdr_type = sdr_type_str
|
||||
|
||||
# Clear queue
|
||||
while not app_module.rtlamr_queue.empty():
|
||||
@@ -136,45 +133,49 @@ def start_rtlamr() -> Response:
|
||||
# Get message type (default to scm)
|
||||
msgtype = data.get('msgtype', 'scm')
|
||||
output_format = data.get('format', 'json')
|
||||
|
||||
|
||||
# Start rtl_tcp first
|
||||
rtl_tcp_just_started = False
|
||||
rtl_tcp_cmd_str = ''
|
||||
with rtl_tcp_lock:
|
||||
if not rtl_tcp_process:
|
||||
logger.info("Starting rtl_tcp server...")
|
||||
try:
|
||||
rtl_tcp_cmd = ['rtl_tcp', '-a', '0.0.0.0']
|
||||
|
||||
|
||||
# Add device index if not 0
|
||||
if device and device != '0':
|
||||
rtl_tcp_cmd.extend(['-d', str(device)])
|
||||
|
||||
|
||||
# Add gain if not auto
|
||||
if gain and gain != '0':
|
||||
rtl_tcp_cmd.extend(['-g', str(gain)])
|
||||
|
||||
|
||||
# Add PPM correction if not 0
|
||||
if ppm and ppm != '0':
|
||||
rtl_tcp_cmd.extend(['-p', str(ppm)])
|
||||
|
||||
|
||||
rtl_tcp_process = subprocess.Popen(
|
||||
rtl_tcp_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
register_process(rtl_tcp_process)
|
||||
|
||||
# Wait a moment for rtl_tcp to start
|
||||
time.sleep(3)
|
||||
|
||||
logger.info(f"rtl_tcp started: {' '.join(rtl_tcp_cmd)}")
|
||||
app_module.rtlamr_queue.put({'type': 'info', 'text': f'rtl_tcp: {" ".join(rtl_tcp_cmd)}'})
|
||||
rtl_tcp_just_started = True
|
||||
rtl_tcp_cmd_str = ' '.join(rtl_tcp_cmd)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start rtl_tcp: {e}")
|
||||
# Release SDR device on rtl_tcp failure
|
||||
if rtlamr_active_device is not None:
|
||||
app_module.release_sdr_device(rtlamr_active_device)
|
||||
app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
|
||||
rtlamr_active_device = None
|
||||
return jsonify({'status': 'error', 'message': f'Failed to start rtl_tcp: {e}'}), 500
|
||||
return api_error(f'Failed to start rtl_tcp: {e}', 500)
|
||||
|
||||
# Wait for rtl_tcp to start outside lock
|
||||
if rtl_tcp_just_started:
|
||||
time.sleep(3)
|
||||
logger.info(f"rtl_tcp started: {rtl_tcp_cmd_str}")
|
||||
app_module.rtlamr_queue.put({'type': 'info', 'text': f'rtl_tcp: {rtl_tcp_cmd_str}'})
|
||||
|
||||
# Build rtlamr command
|
||||
cmd = [
|
||||
@@ -184,16 +185,16 @@ def start_rtlamr() -> Response:
|
||||
f'-format={output_format}',
|
||||
f'-centerfreq={int(float(freq) * 1e6)}'
|
||||
]
|
||||
|
||||
|
||||
# Add filter options if provided
|
||||
filterid = data.get('filterid')
|
||||
if filterid:
|
||||
cmd.append(f'-filterid={filterid}')
|
||||
|
||||
|
||||
filtertype = data.get('filtertype')
|
||||
if filtertype:
|
||||
cmd.append(f'-filtertype={filtertype}')
|
||||
|
||||
|
||||
# Unique messages only
|
||||
if data.get('unique', True):
|
||||
cmd.append('-unique=true')
|
||||
@@ -238,9 +239,9 @@ def start_rtlamr() -> Response:
|
||||
rtl_tcp_process.wait(timeout=2)
|
||||
rtl_tcp_process = None
|
||||
if rtlamr_active_device is not None:
|
||||
app_module.release_sdr_device(rtlamr_active_device)
|
||||
app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
|
||||
rtlamr_active_device = None
|
||||
return jsonify({'status': 'error', 'message': 'rtlamr not found. Install from https://github.com/bemasher/rtlamr'})
|
||||
return api_error('rtlamr not found. Install from https://github.com/bemasher/rtlamr')
|
||||
except Exception as e:
|
||||
# If rtlamr fails, clean up rtl_tcp and release device
|
||||
with rtl_tcp_lock:
|
||||
@@ -249,38 +250,47 @@ def start_rtlamr() -> Response:
|
||||
rtl_tcp_process.wait(timeout=2)
|
||||
rtl_tcp_process = None
|
||||
if rtlamr_active_device is not None:
|
||||
app_module.release_sdr_device(rtlamr_active_device)
|
||||
app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
|
||||
rtlamr_active_device = None
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
return api_error(str(e))
|
||||
|
||||
|
||||
@rtlamr_bp.route('/stop_rtlamr', methods=['POST'])
|
||||
def stop_rtlamr() -> Response:
|
||||
global rtl_tcp_process, rtlamr_active_device
|
||||
global rtl_tcp_process, rtlamr_active_device, rtlamr_active_sdr_type
|
||||
|
||||
# Grab process refs inside locks, clear state, then terminate outside
|
||||
rtlamr_proc = None
|
||||
with app_module.rtlamr_lock:
|
||||
if app_module.rtlamr_process:
|
||||
app_module.rtlamr_process.terminate()
|
||||
try:
|
||||
app_module.rtlamr_process.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
app_module.rtlamr_process.kill()
|
||||
rtlamr_proc = app_module.rtlamr_process
|
||||
app_module.rtlamr_process = None
|
||||
|
||||
if rtlamr_proc:
|
||||
rtlamr_proc.terminate()
|
||||
try:
|
||||
rtlamr_proc.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
rtlamr_proc.kill()
|
||||
|
||||
# Also stop rtl_tcp
|
||||
tcp_proc = None
|
||||
with rtl_tcp_lock:
|
||||
if rtl_tcp_process:
|
||||
rtl_tcp_process.terminate()
|
||||
try:
|
||||
rtl_tcp_process.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
rtl_tcp_process.kill()
|
||||
tcp_proc = rtl_tcp_process
|
||||
rtl_tcp_process = None
|
||||
logger.info("rtl_tcp stopped")
|
||||
|
||||
if tcp_proc:
|
||||
tcp_proc.terminate()
|
||||
try:
|
||||
tcp_proc.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
tcp_proc.kill()
|
||||
logger.info("rtl_tcp stopped")
|
||||
|
||||
# Release device from registry
|
||||
if rtlamr_active_device is not None:
|
||||
app_module.release_sdr_device(rtlamr_active_device)
|
||||
app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
|
||||
rtlamr_active_device = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
+33
-25
@@ -2,32 +2,39 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
|
||||
from flask import Blueprint, jsonify, request, render_template, Response
|
||||
from flask import Blueprint, jsonify, render_template, request
|
||||
|
||||
from config import SHARED_OBSERVER_LOCATION_ENABLED
|
||||
|
||||
from data.satellites import TLE_SATELLITES
|
||||
from utils.database import (
|
||||
get_tracked_satellites,
|
||||
add_tracked_satellite,
|
||||
bulk_add_tracked_satellites,
|
||||
update_tracked_satellite,
|
||||
get_tracked_satellites,
|
||||
remove_tracked_satellite,
|
||||
update_tracked_satellite,
|
||||
)
|
||||
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')
|
||||
|
||||
# Cache skyfield timescale to avoid re-downloading/re-parsing per request
|
||||
_cached_timescale = None
|
||||
|
||||
|
||||
def _get_timescale():
|
||||
global _cached_timescale
|
||||
if _cached_timescale is None:
|
||||
from skyfield.api import load
|
||||
_cached_timescale = load.timescale()
|
||||
return _cached_timescale
|
||||
|
||||
# Maximum response size for external requests (1MB)
|
||||
MAX_RESPONSE_SIZE = 1024 * 1024
|
||||
|
||||
@@ -75,7 +82,7 @@ def init_tle_auto_refresh():
|
||||
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.
|
||||
|
||||
@@ -178,8 +185,8 @@ def satellite_dashboard():
|
||||
def predict_passes():
|
||||
"""Calculate satellite passes using skyfield."""
|
||||
try:
|
||||
from skyfield.api import load, wgs84, EarthSatellite
|
||||
from skyfield.almanac import find_discrete
|
||||
from skyfield.api import EarthSatellite, wgs84
|
||||
except ImportError:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
@@ -195,7 +202,7 @@ def predict_passes():
|
||||
hours = validate_hours(data.get('hours', 24))
|
||||
min_el = validate_elevation(data.get('minEl', 10))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
return api_error(str(e), 400)
|
||||
|
||||
norad_to_name = {
|
||||
25544: 'ISS',
|
||||
@@ -219,7 +226,7 @@ def predict_passes():
|
||||
}
|
||||
name_to_norad = {v: k for k, v in norad_to_name.items()}
|
||||
|
||||
ts = load.timescale()
|
||||
ts = _get_timescale()
|
||||
observer = wgs84.latlon(lat, lon)
|
||||
|
||||
t0 = ts.now()
|
||||
@@ -332,9 +339,9 @@ def predict_passes():
|
||||
def get_satellite_position():
|
||||
"""Get real-time positions of satellites."""
|
||||
try:
|
||||
from skyfield.api import load, wgs84, EarthSatellite
|
||||
from skyfield.api import EarthSatellite, wgs84
|
||||
except ImportError:
|
||||
return jsonify({'status': 'error', 'message': 'skyfield not installed'}), 503
|
||||
return api_error('skyfield not installed', 503)
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
@@ -343,7 +350,7 @@ def get_satellite_position():
|
||||
lat = validate_latitude(data.get('latitude', data.get('lat', 51.5074)))
|
||||
lon = validate_longitude(data.get('longitude', data.get('lon', -0.1278)))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
return api_error(str(e), 400)
|
||||
|
||||
sat_input = data.get('satellites', [])
|
||||
include_track = bool(data.get('includeTrack', True))
|
||||
@@ -361,7 +368,7 @@ def get_satellite_position():
|
||||
else:
|
||||
satellites.append(sat)
|
||||
|
||||
ts = load.timescale()
|
||||
ts = _get_timescale()
|
||||
observer = wgs84.latlon(lat, lon)
|
||||
now = ts.now()
|
||||
now_dt = now.utc_datetime()
|
||||
@@ -468,7 +475,8 @@ def refresh_tle_data() -> list:
|
||||
'NOAA 20 (JPSS-1)': 'NOAA-20',
|
||||
'NOAA 21 (JPSS-2)': 'NOAA-21',
|
||||
'METEOR-M 2': 'METEOR-M2',
|
||||
'METEOR-M2 3': 'METEOR-M2-3'
|
||||
'METEOR-M2 3': 'METEOR-M2-3',
|
||||
'METEOR-M2 4': 'METEOR-M2-4'
|
||||
}
|
||||
|
||||
updated = []
|
||||
@@ -516,7 +524,7 @@ def update_tle():
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating TLE data: {e}")
|
||||
return jsonify({'status': 'error', 'message': 'TLE update failed'})
|
||||
return api_error('TLE update failed')
|
||||
|
||||
|
||||
@satellite_bp.route('/celestrak/<category>')
|
||||
@@ -530,7 +538,7 @@ def fetch_celestrak(category):
|
||||
]
|
||||
|
||||
if category not in valid_categories:
|
||||
return jsonify({'status': 'error', 'message': f'Invalid category. Valid: {valid_categories}'})
|
||||
return api_error(f'Invalid category. Valid: {valid_categories}')
|
||||
|
||||
try:
|
||||
url = f'https://celestrak.org/NORAD/elements/gp.php?GROUP={category}&FORMAT=tle'
|
||||
@@ -571,7 +579,7 @@ def fetch_celestrak(category):
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching CelesTrak data: {e}")
|
||||
return jsonify({'status': 'error', 'message': 'Failed to fetch satellite data'})
|
||||
return api_error('Failed to fetch satellite data')
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -592,7 +600,7 @@ def add_tracked_satellites_endpoint():
|
||||
global _tle_cache
|
||||
data = request.get_json(silent=True)
|
||||
if not data:
|
||||
return jsonify({'status': 'error', 'message': 'No data provided'}), 400
|
||||
return api_error('No data provided', 400)
|
||||
|
||||
# Accept a single satellite dict or a list
|
||||
sat_list = data if isinstance(data, list) else [data]
|
||||
@@ -655,12 +663,12 @@ def update_tracked_satellite_endpoint(norad_id):
|
||||
data = request.json or {}
|
||||
enabled = data.get('enabled')
|
||||
if enabled is None:
|
||||
return jsonify({'status': 'error', 'message': 'Missing enabled field'}), 400
|
||||
return api_error('Missing enabled field', 400)
|
||||
|
||||
ok = update_tracked_satellite(str(norad_id), bool(enabled))
|
||||
if ok:
|
||||
return jsonify({'status': 'success'})
|
||||
return jsonify({'status': 'error', 'message': 'Satellite not found'}), 404
|
||||
return api_error('Satellite not found', 404)
|
||||
|
||||
|
||||
@satellite_bp.route('/tracked/<norad_id>', methods=['DELETE'])
|
||||
@@ -670,4 +678,4 @@ def delete_tracked_satellite_endpoint(norad_id):
|
||||
if ok:
|
||||
return jsonify({'status': 'success', 'message': msg})
|
||||
status_code = 403 if 'builtin' in msg.lower() else 404
|
||||
return jsonify({'status': 'error', 'message': msg}), status_code
|
||||
return api_error(msg, status_code)
|
||||
|
||||
+266
-258
@@ -1,7 +1,8 @@
|
||||
"""RTL_433 sensor monitoring routes."""
|
||||
|
||||
"""RTL_433 sensor monitoring routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import math
|
||||
import queue
|
||||
@@ -9,26 +10,32 @@ import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
import app as app_module
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.validation import (
|
||||
validate_frequency, validate_device_index, validate_gain, validate_ppm,
|
||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||
)
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
import app as app_module
|
||||
from utils.event_pipeline import process_event
|
||||
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.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
|
||||
|
||||
sensor_bp = Blueprint('sensor', __name__)
|
||||
|
||||
# Track which device is being used
|
||||
sensor_active_device: int | None = None
|
||||
|
||||
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__)
|
||||
|
||||
# Track which device is being used
|
||||
sensor_active_device: int | None = None
|
||||
sensor_active_sdr_type: str | None = None
|
||||
|
||||
# RSSI history per device (model_id -> list of (timestamp, rssi))
|
||||
sensor_rssi_history: dict[str, list[tuple[float, float]]] = {}
|
||||
_MAX_RSSI_HISTORY = 60
|
||||
@@ -65,36 +72,36 @@ def _build_scope_waveform(rssi: float, snr: float, noise: float, points: int = 2
|
||||
|
||||
|
||||
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
||||
"""Stream rtl_433 JSON output to queue."""
|
||||
try:
|
||||
app_module.sensor_queue.put({'type': 'status', 'text': 'started'})
|
||||
|
||||
for line in iter(process.stdout.readline, b''):
|
||||
line = line.decode('utf-8', errors='replace').strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
# rtl_433 outputs JSON objects, one per line
|
||||
data = json.loads(line)
|
||||
data['type'] = 'sensor'
|
||||
app_module.sensor_queue.put(data)
|
||||
|
||||
# Track RSSI history per device
|
||||
_model = data.get('model', '')
|
||||
_dev_id = data.get('id', '')
|
||||
_rssi_val = data.get('rssi')
|
||||
if _rssi_val is not None and _model:
|
||||
_hist_key = f"{_model}_{_dev_id}"
|
||||
hist = sensor_rssi_history.setdefault(_hist_key, [])
|
||||
hist.append((time.time(), float(_rssi_val)))
|
||||
if len(hist) > _MAX_RSSI_HISTORY:
|
||||
del hist[: len(hist) - _MAX_RSSI_HISTORY]
|
||||
|
||||
# Push scope event when signal level data is present
|
||||
rssi = data.get('rssi')
|
||||
snr = data.get('snr')
|
||||
noise = data.get('noise')
|
||||
"""Stream rtl_433 JSON output to queue."""
|
||||
try:
|
||||
app_module.sensor_queue.put({'type': 'status', 'text': 'started'})
|
||||
|
||||
for line in iter(process.stdout.readline, b''):
|
||||
line = line.decode('utf-8', errors='replace').strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
# rtl_433 outputs JSON objects, one per line
|
||||
data = json.loads(line)
|
||||
data['type'] = 'sensor'
|
||||
app_module.sensor_queue.put(data)
|
||||
|
||||
# Track RSSI history per device
|
||||
_model = data.get('model', '')
|
||||
_dev_id = data.get('id', '')
|
||||
_rssi_val = data.get('rssi')
|
||||
if _rssi_val is not None and _model:
|
||||
_hist_key = f"{_model}_{_dev_id}"
|
||||
hist = sensor_rssi_history.setdefault(_hist_key, [])
|
||||
hist.append((time.time(), float(_rssi_val)))
|
||||
if len(hist) > _MAX_RSSI_HISTORY:
|
||||
del hist[: len(hist) - _MAX_RSSI_HISTORY]
|
||||
|
||||
# Push scope event when signal level data is present
|
||||
rssi = data.get('rssi')
|
||||
snr = data.get('snr')
|
||||
noise = data.get('noise')
|
||||
if rssi is not None or snr is not None:
|
||||
try:
|
||||
rssi_value = float(rssi) if rssi is not None else 0.0
|
||||
@@ -113,204 +120,205 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
||||
})
|
||||
except (TypeError, ValueError, queue.Full):
|
||||
pass
|
||||
|
||||
# Log if enabled
|
||||
if app_module.logging_enabled:
|
||||
try:
|
||||
with open(app_module.log_file_path, 'a') as f:
|
||||
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
f.write(f"{timestamp} | {data.get('model', 'Unknown')} | {json.dumps(data)}\n")
|
||||
except Exception:
|
||||
pass
|
||||
except json.JSONDecodeError:
|
||||
# Not JSON, send as raw
|
||||
app_module.sensor_queue.put({'type': 'raw', 'text': line})
|
||||
|
||||
except Exception as e:
|
||||
app_module.sensor_queue.put({'type': 'error', 'text': str(e)})
|
||||
finally:
|
||||
global sensor_active_device
|
||||
# Ensure process is terminated
|
||||
try:
|
||||
process.terminate()
|
||||
process.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
unregister_process(process)
|
||||
app_module.sensor_queue.put({'type': 'status', 'text': 'stopped'})
|
||||
with app_module.sensor_lock:
|
||||
app_module.sensor_process = None
|
||||
# Release SDR device
|
||||
if sensor_active_device is not None:
|
||||
app_module.release_sdr_device(sensor_active_device)
|
||||
sensor_active_device = None
|
||||
|
||||
|
||||
@sensor_bp.route('/sensor/status')
|
||||
def sensor_status() -> Response:
|
||||
"""Check if sensor decoder is currently running."""
|
||||
with app_module.sensor_lock:
|
||||
running = app_module.sensor_process is not None and app_module.sensor_process.poll() is None
|
||||
return jsonify({'running': running})
|
||||
|
||||
|
||||
@sensor_bp.route('/start_sensor', methods=['POST'])
|
||||
def start_sensor() -> Response:
|
||||
global sensor_active_device
|
||||
|
||||
with app_module.sensor_lock:
|
||||
if app_module.sensor_process:
|
||||
return jsonify({'status': 'error', 'message': 'Sensor already running'}), 409
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
# Validate inputs
|
||||
try:
|
||||
freq = validate_frequency(data.get('frequency', '433.92'))
|
||||
gain = validate_gain(data.get('gain', '0'))
|
||||
ppm = validate_ppm(data.get('ppm', '0'))
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
# Check for rtl_tcp (remote SDR) connection
|
||||
rtl_tcp_host = data.get('rtl_tcp_host')
|
||||
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
||||
|
||||
# Claim local device if not using remote rtl_tcp
|
||||
if not rtl_tcp_host:
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'sensor')
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
sensor_active_device = device_int
|
||||
|
||||
# Clear queue
|
||||
while not app_module.sensor_queue.empty():
|
||||
try:
|
||||
app_module.sensor_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Get SDR type and build command via abstraction layer
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
|
||||
if rtl_tcp_host:
|
||||
# Validate and create network device
|
||||
try:
|
||||
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
|
||||
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
|
||||
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
|
||||
else:
|
||||
# Create local device object
|
||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||
|
||||
builder = SDRFactory.get_builder(sdr_device.sdr_type)
|
||||
|
||||
# Build ISM band decoder command
|
||||
bias_t = data.get('bias_t', False)
|
||||
cmd = builder.build_ism_command(
|
||||
device=sdr_device,
|
||||
frequency_mhz=freq,
|
||||
gain=float(gain) if gain and gain != 0 else None,
|
||||
ppm=int(ppm) if ppm and ppm != 0 else None,
|
||||
bias_t=bias_t
|
||||
)
|
||||
|
||||
full_cmd = ' '.join(cmd)
|
||||
logger.info(f"Running: {full_cmd}")
|
||||
|
||||
# Add signal level metadata so the frontend scope can display RSSI/SNR
|
||||
# Disable stats reporting to suppress "row count limit 50 reached" warnings
|
||||
cmd.extend(['-M', 'level', '-M', 'stats:0'])
|
||||
|
||||
try:
|
||||
app_module.sensor_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
register_process(app_module.sensor_process)
|
||||
|
||||
# Start output thread
|
||||
thread = threading.Thread(target=stream_sensor_output, args=(app_module.sensor_process,))
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
# Monitor stderr
|
||||
# Filter noisy rtl_433 diagnostics that aren't useful to display
|
||||
_stderr_noise = (
|
||||
'bitbuffer_add_bit',
|
||||
'row count limit',
|
||||
)
|
||||
|
||||
def monitor_stderr():
|
||||
for line in app_module.sensor_process.stderr:
|
||||
err = line.decode('utf-8', errors='replace').strip()
|
||||
if err and not any(noise in err for noise in _stderr_noise):
|
||||
logger.debug(f"[rtl_433] {err}")
|
||||
app_module.sensor_queue.put({'type': 'info', 'text': f'[rtl_433] {err}'})
|
||||
|
||||
stderr_thread = threading.Thread(target=monitor_stderr)
|
||||
stderr_thread.daemon = True
|
||||
stderr_thread.start()
|
||||
|
||||
app_module.sensor_queue.put({'type': 'info', 'text': f'Command: {full_cmd}'})
|
||||
|
||||
return jsonify({'status': 'started', 'command': full_cmd})
|
||||
|
||||
except FileNotFoundError:
|
||||
# Release device on failure
|
||||
if sensor_active_device is not None:
|
||||
app_module.release_sdr_device(sensor_active_device)
|
||||
sensor_active_device = None
|
||||
return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'})
|
||||
except Exception as e:
|
||||
# Release device on failure
|
||||
if sensor_active_device is not None:
|
||||
app_module.release_sdr_device(sensor_active_device)
|
||||
sensor_active_device = None
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
|
||||
@sensor_bp.route('/stop_sensor', methods=['POST'])
|
||||
def stop_sensor() -> Response:
|
||||
global sensor_active_device
|
||||
|
||||
with app_module.sensor_lock:
|
||||
if app_module.sensor_process:
|
||||
app_module.sensor_process.terminate()
|
||||
try:
|
||||
app_module.sensor_process.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
app_module.sensor_process.kill()
|
||||
app_module.sensor_process = None
|
||||
|
||||
# Release device from registry
|
||||
if sensor_active_device is not None:
|
||||
app_module.release_sdr_device(sensor_active_device)
|
||||
sensor_active_device = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
return jsonify({'status': 'not_running'})
|
||||
|
||||
|
||||
|
||||
# Log if enabled
|
||||
if app_module.logging_enabled:
|
||||
try:
|
||||
with open(app_module.log_file_path, 'a') as f:
|
||||
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
f.write(f"{timestamp} | {data.get('model', 'Unknown')} | {json.dumps(data)}\n")
|
||||
except Exception:
|
||||
pass
|
||||
except json.JSONDecodeError:
|
||||
# Not JSON, send as raw
|
||||
app_module.sensor_queue.put({'type': 'raw', 'text': line})
|
||||
|
||||
except Exception as e:
|
||||
app_module.sensor_queue.put({'type': 'error', 'text': str(e)})
|
||||
finally:
|
||||
global sensor_active_device, sensor_active_sdr_type
|
||||
# Ensure process is terminated
|
||||
try:
|
||||
process.terminate()
|
||||
process.wait(timeout=2)
|
||||
except Exception:
|
||||
with contextlib.suppress(Exception):
|
||||
process.kill()
|
||||
unregister_process(process)
|
||||
app_module.sensor_queue.put({'type': 'status', 'text': 'stopped'})
|
||||
with app_module.sensor_lock:
|
||||
app_module.sensor_process = None
|
||||
# Release SDR device
|
||||
if sensor_active_device is not None:
|
||||
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
|
||||
sensor_active_device = None
|
||||
sensor_active_sdr_type = None
|
||||
|
||||
|
||||
@sensor_bp.route('/sensor/status')
|
||||
def sensor_status() -> Response:
|
||||
"""Check if sensor decoder is currently running."""
|
||||
with app_module.sensor_lock:
|
||||
running = app_module.sensor_process is not None and app_module.sensor_process.poll() is None
|
||||
return jsonify({'running': running})
|
||||
|
||||
|
||||
@sensor_bp.route('/start_sensor', methods=['POST'])
|
||||
def start_sensor() -> Response:
|
||||
global sensor_active_device, sensor_active_sdr_type
|
||||
|
||||
with app_module.sensor_lock:
|
||||
if app_module.sensor_process:
|
||||
return api_error('Sensor already running', 409)
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
# Validate inputs
|
||||
try:
|
||||
freq = validate_frequency(data.get('frequency', '433.92'))
|
||||
gain = validate_gain(data.get('gain', '0'))
|
||||
ppm = validate_ppm(data.get('ppm', '0'))
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
except ValueError as e:
|
||||
return api_error(str(e), 400)
|
||||
|
||||
# Check for rtl_tcp (remote SDR) connection
|
||||
rtl_tcp_host = data.get('rtl_tcp_host')
|
||||
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
||||
|
||||
# Get SDR type early so we can pass it to claim/release
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
|
||||
# Claim local device if not using remote rtl_tcp
|
||||
if not rtl_tcp_host:
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'sensor', sdr_type_str)
|
||||
if error:
|
||||
return api_error(error, 409, error_type='DEVICE_BUSY')
|
||||
sensor_active_device = device_int
|
||||
sensor_active_sdr_type = sdr_type_str
|
||||
|
||||
# Clear queue
|
||||
while not app_module.sensor_queue.empty():
|
||||
try:
|
||||
app_module.sensor_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Build command via SDR abstraction layer
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
|
||||
if rtl_tcp_host:
|
||||
# Validate and create network device
|
||||
try:
|
||||
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
|
||||
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
|
||||
except ValueError as e:
|
||||
return api_error(str(e), 400)
|
||||
|
||||
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
|
||||
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
|
||||
else:
|
||||
# Create local device object
|
||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||
|
||||
builder = SDRFactory.get_builder(sdr_device.sdr_type)
|
||||
|
||||
# Build ISM band decoder command
|
||||
bias_t = data.get('bias_t', False)
|
||||
cmd = builder.build_ism_command(
|
||||
device=sdr_device,
|
||||
frequency_mhz=freq,
|
||||
gain=float(gain) if gain and gain != 0 else None,
|
||||
ppm=int(ppm) if ppm and ppm != 0 else None,
|
||||
bias_t=bias_t
|
||||
)
|
||||
|
||||
full_cmd = ' '.join(cmd)
|
||||
logger.info(f"Running: {full_cmd}")
|
||||
|
||||
# Add signal level metadata so the frontend scope can display RSSI/SNR
|
||||
# Disable stats reporting to suppress "row count limit 50 reached" warnings
|
||||
cmd.extend(['-M', 'level', '-M', 'stats:0'])
|
||||
|
||||
try:
|
||||
app_module.sensor_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
register_process(app_module.sensor_process)
|
||||
|
||||
# Start output thread
|
||||
thread = threading.Thread(target=stream_sensor_output, args=(app_module.sensor_process,))
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
# Monitor stderr
|
||||
# Filter noisy rtl_433 diagnostics that aren't useful to display
|
||||
_stderr_noise = (
|
||||
'bitbuffer_add_bit',
|
||||
'row count limit',
|
||||
)
|
||||
|
||||
def monitor_stderr():
|
||||
for line in app_module.sensor_process.stderr:
|
||||
err = line.decode('utf-8', errors='replace').strip()
|
||||
if err and not any(noise in err for noise in _stderr_noise):
|
||||
logger.debug(f"[rtl_433] {err}")
|
||||
app_module.sensor_queue.put({'type': 'info', 'text': f'[rtl_433] {err}'})
|
||||
|
||||
stderr_thread = threading.Thread(target=monitor_stderr)
|
||||
stderr_thread.daemon = True
|
||||
stderr_thread.start()
|
||||
|
||||
app_module.sensor_queue.put({'type': 'info', 'text': f'Command: {full_cmd}'})
|
||||
|
||||
return jsonify({'status': 'started', 'command': full_cmd})
|
||||
|
||||
except FileNotFoundError:
|
||||
# Release device on failure
|
||||
if sensor_active_device is not None:
|
||||
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
|
||||
sensor_active_device = None
|
||||
sensor_active_sdr_type = None
|
||||
return api_error('rtl_433 not found. Install with: brew install rtl_433')
|
||||
except Exception as e:
|
||||
# Release device on failure
|
||||
if sensor_active_device is not None:
|
||||
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
|
||||
sensor_active_device = None
|
||||
sensor_active_sdr_type = None
|
||||
return api_error(str(e))
|
||||
|
||||
|
||||
@sensor_bp.route('/stop_sensor', methods=['POST'])
|
||||
def stop_sensor() -> Response:
|
||||
global sensor_active_device, sensor_active_sdr_type
|
||||
|
||||
with app_module.sensor_lock:
|
||||
if app_module.sensor_process:
|
||||
app_module.sensor_process.terminate()
|
||||
try:
|
||||
app_module.sensor_process.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
app_module.sensor_process.kill()
|
||||
app_module.sensor_process = None
|
||||
|
||||
# Release device from registry
|
||||
if sensor_active_device is not None:
|
||||
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
|
||||
sensor_active_device = None
|
||||
sensor_active_sdr_type = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
return jsonify({'status': 'not_running'})
|
||||
|
||||
|
||||
@sensor_bp.route('/stream_sensor')
|
||||
def stream_sensor() -> Response:
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
@@ -330,12 +338,12 @@ def stream_sensor() -> Response:
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
|
||||
@sensor_bp.route('/sensor/rssi_history')
|
||||
def get_rssi_history() -> Response:
|
||||
"""Return RSSI history for all tracked sensor devices."""
|
||||
result = {}
|
||||
for key, entries in sensor_rssi_history.items():
|
||||
result[key] = [{'t': round(t, 1), 'rssi': rssi} for t, rssi in entries]
|
||||
return jsonify({'status': 'success', 'devices': result})
|
||||
|
||||
|
||||
@sensor_bp.route('/sensor/rssi_history')
|
||||
def get_rssi_history() -> Response:
|
||||
"""Return RSSI history for all tracked sensor devices."""
|
||||
result = {}
|
||||
for key, entries in sensor_rssi_history.items():
|
||||
result[key] = [{'t': round(t, 1), 'rssi': rssi} for t, rssi in entries]
|
||||
return api_success(data={'devices': result})
|
||||
|
||||
+21
-71
@@ -6,16 +6,17 @@ import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
from utils.database import (
|
||||
get_setting,
|
||||
set_setting,
|
||||
delete_setting,
|
||||
get_all_settings,
|
||||
get_correlations,
|
||||
get_setting,
|
||||
set_setting,
|
||||
)
|
||||
from utils.logging import get_logger
|
||||
from utils.responses import api_error, api_success
|
||||
|
||||
logger = get_logger('intercept.settings')
|
||||
|
||||
@@ -27,16 +28,10 @@ def get_settings() -> Response:
|
||||
"""Get all settings."""
|
||||
try:
|
||||
settings = get_all_settings()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'settings': settings
|
||||
})
|
||||
return api_success(data={'settings': settings})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting settings: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@settings_bp.route('', methods=['POST'])
|
||||
@@ -45,10 +40,7 @@ def save_settings() -> Response:
|
||||
data = request.json or {}
|
||||
|
||||
if not data:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'No settings provided'
|
||||
}), 400
|
||||
return api_error('No settings provided', 400)
|
||||
|
||||
try:
|
||||
saved = []
|
||||
@@ -60,16 +52,10 @@ def save_settings() -> Response:
|
||||
set_setting(key, value)
|
||||
saved.append(key)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'saved': saved
|
||||
})
|
||||
return api_success(data={'saved': saved})
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving settings: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@settings_bp.route('/<key>', methods=['GET'])
|
||||
@@ -83,17 +69,10 @@ def get_single_setting(key: str) -> Response:
|
||||
'key': key
|
||||
}), 404
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'key': key,
|
||||
'value': value
|
||||
})
|
||||
return api_success(data={'key': key, 'value': value})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting setting {key}: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@settings_bp.route('/<key>', methods=['PUT'])
|
||||
@@ -103,24 +82,14 @@ def update_single_setting(key: str) -> Response:
|
||||
value = data.get('value')
|
||||
|
||||
if value is None and 'value' not in data:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Value is required'
|
||||
}), 400
|
||||
return api_error('Value is required', 400)
|
||||
|
||||
try:
|
||||
set_setting(key, value)
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'key': key,
|
||||
'value': value
|
||||
})
|
||||
return api_success(data={'key': key, 'value': value})
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating setting {key}: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@settings_bp.route('/<key>', methods=['DELETE'])
|
||||
@@ -129,11 +98,7 @@ def delete_single_setting(key: str) -> Response:
|
||||
try:
|
||||
deleted = delete_setting(key)
|
||||
if deleted:
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'key': key,
|
||||
'deleted': True
|
||||
})
|
||||
return api_success(data={'key': key, 'deleted': True})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'not_found',
|
||||
@@ -141,10 +106,7 @@ def delete_single_setting(key: str) -> Response:
|
||||
}), 404
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting setting {key}: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -158,16 +120,10 @@ def get_device_correlations() -> Response:
|
||||
|
||||
try:
|
||||
correlations = get_correlations(min_confidence)
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'correlations': correlations
|
||||
})
|
||||
return api_success(data={'correlations': correlations})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting correlations: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -207,7 +163,7 @@ def check_dvb_driver_status() -> Response:
|
||||
blacklist_contents = []
|
||||
if blacklist_exists:
|
||||
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('#')]
|
||||
except Exception:
|
||||
pass
|
||||
@@ -229,17 +185,11 @@ def check_dvb_driver_status() -> Response:
|
||||
def blacklist_dvb_drivers() -> Response:
|
||||
"""Blacklist DVB kernel drivers to prevent them from claiming RTL-SDR devices."""
|
||||
if sys.platform != 'linux':
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'This feature is only available on Linux'
|
||||
}), 400
|
||||
return api_error('This feature is only available on Linux', 400)
|
||||
|
||||
# Check if we have permission (need to be running as root or with sudo)
|
||||
if os.geteuid() != 0:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Root privileges required. Run the app with sudo or manually run: sudo modprobe -r dvb_usb_rtl28xxu rtl2832_sdr rtl2832 r820t'
|
||||
}), 403
|
||||
return api_error('Root privileges required. Run the app with sudo or manually run: sudo modprobe -r dvb_usb_rtl28xxu rtl2832_sdr rtl2832 r820t', 403)
|
||||
|
||||
errors = []
|
||||
successes = []
|
||||
|
||||
+5
-4
@@ -11,6 +11,7 @@ from typing import Any
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
from utils.logging import get_logger
|
||||
from utils.responses import api_error
|
||||
|
||||
logger = get_logger('intercept.signalid')
|
||||
|
||||
@@ -294,15 +295,15 @@ def sigidwiki_lookup() -> Response:
|
||||
|
||||
freq_raw = payload.get('frequency_mhz')
|
||||
if freq_raw is None:
|
||||
return jsonify({'status': 'error', 'message': 'frequency_mhz is required'}), 400
|
||||
return api_error('frequency_mhz is required', 400)
|
||||
|
||||
try:
|
||||
frequency_mhz = float(freq_raw)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid frequency_mhz'}), 400
|
||||
return api_error('Invalid frequency_mhz', 400)
|
||||
|
||||
if frequency_mhz <= 0:
|
||||
return jsonify({'status': 'error', 'message': 'frequency_mhz must be positive'}), 400
|
||||
return api_error('frequency_mhz must be positive', 400)
|
||||
|
||||
modulation = str(payload.get('modulation') or '').strip().upper()
|
||||
if modulation and len(modulation) > 16:
|
||||
@@ -331,7 +332,7 @@ def sigidwiki_lookup() -> Response:
|
||||
lookup = _lookup_sigidwiki_matches(frequency_mhz, modulation, limit)
|
||||
except Exception as exc:
|
||||
logger.error('SigID lookup failed: %s', exc)
|
||||
return jsonify({'status': 'error', 'message': 'SigID lookup failed'}), 502
|
||||
return api_error('SigID lookup failed', 502)
|
||||
|
||||
response_payload = {
|
||||
'matches': lookup.get('matches', []),
|
||||
|
||||
+57
-17
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import concurrent.futures
|
||||
import json
|
||||
import time
|
||||
import urllib.error
|
||||
@@ -12,6 +13,7 @@ from typing import Any
|
||||
from flask import Blueprint, Response, jsonify
|
||||
|
||||
from utils.logging import get_logger
|
||||
from utils.responses import api_error
|
||||
|
||||
logger = get_logger('intercept.space_weather')
|
||||
|
||||
@@ -259,22 +261,27 @@ IMAGE_WHITELIST: dict[str, dict[str, str]] = {
|
||||
@space_weather_bp.route('/data')
|
||||
def get_data():
|
||||
"""Return aggregated space weather data from all sources."""
|
||||
data = {
|
||||
'kp_index': _fetch_kp_index(),
|
||||
'kp_forecast': _fetch_kp_forecast(),
|
||||
'scales': _fetch_scales(),
|
||||
'flux': _fetch_flux(),
|
||||
'alerts': _fetch_alerts(),
|
||||
'solar_wind_plasma': _fetch_solar_wind_plasma(),
|
||||
'solar_wind_mag': _fetch_solar_wind_mag(),
|
||||
'xrays': _fetch_xrays(),
|
||||
'xray_flares': _fetch_xray_flares(),
|
||||
'flare_probability': _fetch_flare_probability(),
|
||||
'solar_regions': _fetch_solar_regions(),
|
||||
'sunspot_report': _fetch_sunspot_report(),
|
||||
'band_conditions': _fetch_band_conditions(),
|
||||
'timestamp': time.time(),
|
||||
fetchers = {
|
||||
'kp_index': _fetch_kp_index,
|
||||
'kp_forecast': _fetch_kp_forecast,
|
||||
'scales': _fetch_scales,
|
||||
'flux': _fetch_flux,
|
||||
'alerts': _fetch_alerts,
|
||||
'solar_wind_plasma': _fetch_solar_wind_plasma,
|
||||
'solar_wind_mag': _fetch_solar_wind_mag,
|
||||
'xrays': _fetch_xrays,
|
||||
'xray_flares': _fetch_xray_flares,
|
||||
'flare_probability': _fetch_flare_probability,
|
||||
'solar_regions': _fetch_solar_regions,
|
||||
'sunspot_report': _fetch_sunspot_report,
|
||||
'band_conditions': _fetch_band_conditions,
|
||||
}
|
||||
data = {}
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=13) as executor:
|
||||
futures = {executor.submit(fn): key for key, fn in fetchers.items()}
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
data[futures[future]] = future.result()
|
||||
data['timestamp'] = time.time()
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@@ -283,7 +290,7 @@ def get_image(key: str):
|
||||
"""Proxy and cache whitelisted space weather images."""
|
||||
entry = IMAGE_WHITELIST.get(key)
|
||||
if not entry:
|
||||
return jsonify({'error': 'Unknown image key'}), 404
|
||||
return api_error('Unknown image key', 404)
|
||||
|
||||
cache_key = f'img_{key}'
|
||||
cached = _cache_get(cache_key)
|
||||
@@ -293,8 +300,41 @@ def get_image(key: str):
|
||||
|
||||
img_data = _fetch_bytes(entry['url'])
|
||||
if img_data is None:
|
||||
return jsonify({'error': 'Failed to fetch image'}), 502
|
||||
return api_error('Failed to fetch image', 502)
|
||||
|
||||
_cache_set(cache_key, img_data, TTL_IMAGE)
|
||||
return Response(img_data, content_type=entry['content_type'],
|
||||
headers={'Cache-Control': 'public, max-age=300'})
|
||||
|
||||
|
||||
@space_weather_bp.route('/prefetch-images')
|
||||
def prefetch_images():
|
||||
"""Warm the image cache by fetching all whitelisted images in parallel."""
|
||||
# Only fetch images not already cached
|
||||
to_fetch = {}
|
||||
for key, entry in IMAGE_WHITELIST.items():
|
||||
cache_key = f'img_{key}'
|
||||
if _cache_get(cache_key) is None:
|
||||
to_fetch[key] = entry
|
||||
|
||||
if not to_fetch:
|
||||
return jsonify({'status': 'all cached', 'count': 0})
|
||||
|
||||
def _fetch_and_cache(key: str, entry: dict) -> bool:
|
||||
img_data = _fetch_bytes(entry['url'])
|
||||
if img_data:
|
||||
_cache_set(f'img_{key}', img_data, TTL_IMAGE)
|
||||
return True
|
||||
return False
|
||||
|
||||
fetched = 0
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor:
|
||||
futures = {
|
||||
executor.submit(_fetch_and_cache, k, e): k
|
||||
for k, e in to_fetch.items()
|
||||
}
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
if future.result():
|
||||
fetched += 1
|
||||
|
||||
return jsonify({'status': 'ok', 'fetched': fetched, 'cached': len(IMAGE_WHITELIST) - len(to_fetch)})
|
||||
|
||||
@@ -611,9 +611,9 @@ def get_station(station_id):
|
||||
@spy_stations_bp.route('/filters')
|
||||
def get_filters():
|
||||
"""Return available filter options."""
|
||||
types = list(set(s['type'] for s in STATIONS))
|
||||
countries = sorted(list(set((s['country'], s['country_code']) for s in STATIONS)))
|
||||
modes = sorted(list(set(s['mode'].split('/')[0] for s in STATIONS)))
|
||||
types = list({s['type'] for s in STATIONS})
|
||||
countries = sorted({(s['country'], s['country_code']) for s in STATIONS})
|
||||
modes = sorted({s['mode'].split('/')[0] for s in STATIONS})
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
|
||||
+179
-101
@@ -6,21 +6,24 @@ ISS SSTV events occur during special commemorations and typically transmit on 14
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Generator
|
||||
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
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import sse_stream_fanout
|
||||
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 (
|
||||
ISS_SSTV_FREQ,
|
||||
get_sstv_decoder,
|
||||
is_sstv_available,
|
||||
ISS_SSTV_FREQ,
|
||||
)
|
||||
|
||||
logger = get_logger('intercept.sstv')
|
||||
@@ -36,8 +39,27 @@ ISS_SSTV_FREQ_TOLERANCE_MHZ = 0.05
|
||||
# Queue for SSE progress streaming
|
||||
_sstv_queue: queue.Queue = queue.Queue(maxsize=100)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Caching — ISS position (external API) and schedule (skyfield computation)
|
||||
# ---------------------------------------------------------------------------
|
||||
_iss_position_cache: dict | None = None
|
||||
_iss_position_cache_time: float = 0
|
||||
_iss_position_lock = threading.Lock()
|
||||
ISS_POSITION_CACHE_TTL = 10 # seconds
|
||||
|
||||
_iss_schedule_cache: dict | None = None
|
||||
_iss_schedule_cache_time: float = 0
|
||||
_iss_schedule_cache_key: str | None = None
|
||||
_iss_schedule_lock = threading.Lock()
|
||||
ISS_SCHEDULE_CACHE_TTL = 900 # 15 minutes
|
||||
|
||||
# Reusable skyfield timescale (expensive to create)
|
||||
_timescale = None
|
||||
_timescale_lock = threading.Lock()
|
||||
|
||||
# Track which device is being used
|
||||
sstv_active_device: int | None = None
|
||||
sstv_active_sdr_type: str = 'rtlsdr'
|
||||
|
||||
|
||||
def _progress_callback(data: dict) -> None:
|
||||
@@ -135,6 +157,14 @@ def start_decoder():
|
||||
|
||||
# Get parameters
|
||||
data = request.get_json(silent=True) or {}
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
|
||||
if sdr_type_str != 'rtlsdr':
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'{sdr_type_str.replace("_", " ").title()} is not yet supported for this mode. Please use an RTL-SDR device.'
|
||||
}), 400
|
||||
|
||||
frequency = data.get('frequency', ISS_SSTV_FREQ)
|
||||
modulation = str(data.get('modulation', ISS_SSTV_MODULATION)).strip().lower()
|
||||
device_index = data.get('device', 0)
|
||||
@@ -190,9 +220,9 @@ def start_decoder():
|
||||
longitude = None
|
||||
|
||||
# Claim SDR device
|
||||
global sstv_active_device
|
||||
global sstv_active_device, sstv_active_sdr_type
|
||||
device_int = int(device_index)
|
||||
error = app_module.claim_sdr_device(device_int, 'sstv')
|
||||
error = app_module.claim_sdr_device(device_int, 'sstv', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
@@ -212,6 +242,7 @@ def start_decoder():
|
||||
|
||||
if success:
|
||||
sstv_active_device = device_int
|
||||
sstv_active_sdr_type = sdr_type_str
|
||||
|
||||
result = {
|
||||
'status': 'started',
|
||||
@@ -228,7 +259,7 @@ def start_decoder():
|
||||
return jsonify(result)
|
||||
else:
|
||||
# Release device on failure
|
||||
app_module.release_sdr_device(device_int)
|
||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to start decoder'
|
||||
@@ -243,13 +274,13 @@ def stop_decoder():
|
||||
Returns:
|
||||
JSON confirmation.
|
||||
"""
|
||||
global sstv_active_device
|
||||
global sstv_active_device, sstv_active_sdr_type
|
||||
decoder = get_sstv_decoder()
|
||||
decoder.stop()
|
||||
|
||||
# Release device from registry
|
||||
if sstv_active_device is not None:
|
||||
app_module.release_sdr_device(sstv_active_device)
|
||||
app_module.release_sdr_device(sstv_active_device, sstv_active_sdr_type)
|
||||
sstv_active_device = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
@@ -328,16 +359,16 @@ def get_image(filename: str):
|
||||
|
||||
# Security: only allow alphanumeric filenames with .png extension
|
||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
||||
return api_error('Invalid filename', 400)
|
||||
|
||||
if not filename.endswith('.png'):
|
||||
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
|
||||
return api_error('Only PNG files supported', 400)
|
||||
|
||||
# Find image in decoder's output directory
|
||||
image_path = decoder._output_dir / filename
|
||||
|
||||
if not image_path.exists():
|
||||
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||
return api_error('Image not found', 404)
|
||||
|
||||
return send_file(image_path, mimetype='image/png')
|
||||
|
||||
@@ -357,15 +388,15 @@ def download_image(filename: str):
|
||||
|
||||
# Security: only allow alphanumeric filenames with .png extension
|
||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
||||
return api_error('Invalid filename', 400)
|
||||
|
||||
if not filename.endswith('.png'):
|
||||
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
|
||||
return api_error('Only PNG files supported', 400)
|
||||
|
||||
image_path = decoder._output_dir / filename
|
||||
|
||||
if not image_path.exists():
|
||||
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||
return api_error('Image not found', 404)
|
||||
|
||||
return send_file(image_path, mimetype='image/png', as_attachment=True, download_name=filename)
|
||||
|
||||
@@ -385,15 +416,15 @@ def delete_image(filename: str):
|
||||
|
||||
# Security: only allow alphanumeric filenames with .png extension
|
||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
||||
return api_error('Invalid filename', 400)
|
||||
|
||||
if not filename.endswith('.png'):
|
||||
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
|
||||
return api_error('Only PNG files supported', 400)
|
||||
|
||||
if decoder.delete_image(filename):
|
||||
return jsonify({'status': 'ok'})
|
||||
else:
|
||||
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||
return api_error('Image not found', 404)
|
||||
|
||||
|
||||
@sstv_bp.route('/images', methods=['DELETE'])
|
||||
@@ -409,8 +440,8 @@ def delete_all_images():
|
||||
return jsonify({'status': 'ok', 'deleted': count})
|
||||
|
||||
|
||||
@sstv_bp.route('/stream')
|
||||
def stream_progress():
|
||||
@sstv_bp.route('/stream')
|
||||
def stream_progress():
|
||||
"""
|
||||
SSE stream of SSTV decode progress.
|
||||
|
||||
@@ -422,31 +453,42 @@ def stream_progress():
|
||||
Returns:
|
||||
SSE stream (text/event-stream)
|
||||
"""
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('sstv', msg, msg.get('type'))
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=_sstv_queue,
|
||||
channel_key='sstv',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
on_message=_on_msg,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('sstv', msg, msg.get('type'))
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=_sstv_queue,
|
||||
channel_key='sstv',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
on_message=_on_msg,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
|
||||
def _get_timescale():
|
||||
"""Return a cached skyfield timescale (expensive to create)."""
|
||||
global _timescale
|
||||
with _timescale_lock:
|
||||
if _timescale is None:
|
||||
from skyfield.api import load
|
||||
_timescale = load.timescale()
|
||||
return _timescale
|
||||
|
||||
|
||||
@sstv_bp.route('/iss-schedule')
|
||||
def iss_schedule():
|
||||
"""
|
||||
Get ISS pass schedule for SSTV reception.
|
||||
|
||||
Calculates ISS passes directly using skyfield.
|
||||
Results are cached for 15 minutes per rounded location.
|
||||
|
||||
Query parameters:
|
||||
latitude: Observer latitude (required)
|
||||
@@ -456,6 +498,8 @@ def iss_schedule():
|
||||
Returns:
|
||||
JSON with ISS pass schedule.
|
||||
"""
|
||||
global _iss_schedule_cache, _iss_schedule_cache_time, _iss_schedule_cache_key
|
||||
|
||||
lat = request.args.get('latitude', type=float)
|
||||
lon = request.args.get('longitude', type=float)
|
||||
hours = request.args.get('hours', 48, type=int)
|
||||
@@ -466,10 +510,22 @@ def iss_schedule():
|
||||
'message': 'latitude and longitude parameters required'
|
||||
}), 400
|
||||
|
||||
# Cache key: rounded lat/lon (1 decimal place) so nearby locations share cache
|
||||
cache_key = f"{round(lat, 1)}:{round(lon, 1)}:{hours}"
|
||||
|
||||
with _iss_schedule_lock:
|
||||
now = time.time()
|
||||
if (_iss_schedule_cache is not None
|
||||
and cache_key == _iss_schedule_cache_key
|
||||
and (now - _iss_schedule_cache_time) < ISS_SCHEDULE_CACHE_TTL):
|
||||
return jsonify(_iss_schedule_cache)
|
||||
|
||||
try:
|
||||
from skyfield.api import load, wgs84, EarthSatellite
|
||||
from skyfield.almanac import find_discrete
|
||||
from datetime import timedelta
|
||||
|
||||
from skyfield.almanac import find_discrete
|
||||
from skyfield.api import EarthSatellite, wgs84
|
||||
|
||||
from data.satellites import TLE_SATELLITES
|
||||
|
||||
# Get ISS TLE
|
||||
@@ -480,7 +536,7 @@ def iss_schedule():
|
||||
'message': 'ISS TLE data not available'
|
||||
}), 500
|
||||
|
||||
ts = load.timescale()
|
||||
ts = _get_timescale()
|
||||
satellite = EarthSatellite(iss_tle[1], iss_tle[2], iss_tle[0], ts)
|
||||
observer = wgs84.latlon(lat, lon)
|
||||
|
||||
@@ -543,13 +599,21 @@ def iss_schedule():
|
||||
|
||||
i += 1
|
||||
|
||||
return jsonify({
|
||||
result = {
|
||||
'status': 'ok',
|
||||
'passes': passes,
|
||||
'count': len(passes),
|
||||
'sstv_frequency': ISS_SSTV_FREQ,
|
||||
'note': 'ISS SSTV events are not continuous. Check ARISS.org for scheduled events.'
|
||||
})
|
||||
}
|
||||
|
||||
# Update cache
|
||||
with _iss_schedule_lock:
|
||||
_iss_schedule_cache = result
|
||||
_iss_schedule_cache_time = time.time()
|
||||
_iss_schedule_cache_key = cache_key
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
except ImportError:
|
||||
return jsonify({
|
||||
@@ -565,13 +629,65 @@ def iss_schedule():
|
||||
}), 500
|
||||
|
||||
|
||||
def _fetch_iss_position() -> dict | None:
|
||||
"""Fetch raw ISS lat/lon/altitude from external APIs, with 10s cache."""
|
||||
global _iss_position_cache, _iss_position_cache_time
|
||||
|
||||
with _iss_position_lock:
|
||||
now = time.time()
|
||||
if _iss_position_cache is not None and (now - _iss_position_cache_time) < ISS_POSITION_CACHE_TTL:
|
||||
return _iss_position_cache
|
||||
|
||||
import requests
|
||||
|
||||
cached = None
|
||||
|
||||
# Try primary API: Where The ISS At
|
||||
try:
|
||||
response = requests.get('https://api.wheretheiss.at/v1/satellites/25544', timeout=3)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
cached = {
|
||||
'lat': float(data['latitude']),
|
||||
'lon': float(data['longitude']),
|
||||
'altitude': float(data.get('altitude', 420)),
|
||||
'source': 'wheretheiss',
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Where The ISS At API failed: {e}")
|
||||
|
||||
# Try fallback API: Open Notify
|
||||
if cached is None:
|
||||
try:
|
||||
response = requests.get('http://api.open-notify.org/iss-now.json', timeout=3)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data.get('message') == 'success':
|
||||
cached = {
|
||||
'lat': float(data['iss_position']['latitude']),
|
||||
'lon': float(data['iss_position']['longitude']),
|
||||
'altitude': 420,
|
||||
'source': 'open-notify',
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Open Notify API failed: {e}")
|
||||
|
||||
if cached is not None:
|
||||
with _iss_position_lock:
|
||||
_iss_position_cache = cached
|
||||
_iss_position_cache_time = time.time()
|
||||
|
||||
return cached
|
||||
|
||||
|
||||
@sstv_bp.route('/iss-position')
|
||||
def iss_position():
|
||||
"""
|
||||
Get current ISS position from real-time API.
|
||||
|
||||
Uses the Open Notify API for accurate real-time position,
|
||||
with fallback to "Where The ISS At" API.
|
||||
Uses the "Where The ISS At" API for accurate real-time position,
|
||||
with fallback to Open Notify API. Raw position is cached for 10 seconds;
|
||||
observer-relative data (elevation/azimuth) is computed per-request.
|
||||
|
||||
Query parameters:
|
||||
latitude: Observer latitude (optional, for elevation calc)
|
||||
@@ -580,68 +696,32 @@ def iss_position():
|
||||
Returns:
|
||||
JSON with ISS current position.
|
||||
"""
|
||||
import requests
|
||||
from datetime import datetime
|
||||
|
||||
observer_lat = request.args.get('latitude', type=float)
|
||||
observer_lon = request.args.get('longitude', type=float)
|
||||
|
||||
# Try primary API: Where The ISS At
|
||||
try:
|
||||
response = requests.get('https://api.wheretheiss.at/v1/satellites/25544', timeout=5)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
iss_lat = float(data['latitude'])
|
||||
iss_lon = float(data['longitude'])
|
||||
pos = _fetch_iss_position()
|
||||
if pos is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Unable to fetch ISS position from real-time APIs'
|
||||
}), 503
|
||||
|
||||
result = {
|
||||
'status': 'ok',
|
||||
'lat': iss_lat,
|
||||
'lon': iss_lon,
|
||||
'altitude': float(data.get('altitude', 420)),
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
'source': 'wheretheiss'
|
||||
}
|
||||
result = {
|
||||
'status': 'ok',
|
||||
'lat': pos['lat'],
|
||||
'lon': pos['lon'],
|
||||
'altitude': pos['altitude'],
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
'source': pos['source'],
|
||||
}
|
||||
|
||||
# Calculate observer-relative data if location provided
|
||||
if observer_lat is not None and observer_lon is not None:
|
||||
result.update(_calculate_observer_data(iss_lat, iss_lon, observer_lat, observer_lon))
|
||||
# Calculate observer-relative data if location provided
|
||||
if observer_lat is not None and observer_lon is not None:
|
||||
result.update(_calculate_observer_data(pos['lat'], pos['lon'], observer_lat, observer_lon))
|
||||
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.warning(f"Where The ISS At API failed: {e}")
|
||||
|
||||
# Try fallback API: Open Notify
|
||||
try:
|
||||
response = requests.get('http://api.open-notify.org/iss-now.json', timeout=5)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data.get('message') == 'success':
|
||||
iss_lat = float(data['iss_position']['latitude'])
|
||||
iss_lon = float(data['iss_position']['longitude'])
|
||||
|
||||
result = {
|
||||
'status': 'ok',
|
||||
'lat': iss_lat,
|
||||
'lon': iss_lon,
|
||||
'altitude': 420, # Approximate ISS altitude in km
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
'source': 'open-notify'
|
||||
}
|
||||
|
||||
# Calculate observer-relative data if location provided
|
||||
if observer_lat is not None and observer_lon is not None:
|
||||
result.update(_calculate_observer_data(iss_lat, iss_lon, observer_lat, observer_lon))
|
||||
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.warning(f"Open Notify API failed: {e}")
|
||||
|
||||
# Both APIs failed
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Unable to fetch ISS position from real-time APIs'
|
||||
}), 503
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
def _calculate_observer_data(iss_lat: float, iss_lon: float, obs_lat: float, obs_lon: float) -> dict:
|
||||
@@ -739,7 +819,5 @@ def decode_file():
|
||||
|
||||
finally:
|
||||
# Clean up temp file
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
Path(tmp_path).unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
+36
-62
@@ -6,17 +6,17 @@ frequencies used by amateur radio operators worldwide.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import queue
|
||||
import time
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request, send_file
|
||||
|
||||
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.logging import get_logger
|
||||
from utils.responses import api_error
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.sstv import (
|
||||
get_general_sstv_decoder,
|
||||
)
|
||||
@@ -30,6 +30,7 @@ _sstv_general_queue: queue.Queue = queue.Queue(maxsize=100)
|
||||
|
||||
# Track which device is being used
|
||||
_sstv_general_active_device: int | None = None
|
||||
_sstv_general_active_sdr_type: str = 'rtlsdr'
|
||||
|
||||
# Predefined SSTV frequencies
|
||||
SSTV_FREQUENCIES = [
|
||||
@@ -101,10 +102,7 @@ def start_decoder():
|
||||
decoder = get_general_sstv_decoder()
|
||||
|
||||
if decoder.decoder_available is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow',
|
||||
}), 400
|
||||
return api_error('SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow', 400)
|
||||
|
||||
if decoder.is_running:
|
||||
return jsonify({
|
||||
@@ -119,29 +117,25 @@ def start_decoder():
|
||||
break
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
|
||||
if sdr_type_str != 'rtlsdr':
|
||||
return api_error(f'{sdr_type_str.replace("_", " ").title()} is not yet supported for this mode. Please use an RTL-SDR device.', 400)
|
||||
|
||||
frequency = data.get('frequency')
|
||||
modulation = data.get('modulation')
|
||||
device_index = data.get('device', 0)
|
||||
|
||||
# Validate frequency
|
||||
if frequency is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Frequency is required',
|
||||
}), 400
|
||||
return api_error('Frequency is required', 400)
|
||||
|
||||
try:
|
||||
frequency = float(frequency)
|
||||
if not (1 <= frequency <= 500):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Frequency must be between 1-500 MHz (HF requires upconverter for RTL-SDR)',
|
||||
}), 400
|
||||
return api_error('Frequency must be between 1-500 MHz (HF requires upconverter for RTL-SDR)', 400)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Invalid frequency',
|
||||
}), 400
|
||||
return api_error('Invalid frequency', 400)
|
||||
|
||||
# Auto-detect modulation from frequency table if not specified
|
||||
if not modulation:
|
||||
@@ -149,21 +143,14 @@ def start_decoder():
|
||||
|
||||
# Validate modulation
|
||||
if modulation not in ('fm', 'usb', 'lsb'):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Modulation must be fm, usb, or lsb',
|
||||
}), 400
|
||||
return api_error('Modulation must be fm, usb, or lsb', 400)
|
||||
|
||||
# Claim SDR device
|
||||
global _sstv_general_active_device
|
||||
global _sstv_general_active_device, _sstv_general_active_sdr_type
|
||||
device_int = int(device_index)
|
||||
error = app_module.claim_sdr_device(device_int, 'sstv_general')
|
||||
error = app_module.claim_sdr_device(device_int, 'sstv_general', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error,
|
||||
}), 409
|
||||
return api_error(error, 409, error_type='DEVICE_BUSY')
|
||||
|
||||
# Set callback and start
|
||||
decoder.set_callback(_progress_callback)
|
||||
@@ -175,6 +162,7 @@ def start_decoder():
|
||||
|
||||
if success:
|
||||
_sstv_general_active_device = device_int
|
||||
_sstv_general_active_sdr_type = sdr_type_str
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'frequency': frequency,
|
||||
@@ -182,22 +170,19 @@ def start_decoder():
|
||||
'device': device_index,
|
||||
})
|
||||
else:
|
||||
app_module.release_sdr_device(device_int)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to start decoder',
|
||||
}), 500
|
||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||
return api_error('Failed to start decoder', 500)
|
||||
|
||||
|
||||
@sstv_general_bp.route('/stop', methods=['POST'])
|
||||
def stop_decoder():
|
||||
"""Stop general SSTV decoder."""
|
||||
global _sstv_general_active_device
|
||||
global _sstv_general_active_device, _sstv_general_active_sdr_type
|
||||
decoder = get_general_sstv_decoder()
|
||||
decoder.stop()
|
||||
|
||||
if _sstv_general_active_device is not None:
|
||||
app_module.release_sdr_device(_sstv_general_active_device)
|
||||
app_module.release_sdr_device(_sstv_general_active_device, _sstv_general_active_sdr_type)
|
||||
_sstv_general_active_device = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
@@ -227,15 +212,15 @@ def get_image(filename: str):
|
||||
|
||||
# Security: only allow alphanumeric filenames with .png extension
|
||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
||||
return api_error('Invalid filename', 400)
|
||||
|
||||
if not filename.endswith('.png'):
|
||||
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
|
||||
return api_error('Only PNG files supported', 400)
|
||||
|
||||
image_path = decoder._output_dir / filename
|
||||
|
||||
if not image_path.exists():
|
||||
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||
return api_error('Image not found', 404)
|
||||
|
||||
return send_file(image_path, mimetype='image/png')
|
||||
|
||||
@@ -247,15 +232,15 @@ def download_image(filename: str):
|
||||
|
||||
# Security: only allow alphanumeric filenames with .png extension
|
||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
||||
return api_error('Invalid filename', 400)
|
||||
|
||||
if not filename.endswith('.png'):
|
||||
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
|
||||
return api_error('Only PNG files supported', 400)
|
||||
|
||||
image_path = decoder._output_dir / filename
|
||||
|
||||
if not image_path.exists():
|
||||
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||
return api_error('Image not found', 404)
|
||||
|
||||
return send_file(image_path, mimetype='image/png', as_attachment=True, download_name=filename)
|
||||
|
||||
@@ -267,15 +252,15 @@ def delete_image(filename: str):
|
||||
|
||||
# Security: only allow alphanumeric filenames with .png extension
|
||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
||||
return api_error('Invalid filename', 400)
|
||||
|
||||
if not filename.endswith('.png'):
|
||||
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
|
||||
return api_error('Only PNG files supported', 400)
|
||||
|
||||
if decoder.delete_image(filename):
|
||||
return jsonify({'status': 'ok'})
|
||||
else:
|
||||
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||
return api_error('Image not found', 404)
|
||||
|
||||
|
||||
@sstv_general_bp.route('/images', methods=['DELETE'])
|
||||
@@ -312,18 +297,12 @@ def stream_progress():
|
||||
def decode_file():
|
||||
"""Decode SSTV from an uploaded audio file."""
|
||||
if 'audio' not in request.files:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'No audio file provided',
|
||||
}), 400
|
||||
return api_error('No audio file provided', 400)
|
||||
|
||||
audio_file = request.files['audio']
|
||||
|
||||
if not audio_file.filename:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'No file selected',
|
||||
}), 400
|
||||
return api_error('No file selected', 400)
|
||||
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp:
|
||||
@@ -342,13 +321,8 @@ def decode_file():
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error decoding file: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e),
|
||||
}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
finally:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
Path(tmp_path).unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
+158
-158
@@ -6,24 +6,26 @@ signal replay/transmit, and wideband spectrum analysis.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
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 (
|
||||
SUBGHZ_FREQ_MIN_MHZ,
|
||||
SUBGHZ_FREQ_MAX_MHZ,
|
||||
SUBGHZ_FREQ_MIN_MHZ,
|
||||
SUBGHZ_LNA_GAIN_MAX,
|
||||
SUBGHZ_VGA_GAIN_MAX,
|
||||
SUBGHZ_TX_VGA_GAIN_MAX,
|
||||
SUBGHZ_TX_MAX_DURATION,
|
||||
SUBGHZ_SAMPLE_RATES,
|
||||
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')
|
||||
|
||||
@@ -33,14 +35,12 @@ subghz_bp = Blueprint('subghz', __name__, url_prefix='/subghz')
|
||||
_subghz_queue: queue.Queue = queue.Queue(maxsize=200)
|
||||
|
||||
|
||||
def _event_callback(event: dict) -> None:
|
||||
"""Forward SubGhzManager events to the SSE queue."""
|
||||
try:
|
||||
process_event('subghz', event, event.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
_subghz_queue.put_nowait(event)
|
||||
def _event_callback(event: dict) -> None:
|
||||
"""Forward SubGhzManager events to the SSE queue."""
|
||||
with contextlib.suppress(Exception):
|
||||
process_event('subghz', event, event.get('type'))
|
||||
try:
|
||||
_subghz_queue.put_nowait(event)
|
||||
except queue.Full:
|
||||
try:
|
||||
_subghz_queue.get_nowait()
|
||||
@@ -76,44 +76,44 @@ def _validate_serial(data: dict) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _validate_int(data: dict, key: str, default: int, min_val: int, max_val: int) -> int:
|
||||
"""Validate integer parameter with bounds clamping."""
|
||||
def _validate_int(data: dict, key: str, default: int, min_val: int, max_val: int) -> int:
|
||||
"""Validate integer parameter with bounds clamping."""
|
||||
try:
|
||||
val = int(data.get(key, default))
|
||||
return max(min_val, min(max_val, val))
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
|
||||
def _validate_decode_profile(data: dict, default: str = 'weather') -> str:
|
||||
profile = data.get('decode_profile', default)
|
||||
if not isinstance(profile, str):
|
||||
return default
|
||||
profile = profile.strip().lower()
|
||||
if profile in {'weather', 'all'}:
|
||||
return profile
|
||||
return default
|
||||
|
||||
|
||||
def _validate_optional_float(data: dict, key: str) -> tuple[float | None, str | None]:
|
||||
raw = data.get(key)
|
||||
if raw is None or raw == '':
|
||||
return None, None
|
||||
try:
|
||||
return float(raw), None
|
||||
except (ValueError, TypeError):
|
||||
return None, f'Invalid {key}'
|
||||
|
||||
|
||||
def _validate_bool(data: dict, key: str, default: bool = False) -> bool:
|
||||
raw = data.get(key, default)
|
||||
if isinstance(raw, bool):
|
||||
return raw
|
||||
if isinstance(raw, (int, float)):
|
||||
return bool(raw)
|
||||
if isinstance(raw, str):
|
||||
return raw.strip().lower() in {'1', 'true', 'yes', 'on', 'enabled'}
|
||||
return default
|
||||
return default
|
||||
|
||||
|
||||
def _validate_decode_profile(data: dict, default: str = 'weather') -> str:
|
||||
profile = data.get('decode_profile', default)
|
||||
if not isinstance(profile, str):
|
||||
return default
|
||||
profile = profile.strip().lower()
|
||||
if profile in {'weather', 'all'}:
|
||||
return profile
|
||||
return default
|
||||
|
||||
|
||||
def _validate_optional_float(data: dict, key: str) -> tuple[float | None, str | None]:
|
||||
raw = data.get(key)
|
||||
if raw is None or raw == '':
|
||||
return None, None
|
||||
try:
|
||||
return float(raw), None
|
||||
except (ValueError, TypeError):
|
||||
return None, f'Invalid {key}'
|
||||
|
||||
|
||||
def _validate_bool(data: dict, key: str, default: bool = False) -> bool:
|
||||
raw = data.get(key, default)
|
||||
if isinstance(raw, bool):
|
||||
return raw
|
||||
if isinstance(raw, (int, float)):
|
||||
return bool(raw)
|
||||
if isinstance(raw, str):
|
||||
return raw.strip().lower() in {'1', 'true', 'yes', 'on', 'enabled'}
|
||||
return default
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -136,34 +136,34 @@ def get_presets():
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@subghz_bp.route('/receive/start', methods=['POST'])
|
||||
def start_receive():
|
||||
def start_receive():
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
freq_hz, err = _validate_frequency_hz(data)
|
||||
if err:
|
||||
return jsonify({'status': 'error', 'message': err}), 400
|
||||
return api_error(err, 400)
|
||||
|
||||
sample_rate = _validate_int(data, 'sample_rate', 2000000, 2000000, 20000000)
|
||||
lna_gain = _validate_int(data, 'lna_gain', 32, 0, SUBGHZ_LNA_GAIN_MAX)
|
||||
vga_gain = _validate_int(data, 'vga_gain', 20, 0, SUBGHZ_VGA_GAIN_MAX)
|
||||
trigger_enabled = _validate_bool(data, 'trigger_enabled', False)
|
||||
trigger_pre_ms = _validate_int(data, 'trigger_pre_ms', 350, 50, 5000)
|
||||
trigger_post_ms = _validate_int(data, 'trigger_post_ms', 700, 100, 10000)
|
||||
device_serial = _validate_serial(data)
|
||||
sample_rate = _validate_int(data, 'sample_rate', 2000000, 2000000, 20000000)
|
||||
lna_gain = _validate_int(data, 'lna_gain', 32, 0, SUBGHZ_LNA_GAIN_MAX)
|
||||
vga_gain = _validate_int(data, 'vga_gain', 20, 0, SUBGHZ_VGA_GAIN_MAX)
|
||||
trigger_enabled = _validate_bool(data, 'trigger_enabled', False)
|
||||
trigger_pre_ms = _validate_int(data, 'trigger_pre_ms', 350, 50, 5000)
|
||||
trigger_post_ms = _validate_int(data, 'trigger_post_ms', 700, 100, 10000)
|
||||
device_serial = _validate_serial(data)
|
||||
|
||||
manager = get_subghz_manager()
|
||||
manager.set_callback(_event_callback)
|
||||
|
||||
result = manager.start_receive(
|
||||
frequency_hz=freq_hz,
|
||||
sample_rate=sample_rate,
|
||||
lna_gain=lna_gain,
|
||||
vga_gain=vga_gain,
|
||||
trigger_enabled=trigger_enabled,
|
||||
trigger_pre_ms=trigger_pre_ms,
|
||||
trigger_post_ms=trigger_post_ms,
|
||||
device_serial=device_serial,
|
||||
)
|
||||
result = manager.start_receive(
|
||||
frequency_hz=freq_hz,
|
||||
sample_rate=sample_rate,
|
||||
lna_gain=lna_gain,
|
||||
vga_gain=vga_gain,
|
||||
trigger_enabled=trigger_enabled,
|
||||
trigger_pre_ms=trigger_pre_ms,
|
||||
trigger_post_ms=trigger_post_ms,
|
||||
device_serial=device_serial,
|
||||
)
|
||||
|
||||
status_code = 200 if result.get('status') != 'error' else 409
|
||||
return jsonify(result), status_code
|
||||
@@ -186,25 +186,25 @@ def start_decode():
|
||||
|
||||
freq_hz, err = _validate_frequency_hz(data)
|
||||
if err:
|
||||
return jsonify({'status': 'error', 'message': err}), 400
|
||||
return api_error(err, 400)
|
||||
|
||||
sample_rate = _validate_int(data, 'sample_rate', 2000000, 2000000, 20000000)
|
||||
lna_gain = _validate_int(data, 'lna_gain', 32, 0, SUBGHZ_LNA_GAIN_MAX)
|
||||
vga_gain = _validate_int(data, 'vga_gain', 20, 0, SUBGHZ_VGA_GAIN_MAX)
|
||||
decode_profile = _validate_decode_profile(data)
|
||||
device_serial = _validate_serial(data)
|
||||
sample_rate = _validate_int(data, 'sample_rate', 2000000, 2000000, 20000000)
|
||||
lna_gain = _validate_int(data, 'lna_gain', 32, 0, SUBGHZ_LNA_GAIN_MAX)
|
||||
vga_gain = _validate_int(data, 'vga_gain', 20, 0, SUBGHZ_VGA_GAIN_MAX)
|
||||
decode_profile = _validate_decode_profile(data)
|
||||
device_serial = _validate_serial(data)
|
||||
|
||||
manager = get_subghz_manager()
|
||||
manager.set_callback(_event_callback)
|
||||
|
||||
result = manager.start_decode(
|
||||
frequency_hz=freq_hz,
|
||||
sample_rate=sample_rate,
|
||||
lna_gain=lna_gain,
|
||||
vga_gain=vga_gain,
|
||||
decode_profile=decode_profile,
|
||||
device_serial=device_serial,
|
||||
)
|
||||
sample_rate=sample_rate,
|
||||
lna_gain=lna_gain,
|
||||
vga_gain=vga_gain,
|
||||
decode_profile=decode_profile,
|
||||
device_serial=device_serial,
|
||||
)
|
||||
|
||||
status_code = 200 if result.get('status') != 'error' else 409
|
||||
return jsonify(result), status_code
|
||||
@@ -227,33 +227,33 @@ def start_transmit():
|
||||
|
||||
capture_id = data.get('capture_id')
|
||||
if not capture_id or not isinstance(capture_id, str):
|
||||
return jsonify({'status': 'error', 'message': 'capture_id is required'}), 400
|
||||
return api_error('capture_id is required', 400)
|
||||
|
||||
# Sanitize capture_id
|
||||
if not capture_id.isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
|
||||
return api_error('Invalid capture_id', 400)
|
||||
|
||||
tx_gain = _validate_int(data, 'tx_gain', 20, 0, SUBGHZ_TX_VGA_GAIN_MAX)
|
||||
max_duration = _validate_int(data, 'max_duration', 10, 1, SUBGHZ_TX_MAX_DURATION)
|
||||
start_seconds, start_err = _validate_optional_float(data, 'start_seconds')
|
||||
if start_err:
|
||||
return jsonify({'status': 'error', 'message': start_err}), 400
|
||||
duration_seconds, duration_err = _validate_optional_float(data, 'duration_seconds')
|
||||
if duration_err:
|
||||
return jsonify({'status': 'error', 'message': duration_err}), 400
|
||||
device_serial = _validate_serial(data)
|
||||
tx_gain = _validate_int(data, 'tx_gain', 20, 0, SUBGHZ_TX_VGA_GAIN_MAX)
|
||||
max_duration = _validate_int(data, 'max_duration', 10, 1, SUBGHZ_TX_MAX_DURATION)
|
||||
start_seconds, start_err = _validate_optional_float(data, 'start_seconds')
|
||||
if start_err:
|
||||
return api_error(start_err, 400)
|
||||
duration_seconds, duration_err = _validate_optional_float(data, 'duration_seconds')
|
||||
if duration_err:
|
||||
return api_error(duration_err, 400)
|
||||
device_serial = _validate_serial(data)
|
||||
|
||||
manager = get_subghz_manager()
|
||||
manager.set_callback(_event_callback)
|
||||
|
||||
result = manager.transmit(
|
||||
capture_id=capture_id,
|
||||
tx_gain=tx_gain,
|
||||
max_duration=max_duration,
|
||||
start_seconds=start_seconds,
|
||||
duration_seconds=duration_seconds,
|
||||
device_serial=device_serial,
|
||||
)
|
||||
capture_id=capture_id,
|
||||
tx_gain=tx_gain,
|
||||
max_duration=max_duration,
|
||||
start_seconds=start_seconds,
|
||||
duration_seconds=duration_seconds,
|
||||
device_serial=device_serial,
|
||||
)
|
||||
|
||||
status_code = 200 if result.get('status') != 'error' else 400
|
||||
return jsonify(result), status_code
|
||||
@@ -278,11 +278,11 @@ def start_sweep():
|
||||
freq_start = float(data.get('freq_start_mhz', 300))
|
||||
freq_end = float(data.get('freq_end_mhz', 928))
|
||||
if freq_start >= freq_end:
|
||||
return jsonify({'status': 'error', 'message': 'freq_start must be less than freq_end'}), 400
|
||||
return api_error('freq_start must be less than freq_end', 400)
|
||||
if freq_start < SUBGHZ_FREQ_MIN_MHZ or freq_end > SUBGHZ_FREQ_MAX_MHZ:
|
||||
return jsonify({'status': 'error', 'message': f'Frequency range: {SUBGHZ_FREQ_MIN_MHZ}-{SUBGHZ_FREQ_MAX_MHZ} MHz'}), 400
|
||||
return api_error(f'Frequency range: {SUBGHZ_FREQ_MIN_MHZ}-{SUBGHZ_FREQ_MAX_MHZ} MHz', 400)
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid frequency range'}), 400
|
||||
return api_error('Invalid frequency range', 400)
|
||||
|
||||
bin_width = _validate_int(data, 'bin_width', 100000, 10000, 5000000)
|
||||
device_serial = _validate_serial(data)
|
||||
@@ -326,94 +326,94 @@ def list_captures():
|
||||
@subghz_bp.route('/captures/<capture_id>')
|
||||
def get_capture(capture_id: str):
|
||||
if not capture_id.isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
|
||||
return api_error('Invalid capture_id', 400)
|
||||
|
||||
manager = get_subghz_manager()
|
||||
capture = manager.get_capture(capture_id)
|
||||
if not capture:
|
||||
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
|
||||
return api_error('Capture not found', 404)
|
||||
|
||||
return jsonify({'status': 'ok', 'capture': capture.to_dict()})
|
||||
|
||||
|
||||
@subghz_bp.route('/captures/<capture_id>/download')
|
||||
def download_capture(capture_id: str):
|
||||
if not capture_id.isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
|
||||
@subghz_bp.route('/captures/<capture_id>/download')
|
||||
def download_capture(capture_id: str):
|
||||
if not capture_id.isalnum():
|
||||
return api_error('Invalid capture_id', 400)
|
||||
|
||||
manager = get_subghz_manager()
|
||||
path = manager.get_capture_path(capture_id)
|
||||
if not path:
|
||||
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
|
||||
return api_error('Capture not found', 404)
|
||||
|
||||
return send_file(
|
||||
path,
|
||||
mimetype='application/octet-stream',
|
||||
as_attachment=True,
|
||||
download_name=path.name,
|
||||
)
|
||||
|
||||
|
||||
@subghz_bp.route('/captures/<capture_id>/trim', methods=['POST'])
|
||||
def trim_capture(capture_id: str):
|
||||
if not capture_id.isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
start_seconds, start_err = _validate_optional_float(data, 'start_seconds')
|
||||
if start_err:
|
||||
return jsonify({'status': 'error', 'message': start_err}), 400
|
||||
duration_seconds, duration_err = _validate_optional_float(data, 'duration_seconds')
|
||||
if duration_err:
|
||||
return jsonify({'status': 'error', 'message': duration_err}), 400
|
||||
|
||||
label = data.get('label', '')
|
||||
if label is None:
|
||||
label = ''
|
||||
if not isinstance(label, str) or len(label) > 100:
|
||||
return jsonify({'status': 'error', 'message': 'Label must be a string (max 100 chars)'}), 400
|
||||
|
||||
manager = get_subghz_manager()
|
||||
result = manager.trim_capture(
|
||||
capture_id=capture_id,
|
||||
start_seconds=start_seconds,
|
||||
duration_seconds=duration_seconds,
|
||||
label=label,
|
||||
)
|
||||
|
||||
if result.get('status') == 'ok':
|
||||
return jsonify(result), 200
|
||||
message = str(result.get('message') or 'Trim failed')
|
||||
status_code = 404 if 'not found' in message.lower() else 400
|
||||
return jsonify(result), status_code
|
||||
|
||||
|
||||
@subghz_bp.route('/captures/<capture_id>', methods=['DELETE'])
|
||||
def delete_capture(capture_id: str):
|
||||
if not capture_id.isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
|
||||
as_attachment=True,
|
||||
download_name=path.name,
|
||||
)
|
||||
|
||||
|
||||
@subghz_bp.route('/captures/<capture_id>/trim', methods=['POST'])
|
||||
def trim_capture(capture_id: str):
|
||||
if not capture_id.isalnum():
|
||||
return api_error('Invalid capture_id', 400)
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
start_seconds, start_err = _validate_optional_float(data, 'start_seconds')
|
||||
if start_err:
|
||||
return api_error(start_err, 400)
|
||||
duration_seconds, duration_err = _validate_optional_float(data, 'duration_seconds')
|
||||
if duration_err:
|
||||
return api_error(duration_err, 400)
|
||||
|
||||
label = data.get('label', '')
|
||||
if label is None:
|
||||
label = ''
|
||||
if not isinstance(label, str) or len(label) > 100:
|
||||
return api_error('Label must be a string (max 100 chars)', 400)
|
||||
|
||||
manager = get_subghz_manager()
|
||||
result = manager.trim_capture(
|
||||
capture_id=capture_id,
|
||||
start_seconds=start_seconds,
|
||||
duration_seconds=duration_seconds,
|
||||
label=label,
|
||||
)
|
||||
|
||||
if result.get('status') == 'ok':
|
||||
return jsonify(result), 200
|
||||
message = str(result.get('message') or 'Trim failed')
|
||||
status_code = 404 if 'not found' in message.lower() else 400
|
||||
return jsonify(result), status_code
|
||||
|
||||
|
||||
@subghz_bp.route('/captures/<capture_id>', methods=['DELETE'])
|
||||
def delete_capture(capture_id: str):
|
||||
if not capture_id.isalnum():
|
||||
return api_error('Invalid capture_id', 400)
|
||||
|
||||
manager = get_subghz_manager()
|
||||
if manager.delete_capture(capture_id):
|
||||
return jsonify({'status': 'deleted', 'id': capture_id})
|
||||
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
|
||||
return api_error('Capture not found', 404)
|
||||
|
||||
|
||||
@subghz_bp.route('/captures/<capture_id>', methods=['PATCH'])
|
||||
def update_capture(capture_id: str):
|
||||
if not capture_id.isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
|
||||
return api_error('Invalid capture_id', 400)
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
label = data.get('label', '')
|
||||
|
||||
if not isinstance(label, str) or len(label) > 100:
|
||||
return jsonify({'status': 'error', 'message': 'Label must be a string (max 100 chars)'}), 400
|
||||
return api_error('Label must be a string (max 100 chars)', 400)
|
||||
|
||||
manager = get_subghz_manager()
|
||||
if manager.update_capture_label(capture_id, label):
|
||||
return jsonify({'status': 'updated', 'id': capture_id, 'label': label})
|
||||
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
|
||||
return api_error('Capture not found', 404)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,584 @@
|
||||
"""System Health monitoring blueprint.
|
||||
|
||||
Provides real-time system metrics (CPU, memory, disk, temperatures,
|
||||
network, battery, fans), active process status, SDR device enumeration,
|
||||
location, and weather data via SSE streaming and REST endpoints.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import platform
|
||||
import queue
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
from utils.constants import SSE_KEEPALIVE_INTERVAL, SSE_QUEUE_TIMEOUT
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.responses import api_error
|
||||
from utils.sse import sse_stream_fanout
|
||||
|
||||
try:
|
||||
import psutil
|
||||
|
||||
_HAS_PSUTIL = True
|
||||
except ImportError:
|
||||
psutil = None # type: ignore[assignment]
|
||||
_HAS_PSUTIL = False
|
||||
|
||||
try:
|
||||
import requests as _requests
|
||||
except ImportError:
|
||||
_requests = None # type: ignore[assignment]
|
||||
|
||||
system_bp = Blueprint('system', __name__, url_prefix='/system')
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Background metrics collector
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_metrics_queue: queue.Queue = queue.Queue(maxsize=500)
|
||||
_collector_started = False
|
||||
_collector_lock = threading.Lock()
|
||||
_app_start_time: float | None = None
|
||||
|
||||
# Weather cache
|
||||
_weather_cache: dict[str, Any] = {}
|
||||
_weather_cache_time: float = 0.0
|
||||
_WEATHER_CACHE_TTL = 600 # 10 minutes
|
||||
|
||||
|
||||
def _get_app_start_time() -> float:
|
||||
"""Return the application start timestamp from the main app module."""
|
||||
global _app_start_time
|
||||
if _app_start_time is None:
|
||||
try:
|
||||
import app as app_module
|
||||
|
||||
_app_start_time = getattr(app_module, '_app_start_time', time.time())
|
||||
except Exception:
|
||||
_app_start_time = time.time()
|
||||
return _app_start_time
|
||||
|
||||
|
||||
def _get_app_version() -> str:
|
||||
"""Return the application version string."""
|
||||
try:
|
||||
from config import VERSION
|
||||
|
||||
return VERSION
|
||||
except Exception:
|
||||
return 'unknown'
|
||||
|
||||
|
||||
def _format_uptime(seconds: float) -> str:
|
||||
"""Format seconds into a human-readable uptime string."""
|
||||
days = int(seconds // 86400)
|
||||
hours = int((seconds % 86400) // 3600)
|
||||
minutes = int((seconds % 3600) // 60)
|
||||
parts = []
|
||||
if days > 0:
|
||||
parts.append(f'{days}d')
|
||||
if hours > 0:
|
||||
parts.append(f'{hours}h')
|
||||
parts.append(f'{minutes}m')
|
||||
return ' '.join(parts)
|
||||
|
||||
|
||||
def _collect_process_status() -> dict[str, bool]:
|
||||
"""Return running/stopped status for each decoder process.
|
||||
|
||||
Mirrors the logic in app.py health_check().
|
||||
"""
|
||||
try:
|
||||
import app as app_module
|
||||
|
||||
def _alive(attr: str) -> bool:
|
||||
proc = getattr(app_module, attr, None)
|
||||
if proc is None:
|
||||
return False
|
||||
try:
|
||||
return proc.poll() is None
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
processes: dict[str, bool] = {
|
||||
'pager': _alive('current_process'),
|
||||
'sensor': _alive('sensor_process'),
|
||||
'adsb': _alive('adsb_process'),
|
||||
'ais': _alive('ais_process'),
|
||||
'acars': _alive('acars_process'),
|
||||
'vdl2': _alive('vdl2_process'),
|
||||
'aprs': _alive('aprs_process'),
|
||||
'dsc': _alive('dsc_process'),
|
||||
'morse': _alive('morse_process'),
|
||||
}
|
||||
|
||||
# WiFi
|
||||
try:
|
||||
from app import _get_wifi_health
|
||||
|
||||
wifi_active, _, _ = _get_wifi_health()
|
||||
processes['wifi'] = wifi_active
|
||||
except Exception:
|
||||
processes['wifi'] = False
|
||||
|
||||
# Bluetooth
|
||||
try:
|
||||
from app import _get_bluetooth_health
|
||||
|
||||
bt_active, _ = _get_bluetooth_health()
|
||||
processes['bluetooth'] = bt_active
|
||||
except Exception:
|
||||
processes['bluetooth'] = False
|
||||
|
||||
# SubGHz
|
||||
try:
|
||||
from app import _get_subghz_active
|
||||
|
||||
processes['subghz'] = _get_subghz_active()
|
||||
except Exception:
|
||||
processes['subghz'] = False
|
||||
|
||||
return processes
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _collect_throttle_flags() -> str | None:
|
||||
"""Read Raspberry Pi throttle flags via vcgencmd (Linux/Pi only)."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['vcgencmd', 'get_throttled'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
)
|
||||
if result.returncode == 0 and 'throttled=' in result.stdout:
|
||||
return result.stdout.strip().split('=', 1)[1]
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _collect_power_draw() -> float | None:
|
||||
"""Read power draw in watts from sysfs (Linux only)."""
|
||||
try:
|
||||
power_supply = Path('/sys/class/power_supply')
|
||||
if not power_supply.exists():
|
||||
return None
|
||||
for supply_dir in power_supply.iterdir():
|
||||
power_file = supply_dir / 'power_now'
|
||||
if power_file.exists():
|
||||
val = int(power_file.read_text().strip())
|
||||
return round(val / 1_000_000, 2) # microwatts to watts
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _collect_metrics() -> dict[str, Any]:
|
||||
"""Gather a snapshot of system metrics."""
|
||||
now = time.time()
|
||||
start = _get_app_start_time()
|
||||
uptime_seconds = round(now - start, 2)
|
||||
|
||||
metrics: dict[str, Any] = {
|
||||
'type': 'system_metrics',
|
||||
'timestamp': now,
|
||||
'system': {
|
||||
'hostname': socket.gethostname(),
|
||||
'platform': platform.platform(),
|
||||
'python': platform.python_version(),
|
||||
'version': _get_app_version(),
|
||||
'uptime_seconds': uptime_seconds,
|
||||
'uptime_human': _format_uptime(uptime_seconds),
|
||||
},
|
||||
'processes': _collect_process_status(),
|
||||
}
|
||||
|
||||
if _HAS_PSUTIL:
|
||||
# CPU — overall + per-core + frequency
|
||||
cpu_percent = psutil.cpu_percent(interval=None)
|
||||
cpu_count = psutil.cpu_count() or 1
|
||||
try:
|
||||
load_1, load_5, load_15 = os.getloadavg()
|
||||
except (OSError, AttributeError):
|
||||
load_1 = load_5 = load_15 = 0.0
|
||||
|
||||
per_core = []
|
||||
with contextlib.suppress(Exception):
|
||||
per_core = psutil.cpu_percent(interval=None, percpu=True)
|
||||
|
||||
freq_data = None
|
||||
with contextlib.suppress(Exception):
|
||||
freq = psutil.cpu_freq()
|
||||
if freq:
|
||||
freq_data = {
|
||||
'current': round(freq.current, 0),
|
||||
'min': round(freq.min, 0),
|
||||
'max': round(freq.max, 0),
|
||||
}
|
||||
|
||||
metrics['cpu'] = {
|
||||
'percent': cpu_percent,
|
||||
'count': cpu_count,
|
||||
'load_1': round(load_1, 2),
|
||||
'load_5': round(load_5, 2),
|
||||
'load_15': round(load_15, 2),
|
||||
'per_core': per_core,
|
||||
'freq': freq_data,
|
||||
}
|
||||
|
||||
# Memory
|
||||
mem = psutil.virtual_memory()
|
||||
metrics['memory'] = {
|
||||
'total': mem.total,
|
||||
'used': mem.used,
|
||||
'available': mem.available,
|
||||
'percent': mem.percent,
|
||||
}
|
||||
|
||||
swap = psutil.swap_memory()
|
||||
metrics['swap'] = {
|
||||
'total': swap.total,
|
||||
'used': swap.used,
|
||||
'percent': swap.percent,
|
||||
}
|
||||
|
||||
# Disk — usage + I/O counters
|
||||
try:
|
||||
disk = psutil.disk_usage('/')
|
||||
metrics['disk'] = {
|
||||
'total': disk.total,
|
||||
'used': disk.used,
|
||||
'free': disk.free,
|
||||
'percent': disk.percent,
|
||||
'path': '/',
|
||||
}
|
||||
except Exception:
|
||||
metrics['disk'] = None
|
||||
|
||||
disk_io = None
|
||||
with contextlib.suppress(Exception):
|
||||
dio = psutil.disk_io_counters()
|
||||
if dio:
|
||||
disk_io = {
|
||||
'read_bytes': dio.read_bytes,
|
||||
'write_bytes': dio.write_bytes,
|
||||
'read_count': dio.read_count,
|
||||
'write_count': dio.write_count,
|
||||
}
|
||||
metrics['disk_io'] = disk_io
|
||||
|
||||
# Temperatures
|
||||
try:
|
||||
temps = psutil.sensors_temperatures()
|
||||
if temps:
|
||||
temp_data: dict[str, list[dict[str, Any]]] = {}
|
||||
for chip, entries in temps.items():
|
||||
temp_data[chip] = [
|
||||
{
|
||||
'label': e.label or chip,
|
||||
'current': e.current,
|
||||
'high': e.high,
|
||||
'critical': e.critical,
|
||||
}
|
||||
for e in entries
|
||||
]
|
||||
metrics['temperatures'] = temp_data
|
||||
else:
|
||||
metrics['temperatures'] = None
|
||||
except (AttributeError, Exception):
|
||||
metrics['temperatures'] = None
|
||||
|
||||
# Fans
|
||||
fans_data = None
|
||||
with contextlib.suppress(Exception):
|
||||
fans = psutil.sensors_fans()
|
||||
if fans:
|
||||
fans_data = {}
|
||||
for chip, entries in fans.items():
|
||||
fans_data[chip] = [
|
||||
{'label': e.label or chip, 'current': e.current}
|
||||
for e in entries
|
||||
]
|
||||
metrics['fans'] = fans_data
|
||||
|
||||
# Battery
|
||||
battery_data = None
|
||||
with contextlib.suppress(Exception):
|
||||
bat = psutil.sensors_battery()
|
||||
if bat:
|
||||
battery_data = {
|
||||
'percent': bat.percent,
|
||||
'plugged': bat.power_plugged,
|
||||
'secs_left': bat.secsleft if bat.secsleft != psutil.POWER_TIME_UNLIMITED else None,
|
||||
}
|
||||
metrics['battery'] = battery_data
|
||||
|
||||
# Network interfaces
|
||||
net_ifaces: list[dict[str, Any]] = []
|
||||
with contextlib.suppress(Exception):
|
||||
addrs = psutil.net_if_addrs()
|
||||
stats = psutil.net_if_stats()
|
||||
for iface_name in sorted(addrs.keys()):
|
||||
if iface_name == 'lo':
|
||||
continue
|
||||
iface_info: dict[str, Any] = {'name': iface_name}
|
||||
# Get addresses
|
||||
for addr in addrs[iface_name]:
|
||||
if addr.family == socket.AF_INET:
|
||||
iface_info['ipv4'] = addr.address
|
||||
elif addr.family == socket.AF_INET6:
|
||||
iface_info.setdefault('ipv6', addr.address)
|
||||
elif addr.family == psutil.AF_LINK:
|
||||
iface_info['mac'] = addr.address
|
||||
# Get stats
|
||||
if iface_name in stats:
|
||||
st = stats[iface_name]
|
||||
iface_info['is_up'] = st.isup
|
||||
iface_info['speed'] = st.speed # Mbps
|
||||
iface_info['mtu'] = st.mtu
|
||||
net_ifaces.append(iface_info)
|
||||
metrics['network'] = {'interfaces': net_ifaces}
|
||||
|
||||
# Network I/O counters (raw — JS computes deltas)
|
||||
net_io = None
|
||||
with contextlib.suppress(Exception):
|
||||
counters = psutil.net_io_counters(pernic=True)
|
||||
if counters:
|
||||
net_io = {}
|
||||
for nic, c in counters.items():
|
||||
if nic == 'lo':
|
||||
continue
|
||||
net_io[nic] = {
|
||||
'bytes_sent': c.bytes_sent,
|
||||
'bytes_recv': c.bytes_recv,
|
||||
}
|
||||
metrics['network']['io'] = net_io
|
||||
|
||||
# Connection count
|
||||
conn_count = 0
|
||||
with contextlib.suppress(Exception):
|
||||
conn_count = len(psutil.net_connections())
|
||||
metrics['network']['connections'] = conn_count
|
||||
|
||||
# Boot time
|
||||
boot_ts = None
|
||||
with contextlib.suppress(Exception):
|
||||
boot_ts = psutil.boot_time()
|
||||
metrics['boot_time'] = boot_ts
|
||||
|
||||
# Power / throttle (Pi-specific)
|
||||
metrics['power'] = {
|
||||
'throttled': _collect_throttle_flags(),
|
||||
'draw_watts': _collect_power_draw(),
|
||||
}
|
||||
else:
|
||||
metrics['cpu'] = None
|
||||
metrics['memory'] = None
|
||||
metrics['swap'] = None
|
||||
metrics['disk'] = None
|
||||
metrics['disk_io'] = None
|
||||
metrics['temperatures'] = None
|
||||
metrics['fans'] = None
|
||||
metrics['battery'] = None
|
||||
metrics['network'] = None
|
||||
metrics['boot_time'] = None
|
||||
metrics['power'] = None
|
||||
|
||||
return metrics
|
||||
|
||||
|
||||
def _collector_loop() -> None:
|
||||
"""Background thread that pushes metrics onto the queue every 3 seconds."""
|
||||
# Seed psutil's CPU measurement so the first real read isn't 0%.
|
||||
if _HAS_PSUTIL:
|
||||
with contextlib.suppress(Exception):
|
||||
psutil.cpu_percent(interval=None)
|
||||
|
||||
while True:
|
||||
try:
|
||||
metrics = _collect_metrics()
|
||||
# Non-blocking put — drop oldest if full
|
||||
try:
|
||||
_metrics_queue.put_nowait(metrics)
|
||||
except queue.Full:
|
||||
with contextlib.suppress(queue.Empty):
|
||||
_metrics_queue.get_nowait()
|
||||
_metrics_queue.put_nowait(metrics)
|
||||
except Exception as exc:
|
||||
logger.debug('system metrics collection error: %s', exc)
|
||||
time.sleep(3)
|
||||
|
||||
|
||||
def _ensure_collector() -> None:
|
||||
"""Start the background collector thread once."""
|
||||
global _collector_started
|
||||
if _collector_started:
|
||||
return
|
||||
with _collector_lock:
|
||||
if _collector_started:
|
||||
return
|
||||
t = threading.Thread(target=_collector_loop, daemon=True, name='system-metrics-collector')
|
||||
t.start()
|
||||
_collector_started = True
|
||||
logger.info('System metrics collector started')
|
||||
|
||||
|
||||
def _get_observer_location() -> dict[str, Any]:
|
||||
"""Get observer location from GPS state or config defaults."""
|
||||
lat, lon, source = None, None, 'none'
|
||||
gps_meta: dict[str, Any] = {}
|
||||
|
||||
# Try GPS via utils.gps
|
||||
with contextlib.suppress(Exception):
|
||||
from utils.gps import get_current_position
|
||||
|
||||
pos = get_current_position()
|
||||
if pos and pos.fix_quality >= 2:
|
||||
lat, lon, source = pos.latitude, pos.longitude, 'gps'
|
||||
gps_meta['fix_quality'] = pos.fix_quality
|
||||
gps_meta['satellites'] = pos.satellites
|
||||
if pos.epx is not None and pos.epy is not None:
|
||||
gps_meta['accuracy'] = round(max(pos.epx, pos.epy), 1)
|
||||
if pos.altitude is not None:
|
||||
gps_meta['altitude'] = round(pos.altitude, 1)
|
||||
|
||||
# Fall back to config env vars
|
||||
if lat is None:
|
||||
with contextlib.suppress(Exception):
|
||||
from config import DEFAULT_LATITUDE, DEFAULT_LONGITUDE
|
||||
|
||||
if DEFAULT_LATITUDE != 0.0 or DEFAULT_LONGITUDE != 0.0:
|
||||
lat, lon, source = DEFAULT_LATITUDE, DEFAULT_LONGITUDE, 'config'
|
||||
|
||||
# Fall back to hardcoded constants (London)
|
||||
if lat is None:
|
||||
with contextlib.suppress(Exception):
|
||||
from utils.constants import DEFAULT_LATITUDE as CONST_LAT
|
||||
from utils.constants import DEFAULT_LONGITUDE as CONST_LON
|
||||
|
||||
lat, lon, source = CONST_LAT, CONST_LON, 'default'
|
||||
|
||||
result: dict[str, Any] = {'lat': lat, 'lon': lon, 'source': source}
|
||||
if gps_meta:
|
||||
result['gps'] = gps_meta
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@system_bp.route('/metrics')
|
||||
def get_metrics() -> Response:
|
||||
"""REST snapshot of current system metrics."""
|
||||
_ensure_collector()
|
||||
return jsonify(_collect_metrics())
|
||||
|
||||
|
||||
@system_bp.route('/stream')
|
||||
def stream_system() -> Response:
|
||||
"""SSE stream for real-time system metrics."""
|
||||
_ensure_collector()
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=_metrics_queue,
|
||||
channel_key='system',
|
||||
timeout=SSE_QUEUE_TIMEOUT,
|
||||
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
return response
|
||||
|
||||
|
||||
@system_bp.route('/sdr_devices')
|
||||
def get_sdr_devices() -> Response:
|
||||
"""Enumerate all connected SDR devices (on-demand, not every tick)."""
|
||||
try:
|
||||
from utils.sdr.detection import detect_all_devices
|
||||
|
||||
devices = detect_all_devices()
|
||||
result = []
|
||||
for d in devices:
|
||||
result.append({
|
||||
'type': d.sdr_type.value if hasattr(d.sdr_type, 'value') else str(d.sdr_type),
|
||||
'index': d.index,
|
||||
'name': d.name,
|
||||
'serial': d.serial or '',
|
||||
'driver': d.driver or '',
|
||||
})
|
||||
return jsonify({'devices': result})
|
||||
except Exception as exc:
|
||||
logger.warning('SDR device detection failed: %s', exc)
|
||||
return jsonify({'devices': [], 'error': str(exc)})
|
||||
|
||||
|
||||
@system_bp.route('/location')
|
||||
def get_location() -> Response:
|
||||
"""Return observer location from GPS or config."""
|
||||
return jsonify(_get_observer_location())
|
||||
|
||||
|
||||
@system_bp.route('/weather')
|
||||
def get_weather() -> Response:
|
||||
"""Proxy weather from wttr.in, cached for 10 minutes."""
|
||||
global _weather_cache, _weather_cache_time
|
||||
|
||||
now = time.time()
|
||||
if _weather_cache and (now - _weather_cache_time) < _WEATHER_CACHE_TTL:
|
||||
return jsonify(_weather_cache)
|
||||
|
||||
lat = request.args.get('lat', type=float)
|
||||
lon = request.args.get('lon', type=float)
|
||||
if lat is None or lon is None:
|
||||
loc = _get_observer_location()
|
||||
lat, lon = loc.get('lat'), loc.get('lon')
|
||||
|
||||
if lat is None or lon is None:
|
||||
return api_error('No location available')
|
||||
|
||||
if _requests is None:
|
||||
return api_error('requests library not available')
|
||||
|
||||
try:
|
||||
resp = _requests.get(
|
||||
f'https://wttr.in/{lat},{lon}?format=j1',
|
||||
timeout=5,
|
||||
headers={'User-Agent': 'INTERCEPT-SystemHealth/1.0'},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
current = data.get('current_condition', [{}])[0]
|
||||
weather = {
|
||||
'temp_c': current.get('temp_C'),
|
||||
'temp_f': current.get('temp_F'),
|
||||
'condition': current.get('weatherDesc', [{}])[0].get('value', ''),
|
||||
'humidity': current.get('humidity'),
|
||||
'wind_mph': current.get('windspeedMiles'),
|
||||
'wind_dir': current.get('winddir16Point'),
|
||||
'feels_like_c': current.get('FeelsLikeC'),
|
||||
'visibility': current.get('visibility'),
|
||||
'pressure': current.get('pressure'),
|
||||
}
|
||||
_weather_cache = weather
|
||||
_weather_cache_time = now
|
||||
return jsonify(weather)
|
||||
except Exception as exc:
|
||||
logger.debug('Weather fetch failed: %s', exc)
|
||||
return api_error(str(exc))
|
||||
+1824
-3958
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 utils.logging import get_logger
|
||||
from utils.responses import api_error
|
||||
from utils.updater import (
|
||||
check_for_updates,
|
||||
dismiss_update,
|
||||
@@ -39,10 +40,7 @@ def check_updates() -> Response:
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking for updates: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@updater_bp.route('/status', methods=['GET'])
|
||||
@@ -61,10 +59,7 @@ def update_status() -> Response:
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting update status: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@updater_bp.route('/update', methods=['POST'])
|
||||
@@ -100,10 +95,7 @@ def do_update() -> Response:
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error performing update: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@updater_bp.route('/dismiss', methods=['POST'])
|
||||
@@ -124,20 +116,14 @@ def dismiss_notification() -> Response:
|
||||
version = data.get('version')
|
||||
|
||||
if not version:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Version is required'
|
||||
}), 400
|
||||
return api_error('Version is required', 400)
|
||||
|
||||
try:
|
||||
result = dismiss_update(version)
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.error(f"Error dismissing update: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@updater_bp.route('/restart', methods=['POST'])
|
||||
|
||||
+155
-127
@@ -1,35 +1,38 @@
|
||||
"""VDL2 aircraft datalink routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import pty
|
||||
import queue
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Generator
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import pty
|
||||
import queue
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
import app as app_module
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.acars_translator import translate_message
|
||||
from utils.constants import (
|
||||
PROCESS_START_WAIT,
|
||||
PROCESS_TERMINATE_TIMEOUT,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
PROCESS_START_WAIT,
|
||||
)
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.flight_correlator import get_flight_correlator
|
||||
from utils.logging import sensor_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_gain, validate_ppm
|
||||
|
||||
vdl2_bp = Blueprint('vdl2', __name__, url_prefix='/vdl2')
|
||||
|
||||
@@ -48,6 +51,7 @@ vdl2_last_message_time = None
|
||||
|
||||
# Track which device is being used
|
||||
vdl2_active_device: int | None = None
|
||||
vdl2_active_sdr_type: str | None = None
|
||||
|
||||
|
||||
def find_dumpvdl2():
|
||||
@@ -55,22 +59,22 @@ def find_dumpvdl2():
|
||||
return shutil.which('dumpvdl2')
|
||||
|
||||
|
||||
def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) -> None:
|
||||
"""Stream dumpvdl2 JSON output to queue."""
|
||||
global vdl2_message_count, vdl2_last_message_time
|
||||
|
||||
try:
|
||||
app_module.vdl2_queue.put({'type': 'status', 'status': 'started'})
|
||||
|
||||
# Use appropriate sentinel based on mode (text mode for pty on macOS)
|
||||
sentinel = '' if is_text_mode else b''
|
||||
for line in iter(process.stdout.readline, sentinel):
|
||||
if is_text_mode:
|
||||
line = line.strip()
|
||||
else:
|
||||
line = line.decode('utf-8', errors='replace').strip()
|
||||
if not line:
|
||||
continue
|
||||
def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) -> None:
|
||||
"""Stream dumpvdl2 JSON output to queue."""
|
||||
global vdl2_message_count, vdl2_last_message_time
|
||||
|
||||
try:
|
||||
app_module.vdl2_queue.put({'type': 'status', 'status': 'started'})
|
||||
|
||||
# Use appropriate sentinel based on mode (text mode for pty on macOS)
|
||||
sentinel = '' if is_text_mode else b''
|
||||
for line in iter(process.stdout.readline, sentinel):
|
||||
if is_text_mode:
|
||||
line = line.strip()
|
||||
else:
|
||||
line = line.decode('utf-8', errors='replace').strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
data = json.loads(line)
|
||||
@@ -79,6 +83,21 @@ def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) ->
|
||||
data['type'] = 'vdl2'
|
||||
data['timestamp'] = datetime.utcnow().isoformat() + 'Z'
|
||||
|
||||
# Enrich with translated ACARS label at top level (consistent with ACARS route)
|
||||
try:
|
||||
vdl2_inner = data.get('vdl2', data)
|
||||
acars_payload = (vdl2_inner.get('avlc') or {}).get('acars')
|
||||
if acars_payload and acars_payload.get('label'):
|
||||
translation = translate_message({
|
||||
'label': acars_payload.get('label'),
|
||||
'text': acars_payload.get('msg_text', ''),
|
||||
})
|
||||
data['label_description'] = translation['label_description']
|
||||
data['message_type'] = translation['message_type']
|
||||
data['parsed'] = translation['parsed']
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Update stats
|
||||
vdl2_message_count += 1
|
||||
vdl2_last_message_time = time.time()
|
||||
@@ -86,11 +105,8 @@ def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) ->
|
||||
app_module.vdl2_queue.put(data)
|
||||
|
||||
# Feed flight correlator
|
||||
try:
|
||||
from utils.flight_correlator import get_flight_correlator
|
||||
with contextlib.suppress(Exception):
|
||||
get_flight_correlator().add_vdl2_message(data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Log if enabled
|
||||
if app_module.logging_enabled:
|
||||
@@ -110,24 +126,23 @@ def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) ->
|
||||
logger.error(f"VDL2 stream error: {e}")
|
||||
app_module.vdl2_queue.put({'type': 'error', 'message': str(e)})
|
||||
finally:
|
||||
global vdl2_active_device
|
||||
global vdl2_active_device, vdl2_active_sdr_type
|
||||
# Ensure process is terminated
|
||||
try:
|
||||
process.terminate()
|
||||
process.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
unregister_process(process)
|
||||
app_module.vdl2_queue.put({'type': 'status', 'status': 'stopped'})
|
||||
with app_module.vdl2_lock:
|
||||
app_module.vdl2_process = None
|
||||
# Release SDR device
|
||||
if vdl2_active_device is not None:
|
||||
app_module.release_sdr_device(vdl2_active_device)
|
||||
app_module.release_sdr_device(vdl2_active_device, vdl2_active_sdr_type or 'rtlsdr')
|
||||
vdl2_active_device = None
|
||||
vdl2_active_sdr_type = None
|
||||
|
||||
|
||||
@vdl2_bp.route('/tools')
|
||||
@@ -159,22 +174,16 @@ def vdl2_status() -> Response:
|
||||
@vdl2_bp.route('/start', methods=['POST'])
|
||||
def start_vdl2() -> Response:
|
||||
"""Start VDL2 decoder."""
|
||||
global vdl2_message_count, vdl2_last_message_time, vdl2_active_device
|
||||
global vdl2_message_count, vdl2_last_message_time, vdl2_active_device, vdl2_active_sdr_type
|
||||
|
||||
with app_module.vdl2_lock:
|
||||
if app_module.vdl2_process and app_module.vdl2_process.poll() is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'VDL2 decoder already running'
|
||||
}), 409
|
||||
return api_error('VDL2 decoder already running', 409)
|
||||
|
||||
# Check for dumpvdl2
|
||||
dumpvdl2_path = find_dumpvdl2()
|
||||
if not dumpvdl2_path:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'dumpvdl2 not found. Install from: https://github.com/szpajder/dumpvdl2'
|
||||
}), 400
|
||||
return api_error('dumpvdl2 not found. Install from: https://github.com/szpajder/dumpvdl2', 400)
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
@@ -184,19 +193,23 @@ def start_vdl2() -> Response:
|
||||
gain = validate_gain(data.get('gain', '40'))
|
||||
ppm = validate_ppm(data.get('ppm', '0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
return api_error(str(e), 400)
|
||||
|
||||
# Resolve SDR type for device selection
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
|
||||
# Check if device is available
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'vdl2')
|
||||
error = app_module.claim_sdr_device(device_int, 'vdl2', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
return api_error(error, 409, error_type='DEVICE_BUSY')
|
||||
|
||||
vdl2_active_device = device_int
|
||||
vdl2_active_sdr_type = sdr_type_str
|
||||
|
||||
# Get frequencies - use provided or defaults
|
||||
# dumpvdl2 expects frequencies in Hz (integers)
|
||||
@@ -215,13 +228,6 @@ def start_vdl2() -> Response:
|
||||
vdl2_message_count = 0
|
||||
vdl2_last_message_time = None
|
||||
|
||||
# Resolve SDR type for device selection
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
|
||||
is_soapy = sdr_type not in (SDRType.RTL_SDR,)
|
||||
|
||||
# Build dumpvdl2 command
|
||||
@@ -252,28 +258,28 @@ def start_vdl2() -> Response:
|
||||
logger.info(f"Starting VDL2 decoder: {' '.join(cmd)}")
|
||||
|
||||
try:
|
||||
is_text_mode = False
|
||||
|
||||
# On macOS, use pty to avoid stdout buffering issues
|
||||
if platform.system() == 'Darwin':
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=slave_fd,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True
|
||||
)
|
||||
os.close(slave_fd)
|
||||
# Wrap master_fd as a text file for line-buffered reading
|
||||
process.stdout = io.open(master_fd, 'r', buffering=1)
|
||||
is_text_mode = True
|
||||
else:
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True
|
||||
)
|
||||
is_text_mode = False
|
||||
|
||||
# On macOS, use pty to avoid stdout buffering issues
|
||||
if platform.system() == 'Darwin':
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=slave_fd,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True
|
||||
)
|
||||
os.close(slave_fd)
|
||||
# Wrap master_fd as a text file for line-buffered reading
|
||||
process.stdout = open(master_fd, buffering=1)
|
||||
is_text_mode = True
|
||||
else:
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True
|
||||
)
|
||||
|
||||
# Wait briefly to check if process started
|
||||
time.sleep(PROCESS_START_WAIT)
|
||||
@@ -281,26 +287,29 @@ def start_vdl2() -> Response:
|
||||
if process.poll() is not None:
|
||||
# Process died - release device
|
||||
if vdl2_active_device is not None:
|
||||
app_module.release_sdr_device(vdl2_active_device)
|
||||
app_module.release_sdr_device(vdl2_active_device, vdl2_active_sdr_type or 'rtlsdr')
|
||||
vdl2_active_device = None
|
||||
vdl2_active_sdr_type = None
|
||||
stderr = ''
|
||||
if process.stderr:
|
||||
stderr = process.stderr.read().decode('utf-8', errors='replace')
|
||||
if stderr:
|
||||
logger.error(f"dumpvdl2 stderr:\n{stderr}")
|
||||
error_msg = 'dumpvdl2 failed to start'
|
||||
if stderr:
|
||||
error_msg += f': {stderr[:200]}'
|
||||
error_msg += f': {stderr[:500]}'
|
||||
logger.error(error_msg)
|
||||
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||
return api_error(error_msg, 500)
|
||||
|
||||
app_module.vdl2_process = process
|
||||
register_process(process)
|
||||
|
||||
# Start output streaming thread
|
||||
thread = threading.Thread(
|
||||
target=stream_vdl2_output,
|
||||
args=(process, is_text_mode),
|
||||
daemon=True
|
||||
)
|
||||
# Start output streaming thread
|
||||
thread = threading.Thread(
|
||||
target=stream_vdl2_output,
|
||||
args=(process, is_text_mode),
|
||||
daemon=True
|
||||
)
|
||||
thread.start()
|
||||
|
||||
return jsonify({
|
||||
@@ -313,23 +322,21 @@ def start_vdl2() -> Response:
|
||||
except Exception as e:
|
||||
# Release device on failure
|
||||
if vdl2_active_device is not None:
|
||||
app_module.release_sdr_device(vdl2_active_device)
|
||||
app_module.release_sdr_device(vdl2_active_device, vdl2_active_sdr_type or 'rtlsdr')
|
||||
vdl2_active_device = None
|
||||
vdl2_active_sdr_type = None
|
||||
logger.error(f"Failed to start VDL2 decoder: {e}")
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@vdl2_bp.route('/stop', methods=['POST'])
|
||||
def stop_vdl2() -> Response:
|
||||
"""Stop VDL2 decoder."""
|
||||
global vdl2_active_device
|
||||
global vdl2_active_device, vdl2_active_sdr_type
|
||||
|
||||
with app_module.vdl2_lock:
|
||||
if not app_module.vdl2_process:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'VDL2 decoder not running'
|
||||
}), 400
|
||||
return api_error('VDL2 decoder not running', 400)
|
||||
|
||||
try:
|
||||
app_module.vdl2_process.terminate()
|
||||
@@ -343,31 +350,52 @@ def stop_vdl2() -> Response:
|
||||
|
||||
# Release device from registry
|
||||
if vdl2_active_device is not None:
|
||||
app_module.release_sdr_device(vdl2_active_device)
|
||||
app_module.release_sdr_device(vdl2_active_device, vdl2_active_sdr_type or 'rtlsdr')
|
||||
vdl2_active_device = None
|
||||
vdl2_active_sdr_type = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@vdl2_bp.route('/stream')
|
||||
def stream_vdl2() -> Response:
|
||||
"""SSE stream for VDL2 messages."""
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('vdl2', msg, msg.get('type'))
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.vdl2_queue,
|
||||
channel_key='vdl2',
|
||||
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
|
||||
@vdl2_bp.route('/stream')
|
||||
def stream_vdl2() -> Response:
|
||||
"""SSE stream for VDL2 messages."""
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('vdl2', msg, msg.get('type'))
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.vdl2_queue,
|
||||
channel_key='vdl2',
|
||||
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
|
||||
|
||||
|
||||
|
||||
@vdl2_bp.route('/messages')
|
||||
def get_vdl2_messages() -> Response:
|
||||
"""Get recent VDL2 messages from correlator (for history reload)."""
|
||||
limit = request.args.get('limit', 50, type=int)
|
||||
limit = max(1, min(limit, 200))
|
||||
msgs = get_flight_correlator().get_recent_messages('vdl2', limit)
|
||||
return jsonify(msgs)
|
||||
|
||||
|
||||
@vdl2_bp.route('/clear', methods=['POST'])
|
||||
def clear_vdl2_messages() -> Response:
|
||||
"""Clear stored VDL2 messages and reset counter."""
|
||||
global vdl2_message_count, vdl2_last_message_time
|
||||
get_flight_correlator().clear_vdl2()
|
||||
vdl2_message_count = 0
|
||||
vdl2_last_message_time = None
|
||||
return jsonify({'status': 'cleared'})
|
||||
|
||||
|
||||
@vdl2_bp.route('/frequencies')
|
||||
|
||||
+232
-139
@@ -1,7 +1,10 @@
|
||||
"""WebSocket-based waterfall streaming with I/Q capture and server-side FFT."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import queue
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
@@ -11,14 +14,14 @@ from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from flask import Flask
|
||||
|
||||
try:
|
||||
from flask_sock import Sock
|
||||
WEBSOCKET_AVAILABLE = True
|
||||
except ImportError:
|
||||
WEBSOCKET_AVAILABLE = False
|
||||
Sock = None
|
||||
|
||||
|
||||
try:
|
||||
from flask_sock import Sock
|
||||
WEBSOCKET_AVAILABLE = True
|
||||
except ImportError:
|
||||
WEBSOCKET_AVAILABLE = False
|
||||
Sock = None
|
||||
|
||||
from utils.logging import get_logger
|
||||
from utils.process import register_process, safe_terminate, unregister_process
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
@@ -34,7 +37,7 @@ logger = get_logger('intercept.waterfall_ws')
|
||||
|
||||
AUDIO_SAMPLE_RATE = 48000
|
||||
_shared_state_lock = threading.Lock()
|
||||
_shared_audio_queue: queue.Queue[bytes] = queue.Queue(maxsize=80)
|
||||
_shared_audio_queue: queue.Queue[bytes] = queue.Queue(maxsize=20)
|
||||
_shared_state: dict[str, Any] = {
|
||||
'running': False,
|
||||
'device': None,
|
||||
@@ -46,7 +49,11 @@ _shared_state: dict[str, Any] = {
|
||||
'monitor_modulation': 'wfm',
|
||||
'monitor_squelch': 0,
|
||||
}
|
||||
|
||||
# Generation counter to prevent stale WebSocket handlers from clobbering
|
||||
# shared state set by a newer handler (e.g. old handler's finally block
|
||||
# running after a new connection has already started capture).
|
||||
_capture_generation: int = 0
|
||||
|
||||
# Maximum bandwidth per SDR type (Hz)
|
||||
MAX_BANDWIDTH = {
|
||||
SDRType.RTL_SDR: 2400000,
|
||||
@@ -72,8 +79,23 @@ def _set_shared_capture_state(
|
||||
center_mhz: float | None = None,
|
||||
span_mhz: float | None = None,
|
||||
sample_rate: int | None = None,
|
||||
) -> None:
|
||||
generation: int | None = None,
|
||||
) -> int:
|
||||
"""Update shared capture state.
|
||||
|
||||
Returns the current generation counter. When *running* is True and
|
||||
*generation* is None the counter is bumped; callers should capture
|
||||
the returned value and pass it back when setting running=False so
|
||||
that stale handlers cannot clobber a newer session.
|
||||
"""
|
||||
global _capture_generation
|
||||
with _shared_state_lock:
|
||||
if not running and generation is not None:
|
||||
# Only allow the matching generation to clear the state.
|
||||
if generation != _capture_generation:
|
||||
return _capture_generation
|
||||
if running and generation is None:
|
||||
_capture_generation += 1
|
||||
_shared_state['running'] = bool(running)
|
||||
_shared_state['device'] = device if running else None
|
||||
if center_mhz is not None:
|
||||
@@ -84,8 +106,10 @@ def _set_shared_capture_state(
|
||||
_shared_state['sample_rate'] = int(sample_rate)
|
||||
if not running:
|
||||
_shared_state['monitor_enabled'] = False
|
||||
gen = _capture_generation
|
||||
if not running:
|
||||
_clear_shared_audio_queue()
|
||||
return gen
|
||||
|
||||
|
||||
def _set_shared_monitor(
|
||||
@@ -96,16 +120,20 @@ def _set_shared_monitor(
|
||||
squelch: int | None = None,
|
||||
) -> None:
|
||||
was_enabled = False
|
||||
freq_changed = False
|
||||
with _shared_state_lock:
|
||||
was_enabled = bool(_shared_state.get('monitor_enabled'))
|
||||
_shared_state['monitor_enabled'] = bool(enabled)
|
||||
if frequency_mhz is not None:
|
||||
old_freq = float(_shared_state.get('monitor_freq_mhz', 0.0) or 0.0)
|
||||
_shared_state['monitor_freq_mhz'] = float(frequency_mhz)
|
||||
if abs(float(frequency_mhz) - old_freq) > 1e-6:
|
||||
freq_changed = True
|
||||
if modulation is not None:
|
||||
_shared_state['monitor_modulation'] = str(modulation).lower().strip()
|
||||
if squelch is not None:
|
||||
_shared_state['monitor_squelch'] = max(0, min(100, int(squelch)))
|
||||
if was_enabled and not enabled:
|
||||
if (was_enabled and not enabled) or (enabled and freq_changed):
|
||||
_clear_shared_audio_queue()
|
||||
|
||||
|
||||
@@ -187,18 +215,21 @@ def _demodulate_monitor_audio(
|
||||
monitor_freq_mhz: float,
|
||||
modulation: str,
|
||||
squelch: int,
|
||||
) -> bytes | None:
|
||||
rotator_phase: float = 0.0,
|
||||
) -> tuple[bytes | None, float]:
|
||||
if samples.size < 32 or sample_rate <= 0:
|
||||
return None
|
||||
return None, float(rotator_phase)
|
||||
|
||||
fs = float(sample_rate)
|
||||
freq_offset_hz = (float(monitor_freq_mhz) - float(center_mhz)) * 1e6
|
||||
nyquist = fs * 0.5
|
||||
if abs(freq_offset_hz) > nyquist * 0.98:
|
||||
return None
|
||||
return None, float(rotator_phase)
|
||||
|
||||
n = np.arange(samples.size, dtype=np.float32)
|
||||
rotator = np.exp(-1j * (2.0 * np.pi * freq_offset_hz / fs) * n)
|
||||
phase_inc = (2.0 * np.pi * freq_offset_hz) / fs
|
||||
n = np.arange(samples.size, dtype=np.float64)
|
||||
rotator = np.exp(-1j * (float(rotator_phase) + phase_inc * n)).astype(np.complex64)
|
||||
next_phase = float((float(rotator_phase) + phase_inc * samples.size) % (2.0 * np.pi))
|
||||
shifted = samples * rotator
|
||||
|
||||
mod = str(modulation or 'wfm').lower().strip()
|
||||
@@ -207,11 +238,11 @@ def _demodulate_monitor_audio(
|
||||
if pre_decim > 1:
|
||||
usable = (shifted.size // pre_decim) * pre_decim
|
||||
if usable < pre_decim:
|
||||
return None
|
||||
return None, next_phase
|
||||
shifted = shifted[:usable].reshape(-1, pre_decim).mean(axis=1)
|
||||
fs1 = fs / pre_decim
|
||||
if shifted.size < 16:
|
||||
return None
|
||||
return None, next_phase
|
||||
|
||||
if mod in ('wfm', 'fm'):
|
||||
audio = np.angle(shifted[1:] * np.conj(shifted[:-1])).astype(np.float32)
|
||||
@@ -226,7 +257,7 @@ def _demodulate_monitor_audio(
|
||||
audio = np.real(shifted).astype(np.float32)
|
||||
|
||||
if audio.size < 8:
|
||||
return None
|
||||
return None, next_phase
|
||||
|
||||
audio = audio - float(np.mean(audio))
|
||||
|
||||
@@ -238,7 +269,7 @@ def _demodulate_monitor_audio(
|
||||
|
||||
out_len = int(audio.size * AUDIO_SAMPLE_RATE / fs1)
|
||||
if out_len < 32:
|
||||
return None
|
||||
return None, next_phase
|
||||
x_old = np.linspace(0.0, 1.0, audio.size, endpoint=False, dtype=np.float32)
|
||||
x_new = np.linspace(0.0, 1.0, out_len, endpoint=False, dtype=np.float32)
|
||||
audio = np.interp(x_new, x_old, audio).astype(np.float32)
|
||||
@@ -253,7 +284,7 @@ def _demodulate_monitor_audio(
|
||||
audio = audio * min(20.0, 0.85 / peak)
|
||||
|
||||
pcm = np.clip(audio, -1.0, 1.0)
|
||||
return (pcm * 32767.0).astype(np.int16).tobytes()
|
||||
return (pcm * 32767.0).astype(np.int16).tobytes(), next_phase
|
||||
|
||||
|
||||
def _parse_center_freq_mhz(payload: dict[str, Any]) -> float:
|
||||
@@ -290,100 +321,105 @@ def _pick_sample_rate(span_hz: int, caps: SDRCapabilities, sdr_type: SDRType) ->
|
||||
|
||||
def _resolve_sdr_type(sdr_type_str: str) -> SDRType:
|
||||
"""Convert client sdr_type string to SDRType enum."""
|
||||
mapping = {
|
||||
'rtlsdr': SDRType.RTL_SDR,
|
||||
'rtl_sdr': SDRType.RTL_SDR,
|
||||
'hackrf': SDRType.HACKRF,
|
||||
'limesdr': SDRType.LIME_SDR,
|
||||
'lime_sdr': SDRType.LIME_SDR,
|
||||
'airspy': SDRType.AIRSPY,
|
||||
'sdrplay': SDRType.SDRPLAY,
|
||||
}
|
||||
return mapping.get(sdr_type_str.lower(), SDRType.RTL_SDR)
|
||||
|
||||
|
||||
def _build_dummy_device(device_index: int, sdr_type: SDRType) -> SDRDevice:
|
||||
"""Build a minimal SDRDevice for command building."""
|
||||
builder = SDRFactory.get_builder(sdr_type)
|
||||
caps = builder.get_capabilities()
|
||||
return SDRDevice(
|
||||
sdr_type=sdr_type,
|
||||
index=device_index,
|
||||
name=f'{sdr_type.value}-{device_index}',
|
||||
serial='N/A',
|
||||
driver=sdr_type.value,
|
||||
capabilities=caps,
|
||||
)
|
||||
|
||||
|
||||
def init_waterfall_websocket(app: Flask):
|
||||
"""Initialize WebSocket waterfall streaming."""
|
||||
if not WEBSOCKET_AVAILABLE:
|
||||
logger.warning("flask-sock not installed, WebSocket waterfall disabled")
|
||||
return
|
||||
|
||||
sock = Sock(app)
|
||||
|
||||
@sock.route('/ws/waterfall')
|
||||
def waterfall_stream(ws):
|
||||
"""WebSocket endpoint for real-time waterfall streaming."""
|
||||
logger.info("WebSocket waterfall client connected")
|
||||
|
||||
# Import app module for device claiming
|
||||
import app as app_module
|
||||
|
||||
mapping = {
|
||||
'rtlsdr': SDRType.RTL_SDR,
|
||||
'rtl_sdr': SDRType.RTL_SDR,
|
||||
'hackrf': SDRType.HACKRF,
|
||||
'limesdr': SDRType.LIME_SDR,
|
||||
'lime_sdr': SDRType.LIME_SDR,
|
||||
'airspy': SDRType.AIRSPY,
|
||||
'sdrplay': SDRType.SDRPLAY,
|
||||
}
|
||||
return mapping.get(sdr_type_str.lower(), SDRType.RTL_SDR)
|
||||
|
||||
|
||||
def _build_dummy_device(device_index: int, sdr_type: SDRType) -> SDRDevice:
|
||||
"""Build a minimal SDRDevice for command building."""
|
||||
builder = SDRFactory.get_builder(sdr_type)
|
||||
caps = builder.get_capabilities()
|
||||
return SDRDevice(
|
||||
sdr_type=sdr_type,
|
||||
index=device_index,
|
||||
name=f'{sdr_type.value}-{device_index}',
|
||||
serial='N/A',
|
||||
driver=sdr_type.value,
|
||||
capabilities=caps,
|
||||
)
|
||||
|
||||
|
||||
def init_waterfall_websocket(app: Flask):
|
||||
"""Initialize WebSocket waterfall streaming."""
|
||||
if not WEBSOCKET_AVAILABLE:
|
||||
logger.warning("flask-sock not installed, WebSocket waterfall disabled")
|
||||
return
|
||||
|
||||
sock = Sock(app)
|
||||
|
||||
@sock.route('/ws/waterfall')
|
||||
def waterfall_stream(ws):
|
||||
"""WebSocket endpoint for real-time waterfall streaming."""
|
||||
logger.info("WebSocket waterfall client connected")
|
||||
|
||||
# Import app module for device claiming
|
||||
import app as app_module
|
||||
|
||||
iq_process = None
|
||||
reader_thread = None
|
||||
stop_event = threading.Event()
|
||||
claimed_device = None
|
||||
claimed_sdr_type = 'rtlsdr'
|
||||
my_generation = None # tracks which capture generation this handler owns
|
||||
capture_center_mhz = 0.0
|
||||
capture_start_freq = 0.0
|
||||
capture_end_freq = 0.0
|
||||
capture_span_mhz = 0.0
|
||||
# Queue for outgoing messages — only the main loop touches ws.send()
|
||||
send_queue = queue.Queue(maxsize=120)
|
||||
|
||||
try:
|
||||
while True:
|
||||
# Drain send queue first (non-blocking)
|
||||
while True:
|
||||
try:
|
||||
outgoing = send_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
try:
|
||||
ws.send(outgoing)
|
||||
except Exception:
|
||||
stop_event.set()
|
||||
break
|
||||
|
||||
try:
|
||||
|
||||
try:
|
||||
while True:
|
||||
# Drain send queue first (non-blocking)
|
||||
while True:
|
||||
try:
|
||||
outgoing = send_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
try:
|
||||
ws.send(outgoing)
|
||||
except Exception:
|
||||
stop_event.set()
|
||||
break
|
||||
|
||||
try:
|
||||
msg = ws.receive(timeout=0.01)
|
||||
except Exception as e:
|
||||
err = str(e).lower()
|
||||
if "closed" in err:
|
||||
break
|
||||
if "timed out" not in err:
|
||||
logger.error(f"WebSocket receive error: {e}")
|
||||
continue
|
||||
|
||||
if msg is None:
|
||||
# simple-websocket returns None on timeout AND on
|
||||
# close; check ws.connected to tell them apart.
|
||||
if not ws.connected:
|
||||
break
|
||||
if stop_event.is_set():
|
||||
break
|
||||
continue
|
||||
|
||||
try:
|
||||
data = json.loads(msg)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
|
||||
cmd = data.get('cmd')
|
||||
|
||||
except Exception as e:
|
||||
err = str(e).lower()
|
||||
if "closed" in err:
|
||||
break
|
||||
if "timed out" not in err:
|
||||
logger.error(f"WebSocket receive error: {e}")
|
||||
continue
|
||||
|
||||
if msg is None:
|
||||
# simple-websocket returns None on timeout AND on
|
||||
# close; check ws.connected to tell them apart.
|
||||
if not ws.connected:
|
||||
break
|
||||
if stop_event.is_set():
|
||||
break
|
||||
continue
|
||||
|
||||
try:
|
||||
data = json.loads(msg)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
|
||||
cmd = data.get('cmd')
|
||||
|
||||
if cmd == 'start':
|
||||
shared_before = get_shared_capture_status()
|
||||
keep_monitor_enabled = bool(shared_before.get('monitor_enabled'))
|
||||
keep_monitor_modulation = str(shared_before.get('monitor_modulation', 'wfm'))
|
||||
keep_monitor_squelch = int(shared_before.get('monitor_squelch', 0) or 0)
|
||||
# Stop any existing capture
|
||||
was_restarting = iq_process is not None
|
||||
stop_event.set()
|
||||
@@ -394,9 +430,11 @@ def init_waterfall_websocket(app: Flask):
|
||||
unregister_process(iq_process)
|
||||
iq_process = None
|
||||
if claimed_device is not None:
|
||||
app_module.release_sdr_device(claimed_device)
|
||||
app_module.release_sdr_device(claimed_device, claimed_sdr_type)
|
||||
claimed_device = None
|
||||
_set_shared_capture_state(running=False)
|
||||
claimed_sdr_type = 'rtlsdr'
|
||||
_set_shared_capture_state(running=False, generation=my_generation)
|
||||
my_generation = None
|
||||
stop_event.clear()
|
||||
# Flush stale frames from previous capture
|
||||
while not send_queue.empty():
|
||||
@@ -411,6 +449,12 @@ def init_waterfall_websocket(app: Flask):
|
||||
# Parse config
|
||||
try:
|
||||
center_freq_mhz = _parse_center_freq_mhz(data)
|
||||
requested_vfo_mhz = float(
|
||||
data.get(
|
||||
'vfo_freq_mhz',
|
||||
data.get('frequency_mhz', center_freq_mhz),
|
||||
)
|
||||
)
|
||||
span_mhz = _parse_span_mhz(data)
|
||||
gain_raw = data.get('gain')
|
||||
if gain_raw is None or str(gain_raw).lower() == 'auto':
|
||||
@@ -461,9 +505,20 @@ def init_waterfall_websocket(app: Flask):
|
||||
effective_span_mhz = sample_rate / 1e6
|
||||
start_freq = center_freq_mhz - effective_span_mhz / 2
|
||||
end_freq = center_freq_mhz + effective_span_mhz / 2
|
||||
target_vfo_mhz = requested_vfo_mhz
|
||||
if not (start_freq <= target_vfo_mhz <= end_freq):
|
||||
target_vfo_mhz = center_freq_mhz
|
||||
|
||||
# Claim the device
|
||||
claim_err = app_module.claim_sdr_device(device_index, 'waterfall')
|
||||
# Claim the device (retry when restarting to allow
|
||||
# the kernel time to release the USB handle).
|
||||
max_claim_attempts = 4 if was_restarting else 1
|
||||
claim_err = None
|
||||
for _claim_attempt in range(max_claim_attempts):
|
||||
claim_err = app_module.claim_sdr_device(device_index, 'waterfall', sdr_type_str)
|
||||
if not claim_err:
|
||||
break
|
||||
if _claim_attempt < max_claim_attempts - 1:
|
||||
time.sleep(0.4)
|
||||
if claim_err:
|
||||
ws.send(json.dumps({
|
||||
'status': 'error',
|
||||
@@ -472,6 +527,7 @@ def init_waterfall_websocket(app: Flask):
|
||||
}))
|
||||
continue
|
||||
claimed_device = device_index
|
||||
claimed_sdr_type = sdr_type_str
|
||||
|
||||
# Build I/Q capture command
|
||||
try:
|
||||
@@ -485,14 +541,26 @@ def init_waterfall_websocket(app: Flask):
|
||||
bias_t=bias_t,
|
||||
)
|
||||
except NotImplementedError as e:
|
||||
app_module.release_sdr_device(device_index)
|
||||
app_module.release_sdr_device(device_index, sdr_type_str)
|
||||
claimed_device = None
|
||||
claimed_sdr_type = 'rtlsdr'
|
||||
ws.send(json.dumps({
|
||||
'status': 'error',
|
||||
'message': str(e),
|
||||
}))
|
||||
continue
|
||||
|
||||
# Pre-flight: check the capture binary exists
|
||||
if not shutil.which(iq_cmd[0]):
|
||||
app_module.release_sdr_device(device_index, sdr_type_str)
|
||||
claimed_device = None
|
||||
claimed_sdr_type = 'rtlsdr'
|
||||
ws.send(json.dumps({
|
||||
'status': 'error',
|
||||
'message': f'Required tool "{iq_cmd[0]}" not found. Install SoapySDR tools (rx_sdr).',
|
||||
}))
|
||||
continue
|
||||
|
||||
# Spawn I/Q capture process (retry to handle USB release lag)
|
||||
max_attempts = 3 if was_restarting else 1
|
||||
try:
|
||||
@@ -505,7 +573,7 @@ def init_waterfall_websocket(app: Flask):
|
||||
iq_process = subprocess.Popen(
|
||||
iq_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
bufsize=0,
|
||||
)
|
||||
register_process(iq_process)
|
||||
@@ -513,17 +581,23 @@ def init_waterfall_websocket(app: Flask):
|
||||
# Brief check that process started
|
||||
time.sleep(0.3)
|
||||
if iq_process.poll() is not None:
|
||||
stderr_out = ''
|
||||
if iq_process.stderr:
|
||||
with suppress(Exception):
|
||||
stderr_out = iq_process.stderr.read().decode('utf-8', errors='replace').strip()
|
||||
unregister_process(iq_process)
|
||||
iq_process = None
|
||||
if attempt < max_attempts - 1:
|
||||
logger.info(
|
||||
f"I/Q process exited immediately, "
|
||||
f"retrying ({attempt + 1}/{max_attempts})..."
|
||||
+ (f" stderr: {stderr_out}" if stderr_out else "")
|
||||
)
|
||||
time.sleep(0.5)
|
||||
continue
|
||||
detail = f": {stderr_out}" if stderr_out else ""
|
||||
raise RuntimeError(
|
||||
"I/Q capture process exited immediately"
|
||||
f"I/Q capture process exited immediately{detail}"
|
||||
)
|
||||
break # Process started successfully
|
||||
except Exception as e:
|
||||
@@ -532,8 +606,9 @@ def init_waterfall_websocket(app: Flask):
|
||||
safe_terminate(iq_process)
|
||||
unregister_process(iq_process)
|
||||
iq_process = None
|
||||
app_module.release_sdr_device(device_index)
|
||||
app_module.release_sdr_device(device_index, sdr_type_str)
|
||||
claimed_device = None
|
||||
claimed_sdr_type = 'rtlsdr'
|
||||
ws.send(json.dumps({
|
||||
'status': 'error',
|
||||
'message': f'Failed to start I/Q capture: {e}',
|
||||
@@ -543,9 +618,8 @@ def init_waterfall_websocket(app: Flask):
|
||||
capture_center_mhz = center_freq_mhz
|
||||
capture_start_freq = start_freq
|
||||
capture_end_freq = end_freq
|
||||
capture_span_mhz = effective_span_mhz
|
||||
|
||||
_set_shared_capture_state(
|
||||
my_generation = _set_shared_capture_state(
|
||||
running=True,
|
||||
device=device_index,
|
||||
center_mhz=center_freq_mhz,
|
||||
@@ -553,10 +627,10 @@ def init_waterfall_websocket(app: Flask):
|
||||
sample_rate=sample_rate,
|
||||
)
|
||||
_set_shared_monitor(
|
||||
enabled=False,
|
||||
frequency_mhz=center_freq_mhz,
|
||||
modulation='wfm',
|
||||
squelch=0,
|
||||
enabled=keep_monitor_enabled,
|
||||
frequency_mhz=target_vfo_mhz,
|
||||
modulation=keep_monitor_modulation,
|
||||
squelch=keep_monitor_squelch,
|
||||
)
|
||||
|
||||
# Send started confirmation
|
||||
@@ -570,7 +644,7 @@ def init_waterfall_websocket(app: Flask):
|
||||
'effective_span_mhz': effective_span_mhz,
|
||||
'db_min': db_min,
|
||||
'db_max': db_max,
|
||||
'vfo_freq_mhz': center_freq_mhz,
|
||||
'vfo_freq_mhz': target_vfo_mhz,
|
||||
}))
|
||||
|
||||
# Start reader thread — puts frames on queue, never calls ws.send()
|
||||
@@ -585,6 +659,8 @@ def init_waterfall_websocket(app: Flask):
|
||||
timeslice_samples = max(required_fft_samples, int(_sample_rate / max(1, _fps)))
|
||||
bytes_per_frame = timeslice_samples * 2
|
||||
frame_interval = 1.0 / _fps
|
||||
monitor_rotator_phase = 0.0
|
||||
last_monitor_offset_hz = None
|
||||
|
||||
try:
|
||||
while not stop_evt.is_set():
|
||||
@@ -629,16 +705,30 @@ def init_waterfall_websocket(app: Flask):
|
||||
|
||||
monitor_cfg = _snapshot_monitor_config()
|
||||
if monitor_cfg:
|
||||
audio_chunk = _demodulate_monitor_audio(
|
||||
center_mhz_cfg = float(monitor_cfg.get('center_mhz', _center_mhz))
|
||||
monitor_mhz_cfg = float(monitor_cfg.get('monitor_freq_mhz', _center_mhz))
|
||||
offset_hz = (monitor_mhz_cfg - center_mhz_cfg) * 1e6
|
||||
if (
|
||||
last_monitor_offset_hz is None
|
||||
or abs(offset_hz - last_monitor_offset_hz) > 1.0
|
||||
):
|
||||
monitor_rotator_phase = 0.0
|
||||
last_monitor_offset_hz = offset_hz
|
||||
|
||||
audio_chunk, monitor_rotator_phase = _demodulate_monitor_audio(
|
||||
samples=samples,
|
||||
sample_rate=_sample_rate,
|
||||
center_mhz=monitor_cfg.get('center_mhz', _center_mhz),
|
||||
monitor_freq_mhz=monitor_cfg.get('monitor_freq_mhz', _center_mhz),
|
||||
center_mhz=center_mhz_cfg,
|
||||
monitor_freq_mhz=monitor_mhz_cfg,
|
||||
modulation=monitor_cfg.get('modulation', 'wfm'),
|
||||
squelch=int(monitor_cfg.get('squelch', 0)),
|
||||
rotator_phase=monitor_rotator_phase,
|
||||
)
|
||||
if audio_chunk:
|
||||
_push_shared_audio_chunk(audio_chunk)
|
||||
else:
|
||||
monitor_rotator_phase = 0.0
|
||||
last_monitor_offset_hz = None
|
||||
|
||||
# Pace to target FPS
|
||||
elapsed = time.monotonic() - frame_start
|
||||
@@ -716,33 +806,36 @@ def init_waterfall_websocket(app: Flask):
|
||||
reader_thread.join(timeout=2)
|
||||
reader_thread = None
|
||||
if iq_process:
|
||||
safe_terminate(iq_process)
|
||||
unregister_process(iq_process)
|
||||
iq_process = None
|
||||
safe_terminate(iq_process)
|
||||
unregister_process(iq_process)
|
||||
iq_process = None
|
||||
if claimed_device is not None:
|
||||
app_module.release_sdr_device(claimed_device)
|
||||
app_module.release_sdr_device(claimed_device, claimed_sdr_type)
|
||||
claimed_device = None
|
||||
_set_shared_capture_state(running=False)
|
||||
claimed_sdr_type = 'rtlsdr'
|
||||
_set_shared_capture_state(running=False, generation=my_generation)
|
||||
my_generation = None
|
||||
stop_event.clear()
|
||||
ws.send(json.dumps({'status': 'stopped'}))
|
||||
|
||||
except Exception as e:
|
||||
logger.info(f"WebSocket waterfall closed: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.info(f"WebSocket waterfall closed: {e}")
|
||||
finally:
|
||||
# Cleanup
|
||||
# Cleanup — use generation guard so a stale handler cannot
|
||||
# clobber shared state owned by a newer WS connection.
|
||||
stop_event.set()
|
||||
if reader_thread and reader_thread.is_alive():
|
||||
reader_thread.join(timeout=2)
|
||||
if iq_process:
|
||||
safe_terminate(iq_process)
|
||||
unregister_process(iq_process)
|
||||
if iq_process:
|
||||
safe_terminate(iq_process)
|
||||
unregister_process(iq_process)
|
||||
if claimed_device is not None:
|
||||
app_module.release_sdr_device(claimed_device)
|
||||
_set_shared_capture_state(running=False)
|
||||
app_module.release_sdr_device(claimed_device, claimed_sdr_type)
|
||||
_set_shared_capture_state(running=False, generation=my_generation)
|
||||
# Complete WebSocket close handshake, then shut down the
|
||||
# raw socket so Werkzeug cannot write its HTTP 200 response
|
||||
# on top of the WebSocket stream (which browsers see as
|
||||
# "Invalid frame header").
|
||||
# on top of the WebSocket stream (which browsers see as
|
||||
# "Invalid frame header").
|
||||
with suppress(Exception):
|
||||
ws.close()
|
||||
with suppress(Exception):
|
||||
|
||||
+109
-63
@@ -8,16 +8,26 @@ from __future__ import annotations
|
||||
|
||||
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.responses import api_error
|
||||
from utils.sse import sse_stream
|
||||
from utils.validation import validate_device_index, validate_gain, validate_latitude, validate_longitude, validate_elevation
|
||||
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 (
|
||||
DEFAULT_SAMPLE_RATE,
|
||||
WEATHER_SATELLITES,
|
||||
CaptureProgress,
|
||||
get_weather_sat_decoder,
|
||||
is_weather_sat_available,
|
||||
CaptureProgress,
|
||||
WEATHER_SATELLITES,
|
||||
)
|
||||
|
||||
logger = get_logger('intercept.weather_sat')
|
||||
@@ -40,6 +50,35 @@ def _progress_callback(progress: CaptureProgress) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _release_weather_sat_device(device_index: int) -> None:
|
||||
"""Release an SDR device only if weather-sat currently owns it."""
|
||||
if device_index < 0:
|
||||
return
|
||||
|
||||
try:
|
||||
import app as app_module
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
owner = None
|
||||
get_status = getattr(app_module, 'get_sdr_device_status', None)
|
||||
if callable(get_status):
|
||||
try:
|
||||
owner = get_status().get(device_index)
|
||||
except Exception:
|
||||
owner = None
|
||||
|
||||
if owner and owner != 'weather_sat':
|
||||
logger.debug(
|
||||
'Skipping SDR release for device %s owned by %s',
|
||||
device_index,
|
||||
owner,
|
||||
)
|
||||
return
|
||||
|
||||
app_module.release_sdr_device(device_index)
|
||||
|
||||
|
||||
@weather_sat_bp.route('/status')
|
||||
def get_status():
|
||||
"""Get weather satellite decoder status.
|
||||
@@ -106,6 +145,13 @@ def start_capture():
|
||||
})
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
|
||||
if sdr_type_str != 'rtlsdr':
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'{sdr_type_str.replace("_", " ").title()} is not yet supported for this mode. Please use an RTL-SDR device.'
|
||||
}), 400
|
||||
|
||||
# Validate satellite
|
||||
satellite = data.get('satellite')
|
||||
@@ -128,18 +174,26 @@ def start_capture():
|
||||
|
||||
bias_t = bool(data.get('bias_t', False))
|
||||
|
||||
# Claim SDR device
|
||||
try:
|
||||
import app as app_module
|
||||
error = app_module.claim_sdr_device(device_index, 'weather_sat')
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error,
|
||||
}), 409
|
||||
except ImportError:
|
||||
pass
|
||||
# Check for rtl_tcp (remote SDR) connection
|
||||
rtl_tcp_host = data.get('rtl_tcp_host')
|
||||
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
||||
|
||||
if rtl_tcp_host:
|
||||
try:
|
||||
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
|
||||
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
|
||||
except ValueError as e:
|
||||
return api_error(str(e), 400)
|
||||
|
||||
# Claim SDR device (skip for remote rtl_tcp)
|
||||
if not rtl_tcp_host:
|
||||
try:
|
||||
import app as app_module
|
||||
error = app_module.claim_sdr_device(device_index, 'weather_sat', sdr_type_str)
|
||||
if error:
|
||||
return api_error(error, 409, error_type='DEVICE_BUSY')
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Clear queue
|
||||
while not _weather_sat_queue.empty():
|
||||
@@ -152,19 +206,19 @@ def start_capture():
|
||||
decoder.set_callback(_progress_callback)
|
||||
|
||||
def _release_device():
|
||||
try:
|
||||
import app as app_module
|
||||
app_module.release_sdr_device(device_index)
|
||||
except ImportError:
|
||||
pass
|
||||
if not rtl_tcp_host:
|
||||
_release_weather_sat_device(device_index)
|
||||
|
||||
decoder.set_on_complete(_release_device)
|
||||
|
||||
success = decoder.start(
|
||||
success, error_msg = decoder.start(
|
||||
satellite=satellite,
|
||||
device_index=device_index,
|
||||
gain=gain,
|
||||
sample_rate=DEFAULT_SAMPLE_RATE,
|
||||
bias_t=bias_t,
|
||||
rtl_tcp_host=rtl_tcp_host,
|
||||
rtl_tcp_port=rtl_tcp_port,
|
||||
)
|
||||
|
||||
if success:
|
||||
@@ -181,7 +235,7 @@ def start_capture():
|
||||
_release_device()
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to start capture'
|
||||
'message': error_msg or 'Failed to start capture'
|
||||
}), 500
|
||||
|
||||
|
||||
@@ -283,7 +337,7 @@ def test_decode():
|
||||
decoder.set_callback(_progress_callback)
|
||||
decoder.set_on_complete(None)
|
||||
|
||||
success = decoder.start_from_file(
|
||||
success, error_msg = decoder.start_from_file(
|
||||
satellite=satellite,
|
||||
input_file=input_file,
|
||||
sample_rate=sample_rate,
|
||||
@@ -302,7 +356,7 @@ def test_decode():
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to start file decode'
|
||||
'message': error_msg or 'Failed to start file decode'
|
||||
}), 500
|
||||
|
||||
|
||||
@@ -318,12 +372,7 @@ def stop_capture():
|
||||
|
||||
decoder.stop()
|
||||
|
||||
# Release SDR device
|
||||
try:
|
||||
import app as app_module
|
||||
app_module.release_sdr_device(device_index)
|
||||
except ImportError:
|
||||
pass
|
||||
_release_weather_sat_device(device_index)
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
@@ -373,15 +422,15 @@ def get_image(filename: str):
|
||||
|
||||
# Security: only allow safe filenames
|
||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
||||
return api_error('Invalid filename', 400)
|
||||
|
||||
if not (filename.endswith('.png') or filename.endswith('.jpg') or filename.endswith('.jpeg')):
|
||||
return jsonify({'status': 'error', 'message': 'Only PNG/JPG files supported'}), 400
|
||||
return api_error('Only PNG/JPG files supported', 400)
|
||||
|
||||
image_path = decoder._output_dir / filename
|
||||
|
||||
if not image_path.exists():
|
||||
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||
return api_error('Image not found', 404)
|
||||
|
||||
mimetype = 'image/png' if filename.endswith('.png') else 'image/jpeg'
|
||||
return send_file(image_path, mimetype=mimetype)
|
||||
@@ -400,12 +449,12 @@ def delete_image(filename: str):
|
||||
decoder = get_weather_sat_decoder()
|
||||
|
||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
||||
return api_error('Invalid filename', 400)
|
||||
|
||||
if decoder.delete_image(filename):
|
||||
return jsonify({'status': 'deleted', 'filename': filename})
|
||||
else:
|
||||
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||
return api_error('Image not found', 404)
|
||||
|
||||
|
||||
@weather_sat_bp.route('/images', methods=['DELETE'])
|
||||
@@ -456,17 +505,14 @@ def get_passes():
|
||||
raw_lon = request.args.get('longitude')
|
||||
|
||||
if raw_lat is None or raw_lon is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'latitude and longitude parameters required'
|
||||
}), 400
|
||||
return api_error('latitude and longitude parameters required', 400)
|
||||
|
||||
try:
|
||||
lat = validate_latitude(raw_lat)
|
||||
lon = validate_longitude(raw_lon)
|
||||
except ValueError as e:
|
||||
logger.warning('Invalid coordinates in get_passes: %s', e)
|
||||
return jsonify({'status': 'error', 'message': 'Invalid coordinates'}), 400
|
||||
return api_error('Invalid coordinates', 400)
|
||||
|
||||
hours = max(1, min(request.args.get('hours', 24, type=int), 72))
|
||||
min_elevation = max(0, min(request.args.get('min_elevation', 15, type=float), 90))
|
||||
@@ -563,26 +609,26 @@ def enable_schedule():
|
||||
'message': 'Invalid parameter value'
|
||||
}), 400
|
||||
|
||||
scheduler = get_weather_sat_scheduler()
|
||||
scheduler.set_callbacks(_progress_callback, _scheduler_event_callback)
|
||||
|
||||
try:
|
||||
result = scheduler.enable(
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
min_elevation=min_elev,
|
||||
device=device,
|
||||
gain=gain_val,
|
||||
bias_t=bool(data.get('bias_t', False)),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to enable weather sat scheduler")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to enable scheduler'
|
||||
}), 500
|
||||
|
||||
return jsonify({'status': 'ok', **result})
|
||||
scheduler = get_weather_sat_scheduler()
|
||||
scheduler.set_callbacks(_progress_callback, _scheduler_event_callback)
|
||||
|
||||
try:
|
||||
result = scheduler.enable(
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
min_elevation=min_elev,
|
||||
device=device,
|
||||
gain=gain_val,
|
||||
bias_t=bool(data.get('bias_t', False)),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to enable weather sat scheduler")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to enable scheduler'
|
||||
}), 500
|
||||
|
||||
return jsonify({'status': 'ok', **result})
|
||||
|
||||
|
||||
@weather_sat_bp.route('/schedule/disable', methods=['POST'])
|
||||
@@ -624,10 +670,10 @@ def skip_pass(pass_id: str):
|
||||
from utils.weather_sat_scheduler import get_weather_sat_scheduler
|
||||
|
||||
if not pass_id.replace('_', '').replace('-', '').isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid pass ID'}), 400
|
||||
return api_error('Invalid pass ID', 400)
|
||||
|
||||
scheduler = get_weather_sat_scheduler()
|
||||
if scheduler.skip_pass(pass_id):
|
||||
return jsonify({'status': 'skipped', 'pass_id': pass_id})
|
||||
else:
|
||||
return jsonify({'status': 'error', 'message': 'Pass not found or already processed'}), 404
|
||||
return api_error('Pass not found or already processed', 404)
|
||||
|
||||
+20
-30
@@ -9,9 +9,10 @@ import re
|
||||
import struct
|
||||
import threading
|
||||
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:
|
||||
from flask_sock import Sock
|
||||
@@ -19,7 +20,9 @@ try:
|
||||
except ImportError:
|
||||
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
|
||||
|
||||
logger = get_logger('intercept.websdr')
|
||||
@@ -36,7 +39,7 @@ _cache_timestamp: float = 0
|
||||
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."""
|
||||
if not coord_str:
|
||||
return None
|
||||
@@ -68,8 +71,8 @@ KIWI_DATA_URLS = [
|
||||
|
||||
def _fetch_kiwi_receivers() -> list[dict]:
|
||||
"""Fetch the KiwiSDR receiver list from the public directory."""
|
||||
import urllib.request
|
||||
import json
|
||||
import urllib.request
|
||||
|
||||
receivers = []
|
||||
raw = None
|
||||
@@ -226,8 +229,7 @@ def list_receivers() -> Response:
|
||||
if r.get('freq_lo', 0) <= freq_khz <= r.get('freq_hi', 30000)
|
||||
]
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
return api_success(data={
|
||||
'receivers': filtered[:100],
|
||||
'total': len(filtered),
|
||||
'cached_total': len(receivers),
|
||||
@@ -242,7 +244,7 @@ def nearest_receivers() -> Response:
|
||||
freq_khz = request.args.get('freq_khz', type=float)
|
||||
|
||||
if lat is None or lon is None:
|
||||
return jsonify({'status': 'error', 'message': 'lat and lon are required'}), 400
|
||||
return api_error('lat and lon are required', 400)
|
||||
|
||||
receivers = get_receivers()
|
||||
|
||||
@@ -264,10 +266,7 @@ def nearest_receivers() -> Response:
|
||||
|
||||
with_distance.sort(key=lambda x: x['distance_km'])
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'receivers': with_distance[:10],
|
||||
})
|
||||
return api_success(data={'receivers': with_distance[:10]})
|
||||
|
||||
|
||||
@websdr_bp.route('/spy-station/<station_id>/receivers')
|
||||
@@ -276,7 +275,7 @@ def spy_station_receivers(station_id: str) -> Response:
|
||||
try:
|
||||
from routes.spy_stations import STATIONS
|
||||
except ImportError:
|
||||
return jsonify({'status': 'error', 'message': 'Spy stations module not available'}), 503
|
||||
return api_error('Spy stations module not available', 503)
|
||||
|
||||
# Find the station
|
||||
station = None
|
||||
@@ -286,7 +285,7 @@ def spy_station_receivers(station_id: str) -> Response:
|
||||
break
|
||||
|
||||
if not station:
|
||||
return jsonify({'status': 'error', 'message': 'Station not found'}), 404
|
||||
return api_error('Station not found', 404)
|
||||
|
||||
# Get primary frequency
|
||||
freq_khz = None
|
||||
@@ -298,7 +297,7 @@ def spy_station_receivers(station_id: str) -> Response:
|
||||
freq_khz = station['frequencies'][0].get('freq_khz')
|
||||
|
||||
if freq_khz is None:
|
||||
return jsonify({'status': 'error', 'message': 'No frequency found for station'}), 404
|
||||
return api_error('No frequency found for station', 404)
|
||||
|
||||
receivers = get_receivers()
|
||||
|
||||
@@ -308,8 +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)
|
||||
]
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
return api_success(data={
|
||||
'station': {
|
||||
'id': station['id'],
|
||||
'name': station.get('name', ''),
|
||||
@@ -338,7 +336,7 @@ def websdr_status() -> Response:
|
||||
# KIWISDR AUDIO PROXY
|
||||
# ============================================
|
||||
|
||||
_kiwi_client: Optional[KiwiSDRClient] = None
|
||||
_kiwi_client: KiwiSDRClient | None = None
|
||||
_kiwi_lock = threading.Lock()
|
||||
_kiwi_audio_queue: queue.Queue = queue.Queue(maxsize=200)
|
||||
|
||||
@@ -390,26 +388,18 @@ def _handle_kiwi_command(ws, cmd: str, data: dict) -> None:
|
||||
try:
|
||||
_kiwi_audio_queue.put_nowait(header + pcm_bytes)
|
||||
except queue.Full:
|
||||
try:
|
||||
with contextlib.suppress(queue.Empty):
|
||||
_kiwi_audio_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
try:
|
||||
with contextlib.suppress(queue.Full):
|
||||
_kiwi_audio_queue.put_nowait(header + pcm_bytes)
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
def on_error(msg):
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
ws.send(json.dumps({'type': 'error', 'message': msg}))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def on_disconnect():
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
ws.send(json.dumps({'type': 'disconnected'}))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
with _kiwi_lock:
|
||||
_kiwi_client = KiwiSDRClient(
|
||||
|
||||
+472
@@ -0,0 +1,472 @@
|
||||
"""WeFax (Weather Fax) decoder routes.
|
||||
|
||||
Provides endpoints for decoding HF weather fax transmissions from
|
||||
maritime/aviation weather services worldwide.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import queue
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request, send_file
|
||||
|
||||
import app as app_module
|
||||
from utils.logging import get_logger
|
||||
from utils.responses import api_error
|
||||
from utils.sdr import SDRType
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.validation import validate_frequency
|
||||
from utils.wefax import get_wefax_decoder
|
||||
from utils.wefax_stations import (
|
||||
WEFAX_USB_ALIGNMENT_OFFSET_KHZ,
|
||||
get_current_broadcasts,
|
||||
get_station,
|
||||
load_stations,
|
||||
resolve_tuning_frequency_khz,
|
||||
)
|
||||
|
||||
logger = get_logger('intercept.wefax')
|
||||
|
||||
wefax_bp = Blueprint('wefax', __name__, url_prefix='/wefax')
|
||||
|
||||
# SSE progress queue
|
||||
_wefax_queue: queue.Queue = queue.Queue(maxsize=100)
|
||||
|
||||
# Track active SDR device
|
||||
wefax_active_device: int | None = None
|
||||
wefax_active_sdr_type: str | None = None
|
||||
|
||||
|
||||
def _progress_callback(data: dict) -> None:
|
||||
"""Callback to queue progress updates for SSE stream."""
|
||||
global wefax_active_device, wefax_active_sdr_type
|
||||
|
||||
try:
|
||||
_wefax_queue.put_nowait(data)
|
||||
except queue.Full:
|
||||
try:
|
||||
_wefax_queue.get_nowait()
|
||||
_wefax_queue.put_nowait(data)
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
# Ensure manually claimed SDR devices are always released when a
|
||||
# decode session ends on its own (complete/error/stopped).
|
||||
if (
|
||||
isinstance(data, dict)
|
||||
and data.get('type') == 'wefax_progress'
|
||||
and data.get('status') in ('complete', 'error', 'stopped')
|
||||
and wefax_active_device is not None
|
||||
):
|
||||
app_module.release_sdr_device(wefax_active_device, wefax_active_sdr_type or 'rtlsdr')
|
||||
wefax_active_device = None
|
||||
wefax_active_sdr_type = None
|
||||
|
||||
|
||||
@wefax_bp.route('/status')
|
||||
def get_status():
|
||||
"""Get WeFax decoder status."""
|
||||
decoder = get_wefax_decoder()
|
||||
return jsonify({
|
||||
'available': True,
|
||||
'running': decoder.is_running,
|
||||
'image_count': len(decoder.get_images()),
|
||||
})
|
||||
|
||||
|
||||
@wefax_bp.route('/start', methods=['POST'])
|
||||
def start_decoder():
|
||||
"""Start WeFax decoder.
|
||||
|
||||
JSON body:
|
||||
{
|
||||
"frequency_khz": 4298,
|
||||
"station": "NOJ",
|
||||
"device": 0,
|
||||
"gain": 40,
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"direct_sampling": true,
|
||||
"frequency_reference": "auto" // auto, carrier, or dial
|
||||
}
|
||||
"""
|
||||
decoder = get_wefax_decoder()
|
||||
|
||||
if decoder.is_running:
|
||||
return jsonify({
|
||||
'status': 'already_running',
|
||||
'message': 'WeFax decoder is already running',
|
||||
})
|
||||
|
||||
# Clear queue
|
||||
while not _wefax_queue.empty():
|
||||
try:
|
||||
_wefax_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
# Validate frequency (required)
|
||||
frequency_khz = data.get('frequency_khz')
|
||||
if frequency_khz is None:
|
||||
return api_error('frequency_khz is required', 400)
|
||||
|
||||
try:
|
||||
frequency_khz = float(frequency_khz)
|
||||
# WeFax operates on HF: 2-30 MHz (2000-30000 kHz)
|
||||
freq_mhz = frequency_khz / 1000.0
|
||||
validate_frequency(freq_mhz, min_mhz=2.0, max_mhz=30.0)
|
||||
except (TypeError, ValueError) as e:
|
||||
return api_error(f'Invalid frequency: {e}', 400)
|
||||
|
||||
station = str(data.get('station', '')).strip()
|
||||
device_index = data.get('device', 0)
|
||||
gain = float(data.get('gain', 40.0))
|
||||
ioc = int(data.get('ioc', 576))
|
||||
lpm = int(data.get('lpm', 120))
|
||||
direct_sampling = bool(data.get('direct_sampling', True))
|
||||
frequency_reference = str(data.get('frequency_reference', 'auto')).strip().lower()
|
||||
|
||||
sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower()
|
||||
with contextlib.suppress(ValueError):
|
||||
SDRType(sdr_type_str)
|
||||
if not frequency_reference:
|
||||
frequency_reference = 'auto'
|
||||
|
||||
try:
|
||||
tuned_frequency_khz, resolved_reference, usb_offset_applied = (
|
||||
resolve_tuning_frequency_khz(
|
||||
listed_frequency_khz=frequency_khz,
|
||||
station_callsign=station,
|
||||
frequency_reference=frequency_reference,
|
||||
)
|
||||
)
|
||||
tuned_mhz = tuned_frequency_khz / 1000.0
|
||||
validate_frequency(tuned_mhz, min_mhz=2.0, max_mhz=30.0)
|
||||
except ValueError as e:
|
||||
return api_error(f'Invalid frequency settings: {e}', 400)
|
||||
|
||||
# Validate IOC and LPM
|
||||
if ioc not in (288, 576):
|
||||
return api_error('IOC must be 288 or 576', 400)
|
||||
|
||||
if lpm not in (60, 120):
|
||||
return api_error('LPM must be 60 or 120', 400)
|
||||
|
||||
# Claim SDR device
|
||||
global wefax_active_device, wefax_active_sdr_type
|
||||
device_int = int(device_index)
|
||||
error = app_module.claim_sdr_device(device_int, 'wefax', sdr_type_str)
|
||||
if error:
|
||||
return api_error(error, 409, error_type='DEVICE_BUSY')
|
||||
|
||||
# Set callback and start
|
||||
decoder.set_callback(_progress_callback)
|
||||
success = decoder.start(
|
||||
frequency_khz=tuned_frequency_khz,
|
||||
station=station,
|
||||
device_index=device_int,
|
||||
gain=gain,
|
||||
ioc=ioc,
|
||||
lpm=lpm,
|
||||
direct_sampling=direct_sampling,
|
||||
sdr_type=sdr_type_str,
|
||||
)
|
||||
|
||||
if success:
|
||||
wefax_active_device = device_int
|
||||
wefax_active_sdr_type = sdr_type_str
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'frequency_khz': frequency_khz,
|
||||
'tuned_frequency_khz': tuned_frequency_khz,
|
||||
'frequency_reference': resolved_reference,
|
||||
'usb_offset_applied': usb_offset_applied,
|
||||
'usb_offset_khz': (
|
||||
WEFAX_USB_ALIGNMENT_OFFSET_KHZ if usb_offset_applied else 0.0
|
||||
),
|
||||
'station': station,
|
||||
'ioc': ioc,
|
||||
'lpm': lpm,
|
||||
'device': device_int,
|
||||
})
|
||||
else:
|
||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||
return api_error('Failed to start decoder', 500)
|
||||
|
||||
|
||||
@wefax_bp.route('/stop', methods=['POST'])
|
||||
def stop_decoder():
|
||||
"""Stop WeFax decoder."""
|
||||
global wefax_active_device, wefax_active_sdr_type
|
||||
decoder = get_wefax_decoder()
|
||||
decoder.stop()
|
||||
|
||||
if wefax_active_device is not None:
|
||||
app_module.release_sdr_device(wefax_active_device, wefax_active_sdr_type or 'rtlsdr')
|
||||
wefax_active_device = None
|
||||
wefax_active_sdr_type = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@wefax_bp.route('/stream')
|
||||
def stream_progress():
|
||||
"""SSE stream of WeFax decode progress."""
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=_wefax_queue,
|
||||
channel_key='wefax',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
|
||||
@wefax_bp.route('/images')
|
||||
def list_images():
|
||||
"""Get list of decoded WeFax images."""
|
||||
decoder = get_wefax_decoder()
|
||||
images = decoder.get_images()
|
||||
|
||||
limit = request.args.get('limit', type=int)
|
||||
if limit and limit > 0:
|
||||
images = images[-limit:]
|
||||
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'images': [img.to_dict() for img in images],
|
||||
'count': len(images),
|
||||
})
|
||||
|
||||
|
||||
@wefax_bp.route('/images/<filename>')
|
||||
def get_image(filename: str):
|
||||
"""Get a decoded WeFax image file."""
|
||||
decoder = get_wefax_decoder()
|
||||
|
||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||
return api_error('Invalid filename', 400)
|
||||
|
||||
if not filename.endswith('.png'):
|
||||
return api_error('Only PNG files supported', 400)
|
||||
|
||||
image_path = decoder._output_dir / filename
|
||||
if not image_path.exists():
|
||||
return api_error('Image not found', 404)
|
||||
|
||||
return send_file(image_path, mimetype='image/png')
|
||||
|
||||
|
||||
@wefax_bp.route('/images/<filename>', methods=['DELETE'])
|
||||
def delete_image(filename: str):
|
||||
"""Delete a decoded WeFax image."""
|
||||
decoder = get_wefax_decoder()
|
||||
|
||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||
return api_error('Invalid filename', 400)
|
||||
|
||||
if not filename.endswith('.png'):
|
||||
return api_error('Only PNG files supported', 400)
|
||||
|
||||
if decoder.delete_image(filename):
|
||||
return jsonify({'status': 'ok'})
|
||||
else:
|
||||
return api_error('Image not found', 404)
|
||||
|
||||
|
||||
@wefax_bp.route('/images', methods=['DELETE'])
|
||||
def delete_all_images():
|
||||
"""Delete all decoded WeFax images."""
|
||||
decoder = get_wefax_decoder()
|
||||
count = decoder.delete_all_images()
|
||||
return jsonify({'status': 'ok', 'deleted': count})
|
||||
|
||||
|
||||
# ========================
|
||||
# Auto-Scheduler Endpoints
|
||||
# ========================
|
||||
|
||||
|
||||
def _scheduler_event_callback(event: dict) -> None:
|
||||
"""Forward scheduler events to the SSE queue."""
|
||||
try:
|
||||
_wefax_queue.put_nowait(event)
|
||||
except queue.Full:
|
||||
try:
|
||||
_wefax_queue.get_nowait()
|
||||
_wefax_queue.put_nowait(event)
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
|
||||
@wefax_bp.route('/schedule/enable', methods=['POST'])
|
||||
def enable_schedule():
|
||||
"""Enable auto-scheduling of WeFax broadcast captures.
|
||||
|
||||
JSON body:
|
||||
{
|
||||
"station": "NOJ",
|
||||
"frequency_khz": 4298,
|
||||
"device": 0,
|
||||
"gain": 40,
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"direct_sampling": true,
|
||||
"frequency_reference": "auto" // auto, carrier, or dial
|
||||
}
|
||||
|
||||
Returns:
|
||||
JSON with scheduler status.
|
||||
"""
|
||||
from utils.wefax_scheduler import get_wefax_scheduler
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
station = str(data.get('station', '')).strip()
|
||||
if not station:
|
||||
return api_error('station is required', 400)
|
||||
|
||||
frequency_khz = data.get('frequency_khz')
|
||||
if frequency_khz is None:
|
||||
return api_error('frequency_khz is required', 400)
|
||||
|
||||
try:
|
||||
frequency_khz = float(frequency_khz)
|
||||
freq_mhz = frequency_khz / 1000.0
|
||||
validate_frequency(freq_mhz, min_mhz=2.0, max_mhz=30.0)
|
||||
except (TypeError, ValueError) as e:
|
||||
return api_error(f'Invalid frequency: {e}', 400)
|
||||
|
||||
device = int(data.get('device', 0))
|
||||
gain = float(data.get('gain', 40.0))
|
||||
ioc = int(data.get('ioc', 576))
|
||||
lpm = int(data.get('lpm', 120))
|
||||
direct_sampling = bool(data.get('direct_sampling', True))
|
||||
frequency_reference = str(data.get('frequency_reference', 'auto')).strip().lower()
|
||||
if not frequency_reference:
|
||||
frequency_reference = 'auto'
|
||||
|
||||
try:
|
||||
tuned_frequency_khz, resolved_reference, usb_offset_applied = (
|
||||
resolve_tuning_frequency_khz(
|
||||
listed_frequency_khz=frequency_khz,
|
||||
station_callsign=station,
|
||||
frequency_reference=frequency_reference,
|
||||
)
|
||||
)
|
||||
tuned_mhz = tuned_frequency_khz / 1000.0
|
||||
validate_frequency(tuned_mhz, min_mhz=2.0, max_mhz=30.0)
|
||||
except ValueError as e:
|
||||
return api_error(f'Invalid frequency settings: {e}', 400)
|
||||
|
||||
scheduler = get_wefax_scheduler()
|
||||
scheduler.set_callbacks(_progress_callback, _scheduler_event_callback)
|
||||
|
||||
try:
|
||||
result = scheduler.enable(
|
||||
station=station,
|
||||
frequency_khz=tuned_frequency_khz,
|
||||
device=device,
|
||||
gain=gain,
|
||||
ioc=ioc,
|
||||
lpm=lpm,
|
||||
direct_sampling=direct_sampling,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to enable WeFax scheduler")
|
||||
return api_error('Failed to enable scheduler', 500)
|
||||
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
**result,
|
||||
'frequency_khz': frequency_khz,
|
||||
'tuned_frequency_khz': tuned_frequency_khz,
|
||||
'frequency_reference': resolved_reference,
|
||||
'usb_offset_applied': usb_offset_applied,
|
||||
'usb_offset_khz': (
|
||||
WEFAX_USB_ALIGNMENT_OFFSET_KHZ if usb_offset_applied else 0.0
|
||||
),
|
||||
})
|
||||
|
||||
|
||||
@wefax_bp.route('/schedule/disable', methods=['POST'])
|
||||
def disable_schedule():
|
||||
"""Disable auto-scheduling."""
|
||||
from utils.wefax_scheduler import get_wefax_scheduler
|
||||
|
||||
scheduler = get_wefax_scheduler()
|
||||
result = scheduler.disable()
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@wefax_bp.route('/schedule/status')
|
||||
def schedule_status():
|
||||
"""Get current scheduler state."""
|
||||
from utils.wefax_scheduler import get_wefax_scheduler
|
||||
|
||||
scheduler = get_wefax_scheduler()
|
||||
return jsonify(scheduler.get_status())
|
||||
|
||||
|
||||
@wefax_bp.route('/schedule/broadcasts')
|
||||
def schedule_broadcasts():
|
||||
"""List scheduled broadcasts."""
|
||||
from utils.wefax_scheduler import get_wefax_scheduler
|
||||
|
||||
scheduler = get_wefax_scheduler()
|
||||
broadcasts = scheduler.get_broadcasts()
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'broadcasts': broadcasts,
|
||||
'count': len(broadcasts),
|
||||
})
|
||||
|
||||
|
||||
@wefax_bp.route('/schedule/skip/<broadcast_id>', methods=['POST'])
|
||||
def skip_broadcast(broadcast_id: str):
|
||||
"""Skip a scheduled broadcast."""
|
||||
from utils.wefax_scheduler import get_wefax_scheduler
|
||||
|
||||
if not broadcast_id.replace('_', '').replace('-', '').isalnum():
|
||||
return api_error('Invalid broadcast ID', 400)
|
||||
|
||||
scheduler = get_wefax_scheduler()
|
||||
if scheduler.skip_broadcast(broadcast_id):
|
||||
return jsonify({'status': 'skipped', 'broadcast_id': broadcast_id})
|
||||
else:
|
||||
return api_error('Broadcast not found or already processed', 404)
|
||||
|
||||
|
||||
@wefax_bp.route('/stations')
|
||||
def list_stations():
|
||||
"""Get all WeFax stations from the database."""
|
||||
stations = load_stations()
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'stations': stations,
|
||||
'count': len(stations),
|
||||
})
|
||||
|
||||
|
||||
@wefax_bp.route('/stations/<callsign>')
|
||||
def station_detail(callsign: str):
|
||||
"""Get station detail including current schedule info."""
|
||||
station = get_station(callsign)
|
||||
if not station:
|
||||
return api_error(f'Station {callsign} not found', 404)
|
||||
|
||||
current = get_current_broadcasts(callsign)
|
||||
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'station': station,
|
||||
'current_broadcasts': current,
|
||||
})
|
||||
+234
-239
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
@@ -11,69 +12,74 @@ import re
|
||||
import subprocess
|
||||
import threading
|
||||
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
|
||||
from utils.dependencies import check_tool, get_tool_path
|
||||
from utils.logging import wifi_logger as logger
|
||||
from utils.process import is_valid_mac, is_valid_channel
|
||||
from utils.validation import validate_wifi_channel, validate_mac_address, validate_network_interface
|
||||
from utils.sse import format_sse, sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from data.oui import get_manufacturer
|
||||
from data.oui import get_manufacturer
|
||||
from utils.constants import (
|
||||
WIFI_TERMINATE_TIMEOUT,
|
||||
PMKID_TERMINATE_TIMEOUT,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
WIFI_CSV_PARSE_INTERVAL,
|
||||
WIFI_CSV_TIMEOUT_WARNING,
|
||||
SUBPROCESS_TIMEOUT_SHORT,
|
||||
SUBPROCESS_TIMEOUT_MEDIUM,
|
||||
SUBPROCESS_TIMEOUT_LONG,
|
||||
DEAUTH_TIMEOUT,
|
||||
MIN_DEAUTH_COUNT,
|
||||
MAX_DEAUTH_COUNT,
|
||||
DEFAULT_DEAUTH_COUNT,
|
||||
PROCESS_START_WAIT,
|
||||
MONITOR_MODE_DELAY,
|
||||
WIFI_CAPTURE_PATH_PREFIX,
|
||||
HANDSHAKE_CAPTURE_PATH_PREFIX,
|
||||
PMKID_CAPTURE_PATH_PREFIX,
|
||||
SUBPROCESS_TIMEOUT_SHORT,
|
||||
)
|
||||
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')
|
||||
|
||||
# --- v1 deprecation ---
|
||||
# These endpoints are deprecated in favor of /wifi/v2/*.
|
||||
# Frontend still uses v1, so they remain active.
|
||||
# Migration: switch frontend to v2 endpoints, then remove this file.
|
||||
_v1_deprecation_logged = set()
|
||||
|
||||
|
||||
@wifi_bp.after_request
|
||||
def _add_deprecation_header(response):
|
||||
"""Add X-Deprecated header to all v1 WiFi responses."""
|
||||
response.headers['X-Deprecated'] = 'Use /wifi/v2/* endpoints instead'
|
||||
endpoint = request.endpoint or ''
|
||||
if endpoint not in _v1_deprecation_logged:
|
||||
_v1_deprecation_logged.add(endpoint)
|
||||
logger.warning(f"Deprecated v1 WiFi endpoint called: {request.path} — migrate to /wifi/v2/*")
|
||||
return response
|
||||
|
||||
|
||||
# PMKID process state
|
||||
pmkid_process = None
|
||||
pmkid_lock = threading.Lock()
|
||||
|
||||
|
||||
def _parse_channel_list(raw_channels: Any) -> list[int] | None:
|
||||
"""Parse a channel list from string/list input."""
|
||||
if raw_channels in (None, '', []):
|
||||
return None
|
||||
|
||||
if isinstance(raw_channels, str):
|
||||
parts = [p.strip() for p in re.split(r'[\s,]+', raw_channels) if p.strip()]
|
||||
elif isinstance(raw_channels, (list, tuple, set)):
|
||||
parts = list(raw_channels)
|
||||
else:
|
||||
parts = [raw_channels]
|
||||
|
||||
channels: list[int] = []
|
||||
seen = set()
|
||||
for part in parts:
|
||||
if part in (None, ''):
|
||||
continue
|
||||
ch = validate_wifi_channel(part)
|
||||
if ch not in seen:
|
||||
channels.append(ch)
|
||||
seen.add(ch)
|
||||
|
||||
return channels or None
|
||||
pmkid_process = None
|
||||
pmkid_lock = threading.Lock()
|
||||
|
||||
|
||||
def _parse_channel_list(raw_channels: Any) -> list[int] | None:
|
||||
"""Parse a channel list from string/list input."""
|
||||
if raw_channels in (None, '', []):
|
||||
return None
|
||||
|
||||
if isinstance(raw_channels, str):
|
||||
parts = [p.strip() for p in re.split(r'[\s,]+', raw_channels) if p.strip()]
|
||||
elif isinstance(raw_channels, (list, tuple, set)):
|
||||
parts = list(raw_channels)
|
||||
else:
|
||||
parts = [raw_channels]
|
||||
|
||||
channels: list[int] = []
|
||||
seen = set()
|
||||
for part in parts:
|
||||
if part in (None, ''):
|
||||
continue
|
||||
ch = validate_wifi_channel(part)
|
||||
if ch not in seen:
|
||||
channels.append(ch)
|
||||
seen.add(ch)
|
||||
|
||||
return channels or None
|
||||
|
||||
|
||||
def detect_wifi_interfaces():
|
||||
@@ -182,9 +188,9 @@ def _get_interface_details(iface_name):
|
||||
# Get MAC address
|
||||
try:
|
||||
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()
|
||||
except (FileNotFoundError, IOError):
|
||||
except (OSError, FileNotFoundError):
|
||||
pass
|
||||
|
||||
# Get driver name
|
||||
@@ -193,7 +199,7 @@ def _get_interface_details(iface_name):
|
||||
if os.path.islink(driver_link):
|
||||
driver_path = os.readlink(driver_link)
|
||||
details['driver'] = os.path.basename(driver_path)
|
||||
except (FileNotFoundError, IOError, OSError):
|
||||
except (FileNotFoundError, OSError):
|
||||
pass
|
||||
|
||||
# Try airmon-ng first for chipset info (most reliable for WiFi adapters)
|
||||
@@ -211,11 +217,10 @@ def _get_interface_details(iface_name):
|
||||
break
|
||||
# Also try space-separated format
|
||||
parts = line.split()
|
||||
if len(parts) >= 4:
|
||||
if parts[1] == iface_name or parts[1].startswith(iface_name):
|
||||
details['driver'] = parts[2]
|
||||
details['chipset'] = ' '.join(parts[3:])
|
||||
break
|
||||
if len(parts) >= 4 and (parts[1] == iface_name or parts[1].startswith(iface_name)):
|
||||
details['driver'] = parts[2]
|
||||
details['chipset'] = ' '.join(parts[3:])
|
||||
break
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
|
||||
pass
|
||||
|
||||
@@ -227,10 +232,10 @@ def _get_interface_details(iface_name):
|
||||
# Try to get USB product name
|
||||
for usb_path in [f'{device_path}/product', f'{device_path}/../product']:
|
||||
try:
|
||||
with open(usb_path, 'r') as f:
|
||||
with open(usb_path) as f:
|
||||
details['chipset'] = f.read().strip()
|
||||
break
|
||||
except (FileNotFoundError, IOError):
|
||||
except (OSError, FileNotFoundError):
|
||||
pass
|
||||
|
||||
# If no USB product, try lsusb for USB devices
|
||||
@@ -238,7 +243,7 @@ def _get_interface_details(iface_name):
|
||||
try:
|
||||
# Get USB bus/device info
|
||||
uevent_path = f'{device_path}/uevent'
|
||||
with open(uevent_path, 'r') as f:
|
||||
with open(uevent_path) as f:
|
||||
for line in f:
|
||||
if line.startswith('PRODUCT='):
|
||||
# PRODUCT format: vendor/product/bcdDevice
|
||||
@@ -261,9 +266,9 @@ def _get_interface_details(iface_name):
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
break
|
||||
except (FileNotFoundError, IOError):
|
||||
except (OSError, FileNotFoundError):
|
||||
pass
|
||||
except (FileNotFoundError, IOError, OSError):
|
||||
except (FileNotFoundError, OSError):
|
||||
pass
|
||||
|
||||
return details
|
||||
@@ -275,7 +280,7 @@ def parse_airodump_csv(csv_path):
|
||||
clients = {}
|
||||
|
||||
try:
|
||||
with open(csv_path, 'r', errors='replace') as f:
|
||||
with open(csv_path, errors='replace') as f:
|
||||
content = f.read()
|
||||
|
||||
sections = content.split('\n\n')
|
||||
@@ -455,7 +460,7 @@ def toggle_monitor_mode():
|
||||
try:
|
||||
interface = validate_network_interface(data.get('interface'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
return api_error(str(e), 400)
|
||||
|
||||
if action == 'start':
|
||||
if check_tool('airmon-ng'):
|
||||
@@ -575,20 +580,16 @@ def toggle_monitor_mode():
|
||||
all_wireless = [f for f in os.listdir('/sys/class/net')
|
||||
if os.path.exists(f'/sys/class/net/{f}/wireless') or 'mon' in f or f.startswith('wl')]
|
||||
logger.error(f"Monitor interface not found. Tried: {monitor_iface}. Available: {all_wireless}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Monitor interface not created. airmon-ng output: {output[:500]}. Available interfaces: {all_wireless}'
|
||||
})
|
||||
return api_error(f'Monitor interface not created. airmon-ng output: {output[:500]}. Available interfaces: {all_wireless}')
|
||||
|
||||
app_module.wifi_monitor_interface = monitor_iface
|
||||
app_module.wifi_queue.put({'type': 'info', 'text': f'Monitor mode enabled on {app_module.wifi_monitor_interface}'})
|
||||
logger.info(f"Monitor mode enabled on {monitor_iface}")
|
||||
return jsonify({'status': 'success', 'monitor_interface': app_module.wifi_monitor_interface})
|
||||
return api_success(data={'monitor_interface': app_module.wifi_monitor_interface})
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
logger.error(f"Error enabling monitor mode: {e}", exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
return api_error(str(e))
|
||||
|
||||
elif check_tool('iw'):
|
||||
try:
|
||||
@@ -596,11 +597,11 @@ def toggle_monitor_mode():
|
||||
subprocess.run(['iw', interface, 'set', 'monitor', 'control'], capture_output=True)
|
||||
subprocess.run(['ip', 'link', 'set', interface, 'up'], capture_output=True)
|
||||
app_module.wifi_monitor_interface = interface
|
||||
return jsonify({'status': 'success', 'monitor_interface': interface})
|
||||
return api_success(data={'monitor_interface': interface})
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
return api_error(str(e))
|
||||
else:
|
||||
return jsonify({'status': 'error', 'message': 'No monitor mode tools available.'})
|
||||
return api_error('No monitor mode tools available.')
|
||||
|
||||
else: # stop
|
||||
if check_tool('airmon-ng'):
|
||||
@@ -609,20 +610,20 @@ def toggle_monitor_mode():
|
||||
subprocess.run([airmon_path, 'stop', app_module.wifi_monitor_interface or interface],
|
||||
capture_output=True, text=True, timeout=15)
|
||||
app_module.wifi_monitor_interface = None
|
||||
return jsonify({'status': 'success', 'message': 'Monitor mode disabled'})
|
||||
return api_success(message='Monitor mode disabled')
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
return api_error(str(e))
|
||||
elif check_tool('iw'):
|
||||
try:
|
||||
subprocess.run(['ip', 'link', 'set', interface, 'down'], capture_output=True)
|
||||
subprocess.run(['iw', interface, 'set', 'type', 'managed'], capture_output=True)
|
||||
subprocess.run(['ip', 'link', 'set', interface, 'up'], capture_output=True)
|
||||
app_module.wifi_monitor_interface = None
|
||||
return jsonify({'status': 'success', 'message': 'Monitor mode disabled'})
|
||||
return api_success(message='Monitor mode disabled')
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
return api_error(str(e))
|
||||
|
||||
return jsonify({'status': 'error', 'message': 'Unknown action'})
|
||||
return api_error('Unknown action')
|
||||
|
||||
|
||||
@wifi_bp.route('/scan/start', methods=['POST'])
|
||||
@@ -630,12 +631,12 @@ def start_wifi_scan():
|
||||
"""Start WiFi scanning with airodump-ng."""
|
||||
with app_module.wifi_lock:
|
||||
if app_module.wifi_process:
|
||||
return jsonify({'status': 'error', 'message': 'Scan already running'})
|
||||
return api_error('Scan already running')
|
||||
|
||||
data = request.json
|
||||
channel = data.get('channel')
|
||||
channels = data.get('channels')
|
||||
band = data.get('band', 'abg')
|
||||
channel = data.get('channel')
|
||||
channels = data.get('channels')
|
||||
band = data.get('band', 'abg')
|
||||
|
||||
# Use provided interface or fall back to stored monitor interface
|
||||
interface = data.get('interface')
|
||||
@@ -643,21 +644,18 @@ def start_wifi_scan():
|
||||
try:
|
||||
interface = validate_network_interface(interface)
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
return api_error(str(e), 400)
|
||||
else:
|
||||
interface = app_module.wifi_monitor_interface
|
||||
|
||||
if not interface:
|
||||
return jsonify({'status': 'error', 'message': 'No monitor interface available.'})
|
||||
return api_error('No monitor interface available.')
|
||||
|
||||
# Verify interface exists
|
||||
if not os.path.exists(f'/sys/class/net/{interface}'):
|
||||
all_wireless = [f for f in os.listdir('/sys/class/net')
|
||||
if os.path.exists(f'/sys/class/net/{f}/wireless') or 'mon' in f or f.startswith('wl')]
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Interface "{interface}" does not exist. Available: {all_wireless}'
|
||||
})
|
||||
return api_error(f'Interface "{interface}" does not exist. Available: {all_wireless}')
|
||||
|
||||
app_module.wifi_networks = {}
|
||||
app_module.wifi_clients = {}
|
||||
@@ -670,11 +668,9 @@ def start_wifi_scan():
|
||||
|
||||
csv_path = '/tmp/intercept_wifi'
|
||||
|
||||
for f in [f'/tmp/intercept_wifi-01.csv', f'/tmp/intercept_wifi-01.cap']:
|
||||
try:
|
||||
for f in ['/tmp/intercept_wifi-01.csv', '/tmp/intercept_wifi-01.cap']:
|
||||
with contextlib.suppress(OSError):
|
||||
os.remove(f)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
airodump_path = get_tool_path('airodump-ng')
|
||||
cmd = [
|
||||
@@ -685,17 +681,17 @@ def start_wifi_scan():
|
||||
interface
|
||||
]
|
||||
|
||||
channel_list = None
|
||||
if channels:
|
||||
try:
|
||||
channel_list = _parse_channel_list(channels)
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
if channel_list:
|
||||
cmd.extend(['-c', ','.join(str(c) for c in channel_list)])
|
||||
elif channel:
|
||||
cmd.extend(['-c', str(channel)])
|
||||
channel_list = None
|
||||
if channels:
|
||||
try:
|
||||
channel_list = _parse_channel_list(channels)
|
||||
except ValueError as e:
|
||||
return api_error(str(e), 400)
|
||||
|
||||
if channel_list:
|
||||
cmd.extend(['-c', ','.join(str(c) for c in channel_list)])
|
||||
elif channel:
|
||||
cmd.extend(['-c', str(channel)])
|
||||
|
||||
logger.info(f"Running: {' '.join(cmd)}")
|
||||
|
||||
@@ -723,7 +719,7 @@ def start_wifi_scan():
|
||||
error_msg = 'Permission denied. Try running with sudo.'
|
||||
|
||||
logger.error(f"airodump-ng failed for interface '{interface}': {error_msg}")
|
||||
return jsonify({'status': 'error', 'message': error_msg, 'interface': interface})
|
||||
return api_error(error_msg)
|
||||
|
||||
thread = threading.Thread(target=stream_airodump_output, args=(app_module.wifi_process, csv_path))
|
||||
thread.daemon = True
|
||||
@@ -734,9 +730,9 @@ def start_wifi_scan():
|
||||
return jsonify({'status': 'started', 'interface': interface})
|
||||
|
||||
except FileNotFoundError:
|
||||
return jsonify({'status': 'error', 'message': 'airodump-ng not found.'})
|
||||
return api_error('airodump-ng not found.')
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
return api_error(str(e))
|
||||
|
||||
|
||||
@wifi_bp.route('/scan/stop', methods=['POST'])
|
||||
@@ -768,18 +764,18 @@ def send_deauth():
|
||||
try:
|
||||
interface = validate_network_interface(interface)
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
return api_error(str(e), 400)
|
||||
else:
|
||||
interface = app_module.wifi_monitor_interface
|
||||
|
||||
if not target_bssid:
|
||||
return jsonify({'status': 'error', 'message': 'Target BSSID required'})
|
||||
return api_error('Target BSSID required')
|
||||
|
||||
if not is_valid_mac(target_bssid):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid BSSID format'})
|
||||
return api_error('Invalid BSSID format')
|
||||
|
||||
if not is_valid_mac(target_client):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid client MAC format'})
|
||||
return api_error('Invalid client MAC format')
|
||||
|
||||
try:
|
||||
count = int(count)
|
||||
@@ -789,10 +785,10 @@ def send_deauth():
|
||||
count = 5
|
||||
|
||||
if not interface:
|
||||
return jsonify({'status': 'error', 'message': 'No monitor interface'})
|
||||
return api_error('No monitor interface')
|
||||
|
||||
if not check_tool('aireplay-ng'):
|
||||
return jsonify({'status': 'error', 'message': 'aireplay-ng not found'})
|
||||
return api_error('aireplay-ng not found')
|
||||
|
||||
try:
|
||||
aireplay_path = get_tool_path('aireplay-ng')
|
||||
@@ -809,14 +805,14 @@ def send_deauth():
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
|
||||
if result.returncode == 0:
|
||||
return jsonify({'status': 'success', 'message': f'Sent {count} deauth packets'})
|
||||
return api_success(message=f'Sent {count} deauth packets')
|
||||
else:
|
||||
return jsonify({'status': 'error', 'message': result.stderr})
|
||||
return api_error(result.stderr)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return jsonify({'status': 'success', 'message': 'Deauth sent (timed out)'})
|
||||
return api_success(message='Deauth sent (timed out)')
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
return api_error(str(e))
|
||||
|
||||
|
||||
@wifi_bp.route('/handshake/capture', methods=['POST'])
|
||||
@@ -832,22 +828,22 @@ def capture_handshake():
|
||||
try:
|
||||
interface = validate_network_interface(interface)
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
return api_error(str(e), 400)
|
||||
else:
|
||||
interface = app_module.wifi_monitor_interface
|
||||
|
||||
if not target_bssid or not channel:
|
||||
return jsonify({'status': 'error', 'message': 'BSSID and channel required'})
|
||||
return api_error('BSSID and channel required')
|
||||
|
||||
if not is_valid_mac(target_bssid):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid BSSID format'})
|
||||
return api_error('Invalid BSSID format')
|
||||
|
||||
if not is_valid_channel(channel):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid channel'})
|
||||
return api_error('Invalid channel')
|
||||
|
||||
with app_module.wifi_lock:
|
||||
if app_module.wifi_process:
|
||||
return jsonify({'status': 'error', 'message': 'Scan already running.'})
|
||||
return api_error('Scan already running.')
|
||||
|
||||
capture_path = f'/tmp/intercept_handshake_{target_bssid.replace(":", "")}'
|
||||
|
||||
@@ -866,7 +862,7 @@ def capture_handshake():
|
||||
app_module.wifi_queue.put({'type': 'info', 'text': f'Capturing handshakes for {target_bssid}'})
|
||||
return jsonify({'status': 'started', 'capture_file': capture_path + '-01.cap'})
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
return api_error(str(e))
|
||||
|
||||
|
||||
@wifi_bp.route('/handshake/status', methods=['POST'])
|
||||
@@ -877,7 +873,7 @@ def check_handshake_status():
|
||||
target_bssid = data.get('bssid', '')
|
||||
|
||||
if not capture_file.startswith('/tmp/intercept_handshake_') or '..' in capture_file:
|
||||
return jsonify({'status': 'error', 'message': 'Invalid capture file path'})
|
||||
return api_error('Invalid capture file path')
|
||||
|
||||
if not os.path.exists(capture_file):
|
||||
with app_module.wifi_lock:
|
||||
@@ -887,53 +883,53 @@ def check_handshake_status():
|
||||
return jsonify({'status': 'stopped', 'file_exists': False, 'handshake_found': False})
|
||||
|
||||
file_size = os.path.getsize(capture_file)
|
||||
handshake_found = False
|
||||
handshake_valid: bool | None = None
|
||||
handshake_checked = False
|
||||
handshake_reason: str | None = None
|
||||
handshake_found = False
|
||||
handshake_valid: bool | None = None
|
||||
handshake_checked = False
|
||||
handshake_reason: str | None = None
|
||||
|
||||
try:
|
||||
if target_bssid and is_valid_mac(target_bssid):
|
||||
aircrack_path = get_tool_path('aircrack-ng')
|
||||
if aircrack_path:
|
||||
result = subprocess.run(
|
||||
[aircrack_path, '-a', '2', '-b', target_bssid, capture_file],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
output = result.stdout + result.stderr
|
||||
output_lower = output.lower()
|
||||
handshake_checked = True
|
||||
|
||||
if 'no valid wpa handshakes found' in output_lower:
|
||||
handshake_valid = False
|
||||
handshake_reason = 'No valid WPA handshake found'
|
||||
elif '0 handshake' in output_lower:
|
||||
handshake_valid = False
|
||||
elif '1 handshake' in output_lower or ('handshake' in output_lower and 'wpa' in output_lower):
|
||||
handshake_valid = True
|
||||
else:
|
||||
handshake_valid = False
|
||||
if target_bssid and is_valid_mac(target_bssid):
|
||||
aircrack_path = get_tool_path('aircrack-ng')
|
||||
if aircrack_path:
|
||||
result = subprocess.run(
|
||||
[aircrack_path, '-a', '2', '-b', target_bssid, capture_file],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
output = result.stdout + result.stderr
|
||||
output_lower = output.lower()
|
||||
handshake_checked = True
|
||||
|
||||
if 'no valid wpa handshakes found' in output_lower:
|
||||
handshake_valid = False
|
||||
handshake_reason = 'No valid WPA handshake found'
|
||||
elif '0 handshake' in output_lower:
|
||||
handshake_valid = False
|
||||
elif '1 handshake' in output_lower or ('handshake' in output_lower and 'wpa' in output_lower):
|
||||
handshake_valid = True
|
||||
else:
|
||||
handshake_valid = False
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking handshake: {e}")
|
||||
|
||||
if handshake_valid:
|
||||
handshake_found = True
|
||||
normalized_bssid = target_bssid.upper() if target_bssid else None
|
||||
if normalized_bssid and normalized_bssid not in app_module.wifi_handshakes:
|
||||
app_module.wifi_handshakes.append(normalized_bssid)
|
||||
|
||||
return jsonify({
|
||||
'status': 'running' if app_module.wifi_process and app_module.wifi_process.poll() is None else 'stopped',
|
||||
'file_exists': True,
|
||||
'file_size': file_size,
|
||||
'file': capture_file,
|
||||
'handshake_found': handshake_found,
|
||||
'handshake_valid': handshake_valid,
|
||||
'handshake_checked': handshake_checked,
|
||||
'handshake_reason': handshake_reason
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking handshake: {e}")
|
||||
|
||||
if handshake_valid:
|
||||
handshake_found = True
|
||||
normalized_bssid = target_bssid.upper() if target_bssid else None
|
||||
if normalized_bssid and normalized_bssid not in app_module.wifi_handshakes:
|
||||
app_module.wifi_handshakes.append(normalized_bssid)
|
||||
|
||||
return jsonify({
|
||||
'status': 'running' if app_module.wifi_process and app_module.wifi_process.poll() is None else 'stopped',
|
||||
'file_exists': True,
|
||||
'file_size': file_size,
|
||||
'file': capture_file,
|
||||
'handshake_found': handshake_found,
|
||||
'handshake_valid': handshake_valid,
|
||||
'handshake_checked': handshake_checked,
|
||||
'handshake_reason': handshake_reason
|
||||
})
|
||||
|
||||
|
||||
@wifi_bp.route('/pmkid/capture', methods=['POST'])
|
||||
@@ -951,19 +947,19 @@ def capture_pmkid():
|
||||
try:
|
||||
interface = validate_network_interface(interface)
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
return api_error(str(e), 400)
|
||||
else:
|
||||
interface = app_module.wifi_monitor_interface
|
||||
|
||||
if not target_bssid:
|
||||
return jsonify({'status': 'error', 'message': 'BSSID required'})
|
||||
return api_error('BSSID required')
|
||||
|
||||
if not is_valid_mac(target_bssid):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid BSSID format'})
|
||||
return api_error('Invalid BSSID format')
|
||||
|
||||
with pmkid_lock:
|
||||
if pmkid_process and pmkid_process.poll() is None:
|
||||
return jsonify({'status': 'error', 'message': 'PMKID capture already running'})
|
||||
return api_error('PMKID capture already running')
|
||||
|
||||
capture_path = f'/tmp/intercept_pmkid_{target_bssid.replace(":", "")}.pcapng'
|
||||
filter_file = f'/tmp/pmkid_filter_{target_bssid.replace(":", "")}'
|
||||
@@ -986,9 +982,9 @@ def capture_pmkid():
|
||||
pmkid_process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
return jsonify({'status': 'started', 'file': capture_path})
|
||||
except FileNotFoundError:
|
||||
return jsonify({'status': 'error', 'message': 'hcxdumptool not found.'})
|
||||
return api_error('hcxdumptool not found.')
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
return api_error(str(e))
|
||||
|
||||
|
||||
@wifi_bp.route('/pmkid/status', methods=['POST'])
|
||||
@@ -998,7 +994,7 @@ def check_pmkid_status():
|
||||
capture_file = data.get('file', '')
|
||||
|
||||
if not capture_file.startswith('/tmp/intercept_pmkid_') or '..' in capture_file:
|
||||
return jsonify({'status': 'error', 'message': 'Invalid capture file path'})
|
||||
return api_error('Invalid capture file path')
|
||||
|
||||
if not os.path.exists(capture_file):
|
||||
return jsonify({'pmkid_found': False, 'file_exists': False})
|
||||
@@ -1008,7 +1004,7 @@ def check_pmkid_status():
|
||||
|
||||
try:
|
||||
hash_file = capture_file.replace('.pcapng', '.22000')
|
||||
result = subprocess.run(
|
||||
subprocess.run(
|
||||
['hcxpcapngtool', '-o', hash_file, capture_file],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
@@ -1054,23 +1050,23 @@ def crack_handshake():
|
||||
|
||||
# Validate paths to prevent path traversal
|
||||
if not capture_file.startswith('/tmp/intercept_handshake_') or '..' in capture_file:
|
||||
return jsonify({'status': 'error', 'message': 'Invalid capture file path'}), 400
|
||||
return api_error('Invalid capture file path', 400)
|
||||
|
||||
if '..' in wordlist:
|
||||
return jsonify({'status': 'error', 'message': 'Invalid wordlist path'}), 400
|
||||
return api_error('Invalid wordlist path', 400)
|
||||
|
||||
if not os.path.exists(capture_file):
|
||||
return jsonify({'status': 'error', 'message': 'Capture file not found'}), 404
|
||||
return api_error('Capture file not found', 404)
|
||||
|
||||
if not os.path.exists(wordlist):
|
||||
return jsonify({'status': 'error', 'message': 'Wordlist file not found'}), 404
|
||||
return api_error('Wordlist file not found', 404)
|
||||
|
||||
if target_bssid and not is_valid_mac(target_bssid):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid BSSID format'}), 400
|
||||
return api_error('Invalid BSSID format', 400)
|
||||
|
||||
aircrack_path = get_tool_path('aircrack-ng')
|
||||
if not aircrack_path:
|
||||
return jsonify({'status': 'error', 'message': 'aircrack-ng not found'}), 500
|
||||
return api_error('aircrack-ng not found', 500)
|
||||
|
||||
try:
|
||||
cmd = [aircrack_path, '-a', '2', '-w', wordlist]
|
||||
@@ -1099,8 +1095,7 @@ def crack_handshake():
|
||||
if match:
|
||||
password = match.group(1)
|
||||
logger.info(f"Password cracked for {target_bssid}: {password}")
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
return api_success(data={
|
||||
'password': password,
|
||||
'bssid': target_bssid
|
||||
})
|
||||
@@ -1118,7 +1113,7 @@ def crack_handshake():
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Crack error: {e}")
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@wifi_bp.route('/networks')
|
||||
@@ -1132,33 +1127,33 @@ def get_wifi_networks():
|
||||
})
|
||||
|
||||
|
||||
@wifi_bp.route('/stream')
|
||||
def stream_wifi():
|
||||
"""SSE stream for WiFi events."""
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('wifi', msg, msg.get('type'))
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.wifi_queue,
|
||||
channel_key='wifi',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
on_message=_on_msg,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
@wifi_bp.route('/stream')
|
||||
def stream_wifi():
|
||||
"""SSE stream for WiFi events."""
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('wifi', msg, msg.get('type'))
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.wifi_queue,
|
||||
channel_key='wifi',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
on_message=_on_msg,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 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')
|
||||
@@ -1189,7 +1184,7 @@ def get_v2_capabilities():
|
||||
})
|
||||
except Exception as e:
|
||||
logger.exception("Error checking capabilities")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/scan/quick', methods=['POST'])
|
||||
@@ -1220,7 +1215,7 @@ def v2_quick_scan():
|
||||
})
|
||||
except Exception as e:
|
||||
logger.exception("Error in quick scan")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/scan/start', methods=['POST'])
|
||||
@@ -1239,10 +1234,10 @@ def v2_start_scan():
|
||||
return jsonify({'status': 'started'})
|
||||
else:
|
||||
status = scanner.get_status()
|
||||
return jsonify({'error': status.error or 'Failed to start scan'}), 400
|
||||
return api_error(status.error or 'Failed to start scan', 400)
|
||||
except Exception as e:
|
||||
logger.exception("Error starting deep scan")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/scan/stop', methods=['POST'])
|
||||
@@ -1254,7 +1249,7 @@ def v2_stop_scan():
|
||||
return jsonify({'status': 'stopped'})
|
||||
except Exception as e:
|
||||
logger.exception("Error stopping scan")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/scan/status')
|
||||
@@ -1274,7 +1269,7 @@ def v2_scan_status():
|
||||
})
|
||||
except Exception as e:
|
||||
logger.exception("Error getting scan status")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/networks')
|
||||
@@ -1289,7 +1284,7 @@ def v2_get_networks():
|
||||
})
|
||||
except Exception as e:
|
||||
logger.exception("Error getting networks")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/clients')
|
||||
@@ -1326,7 +1321,7 @@ def v2_get_clients():
|
||||
})
|
||||
except Exception as e:
|
||||
logger.exception("Error getting clients")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/probes')
|
||||
@@ -1341,7 +1336,7 @@ def v2_get_probes():
|
||||
})
|
||||
except Exception as e:
|
||||
logger.exception("Error getting probes")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/channels')
|
||||
@@ -1357,7 +1352,7 @@ def v2_get_channels():
|
||||
})
|
||||
except Exception as e:
|
||||
logger.exception("Error getting channel stats")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/stream')
|
||||
@@ -1448,11 +1443,11 @@ def v2_export():
|
||||
return response
|
||||
|
||||
else:
|
||||
return jsonify({'error': f'Unknown format: {format_type}'}), 400
|
||||
return api_error(f'Unknown format: {format_type}', 400)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Error exporting data")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/baseline/set', methods=['POST'])
|
||||
@@ -1464,7 +1459,7 @@ def v2_set_baseline():
|
||||
return jsonify({'status': 'baseline_set', 'count': len(scanner._baseline_networks)})
|
||||
except Exception as e:
|
||||
logger.exception("Error setting baseline")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/baseline/clear', methods=['POST'])
|
||||
@@ -1476,7 +1471,7 @@ def v2_clear_baseline():
|
||||
return jsonify({'status': 'baseline_cleared'})
|
||||
except Exception as e:
|
||||
logger.exception("Error clearing baseline")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/clear', methods=['POST'])
|
||||
@@ -1488,7 +1483,7 @@ def v2_clear_data():
|
||||
return jsonify({'status': 'cleared'})
|
||||
except Exception as e:
|
||||
logger.exception("Error clearing data")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -1535,11 +1530,11 @@ def v2_deauth_status():
|
||||
})
|
||||
except Exception as e:
|
||||
logger.exception("Error getting deauth status")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/deauth/stream')
|
||||
def v2_deauth_stream():
|
||||
@wifi_bp.route('/v2/deauth/stream')
|
||||
def v2_deauth_stream():
|
||||
"""
|
||||
SSE stream for real-time deauth alerts.
|
||||
|
||||
@@ -1550,18 +1545,18 @@ def v2_deauth_stream():
|
||||
- deauth_error: An error occurred
|
||||
- keepalive: Periodic keepalive
|
||||
"""
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.deauth_detector_queue,
|
||||
channel_key='wifi_deauth',
|
||||
timeout=SSE_QUEUE_TIMEOUT,
|
||||
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.deauth_detector_queue,
|
||||
channel_key='wifi_deauth',
|
||||
timeout=SSE_QUEUE_TIMEOUT,
|
||||
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
|
||||
@@ -1600,7 +1595,7 @@ def v2_deauth_alerts():
|
||||
})
|
||||
except Exception as e:
|
||||
logger.exception("Error getting deauth alerts")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/deauth/clear', methods=['POST'])
|
||||
@@ -1620,4 +1615,4 @@ def v2_deauth_clear():
|
||||
return jsonify({'status': 'cleared'})
|
||||
except Exception as e:
|
||||
logger.exception("Error clearing deauth alerts")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
+54
-58
@@ -7,25 +7,26 @@ channel analysis, hidden SSID correlation, and SSE streaming.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Generator
|
||||
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 (
|
||||
get_wifi_scanner,
|
||||
analyze_channels,
|
||||
get_hidden_correlator,
|
||||
SCAN_MODE_QUICK,
|
||||
SCAN_MODE_DEEP,
|
||||
)
|
||||
from utils.sse import format_sse
|
||||
from utils.validation import validate_wifi_channel
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.responses import api_error
|
||||
from utils.sse import format_sse
|
||||
from utils.validation import validate_wifi_channel
|
||||
from utils.wifi import (
|
||||
SCAN_MODE_DEEP,
|
||||
analyze_channels,
|
||||
get_hidden_correlator,
|
||||
get_wifi_scanner,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -87,44 +88,44 @@ def start_deep_scan():
|
||||
|
||||
Requires monitor mode interface and root privileges.
|
||||
|
||||
Request body:
|
||||
interface: Monitor mode interface (e.g., 'wlan0mon')
|
||||
band: Band to scan ('2.4', '5', 'all')
|
||||
channel: Optional specific channel to monitor
|
||||
channels: Optional list or comma-separated channels to monitor
|
||||
Request body:
|
||||
interface: Monitor mode interface (e.g., 'wlan0mon')
|
||||
band: Band to scan ('2.4', '5', 'all')
|
||||
channel: Optional specific channel to monitor
|
||||
channels: Optional list or comma-separated channels to monitor
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
interface = data.get('interface')
|
||||
band = data.get('band', 'all')
|
||||
channel = data.get('channel')
|
||||
channels = data.get('channels')
|
||||
|
||||
channel_list = None
|
||||
if channels:
|
||||
if isinstance(channels, str):
|
||||
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
|
||||
elif isinstance(channels, (list, tuple, set)):
|
||||
channel_list = list(channels)
|
||||
else:
|
||||
channel_list = [channels]
|
||||
try:
|
||||
channel_list = [validate_wifi_channel(c) for c in channel_list]
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({'error': 'Invalid channels'}), 400
|
||||
|
||||
if channel:
|
||||
try:
|
||||
channel = validate_wifi_channel(channel)
|
||||
except ValueError:
|
||||
return jsonify({'error': 'Invalid channel'}), 400
|
||||
channel = data.get('channel')
|
||||
channels = data.get('channels')
|
||||
|
||||
channel_list = None
|
||||
if channels:
|
||||
if isinstance(channels, str):
|
||||
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
|
||||
elif isinstance(channels, (list, tuple, set)):
|
||||
channel_list = list(channels)
|
||||
else:
|
||||
channel_list = [channels]
|
||||
try:
|
||||
channel_list = [validate_wifi_channel(c) for c in channel_list]
|
||||
except (TypeError, ValueError):
|
||||
return api_error('Invalid channels', 400)
|
||||
|
||||
if channel:
|
||||
try:
|
||||
channel = validate_wifi_channel(channel)
|
||||
except ValueError:
|
||||
return api_error('Invalid channel', 400)
|
||||
|
||||
scanner = get_wifi_scanner()
|
||||
success = scanner.start_deep_scan(
|
||||
interface=interface,
|
||||
band=band,
|
||||
channel=channel,
|
||||
channels=channel_list,
|
||||
)
|
||||
success = scanner.start_deep_scan(
|
||||
interface=interface,
|
||||
band=band,
|
||||
channel=channel,
|
||||
channels=channel_list,
|
||||
)
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
@@ -133,10 +134,7 @@ def start_deep_scan():
|
||||
'interface': interface or scanner._capabilities.monitor_interface,
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error': scanner._status.error,
|
||||
}), 400
|
||||
return api_error(scanner._status.error or 'Scan failed', 400)
|
||||
|
||||
|
||||
@wifi_v2_bp.route('/scan/stop', methods=['POST'])
|
||||
@@ -235,7 +233,7 @@ def get_network(bssid):
|
||||
if network:
|
||||
return jsonify(network.to_dict())
|
||||
else:
|
||||
return jsonify({'error': 'Network not found'}), 404
|
||||
return api_error('Network not found', 404)
|
||||
|
||||
|
||||
@wifi_v2_bp.route('/clients', methods=['GET'])
|
||||
@@ -282,7 +280,7 @@ def get_client(mac):
|
||||
if client:
|
||||
return jsonify(client.to_dict())
|
||||
else:
|
||||
return jsonify({'error': 'Client not found'}), 404
|
||||
return api_error('Client not found', 404)
|
||||
|
||||
|
||||
@wifi_v2_bp.route('/probes', methods=['GET'])
|
||||
@@ -406,14 +404,12 @@ def event_stream():
|
||||
- keepalive: Periodic keepalive
|
||||
"""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
scanner = get_wifi_scanner()
|
||||
|
||||
for event in scanner.get_event_stream():
|
||||
try:
|
||||
process_event('wifi', event, event.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
yield format_sse(event)
|
||||
scanner = get_wifi_scanner()
|
||||
|
||||
for event in scanner.get_event_stream():
|
||||
with contextlib.suppress(Exception):
|
||||
process_event('wifi', event, event.get('type'))
|
||||
yield format_sse(event)
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env bash
|
||||
# INTERCEPT - Production Startup Script
|
||||
#
|
||||
# Starts INTERCEPT with gunicorn + gevent for production use.
|
||||
# Falls back to Flask dev server if gunicorn is not installed.
|
||||
#
|
||||
# Requires sudo for SDR, WiFi monitor mode, and Bluetooth access.
|
||||
#
|
||||
# Usage:
|
||||
# sudo ./start.sh # Default: 0.0.0.0:5050
|
||||
# sudo ./start.sh -p 8080 # Custom port
|
||||
# sudo ./start.sh --https # HTTPS with self-signed cert
|
||||
# sudo ./start.sh --debug # Debug mode (Flask dev server)
|
||||
# sudo ./start.sh --check-deps # Check dependencies and exit
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Resolve Python from venv or system ───────────────────────────────────────
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# ── Load .env if present ──────────────────────────────────────────────────────
|
||||
if [[ -f "$SCRIPT_DIR/.env" ]]; then
|
||||
set -a
|
||||
source "$SCRIPT_DIR/.env"
|
||||
set +a
|
||||
fi
|
||||
|
||||
if [[ -x "$SCRIPT_DIR/venv/bin/python" ]]; then
|
||||
PYTHON="$SCRIPT_DIR/venv/bin/python"
|
||||
elif [[ -n "${VIRTUAL_ENV:-}" && -x "$VIRTUAL_ENV/bin/python" ]]; then
|
||||
PYTHON="$VIRTUAL_ENV/bin/python"
|
||||
else
|
||||
PYTHON="$(command -v python3 || command -v python)"
|
||||
fi
|
||||
|
||||
# ── Defaults (can be overridden by env vars or CLI flags) ────────────────────
|
||||
HOST="${INTERCEPT_HOST:-0.0.0.0}"
|
||||
PORT="${INTERCEPT_PORT:-5050}"
|
||||
DEBUG=0
|
||||
HTTPS=0
|
||||
CHECK_DEPS=0
|
||||
|
||||
# ── Parse CLI arguments ─────────────────────────────────────────────────────
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-p|--port)
|
||||
PORT="$2"
|
||||
shift 2
|
||||
;;
|
||||
-H|--host)
|
||||
HOST="$2"
|
||||
shift 2
|
||||
;;
|
||||
-d|--debug)
|
||||
DEBUG=1
|
||||
shift
|
||||
;;
|
||||
--https)
|
||||
HTTPS=1
|
||||
shift
|
||||
;;
|
||||
--check-deps)
|
||||
CHECK_DEPS=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
echo "Usage: start.sh [OPTIONS]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -p, --port PORT Port to listen on (default: 5050)"
|
||||
echo " -H, --host HOST Host to bind to (default: 0.0.0.0)"
|
||||
echo " -d, --debug Run in debug mode (Flask dev server)"
|
||||
echo " --https Enable HTTPS with self-signed certificate"
|
||||
echo " --check-deps Check dependencies and exit"
|
||||
echo " -h, --help Show this help message"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Export for config.py ─────────────────────────────────────────────────────
|
||||
export INTERCEPT_HOST="$HOST"
|
||||
export INTERCEPT_PORT="$PORT"
|
||||
|
||||
# ── macOS: allow fork() after ObjC initialisation (gunicorn + gevent) ────
|
||||
if [[ "$(uname)" == "Darwin" ]]; then
|
||||
export OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES
|
||||
fi
|
||||
|
||||
# ── Fix ownership of user data dirs when run via sudo ────────────────────────
|
||||
# When invoked via sudo the server process runs as root, so every file it
|
||||
# creates (configs, logs, database) ends up owned by root. On the *next*
|
||||
# startup we fix that retroactively, and we also pre-create known runtime
|
||||
# directories so they get correct ownership from the start.
|
||||
if [[ "$(id -u)" -eq 0 && -n "${SUDO_USER:-}" ]]; then
|
||||
# Pre-create directories that routes may need at runtime
|
||||
mkdir -p "$SCRIPT_DIR/instance" \
|
||||
"$SCRIPT_DIR/data/radiosonde/logs" \
|
||||
"$SCRIPT_DIR/data/weather_sat"
|
||||
|
||||
for dir in instance data certs; do
|
||||
if [[ -d "$SCRIPT_DIR/$dir" ]]; then
|
||||
chown -R "$SUDO_USER" "$SCRIPT_DIR/$dir"
|
||||
fi
|
||||
done
|
||||
|
||||
# Export real user identity so Python can chown runtime-created files
|
||||
export INTERCEPT_SUDO_UID="$(id -u "$SUDO_USER")"
|
||||
export INTERCEPT_SUDO_GID="$(id -g "$SUDO_USER")"
|
||||
fi
|
||||
|
||||
# ── Dependency check (delegate to intercept.py) ─────────────────────────────
|
||||
if [[ "$CHECK_DEPS" -eq 1 ]]; then
|
||||
exec "$PYTHON" intercept.py --check-deps
|
||||
fi
|
||||
|
||||
# ── Debug mode always uses Flask dev server ──────────────────────────────────
|
||||
if [[ "$DEBUG" -eq 1 ]]; then
|
||||
echo "[INTERCEPT] Starting in debug mode (Flask dev server)..."
|
||||
export INTERCEPT_DEBUG=1
|
||||
exec "$PYTHON" intercept.py --host "$HOST" --port "$PORT" --debug
|
||||
fi
|
||||
|
||||
# ── HTTPS certificate generation ────────────────────────────────────────────
|
||||
CERT_DIR="certs"
|
||||
CERT_FILE="$CERT_DIR/intercept.crt"
|
||||
KEY_FILE="$CERT_DIR/intercept.key"
|
||||
|
||||
if [[ "$HTTPS" -eq 1 ]]; then
|
||||
if [[ ! -f "$CERT_FILE" || ! -f "$KEY_FILE" ]]; then
|
||||
echo "[INTERCEPT] Generating self-signed SSL certificate..."
|
||||
mkdir -p "$CERT_DIR"
|
||||
openssl req -x509 -newkey rsa:2048 \
|
||||
-keyout "$KEY_FILE" -out "$CERT_FILE" \
|
||||
-days 365 -nodes \
|
||||
-subj '/CN=intercept/O=INTERCEPT/C=US' 2>/dev/null
|
||||
echo "[INTERCEPT] SSL certificate generated: $CERT_FILE"
|
||||
else
|
||||
echo "[INTERCEPT] Using existing SSL certificate: $CERT_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Detect gunicorn + gevent ─────────────────────────────────────────────────
|
||||
HAS_GUNICORN=0
|
||||
HAS_GEVENT=0
|
||||
|
||||
if "$PYTHON" -c "import gunicorn" 2>/dev/null; then
|
||||
HAS_GUNICORN=1
|
||||
fi
|
||||
if "$PYTHON" -c "import gevent" 2>/dev/null; then
|
||||
HAS_GEVENT=1
|
||||
fi
|
||||
|
||||
# ── Resolve LAN address for display ──────────────────────────────────────────
|
||||
if [[ "$HOST" == "0.0.0.0" ]]; then
|
||||
LAN_IP=$(hostname -I 2>/dev/null | awk '{print $1}' || true)
|
||||
# hostname -I on macOS fails or returns empty — try macOS methods
|
||||
if [[ -z "$LAN_IP" ]]; then
|
||||
LAN_IP=$(ipconfig getifaddr en0 2>/dev/null || true)
|
||||
fi
|
||||
if [[ -z "$LAN_IP" ]]; then
|
||||
LAN_IP=$(ipconfig getifaddr en1 2>/dev/null || true)
|
||||
fi
|
||||
if [[ -z "$LAN_IP" ]]; then
|
||||
LAN_IP=$(ifconfig 2>/dev/null | grep "inet " | grep -v 127.0.0.1 | head -1 | awk '{print $2}' || true)
|
||||
fi
|
||||
LAN_IP="${LAN_IP:-localhost}"
|
||||
else
|
||||
LAN_IP="$HOST"
|
||||
fi
|
||||
PROTO="http"
|
||||
[[ "$HTTPS" -eq 1 ]] && PROTO="https"
|
||||
|
||||
# ── Start the server ─────────────────────────────────────────────────────────
|
||||
if [[ "$HAS_GUNICORN" -eq 1 && "$HAS_GEVENT" -eq 1 ]]; then
|
||||
echo "[INTERCEPT] Starting production server (gunicorn + gevent)..."
|
||||
echo "[INTERCEPT] Listening on ${PROTO}://${LAN_IP}:${PORT}"
|
||||
|
||||
GUNICORN_ARGS=(
|
||||
-c "$SCRIPT_DIR/gunicorn.conf.py"
|
||||
-k gevent
|
||||
-w 1
|
||||
--timeout 300
|
||||
--graceful-timeout 5
|
||||
--worker-connections 1000
|
||||
--bind "${HOST}:${PORT}"
|
||||
--access-logfile -
|
||||
--error-logfile -
|
||||
)
|
||||
|
||||
if [[ "$HTTPS" -eq 1 ]]; then
|
||||
GUNICORN_ARGS+=(--certfile "$CERT_FILE" --keyfile "$KEY_FILE")
|
||||
echo "[INTERCEPT] HTTPS enabled"
|
||||
fi
|
||||
|
||||
exec "$PYTHON" -m gunicorn "${GUNICORN_ARGS[@]}" app:app
|
||||
else
|
||||
if [[ "$HAS_GUNICORN" -eq 0 ]]; then
|
||||
echo "[INTERCEPT] gunicorn not found — falling back to Flask dev server"
|
||||
fi
|
||||
if [[ "$HAS_GEVENT" -eq 0 ]]; then
|
||||
echo "[INTERCEPT] gevent not found — falling back to Flask dev server"
|
||||
fi
|
||||
echo "[INTERCEPT] Install with: pip install gunicorn gevent"
|
||||
echo ""
|
||||
|
||||
FLASK_ARGS=(--host "$HOST" --port "$PORT")
|
||||
if [[ "$HTTPS" -eq 1 ]]; then
|
||||
FLASK_ARGS+=(--https)
|
||||
fi
|
||||
|
||||
exec "$PYTHON" intercept.py "${FLASK_ARGS[@]}"
|
||||
fi
|
||||
@@ -414,7 +414,7 @@ body {
|
||||
.acars-sidebar .acars-btn {
|
||||
background: var(--accent-green);
|
||||
border: none;
|
||||
color: #fff;
|
||||
color: var(--text-inverse);
|
||||
padding: 6px 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
@@ -575,7 +575,7 @@ body {
|
||||
.vdl2-sidebar .vdl2-btn {
|
||||
background: var(--accent-green);
|
||||
border: none;
|
||||
color: #fff;
|
||||
color: var(--text-inverse);
|
||||
padding: 6px 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
@@ -1246,7 +1246,7 @@ body {
|
||||
|
||||
.control-group select {
|
||||
padding: 4px 8px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
background: var(--bg-dark);
|
||||
border: 1px solid rgba(74, 158, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
color: var(--accent-cyan);
|
||||
@@ -1347,7 +1347,7 @@ body {
|
||||
padding: 6px 16px;
|
||||
border: none;
|
||||
background: var(--accent-green);
|
||||
color: #fff;
|
||||
color: var(--text-inverse);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
@@ -1365,7 +1365,7 @@ body {
|
||||
|
||||
.start-btn.active {
|
||||
background: var(--accent-red);
|
||||
color: #fff;
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.start-btn.active:hover {
|
||||
@@ -1497,7 +1497,7 @@ body {
|
||||
padding: 6px 12px;
|
||||
background: var(--accent-green);
|
||||
border: none;
|
||||
color: #fff;
|
||||
color: var(--text-inverse);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
@@ -1518,7 +1518,7 @@ body {
|
||||
|
||||
.airband-btn.active {
|
||||
background: var(--accent-red);
|
||||
color: #fff;
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.airband-btn.active:hover {
|
||||
@@ -1912,7 +1912,7 @@ body {
|
||||
.strip-report-btn {
|
||||
background: linear-gradient(135deg, var(--accent-cyan) 0%, #2b6fb8 100%);
|
||||
border: none;
|
||||
color: white;
|
||||
color: var(--text-inverse);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
@@ -2224,7 +2224,7 @@ body {
|
||||
.strip-btn.primary {
|
||||
background: linear-gradient(135deg, var(--accent-cyan) 0%, #2b6fb8 100%);
|
||||
border: none;
|
||||
color: white;
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.strip-btn.primary:hover {
|
||||
@@ -2449,7 +2449,7 @@ body {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
@media (max-width: 480px) {
|
||||
.squawk-item {
|
||||
grid-template-columns: 45px 80px 1fr;
|
||||
gap: 8px;
|
||||
|
||||
@@ -269,6 +269,21 @@ body {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.data-control-group {
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
.data-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.data-actions input[type="date"] {
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
background: var(--accent-cyan);
|
||||
border: none;
|
||||
@@ -285,6 +300,31 @@ body {
|
||||
box-shadow: 0 6px 14px rgba(74, 158, 255, 0.3);
|
||||
}
|
||||
|
||||
.primary-btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.warn-btn {
|
||||
background: var(--accent-amber);
|
||||
color: #0a0c10;
|
||||
}
|
||||
|
||||
.warn-btn:hover {
|
||||
box-shadow: 0 6px 14px rgba(214, 168, 94, 0.3);
|
||||
}
|
||||
|
||||
.danger-btn {
|
||||
background: #d84f63;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.danger-btn:hover {
|
||||
box-shadow: 0 6px 14px rgba(216, 79, 99, 0.35);
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
@@ -296,6 +336,16 @@ body {
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.status-pill.ok {
|
||||
border-color: var(--accent-green);
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.status-pill.error {
|
||||
border-color: #d84f63;
|
||||
color: #d84f63;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(300px, 1fr) minmax(320px, 1fr);
|
||||
@@ -364,6 +414,37 @@ body {
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
}
|
||||
|
||||
.aircraft-row.military {
|
||||
background: rgba(85, 107, 47, 0.12);
|
||||
}
|
||||
|
||||
.aircraft-row.military:hover {
|
||||
background: rgba(85, 107, 47, 0.22);
|
||||
}
|
||||
|
||||
.mil-badge,
|
||||
.civ-badge {
|
||||
display: inline-block;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.8px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.mil-badge {
|
||||
background: rgba(85, 107, 47, 0.35);
|
||||
color: #a3b86c;
|
||||
border: 1px solid rgba(85, 107, 47, 0.6);
|
||||
}
|
||||
|
||||
.civ-badge {
|
||||
background: rgba(74, 158, 255, 0.15);
|
||||
color: var(--text-dim);
|
||||
border: 1px solid rgba(74, 158, 255, 0.25);
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
@@ -603,7 +684,7 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
@media (max-width: 768px) {
|
||||
.controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
@@ -614,6 +695,15 @@ body {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.data-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.data-actions input[type="date"],
|
||||
.data-actions .primary-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.panel {
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
@@ -394,7 +394,7 @@ body {
|
||||
.strip-btn.primary {
|
||||
background: linear-gradient(135deg, var(--accent-cyan) 0%, #2b6fb8 100%);
|
||||
border: none;
|
||||
color: white;
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
/* Main dashboard grid - Mobile first */
|
||||
@@ -779,7 +779,7 @@ body {
|
||||
|
||||
.control-group select {
|
||||
padding: 4px 8px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
background: var(--bg-dark);
|
||||
border: 1px solid rgba(74, 158, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
color: var(--accent-cyan);
|
||||
@@ -812,7 +812,7 @@ body {
|
||||
padding: 6px 16px;
|
||||
border: none;
|
||||
background: var(--accent-green);
|
||||
color: #fff;
|
||||
color: var(--text-inverse);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
@@ -830,7 +830,7 @@ body {
|
||||
|
||||
.start-btn.active {
|
||||
background: var(--accent-red);
|
||||
color: #fff;
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.start-btn.active:hover {
|
||||
@@ -1282,7 +1282,7 @@ body {
|
||||
.dsc-distress-alert button {
|
||||
background: var(--accent-red);
|
||||
border: none;
|
||||
color: white;
|
||||
color: var(--text-inverse);
|
||||
padding: 10px 24px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
@@ -1313,7 +1313,7 @@ body {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
color: white;
|
||||
color: var(--text-inverse);
|
||||
border: 2px solid white;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@
|
||||
|
||||
.activity-timeline-btn.active {
|
||||
background: var(--timeline-accent);
|
||||
color: #000;
|
||||
color: var(--text-inverse);
|
||||
border-color: var(--timeline-accent);
|
||||
}
|
||||
|
||||
|
||||
@@ -522,7 +522,7 @@
|
||||
/* ============================================
|
||||
RESPONSIVE ADJUSTMENTS
|
||||
============================================ */
|
||||
@media (max-width: 600px) {
|
||||
@media (max-width: 480px) {
|
||||
.device-signal-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
@@ -841,7 +841,7 @@
|
||||
/* ============================================
|
||||
RESPONSIVE MODAL
|
||||
============================================ */
|
||||
@media (max-width: 600px) {
|
||||
@media (max-width: 480px) {
|
||||
.modal-signal-stats {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@@ -185,7 +185,7 @@
|
||||
.function-strip .strip-btn.primary {
|
||||
background: linear-gradient(135deg, var(--accent-green) 0%, #10b981 100%);
|
||||
border: none;
|
||||
color: #000;
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.function-strip .strip-btn.primary:hover:not(:disabled) {
|
||||
@@ -195,7 +195,7 @@
|
||||
.function-strip .strip-btn.stop {
|
||||
background: linear-gradient(135deg, var(--accent-red) 0%, #dc2626 100%);
|
||||
border: none;
|
||||
color: #fff;
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.function-strip .strip-btn.stop:hover:not(:disabled) {
|
||||
@@ -304,7 +304,7 @@
|
||||
border-color: rgba(0, 122, 255, 0.3);
|
||||
}
|
||||
.function-strip.bt-strip .strip-value {
|
||||
color: #0a84ff;
|
||||
color: var(--accent-blue, #0a84ff);
|
||||
}
|
||||
|
||||
.function-strip.wifi-strip .strip-stat {
|
||||
@@ -332,24 +332,24 @@
|
||||
border-color: rgba(255, 59, 48, 0.6);
|
||||
}
|
||||
.function-strip.tscm-strip .strip-value {
|
||||
color: #ef4444; /* Explicit red color */
|
||||
color: var(--accent-red);
|
||||
}
|
||||
.function-strip.tscm-strip .strip-label {
|
||||
color: #9ca3af; /* Explicit light gray */
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.function-strip.tscm-strip .strip-select {
|
||||
color: #e8eaed; /* Explicit white for selects */
|
||||
color: var(--text-primary);
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.function-strip.tscm-strip .strip-btn {
|
||||
color: #e8eaed; /* Explicit white for buttons */
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.function-strip.tscm-strip .strip-tool {
|
||||
color: #e8eaed; /* Explicit white for tool indicators */
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.function-strip.tscm-strip .strip-time,
|
||||
.function-strip.tscm-strip .strip-status span {
|
||||
color: #9ca3af; /* Explicit gray for status/time */
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.function-strip.rtlamr-strip .strip-stat {
|
||||
@@ -361,7 +361,7 @@
|
||||
border-color: rgba(175, 82, 222, 0.3);
|
||||
}
|
||||
.function-strip.rtlamr-strip .strip-value {
|
||||
color: #af52de;
|
||||
color: var(--accent-purple, #af52de);
|
||||
}
|
||||
|
||||
.function-strip.listening-strip .strip-stat {
|
||||
|
||||
@@ -1,293 +1,293 @@
|
||||
/**
|
||||
* Proximity Visualization Components
|
||||
* Styles for radar and timeline heatmap
|
||||
*/
|
||||
|
||||
/* ============================================
|
||||
PROXIMITY RADAR
|
||||
============================================ */
|
||||
|
||||
.proximity-radar-svg {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.radar-device {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.radar-device:hover .radar-dot {
|
||||
filter: brightness(1.5);
|
||||
}
|
||||
|
||||
/* Invisible larger hit area to prevent hover flicker */
|
||||
.radar-device-hitarea {
|
||||
fill: transparent;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.radar-dot-pulse circle:first-child {
|
||||
animation: radar-pulse 1.5s ease-out infinite;
|
||||
}
|
||||
|
||||
@keyframes radar-pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.radar-sweep {
|
||||
transform-origin: 50% 50%;
|
||||
}
|
||||
|
||||
/* Radar filter buttons */
|
||||
.bt-radar-filter-btn {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.bt-radar-filter-btn:hover {
|
||||
background: var(--bg-hover, #333) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.bt-radar-filter-btn.active {
|
||||
background: #00d4ff !important;
|
||||
color: #000 !important;
|
||||
border-color: #00d4ff !important;
|
||||
}
|
||||
|
||||
#btRadarPauseBtn.active {
|
||||
background: #f97316 !important;
|
||||
color: #000 !important;
|
||||
border-color: #f97316 !important;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
TIMELINE HEATMAP
|
||||
============================================ */
|
||||
|
||||
.timeline-heatmap-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
margin-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border-color, #333);
|
||||
}
|
||||
|
||||
.heatmap-control-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim, #888);
|
||||
}
|
||||
|
||||
.heatmap-select {
|
||||
background: var(--bg-tertiary, #1a1a1a);
|
||||
border: 1px solid var(--border-color, #333);
|
||||
border-radius: 4px;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
font-size: 10px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.heatmap-select:hover {
|
||||
border-color: var(--accent-color, #00d4ff);
|
||||
}
|
||||
|
||||
.heatmap-btn {
|
||||
background: var(--bg-tertiary, #1a1a1a);
|
||||
border: 1px solid var(--border-color, #333);
|
||||
border-radius: 4px;
|
||||
color: var(--text-dim, #888);
|
||||
font-size: 10px;
|
||||
padding: 4px 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.heatmap-btn:hover {
|
||||
background: var(--bg-hover, #252525);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
.heatmap-btn.active {
|
||||
background: #f97316;
|
||||
color: #000;
|
||||
border-color: #f97316;
|
||||
}
|
||||
|
||||
.timeline-heatmap-content {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.heatmap-loading,
|
||||
.heatmap-empty,
|
||||
.heatmap-error {
|
||||
color: var(--text-dim, #666);
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.heatmap-error {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.heatmap-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
.heatmap-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 0;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.heatmap-row:hover:not(.heatmap-header) {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.heatmap-row.selected {
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
outline: 1px solid rgba(0, 212, 255, 0.3);
|
||||
}
|
||||
|
||||
.heatmap-header {
|
||||
cursor: default;
|
||||
border-bottom: 1px solid var(--border-color, #333);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.heatmap-label {
|
||||
width: 120px;
|
||||
min-width: 120px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding-right: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.heatmap-label .device-name {
|
||||
font-size: 10px;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.heatmap-label .device-rssi {
|
||||
font-size: 9px;
|
||||
color: var(--text-dim, #666);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.heatmap-cells {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.heatmap-cell {
|
||||
border-radius: 2px;
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
.heatmap-cell:hover {
|
||||
transform: scale(1.5);
|
||||
z-index: 10;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.heatmap-time-label {
|
||||
font-size: 8px;
|
||||
color: var(--text-dim, #666);
|
||||
text-align: center;
|
||||
transform: rotate(-45deg);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.heatmap-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding-top: 8px;
|
||||
margin-top: 8px;
|
||||
border-top: 1px solid var(--border-color, #333);
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #666);
|
||||
}
|
||||
|
||||
.legend-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.legend-color {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
ZONE SUMMARY
|
||||
============================================ */
|
||||
|
||||
#btZoneSummary {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
#btZoneSummary > div {
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
RESPONSIVE ADJUSTMENTS
|
||||
============================================ */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.timeline-heatmap-controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.heatmap-control-group {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.proximity-radar-svg {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#btRadarControls {
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
#btZoneSummary {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Proximity Visualization Components
|
||||
* Styles for radar and timeline heatmap
|
||||
*/
|
||||
|
||||
/* ============================================
|
||||
PROXIMITY RADAR
|
||||
============================================ */
|
||||
|
||||
.proximity-radar-svg {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.radar-device {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.radar-device:hover .radar-dot {
|
||||
filter: brightness(1.5);
|
||||
}
|
||||
|
||||
/* Invisible larger hit area to prevent hover flicker */
|
||||
.radar-device-hitarea {
|
||||
fill: transparent;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.radar-dot-pulse circle:first-child {
|
||||
animation: radar-pulse 1.5s ease-out infinite;
|
||||
}
|
||||
|
||||
@keyframes radar-pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.radar-sweep {
|
||||
transform-origin: 50% 50%;
|
||||
}
|
||||
|
||||
/* Radar filter buttons */
|
||||
.bt-radar-filter-btn {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.bt-radar-filter-btn:hover {
|
||||
background: var(--bg-hover, #333) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
.bt-radar-filter-btn.active {
|
||||
background: var(--accent-cyan) !important;
|
||||
color: var(--text-inverse) !important;
|
||||
border-color: var(--accent-cyan) !important;
|
||||
}
|
||||
|
||||
#btRadarPauseBtn.active {
|
||||
background: var(--accent-orange) !important;
|
||||
color: var(--text-inverse) !important;
|
||||
border-color: var(--accent-orange) !important;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
TIMELINE HEATMAP
|
||||
============================================ */
|
||||
|
||||
.timeline-heatmap-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
margin-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border-color, #333);
|
||||
}
|
||||
|
||||
.heatmap-control-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim, #888);
|
||||
}
|
||||
|
||||
.heatmap-select {
|
||||
background: var(--bg-tertiary, #1a1a1a);
|
||||
border: 1px solid var(--border-color, #333);
|
||||
border-radius: 4px;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
font-size: 10px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.heatmap-select:hover {
|
||||
border-color: var(--accent-color, #00d4ff);
|
||||
}
|
||||
|
||||
.heatmap-btn {
|
||||
background: var(--bg-tertiary, #1a1a1a);
|
||||
border: 1px solid var(--border-color, #333);
|
||||
border-radius: 4px;
|
||||
color: var(--text-dim, #888);
|
||||
font-size: 10px;
|
||||
padding: 4px 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.heatmap-btn:hover {
|
||||
background: var(--bg-hover, #252525);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
.heatmap-btn.active {
|
||||
background: var(--accent-orange);
|
||||
color: var(--text-inverse);
|
||||
border-color: var(--accent-orange);
|
||||
}
|
||||
|
||||
.timeline-heatmap-content {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.heatmap-loading,
|
||||
.heatmap-empty,
|
||||
.heatmap-error {
|
||||
color: var(--text-dim, #666);
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.heatmap-error {
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
.heatmap-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
.heatmap-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 0;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.heatmap-row:hover:not(.heatmap-header) {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.heatmap-row.selected {
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
outline: 1px solid rgba(0, 212, 255, 0.3);
|
||||
}
|
||||
|
||||
.heatmap-header {
|
||||
cursor: default;
|
||||
border-bottom: 1px solid var(--border-color, #333);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.heatmap-label {
|
||||
width: 120px;
|
||||
min-width: 120px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding-right: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.heatmap-label .device-name {
|
||||
font-size: 10px;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.heatmap-label .device-rssi {
|
||||
font-size: 9px;
|
||||
color: var(--text-dim, #666);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.heatmap-cells {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.heatmap-cell {
|
||||
border-radius: 2px;
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
.heatmap-cell:hover {
|
||||
transform: scale(1.5);
|
||||
z-index: 10;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.heatmap-time-label {
|
||||
font-size: 8px;
|
||||
color: var(--text-dim, #666);
|
||||
text-align: center;
|
||||
transform: rotate(-45deg);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.heatmap-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding-top: 8px;
|
||||
margin-top: 8px;
|
||||
border-top: 1px solid var(--border-color, #333);
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #666);
|
||||
}
|
||||
|
||||
.legend-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.legend-color {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
ZONE SUMMARY
|
||||
============================================ */
|
||||
|
||||
#btZoneSummary {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
#btZoneSummary > div {
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
RESPONSIVE ADJUSTMENTS
|
||||
============================================ */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.timeline-heatmap-controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.heatmap-control-group {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.proximity-radar-svg {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#btRadarControls {
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
#btZoneSummary {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,19 +279,19 @@
|
||||
|
||||
.signal-proto-badge.aprs {
|
||||
background: rgba(6, 182, 212, 0.15);
|
||||
color: #06b6d4;
|
||||
color: var(--proto-aprs, #06b6d4);
|
||||
border-color: rgba(6, 182, 212, 0.25);
|
||||
}
|
||||
|
||||
.signal-proto-badge.ais {
|
||||
background: rgba(139, 92, 246, 0.15);
|
||||
color: #8b5cf6;
|
||||
color: var(--proto-ais, #8b5cf6);
|
||||
border-color: rgba(139, 92, 246, 0.25);
|
||||
}
|
||||
|
||||
.signal-proto-badge.acars {
|
||||
background: rgba(236, 72, 153, 0.15);
|
||||
color: #ec4899;
|
||||
color: var(--proto-acars, #ec4899);
|
||||
border-color: rgba(236, 72, 153, 0.25);
|
||||
}
|
||||
|
||||
@@ -976,25 +976,25 @@
|
||||
/* Meter protocol badges */
|
||||
.signal-proto-badge.meter {
|
||||
background: rgba(234, 179, 8, 0.15);
|
||||
color: #eab308;
|
||||
color: var(--accent-yellow, #eab308);
|
||||
border-color: rgba(234, 179, 8, 0.25);
|
||||
}
|
||||
|
||||
.signal-proto-badge.meter.electric {
|
||||
background: rgba(234, 179, 8, 0.15);
|
||||
color: #eab308;
|
||||
color: var(--accent-yellow, #eab308);
|
||||
border-color: rgba(234, 179, 8, 0.25);
|
||||
}
|
||||
|
||||
.signal-proto-badge.meter.gas {
|
||||
background: rgba(249, 115, 22, 0.15);
|
||||
color: #f97316;
|
||||
color: var(--accent-orange, #f97316);
|
||||
border-color: rgba(249, 115, 22, 0.25);
|
||||
}
|
||||
|
||||
.signal-proto-badge.meter.water {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: #3b82f6;
|
||||
color: var(--signal-new, #3b82f6);
|
||||
border-color: rgba(59, 130, 246, 0.25);
|
||||
}
|
||||
|
||||
@@ -1060,12 +1060,12 @@
|
||||
|
||||
.meter-delta.positive {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: #22c55e;
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.meter-delta.negative {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #ef4444;
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
/* Sparkline container */
|
||||
@@ -1128,7 +1128,7 @@
|
||||
}
|
||||
|
||||
/* Responsive adjustments for aggregated meters */
|
||||
@media (max-width: 500px) {
|
||||
@media (max-width: 480px) {
|
||||
.meter-aggregated-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: auto auto;
|
||||
@@ -1431,7 +1431,7 @@
|
||||
|
||||
.signal-station-clickable:hover {
|
||||
background: var(--accent-purple);
|
||||
color: #000;
|
||||
color: var(--text-inverse);
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 0 8px rgba(138, 43, 226, 0.4);
|
||||
}
|
||||
@@ -1587,14 +1587,14 @@
|
||||
background: var(--accent-purple, #8a2be2);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
color: var(--text-inverse);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.station-raw-copy-btn:hover {
|
||||
background: var(--accent-cyan, #00d4ff);
|
||||
color: #000;
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
@@ -1794,14 +1794,14 @@
|
||||
background: var(--accent-purple, #8a2be2);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
color: var(--text-inverse);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.signal-details-copy-btn:hover {
|
||||
background: var(--accent-cyan, #00d4ff);
|
||||
color: #000;
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
/* Signal Details Content Sections */
|
||||
@@ -1922,7 +1922,7 @@
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 500px) {
|
||||
@media (max-width: 480px) {
|
||||
.signal-details-modal-content {
|
||||
width: 95%;
|
||||
max-height: 90vh;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user