mirror of
https://github.com/smittix/intercept.git
synced 2026-07-05 08:08:14 -07:00
Compare commits
393 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 526f21b244 | |||
| 1ded1e259d | |||
| c2d766fcfa | |||
| 339f6cc04b | |||
| 76814a51b3 | |||
| 11b94036ff | |||
| df66d1e445 | |||
| 5f1d38282c | |||
| 115dffd18e | |||
| 76fcce949c | |||
| 60a4dd1c90 | |||
| bbcd9257f3 | |||
| 48b9d9d05a | |||
| 0d45d5ce07 | |||
| f792f2b164 | |||
| 0801da3ba3 | |||
| d8788121e0 | |||
| 386b95a25d | |||
| 753a08234e | |||
| 276b151e9e | |||
| 30450295b5 | |||
| 47c0fcbefa | |||
| 2ec5085673 | |||
| 379b6a9667 | |||
| 5cff7de117 | |||
| 0588055d1f | |||
| e14271c5ee | |||
| e38b8fb464 | |||
| a1b1e5a77e | |||
| 5d3811cc60 | |||
| 8813d069bc | |||
| cdb5285b68 | |||
| 67847eb708 | |||
| c870f118bf | |||
| a202c9dd94 | |||
| 07887b7c99 | |||
| 0af3028151 | |||
| 5e996654fe | |||
| 320fe82348 | |||
| 1c72e15c7c | |||
| 74d5663f73 | |||
| f4a9cb7da6 | |||
| 2f6afd5e28 | |||
| 9463d53763 | |||
| c177dd354a | |||
| d4652017f5 | |||
| b68a53eb53 | |||
| d68d1ec53a | |||
| 9c15ece508 | |||
| fe222c0393 | |||
| 68cafe8cd0 | |||
| d01742678c | |||
| 31ae70b8fa | |||
| e7f13a5856 | |||
| a9ed367148 | |||
| 2505218385 | |||
| b5c35890af | |||
| 484d9ce21b | |||
| 9353527e1b | |||
| fd3ad63971 | |||
| 2e583649d0 | |||
| a3c509aa94 | |||
| f26a820b1d | |||
| 901e7f95e8 | |||
| 592d11aae2 | |||
| 30a0085f1d | |||
| b30d883974 | |||
| ea8f72f7ff | |||
| a3f2fa7b88 | |||
| 5100f55586 | |||
| 9d41ffbb59 | |||
| 517eb8cb77 | |||
| 9d72c88a28 | |||
| e1922d7a30 | |||
| c48d66d1b4 | |||
| fbea33e7cb | |||
| af26a01703 | |||
| 336d0d81ec | |||
| 076d17da18 | |||
| 5b4b99707a | |||
| db7b056cf4 | |||
| eb0512b3c0 | |||
| cafc2554de | |||
| 2b25d5cbad | |||
| 0a75322ad1 | |||
| 95776f5519 | |||
| 678aefd76e | |||
| 41a720f1f6 | |||
| 0b5d858187 | |||
| e65a25e526 | |||
| dbe2003d75 | |||
| a5f92ded37 | |||
| f7cf0a87cc | |||
| 902a21fc40 | |||
| eeaf87c7f2 | |||
| e6e6cb3b9a | |||
| 260240728a | |||
| 0e0e17b089 | |||
| efc14b4de0 | |||
| 6ed24b758d | |||
| 646eb09e1d | |||
| 1dd3e485a6 | |||
| 77cdd56641 | |||
| b26d94c967 | |||
| 98e01f4c5b | |||
| 32f245e6ef | |||
| bf50cb4acd | |||
| e778efa5b6 | |||
| 7d537998ca | |||
| daaf3d2158 | |||
| 2dfdcd39f1 | |||
| 5e568f59ba | |||
| ae5664dbb4 | |||
| e64d82ebb5 | |||
| c5fdf7f7e9 | |||
| a59d4ec603 | |||
| 2cdf156cd0 | |||
| 7c535d7ba8 | |||
| f7d8af493a | |||
| 46f076077b | |||
| 020126b6e0 | |||
| 99268e47b8 | |||
| 7940728b30 | |||
| c4d6d50687 | |||
| 52ab1b60a3 | |||
| 9641c43384 | |||
| 1f7e0881f3 | |||
| b2cc6b65ad | |||
| 28a779b91b | |||
| e397a69dae | |||
| 3554817f91 | |||
| 410225d54d | |||
| 4ba8a40af9 | |||
| 6523686aca | |||
| 2475e5dd5a | |||
| 8f6bfb4df1 | |||
| 36a1542176 | |||
| 71011dd67c | |||
| 173ddc9eac | |||
| 53699482e1 | |||
| e5c5afb158 | |||
| f2af2ad0b6 | |||
| 3f6f8a5695 | |||
| 0d9bb53722 | |||
| d84cd41896 | |||
| ed4b6ef897 | |||
| f50f5e2d44 | |||
| 71eaf1d22a | |||
| ba02f761c6 | |||
| 80bbdb2c09 | |||
| 6807ee6878 | |||
| 04d2e1a7bf | |||
| 07e45f508a | |||
| 2b9665c723 | |||
| c73d02f76c | |||
| 5048711acb | |||
| 62e53c5dfa | |||
| 333c5cf8d3 | |||
| d033a95b0e | |||
| 3b480eb183 | |||
| 8632e31c01 | |||
| 14e6305aa4 | |||
| e059be2d84 | |||
| 1a99a7213f | |||
| f9e8fa896d | |||
| 59713ffc22 | |||
| 681a498461 | |||
| e8b94b6efc | |||
| 5dda961dbb | |||
| a6ce5d5426 | |||
| 772b5d0973 | |||
| b707468cb6 | |||
| e33dff1ab9 | |||
| 58222b3474 | |||
| b78ca51db1 | |||
| 4a149525bd | |||
| dd1c6b8b62 | |||
| a982ff5885 | |||
| 7cf94cce14 | |||
| 1dc45a285d | |||
| c70c93c814 | |||
| f51682f929 | |||
| f12f4145ef | |||
| 6dce911172 | |||
| 8ae19beef6 | |||
| 3693b02cb9 | |||
| ac2f7ea032 | |||
| 24f12b1220 | |||
| 88db107691 | |||
| 7dfefb48e6 | |||
| 12af6e250e | |||
| 5ffd9e5fb3 | |||
| 7d78bb45d6 | |||
| 9a82328de2 | |||
| 51f8a6f65b | |||
| 99edea33e3 | |||
| f97782724e | |||
| e4df3eaecb | |||
| 16b95e4804 | |||
| f1a029262b | |||
| 51c10144c7 | |||
| b5ae7fe472 | |||
| b75d28f284 | |||
| 4a3a7127ca | |||
| bfff092657 | |||
| f2f17ac26e | |||
| 8c61af2863 | |||
| 34fb030af1 | |||
| 238ad7936a | |||
| b01598753d | |||
| 3fedff9d08 | |||
| 1fc80b05b1 | |||
| 0210791c69 | |||
| 592e97719b | |||
| ea80b5ebc3 | |||
| fe64dd9c93 | |||
| f0fb97512a | |||
| 6ea34a4c60 | |||
| 6572119360 | |||
| efb7d0ed20 | |||
| 5b9d81e3a8 | |||
| 71e5599300 | |||
| 6967a44620 | |||
| ab4745c70a | |||
| d2c00b4b2c | |||
| d45b8bc2fb | |||
| 2511227c4e | |||
| 5ee60c5259 | |||
| 7a4dbb8260 | |||
| 73b227c49b | |||
| bfbf06f5c5 | |||
| e5a0635418 | |||
| 2fce80677a | |||
| 56ebdd7670 | |||
| 4c37d39e07 | |||
| d1d44195c1 | |||
| 0dbcb175c0 | |||
| ea348b3360 | |||
| 36399cf4aa | |||
| 837090d150 | |||
| d01cb4b6f3 | |||
| 3aadaf1c86 | |||
| 6de443e833 | |||
| f4672cf0c7 | |||
| b66ac935b7 | |||
| 7d704c9d42 | |||
| ebc838fa9d | |||
| 1e5bc0054d | |||
| 43fb735e4e | |||
| 1dde2a008e | |||
| af2ab567ca | |||
| 6928b8a622 | |||
| 205f396942 | |||
| 89c7c2fb07 | |||
| b20b9838d0 | |||
| 2d65c4efbf | |||
| 34e1d25069 | |||
| 90d39f12c1 | |||
| bca7888077 | |||
| cbc6275307 | |||
| b26ce4f56f | |||
| 44428c2517 | |||
| a670103325 | |||
| a2bd0e27f9 | |||
| 7ca018fd7b | |||
| 607a2f28fa | |||
| a42ea35d8b | |||
| 123d38d295 | |||
| 35c874da52 | |||
| ad4a4db160 | |||
| 72d4fab25e | |||
| 7c4342e560 | |||
| 33959403f4 | |||
| f549957c0b | |||
| e5abeba11c | |||
| 8cf1b05042 | |||
| cfcdc8e85e | |||
| d240ae06e3 | |||
| d84237dbb4 | |||
| 7194422c0e | |||
| d20808fb35 | |||
| 51b332f4cf | |||
| a8f73f9a73 | |||
| 4798652ad5 | |||
| 080464de98 | |||
| 8caec74c5c | |||
| 511cecb311 | |||
| 0992d6578c | |||
| 3f1564817c | |||
| b62b97ab57 | |||
| 2eeea3b74d | |||
| f05a5197cd | |||
| 016d05f082 | |||
| 302a362885 | |||
| 81c05859fc | |||
| f1881fdf52 | |||
| d0731120f9 | |||
| 7677b12f74 | |||
| ddaf5aa64e | |||
| 2418ae2d8b | |||
| 0916b62bfe | |||
| 0b22393395 | |||
| 9fa492e20c | |||
| fa46483dd9 | |||
| 18b442eb21 | |||
| 5f34d20287 | |||
| 5905aa6415 | |||
| aaed831420 | |||
| 007a8d50c6 | |||
| 02ce4d5bb6 | |||
| 613258c3a2 | |||
| 4410aa2433 | |||
| 54ad3b9362 | |||
| 2cf2c6af2a | |||
| f5f3e766ad | |||
| fb8b6a01e8 | |||
| db0a26cd64 | |||
| 8b1ca5ab96 | |||
| cb0fb4f3be | |||
| 334146b799 | |||
| 63237b9534 | |||
| 595a2003d5 | |||
| 3afaa6e1ee | |||
| 5731631ebc | |||
| ac445184b6 | |||
| 981b103b90 | |||
| af7b29b6b0 | |||
| 0ff0df632b | |||
| 73e17e8509 | |||
| 317e0d7108 | |||
| dd37a0b5a7 | |||
| 28f172a643 | |||
| 96146a2e2c | |||
| e32942fb35 | |||
| a61d4331f0 | |||
| 62ee2252a3 | |||
| 6fd5098b89 | |||
| 6941e704cd | |||
| 985c8a155a | |||
| d0402f4746 | |||
| 6dc0936d6d | |||
| 38a10cb0de | |||
| badf587be6 | |||
| a995fceb8c | |||
| 2a9c98a83d | |||
| 4cf394f92e | |||
| e388baa464 | |||
| 5cae753e0d | |||
| 86625cf3ec | |||
| 98bb6ce10b | |||
| cbe7f591e3 | |||
| 0078d539de | |||
| e1b532d48a | |||
| f043baed9f | |||
| 8d8ee57cec | |||
| 4607c358ed | |||
| ed1461626b | |||
| ee9bd9bbb2 | |||
| 75da95b38a | |||
| 5896ebd5b7 | |||
| 9e7dfbda5a | |||
| dc84e933c1 | |||
| 3140f54419 | |||
| e9fdadbbd8 | |||
| 8d537a61ed | |||
| ddf23377c3 | |||
| c0138ed849 | |||
| b5115d4aa1 | |||
| 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 |
@@ -1,42 +0,0 @@
|
||||
## 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.
|
||||
+4
-1
@@ -40,7 +40,10 @@ tasks/
|
||||
|
||||
# Runtime data (mounted as volume)
|
||||
instance/
|
||||
data/
|
||||
|
||||
# data/ is a Python package — only exclude non-code files
|
||||
data/*.csv
|
||||
data/*.db
|
||||
|
||||
# Build scripts
|
||||
build-multiarch.sh
|
||||
|
||||
@@ -10,16 +10,26 @@ jobs:
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- run: pip install ruff
|
||||
- run: pip install -r requirements-dev.txt
|
||||
- run: ruff check .
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
group: ["a-l", "m-z"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- run: pip install -r requirements.txt
|
||||
- run: pip install pytest
|
||||
- run: pytest --tb=short -q
|
||||
- run: pip install -r requirements-dev.txt
|
||||
- name: Run tests (${{ matrix.group }})
|
||||
run: |
|
||||
if [ "${{ matrix.group }}" = "a-l" ]; then
|
||||
pytest tests/test_[a-l]*.py --tb=short -q
|
||||
else
|
||||
pytest tests/test_[m-z]*.py --tb=short -q
|
||||
fi
|
||||
continue-on-error: true
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
# Set permissions for GITHUB_TOKEN
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
# Step 1: Check out the repository code
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Step 2: Set up QEMU for multi-arch builds
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
# Step 3: Set up Docker Buildx for advanced features
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# Step 4: Log in to GitHub Container Registry
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Step 5: Generate tags and labels from Git metadata
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
# Tag with branch name
|
||||
type=ref,event=branch
|
||||
# Tag with PR number
|
||||
type=ref,event=pr
|
||||
# Tag with semver from git tag
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
# Tag with short SHA
|
||||
type=sha,prefix=
|
||||
|
||||
# Step 6: Build and push the Docker image
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
# Only push on main branch and tags, not PRs
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
# Enable build cache for faster builds
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -67,3 +67,8 @@ data/subghz/captures/
|
||||
|
||||
# Local utility scripts
|
||||
reset-sdr.*
|
||||
.superpowers/
|
||||
docs/superpowers/
|
||||
|
||||
# Git worktrees
|
||||
.worktrees/
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.11.4
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
- id: ruff-format
|
||||
@@ -0,0 +1,178 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
INTERCEPT is a web-based Signal Intelligence (SIGINT) platform providing a unified Flask interface for software-defined radio (SDR) tools. It supports pager decoding, 433MHz sensors, ADS-B aircraft tracking, ACARS messaging, WiFi/Bluetooth scanning, satellite tracking, ISS SSTV decoding, AIS vessel tracking, weather satellite imagery (NOAA APT & Meteor LRPT), and Meshtastic mesh networking.
|
||||
|
||||
## Common Commands
|
||||
|
||||
### Docker (Primary)
|
||||
```bash
|
||||
# Build and run (basic profile)
|
||||
docker compose --profile basic up -d
|
||||
|
||||
# Build and run with ADS-B history (Postgres)
|
||||
docker compose --profile history up -d
|
||||
|
||||
# Rebuild after code changes
|
||||
docker compose --profile basic up -d --build
|
||||
|
||||
# Multi-arch build (amd64 + arm64 for RPi)
|
||||
./build-multiarch.sh
|
||||
```
|
||||
|
||||
### Local Setup (Alternative)
|
||||
```bash
|
||||
# First-time setup (interactive wizard with install profiles)
|
||||
./setup.sh
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
```bash
|
||||
# Run all tests
|
||||
pytest
|
||||
|
||||
# Run specific test file
|
||||
pytest tests/test_bluetooth.py
|
||||
|
||||
# Run with coverage
|
||||
pytest --cov=routes --cov=utils
|
||||
|
||||
# Run a specific test
|
||||
pytest tests/test_bluetooth.py::test_function_name -v
|
||||
```
|
||||
|
||||
### Linting and Formatting
|
||||
```bash
|
||||
# Lint with ruff
|
||||
ruff check .
|
||||
|
||||
# Auto-fix linting issues
|
||||
ruff check --fix .
|
||||
|
||||
# Format with black
|
||||
black .
|
||||
|
||||
# Type checking
|
||||
mypy .
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Entry Points
|
||||
- `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:
|
||||
- `pager.py` - POCSAG/FLEX decoding via rtl_fm + multimon-ng
|
||||
- `sensor.py` - 433MHz IoT sensors via rtl_433
|
||||
- `adsb.py` - Aircraft tracking via dump1090 (SBS protocol on port 30003)
|
||||
- `acars.py` - Aircraft datalink messages via acarsdec
|
||||
- `wifi.py`, `wifi_v2.py` - WiFi scanning (legacy and unified APIs)
|
||||
- `bluetooth.py`, `bluetooth_v2.py` - Bluetooth scanning (legacy and unified APIs)
|
||||
- `satellite.py` - Pass prediction using TLE data
|
||||
- `sstv.py` - ISS SSTV image decoding via slowrx
|
||||
- `weather_sat.py` - NOAA APT & Meteor LRPT via SatDump
|
||||
- `ais.py` - AIS vessel tracking and VHF DSC distress monitoring
|
||||
- `aprs.py` - Amateur packet radio via direwolf
|
||||
- `rtlamr.py` - Utility meter reading
|
||||
- `meshtastic_routes.py` - Meshtastic LoRa mesh networking
|
||||
|
||||
### Core Utilities (utils/)
|
||||
|
||||
**SDR Abstraction Layer** (`utils/sdr/`):
|
||||
- `SDRFactory` with factory pattern for multiple SDR types (RTL-SDR, LimeSDR, HackRF, Airspy, SDRPlay)
|
||||
- Each type has a `CommandBuilder` for generating CLI commands
|
||||
|
||||
**Bluetooth Module** (`utils/bluetooth/`):
|
||||
- Multi-backend: DBus/BlueZ primary, fallback for systems without BlueZ
|
||||
- `aggregator.py` - Merges observations across time
|
||||
- `tracker_signatures.py` - 47K+ known tracker fingerprints (AirTag, Tile, SmartTag)
|
||||
- `heuristics.py` - Behavioral analysis for device classification
|
||||
|
||||
**TSCM (Counter-Surveillance)** (`utils/tscm/`):
|
||||
- `baseline.py` - Snapshot "normal" RF environment
|
||||
- `detector.py` - Compare current scan to baseline, flag anomalies
|
||||
- `device_identity.py` - Track devices despite MAC randomization
|
||||
- `correlation.py` - Cross-reference Bluetooth and WiFi observations
|
||||
|
||||
**WiFi Utilities** (`utils/wifi/`):
|
||||
- Platform-agnostic scanner with parsers for airodump-ng, nmcli, iw, iwlist, airport (macOS)
|
||||
- `channel_analyzer.py` - Frequency band analysis
|
||||
|
||||
**Weather Satellite** (`utils/weather_sat.py`):
|
||||
- Singleton `WeatherSatDecoder` using SatDump CLI for NOAA APT and Meteor LRPT
|
||||
- Subprocess management with stdout parsing, image watcher via rglob
|
||||
- Pass prediction using skyfield TLE data
|
||||
|
||||
**SSTV Decoder** (`utils/sstv.py`):
|
||||
- ISS SSTV reception via slowrx with Doppler tracking
|
||||
- Singleton pattern, image gallery with timestamped filenames
|
||||
|
||||
### 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. 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.
|
||||
|
||||
**Data Stores**: `DataStore` class with TTL-based automatic cleanup (WiFi: 10min, Bluetooth: 5min, Aircraft: 5min).
|
||||
|
||||
**Input Validation**: Centralized in `utils/validation.py` - always validate frequencies, gains, device indices before spawning processes.
|
||||
|
||||
### External Tool Integrations
|
||||
|
||||
| Tool | Purpose | Integration |
|
||||
|------|---------|-------------|
|
||||
| rtl_fm | FM demodulation | Subprocess, pipes to multimon-ng |
|
||||
| multimon-ng | Pager decoding | Reads from rtl_fm stdout |
|
||||
| rtl_433 | 433MHz sensors | JSON output parsing |
|
||||
| dump1090 | ADS-B decoding | SBS protocol socket (port 30003) |
|
||||
| acarsdec | ACARS messages | Output parsing |
|
||||
| airmon-ng/airodump-ng | WiFi scanning | Monitor mode, CSV parsing |
|
||||
| bluetoothctl/hcitool | Bluetooth | Fallback when DBus unavailable |
|
||||
| slowrx | SSTV decoding | Subprocess with audio pipe |
|
||||
| SatDump | Weather satellites | CLI live mode, NOAA APT + Meteor LRPT |
|
||||
| AIS-catcher | AIS vessel tracking | JSON output parsing |
|
||||
| direwolf | APRS | TNC modem for packet radio |
|
||||
|
||||
### Frontend Structure
|
||||
- **Templates**: `templates/index.html` (main SPA), `templates/partials/modes/*.html` (sidebar panels), `templates/partials/nav.html` (global nav)
|
||||
- **JS Modules**: `static/js/modes/*.js` - IIFE pattern per mode (e.g., `WeatherSat`, `SSTV`, `Meshtastic`)
|
||||
- **CSS**: `static/css/modes/*.css` - scoped styles per mode, CSS variables for theming (`--bg-card`, `--accent-cyan`, `--font-mono`)
|
||||
- **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.). 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
|
||||
|
||||
### Configuration
|
||||
- `config.py` - Environment variable support with `INTERCEPT_` prefix (e.g., `INTERCEPT_PORT`, `INTERCEPT_WEATHER_SAT_GAIN`)
|
||||
- Database: SQLite in `instance/` directory for settings, baselines, history
|
||||
|
||||
## Testing Notes
|
||||
|
||||
Tests use pytest with extensive mocking of external tools. Key fixtures in `tests/conftest.py`. Mock subprocess calls when testing decoder integration.
|
||||
+140
@@ -2,6 +2,146 @@
|
||||
|
||||
All notable changes to iNTERCEPT will be documented in this file.
|
||||
|
||||
## [2.28.0] - 2026-07-05
|
||||
|
||||
### Added
|
||||
- **Signal ID** — Offline signal identification against a bundled 594-signal database (seeded from SigID Wiki via Artemis-DB). Match an unknown signal by frequency, bandwidth, and modulation; results are ranked 0–100 with match reasons and SigID Wiki links.
|
||||
- **Signal ID modal** — Standalone overlay (`SignalIdModal`) accessible from a new "Signal ID" button in the global nav Intel group, and from a dedicated "Identify Signal" button in the waterfall sidebar. Pre-populates from the current waterfall frequency when opened from there; accepts manual entry from the nav.
|
||||
- **`POST /signalid/match` route** — Scored matching API with 60-second in-process cache. Scores frequency centrality (40 pts), bandwidth match (30 pts), modulation match (20 pts), and region match (10 pts). Returns ranked matches with `match_reasons` annotations.
|
||||
- **`bin/import_artemis.py`** — One-command database refresh script. Downloads the latest Artemis-DB tar (~300 MB), extracts it, and merges new signals into `data/signals.json`. Run with `python3 bin/import_artemis.py --download`.
|
||||
|
||||
---
|
||||
|
||||
## [2.27.0] - 2026-05-20
|
||||
|
||||
### Fixed
|
||||
- **Two-window hang** — Opening the app in two browser tabs/windows caused it to become completely unresponsive. Root cause: HTTP/1.1 limits browsers to 6 connections per origin (shared across all tabs). VoiceAlerts was automatically opening 3 SSE streams per window on page load, so two windows produced 8 persistent connections and permanently blocked all regular HTTP requests. VoiceAlerts streams are now opt-in (disabled by default); users can enable them in settings.
|
||||
- **Alert messages split between windows** — The `/alerts/stream` SSE endpoint read from a single queue, so two windows would each receive only half the alerts. Now uses `sse_stream_fanout` so every window gets every alert.
|
||||
- **Bluetooth v2 stream split between windows** — Same single-queue issue in `/api/bluetooth/stream`. Fixed with fanout via `subscribe_fanout_queue`, preserving named SSE events (`device_update`, `scan_started`, etc.).
|
||||
- **ICAO lookup cache unbounded growth** — `_looked_up_icaos` set was never evicted; capped at 50 000 entries with LRU eviction to prevent memory growth under sustained ADS-B load.
|
||||
- **Concurrent ICAO clear race** — `popitem()` on the ICAO dict could raise `RuntimeError` if a clear happened concurrently; guarded with try/except.
|
||||
- **Bluetooth tracker fingerprint stability** — Tracker signature scan was incorrectly resetting stability counters on unchanged payloads; now skips the scan when the BLE payload fingerprint is unchanged.
|
||||
|
||||
### Added
|
||||
- **UI Tier system** — Three display modes selectable from the nav bar: *Lean* (minimal, no decorative elements), *Standard* (default), and *Enhanced* (full animations and ambient effects). Replaces the old animations toggle.
|
||||
- **Display mode in first-run setup** — The first-run modal now includes a display mode selection step so new users can pick their preferred visual style during initial setup.
|
||||
|
||||
### Performance
|
||||
- ADS-B SSE snapshot priming moved inside the response generator (avoids blocking before headers are sent).
|
||||
- WiFi network filter combined into a single list pass instead of chained filters.
|
||||
- Bluetooth tracker signature scan skips processing when the BLE payload fingerprint is unchanged.
|
||||
- `DataStore` cleanup minimises lock hold time by collecting expired keys before acquiring the write lock.
|
||||
|
||||
---
|
||||
|
||||
## [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
|
||||
|
||||
@@ -158,10 +158,21 @@ Each signal type has its own Flask blueprint:
|
||||
| direwolf | APRS | TNC modem for packet radio |
|
||||
|
||||
### Frontend Structure
|
||||
- **UI direction (decided 2026-06-12)**: map-heavy modes get dedicated dashboard
|
||||
pages (`/adsb/dashboard`, `/ais/dashboard`, `/satellite/dashboard`); the SPA
|
||||
in `index.html` keeps text/scan modes. APRS and Meshtastic are map-centric
|
||||
and should migrate to dashboards under their own plans — do not grow their
|
||||
SPA footprint.
|
||||
- **Templates**: `templates/index.html` (main SPA), `templates/partials/modes/*.html` (sidebar panels), `templates/partials/nav.html` (global nav)
|
||||
- **JS Modules**: `static/js/modes/*.js` - IIFE pattern per mode (e.g., `WeatherSat`, `SSTV`, `Meshtastic`)
|
||||
- **CSS**: `static/css/modes/*.css` - scoped styles per mode, CSS variables for theming (`--bg-card`, `--accent-cyan`, `--font-mono`)
|
||||
- **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()`
|
||||
- **Mode Integration**: Each mode is declared once in `static/js/mode-registry.js`
|
||||
(label, group, elementId, module, init/destroy hooks, visuals flag). The
|
||||
catalog, sidebar toggles, destroy map, visuals list, and init dispatch in
|
||||
`templates/index.html` are all derived from it. A new mode additionally needs:
|
||||
its partial in `templates/partials/modes/`, entries in the CSS/JS lazy-load
|
||||
asset maps in `index.html`, and its include in the partials block.
|
||||
`tests/test_mode_registry.py` enforces registry/asset consistency.
|
||||
|
||||
### Docker
|
||||
- `Dockerfile` - Single-stage build with all SDR tools compiled from source (dump1090, AIS-catcher, slowrx, SatDump, etc.). CMD runs `start.sh` (gunicorn + gevent)
|
||||
|
||||
+29
-7
@@ -126,11 +126,15 @@ RUN cd /tmp \
|
||||
&& rm -rf /tmp/slowrx
|
||||
|
||||
# Build SatDump (weather satellite decoder - NOAA APT & Meteor LRPT) — pinned to v1.2.2
|
||||
# Split into compile (heavy, cached) and staging (light, safe to change) layers
|
||||
RUN cd /tmp \
|
||||
&& git clone --depth 1 --branch 1.2.2 https://github.com/SatDump/SatDump.git \
|
||||
&& cd SatDump \
|
||||
&& mkdir build && cd build \
|
||||
&& cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF -DCMAKE_INSTALL_LIBDIR=lib .. \
|
||||
&& 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 \
|
||||
@@ -144,14 +148,29 @@ RUN cd /tmp \
|
||||
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
|
||||
|
||||
# Stage SatDump artifacts (separate layer so compile cache survives staging changes)
|
||||
# On arm64 cmake installs to /usr/{bin,lib,share}; on x86 to /usr/local/{bin,lib,share}
|
||||
RUN mkdir -p /staging/usr/local/share /staging/usr/local/lib/satdump/plugins \
|
||||
# Binary
|
||||
&& (cp -a /usr/local/bin/satdump /staging/usr/local/bin/ 2>/dev/null \
|
||||
|| cp -a /usr/bin/satdump /staging/usr/local/bin/) \
|
||||
# Core shared library
|
||||
&& (cp -a /usr/local/lib/libsatdump* /staging/usr/local/lib/ 2>/dev/null \
|
||||
|| cp -a /usr/lib/libsatdump* /staging/usr/local/lib/) \
|
||||
# Plugins
|
||||
&& (cp -a /usr/local/lib/satdump/plugins/*.so /staging/usr/local/lib/satdump/plugins/ 2>/dev/null \
|
||||
|| cp -a /usr/lib/satdump/plugins/*.so /staging/usr/local/lib/satdump/plugins/ 2>/dev/null \
|
||||
|| true) \
|
||||
# Pipeline definitions and resources
|
||||
&& (cp -a /usr/local/share/satdump /staging/usr/local/share/ 2>/dev/null \
|
||||
|| cp -a /usr/share/satdump /staging/usr/local/share/) \
|
||||
# Verify
|
||||
&& test -x /staging/usr/local/bin/satdump \
|
||||
&& ls /staging/usr/local/share/satdump/pipelines/*.json >/dev/null 2>&1 \
|
||||
&& echo "SatDump staging OK: $(ls /staging/usr/local/share/satdump/pipelines/*.json | wc -l) pipeline files"
|
||||
|
||||
# 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 \
|
||||
@@ -216,6 +235,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpng16-16 \
|
||||
libtiff6 \
|
||||
libjemalloc2 \
|
||||
libfftw3-double3 \
|
||||
libfftw3-single3 \
|
||||
libvolk-bin \
|
||||
libnng1 \
|
||||
libzstd1 \
|
||||
@@ -251,6 +272,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
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/usr/local/share/ /usr/local/share/
|
||||
COPY --from=builder /staging/opt/ /opt/
|
||||
|
||||
# Copy radiosonde Python dependencies installed during builder stage
|
||||
|
||||
@@ -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">
|
||||
@@ -19,7 +21,7 @@ Support the developer of this open-source project
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="static/images/screenshots/intercept-main.png" alt="Screenshot">
|
||||
<img src="docs/images/intercept-main.png" alt="Screenshot">
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -53,32 +55,7 @@ Support the developer of this open-source project
|
||||
- **Spy Stations** - Number stations and diplomatic HF network database
|
||||
- **Remote Agents** - Distributed SIGINT with remote sensor nodes
|
||||
- **Offline Mode** - Bundled assets for air-gapped/field deployments
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
- **Drone Intelligence** - Multi-vector UAV detection via ASTM F3411 Remote ID (WiFi/BLE), RTL-SDR 433/868 MHz RF, and HackRF 2.4/5.8 GHz scanning with live contact map and risk scoring
|
||||
|
||||
---
|
||||
|
||||
@@ -147,44 +124,7 @@ docker compose --profile basic up -d --build
|
||||
|
||||
> **Note:** Docker requires privileged mode for USB SDR access. SDR devices are passed through via `/dev/bus/usb`.
|
||||
|
||||
#### Multi-Architecture Builds (amd64 + arm64)
|
||||
|
||||
Cross-compile on an x64 machine and push to a registry. This is much faster than building natively on an RPi.
|
||||
|
||||
```bash
|
||||
# One-time setup on your x64 build machine
|
||||
docker run --privileged --rm tonistiigi/binfmt --install all
|
||||
docker buildx create --name intercept-builder --use --bootstrap
|
||||
|
||||
# Build and push for both architectures
|
||||
REGISTRY=ghcr.io/youruser ./build-multiarch.sh --push
|
||||
|
||||
# On the RPi5, just pull and run
|
||||
INTERCEPT_IMAGE=ghcr.io/youruser/intercept:latest docker compose --profile basic up -d
|
||||
```
|
||||
|
||||
Build script options:
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--push` | Push to container registry |
|
||||
| `--load` | Load into local Docker (single platform only) |
|
||||
| `--arm64-only` | Build arm64 only (for RPi deployment) |
|
||||
| `--amd64-only` | Build amd64 only |
|
||||
|
||||
Environment variables: `REGISTRY`, `IMAGE_NAME`, `IMAGE_TAG`
|
||||
|
||||
#### Using a Pre-built Image
|
||||
|
||||
If you've pushed to a registry, you can skip building entirely on the target machine:
|
||||
|
||||
```bash
|
||||
# Set in .env or export
|
||||
INTERCEPT_IMAGE=ghcr.io/youruser/intercept:latest
|
||||
|
||||
# Then just run
|
||||
docker compose --profile basic up -d
|
||||
```
|
||||
For multi-architecture builds (amd64 + arm64 for Raspberry Pi), see `build-multiarch.sh` — it handles cross-compilation and registry push in one step.
|
||||
|
||||
### Environment Configuration
|
||||
|
||||
@@ -251,9 +191,9 @@ Checks installed tools, SDR devices, port availability, permissions, Python venv
|
||||
|
||||
### 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 credentials can be changed in the ADMIN_USERNAME & ADMIN_PASSWORD variables in config.py
|
||||
Default credentials: **admin / admin** — change these in `config.py` (`ADMIN_USERNAME` / `ADMIN_PASSWORD`) before exposing the app on a network.
|
||||
|
||||
---
|
||||
|
||||
@@ -290,9 +230,10 @@ Most features work with a basic RTL-SDR dongle (RTL2832U + R820T2).
|
||||
## Documentation
|
||||
|
||||
- [Usage Guide](docs/USAGE.md) - Detailed instructions for each mode
|
||||
- [Distributed Agents](docs/DISTRIBUTED_AGENTS.md) - Remote sensor node deployment
|
||||
- [Hardware Guide](docs/HARDWARE.md) - SDR hardware and advanced setup
|
||||
- [Hardware Guide](docs/HARDWARE.md) - SDR hardware, manual install, and advanced setup
|
||||
- [Troubleshooting](docs/TROUBLESHOOTING.md) - Common issues and solutions
|
||||
- [Distributed Agents](docs/DISTRIBUTED_AGENTS.md) - Remote sensor node deployment
|
||||
- [Webhooks](docs/WEBHOOKS.md) - Alert rules and webhook integration
|
||||
- [Security](docs/SECURITY.md) - Network security and best practices
|
||||
|
||||
---
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"version": "2026-02-22_17194a71",
|
||||
"downloaded": "2026-02-27T10:41:04.872620Z"
|
||||
}
|
||||
Executable
+418
@@ -0,0 +1,418 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Import signals from the Artemis signal database into data/signals.json.
|
||||
|
||||
The Artemis signal database (https://github.com/AresValley/Artemis-DB) is
|
||||
distributed as a tar archive and can be downloaded without installing Artemis.
|
||||
This script handles the full workflow: download → extract → import → validate.
|
||||
|
||||
Usage:
|
||||
# Download the latest database and import automatically (easiest):
|
||||
python3 bin/import_artemis.py --download
|
||||
|
||||
# Or point at an already-extracted data.sqlite:
|
||||
python3 bin/import_artemis.py /path/to/data.sqlite
|
||||
|
||||
Options:
|
||||
--download Fetch the latest Artemis-DB tar, extract, and import
|
||||
--dry-run Show what would be imported without writing anything
|
||||
--no-merge Replace data/signals.json entirely instead of merging
|
||||
--limit N Only process the first N signals (for testing)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import urllib.request
|
||||
from collections import defaultdict
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
SIGNALS_JSON = REPO_ROOT / "data" / "signals.json"
|
||||
|
||||
RELEASE_INFO_URL = (
|
||||
"https://raw.githubusercontent.com/AresValley/Artemis/master/config/release-info.json"
|
||||
)
|
||||
|
||||
_LOCATION_MAP: dict[str, str] = {
|
||||
"worldwide": "GLOBAL",
|
||||
"global": "GLOBAL",
|
||||
"international": "GLOBAL",
|
||||
"europe": "EU",
|
||||
"european union": "EU",
|
||||
"eu": "EU",
|
||||
"north america": "US",
|
||||
"united states": "US",
|
||||
"usa": "US",
|
||||
"us": "US",
|
||||
"united kingdom": "UK",
|
||||
"uk": "UK",
|
||||
"great britain": "UK",
|
||||
"australia": "AU",
|
||||
"au": "AU",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Download helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _fetch_json(url: str) -> dict:
|
||||
with urllib.request.urlopen(url, timeout=15) as r:
|
||||
return json.loads(r.read())
|
||||
|
||||
|
||||
def _download_db(dest_dir: Path) -> Path:
|
||||
"""Download the latest Artemis-DB tar and extract it. Returns the data.sqlite path."""
|
||||
print("Fetching release info…")
|
||||
info = _fetch_json(RELEASE_INFO_URL)
|
||||
db_info = info["sigID_DB"]
|
||||
version = db_info["version"]
|
||||
url = db_info["url"]
|
||||
total = db_info.get("total_bytes", 0)
|
||||
|
||||
print(f"Artemis-DB version {version} ({total // 1_000_000} MB)")
|
||||
print(f"Downloading: {url}")
|
||||
|
||||
tar_path = dest_dir / url.split("/")[-1]
|
||||
|
||||
# curl gives real-time progress and is significantly faster than urllib
|
||||
result = subprocess.run(
|
||||
["curl", "-L", "--progress-bar", "-o", str(tar_path), url],
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"curl failed with exit code {result.returncode}")
|
||||
|
||||
print("Extracting…")
|
||||
extract_dir = dest_dir / "extracted"
|
||||
extract_dir.mkdir()
|
||||
with tarfile.open(tar_path) as tf:
|
||||
# Safety: strip any absolute paths or '..' traversal
|
||||
members = [
|
||||
m for m in tf.getmembers()
|
||||
if not os.path.isabs(m.name) and ".." not in m.name
|
||||
]
|
||||
tf.extractall(extract_dir, members=members)
|
||||
|
||||
# Find data.sqlite anywhere in the extracted tree
|
||||
matches = list(extract_dir.rglob("data.sqlite"))
|
||||
if not matches:
|
||||
raise FileNotFoundError(
|
||||
f"No data.sqlite found after extracting {tar_path}. "
|
||||
"The Artemis-DB tar structure may have changed."
|
||||
)
|
||||
|
||||
return matches[0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Discovery (for users who have Artemis installed)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _find_installed_db() -> Path | None:
|
||||
home = Path.home()
|
||||
candidates = [
|
||||
home / "Library" / "Application Support" / "AresValley" / "Artemis" / "data",
|
||||
home / ".local" / "share" / "AresValley" / "Artemis" / "data",
|
||||
home / "AppData" / "Local" / "AresValley" / "Artemis" / "data",
|
||||
]
|
||||
xdg = os.environ.get("XDG_DATA_HOME")
|
||||
if xdg:
|
||||
candidates.append(Path(xdg) / "AresValley" / "Artemis" / "data")
|
||||
|
||||
for base in candidates:
|
||||
if base.is_dir():
|
||||
for candidate in sorted(base.glob("*/data.sqlite")):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Conversion helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _slugify(name: str) -> str:
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
|
||||
return slug[:60]
|
||||
|
||||
|
||||
def _unique_slug(base: str, used: set[str]) -> str:
|
||||
if base not in used:
|
||||
return base
|
||||
n = 2
|
||||
while f"{base}-{n}" in used:
|
||||
n += 1
|
||||
return f"{base}-{n}"
|
||||
|
||||
|
||||
def _map_location(value: str) -> str:
|
||||
return _LOCATION_MAP.get(value.lower().strip(), "GLOBAL")
|
||||
|
||||
|
||||
def _group_frequencies(freq_hz_list: list[int]) -> list[dict[str, int]]:
|
||||
"""Convert point frequencies (Hz) into min/max range dicts.
|
||||
|
||||
Consecutive frequencies within a 1.5× ratio are merged into one range
|
||||
(e.g. 87.5 MHz + 108 MHz → one FM-band range). Isolated points get
|
||||
a ±0.5% window, minimum 1 kHz each side.
|
||||
"""
|
||||
freqs = sorted(f for f in freq_hz_list if f > 0)
|
||||
if not freqs:
|
||||
return []
|
||||
|
||||
groups: list[list[int]] = []
|
||||
current = [freqs[0]]
|
||||
for f in freqs[1:]:
|
||||
if f / current[-1] < 1.5:
|
||||
current.append(f)
|
||||
else:
|
||||
groups.append(current)
|
||||
current = [f]
|
||||
groups.append(current)
|
||||
|
||||
ranges = []
|
||||
for g in groups:
|
||||
lo, hi = min(g), max(g)
|
||||
if lo == hi:
|
||||
margin = max(int(lo * 0.005), 1000)
|
||||
lo = max(1, lo - margin)
|
||||
hi = hi + margin
|
||||
ranges.append({"min_hz": lo, "max_hz": hi})
|
||||
return ranges
|
||||
|
||||
|
||||
def _bandwidth_range(bw_values: list[int]) -> dict[str, int] | None:
|
||||
values = [v for v in bw_values if v > 0]
|
||||
if not values:
|
||||
return None
|
||||
lo, hi = min(values), max(values)
|
||||
if lo == hi:
|
||||
margin = max(int(lo * 0.10), 1)
|
||||
lo = max(1, lo - margin)
|
||||
hi = hi + margin
|
||||
return {"min_hz": lo, "max_hz": hi}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Database loading
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def load_artemis(db_path: Path, limit: int = 0, existing_ids: set[str] | None = None) -> list[dict]:
|
||||
"""Read signals from an Artemis data.sqlite and return them in Intercept's schema."""
|
||||
with closing(sqlite3.connect(str(db_path))) as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT SIG_ID, NAME, DESCRIPTION, URL FROM signals ORDER BY NAME")
|
||||
all_signals = cur.fetchall()
|
||||
|
||||
cur.execute("SELECT SIG_ID, VALUE FROM frequency")
|
||||
freq_rows = cur.fetchall()
|
||||
cur.execute("SELECT SIG_ID, VALUE FROM bandwidth")
|
||||
bw_rows = cur.fetchall()
|
||||
cur.execute("SELECT SIG_ID, VALUE FROM modulation")
|
||||
mod_rows = cur.fetchall()
|
||||
cur.execute("SELECT SIG_ID, VALUE FROM location")
|
||||
loc_rows = cur.fetchall()
|
||||
cur.execute("""
|
||||
SELECT category.SIG_ID, category_label.VALUE
|
||||
FROM category
|
||||
JOIN category_label ON category.CLB_ID = category_label.CLB_ID
|
||||
""")
|
||||
cat_rows = cur.fetchall()
|
||||
|
||||
freqs: dict = defaultdict(list)
|
||||
for sid, v in freq_rows:
|
||||
if v:
|
||||
freqs[sid].append(int(v))
|
||||
|
||||
bws: dict = defaultdict(list)
|
||||
for sid, v in bw_rows:
|
||||
if v:
|
||||
bws[sid].append(int(v))
|
||||
|
||||
mods: dict = defaultdict(list)
|
||||
for sid, v in mod_rows:
|
||||
if v:
|
||||
token = v.strip().upper()
|
||||
if token not in mods[sid]:
|
||||
mods[sid].append(token)
|
||||
|
||||
locs: dict = defaultdict(list)
|
||||
for sid, v in loc_rows:
|
||||
if v:
|
||||
r = _map_location(v)
|
||||
if r not in locs[sid]:
|
||||
locs[sid].append(r)
|
||||
|
||||
cats: dict = defaultdict(list)
|
||||
for sid, v in cat_rows:
|
||||
if v:
|
||||
label = v.strip().lower()
|
||||
if label not in cats[sid]:
|
||||
cats[sid].append(label)
|
||||
|
||||
if limit:
|
||||
all_signals = all_signals[:limit]
|
||||
|
||||
used_ids: set[str] = set(existing_ids or [])
|
||||
results = []
|
||||
skipped_no_freq = 0
|
||||
|
||||
for sig_id, name, description, url in all_signals:
|
||||
if not name:
|
||||
continue
|
||||
freq_ranges = _group_frequencies(freqs[sig_id])
|
||||
if not freq_ranges:
|
||||
skipped_no_freq += 1
|
||||
continue
|
||||
|
||||
sigidwiki_url: str | None = None
|
||||
if url and "sigidwiki" in url.lower():
|
||||
sigidwiki_url = url.strip()
|
||||
|
||||
slug = _unique_slug(_slugify(name.strip()), used_ids)
|
||||
used_ids.add(slug)
|
||||
|
||||
results.append({
|
||||
"id": slug,
|
||||
"name": name.strip(),
|
||||
"description": (description or "").strip(),
|
||||
"categories": cats[sig_id],
|
||||
"frequency_ranges": freq_ranges,
|
||||
"bandwidth_range": _bandwidth_range(bws[sig_id]),
|
||||
"modulations": mods[sig_id],
|
||||
"regions": locs[sig_id] or ["GLOBAL"],
|
||||
"sigidwiki_url": sigidwiki_url,
|
||||
})
|
||||
|
||||
if skipped_no_freq:
|
||||
print(f" Skipped {skipped_no_freq} signals with no frequency data")
|
||||
return results
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Merge
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def merge(existing: list[dict], imported: list[dict]) -> tuple[list[dict], int, int]:
|
||||
existing_names = {s["name"].lower() for s in existing}
|
||||
merged = list(existing)
|
||||
added = skipped = 0
|
||||
for sig in imported:
|
||||
if sig["name"].lower() in existing_names:
|
||||
skipped += 1
|
||||
else:
|
||||
merged.append(sig)
|
||||
existing_names.add(sig["name"].lower())
|
||||
added += 1
|
||||
return merged, added, skipped
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Import Artemis signal database into data/signals.json",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__,
|
||||
)
|
||||
parser.add_argument(
|
||||
"db_path", nargs="?",
|
||||
help="Path to data.sqlite (omit to auto-detect installed Artemis DB)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--download", action="store_true",
|
||||
help="Download the latest Artemis-DB tar (~290 MB) and import automatically",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run", action="store_true",
|
||||
help="Show import stats without writing data/signals.json",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-merge", action="store_true",
|
||||
help="Replace data/signals.json entirely instead of merging",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--limit", type=int, default=0, metavar="N",
|
||||
help="Only process the first N signals (for testing)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
tmp_dir: tempfile.TemporaryDirectory | None = None
|
||||
db_path: Path | None = None
|
||||
|
||||
try:
|
||||
if args.download:
|
||||
tmp_dir = tempfile.TemporaryDirectory(prefix="artemis-db-")
|
||||
db_path = _download_db(Path(tmp_dir.name))
|
||||
|
||||
elif args.db_path:
|
||||
db_path = Path(args.db_path)
|
||||
if not db_path.exists():
|
||||
print(f"Error: {db_path} does not exist", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
else:
|
||||
db_path = _find_installed_db()
|
||||
if db_path is None:
|
||||
print(
|
||||
"No Artemis database found automatically.\n\n"
|
||||
"Options:\n"
|
||||
" 1. Download automatically (~290 MB):\n"
|
||||
" python3 bin/import_artemis.py --download\n\n"
|
||||
" 2. Provide the path manually:\n"
|
||||
" python3 bin/import_artemis.py /path/to/data.sqlite",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
print(f"Found installed Artemis database: {db_path}")
|
||||
|
||||
existing = json.loads(SIGNALS_JSON.read_text()) if SIGNALS_JSON.exists() else []
|
||||
existing_ids = {s["id"] for s in existing}
|
||||
|
||||
print(f"Loading: {db_path}")
|
||||
imported = load_artemis(db_path, limit=args.limit, existing_ids=existing_ids)
|
||||
print(f" {len(imported)} signals with frequency data")
|
||||
|
||||
if args.no_merge:
|
||||
final, added, skipped = imported, len(imported), 0
|
||||
else:
|
||||
print(f" Existing signals.json: {len(existing)} signals")
|
||||
final, added, skipped = merge(existing, imported)
|
||||
if skipped:
|
||||
print(f" Skipped {skipped} duplicates (name already in signals.json)")
|
||||
|
||||
print(f" Adding {added} signals → total {len(final)}")
|
||||
|
||||
if args.dry_run:
|
||||
print("\nDry run — nothing written.")
|
||||
if imported:
|
||||
print("\nSample (first 2 imported signals):")
|
||||
for s in imported[:2]:
|
||||
print(json.dumps(s, indent=2))
|
||||
return
|
||||
|
||||
SIGNALS_JSON.write_text(json.dumps(final, indent=2) + "\n")
|
||||
print(f"\nWritten: {SIGNALS_JSON}")
|
||||
print("\nValidate:")
|
||||
print(" python3 -m pytest tests/test_signals_json.py --noconftest -v")
|
||||
|
||||
finally:
|
||||
if tmp_dir is not None:
|
||||
print("Cleaning up temporary files…")
|
||||
tmp_dir.cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -7,10 +7,136 @@ import os
|
||||
import sys
|
||||
|
||||
# Application version
|
||||
VERSION = "2.24.0"
|
||||
VERSION = "2.28.0"
|
||||
|
||||
# Changelog - latest release notes (shown on welcome screen)
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "2.28.0",
|
||||
"date": "July 2026",
|
||||
"highlights": [
|
||||
"Feat: Signal ID — offline signal identification against a bundled 594-signal database",
|
||||
"Feat: Signal ID modal accessible from the global nav and waterfall sidebar",
|
||||
"Feat: scored matching by frequency, bandwidth, modulation, and region (0–100)",
|
||||
"Feat: import script (bin/import_artemis.py) to refresh database from Artemis-DB",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "2.27.0",
|
||||
"date": "May 2026",
|
||||
"highlights": [
|
||||
"Fix: two-window hang caused by browser HTTP/1.1 connection pool exhaustion",
|
||||
"Fix: SSE alert and Bluetooth streams now fan out to all windows (no more split messages)",
|
||||
"Feat: UI tier system — lean, standard, enhanced display modes via nav toggle",
|
||||
"Feat: first-run setup modal includes display mode selection",
|
||||
"Perf: ADS-B SSE snapshot priming moved into generator; WiFi filter combined into single pass",
|
||||
"Perf: Bluetooth tracker signature scan skips unchanged fingerprints",
|
||||
"Fix: ICAO lookup cache capped at 50k entries with LRU eviction",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "2.26.13",
|
||||
"date": "March 2026",
|
||||
"highlights": [
|
||||
"Fix TSCM sweep module variable scoping and stale progress bar",
|
||||
"Fix 5GHz WiFi scanning failures in deep scan and band detection",
|
||||
"Fix ADS-B remote mode incorrectly stopping other SDR services",
|
||||
"Fix radiosonde false 'missing' report at end of setup",
|
||||
"Satellite tracker: TLE auto-refresh, polar plot fixes, pass calculation improvements",
|
||||
"Fix weather satellite handoff (remove defunct METEOR-M2)",
|
||||
"Add multi-arch Docker CI workflow (amd64 + arm64)",
|
||||
],
|
||||
},
|
||||
{
|
||||
"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",
|
||||
@@ -19,7 +145,7 @@ CHANGELOG = [
|
||||
"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",
|
||||
@@ -35,7 +161,7 @@ CHANGELOG = [
|
||||
"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",
|
||||
@@ -46,7 +172,7 @@ CHANGELOG = [
|
||||
"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",
|
||||
@@ -64,7 +190,7 @@ CHANGELOG = [
|
||||
"WebSDR major overhaul with improved receiver management and audio streaming",
|
||||
"Documentation audit: fixed license, tool names, entry points, and SSTV decoder references",
|
||||
"Help modal updated with ACARS and VDL2 mode descriptions",
|
||||
]
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "2.21.1",
|
||||
@@ -73,7 +199,7 @@ CHANGELOG = [
|
||||
"BT Locate map first-load fix with render stabilization retries during initial mode open",
|
||||
"BT Locate trail restore optimization for faster startup when historical GPS points exist",
|
||||
"BT Locate mode-switch map invalidation timing fix to prevent delayed/blank map render",
|
||||
]
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "2.21.0",
|
||||
@@ -85,7 +211,7 @@ CHANGELOG = [
|
||||
"Bluetooth/WiFi runtime health fixes with BT Locate continuity and confidence improvements",
|
||||
"ADS-B/VDL2 streaming reliability upgrades for multi-client SSE fanout and remote decoding",
|
||||
"Analytics enhancements with operational insights and temporal pattern panels",
|
||||
]
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "2.20.0",
|
||||
@@ -96,7 +222,7 @@ CHANGELOG = [
|
||||
"HF band conditions, D-RAP absorption maps, aurora forecast, and solar imagery",
|
||||
"NOAA Space Weather Scales (G/S/R), flare probability, and active solar regions",
|
||||
"No SDR hardware required — all data from public APIs with server-side caching",
|
||||
]
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "2.19.0",
|
||||
@@ -109,7 +235,7 @@ CHANGELOG = [
|
||||
"Setup script overhauled for reliability and macOS compatibility",
|
||||
"GPS fix for preserving satellites across DOP-only SKY messages",
|
||||
"Fix gpsd deadlock causing GPS connect to hang",
|
||||
]
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "2.18.0",
|
||||
@@ -121,7 +247,7 @@ CHANGELOG = [
|
||||
"ADS-B: stale dump1090 process cleanup via PID file tracking",
|
||||
"GPS: error state indicator and UI refinements",
|
||||
"Proximity radar and signal card UI improvements",
|
||||
]
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "2.17.0",
|
||||
@@ -131,7 +257,7 @@ CHANGELOG = [
|
||||
"IRK auto-detection: extract Identity Resolving Keys from paired devices (macOS/Linux)",
|
||||
"GPS mode: real-time position tracking with live map, speed, altitude, and satellite info",
|
||||
"Bluetooth scanner lifecycle fix for bleak scan timeout tracking",
|
||||
]
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "2.16.0",
|
||||
@@ -143,7 +269,7 @@ CHANGELOG = [
|
||||
"Shared waterfall UI across SDR modes",
|
||||
"Listening post audio stuttering fix and SDR race condition fixes",
|
||||
"Multi-arch Docker build support (amd64 + arm64)",
|
||||
]
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "2.15.0",
|
||||
@@ -155,7 +281,7 @@ CHANGELOG = [
|
||||
"Real-time signal scope for pager, sensor, and SSTV modes",
|
||||
"USB-level device probe to prevent cryptic rtl_fm crashes",
|
||||
"SDR device lock-up fix from unreleased device registry on crash",
|
||||
]
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "2.14.0",
|
||||
@@ -166,7 +292,7 @@ CHANGELOG = [
|
||||
"Listening Post signal scanner and audio pipeline improvements",
|
||||
"TSCM sweep resilience, WiFi detection, and correlation fixes",
|
||||
"APRS rtl_fm startup and SDR device conflict fixes",
|
||||
]
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "2.13.1",
|
||||
@@ -178,7 +304,7 @@ CHANGELOG = [
|
||||
"WiFi connected clients panel now filters to selected AP",
|
||||
"Global navigation bar across all dashboards",
|
||||
"Fixed USB device contention when starting audio pipeline",
|
||||
]
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "2.13.0",
|
||||
@@ -188,7 +314,7 @@ CHANGELOG = [
|
||||
"Help modal system with keyboard shortcuts reference",
|
||||
"Global navbar and settings modal accessible from all dashboards",
|
||||
"Probed SSID badges for connected clients",
|
||||
]
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "2.12.1",
|
||||
@@ -199,7 +325,7 @@ CHANGELOG = [
|
||||
"Real-time Doppler tracking for ISS SSTV reception",
|
||||
"TCP connection support for Meshtastic",
|
||||
"Shared observer location with auto-start options",
|
||||
]
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "2.12.0",
|
||||
@@ -209,7 +335,7 @@ CHANGELOG = [
|
||||
"GitHub update notifications for new releases",
|
||||
"Meshtastic QR code support and telemetry display",
|
||||
"New Space category with reorganized UI",
|
||||
]
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "2.11.0",
|
||||
@@ -219,7 +345,7 @@ CHANGELOG = [
|
||||
"Ubertooth One BLE scanning support",
|
||||
"Offline mode with bundled assets",
|
||||
"Settings modal with tile provider configuration",
|
||||
]
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "2.10.0",
|
||||
@@ -229,7 +355,7 @@ CHANGELOG = [
|
||||
"Spy Stations database (number stations & diplomatic HF)",
|
||||
"MMSI country identification and distress alert overlays",
|
||||
"SDR device conflict detection for AIS/DSC",
|
||||
]
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "2.9.5",
|
||||
@@ -239,7 +365,7 @@ CHANGELOG = [
|
||||
"Clickable score cards and device detail expansion",
|
||||
"RF scanning improvements with status feedback",
|
||||
"Root privilege check and warning display",
|
||||
]
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "2.9.0",
|
||||
@@ -249,7 +375,7 @@ CHANGELOG = [
|
||||
"TSCM baseline recording now captures device data",
|
||||
"Device identity engine integration for threat detection",
|
||||
"Welcome screen with mode selection",
|
||||
]
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "2.8.0",
|
||||
@@ -259,20 +385,20 @@ CHANGELOG = [
|
||||
"WiFi/Bluetooth device correlation engine",
|
||||
"Tracker detection (AirTag, Tile, SmartTag)",
|
||||
"Risk scoring and threat classification",
|
||||
]
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _get_env(key: str, default: str) -> str:
|
||||
"""Get environment variable with default."""
|
||||
return os.environ.get(f'INTERCEPT_{key}', default)
|
||||
return os.environ.get(f"INTERCEPT_{key}", default)
|
||||
|
||||
|
||||
def _get_env_int(key: str, default: int) -> int:
|
||||
"""Get environment variable as integer with default."""
|
||||
try:
|
||||
return int(os.environ.get(f'INTERCEPT_{key}', str(default)))
|
||||
return int(os.environ.get(f"INTERCEPT_{key}", str(default)))
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
@@ -280,134 +406,134 @@ def _get_env_int(key: str, default: int) -> int:
|
||||
def _get_env_float(key: str, default: float) -> float:
|
||||
"""Get environment variable as float with default."""
|
||||
try:
|
||||
return float(os.environ.get(f'INTERCEPT_{key}', str(default)))
|
||||
return float(os.environ.get(f"INTERCEPT_{key}", str(default)))
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def _get_env_bool(key: str, default: bool) -> bool:
|
||||
"""Get environment variable as boolean with default."""
|
||||
val = os.environ.get(f'INTERCEPT_{key}', '').lower()
|
||||
if val in ('true', '1', 'yes', 'on'):
|
||||
val = os.environ.get(f"INTERCEPT_{key}", "").lower()
|
||||
if val in ("true", "1", "yes", "on"):
|
||||
return True
|
||||
if val in ('false', '0', 'no', 'off'):
|
||||
if val in ("false", "0", "no", "off"):
|
||||
return False
|
||||
return default
|
||||
|
||||
|
||||
# Logging configuration
|
||||
_log_level_str = _get_env('LOG_LEVEL', 'WARNING').upper()
|
||||
_log_level_str = _get_env("LOG_LEVEL", "WARNING").upper()
|
||||
LOG_LEVEL = getattr(logging, _log_level_str, logging.WARNING)
|
||||
LOG_FORMAT = _get_env('LOG_FORMAT', '%(asctime)s - %(levelname)s - %(message)s')
|
||||
LOG_FORMAT = _get_env("LOG_FORMAT", "%(asctime)s - %(levelname)s - %(message)s")
|
||||
|
||||
# Server settings
|
||||
HOST = _get_env('HOST', '0.0.0.0')
|
||||
PORT = _get_env_int('PORT', 5050)
|
||||
DEBUG = _get_env_bool('DEBUG', False)
|
||||
THREADED = _get_env_bool('THREADED', True)
|
||||
HOST = _get_env("HOST", "0.0.0.0")
|
||||
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', '')
|
||||
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')
|
||||
DEFAULT_GAIN = _get_env("DEFAULT_GAIN", "40")
|
||||
DEFAULT_DEVICE = _get_env("DEFAULT_DEVICE", "0")
|
||||
|
||||
# Pager defaults
|
||||
DEFAULT_PAGER_FREQ = _get_env('PAGER_FREQ', '929.6125M')
|
||||
DEFAULT_PAGER_FREQ = _get_env("PAGER_FREQ", "929.6125M")
|
||||
|
||||
# Timeouts
|
||||
PROCESS_TIMEOUT = _get_env_int('PROCESS_TIMEOUT', 5)
|
||||
SOCKET_TIMEOUT = _get_env_int('SOCKET_TIMEOUT', 5)
|
||||
SSE_TIMEOUT = _get_env_int('SSE_TIMEOUT', 1)
|
||||
PROCESS_TIMEOUT = _get_env_int("PROCESS_TIMEOUT", 5)
|
||||
SOCKET_TIMEOUT = _get_env_int("SOCKET_TIMEOUT", 5)
|
||||
SSE_TIMEOUT = _get_env_int("SSE_TIMEOUT", 1)
|
||||
|
||||
# WiFi settings
|
||||
WIFI_UPDATE_INTERVAL = _get_env_float('WIFI_UPDATE_INTERVAL', 2.0)
|
||||
AIRODUMP_HEADER_LINES = _get_env_int('AIRODUMP_HEADER_LINES', 2)
|
||||
WIFI_UPDATE_INTERVAL = _get_env_float("WIFI_UPDATE_INTERVAL", 2.0)
|
||||
AIRODUMP_HEADER_LINES = _get_env_int("AIRODUMP_HEADER_LINES", 2)
|
||||
|
||||
# Bluetooth settings
|
||||
BT_SCAN_TIMEOUT = _get_env_int('BT_SCAN_TIMEOUT', 10)
|
||||
BT_UPDATE_INTERVAL = _get_env_float('BT_UPDATE_INTERVAL', 2.0)
|
||||
BT_SCAN_TIMEOUT = _get_env_int("BT_SCAN_TIMEOUT", 10)
|
||||
BT_UPDATE_INTERVAL = _get_env_float("BT_UPDATE_INTERVAL", 2.0)
|
||||
|
||||
# ADS-B settings
|
||||
ADSB_SBS_PORT = _get_env_int('ADSB_SBS_PORT', 30003)
|
||||
ADSB_UPDATE_INTERVAL = _get_env_float('ADSB_UPDATE_INTERVAL', 1.0)
|
||||
ADSB_AUTO_START = _get_env_bool('ADSB_AUTO_START', False)
|
||||
ADSB_HISTORY_ENABLED = _get_env_bool('ADSB_HISTORY_ENABLED', False)
|
||||
ADSB_DB_HOST = _get_env('ADSB_DB_HOST', 'localhost')
|
||||
ADSB_DB_PORT = _get_env_int('ADSB_DB_PORT', 5432)
|
||||
ADSB_DB_NAME = _get_env('ADSB_DB_NAME', 'intercept_adsb')
|
||||
ADSB_DB_USER = _get_env('ADSB_DB_USER', 'intercept')
|
||||
ADSB_DB_PASSWORD = _get_env('ADSB_DB_PASSWORD', 'intercept')
|
||||
ADSB_HISTORY_BATCH_SIZE = _get_env_int('ADSB_HISTORY_BATCH_SIZE', 500)
|
||||
ADSB_HISTORY_FLUSH_INTERVAL = _get_env_float('ADSB_HISTORY_FLUSH_INTERVAL', 1.0)
|
||||
ADSB_HISTORY_QUEUE_SIZE = _get_env_int('ADSB_HISTORY_QUEUE_SIZE', 50000)
|
||||
ADSB_SBS_PORT = _get_env_int("ADSB_SBS_PORT", 30003)
|
||||
ADSB_UPDATE_INTERVAL = _get_env_float("ADSB_UPDATE_INTERVAL", 1.0)
|
||||
ADSB_AUTO_START = _get_env_bool("ADSB_AUTO_START", False)
|
||||
ADSB_HISTORY_ENABLED = _get_env_bool("ADSB_HISTORY_ENABLED", False)
|
||||
ADSB_DB_HOST = _get_env("ADSB_DB_HOST", "localhost")
|
||||
ADSB_DB_PORT = _get_env_int("ADSB_DB_PORT", 5432)
|
||||
ADSB_DB_NAME = _get_env("ADSB_DB_NAME", "intercept_adsb")
|
||||
ADSB_DB_USER = _get_env("ADSB_DB_USER", "intercept")
|
||||
ADSB_DB_PASSWORD = _get_env("ADSB_DB_PASSWORD", "intercept")
|
||||
ADSB_HISTORY_BATCH_SIZE = _get_env_int("ADSB_HISTORY_BATCH_SIZE", 500)
|
||||
ADSB_HISTORY_FLUSH_INTERVAL = _get_env_float("ADSB_HISTORY_FLUSH_INTERVAL", 1.0)
|
||||
ADSB_HISTORY_QUEUE_SIZE = _get_env_int("ADSB_HISTORY_QUEUE_SIZE", 50000)
|
||||
|
||||
# Observer location settings
|
||||
SHARED_OBSERVER_LOCATION_ENABLED = _get_env_bool('SHARED_OBSERVER_LOCATION', True)
|
||||
DEFAULT_LATITUDE = _get_env_float('DEFAULT_LAT', 0.0)
|
||||
DEFAULT_LONGITUDE = _get_env_float('DEFAULT_LON', 0.0)
|
||||
SHARED_OBSERVER_LOCATION_ENABLED = _get_env_bool("SHARED_OBSERVER_LOCATION", True)
|
||||
DEFAULT_LATITUDE = _get_env_float("DEFAULT_LAT", 0.0)
|
||||
DEFAULT_LONGITUDE = _get_env_float("DEFAULT_LON", 0.0)
|
||||
|
||||
# Satellite settings
|
||||
SATELLITE_UPDATE_INTERVAL = _get_env_int('SATELLITE_UPDATE_INTERVAL', 30)
|
||||
SATELLITE_TRAJECTORY_POINTS = _get_env_int('SATELLITE_TRAJECTORY_POINTS', 30)
|
||||
SATELLITE_ORBIT_MINUTES = _get_env_int('SATELLITE_ORBIT_MINUTES', 45)
|
||||
SATELLITE_UPDATE_INTERVAL = _get_env_int("SATELLITE_UPDATE_INTERVAL", 30)
|
||||
SATELLITE_TRAJECTORY_POINTS = _get_env_int("SATELLITE_TRAJECTORY_POINTS", 30)
|
||||
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', 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)
|
||||
WEATHER_SAT_DEFAULT_GAIN = _get_env_float("WEATHER_SAT_GAIN", 30.0)
|
||||
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)
|
||||
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)
|
||||
SUBGHZ_DEFAULT_LNA_GAIN = _get_env_int('SUBGHZ_LNA_GAIN', 32)
|
||||
SUBGHZ_DEFAULT_VGA_GAIN = _get_env_int('SUBGHZ_VGA_GAIN', 20)
|
||||
SUBGHZ_DEFAULT_TX_GAIN = _get_env_int('SUBGHZ_TX_GAIN', 20)
|
||||
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)
|
||||
SUBGHZ_DEFAULT_FREQUENCY = _get_env_float("SUBGHZ_FREQUENCY", 433.92)
|
||||
SUBGHZ_DEFAULT_SAMPLE_RATE = _get_env_int("SUBGHZ_SAMPLE_RATE", 2000000)
|
||||
SUBGHZ_DEFAULT_LNA_GAIN = _get_env_int("SUBGHZ_LNA_GAIN", 32)
|
||||
SUBGHZ_DEFAULT_VGA_GAIN = _get_env_int("SUBGHZ_VGA_GAIN", 20)
|
||||
SUBGHZ_DEFAULT_TX_GAIN = _get_env_int("SUBGHZ_TX_GAIN", 20)
|
||||
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)
|
||||
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)
|
||||
UPDATE_CHECK_INTERVAL_HOURS = _get_env_int('UPDATE_CHECK_INTERVAL_HOURS', 6)
|
||||
GITHUB_REPO = _get_env("GITHUB_REPO", "smittix/intercept")
|
||||
UPDATE_CHECK_ENABLED = _get_env_bool("UPDATE_CHECK_ENABLED", True)
|
||||
UPDATE_CHECK_INTERVAL_HOURS = _get_env_int("UPDATE_CHECK_INTERVAL_HOURS", 6)
|
||||
|
||||
# Alerting
|
||||
ALERT_WEBHOOK_URL = _get_env('ALERT_WEBHOOK_URL', '')
|
||||
ALERT_WEBHOOK_SECRET = _get_env('ALERT_WEBHOOK_SECRET', '')
|
||||
ALERT_WEBHOOK_TIMEOUT = _get_env_int('ALERT_WEBHOOK_TIMEOUT', 5)
|
||||
ALERT_WEBHOOK_URL = _get_env("ALERT_WEBHOOK_URL", "")
|
||||
ALERT_WEBHOOK_SECRET = _get_env("ALERT_WEBHOOK_SECRET", "")
|
||||
ALERT_WEBHOOK_TIMEOUT = _get_env_int("ALERT_WEBHOOK_TIMEOUT", 5)
|
||||
|
||||
# Admin credentials
|
||||
ADMIN_USERNAME = _get_env('ADMIN_USERNAME', 'admin')
|
||||
ADMIN_PASSWORD = _get_env('ADMIN_PASSWORD', '')
|
||||
ADMIN_USERNAME = _get_env("ADMIN_USERNAME", "admin")
|
||||
ADMIN_PASSWORD = _get_env("ADMIN_PASSWORD", "admin")
|
||||
|
||||
# Signal identification region (affects match ranking; does not filter results)
|
||||
# Valid values: GLOBAL, EU, US, UK, AU
|
||||
REGION = _get_env("REGION", "GLOBAL")
|
||||
|
||||
|
||||
def configure_logging() -> None:
|
||||
"""Configure application logging."""
|
||||
logging.basicConfig(
|
||||
level=LOG_LEVEL,
|
||||
format=LOG_FORMAT,
|
||||
stream=sys.stderr
|
||||
)
|
||||
logging.basicConfig(level=LOG_LEVEL, format=LOG_FORMAT, stream=sys.stderr)
|
||||
# Suppress Flask development server warning
|
||||
logging.getLogger('werkzeug').setLevel(LOG_LEVEL)
|
||||
logging.getLogger("werkzeug").setLevel(LOG_LEVEL)
|
||||
|
||||
+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
|
||||
|
||||
+377
-106
@@ -1,21 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import json
|
||||
|
||||
logger = logging.getLogger('intercept.oui')
|
||||
logger = logging.getLogger("intercept.oui")
|
||||
|
||||
|
||||
def load_oui_database() -> dict[str, str] | None:
|
||||
"""Load OUI database from external JSON file, with fallback to built-in."""
|
||||
oui_file = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'oui_database.json')
|
||||
oui_file = os.path.join(os.path.dirname(os.path.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('_')}
|
||||
return {k: v for k, v in data.items() if not k.startswith("_")}
|
||||
except Exception as e:
|
||||
logger.warning(f"Error loading oui_database.json: {e}, using built-in database")
|
||||
return None # Will fall back to built-in
|
||||
@@ -24,143 +24,414 @@ def load_oui_database() -> dict[str, str] | None:
|
||||
def get_manufacturer(mac: str) -> str:
|
||||
"""Look up manufacturer from MAC address OUI."""
|
||||
prefix = mac[:8].upper()
|
||||
return OUI_DATABASE.get(prefix, 'Unknown')
|
||||
return OUI_DATABASE.get(prefix, "Unknown")
|
||||
|
||||
|
||||
# OUI Database for manufacturer lookup (expanded)
|
||||
OUI_DATABASE = {
|
||||
# Apple (extensive list)
|
||||
'00:25:DB': 'Apple', '04:52:F3': 'Apple', '0C:3E:9F': 'Apple', '10:94:BB': 'Apple',
|
||||
'14:99:E2': 'Apple', '20:78:F0': 'Apple', '28:6A:BA': 'Apple', '3C:22:FB': 'Apple',
|
||||
'40:98:AD': 'Apple', '48:D7:05': 'Apple', '4C:57:CA': 'Apple', '54:4E:90': 'Apple',
|
||||
'5C:97:F3': 'Apple', '60:F8:1D': 'Apple', '68:DB:CA': 'Apple', '70:56:81': 'Apple',
|
||||
'78:7B:8A': 'Apple', '7C:D1:C3': 'Apple', '84:FC:FE': 'Apple', '8C:2D:AA': 'Apple',
|
||||
'90:B0:ED': 'Apple', '98:01:A7': 'Apple', '98:D6:BB': 'Apple', 'A4:D1:D2': 'Apple',
|
||||
'AC:BC:32': 'Apple', 'B0:34:95': 'Apple', 'B8:C1:11': 'Apple', 'C8:69:CD': 'Apple',
|
||||
'D0:03:4B': 'Apple', 'DC:A9:04': 'Apple', 'E0:C7:67': 'Apple', 'F0:18:98': 'Apple',
|
||||
'F4:5C:89': 'Apple', '78:4F:43': 'Apple', '00:CD:FE': 'Apple', '04:4B:ED': 'Apple',
|
||||
'04:D3:CF': 'Apple', '08:66:98': 'Apple', '0C:74:C2': 'Apple', '10:DD:B1': 'Apple',
|
||||
'14:10:9F': 'Apple', '18:EE:69': 'Apple', '1C:36:BB': 'Apple', '24:A0:74': 'Apple',
|
||||
'28:37:37': 'Apple', '2C:BE:08': 'Apple', '34:08:BC': 'Apple', '38:C9:86': 'Apple',
|
||||
'3C:06:30': 'Apple', '44:D8:84': 'Apple', '48:A9:1C': 'Apple', '4C:32:75': 'Apple',
|
||||
'50:32:37': 'Apple', '54:26:96': 'Apple', '58:B0:35': 'Apple', '5C:F7:E6': 'Apple',
|
||||
'64:A3:CB': 'Apple', '68:FE:F7': 'Apple', '6C:4D:73': 'Apple', '70:DE:E2': 'Apple',
|
||||
'74:E2:F5': 'Apple', '78:67:D7': 'Apple', '7C:04:D0': 'Apple', '80:E6:50': 'Apple',
|
||||
'84:78:8B': 'Apple', '88:66:A5': 'Apple', '8C:85:90': 'Apple', '94:E9:6A': 'Apple',
|
||||
'9C:F4:8E': 'Apple', 'A0:99:9B': 'Apple', 'A4:83:E7': 'Apple', 'A8:5C:2C': 'Apple',
|
||||
'AC:1F:74': 'Apple', 'B0:19:C6': 'Apple', 'B4:F1:DA': 'Apple', 'BC:52:B7': 'Apple',
|
||||
'C0:A5:3E': 'Apple', 'C4:B3:01': 'Apple', 'CC:20:E8': 'Apple', 'D0:C5:F3': 'Apple',
|
||||
'D4:61:9D': 'Apple', 'D8:1C:79': 'Apple', 'E0:5F:45': 'Apple', 'E4:C6:3D': 'Apple',
|
||||
'F0:B4:79': 'Apple', 'F4:0F:24': 'Apple', 'F8:4D:89': 'Apple', 'FC:D8:48': 'Apple',
|
||||
"00:25:DB": "Apple",
|
||||
"04:52:F3": "Apple",
|
||||
"0C:3E:9F": "Apple",
|
||||
"10:94:BB": "Apple",
|
||||
"14:99:E2": "Apple",
|
||||
"20:78:F0": "Apple",
|
||||
"28:6A:BA": "Apple",
|
||||
"3C:22:FB": "Apple",
|
||||
"40:98:AD": "Apple",
|
||||
"48:D7:05": "Apple",
|
||||
"4C:57:CA": "Apple",
|
||||
"54:4E:90": "Apple",
|
||||
"5C:97:F3": "Apple",
|
||||
"60:F8:1D": "Apple",
|
||||
"68:DB:CA": "Apple",
|
||||
"70:56:81": "Apple",
|
||||
"78:7B:8A": "Apple",
|
||||
"7C:D1:C3": "Apple",
|
||||
"84:FC:FE": "Apple",
|
||||
"8C:2D:AA": "Apple",
|
||||
"90:B0:ED": "Apple",
|
||||
"98:01:A7": "Apple",
|
||||
"98:D6:BB": "Apple",
|
||||
"A4:D1:D2": "Apple",
|
||||
"AC:BC:32": "Apple",
|
||||
"B0:34:95": "Apple",
|
||||
"B8:C1:11": "Apple",
|
||||
"C8:69:CD": "Apple",
|
||||
"D0:03:4B": "Apple",
|
||||
"DC:A9:04": "Apple",
|
||||
"E0:C7:67": "Apple",
|
||||
"F0:18:98": "Apple",
|
||||
"F4:5C:89": "Apple",
|
||||
"78:4F:43": "Apple",
|
||||
"00:CD:FE": "Apple",
|
||||
"04:4B:ED": "Apple",
|
||||
"04:D3:CF": "Apple",
|
||||
"08:66:98": "Apple",
|
||||
"0C:74:C2": "Apple",
|
||||
"10:DD:B1": "Apple",
|
||||
"14:10:9F": "Apple",
|
||||
"18:EE:69": "Apple",
|
||||
"1C:36:BB": "Apple",
|
||||
"24:A0:74": "Apple",
|
||||
"28:37:37": "Apple",
|
||||
"2C:BE:08": "Apple",
|
||||
"34:08:BC": "Apple",
|
||||
"38:C9:86": "Apple",
|
||||
"3C:06:30": "Apple",
|
||||
"44:D8:84": "Apple",
|
||||
"48:A9:1C": "Apple",
|
||||
"4C:32:75": "Apple",
|
||||
"50:32:37": "Apple",
|
||||
"54:26:96": "Apple",
|
||||
"58:B0:35": "Apple",
|
||||
"5C:F7:E6": "Apple",
|
||||
"64:A3:CB": "Apple",
|
||||
"68:FE:F7": "Apple",
|
||||
"6C:4D:73": "Apple",
|
||||
"70:DE:E2": "Apple",
|
||||
"74:E2:F5": "Apple",
|
||||
"78:67:D7": "Apple",
|
||||
"7C:04:D0": "Apple",
|
||||
"80:E6:50": "Apple",
|
||||
"84:78:8B": "Apple",
|
||||
"88:66:A5": "Apple",
|
||||
"8C:85:90": "Apple",
|
||||
"94:E9:6A": "Apple",
|
||||
"9C:F4:8E": "Apple",
|
||||
"A0:99:9B": "Apple",
|
||||
"A4:83:E7": "Apple",
|
||||
"A8:5C:2C": "Apple",
|
||||
"AC:1F:74": "Apple",
|
||||
"B0:19:C6": "Apple",
|
||||
"B4:F1:DA": "Apple",
|
||||
"BC:52:B7": "Apple",
|
||||
"C0:A5:3E": "Apple",
|
||||
"C4:B3:01": "Apple",
|
||||
"CC:20:E8": "Apple",
|
||||
"D0:C5:F3": "Apple",
|
||||
"D4:61:9D": "Apple",
|
||||
"D8:1C:79": "Apple",
|
||||
"E0:5F:45": "Apple",
|
||||
"E4:C6:3D": "Apple",
|
||||
"F0:B4:79": "Apple",
|
||||
"F4:0F:24": "Apple",
|
||||
"F8:4D:89": "Apple",
|
||||
"FC:D8:48": "Apple",
|
||||
# Samsung
|
||||
'00:1B:66': 'Samsung', '00:21:19': 'Samsung', '00:26:37': 'Samsung', '5C:0A:5B': 'Samsung',
|
||||
'8C:71:F8': 'Samsung', 'C4:73:1E': 'Samsung', '38:2C:4A': 'Samsung', '00:1E:4C': 'Samsung',
|
||||
'00:12:47': 'Samsung', '00:15:99': 'Samsung', '00:17:D5': 'Samsung', '00:1D:F6': 'Samsung',
|
||||
'00:21:D1': 'Samsung', '00:24:54': 'Samsung', '00:26:5D': 'Samsung', '08:D4:2B': 'Samsung',
|
||||
'10:D5:42': 'Samsung', '14:49:E0': 'Samsung', '18:3A:2D': 'Samsung', '1C:66:AA': 'Samsung',
|
||||
'24:4B:81': 'Samsung', '28:98:7B': 'Samsung', '2C:AE:2B': 'Samsung', '30:96:FB': 'Samsung',
|
||||
'34:C3:AC': 'Samsung', '38:01:95': 'Samsung', '3C:5A:37': 'Samsung', '40:0E:85': 'Samsung',
|
||||
'44:4E:1A': 'Samsung', '4C:BC:A5': 'Samsung', '50:01:BB': 'Samsung', '50:A4:D0': 'Samsung',
|
||||
'54:88:0E': 'Samsung', '58:C3:8B': 'Samsung', '5C:2E:59': 'Samsung', '60:D0:A9': 'Samsung',
|
||||
'64:B3:10': 'Samsung', '68:48:98': 'Samsung', '6C:2F:2C': 'Samsung', '70:F9:27': 'Samsung',
|
||||
'74:45:8A': 'Samsung', '78:47:1D': 'Samsung', '7C:0B:C6': 'Samsung', '84:11:9E': 'Samsung',
|
||||
'88:32:9B': 'Samsung', '8C:77:12': 'Samsung', '90:18:7C': 'Samsung', '94:35:0A': 'Samsung',
|
||||
'98:52:B1': 'Samsung', '9C:02:98': 'Samsung', 'A0:0B:BA': 'Samsung', 'A4:7B:85': 'Samsung',
|
||||
'A8:06:00': 'Samsung', 'AC:5F:3E': 'Samsung', 'B0:72:BF': 'Samsung', 'B4:79:A7': 'Samsung',
|
||||
'BC:44:86': 'Samsung', 'C0:97:27': 'Samsung', 'C4:42:02': 'Samsung', 'CC:07:AB': 'Samsung',
|
||||
'D0:22:BE': 'Samsung', 'D4:87:D8': 'Samsung', 'D8:90:E8': 'Samsung', 'E4:7C:F9': 'Samsung',
|
||||
'E8:50:8B': 'Samsung', 'F0:25:B7': 'Samsung', 'F4:7B:5E': 'Samsung', 'FC:A1:3E': 'Samsung',
|
||||
"00:1B:66": "Samsung",
|
||||
"00:21:19": "Samsung",
|
||||
"00:26:37": "Samsung",
|
||||
"5C:0A:5B": "Samsung",
|
||||
"8C:71:F8": "Samsung",
|
||||
"C4:73:1E": "Samsung",
|
||||
"38:2C:4A": "Samsung",
|
||||
"00:1E:4C": "Samsung",
|
||||
"00:12:47": "Samsung",
|
||||
"00:15:99": "Samsung",
|
||||
"00:17:D5": "Samsung",
|
||||
"00:1D:F6": "Samsung",
|
||||
"00:21:D1": "Samsung",
|
||||
"00:24:54": "Samsung",
|
||||
"00:26:5D": "Samsung",
|
||||
"08:D4:2B": "Samsung",
|
||||
"10:D5:42": "Samsung",
|
||||
"14:49:E0": "Samsung",
|
||||
"18:3A:2D": "Samsung",
|
||||
"1C:66:AA": "Samsung",
|
||||
"24:4B:81": "Samsung",
|
||||
"28:98:7B": "Samsung",
|
||||
"2C:AE:2B": "Samsung",
|
||||
"30:96:FB": "Samsung",
|
||||
"34:C3:AC": "Samsung",
|
||||
"38:01:95": "Samsung",
|
||||
"3C:5A:37": "Samsung",
|
||||
"40:0E:85": "Samsung",
|
||||
"44:4E:1A": "Samsung",
|
||||
"4C:BC:A5": "Samsung",
|
||||
"50:01:BB": "Samsung",
|
||||
"50:A4:D0": "Samsung",
|
||||
"54:88:0E": "Samsung",
|
||||
"58:C3:8B": "Samsung",
|
||||
"5C:2E:59": "Samsung",
|
||||
"60:D0:A9": "Samsung",
|
||||
"64:B3:10": "Samsung",
|
||||
"68:48:98": "Samsung",
|
||||
"6C:2F:2C": "Samsung",
|
||||
"70:F9:27": "Samsung",
|
||||
"74:45:8A": "Samsung",
|
||||
"78:47:1D": "Samsung",
|
||||
"7C:0B:C6": "Samsung",
|
||||
"84:11:9E": "Samsung",
|
||||
"88:32:9B": "Samsung",
|
||||
"8C:77:12": "Samsung",
|
||||
"90:18:7C": "Samsung",
|
||||
"94:35:0A": "Samsung",
|
||||
"98:52:B1": "Samsung",
|
||||
"9C:02:98": "Samsung",
|
||||
"A0:0B:BA": "Samsung",
|
||||
"A4:7B:85": "Samsung",
|
||||
"A8:06:00": "Samsung",
|
||||
"AC:5F:3E": "Samsung",
|
||||
"B0:72:BF": "Samsung",
|
||||
"B4:79:A7": "Samsung",
|
||||
"BC:44:86": "Samsung",
|
||||
"C0:97:27": "Samsung",
|
||||
"C4:42:02": "Samsung",
|
||||
"CC:07:AB": "Samsung",
|
||||
"D0:22:BE": "Samsung",
|
||||
"D4:87:D8": "Samsung",
|
||||
"D8:90:E8": "Samsung",
|
||||
"E4:7C:F9": "Samsung",
|
||||
"E8:50:8B": "Samsung",
|
||||
"F0:25:B7": "Samsung",
|
||||
"F4:7B:5E": "Samsung",
|
||||
"FC:A1:3E": "Samsung",
|
||||
# Google
|
||||
'54:60:09': 'Google', '00:1A:11': 'Google', 'F4:F5:D8': 'Google', '94:EB:2C': 'Google',
|
||||
'64:B5:C6': 'Google', '3C:5A:B4': 'Google', 'F8:8F:CA': 'Google', '20:DF:B9': 'Google',
|
||||
'54:27:1E': 'Google', '58:CB:52': 'Google', 'A4:77:33': 'Google', 'F4:0E:22': 'Google',
|
||||
"54:60:09": "Google",
|
||||
"00:1A:11": "Google",
|
||||
"F4:F5:D8": "Google",
|
||||
"94:EB:2C": "Google",
|
||||
"64:B5:C6": "Google",
|
||||
"3C:5A:B4": "Google",
|
||||
"F8:8F:CA": "Google",
|
||||
"20:DF:B9": "Google",
|
||||
"54:27:1E": "Google",
|
||||
"58:CB:52": "Google",
|
||||
"A4:77:33": "Google",
|
||||
"F4:0E:22": "Google",
|
||||
# Sony
|
||||
'00:13:A9': 'Sony', '00:1D:28': 'Sony', '00:24:BE': 'Sony', '04:5D:4B': 'Sony',
|
||||
'08:A9:5A': 'Sony', '10:4F:A8': 'Sony', '24:21:AB': 'Sony', '30:52:CB': 'Sony',
|
||||
'40:B8:37': 'Sony', '58:48:22': 'Sony', '70:9E:29': 'Sony', '84:00:D2': 'Sony',
|
||||
'AC:9B:0A': 'Sony', 'B4:52:7D': 'Sony', 'BC:60:A7': 'Sony', 'FC:0F:E6': 'Sony',
|
||||
"00:13:A9": "Sony",
|
||||
"00:1D:28": "Sony",
|
||||
"00:24:BE": "Sony",
|
||||
"04:5D:4B": "Sony",
|
||||
"08:A9:5A": "Sony",
|
||||
"10:4F:A8": "Sony",
|
||||
"24:21:AB": "Sony",
|
||||
"30:52:CB": "Sony",
|
||||
"40:B8:37": "Sony",
|
||||
"58:48:22": "Sony",
|
||||
"70:9E:29": "Sony",
|
||||
"84:00:D2": "Sony",
|
||||
"AC:9B:0A": "Sony",
|
||||
"B4:52:7D": "Sony",
|
||||
"BC:60:A7": "Sony",
|
||||
"FC:0F:E6": "Sony",
|
||||
# Bose
|
||||
'00:0C:8A': 'Bose', '04:52:C7': 'Bose', '08:DF:1F': 'Bose', '2C:41:A1': 'Bose',
|
||||
'4C:87:5D': 'Bose', '60:AB:D2': 'Bose', '88:C9:E8': 'Bose', 'D8:9C:67': 'Bose',
|
||||
"00:0C:8A": "Bose",
|
||||
"04:52:C7": "Bose",
|
||||
"08:DF:1F": "Bose",
|
||||
"2C:41:A1": "Bose",
|
||||
"4C:87:5D": "Bose",
|
||||
"60:AB:D2": "Bose",
|
||||
"88:C9:E8": "Bose",
|
||||
"D8:9C:67": "Bose",
|
||||
# JBL/Harman
|
||||
'00:1D:DF': 'JBL', '08:AE:D6': 'JBL', '20:3C:AE': 'JBL', '44:5E:F3': 'JBL',
|
||||
'50:C9:71': 'JBL', '74:5E:1C': 'JBL', '88:C6:26': 'JBL', 'AC:12:2F': 'JBL',
|
||||
"00:1D:DF": "JBL",
|
||||
"08:AE:D6": "JBL",
|
||||
"20:3C:AE": "JBL",
|
||||
"44:5E:F3": "JBL",
|
||||
"50:C9:71": "JBL",
|
||||
"74:5E:1C": "JBL",
|
||||
"88:C6:26": "JBL",
|
||||
"AC:12:2F": "JBL",
|
||||
# Beats (Apple subsidiary)
|
||||
'00:61:71': 'Beats', '48:D6:D5': 'Beats', '9C:64:8B': 'Beats', 'A4:E9:75': 'Beats',
|
||||
"00:61:71": "Beats",
|
||||
"48:D6:D5": "Beats",
|
||||
"9C:64:8B": "Beats",
|
||||
"A4:E9:75": "Beats",
|
||||
# Jabra/GN Audio
|
||||
'00:13:17': 'Jabra', '1C:48:F9': 'Jabra', '50:C2:ED': 'Jabra', '70:BF:92': 'Jabra',
|
||||
'74:5C:4B': 'Jabra', '94:16:25': 'Jabra', 'D0:81:7A': 'Jabra', 'E8:EE:CC': 'Jabra',
|
||||
"00:13:17": "Jabra",
|
||||
"1C:48:F9": "Jabra",
|
||||
"50:C2:ED": "Jabra",
|
||||
"70:BF:92": "Jabra",
|
||||
"74:5C:4B": "Jabra",
|
||||
"94:16:25": "Jabra",
|
||||
"D0:81:7A": "Jabra",
|
||||
"E8:EE:CC": "Jabra",
|
||||
# Sennheiser
|
||||
'00:1B:66': 'Sennheiser', '00:22:27': 'Sennheiser', 'B8:AD:3E': 'Sennheiser',
|
||||
"00:1B:66": "Sennheiser",
|
||||
"00:22:27": "Sennheiser",
|
||||
"B8:AD:3E": "Sennheiser",
|
||||
# Xiaomi
|
||||
'04:CF:8C': 'Xiaomi', '0C:1D:AF': 'Xiaomi', '10:2A:B3': 'Xiaomi', '18:59:36': 'Xiaomi',
|
||||
'20:47:DA': 'Xiaomi', '28:6C:07': 'Xiaomi', '34:CE:00': 'Xiaomi', '38:A4:ED': 'Xiaomi',
|
||||
'44:23:7C': 'Xiaomi', '50:64:2B': 'Xiaomi', '58:44:98': 'Xiaomi', '64:09:80': 'Xiaomi',
|
||||
'74:23:44': 'Xiaomi', '78:02:F8': 'Xiaomi', '7C:1C:4E': 'Xiaomi', '84:F3:EB': 'Xiaomi',
|
||||
'8C:BE:BE': 'Xiaomi', '98:FA:E3': 'Xiaomi', 'A4:77:58': 'Xiaomi', 'AC:C1:EE': 'Xiaomi',
|
||||
'B0:E2:35': 'Xiaomi', 'C4:0B:CB': 'Xiaomi', 'C8:47:8C': 'Xiaomi', 'D4:97:0B': 'Xiaomi',
|
||||
'E4:46:DA': 'Xiaomi', 'F0:B4:29': 'Xiaomi', 'FC:64:BA': 'Xiaomi',
|
||||
"04:CF:8C": "Xiaomi",
|
||||
"0C:1D:AF": "Xiaomi",
|
||||
"10:2A:B3": "Xiaomi",
|
||||
"18:59:36": "Xiaomi",
|
||||
"20:47:DA": "Xiaomi",
|
||||
"28:6C:07": "Xiaomi",
|
||||
"34:CE:00": "Xiaomi",
|
||||
"38:A4:ED": "Xiaomi",
|
||||
"44:23:7C": "Xiaomi",
|
||||
"50:64:2B": "Xiaomi",
|
||||
"58:44:98": "Xiaomi",
|
||||
"64:09:80": "Xiaomi",
|
||||
"74:23:44": "Xiaomi",
|
||||
"78:02:F8": "Xiaomi",
|
||||
"7C:1C:4E": "Xiaomi",
|
||||
"84:F3:EB": "Xiaomi",
|
||||
"8C:BE:BE": "Xiaomi",
|
||||
"98:FA:E3": "Xiaomi",
|
||||
"A4:77:58": "Xiaomi",
|
||||
"AC:C1:EE": "Xiaomi",
|
||||
"B0:E2:35": "Xiaomi",
|
||||
"C4:0B:CB": "Xiaomi",
|
||||
"C8:47:8C": "Xiaomi",
|
||||
"D4:97:0B": "Xiaomi",
|
||||
"E4:46:DA": "Xiaomi",
|
||||
"F0:B4:29": "Xiaomi",
|
||||
"FC:64:BA": "Xiaomi",
|
||||
# Huawei
|
||||
'00:18:82': 'Huawei', '00:1E:10': 'Huawei', '00:25:68': 'Huawei', '04:B0:E7': 'Huawei',
|
||||
'08:63:61': 'Huawei', '10:1B:54': 'Huawei', '18:DE:D7': 'Huawei', '20:A6:80': 'Huawei',
|
||||
'28:31:52': 'Huawei', '34:12:98': 'Huawei', '3C:47:11': 'Huawei', '48:00:31': 'Huawei',
|
||||
'4C:50:77': 'Huawei', '5C:7D:5E': 'Huawei', '60:DE:44': 'Huawei', '70:72:3C': 'Huawei',
|
||||
'78:F5:57': 'Huawei', '80:B6:86': 'Huawei', '88:53:D4': 'Huawei', '94:04:9C': 'Huawei',
|
||||
'A4:99:47': 'Huawei', 'B4:15:13': 'Huawei', 'BC:76:70': 'Huawei', 'C8:D1:5E': 'Huawei',
|
||||
'DC:D2:FC': 'Huawei', 'E4:68:A3': 'Huawei', 'F4:63:1F': 'Huawei',
|
||||
"00:18:82": "Huawei",
|
||||
"00:1E:10": "Huawei",
|
||||
"00:25:68": "Huawei",
|
||||
"04:B0:E7": "Huawei",
|
||||
"08:63:61": "Huawei",
|
||||
"10:1B:54": "Huawei",
|
||||
"18:DE:D7": "Huawei",
|
||||
"20:A6:80": "Huawei",
|
||||
"28:31:52": "Huawei",
|
||||
"34:12:98": "Huawei",
|
||||
"3C:47:11": "Huawei",
|
||||
"48:00:31": "Huawei",
|
||||
"4C:50:77": "Huawei",
|
||||
"5C:7D:5E": "Huawei",
|
||||
"60:DE:44": "Huawei",
|
||||
"70:72:3C": "Huawei",
|
||||
"78:F5:57": "Huawei",
|
||||
"80:B6:86": "Huawei",
|
||||
"88:53:D4": "Huawei",
|
||||
"94:04:9C": "Huawei",
|
||||
"A4:99:47": "Huawei",
|
||||
"B4:15:13": "Huawei",
|
||||
"BC:76:70": "Huawei",
|
||||
"C8:D1:5E": "Huawei",
|
||||
"DC:D2:FC": "Huawei",
|
||||
"E4:68:A3": "Huawei",
|
||||
"F4:63:1F": "Huawei",
|
||||
# OnePlus/BBK
|
||||
'64:A2:F9': 'OnePlus', 'C0:EE:FB': 'OnePlus', '94:65:2D': 'OnePlus',
|
||||
"64:A2:F9": "OnePlus",
|
||||
"C0:EE:FB": "OnePlus",
|
||||
"94:65:2D": "OnePlus",
|
||||
# Fitbit
|
||||
'2C:09:4D': 'Fitbit', 'C4:D9:87': 'Fitbit', 'E4:88:6D': 'Fitbit',
|
||||
"2C:09:4D": "Fitbit",
|
||||
"C4:D9:87": "Fitbit",
|
||||
"E4:88:6D": "Fitbit",
|
||||
# Garmin
|
||||
'00:1C:D1': 'Garmin', 'C4:AC:59': 'Garmin', 'E8:0F:C8': 'Garmin',
|
||||
"00:1C:D1": "Garmin",
|
||||
"C4:AC:59": "Garmin",
|
||||
"E8:0F:C8": "Garmin",
|
||||
# Microsoft
|
||||
'00:50:F2': 'Microsoft', '28:18:78': 'Microsoft', '60:45:BD': 'Microsoft',
|
||||
'7C:1E:52': 'Microsoft', '98:5F:D3': 'Microsoft', 'B4:0E:DE': 'Microsoft',
|
||||
"00:50:F2": "Microsoft",
|
||||
"28:18:78": "Microsoft",
|
||||
"60:45:BD": "Microsoft",
|
||||
"7C:1E:52": "Microsoft",
|
||||
"98:5F:D3": "Microsoft",
|
||||
"B4:0E:DE": "Microsoft",
|
||||
# Intel
|
||||
'00:1B:21': 'Intel', '00:1C:C0': 'Intel', '00:1E:64': 'Intel', '00:21:5C': 'Intel',
|
||||
'08:D4:0C': 'Intel', '18:1D:EA': 'Intel', '34:02:86': 'Intel', '40:74:E0': 'Intel',
|
||||
'48:51:B7': 'Intel', '58:A0:23': 'Intel', '64:D4:DA': 'Intel', '80:19:34': 'Intel',
|
||||
'8C:8D:28': 'Intel', 'A4:4E:31': 'Intel', 'B4:6B:FC': 'Intel', 'C8:D0:83': 'Intel',
|
||||
"00:1B:21": "Intel",
|
||||
"00:1C:C0": "Intel",
|
||||
"00:1E:64": "Intel",
|
||||
"00:21:5C": "Intel",
|
||||
"08:D4:0C": "Intel",
|
||||
"18:1D:EA": "Intel",
|
||||
"34:02:86": "Intel",
|
||||
"40:74:E0": "Intel",
|
||||
"48:51:B7": "Intel",
|
||||
"58:A0:23": "Intel",
|
||||
"64:D4:DA": "Intel",
|
||||
"80:19:34": "Intel",
|
||||
"8C:8D:28": "Intel",
|
||||
"A4:4E:31": "Intel",
|
||||
"B4:6B:FC": "Intel",
|
||||
"C8:D0:83": "Intel",
|
||||
# Qualcomm/Atheros
|
||||
'00:03:7F': 'Qualcomm', '00:24:E4': 'Qualcomm', '04:F0:21': 'Qualcomm',
|
||||
'1C:4B:D6': 'Qualcomm', '88:71:B1': 'Qualcomm', 'A0:65:18': 'Qualcomm',
|
||||
"00:03:7F": "Qualcomm",
|
||||
"00:24:E4": "Qualcomm",
|
||||
"04:F0:21": "Qualcomm",
|
||||
"1C:4B:D6": "Qualcomm",
|
||||
"88:71:B1": "Qualcomm",
|
||||
"A0:65:18": "Qualcomm",
|
||||
# Broadcom
|
||||
'00:10:18': 'Broadcom', '00:1A:2B': 'Broadcom', '20:10:7A': 'Broadcom',
|
||||
"00:10:18": "Broadcom",
|
||||
"00:1A:2B": "Broadcom",
|
||||
"20:10:7A": "Broadcom",
|
||||
# Realtek
|
||||
'00:0A:EB': 'Realtek', '00:E0:4C': 'Realtek', '48:02:2A': 'Realtek',
|
||||
'52:54:00': 'Realtek', '80:EA:96': 'Realtek',
|
||||
"00:0A:EB": "Realtek",
|
||||
"00:E0:4C": "Realtek",
|
||||
"48:02:2A": "Realtek",
|
||||
"52:54:00": "Realtek",
|
||||
"80:EA:96": "Realtek",
|
||||
# Logitech
|
||||
'00:1F:20': 'Logitech', '34:88:5D': 'Logitech', '6C:B7:49': 'Logitech',
|
||||
"00:1F:20": "Logitech",
|
||||
"34:88:5D": "Logitech",
|
||||
"6C:B7:49": "Logitech",
|
||||
# Lenovo
|
||||
'00:09:2D': 'Lenovo', '28:D2:44': 'Lenovo', '54:EE:75': 'Lenovo', '98:FA:9B': 'Lenovo',
|
||||
"00:09:2D": "Lenovo",
|
||||
"28:D2:44": "Lenovo",
|
||||
"54:EE:75": "Lenovo",
|
||||
"98:FA:9B": "Lenovo",
|
||||
# Dell
|
||||
'00:14:22': 'Dell', '00:1A:A0': 'Dell', '18:DB:F2': 'Dell', '34:17:EB': 'Dell',
|
||||
'78:2B:CB': 'Dell', 'A4:BA:DB': 'Dell', 'E4:B9:7A': 'Dell',
|
||||
"00:14:22": "Dell",
|
||||
"00:1A:A0": "Dell",
|
||||
"18:DB:F2": "Dell",
|
||||
"34:17:EB": "Dell",
|
||||
"78:2B:CB": "Dell",
|
||||
"A4:BA:DB": "Dell",
|
||||
"E4:B9:7A": "Dell",
|
||||
# HP
|
||||
'00:0F:61': 'HP', '00:14:C2': 'HP', '10:1F:74': 'HP', '28:80:23': 'HP',
|
||||
'38:63:BB': 'HP', '5C:B9:01': 'HP', '80:CE:62': 'HP', 'A0:D3:C1': 'HP',
|
||||
"00:0F:61": "HP",
|
||||
"00:14:C2": "HP",
|
||||
"10:1F:74": "HP",
|
||||
"28:80:23": "HP",
|
||||
"38:63:BB": "HP",
|
||||
"5C:B9:01": "HP",
|
||||
"80:CE:62": "HP",
|
||||
"A0:D3:C1": "HP",
|
||||
# Tile
|
||||
'F8:E4:E3': 'Tile', 'C4:E7:BE': 'Tile', 'DC:54:D7': 'Tile', 'E4:B0:21': 'Tile',
|
||||
"F8:E4:E3": "Tile",
|
||||
"C4:E7:BE": "Tile",
|
||||
"DC:54:D7": "Tile",
|
||||
"E4:B0:21": "Tile",
|
||||
# Raspberry Pi
|
||||
'B8:27:EB': 'Raspberry Pi', 'DC:A6:32': 'Raspberry Pi', 'E4:5F:01': 'Raspberry Pi',
|
||||
"B8:27:EB": "Raspberry Pi",
|
||||
"DC:A6:32": "Raspberry Pi",
|
||||
"E4:5F:01": "Raspberry Pi",
|
||||
# Amazon
|
||||
'00:FC:8B': 'Amazon', '10:CE:A9': 'Amazon', '34:D2:70': 'Amazon', '40:B4:CD': 'Amazon',
|
||||
'44:65:0D': 'Amazon', '68:54:FD': 'Amazon', '74:C2:46': 'Amazon', '84:D6:D0': 'Amazon',
|
||||
'A0:02:DC': 'Amazon', 'AC:63:BE': 'Amazon', 'B4:7C:9C': 'Amazon', 'FC:65:DE': 'Amazon',
|
||||
"00:FC:8B": "Amazon",
|
||||
"10:CE:A9": "Amazon",
|
||||
"34:D2:70": "Amazon",
|
||||
"40:B4:CD": "Amazon",
|
||||
"44:65:0D": "Amazon",
|
||||
"68:54:FD": "Amazon",
|
||||
"74:C2:46": "Amazon",
|
||||
"84:D6:D0": "Amazon",
|
||||
"A0:02:DC": "Amazon",
|
||||
"AC:63:BE": "Amazon",
|
||||
"B4:7C:9C": "Amazon",
|
||||
"FC:65:DE": "Amazon",
|
||||
# Skullcandy
|
||||
'00:01:00': 'Skullcandy', '88:E6:03': 'Skullcandy',
|
||||
"00:01:00": "Skullcandy",
|
||||
"88:E6:03": "Skullcandy",
|
||||
# Bang & Olufsen
|
||||
'00:21:3E': 'Bang & Olufsen', '78:C5:E5': 'Bang & Olufsen',
|
||||
"00:21:3E": "Bang & Olufsen",
|
||||
"78:C5:E5": "Bang & Olufsen",
|
||||
# Audio-Technica
|
||||
'A0:E9:DB': 'Audio-Technica', 'EC:81:93': 'Audio-Technica',
|
||||
"A0:E9:DB": "Audio-Technica",
|
||||
"EC:81:93": "Audio-Technica",
|
||||
# Plantronics/Poly
|
||||
'00:1D:DF': 'Plantronics', 'B0:B4:48': 'Plantronics', 'E8:FC:AF': 'Plantronics',
|
||||
"00:1D:DF": "Plantronics",
|
||||
"B0:B4:48": "Plantronics",
|
||||
"E8:FC:AF": "Plantronics",
|
||||
# Anker
|
||||
'AC:89:95': 'Anker', 'E8:AB:FA': 'Anker',
|
||||
"AC:89:95": "Anker",
|
||||
"E8:AB:FA": "Anker",
|
||||
# Misc/Generic
|
||||
'00:00:0A': 'Omron', '00:1A:7D': 'Cyber-Blue', '00:1E:3D': 'Alps Electric',
|
||||
'00:0B:57': 'Silicon Wave', '00:02:72': 'CC&C',
|
||||
"00:00:0A": "Omron",
|
||||
"00:1A:7D": "Cyber-Blue",
|
||||
"00:1E:3D": "Alps Electric",
|
||||
"00:0B:57": "Silicon Wave",
|
||||
"00:02:72": "CC&C",
|
||||
}
|
||||
|
||||
# Try to load from external file (easier to update)
|
||||
|
||||
+50
-32
@@ -1,32 +1,50 @@
|
||||
# TLE data for satellite tracking (updated periodically)
|
||||
# To update: click "Update TLE" in satellite dashboard or SSTV mode
|
||||
# Data source: CelesTrak (celestrak.org)
|
||||
TLE_SATELLITES = {
|
||||
'ISS': ('ISS (ZARYA)',
|
||||
'1 25544U 98067A 25029.51432176 .00020818 00000+0 36919-3 0 9991',
|
||||
'2 25544 51.6400 157.5640 0002671 123.5041 236.6291 15.49988902492099'),
|
||||
'NOAA-15': ('NOAA 15',
|
||||
'1 25338U 98030A 25028.84157420 .00000535 00000+0 26168-3 0 9999',
|
||||
'2 25338 98.5676 356.1853 0009968 282.2567 77.7505 14.26225252390049'),
|
||||
'NOAA-18': ('NOAA 18',
|
||||
'1 28654U 05018A 25028.87364583 .00000454 00000+0 25082-3 0 9996',
|
||||
'2 28654 98.8801 59.1618 0013609 281.7181 78.2479 14.13003043 24668'),
|
||||
'NOAA-19': ('NOAA 19',
|
||||
'1 33591U 09005A 25028.82370718 .00000425 00000+0 24556-3 0 9998',
|
||||
'2 33591 99.0905 25.2347 0013428 265.3457 94.6190 14.13019285827447'),
|
||||
'NOAA-20': ('NOAA 20 (JPSS-1)',
|
||||
'1 43013U 17073A 25028.83917428 .00000284 00000+0 15698-3 0 9995',
|
||||
'2 43013 98.7104 59.9558 0001165 102.5891 257.5432 14.19571458378899'),
|
||||
'NOAA-21': ('NOAA 21 (JPSS-2)',
|
||||
'1 54234U 22150A 25028.86292604 .00000268 00000+0 14911-3 0 9995',
|
||||
'2 54234 98.7064 59.6648 0001271 88.4689 271.6646 14.19545810114699'),
|
||||
'METEOR-M2': ('METEOR-M 2',
|
||||
'1 40069U 14037A 25028.47802083 .00000099 00000+0 69422-4 0 9990',
|
||||
'2 40069 98.4752 356.8632 0003942 251.7291 108.3489 14.20719440555299'),
|
||||
'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'),
|
||||
}
|
||||
# TLE data for satellite tracking (updated periodically)
|
||||
# To update: click "Update TLE" in satellite dashboard or SSTV mode
|
||||
# Data source: CelesTrak (celestrak.org)
|
||||
TLE_SATELLITES = {
|
||||
"ISS": (
|
||||
"ISS (ZARYA)",
|
||||
"1 25544U 98067A 23321.52083333 .00016717 00000-0 30171-3 0 9992",
|
||||
"2 25544 51.6416 20.4567 0004561 45.3212 67.8912 15.49876543123456",
|
||||
),
|
||||
"NOAA-15": (
|
||||
"NOAA 15",
|
||||
"1 25338U 98030A 25028.84157420 .00000535 00000+0 26168-3 0 9999",
|
||||
"2 25338 98.5676 356.1853 0009968 282.2567 77.7505 14.26225252390049",
|
||||
),
|
||||
"NOAA-18": (
|
||||
"NOAA 18",
|
||||
"1 28654U 05018A 25028.87364583 .00000454 00000+0 25082-3 0 9996",
|
||||
"2 28654 98.8801 59.1618 0013609 281.7181 78.2479 14.13003043 24668",
|
||||
),
|
||||
"NOAA-19": (
|
||||
"NOAA 19",
|
||||
"1 33591U 09005A 25028.82370718 .00000425 00000+0 24556-3 0 9998",
|
||||
"2 33591 99.0905 25.2347 0013428 265.3457 94.6190 14.13019285827447",
|
||||
),
|
||||
"NOAA-20": (
|
||||
"NOAA 20 (JPSS-1)",
|
||||
"1 43013U 17073A 26141.21646093 .00000052 00000+0 45436-4 0 9996",
|
||||
"2 43013 98.7764 80.9203 0001233 42.6389 317.4882 14.19506117440643",
|
||||
),
|
||||
"NOAA-21": (
|
||||
"NOAA 21 (JPSS-2)",
|
||||
"1 54234U 22150A 26141.25034758 .00000025 00000+0 32664-4 0 9997",
|
||||
"2 54234 98.7052 80.4933 0000516 290.1874 69.9247 14.19559916182728",
|
||||
),
|
||||
"METEOR-M2": (
|
||||
"METEOR-M 2",
|
||||
"1 40069U 14037A 26141.25652306 .00000366 00000+0 18646-3 0 9999",
|
||||
"2 40069 98.5106 117.9520 0006860 109.5984 250.5935 14.21454410615491",
|
||||
),
|
||||
"METEOR-M2-3": (
|
||||
"METEOR-M2 3",
|
||||
"1 57166U 23091A 26141.32851392 -.00000014 00000+0 12575-4 0 9996",
|
||||
"2 57166 98.6097 196.8537 0002910 239.0757 121.0137 14.24044204150691",
|
||||
),
|
||||
"METEOR-M2-4": (
|
||||
"METEOR-M2 4",
|
||||
"1 59051U 24039A 26141.24240655 .00000007 00000+0 22827-4 0 9991",
|
||||
"2 59051 98.6997 100.8818 0005969 244.5272 115.5289 14.22426426115439",
|
||||
),
|
||||
}
|
||||
|
||||
+16993
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
|
||||
+18
-14
@@ -6,17 +6,15 @@
|
||||
# Basic usage (build locally):
|
||||
# docker compose --profile basic up -d --build
|
||||
#
|
||||
# Basic usage (pre-built image from registry):
|
||||
# INTERCEPT_IMAGE=ghcr.io/user/intercept:latest docker compose --profile basic up -d
|
||||
#
|
||||
# With ADS-B history (Postgres):
|
||||
# docker compose --profile history up -d
|
||||
|
||||
services:
|
||||
intercept:
|
||||
# When INTERCEPT_IMAGE is set, use that pre-built image; otherwise build locally
|
||||
image: ${INTERCEPT_IMAGE:-intercept:latest}
|
||||
# Always build and use the local image
|
||||
image: intercept:latest
|
||||
build: .
|
||||
pull_policy: never
|
||||
container_name: intercept
|
||||
ports:
|
||||
- "5050:5050"
|
||||
@@ -28,8 +26,12 @@ services:
|
||||
devices:
|
||||
- /dev/bus/usb:/dev/bus/usb
|
||||
volumes:
|
||||
# Persist decoded images and database across container rebuilds
|
||||
- ./data:/app/data
|
||||
# Persist runtime output directories across container rebuilds.
|
||||
# Mount subdirectories individually so Python modules in /app/data are not shadowed.
|
||||
- ./data/weather_sat:/app/data/weather_sat
|
||||
- ./data/radiosonde:/app/data/radiosonde
|
||||
- ./data/subghz:/app/data/subghz
|
||||
- ./data/adsb:/app/data/adsb
|
||||
# Optional: mount logs directory
|
||||
# - ./logs:/app/logs
|
||||
environment:
|
||||
@@ -68,9 +70,10 @@ services:
|
||||
# ADS-B history with Postgres persistence
|
||||
# Enable with: docker compose --profile history up -d
|
||||
intercept-history:
|
||||
# Same image/build fallback pattern as above
|
||||
image: ${INTERCEPT_IMAGE:-intercept:latest}
|
||||
# Always build and use the local image
|
||||
image: intercept:latest
|
||||
build: .
|
||||
pull_policy: never
|
||||
container_name: intercept-history
|
||||
profiles:
|
||||
- history
|
||||
@@ -86,7 +89,10 @@ services:
|
||||
devices:
|
||||
- /dev/bus/usb:/dev/bus/usb
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./data/weather_sat:/app/data/weather_sat
|
||||
- ./data/radiosonde:/app/data/radiosonde
|
||||
- ./data/subghz:/app/data/subghz
|
||||
- ./data/adsb:/app/data/adsb
|
||||
environment:
|
||||
- TZ=${TZ:-UTC}
|
||||
- INTERCEPT_HOST=0.0.0.0
|
||||
@@ -105,6 +111,8 @@ services:
|
||||
- INTERCEPT_ADSB_AUTO_START=${INTERCEPT_ADSB_AUTO_START:-false}
|
||||
# Shared observer location across modules
|
||||
- INTERCEPT_SHARED_OBSERVER_LOCATION=${INTERCEPT_SHARED_OBSERVER_LOCATION:-true}
|
||||
# Disable login auth (set to true for local/dev use)
|
||||
- INTERCEPT_DISABLE_AUTH=${INTERCEPT_DISABLE_AUTH:-false}
|
||||
# Default observer coordinates (set to your location to skip the GPS prompt)
|
||||
# - INTERCEPT_DEFAULT_LAT=${INTERCEPT_DEFAULT_LAT:-0}
|
||||
# - INTERCEPT_DEFAULT_LON=${INTERCEPT_DEFAULT_LON:-0}
|
||||
@@ -135,7 +143,3 @@ services:
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# Optional: Add volume for persistent SQLite database
|
||||
# volumes:
|
||||
# intercept-data:
|
||||
|
||||
+43
-515
@@ -1,515 +1,43 @@
|
||||
# INTERCEPT Features
|
||||
|
||||
Complete feature list for all modules.
|
||||
|
||||
## Pager Decoding
|
||||
|
||||
- **Real-time decoding** of POCSAG (512/1200/2400) and FLEX protocols
|
||||
- **Customizable frequency presets** stored in browser
|
||||
- **Auto-restart** on frequency change while decoding
|
||||
|
||||
## 433MHz Sensor Decoding
|
||||
|
||||
- **200+ device protocols** supported via rtl_433
|
||||
- **Weather stations** - temperature, humidity, wind, rain
|
||||
- **TPMS** - Tire pressure monitoring sensors
|
||||
- **Doorbells, remotes, and IoT devices**
|
||||
- **Smart meters** and utility monitors
|
||||
|
||||
## Sub-GHz Analyzer
|
||||
|
||||
- **HackRF-based** signal capture and analysis for 300-928 MHz ISM bands
|
||||
- **Protocol decoding** - identify and decode common Sub-GHz protocols
|
||||
- **Signal replay/transmit** capabilities for authorized testing
|
||||
- **Wideband spectrum analysis** with real-time visualization
|
||||
- **I/Q capture** - record raw samples for offline analysis
|
||||
|
||||
## Spy Stations (Number Stations)
|
||||
|
||||
- **Comprehensive database** of active number stations and diplomatic networks
|
||||
- **Station profiles** - frequencies, schedules, operators, descriptions
|
||||
- **Filter by type** - number stations vs diplomatic networks
|
||||
- **Filter by country** - Russia, Cuba, Israel, Poland, North Korea, etc.
|
||||
- **Filter by mode** - USB, AM, CW, OFDM
|
||||
- **Tune integration** - click to tune Listening Post to station frequency
|
||||
- **Source links** - references to priyom.org for detailed information
|
||||
- **Famous stations** - UVB-76 "The Buzzer", Cuban HM01, Israeli E17z
|
||||
|
||||
## ADS-B Aircraft Tracking
|
||||
|
||||
- **Real-time aircraft tracking** via dump1090 or rtl_adsb
|
||||
- **Full-screen dashboard** - dedicated popout with virtual radar scope
|
||||
- **Interactive Leaflet map** with OpenStreetMap tiles (dark-themed)
|
||||
- **Aircraft trails** - optional flight path history visualization
|
||||
- **Range rings** - distance reference circles from observer position
|
||||
- **Aircraft filtering** - show all, military only, civil only, or emergency only
|
||||
- **Marker clustering** - group nearby aircraft at lower zoom levels
|
||||
- **Reception statistics** - max range, message rate, busiest hour, total seen
|
||||
- **Persistent ADS-B history** - optional Postgres-backed message and snapshot storage
|
||||
- **History reporting dashboard** - session controls, aircraft timelines, and detail modal
|
||||
- **Observer location** - manual input or GPS geolocation
|
||||
- **Audio alerts** - notifications for military and emergency aircraft
|
||||
- **Emergency squawk highlighting** - visual alerts for 7500/7600/7700
|
||||
- **Aircraft details popup** - callsign, altitude, speed, heading, squawk, ICAO
|
||||
|
||||
<p align="center">
|
||||
<img src="/static/images/screenshots/screenshot_radar.png" alt="Screenshot">
|
||||
</p>
|
||||
|
||||
## AIS Vessel Tracking
|
||||
|
||||
- **Real-time vessel tracking** via AIS-catcher or rtl_ais
|
||||
- **Full-screen dashboard** - dedicated popout with maritime map
|
||||
- **Interactive Leaflet map** with OpenStreetMap tiles (dark-themed)
|
||||
- **Vessel trails** - optional track history visualization
|
||||
- **Vessel details popup** - name, MMSI, callsign, destination, ship type, speed, heading
|
||||
- **Country identification** - flag lookup via Maritime Identification Digits (MID)
|
||||
|
||||
### VHF DSC Channel 70 Monitoring
|
||||
|
||||
Digital Selective Calling (DSC) monitoring on the international maritime distress frequency.
|
||||
|
||||
- **Real-time DSC decoding** - Distress, Urgency, Safety, and Routine messages
|
||||
- **MMSI country lookup** - 180+ Maritime Identification Digit codes
|
||||
- **Distress nature identification** - Fire, Flooding, Collision, Sinking, Piracy, MOB, etc.
|
||||
- **Position extraction** - Automatic lat/lon parsing from distress messages
|
||||
- **Map markers** - Distress positions plotted with pulsing alert markers
|
||||
- **Visual alert overlay** - Prominent popup for DISTRESS and URGENCY messages
|
||||
- **Audio alerts** - Notification sound for critical messages
|
||||
- **Alert persistence** - Critical alerts stored permanently in database
|
||||
- **Acknowledgement workflow** - Track response status with notes
|
||||
- **SDR conflict detection** - Prevents device collisions with AIS tracking
|
||||
- **Alert summary** - Dashboard counts for unacknowledged distress/urgency
|
||||
|
||||
## ACARS Messaging
|
||||
|
||||
- **Real-time ACARS decoding** via acarsdec
|
||||
- **Aircraft datalink messages** - operational, weather, and position reports
|
||||
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
|
||||
- **Message filtering** - filter by message type, flight, or registration
|
||||
|
||||
## VDL2 (VHF Data Link Mode 2)
|
||||
|
||||
- **Real-time VDL2 decoding** via dumpvdl2 on standard VDL2 frequencies
|
||||
- **ACARS-over-AVLC** message capture with full frame parsing
|
||||
- **Signal analysis** - frequency, signal level, noise level, SNR, burst length
|
||||
- **AVLC frame details** - source/destination addresses, frame type, command/response
|
||||
- **Raw JSON inspection** - expandable raw message data for each frame
|
||||
- **Multi-frequency monitoring** - simultaneous reception on multiple VDL2 channels
|
||||
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
|
||||
- **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
|
||||
|
||||
## Weather Satellites
|
||||
|
||||
- **NOAA APT** and **Meteor LRPT** image decoding via SatDump
|
||||
- **Auto-scheduler** with pass prediction and automatic capture
|
||||
- **Polar plot** - real-time satellite position on azimuth/elevation display
|
||||
- **Ground track map** - orbit path with past/future trajectory
|
||||
- **Image gallery** with timestamped decoded imagery
|
||||
|
||||
## WebSDR
|
||||
|
||||
- **KiwiSDR network integration** for remote HF/shortwave listening
|
||||
- **WebSocket audio streaming** from remote receivers
|
||||
- **Receiver discovery** with automatic caching
|
||||
- **Frequency tuning** with band presets
|
||||
|
||||
## ISS SSTV
|
||||
|
||||
- **ISS SSTV image reception** on 145.800 MHz FM during special event transmissions
|
||||
- **Real-time ISS tracking** with world map and pass predictions
|
||||
- **Doppler correction** - optional lat/lon input for real-time frequency shift compensation
|
||||
- **Next pass countdown** - time remaining until ISS is overhead
|
||||
- **Image gallery** with timestamped decoded imagery
|
||||
- **TLE updates** - fetch latest ISS orbital elements
|
||||
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
|
||||
|
||||
## HF SSTV
|
||||
|
||||
- **Terrestrial SSTV decoding** across HF (80m-10m), VHF (6m, 2m), and UHF (70cm) bands
|
||||
- **Predefined frequency lookup** for 13 active SSTV calling frequencies
|
||||
- **Auto-modulation selection** - frequency table maps to correct mode (USB, LSB, FM)
|
||||
- **Image gallery** with decoded transmissions
|
||||
- **Common modes supported** - PD120, PD180, Martin1, Scottie1, Robot36
|
||||
|
||||
## APRS
|
||||
|
||||
- **Amateur packet radio** position reports and telemetry via direwolf
|
||||
- **Region-specific frequencies** - 144.390 MHz (North America), 144.800 MHz (Europe), and more
|
||||
- **Real-time position tracking** on interactive map
|
||||
- **Message and telemetry display** from APRS network
|
||||
|
||||
## Utility Meter Reading
|
||||
|
||||
- **Smart meter monitoring** via rtl_amr for electric, gas, and water meters
|
||||
- **Real-time JSON output** with meter ID, consumption, and signal data
|
||||
- **Multiple meter protocol support** via rtl_tcp integration
|
||||
|
||||
## Space Weather
|
||||
|
||||
- **Real-time solar indices** - Solar Flux Index (SFI), Kp index, A-index, sunspot number
|
||||
- **NOAA Space Weather Scales** - Geomagnetic storms (G), solar radiation (S), radio blackouts (R)
|
||||
- **HF band conditions** - Day/night propagation from HamQSL for 80m through 10m bands
|
||||
- **Solar wind monitoring** - Speed, density, and IMF Bz from DSCOVR satellite
|
||||
- **X-ray flux chart** - GOES X-ray data with flare class scale (A/B/C/M/X)
|
||||
- **Flare probability** - 1-day and 3-day C/M/X-class flare forecasts
|
||||
- **Solar imagery** - NASA SDO 193A, 304A, and magnetogram images
|
||||
- **D-RAP absorption maps** - HF radio absorption at 5-30 MHz frequency bands
|
||||
- **Aurora forecast** - OVATION aurora oval visualization
|
||||
- **SWPC alerts** - Real-time space weather alerts and warnings
|
||||
- **Active solar regions** - Current sunspot region data with location and area
|
||||
- **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
|
||||
- **Polar sky plot** - real-time satellite positions on azimuth/elevation display
|
||||
- **Ground track map** - satellite orbit path with past/future trajectory
|
||||
- **Pass prediction** for satellites using TLE data
|
||||
- **Add satellites** via manual TLE entry or Celestrak import
|
||||
- **Celestrak integration** - fetch by category (Amateur, Weather, ISS, Starlink, etc.)
|
||||
- **Next pass countdown** - time remaining, visibility duration, max elevation
|
||||
- **Telemetry panel** - real-time azimuth, elevation, range, velocity
|
||||
- **Multiple satellite tracking** simultaneously
|
||||
|
||||
<p align="center">
|
||||
<img src="/static/images/screenshots/screenshot_sat.png" alt="Screenshot">
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="/static/images/screenshots/screenshot_sat_2.png" alt="Screenshot">
|
||||
</p>
|
||||
|
||||
## WiFi Reconnaissance
|
||||
|
||||
- **Monitor mode** management via airmon-ng
|
||||
- **Network scanning** with airodump-ng and channel hopping
|
||||
- **Handshake capture** with real-time status and auto-detection
|
||||
- **Deauthentication attacks** for authorized testing
|
||||
- **Channel utilization** visualization (2.4GHz and 5GHz)
|
||||
- **Security overview** chart and real-time radar display
|
||||
- **Client vendor lookup** via OUI database
|
||||
- **Drone detection** - automatic detection via SSID patterns and OUI (DJI, Parrot, Autel, etc.)
|
||||
- **Rogue AP detection** - alerts for same SSID on multiple BSSIDs
|
||||
- **Signal history graph** - track signal strength over time for any device
|
||||
- **Network topology** - visual map of APs and connected clients
|
||||
- **Channel recommendation** - optimal channel suggestions based on congestion
|
||||
- **Hidden SSID revealer** - captures hidden networks from probe requests
|
||||
- **Client probe analysis** - privacy leak detection from probe requests
|
||||
- **Device correlation** - matches WiFi and Bluetooth devices by manufacturer
|
||||
|
||||
## Bluetooth Scanning
|
||||
|
||||
- **BLE and Classic** Bluetooth device scanning
|
||||
- **Multiple scan modes** - hcitool, bluetoothctl, bleak
|
||||
- **Tracker detection** - AirTag, Tile, Samsung SmartTag, Chipolo
|
||||
- **Device classification** - phones, audio, wearables, computers
|
||||
- **Manufacturer lookup** via OUI database and Bluetooth Company IDs
|
||||
- **Proximity radar** visualization
|
||||
- **Device type breakdown** chart
|
||||
|
||||
## BT Locate (SAR Bluetooth Device Location)
|
||||
|
||||
Search and rescue Bluetooth device location with GPS-tagged signal trail mapping.
|
||||
|
||||
### Core Features
|
||||
- **Target tracking** - Locate devices by MAC address, name pattern, or IRK (Identity Resolving Key)
|
||||
- **RPA resolution** - Resolve BLE Resolvable Private Addresses using IRK for tracking devices with randomized addresses
|
||||
- **IRK auto-detection** - Extract IRKs from paired devices on macOS and Linux
|
||||
- **GPS-tagged signal trail** - Every detection is tagged with GPS coordinates for trail mapping
|
||||
- **Proximity bands** - IMMEDIATE (<1m), NEAR (1-5m), FAR (>5m) with color-coded HUD
|
||||
- **RSSI history chart** - Real-time signal strength sparkline for trend analysis
|
||||
- **Distance estimation** - Log-distance path loss model with environment presets
|
||||
- **Audio proximity alerts** - Web Audio API tones that increase in pitch as signal strengthens
|
||||
- **Hand-off from Bluetooth mode** - One-click transfer of a device from BT scanner to BT Locate
|
||||
|
||||
### Environment Presets
|
||||
- **Open Field** (n=2.0) - Free space path loss
|
||||
- **Outdoor** (n=2.2) - Typical outdoor environment
|
||||
- **Indoor** (n=3.0) - Indoor with walls and obstacles
|
||||
|
||||
### Map & Trail
|
||||
- Interactive Leaflet map with GPS trail visualization
|
||||
- Trail points color-coded by proximity band
|
||||
- Polyline connecting detection points for path visualization
|
||||
- Supports user-configured tile providers
|
||||
|
||||
### Requirements
|
||||
- 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.
|
||||
|
||||
### Features
|
||||
- **Live position tracking** - Real-time latitude, longitude, altitude display
|
||||
- **Interactive map** - Current position on Leaflet map with track history
|
||||
- **Speed and heading** - Real-time speed (km/h) and compass heading
|
||||
- **Satellite info** - Number of satellites in view and fix quality
|
||||
- **Track recording** - Record GPS tracks with export capability
|
||||
- **Accuracy display** - Horizontal and vertical position accuracy (EPX/EPY)
|
||||
|
||||
### Requirements
|
||||
- USB GPS receiver connected via gpsd
|
||||
- gpsd daemon running (`sudo gpsd /dev/ttyUSB0 -F /var/run/gpsd.sock`)
|
||||
|
||||
## TSCM Counter-Surveillance Mode
|
||||
|
||||
Technical Surveillance Countermeasures (TSCM) screening for detecting wireless surveillance indicators.
|
||||
|
||||
### 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** (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
|
||||
|
||||
### MAC-Randomization Resistant Detection
|
||||
- **Device fingerprinting** based on advertisement payloads, not MAC addresses
|
||||
- **Behavioral clustering** - groups observations into probable physical devices
|
||||
- **Session tracking** - monitors device presence windows
|
||||
- **Timing pattern analysis** - detects characteristic advertising intervals
|
||||
- **RSSI trajectory correlation** - identifies co-located devices
|
||||
|
||||
### Risk Assessment
|
||||
- **Three-tier scoring model**:
|
||||
- Informational (0-2): Known or expected devices
|
||||
- Needs Review (3-5): Unusual devices requiring assessment
|
||||
- High Interest (6+): Multiple indicators warrant investigation
|
||||
- **Risk indicators**: Stable RSSI, audio-capable, ESP32 chipsets, hidden identity, MAC rotation
|
||||
- **Audit trail** - full evidence chain for each link/flag
|
||||
- **Client-safe disclaimers** - findings are indicators, not confirmed surveillance
|
||||
|
||||
### Limitations (Documented)
|
||||
- Cannot detect non-transmitting devices
|
||||
- False positives/negatives expected
|
||||
- Results require professional verification
|
||||
- No cryptographic de-randomization
|
||||
- Passive screening only (no active probing by default)
|
||||
|
||||
## Meshtastic Mesh Networks
|
||||
|
||||
Integration with Meshtastic LoRa mesh networking devices for decentralized communication.
|
||||
|
||||
### Device Support
|
||||
- **Heltec** - LoRa32 series
|
||||
- **T-Beam** - TTGO T-Beam with GPS
|
||||
- **RAK** - WisBlock series
|
||||
- Any Meshtastic-compatible device via USB/Serial
|
||||
|
||||
### Features
|
||||
- **Real-time messaging** - Stream messages as they arrive
|
||||
- **Channel configuration** - Set encryption keys and channel names
|
||||
- **Node information** - View connected nodes with signal metrics
|
||||
- **Message history** - Up to 500 messages retained
|
||||
- **Signal quality** - RSSI and SNR for each message
|
||||
- **Hop tracking** - See message hop count
|
||||
|
||||
### Requirements
|
||||
- Physical Meshtastic device connected via USB
|
||||
- Meshtastic Python SDK (`pip install meshtastic`)
|
||||
|
||||
## Ubertooth One BLE Scanning
|
||||
|
||||
Advanced Bluetooth Low Energy scanning using Ubertooth One hardware.
|
||||
|
||||
### Capabilities
|
||||
- **40-channel scanning** - Capture BLE advertisements across all channels
|
||||
- **Raw payload access** - Full advertising data for analysis
|
||||
- **Passive sniffing** - No active scanning required
|
||||
- **MAC address extraction** - Public and random address types
|
||||
- **RSSI measurement** - Signal strength for proximity estimation
|
||||
|
||||
### Integration
|
||||
- Works alongside standard BlueZ/DBus Bluetooth scanning
|
||||
- Automatically detected when ubertooth-btle is available
|
||||
- Falls back to standard adapter if Ubertooth not present
|
||||
|
||||
### Requirements
|
||||
- Ubertooth One hardware
|
||||
- ubertooth-btle command-line tool installed
|
||||
- libubertooth library
|
||||
|
||||
## Remote Agents (Distributed SIGINT)
|
||||
|
||||
Deploy lightweight sensor nodes across multiple locations and aggregate data to a central controller.
|
||||
|
||||
### Architecture
|
||||
- **Hub-and-spoke model** - Central controller with multiple remote agents
|
||||
- **Push and Pull modes** - Agents can push data automatically or respond to on-demand requests
|
||||
- **API key authentication** - Secure communication between agents and controller
|
||||
|
||||
### Agent Features
|
||||
- **Standalone deployment** - Run on Raspberry Pi, mini PCs, or any Linux device with SDR
|
||||
- **All modes supported** - Pager, sensor, ADS-B, AIS, WiFi, Bluetooth, and more
|
||||
- **GPS integration** - Automatic location tagging from USB GPS receivers
|
||||
- **Multi-SDR support** - Run multiple modes simultaneously on agents with multiple SDRs
|
||||
- **Capability discovery** - Controller auto-detects available modes and devices
|
||||
|
||||
### Controller Features
|
||||
- **Agent management UI** - Register, test, and remove agents from `/controller/manage`
|
||||
- **Real-time status** - Health monitoring with online/offline indicators
|
||||
- **Unified data stream** - Aggregate data from all agents via SSE
|
||||
- **Dashboard integration** - Agent selector in ADS-B, AIS, and main dashboards
|
||||
- **Device conflict detection** - Smart warnings when SDR is in use
|
||||
|
||||
### Use Cases
|
||||
- **Wide-area monitoring** - Cover larger geographic areas with distributed sensors
|
||||
- **Remote installations** - Deploy sensors in locations without direct access
|
||||
- **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
|
||||
- **Active mode indicator** - shows current mode with pulse animation
|
||||
- **Collapsible sections** - click any header to collapse/expand
|
||||
- **Panel styling** - gradient backgrounds with indicator dots
|
||||
- **Tabbed mode selector** with icons (grouped by SDR/RF and Wireless)
|
||||
- **Consistent design** - unified styling across main dashboard and popouts
|
||||
- **Dark/Light theme toggle** - click moon/sun icon in header, preference saved
|
||||
- **Browser notifications** - desktop alerts for critical events (drones, rogue APs, handshakes)
|
||||
- **Built-in help page** - accessible via ? button or F1 key
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| F1 | Open help |
|
||||
| ? | Open help (when not typing) |
|
||||
| Escape | Close help/modals |
|
||||
|
||||
## Offline Mode
|
||||
|
||||
Run iNTERCEPT without internet connectivity by using bundled local assets.
|
||||
|
||||
### Bundled Assets
|
||||
- **Leaflet 1.9.4** - Map library with marker images
|
||||
- **Chart.js 4.4.1** - Signal strength graphs
|
||||
- **Inter font** - Primary UI font (400, 500, 600, 700 weights)
|
||||
- **JetBrains Mono font** - Monospace/code font (400, 500, 600, 700 weights)
|
||||
|
||||
### Settings Modal
|
||||
Access via the gear icon in the navigation bar:
|
||||
- **Offline Tab** - Toggle offline mode, configure asset sources (CDN vs local)
|
||||
- **Display Tab** - Theme and animation preferences
|
||||
- **About Tab** - Version info and links
|
||||
|
||||
### Map Tile Providers
|
||||
Choose from multiple tile sources for maps:
|
||||
- **OpenStreetMap** - Default, general purpose
|
||||
- **CartoDB Dark** - Dark themed, matches UI
|
||||
- **CartoDB Positron** - Light themed
|
||||
- **ESRI World Imagery** - Satellite imagery
|
||||
- **Custom URL** - Connect to your own tile server (e.g., local OpenStreetMap tile cache)
|
||||
|
||||
### Local Asset Status
|
||||
The settings modal shows availability status for each bundled asset:
|
||||
- Green "Available" badge when asset is present
|
||||
- Red "Missing" badge when asset is not found
|
||||
- Click "Check Assets" to refresh status
|
||||
|
||||
### Use Cases
|
||||
- **Air-gapped environments** - Run on isolated networks
|
||||
- **Field deployments** - Operate without reliable internet
|
||||
- **Local tile servers** - Use pre-cached map tiles for specific regions
|
||||
- **Reduced latency** - Faster loading with local assets
|
||||
|
||||
## 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
|
||||
- **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
|
||||
- **Disclaimer acceptance** on first use
|
||||
- **Auto-stop** when switching between modes
|
||||
|
||||
# INTERCEPT Features
|
||||
|
||||
Quick reference for all supported modes. Click any mode for full usage instructions.
|
||||
|
||||
| Mode | Description | Tools Required |
|
||||
|------|-------------|----------------|
|
||||
| [Pager Decoding](USAGE.md#pager-mode) | POCSAG 512/1200/2400 and FLEX decoding | RTL-SDR, multimon-ng |
|
||||
| [433MHz Sensors](USAGE.md#433mhz-sensor-mode) | 200+ device protocols — weather, TPMS, IoT | RTL-SDR, rtl_433 |
|
||||
| [Sub-GHz Analyzer](USAGE.md#sub-ghz-analyzer) | 300–928 MHz ISM capture, decode, replay | HackRF |
|
||||
| [Aircraft Tracking (ADS-B)](USAGE.md#aircraft-mode-ads-b) | Real-time radar map, virtual radar scope, filtering | RTL-SDR, dump1090 |
|
||||
| [ADS-B History](USAGE.md#ads-b-history-optional) | Persistent aircraft history and reporting dashboard | PostgreSQL (optional) |
|
||||
| [Vessel Tracking (AIS)](USAGE.md#ais-vessel-tracking) | Maritime map, DSC Channel 70 distress monitoring | RTL-SDR, AIS-catcher |
|
||||
| [ACARS Messaging](USAGE.md#acars-messaging) | Aircraft datalink messages | RTL-SDR, acarsdec |
|
||||
| [VDL2](USAGE.md#vdl2-aircraft-datalink) | VHF Data Link Mode 2 aircraft datalink | RTL-SDR, dumpvdl2 |
|
||||
| [Listening Post](USAGE.md#listening-post) | Wideband scanner with real-time audio streaming | RTL-SDR/HackRF, Icecast |
|
||||
| [Weather Satellites](USAGE.md#weather-satellites) | NOAA APT and Meteor LRPT image decoding | RTL-SDR, SatDump |
|
||||
| [WebSDR](USAGE.md#websdr) | Remote HF/shortwave listening via KiwiSDR network | None (web-based) |
|
||||
| [ISS SSTV](USAGE.md#iss-sstv) | Slow-scan TV image reception from the ISS | RTL-SDR, slowrx |
|
||||
| [HF SSTV](USAGE.md#hf-sstv) | Terrestrial SSTV on shortwave and VHF | RTL-SDR, slowrx |
|
||||
| [APRS](USAGE.md#aprs) | Amateur packet radio position reports and telemetry | RTL-SDR/TNC, direwolf |
|
||||
| [Satellite Tracking](USAGE.md#satellite-mode) | Pass prediction, polar plot, ground track map | RTL-SDR (optional) |
|
||||
| [Utility Meters](USAGE.md#utility-meters) | Electric, gas, and water meter reading | RTL-SDR, rtlamr |
|
||||
| [WiFi Scanning](USAGE.md#wifi-mode) | Monitor mode reconnaissance, network discovery | Monitor-mode WiFi adapter |
|
||||
| [Bluetooth Scanning](USAGE.md#bluetooth-mode) | Device discovery, tracker detection (AirTag, Tile…) | Bluetooth adapter |
|
||||
| [BT Locate](USAGE.md#bt-locate-sar-device-location) | GPS-tagged signal trail and proximity alerts | Bluetooth + GPS |
|
||||
| [WiFi Locate](USAGE.md#wifi-locate-mode) | Locate APs by BSSID with signal meter and distance | WiFi adapter |
|
||||
| [GPS](USAGE.md#gps-mode) | Real-time position, speed, altitude, satellite map | GPS receiver, gpsd |
|
||||
| [TSCM](USAGE.md#tscm-counter-surveillance) | RF baseline comparison and threat detection | RTL-SDR + BT + WiFi |
|
||||
| [Drone Intelligence](USAGE.md#drone-intelligence) | UAV detection via Remote ID, RF, and HackRF | RTL-SDR / HackRF |
|
||||
| [Spy Stations](USAGE.md#spy-stations) | Number stations and diplomatic HF network database | None (database lookup) |
|
||||
| [Meshtastic](USAGE.md#meshtastic) | LoRa mesh network integration | Meshtastic device |
|
||||
| [Space Weather](USAGE.md#space-weather) | Solar and geomagnetic data from NOAA/NASA | None (web-based) |
|
||||
| [Remote Agents](USAGE.md#remote-agents-distributed-sigint) | Distributed SIGINT with remote sensor nodes | Multiple SDR nodes |
|
||||
| [Offline Mode](USAGE.md#offline-mode) | Bundled assets for air-gapped/field deployments | Any |
|
||||
|
||||
## Detailed Docs
|
||||
|
||||
- [Usage Guide](USAGE.md) — per-mode setup and operation
|
||||
- [Hardware Guide](HARDWARE.md) — SDR hardware, drivers, multiple dongles, Icecast
|
||||
- [Webhooks](WEBHOOKS.md) — alert rules and webhook integration
|
||||
- [Distributed Agents](DISTRIBUTED_AGENTS.md) — remote sensor node deployment
|
||||
- [Troubleshooting](TROUBLESHOOTING.md) — common issues and solutions
|
||||
- [Security](SECURITY.md) — network security and best practices
|
||||
|
||||
+55
-82
@@ -12,41 +12,11 @@ INTERCEPT automatically detects connected devices.
|
||||
|
||||
---
|
||||
|
||||
## Quick Install
|
||||
## Manual Installation
|
||||
|
||||
### Recommended: Use the Setup Script
|
||||
For most users `./setup.sh` handles everything. The steps below are for manual installs or when you need fine-grained control.
|
||||
|
||||
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)
|
||||
### macOS (Homebrew)
|
||||
|
||||
```bash
|
||||
# Install Homebrew if needed
|
||||
@@ -68,7 +38,7 @@ brew install soapysdr limesuite soapylms7
|
||||
brew install hackrf soapyhackrf
|
||||
```
|
||||
|
||||
### Manual Install: Debian / Ubuntu / Raspberry Pi OS
|
||||
### Debian / Ubuntu / Raspberry Pi OS
|
||||
|
||||
```bash
|
||||
# Update package lists
|
||||
@@ -264,54 +234,6 @@ SoapySDRUtil --find
|
||||
|
||||
---
|
||||
|
||||
## Python Environment
|
||||
|
||||
### Using setup.sh (Recommended)
|
||||
```bash
|
||||
./setup.sh
|
||||
```
|
||||
|
||||
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
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Running INTERCEPT
|
||||
|
||||
After installation:
|
||||
|
||||
```bash
|
||||
sudo ./start.sh
|
||||
|
||||
# Custom port
|
||||
sudo ./start.sh -p 8080
|
||||
|
||||
# HTTPS
|
||||
sudo ./start.sh --https
|
||||
```
|
||||
|
||||
Open **http://localhost:5050** in your browser.
|
||||
|
||||
---
|
||||
|
||||
## Complete Tool Reference
|
||||
|
||||
| Tool | Package (Debian) | Package (macOS) | Required For |
|
||||
@@ -410,6 +332,57 @@ brew install librtlsdr
|
||||
|
||||
---
|
||||
|
||||
## Listening Post — Icecast Setup
|
||||
|
||||
The Listening Post streams audio via Icecast (2-10 second latency). INTERCEPT starts Icecast automatically when you begin listening, but you must install and configure it first.
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo apt install icecast2
|
||||
|
||||
# macOS
|
||||
brew install icecast
|
||||
```
|
||||
|
||||
### Configure
|
||||
|
||||
On Debian/Ubuntu you'll be prompted during install. Otherwise edit `/etc/icecast2/icecast.xml`:
|
||||
|
||||
```xml
|
||||
<icecast>
|
||||
<authentication>
|
||||
<source-password>hackme</source-password>
|
||||
<admin-password>your-admin-password</admin-password>
|
||||
</authentication>
|
||||
<hostname>localhost</hostname>
|
||||
<listen-socket>
|
||||
<port>8000</port>
|
||||
</listen-socket>
|
||||
</icecast>
|
||||
```
|
||||
|
||||
### Start
|
||||
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo systemctl enable icecast2 && sudo systemctl start icecast2
|
||||
|
||||
# macOS
|
||||
brew services start icecast
|
||||
```
|
||||
|
||||
Verify it's running at http://localhost:8000.
|
||||
|
||||
### INTERCEPT defaults
|
||||
|
||||
INTERCEPT expects Icecast on `127.0.0.1:8000` with source password `hackme` and mount `/listen.mp3`. To change these, update the defaults in `routes/listening_post.py` or adjust via the Listening Post config panel in the UI.
|
||||
|
||||
For audio troubleshooting, see [TROUBLESHOOTING.md](TROUBLESHOOTING.md#audio-streaming-issues).
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- **Bluetooth on macOS**: Uses bleak library (CoreBluetooth backend), bluez tools not needed
|
||||
|
||||
+5
-3
@@ -23,13 +23,15 @@ By default, INTERCEPT binds to `0.0.0.0:5050`, making it accessible from any net
|
||||
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.
|
||||
3. **Trusted Networks Only**: Only run INTERCEPT on networks you trust. Default credentials are admin / admin — change them before network exposure.
|
||||
|
||||
## Authentication
|
||||
|
||||
INTERCEPT does **not** include authentication. This is by design for ease of use as a personal tool. If you need to expose INTERCEPT to untrusted networks:
|
||||
INTERCEPT includes basic username/password authentication (default credentials: **admin / admin**). **Change these before exposing the application on any network** — update `ADMIN_USERNAME` and `ADMIN_PASSWORD` in `config.py`.
|
||||
|
||||
1. Use a reverse proxy (nginx, Caddy) with authentication
|
||||
For additional protection when exposing INTERCEPT beyond your local machine:
|
||||
|
||||
1. Use a reverse proxy (nginx, Caddy) with authentication or TLS
|
||||
2. Use a VPN to access your home network
|
||||
3. Use SSH port forwarding: `ssh -L 5050:localhost:5050 your-server`
|
||||
|
||||
|
||||
+4
-81
@@ -110,17 +110,7 @@ pip install --user -r requirements.txt
|
||||
|
||||
### Linux udev rules for RTL-SDR
|
||||
|
||||
```bash
|
||||
sudo bash -c 'cat > /etc/udev/rules.d/20-rtlsdr.rules << EOF
|
||||
SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2838", MODE="0666"
|
||||
SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2832", MODE="0666"
|
||||
EOF'
|
||||
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger
|
||||
```
|
||||
|
||||
Then unplug and replug your RTL-SDR.
|
||||
See [HARDWARE.md — RTL-SDR Setup](HARDWARE.md#rtl-sdr-setup-linux) for udev rules and driver blacklisting.
|
||||
|
||||
### Device busy error
|
||||
|
||||
@@ -189,78 +179,11 @@ which rx_fm
|
||||
|
||||
If `rx_fm` is installed, select your device from the SDR dropdown in the Listening Post - HackRF, Airspy, LimeSDR, and SDRPlay are all supported.
|
||||
|
||||
### Setting up Icecast for Listening Post Audio
|
||||
### Listening Post — No Audio / Icecast Errors
|
||||
|
||||
The Listening Post uses Icecast for low-latency audio streaming (2-10 second latency). Intercept will automatically start Icecast when you begin listening, but you must install and configure it first.
|
||||
For Icecast install and configuration, see [HARDWARE.md — Listening Post Setup](HARDWARE.md#listening-post--icecast-setup).
|
||||
|
||||
**Install Icecast:**
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo apt install icecast2
|
||||
|
||||
# macOS
|
||||
brew install icecast
|
||||
```
|
||||
|
||||
**Configure Icecast:**
|
||||
|
||||
During installation on Debian/Ubuntu, you'll be prompted to configure. Otherwise, edit `/etc/icecast2/icecast.xml`:
|
||||
|
||||
```xml
|
||||
<icecast>
|
||||
<authentication>
|
||||
<!-- Source password - used by ffmpeg to send audio -->
|
||||
<source-password>hackme</source-password>
|
||||
<!-- Admin password for web interface -->
|
||||
<admin-password>your-admin-password</admin-password>
|
||||
</authentication>
|
||||
<hostname>localhost</hostname>
|
||||
<listen-socket>
|
||||
<port>8000</port>
|
||||
</listen-socket>
|
||||
</icecast>
|
||||
```
|
||||
|
||||
**Start Icecast:**
|
||||
```bash
|
||||
# Ubuntu/Debian (as service)
|
||||
sudo systemctl enable icecast2
|
||||
sudo systemctl start icecast2
|
||||
|
||||
# Or run directly
|
||||
icecast -c /etc/icecast2/icecast.xml
|
||||
|
||||
# macOS
|
||||
brew services start icecast
|
||||
# Or: icecast -c /usr/local/etc/icecast.xml
|
||||
```
|
||||
|
||||
**Verify Icecast is running:**
|
||||
- Open http://localhost:8000 in your browser
|
||||
- You should see the Icecast status page
|
||||
|
||||
**Configure Intercept (optional):**
|
||||
|
||||
The default configuration expects Icecast on `127.0.0.1:8000` with source password `hackme` and mount point `/listen.mp3`. To change these, modify the scanner config in your API calls or update the defaults in `routes/listening_post.py`:
|
||||
|
||||
```python
|
||||
scanner_config = {
|
||||
# ... other settings ...
|
||||
'icecast_host': '127.0.0.1',
|
||||
'icecast_port': 8000,
|
||||
'icecast_mount': '/listen.mp3',
|
||||
'icecast_source_password': 'hackme',
|
||||
}
|
||||
```
|
||||
|
||||
**Troubleshooting Icecast:**
|
||||
|
||||
- **"Connection refused" errors**: Ensure Icecast is running on the configured port
|
||||
- **"Authentication failed"**: Check the source password matches between Icecast config and Intercept
|
||||
- **No audio playing**: Check Icecast status page (http://localhost:8000) to verify the mount point is active
|
||||
- **High latency**: Ensure nginx/reverse proxy isn't buffering - add `proxy_buffering off;` to nginx config
|
||||
|
||||
### Audio Streaming Issues - Detailed Debugging
|
||||
### Audio Streaming Issues
|
||||
|
||||
If the Listening Post shows "Icecast mount not active" errors or audio doesn't play:
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# iNTERCEPT UI Guide
|
||||
|
||||
> **This is a contributor/developer reference.** It documents the design system, CSS tokens, and patterns for adding new modes or dashboards. If you're looking for usage instructions, see [USAGE.md](USAGE.md).
|
||||
|
||||
This guide documents the UI design system, components, and patterns used in iNTERCEPT.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
+32
-40
@@ -446,6 +446,35 @@ Digital Selective Calling monitoring runs alongside AIS:
|
||||
- Full functionality requires WiFi adapter, Bluetooth adapter, and SDR hardware
|
||||
- Threat detection uses a database of 47K+ known tracker fingerprints
|
||||
|
||||
## Drone Intelligence
|
||||
|
||||
1. **Open Mode** - Select "Drone Intel" from the Intel group in the navigation bar
|
||||
2. **Configure Interfaces** - Enter your WiFi interface name (must support monitor mode) for Remote ID detection
|
||||
3. **Set RTL-SDR Index** - If you have multiple RTL-SDR devices, enter the device index (default: 0)
|
||||
4. **Start** - Click "Start Scan" to activate all available detection vectors simultaneously
|
||||
5. **Monitor Contacts** - Detected drone contacts appear in the contact list with ID, vectors, risk level, and last seen time
|
||||
6. **View Map** - Contacts with GPS data from Remote ID are plotted on the live map
|
||||
|
||||
### Detection Vectors
|
||||
|
||||
- **Remote ID (WiFi/BLE)** — Passive sniff of 802.11 beacon frames and BLE advertisements. Decodes ASTM F3411 payloads: drone GPS, operator ID, drone type, speed, altitude, and emergency status
|
||||
- **433/868 MHz RF** — RTL-SDR scans ISM bands for drone control link and telemetry RF signatures
|
||||
- **2.4/5.8 GHz** — HackRF (if present) sweeps video downlink bands for active drone transmissions
|
||||
|
||||
### Risk Levels
|
||||
|
||||
- **High** — Drone operating without Remote ID (non-compliant) or malformed ASTM frame. Warrants immediate attention.
|
||||
- **Medium** — Contact detected on multiple RF vectors, or significant RSSI difference between vectors (>15 dB). May indicate evasion or multi-radio platform.
|
||||
- **Low** — Compliant Remote ID broadcast, single detection vector. Standard consumer drone.
|
||||
|
||||
### Tips
|
||||
|
||||
- Remote ID is mandatory for drones >250g in the US (FAA) and EU (EU 2019/945) — absence of Remote ID is itself a significant indicator
|
||||
- WiFi adapter must support monitor mode; run `airmon-ng check kill` if other processes interfere
|
||||
- The contact map only shows drones that broadcast GPS coordinates via Remote ID
|
||||
- Contacts expire after 120 seconds of inactivity — the list shows only currently active drones
|
||||
- HackRF detection is passive (receive-only); no transmission occurs
|
||||
|
||||
## Spy Stations
|
||||
|
||||
1. **Browse Database** - View the full list of documented number stations and diplomatic networks
|
||||
@@ -539,45 +568,8 @@ Enable "Show All Agents" to aggregate data from all registered agents simultaneo
|
||||
|
||||
For complete documentation, see [Distributed Agents Guide](DISTRIBUTED_AGENTS.md).
|
||||
|
||||
## Configuration
|
||||
## Webhooks & Notifications
|
||||
|
||||
INTERCEPT can be configured via environment variables:
|
||||
INTERCEPT has a built-in alert engine that fires webhooks on decoded events. Configure rules in the Alerts panel to forward pager messages, ADS-B alerts, WiFi events, or anything else to Discord, Slack, n8n, Home Assistant, or any HTTP endpoint.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `INTERCEPT_HOST` | `0.0.0.0` | Server bind address |
|
||||
| `INTERCEPT_PORT` | `5050` | Server port |
|
||||
| `INTERCEPT_DEBUG` | `false` | Enable debug mode |
|
||||
| `INTERCEPT_LOG_LEVEL` | `WARNING` | Log level (DEBUG, INFO, WARNING, ERROR) |
|
||||
| `INTERCEPT_DEFAULT_GAIN` | `40` | Default RTL-SDR gain |
|
||||
|
||||
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
|
||||
|
||||
-p, --port PORT Port to run server on (default: 5050)
|
||||
-H, --host HOST Host to bind to (default: 0.0.0.0)
|
||||
-d, --debug Enable debug mode
|
||||
--check-deps Check dependencies and exit
|
||||
```
|
||||
See [WEBHOOKS.md](WEBHOOKS.md) for configuration, payload format, and Discord relay setup.
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# Webhooks & Alert Notifications
|
||||
|
||||
INTERCEPT has a built-in alert engine that fires webhooks when decoded events match configurable rules. Forward pager messages (or events from any other mode) to Discord, Slack, n8n, Home Assistant, or any HTTP endpoint.
|
||||
|
||||
## How it works
|
||||
|
||||
1. Configure **alert rules** in the Alerts UI — each rule defines which mode and event type to watch, optional match criteria, and a severity level.
|
||||
2. When an incoming event matches a rule, INTERCEPT stores it in the alert log and POSTs a JSON payload to your configured webhook URL.
|
||||
3. All modes are supported: pager, sensor, ADS-B, AIS, ACARS, WiFi, Bluetooth, and more.
|
||||
|
||||
## Configuration
|
||||
|
||||
Set these environment variables in your `.env` file or `docker-compose.yml`:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `ALERT_WEBHOOK_URL` | _(empty)_ | URL to POST alert payloads to |
|
||||
| `ALERT_WEBHOOK_SECRET` | _(empty)_ | Optional token sent as `X-Alert-Token` header |
|
||||
| `ALERT_WEBHOOK_TIMEOUT` | `5` | HTTP timeout in seconds |
|
||||
|
||||
```env
|
||||
ALERT_WEBHOOK_URL=https://your-endpoint.example.com/intercept-alerts
|
||||
ALERT_WEBHOOK_SECRET=mysecrettoken
|
||||
```
|
||||
|
||||
## Creating alert rules
|
||||
|
||||
1. Open the **Alerts** panel in INTERCEPT
|
||||
2. Click **New Rule**
|
||||
3. Configure:
|
||||
- **Mode**: `pager` (or any other mode, or leave blank to match all)
|
||||
- **Event type**: `message` for pager decodes (or blank to match all event types)
|
||||
- **Match criteria**: leave empty to forward everything, or add filters (e.g. capcode equals `1234567`, or message contains `FIRE`)
|
||||
- **Severity**: `low`, `medium`, or `high`
|
||||
4. Save and enable the rule
|
||||
|
||||
## Webhook payload format
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 42,
|
||||
"rule_id": 1,
|
||||
"mode": "pager",
|
||||
"event_type": "message",
|
||||
"severity": "medium",
|
||||
"title": "My Pager Rule",
|
||||
"message": "message | 1234567",
|
||||
"created_at": "2026-04-13T10:00:00+00:00",
|
||||
"payload": {
|
||||
"mode": "pager",
|
||||
"event_type": "message",
|
||||
"event": {
|
||||
"capcode": "1234567",
|
||||
"message": "UNIT 4 RESPOND TO 123 MAIN ST",
|
||||
"type": "POCSAG1200"
|
||||
},
|
||||
"rule": { "id": 1, "name": "My Pager Rule" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Sending to Discord
|
||||
|
||||
Discord webhooks expect a different JSON format, so you need a relay between INTERCEPT and Discord.
|
||||
|
||||
**Option A — No-code relay (recommended)**
|
||||
|
||||
Use [n8n](https://n8n.io), [Make](https://make.com), or [Pipedream](https://pipedream.com) to receive INTERCEPT's webhook and forward it to Discord with a custom message template. Point `ALERT_WEBHOOK_URL` at your workflow's ingest URL.
|
||||
|
||||
**Option B — Self-hosted Python relay**
|
||||
|
||||
Save as `discord_relay.py` and run it alongside INTERCEPT:
|
||||
|
||||
```python
|
||||
from flask import Flask, request
|
||||
import urllib.request, json
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
DISCORD_WEBHOOK_URL = "https://discord.com/api/webhooks/YOUR_ID/YOUR_TOKEN"
|
||||
|
||||
@app.post("/relay")
|
||||
def relay():
|
||||
data = request.get_json(force=True)
|
||||
mode = data.get("mode", "unknown").upper()
|
||||
title = data.get("title", "Alert")
|
||||
message = data.get("message", "")
|
||||
event = data.get("payload", {}).get("event", {})
|
||||
|
||||
lines = [f"**[{mode}]** {title}", message]
|
||||
if event.get("capcode"):
|
||||
lines.append(f"Capcode: `{event['capcode']}`")
|
||||
if event.get("type"):
|
||||
lines.append(f"Protocol: {event['type']}")
|
||||
|
||||
payload = json.dumps({"content": "\n".join(lines)}).encode()
|
||||
req = urllib.request.Request(
|
||||
DISCORD_WEBHOOK_URL,
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
urllib.request.urlopen(req, timeout=5)
|
||||
return "", 204
|
||||
|
||||
app.run(host="0.0.0.0", port=5051)
|
||||
```
|
||||
|
||||
Set `ALERT_WEBHOOK_URL=http://localhost:5051/relay` and run: `python3 discord_relay.py`
|
||||
|
||||
## Filtering specific capcodes
|
||||
|
||||
To only forward decodes from a specific capcode, set the rule's **Match criteria**:
|
||||
|
||||
| Field | Operator | Value |
|
||||
|-------|----------|-------|
|
||||
| `capcode` | equals | `1234567` |
|
||||
|
||||
Multiple rules can coexist — e.g. one rule for all pager traffic and a second rule for emergency capcodes with `high` severity routed to a separate channel.
|
||||
@@ -0,0 +1,133 @@
|
||||
# Pager & 433 Sensor Display Revamp
|
||||
|
||||
**Date:** 2026-05-21
|
||||
**Status:** Approved
|
||||
|
||||
## Overview
|
||||
|
||||
Replace the plain chronological card feed for the Pager and 433 Sensor modes with purpose-built views that better surface the structure of each signal type. Both new views are opt-out (toggle to classic feed available).
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
The two modes use slightly different DOM strategies suited to each layout.
|
||||
|
||||
**Pager:** `#pagerDirectoryView` is the left directory panel only. The output panel parent switches to `display: flex` in directory mode, placing the directory panel and `#output` side by side. `#output` becomes the right feed panel — no duplication, no hidden copy.
|
||||
|
||||
**Sensor:** `#sensorDashboardView` is a full-replacement grid that sits alongside `#output`. In dashboard mode `#output` is hidden but continues to receive classic `signal-card` insertions so export and filtering remain intact.
|
||||
|
||||
```
|
||||
[output-panel] (flex in pager directory mode)
|
||||
[#pagerDirectoryView] ← left dir panel only; shown in pager directory mode
|
||||
[#sensorDashboardView] ← full replacement grid; shown in sensor dashboard mode
|
||||
[#output] ← right feed panel (pager) or hidden (sensor); always updated
|
||||
```
|
||||
|
||||
`addMessage()` gets a hook to `PagerDirectory.addMessage()` for directory panel updates only (the feed is `#output` itself). `addSensorReading()` gets a hook to `SensorDashboard.addReading()` for station card updates. No other existing logic changes.
|
||||
|
||||
### New files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `static/js/components/pager-directory.js` | PagerDirectory component |
|
||||
| `static/js/components/sensor-dashboard.js` | SensorDashboard component |
|
||||
| `static/css/components/pager-directory.css` | Directory view styles |
|
||||
| `static/css/components/sensor-dashboard.css` | Dashboard view styles |
|
||||
|
||||
`templates/index.html` gets:
|
||||
- Two new sibling containers (`#pagerDirectoryView`, `#sensorDashboardView`)
|
||||
- Toggle buttons in the output panel header (one per mode, shown when that mode is active)
|
||||
- Script/link tags for the four new files
|
||||
- One-line hook calls inside `addMessage()` and `addSensorReading()`
|
||||
|
||||
---
|
||||
|
||||
## Pager — Source Directory View
|
||||
|
||||
### Layout
|
||||
|
||||
Split panel, full height of the output area:
|
||||
|
||||
- **Left (200 px fixed):** address directory panel
|
||||
- **Right (flex):** full message feed
|
||||
|
||||
### Directory panel (left)
|
||||
|
||||
- One row per unique pager address seen this session
|
||||
- Sorted by message count descending (most active at top)
|
||||
- Each row shows:
|
||||
- Protocol badge (`P` = POCSAG, `F` = FLEX), coloured accordingly
|
||||
- Address string
|
||||
- Message count (`×24`)
|
||||
- Relative-width activity bar (count relative to the highest-count address)
|
||||
- Last-seen relative timestamp (`just now`, `2m ago`)
|
||||
- Green dot when a new message arrives from that address (fades after 3 s)
|
||||
- Blue left-border accent on the currently highlighted address
|
||||
- Directory state is in-memory for the session only (not persisted)
|
||||
|
||||
### Feed panel (right)
|
||||
|
||||
- Shows **all messages** at all times (no filtering)
|
||||
- When an address is highlighted via the directory:
|
||||
- Feed scrolls to that address's most recent card
|
||||
- All cards from that address get a blue left-border + subtle background tint
|
||||
- Sub-header shows `"<address> highlighted"` with a "clear highlight" link
|
||||
- Clicking "clear highlight" (or clicking the same address again) removes all highlighting and returns to the plain feed
|
||||
- Cards are otherwise identical to the existing `signal-card` format
|
||||
|
||||
### Toggle
|
||||
|
||||
- Button group top-right of the output panel header: **Directory** | **Feed**
|
||||
- Default: **Directory**
|
||||
- Preference saved to `localStorage` key `pagerView` (`'directory'` | `'feed'`)
|
||||
- Restored on mode switch
|
||||
|
||||
---
|
||||
|
||||
## 433 Sensor — Station Dashboard View
|
||||
|
||||
### Layout
|
||||
|
||||
Responsive CSS grid of station cards (3 columns on typical desktop width, wrapping as needed).
|
||||
|
||||
### Station card
|
||||
|
||||
One persistent card per unique device, keyed by `model + id`. Cards are created on first reading and updated in place on subsequent readings from the same device.
|
||||
|
||||
Each card contains:
|
||||
|
||||
- **Header:** device model name (e.g. `Acurite-Tower`), device ID + channel, last-seen relative timestamp (green when < 10 s)
|
||||
- **Readings:** the primary numeric values for that device (temperature, humidity, pressure, wind speed, rain, etc.) — label + value + unit, displayed as a small inline grid
|
||||
- **Sparkline:** SVG polyline tracking the primary numeric value across the last 30 readings. Colour matches the reading type (amber for temperature, blue for humidity/wind, purple for pressure). A filled circle marks the latest data point.
|
||||
- **Footer:** battery status (green `BAT OK` / red `BAT LOW`), SNR value, frequency badge
|
||||
|
||||
### State-only devices
|
||||
|
||||
Devices that emit only a state (doorbells, PIR sensors, etc.) get a card with a state indicator (coloured dot + label e.g. `MOTION DETECTED`) in place of numeric readings. The sparkline area is replaced with an "event-only device" label. Card still flashes on each event.
|
||||
|
||||
### Flash on update
|
||||
|
||||
When a new reading arrives for a known device:
|
||||
- Card receives a CSS animation class that briefly tints the background (blue for temp sensors, purple for other types) and fades back to normal over ~0.8 s
|
||||
- Values update in place; the sparkline dot advances right
|
||||
|
||||
### New device appearance
|
||||
|
||||
First time a device is seen: card slides in with a subtle green border accent. The border fades to normal after the first update.
|
||||
|
||||
### Toggle
|
||||
|
||||
- Button group top-right of output panel header: **Dashboard** | **Feed**
|
||||
- Default: **Dashboard**
|
||||
- Preference saved to `localStorage` key `sensorView` (`'dashboard'` | `'feed'`)
|
||||
- Restored on mode switch
|
||||
|
||||
---
|
||||
|
||||
## Shared behaviour
|
||||
|
||||
- Both toggles are shown only when the relevant mode is active
|
||||
- Classic `#output` feed always receives cards in the background (export, CSV/JSON, existing filter bar all continue to work)
|
||||
- No changes to SSE handling, process management, or backend routes
|
||||
- No new backend endpoints required
|
||||
+14
-4
@@ -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">30+</span>
|
||||
<span class="stat-value">35</span>
|
||||
<span class="stat-label">Modes</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
@@ -97,6 +97,11 @@
|
||||
<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="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg></div>
|
||||
<h3>Signal ID</h3>
|
||||
<p>Identify unknown signals offline against a bundled 594-signal database. Scored matching by frequency, bandwidth, and modulation with SigID Wiki references.</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>
|
||||
@@ -202,6 +207,11 @@
|
||||
<h3>TSCM</h3>
|
||||
<p>Counter-surveillance with baseline recording, threat detection, device correlation, and risk scoring.</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="6" cy="6" r="2"/><circle cx="18" cy="6" r="2"/><circle cx="6" cy="18" r="2"/><circle cx="18" cy="18" r="2"/><rect x="9" y="9" width="6" height="6" rx="1"/><line x1="8" y1="8" x2="9" y2="9"/><line x1="16" y1="8" x2="15" y2="9"/><line x1="8" y1="16" x2="9" y2="15"/><line x1="16" y1="16" x2="15" y2="15"/></svg></div>
|
||||
<h3>Drone Intelligence</h3>
|
||||
<p>Multi-vector UAV detection via ASTM F3411 Remote ID (WiFi/BLE), RTL-SDR 433/868 MHz RF fingerprinting, and HackRF 2.4/5.8 GHz scanning with live contact map and risk scoring.</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="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg></div>
|
||||
<h3>Meshtastic</h3>
|
||||
@@ -435,7 +445,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">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,238 @@
|
||||
# Meshcore Support — Design Spec
|
||||
|
||||
**Date:** 2026-05-10
|
||||
**Status:** Approved
|
||||
|
||||
## Overview
|
||||
|
||||
Add a Meshcore mode to Intercept, providing full feature parity with the existing Meshtastic module. Meshcore is a LoRa mesh radio platform using a repeater-based routing model (dedicated infrastructure nodes relay; clients do not). It has an official Python library (`meshcore`, PyPI) and a published companion protocol.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|---|---|---|
|
||||
| Connection methods | USB serial + TCP + BLE | Maximum hardware flexibility |
|
||||
| Feature scope | Full parity with Meshtastic | Messages, node map, telemetry, traceroute, repeater management |
|
||||
| Async integration | Background asyncio thread | meshcore library is asyncio-based; this isolates it cleanly from Flask/gevent |
|
||||
| UI layout | Messages-first (mirror Meshtastic) | Sidebar: contacts/nodes. Center: message feed. Tabs: map, telemetry, repeaters |
|
||||
| BLE in Docker | Document limitation + proxy workaround | BLE unavailable in containers; meshcore-proxy bridges BLE → TCP |
|
||||
|
||||
## Architecture
|
||||
|
||||
### New Files
|
||||
|
||||
```
|
||||
utils/meshcore.py # MeshcoreClient singleton + dataclasses
|
||||
utils/meshcore_client.py # Thin async wrapper around meshcore library (lives in asyncio thread)
|
||||
routes/meshcore.py # Flask blueprint (/meshcore)
|
||||
static/js/modes/meshcore.js # Frontend IIFE module
|
||||
static/css/modes/meshcore.css # Scoped styles
|
||||
templates/partials/modes/meshcore.html # Sidebar partial
|
||||
tests/test_meshcore_client.py
|
||||
tests/test_meshcore_routes.py
|
||||
tests/test_meshcore_integration.py
|
||||
```
|
||||
|
||||
### Modified Files
|
||||
|
||||
- `routes/__init__.py` — import + `register_blueprint(meshcore_bp)`
|
||||
- `templates/index.html` — ~12 insertion points (CSS, partial, JS, validModes, modeGroups, etc.)
|
||||
- `requirements.txt` — add `meshcore>=1.0.0` (optional dep, graceful fallback if absent)
|
||||
- `.gitignore` — already has `.superpowers/` ✓
|
||||
|
||||
### Async Bridge Pattern
|
||||
|
||||
```
|
||||
meshcore library (asyncio event loop in daemon OS thread)
|
||||
→ event callbacks (_on_message, _on_node_update, _on_telemetry)
|
||||
→ asyncio.run_coroutine_threadsafe() → queue.Queue (thread-safe, max 500)
|
||||
→ /meshcore/stream SSE generator drains queue (30s keepalive timeout)
|
||||
→ Frontend EventSource routes by event type
|
||||
```
|
||||
|
||||
This is the same conceptual pattern as all other decoder integrations in Intercept (ADS-B socket reader, AIS-catcher output thread, rtl_433 stdout thread), just with an explicit asyncio loop instead of a subprocess thread.
|
||||
|
||||
## Data Model
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class MeshcoreMessage:
|
||||
id: str
|
||||
sender_id: str
|
||||
recipient_id: str # node ID or broadcast address
|
||||
text: str
|
||||
timestamp: datetime
|
||||
hop_count: int
|
||||
snr: float | None
|
||||
is_direct: bool # DM vs broadcast
|
||||
pending: bool = False # optimistic send state
|
||||
|
||||
@dataclass
|
||||
class MeshcoreNode:
|
||||
node_id: str
|
||||
name: str
|
||||
is_repeater: bool # key Meshcore distinction — rendered differently on map
|
||||
lat: float | None
|
||||
lon: float | None
|
||||
battery_pct: int | None
|
||||
last_seen: datetime
|
||||
snr: float | None
|
||||
hops_away: int | None
|
||||
|
||||
@dataclass
|
||||
class MeshcoreContact:
|
||||
node_id: str
|
||||
name: str
|
||||
public_key: str # Meshcore uses key-based addressing
|
||||
last_msg: datetime | None
|
||||
|
||||
@dataclass
|
||||
class MeshcoreTelemetry:
|
||||
node_id: str
|
||||
timestamp: datetime
|
||||
battery_pct: int | None
|
||||
voltage: float | None
|
||||
temperature: float | None
|
||||
humidity: float | None
|
||||
uptime_secs: int | None
|
||||
|
||||
@dataclass
|
||||
class MeshcoreTraceroute:
|
||||
origin_id: str
|
||||
destination_id: str
|
||||
hops: list[str]
|
||||
snr_per_hop: list[float]
|
||||
timestamp: datetime
|
||||
|
||||
@dataclass
|
||||
class SerialConfig:
|
||||
port: str | None = None # None = auto-discover
|
||||
baud: int = 115200
|
||||
|
||||
@dataclass
|
||||
class TCPConfig:
|
||||
host: str = "localhost"
|
||||
port: int = 5000 # meshcore-proxy default
|
||||
|
||||
@dataclass
|
||||
class BLEConfig:
|
||||
device_address: str | None = None # None = scan for first Meshcore device
|
||||
|
||||
ConnectionConfig = SerialConfig | TCPConfig | BLEConfig
|
||||
```
|
||||
|
||||
Connection state enum: `DISCONNECTED | CONNECTING | CONNECTED | ERROR`
|
||||
|
||||
## Connection Handling
|
||||
|
||||
### Serial
|
||||
Auto-discover: scan `/dev/ttyUSB*`, `/dev/ttyACM*`, `/dev/cu.usbserial*` and return list to frontend via `GET /meshcore/ports`. User can also specify path directly.
|
||||
|
||||
### TCP
|
||||
Direct connection to `host:port`. Primary use case: meshcore-proxy running on the host, exposing a local USB or BLE device over TCP for Docker deployments.
|
||||
|
||||
### BLE
|
||||
- Linux/RPi: meshcore library uses BlueZ (requires `bluetoothctl` accessible)
|
||||
- macOS: meshcore library uses CoreBluetooth
|
||||
- Docker: detect via presence of `/.dockerenv` or `INTERCEPT_DOCKER=1` env var; connect attempt fails fast with clear error directing user to meshcore-proxy
|
||||
|
||||
`GET /meshcore/ble/scan` returns: `[{"address": "AA:BB:CC:DD:EE:FF", "name": "MeshCore-Node1", "rssi": -72}]`
|
||||
|
||||
### Reconnect
|
||||
Exponential backoff: 3 retries at 5s, 15s, 45s (cap 60s). On final failure, pushes `status` SSE event with `state: "error"`. User can manually retry via `POST /meshcore/connect`.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| GET | /meshcore/status | Connection state + transport info |
|
||||
| POST | /meshcore/connect | Connect with SerialConfig, TCPConfig, or BLEConfig |
|
||||
| POST | /meshcore/disconnect | Disconnect and stop background thread |
|
||||
| GET | /meshcore/ports | List available serial ports |
|
||||
| GET | /meshcore/ble/scan | Scan for nearby Meshcore BLE devices |
|
||||
| GET | /meshcore/stream | SSE stream (messages, nodes, telemetry, status) |
|
||||
| GET | /meshcore/messages | Recent messages (last 500) |
|
||||
| POST | /meshcore/send | Send text message |
|
||||
| GET | /meshcore/nodes | All known nodes |
|
||||
| GET | /meshcore/contacts | Contact list |
|
||||
| POST | /meshcore/contacts | Add contact |
|
||||
| DELETE | /meshcore/contacts/`<id>` | Remove contact |
|
||||
| GET | /meshcore/telemetry/`<node_id>` | Telemetry history for node |
|
||||
| POST | /meshcore/traceroute | Request traceroute to node |
|
||||
| GET | /meshcore/repeaters | List repeater nodes |
|
||||
|
||||
## SSE Event Format
|
||||
|
||||
```json
|
||||
{"type": "message", "data": { ...MeshcoreMessage }}
|
||||
{"type": "node", "data": { ...MeshcoreNode }}
|
||||
{"type": "telemetry", "data": { ...MeshcoreTelemetry }}
|
||||
{"type": "traceroute", "data": { ...MeshcoreTraceroute }}
|
||||
{"type": "status", "data": {"state": "connected", "transport": "serial", "device": "/dev/ttyUSB0"}}
|
||||
```
|
||||
|
||||
Keepalive comment (`: keepalive`) sent every 30 seconds on idle.
|
||||
|
||||
## Frontend (meshcore.js)
|
||||
|
||||
IIFE pattern, same as all other Intercept JS modules. Key responsibilities:
|
||||
|
||||
- **SSE consumer** — `EventSource('/meshcore/stream')`, routes events by `type`
|
||||
- **Message feed** — append to scrolling list, optimistic pending state on send
|
||||
- **Sidebar** — contact list + node list; repeaters shown separately with triangle icon (vs circle for client nodes), matching Meshcore UI conventions
|
||||
- **Tabs** — Map (Leaflet, reuse existing map setup pattern), Telemetry (Chart.js, reuse existing chart helpers), Repeaters (dedicated table view)
|
||||
- **Connection panel** — transport selector (Serial / TCP / BLE), port/IP/address input, connect/disconnect button
|
||||
- **Traceroute modal** — hop diagram with SNR annotations, same visual style as Meshtastic traceroute
|
||||
|
||||
## Repeater Management
|
||||
|
||||
Meshcore repeaters are a first-class concept (unlike Meshtastic where all nodes relay). Design:
|
||||
|
||||
- Repeaters identified by `is_repeater: true` on `MeshcoreNode`
|
||||
- Rendered on map as orange triangles (client nodes = blue circles)
|
||||
- Dedicated "Repeaters" tab in the main panel showing: name, location, uptime, last seen, hop count
|
||||
- Repeater stats surfaced in telemetry if available (uptime_secs from `MeshcoreTelemetry`)
|
||||
|
||||
## Error Handling
|
||||
|
||||
- meshcore library not installed → mode loads but shows "meshcore package required: `pip install meshcore`"
|
||||
- BLE in Docker → clear error: "BLE unavailable in Docker. Run meshcore-proxy on the host and connect via TCP."
|
||||
- Serial port not found → return available ports list in error response
|
||||
- Connection lost mid-session → automatic reconnect with backoff; SSE `status` event updates UI indicator
|
||||
- Send failure → SSE event clears pending state, shows error in message feed
|
||||
|
||||
## Testing
|
||||
|
||||
**`tests/test_meshcore_client.py`**
|
||||
- Connection state machine transitions
|
||||
- Reconnect backoff timing (mock asyncio loop)
|
||||
- Message parsing and queue feeding
|
||||
- Node/contact TTL expiry
|
||||
- BLE unavailability error (Docker scenario)
|
||||
|
||||
**`tests/test_meshcore_routes.py`**
|
||||
- All REST endpoints: correct JSON shape, status codes
|
||||
- `/meshcore/connect` with each connection config type
|
||||
- `/meshcore/send` with missing/invalid params → 400
|
||||
- SSE stream yields keepalive on empty queue
|
||||
- Input validation via `utils/validation.py`
|
||||
|
||||
**`tests/test_meshcore_integration.py`**
|
||||
- Mock meshcore library at boundary (same approach as mocking meshtastic SDK)
|
||||
- Full round-trip: connect → receive message event → appears in SSE stream
|
||||
- Traceroute request → hop structure correctly parsed
|
||||
|
||||
## Dependencies
|
||||
|
||||
```
|
||||
meshcore>=1.0.0 # optional — graceful degradation if absent
|
||||
```
|
||||
|
||||
No new frontend dependencies — Leaflet and Chart.js already present.
|
||||
|
||||
## Reference
|
||||
|
||||
- Meshcore Python library: https://github.com/meshcore-dev/meshcore_py
|
||||
- Companion protocol: https://docs.meshcore.io/companion_protocol/
|
||||
- meshcore-proxy (BLE/serial → TCP bridge): https://github.com/rgregg/meshcore-proxy
|
||||
- Existing Meshtastic implementation (reference): `utils/meshtastic.py`, `routes/meshtastic.py`
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,264 @@
|
||||
# Signal Identification — Design Spec
|
||||
**Date:** 2026-07-02
|
||||
**Status:** Approved
|
||||
|
||||
## Overview
|
||||
|
||||
Extend Intercept's existing signal identification capability with a bundled local signal database (~500 signals) and a rich modal overlay that works both from the waterfall (pre-populated) and standalone from the global nav (manual entry). The goal is Artemis-like offline signal lookup integrated directly into Intercept's workflow — no manual browsing required.
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Intercept already has two signal identification mechanisms:
|
||||
|
||||
| Mechanism | Location | Coverage | Offline? |
|
||||
|---|---|---|---|
|
||||
| Local heuristic engine | `utils/signal_guess.py` | ~20 signal types | Yes |
|
||||
| SigID Wiki API proxy | `routes/signalid.py` | ~500+ signals | No |
|
||||
|
||||
Both surface results in the waterfall sidebar Signal ID panel. The heuristic engine is limited in coverage; the Wiki proxy requires internet and has latency. This design replaces the heuristic engine's role with a bundled database and adds a richer modal UI.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
data/signals.json ← bundled signal database (~500 entries)
|
||||
↓ loaded at startup, cached in memory
|
||||
routes/signalid.py ← new POST /signalid/match route
|
||||
↓ JSON response with ranked matches + match_reasons
|
||||
static/js/signal-id-modal.js ← standalone modal component
|
||||
↑ called from waterfall.js (pre-populated) and nav (blank)
|
||||
```
|
||||
|
||||
### What stays unchanged
|
||||
- `utils/signal_guess.py` — left in place but no longer called from the new modal. Its role is superseded by the database-driven matcher. The existing `/receiver/signal/guess` route remains registered.
|
||||
- `routes/signalid.py /signalid/sigidwiki` — untouched. SigID Wiki links in the modal results make it redundant as a parallel lookup; matched results carry a `sigidwiki_url` field instead.
|
||||
- Waterfall SSE streaming, SDR process management, all other routes
|
||||
|
||||
---
|
||||
|
||||
## Database Schema
|
||||
|
||||
**File:** `data/signals.json`
|
||||
**Format:** JSON array of signal objects
|
||||
**Source:** Seeded from SigID Wiki (CC BY-SA), hand-curated, version-controlled in the repo
|
||||
|
||||
### Signal object
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "fm-broadcast",
|
||||
"name": "FM Broadcast Radio",
|
||||
"description": "Commercial FM radio stations. Wideband stereo audio, typically 87.5–108 MHz. Used worldwide for public broadcasting.",
|
||||
"categories": ["broadcast", "commercial", "audio"],
|
||||
"frequency_ranges": [
|
||||
{ "min_hz": 87500000, "max_hz": 108000000 }
|
||||
],
|
||||
"bandwidth_range": { "min_hz": 150000, "max_hz": 250000 },
|
||||
"modulations": ["WFM", "FM"],
|
||||
"regions": ["GLOBAL"],
|
||||
"sigidwiki_url": "https://www.sigidwiki.com/wiki/FM_Broadcast"
|
||||
}
|
||||
```
|
||||
|
||||
### Field definitions
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `id` | string | yes | Unique kebab-case slug |
|
||||
| `name` | string | yes | Display name |
|
||||
| `description` | string | yes | Plain-English, one paragraph max |
|
||||
| `categories` | string[] | yes | e.g. `broadcast`, `aviation`, `maritime`, `utility`, `amateur`, `military`, `telemetry` |
|
||||
| `frequency_ranges` | `{min_hz, max_hz}`[] | yes | List — some signals appear on multiple non-contiguous bands |
|
||||
| `bandwidth_range` | `{min_hz, max_hz}` \| null | yes | Null if unknown or highly variable |
|
||||
| `modulations` | string[] | yes | Uppercase tokens matching waterfall mode selector: `WFM`, `FM`, `AM`, `USB`, `LSB`, `FSK`, `OOK`, `PSK`, etc. |
|
||||
| `regions` | string[] | yes | `GLOBAL`, `EU`, `US`, `UK`, `AU` — used to deprioritise region-mismatched results |
|
||||
| `sigidwiki_url` | string \| null | yes | Direct link shown as "View reference" in modal. Null if no page exists. |
|
||||
|
||||
### Initial seed
|
||||
The ~20 signals already in `utils/signal_guess.py` are migrated to this schema as the starting point. The file grows to ~500 signals seeded from SigID Wiki structured data.
|
||||
|
||||
---
|
||||
|
||||
## Backend: `/signalid/match`
|
||||
|
||||
**Route:** `POST /signalid/match` added to the existing `signalid_bp` blueprint in `routes/signalid.py`
|
||||
|
||||
### Request
|
||||
|
||||
```json
|
||||
{
|
||||
"frequency_mhz": 100.1,
|
||||
"bandwidth_hz": 200000,
|
||||
"modulation": "WFM",
|
||||
"limit": 8
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Default | Constraints |
|
||||
|---|---|---|---|
|
||||
| `frequency_mhz` | yes | — | > 0 |
|
||||
| `bandwidth_hz` | no | null | > 0 if provided |
|
||||
| `modulation` | no | null | Truncated to 16 chars, uppercased |
|
||||
| `limit` | no | 8 | Clamped to 1–20 |
|
||||
|
||||
### Matching algorithm
|
||||
|
||||
**Step 1 — Hard filter (frequency overlap)**
|
||||
Discard any signal where the query frequency does not fall within at least one `frequency_range`. Reduces ~500 candidates to typically 3–15.
|
||||
|
||||
**Step 2 — Score each candidate (0–100 points)**
|
||||
|
||||
| Criterion | Max pts | Logic |
|
||||
|---|---|---|
|
||||
| Frequency centrality | 40 | How centred the query is within the matched range. Dead centre = 40, at the range boundary = 10. |
|
||||
| Bandwidth match | 30 | Only if `bandwidth_hz` provided. Within `bandwidth_range` = 30, within 2× = 15, outside = 0. Signal has null `bandwidth_range` = 10 (neutral). If `bandwidth_hz` not provided, all signals score 15 (neutral). |
|
||||
| Modulation match | 20 | Query modulation in signal's `modulations` = 20. No modulation provided = 10 (neutral). Mismatch = 0. |
|
||||
| Region match | 10 | Signal `regions` includes user's configured region or `GLOBAL` = 10, else 5. User region read from `config.py` (`INTERCEPT_REGION`, default `GLOBAL`). |
|
||||
|
||||
**Step 3 — Sort and annotate**
|
||||
Sort by score descending. Attach `match_reasons` list to each result (e.g. `["frequency: centre of range", "bandwidth: within typical", "modulation: exact match"]`). Return top `limit` results.
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"frequency_mhz": 100.1,
|
||||
"bandwidth_hz": 200000,
|
||||
"modulation": "WFM",
|
||||
"matches": [
|
||||
{
|
||||
"id": "fm-broadcast",
|
||||
"name": "FM Broadcast Radio",
|
||||
"description": "Commercial FM radio stations...",
|
||||
"categories": ["broadcast", "commercial", "audio"],
|
||||
"frequency_ranges": [{ "min_hz": 87500000, "max_hz": 108000000 }],
|
||||
"bandwidth_range": { "min_hz": 150000, "max_hz": 250000 },
|
||||
"modulations": ["WFM", "FM"],
|
||||
"regions": ["GLOBAL"],
|
||||
"sigidwiki_url": "https://www.sigidwiki.com/wiki/FM_Broadcast",
|
||||
"score": 87,
|
||||
"match_reasons": [
|
||||
"frequency: centre of range",
|
||||
"bandwidth: within typical",
|
||||
"modulation: exact match"
|
||||
]
|
||||
}
|
||||
],
|
||||
"match_count": 1,
|
||||
"cached": false
|
||||
}
|
||||
```
|
||||
|
||||
### Caching
|
||||
Results cached in-process for 60 seconds keyed by `{frequency_mhz}|{bandwidth_hz}|{modulation}|{limit}`. The database itself is loaded once at startup and never re-read during a session.
|
||||
|
||||
### Error cases
|
||||
- `frequency_mhz` missing or invalid → 400
|
||||
- `data/signals.json` missing or malformed at startup → route returns 503 with message
|
||||
- No matches → 200 with `matches: []`
|
||||
|
||||
---
|
||||
|
||||
## Frontend: Modal Component
|
||||
|
||||
### Files
|
||||
- `static/js/signal-id-modal.js` — standalone IIFE module (`SignalIdModal`)
|
||||
- Modal HTML injected into DOM on first call (not in any template)
|
||||
- Styled with existing CSS variables, no new stylesheet required
|
||||
|
||||
### Entry points
|
||||
|
||||
**From the waterfall** (`waterfall.js`):
|
||||
```js
|
||||
SignalIdModal.open({ frequency_mhz: _monitorFreqMhz, modulation: _getMonitorMode() });
|
||||
```
|
||||
Replaces the current inline Signal ID sidebar panel. The "Identify Signal" button in the waterfall sidebar triggers this.
|
||||
|
||||
**From the global nav** (`templates/partials/nav.html`):
|
||||
A "Signal ID" nav link calls `SignalIdModal.open({})` — opens with blank fields.
|
||||
|
||||
### Modal layout
|
||||
|
||||
```
|
||||
┌─ Signal Identification ──────────────────── [×] ─┐
|
||||
│ │
|
||||
│ Frequency [ 100.0000 ] MHz │
|
||||
│ Bandwidth [ optional ] kHz (improves match) │
|
||||
│ Modulation [ WFM ▾ ] │
|
||||
│ [Search] │
|
||||
├────────────────────────────────────────────────── │
|
||||
│ ● FM Broadcast Radio ████████ 87 │
|
||||
│ 87.5–108 MHz · WFM · Wideband │
|
||||
│ Commercial FM radio. Stereo audio broadcast… │
|
||||
│ [broadcast] [commercial] [audio] │
|
||||
│ Frequency: centre of range · Modulation: exact │
|
||||
│ ↗ View on SigID Wiki │
|
||||
│ ───────────────────────────────────────────── │
|
||||
│ ○ RDS Data (FM subcarrier) ████░░░░ 52 │
|
||||
│ … │
|
||||
└────────────────────────────────────────────────── ┘
|
||||
```
|
||||
|
||||
### Behaviour
|
||||
- Frequency pre-filled from caller; blank if opened from nav
|
||||
- Bandwidth field placeholder: "optional — improves matching", value in kHz (converted to Hz before API call)
|
||||
- Modulation pre-filled from caller or defaults to `WFM`
|
||||
- Search button disabled if frequency field is empty or invalid
|
||||
- "Search" fires `POST /signalid/match` — results render inline, modal stays open
|
||||
- Top result shows filled dot (●), rest show open dot (○)
|
||||
- Score shown as a proportional bar + integer (0–100)
|
||||
- `match_reasons` shown as a compact line of text below the signal name
|
||||
- `sigidwiki_url` shown as "↗ View on SigID Wiki" link (opens new tab); not rendered if null
|
||||
- `[×]` and clicking the backdrop close the modal
|
||||
- Waterfall continues running behind the modal; no state is lost
|
||||
|
||||
### Error states in modal
|
||||
| Condition | Display |
|
||||
|---|---|
|
||||
| Frequency empty/invalid | Search button disabled, field outlined red |
|
||||
| No matches returned | "No signals match [X] MHz — try adjusting the frequency or leaving bandwidth blank" |
|
||||
| Network/server error | "Search failed" + Retry button |
|
||||
| `sigidwiki_url` null | Link not rendered |
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### `tests/test_signalid_match.py`
|
||||
Unit tests for the matching algorithm as a pure function (no Flask test client needed for most):
|
||||
|
||||
- Frequency exactly at range boundary → included
|
||||
- Frequency 1 Hz outside range → excluded
|
||||
- Signal with multiple `frequency_ranges` → matched by whichever range contains the query
|
||||
- Bandwidth within range → score 30; at 2× → score 15; outside 2× → score 0
|
||||
- Bandwidth not provided → all signals score 15 neutral
|
||||
- Modulation exact match → score 20; not provided → 10; mismatch → 0
|
||||
- No matches → empty list, 200 response
|
||||
- `limit` clamping (0 → 1, 25 → 20)
|
||||
|
||||
### `tests/test_signals_json.py`
|
||||
Schema validation test — loads `data/signals.json` and asserts every entry has:
|
||||
- Required fields present and correct types
|
||||
- `min_hz < max_hz` in all frequency and bandwidth ranges
|
||||
- `min_hz > 0` in all ranges
|
||||
- `id` is unique across all entries
|
||||
- `modulations` tokens are uppercase strings
|
||||
|
||||
### No mocking required
|
||||
The matcher is a pure function over the in-memory database. Flask route tests use the real JSON file.
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Auto-bandwidth measurement from FFT
|
||||
- Audio sample upload and DSP analysis
|
||||
- Waterfall screenshot / image analysis
|
||||
- Periodic database sync or remote update
|
||||
- Audio sample or waterfall image hosting
|
||||
- Region auto-detection from IP
|
||||
- Standalone Signal Library browse mode (natural phase 2)
|
||||
+16
-1
@@ -28,7 +28,7 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
body > *:not(#bg-canvas) {
|
||||
body > *:not(#bg-canvas):not(#lightbox) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Minimal Flask-SocketIO compatibility shim.
|
||||
|
||||
This is only intended to satisfy radiosonde_auto_rx's optional web UI
|
||||
dependency in environments where ``flask_socketio`` is not installed.
|
||||
It provides the small subset of the API that auto_rx imports.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
|
||||
class SocketIO:
|
||||
"""Very small subset of Flask-SocketIO's SocketIO interface."""
|
||||
|
||||
def __init__(self, app, async_mode: str | None = None, *args, **kwargs):
|
||||
self.app = app
|
||||
self.async_mode = async_mode or "threading"
|
||||
self._handlers: dict[tuple[str, str | None], Callable[..., Any]] = {}
|
||||
|
||||
def on(self, event: str, namespace: str | None = None):
|
||||
"""Register an event handler decorator."""
|
||||
|
||||
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
||||
self._handlers[(event, namespace)] = func
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
def emit(self, event: str, data: Any = None, namespace: str | None = None, *args, **kwargs) -> None:
|
||||
"""No-op emit used when the real Socket.IO server is unavailable."""
|
||||
return None
|
||||
|
||||
def run(self, app=None, host: str = "127.0.0.1", port: int = 5000, *args, **kwargs) -> None:
|
||||
"""Fallback to Flask's built-in development server."""
|
||||
flask_app = app or self.app
|
||||
flask_app.run(
|
||||
host=host,
|
||||
port=port,
|
||||
threaded=True,
|
||||
use_reloader=False,
|
||||
)
|
||||
@@ -1,210 +0,0 @@
|
||||
DMSP 5D-3 F16 (USA 172)
|
||||
1 28054U 03048A 26037.66410905 .00000171 00000+0 11311-3 0 9991
|
||||
2 28054 99.0018 60.5544 0007736 150.6435 318.8272 14.14449870151032
|
||||
METEOSAT-9 (MSG-2)
|
||||
1 28912U 05049B 26037.20698824 .00000122 00000+0 00000+0 0 9990
|
||||
2 28912 9.0646 55.4438 0001292 220.3216 340.7358 1.00280364 5681
|
||||
DMSP 5D-3 F17 (USA 191)
|
||||
1 29522U 06050A 26037.63495522 .00000221 00000+0 13641-3 0 9997
|
||||
2 29522 98.7406 46.8646 0011088 71.3269 288.9107 14.14949568993957
|
||||
FENGYUN 3A
|
||||
1 32958U 08026A 26037.29889977 .00000162 00000+0 97205-4 0 9995
|
||||
2 32958 98.6761 340.6748 0009336 139.4536 220.7337 14.19536323916838
|
||||
GOES 14
|
||||
1 35491U 09033A 26037.59737599 .00000128 00000+0 00000+0 0 9998
|
||||
2 35491 1.3510 84.7861 0001663 279.3774 203.6871 1.00112472 5283
|
||||
DMSP 5D-3 F18 (USA 210)
|
||||
1 35951U 09057A 26037.59574243 .00000344 00000+0 20119-3 0 9997
|
||||
2 35951 98.8912 18.7405 0010014 262.2671 97.7365 14.14814612841124
|
||||
EWS-G2 (GOES 15)
|
||||
1 36411U 10008A 26037.42417604 .00000037 00000+0 00000+0 0 9998
|
||||
2 36411 0.9477 85.6904 0004764 200.6178 64.5237 1.00275731 58322
|
||||
COMS 1
|
||||
1 36744U 10032A 26037.66884865 -.00000343 00000+0 00000+0 0 9998
|
||||
2 36744 4.4730 77.2684 0001088 239.9858 188.4845 1.00274368 49786
|
||||
FENGYUN 3B
|
||||
1 37214U 10059A 26037.62488625 .00000510 00000+0 28715-3 0 9992
|
||||
2 37214 98.9821 82.9728 0021838 194.4193 280.6049 14.14810700788968
|
||||
SUOMI NPP
|
||||
1 37849U 11061A 26037.58885771 .00000151 00000+0 92735-4 0 9993
|
||||
2 37849 98.7835 339.4455 0001677 23.1332 336.9919 14.19534335739918
|
||||
METEOSAT-10 (MSG-3)
|
||||
1 38552U 12035B 26037.34062893 -.00000007 00000+0 00000+0 0 9993
|
||||
2 38552 4.3618 61.5789 0002324 286.1065 271.3938 1.00272839 49549
|
||||
METOP-B
|
||||
1 38771U 12049A 26037.61376690 .00000161 00000+0 93652-4 0 9994
|
||||
2 38771 98.6708 91.6029 0002456 28.4142 331.7169 14.21434029694718
|
||||
INSAT-3D
|
||||
1 39216U 13038B 26037.58021591 -.00000338 00000+0 00000+0 0 9998
|
||||
2 39216 1.5890 84.3012 0001719 220.0673 170.6954 1.00273812 45771
|
||||
FENGYUN 3C
|
||||
1 39260U 13052A 26037.57879946 .00000181 00000+0 11337-3 0 9991
|
||||
2 39260 98.4839 17.5531 0015475 42.6626 317.5748 14.15718213640089
|
||||
METEOR-M 2
|
||||
1 40069U 14037A 26037.57010537 .00000364 00000+0 18579-3 0 9995
|
||||
2 40069 98.4979 18.0359 0006835 60.5067 299.6792 14.21415164600761
|
||||
HIMAWARI-8
|
||||
1 40267U 14060A 26037.58238259 -.00000273 00000+0 00000+0 0 9991
|
||||
2 40267 0.0457 252.0286 0000958 31.3580 203.5957 1.00278490 41450
|
||||
FENGYUN 2G
|
||||
1 40367U 14090A 26037.64556289 -.00000299 00000+0 00000+0 0 9996
|
||||
2 40367 5.3089 74.4184 0001565 198.1345 195.9683 1.00263067 40698
|
||||
METEOSAT-11 (MSG-4)
|
||||
1 40732U 15034A 26037.62779616 .00000065 00000+0 00000+0 0 9990
|
||||
2 40732 2.8728 71.8294 0001180 241.7344 58.8290 1.00268087 5909
|
||||
ELEKTRO-L 2
|
||||
1 41105U 15074A 26037.40900929 -.00000118 00000+0 00000+0 0 9998
|
||||
2 41105 6.3653 72.1489 0003612 229.0998 328.0297 1.00272232 37198
|
||||
INSAT-3DR
|
||||
1 41752U 16054A 26037.65505200 -.00000075 00000+0 00000+0 0 9997
|
||||
2 41752 0.0554 93.8053 0013744 184.8269 167.9427 1.00271627 34504
|
||||
HIMAWARI-9
|
||||
1 41836U 16064A 26037.58238259 -.00000273 00000+0 00000+0 0 9990
|
||||
2 41836 0.0124 137.0088 0001068 210.1850 139.9064 1.00271322 33905
|
||||
GOES 16
|
||||
1 41866U 16071A 26037.60517604 -.00000089 00000+0 00000+0 0 9993
|
||||
2 41866 0.1490 94.1417 0002832 199.6896 316.0413 1.00271854 33798
|
||||
FENGYUN 4A
|
||||
1 41882U 16077A 26037.65041625 -.00000356 00000+0 00000+0 0 9994
|
||||
2 41882 1.9907 81.7886 0006284 132.9819 279.8453 1.00276098 33627
|
||||
CYGFM05
|
||||
1 41884U 16078A 26037.42561482 .00027408 00000+0 46309-3 0 9992
|
||||
2 41884 34.9596 42.6579 0007295 332.2973 27.7361 15.50585086508404
|
||||
CYGFM04
|
||||
1 41885U 16078B 26037.34428483 .00032519 00000+0 49575-3 0 9994
|
||||
2 41885 34.9348 16.2836 0005718 359.2189 0.8525 15.53424088508589
|
||||
CYGFM02
|
||||
1 41886U 16078C 26037.35007768 .00035591 00000+0 50564-3 0 9998
|
||||
2 41886 34.9436 13.7490 0006836 2.8379 357.2383 15.55324468508720
|
||||
CYGFM01
|
||||
1 41887U 16078D 26037.39685921 .00028560 00000+0 47572-3 0 9999
|
||||
2 41887 34.9425 44.8029 0007415 323.1915 36.8298 15.50976884508344
|
||||
CYGFM08
|
||||
1 41888U 16078E 26037.34185185 .00031327 00000+0 49606-3 0 9997
|
||||
2 41888 34.9457 27.4597 0008083 350.5361 9.5208 15.52364941508578
|
||||
CYGFM07
|
||||
1 41890U 16078G 26037.32199955 .00032204 00000+0 49829-3 0 9990
|
||||
2 41890 34.9475 16.2411 0005914 7.0804 353.0002 15.53017084508593
|
||||
CYGFM03
|
||||
1 41891U 16078H 26037.35550653 .00031487 00000+0 48940-3 0 9995
|
||||
2 41891 34.9430 17.9804 0005939 349.1458 10.9136 15.52895386508574
|
||||
FENGYUN 3D
|
||||
1 43010U 17072A 26037.62659924 .00000092 00000+0 65298-4 0 9990
|
||||
2 43010 98.9980 9.7978 0002479 69.6779 290.4663 14.19704535426460
|
||||
NOAA 20 (JPSS-1)
|
||||
1 43013U 17073A 26037.60336371 .00000124 00000+0 79520-4 0 9999
|
||||
2 43013 98.7658 338.3064 0000377 14.6433 345.4754 14.19527655425942
|
||||
GOES 17
|
||||
1 43226U 18022A 26037.60794939 -.00000180 00000+0 00000+0 0 9993
|
||||
2 43226 0.6016 88.1527 0002754 213.0089 324.8756 1.00269924 29115
|
||||
FENGYUN 2H
|
||||
1 43491U 18050A 26037.66161282 -.00000125 00000+0 00000+0 0 9992
|
||||
2 43491 2.6948 80.6967 0002145 171.8276 201.3055 1.00274855 28134
|
||||
METOP-C
|
||||
1 43689U 18087A 26037.63948662 .00000167 00000+0 96262-4 0 9998
|
||||
2 43689 98.6834 99.5280 0001629 143.8933 216.2355 14.21510040376280
|
||||
GEO-KOMPSAT-2A
|
||||
1 43823U 18100A 26037.57995591 .00000000 00000+0 00000+0 0 9996
|
||||
2 43823 0.0152 95.1913 0001141 313.4173 65.1318 1.00271011 26327
|
||||
METEOR-M2 2
|
||||
1 44387U 19038A 26037.58492015 .00000244 00000+0 12531-3 0 9993
|
||||
2 44387 98.9044 23.0180 0002141 55.2566 304.8814 14.24320728342700
|
||||
ARKTIKA-M 1
|
||||
1 47719U 21016A 26035.90384421 -.00000136 00000+0 00000+0 0 9994
|
||||
2 47719 63.1930 76.4940 7230705 269.3476 15.2984 2.00623094 36131
|
||||
FENGYUN 3E
|
||||
1 49008U 21062A 26037.62586080 .00000245 00000+0 13631-3 0 9992
|
||||
2 49008 98.7499 42.4910 0002627 96.2819 263.8657 14.19890127238058
|
||||
GOES 18
|
||||
1 51850U 22021A 26037.59876267 .00000098 00000+0 00000+0 0 9999
|
||||
2 51850 0.0198 91.3546 0000843 290.2366 193.6737 1.00273310 5288
|
||||
NOAA 21 (JPSS-2)
|
||||
1 54234U 22150A 26037.56792604 .00000152 00000+0 92800-4 0 9995
|
||||
2 54234 98.7521 338.1972 0001388 169.8161 190.3044 14.19543641168012
|
||||
METEOSAT-12 (MTG-I1)
|
||||
1 54743U 22170C 26037.62580281 -.00000006 00000+0 00000+0 0 9990
|
||||
2 54743 0.7119 25.1556 0002027 273.4388 63.0828 1.00270670 11667
|
||||
TIANMU-1 03
|
||||
1 55973U 23039A 26037.63298084 .00025307 00000+0 57478-3 0 9994
|
||||
2 55973 97.5143 206.9374 0002852 198.5193 161.5950 15.43014921160671
|
||||
TIANMU-1 04
|
||||
1 55974U 23039B 26037.59957323 .00027172 00000+0 60888-3 0 9999
|
||||
2 55974 97.5075 206.0729 0003605 196.0743 164.0390 15.43399931160675
|
||||
TIANMU-1 05
|
||||
1 55975U 23039C 26037.60840428 .00024975 00000+0 56836-3 0 9995
|
||||
2 55975 97.5122 206.5750 0002421 224.3240 135.7814 15.42959696160653
|
||||
TIANMU-1 06
|
||||
1 55976U 23039D 26037.60004198 .00024821 00000+0 55598-3 0 9996
|
||||
2 55976 97.5133 207.0788 0002810 218.0193 142.0857 15.43432906160673
|
||||
FENGYUN 3G
|
||||
1 56232U 23055A 26037.30935013 .00046475 00000+0 74423-3 0 9993
|
||||
2 56232 49.9940 300.8928 0009962 237.3703 122.6303 15.52544991159665
|
||||
METEOR-M2 3
|
||||
1 57166U 23091A 26037.62090481 .00000022 00000+0 28455-4 0 9999
|
||||
2 57166 98.6282 95.1607 0004003 174.5474 185.5750 14.24034408135931
|
||||
TIANMU-1 07
|
||||
1 57399U 23101A 26037.63242936 .00011510 00000+0 41012-3 0 9991
|
||||
2 57399 97.2786 91.2606 0002747 218.4597 141.6448 15.29074661141694
|
||||
TIANMU-1 08
|
||||
1 57400U 23101B 26037.66743594 .00011474 00000+0 41016-3 0 9996
|
||||
2 57400 97.2774 91.0783 0004440 227.8102 132.2762 15.28966110141699
|
||||
TIANMU-1 09
|
||||
1 57401U 23101C 26037.65072558 .00011360 00000+0 40433-3 0 9997
|
||||
2 57401 97.2732 90.5514 0003773 229.5297 130.5615 15.29113177141698
|
||||
TIANMU-1 10
|
||||
1 57402U 23101D 26037.61974057 .00011836 00000+0 42113-3 0 9994
|
||||
2 57402 97.2810 91.4302 0005461 233.7620 126.3116 15.29106286141685
|
||||
FENGYUN 3F
|
||||
1 57490U 23111A 26037.61228373 .00000135 00000+0 84019-4 0 9997
|
||||
2 57490 98.6988 109.9815 0001494 99.6638 260.4707 14.19912110130332
|
||||
ARKTIKA-M 2
|
||||
1 58584U 23198A 26037.15964049 .00000160 00000+0 00000+0 0 9994
|
||||
2 58584 63.2225 168.8508 6872222 267.8808 18.8364 2.00612776 15698
|
||||
TIANMU-1 11
|
||||
1 58645U 23205A 26037.58628093 .00009545 00000+0 37951-3 0 9999
|
||||
2 58645 97.3574 61.2485 0010997 103.8713 256.3749 15.25445149117601
|
||||
TIANMU-1 12
|
||||
1 58646U 23205B 26037.61705312 .00010066 00000+0 40129-3 0 9995
|
||||
2 58646 97.3561 61.0663 0009308 89.8253 270.4052 15.25355570117590
|
||||
TIANMU-1 13
|
||||
1 58647U 23205C 26037.64894829 .00010029 00000+0 39925-3 0 9992
|
||||
2 58647 97.3589 61.3229 0009456 74.8265 285.4018 15.25403883117592
|
||||
TIANMU-1 14
|
||||
1 58648U 23205D 26037.63305929 .00009719 00000+0 38718-3 0 9993
|
||||
2 58648 97.3523 60.6045 0010314 77.9995 282.2399 15.25381326117592
|
||||
TIANMU-1 19
|
||||
1 58660U 23208A 26037.58812600 .00016491 00000+0 58449-3 0 9991
|
||||
2 58660 97.4377 153.5627 0006125 66.0574 294.1307 15.29155961117352
|
||||
TIANMU-1 20
|
||||
1 58661U 23208B 26037.59661536 .00016638 00000+0 56823-3 0 9990
|
||||
2 58661 97.4315 154.0738 0008420 72.4906 287.7255 15.30347593117439
|
||||
TIANMU-1 21
|
||||
1 58662U 23208C 26037.56944589 .00017161 00000+0 55253-3 0 9998
|
||||
2 58662 97.4367 156.2063 0008160 67.8039 292.4068 15.32247056117540
|
||||
TIANMU-1 22
|
||||
1 58663U 23208D 26037.59847459 .00015396 00000+0 55169-3 0 9994
|
||||
2 58663 97.4371 153.6033 0005010 87.2275 272.9538 15.28818503117364
|
||||
TIANMU-1 15
|
||||
1 58700U 24004A 26037.63062994 .00009739 00000+0 38850-3 0 9991
|
||||
2 58700 97.4651 223.9243 0008449 88.7599 271.4607 15.25356935115862
|
||||
TIANMU-1 16
|
||||
1 58701U 24004B 26037.61474986 .00010691 00000+0 42590-3 0 9993
|
||||
2 58701 97.4590 223.2544 0006831 91.0928 269.1093 15.25387104115863
|
||||
TIANMU-1 17
|
||||
1 58702U 24004C 26037.59783649 .00011079 00000+0 44078-3 0 9994
|
||||
2 58702 97.4624 223.6760 0006020 92.0871 268.1056 15.25425175115852
|
||||
TIANMU-1 18
|
||||
1 58703U 24004D 26037.64767373 .00010786 00000+0 42976-3 0 9996
|
||||
2 58703 97.4642 223.9320 0005432 91.0134 269.1726 15.25387870115860
|
||||
INSAT-3DS
|
||||
1 58990U 24033A 26037.64159978 -.00000153 00000+0 00000+0 0 9998
|
||||
2 58990 0.0277 242.2492 0001855 99.2205 108.3003 1.00271452 45758
|
||||
METEOR-M2 4
|
||||
1 59051U 24039A 26037.62796654 .00000070 00000+0 51194-4 0 9991
|
||||
2 59051 98.6849 358.6843 0006923 178.9165 181.2029 14.22412185100701
|
||||
GOES 19
|
||||
1 60133U 24119A 26037.61098274 -.00000246 00000+0 00000+0 0 9996
|
||||
2 60133 0.0027 288.6290 0001204 74.2636 278.5881 1.00270967 5651
|
||||
FENGYUN 3H
|
||||
1 65815U 25219A 26037.60879211 .00000151 00000+0 91464-4 0 9990
|
||||
2 65815 98.6649 341.0050 0001596 86.5100 273.6260 14.19924132 18857
|
||||
+4
-3
@@ -1,6 +1,8 @@
|
||||
"""Gunicorn configuration for INTERCEPT."""
|
||||
|
||||
import contextlib
|
||||
import warnings
|
||||
|
||||
warnings.filterwarnings(
|
||||
'ignore',
|
||||
message='Patching more than once',
|
||||
@@ -33,10 +35,8 @@ def post_fork(server, worker):
|
||||
_orig = _ForkHooks.after_fork_in_child
|
||||
|
||||
def _safe_after_fork(self):
|
||||
try:
|
||||
with contextlib.suppress(AssertionError):
|
||||
_orig(self)
|
||||
except AssertionError:
|
||||
pass
|
||||
|
||||
_ForkHooks.after_fork_in_child = _safe_after_fork
|
||||
except Exception:
|
||||
@@ -53,6 +53,7 @@ def post_worker_init(worker):
|
||||
"""
|
||||
try:
|
||||
import ssl
|
||||
|
||||
from gevent import get_hub
|
||||
hub = get_hub()
|
||||
suppress = (SystemExit, ssl.SSLZeroReturnError, ssl.SSLError)
|
||||
|
||||
@@ -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:
|
||||
|
||||
+1215
-1473
File diff suppressed because it is too large
Load Diff
+42
-5
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "intercept"
|
||||
version = "2.24.0"
|
||||
version = "2.27.0"
|
||||
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
@@ -27,13 +27,16 @@ classifiers = [
|
||||
]
|
||||
dependencies = [
|
||||
"flask>=3.0.0",
|
||||
"flask-wtf>=1.2.0",
|
||||
"flask-compress>=1.15",
|
||||
"flask-limiter>=2.5.4",
|
||||
"flask-sock",
|
||||
"simple-websocket>=0.5.1",
|
||||
"websocket-client>=1.6.0",
|
||||
"skyfield>=1.45",
|
||||
"pyserial>=3.5",
|
||||
"Werkzeug>=3.1.5",
|
||||
"flask-limiter>=2.5.4",
|
||||
"bleak>=0.21.0",
|
||||
"flask-sock",
|
||||
"websocket-client>=1.6.0",
|
||||
"requests>=2.28.0",
|
||||
]
|
||||
|
||||
@@ -51,6 +54,7 @@ dev = [
|
||||
"black>=23.0.0",
|
||||
"mypy>=1.0.0",
|
||||
"types-flask>=1.1.0",
|
||||
"pre-commit>=3.0.0",
|
||||
]
|
||||
|
||||
optionals = [
|
||||
@@ -59,8 +63,13 @@ optionals = [
|
||||
"numpy>=1.24.0",
|
||||
"Pillow>=9.0.0",
|
||||
"meshtastic>=2.0.0",
|
||||
"meshcore>=2.3.0",
|
||||
"psycopg2-binary>=2.9.9",
|
||||
"scapy>=2.4.5",
|
||||
"cryptography>=41.0.0",
|
||||
"psutil>=5.9.0",
|
||||
"gunicorn>=21.2.0",
|
||||
"gevent>=23.9.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
@@ -93,8 +102,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"]
|
||||
|
||||
@@ -127,7 +160,11 @@ exclude = [
|
||||
testpaths = ["tests"]
|
||||
python_files = ["test_*.py"]
|
||||
python_functions = ["test_*"]
|
||||
addopts = "-v --tb=short"
|
||||
# 'live' tests drive real SDR hardware — run explicitly with: pytest -m live
|
||||
addopts = "-v --tb=short -m 'not live'"
|
||||
markers = [
|
||||
"live: tests that require real SDR hardware and run live decoders",
|
||||
]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["app", "routes", "utils", "data"]
|
||||
|
||||
@@ -10,6 +10,7 @@ pytest-mock>=3.15.1
|
||||
ruff>=0.1.0
|
||||
black>=23.0.0
|
||||
mypy>=1.0.0
|
||||
pre-commit>=3.0.0
|
||||
|
||||
# Type stubs
|
||||
types-flask>=1.1.0
|
||||
|
||||
@@ -27,6 +27,8 @@ pyserial>=3.5
|
||||
|
||||
# Meshtastic mesh network support (optional - only needed for Meshtastic features)
|
||||
meshtastic>=2.0.0
|
||||
# meshcore 2.3.0+ required for EventType.STATS_CORE; needs Python 3.10+
|
||||
meshcore>=2.3.0
|
||||
|
||||
# Deauthentication attack detection (optional - for WiFi TSCM)
|
||||
scapy>=2.4.5
|
||||
@@ -45,6 +47,7 @@ cryptography>=41.0.0
|
||||
# mypy>=1.0.0
|
||||
# WebSocket support for in-app audio streaming (KiwiSDR, Listening Post)
|
||||
flask-sock
|
||||
simple-websocket>=0.5.1
|
||||
websocket-client>=1.6.0
|
||||
|
||||
# System health monitoring (optional - graceful fallback if unavailable)
|
||||
|
||||
+10
-3
@@ -18,14 +18,17 @@ def register_blueprints(app):
|
||||
from .bt_locate import bt_locate_bp
|
||||
from .controller import controller_bp
|
||||
from .correlation import correlation_bp
|
||||
from .drone import drone_bp
|
||||
from .dsc import dsc_bp
|
||||
from .gps import gps_bp
|
||||
from .ground_station import ground_station_bp
|
||||
from .listening_post import receiver_bp
|
||||
from .meshcore import meshcore_bp
|
||||
from .meshtastic import meshtastic_bp
|
||||
from .meteor_websocket import meteor_bp
|
||||
from .morse import morse_bp
|
||||
from .ook import ook_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
|
||||
@@ -44,8 +47,8 @@ def register_blueprints(app):
|
||||
from .updater import updater_bp
|
||||
from .vdl2 import vdl2_bp
|
||||
from .weather_sat import weather_sat_bp
|
||||
from .wefax import wefax_bp
|
||||
from .websdr import websdr_bp
|
||||
from .wefax import wefax_bp
|
||||
from .wifi import wifi_bp
|
||||
from .wifi_v2 import wifi_v2_bp
|
||||
|
||||
@@ -68,6 +71,7 @@ def register_blueprints(app):
|
||||
app.register_blueprint(correlation_bp)
|
||||
app.register_blueprint(receiver_bp)
|
||||
app.register_blueprint(meshtastic_bp)
|
||||
app.register_blueprint(meshcore_bp)
|
||||
app.register_blueprint(tscm_bp)
|
||||
app.register_blueprint(spy_stations_bp)
|
||||
app.register_blueprint(controller_bp) # Remote agent controller
|
||||
@@ -89,6 +93,8 @@ def register_blueprints(app):
|
||||
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
|
||||
app.register_blueprint(ground_station_bp) # Ground station automation
|
||||
app.register_blueprint(drone_bp) # Drone intelligence / UAV detection
|
||||
|
||||
# Exempt all API blueprints from CSRF (they use JSON, not form tokens)
|
||||
if _csrf:
|
||||
@@ -97,5 +103,6 @@ def register_blueprints(app):
|
||||
|
||||
# Initialize TSCM state with queue and lock from app
|
||||
import app as app_module
|
||||
if hasattr(app_module, 'tscm_queue') and hasattr(app_module, 'tscm_lock'):
|
||||
|
||||
if hasattr(app_module, "tscm_queue") and hasattr(app_module, "tscm_lock"):
|
||||
init_tscm_state(app_module.tscm_queue, app_module.tscm_lock)
|
||||
|
||||
+6
-10
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
@@ -13,11 +13,10 @@ import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Generator
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
from utils.responses import api_success, api_error
|
||||
import app as app_module
|
||||
from utils.acars_translator import translate_message
|
||||
from utils.constants import (
|
||||
@@ -30,6 +29,7 @@ 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
|
||||
@@ -143,10 +143,8 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
|
||||
app_module.acars_queue.put(data)
|
||||
|
||||
# Feed flight correlator
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
get_flight_correlator().add_acars_message(data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Log if enabled
|
||||
if app_module.logging_enabled:
|
||||
@@ -172,10 +170,8 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
|
||||
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:
|
||||
@@ -335,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(
|
||||
|
||||
+726
-613
File diff suppressed because it is too large
Load Diff
+57
-24
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
@@ -10,30 +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
|
||||
|
||||
from utils.responses import api_success, api_error
|
||||
import app as app_module
|
||||
from config import SHARED_OBSERVER_LOCATION_ENABLED
|
||||
from utils.logging import get_logger
|
||||
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')
|
||||
|
||||
@@ -128,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')
|
||||
@@ -163,10 +160,8 @@ def parse_ais_stream(port: int):
|
||||
time.sleep(AIS_RECONNECT_DELAY)
|
||||
finally:
|
||||
if sock:
|
||||
try:
|
||||
with contextlib.suppress(OSError):
|
||||
sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
ais_connected = False
|
||||
logger.info("AIS stream parser stopped")
|
||||
@@ -413,11 +408,24 @@ def start_ais():
|
||||
bias_t = data.get('bias_t', False)
|
||||
tcp_port = AIS_TCP_PORT
|
||||
|
||||
# Optional UDP NMEA forwarding (e.g. for OpenCPN on port 10110)
|
||||
udp_host = data.get('udp_host') or None
|
||||
udp_port = None
|
||||
if udp_host:
|
||||
try:
|
||||
udp_port = int(data.get('udp_port', 10110))
|
||||
if not 1 <= udp_port <= 65535:
|
||||
raise ValueError
|
||||
except (TypeError, ValueError):
|
||||
return api_error('Invalid udp_port (1-65535)', 400)
|
||||
|
||||
cmd = builder.build_ais_command(
|
||||
device=sdr_device,
|
||||
gain=float(gain),
|
||||
bias_t=bias_t,
|
||||
tcp_port=tcp_port
|
||||
tcp_port=tcp_port,
|
||||
udp_host=udp_host,
|
||||
udp_port=udp_port,
|
||||
)
|
||||
|
||||
# Use the found AIS-catcher path
|
||||
@@ -440,10 +448,8 @@ def start_ais():
|
||||
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.'
|
||||
@@ -533,7 +539,7 @@ def get_vessel_dsc(mmsi: str):
|
||||
|
||||
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:
|
||||
@@ -542,6 +548,31 @@ def get_vessel_dsc(mmsi: str):
|
||||
return api_success(data={'mmsi': mmsi, 'dsc_messages': matches})
|
||||
|
||||
|
||||
@ais_bp.route('/vessels')
|
||||
def ais_vessels():
|
||||
"""Export current AIS vessel data as JSON.
|
||||
|
||||
Returns a snapshot of all tracked vessels suitable for integration
|
||||
with external tools (OpenCPN, ship tracking apps, etc.).
|
||||
|
||||
Query parameters:
|
||||
mmsi: Filter to a specific MMSI (optional)
|
||||
|
||||
Returns:
|
||||
JSON with vessel list and metadata.
|
||||
"""
|
||||
vessels = dict(app_module.ais_vessels)
|
||||
|
||||
mmsi_filter = request.args.get('mmsi')
|
||||
if mmsi_filter:
|
||||
vessels = {k: v for k, v in vessels.items() if str(k) == str(mmsi_filter)}
|
||||
|
||||
return jsonify({
|
||||
'count': len(vessels),
|
||||
'vessels': list(vessels.values()),
|
||||
})
|
||||
|
||||
|
||||
@ais_bp.route('/dashboard')
|
||||
def ais_dashboard():
|
||||
"""Popout AIS dashboard."""
|
||||
@@ -549,5 +580,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,
|
||||
)
|
||||
|
||||
+33
-34
@@ -2,76 +2,75 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
import time
|
||||
from typing import Generator
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
from flask import Blueprint, Response, request
|
||||
|
||||
from utils.alerts import get_alert_manager
|
||||
from utils.responses import api_success, api_error
|
||||
from utils.sse import format_sse
|
||||
from utils.responses import api_error, api_success
|
||||
from utils.sse import sse_stream_fanout
|
||||
|
||||
alerts_bp = Blueprint('alerts', __name__, url_prefix='/alerts')
|
||||
alerts_bp = Blueprint("alerts", __name__, url_prefix="/alerts")
|
||||
|
||||
|
||||
@alerts_bp.route('/rules', methods=['GET'])
|
||||
@alerts_bp.route("/rules", methods=["GET"])
|
||||
def list_rules():
|
||||
manager = get_alert_manager()
|
||||
include_disabled = request.args.get('all') in ('1', 'true', 'yes')
|
||||
return api_success(data={'rules': manager.list_rules(include_disabled=include_disabled)})
|
||||
include_disabled = request.args.get("all") in ("1", "true", "yes")
|
||||
return api_success(data={"rules": manager.list_rules(include_disabled=include_disabled)})
|
||||
|
||||
|
||||
@alerts_bp.route('/rules', methods=['POST'])
|
||||
@alerts_bp.route("/rules", methods=["POST"])
|
||||
def create_rule():
|
||||
data = request.get_json() or {}
|
||||
if not isinstance(data.get('match', {}), dict):
|
||||
return api_error('match must be a JSON object', 400)
|
||||
if not isinstance(data.get("match", {}), dict):
|
||||
return api_error("match must be a JSON object", 400)
|
||||
|
||||
manager = get_alert_manager()
|
||||
rule_id = manager.add_rule(data)
|
||||
return api_success(data={'rule_id': rule_id})
|
||||
return api_success(data={"rule_id": rule_id})
|
||||
|
||||
|
||||
@alerts_bp.route('/rules/<int:rule_id>', methods=['PUT', 'PATCH'])
|
||||
@alerts_bp.route("/rules/<int:rule_id>", methods=["PUT", "PATCH"])
|
||||
def update_rule(rule_id: int):
|
||||
data = request.get_json() or {}
|
||||
manager = get_alert_manager()
|
||||
ok = manager.update_rule(rule_id, data)
|
||||
if not ok:
|
||||
return api_error('Rule not found or no changes', 404)
|
||||
return api_error("Rule not found or no changes", 404)
|
||||
return api_success()
|
||||
|
||||
|
||||
@alerts_bp.route('/rules/<int:rule_id>', methods=['DELETE'])
|
||||
@alerts_bp.route("/rules/<int:rule_id>", methods=["DELETE"])
|
||||
def delete_rule(rule_id: int):
|
||||
manager = get_alert_manager()
|
||||
ok = manager.delete_rule(rule_id)
|
||||
if not ok:
|
||||
return api_error('Rule not found', 404)
|
||||
return api_error("Rule not found", 404)
|
||||
return api_success()
|
||||
|
||||
|
||||
@alerts_bp.route('/events', methods=['GET'])
|
||||
@alerts_bp.route("/events", methods=["GET"])
|
||||
def list_events():
|
||||
manager = get_alert_manager()
|
||||
limit = request.args.get('limit', default=100, type=int)
|
||||
mode = request.args.get('mode')
|
||||
severity = request.args.get('severity')
|
||||
limit = request.args.get("limit", default=100, type=int)
|
||||
mode = request.args.get("mode")
|
||||
severity = request.args.get("severity")
|
||||
events = manager.list_events(limit=limit, mode=mode, severity=severity)
|
||||
return api_success(data={'events': events})
|
||||
return api_success(data={"events": events})
|
||||
|
||||
|
||||
@alerts_bp.route('/stream', methods=['GET'])
|
||||
@alerts_bp.route("/stream", methods=["GET"])
|
||||
def stream_alerts() -> Response:
|
||||
manager = get_alert_manager()
|
||||
|
||||
def generate() -> Generator[str, None, None]:
|
||||
for event in manager.stream_events(timeout=1.0):
|
||||
yield format_sse(event)
|
||||
|
||||
response = Response(generate(), 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=manager._queue,
|
||||
channel_key="alerts",
|
||||
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
|
||||
|
||||
+73
-72
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
@@ -15,14 +16,23 @@ import tempfile
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from subprocess import PIPE, STDOUT
|
||||
from typing import Any, Generator, Optional
|
||||
from subprocess import PIPE
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
from utils.responses import api_success, api_error
|
||||
import app as app_module
|
||||
from utils.constants import (
|
||||
PROCESS_START_WAIT,
|
||||
PROCESS_TERMINATE_TIMEOUT,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
)
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.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,
|
||||
@@ -30,15 +40,6 @@ from utils.validation import (
|
||||
validate_rtl_tcp_host,
|
||||
validate_rtl_tcp_port,
|
||||
)
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.constants import (
|
||||
PROCESS_TERMINATE_TIMEOUT,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
PROCESS_START_WAIT,
|
||||
)
|
||||
|
||||
aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs')
|
||||
|
||||
@@ -75,27 +76,27 @@ METER_MIN_INTERVAL = 0.1 # Max 10 updates/sec
|
||||
METER_MIN_CHANGE = 2 # Only send if level changes by at least this much
|
||||
|
||||
|
||||
def find_direwolf() -> Optional[str]:
|
||||
def find_direwolf() -> str | None:
|
||||
"""Find direwolf binary."""
|
||||
return shutil.which('direwolf')
|
||||
|
||||
|
||||
def find_multimon_ng() -> Optional[str]:
|
||||
def find_multimon_ng() -> str | None:
|
||||
"""Find multimon-ng binary."""
|
||||
return shutil.which('multimon-ng')
|
||||
|
||||
|
||||
def find_rtl_fm() -> Optional[str]:
|
||||
def find_rtl_fm() -> str | None:
|
||||
"""Find rtl_fm binary."""
|
||||
return shutil.which('rtl_fm')
|
||||
|
||||
|
||||
def find_rx_fm() -> Optional[str]:
|
||||
def find_rx_fm() -> str | None:
|
||||
"""Find SoapySDR rx_fm binary."""
|
||||
return shutil.which('rx_fm')
|
||||
|
||||
|
||||
def find_rtl_power() -> Optional[str]:
|
||||
def find_rtl_power() -> str | None:
|
||||
"""Find rtl_power binary for spectrum scanning."""
|
||||
return shutil.which('rtl_power')
|
||||
|
||||
@@ -142,7 +143,7 @@ def normalize_aprs_output_line(line: str) -> str:
|
||||
return normalized
|
||||
|
||||
|
||||
def parse_aprs_packet(raw_packet: str) -> Optional[dict]:
|
||||
def parse_aprs_packet(raw_packet: str) -> dict | None:
|
||||
"""Parse APRS packet into structured data.
|
||||
|
||||
Supports all major APRS packet types:
|
||||
@@ -431,7 +432,7 @@ def parse_aprs_packet(raw_packet: str) -> Optional[dict]:
|
||||
return None
|
||||
|
||||
|
||||
def parse_position(data: str) -> Optional[dict]:
|
||||
def parse_position(data: str) -> dict | None:
|
||||
"""Parse APRS position data."""
|
||||
try:
|
||||
# Format: DDMM.mmN/DDDMM.mmW (or similar with symbols)
|
||||
@@ -591,7 +592,7 @@ def parse_position(data: str) -> Optional[dict]:
|
||||
return None
|
||||
|
||||
|
||||
def parse_object(data: str) -> Optional[dict]:
|
||||
def parse_object(data: str) -> dict | None:
|
||||
"""Parse APRS object data.
|
||||
|
||||
Object format: ;OBJECTNAME*DDHHMMzPOSITION or ;OBJECTNAME_DDHHMMzPOSITION
|
||||
@@ -649,7 +650,7 @@ def parse_object(data: str) -> Optional[dict]:
|
||||
return None
|
||||
|
||||
|
||||
def parse_item(data: str) -> Optional[dict]:
|
||||
def parse_item(data: str) -> dict | None:
|
||||
"""Parse APRS item data.
|
||||
|
||||
Item format: )ITEMNAME!POSITION or )ITEMNAME_POSITION
|
||||
@@ -830,7 +831,7 @@ MIC_E_MESSAGE_TYPES = {
|
||||
}
|
||||
|
||||
|
||||
def parse_mic_e(dest: str, data: str) -> Optional[dict]:
|
||||
def parse_mic_e(dest: str, data: str) -> dict | None:
|
||||
"""Parse Mic-E encoded position from destination and data fields.
|
||||
|
||||
Mic-E is a highly compressed format that encodes:
|
||||
@@ -973,7 +974,7 @@ def parse_mic_e(dest: str, data: str) -> Optional[dict]:
|
||||
return None
|
||||
|
||||
|
||||
def parse_compressed_position(data: str) -> Optional[dict]:
|
||||
def parse_compressed_position(data: str) -> dict | None:
|
||||
r"""Parse compressed position format (Base-91 encoding).
|
||||
|
||||
Compressed format: /YYYYXXXX$csT
|
||||
@@ -1057,7 +1058,7 @@ def parse_compressed_position(data: str) -> Optional[dict]:
|
||||
return None
|
||||
|
||||
|
||||
def parse_telemetry(data: str) -> Optional[dict]:
|
||||
def parse_telemetry(data: str) -> dict | None:
|
||||
"""Parse APRS telemetry data.
|
||||
|
||||
Format: T#sss,aaa,aaa,aaa,aaa,aaa,bbbbbbbb
|
||||
@@ -1122,7 +1123,7 @@ def parse_telemetry(data: str) -> Optional[dict]:
|
||||
return None
|
||||
|
||||
|
||||
def parse_telemetry_definition(callsign: str, msg_type: str, content: str) -> Optional[dict]:
|
||||
def parse_telemetry_definition(callsign: str, msg_type: str, content: str) -> dict | None:
|
||||
"""Parse telemetry definition messages (PARM, UNIT, EQNS, BITS).
|
||||
|
||||
These messages define the meaning of telemetry values for a station.
|
||||
@@ -1174,7 +1175,7 @@ def parse_telemetry_definition(callsign: str, msg_type: str, content: str) -> Op
|
||||
return None
|
||||
|
||||
|
||||
def parse_phg(data: str) -> Optional[dict]:
|
||||
def parse_phg(data: str) -> dict | None:
|
||||
"""Parse PHG (Power/Height/Gain/Directivity) data.
|
||||
|
||||
Format: PHGphgd
|
||||
@@ -1217,7 +1218,7 @@ def parse_phg(data: str) -> Optional[dict]:
|
||||
return None
|
||||
|
||||
|
||||
def parse_rng(data: str) -> Optional[dict]:
|
||||
def parse_rng(data: str) -> dict | None:
|
||||
"""Parse RNG (radio range) data.
|
||||
|
||||
Format: RNGrrrr where rrrr is range in miles.
|
||||
@@ -1231,7 +1232,7 @@ def parse_rng(data: str) -> Optional[dict]:
|
||||
return None
|
||||
|
||||
|
||||
def parse_df_report(data: str) -> Optional[dict]:
|
||||
def parse_df_report(data: str) -> dict | None:
|
||||
"""Parse Direction Finding (DF) report.
|
||||
|
||||
Format: CSE/SPD/BRG/NRQ or similar patterns.
|
||||
@@ -1260,7 +1261,7 @@ def parse_df_report(data: str) -> Optional[dict]:
|
||||
return None
|
||||
|
||||
|
||||
def parse_timestamp(data: str) -> Optional[dict]:
|
||||
def parse_timestamp(data: str) -> dict | None:
|
||||
"""Parse APRS timestamp from position data.
|
||||
|
||||
Formats:
|
||||
@@ -1304,7 +1305,7 @@ def parse_timestamp(data: str) -> Optional[dict]:
|
||||
return None
|
||||
|
||||
|
||||
def parse_third_party(data: str) -> Optional[dict]:
|
||||
def parse_third_party(data: str) -> dict | None:
|
||||
"""Parse third-party traffic (packets relayed from another network).
|
||||
|
||||
Format: }CALL>PATH:DATA (the } indicates third-party)
|
||||
@@ -1330,7 +1331,7 @@ def parse_third_party(data: str) -> Optional[dict]:
|
||||
return None
|
||||
|
||||
|
||||
def parse_user_defined(data: str) -> Optional[dict]:
|
||||
def parse_user_defined(data: str) -> dict | None:
|
||||
"""Parse user-defined data format.
|
||||
|
||||
Format: {UUXXXX...
|
||||
@@ -1352,7 +1353,7 @@ def parse_user_defined(data: str) -> Optional[dict]:
|
||||
return None
|
||||
|
||||
|
||||
def parse_capabilities(data: str) -> Optional[dict]:
|
||||
def parse_capabilities(data: str) -> dict | None:
|
||||
"""Parse station capabilities response.
|
||||
|
||||
Format: <capability1,capability2,...
|
||||
@@ -1381,7 +1382,7 @@ def parse_capabilities(data: str) -> Optional[dict]:
|
||||
return None
|
||||
|
||||
|
||||
def parse_nmea(data: str) -> Optional[dict]:
|
||||
def parse_nmea(data: str) -> dict | None:
|
||||
"""Parse raw GPS NMEA sentences.
|
||||
|
||||
APRS can include raw NMEA data starting with $.
|
||||
@@ -1409,7 +1410,7 @@ def parse_nmea(data: str) -> Optional[dict]:
|
||||
return None
|
||||
|
||||
|
||||
def parse_audio_level(line: str) -> Optional[int]:
|
||||
def parse_audio_level(line: str) -> int | None:
|
||||
"""Parse direwolf audio level line and return normalized level (0-100).
|
||||
|
||||
Direwolf outputs lines like:
|
||||
@@ -1579,10 +1580,8 @@ def stream_aprs_output(master_fd: int, rtl_process: subprocess.Popen, decoder_pr
|
||||
logger.error(f"APRS stream error: {e}")
|
||||
app_module.aprs_queue.put({'type': 'error', 'message': str(e)})
|
||||
finally:
|
||||
try:
|
||||
with contextlib.suppress(OSError):
|
||||
os.close(master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
app_module.aprs_queue.put({'type': 'status', 'status': 'stopped'})
|
||||
# Cleanup processes
|
||||
for proc in [rtl_process, decoder_process]:
|
||||
@@ -1590,10 +1589,8 @@ def stream_aprs_output(master_fd: int, rtl_process: subprocess.Popen, decoder_pr
|
||||
proc.terminate()
|
||||
proc.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
# Release SDR device — only if it's still ours (not reclaimed by a new start)
|
||||
if my_device is not None and aprs_active_device == my_device:
|
||||
app_module.release_sdr_device(my_device, aprs_active_sdr_type or 'rtlsdr')
|
||||
@@ -1860,14 +1857,10 @@ def start_aprs() -> Response:
|
||||
if stderr_output:
|
||||
error_msg += f': {stderr_output[:500]}'
|
||||
logger.error(error_msg)
|
||||
try:
|
||||
with contextlib.suppress(OSError):
|
||||
os.close(master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
decoder_process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
if aprs_active_device is not None:
|
||||
app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
|
||||
aprs_active_device = None
|
||||
@@ -1888,14 +1881,10 @@ def start_aprs() -> Response:
|
||||
if error_output:
|
||||
error_msg += f': {error_output}'
|
||||
logger.error(error_msg)
|
||||
try:
|
||||
with contextlib.suppress(OSError):
|
||||
os.close(master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
rtl_process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
if aprs_active_device is not None:
|
||||
app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
|
||||
aprs_active_device = None
|
||||
@@ -1935,7 +1924,13 @@ def start_aprs() -> Response:
|
||||
|
||||
@aprs_bp.route('/stop', methods=['POST'])
|
||||
def stop_aprs() -> Response:
|
||||
"""Stop APRS decoder."""
|
||||
"""Stop APRS decoder.
|
||||
|
||||
Releases the SDR device immediately so the status panel updates
|
||||
without waiting for process termination. Process cleanup runs in a
|
||||
background thread to avoid blocking the HTTP response (which caused
|
||||
frontend timeout errors when two processes each took up to 2s to die).
|
||||
"""
|
||||
global aprs_active_device, aprs_active_sdr_type
|
||||
|
||||
with app_module.aprs_lock:
|
||||
@@ -1950,6 +1945,28 @@ def stop_aprs() -> Response:
|
||||
if not processes_to_stop:
|
||||
return api_error('APRS decoder not running', 400)
|
||||
|
||||
# Release SDR device immediately so status panel reflects the
|
||||
# change without waiting for process termination.
|
||||
if aprs_active_device is not None:
|
||||
app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
|
||||
aprs_active_device = None
|
||||
aprs_active_sdr_type = None
|
||||
|
||||
# Capture refs to clear before releasing the lock
|
||||
master_fd = getattr(app_module, 'aprs_master_fd', None)
|
||||
app_module.aprs_process = None
|
||||
if hasattr(app_module, 'aprs_rtl_process'):
|
||||
app_module.aprs_rtl_process = None
|
||||
app_module.aprs_master_fd = None
|
||||
|
||||
# Terminate processes in background so the response returns fast.
|
||||
# Each proc.wait() can block up to PROCESS_TERMINATE_TIMEOUT (2s),
|
||||
# which previously caused the frontend 2200ms fetch to abort.
|
||||
def _cleanup():
|
||||
# Close PTY master fd first — this unblocks the stream thread
|
||||
if master_fd is not None:
|
||||
with contextlib.suppress(OSError):
|
||||
os.close(master_fd)
|
||||
for proc in processes_to_stop:
|
||||
try:
|
||||
proc.terminate()
|
||||
@@ -1959,23 +1976,7 @@ def stop_aprs() -> Response:
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping APRS process: {e}")
|
||||
|
||||
# Close PTY master fd
|
||||
if hasattr(app_module, 'aprs_master_fd') and app_module.aprs_master_fd is not None:
|
||||
try:
|
||||
os.close(app_module.aprs_master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
app_module.aprs_master_fd = None
|
||||
|
||||
app_module.aprs_process = None
|
||||
if hasattr(app_module, 'aprs_rtl_process'):
|
||||
app_module.aprs_rtl_process = None
|
||||
|
||||
# Release SDR device
|
||||
if aprs_active_device is not None:
|
||||
app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
|
||||
aprs_active_device = None
|
||||
aprs_active_sdr_type = None
|
||||
threading.Thread(target=_cleanup, daemon=True).start()
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
@@ -2099,7 +2100,7 @@ def scan_aprs_spectrum() -> Response:
|
||||
return api_error('rtl_power did not produce output file', 500)
|
||||
|
||||
bins = []
|
||||
with open(tmp_file, 'r') as f:
|
||||
with open(tmp_file) as f:
|
||||
reader = csv.reader(f)
|
||||
for row in reader:
|
||||
if len(row) < 7:
|
||||
|
||||
@@ -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")
|
||||
|
||||
+14
-27
@@ -2,8 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import contextlib
|
||||
import os
|
||||
import platform
|
||||
import pty
|
||||
@@ -13,30 +12,22 @@ 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
|
||||
|
||||
from utils.responses import api_success, api_error
|
||||
import app as app_module
|
||||
from utils.dependencies import check_tool
|
||||
from utils.logging import bluetooth_logger as logger
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.validation import validate_bluetooth_interface
|
||||
from 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')
|
||||
|
||||
@@ -328,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)})
|
||||
@@ -485,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:
|
||||
@@ -507,7 +494,7 @@ 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
|
||||
})
|
||||
|
||||
|
||||
+560
-535
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -12,7 +12,6 @@ from collections.abc import Generator
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
from utils.responses import api_success, api_error
|
||||
from utils.bluetooth.irk_extractor import get_paired_irks
|
||||
from utils.bt_locate import (
|
||||
Environment,
|
||||
@@ -22,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')
|
||||
|
||||
+327
-323
File diff suppressed because it is too large
Load Diff
@@ -2,12 +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.responses import api_success, api_error
|
||||
from utils.logging import get_logger
|
||||
from utils.responses import api_error, api_success
|
||||
|
||||
logger = get_logger('intercept.correlation')
|
||||
|
||||
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
"""Drone intelligence routes — multi-vector UAV detection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import queue
|
||||
import subprocess
|
||||
import threading
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
import app as app_module
|
||||
from utils.constants import SSE_KEEPALIVE_INTERVAL, SSE_QUEUE_TIMEOUT
|
||||
from utils.drone.correlator import DroneCorrelator
|
||||
from utils.drone.remote_id import RemoteIDScanner
|
||||
from utils.drone.rf_detector import RFDetector
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.validation import validate_device_index
|
||||
|
||||
logger = logging.getLogger("intercept.drone")
|
||||
|
||||
drone_bp = Blueprint("drone", __name__, url_prefix="/drone")
|
||||
|
||||
_correlator: DroneCorrelator | None = None
|
||||
_remote_id_scanner: RemoteIDScanner | None = None
|
||||
_rf_detector: RFDetector | None = None
|
||||
_obs_queue: queue.Queue | None = None # raw observations from scanners/detectors
|
||||
_relay_thread: threading.Thread | None = None
|
||||
_drone_running = False
|
||||
_drone_lock = threading.Lock()
|
||||
|
||||
_SENTINEL = object()
|
||||
|
||||
|
||||
def _relay_observations() -> None:
|
||||
"""Read raw observations from _obs_queue and feed them into the correlator."""
|
||||
while True:
|
||||
obs = _obs_queue.get()
|
||||
if obs is _SENTINEL:
|
||||
break
|
||||
if _correlator is not None:
|
||||
_correlator.process(obs)
|
||||
|
||||
|
||||
def _ensure_workers() -> None:
|
||||
global _correlator, _remote_id_scanner, _rf_detector, _obs_queue, _relay_thread
|
||||
if _obs_queue is None:
|
||||
_obs_queue = queue.Queue(maxsize=512)
|
||||
if _correlator is None:
|
||||
_correlator = DroneCorrelator(output_queue=app_module.drone_queue)
|
||||
if _remote_id_scanner is None:
|
||||
_remote_id_scanner = RemoteIDScanner(output_queue=_obs_queue)
|
||||
if _rf_detector is None:
|
||||
_rf_detector = RFDetector(output_queue=_obs_queue)
|
||||
if _relay_thread is None or not _relay_thread.is_alive():
|
||||
_relay_thread = threading.Thread(target=_relay_observations, daemon=True)
|
||||
_relay_thread.start()
|
||||
|
||||
|
||||
@drone_bp.route("/devices")
|
||||
def devices():
|
||||
"""Return available WiFi interfaces and SDR devices for drone detection."""
|
||||
result: dict = {"wifi_interfaces": [], "sdr_devices": []}
|
||||
|
||||
# WiFi interfaces via iw/iwconfig
|
||||
if platform.system() == "Darwin":
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["networksetup", "-listallhardwareports"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
).stdout
|
||||
lines = out.split("\n")
|
||||
for i, line in enumerate(lines):
|
||||
if "Wi-Fi" in line or "AirPort" in line:
|
||||
port = line.replace("Hardware Port:", "").strip()
|
||||
for j in range(i + 1, min(i + 3, len(lines))):
|
||||
if "Device:" in lines[j]:
|
||||
dev = lines[j].split("Device:")[1].strip()
|
||||
result["wifi_interfaces"].append(
|
||||
{
|
||||
"name": dev,
|
||||
"display_name": f"{port} ({dev})",
|
||||
"type": "internal",
|
||||
"monitor_capable": False,
|
||||
}
|
||||
)
|
||||
break
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
out = subprocess.run(["iw", "dev"], capture_output=True, text=True, timeout=5).stdout
|
||||
current: str | None = None
|
||||
for line in out.split("\n"):
|
||||
line = line.strip()
|
||||
if line.startswith("Interface"):
|
||||
current = line.split()[1]
|
||||
elif current and "type" in line:
|
||||
iface_type = line.split()[-1]
|
||||
result["wifi_interfaces"].append(
|
||||
{
|
||||
"name": current,
|
||||
"display_name": f"{current} ({iface_type})",
|
||||
"type": iface_type,
|
||||
"monitor_capable": True,
|
||||
}
|
||||
)
|
||||
current = None
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
|
||||
try:
|
||||
out = subprocess.run(["iwconfig"], capture_output=True, text=True, timeout=5).stdout
|
||||
for line in out.split("\n"):
|
||||
if "IEEE 802.11" in line:
|
||||
iface = line.split()[0]
|
||||
result["wifi_interfaces"].append(
|
||||
{
|
||||
"name": iface,
|
||||
"display_name": f"{iface} (managed)",
|
||||
"type": "managed",
|
||||
"monitor_capable": True,
|
||||
}
|
||||
)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
|
||||
pass
|
||||
|
||||
# SDR devices
|
||||
try:
|
||||
from utils.sdr import SDRFactory
|
||||
|
||||
for sdr in SDRFactory.detect_devices():
|
||||
sdr_type = sdr.sdr_type.value if hasattr(sdr.sdr_type, "value") else str(sdr.sdr_type)
|
||||
display = sdr.name
|
||||
if sdr.serial and sdr.serial not in ("N/A", "Unknown"):
|
||||
display = f"{sdr.name} (SN: {sdr.serial[-8:]})"
|
||||
result["sdr_devices"].append(
|
||||
{"index": sdr.index, "name": sdr.name, "display_name": display, "type": sdr_type}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
running_as_root = os.geteuid() == 0
|
||||
warnings = []
|
||||
if not running_as_root:
|
||||
warnings.append(
|
||||
{
|
||||
"type": "privileges",
|
||||
"message": "Not running as root — WiFi monitor mode may be unavailable.",
|
||||
}
|
||||
)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"devices": result,
|
||||
"running_as_root": running_as_root,
|
||||
"warnings": warnings,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@drone_bp.route("/status")
|
||||
def status():
|
||||
vectors = []
|
||||
if _remote_id_scanner and _remote_id_scanner.running:
|
||||
vectors.append("REMOTE_ID")
|
||||
if _rf_detector and _rf_detector.running:
|
||||
vectors.append("RF")
|
||||
return jsonify(
|
||||
{
|
||||
"running": _drone_running,
|
||||
"vectors": vectors,
|
||||
"contact_count": len(_correlator.get_all()) if _correlator else 0,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@drone_bp.route("/contacts")
|
||||
def contacts():
|
||||
if not _correlator:
|
||||
return jsonify([])
|
||||
return jsonify(_correlator.get_all())
|
||||
|
||||
|
||||
@drone_bp.route("/start", methods=["POST"])
|
||||
def start():
|
||||
global _drone_running
|
||||
body = request.json or {}
|
||||
wifi_iface = body.get("wifi_iface") or None
|
||||
try:
|
||||
rtl_index = validate_device_index(body.get("rtl_sdr_index", 0))
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
use_hackrf = bool(body.get("use_hackrf", True))
|
||||
|
||||
with _drone_lock:
|
||||
_ensure_workers()
|
||||
if not _drone_running:
|
||||
if _remote_id_scanner:
|
||||
_remote_id_scanner.start(wifi_iface=wifi_iface)
|
||||
if _rf_detector:
|
||||
_rf_detector.start(rtl_sdr_index=rtl_index, use_hackrf=use_hackrf)
|
||||
_drone_running = True
|
||||
logger.info("Drone detection started")
|
||||
|
||||
return jsonify({"status": "ok", "running": True})
|
||||
|
||||
|
||||
@drone_bp.route("/stop", methods=["POST"])
|
||||
def stop():
|
||||
global _drone_running
|
||||
with _drone_lock:
|
||||
if _remote_id_scanner:
|
||||
_remote_id_scanner.stop()
|
||||
if _rf_detector:
|
||||
_rf_detector.stop()
|
||||
if _obs_queue is not None:
|
||||
_obs_queue.put_nowait(_SENTINEL)
|
||||
_drone_running = False
|
||||
logger.info("Drone detection stopped")
|
||||
return jsonify({"status": "ok", "running": False})
|
||||
|
||||
|
||||
@drone_bp.route("/stream")
|
||||
def stream():
|
||||
return Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.drone_queue,
|
||||
channel_key="drone",
|
||||
timeout=SSE_QUEUE_TIMEOUT,
|
||||
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||
),
|
||||
mimetype="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
+19
-32
@@ -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,37 +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
|
||||
|
||||
from utils.responses import api_success, api_error
|
||||
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.dependencies import get_tool_path
|
||||
from utils.dsc.parser import parse_dsc_message
|
||||
from utils.sse import sse_stream_fanout
|
||||
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,
|
||||
)
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.dependencies import get_tool_path
|
||||
from utils.process import register_process, unregister_process
|
||||
|
||||
logger = logging.getLogger('intercept.dsc')
|
||||
|
||||
@@ -83,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
|
||||
@@ -179,10 +178,8 @@ def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> Non
|
||||
})
|
||||
finally:
|
||||
global dsc_active_device, dsc_active_sdr_type
|
||||
try:
|
||||
with contextlib.suppress(OSError):
|
||||
os.close(master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
dsc_running = False
|
||||
# Cleanup both processes
|
||||
with app_module.dsc_lock:
|
||||
@@ -193,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:
|
||||
@@ -466,10 +461,8 @@ 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, dsc_active_sdr_type or 'rtlsdr')
|
||||
@@ -485,10 +478,8 @@ 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, dsc_active_sdr_type or 'rtlsdr')
|
||||
@@ -518,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
|
||||
|
||||
@@ -531,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
|
||||
|
||||
|
||||
@@ -3,12 +3,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
import time
|
||||
from collections.abc import Generator
|
||||
|
||||
from flask import Blueprint, Response, jsonify
|
||||
|
||||
from utils.responses import api_success, api_error
|
||||
from utils.gps import (
|
||||
GPSPosition,
|
||||
GPSSkyData,
|
||||
|
||||
@@ -0,0 +1,567 @@
|
||||
"""Ground Station REST API + SSE + WebSocket endpoints.
|
||||
|
||||
Phases implemented here:
|
||||
1 — Profile CRUD, scheduler control, observation history, SSE stream
|
||||
3 — SigMF recording browser (list / download / delete)
|
||||
5 — /ws/satellite_waterfall WebSocket
|
||||
6 — Rotator config / status / point / park endpoints
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import queue
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request, send_file
|
||||
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import sse_stream_fanout
|
||||
|
||||
logger = get_logger('intercept.ground_station.routes')
|
||||
|
||||
ground_station_bp = Blueprint('ground_station', __name__, url_prefix='/ground_station')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_scheduler():
|
||||
from utils.ground_station.scheduler import get_ground_station_scheduler
|
||||
return get_ground_station_scheduler()
|
||||
|
||||
|
||||
def _get_queue():
|
||||
import app as _app
|
||||
return getattr(_app, 'ground_station_queue', None) or queue.Queue()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 1 — Observation Profiles
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@ground_station_bp.route('/profiles', methods=['GET'])
|
||||
def list_profiles():
|
||||
from utils.ground_station.observation_profile import list_profiles as _list
|
||||
return jsonify([p.to_dict() for p in _list()])
|
||||
|
||||
|
||||
@ground_station_bp.route('/profiles/<int:norad_id>', methods=['GET'])
|
||||
def get_profile(norad_id: int):
|
||||
from utils.ground_station.observation_profile import get_profile as _get
|
||||
p = _get(norad_id)
|
||||
if not p:
|
||||
return jsonify({'error': f'No profile for NORAD {norad_id}'}), 404
|
||||
return jsonify(p.to_dict())
|
||||
|
||||
|
||||
@ground_station_bp.route('/profiles', methods=['POST'])
|
||||
def create_profile():
|
||||
data = request.get_json(force=True) or {}
|
||||
try:
|
||||
_validate_profile(data)
|
||||
except ValueError as e:
|
||||
return jsonify({'error': str(e)}), 400
|
||||
|
||||
from utils.ground_station.observation_profile import (
|
||||
ObservationProfile,
|
||||
legacy_decoder_to_tasks,
|
||||
normalize_tasks,
|
||||
save_profile,
|
||||
tasks_to_legacy_decoder,
|
||||
)
|
||||
tasks = normalize_tasks(data.get('tasks'))
|
||||
if not tasks:
|
||||
tasks = legacy_decoder_to_tasks(
|
||||
str(data.get('decoder_type', 'fm')),
|
||||
bool(data.get('record_iq', False)),
|
||||
)
|
||||
profile = ObservationProfile(
|
||||
norad_id=int(data['norad_id']),
|
||||
name=str(data['name']),
|
||||
frequency_mhz=float(data['frequency_mhz']),
|
||||
decoder_type=tasks_to_legacy_decoder(tasks),
|
||||
gain=float(data.get('gain', 40.0)),
|
||||
bandwidth_hz=int(data.get('bandwidth_hz', 200_000)),
|
||||
min_elevation=float(data.get('min_elevation', 10.0)),
|
||||
enabled=bool(data.get('enabled', True)),
|
||||
record_iq=bool(data.get('record_iq', False)) or ('record_iq' in tasks),
|
||||
iq_sample_rate=int(data.get('iq_sample_rate', 2_400_000)),
|
||||
tasks=tasks,
|
||||
)
|
||||
saved = save_profile(profile)
|
||||
return jsonify(saved.to_dict()), 201
|
||||
|
||||
|
||||
@ground_station_bp.route('/profiles/<int:norad_id>', methods=['PUT'])
|
||||
def update_profile(norad_id: int):
|
||||
data = request.get_json(force=True) or {}
|
||||
from utils.ground_station.observation_profile import (
|
||||
get_profile as _get,
|
||||
)
|
||||
from utils.ground_station.observation_profile import (
|
||||
legacy_decoder_to_tasks,
|
||||
normalize_tasks,
|
||||
save_profile,
|
||||
tasks_to_legacy_decoder,
|
||||
)
|
||||
existing = _get(norad_id)
|
||||
if not existing:
|
||||
return jsonify({'error': f'No profile for NORAD {norad_id}'}), 404
|
||||
|
||||
# Apply updates
|
||||
for field, cast in [
|
||||
('name', str), ('frequency_mhz', float), ('decoder_type', str),
|
||||
('gain', float), ('bandwidth_hz', int), ('min_elevation', float),
|
||||
]:
|
||||
if field in data:
|
||||
setattr(existing, field, cast(data[field]))
|
||||
for field in ('enabled', 'record_iq'):
|
||||
if field in data:
|
||||
setattr(existing, field, bool(data[field]))
|
||||
if 'iq_sample_rate' in data:
|
||||
existing.iq_sample_rate = int(data['iq_sample_rate'])
|
||||
if 'tasks' in data:
|
||||
existing.tasks = normalize_tasks(data['tasks'])
|
||||
elif 'decoder_type' in data:
|
||||
existing.tasks = legacy_decoder_to_tasks(
|
||||
str(data.get('decoder_type', existing.decoder_type)),
|
||||
bool(data.get('record_iq', existing.record_iq)),
|
||||
)
|
||||
|
||||
existing.decoder_type = tasks_to_legacy_decoder(existing.tasks)
|
||||
existing.record_iq = bool(existing.record_iq) or ('record_iq' in existing.tasks)
|
||||
|
||||
saved = save_profile(existing)
|
||||
return jsonify(saved.to_dict())
|
||||
|
||||
|
||||
@ground_station_bp.route('/profiles/<int:norad_id>', methods=['DELETE'])
|
||||
def delete_profile(norad_id: int):
|
||||
from utils.ground_station.observation_profile import delete_profile as _del
|
||||
ok = _del(norad_id)
|
||||
if not ok:
|
||||
return jsonify({'error': f'No profile for NORAD {norad_id}'}), 404
|
||||
return jsonify({'status': 'deleted', 'norad_id': norad_id})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 1 — Scheduler control
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@ground_station_bp.route('/scheduler/status', methods=['GET'])
|
||||
def scheduler_status():
|
||||
return jsonify(_get_scheduler().get_status())
|
||||
|
||||
|
||||
@ground_station_bp.route('/scheduler/enable', methods=['POST'])
|
||||
def scheduler_enable():
|
||||
data = request.get_json(force=True) or {}
|
||||
try:
|
||||
lat = float(data.get('lat', 0.0))
|
||||
lon = float(data.get('lon', 0.0))
|
||||
device = int(data.get('device', 0))
|
||||
sdr_type = str(data.get('sdr_type', 'rtlsdr'))
|
||||
except (TypeError, ValueError) as e:
|
||||
return jsonify({'error': str(e)}), 400
|
||||
|
||||
status = _get_scheduler().enable(lat=lat, lon=lon, device=device, sdr_type=sdr_type)
|
||||
return jsonify(status)
|
||||
|
||||
|
||||
@ground_station_bp.route('/scheduler/disable', methods=['POST'])
|
||||
def scheduler_disable():
|
||||
return jsonify(_get_scheduler().disable())
|
||||
|
||||
|
||||
@ground_station_bp.route('/scheduler/observations', methods=['GET'])
|
||||
def get_observations():
|
||||
return jsonify(_get_scheduler().get_scheduled_observations())
|
||||
|
||||
|
||||
@ground_station_bp.route('/scheduler/trigger/<int:norad_id>', methods=['POST'])
|
||||
def trigger_manual(norad_id: int):
|
||||
ok, msg = _get_scheduler().trigger_manual(norad_id)
|
||||
if not ok:
|
||||
return jsonify({'error': msg}), 400
|
||||
return jsonify({'status': 'started', 'message': msg})
|
||||
|
||||
|
||||
@ground_station_bp.route('/scheduler/stop', methods=['POST'])
|
||||
def stop_active():
|
||||
return jsonify(_get_scheduler().stop_active())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 1 — Observation history (from DB)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@ground_station_bp.route('/observations', methods=['GET'])
|
||||
def observation_history():
|
||||
limit = min(int(request.args.get('limit', 50)), 200)
|
||||
try:
|
||||
from utils.database import get_db
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(
|
||||
'''SELECT * FROM ground_station_observations
|
||||
ORDER BY created_at DESC LIMIT ?''',
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return jsonify([dict(r) for r in rows])
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch observation history: {e}")
|
||||
return jsonify([])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 1 — SSE stream
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@ground_station_bp.route('/stream')
|
||||
def sse_stream():
|
||||
gs_queue = _get_queue()
|
||||
return Response(
|
||||
sse_stream_fanout(gs_queue, 'ground_station'),
|
||||
mimetype='text/event-stream',
|
||||
headers={
|
||||
'Cache-Control': 'no-cache',
|
||||
'X-Accel-Buffering': 'no',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 3 — SigMF recording browser
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@ground_station_bp.route('/recordings', methods=['GET'])
|
||||
def list_recordings():
|
||||
try:
|
||||
from utils.database import get_db
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(
|
||||
'SELECT * FROM sigmf_recordings ORDER BY created_at DESC LIMIT 100'
|
||||
).fetchall()
|
||||
return jsonify([dict(r) for r in rows])
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch recordings: {e}")
|
||||
return jsonify([])
|
||||
|
||||
|
||||
@ground_station_bp.route('/recordings/<int:rec_id>', methods=['GET'])
|
||||
def get_recording(rec_id: int):
|
||||
try:
|
||||
from utils.database import get_db
|
||||
with get_db() as conn:
|
||||
row = conn.execute(
|
||||
'SELECT * FROM sigmf_recordings WHERE id=?', (rec_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return jsonify({'error': 'Not found'}), 404
|
||||
return jsonify(dict(row))
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@ground_station_bp.route('/recordings/<int:rec_id>', methods=['DELETE'])
|
||||
def delete_recording(rec_id: int):
|
||||
try:
|
||||
from utils.database import get_db
|
||||
with get_db() as conn:
|
||||
row = conn.execute(
|
||||
'SELECT sigmf_data_path, sigmf_meta_path FROM sigmf_recordings WHERE id=?',
|
||||
(rec_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return jsonify({'error': 'Not found'}), 404
|
||||
# Remove files
|
||||
for path_col in ('sigmf_data_path', 'sigmf_meta_path'):
|
||||
p = Path(row[path_col])
|
||||
if p.exists():
|
||||
p.unlink(missing_ok=True)
|
||||
conn.execute('DELETE FROM sigmf_recordings WHERE id=?', (rec_id,))
|
||||
return jsonify({'status': 'deleted', 'id': rec_id})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@ground_station_bp.route('/recordings/<int:rec_id>/download/<file_type>')
|
||||
def download_recording(rec_id: int, file_type: str):
|
||||
if file_type not in ('data', 'meta'):
|
||||
return jsonify({'error': 'file_type must be data or meta'}), 400
|
||||
try:
|
||||
from utils.database import get_db
|
||||
with get_db() as conn:
|
||||
row = conn.execute(
|
||||
'SELECT sigmf_data_path, sigmf_meta_path FROM sigmf_recordings WHERE id=?',
|
||||
(rec_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return jsonify({'error': 'Not found'}), 404
|
||||
|
||||
col = 'sigmf_data_path' if file_type == 'data' else 'sigmf_meta_path'
|
||||
p = Path(row[col])
|
||||
if not p.exists():
|
||||
return jsonify({'error': 'File not found on disk'}), 404
|
||||
|
||||
mimetype = 'application/octet-stream' if file_type == 'data' else 'application/json'
|
||||
return send_file(p, mimetype=mimetype, as_attachment=True, download_name=p.name)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@ground_station_bp.route('/outputs', methods=['GET'])
|
||||
def list_outputs():
|
||||
try:
|
||||
query = '''
|
||||
SELECT * FROM ground_station_outputs
|
||||
WHERE (? IS NULL OR norad_id = ?)
|
||||
AND (? IS NULL OR observation_id = ?)
|
||||
AND (? IS NULL OR output_type = ?)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 200
|
||||
'''
|
||||
norad_id = request.args.get('norad_id', type=int)
|
||||
observation_id = request.args.get('observation_id', type=int)
|
||||
output_type = request.args.get('type')
|
||||
|
||||
from utils.database import get_db
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(
|
||||
query,
|
||||
(
|
||||
norad_id, norad_id,
|
||||
observation_id, observation_id,
|
||||
output_type, output_type,
|
||||
),
|
||||
).fetchall()
|
||||
|
||||
results = []
|
||||
for row in rows:
|
||||
item = dict(row)
|
||||
metadata_raw = item.get('metadata_json')
|
||||
if metadata_raw:
|
||||
try:
|
||||
item['metadata'] = json.loads(metadata_raw)
|
||||
except json.JSONDecodeError:
|
||||
item['metadata'] = {}
|
||||
else:
|
||||
item['metadata'] = {}
|
||||
item.pop('metadata_json', None)
|
||||
results.append(item)
|
||||
return jsonify(results)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@ground_station_bp.route('/outputs/<int:output_id>/download', methods=['GET'])
|
||||
def download_output(output_id: int):
|
||||
try:
|
||||
from utils.database import get_db
|
||||
with get_db() as conn:
|
||||
row = conn.execute(
|
||||
'SELECT file_path FROM ground_station_outputs WHERE id=?',
|
||||
(output_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return jsonify({'error': 'Not found'}), 404
|
||||
p = Path(row['file_path'])
|
||||
if not p.exists():
|
||||
return jsonify({'error': 'File not found on disk'}), 404
|
||||
return send_file(p, as_attachment=True, download_name=p.name)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@ground_station_bp.route('/decode-jobs', methods=['GET'])
|
||||
def list_decode_jobs():
|
||||
try:
|
||||
query = '''
|
||||
SELECT * FROM ground_station_decode_jobs
|
||||
WHERE (? IS NULL OR norad_id = ?)
|
||||
AND (? IS NULL OR observation_id = ?)
|
||||
AND (? IS NULL OR backend = ?)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
'''
|
||||
norad_id = request.args.get('norad_id', type=int)
|
||||
observation_id = request.args.get('observation_id', type=int)
|
||||
backend = request.args.get('backend')
|
||||
limit = min(request.args.get('limit', 20, type=int) or 20, 200)
|
||||
|
||||
from utils.database import get_db
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(
|
||||
query,
|
||||
(
|
||||
norad_id, norad_id,
|
||||
observation_id, observation_id,
|
||||
backend, backend,
|
||||
limit,
|
||||
),
|
||||
).fetchall()
|
||||
|
||||
results = []
|
||||
for row in rows:
|
||||
item = dict(row)
|
||||
details_raw = item.get('details_json')
|
||||
if details_raw:
|
||||
try:
|
||||
item['details'] = json.loads(details_raw)
|
||||
except json.JSONDecodeError:
|
||||
item['details'] = {}
|
||||
else:
|
||||
item['details'] = {}
|
||||
item.pop('details_json', None)
|
||||
results.append(item)
|
||||
return jsonify(results)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 5 — Live waterfall WebSocket
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def init_ground_station_websocket(app) -> None:
|
||||
"""Register the /ws/satellite_waterfall WebSocket endpoint."""
|
||||
try:
|
||||
from flask_sock import Sock
|
||||
except ImportError:
|
||||
logger.warning("flask-sock not installed — satellite waterfall WebSocket disabled")
|
||||
return
|
||||
|
||||
sock = Sock(app)
|
||||
|
||||
@sock.route('/ws/satellite_waterfall')
|
||||
def satellite_waterfall_ws(ws):
|
||||
"""Stream binary waterfall frames from the active ground station IQ bus."""
|
||||
scheduler = _get_scheduler()
|
||||
wf_queue = scheduler.waterfall_queue
|
||||
|
||||
from utils.sse import subscribe_fanout_queue
|
||||
sub_queue, unsubscribe = subscribe_fanout_queue(
|
||||
source_queue=wf_queue,
|
||||
channel_key='gs_waterfall',
|
||||
subscriber_queue_size=120,
|
||||
)
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
frame = sub_queue.get(timeout=1.0)
|
||||
try:
|
||||
ws.send(frame)
|
||||
except Exception:
|
||||
break
|
||||
except queue.Empty:
|
||||
if not ws.connected:
|
||||
break
|
||||
finally:
|
||||
unsubscribe()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 6 — Rotator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@ground_station_bp.route('/rotator/status', methods=['GET'])
|
||||
def rotator_status():
|
||||
from utils.rotator import get_rotator
|
||||
return jsonify(get_rotator().get_status())
|
||||
|
||||
|
||||
@ground_station_bp.route('/rotator/config', methods=['POST'])
|
||||
def rotator_config():
|
||||
data = request.get_json(force=True) or {}
|
||||
host = str(data.get('host', '127.0.0.1'))
|
||||
port = int(data.get('port', 4533))
|
||||
from utils.rotator import get_rotator
|
||||
ok = get_rotator().connect(host, port)
|
||||
if not ok:
|
||||
return jsonify({'error': f'Could not connect to rotctld at {host}:{port}'}), 503
|
||||
return jsonify(get_rotator().get_status())
|
||||
|
||||
|
||||
@ground_station_bp.route('/rotator/point', methods=['POST'])
|
||||
def rotator_point():
|
||||
data = request.get_json(force=True) or {}
|
||||
try:
|
||||
az = float(data['az'])
|
||||
el = float(data['el'])
|
||||
except (KeyError, TypeError, ValueError) as e:
|
||||
return jsonify({'error': f'az and el required: {e}'}), 400
|
||||
from utils.rotator import get_rotator
|
||||
ok = get_rotator().point_to(az, el)
|
||||
if not ok:
|
||||
return jsonify({'error': 'Rotator command failed'}), 503
|
||||
return jsonify({'status': 'ok', 'az': az, 'el': el})
|
||||
|
||||
|
||||
@ground_station_bp.route('/rotator/park', methods=['POST'])
|
||||
def rotator_park():
|
||||
from utils.rotator import get_rotator
|
||||
ok = get_rotator().park()
|
||||
if not ok:
|
||||
return jsonify({'error': 'Rotator park failed'}), 503
|
||||
return jsonify({'status': 'parked'})
|
||||
|
||||
|
||||
@ground_station_bp.route('/rotator/disconnect', methods=['POST'])
|
||||
def rotator_disconnect():
|
||||
from utils.rotator import get_rotator
|
||||
get_rotator().disconnect()
|
||||
return jsonify({'status': 'disconnected'})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Input validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _validate_profile(data: dict) -> None:
|
||||
if 'norad_id' not in data:
|
||||
raise ValueError("norad_id is required")
|
||||
if 'name' not in data:
|
||||
raise ValueError("name is required")
|
||||
if 'frequency_mhz' not in data:
|
||||
raise ValueError("frequency_mhz is required")
|
||||
try:
|
||||
norad_id = int(data['norad_id'])
|
||||
if norad_id <= 0:
|
||||
raise ValueError("norad_id must be positive")
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError("norad_id must be a positive integer")
|
||||
try:
|
||||
freq = float(data['frequency_mhz'])
|
||||
if not (0.1 <= freq <= 3000.0):
|
||||
raise ValueError("frequency_mhz must be between 0.1 and 3000")
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError("frequency_mhz must be a number between 0.1 and 3000")
|
||||
from utils.ground_station.observation_profile import VALID_TASK_TYPES
|
||||
|
||||
valid_decoders = {'fm', 'afsk', 'gmsk', 'bpsk', 'iq_only'}
|
||||
if 'tasks' in data:
|
||||
if not isinstance(data['tasks'], list):
|
||||
raise ValueError("tasks must be a list")
|
||||
invalid = [
|
||||
str(task) for task in data['tasks']
|
||||
if str(task).strip().lower() not in VALID_TASK_TYPES
|
||||
]
|
||||
if invalid:
|
||||
raise ValueError(
|
||||
f"tasks contains unsupported values: {', '.join(invalid)}"
|
||||
)
|
||||
else:
|
||||
dt = str(data.get('decoder_type', 'fm'))
|
||||
if dt not in valid_decoders:
|
||||
raise ValueError(f"decoder_type must be one of: {', '.join(sorted(valid_decoders))}")
|
||||
@@ -11,8 +11,8 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import queue
|
||||
import signal
|
||||
import shutil
|
||||
import signal
|
||||
import struct
|
||||
import subprocess
|
||||
import threading
|
||||
@@ -22,15 +22,15 @@ from typing import Dict, List, Optional
|
||||
|
||||
from flask import Blueprint
|
||||
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.constants import (
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
PROCESS_TERMINATE_TIMEOUT,
|
||||
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')
|
||||
|
||||
@@ -39,6 +39,8 @@ 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
|
||||
|
||||
# ============================================
|
||||
@@ -57,16 +59,16 @@ audio_source = 'process'
|
||||
audio_start_token = 0
|
||||
|
||||
# Scanner state
|
||||
scanner_thread: Optional[threading.Thread] = None
|
||||
scanner_thread: threading.Thread | None = None
|
||||
scanner_running = False
|
||||
scanner_lock = threading.Lock()
|
||||
scanner_paused = False
|
||||
scanner_current_freq = 0.0
|
||||
scanner_active_device: Optional[int] = None
|
||||
scanner_active_device: int | None = None
|
||||
scanner_active_sdr_type: str = 'rtlsdr'
|
||||
receiver_active_device: Optional[int] = None
|
||||
receiver_active_device: int | None = None
|
||||
receiver_active_sdr_type: str = 'rtlsdr'
|
||||
scanner_power_process: Optional[subprocess.Popen] = None
|
||||
scanner_power_process: subprocess.Popen | None = None
|
||||
scanner_config = {
|
||||
'start_freq': 88.0,
|
||||
'end_freq': 108.0,
|
||||
@@ -84,7 +86,7 @@ scanner_config = {
|
||||
}
|
||||
|
||||
# Activity log
|
||||
activity_log: List[Dict] = []
|
||||
activity_log: list[dict] = []
|
||||
activity_log_lock = threading.Lock()
|
||||
MAX_LOG_ENTRIES = 500
|
||||
|
||||
@@ -95,12 +97,12 @@ scanner_queue: queue.Queue = queue.Queue(maxsize=100)
|
||||
scanner_skip_signal = False
|
||||
|
||||
# Waterfall / spectrogram state
|
||||
waterfall_process: Optional[subprocess.Popen] = None
|
||||
waterfall_thread: Optional[threading.Thread] = None
|
||||
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: Optional[int] = None
|
||||
waterfall_active_device: int | None = None
|
||||
waterfall_active_sdr_type: str = 'rtlsdr'
|
||||
waterfall_config = {
|
||||
'start_freq': 88.0,
|
||||
@@ -185,13 +187,11 @@ def add_activity_log(event_type: str, frequency: float, details: str = ''):
|
||||
activity_log.pop()
|
||||
|
||||
# Also push to SSE queue
|
||||
try:
|
||||
with contextlib.suppress(queue.Full):
|
||||
scanner_queue.put_nowait({
|
||||
'type': 'log',
|
||||
'entry': entry
|
||||
})
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
|
||||
def _start_audio_stream(
|
||||
@@ -348,12 +348,12 @@ def _start_audio_stream(
|
||||
rtl_stderr = ''
|
||||
ffmpeg_stderr = ''
|
||||
try:
|
||||
with open(rtl_stderr_log, 'r') as f:
|
||||
with open(rtl_stderr_log) as f:
|
||||
rtl_stderr = f.read().strip()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
with open(ffmpeg_stderr_log, 'r') as f:
|
||||
with open(ffmpeg_stderr_log) as f:
|
||||
ffmpeg_stderr = f.read().strip()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -502,10 +502,8 @@ def _stop_waterfall_internal() -> None:
|
||||
waterfall_process.terminate()
|
||||
waterfall_process.wait(timeout=1)
|
||||
except Exception:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
waterfall_process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
waterfall_process = None
|
||||
|
||||
if waterfall_active_device is not None:
|
||||
@@ -517,7 +515,9 @@ def _stop_waterfall_internal() -> None:
|
||||
# ============================================
|
||||
# Import sub-modules to register routes on receiver_bp
|
||||
# ============================================
|
||||
from . import scanner # noqa: E402, F401
|
||||
from . import audio # noqa: E402, F401
|
||||
from . import waterfall # noqa: E402, F401
|
||||
from . import tools # noqa: E402, F401
|
||||
from . import (
|
||||
audio, # noqa: E402, F401
|
||||
scanner, # noqa: E402, F401
|
||||
tools, # noqa: E402, F401
|
||||
waterfall, # noqa: E402, F401
|
||||
)
|
||||
|
||||
@@ -2,27 +2,27 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import select
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from flask import jsonify, request, Response
|
||||
from flask import Response, jsonify, request
|
||||
|
||||
import routes.listening_post as _state
|
||||
|
||||
from . import (
|
||||
receiver_bp,
|
||||
logger,
|
||||
app_module,
|
||||
scanner_config,
|
||||
_wav_header,
|
||||
_start_audio_stream,
|
||||
_stop_audio_stream,
|
||||
_stop_waterfall_internal,
|
||||
_wav_header,
|
||||
app_module,
|
||||
logger,
|
||||
normalize_modulation,
|
||||
receiver_bp,
|
||||
scanner_config,
|
||||
)
|
||||
import routes.listening_post as _state
|
||||
|
||||
|
||||
# ============================================
|
||||
# MANUAL AUDIO ENDPOINTS (for direct listening)
|
||||
@@ -106,23 +106,17 @@ def start_audio() -> Response:
|
||||
# Scanner teardown outside lock (blocking: thread join, process wait, pkill, sleep)
|
||||
if need_scanner_teardown:
|
||||
if scanner_thread_ref and scanner_thread_ref.is_alive():
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
scanner_thread_ref.join(timeout=2.0)
|
||||
except Exception:
|
||||
pass
|
||||
if scanner_proc_ref and scanner_proc_ref.poll() is None:
|
||||
try:
|
||||
scanner_proc_ref.terminate()
|
||||
scanner_proc_ref.wait(timeout=1)
|
||||
except Exception:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
scanner_proc_ref.kill()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
subprocess.run(['pkill', '-9', 'rtl_power'], capture_output=True, timeout=0.5)
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(0.5)
|
||||
|
||||
# Re-acquire lock for waterfall check and device claim
|
||||
@@ -232,7 +226,7 @@ def start_audio() -> Response:
|
||||
start_error = ''
|
||||
for log_path in ('/tmp/rtl_fm_stderr.log', '/tmp/ffmpeg_stderr.log'):
|
||||
try:
|
||||
with open(log_path, 'r') as handle:
|
||||
with open(log_path) as handle:
|
||||
content = handle.read().strip()
|
||||
if content:
|
||||
start_error = content.splitlines()[-1]
|
||||
@@ -290,7 +284,7 @@ def audio_debug() -> Response:
|
||||
|
||||
def _read_log(path: str) -> str:
|
||||
try:
|
||||
with open(path, 'r') as handle:
|
||||
with open(path) as handle:
|
||||
return handle.read().strip()
|
||||
except Exception:
|
||||
return ''
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import math
|
||||
import queue
|
||||
import struct
|
||||
@@ -10,32 +11,32 @@ import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from flask import jsonify, request, Response
|
||||
from flask import Response, jsonify, request
|
||||
|
||||
import routes.listening_post as _state
|
||||
|
||||
from . import (
|
||||
receiver_bp,
|
||||
logger,
|
||||
app_module,
|
||||
scanner_queue,
|
||||
scanner_config,
|
||||
scanner_lock,
|
||||
activity_log,
|
||||
activity_log_lock,
|
||||
add_activity_log,
|
||||
find_rtl_fm,
|
||||
find_rtl_power,
|
||||
find_rx_fm,
|
||||
normalize_modulation,
|
||||
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,
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
)
|
||||
import routes.listening_post as _state
|
||||
|
||||
|
||||
# ============================================
|
||||
# SCANNER IMPLEMENTATION
|
||||
@@ -76,7 +77,7 @@ def scanner_loop():
|
||||
_state.scanner_current_freq = current_freq
|
||||
|
||||
# Notify clients of frequency change
|
||||
try:
|
||||
with contextlib.suppress(queue.Full):
|
||||
scanner_queue.put_nowait({
|
||||
'type': 'freq_change',
|
||||
'frequency': current_freq,
|
||||
@@ -84,8 +85,6 @@ def scanner_loop():
|
||||
'range_start': scanner_config['start_freq'],
|
||||
'range_end': scanner_config['end_freq']
|
||||
})
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
# Start rtl_fm at this frequency
|
||||
freq_hz = int(current_freq * 1e6)
|
||||
@@ -168,7 +167,7 @@ def scanner_loop():
|
||||
audio_detected = rms > effective_threshold
|
||||
|
||||
# Send level info to clients
|
||||
try:
|
||||
with contextlib.suppress(queue.Full):
|
||||
scanner_queue.put_nowait({
|
||||
'type': 'scan_update',
|
||||
'frequency': current_freq,
|
||||
@@ -178,8 +177,6 @@ def scanner_loop():
|
||||
'range_start': scanner_config['start_freq'],
|
||||
'range_end': scanner_config['end_freq']
|
||||
})
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
if audio_detected and _state.scanner_running:
|
||||
if not signal_detected:
|
||||
@@ -214,13 +211,11 @@ def scanner_loop():
|
||||
_state.scanner_skip_signal = False
|
||||
signal_detected = False
|
||||
_stop_audio_stream()
|
||||
try:
|
||||
with contextlib.suppress(queue.Full):
|
||||
scanner_queue.put_nowait({
|
||||
'type': 'signal_skipped',
|
||||
'frequency': current_freq
|
||||
})
|
||||
except queue.Full:
|
||||
pass
|
||||
# Move to next frequency (step is in kHz, convert to MHz)
|
||||
current_freq += step_mhz
|
||||
if current_freq > scanner_config['end_freq']:
|
||||
@@ -240,15 +235,13 @@ def scanner_loop():
|
||||
if _state.scanner_running and not _state.scanner_skip_signal:
|
||||
signal_detected = False
|
||||
_stop_audio_stream()
|
||||
try:
|
||||
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']
|
||||
})
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
current_freq += step_mhz
|
||||
if current_freq > scanner_config['end_freq']:
|
||||
@@ -268,13 +261,11 @@ def scanner_loop():
|
||||
# Stop audio
|
||||
_stop_audio_stream()
|
||||
|
||||
try:
|
||||
with contextlib.suppress(queue.Full):
|
||||
scanner_queue.put_nowait({
|
||||
'type': 'signal_lost',
|
||||
'frequency': current_freq
|
||||
})
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
# Move to next frequency (step is in kHz, convert to MHz)
|
||||
current_freq += step_mhz
|
||||
@@ -321,7 +312,7 @@ def scanner_loop_power():
|
||||
step_khz = scanner_config['step']
|
||||
gain = scanner_config['gain']
|
||||
device = scanner_config['device']
|
||||
squelch = scanner_config['squelch']
|
||||
scanner_config['squelch']
|
||||
mod = scanner_config['modulation']
|
||||
|
||||
# Configure sweep
|
||||
@@ -355,7 +346,7 @@ def scanner_loop_power():
|
||||
|
||||
if not stdout:
|
||||
add_activity_log('error', start_mhz, 'Power sweep produced no data')
|
||||
try:
|
||||
with contextlib.suppress(queue.Full):
|
||||
scanner_queue.put_nowait({
|
||||
'type': 'scan_update',
|
||||
'frequency': end_mhz,
|
||||
@@ -365,8 +356,6 @@ def scanner_loop_power():
|
||||
'range_start': scanner_config['start_freq'],
|
||||
'range_end': scanner_config['end_freq']
|
||||
})
|
||||
except queue.Full:
|
||||
pass
|
||||
time.sleep(0.2)
|
||||
continue
|
||||
|
||||
@@ -414,7 +403,7 @@ def scanner_loop_power():
|
||||
|
||||
if not segments:
|
||||
add_activity_log('error', start_mhz, 'Power sweep bins missing')
|
||||
try:
|
||||
with contextlib.suppress(queue.Full):
|
||||
scanner_queue.put_nowait({
|
||||
'type': 'scan_update',
|
||||
'frequency': end_mhz,
|
||||
@@ -424,8 +413,6 @@ def scanner_loop_power():
|
||||
'range_start': scanner_config['start_freq'],
|
||||
'range_end': scanner_config['end_freq']
|
||||
})
|
||||
except queue.Full:
|
||||
pass
|
||||
time.sleep(0.2)
|
||||
continue
|
||||
|
||||
@@ -457,7 +444,7 @@ def scanner_loop_power():
|
||||
level = int(max(0, snr) * 100)
|
||||
threshold = int(snr_threshold * 100)
|
||||
progress = min(1.0, (segment_offset + idx) / max(1, total_bins - 1))
|
||||
try:
|
||||
with contextlib.suppress(queue.Full):
|
||||
scanner_queue.put_nowait({
|
||||
'type': 'scan_update',
|
||||
'frequency': _state.scanner_current_freq,
|
||||
@@ -468,8 +455,6 @@ def scanner_loop_power():
|
||||
'range_start': scanner_config['start_freq'],
|
||||
'range_end': scanner_config['end_freq']
|
||||
})
|
||||
except queue.Full:
|
||||
pass
|
||||
segment_offset += len(bin_values)
|
||||
|
||||
# Detect peaks (clusters above threshold)
|
||||
@@ -505,7 +490,7 @@ def scanner_loop_power():
|
||||
threshold = int(snr_threshold * 100)
|
||||
add_activity_log('signal_found', freq_mhz,
|
||||
f'Peak detected at {freq_mhz:.3f} MHz ({mod.upper()})')
|
||||
try:
|
||||
with contextlib.suppress(queue.Full):
|
||||
scanner_queue.put_nowait({
|
||||
'type': 'signal_found',
|
||||
'frequency': freq_mhz,
|
||||
@@ -517,8 +502,6 @@ def scanner_loop_power():
|
||||
'range_start': scanner_config['start_freq'],
|
||||
'range_end': scanner_config['end_freq']
|
||||
})
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
add_activity_log('scan_cycle', start_mhz, 'Power sweep complete')
|
||||
time.sleep(max(0.1, scanner_config.get('scan_delay', 0.5)))
|
||||
@@ -590,9 +573,8 @@ def start_scanner() -> Response:
|
||||
sdr_type = scanner_config['sdr_type']
|
||||
|
||||
# Power scan only supports RTL-SDR for now
|
||||
if scanner_config['scan_method'] == 'power':
|
||||
if sdr_type != 'rtlsdr' or not find_rtl_power():
|
||||
scanner_config['scan_method'] = 'classic'
|
||||
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':
|
||||
@@ -666,10 +648,8 @@ def stop_scanner() -> Response:
|
||||
_state.scanner_power_process.terminate()
|
||||
_state.scanner_power_process.wait(timeout=1)
|
||||
except Exception:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
_state.scanner_power_process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
_state.scanner_power_process = None
|
||||
if _state.scanner_active_device is not None:
|
||||
app_module.release_sdr_device(_state.scanner_active_device, _state.scanner_active_sdr_type)
|
||||
|
||||
@@ -2,18 +2,17 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import jsonify, request, Response
|
||||
from flask import Response, jsonify, request
|
||||
|
||||
from . import (
|
||||
receiver_bp,
|
||||
logger,
|
||||
find_ffmpeg,
|
||||
find_rtl_fm,
|
||||
find_rtl_power,
|
||||
find_rx_fm,
|
||||
find_ffmpeg,
|
||||
logger,
|
||||
receiver_bp,
|
||||
)
|
||||
|
||||
|
||||
# ============================================
|
||||
# TOOL CHECK ENDPOINT
|
||||
# ============================================
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import math
|
||||
import queue
|
||||
import struct
|
||||
@@ -11,23 +12,23 @@ import time
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from flask import jsonify, request, Response
|
||||
from flask import Response, jsonify, request
|
||||
|
||||
from . import (
|
||||
receiver_bp,
|
||||
logger,
|
||||
app_module,
|
||||
_stop_waterfall_internal,
|
||||
process_event,
|
||||
sse_stream_fanout,
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
find_rtl_power,
|
||||
SDRFactory,
|
||||
SDRType,
|
||||
)
|
||||
import routes.listening_post as _state
|
||||
|
||||
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
|
||||
@@ -75,14 +76,12 @@ def _parse_rtl_power_line(line: str) -> tuple[str | None, float | None, float |
|
||||
|
||||
def _queue_waterfall_error(message: str) -> None:
|
||||
"""Push an error message onto the waterfall SSE queue."""
|
||||
try:
|
||||
with contextlib.suppress(queue.Full):
|
||||
_state.waterfall_queue.put_nowait({
|
||||
'type': 'waterfall_error',
|
||||
'message': message,
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
})
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
|
||||
def _downsample_bins(values: list[float], target: int) -> list[float]:
|
||||
@@ -229,14 +228,10 @@ def _waterfall_loop_iq(sdr_type: SDRType):
|
||||
try:
|
||||
_state.waterfall_queue.put_nowait(msg)
|
||||
except queue.Full:
|
||||
try:
|
||||
with contextlib.suppress(queue.Empty):
|
||||
_state.waterfall_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
try:
|
||||
with contextlib.suppress(queue.Full):
|
||||
_state.waterfall_queue.put_nowait(msg)
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
# Throttle to respect interval
|
||||
time.sleep(interval)
|
||||
@@ -254,10 +249,8 @@ def _waterfall_loop_iq(sdr_type: SDRType):
|
||||
_state.waterfall_process.terminate()
|
||||
_state.waterfall_process.wait(timeout=1)
|
||||
except Exception:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
_state.waterfall_process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
_state.waterfall_process = None
|
||||
logger.info("Waterfall IQ loop stopped")
|
||||
|
||||
@@ -346,14 +339,10 @@ def _waterfall_loop_rtl_power():
|
||||
try:
|
||||
_state.waterfall_queue.put_nowait(msg)
|
||||
except queue.Full:
|
||||
try:
|
||||
with contextlib.suppress(queue.Empty):
|
||||
_state.waterfall_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
try:
|
||||
with contextlib.suppress(queue.Full):
|
||||
_state.waterfall_queue.put_nowait(msg)
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
all_bins = []
|
||||
sweep_start_hz = start_hz
|
||||
@@ -379,10 +368,8 @@ def _waterfall_loop_rtl_power():
|
||||
'bins': bins_to_send,
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
}
|
||||
try:
|
||||
with contextlib.suppress(queue.Full):
|
||||
_state.waterfall_queue.put_nowait(msg)
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
if _state.waterfall_running and not received_any:
|
||||
_queue_waterfall_error('No waterfall FFT data received from rtl_power')
|
||||
@@ -397,10 +384,8 @@ def _waterfall_loop_rtl_power():
|
||||
_state.waterfall_process.terminate()
|
||||
_state.waterfall_process.wait(timeout=1)
|
||||
except Exception:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
_state.waterfall_process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
_state.waterfall_process = None
|
||||
logger.info("Waterfall loop stopped")
|
||||
|
||||
@@ -432,9 +417,8 @@ def start_waterfall() -> Response:
|
||||
sdr_type_str = sdr_type.value
|
||||
|
||||
# RTL-SDR uses rtl_power; other types use rx_sdr via IQ capture
|
||||
if sdr_type == SDRType.RTL_SDR:
|
||||
if not find_rtl_power():
|
||||
return jsonify({'status': 'error', 'message': 'rtl_power not found'}), 503
|
||||
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))
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Meshcore device routes.
|
||||
|
||||
Endpoints for connecting to Meshcore devices (serial, TCP, BLE),
|
||||
streaming live events, and managing messages, contacts, and nodes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import queue
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
from utils.logging import get_logger
|
||||
from utils.meshcore import (
|
||||
BLEConfig,
|
||||
MeshcoreContact,
|
||||
SerialConfig,
|
||||
TCPConfig,
|
||||
get_meshcore_client,
|
||||
is_meshcore_available,
|
||||
list_serial_ports,
|
||||
)
|
||||
from utils.responses import api_error
|
||||
|
||||
logger = get_logger("intercept.meshcore")
|
||||
|
||||
meshcore_bp = Blueprint("meshcore", __name__, url_prefix="/meshcore")
|
||||
|
||||
|
||||
def _client():
|
||||
return get_meshcore_client()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Status & connection management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@meshcore_bp.route("/status")
|
||||
def status():
|
||||
if not is_meshcore_available():
|
||||
return jsonify(
|
||||
{
|
||||
"available": False,
|
||||
"state": "unavailable",
|
||||
"message": "meshcore package not installed. Run: pip install meshcore",
|
||||
}
|
||||
)
|
||||
c = _client()
|
||||
state, message = c.get_state()
|
||||
payload = {"available": True, "state": state.value}
|
||||
if message:
|
||||
payload["message"] = message
|
||||
return jsonify(payload)
|
||||
|
||||
|
||||
@meshcore_bp.route("/connect", methods=["POST"])
|
||||
def connect():
|
||||
if not is_meshcore_available():
|
||||
return api_error("meshcore not installed", 503)
|
||||
data = request.get_json(silent=True) or {}
|
||||
transport = data.get("transport", "serial")
|
||||
|
||||
if transport == "serial":
|
||||
config = SerialConfig(port=data.get("port"), baud=int(data.get("baud", 115200)))
|
||||
elif transport == "tcp":
|
||||
host = data.get("host", "localhost")
|
||||
port = int(data.get("port", 5000))
|
||||
config = TCPConfig(host=host, port=port)
|
||||
elif transport == "ble":
|
||||
config = BLEConfig(device_address=data.get("address"))
|
||||
else:
|
||||
return api_error(f"Unknown transport: {transport}", 400)
|
||||
|
||||
_client().connect(config)
|
||||
return jsonify({"status": "connecting", "transport": transport})
|
||||
|
||||
|
||||
@meshcore_bp.route("/disconnect", methods=["POST"])
|
||||
def disconnect():
|
||||
_client().disconnect()
|
||||
return jsonify({"status": "disconnected"})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Discovery
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@meshcore_bp.route("/ports")
|
||||
def ports():
|
||||
return jsonify({"ports": list_serial_ports()})
|
||||
|
||||
|
||||
@meshcore_bp.route("/ble/scan")
|
||||
def ble_scan():
|
||||
if not is_meshcore_available():
|
||||
return api_error("meshcore not installed", 503)
|
||||
devices = _client().scan_ble()
|
||||
return jsonify({"devices": devices})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SSE stream
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@meshcore_bp.route("/stream")
|
||||
def stream():
|
||||
def _gen():
|
||||
q = _client().get_queue()
|
||||
while True:
|
||||
try:
|
||||
event = q.get(timeout=30)
|
||||
yield f"data: {json.dumps(event)}\n\n"
|
||||
except queue.Empty:
|
||||
yield ": keepalive\n\n"
|
||||
|
||||
return Response(
|
||||
_gen(),
|
||||
mimetype="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Messages
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@meshcore_bp.route("/messages")
|
||||
def messages():
|
||||
return jsonify({"messages": _client().get_messages()})
|
||||
|
||||
|
||||
@meshcore_bp.route("/send", methods=["POST"])
|
||||
def send():
|
||||
data = request.get_json(silent=True) or {}
|
||||
text = data.get("text", "").strip()
|
||||
recipient_id = data.get("recipient_id", "BROADCAST")
|
||||
if not text:
|
||||
return api_error("text is required", 400)
|
||||
if len(text) > 237:
|
||||
return api_error("text exceeds 237-character Meshcore limit", 400)
|
||||
_client().send_text(recipient_id, text)
|
||||
return jsonify({"status": "queued"})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Nodes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@meshcore_bp.route("/nodes")
|
||||
def nodes():
|
||||
return jsonify({"nodes": _client().get_nodes()})
|
||||
|
||||
|
||||
@meshcore_bp.route("/repeaters")
|
||||
def repeaters():
|
||||
return jsonify({"repeaters": _client().get_repeaters()})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Contacts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@meshcore_bp.route("/contacts", methods=["GET"])
|
||||
def list_contacts():
|
||||
return jsonify({"contacts": _client().get_contacts()})
|
||||
|
||||
|
||||
@meshcore_bp.route("/contacts", methods=["POST"])
|
||||
def add_contact():
|
||||
data = request.get_json(silent=True) or {}
|
||||
node_id = data.get("node_id", "").strip()
|
||||
name = data.get("name", "").strip()
|
||||
public_key = data.get("public_key", "").strip()
|
||||
if not node_id or not name or not public_key:
|
||||
return api_error("node_id, name, and public_key are required", 400)
|
||||
contact = MeshcoreContact(node_id=node_id, name=name, public_key=public_key, last_msg=None)
|
||||
_client().add_contact(contact)
|
||||
return jsonify({"status": "added", "contact": contact.to_dict()})
|
||||
|
||||
|
||||
@meshcore_bp.route("/contacts/<node_id>", methods=["DELETE"])
|
||||
def delete_contact(node_id: str):
|
||||
removed = _client().remove_contact(node_id)
|
||||
if not removed:
|
||||
return api_error("contact not found", 404)
|
||||
return jsonify({"status": "removed"})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Telemetry & traceroute
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@meshcore_bp.route("/telemetry/<node_id>")
|
||||
def telemetry(node_id: str):
|
||||
return jsonify({"node_id": node_id, "telemetry": _client().get_telemetry(node_id)})
|
||||
|
||||
|
||||
@meshcore_bp.route("/traceroute", methods=["POST"])
|
||||
def traceroute():
|
||||
data = request.get_json(silent=True) or {}
|
||||
node_id = data.get("node_id", "").strip()
|
||||
if not node_id:
|
||||
return api_error("node_id is required", 400)
|
||||
_client().request_traceroute(node_id)
|
||||
return jsonify({"status": "requested", "node_id": node_id})
|
||||
@@ -11,21 +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.responses import api_success, api_error
|
||||
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')
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ from typing import Any
|
||||
|
||||
from flask import Blueprint, Flask, Response, jsonify, request
|
||||
|
||||
from utils.responses import api_success, api_error
|
||||
from utils.responses import api_error
|
||||
|
||||
try:
|
||||
from flask_sock import Sock
|
||||
|
||||
+1
-1
@@ -13,7 +13,6 @@ from typing import Any
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
from utils.responses import api_success, api_error
|
||||
import app as app_module
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.logging import sensor_logger as logger
|
||||
@@ -22,6 +21,7 @@ from utils.morse import (
|
||||
morse_decoder_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 (
|
||||
|
||||
+58
-63
@@ -2,54 +2,50 @@
|
||||
Offline mode routes - Asset management and settings for offline operation.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
from utils.database import get_setting, set_setting
|
||||
from utils.responses import api_success, api_error
|
||||
import os
|
||||
|
||||
offline_bp = Blueprint('offline', __name__, url_prefix='/offline')
|
||||
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,
|
||||
"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.assets_source": "local",
|
||||
"offline.fonts_source": "local",
|
||||
"offline.tile_provider": "cartodb_dark_cyan",
|
||||
"offline.tile_server_url": "",
|
||||
"offline.stadia_key": "",
|
||||
}
|
||||
|
||||
# Asset paths to check
|
||||
ASSET_PATHS = {
|
||||
'leaflet': [
|
||||
'static/vendor/leaflet/leaflet.js',
|
||||
'static/vendor/leaflet/leaflet.css'
|
||||
"leaflet": ["static/vendor/leaflet/leaflet.js", "static/vendor/leaflet/leaflet.css"],
|
||||
"chartjs": ["static/vendor/chartjs/chart.umd.min.js"],
|
||||
"inter": [
|
||||
"static/vendor/fonts/Inter-Regular.woff2",
|
||||
"static/vendor/fonts/Inter-Medium.woff2",
|
||||
"static/vendor/fonts/Inter-SemiBold.woff2",
|
||||
"static/vendor/fonts/Inter-Bold.woff2",
|
||||
],
|
||||
'chartjs': [
|
||||
'static/vendor/chartjs/chart.umd.min.js'
|
||||
"jetbrains": [
|
||||
"static/vendor/fonts/JetBrainsMono-Regular.woff2",
|
||||
"static/vendor/fonts/JetBrainsMono-Medium.woff2",
|
||||
"static/vendor/fonts/JetBrainsMono-SemiBold.woff2",
|
||||
"static/vendor/fonts/JetBrainsMono-Bold.woff2",
|
||||
],
|
||||
'inter': [
|
||||
'static/vendor/fonts/Inter-Regular.woff2',
|
||||
'static/vendor/fonts/Inter-Medium.woff2',
|
||||
'static/vendor/fonts/Inter-SemiBold.woff2',
|
||||
'static/vendor/fonts/Inter-Bold.woff2'
|
||||
"leaflet_images": [
|
||||
"static/vendor/leaflet/images/marker-icon.png",
|
||||
"static/vendor/leaflet/images/marker-icon-2x.png",
|
||||
"static/vendor/leaflet/images/marker-shadow.png",
|
||||
"static/vendor/leaflet/images/layers.png",
|
||||
"static/vendor/leaflet/images/layers-2x.png",
|
||||
],
|
||||
'jetbrains': [
|
||||
'static/vendor/fonts/JetBrainsMono-Regular.woff2',
|
||||
'static/vendor/fonts/JetBrainsMono-Medium.woff2',
|
||||
'static/vendor/fonts/JetBrainsMono-SemiBold.woff2',
|
||||
'static/vendor/fonts/JetBrainsMono-Bold.woff2'
|
||||
],
|
||||
'leaflet_images': [
|
||||
'static/vendor/leaflet/images/marker-icon.png',
|
||||
'static/vendor/leaflet/images/marker-icon-2x.png',
|
||||
'static/vendor/leaflet/images/marker-shadow.png',
|
||||
'static/vendor/leaflet/images/layers.png',
|
||||
'static/vendor/leaflet/images/layers-2x.png'
|
||||
],
|
||||
'leaflet_heat': [
|
||||
'static/vendor/leaflet-heat/leaflet-heat.js'
|
||||
]
|
||||
"leaflet_heat": ["static/vendor/leaflet-heat/leaflet-heat.js"],
|
||||
}
|
||||
|
||||
|
||||
@@ -61,26 +57,26 @@ def get_offline_settings():
|
||||
return settings
|
||||
|
||||
|
||||
@offline_bp.route('/settings', methods=['GET'])
|
||||
@offline_bp.route("/settings", methods=["GET"])
|
||||
def get_settings():
|
||||
"""Get current offline settings."""
|
||||
settings = get_offline_settings()
|
||||
return api_success(data={'settings': settings})
|
||||
return api_success(data={"settings": settings})
|
||||
|
||||
|
||||
@offline_bp.route('/settings', methods=['POST'])
|
||||
@offline_bp.route("/settings", methods=["POST"])
|
||||
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 api_error('Missing key or value', 400)
|
||||
if not data or "key" not in data or "value" not in data:
|
||||
return api_error("Missing key or value", 400)
|
||||
|
||||
key = data['key']
|
||||
value = data['value']
|
||||
key = data["key"]
|
||||
value = data["value"]
|
||||
|
||||
# Validate key is an allowed setting
|
||||
if key not in OFFLINE_DEFAULTS:
|
||||
return api_error(f'Unknown setting: {key}', 400)
|
||||
return api_error(f"Unknown setting: {key}", 400)
|
||||
|
||||
# Validate value type matches default
|
||||
default_type = type(OFFLINE_DEFAULTS[key])
|
||||
@@ -88,18 +84,18 @@ def save_setting():
|
||||
# Try to convert
|
||||
try:
|
||||
if default_type == bool:
|
||||
value = str(value).lower() in ('true', '1', 'yes')
|
||||
value = str(value).lower() in ("true", "1", "yes")
|
||||
else:
|
||||
value = default_type(value)
|
||||
except (ValueError, TypeError):
|
||||
return api_error(f'Invalid value type for {key}', 400)
|
||||
return api_error(f"Invalid value type for {key}", 400)
|
||||
|
||||
set_setting(key, value)
|
||||
|
||||
return api_success(data={'key': key, 'value': value})
|
||||
return api_success(data={"key": key, "value": value})
|
||||
|
||||
|
||||
@offline_bp.route('/status', methods=['GET'])
|
||||
@offline_bp.route("/status", methods=["GET"])
|
||||
def get_status():
|
||||
"""Check status of local assets."""
|
||||
# Get the app root directory
|
||||
@@ -117,37 +113,36 @@ def get_status():
|
||||
available = False
|
||||
missing.append(path)
|
||||
|
||||
results[asset_name] = {
|
||||
'available': available,
|
||||
'missing': missing if not available else []
|
||||
}
|
||||
results[asset_name] = {"available": available, "missing": missing if not available else []}
|
||||
|
||||
if not available:
|
||||
all_available = False
|
||||
|
||||
return api_success(data={
|
||||
'all_available': all_available,
|
||||
'assets': results,
|
||||
'offline_enabled': get_setting('offline.enabled', False)
|
||||
})
|
||||
return api_success(
|
||||
data={
|
||||
"all_available": all_available,
|
||||
"assets": results,
|
||||
"offline_enabled": get_setting("offline.enabled", False),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@offline_bp.route('/check-asset', methods=['GET'])
|
||||
@offline_bp.route("/check-asset", methods=["GET"])
|
||||
def check_asset():
|
||||
"""Check if a specific asset file exists."""
|
||||
path = request.args.get('path', '')
|
||||
path = request.args.get("path", "")
|
||||
if not path:
|
||||
return api_error('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 api_error('Invalid path', 400)
|
||||
if not path.startswith("/static/vendor/"):
|
||||
return api_error("Invalid path", 400)
|
||||
|
||||
# Remove leading slash and construct full path
|
||||
relative_path = path.lstrip('/')
|
||||
relative_path = path.lstrip("/")
|
||||
app_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
full_path = os.path.join(app_root, relative_path)
|
||||
|
||||
exists = os.path.exists(full_path)
|
||||
|
||||
return api_success(data={'path': path, 'exists': exists})
|
||||
return api_success(data={"path": path, "exists": exists})
|
||||
|
||||
+1
-1
@@ -19,10 +19,10 @@ from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
import app as app_module
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.responses import api_success, api_error
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.ook import ook_parser_thread
|
||||
from utils.process import register_process, safe_terminate, unregister_process
|
||||
from utils.responses import api_error
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.validation import (
|
||||
|
||||
+25
-34
@@ -2,34 +2,39 @@
|
||||
|
||||
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
|
||||
|
||||
from utils.responses import api_success, api_error
|
||||
import app as app_module
|
||||
from utils.logging import pager_logger as logger
|
||||
from utils.validation import (
|
||||
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__)
|
||||
|
||||
@@ -189,10 +194,8 @@ def audio_relay_thread(
|
||||
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:
|
||||
@@ -237,10 +240,8 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
|
||||
app_module.output_queue.put({'type': 'error', 'text': str(e)})
|
||||
finally:
|
||||
global pager_active_device, pager_active_sdr_type
|
||||
try:
|
||||
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)
|
||||
@@ -255,10 +256,8 @@ 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:
|
||||
@@ -454,10 +453,8 @@ 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, pager_active_sdr_type or 'rtlsdr')
|
||||
@@ -470,10 +467,8 @@ 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, pager_active_sdr_type or 'rtlsdr')
|
||||
@@ -498,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()
|
||||
|
||||
+185
-97
@@ -7,20 +7,21 @@ telemetry (position, altitude, temperature, humidity, pressure) on the
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import shlex
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
from utils.responses import api_success, api_error
|
||||
import app as app_module
|
||||
from utils.constants import (
|
||||
MAX_RADIOSONDE_AGE_SECONDS,
|
||||
@@ -32,6 +33,7 @@ from utils.constants import (
|
||||
)
|
||||
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 (
|
||||
@@ -41,9 +43,10 @@ from utils.validation import (
|
||||
validate_longitude,
|
||||
)
|
||||
|
||||
logger = get_logger('intercept.radiosonde')
|
||||
|
||||
radiosonde_bp = Blueprint('radiosonde', __name__, url_prefix='/radiosonde')
|
||||
logger = get_logger('intercept.radiosonde')
|
||||
|
||||
radiosonde_bp = Blueprint('radiosonde', __name__, url_prefix='/radiosonde')
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# Track radiosonde state
|
||||
radiosonde_running = False
|
||||
@@ -65,8 +68,8 @@ AUTO_RX_PATHS = [
|
||||
]
|
||||
|
||||
|
||||
def find_auto_rx() -> str | None:
|
||||
"""Find radiosonde_auto_rx script/binary."""
|
||||
def find_auto_rx() -> str | None:
|
||||
"""Find radiosonde_auto_rx script/binary."""
|
||||
# Check PATH first
|
||||
path = shutil.which('radiosonde_auto_rx')
|
||||
if path:
|
||||
@@ -76,10 +79,123 @@ def find_auto_rx() -> str | None:
|
||||
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
|
||||
for p in AUTO_RX_PATHS:
|
||||
if os.path.isfile(p):
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_shebang_interpreter(script_path: str) -> str | None:
|
||||
"""Resolve a Python interpreter from a script shebang if possible."""
|
||||
try:
|
||||
with open(script_path, encoding='utf-8', errors='ignore') as handle:
|
||||
first_line = handle.readline().strip()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
if not first_line.startswith('#!'):
|
||||
return None
|
||||
|
||||
parts = shlex.split(first_line[2:].strip())
|
||||
if not parts:
|
||||
return None
|
||||
|
||||
if os.path.basename(parts[0]) == 'env' and len(parts) > 1:
|
||||
return shutil.which(parts[1])
|
||||
|
||||
return parts[0]
|
||||
|
||||
|
||||
def _resolve_pip_python(pip_bin: str | None) -> str | None:
|
||||
"""Resolve the Python interpreter used by a pip executable."""
|
||||
if not pip_bin:
|
||||
return None
|
||||
return _resolve_shebang_interpreter(pip_bin)
|
||||
|
||||
|
||||
def _build_auto_rx_env(auto_rx_dir: str) -> dict[str, str]:
|
||||
"""Build environment for radiosonde_auto_rx with compatibility shims."""
|
||||
env = os.environ.copy()
|
||||
python_path_entries = [PROJECT_ROOT, auto_rx_dir]
|
||||
existing_pythonpath = env.get('PYTHONPATH', '')
|
||||
if existing_pythonpath:
|
||||
python_path_entries.append(existing_pythonpath)
|
||||
env['PYTHONPATH'] = os.pathsep.join(entry for entry in python_path_entries if entry)
|
||||
return env
|
||||
|
||||
|
||||
def _iter_auto_rx_python_candidates(auto_rx_path: str):
|
||||
"""Yield plausible Python interpreters for radiosonde_auto_rx."""
|
||||
auto_rx_abs = os.path.abspath(auto_rx_path)
|
||||
auto_rx_dir = os.path.dirname(auto_rx_abs)
|
||||
install_root = os.path.dirname(auto_rx_dir)
|
||||
install_parent = os.path.dirname(install_root)
|
||||
|
||||
candidates = [
|
||||
_resolve_shebang_interpreter(auto_rx_abs),
|
||||
sys.executable,
|
||||
os.path.join(install_root, 'venv', 'bin', 'python'),
|
||||
os.path.join(install_root, 'venv', 'bin', 'python3'),
|
||||
os.path.join(install_root, '.venv', 'bin', 'python'),
|
||||
os.path.join(install_root, '.venv', 'bin', 'python3'),
|
||||
os.path.join(auto_rx_dir, 'venv', 'bin', 'python'),
|
||||
os.path.join(auto_rx_dir, 'venv', 'bin', 'python3'),
|
||||
os.path.join(auto_rx_dir, '.venv', 'bin', 'python'),
|
||||
os.path.join(auto_rx_dir, '.venv', 'bin', 'python3'),
|
||||
os.path.join(install_parent, 'venv', 'bin', 'python'),
|
||||
os.path.join(install_parent, 'venv', 'bin', 'python3'),
|
||||
os.path.join(install_parent, '.venv', 'bin', 'python'),
|
||||
os.path.join(install_parent, '.venv', 'bin', 'python3'),
|
||||
_resolve_pip_python(shutil.which('pip3')),
|
||||
_resolve_pip_python(shutil.which('pip')),
|
||||
shutil.which('python3'),
|
||||
shutil.which('python'),
|
||||
'/usr/local/bin/python3',
|
||||
'/usr/local/bin/python',
|
||||
'/usr/bin/python3',
|
||||
]
|
||||
|
||||
seen: set[str] = set()
|
||||
for candidate in candidates:
|
||||
if not candidate:
|
||||
continue
|
||||
candidate_abs = os.path.abspath(candidate)
|
||||
if candidate_abs in seen:
|
||||
continue
|
||||
seen.add(candidate_abs)
|
||||
if os.path.isfile(candidate_abs) and os.access(candidate_abs, os.X_OK):
|
||||
yield candidate_abs
|
||||
|
||||
|
||||
def _resolve_auto_rx_python(auto_rx_path: str) -> tuple[str | None, str, list[str]]:
|
||||
"""Pick a Python interpreter that can import autorx.scan successfully."""
|
||||
auto_rx_dir = os.path.dirname(os.path.abspath(auto_rx_path))
|
||||
auto_rx_env = _build_auto_rx_env(auto_rx_dir)
|
||||
checked: list[str] = []
|
||||
last_error = 'No usable Python interpreter found'
|
||||
|
||||
for python_bin in _iter_auto_rx_python_candidates(auto_rx_path):
|
||||
checked.append(python_bin)
|
||||
try:
|
||||
dep_check = subprocess.run(
|
||||
[python_bin, '-c', 'import autorx.scan'],
|
||||
cwd=auto_rx_dir,
|
||||
env=auto_rx_env,
|
||||
capture_output=True,
|
||||
timeout=10,
|
||||
)
|
||||
except Exception as exc:
|
||||
last_error = str(exc)
|
||||
continue
|
||||
|
||||
if dep_check.returncode == 0:
|
||||
return python_bin, '', checked
|
||||
|
||||
stderr_output = dep_check.stderr.decode('utf-8', errors='ignore').strip()
|
||||
stdout_output = dep_check.stdout.decode('utf-8', errors='ignore').strip()
|
||||
last_error = stderr_output or stdout_output or f'Interpreter exited with code {dep_check.returncode}'
|
||||
|
||||
return None, last_error, checked
|
||||
|
||||
|
||||
def generate_station_cfg(
|
||||
@@ -270,7 +386,7 @@ def _fix_data_ownership(path: str) -> None:
|
||||
return
|
||||
try:
|
||||
uid_int, gid_int = int(uid), int(gid)
|
||||
for dirpath, dirnames, filenames in os.walk(path):
|
||||
for dirpath, _dirnames, filenames in os.walk(path):
|
||||
os.chown(dirpath, uid_int, gid_int)
|
||||
for fname in filenames:
|
||||
os.chown(os.path.join(dirpath, fname), uid_int, gid_int)
|
||||
@@ -315,18 +431,14 @@ def parse_radiosonde_udp(udp_port: int) -> None:
|
||||
if serial:
|
||||
with _balloons_lock:
|
||||
radiosonde_balloons[serial] = balloon
|
||||
try:
|
||||
with contextlib.suppress(queue.Full):
|
||||
app_module.radiosonde_queue.put_nowait({
|
||||
'type': 'balloon',
|
||||
**balloon,
|
||||
})
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
try:
|
||||
with contextlib.suppress(OSError):
|
||||
sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
_udp_socket = None
|
||||
logger.info("Radiosonde UDP listener stopped")
|
||||
|
||||
@@ -354,71 +466,51 @@ def _process_telemetry(msg: dict) -> dict | None:
|
||||
# Position
|
||||
for key in ('lat', 'latitude'):
|
||||
if key in msg:
|
||||
try:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
balloon['lat'] = float(msg[key])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
break
|
||||
for key in ('lon', 'longitude'):
|
||||
if key in msg:
|
||||
try:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
balloon['lon'] = float(msg[key])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
break
|
||||
|
||||
# Altitude (metres)
|
||||
if 'alt' in msg:
|
||||
try:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
balloon['alt'] = float(msg['alt'])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Meteorological data
|
||||
for field in ('temp', 'humidity', 'pressure'):
|
||||
if field in msg:
|
||||
try:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
balloon[field] = float(msg[field])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Velocity
|
||||
if 'vel_h' in msg:
|
||||
try:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
balloon['vel_h'] = float(msg['vel_h'])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if 'vel_v' in msg:
|
||||
try:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
balloon['vel_v'] = float(msg['vel_v'])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if 'heading' in msg:
|
||||
try:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
balloon['heading'] = float(msg['heading'])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# GPS satellites
|
||||
if 'sats' in msg:
|
||||
try:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
balloon['sats'] = int(msg['sats'])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Battery voltage
|
||||
if 'batt' in msg:
|
||||
try:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
balloon['batt'] = float(msg['batt'])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Frequency
|
||||
if 'freq' in msg:
|
||||
try:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
balloon['freq'] = float(msg['freq'])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
balloon['last_seen'] = time.time()
|
||||
return balloon
|
||||
@@ -567,43 +659,43 @@ def start_radiosonde():
|
||||
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,
|
||||
# Build command - auto_rx -c expects the path to station.cfg
|
||||
cfg_abs = os.path.abspath(cfg_path)
|
||||
if auto_rx_path.endswith('.py'):
|
||||
selected_python, dep_error, checked_interpreters = _resolve_auto_rx_python(auto_rx_path)
|
||||
if not selected_python:
|
||||
logger.error(
|
||||
"radiosonde_auto_rx dependency check failed across interpreters %s: %s",
|
||||
checked_interpreters,
|
||||
dep_error,
|
||||
)
|
||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||
checked_msg = ', '.join(checked_interpreters) if checked_interpreters else 'none'
|
||||
return api_error(
|
||||
'radiosonde_auto_rx dependencies not satisfied. '
|
||||
'Install or repair its Python environment (missing packages such as semver). '
|
||||
f'Checked interpreters: {checked_msg}. '
|
||||
f'Last error: {dep_error[:500]}',
|
||||
500,
|
||||
)
|
||||
cmd = [selected_python, 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))
|
||||
auto_rx_env = _build_auto_rx_env(auto_rx_dir)
|
||||
|
||||
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,
|
||||
)
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True,
|
||||
cwd=auto_rx_dir,
|
||||
env=auto_rx_env,
|
||||
)
|
||||
|
||||
# Wait briefly for process to start
|
||||
time.sleep(2.0)
|
||||
@@ -612,12 +704,10 @@ def start_radiosonde():
|
||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||
stderr_output = ''
|
||||
if app_module.radiosonde_process.stderr:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
stderr_output = app_module.radiosonde_process.stderr.read().decode(
|
||||
'utf-8', errors='ignore'
|
||||
).strip()
|
||||
except Exception:
|
||||
pass
|
||||
if stderr_output:
|
||||
logger.error(f"radiosonde_auto_rx stderr:\n{stderr_output}")
|
||||
if stderr_output and (
|
||||
@@ -686,10 +776,8 @@ def stop_radiosonde():
|
||||
|
||||
# Close UDP socket to unblock listener thread
|
||||
if _udp_socket:
|
||||
try:
|
||||
with contextlib.suppress(OSError):
|
||||
_udp_socket.close()
|
||||
except OSError:
|
||||
pass
|
||||
_udp_socket = None
|
||||
|
||||
# Release SDR device
|
||||
|
||||
@@ -5,10 +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.responses import api_success, api_error
|
||||
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')
|
||||
|
||||
|
||||
+13
-19
@@ -2,25 +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
|
||||
|
||||
from utils.responses import api_success, api_error
|
||||
import app as app_module
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.validation import (
|
||||
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__)
|
||||
|
||||
@@ -70,10 +68,8 @@ def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
|
||||
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:
|
||||
@@ -82,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'})
|
||||
@@ -139,7 +133,7 @@ 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 = ''
|
||||
@@ -191,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')
|
||||
|
||||
+708
-334
File diff suppressed because it is too large
Load Diff
+16
-13
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import math
|
||||
import queue
|
||||
@@ -9,21 +10,25 @@ 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
|
||||
|
||||
from utils.responses import api_success, api_error
|
||||
import app as app_module
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.validation import (
|
||||
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.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.validation import (
|
||||
validate_device_index,
|
||||
validate_frequency,
|
||||
validate_gain,
|
||||
validate_ppm,
|
||||
validate_rtl_tcp_host,
|
||||
validate_rtl_tcp_port,
|
||||
)
|
||||
|
||||
sensor_bp = Blueprint('sensor', __name__)
|
||||
|
||||
@@ -137,10 +142,8 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
||||
process.terminate()
|
||||
process.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
unregister_process(process)
|
||||
app_module.sensor_queue.put({'type': 'status', 'text': 'stopped'})
|
||||
with app_module.sensor_lock:
|
||||
|
||||
+125
-22
@@ -1,26 +1,101 @@
|
||||
"""Settings management routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
from utils.database import (
|
||||
get_setting,
|
||||
set_setting,
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
from utils.database import (
|
||||
delete_setting,
|
||||
get_all_settings,
|
||||
get_correlations,
|
||||
)
|
||||
from utils.logging import get_logger
|
||||
from utils.responses import api_error, api_success
|
||||
|
||||
logger = get_logger('intercept.settings')
|
||||
|
||||
settings_bp = Blueprint('settings', __name__, url_prefix='/settings')
|
||||
get_setting,
|
||||
set_setting,
|
||||
)
|
||||
from utils.logging import get_logger
|
||||
from utils.responses import api_error, api_success
|
||||
from utils.validation import validate_latitude, validate_longitude
|
||||
|
||||
logger = get_logger('intercept.settings')
|
||||
|
||||
settings_bp = Blueprint('settings', __name__, url_prefix='/settings')
|
||||
_env_lock = threading.Lock()
|
||||
|
||||
|
||||
def _get_env_file_path() -> Path:
|
||||
"""Return the project .env path."""
|
||||
return Path(__file__).resolve().parent.parent / '.env'
|
||||
|
||||
|
||||
def _write_env_value(key: str, value: str, env_path: Path | None = None) -> None:
|
||||
"""Create or update a single key in the project .env file."""
|
||||
path = env_path or _get_env_file_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with _env_lock:
|
||||
lines = path.read_text().splitlines() if path.exists() else [
|
||||
'# INTERCEPT environment configuration',
|
||||
'',
|
||||
]
|
||||
|
||||
pattern = re.compile(rf'^\s*{re.escape(key)}=')
|
||||
updated = False
|
||||
new_lines: list[str] = []
|
||||
for line in lines:
|
||||
if pattern.match(line):
|
||||
if not updated:
|
||||
new_lines.append(f'{key}={value}')
|
||||
updated = True
|
||||
continue
|
||||
new_lines.append(line)
|
||||
|
||||
if not updated:
|
||||
if new_lines and new_lines[-1] != '':
|
||||
new_lines.append('')
|
||||
new_lines.append(f'{key}={value}')
|
||||
|
||||
path.write_text('\n'.join(new_lines).rstrip('\n') + '\n')
|
||||
|
||||
sudo_uid = os.environ.get('INTERCEPT_SUDO_UID')
|
||||
sudo_gid = os.environ.get('INTERCEPT_SUDO_GID')
|
||||
if os.geteuid() == 0 and sudo_uid and sudo_gid:
|
||||
with contextlib.suppress(OSError, ValueError):
|
||||
os.chown(path, int(sudo_uid), int(sudo_gid))
|
||||
|
||||
|
||||
def _apply_runtime_observer_defaults(lat: float, lon: float) -> None:
|
||||
"""Update in-process defaults so refreshed pages use the saved location."""
|
||||
lat_str = str(lat)
|
||||
lon_str = str(lon)
|
||||
os.environ['INTERCEPT_DEFAULT_LAT'] = lat_str
|
||||
os.environ['INTERCEPT_DEFAULT_LON'] = lon_str
|
||||
|
||||
import config
|
||||
|
||||
config.DEFAULT_LATITUDE = lat
|
||||
config.DEFAULT_LONGITUDE = lon
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
import app as app_module
|
||||
app_module.DEFAULT_LATITUDE = lat
|
||||
app_module.DEFAULT_LONGITUDE = lon
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
from routes import adsb as adsb_routes
|
||||
adsb_routes.DEFAULT_LATITUDE = lat
|
||||
adsb_routes.DEFAULT_LONGITUDE = lon
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
from routes import ais as ais_routes
|
||||
ais_routes.DEFAULT_LATITUDE = lat
|
||||
ais_routes.DEFAULT_LONGITUDE = lon
|
||||
|
||||
|
||||
@settings_bp.route('', methods=['GET'])
|
||||
@@ -92,8 +167,8 @@ def update_single_setting(key: str) -> Response:
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@settings_bp.route('/<key>', methods=['DELETE'])
|
||||
def delete_single_setting(key: str) -> Response:
|
||||
@settings_bp.route('/<key>', methods=['DELETE'])
|
||||
def delete_single_setting(key: str) -> Response:
|
||||
"""Delete a setting."""
|
||||
try:
|
||||
deleted = delete_setting(key)
|
||||
@@ -106,7 +181,35 @@ def delete_single_setting(key: str) -> Response:
|
||||
}), 404
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting setting {key}: {e}")
|
||||
return api_error(str(e), 500)
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@settings_bp.route('/observer-location', methods=['POST'])
|
||||
def save_observer_location() -> Response:
|
||||
"""Persist observer location to .env and refresh in-process defaults."""
|
||||
data = request.json or {}
|
||||
|
||||
try:
|
||||
lat = validate_latitude(data.get('lat'))
|
||||
lon = validate_longitude(data.get('lon'))
|
||||
except ValueError as exc:
|
||||
return api_error(str(exc), 400)
|
||||
|
||||
try:
|
||||
_write_env_value('INTERCEPT_DEFAULT_LAT', str(lat))
|
||||
_write_env_value('INTERCEPT_DEFAULT_LON', str(lon))
|
||||
_apply_runtime_observer_defaults(lat, lon)
|
||||
return api_success(
|
||||
data={
|
||||
'lat': lat,
|
||||
'lon': lon,
|
||||
'saved': ['INTERCEPT_DEFAULT_LAT', 'INTERCEPT_DEFAULT_LON'],
|
||||
},
|
||||
message='Observer location saved to .env',
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(f'Error saving observer location to .env: {exc}')
|
||||
return api_error(str(exc), 500)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -163,7 +266,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
|
||||
|
||||
+95
-1
@@ -8,10 +8,12 @@ import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
import config
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
from utils.responses import api_success, api_error
|
||||
from utils.logging import get_logger
|
||||
from utils.responses import api_error
|
||||
from utils.signal_db import match_signals
|
||||
|
||||
logger = get_logger('intercept.signalid')
|
||||
|
||||
@@ -351,3 +353,95 @@ def sigidwiki_lookup() -> Response:
|
||||
**response_payload,
|
||||
})
|
||||
|
||||
|
||||
_match_cache: dict[str, dict[str, Any]] = {}
|
||||
|
||||
|
||||
@signalid_bp.route('/match', methods=['POST'])
|
||||
def signalid_match() -> Response:
|
||||
"""Match a signal by frequency, bandwidth, and modulation against the local database."""
|
||||
payload = request.get_json(silent=True) or {}
|
||||
|
||||
freq_raw = payload.get('frequency_mhz')
|
||||
if freq_raw is None:
|
||||
return api_error('frequency_mhz is required', 400)
|
||||
try:
|
||||
frequency_mhz = float(freq_raw)
|
||||
except (TypeError, ValueError):
|
||||
return api_error('Invalid frequency_mhz', 400)
|
||||
if frequency_mhz <= 0:
|
||||
return api_error('frequency_mhz must be positive', 400)
|
||||
|
||||
bw_raw = payload.get('bandwidth_hz')
|
||||
bandwidth_hz: int | None = None
|
||||
if bw_raw is not None:
|
||||
try:
|
||||
bandwidth_hz = int(float(bw_raw))
|
||||
except (TypeError, ValueError):
|
||||
return api_error('Invalid bandwidth_hz', 400)
|
||||
if bandwidth_hz <= 0:
|
||||
return api_error('bandwidth_hz must be positive', 400)
|
||||
|
||||
modulation = str(payload.get('modulation') or '').strip().upper()[:16] or None
|
||||
|
||||
limit_raw = payload.get('limit', 8)
|
||||
try:
|
||||
limit = max(1, min(int(limit_raw), 20))
|
||||
except (TypeError, ValueError):
|
||||
limit = 8
|
||||
|
||||
region = getattr(config, 'REGION', 'GLOBAL')
|
||||
|
||||
cache_key = f'{round(frequency_mhz, 6)}|{bandwidth_hz}|{modulation}|{limit}|{region}'
|
||||
cached = _cache_get_match(cache_key)
|
||||
if cached is not None:
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'frequency_mhz': round(frequency_mhz, 6),
|
||||
'bandwidth_hz': bandwidth_hz,
|
||||
'modulation': modulation,
|
||||
'cached': True,
|
||||
**cached,
|
||||
})
|
||||
|
||||
try:
|
||||
matches = match_signals(
|
||||
frequency_mhz=frequency_mhz,
|
||||
bandwidth_hz=bandwidth_hz,
|
||||
modulation=modulation,
|
||||
region=region,
|
||||
limit=limit,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error('Signal match failed: %s', exc)
|
||||
return api_error('Signal match failed', 503)
|
||||
|
||||
response_data = {
|
||||
'matches': matches,
|
||||
'match_count': len(matches),
|
||||
}
|
||||
_cache_set_match(cache_key, response_data)
|
||||
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'frequency_mhz': round(frequency_mhz, 6),
|
||||
'bandwidth_hz': bandwidth_hz,
|
||||
'modulation': modulation,
|
||||
'cached': False,
|
||||
**response_data,
|
||||
})
|
||||
|
||||
|
||||
def _cache_get_match(key: str) -> Any | None:
|
||||
entry = _match_cache.get(key)
|
||||
if not entry:
|
||||
return None
|
||||
if time.time() >= entry['expires']:
|
||||
_match_cache.pop(key, None)
|
||||
return None
|
||||
return entry['data']
|
||||
|
||||
|
||||
def _cache_set_match(key: str, data: Any, ttl: int = 60) -> None:
|
||||
_match_cache[key] = {'data': data, 'expires': time.time() + ttl}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from typing import Any
|
||||
from flask import Blueprint, Response, jsonify
|
||||
|
||||
from utils.logging import get_logger
|
||||
from utils.responses import api_success, api_error
|
||||
from utils.responses import api_error
|
||||
|
||||
logger = get_logger('intercept.space_weather')
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
+238
-233
@@ -6,32 +6,34 @@ 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 Any
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response, send_file
|
||||
from flask import Blueprint, Response, jsonify, request, send_file
|
||||
|
||||
from utils.responses import api_success, api_error
|
||||
import app as app_module
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import sse_stream_fanout
|
||||
from routes.satellite import get_cached_tle
|
||||
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')
|
||||
logger = get_logger("intercept.sstv")
|
||||
|
||||
sstv_bp = Blueprint('sstv', __name__, url_prefix='/sstv')
|
||||
sstv_bp = Blueprint("sstv", __name__, url_prefix="/sstv")
|
||||
|
||||
# ISS SSTV runs on a fixed downlink; allow a small entry tolerance so users
|
||||
# can type nearby values and still land on the canonical center frequency.
|
||||
ISS_SSTV_MODULATION = 'fm'
|
||||
ISS_SSTV_MODULATION = "fm"
|
||||
ISS_SSTV_FREQUENCIES = (ISS_SSTV_FREQ,)
|
||||
ISS_SSTV_FREQ_TOLERANCE_MHZ = 0.05
|
||||
|
||||
@@ -58,7 +60,7 @@ _timescale_lock = threading.Lock()
|
||||
|
||||
# Track which device is being used
|
||||
sstv_active_device: int | None = None
|
||||
sstv_active_sdr_type: str = 'rtlsdr'
|
||||
sstv_active_sdr_type: str = "rtlsdr"
|
||||
|
||||
|
||||
def _progress_callback(data: dict) -> None:
|
||||
@@ -81,7 +83,7 @@ def _normalize_iss_frequency(frequency_mhz: float) -> float | None:
|
||||
return None
|
||||
|
||||
|
||||
@sstv_bp.route('/status')
|
||||
@sstv_bp.route("/status")
|
||||
def get_status():
|
||||
"""
|
||||
Get SSTV decoder status.
|
||||
@@ -93,24 +95,24 @@ def get_status():
|
||||
decoder = get_sstv_decoder()
|
||||
|
||||
result = {
|
||||
'available': available,
|
||||
'decoder': decoder.decoder_available,
|
||||
'running': decoder.is_running,
|
||||
'iss_frequency': ISS_SSTV_FREQ,
|
||||
'modulation': ISS_SSTV_MODULATION,
|
||||
'image_count': len(decoder.get_images()),
|
||||
'doppler_enabled': decoder.doppler_enabled,
|
||||
"available": available,
|
||||
"decoder": decoder.decoder_available,
|
||||
"running": decoder.is_running,
|
||||
"iss_frequency": ISS_SSTV_FREQ,
|
||||
"modulation": ISS_SSTV_MODULATION,
|
||||
"image_count": len(decoder.get_images()),
|
||||
"doppler_enabled": decoder.doppler_enabled,
|
||||
}
|
||||
|
||||
# Include Doppler info if available
|
||||
doppler_info = decoder.last_doppler_info
|
||||
if doppler_info:
|
||||
result['doppler'] = doppler_info.to_dict()
|
||||
result["doppler"] = doppler_info.to_dict()
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@sstv_bp.route('/start', methods=['POST'])
|
||||
@sstv_bp.route("/start", methods=["POST"])
|
||||
def start_decoder():
|
||||
"""
|
||||
Start SSTV decoder.
|
||||
@@ -132,20 +134,24 @@ def start_decoder():
|
||||
JSON with start status.
|
||||
"""
|
||||
if not is_sstv_available():
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow'
|
||||
}), 400
|
||||
return jsonify(
|
||||
{
|
||||
"status": "error",
|
||||
"message": "SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow",
|
||||
}
|
||||
), 400
|
||||
|
||||
decoder = get_sstv_decoder()
|
||||
|
||||
if decoder.is_running:
|
||||
return jsonify({
|
||||
'status': 'already_running',
|
||||
'frequency': ISS_SSTV_FREQ,
|
||||
'modulation': ISS_SSTV_MODULATION,
|
||||
'doppler_enabled': decoder.doppler_enabled
|
||||
})
|
||||
return jsonify(
|
||||
{
|
||||
"status": "already_running",
|
||||
"frequency": ISS_SSTV_FREQ,
|
||||
"modulation": ISS_SSTV_MODULATION,
|
||||
"doppler_enabled": decoder.doppler_enabled,
|
||||
}
|
||||
)
|
||||
|
||||
# Clear queue
|
||||
while not _sstv_queue.empty():
|
||||
@@ -156,43 +162,38 @@ def start_decoder():
|
||||
|
||||
# Get parameters
|
||||
data = request.get_json(silent=True) or {}
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
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
|
||||
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)
|
||||
latitude = data.get('latitude')
|
||||
longitude = data.get('longitude')
|
||||
frequency = data.get("frequency", ISS_SSTV_FREQ)
|
||||
modulation = str(data.get("modulation", ISS_SSTV_MODULATION)).strip().lower()
|
||||
device_index = data.get("device", 0)
|
||||
latitude = data.get("latitude")
|
||||
longitude = data.get("longitude")
|
||||
|
||||
# Validate modulation (ISS mode is FM-only)
|
||||
if modulation != ISS_SSTV_MODULATION:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Modulation must be {ISS_SSTV_MODULATION} for ISS SSTV mode'
|
||||
}), 400
|
||||
return jsonify(
|
||||
{"status": "error", "message": f"Modulation must be {ISS_SSTV_MODULATION} for ISS SSTV mode"}
|
||||
), 400
|
||||
|
||||
# Validate frequency
|
||||
try:
|
||||
frequency = float(frequency)
|
||||
normalized_frequency = _normalize_iss_frequency(frequency)
|
||||
if normalized_frequency is None:
|
||||
supported = ', '.join(f'{freq:.3f}' for freq in ISS_SSTV_FREQUENCIES)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Supported ISS SSTV frequency: {supported} MHz FM'
|
||||
}), 400
|
||||
supported = ", ".join(f"{freq:.3f}" for freq in ISS_SSTV_FREQUENCIES)
|
||||
return jsonify({"status": "error", "message": f"Supported ISS SSTV frequency: {supported} MHz FM"}), 400
|
||||
frequency = normalized_frequency
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Invalid frequency'
|
||||
}), 400
|
||||
return jsonify({"status": "error", "message": "Invalid frequency"}), 400
|
||||
|
||||
# Validate location if provided
|
||||
if latitude is not None and longitude is not None:
|
||||
@@ -200,20 +201,11 @@ def start_decoder():
|
||||
latitude = float(latitude)
|
||||
longitude = float(longitude)
|
||||
if not (-90 <= latitude <= 90):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Latitude must be between -90 and 90'
|
||||
}), 400
|
||||
return jsonify({"status": "error", "message": "Latitude must be between -90 and 90"}), 400
|
||||
if not (-180 <= longitude <= 180):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Longitude must be between -180 and 180'
|
||||
}), 400
|
||||
return jsonify({"status": "error", "message": "Longitude must be between -180 and 180"}), 400
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Invalid latitude or longitude'
|
||||
}), 400
|
||||
return jsonify({"status": "error", "message": "Invalid latitude or longitude"}), 400
|
||||
else:
|
||||
latitude = None
|
||||
longitude = None
|
||||
@@ -221,13 +213,9 @@ def start_decoder():
|
||||
# Claim SDR device
|
||||
global sstv_active_device, sstv_active_sdr_type
|
||||
device_int = int(device_index)
|
||||
error = app_module.claim_sdr_device(device_int, 'sstv', sdr_type_str)
|
||||
error = app_module.claim_sdr_device(device_int, "sstv", sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
return jsonify({"status": "error", "error_type": "DEVICE_BUSY", "message": error}), 409
|
||||
|
||||
# Set callback and start
|
||||
decoder.set_callback(_progress_callback)
|
||||
@@ -244,28 +232,25 @@ def start_decoder():
|
||||
sstv_active_sdr_type = sdr_type_str
|
||||
|
||||
result = {
|
||||
'status': 'started',
|
||||
'frequency': frequency,
|
||||
'modulation': ISS_SSTV_MODULATION,
|
||||
'device': device_index,
|
||||
'doppler_enabled': decoder.doppler_enabled
|
||||
"status": "started",
|
||||
"frequency": frequency,
|
||||
"modulation": ISS_SSTV_MODULATION,
|
||||
"device": device_index,
|
||||
"doppler_enabled": decoder.doppler_enabled,
|
||||
}
|
||||
|
||||
# Include initial Doppler info if available
|
||||
if decoder.doppler_enabled and decoder.last_doppler_info:
|
||||
result['doppler'] = decoder.last_doppler_info.to_dict()
|
||||
result["doppler"] = decoder.last_doppler_info.to_dict()
|
||||
|
||||
return jsonify(result)
|
||||
else:
|
||||
# Release device on failure
|
||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to start decoder'
|
||||
}), 500
|
||||
return jsonify({"status": "error", "message": "Failed to start decoder"}), 500
|
||||
|
||||
|
||||
@sstv_bp.route('/stop', methods=['POST'])
|
||||
@sstv_bp.route("/stop", methods=["POST"])
|
||||
def stop_decoder():
|
||||
"""
|
||||
Stop SSTV decoder.
|
||||
@@ -282,10 +267,10 @@ def stop_decoder():
|
||||
app_module.release_sdr_device(sstv_active_device, sstv_active_sdr_type)
|
||||
sstv_active_device = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
return jsonify({"status": "stopped"})
|
||||
|
||||
|
||||
@sstv_bp.route('/doppler')
|
||||
@sstv_bp.route("/doppler")
|
||||
def get_doppler():
|
||||
"""
|
||||
Get current Doppler shift information.
|
||||
@@ -298,27 +283,28 @@ def get_doppler():
|
||||
decoder = get_sstv_decoder()
|
||||
|
||||
if not decoder.doppler_enabled:
|
||||
return jsonify({
|
||||
'status': 'disabled',
|
||||
'message': 'Doppler tracking not enabled. Provide latitude/longitude when starting decoder.'
|
||||
})
|
||||
return jsonify(
|
||||
{
|
||||
"status": "disabled",
|
||||
"message": "Doppler tracking not enabled. Provide latitude/longitude when starting decoder.",
|
||||
}
|
||||
)
|
||||
|
||||
doppler_info = decoder.last_doppler_info
|
||||
if not doppler_info:
|
||||
return jsonify({
|
||||
'status': 'unavailable',
|
||||
'message': 'Doppler data not yet available'
|
||||
})
|
||||
return jsonify({"status": "unavailable", "message": "Doppler data not yet available"})
|
||||
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'doppler': doppler_info.to_dict(),
|
||||
'nominal_frequency_mhz': ISS_SSTV_FREQ,
|
||||
'corrected_frequency_mhz': doppler_info.frequency_hz / 1_000_000
|
||||
})
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"doppler": doppler_info.to_dict(),
|
||||
"nominal_frequency_mhz": ISS_SSTV_FREQ,
|
||||
"corrected_frequency_mhz": doppler_info.frequency_hz / 1_000_000,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@sstv_bp.route('/images')
|
||||
@sstv_bp.route("/images")
|
||||
def list_images():
|
||||
"""
|
||||
Get list of decoded SSTV images.
|
||||
@@ -332,18 +318,14 @@ def list_images():
|
||||
decoder = get_sstv_decoder()
|
||||
images = decoder.get_images()
|
||||
|
||||
limit = request.args.get('limit', type=int)
|
||||
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)
|
||||
})
|
||||
return jsonify({"status": "ok", "images": [img.to_dict() for img in images], "count": len(images)})
|
||||
|
||||
|
||||
@sstv_bp.route('/images/<filename>')
|
||||
@sstv_bp.route("/images/<filename>")
|
||||
def get_image(filename: str):
|
||||
"""
|
||||
Get a decoded SSTV image file.
|
||||
@@ -357,22 +339,22 @@ def get_image(filename: str):
|
||||
decoder = get_sstv_decoder()
|
||||
|
||||
# Security: only allow alphanumeric filenames with .png extension
|
||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||
return api_error('Invalid filename', 400)
|
||||
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 not filename.endswith(".png"):
|
||||
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 api_error('Image not found', 404)
|
||||
return api_error("Image not found", 404)
|
||||
|
||||
return send_file(image_path, mimetype='image/png')
|
||||
return send_file(image_path, mimetype="image/png")
|
||||
|
||||
|
||||
@sstv_bp.route('/images/<filename>/download')
|
||||
@sstv_bp.route("/images/<filename>/download")
|
||||
def download_image(filename: str):
|
||||
"""
|
||||
Download a decoded SSTV image file.
|
||||
@@ -386,21 +368,21 @@ def download_image(filename: str):
|
||||
decoder = get_sstv_decoder()
|
||||
|
||||
# Security: only allow alphanumeric filenames with .png extension
|
||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||
return api_error('Invalid filename', 400)
|
||||
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 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 api_error("Image not found", 404)
|
||||
|
||||
return send_file(image_path, mimetype='image/png', as_attachment=True, download_name=filename)
|
||||
return send_file(image_path, mimetype="image/png", as_attachment=True, download_name=filename)
|
||||
|
||||
|
||||
@sstv_bp.route('/images/<filename>', methods=['DELETE'])
|
||||
@sstv_bp.route("/images/<filename>", methods=["DELETE"])
|
||||
def delete_image(filename: str):
|
||||
"""
|
||||
Delete a decoded SSTV image.
|
||||
@@ -414,19 +396,19 @@ def delete_image(filename: str):
|
||||
decoder = get_sstv_decoder()
|
||||
|
||||
# Security: only allow alphanumeric filenames with .png extension
|
||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||
return api_error('Invalid filename', 400)
|
||||
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 not filename.endswith(".png"):
|
||||
return api_error("Only PNG files supported", 400)
|
||||
|
||||
if decoder.delete_image(filename):
|
||||
return jsonify({'status': 'ok'})
|
||||
return jsonify({"status": "ok"})
|
||||
else:
|
||||
return api_error('Image not found', 404)
|
||||
return api_error("Image not found", 404)
|
||||
|
||||
|
||||
@sstv_bp.route('/images', methods=['DELETE'])
|
||||
@sstv_bp.route("/images", methods=["DELETE"])
|
||||
def delete_all_images():
|
||||
"""
|
||||
Delete all decoded SSTV images.
|
||||
@@ -436,10 +418,10 @@ def delete_all_images():
|
||||
"""
|
||||
decoder = get_sstv_decoder()
|
||||
count = decoder.delete_all_images()
|
||||
return jsonify({'status': 'ok', 'deleted': count})
|
||||
return jsonify({"status": "ok", "deleted": count})
|
||||
|
||||
|
||||
@sstv_bp.route('/stream')
|
||||
@sstv_bp.route("/stream")
|
||||
def stream_progress():
|
||||
"""
|
||||
SSE stream of SSTV decode progress.
|
||||
@@ -452,22 +434,23 @@ def stream_progress():
|
||||
Returns:
|
||||
SSE stream (text/event-stream)
|
||||
"""
|
||||
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('sstv', msg, msg.get('type'))
|
||||
process_event("sstv", msg, msg.get("type"))
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=_sstv_queue,
|
||||
channel_key='sstv',
|
||||
channel_key="sstv",
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
on_message=_on_msg,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
mimetype="text/event-stream",
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
response.headers["Cache-Control"] = "no-cache"
|
||||
response.headers["X-Accel-Buffering"] = "no"
|
||||
response.headers["Connection"] = "keep-alive"
|
||||
return response
|
||||
|
||||
|
||||
@@ -477,11 +460,12 @@ def _get_timescale():
|
||||
with _timescale_lock:
|
||||
if _timescale is None:
|
||||
from skyfield.api import load
|
||||
_timescale = load.timescale()
|
||||
|
||||
_timescale = load.timescale(builtin=True)
|
||||
return _timescale
|
||||
|
||||
|
||||
@sstv_bp.route('/iss-schedule')
|
||||
@sstv_bp.route("/iss-schedule")
|
||||
def iss_schedule():
|
||||
"""
|
||||
Get ISS pass schedule for SSTV reception.
|
||||
@@ -499,39 +483,35 @@ def iss_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)
|
||||
lat = request.args.get("latitude", type=float)
|
||||
lon = request.args.get("longitude", type=float)
|
||||
hours = request.args.get("hours", 48, type=int)
|
||||
|
||||
if lat is None or lon is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'latitude and longitude parameters required'
|
||||
}), 400
|
||||
return jsonify({"status": "error", "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):
|
||||
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 wgs84, EarthSatellite
|
||||
from skyfield.almanac import find_discrete
|
||||
from datetime import timedelta
|
||||
from data.satellites import TLE_SATELLITES
|
||||
|
||||
# Get ISS TLE
|
||||
iss_tle = TLE_SATELLITES.get('ISS')
|
||||
from skyfield.almanac import find_discrete
|
||||
from skyfield.api import EarthSatellite, wgs84
|
||||
|
||||
# Get ISS TLE from live cache (kept fresh by auto-refresh)
|
||||
iss_tle = get_cached_tle("ISS")
|
||||
if not iss_tle:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'ISS TLE data not available'
|
||||
}), 500
|
||||
return jsonify({"status": "error", "message": "ISS TLE data not available"}), 500
|
||||
|
||||
ts = _get_timescale()
|
||||
satellite = EarthSatellite(iss_tle[1], iss_tle[2], iss_tle[0], ts)
|
||||
@@ -546,7 +526,7 @@ def iss_schedule():
|
||||
alt, _, _ = topocentric.altaz()
|
||||
return alt.degrees > 0
|
||||
|
||||
above_horizon.step_days = 1/720
|
||||
above_horizon.step_days = 1 / 720
|
||||
|
||||
times, events = find_discrete(t0, t1, above_horizon)
|
||||
|
||||
@@ -585,23 +565,25 @@ def iss_schedule():
|
||||
max_el = alt.degrees
|
||||
|
||||
if max_el >= 10: # Min elevation filter
|
||||
passes.append({
|
||||
'satellite': 'ISS',
|
||||
'startTime': rise_time.utc_datetime().strftime('%Y-%m-%d %H:%M UTC'),
|
||||
'startTimeISO': rise_time.utc_datetime().isoformat(),
|
||||
'maxEl': round(max_el, 1),
|
||||
'duration': duration_minutes,
|
||||
'color': '#00ffff'
|
||||
})
|
||||
passes.append(
|
||||
{
|
||||
"satellite": "ISS",
|
||||
"startTime": rise_time.utc_datetime().strftime("%Y-%m-%d %H:%M UTC"),
|
||||
"startTimeISO": rise_time.utc_datetime().isoformat(),
|
||||
"maxEl": round(max_el, 1),
|
||||
"duration": duration_minutes,
|
||||
"color": "#00ffff",
|
||||
}
|
||||
)
|
||||
|
||||
i += 1
|
||||
|
||||
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.'
|
||||
"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
|
||||
@@ -613,17 +595,11 @@ def iss_schedule():
|
||||
return jsonify(result)
|
||||
|
||||
except ImportError:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'skyfield library not installed'
|
||||
}), 503
|
||||
return jsonify({"status": "error", "message": "skyfield library not installed"}), 503
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting ISS schedule: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
return jsonify({"status": "error", "message": str(e)}), 500
|
||||
|
||||
|
||||
def _fetch_iss_position() -> dict | None:
|
||||
@@ -641,14 +617,14 @@ def _fetch_iss_position() -> dict | None:
|
||||
|
||||
# Try primary API: Where The ISS At
|
||||
try:
|
||||
response = requests.get('https://api.wheretheiss.at/v1/satellites/25544', timeout=3)
|
||||
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',
|
||||
"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}")
|
||||
@@ -656,15 +632,15 @@ def _fetch_iss_position() -> dict | None:
|
||||
# Try fallback API: Open Notify
|
||||
if cached is None:
|
||||
try:
|
||||
response = requests.get('http://api.open-notify.org/iss-now.json', timeout=3)
|
||||
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':
|
||||
if data.get("message") == "success":
|
||||
cached = {
|
||||
'lat': float(data['iss_position']['latitude']),
|
||||
'lon': float(data['iss_position']['longitude']),
|
||||
'altitude': 420,
|
||||
'source': 'open-notify',
|
||||
"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}")
|
||||
@@ -677,7 +653,7 @@ def _fetch_iss_position() -> dict | None:
|
||||
return cached
|
||||
|
||||
|
||||
@sstv_bp.route('/iss-position')
|
||||
@sstv_bp.route("/iss-position")
|
||||
def iss_position():
|
||||
"""
|
||||
Get current ISS position from real-time API.
|
||||
@@ -695,28 +671,25 @@ def iss_position():
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
observer_lat = request.args.get('latitude', type=float)
|
||||
observer_lon = request.args.get('longitude', type=float)
|
||||
observer_lat = request.args.get("latitude", type=float)
|
||||
observer_lon = request.args.get("longitude", type=float)
|
||||
|
||||
pos = _fetch_iss_position()
|
||||
if pos is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Unable to fetch ISS position from real-time APIs'
|
||||
}), 503
|
||||
return jsonify({"status": "error", "message": "Unable to fetch ISS position from real-time APIs"}), 503
|
||||
|
||||
result = {
|
||||
'status': 'ok',
|
||||
'lat': pos['lat'],
|
||||
'lon': pos['lon'],
|
||||
'altitude': pos['altitude'],
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
'source': pos['source'],
|
||||
"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(pos['lat'], pos['lon'], observer_lat, observer_lon))
|
||||
result.update(_calculate_observer_data(pos["lat"], pos["lon"], observer_lat, observer_lon))
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
@@ -740,7 +713,7 @@ def _calculate_observer_data(iss_lat: float, iss_lon: float, obs_lat: float, obs
|
||||
# Haversine for ground distance
|
||||
dlat = lat2 - lat1
|
||||
dlon = lon2 - lon1
|
||||
a = math.sin(dlat/2)**2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon/2)**2
|
||||
a = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2
|
||||
c = 2 * math.asin(math.sqrt(a))
|
||||
ground_distance = earth_radius * c
|
||||
|
||||
@@ -760,14 +733,60 @@ def _calculate_observer_data(iss_lat: float, iss_lon: float, obs_lat: float, obs
|
||||
azimuth = math.degrees(math.atan2(y, x))
|
||||
azimuth = (azimuth + 360) % 360
|
||||
|
||||
return {
|
||||
'elevation': round(elevation, 1),
|
||||
'azimuth': round(azimuth, 1),
|
||||
'distance': round(slant_range, 1)
|
||||
}
|
||||
return {"elevation": round(elevation, 1), "azimuth": round(azimuth, 1), "distance": round(slant_range, 1)}
|
||||
|
||||
|
||||
@sstv_bp.route('/decode-file', methods=['POST'])
|
||||
@sstv_bp.route("/iss-track")
|
||||
def iss_track():
|
||||
"""
|
||||
Return ISS ground track points propagated from TLE data.
|
||||
|
||||
Uses skyfield SGP4 propagation over ±90 minutes (roughly one full orbit)
|
||||
to produce an accurate track that accounts for Earth's rotation.
|
||||
|
||||
Returns:
|
||||
JSON with list of {lat, lon, past} points.
|
||||
"""
|
||||
try:
|
||||
from datetime import timedelta
|
||||
|
||||
from skyfield.api import EarthSatellite, wgs84
|
||||
|
||||
iss_tle = get_cached_tle("ISS")
|
||||
if not iss_tle:
|
||||
return jsonify({"status": "error", "message": "ISS TLE not available"}), 500
|
||||
|
||||
ts = _get_timescale()
|
||||
satellite = EarthSatellite(iss_tle[1], iss_tle[2], iss_tle[0], ts)
|
||||
now = ts.now()
|
||||
now_dt = now.utc_datetime()
|
||||
|
||||
track = []
|
||||
for minutes_offset in range(-90, 91, 1):
|
||||
t_point = ts.utc(now_dt + timedelta(minutes=minutes_offset))
|
||||
try:
|
||||
geo = satellite.at(t_point)
|
||||
sp = wgs84.subpoint(geo)
|
||||
track.append(
|
||||
{
|
||||
"lat": round(float(sp.latitude.degrees), 4),
|
||||
"lon": round(float(sp.longitude.degrees), 4),
|
||||
"past": minutes_offset < 0,
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return jsonify({"status": "ok", "track": track})
|
||||
|
||||
except ImportError:
|
||||
return jsonify({"status": "error", "message": "skyfield not installed"}), 503
|
||||
except Exception as e:
|
||||
logger.error(f"Error computing ISS track: {e}")
|
||||
return jsonify({"status": "error", "message": str(e)}), 500
|
||||
|
||||
|
||||
@sstv_bp.route("/decode-file", methods=["POST"])
|
||||
def decode_file():
|
||||
"""
|
||||
Decode SSTV from an uploaded audio file.
|
||||
@@ -777,23 +796,18 @@ def decode_file():
|
||||
Returns:
|
||||
JSON with decoded images.
|
||||
"""
|
||||
if 'audio' not in request.files:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'No audio file provided'
|
||||
}), 400
|
||||
if "audio" not in request.files:
|
||||
return jsonify({"status": "error", "message": "No audio file provided"}), 400
|
||||
|
||||
audio_file = request.files['audio']
|
||||
audio_file = request.files["audio"]
|
||||
|
||||
if not audio_file.filename:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'No file selected'
|
||||
}), 400
|
||||
return jsonify({"status": "error", "message": "No file selected"}), 400
|
||||
|
||||
# Save to temp file
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp:
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
|
||||
audio_file.save(tmp.name)
|
||||
tmp_path = tmp.name
|
||||
|
||||
@@ -801,22 +815,13 @@ def decode_file():
|
||||
decoder = get_sstv_decoder()
|
||||
images = decoder.decode_file(tmp_path)
|
||||
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'images': [img.to_dict() for img in images],
|
||||
'count': len(images)
|
||||
})
|
||||
return jsonify({"status": "ok", "images": [img.to_dict() for img in images], "count": len(images)})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error decoding file: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
return jsonify({"status": "error", "message": str(e)}), 500
|
||||
|
||||
finally:
|
||||
# Clean up temp file
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
Path(tmp_path).unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -6,18 +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
|
||||
|
||||
from utils.responses import api_success, api_error
|
||||
import app as app_module
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import sse_stream_fanout
|
||||
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,
|
||||
)
|
||||
@@ -325,7 +324,5 @@ def decode_file():
|
||||
return api_error(str(e), 500)
|
||||
|
||||
finally:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
Path(tmp_path).unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
+15
-16
@@ -6,25 +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.responses import api_success, api_error
|
||||
from utils.constants import (
|
||||
SUBGHZ_FREQ_MAX_MHZ,
|
||||
SUBGHZ_FREQ_MIN_MHZ,
|
||||
SUBGHZ_LNA_GAIN_MAX,
|
||||
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
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.constants import (
|
||||
SUBGHZ_FREQ_MIN_MHZ,
|
||||
SUBGHZ_FREQ_MAX_MHZ,
|
||||
SUBGHZ_LNA_GAIN_MAX,
|
||||
SUBGHZ_VGA_GAIN_MAX,
|
||||
SUBGHZ_TX_VGA_GAIN_MAX,
|
||||
SUBGHZ_TX_MAX_DURATION,
|
||||
SUBGHZ_SAMPLE_RATES,
|
||||
SUBGHZ_PRESETS,
|
||||
)
|
||||
|
||||
logger = get_logger('intercept.subghz')
|
||||
|
||||
@@ -36,10 +37,8 @@ _subghz_queue: queue.Queue = queue.Queue(maxsize=200)
|
||||
|
||||
def _event_callback(event: dict) -> None:
|
||||
"""Forward SubGhzManager events to the SSE queue."""
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
process_event('subghz', event, event.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
_subghz_queue.put_nowait(event)
|
||||
except queue.Full:
|
||||
|
||||
+168
-166
@@ -11,6 +11,7 @@ import contextlib
|
||||
import os
|
||||
import platform
|
||||
import queue
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
@@ -22,7 +23,7 @@ from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
from utils.constants import SSE_KEEPALIVE_INTERVAL, SSE_QUEUE_TIMEOUT
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.responses import api_success, api_error
|
||||
from utils.responses import api_error
|
||||
from utils.sse import sse_stream_fanout
|
||||
|
||||
try:
|
||||
@@ -38,7 +39,7 @@ try:
|
||||
except ImportError:
|
||||
_requests = None # type: ignore[assignment]
|
||||
|
||||
system_bp = Blueprint('system', __name__, url_prefix='/system')
|
||||
system_bp = Blueprint("system", __name__, url_prefix="/system")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Background metrics collector
|
||||
@@ -62,7 +63,7 @@ def _get_app_start_time() -> float:
|
||||
try:
|
||||
import app as app_module
|
||||
|
||||
_app_start_time = getattr(app_module, '_app_start_time', time.time())
|
||||
_app_start_time = getattr(app_module, "_app_start_time", time.time())
|
||||
except Exception:
|
||||
_app_start_time = time.time()
|
||||
return _app_start_time
|
||||
@@ -75,7 +76,7 @@ def _get_app_version() -> str:
|
||||
|
||||
return VERSION
|
||||
except Exception:
|
||||
return 'unknown'
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _format_uptime(seconds: float) -> str:
|
||||
@@ -85,11 +86,11 @@ def _format_uptime(seconds: float) -> str:
|
||||
minutes = int((seconds % 3600) // 60)
|
||||
parts = []
|
||||
if days > 0:
|
||||
parts.append(f'{days}d')
|
||||
parts.append(f"{days}d")
|
||||
if hours > 0:
|
||||
parts.append(f'{hours}h')
|
||||
parts.append(f'{minutes}m')
|
||||
return ' '.join(parts)
|
||||
parts.append(f"{hours}h")
|
||||
parts.append(f"{minutes}m")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def _collect_process_status() -> dict[str, bool]:
|
||||
@@ -110,15 +111,15 @@ def _collect_process_status() -> dict[str, bool]:
|
||||
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'),
|
||||
"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
|
||||
@@ -126,26 +127,26 @@ def _collect_process_status() -> dict[str, bool]:
|
||||
from app import _get_wifi_health
|
||||
|
||||
wifi_active, _, _ = _get_wifi_health()
|
||||
processes['wifi'] = wifi_active
|
||||
processes["wifi"] = wifi_active
|
||||
except Exception:
|
||||
processes['wifi'] = False
|
||||
processes["wifi"] = False
|
||||
|
||||
# Bluetooth
|
||||
try:
|
||||
from app import _get_bluetooth_health
|
||||
|
||||
bt_active, _ = _get_bluetooth_health()
|
||||
processes['bluetooth'] = bt_active
|
||||
processes["bluetooth"] = bt_active
|
||||
except Exception:
|
||||
processes['bluetooth'] = False
|
||||
processes["bluetooth"] = False
|
||||
|
||||
# SubGHz
|
||||
try:
|
||||
from app import _get_subghz_active
|
||||
|
||||
processes['subghz'] = _get_subghz_active()
|
||||
processes["subghz"] = _get_subghz_active()
|
||||
except Exception:
|
||||
processes['subghz'] = False
|
||||
processes["subghz"] = False
|
||||
|
||||
return processes
|
||||
except Exception:
|
||||
@@ -154,15 +155,17 @@ def _collect_process_status() -> dict[str, bool]:
|
||||
|
||||
def _collect_throttle_flags() -> str | None:
|
||||
"""Read Raspberry Pi throttle flags via vcgencmd (Linux/Pi only)."""
|
||||
if shutil.which("vcgencmd") is None:
|
||||
return None
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['vcgencmd', 'get_throttled'],
|
||||
["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]
|
||||
if result.returncode == 0 and "throttled=" in result.stdout:
|
||||
return result.stdout.strip().split("=", 1)[1]
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
@@ -171,11 +174,11 @@ def _collect_throttle_flags() -> str | None:
|
||||
def _collect_power_draw() -> float | None:
|
||||
"""Read power draw in watts from sysfs (Linux only)."""
|
||||
try:
|
||||
power_supply = Path('/sys/class/power_supply')
|
||||
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'
|
||||
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
|
||||
@@ -191,17 +194,17 @@ def _collect_metrics() -> dict[str, Any]:
|
||||
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),
|
||||
"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(),
|
||||
"processes": _collect_process_status(),
|
||||
}
|
||||
|
||||
if _HAS_PSUTIL:
|
||||
@@ -222,61 +225,61 @@ def _collect_metrics() -> dict[str, Any]:
|
||||
freq = psutil.cpu_freq()
|
||||
if freq:
|
||||
freq_data = {
|
||||
'current': round(freq.current, 0),
|
||||
'min': round(freq.min, 0),
|
||||
'max': round(freq.max, 0),
|
||||
"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,
|
||||
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,
|
||||
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,
|
||||
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': '/',
|
||||
disk = psutil.disk_usage("/")
|
||||
metrics["disk"] = {
|
||||
"total": disk.total,
|
||||
"used": disk.used,
|
||||
"free": disk.free,
|
||||
"percent": disk.percent,
|
||||
"path": "/",
|
||||
}
|
||||
except Exception:
|
||||
metrics['disk'] = None
|
||||
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,
|
||||
"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
|
||||
metrics["disk_io"] = disk_io
|
||||
|
||||
# Temperatures
|
||||
try:
|
||||
@@ -286,18 +289,18 @@ def _collect_metrics() -> 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,
|
||||
"label": e.label or chip,
|
||||
"current": e.current,
|
||||
"high": e.high,
|
||||
"critical": e.critical,
|
||||
}
|
||||
for e in entries
|
||||
]
|
||||
metrics['temperatures'] = temp_data
|
||||
metrics["temperatures"] = temp_data
|
||||
else:
|
||||
metrics['temperatures'] = None
|
||||
metrics["temperatures"] = None
|
||||
except (AttributeError, Exception):
|
||||
metrics['temperatures'] = None
|
||||
metrics["temperatures"] = None
|
||||
|
||||
# Fans
|
||||
fans_data = None
|
||||
@@ -306,11 +309,8 @@ def _collect_metrics() -> dict[str, Any]:
|
||||
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
|
||||
fans_data[chip] = [{"label": e.label or chip, "current": e.current} for e in entries]
|
||||
metrics["fans"] = fans_data
|
||||
|
||||
# Battery
|
||||
battery_data = None
|
||||
@@ -318,11 +318,11 @@ def _collect_metrics() -> dict[str, Any]:
|
||||
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,
|
||||
"percent": bat.percent,
|
||||
"plugged": bat.power_plugged,
|
||||
"secs_left": bat.secsleft if bat.secsleft != psutil.POWER_TIME_UNLIMITED else None,
|
||||
}
|
||||
metrics['battery'] = battery_data
|
||||
metrics["battery"] = battery_data
|
||||
|
||||
# Network interfaces
|
||||
net_ifaces: list[dict[str, Any]] = []
|
||||
@@ -330,25 +330,25 @@ def _collect_metrics() -> dict[str, Any]:
|
||||
addrs = psutil.net_if_addrs()
|
||||
stats = psutil.net_if_stats()
|
||||
for iface_name in sorted(addrs.keys()):
|
||||
if iface_name == 'lo':
|
||||
if iface_name == "lo":
|
||||
continue
|
||||
iface_info: dict[str, Any] = {'name': iface_name}
|
||||
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
|
||||
iface_info["ipv4"] = addr.address
|
||||
elif addr.family == socket.AF_INET6:
|
||||
iface_info.setdefault('ipv6', addr.address)
|
||||
iface_info.setdefault("ipv6", addr.address)
|
||||
elif addr.family == psutil.AF_LINK:
|
||||
iface_info['mac'] = addr.address
|
||||
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
|
||||
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}
|
||||
metrics["network"] = {"interfaces": net_ifaces}
|
||||
|
||||
# Network I/O counters (raw — JS computes deltas)
|
||||
net_io = None
|
||||
@@ -357,43 +357,43 @@ def _collect_metrics() -> dict[str, Any]:
|
||||
if counters:
|
||||
net_io = {}
|
||||
for nic, c in counters.items():
|
||||
if nic == 'lo':
|
||||
if nic == "lo":
|
||||
continue
|
||||
net_io[nic] = {
|
||||
'bytes_sent': c.bytes_sent,
|
||||
'bytes_recv': c.bytes_recv,
|
||||
"bytes_sent": c.bytes_sent,
|
||||
"bytes_recv": c.bytes_recv,
|
||||
}
|
||||
metrics['network']['io'] = net_io
|
||||
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
|
||||
metrics["network"]["connections"] = conn_count
|
||||
|
||||
# Boot time
|
||||
boot_ts = None
|
||||
with contextlib.suppress(Exception):
|
||||
boot_ts = psutil.boot_time()
|
||||
metrics['boot_time'] = boot_ts
|
||||
metrics["boot_time"] = boot_ts
|
||||
|
||||
# Power / throttle (Pi-specific)
|
||||
metrics['power'] = {
|
||||
'throttled': _collect_throttle_flags(),
|
||||
'draw_watts': _collect_power_draw(),
|
||||
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
|
||||
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
|
||||
|
||||
@@ -416,7 +416,7 @@ def _collector_loop() -> None:
|
||||
_metrics_queue.get_nowait()
|
||||
_metrics_queue.put_nowait(metrics)
|
||||
except Exception as exc:
|
||||
logger.debug('system metrics collection error: %s', exc)
|
||||
logger.debug("system metrics collection error: %s", exc)
|
||||
time.sleep(3)
|
||||
|
||||
|
||||
@@ -428,15 +428,15 @@ def _ensure_collector() -> None:
|
||||
with _collector_lock:
|
||||
if _collector_started:
|
||||
return
|
||||
t = threading.Thread(target=_collector_loop, daemon=True, name='system-metrics-collector')
|
||||
t = threading.Thread(target=_collector_loop, daemon=True, name="system-metrics-collector")
|
||||
t.start()
|
||||
_collector_started = True
|
||||
logger.info('System metrics collector started')
|
||||
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'
|
||||
lat, lon, source = None, None, "none"
|
||||
gps_meta: dict[str, Any] = {}
|
||||
|
||||
# Try GPS via utils.gps
|
||||
@@ -445,13 +445,13 @@ def _get_observer_location() -> dict[str, Any]:
|
||||
|
||||
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
|
||||
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)
|
||||
gps_meta["accuracy"] = round(max(pos.epx, pos.epy), 1)
|
||||
if pos.altitude is not None:
|
||||
gps_meta['altitude'] = round(pos.altitude, 1)
|
||||
gps_meta["altitude"] = round(pos.altitude, 1)
|
||||
|
||||
# Fall back to config env vars
|
||||
if lat is None:
|
||||
@@ -459,7 +459,7 @@ def _get_observer_location() -> dict[str, Any]:
|
||||
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'
|
||||
lat, lon, source = DEFAULT_LATITUDE, DEFAULT_LONGITUDE, "config"
|
||||
|
||||
# Fall back to hardcoded constants (London)
|
||||
if lat is None:
|
||||
@@ -467,11 +467,11 @@ def _get_observer_location() -> dict[str, Any]:
|
||||
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'
|
||||
lat, lon, source = CONST_LAT, CONST_LON, "default"
|
||||
|
||||
result: dict[str, Any] = {'lat': lat, 'lon': lon, 'source': source}
|
||||
result: dict[str, Any] = {"lat": lat, "lon": lon, "source": source}
|
||||
if gps_meta:
|
||||
result['gps'] = gps_meta
|
||||
result["gps"] = gps_meta
|
||||
return result
|
||||
|
||||
|
||||
@@ -480,14 +480,14 @@ def _get_observer_location() -> dict[str, Any]:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@system_bp.route('/metrics')
|
||||
@system_bp.route("/metrics")
|
||||
def get_metrics() -> Response:
|
||||
"""REST snapshot of current system metrics."""
|
||||
_ensure_collector()
|
||||
return jsonify(_collect_metrics())
|
||||
|
||||
|
||||
@system_bp.route('/stream')
|
||||
@system_bp.route("/stream")
|
||||
def stream_system() -> Response:
|
||||
"""SSE stream for real-time system metrics."""
|
||||
_ensure_collector()
|
||||
@@ -495,18 +495,18 @@ def stream_system() -> Response:
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=_metrics_queue,
|
||||
channel_key='system',
|
||||
channel_key="system",
|
||||
timeout=SSE_QUEUE_TIMEOUT,
|
||||
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
mimetype="text/event-stream",
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers["Cache-Control"] = "no-cache"
|
||||
response.headers["X-Accel-Buffering"] = "no"
|
||||
return response
|
||||
|
||||
|
||||
@system_bp.route('/sdr_devices')
|
||||
@system_bp.route("/sdr_devices")
|
||||
def get_sdr_devices() -> Response:
|
||||
"""Enumerate all connected SDR devices (on-demand, not every tick)."""
|
||||
try:
|
||||
@@ -515,26 +515,28 @@ def get_sdr_devices() -> Response:
|
||||
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})
|
||||
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)})
|
||||
logger.warning("SDR device detection failed: %s", exc)
|
||||
return jsonify({"devices": [], "error": str(exc)})
|
||||
|
||||
|
||||
@system_bp.route('/location')
|
||||
@system_bp.route("/location")
|
||||
def get_location() -> Response:
|
||||
"""Return observer location from GPS or config."""
|
||||
return jsonify(_get_observer_location())
|
||||
|
||||
|
||||
@system_bp.route('/weather')
|
||||
@system_bp.route("/weather")
|
||||
def get_weather() -> Response:
|
||||
"""Proxy weather from wttr.in, cached for 10 minutes."""
|
||||
global _weather_cache, _weather_cache_time
|
||||
@@ -543,42 +545,42 @@ def get_weather() -> Response:
|
||||
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)
|
||||
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')
|
||||
lat, lon = loc.get("lat"), loc.get("lon")
|
||||
|
||||
if lat is None or lon is None:
|
||||
return api_error('No location available')
|
||||
return api_error("No location available")
|
||||
|
||||
if _requests is None:
|
||||
return api_error('requests library not available')
|
||||
return api_error("requests library not available")
|
||||
|
||||
try:
|
||||
resp = _requests.get(
|
||||
f'https://wttr.in/{lat},{lon}?format=j1',
|
||||
f"https://wttr.in/{lat},{lon}?format=j1",
|
||||
timeout=5,
|
||||
headers={'User-Agent': 'INTERCEPT-SystemHealth/1.0'},
|
||||
headers={"User-Agent": "INTERCEPT-SystemHealth/1.0"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
current = data.get('current_condition', [{}])[0]
|
||||
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'),
|
||||
"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)
|
||||
logger.debug("Weather fetch failed: %s", exc)
|
||||
return api_error(str(exc))
|
||||
|
||||
+27
-25
@@ -7,6 +7,7 @@ threat detection, and reporting.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
@@ -23,9 +24,9 @@ from data.tscm_frequencies import (
|
||||
get_sweep_preset,
|
||||
)
|
||||
from utils.database import (
|
||||
acknowledge_tscm_threat,
|
||||
add_device_timeline_entry,
|
||||
add_tscm_threat,
|
||||
acknowledge_tscm_threat,
|
||||
cleanup_old_timeline_entries,
|
||||
create_tscm_schedule,
|
||||
create_tscm_sweep,
|
||||
@@ -43,6 +44,8 @@ from utils.database import (
|
||||
update_tscm_schedule,
|
||||
update_tscm_sweep,
|
||||
)
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.tscm.baseline import (
|
||||
BaselineComparator,
|
||||
BaselineRecorder,
|
||||
@@ -56,12 +59,10 @@ from utils.tscm.correlation import (
|
||||
from utils.tscm.detector import ThreatDetector
|
||||
from utils.tscm.device_identity import (
|
||||
get_identity_engine,
|
||||
reset_identity_engine,
|
||||
ingest_ble_dict,
|
||||
ingest_wifi_dict,
|
||||
reset_identity_engine,
|
||||
)
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.sse import sse_stream_fanout
|
||||
|
||||
# Import unified Bluetooth scanner helper for TSCM integration
|
||||
try:
|
||||
@@ -489,6 +490,7 @@ def _start_sweep_internal(
|
||||
bt_interface: str = '',
|
||||
sdr_device: int | None = None,
|
||||
verbose_results: bool = False,
|
||||
custom_ranges: list[dict] | None = None,
|
||||
) -> dict:
|
||||
"""Start a TSCM sweep without request context."""
|
||||
global _sweep_running, _sweep_thread, _current_sweep_id
|
||||
@@ -531,7 +533,7 @@ def _start_sweep_internal(
|
||||
_sweep_thread = threading.Thread(
|
||||
target=_run_sweep,
|
||||
args=(sweep_type, baseline_id, wifi_enabled, bt_enabled, rf_enabled,
|
||||
wifi_interface, bt_interface, sdr_device, verbose_results),
|
||||
wifi_interface, bt_interface, sdr_device, verbose_results, custom_ranges),
|
||||
daemon=True
|
||||
)
|
||||
_sweep_thread.start()
|
||||
@@ -659,8 +661,8 @@ def _scan_bluetooth_devices(interface: str, duration: int = 10) -> list[dict]:
|
||||
Uses the BLE scanner module (bleak library) for proper manufacturer ID
|
||||
detection, with fallback to system tools if bleak is unavailable.
|
||||
"""
|
||||
import platform
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -874,10 +876,8 @@ def _scan_bluetooth_devices(interface: str, duration: int = 10) -> list[dict]:
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
|
||||
try:
|
||||
with contextlib.suppress(OSError):
|
||||
os.close(master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
logger.info(f"bluetoothctl scan found {len(devices)} devices")
|
||||
|
||||
@@ -914,7 +914,8 @@ def _scan_rf_signals(
|
||||
"""
|
||||
# Default stop check uses module-level _sweep_running
|
||||
if stop_check is None:
|
||||
stop_check = lambda: not _sweep_running
|
||||
def stop_check():
|
||||
return not _sweep_running
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -954,11 +955,11 @@ def _scan_rf_signals(
|
||||
# Tool exists but no device detected — try anyway (detection may have failed)
|
||||
sdr_type = 'rtlsdr'
|
||||
sweep_tool_path = rtl_power_path
|
||||
logger.info(f"No SDR detected but rtl_power found, attempting RTL-SDR scan")
|
||||
logger.info("No SDR detected but rtl_power found, attempting RTL-SDR scan")
|
||||
elif hackrf_sweep_path:
|
||||
sdr_type = 'hackrf'
|
||||
sweep_tool_path = hackrf_sweep_path
|
||||
logger.info(f"No SDR detected but hackrf_sweep found, attempting HackRF scan")
|
||||
logger.info("No SDR detected but hackrf_sweep found, attempting HackRF scan")
|
||||
|
||||
if not sweep_tool_path:
|
||||
logger.warning("No supported sweep tool found (rtl_power or hackrf_sweep)")
|
||||
@@ -1059,14 +1060,14 @@ def _scan_rf_signals(
|
||||
|
||||
# Parse the CSV output (same format for both rtl_power and hackrf_sweep)
|
||||
if os.path.exists(tmp_path) and os.path.getsize(tmp_path) > 0:
|
||||
with open(tmp_path, 'r') as f:
|
||||
with open(tmp_path) as f:
|
||||
for line in f:
|
||||
parts = line.strip().split(',')
|
||||
if len(parts) >= 7:
|
||||
try:
|
||||
# CSV format: date, time, hz_low, hz_high, hz_step, samples, db_values...
|
||||
hz_low = int(parts[2].strip())
|
||||
hz_high = int(parts[3].strip())
|
||||
int(parts[3].strip())
|
||||
hz_step = float(parts[4].strip())
|
||||
db_values = [float(x) for x in parts[6:] if x.strip()]
|
||||
|
||||
@@ -1100,10 +1101,8 @@ def _scan_rf_signals(
|
||||
|
||||
finally:
|
||||
# Cleanup temp file
|
||||
try:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Deduplicate nearby frequencies (within 100kHz)
|
||||
if signals:
|
||||
@@ -1129,7 +1128,8 @@ def _run_sweep(
|
||||
wifi_interface: str = '',
|
||||
bt_interface: str = '',
|
||||
sdr_device: int | None = None,
|
||||
verbose_results: bool = False
|
||||
verbose_results: bool = False,
|
||||
custom_ranges: list[dict] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Run the TSCM sweep in a background thread.
|
||||
@@ -1506,7 +1506,7 @@ def _run_sweep(
|
||||
'rf_count': len(all_rf),
|
||||
})
|
||||
# Try RF scan even if sdr_device is None (will use device 0)
|
||||
rf_signals = _scan_rf_signals(sdr_device, sweep_ranges=preset.get('ranges'))
|
||||
rf_signals = _scan_rf_signals(sdr_device, sweep_ranges=custom_ranges or preset.get('ranges'))
|
||||
|
||||
# If no signals and this is first RF scan, send info event
|
||||
if not rf_signals and last_rf_scan == 0:
|
||||
@@ -1816,9 +1816,11 @@ def _generate_assessment(summary: dict) -> str:
|
||||
# =============================================================================
|
||||
# Import sub-modules to register routes on tscm_bp
|
||||
# =============================================================================
|
||||
from routes.tscm import sweep # noqa: E402, F401
|
||||
from routes.tscm import baseline # noqa: E402, F401
|
||||
from routes.tscm import cases # noqa: E402, F401
|
||||
from routes.tscm import meeting # noqa: E402, F401
|
||||
from routes.tscm import analysis # noqa: E402, F401
|
||||
from routes.tscm import schedules # noqa: E402, F401
|
||||
from routes.tscm import (
|
||||
analysis, # noqa: E402, F401
|
||||
baseline, # noqa: E402, F401
|
||||
cases, # noqa: E402, F401
|
||||
meeting, # noqa: E402, F401
|
||||
schedules, # noqa: E402, F401
|
||||
sweep, # noqa: E402, F401
|
||||
)
|
||||
|
||||
@@ -14,7 +14,6 @@ from datetime import datetime
|
||||
from flask import Response, jsonify, request
|
||||
|
||||
from routes.tscm import (
|
||||
_current_sweep_id,
|
||||
_generate_assessment,
|
||||
tscm_bp,
|
||||
)
|
||||
@@ -253,9 +252,9 @@ def get_pdf_report():
|
||||
summary, and mandatory disclaimers.
|
||||
"""
|
||||
try:
|
||||
from utils.tscm.reports import generate_report, get_pdf_report
|
||||
from utils.tscm.advanced import detect_sweep_capabilities, get_timeline_manager
|
||||
from routes.tscm import _current_sweep_id
|
||||
from utils.tscm.advanced import detect_sweep_capabilities, get_timeline_manager
|
||||
from utils.tscm.reports import generate_report, get_pdf_report
|
||||
|
||||
sweep_id = request.args.get('sweep_id', _current_sweep_id, type=int)
|
||||
if not sweep_id:
|
||||
@@ -306,9 +305,9 @@ def get_technical_annex():
|
||||
for audit purposes. No packet data included.
|
||||
"""
|
||||
try:
|
||||
from utils.tscm.reports import generate_report, get_json_annex, get_csv_annex
|
||||
from utils.tscm.advanced import detect_sweep_capabilities, get_timeline_manager
|
||||
from routes.tscm import _current_sweep_id
|
||||
from utils.tscm.advanced import detect_sweep_capabilities, get_timeline_manager
|
||||
from utils.tscm.reports import generate_report, get_csv_annex, get_json_annex
|
||||
|
||||
sweep_id = request.args.get('sweep_id', _current_sweep_id, type=int)
|
||||
format_type = request.args.get('format', 'json')
|
||||
@@ -900,8 +899,8 @@ def get_device_timeline_endpoint(identifier: str):
|
||||
and meeting window correlation.
|
||||
"""
|
||||
try:
|
||||
from utils.tscm.advanced import get_timeline_manager
|
||||
from utils.database import get_device_timeline
|
||||
from utils.tscm.advanced import get_timeline_manager
|
||||
|
||||
protocol = request.args.get('protocol', 'bluetooth')
|
||||
since_hours = request.args.get('since_hours', 24, type=int)
|
||||
|
||||
@@ -25,7 +25,6 @@ from utils.database import (
|
||||
set_active_tscm_baseline,
|
||||
)
|
||||
from utils.tscm.baseline import (
|
||||
BaselineComparator,
|
||||
get_comparison_for_active_baseline,
|
||||
)
|
||||
|
||||
@@ -213,7 +212,6 @@ def get_baseline_diff(baseline_id: int, sweep_id: int):
|
||||
def get_baseline_health(baseline_id: int):
|
||||
"""Get health assessment for a baseline."""
|
||||
try:
|
||||
from utils.tscm.advanced import BaselineHealth
|
||||
|
||||
baseline = get_tscm_baseline(baseline_id)
|
||||
if not baseline:
|
||||
|
||||
@@ -91,7 +91,6 @@ def start_tracked_meeting():
|
||||
"""
|
||||
from utils.database import start_meeting_window
|
||||
from utils.tscm.advanced import get_timeline_manager
|
||||
from routes.tscm import _current_sweep_id
|
||||
|
||||
data = request.get_json() or {}
|
||||
|
||||
@@ -156,9 +155,9 @@ def end_tracked_meeting(meeting_id: int):
|
||||
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
|
||||
from routes.tscm import _current_sweep_id
|
||||
|
||||
# Get meeting window
|
||||
windows = get_meeting_windows(_current_sweep_id or 0)
|
||||
@@ -194,7 +193,6 @@ def get_meeting_summary_endpoint(meeting_id: int):
|
||||
def get_active_meeting():
|
||||
"""Get currently active meeting window."""
|
||||
from utils.database import get_active_meeting_window
|
||||
from routes.tscm import _current_sweep_id
|
||||
|
||||
meeting = get_active_meeting_window(_current_sweep_id)
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ from routes.tscm import (
|
||||
_get_schedule_timezone,
|
||||
_next_run_from_cron,
|
||||
_start_sweep_internal,
|
||||
_sweep_running,
|
||||
tscm_bp,
|
||||
)
|
||||
from utils.database import (
|
||||
|
||||
+33
-20
@@ -7,27 +7,22 @@ Handles /sweep/*, /status, /devices, /presets/*, /feed/*,
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
from flask import Response, jsonify, request
|
||||
|
||||
from data.tscm_frequencies import get_all_sweep_presets, get_sweep_preset
|
||||
from routes.tscm import (
|
||||
_current_sweep_id,
|
||||
_baseline_recorder,
|
||||
_emit_event,
|
||||
_start_sweep_internal,
|
||||
_sweep_running,
|
||||
tscm_bp,
|
||||
tscm_queue,
|
||||
_baseline_recorder,
|
||||
)
|
||||
from data.tscm_frequencies import get_all_sweep_presets, get_sweep_preset
|
||||
from utils.database import get_tscm_sweep, update_tscm_sweep
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.sse import sse_stream_fanout
|
||||
@@ -38,8 +33,8 @@ logger = logging.getLogger('intercept.tscm')
|
||||
@tscm_bp.route('/status')
|
||||
def tscm_status():
|
||||
"""Check if any TSCM operation is currently running."""
|
||||
from routes.tscm import _sweep_running
|
||||
return jsonify({'running': _sweep_running})
|
||||
import routes.tscm as _tscm_pkg
|
||||
return jsonify({'running': _tscm_pkg._sweep_running})
|
||||
|
||||
|
||||
@tscm_bp.route('/sweep/start', methods=['POST'])
|
||||
@@ -60,6 +55,25 @@ def start_sweep():
|
||||
bt_interface = data.get('bt_interface', '')
|
||||
sdr_device = data.get('sdr_device')
|
||||
|
||||
# Validate custom frequency ranges if provided
|
||||
custom_ranges = None
|
||||
if sweep_type == 'custom':
|
||||
raw_ranges = data.get('custom_ranges') or []
|
||||
validated = []
|
||||
for rng in raw_ranges:
|
||||
try:
|
||||
start = float(rng.get('start', 0))
|
||||
end = float(rng.get('end', 0))
|
||||
step = float(rng.get('step', 0.1))
|
||||
if 0 < start < end <= 6000:
|
||||
validated.append({'start': start, 'end': end, 'step': step,
|
||||
'name': rng.get('name') or f'{start:.0f}–{end:.0f} MHz'})
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if not validated:
|
||||
return jsonify({'status': 'error', 'message': 'custom sweep requires valid start/end MHz'}), 400
|
||||
custom_ranges = validated
|
||||
|
||||
result = _start_sweep_internal(
|
||||
sweep_type=sweep_type,
|
||||
baseline_id=baseline_id,
|
||||
@@ -70,6 +84,7 @@ def start_sweep():
|
||||
bt_interface=bt_interface,
|
||||
sdr_device=sdr_device,
|
||||
verbose_results=verbose_results,
|
||||
custom_ranges=custom_ranges,
|
||||
)
|
||||
http_status = result.pop('http_status', 200)
|
||||
return jsonify(result), http_status
|
||||
@@ -98,15 +113,15 @@ def stop_sweep():
|
||||
@tscm_bp.route('/sweep/status')
|
||||
def sweep_status():
|
||||
"""Get current sweep status."""
|
||||
from routes.tscm import _sweep_running, _current_sweep_id
|
||||
import routes.tscm as _tscm_pkg
|
||||
|
||||
status = {
|
||||
'running': _sweep_running,
|
||||
'sweep_id': _current_sweep_id,
|
||||
'running': _tscm_pkg._sweep_running,
|
||||
'sweep_id': _tscm_pkg._current_sweep_id,
|
||||
}
|
||||
|
||||
if _current_sweep_id:
|
||||
sweep = get_tscm_sweep(_current_sweep_id)
|
||||
if _tscm_pkg._current_sweep_id:
|
||||
sweep = get_tscm_sweep(_tscm_pkg._current_sweep_id)
|
||||
if sweep:
|
||||
status['sweep'] = sweep
|
||||
|
||||
@@ -116,14 +131,15 @@ def sweep_status():
|
||||
@tscm_bp.route('/sweep/stream')
|
||||
def sweep_stream():
|
||||
"""SSE stream for real-time sweep updates."""
|
||||
from routes.tscm import tscm_queue
|
||||
|
||||
import routes.tscm as _tscm_pkg
|
||||
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('tscm', msg, msg.get('type'))
|
||||
|
||||
return Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=tscm_queue,
|
||||
source_queue=_tscm_pkg.tscm_queue,
|
||||
channel_key='tscm',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
@@ -218,7 +234,7 @@ def get_tscm_devices():
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
blocks = re.split(r'(?=^hci\d+:)', result.stdout, flags=re.MULTILINE)
|
||||
for idx, block in enumerate(blocks):
|
||||
for _idx, block in enumerate(blocks):
|
||||
if block.strip():
|
||||
first_line = block.split('\n')[0]
|
||||
match = re.match(r'(hci\d+):', first_line)
|
||||
@@ -353,7 +369,6 @@ def get_preset(preset_name: str):
|
||||
@tscm_bp.route('/feed/wifi', methods=['POST'])
|
||||
def feed_wifi():
|
||||
"""Feed WiFi device data for baseline recording."""
|
||||
from routes.tscm import _baseline_recorder
|
||||
|
||||
data = request.get_json()
|
||||
if data:
|
||||
@@ -367,7 +382,6 @@ def feed_wifi():
|
||||
@tscm_bp.route('/feed/bluetooth', methods=['POST'])
|
||||
def feed_bluetooth():
|
||||
"""Feed Bluetooth device data for baseline recording."""
|
||||
from routes.tscm import _baseline_recorder
|
||||
|
||||
data = request.get_json()
|
||||
if data:
|
||||
@@ -378,7 +392,6 @@ def feed_bluetooth():
|
||||
@tscm_bp.route('/feed/rf', methods=['POST'])
|
||||
def feed_rf():
|
||||
"""Feed RF signal data for baseline recording."""
|
||||
from routes.tscm import _baseline_recorder
|
||||
|
||||
data = request.get_json()
|
||||
if data:
|
||||
|
||||
+1
-1
@@ -4,8 +4,8 @@ from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
from utils.responses import api_success, api_error
|
||||
from utils.logging import get_logger
|
||||
from utils.responses import api_error
|
||||
from utils.updater import (
|
||||
check_for_updates,
|
||||
dismiss_update,
|
||||
|
||||
+33
-13
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
@@ -13,12 +13,11 @@ import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Generator
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
import app as app_module
|
||||
from utils.responses import api_success, api_error
|
||||
from utils.acars_translator import translate_message
|
||||
from utils.constants import (
|
||||
PROCESS_START_WAIT,
|
||||
@@ -30,6 +29,7 @@ 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
|
||||
@@ -83,11 +83,35 @@ 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)
|
||||
# Flatten nested VDL2 identifying fields to top level for correlator matching
|
||||
# dumpvdl2 nests flight/reg inside vdl2.avlc.acars and ICAO in avlc.src.addr
|
||||
try:
|
||||
vdl2_inner = data.get('vdl2', data)
|
||||
acars_payload = (vdl2_inner.get('avlc') or {}).get('acars')
|
||||
if acars_payload and acars_payload.get('label'):
|
||||
avlc = vdl2_inner.get('avlc') or {}
|
||||
acars_payload = avlc.get('acars') or {}
|
||||
|
||||
# Promote AVLC source address — this is the aircraft ICAO hex
|
||||
# Do this FIRST so even non-ACARS VDL2 frames can be correlated
|
||||
src = avlc.get('src') or {}
|
||||
src_addr = src.get('addr', '')
|
||||
src_type = src.get('type', '')
|
||||
if src_addr and src_type == 'Aircraft':
|
||||
data['icao'] = src_addr.upper()
|
||||
data['addr'] = src_addr.upper()
|
||||
|
||||
# Promote ACARS fields to top level so FlightCorrelator can match them
|
||||
if acars_payload.get('flight'):
|
||||
data['flight'] = acars_payload['flight']
|
||||
if acars_payload.get('reg'):
|
||||
data['reg'] = acars_payload['reg']
|
||||
data['tail'] = acars_payload['reg']
|
||||
if acars_payload.get('label'):
|
||||
data['label'] = acars_payload['label']
|
||||
if acars_payload.get('msg_text'):
|
||||
data['text'] = acars_payload['msg_text']
|
||||
|
||||
# Enrich with translated ACARS label (consistent with ACARS route)
|
||||
if acars_payload.get('label'):
|
||||
translation = translate_message({
|
||||
'label': acars_payload.get('label'),
|
||||
'text': acars_payload.get('msg_text', ''),
|
||||
@@ -105,10 +129,8 @@ def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) ->
|
||||
app_module.vdl2_queue.put(data)
|
||||
|
||||
# Feed flight correlator
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
get_flight_correlator().add_vdl2_message(data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Log if enabled
|
||||
if app_module.logging_enabled:
|
||||
@@ -134,10 +156,8 @@ def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) ->
|
||||
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:
|
||||
@@ -275,7 +295,7 @@ def start_vdl2() -> 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(
|
||||
|
||||
@@ -372,7 +372,6 @@ def init_waterfall_websocket(app: Flask):
|
||||
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)
|
||||
|
||||
@@ -619,7 +618,6 @@ 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
|
||||
|
||||
my_generation = _set_shared_capture_state(
|
||||
running=True,
|
||||
|
||||
+140
-22
@@ -1,25 +1,36 @@
|
||||
"""Weather Satellite decoder routes.
|
||||
|
||||
Provides endpoints for capturing and decoding weather satellite images
|
||||
from NOAA (APT) and Meteor (LRPT) satellites using SatDump.
|
||||
Provides endpoints for capturing and decoding Meteor LRPT weather
|
||||
imagery, including shared results produced by the ground-station
|
||||
observation pipeline.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import queue
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response, send_file
|
||||
from flask import Blueprint, Response, jsonify, request, send_file
|
||||
|
||||
from utils.responses import api_success, api_error
|
||||
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, validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||
from utils.validation import (
|
||||
validate_device_index,
|
||||
validate_elevation,
|
||||
validate_gain,
|
||||
validate_latitude,
|
||||
validate_longitude,
|
||||
validate_rtl_tcp_host,
|
||||
validate_rtl_tcp_port,
|
||||
)
|
||||
from utils.weather_sat import (
|
||||
DEFAULT_SAMPLE_RATE,
|
||||
WEATHER_SATELLITES,
|
||||
CaptureProgress,
|
||||
get_weather_sat_decoder,
|
||||
is_weather_sat_available,
|
||||
CaptureProgress,
|
||||
WEATHER_SATELLITES,
|
||||
DEFAULT_SAMPLE_RATE,
|
||||
)
|
||||
|
||||
logger = get_logger('intercept.weather_sat')
|
||||
@@ -29,6 +40,15 @@ weather_sat_bp = Blueprint('weather_sat', __name__, url_prefix='/weather-sat')
|
||||
# Queue for SSE progress streaming
|
||||
_weather_sat_queue: queue.Queue = queue.Queue(maxsize=100)
|
||||
|
||||
METEOR_NORAD_IDS = {
|
||||
'METEOR-M2-3': 57166,
|
||||
'METEOR-M2-4': 59051,
|
||||
}
|
||||
ALLOWED_TEST_DECODE_DIRS = (
|
||||
Path(__file__).resolve().parent.parent / 'data',
|
||||
Path(__file__).resolve().parent.parent / 'instance' / 'ground_station' / 'recordings',
|
||||
)
|
||||
|
||||
|
||||
def _progress_callback(progress: CaptureProgress) -> None:
|
||||
"""Callback to queue progress updates for SSE stream."""
|
||||
@@ -112,9 +132,9 @@ def start_capture():
|
||||
|
||||
JSON body:
|
||||
{
|
||||
"satellite": "NOAA-18", // Required: satellite key
|
||||
"satellite": "METEOR-M2-3", // Required: satellite key
|
||||
"device": 0, // RTL-SDR device index (default: 0)
|
||||
"gain": 40.0, // SDR gain in dB (default: 40)
|
||||
"gain": 30.0, // SDR gain in dB (default: 30)
|
||||
"bias_t": false // Enable bias-T for LNA (default: false)
|
||||
}
|
||||
|
||||
@@ -156,7 +176,7 @@ def start_capture():
|
||||
# Validate device index and gain
|
||||
try:
|
||||
device_index = validate_device_index(data.get('device', 0))
|
||||
gain = validate_gain(data.get('gain', 40.0))
|
||||
gain = validate_gain(data.get('gain', 30.0))
|
||||
except ValueError as e:
|
||||
logger.warning('Invalid parameter in start_capture: %s', e)
|
||||
return jsonify({
|
||||
@@ -240,7 +260,7 @@ def test_decode():
|
||||
|
||||
JSON body:
|
||||
{
|
||||
"satellite": "NOAA-18", // Required: satellite key
|
||||
"satellite": "METEOR-M2-3", // Required: satellite key
|
||||
"input_file": "/path/to/file", // Required: server-side file path
|
||||
"sample_rate": 1000000 // Sample rate in Hz (default: 1000000)
|
||||
}
|
||||
@@ -284,14 +304,13 @@ def test_decode():
|
||||
from pathlib import Path
|
||||
input_path = Path(input_file)
|
||||
|
||||
# Security: restrict to data directory (anchored to app root, not CWD)
|
||||
allowed_base = Path(__file__).resolve().parent.parent / 'data'
|
||||
# Restrict test-decode to application-owned sample and recording paths.
|
||||
try:
|
||||
resolved = input_path.resolve()
|
||||
if not resolved.is_relative_to(allowed_base):
|
||||
if not any(resolved.is_relative_to(base) for base in ALLOWED_TEST_DECODE_DIRS):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'input_file must be under the data/ directory'
|
||||
'message': 'input_file must be under INTERCEPT data or ground-station recordings'
|
||||
}), 403
|
||||
except (OSError, ValueError):
|
||||
return jsonify({
|
||||
@@ -381,21 +400,34 @@ def list_images():
|
||||
JSON with list of decoded images.
|
||||
"""
|
||||
decoder = get_weather_sat_decoder()
|
||||
images = decoder.get_images()
|
||||
images = [
|
||||
{
|
||||
**img.to_dict(),
|
||||
'source': 'weather_sat',
|
||||
'deletable': True,
|
||||
}
|
||||
for img in decoder.get_images()
|
||||
]
|
||||
images.extend(_get_ground_station_images())
|
||||
|
||||
# Filter by satellite if specified
|
||||
satellite_filter = request.args.get('satellite')
|
||||
if satellite_filter:
|
||||
images = [img for img in images if img.satellite == satellite_filter]
|
||||
images = [
|
||||
img for img in images
|
||||
if str(img.get('satellite', '')).upper() == satellite_filter.upper()
|
||||
]
|
||||
|
||||
images.sort(key=lambda img: img.get('timestamp') or '', reverse=True)
|
||||
|
||||
# Apply limit
|
||||
limit = request.args.get('limit', type=int)
|
||||
if limit and limit > 0:
|
||||
images = images[-limit:]
|
||||
images = images[:limit]
|
||||
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'images': [img.to_dict() for img in images],
|
||||
'images': images,
|
||||
'count': len(images),
|
||||
})
|
||||
|
||||
@@ -428,6 +460,36 @@ def get_image(filename: str):
|
||||
return send_file(image_path, mimetype=mimetype)
|
||||
|
||||
|
||||
@weather_sat_bp.route('/images/shared/<int:output_id>')
|
||||
def get_shared_image(output_id: int):
|
||||
"""Serve a Meteor image stored in ground-station outputs."""
|
||||
try:
|
||||
from utils.database import get_db
|
||||
|
||||
with get_db() as conn:
|
||||
row = conn.execute(
|
||||
'''
|
||||
SELECT file_path FROM ground_station_outputs
|
||||
WHERE id=? AND output_type='image'
|
||||
''',
|
||||
(output_id,),
|
||||
).fetchone()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load shared weather image %s: %s", output_id, e)
|
||||
return api_error('Image not found', 404)
|
||||
|
||||
if not row:
|
||||
return api_error('Image not found', 404)
|
||||
|
||||
image_path = Path(row['file_path'])
|
||||
if not image_path.exists():
|
||||
return api_error('Image not found', 404)
|
||||
|
||||
suffix = image_path.suffix.lower()
|
||||
mimetype = 'image/png' if suffix == '.png' else 'image/jpeg'
|
||||
return send_file(image_path, mimetype=mimetype)
|
||||
|
||||
|
||||
@weather_sat_bp.route('/images/<filename>', methods=['DELETE'])
|
||||
def delete_image(filename: str):
|
||||
"""Delete a decoded image.
|
||||
@@ -461,6 +523,62 @@ def delete_all_images():
|
||||
return jsonify({'status': 'ok', 'deleted': count})
|
||||
|
||||
|
||||
def _get_ground_station_images() -> list[dict]:
|
||||
try:
|
||||
from utils.database import get_db
|
||||
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(
|
||||
'''
|
||||
SELECT id, norad_id, file_path, metadata_json, created_at
|
||||
FROM ground_station_outputs
|
||||
WHERE output_type='image' AND backend='meteor_lrpt'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 200
|
||||
'''
|
||||
).fetchall()
|
||||
except Exception as e:
|
||||
logger.debug("Failed to fetch ground-station weather outputs: %s", e)
|
||||
return []
|
||||
|
||||
images: list[dict] = []
|
||||
for row in rows:
|
||||
file_path = Path(row['file_path'])
|
||||
if not file_path.exists():
|
||||
continue
|
||||
|
||||
metadata = {}
|
||||
raw_metadata = row['metadata_json']
|
||||
if raw_metadata:
|
||||
try:
|
||||
metadata = json.loads(raw_metadata)
|
||||
except json.JSONDecodeError:
|
||||
metadata = {}
|
||||
|
||||
satellite = metadata.get('satellite') or _satellite_from_norad(row['norad_id'])
|
||||
images.append({
|
||||
'filename': file_path.name,
|
||||
'satellite': satellite,
|
||||
'mode': metadata.get('mode', 'LRPT'),
|
||||
'timestamp': metadata.get('timestamp') or row['created_at'],
|
||||
'frequency': metadata.get('frequency', 137.9),
|
||||
'size_bytes': metadata.get('size_bytes') or file_path.stat().st_size,
|
||||
'product': metadata.get('product', ''),
|
||||
'url': f"/weather-sat/images/shared/{row['id']}",
|
||||
'source': 'ground_station',
|
||||
'deletable': False,
|
||||
'output_id': row['id'],
|
||||
})
|
||||
return images
|
||||
|
||||
|
||||
def _satellite_from_norad(norad_id: int | None) -> str:
|
||||
for satellite, known_norad in METEOR_NORAD_IDS.items():
|
||||
if known_norad == norad_id:
|
||||
return satellite
|
||||
return 'METEOR'
|
||||
|
||||
|
||||
@weather_sat_bp.route('/stream')
|
||||
def stream_progress():
|
||||
"""SSE stream of capture/decode progress.
|
||||
@@ -571,7 +689,7 @@ def enable_schedule():
|
||||
"longitude": -0.1, // Required
|
||||
"min_elevation": 15, // Minimum pass elevation (default: 15)
|
||||
"device": 0, // RTL-SDR device index (default: 0)
|
||||
"gain": 40.0, // SDR gain (default: 40)
|
||||
"gain": 30.0, // SDR gain (default: 30)
|
||||
"bias_t": false // Enable bias-T (default: false)
|
||||
}
|
||||
|
||||
@@ -613,7 +731,7 @@ def enable_schedule():
|
||||
gain=gain_val,
|
||||
bias_t=bool(data.get('bias_t', False)),
|
||||
)
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
logger.exception("Failed to enable weather sat scheduler")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
|
||||
+12
-19
@@ -9,11 +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_success, api_error
|
||||
from utils.responses import api_error, api_success
|
||||
|
||||
try:
|
||||
from flask_sock import Sock
|
||||
@@ -21,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')
|
||||
@@ -38,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
|
||||
@@ -70,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
|
||||
@@ -335,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)
|
||||
|
||||
@@ -387,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(
|
||||
|
||||
+4
-5
@@ -6,13 +6,14 @@ maritime/aviation weather services worldwide.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import queue
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request, send_file
|
||||
|
||||
from utils.responses import api_success, api_error
|
||||
import app as app_module
|
||||
from utils.logging import get_logger
|
||||
from utils.responses import api_error
|
||||
from utils.sdr import SDRType
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.validation import validate_frequency
|
||||
@@ -129,10 +130,8 @@ def start_decoder():
|
||||
frequency_reference = str(data.get('frequency_reference', 'auto')).strip().lower()
|
||||
|
||||
sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower()
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
with contextlib.suppress(ValueError):
|
||||
SDRType(sdr_type_str)
|
||||
if not frequency_reference:
|
||||
frequency_reference = 'auto'
|
||||
|
||||
|
||||
+40
-52
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
@@ -11,39 +12,25 @@ 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
|
||||
|
||||
from utils.responses import api_success, api_error
|
||||
import app as app_module
|
||||
from utils.dependencies import check_tool, get_tool_path
|
||||
from utils.logging import wifi_logger as logger
|
||||
from utils.process import is_valid_mac, is_valid_channel
|
||||
from utils.validation import validate_wifi_channel, validate_mac_address, validate_network_interface
|
||||
from utils.sse import format_sse, sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from data.oui import get_manufacturer
|
||||
from utils.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')
|
||||
|
||||
@@ -201,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
|
||||
@@ -212,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)
|
||||
@@ -230,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
|
||||
|
||||
@@ -246,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
|
||||
@@ -257,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
|
||||
@@ -280,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
|
||||
@@ -294,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')
|
||||
@@ -602,7 +588,6 @@ def toggle_monitor_mode():
|
||||
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 api_error(str(e))
|
||||
|
||||
@@ -683,20 +668,11 @@ 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 = [
|
||||
airodump_path,
|
||||
'-w', csv_path,
|
||||
'--output-format', 'csv,pcap',
|
||||
'--band', band,
|
||||
interface
|
||||
]
|
||||
|
||||
channel_list = None
|
||||
if channels:
|
||||
@@ -705,10 +681,22 @@ def start_wifi_scan():
|
||||
except ValueError as e:
|
||||
return api_error(str(e), 400)
|
||||
|
||||
cmd = [
|
||||
airodump_path,
|
||||
'-w', csv_path,
|
||||
'--output-format', 'csv,pcap',
|
||||
]
|
||||
|
||||
# --band and -c are mutually exclusive: only add --band when not
|
||||
# locking to specific channels, and always place the interface last.
|
||||
if channel_list:
|
||||
cmd.extend(['-c', ','.join(str(c) for c in channel_list)])
|
||||
elif channel:
|
||||
cmd.extend(['-c', str(channel)])
|
||||
else:
|
||||
cmd.extend(['--band', band])
|
||||
|
||||
cmd.append(interface)
|
||||
|
||||
logger.info(f"Running: {' '.join(cmd)}")
|
||||
|
||||
@@ -1021,7 +1009,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
|
||||
)
|
||||
@@ -1170,7 +1158,7 @@ def stream_wifi():
|
||||
# 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')
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user