mirror of
https://github.com/smittix/intercept.git
synced 2026-06-16 01:19:46 -07:00
Compare commits
141 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
+5
-1
@@ -40,7 +40,11 @@ tasks/
|
||||
|
||||
# Runtime data (mounted as volume)
|
||||
instance/
|
||||
data/
|
||||
|
||||
# data/ is a Python package — only exclude non-code files
|
||||
data/*.json
|
||||
data/*.csv
|
||||
data/*.db
|
||||
|
||||
# Build scripts
|
||||
build-multiarch.sh
|
||||
|
||||
@@ -10,7 +10,7 @@ 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:
|
||||
@@ -20,6 +20,7 @@ jobs:
|
||||
- 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
|
||||
run: pytest --tb=short -q
|
||||
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
|
||||
+108
@@ -2,6 +2,114 @@
|
||||
|
||||
All notable changes to iNTERCEPT will be documented in this file.
|
||||
|
||||
## [2.26.11] - 2026-03-14
|
||||
|
||||
### Fixed
|
||||
- **APRS map ignores configured observer position** — The APRS map always fell back to the centre of the US (39.8°N, 98.6°W) when no live GPS fix was available, ignoring the observer position configured in `.env` (`INTERCEPT_DEFAULT_LAT` / `INTERCEPT_DEFAULT_LON`). Now seeds the APRS user location from the shared observer location on page load, so the map centres correctly and distance calculations work. (#193)
|
||||
|
||||
---
|
||||
|
||||
## [2.26.10] - 2026-03-14
|
||||
|
||||
### Fixed
|
||||
- **APRS stop timeout and inverted SDR device status** — The APRS stop endpoint terminated two processes sequentially (up to 4s) while the frontend timed out at 2.2s, causing console errors and the SDR status panel to show stale state (active after stop, idle during use). Now releases the SDR device immediately and terminates processes in a background thread so the response returns instantly. (#194)
|
||||
|
||||
---
|
||||
|
||||
## [2.26.9] - 2026-03-14
|
||||
|
||||
### Fixed
|
||||
- **ADS-B bias-t support for RTL-SDR Blog V4** — When dump1090 lacks native `--enable-biast` support, the system now falls back to `rtl_biast` (from RTL-SDR Blog drivers) to enable bias-t power before starting dump1090. The Blog V4's built-in LNA requires bias-t to receive ADS-B signals. (#195)
|
||||
|
||||
---
|
||||
|
||||
## [2.26.8] - 2026-03-14
|
||||
|
||||
### Fixed
|
||||
- **acarsdec build failure on macOS** — `HOST_NAME_MAX` is Linux-specific (`<limits.h>`) and undefined on macOS, causing 3 compile errors in `acarsdec.c`. Now patched with `#define HOST_NAME_MAX 255` before building. Also fixed deprecated `-Ofast` flag warning on all macOS architectures (was only patched for arm64). (#187)
|
||||
|
||||
---
|
||||
|
||||
## [2.26.7] - 2026-03-14
|
||||
|
||||
### Fixed
|
||||
- **Health check SDR detection on macOS** — `timeout` (GNU coreutils) is not available on macOS, causing `rtl_test` to silently fail and report "No RTL-SDR device found" even when one is connected. Now tries `timeout`, then `gtimeout` (Homebrew coreutils), then falls back to a background process with manual kill. (#188)
|
||||
|
||||
---
|
||||
|
||||
## [2.26.6] - 2026-03-14
|
||||
|
||||
### Fixed
|
||||
- **Oversized branded 'i' logo on dashboards** — `.logo span { display: inline }` in dashboard CSS had higher specificity (0,1,1) than `.brand-i { display: inline-block }` (0,1,0), forcing the branded "i" SVG to render as inline which ignores width/height. Added `.logo .brand-i` selector (0,2,0) to retain `inline-block` display. (#189)
|
||||
|
||||
---
|
||||
|
||||
## [2.26.5] - 2026-03-14
|
||||
|
||||
### Fixed
|
||||
- **Database errors crash entire UI** — `get_setting()` now catches `sqlite3.OperationalError` and returns the default value instead of propagating the exception. Previously, if the database was inaccessible (e.g. root-owned `instance/` directory from running with `sudo`), the `inject_offline_settings` context processor would crash every page render with a 500 Internal Server Error. (#190)
|
||||
|
||||
---
|
||||
|
||||
## [2.26.4] - 2026-03-14
|
||||
|
||||
### Fixed
|
||||
- **Environment Configurator crash** — `read_env_var()` crashed with "Setup failed at line 2333" when `.env` existed but didn't contain the variable being looked up. `grep` returned exit code 1 (no match), which `pipefail` propagated and `set -e` turned into a fatal error. Fixed by appending `|| true` to the pipeline. (#191)
|
||||
|
||||
---
|
||||
|
||||
## [2.26.3] - 2026-03-13
|
||||
|
||||
### Fixed
|
||||
- **SatDump AVX2 crash** — SatDump now compiles with `-march=x86-64` on x86_64 platforms (Docker and `setup.sh`), preventing "Illegal instruction" crashes on CPUs without AVX2. SIMD plugins still use runtime detection for acceleration on capable hardware. (#185)
|
||||
|
||||
---
|
||||
|
||||
## [2.26.2] - 2026-03-13
|
||||
|
||||
### Fixed
|
||||
- **Docker startup crash** — `.dockerignore` excluded the entire `data/` directory, which is now a Python package (`data.oui`, `data.patterns`, `data.satellites`). Caused `ModuleNotFoundError: No module named 'data.oui'` on container startup. Fixed by only excluding non-code files from `data/`.
|
||||
|
||||
---
|
||||
|
||||
## [2.26.1] - 2026-03-13
|
||||
|
||||
### Fixed
|
||||
- **Default admin credentials** — Default `ADMIN_PASSWORD` changed from empty string to `admin`, matching the README documentation (`admin:admin`)
|
||||
- **Config credential sync** — Admin password changes in `config.py` or via `INTERCEPT_ADMIN_PASSWORD` env var now sync to the database on restart, without needing to delete the DB
|
||||
|
||||
---
|
||||
|
||||
## [2.26.0] - 2026-03-13
|
||||
|
||||
### Fixed
|
||||
- **SSE fanout crash** - `_run_fanout` daemon thread no longer crashes with `AttributeError: 'NoneType' object has no attribute 'get'` when source queue becomes None during interpreter shutdown
|
||||
- **Branded logo FOUC** - Added inline `width`/`height` to branded "i" SVG elements across 10 templates to prevent oversized rendering before CSS loads; refresh no longer needed
|
||||
|
||||
---
|
||||
|
||||
## [2.25.0] - 2026-03-12
|
||||
|
||||
### Added
|
||||
- **SSEManager** - Centralized SSE connection management with exponential backoff reconnection and visual connection status indicator
|
||||
- **Loading button states** - `withLoadingButton()` utility for async action buttons across all modes
|
||||
- **Actionable error reporting** - `reportActionableError()` added to 5 mode JS files for user-friendly error messages
|
||||
- **Destructive action confirmation modals** - Custom modal system replacing 25 native `confirm()` calls
|
||||
|
||||
### Changed
|
||||
- **Accessibility improvements** - aria-labels on interactive elements, form label associations, keyboard-navigable lists
|
||||
- **CSS variable adoption** - Replaced hardcoded hex colors with CSS custom properties across 16+ files
|
||||
- **Inline style extraction** - `classList.toggle()` replaces inline `display` manipulation throughout codebase
|
||||
- **Merged `global-nav.css` into `layout.css`** - Consolidated navigation styles
|
||||
- **Reduced `!important` usage** - Responsive.css `!important` count reduced from 71 to 8
|
||||
- **Standardized breakpoints** - Unified to 480/768/1024/1280px across all responsive styles
|
||||
- **Mobile UX polish** - Improved touch targets, code overflow handling, and responsive layouts
|
||||
|
||||
### Fixed
|
||||
- Deep-linked mode scripts now wait for body parse before executing, preventing initialization failures
|
||||
|
||||
---
|
||||
|
||||
## [2.24.0] - 2026-03-10
|
||||
|
||||
### Added
|
||||
|
||||
+4
-1
@@ -130,7 +130,10 @@ 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 \
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -6,8 +6,8 @@ Flask application and shared state.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import site
|
||||
import sys
|
||||
|
||||
from utils.database import get_db
|
||||
|
||||
@@ -17,32 +17,44 @@ if not site.ENABLE_USER_SITE:
|
||||
if user_site and user_site not in sys.path:
|
||||
sys.path.insert(0, user_site)
|
||||
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import threading
|
||||
import platform
|
||||
import queue
|
||||
import subprocess
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from typing import Any
|
||||
|
||||
from flask import Flask, render_template, jsonify, send_file, Response, request,redirect, url_for, flash, session, send_from_directory
|
||||
from flask import (
|
||||
Flask,
|
||||
Response,
|
||||
flash,
|
||||
jsonify,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
send_file,
|
||||
send_from_directory,
|
||||
session,
|
||||
url_for,
|
||||
)
|
||||
from werkzeug.security import check_password_hash
|
||||
from config import VERSION, CHANGELOG, SHARED_OBSERVER_LOCATION_ENABLED, DEFAULT_LATITUDE, DEFAULT_LONGITUDE
|
||||
from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
|
||||
from utils.process import cleanup_stale_processes, cleanup_stale_dump1090
|
||||
from utils.sdr import SDRFactory
|
||||
|
||||
from config import CHANGELOG, DEFAULT_LATITUDE, DEFAULT_LONGITUDE, SHARED_OBSERVER_LOCATION_ENABLED, VERSION
|
||||
from utils.cleanup import DataStore, cleanup_manager
|
||||
from utils.constants import (
|
||||
MAX_AIRCRAFT_AGE_SECONDS,
|
||||
MAX_WIFI_NETWORK_AGE_SECONDS,
|
||||
MAX_BT_DEVICE_AGE_SECONDS,
|
||||
MAX_VESSEL_AGE_SECONDS,
|
||||
MAX_DSC_MESSAGE_AGE_SECONDS,
|
||||
MAX_DEAUTH_ALERTS_AGE_SECONDS,
|
||||
MAX_DSC_MESSAGE_AGE_SECONDS,
|
||||
MAX_VESSEL_AGE_SECONDS,
|
||||
MAX_WIFI_NETWORK_AGE_SECONDS,
|
||||
QUEUE_MAX_SIZE,
|
||||
)
|
||||
import logging
|
||||
from utils.dependencies import check_all_dependencies, check_tool
|
||||
from utils.process import cleanup_stale_dump1090, cleanup_stale_processes
|
||||
from utils.sdr import SDRFactory
|
||||
|
||||
try:
|
||||
from flask_limiter import Limiter
|
||||
from flask_limiter.util import get_remote_address
|
||||
@@ -60,7 +72,9 @@ try:
|
||||
except ImportError:
|
||||
_has_csrf = False
|
||||
# Track application start time for uptime calculation
|
||||
import contextlib
|
||||
import time as _time
|
||||
|
||||
_app_start_time = _time.time()
|
||||
logger = logging.getLogger('intercept.database')
|
||||
|
||||
@@ -124,7 +138,7 @@ else:
|
||||
os.environ['WERKZEUG_DEBUG_PIN'] = 'off'
|
||||
|
||||
# ============================================
|
||||
# ERROR HANDLERS
|
||||
# ERROR HANDLERS
|
||||
# ============================================
|
||||
@app.errorhandler(429)
|
||||
def ratelimit_handler(e):
|
||||
@@ -260,6 +274,9 @@ dsc_lock = threading.Lock()
|
||||
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
tscm_lock = threading.Lock()
|
||||
|
||||
# Ground Station automation
|
||||
ground_station_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
|
||||
# SubGHz Transceiver (HackRF)
|
||||
subghz_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
subghz_lock = threading.Lock()
|
||||
@@ -425,7 +442,7 @@ def require_login():
|
||||
# If user is not logged in and the current route is not allowed...
|
||||
if 'logged_in' not in session and request.endpoint not in allowed_routes:
|
||||
return redirect(url_for('login'))
|
||||
|
||||
|
||||
@app.route('/logout')
|
||||
def logout():
|
||||
session.pop('logged_in', None)
|
||||
@@ -437,7 +454,7 @@ def login():
|
||||
if request.method == 'POST':
|
||||
username = request.form.get('username')
|
||||
password = request.form.get('password')
|
||||
|
||||
|
||||
# Connect to DB and find user
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute(
|
||||
@@ -452,21 +469,24 @@ def login():
|
||||
session['logged_in'] = True
|
||||
session['username'] = username
|
||||
session['role'] = user['role']
|
||||
|
||||
|
||||
logger.info(f"User '{username}' logged in successfully.")
|
||||
return redirect(url_for('index'))
|
||||
else:
|
||||
logger.warning(f"Failed login attempt for username: {username}")
|
||||
flash("ACCESS DENIED: INVALID CREDENTIALS", "error")
|
||||
|
||||
|
||||
return render_template('login.html', version=VERSION)
|
||||
|
||||
@app.route('/')
|
||||
def index() -> str:
|
||||
tools = {
|
||||
'rtl_fm': check_tool('rtl_fm'),
|
||||
'multimon': check_tool('multimon-ng'),
|
||||
'rtl_433': check_tool('rtl_433'),
|
||||
@app.route('/')
|
||||
def index() -> str:
|
||||
if request.args.get('mode') == 'satellite':
|
||||
return redirect(url_for('satellite.satellite_dashboard'))
|
||||
|
||||
tools = {
|
||||
'rtl_fm': check_tool('rtl_fm'),
|
||||
'multimon': check_tool('multimon-ng'),
|
||||
'rtl_433': check_tool('rtl_433'),
|
||||
'rtlamr': check_tool('rtlamr')
|
||||
}
|
||||
devices = [d.to_dict() for d in SDRFactory.detect_devices()]
|
||||
@@ -1023,10 +1043,8 @@ def kill_all() -> Response:
|
||||
bt_process.terminate()
|
||||
bt_process.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
bt_process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
bt_process = None
|
||||
|
||||
# Reset Bluetooth v2 scanner
|
||||
@@ -1113,28 +1131,35 @@ def _init_app() -> None:
|
||||
try:
|
||||
from routes.audio_websocket import init_audio_websocket
|
||||
init_audio_websocket(app)
|
||||
except ImportError:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Initialize KiwiSDR WebSocket audio proxy
|
||||
try:
|
||||
from routes.websdr import init_websdr_audio
|
||||
init_websdr_audio(app)
|
||||
except ImportError:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Initialize WebSocket for waterfall streaming
|
||||
try:
|
||||
from routes.waterfall_websocket import init_waterfall_websocket
|
||||
init_waterfall_websocket(app)
|
||||
except ImportError:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Initialize WebSocket for meteor scatter monitoring
|
||||
try:
|
||||
from routes.meteor_websocket import init_meteor_websocket
|
||||
init_meteor_websocket(app)
|
||||
except ImportError:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Initialize WebSocket for ground station live waterfall
|
||||
try:
|
||||
from routes.ground_station import init_ground_station_websocket
|
||||
init_ground_station_websocket(app)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Defer heavy/network operations so the worker can serve requests immediately
|
||||
@@ -1155,10 +1180,10 @@ def _init_app() -> None:
|
||||
# Register and start database cleanup
|
||||
try:
|
||||
from utils.database import (
|
||||
cleanup_old_dsc_alerts,
|
||||
cleanup_old_payloads,
|
||||
cleanup_old_signal_history,
|
||||
cleanup_old_timeline_entries,
|
||||
cleanup_old_dsc_alerts,
|
||||
cleanup_old_payloads
|
||||
)
|
||||
cleanup_manager.register_db_cleanup(cleanup_old_signal_history, interval_multiplier=1440)
|
||||
cleanup_manager.register_db_cleanup(cleanup_old_timeline_entries, interval_multiplier=1440)
|
||||
@@ -1176,6 +1201,30 @@ def _init_app() -> None:
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to initialize TLE auto-refresh: {e}")
|
||||
|
||||
# Pre-warm SatNOGS transmitter cache so first dashboard load is instant
|
||||
try:
|
||||
if not os.environ.get('TESTING'):
|
||||
from utils.satnogs import prefetch_transmitters
|
||||
prefetch_transmitters()
|
||||
except Exception as e:
|
||||
logger.warning(f"SatNOGS prefetch failed: {e}")
|
||||
|
||||
# Wire ground station scheduler event → SSE queue
|
||||
try:
|
||||
import app as _self
|
||||
from utils.ground_station.scheduler import get_ground_station_scheduler
|
||||
gs_scheduler = get_ground_station_scheduler()
|
||||
|
||||
def _gs_event_to_sse(event: dict) -> None:
|
||||
try:
|
||||
_self.ground_station_queue.put_nowait(event)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
gs_scheduler.set_event_callback(_gs_event_to_sse)
|
||||
except Exception as e:
|
||||
logger.warning(f"Ground station scheduler init failed: {e}")
|
||||
|
||||
threading.Thread(target=_deferred_init, daemon=True).start()
|
||||
|
||||
|
||||
@@ -1186,6 +1235,7 @@ _init_app()
|
||||
def main() -> None:
|
||||
"""Main entry point."""
|
||||
import argparse
|
||||
|
||||
import config
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
@@ -1227,7 +1277,7 @@ def main() -> None:
|
||||
results = check_all_dependencies()
|
||||
print("Dependency Status:")
|
||||
print("-" * 40)
|
||||
for mode, info in results.items():
|
||||
for _mode, info in results.items():
|
||||
status = "✓" if info['ready'] else "✗"
|
||||
print(f"\n{status} {info['name']}:")
|
||||
for tool, tool_info in info['tools'].items():
|
||||
|
||||
@@ -7,10 +7,100 @@ import os
|
||||
import sys
|
||||
|
||||
# Application version
|
||||
VERSION = "2.24.0"
|
||||
VERSION = "2.26.12"
|
||||
|
||||
# Changelog - latest release notes (shown on welcome screen)
|
||||
CHANGELOG = [
|
||||
{
|
||||
"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",
|
||||
@@ -399,7 +489,7 @@ ALERT_WEBHOOK_TIMEOUT = _get_env_int('ALERT_WEBHOOK_TIMEOUT', 5)
|
||||
|
||||
# Admin credentials
|
||||
ADMIN_USERNAME = _get_env('ADMIN_USERNAME', 'admin')
|
||||
ADMIN_PASSWORD = _get_env('ADMIN_PASSWORD', '')
|
||||
ADMIN_PASSWORD = _get_env('ADMIN_PASSWORD', 'admin')
|
||||
|
||||
|
||||
def configure_logging() -> None:
|
||||
|
||||
+5
-5
@@ -1,10 +1,10 @@
|
||||
# Data modules for INTERCEPT
|
||||
from .oui import OUI_DATABASE, load_oui_database, get_manufacturer
|
||||
from .satellites import TLE_SATELLITES
|
||||
from .oui import OUI_DATABASE, get_manufacturer, load_oui_database
|
||||
from .patterns import (
|
||||
AIRTAG_PREFIXES,
|
||||
TILE_PREFIXES,
|
||||
SAMSUNG_TRACKER,
|
||||
DRONE_SSID_PATTERNS,
|
||||
DRONE_OUI_PREFIXES,
|
||||
DRONE_SSID_PATTERNS,
|
||||
SAMSUNG_TRACKER,
|
||||
TILE_PREFIXES,
|
||||
)
|
||||
from .satellites import TLE_SATELLITES
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import json
|
||||
|
||||
logger = logging.getLogger('intercept.oui')
|
||||
|
||||
@@ -12,7 +12,7 @@ def load_oui_database() -> dict[str, str] | None:
|
||||
oui_file = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'oui_database.json')
|
||||
try:
|
||||
if os.path.exists(oui_file):
|
||||
with open(oui_file, 'r') as f:
|
||||
with open(oui_file) as f:
|
||||
data = json.load(f)
|
||||
# Remove comment fields
|
||||
return {k: v for k, v in data.items() if not k.startswith('_')}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -438,6 +438,8 @@ Deploy lightweight sensor nodes across multiple locations and aggregate data to
|
||||
|
||||
- **Mode-specific header stats** - real-time badges showing key metrics per mode
|
||||
- **UTC clock** - always visible in header for time-critical operations
|
||||
- **SSE connection status indicator** - real-time connection state with SSEManager and exponential backoff reconnection
|
||||
- **Accessibility** - aria-labels, form label associations, keyboard list navigation, and destructive action confirmation modals
|
||||
- **Active mode indicator** - shows current mode with pulse animation
|
||||
- **Collapsible sections** - click any header to collapse/expand
|
||||
- **Panel styling** - gradient backgrounds with indicator dots
|
||||
|
||||
+4
-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">34</span>
|
||||
<span class="stat-label">Modes</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
@@ -435,7 +435,7 @@ docker compose --profile basic up -d --build</code></pre>
|
||||
<div class="container">
|
||||
<div class="footer-content">
|
||||
<div class="footer-brand">
|
||||
<span class="footer-logo">iNTERCEPT</span>
|
||||
<span class="footer-logo"><span class="brand-i"><svg viewBox="36 14 28 68" width="1em" height="1em" xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="20" r="6" fill="#00ff88"/><rect x="44" y="33" width="12" height="45" rx="2" fill="#00d4ff"/><rect x="38" y="33" width="24" height="4" rx="1" fill="#00d4ff"/><rect x="38" y="74" width="24" height="4" rx="1" fill="#00d4ff"/></svg></span>NTERCEPT</span>
|
||||
<p>Signal Intelligence Platform</p>
|
||||
</div>
|
||||
<div class="footer-links">
|
||||
|
||||
@@ -86,6 +86,21 @@ body {
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
/* Branded "i" — inline SVG glyph matching the app logo */
|
||||
.brand-i {
|
||||
display: inline-block;
|
||||
width: 0.55em;
|
||||
height: 0.9em;
|
||||
vertical-align: baseline;
|
||||
position: relative;
|
||||
top: 0.05em;
|
||||
}
|
||||
.brand-i svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
+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:
|
||||
|
||||
+33
-62
@@ -13,6 +13,7 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import configparser
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -26,25 +27,24 @@ import sys
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from socketserver import ThreadingMixIn
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
# Add parent directory to path for imports
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# Import dependency checking from Intercept utils
|
||||
try:
|
||||
from utils.dependencies import check_all_dependencies, check_tool, TOOL_DEPENDENCIES
|
||||
from utils.dependencies import TOOL_DEPENDENCIES, check_all_dependencies, check_tool
|
||||
HAS_DEPENDENCIES_MODULE = True
|
||||
except ImportError:
|
||||
HAS_DEPENDENCIES_MODULE = False
|
||||
|
||||
# Import TSCM modules for consistent analysis (same as local mode)
|
||||
try:
|
||||
from utils.tscm.detector import ThreatDetector
|
||||
from utils.tscm.correlation import CorrelationEngine
|
||||
from utils.tscm.detector import ThreatDetector
|
||||
HAS_TSCM_MODULES = True
|
||||
except ImportError:
|
||||
HAS_TSCM_MODULES = False
|
||||
@@ -53,7 +53,7 @@ except ImportError:
|
||||
|
||||
# Import database functions for baseline support (same as local mode)
|
||||
try:
|
||||
from utils.database import get_tscm_baseline, get_active_tscm_baseline
|
||||
from utils.database import get_active_tscm_baseline, get_tscm_baseline
|
||||
HAS_BASELINE_DB = True
|
||||
except ImportError:
|
||||
HAS_BASELINE_DB = False
|
||||
@@ -143,7 +143,7 @@ class AgentConfig:
|
||||
|
||||
# Modes section
|
||||
if parser.has_section('modes'):
|
||||
for mode in self.modes_enabled.keys():
|
||||
for mode in self.modes_enabled:
|
||||
if parser.has_option('modes', mode):
|
||||
self.modes_enabled[mode] = parser.getboolean('modes', mode)
|
||||
|
||||
@@ -310,10 +310,8 @@ class ControllerPushClient(threading.Thread):
|
||||
except Exception as e:
|
||||
item['attempts'] += 1
|
||||
if item['attempts'] < 3 and not self.stop_event.is_set():
|
||||
try:
|
||||
with contextlib.suppress(queue.Full):
|
||||
self.queue.put_nowait(item)
|
||||
except queue.Full:
|
||||
pass
|
||||
else:
|
||||
logger.warning(f"Failed to push after {item['attempts']} attempts: {e}")
|
||||
finally:
|
||||
@@ -795,9 +793,7 @@ class ModeManager:
|
||||
info['vessel_count'] = len(getattr(self, 'ais_vessels', {}))
|
||||
elif mode == 'aprs':
|
||||
info['station_count'] = len(getattr(self, 'aprs_stations', {}))
|
||||
elif mode == 'pager':
|
||||
info['message_count'] = len(self.data_snapshots.get(mode, []))
|
||||
elif mode == 'acars':
|
||||
elif mode == 'pager' or mode == 'acars':
|
||||
info['message_count'] = len(self.data_snapshots.get(mode, []))
|
||||
elif mode == 'rtlamr':
|
||||
info['reading_count'] = len(self.data_snapshots.get(mode, []))
|
||||
@@ -1073,10 +1069,8 @@ class ModeManager:
|
||||
proc.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
proc.wait(timeout=1)
|
||||
except Exception:
|
||||
pass
|
||||
except (OSError, ProcessLookupError) as e:
|
||||
# Process already dead or inaccessible
|
||||
logger.debug(f"Process cleanup for {mode}: {e}")
|
||||
@@ -1297,10 +1291,8 @@ class ModeManager:
|
||||
except Exception as e:
|
||||
logger.error(f"Sensor output reader error: {e}")
|
||||
finally:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
proc.wait(timeout=1)
|
||||
except Exception:
|
||||
pass
|
||||
logger.info("Sensor output reader stopped")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -1661,16 +1653,14 @@ class ModeManager:
|
||||
try:
|
||||
from utils.validation import validate_network_interface
|
||||
interface = validate_network_interface(interface)
|
||||
except (ImportError, ValueError) as e:
|
||||
except (ImportError, ValueError):
|
||||
if not os.path.exists(f'/sys/class/net/{interface}'):
|
||||
return {'status': 'error', 'message': f'Interface {interface} not found'}
|
||||
|
||||
csv_path = '/tmp/intercept_agent_wifi'
|
||||
for f in [f'{csv_path}-01.csv', f'{csv_path}-01.cap', f'{csv_path}-01.gps']:
|
||||
try:
|
||||
with contextlib.suppress(OSError):
|
||||
os.remove(f)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
airodump_path = self._get_tool_path('airodump-ng')
|
||||
if not airodump_path:
|
||||
@@ -1931,7 +1921,7 @@ class ModeManager:
|
||||
logger.warning("Intercept WiFi parser not available, using fallback")
|
||||
# Fallback: simple parsing if running standalone
|
||||
try:
|
||||
with open(csv_path, 'r', errors='replace') as f:
|
||||
with open(csv_path, errors='replace') as f:
|
||||
content = f.read()
|
||||
for section in content.split('\n\n'):
|
||||
lines = section.strip().split('\n')
|
||||
@@ -2303,10 +2293,8 @@ class ModeManager:
|
||||
except Exception as e:
|
||||
logger.error(f"Pager reader error: {e}")
|
||||
finally:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
proc.wait(timeout=1)
|
||||
except Exception:
|
||||
pass
|
||||
if 'pager_rtl' in self.processes:
|
||||
try:
|
||||
rtl_proc = self.processes['pager_rtl']
|
||||
@@ -2491,7 +2479,7 @@ class ModeManager:
|
||||
|
||||
sock.close()
|
||||
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
retry_count += 1
|
||||
if retry_count >= 10:
|
||||
logger.error("Max AIS retries reached")
|
||||
@@ -2701,10 +2689,8 @@ class ModeManager:
|
||||
except Exception as e:
|
||||
logger.error(f"ACARS reader error: {e}")
|
||||
finally:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
proc.wait(timeout=1)
|
||||
except Exception:
|
||||
pass
|
||||
logger.info("ACARS reader stopped")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -2846,10 +2832,8 @@ class ModeManager:
|
||||
except Exception as e:
|
||||
logger.error(f"APRS reader error: {e}")
|
||||
finally:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
proc.wait(timeout=1)
|
||||
except Exception:
|
||||
pass
|
||||
if 'aprs_rtl' in self.processes:
|
||||
try:
|
||||
rtl_proc = self.processes['aprs_rtl']
|
||||
@@ -3021,10 +3005,8 @@ class ModeManager:
|
||||
except Exception as e:
|
||||
logger.error(f"RTLAMR reader error: {e}")
|
||||
finally:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
proc.wait(timeout=1)
|
||||
except Exception:
|
||||
pass
|
||||
if 'rtlamr_tcp' in self.processes:
|
||||
try:
|
||||
tcp_proc = self.processes['rtlamr_tcp']
|
||||
@@ -3142,10 +3124,8 @@ class ModeManager:
|
||||
except Exception as e:
|
||||
logger.error(f"DSC reader error: {e}")
|
||||
finally:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
proc.wait(timeout=1)
|
||||
except Exception:
|
||||
pass
|
||||
logger.info("DSC reader stopped")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -3219,13 +3199,13 @@ class ModeManager:
|
||||
stop_event = self.stop_events.get(mode)
|
||||
|
||||
# Import existing Intercept TSCM functions
|
||||
from routes.tscm import _scan_wifi_networks, _scan_wifi_clients, _scan_bluetooth_devices, _scan_rf_signals
|
||||
from routes.tscm import _scan_bluetooth_devices, _scan_rf_signals, _scan_wifi_clients, _scan_wifi_networks
|
||||
logger.info("TSCM imports successful")
|
||||
|
||||
sweep_ranges = None
|
||||
if sweep_type:
|
||||
try:
|
||||
from data.tscm_frequencies import get_sweep_preset, SWEEP_PRESETS
|
||||
from data.tscm_frequencies import SWEEP_PRESETS, get_sweep_preset
|
||||
preset = get_sweep_preset(sweep_type) or SWEEP_PRESETS.get('standard')
|
||||
sweep_ranges = preset.get('ranges') if preset else None
|
||||
except Exception:
|
||||
@@ -3412,7 +3392,8 @@ class ModeManager:
|
||||
if scan_rf and (current_time - last_rf_scan) >= rf_scan_interval:
|
||||
try:
|
||||
# Pass a stop check that uses our stop_event (not the module's _sweep_running)
|
||||
agent_stop_check = lambda: stop_event and stop_event.is_set()
|
||||
def agent_stop_check():
|
||||
return stop_event and stop_event.is_set()
|
||||
rf_signals = _scan_rf_signals(
|
||||
sdr_device,
|
||||
stop_check=agent_stop_check,
|
||||
@@ -3521,7 +3502,7 @@ class ModeManager:
|
||||
stations_url = 'https://celestrak.org/NORAD/elements/gp.php?GROUP=weather&FORMAT=tle'
|
||||
satellites = load.tle_file(stations_url)
|
||||
|
||||
ts = load.timescale()
|
||||
ts = load.timescale(builtin=True)
|
||||
observer = Topos(latitude_degrees=lat, longitude_degrees=lon)
|
||||
|
||||
logger.info(f"Satellite predictor: {len(satellites)} satellites loaded")
|
||||
@@ -3610,10 +3591,8 @@ class ModeManager:
|
||||
# Ensure test process is killed on any error
|
||||
if test_proc and test_proc.poll() is None:
|
||||
test_proc.kill()
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
test_proc.wait(timeout=1)
|
||||
except Exception:
|
||||
pass
|
||||
return {'status': 'error', 'message': f'SDR check failed: {str(e)}'}
|
||||
|
||||
# Initialize state
|
||||
@@ -3647,9 +3626,9 @@ class ModeManager:
|
||||
step: float, modulation: str, squelch: int,
|
||||
device: str, gain: str, dwell_time: float = 1.0):
|
||||
"""Scan frequency range and report signal detections."""
|
||||
import select
|
||||
import os
|
||||
import fcntl
|
||||
import os
|
||||
import select
|
||||
|
||||
mode = 'listening_post'
|
||||
stop_event = self.stop_events.get(mode)
|
||||
@@ -3709,7 +3688,7 @@ class ModeManager:
|
||||
signal_detected = True
|
||||
except Exception:
|
||||
pass
|
||||
except (IOError, BlockingIOError):
|
||||
except (OSError, BlockingIOError):
|
||||
pass
|
||||
|
||||
proc.terminate()
|
||||
@@ -4131,27 +4110,19 @@ def main():
|
||||
|
||||
# Stop push services
|
||||
if data_push_loop:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
data_push_loop.stop()
|
||||
except Exception:
|
||||
pass
|
||||
if push_client:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
push_client.stop()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Stop GPS
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
gps_manager.stop()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Shutdown HTTP server
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
httpd.shutdown()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Run cleanup in background thread so signal handler returns quickly
|
||||
cleanup_thread = threading.Thread(target=cleanup, daemon=True)
|
||||
|
||||
+25
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "intercept"
|
||||
version = "2.24.0"
|
||||
version = "2.26.11"
|
||||
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
@@ -93,8 +93,32 @@ ignore = [
|
||||
"B008", # do not perform function calls in argument defaults
|
||||
"B905", # zip without explicit strict
|
||||
"SIM108", # use ternary operator instead of if-else
|
||||
"SIM102", # collapsible if statements
|
||||
"SIM105", # use contextlib.suppress (stylistic, not a bug)
|
||||
"SIM115", # use context manager for open (not always applicable)
|
||||
"SIM116", # use dict instead of if/elif chain (stylistic)
|
||||
"SIM117", # combine nested with statements (stylistic)
|
||||
"E402", # module-level import not at top (needed for conditional imports)
|
||||
"E741", # ambiguous variable name
|
||||
"E721", # type comparison (use isinstance)
|
||||
"E722", # bare except
|
||||
"B904", # raise from within except (stylistic)
|
||||
"B007", # unused loop variable (use _ prefix)
|
||||
"B023", # function definition doesn't bind loop variable
|
||||
"F601", # membership test with duplicate items
|
||||
"F821", # undefined name (too many false positives with conditional imports)
|
||||
"UP035", # deprecated typing imports
|
||||
]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"__init__.py" = ["F401"] # re-exports in __init__.py are intentional
|
||||
"utils/bluetooth/capability_check.py" = ["F401"] # imports used for availability checking
|
||||
"utils/bluetooth/fallback_scanner.py" = ["F401"] # imports used for availability checking
|
||||
"utils/tscm/ble_scanner.py" = ["F401"] # imports used for availability checking
|
||||
"utils/wifi/deauth_detector.py" = ["F401"] # imports used for availability checking
|
||||
"routes/dsc.py" = ["F401"] # imports used for availability checking
|
||||
"intercept_agent.py" = ["F401"] # conditional imports
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
known-first-party = ["app", "config", "routes", "utils", "data"]
|
||||
|
||||
|
||||
@@ -45,6 +45,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)
|
||||
|
||||
+4
-2
@@ -20,12 +20,13 @@ def register_blueprints(app):
|
||||
from .correlation import correlation_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 .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 +45,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
|
||||
|
||||
@@ -89,6 +90,7 @@ 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
|
||||
|
||||
# Exempt all API blueprints from CSRF (they use JSON, not form tokens)
|
||||
if _csrf:
|
||||
|
||||
+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(
|
||||
|
||||
+165
-194
@@ -2,9 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import shutil
|
||||
@@ -13,11 +13,11 @@ import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Generator
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, Response, jsonify, make_response, render_template, request
|
||||
|
||||
from utils.responses import api_success, api_error
|
||||
from utils.responses import api_error, api_success
|
||||
|
||||
# psycopg2 is optional - only needed for PostgreSQL history persistence
|
||||
try:
|
||||
@@ -29,6 +29,8 @@ except ImportError:
|
||||
RealDictCursor = None # type: ignore
|
||||
PSYCOPG2_AVAILABLE = False
|
||||
|
||||
import contextlib
|
||||
|
||||
import app as app_module
|
||||
from config import (
|
||||
ADSB_AUTO_START,
|
||||
@@ -38,6 +40,8 @@ from config import (
|
||||
ADSB_DB_PORT,
|
||||
ADSB_DB_USER,
|
||||
ADSB_HISTORY_ENABLED,
|
||||
DEFAULT_LATITUDE,
|
||||
DEFAULT_LONGITUDE,
|
||||
SHARED_OBSERVER_LOCATION_ENABLED,
|
||||
)
|
||||
from utils import aircraft_db
|
||||
@@ -406,18 +410,17 @@ def _get_active_session() -> dict[str, Any] | None:
|
||||
return None
|
||||
_ensure_history_schema()
|
||||
try:
|
||||
with _get_history_connection() as conn:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
with _get_history_connection() as conn, conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT *
|
||||
FROM adsb_sessions
|
||||
WHERE ended_at IS NULL
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
return cur.fetchone()
|
||||
)
|
||||
return cur.fetchone()
|
||||
except Exception as exc:
|
||||
logger.warning("ADS-B session lookup failed: %s", exc)
|
||||
return None
|
||||
@@ -436,10 +439,9 @@ def _record_session_start(
|
||||
return None
|
||||
_ensure_history_schema()
|
||||
try:
|
||||
with _get_history_connection() as conn:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
with _get_history_connection() as conn, conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO adsb_sessions (
|
||||
device_index,
|
||||
sdr_type,
|
||||
@@ -451,16 +453,16 @@ def _record_session_start(
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
device_index,
|
||||
sdr_type,
|
||||
remote_host,
|
||||
remote_port,
|
||||
start_source,
|
||||
started_by,
|
||||
),
|
||||
)
|
||||
return cur.fetchone()
|
||||
(
|
||||
device_index,
|
||||
sdr_type,
|
||||
remote_host,
|
||||
remote_port,
|
||||
start_source,
|
||||
started_by,
|
||||
),
|
||||
)
|
||||
return cur.fetchone()
|
||||
except Exception as exc:
|
||||
logger.warning("ADS-B session start record failed: %s", exc)
|
||||
return None
|
||||
@@ -471,10 +473,9 @@ def _record_session_stop(*, stop_source: str | None, stopped_by: str | None) ->
|
||||
return None
|
||||
_ensure_history_schema()
|
||||
try:
|
||||
with _get_history_connection() as conn:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
with _get_history_connection() as conn, conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE adsb_sessions
|
||||
SET ended_at = NOW(),
|
||||
stop_source = COALESCE(%s, stop_source),
|
||||
@@ -482,9 +483,9 @@ def _record_session_stop(*, stop_source: str | None, stopped_by: str | None) ->
|
||||
WHERE ended_at IS NULL
|
||||
RETURNING *
|
||||
""",
|
||||
(stop_source, stopped_by),
|
||||
)
|
||||
return cur.fetchone()
|
||||
(stop_source, stopped_by),
|
||||
)
|
||||
return cur.fetchone()
|
||||
except Exception as exc:
|
||||
logger.warning("ADS-B session stop record failed: %s", exc)
|
||||
return None
|
||||
@@ -665,10 +666,8 @@ def parse_sbs_stream(service_addr):
|
||||
|
||||
elif msg_type == '3' and len(parts) > 15:
|
||||
if parts[11]:
|
||||
try:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
aircraft['altitude'] = int(float(parts[11]))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if parts[14] and parts[15]:
|
||||
try:
|
||||
aircraft['lat'] = float(parts[14])
|
||||
@@ -678,15 +677,11 @@ def parse_sbs_stream(service_addr):
|
||||
|
||||
elif msg_type == '4' and len(parts) > 16:
|
||||
if parts[12]:
|
||||
try:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
aircraft['speed'] = int(float(parts[12]))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if parts[13]:
|
||||
try:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
aircraft['heading'] = int(float(parts[13]))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if parts[16]:
|
||||
try:
|
||||
aircraft['vertical_rate'] = int(float(parts[16]))
|
||||
@@ -705,10 +700,8 @@ def parse_sbs_stream(service_addr):
|
||||
if callsign:
|
||||
aircraft['callsign'] = callsign
|
||||
if parts[11]:
|
||||
try:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
aircraft['altitude'] = int(float(parts[11]))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
elif msg_type == '6' and len(parts) > 17:
|
||||
if parts[17]:
|
||||
@@ -724,20 +717,14 @@ def parse_sbs_stream(service_addr):
|
||||
|
||||
elif msg_type == '2' and len(parts) > 15:
|
||||
if parts[11]:
|
||||
try:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
aircraft['altitude'] = int(float(parts[11]))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if parts[12]:
|
||||
try:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
aircraft['speed'] = int(float(parts[12]))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if parts[13]:
|
||||
try:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
aircraft['heading'] = int(float(parts[13]))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if parts[14] and parts[15]:
|
||||
try:
|
||||
aircraft['lat'] = float(parts[14])
|
||||
@@ -765,10 +752,8 @@ def parse_sbs_stream(service_addr):
|
||||
time.sleep(SBS_RECONNECT_DELAY)
|
||||
finally:
|
||||
if sock:
|
||||
try:
|
||||
with contextlib.suppress(OSError):
|
||||
sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
adsb_connected = False
|
||||
logger.info("SBS stream parser stopped")
|
||||
@@ -782,23 +767,14 @@ def check_adsb_tools():
|
||||
has_readsb = shutil.which('readsb') is not None
|
||||
has_rtl_adsb = shutil.which('rtl_adsb') is not None
|
||||
|
||||
# Check what SDR hardware is detected
|
||||
devices = SDRFactory.detect_devices()
|
||||
has_rtlsdr = any(d.sdr_type == SDRType.RTL_SDR for d in devices)
|
||||
has_soapy_sdr = any(d.sdr_type in (SDRType.HACKRF, SDRType.LIME_SDR, SDRType.AIRSPY) for d in devices)
|
||||
soapy_types = [d.sdr_type.value for d in devices if d.sdr_type in (SDRType.HACKRF, SDRType.LIME_SDR, SDRType.AIRSPY)]
|
||||
|
||||
# Determine if readsb is needed but missing
|
||||
needs_readsb = has_soapy_sdr and not has_readsb
|
||||
|
||||
return jsonify({
|
||||
'dump1090': has_dump1090,
|
||||
'readsb': has_readsb,
|
||||
'rtl_adsb': has_rtl_adsb,
|
||||
'has_rtlsdr': has_rtlsdr,
|
||||
'has_soapy_sdr': has_soapy_sdr,
|
||||
'soapy_types': soapy_types,
|
||||
'needs_readsb': needs_readsb
|
||||
'has_rtlsdr': None,
|
||||
'has_soapy_sdr': None,
|
||||
'soapy_types': [],
|
||||
'needs_readsb': False
|
||||
})
|
||||
|
||||
|
||||
@@ -1019,10 +995,8 @@ def start_adsb():
|
||||
adsb_active_sdr_type = None
|
||||
stderr_output = ''
|
||||
if app_module.adsb_process.stderr:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
stderr_output = app_module.adsb_process.stderr.read().decode('utf-8', errors='ignore').strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Parse stderr to provide specific guidance
|
||||
error_type = 'START_FAILED'
|
||||
@@ -1184,16 +1158,17 @@ def stream_adsb():
|
||||
|
||||
def generate():
|
||||
last_keepalive = time.time()
|
||||
# Send immediate keepalive so Werkzeug dev server flushes response
|
||||
# headers right away (it buffers until first body byte is written).
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
msg = client_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
process_event('adsb', msg, msg.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
@@ -1218,6 +1193,8 @@ def adsb_dashboard():
|
||||
'adsb_dashboard.html',
|
||||
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
||||
adsb_auto_start=ADSB_AUTO_START,
|
||||
default_latitude=DEFAULT_LATITUDE,
|
||||
default_longitude=DEFAULT_LONGITUDE,
|
||||
embedded=embedded,
|
||||
)
|
||||
|
||||
@@ -1251,10 +1228,9 @@ def adsb_history_summary():
|
||||
"""
|
||||
|
||||
try:
|
||||
with _get_history_connection() as conn:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(sql, (window, window, window, window, window))
|
||||
row = cur.fetchone() or {}
|
||||
with _get_history_connection() as conn, conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(sql, (window, window, window, window, window))
|
||||
row = cur.fetchone() or {}
|
||||
return jsonify(row)
|
||||
except Exception as exc:
|
||||
logger.warning("ADS-B history summary failed: %s", exc)
|
||||
@@ -1301,10 +1277,9 @@ def adsb_history_aircraft():
|
||||
"""
|
||||
|
||||
try:
|
||||
with _get_history_connection() as conn:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(sql, (window, search, pattern, pattern, pattern, limit))
|
||||
rows = cur.fetchall()
|
||||
with _get_history_connection() as conn, conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(sql, (window, search, pattern, pattern, pattern, limit))
|
||||
rows = cur.fetchall()
|
||||
return jsonify({'aircraft': rows, 'count': len(rows)})
|
||||
except Exception as exc:
|
||||
logger.warning("ADS-B history aircraft query failed: %s", exc)
|
||||
@@ -1336,10 +1311,9 @@ def adsb_history_timeline():
|
||||
"""
|
||||
|
||||
try:
|
||||
with _get_history_connection() as conn:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(sql, (icao, window, limit))
|
||||
rows = cur.fetchall()
|
||||
with _get_history_connection() as conn, conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(sql, (icao, window, limit))
|
||||
rows = cur.fetchall()
|
||||
return jsonify({'icao': icao, 'timeline': rows, 'count': len(rows)})
|
||||
except Exception as exc:
|
||||
logger.warning("ADS-B history timeline query failed: %s", exc)
|
||||
@@ -1368,10 +1342,9 @@ def adsb_history_messages():
|
||||
"""
|
||||
|
||||
try:
|
||||
with _get_history_connection() as conn:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(sql, (window, icao, icao, limit))
|
||||
rows = cur.fetchall()
|
||||
with _get_history_connection() as conn, conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(sql, (window, icao, icao, limit))
|
||||
rows = cur.fetchall()
|
||||
return jsonify({'icao': icao, 'messages': rows, 'count': len(rows)})
|
||||
except Exception as exc:
|
||||
logger.warning("ADS-B history message query failed: %s", exc)
|
||||
@@ -1418,89 +1391,88 @@ def adsb_history_export():
|
||||
]
|
||||
|
||||
try:
|
||||
with _get_history_connection() as conn:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
if export_type in {'snapshots', 'all'}:
|
||||
snapshot_where: list[str] = []
|
||||
snapshot_params: list[Any] = []
|
||||
_add_time_filter(
|
||||
where_parts=snapshot_where,
|
||||
params=snapshot_params,
|
||||
scope=scope,
|
||||
timestamp_field='captured_at',
|
||||
since_minutes=since_minutes,
|
||||
start=start,
|
||||
end=end,
|
||||
)
|
||||
if icao:
|
||||
snapshot_where.append("icao = %s")
|
||||
snapshot_params.append(icao)
|
||||
if search:
|
||||
snapshot_where.append("(icao ILIKE %s OR callsign ILIKE %s OR registration ILIKE %s)")
|
||||
snapshot_params.extend([pattern, pattern, pattern])
|
||||
with _get_history_connection() as conn, conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
if export_type in {'snapshots', 'all'}:
|
||||
snapshot_where: list[str] = []
|
||||
snapshot_params: list[Any] = []
|
||||
_add_time_filter(
|
||||
where_parts=snapshot_where,
|
||||
params=snapshot_params,
|
||||
scope=scope,
|
||||
timestamp_field='captured_at',
|
||||
since_minutes=since_minutes,
|
||||
start=start,
|
||||
end=end,
|
||||
)
|
||||
if icao:
|
||||
snapshot_where.append("icao = %s")
|
||||
snapshot_params.append(icao)
|
||||
if search:
|
||||
snapshot_where.append("(icao ILIKE %s OR callsign ILIKE %s OR registration ILIKE %s)")
|
||||
snapshot_params.extend([pattern, pattern, pattern])
|
||||
|
||||
snapshot_sql = """
|
||||
snapshot_sql = """
|
||||
SELECT captured_at, icao, callsign, registration, type_code, type_desc,
|
||||
altitude, speed, heading, vertical_rate, lat, lon, squawk, source_host
|
||||
FROM adsb_snapshots
|
||||
"""
|
||||
if snapshot_where:
|
||||
snapshot_sql += " WHERE " + " AND ".join(snapshot_where)
|
||||
snapshot_sql += " ORDER BY captured_at DESC"
|
||||
cur.execute(snapshot_sql, tuple(snapshot_params))
|
||||
snapshots = _filter_by_classification(cur.fetchall())
|
||||
if snapshot_where:
|
||||
snapshot_sql += " WHERE " + " AND ".join(snapshot_where)
|
||||
snapshot_sql += " ORDER BY captured_at DESC"
|
||||
cur.execute(snapshot_sql, tuple(snapshot_params))
|
||||
snapshots = _filter_by_classification(cur.fetchall())
|
||||
|
||||
if export_type in {'messages', 'all'}:
|
||||
message_where: list[str] = []
|
||||
message_params: list[Any] = []
|
||||
_add_time_filter(
|
||||
where_parts=message_where,
|
||||
params=message_params,
|
||||
scope=scope,
|
||||
timestamp_field='received_at',
|
||||
since_minutes=since_minutes,
|
||||
start=start,
|
||||
end=end,
|
||||
)
|
||||
if icao:
|
||||
message_where.append("icao = %s")
|
||||
message_params.append(icao)
|
||||
if search:
|
||||
message_where.append("(icao ILIKE %s OR callsign ILIKE %s)")
|
||||
message_params.extend([pattern, pattern])
|
||||
if export_type in {'messages', 'all'}:
|
||||
message_where: list[str] = []
|
||||
message_params: list[Any] = []
|
||||
_add_time_filter(
|
||||
where_parts=message_where,
|
||||
params=message_params,
|
||||
scope=scope,
|
||||
timestamp_field='received_at',
|
||||
since_minutes=since_minutes,
|
||||
start=start,
|
||||
end=end,
|
||||
)
|
||||
if icao:
|
||||
message_where.append("icao = %s")
|
||||
message_params.append(icao)
|
||||
if search:
|
||||
message_where.append("(icao ILIKE %s OR callsign ILIKE %s)")
|
||||
message_params.extend([pattern, pattern])
|
||||
|
||||
message_sql = """
|
||||
message_sql = """
|
||||
SELECT received_at, msg_time, logged_time, icao, msg_type, callsign,
|
||||
altitude, speed, heading, vertical_rate, lat, lon, squawk,
|
||||
session_id, aircraft_id, flight_id, source_host, raw_line
|
||||
FROM adsb_messages
|
||||
"""
|
||||
if message_where:
|
||||
message_sql += " WHERE " + " AND ".join(message_where)
|
||||
message_sql += " ORDER BY received_at DESC"
|
||||
cur.execute(message_sql, tuple(message_params))
|
||||
messages = _filter_by_classification(cur.fetchall())
|
||||
if message_where:
|
||||
message_sql += " WHERE " + " AND ".join(message_where)
|
||||
message_sql += " ORDER BY received_at DESC"
|
||||
cur.execute(message_sql, tuple(message_params))
|
||||
messages = _filter_by_classification(cur.fetchall())
|
||||
|
||||
if export_type in {'sessions', 'all'}:
|
||||
session_where: list[str] = []
|
||||
session_params: list[Any] = []
|
||||
if scope == 'custom' and start is not None and end is not None:
|
||||
session_where.append("COALESCE(ended_at, %s) >= %s AND started_at < %s")
|
||||
session_params.extend([end, start, end])
|
||||
elif scope == 'window':
|
||||
session_where.append("COALESCE(ended_at, NOW()) >= NOW() - INTERVAL %s")
|
||||
session_params.append(f'{since_minutes} minutes')
|
||||
if export_type in {'sessions', 'all'}:
|
||||
session_where: list[str] = []
|
||||
session_params: list[Any] = []
|
||||
if scope == 'custom' and start is not None and end is not None:
|
||||
session_where.append("COALESCE(ended_at, %s) >= %s AND started_at < %s")
|
||||
session_params.extend([end, start, end])
|
||||
elif scope == 'window':
|
||||
session_where.append("COALESCE(ended_at, NOW()) >= NOW() - INTERVAL %s")
|
||||
session_params.append(f'{since_minutes} minutes')
|
||||
|
||||
session_sql = """
|
||||
session_sql = """
|
||||
SELECT id, started_at, ended_at, device_index, sdr_type, remote_host,
|
||||
remote_port, start_source, stop_source, started_by, stopped_by, notes
|
||||
FROM adsb_sessions
|
||||
"""
|
||||
if session_where:
|
||||
session_sql += " WHERE " + " AND ".join(session_where)
|
||||
session_sql += " ORDER BY started_at DESC"
|
||||
cur.execute(session_sql, tuple(session_params))
|
||||
sessions = cur.fetchall()
|
||||
if session_where:
|
||||
session_sql += " WHERE " + " AND ".join(session_where)
|
||||
session_sql += " ORDER BY started_at DESC"
|
||||
cur.execute(session_sql, tuple(session_params))
|
||||
sessions = cur.fetchall()
|
||||
except Exception as exc:
|
||||
logger.warning("ADS-B history export failed: %s", exc)
|
||||
return api_error('History database unavailable', 503)
|
||||
@@ -1570,59 +1542,58 @@ def adsb_history_prune():
|
||||
return api_error('mode must be range or all', 400)
|
||||
|
||||
try:
|
||||
with _get_history_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
deleted = {'messages': 0, 'snapshots': 0}
|
||||
with _get_history_connection() as conn, conn.cursor() as cur:
|
||||
deleted = {'messages': 0, 'snapshots': 0}
|
||||
|
||||
if mode == 'all':
|
||||
cur.execute("DELETE FROM adsb_messages")
|
||||
deleted['messages'] = max(0, cur.rowcount or 0)
|
||||
cur.execute("DELETE FROM adsb_snapshots")
|
||||
deleted['snapshots'] = max(0, cur.rowcount or 0)
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'mode': 'all',
|
||||
'deleted': deleted,
|
||||
'total_deleted': deleted['messages'] + deleted['snapshots'],
|
||||
})
|
||||
if mode == 'all':
|
||||
cur.execute("DELETE FROM adsb_messages")
|
||||
deleted['messages'] = max(0, cur.rowcount or 0)
|
||||
cur.execute("DELETE FROM adsb_snapshots")
|
||||
deleted['snapshots'] = max(0, cur.rowcount or 0)
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'mode': 'all',
|
||||
'deleted': deleted,
|
||||
'total_deleted': deleted['messages'] + deleted['snapshots'],
|
||||
})
|
||||
|
||||
start = _parse_iso_datetime(payload.get('start'))
|
||||
end = _parse_iso_datetime(payload.get('end'))
|
||||
if start is None or end is None:
|
||||
return api_error('start and end ISO datetime values are required', 400)
|
||||
if end <= start:
|
||||
return api_error('end must be after start', 400)
|
||||
if end - start > timedelta(days=31):
|
||||
return api_error('range cannot exceed 31 days', 400)
|
||||
start = _parse_iso_datetime(payload.get('start'))
|
||||
end = _parse_iso_datetime(payload.get('end'))
|
||||
if start is None or end is None:
|
||||
return api_error('start and end ISO datetime values are required', 400)
|
||||
if end <= start:
|
||||
return api_error('end must be after start', 400)
|
||||
if end - start > timedelta(days=31):
|
||||
return api_error('range cannot exceed 31 days', 400)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
cur.execute(
|
||||
"""
|
||||
DELETE FROM adsb_messages
|
||||
WHERE received_at >= %s
|
||||
AND received_at < %s
|
||||
""",
|
||||
(start, end),
|
||||
)
|
||||
deleted['messages'] = max(0, cur.rowcount or 0)
|
||||
(start, end),
|
||||
)
|
||||
deleted['messages'] = max(0, cur.rowcount or 0)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
cur.execute(
|
||||
"""
|
||||
DELETE FROM adsb_snapshots
|
||||
WHERE captured_at >= %s
|
||||
AND captured_at < %s
|
||||
""",
|
||||
(start, end),
|
||||
)
|
||||
deleted['snapshots'] = max(0, cur.rowcount or 0)
|
||||
(start, end),
|
||||
)
|
||||
deleted['snapshots'] = max(0, cur.rowcount or 0)
|
||||
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'mode': 'range',
|
||||
'start': start.isoformat(),
|
||||
'end': end.isoformat(),
|
||||
'deleted': deleted,
|
||||
'total_deleted': deleted['messages'] + deleted['snapshots'],
|
||||
})
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'mode': 'range',
|
||||
'start': start.isoformat(),
|
||||
'end': end.isoformat(),
|
||||
'deleted': deleted,
|
||||
'total_deleted': deleted['messages'] + deleted['snapshots'],
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.warning("ADS-B history prune failed: %s", exc)
|
||||
return api_error('History database unavailable', 503)
|
||||
|
||||
+18
-23
@@ -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")
|
||||
@@ -440,10 +435,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 +526,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:
|
||||
@@ -549,5 +542,7 @@ def ais_dashboard():
|
||||
return render_template(
|
||||
'ais_dashboard.html',
|
||||
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
||||
default_latitude=DEFAULT_LATITUDE,
|
||||
default_longitude=DEFAULT_LONGITUDE,
|
||||
embedded=embedded,
|
||||
)
|
||||
|
||||
+3
-5
@@ -2,14 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
import time
|
||||
from typing import Generator
|
||||
from collections.abc import Generator
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
from flask import Blueprint, Response, request
|
||||
|
||||
from utils.alerts import get_alert_manager
|
||||
from utils.responses import api_success, api_error
|
||||
from utils.responses import api_error, api_success
|
||||
from utils.sse import format_sse
|
||||
|
||||
alerts_bp = Blueprint('alerts', __name__, url_prefix='/alerts')
|
||||
|
||||
+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
|
||||
})
|
||||
|
||||
|
||||
+7
-14
@@ -7,31 +7,27 @@ aggregation, and heuristics.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Generator
|
||||
from datetime import datetime
|
||||
from typing import Generator
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request, session
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
from utils.bluetooth import (
|
||||
BluetoothScanner,
|
||||
BTDeviceAggregate,
|
||||
get_bluetooth_scanner,
|
||||
check_capabilities,
|
||||
RANGE_UNKNOWN,
|
||||
TrackerType,
|
||||
TrackerConfidence,
|
||||
get_tracker_engine,
|
||||
get_bluetooth_scanner,
|
||||
)
|
||||
from utils.database import get_db
|
||||
from utils.responses import api_success, api_error
|
||||
from utils.sse import format_sse
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.responses import api_error
|
||||
from utils.sse import format_sse
|
||||
|
||||
logger = logging.getLogger('intercept.bluetooth_v2')
|
||||
|
||||
@@ -901,10 +897,8 @@ def stream_events():
|
||||
"""Generate SSE events from scanner."""
|
||||
for event in scanner.stream_events(timeout=1.0):
|
||||
event_name, event_data = map_event_type(event)
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
process_event('bluetooth', event_data, event_name)
|
||||
except Exception:
|
||||
pass
|
||||
yield format_sse(event_data, event=event_name)
|
||||
|
||||
return Response(
|
||||
@@ -972,7 +966,6 @@ def get_tscm_bluetooth_snapshot(duration: int = 8) -> list[dict]:
|
||||
Returns:
|
||||
List of device dictionaries in TSCM format.
|
||||
"""
|
||||
import time
|
||||
import logging
|
||||
logger = logging.getLogger('intercept.bluetooth_v2')
|
||||
|
||||
|
||||
+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')
|
||||
|
||||
+71
-48
@@ -10,40 +10,46 @@ This blueprint provides:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Generator
|
||||
from datetime import datetime, timezone
|
||||
from typing import Generator
|
||||
|
||||
import requests
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
from utils.responses import api_success, api_error
|
||||
from utils.agent_client import AgentClient, AgentConnectionError, AgentHTTPError, create_client_from_agent
|
||||
from utils.database import (
|
||||
create_agent, get_agent, get_agent_by_name, list_agents,
|
||||
update_agent, delete_agent, store_push_payload, get_recent_payloads
|
||||
)
|
||||
from utils.agent_client import (
|
||||
AgentClient, AgentHTTPError, AgentConnectionError, create_client_from_agent
|
||||
create_agent,
|
||||
delete_agent,
|
||||
get_agent,
|
||||
get_agent_by_name,
|
||||
get_recent_payloads,
|
||||
list_agents,
|
||||
store_push_payload,
|
||||
update_agent,
|
||||
)
|
||||
from utils.responses import api_error
|
||||
from utils.sse import format_sse
|
||||
from utils.trilateration import (
|
||||
DeviceLocationTracker, PathLossModel, Trilateration,
|
||||
AgentObservation, estimate_location_from_observations
|
||||
DeviceLocationTracker,
|
||||
PathLossModel,
|
||||
Trilateration,
|
||||
estimate_location_from_observations,
|
||||
)
|
||||
|
||||
logger = logging.getLogger('intercept.controller')
|
||||
|
||||
controller_bp = Blueprint('controller', __name__, url_prefix='/controller')
|
||||
|
||||
# Multi-agent SSE fanout state (per-client queues).
|
||||
_agent_stream_subscribers: set[queue.Queue] = set()
|
||||
_agent_stream_subscribers_lock = threading.Lock()
|
||||
_AGENT_STREAM_CLIENT_QUEUE_SIZE = 500
|
||||
logger = logging.getLogger('intercept.controller')
|
||||
|
||||
controller_bp = Blueprint('controller', __name__, url_prefix='/controller')
|
||||
AGENT_HEALTH_TIMEOUT_SECONDS = 2.0
|
||||
AGENT_STATUS_TIMEOUT_SECONDS = 2.5
|
||||
|
||||
# Multi-agent SSE fanout state (per-client queues).
|
||||
_agent_stream_subscribers: set[queue.Queue] = set()
|
||||
_agent_stream_subscribers_lock = threading.Lock()
|
||||
_AGENT_STREAM_CLIENT_QUEUE_SIZE = 500
|
||||
|
||||
|
||||
def _broadcast_agent_data(payload: dict) -> None:
|
||||
@@ -73,14 +79,18 @@ def get_agents():
|
||||
agents = list_agents(active_only=active_only)
|
||||
|
||||
# Optionally refresh status for each agent
|
||||
refresh = request.args.get('refresh', 'false').lower() == 'true'
|
||||
if refresh:
|
||||
for agent in agents:
|
||||
try:
|
||||
client = create_client_from_agent(agent)
|
||||
agent['healthy'] = client.health_check()
|
||||
except Exception:
|
||||
agent['healthy'] = False
|
||||
refresh = request.args.get('refresh', 'false').lower() == 'true'
|
||||
if refresh:
|
||||
for agent in agents:
|
||||
try:
|
||||
client = AgentClient(
|
||||
agent['base_url'],
|
||||
api_key=agent.get('api_key'),
|
||||
timeout=AGENT_HEALTH_TIMEOUT_SECONDS,
|
||||
)
|
||||
agent['healthy'] = client.health_check()
|
||||
except Exception:
|
||||
agent['healthy'] = False
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
@@ -323,27 +333,36 @@ def check_all_agents_health():
|
||||
'error': None
|
||||
}
|
||||
|
||||
try:
|
||||
client = create_client_from_agent(agent)
|
||||
|
||||
# Time the health check
|
||||
start_time = time.time()
|
||||
is_healthy = client.health_check()
|
||||
response_time = (time.time() - start_time) * 1000
|
||||
try:
|
||||
client = AgentClient(
|
||||
agent['base_url'],
|
||||
api_key=agent.get('api_key'),
|
||||
timeout=AGENT_HEALTH_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
# Time the health check
|
||||
start_time = time.time()
|
||||
is_healthy = client.health_check()
|
||||
response_time = (time.time() - start_time) * 1000
|
||||
|
||||
result['healthy'] = is_healthy
|
||||
result['response_time_ms'] = round(response_time, 1)
|
||||
|
||||
if is_healthy:
|
||||
# Update last_seen in database
|
||||
update_agent(agent['id'], update_last_seen=True)
|
||||
|
||||
# Also fetch running modes
|
||||
try:
|
||||
status = client.get_status()
|
||||
result['running_modes'] = status.get('running_modes', [])
|
||||
result['running_modes_detail'] = status.get('running_modes_detail', {})
|
||||
except Exception:
|
||||
# Update last_seen in database
|
||||
update_agent(agent['id'], update_last_seen=True)
|
||||
|
||||
# Also fetch running modes
|
||||
try:
|
||||
status_client = AgentClient(
|
||||
agent['base_url'],
|
||||
api_key=agent.get('api_key'),
|
||||
timeout=AGENT_STATUS_TIMEOUT_SECONDS,
|
||||
)
|
||||
status = status_client.get_status()
|
||||
result['running_modes'] = status.get('running_modes', [])
|
||||
result['running_modes_detail'] = status.get('running_modes_detail', {})
|
||||
except Exception:
|
||||
pass # Status fetch is optional
|
||||
|
||||
except AgentConnectionError as e:
|
||||
@@ -669,6 +688,7 @@ def stream_all_agents():
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
|
||||
try:
|
||||
while True:
|
||||
@@ -700,15 +720,18 @@ def stream_all_agents():
|
||||
def agent_management_page():
|
||||
"""Render the agent management page."""
|
||||
from flask import render_template
|
||||
|
||||
from config import VERSION
|
||||
return render_template('agents.html', version=VERSION)
|
||||
|
||||
|
||||
@controller_bp.route('/monitor')
|
||||
def network_monitor_page():
|
||||
"""Render the network monitor page for multi-agent aggregated view."""
|
||||
@controller_bp.route('/monitor')
|
||||
def network_monitor_page():
|
||||
"""Render the network monitor page for multi-agent aggregated view."""
|
||||
from flask import render_template
|
||||
return render_template('network_monitor.html')
|
||||
|
||||
from config import VERSION
|
||||
return render_template('network_monitor.html', version=VERSION)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
+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))
|
||||
|
||||
@@ -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 (
|
||||
|
||||
+5
-3
@@ -2,11 +2,13 @@
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
+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')
|
||||
|
||||
+459
-164
@@ -2,30 +2,28 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
from flask import Blueprint, Response, jsonify, make_response, render_template, request
|
||||
|
||||
from flask import Blueprint, jsonify, request, render_template, Response
|
||||
|
||||
from utils.responses import api_success, api_error
|
||||
from config import SHARED_OBSERVER_LOCATION_ENABLED
|
||||
|
||||
from data.satellites import TLE_SATELLITES
|
||||
from utils.database import (
|
||||
get_tracked_satellites,
|
||||
add_tracked_satellite,
|
||||
bulk_add_tracked_satellites,
|
||||
update_tracked_satellite,
|
||||
get_tracked_satellites,
|
||||
remove_tracked_satellite,
|
||||
update_tracked_satellite,
|
||||
)
|
||||
from utils.logging import satellite_logger as logger
|
||||
from utils.validation import validate_latitude, validate_longitude, validate_hours, validate_elevation
|
||||
from utils.responses import api_error
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.validation import validate_elevation, validate_hours, validate_latitude, validate_longitude
|
||||
|
||||
satellite_bp = Blueprint('satellite', __name__, url_prefix='/satellite')
|
||||
|
||||
@@ -37,7 +35,8 @@ def _get_timescale():
|
||||
global _cached_timescale
|
||||
if _cached_timescale is None:
|
||||
from skyfield.api import load
|
||||
_cached_timescale = load.timescale()
|
||||
# Use bundled timescale data so the first request does not block on network I/O.
|
||||
_cached_timescale = load.timescale(builtin=True)
|
||||
return _cached_timescale
|
||||
|
||||
# Maximum response size for external requests (1MB)
|
||||
@@ -49,6 +48,26 @@ ALLOWED_TLE_HOSTS = ['celestrak.org', 'celestrak.com', 'www.celestrak.org', 'www
|
||||
# Local TLE cache (can be updated via API)
|
||||
_tle_cache = dict(TLE_SATELLITES)
|
||||
|
||||
# Ground track cache: key=(sat_name, tle_line1[:20]) -> (track_data, computed_at_timestamp)
|
||||
# TTL is 1800 seconds (30 minutes)
|
||||
_track_cache: dict = {}
|
||||
_TRACK_CACHE_TTL = 1800
|
||||
|
||||
# Thread pool for background ground-track computation (non-blocking from 1Hz tracker loop)
|
||||
from concurrent.futures import ThreadPoolExecutor as _ThreadPoolExecutor
|
||||
|
||||
_track_executor = _ThreadPoolExecutor(max_workers=2, thread_name_prefix='sat-track')
|
||||
_track_in_progress: set = set() # cache keys currently being computed
|
||||
_pass_cache: dict = {}
|
||||
_PASS_CACHE_TTL = 300
|
||||
|
||||
_BUILTIN_NORAD_TO_KEY = {
|
||||
25544: 'ISS',
|
||||
40069: 'METEOR-M2',
|
||||
57166: 'METEOR-M2-3',
|
||||
59051: 'METEOR-M2-4',
|
||||
}
|
||||
|
||||
|
||||
def _load_db_satellites_into_cache():
|
||||
"""Load user-tracked satellites from DB into the TLE cache."""
|
||||
@@ -69,9 +88,251 @@ def _load_db_satellites_into_cache():
|
||||
logger.warning(f"Failed to load DB satellites into TLE cache: {e}")
|
||||
|
||||
|
||||
def _normalize_satellite_name(value: object) -> str:
|
||||
"""Normalize satellite identifiers for loose name matching."""
|
||||
return str(value or '').strip().replace(' ', '-').upper()
|
||||
|
||||
|
||||
def _get_tracked_satellite_maps() -> tuple[dict[int, dict], dict[str, dict]]:
|
||||
"""Return tracked satellites indexed by NORAD ID and normalized name."""
|
||||
by_norad: dict[int, dict] = {}
|
||||
by_name: dict[str, dict] = {}
|
||||
try:
|
||||
for sat in get_tracked_satellites():
|
||||
try:
|
||||
norad_id = int(sat['norad_id'])
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
by_norad[norad_id] = sat
|
||||
by_name[_normalize_satellite_name(sat.get('name'))] = sat
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to read tracked satellites for lookup: {e}")
|
||||
return by_norad, by_name
|
||||
|
||||
|
||||
def _resolve_satellite_request(sat: object, tracked_by_norad: dict[int, dict], tracked_by_name: dict[str, dict]) -> tuple[str, int | None, tuple[str, str, str] | None]:
|
||||
"""Resolve a satellite request to display name, NORAD ID, and TLE data."""
|
||||
norad_id: int | None = None
|
||||
sat_key: str | None = None
|
||||
tracked: dict | None = None
|
||||
|
||||
if isinstance(sat, int):
|
||||
norad_id = sat
|
||||
elif isinstance(sat, str):
|
||||
stripped = sat.strip()
|
||||
if stripped.isdigit():
|
||||
norad_id = int(stripped)
|
||||
else:
|
||||
sat_key = stripped
|
||||
|
||||
if norad_id is not None:
|
||||
tracked = tracked_by_norad.get(norad_id)
|
||||
sat_key = _BUILTIN_NORAD_TO_KEY.get(norad_id) or (tracked.get('name') if tracked else str(norad_id))
|
||||
else:
|
||||
normalized = _normalize_satellite_name(sat_key)
|
||||
tracked = tracked_by_name.get(normalized)
|
||||
if tracked:
|
||||
try:
|
||||
norad_id = int(tracked['norad_id'])
|
||||
except (TypeError, ValueError):
|
||||
norad_id = None
|
||||
sat_key = tracked.get('name') or sat_key
|
||||
|
||||
tle_data = None
|
||||
candidate_keys: list[str] = []
|
||||
if sat_key:
|
||||
candidate_keys.extend([
|
||||
sat_key,
|
||||
_normalize_satellite_name(sat_key),
|
||||
])
|
||||
if tracked and tracked.get('name'):
|
||||
candidate_keys.extend([
|
||||
tracked['name'],
|
||||
_normalize_satellite_name(tracked['name']),
|
||||
])
|
||||
|
||||
seen: set[str] = set()
|
||||
for key in candidate_keys:
|
||||
norm = _normalize_satellite_name(key)
|
||||
if norm in seen:
|
||||
continue
|
||||
seen.add(norm)
|
||||
if key in _tle_cache:
|
||||
tle_data = _tle_cache[key]
|
||||
break
|
||||
if norm in _tle_cache:
|
||||
tle_data = _tle_cache[norm]
|
||||
break
|
||||
|
||||
if tle_data is None and tracked and tracked.get('tle_line1') and tracked.get('tle_line2'):
|
||||
display_name = tracked.get('name') or sat_key or str(norad_id or 'UNKNOWN')
|
||||
tle_data = (display_name, tracked['tle_line1'], tracked['tle_line2'])
|
||||
_tle_cache[_normalize_satellite_name(display_name)] = tle_data
|
||||
|
||||
if tle_data is None and sat_key:
|
||||
normalized = _normalize_satellite_name(sat_key)
|
||||
for key, value in _tle_cache.items():
|
||||
if key == normalized or _normalize_satellite_name(value[0]) == normalized:
|
||||
tle_data = value
|
||||
break
|
||||
|
||||
display_name = _BUILTIN_NORAD_TO_KEY.get(norad_id or -1)
|
||||
if not display_name:
|
||||
display_name = (tracked.get('name') if tracked else None) or (tle_data[0] if tle_data else None) or (sat_key if sat_key else str(norad_id or 'UNKNOWN'))
|
||||
return display_name, norad_id, tle_data
|
||||
|
||||
|
||||
def _make_pass_cache_key(
|
||||
lat: float,
|
||||
lon: float,
|
||||
hours: int,
|
||||
min_el: float,
|
||||
resolved_satellites: list[tuple[str, int, tuple[str, str, str]]],
|
||||
) -> tuple:
|
||||
"""Build a stable cache key for predicted passes."""
|
||||
return (
|
||||
round(lat, 4),
|
||||
round(lon, 4),
|
||||
int(hours),
|
||||
round(float(min_el), 1),
|
||||
tuple(
|
||||
(
|
||||
sat_name,
|
||||
norad_id,
|
||||
tle_data[1][:32],
|
||||
tle_data[2][:32],
|
||||
)
|
||||
for sat_name, norad_id, tle_data in resolved_satellites
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _start_satellite_tracker():
|
||||
"""Background thread: push live satellite positions to satellite_queue every second."""
|
||||
import app as app_module
|
||||
|
||||
try:
|
||||
from skyfield.api import EarthSatellite, wgs84
|
||||
except ImportError:
|
||||
logger.warning("skyfield not installed; satellite tracker thread will not run")
|
||||
return
|
||||
|
||||
ts = _get_timescale()
|
||||
logger.info("Satellite tracker thread started")
|
||||
|
||||
while True:
|
||||
try:
|
||||
now = ts.now()
|
||||
now_dt = now.utc_datetime()
|
||||
|
||||
tracked = get_tracked_satellites(enabled_only=True)
|
||||
positions = []
|
||||
|
||||
for sat_rec in tracked:
|
||||
sat_name = sat_rec['name']
|
||||
norad_id = sat_rec.get('norad_id', 0)
|
||||
tle1 = sat_rec.get('tle_line1')
|
||||
tle2 = sat_rec.get('tle_line2')
|
||||
if not tle1 or not tle2:
|
||||
# Fall back to TLE cache. Try the builtin NORAD-ID key first
|
||||
# (e.g. 'ISS'), then the name-derived key as a last resort.
|
||||
try:
|
||||
norad_int = int(norad_id)
|
||||
except (TypeError, ValueError):
|
||||
norad_int = 0
|
||||
builtin_key = _BUILTIN_NORAD_TO_KEY.get(norad_int)
|
||||
cache_key = builtin_key if (builtin_key and builtin_key in _tle_cache) else sat_name.replace(' ', '-').upper()
|
||||
if cache_key not in _tle_cache:
|
||||
continue
|
||||
tle_entry = _tle_cache[cache_key]
|
||||
tle1 = tle_entry[1]
|
||||
tle2 = tle_entry[2]
|
||||
|
||||
try:
|
||||
satellite = EarthSatellite(tle1, tle2, sat_name, ts)
|
||||
geocentric = satellite.at(now)
|
||||
subpoint = wgs84.subpoint(geocentric)
|
||||
|
||||
# SSE stream is server-wide and cannot know per-client observer
|
||||
# location. Observer-relative fields (elevation, azimuth, distance,
|
||||
# visible) are intentionally omitted here — the per-client HTTP poll
|
||||
# at /satellite/position owns those using the client's actual location.
|
||||
pos = {
|
||||
'satellite': sat_name,
|
||||
'norad_id': norad_id,
|
||||
'lat': float(subpoint.latitude.degrees),
|
||||
'lon': float(subpoint.longitude.degrees),
|
||||
'altitude': float(subpoint.elevation.km),
|
||||
}
|
||||
|
||||
# Ground track with caching (90 points, TTL 1800s).
|
||||
# If the cache is stale, kick off background computation so the
|
||||
# 1Hz tracker loop is not blocked. The client retains the previous
|
||||
# track via SSE merge until the new one arrives next tick.
|
||||
cache_key_track = (sat_name, tle1[:20])
|
||||
cached = _track_cache.get(cache_key_track)
|
||||
if cached and (time.time() - cached[1]) < _TRACK_CACHE_TTL:
|
||||
pos['groundTrack'] = cached[0]
|
||||
elif cache_key_track not in _track_in_progress:
|
||||
_track_in_progress.add(cache_key_track)
|
||||
_sat_ref = satellite
|
||||
_ts_ref = ts
|
||||
_now_dt_ref = now_dt
|
||||
|
||||
def _compute_track(_sat=_sat_ref, _ts=_ts_ref, _now_dt=_now_dt_ref, _key=cache_key_track):
|
||||
try:
|
||||
track = []
|
||||
for minutes_offset in range(-45, 46, 1):
|
||||
t_point = _ts.utc(_now_dt + timedelta(minutes=minutes_offset))
|
||||
try:
|
||||
geo = _sat.at(t_point)
|
||||
sp = wgs84.subpoint(geo)
|
||||
track.append({
|
||||
'lat': float(sp.latitude.degrees),
|
||||
'lon': float(sp.longitude.degrees),
|
||||
'past': minutes_offset < 0,
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
_track_cache[_key] = (track, time.time())
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
_track_in_progress.discard(_key)
|
||||
|
||||
_track_executor.submit(_compute_track)
|
||||
# groundTrack omitted this tick; frontend retains prior value
|
||||
|
||||
positions.append(pos)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if positions:
|
||||
msg = {
|
||||
'type': 'positions',
|
||||
'positions': positions,
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
}
|
||||
try:
|
||||
app_module.satellite_queue.put_nowait(msg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Satellite tracker error: {e}")
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
_TLE_REFRESH_INTERVAL_SECONDS = 24 * 60 * 60 # 24 hours
|
||||
|
||||
|
||||
def init_tle_auto_refresh():
|
||||
"""Initialize TLE auto-refresh. Called by app.py after initialization."""
|
||||
import threading
|
||||
def _schedule_next_tle_refresh(delay: float = _TLE_REFRESH_INTERVAL_SECONDS) -> None:
|
||||
t = threading.Timer(delay, _auto_refresh_tle)
|
||||
t.daemon = True
|
||||
t.start()
|
||||
|
||||
def _auto_refresh_tle():
|
||||
try:
|
||||
@@ -81,13 +342,25 @@ def init_tle_auto_refresh():
|
||||
logger.info(f"Auto-refreshed TLE data for: {', '.join(updated)}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Auto TLE refresh failed: {e}")
|
||||
finally:
|
||||
# Schedule next refresh regardless of success/failure
|
||||
_schedule_next_tle_refresh()
|
||||
|
||||
# Start auto-refresh in background
|
||||
# First refresh 2 seconds after startup, then every 24 hours
|
||||
threading.Timer(2.0, _auto_refresh_tle).start()
|
||||
logger.info("TLE auto-refresh scheduled")
|
||||
logger.info("TLE auto-refresh scheduled (24h interval)")
|
||||
|
||||
# Start live position tracker thread
|
||||
tracker_thread = threading.Thread(
|
||||
target=_start_satellite_tracker,
|
||||
daemon=True,
|
||||
name='satellite-tracker',
|
||||
)
|
||||
tracker_thread.start()
|
||||
logger.info("Satellite tracker thread launched")
|
||||
|
||||
|
||||
def _fetch_iss_realtime(observer_lat: Optional[float] = None, observer_lon: Optional[float] = None) -> Optional[dict]:
|
||||
def _fetch_iss_realtime(observer_lat: float | None = None, observer_lon: float | None = None) -> dict | None:
|
||||
"""
|
||||
Fetch real-time ISS position from external APIs.
|
||||
|
||||
@@ -128,6 +401,7 @@ def _fetch_iss_realtime(observer_lat: Optional[float] = None, observer_lon: Opti
|
||||
|
||||
result = {
|
||||
'satellite': 'ISS',
|
||||
'norad_id': 25544,
|
||||
'lat': iss_lat,
|
||||
'lon': iss_lon,
|
||||
'altitude': iss_alt,
|
||||
@@ -179,29 +453,34 @@ def _fetch_iss_realtime(observer_lat: Optional[float] = None, observer_lon: Opti
|
||||
def satellite_dashboard():
|
||||
"""Popout satellite tracking dashboard."""
|
||||
embedded = request.args.get('embedded', 'false') == 'true'
|
||||
return render_template(
|
||||
response = make_response(render_template(
|
||||
'satellite_dashboard.html',
|
||||
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
||||
embedded=embedded,
|
||||
)
|
||||
))
|
||||
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
|
||||
response.headers['Pragma'] = 'no-cache'
|
||||
response.headers['Expires'] = '0'
|
||||
return response
|
||||
|
||||
|
||||
@satellite_bp.route('/predict', methods=['POST'])
|
||||
def predict_passes():
|
||||
"""Calculate satellite passes using skyfield."""
|
||||
try:
|
||||
from skyfield.api import wgs84, EarthSatellite
|
||||
from skyfield.almanac import find_discrete
|
||||
from skyfield.api import EarthSatellite, wgs84
|
||||
except ImportError:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'skyfield library not installed. Run: pip install skyfield'
|
||||
}), 503
|
||||
|
||||
from utils.satellite_predict import predict_passes as _predict_passes
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
# Validate inputs
|
||||
try:
|
||||
# Validate inputs
|
||||
lat = validate_latitude(data.get('latitude', data.get('lat', 51.5074)))
|
||||
lon = validate_longitude(data.get('longitude', data.get('lon', -0.1278)))
|
||||
hours = validate_hours(data.get('hours', 24))
|
||||
@@ -209,142 +488,115 @@ def predict_passes():
|
||||
except ValueError as e:
|
||||
return api_error(str(e), 400)
|
||||
|
||||
norad_to_name = {
|
||||
25544: 'ISS',
|
||||
40069: 'METEOR-M2',
|
||||
57166: 'METEOR-M2-3'
|
||||
}
|
||||
try:
|
||||
sat_input = data.get('satellites', ['ISS', 'METEOR-M2-3', 'METEOR-M2-4'])
|
||||
passes = []
|
||||
colors = {
|
||||
'ISS': '#00ffff',
|
||||
'METEOR-M2': '#9370DB',
|
||||
'METEOR-M2-3': '#ff00ff',
|
||||
'METEOR-M2-4': '#00ff88',
|
||||
}
|
||||
tracked_by_norad, tracked_by_name = _get_tracked_satellite_maps()
|
||||
|
||||
sat_input = data.get('satellites', ['ISS', 'METEOR-M2', 'METEOR-M2-3'])
|
||||
satellites = []
|
||||
for sat in sat_input:
|
||||
if isinstance(sat, int) and sat in norad_to_name:
|
||||
satellites.append(norad_to_name[sat])
|
||||
else:
|
||||
satellites.append(sat)
|
||||
resolved_satellites: list[tuple[str, int, tuple[str, str, str]]] = []
|
||||
for sat in sat_input:
|
||||
sat_name, norad_id, tle_data = _resolve_satellite_request(
|
||||
sat,
|
||||
tracked_by_norad,
|
||||
tracked_by_name,
|
||||
)
|
||||
if not tle_data:
|
||||
continue
|
||||
resolved_satellites.append((sat_name, norad_id or 0, tle_data))
|
||||
|
||||
passes = []
|
||||
colors = {
|
||||
'ISS': '#00ffff',
|
||||
'METEOR-M2': '#9370DB',
|
||||
'METEOR-M2-3': '#ff00ff'
|
||||
}
|
||||
name_to_norad = {v: k for k, v in norad_to_name.items()}
|
||||
if not resolved_satellites:
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'passes': [],
|
||||
'cached': False,
|
||||
})
|
||||
|
||||
ts = _get_timescale()
|
||||
observer = wgs84.latlon(lat, lon)
|
||||
cache_key = _make_pass_cache_key(lat, lon, hours, min_el, resolved_satellites)
|
||||
cached = _pass_cache.get(cache_key)
|
||||
now_ts = time.time()
|
||||
if cached and (now_ts - cached[1]) < _PASS_CACHE_TTL:
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'passes': cached[0],
|
||||
'cached': True,
|
||||
})
|
||||
|
||||
t0 = ts.now()
|
||||
t1 = ts.utc(t0.utc_datetime() + timedelta(hours=hours))
|
||||
|
||||
for sat_name in satellites:
|
||||
if sat_name not in _tle_cache:
|
||||
continue
|
||||
|
||||
tle_data = _tle_cache[sat_name]
|
||||
try:
|
||||
satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
def above_horizon(t):
|
||||
diff = satellite - observer
|
||||
topocentric = diff.at(t)
|
||||
alt, _, _ = topocentric.altaz()
|
||||
return alt.degrees > 0
|
||||
|
||||
above_horizon.step_days = 1/720
|
||||
|
||||
try:
|
||||
times, events = find_discrete(t0, t1, above_horizon)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
i = 0
|
||||
while i < len(times):
|
||||
if i < len(events) and events[i]:
|
||||
rise_time = times[i]
|
||||
set_time = None
|
||||
for j in range(i + 1, len(times)):
|
||||
if not events[j]:
|
||||
set_time = times[j]
|
||||
i = j
|
||||
break
|
||||
|
||||
if set_time is None:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
trajectory = []
|
||||
max_elevation = 0
|
||||
num_points = 30
|
||||
|
||||
duration_seconds = (set_time.utc_datetime() - rise_time.utc_datetime()).total_seconds()
|
||||
|
||||
for k in range(num_points):
|
||||
frac = k / (num_points - 1)
|
||||
t_point = ts.utc(rise_time.utc_datetime() + timedelta(seconds=duration_seconds * frac))
|
||||
ts = _get_timescale()
|
||||
observer = wgs84.latlon(lat, lon)
|
||||
t0 = ts.now()
|
||||
t1 = ts.utc(t0.utc_datetime() + timedelta(hours=hours))
|
||||
|
||||
for sat_name, norad_id, tle_data in resolved_satellites:
|
||||
current_pos = None
|
||||
try:
|
||||
satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts)
|
||||
geo = satellite.at(t0)
|
||||
sp = wgs84.subpoint(geo)
|
||||
current_pos = {
|
||||
'lat': float(sp.latitude.degrees),
|
||||
'lon': float(sp.longitude.degrees),
|
||||
'altitude': float(sp.elevation.km),
|
||||
}
|
||||
# Add observer-relative data using the request's observer location
|
||||
try:
|
||||
diff = satellite - observer
|
||||
topocentric = diff.at(t_point)
|
||||
alt, az, _ = topocentric.altaz()
|
||||
topo = diff.at(t0)
|
||||
alt_deg, az_deg, dist_km = topo.altaz()
|
||||
current_pos['elevation'] = round(float(alt_deg.degrees), 1)
|
||||
current_pos['azimuth'] = round(float(az_deg.degrees), 1)
|
||||
current_pos['distance'] = round(float(dist_km.km), 1)
|
||||
current_pos['visible'] = bool(alt_deg.degrees > 0)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
el = alt.degrees
|
||||
azimuth = az.degrees
|
||||
sat_passes = _predict_passes(tle_data, observer, ts, t0, t1, min_el=min_el)
|
||||
for p in sat_passes:
|
||||
p['satellite'] = sat_name
|
||||
p['norad'] = norad_id
|
||||
p['color'] = colors.get(sat_name, '#00ff00')
|
||||
if current_pos:
|
||||
p['currentPos'] = current_pos
|
||||
passes.extend(sat_passes)
|
||||
|
||||
if el > max_elevation:
|
||||
max_elevation = el
|
||||
passes.sort(key=lambda p: p['startTimeISO'])
|
||||
# Only cache non-empty results to avoid serving a stale empty response
|
||||
# on the next request (which could happen if TLEs were too old to produce
|
||||
# any events — the auto-refresh will update them shortly after startup).
|
||||
if passes:
|
||||
_pass_cache[cache_key] = (passes, now_ts)
|
||||
|
||||
trajectory.append({'el': float(max(0, el)), 'az': float(azimuth)})
|
||||
|
||||
if max_elevation >= min_el:
|
||||
duration_minutes = int(duration_seconds / 60)
|
||||
|
||||
ground_track = []
|
||||
for k in range(60):
|
||||
frac = k / 59
|
||||
t_point = ts.utc(rise_time.utc_datetime() + timedelta(seconds=duration_seconds * frac))
|
||||
geocentric = satellite.at(t_point)
|
||||
subpoint = wgs84.subpoint(geocentric)
|
||||
ground_track.append({
|
||||
'lat': float(subpoint.latitude.degrees),
|
||||
'lon': float(subpoint.longitude.degrees)
|
||||
})
|
||||
|
||||
current_geo = satellite.at(ts.now())
|
||||
current_subpoint = wgs84.subpoint(current_geo)
|
||||
|
||||
passes.append({
|
||||
'satellite': sat_name,
|
||||
'norad': name_to_norad.get(sat_name, 0),
|
||||
'startTime': rise_time.utc_datetime().strftime('%Y-%m-%d %H:%M UTC'),
|
||||
'startTimeISO': rise_time.utc_datetime().isoformat(),
|
||||
'maxEl': float(round(max_elevation, 1)),
|
||||
'duration': int(duration_minutes),
|
||||
'trajectory': trajectory,
|
||||
'groundTrack': ground_track,
|
||||
'currentPos': {
|
||||
'lat': float(current_subpoint.latitude.degrees),
|
||||
'lon': float(current_subpoint.longitude.degrees)
|
||||
},
|
||||
'color': colors.get(sat_name, '#00ff00')
|
||||
})
|
||||
|
||||
i += 1
|
||||
|
||||
passes.sort(key=lambda p: p['startTime'])
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'passes': passes
|
||||
})
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'passes': passes,
|
||||
'cached': False,
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.exception('Satellite pass calculation failed')
|
||||
if 'cache_key' in locals():
|
||||
stale_cached = _pass_cache.get(cache_key)
|
||||
if stale_cached and stale_cached[0]:
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'passes': stale_cached[0],
|
||||
'cached': True,
|
||||
'stale': True,
|
||||
})
|
||||
return api_error(f'Failed to calculate passes: {exc}', 500)
|
||||
|
||||
|
||||
@satellite_bp.route('/position', methods=['POST'])
|
||||
def get_satellite_position():
|
||||
"""Get real-time positions of satellites."""
|
||||
try:
|
||||
from skyfield.api import wgs84, EarthSatellite
|
||||
from skyfield.api import EarthSatellite, wgs84
|
||||
except ImportError:
|
||||
return api_error('skyfield not installed', 503)
|
||||
|
||||
@@ -359,35 +611,30 @@ def get_satellite_position():
|
||||
|
||||
sat_input = data.get('satellites', [])
|
||||
include_track = bool(data.get('includeTrack', True))
|
||||
prefer_realtime_api = bool(data.get('preferRealtimeApi', False))
|
||||
|
||||
norad_to_name = {
|
||||
25544: 'ISS',
|
||||
40069: 'METEOR-M2',
|
||||
57166: 'METEOR-M2-3'
|
||||
}
|
||||
|
||||
satellites = []
|
||||
for sat in sat_input:
|
||||
if isinstance(sat, int) and sat in norad_to_name:
|
||||
satellites.append(norad_to_name[sat])
|
||||
else:
|
||||
satellites.append(sat)
|
||||
|
||||
ts = _get_timescale()
|
||||
observer = wgs84.latlon(lat, lon)
|
||||
now = ts.now()
|
||||
now_dt = now.utc_datetime()
|
||||
ts = None
|
||||
now = None
|
||||
now_dt = None
|
||||
tracked_by_norad, tracked_by_name = _get_tracked_satellite_maps()
|
||||
|
||||
positions = []
|
||||
|
||||
for sat_name in satellites:
|
||||
# Special handling for ISS - use real-time API for accurate position
|
||||
if sat_name == 'ISS':
|
||||
for sat in sat_input:
|
||||
sat_name, norad_id, tle_data = _resolve_satellite_request(sat, tracked_by_norad, tracked_by_name)
|
||||
# Optional special handling for ISS. The dashboard does not enable this
|
||||
# because external API latency can make live updates stall.
|
||||
if prefer_realtime_api and (norad_id == 25544 or sat_name == 'ISS'):
|
||||
iss_data = _fetch_iss_realtime(lat, lon)
|
||||
if iss_data:
|
||||
# Add orbit track if requested (using TLE for track prediction)
|
||||
if include_track and 'ISS' in _tle_cache:
|
||||
try:
|
||||
if ts is None:
|
||||
ts = _get_timescale()
|
||||
now = ts.now()
|
||||
now_dt = now.utc_datetime()
|
||||
tle_data = _tle_cache['ISS']
|
||||
satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts)
|
||||
orbit_track = []
|
||||
@@ -407,14 +654,17 @@ def get_satellite_position():
|
||||
except Exception:
|
||||
pass
|
||||
positions.append(iss_data)
|
||||
continue
|
||||
continue
|
||||
|
||||
# Other satellites - use TLE data
|
||||
if sat_name not in _tle_cache:
|
||||
if not tle_data:
|
||||
continue
|
||||
|
||||
tle_data = _tle_cache[sat_name]
|
||||
try:
|
||||
if ts is None:
|
||||
ts = _get_timescale()
|
||||
now = ts.now()
|
||||
now_dt = now.utc_datetime()
|
||||
satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts)
|
||||
|
||||
geocentric = satellite.at(now)
|
||||
@@ -426,9 +676,10 @@ def get_satellite_position():
|
||||
|
||||
pos_data = {
|
||||
'satellite': sat_name,
|
||||
'norad_id': norad_id,
|
||||
'lat': float(subpoint.latitude.degrees),
|
||||
'lon': float(subpoint.longitude.degrees),
|
||||
'altitude': float(geocentric.distance().km - 6371),
|
||||
'altitude': float(subpoint.elevation.km),
|
||||
'elevation': float(alt.degrees),
|
||||
'azimuth': float(az.degrees),
|
||||
'distance': float(distance.km),
|
||||
@@ -451,6 +702,7 @@ def get_satellite_position():
|
||||
continue
|
||||
|
||||
pos_data['track'] = orbit_track
|
||||
pos_data['groundTrack'] = orbit_track
|
||||
|
||||
positions.append(pos_data)
|
||||
except Exception:
|
||||
@@ -463,6 +715,49 @@ def get_satellite_position():
|
||||
})
|
||||
|
||||
|
||||
@satellite_bp.route('/transmitters/<int:norad_id>')
|
||||
def get_transmitters_endpoint(norad_id: int):
|
||||
"""Return SatNOGS transmitter data for a satellite by NORAD ID."""
|
||||
from utils.satnogs import get_transmitters
|
||||
transmitters = get_transmitters(norad_id)
|
||||
return jsonify({'status': 'success', 'norad_id': norad_id, 'transmitters': transmitters})
|
||||
|
||||
|
||||
@satellite_bp.route('/parse-packet', methods=['POST'])
|
||||
def parse_packet():
|
||||
"""Parse a raw satellite telemetry packet (base64-encoded)."""
|
||||
import base64
|
||||
|
||||
from utils.satellite_telemetry import auto_parse
|
||||
data = request.json or {}
|
||||
try:
|
||||
raw_bytes = base64.b64decode(data.get('data', ''))
|
||||
except Exception:
|
||||
return api_error('Invalid base64 data', 400)
|
||||
result = auto_parse(raw_bytes)
|
||||
return jsonify({'status': 'success', 'parsed': result})
|
||||
|
||||
|
||||
@satellite_bp.route('/stream_satellite')
|
||||
def stream_satellite() -> Response:
|
||||
"""SSE endpoint streaming live satellite positions from the background tracker."""
|
||||
import app as app_module
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.satellite_queue,
|
||||
channel_key='satellite',
|
||||
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
|
||||
|
||||
|
||||
def refresh_tle_data() -> list:
|
||||
"""
|
||||
Refresh TLE data from CelesTrak.
|
||||
|
||||
+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
|
||||
|
||||
+1
-1
@@ -10,8 +10,8 @@ from typing import Any
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
from utils.responses import api_success, api_error
|
||||
from utils.logging import get_logger
|
||||
from utils.responses import api_error
|
||||
|
||||
logger = get_logger('intercept.signalid')
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
+19
-18
@@ -6,23 +6,24 @@ ISS SSTV events occur during special commemorations and typically transmit on 14
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import 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 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')
|
||||
@@ -471,14 +472,14 @@ def stream_progress():
|
||||
return response
|
||||
|
||||
|
||||
def _get_timescale():
|
||||
"""Return a cached skyfield timescale (expensive to create)."""
|
||||
global _timescale
|
||||
with _timescale_lock:
|
||||
if _timescale is None:
|
||||
from skyfield.api import load
|
||||
_timescale = load.timescale()
|
||||
return _timescale
|
||||
def _get_timescale():
|
||||
"""Return a cached skyfield timescale (expensive to create)."""
|
||||
global _timescale
|
||||
with _timescale_lock:
|
||||
if _timescale is None:
|
||||
from skyfield.api import load
|
||||
_timescale = load.timescale(builtin=True)
|
||||
return _timescale
|
||||
|
||||
|
||||
@sstv_bp.route('/iss-schedule')
|
||||
@@ -520,9 +521,11 @@ def iss_schedule():
|
||||
return jsonify(_iss_schedule_cache)
|
||||
|
||||
try:
|
||||
from skyfield.api import wgs84, EarthSatellite
|
||||
from skyfield.almanac import find_discrete
|
||||
from datetime import timedelta
|
||||
|
||||
from skyfield.almanac import find_discrete
|
||||
from skyfield.api import EarthSatellite, wgs84
|
||||
|
||||
from data.satellites import TLE_SATELLITES
|
||||
|
||||
# Get ISS TLE
|
||||
@@ -816,7 +819,5 @@ def decode_file():
|
||||
|
||||
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:
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
from utils.constants import SSE_KEEPALIVE_INTERVAL, SSE_QUEUE_TIMEOUT
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.responses import api_success, api_error
|
||||
from utils.responses import api_error
|
||||
from utils.sse import sse_stream_fanout
|
||||
|
||||
try:
|
||||
|
||||
+22
-22
@@ -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:
|
||||
@@ -659,8 +660,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 +875,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 +913,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 +954,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 +1059,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 +1100,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:
|
||||
@@ -1816,9 +1814,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 (
|
||||
|
||||
+13
-17
@@ -7,27 +7,25 @@ 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 (
|
||||
_baseline_recorder,
|
||||
_current_sweep_id,
|
||||
_emit_event,
|
||||
_start_sweep_internal,
|
||||
_sweep_running,
|
||||
tscm_bp,
|
||||
tscm_queue,
|
||||
_baseline_recorder,
|
||||
)
|
||||
from data.tscm_frequencies import get_all_sweep_presets, get_sweep_preset
|
||||
from utils.database import get_tscm_sweep, update_tscm_sweep
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.sse import sse_stream_fanout
|
||||
@@ -38,8 +36,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'])
|
||||
@@ -98,15 +96,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 +114,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 +217,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 +352,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 +365,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 +375,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,
|
||||
|
||||
+6
-10
@@ -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
|
||||
@@ -105,10 +105,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 +132,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 +271,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,
|
||||
|
||||
+176
-58
@@ -1,33 +1,53 @@
|
||||
"""Weather Satellite decoder routes.
|
||||
"""Weather Satellite decoder routes.
|
||||
|
||||
Provides endpoints for capturing and decoding Meteor LRPT weather
|
||||
imagery, including shared results produced by the ground-station
|
||||
observation pipeline.
|
||||
"""
|
||||
|
||||
Provides endpoints for capturing and decoding weather satellite images
|
||||
from NOAA (APT) and Meteor (LRPT) satellites using SatDump.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import queue
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request, send_file
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response, send_file
|
||||
|
||||
from utils.responses import api_success, api_error
|
||||
from utils.logging import get_logger
|
||||
from utils.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')
|
||||
|
||||
weather_sat_bp = Blueprint('weather_sat', __name__, url_prefix='/weather-sat')
|
||||
logger = get_logger('intercept.weather_sat')
|
||||
|
||||
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)
|
||||
_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:
|
||||
@@ -112,7 +132,7 @@ 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)
|
||||
"bias_t": false // Enable bias-T for LNA (default: false)
|
||||
@@ -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,15 +304,14 @@ 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'
|
||||
try:
|
||||
resolved = input_path.resolve()
|
||||
if not resolved.is_relative_to(allowed_base):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'input_file must be under the data/ directory'
|
||||
}), 403
|
||||
# Restrict test-decode to application-owned sample and recording paths.
|
||||
try:
|
||||
resolved = input_path.resolve()
|
||||
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 INTERCEPT data or ground-station recordings'
|
||||
}), 403
|
||||
except (OSError, ValueError):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
@@ -369,8 +388,8 @@ def stop_capture():
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@weather_sat_bp.route('/images')
|
||||
def list_images():
|
||||
@weather_sat_bp.route('/images')
|
||||
def list_images():
|
||||
"""Get list of decoded weather satellite images.
|
||||
|
||||
Query parameters:
|
||||
@@ -380,28 +399,41 @@ def list_images():
|
||||
Returns:
|
||||
JSON with list of decoded images.
|
||||
"""
|
||||
decoder = get_weather_sat_decoder()
|
||||
images = decoder.get_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]
|
||||
|
||||
# Apply limit
|
||||
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),
|
||||
})
|
||||
decoder = get_weather_sat_decoder()
|
||||
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 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]
|
||||
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'images': images,
|
||||
'count': len(images),
|
||||
})
|
||||
|
||||
|
||||
@weather_sat_bp.route('/images/<filename>')
|
||||
def get_image(filename: str):
|
||||
@weather_sat_bp.route('/images/<filename>')
|
||||
def get_image(filename: str):
|
||||
"""Serve a decoded weather satellite image file.
|
||||
|
||||
Args:
|
||||
@@ -424,8 +456,38 @@ def get_image(filename: str):
|
||||
if not image_path.exists():
|
||||
return api_error('Image not found', 404)
|
||||
|
||||
mimetype = 'image/png' if filename.endswith('.png') else 'image/jpeg'
|
||||
return send_file(image_path, mimetype=mimetype)
|
||||
mimetype = 'image/png' if filename.endswith('.png') else 'image/jpeg'
|
||||
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'])
|
||||
@@ -450,15 +512,71 @@ def delete_image(filename: str):
|
||||
|
||||
|
||||
@weather_sat_bp.route('/images', methods=['DELETE'])
|
||||
def delete_all_images():
|
||||
def delete_all_images():
|
||||
"""Delete all decoded weather satellite images.
|
||||
|
||||
Returns:
|
||||
JSON with count of deleted images.
|
||||
"""
|
||||
decoder = get_weather_sat_decoder()
|
||||
count = decoder.delete_all_images()
|
||||
return jsonify({'status': 'ok', 'deleted': count})
|
||||
count = decoder.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')
|
||||
@@ -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')
|
||||
|
||||
+12
-14
@@ -7,26 +7,26 @@ channel analysis, hidden SSID correlation, and SSE streaming.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Generator
|
||||
from datetime import datetime
|
||||
from typing import Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
from utils.wifi import (
|
||||
get_wifi_scanner,
|
||||
analyze_channels,
|
||||
get_hidden_correlator,
|
||||
SCAN_MODE_QUICK,
|
||||
SCAN_MODE_DEEP,
|
||||
)
|
||||
from utils.responses import api_success, api_error
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.responses import api_error
|
||||
from utils.sse import format_sse
|
||||
from utils.validation import validate_wifi_channel
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.wifi import (
|
||||
SCAN_MODE_DEEP,
|
||||
analyze_channels,
|
||||
get_hidden_correlator,
|
||||
get_wifi_scanner,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -407,10 +407,8 @@ def event_stream():
|
||||
scanner = get_wifi_scanner()
|
||||
|
||||
for event in scanner.get_event_stream():
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
process_event('wifi', event, event.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
yield format_sse(event)
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Minimal semver compatibility shim.
|
||||
|
||||
This project vendors a tiny subset of the ``semver`` package API so
|
||||
integrations like radiosonde_auto_rx can run even when the external
|
||||
dependency is missing from the target Python environment.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import Iterable
|
||||
|
||||
_SEMVER_RE = re.compile(
|
||||
r"^\s*"
|
||||
r"(?P<major>0|[1-9]\d*)"
|
||||
r"(?:\.(?P<minor>0|[1-9]\d*))?"
|
||||
r"(?:\.(?P<patch>0|[1-9]\d*))?"
|
||||
r"(?:-(?P<prerelease>[0-9A-Za-z.-]+))?"
|
||||
r"(?:\+(?P<build>[0-9A-Za-z.-]+))?"
|
||||
r"\s*$"
|
||||
)
|
||||
|
||||
|
||||
def _split_prerelease(value: str | None) -> list[int | str]:
|
||||
if not value:
|
||||
return []
|
||||
parts: list[int | str] = []
|
||||
for token in value.split("."):
|
||||
parts.append(int(token) if token.isdigit() else token)
|
||||
return parts
|
||||
|
||||
|
||||
def _compare_identifiers(left: Iterable[int | str], right: Iterable[int | str]) -> int:
|
||||
left_parts = list(left)
|
||||
right_parts = list(right)
|
||||
for l_part, r_part in zip(left_parts, right_parts):
|
||||
if l_part == r_part:
|
||||
continue
|
||||
if isinstance(l_part, int) and isinstance(r_part, str):
|
||||
return -1
|
||||
if isinstance(l_part, str) and isinstance(r_part, int):
|
||||
return 1
|
||||
return -1 if l_part < r_part else 1
|
||||
if len(left_parts) == len(right_parts):
|
||||
return 0
|
||||
return -1 if len(left_parts) < len(right_parts) else 1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VersionInfo:
|
||||
major: int
|
||||
minor: int = 0
|
||||
patch: int = 0
|
||||
prerelease: str | None = None
|
||||
build: str | None = None
|
||||
|
||||
@classmethod
|
||||
def parse(cls, version: str) -> VersionInfo:
|
||||
match = _SEMVER_RE.match(str(version))
|
||||
if not match:
|
||||
raise ValueError(f"{version!r} is not valid SemVer")
|
||||
groups = match.groupdict()
|
||||
return cls(
|
||||
major=int(groups["major"]),
|
||||
minor=int(groups["minor"] or 0),
|
||||
patch=int(groups["patch"] or 0),
|
||||
prerelease=groups["prerelease"],
|
||||
build=groups["build"],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def isvalid(cls, version: str) -> bool:
|
||||
return _SEMVER_RE.match(str(version)) is not None
|
||||
|
||||
@classmethod
|
||||
def is_valid(cls, version: str) -> bool:
|
||||
return cls.isvalid(version)
|
||||
|
||||
def compare(self, other: str | VersionInfo) -> int:
|
||||
return compare(self, other)
|
||||
|
||||
def match(self, expr: str) -> bool:
|
||||
return match(str(self), expr)
|
||||
|
||||
def bump_major(self) -> VersionInfo:
|
||||
return VersionInfo(self.major + 1, 0, 0)
|
||||
|
||||
def bump_minor(self) -> VersionInfo:
|
||||
return VersionInfo(self.major, self.minor + 1, 0)
|
||||
|
||||
def bump_patch(self) -> VersionInfo:
|
||||
return VersionInfo(self.major, self.minor, self.patch + 1)
|
||||
|
||||
def finalize_version(self) -> VersionInfo:
|
||||
return VersionInfo(self.major, self.minor, self.patch)
|
||||
|
||||
def replace(self, **changes) -> VersionInfo:
|
||||
return replace(self, **changes)
|
||||
|
||||
def __str__(self) -> str:
|
||||
value = f"{self.major}.{self.minor}.{self.patch}"
|
||||
if self.prerelease:
|
||||
value += f"-{self.prerelease}"
|
||||
if self.build:
|
||||
value += f"+{self.build}"
|
||||
return value
|
||||
|
||||
|
||||
def parse(version: str) -> VersionInfo:
|
||||
return VersionInfo.parse(version)
|
||||
|
||||
|
||||
def compare(left: str | VersionInfo, right: str | VersionInfo) -> int:
|
||||
left_ver = left if isinstance(left, VersionInfo) else parse(str(left))
|
||||
right_ver = right if isinstance(right, VersionInfo) else parse(str(right))
|
||||
|
||||
left_core = (left_ver.major, left_ver.minor, left_ver.patch)
|
||||
right_core = (right_ver.major, right_ver.minor, right_ver.patch)
|
||||
if left_core != right_core:
|
||||
return -1 if left_core < right_core else 1
|
||||
|
||||
if left_ver.prerelease == right_ver.prerelease:
|
||||
return 0
|
||||
if left_ver.prerelease is None:
|
||||
return 1
|
||||
if right_ver.prerelease is None:
|
||||
return -1
|
||||
return _compare_identifiers(
|
||||
_split_prerelease(left_ver.prerelease),
|
||||
_split_prerelease(right_ver.prerelease),
|
||||
)
|
||||
|
||||
|
||||
def match(version: str | VersionInfo, expr: str) -> bool:
|
||||
version_info = version if isinstance(version, VersionInfo) else parse(str(version))
|
||||
expression = str(expr).strip()
|
||||
for operator in ("<=", ">=", "==", "!=", "<", ">"):
|
||||
if expression.startswith(operator):
|
||||
other = parse(expression[len(operator):].strip())
|
||||
result = compare(version_info, other)
|
||||
return {
|
||||
"<": result < 0,
|
||||
"<=": result <= 0,
|
||||
">": result > 0,
|
||||
">=": result >= 0,
|
||||
"==": result == 0,
|
||||
"!=": result != 0,
|
||||
}[operator]
|
||||
return compare(version_info, parse(expression)) == 0
|
||||
|
||||
|
||||
def max_ver(left: str | VersionInfo, right: str | VersionInfo) -> VersionInfo:
|
||||
left_ver = left if isinstance(left, VersionInfo) else parse(str(left))
|
||||
right_ver = right if isinstance(right, VersionInfo) else parse(str(right))
|
||||
return left_ver if compare(left_ver, right_ver) >= 0 else right_ver
|
||||
|
||||
|
||||
def min_ver(left: str | VersionInfo, right: str | VersionInfo) -> VersionInfo:
|
||||
left_ver = left if isinstance(left, VersionInfo) else parse(str(left))
|
||||
right_ver = right if isinstance(right, VersionInfo) else parse(str(right))
|
||||
return left_ver if compare(left_ver, right_ver) <= 0 else right_ver
|
||||
@@ -174,7 +174,7 @@ read_env_var() {
|
||||
local fallback="${2:-}"
|
||||
if [[ -f "$SCRIPT_DIR/.env" ]]; then
|
||||
local val
|
||||
val=$(grep -E "^${key}=" "$SCRIPT_DIR/.env" 2>/dev/null | tail -1 | cut -d'=' -f2-)
|
||||
val=$(grep -E "^${key}=" "$SCRIPT_DIR/.env" 2>/dev/null | tail -1 | cut -d'=' -f2- || true)
|
||||
if [[ -n "$val" ]]; then
|
||||
# Strip surrounding quotes
|
||||
val="${val#\"}"
|
||||
@@ -438,7 +438,11 @@ check_tools() {
|
||||
check_optional "dumpvdl2" "VDL2 decoder" dumpvdl2
|
||||
check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher
|
||||
check_optional "satdump" "Weather satellite decoder (NOAA/Meteor)" satdump
|
||||
check_optional "auto_rx.py" "Radiosonde weather balloon decoder" auto_rx.py
|
||||
if [[ -f /opt/radiosonde_auto_rx/auto_rx/auto_rx.py ]] && [[ -f /opt/radiosonde_auto_rx/auto_rx/dft_detect ]]; then
|
||||
ok "auto_rx.py - Radiosonde weather balloon decoder"
|
||||
else
|
||||
warn "auto_rx.py - Radiosonde weather balloon decoder (missing, optional)"
|
||||
fi
|
||||
echo
|
||||
info "GPS:"
|
||||
check_required "gpsd" "GPS daemon" gpsd
|
||||
@@ -487,6 +491,16 @@ import sys
|
||||
raise SystemExit(0 if sys.version_info >= (3,9) else 1)
|
||||
PY
|
||||
ok "Python version OK (>= 3.9)"
|
||||
|
||||
# Python 3.13+ warning: some packages (gevent, numpy, scipy) may not have
|
||||
# pre-built wheels yet and will be skipped to avoid hanging on compilation.
|
||||
if python3 - <<'PY'
|
||||
import sys
|
||||
raise SystemExit(0 if sys.version_info >= (3,13) else 1)
|
||||
PY
|
||||
then
|
||||
warn "Python 3.13+ detected: optional packages without pre-built wheels will be skipped (--prefer-binary)."
|
||||
fi
|
||||
}
|
||||
|
||||
install_python_deps() {
|
||||
@@ -520,8 +534,11 @@ install_python_deps() {
|
||||
source venv/bin/activate
|
||||
local PIP="venv/bin/python -m pip"
|
||||
local PY="venv/bin/python"
|
||||
# --no-cache-dir avoids pip hanging on a corrupt/stale HTTP cache (cachecontrol .pyc issue)
|
||||
# --timeout prevents pip from hanging indefinitely on slow/unresponsive PyPI connections
|
||||
local PIP_OPTS="--no-cache-dir --timeout 120"
|
||||
|
||||
if ! $PIP install --upgrade pip setuptools wheel; then
|
||||
if ! $PIP install $PIP_OPTS --upgrade pip setuptools wheel; then
|
||||
warn "pip/setuptools/wheel upgrade failed - continuing with existing versions"
|
||||
else
|
||||
ok "Upgraded pip tooling"
|
||||
@@ -530,24 +547,39 @@ install_python_deps() {
|
||||
progress "Installing Python dependencies"
|
||||
|
||||
info "Installing core packages..."
|
||||
$PIP install --quiet "flask>=3.0.0" "flask-limiter>=2.5.4" "requests>=2.28.0" \
|
||||
"Werkzeug>=3.1.5" "pyserial>=3.5" 2>/dev/null || true
|
||||
$PIP install $PIP_OPTS "flask>=3.0.0" "flask-wtf>=1.2.0" "flask-compress>=1.15" \
|
||||
"flask-limiter>=2.5.4" "requests>=2.28.0" \
|
||||
"Werkzeug>=3.1.5" "pyserial>=3.5" || true
|
||||
|
||||
$PY -c "import flask; import requests; from flask_limiter import Limiter" 2>/dev/null || {
|
||||
fail "Critical Python packages (flask, requests, flask-limiter) not installed"
|
||||
echo "Try: venv/bin/pip install flask requests flask-limiter"
|
||||
exit 1
|
||||
}
|
||||
# Verify core packages are installed by checking pip's reported list (avoids hanging imports)
|
||||
for core_pkg in flask requests flask-limiter flask-compress flask-wtf; do
|
||||
if ! $PIP show "$core_pkg" >/dev/null 2>&1; then
|
||||
fail "Critical Python package not installed: ${core_pkg}"
|
||||
echo "Try: venv/bin/pip install ${core_pkg}"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
ok "Core Python packages installed"
|
||||
|
||||
info "Installing optional packages..."
|
||||
for pkg in "flask-sock" "websocket-client>=1.6.0" "numpy>=1.24.0" "scipy>=1.10.0" \
|
||||
"Pillow>=9.0.0" "skyfield>=1.45" "bleak>=0.21.0" "psycopg2-binary>=2.9.9" \
|
||||
"meshtastic>=2.0.0" "scapy>=2.4.5" "qrcode[pil]>=7.4" "cryptography>=41.0.0" \
|
||||
"gunicorn>=21.2.0" "gevent>=23.9.0" "psutil>=5.9.0"; do
|
||||
pkg_name="${pkg%%>=*}"
|
||||
# Pure-Python packages: install without --only-binary so they always succeed regardless of platform
|
||||
for pkg in "flask-sock" "simple-websocket>=0.5.1" "websocket-client>=1.6.0" \
|
||||
"skyfield>=1.45" "bleak>=0.21.0" "meshtastic>=2.0.0" \
|
||||
"qrcode[pil]>=7.4" "gunicorn>=21.2.0" "psutil>=5.9.0"; do
|
||||
pkg_name="${pkg%%[><=]*}"
|
||||
info " Installing ${pkg_name}..."
|
||||
if ! $PIP install "$pkg"; then
|
||||
if ! $PIP install $PIP_OPTS "$pkg"; then
|
||||
warn "${pkg_name} failed to install (optional - related features may be unavailable)"
|
||||
fi
|
||||
done
|
||||
# Compiled packages: use --only-binary :all: to skip slow source compilation on RPi
|
||||
for pkg in "numpy>=1.24.0" "scipy>=1.10.0" "Pillow>=9.0.0" \
|
||||
"psycopg2-binary>=2.9.9" "scapy>=2.4.5" "cryptography>=41.0.0" \
|
||||
"gevent>=23.9.0"; do
|
||||
pkg_name="${pkg%%[><=]*}"
|
||||
info " Installing ${pkg_name}..."
|
||||
# --only-binary :all: prevents source compilation hangs for heavy packages
|
||||
if ! $PIP install $PIP_OPTS --only-binary :all: "$pkg"; then
|
||||
warn "${pkg_name} failed to install (optional - related features may be unavailable)"
|
||||
fi
|
||||
done
|
||||
@@ -603,7 +635,25 @@ apt_install() {
|
||||
fi
|
||||
}
|
||||
|
||||
wait_for_apt_lock() {
|
||||
local max_wait=120
|
||||
local waited=0
|
||||
while fuser /var/lib/dpkg/lock-frontend /var/lib/apt/lists/lock /var/cache/apt/archives/lock >/dev/null 2>&1; do
|
||||
if [[ $waited -eq 0 ]]; then
|
||||
info "Waiting for apt lock (another package manager is running)..."
|
||||
fi
|
||||
sleep 5
|
||||
waited=$((waited + 5))
|
||||
if [[ $waited -ge $max_wait ]]; then
|
||||
warn "apt lock held for over ${max_wait}s. Continuing anyway (may fail)."
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
apt_try_install_any() {
|
||||
wait_for_apt_lock
|
||||
local p
|
||||
for p in "$@"; do
|
||||
if $SUDO apt-get install -y --no-install-recommends "$p" >/dev/null 2>&1; then
|
||||
@@ -751,9 +801,26 @@ install_acarsdec_from_source_macos() {
|
||||
|
||||
cd "$tmp_dir/acarsdec"
|
||||
|
||||
# Replace deprecated -Ofast (all macOS, not just arm64)
|
||||
if grep -q '\-Ofast' CMakeLists.txt 2>/dev/null; then
|
||||
sed -i '' 's/-Ofast/-O3 -ffast-math/g' CMakeLists.txt
|
||||
info "Patched deprecated -Ofast flag"
|
||||
fi
|
||||
|
||||
# macOS doesn't have -march=native on arm64
|
||||
if [[ "$(uname -m)" == "arm64" ]]; then
|
||||
sed -i '' 's/-Ofast -march=native/-O3 -ffast-math/g' CMakeLists.txt
|
||||
info "Patched compiler flags for Apple Silicon (arm64)"
|
||||
sed -i '' 's/ -march=native//g' CMakeLists.txt
|
||||
info "Removed -march=native for Apple Silicon"
|
||||
fi
|
||||
|
||||
# HOST_NAME_MAX is Linux-specific; macOS uses _POSIX_HOST_NAME_MAX
|
||||
if grep -q 'HOST_NAME_MAX' acarsdec.c 2>/dev/null; then
|
||||
sed -i '' '1i\
|
||||
#ifndef HOST_NAME_MAX\
|
||||
#define HOST_NAME_MAX 255\
|
||||
#endif
|
||||
' acarsdec.c
|
||||
info "Patched HOST_NAME_MAX for macOS compatibility"
|
||||
fi
|
||||
|
||||
if grep -q 'pthread_tryjoin_np' rtl.c 2>/dev/null; then
|
||||
@@ -957,8 +1024,14 @@ install_satdump_from_source_debian() {
|
||||
) &
|
||||
progress_pid=$!
|
||||
|
||||
local arch_flags=""
|
||||
if [[ "$(uname -m)" == "x86_64" ]]; then
|
||||
arch_flags="-march=x86-64"
|
||||
fi
|
||||
|
||||
if cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF -DCMAKE_INSTALL_LIBDIR=lib \
|
||||
-DCMAKE_CXX_FLAGS="-Wno-template-body" .. >"$build_log" 2>&1 \
|
||||
-DCMAKE_C_FLAGS="$arch_flags" \
|
||||
-DCMAKE_CXX_FLAGS="$arch_flags -Wno-template-body" .. >"$build_log" 2>&1 \
|
||||
&& make -j "$(nproc)" >>"$build_log" 2>&1; then
|
||||
kill $progress_pid 2>/dev/null; wait $progress_pid 2>/dev/null
|
||||
$SUDO make install >/dev/null 2>&1
|
||||
@@ -1697,6 +1770,7 @@ install_profiles() {
|
||||
export NEEDRESTART_MODE=a
|
||||
fi
|
||||
|
||||
wait_for_apt_lock
|
||||
info "Updating APT package lists..."
|
||||
if ! $SUDO apt-get update -y >/dev/null 2>&1; then
|
||||
warn "apt-get update reported errors. Continuing anyway."
|
||||
@@ -1918,7 +1992,18 @@ do_health_check() {
|
||||
info "SDR device detection..."
|
||||
if cmd_exists rtl_test; then
|
||||
local rtl_output
|
||||
rtl_output=$(timeout 3 rtl_test -d 0 2>&1 || true)
|
||||
if cmd_exists timeout; then
|
||||
rtl_output=$(timeout 3 rtl_test -d 0 2>&1 || true)
|
||||
elif cmd_exists gtimeout; then
|
||||
rtl_output=$(gtimeout 3 rtl_test -d 0 2>&1 || true)
|
||||
else
|
||||
# No timeout command (common on macOS) — run with background kill
|
||||
rtl_test -d 0 > /tmp/.rtl_test_out 2>&1 & local rtl_pid=$!
|
||||
sleep 2
|
||||
kill "$rtl_pid" 2>/dev/null; wait "$rtl_pid" 2>/dev/null
|
||||
rtl_output=$(cat /tmp/.rtl_test_out 2>/dev/null || true)
|
||||
rm -f /tmp/.rtl_test_out
|
||||
fi
|
||||
if echo "$rtl_output" | grep -q "Found\|Using device"; then
|
||||
ok "RTL-SDR device detected"
|
||||
((pass++)) || true
|
||||
@@ -1978,8 +2063,8 @@ do_health_check() {
|
||||
ok "Python venv exists"
|
||||
((pass++)) || true
|
||||
|
||||
if venv/bin/python -c "import flask; import requests" 2>/dev/null; then
|
||||
ok "Critical Python packages (flask, requests) — OK"
|
||||
if venv/bin/python -s -c "import flask; import requests; import flask_compress; import flask_wtf" 2>/dev/null; then
|
||||
ok "Critical Python packages (flask, requests, flask-compress, flask-wtf) — OK"
|
||||
((pass++)) || true
|
||||
else
|
||||
fail "Critical Python packages missing in venv"
|
||||
|
||||
@@ -87,6 +87,25 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Branded "i" — inline SVG that matches the logo icon.
|
||||
Sized to 0.9em so it sits naturally alongside text at any font-size.
|
||||
Uses .logo .brand-i (0,2,0) to beat .logo span (0,1,1) in dashboard CSS
|
||||
which otherwise forces display:inline and breaks width/height. */
|
||||
.brand-i,
|
||||
.logo .brand-i {
|
||||
display: inline-block;
|
||||
width: 0.55em;
|
||||
height: 0.9em;
|
||||
vertical-align: baseline;
|
||||
position: relative;
|
||||
top: 0.05em;
|
||||
}
|
||||
.brand-i svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.app-logo-tagline {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-dim);
|
||||
|
||||
+1033
-58
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 300" width="1200" height="300">
|
||||
<defs>
|
||||
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
|
||||
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="rgba(0,212,255,0.025)" stroke-width="1"/>
|
||||
</pattern>
|
||||
<radialGradient id="glow1" cx="75%" cy="30%" r="35%">
|
||||
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.05"/>
|
||||
<stop offset="100%" stop-color="#00d4ff" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="glow2" cx="25%" cy="70%" r="30%">
|
||||
<stop offset="0%" stop-color="#00ff88" stop-opacity="0.03"/>
|
||||
<stop offset="100%" stop-color="#00ff88" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect width="1200" height="300" fill="#0a0a0f"/>
|
||||
<rect width="1200" height="300" fill="url(#grid)"/>
|
||||
<rect width="1200" height="300" fill="url(#glow1)"/>
|
||||
<rect width="1200" height="300" fill="url(#glow2)"/>
|
||||
|
||||
<!-- Logo -->
|
||||
<g transform="translate(340, 60) scale(1.0)">
|
||||
<path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="4" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="3.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="4" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="3.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<circle cx="50" cy="22" r="7" fill="#00ff88"/>
|
||||
<rect x="43" y="35" width="14" height="45" rx="2" fill="#00d4ff"/>
|
||||
<rect x="36" y="35" width="28" height="5" rx="1" fill="#00d4ff"/>
|
||||
<rect x="36" y="75" width="28" height="5" rx="1" fill="#00d4ff"/>
|
||||
</g>
|
||||
|
||||
<!-- Title: branded "i" glyph + NTERCEPT text -->
|
||||
<g transform="translate(476, 78) scale(0.76)">
|
||||
<circle cx="50" cy="18" r="6" fill="#00ff88"/>
|
||||
<rect x="44" y="30" width="12" height="45" rx="2" fill="#00d4ff"/>
|
||||
<rect x="38" y="30" width="24" height="4" rx="1" fill="#00d4ff"/>
|
||||
<rect x="38" y="71" width="24" height="4" rx="1" fill="#00d4ff"/>
|
||||
</g>
|
||||
<text x="524" y="135" font-family="'Segoe UI','Helvetica Neue',Arial,sans-serif" font-size="64" font-weight="800" letter-spacing="-1.5" fill="white">NTERCEPT</text>
|
||||
|
||||
<!-- Subtitle -->
|
||||
<text x="490" y="170" font-family="'Segoe UI','Helvetica Neue',Arial,sans-serif" font-size="18" font-weight="300" fill="rgba(255,255,255,0.35)">
|
||||
Web-Based Signal Intelligence Platform
|
||||
</text>
|
||||
|
||||
<!-- Feature dots -->
|
||||
<g font-family="'Courier New',monospace" font-size="11" fill="rgba(0,212,255,0.4)" letter-spacing="1.5">
|
||||
<circle cx="492" cy="206" r="2" fill="#00ff88" opacity="0.6"/>
|
||||
<text x="500" y="210">34 Signal Modes</text>
|
||||
<circle cx="492" cy="228" r="2" fill="#00ff88" opacity="0.6"/>
|
||||
<text x="500" y="232">Multi-SDR Support</text>
|
||||
<circle cx="492" cy="250" r="2" fill="#00ff88" opacity="0.6"/>
|
||||
<text x="500" y="254">Open Source</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,68 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 640" width="1280" height="640">
|
||||
<defs>
|
||||
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
|
||||
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="rgba(0,212,255,0.025)" stroke-width="1"/>
|
||||
</pattern>
|
||||
<radialGradient id="glow1" cx="80%" cy="20%" r="40%">
|
||||
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.06"/>
|
||||
<stop offset="100%" stop-color="#00d4ff" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="glow2" cx="20%" cy="80%" r="30%">
|
||||
<stop offset="0%" stop-color="#00ff88" stop-opacity="0.04"/>
|
||||
<stop offset="100%" stop-color="#00ff88" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect width="1280" height="640" fill="#0a0a0f"/>
|
||||
<rect width="1280" height="640" fill="url(#grid)"/>
|
||||
<rect width="1280" height="640" fill="url(#glow1)"/>
|
||||
<rect width="1280" height="640" fill="url(#glow2)"/>
|
||||
|
||||
<!-- Corner brackets -->
|
||||
<g stroke="rgba(0,212,255,0.2)" stroke-width="1.5" fill="none">
|
||||
<polyline points="24,48 24,24 48,24"/>
|
||||
<polyline points="1232,24 1256,24 1256,48"/>
|
||||
<polyline points="24,592 24,616 48,616"/>
|
||||
<polyline points="1232,616 1256,616 1256,592"/>
|
||||
</g>
|
||||
|
||||
<!-- Logo -->
|
||||
<g transform="translate(560, 140) scale(1.4)">
|
||||
<path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="4" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="3.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="4" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="3.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<circle cx="50" cy="22" r="7" fill="#00ff88"/>
|
||||
<rect x="43" y="35" width="14" height="45" rx="2" fill="#00d4ff"/>
|
||||
<rect x="36" y="35" width="28" height="5" rx="1" fill="#00d4ff"/>
|
||||
<rect x="36" y="75" width="28" height="5" rx="1" fill="#00d4ff"/>
|
||||
</g>
|
||||
|
||||
<!-- Title: branded "i" glyph + NTERCEPT text -->
|
||||
<g transform="translate(337, 304) scale(1.0)">
|
||||
<circle cx="50" cy="18" r="6" fill="#00ff88"/>
|
||||
<rect x="44" y="30" width="12" height="45" rx="2" fill="#00d4ff"/>
|
||||
<rect x="38" y="30" width="24" height="4" rx="1" fill="#00d4ff"/>
|
||||
<rect x="38" y="71" width="24" height="4" rx="1" fill="#00d4ff"/>
|
||||
</g>
|
||||
<text x="400" y="380" font-family="'Segoe UI','Helvetica Neue',Arial,sans-serif" font-size="84" font-weight="800" letter-spacing="-2" fill="white">NTERCEPT</text>
|
||||
|
||||
<!-- Subtitle -->
|
||||
<text x="640" y="425" text-anchor="middle" font-family="'Segoe UI','Helvetica Neue',Arial,sans-serif" font-size="24" font-weight="300" fill="rgba(255,255,255,0.4)" letter-spacing="0.5">
|
||||
Web-Based Signal Intelligence Platform
|
||||
</text>
|
||||
|
||||
<!-- Tags -->
|
||||
<g font-family="'Courier New',monospace" font-size="12" fill="rgba(0,212,255,0.5)" letter-spacing="2">
|
||||
<text x="440" y="480" text-anchor="middle">SDR</text>
|
||||
<text x="520" y="480" text-anchor="middle" fill="rgba(255,255,255,0.15)">|</text>
|
||||
<text x="600" y="480" text-anchor="middle">RF ANALYSIS</text>
|
||||
<text x="698" y="480" text-anchor="middle" fill="rgba(255,255,255,0.15)">|</text>
|
||||
<text x="778" y="480" text-anchor="middle">34 MODES</text>
|
||||
<text x="858" y="480" text-anchor="middle" fill="rgba(255,255,255,0.15)">|</text>
|
||||
<text x="950" y="480" text-anchor="middle">OPEN SOURCE</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
+33
-21
@@ -10,10 +10,11 @@ let currentAgent = 'local';
|
||||
let agentEventSource = null;
|
||||
let multiAgentMode = false; // Show combined results from all agents
|
||||
let multiAgentPollInterval = null;
|
||||
let agentRunningModes = []; // Track agent's running modes for conflict detection
|
||||
let agentRunningModesDetail = {}; // Track device info per mode (for multi-SDR agents)
|
||||
let healthCheckInterval = null; // Health monitoring interval
|
||||
let agentHealthStatus = {}; // Cache of health status per agent ID
|
||||
let agentRunningModes = []; // Track agent's running modes for conflict detection
|
||||
let agentRunningModesDetail = {}; // Track device info per mode (for multi-SDR agents)
|
||||
let healthCheckInterval = null; // Health monitoring interval
|
||||
let agentHealthStatus = {}; // Cache of health status per agent ID
|
||||
let healthCheckKickoffTimer = null;
|
||||
|
||||
// ============== AGENT HEALTH MONITORING ==============
|
||||
|
||||
@@ -21,27 +22,38 @@ let agentHealthStatus = {}; // Cache of health status per agent ID
|
||||
* Start periodic health monitoring for all agents.
|
||||
* Runs every 30 seconds to check agent health status.
|
||||
*/
|
||||
function startHealthMonitoring() {
|
||||
// Don't start if already running
|
||||
if (healthCheckInterval) return;
|
||||
|
||||
// Initial check
|
||||
checkAllAgentsHealth();
|
||||
|
||||
// Start periodic checks every 30 seconds
|
||||
healthCheckInterval = setInterval(checkAllAgentsHealth, 30000);
|
||||
console.log('[AgentManager] Health monitoring started (30s interval)');
|
||||
}
|
||||
function startHealthMonitoring() {
|
||||
// Don't start if already running
|
||||
if (healthCheckInterval) return;
|
||||
|
||||
// Defer the first probe so heavy dashboards can finish initial render
|
||||
// before we start contacting remote agents.
|
||||
if (healthCheckKickoffTimer) {
|
||||
clearTimeout(healthCheckKickoffTimer);
|
||||
}
|
||||
healthCheckKickoffTimer = setTimeout(() => {
|
||||
healthCheckKickoffTimer = null;
|
||||
checkAllAgentsHealth();
|
||||
}, 5000);
|
||||
|
||||
// Start periodic checks every 30 seconds
|
||||
healthCheckInterval = setInterval(checkAllAgentsHealth, 30000);
|
||||
console.log('[AgentManager] Health monitoring started (30s interval)');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop health monitoring.
|
||||
*/
|
||||
function stopHealthMonitoring() {
|
||||
if (healthCheckInterval) {
|
||||
clearInterval(healthCheckInterval);
|
||||
healthCheckInterval = null;
|
||||
console.log('[AgentManager] Health monitoring stopped');
|
||||
}
|
||||
function stopHealthMonitoring() {
|
||||
if (healthCheckKickoffTimer) {
|
||||
clearTimeout(healthCheckKickoffTimer);
|
||||
healthCheckKickoffTimer = null;
|
||||
}
|
||||
if (healthCheckInterval) {
|
||||
clearInterval(healthCheckInterval);
|
||||
healthCheckInterval = null;
|
||||
console.log('[AgentManager] Health monitoring stopped');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+103
-33
@@ -8,16 +8,41 @@ const AlertCenter = (function() {
|
||||
let eventSource = null;
|
||||
let reconnectTimer = null;
|
||||
let lastConnectionWarningAt = 0;
|
||||
let rulesLoaded = false;
|
||||
let rulesPromise = null;
|
||||
let bootTimer = null;
|
||||
let feedLoaded = false;
|
||||
|
||||
function init() {
|
||||
loadRules();
|
||||
loadFeed();
|
||||
connect();
|
||||
function init(options = {}) {
|
||||
const connectFeed = options.connectFeed !== false;
|
||||
const refreshRules = options.refreshRules === true;
|
||||
|
||||
if (bootTimer) {
|
||||
clearTimeout(bootTimer);
|
||||
bootTimer = null;
|
||||
}
|
||||
|
||||
loadRules(refreshRules);
|
||||
|
||||
if (connectFeed) {
|
||||
if (!feedLoaded) {
|
||||
loadFeed();
|
||||
}
|
||||
connect();
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleInit(delayMs = 15000) {
|
||||
if (bootTimer || eventSource) return;
|
||||
bootTimer = window.setTimeout(() => {
|
||||
bootTimer = null;
|
||||
init();
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
return;
|
||||
}
|
||||
|
||||
eventSource = new EventSource('/alerts/stream');
|
||||
@@ -40,6 +65,10 @@ const AlertCenter = (function() {
|
||||
lastConnectionWarningAt = now;
|
||||
console.warn('[Alerts] SSE connection error; retrying');
|
||||
}
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
reconnectTimer = setTimeout(connect, 2500);
|
||||
};
|
||||
@@ -133,6 +162,7 @@ const AlertCenter = (function() {
|
||||
}
|
||||
|
||||
function loadFeed() {
|
||||
feedLoaded = true;
|
||||
fetch('/alerts/events?limit=30')
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
@@ -144,21 +174,37 @@ const AlertCenter = (function() {
|
||||
.catch((err) => console.error('[Alerts] Load feed failed', err));
|
||||
}
|
||||
|
||||
function loadRules() {
|
||||
return fetch('/alerts/rules?all=1')
|
||||
function loadRules(force = false) {
|
||||
if (!force && rulesLoaded) {
|
||||
renderRulesUI();
|
||||
return Promise.resolve(rules);
|
||||
}
|
||||
if (!force && rulesPromise) {
|
||||
return rulesPromise;
|
||||
}
|
||||
|
||||
rulesPromise = fetch('/alerts/rules?all=1')
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.status === 'success') {
|
||||
rules = data.rules || [];
|
||||
rulesLoaded = true;
|
||||
renderRulesUI();
|
||||
}
|
||||
return rules;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[Alerts] Load rules failed', err);
|
||||
if (typeof reportActionableError === 'function') {
|
||||
reportActionableError('Alert Rules', err, { onRetry: loadRules });
|
||||
}
|
||||
throw err;
|
||||
})
|
||||
.finally(() => {
|
||||
rulesPromise = null;
|
||||
});
|
||||
|
||||
return rulesPromise;
|
||||
}
|
||||
|
||||
function saveRule() {
|
||||
@@ -260,7 +306,7 @@ const AlertCenter = (function() {
|
||||
if (data.status !== 'success') {
|
||||
throw new Error(data.message || 'Failed to update rule');
|
||||
}
|
||||
return loadRules();
|
||||
return loadRules(true);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (typeof reportActionableError === 'function') {
|
||||
@@ -287,7 +333,7 @@ const AlertCenter = (function() {
|
||||
if (Number(getEditingRuleId()) === Number(ruleId)) {
|
||||
clearRuleForm();
|
||||
}
|
||||
return loadRules();
|
||||
return loadRules(true);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (typeof reportActionableError === 'function') {
|
||||
@@ -325,7 +371,7 @@ const AlertCenter = (function() {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled }),
|
||||
}).then(() => loadRules());
|
||||
}).then(() => loadRules(true));
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
@@ -341,7 +387,7 @@ const AlertCenter = (function() {
|
||||
enabled: true,
|
||||
notify: { webhook: true },
|
||||
}),
|
||||
}).then(() => loadRules());
|
||||
}).then(() => loadRules(true));
|
||||
}
|
||||
return null;
|
||||
});
|
||||
@@ -349,41 +395,63 @@ const AlertCenter = (function() {
|
||||
|
||||
function addBluetoothWatchlist(address, name) {
|
||||
if (!address) return;
|
||||
const upper = String(address).toUpperCase();
|
||||
const existing = rules.find((r) => r.mode === 'bluetooth' && r.match && String(r.match.address || '').toUpperCase() === upper);
|
||||
if (existing) return;
|
||||
loadRules().then(() => {
|
||||
const upper = String(address).toUpperCase();
|
||||
const existing = rules.find((r) => r.mode === 'bluetooth' && r.match && String(r.match.address || '').toUpperCase() === upper);
|
||||
if (existing) return;
|
||||
|
||||
fetch('/alerts/rules', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: name ? `Watchlist ${name}` : `Watchlist ${upper}`,
|
||||
mode: 'bluetooth',
|
||||
event_type: 'device_update',
|
||||
match: { address: upper },
|
||||
severity: 'medium',
|
||||
enabled: true,
|
||||
notify: { webhook: true },
|
||||
}),
|
||||
}).then(() => loadRules());
|
||||
return fetch('/alerts/rules', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: name ? `Watchlist ${name}` : `Watchlist ${upper}`,
|
||||
mode: 'bluetooth',
|
||||
event_type: 'device_update',
|
||||
match: { address: upper },
|
||||
severity: 'medium',
|
||||
enabled: true,
|
||||
notify: { webhook: true },
|
||||
}),
|
||||
}).then(() => loadRules(true));
|
||||
});
|
||||
}
|
||||
|
||||
function removeBluetoothWatchlist(address) {
|
||||
if (!address) return;
|
||||
const upper = String(address).toUpperCase();
|
||||
const existing = rules.find((r) => r.mode === 'bluetooth' && r.match && String(r.match.address || '').toUpperCase() === upper);
|
||||
if (!existing) return;
|
||||
loadRules().then(() => {
|
||||
const upper = String(address).toUpperCase();
|
||||
const existing = rules.find((r) => r.mode === 'bluetooth' && r.match && String(r.match.address || '').toUpperCase() === upper);
|
||||
if (!existing) return;
|
||||
|
||||
fetch(`/alerts/rules/${existing.id}`, { method: 'DELETE' })
|
||||
.then(() => loadRules());
|
||||
return fetch(`/alerts/rules/${existing.id}`, { method: 'DELETE' })
|
||||
.then(() => loadRules(true));
|
||||
});
|
||||
}
|
||||
|
||||
function isWatchlisted(address) {
|
||||
if (!address) return false;
|
||||
if (!rulesLoaded && !rulesPromise) {
|
||||
loadRules();
|
||||
}
|
||||
const upper = String(address).toUpperCase();
|
||||
return rules.some((r) => r.mode === 'bluetooth' && r.match && String(r.match.address || '').toUpperCase() === upper && r.enabled);
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
if (bootTimer) {
|
||||
clearTimeout(bootTimer);
|
||||
bootTimer = null;
|
||||
}
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return String(str)
|
||||
@@ -396,6 +464,7 @@ const AlertCenter = (function() {
|
||||
|
||||
return {
|
||||
init,
|
||||
scheduleInit,
|
||||
loadFeed,
|
||||
loadRules,
|
||||
saveRule,
|
||||
@@ -408,11 +477,12 @@ const AlertCenter = (function() {
|
||||
addBluetoothWatchlist,
|
||||
removeBluetoothWatchlist,
|
||||
isWatchlisted,
|
||||
destroy,
|
||||
};
|
||||
})();
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (typeof AlertCenter !== 'undefined') {
|
||||
AlertCenter.init();
|
||||
AlertCenter.scheduleInit();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -17,9 +17,10 @@ const CheatSheets = (function () {
|
||||
sstv: { title: 'ISS SSTV', icon: '🖼️', hardware: 'RTL-SDR + 145MHz antenna', description: 'Receives ISS SSTV images via slowrx.', whatToExpect: 'Color images during ISS SSTV events (PD180 mode).', tips: ['ISS SSTV: 145.800 MHz', 'Check ARISS for active event dates', 'ISS must be overhead — check pass times'] },
|
||||
weathersat: { title: 'Weather Satellites', icon: '🌤️', hardware: 'RTL-SDR + 137MHz turnstile/QFH antenna', description: 'Decodes NOAA APT and Meteor LRPT weather imagery via SatDump.', whatToExpect: 'Infrared/visible cloud imagery.', tips: ['NOAA 15/18/19: 137.1–137.9 MHz APT', 'Meteor M2-3: 137.9 MHz LRPT', 'Use circular polarized antenna (QFH or turnstile)'] },
|
||||
sstv_general:{ title: 'HF SSTV', icon: '📷', hardware: 'RTL-SDR + HF upconverter', description: 'Receives HF SSTV transmissions.', whatToExpect: 'Amateur radio images on 14.230 MHz (USB mode).', tips: ['14.230 MHz USB is primary HF SSTV frequency', 'Scottie 1 and Martin 1 most common', 'Best during daylight hours'] },
|
||||
gps: { title: 'GPS Receiver', icon: '🗺️', hardware: 'USB GPS receiver (NMEA)', description: 'Streams GPS position and feeds location to other modes.', whatToExpect: 'Lat/lon, altitude, speed, heading, satellite count.', tips: ['BT Locate uses GPS for trail logging', 'Set observer location for satellite prediction', 'Verify a 3D fix before relying on altitude'] },
|
||||
spaceweather:{ title: 'Space Weather', icon: '☀️', hardware: 'None (NOAA/SpaceWeatherLive data)', description: 'Monitors solar activity and geomagnetic storm indices.', whatToExpect: 'Kp index, solar flux, X-ray flare alerts, CME tracking.', tips: ['High Kp (≥5) = geomagnetic storm', 'X-class flares cause HF radio blackouts', 'Check before HF or satellite operations'] },
|
||||
tscm: { title: 'TSCM Counter-Surveillance', icon: '🔍', hardware: 'WiFi + Bluetooth adapters', description: 'Technical Surveillance Countermeasures — detects hidden devices.', whatToExpect: 'RF baseline comparison, rogue device alerts, tracker detection.', tips: ['Take baseline in a known-clean environment', 'New strong signals = potential bug', 'Correlate WiFi + Bluetooth observations'] },
|
||||
gps: { title: 'GPS Receiver', icon: '🗺️', hardware: 'USB GPS receiver (NMEA)', description: 'Streams GPS position and feeds location to other modes.', whatToExpect: 'Lat/lon, altitude, speed, heading, satellite count.', tips: ['BT Locate uses GPS for trail logging', 'Set observer location for satellite prediction', 'Verify a 3D fix before relying on altitude'] },
|
||||
spaceweather:{ title: 'Space Weather', icon: '☀️', hardware: 'None (NOAA/SpaceWeatherLive data)', description: 'Monitors solar activity and geomagnetic storm indices.', whatToExpect: 'Kp index, solar flux, X-ray flare alerts, CME tracking.', tips: ['High Kp (≥5) = geomagnetic storm', 'X-class flares cause HF radio blackouts', 'Check before HF or satellite operations'] },
|
||||
controller_monitor: { title: 'Controller Monitor', icon: '🖧', hardware: 'Optional remote agents', description: 'Aggregated controller view across connected agents and local sources.', whatToExpect: 'Combined device activity, logs, and agent health in one place.', tips: ['Use it to compare what each agent is seeing', 'Check agent status before remote starts', 'Open Manage to add or troubleshoot agents'] },
|
||||
tscm: { title: 'TSCM Counter-Surveillance', icon: '🔍', hardware: 'WiFi + Bluetooth adapters', description: 'Technical Surveillance Countermeasures — detects hidden devices.', whatToExpect: 'RF baseline comparison, rogue device alerts, tracker detection.', tips: ['Take baseline in a known-clean environment', 'New strong signals = potential bug', 'Correlate WiFi + Bluetooth observations'] },
|
||||
spystations: { title: 'Spy Stations', icon: '🕵️', hardware: 'RTL-SDR + HF antenna', description: 'Database of known number stations, military, and diplomatic HF signals.', whatToExpect: 'Scheduled broadcasts, frequency database, tune-to links.', tips: ['Numbers stations often broadcast on the hour', 'Use Spectrum Waterfall to tune directly', 'STANAG and HF mil signals are common'] },
|
||||
websdr: { title: 'WebSDR', icon: '🌐', hardware: 'None (uses remote SDR servers)', description: 'Access remote WebSDR receivers worldwide for HF shortwave listening.', whatToExpect: 'Live audio from global HF receivers, waterfall display.', tips: ['websdr.org lists available servers', 'Good for HF when local antenna is lacking', 'Use in-app player for seamless experience'] },
|
||||
subghz: { title: 'SubGHz Transceiver', icon: '📡', hardware: 'HackRF One', description: 'Transmit and receive sub-GHz RF signals for IoT and industrial protocols.', whatToExpect: 'Raw signal capture, replay, and protocol analysis.', tips: ['Only use on licensed frequencies', 'Capture mode records raw IQ for replay', 'Common: garage doors, keyfobs, 315/433/868/915 MHz'] },
|
||||
|
||||
@@ -330,6 +330,11 @@ const CommandPalette = (function() {
|
||||
}
|
||||
|
||||
function goToMode(mode) {
|
||||
if (mode === 'satellite') {
|
||||
window.open('/satellite/dashboard', '_blank', 'noopener');
|
||||
return;
|
||||
}
|
||||
|
||||
const welcome = document.getElementById('welcomePage');
|
||||
if (welcome && getComputedStyle(welcome).display !== 'none') {
|
||||
welcome.style.display = 'none';
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
// Shared observer location helper for map-based modules.
|
||||
// Default: shared location enabled unless explicitly disabled via config.
|
||||
window.ObserverLocation = (function() {
|
||||
const DEFAULT_LOCATION = (window.INTERCEPT_DEFAULT_LAT && window.INTERCEPT_DEFAULT_LON)
|
||||
? { lat: window.INTERCEPT_DEFAULT_LAT, lon: window.INTERCEPT_DEFAULT_LON }
|
||||
: { lat: 51.5074, lon: -0.1278 };
|
||||
const SHARED_KEY = 'observerLocation';
|
||||
const AIS_KEY = 'ais_observerLocation';
|
||||
const LEGACY_LAT_KEY = 'observerLat';
|
||||
@@ -21,6 +18,9 @@ window.ObserverLocation = (function() {
|
||||
return { lat: latNum, lon: lonNum };
|
||||
}
|
||||
|
||||
const DEFAULT_LOCATION = normalize(window.INTERCEPT_DEFAULT_LAT, window.INTERCEPT_DEFAULT_LON)
|
||||
|| { lat: 51.5074, lon: -0.1278 };
|
||||
|
||||
function parseLocation(raw) {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
@@ -39,7 +39,7 @@ window.ObserverLocation = (function() {
|
||||
function readLegacyLatLon() {
|
||||
const lat = localStorage.getItem(LEGACY_LAT_KEY);
|
||||
const lon = localStorage.getItem(LEGACY_LON_KEY);
|
||||
if (!lat || !lon) return null;
|
||||
if (lat === null || lon === null) return null;
|
||||
return normalize(lat, lon);
|
||||
}
|
||||
|
||||
@@ -60,11 +60,12 @@ window.ObserverLocation = (function() {
|
||||
}
|
||||
|
||||
function setShared(location, options = {}) {
|
||||
if (!location) return;
|
||||
localStorage.setItem(SHARED_KEY, JSON.stringify(location));
|
||||
const normalized = location ? normalize(location.lat, location.lon) : null;
|
||||
if (!normalized) return;
|
||||
localStorage.setItem(SHARED_KEY, JSON.stringify(normalized));
|
||||
if (options.updateLegacy !== false) {
|
||||
localStorage.setItem(LEGACY_LAT_KEY, location.lat.toString());
|
||||
localStorage.setItem(LEGACY_LON_KEY, location.lon.toString());
|
||||
localStorage.setItem(LEGACY_LAT_KEY, normalized.lat.toString());
|
||||
localStorage.setItem(LEGACY_LON_KEY, normalized.lon.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,16 +85,17 @@ window.ObserverLocation = (function() {
|
||||
}
|
||||
|
||||
function setForModule(moduleKey, location, options = {}) {
|
||||
if (!location) return;
|
||||
const normalized = location ? normalize(location.lat, location.lon) : null;
|
||||
if (!normalized) return;
|
||||
if (isSharedEnabled()) {
|
||||
setShared(location, options);
|
||||
setShared(normalized, options);
|
||||
return;
|
||||
}
|
||||
if (moduleKey) {
|
||||
localStorage.setItem(moduleKey, JSON.stringify(location));
|
||||
localStorage.setItem(moduleKey, JSON.stringify(normalized));
|
||||
} else if (options.fallbackToLatLon) {
|
||||
localStorage.setItem(LEGACY_LAT_KEY, location.lat.toString());
|
||||
localStorage.setItem(LEGACY_LON_KEY, location.lon.toString());
|
||||
localStorage.setItem(LEGACY_LAT_KEY, normalized.lat.toString());
|
||||
localStorage.setItem(LEGACY_LON_KEY, normalized.lon.toString());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -137,9 +137,3 @@ const RecordingUI = (function() {
|
||||
openReplay,
|
||||
};
|
||||
})();
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (typeof RecordingUI !== 'undefined') {
|
||||
RecordingUI.init();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -896,23 +896,26 @@ function loadObserverLocation() {
|
||||
lon = shared.lon.toString();
|
||||
}
|
||||
|
||||
const hasLat = lat !== undefined && lat !== null && lat !== '';
|
||||
const hasLon = lon !== undefined && lon !== null && lon !== '';
|
||||
|
||||
const latInput = document.getElementById('observerLatInput');
|
||||
const lonInput = document.getElementById('observerLonInput');
|
||||
const currentLatDisplay = document.getElementById('currentLatDisplay');
|
||||
const currentLonDisplay = document.getElementById('currentLonDisplay');
|
||||
|
||||
if (latInput && lat) latInput.value = lat;
|
||||
if (lonInput && lon) lonInput.value = lon;
|
||||
if (latInput && hasLat) latInput.value = lat;
|
||||
if (lonInput && hasLon) lonInput.value = lon;
|
||||
|
||||
if (currentLatDisplay) {
|
||||
currentLatDisplay.textContent = lat ? parseFloat(lat).toFixed(4) + '°' : 'Not set';
|
||||
currentLatDisplay.textContent = hasLat ? parseFloat(lat).toFixed(4) + '°' : 'Not set';
|
||||
}
|
||||
if (currentLonDisplay) {
|
||||
currentLonDisplay.textContent = lon ? parseFloat(lon).toFixed(4) + '°' : 'Not set';
|
||||
currentLonDisplay.textContent = hasLon ? parseFloat(lon).toFixed(4) + '°' : 'Not set';
|
||||
}
|
||||
|
||||
// Sync dashboard-specific location keys for backward compatibility
|
||||
if (lat !== undefined && lat !== null && lat !== '' && lon !== undefined && lon !== null && lon !== '') {
|
||||
if (hasLat && hasLon) {
|
||||
const locationObj = JSON.stringify({ lat: parseFloat(lat), lon: parseFloat(lon) });
|
||||
if (!localStorage.getItem('observerLocation')) {
|
||||
localStorage.setItem('observerLocation', locationObj);
|
||||
@@ -1011,9 +1014,9 @@ function detectLocationGPS(btn) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Save observer location to localStorage
|
||||
* Save observer location to localStorage and persist defaults to .env
|
||||
*/
|
||||
function saveObserverLocation() {
|
||||
async function saveObserverLocation() {
|
||||
const latInput = document.getElementById('observerLatInput');
|
||||
const lonInput = document.getElementById('observerLonInput');
|
||||
|
||||
@@ -1056,19 +1059,48 @@ function saveObserverLocation() {
|
||||
if (currentLatDisplay) currentLatDisplay.textContent = lat.toFixed(4) + '°';
|
||||
if (currentLonDisplay) currentLonDisplay.textContent = lon.toFixed(4) + '°';
|
||||
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('Location', 'Observer location saved');
|
||||
}
|
||||
|
||||
if (window.observerLocation) {
|
||||
window.observerLocation.lat = lat;
|
||||
window.observerLocation.lon = lon;
|
||||
}
|
||||
|
||||
let notificationMessage = 'Observer location saved';
|
||||
|
||||
try {
|
||||
const response = await fetch('/settings/observer-location', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ lat, lon }),
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok || data.status === 'error') {
|
||||
throw new Error(data.message || 'Failed to save observer location to .env');
|
||||
}
|
||||
window.INTERCEPT_DEFAULT_LAT = lat;
|
||||
window.INTERCEPT_DEFAULT_LON = lon;
|
||||
notificationMessage = 'Observer location saved to settings and .env';
|
||||
} catch (error) {
|
||||
notificationMessage = `Observer location saved for this browser, but .env update failed: ${error.message}`;
|
||||
}
|
||||
|
||||
// Refresh SSTV ISS schedule if available
|
||||
if (typeof SSTV !== 'undefined' && typeof SSTV.loadIssSchedule === 'function') {
|
||||
SSTV.loadIssSchedule();
|
||||
}
|
||||
|
||||
// Update APRS user location if function is available
|
||||
if (typeof updateAprsUserLocation === 'function') {
|
||||
updateAprsUserLocation({ latitude: lat, longitude: lon });
|
||||
}
|
||||
|
||||
// Notify all listeners (any mode can subscribe)
|
||||
window.dispatchEvent(new CustomEvent('observer-location-changed', { detail: { lat, lon } }));
|
||||
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('Location', notificationMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -1260,11 +1292,11 @@ function switchSettingsTab(tabName) {
|
||||
} else if (tabName === 'alerts') {
|
||||
loadVoiceAlertConfig();
|
||||
if (typeof AlertCenter !== 'undefined') {
|
||||
AlertCenter.loadFeed();
|
||||
AlertCenter.init();
|
||||
}
|
||||
} else if (tabName === 'recording') {
|
||||
if (typeof RecordingUI !== 'undefined') {
|
||||
RecordingUI.refresh();
|
||||
RecordingUI.init();
|
||||
}
|
||||
} else if (tabName === 'apikeys') {
|
||||
loadApiKeyStatus();
|
||||
|
||||
+41
-23
@@ -2,12 +2,13 @@
|
||||
* Updater Module - GitHub update checking and notification system
|
||||
*/
|
||||
|
||||
const Updater = {
|
||||
// State
|
||||
_checkInterval: null,
|
||||
_toastElement: null,
|
||||
_modalElement: null,
|
||||
_updateData: null,
|
||||
const Updater = {
|
||||
// State
|
||||
_checkInterval: null,
|
||||
_startupCheckTimer: null,
|
||||
_toastElement: null,
|
||||
_modalElement: null,
|
||||
_updateData: null,
|
||||
|
||||
// Configuration
|
||||
CHECK_INTERVAL_MS: 6 * 60 * 60 * 1000, // 6 hours in milliseconds
|
||||
@@ -15,18 +16,31 @@ const Updater = {
|
||||
/**
|
||||
* Initialize the updater module
|
||||
*/
|
||||
init() {
|
||||
// Create toast container if it doesn't exist
|
||||
this._ensureToastContainer();
|
||||
|
||||
// Check for updates on page load
|
||||
this.checkForUpdates();
|
||||
|
||||
// Set up periodic checks
|
||||
this._checkInterval = setInterval(() => {
|
||||
this.checkForUpdates();
|
||||
}, this.CHECK_INTERVAL_MS);
|
||||
},
|
||||
init() {
|
||||
// Create toast container if it doesn't exist
|
||||
this._ensureToastContainer();
|
||||
|
||||
const enabled = localStorage.getItem('intercept_update_check_enabled') !== 'false';
|
||||
if (!enabled) {
|
||||
this.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// Defer the first check so the active dashboard can finish loading first.
|
||||
if (!this._startupCheckTimer) {
|
||||
this._startupCheckTimer = setTimeout(() => {
|
||||
this._startupCheckTimer = null;
|
||||
this.checkForUpdates();
|
||||
}, 15000);
|
||||
}
|
||||
|
||||
// Set up periodic checks
|
||||
if (!this._checkInterval) {
|
||||
this._checkInterval = setInterval(() => {
|
||||
this.checkForUpdates();
|
||||
}, this.CHECK_INTERVAL_MS);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Ensure toast container exists in DOM
|
||||
@@ -505,11 +519,15 @@ const Updater = {
|
||||
/**
|
||||
* Clean up on page unload
|
||||
*/
|
||||
destroy() {
|
||||
if (this._checkInterval) {
|
||||
clearInterval(this._checkInterval);
|
||||
this._checkInterval = null;
|
||||
}
|
||||
destroy() {
|
||||
if (this._startupCheckTimer) {
|
||||
clearTimeout(this._startupCheckTimer);
|
||||
this._startupCheckTimer = null;
|
||||
}
|
||||
if (this._checkInterval) {
|
||||
clearInterval(this._checkInterval);
|
||||
this._checkInterval = null;
|
||||
}
|
||||
this.hideToast();
|
||||
this.hideModal();
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ const VoiceAlerts = (function () {
|
||||
let _queue = [];
|
||||
let _speaking = false;
|
||||
let _sources = {};
|
||||
let _streamStartTimer = null;
|
||||
const STORAGE_KEY = 'intercept-voice-muted';
|
||||
const CONFIG_KEY = 'intercept-voice-config';
|
||||
const RATE_MIN = 0.5;
|
||||
@@ -132,7 +133,12 @@ const VoiceAlerts = (function () {
|
||||
}
|
||||
|
||||
function _startStreams() {
|
||||
if (_streamStartTimer) {
|
||||
clearTimeout(_streamStartTimer);
|
||||
_streamStartTimer = null;
|
||||
}
|
||||
if (!_enabled) return;
|
||||
if (Object.keys(_sources).length > 0) return;
|
||||
|
||||
// Pager stream
|
||||
if (_config.streams.pager) {
|
||||
@@ -173,17 +179,32 @@ const VoiceAlerts = (function () {
|
||||
}
|
||||
|
||||
function _stopStreams() {
|
||||
if (_streamStartTimer) {
|
||||
clearTimeout(_streamStartTimer);
|
||||
_streamStartTimer = null;
|
||||
}
|
||||
Object.values(_sources).forEach(es => { try { es.close(); } catch (_) {} });
|
||||
_sources = {};
|
||||
}
|
||||
|
||||
function init() {
|
||||
function init(options) {
|
||||
const opts = options || {};
|
||||
_loadConfig();
|
||||
if (_isSpeechSupported()) {
|
||||
// Prime voices list early so user-triggered test calls are less likely to be silent.
|
||||
speechSynthesis.getVoices();
|
||||
}
|
||||
_startStreams();
|
||||
if (opts.startStreams !== false) {
|
||||
_startStreams();
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleStreamStart(delayMs) {
|
||||
if (_streamStartTimer || Object.keys(_sources).length > 0 || !_enabled) return;
|
||||
_streamStartTimer = window.setTimeout(() => {
|
||||
_streamStartTimer = null;
|
||||
_startStreams();
|
||||
}, Number(delayMs) > 0 ? Number(delayMs) : 20000);
|
||||
}
|
||||
|
||||
function setEnabled(val) {
|
||||
@@ -255,7 +276,7 @@ const VoiceAlerts = (function () {
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
return { init, speak, toggleMute, setEnabled, getConfig, setConfig, getAvailableVoices, testVoice, PRIORITY };
|
||||
return { init, scheduleStreamStart, speak, toggleMute, setEnabled, getConfig, setConfig, getAvailableVoices, testVoice, PRIORITY };
|
||||
})();
|
||||
|
||||
window.VoiceAlerts = VoiceAlerts;
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Ground Station Live Waterfall — Phase 5
|
||||
*
|
||||
* Subscribes to /ws/satellite_waterfall, receives binary frames in the same
|
||||
* wire format as the main listening-post waterfall, and renders them onto the
|
||||
* <canvas id="gs-waterfall"> element in satellite_dashboard.html.
|
||||
*
|
||||
* Wire frame format (matches utils/waterfall_fft.build_binary_frame):
|
||||
* [uint8 msg_type=0x01]
|
||||
* [float32 start_freq_mhz]
|
||||
* [float32 end_freq_mhz]
|
||||
* [uint16 bin_count]
|
||||
* [uint8[] bins] — 0=noise floor, 255=strongest signal
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const CANVAS_ID = 'gs-waterfall';
|
||||
const ROW_HEIGHT = 2; // px per waterfall row
|
||||
const SCROLL_STEP = ROW_HEIGHT;
|
||||
|
||||
let _ws = null;
|
||||
let _canvas = null;
|
||||
let _ctx = null;
|
||||
let _offscreen = null; // offscreen ImageData buffer
|
||||
let _reconnectTimer = null;
|
||||
let _centerMhz = 0;
|
||||
let _spanMhz = 0;
|
||||
let _connected = false;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Colour palette — 256-entry RGB array (matches listening-post waterfall)
|
||||
// -----------------------------------------------------------------------
|
||||
const _palette = _buildPalette();
|
||||
|
||||
function _buildPalette() {
|
||||
const p = new Uint8Array(256 * 3);
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let r, g, b;
|
||||
if (i < 64) {
|
||||
// black → dark blue
|
||||
r = 0; g = 0; b = Math.round(i * 2);
|
||||
} else if (i < 128) {
|
||||
// dark blue → cyan
|
||||
const t = (i - 64) / 64;
|
||||
r = 0; g = Math.round(t * 200); b = Math.round(128 + t * 127);
|
||||
} else if (i < 192) {
|
||||
// cyan → yellow
|
||||
const t = (i - 128) / 64;
|
||||
r = Math.round(t * 255); g = 200; b = Math.round(255 - t * 255);
|
||||
} else {
|
||||
// yellow → white
|
||||
const t = (i - 192) / 64;
|
||||
r = 255; g = 200; b = Math.round(t * 255);
|
||||
}
|
||||
p[i * 3] = r; p[i * 3 + 1] = g; p[i * 3 + 2] = b;
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Public API
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
window.GroundStationWaterfall = {
|
||||
init,
|
||||
connect,
|
||||
disconnect,
|
||||
isConnected: () => _connected,
|
||||
setCenterFreq: (mhz, span) => { _centerMhz = mhz; _spanMhz = span; },
|
||||
};
|
||||
|
||||
function init() {
|
||||
_canvas = document.getElementById(CANVAS_ID);
|
||||
if (!_canvas) return;
|
||||
_ctx = _canvas.getContext('2d');
|
||||
_resizeCanvas();
|
||||
window.addEventListener('resize', _resizeCanvas);
|
||||
_drawPlaceholder();
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (_ws && (_ws.readyState === WebSocket.CONNECTING || _ws.readyState === WebSocket.OPEN)) {
|
||||
return;
|
||||
}
|
||||
if (_reconnectTimer) {
|
||||
clearTimeout(_reconnectTimer);
|
||||
_reconnectTimer = null;
|
||||
}
|
||||
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const url = `${proto}//${location.host}/ws/satellite_waterfall`;
|
||||
|
||||
try {
|
||||
_ws = new WebSocket(url);
|
||||
_ws.binaryType = 'arraybuffer';
|
||||
|
||||
_ws.onopen = () => {
|
||||
_connected = true;
|
||||
_updateStatus('LIVE');
|
||||
console.log('[GS Waterfall] WebSocket connected');
|
||||
};
|
||||
|
||||
_ws.onmessage = (evt) => {
|
||||
if (evt.data instanceof ArrayBuffer) {
|
||||
_handleFrame(evt.data);
|
||||
}
|
||||
};
|
||||
|
||||
_ws.onclose = () => {
|
||||
_connected = false;
|
||||
_updateStatus('DISCONNECTED');
|
||||
_scheduleReconnect();
|
||||
};
|
||||
|
||||
_ws.onerror = (e) => {
|
||||
console.warn('[GS Waterfall] WebSocket error', e);
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('[GS Waterfall] Failed to create WebSocket', e);
|
||||
_scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
if (_reconnectTimer) { clearTimeout(_reconnectTimer); _reconnectTimer = null; }
|
||||
if (_ws) { _ws.close(); _ws = null; }
|
||||
_connected = false;
|
||||
_updateStatus('STOPPED');
|
||||
_drawPlaceholder();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Frame rendering
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
function _handleFrame(buf) {
|
||||
const view = new DataView(buf);
|
||||
if (buf.byteLength < 11) return;
|
||||
|
||||
const msgType = view.getUint8(0);
|
||||
if (msgType !== 0x01) return;
|
||||
|
||||
// const startFreq = view.getFloat32(1, true); // little-endian
|
||||
// const endFreq = view.getFloat32(5, true);
|
||||
const binCount = view.getUint16(9, true);
|
||||
if (buf.byteLength < 11 + binCount) return;
|
||||
|
||||
const bins = new Uint8Array(buf, 11, binCount);
|
||||
|
||||
if (!_canvas || !_ctx) return;
|
||||
|
||||
const W = _canvas.width;
|
||||
const H = _canvas.height;
|
||||
|
||||
// Scroll existing image up by ROW_HEIGHT pixels
|
||||
if (!_offscreen || _offscreen.width !== W || _offscreen.height !== H) {
|
||||
_offscreen = _ctx.getImageData(0, 0, W, H);
|
||||
} else {
|
||||
_offscreen = _ctx.getImageData(0, 0, W, H);
|
||||
}
|
||||
|
||||
// Shift rows up by ROW_HEIGHT
|
||||
const data = _offscreen.data;
|
||||
const rowBytes = W * 4;
|
||||
data.copyWithin(0, SCROLL_STEP * rowBytes);
|
||||
|
||||
// Write new row(s) at the bottom
|
||||
const bottom = H - ROW_HEIGHT;
|
||||
for (let row = 0; row < ROW_HEIGHT; row++) {
|
||||
const rowStart = (bottom + row) * rowBytes;
|
||||
for (let x = 0; x < W; x++) {
|
||||
const binIdx = Math.floor((x / W) * binCount);
|
||||
const val = bins[Math.min(binIdx, binCount - 1)];
|
||||
const pi = val * 3;
|
||||
const di = rowStart + x * 4;
|
||||
data[di] = _palette[pi];
|
||||
data[di + 1] = _palette[pi + 1];
|
||||
data[di + 2] = _palette[pi + 2];
|
||||
data[di + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
_ctx.putImageData(_offscreen, 0, 0);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
function _resizeCanvas() {
|
||||
if (!_canvas) return;
|
||||
const container = _canvas.parentElement;
|
||||
if (container) {
|
||||
_canvas.width = container.clientWidth || 400;
|
||||
_canvas.height = container.clientHeight || 200;
|
||||
}
|
||||
_offscreen = null;
|
||||
_drawPlaceholder();
|
||||
}
|
||||
|
||||
function _drawPlaceholder() {
|
||||
if (!_ctx || !_canvas) return;
|
||||
_ctx.fillStyle = '#000a14';
|
||||
_ctx.fillRect(0, 0, _canvas.width, _canvas.height);
|
||||
_ctx.fillStyle = 'rgba(0,212,255,0.3)';
|
||||
_ctx.font = '12px monospace';
|
||||
_ctx.textAlign = 'center';
|
||||
_ctx.fillText('AWAITING SATELLITE PASS', _canvas.width / 2, _canvas.height / 2);
|
||||
_ctx.textAlign = 'left';
|
||||
}
|
||||
|
||||
function _updateStatus(text) {
|
||||
const el = document.getElementById('gsWaterfallStatus');
|
||||
if (el) el.textContent = text;
|
||||
}
|
||||
|
||||
function _scheduleReconnect(delayMs = 5000) {
|
||||
if (_reconnectTimer) return;
|
||||
_reconnectTimer = setTimeout(() => {
|
||||
_reconnectTimer = null;
|
||||
connect();
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
// Auto-init when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
@@ -16,8 +16,9 @@ const Meshtastic = (function() {
|
||||
|
||||
// Map state
|
||||
let meshMap = null;
|
||||
let meshMarkers = {}; // nodeId -> marker
|
||||
let localNodeId = null;
|
||||
let meshMarkers = {}; // nodeId -> marker
|
||||
let localNodeId = null;
|
||||
let clickDelegationAttached = false;
|
||||
|
||||
/**
|
||||
* Initialize the Meshtastic mode
|
||||
@@ -32,11 +33,14 @@ const Meshtastic = (function() {
|
||||
/**
|
||||
* Setup event delegation for dynamically created elements
|
||||
*/
|
||||
function setupEventDelegation() {
|
||||
// Handle button clicks in Leaflet popups and elsewhere
|
||||
document.addEventListener('click', function(e) {
|
||||
const tracerouteBtn = e.target.closest('.mesh-traceroute-btn');
|
||||
if (tracerouteBtn) {
|
||||
function setupEventDelegation() {
|
||||
if (clickDelegationAttached) return;
|
||||
clickDelegationAttached = true;
|
||||
|
||||
// Handle button clicks in Leaflet popups and elsewhere
|
||||
document.addEventListener('click', function(e) {
|
||||
const tracerouteBtn = e.target.closest('.mesh-traceroute-btn');
|
||||
if (tracerouteBtn) {
|
||||
const nodeId = tracerouteBtn.dataset.nodeId;
|
||||
if (nodeId) {
|
||||
sendTraceroute(nodeId);
|
||||
|
||||
@@ -16,8 +16,13 @@ const SpaceWeather = (function () {
|
||||
let _xrayChart = null;
|
||||
|
||||
// Current image selections
|
||||
let _solarImageKey = 'sdo_193';
|
||||
let _drapFreq = 'drap_global';
|
||||
let _solarImageKey = 'sdo_193';
|
||||
let _drapFreq = 'drap_global';
|
||||
const SOLAR_IMAGE_FALLBACKS = {
|
||||
sdo_193: 'https://sdo.gsfc.nasa.gov/assets/img/latest/latest_512_0193.jpg',
|
||||
sdo_304: 'https://sdo.gsfc.nasa.gov/assets/img/latest/latest_512_0304.jpg',
|
||||
sdo_magnetogram: 'https://sdo.gsfc.nasa.gov/assets/img/latest/latest_512_HMIBC.jpg',
|
||||
};
|
||||
|
||||
/** Stable cache-bust key that rotates every 5 minutes (matches backend max-age). */
|
||||
function _cacheBust() {
|
||||
@@ -48,33 +53,35 @@ const SpaceWeather = (function () {
|
||||
_fetchData();
|
||||
}
|
||||
|
||||
function selectSolarImage(key) {
|
||||
_solarImageKey = key;
|
||||
_updateSolarImageTabs();
|
||||
const frame = document.getElementById('swSolarImageFrame');
|
||||
if (frame) {
|
||||
frame.innerHTML = '<div class="sw-loading">Loading</div>';
|
||||
const img = new Image();
|
||||
img.onload = function () { frame.innerHTML = ''; frame.appendChild(img); };
|
||||
img.onerror = function () { frame.innerHTML = '<div class="sw-empty">Failed to load image</div>'; };
|
||||
img.src = '/space-weather/image/' + key + '?' + _cacheBust();
|
||||
img.alt = key;
|
||||
}
|
||||
}
|
||||
function selectSolarImage(key) {
|
||||
_solarImageKey = key;
|
||||
_updateSolarImageTabs();
|
||||
const frame = document.getElementById('swSolarImageFrame');
|
||||
if (frame) {
|
||||
frame.innerHTML = '<div class="sw-loading">Loading</div>';
|
||||
_loadImageWithFallback(
|
||||
frame,
|
||||
['/space-weather/image/' + key + '?' + _cacheBust(), _directImageUrlForKey(key)],
|
||||
key,
|
||||
'<div class="sw-empty">NASA SDO image is temporarily unavailable</div>'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function selectDrapFreq(key) {
|
||||
_drapFreq = key;
|
||||
_updateDrapTabs();
|
||||
const frame = document.getElementById('swDrapImageFrame');
|
||||
if (frame) {
|
||||
frame.innerHTML = '<div class="sw-loading">Loading</div>';
|
||||
const img = new Image();
|
||||
img.onload = function () { frame.innerHTML = ''; frame.appendChild(img); };
|
||||
img.onerror = function () { frame.innerHTML = '<div class="sw-empty">Failed to load image</div>'; };
|
||||
img.src = '/space-weather/image/' + key + '?' + _cacheBust();
|
||||
img.alt = key;
|
||||
}
|
||||
}
|
||||
function selectDrapFreq(key) {
|
||||
_drapFreq = key;
|
||||
_updateDrapTabs();
|
||||
const frame = document.getElementById('swDrapImageFrame');
|
||||
if (frame) {
|
||||
frame.innerHTML = '<div class="sw-loading">Loading</div>';
|
||||
_loadImageWithFallback(
|
||||
frame,
|
||||
['/space-weather/image/' + key + '?' + _cacheBust()],
|
||||
key,
|
||||
'<div class="sw-empty">Failed to load image</div>'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAutoRefresh() {
|
||||
const cb = document.getElementById('swAutoRefresh');
|
||||
@@ -94,9 +101,41 @@ const SpaceWeather = (function () {
|
||||
}
|
||||
}
|
||||
|
||||
function _stopAutoRefresh() {
|
||||
if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; }
|
||||
}
|
||||
function _stopAutoRefresh() {
|
||||
if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; }
|
||||
}
|
||||
|
||||
function _directImageUrlForKey(key) {
|
||||
const base = SOLAR_IMAGE_FALLBACKS[key];
|
||||
if (!base) return null;
|
||||
return base + '?' + _cacheBust();
|
||||
}
|
||||
|
||||
function _loadImageWithFallback(frame, urls, alt, failureHtml) {
|
||||
const candidates = (urls || []).filter(Boolean);
|
||||
if (!frame || candidates.length === 0) {
|
||||
if (frame) frame.innerHTML = failureHtml;
|
||||
return;
|
||||
}
|
||||
|
||||
let index = 0;
|
||||
const img = new Image();
|
||||
img.alt = alt;
|
||||
img.referrerPolicy = 'no-referrer';
|
||||
img.onload = function () {
|
||||
frame.innerHTML = '';
|
||||
frame.appendChild(img);
|
||||
};
|
||||
img.onerror = function () {
|
||||
index += 1;
|
||||
if (index < candidates.length) {
|
||||
img.src = candidates[index];
|
||||
return;
|
||||
}
|
||||
frame.innerHTML = failureHtml;
|
||||
};
|
||||
img.src = candidates[index];
|
||||
}
|
||||
|
||||
function _fetchData() {
|
||||
fetch('/space-weather/data')
|
||||
|
||||
+11
-8
@@ -14,10 +14,11 @@ const SSTV = (function() {
|
||||
let issMarker = null;
|
||||
let issTrackLine = null;
|
||||
let issPosition = null;
|
||||
let issUpdateInterval = null;
|
||||
let countdownInterval = null;
|
||||
let nextPassData = null;
|
||||
let pendingMapInvalidate = false;
|
||||
let issUpdateInterval = null;
|
||||
let countdownInterval = null;
|
||||
let nextPassData = null;
|
||||
let pendingMapInvalidate = false;
|
||||
let locationListenersAttached = false;
|
||||
|
||||
// ISS frequency
|
||||
const ISS_FREQ = 145.800;
|
||||
@@ -92,10 +93,12 @@ const SSTV = (function() {
|
||||
if (latInput && storedLat) latInput.value = storedLat;
|
||||
if (lonInput && storedLon) lonInput.value = storedLon;
|
||||
|
||||
// Add change handlers to save and refresh
|
||||
if (latInput) latInput.addEventListener('change', saveLocationFromInputs);
|
||||
if (lonInput) lonInput.addEventListener('change', saveLocationFromInputs);
|
||||
}
|
||||
if (!locationListenersAttached) {
|
||||
if (latInput) latInput.addEventListener('change', saveLocationFromInputs);
|
||||
if (lonInput) lonInput.addEventListener('change', saveLocationFromInputs);
|
||||
locationListenersAttached = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save location from input fields
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
/**
|
||||
* Weather Satellite Mode
|
||||
* NOAA APT and Meteor LRPT decoder interface with auto-scheduler,
|
||||
* Meteor LRPT decoder interface with auto-scheduler,
|
||||
* polar plot, styled real-world map, countdown, and timeline.
|
||||
*/
|
||||
|
||||
const WeatherSat = (function() {
|
||||
const METEOR_NORAD_IDS = {
|
||||
'METEOR-M2-3': 57166,
|
||||
'METEOR-M2-4': 59051,
|
||||
};
|
||||
|
||||
// State
|
||||
let isRunning = false;
|
||||
let eventSource = null;
|
||||
@@ -27,11 +32,28 @@ const WeatherSat = (function() {
|
||||
let consoleAutoHideTimer = null;
|
||||
let currentModalFilename = null;
|
||||
let locationListenersAttached = false;
|
||||
let initialized = false;
|
||||
let imageRefreshInterval = null;
|
||||
let lastDecodeJobSignature = null;
|
||||
let lastDecodeSatellite = null;
|
||||
|
||||
/**
|
||||
* Initialize the Weather Satellite mode
|
||||
*/
|
||||
function init() {
|
||||
if (initialized) {
|
||||
checkStatus();
|
||||
loadImages();
|
||||
loadLocationInputs();
|
||||
loadPasses();
|
||||
startCountdownTimer();
|
||||
checkSchedulerStatus();
|
||||
initGroundMap();
|
||||
loadLatestDecodeJob();
|
||||
return;
|
||||
}
|
||||
initialized = true;
|
||||
|
||||
checkStatus();
|
||||
loadImages();
|
||||
loadLocationInputs();
|
||||
@@ -39,14 +61,8 @@ const WeatherSat = (function() {
|
||||
startCountdownTimer();
|
||||
checkSchedulerStatus();
|
||||
initGroundMap();
|
||||
|
||||
// Re-filter passes when satellite selection changes
|
||||
const satSelect = document.getElementById('weatherSatSelect');
|
||||
if (satSelect) {
|
||||
satSelect.addEventListener('change', () => {
|
||||
applyPassFilter();
|
||||
});
|
||||
}
|
||||
ensureImageRefresh();
|
||||
loadLatestDecodeJob();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,7 +148,14 @@ const WeatherSat = (function() {
|
||||
if (latInput) latInput.addEventListener('change', saveLocationFromInputs);
|
||||
if (lonInput) lonInput.addEventListener('change', saveLocationFromInputs);
|
||||
const satSelect = document.getElementById('weatherSatSelect');
|
||||
if (satSelect) satSelect.addEventListener('change', applyPassFilter);
|
||||
if (satSelect) {
|
||||
satSelect.addEventListener('change', () => {
|
||||
resetDecodeJobDisplay();
|
||||
applyPassFilter();
|
||||
loadImages();
|
||||
loadLatestDecodeJob();
|
||||
});
|
||||
}
|
||||
locationListenersAttached = true;
|
||||
}
|
||||
}
|
||||
@@ -302,6 +325,19 @@ const WeatherSat = (function() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-select a satellite without starting capture.
|
||||
* Used by the satellite dashboard handoff so the user can review
|
||||
* settings before hitting Start.
|
||||
*/
|
||||
function preSelect(satellite) {
|
||||
const satSelect = document.getElementById('weatherSatSelect');
|
||||
if (satSelect) {
|
||||
satSelect.value = satellite;
|
||||
satSelect.dispatchEvent(new Event('change'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start capture for a specific pass
|
||||
*/
|
||||
@@ -309,6 +345,7 @@ const WeatherSat = (function() {
|
||||
const satSelect = document.getElementById('weatherSatSelect');
|
||||
if (satSelect) {
|
||||
satSelect.value = satellite;
|
||||
satSelect.dispatchEvent(new Event('change'));
|
||||
}
|
||||
start();
|
||||
}
|
||||
@@ -521,6 +558,7 @@ const WeatherSat = (function() {
|
||||
updatePhaseIndicator('error');
|
||||
if (consoleAutoHideTimer) clearTimeout(consoleAutoHideTimer);
|
||||
consoleAutoHideTimer = setTimeout(() => showConsole(false), 15000);
|
||||
loadImages();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1534,7 +1572,12 @@ const WeatherSat = (function() {
|
||||
*/
|
||||
async function loadImages() {
|
||||
try {
|
||||
const response = await fetch('/weather-sat/images');
|
||||
const satSelect = document.getElementById('weatherSatSelect');
|
||||
const selectedSatellite = satSelect?.value || '';
|
||||
const url = selectedSatellite
|
||||
? `/weather-sat/images?satellite=${encodeURIComponent(selectedSatellite)}`
|
||||
: '/weather-sat/images';
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'ok') {
|
||||
@@ -1599,6 +1642,14 @@ const WeatherSat = (function() {
|
||||
html += `<div class="wxsat-date-header">${escapeHtml(date)}</div>`;
|
||||
html += imgs.map(img => {
|
||||
const fn = escapeHtml(img.filename || img.url.split('/').pop());
|
||||
const deleteButton = img.deletable === false ? '' : `
|
||||
<div class="wxsat-image-actions">
|
||||
<button onclick="event.stopPropagation(); WeatherSat.deleteImage('${fn}')" title="Delete image">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>`;
|
||||
return `
|
||||
<div class="wxsat-image-card">
|
||||
<div class="wxsat-image-clickable" onclick="WeatherSat.showImage('${escapeHtml(img.url)}', '${escapeHtml(img.satellite)}', '${escapeHtml(img.product)}', '${fn}')">
|
||||
@@ -1609,13 +1660,7 @@ const WeatherSat = (function() {
|
||||
<div class="wxsat-image-timestamp">${formatTimestamp(img.timestamp)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wxsat-image-actions">
|
||||
<button onclick="event.stopPropagation(); WeatherSat.deleteImage('${fn}')" title="Delete image">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
${deleteButton}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
@@ -1707,9 +1752,14 @@ const WeatherSat = (function() {
|
||||
*/
|
||||
async function deleteAllImages() {
|
||||
if (images.length === 0) return;
|
||||
const deletableCount = images.filter(img => img.deletable !== false).length;
|
||||
if (deletableCount === 0) {
|
||||
showNotification('Weather Sat', 'Only shared ground-station imagery is available here');
|
||||
return;
|
||||
}
|
||||
const confirmed = await AppFeedback.confirmAction({
|
||||
title: 'Delete All Images',
|
||||
message: `Delete all ${images.length} decoded images? This cannot be undone.`,
|
||||
message: `Delete all ${deletableCount} local decoded images? Shared ground-station outputs will be kept.`,
|
||||
confirmLabel: 'Delete All',
|
||||
confirmClass: 'btn-danger'
|
||||
});
|
||||
@@ -1720,8 +1770,8 @@ const WeatherSat = (function() {
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'ok') {
|
||||
images = [];
|
||||
updateImageCount(0);
|
||||
images = images.filter(img => img.deletable === false);
|
||||
updateImageCount(images.length);
|
||||
renderGallery();
|
||||
showNotification('Weather Sat', `Deleted ${data.deleted} images`);
|
||||
} else {
|
||||
@@ -1745,6 +1795,145 @@ const WeatherSat = (function() {
|
||||
}
|
||||
}
|
||||
|
||||
function ensureImageRefresh() {
|
||||
if (imageRefreshInterval) return;
|
||||
imageRefreshInterval = setInterval(() => {
|
||||
const mode = document.getElementById('weatherSatMode');
|
||||
if (!mode || !mode.classList.contains('active')) return;
|
||||
loadImages();
|
||||
loadLatestDecodeJob();
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
function getSelectedMeteorNorad() {
|
||||
const satSelect = document.getElementById('weatherSatSelect');
|
||||
const satellite = satSelect?.value || '';
|
||||
return METEOR_NORAD_IDS[satellite] || null;
|
||||
}
|
||||
|
||||
async function loadLatestDecodeJob() {
|
||||
const norad = getSelectedMeteorNorad();
|
||||
if (!norad) return;
|
||||
const satSelect = document.getElementById('weatherSatSelect');
|
||||
const satellite = satSelect?.value || null;
|
||||
|
||||
if (satellite !== lastDecodeSatellite) {
|
||||
lastDecodeSatellite = satellite;
|
||||
lastDecodeJobSignature = null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/ground_station/decode-jobs?norad_id=${encodeURIComponent(norad)}&backend=meteor_lrpt&limit=1`);
|
||||
const jobs = await response.json();
|
||||
if (!Array.isArray(jobs) || !jobs.length) {
|
||||
resetDecodeJobDisplay();
|
||||
return;
|
||||
}
|
||||
|
||||
const job = jobs[0];
|
||||
const details = job.details || {};
|
||||
const signature = `${job.id}:${job.status}:${job.error_message || ''}`;
|
||||
const captureStatus = document.getElementById('wxsatCaptureStatus');
|
||||
const captureMsg = document.getElementById('wxsatCaptureMsg');
|
||||
const captureElapsed = document.getElementById('wxsatCaptureElapsed');
|
||||
const summary = formatDecodeJobSummary(job, details);
|
||||
|
||||
if (!isRunning) {
|
||||
if (job.status === 'queued') {
|
||||
updateStatusUI('idle', 'Decode queued');
|
||||
if (captureMsg) captureMsg.textContent = summary;
|
||||
if (captureElapsed) captureElapsed.textContent = '--';
|
||||
if (captureStatus) captureStatus.classList.add('active');
|
||||
} else if (job.status === 'decoding') {
|
||||
updateStatusUI('decoding', 'Ground-station decode running');
|
||||
if (captureMsg) captureMsg.textContent = summary;
|
||||
if (captureStatus) captureStatus.classList.add('active');
|
||||
} else if (job.status === 'failed') {
|
||||
updateStatusUI('idle', 'Last decode failed');
|
||||
if (captureMsg) captureMsg.textContent = summary;
|
||||
if (captureElapsed) captureElapsed.textContent = formatDecodeJobMeta(details);
|
||||
if (captureStatus) captureStatus.classList.remove('active');
|
||||
if (signature !== lastDecodeJobSignature) {
|
||||
showConsole(true);
|
||||
addConsoleEntry(summary, 'error');
|
||||
const context = formatDecodeJobContext(details);
|
||||
if (context) addConsoleEntry(context, 'warning');
|
||||
}
|
||||
} else if (job.status === 'complete') {
|
||||
const count = details.output_count;
|
||||
updateStatusUI('idle', count ? `Last decode: ${count} image${count === 1 ? '' : 's'}` : 'Last decode complete');
|
||||
if (captureMsg) captureMsg.textContent = summary;
|
||||
if (captureElapsed) captureElapsed.textContent = formatDecodeJobMeta(details);
|
||||
if (captureStatus) captureStatus.classList.remove('active');
|
||||
if (signature !== lastDecodeJobSignature) {
|
||||
addConsoleEntry(
|
||||
count ? `Ground-station decode complete: ${count} image${count === 1 ? '' : 's'} produced`
|
||||
: 'Ground-station decode complete',
|
||||
'signal'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lastDecodeJobSignature = signature;
|
||||
} catch (err) {
|
||||
console.error('Failed to load latest decode job:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function resetDecodeJobDisplay() {
|
||||
if (isRunning) return;
|
||||
const captureStatus = document.getElementById('wxsatCaptureStatus');
|
||||
const captureMsg = document.getElementById('wxsatCaptureMsg');
|
||||
const captureElapsed = document.getElementById('wxsatCaptureElapsed');
|
||||
if (captureStatus) captureStatus.classList.remove('active');
|
||||
if (captureMsg) captureMsg.textContent = '--';
|
||||
if (captureElapsed) captureElapsed.textContent = '--';
|
||||
updateStatusUI('idle', 'Idle');
|
||||
}
|
||||
|
||||
function formatDecodeJobSummary(job, details) {
|
||||
if (job.status === 'queued') return 'Ground-station decode queued';
|
||||
if (job.status === 'decoding') return details.message || 'Ground-station decode in progress';
|
||||
if (job.status === 'complete') {
|
||||
const count = details.output_count;
|
||||
return count ? `Ground-station decode complete: ${count} image${count === 1 ? '' : 's'} produced`
|
||||
: 'Ground-station decode complete';
|
||||
}
|
||||
if (job.status === 'failed') {
|
||||
const reasonLabels = {
|
||||
sample_rate_too_low: 'Sample rate too low for Meteor LRPT',
|
||||
invalid_sample_rate: 'Sample rate rejected by decoder',
|
||||
recording_too_small: 'Recording too small for useful decode',
|
||||
satdump_failed: 'SatDump decode failed',
|
||||
permission_error: 'Decoder could not access recording/output path',
|
||||
input_missing: 'Input recording was not accessible',
|
||||
missing_recording: 'Recording was missing when decode started',
|
||||
no_imagery_produced: 'Decode produced no imagery',
|
||||
};
|
||||
return job.error_message || reasonLabels[details.reason] || details.message || 'Last decode failed';
|
||||
}
|
||||
return details.message || 'Decode status unavailable';
|
||||
}
|
||||
|
||||
function formatDecodeJobMeta(details) {
|
||||
const parts = [];
|
||||
if (details.sample_rate) parts.push(`${Number(details.sample_rate).toLocaleString()} Hz`);
|
||||
if (details.file_size_human) parts.push(details.file_size_human);
|
||||
return parts.join(' / ') || '--';
|
||||
}
|
||||
|
||||
function formatDecodeJobContext(details) {
|
||||
const parts = [];
|
||||
if (details.reason) parts.push(`Reason: ${String(details.reason).replace(/_/g, ' ')}`);
|
||||
if (details.sample_rate) parts.push(`Sample rate ${Number(details.sample_rate).toLocaleString()} Hz`);
|
||||
if (details.file_size_human) parts.push(`Recording ${details.file_size_human}`);
|
||||
if (details.last_returncode !== undefined && details.last_returncode !== null) {
|
||||
parts.push(`Exit code ${details.last_returncode}`);
|
||||
}
|
||||
return parts.join(' | ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML
|
||||
*/
|
||||
@@ -1910,6 +2099,7 @@ const WeatherSat = (function() {
|
||||
destroy,
|
||||
start,
|
||||
stop,
|
||||
preSelect,
|
||||
startPass,
|
||||
selectPass,
|
||||
testDecode,
|
||||
|
||||
+8
-117
@@ -1,122 +1,13 @@
|
||||
/* INTERCEPT Service Worker — cache-first static, network-only for API/SSE/WS */
|
||||
const CACHE_NAME = 'intercept-v3';
|
||||
|
||||
const NETWORK_ONLY_PREFIXES = [
|
||||
'/stream', '/ws/', '/api/', '/gps/', '/wifi/', '/bluetooth/',
|
||||
'/adsb/', '/ais/', '/acars/', '/aprs/', '/tscm/', '/satellite/',
|
||||
'/meshtastic/', '/bt_locate/', '/receiver/', '/sensor/', '/pager/',
|
||||
'/sstv/', '/weather-sat/', '/subghz/', '/rtlamr/', '/dsc/', '/vdl2/',
|
||||
'/spy/', '/space-weather/', '/websdr/', '/analytics/', '/correlation/',
|
||||
'/recordings/', '/controller/', '/ops/',
|
||||
];
|
||||
|
||||
const STATIC_PREFIXES = [
|
||||
'/static/css/',
|
||||
'/static/js/',
|
||||
'/static/icons/',
|
||||
'/static/fonts/',
|
||||
];
|
||||
|
||||
const CACHE_EXACT = ['/manifest.json'];
|
||||
|
||||
function isHttpRequest(req) {
|
||||
const url = new URL(req.url);
|
||||
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||
}
|
||||
|
||||
function isNetworkOnly(req) {
|
||||
if (req.method !== 'GET') return true;
|
||||
const accept = req.headers.get('Accept') || '';
|
||||
if (accept.includes('text/event-stream')) return true;
|
||||
const url = new URL(req.url);
|
||||
return NETWORK_ONLY_PREFIXES.some(p => url.pathname.startsWith(p));
|
||||
}
|
||||
|
||||
function isStaticAsset(req) {
|
||||
const url = new URL(req.url);
|
||||
if (CACHE_EXACT.includes(url.pathname)) return true;
|
||||
return STATIC_PREFIXES.some(p => url.pathname.startsWith(p));
|
||||
}
|
||||
|
||||
function fallbackResponse(req, status = 503) {
|
||||
const accept = req.headers.get('Accept') || '';
|
||||
if (accept.includes('application/json')) {
|
||||
return new Response(
|
||||
JSON.stringify({ status: 'error', message: 'Network unavailable' }),
|
||||
{
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (accept.includes('text/event-stream')) {
|
||||
return new Response('', {
|
||||
status,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
});
|
||||
}
|
||||
|
||||
return new Response('Offline', {
|
||||
status,
|
||||
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
||||
});
|
||||
}
|
||||
|
||||
self.addEventListener('install', (e) => {
|
||||
/* INTERCEPT Service Worker disabled to avoid stale cached static assets. */
|
||||
self.addEventListener('install', () => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (e) => {
|
||||
e.waitUntil(
|
||||
caches.keys().then(keys =>
|
||||
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
|
||||
).then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (e) => {
|
||||
const req = e.request;
|
||||
|
||||
// Ignore non-HTTP(S) requests so extensions/browser-internal URLs are untouched.
|
||||
if (!isHttpRequest(req)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Always bypass service worker for non-GET and streaming routes
|
||||
if (isNetworkOnly(req)) {
|
||||
e.respondWith(
|
||||
fetch(req).catch(() => fallbackResponse(req, 503))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache-first for static assets
|
||||
if (isStaticAsset(req)) {
|
||||
e.respondWith(
|
||||
caches.open(CACHE_NAME).then(cache =>
|
||||
cache.match(req).then(cached => {
|
||||
if (cached) {
|
||||
// Revalidate in background
|
||||
fetch(req).then(res => {
|
||||
if (res && res.status === 200) cache.put(req, res.clone());
|
||||
}).catch(() => {});
|
||||
return cached;
|
||||
}
|
||||
return fetch(req).then(res => {
|
||||
if (res && res.status === 200) cache.put(req, res.clone());
|
||||
return res;
|
||||
}).catch(() => fallbackResponse(req, 504));
|
||||
})
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Network-first for HTML pages
|
||||
e.respondWith(
|
||||
fetch(req).catch(() =>
|
||||
caches.match(req).then(cached => cached || new Response('Offline', { status: 503 }))
|
||||
)
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys()
|
||||
.then((keys) => Promise.all(keys.filter((key) => key.startsWith('intercept-')).map((key) => caches.delete(key))))
|
||||
.then(() => self.registration.unregister())
|
||||
.then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
+345
-174
@@ -4,27 +4,10 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AIRCRAFT RADAR // INTERCEPT - See the Invisible</title>
|
||||
<!-- Preconnect hints -->
|
||||
{% if offline_settings.assets_source != 'local' %}
|
||||
<link rel="preconnect" href="https://unpkg.com" crossorigin>
|
||||
{% endif %}
|
||||
{% if offline_settings.fonts_source != 'local' %}
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
{% endif %}
|
||||
<link rel="preconnect" href="https://cartodb-basemaps-a.global.ssl.fastly.net" crossorigin>
|
||||
<!-- Fonts - Conditional CDN/Local loading -->
|
||||
{% if offline_settings.fonts_source == 'local' %}
|
||||
<!-- Dedicated dashboards always use bundled assets so navigation is not
|
||||
blocked by external CDN reachability. -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
|
||||
{% else %}
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto+Condensed:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
{% endif %}
|
||||
<!-- Leaflet CSS -->
|
||||
{% if offline_settings.assets_source == 'local' %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.css') }}">
|
||||
{% else %}
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
{% endif %}
|
||||
<!-- Core CSS -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/variables.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
||||
@@ -36,22 +19,20 @@
|
||||
<script>
|
||||
window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }};
|
||||
window.INTERCEPT_ADSB_AUTO_START = {{ adsb_auto_start | tojson }};
|
||||
window.INTERCEPT_DEFAULT_LAT = {{ default_latitude | tojson }};
|
||||
window.INTERCEPT_DEFAULT_LON = {{ default_longitude | tojson }};
|
||||
</script>
|
||||
{% if offline_settings.assets_source == 'local' %}
|
||||
<script defer src="{{ url_for('static', filename='vendor/leaflet/leaflet.js') }}"></script>
|
||||
{% else %}
|
||||
<script defer src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
{% endif %}
|
||||
<script defer src="{{ url_for('static', filename='js/core/observer-location.js') }}"></script>
|
||||
</head>
|
||||
<body>
|
||||
<body data-mode="adsb">
|
||||
<div class="radar-bg"></div>
|
||||
<div class="scanline"></div>
|
||||
|
||||
<header class="header">
|
||||
<div class="logo">
|
||||
AIRCRAFT RADAR
|
||||
<span>// INTERCEPT - See the Invisible</span>
|
||||
<span>// <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 - See the Invisible</span>
|
||||
</div>
|
||||
<div class="status-bar">
|
||||
<!-- Agent Selector -->
|
||||
@@ -328,9 +309,9 @@
|
||||
</select>
|
||||
<button class="watchlist-btn" onclick="showWatchlistModal()" title="Manage Watchlist">★</button>
|
||||
<select id="rangeSelect" onchange="updateRange()" title="Range rings distance">
|
||||
<option value="50">50nm</option>
|
||||
<option value="50" selected>50nm</option>
|
||||
<option value="100">100nm</option>
|
||||
<option value="200" selected>200nm</option>
|
||||
<option value="200">200nm</option>
|
||||
<option value="300">300nm</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -340,8 +321,8 @@
|
||||
<div class="control-group">
|
||||
<span class="control-group-label">LOCATION</span>
|
||||
<div class="control-group-items">
|
||||
<input type="text" id="obsLat" value="51.5074" onchange="updateObserverLoc()" style="width: 70px;" title="Latitude" placeholder="Lat">
|
||||
<input type="text" id="obsLon" value="-0.1278" onchange="updateObserverLoc()" style="width: 70px;" title="Longitude" placeholder="Lon">
|
||||
<input type="text" id="obsLat" value="{{ default_latitude }}" onchange="updateObserverLoc()" style="width: 70px;" title="Latitude" placeholder="Lat">
|
||||
<input type="text" id="obsLon" value="{{ default_longitude }}" onchange="updateObserverLoc()" style="width: 70px;" title="Longitude" placeholder="Lon">
|
||||
<span id="gpsIndicator" class="gps-indicator" style="display: none;" title="GPS connected via gpsd"><span class="gps-dot"></span> GPS</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -441,6 +422,7 @@
|
||||
let eventSource = null;
|
||||
let agentPollTimer = null; // Polling fallback for agent mode
|
||||
let isTracking = false;
|
||||
let isTrackingStarting = false;
|
||||
let currentFilter = 'all';
|
||||
// ICAO -> { emergency: bool, watchlist: bool, military: bool }
|
||||
let alertedAircraft = {};
|
||||
@@ -458,6 +440,13 @@
|
||||
let panelSelectionFallbackTimer = null;
|
||||
let panelSelectionStageTimer = null;
|
||||
let mapCrosshairRequestId = 0;
|
||||
let detectedDevicesPromise = null;
|
||||
let deviceDetectionRetryTimer = null;
|
||||
let clockInterval = null;
|
||||
let cleanupInterval = null;
|
||||
let delayedGpsInitTimer = null;
|
||||
let delayedDriverCheckTimer = null;
|
||||
let delayedAircraftDbTimer = null;
|
||||
// Watchlist - persisted to localStorage
|
||||
let watchlist = JSON.parse(localStorage.getItem('adsb_watchlist') || '[]');
|
||||
|
||||
@@ -467,7 +456,7 @@
|
||||
let showTrails = true;
|
||||
const MAX_TRAIL_POINTS = 100;
|
||||
|
||||
let maxRange = 200; // nautical miles
|
||||
let maxRange = 50; // nautical miles
|
||||
|
||||
// Statistics
|
||||
let stats = {
|
||||
@@ -643,7 +632,9 @@
|
||||
if (parsed.lat !== undefined && parsed.lat !== null && parsed.lon !== undefined && parsed.lon !== null) return parsed;
|
||||
} catch (e) {}
|
||||
}
|
||||
return { lat: 51.5074, lon: -0.1278 };
|
||||
const defaultLat = window.INTERCEPT_DEFAULT_LAT || 51.5074;
|
||||
const defaultLon = window.INTERCEPT_DEFAULT_LON || -0.1278;
|
||||
return { lat: defaultLat, lon: defaultLon };
|
||||
})();
|
||||
let rangeRingsLayer = null;
|
||||
let observerMarker = null;
|
||||
@@ -1602,7 +1593,7 @@ ACARS: ${r.statistics.acarsMessages} messages`;
|
||||
// ============================================
|
||||
async function autoConnectGps() {
|
||||
try {
|
||||
const response = await fetch('/gps/auto-connect', { method: 'POST' });
|
||||
const response = await fetchJsonWithTimeout('/gps/auto-connect', { method: 'POST' }, 2000);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'connected') {
|
||||
@@ -1733,98 +1724,227 @@ ACARS: ${r.statistics.acarsMessages} messages`;
|
||||
window.addEventListener('pagehide', function() {
|
||||
if (eventSource) { eventSource.close(); eventSource = null; }
|
||||
if (gpsEventSource) { gpsEventSource.close(); gpsEventSource = null; }
|
||||
if (acarsEventSource) { acarsEventSource.close(); acarsEventSource = null; }
|
||||
if (vdl2EventSource) { vdl2EventSource.close(); vdl2EventSource = null; }
|
||||
if (allAgentsEventSource) { allAgentsEventSource.close(); allAgentsEventSource = null; }
|
||||
if (agentPollTimer) { clearInterval(agentPollTimer); agentPollTimer = null; }
|
||||
if (acarsPollTimer) { clearInterval(acarsPollTimer); acarsPollTimer = null; }
|
||||
if (vdl2PollTimer) { clearInterval(vdl2PollTimer); vdl2PollTimer = null; }
|
||||
if (clockInterval) { clearInterval(clockInterval); clockInterval = null; }
|
||||
if (cleanupInterval) { clearInterval(cleanupInterval); cleanupInterval = null; }
|
||||
if (delayedGpsInitTimer) { clearTimeout(delayedGpsInitTimer); delayedGpsInitTimer = null; }
|
||||
if (delayedDriverCheckTimer) { clearTimeout(delayedDriverCheckTimer); delayedDriverCheckTimer = null; }
|
||||
if (delayedAircraftDbTimer) { clearTimeout(delayedAircraftDbTimer); delayedAircraftDbTimer = null; }
|
||||
if (deviceDetectionRetryTimer) { clearTimeout(deviceDetectionRetryTimer); deviceDetectionRetryTimer = null; }
|
||||
});
|
||||
|
||||
function ensureAdsbMapBootstrapped() {
|
||||
if (radarMap) return;
|
||||
try {
|
||||
initMap();
|
||||
} catch (e) {
|
||||
console.error('ADS-B map bootstrap failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Initialize observer location input fields from saved location
|
||||
const obsLatInput = document.getElementById('obsLat');
|
||||
const obsLonInput = document.getElementById('obsLon');
|
||||
if (obsLatInput) obsLatInput.value = observerLocation.lat.toFixed(4);
|
||||
if (obsLonInput) obsLonInput.value = observerLocation.lon.toFixed(4);
|
||||
// Bring the map up first so a later startup error cannot leave the
|
||||
// dashboard in a half-rendered "shell only" state.
|
||||
ensureAdsbMapBootstrapped();
|
||||
|
||||
// Initialize detection sound toggle from localStorage
|
||||
const detectionToggle = document.getElementById('detectionSoundToggle');
|
||||
if (detectionToggle) detectionToggle.checked = detectionSoundEnabled;
|
||||
try {
|
||||
// Initialize observer location input fields from saved location
|
||||
const obsLatInput = document.getElementById('obsLat');
|
||||
const obsLonInput = document.getElementById('obsLon');
|
||||
if (obsLatInput) obsLatInput.value = observerLocation.lat.toFixed(4);
|
||||
if (obsLonInput) obsLonInput.value = observerLocation.lon.toFixed(4);
|
||||
|
||||
// Load Bias-T setting from localStorage
|
||||
loadAdsbBiasTSetting();
|
||||
// Initialize detection sound toggle from localStorage
|
||||
const detectionToggle = document.getElementById('detectionSoundToggle');
|
||||
if (detectionToggle) detectionToggle.checked = detectionSoundEnabled;
|
||||
} catch (e) {
|
||||
console.error('ADS-B UI bootstrap warning:', e);
|
||||
}
|
||||
|
||||
initMap();
|
||||
initDeviceSelectors();
|
||||
updateClock();
|
||||
setInterval(updateClock, 1000);
|
||||
setInterval(cleanupOldAircraft, 10000);
|
||||
checkAdsbTools();
|
||||
checkAircraftDatabase();
|
||||
checkDvbDriverConflict();
|
||||
try {
|
||||
loadAdsbBiasTSetting();
|
||||
} catch (e) {
|
||||
console.error('ADS-B Bias-T bootstrap warning:', e);
|
||||
}
|
||||
|
||||
// Auto-connect to gpsd if available
|
||||
autoConnectGps();
|
||||
showDeviceDetectionPendingState();
|
||||
initDeviceSelectors()
|
||||
.then((devices) => checkAdsbTools(devices))
|
||||
.catch((e) => {
|
||||
console.error('ADS-B device selector bootstrap warning:', e);
|
||||
checkAdsbTools([]);
|
||||
});
|
||||
|
||||
// Sync tracking state if ADS-B already running
|
||||
syncTrackingStatus();
|
||||
deviceDetectionRetryTimer = setTimeout(() => {
|
||||
deviceDetectionRetryTimer = null;
|
||||
const adsbSelect = document.getElementById('adsbDeviceSelect');
|
||||
const emptyText = adsbSelect?.options?.[0]?.textContent || '';
|
||||
const stillWaitingForDevices = adsbSelect && adsbSelect.options.length === 1
|
||||
&& /No SDR|Detecting SDR/i.test(emptyText);
|
||||
|
||||
if (!stillWaitingForDevices) return;
|
||||
|
||||
initDeviceSelectors(true, 20000)
|
||||
.then((devices) => checkAdsbTools(devices))
|
||||
.catch((e) => {
|
||||
console.error('ADS-B device selector retry warning:', e);
|
||||
});
|
||||
}, 6000);
|
||||
|
||||
try {
|
||||
updateClock();
|
||||
clockInterval = setInterval(updateClock, 1000);
|
||||
cleanupInterval = setInterval(cleanupOldAircraft, 10000);
|
||||
} catch (e) {
|
||||
console.error('ADS-B timer bootstrap warning:', e);
|
||||
}
|
||||
|
||||
// Defer nonessential startup probes so the page can paint and
|
||||
// return navigation remains snappy if the user leaves quickly.
|
||||
delayedAircraftDbTimer = setTimeout(() => {
|
||||
delayedAircraftDbTimer = null;
|
||||
checkAircraftDatabase();
|
||||
}, 1200);
|
||||
|
||||
delayedDriverCheckTimer = setTimeout(() => {
|
||||
delayedDriverCheckTimer = null;
|
||||
checkDvbDriverConflict();
|
||||
}, 1800);
|
||||
|
||||
delayedGpsInitTimer = setTimeout(() => {
|
||||
delayedGpsInitTimer = null;
|
||||
autoConnectGps();
|
||||
}, 2500);
|
||||
|
||||
syncTrackingStatus().catch((e) => {
|
||||
console.error('ADS-B tracking status bootstrap warning:', e);
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
if (!radarMap) {
|
||||
console.warn('ADS-B map was not initialized during DOMContentLoaded, retrying on window load');
|
||||
ensureAdsbMapBootstrapped();
|
||||
}
|
||||
});
|
||||
|
||||
// Track which device is being used for ADS-B tracking
|
||||
let adsbActiveDevice = null;
|
||||
|
||||
function initDeviceSelectors() {
|
||||
// Populate both ADS-B and airband device selectors
|
||||
fetch('/devices')
|
||||
.then(r => r.json())
|
||||
.then(devices => {
|
||||
const adsbSelect = document.getElementById('adsbDeviceSelect');
|
||||
const airbandSelect = document.getElementById('airbandDeviceSelect');
|
||||
function fetchJsonWithTimeout(url, options = {}, timeoutMs = 4000) {
|
||||
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
||||
const timeoutId = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
|
||||
return fetch(url, {
|
||||
...options,
|
||||
...(controller ? { signal: controller.signal } : {})
|
||||
}).finally(() => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
});
|
||||
}
|
||||
|
||||
// Clear loading state
|
||||
adsbSelect.innerHTML = '';
|
||||
airbandSelect.innerHTML = '';
|
||||
function populateCompositeDeviceSelect(select, devices, emptyLabel = 'No SDR detected') {
|
||||
if (!select) return;
|
||||
select.innerHTML = '';
|
||||
|
||||
if (!devices || devices.length === 0) {
|
||||
select.innerHTML = `<option value="rtlsdr:0">${emptyLabel}</option>`;
|
||||
return;
|
||||
}
|
||||
|
||||
devices.forEach((dev, i) => {
|
||||
const idx = dev.index !== undefined ? dev.index : i;
|
||||
const sdrType = dev.sdr_type || 'rtlsdr';
|
||||
const option = document.createElement('option');
|
||||
option.value = `${sdrType}:${idx}`;
|
||||
option.dataset.sdrType = sdrType;
|
||||
option.dataset.index = idx;
|
||||
option.textContent = `SDR ${idx}: ${dev.name || dev.type || 'SDR'}`;
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
function showDeviceDetectionPendingState() {
|
||||
populateCompositeDeviceSelect(document.getElementById('adsbDeviceSelect'), [], 'Detecting SDRs...');
|
||||
populateCompositeDeviceSelect(document.getElementById('airbandDeviceSelect'), [], 'Detecting SDRs...');
|
||||
populateCompositeDeviceSelect(document.getElementById('acarsDeviceSelect'), [], 'Detecting SDRs...');
|
||||
populateCompositeDeviceSelect(document.getElementById('vdl2DeviceSelect'), [], 'Detecting SDRs...');
|
||||
}
|
||||
|
||||
function getDetectedDevices(force = false, timeoutMs = 12000) {
|
||||
if (force) {
|
||||
detectedDevicesPromise = null;
|
||||
}
|
||||
|
||||
if (!force && detectedDevicesPromise) {
|
||||
return detectedDevicesPromise;
|
||||
}
|
||||
|
||||
detectedDevicesPromise = fetchJsonWithTimeout('/devices', {}, timeoutMs)
|
||||
.then((r) => r.ok ? r.json() : [])
|
||||
.then((devices) => {
|
||||
if (!Array.isArray(devices)) {
|
||||
detectedDevicesPromise = null;
|
||||
return [];
|
||||
}
|
||||
|
||||
if (devices.length === 0) {
|
||||
adsbSelect.innerHTML = '<option value="rtlsdr:0">No SDR found</option>';
|
||||
airbandSelect.innerHTML = '<option value="rtlsdr:0">No SDR found</option>';
|
||||
airbandSelect.disabled = true;
|
||||
} else {
|
||||
devices.forEach((dev, i) => {
|
||||
const idx = dev.index !== undefined ? dev.index : i;
|
||||
const sdrType = dev.sdr_type || 'rtlsdr';
|
||||
const compositeVal = `${sdrType}:${idx}`;
|
||||
const displayName = `SDR ${idx}: ${dev.name}`;
|
||||
|
||||
// Add to ADS-B selector
|
||||
const adsbOpt = document.createElement('option');
|
||||
adsbOpt.value = compositeVal;
|
||||
adsbOpt.dataset.sdrType = sdrType;
|
||||
adsbOpt.dataset.index = idx;
|
||||
adsbOpt.textContent = displayName;
|
||||
adsbSelect.appendChild(adsbOpt);
|
||||
|
||||
// Add to Airband selector
|
||||
const airbandOpt = document.createElement('option');
|
||||
airbandOpt.value = compositeVal;
|
||||
airbandOpt.dataset.sdrType = sdrType;
|
||||
airbandOpt.dataset.index = idx;
|
||||
airbandOpt.textContent = displayName;
|
||||
airbandSelect.appendChild(airbandOpt);
|
||||
});
|
||||
|
||||
// Default: ADS-B uses first device, Airband uses second (if available)
|
||||
adsbSelect.value = adsbSelect.options[0]?.value || 'rtlsdr:0';
|
||||
if (devices.length > 1) {
|
||||
airbandSelect.value = airbandSelect.options[1]?.value || airbandSelect.options[0]?.value || 'rtlsdr:0';
|
||||
}
|
||||
|
||||
// Show warning if only one device
|
||||
if (devices.length === 1) {
|
||||
document.getElementById('airbandStatus').textContent = '1 SDR only';
|
||||
document.getElementById('airbandStatus').style.color = 'var(--accent-orange)';
|
||||
}
|
||||
detectedDevicesPromise = null;
|
||||
}
|
||||
|
||||
return devices;
|
||||
})
|
||||
.catch(() => {
|
||||
document.getElementById('adsbDeviceSelect').innerHTML = '<option value="rtlsdr:0">Error</option>';
|
||||
document.getElementById('airbandDeviceSelect').innerHTML = '<option value="rtlsdr:0">Error</option>';
|
||||
.catch((err) => {
|
||||
console.warn('[ADS-B] Device detection failed:', err?.message || err);
|
||||
detectedDevicesPromise = null;
|
||||
return [];
|
||||
});
|
||||
return detectedDevicesPromise;
|
||||
}
|
||||
|
||||
function initDeviceSelectors(force = false, timeoutMs = 12000) {
|
||||
return getDetectedDevices(force, timeoutMs).then((devices) => {
|
||||
const adsbSelect = document.getElementById('adsbDeviceSelect');
|
||||
const airbandSelect = document.getElementById('airbandDeviceSelect');
|
||||
const acarsSelect = document.getElementById('acarsDeviceSelect');
|
||||
const vdl2Select = document.getElementById('vdl2DeviceSelect');
|
||||
|
||||
populateCompositeDeviceSelect(adsbSelect, devices, 'No SDR found');
|
||||
populateCompositeDeviceSelect(airbandSelect, devices, 'No SDR found');
|
||||
populateCompositeDeviceSelect(acarsSelect, devices);
|
||||
populateCompositeDeviceSelect(vdl2Select, devices);
|
||||
|
||||
if (!devices || devices.length === 0) {
|
||||
if (airbandSelect) airbandSelect.disabled = true;
|
||||
return devices;
|
||||
}
|
||||
|
||||
if (airbandSelect) {
|
||||
airbandSelect.disabled = false;
|
||||
}
|
||||
|
||||
if (adsbSelect) {
|
||||
adsbSelect.value = adsbSelect.options[0]?.value || 'rtlsdr:0';
|
||||
}
|
||||
if (airbandSelect && devices.length > 1) {
|
||||
airbandSelect.value = airbandSelect.options[1]?.value || airbandSelect.options[0]?.value || 'rtlsdr:0';
|
||||
}
|
||||
|
||||
if (devices.length === 1) {
|
||||
document.getElementById('airbandStatus').textContent = '1 SDR only';
|
||||
document.getElementById('airbandStatus').style.color = 'var(--accent-orange)';
|
||||
}
|
||||
|
||||
return devices;
|
||||
}).catch(() => {
|
||||
document.getElementById('adsbDeviceSelect').innerHTML = '<option value="rtlsdr:0">Error</option>';
|
||||
document.getElementById('airbandDeviceSelect').innerHTML = '<option value="rtlsdr:0">Error</option>';
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
function checkDvbDriverConflict() {
|
||||
@@ -1928,12 +2048,15 @@ ACARS: ${r.statistics.acarsMessages} messages`;
|
||||
if (warning) warning.remove();
|
||||
}
|
||||
|
||||
function checkAdsbTools() {
|
||||
fetch('/adsb/tools')
|
||||
function checkAdsbTools(devices = []) {
|
||||
fetchJsonWithTimeout('/adsb/tools', {}, 3000)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.needs_readsb) {
|
||||
showReadsbWarning(data.soapy_types);
|
||||
const soapyTypes = (devices || [])
|
||||
.filter((d) => ['hackrf', 'limesdr', 'airspy'].includes((d.sdr_type || '').toLowerCase()))
|
||||
.map((d) => d.sdr_type);
|
||||
if (!data.readsb && soapyTypes.length > 0) {
|
||||
showReadsbWarning(soapyTypes);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
@@ -1945,7 +2068,7 @@ ACARS: ${r.statistics.acarsMessages} messages`;
|
||||
let aircraftDbStatus = { installed: false };
|
||||
|
||||
function checkAircraftDatabase() {
|
||||
fetch('/adsb/aircraft-db/status')
|
||||
fetchJsonWithTimeout('/adsb/aircraft-db/status', {}, 2000)
|
||||
.then(r => r.json())
|
||||
.then(status => {
|
||||
aircraftDbStatus = status;
|
||||
@@ -1953,7 +2076,7 @@ ACARS: ${r.statistics.acarsMessages} messages`;
|
||||
showAircraftDbBanner('not_installed');
|
||||
} else {
|
||||
// Check for updates in background
|
||||
fetch('/adsb/aircraft-db/check-updates')
|
||||
fetchJsonWithTimeout('/adsb/aircraft-db/check-updates', {}, 2000)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.update_available) {
|
||||
@@ -2096,6 +2219,77 @@ sudo make install</code>
|
||||
now.toISOString().substring(11, 19) + ' UTC';
|
||||
}
|
||||
|
||||
function createFallbackGridLayer() {
|
||||
const layer = L.gridLayer({
|
||||
tileSize: 256,
|
||||
updateWhenIdle: true,
|
||||
attribution: 'Local fallback grid'
|
||||
});
|
||||
layer.createTile = function(coords) {
|
||||
const tile = document.createElement('canvas');
|
||||
tile.width = 256;
|
||||
tile.height = 256;
|
||||
const ctx = tile.getContext('2d');
|
||||
|
||||
ctx.fillStyle = '#08121c';
|
||||
ctx.fillRect(0, 0, 256, 256);
|
||||
|
||||
ctx.strokeStyle = 'rgba(0, 212, 255, 0.14)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, 0);
|
||||
ctx.lineTo(256, 0);
|
||||
ctx.moveTo(0, 0);
|
||||
ctx.lineTo(0, 256);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.strokeStyle = 'rgba(0, 212, 255, 0.08)';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(128, 0);
|
||||
ctx.lineTo(128, 256);
|
||||
ctx.moveTo(0, 128);
|
||||
ctx.lineTo(256, 128);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = 'rgba(160, 220, 255, 0.28)';
|
||||
ctx.font = '11px "JetBrains Mono", monospace';
|
||||
ctx.fillText(`Z${coords.z} X${coords.x} Y${coords.y}`, 12, 22);
|
||||
|
||||
return tile;
|
||||
};
|
||||
return layer;
|
||||
}
|
||||
|
||||
async function upgradeRadarTilesFromSettings(fallbackTiles) {
|
||||
if (typeof Settings === 'undefined') return;
|
||||
|
||||
try {
|
||||
await Settings.init();
|
||||
if (!radarMap) return;
|
||||
|
||||
const configuredLayer = Settings.createTileLayer();
|
||||
let tileLoaded = false;
|
||||
|
||||
configuredLayer.once('load', () => {
|
||||
tileLoaded = true;
|
||||
if (radarMap && fallbackTiles && radarMap.hasLayer(fallbackTiles)) {
|
||||
radarMap.removeLayer(fallbackTiles);
|
||||
}
|
||||
});
|
||||
|
||||
configuredLayer.on('tileerror', () => {
|
||||
if (!tileLoaded) {
|
||||
console.warn('ADS-B tile layer failed to load, keeping fallback grid');
|
||||
}
|
||||
});
|
||||
|
||||
configuredLayer.addTo(radarMap);
|
||||
Settings.registerMap(radarMap);
|
||||
} catch (e) {
|
||||
console.warn('ADS-B: Settings/tile upgrade failed, using fallback grid:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function initMap() {
|
||||
// Guard against double initialization (e.g. bfcache restore)
|
||||
const container = document.getElementById('radarMap');
|
||||
@@ -2111,13 +2305,9 @@ sudo make install</code>
|
||||
// Use settings manager for tile layer (allows runtime changes)
|
||||
window.radarMap = radarMap;
|
||||
|
||||
// Add fallback tiles immediately so the map is never blank
|
||||
const fallbackTiles = L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||||
maxZoom: 19,
|
||||
subdomains: 'abcd',
|
||||
className: 'tile-layer-cyan'
|
||||
}).addTo(radarMap);
|
||||
// Use a zero-network fallback so dashboard navigation stays fast even
|
||||
// when internet map providers are slow or unreachable.
|
||||
const fallbackTiles = createFallbackGridLayer().addTo(radarMap);
|
||||
|
||||
// Draw range rings after map is ready
|
||||
setTimeout(() => drawRangeRings(), 100);
|
||||
@@ -2132,20 +2322,9 @@ sudo make install</code>
|
||||
if (radarMap) radarMap.invalidateSize();
|
||||
}, 500);
|
||||
|
||||
// Upgrade tiles via Settings in the background (non-blocking)
|
||||
if (typeof Settings !== 'undefined') {
|
||||
try {
|
||||
await Promise.race([
|
||||
Settings.init(),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('Settings timeout')), 5000))
|
||||
]);
|
||||
radarMap.removeLayer(fallbackTiles);
|
||||
Settings.createTileLayer().addTo(radarMap);
|
||||
Settings.registerMap(radarMap);
|
||||
} catch (e) {
|
||||
console.warn('Settings init failed/timed out, using fallback tiles:', e);
|
||||
}
|
||||
}
|
||||
// Upgrade tiles via Settings in the background without tearing down
|
||||
// the local fallback grid until a real tile layer actually loads.
|
||||
upgradeRadarTilesFromSettings(fallbackTiles);
|
||||
}
|
||||
|
||||
// Handle window resize for map (especially important on mobile)
|
||||
@@ -2189,6 +2368,10 @@ sudo make install</code>
|
||||
const btn = document.getElementById('startBtn');
|
||||
const useAgent = typeof adsbCurrentAgent !== 'undefined' && adsbCurrentAgent !== 'local';
|
||||
|
||||
if (isTrackingStarting) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isTracking) {
|
||||
// Check for remote dump1090 config (only for local mode)
|
||||
const remoteConfig = !useAgent ? getRemoteDump1090Config() : null;
|
||||
@@ -2206,7 +2389,8 @@ sudo make install</code>
|
||||
const adsbDevice = parseInt(adsbDeviceIdx) || 0;
|
||||
|
||||
// Pre-flight: check if another mode is using this device and auto-stop it
|
||||
if (!useAgent) {
|
||||
// Skip when using a remote SBS feed — no local SDR is needed
|
||||
if (!useAgent && !remoteConfig) {
|
||||
try {
|
||||
const devResp = await fetch('/devices/status');
|
||||
if (devResp.ok) {
|
||||
@@ -2266,6 +2450,10 @@ sudo make install</code>
|
||||
requestBody.remote_sbs_host = remoteConfig.host;
|
||||
requestBody.remote_sbs_port = remoteConfig.port;
|
||||
}
|
||||
isTrackingStarting = true;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'STARTING...';
|
||||
updateTrackingStatusDisplay();
|
||||
try {
|
||||
// Route through agent proxy if using remote agent
|
||||
const url = useAgent
|
||||
@@ -2292,10 +2480,12 @@ sudo make install</code>
|
||||
drawRangeRings();
|
||||
startSessionTimer();
|
||||
isTracking = true;
|
||||
isTrackingStarting = false;
|
||||
adsbActiveDevice = adsbDevice; // Track which device is being used
|
||||
adsbTrackingSource = useAgent ? adsbCurrentAgent : 'local'; // Track which source started tracking
|
||||
btn.textContent = 'STOP';
|
||||
btn.classList.add('active');
|
||||
btn.disabled = false;
|
||||
document.getElementById('trackingDot').classList.remove('inactive');
|
||||
updateTrackingStatusDisplay();
|
||||
// Disable ADS-B device selector while tracking
|
||||
@@ -2315,6 +2505,14 @@ sudo make install</code>
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Error: ' + err.message);
|
||||
} finally {
|
||||
if (!isTracking) {
|
||||
isTrackingStarting = false;
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'START';
|
||||
btn.classList.remove('active');
|
||||
updateTrackingStatusDisplay();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
@@ -4277,26 +4475,9 @@ sudo make install</code>
|
||||
|
||||
// Populate ACARS device selector
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
fetch('/devices')
|
||||
.then(r => r.json())
|
||||
.then(devices => {
|
||||
const select = document.getElementById('acarsDeviceSelect');
|
||||
select.innerHTML = '';
|
||||
if (devices.length === 0) {
|
||||
select.innerHTML = '<option value="rtlsdr:0">No SDR detected</option>';
|
||||
} else {
|
||||
devices.forEach((d, i) => {
|
||||
const opt = document.createElement('option');
|
||||
const sdrType = d.sdr_type || 'rtlsdr';
|
||||
const idx = d.index !== undefined ? d.index : i;
|
||||
opt.value = `${sdrType}:${idx}`;
|
||||
opt.dataset.sdrType = sdrType;
|
||||
opt.dataset.index = idx;
|
||||
opt.textContent = `SDR ${idx}: ${d.name || d.type || 'SDR'}`;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
}
|
||||
});
|
||||
getDetectedDevices().then((devices) => {
|
||||
populateCompositeDeviceSelect(document.getElementById('acarsDeviceSelect'), devices);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
@@ -4826,26 +5007,9 @@ sudo make install</code>
|
||||
|
||||
// Populate VDL2 device selector and check running status
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
fetch('/devices')
|
||||
.then(r => r.json())
|
||||
.then(devices => {
|
||||
const select = document.getElementById('vdl2DeviceSelect');
|
||||
select.innerHTML = '';
|
||||
if (devices.length === 0) {
|
||||
select.innerHTML = '<option value="rtlsdr:0">No SDR detected</option>';
|
||||
} else {
|
||||
devices.forEach((d, i) => {
|
||||
const opt = document.createElement('option');
|
||||
const sdrType = d.sdr_type || 'rtlsdr';
|
||||
const idx = d.index !== undefined ? d.index : i;
|
||||
opt.value = `${sdrType}:${idx}`;
|
||||
opt.dataset.sdrType = sdrType;
|
||||
opt.dataset.index = idx;
|
||||
opt.textContent = `SDR ${idx}: ${d.name || d.type || 'SDR'}`;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
}
|
||||
});
|
||||
getDetectedDevices().then((devices) => {
|
||||
populateCompositeDeviceSelect(document.getElementById('vdl2DeviceSelect'), devices);
|
||||
});
|
||||
|
||||
// Check if VDL2 is already running (e.g. after page reload)
|
||||
fetch('/vdl2/status')
|
||||
@@ -5553,10 +5717,14 @@ sudo make install</code>
|
||||
{% include 'partials/help-modal.html' %}
|
||||
|
||||
<script src="{{ url_for('static', filename='js/core/voice-alerts.js') }}?v={{ version }}&r=adsbvoice1"></script>
|
||||
<script src="{{ url_for('static', filename='js/core/keyboard-shortcuts.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/core/cheat-sheets.js') }}"></script>
|
||||
{% include 'partials/nav-utility-modals.html' %}
|
||||
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}?v={{ version }}&r=maptheme17"></script>
|
||||
<script>
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
if (typeof VoiceAlerts !== 'undefined') VoiceAlerts.init();
|
||||
if (typeof KeyboardShortcuts !== 'undefined') KeyboardShortcuts.init();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -5594,7 +5762,10 @@ sudo make install</code>
|
||||
const statusEl = document.getElementById('trackingStatus');
|
||||
if (!statusEl) return;
|
||||
|
||||
if (!isTracking) {
|
||||
if (isTrackingStarting && !isTracking) {
|
||||
statusEl.textContent = 'INITIALIZING';
|
||||
statusEl.title = 'Starting ADS-B receiver';
|
||||
} else if (!isTracking) {
|
||||
statusEl.textContent = 'STANDBY';
|
||||
statusEl.title = 'Select source and click START';
|
||||
} else {
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<header class="header">
|
||||
<div class="logo">
|
||||
ADS-B HISTORY
|
||||
<span>// INTERCEPT REPORTING</span>
|
||||
<span>// <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 REPORTING</span>
|
||||
</div>
|
||||
<div class="status-bar">
|
||||
<a href="/adsb/dashboard" class="back-link">Live Radar</a>
|
||||
|
||||
@@ -281,7 +281,7 @@
|
||||
</svg>
|
||||
</div>
|
||||
<h1 style="margin: 0;">
|
||||
iNTERCEPT <span class="tagline">// Remote Agents</span>
|
||||
<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 class="tagline">// Remote Agents</span>
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -4,27 +4,10 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>VESSEL RADAR // INTERCEPT - See the Invisible</title>
|
||||
<!-- Preconnect hints -->
|
||||
{% if offline_settings.assets_source != 'local' %}
|
||||
<link rel="preconnect" href="https://unpkg.com" crossorigin>
|
||||
{% endif %}
|
||||
{% if offline_settings.fonts_source != 'local' %}
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
{% endif %}
|
||||
<link rel="preconnect" href="https://cartodb-basemaps-a.global.ssl.fastly.net" crossorigin>
|
||||
<!-- Fonts - Conditional CDN/Local loading -->
|
||||
{% if offline_settings.fonts_source == 'local' %}
|
||||
<!-- Dedicated dashboards always use bundled assets so navigation is not
|
||||
blocked by external CDN reachability. -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
|
||||
{% else %}
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto+Condensed:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
{% endif %}
|
||||
<!-- Leaflet CSS -->
|
||||
{% if offline_settings.assets_source == 'local' %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.css') }}">
|
||||
{% else %}
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
{% endif %}
|
||||
<!-- Core CSS -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/variables.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/ais_dashboard.css') }}">
|
||||
@@ -35,15 +18,13 @@
|
||||
<!-- Deferred scripts -->
|
||||
<script>
|
||||
window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }};
|
||||
window.INTERCEPT_DEFAULT_LAT = {{ default_latitude | tojson }};
|
||||
window.INTERCEPT_DEFAULT_LON = {{ default_longitude | tojson }};
|
||||
</script>
|
||||
{% if offline_settings.assets_source == 'local' %}
|
||||
<script defer src="{{ url_for('static', filename='vendor/leaflet/leaflet.js') }}"></script>
|
||||
{% else %}
|
||||
<script defer src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
{% endif %}
|
||||
<script defer src="{{ url_for('static', filename='js/core/observer-location.js') }}"></script>
|
||||
</head>
|
||||
<body>
|
||||
<body data-mode="ais">
|
||||
<!-- Radar background effects -->
|
||||
<div class="radar-bg"></div>
|
||||
<div class="scanline"></div>
|
||||
@@ -51,7 +32,7 @@
|
||||
<header class="header">
|
||||
<div class="logo">
|
||||
VESSEL RADAR
|
||||
<span>// INTERCEPT - AIS Tracking</span>
|
||||
<span>// <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 - AIS Tracking</span>
|
||||
</div>
|
||||
<div class="status-bar">
|
||||
<!-- Agent Selector -->
|
||||
@@ -185,8 +166,8 @@
|
||||
<div class="control-group">
|
||||
<span class="control-group-label">LOCATION</span>
|
||||
<div class="control-group-items">
|
||||
<input type="text" id="obsLat" value="51.5074" onchange="updateObserverLoc()" style="width: 70px;" title="Latitude" placeholder="Lat">
|
||||
<input type="text" id="obsLon" value="-0.1278" onchange="updateObserverLoc()" style="width: 70px;" title="Longitude" placeholder="Lon">
|
||||
<input type="text" id="obsLat" value="{{ default_latitude }}" onchange="updateObserverLoc()" style="width: 70px;" title="Latitude" placeholder="Lat">
|
||||
<input type="text" id="obsLon" value="{{ default_longitude }}" onchange="updateObserverLoc()" style="width: 70px;" title="Longitude" placeholder="Lon">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -248,7 +229,9 @@
|
||||
if (window.ObserverLocation && ObserverLocation.getForModule) {
|
||||
return ObserverLocation.getForModule('ais_observerLocation');
|
||||
}
|
||||
return { lat: 51.5074, lon: -0.1278 };
|
||||
const defaultLat = window.INTERCEPT_DEFAULT_LAT || 51.5074;
|
||||
const defaultLon = window.INTERCEPT_DEFAULT_LON || -0.1278;
|
||||
return { lat: defaultLat, lon: defaultLon };
|
||||
})();
|
||||
let rangeRingsLayer = null;
|
||||
let observerMarker = null;
|
||||
@@ -405,6 +388,47 @@
|
||||
};
|
||||
|
||||
// Initialize map
|
||||
function createFallbackGridLayer() {
|
||||
const layer = L.gridLayer({
|
||||
tileSize: 256,
|
||||
updateWhenIdle: true,
|
||||
attribution: 'Local fallback grid'
|
||||
});
|
||||
layer.createTile = function(coords) {
|
||||
const tile = document.createElement('canvas');
|
||||
tile.width = 256;
|
||||
tile.height = 256;
|
||||
const ctx = tile.getContext('2d');
|
||||
|
||||
ctx.fillStyle = '#07131c';
|
||||
ctx.fillRect(0, 0, 256, 256);
|
||||
|
||||
ctx.strokeStyle = 'rgba(0, 212, 255, 0.12)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, 0);
|
||||
ctx.lineTo(256, 0);
|
||||
ctx.moveTo(0, 0);
|
||||
ctx.lineTo(0, 256);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.strokeStyle = 'rgba(34, 197, 94, 0.10)';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(128, 0);
|
||||
ctx.lineTo(128, 256);
|
||||
ctx.moveTo(0, 128);
|
||||
ctx.lineTo(256, 128);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = 'rgba(160, 220, 255, 0.28)';
|
||||
ctx.font = '11px "JetBrains Mono", monospace';
|
||||
ctx.fillText(`Z${coords.z} X${coords.x} Y${coords.y}`, 12, 22);
|
||||
|
||||
return tile;
|
||||
};
|
||||
return layer;
|
||||
}
|
||||
|
||||
async function initMap() {
|
||||
// Guard against double initialization (e.g. bfcache restore)
|
||||
const container = document.getElementById('vesselMap');
|
||||
@@ -424,13 +448,9 @@
|
||||
// Use settings manager for tile layer (allows runtime changes)
|
||||
window.vesselMap = vesselMap;
|
||||
|
||||
// Add fallback tile layer immediately so the map is never blank
|
||||
const fallbackTiles = L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||||
maxZoom: 19,
|
||||
subdomains: 'abcd',
|
||||
className: 'tile-layer-cyan'
|
||||
}).addTo(vesselMap);
|
||||
// Use a zero-network fallback so dashboard navigation stays fast even
|
||||
// when internet map providers are slow or unreachable.
|
||||
const fallbackTiles = createFallbackGridLayer().addTo(vesselMap);
|
||||
|
||||
// Then try to upgrade tiles via Settings (non-blocking)
|
||||
if (typeof Settings !== 'undefined') {
|
||||
@@ -1612,7 +1632,20 @@
|
||||
<!-- Help Modal -->
|
||||
{% include 'partials/help-modal.html' %}
|
||||
|
||||
<script src="{{ url_for('static', filename='js/core/voice-alerts.js') }}?v={{ version }}&r=voicefix2"></script>
|
||||
<script src="{{ url_for('static', filename='js/core/keyboard-shortcuts.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/core/cheat-sheets.js') }}"></script>
|
||||
{% include 'partials/nav-utility-modals.html' %}
|
||||
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}?v={{ version }}&r=maptheme17"></script>
|
||||
<script>
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
if (typeof VoiceAlerts !== 'undefined') {
|
||||
VoiceAlerts.init({ startStreams: false });
|
||||
VoiceAlerts.scheduleStreamStart(20000);
|
||||
}
|
||||
if (typeof KeyboardShortcuts !== 'undefined') KeyboardShortcuts.init();
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Agent Manager -->
|
||||
<script src="{{ url_for('static', filename='js/core/agents.js') }}"></script>
|
||||
|
||||
+341
-118
@@ -20,7 +20,6 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
{% endif %}
|
||||
<link rel="preconnect" href="https://cartodb-basemaps-a.global.ssl.fastly.net" crossorigin>
|
||||
<!-- Disclaimer gate - must accept before seeing welcome page -->
|
||||
<script>
|
||||
// Check BEFORE page renders - if disclaimer not accepted, hide welcome page
|
||||
@@ -162,28 +161,9 @@
|
||||
if (!mode) return;
|
||||
window.ensureModeStyles(mode).catch(() => {});
|
||||
})();
|
||||
// Warm remaining lazy mode styles in the background to avoid first-switch FOUC.
|
||||
(function warmModeStylesInBackground() {
|
||||
const modeMap = window.INTERCEPT_MODE_STYLE_MAP || {};
|
||||
const queryMode = new URLSearchParams(window.location.search).get('mode');
|
||||
const selectedMode = queryMode === 'listening' ? 'waterfall' : queryMode;
|
||||
const modes = Object.keys(modeMap).filter((mode) => mode !== selectedMode);
|
||||
if (!modes.length) return;
|
||||
|
||||
const warm = function () {
|
||||
modes.forEach(function (mode, index) {
|
||||
setTimeout(function () {
|
||||
window.ensureModeStyles(mode).catch(() => {});
|
||||
}, index * 40);
|
||||
});
|
||||
};
|
||||
|
||||
if (typeof window.requestIdleCallback === 'function') {
|
||||
window.requestIdleCallback(warm, { timeout: 2000 });
|
||||
} else {
|
||||
setTimeout(warm, 600);
|
||||
}
|
||||
})();
|
||||
// Do not warm every mode stylesheet on the welcome page. The eager
|
||||
// background fetch storm was adding substantial cross-mode load and
|
||||
// delaying dedicated dashboards like ADS-B.
|
||||
</script>
|
||||
<script>
|
||||
window.INTERCEPT_MODE_SCRIPT_MAP = {
|
||||
@@ -293,7 +273,7 @@
|
||||
<rect x="38" y="76" width="24" height="4" rx="1" fill="#00d4ff" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="welcome-title">iNTERCEPT</h1>
|
||||
<h1 class="welcome-title"><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="welcome-tagline">// See the Invisible</p>
|
||||
<span class="welcome-version">v{{ version }}</span>
|
||||
<button type="button" class="welcome-settings-btn" onclick="showSettings()" title="Settings" aria-label="Open settings">
|
||||
@@ -394,10 +374,10 @@
|
||||
<div class="mode-category">
|
||||
<h3 class="mode-category-title"><span class="mode-category-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/></svg></span> Space</h3>
|
||||
<div class="mode-grid mode-grid-compact">
|
||||
<button class="mode-card mode-card-sm" onclick="selectMode('satellite')">
|
||||
<a href="/satellite/dashboard" target="_blank" rel="noopener noreferrer" class="mode-card mode-card-sm" style="text-decoration: none;">
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/><path d="m16 8 3-3"/><path d="M9 21a6 6 0 0 0-6-6"/></svg></span>
|
||||
<span class="mode-name">Satellite</span>
|
||||
</button>
|
||||
</a>
|
||||
<button class="mode-card mode-card-sm" onclick="selectMode('sstv')">
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M3 9h2"/><path d="M19 9h2"/><path d="M3 15h2"/><path d="M19 15h2"/></svg></span>
|
||||
<span class="mode-name">ISS SSTV</span>
|
||||
@@ -589,7 +569,7 @@
|
||||
<rect x="38" y="76" width="24" height="4" rx="1" fill="#00d4ff" />
|
||||
</svg>
|
||||
</a>
|
||||
<h1>iNTERCEPT <span class="tagline">// See the Invisible</span></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 <span class="tagline">// See the Invisible</span></h1>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<span class="active-mode-indicator" id="activeModeIndicator"><span class="pulse-dot"></span>PAGER</span>
|
||||
@@ -1416,8 +1396,9 @@
|
||||
|
||||
<!-- Satellite Dashboard (Embedded) -->
|
||||
<div id="satelliteVisuals" class="satellite-dashboard-embed" style="display: none;">
|
||||
<iframe id="satelliteDashboardFrame" src="/satellite/dashboard?embedded=true" frameborder="0"
|
||||
<iframe id="satelliteDashboardFrame" data-src="/satellite/dashboard?embedded=true&v={{ version }}" frameborder="0"
|
||||
style="width: 100%; height: 100%; min-height: 700px; border: none; border-radius: 8px;"
|
||||
loading="lazy"
|
||||
allowfullscreen>
|
||||
</iframe>
|
||||
</div>
|
||||
@@ -3604,6 +3585,10 @@
|
||||
|
||||
// Mode selection from welcome page
|
||||
function selectMode(mode) {
|
||||
if (mode === 'satellite') {
|
||||
window.open('/satellite/dashboard', '_blank', 'noopener');
|
||||
return;
|
||||
}
|
||||
selectedStartMode = mode;
|
||||
const welcome = document.getElementById('welcomePage');
|
||||
welcome.classList.add('fade-out');
|
||||
@@ -3664,6 +3649,10 @@
|
||||
function applyModeFromQuery() {
|
||||
const mode = getModeFromQuery();
|
||||
if (!mode) return;
|
||||
if (mode === 'satellite') {
|
||||
window.location.replace('/satellite/dashboard');
|
||||
return;
|
||||
}
|
||||
const accepted = localStorage.getItem('disclaimerAccepted') === 'true';
|
||||
if (accepted) {
|
||||
const welcome = document.getElementById('welcomePage');
|
||||
@@ -3890,7 +3879,11 @@
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (parsed.lat && parsed.lon) return parsed;
|
||||
const lat = Number(parsed.lat);
|
||||
const lon = Number(parsed.lon);
|
||||
if (Number.isFinite(lat) && Number.isFinite(lon)) {
|
||||
return { lat, lon };
|
||||
}
|
||||
} catch (e) { }
|
||||
}
|
||||
return { lat: 51.5074, lon: -0.1278 };
|
||||
@@ -3899,6 +3892,8 @@
|
||||
// GPS Dongle state
|
||||
let gpsConnected = false;
|
||||
let gpsEventSource = null;
|
||||
let gpsAutoConnectTimer = null;
|
||||
let gpsAutoConnectInFlight = null;
|
||||
let gpsLastPosition = null;
|
||||
|
||||
// Satellite state
|
||||
@@ -4097,8 +4092,8 @@
|
||||
if (obsLatInput) obsLatInput.value = observerLocation.lat.toFixed(4);
|
||||
if (obsLonInput) obsLonInput.value = observerLocation.lon.toFixed(4);
|
||||
|
||||
// Auto-connect to gpsd if available
|
||||
autoConnectGps();
|
||||
// Defer GPS auto-connect so it doesn't compete with initial dashboard navigation.
|
||||
scheduleGpsAutoConnect();
|
||||
|
||||
// Load pager message filters
|
||||
loadPagerFilters();
|
||||
@@ -4206,7 +4201,14 @@
|
||||
acars: () => { if (acarsMainEventSource) { acarsMainEventSource.close(); acarsMainEventSource = null; } },
|
||||
vdl2: () => { if (vdl2MainEventSource) { vdl2MainEventSource.close(); vdl2MainEventSource = null; } },
|
||||
radiosonde: () => { if (radiosondeEventSource) { radiosondeEventSource.close(); radiosondeEventSource = null; } },
|
||||
aprs: () => { if (aprsEventSource) { aprsEventSource.close(); aprsEventSource = null; } },
|
||||
aprs: () => {
|
||||
if (typeof destroyAprsMode === 'function') {
|
||||
destroyAprsMode();
|
||||
} else if (aprsEventSource) {
|
||||
aprsEventSource.close();
|
||||
aprsEventSource = null;
|
||||
}
|
||||
},
|
||||
tscm: () => { if (tscmEventSource) { tscmEventSource.close(); tscmEventSource = null; } },
|
||||
meteor: () => typeof MeteorScatter !== 'undefined' && MeteorScatter.destroy?.(),
|
||||
ook: () => typeof OokMode !== 'undefined' && OokMode.destroy?.(),
|
||||
@@ -4331,8 +4333,10 @@
|
||||
activeScans: getActiveScanSummary(),
|
||||
});
|
||||
}
|
||||
// Let dedicated dashboards navigate immediately.
|
||||
// Pre-navigation stop requests from active modes like Pager
|
||||
// can stall same-tab navigation badly on some browsers.
|
||||
destroyCurrentMode();
|
||||
stopActiveLocalScansForNavigation();
|
||||
} catch (_) {
|
||||
// Ignore malformed hrefs.
|
||||
}
|
||||
@@ -4382,12 +4386,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
let modeSwitchRequestId = 0;
|
||||
|
||||
// Mode switching
|
||||
async function switchMode(mode, options = {}) {
|
||||
const requestId = ++modeSwitchRequestId;
|
||||
const { updateUrl = true } = options;
|
||||
const switchStartMs = performance.now();
|
||||
const previousMode = currentMode;
|
||||
if (mode === 'listening') mode = 'waterfall';
|
||||
if (mode === 'satellite') {
|
||||
window.open('/satellite/dashboard', '_blank', 'noopener');
|
||||
return;
|
||||
}
|
||||
if (!validModes.has(mode)) mode = 'pager';
|
||||
const styleReadyPromise = (typeof window.ensureModeStyles === 'function')
|
||||
? Promise.resolve(window.ensureModeStyles(mode)).catch((err) => {
|
||||
@@ -4453,6 +4464,7 @@
|
||||
const stopPhaseMs = Math.round(performance.now() - stopPhaseStartMs);
|
||||
await styleReadyPromise;
|
||||
await scriptReadyPromise;
|
||||
if (requestId !== modeSwitchRequestId) return;
|
||||
|
||||
// Generic module cleanup — destroy previous mode's timers, SSE, etc.
|
||||
if (previousMode && previousMode !== mode) {
|
||||
@@ -4461,6 +4473,7 @@
|
||||
try { destroyFn(); } catch(e) { console.warn(`[switchMode] destroy ${previousMode} failed:`, e); }
|
||||
}
|
||||
}
|
||||
if (requestId !== modeSwitchRequestId) return;
|
||||
|
||||
currentMode = mode;
|
||||
document.body.setAttribute('data-mode', mode);
|
||||
@@ -4476,6 +4489,7 @@
|
||||
// Sync with local status
|
||||
syncLocalModeStates();
|
||||
}
|
||||
if (requestId !== modeSwitchRequestId) return;
|
||||
|
||||
// Close dropdowns and update active state
|
||||
closeAllDropdowns();
|
||||
@@ -4564,9 +4578,27 @@
|
||||
if (btLayoutContainer) btLayoutContainer.classList.toggle('active', mode === 'bluetooth');
|
||||
if (satelliteVisuals) satelliteVisuals.style.display = mode === 'satellite' ? 'block' : 'none';
|
||||
const satFrame = document.getElementById('satelliteDashboardFrame');
|
||||
if (satFrame && satFrame.contentWindow) {
|
||||
if (satFrame && mode === 'satellite') {
|
||||
const baseSrc = satFrame.dataset.src || '/satellite/dashboard?embedded=true&v={{ version }}';
|
||||
const currentSrc = satFrame.getAttribute('src') || '';
|
||||
if (!currentSrc || currentSrc === 'about:blank') {
|
||||
satFrame.src = `${baseSrc}&ts=${Date.now()}`;
|
||||
}
|
||||
} else if (satFrame) {
|
||||
const currentSrc = satFrame.getAttribute('src') || '';
|
||||
if (currentSrc && currentSrc !== 'about:blank') {
|
||||
satFrame.src = 'about:blank';
|
||||
}
|
||||
}
|
||||
if (satFrame && satFrame.contentWindow && satFrame.getAttribute('src') && satFrame.getAttribute('src') !== 'about:blank') {
|
||||
satFrame.contentWindow.postMessage({type: 'satellite-visibility', visible: mode === 'satellite'}, '*');
|
||||
}
|
||||
|
||||
// Weather-sat handoff: when switching away from satellite mode, clear any pending handoff banner
|
||||
if (mode !== 'satellite' && mode !== 'weathersat') {
|
||||
const existing = document.getElementById('weatherSatHandoffBanner');
|
||||
if (existing) existing.remove();
|
||||
}
|
||||
if (aprsVisuals) aprsVisuals.style.display = mode === 'aprs' ? 'flex' : 'none';
|
||||
if (tscmVisuals) tscmVisuals.style.display = mode === 'tscm' ? 'flex' : 'none';
|
||||
if (spyStationsVisuals) spyStationsVisuals.style.display = mode === 'spystations' ? 'flex' : 'none';
|
||||
@@ -4789,6 +4821,7 @@
|
||||
} else if (mode === 'ook') {
|
||||
OokMode.init();
|
||||
}
|
||||
if (requestId !== modeSwitchRequestId) return;
|
||||
|
||||
// Waterfall destroy is now handled by moduleDestroyMap above.
|
||||
|
||||
@@ -9847,10 +9880,39 @@
|
||||
let aprsStationCount = 0;
|
||||
let aprsMeterLastUpdate = 0;
|
||||
let aprsMeterCheckInterval = null;
|
||||
let aprsClockInterval = null;
|
||||
const APRS_METER_TIMEOUT = 5000; // 5 seconds for "no signal" state
|
||||
|
||||
// APRS user location (from GPS)
|
||||
// APRS user location (from GPS or shared observer location)
|
||||
let aprsUserLocation = { lat: null, lon: null };
|
||||
|
||||
// Seed from configured observer location so the map centres on the
|
||||
// user's position even without a live GPS fix.
|
||||
(function _seedAprsLocation() {
|
||||
if (typeof ObserverLocation !== 'undefined' && ObserverLocation.getShared) {
|
||||
const shared = ObserverLocation.getShared();
|
||||
if (shared && aprsHasValidCoordinates(shared.lat, shared.lon)) {
|
||||
aprsUserLocation.lat = shared.lat;
|
||||
aprsUserLocation.lon = shared.lon;
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Fallback: read the Jinja-injected defaults directly
|
||||
const lat = Number(window.INTERCEPT_DEFAULT_LAT);
|
||||
const lon = Number(window.INTERCEPT_DEFAULT_LON);
|
||||
if (aprsHasValidCoordinates(lat, lon)) {
|
||||
aprsUserLocation.lat = lat;
|
||||
aprsUserLocation.lon = lon;
|
||||
}
|
||||
})();
|
||||
|
||||
// Listen for observer location changes from settings or other sources
|
||||
window.addEventListener('observer-location-changed', function(e) {
|
||||
if (e.detail && aprsHasValidCoordinates(e.detail.lat, e.detail.lon)) {
|
||||
updateAprsUserLocation({ latitude: e.detail.lat, longitude: e.detail.lon });
|
||||
}
|
||||
});
|
||||
|
||||
let aprsUserMarker = null;
|
||||
|
||||
// Calculate distance in miles using Haversine formula
|
||||
@@ -9962,12 +10024,65 @@
|
||||
});
|
||||
}
|
||||
|
||||
function createAprsFallbackGridLayer() {
|
||||
const layer = L.gridLayer({
|
||||
tileSize: 256,
|
||||
updateWhenIdle: true,
|
||||
attribution: 'Local fallback grid'
|
||||
});
|
||||
layer.createTile = function(coords) {
|
||||
const tile = document.createElement('canvas');
|
||||
tile.width = 256;
|
||||
tile.height = 256;
|
||||
const ctx = tile.getContext('2d');
|
||||
|
||||
ctx.fillStyle = '#08121c';
|
||||
ctx.fillRect(0, 0, 256, 256);
|
||||
|
||||
ctx.strokeStyle = 'rgba(0, 212, 255, 0.12)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, 0);
|
||||
ctx.lineTo(256, 0);
|
||||
ctx.moveTo(0, 0);
|
||||
ctx.lineTo(0, 256);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.06)';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(128, 0);
|
||||
ctx.lineTo(128, 256);
|
||||
ctx.moveTo(0, 128);
|
||||
ctx.lineTo(256, 128);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = 'rgba(160, 220, 255, 0.28)';
|
||||
ctx.font = '11px "JetBrains Mono", monospace';
|
||||
ctx.fillText(`Z${coords.z} X${coords.x} Y${coords.y}`, 12, 22);
|
||||
|
||||
return tile;
|
||||
};
|
||||
return layer;
|
||||
}
|
||||
|
||||
async function initAprsMap() {
|
||||
if (aprsMap) return;
|
||||
|
||||
const mapContainer = document.getElementById('aprsMap');
|
||||
if (!mapContainer) return;
|
||||
|
||||
// Refresh from ObserverLocation in case it changed since page load
|
||||
if (!aprsHasValidCoordinates(aprsUserLocation.lat, aprsUserLocation.lon) ||
|
||||
(aprsUserLocation.lat === 0 && aprsUserLocation.lon === 0)) {
|
||||
if (typeof ObserverLocation !== 'undefined' && ObserverLocation.getShared) {
|
||||
const shared = ObserverLocation.getShared();
|
||||
if (shared && aprsHasValidCoordinates(shared.lat, shared.lon)) {
|
||||
aprsUserLocation.lat = shared.lat;
|
||||
aprsUserLocation.lon = shared.lon;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use GPS location if available, otherwise default to center of US
|
||||
const gpsLat = Number(gpsLastPosition && gpsLastPosition.latitude);
|
||||
const gpsLon = Number(gpsLastPosition && gpsLastPosition.longitude);
|
||||
@@ -9981,13 +10096,8 @@
|
||||
aprsMap = L.map('aprsMap').setView([initialLat, initialLon], initialZoom);
|
||||
window.aprsMap = aprsMap;
|
||||
|
||||
// Add fallback tiles immediately so the map is visible instantly
|
||||
const fallbackTiles = L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||||
maxZoom: 19,
|
||||
subdomains: 'abcd',
|
||||
className: 'tile-layer-cyan'
|
||||
}).addTo(aprsMap);
|
||||
// Zero-network fallback so mode switches never block on external tiles.
|
||||
const fallbackTiles = createAprsFallbackGridLayer().addTo(aprsMap);
|
||||
|
||||
// Upgrade tiles in background via Settings (with timeout fallback)
|
||||
if (typeof Settings !== 'undefined') {
|
||||
@@ -10011,7 +10121,8 @@
|
||||
}
|
||||
|
||||
// Update time display (both map header and function bar)
|
||||
setInterval(() => {
|
||||
if (aprsClockInterval) clearInterval(aprsClockInterval);
|
||||
aprsClockInterval = setInterval(() => {
|
||||
const now = new Date();
|
||||
const timeStr = now.toLocaleTimeString('en-US', { hour12: false });
|
||||
const utcStr = now.toUTCString().slice(17, 25) + ' UTC';
|
||||
@@ -10024,6 +10135,31 @@
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function destroyAprsMode() {
|
||||
stopAprsMeterCheck();
|
||||
if (aprsEventSource) {
|
||||
aprsEventSource.close();
|
||||
aprsEventSource = null;
|
||||
}
|
||||
if (aprsPollTimer) {
|
||||
clearInterval(aprsPollTimer);
|
||||
aprsPollTimer = null;
|
||||
}
|
||||
if (aprsClockInterval) {
|
||||
clearInterval(aprsClockInterval);
|
||||
aprsClockInterval = null;
|
||||
}
|
||||
if (aprsMap) {
|
||||
try {
|
||||
aprsMap.remove();
|
||||
} catch (_) {}
|
||||
aprsMap = null;
|
||||
window.aprsMap = null;
|
||||
}
|
||||
aprsMarkers = {};
|
||||
aprsUserMarker = null;
|
||||
}
|
||||
|
||||
function updateAprsStatus(state, freq) {
|
||||
// Update function bar status
|
||||
const stripDot = document.getElementById('aprsStripDot');
|
||||
@@ -10717,26 +10853,51 @@
|
||||
// GPS FUNCTIONS (gpsd auto-connect)
|
||||
// ============================================
|
||||
|
||||
async function autoConnectGps() {
|
||||
// Automatically try to connect to gpsd on page load
|
||||
try {
|
||||
const response = await fetch('/gps/auto-connect', { method: 'POST' });
|
||||
const data = await response.json();
|
||||
function scheduleGpsAutoConnect(delayMs = 20000) {
|
||||
if (gpsConnected || gpsAutoConnectInFlight || gpsAutoConnectTimer) return;
|
||||
gpsAutoConnectTimer = setTimeout(() => {
|
||||
gpsAutoConnectTimer = null;
|
||||
autoConnectGps();
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
if (data.status === 'connected') {
|
||||
gpsConnected = true;
|
||||
startGpsStream();
|
||||
showGpsIndicator(true);
|
||||
console.log('GPS: Auto-connected to gpsd');
|
||||
if (data.position) {
|
||||
updateLocationFromGps(data.position);
|
||||
}
|
||||
} else {
|
||||
console.log('GPS: gpsd not available -', data.message);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('GPS: Auto-connect failed -', e.message);
|
||||
async function autoConnectGps() {
|
||||
if (gpsConnected) return true;
|
||||
if (gpsAutoConnectTimer) {
|
||||
clearTimeout(gpsAutoConnectTimer);
|
||||
gpsAutoConnectTimer = null;
|
||||
}
|
||||
if (gpsAutoConnectInFlight) {
|
||||
return gpsAutoConnectInFlight;
|
||||
}
|
||||
|
||||
gpsAutoConnectInFlight = (async () => {
|
||||
try {
|
||||
const response = await fetch('/gps/auto-connect', { method: 'POST' });
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'connected') {
|
||||
gpsConnected = true;
|
||||
startGpsStream();
|
||||
showGpsIndicator(true);
|
||||
console.log('GPS: Auto-connected to gpsd');
|
||||
if (data.position) {
|
||||
updateLocationFromGps(data.position);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log('GPS: gpsd not available -', data.message);
|
||||
return false;
|
||||
} catch (e) {
|
||||
console.log('GPS: Auto-connect failed -', e.message);
|
||||
return false;
|
||||
} finally {
|
||||
gpsAutoConnectInFlight = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return gpsAutoConnectInFlight;
|
||||
}
|
||||
|
||||
let gpsReconnectTimeout = null;
|
||||
@@ -10804,25 +10965,26 @@
|
||||
});
|
||||
|
||||
function updateLocationFromGps(position) {
|
||||
if (!position || !position.latitude || !position.longitude) {
|
||||
const lat = Number(position && position.latitude);
|
||||
const lon = Number(position && position.longitude);
|
||||
const fixQuality = Number(position && position.fix_quality);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
|
||||
return;
|
||||
}
|
||||
if (Number.isFinite(fixQuality) && fixQuality < 2) return;
|
||||
|
||||
// Update satellite observer location
|
||||
const satLatInput = document.getElementById('obsLat');
|
||||
const satLonInput = document.getElementById('obsLon');
|
||||
if (satLatInput) satLatInput.value = position.latitude.toFixed(4);
|
||||
if (satLonInput) satLonInput.value = position.longitude.toFixed(4);
|
||||
if (satLatInput) satLatInput.value = lat.toFixed(4);
|
||||
if (satLonInput) satLonInput.value = lon.toFixed(4);
|
||||
|
||||
// Update observerLocation
|
||||
observerLocation.lat = position.latitude;
|
||||
observerLocation.lon = position.longitude;
|
||||
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
|
||||
ObserverLocation.setShared({ lat: position.latitude, lon: position.longitude });
|
||||
}
|
||||
observerLocation.lat = lat;
|
||||
observerLocation.lon = lon;
|
||||
|
||||
// Update APRS user location
|
||||
updateAprsUserLocation(position);
|
||||
// Keep live GPS separate from the configured shared observer location.
|
||||
updateAprsUserLocation({ latitude: lat, longitude: lon });
|
||||
}
|
||||
|
||||
function showGpsIndicator(show) {
|
||||
@@ -11496,10 +11658,13 @@
|
||||
function fetchCelestrakCategory(category) {
|
||||
const status = document.getElementById('celestrakStatus');
|
||||
status.innerHTML = '<span style="color: var(--accent-cyan);">Fetching ' + category + '...</span>';
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 15000);
|
||||
|
||||
fetch('/satellite/celestrak/' + category)
|
||||
fetch('/satellite/celestrak/' + category, { signal: controller.signal })
|
||||
.then(r => r.json())
|
||||
.then(async data => {
|
||||
clearTimeout(timeout);
|
||||
if (data.status === 'success' && data.satellites) {
|
||||
const toAdd = data.satellites
|
||||
.filter(sat => !trackedSatellites.find(s => s.norad === String(sat.norad)))
|
||||
@@ -11544,8 +11709,10 @@
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
clearTimeout(timeout);
|
||||
const msg = err && err.message ? err.message : 'Network error';
|
||||
status.innerHTML = `<span style="color: var(--accent-red);">Import failed: ${msg}</span>`;
|
||||
const label = err && err.name === 'AbortError' ? 'Request timed out' : msg;
|
||||
status.innerHTML = `<span style="color: var(--accent-red);">Import failed: ${label}</span>`;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11555,7 +11722,7 @@
|
||||
.then(data => {
|
||||
if (data.status === 'success' && data.satellites) {
|
||||
trackedSatellites = data.satellites.map(sat => ({
|
||||
id: sat.name.replace(/[^a-zA-Z0-9]/g, '-').toUpperCase(),
|
||||
id: String(sat.norad_id),
|
||||
name: sat.name,
|
||||
norad: sat.norad_id,
|
||||
builtin: sat.builtin,
|
||||
@@ -11569,8 +11736,9 @@
|
||||
// Fallback to hardcoded defaults if API fails
|
||||
if (trackedSatellites.length === 0) {
|
||||
trackedSatellites = [
|
||||
{ id: 'ISS', name: 'ISS (ZARYA)', norad: '25544', builtin: true, checked: true },
|
||||
{ id: 'METEOR-M2', name: 'Meteor-M 2', norad: '40069', builtin: true, checked: true }
|
||||
{ id: '25544', name: 'ISS (ZARYA)', norad: '25544', builtin: true, checked: true },
|
||||
{ id: '57166', name: 'Meteor-M2-3', norad: '57166', builtin: true, checked: true },
|
||||
{ id: '59051', name: 'Meteor-M2-4', norad: '59051', builtin: true, checked: true }
|
||||
];
|
||||
renderSatelliteList();
|
||||
}
|
||||
@@ -14572,7 +14740,6 @@
|
||||
document.getElementById('tscmProgress').style.display = 'none';
|
||||
document.getElementById('tscmProgressLabel').textContent = 'Sweep Complete';
|
||||
document.getElementById('tscmProgressPercent').textContent = '100%';
|
||||
document.getElementById('tscmProgressBar').style.width = '100%';
|
||||
|
||||
// Final update of counts
|
||||
updateTscmThreatCounts();
|
||||
@@ -16100,40 +16267,6 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Check dependencies on page load
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// Check if user dismissed the startup check
|
||||
const dismissed = localStorage.getItem('depsCheckDismissed');
|
||||
|
||||
// Quick check for missing dependencies
|
||||
fetch('/dependencies')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'success') {
|
||||
let missingModes = 0;
|
||||
let missingTools = [];
|
||||
|
||||
for (const [modeKey, mode] of Object.entries(data.modes)) {
|
||||
if (!mode.ready) {
|
||||
missingModes++;
|
||||
mode.missing_required.forEach(tool => {
|
||||
if (!missingTools.includes(tool)) {
|
||||
missingTools.push(tool);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Show startup prompt if tools are missing and not dismissed
|
||||
// Only show if disclaimer has been accepted
|
||||
const disclaimerAccepted = localStorage.getItem('disclaimerAccepted') === 'true';
|
||||
if (missingModes > 0 && !dismissed && disclaimerAccepted) {
|
||||
showStartupDepsPrompt(missingModes, missingTools.length);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function showStartupDepsPrompt(modeCount, toolCount) {
|
||||
const notice = document.createElement('div');
|
||||
notice.id = 'startupDepsModal';
|
||||
@@ -16257,18 +16390,108 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PWA Service Worker Registration -->
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/static/sw.js').catch(() => {});
|
||||
});
|
||||
}
|
||||
// Initialize global core modules after page load
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
if (typeof VoiceAlerts !== 'undefined') VoiceAlerts.init();
|
||||
if (typeof VoiceAlerts !== 'undefined') {
|
||||
VoiceAlerts.init({ startStreams: false });
|
||||
VoiceAlerts.scheduleStreamStart(20000);
|
||||
}
|
||||
if (typeof KeyboardShortcuts !== 'undefined') KeyboardShortcuts.init();
|
||||
});
|
||||
|
||||
// ── Weather-satellite handoff from the satellite dashboard iframe/tab ─────
|
||||
function processWeatherSatHandoff(payload) {
|
||||
if (!payload || payload.type !== 'weather-sat-handoff') return;
|
||||
|
||||
const { satellite, aosTime, tcaEl, duration } = payload;
|
||||
if (!satellite) return;
|
||||
|
||||
// Determine how far away the pass is
|
||||
const aosMs = aosTime ? (new Date(aosTime) - Date.now()) : Infinity;
|
||||
const minsAway = aosMs / 60000;
|
||||
|
||||
// Switch to weather-satellite mode and pre-select the satellite
|
||||
switchMode('weathersat', { updateUrl: true }).then(() => {
|
||||
if (typeof WeatherSat !== 'undefined') {
|
||||
if (minsAway <= 2) {
|
||||
// Pass is imminent — start immediately
|
||||
WeatherSat.startPass(satellite);
|
||||
showNotification('Weather Sat', `Auto-starting capture: ${satellite}`);
|
||||
} else {
|
||||
// Pre-select so the user can review settings and hit Start
|
||||
WeatherSat.preSelect(satellite);
|
||||
showHandoffBanner(satellite, minsAway, tcaEl, duration);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function consumePendingWeatherSatHandoff() {
|
||||
let raw = null;
|
||||
try {
|
||||
raw = window.sessionStorage?.getItem('intercept.pendingWeatherSatHandoff')
|
||||
|| window.localStorage?.getItem('intercept.pendingWeatherSatHandoff');
|
||||
} catch (_) {
|
||||
raw = null;
|
||||
}
|
||||
if (!raw) return;
|
||||
|
||||
try {
|
||||
window.sessionStorage?.removeItem('intercept.pendingWeatherSatHandoff');
|
||||
window.localStorage?.removeItem('intercept.pendingWeatherSatHandoff');
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
processWeatherSatHandoff(JSON.parse(raw));
|
||||
} catch (err) {
|
||||
console.warn('Failed to consume weather-satellite handoff payload:', err);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
processWeatherSatHandoff(event.data);
|
||||
});
|
||||
|
||||
function showHandoffBanner(satellite, minsAway, tcaEl, duration) {
|
||||
// Remove any existing banner
|
||||
const existing = document.getElementById('weatherSatHandoffBanner');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const mins = Math.round(minsAway);
|
||||
const elStr = tcaEl != null ? `${Number(tcaEl).toFixed(0)}°` : '?°';
|
||||
const durStr = duration != null ? `${Math.round(duration)} min` : '';
|
||||
|
||||
const banner = document.createElement('div');
|
||||
banner.id = 'weatherSatHandoffBanner';
|
||||
banner.style.cssText = [
|
||||
'position:fixed', 'top:60px', 'left:50%', 'transform:translateX(-50%)',
|
||||
'background:rgba(0,20,30,0.95)', 'border:1px solid rgba(0,255,136,0.5)',
|
||||
'color:#00ff88', 'font-family:var(--font-mono,monospace)', 'font-size:12px',
|
||||
'padding:10px 18px', 'border-radius:6px', 'z-index:9999',
|
||||
'display:flex', 'align-items:center', 'gap:12px',
|
||||
'box-shadow:0 0 20px rgba(0,255,136,0.2)'
|
||||
].join(';');
|
||||
|
||||
banner.innerHTML = `
|
||||
<span>📡 <strong>${satellite}</strong> pass in <strong>${mins} min</strong> · max ${elStr}${durStr ? ' · ' + durStr : ''} — satellite pre-selected</span>
|
||||
<button onclick="if(typeof WeatherSat!=='undefined')WeatherSat.start();this.closest('#weatherSatHandoffBanner').remove();"
|
||||
style="background:rgba(0,255,136,0.2);border:1px solid rgba(0,255,136,0.5);color:#00ff88;padding:3px 10px;border-radius:4px;cursor:pointer;font-family:inherit;font-size:11px;">
|
||||
Start Now
|
||||
</button>
|
||||
<button onclick="this.closest('#weatherSatHandoffBanner').remove();"
|
||||
style="background:none;border:none;color:#666;cursor:pointer;font-size:14px;padding:0 4px;">✕</button>
|
||||
`;
|
||||
|
||||
document.body.appendChild(banner);
|
||||
|
||||
// Auto-dismiss after 2 minutes
|
||||
setTimeout(() => { if (banner.parentNode) banner.remove(); }, 120000);
|
||||
}
|
||||
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
setTimeout(consumePendingWeatherSatHandoff, 250);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
<rect x="38" y="76" width="24" height="4" rx="1" fill="var(--accent-cyan)"/>
|
||||
</svg>
|
||||
<span class="app-logo-text">
|
||||
<span class="app-logo-title">iNTERCEPT</span>
|
||||
<span class="app-logo-title"><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>
|
||||
<span class="app-logo-tagline">// See the Invisible</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user