Compare commits

..

391 Commits

Author SHA1 Message Date
Smittix 2bbf896e7c v2.26.5: fix database errors crashing entire UI (#190)
get_setting() now catches sqlite3.OperationalError and returns the
default value. Previously, an inaccessible database (e.g. root-owned
instance/ from sudo) caused inject_offline_settings to crash every
page render with 500 Internal Server Error.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 15:49:29 +00:00
Smittix faf57741a1 v2.26.4: fix Environment Configurator crash when .env variable missing (#191)
read_env_var() grep pipeline failed under set -euo pipefail when .env
existed but didn't contain the requested key. grep returned 1 (no match),
pipefail propagated it, and set -e killed the script.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 15:44:48 +00:00
Smittix fd7d01fc7d v2.26.3: fix SatDump AVX2 crash on older CPUs (#185)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 15:41:28 +00:00
Smittix 8ef9dca6ee fix(build): compile SatDump with baseline x86-64 to avoid AVX2 crashes (#185)
On x86_64, explicitly pass -march=x86-64 so the compiler emits only
baseline instructions. SatDump's SIMD plugins still compile with their
own per-target flags and do runtime CPU detection, so AVX2 acceleration
remains available on capable hardware. ARM builds are unaffected.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 20:58:59 +00:00
Smittix 4610804de6 v2.26.2: fix Docker startup crash — data/ package excluded by .dockerignore
The data/ directory became a Python package (oui.py, patterns.py, satellites.py)
in v2.26.0, but .dockerignore still blanket-excluded it as runtime data.
This caused ModuleNotFoundError: No module named 'data.oui' on container startup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:35:22 +00:00
Smittix 6d8836ddfc feat(docs): add branded 'i' logo to GitHub Pages site
Apply the branded SVG "i" glyph to nav logo, hero heading, and footer
on the GitHub Pages landing page, matching the main app's branding.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:51:42 +00:00
Smittix 17944554e6 v2.26.1: fix default admin credentials (admin:admin)
Patch release for #186 — default ADMIN_PASSWORD now matches README,
and credential changes in config.py sync to DB on restart.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:31:01 +00:00
Smittix 47a7376632 fix(auth): default admin password now matches README (admin:admin)
The default ADMIN_PASSWORD was an empty string, triggering random
password generation on first run — contradicting the README which
states admin:admin. Additionally, editing config.py after first run
had no effect since init_db() only seeded users on an empty table.

- Change default ADMIN_PASSWORD from '' to 'admin'
- Sync admin credentials from config on every startup so that
  changes to config.py or env vars take effect without wiping the DB

Fixes #186

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:30:04 +00:00
Smittix e00fbfddc1 v2.26.0: fix SSE fanout crash and branded logo FOUC
- Fix SSE fanout thread AttributeError when source queue is None during
  interpreter shutdown by snapshotting to local variable with null guard
- Fix branded "i" logo rendering oversized on first page load (FOUC) by
  adding inline width/height to SVG elements across 10 templates
- Bump version to 2.26.0 in config.py, pyproject.toml, and CHANGELOG.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:51:27 +00:00
Smittix 00362bcd57 fix(branding): bump "i" glyph size slightly in GitHub SVGs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:44:23 +00:00
Smittix fe42ca207c fix(branding): reduce "i" glyph size and fix baseline alignment in GitHub SVGs
Scale down the branded "i" to sit as a proper lowercase glyph beside
the uppercase "NTERCEPT" text, with the stem bottom on the baseline
and the dot just above cap height.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:43:29 +00:00
Smittix 612e137a60 fix(branding): align "i" glyph with text baseline in GitHub SVGs, add brand pack & wallpaper generator
Scale the branded "i" glyph proportionally to each SVG's font size
(scale 0.94 for 64px, 1.24 for 84px) and align the stem bottom to
the text baseline so the glyph sits naturally beside "NTERCEPT".

Also adds brand-pack.html (logos, profiles, banners, stickers, release
templates) and wallpapers.html (12 themes, 8 resolutions, PNG export).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:41:19 +00:00
Smittix 17913fc0e8 fix(branding): use branded "i" glyph in GitHub SVG assets
Replace the plain cyan text "i" with the logo-style SVG glyph (green dot
+ cyan stem/bars) in both the README banner and social preview images.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:29:10 +00:00
Smittix 44d256179b fix(branding): use inline SVG for branded "i" instead of CSS pseudo-element
The CSS ::after dot positioning was unreliable across fonts and sizes.
Switch to an inline SVG of the "i" glyph (green dot + cyan stem/bars)
extracted from the logo — renders pixel-perfect at any size.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:24:35 +00:00
Smittix 3c05429041 fix(branding): use regular "i" glyph with green dot overlay
The dotless i (ı) wasn't rendering in all fonts. Switch to a regular "i"
with the green dot CSS overlay positioned on top of the native dot.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:20:56 +00:00
Smittix 6727b95596 feat(branding): branded "i" with cyan stem and green dot across all titles
Matches the logo icon — the "i" in iNTERCEPT now renders with a cyan
letter and green dot via CSS, consistent across the main header, welcome
card, dashboard headers, help modal, settings modal, and all popout pages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:17:24 +00:00
Smittix 08b930d6e6 feat: add branded SVG assets and README banner
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:11:25 +00:00
Smittix 454a373874 chore: bump version to v2.25.0 — UI/UX overhaul release
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 21:38:51 +00:00
Smittix 90281b1535 fix(modes): deep-linked mode scripts fail when body not yet parsed
ensureModeScript() used document.body.appendChild() to load lazy mode
scripts, but the preload for ?mode= query params runs in <head> before
<body> exists, causing all deep-linked modes to silently fail.

Also fix cross-mode handoffs (BT→BT Locate, WiFi→WiFi Locate,
Spy Stations→Waterfall) that assumed target module was already loaded.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:49:08 +00:00
Smittix e687862043 feat: UI/UX overhaul — CSS cleanup, accessibility, error handling, inline style extraction
Phase 0 — CSS-only fixes:
- Fix --font-mono to use real monospace stack (JetBrains Mono, Fira Code, etc.)
- Replace hardcoded hex colors with CSS variables across 16+ files
- Merge global-nav.css (507 lines) into layout.css, delete original
- Reduce !important in responsive.css from 71 to 8 via .app-shell specificity
- Standardize breakpoints to 480/768/1024/1280px

Phase 1 — Loading states & SSE connection feedback:
- Add centralized SSEManager (sse-manager.js) with exponential backoff
- Add SSE status indicator dot in nav bar
- Add withLoadingButton() + .btn-loading CSS spinner
- Add mode section crossfade transitions

Phase 2 — Accessibility:
- Add aria-labels to icon-only buttons across mode partials
- Add for/id associations to 42 form labels in 5 mode partials
- Add aria-live on toast stack, enableListKeyNav() utility

Phase 3 — Destructive action guards & list overflow:
- Add confirmAction() styled modal, replace all 25 native confirm() calls
- Add toast cap at 5 simultaneous toasts
- Add list overflow indicator CSS

Phase 4 — Inline style extraction:
- Refactor switchMode() in app.js and index.html to use classList.toggle()
- Add CSS toggle rules for all switchMode-controlled elements
- Remove inline style="display:none" from 7+ HTML elements
- Add utility classes (.hidden, .d-flex, .d-grid, etc.)

Phase 5 — Mobile UX polish:
- pre/code overflow handling already in place
- Touch target sizing via --touch-min variable

Phase 6 — Error handling consistency:
- Add reportActionableError() to user-facing catch blocks in 5 mode JS files
- 28 error toast additions alongside existing console.error calls

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 13:04:36 +00:00
Smittix 05412fbfc3 fix(wifi_locate): read correct RSSI field from SSE network events
Backend sends rssi_current but frontend was reading net.signal || net.rssi,
causing RSSI to parse as NaN and silently skipping all meter/audio updates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:27:12 +00:00
Smittix aa787f0b53 fix(setup): skip apt for acarsdec, build from source directly (#183)
acarsdec is not available in apt repos, so the apt_install attempt
always failed with a confusing error message before falling through
to the source build. Skip the apt attempt and go straight to compiling
from source on Linux.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 18:06:06 +00:00
Smittix ab033b35d3 feat: WiFi Locate mode, mobile nav groups, v2.24.0
Add WiFi Locate mode for locating access points by BSSID with real-time
signal meter, distance estimation, RSSI history chart, and audio
proximity tones. Includes hand-off from WiFi detail drawer, environment
presets (Free Space/Outdoor/Indoor), and signal-lost detection.

Also includes:
- Mobile navigation reorganized into labeled groups (SIG/TRK/SPC/WIFI/INTEL/SYS)
- flask-limiter made optional with graceful degradation
- Fix radiosonde setup missing semver Python dependency
- Documentation updates (FEATURES, USAGE, UI_GUIDE, GitHub Pages site)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:49:03 +00:00
Smittix e383575c80 fix(ook): use process group kill for reliable stop
The OOK subprocess was spawned without start_new_session=True, so
process.terminate() only signalled the parent — child processes kept
running. Now uses os.killpg() to terminate the entire process group,
matching the pattern used by all other routes (ADS-B, AIS, ACARS, etc.).

Also fixes silent error swallowing in the frontend stop handler so the
UI resets even if the backend request fails.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 12:32:29 +00:00
Smittix fd12d11fab fix(setup): add timeout to rtl_test health check to prevent hangs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 08:59:08 +00:00
Smittix 0fbb446209 fix(airband): parse composite device value and send sdr_type to backend
The airband start function was calling parseInt() directly on composite
device selector values like "rtlsdr:0", which always returned NaN and
fell back to device 0. This also meant sdr_type was never sent to the
backend, and could result in int(None) TypeError on the server.

Now properly splits the composite value (matching ADS-B/ACARS/VDL2
pattern) and sends both device index and sdr_type. Also hardened
backend int() parsing to use explicit None checks.

Fixes: "Airband Error: Invalid parameter: int() argument must be a
string, a bytes-like object or a real number, not 'NoneType'"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 19:37:22 +00:00
Smittix 4ea64bd7ef Merge pull request #178 from thatsatechnique/main
feat: add Generic OOK Signal Decoder module
2026-03-06 21:58:43 +00:00
thatsatechnique 7d9a220230 fix(ook): replace innerHTML with createElement/textContent in appendFrameEntry
Addresses final upstream review — all backend-derived values (timestamp,
bit_count, rssi, hex, ascii) now use DOM methods instead of innerHTML
interpolation, closing the last XSS surface. Bumps cache-buster to ook2.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 12:55:07 -08:00
thatsatechnique 0afa15bb16 Merge remote-tracking branch 'upstream/main' 2026-03-06 12:47:51 -08:00
Smittix d66ab01d34 fix: prefer apt package for SatDump on Ubuntu 24.10+
SatDump v1.2.2 has multiple GCC 15 build failures (sol2 templates,
libacars incompatible pointer types) that are difficult to patch
exhaustively. On distros where SatDump is available as a system
package (Ubuntu 24.10+, Debian Trixie+), install via apt instead
of building from source. Falls back to source build on older systems.

Closes #180

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 17:26:08 +00:00
thatsatechnique 91989a0216 fix(ook): address Copilot review — stale process, XSS presets, localStorage
- Detect crashed rtl_433 process via poll() and clean up stale state
  instead of permanently blocking restarts with 409
- Replace innerHTML+onclick preset rendering with createElement/addEventListener
  to prevent XSS via crafted localStorage frequency values
- Normalize preset frequencies to toFixed(3) on save and render
- Add try/catch + shape validation to loadPresets() for corrupted localStorage

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:21:14 -08:00
thatsatechnique 7b4ad20805 fix(ook): address upstream PR review — SDR tracking, validation, cleanup, XSS
Critical:
- Pass sdr_type_str to claim/release_sdr_device (was missing 3rd arg)
- Add ook_active_sdr_type module-level var for proper device registry tracking
- Add server-side range validation on all timing params via validate_positive_int

Major:
- Extract cleanup_ook() function for full teardown (stop_event, pipes, process,
  SDR release) — called from both stop_ook() and kill_all()
- Replace Popen monkey-patching with module-level _ook_stop_event/_ook_parser_thread
- Fix XSS: define local _esc() fallback in ook.js, never use raw innerHTML
- Remove dead inversion code path in utils/ook.py (bytes.fromhex on same
  string that already failed decode — could never produce a result)

Minor:
- Status event key 'status' → 'text' for consistency with other modules
- Parser thread logging: debug → warning for missing code field and errors
- Parser thread emits status:stopped on exit (normal EOF or crash)
- Add cache-busting ?v={{ version }}&r=ook1 to ook.js script include
- Fix gain/ppm comparison: != '0' (string) → != 0 (number)

Tests: 22 → 33 (added start success, stop with process, SSE stream,
timing range validation, stopped-on-exit event)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:32:31 -08:00
Smittix a1b0616ee6 feat: add military/civilian classification filter to ADS-B history
Add client-side and server-side military aircraft detection using ICAO
hex ranges and callsign prefixes (matching live dashboard logic). History
table shows MIL/CIV badges with filtering dropdown, and exports respect
the classification filter.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:21:05 +00:00
Smittix a146a21285 feat: add ADS-B history filtering, export, and UI improvements
Add date range filtering, CSV export, and enhanced history page styling
for the ADS-B aircraft tracking history feature.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:54:55 +00:00
Smittix 87a5715f30 fix: add progress messages to dump1090 install flow (#177)
Users reported setup.sh appearing stuck during dump1090 installation on
Ubuntu 25.10. Added progress messages before APT package checks, build
dependency installation, and fallback clone steps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:54:49 +00:00
Smittix 52a28167c9 fix: SatDump build failure on GCC 15 (Ubuntu 25.10+)
Add -Wno-template-body to CMAKE_CXX_FLAGS to suppress GCC 15's
-Wtemplate-body warning that breaks SatDump's bundled sol2/sol.hpp.
The flag is silently ignored by older GCC versions.

Closes #180

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:27:54 +00:00
Smittix 1403d49049 fix: restore HackRF One/Pro detection when PATH is restricted 2026-03-05 09:31:21 +00:00
thatsatechnique 9090b415cc Merge remote-tracking branch 'upstream/main' 2026-03-04 14:54:56 -08:00
thatsatechnique 3f1606c38f Merge branch 'feature/ook-decoder' 2026-03-04 14:54:10 -08:00
thatsatechnique 18db66bce3 fix(ook): harden for upstream review — tests, cleanup, CSS extraction
- Add kill_all() handler for OOK process cleanup on global reset
- Fix stop_ook() to close pipes and join parser thread (prevents hangs)
- Add ook.css with CSS classes, replace inline styles in ook.html
- Register ook.css in lazy-load style map (INTERCEPT_MODE_STYLE_MAP)
- Fix frontend frequency min=24 to match backend validation
- Add 22 unit tests for decode_ook_frame, ook_parser_thread, and routes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 14:52:32 -08:00
Smittix 10077eee60 fix: HackRF One support — detection, ADS-B, waterfall, and error handling
- Parse hackrf_info stderr (newer firmware) and handle non-zero exit codes
- Fix gain_max from 62 to 102 (combined LNA 40 + VGA 62)
- Apply resolved readsb binary path for all SDR types, not just RTL-SDR
- Add HackRF/SoapySDR-specific error messages in ADS-B startup
- Add HackRF waterfall support via rx_sdr IQ capture + FFT
- Add 17 tests for HackRF detection and command builder

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:31:51 +00:00
Chris Brown 14568f8cc7 Merge pull request #7 from thatsatechnique/feature/ook-decoder
Feature/ook decoder
2026-03-04 14:31:32 -08:00
thatsatechnique 93fb694e25 fix(ook): address code review findings from Copilot PR review
- Fix XSS: escape ASCII output in innerHTML via escapeHtml()
- Fix deadlock: use put_nowait() for queue ops under ook_lock
- Fix SSE leak: add ook to moduleDestroyMap so switching modes
  closes the EventSource
- Fix RSSI: explicit null check preserves valid zero values in
  JSON export
- Add frame cap: trim oldest frames at 5000 to prevent unbounded
  memory growth on busy bands
- Validate timing params: wrap int() casts in try/except, return
  400 instead of 500 on invalid input
- Fix PWM hint: correct to short=0/long=1 matching rtl_433
  OOK_PWM convention (UI, JS hints, and cheat sheet)
- Fix inversion docstring: clarify fallback only applies when
  primary hex parse fails, not for valid decoded frames

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 14:29:55 -08:00
thatsatechnique cde24642ac feat(ook): add persistent frequency presets with add/remove/reset
Replace hardcoded frequency buttons with localStorage-backed presets.
Default presets are standard ISM frequencies (433.920, 315, 868, 915 MHz).
Users can add custom frequencies, right-click to remove, and reset to
defaults — matching the pager module pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 14:28:49 -08:00
thatsatechnique b4757b1589 feat(ook): add cheat sheet with modulation and timing guide
Covers identifying modulation type (PWM/PPM/Manchester), finding
pulse timing via rtl_433 -A, common ISM frequencies and timings,
and troubleshooting tips for tolerance and bit order.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 14:28:49 -08:00
thatsatechnique f771100a4c fix(ook): fix output panel layout, persist frames, wire global status bar
- Fix double-scroll by switching ookOutputPanel to flex layout
- Keep decoded frames visible after stopping (persist for review)
- Wire global Clear/CSV/JSON status bar buttons to OOK functions
- Hide default output pane in OOK mode (uses own panel)
- Add command display showing the active rtl_433 command
- Add JSON export and auto-scroll support
- Fix 0x prefix stripping in OOK hex decoder
- Fix PWM encoding hint text

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 14:28:49 -08:00
thatsatechnique 0c3ccac21c feat(ook): add timing presets, RSSI, bit-order suggest, pattern filter, TSCM link
- Timing presets: five quick-fill buttons (300/600, 300/900, 400/800, 500/1500, 500 MC)
  that populate all six pulse-timing fields at once — maps to CTF flag timing profiles
- RSSI per frame: add -M level to rtl_433 command; parse snr/rssi/level from JSON;
  display dB SNR inline with each frame; include rssi_db column in CSV export
- Auto bit-order suggest: "Suggest" button counts printable chars across all stored
  frames for MSB vs LSB, selects the winner, shows count — no decoder restart needed
- Pattern filter: live hex/ASCII filter input above the frame log; hides non-matching
  frames and highlights matches in green; respects current bit order
- TSCM integration: "Decode (OOK)" button in RF signal device details panel switches
  to OOK mode and pre-fills frequency — frontend-only, no backend changes needed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 14:28:49 -08:00
thatsatechnique 4c282bb055 feat: add Generic OOK Signal Decoder module
New 'OOK Decoder' mode for capturing and decoding arbitrary OOK/ASK
signals using rtl_433's flex decoder with fully configurable pulse
timing. Covers PWM, PPM, and Manchester encoding schemes.

Backend (utils/ook.py, routes/ook.py):
- Configurable modulation: OOK_PWM, OOK_PPM, OOK_MC_ZEROBIT
- Full rtl_433 flex spec builder with user-supplied pulse timings
- Bit-inversion fallback for transmitters with swapped short/long mapping
- Optional frame deduplication for repeated transmissions
- SSE streaming via /ook/stream

Frontend (static/js/modes/ook.js, templates/partials/modes/ook.html):
- Live MSB/LSB bit-order toggle — re-renders all stored frames instantly
  without restarting the decoder
- Full-detail frame display: timestamp, bit count, hex, dotted ASCII
- Modulation selector buttons with encoding hint text
- Full timing grid: short, long, gap/reset, tolerance, min bits
- CSV export of captured frames
- Global SDR device panel injection (device, SDR type, rtl_tcp, bias-T)

Integration (app.py, routes/__init__.py, templates/):
- Globals: ook_process, ook_queue, ook_lock
- Registered blueprint, nav entries (desktop + mobile), welcome card
- ookOutputPanel in visuals area with bit-order toolbar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 14:28:49 -08:00
thatsatechnique 4741124d94 Merge remote-tracking branch 'upstream/main' 2026-03-04 14:28:02 -08:00
Smittix 9afd99bf7c fix: add progress messages to setup.sh for long-running install steps
Users had no visibility into what was happening during silent apt/pip
installs. Added info messages before Python package installs, APT
package lists update, and PostgreSQL installation.
2026-03-04 21:18:39 +00:00
Smittix fef54e5276 Merge pull request #175 from thatsatechnique/fix/pager-display-classification
fix: improve pager message display and mute visibility
2026-03-04 18:00:33 +00:00
Smittix f62c9871c4 feat: rewrite setup.sh as menu-driven installer with profile system
Replace the linear setup.sh with an interactive menu-driven installer:
- First-time wizard with OS detection and profile selection
- Install profiles: Core SIGINT, Maritime, Weather, RF Security, Full, Custom
- System health check (tools, SDR devices, ports, permissions, venv, PostgreSQL)
- Automated PostgreSQL setup for ADS-B history (creates DB, user, tables, indexes)
- Environment configurator for interactive INTERCEPT_* variable editing
- Update tools (rebuild source-built binaries)
- Uninstall/cleanup with granular options and double-confirm for destructive ops
- View status table of all tools with installed/missing state
- CLI flags: --non-interactive, --profile=, --health-check, --postgres-setup, --menu
- .env file helpers (read/write) with start.sh auto-sourcing
- Bash 3.2 compatible (no associative arrays) for macOS support

Update all documentation to reflect the new menu system:
- README.md: installation section with profiles, CLI flags, env config, health check
- CLAUDE.md: entry points and local setup commands
- docs/index.html: GitHub Pages install cards with profile mentions
- docs/HARDWARE.md: setup script section with profile table
- docs/TROUBLESHOOTING.md: health check and profile-based install guidance
- docs/DISTRIBUTED_AGENTS.md: controller quick start

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 17:07:41 +00:00
Smittix 0e03b84260 chore: add data/radiosonde/ to .gitignore and remove duplicate section
Runtime data (station config, logs) should not be tracked in version control.
Also removes duplicate "Local data" block in .gitignore.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 11:32:51 +00:00
Smittix f73f3466fd fix: browser hangs when navigating from WeFax to ADS-B dashboard
SSE EventSources and running processes were not cleaned up during
dashboard navigation, saturating the browser's per-origin connection
limit. Extract moduleDestroyMap into shared getModuleDestroyFn() and
call destroyCurrentMode() before navigation. Also expand
stopActiveLocalScansForNavigation() to cover wefax, weathersat, sstv,
subghz, meshtastic, and gps modes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:35:33 +00:00
Smittix 8d91c200a5 fix: HackRF users get misleading RTL-SDR error in rtlamr/sstv/weather-sat modes
Several modes didn't pass sdr_type to claim_sdr_device(), defaulting to
'rtlsdr' and triggering an rtl_test USB probe that fails for HackRF with
a confusing "check that the RTL-SDR is connected" message.

- Add sdr_type to frontend start requests for rtlamr, weather-sat, sstv-general
- Read sdr_type in backend routes and pass to claim/release_sdr_device()
- Add early guard returning clear "not yet supported" error for non-RTL-SDR
  hardware in modes that are hardcoded to RTL-SDR tools
- Make probe_rtlsdr_device error message device-type-agnostic

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 07:52:13 +00:00
Chris Brown 9bf75a069e Merge pull request #6 from thatsatechnique/fix/pager-display-classification
fix: improve pager message display and mute visibility
2026-03-03 16:13:01 -08:00
ribs ec62cd9083 fix: prevent silent muting from hiding pager messages
The "Mute" button on pager cards persists muted addresses to
localStorage with no visible indicator, making it easy to
accidentally hide an address and forget about it. This caused
flag fragment messages on RIC 1337 to silently disappear.

- Add "X muted source(s) — Unmute All" indicator to sidebar
- Stop persisting hideToneOnly filter across sessions so the
  default (show all) always applies on page load
- Remove default checked state from Tone Only filter checkbox

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:05:01 -08:00
ribs 302b150c36 fix: strip CRLF from shell scripts during Docker build
Safety net for Windows developers whose git config (core.autocrlf=true)
converts LF to CRLF on checkout. Even with .gitattributes forcing eol=lf,
some git configurations can still produce CRLF working copies. The sed
pass after COPY ensures start.sh and other scripts always have Unix
line endings inside the container.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:05:01 -08:00
ribs cf022ed1c0 fix: add .gitattributes to enforce LF line endings for shell scripts
Docker containers crash on startup when shell scripts have CRLF line
endings (from Windows git checkout with core.autocrlf=true). The
start.sh gunicorn entrypoint fails with "$'\r': command not found".

Add .gitattributes forcing eol=lf for *.sh and Dockerfile so Docker
builds work regardless of the developer's git line ending config.
Also normalizes two scripts that were committed with CRLF.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:05:01 -08:00
ribs d3326409bf test: add unit tests for pager multimon-ng output parser
Cover all parse_multimon_output code paths:
- Alpha and Numeric content types across POCSAG baud rates
- Empty content and special characters (base64, punctuation)
- Catch-all pattern for non-standard content type labels
- Address-only (Tone) messages with trailing whitespace
- FLEX simple format and unrecognized input lines

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:05:01 -08:00
ribs 3de5e68e68 fix: improve pager message display and encryption classification
Three issues caused POCSAG messages to be incorrectly hidden or
misclassified in the Device Intelligence panel:

1. detectEncryption used a narrow character class ([a-zA-Z0-9\s.,!?-])
   to measure "printable ratio". Messages containing common printable
   ASCII characters like : = / + @ fell below the 0.8 threshold and
   returned null ("Unknown") instead of false ("Plaintext"). Simplified
   to check all printable ASCII (\x20-\x7E) which correctly classifies
   base64, structured data, and punctuation-heavy content.

2. The default hideToneOnly filter was true, hiding all address-only
   (Tone) pager messages. When RF conditions cause multimon-ng to decode
   the address but not the message content, the resulting Tone card was
   silently filtered. Changed default to false so users see all traffic
   and can opt-in to filtering.

3. The multimon-ng output parser only recognized "Alpha" and "Numeric"
   content type labels. Added a catch-all pattern to capture any
   additional content type labels that future multimon-ng versions or
   forks might emit, rather than dropping them to raw output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:05:01 -08:00
Smittix 325dafacbc fix: improve startup error reporting with full stderr logging and dependency pre-check
Radiosonde route now runs a quick import check before launching the full
subprocess, catching missing Python dependencies immediately with a clear
message instead of a truncated traceback. Error messages are context-aware:
import errors suggest re-running setup.sh rather than checking SDR connections.

Increased stderr truncation limit from 200 to 500 chars and added full stderr
logging via logger.error() across all affected routes (radiosonde, ais, aprs,
acars, vdl2) for easier debugging.

Closes #173

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 20:46:14 +00:00
Smittix 2f5f429e83 fix: airband start crash when device selector not yet populated
When the /devices fetch hasn't completed or fails, parseInt on an empty
select returns NaN which JSON-serializes to null. The backend then calls
int(None) and raises TypeError. Fix both layers: frontend falls back to
0 on NaN, backend uses `or` defaults so null values don't bypass the
fallback.

Also adds a short TTL cache to detect_all_devices() so multiple
concurrent callers on the same page load don't each spawn blocking
subprocess probes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 18:56:38 +00:00
Smittix fb4482fac7 fix: setup.sh flask-sock install failures on Debian 13 / RPi (#170)
Three compounding bugs prevented flask-sock (and other C-extension
packages) from installing and hid the actual errors:

- Add python3-dev to Debian apt installs so Python.h is available for
  building gevent, cryptography, etc.
- Remove 2>/dev/null from optional packages pip loop so install errors
  are visible and diagnosable
- Surface pip/setuptools/wheel upgrade failures with a warning instead
  of silently swallowing them

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 18:32:32 +00:00
Smittix 32f04d4ed8 fix: morse decoder splitting dahs into dits due to mid-element signal dropout
Add dropout tolerance (2 blocks ~40ms) to bridge brief signal gaps that
caused the state machine to chop dahs into multiple dits. Also fix scope
SNR display to use actual noise_ref instead of noise_floor.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 13:24:40 +00:00
Smittix 38644bced6 fix: replace 100+ hardcoded colors with CSS variables for light theme
Add theme-aware severity/neon CSS variables and replace hardcoded hex
colors (#fff, #000, #00ff88, #ffcc00, etc.) with var() references
across 26 files so text remains readable in both dark and light themes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 11:35:17 +00:00
Smittix f3d475d53a fix: light theme nav labels, run-state chips, and buttons
Nav active labels used color: var(--bg-primary) which resolved to
near-white on light backgrounds. Run-state chips and buttons had
hardcoded dark RGBA backgrounds. Added light-theme overrides for
readable text and appropriate light backgrounds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 10:58:21 +00:00
Smittix 195c224189 fix: stop satellite position polling when mode is inactive
Use postMessage from parent page to notify the satellite dashboard
iframe of visibility changes, preventing unnecessary POST requests
to /satellite/position when the user isn't viewing satellite mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 10:50:32 +00:00
Smittix f07ec23da9 feat: add space weather image prefetch and stable cache-busting
Backend: Add /prefetch-images endpoint that warms the image cache in
parallel using a thread pool, skipping already-cached images.

Frontend: Trigger prefetch on mode init so images load instantly.
Replace per-request Date.now() cache-bust with a 5-minute rotating
key to allow browser caching aligned with backend max-age.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 22:21:55 +00:00
Smittix 4b64862eb4 fix: release SDR device when switching modes across pages
When navigating from another mode (e.g. pager) to the ADS-B dashboard,
the old process could still hold the USB device. Two fixes:

1. routes/adsb.py: If dump1090 starts but SBS port never comes up,
   kill the process and return a DEVICE_BUSY error instead of silently
   claiming success with no data.

2. templates/adsb_dashboard.html: Pre-flight conflict check in
   toggleTracking() queries /devices/status and auto-stops any
   conflicting mode before starting ADS-B, with a 1.5s USB release
   delay.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 22:21:30 +00:00
Smittix eea44f9a6b fix: move meteor scatter start button below antenna guide
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 22:02:15 +00:00
Smittix de3f972aa2 fix: detect bias-t support before passing -T to rtl_sdr/rtl_fm
Stock rtl-sdr packages don't support the -T bias-tee flag (only
RTL-SDR Blog builds do). Passing -T to stock rtl_sdr causes an
immediate exit, breaking meteor scatter and waterfall modes.

Now probes the tool's --help output before adding -T, with a regex
that avoids false-matching "DVB-T" in the description text.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 21:52:54 +00:00
Smittix 6a334c61df fix: resolve meteor WebSocket race condition and setup apt-get failure
Meteor: onopen callback used closure variable _ws instead of `this`,
so a double-click during CONNECTING state sent on the wrong socket.
Also clean up any in-progress connection on re-start, not just running ones.

Setup: make apt-get update non-fatal so third-party repo errors
(e.g. stale PPAs on Debian) don't abort the entire install.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 21:48:56 +00:00
Smittix 63994ec1d4 fix: suppress double monkey-patch warnings and fork hook assertions
Match gunicorn's patch_all() args exactly (remove subprocess=False),
filter the MonkeyPatchWarning from the unavoidable double-patch, and
wrap gevent's _ForkHooks.after_fork_in_child to catch the spurious
AssertionError that fires when subprocesses fork after double-patching.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 21:26:55 +00:00
Smittix 6011d6fb41 fix: apply gevent monkey-patch in post_fork to prevent ARM worker deadlock
Gunicorn's gevent worker deadlocks during init_process() on Raspberry Pi
(ARM) before it can apply its own monkey-patching. Patching in post_fork
runs immediately after fork and before worker init, avoiding the race.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 21:24:04 +00:00
Smittix 845629ea46 feat: enhance Meteor Scatter with sidebar fixes and visual effects
Move SDR Device below mode title, add sidebar Start/Stop buttons,
and add starfield canvas, meteor streak animations, particle bursts,
signal strength meter, and enhanced ping flash effects.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 21:04:35 +00:00
Smittix 7311dd10ab feat: add Meteor Scatter mode for VHF beacon ping detection
Full-stack meteor scatter monitoring mode that captures IQ data from
an RTL-SDR, computes FFT waterfall frames via WebSocket, and runs a
real-time detection engine to identify transient VHF reflections from
meteor ionization trails (e.g. GRAVES radar at 143.050 MHz).

Backend: MeteorDetector with EMA noise floor, SNR threshold state
machine (IDLE/DETECTING/ACTIVE/COOLDOWN), hysteresis, and CSV/JSON
export. WebSocket at /ws/meteor for binary waterfall frames, SSE at
/meteor/stream for detection events and stats.

Frontend: spectrum + waterfall + timeline canvases, event table with
SNR/duration/confidence, stats strip, turbo colour LUT. Uses shared
SDR device selection panel with conflict tracking.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 20:38:15 +00:00
Smittix e2e92b6b38 fix: cache ISS position/schedule and parallelize SSTV init API calls
SSTV mode was slow to populate next-pass countdown and ISS location map
due to uncached skyfield computation and sequential JS API calls.

- Cache ISS position (10s TTL) and schedule (15min TTL, keyed by rounded lat/lon)
- Cache skyfield timescale object (expensive to create on every request)
- Reduce external API timeouts from 5s to 3s
- Fire checkStatus, loadImages, loadIssSchedule, updateIssPosition in parallel via Promise.all

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 18:05:57 +00:00
Smittix 5534493bd1 fix: parallelize space weather API fetches to reduce cold-cache latency
The /space-weather/data endpoint made 13 sequential HTTP requests, each
with a 15s timeout, causing 30-195s load times on cold cache. Now uses
ThreadPoolExecutor to fetch all sources concurrently, reducing worst-case
latency to ~15s (single slowest request).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 17:51:31 +00:00
Smittix 86fa6326e9 fix: prevent radiosonde strip from stretching in flex column layout
Add flex-shrink: 0 so the strip holds its intrinsic height instead of
being distorted by the parent flex container.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 17:30:27 +00:00
Smittix be70d2e43b feat: move radiosonde status display to main pane stats strip
Move tracking state, balloon count, last update, and waveform from the
sidebar into a stats strip above the map, matching the APRS strip pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 16:58:16 +00:00
Smittix e89a0ef486 fix: pass config file path (not directory) to radiosonde_auto_rx -c flag
Reverts the incorrect assumption from f8e5d61 that -c expects a
directory. The auto_rx -c flag expects the full path to station.cfg.
Passing the directory caused "Config file ... does not exist!" on start.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 16:35:25 +00:00
Smittix bcf447fe4e fix: prevent root-owned data files from breaking radiosonde start
Running via sudo creates data/radiosonde/ as root. On next run the
config write fails with an unhandled OSError, Flask returns an HTML 500,
and the frontend shows a cryptic JSON parse error.

Three-layer fix:
- start.sh: pre-create known data dirs before chown, add certs/ to the
  list, export INTERCEPT_SUDO_UID/GID for runtime use
- generate_station_cfg: catch OSError with actionable message, chown
  newly created files to the real user via _fix_data_ownership()
- start_radiosonde: wrap config generation in try/except so it returns
  JSON instead of letting Flask emit an HTML error page

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 16:27:20 +00:00
Smittix 90b455aa6c feat: add signal activity waveform component for radiosonde mode
Reusable SVG bar waveform (SignalWaveform.Live) that animates in response
to incoming SSE data — idle breathing when stopped, active oscillation
proportional to telemetry update frequency, smooth decay on signal loss.

Integrated into radiosonde Status section with ping() on each balloon
message and stop() on tracking stop. Also hardens the fetch error path
to show a readable message instead of a JSON parse error when the server
returns HTML.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 16:27:09 +00:00
Smittix f8e5d61fa9 fix: pass config directory (not file path) to radiosonde_auto_rx -c flag
The -c flag expects a directory containing station.cfg, but we were
passing the full file path, so auto_rx could never find its config.
Also fix sonde_type priority to prefer subtype over type.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 15:12:30 +00:00
Smittix bd67195238 fix: apply light theme to sidebar, nav, and visual refresh components (#168)
The visual refresh layer hardcoded dark rgba() gradients that overrode
variable-based backgrounds. Added [data-theme="light"] overrides for
visual refresh CSS variables and comprehensive component backgrounds
in index.css and global-nav.css.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 14:28:11 +00:00
Smittix d78ab5cc2c fix: install flask-sock individually to surface failures (#170)
Move flask-sock and websocket-client from the batch core install (where
failures are silently swallowed) to the optional packages loop so users
see a clear warning if either package fails to build on ARM.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 14:06:33 +00:00
Smittix d087780d9f fix: WeFax sidebar minor GUI issues (#167)
Remove section hover shift, fix broken NOAA PDF link, reorder sections
to match Weather Satellite pattern, and fix text alignment spacing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 13:48:42 +00:00
Smittix 8379f42ec3 fix: close leaked file descriptors on mode switch (#169)
SSE EventSource connections for AIS, ACARS, VDL2, and radiosonde were
not closed when switching modes, causing fd exhaustion after repeated
switches. Also fixes socket leaks on exception paths in AIS/ADS-B
stream parsers, closes subprocess pipes in safe_terminate/cleanup, and
caches skyfield timescale at module level to avoid per-request fd churn.

Closes #169

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 13:38:21 +00:00
Smittix ff9961b846 fix: add missing METEOR-M2-4 TLE data for pass predictions
METEOR-M2-4 was defined as an active weather satellite but had no
orbital data, so pass predictions always returned empty. Added TLE
entry and CelesTrak name mapping for automatic refresh.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 11:45:20 +00:00
Smittix 5e99d19165 fix: suppress noisy SSL handshake errors in gunicorn logs
Add SSLZeroReturnError and SSLError to gevent's NOT_ERROR list so
dropped TLS handshakes (browser preflight, plain HTTP to HTTPS port)
don't print scary tracebacks to the console.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:39:52 +00:00
Smittix 0df412c014 feat: show LAN address instead of 0.0.0.0 in start.sh output
Resolves the machine's LAN IP via hostname -I so users see a
clickable URL they can use from other devices on the network.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:10:44 +00:00
Smittix e756a00cc9 fix: add proactive DB writability check before init_db writes
sqlite3.connect() opens read-only files without error — the failure
only surfaces on the first write (INSERT). Add an upfront os.access()
check on both the directory and file, with a clear error showing the
owner and the exact chown command to fix it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:03:11 +00:00
Smittix c35131462e fix: prevent root-owned database files when running with sudo
When start.sh runs via sudo, chown instance/ and data/ back to the
invoking user so the SQLite DB stays accessible without sudo. Also
adds a clear error message in get_connection() when the DB can't be
opened due to permissions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 09:59:41 +00:00
Smittix bad637591a fix: replace duplicate libfftw3-dev with libfftw3-bin for SatDump runtime
The FFTW3 dev package was listed twice in the build stage and both
copies were removed during cleanup, taking the runtime .so with them.
Switching the duplicate to libfftw3-bin ensures libfftw3f.so.3 persists.

Fixes #166

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 09:30:09 +00:00
Smittix 910b69594d Merge pull request #145 from mitchross/main
All review issues addressed. Merging with fixup commit for XSS escaping, import cleanup, VDL2 click behavior, frequency defaults, and misc fixes.
2026-03-01 20:42:50 +00:00
Smittix a154601e86 fix: address PR #145 review issues
- Escape ac.icao, callsign, typeCode with escapeHtml() in aircraft card (XSS)
- Add linking comments between duplicated IATA_TO_ICAO mappings
- VDL2 sidebar: single-click selects aircraft, double-click opens modal
- Remove stale ICAOs from acarsAircraftIcaos in cleanupOldAircraft()
- Add null guard to drawPolarPlot() in weather-satellite.js
- Move deferred imports (translate_message, get_flight_correlator) to module level
- Check all frequency checkboxes by default on initial load
- Remove extra blank lines and uncertain MC/MCO airline code entry
- Add TODO comments linking duplicated renderAcarsCard implementations

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 20:42:14 +00:00
Smittix bdeb32e723 feat: add rtl_tcp remote SDR support to weather satellite decoder
Extends the rtl_tcp support (added in c1339b6 for APRS, Morse, DSC) to
the weather satellite mode. When a remote SDR host is provided, SatDump
uses --source rtltcp instead of --source rtlsdr, local device claiming
is skipped, and the frontend sends rtl_tcp params via getRemoteSDRConfig().

Closes #166

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 16:55:04 +00:00
Smittix 2de592f798 fix: suppress noisy gevent SystemExit traceback on shutdown
When stopping gunicorn with Ctrl+C, the gevent worker's handle_quit()
calls sys.exit(0) inside a greenlet, causing gevent to print a
SystemExit traceback. Add a gunicorn config with post_worker_init hook
that marks SystemExit as a non-error in gevent's hub.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 21:31:01 +00:00
Mitch Ross b5c3d71247 Merge branch 'smittix:main' into main 2026-02-28 16:30:56 -05:00
Smittix c1339b6c65 feat: add rtl_tcp remote SDR support to aprs, morse, and dsc routes
Closes #164. Only pager and sensor routes supported rtl_tcp connections.
Now aprs, morse, and dsc routes follow the same pattern: extract
rtl_tcp_host/port from the request, skip local device claiming for
remote connections, and use SDRFactory.create_network_device(). DSC also
refactored from manual rtl_fm command building to use SDRFactory's
builder abstraction. Frontend wired up for all three modes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 19:21:28 +00:00
Smittix 153aacba03 fix: defer heavy init so gunicorn worker serves requests immediately
Blueprint registration and database init run synchronously (essential
for routing). Process cleanup, database cleanup scheduling, and TLE
satellite updates are deferred to a background thread with a 1-second
delay so the gevent worker can start serving HTTP requests right away.

Previously all init ran synchronously during module import, blocking
the single gevent worker for minutes while TLE data was fetched from
CelesTrak.

Also removes duplicate TLE update — init_tle_auto_refresh() already
schedules its own background fetch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:59:46 +00:00
Smittix bcbadac995 docs: add sudo to start.sh usage comments and USAGE.md examples
All other docs already reference sudo ./start.sh but the inline usage
comments in start.sh itself and the --help example in USAGE.md were
missing it, which could lead users to run without root privileges.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:51:02 +00:00
Smittix a6e62f4674 fix: skip custom signal handlers when running under gunicorn
The custom SIGINT/SIGTERM handler in utils/process.py overrode
gunicorn's own signal management, causing KeyboardInterrupt to fire
inside the gevent worker on Ctrl+C instead of allowing gunicorn's
graceful shutdown. Now detects if another signal manager (gunicorn)
has already installed handlers and defers to it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:48:32 +00:00
Smittix 77255e015d fix: register blueprints at module level for gunicorn compatibility
Blueprint registration, database init, cleanup, and websocket setup
were all inside main() which only runs via 'python intercept.py'.
When gunicorn imports app:app, it got a bare Flask app with no routes,
causing every endpoint to return 404.

Extracted initialization into _init_app() called at module level with
a guard to prevent double-init when main() is also used.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:46:44 +00:00
Smittix 6cbe94cf20 fix: restore flask-limiter as mandatory dependency
Rate limiting on login is a security requirement, not optional.
Reverts the no-op fallback — if flask-limiter is missing, the app
will fail fast with a clear import error rather than silently
running without rate limiting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:35:46 +00:00
Smittix cdf10e1d6a fix: add missing psutil to setup.sh and relax flask-limiter check
- psutil was in requirements.txt but missing from setup.sh optional list
- Verification check no longer hard-fails on flask-limiter since app.py
  now handles it as optional with a no-op fallback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:34:39 +00:00
Smittix a22244a041 fix: make flask-limiter import optional to prevent worker boot failure
flask-limiter may not be installed (e.g. RPi venv). The hard import
crashed the gunicorn gevent worker on startup, causing all routes to
return 404 with no visible error. Now falls back to a no-op limiter.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:29:10 +00:00
Smittix 9371fccd62 fix: add graceful-timeout to gunicorn so Ctrl+C shuts down promptly
Long-lived SSE connections prevent the gevent worker from exiting on
SIGINT. --graceful-timeout 5 force-kills the worker after 5 seconds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:25:26 +00:00
Smittix b4b6fdc0fc fix: remove manual gevent monkey-patch that blocked gunicorn worker boot
Gunicorn's gevent worker (-k gevent) handles monkey-patching internally.
The manual patch_all() in app.py ran in the master process before worker
fork, preventing the worker from booting (no 'Booting worker' log line,
server unreachable).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:24:22 +00:00
Smittix 9e3fcb8edd fix: use venv Python in start.sh instead of bare python
Resolves ModuleNotFoundError when running outside a venv by auto-detecting
the venv/bin/python relative to the script, falling back to VIRTUAL_ENV
or system python3.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:22:31 +00:00
Smittix 2c7909e502 fix: convert start.sh line endings from CRLF to LF
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:21:27 +00:00
Smittix 003c4d62cf feat: add gunicorn + gevent production server via start.sh
Add start.sh as the recommended production entry point with:
- gunicorn + gevent worker for concurrent SSE/WebSocket handling
- CLI flags for port, host, debug, HTTPS, and dependency checks
- Auto-fallback to Flask dev server if gunicorn not installed
- Conditional gevent monkey-patch in app.py via INTERCEPT_USE_GEVENT env var
- Docker CMD updated to use start.sh
- Updated all docs, setup.sh, and requirements.txt accordingly

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:18:40 +00:00
Smittix 10e4804e0a fix: bluetooth no results, audio waveform leak, and mode switch cleanup
- Change 'already_running' to 'already_scanning' status in bluetooth_v2
  so frontend recognizes the response and connects the SSE stream
- Hide pagerScopePanel and sensorScopePanel in switchMode() to prevent
  audio waveform bars leaking into other modes
- Clear devices Map, pendingDeviceIds Set, and UI in BluetoothMode.destroy()
  to prevent memory accumulation on repeated mode switches

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:16:55 +00:00
Smittix 05edfb93dc fix: parse actual board name from hackrf_info for HackRF Pro support
Previously all HackRF devices were hardcoded as "HackRF One" regardless
of actual hardware variant. Now parses the Board ID line from hackrf_info
to correctly identify HackRF Pro, HackRF One, and other variants.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 14:18:30 +00:00
Smittix e5006a9896 chore: release v2.23.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 20:38:35 +00:00
Smittix 7d1fcfe895 feat: add station location and distance tracking to radiosonde mode
- Pass observer location and gpsd status to radiosonde_auto_rx station config
- Add station marker on radiosonde map with GPS live position updates
- Display distance from station to each balloon in cards and popups
- Update aircraft database

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 19:49:58 +00:00
Smittix c6e8602184 Merge pull request #160 from thatsatechnique/main
feat: add OOK/AM envelope detection mode to Morse decoder
2026-02-27 19:49:10 +00:00
mitchross 29873fb3c0 Merge upstream/main and resolve acars, vdl2, dashboard conflicts
Resolved conflicts:
- routes/acars.py: keep /messages and /clear endpoints for history reload
- routes/vdl2.py: keep /messages and /clear endpoints for history reload
- templates/adsb_dashboard.html: keep removal of hardcoded device-1
  defaults for ACARS/VDL2 selectors (users pick their own device)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 14:47:57 -05:00
Smittix 4f096c6c01 perf: add destroy() lifecycle to all mode modules to prevent resource leaks
Mode modules were leaking EventSource connections, setInterval timers,
and setTimeout timers on every mode switch, causing progressive browser
sluggishness. Added destroy() to 8 modules missing it (meshtastic,
bluetooth, wifi, bt_locate, sstv, sstv-general, websdr, spy-stations)
and centralized all destroy calls in switchMode() via a moduleDestroyMap
that cleanly tears down only the previous mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 19:18:13 +00:00
Chris Brown 9e911e845f Merge pull request #4 from thatsatechnique/feature/morse-ook-envelope
feat: add OOK/AM envelope detection mode to Morse decoder
2026-02-27 10:42:17 -08:00
ribs 377519fd95 feat: add OOK/AM envelope detection mode to Morse decoder
Re-implements envelope detection on top of the rewritten Morse decoder.
Addresses PR #160 review feedback:
- Rebase: rebuilt on current upstream/main (lifecycle state machine)
- Gap thresholds: 2.0/5.0 for envelope only; goertzel keeps 2.6/6.0
- Frequency validation: max_mhz=1766 for envelope, 30 for goertzel
- Tests: EnvelopeDetector unit tests + envelope-mode decoder test
- Envelope uses direct magnitude threshold (no SNR/noise ref)
- Goertzel path completely unchanged

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 10:35:56 -08:00
Smittix fb064a22fb fix: add delay after probe to prevent USB claim race with dump1090
rtl_test opens the USB device during probing. After killing the
process, the kernel may not release the USB interface immediately.
dump1090 then fails with usb_claim_interface error -6. Add a 0.5s
delay after probe cleanup to allow the kernel to fully release the
device before the actual decoder opens it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 14:44:17 +00:00
Smittix 7af6d45ca1 fix: probe return code check incorrectly blocks valid devices
rtl_test -t often exits non-zero after finding a device (e.g.
"No E4000 tuner found, aborting" with R820T tuners). The return
code fallback was firing even when the "Found N device(s)" success
message had already been matched. Track device_found separately
and only use return code as fallback when no success was seen.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 14:39:39 +00:00
Smittix 54987e4c8d fix: ADS-B probe incorrectly treats "No devices found" as success
The success check ('Found' in line and 'device' in line) matched
"No supported devices found" since both keywords appear. Add a
pre-check for negative device messages, a return code fallback,
and a clearer error message.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 14:35:28 +00:00
Smittix 7683a925df fix: update radiosonde stop UI immediately on click
The stop button appeared unresponsive because UI updates waited for the
server response. If the fetch hung or errored, the user saw nothing.
Now the UI updates immediately (matching the pager stop pattern) and
the server request happens in the background.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 12:18:54 +00:00
Smittix 824514d922 fix: use complete station.cfg with all required fields for auto_rx v1.8+
Auto_rx reads many config keys without defaults and crashes if they're
missing, even for disabled features like email. Include every section
and key from the example config to prevent missing-key errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 11:31:41 +00:00
Smittix 79a0dae04b fix: rewrite radiosonde station.cfg to match auto_rx v1.8+ format
The config format changed significantly: SDR settings moved to [sdr_1],
[positioning] became [location], and many sections are now required.
Also enable payload_summary UDP output so telemetry reaches our listener.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 11:29:53 +00:00
Smittix e176438934 fix: radiosonde config path and dependency detection
- Pass config file path (not directory) to auto_rx -c flag
- Use absolute paths in generated station.cfg since auto_rx runs
  with cwd set to its install directory
- Teach dependency checker about auto_rx.py at /opt install path
  so the "missing dependency" banner no longer appears

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 11:27:16 +00:00
Smittix 3254d82d11 fix: re-run radiosonde install when C decoders are missing
The setup.sh skip check only looked for auto_rx.py, so a previous
incomplete install (Python files but no compiled binaries) would be
treated as fully installed. Now also checks for dft_detect binary.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 11:21:50 +00:00
Smittix 24d50c921e fix: build radiosonde_auto_rx C decoders (dft_detect, fsk_demod, etc.)
setup.sh and Dockerfile were installing the Python package and copying
files but skipping the build.sh step that compiles the C decoders.
This caused "Binary dft_detect does not exist" at runtime.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 11:19:23 +00:00
Smittix db2f3fc8e5 fix: use sys.executable for radiosonde subprocess to find venv packages
The subprocess was launched with bare 'python' which on Debian doesn't
exist (python3 only) and wouldn't have access to the venv-installed
radiosonde dependencies anyway. Using sys.executable ensures the same
interpreter (with all installed packages) is used.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 11:17:19 +00:00
Smittix 952736c127 fix: set cwd for radiosonde subprocess so autorx package imports resolve
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 11:15:50 +00:00
Smittix 997dac3b9f fix: ADS-B device release leak and startup performance
Move adsb_active_device/sdr_type assignment to immediately after
claim_sdr_device so stop_adsb() can always release the device, even
during startup. Sync sdr_type_str after SDRType fallback to prevent
claim/release key mismatch. Clear active device on all error paths.

Replace blind 3s sleep for dump1090 readiness with port-polling loop
(100ms intervals, 3s max). Replace subprocess.run() in rtl_test probe
with Popen + select-based early termination on success/error detection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 11:13:44 +00:00
Smittix 3f6fa5ba28 fix: use project venv pip for radiosonde_auto_rx install
Avoids PEP 668 externally-managed-environment error on Debian Bookworm
by using the project's venv/bin/pip instead of system pip3.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 10:50:16 +00:00
Smittix 5b06c57565 feat: add radiosonde weather balloon tracking mode
Integrate radiosonde_auto_rx for automatic weather balloon detection and
decoding on 400-406 MHz. Includes UDP telemetry parsing, Leaflet map with
altitude-colored markers and trajectory tracks, SDR device registry
integration, setup script installation, and Docker support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 10:46:33 +00:00
Smittix 5aa68a49c6 fix: SDR device registry collision with multiple SDR types
The registry used plain int keys (device index), so HackRF at index 0
and RTL-SDR at index 0 would collide. Changed to composite string keys
("sdr_type:index") so each SDR type+index pair is tracked independently.
Updated all route callers, frontend device selectors, and session restore.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 09:06:41 +00:00
Smittix 0d13638d70 fix: APRS 15-minute startup delay caused by pipe buffering
Switch direwolf subprocess output from PIPE to PTY (pseudo-terminal),
forcing line-buffered output so packets arrive immediately instead of
waiting for a 4-8KB pipe buffer to fill. Matches the proven pattern
used by pager mode.

Also enhances direwolf config with FIX_BITS error correction and
disables unused AGWPE/KISS server ports.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 08:43:54 +00:00
Smittix f9dc54cc3b fix: globe init using requestAnimationFrame retry like GPS mode
The globe wasn't rendering because initGlobe() used setTimeout(100)
which can race with the display:none removal by switchMode(). Both
GPS and WebSDR modes use requestAnimationFrame to wait for the browser
to compute layout before initializing Globe.gl.

- Replace setTimeout with RAF-based retry loop (up to 8 frames)
- Add try-catch around Globe() init with fallback message
- Match the proven pattern from GPS and WebSDR modes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 00:04:28 +00:00
Smittix f679433ac0 fix: globe rendering, CPU sizing, manual location support
- Fix globe destroyed on re-render by preserving canvas DOM node across
  renderLocationCard() calls instead of recreating from scratch
- Reduce globe.gl camera minDistance (180->120) so globe is visible in
  200px container
- Clear stale globeInstance ref when canvas is gone
- Enlarge CPU gauge (90->110px), percentage label (18->22px), core bars
  (24->48px height), and detail text (11->12px)
- JS fetchLocation() now supplements server response with client-side
  ObserverLocation.getShared() from localStorage when server returns
  'default' or 'none', picking up manual coordinates from settings modal
- Location priority: GPS > config env vars > manual (localStorage) > default

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 00:00:51 +00:00
Smittix 4b31474080 fix: location fallback to constants, compact card sizing
- Add third location fallback to utils/constants (London 51.5074/-0.1278)
  so location always resolves even without GPS or env vars configured
- Remove min-height from sys-card to eliminate wasted space
- Switch System Info to vertical key-value layout filling the card
- Clean up OS string (strip glibc suffix), use locale date for boot time
- Bump info grid font size from 11px to 12px for readability

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 23:55:36 +00:00
Smittix f72b43c6bf fix: use GPS utility for location, compact dashboard layout
Replace broken app.gps_state lookup with utils.gps.get_current_position()
and return GPS metadata (fix quality, satellites, accuracy). Shrink location
card to single-column with 200px globe, move System Info into row 2.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 23:51:26 +00:00
Smittix 0a90010c1f feat: enhance System Health dashboard with rich telemetry and visualizations
Add SVG arc gauge, per-core CPU bars, temperature sparkline, network
interface monitoring with bandwidth deltas, disk I/O rates, 3D globe
with observer location, weather overlay, battery/fan/throttle support,
and process grid layout. New /system/location and /system/weather endpoints.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 23:42:45 +00:00
mitchross 81a8f24e27 Merge upstream/main and resolve weather-satellite.js conflict
Keep allPasses assignment for satellite filtering support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 18:37:09 -05:00
mitchross 4712616339 Fix ADS-B sidebar deselect bug, ACARS XSS, and classifier dead code
- Clear sidebar highlights and ACARS message timer when stale selected
  aircraft is removed in cleanupOldAircraft()
- Escape all user-controlled strings in renderAcarsCard(),
  addAcarsMessage(), and renderAcarsMainCard() before innerHTML insertion
- Remove dead duplicate H1 check in classify_message_type
- Move _d label from link_test set to handshake return path

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 18:34:35 -05:00
Smittix 1cfeb193c7 feat: add System Health monitoring mode
Real-time dashboard for host metrics (CPU, memory, disk, temperatures),
active decoder process status, and SDR device enumeration via SSE streaming.
Auto-connects when entering the mode with graceful psutil fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 23:21:52 +00:00
Smittix 69b402f872 fix: prevent APRS stream crash on invalid UTF-8 bytes from decoder
Switch decoder subprocess from text mode to binary mode and decode
each line with errors='replace' so corrupted radio bytes (e.g. 0xf7)
are substituted instead of raising UnicodeDecodeError after long runs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 23:02:07 +00:00
Smittix deb7e2d15d fix: filter upcoming passes by selected satellite in weather sat mode
The satellite dropdown had no change listener, so selecting a different
satellite never updated the pass list, timeline, countdown, or polar plot.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 22:58:22 +00:00
Smittix 645b3b8632 fix: harden DSC decoder against noise-induced false decodes
Stricter dot pattern detection (200 bits/100 alternations), bounded
phasing strip (max 7), symbol check bit parity validation, EOS minimum
position check, strict MMSI decode (reject out-of-range symbols),
format-aware telecommand extraction, and expanded critical category
detection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 22:52:49 +00:00
Smittix ee81eb44cd Merge pull request #161 from sliceratwork/main
Fix background color for selects in ADSB, AIS and APRS dashboards
2026-02-26 22:21:47 +00:00
Smittix fd3552e725 fix: include core/components.css for btn-ghost styling
The .btn, .btn-sm, and .btn-ghost classes used by morse mode buttons
(TXT, CSV, Copy, Clear) were defined in core/components.css but the
stylesheet was never loaded in index.html, causing unstyled buttons.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 22:20:27 +00:00
Smittix 818d9c9f90 morse: fix stop timeout causing restart loop via checkStatus
When the stop POST timed out (5s), lifecycle was set to 'idle' on error,
allowing checkStatus to see running=true and reconnect SSE. Now:
- stop .then() stays in 'stopping' on timeout/error instead of going idle
- checkStatus skips reconnect when lifecycle is 'stopping' post-timeout
  but still transitions to idle when server confirms running=false
- LOCAL_STOP_TIMEOUT_MS raised from 5s to 12s to match server cleanup time

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 22:17:26 +00:00
Smittix dc0775f7df morse: guard in-flight status polls from overriding stop state
The previous stopPromise guard only prevented new polls from being
dispatched. Polls already in-flight before stop was clicked could still
return with running=true and override the stopping lifecycle, causing
SSE reconnection and an apparent restart loop. Add a second guard in
the .then() handler to check stopPromise/lifecycle before acting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 22:04:32 +00:00
Smittix c0fb22124b morse: fix stop restart loop and lower SNR threshold for decoding
Guard checkStatus() against in-flight stop to prevent status poller
from overriding stopping state and reconnecting SSE. Lower SNR floor
from 1.3 to 1.15 to accommodate weaker CW signals. Add SNR/noise_ref
to scope events and metrics for real-time threshold debugging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 21:57:08 +00:00
Smittix 97b10b3ac9 morse: fix SNR threshold for real CW and stop timeout
Widen noise detector offset from ±100Hz to ±200Hz to reduce spectral
leakage into the noise reference, and scale threshold_multiplier for
SNR space (2.8 → 1.54) so real CW signals reliably trigger tone
detection instead of producing all-E's at 60 WPM.

Fix misleading "decoder startup" timeout message on stop requests and
increase stop timeout from 2.2s to 5s.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 21:30:43 +00:00
Smittix be522d4dfe morse: use SNR-based tone detection to fix stuck-ON decoder
The previous magnitude-based threshold couldn't distinguish CW tone from
AGC-amplified inter-element silence — the Goertzel level stayed above
threshold permanently, preventing any tone OFF transitions and thus zero
character decodes.

Switch tone detection to use SNR (tone_mag / adjacent_band_noise_ref).
Both bands are equally amplified by AGC, so the ratio is gain-invariant.
Also replace the conditional noise_ref guard with unconditional blending
so the noise floor tracks actual ambient levels continuously.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 21:17:21 +00:00
Smittix 33a360b483 morse: fix startup race and stuck noise floor in Goertzel decoder
Filter decoder-thread 'stopped' status events that race with the route
lifecycle, causing the frontend to drop back to idle on first start.
Pull noise floor upward using adjacent-frequency Goertzel reference when
warmup calibration runs before AGC converges, preventing permanent
tone-on with zero character decodes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 21:05:25 +00:00
Smittix 2e1b9b27be morse: replace multimon-ng with custom Goertzel decoder for live CW
The multimon-ng MORSE_CW decoder never reliably decoded characters.
Switch live decode to use the existing morse_decoder_thread() which
wraps MorseDecoder with Goertzel tone detection, adaptive thresholds,
and proper timing estimation — eliminating multimon-ng, PTY plumbing,
and the relay thread from the CW pipeline entirely.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 20:47:51 +00:00
Smittix d6fe1123b4 morse: tune usb capture by cw tone offset 2026-02-26 18:16:43 +00:00
mitchross 5fcfa2f72f Add multi-SDR setup guide to hardware docs
Step-by-step instructions for running multiple RTL-SDR dongles:
serial burning, udev symlinks, USB power, and Docker passthrough.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 12:52:54 -05:00
Smittix 24d1777e63 morse: add multimon decoder alias fallback and clear stale idle scope 2026-02-26 17:43:23 +00:00
Smittix 794dd693cf morse: auto-fallback to alternate SDR device on no-PCM startup 2026-02-26 17:38:22 +00:00
Smittix 0cadf07985 morse: stop forcing rtl_fm squelch flag 2026-02-26 17:31:25 +00:00
Smittix bb263ce1b0 morse: remove select()-based pipe polling for capture/output 2026-02-26 17:29:41 +00:00
Smittix 23d592af1d morse: align rtl_fm streaming path with pager backend 2026-02-26 17:25:33 +00:00
Smittix ababa63856 morse: switch live decode to rtl_fm + multimon backend 2026-02-26 17:20:20 +00:00
Smittix fdffb8e88e Add FIFO transport fallback for Morse SDR sample stream 2026-02-26 16:25:37 +00:00
Smittix 98642e43c7 Use fd-backed stdout paths for Morse rtl_sdr/rtl_fm 2026-02-26 16:16:46 +00:00
Smittix 8cb7edf41e Use non-blocking pipe reads and raw-stream telemetry for Morse 2026-02-26 16:02:10 +00:00
Smittix 64f0e687a0 Fix Morse stderr thread race and broaden startup fallbacks 2026-02-26 15:37:17 +00:00
Smittix 6a54bc8cf3 Use stable RTL IQ sample rate for Morse IQ fallback 2026-02-26 15:08:03 +00:00
Smittix b32d30b789 Force fresh Morse JS and robust IQ stdout capture 2026-02-26 14:08:22 +00:00
Smittix d3b737c19b Switch Morse startup to IQ-first and harden timeout handling 2026-02-26 13:44:04 +00:00
Smittix 146bca4b37 Speed up Morse startup failure cleanup to avoid request timeouts 2026-02-26 13:30:09 +00:00
Smittix e3cf9daaed Add IQ-capture Morse fallback when rtl_fm has no PCM 2026-02-26 13:06:38 +00:00
Smittix 81e5f5479f Add merged-stream rtl_fm fallback for Morse startup 2026-02-26 12:58:29 +00:00
Smittix a5eefc712a Add rtl_fm resample and dc/agc Morse startup fallbacks 2026-02-26 12:46:22 +00:00
Smittix a50d200af4 Prevent Morse start timeout aborts on slow startup 2026-02-26 12:39:31 +00:00
Smittix 99db7f1faf Prefer no-squelch rtl_fm startup profile for Morse 2026-02-26 12:28:53 +00:00
Smittix 4560ec1800 Harden Morse startup PCM detection and retry fallback 2026-02-26 12:25:23 +00:00
Smittix d92146d678 Support explicit tool path overrides via INTERCEPT_*_PATH 2026-02-26 12:13:48 +00:00
Smittix 70e4bc557b Prefer native Homebrew tool paths on Apple Silicon 2026-02-26 12:07:45 +00:00
Smittix c1dd615e11 Force explicit rtl_fm squelch-off and log first PCM chunk 2026-02-26 11:59:07 +00:00
Smittix 63cc1647fb Move Morse PCM ingestion to dedicated reader thread 2026-02-26 11:53:03 +00:00
Smittix d9228fb05a Use buffered read path for Morse PCM stream stability 2026-02-26 11:44:59 +00:00
Smittix 806bc1397a Keep Morse panels visible and persist startup error diagnostics 2026-02-26 11:38:49 +00:00
Smittix 7560691fbb Harden Morse PCM read loop and add stream diagnostics 2026-02-26 11:26:12 +00:00
Smittix 8eb4ff41e2 Improve Morse stream startup compatibility and diagnostics 2026-02-26 11:15:45 +00:00
Smittix 286ab53d26 Fix Morse mode lifecycle stop hangs and rebuild CW decoder 2026-02-26 11:03:00 +00:00
Smittix 5d90c308a9 Fix Morse decoder not receiving PCM audio from rtl_fm
Add bufsize=0 to Popen for raw FileIO instead of BufferedReader, and
start decoder/stderr threads immediately before sleep+poll so stdout
is read without delay — matching the working pager pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 10:21:14 +00:00
Smittix 9622a00ea1 Fix Morse reader to bypass BufferedReader via os.read on raw fd
BufferedReader.read(n) on non-interactive streams (Python 3.14) blocks
until the full n bytes accumulate, starving the decoder of real-time
PCM data. Use os.read() on the raw file descriptor instead, which
returns as soon as any data is available. Falls back to .read() for
file-like objects without fileno() (e.g. BytesIO in tests).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 10:00:38 +00:00
Smittix 7c9ef9b895 Fix Morse decoder not receiving PCM audio from rtl_fm pipe
Replace select.select()+os.read() with a blocking reader thread feeding
a queue, matching pager's working pattern. The select() approach fails
to detect available data on Python 3.14's BufferedReader-wrapped pipes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 09:55:43 +00:00
Smittix bfae73cabf Forward rtl_fm stderr to Morse frontend diagnostic log
rtl_fm prints device info, tuning, and errors to stderr but the morse
route only logged these server-side. Now stderr lines are forwarded to
the morse queue as info events, displayed in a compact diagnostic log
below the scope canvas. After 10s with no audio data, the scope text
escalates to prompt the user to check the SDR log.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 09:43:28 +00:00
Smittix c0c066904c Fix Morse decoder scope events not reaching frontend
Replace blocking rtl_stdout.read() with select()+os.read() so the
decoder thread emits diagnostic heartbeat scope events when rtl_fm
produces no PCM data (common in direct sampling mode). Add waiting-state
rendering in the scope canvas and hide the generic placeholder/status
bar for morse mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 09:30:58 +00:00
Smittix 2eea28da05 Fix Morse decoder silent on real HF signals via AGC and warm-up
Add automatic gain control (AGC) before Goertzel processing to normalize
quiet audio from direct sampling mode where the -g gain flag has no effect.
Fix broken adaptive threshold bootstrap by adding a 50-block warm-up phase
that collects magnitude statistics before seeding noise floor and signal peak.
Lower threshold ratio from 50% to 30% for better weak-CW sensitivity.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 09:10:37 +00:00
Smittix df84c42b8b Fix direct sampling flag to use portable -E direct2 syntax
The -D flag is only available in newer rtl_fm builds. Docker and distro
packages use the older -E direct / -E direct2 flags instead, which are
universally supported.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 08:56:46 +00:00
Andrei Stefan 860db12200 Merge branch 'smittix:main' into main 2026-02-26 10:51:22 +02:00
Smittix 0bf8341b6c Fix Morse mode HF reception, stop button, and UX guidance
Enable direct sampling (-D 2) for RTL-SDR at HF frequencies below 24 MHz
so rtl_fm can actually receive CW signals. Add startup health check to
detect immediate rtl_fm failures. Push stopped status event from decoder
thread on EOF so the frontend auto-resets. Add frequency placeholder and
help text. Fix stop button silently swallowing errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 08:43:51 +00:00
Smittix 2ec458aa14 Fix Morse mode rejecting valid HF frequencies
validate_frequency() defaults to 24-1766 MHz (VHF/UHF range), but Morse/CW
operates on HF bands (0.5-30 MHz). Pass explicit min/max to allow HF frequencies.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 08:09:25 +00:00
mitchross 5f583e5718 Merge upstream/main and resolve weather-satellite.js conflict
Resolved conflict in static/js/modes/weather-satellite.js:
- Kept allPasses state variable and applyPassFilter() for satellite pass filtering
- Kept satellite select dropdown listener for filter feature
- Adopted upstream's optimistic stop() UI pattern for better responsiveness
- Kept optional chaining (pass?.trajectory) since drawPolarPlot can receive null

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 00:37:02 -05:00
Andrei deea80e32c Fix background color for selects 2026-02-25 23:39:30 -05:00
Smittix 37f0197f9a Add settings button to welcome dashboard
Closes #155 — users can now access settings directly from the welcome
screen without entering a mode first.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 23:40:27 +00:00
Smittix dc7c05b03f Fix welcome dashboard jitter and refine Morse mode UI
Fix "What's New" section shifting up/down on smaller screens (#157) by
isolating the logo pulse animation to its own compositing layer, stabilizing
the scrollbar gutter, and pinning the welcome container dimensions.

Morse mode improvements: relocate scope and decoded output panels to the
main content area, use shared SDR device controls, and reduce panel heights
for better layout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 23:26:47 +00:00
Smittix 8a46293e5c Fix DSC decoder for ITU-R M.493 compliance
Correct modulation parameters (1200 bps, 2100/1300 Hz tones), replace
invented format codes with the six ITU-defined specifiers {102, 112,
114, 116, 120, 123}, accept all valid EOS symbols (117, 122, 127),
add parser validation (format, MMSI, raw field, telecommand range),
and fix truthiness bugs that dropped zero-valued fields.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:02:08 +00:00
Smittix 935b7a4d9d Fix weather satellite mode returning false success on SatDump startup failure
Add synchronous startup verification after Popen() — sleep 0.5s and poll
the process before returning to the caller. If SatDump exits immediately
(missing device, bad args), raise RuntimeError with the actual error
message instead of returning status: 'started'. Keep a shorter (2s) async
backup check for slower failures.

Also fix --source_id handling: omit the flag entirely when no serial number
is found instead of passing "0" which SatDump may reject. Change start()
and start_from_file() to return (bool, str|None) tuples so error messages
propagate through to the HTTP response.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 21:49:16 +00:00
Smittix a50f77629c Fix Morse mode button styling to match standard UI patterns
Use run-btn/stop-btn classes and bottom placement instead of
btn-primary/btn-danger in a flex section, and preset-btn class
for band presets. Aligns with all other mode panels.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 21:18:35 +00:00
Smittix ecdc060d81 Add HackRF support to TSCM RF scan and misc improvements
TSCM RF scan now auto-detects HackRF via SDRFactory and uses
hackrf_sweep as an alternative to rtl_power. Also includes
improvements to listening post, rtlamr, weather satellite,
SubGHz, Meshtastic, SSTV, WeFax, and process monitor modules.

Fixes #154

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 20:58:57 +00:00
Smittix ee9356c358 Add CW/Morse code decoder mode
New signal mode for decoding Morse code (CW) transmissions via SDR.
Includes route blueprint, utility decoder, frontend UI, and tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 20:58:48 +00:00
Smittix 7fdf162f1e Fix waterfall retaining invalid span after error (#150)
When an error occurred with an out-of-range span (e.g. 30 MHz on
RTL-SDR), the span input kept the invalid value. Track the last
effective span from successful starts and reset the input on error.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 20:31:30 +00:00
Smittix 56514a839f Fix WeFax showing misleading "rtl_fm failed" error with HackRF (#147)
Replace hardcoded "rtl_fm" references in wefax.py with the actual SDR
tool name so error messages correctly show "rx_fm" for non-RTL devices.
Use get_tool_path('rx_fm') in all SoapySDR command builders to match
the pattern already used for rx_sdr.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 18:46:04 +00:00
Smittix dbf76a4e84 Improve waterfall error handling and SDR tool path resolution
- Add pre-flight check for I/Q capture binary before spawning process
- Capture stderr from I/Q process for better error diagnostics
- Sync effective span value back to UI when backend adjusts it
- Use get_tool_path('rx_sdr') in Airspy, HackRF, LimeSDR, and SDRPlay
  command builders to support custom install locations

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 18:32:14 +00:00
Smittix 3f7430d114 Fix APRS stop/start not repopulating stations
- Make stopAprs() async and await backend stop completion before
  re-enabling the Start button, preventing race where a late stop
  request kills newly started processes
- Add cache-buster param to EventSource URL to prevent browser
  SSE connection reuse between stop/start cycles
- Capture aprs_active_device locally in stream_aprs_output so the
  old thread's finally block doesn't release a device claimed by
  a new session

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 18:31:10 +00:00
Smittix f3158cbb69 Add multi-SDR support to WeFax decoder (HackRF, LimeSDR, Airspy, SDRPlay)
Replace hardcoded rtl_fm with SDRFactory abstraction layer so WeFax works
with any supported SDR hardware, matching the pattern used by APRS and
other modes. RTL-SDR direct sampling flag preserved for HF reception.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:45:07 +00:00
Smittix 2202e3ed98 Keep collapse button above WeFax pane in sidebar ordering
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 13:03:38 +00:00
Smittix 844e57e239 Move WeFax sidebar pane above SDR Device section
Use CSS order to place the WeFax decoder panel first in the
sidebar when wefax mode is active.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 13:01:19 +00:00
Smittix 5b6df923fc Fix APRS map centering at [0,0] when GPS unavailable
Number(null) evaluates to 0 which passes Number.isFinite(),
causing aprsHasValidCoordinates(null, null) to return true.
This made initAprsMap() center the map at [0,0] (Gulf of Guinea)
at zoom 8 instead of the US default, hiding all station markers
off-screen.

Add null guards (lat != null && lon != null) to reject null/undefined
while still accepting 0 as a valid equator coordinate.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 12:57:42 +00:00
Smittix 9724ec57f9 Fix pager sidebar squishing sections when all expanded
Add flex-shrink: 0 to .section, .run-btn, and .stop-btn so flex
children maintain natural height and the sidebar scrolls instead
of compressing content on 1080p displays.

Fixes #151

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 10:22:49 +00:00
Smittix 2d92243341 Harden APRS station plotting across payload variants 2026-02-25 10:19:22 +00:00
Smittix 6ec15461af Fix SSE fanout backlog causing delayed bursty updates 2026-02-25 10:12:16 +00:00
Smittix 2c76039f2c Fix ADS-B and VDL2 stop button handling 2026-02-25 10:05:16 +00:00
Smittix c4bde6c707 Fix APRS map ingestion and parser compatibility 2026-02-24 23:39:54 +00:00
Smittix 6384e39576 Fix GPS globe startup and satellite polling errors 2026-02-24 23:32:08 +00:00
Smittix 5edfe1797c wefax: auto-align carrier frequencies for usb tuning 2026-02-24 23:20:09 +00:00
Smittix 4bf452d462 Fix APRS parser for direwolf bracket-prefixed frames 2026-02-24 22:52:34 +00:00
Smittix f6b0edaf5a Harden GPS mode updates with callback reattach and status polling fallback 2026-02-24 22:50:17 +00:00
Smittix 18efed891a Fix APRS agent stream/poll payload handling and state reset 2026-02-24 22:38:04 +00:00
Smittix 60a3ae225f Avoid duplicate/deprecated Three.js globe script loading 2026-02-24 22:32:46 +00:00
Smittix afd3d34f43 Handle transient network suspension in frontend polling and SSE 2026-02-24 22:25:59 +00:00
Smittix 0344862a0c refine(gps): replace animated globe markers with satellite icons 2026-02-24 22:16:58 +00:00
Smittix 43e6d4a1b8 feat(gps): switch sky view to textured 3D globe 2026-02-24 22:09:26 +00:00
Smittix 53c65febed Fix mode FOUC by awaiting and warming lazy styles 2026-02-24 22:01:13 +00:00
Smittix cec8bccb03 Add ADS-B voice alerts for military and emergency detections 2026-02-24 21:54:36 +00:00
Smittix 6c20b3d23f Apply pending weather-sat and wefax updates 2026-02-24 21:46:58 +00:00
Smittix 53f54af871 Fix Python 3.9 startup crash in waterfall websocket 2026-02-24 21:02:03 +00:00
Smittix caa4357870 Improve WeFax delete handling and modal actions 2026-02-24 20:40:06 +00:00
Smittix 3e608c62a0 Fix SSE fanout packet loss on reconnect 2026-02-24 20:38:19 +00:00
Smittix 0afa25e57c Fix weather sat 0dB SNR: increase sample rate to 2.4 MHz for Meteor LRPT
The default 1 MHz sample rate was too low for SatDump's meteor_m2-x_lrpt
pipeline, causing NOSYNC and 0.000dB SNR. Bumped to 2.4 MHz (SatDump
recommendation) and wired up the WEATHER_SAT_SAMPLE_RATE config value
so it actually gets passed to decoder.start() from both the auto-scheduler
and manual start route.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:27:08 +00:00
Smittix b3af44652f Fix WeFax auto-scheduler: prevent silent timer death and connect SSE
Timer threads now log on fire and catch all exceptions so scheduled
captures never die silently.  Frontend connects SSE when the scheduler
is enabled (not only on manual Start) and polls /wefax/status every 10s
as a fallback so the UI stays in sync with auto-fired captures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 18:40:23 +00:00
Smittix 67321adade Add WeFax image modal with download and delete buttons
Replace window.open() with a fullscreen modal matching the SSTV
pattern: toolbar with download/delete SVG buttons, close button,
click-outside-to-close, and confirmation before delete.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 16:23:56 +00:00
Smittix 6894e626a9 Fix WeFax image not appearing in gallery after stop
stop() was returning before the decode thread could save any partial
image to disk, so the frontend loadImages() call found nothing new.
Join the decode thread (2s timeout) before returning — with select()-
based reads the thread exits within ~0.5s so this stays responsive.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 16:16:52 +00:00
Smittix 9745215038 Fix WeFax start/stop/SSE reliability
- Replace blocking stdout.read() with select()-based non-blocking reads
  so the decode thread responds to stop within 0.5s
- Make stop() non-blocking by releasing the lock before terminating the
  process and removing the redundant wait()
- Move initial scanning SSE event from start() into the decode thread so
  it fires after the frontend EventSource connects
- Update frontend stop() to give immediate UI feedback before the fetch

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 16:10:34 +00:00
Smittix b72a2f1092 Fix WeFax error detection and surface errors in strip UI
rtl_fm subprocess failures (missing tool, no SDR hardware) were silent —
add tool-path check and post-spawn health check in _start_pipeline(),
show errors prominently in the strip status bar (red text + red dot),
and include error detail in scheduler skip events.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 15:53:57 +00:00
Smittix 2da8dca167 Add WeFax 24h broadcast timeline and improve start button feedback
Flash the Start button itself with amber pulse when clicked without a
station selected, and show "Select Station" in the strip status text
right next to the button so the error is immediately visible.

Add a 24-hour timeline bar with broadcast window markers, red UTC time
cursor, and countdown boxes (HRS/MIN/SEC) that tick down to the next
broadcast. Broadcasts show as amber blocks on the timeline track with
imminent/active visual states matching the weather satellite pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 15:17:01 +00:00
Smittix 085a6177f9 Add WeFax start button feedback and auto-capture scheduler
Fix silent failure when starting without station/frequency selected by
flashing amber on status text and dropdowns. Add auto-capture scheduler
that uses fixed UTC broadcast schedules from station data to
automatically start/stop WeFax decoding at broadcast times.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 13:28:53 +00:00
Smittix 01abcac8f2 Add WeFax (Weather Fax) decoder mode
Implement HF radiofax decoding with custom Python DSP pipeline
(rtl_fm USB → Goertzel/Hilbert demodulation), 33-station database
with broadcast schedules, audio waveform scope, live image preview,
and decoded image gallery. Amber/gold UI theme for HF distinction.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 12:30:31 +00:00
Smittix 2a5f537381 Coalesce rapid step-button frequency changes 2026-02-24 10:01:29 +00:00
Smittix 07b5b72878 Sync monitor state text with tuned waterfall frequency 2026-02-24 09:59:07 +00:00
Smittix 1a1a398962 Use selected SDR for monitor retune/start path 2026-02-24 09:54:10 +00:00
Smittix b7d90e8e5e Fix monitor retune when frequency changes during startup 2026-02-24 09:37:22 +00:00
Smittix 55c38522a4 Bind monitor audio stream to start request token 2026-02-24 09:15:24 +00:00
Smittix d9b528f3d3 Retry monitor audio starts after stale token responses 2026-02-24 09:04:51 +00:00
Smittix 9cd7f1c0c8 Snapshot audio tune config when spawning demod process 2026-02-24 08:55:32 +00:00
Smittix a350c82893 Use monotonic audio start tokens across page reloads 2026-02-24 08:46:17 +00:00
mitchross 6a690abf82 Fix review issues: profiles, imports, clear reset, frequencies, VDL2 enrichment
- Remove profiles: [basic] from intercept service so docker compose up -d
  works without --profile flag (fixes breaking change for existing deployments)
- Add missing Any import to routes/acars.py and routes/vdl2.py
- Reset last_message_time to None in ACARS and VDL2 clear endpoints
- Restore 131.725 and 131.825 to default ACARS frequencies (major US carriers)
- Copy VDL2 ACARS enrichment fields to top-level data dict instead of mutating
  nested acars_payload (consistent with ACARS route pattern)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 23:31:20 -05:00
mitchross e19a990b64 Merge upstream/main and resolve adsb_dashboard.html conflict
Take upstream's crosshair animation system and updated selectAircraft(icao, source) signature.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 23:16:39 -05:00
Smittix 975a95e1b0 Prevent stale monitor start requests from retuning audio 2026-02-23 23:56:21 +00:00
Smittix 2af238aed5 Use pending click target for monitor retune frequency 2026-02-23 23:45:14 +00:00
Smittix e81a409234 Stabilize monitor retune across waterfall click restarts 2026-02-23 23:39:50 +00:00
Smittix 1c76671ed7 Force recenter retune for monitor click tuning 2026-02-23 23:23:35 +00:00
Smittix 9ece4d658d Recenter capture for shared monitor tune clicks 2026-02-23 23:20:21 +00:00
Smittix 739b0b136e Fix shared waterfall monitor tuning across in-span clicks 2026-02-23 23:14:37 +00:00
Smittix 199ff4b47c Fix monitor retune race after waterfall tune clicks 2026-02-23 23:07:35 +00:00
Smittix 65e5552c7d Fix waterfall canvas click-to-tune interaction 2026-02-23 23:00:49 +00:00
Smittix a5452fa1b1 fix: flush shared audio queue on VFO frequency change
The shared audio queue (maxsize reduced from 80 to 20) was not flushed
when the monitor frequency changed — only when the monitor was disabled.
This caused up to 4 seconds of stale old-frequency audio to play after
clicking to tune, making click-to-tune appear non-functional.

Now flushes the queue whenever the VFO frequency changes, so audio at
the new frequency begins within ~50ms (one FFT frame).
2026-02-23 22:42:41 +00:00
Smittix 889c08691f fix: stop monitor button greyed out during retune and click-to-tune race
1. Stop Monitor button was disabled during shared monitor retunes
   because _syncMonitorButtons disabled the button whenever
   _startingMonitor was true, even if the monitor was already active.
   Now only disables during initial start (not retunes).

2. Click-to-tune was inconsistent because the shared monitor retune
   (rearm after capture restart) captured the center frequency early
   in _startMonitorInternal, then sent it via POST to /audio/start.
   If the user clicked a new frequency during the async reconnect,
   the POST carried the stale frequency and could override the click.
   Now retunes use the live _monitorFreqMhz and send a WS tune sync
   after reconnecting to ensure the backend has the latest VFO.
2026-02-23 22:11:33 +00:00
Smittix 0a4a0689a0 fix: zombie IQ process holds USB and stale WS handler clobbers shared state
Two root causes for the waterfall/monitor lockup when scrolling past the
2.4 MHz RTL-SDR span:

1. safe_terminate() sent SIGKILL but never called wait(), leaving a
   zombie process that kept the USB device handle open. The subsequent
   capture restart failed the USB probe and the monitor could not use
   the shared IQ path, falling back to a process-based monitor that
   stole the SDR from the waterfall.

2. When the frontend created a new WebSocket after a failure, the old
   handler's finally block called _set_shared_capture_state(running=False)
   which could race with the new handler's running=True, making the
   shared monitor path unavailable. Added a generation counter so only
   the owning handler can clear the shared state.
2026-02-23 21:59:03 +00:00
Smittix 0daee74cf0 fix: waterfall device claim fails on frequency change due to USB release lag
When restarting capture for a new frequency, the USB handle from the
just-killed process may not be released by the kernel in time for the
rtl_test probe inside claim_sdr_device. Add retry logic (up to 4
attempts with 0.4s backoff) matching the pattern already used by the
audio start endpoint.

Also clean up stale shared-monitor state in the frontend error handler
so the monitor button is not left disabled when the capture restart
fails.
2026-02-23 21:41:14 +00:00
Smittix 2e6bb8882f fix: waterfall monitor state desync on frequency change and restart
When changing frequency with shared monitor active, the monitor retune
could be silently dropped if a previous retune was still in-flight,
leaving the UI stuck on "Starting <freq>". After stopping and restarting
the waterfall, the monitor button could remain disabled because
_startingMonitor was never reset and _monitorRetuneTimer was not cleared.

- Cancel in-flight monitor start when queuing a new retune
- Always clear _pendingSharedMonitorRearm in started handler
- Clear _monitorRetuneTimer and reset _startingMonitor in stop()
2026-02-23 21:35:34 +00:00
Smittix 365333d425 feat: add HTTPS support via INTERCEPT_HTTPS config
Auto-generates a self-signed certificate into data/certs/ when
INTERCEPT_HTTPS=true, or accepts custom cert/key paths via
INTERCEPT_SSL_CERT and INTERCEPT_SSL_KEY. Resolves 400 errors
from browsers sending TLS ClientHello to the plain HTTP server.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 21:26:33 +00:00
Smittix 367048e853 chore: bump version to 2.22.3 and update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 21:03:27 +00:00
Smittix 406ca28304 fix: suppress stale WebSocket close message after stopping waterfall
stop() sets _ws = null before the async onclose fires, so the handler
now early-returns when _ws is null instead of showing the misleading
"WebSocket closed before ready" retry message.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 21:01:59 +00:00
Smittix f889c53d92 fix: waterfall monitor audio delay and unresponsive stop button
- _waitForPlayback now only succeeds on playing/timeupdate events, not
  loadeddata/canplay which fire from just the WAV header before real
  audio arrives
- stopMonitor() pauses audio and updates UI immediately instead of
  blocking on the backend stop request (1+ second delay)
- Reduced backend audio stop sleep from 1.0s to 0.15s; the start
  retry loop already handles USB contention

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:59:40 +00:00
Smittix b0af1d16d2 chore: bump pyproject.toml version to 2.22.2
Was missed during previous 2.22.x release bumps.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 20:27:35 +00:00
Smittix 4e67b77714 fix: first-load rendering for Waterfall CSS and WebSDR globe
- Waterfall: load waterfall.css eagerly in <head> instead of lazily on
  mode switch; the lazy inject raced with the panel becoming visible,
  leaving unstyled HTML for up to 20 s on cold cache
- WebSDR: await a requestAnimationFrame before calling Globe()(mapEl) so
  the browser has committed the display:flex layout and clientWidth/
  clientHeight are non-zero; previously the globe WebGL renderer was
  created at 0×0 (especially on warm-cache refreshes) and could not
  recover via the deferred resize calls
- Bump version to 2.22.2

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 20:25:05 +00:00
Smittix b1993847b5 docs: remove RF Heatmap references — feature was not shipped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 19:40:15 +00:00
Smittix cde79f4619 fix: use official favicon.svg logo for all PWA and app icons
Regenerates icon-192.png, icon-512.png, apple-touch-icon.png, and
favicon-32.png from the official iNTERCEPT logo (favicon.svg) instead
of the placeholder icon.svg. Also replaces icon.svg with the official
logo so the SVG manifest entry is consistent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 19:37:44 +00:00
Smittix cc271819ad chore: bump version to 2.22.1 and update changelog
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 19:36:55 +00:00
Smittix 8cd64ce3ca fix: PWA install prompt - add PNG icons and fix apple-touch-icon
Browsers require PNG icons (192x192, 512x512) in the manifest to show
the install prompt. SVG-only manifests are not sufficient. Also adds the
180x180 apple-touch-icon PNG for iOS home screen, bumps SW cache to v3,
and adds scope to the manifest.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 19:36:28 +00:00
Smittix 9705e58691 Release v2.22.0
Waterfall overhaul, new modes (fingerprint, RF heatmap, SignalID, voice
alerts), PWA support, mode stop responsiveness improvements, ADS-B MSG2
surface tracking, WebSDR overhaul, and full documentation audit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 19:31:10 +00:00
Smittix 3acdab816a Improve mode transitions and add nav perf instrumentation 2026-02-23 18:14:31 +00:00
Smittix c31ed14041 Improve mode stop responsiveness and timeout handling 2026-02-23 17:53:50 +00:00
Smittix 7241dbed35 chore: commit all pending changes 2026-02-23 16:51:32 +00:00
Smittix 94b358f686 Commit all pending workspace changes 2026-02-23 14:28:57 +00:00
Smittix 8e19f7e688 Fix ADS-B update flush timing and parse MSG2 surface data 2026-02-23 13:39:01 +00:00
Smittix 7ea06caaa2 Remove legacy RF modes and add SignalID route/tests 2026-02-23 13:34:00 +00:00
Mitch Ross 1c681b6777 Merge branch 'smittix:main' into main 2026-02-22 21:35:05 -05:00
Smittix 5f480caa3f feat: ship waterfall receiver overhaul and platform mode updates 2026-02-22 23:22:37 +00:00
Smittix 5d4b61b4c3 Fix nested nav bar appearing in embedded dashboard iframes
When dashboards (satellite, ADS-B, AIS) are loaded via iframe with
?embedded=true, the full navigation bar was still rendered, creating
a "UI in UI" effect. Pass the embedded query param from route handlers
to templates and conditionally skip the nav include.

Fixes #144

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 13:16:51 +00:00
Mitch Ross ab064b4c91 fix 2026-02-21 15:50:58 -05:00
Mitch Ross 26ecd3dd93 Merge branch 'smittix:main' into main 2026-02-21 12:12:54 -05:00
Smittix a8e2b9d98d Shrink hit areas and spread overlapping radar dots
Hit area: was Math.max(dotSize * 2, 15) — up to 24px radius around a 4px
dot. Now the CSS hover-flicker is fixed the large hit area is unnecessary
and was the reason dots activated when merely nearby. Changed to dotSize + 4
(proportional, 4px padding around the visual circle).

Overlap spread: compute all band positions first, then run an iterative
push-apart pass (spreadOverlappingDots) that nudges any two dots whose
arc gap is smaller than 2 * maxHitArea + 2px apart. Positions within a
band are stable across renders (same hash angle, same band = same output
before spreading) so dots don't shuffle on every update.

Z-order: sort visible devices by rssi_current ascending before rendering
so the strongest signal lands last in SVG order and receives clicks when
dots stack.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 14:51:45 +00:00
Smittix 4b225db9da Fix proximity radar jitter caused by CSS scale-on-hover feedback loop
The root cause was in proximity-viz.css, not the JS:

  .radar-device:hover { transform: scale(1.2); }

When the cursor entered a .radar-device, the 1.2x scale physically moved
the hit-area boundary, pushing the cursor outside it. The browser then
fired mouseout, the scale reverted, the cursor was back inside, mouseover
fired again, and the scale reapplied — a rapid enter/exit loop that looked
like the dot jumping and dancing.

Replace the geometry-changing scale with a brightness filter on the dot
circle only. filter: brightness() does not affect pointer-event hit testing
so there is no feedback loop, and the hover still gives clear visual
feedback. Also removes the transition: transform rule that was animating
the scale and contributing to the flicker.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 14:44:46 +00:00
Smittix aba4ccd040 Fix radar jitter by using band-only positioning
Replace continuous estimated_distance_m-based radius with proximity band
snapping (immediate/near/far/unknown → fixed radius ratios of 0.15/0.40/
0.70/0.90). The proximity_band is computed server-side from rssi_ema which
is already smoothed, so it changes infrequently — dots now only move when
a device genuinely crosses a band boundary rather than on every RSSI
fluctuation.

Also removes the client-side EMA and positionCache added in the previous
commit, and reverts CSS style.transform back to SVG transform attribute to
avoid coordinate-system mismatch when the SVG is displayed at a scaled size.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 14:38:50 +00:00
Smittix f8a6d0ae70 Smooth proximity radar positions with EMA and CSS transitions
The remaining jitter after the in-place DOM rewrite was caused by RSSI
fluctuations propagating directly into dot positions on every 200ms
update cycle.

Two fixes:
1. Client-side EMA (alpha=0.25) on x/y coordinates per device. Each
   render blends 25% toward the new raw position and retains 75% of the
   smoothed position, filtering high-frequency RSSI noise without hiding
   genuine distance changes. positionCache is keyed by device_key and
   cleared on device removal or radar reset.

2. CSS transition (transform 0.6s ease-out) on each wrapper element.
   Switching from SVG transform attribute to style.transform enables
   native CSS transitions, so any remaining position change (e.g. a band
   crossing) animates smoothly rather than snapping.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 14:35:42 +00:00
Smittix 00681840c8 Rewrite proximity radar to use in-place DOM updates
Instead of rebuilding devicesGroup.innerHTML on every render, mutate
existing SVG elements in-place (update transforms, attributes, class
names) and only create/remove elements when devices genuinely appear
or disappear from the visible set.

This eliminates the root cause of both the jitter and the blank-radar
regression: hover state can never be disrupted by a render because the
DOM elements under the cursor are never destroyed. The isHovered /
renderPending / interactionLockUntil state machine and its associated
mouseover/mouseout listeners are removed entirely — they are no longer
needed. A shared buildSelectRing() helper deduplicates the animated
selection ring construction used by renderDevices() and
applySelectionToElement(). Closes #143.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 14:29:41 +00:00
Smittix 00be3e940a Fix proximity radar hover jitter without breaking device rendering
Replace capture-phase mouseenter/mouseleave with bubbling mouseover/mouseout
for tracking hover state in the ProximityRadar component.

The capture-phase approach caused two problems:
1. Moving between sibling child elements (hit-area → dot circle) fired
   mouseleave, prematurely clearing isHovered and triggering a DOM rebuild
   that caused visible jitter.
2. When renderDevices() rebuilt innerHTML, the browser fired mouseleave for
   the destroyed element with relatedTarget pointing at the newly created
   element at the same position, leaving isHovered permanently stuck at true
   and suppressing all future renders.

The fix uses mouseover/mouseout (which bubble) with devicesGroup.contains()
to reliably detect whether the cursor genuinely left the device group, immune
to innerHTML rebuilds. Fixes both WiFi and Bluetooth proximity radars as they
share this component. Closes #143.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 14:22:59 +00:00
Mitch Ross c2405bfe14 feat(adsb): improve ACARS/VDL2 panels with history, clear, smooth updates, and translation
- Persist ACARS/VDL2 messages across page refresh via new /acars/messages
  and /vdl2/messages endpoints backed by FlightCorrelator
- Add clear buttons to ACARS/VDL2 sidebars and right-panel datalink section
  with /acars/clear and /vdl2/clear endpoints
- Fix right-panel DATALINK MESSAGES flickering by diffing innerHTML before
  updating, with opacity transition for smooth refreshes
- Add aircraft deselect toggle (click selected aircraft again to deselect)
- Enrich VDL2 messages with ACARS label translation (label_description,
  message_type, parsed fields) matching existing ACARS translator

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 23:48:50 -05:00
mitchross 01409cfdea fix(adsb): use actual device index for ACARS/VDL2 SDR conflict checks
ACARS and VDL2 conflict warnings were hardcoded to check device === '0'
instead of comparing against the actual ADS-B device (adsbActiveDevice).
This caused false warnings when ADS-B used a different device index.

Also removes hardcoded device-1 defaults for ACARS/VDL2 selectors —
users should pick their own device based on their antenna setup.

Adds profiles: [basic] to the intercept service in docker-compose so it
doesn't port-conflict with intercept-history when using --profile history.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 17:29:47 -05:00
Mitch Ross 130f58d9cc feat(adsb): add IATA↔ICAO airline code translation for ACARS cross-linking
ACARS messages use IATA codes (e.g. UA2412) while ADS-B uses ICAO
callsigns (e.g. UAL2412). Add a translation layer so the two can
match, enabling click-to-highlight and datalink message correlation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 16:39:00 -05:00
Smittix fb2a12773a Force local dashboard assets and quiet BT locate warnings 2026-02-20 19:11:21 +00:00
Smittix 167f10c7f7 Harden BT Locate handoff matching and start flow 2026-02-20 18:57:06 +00:00
mitchross 15d5cb2272 feat(adsb): cross-link ACARS sidebar messages with tracked aircraft
Click an ACARS message in the left sidebar to zoom the map to the
matching aircraft and open its detail panel. Aircraft with ACARS
activity show a DLK badge in the tracked list. Default NA frequency
changed to only check 131.550 on initial load.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 13:24:00 -05:00
Smittix e386016349 Default dashboard assets/fonts to local bundles 2026-02-20 18:03:06 +00:00
Smittix aec925753e Pause BT Locate processing when mode is hidden 2026-02-20 17:48:22 +00:00
Smittix c3bf30b49c Fix BT Locate startup/map rendering and CelesTrak import reliability 2026-02-20 17:35:57 +00:00
Smittix c0221ba53d Fix manual TLE parsing for pasted multiline input 2026-02-20 17:18:15 +00:00
Smittix af5b17e841 Remove Drone Ops feature end-to-end 2026-02-20 17:09:17 +00:00
Smittix b628a5f751 Add drone ops mode and retire DMR support 2026-02-20 17:02:16 +00:00
mitchross d28d8cb9ef feat(acars): add message translator and ADS-B datalink integration
Add ACARS label translation, message classification, and field parsers
so decoded messages show human-readable descriptions instead of raw
label codes (H1, DF, _d, 5Z, etc.). Integrate translated ACARS
messages into the ADS-B aircraft detail panel and add a live message
feed to the standalone ACARS mode.

- New utils/acars_translator.py with ~50 label codes, type classifier,
  and parsers for position reports, engine data, weather, and OOOI
- Enrich messages at ingest in routes/acars.py with translation fields
- Backfill translation in /adsb/aircraft/<icao>/messages endpoint
- ADS-B dashboard: DATALINK MESSAGES section in aircraft detail panel
  with auto-refresh, color-coded type badges, and parsed field display
- Standalone ACARS mode: scrollable live message feed (max 30 cards)
- Fix default N. America ACARS frequencies to 131.550/130.025/129.125
- Unit tests covering all translator functions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 11:11:57 -05:00
Smittix 9ec316fbe2 fix(bt-locate): stabilize first-load map and release v2.21.1 2026-02-20 00:49:08 +00:00
Smittix a407c7708d chore(release): v2.21.0 2026-02-20 00:37:37 +00:00
Smittix 1466fc2d30 Apply global map theme updates and UI improvements 2026-02-20 00:32:58 +00:00
Smittix 963bcdf9fa Improve cross-app UX: accessibility, mode consistency, and render performance 2026-02-19 22:32:08 +00:00
Smittix cfe03317c9 Fix weather sat auto-scheduler and Mercator tracking 2026-02-19 21:55:07 +00:00
Smittix 37ba12daaa Fix BT/WiFi run-state health and BT Locate tracking continuity 2026-02-19 21:39:09 +00:00
Smittix 5c47e9f10a feat: ship platform UX and reliability upgrades 2026-02-19 20:46:28 +00:00
Smittix 694786d4e0 Fix ADS-B SSE fanout for multi-client streams 2026-02-19 18:26:23 +00:00
Smittix 06a00ca6b5 Fix remote VDL2 streaming path and improve decoder reliability 2026-02-19 15:57:13 +00:00
Smittix bbc25ddaa0 Improve Bluetooth scanner filtering, stats, and layout 2026-02-19 14:04:12 +00:00
Smittix 02a94281c3 Improve Analytics with operational insights and temporal pattern panels 2026-02-19 12:59:39 +00:00
Smittix cbe5faab3b Enhance BT Locate with smoothing, confidence, strongest signal, and export 2026-02-19 12:51:25 +00:00
Smittix cacfbf5713 Update HF SSTV 2m preset to 145.500 MHz 2026-02-19 12:34:08 +00:00
Smittix 2faed68af4 Align ISS SSTV start flow with HF decoder contract 2026-02-19 12:29:27 +00:00
Smittix bec0881018 Set HF SSTV default modulation to FM 2026-02-19 12:23:25 +00:00
Smittix da2a700bcc Fix SSTV slant correction wedge artifact 2026-02-19 12:18:20 +00:00
Smittix cd3ed9a03b Fix weather satellite next-pass countdown timestamps 2026-02-19 12:12:12 +00:00
Smittix f7fad076c2 fix: Expand Scottie sync deviation search window to fix under-correction
The slant correction was severely under-correcting because bwd=50 caused
the sync deviation measurements to saturate after only ~25 lines (for a
2-sample/line SDR clock drift). Lines 25-256 all reported deviation=-50,
pulling the linear regression slope toward zero.

Increase bwd and fwd to 800 samples each — sufficient to track cumulative
drift from up to ~±200 ppm SDR clock offset across the full 256-line image.

Also use a full-sync-length (432-sample) Goertzel window instead of 1/3
length, giving ~111 Hz frequency resolution to cleanly separate the 1200 Hz
sync tone from 1500 Hz pixel data. Search is stepped at 5 samples (~0.1 ms)
for efficiency, keeping the goertzel_batch batch size at ~320 windows/line.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 11:04:06 +00:00
Smittix a397271553 fix: Slant correction via post-processing shear, not in-decoder sync fixup
Previous attempts to correct slant by altering R-channel placement and
buffer consumption caused cascading failures: a false positive in B pixel
data would misplace R, then the wrong consumed value misaligned the next
line's G, and the error compounded across all 256 lines.

New approach (safe by design):
- Sync search is measurement-only: never touches pos or consumed, so
  a noisy or wrong measurement cannot corrupt the current or future lines.
- Per-line deviation (measured sync position minus expected) is recorded
  in self._sync_deviations throughout the decode.
- get_image() fits a line through the deviations (linear regression) to
  estimate the per-line SDR clock drift rate, then applies a horizontal
  shear to the assembled PIL image: each row is shifted by
  -round(row × drift_rate × width / channel_samples) pixels.
- Worst case (all measurements fail): no correction applied, image
  quality identical to the pre-change baseline.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 10:43:16 +00:00
Smittix 83a54ccb20 fix: Replace coarse Scottie sync search with vectorised fine scan
The step-49 coarse scan introduced up to ±24 sample uncertainty in R
channel placement. When accumulated SDR clock drift pushed the actual
sync 35+ samples early in the search region, the step-49 windows could
land on the B-pixel tail and return position 0, misplacing R by ~50
samples (~16 pixel colour shift) — worse than no correction at all.

Replace with a vectorised goertzel_batch sliding-window scan at step=1
over a short window (sync_duration / 3 ≈ 3 ms), giving single-sample
accuracy. Use consumed=pos (instead of max(pos,line_samples)) when the
sync is found, so the next line starts at its correct separator and
per-line timing errors stop accumulating entirely.

Falls back to the fixed-offset path whenever the sync is not found
(e.g. noisy signal), preserving the pre-change baseline quality.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 10:29:19 +00:00
Smittix 2e9bab75b1 fix: Correct Scottie sync search to prevent decoder stall
The previous sync search used search_margin = line_samples/10 (~306
samples for Scottie2), reaching deep into B channel pixel data behind
pos and well past the expected sync end ahead of pos.

When _find_sync returned a position in the late portion of that wide
region, pos + R_channel_samples exceeded the buffer length. The
buffer-too-short guard in _decode_line then returned early without
consuming data or advancing the line counter, causing the stall guard
in feed() to permanently break the decode loop.

Fix: use a 50-sample backward margin (covers >130 ppm SDR drift) and
a forward margin capped to whatever the current buffer can safely
support for the R channel. A final candidate-position check before
committing pos ensures no overflow is possible.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 10:01:28 +00:00
Smittix 0dc40bbea3 fix: Correct Scottie SSTV image slant by resyncing to mid-line sync pulse
Scottie modes place their horizontal sync pulse between the Blue and Red
channels. The decoder was using a fixed offset to skip over it, so any
SDR clock error accumulated line-by-line and produced a visible diagonal
slant in the decoded image.

Fix: search for the actual 1200 Hz sync pulse in a ±10% window around
the expected position before decoding the Red channel, then align to the
real pulse. This resets accumulated clock drift on every scanline, the
same way Martin and Robot modes already handle their front-of-line sync.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 09:54:28 +00:00
Smittix 17f6947648 fix: Correct SSTV VIS codes and replace Goertzel pixel decoder with Hilbert transform
Fix wrong VIS codes for PD90 (96→99), PD120 (93→95), PD180 (95→97),
PD240 (113→96), and ScottieDX (55→76). This caused PD180 to be detected
as PD90 and PD120 to fail entirely.

Replace batch Goertzel pixel decoding with analytic signal (Hilbert
transform) FM demodulation. The Goertzel approach used 96-sample windows
with ~500 Hz resolution — wider than the 800 Hz pixel frequency range —
making accurate pixel decoding impossible for fast modes like Martin2
and Scottie2. The Hilbert method computes per-sample instantaneous
frequency, matching the approach used by QSSTV and other professional
SSTV decoders.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 09:23:15 +00:00
Smittix 481651c88d fix: Improve HF SSTV VIS detection reliability and error correction
Tolerate intermittent ambiguous windows during leader detection (up to
3 consecutive misses), use energy-based break detection when tone
classification fails at leader-break boundary, and add single-bit VIS
error correction for parity-bit and data-bit corruption on noisy HF.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 21:52:56 +00:00
Smittix ad4903d4ac fix: Add missing SSTV mode specs for HF decoding (PD90/PD160/PD240/ScottieDX)
VIS detection recognized these modes but ALL_MODES had no decoder specs,
causing silent decode failures on common HF frequencies like 14.230 MHz.
Also emit a user-visible SSE event when an unsupported VIS code is detected.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 21:29:34 +00:00
Smittix 3a962ca207 fix: SSTV VIS detector stuck in DETECTED state on validation failure
The previous fix (f29ae3d) introduced a regression: when VIS parity
check failed or the VIS code was unrecognized, the detector entered
DETECTED state permanently and never resumed scanning. Now it resets
to IDLE on validation failure and only enters DETECTED on success.

Also resets partial image progress counter between consecutive decodes
and adds SDR device claiming to general SSTV route to prevent conflicts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 21:12:12 +00:00
Smittix f29ae3d5a8 fix: Preserve image-start samples across VIS-to-decoder boundary
VISDetector._process_window() was calling self.reset() inside the
STOP_BIT handler, wiping self._buffer before feed() could advance
past the triggering window. All audio samples buffered after the
VIS STOP_BIT (the start of the first scan line) were silently
discarded, causing the image decoder to begin decoding mid-stream
with no alignment reference. The result was every scan line being
desynchronised from the first, producing the diagonal stripes and
scrambled colours seen in decoded images.

Fix: remove the premature reset() from _process_window(). The
STOP_BIT handler now sets state=DETECTED and returns the result.
A new remaining_buffer property exposes the post-VIS samples.
_decode_audio_stream() and decode_file() capture those samples
before calling reset(), then immediately feed them into the newly
created SSTVImageDecoder so decoding begins from sample 0 of
the first sync pulse.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-18 19:06:32 +00:00
Smittix 37d24a539d fix: Remove stale dump1090 symlink before install check
If dump1090-mutability was installed by a previous run and later
removed (e.g. by apt removing it as a reverse dep), the symlink at
/usr/local/sbin/dump1090 is left pointing at a non-existent target.
cmd_exists finds the broken symlink and treats dump1090 as installed,
so the real install is skipped and running dump1090 gives
"No such file or directory".

Before the install check, resolve the command path and delete it if
it exists in PATH but is not executable (broken symlink).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-18 17:36:25 +00:00
Smittix 622f23c091 fix: Use ldconfig priority file instead of removing apt rtl-sdr packages
The apt-removal approach caused cascading failures: removing librtlsdr0
swept out dump1090-mutability and other reverse deps, then source builds
reinstalled librtlsdr-dev (pulling librtlsdr0 back), and the dump1090
subshell crashed because kill "" (empty progress_pid after progress_pid=)
returned non-zero and fired the global ERR trap.

Switch to a targeted ldconfig priority file instead:
- Write /etc/ld.so.conf.d/00-local-first.conf containing /usr/local/lib
- Files named 00-* sort before aarch64-linux-gnu.conf alphabetically,
  so ldconfig lists /usr/local/lib/librtlsdr.so.0 (Blog) first
- apt librtlsdr0, rtl-sdr, dump1090-mutability etc. are never touched
- Source build functions keep their unconditional apt_install librtlsdr-dev

Also fix the dump1090 EXIT trap: guard kill/wait against empty
progress_pid so it does not fire the ERR trap after a clean exit 0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-18 17:10:42 +00:00
Smittix b70db887b1 fix: Silence non-zero wait exit after killing dump1090 progress spinner
The global ERR trap (trap 'on_error $LINENO' ERR) fires on any non-zero
exit. After `kill $progress_pid`, `wait $progress_pid` returns 143
(128+SIGTERM), triggering the trap and aborting the build even when
make itself succeeded. Add `|| true` to all five wait calls in
install_dump1090_from_source_debian (inline and EXIT trap).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-18 17:05:07 +00:00
Smittix 7f13af3fcd fix: Prevent apt librtlsdr-dev reinstalling librtlsdr0 after Blog install
When Blog drivers are installed, apt rtl-sdr/librtlsdr0/librtlsdr-dev
are removed to ensure the Blog library in /usr/local/lib is the only
one ldconfig sees.  But four source-build functions each called
`apt_install librtlsdr-dev`, which re-pulled librtlsdr0 from apt and
immediately re-shadowed the Blog library.

Fix: each function now checks `pkg-config --exists librtlsdr` first;
if the Blog drivers (or any other /usr/local install) already provide
the headers and .pc file the apt install is skipped entirely.

Also add a post-removal guard in install_rtlsdr_blog_drivers_debian:
after apt removes librtlsdr0 it may silently sweep out dump1090-mutability
as a reverse dep.  The guard detects this and rebuilds dump1090 from
source immediately, using the Blog drivers' headers via pkg-config.

Affected functions:
- install_dump1090_from_source_debian
- install_acarsdec_from_source_debian
- install_dumpvdl2_from_source_debian
- install_aiscatcher_from_source_debian

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-18 17:01:13 +00:00
Smittix 9afff0f4b2 fix: Remove librtlsdr0 apt package when installing Blog drivers
Removing only the rtl-sdr binary package left librtlsdr0 (the library)
installed at /lib/aarch64-linux-gnu/librtlsdr.so.0. ldconfig lists the
multiarch path before /usr/local/lib, so even the Blog driver binary
(/usr/local/bin/rtl_test) was loading the old apt library — which has
no R828D/V4 tuner support — causing the PLL-not-locked / deaf dongle
symptom.

Now remove rtl-sdr, librtlsdr0, and librtlsdr-dev together so the only
librtlsdr.so.0 in the ldconfig cache is the Blog drivers' copy in
/usr/local/lib.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-18 16:55:30 +00:00
Smittix 5a7a6ce522 fix: Remove apt rtl-sdr conflict and always unload DVB modules on Pi
Two bugs caused RTL-SDR dongles to be deaf after setup on Raspberry Pi:

1. The apt `rtl-sdr` package was left installed alongside the Blog
   drivers, creating a binary/library ambiguity. Anything linking or
   calling the apt binaries in /usr/bin used the non-V4-aware library
   from /usr/lib instead of the Blog drivers in /usr/local. Fix: remove
   the apt package immediately after a successful Blog driver build.

2. `blacklist_kernel_drivers_debian` returned early with "already
   present" without ever running `modprobe -r`, so dvb_usb_rtl28xxu
   could remain loaded and hold the device in DVB mode (rtl_test sees
   the USB device but the tuner is unconfigured). Fix: always run the
   module unload loop regardless of whether the blacklist file is new.
   Also add `update-initramfs -u` so the blacklist survives reboots.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-18 16:22:45 +00:00
Smittix 36b6539044 fix: Prompt for RTL-SDR Blog V4 drivers instead of silently skipping
The previous logic installed rtl-sdr via apt first, then gated the Blog
driver install on cmd_exists rtl_test — which was always true, so V4
drivers were never installed. Replace with a yes/no prompt (default y,
backward-compatible) guarded by IS_DRAGONOS for pre-configured distros.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-18 16:12:12 +00:00
Smittix 6c6cd8a280 fix: Resolve light/dark theme issues across dashboards and settings
- Add missing setThemePreference() and setAnimationsEnabled() functions
  to settings-manager.js; sync theme/animations dropdowns in _updateUI
- Fix base.html toggleTheme() saving to wrong localStorage key ('theme'
  instead of 'intercept-theme'), causing theme not to persist in ADS-B
  and AIS dashboards; also sync button icon and persist to server
- Add [data-theme="light"] CSS variable overrides to adsb_dashboard.css
  and ais_dashboard.css so the dashboards respond to light theme
- Fix GPS sky view canvas (gps.js) to read grid/label colours from CSS
  variables instead of hardcoded dark hex values; add MutationObserver
  to redraw immediately on theme change
- Fix satellite_dashboard.html polar plot functions to read background,
  accent and text colours from CSS variables

Closes #139

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-18 13:58:49 +00:00
Smittix 4df112e712 fix: Disclaimer warning icon overlapping heading text
The .icon base class (global-nav.css) forces display:inline-flex and
width/height of 18-20px, overriding the intended 48px size and causing
the SVG to render inline inside the h2 rather than as a block above it.

Override with display:block, explicit 48px dimensions, and auto margins
so the icon renders centred above the DISCLAIMER heading.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-18 12:24:28 +00:00
Smittix 3d8b8bbfdc fix: Suppress noisy pip output during core package install
Replace the | tail -5 filter with pip --quiet and 2>/dev/null to
silence 'Requirement already satisfied' lines and the harmless
send2trash metadata warning that were leaking to the terminal.
The import verification step still catches real install failures.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-18 12:21:01 +00:00
Smittix 076339024f fix: Add newline before closing GCC pragma in SatDump lua_utils patch
If lua_utils.cpp has no trailing newline the closing pragma was appended
directly to the last line (};#pragma GCC diagnostic pop), causing a
stray '#' compile error on GCC 13+ / Raspberry Pi OS Bookworm.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-18 12:00:05 +00:00
Smittix e82f0f36d2 fix: Handle libvolk package name difference on Raspberry Pi OS
On Raspberry Pi OS Bookworm the package is libvolk2-dev, not libvolk-dev.
Also soft-fail optional SDR hardware libs (libjemalloc, libnng, SoapySDR,
HackRF, LimeSuite) so a missing package no longer aborts the SatDump build.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-18 11:51:37 +00:00
Smittix f4ade209f9 feat: Weather satellite and ADS-B trail rendering improvements
Weather Satellite:
- Fix duplicate event listeners on mode re-entry via locationListenersAttached guard
- Add suspend() to stop countdown/SSE stream when switching away from the mode
- Call WeatherSat.suspend() in switchMode() when leaving weathersat
- Fix toggleScheduler() to take the checkbox element as source of truth,
  preventing both checkboxes fighting each other
- Reset isRunning/UI state after auto-capture completes (scheduler path)
- Always re-select first pass and reset selectedPassIndex after loadPasses()
- Keep timeline cursor in sync inside selectPass()
- Add seconds to pass ID format to avoid collisions on concurrent passes
- Improve predict_passes() comment clarity; fix trajectory comment

ADS-B dashboard:
- Batch altitude-colour trail segments into runs of same-colour polylines,
  reducing Leaflet layer count from O(trail length) to O(colour changes)
  for significantly better rendering performance with many aircraft

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-18 11:43:47 +00:00
Smittix b0652595fa fix: Add parallel jobs and progress output to dump1090 Debian build
Single-threaded make on a Raspberry Pi 5 could take 5-10+ minutes
with no output, making the setup appear hung. Now uses all available
CPU cores and prints a "still compiling" heartbeat every 20s.
Also prints build log tail on failure for easier debugging.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-18 11:43:13 +00:00
Smittix 332172881e refactor: Reorganize nav groups into Signals, Tracking, Space, Wireless, Intel
Replaces the old SDR/RF, Wireless, Security, Space layout with a cleaner
five-group structure. Tracking (Aircraft, Vessels, APRS, GPS) becomes its
own top-level group; Meshtastic moves to Wireless; WebSDR and Spy Stations
move to Intel. Also fixes BT Locate overflow/min-height CSS.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-18 09:30:11 +00:00
Smittix e05ac97749 feat: Zoom map to max on first GPS lock in BT Locate
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 22:44:04 +00:00
Smittix 615a83c23f docs: Add Space Weather screenshots to GitHub Pages site
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 22:26:50 +00:00
Smittix d017375f64 docs: Add APRS screenshot to GitHub Pages site
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 22:25:48 +00:00
Smittix 0b5235f619 feat: Add SONATE-2 satellite frequencies to APRS and HF SSTV
APRS: 145.825 MHz digipeater (shared with ISS)
HF SSTV: 145.880 MHz FM (Martin M1 mode)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 22:15:13 +00:00
Smittix 16239c1d31 feat: Add Space Weather mode with real-time solar and geomagnetic monitoring
New mode providing real-time space weather data from NOAA SWPC, NASA SDO,
and HamQSL APIs. Includes Kp index, solar wind, X-ray flux charts, HF band
conditions, D-RAP absorption maps, aurora forecast, solar imagery, flare
probability, and active solar regions. No SDR hardware required.

Bumps version to 2.20.0. Updates all documentation including README, FEATURES,
USAGE, UI_GUIDE, help modal, and GitHub Pages site.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 22:10:34 +00:00
Smittix cae7a0586f fix: Update North America ACARS frequencies and add ISS APRS option
Update default ACARS frequencies for North America to 131.725/131.825 MHz and add ISS (145.825 MHz) as a selectable APRS frequency region.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 18:37:15 +00:00
Smittix 23f28a8102 fix: Resolve multiple weather satellite decoder bugs
- Fix SatDump crash reported as "Capture complete" by collecting exit
  status via process.wait() before checking returncode
- Fix PTY file descriptor double-close race between stop() and reader
  thread by adding thread-safe _close_pty() helper with dedicated lock
- Fix image watcher missing final images by doing post-exit scans after
  SatDump process ends, using threading.Event for fast wakeup
- Fix failed image copy permanently skipping file by only marking as
  known after successful copy
- Fix frontend error handler not resetting isRunning, preventing new
  captures after a crash
- Fix console auto-hide timer leak on rapid complete/error events
- Fix ground track and auto-scheduler ignoring shared ObserverLocation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 16:16:28 +00:00
Smittix 34ecec3800 fix: Hide collapse sidebar button in analytics mode
The button is unnecessary since analytics expands the sidebar to
full width with no output panel to reveal.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 15:21:18 +00:00
Smittix d40bd37406 fix: Expand analytics sections on mode switch
Sidebar sections are collapsed by default on DOMContentLoaded. When
switching to analytics mode, expand all its sections so the dashboard
content is visible immediately.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 15:18:15 +00:00
Smittix 4ed41434e2 fix: Hide output panel in analytics mode to prevent overlay
Analytics is a sidebar-only mode with no visuals container, so the
output panel was rendering on top of the analytics content. Add
analytics-active class to expand the sidebar full-width and hide
the output panel when in analytics mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 15:13:24 +00:00
Smittix 6a0b54fa0e fix: Hide output console when switching to analytics mode
The decoder output panel was not being hidden when entering analytics
mode, causing it to render on top of the analytics dashboard.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 14:22:21 +00:00
Smittix b83ecfcc19 feat: Add ACARS, VDL2, APRS, and Meshtastic to analytics dashboard
Extend cross-mode analytics to include ACARS/VDL2 message counts, APRS
stations, and Meshtastic messages. Refactor count helpers into reusable
_safe_len() and _safe_route_attr() utilities. Add health checks for
rtlamr, dmr, and meshtastic modes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 14:13:28 +00:00
Smittix 671bf38083 fix: Read WiFi/BT data from v2 scanners in analytics dashboard
The analytics summary, health, and export were only reading from legacy
DataStores (app_module.wifi_networks, bt_devices) which the v2 WiFi and
Bluetooth scanners don't populate. Now checks v2 scanner singletons
first and falls back to legacy stores.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 13:48:56 +00:00
Smittix 0f5a414a09 feat: Add cross-mode analytics dashboard with geofencing, correlations, and data export
Adds a unified analytics mode under the Security nav group that aggregates
data across all signal modes. Includes emergency squawk alerting (7700/7600/7500),
vertical rate anomaly detection, ACARS/VDL2-to-ADS-B flight correlation,
geofence zones with enter/exit detection for aircraft/vessels/APRS stations,
temporal pattern detection, RSSI history tracking, Meshtastic topology mapping,
and JSON/CSV data export.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 12:59:31 +00:00
Smittix 831426948f fix: Reconnect VDL2/ACARS streams after navigating away from ADS-B dashboard
When navigating away from the dashboard and back, the page reloads with
no knowledge of running decoders. Add status checks on page load to sync
UI state and reconnect SSE streams. Also add auto-reconnect on SSE error
with guard conditions to prevent loops when intentionally stopped.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:38:02 +00:00
Smittix df2c0a0d25 fix: Report SatDump crash as error instead of misleading "Capture complete"
Check process exit code when SatDump terminates — non-zero exit now
emits an error status with the exit code instead of falsely reporting
a successful capture completion.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:11:33 +00:00
Smittix d427f69dcd chore: Bump version to 2.19.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 23:42:41 +00:00
Smittix cab04e6e2c feat: Make trails checked by default and remove both radar modes from ADS-B
Trails checkbox now defaults to checked (on). Removed the RADAR view
toggle, Radar overlay checkbox, RadarScope class, and all associated
animation/overlay JS and CSS.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 23:39:50 +00:00
Smittix 8969fefe2e feat: Bundle Roboto Condensed woff2 for offline mode
Add latin and latin-ext woff2 variable font files for Roboto Condensed.
Update fonts-local.css with @font-face declarations using weight range
300-700. Restore conditional CDN/local font loading across all templates
and fix nested Jinja conditionals in dashboard pages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 23:33:49 +00:00
Smittix 5e9fcc5c49 feat: Switch application font to Roboto Condensed
Replace IBM Plex Mono, Space Mono, and JetBrains Mono with Roboto
Condensed across all CSS variables, inline styles, canvas ctx.font
references, and Google Fonts CDN links. Updates 28 files covering
templates, stylesheets, and JS modules for consistent typography.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 23:29:05 +00:00
Smittix 53b23fc2f7 fix: Replace emoji icons with SVGs, deduplicate help modal, fix fonts
- Replace all emoji HTML entities in Stats Bar Icons with matching SVGs
  from the actual stats bar implementation
- Remove stale inline help modal from index.html, use shared partial
- Set help modal font-family to match app-wide IBM Plex Mono
- Reduce font sizes for cleaner, more professional appearance
- Tighten padding and spacing throughout the help modal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 23:20:29 +00:00
Smittix eeb3a29ecf docs: Update help modal with all modes and correct SVG icons
Replace emoji icons with actual SVG icons matching nav.html. Add missing
mode descriptions for WebSDR, SubGHz, ISS SSTV, Weather Sat, HF SSTV,
GPS, and BT Locate. Update requirements section with all mode prereqs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 23:14:42 +00:00
Smittix 4cdfa98a4e feat: Add Support & Contact section with Buy Me a Coffee and email
Add a 4-card support section with Buy Me a Coffee (highlighted in gold),
obfuscated email (click-to-reveal to defeat scrapers), Discord, and
GitHub Issues. Email is assembled via JS at runtime with no plaintext
address in the HTML source. Added links to footer as well.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 23:04:58 +00:00
Smittix 9fcec6cbb8 feat: Add animated satellite/signal background to GitHub Pages
Canvas-based animation with orbiting satellite dots, signal pulse rings,
drifting particles, and a faint grid overlay. Uses the accent cyan color
at very low opacity to stay subtle. Particles brighten near the cursor.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 22:58:09 +00:00
Smittix a527ac191a docs: Update dashboard screenshot with v2.18.0 main screen
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 22:55:50 +00:00
Smittix 8cd3aafd10 docs: Update documentation with new modes, screenshots, and carousel UI
Add VDL2 to README, FEATURES.md, and USAGE.md. Add missing usage guides
for ACARS, WebSDR, ISS SSTV, HF SSTV, TSCM, Spy Stations, and Offline
Mode. Add ISS SSTV section to FEATURES.md. Add 7 new screenshots to
GitHub Pages (Spy Stations, GPS, WebSDR, VDL2, Weather Satellite,
Satellite Tracker, ISS SSTV). Redesign features section as a filterable
carousel with category tabs, SVG icons, and scroll indicators.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 22:52:22 +00:00
Smittix 5c76a423af feat: Remove ACARS as standalone mode, already in ADS-B dashboard
Same as VDL2 - ACARS is integrated into the ADS-B dashboard sidebar
so it doesn't need its own separate mode entry in the nav or index.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 22:25:21 +00:00
Smittix c80bf99b91 fix: Raise nav bar z-index so Space dropdown isn't clipped by content
The mode-nav dropdown menus were being visually covered by the main
content area (maps, visuals) below. Increase z-index from 100 to 1100
so dropdown menus render above all page content.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 22:22:15 +00:00
Smittix 6e5cb0a23e fix: Hide cyan scrollbar on controls bar and prevent airband cutoff
The blue bar at the bottom was the cyan-styled horizontal scrollbar on
the controls-bar. Hide it and allow the airband group to flex/wrap so
it stays within the viewport instead of overflowing off-screen.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 22:17:49 +00:00
Smittix ffb98425f1 feat: Consolidate VDL2 into ADS-B dashboard, fix blue bar, add CSV export
Remove VDL2 as a standalone mode since it's already integrated into the
ADS-B dashboard sidebar. Remove the blue border-top on the controls bar.
Add CSV export button to VDL2 panel for downloading collected messages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 22:12:03 +00:00
Smittix 533e92c711 fix: Unwrap dumpvdl2 nested vdl2 key so modal displays data correctly
dumpvdl2 JSON nests all fields under a "vdl2" object. Both the sidebar
cards and modal now unwrap this correctly. Modal sections reorganized
into Radio (signal/noise/freq/FEC), AVLC Frame, ACARS, XID, and
Message body with all available fields extracted.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 21:52:36 +00:00
Smittix 9f32b05719 feat: Replace VDL2 inline expand with modal for easier reading
Message cards now open a centered modal overlay on click with organized
sections (Connection, ACARS, Position, Message) in a readable grid
layout. Includes raw JSON toggle, closes via X button, backdrop click,
or Escape key.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 21:49:40 +00:00
Smittix 2a05aaa4d8 feat: Make VDL2 message cards clickable with expandable details
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 21:39:51 +00:00
Smittix 6529febcfa fix: Overhaul setup.sh for reliability and macOS compatibility
- Use pre-built SatDump DMG on macOS instead of building from source
  (avoids sol2/Apple Clang deprecation errors)
- Fix `python: command not found` by using explicit venv/bin/python paths
- Split pip install into core + optional packages to avoid all-or-nothing
  failures on newer Python versions
- Make dumpvdl2 optional (warn instead of fail) since VDL2 is one feature
- Fix Homebrew volk package name (libvolk -> volk)
- Add GCC 13+ sol2 deprecation pragma patch for Debian SatDump build
- Quote $(which) to handle paths with spaces
- Remove macOS sed fallback from Debian-only function
- Update TOTAL_STEPS counts (macOS: 22, Debian: 28)
- Add hdiutil detach cleanup to SatDump DMG install trap

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 21:25:15 +00:00
Smittix bd87d4b4c6 fix: Use full dumpvdl2 output specifier for v2.6.0 compatibility
dumpvdl2 2.6.0 requires the complete output specifier format
'decoded:json:file:path=-' instead of just 'decoded:json'.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 20:36:13 +00:00
Smittix 5a0589dd69 fix: Wire up VDL2 agent mode, fix dashboard layout gap and stats strip sizing
- Add VDL2 to syncModeUI setter map and allModes array in agents.js
  so agent state sync works for VDL2
- Fix dashboard bottom gap by using flex layout on body instead of
  hardcoded calc(100dvh - 160px) height
- Match source stat font-size to other stats (14px) for consistent
  strip sizing
- Add left-sidebars wrapper, VDL2 agent mode support, mutual sidebar
  collapse, and ACARS/VDL2 modeNames in index.html

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 20:27:25 +00:00
Smittix 5605ae0359 fix: Preserve GPS satellites across DOP-only SKY messages
gpsd sends multiple SKY messages per cycle — some contain only DOP
values with an empty satellites array. Previously this would overwrite
the satellite list, causing the sky view to flicker empty. Now DOP-only
SKY messages preserve the existing satellite list. Also adds a 5-second
polling fallback for satellite data since SSE can miss sky updates due
to queue contention.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 19:48:46 +00:00
Smittix 2b3f351ff0 feat: Add VDL2 mode and ACARS standalone frontend
Add VDL2 (VHF Digital Link Mode 2) decoding via dumpvdl2 as a new mode,
and promote ACARS from ADS-B-dashboard-only to a first-class standalone
mode in the main SPA. Both aviation datalink modes now have full nav
entries, sidebar partials with region-based frequency selectors, and
SSE streaming. VDL2 also integrated into the ADS-B dashboard as a
collapsible sidebar alongside ACARS.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 19:48:10 +00:00
Smittix 126b9ba2ee fix: Handle CMake 4.0+ compatibility for acarsdec build (#136)
CMake 4.0 removed backward compat with cmake_minimum_required < 3.5.
Add -DCMAKE_POLICY_VERSION_MINIMUM=3.5 to acarsdec cmake invocations
in setup.sh (macOS + Debian) and Dockerfile.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 15:41:22 +00:00
Smittix c0498ebe68 fix: Resolve gpsd deadlock causing GPS connect to hang
start_gpsd_daemon() acquires _gpsd_process_lock then calls
stop_gpsd_daemon() which tries to acquire the same non-reentrant Lock,
causing an immediate deadlock. Changed to RLock so the same thread can
re-enter the lock.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 15:38:00 +00:00
393 changed files with 77364 additions and 32691 deletions
+42
View File
@@ -0,0 +1,42 @@
## Workflow Orchestration
### 1. Plan Node Default
- Enter plan mode for ANY non-trivial task (3+ steps or architectural decisions)
- If something goes sideways, STOP and re-plan immediately - don't keep pushing
- Use plan mode for verification steps, not just building
- Write detailed specs upfront to reduce ambiguity
### 2. Subagent Strategy
- Use subagents liberally to keep main context window clean
- Offload research, exploration, and parallel analysis to subagents
- For complex problems, throw more compute at it via subagents
- One tack per subagent for focused execution
### 3. Self-Improvement Loop
- After ANY correction from the user: update 'tasks/lessons.md" with the pattern
- Write rules for yourself that prevent the same mistake
- Ruthlessly iterate on these lessons until mistake rate drops
- Review lessons at session start for relevant project
### 4. Verification Before Done
- Never mark a task complete without proving it works
- Diff behavior between main and your changes when relevant
- Ask yourself: "Would a staff engineer approve this?"
- Run tests, check logs, demonstrate correctness
### 5. Demand Elegance (Balanced)
- For non-trivial changes: pause and ask "is there a more elegant way?"
- If a fix feels hacky: "Knowing everything I know now, implement the elegant solution"
- Skip this for simple, obvious fixes - don't over-engineer
-Challenge your own work before presenting it
### 6. Autonomous Bug Fizing
- When given a bug report: just fix it. Don't ask for hand-holding
- Point at logs, errors, failing tests - then resolve them
- Zero context switching required from the user
- Go fix failing CI tests without being told how
## Task Management
1. **Plan First**: Write plan to "tasks/todo.md" with checkable items
2. **Verify Plan**: Check in before starting implementation
3. **Track Progress**: Mark items complete as you go
4. **Explain Changes**: High-level summary at each step
5. **Document Results**: Add review section to 'tasks/todo.md"
6. **Capture Lessons**: Update 'tasks/lessons.md' after corrections
## Core Principles
- **Simplicity First**: Make every change as simple as possible. Impact minimal code.
- **No Laziness**: Find root causes. No temporary fixes. Senior developer standards.
- **Minimat Impact**: Changes should only touch what's necessary. Avoid introducing bugs.
+20 -1
View File
@@ -1,6 +1,8 @@
# Git
# Git & CI
.git
.gitignore
.github
.claude
# Python
__pycache__
@@ -29,6 +31,23 @@ tests/
.coverage
htmlcov/
.mypy_cache/
.ruff_cache
.DS_Store
tasks/
# Documentation
*.md
# Runtime data (mounted as volume)
instance/
# data/ is a Python package — only exclude non-code files
data/*.json
data/*.csv
data/*.db
# Build scripts
build-multiarch.sh
# Logs
*.log
+39 -2
View File
@@ -1,2 +1,39 @@
# Uncomment and set to use external storage for ADS-B history
# PGDATA_PATH=/mnt/external/intercept/pgdata
# =============================================================================
# INTERCEPT CONTROLLER (.env)
# =============================================================================
# Copy to .env and edit for your setup
# Container timezone (e.g. America/New_York, Europe/London, Australia/Sydney)
TZ=UTC
# Flask secret key (auto-generated if not set)
# INTERCEPT_SECRET_KEY=your-secret-key-here
# Admin credentials (password auto-generated on first run if not set)
# INTERCEPT_ADMIN_USERNAME=admin
# INTERCEPT_ADMIN_PASSWORD=your-password-here
# Postgres password (default: intercept)
INTERCEPT_ADSB_DB_PASSWORD=intercept
# Auto-start ADS-B when dashboard loads
INTERCEPT_ADSB_AUTO_START=false
# Share observer location across all modules
INTERCEPT_SHARED_OBSERVER_LOCATION=true
# Observer coordinates (uncomment and set to skip GPS prompt)
# INTERCEPT_DEFAULT_LAT=40.7128
# INTERCEPT_DEFAULT_LON=-74.0060
# =============================================================================
# AGENT SETTINGS (for docker-compose.agent.yml on remote Pis)
# =============================================================================
# Agent identity
AGENT_NAME=sdr-agent-1
AGENT_PORT=8020
# Controller connection (IP of the machine running docker-compose.yml)
CONTROLLER_URL=http://192.168.1.100:5050
AGENT_API_KEY=changeme
+3
View File
@@ -0,0 +1,3 @@
# Force LF line endings for files that must run on Linux (Docker)
*.sh text eol=lf
Dockerfile text eol=lf
+26
View File
@@ -0,0 +1,26 @@
name: CI
on: [push, pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- run: pip install -r requirements-dev.txt
- run: ruff check .
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- run: pip install -r requirements-dev.txt
- name: Run tests
run: pytest --tb=short -q
continue-on-error: true
+6 -4
View File
@@ -18,10 +18,6 @@ pager_messages.log
downloads/
pgdata/
# Local data
downloads/
pgdata/
# IDE
.idea/
.vscode/
@@ -58,6 +54,9 @@ intercept_agent_*.cfg
# Weather satellite runtime data (decoded images, samples, SatDump output)
data/weather_sat/
# Radiosonde runtime data (station config, logs)
data/radiosonde/
# SDR capture files (large IQ recordings)
data/subghz/captures/
@@ -65,3 +64,6 @@ data/subghz/captures/
.env
.env.*
!.env.example
# Local utility scripts
reset-sdr.*
+202
View File
@@ -2,6 +2,208 @@
All notable changes to iNTERCEPT will be documented in this file.
## [2.26.5] - 2026-03-14
### Fixed
- **Database errors crash entire UI** — `get_setting()` now catches `sqlite3.OperationalError` and returns the default value instead of propagating the exception. Previously, if the database was inaccessible (e.g. root-owned `instance/` directory from running with `sudo`), the `inject_offline_settings` context processor would crash every page render with a 500 Internal Server Error. (#190)
---
## [2.26.4] - 2026-03-14
### Fixed
- **Environment Configurator crash** — `read_env_var()` crashed with "Setup failed at line 2333" when `.env` existed but didn't contain the variable being looked up. `grep` returned exit code 1 (no match), which `pipefail` propagated and `set -e` turned into a fatal error. Fixed by appending `|| true` to the pipeline. (#191)
---
## [2.26.3] - 2026-03-13
### Fixed
- **SatDump AVX2 crash** — SatDump now compiles with `-march=x86-64` on x86_64 platforms (Docker and `setup.sh`), preventing "Illegal instruction" crashes on CPUs without AVX2. SIMD plugins still use runtime detection for acceleration on capable hardware. (#185)
---
## [2.26.2] - 2026-03-13
### Fixed
- **Docker startup crash** — `.dockerignore` excluded the entire `data/` directory, which is now a Python package (`data.oui`, `data.patterns`, `data.satellites`). Caused `ModuleNotFoundError: No module named 'data.oui'` on container startup. Fixed by only excluding non-code files from `data/`.
---
## [2.26.1] - 2026-03-13
### Fixed
- **Default admin credentials** — Default `ADMIN_PASSWORD` changed from empty string to `admin`, matching the README documentation (`admin:admin`)
- **Config credential sync** — Admin password changes in `config.py` or via `INTERCEPT_ADMIN_PASSWORD` env var now sync to the database on restart, without needing to delete the DB
---
## [2.26.0] - 2026-03-13
### Fixed
- **SSE fanout crash** - `_run_fanout` daemon thread no longer crashes with `AttributeError: 'NoneType' object has no attribute 'get'` when source queue becomes None during interpreter shutdown
- **Branded logo FOUC** - Added inline `width`/`height` to branded "i" SVG elements across 10 templates to prevent oversized rendering before CSS loads; refresh no longer needed
---
## [2.25.0] - 2026-03-12
### Added
- **SSEManager** - Centralized SSE connection management with exponential backoff reconnection and visual connection status indicator
- **Loading button states** - `withLoadingButton()` utility for async action buttons across all modes
- **Actionable error reporting** - `reportActionableError()` added to 5 mode JS files for user-friendly error messages
- **Destructive action confirmation modals** - Custom modal system replacing 25 native `confirm()` calls
### Changed
- **Accessibility improvements** - aria-labels on interactive elements, form label associations, keyboard-navigable lists
- **CSS variable adoption** - Replaced hardcoded hex colors with CSS custom properties across 16+ files
- **Inline style extraction** - `classList.toggle()` replaces inline `display` manipulation throughout codebase
- **Merged `global-nav.css` into `layout.css`** - Consolidated navigation styles
- **Reduced `!important` usage** - Responsive.css `!important` count reduced from 71 to 8
- **Standardized breakpoints** - Unified to 480/768/1024/1280px across all responsive styles
- **Mobile UX polish** - Improved touch targets, code overflow handling, and responsive layouts
### Fixed
- Deep-linked mode scripts now wait for body parse before executing, preventing initialization failures
---
## [2.24.0] - 2026-03-10
### Added
- **WiFi Locate Mode** - Locate WiFi access points by BSSID with real-time signal meter, distance estimation, RSSI chart, and audio proximity tones. Hand-off from WiFi detail drawer, environment presets (Free Space/Outdoor/Indoor), and signal-lost detection.
### Changed
- Mobile navigation bar reorganized into labeled groups (SIG, TRK, SPC, WIFI, INTEL, SYS) for better usability
- flask-limiter made optional — rate limiting degrades gracefully if package is missing
### Fixed
- Radiosonde setup missing `semver` Python dependency — `setup.sh` now explicitly installs it alongside `requirements.txt`
## [2.23.0] - 2026-02-27
### Added
- **Radiosonde Weather Balloon Tracking** - 400-406 MHz reception via radiosonde_auto_rx with telemetry, map, and station distance tracking
- **CW/Morse Code Decoder** - Custom Goertzel tone detection with OOK/AM envelope detection mode for ISM bands
- **WeFax (Weather Fax) Decoder** - HF weather fax reception with auto-scheduler, broadcast timeline, and image gallery
- **System Health Monitoring** - Telemetry dashboard with process monitoring and system metrics
- **HTTPS Support** - TLS via `INTERCEPT_HTTPS` configuration
- **ADS-B Voice Alerts** - Text-to-speech notifications for military and emergency aircraft detections
- **HackRF TSCM RF Scan** - HackRF support added to TSCM counter-surveillance RF sweep
- **Multi-SDR WeFax** - Multiple SDR hardware support for WeFax decoder
- **Tool Path Overrides** - `INTERCEPT_*_PATH` environment variables for custom tool locations
- **Homebrew Tool Detection** - Native path detection for Apple Silicon Homebrew installations
- **Production Server** - `start.sh` with gunicorn + gevent for concurrent SSE/WebSocket handling — eliminates multi-client page load delays
### Changed
- Morse decoder rebuilt with custom Goertzel decoder, replacing multimon-ng dependency
- GPS mode upgraded to textured 3D globe visualization
- Destroy lifecycle added to all mode modules to prevent resource leaks
- Docker container now uses gunicorn + gevent by default via `start.sh`
### Fixed
- ADS-B device release leak and startup performance regression
- ADS-B probe incorrectly treating "No devices found" as success
- USB claim race condition after SDR probe
- SDR device registry collision when multiple SDR types present
- APRS 15-minute startup delay caused by pipe buffering
- APRS map centering at [0,0] when GPS unavailable
- DSC decoder ITU-R M.493 compliance issues
- Weather satellite 0dB SNR — increased sample rate for Meteor LRPT
- SSE fanout backlog causing delayed updates across all modes
- SSE reconnect packet loss during client reconnection
- Waterfall monitor tuning race conditions
- Mode FOUC (flash of unstyled content) on initial navigation
- Various Morse decoder stability and lifecycle fixes
---
## [2.22.3] - 2026-02-23
### Fixed
- Waterfall control panel rendered as unstyled text for up to 20 seconds on first visit — CSS is now loaded eagerly with the rest of the page assets
- WebSDR globe failed to render on first page load — initialization now waits for a layout frame before mounting the WebGL renderer, ensuring the container has non-zero dimensions
- Waterfall monitor audio took minutes to start — `_waitForPlayback` now only reports success on actual audio playback (`playing`/`timeupdate`), not from the WAV header alone (`loadeddata`/`canplay`)
- Waterfall monitor could not be stopped — `stopMonitor()` now pauses audio and updates the UI immediately instead of waiting for the backend stop request (which blocked for 1+ seconds during SDR process cleanup)
- Stopping the waterfall no longer shows a stale "WebSocket closed before ready" message — the `onclose` handler now detects intentional closes
---
## [2.22.1] - 2026-02-23
### Fixed
- PWA install prompt not appearing — manifest now includes required PNG icons (192×192, 512×512)
- Apple touch icon updated to PNG for iOS Safari compatibility
- Service worker cache bumped to bust stale cached assets
---
## [2.22.0] - 2026-02-23
### Added
- **Waterfall Receiver Overhaul** - WebSocket-based I/Q streaming with server-side FFT, click-to-tune, zoom controls, and auto-scaling
- **Voice Alerts** - Configurable text-to-speech event notifications across modes
- **Signal Fingerprinting** - RF device identification and pattern analysis mode
- **SignalID** - Automatic signal classification via SigIDWiki API integration
- **PWA Support** - Installable web app with service worker caching and manifest
- **Real-time Signal Scope** - Live signal visualization for pager, sensor, and SSTV modes
- **ADS-B MSG2 Surface Parsing** - Ground vehicle movement tracking from MSG2 frames
- **Cheat Sheets** - Quick reference overlays for keyboard shortcuts and mode controls
- App icon (SVG) for PWA and browser tab
### Changed
- **WebSDR overhaul** - Improved receiver management, audio streaming, and UI
- **Mode stop responsiveness** - Faster timeout handling and improved WiFi/Bluetooth scanner shutdown
- **Mode transitions** - Smoother navigation with performance instrumentation
- **BT Locate** - Refactored JS engine with improved trail management and signal smoothing
- **Listening Post** - Refactored with cross-module frequency routing
- **SSTV decoder** - State machine improvements and partial image streaming
- Analytics mode removed; per-mode analytics panels integrated into existing dashboards
### Fixed
- ADS-B SSE multi-client fanout stability and update flush timing
- WiFi scanner robustness and monitor mode teardown reliability
- Agent client reliability improvements for remote sensor nodes
- SSTV VIS detector state reporting in signal monitor diagnostics
### Documentation
- Complete documentation audit across README, FEATURES, USAGE, help modal, and GitHub Pages
- Fixed license badge (MIT → Apache 2.0) to match actual LICENSE file
- Fixed tool name `rtl_amr``rtlamr` throughout all docs
- Fixed incorrect entry point examples (`python app.py``sudo -E venv/bin/python intercept.py`)
- Removed duplicate AIS Vessel Tracking section from FEATURES.md
- Updated SSTV requirements: pure Python decoder, no external `slowrx` needed
- Added ACARS and VDL2 mode descriptions to in-app help modal
- GitHub Pages site: corrected Docker command, license, and tool name references
---
## [2.21.1] - 2026-02-20
### Fixed
- BT Locate map first-load rendering race that could cause blank/late map initialization
- BT Locate mode switch timing so Leaflet invalidation runs after panel visibility settles
- BT Locate trail restore startup latency by batching historical GPS point rendering
---
## [2.21.0] - 2026-02-20
### Added
- Analytics panels for operational insights and temporal pattern analysis
### Changed
- Global map theme refresh with improved contrast and cross-dashboard consistency
- Cross-app UX refinements for accessibility, mode consistency, and render performance
- BT Locate enhancements including improved continuity, smoothing, and confidence reporting
### Fixed
- Weather satellite auto-scheduler and Mercator tracking reliability issues
- Bluetooth/WiFi runtime health issues affecting scanner continuity
- ADS-B SSE multi-client fanout stability and remote VDL2 streaming reliability
---
## [2.15.0] - 2026-02-09
### Added
+21 -9
View File
@@ -25,15 +25,25 @@ docker compose --profile basic up -d --build
### Local Setup (Alternative)
```bash
# Initial setup (installs dependencies and configures SDR tools)
# First-time setup (interactive wizard with install profiles)
./setup.sh
# Run the application (requires sudo for SDR/network access)
# Or headless full install
./setup.sh --non-interactive
# Or install specific profiles
./setup.sh --profile=core,weather
# Run with production server (gunicorn + gevent, handles concurrent SSE/WebSocket)
sudo ./start.sh
# Or for quick local dev (Flask dev server)
sudo -E venv/bin/python intercept.py
# Or activate venv first
source venv/bin/activate
sudo -E python intercept.py
# Other setup utilities
./setup.sh --health-check # Verify installation
./setup.sh --postgres-setup # Set up ADS-B history database
./setup.sh --menu # Force interactive menu
```
### Testing
@@ -69,8 +79,10 @@ mypy .
## Architecture
### Entry Points
- `intercept.py` - Main entry point script
- `app.py` - Flask application initialization, global state management, process lifecycle, SSE streaming infrastructure
- `setup.sh` - Menu-driven installer with profile system (wizard, health check, PostgreSQL setup, env configurator, update, uninstall). Sources `.env` on startup via `start.sh`.
- `start.sh` - Production startup script (gunicorn + gevent auto-detection, CLI flags, HTTPS, `.env` sourcing, fallback to Flask dev server)
- `intercept.py` - Direct Flask dev server entry point (quick local development)
- `app.py` - Flask application initialization, global state management, process lifecycle, SSE streaming infrastructure, conditional gevent monkey-patch
### Route Blueprints (routes/)
Each signal type has its own Flask blueprint:
@@ -121,7 +133,7 @@ Each signal type has its own Flask blueprint:
### Key Patterns
**Server-Sent Events (SSE)**: All real-time features stream via SSE endpoints (`/stream_pager`, `/stream_sensor`, etc.). Pattern uses `queue.Queue` with timeout and keepalive messages.
**Server-Sent Events (SSE)**: All real-time features stream via SSE endpoints (`/stream_pager`, `/stream_sensor`, etc.). Pattern uses `queue.Queue` with timeout and keepalive messages. Under gunicorn + gevent, each SSE connection is a lightweight greenlet instead of an OS thread.
**Process Management**: External decoders run as subprocesses with output threads feeding queues. Use `safe_terminate()` for cleanup. Global locks prevent race conditions.
@@ -152,7 +164,7 @@ Each signal type has its own Flask blueprint:
- **Mode Integration**: Each mode needs entries in `index.html` at ~12 points: CSS include, welcome card, partial include, visuals container, JS include, `validModes` set, `modeGroups` map, classList toggle, `modeNames`, visuals display toggle, titles, and init call in `switchMode()`
### Docker
- `Dockerfile` - Single-stage build with all SDR tools compiled from source (dump1090, AIS-catcher, slowrx, SatDump, etc.)
- `Dockerfile` - Single-stage build with all SDR tools compiled from source (dump1090, AIS-catcher, slowrx, SatDump, etc.). CMD runs `start.sh` (gunicorn + gevent)
- `docker-compose.yml` - Two profiles: `basic` (standalone) and `history` (with Postgres for ADS-B)
- `build-multiarch.sh` - Multi-arch build script for amd64 + arm64 (RPi5)
- Data persisted via `./data:/app/data` volume mount
+209 -173
View File
@@ -1,6 +1,197 @@
# INTERCEPT - Signal Intelligence Platform
# Docker container for running the web interface
# Multi-stage build: builder compiles tools, runtime keeps only what's needed
###############################################################################
# Stage 1: Builder — compile all tools from source
###############################################################################
FROM python:3.11-slim AS builder
WORKDIR /tmp/build
# Install ALL build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
git \
pkg-config \
cmake \
librtlsdr-dev \
libusb-1.0-0-dev \
libncurses-dev \
libsndfile1-dev \
libgtk-3-dev \
libasound2-dev \
libsoapysdr-dev \
libhackrf-dev \
liblimesuite-dev \
libfftw3-dev \
libpng-dev \
libtiff-dev \
libjemalloc-dev \
libvolk-dev \
libnng-dev \
libzstd-dev \
libsqlite3-dev \
libcurl4-openssl-dev \
zlib1g-dev \
libzmq3-dev \
libpulse-dev \
libfftw3-bin \
liblapack-dev \
libglib2.0-dev \
libxml2-dev \
curl \
&& rm -rf /var/lib/apt/lists/*
# Create staging directory for all built artifacts
RUN mkdir -p /staging/usr/bin /staging/usr/local/bin /staging/usr/local/lib /staging/opt
# Build dump1090
RUN cd /tmp \
&& git clone --depth 1 https://github.com/flightaware/dump1090.git \
&& cd dump1090 \
&& sed -i 's/-Werror//g' Makefile \
&& make BLADERF=no RTLSDR=yes \
&& cp dump1090 /staging/usr/bin/dump1090-fa \
&& ln -s /usr/bin/dump1090-fa /staging/usr/bin/dump1090 \
&& rm -rf /tmp/dump1090
# Build AIS-catcher
RUN cd /tmp \
&& git clone https://github.com/jvde-github/AIS-catcher.git \
&& cd AIS-catcher \
&& mkdir build && cd build \
&& cmake .. \
&& make \
&& cp AIS-catcher /staging/usr/bin/AIS-catcher \
&& rm -rf /tmp/AIS-catcher
# Build readsb
RUN cd /tmp \
&& git clone --depth 1 https://github.com/wiedehopf/readsb.git \
&& cd readsb \
&& make BLADERF=no PLUTOSDR=no SOAPYSDR=yes \
&& cp readsb /staging/usr/bin/readsb \
&& rm -rf /tmp/readsb
# Build rx_tools
RUN cd /tmp \
&& git clone https://github.com/rxseger/rx_tools.git \
&& cd rx_tools \
&& mkdir build && cd build \
&& cmake .. \
&& make \
&& DESTDIR=/staging make install \
&& rm -rf /tmp/rx_tools
# Build acarsdec
RUN cd /tmp \
&& git clone --depth 1 https://github.com/TLeconte/acarsdec.git \
&& cd acarsdec \
&& mkdir build && cd build \
&& cmake .. -Drtl=ON -DCMAKE_POLICY_VERSION_MINIMUM=3.5 \
&& make \
&& cp acarsdec /staging/usr/bin/acarsdec \
&& rm -rf /tmp/acarsdec
# Build libacars (required by dumpvdl2)
RUN cd /tmp \
&& git clone --depth 1 https://github.com/szpajder/libacars.git \
&& cd libacars \
&& mkdir build && cd build \
&& cmake .. \
&& make \
&& make install \
&& ldconfig \
&& cp -a /usr/local/lib/libacars* /staging/usr/local/lib/ \
&& rm -rf /tmp/libacars
# Build dumpvdl2 (VDL2 aircraft datalink decoder)
RUN cd /tmp \
&& git clone --depth 1 https://github.com/szpajder/dumpvdl2.git \
&& cd dumpvdl2 \
&& mkdir build && cd build \
&& cmake .. \
&& make \
&& cp src/dumpvdl2 /staging/usr/bin/dumpvdl2 \
&& rm -rf /tmp/dumpvdl2
# Build slowrx (SSTV decoder) — pinned to known-good commit
RUN cd /tmp \
&& git clone https://github.com/windytan/slowrx.git \
&& cd slowrx \
&& git checkout ca6d7012 \
&& make \
&& install -m 0755 slowrx /staging/usr/local/bin/slowrx \
&& rm -rf /tmp/slowrx
# Build SatDump (weather satellite decoder - NOAA APT & Meteor LRPT) — pinned to v1.2.2
RUN cd /tmp \
&& git clone --depth 1 --branch 1.2.2 https://github.com/SatDump/SatDump.git \
&& cd SatDump \
&& mkdir build && cd build \
&& ARCH_FLAGS=""; if [ "$(uname -m)" = "x86_64" ]; then ARCH_FLAGS="-march=x86-64"; fi \
&& cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF -DCMAKE_INSTALL_LIBDIR=lib \
-DCMAKE_C_FLAGS="$ARCH_FLAGS" \
-DCMAKE_CXX_FLAGS="$ARCH_FLAGS" .. \
&& make -j$(nproc) \
&& make install \
&& ldconfig \
# Ensure SatDump plugins are in the expected path (handles multiarch differences)
&& mkdir -p /usr/local/lib/satdump/plugins \
&& if [ -z "$(ls /usr/local/lib/satdump/plugins/*.so 2>/dev/null)" ]; then \
for dir in /usr/local/lib/*/satdump/plugins /usr/lib/*/satdump/plugins /usr/lib/satdump/plugins; do \
if [ -d "$dir" ] && [ -n "$(ls "$dir"/*.so 2>/dev/null)" ]; then \
ln -sf "$dir"/*.so /usr/local/lib/satdump/plugins/; \
break; \
fi; \
done; \
fi \
# Copy SatDump install artifacts to staging
&& cp -a /usr/local/bin/satdump /staging/usr/local/bin/ 2>/dev/null || true \
&& cp -a /usr/local/lib/libsatdump* /staging/usr/local/lib/ 2>/dev/null || true \
&& cp -a /usr/local/lib/satdump /staging/usr/local/lib/ 2>/dev/null || true \
&& cp -a /usr/local/share/satdump /staging/usr/local/share/ 2>/dev/null; mkdir -p /staging/usr/local/share \
&& cp -a /usr/local/share/satdump /staging/usr/local/share/ 2>/dev/null || true \
&& rm -rf /tmp/SatDump
# Build hackrf CLI tools from source — avoids libhackrf0 version conflict
# between the 'hackrf' apt package and soapysdr-module-hackrf's newer libhackrf0
RUN cd /tmp \
&& git clone --depth 1 https://github.com/greatscottgadgets/hackrf.git \
&& cd hackrf/host \
&& mkdir build && cd build \
&& cmake .. \
&& make \
&& make install \
&& ldconfig \
&& cp -a /usr/local/bin/hackrf_* /staging/usr/local/bin/ 2>/dev/null || true \
&& cp -a /usr/local/lib/libhackrf* /staging/usr/local/lib/ 2>/dev/null || true \
&& rm -rf /tmp/hackrf
# Install radiosonde_auto_rx (weather balloon decoder)
RUN cd /tmp \
&& git clone --depth 1 https://github.com/projecthorus/radiosonde_auto_rx.git \
&& cd radiosonde_auto_rx/auto_rx \
&& pip install --no-cache-dir -r requirements.txt semver \
&& bash build.sh \
&& mkdir -p /staging/opt/radiosonde_auto_rx/auto_rx \
&& cp -r . /staging/opt/radiosonde_auto_rx/auto_rx/ \
&& chmod +x /staging/opt/radiosonde_auto_rx/auto_rx/auto_rx.py \
&& rm -rf /tmp/radiosonde_auto_rx
# Build rtlamr (utility meter decoder - requires Go)
RUN cd /tmp \
&& curl -fsSL "https://go.dev/dl/go1.22.5.linux-$(dpkg --print-architecture).tar.gz" | tar -C /usr/local -xz \
&& export PATH="$PATH:/usr/local/go/bin" \
&& export GOPATH=/tmp/gopath \
&& go install github.com/bemasher/rtlamr@latest \
&& cp /tmp/gopath/bin/rtlamr /staging/usr/bin/rtlamr \
&& rm -rf /usr/local/go /tmp/gopath
###############################################################################
# Stage 2: Runtime — lean image with only runtime dependencies
###############################################################################
FROM python:3.11-slim
LABEL maintainer="INTERCEPT Project"
@@ -12,12 +203,10 @@ WORKDIR /app
# Pre-accept tshark non-root capture prompt for non-interactive install
RUN echo 'wireshark-common wireshark-common/install-setuid boolean true' | debconf-set-selections
# Install system dependencies for SDR tools
# Install ONLY runtime dependencies (no -dev packages, no build tools)
RUN apt-get update && apt-get install -y --no-install-recommends \
# RTL-SDR tools
rtl-sdr \
librtlsdr-dev \
libusb-1.0-0-dev \
# 433MHz decoder
rtl-433 \
# Pager decoder
@@ -43,7 +232,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# GPS support
gpsd \
gpsd-clients \
# Utilities
# APRS
direwolf \
# WiFi Extra
@@ -57,178 +245,22 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
soapysdr-module-airspy \
airspy \
limesuite \
hackrf \
# Utilities
curl \
procps \
&& rm -rf /var/lib/apt/lists/*
# Build dump1090-fa and acarsdec from source (packages not available in slim repos)
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
git \
pkg-config \
cmake \
libncurses-dev \
libsndfile1-dev \
# GTK is required for slowrx (SSTV decoder GUI dependency).
# Note: slowrx is kept for backwards compatibility, but the pure Python
# SSTV decoder in utils/sstv/ is now the primary implementation.
# GTK can be removed if slowrx is deprecated in future releases.
libgtk-3-dev \
libasound2-dev \
libsoapysdr-dev \
libhackrf-dev \
liblimesuite-dev \
libfftw3-dev \
libpng-dev \
libtiff-dev \
libjemalloc-dev \
libvolk-dev \
libnng-dev \
libzstd-dev \
libsqlite3-dev \
libcurl4-openssl-dev \
zlib1g-dev \
libzmq3-dev \
libpulse-dev \
libfftw3-dev \
liblapack-dev \
libcodec2-dev \
# Build dump1090
&& cd /tmp \
&& git clone --depth 1 https://github.com/flightaware/dump1090.git \
&& cd dump1090 \
&& sed -i 's/-Werror//g' Makefile \
&& make BLADERF=no RTLSDR=yes \
&& cp dump1090 /usr/bin/dump1090-fa \
&& ln -s /usr/bin/dump1090-fa /usr/bin/dump1090 \
&& rm -rf /tmp/dump1090 \
# Build AIS-catcher
&& cd /tmp \
&& git clone https://github.com/jvde-github/AIS-catcher.git \
&& cd AIS-catcher \
&& mkdir build && cd build \
&& cmake .. \
&& make \
&& cp AIS-catcher /usr/bin/AIS-catcher \
&& cd /tmp \
&& rm -rf /tmp/AIS-catcher \
# Build readsb
&& cd /tmp \
&& git clone --depth 1 https://github.com/wiedehopf/readsb.git \
&& cd readsb \
&& make BLADERF=no PLUTOSDR=no SOAPYSDR=yes \
&& cp readsb /usr/bin/readsb \
&& cd /tmp \
&& rm -rf /tmp/readsb \
# Build rx_tools
&& cd /tmp \
&& git clone https://github.com/rxseger/rx_tools.git \
&& cd rx_tools \
&& mkdir build && cd build \
&& cmake .. \
&& make \
&& make install \
&& cd /tmp \
&& rm -rf /tmp/rx_tools \
# Build acarsdec
&& cd /tmp \
&& git clone --depth 1 https://github.com/TLeconte/acarsdec.git \
&& cd acarsdec \
&& mkdir build && cd build \
&& cmake .. -Drtl=ON \
&& make \
&& cp acarsdec /usr/bin/acarsdec \
&& rm -rf /tmp/acarsdec \
# Build slowrx (SSTV decoder) — pinned to known-good commit
&& cd /tmp \
&& git clone https://github.com/windytan/slowrx.git \
&& cd slowrx \
&& git checkout ca6d7012 \
&& make \
&& install -m 0755 slowrx /usr/local/bin/slowrx \
&& rm -rf /tmp/slowrx \
# Build SatDump (weather satellite decoder - NOAA APT & Meteor LRPT) — pinned to v1.2.2
&& cd /tmp \
&& git clone --depth 1 --branch 1.2.2 https://github.com/SatDump/SatDump.git \
&& cd SatDump \
&& mkdir build && cd build \
&& cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF -DCMAKE_INSTALL_LIBDIR=lib .. \
&& make -j$(nproc) \
&& make install \
&& ldconfig \
# Ensure SatDump plugins are in the expected path (handles multiarch differences)
&& mkdir -p /usr/local/lib/satdump/plugins \
&& if [ -z "$(ls /usr/local/lib/satdump/plugins/*.so 2>/dev/null)" ]; then \
for dir in /usr/local/lib/*/satdump/plugins /usr/lib/*/satdump/plugins /usr/lib/satdump/plugins; do \
if [ -d "$dir" ] && [ -n "$(ls "$dir"/*.so 2>/dev/null)" ]; then \
ln -sf "$dir"/*.so /usr/local/lib/satdump/plugins/; \
break; \
fi; \
done; \
fi \
&& cd /tmp \
&& rm -rf /tmp/SatDump \
# Build rtlamr (utility meter decoder - requires Go)
&& cd /tmp \
&& curl -fsSL "https://go.dev/dl/go1.22.5.linux-$(dpkg --print-architecture).tar.gz" | tar -C /usr/local -xz \
&& export PATH="$PATH:/usr/local/go/bin" \
&& export GOPATH=/tmp/gopath \
&& go install github.com/bemasher/rtlamr@latest \
&& cp /tmp/gopath/bin/rtlamr /usr/bin/rtlamr \
&& rm -rf /usr/local/go /tmp/gopath \
# Build mbelib (required by DSD)
&& cd /tmp \
&& git clone https://github.com/lwvmobile/mbelib.git \
&& cd mbelib \
&& (git checkout ambe_tones || true) \
&& mkdir build && cd build \
&& cmake .. \
&& make -j$(nproc) \
&& make install \
&& ldconfig \
&& rm -rf /tmp/mbelib \
# Build DSD-FME (Digital Speech Decoder for DMR/P25)
&& cd /tmp \
&& git clone --depth 1 https://github.com/lwvmobile/dsd-fme.git \
&& cd dsd-fme \
&& mkdir build && cd build \
&& cmake .. \
&& make -j$(nproc) \
&& make install \
&& ldconfig \
&& rm -rf /tmp/dsd-fme \
# Cleanup build tools to reduce image size
# libgtk-3-dev is explicitly removed; runtime GTK libs remain for slowrx
&& apt-get remove -y \
build-essential \
git \
pkg-config \
cmake \
libncurses-dev \
libsndfile1-dev \
libgtk-3-dev \
libasound2-dev \
libpng-dev \
libtiff-dev \
libjemalloc-dev \
libvolk-dev \
libnng-dev \
libzstd-dev \
libsoapysdr-dev \
libhackrf-dev \
liblimesuite-dev \
libsqlite3-dev \
libcurl4-openssl-dev \
zlib1g-dev \
libzmq3-dev \
libpulse-dev \
libfftw3-dev \
liblapack-dev \
libcodec2-dev \
&& apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/*
# Copy compiled binaries and libraries from builder stage
COPY --from=builder /staging/usr/bin/ /usr/bin/
COPY --from=builder /staging/usr/local/bin/ /usr/local/bin/
COPY --from=builder /staging/usr/local/lib/ /usr/local/lib/
COPY --from=builder /staging/opt/ /opt/
# Copy radiosonde Python dependencies installed during builder stage
COPY --from=builder /usr/local/lib/python3.11/site-packages/ /usr/local/lib/python3.11/site-packages/
# Refresh shared library cache for custom-built libraries
RUN ldconfig
# Copy requirements first for better caching
COPY requirements.txt .
@@ -237,11 +269,15 @@ RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Strip Windows CRLF from shell scripts (git autocrlf can re-introduce them)
RUN find . -name '*.sh' -exec sed -i 's/\r$//' {} +
# Create data directory for persistence
RUN mkdir -p /app/data /app/data/weather_sat
RUN mkdir -p /app/data /app/data/weather_sat /app/data/radiosonde/logs
# Expose web interface port
EXPOSE 5050
EXPOSE 5443
# Environment variables with defaults
ENV INTERCEPT_HOST=0.0.0.0 \
@@ -254,4 +290,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -sf http://localhost:5050/health || exit 1
# Run the application
CMD ["python", "intercept.py"]
CMD ["/bin/bash", "start.sh"]
+126 -40
View File
@@ -1,13 +1,15 @@
# 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+">
<img src="https://img.shields.io/badge/license-MIT-green.svg" alt="MIT License">
<img src="https://img.shields.io/badge/license-Apache--2.0-green.svg" alt="Apache 2.0 License">
<img src="https://img.shields.io/badge/platform-macOS%20%7C%20Linux-lightgrey.svg" alt="Platform">
</p>
<p align="center">
Support the developer of this open-source project
Support the developer of this open-source project
</p>
<p align="center">
@@ -32,6 +34,7 @@ Support the developer of this open-source project
- **Aircraft Tracking** - ADS-B via dump1090 with real-time map and radar
- **Vessel Tracking** - AIS ship tracking with VHF DSC distress monitoring
- **ACARS Messaging** - Aircraft datalink messages via acarsdec
- **VDL2** - VHF Data Link Mode 2 aircraft datalink decoding via dumpvdl2
- **Listening Post** - Wideband frequency scanner with real-time audio monitoring
- **Weather Satellites** - NOAA APT and Meteor LRPT image decoding via SatDump with auto-scheduler
- **WebSDR** - Remote HF/shortwave listening via KiwiSDR network
@@ -39,30 +42,101 @@ Support the developer of this open-source project
- **HF SSTV** - Terrestrial SSTV on shortwave frequencies (80m-10m, VHF, UHF)
- **APRS** - Amateur packet radio position reports and telemetry via direwolf
- **Satellite Tracking** - Pass prediction with polar plot and ground track map
- **Utility Meters** - Electric, gas, and water meter reading via rtl_amr
- **Utility Meters** - Electric, gas, and water meter reading via rtlamr
- **ADS-B History** - Persistent aircraft history with reporting dashboard (Postgres optional)
- **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng
- **Bluetooth Scanning** - Device discovery and tracker detection (with Ubertooth support)
- **BT Locate** - SAR Bluetooth device location with GPS-tagged signal trail mapping and proximity alerts
- **WiFi Locate** - Locate WiFi access points by BSSID with real-time signal meter, distance estimation, and proximity audio
- **GPS** - Real-time GPS position tracking with live map, speed, altitude, and satellite info
- **TSCM** - Counter-surveillance with RF baseline comparison and threat detection
- **Meshtastic** - LoRa mesh network integration
- **Space Weather** - Real-time solar and geomagnetic data from NOAA SWPC, NASA SDO, and HamQSL (no SDR required)
- **Spy Stations** - Number stations and diplomatic HF network database
- **Remote Agents** - Distributed SIGINT with remote sensor nodes
- **Offline Mode** - Bundled assets for air-gapped/field deployments
---
## Installation / Debian / Ubuntu / MacOS
## CW / Morse Decoder Notes
```
Live backend:
- Uses `rtl_fm` piped into `multimon-ng` (`MORSE_CW`) for real-time decode.
Recommended baseline settings:
- **Tone**: `700 Hz`
- **Bandwidth**: `200 Hz` (use `100 Hz` for crowded bands, `400 Hz` for drifting signals)
- **Threshold Mode**: `Auto`
- **WPM Mode**: `Auto`
Auto Tone Track behavior:
- Continuously measures nearby tone energy around the configured CW pitch.
- Steers the detector toward the strongest valid CW tone when signal-to-noise is sufficient.
- Use **Hold Tone Lock** to freeze tracking once the desired signal is centered.
Troubleshooting (no decode / noisy decode):
- Confirm demod path is **USB/CW-compatible** and frequency is tuned correctly.
- If multiple SDRs are connected and the selected one has no PCM output, Morse startup now auto-tries other detected SDR devices and reports the active device/serial in status logs.
- Match **tone** and **bandwidth** to the actual sidetone/pitch.
- Try **Threshold Auto** first; if needed, switch to manual threshold and recalibrate.
- Use **Reset/Calibrate** after major frequency or band condition changes.
- Raise **Minimum Signal Gate** to suppress random noise keying.
---
## Installation / Debian / Ubuntu / macOS
### Quick Start
**1. Clone and run:**
```bash
git clone https://github.com/smittix/intercept.git
cd intercept
./setup.sh
sudo -E venv/bin/python intercept.py
./setup.sh # Interactive menu (first run launches setup wizard)
sudo ./start.sh
```
On first run, `setup.sh` launches a **guided wizard** that detects your OS, lets you choose install profiles, sets up the Python environment, and optionally configures environment variables and PostgreSQL.
On subsequent runs, it opens an **interactive menu**:
```
INTERCEPT Setup Menu
════════════════════════════════════════
1) Install / Add Modules
2) System Health Check
3) Database Setup (ADS-B History)
4) Update Tools
5) Environment Configurator
6) Uninstall / Cleanup
7) View Status
0) Exit
```
> **Production vs Dev server:** `start.sh` auto-detects gunicorn + gevent and runs a production server with cooperative greenlets — handles multiple SSE/WebSocket clients without blocking. Falls back to Flask dev server if gunicorn is not installed. For quick local development, you can still use `sudo -E venv/bin/python intercept.py` directly.
### Install Profiles
Choose what to install during the wizard or via menu option 1:
| # | Profile | Tools |
|---|---------|-------|
| 1 | Core SIGINT | rtl_sdr, multimon-ng, rtl_433, dump1090, acarsdec, dumpvdl2, ffmpeg, gpsd |
| 2 | Maritime & Radio | AIS-catcher, direwolf |
| 3 | Weather & Space | SatDump, radiosonde_auto_rx |
| 4 | RF Security | aircrack-ng, HackRF, BlueZ, hcxtools, Ubertooth, SoapySDR |
| 5 | Full SIGINT | All of the above |
| 6 | Custom | Per-tool checklist |
Multiple profiles can be combined (e.g. enter `1 3` for Core + Weather).
### CLI Flags
```bash
./setup.sh --non-interactive # Headless full install (same as legacy behavior)
./setup.sh --profile=core,weather # Install specific profiles
./setup.sh --health-check # Check system health and exit
./setup.sh --postgres-setup # Run PostgreSQL setup and exit
./setup.sh --menu # Force interactive menu
```
### Docker
@@ -114,16 +188,40 @@ INTERCEPT_IMAGE=ghcr.io/youruser/intercept:latest
docker compose --profile basic up -d
```
### ADS-B History (Optional)
### Environment Configuration
The ADS-B history feature persists aircraft messages to Postgres for long-term analysis.
Use the **Environment Configurator** (menu option 5) to interactively set any `INTERCEPT_*` variable. Settings are saved to a `.env` file that `start.sh` sources automatically on startup.
You can also create or edit `.env` manually:
```bash
# .env (auto-loaded by start.sh)
INTERCEPT_PORT=5050
INTERCEPT_ADSB_AUTO_START=true
INTERCEPT_DEFAULT_LAT=51.5074
INTERCEPT_DEFAULT_LON=-0.1278
```
### ADS-B History (Optional)
The ADS-B history feature persists aircraft messages to PostgreSQL for long-term analysis.
**Automated setup (local install):**
```bash
./setup.sh --postgres-setup
# Or use menu option 3: Database Setup
```
This will install PostgreSQL if needed, create the database/user/tables, and write the connection settings to `.env`.
**Docker:**
```bash
# Start with ADS-B history and Postgres
docker compose --profile history up -d
```
Set the following environment variables (for example in a `.env` file):
Set the following environment variables (in `.env`):
```bash
INTERCEPT_ADSB_HISTORY_ENABLED=true
@@ -134,30 +232,6 @@ INTERCEPT_ADSB_DB_USER=intercept
INTERCEPT_ADSB_DB_PASSWORD=intercept
```
### Other ADS-B Settings
Set these as environment variables for either local installs or Docker:
| Variable | Default | Description |
|----------|---------|-------------|
| `INTERCEPT_ADSB_AUTO_START` | `false` | Auto-start ADS-B tracking when the dashboard loads |
| `INTERCEPT_SHARED_OBSERVER_LOCATION` | `true` | Share observer location across ADS-B/AIS/SSTV/Satellite modules |
**Local install example**
```bash
INTERCEPT_ADSB_AUTO_START=true \
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
python app.py
```
**Docker example (.env)**
```bash
INTERCEPT_ADSB_AUTO_START=true
INTERCEPT_SHARED_OBSERVER_LOCATION=false
```
To store Postgres data on external storage, set `PGDATA_PATH` (defaults to `./pgdata`):
```bash
@@ -166,11 +240,22 @@ PGDATA_PATH=/mnt/usbpi1/intercept/pgdata
Then open **/adsb/history** for the reporting dashboard.
### System Health Check
Verify your installation is complete and working:
```bash
./setup.sh --health-check
# Or use menu option 2
```
Checks installed tools, SDR devices, port availability, permissions, Python venv, `.env` configuration, and PostgreSQL connectivity.
### Open the Interface
After starting, open **http://localhost:5050** in your browser. The username and password is <b>admin</b>:<b>admin</b>
After starting, open **http://localhost:5050** in your browser. The username and password is <b>admin</b>:<b>admin</b>
The credentials can be change in the ADMIN_USERNAME & ADMIN_PASSWORD variables in config.py
The credentials can be changed in the ADMIN_USERNAME & ADMIN_PASSWORD variables in config.py
---
@@ -243,7 +328,8 @@ Created by **smittix** - [GitHub](https://github.com/smittix)
[AIS-catcher](https://github.com/jvde-github/AIS-catcher) |
[acarsdec](https://github.com/TLeconte/acarsdec) |
[direwolf](https://github.com/wb2osz/direwolf) |
[rtl_amr](https://github.com/bemasher/rtlamr) |
[rtlamr](https://github.com/bemasher/rtlamr) |
[dumpvdl2](https://github.com/szpajder/dumpvdl2) |
[aircrack-ng](https://www.aircrack-ng.org/) |
[Leaflet.js](https://leafletjs.com/) |
[SatDump](https://github.com/SatDump/SatDump) |
+1 -1
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,4 +1,4 @@
{
"version": "2026-02-01_ba81b697",
"downloaded": "2026-02-04T17:06:54.806043Z"
"version": "2026-02-22_17194a71",
"downloaded": "2026-02-27T10:41:04.872620Z"
}
+498 -157
View File
@@ -6,8 +6,8 @@ Flask application and shared state.
from __future__ import annotations
import sys
import site
import sys
from utils.database import get_db
@@ -17,54 +17,128 @@ if not site.ENABLE_USER_SITE:
if user_site and user_site not in sys.path:
sys.path.insert(0, user_site)
import logging
import os
import queue
import threading
import platform
import queue
import subprocess
import threading
from pathlib import Path
from typing import Any
from flask import Flask, render_template, jsonify, send_file, Response, request,redirect, url_for, flash, session
from flask import (
Flask,
Response,
flash,
jsonify,
redirect,
render_template,
request,
send_file,
send_from_directory,
session,
url_for,
)
from werkzeug.security import check_password_hash
from config import VERSION, CHANGELOG, SHARED_OBSERVER_LOCATION_ENABLED, DEFAULT_LATITUDE, DEFAULT_LONGITUDE
from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
from utils.process import cleanup_stale_processes, cleanup_stale_dump1090
from utils.sdr import SDRFactory
from config import CHANGELOG, DEFAULT_LATITUDE, DEFAULT_LONGITUDE, SHARED_OBSERVER_LOCATION_ENABLED, VERSION
from utils.cleanup import DataStore, cleanup_manager
from utils.constants import (
MAX_AIRCRAFT_AGE_SECONDS,
MAX_WIFI_NETWORK_AGE_SECONDS,
MAX_BT_DEVICE_AGE_SECONDS,
MAX_VESSEL_AGE_SECONDS,
MAX_DSC_MESSAGE_AGE_SECONDS,
MAX_DEAUTH_ALERTS_AGE_SECONDS,
MAX_DSC_MESSAGE_AGE_SECONDS,
MAX_VESSEL_AGE_SECONDS,
MAX_WIFI_NETWORK_AGE_SECONDS,
QUEUE_MAX_SIZE,
)
import logging
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from utils.dependencies import check_all_dependencies, check_tool
from utils.process import cleanup_stale_dump1090, cleanup_stale_processes
from utils.sdr import SDRFactory
try:
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
_has_limiter = True
except ImportError:
_has_limiter = False
try:
from flask_compress import Compress
_has_compress = True
except ImportError:
_has_compress = False
try:
from flask_wtf.csrf import CSRFProtect
_has_csrf = True
except ImportError:
_has_csrf = False
# Track application start time for uptime calculation
import contextlib
import time as _time
_app_start_time = _time.time()
logger = logging.getLogger('intercept.database')
# Create Flask app
app = Flask(__name__)
app.secret_key = "signals_intelligence_secret" # Required for flash messages
def _load_or_generate_secret_key():
"""Load secret key from env var or instance file, generating if needed."""
env_key = os.environ.get('INTERCEPT_SECRET_KEY')
if env_key:
return env_key
key_path = Path('instance/secret.key')
if key_path.exists():
return key_path.read_text().strip()
key_path.parent.mkdir(exist_ok=True)
key = os.urandom(32).hex()
key_path.write_text(key)
return key
app.secret_key = _load_or_generate_secret_key()
# Set up HTTP compression (gzip/brotli for HTML, CSS, JS, JSON)
if _has_compress:
Compress(app)
else:
logging.getLogger('intercept').warning(
"flask-compress not installed HTTP compression disabled. "
"Install with: pip install flask-compress"
)
# Set up rate limiting
limiter = Limiter(
key_func=get_remote_address, # Identifies the user by their IP
app=app,
storage_uri="memory://", # Use RAM memory (change to redis:// etc. for distributed setups)
)
if _has_limiter:
limiter = Limiter(
key_func=get_remote_address,
app=app,
storage_uri="memory://",
)
else:
logging.getLogger('intercept').warning(
"flask-limiter not installed rate limiting disabled. "
"Install with: pip install flask-limiter"
)
class _NoopLimiter:
"""Stub so @limiter.limit() decorators are silently ignored."""
def limit(self, *a, **kw):
def decorator(f):
return f
return decorator
limiter = _NoopLimiter()
# Set up CSRF protection
if _has_csrf:
csrf = CSRFProtect(app)
else:
logging.getLogger('intercept').warning(
"flask-wtf not installed CSRF protection disabled. "
"Install with: pip install flask-wtf"
)
csrf = None
# Disable Werkzeug debugger PIN (not needed for local development tool)
os.environ['WERKZEUG_DEBUG_PIN'] = 'off'
# ============================================
# ERROR HANDLERS
# ERROR HANDLERS
# ============================================
@app.errorhandler(429)
def ratelimit_handler(e):
@@ -89,6 +163,12 @@ def add_security_headers(response):
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
# Permissions policy (disable unnecessary features)
response.headers['Permissions-Policy'] = 'geolocation=(self), microphone=()'
# Cache-Control for static assets
if request.path.startswith('/static/'):
if '/vendor/' in request.path:
response.headers['Cache-Control'] = 'public, max-age=604800' # 7 days for vendored libs
else:
response.headers['Cache-Control'] = 'public, max-age=86400' # 24h for app assets
return response
@@ -100,11 +180,24 @@ def add_security_headers(response):
def inject_offline_settings():
"""Inject offline settings into all templates."""
from utils.database import get_setting
# Privacy-first defaults: keep dashboard assets/fonts local to avoid
# third-party tracker/storage defenses in strict browsers.
assets_source = str(get_setting('offline.assets_source', 'local') or 'local').lower()
fonts_source = str(get_setting('offline.fonts_source', 'local') or 'local').lower()
if assets_source not in ('local', 'cdn'):
assets_source = 'local'
if fonts_source not in ('local', 'cdn'):
fonts_source = 'local'
# Force local delivery for core dashboard pages.
assets_source = 'local'
fonts_source = 'local'
return {
'offline_settings': {
'enabled': get_setting('offline.enabled', False),
'assets_source': get_setting('offline.assets_source', 'cdn'),
'fonts_source': get_setting('offline.fonts_source', 'cdn'),
'assets_source': assets_source,
'fonts_source': fonts_source,
'tile_provider': get_setting('offline.tile_provider', 'cartodb_dark_cyan'),
'tile_server_url': get_setting('offline.tile_server_url', '')
}
@@ -150,6 +243,11 @@ acars_process = None
acars_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
acars_lock = threading.Lock()
# VDL2 aircraft datalink
vdl2_process = None
vdl2_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
vdl2_lock = threading.Lock()
# APRS amateur radio tracking
aprs_process = None
aprs_rtl_process = None
@@ -172,12 +270,6 @@ dsc_rtl_process = None
dsc_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
dsc_lock = threading.Lock()
# DMR / Digital Voice
dmr_process = None
dmr_rtl_process = None
dmr_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
dmr_lock = threading.Lock()
# TSCM (Technical Surveillance Countermeasures)
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
tscm_lock = threading.Lock()
@@ -186,6 +278,26 @@ tscm_lock = threading.Lock()
subghz_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
subghz_lock = threading.Lock()
# Radiosonde weather balloon tracking
radiosonde_process = None
radiosonde_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
radiosonde_lock = threading.Lock()
# CW/Morse code decoder
morse_process = None
morse_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
morse_lock = threading.Lock()
# Meteor scatter detection
meteor_process = None
meteor_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
meteor_lock = threading.Lock()
# Generic OOK signal decoder
ook_process = None
ook_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
ook_lock = threading.Lock()
# Deauth Attack Detection
deauth_detector = None
deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
@@ -240,12 +352,12 @@ cleanup_manager.register(deauth_alerts)
# SDR DEVICE REGISTRY
# ============================================
# Tracks which mode is using which SDR device to prevent conflicts
# Key: device_index (int), Value: mode_name (str)
sdr_device_registry: dict[int, str] = {}
# Key: "sdr_type:device_index" (str), Value: mode_name (str)
sdr_device_registry: dict[str, str] = {}
sdr_device_registry_lock = threading.Lock()
def claim_sdr_device(device_index: int, mode_name: str) -> str | None:
def claim_sdr_device(device_index: int, mode_name: str, sdr_type: str = 'rtlsdr') -> str | None:
"""Claim an SDR device for a mode.
Checks the in-app registry first, then probes the USB device to
@@ -255,43 +367,48 @@ def claim_sdr_device(device_index: int, mode_name: str) -> str | None:
Args:
device_index: The SDR device index to claim
mode_name: Name of the mode claiming the device (e.g., 'sensor', 'rtlamr')
sdr_type: SDR type string (e.g., 'rtlsdr', 'hackrf', 'limesdr')
Returns:
Error message if device is in use, None if successfully claimed
"""
key = f"{sdr_type}:{device_index}"
with sdr_device_registry_lock:
if device_index in sdr_device_registry:
in_use_by = sdr_device_registry[device_index]
return f'SDR device {device_index} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.'
if key in sdr_device_registry:
in_use_by = sdr_device_registry[key]
return f'SDR device {sdr_type}:{device_index} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.'
# Probe the USB device to catch external processes holding the handle
try:
from utils.sdr.detection import probe_rtlsdr_device
usb_error = probe_rtlsdr_device(device_index)
if usb_error:
return usb_error
except Exception:
pass # If probe fails, let the caller proceed normally
if sdr_type == 'rtlsdr':
try:
from utils.sdr.detection import probe_rtlsdr_device
usb_error = probe_rtlsdr_device(device_index)
if usb_error:
return usb_error
except Exception:
pass # If probe fails, let the caller proceed normally
sdr_device_registry[device_index] = mode_name
sdr_device_registry[key] = mode_name
return None
def release_sdr_device(device_index: int) -> None:
def release_sdr_device(device_index: int, sdr_type: str = 'rtlsdr') -> None:
"""Release an SDR device from the registry.
Args:
device_index: The SDR device index to release
sdr_type: SDR type string (e.g., 'rtlsdr', 'hackrf', 'limesdr')
"""
key = f"{sdr_type}:{device_index}"
with sdr_device_registry_lock:
sdr_device_registry.pop(device_index, None)
sdr_device_registry.pop(key, None)
def get_sdr_device_status() -> dict[int, str]:
def get_sdr_device_status() -> dict[str, str]:
"""Get current SDR device allocations.
Returns:
Dictionary mapping device indices to mode names
Dictionary mapping 'sdr_type:device_index' keys to mode names
"""
with sdr_device_registry_lock:
return dict(sdr_device_registry)
@@ -322,7 +439,7 @@ def require_login():
# If user is not logged in and the current route is not allowed...
if 'logged_in' not in session and request.endpoint not in allowed_routes:
return redirect(url_for('login'))
@app.route('/logout')
def logout():
session.pop('logged_in', None)
@@ -334,7 +451,7 @@ def login():
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
# Connect to DB and find user
with get_db() as conn:
cursor = conn.execute(
@@ -349,13 +466,13 @@ def login():
session['logged_in'] = True
session['username'] = username
session['role'] = user['role']
logger.info(f"User '{username}' logged in successfully.")
return redirect(url_for('index'))
else:
logger.warning(f"Failed login attempt for username: {username}")
flash("ACCESS DENIED: INVALID CREDENTIALS", "error")
return render_template('login.html', version=VERSION)
@app.route('/')
@@ -384,6 +501,18 @@ def favicon() -> Response:
return send_file('favicon.svg', mimetype='image/svg+xml')
@app.route('/sw.js')
def service_worker() -> Response:
resp = send_from_directory('static', 'sw.js', mimetype='application/javascript')
resp.headers['Service-Worker-Allowed'] = '/'
return resp
@app.route('/manifest.json')
def pwa_manifest() -> Response:
return send_from_directory('static', 'manifest.json', mimetype='application/manifest+json')
@app.route('/devices')
def get_devices() -> Response:
"""Get all detected SDR devices with hardware type info."""
@@ -400,8 +529,9 @@ def get_devices_status() -> Response:
result = []
for device in devices:
d = device.to_dict()
d['in_use'] = device.index in registry
d['used_by'] = registry.get(device.index)
key = f"{device.sdr_type.value}:{device.index}"
d['in_use'] = key in registry
d['used_by'] = registry.get(key)
result.append(d)
return jsonify(result)
@@ -656,68 +786,183 @@ def _get_subghz_active() -> bool:
return False
def _get_dmr_active() -> bool:
"""Check if Digital Voice decoder has an active process."""
def _get_singleton_running(module_path: str, getter_name: str, attr: str) -> bool:
"""Safely check if a singleton-based mode is running without creating instances."""
try:
from routes import dmr as dmr_module
proc = dmr_module.dmr_dsd_process
return bool(dmr_module.dmr_running and proc and proc.poll() is None)
import importlib
mod = importlib.import_module(module_path)
getter = getattr(mod, getter_name)
instance = getter()
if instance is None:
return False
return bool(getattr(instance, attr, False))
except Exception:
return False
def _get_tscm_active() -> bool:
"""Check if a TSCM sweep is running."""
try:
from routes.tscm import _sweep_running
return bool(_sweep_running)
except Exception:
return False
def _get_bluetooth_health() -> tuple[bool, int]:
"""Return Bluetooth active state and best-effort device count."""
legacy_running = bt_process is not None and (bt_process.poll() is None if bt_process else False)
scanner_running = False
scanner_count = 0
try:
from utils.bluetooth.scanner import _scanner_instance as bt_scanner
if bt_scanner is not None:
scanner_running = bool(bt_scanner.is_scanning)
scanner_count = int(bt_scanner.device_count)
except Exception:
scanner_running = False
scanner_count = 0
locate_running = False
try:
from utils.bt_locate import get_locate_session
session = get_locate_session()
if session and getattr(session, 'active', False):
scanner = getattr(session, '_scanner', None)
locate_running = bool(scanner and scanner.is_scanning)
except Exception:
locate_running = False
return (legacy_running or scanner_running or locate_running), max(len(bt_devices), scanner_count)
def _get_wifi_health() -> tuple[bool, int, int]:
"""Return WiFi active state and best-effort network/client counts."""
legacy_running = wifi_process is not None and (wifi_process.poll() is None if wifi_process else False)
scanner_running = False
scanner_networks = 0
scanner_clients = 0
try:
from utils.wifi.scanner import _scanner_instance as wifi_scanner
if wifi_scanner is not None:
status = wifi_scanner.get_status()
scanner_running = bool(status.is_scanning)
scanner_networks = int(status.networks_found or 0)
scanner_clients = int(status.clients_found or 0)
except Exception:
scanner_running = False
scanner_networks = 0
scanner_clients = 0
return (
legacy_running or scanner_running,
max(len(wifi_networks), scanner_networks),
max(len(wifi_clients), scanner_clients),
)
@app.route('/health')
def health_check() -> Response:
"""Health check endpoint for monitoring."""
import platform
import time
return jsonify({
'status': 'healthy',
bt_active, bt_device_count = _get_bluetooth_health()
wifi_active, wifi_network_count, wifi_client_count = _get_wifi_health()
# Database health check
db_ok = True
try:
from utils.database import get_connection
get_connection().execute('SELECT 1')
except Exception:
db_ok = False
# SDR device count (cached, non-blocking)
sdr_count = 0
try:
from utils.sdr.detection import get_cached_devices
cached = get_cached_devices()
if cached is not None:
sdr_count = len(cached)
except (ImportError, Exception):
pass
overall_status = 'healthy' if db_ok else 'degraded'
status_code = 200 if db_ok else 503
response = jsonify({
'status': overall_status,
'version': VERSION,
'uptime_seconds': round(time.time() - _app_start_time, 2),
'system': {
'python_version': platform.python_version(),
'platform': platform.platform(),
},
'database': db_ok,
'sdr_devices': sdr_count,
'rate_limiting': _has_limiter,
'processes': {
'pager': current_process is not None and (current_process.poll() is None if current_process else False),
'sensor': sensor_process is not None and (sensor_process.poll() is None if sensor_process else False),
'adsb': adsb_process is not None and (adsb_process.poll() is None if adsb_process else False),
'ais': ais_process is not None and (ais_process.poll() is None if ais_process else False),
'acars': acars_process is not None and (acars_process.poll() is None if acars_process else False),
'vdl2': vdl2_process is not None and (vdl2_process.poll() is None if vdl2_process else False),
'aprs': aprs_process is not None and (aprs_process.poll() is None if aprs_process else False),
'wifi': wifi_process is not None and (wifi_process.poll() is None if wifi_process else False),
'bluetooth': bt_process is not None and (bt_process.poll() is None if bt_process else False),
'wifi': wifi_active,
'bluetooth': bt_active,
'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False),
'dmr': _get_dmr_active(),
'radiosonde': radiosonde_process is not None and (radiosonde_process.poll() is None if radiosonde_process else False),
'morse': morse_process is not None and (morse_process.poll() is None if morse_process else False),
'subghz': _get_subghz_active(),
'rtlamr': rtlamr_process is not None and (rtlamr_process.poll() is None if rtlamr_process else False),
'meshtastic': _get_singleton_running('utils.meshtastic', 'get_meshtastic_client', 'is_running'),
'sstv': _get_singleton_running('utils.sstv', 'get_sstv_decoder', 'is_running'),
'weathersat': _get_singleton_running('utils.weather_sat', 'get_weather_sat_decoder', 'is_running'),
'wefax': _get_singleton_running('utils.wefax', 'get_wefax_decoder', 'is_running'),
'sstv_general': _get_singleton_running('utils.sstv', 'get_general_sstv_decoder', 'is_running'),
'tscm': _get_tscm_active(),
'gps': _get_singleton_running('utils.gps', 'get_gps_reader', 'is_running'),
'bt_locate': _get_singleton_running('utils.bt_locate', 'get_locate_session', 'is_active'),
},
'data': {
'aircraft_count': len(adsb_aircraft),
'vessel_count': len(ais_vessels),
'wifi_networks_count': len(wifi_networks),
'wifi_clients_count': len(wifi_clients),
'bt_devices_count': len(bt_devices),
'wifi_networks_count': wifi_network_count,
'wifi_clients_count': wifi_client_count,
'bt_devices_count': bt_device_count,
'dsc_messages_count': len(dsc_messages),
}
})
response.status_code = status_code
return response
@app.route('/killall', methods=['POST'])
@(csrf.exempt if csrf else lambda f: f)
def kill_all() -> Response:
"""Kill all decoder, WiFi, and Bluetooth processes."""
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
global vdl2_process, morse_process, radiosonde_process, ook_process
global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_process
global dmr_process, dmr_rtl_process
# Import adsb and ais modules to reset their state
# Import modules to reset their state
from routes import adsb as adsb_module
from routes import ais as ais_module
from routes import radiosonde as radiosonde_module
from utils.bluetooth import reset_bluetooth_scanner
killed = []
processes_to_kill = [
'rtl_fm', 'multimon-ng', 'rtl_433',
'airodump-ng', 'aireplay-ng', 'airmon-ng',
'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher',
'hcitool', 'bluetoothctl', 'satdump', 'dsd',
'dump1090', 'acarsdec', 'dumpvdl2', 'direwolf', 'AIS-catcher',
'hcitool', 'bluetoothctl', 'satdump',
'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg',
'hackrf_transfer', 'hackrf_sweep'
'hackrf_transfer', 'hackrf_sweep',
'auto_rx'
]
for proc in processes_to_kill:
@@ -747,10 +992,34 @@ def kill_all() -> Response:
ais_process = None
ais_module.ais_running = False
# Reset Radiosonde state
with radiosonde_lock:
radiosonde_process = None
radiosonde_module.radiosonde_running = False
# Reset ACARS state
with acars_lock:
acars_process = None
# Reset VDL2 state
with vdl2_lock:
vdl2_process = None
# Reset Morse state
with morse_lock:
morse_process = None
# Reset OOK state (full cleanup: parser thread, pipes, SDR release)
with ook_lock:
try:
from routes.ook import cleanup_ook
cleanup_ook(emit_status=False)
except Exception:
if ook_process:
safe_terminate(ook_process)
unregister_process(ook_process)
ook_process = None
# Reset APRS state
with aprs_lock:
aprs_process = None
@@ -761,11 +1030,6 @@ def kill_all() -> Response:
dsc_process = None
dsc_rtl_process = None
# Reset DMR state
with dmr_lock:
dmr_process = None
dmr_rtl_process = None
# Reset Bluetooth state (legacy)
with bt_lock:
if bt_process:
@@ -773,10 +1037,8 @@ def kill_all() -> Response:
bt_process.terminate()
bt_process.wait(timeout=2)
except Exception:
try:
with contextlib.suppress(Exception):
bt_process.kill()
except Exception:
pass
bt_process = None
# Reset Bluetooth v2 scanner
@@ -800,9 +1062,143 @@ def kill_all() -> Response:
return jsonify({'status': 'killed', 'processes': killed})
def _ensure_self_signed_cert(cert_dir: str) -> tuple:
"""Generate a self-signed certificate if one doesn't already exist.
Returns (cert_path, key_path) tuple.
"""
cert_path = os.path.join(cert_dir, 'intercept.crt')
key_path = os.path.join(cert_dir, 'intercept.key')
if os.path.exists(cert_path) and os.path.exists(key_path):
print(f"Using existing SSL certificate: {cert_path}")
return cert_path, key_path
os.makedirs(cert_dir, exist_ok=True)
print("Generating self-signed SSL certificate...")
import subprocess
result = subprocess.run([
'openssl', 'req', '-x509', '-newkey', 'rsa:2048',
'-keyout', key_path, '-out', cert_path,
'-days', '365', '-nodes',
'-subj', '/CN=intercept/O=INTERCEPT/C=US',
], capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f"Failed to generate SSL certificate: {result.stderr}")
print(f"SSL certificate generated: {cert_path}")
return cert_path, key_path
_app_initialized = False
def _init_app() -> None:
"""Initialize blueprints, database, and websockets.
Safe to call multiple times — subsequent calls are no-ops.
Called automatically at module level for gunicorn, and also
from main() for the Flask dev server path.
Heavy/network operations (TLE updates, process cleanup) are
deferred to a background thread so the worker can serve
requests immediately.
"""
global _app_initialized
if _app_initialized:
return
_app_initialized = True
import os
# Initialize database for settings storage
from utils.database import init_db
init_db()
# Register blueprints (essential — without these, all routes 404)
from routes import register_blueprints
register_blueprints(app)
# Initialize WebSocket for audio streaming
try:
from routes.audio_websocket import init_audio_websocket
init_audio_websocket(app)
except ImportError:
pass
# Initialize KiwiSDR WebSocket audio proxy
try:
from routes.websdr import init_websdr_audio
init_websdr_audio(app)
except ImportError:
pass
# Initialize WebSocket for waterfall streaming
try:
from routes.waterfall_websocket import init_waterfall_websocket
init_waterfall_websocket(app)
except ImportError:
pass
# Initialize WebSocket for meteor scatter monitoring
try:
from routes.meteor_websocket import init_meteor_websocket
init_meteor_websocket(app)
except ImportError:
pass
# Defer heavy/network operations so the worker can serve requests immediately
import threading
def _deferred_init():
"""Run heavy initialization after a short delay."""
import time
time.sleep(1) # Let the worker start serving first
# Clean up stale processes from previous runs
try:
cleanup_stale_processes()
cleanup_stale_dump1090()
except Exception as e:
logger.warning(f"Stale process cleanup failed: {e}")
# Register and start database cleanup
try:
from utils.database import (
cleanup_old_dsc_alerts,
cleanup_old_payloads,
cleanup_old_signal_history,
cleanup_old_timeline_entries,
)
cleanup_manager.register_db_cleanup(cleanup_old_signal_history, interval_multiplier=1440)
cleanup_manager.register_db_cleanup(cleanup_old_timeline_entries, interval_multiplier=1440)
cleanup_manager.register_db_cleanup(cleanup_old_dsc_alerts, interval_multiplier=1440)
cleanup_manager.register_db_cleanup(cleanup_old_payloads, interval_multiplier=1440)
cleanup_manager.start()
except Exception as e:
logger.warning(f"Cleanup manager init failed: {e}")
# Initialize TLE auto-refresh (must be after blueprint registration)
try:
from routes.satellite import init_tle_auto_refresh
if not os.environ.get('TESTING'):
init_tle_auto_refresh()
except Exception as e:
logger.warning(f"Failed to initialize TLE auto-refresh: {e}")
threading.Thread(target=_deferred_init, daemon=True).start()
# Auto-initialize when imported (e.g. by gunicorn)
_init_app()
def main() -> None:
"""Main entry point."""
import argparse
import config
parser = argparse.ArgumentParser(
@@ -826,6 +1222,12 @@ def main() -> None:
default=config.DEBUG,
help='Enable debug mode'
)
parser.add_argument(
'--https',
action='store_true',
default=config.HTTPS,
help='Enable HTTPS with self-signed certificate'
)
parser.add_argument(
'--check-deps',
action='store_true',
@@ -838,7 +1240,7 @@ def main() -> None:
results = check_all_dependencies()
print("Dependency Status:")
print("-" * 40)
for mode, info in results.items():
for _mode, info in results.items():
status = "" if info['ready'] else ""
print(f"\n{status} {info['name']}:")
for tool, tool_info in info['tools'].items():
@@ -875,83 +1277,21 @@ def main() -> None:
print("Running as root - full capabilities enabled")
print()
# Clean up any stale processes from previous runs
cleanup_stale_processes()
cleanup_stale_dump1090()
# Ensure app is initialized (no-op if already done by module-level call)
_init_app()
# Initialize database for settings storage
from utils.database import init_db
init_db()
# Configure SSL if HTTPS is enabled
ssl_context = None
if args.https:
cert_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data', 'certs')
if config.SSL_CERT and config.SSL_KEY:
ssl_context = (config.SSL_CERT, config.SSL_KEY)
print(f"Using provided SSL certificate: {config.SSL_CERT}")
else:
ssl_context = _ensure_self_signed_cert(cert_dir)
# Register database cleanup functions
from utils.database import (
cleanup_old_signal_history,
cleanup_old_timeline_entries,
cleanup_old_dsc_alerts,
cleanup_old_payloads
)
cleanup_manager.register_db_cleanup(cleanup_old_signal_history, interval_multiplier=1440) # Every 24 hours
cleanup_manager.register_db_cleanup(cleanup_old_timeline_entries, interval_multiplier=1440) # Every 24 hours
cleanup_manager.register_db_cleanup(cleanup_old_dsc_alerts, interval_multiplier=1440) # Every 24 hours
cleanup_manager.register_db_cleanup(cleanup_old_payloads, interval_multiplier=1440) # Every 24 hours
# Start automatic cleanup of stale data entries
cleanup_manager.start()
# Register blueprints
from routes import register_blueprints
register_blueprints(app)
# Initialize TLE auto-refresh (must be after blueprint registration)
try:
from routes.satellite import init_tle_auto_refresh
import os
if not os.environ.get('TESTING'):
init_tle_auto_refresh()
except Exception as e:
logger.warning(f"Failed to initialize TLE auto-refresh: {e}")
# Update TLE data in background thread (non-blocking)
def update_tle_background():
try:
from routes.satellite import refresh_tle_data
print("Updating satellite TLE data from CelesTrak...")
updated = refresh_tle_data()
if updated:
print(f"TLE data updated for: {', '.join(updated)}")
else:
print("TLE update: No satellites updated (may be offline)")
except Exception as e:
print(f"TLE update failed (will use cached data): {e}")
tle_thread = threading.Thread(target=update_tle_background, daemon=True)
tle_thread.start()
# Initialize WebSocket for audio streaming
try:
from routes.audio_websocket import init_audio_websocket
init_audio_websocket(app)
print("WebSocket audio streaming enabled")
except ImportError as e:
print(f"WebSocket audio disabled (install flask-sock): {e}")
# Initialize KiwiSDR WebSocket audio proxy
try:
from routes.websdr import init_websdr_audio
init_websdr_audio(app)
print("KiwiSDR audio proxy enabled")
except ImportError as e:
print(f"KiwiSDR audio proxy disabled: {e}")
# Initialize WebSocket for waterfall streaming
try:
from routes.waterfall_websocket import init_waterfall_websocket
init_waterfall_websocket(app)
print("WebSocket waterfall streaming enabled")
except ImportError as e:
print(f"WebSocket waterfall disabled: {e}")
print(f"Open http://localhost:{args.port} in your browser")
protocol = 'https' if ssl_context else 'http'
print(f"Open {protocol}://localhost:{args.port} in your browser")
print()
print("Press Ctrl+C to stop")
print()
@@ -963,4 +1303,5 @@ def main() -> None:
debug=args.debug,
threaded=True,
load_dotenv=False,
ssl_context=ssl_context,
)
+139 -139
View File
@@ -1,139 +1,139 @@
#!/bin/bash
# INTERCEPT - Multi-architecture Docker image builder
#
# Builds for both linux/amd64 and linux/arm64 using Docker buildx.
# Run this on your x64 machine to cross-compile the arm64 image
# instead of building natively on the RPi5.
#
# Prerequisites (one-time setup):
# docker run --privileged --rm tonistiigi/binfmt --install all
# docker buildx create --name intercept-builder --use --bootstrap
#
# Usage:
# ./build-multiarch.sh # Build both platforms, load locally
# ./build-multiarch.sh --push # Build and push to registry
# ./build-multiarch.sh --arm64-only # Build arm64 only (for RPi)
# REGISTRY=ghcr.io/user ./build-multiarch.sh --push
#
# Environment variables:
# REGISTRY - Container registry (default: docker.io/library)
# IMAGE_NAME - Image name (default: intercept)
# IMAGE_TAG - Image tag (default: latest)
set -euo pipefail
# Configuration
REGISTRY="${REGISTRY:-}"
IMAGE_NAME="${IMAGE_NAME:-intercept}"
IMAGE_TAG="${IMAGE_TAG:-latest}"
BUILDER_NAME="intercept-builder"
PLATFORMS="linux/amd64,linux/arm64"
# Parse arguments
PUSH=false
LOAD=false
ARM64_ONLY=false
for arg in "$@"; do
case $arg in
--push) PUSH=true ;;
--load) LOAD=true ;;
--arm64-only)
ARM64_ONLY=true
PLATFORMS="linux/arm64"
;;
--amd64-only)
PLATFORMS="linux/amd64"
;;
--help|-h)
echo "Usage: $0 [--push] [--load] [--arm64-only] [--amd64-only]"
echo ""
echo "Options:"
echo " --push Push to container registry"
echo " --load Load into local Docker (single platform only)"
echo " --arm64-only Build arm64 only (for RPi5 deployment)"
echo " --amd64-only Build amd64 only"
echo ""
echo "Environment variables:"
echo " REGISTRY Container registry (e.g. ghcr.io/username)"
echo " IMAGE_NAME Image name (default: intercept)"
echo " IMAGE_TAG Image tag (default: latest)"
echo ""
echo "Examples:"
echo " $0 --push # Build both, push"
echo " REGISTRY=ghcr.io/myuser $0 --push # Push to GHCR"
echo " $0 --arm64-only --load # Build arm64, load locally"
echo " $0 --arm64-only --push && ssh rpi docker pull # Build + deploy to RPi"
exit 0
;;
*)
echo "Unknown option: $arg"
exit 1
;;
esac
done
# Build full image reference
if [ -n "$REGISTRY" ]; then
FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}"
else
FULL_IMAGE="${IMAGE_NAME}:${IMAGE_TAG}"
fi
echo "============================================"
echo " INTERCEPT Multi-Architecture Builder"
echo "============================================"
echo " Image: ${FULL_IMAGE}"
echo " Platforms: ${PLATFORMS}"
echo " Push: ${PUSH}"
echo "============================================"
echo ""
# Check if buildx builder exists, create if not
if ! docker buildx inspect "$BUILDER_NAME" >/dev/null 2>&1; then
echo "Creating buildx builder: ${BUILDER_NAME}"
docker buildx create --name "$BUILDER_NAME" --use --bootstrap
# Check for QEMU support
if ! docker run --rm --privileged tonistiigi/binfmt --install all >/dev/null 2>&1; then
echo "WARNING: QEMU binfmt setup may have failed."
echo "Run: docker run --privileged --rm tonistiigi/binfmt --install all"
fi
else
docker buildx use "$BUILDER_NAME"
fi
# Build command
BUILD_CMD="docker buildx build --platform ${PLATFORMS} --tag ${FULL_IMAGE}"
if [ "$PUSH" = true ]; then
BUILD_CMD="${BUILD_CMD} --push"
echo "Will push to: ${FULL_IMAGE}"
elif [ "$LOAD" = true ]; then
# --load only works with single platform
if echo "$PLATFORMS" | grep -q ","; then
echo "ERROR: --load only works with a single platform."
echo "Use --arm64-only or --amd64-only with --load."
exit 1
fi
BUILD_CMD="${BUILD_CMD} --load"
echo "Will load into local Docker"
fi
echo ""
echo "Building..."
echo "Command: ${BUILD_CMD} ."
echo ""
$BUILD_CMD .
echo ""
echo "============================================"
echo " Build complete!"
if [ "$PUSH" = true ]; then
echo " Image pushed to: ${FULL_IMAGE}"
echo ""
echo " Pull on RPi5:"
echo " docker pull ${FULL_IMAGE}"
fi
echo "============================================"
#!/bin/bash
# INTERCEPT - Multi-architecture Docker image builder
#
# Builds for both linux/amd64 and linux/arm64 using Docker buildx.
# Run this on your x64 machine to cross-compile the arm64 image
# instead of building natively on the RPi5.
#
# Prerequisites (one-time setup):
# docker run --privileged --rm tonistiigi/binfmt --install all
# docker buildx create --name intercept-builder --use --bootstrap
#
# Usage:
# ./build-multiarch.sh # Build both platforms, load locally
# ./build-multiarch.sh --push # Build and push to registry
# ./build-multiarch.sh --arm64-only # Build arm64 only (for RPi)
# REGISTRY=ghcr.io/user ./build-multiarch.sh --push
#
# Environment variables:
# REGISTRY - Container registry (default: docker.io/library)
# IMAGE_NAME - Image name (default: intercept)
# IMAGE_TAG - Image tag (default: latest)
set -euo pipefail
# Configuration
REGISTRY="${REGISTRY:-}"
IMAGE_NAME="${IMAGE_NAME:-intercept}"
IMAGE_TAG="${IMAGE_TAG:-latest}"
BUILDER_NAME="intercept-builder"
PLATFORMS="linux/amd64,linux/arm64"
# Parse arguments
PUSH=false
LOAD=false
ARM64_ONLY=false
for arg in "$@"; do
case $arg in
--push) PUSH=true ;;
--load) LOAD=true ;;
--arm64-only)
ARM64_ONLY=true
PLATFORMS="linux/arm64"
;;
--amd64-only)
PLATFORMS="linux/amd64"
;;
--help|-h)
echo "Usage: $0 [--push] [--load] [--arm64-only] [--amd64-only]"
echo ""
echo "Options:"
echo " --push Push to container registry"
echo " --load Load into local Docker (single platform only)"
echo " --arm64-only Build arm64 only (for RPi5 deployment)"
echo " --amd64-only Build amd64 only"
echo ""
echo "Environment variables:"
echo " REGISTRY Container registry (e.g. ghcr.io/username)"
echo " IMAGE_NAME Image name (default: intercept)"
echo " IMAGE_TAG Image tag (default: latest)"
echo ""
echo "Examples:"
echo " $0 --push # Build both, push"
echo " REGISTRY=ghcr.io/myuser $0 --push # Push to GHCR"
echo " $0 --arm64-only --load # Build arm64, load locally"
echo " $0 --arm64-only --push && ssh rpi docker pull # Build + deploy to RPi"
exit 0
;;
*)
echo "Unknown option: $arg"
exit 1
;;
esac
done
# Build full image reference
if [ -n "$REGISTRY" ]; then
FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}"
else
FULL_IMAGE="${IMAGE_NAME}:${IMAGE_TAG}"
fi
echo "============================================"
echo " INTERCEPT Multi-Architecture Builder"
echo "============================================"
echo " Image: ${FULL_IMAGE}"
echo " Platforms: ${PLATFORMS}"
echo " Push: ${PUSH}"
echo "============================================"
echo ""
# Check if buildx builder exists, create if not
if ! docker buildx inspect "$BUILDER_NAME" >/dev/null 2>&1; then
echo "Creating buildx builder: ${BUILDER_NAME}"
docker buildx create --name "$BUILDER_NAME" --use --bootstrap
# Check for QEMU support
if ! docker run --rm --privileged tonistiigi/binfmt --install all >/dev/null 2>&1; then
echo "WARNING: QEMU binfmt setup may have failed."
echo "Run: docker run --privileged --rm tonistiigi/binfmt --install all"
fi
else
docker buildx use "$BUILDER_NAME"
fi
# Build command
BUILD_CMD="docker buildx build --platform ${PLATFORMS} --tag ${FULL_IMAGE}"
if [ "$PUSH" = true ]; then
BUILD_CMD="${BUILD_CMD} --push"
echo "Will push to: ${FULL_IMAGE}"
elif [ "$LOAD" = true ]; then
# --load only works with single platform
if echo "$PLATFORMS" | grep -q ","; then
echo "ERROR: --load only works with a single platform."
echo "Use --arm64-only or --amd64-only with --load."
exit 1
fi
BUILD_CMD="${BUILD_CMD} --load"
echo "Will load into local Docker"
fi
echo ""
echo "Building..."
echo "Command: ${BUILD_CMD} ."
echo ""
$BUILD_CMD .
echo ""
echo "============================================"
echo " Build complete!"
if [ "$PUSH" = true ]; then
echo " Image pushed to: ${FULL_IMAGE}"
echo ""
echo " Pull on RPi5:"
echo " docker pull ${FULL_IMAGE}"
fi
echo "============================================"
+176 -5
View File
@@ -7,10 +7,165 @@ import os
import sys
# Application version
VERSION = "2.18.0"
VERSION = "2.26.5"
# Changelog - latest release notes (shown on welcome screen)
CHANGELOG = [
{
"version": "2.26.5",
"date": "March 2026",
"highlights": [
"Fix database errors crashing the entire UI — pages now degrade gracefully",
]
},
{
"version": "2.26.4",
"date": "March 2026",
"highlights": [
"Fix Environment Configurator crash when .env exists but variable is missing",
]
},
{
"version": "2.26.3",
"date": "March 2026",
"highlights": [
"Fix SatDump AVX2 crash on older CPUs — build now targets baseline x86-64",
]
},
{
"version": "2.26.2",
"date": "March 2026",
"highlights": [
"Fix Docker startup crash — data/ Python package was excluded by .dockerignore",
]
},
{
"version": "2.26.1",
"date": "March 2026",
"highlights": [
"Fix default admin credentials — now matches README (admin:admin)",
"Admin password changes in config.py / env vars now sync to DB on restart",
]
},
{
"version": "2.26.0",
"date": "March 2026",
"highlights": [
"Fix SSE fanout thread crash when source queue is None during shutdown",
"Fix branded 'i' logo FOUC (flash of unstyled content) on first page load",
]
},
{
"version": "2.25.0",
"date": "March 2026",
"highlights": [
"UI/UX overhaul — SSEManager with exponential backoff and connection status indicator",
"Accessibility improvements — aria-labels, form label associations, keyboard list navigation",
"Destructive action confirmation modals replace native confirm() dialogs",
"CSS variable adoption, inline style extraction, and reduced !important usage",
"Loading button states, actionable error reporting, and mobile UX polish",
]
},
{
"version": "2.24.0",
"date": "March 2026",
"highlights": [
"WiFi Locate mode — locate access points by BSSID with real-time signal meter, distance estimation, RSSI chart, and audio proximity tones",
"Mobile navigation reorganized into labeled groups for better usability",
"flask-limiter made optional for graceful degradation",
"Radiosonde setup fix — missing semver dependency",
]
},
{
"version": "2.23.0",
"date": "February 2026",
"highlights": [
"Radiosonde weather balloon tracking mode with telemetry, map, and station distance",
"CW/Morse code decoder with Goertzel tone detection and OOK envelope mode",
"WeFax (Weather Fax) decoder with auto-scheduler and broadcast timeline",
"System Health monitoring mode with telemetry dashboard",
"HTTPS support, HackRF TSCM RF scan, ADS-B voice alerts",
"Production server (start.sh) with gunicorn + gevent for concurrent multi-client support",
"Multi-SDR support for WeFax, tool path overrides, native Homebrew detection",
"GPS mode upgraded to textured 3D globe",
"Destroy lifecycle added to all mode modules to prevent resource leaks",
"Dozens of bug fixes across ADS-B, APRS, SSE, Morse, waterfall, and more",
]
},
{
"version": "2.22.3",
"date": "February 2026",
"highlights": [
"Waterfall control panel no longer shows as unstyled text on first visit",
"WebSDR globe renders correctly on first page load without requiring a refresh",
"Waterfall monitor audio no longer takes minutes to start — playback detection now waits for real audio data instead of just the WAV header",
"Waterfall monitor stop is now instant — audio pauses and UI updates immediately instead of waiting for backend cleanup",
"Stopping the waterfall no longer shows a stale 'WebSocket closed before ready' message",
]
},
{
"version": "2.22.1",
"date": "February 2026",
"highlights": [
"Waterfall receiver overhaul: WebSocket I/Q streaming with server-side FFT, click-to-tune, and zoom controls",
"Voice alerts for configurable event notifications across modes",
"Signal fingerprinting mode for RF device identification and pattern analysis",
"SignalID integration via SigIDWiki API for automatic signal classification",
"PWA support: installable web app with service worker and manifest",
"Mode stop responsiveness improvements with faster timeout handling",
"Navigation performance instrumentation and smoother mode transitions",
"Pager, sensor, and SSTV real-time signal scope visualization",
"ADS-B MSG2 surface movement parsing for ground vehicle tracking",
"WebSDR major overhaul with improved receiver management and audio streaming",
"Documentation audit: fixed license, tool names, entry points, and SSTV decoder references",
"Help modal updated with ACARS and VDL2 mode descriptions",
]
},
{
"version": "2.21.1",
"date": "February 2026",
"highlights": [
"BT Locate map first-load fix with render stabilization retries during initial mode open",
"BT Locate trail restore optimization for faster startup when historical GPS points exist",
"BT Locate mode-switch map invalidation timing fix to prevent delayed/blank map render",
]
},
{
"version": "2.21.0",
"date": "February 2026",
"highlights": [
"Global map theme refresh with improved contrast and cross-dashboard consistency",
"Cross-app UX updates for accessibility, mode consistency, and render performance",
"Weather satellite reliability fixes for auto-scheduler and Mercator pass tracking",
"Bluetooth/WiFi runtime health fixes with BT Locate continuity and confidence improvements",
"ADS-B/VDL2 streaming reliability upgrades for multi-client SSE fanout and remote decoding",
"Analytics enhancements with operational insights and temporal pattern panels",
]
},
{
"version": "2.20.0",
"date": "February 2026",
"highlights": [
"Space Weather mode: real-time solar and geomagnetic monitoring from NOAA SWPC, NASA SDO, and HamQSL",
"Kp index, solar wind, X-ray flux charts with Chart.js visualization",
"HF band conditions, D-RAP absorption maps, aurora forecast, and solar imagery",
"NOAA Space Weather Scales (G/S/R), flare probability, and active solar regions",
"No SDR hardware required — all data from public APIs with server-side caching",
]
},
{
"version": "2.19.0",
"date": "February 2026",
"highlights": [
"VDL2 mode with modal message viewer, consolidated into ADS-B dashboard",
"ADS-B: trails enabled by default, radar modes removed, CSV export added",
"Bundled Roboto Condensed font for offline mode with SVG icon overhaul",
"Help modal updated with all modes and correct SVG icons",
"Setup script overhauled for reliability and macOS compatibility",
"GPS fix for preserving satellites across DOP-only SKY messages",
"Fix gpsd deadlock causing GPS connect to hang",
]
},
{
"version": "2.18.0",
"date": "February 2026",
@@ -54,7 +209,6 @@ CHANGELOG = [
"Pure Python SSTV decoder replacing broken slowrx dependency",
"Real-time signal scope for pager, sensor, and SSTV modes",
"USB-level device probe to prevent cryptic rtl_fm crashes",
"DMR dsd-fme protocol fixes, tuning controls, and state sync",
"SDR device lock-up fix from unreleased device registry on crash",
]
},
@@ -62,8 +216,6 @@ CHANGELOG = [
"version": "2.14.0",
"date": "February 2026",
"highlights": [
"DMR/P25/NXDN/D-STAR digital voice decoder with dsd-fme",
"DMR visual synthesizer with event-driven spring-physics bars",
"HF SSTV general mode with predefined shortwave frequencies",
"WebSDR integration for remote HF/shortwave listening",
"Listening Post signal scanner and audio pipeline improvements",
@@ -209,6 +361,11 @@ PORT = _get_env_int('PORT', 5050)
DEBUG = _get_env_bool('DEBUG', False)
THREADED = _get_env_bool('THREADED', True)
# HTTPS / SSL settings
HTTPS = _get_env_bool('HTTPS', False)
SSL_CERT = _get_env('SSL_CERT', '')
SSL_KEY = _get_env('SSL_KEY', '')
# Default RTL-SDR settings
DEFAULT_GAIN = _get_env('DEFAULT_GAIN', '40')
DEFAULT_DEVICE = _get_env('DEFAULT_DEVICE', '0')
@@ -255,12 +412,20 @@ SATELLITE_ORBIT_MINUTES = _get_env_int('SATELLITE_ORBIT_MINUTES', 45)
# Weather satellite settings
WEATHER_SAT_DEFAULT_GAIN = _get_env_float('WEATHER_SAT_GAIN', 40.0)
WEATHER_SAT_SAMPLE_RATE = _get_env_int('WEATHER_SAT_SAMPLE_RATE', 1000000)
WEATHER_SAT_SAMPLE_RATE = _get_env_int('WEATHER_SAT_SAMPLE_RATE', 2400000)
WEATHER_SAT_MIN_ELEVATION = _get_env_float('WEATHER_SAT_MIN_ELEVATION', 15.0)
WEATHER_SAT_PREDICTION_HOURS = _get_env_int('WEATHER_SAT_PREDICTION_HOURS', 24)
WEATHER_SAT_SCHEDULE_REFRESH_MINUTES = _get_env_int('WEATHER_SAT_SCHEDULE_REFRESH_MINUTES', 30)
WEATHER_SAT_CAPTURE_BUFFER_SECONDS = _get_env_int('WEATHER_SAT_CAPTURE_BUFFER_SECONDS', 30)
# WeFax (Weather Fax) settings
WEFAX_DEFAULT_GAIN = _get_env_float('WEFAX_GAIN', 40.0)
WEFAX_SAMPLE_RATE = _get_env_int('WEFAX_SAMPLE_RATE', 22050)
WEFAX_DEFAULT_IOC = _get_env_int('WEFAX_IOC', 576)
WEFAX_DEFAULT_LPM = _get_env_int('WEFAX_LPM', 120)
WEFAX_SCHEDULE_REFRESH_MINUTES = _get_env_int('WEFAX_SCHEDULE_REFRESH_MINUTES', 30)
WEFAX_CAPTURE_BUFFER_SECONDS = _get_env_int('WEFAX_CAPTURE_BUFFER_SECONDS', 30)
# SubGHz transceiver settings (HackRF)
SUBGHZ_DEFAULT_FREQUENCY = _get_env_float('SUBGHZ_FREQUENCY', 433.92)
SUBGHZ_DEFAULT_SAMPLE_RATE = _get_env_int('SUBGHZ_SAMPLE_RATE', 2000000)
@@ -271,6 +436,12 @@ SUBGHZ_MAX_TX_DURATION = _get_env_int('SUBGHZ_MAX_TX_DURATION', 10)
SUBGHZ_SWEEP_START_MHZ = _get_env_float('SUBGHZ_SWEEP_START', 300.0)
SUBGHZ_SWEEP_END_MHZ = _get_env_float('SUBGHZ_SWEEP_END', 928.0)
# Radiosonde settings
RADIOSONDE_FREQ_MIN = _get_env_float('RADIOSONDE_FREQ_MIN', 400.0)
RADIOSONDE_FREQ_MAX = _get_env_float('RADIOSONDE_FREQ_MAX', 406.0)
RADIOSONDE_DEFAULT_GAIN = _get_env_float('RADIOSONDE_GAIN', 40.0)
RADIOSONDE_UDP_PORT = _get_env_int('RADIOSONDE_UDP_PORT', 55673)
# Update checking
GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept')
UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True)
+5 -5
View File
@@ -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
View File
@@ -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('_')}
+3
View File
@@ -26,4 +26,7 @@ TLE_SATELLITES = {
'METEOR-M2-3': ('METEOR-M2 3',
'1 57166U 23091A 25028.81539352 .00000157 00000+0 94432-4 0 9993',
'2 57166 98.7690 91.9652 0001790 107.4859 252.6519 14.23646028 77844'),
'METEOR-M2-4': ('METEOR-M2 4',
'1 59051U 24039A 26061.19281216 .00000032 00000+0 34037-4 0 9998',
'2 59051 98.6892 21.9068 0008025 115.2158 244.9852 14.22415711104050'),
}
+3 -3
View File
@@ -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
+733
View File
@@ -0,0 +1,733 @@
{
"stations": [
{
"name": "USCG Kodiak",
"callsign": "NOJ",
"country": "US",
"city": "Kodiak, AK",
"coordinates": [57.78, -152.50],
"frequencies": [
{"khz": 2054, "description": "Night"},
{"khz": 4298, "description": "Primary"},
{"khz": 8459, "description": "Day"},
{"khz": 12412.5, "description": "Extended"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "03:40", "duration_min": 148, "content": "Chart Series 1"},
{"utc": "09:50", "duration_min": 138, "content": "Chart Series 2"},
{"utc": "15:40", "duration_min": 148, "content": "Chart Series 3"},
{"utc": "21:50", "duration_min": 98, "content": "Chart Series 4"}
]
},
{
"name": "USCG Boston",
"callsign": "NMF",
"country": "US",
"city": "Boston, MA",
"coordinates": [42.36, -71.04],
"frequencies": [
{"khz": 4235, "description": "Night"},
{"khz": 6340.5, "description": "Primary"},
{"khz": 9110, "description": "Day"},
{"khz": 12750, "description": "Extended"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "02:30", "duration_min": 20, "content": "Wind/Wave Analysis"},
{"utc": "04:38", "duration_min": 20, "content": "Sea State Analysis"},
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "09:30", "duration_min": 20, "content": "48-Hour Surface Prog"},
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "14:00", "duration_min": 20, "content": "24-Hour Surface Prog"},
{"utc": "16:00", "duration_min": 20, "content": "Sea State Analysis"},
{"utc": "18:10", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "22:00", "duration_min": 20, "content": "Satellite Image"}
]
},
{
"name": "USCG New Orleans",
"callsign": "NMG",
"country": "US",
"city": "New Orleans, LA",
"coordinates": [29.95, -90.07],
"frequencies": [
{"khz": 4317.9, "description": "Night"},
{"khz": 8503.9, "description": "Primary"},
{"khz": 12789.9, "description": "Day"},
{"khz": 17146.4, "description": "Extended"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "03:00", "duration_min": 20, "content": "24-Hour Surface Prog"},
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "09:00", "duration_min": 20, "content": "Sea State Analysis"},
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "15:00", "duration_min": 20, "content": "48-Hour Surface Prog"},
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "21:00", "duration_min": 20, "content": "Tropical Analysis"}
]
},
{
"name": "USCG Pt. Reyes",
"callsign": "NMC",
"country": "US",
"city": "Pt. Reyes, CA",
"coordinates": [38.07, -122.97],
"frequencies": [
{"khz": 4346, "description": "Night"},
{"khz": 8682, "description": "Primary"},
{"khz": 12786, "description": "Day"},
{"khz": 17151.2, "description": "Extended"},
{"khz": 22527, "description": "DX"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "01:40", "duration_min": 20, "content": "Wind/Wave Analysis"},
{"utc": "06:55", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "11:20", "duration_min": 20, "content": "48-Hour Surface Prog"},
{"utc": "14:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "18:40", "duration_min": 20, "content": "Sea State Analysis"},
{"utc": "23:20", "duration_min": 20, "content": "Satellite Image"}
]
},
{
"name": "USCG Honolulu",
"callsign": "KVM70",
"country": "US",
"city": "Honolulu, HI",
"coordinates": [21.31, -157.86],
"frequencies": [
{"khz": 9982.5, "description": "Primary"},
{"khz": 11090, "description": "Day"},
{"khz": 16135, "description": "Extended"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "05:19", "duration_min": 20, "content": "Surface Prog"},
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "17:19", "duration_min": 20, "content": "Sea State Analysis"}
]
},
{
"name": "RN Northwood",
"callsign": "GYA",
"country": "GB",
"city": "Northwood, London",
"coordinates": [51.63, -0.42],
"frequencies": [
{"khz": 2618.5, "description": "Night"},
{"khz": 3280.5, "description": "Night Alt"},
{"khz": 4610, "description": "Primary"},
{"khz": 6834, "description": "Day Alt"},
{"khz": 8040, "description": "Day"},
{"khz": 11086.5, "description": "Extended"},
{"khz": 12390, "description": "Persian Gulf"},
{"khz": 18261, "description": "DX"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "03:30", "duration_min": 20, "content": "24-Hour Surface Prog"},
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "08:00", "duration_min": 20, "content": "Sea State Forecast"},
{"utc": "09:30", "duration_min": 20, "content": "Extended Forecast"},
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "15:30", "duration_min": 20, "content": "48-Hour Surface Prog"},
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "19:00", "duration_min": 20, "content": "Wave Period Forecast"},
{"utc": "21:30", "duration_min": 20, "content": "Extended Forecast"}
]
},
{
"name": "DWD Hamburg/Pinneberg",
"callsign": "DDH",
"country": "DE",
"city": "Pinneberg",
"coordinates": [53.66, 9.80],
"frequencies": [
{"khz": 3855, "description": "Night (DDH3, 10kW)"},
{"khz": 7880, "description": "Primary (DDK3, 20kW)"},
{"khz": 13882.5, "description": "Day (DDK6, 20kW)"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "04:30", "duration_min": 20, "content": "Surface Analysis N. Atlantic"},
{"utc": "07:15", "duration_min": 20, "content": "Surface Prog"},
{"utc": "09:30", "duration_min": 20, "content": "Surface Analysis Europe"},
{"utc": "10:07", "duration_min": 20, "content": "Sea State North Sea"},
{"utc": "13:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "15:20", "duration_min": 20, "content": "Extended Prog"},
{"utc": "15:40", "duration_min": 20, "content": "Sea Ice Chart"},
{"utc": "16:30", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "21:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "21:15", "duration_min": 20, "content": "Surface Prog"}
]
},
{
"name": "JMA Tokyo",
"callsign": "JMH",
"country": "JP",
"city": "Tokyo",
"coordinates": [35.69, 139.69],
"frequencies": [
{"khz": 3622.5, "description": "Night"},
{"khz": 7795, "description": "Primary"},
{"khz": 13988.5, "description": "Day"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "01:30", "duration_min": 20, "content": "24-Hour Prog"},
{"utc": "03:00", "duration_min": 20, "content": "Satellite Image"},
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "07:30", "duration_min": 20, "content": "Wave Analysis"},
{"utc": "09:00", "duration_min": 20, "content": "Satellite Image"},
{"utc": "10:19", "duration_min": 20, "content": "Tropical Cyclone Info"},
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "15:00", "duration_min": 20, "content": "Satellite Image"},
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "21:00", "duration_min": 20, "content": "48-Hour Prog"}
]
},
{
"name": "Kyodo News Tokyo",
"callsign": "JJC",
"country": "JP",
"city": "Tokyo",
"coordinates": [35.69, 139.69],
"frequencies": [
{"khz": 4316, "description": "Night"},
{"khz": 8467.5, "description": "Primary"},
{"khz": 12745.5, "description": "Day"},
{"khz": 16971, "description": "Extended"},
{"khz": 17069.6, "description": "DX"},
{"khz": 22542, "description": "DX 2"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "00:00", "duration_min": 20, "content": "Press Photo/News Fax"},
{"utc": "04:00", "duration_min": 20, "content": "Press Photo/News Fax"},
{"utc": "08:00", "duration_min": 20, "content": "Press Photo/News Fax"},
{"utc": "12:00", "duration_min": 20, "content": "Press Photo/News Fax"},
{"utc": "16:00", "duration_min": 20, "content": "Press Photo/News Fax"},
{"utc": "20:00", "duration_min": 20, "content": "Press Photo/News Fax"}
]
},
{
"name": "Kagoshima Fisheries",
"callsign": "JFX",
"country": "JP",
"city": "Kagoshima",
"coordinates": [31.60, 130.56],
"frequencies": [
{"khz": 4274, "description": "Night"},
{"khz": 8658, "description": "Primary"},
{"khz": 13074, "description": "Day"},
{"khz": 16907.5, "description": "Extended"},
{"khz": 22559.6, "description": "DX"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "00:00", "duration_min": 20, "content": "Sea Surface Temp"},
{"utc": "04:00", "duration_min": 20, "content": "Fishing Forecast"},
{"utc": "08:00", "duration_min": 20, "content": "Sea Surface Temp"},
{"utc": "12:00", "duration_min": 20, "content": "Current Chart"},
{"utc": "16:00", "duration_min": 20, "content": "Fishing Forecast"},
{"utc": "20:00", "duration_min": 20, "content": "Sea Surface Temp"}
]
},
{
"name": "KMA Seoul",
"callsign": "HLL2",
"country": "KR",
"city": "Seoul",
"coordinates": [37.57, 126.98],
"frequencies": [
{"khz": 3585, "description": "Night"},
{"khz": 5857.5, "description": "Primary"},
{"khz": 7433.5, "description": "Day"},
{"khz": 9165, "description": "Extended"},
{"khz": 13570, "description": "DX"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "03:00", "duration_min": 20, "content": "24-Hour Prog"},
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "09:00", "duration_min": 20, "content": "Satellite Image"},
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "15:00", "duration_min": 20, "content": "Sea State Analysis"},
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "21:00", "duration_min": 20, "content": "48-Hour Prog"}
]
},
{
"name": "Taipei Met",
"callsign": "BMF",
"country": "TW",
"city": "Taipei",
"coordinates": [25.03, 121.57],
"frequencies": [
{"khz": 4616, "description": "Primary"},
{"khz": 8140, "description": "Day"},
{"khz": 13900, "description": "Extended"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}
]
},
{
"name": "Bangkok Met",
"callsign": "HSW64",
"country": "TH",
"city": "Bangkok",
"coordinates": [13.76, 100.50],
"frequencies": [
{"khz": 7396.8, "description": "Primary"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}
]
},
{
"name": "Shanghai Met",
"callsign": "XSG",
"country": "CN",
"city": "Shanghai",
"coordinates": [31.23, 121.47],
"frequencies": [
{"khz": 4170, "description": "Night"},
{"khz": 8302, "description": "Primary"},
{"khz": 12382, "description": "Day"},
{"khz": 16559, "description": "Extended"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}
]
},
{
"name": "Guangzhou Radio",
"callsign": "XSQ",
"country": "CN",
"city": "Guangzhou",
"coordinates": [23.13, 113.26],
"frequencies": [
{"khz": 4199.8, "description": "Night"},
{"khz": 8412.5, "description": "Primary"},
{"khz": 12629.3, "description": "Day"},
{"khz": 16826.3, "description": "Extended"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}
]
},
{
"name": "Singapore Met",
"callsign": "9VF",
"country": "SG",
"city": "Singapore",
"coordinates": [1.35, 103.82],
"frequencies": [
{"khz": 16035, "description": "Primary"},
{"khz": 17430, "description": "Alternate"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}
]
},
{
"name": "New Delhi Met",
"callsign": "ATP",
"country": "IN",
"city": "New Delhi",
"coordinates": [28.61, 77.21],
"frequencies": [
{"khz": 7405, "description": "Night"},
{"khz": 14842, "description": "Day"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}
]
},
{
"name": "Murmansk Met",
"callsign": "RBW",
"country": "RU",
"city": "Murmansk",
"coordinates": [68.97, 33.09],
"frequencies": [
{"khz": 6445.5, "description": "Night"},
{"khz": 7907, "description": "Primary"},
{"khz": 8444, "description": "Day"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "07:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "08:00", "duration_min": 20, "content": "Ice Chart"},
{"utc": "14:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "14:30", "duration_min": 20, "content": "Surface Prog"},
{"utc": "20:00", "duration_min": 20, "content": "Surface Analysis"}
]
},
{
"name": "St. Petersburg Met",
"callsign": "RDD78",
"country": "RU",
"city": "St. Petersburg",
"coordinates": [59.93, 30.32],
"frequencies": [
{"khz": 2640, "description": "Night"},
{"khz": 4212, "description": "Primary"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}
]
},
{
"name": "Athens Met",
"callsign": "SVJ4",
"country": "GR",
"city": "Athens",
"coordinates": [37.97, 23.73],
"frequencies": [
{"khz": 4482.9, "description": "Night"},
{"khz": 8106.9, "description": "Primary"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis Med"},
{"utc": "09:00", "duration_min": 20, "content": "Surface Prog"},
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis Med"},
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis Med"}
]
},
{
"name": "Charleville Met",
"callsign": "VMC",
"country": "AU",
"city": "Charleville, QLD",
"coordinates": [-26.41, 146.24],
"frequencies": [
{"khz": 2628, "description": "Night"},
{"khz": 5100, "description": "Primary"},
{"khz": 11030, "description": "Day"},
{"khz": 13920, "description": "Extended"},
{"khz": 20469, "description": "DX"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "00:00", "duration_min": 20, "content": "MSLP Analysis"},
{"utc": "03:00", "duration_min": 20, "content": "Prognosis"},
{"utc": "06:00", "duration_min": 20, "content": "MSLP Analysis"},
{"utc": "09:00", "duration_min": 20, "content": "Sea/Swell Chart"},
{"utc": "12:00", "duration_min": 20, "content": "MSLP Analysis"},
{"utc": "18:00", "duration_min": 20, "content": "MSLP Analysis"},
{"utc": "19:00", "duration_min": 20, "content": "Prognosis"}
]
},
{
"name": "Wiluna Met",
"callsign": "VMW",
"country": "AU",
"city": "Wiluna, WA",
"coordinates": [-26.59, 120.23],
"frequencies": [
{"khz": 5755, "description": "Night"},
{"khz": 7535, "description": "Primary"},
{"khz": 10555, "description": "Day"},
{"khz": 15615, "description": "Extended"},
{"khz": 18060, "description": "DX"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "00:00", "duration_min": 20, "content": "MSLP Analysis"},
{"utc": "06:00", "duration_min": 20, "content": "MSLP Analysis"},
{"utc": "11:00", "duration_min": 20, "content": "Prognosis"},
{"utc": "18:00", "duration_min": 20, "content": "MSLP Analysis"},
{"utc": "21:00", "duration_min": 20, "content": "Sea/Swell Chart"}
]
},
{
"name": "NZ MetService",
"callsign": "ZKLF",
"country": "NZ",
"city": "Auckland",
"coordinates": [-36.85, 174.76],
"frequencies": [
{"khz": 3247.4, "description": "Night"},
{"khz": 5807, "description": "Primary"},
{"khz": 9459, "description": "Day"},
{"khz": 13550.5, "description": "Extended"},
{"khz": 16340.1, "description": "DX"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}
]
},
{
"name": "CFH Halifax",
"callsign": "CFH",
"country": "CA",
"city": "Halifax, NS",
"coordinates": [44.65, -63.57],
"frequencies": [
{"khz": 4271, "description": "Night"},
{"khz": 6496.4, "description": "Primary"},
{"khz": 10536, "description": "Day"},
{"khz": 13510, "description": "Extended"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "03:00", "duration_min": 20, "content": "Surface Prog"},
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "22:22", "duration_min": 20, "content": "Ice Chart"},
{"utc": "23:01", "duration_min": 20, "content": "Surface Analysis"}
]
},
{
"name": "CCG Iqaluit",
"callsign": "VFF",
"country": "CA",
"city": "Iqaluit, NU",
"coordinates": [63.75, -68.52],
"frequencies": [
{"khz": 3253, "description": "Night"},
{"khz": 7710, "description": "Day"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "00:10", "duration_min": 20, "content": "Ice Chart"},
{"utc": "05:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "07:00", "duration_min": 20, "content": "Ice Chart"},
{"utc": "10:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "11:00", "duration_min": 20, "content": "Ice Chart"},
{"utc": "21:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "23:30", "duration_min": 20, "content": "Ice Chart"}
]
},
{
"name": "CCG Inuvik",
"callsign": "VFA",
"country": "CA",
"city": "Inuvik, NT",
"coordinates": [68.36, -133.72],
"frequencies": [
{"khz": 4292, "description": "Night"},
{"khz": 8457.8, "description": "Primary"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "02:00", "duration_min": 20, "content": "Ice Chart"},
{"utc": "16:30", "duration_min": 20, "content": "Ice Chart"}
]
},
{
"name": "CCG Sydney",
"callsign": "VCO",
"country": "CA",
"city": "Sydney, NS",
"coordinates": [46.14, -60.19],
"frequencies": [
{"khz": 4416, "description": "Night"},
{"khz": 6915.1, "description": "Primary"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "11:21", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "11:42", "duration_min": 20, "content": "Surface Prog"},
{"utc": "17:41", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "22:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "23:31", "duration_min": 20, "content": "Surface Prog"}
]
},
{
"name": "Cape Naval",
"callsign": "ZSJ",
"country": "ZA",
"city": "Cape Town",
"coordinates": [-33.92, 18.42],
"frequencies": [
{"khz": 4014, "description": "Night"},
{"khz": 7508, "description": "Primary"},
{"khz": 13538, "description": "Day"},
{"khz": 18238, "description": "Extended"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "04:30", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "05:00", "duration_min": 20, "content": "Sea State"},
{"utc": "06:30", "duration_min": 20, "content": "Surface Prog"},
{"utc": "07:30", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "08:00", "duration_min": 20, "content": "Satellite Image"},
{"utc": "10:30", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "11:00", "duration_min": 20, "content": "Sea State"},
{"utc": "15:30", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "15:40", "duration_min": 20, "content": "Surface Prog"},
{"utc": "22:30", "duration_min": 20, "content": "Surface Analysis"}
]
},
{
"name": "Valparaiso Naval",
"callsign": "CBV",
"country": "CL",
"city": "Valparaiso",
"coordinates": [-33.05, -71.62],
"frequencies": [
{"khz": 4228, "description": "Night"},
{"khz": 8677, "description": "Primary"},
{"khz": 17146.4, "description": "Day"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "11:15", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "11:30", "duration_min": 20, "content": "Surface Prog"},
{"utc": "16:30", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "16:45", "duration_min": 20, "content": "Sea State"},
{"utc": "19:15", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "19:30", "duration_min": 20, "content": "Surface Prog"},
{"utc": "22:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "23:10", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "23:25", "duration_min": 20, "content": "Sea State"}
]
},
{
"name": "Magallanes Naval",
"callsign": "CBM",
"country": "CL",
"city": "Punta Arenas",
"coordinates": [-53.16, -70.91],
"frequencies": [
{"khz": 4322, "description": "Night"},
{"khz": 8696, "description": "Primary"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "01:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "13:00", "duration_min": 20, "content": "Surface Analysis"}
]
},
{
"name": "Rio de Janeiro Naval",
"callsign": "PWZ33",
"country": "BR",
"city": "Rio de Janeiro",
"coordinates": [-22.91, -43.17],
"frequencies": [
{"khz": 12665, "description": "Primary"},
{"khz": 16978, "description": "Day"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "07:45", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "16:30", "duration_min": 20, "content": "Surface Analysis"}
]
},
{
"name": "Dakar Met",
"callsign": "6VU",
"country": "SN",
"city": "Dakar",
"coordinates": [14.69, -17.44],
"frequencies": [
{"khz": 13667.5, "description": "Primary"},
{"khz": 19750, "description": "Day"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"}
]
},
{
"name": "Misaki Fisheries",
"callsign": "JFC",
"country": "JP",
"city": "Miura",
"coordinates": [35.14, 139.62],
"frequencies": [
{"khz": 8616, "description": "Primary"},
{"khz": 13074, "description": "Day"},
{"khz": 17231, "description": "Extended"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "00:00", "duration_min": 20, "content": "Sea Surface Temp"},
{"utc": "06:00", "duration_min": 20, "content": "Current Chart"},
{"utc": "12:00", "duration_min": 20, "content": "Fishing Forecast"},
{"utc": "18:00", "duration_min": 20, "content": "Sea Surface Temp"}
]
}
]
}
+15
View File
@@ -1,6 +1,8 @@
# INTERCEPT - Signal Intelligence Platform
# Docker Compose configuration for easy deployment
#
# Uses gunicorn + gevent production server via start.sh (handles concurrent SSE/WebSocket)
#
# Basic usage (build locally):
# docker compose --profile basic up -d --build
#
@@ -18,6 +20,8 @@ services:
container_name: intercept
ports:
- "5050:5050"
# Uncomment for HTTPS support (set INTERCEPT_HTTPS=true below)
# - "5443:5443"
# Privileged mode required for USB SDR device access
privileged: true
# USB device mapping for all USB devices
@@ -29,9 +33,13 @@ services:
# Optional: mount logs directory
# - ./logs:/app/logs
environment:
- TZ=${TZ:-UTC}
- INTERCEPT_HOST=0.0.0.0
- INTERCEPT_PORT=5050
- INTERCEPT_LOG_LEVEL=INFO
# HTTPS support (auto-generates self-signed cert)
# - INTERCEPT_HTTPS=true
# - INTERCEPT_PORT=5443
# ADS-B history is disabled by default
# To enable, use: docker compose --profile history up -d
# - INTERCEPT_ADSB_HISTORY_ENABLED=true
@@ -70,6 +78,8 @@ services:
- adsb_db
ports:
- "5050:5050"
# Uncomment for HTTPS support (set INTERCEPT_HTTPS=true below)
# - "5443:5443"
# Privileged mode required for USB SDR device access
privileged: true
# USB device mapping for all USB devices
@@ -78,9 +88,13 @@ services:
volumes:
- ./data:/app/data
environment:
- TZ=${TZ:-UTC}
- INTERCEPT_HOST=0.0.0.0
- INTERCEPT_PORT=5050
- INTERCEPT_LOG_LEVEL=INFO
# HTTPS support (auto-generates self-signed cert)
# - INTERCEPT_HTTPS=true
# - INTERCEPT_PORT=5443
- INTERCEPT_ADSB_HISTORY_ENABLED=true
- INTERCEPT_ADSB_DB_HOST=adsb_db
- INTERCEPT_ADSB_DB_PORT=5432
@@ -108,6 +122,7 @@ services:
profiles:
- history
environment:
- TZ=${TZ:-UTC}
- POSTGRES_DB=intercept_adsb
- POSTGRES_USER=intercept
- POSTGRES_PASSWORD=intercept
+2 -2
View File
@@ -38,8 +38,8 @@ The controller is the main Intercept application:
```bash
cd intercept
python app.py
# Runs on http://localhost:5050
./setup.sh # First-time setup (choose install profiles)
sudo ./start.sh # Production server on http://localhost:5050
```
### 2. Configure an Agent
+115 -14
View File
@@ -24,17 +24,6 @@ Complete feature list for all modules.
- **Wideband spectrum analysis** with real-time visualization
- **I/Q capture** - record raw samples for offline analysis
## AIS Vessel Tracking
- **Real-time vessel tracking** via AIS-catcher on 161.975/162.025 MHz
- **Full-screen dashboard** - dedicated popout with interactive map
- **Interactive Leaflet map** with OpenStreetMap tiles (dark-themed)
- **Vessel details popup** - name, MMSI, callsign, destination, ETA
- **Navigation data** - speed, course, heading, rate of turn
- **Ship type classification** - cargo, tanker, passenger, fishing, etc.
- **Vessel dimensions** - length, width, draught
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
## Spy Stations (Number Stations)
- **Comprehensive database** of active number stations and diplomatic networks
@@ -99,11 +88,42 @@ Digital Selective Calling (DSC) monitoring on the international maritime distres
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
- **Message filtering** - filter by message type, flight, or registration
## VDL2 (VHF Data Link Mode 2)
- **Real-time VDL2 decoding** via dumpvdl2 on standard VDL2 frequencies
- **ACARS-over-AVLC** message capture with full frame parsing
- **Signal analysis** - frequency, signal level, noise level, SNR, burst length
- **AVLC frame details** - source/destination addresses, frame type, command/response
- **Raw JSON inspection** - expandable raw message data for each frame
- **Multi-frequency monitoring** - simultaneous reception on multiple VDL2 channels
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
- **CSV/JSON export** - export captured messages for offline analysis
- **Integrated with ADS-B dashboard** - VDL2 messages linked to aircraft tracking
## CW/Morse Code Decoder
- **Custom Goertzel tone detection** for CW (continuous wave) Morse decoding
- **OOK/AM envelope detection** mode for on-off keying signals in ISM bands
- **HF frequency presets** for amateur CW bands (160m-10m)
- **ISM band presets** for OOK envelope mode (315 MHz, 433 MHz, 868 MHz, 915 MHz)
- **Real-time character and word output** with WPM estimation
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
## WeFax (Weather Fax)
- **HF weather fax reception** from marine and meteorological broadcast stations
- **Broadcast timeline** with scheduled transmission times by station
- **Auto-scheduler** for unattended capture of scheduled broadcasts
- **Image gallery** with timestamped decoded weather charts
- **Station presets** for major WeFax broadcasters worldwide
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
## Listening Post
- **Wideband frequency scanning** via rtl_power sweep with SNR filtering
- **Real-time audio monitoring** with FM and SSB demodulation
- **Cross-module frequency routing** from scanner to decoders
- **Waterfall spectrum display** for visual signal identification
- **Customizable frequency presets** and band bookmarks
- **Multi-SDR support** - RTL-SDR, LimeSDR, HackRF, Airspy, SDRplay
@@ -122,11 +142,23 @@ Digital Selective Calling (DSC) monitoring on the international maritime distres
- **Receiver discovery** with automatic caching
- **Frequency tuning** with band presets
## ISS SSTV
- **ISS SSTV image reception** on 145.800 MHz FM during special event transmissions
- **Real-time ISS tracking** with world map and pass predictions
- **Doppler correction** - optional lat/lon input for real-time frequency shift compensation
- **Next pass countdown** - time remaining until ISS is overhead
- **Image gallery** with timestamped decoded imagery
- **TLE updates** - fetch latest ISS orbital elements
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
## HF SSTV
- **Terrestrial SSTV decoding** across HF (80m-10m), VHF (6m, 2m), and UHF (70cm) bands
- **Predefined frequency lookup** for active SSTV calling frequencies
- **Predefined frequency lookup** for 13 active SSTV calling frequencies
- **Auto-modulation selection** - frequency table maps to correct mode (USB, LSB, FM)
- **Image gallery** with decoded transmissions
- **Common modes supported** - PD120, PD180, Martin1, Scottie1, Robot36
## APRS
@@ -141,6 +173,32 @@ Digital Selective Calling (DSC) monitoring on the international maritime distres
- **Real-time JSON output** with meter ID, consumption, and signal data
- **Multiple meter protocol support** via rtl_tcp integration
## Space Weather
- **Real-time solar indices** - Solar Flux Index (SFI), Kp index, A-index, sunspot number
- **NOAA Space Weather Scales** - Geomagnetic storms (G), solar radiation (S), radio blackouts (R)
- **HF band conditions** - Day/night propagation from HamQSL for 80m through 10m bands
- **Solar wind monitoring** - Speed, density, and IMF Bz from DSCOVR satellite
- **X-ray flux chart** - GOES X-ray data with flare class scale (A/B/C/M/X)
- **Flare probability** - 1-day and 3-day C/M/X-class flare forecasts
- **Solar imagery** - NASA SDO 193A, 304A, and magnetogram images
- **D-RAP absorption maps** - HF radio absorption at 5-30 MHz frequency bands
- **Aurora forecast** - OVATION aurora oval visualization
- **SWPC alerts** - Real-time space weather alerts and warnings
- **Active solar regions** - Current sunspot region data with location and area
- **Auto-refresh** - 5-minute polling with manual refresh option
- **No SDR required** - Data fetched from NOAA SWPC, NASA SDO, and HamQSL public APIs
## Radiosonde Weather Balloon Tracking
- **400-406 MHz reception** via radiosonde_auto_rx for weather balloon telemetry
- **Frequency presets** for common radiosonde bands
- **Real-time telemetry** - altitude, temperature, humidity, pressure, GPS position
- **Interactive map** with balloon trajectory and burst point prediction
- **Station location** with configurable observer position
- **Distance tracking** - real-time distance-to-balloon calculation
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
## Satellite Tracking
- **Full-screen dashboard** - dedicated popout with polar plot and ground track
@@ -218,6 +276,34 @@ Search and rescue Bluetooth device location with GPS-tagged signal trail mapping
- Bluetooth adapter (built-in or USB)
- GPS receiver (optional, falls back to manual coordinates)
## WiFi Locate
Locate a WiFi access point by BSSID using real-time signal strength tracking.
### Core Features
- **Target by BSSID** - Enter any MAC address or hand off from the WiFi scanner
- **Real-time signal meter** - Large dBm display with color-coded strength (good/medium/weak)
- **20-segment signal bar** - Visual proximity indicator with red/yellow/green segments
- **RSSI history chart** - Canvas sparkline showing signal trend over time
- **Distance estimation** - Log-distance path loss model with configurable environment presets
- **Audio proximity alerts** - Web Audio API tones that increase in pitch and frequency as signal strengthens
- **Signal lost detection** - 30-second timeout with visual overlay when target disappears
- **Hand-off from WiFi mode** - One-click transfer from WiFi detail drawer to WiFi Locate
- **Stats tracking** - Current, min, max, and average RSSI across session
### Environment Presets
- **Open Field** (n=2.0) - Free space path loss
- **Outdoor** (n=2.8) - Typical outdoor environment (default)
- **Indoor** (n=3.5) - Indoor with walls and obstacles
### Mode Transition
- WiFi scan is preserved when switching between WiFi and WiFi Locate modes
- Deep scan auto-starts if not already running
### Requirements
- WiFi adapter capable of monitor mode
- aircrack-ng suite for deep scanning
## GPS Mode
Real-time GPS position tracking with live map visualization.
@@ -241,7 +327,7 @@ Technical Surveillance Countermeasures (TSCM) screening for detecting wireless s
### Wireless Sweep Features
- **BLE scanning** with manufacturer data detection (AirTags, Tile, SmartTags, ESP32)
- **WiFi scanning** for rogue APs, hidden SSIDs, camera devices
- **RF spectrum analysis** (requires RTL-SDR) - FM bugs, ISM bands, video transmitters
- **RF spectrum analysis** (RTL-SDR or HackRF) - FM bugs, ISM bands, video transmitters
- **Cross-protocol correlation** - links devices across BLE/WiFi/RF
- **Baseline comparison** - detect new/unknown devices vs known environment
@@ -340,10 +426,20 @@ Deploy lightweight sensor nodes across multiple locations and aggregate data to
- **Redundancy** - Multiple nodes for reliable coverage
- **Triangulation** - Use multiple GPS-enabled agents for signal location
## System Health
- **Telemetry dashboard** with real-time system metrics
- **Process monitoring** for all running SDR tools and decoders
- **CPU, memory, and disk usage** tracking
- **SDR device status** overview
- **No SDR required** - monitors system health independently
## User Interface
- **Mode-specific header stats** - real-time badges showing key metrics per mode
- **UTC clock** - always visible in header for time-critical operations
- **SSE connection status indicator** - real-time connection state with SSEManager and exponential backoff reconnection
- **Accessibility** - aria-labels, form label associations, keyboard list navigation, and destructive action confirmation modals
- **Active mode indicator** - shows current mode with pulse animation
- **Collapsible sections** - click any header to collapse/expand
- **Panel styling** - gradient backgrounds with indicator dots
@@ -400,14 +496,19 @@ The settings modal shows availability status for each bundled asset:
## General
- **Web-based interface** - no desktop app needed
- **Production server** - gunicorn + gevent via `start.sh` for concurrent SSE/WebSocket handling (falls back to Flask dev server)
- **Live message streaming** via Server-Sent Events (SSE)
- **Audio alerts** with mute toggle
- **Message export** to CSV/JSON
- **Signal activity meter** and waterfall display
- **Message logging** to file with timestamps
- **Multi-SDR hardware support** - RTL-SDR, LimeSDR, HackRF
- **HTTPS support** via `INTERCEPT_HTTPS` configuration for secure deployments
- **Voice alerts** for configurable event notifications across modes
- **Multi-SDR hardware support** - RTL-SDR, LimeSDR, HackRF, Airspy, SDRplay
- **Automatic device detection** across all supported hardware
- **Hardware-specific validation** - frequency/gain ranges per device type
- **Tool path overrides** via `INTERCEPT_*_PATH` environment variables
- **Native Homebrew detection** for Apple Silicon tool paths
- **Configurable gain and PPM correction**
- **Device intelligence** dashboard with tracking
- **GPS dongle support** - USB GPS receivers for precise observer location
+172 -9
View File
@@ -14,7 +14,39 @@ INTERCEPT automatically detects connected devices.
## Quick Install
### macOS (Homebrew)
### Recommended: Use the Setup Script
The setup script provides an interactive menu with install profiles for selective installation:
```bash
git clone https://github.com/smittix/intercept.git
cd intercept
./setup.sh
```
On first run, a guided wizard walks you through profile selection:
| Profile | What it installs |
|---------|-----------------|
| Core SIGINT | rtl_sdr, multimon-ng, rtl_433, dump1090, acarsdec, dumpvdl2, ffmpeg, gpsd |
| Maritime & Radio | AIS-catcher, direwolf |
| Weather & Space | SatDump, radiosonde_auto_rx |
| RF Security | aircrack-ng, HackRF, BlueZ, hcxtools, Ubertooth, SoapySDR |
| Full SIGINT | All of the above |
For headless/CI installs:
```bash
./setup.sh --non-interactive # Install everything
./setup.sh --profile=core,maritime # Install specific profiles
```
After installation, use the menu to manage your setup:
```bash
./setup.sh # Opens interactive menu
./setup.sh --health-check # Verify installation
```
### Manual Install: macOS (Homebrew)
```bash
# Install Homebrew if needed
@@ -36,7 +68,7 @@ brew install soapysdr limesuite soapylms7
brew install hackrf soapyhackrf
```
### Debian / Ubuntu / Raspberry Pi OS
### Manual Install: Debian / Ubuntu / Raspberry Pi OS
```bash
# Update package lists
@@ -94,6 +126,126 @@ sudo modprobe -r dvb_usb_rtl28xxu
---
## Multiple RTL-SDR Dongles
If you're running two (or more) RTL-SDR dongles on the same machine, they ship with the same default serial number so Linux can't tell them apart reliably. Follow these steps to give each a unique identity.
### Step 1: Blacklist the DVB-T driver
Already covered above, but make sure this is done first — the kernel's DVB driver will grab the dongles before librtlsdr can:
```bash
echo "blacklist dvb_usb_rtl28xxu" | sudo tee /etc/modprobe.d/blacklist-rtl.conf
sudo modprobe -r dvb_usb_rtl28xxu
```
### Step 2: Burn unique serial numbers
Each dongle has an EEPROM that stores a serial number. By default they're all `00000001`. You need to give each one a unique serial.
**Plug in only the first dongle**, then:
```bash
rtl_eeprom -d 0 -s 00000001
```
**Unplug it, plug in the second dongle**, then:
```bash
rtl_eeprom -d 0 -s 00000002
```
> Pick any 8-digit hex serials you like. The `-d 0` means "device index 0" (the only one plugged in).
Unplug and replug both dongles after writing.
### Step 3: Verify
With both plugged in:
```bash
rtl_test -t
```
You should see:
```
0: Realtek, RTL2838UHIDIR, SN: 00000001
1: Realtek, RTL2838UHIDIR, SN: 00000002
```
**Tip:** If you don't know which physical dongle has which serial, unplug one and run `rtl_test -t` — the one still detected is the one still plugged in.
### Step 4: Udev rules with stable symlinks
Create rules that give each dongle a persistent name based on its serial:
```bash
sudo bash -c 'cat > /etc/udev/rules.d/20-rtlsdr.rules << EOF
# RTL-SDR dongles - permissions and stable symlinks by serial
SUBSYSTEM=="usb", ATTR{idVendor}=="0bda", ATTR{idProduct}=="2838", MODE="0666"
SUBSYSTEM=="usb", ATTR{idVendor}=="0bda", ATTR{idProduct}=="2832", MODE="0666"
# Symlinks by serial — change names/serials to match your hardware
SUBSYSTEM=="usb", ATTR{idVendor}=="0bda", ATTRS{serial}=="00000001", SYMLINK+="sdr-dongle1"
SUBSYSTEM=="usb", ATTR{idVendor}=="0bda", ATTRS{serial}=="00000002", SYMLINK+="sdr-dongle2"
EOF'
sudo udevadm control --reload-rules
sudo udevadm trigger
```
After replugging, you'll have `/dev/sdr-dongle1` and `/dev/sdr-dongle2`.
### Step 5: USB power (Raspberry Pi)
Two dongles can draw more current than the Pi allows by default:
```bash
# In /boot/firmware/config.txt, add:
usb_max_current_enable=1
```
Disable USB autosuspend so dongles don't get powered off:
```bash
# In /etc/default/grub or kernel cmdline, add:
usbcore.autosuspend=-1
```
Or via udev:
```bash
echo 'ACTION=="add", SUBSYSTEM=="usb", ATTR{power/autosuspend}="-1"' | \
sudo tee /etc/udev/rules.d/50-usb-autosuspend.rules
```
### Step 6: Docker access
Your `docker-compose.yml` needs privileged mode and USB passthrough:
```yaml
services:
intercept:
privileged: true
volumes:
- /dev/bus/usb:/dev/bus/usb
```
INTERCEPT auto-detects both dongles inside the container via `rtl_test -t` and addresses them by device index (`-d 0`, `-d 1`).
### Quick reference
| Step | What | Why |
|------|------|-----|
| Blacklist DVB | `/etc/modprobe.d/blacklist-rtl.conf` | Kernel won't steal the dongles |
| Burn serials | `rtl_eeprom -d 0 -s <serial>` | Unique identity per dongle |
| Udev rules | `/etc/udev/rules.d/20-rtlsdr.rules` | Permissions + stable `/dev/sdr-*` names |
| USB power | `config.txt` + autosuspend off | Enough current for two dongles on a Pi |
| Docker | `privileged: true` + USB volume | Container sees both dongles |
---
## Verify Installation
### Check dependencies
@@ -119,11 +271,19 @@ SoapySDRUtil --find
./setup.sh
```
This automatically:
- Detects your OS
- Creates a virtual environment if needed (for PEP 668 systems)
- Installs Python dependencies
- Checks for required tools
The setup wizard automatically:
- Detects your OS (macOS, Debian/Ubuntu, DragonOS)
- Lets you choose install profiles (Core, Maritime, Weather, Security, Full, Custom)
- Creates a virtual environment with system site-packages
- Installs Python dependencies (core + optional)
- Runs a health check to verify everything works
After initial setup, use the menu to manage your environment:
- **Install / Add Modules** — add tools you didn't install initially
- **System Health Check** — verify all tools and dependencies
- **Environment Configurator** — set `INTERCEPT_*` variables interactively
- **Update Tools** — rebuild source-built tools (dump1090, SatDump, etc.)
- **View Status** — see what's installed at a glance
### Manual setup
```bash
@@ -139,10 +299,13 @@ pip install -r requirements.txt
After installation:
```bash
sudo -E venv/bin/python intercept.py
sudo ./start.sh
# Custom port
INTERCEPT_PORT=8080 sudo -E venv/bin/python intercept.py
sudo ./start.sh -p 8080
# HTTPS
sudo ./start.sh --https
```
Open **http://localhost:5050** in your browser.
+2 -3
View File
@@ -18,10 +18,9 @@ By default, INTERCEPT binds to `0.0.0.0:5050`, making it accessible from any net
echo "block in on en0 proto tcp from any to any port 5050" | sudo pfctl -ef -
```
2. **Bind to Localhost**: For local-only access, set the host environment variable:
2. **Bind to Localhost**: For local-only access, set the host or use the CLI flag:
```bash
export INTERCEPT_HOST=127.0.0.1
python intercept.py
sudo ./start.sh -H 127.0.0.1
```
3. **Trusted Networks Only**: Only run INTERCEPT on networks you trust. The application has no authentication mechanism.
+17 -7
View File
@@ -25,7 +25,7 @@ sudo apt install python3-flask python3-requests python3-serial python3-skyfield
# Then create venv with system packages
python3 -m venv --system-site-packages venv
source venv/bin/activate
sudo venv/bin/python intercept.py
sudo ./start.sh
```
### "error: externally-managed-environment" (pip blocked)
@@ -61,18 +61,21 @@ sudo apt install python3.11 python3.11-venv python3-pip
python3.11 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
sudo venv/bin/python intercept.py
sudo ./start.sh
```
### Alternative: Use the setup script
The setup script handles all installation automatically, including apt packages:
The setup script handles all installation automatically, including apt packages and source builds:
```bash
chmod +x setup.sh
./setup.sh
./setup.sh # Interactive wizard (first run) or menu
./setup.sh --non-interactive # Headless full install
./setup.sh --health-check # Diagnose installation issues
```
The setup menu also includes a **System Health Check** (option 2) that verifies all tools, SDR devices, ports, permissions, and Python packages — useful for diagnosing installation problems.
### "pip: command not found"
```bash
@@ -336,7 +339,7 @@ rtl_fm -M am -f 118000000 -s 24000 -r 24000 -g 40 2>/dev/null | \
Run INTERCEPT with sudo:
```bash
sudo -E venv/bin/python intercept.py
sudo ./start.sh
```
### Interface not found after enabling monitor mode
@@ -373,7 +376,14 @@ sudo usermod -a -G bluetooth $USER
### Cannot install dump1090 in Debian (ADS-B mode)
On newer Debian versions, dump1090 may not be in repositories. The recommended action is to build from source or use the setup.sh script which will do it for you.
On newer Debian versions, dump1090 may not be in repositories. Use the setup script which builds it from source automatically:
```bash
./setup.sh # Select Core SIGINT profile, or
./setup.sh --profile=core # Install core tools including dump1090
```
The setup menu's **Install / Add Modules** option also lets you install dump1090 individually via the Custom tool checklist.
### No aircraft appearing (ADS-B mode)
+14 -5
View File
@@ -206,14 +206,23 @@ Extended base for full-screen dashboards (maps, visualizations).
| `listening` | Listening post |
| `spystations` | Spy stations |
| `meshtastic` | Mesh networking |
| `weathersat` | Weather satellites |
| `sstv_general` | HF SSTV |
| `gps` | GPS tracking |
| `websdr` | WebSDR |
| `subghz` | Sub-GHz analyzer |
| `bt_locate` | BT Locate |
| `wifi_locate` | WiFi Locate |
| `analytics` | Analytics dashboard |
| `spaceweather` | Space weather |
### Navigation Groups
The navigation is organized into groups:
- **SDR / RF**: Pager, 433MHz, Meters, Aircraft, Vessels, APRS, Listening Post, Spy Stations, Meshtastic
- **Wireless**: WiFi, Bluetooth
- **Security**: TSCM
- **Space**: Satellite, ISS SSTV
- **Signals**: Pager, 433MHz, Meters, Listening Post, SubGHz
- **Tracking**: Aircraft, Vessels, APRS, GPS
- **Space**: Satellite, ISS SSTV, Weather Sat, HF SSTV, Space Weather
- **Wireless**: WiFi, Bluetooth, BT Locate, WiFi Locate, Meshtastic
- **Intel**: TSCM, Analytics, Spy Stations, WebSDR
---
+216 -2
View File
@@ -69,6 +69,22 @@ INTERCEPT automatically detects known trackers:
Common ISM band protocols including garage doors, key fobs, weather stations, and IoT devices in the 300-928 MHz range.
## VDL2 (Aircraft Datalink)
1. **Select Hardware** - Choose your SDR type
2. **Select Device** - Choose your SDR device
3. **Set Frequencies** - Default VDL2 frequencies are pre-configured (136.975, 136.725, 136.775 MHz etc.)
4. **Start Decoding** - Click "Start" to begin VDL2 reception via dumpvdl2
5. **View Messages** - AVLC frames appear with source/destination, signal levels, and decoded content
6. **Inspect Details** - Click a message to view full AVLC frame details and raw JSON
7. **Export** - Use CSV or JSON export buttons to save captured messages
### Tips
- VDL2 is most active near airports and along flight corridors
- Multiple frequencies can be monitored simultaneously for better coverage
- VDL2 data is also accessible from the ADS-B dashboard
## Listening Post
1. **Select Hardware** - Choose your SDR type
@@ -110,6 +126,23 @@ The system highlights aircraft transmitting emergency squawks:
- **7600** - Radio failure
- **7700** - General emergency
## ACARS Messaging
1. **Select Hardware** - Choose your SDR type
2. **Select Device** - Choose your SDR device
3. **Select Region** - Choose North America, Europe, or Asia-Pacific to auto-populate frequencies
4. **Select Frequencies** - Check one or more ACARS frequencies (131.550 MHz primary worldwide, 130.025 MHz secondary USA/Canada, etc.)
5. **Adjust Gain** - Set gain (0 for auto, or 0-50 dB)
6. **Start Decoding** - Click "Start" to begin ACARS reception via acarsdec
7. **View Messages** - Aircraft messages appear in real-time with flight ID, registration, and content
### Tips
- A vertical polarization antenna works best for ACARS
- Quarter-wave dipole: 57 cm per element at 130 MHz
- Stock SDR antenna may work at close range near airports
- Outdoor placement with clear sky view significantly improves reception
## ADS-B History (Optional)
The history dashboard persists aircraft messages and per-aircraft snapshots to Postgres for long-running tracking and reporting.
@@ -139,7 +172,7 @@ Set the following environment variables (Docker recommended):
```bash
INTERCEPT_ADSB_AUTO_START=true \
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
python app.py
sudo ./start.sh
```
**Docker example (.env)**
@@ -206,6 +239,25 @@ Enable the auto-scheduler to automatically capture passes:
- Starts SatDump at the correct time and frequency
- Decoded images are saved with timestamps
## Space Weather
1. **Switch to Space Weather mode** - Select "Space Weather" from the Space navigation group
2. **View Dashboard** - Solar indices, NOAA scales, band conditions, and charts load automatically
3. **Solar Imagery** - Toggle between SDO 193A, 304A, and Magnetogram views
4. **D-RAP Maps** - Select frequency (5-30 MHz) to view HF radio absorption maps
5. **Aurora Forecast** - View the OVATION aurora oval for the northern hemisphere
6. **Alerts** - Review current SWPC space weather alerts and warnings
7. **Active Regions** - View solar active region data (number, location, area)
8. **Refresh** - Data auto-refreshes every 5 minutes, or click "Refresh Now"
### Tips
- No SDR hardware required — all data comes from public APIs (NOAA SWPC, NASA SDO, HamQSL)
- Check HF band conditions before operating on shortwave frequencies
- Kp >= 5 indicates geomagnetic storm conditions that may affect HF propagation
- D-RAP maps show where HF absorption is highest — useful for path planning
- Solar imagery updates approximately every 15 minutes from NASA SDO
## AIS Vessel Tracking
1. **Select Hardware** - Choose your SDR type
@@ -221,6 +273,61 @@ Digital Selective Calling monitoring runs alongside AIS:
- Distress positions plotted with pulsing alert markers
- Audio alerts for critical messages
## WebSDR
1. **Set Frequency** - Enter a frequency in kHz (e.g., 6500 for 6.5 MHz)
2. **Select Mode** - Choose demodulation mode (USB, LSB, AM, CW)
3. **Find Receivers** - Click "Find Receivers" to discover available KiwiSDR nodes worldwide
4. **Select Receiver** - Click a receiver from the list to connect
5. **Listen** - Audio streams in real-time via WebSocket
6. **Adjust Volume** - Use the volume slider and monitor the S-meter
7. **Spy Station Presets** - Use the quick-tune buttons to jump to known number station frequencies
### Tips
- Requires an internet connection to access the KiwiSDR network
- Receiver list is cached for 1 hour to reduce API load
- Receivers are sorted by distance from your location
- Integrated spy station presets allow quick tuning to SIGINT targets
## ISS SSTV
1. **Select Hardware** - Choose your SDR type
2. **Select Device** - Choose your SDR device
3. **Set Frequency** - Default is 145.800 MHz (ISS downlink)
4. **Set Location** - Enter lat/lon for Doppler correction and pass prediction
5. **Update TLE** - Click "Update TLE" to fetch latest ISS orbital elements
6. **Wait for Pass** - The next pass countdown shows when ISS will be overhead
7. **Start Decoding** - Click "Start" to begin SSTV reception
8. **View Images** - Decoded SSTV images appear in the gallery with timestamps
### Tips
- A V-dipole or better antenna is required (stock antenna will not work)
- V-dipole construction: 51 cm per element at 145.8 MHz, 120-degree angle between elements
- ISS SSTV events occur during special anniversaries and missions — check ARISS for schedules
- Best passes have elevation > 30 degrees above horizon
- Doppler shift tracking dramatically improves reception quality
- Common SSTV modes: PD120, PD180, Martin1, Scottie1
- Outdoor antenna placement with clear sky view is essential
## HF SSTV
1. **Select Hardware** - Choose your SDR type
2. **Select Device** - Choose your SDR device
3. **Select Frequency** - Choose from 13 preset frequencies or enter a custom one
4. **Modulation** - Auto-selected based on frequency (USB for HF, FM for VHF/UHF)
5. **Start Decoding** - Click "Start" to begin SSTV reception
6. **View Images** - Decoded amateur radio images appear in the gallery
### Tips
- HF frequencies (3-30 MHz) require an upconverter with RTL-SDR
- VHF/UHF frequencies (145 MHz, 433 MHz) work directly with RTL-SDR
- Most popular frequency: 14.230 MHz USB (20m band) with regular activity
- Weekend activity peaks on most HF bands
- Amateur license is not required to receive (listen-only)
## APRS
1. **Select Hardware** - Choose your SDR type
@@ -270,6 +377,39 @@ Digital Selective Calling monitoring runs alongside AIS:
- The RSSI chart shows signal trend over time — use it to determine if you're getting closer
- Clear the trail when starting a new search area
## WiFi Locate Mode
1. **Set Target** - Enter a BSSID (MAC address) in AA:BB:CC:DD:EE:FF format, or hand off from WiFi mode
2. **Choose Environment** - Select the RF environment preset:
- **Open Field** (n=2.0) - Best for open areas with line-of-sight
- **Outdoor** (n=2.8) - Default, works well in most outdoor settings
- **Indoor** (n=3.5) - For buildings with walls and obstacles
3. **Start Locate** - Click "Start Locate" to begin tracking
4. **Monitor Signal** - The HUD shows:
- Large dBm reading with color coding (green/yellow/red)
- 20-segment signal bar for quick visual reference
- Estimated distance based on path loss model
- RSSI history chart for trend analysis
- Current/min/max/average statistics
5. **Follow the Signal** - Move towards stronger signal (higher RSSI / closer distance)
6. **Audio Alerts** - Enable audio for proximity tones that speed up as signal strengthens
### Hand-off from WiFi Mode
1. Open WiFi scanning mode and start a deep scan
2. Click any network to open the detail drawer
3. Click the "Locate" button in the drawer header
4. WiFi Locate opens with the BSSID and SSID pre-filled
5. Click "Start Locate" to begin tracking
### Tips
- Deep scan is required for continuous RSSI updates — WiFi Locate auto-starts it if needed
- The WiFi scan is preserved when switching between WiFi and WiFi Locate modes
- Signal lost overlay appears after 30 seconds without an update from the target
- The distance estimate is approximate — environment preset significantly affects accuracy
- Indoor environments with walls attenuate signal more than open field
## GPS Mode
1. **Start GPS** - Click "Start" to connect to gpsd and begin position tracking
@@ -283,6 +423,46 @@ Digital Selective Calling monitoring runs alongside AIS:
- GPS fix may take 30-60 seconds after cold start
- Accuracy improves with more satellites in view
## TSCM (Counter-Surveillance)
1. **Select Sweep Type** - Choose from Quick Scan (2 min), Standard (5 min), Full Sweep (15 min), or presets for Wireless Cameras, Body-Worn Devices, or GPS Trackers
2. **Select Scan Sources** - Toggle WiFi, Bluetooth, and/or RF/SDR scanning and select the appropriate interfaces
3. **Select Baseline** - Optionally choose a previously recorded baseline to compare against
4. **Start Sweep** - Click "Start Sweep" to begin scanning
5. **Review Results** - Detected devices are classified and scored by threat level
6. **Record Baseline** - In a known clean environment, record a baseline for future comparison
7. **Export Report** - Generate PDF report, JSON annex, or CSV data
### Threat Levels
- **Informational (0-2)** - Known or expected devices
- **Needs Review (3-5)** - Unusual devices requiring assessment
- **High Interest (6+)** - Multiple indicators warrant investigation
### Tips
- Record a baseline in a known clean environment before conducting sweeps
- Use the meeting window feature to flag new RF signatures during sensitive periods
- Full functionality requires WiFi adapter, Bluetooth adapter, and SDR hardware
- Threat detection uses a database of 47K+ known tracker fingerprints
## Spy Stations
1. **Browse Database** - View the full list of documented number stations and diplomatic networks
2. **Filter by Type** - Toggle between Number Stations and Diplomatic Networks
3. **Filter by Country** - Select specific countries (Russia, Cuba, Israel, Poland, etc.)
4. **Filter by Mode** - Filter by demodulation mode (USB, AM, CW, OFDM)
5. **View Details** - Click "Details" on a station card for full information
6. **Tune In** - Click "Tune In" to route the station frequency to the Listening Post or WebSDR
### Tips
- Data sourced from priyom.org (non-profit monitoring community)
- Most activity is on HF bands (3-30 MHz) — propagation varies by time of day
- Notable stations: UVB-76 "The Buzzer" (4625 kHz), E06 English Man, HM01 Cuban Numbers
- Legal to monitor in most countries (check local regulations)
- No decryption or content decoding is included — this is a reference database
## Meshtastic
1. **Connect Device** - Plug in a Meshtastic device via USB or connect via TCP
@@ -291,6 +471,22 @@ Digital Selective Calling monitoring runs alongside AIS:
4. **View Nodes** - Connected nodes displayed with signal metrics (RSSI, SNR)
5. **Send Messages** - Type messages to broadcast on the mesh
## Offline Mode
1. **Open Settings** - Click the gear icon in the navigation bar
2. **Offline Tab** - Toggle "Offline Mode" to enable local assets
3. **Configure Sources** - Switch assets and fonts from CDN to local
4. **Set Tile Provider** - Choose a map tile provider or enter a custom tile server URL
5. **Check Assets** - Click "Check Assets" to verify all local files are present
### Tips
- Download required assets: Leaflet JS/CSS, Chart.js, Inter and JetBrains Mono fonts
- Assets are stored in the `static/vendor/` directory
- For maps, you need a local tile server (e.g., self-hosted OpenStreetMap tiles)
- Missing assets fail gracefully with console warnings
- Useful for air-gapped environments, field deployments, or reducing latency
## Remote Agents (Distributed SIGINT)
Deploy lightweight sensor nodes across multiple locations and aggregate data to a central controller.
@@ -355,10 +551,28 @@ INTERCEPT can be configured via environment variables:
| `INTERCEPT_LOG_LEVEL` | `WARNING` | Log level (DEBUG, INFO, WARNING, ERROR) |
| `INTERCEPT_DEFAULT_GAIN` | `40` | Default RTL-SDR gain |
Example: `INTERCEPT_PORT=8080 sudo -E venv/bin/python intercept.py`
Example: `INTERCEPT_PORT=8080 sudo ./start.sh`
## Command-line Options
### Production server (recommended)
```
sudo ./start.sh --help
-p, --port PORT Port to listen on (default: 5050)
-H, --host HOST Host to bind to (default: 0.0.0.0)
-d, --debug Run in debug mode (Flask dev server)
--https Enable HTTPS with self-signed certificate
--check-deps Check dependencies and exit
```
> **Note:** `sudo` is required for SDR hardware access, WiFi monitor mode, and Bluetooth low-level operations.
`start.sh` auto-detects gunicorn + gevent and runs a production WSGI server with cooperative greenlets — this handles multiple SSE streams and WebSocket connections concurrently without blocking. Falls back to the Flask dev server if gunicorn is not installed.
### Development server
```
python3 intercept.py --help
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 694 KiB

After

Width:  |  Height:  |  Size: 790 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 853 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 570 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 876 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 455 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 886 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

+578 -153
View File
@@ -11,9 +11,10 @@
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<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>
@@ -27,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>
@@ -35,7 +36,7 @@
</div>
<div class="hero-stats">
<div class="stat">
<span class="stat-value">20+</span>
<span class="stat-value">34</span>
<span class="stat-label">Modes</span>
</div>
<div class="stat">
@@ -58,151 +59,174 @@
<h2>Capabilities</h2>
<p class="section-subtitle">Everything you need for signal intelligence in one interface</p>
<div class="features-grid">
<div class="feature-card">
<div class="feature-icon">📟</div>
<h3>Pager Decoding</h3>
<p>Decode POCSAG and FLEX pager messages using rtl_fm and multimon-ng. Monitor emergency services and legacy paging systems.</p>
</div>
<div class="feature-card">
<div class="feature-icon">✈️</div>
<h3>Aircraft Tracking</h3>
<p>Real-time ADS-B tracking with interactive maps, aircraft photos, emergency squawk detection, and range visualization.</p>
</div>
<div class="feature-card">
<div class="feature-icon">📡</div>
<h3>433MHz Sensors</h3>
<p>Decode 200+ protocols including weather stations, TPMS, smart home devices, and IoT sensors via rtl_433.</p>
</div>
<div class="feature-card">
<div class="feature-icon">📻</div>
<h3>Sub-GHz Analyzer</h3>
<p>HackRF-based signal capture and protocol decoding for 300-928 MHz ISM bands with spectrum analysis and replay.</p>
</div>
<div class="feature-card">
<div class="feature-icon">📻</div>
<h3>Listening Post</h3>
<p>Frequency scanner with real-time audio monitoring, fine-tuning controls, and customizable frequency presets.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🛰️</div>
<h3>Satellite Tracking</h3>
<p>Track satellites with TLE data, sky plots, ground track visualization, and pass predictions for your location.</p>
</div>
<div class="feature-card">
<div class="feature-icon">📶</div>
<h3>WiFi Scanning</h3>
<p>Monitor mode reconnaissance via aircrack-ng. Network discovery, client tracking, and handshake capture.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🔵</div>
<h3>Bluetooth Scanning</h3>
<p>Device discovery with tracker detection for AirTags, Tile, Samsung SmartTag, and other Bluetooth devices.</p>
</div>
<div class="feature-card">
<div class="feature-icon">📍</div>
<h3>BT Locate</h3>
<p>SAR Bluetooth device location with GPS-tagged signal trail mapping, IRK-based RPA resolution, and proximity audio alerts.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🛰️</div>
<h3>GPS Tracking</h3>
<p>Real-time GPS position tracking with live map, speed, heading, altitude, satellite info, and track recording.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🛡️</div>
<h3>TSCM</h3>
<p>Counter-surveillance with baseline recording, threat detection, device correlation, and risk scoring.</p>
</div>
<div class="feature-card">
<div class="feature-icon"></div>
<h3>Meter Reading</h3>
<p>Intercept smart utility meters via rtl_amr. Monitor electricity, gas, and water meter transmissions.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🚢</div>
<h3>Vessel Tracking</h3>
<p>Real-time AIS ship tracking via AIS-catcher. Monitor maritime traffic with vessel details, course, speed, and destination.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🔢</div>
<h3>Spy Stations</h3>
<p>Number stations and diplomatic HF network database. Frequencies, schedules, and background info from priyom.org.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🌐</div>
<h3>Remote Agents</h3>
<p>Distributed signal intelligence with remote sensor nodes. Deploy agents across multiple locations and aggregate data to a central controller.</p>
</div>
<div class="feature-card">
<div class="feature-icon">📴</div>
<h3>Offline Mode</h3>
<p>Run without internet using bundled assets. Choose from multiple map tile providers or use your own local tile server.</p>
</div>
<div class="feature-card">
<div class="feature-icon">📡</div>
<h3>Meshtastic</h3>
<p>LoRa mesh network integration. Connect to Meshtastic devices for decentralized, long-range communication monitoring.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🌧️</div>
<h3>Weather Satellites</h3>
<p>NOAA APT and Meteor LRPT image decoding via SatDump with auto-scheduler, polar plot, and ground track map.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🖼️</div>
<h3>ISS SSTV</h3>
<p>Receive Slow Scan Television from the ISS. Real-time tracking globe, pass predictions, and image decoding.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🖼️</div>
<h3>HF SSTV</h3>
<p>Terrestrial SSTV on shortwave frequencies. Decode amateur radio image transmissions across HF, VHF, and UHF bands.</p>
</div>
<div class="feature-card">
<div class="feature-icon">✈️</div>
<h3>ACARS</h3>
<p>Aircraft datalink messages via acarsdec. Decode operational, weather, and position reports from commercial flights.</p>
</div>
<div class="feature-card">
<div class="feature-icon">📍</div>
<h3>APRS</h3>
<p>Amateur packet radio position reports and telemetry via direwolf. Track amateur radio operators on an interactive map.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🌐</div>
<h3>WebSDR</h3>
<p>Remote HF/shortwave listening via the KiwiSDR network. Access receivers worldwide with real-time audio streaming.</p>
</div>
<div class="feature-card">
<div class="feature-icon"></div>
<h3>Utility Meters</h3>
<p>Smart meter monitoring via rtl_amr. Receive electric, gas, and water meter broadcasts in real time.</p>
</div>
<div class="carousel-filters">
<button class="filter-btn active" data-filter="all">All</button>
<button class="filter-btn" data-filter="signals">Signals</button>
<button class="filter-btn" data-filter="tracking">Tracking</button>
<button class="filter-btn" data-filter="space">Space</button>
<button class="filter-btn" data-filter="wireless">Wireless</button>
<button class="filter-btn" data-filter="intel">Intel</button>
<button class="filter-btn" data-filter="platform">Platform</button>
</div>
<div class="carousel-wrapper">
<button class="carousel-arrow carousel-arrow-left" aria-label="Scroll left">&#8249;</button>
<div class="carousel-track">
<div class="feature-card" data-category="signals">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="6" y1="9" x2="6" y2="15"/><line x1="10" y1="9" x2="10" y2="15"/><line x1="14" y1="11" x2="18" y2="11"/><line x1="14" y1="13" x2="18" y2="13"/></svg></div>
<h3>Pager Decoding</h3>
<p>Decode POCSAG and FLEX pager messages using rtl_fm and multimon-ng. Monitor emergency services and legacy paging systems.</p>
</div>
<div class="feature-card" data-category="signals">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v4"/><path d="M12 18v4"/><circle cx="12" cy="12" r="4"/><path d="M4.93 4.93l2.83 2.83"/><path d="M16.24 16.24l2.83 2.83"/><path d="M2 12h4"/><path d="M18 12h4"/><path d="M4.93 19.07l2.83-2.83"/><path d="M16.24 7.76l2.83-2.83"/></svg></div>
<h3>433MHz Sensors</h3>
<p>Decode 200+ protocols including weather stations, TPMS, smart home devices, and IoT sensors via rtl_433.</p>
</div>
<div class="feature-card" data-category="signals">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h4l3-9 6 18 3-9h4"/></svg></div>
<h3>Sub-GHz Analyzer</h3>
<p>HackRF-based signal capture and protocol decoding for 300-928 MHz ISM bands with spectrum analysis and replay.</p>
</div>
<div class="feature-card" data-category="signals">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 18.5a6.5 6.5 0 1 1 0-13"/><path d="M17 12h5"/><path d="M12 7V2"/><circle cx="12" cy="12" r="2"/><path d="M8.5 8.5L5 5"/></svg></div>
<h3>Listening Post</h3>
<p>Frequency scanner with real-time audio monitoring, fine-tuning controls, and customizable frequency presets.</p>
</div>
<div class="feature-card" data-category="signals">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h2"/><path d="M8 12h1"/><path d="M11 12h2"/><path d="M15 12h1"/><path d="M18 12h2"/><circle cx="6" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="18" cy="12" r="1"/><path d="M4 8h16"/><path d="M4 16h16"/></svg></div>
<h3>CW/Morse Decoder</h3>
<p>Morse code decoding with custom Goertzel tone detection for CW and OOK/AM envelope detection for ISM band signals.</p>
</div>
<div class="feature-card" data-category="intel">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></div>
<h3>WebSDR</h3>
<p>Remote HF/shortwave listening via the KiwiSDR network. Access receivers worldwide with real-time audio streaming.</p>
</div>
<div class="feature-card" data-category="intel">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><path d="M8 8h2v2H8z"/><path d="M14 8h2v2h-2z"/><path d="M8 14h2v2H8z"/><path d="M14 14h2v2h-2z"/><path d="M11 8h2v2h-2z"/><path d="M11 11h2v2h-2z"/></svg></div>
<h3>Spy Stations</h3>
<p>Number stations and diplomatic HF network database. Frequencies, schedules, and background info from priyom.org.</p>
</div>
<div class="feature-card" data-category="tracking">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg></div>
<h3>APRS</h3>
<p>Amateur packet radio position reports and telemetry via direwolf. Track amateur radio operators on an interactive map.</p>
</div>
<div class="feature-card" data-category="signals">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg></div>
<h3>Utility Meters</h3>
<p>Smart meter monitoring via rtlamr. Receive electric, gas, and water meter broadcasts in real time.</p>
</div>
<div class="feature-card" data-category="tracking">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17.8 19.2L16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z"/></svg></div>
<h3>Aircraft Tracking</h3>
<p>Real-time ADS-B tracking with interactive maps, aircraft photos, emergency squawk detection, and range visualization.</p>
</div>
<div class="feature-card" data-category="tracking">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 8h10"/><path d="M7 12h6"/><path d="M7 16h8"/></svg></div>
<h3>ACARS</h3>
<p>Aircraft datalink messages via acarsdec. Decode operational, weather, and position reports from commercial flights.</p>
</div>
<div class="feature-card" data-category="tracking">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="M5 12h14"/><circle cx="12" cy="12" r="9"/><path d="M3.5 9h17"/><path d="M3.5 15h17"/></svg></div>
<h3>VDL2</h3>
<p>VHF Data Link Mode 2 aircraft datalink decoding via dumpvdl2. Real-time ACARS-over-AVLC message capture with signal analysis.</p>
</div>
<div class="feature-card" data-category="tracking">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 20l4-4h3l4-7 2 4h2l5-9"/><path d="M22 20H2"/><path d="M6 16v4"/></svg></div>
<h3>Vessel Tracking</h3>
<p>Real-time AIS ship tracking via AIS-catcher. Monitor maritime traffic with vessel details, course, speed, and destination.</p>
</div>
<div class="feature-card" data-category="space">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 1v4"/><path d="M12 19v4"/><path d="M5 5l2 2"/><path d="M17 17l2 2"/><path d="M1 12h4"/><path d="M19 12h4"/><path d="M5 19l2-2"/><path d="M17 7l2-2"/><ellipse cx="12" cy="12" rx="10" ry="4" transform="rotate(45 12 12)"/></svg></div>
<h3>Satellite Tracking</h3>
<p>Track satellites with TLE data, sky plots, ground track visualization, and pass predictions for your location.</p>
</div>
<div class="feature-card" data-category="space">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/><circle cx="12" cy="12" r="4"/><path d="M16 12a4 4 0 0 0-4-4"/></svg></div>
<h3>Weather Satellites</h3>
<p>NOAA APT and Meteor LRPT image decoding via SatDump with auto-scheduler, polar plot, and ground track map.</p>
</div>
<div class="feature-card" data-category="space">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg></div>
<h3>ISS SSTV</h3>
<p>Receive Slow Scan Television from the ISS. Real-time tracking globe, pass predictions, and image decoding.</p>
</div>
<div class="feature-card" data-category="space">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 3v18"/></svg></div>
<h3>HF SSTV</h3>
<p>Terrestrial SSTV on shortwave frequencies. Decode amateur radio image transmissions across HF, VHF, and UHF bands.</p>
</div>
<div class="feature-card" data-category="space">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 15h18"/><path d="M3 9h18"/><path d="M6 3v18"/><path d="M18 3v18"/><path d="M9 6h6"/></svg></div>
<h3>WeFax</h3>
<p>HF weather fax decoder with broadcast timeline, auto-scheduler, and image gallery for marine weather charts.</p>
</div>
<div class="feature-card" data-category="tracking">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/><path d="M12 8l4 4-4 4"/></svg></div>
<h3>GPS Tracking</h3>
<p>Real-time GPS position tracking with live map, speed, heading, altitude, satellite info, and track recording.</p>
</div>
<div class="feature-card" data-category="tracking">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v6"/><path d="M12 22v-6"/><circle cx="12" cy="12" r="4"/><path d="M8 12H2"/><path d="M22 12h-6"/><path d="M12 8a20 20 0 0 1 0 8"/><path d="M7 4l2 3"/><path d="M17 20l-2-3"/></svg></div>
<h3>Radiosonde</h3>
<p>Weather balloon tracking on 400-406 MHz via radiosonde_auto_rx. Real-time telemetry, trajectory map, and station distance.</p>
</div>
<div class="feature-card" data-category="space">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg></div>
<h3>Space Weather</h3>
<p>Real-time solar and geomagnetic monitoring. Kp index, HF band conditions, solar imagery, D-RAP maps, and aurora forecasts from NOAA SWPC.</p>
</div>
<div class="feature-card" data-category="wireless">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1"/></svg></div>
<h3>WiFi Scanning</h3>
<p>Monitor mode reconnaissance via aircrack-ng. Network discovery, client tracking, and handshake capture.</p>
</div>
<div class="feature-card" data-category="wireless">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="6"/><path d="M12 16v5"/><path d="M8 21h8"/><path d="M9.5 7.5L12 10l2.5-2.5"/></svg></div>
<h3>Bluetooth Scanning</h3>
<p>Device discovery with tracker detection for AirTags, Tile, Samsung SmartTag, and other Bluetooth devices.</p>
</div>
<div class="feature-card" data-category="wireless">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><circle cx="12" cy="12" r="7" stroke-dasharray="4 2"/><circle cx="12" cy="12" r="11" stroke-dasharray="2 3"/><line x1="12" y1="1" x2="12" y2="3"/></svg></div>
<h3>BT Locate</h3>
<p>SAR Bluetooth device location with GPS-tagged signal trail mapping, IRK-based RPA resolution, and proximity audio alerts.</p>
</div>
<div class="feature-card" data-category="wireless">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor" stroke="none"/><circle cx="12" cy="10" r="2"/><path d="M12 14v-2"/></svg></div>
<h3>WiFi Locate</h3>
<p>Locate WiFi access points by BSSID with real-time signal meter, distance estimation, RSSI chart, and audio proximity tones.</p>
</div>
<div class="feature-card" data-category="intel">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s-8-4.5-8-11.8A8 8 0 0 1 12 2a8 8 0 0 1 8 8.2c0 7.3-8 11.8-8 11.8z"/><circle cx="12" cy="10" r="3"/><path d="M12 2v3"/><path d="M4.93 4.93l2.12 2.12"/><path d="M20 12h-3"/></svg></div>
<h3>TSCM</h3>
<p>Counter-surveillance with baseline recording, threat detection, device correlation, and risk scoring.</p>
</div>
<div class="feature-card" data-category="wireless">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg></div>
<h3>Meshtastic</h3>
<p>LoRa mesh network integration. Connect to Meshtastic devices for decentralized, long-range communication monitoring.</p>
</div>
<div class="feature-card" data-category="platform">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/><circle cx="9" cy="10" r="1.5"/><circle cx="15" cy="10" r="1.5"/><path d="M5 10h2"/><path d="M17 10h2"/></svg></div>
<h3>Remote Agents</h3>
<p>Distributed signal intelligence with remote sensor nodes. Deploy agents across multiple locations and aggregate data to a central controller.</p>
</div>
<div class="feature-card" data-category="platform">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18.36 6.64A9 9 0 0 1 20.77 15"/><path d="M6.16 6.16a9 9 0 0 0-2.57 8.84"/><path d="M12 2v4"/><path d="M2 12h4"/><line x1="2" y1="2" x2="22" y2="22"/><circle cx="12" cy="12" r="3"/></svg></div>
<h3>Offline Mode</h3>
<p>Run without internet using bundled assets. Choose from multiple map tile providers or use your own local tile server.</p>
</div>
<div class="feature-card" data-category="platform">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg></div>
<h3>System Health</h3>
<p>Real-time telemetry dashboard with process monitoring, system metrics, and SDR device status overview.</p>
</div>
</div>
<button class="carousel-arrow carousel-arrow-right" aria-label="Scroll right">&#8250;</button>
</div>
<div class="carousel-indicators" id="carousel-indicators"></div>
</div>
</section>
@@ -252,6 +276,46 @@
<img src="images/bt-locate.png" alt="BT Locate SAR Tracker">
<span class="screenshot-label">BT Locate — SAR Tracker</span>
</div>
<div class="screenshot-item">
<img src="images/spy-stations.png" alt="Spy Stations Database">
<span class="screenshot-label">Spy Stations</span>
</div>
<div class="screenshot-item">
<img src="images/gps.png" alt="GPS Receiver">
<span class="screenshot-label">GPS Receiver</span>
</div>
<div class="screenshot-item">
<img src="images/websdr.png" alt="WebSDR Remote Listening">
<span class="screenshot-label">WebSDR</span>
</div>
<div class="screenshot-item">
<img src="images/aprs.png" alt="APRS Tracker">
<span class="screenshot-label">APRS Tracker</span>
</div>
<div class="screenshot-item">
<img src="images/vdl2.png" alt="VDL2 Aircraft Datalink">
<span class="screenshot-label">VDL2 Aircraft Datalink</span>
</div>
<div class="screenshot-item">
<img src="images/weather-satellite.png" alt="Weather Satellite Decoder">
<span class="screenshot-label">Weather Satellite</span>
</div>
<div class="screenshot-item">
<img src="images/space-weather-1.png" alt="Space Weather Dashboard">
<span class="screenshot-label">Space Weather</span>
</div>
<div class="screenshot-item">
<img src="images/space-weather-2.png" alt="Space Weather Solar Imagery">
<span class="screenshot-label">Space Weather — Solar &amp; Aurora</span>
</div>
<div class="screenshot-item">
<img src="images/satellite-tracker.png" alt="Satellite Tracker">
<span class="screenshot-label">Satellite Tracker</span>
</div>
<div class="screenshot-item">
<img src="images/iss-sstv.png" alt="ISS SSTV Decoder">
<span class="screenshot-label">ISS SSTV</span>
</div>
</div>
</div>
</section>
@@ -271,10 +335,10 @@
<div class="code-block">
<pre><code>git clone https://github.com/smittix/intercept.git
cd intercept
./setup.sh
sudo -E venv/bin/python intercept.py</code></pre>
./setup.sh # Interactive wizard with install profiles
sudo ./start.sh</code></pre>
</div>
<p class="install-note">Requires Python 3.9+ and RTL-SDR drivers</p>
<p class="install-note">Menu-driven setup: choose Core, Maritime, Weather, Security, or Full SIGINT profiles. Headless mode: <code>./setup.sh --non-interactive</code></p>
</div>
<div class="install-card">
@@ -282,7 +346,7 @@ sudo -E venv/bin/python intercept.py</code></pre>
<div class="code-block">
<pre><code>git clone https://github.com/smittix/intercept.git
cd intercept
docker compose up -d</code></pre>
docker compose --profile basic up -d --build</code></pre>
</div>
<p class="install-note">Requires privileged mode for USB SDR access</p>
</div>
@@ -291,6 +355,7 @@ docker compose up -d</code></pre>
<div class="post-install">
<p>After starting, open <code>http://localhost:5050</code> in your browser.</p>
<p>Default credentials: <code>admin</code> / <code>admin</code></p>
<p>Run <code>./setup.sh --health-check</code> to verify your installation, or use menu option 2 for a full system health check.</p>
</div>
</div>
</section>
@@ -336,22 +401,54 @@ docker compose up -d</code></pre>
</div>
</section>
<section class="support">
<div class="container">
<h2>Support & Contact</h2>
<p class="section-subtitle">Help keep iNTERCEPT alive or get in touch</p>
<div class="support-grid">
<a href="https://www.buymeacoffee.com/smittix" target="_blank" class="support-card support-coffee">
<div class="support-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 8h1a4 4 0 0 1 0 8h-1"/><path d="M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V8z"/><line x1="6" y1="2" x2="6" y2="4"/><line x1="10" y1="2" x2="10" y2="4"/><line x1="14" y1="2" x2="14" y2="4"/></svg></div>
<h3>Buy Me a Coffee</h3>
<p>Support development with a one-time donation</p>
</a>
<a href="#" id="email-card" class="support-card" onclick="return false;">
<div class="support-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M22 4L12 13 2 4"/></svg></div>
<h3>Email</h3>
<p id="email-text">Click to reveal</p>
</a>
<a href="https://discord.gg/EyeksEJmWE" target="_blank" class="support-card">
<div class="support-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><circle cx="12" cy="12" r="10"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></div>
<h3>Discord</h3>
<p>Join the community for help and discussion</p>
</a>
<a href="https://github.com/smittix/intercept/issues" target="_blank" class="support-card">
<div class="support-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></div>
<h3>Report an Issue</h3>
<p>Bug reports and feature requests on GitHub</p>
</a>
</div>
</div>
</section>
<footer class="footer">
<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">
<a href="https://github.com/smittix/intercept" target="_blank">GitHub</a>
<a href="https://discord.gg/EyeksEJmWE" target="_blank">Discord</a>
<a href="#" id="footer-email">Email</a>
<a href="https://www.buymeacoffee.com/smittix" target="_blank">Donate</a>
<a href="https://github.com/smittix/intercept/blob/main/docs/USAGE.md">Documentation</a>
<a href="https://github.com/smittix/intercept/blob/main/docs/DISTRIBUTED_AGENTS.md">Remote Agents</a>
</div>
</div>
<div class="footer-bottom">
<p>Created by <a href="https://github.com/smittix" target="_blank">smittix</a> · MIT License</p>
<p>Created by <a href="https://github.com/smittix" target="_blank">smittix</a> · Apache 2.0 License</p>
<p class="disclaimer">For educational and authorized testing purposes only.</p>
</div>
</div>
@@ -394,6 +491,334 @@ docker compose up -d</code></pre>
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeLightbox();
});
// Carousel functionality
(function() {
const track = document.querySelector('.carousel-track');
const cards = Array.from(track.querySelectorAll('.feature-card'));
const leftArrow = document.querySelector('.carousel-arrow-left');
const rightArrow = document.querySelector('.carousel-arrow-right');
const filterBtns = document.querySelectorAll('.filter-btn');
const indicatorContainer = document.getElementById('carousel-indicators');
const SCROLL_AMOUNT = 300;
function updateArrows() {
leftArrow.disabled = track.scrollLeft <= 0;
rightArrow.disabled = track.scrollLeft + track.clientWidth >= track.scrollWidth - 2;
}
function buildIndicators() {
const visible = cards.filter(c => !c.classList.contains('hidden'));
const totalWidth = visible.length * 300;
const pages = Math.max(1, Math.ceil(totalWidth / track.clientWidth));
indicatorContainer.innerHTML = '';
for (let i = 0; i < pages; i++) {
const dot = document.createElement('button');
dot.className = 'carousel-dot' + (i === 0 ? ' active' : '');
dot.addEventListener('click', () => {
track.scrollTo({ left: (track.scrollWidth / pages) * i, behavior: 'smooth' });
});
indicatorContainer.appendChild(dot);
}
}
function updateIndicators() {
const dots = indicatorContainer.querySelectorAll('.carousel-dot');
if (!dots.length) return;
const ratio = track.scrollLeft / Math.max(1, track.scrollWidth - track.clientWidth);
const idx = Math.round(ratio * (dots.length - 1));
dots.forEach((d, i) => d.classList.toggle('active', i === idx));
}
leftArrow.addEventListener('click', () => {
track.scrollBy({ left: -SCROLL_AMOUNT, behavior: 'smooth' });
});
rightArrow.addEventListener('click', () => {
track.scrollBy({ left: SCROLL_AMOUNT, behavior: 'smooth' });
});
track.addEventListener('scroll', () => {
updateArrows();
updateIndicators();
});
filterBtns.forEach(btn => {
btn.addEventListener('click', () => {
filterBtns.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const filter = btn.dataset.filter;
cards.forEach(card => {
if (filter === 'all' || card.dataset.category === filter) {
card.classList.remove('hidden');
} else {
card.classList.add('hidden');
}
});
track.scrollTo({ left: 0 });
buildIndicators();
updateArrows();
});
});
buildIndicators();
updateArrows();
window.addEventListener('resize', () => { buildIndicators(); updateArrows(); });
})();
// Obfuscated email - assembled at runtime to defeat scrapers
(function() {
const p = ['smittix', 'outlook', 'com'];
const addr = p[0] + '@' + p[1] + '.' + p[2];
const card = document.getElementById('email-card');
const text = document.getElementById('email-text');
const footerLink = document.getElementById('footer-email');
let revealed = false;
card.addEventListener('click', function(e) {
e.preventDefault();
if (!revealed) {
text.textContent = addr;
revealed = true;
} else {
window.location.href = 'mail' + 'to:' + addr;
}
});
footerLink.addEventListener('click', function(e) {
e.preventDefault();
window.location.href = 'mail' + 'to:' + addr;
});
})();
</script>
<script>
// Animated satellite & signal background
(function() {
const canvas = document.getElementById('bg-canvas');
const ctx = canvas.getContext('2d');
let w, h, dpr;
let orbits = [];
let pulses = [];
let particles = [];
let mouse = { x: -1000, y: -1000 };
function resize() {
dpr = Math.min(window.devicePixelRatio || 1, 2);
w = window.innerWidth;
h = document.documentElement.scrollHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
canvas.style.width = w + 'px';
canvas.style.height = h + 'px';
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// Orbital paths with satellites
function createOrbits() {
orbits = [];
const count = Math.max(4, Math.floor(w / 300));
for (let i = 0; i < count; i++) {
const cx = Math.random() * w;
const cy = Math.random() * h;
const rx = 120 + Math.random() * 280;
const ry = 40 + Math.random() * 100;
const tilt = (Math.random() - 0.5) * 1.2;
const speed = (0.0002 + Math.random() * 0.0004) * (Math.random() > 0.5 ? 1 : -1);
const sats = [];
const satCount = 1 + Math.floor(Math.random() * 2);
for (let j = 0; j < satCount; j++) {
sats.push({ angle: Math.random() * Math.PI * 2, pulseTimer: 0 });
}
orbits.push({ cx, cy, rx, ry, tilt, speed, sats });
}
}
// Floating signal particles (tiny dots drifting upward)
function createParticles() {
particles = [];
const count = Math.max(30, Math.floor((w * h) / 25000));
for (let i = 0; i < count; i++) {
particles.push({
x: Math.random() * w,
y: Math.random() * h,
vy: -(0.08 + Math.random() * 0.15),
vx: (Math.random() - 0.5) * 0.1,
size: 0.5 + Math.random() * 1.2,
alpha: 0.1 + Math.random() * 0.25,
flicker: Math.random() * Math.PI * 2,
});
}
}
function spawnPulse(x, y) {
pulses.push({ x, y, r: 2, maxR: 50 + Math.random() * 40, alpha: 0.35 });
}
function drawOrbitPath(orbit) {
ctx.save();
ctx.translate(orbit.cx, orbit.cy);
ctx.rotate(orbit.tilt);
ctx.beginPath();
ctx.ellipse(0, 0, orbit.rx, orbit.ry, 0, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(0, 212, 170, 0.04)';
ctx.lineWidth = 1;
ctx.stroke();
ctx.restore();
}
function drawSatellite(orbit, sat, dt) {
sat.angle += orbit.speed * dt;
const cos = Math.cos(orbit.tilt);
const sin = Math.sin(orbit.tilt);
const ex = orbit.rx * Math.cos(sat.angle);
const ey = orbit.ry * Math.sin(sat.angle);
const sx = orbit.cx + ex * cos - ey * sin;
const sy = orbit.cy + ex * sin + ey * cos;
// Satellite dot
ctx.beginPath();
ctx.arc(sx, sy, 2, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(0, 212, 170, 0.7)';
ctx.fill();
// Faint glow
ctx.beginPath();
ctx.arc(sx, sy, 6, 0, Math.PI * 2);
const g = ctx.createRadialGradient(sx, sy, 0, sx, sy, 6);
g.addColorStop(0, 'rgba(0, 212, 170, 0.15)');
g.addColorStop(1, 'rgba(0, 212, 170, 0)');
ctx.fillStyle = g;
ctx.fill();
// Periodic signal pulse
sat.pulseTimer += dt;
if (sat.pulseTimer > 3000 + Math.random() * 500) {
sat.pulseTimer = 0;
spawnPulse(sx, sy);
}
}
function drawPulses(dt) {
for (let i = pulses.length - 1; i >= 0; i--) {
const p = pulses[i];
p.r += dt * 0.025;
p.alpha = 0.35 * (1 - p.r / p.maxR);
if (p.r >= p.maxR) { pulses.splice(i, 1); continue; }
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.strokeStyle = `rgba(0, 212, 170, ${p.alpha})`;
ctx.lineWidth = 1;
ctx.stroke();
// Second ring
if (p.r > 12) {
ctx.beginPath();
ctx.arc(p.x, p.y, p.r * 0.6, 0, Math.PI * 2);
ctx.strokeStyle = `rgba(0, 136, 255, ${p.alpha * 0.5})`;
ctx.stroke();
}
}
}
function drawParticles(dt, time) {
for (const p of particles) {
p.y += p.vy * dt * 0.06;
p.x += p.vx * dt * 0.06;
p.flicker += dt * 0.002;
if (p.y < -10) { p.y = h + 10; p.x = Math.random() * w; }
if (p.x < -10) p.x = w + 10;
if (p.x > w + 10) p.x = -10;
const flick = p.alpha * (0.6 + 0.4 * Math.sin(p.flicker));
// Mouse interaction - subtle brighten
const dx = p.x - mouse.x;
const dy = p.y - mouse.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const boost = dist < 150 ? 0.3 * (1 - dist / 150) : 0;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fillStyle = `rgba(0, 212, 170, ${Math.min(flick + boost, 0.6)})`;
ctx.fill();
}
}
// Faint grid lines (signal grid)
function drawGrid(time) {
ctx.strokeStyle = 'rgba(0, 212, 170, 0.015)';
ctx.lineWidth = 1;
const spacing = 120;
const offset = (time * 0.005) % spacing;
for (let x = -spacing + offset; x < w + spacing; x += spacing) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, h);
ctx.stroke();
}
for (let y = -spacing + offset * 0.7; y < h + spacing; y += spacing) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(w, y);
ctx.stroke();
}
}
let last = 0;
function animate(now) {
const dt = last ? Math.min(now - last, 50) : 16;
last = now;
ctx.clearRect(0, 0, w, h);
drawGrid(now);
for (const orbit of orbits) {
drawOrbitPath(orbit);
for (const sat of orbit.sats) {
drawSatellite(orbit, sat, dt);
}
}
drawPulses(dt);
drawParticles(dt, now);
requestAnimationFrame(animate);
}
// Track mouse for particle interaction
document.addEventListener('mousemove', (e) => {
mouse.x = e.clientX;
mouse.y = e.clientY + window.scrollY;
});
// Resize handling
let resizeTimer;
function handleResize() {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
resize();
createOrbits();
createParticles();
}, 200);
}
// Keep canvas height synced with document
const ro = new ResizeObserver(() => { handleResize(); });
ro.observe(document.documentElement);
window.addEventListener('resize', handleResize);
resize();
createOrbits();
createParticles();
requestAnimationFrame(animate);
})();
</script>
</body>
</html>
+273 -9
View File
@@ -17,6 +17,22 @@
--gradient-end: #0088ff;
}
/* Animated background canvas */
#bg-canvas {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
pointer-events: none;
}
body > *:not(#bg-canvas) {
position: relative;
z-index: 1;
}
* {
margin: 0;
padding: 0;
@@ -70,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;
@@ -245,18 +276,74 @@ section h2 {
background: var(--bg-secondary);
}
.features-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 24px;
/* Category filter tabs */
.carousel-filters {
display: flex;
justify-content: center;
gap: 8px;
margin-bottom: 40px;
flex-wrap: wrap;
}
.filter-btn {
font-family: 'JetBrains Mono', monospace;
font-size: 0.8rem;
font-weight: 500;
padding: 8px 20px;
border-radius: 20px;
border: 1px solid var(--border);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.25s;
letter-spacing: 0.5px;
}
.filter-btn:hover {
border-color: var(--accent);
color: var(--text-primary);
}
.filter-btn.active {
background: var(--accent);
color: var(--bg-primary);
border-color: var(--accent);
}
/* Carousel */
.carousel-wrapper {
position: relative;
padding: 0 56px;
}
.carousel-track {
display: flex;
gap: 20px;
overflow-x: auto;
scroll-behavior: smooth;
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
padding: 8px 0 16px;
}
.carousel-track::-webkit-scrollbar {
display: none;
}
.feature-card {
flex: 0 0 280px;
scroll-snap-align: start;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 32px 24px;
transition: all 0.3s;
min-height: 200px;
}
.feature-card.hidden {
display: none;
}
.feature-card:hover {
@@ -266,8 +353,15 @@ section h2 {
}
.feature-icon {
font-size: 2rem;
width: 36px;
height: 36px;
margin-bottom: 16px;
color: var(--accent);
}
.feature-icon svg {
width: 100%;
height: 100%;
}
.feature-card h3 {
@@ -283,6 +377,81 @@ section h2 {
line-height: 1.7;
}
/* Carousel arrows */
.carousel-arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 44px;
height: 44px;
border-radius: 50%;
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--text-primary);
font-size: 1.5rem;
cursor: pointer;
transition: all 0.25s;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
line-height: 1;
}
.carousel-arrow:hover {
background: var(--bg-card-hover);
border-color: var(--accent);
color: var(--accent);
}
.carousel-arrow:disabled {
opacity: 0.3;
cursor: default;
}
.carousel-arrow:disabled:hover {
background: var(--bg-card);
border-color: var(--border);
color: var(--text-primary);
}
.carousel-arrow-left {
left: 0;
}
.carousel-arrow-right {
right: 0;
}
/* Carousel indicators */
.carousel-indicators {
display: flex;
justify-content: center;
gap: 8px;
margin-top: 28px;
}
.carousel-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--border);
border: none;
cursor: pointer;
transition: all 0.25s;
padding: 0;
}
.carousel-dot.active {
background: var(--accent);
width: 24px;
border-radius: 4px;
}
.carousel-dot:hover {
background: var(--text-muted);
}
/* Screenshots */
.screenshot-gallery {
display: grid;
@@ -550,6 +719,72 @@ section h2 {
gap: 16px;
}
/* Support & Contact */
.support {
background: var(--bg-secondary);
}
.support-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 24px;
}
.support-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 32px 24px;
text-align: center;
text-decoration: none;
transition: all 0.3s;
display: block;
}
.support-card:hover {
background: var(--bg-card-hover);
border-color: var(--accent);
transform: translateY(-4px);
}
.support-card.support-coffee {
border-color: rgba(255, 193, 59, 0.3);
}
.support-card.support-coffee:hover {
border-color: #ffc13b;
box-shadow: 0 8px 30px rgba(255, 193, 59, 0.1);
}
.support-card.support-coffee .support-icon {
color: #ffc13b;
}
.support-icon {
width: 36px;
height: 36px;
margin: 0 auto 16px;
color: var(--accent);
}
.support-icon svg {
width: 100%;
height: 100%;
}
.support-card h3 {
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 8px;
color: var(--text-primary);
}
.support-card p {
font-size: 0.85rem;
color: var(--text-secondary);
line-height: 1.6;
}
/* Footer */
.footer {
background: var(--bg-secondary);
@@ -641,14 +876,22 @@ section h2 {
margin: 0 auto;
}
.features-grid {
grid-template-columns: repeat(2, 1fr);
.carousel-wrapper {
padding: 0 48px;
}
.feature-card {
flex: 0 0 260px;
}
.screenshot-gallery {
grid-template-columns: repeat(2, 1fr);
}
.support-grid {
grid-template-columns: repeat(2, 1fr);
}
.install-options {
grid-template-columns: 1fr;
}
@@ -669,14 +912,35 @@ section h2 {
gap: 24px;
}
.features-grid {
grid-template-columns: 1fr;
.carousel-wrapper {
padding: 0 4px;
}
.carousel-arrow {
display: none;
}
.feature-card {
flex: 0 0 260px;
}
.carousel-filters {
gap: 6px;
}
.filter-btn {
font-size: 0.7rem;
padding: 6px 14px;
}
.screenshot-gallery {
grid-template-columns: 1fr;
}
.support-grid {
grid-template-columns: 1fr;
}
.nav-links {
display: none;
}
+30 -30
View File
@@ -1,30 +1,30 @@
#!/usr/bin/env bash
# Download sample NOAA APT recordings for testing the weather satellite
# test-decode feature. These are FM-demodulated audio WAV files.
#
# Usage:
# ./download-weather-sat-samples.sh
# docker exec intercept /app/download-weather-sat-samples.sh
set -euo pipefail
SAMPLE_DIR="$(dirname "$0")/data/weather_sat/samples"
mkdir -p "$SAMPLE_DIR"
echo "Downloading NOAA APT sample files to $SAMPLE_DIR ..."
# Full satellite pass recorded over Argentina (NOAA, 11025 Hz mono WAV)
# Source: https://github.com/martinber/noaa-apt
if [ ! -f "$SAMPLE_DIR/noaa_apt_argentina.wav" ]; then
echo " -> noaa_apt_argentina.wav (18 MB) ..."
curl -fSL -o "$SAMPLE_DIR/noaa_apt_argentina.wav" \
"https://noaa-apt.mbernardi.com.ar/examples/argentina.wav"
else
echo " -> noaa_apt_argentina.wav (already exists)"
fi
echo ""
echo "Done. Test decode with:"
echo " Satellite: NOAA-18"
echo " File path: data/weather_sat/samples/noaa_apt_argentina.wav"
echo " Sample rate: 11025 Hz"
#!/usr/bin/env bash
# Download sample NOAA APT recordings for testing the weather satellite
# test-decode feature. These are FM-demodulated audio WAV files.
#
# Usage:
# ./download-weather-sat-samples.sh
# docker exec intercept /app/download-weather-sat-samples.sh
set -euo pipefail
SAMPLE_DIR="$(dirname "$0")/data/weather_sat/samples"
mkdir -p "$SAMPLE_DIR"
echo "Downloading NOAA APT sample files to $SAMPLE_DIR ..."
# Full satellite pass recorded over Argentina (NOAA, 11025 Hz mono WAV)
# Source: https://github.com/martinber/noaa-apt
if [ ! -f "$SAMPLE_DIR/noaa_apt_argentina.wav" ]; then
echo " -> noaa_apt_argentina.wav (18 MB) ..."
curl -fSL -o "$SAMPLE_DIR/noaa_apt_argentina.wav" \
"https://noaa-apt.mbernardi.com.ar/examples/argentina.wav"
else
echo " -> noaa_apt_argentina.wav (already exists)"
fi
echo ""
echo "Done. Test decode with:"
echo " Satellite: NOAA-18"
echo " File path: data/weather_sat/samples/noaa_apt_argentina.wav"
echo " Sample rate: 11025 Hz"
+64
View File
@@ -0,0 +1,64 @@
"""Gunicorn configuration for INTERCEPT."""
import contextlib
import warnings
warnings.filterwarnings(
'ignore',
message='Patching more than once',
category=DeprecationWarning,
)
def post_fork(server, worker):
"""Apply gevent monkey-patching immediately after fork.
Gunicorn's built-in gevent worker is supposed to handle this, but on
some platforms (notably Raspberry Pi / ARM) the worker deadlocks during
its own init_process() before it gets to patch. Doing it here — right
after fork, before any worker initialisation — avoids the race.
Gunicorn's gevent worker will call patch_all() again in init_process();
the duplicate call is harmless (gevent unions the flags) and the
MonkeyPatchWarning is suppressed above.
"""
try:
from gevent import monkey
monkey.patch_all()
except Exception:
pass
# Silence the spurious AssertionError in gevent's fork hooks that fires
# when subprocesses fork after a double monkey-patch.
try:
from gevent.threading import _ForkHooks
_orig = _ForkHooks.after_fork_in_child
def _safe_after_fork(self):
with contextlib.suppress(AssertionError):
_orig(self)
_ForkHooks.after_fork_in_child = _safe_after_fork
except Exception:
pass
def post_worker_init(worker):
"""Suppress noisy SystemExit tracebacks during gevent worker shutdown.
When gunicorn receives SIGINT, the gevent worker's handle_quit()
calls sys.exit(0) inside a greenlet. Gevent treats SystemExit as
an error by default and prints a traceback. Adding it to NOT_ERROR
silences this harmless noise.
"""
try:
import ssl
from gevent import get_hub
hub = get_hub()
suppress = (SystemExit, ssl.SSLZeroReturnError, ssl.SSLError)
for exc in suppress:
if exc not in hub.NOT_ERROR:
hub.NOT_ERROR = hub.NOT_ERROR + (exc,)
except Exception:
pass
-8
View File
@@ -16,14 +16,6 @@ Requires RTL-SDR hardware for RF modes.
import sys
# Check Python version early, before imports that use 3.9+ syntax
if sys.version_info < (3, 9):
print(f"Error: Python 3.9 or higher is required.")
print(f"You are running Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}")
print("\nTo fix this:")
print(" - On Ubuntu/Debian: sudo apt install python3.9 (or newer)")
print(" - On macOS: brew install python@3.11")
print(" - Or use pyenv to install a newer version")
sys.exit(1)
# Handle --version early before other imports
if '--version' in sys.argv or '-V' in sys.argv:
+48 -66
View File
@@ -13,6 +13,7 @@ from __future__ import annotations
import argparse
import configparser
import contextlib
import json
import logging
import os
@@ -26,25 +27,24 @@ import sys
import threading
import time
from datetime import datetime, timezone
from http.server import HTTPServer, BaseHTTPRequestHandler
from http.server import BaseHTTPRequestHandler, HTTPServer
from socketserver import ThreadingMixIn
from typing import Any
from urllib.parse import urlparse, parse_qs
from urllib.parse import parse_qs, urlparse
# Add parent directory to path for imports
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
# Import dependency checking from Intercept utils
try:
from utils.dependencies import check_all_dependencies, check_tool, TOOL_DEPENDENCIES
from utils.dependencies import TOOL_DEPENDENCIES, check_all_dependencies, check_tool
HAS_DEPENDENCIES_MODULE = True
except ImportError:
HAS_DEPENDENCIES_MODULE = False
# Import TSCM modules for consistent analysis (same as local mode)
try:
from utils.tscm.detector import ThreatDetector
from utils.tscm.correlation import CorrelationEngine
from utils.tscm.detector import ThreatDetector
HAS_TSCM_MODULES = True
except ImportError:
HAS_TSCM_MODULES = False
@@ -53,7 +53,7 @@ except ImportError:
# Import database functions for baseline support (same as local mode)
try:
from utils.database import get_tscm_baseline, get_active_tscm_baseline
from utils.database import get_active_tscm_baseline, get_tscm_baseline
HAS_BASELINE_DB = True
except ImportError:
HAS_BASELINE_DB = False
@@ -143,7 +143,7 @@ class AgentConfig:
# Modes section
if parser.has_section('modes'):
for mode in self.modes_enabled.keys():
for mode in self.modes_enabled:
if parser.has_option('modes', mode):
self.modes_enabled[mode] = parser.getboolean('modes', mode)
@@ -310,10 +310,8 @@ class ControllerPushClient(threading.Thread):
except Exception as e:
item['attempts'] += 1
if item['attempts'] < 3 and not self.stop_event.is_set():
try:
with contextlib.suppress(queue.Full):
self.queue.put_nowait(item)
except queue.Full:
pass
else:
logger.warning(f"Failed to push after {item['attempts']} attempts: {e}")
finally:
@@ -795,9 +793,7 @@ class ModeManager:
info['vessel_count'] = len(getattr(self, 'ais_vessels', {}))
elif mode == 'aprs':
info['station_count'] = len(getattr(self, 'aprs_stations', {}))
elif mode == 'pager':
info['message_count'] = len(self.data_snapshots.get(mode, []))
elif mode == 'acars':
elif mode == 'pager' or mode == 'acars':
info['message_count'] = len(self.data_snapshots.get(mode, []))
elif mode == 'rtlamr':
info['reading_count'] = len(self.data_snapshots.get(mode, []))
@@ -1073,10 +1069,8 @@ class ModeManager:
proc.wait(timeout=2)
except subprocess.TimeoutExpired:
proc.kill()
try:
with contextlib.suppress(Exception):
proc.wait(timeout=1)
except Exception:
pass
except (OSError, ProcessLookupError) as e:
# Process already dead or inaccessible
logger.debug(f"Process cleanup for {mode}: {e}")
@@ -1297,10 +1291,8 @@ class ModeManager:
except Exception as e:
logger.error(f"Sensor output reader error: {e}")
finally:
try:
with contextlib.suppress(Exception):
proc.wait(timeout=1)
except Exception:
pass
logger.info("Sensor output reader stopped")
# -------------------------------------------------------------------------
@@ -1661,16 +1653,14 @@ class ModeManager:
try:
from utils.validation import validate_network_interface
interface = validate_network_interface(interface)
except (ImportError, ValueError) as e:
except (ImportError, ValueError):
if not os.path.exists(f'/sys/class/net/{interface}'):
return {'status': 'error', 'message': f'Interface {interface} not found'}
csv_path = '/tmp/intercept_agent_wifi'
for f in [f'{csv_path}-01.csv', f'{csv_path}-01.cap', f'{csv_path}-01.gps']:
try:
with contextlib.suppress(OSError):
os.remove(f)
except OSError:
pass
airodump_path = self._get_tool_path('airodump-ng')
if not airodump_path:
@@ -1931,7 +1921,7 @@ class ModeManager:
logger.warning("Intercept WiFi parser not available, using fallback")
# Fallback: simple parsing if running standalone
try:
with open(csv_path, 'r', errors='replace') as f:
with open(csv_path, errors='replace') as f:
content = f.read()
for section in content.split('\n\n'):
lines = section.strip().split('\n')
@@ -2303,10 +2293,8 @@ class ModeManager:
except Exception as e:
logger.error(f"Pager reader error: {e}")
finally:
try:
with contextlib.suppress(Exception):
proc.wait(timeout=1)
except Exception:
pass
if 'pager_rtl' in self.processes:
try:
rtl_proc = self.processes['pager_rtl']
@@ -2491,7 +2479,7 @@ class ModeManager:
sock.close()
except Exception as e:
except Exception:
retry_count += 1
if retry_count >= 10:
logger.error("Max AIS retries reached")
@@ -2701,10 +2689,8 @@ class ModeManager:
except Exception as e:
logger.error(f"ACARS reader error: {e}")
finally:
try:
with contextlib.suppress(Exception):
proc.wait(timeout=1)
except Exception:
pass
logger.info("ACARS reader stopped")
# -------------------------------------------------------------------------
@@ -2846,10 +2832,8 @@ class ModeManager:
except Exception as e:
logger.error(f"APRS reader error: {e}")
finally:
try:
with contextlib.suppress(Exception):
proc.wait(timeout=1)
except Exception:
pass
if 'aprs_rtl' in self.processes:
try:
rtl_proc = self.processes['aprs_rtl']
@@ -2860,11 +2844,22 @@ class ModeManager:
pass
logger.info("APRS reader stopped")
def _parse_aprs_packet(self, line: str) -> dict | None:
"""Parse APRS packet from direwolf or multimon-ng."""
match = re.match(r'([A-Z0-9-]+)>([^:]+):(.+)', line)
if not match:
return None
def _parse_aprs_packet(self, line: str) -> dict | None:
"""Parse APRS packet from direwolf or multimon-ng."""
if not line:
return None
# Normalize common decoder prefixes before parsing.
# multimon-ng: "AFSK1200: ..."
# direwolf: "[0.4] ...", "[0L] ..."
line = line.strip()
if line.startswith('AFSK1200:'):
line = line[9:].strip()
line = re.sub(r'^(?:\[[^\]]+\]\s*)+', '', line)
match = re.match(r'([A-Z0-9-]+)>([^:]+):(.+)', line)
if not match:
return None
callsign = match.group(1)
path = match.group(2)
@@ -3010,10 +3005,8 @@ class ModeManager:
except Exception as e:
logger.error(f"RTLAMR reader error: {e}")
finally:
try:
with contextlib.suppress(Exception):
proc.wait(timeout=1)
except Exception:
pass
if 'rtlamr_tcp' in self.processes:
try:
tcp_proc = self.processes['rtlamr_tcp']
@@ -3131,10 +3124,8 @@ class ModeManager:
except Exception as e:
logger.error(f"DSC reader error: {e}")
finally:
try:
with contextlib.suppress(Exception):
proc.wait(timeout=1)
except Exception:
pass
logger.info("DSC reader stopped")
# -------------------------------------------------------------------------
@@ -3208,13 +3199,13 @@ class ModeManager:
stop_event = self.stop_events.get(mode)
# Import existing Intercept TSCM functions
from routes.tscm import _scan_wifi_networks, _scan_wifi_clients, _scan_bluetooth_devices, _scan_rf_signals
from routes.tscm import _scan_bluetooth_devices, _scan_rf_signals, _scan_wifi_clients, _scan_wifi_networks
logger.info("TSCM imports successful")
sweep_ranges = None
if sweep_type:
try:
from data.tscm_frequencies import get_sweep_preset, SWEEP_PRESETS
from data.tscm_frequencies import SWEEP_PRESETS, get_sweep_preset
preset = get_sweep_preset(sweep_type) or SWEEP_PRESETS.get('standard')
sweep_ranges = preset.get('ranges') if preset else None
except Exception:
@@ -3401,7 +3392,8 @@ class ModeManager:
if scan_rf and (current_time - last_rf_scan) >= rf_scan_interval:
try:
# Pass a stop check that uses our stop_event (not the module's _sweep_running)
agent_stop_check = lambda: stop_event and stop_event.is_set()
def agent_stop_check():
return stop_event and stop_event.is_set()
rf_signals = _scan_rf_signals(
sdr_device,
stop_check=agent_stop_check,
@@ -3599,10 +3591,8 @@ class ModeManager:
# Ensure test process is killed on any error
if test_proc and test_proc.poll() is None:
test_proc.kill()
try:
with contextlib.suppress(Exception):
test_proc.wait(timeout=1)
except Exception:
pass
return {'status': 'error', 'message': f'SDR check failed: {str(e)}'}
# Initialize state
@@ -3636,9 +3626,9 @@ class ModeManager:
step: float, modulation: str, squelch: int,
device: str, gain: str, dwell_time: float = 1.0):
"""Scan frequency range and report signal detections."""
import select
import os
import fcntl
import os
import select
mode = 'listening_post'
stop_event = self.stop_events.get(mode)
@@ -3698,7 +3688,7 @@ class ModeManager:
signal_detected = True
except Exception:
pass
except (IOError, BlockingIOError):
except (OSError, BlockingIOError):
pass
proc.terminate()
@@ -4120,27 +4110,19 @@ def main():
# Stop push services
if data_push_loop:
try:
with contextlib.suppress(Exception):
data_push_loop.stop()
except Exception:
pass
if push_client:
try:
with contextlib.suppress(Exception):
push_client.stop()
except Exception:
pass
# Stop GPS
try:
with contextlib.suppress(Exception):
gps_manager.stop()
except Exception:
pass
# Shutdown HTTP server
try:
with contextlib.suppress(Exception):
httpd.shutdown()
except Exception:
pass
# Run cleanup in background thread so signal handler returns quickly
cleanup_thread = threading.Thread(target=cleanup, daemon=True)
+25 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "intercept"
version = "2.18.0"
version = "2.26.5"
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"]
+9
View File
@@ -1,5 +1,7 @@
# Core dependencies
flask>=3.0.0
flask-wtf>=1.2.0
flask-compress>=1.15
flask-limiter>=2.5.4
requests>=2.28.0
Werkzeug>=3.1.5
@@ -44,3 +46,10 @@ cryptography>=41.0.0
# WebSocket support for in-app audio streaming (KiwiSDR, Listening Post)
flask-sock
websocket-client>=1.6.0
# System health monitoring (optional - graceful fallback if unavailable)
psutil>=5.9.0
# Production WSGI server (optional - falls back to Flask dev server)
gunicorn>=21.2.0
gevent>=23.9.0
+57 -30
View File
@@ -1,39 +1,53 @@
# Routes package - registers all blueprints with the Flask app
def register_blueprints(app):
"""Register all route blueprints with the Flask app."""
from .pager import pager_bp
from .sensor import sensor_bp
from .rtlamr import rtlamr_bp
from .wifi import wifi_bp
from .wifi_v2 import wifi_v2_bp
from .bluetooth import bluetooth_bp
from .bluetooth_v2 import bluetooth_v2_bp
# Import CSRF to exempt API blueprints (they use JSON, not form tokens)
try:
from app import csrf as _csrf
except ImportError:
_csrf = None
from .acars import acars_bp
from .adsb import adsb_bp
from .ais import ais_bp
from .dsc import dsc_bp
from .acars import acars_bp
from .aprs import aprs_bp
from .satellite import satellite_bp
from .gps import gps_bp
from .settings import settings_bp
from .correlation import correlation_bp
from .listening_post import listening_post_bp
from .meshtastic import meshtastic_bp
from .tscm import tscm_bp, init_tscm_state
from .spy_stations import spy_stations_bp
from .controller import controller_bp
from .offline import offline_bp
from .updater import updater_bp
from .sstv import sstv_bp
from .weather_sat import weather_sat_bp
from .sstv_general import sstv_general_bp
from .dmr import dmr_bp
from .websdr import websdr_bp
from .alerts import alerts_bp
from .recordings import recordings_bp
from .subghz import subghz_bp
from .aprs import aprs_bp
from .bluetooth import bluetooth_bp
from .bluetooth_v2 import bluetooth_v2_bp
from .bt_locate import bt_locate_bp
from .controller import controller_bp
from .correlation import correlation_bp
from .dsc import dsc_bp
from .gps import gps_bp
from .listening_post import receiver_bp
from .meshtastic import meshtastic_bp
from .meteor_websocket import meteor_bp
from .morse import morse_bp
from .offline import offline_bp
from .ook import ook_bp
from .pager import pager_bp
from .radiosonde import radiosonde_bp
from .recordings import recordings_bp
from .rtlamr import rtlamr_bp
from .satellite import satellite_bp
from .sensor import sensor_bp
from .settings import settings_bp
from .signalid import signalid_bp
from .space_weather import space_weather_bp
from .spy_stations import spy_stations_bp
from .sstv import sstv_bp
from .sstv_general import sstv_general_bp
from .subghz import subghz_bp
from .system import system_bp
from .tscm import init_tscm_state, tscm_bp
from .updater import updater_bp
from .vdl2 import vdl2_bp
from .weather_sat import weather_sat_bp
from .websdr import websdr_bp
from .wefax import wefax_bp
from .wifi import wifi_bp
from .wifi_v2 import wifi_v2_bp
app.register_blueprint(pager_bp)
app.register_blueprint(sensor_bp)
@@ -46,12 +60,13 @@ def register_blueprints(app):
app.register_blueprint(ais_bp)
app.register_blueprint(dsc_bp) # VHF DSC maritime distress
app.register_blueprint(acars_bp)
app.register_blueprint(vdl2_bp)
app.register_blueprint(aprs_bp)
app.register_blueprint(satellite_bp)
app.register_blueprint(gps_bp)
app.register_blueprint(settings_bp)
app.register_blueprint(correlation_bp)
app.register_blueprint(listening_post_bp)
app.register_blueprint(receiver_bp)
app.register_blueprint(meshtastic_bp)
app.register_blueprint(tscm_bp)
app.register_blueprint(spy_stations_bp)
@@ -61,12 +76,24 @@ def register_blueprints(app):
app.register_blueprint(sstv_bp) # ISS SSTV decoder
app.register_blueprint(weather_sat_bp) # NOAA/Meteor weather satellite decoder
app.register_blueprint(sstv_general_bp) # General terrestrial SSTV
app.register_blueprint(dmr_bp) # DMR / P25 / Digital Voice
app.register_blueprint(websdr_bp) # HF/Shortwave WebSDR
app.register_blueprint(alerts_bp) # Cross-mode alerts
app.register_blueprint(recordings_bp) # Session recordings
app.register_blueprint(subghz_bp) # SubGHz transceiver (HackRF)
app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking
app.register_blueprint(space_weather_bp) # Space weather monitoring
app.register_blueprint(signalid_bp) # External signal ID enrichment
app.register_blueprint(wefax_bp) # WeFax HF weather fax decoder
app.register_blueprint(meteor_bp) # Meteor scatter detection
app.register_blueprint(morse_bp) # CW/Morse code decoder
app.register_blueprint(radiosonde_bp) # Radiosonde weather balloon tracking
app.register_blueprint(system_bp) # System health monitoring
app.register_blueprint(ook_bp) # Generic OOK signal decoder
# Exempt all API blueprints from CSRF (they use JSON, not form tokens)
if _csrf:
for bp in app.blueprints.values():
_csrf.exempt(bp)
# Initialize TSCM state with queue and lock from app
import app as app_module
+93 -70
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
import io
import contextlib
import json
import os
import platform
@@ -13,33 +13,36 @@ import subprocess
import threading
import time
from datetime import datetime
from typing import Generator
from typing import Any
from flask import Blueprint, jsonify, request, Response
from flask import Blueprint, Response, jsonify, request
import app as app_module
from utils.logging import sensor_logger as logger
from utils.validation import validate_device_index, validate_gain, validate_ppm
from utils.sdr import SDRFactory, SDRType
from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.acars_translator import translate_message
from utils.constants import (
PROCESS_START_WAIT,
PROCESS_TERMINATE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT,
PROCESS_START_WAIT,
)
from utils.event_pipeline import process_event
from utils.flight_correlator import get_flight_correlator
from utils.logging import sensor_logger as logger
from utils.process import register_process, unregister_process
from utils.responses import api_error
from utils.sdr import SDRFactory, SDRType
from utils.sse import sse_stream_fanout
from utils.validation import validate_device_index, validate_gain, validate_ppm
acars_bp = Blueprint('acars', __name__, url_prefix='/acars')
# Default VHF ACARS frequencies (MHz) - common worldwide
# Default VHF ACARS frequencies (MHz) - North America primary
DEFAULT_ACARS_FREQUENCIES = [
'131.550', # Primary worldwide
'130.025', # Secondary USA/Canada
'129.125', # USA
'131.525', # Europe
'131.725', # Europe secondary
'131.550', # Primary worldwide / North America
'130.025', # North America secondary
'129.125', # North America tertiary
'131.725', # North America (major US carriers)
'131.825', # North America (major US carriers)
]
# Message counter for statistics
@@ -48,6 +51,7 @@ acars_last_message_time = None
# Track which device is being used
acars_active_device: int | None = None
acars_active_sdr_type: str | None = None
def find_acarsdec():
@@ -123,12 +127,25 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
data['type'] = 'acars'
data['timestamp'] = datetime.utcnow().isoformat() + 'Z'
# Enrich with translated label and parsed fields
try:
translation = translate_message(data)
data['label_description'] = translation['label_description']
data['message_type'] = translation['message_type']
data['parsed'] = translation['parsed']
except Exception:
pass
# Update stats
acars_message_count += 1
acars_last_message_time = time.time()
app_module.acars_queue.put(data)
# Feed flight correlator
with contextlib.suppress(Exception):
get_flight_correlator().add_acars_message(data)
# Log if enabled
if app_module.logging_enabled:
try:
@@ -147,24 +164,23 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
logger.error(f"ACARS stream error: {e}")
app_module.acars_queue.put({'type': 'error', 'message': str(e)})
finally:
global acars_active_device
global acars_active_device, acars_active_sdr_type
# Ensure process is terminated
try:
process.terminate()
process.wait(timeout=2)
except Exception:
try:
with contextlib.suppress(Exception):
process.kill()
except Exception:
pass
unregister_process(process)
app_module.acars_queue.put({'type': 'status', 'status': 'stopped'})
with app_module.acars_lock:
app_module.acars_process = None
# Release SDR device
if acars_active_device is not None:
app_module.release_sdr_device(acars_active_device)
app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr')
acars_active_device = None
acars_active_sdr_type = None
@acars_bp.route('/tools')
@@ -196,22 +212,16 @@ def acars_status() -> Response:
@acars_bp.route('/start', methods=['POST'])
def start_acars() -> Response:
"""Start ACARS decoder."""
global acars_message_count, acars_last_message_time, acars_active_device
global acars_message_count, acars_last_message_time, acars_active_device, acars_active_sdr_type
with app_module.acars_lock:
if app_module.acars_process and app_module.acars_process.poll() is None:
return jsonify({
'status': 'error',
'message': 'ACARS decoder already running'
}), 409
return api_error('ACARS decoder already running', 409)
# Check for acarsdec
acarsdec_path = find_acarsdec()
if not acarsdec_path:
return jsonify({
'status': 'error',
'message': 'acarsdec not found. Install with: sudo apt install acarsdec'
}), 400
return api_error('acarsdec not found. Install with: sudo apt install acarsdec', 400)
data = request.json or {}
@@ -221,19 +231,19 @@ def start_acars() -> Response:
gain = validate_gain(data.get('gain', '40'))
ppm = validate_ppm(data.get('ppm', '0'))
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
return api_error(str(e), 400)
# Resolve SDR type for device selection
sdr_type_str = data.get('sdr_type', 'rtlsdr')
# Check if device is available
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'acars')
error = app_module.claim_sdr_device(device_int, 'acars', sdr_type_str)
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
return api_error(error, 409, error_type='DEVICE_BUSY')
acars_active_device = device_int
acars_active_sdr_type = sdr_type_str
# Get frequencies - use provided or defaults
frequencies = data.get('frequencies', DEFAULT_ACARS_FREQUENCIES)
@@ -251,8 +261,6 @@ def start_acars() -> Response:
acars_message_count = 0
acars_last_message_time = None
# Resolve SDR type for device selection
sdr_type_str = data.get('sdr_type', 'rtlsdr')
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
@@ -323,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(
@@ -339,16 +347,19 @@ def start_acars() -> Response:
if process.poll() is not None:
# Process died - release device
if acars_active_device is not None:
app_module.release_sdr_device(acars_active_device)
app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr')
acars_active_device = None
acars_active_sdr_type = None
stderr = ''
if process.stderr:
stderr = process.stderr.read().decode('utf-8', errors='replace')
error_msg = f'acarsdec failed to start'
if stderr:
error_msg += f': {stderr[:200]}'
logger.error(f"acarsdec stderr:\n{stderr}")
error_msg = 'acarsdec failed to start'
if stderr:
error_msg += f': {stderr[:500]}'
logger.error(error_msg)
return jsonify({'status': 'error', 'message': error_msg}), 500
return api_error(error_msg, 500)
app_module.acars_process = process
register_process(process)
@@ -371,23 +382,21 @@ def start_acars() -> Response:
except Exception as e:
# Release device on failure
if acars_active_device is not None:
app_module.release_sdr_device(acars_active_device)
app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr')
acars_active_device = None
acars_active_sdr_type = None
logger.error(f"Failed to start ACARS decoder: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500
return api_error(str(e), 500)
@acars_bp.route('/stop', methods=['POST'])
def stop_acars() -> Response:
"""Stop ACARS decoder."""
global acars_active_device
global acars_active_device, acars_active_sdr_type
with app_module.acars_lock:
if not app_module.acars_process:
return jsonify({
'status': 'error',
'message': 'ACARS decoder not running'
}), 400
return api_error('ACARS decoder not running', 400)
try:
app_module.acars_process.terminate()
@@ -401,8 +410,9 @@ def stop_acars() -> Response:
# Release device from registry
if acars_active_device is not None:
app_module.release_sdr_device(acars_active_device)
app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr')
acars_active_device = None
acars_active_sdr_type = None
return jsonify({'status': 'stopped'})
@@ -410,37 +420,50 @@ def stop_acars() -> Response:
@acars_bp.route('/stream')
def stream_acars() -> Response:
"""SSE stream for ACARS messages."""
def generate() -> Generator[str, None, None]:
last_keepalive = time.time()
def _on_msg(msg: dict[str, Any]) -> None:
process_event('acars', msg, msg.get('type'))
while True:
try:
msg = app_module.acars_queue.get(timeout=SSE_QUEUE_TIMEOUT)
last_keepalive = time.time()
try:
process_event('acars', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg)
except queue.Empty:
now = time.time()
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream')
response = Response(
sse_stream_fanout(
source_queue=app_module.acars_queue,
channel_key='acars',
timeout=SSE_QUEUE_TIMEOUT,
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
on_message=_on_msg,
),
mimetype='text/event-stream',
)
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
return response
@acars_bp.route('/messages')
def get_acars_messages() -> Response:
"""Get recent ACARS messages from correlator (for history reload)."""
limit = request.args.get('limit', 50, type=int)
limit = max(1, min(limit, 200))
msgs = get_flight_correlator().get_recent_messages('acars', limit)
return jsonify(msgs)
@acars_bp.route('/clear', methods=['POST'])
def clear_acars_messages() -> Response:
"""Clear stored ACARS messages and reset counter."""
global acars_message_count, acars_last_message_time
get_flight_correlator().clear_acars()
acars_message_count = 0
acars_last_message_time = None
return jsonify({'status': 'cleared'})
@acars_bp.route('/frequencies')
def get_frequencies() -> Response:
"""Get default ACARS frequencies."""
return jsonify({
'default': DEFAULT_ACARS_FREQUENCIES,
'regions': {
'north_america': ['129.125', '130.025', '130.450', '131.550'],
'north_america': ['131.550', '130.025', '129.125', '131.725', '131.825'],
'europe': ['131.525', '131.725', '131.550'],
'asia_pacific': ['131.550', '131.450'],
}
+755 -160
View File
File diff suppressed because it is too large Load Diff
+92 -57
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import contextlib
import json
import os
import queue
@@ -10,29 +11,28 @@ import socket
import subprocess
import threading
import time
from typing import Generator
from flask import Blueprint, jsonify, request, Response, render_template
from flask import Blueprint, Response, jsonify, render_template, request
import app as app_module
from config import SHARED_OBSERVER_LOCATION_ENABLED
from utils.logging import get_logger
from utils.validation import validate_device_index, validate_gain
from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.sdr import SDRFactory, SDRType
from utils.constants import (
AIS_RECONNECT_DELAY,
AIS_SOCKET_TIMEOUT,
AIS_TCP_PORT,
AIS_TERMINATE_TIMEOUT,
AIS_SOCKET_TIMEOUT,
AIS_RECONNECT_DELAY,
AIS_UPDATE_INTERVAL,
PROCESS_TERMINATE_TIMEOUT,
SOCKET_BUFFER_SIZE,
SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT,
SOCKET_CONNECT_TIMEOUT,
PROCESS_TERMINATE_TIMEOUT,
)
from utils.event_pipeline import process_event
from utils.logging import get_logger
from utils.responses import api_error, api_success
from utils.sdr import SDRFactory, SDRType
from utils.sse import sse_stream_fanout
from utils.validation import validate_device_index, validate_gain
logger = get_logger('intercept.ais')
@@ -44,6 +44,7 @@ ais_connected = False
ais_messages_received = 0
ais_last_message_time = None
ais_active_device = None
ais_active_sdr_type: str | None = None
_ais_error_logged = True
# Common installation paths for AIS-catcher
@@ -79,6 +80,7 @@ def parse_ais_stream(port: int):
_ais_error_logged = True
while ais_running:
sock = None
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(AIS_SOCKET_TIMEOUT)
@@ -124,20 +126,31 @@ def parse_ais_stream(port: int):
if now - last_update >= AIS_UPDATE_INTERVAL:
for mmsi in pending_updates:
if mmsi in app_module.ais_vessels:
try:
_vessel_snap = app_module.ais_vessels[mmsi]
with contextlib.suppress(queue.Full):
app_module.ais_queue.put_nowait({
'type': 'vessel',
**app_module.ais_vessels[mmsi]
**_vessel_snap
})
except queue.Full:
pass
# Geofence check
_v_lat = _vessel_snap.get('lat')
_v_lon = _vessel_snap.get('lon')
if _v_lat and _v_lon:
try:
from utils.geofence import get_geofence_manager
for _gf_evt in get_geofence_manager().check_position(
mmsi, 'vessel', _v_lat, _v_lon,
{'name': _vessel_snap.get('name'), 'ship_type': _vessel_snap.get('ship_type_text')}
):
process_event('ais', _gf_evt, 'geofence')
except Exception:
pass
pending_updates.clear()
last_update = now
except socket.timeout:
continue
sock.close()
ais_connected = False
except OSError as e:
ais_connected = False
@@ -145,6 +158,10 @@ def parse_ais_stream(port: int):
logger.warning(f"AIS connection error: {e}, reconnecting...")
_ais_error_logged = True
time.sleep(AIS_RECONNECT_DELAY)
finally:
if sock:
with contextlib.suppress(OSError):
sock.close()
ais_connected = False
logger.info("AIS stream parser stopped")
@@ -282,6 +299,16 @@ def process_ais_message(msg: dict) -> dict | None:
# Timestamp
vessel['last_seen'] = time.time()
# Check for DSC DISTRESS matching this MMSI
try:
for _dsc_key, _dsc_msg in app_module.dsc_messages.items():
if (str(_dsc_msg.get('source_mmsi', '')) == mmsi
and _dsc_msg.get('category', '').upper() == 'DISTRESS'):
vessel['dsc_distress'] = True
break
except Exception:
pass
return vessel
@@ -326,11 +353,11 @@ def ais_status():
@ais_bp.route('/start', methods=['POST'])
def start_ais():
"""Start AIS tracking."""
global ais_running, ais_active_device
global ais_running, ais_active_device, ais_active_sdr_type
with app_module.ais_lock:
if ais_running:
return jsonify({'status': 'already_running', 'message': 'AIS tracking already active'}), 409
return api_error('AIS tracking already active', 409)
data = request.json or {}
@@ -339,15 +366,12 @@ def start_ais():
gain = int(validate_gain(data.get('gain', '40')))
device = validate_device_index(data.get('device', '0'))
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
return api_error(str(e), 400)
# Find AIS-catcher
ais_catcher_path = find_ais_catcher()
if not ais_catcher_path:
return jsonify({
'status': 'error',
'message': 'AIS-catcher not found. Install from https://github.com/jvde-github/AIS-catcher/releases'
}), 400
return api_error('AIS-catcher not found. Install from https://github.com/jvde-github/AIS-catcher/releases', 400)
# Get SDR type from request
sdr_type_str = data.get('sdr_type', 'rtlsdr')
@@ -373,13 +397,9 @@ def start_ais():
# Check if device is available
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'ais')
error = app_module.claim_sdr_device(device_int, 'ais', sdr_type_str)
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
return api_error(error, 409, error_type='DEVICE_BUSY')
# Build command using SDR abstraction
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
@@ -412,20 +432,21 @@ def start_ais():
if app_module.ais_process.poll() is not None:
# Release device on failure
app_module.release_sdr_device(device_int)
app_module.release_sdr_device(device_int, sdr_type_str)
stderr_output = ''
if app_module.ais_process.stderr:
try:
with contextlib.suppress(Exception):
stderr_output = app_module.ais_process.stderr.read().decode('utf-8', errors='ignore').strip()
except Exception:
pass
if stderr_output:
logger.error(f"AIS-catcher stderr:\n{stderr_output}")
error_msg = 'AIS-catcher failed to start. Check SDR device connection.'
if stderr_output:
error_msg += f' Error: {stderr_output[:200]}'
return jsonify({'status': 'error', 'message': error_msg}), 500
error_msg += f' Error: {stderr_output[:500]}'
return api_error(error_msg, 500)
ais_running = True
ais_active_device = device
ais_active_sdr_type = sdr_type_str
# Start TCP parser thread
thread = threading.Thread(target=parse_ais_stream, args=(tcp_port,), daemon=True)
@@ -439,15 +460,15 @@ def start_ais():
})
except Exception as e:
# Release device on failure
app_module.release_sdr_device(device_int)
app_module.release_sdr_device(device_int, sdr_type_str)
logger.error(f"Failed to start AIS-catcher: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500
return api_error(str(e), 500)
@ais_bp.route('/stop', methods=['POST'])
def stop_ais():
"""Stop AIS tracking."""
global ais_running, ais_active_device
global ais_running, ais_active_device, ais_active_sdr_type
with app_module.ais_lock:
if app_module.ais_process:
@@ -466,10 +487,11 @@ def stop_ais():
# Release device from registry
if ais_active_device is not None:
app_module.release_sdr_device(ais_active_device)
app_module.release_sdr_device(ais_active_device, ais_active_sdr_type or 'rtlsdr')
ais_running = False
ais_active_device = None
ais_active_sdr_type = None
app_module.ais_vessels.clear()
return jsonify({'status': 'stopped'})
@@ -478,34 +500,47 @@ def stop_ais():
@ais_bp.route('/stream')
def stream_ais():
"""SSE stream for AIS vessels."""
def generate() -> Generator[str, None, None]:
last_keepalive = time.time()
def _on_msg(msg: dict[str, Any]) -> None:
process_event('ais', msg, msg.get('type'))
while True:
try:
msg = app_module.ais_queue.get(timeout=SSE_QUEUE_TIMEOUT)
last_keepalive = time.time()
try:
process_event('ais', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg)
except queue.Empty:
now = time.time()
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream')
response = Response(
sse_stream_fanout(
source_queue=app_module.ais_queue,
channel_key='ais',
timeout=SSE_QUEUE_TIMEOUT,
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
on_message=_on_msg,
),
mimetype='text/event-stream',
)
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
return response
@ais_bp.route('/vessel/<mmsi>/dsc')
def get_vessel_dsc(mmsi: str):
"""Get DSC messages associated with a vessel MMSI."""
if not mmsi or not mmsi.isdigit():
return api_error('Invalid MMSI', 400)
matches = []
try:
for _key, msg in app_module.dsc_messages.items():
if str(msg.get('source_mmsi', '')) == mmsi:
matches.append(dict(msg))
except Exception:
pass
return api_success(data={'mmsi': mmsi, 'dsc_messages': matches})
@ais_bp.route('/dashboard')
def ais_dashboard():
"""Popout AIS dashboard."""
embedded = request.args.get('embedded', 'false') == 'true'
return render_template(
'ais_dashboard.html',
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
embedded=embedded,
)
+11 -12
View File
@@ -2,13 +2,12 @@
from __future__ import annotations
import queue
import time
from typing import Generator
from collections.abc import Generator
from flask import Blueprint, Response, jsonify, request
from flask import Blueprint, Response, request
from utils.alerts import get_alert_manager
from utils.responses import api_error, api_success
from utils.sse import format_sse
alerts_bp = Blueprint('alerts', __name__, url_prefix='/alerts')
@@ -18,18 +17,18 @@ alerts_bp = Blueprint('alerts', __name__, url_prefix='/alerts')
def list_rules():
manager = get_alert_manager()
include_disabled = request.args.get('all') in ('1', 'true', 'yes')
return jsonify({'status': 'success', 'rules': manager.list_rules(include_disabled=include_disabled)})
return api_success(data={'rules': manager.list_rules(include_disabled=include_disabled)})
@alerts_bp.route('/rules', methods=['POST'])
def create_rule():
data = request.get_json() or {}
if not isinstance(data.get('match', {}), dict):
return jsonify({'status': 'error', 'message': 'match must be a JSON object'}), 400
return api_error('match must be a JSON object', 400)
manager = get_alert_manager()
rule_id = manager.add_rule(data)
return jsonify({'status': 'success', 'rule_id': rule_id})
return api_success(data={'rule_id': rule_id})
@alerts_bp.route('/rules/<int:rule_id>', methods=['PUT', 'PATCH'])
@@ -38,8 +37,8 @@ def update_rule(rule_id: int):
manager = get_alert_manager()
ok = manager.update_rule(rule_id, data)
if not ok:
return jsonify({'status': 'error', 'message': 'Rule not found or no changes'}), 404
return jsonify({'status': 'success'})
return api_error('Rule not found or no changes', 404)
return api_success()
@alerts_bp.route('/rules/<int:rule_id>', methods=['DELETE'])
@@ -47,8 +46,8 @@ def delete_rule(rule_id: int):
manager = get_alert_manager()
ok = manager.delete_rule(rule_id)
if not ok:
return jsonify({'status': 'error', 'message': 'Rule not found'}), 404
return jsonify({'status': 'success'})
return api_error('Rule not found', 404)
return api_success()
@alerts_bp.route('/events', methods=['GET'])
@@ -58,7 +57,7 @@ def list_events():
mode = request.args.get('mode')
severity = request.args.get('severity')
events = manager.list_events(limit=limit, mode=mode, severity=severity)
return jsonify({'status': 'success', 'events': events})
return api_success(data={'events': events})
@alerts_bp.route('/stream', methods=['GET'])
+480 -283
View File
File diff suppressed because it is too large Load Diff
+8 -15
View File
@@ -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")
+58 -61
View File
@@ -2,8 +2,7 @@
from __future__ import annotations
import fcntl
import json
import contextlib
import os
import platform
import pty
@@ -13,32 +12,42 @@ import select
import subprocess
import threading
import time
from typing import Any, Generator
from typing import Any
from flask import Blueprint, jsonify, request, Response
from flask import Blueprint, Response, jsonify, request
import app as app_module
from utils.dependencies import check_tool
from utils.logging import bluetooth_logger as logger
from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.validation import validate_bluetooth_interface
from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer
from data.patterns import AIRTAG_PREFIXES, TILE_PREFIXES, SAMSUNG_TRACKER
from data.oui import OUI_DATABASE, get_manufacturer, load_oui_database
from data.patterns import AIRTAG_PREFIXES, SAMSUNG_TRACKER, TILE_PREFIXES
from utils.constants import (
BT_TERMINATE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT,
SUBPROCESS_TIMEOUT_SHORT,
SERVICE_ENUM_TIMEOUT,
PROCESS_START_WAIT,
BT_RESET_DELAY,
BT_ADAPTER_DOWN_WAIT,
PROCESS_TERMINATE_TIMEOUT,
)
from utils.dependencies import check_tool
from utils.event_pipeline import process_event
from utils.logging import bluetooth_logger as logger
from utils.responses import api_error, api_success
from utils.sse import sse_stream_fanout
from utils.validation import validate_bluetooth_interface
bluetooth_bp = Blueprint('bluetooth', __name__, url_prefix='/bt')
# --- v1 deprecation ---
# These endpoints are deprecated in favor of /api/bluetooth/*.
# Frontend still uses v1, so they remain active.
# Migration: switch frontend to v2 endpoints, then remove this file.
_v1_deprecation_logged = set()
@bluetooth_bp.after_request
def _add_deprecation_header(response):
"""Add X-Deprecated header to all v1 Bluetooth responses."""
response.headers['X-Deprecated'] = 'Use /api/bluetooth/* endpoints instead'
endpoint = request.endpoint or ''
if endpoint not in _v1_deprecation_logged:
_v1_deprecation_logged.add(endpoint)
logger.warning(f"Deprecated v1 Bluetooth endpoint called: {request.path} — migrate to /api/bluetooth/*")
return response
def classify_bt_device(name, device_class, services, manufacturer=None):
"""Classify Bluetooth device type based on available info."""
@@ -310,10 +319,8 @@ def stream_bt_scan(process, scan_mode):
except OSError:
break
try:
with contextlib.suppress(OSError):
os.close(master_fd)
except OSError:
pass
except Exception as e:
app_module.bt_queue.put({'type': 'error', 'text': str(e)})
@@ -331,8 +338,8 @@ def reload_oui_database_route():
if new_db:
OUI_DATABASE.clear()
OUI_DATABASE.update(new_db)
return jsonify({'status': 'success', 'entries': len(OUI_DATABASE)})
return jsonify({'status': 'error', 'message': 'Could not load oui_database.json'})
return api_success(data={'entries': len(OUI_DATABASE)})
return api_error('Could not load oui_database.json')
@bluetooth_bp.route('/interfaces')
@@ -359,7 +366,7 @@ def start_bt_scan():
with app_module.bt_lock:
if app_module.bt_process:
if app_module.bt_process.poll() is None:
return jsonify({'status': 'error', 'message': 'Scan already running'})
return api_error('Scan already running')
else:
app_module.bt_process = None
@@ -371,7 +378,7 @@ def start_bt_scan():
try:
interface = validate_bluetooth_interface(data.get('interface', 'hci0'))
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
return api_error(str(e), 400)
app_module.bt_interface = interface
app_module.bt_devices = {}
@@ -413,14 +420,14 @@ def start_bt_scan():
os.write(master_fd, b'scan on\n')
else:
return jsonify({'status': 'error', 'message': f'Unknown scan mode: {scan_mode}'})
return api_error(f'Unknown scan mode: {scan_mode}')
time.sleep(0.5)
if app_module.bt_process.poll() is not None:
stderr_output = app_module.bt_process.stderr.read().decode('utf-8', errors='replace').strip()
app_module.bt_process = None
return jsonify({'status': 'error', 'message': stderr_output or 'Process failed to start'})
return api_error(stderr_output or 'Process failed to start')
thread = threading.Thread(target=stream_bt_scan, args=(app_module.bt_process, scan_mode))
thread.daemon = True
@@ -430,9 +437,9 @@ def start_bt_scan():
return jsonify({'status': 'started', 'mode': scan_mode, 'interface': interface})
except FileNotFoundError as e:
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'})
return api_error(f'Tool not found: {e.filename}')
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)})
return api_error(str(e))
@bluetooth_bp.route('/scan/stop', methods=['POST'])
@@ -459,7 +466,7 @@ def reset_bt_adapter():
try:
interface = validate_bluetooth_interface(data.get('interface', 'hci0'))
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
return api_error(str(e), 400)
with app_module.bt_lock:
if app_module.bt_process:
@@ -467,10 +474,8 @@ def reset_bt_adapter():
app_module.bt_process.terminate()
app_module.bt_process.wait(timeout=2)
except (subprocess.TimeoutExpired, OSError):
try:
with contextlib.suppress(OSError):
app_module.bt_process.kill()
except OSError:
pass
app_module.bt_process = None
try:
@@ -489,12 +494,12 @@ def reset_bt_adapter():
return jsonify({
'status': 'success' if is_up else 'warning',
'message': f'Adapter {interface} reset' if is_up else f'Reset attempted but adapter may still be down',
'message': f'Adapter {interface} reset' if is_up else 'Reset attempted but adapter may still be down',
'is_up': is_up
})
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)})
return api_error(str(e))
@bluetooth_bp.route('/enum', methods=['POST'])
@@ -504,7 +509,7 @@ def enum_bt_services():
target_mac = data.get('mac')
if not target_mac:
return jsonify({'status': 'error', 'message': 'Target MAC required'})
return api_error('Target MAC required')
try:
result = subprocess.run(
@@ -529,18 +534,17 @@ def enum_bt_services():
app_module.bt_services[target_mac] = services
return jsonify({
'status': 'success',
return api_success(data={
'mac': target_mac,
'services': services
})
except subprocess.TimeoutExpired:
return jsonify({'status': 'error', 'message': 'Connection timed out'})
return api_error('Connection timed out')
except FileNotFoundError:
return jsonify({'status': 'error', 'message': 'sdptool not found'})
return api_error('sdptool not found')
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)})
return api_error(str(e))
@bluetooth_bp.route('/devices')
@@ -556,26 +560,19 @@ def get_bt_devices():
@bluetooth_bp.route('/stream')
def stream_bt():
"""SSE stream for Bluetooth events."""
def generate():
last_keepalive = time.time()
keepalive_interval = 30.0
def _on_msg(msg: dict[str, Any]) -> None:
process_event('bluetooth', msg, msg.get('type'))
while True:
try:
msg = app_module.bt_queue.get(timeout=1)
last_keepalive = time.time()
try:
process_event('bluetooth', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg)
except queue.Empty:
now = time.time()
if now - last_keepalive >= keepalive_interval:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream')
response = Response(
sse_stream_fanout(
source_queue=app_module.bt_queue,
channel_key='bluetooth',
timeout=1.0,
keepalive_interval=30.0,
on_message=_on_msg,
),
mimetype='text/event-stream',
)
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
+11 -17
View File
@@ -7,30 +7,27 @@ aggregation, and heuristics.
from __future__ import annotations
import contextlib
import csv
import io
import json
import logging
import threading
import time
from collections.abc import Generator
from datetime import datetime
from typing import Generator
from flask import Blueprint, Response, jsonify, request, session
from flask import Blueprint, Response, jsonify, request
from utils.bluetooth import (
BluetoothScanner,
BTDeviceAggregate,
get_bluetooth_scanner,
check_capabilities,
RANGE_UNKNOWN,
TrackerType,
TrackerConfidence,
get_tracker_engine,
get_bluetooth_scanner,
)
from utils.database import get_db
from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.responses import api_error
from utils.sse import format_sse
logger = logging.getLogger('intercept.bluetooth_v2')
@@ -231,7 +228,7 @@ def start_scan():
# Validate mode
valid_modes = ('auto', 'dbus', 'bleak', 'hcitool', 'bluetoothctl', 'ubertooth')
if mode not in valid_modes:
return jsonify({'error': f'Invalid mode. Must be one of: {valid_modes}'}), 400
return api_error(f'Invalid mode. Must be one of: {valid_modes}', 400)
# Get scanner instance
scanner = get_bluetooth_scanner(adapter_id)
@@ -261,7 +258,7 @@ def start_scan():
# Check if already scanning
if scanner.is_scanning:
return jsonify({
'status': 'already_running',
'status': 'already_scanning',
'scan_status': scanner.get_status().to_dict()
})
@@ -389,7 +386,7 @@ def get_device(device_id: str):
device = scanner.get_device(device_id)
if not device:
return jsonify({'error': 'Device not found'}), 404
return api_error('Device not found', 404)
return jsonify(device.to_dict())
@@ -529,7 +526,7 @@ def get_tracker_detail(device_id: str):
device = scanner.get_device(device_id)
if not device:
return jsonify({'error': 'Device not found'}), 404
return api_error('Device not found', 404)
# Get RSSI history for timeline
rssi_history = device.get_rssi_history(max_points=100)
@@ -900,10 +897,8 @@ def stream_events():
"""Generate SSE events from scanner."""
for event in scanner.stream_events(timeout=1.0):
event_name, event_data = map_event_type(event)
try:
with contextlib.suppress(Exception):
process_event('bluetooth', event_data, event_name)
except Exception:
pass
yield format_sse(event_data, event=event_name)
return Response(
@@ -971,7 +966,6 @@ def get_tscm_bluetooth_snapshot(duration: int = 8) -> list[dict]:
Returns:
List of device dictionaries in TSCM format.
"""
import time
import logging
logger = logging.getLogger('intercept.bluetooth_v2')
+39 -15
View File
@@ -21,6 +21,7 @@ from utils.bt_locate import (
start_locate_session,
stop_locate_session,
)
from utils.responses import api_error
from utils.sse import format_sse
logger = logging.getLogger('intercept.bt_locate')
@@ -38,6 +39,8 @@ def start_session():
- name_pattern: Target name substring (optional)
- irk_hex: Identity Resolving Key hex string (optional)
- device_id: Device ID from Bluetooth scanner (optional)
- device_key: Stable device key from Bluetooth scanner (optional)
- fingerprint_id: Payload fingerprint ID from Bluetooth scanner (optional)
- known_name: Hand-off device name (optional)
- known_manufacturer: Hand-off manufacturer (optional)
- last_known_rssi: Hand-off last RSSI (optional)
@@ -55,28 +58,41 @@ def start_session():
name_pattern=data.get('name_pattern'),
irk_hex=data.get('irk_hex'),
device_id=data.get('device_id'),
device_key=data.get('device_key'),
fingerprint_id=data.get('fingerprint_id'),
known_name=data.get('known_name'),
known_manufacturer=data.get('known_manufacturer'),
last_known_rssi=data.get('last_known_rssi'),
)
# At least one identifier required
if not any([target.mac_address, target.name_pattern, target.irk_hex, target.device_id]):
return jsonify({'error': 'At least one target identifier required (mac_address, name_pattern, irk_hex, or device_id)'}), 400
if not any([
target.mac_address,
target.name_pattern,
target.irk_hex,
target.device_id,
target.device_key,
target.fingerprint_id,
]):
return api_error(
'At least one target identifier required '
'(mac_address, name_pattern, irk_hex, device_id, device_key, or fingerprint_id)',
400
)
# Parse environment
env_str = data.get('environment', 'OUTDOOR').upper()
try:
environment = Environment[env_str]
except KeyError:
return jsonify({'error': f'Invalid environment: {env_str}'}), 400
return api_error(f'Invalid environment: {env_str}', 400)
custom_exponent = data.get('custom_exponent')
if custom_exponent is not None:
try:
custom_exponent = float(custom_exponent)
except (ValueError, TypeError):
return jsonify({'error': 'custom_exponent must be a number'}), 400
return api_error('custom_exponent must be a number', 400)
# Fallback coordinates when GPS is unavailable (from user settings)
fallback_lat = None
@@ -93,9 +109,16 @@ def start_session():
f"env={environment.name}, fallback=({fallback_lat}, {fallback_lon})"
)
session = start_locate_session(
target, environment, custom_exponent, fallback_lat, fallback_lon
)
try:
session = start_locate_session(
target, environment, custom_exponent, fallback_lat, fallback_lon
)
except RuntimeError as exc:
logger.warning(f"Unable to start BT Locate session: {exc}")
return api_error('Bluetooth scanner could not be started. Check adapter permissions/capabilities.', 503)
except Exception as exc:
logger.exception(f"Unexpected error starting BT Locate session: {exc}")
return api_error('Failed to start locate session', 500)
return jsonify({
'status': 'started',
@@ -124,7 +147,8 @@ def get_status():
'target': None,
})
return jsonify(session.get_status())
include_debug = str(request.args.get('debug', '')).lower() in ('1', 'true', 'yes')
return jsonify(session.get_status(include_debug=include_debug))
@bt_locate_bp.route('/trail', methods=['GET'])
@@ -186,15 +210,15 @@ def test_resolve_rpa():
address = data.get('address', '')
if not irk_hex or not address:
return jsonify({'error': 'irk_hex and address are required'}), 400
return api_error('irk_hex and address are required', 400)
try:
irk = bytes.fromhex(irk_hex)
except ValueError:
return jsonify({'error': 'Invalid IRK hex string'}), 400
return api_error('Invalid IRK hex string', 400)
if len(irk) != 16:
return jsonify({'error': 'IRK must be exactly 16 bytes (32 hex characters)'}), 400
return api_error('IRK must be exactly 16 bytes (32 hex characters)', 400)
result = resolve_rpa(irk, address)
return jsonify({
@@ -209,14 +233,14 @@ def set_environment():
"""Update the environment on the active session."""
session = get_locate_session()
if not session:
return jsonify({'error': 'no active session'}), 400
return api_error('no active session', 400)
data = request.get_json() or {}
env_str = data.get('environment', '').upper()
try:
environment = Environment[env_str]
except KeyError:
return jsonify({'error': f'Invalid environment: {env_str}'}), 400
return api_error(f'Invalid environment: {env_str}', 400)
custom_exponent = data.get('custom_exponent')
if custom_exponent is not None:
@@ -238,11 +262,11 @@ def debug_matching():
"""Debug endpoint showing scanner devices and match results."""
session = get_locate_session()
if not session:
return jsonify({'error': 'no session'})
return api_error('no session')
scanner = session._scanner
if not scanner:
return jsonify({'error': 'no scanner'})
return api_error('no scanner')
devices = scanner.get_devices(max_age_seconds=30)
return jsonify({
+164 -176
View File
@@ -10,36 +10,60 @@ This blueprint provides:
from __future__ import annotations
import json
import logging
import queue
import time
from datetime import datetime, timezone
from typing import Generator
import requests
from flask import Blueprint, jsonify, request, Response
import logging
import queue
import threading
import time
from collections.abc import Generator
from datetime import datetime, timezone
import requests
from flask import Blueprint, Response, jsonify, request
from utils.agent_client import AgentClient, AgentConnectionError, AgentHTTPError, create_client_from_agent
from utils.database import (
create_agent, get_agent, get_agent_by_name, list_agents,
update_agent, delete_agent, store_push_payload, get_recent_payloads
)
from utils.agent_client import (
AgentClient, AgentHTTPError, AgentConnectionError, create_client_from_agent
create_agent,
delete_agent,
get_agent,
get_agent_by_name,
get_recent_payloads,
list_agents,
store_push_payload,
update_agent,
)
from utils.responses import api_error
from utils.sse import format_sse
from utils.trilateration import (
DeviceLocationTracker, PathLossModel, Trilateration,
AgentObservation, estimate_location_from_observations
DeviceLocationTracker,
PathLossModel,
Trilateration,
estimate_location_from_observations,
)
logger = logging.getLogger('intercept.controller')
controller_bp = Blueprint('controller', __name__, url_prefix='/controller')
# Multi-agent data queue for combined SSE stream
agent_data_queue: queue.Queue = queue.Queue(maxsize=1000)
# Multi-agent SSE fanout state (per-client queues).
_agent_stream_subscribers: set[queue.Queue] = set()
_agent_stream_subscribers_lock = threading.Lock()
_AGENT_STREAM_CLIENT_QUEUE_SIZE = 500
def _broadcast_agent_data(payload: dict) -> None:
"""Fan out an ingested payload to all active /controller/stream/all clients."""
with _agent_stream_subscribers_lock:
subscribers = tuple(_agent_stream_subscribers)
for subscriber in subscribers:
try:
subscriber.put_nowait(payload)
except queue.Full:
try:
subscriber.get_nowait()
subscriber.put_nowait(payload)
except (queue.Empty, queue.Full):
continue
# =============================================================================
@@ -89,28 +113,25 @@ def register_agent():
base_url = data.get('base_url', '').strip()
if not name:
return jsonify({'status': 'error', 'message': 'Agent name is required'}), 400
return api_error('Agent name is required', 400)
if not base_url:
return jsonify({'status': 'error', 'message': 'Base URL is required'}), 400
return api_error('Base URL is required', 400)
# Validate URL format
from urllib.parse import urlparse
try:
parsed = urlparse(base_url)
if parsed.scheme not in ('http', 'https'):
return jsonify({'status': 'error', 'message': 'URL must start with http:// or https://'}), 400
return api_error('URL must start with http:// or https://', 400)
if not parsed.netloc:
return jsonify({'status': 'error', 'message': 'Invalid URL format'}), 400
return api_error('Invalid URL format', 400)
except Exception:
return jsonify({'status': 'error', 'message': 'Invalid URL format'}), 400
return api_error('Invalid URL format', 400)
# Check if agent already exists
existing = get_agent_by_name(name)
if existing:
return jsonify({
'status': 'error',
'message': f'Agent with name "{name}" already exists'
}), 409
return api_error(f'Agent with name "{name}" already exists', 409)
# Try to connect and get capabilities
api_key = data.get('api_key', '').strip() or None
@@ -152,7 +173,7 @@ def register_agent():
except Exception as e:
logger.exception("Failed to create agent")
return jsonify({'status': 'error', 'message': str(e)}), 500
return api_error(str(e), 500)
@controller_bp.route('/agents/<int:agent_id>', methods=['GET'])
@@ -160,7 +181,7 @@ def get_agent_detail(agent_id: int):
"""Get details of a specific agent."""
agent = get_agent(agent_id)
if not agent:
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
return api_error('Agent not found', 404)
# Optionally refresh from agent
refresh = request.args.get('refresh', 'false').lower() == 'true'
@@ -196,7 +217,7 @@ def update_agent_detail(agent_id: int):
"""Update an agent's details."""
agent = get_agent(agent_id)
if not agent:
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
return api_error('Agent not found', 404)
data = request.json or {}
@@ -218,7 +239,7 @@ def remove_agent(agent_id: int):
"""Delete an agent."""
agent = get_agent(agent_id)
if not agent:
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
return api_error('Agent not found', 404)
delete_agent(agent_id)
return jsonify({'status': 'success', 'message': 'Agent deleted'})
@@ -229,7 +250,7 @@ def refresh_agent_metadata(agent_id: int):
"""Refresh an agent's capabilities and status."""
agent = get_agent(agent_id)
if not agent:
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
return api_error('Agent not found', 404)
try:
client = create_client_from_agent(agent)
@@ -255,16 +276,10 @@ def refresh_agent_metadata(agent_id: int):
'metadata': metadata
})
else:
return jsonify({
'status': 'error',
'message': 'Agent is not reachable'
}), 503
return api_error('Agent is not reachable', 503)
except (AgentHTTPError, AgentConnectionError) as e:
return jsonify({
'status': 'error',
'message': f'Failed to reach agent: {e}'
}), 503
return api_error(f'Failed to reach agent: {e}', 503)
# =============================================================================
@@ -276,7 +291,7 @@ def get_agent_status(agent_id: int):
"""Get an agent's current status including running modes."""
agent = get_agent(agent_id)
if not agent:
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
return api_error('Agent not found', 404)
try:
client = create_client_from_agent(agent)
@@ -288,10 +303,7 @@ def get_agent_status(agent_id: int):
'agent_status': status
})
except (AgentHTTPError, AgentConnectionError) as e:
return jsonify({
'status': 'error',
'message': f'Failed to reach agent: {e}'
}), 503
return api_error(f'Failed to reach agent: {e}', 503)
@controller_bp.route('/agents/health', methods=['GET'])
@@ -365,7 +377,7 @@ def proxy_start_mode(agent_id: int, mode: str):
"""Start a mode on a remote agent."""
agent = get_agent(agent_id)
if not agent:
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
return api_error('Agent not found', 404)
params = request.json or {}
@@ -384,15 +396,9 @@ def proxy_start_mode(agent_id: int, mode: str):
})
except AgentConnectionError as e:
return jsonify({
'status': 'error',
'message': f'Cannot connect to agent: {e}'
}), 503
return api_error(f'Cannot connect to agent: {e}', 503)
except AgentHTTPError as e:
return jsonify({
'status': 'error',
'message': f'Agent error: {e}'
}), 502
return api_error(f'Agent error: {e}', 502)
@controller_bp.route('/agents/<int:agent_id>/<mode>/stop', methods=['POST'])
@@ -400,7 +406,7 @@ def proxy_stop_mode(agent_id: int, mode: str):
"""Stop a mode on a remote agent."""
agent = get_agent(agent_id)
if not agent:
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
return api_error('Agent not found', 404)
try:
client = create_client_from_agent(agent)
@@ -416,15 +422,9 @@ def proxy_stop_mode(agent_id: int, mode: str):
})
except AgentConnectionError as e:
return jsonify({
'status': 'error',
'message': f'Cannot connect to agent: {e}'
}), 503
return api_error(f'Cannot connect to agent: {e}', 503)
except AgentHTTPError as e:
return jsonify({
'status': 'error',
'message': f'Agent error: {e}'
}), 502
return api_error(f'Agent error: {e}', 502)
@controller_bp.route('/agents/<int:agent_id>/<mode>/status', methods=['GET'])
@@ -432,7 +432,7 @@ def proxy_mode_status(agent_id: int, mode: str):
"""Get mode status from a remote agent."""
agent = get_agent(agent_id)
if not agent:
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
return api_error('Agent not found', 404)
try:
client = create_client_from_agent(agent)
@@ -446,18 +446,15 @@ def proxy_mode_status(agent_id: int, mode: str):
})
except (AgentHTTPError, AgentConnectionError) as e:
return jsonify({
'status': 'error',
'message': f'Agent error: {e}'
}), 502
return api_error(f'Agent error: {e}', 502)
@controller_bp.route('/agents/<int:agent_id>/<mode>/data', methods=['GET'])
def proxy_mode_data(agent_id: int, mode: str):
"""Get current data from a remote agent."""
agent = get_agent(agent_id)
if not agent:
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
@controller_bp.route('/agents/<int:agent_id>/<mode>/data', methods=['GET'])
def proxy_mode_data(agent_id: int, mode: str):
"""Get current data from a remote agent."""
agent = get_agent(agent_id)
if not agent:
return api_error('Agent not found', 404)
try:
client = create_client_from_agent(agent)
@@ -475,60 +472,57 @@ def proxy_mode_data(agent_id: int, mode: str):
'data': result
})
except (AgentHTTPError, AgentConnectionError) as e:
return jsonify({
'status': 'error',
'message': f'Agent error: {e}'
}), 502
@controller_bp.route('/agents/<int:agent_id>/<mode>/stream')
def proxy_mode_stream(agent_id: int, mode: str):
"""Proxy SSE stream from a remote agent."""
agent = get_agent(agent_id)
if not agent:
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
client = create_client_from_agent(agent)
query = request.query_string.decode('utf-8')
url = f"{client.base_url}/{mode}/stream"
if query:
url = f"{url}?{query}"
headers = {'Accept': 'text/event-stream'}
if agent.get('api_key'):
headers['X-API-Key'] = agent['api_key']
def generate() -> Generator[str, None, None]:
try:
with requests.get(url, headers=headers, stream=True, timeout=(5, 3600)) as resp:
resp.raise_for_status()
for chunk in resp.iter_content(chunk_size=1024):
if not chunk:
continue
yield chunk.decode('utf-8', errors='ignore')
except Exception as e:
logger.error(f"SSE proxy error for agent {agent_id}/{mode}: {e}")
yield format_sse({
'type': 'error',
'message': str(e),
'agent_id': agent_id,
'mode': mode,
})
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response
@controller_bp.route('/agents/<int:agent_id>/wifi/monitor', methods=['POST'])
def proxy_wifi_monitor(agent_id: int):
"""Toggle monitor mode on a remote agent's WiFi interface."""
agent = get_agent(agent_id)
if not agent:
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
except (AgentHTTPError, AgentConnectionError) as e:
return api_error(f'Agent error: {e}', 502)
@controller_bp.route('/agents/<int:agent_id>/<mode>/stream')
def proxy_mode_stream(agent_id: int, mode: str):
"""Proxy SSE stream from a remote agent."""
agent = get_agent(agent_id)
if not agent:
return api_error('Agent not found', 404)
client = create_client_from_agent(agent)
query = request.query_string.decode('utf-8')
url = f"{client.base_url}/{mode}/stream"
if query:
url = f"{url}?{query}"
headers = {'Accept': 'text/event-stream'}
if agent.get('api_key'):
headers['X-API-Key'] = agent['api_key']
def generate() -> Generator[str, None, None]:
try:
with requests.get(url, headers=headers, stream=True, timeout=(5, 3600)) as resp:
resp.raise_for_status()
for chunk in resp.iter_content(chunk_size=1024):
if not chunk:
continue
yield chunk.decode('utf-8', errors='ignore')
except Exception as e:
logger.error(f"SSE proxy error for agent {agent_id}/{mode}: {e}")
yield format_sse({
'type': 'error',
'message': str(e),
'agent_id': agent_id,
'mode': mode,
})
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response
@controller_bp.route('/agents/<int:agent_id>/wifi/monitor', methods=['POST'])
def proxy_wifi_monitor(agent_id: int):
"""Toggle monitor mode on a remote agent's WiFi interface."""
agent = get_agent(agent_id)
if not agent:
return api_error('Agent not found', 404)
data = request.json or {}
@@ -563,15 +557,9 @@ def proxy_wifi_monitor(agent_id: int):
})
except AgentConnectionError as e:
return jsonify({
'status': 'error',
'message': f'Cannot connect to agent: {e}'
}), 503
return api_error(f'Cannot connect to agent: {e}', 503)
except AgentHTTPError as e:
return jsonify({
'status': 'error',
'message': f'Agent error: {e}'
}), 502
return api_error(f'Agent error: {e}', 502)
# =============================================================================
@@ -597,23 +585,23 @@ def ingest_push_data():
"""
data = request.json
if not data:
return jsonify({'status': 'error', 'message': 'No data provided'}), 400
return api_error('No data provided', 400)
agent_name = data.get('agent_name')
if not agent_name:
return jsonify({'status': 'error', 'message': 'agent_name required'}), 400
return api_error('agent_name required', 400)
# Find agent
agent = get_agent_by_name(agent_name)
if not agent:
return jsonify({'status': 'error', 'message': 'Unknown agent'}), 401
return api_error('Unknown agent', 401)
# Validate API key if configured
if agent.get('api_key'):
provided_key = request.headers.get('X-API-Key', '')
if provided_key != agent['api_key']:
logger.warning(f"Invalid API key from agent {agent_name}")
return jsonify({'status': 'error', 'message': 'Invalid API key'}), 401
return api_error('Invalid API key', 401)
# Store payload
try:
@@ -625,19 +613,16 @@ def ingest_push_data():
received_at=data.get('received_at')
)
# Emit to SSE stream
try:
agent_data_queue.put_nowait({
'type': 'agent_data',
'agent_id': agent['id'],
'agent_name': agent_name,
'scan_type': data.get('scan_type'),
'interface': data.get('interface'),
'payload': data.get('payload'),
'received_at': data.get('received_at') or datetime.now(timezone.utc).isoformat()
})
except queue.Full:
logger.warning("Agent data queue full, data may be lost")
# Emit to SSE stream (fanout to all connected clients)
_broadcast_agent_data({
'type': 'agent_data',
'agent_id': agent['id'],
'agent_name': agent_name,
'scan_type': data.get('scan_type'),
'interface': data.get('interface'),
'payload': data.get('payload'),
'received_at': data.get('received_at') or datetime.now(timezone.utc).isoformat()
})
return jsonify({
'status': 'accepted',
@@ -646,7 +631,7 @@ def ingest_push_data():
except Exception as e:
logger.exception("Failed to store push payload")
return jsonify({'status': 'error', 'message': str(e)}), 500
return api_error(str(e), 500)
@controller_bp.route('/api/payloads', methods=['GET'])
@@ -681,20 +666,28 @@ def stream_all_agents():
This endpoint streams push data as it arrives from agents.
Each message is tagged with agent_id and agent_name.
"""
client_queue: queue.Queue = queue.Queue(maxsize=_AGENT_STREAM_CLIENT_QUEUE_SIZE)
with _agent_stream_subscribers_lock:
_agent_stream_subscribers.add(client_queue)
def generate() -> Generator[str, None, None]:
last_keepalive = time.time()
keepalive_interval = 30.0
while True:
try:
msg = agent_data_queue.get(timeout=1.0)
last_keepalive = time.time()
yield format_sse(msg)
except queue.Empty:
now = time.time()
if now - last_keepalive >= keepalive_interval:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
try:
while True:
try:
msg = client_queue.get(timeout=1.0)
last_keepalive = time.time()
yield format_sse(msg)
except queue.Empty:
now = time.time()
if now - last_keepalive >= keepalive_interval:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
finally:
with _agent_stream_subscribers_lock:
_agent_stream_subscribers.discard(client_queue)
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
@@ -711,6 +704,7 @@ def stream_all_agents():
def agent_management_page():
"""Render the agent management page."""
from flask import render_template
from config import VERSION
return render_template('agents.html', version=VERSION)
@@ -759,7 +753,7 @@ def add_location_observation():
required = ['device_id', 'agent_name', 'agent_lat', 'agent_lon', 'rssi']
for field in required:
if field not in data:
return jsonify({'status': 'error', 'message': f'Missing required field: {field}'}), 400
return api_error(f'Missing required field: {field}', 400)
# Look up agent GPS from database if not provided
agent_lat = data.get('agent_lat')
@@ -773,10 +767,7 @@ def add_location_observation():
agent_lon = coords.get('lon') or coords.get('longitude')
if agent_lat is None or agent_lon is None:
return jsonify({
'status': 'error',
'message': 'Agent GPS coordinates required'
}), 400
return api_error('Agent GPS coordinates required', 400)
estimate = device_tracker.add_observation(
device_id=data['device_id'],
@@ -813,10 +804,7 @@ def estimate_location():
observations = data.get('observations', [])
if len(observations) < 2:
return jsonify({
'status': 'error',
'message': 'At least 2 observations required'
}), 400
return api_error('At least 2 observations required', 400)
environment = data.get('environment', 'outdoor')
@@ -828,7 +816,7 @@ def estimate_location():
})
except Exception as e:
logger.exception("Location estimation failed")
return jsonify({'status': 'error', 'message': str(e)}), 500
return api_error(str(e), 500)
@controller_bp.route('/api/location/<device_id>', methods=['GET'])
@@ -880,7 +868,7 @@ def get_devices_near():
lon = float(request.args.get('lon', 0))
radius = float(request.args.get('radius', 100))
except (ValueError, TypeError):
return jsonify({'status': 'error', 'message': 'Invalid coordinates'}), 400
return api_error('Invalid coordinates', 400)
results = device_tracker.get_devices_near(lat, lon, radius)
+10 -32
View File
@@ -2,11 +2,12 @@
from __future__ import annotations
from flask import Blueprint, jsonify, request, Response
from flask import Blueprint, Response, request
import app as app_module
from utils.correlation import get_correlations
from utils.logging import get_logger
from utils.responses import api_error, api_success
logger = get_logger('intercept.correlation')
@@ -39,18 +40,14 @@ def get_device_correlations() -> Response:
include_historical=include_historical
)
return jsonify({
'status': 'success',
return api_success(data={
'correlations': correlations,
'wifi_count': len(wifi_devices),
'bt_count': len(bt_devices)
})
except Exception as e:
logger.error(f"Error calculating correlations: {e}")
return jsonify({
'status': 'error',
'message': str(e)
}), 500
return api_error(str(e), 500)
@correlation_bp.route('/analyze', methods=['POST'])
@@ -67,10 +64,7 @@ def analyze_correlation() -> Response:
bt_mac = data.get('bt_mac')
if not wifi_mac or not bt_mac:
return jsonify({
'status': 'error',
'message': 'wifi_mac and bt_mac are required'
}), 400
return api_error('wifi_mac and bt_mac are required', 400)
try:
# Get device data
@@ -81,16 +75,10 @@ def analyze_correlation() -> Response:
bt_device = app_module.bt_devices.get(bt_mac)
if not wifi_device:
return jsonify({
'status': 'error',
'message': f'WiFi device {wifi_mac} not found'
}), 404
return api_error(f'WiFi device {wifi_mac} not found', 404)
if not bt_device:
return jsonify({
'status': 'error',
'message': f'Bluetooth device {bt_mac} not found'
}), 404
return api_error(f'Bluetooth device {bt_mac} not found', 404)
# Calculate correlation for this specific pair
correlations = get_correlations(
@@ -101,19 +89,9 @@ def analyze_correlation() -> Response:
)
if correlations:
return jsonify({
'status': 'success',
'correlation': correlations[0]
})
return api_success(data={'correlation': correlations[0]})
else:
return jsonify({
'status': 'success',
'correlation': None,
'message': 'No correlation detected between these devices'
})
return api_success(data={'correlation': None}, message='No correlation detected between these devices')
except Exception as e:
logger.error(f"Error analyzing correlation: {e}")
return jsonify({
'status': 'error',
'message': str(e)
}), 500
return api_error(str(e), 500)
-758
View File
@@ -1,758 +0,0 @@
"""DMR / P25 / Digital Voice decoding routes."""
from __future__ import annotations
import os
import queue
import re
import select
import shutil
import subprocess
import threading
import time
from datetime import datetime
from typing import Generator, Optional
from flask import Blueprint, jsonify, request, Response
import app as app_module
from utils.logging import get_logger
from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.process import register_process, unregister_process
from utils.validation import validate_frequency, validate_gain, validate_device_index, validate_ppm
from utils.sdr import SDRFactory, SDRType
from utils.constants import (
SSE_QUEUE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL,
QUEUE_MAX_SIZE,
)
logger = get_logger('intercept.dmr')
dmr_bp = Blueprint('dmr', __name__, url_prefix='/dmr')
# ============================================
# GLOBAL STATE
# ============================================
dmr_rtl_process: Optional[subprocess.Popen] = None
dmr_dsd_process: Optional[subprocess.Popen] = None
dmr_thread: Optional[threading.Thread] = None
dmr_running = False
dmr_has_audio = False # True when ffmpeg available and dsd outputs audio
dmr_lock = threading.Lock()
dmr_queue: queue.Queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
dmr_active_device: Optional[int] = None
# Audio mux: the sole reader of dsd-fme stdout. Fans out bytes to all
# active ffmpeg stdin sinks when streaming clients are connected.
# This prevents dsd-fme from blocking on stdout (which would also
# freeze stderr / text data output).
_ffmpeg_sinks: set[object] = set()
_ffmpeg_sinks_lock = threading.Lock()
VALID_PROTOCOLS = ['auto', 'dmr', 'p25', 'nxdn', 'dstar', 'provoice']
# Classic dsd flags
_DSD_PROTOCOL_FLAGS = {
'auto': [],
'dmr': ['-fd'],
'p25': ['-fp'],
'nxdn': ['-fn'],
'dstar': ['-fi'],
'provoice': ['-fv'],
}
# dsd-fme remapped several flags from classic DSD:
# -fs = DMR Simplex (NOT -fd which is D-STAR!),
# -fd = D-STAR (NOT DMR!), -fp = ProVoice (NOT P25),
# -fi = NXDN48 (NOT D-Star), -f1 = P25 Phase 1,
# -ft = XDMA multi-protocol decoder
_DSD_FME_PROTOCOL_FLAGS = {
'auto': ['-fa'], # Broad auto: P25 (P1/P2), DMR, D-STAR, YSF, X2-TDMA
'dmr': ['-fs'], # DMR Simplex (-fd is D-STAR in dsd-fme!)
'p25': ['-ft'], # P25 P1/P2 coverage (also includes DMR in dsd-fme)
'nxdn': ['-fn'], # NXDN96
'dstar': ['-fd'], # D-STAR (-fd in dsd-fme, NOT DMR!)
'provoice': ['-fp'], # ProVoice (-fp in dsd-fme, not -fv)
}
# Modulation hints: force C4FM for protocols that use it, improving
# sync reliability vs letting dsd-fme auto-detect modulation type.
_DSD_FME_MODULATION = {
'dmr': ['-mc'], # C4FM
'nxdn': ['-mc'], # C4FM
}
# ============================================
# HELPERS
# ============================================
def find_dsd() -> tuple[str | None, bool]:
"""Find DSD (Digital Speech Decoder) binary.
Checks for dsd-fme first (common fork), then falls back to dsd.
Returns (path, is_fme) tuple.
"""
path = shutil.which('dsd-fme')
if path:
return path, True
path = shutil.which('dsd')
if path:
return path, False
return None, False
def find_rtl_fm() -> str | None:
"""Find rtl_fm binary."""
return shutil.which('rtl_fm')
def find_rx_fm() -> str | None:
"""Find SoapySDR rx_fm binary."""
return shutil.which('rx_fm')
def find_ffmpeg() -> str | None:
"""Find ffmpeg for audio encoding."""
return shutil.which('ffmpeg')
def parse_dsd_output(line: str) -> dict | None:
"""Parse a line of DSD stderr output into a structured event.
Handles output from both classic ``dsd`` and ``dsd-fme`` which use
different formatting for talkgroup / source / voice frame lines.
"""
line = line.strip()
if not line:
return None
# Skip DSD/dsd-fme startup banner lines (ASCII art, version info, etc.)
# Only filter lines that are purely decorative — dsd-fme uses box-drawing
# characters (│, ─) as column separators in DATA lines, so we must not
# discard lines that also contain alphanumeric content.
stripped_of_box = re.sub(r'[╔╗╚╝║═██▀▄╗╝╩╦╠╣╬│┤├┘└┐┌─┼█▓▒░\s]', '', line)
if not stripped_of_box:
return None
if re.match(r'^\s*(Build Version|MBElib|CODEC2|Audio (Out|In)|Decoding )', line):
return None
ts = datetime.now().strftime('%H:%M:%S')
# Sync detection: "Sync: +DMR (data)" or "Sync: +P25 Phase 1"
sync_match = re.match(r'Sync:\s*\+?(\S+.*)', line)
if sync_match:
return {
'type': 'sync',
'protocol': sync_match.group(1).strip(),
'timestamp': ts,
}
# Talkgroup and Source — check BEFORE slot so "Slot 1 Voice LC, TG: …"
# is captured as a call event rather than a bare slot event.
# Classic dsd: "TG: 12345 Src: 67890"
# dsd-fme: "TG: 12345, Src: 67890" or "Talkgroup: 12345, Source: 67890"
# "TGT: 12345 | SRC: 67890" (pipe-delimited variant)
tg_match = re.search(
r'(?:TGT?|Talkgroup)[:\s]+(\d+)[,|│\s]+(?:Src|Source|SRC)[:\s]+(\d+)', line, re.IGNORECASE
)
if tg_match:
result = {
'type': 'call',
'talkgroup': int(tg_match.group(1)),
'source_id': int(tg_match.group(2)),
'timestamp': ts,
}
# Extract slot if present on the same line
slot_inline = re.search(r'Slot\s*(\d+)', line)
if slot_inline:
result['slot'] = int(slot_inline.group(1))
return result
# P25 NAC (Network Access Code) — check before voice/slot
nac_match = re.search(r'NAC[:\s]+([0-9A-Fa-f]+)', line)
if nac_match:
return {
'type': 'nac',
'nac': nac_match.group(1),
'timestamp': ts,
}
# Voice frame detection — check BEFORE bare slot match
# Classic dsd: "Voice" keyword in frame lines
# dsd-fme: "voice" or "Voice LC" or "VOICE" in output
if re.search(r'\bvoice\b', line, re.IGNORECASE):
result = {
'type': 'voice',
'detail': line,
'timestamp': ts,
}
slot_inline = re.search(r'Slot\s*(\d+)', line)
if slot_inline:
result['slot'] = int(slot_inline.group(1))
return result
# Bare slot info (only when line is *just* slot info, not voice/call)
slot_match = re.match(r'\s*Slot\s*(\d+)\s*$', line)
if slot_match:
return {
'type': 'slot',
'slot': int(slot_match.group(1)),
'timestamp': ts,
}
# dsd-fme status lines we can surface: "TDMA", "CACH", "PI", "BS", etc.
# Also catches "Closing", "Input", and other lifecycle lines.
# Forward as raw so the frontend can show decoder is alive.
return {
'type': 'raw',
'text': line[:200],
'timestamp': ts,
}
_HEARTBEAT_INTERVAL = 3.0 # seconds between heartbeats when decoder is idle
# 100ms of silence at 8kHz 16-bit mono = 1600 bytes
_SILENCE_CHUNK = b'\x00' * 1600
def _register_audio_sink(sink: object) -> None:
"""Register an ffmpeg stdin sink for mux fanout."""
with _ffmpeg_sinks_lock:
_ffmpeg_sinks.add(sink)
def _unregister_audio_sink(sink: object) -> None:
"""Remove an ffmpeg stdin sink from mux fanout."""
with _ffmpeg_sinks_lock:
_ffmpeg_sinks.discard(sink)
def _get_audio_sinks() -> tuple[object, ...]:
"""Snapshot current audio sinks for lock-free iteration."""
with _ffmpeg_sinks_lock:
return tuple(_ffmpeg_sinks)
def _stop_process(proc: Optional[subprocess.Popen]) -> None:
"""Terminate and unregister a subprocess if present."""
if not proc:
return
if proc.poll() is None:
try:
proc.terminate()
proc.wait(timeout=2)
except Exception:
try:
proc.kill()
except Exception:
pass
unregister_process(proc)
def _reset_runtime_state(*, release_device: bool) -> None:
"""Reset process + runtime state and optionally release SDR ownership."""
global dmr_rtl_process, dmr_dsd_process
global dmr_running, dmr_has_audio, dmr_active_device
_stop_process(dmr_dsd_process)
_stop_process(dmr_rtl_process)
dmr_rtl_process = None
dmr_dsd_process = None
dmr_running = False
dmr_has_audio = False
with _ffmpeg_sinks_lock:
_ffmpeg_sinks.clear()
if release_device and dmr_active_device is not None:
app_module.release_sdr_device(dmr_active_device)
dmr_active_device = None
def _dsd_audio_mux(dsd_stdout):
"""Mux thread: sole reader of dsd-fme stdout.
Always drains dsd-fme's audio output to prevent the process from
blocking on stdout writes (which would also freeze stderr / text
data). When streaming clients are connected, forwards data to all
active ffmpeg stdin sinks with silence fill during voice gaps.
"""
try:
while dmr_running:
ready, _, _ = select.select([dsd_stdout], [], [], 0.1)
if ready:
data = os.read(dsd_stdout.fileno(), 4096)
if not data:
break
sinks = _get_audio_sinks()
for sink in sinks:
try:
sink.write(data)
sink.flush()
except (BrokenPipeError, OSError, ValueError):
_unregister_audio_sink(sink)
else:
# No audio from decoder — feed silence if client connected
sinks = _get_audio_sinks()
for sink in sinks:
try:
sink.write(_SILENCE_CHUNK)
sink.flush()
except (BrokenPipeError, OSError, ValueError):
_unregister_audio_sink(sink)
except (OSError, ValueError):
pass
def _queue_put(event: dict):
"""Put an event on the DMR queue, dropping oldest if full."""
try:
dmr_queue.put_nowait(event)
except queue.Full:
try:
dmr_queue.get_nowait()
except queue.Empty:
pass
try:
dmr_queue.put_nowait(event)
except queue.Full:
pass
def stream_dsd_output(rtl_process: subprocess.Popen, dsd_process: subprocess.Popen):
"""Read DSD stderr output and push parsed events to the queue.
Uses select() with a timeout so we can send periodic heartbeat
events while readline() would otherwise block indefinitely during
silence (no signal being decoded).
"""
global dmr_running
try:
_queue_put({'type': 'status', 'text': 'started'})
last_heartbeat = time.time()
while dmr_running:
if dsd_process.poll() is not None:
break
# Wait up to 1s for data on stderr instead of blocking forever
ready, _, _ = select.select([dsd_process.stderr], [], [], 1.0)
if ready:
line = dsd_process.stderr.readline()
if not line:
if dsd_process.poll() is not None:
break
continue
text = line.decode('utf-8', errors='replace').strip()
if not text:
continue
logger.debug("DSD raw: %s", text)
parsed = parse_dsd_output(text)
if parsed:
_queue_put(parsed)
last_heartbeat = time.time()
else:
# No stderr output — send heartbeat so frontend knows
# decoder is still alive and listening
now = time.time()
if now - last_heartbeat >= _HEARTBEAT_INTERVAL:
_queue_put({
'type': 'heartbeat',
'timestamp': datetime.now().strftime('%H:%M:%S'),
})
last_heartbeat = now
except Exception as e:
logger.error(f"DSD stream error: {e}")
finally:
global dmr_active_device, dmr_rtl_process, dmr_dsd_process
global dmr_has_audio
dmr_running = False
dmr_has_audio = False
with _ffmpeg_sinks_lock:
_ffmpeg_sinks.clear()
# Capture exit info for diagnostics
rc = dsd_process.poll()
reason = 'stopped'
detail = ''
if rc is not None and rc != 0:
reason = 'crashed'
try:
remaining = dsd_process.stderr.read(1024)
if remaining:
detail = remaining.decode('utf-8', errors='replace').strip()[:200]
except Exception:
pass
logger.warning(f"DSD process exited with code {rc}: {detail}")
# Cleanup decoder + demod processes
_stop_process(dsd_process)
_stop_process(rtl_process)
dmr_rtl_process = None
dmr_dsd_process = None
_queue_put({'type': 'status', 'text': reason, 'exit_code': rc, 'detail': detail})
# Release SDR device
if dmr_active_device is not None:
app_module.release_sdr_device(dmr_active_device)
dmr_active_device = None
logger.info("DSD stream thread stopped")
# ============================================
# API ENDPOINTS
# ============================================
@dmr_bp.route('/tools')
def check_tools() -> Response:
"""Check for required tools."""
dsd_path, _ = find_dsd()
rtl_fm = find_rtl_fm()
rx_fm = find_rx_fm()
ffmpeg = find_ffmpeg()
return jsonify({
'dsd': dsd_path is not None,
'rtl_fm': rtl_fm is not None,
'rx_fm': rx_fm is not None,
'ffmpeg': ffmpeg is not None,
'available': dsd_path is not None and (rtl_fm is not None or rx_fm is not None),
'protocols': VALID_PROTOCOLS,
})
@dmr_bp.route('/start', methods=['POST'])
def start_dmr() -> Response:
"""Start digital voice decoding."""
global dmr_rtl_process, dmr_dsd_process, dmr_thread
global dmr_running, dmr_has_audio, dmr_active_device
dsd_path, is_fme = find_dsd()
if not dsd_path:
return jsonify({'status': 'error', 'message': 'dsd not found. Install dsd-fme or dsd.'}), 503
data = request.json or {}
try:
frequency = validate_frequency(data.get('frequency', 462.5625))
gain = int(validate_gain(data.get('gain', 40)))
device = validate_device_index(data.get('device', 0))
protocol = str(data.get('protocol', 'auto')).lower()
ppm = validate_ppm(data.get('ppm', 0))
except (ValueError, TypeError) as e:
return jsonify({'status': 'error', 'message': f'Invalid parameter: {e}'}), 400
sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower()
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
if protocol not in VALID_PROTOCOLS:
return jsonify({'status': 'error', 'message': f'Invalid protocol. Use: {", ".join(VALID_PROTOCOLS)}'}), 400
if sdr_type == SDRType.RTL_SDR:
if not find_rtl_fm():
return jsonify({'status': 'error', 'message': 'rtl_fm not found. Install rtl-sdr tools.'}), 503
else:
if not find_rx_fm():
return jsonify({
'status': 'error',
'message': f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.'
}), 503
# Clear stale queue
try:
while True:
dmr_queue.get_nowait()
except queue.Empty:
pass
# Reserve running state before we start claiming resources/processes
# so concurrent /start requests cannot race each other.
with dmr_lock:
if dmr_running:
return jsonify({'status': 'error', 'message': 'Already running'}), 409
dmr_running = True
dmr_has_audio = False
# Claim SDR device — use protocol name so the device panel shows
# "D-STAR", "P25", etc. instead of always "DMR"
mode_label = protocol.upper() if protocol != 'auto' else 'DMR'
error = app_module.claim_sdr_device(device, mode_label)
if error:
with dmr_lock:
dmr_running = False
return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409
dmr_active_device = device
# Build FM demodulation command via SDR abstraction.
try:
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
builder = SDRFactory.get_builder(sdr_type)
rtl_cmd = builder.build_fm_demod_command(
device=sdr_device,
frequency_mhz=frequency,
sample_rate=48000,
gain=float(gain) if gain > 0 else None,
ppm=int(ppm) if ppm != 0 else None,
modulation='fm',
squelch=None,
bias_t=bool(data.get('bias_t', False)),
)
if sdr_type == SDRType.RTL_SDR:
# Keep squelch fully open for digital bitstreams.
rtl_cmd.extend(['-l', '0'])
except Exception as e:
_reset_runtime_state(release_device=True)
return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500
# Build DSD command
# Audio output: pipe decoded audio (8kHz s16le PCM) to stdout for
# ffmpeg transcoding. Both dsd-fme and classic dsd support '-o -'.
# If ffmpeg is unavailable, fall back to discarding audio.
ffmpeg_path = find_ffmpeg()
if ffmpeg_path:
audio_out = '-'
else:
audio_out = 'null' if is_fme else '-'
logger.warning("ffmpeg not found — audio streaming disabled, data-only mode")
dsd_cmd = [dsd_path, '-i', '-', '-o', audio_out]
if is_fme:
dsd_cmd.extend(_DSD_FME_PROTOCOL_FLAGS.get(protocol, []))
dsd_cmd.extend(_DSD_FME_MODULATION.get(protocol, []))
# Event log to stderr so we capture TG/Source/Voice data that
# dsd-fme may not output on stderr by default.
dsd_cmd.extend(['-J', '/dev/stderr'])
# Relax CRC checks for marginal signals — lets more frames
# through at the cost of occasional decode errors.
if data.get('relaxCrc', False):
dsd_cmd.append('-F')
else:
dsd_cmd.extend(_DSD_PROTOCOL_FLAGS.get(protocol, []))
try:
dmr_rtl_process = subprocess.Popen(
rtl_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
register_process(dmr_rtl_process)
# DSD stdout → PIPE when ffmpeg available (audio pipeline),
# otherwise DEVNULL (data-only mode)
dsd_stdout = subprocess.PIPE if ffmpeg_path else subprocess.DEVNULL
dmr_dsd_process = subprocess.Popen(
dsd_cmd,
stdin=dmr_rtl_process.stdout,
stdout=dsd_stdout,
stderr=subprocess.PIPE,
)
register_process(dmr_dsd_process)
# Allow rtl_fm to send directly to dsd
dmr_rtl_process.stdout.close()
# Start mux thread: always drains dsd-fme stdout to prevent the
# process from blocking (which would freeze stderr / text data).
# ffmpeg is started lazily per-client in /dmr/audio/stream.
if ffmpeg_path and dmr_dsd_process.stdout:
dmr_has_audio = True
threading.Thread(
target=_dsd_audio_mux,
args=(dmr_dsd_process.stdout,),
daemon=True,
).start()
time.sleep(0.3)
rtl_rc = dmr_rtl_process.poll()
dsd_rc = dmr_dsd_process.poll()
if rtl_rc is not None or dsd_rc is not None:
# Process died — capture stderr for diagnostics
rtl_err = ''
if dmr_rtl_process.stderr:
rtl_err = dmr_rtl_process.stderr.read().decode('utf-8', errors='replace')[:500]
dsd_err = ''
if dmr_dsd_process.stderr:
dsd_err = dmr_dsd_process.stderr.read().decode('utf-8', errors='replace')[:500]
logger.error(f"DSD pipeline died: rtl_fm rc={rtl_rc} err={rtl_err!r}, dsd rc={dsd_rc} err={dsd_err!r}")
# Terminate surviving processes and release resources.
_reset_runtime_state(release_device=True)
# Surface a clear error to the user
detail = rtl_err.strip() or dsd_err.strip()
if 'usb_claim_interface' in rtl_err or 'Failed to open' in rtl_err:
msg = f'SDR device {device} is busy — it may be in use by another mode or process. Try a different device.'
elif detail:
msg = f'Failed to start DSD pipeline: {detail}'
else:
msg = 'Failed to start DSD pipeline'
return jsonify({'status': 'error', 'message': msg}), 500
# Drain rtl_fm stderr in background to prevent pipe blocking
def _drain_rtl_stderr(proc):
try:
for line in proc.stderr:
pass
except Exception:
pass
threading.Thread(target=_drain_rtl_stderr, args=(dmr_rtl_process,), daemon=True).start()
dmr_thread = threading.Thread(
target=stream_dsd_output,
args=(dmr_rtl_process, dmr_dsd_process),
daemon=True,
)
dmr_thread.start()
return jsonify({
'status': 'started',
'frequency': frequency,
'protocol': protocol,
'sdr_type': sdr_type.value,
'has_audio': dmr_has_audio,
})
except Exception as e:
logger.error(f"Failed to start DMR: {e}")
_reset_runtime_state(release_device=True)
return jsonify({'status': 'error', 'message': str(e)}), 500
@dmr_bp.route('/stop', methods=['POST'])
def stop_dmr() -> Response:
"""Stop digital voice decoding."""
with dmr_lock:
_reset_runtime_state(release_device=True)
return jsonify({'status': 'stopped'})
@dmr_bp.route('/status')
def dmr_status() -> Response:
"""Get DMR decoder status."""
return jsonify({
'running': dmr_running,
'device': dmr_active_device,
'has_audio': dmr_has_audio,
})
@dmr_bp.route('/audio/stream')
def stream_dmr_audio() -> Response:
"""Stream decoded digital voice audio as WAV.
Starts a per-client ffmpeg encoder. The global mux thread
(_dsd_audio_mux) forwards DSD audio to this ffmpeg's stdin while
the client is connected, and discards audio otherwise. This avoids
the pipe-buffer deadlock that occurs when ffmpeg is started at
decoder launch (its stdout fills up before any HTTP client reads
it, back-pressuring the entire pipeline and freezing stderr/text
data output).
"""
if not dmr_running or not dmr_has_audio:
return Response(b'', mimetype='audio/wav', status=204)
ffmpeg_path = find_ffmpeg()
if not ffmpeg_path:
return Response(b'', mimetype='audio/wav', status=503)
encoder_cmd = [
ffmpeg_path, '-hide_banner', '-loglevel', 'error',
'-fflags', 'nobuffer', '-flags', 'low_delay',
'-probesize', '32', '-analyzeduration', '0',
'-f', 's16le', '-ar', '8000', '-ac', '1', '-i', 'pipe:0',
'-acodec', 'pcm_s16le', '-ar', '44100', '-f', 'wav', 'pipe:1',
]
audio_proc = subprocess.Popen(
encoder_cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
# Drain ffmpeg stderr to prevent blocking
threading.Thread(
target=lambda p: [None for _ in p.stderr],
args=(audio_proc,), daemon=True,
).start()
if audio_proc.stdin:
_register_audio_sink(audio_proc.stdin)
def generate():
try:
while dmr_running and audio_proc.poll() is None:
ready, _, _ = select.select([audio_proc.stdout], [], [], 2.0)
if ready:
chunk = audio_proc.stdout.read(4096)
if chunk:
yield chunk
else:
break
else:
if audio_proc.poll() is not None:
break
except GeneratorExit:
pass
except Exception as e:
logger.error(f"DMR audio stream error: {e}")
finally:
# Disconnect mux → ffmpeg, then clean up
if audio_proc.stdin:
_unregister_audio_sink(audio_proc.stdin)
try:
audio_proc.stdin.close()
except Exception:
pass
try:
audio_proc.terminate()
audio_proc.wait(timeout=2)
except Exception:
try:
audio_proc.kill()
except Exception:
pass
return Response(
generate(),
mimetype='audio/wav',
headers={
'Content-Type': 'audio/wav',
'Cache-Control': 'no-cache, no-store',
'X-Accel-Buffering': 'no',
'Transfer-Encoding': 'chunked',
},
)
@dmr_bp.route('/stream')
def stream_dmr() -> Response:
"""SSE stream for DMR decoder events."""
def generate() -> Generator[str, None, None]:
last_keepalive = time.time()
while True:
try:
msg = dmr_queue.get(timeout=SSE_QUEUE_TIMEOUT)
last_keepalive = time.time()
try:
process_event('dmr', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg)
except queue.Empty:
now = time.time()
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
return response
+97 -82
View File
@@ -6,7 +6,7 @@ distress and safety communications per ITU-R M.493.
from __future__ import annotations
import json
import contextlib
import logging
import os
import pty
@@ -16,31 +16,36 @@ import shutil
import subprocess
import threading
import time
from datetime import datetime
from typing import Any, Generator
from typing import Any
from flask import Blueprint, jsonify, request, Response
from flask import Blueprint, Response, jsonify, request
import app as app_module
from utils.constants import (
DSC_VHF_FREQUENCY_MHZ,
DSC_SAMPLE_RATE,
DSC_TERMINATE_TIMEOUT,
DSC_VHF_FREQUENCY_MHZ,
)
from utils.database import (
store_dsc_alert,
get_dsc_alerts,
get_dsc_alert,
acknowledge_dsc_alert,
get_dsc_alert,
get_dsc_alert_summary,
get_dsc_alerts,
store_dsc_alert,
)
from utils.dsc.parser import parse_dsc_message
from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.validation import validate_device_index, validate_gain
from utils.sdr import SDRFactory, SDRType
from utils.dependencies import get_tool_path
from utils.dsc.parser import parse_dsc_message
from utils.event_pipeline import process_event
from utils.process import register_process, unregister_process
from utils.responses import api_error
from utils.sdr import SDRFactory, SDRType
from utils.sse import sse_stream_fanout
from utils.validation import (
validate_device_index,
validate_gain,
validate_rtl_tcp_host,
validate_rtl_tcp_port,
)
logger = logging.getLogger('intercept.dsc')
@@ -51,6 +56,7 @@ dsc_running = False
# Track which device is being used
dsc_active_device: int | None = None
dsc_active_sdr_type: str | None = None
def _get_dsc_decoder_path() -> str | None:
@@ -76,8 +82,8 @@ def _check_dsc_tools() -> dict:
# Check for scipy/numpy (needed for decoder)
scipy_available = False
try:
import scipy
import numpy
import scipy
scipy_available = True
except ImportError:
pass
@@ -171,11 +177,9 @@ def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> Non
'error': str(e)
})
finally:
global dsc_active_device
try:
global dsc_active_device, dsc_active_sdr_type
with contextlib.suppress(OSError):
os.close(master_fd)
except OSError:
pass
dsc_running = False
# Cleanup both processes
with app_module.dsc_lock:
@@ -186,10 +190,8 @@ def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> Non
proc.terminate()
proc.wait(timeout=2)
except Exception:
try:
with contextlib.suppress(Exception):
proc.kill()
except Exception:
pass
unregister_process(proc)
app_module.dsc_queue.put({'type': 'status', 'status': 'stopped'})
with app_module.dsc_lock:
@@ -197,8 +199,9 @@ def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> Non
app_module.dsc_rtl_process = None
# Release SDR device
if dsc_active_device is not None:
app_module.release_sdr_device(dsc_active_device)
app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
dsc_active_device = None
dsc_active_sdr_type = None
def _store_critical_alert(msg: dict) -> None:
@@ -331,18 +334,32 @@ def start_decoding() -> Response:
'message': str(e)
}), 400
# Check if device is available using centralized registry
global dsc_active_device
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'dsc')
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
# Get SDR type from request
sdr_type_str = data.get('sdr_type', 'rtlsdr')
dsc_active_device = device_int
# Check for rtl_tcp (remote SDR) connection
rtl_tcp_host = data.get('rtl_tcp_host')
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
# Check if device is available using centralized registry (skip for remote rtl_tcp)
global dsc_active_device, dsc_active_sdr_type
if not rtl_tcp_host:
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'dsc', sdr_type_str)
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
dsc_active_device = device_int
dsc_active_sdr_type = sdr_type_str
# Clear queue
while not app_module.dsc_queue.empty():
@@ -351,22 +368,32 @@ def start_decoding() -> Response:
except queue.Empty:
break
# Build rtl_fm command
rtl_fm_path = tools['rtl_fm']['path']
# Build rtl_fm command via SDR abstraction layer
decoder_path = tools['dsc_decoder']['path']
# rtl_fm command for DSC decoding
# DSC uses narrow FM at 156.525 MHz with 48kHz sample rate
rtl_cmd = [
rtl_fm_path,
'-f', f'{DSC_VHF_FREQUENCY_MHZ}M',
'-s', str(DSC_SAMPLE_RATE),
'-d', str(device),
'-g', str(gain),
'-M', 'fm', # FM demodulation
'-l', '0', # No squelch for DSC
'-E', 'dc' # DC blocking filter
]
if rtl_tcp_host:
try:
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
except ValueError as e:
return api_error(str(e), 400)
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
else:
sdr_device = SDRFactory.create_default_device(sdr_type, index=int(device))
builder = SDRFactory.get_builder(sdr_device.sdr_type)
rtl_cmd = list(builder.build_fm_demod_command(
device=sdr_device,
frequency_mhz=DSC_VHF_FREQUENCY_MHZ,
sample_rate=DSC_SAMPLE_RATE,
gain=float(gain) if gain and str(gain) != '0' else None,
modulation='fm',
squelch=0,
))
# Ensure trailing '-' for stdin piping and add DC blocking filter
if rtl_cmd and rtl_cmd[-1] == '-':
rtl_cmd = rtl_cmd[:-1] + ['-E', 'dc', '-']
# Decoder command
decoder_cmd = [decoder_path]
@@ -434,14 +461,13 @@ def start_decoding() -> Response:
rtl_process.terminate()
rtl_process.wait(timeout=2)
except Exception:
try:
with contextlib.suppress(Exception):
rtl_process.kill()
except Exception:
pass
# Release device on failure
if dsc_active_device is not None:
app_module.release_sdr_device(dsc_active_device)
app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
dsc_active_device = None
dsc_active_sdr_type = None
return jsonify({
'status': 'error',
'message': f'Tool not found: {e.filename}'
@@ -452,14 +478,13 @@ def start_decoding() -> Response:
rtl_process.terminate()
rtl_process.wait(timeout=2)
except Exception:
try:
with contextlib.suppress(Exception):
rtl_process.kill()
except Exception:
pass
# Release device on failure
if dsc_active_device is not None:
app_module.release_sdr_device(dsc_active_device)
app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
dsc_active_device = None
dsc_active_sdr_type = None
logger.error(f"Failed to start DSC decoder: {e}")
return jsonify({
'status': 'error',
@@ -470,7 +495,7 @@ def start_decoding() -> Response:
@dsc_bp.route('/stop', methods=['POST'])
def stop_decoding() -> Response:
"""Stop DSC decoder."""
global dsc_running, dsc_active_device
global dsc_running, dsc_active_device, dsc_active_sdr_type
with app_module.dsc_lock:
if not app_module.dsc_process:
@@ -484,10 +509,8 @@ def stop_decoding() -> Response:
app_module.dsc_rtl_process.terminate()
app_module.dsc_rtl_process.wait(timeout=DSC_TERMINATE_TIMEOUT)
except subprocess.TimeoutExpired:
try:
with contextlib.suppress(OSError):
app_module.dsc_rtl_process.kill()
except OSError:
pass
except OSError:
pass
@@ -497,10 +520,8 @@ def stop_decoding() -> Response:
app_module.dsc_process.terminate()
app_module.dsc_process.wait(timeout=DSC_TERMINATE_TIMEOUT)
except subprocess.TimeoutExpired:
try:
with contextlib.suppress(OSError):
app_module.dsc_process.kill()
except OSError:
pass
except OSError:
pass
@@ -509,8 +530,9 @@ def stop_decoding() -> Response:
# Release device from registry
if dsc_active_device is not None:
app_module.release_sdr_device(dsc_active_device)
app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
dsc_active_device = None
dsc_active_sdr_type = None
return jsonify({'status': 'stopped'})
@@ -518,26 +540,19 @@ def stop_decoding() -> Response:
@dsc_bp.route('/stream')
def stream() -> Response:
"""SSE stream for real-time DSC messages."""
def generate() -> Generator[str, None, None]:
last_keepalive = time.time()
keepalive_interval = 30.0
def _on_msg(msg: dict[str, Any]) -> None:
process_event('dsc', msg, msg.get('type'))
while True:
try:
msg = app_module.dsc_queue.get(timeout=1)
last_keepalive = time.time()
try:
process_event('dsc', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg)
except queue.Empty:
now = time.time()
if now - last_keepalive >= keepalive_interval:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream')
response = Response(
sse_stream_fanout(
source_queue=app_module.dsc_queue,
channel_key='dsc',
timeout=1.0,
keepalive_interval=30.0,
on_message=_on_msg,
),
mimetype='text/event-stream',
)
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
+16 -21
View File
@@ -3,8 +3,6 @@
from __future__ import annotations
import queue
import time
from collections.abc import Generator
from flask import Blueprint, Response, jsonify
@@ -21,7 +19,7 @@ from utils.gps import (
stop_gpsd_daemon,
)
from utils.logging import get_logger
from utils.sse import format_sse
from utils.sse import sse_stream_fanout
logger = get_logger('intercept.gps')
@@ -68,6 +66,9 @@ def auto_connect_gps():
# Check if already running
reader = get_gps_reader()
if reader and reader.is_running:
# Ensure stream callbacks are attached for this process.
reader.add_callback(_position_callback)
reader.add_sky_callback(_sky_callback)
position = reader.position
sky = reader.sky
return jsonify({
@@ -211,9 +212,10 @@ def get_satellites():
if not reader or not reader.is_running:
return jsonify({
'status': 'error',
'status': 'waiting',
'running': False,
'message': 'GPS client not running'
}), 400
})
sky = reader.sky
if sky:
@@ -231,22 +233,15 @@ def get_satellites():
@gps_bp.route('/stream')
def stream_gps():
"""SSE stream of GPS position and sky updates."""
def generate() -> Generator[str, None, None]:
last_keepalive = time.time()
keepalive_interval = 30.0
while True:
try:
data = _gps_queue.get(timeout=1)
last_keepalive = time.time()
yield format_sse(data)
except queue.Empty:
now = time.time()
if now - last_keepalive >= keepalive_interval:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream')
response = Response(
sse_stream_fanout(
source_queue=_gps_queue,
channel_key='gps',
timeout=1.0,
keepalive_interval=30.0,
),
mimetype='text/event-stream',
)
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
File diff suppressed because it is too large Load Diff
+523
View File
@@ -0,0 +1,523 @@
"""Receiver routes for radio monitoring and frequency scanning.
This package splits the listening post into sub-modules:
scanner - /scanner/*, /presets routes
audio - /audio/* routes
waterfall - /waterfall/* routes
tools - /tools, /signal/guess routes
"""
from __future__ import annotations
import os
import queue
import shutil
import signal
import struct
import subprocess
import threading
import time
from datetime import datetime
from typing import Dict, List, Optional
from flask import Blueprint
from utils.constants import (
PROCESS_TERMINATE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT,
)
from utils.event_pipeline import process_event
from utils.logging import get_logger
from utils.sdr import SDRFactory, SDRType
from utils.sse import sse_stream_fanout
logger = get_logger('intercept.receiver')
receiver_bp = Blueprint('receiver', __name__, url_prefix='/receiver')
# Deferred import to avoid circular import at module load time.
# app.py -> register_blueprints -> from .listening_post import receiver_bp
# must find receiver_bp already defined (above) before this import runs.
import contextlib
import app as app_module # noqa: E402
# ============================================
# GLOBAL STATE
# ============================================
# Audio demodulation state
audio_process = None
audio_rtl_process = None
audio_lock = threading.Lock()
audio_start_lock = threading.Lock()
audio_running = False
audio_frequency = 0.0
audio_modulation = 'fm'
audio_source = 'process'
audio_start_token = 0
# Scanner state
scanner_thread: threading.Thread | None = None
scanner_running = False
scanner_lock = threading.Lock()
scanner_paused = False
scanner_current_freq = 0.0
scanner_active_device: int | None = None
scanner_active_sdr_type: str = 'rtlsdr'
receiver_active_device: int | None = None
receiver_active_sdr_type: str = 'rtlsdr'
scanner_power_process: subprocess.Popen | None = None
scanner_config = {
'start_freq': 88.0,
'end_freq': 108.0,
'step': 0.1,
'modulation': 'wfm',
'squelch': 0,
'dwell_time': 10.0, # Seconds to stay on active frequency
'scan_delay': 0.1, # Seconds between frequency hops (keep low for fast scanning)
'device': 0,
'gain': 40,
'bias_t': False, # Bias-T power for external LNA
'sdr_type': 'rtlsdr', # SDR type: rtlsdr, hackrf, airspy, limesdr, sdrplay
'scan_method': 'power', # power (rtl_power) or classic (rtl_fm hop)
'snr_threshold': 8,
}
# Activity log
activity_log: list[dict] = []
activity_log_lock = threading.Lock()
MAX_LOG_ENTRIES = 500
# SSE queue for scanner events
scanner_queue: queue.Queue = queue.Queue(maxsize=100)
# Flag to trigger skip from API
scanner_skip_signal = False
# Waterfall / spectrogram state
waterfall_process: subprocess.Popen | None = None
waterfall_thread: threading.Thread | None = None
waterfall_running = False
waterfall_lock = threading.Lock()
waterfall_queue: queue.Queue = queue.Queue(maxsize=200)
waterfall_active_device: int | None = None
waterfall_active_sdr_type: str = 'rtlsdr'
waterfall_config = {
'start_freq': 88.0,
'end_freq': 108.0,
'bin_size': 10000,
'gain': 40,
'device': 0,
'max_bins': 1024,
'interval': 0.4,
}
# ============================================
# HELPER FUNCTIONS (shared across sub-modules)
# ============================================
VALID_MODULATIONS = ['fm', 'wfm', 'am', 'usb', 'lsb']
def find_rtl_fm() -> str | None:
"""Find rtl_fm binary."""
return shutil.which('rtl_fm')
def find_rtl_power() -> str | None:
"""Find rtl_power binary."""
return shutil.which('rtl_power')
def find_rx_fm() -> str | None:
"""Find rx_fm binary (SoapySDR FM demodulator for HackRF/Airspy/LimeSDR)."""
return shutil.which('rx_fm')
def find_ffmpeg() -> str | None:
"""Find ffmpeg for audio encoding."""
return shutil.which('ffmpeg')
def normalize_modulation(value: str) -> str:
"""Normalize and validate modulation string."""
mod = str(value or '').lower().strip()
if mod not in VALID_MODULATIONS:
raise ValueError(f'Invalid modulation. Use: {", ".join(VALID_MODULATIONS)}')
return mod
def _rtl_fm_demod_mode(modulation: str) -> str:
"""Map UI modulation names to rtl_fm demod tokens."""
mod = str(modulation or '').lower().strip()
return 'wbfm' if mod == 'wfm' else mod
def _wav_header(sample_rate: int = 48000, bits_per_sample: int = 16, channels: int = 1) -> bytes:
"""Create a streaming WAV header with unknown data length."""
bytes_per_sample = bits_per_sample // 8
byte_rate = sample_rate * channels * bytes_per_sample
block_align = channels * bytes_per_sample
return (
b'RIFF'
+ struct.pack('<I', 0xFFFFFFFF)
+ b'WAVE'
+ b'fmt '
+ struct.pack('<IHHIIHH', 16, 1, channels, sample_rate, byte_rate, block_align, bits_per_sample)
+ b'data'
+ struct.pack('<I', 0xFFFFFFFF)
)
def add_activity_log(event_type: str, frequency: float, details: str = ''):
"""Add entry to activity log."""
with activity_log_lock:
entry = {
'timestamp': datetime.utcnow().isoformat() + 'Z',
'type': event_type,
'frequency': frequency,
'details': details,
}
activity_log.insert(0, entry)
# Trim log
while len(activity_log) > MAX_LOG_ENTRIES:
activity_log.pop()
# Also push to SSE queue
with contextlib.suppress(queue.Full):
scanner_queue.put_nowait({
'type': 'log',
'entry': entry
})
def _start_audio_stream(
frequency: float,
modulation: str,
*,
device: int | None = None,
sdr_type: str | None = None,
gain: int | None = None,
squelch: int | None = None,
bias_t: bool | None = None,
):
"""Start audio streaming at given frequency."""
global audio_process, audio_rtl_process, audio_running, audio_frequency, audio_modulation
# Stop existing stream and snapshot config under lock
with audio_lock:
_stop_audio_stream_internal()
ffmpeg_path = find_ffmpeg()
if not ffmpeg_path:
logger.error("ffmpeg not found")
return
# Snapshot runtime tuning config so the spawned demod command cannot
# drift if shared scanner_config changes while startup is in-flight.
device_index = int(device if device is not None else scanner_config.get('device', 0))
gain_value = int(gain if gain is not None else scanner_config.get('gain', 40))
squelch_value = int(squelch if squelch is not None else scanner_config.get('squelch', 0))
bias_t_enabled = bool(scanner_config.get('bias_t', False) if bias_t is None else bias_t)
sdr_type_str = str(sdr_type if sdr_type is not None else scanner_config.get('sdr_type', 'rtlsdr')).lower()
# Build commands outside lock (no blocking I/O, just command construction)
try:
resolved_sdr_type = SDRType(sdr_type_str)
except ValueError:
resolved_sdr_type = SDRType.RTL_SDR
# Set sample rates based on modulation
if modulation == 'wfm':
sample_rate = 170000
resample_rate = 32000
elif modulation in ['usb', 'lsb']:
sample_rate = 12000
resample_rate = 12000
else:
sample_rate = 24000
resample_rate = 24000
# Build the SDR command based on device type
if resolved_sdr_type == SDRType.RTL_SDR:
rtl_fm_path = find_rtl_fm()
if not rtl_fm_path:
logger.error("rtl_fm not found")
return
freq_hz = int(frequency * 1e6)
sdr_cmd = [
rtl_fm_path,
'-M', _rtl_fm_demod_mode(modulation),
'-f', str(freq_hz),
'-s', str(sample_rate),
'-r', str(resample_rate),
'-g', str(gain_value),
'-d', str(device_index),
'-l', str(squelch_value),
]
if bias_t_enabled:
sdr_cmd.append('-T')
else:
rx_fm_path = find_rx_fm()
if not rx_fm_path:
logger.error(f"rx_fm not found - required for {resolved_sdr_type.value}. Install SoapySDR utilities.")
return
sdr_device = SDRFactory.create_default_device(resolved_sdr_type, index=device_index)
builder = SDRFactory.get_builder(resolved_sdr_type)
sdr_cmd = builder.build_fm_demod_command(
device=sdr_device,
frequency_mhz=frequency,
sample_rate=resample_rate,
gain=float(gain_value),
modulation=modulation,
squelch=squelch_value,
bias_t=bias_t_enabled,
)
sdr_cmd[0] = rx_fm_path
encoder_cmd = [
ffmpeg_path,
'-hide_banner',
'-loglevel', 'error',
'-fflags', 'nobuffer',
'-flags', 'low_delay',
'-probesize', '32',
'-analyzeduration', '0',
'-f', 's16le',
'-ar', str(resample_rate),
'-ac', '1',
'-i', 'pipe:0',
'-acodec', 'pcm_s16le',
'-ar', '44100',
'-f', 'wav',
'pipe:1'
]
# Retry loop outside lock — spawning + health check sleeps don't block
# other operations. audio_start_lock already serializes callers.
try:
rtl_stderr_log = '/tmp/rtl_fm_stderr.log'
ffmpeg_stderr_log = '/tmp/ffmpeg_stderr.log'
logger.info(f"Starting audio: {frequency} MHz, mod={modulation}, device={device_index}")
new_rtl_proc = None
new_audio_proc = None
max_attempts = 3
for attempt in range(max_attempts):
new_rtl_proc = None
new_audio_proc = None
rtl_err_handle = None
ffmpeg_err_handle = None
try:
rtl_err_handle = open(rtl_stderr_log, 'w')
ffmpeg_err_handle = open(ffmpeg_stderr_log, 'w')
new_rtl_proc = subprocess.Popen(
sdr_cmd,
stdout=subprocess.PIPE,
stderr=rtl_err_handle,
bufsize=0,
start_new_session=True
)
new_audio_proc = subprocess.Popen(
encoder_cmd,
stdin=new_rtl_proc.stdout,
stdout=subprocess.PIPE,
stderr=ffmpeg_err_handle,
bufsize=0,
start_new_session=True
)
if new_rtl_proc.stdout:
new_rtl_proc.stdout.close()
finally:
if rtl_err_handle:
rtl_err_handle.close()
if ffmpeg_err_handle:
ffmpeg_err_handle.close()
# Brief delay to check if process started successfully
time.sleep(0.3)
if (new_rtl_proc and new_rtl_proc.poll() is not None) or (
new_audio_proc and new_audio_proc.poll() is not None
):
rtl_stderr = ''
ffmpeg_stderr = ''
try:
with open(rtl_stderr_log) as f:
rtl_stderr = f.read().strip()
except Exception:
pass
try:
with open(ffmpeg_stderr_log) as f:
ffmpeg_stderr = f.read().strip()
except Exception:
pass
if 'usb_claim_interface' in rtl_stderr and attempt < max_attempts - 1:
logger.warning(f"USB device busy (attempt {attempt + 1}/{max_attempts}), waiting for release...")
if new_audio_proc:
try:
new_audio_proc.terminate()
new_audio_proc.wait(timeout=0.5)
except Exception:
pass
if new_rtl_proc:
try:
new_rtl_proc.terminate()
new_rtl_proc.wait(timeout=0.5)
except Exception:
pass
time.sleep(1.0)
continue
if new_audio_proc and new_audio_proc.poll() is None:
try:
new_audio_proc.terminate()
new_audio_proc.wait(timeout=0.5)
except Exception:
pass
if new_rtl_proc and new_rtl_proc.poll() is None:
try:
new_rtl_proc.terminate()
new_rtl_proc.wait(timeout=0.5)
except Exception:
pass
new_audio_proc = None
new_rtl_proc = None
logger.error(
f"Audio pipeline exited immediately. rtl_fm stderr: {rtl_stderr}, ffmpeg stderr: {ffmpeg_stderr}"
)
return
# Pipeline started successfully
break
# Verify pipeline is still alive, then install under lock
if (
not new_audio_proc
or not new_rtl_proc
or new_audio_proc.poll() is not None
or new_rtl_proc.poll() is not None
):
logger.warning("Audio pipeline did not remain alive after startup")
# Clean up failed processes
if new_audio_proc:
try:
new_audio_proc.terminate()
new_audio_proc.wait(timeout=0.5)
except Exception:
pass
if new_rtl_proc:
try:
new_rtl_proc.terminate()
new_rtl_proc.wait(timeout=0.5)
except Exception:
pass
return
# Install processes under lock
with audio_lock:
audio_rtl_process = new_rtl_proc
audio_process = new_audio_proc
audio_running = True
audio_frequency = frequency
audio_modulation = modulation
logger.info(f"Audio stream started: {frequency} MHz ({modulation}) via {resolved_sdr_type.value}")
except Exception as e:
logger.error(f"Failed to start audio stream: {e}")
def _stop_audio_stream():
"""Stop audio streaming."""
with audio_lock:
_stop_audio_stream_internal()
def _stop_audio_stream_internal():
"""Internal stop (must hold lock)."""
global audio_process, audio_rtl_process, audio_running, audio_frequency, audio_source
# Set flag first to stop any streaming
audio_running = False
audio_frequency = 0.0
previous_source = audio_source
audio_source = 'process'
if previous_source == 'waterfall':
try:
from routes.waterfall_websocket import stop_shared_monitor_from_capture
stop_shared_monitor_from_capture()
except Exception:
pass
had_processes = audio_process is not None or audio_rtl_process is not None
# Kill the pipeline processes and their groups
if audio_process:
try:
# Kill entire process group (SDR demod + ffmpeg)
try:
os.killpg(os.getpgid(audio_process.pid), signal.SIGKILL)
except (ProcessLookupError, PermissionError):
audio_process.kill()
audio_process.wait(timeout=0.5)
except Exception:
pass
if audio_rtl_process:
try:
try:
os.killpg(os.getpgid(audio_rtl_process.pid), signal.SIGKILL)
except (ProcessLookupError, PermissionError):
audio_rtl_process.kill()
audio_rtl_process.wait(timeout=0.5)
except Exception:
pass
audio_process = None
audio_rtl_process = None
# Brief pause for SDR device USB interface to be released by kernel.
# The _start_audio_stream retry loop handles longer contention windows
# so only a minimal delay is needed here.
if had_processes:
time.sleep(0.15)
def _stop_waterfall_internal() -> None:
"""Stop the waterfall display and release resources."""
global waterfall_running, waterfall_process, waterfall_active_device, waterfall_active_sdr_type
waterfall_running = False
if waterfall_process and waterfall_process.poll() is None:
try:
waterfall_process.terminate()
waterfall_process.wait(timeout=1)
except Exception:
with contextlib.suppress(Exception):
waterfall_process.kill()
waterfall_process = None
if waterfall_active_device is not None:
app_module.release_sdr_device(waterfall_active_device, waterfall_active_sdr_type)
waterfall_active_device = None
waterfall_active_sdr_type = 'rtlsdr'
# ============================================
# Import sub-modules to register routes on receiver_bp
# ============================================
from . import (
audio, # noqa: E402, F401
scanner, # noqa: E402, F401
tools, # noqa: E402, F401
waterfall, # noqa: E402, F401
)
+496
View File
@@ -0,0 +1,496 @@
"""Audio routes for manual listening and audio streaming."""
from __future__ import annotations
import contextlib
import os
import select
import subprocess
import time
from flask import Response, jsonify, request
import routes.listening_post as _state
from . import (
_start_audio_stream,
_stop_audio_stream,
_stop_waterfall_internal,
_wav_header,
app_module,
logger,
normalize_modulation,
receiver_bp,
scanner_config,
)
# ============================================
# MANUAL AUDIO ENDPOINTS (for direct listening)
# ============================================
@receiver_bp.route('/audio/start', methods=['POST'])
def start_audio() -> Response:
"""Start audio at specific frequency (manual mode)."""
data = request.json or {}
try:
frequency = float(data.get('frequency', 0))
modulation = normalize_modulation(data.get('modulation', 'wfm'))
squelch = int(data['squelch']) if data.get('squelch') is not None else 0
gain = int(data['gain']) if data.get('gain') is not None else 40
device = int(data['device']) if data.get('device') is not None else 0
sdr_type = str(data.get('sdr_type', 'rtlsdr')).lower()
request_token_raw = data.get('request_token')
request_token = int(request_token_raw) if request_token_raw is not None else None
bias_t_raw = data.get('bias_t', scanner_config.get('bias_t', False))
if isinstance(bias_t_raw, str):
bias_t = bias_t_raw.strip().lower() in {'1', 'true', 'yes', 'on'}
else:
bias_t = bool(bias_t_raw)
except (ValueError, TypeError) as e:
return jsonify({
'status': 'error',
'message': f'Invalid parameter: {e}'
}), 400
if frequency <= 0:
return jsonify({
'status': 'error',
'message': 'frequency is required'
}), 400
valid_sdr_types = ['rtlsdr', 'hackrf', 'airspy', 'limesdr', 'sdrplay']
if sdr_type not in valid_sdr_types:
return jsonify({
'status': 'error',
'message': f'Invalid sdr_type. Use: {", ".join(valid_sdr_types)}'
}), 400
with _state.audio_start_lock:
if request_token is not None:
if request_token < _state.audio_start_token:
return jsonify({
'status': 'stale',
'message': 'Superseded audio start request',
'source': _state.audio_source,
'superseded': True,
'current_token': _state.audio_start_token,
}), 409
_state.audio_start_token = request_token
else:
_state.audio_start_token += 1
request_token = _state.audio_start_token
# Grab scanner refs inside lock, signal stop, clear state
need_scanner_teardown = False
scanner_thread_ref = None
scanner_proc_ref = None
if _state.scanner_running:
_state.scanner_running = False
if _state.scanner_active_device is not None:
app_module.release_sdr_device(_state.scanner_active_device, _state.scanner_active_sdr_type)
_state.scanner_active_device = None
_state.scanner_active_sdr_type = 'rtlsdr'
scanner_thread_ref = _state.scanner_thread
scanner_proc_ref = _state.scanner_power_process
_state.scanner_power_process = None
need_scanner_teardown = True
# Update config for audio
scanner_config['squelch'] = squelch
scanner_config['gain'] = gain
scanner_config['device'] = device
scanner_config['sdr_type'] = sdr_type
scanner_config['bias_t'] = bias_t
# Scanner teardown outside lock (blocking: thread join, process wait, pkill, sleep)
if need_scanner_teardown:
if scanner_thread_ref and scanner_thread_ref.is_alive():
with contextlib.suppress(Exception):
scanner_thread_ref.join(timeout=2.0)
if scanner_proc_ref and scanner_proc_ref.poll() is None:
try:
scanner_proc_ref.terminate()
scanner_proc_ref.wait(timeout=1)
except Exception:
with contextlib.suppress(Exception):
scanner_proc_ref.kill()
with contextlib.suppress(Exception):
subprocess.run(['pkill', '-9', 'rtl_power'], capture_output=True, timeout=0.5)
time.sleep(0.5)
# Re-acquire lock for waterfall check and device claim
with _state.audio_start_lock:
# Preferred path: when waterfall WebSocket is active on the same SDR,
# derive monitor audio from that IQ stream instead of spawning rtl_fm.
try:
from routes.waterfall_websocket import (
get_shared_capture_status,
start_shared_monitor_from_capture,
)
shared = get_shared_capture_status()
if shared.get('running') and shared.get('device') == device:
_stop_audio_stream()
ok, msg = start_shared_monitor_from_capture(
device=device,
frequency_mhz=frequency,
modulation=modulation,
squelch=squelch,
)
if ok:
_state.audio_running = True
_state.audio_frequency = frequency
_state.audio_modulation = modulation
_state.audio_source = 'waterfall'
# Shared monitor uses the waterfall's existing SDR claim.
if _state.receiver_active_device is not None:
app_module.release_sdr_device(_state.receiver_active_device, _state.receiver_active_sdr_type)
_state.receiver_active_device = None
_state.receiver_active_sdr_type = 'rtlsdr'
return jsonify({
'status': 'started',
'frequency': frequency,
'modulation': modulation,
'source': 'waterfall',
'request_token': request_token,
})
logger.warning(f"Shared waterfall monitor unavailable: {msg}")
except Exception as e:
logger.debug(f"Shared waterfall monitor probe failed: {e}")
# Stop waterfall if it's using the same SDR (SSE path)
if _state.waterfall_running and _state.waterfall_active_device == device:
_stop_waterfall_internal()
time.sleep(0.2)
# Claim device for listening audio. The WebSocket waterfall handler
# may still be tearing down its IQ capture process (thread join +
# safe_terminate can take several seconds), so we retry with back-off
# to give the USB device time to be fully released.
if _state.receiver_active_device is None or _state.receiver_active_device != device:
if _state.receiver_active_device is not None:
app_module.release_sdr_device(_state.receiver_active_device, _state.receiver_active_sdr_type)
_state.receiver_active_device = None
_state.receiver_active_sdr_type = 'rtlsdr'
error = None
max_claim_attempts = 6
for attempt in range(max_claim_attempts):
error = app_module.claim_sdr_device(device, 'receiver', sdr_type)
if not error:
break
if attempt < max_claim_attempts - 1:
logger.debug(
f"Device claim attempt {attempt + 1}/{max_claim_attempts} "
f"failed, retrying in 0.5s: {error}"
)
time.sleep(0.5)
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
_state.receiver_active_device = device
_state.receiver_active_sdr_type = sdr_type
_start_audio_stream(
frequency,
modulation,
device=device,
sdr_type=sdr_type,
gain=gain,
squelch=squelch,
bias_t=bias_t,
)
if _state.audio_running:
_state.audio_source = 'process'
return jsonify({
'status': 'started',
'frequency': _state.audio_frequency,
'modulation': _state.audio_modulation,
'source': 'process',
'request_token': request_token,
})
# Avoid leaving a stale device claim after startup failure.
if _state.receiver_active_device is not None:
app_module.release_sdr_device(_state.receiver_active_device, _state.receiver_active_sdr_type)
_state.receiver_active_device = None
_state.receiver_active_sdr_type = 'rtlsdr'
start_error = ''
for log_path in ('/tmp/rtl_fm_stderr.log', '/tmp/ffmpeg_stderr.log'):
try:
with open(log_path) as handle:
content = handle.read().strip()
if content:
start_error = content.splitlines()[-1]
break
except Exception:
continue
message = 'Failed to start audio. Check SDR device.'
if start_error:
message = f'Failed to start audio: {start_error}'
return jsonify({
'status': 'error',
'message': message
}), 500
@receiver_bp.route('/audio/stop', methods=['POST'])
def stop_audio() -> Response:
"""Stop audio."""
_stop_audio_stream()
if _state.receiver_active_device is not None:
app_module.release_sdr_device(_state.receiver_active_device, _state.receiver_active_sdr_type)
_state.receiver_active_device = None
_state.receiver_active_sdr_type = 'rtlsdr'
return jsonify({'status': 'stopped'})
@receiver_bp.route('/audio/status')
def audio_status() -> Response:
"""Get audio status."""
running = _state.audio_running
if _state.audio_source == 'waterfall':
try:
from routes.waterfall_websocket import get_shared_capture_status
shared = get_shared_capture_status()
running = bool(shared.get('running') and shared.get('monitor_enabled'))
except Exception:
running = False
return jsonify({
'running': running,
'frequency': _state.audio_frequency,
'modulation': _state.audio_modulation,
'source': _state.audio_source,
})
@receiver_bp.route('/audio/debug')
def audio_debug() -> Response:
"""Get audio debug status and recent stderr logs."""
rtl_log_path = '/tmp/rtl_fm_stderr.log'
ffmpeg_log_path = '/tmp/ffmpeg_stderr.log'
sample_path = '/tmp/audio_probe.bin'
def _read_log(path: str) -> str:
try:
with open(path) as handle:
return handle.read().strip()
except Exception:
return ''
shared = {}
if _state.audio_source == 'waterfall':
try:
from routes.waterfall_websocket import get_shared_capture_status
shared = get_shared_capture_status()
except Exception:
shared = {}
return jsonify({
'running': _state.audio_running,
'frequency': _state.audio_frequency,
'modulation': _state.audio_modulation,
'source': _state.audio_source,
'sdr_type': scanner_config.get('sdr_type', 'rtlsdr'),
'device': scanner_config.get('device', 0),
'gain': scanner_config.get('gain', 0),
'squelch': scanner_config.get('squelch', 0),
'audio_process_alive': bool(_state.audio_process and _state.audio_process.poll() is None),
'shared_capture': shared,
'rtl_fm_stderr': _read_log(rtl_log_path),
'ffmpeg_stderr': _read_log(ffmpeg_log_path),
'audio_probe_bytes': os.path.getsize(sample_path) if os.path.exists(sample_path) else 0,
})
@receiver_bp.route('/audio/probe')
def audio_probe() -> Response:
"""Grab a small chunk of audio bytes from the pipeline for debugging."""
if _state.audio_source == 'waterfall':
try:
from routes.waterfall_websocket import read_shared_monitor_audio_chunk
data = read_shared_monitor_audio_chunk(timeout=2.0)
if not data:
return jsonify({'status': 'error', 'message': 'no shared audio data available'}), 504
sample_path = '/tmp/audio_probe.bin'
with open(sample_path, 'wb') as handle:
handle.write(data)
return jsonify({'status': 'ok', 'bytes': len(data), 'source': 'waterfall'})
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}), 500
if not _state.audio_process or not _state.audio_process.stdout:
return jsonify({'status': 'error', 'message': 'audio process not running'}), 400
sample_path = '/tmp/audio_probe.bin'
size = 0
try:
ready, _, _ = select.select([_state.audio_process.stdout], [], [], 2.0)
if not ready:
return jsonify({'status': 'error', 'message': 'no data available'}), 504
data = _state.audio_process.stdout.read(4096)
if not data:
return jsonify({'status': 'error', 'message': 'no data read'}), 504
with open(sample_path, 'wb') as handle:
handle.write(data)
size = len(data)
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}), 500
return jsonify({'status': 'ok', 'bytes': size})
@receiver_bp.route('/audio/stream')
def stream_audio() -> Response:
"""Stream WAV audio."""
request_token_raw = request.args.get('request_token')
request_token = None
if request_token_raw is not None:
try:
request_token = int(request_token_raw)
except (ValueError, TypeError):
request_token = None
if request_token is not None and request_token < _state.audio_start_token:
return Response(b'', mimetype='audio/wav', status=204)
if _state.audio_source == 'waterfall':
for _ in range(40):
if _state.audio_running:
break
time.sleep(0.05)
if not _state.audio_running:
return Response(b'', mimetype='audio/wav', status=204)
def generate_shared():
try:
from routes.waterfall_websocket import (
get_shared_capture_status,
read_shared_monitor_audio_chunk,
)
except Exception:
return
# Browser expects an immediate WAV header.
yield _wav_header(sample_rate=48000)
inactive_since: float | None = None
while _state.audio_running and _state.audio_source == 'waterfall':
if request_token is not None and request_token < _state.audio_start_token:
break
chunk = read_shared_monitor_audio_chunk(timeout=1.0)
if chunk:
inactive_since = None
yield chunk
continue
shared = get_shared_capture_status()
if shared.get('running') and shared.get('monitor_enabled'):
inactive_since = None
continue
if inactive_since is None:
inactive_since = time.monotonic()
continue
if (time.monotonic() - inactive_since) < 4.0:
continue
if not shared.get('running') or not shared.get('monitor_enabled'):
_state.audio_running = False
_state.audio_source = 'process'
break
return Response(
generate_shared(),
mimetype='audio/wav',
headers={
'Content-Type': 'audio/wav',
'Cache-Control': 'no-cache, no-store',
'X-Accel-Buffering': 'no',
'Transfer-Encoding': 'chunked',
}
)
# Wait for audio process to be ready (up to 2 seconds).
for _ in range(40):
if _state.audio_running and _state.audio_process:
break
time.sleep(0.05)
if not _state.audio_running or not _state.audio_process:
return Response(b'', mimetype='audio/wav', status=204)
def generate():
# Capture local reference to avoid race condition with stop
proc = _state.audio_process
if not proc or not proc.stdout:
return
try:
# Drain stale audio that accumulated in the pipe buffer
# between pipeline start and stream connection. Keep the
# first chunk (contains WAV header) and discard the rest
# so the browser starts close to real-time.
header_chunk = None
while True:
ready, _, _ = select.select([proc.stdout], [], [], 0)
if not ready:
break
chunk = proc.stdout.read(8192)
if not chunk:
break
if header_chunk is None:
header_chunk = chunk
if header_chunk:
yield header_chunk
# Stream real-time audio
first_chunk_deadline = time.time() + 20.0
warned_wait = False
while _state.audio_running and proc.poll() is None:
if request_token is not None and request_token < _state.audio_start_token:
break
# Use select to avoid blocking forever
ready, _, _ = select.select([proc.stdout], [], [], 2.0)
if ready:
chunk = proc.stdout.read(8192)
if chunk:
warned_wait = False
yield chunk
else:
break
else:
# Keep connection open while demodulator settles.
if time.time() > first_chunk_deadline:
if not warned_wait:
logger.warning("Audio stream still waiting for first chunk")
warned_wait = True
continue
# Timeout - check if process died
if proc.poll() is not None:
break
except GeneratorExit:
pass
except Exception as e:
logger.error(f"Audio stream error: {e}")
return Response(
generate(),
mimetype='audio/wav',
headers={
'Content-Type': 'audio/wav',
'Cache-Control': 'no-cache, no-store',
'X-Accel-Buffering': 'no',
'Transfer-Encoding': 'chunked',
}
)
+804
View File
@@ -0,0 +1,804 @@
"""Scanner routes and implementation for frequency scanning."""
from __future__ import annotations
import contextlib
import math
import queue
import struct
import subprocess
import threading
import time
from typing import Any
from flask import Response, jsonify, request
import routes.listening_post as _state
from . import (
SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT,
_rtl_fm_demod_mode,
_start_audio_stream,
_stop_audio_stream,
activity_log,
activity_log_lock,
add_activity_log,
app_module,
find_rtl_fm,
find_rtl_power,
find_rx_fm,
logger,
normalize_modulation,
process_event,
receiver_bp,
scanner_config,
scanner_lock,
scanner_queue,
sse_stream_fanout,
)
# ============================================
# SCANNER IMPLEMENTATION
# ============================================
def scanner_loop():
"""Main scanner loop - scans frequencies looking for signals."""
logger.info("Scanner thread started")
add_activity_log('scanner_start', scanner_config['start_freq'],
f"Scanning {scanner_config['start_freq']}-{scanner_config['end_freq']} MHz")
rtl_fm_path = find_rtl_fm()
if not rtl_fm_path:
logger.error("rtl_fm not found")
add_activity_log('error', 0, 'rtl_fm not found')
_state.scanner_running = False
return
current_freq = scanner_config['start_freq']
last_signal_time = 0
signal_detected = False
try:
while _state.scanner_running:
# Check if paused
if _state.scanner_paused:
time.sleep(0.1)
continue
# Read config values on each iteration (allows live updates)
step_mhz = scanner_config['step'] / 1000.0
squelch = scanner_config['squelch']
mod = scanner_config['modulation']
gain = scanner_config['gain']
device = scanner_config['device']
_state.scanner_current_freq = current_freq
# Notify clients of frequency change
with contextlib.suppress(queue.Full):
scanner_queue.put_nowait({
'type': 'freq_change',
'frequency': current_freq,
'scanning': not signal_detected,
'range_start': scanner_config['start_freq'],
'range_end': scanner_config['end_freq']
})
# Start rtl_fm at this frequency
freq_hz = int(current_freq * 1e6)
# Sample rates
if mod == 'wfm':
sample_rate = 170000
resample_rate = 32000
elif mod in ['usb', 'lsb']:
sample_rate = 12000
resample_rate = 12000
else:
sample_rate = 24000
resample_rate = 24000
# Don't use squelch in rtl_fm - we want to analyze raw audio
rtl_cmd = [
rtl_fm_path,
'-M', _rtl_fm_demod_mode(mod),
'-f', str(freq_hz),
'-s', str(sample_rate),
'-r', str(resample_rate),
'-g', str(gain),
'-d', str(device),
]
# Add bias-t flag if enabled (for external LNA power)
if scanner_config.get('bias_t', False):
rtl_cmd.append('-T')
try:
# Start rtl_fm
rtl_proc = subprocess.Popen(
rtl_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL
)
# Read audio data for analysis
audio_data = b''
# Read audio samples for a short period
sample_duration = 0.25 # 250ms - balance between speed and detection
bytes_needed = int(resample_rate * 2 * sample_duration) # 16-bit mono
while len(audio_data) < bytes_needed and _state.scanner_running:
chunk = rtl_proc.stdout.read(4096)
if not chunk:
break
audio_data += chunk
# Clean up rtl_fm
rtl_proc.terminate()
try:
rtl_proc.wait(timeout=1)
except subprocess.TimeoutExpired:
rtl_proc.kill()
# Analyze audio level
audio_detected = False
rms = 0
threshold = 500
if len(audio_data) > 100:
samples = struct.unpack(f'{len(audio_data)//2}h', audio_data)
# Calculate RMS level (root mean square)
rms = (sum(s*s for s in samples) / len(samples)) ** 0.5
# Threshold based on squelch setting
# Lower squelch = more sensitive (lower threshold)
# squelch 0 = very sensitive, squelch 100 = only strong signals
if mod == 'wfm':
# WFM: threshold 500-10000 based on squelch
threshold = 500 + (squelch * 95)
min_threshold = 1500
else:
# AM/NFM: threshold 300-6500 based on squelch
threshold = 300 + (squelch * 62)
min_threshold = 900
effective_threshold = max(threshold, min_threshold)
audio_detected = rms > effective_threshold
# Send level info to clients
with contextlib.suppress(queue.Full):
scanner_queue.put_nowait({
'type': 'scan_update',
'frequency': current_freq,
'level': int(rms),
'threshold': int(effective_threshold) if 'effective_threshold' in dir() else 0,
'detected': audio_detected,
'range_start': scanner_config['start_freq'],
'range_end': scanner_config['end_freq']
})
if audio_detected and _state.scanner_running:
if not signal_detected:
# New signal found!
signal_detected = True
last_signal_time = time.time()
add_activity_log('signal_found', current_freq,
f'Signal detected on {current_freq:.3f} MHz ({mod.upper()})')
logger.info(f"Signal found at {current_freq} MHz")
# Start audio streaming for user
_start_audio_stream(current_freq, mod)
try:
snr_db = round(10 * math.log10(rms / effective_threshold), 1) if rms > 0 and effective_threshold > 0 else 0.0
scanner_queue.put_nowait({
'type': 'signal_found',
'frequency': current_freq,
'modulation': mod,
'audio_streaming': True,
'level': int(rms),
'threshold': int(effective_threshold),
'snr': snr_db,
'range_start': scanner_config['start_freq'],
'range_end': scanner_config['end_freq']
})
except queue.Full:
pass
# Check for skip signal
if _state.scanner_skip_signal:
_state.scanner_skip_signal = False
signal_detected = False
_stop_audio_stream()
with contextlib.suppress(queue.Full):
scanner_queue.put_nowait({
'type': 'signal_skipped',
'frequency': current_freq
})
# Move to next frequency (step is in kHz, convert to MHz)
current_freq += step_mhz
if current_freq > scanner_config['end_freq']:
current_freq = scanner_config['start_freq']
continue
# Stay on this frequency (dwell) but check periodically
dwell_start = time.time()
while (time.time() - dwell_start) < scanner_config['dwell_time'] and _state.scanner_running:
if _state.scanner_skip_signal:
break
time.sleep(0.2)
last_signal_time = time.time()
# After dwell, move on to keep scanning
if _state.scanner_running and not _state.scanner_skip_signal:
signal_detected = False
_stop_audio_stream()
with contextlib.suppress(queue.Full):
scanner_queue.put_nowait({
'type': 'signal_lost',
'frequency': current_freq,
'range_start': scanner_config['start_freq'],
'range_end': scanner_config['end_freq']
})
current_freq += step_mhz
if current_freq > scanner_config['end_freq']:
current_freq = scanner_config['start_freq']
add_activity_log('scan_cycle', current_freq, 'Scan cycle complete')
time.sleep(scanner_config['scan_delay'])
else:
# No signal at this frequency
if signal_detected:
# Signal lost
duration = time.time() - last_signal_time + scanner_config['dwell_time']
add_activity_log('signal_lost', current_freq,
f'Signal lost after {duration:.1f}s')
signal_detected = False
# Stop audio
_stop_audio_stream()
with contextlib.suppress(queue.Full):
scanner_queue.put_nowait({
'type': 'signal_lost',
'frequency': current_freq
})
# Move to next frequency (step is in kHz, convert to MHz)
current_freq += step_mhz
if current_freq > scanner_config['end_freq']:
current_freq = scanner_config['start_freq']
add_activity_log('scan_cycle', current_freq, 'Scan cycle complete')
time.sleep(scanner_config['scan_delay'])
except Exception as e:
logger.error(f"Scanner error at {current_freq} MHz: {e}")
time.sleep(0.5)
except Exception as e:
logger.error(f"Scanner loop error: {e}")
finally:
_state.scanner_running = False
_stop_audio_stream()
add_activity_log('scanner_stop', _state.scanner_current_freq, 'Scanner stopped')
logger.info("Scanner thread stopped")
def scanner_loop_power():
"""Power sweep scanner using rtl_power to detect peaks."""
logger.info("Power sweep scanner thread started")
add_activity_log('scanner_start', scanner_config['start_freq'],
f"Power sweep {scanner_config['start_freq']}-{scanner_config['end_freq']} MHz")
rtl_power_path = find_rtl_power()
if not rtl_power_path:
logger.error("rtl_power not found")
add_activity_log('error', 0, 'rtl_power not found')
_state.scanner_running = False
return
try:
while _state.scanner_running:
if _state.scanner_paused:
time.sleep(0.1)
continue
start_mhz = scanner_config['start_freq']
end_mhz = scanner_config['end_freq']
step_khz = scanner_config['step']
gain = scanner_config['gain']
device = scanner_config['device']
scanner_config['squelch']
mod = scanner_config['modulation']
# Configure sweep
bin_hz = max(1000, int(step_khz * 1000))
start_hz = int(start_mhz * 1e6)
end_hz = int(end_mhz * 1e6)
# Integration time per sweep (seconds)
integration = max(0.3, min(1.0, scanner_config.get('scan_delay', 0.5)))
cmd = [
rtl_power_path,
'-f', f'{start_hz}:{end_hz}:{bin_hz}',
'-i', f'{integration}',
'-1',
'-g', str(gain),
'-d', str(device),
]
try:
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
_state.scanner_power_process = proc
stdout, _ = proc.communicate(timeout=15)
except subprocess.TimeoutExpired:
proc.kill()
stdout = b''
finally:
_state.scanner_power_process = None
if not _state.scanner_running:
break
if not stdout:
add_activity_log('error', start_mhz, 'Power sweep produced no data')
with contextlib.suppress(queue.Full):
scanner_queue.put_nowait({
'type': 'scan_update',
'frequency': end_mhz,
'level': 0,
'threshold': int(float(scanner_config.get('snr_threshold', 12)) * 100),
'detected': False,
'range_start': scanner_config['start_freq'],
'range_end': scanner_config['end_freq']
})
time.sleep(0.2)
continue
lines = stdout.decode(errors='ignore').splitlines()
segments = []
for line in lines:
if not line or line.startswith('#'):
continue
parts = [p.strip() for p in line.split(',')]
# Find start_hz token
start_idx = None
for i, tok in enumerate(parts):
try:
val = float(tok)
except ValueError:
continue
if val > 1e5:
start_idx = i
break
if start_idx is None or len(parts) < start_idx + 6:
continue
try:
sweep_start = float(parts[start_idx])
sweep_end = float(parts[start_idx + 1])
sweep_bin = float(parts[start_idx + 2])
raw_values = []
for v in parts[start_idx + 3:]:
try:
raw_values.append(float(v))
except ValueError:
continue
# rtl_power may include a samples field before the power list
if raw_values and raw_values[0] >= 0 and any(val < 0 for val in raw_values[1:]):
raw_values = raw_values[1:]
bin_values = raw_values
except ValueError:
continue
if not bin_values:
continue
segments.append((sweep_start, sweep_end, sweep_bin, bin_values))
if not segments:
add_activity_log('error', start_mhz, 'Power sweep bins missing')
with contextlib.suppress(queue.Full):
scanner_queue.put_nowait({
'type': 'scan_update',
'frequency': end_mhz,
'level': 0,
'threshold': int(float(scanner_config.get('snr_threshold', 12)) * 100),
'detected': False,
'range_start': scanner_config['start_freq'],
'range_end': scanner_config['end_freq']
})
time.sleep(0.2)
continue
# Process segments in ascending frequency order to avoid backtracking in UI
segments.sort(key=lambda s: s[0])
total_bins = sum(len(seg[3]) for seg in segments)
if total_bins <= 0:
time.sleep(0.2)
continue
segment_offset = 0
for sweep_start, sweep_end, sweep_bin, bin_values in segments:
# Noise floor (median)
sorted_vals = sorted(bin_values)
mid = len(sorted_vals) // 2
noise_floor = sorted_vals[mid]
# SNR threshold (dB)
snr_threshold = float(scanner_config.get('snr_threshold', 12))
# Emit progress updates (throttled)
emit_stride = max(1, len(bin_values) // 60)
for idx, val in enumerate(bin_values):
if idx % emit_stride != 0 and idx != len(bin_values) - 1:
continue
freq_hz = sweep_start + sweep_bin * idx
_state.scanner_current_freq = freq_hz / 1e6
snr = val - noise_floor
level = int(max(0, snr) * 100)
threshold = int(snr_threshold * 100)
progress = min(1.0, (segment_offset + idx) / max(1, total_bins - 1))
with contextlib.suppress(queue.Full):
scanner_queue.put_nowait({
'type': 'scan_update',
'frequency': _state.scanner_current_freq,
'level': level,
'threshold': threshold,
'detected': snr >= snr_threshold,
'progress': progress,
'range_start': scanner_config['start_freq'],
'range_end': scanner_config['end_freq']
})
segment_offset += len(bin_values)
# Detect peaks (clusters above threshold)
peaks = []
in_cluster = False
peak_idx = None
peak_val = None
for idx, val in enumerate(bin_values):
snr = val - noise_floor
if snr >= snr_threshold:
if not in_cluster:
in_cluster = True
peak_idx = idx
peak_val = val
else:
if val > peak_val:
peak_val = val
peak_idx = idx
else:
if in_cluster and peak_idx is not None:
peaks.append((peak_idx, peak_val))
in_cluster = False
peak_idx = None
peak_val = None
if in_cluster and peak_idx is not None:
peaks.append((peak_idx, peak_val))
for idx, val in peaks:
freq_hz = sweep_start + sweep_bin * (idx + 0.5)
freq_mhz = freq_hz / 1e6
snr = val - noise_floor
level = int(max(0, snr) * 100)
threshold = int(snr_threshold * 100)
add_activity_log('signal_found', freq_mhz,
f'Peak detected at {freq_mhz:.3f} MHz ({mod.upper()})')
with contextlib.suppress(queue.Full):
scanner_queue.put_nowait({
'type': 'signal_found',
'frequency': freq_mhz,
'modulation': mod,
'audio_streaming': False,
'level': level,
'threshold': threshold,
'snr': round(snr, 1),
'range_start': scanner_config['start_freq'],
'range_end': scanner_config['end_freq']
})
add_activity_log('scan_cycle', start_mhz, 'Power sweep complete')
time.sleep(max(0.1, scanner_config.get('scan_delay', 0.5)))
except Exception as e:
logger.error(f"Power sweep scanner error: {e}")
finally:
_state.scanner_running = False
add_activity_log('scanner_stop', _state.scanner_current_freq, 'Scanner stopped')
logger.info("Power sweep scanner thread stopped")
# ============================================
# SCANNER API ENDPOINTS
# ============================================
@receiver_bp.route('/scanner/start', methods=['POST'])
def start_scanner() -> Response:
"""Start the frequency scanner."""
with scanner_lock:
if _state.scanner_running:
return jsonify({
'status': 'error',
'message': 'Scanner already running'
}), 409
# Clear stale queue entries so UI updates immediately
try:
while True:
scanner_queue.get_nowait()
except queue.Empty:
pass
data = request.json or {}
# Update scanner config
try:
scanner_config['start_freq'] = float(data.get('start_freq', 88.0))
scanner_config['end_freq'] = float(data.get('end_freq', 108.0))
scanner_config['step'] = float(data.get('step', 0.1))
scanner_config['modulation'] = normalize_modulation(data.get('modulation', 'wfm'))
scanner_config['squelch'] = int(data.get('squelch', 0))
scanner_config['dwell_time'] = float(data.get('dwell_time', 3.0))
scanner_config['scan_delay'] = float(data.get('scan_delay', 0.5))
scanner_config['device'] = int(data.get('device', 0))
scanner_config['gain'] = int(data.get('gain', 40))
scanner_config['bias_t'] = bool(data.get('bias_t', False))
scanner_config['sdr_type'] = str(data.get('sdr_type', 'rtlsdr')).lower()
scanner_config['scan_method'] = str(data.get('scan_method', '')).lower().strip()
if data.get('snr_threshold') is not None:
scanner_config['snr_threshold'] = float(data.get('snr_threshold'))
except (ValueError, TypeError) as e:
return jsonify({
'status': 'error',
'message': f'Invalid parameter: {e}'
}), 400
# Validate
if scanner_config['start_freq'] >= scanner_config['end_freq']:
return jsonify({
'status': 'error',
'message': 'start_freq must be less than end_freq'
}), 400
# Decide scan method
if not scanner_config['scan_method']:
scanner_config['scan_method'] = 'power' if find_rtl_power() else 'classic'
sdr_type = scanner_config['sdr_type']
# Power scan only supports RTL-SDR for now
if scanner_config['scan_method'] == 'power' and (sdr_type != 'rtlsdr' or not find_rtl_power()):
scanner_config['scan_method'] = 'classic'
# Check tools based on chosen method
if scanner_config['scan_method'] == 'power':
if not find_rtl_power():
return jsonify({
'status': 'error',
'message': 'rtl_power not found. Install rtl-sdr tools.'
}), 503
# Release listening device if active
if _state.receiver_active_device is not None:
app_module.release_sdr_device(_state.receiver_active_device, _state.receiver_active_sdr_type)
_state.receiver_active_device = None
_state.receiver_active_sdr_type = 'rtlsdr'
# Claim device for scanner
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner', scanner_config['sdr_type'])
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
_state.scanner_active_device = scanner_config['device']
_state.scanner_active_sdr_type = scanner_config['sdr_type']
_state.scanner_running = True
_state.scanner_thread = threading.Thread(target=scanner_loop_power, daemon=True)
_state.scanner_thread.start()
else:
if sdr_type == 'rtlsdr':
if not find_rtl_fm():
return jsonify({
'status': 'error',
'message': 'rtl_fm not found. Install rtl-sdr tools.'
}), 503
else:
if not find_rx_fm():
return jsonify({
'status': 'error',
'message': f'rx_fm not found. Install SoapySDR utilities for {sdr_type}.'
}), 503
if _state.receiver_active_device is not None:
app_module.release_sdr_device(_state.receiver_active_device, _state.receiver_active_sdr_type)
_state.receiver_active_device = None
_state.receiver_active_sdr_type = 'rtlsdr'
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner', scanner_config['sdr_type'])
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
_state.scanner_active_device = scanner_config['device']
_state.scanner_active_sdr_type = scanner_config['sdr_type']
_state.scanner_running = True
_state.scanner_thread = threading.Thread(target=scanner_loop, daemon=True)
_state.scanner_thread.start()
return jsonify({
'status': 'started',
'config': scanner_config
})
@receiver_bp.route('/scanner/stop', methods=['POST'])
def stop_scanner() -> Response:
"""Stop the frequency scanner."""
_state.scanner_running = False
_stop_audio_stream()
if _state.scanner_power_process and _state.scanner_power_process.poll() is None:
try:
_state.scanner_power_process.terminate()
_state.scanner_power_process.wait(timeout=1)
except Exception:
with contextlib.suppress(Exception):
_state.scanner_power_process.kill()
_state.scanner_power_process = None
if _state.scanner_active_device is not None:
app_module.release_sdr_device(_state.scanner_active_device, _state.scanner_active_sdr_type)
_state.scanner_active_device = None
_state.scanner_active_sdr_type = 'rtlsdr'
return jsonify({'status': 'stopped'})
@receiver_bp.route('/scanner/pause', methods=['POST'])
def pause_scanner() -> Response:
"""Pause/resume the scanner."""
_state.scanner_paused = not _state.scanner_paused
if _state.scanner_paused:
add_activity_log('scanner_pause', _state.scanner_current_freq, 'Scanner paused')
else:
add_activity_log('scanner_resume', _state.scanner_current_freq, 'Scanner resumed')
return jsonify({
'status': 'paused' if _state.scanner_paused else 'resumed',
'paused': _state.scanner_paused
})
@receiver_bp.route('/scanner/skip', methods=['POST'])
def skip_signal() -> Response:
"""Skip current signal and continue scanning."""
if not _state.scanner_running:
return jsonify({
'status': 'error',
'message': 'Scanner not running'
}), 400
_state.scanner_skip_signal = True
add_activity_log('signal_skip', _state.scanner_current_freq, f'Skipped signal at {_state.scanner_current_freq:.3f} MHz')
return jsonify({
'status': 'skipped',
'frequency': _state.scanner_current_freq
})
@receiver_bp.route('/scanner/config', methods=['POST'])
def update_scanner_config() -> Response:
"""Update scanner config while running (step, squelch, gain, dwell)."""
data = request.json or {}
updated = []
if 'step' in data:
scanner_config['step'] = float(data['step'])
updated.append(f"step={data['step']}kHz")
if 'squelch' in data:
scanner_config['squelch'] = int(data['squelch'])
updated.append(f"squelch={data['squelch']}")
if 'gain' in data:
scanner_config['gain'] = int(data['gain'])
updated.append(f"gain={data['gain']}")
if 'dwell_time' in data:
scanner_config['dwell_time'] = int(data['dwell_time'])
updated.append(f"dwell={data['dwell_time']}s")
if 'modulation' in data:
try:
scanner_config['modulation'] = normalize_modulation(data['modulation'])
updated.append(f"mod={data['modulation']}")
except (ValueError, TypeError) as e:
return jsonify({
'status': 'error',
'message': str(e)
}), 400
if updated:
logger.info(f"Scanner config updated: {', '.join(updated)}")
return jsonify({
'status': 'updated',
'config': scanner_config
})
@receiver_bp.route('/scanner/status')
def scanner_status() -> Response:
"""Get scanner status."""
return jsonify({
'running': _state.scanner_running,
'paused': _state.scanner_paused,
'current_freq': _state.scanner_current_freq,
'config': scanner_config,
'audio_streaming': _state.audio_running,
'audio_frequency': _state.audio_frequency
})
@receiver_bp.route('/scanner/stream')
def stream_scanner_events() -> Response:
"""SSE stream for scanner events."""
def _on_msg(msg: dict[str, Any]) -> None:
process_event('receiver_scanner', msg, msg.get('type'))
response = Response(
sse_stream_fanout(
source_queue=scanner_queue,
channel_key='receiver_scanner',
timeout=SSE_QUEUE_TIMEOUT,
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
on_message=_on_msg,
),
mimetype='text/event-stream',
)
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
return response
@receiver_bp.route('/scanner/log')
def get_activity_log() -> Response:
"""Get activity log."""
limit = request.args.get('limit', 100, type=int)
with activity_log_lock:
return jsonify({
'log': activity_log[:limit],
'total': len(activity_log)
})
@receiver_bp.route('/scanner/log/clear', methods=['POST'])
def clear_activity_log() -> Response:
"""Clear activity log."""
with activity_log_lock:
activity_log.clear()
return jsonify({'status': 'cleared'})
@receiver_bp.route('/presets')
def get_presets() -> Response:
"""Get scanner presets."""
presets = [
{'name': 'FM Broadcast', 'start': 88.0, 'end': 108.0, 'step': 0.2, 'mod': 'wfm'},
{'name': 'Air Band', 'start': 118.0, 'end': 137.0, 'step': 0.025, 'mod': 'am'},
{'name': 'Marine VHF', 'start': 156.0, 'end': 163.0, 'step': 0.025, 'mod': 'fm'},
{'name': 'Amateur 2m', 'start': 144.0, 'end': 148.0, 'step': 0.0125, 'mod': 'fm'},
{'name': 'Amateur 70cm', 'start': 430.0, 'end': 440.0, 'step': 0.025, 'mod': 'fm'},
{'name': 'PMR446', 'start': 446.0, 'end': 446.2, 'step': 0.0125, 'mod': 'fm'},
{'name': 'FRS/GMRS', 'start': 462.5, 'end': 467.7, 'step': 0.025, 'mod': 'fm'},
{'name': 'Weather Radio', 'start': 162.4, 'end': 162.55, 'step': 0.025, 'mod': 'fm'},
]
return jsonify({'presets': presets})
+90
View File
@@ -0,0 +1,90 @@
"""Tool check and signal identification routes."""
from __future__ import annotations
from flask import Response, jsonify, request
from . import (
find_ffmpeg,
find_rtl_fm,
find_rtl_power,
find_rx_fm,
logger,
receiver_bp,
)
# ============================================
# TOOL CHECK ENDPOINT
# ============================================
@receiver_bp.route('/tools')
def check_tools() -> Response:
"""Check for required tools."""
rtl_fm = find_rtl_fm()
rtl_power = find_rtl_power()
rx_fm = find_rx_fm()
ffmpeg = find_ffmpeg()
# Determine which SDR types are supported
supported_sdr_types = []
if rtl_fm:
supported_sdr_types.append('rtlsdr')
if rx_fm:
# rx_fm from SoapySDR supports these types
supported_sdr_types.extend(['hackrf', 'airspy', 'limesdr', 'sdrplay'])
return jsonify({
'rtl_fm': rtl_fm is not None,
'rtl_power': rtl_power is not None,
'rx_fm': rx_fm is not None,
'ffmpeg': ffmpeg is not None,
'available': (rtl_fm is not None or rx_fm is not None) and ffmpeg is not None,
'supported_sdr_types': supported_sdr_types
})
# ============================================
# SIGNAL IDENTIFICATION ENDPOINT
# ============================================
@receiver_bp.route('/signal/guess', methods=['POST'])
def guess_signal() -> Response:
"""Identify a signal based on frequency, modulation, and other parameters."""
data = request.json or {}
freq_mhz = data.get('frequency_mhz')
if freq_mhz is None:
return jsonify({'status': 'error', 'message': 'frequency_mhz is required'}), 400
try:
freq_mhz = float(freq_mhz)
except (ValueError, TypeError):
return jsonify({'status': 'error', 'message': 'Invalid frequency_mhz'}), 400
if freq_mhz <= 0:
return jsonify({'status': 'error', 'message': 'frequency_mhz must be positive'}), 400
frequency_hz = int(freq_mhz * 1e6)
modulation = data.get('modulation')
bandwidth_hz = data.get('bandwidth_hz')
if bandwidth_hz is not None:
try:
bandwidth_hz = int(bandwidth_hz)
except (ValueError, TypeError):
bandwidth_hz = None
region = data.get('region', 'UK/EU')
try:
from utils.signal_guess import guess_signal_type_dict
result = guess_signal_type_dict(
frequency_hz=frequency_hz,
modulation=modulation,
bandwidth_hz=bandwidth_hz,
region=region,
)
return jsonify({'status': 'ok', **result})
except Exception as e:
logger.error(f"Signal guess error: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500
+493
View File
@@ -0,0 +1,493 @@
"""Waterfall / spectrogram routes and implementation."""
from __future__ import annotations
import contextlib
import math
import queue
import struct
import subprocess
import threading
import time
from datetime import datetime
from typing import Any
from flask import Response, jsonify, request
import routes.listening_post as _state
from . import (
SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT,
SDRFactory,
SDRType,
_stop_waterfall_internal,
app_module,
find_rtl_power,
logger,
process_event,
receiver_bp,
sse_stream_fanout,
)
# ============================================
# WATERFALL HELPER FUNCTIONS
# ============================================
def _parse_rtl_power_line(line: str) -> tuple[str | None, float | None, float | None, list[float]]:
"""Parse a single rtl_power CSV line into bins."""
if not line or line.startswith('#'):
return None, None, None, []
parts = [p.strip() for p in line.split(',')]
if len(parts) < 6:
return None, None, None, []
# Timestamp in first two fields (YYYY-MM-DD, HH:MM:SS)
timestamp = f"{parts[0]} {parts[1]}" if len(parts) >= 2 else parts[0]
start_idx = None
for i, tok in enumerate(parts):
try:
val = float(tok)
except ValueError:
continue
if val > 1e5:
start_idx = i
break
if start_idx is None or len(parts) < start_idx + 4:
return timestamp, None, None, []
try:
seg_start = float(parts[start_idx])
seg_end = float(parts[start_idx + 1])
raw_values = []
for v in parts[start_idx + 3:]:
try:
raw_values.append(float(v))
except ValueError:
continue
if raw_values and raw_values[0] >= 0 and any(val < 0 for val in raw_values[1:]):
raw_values = raw_values[1:]
return timestamp, seg_start, seg_end, raw_values
except ValueError:
return timestamp, None, None, []
def _queue_waterfall_error(message: str) -> None:
"""Push an error message onto the waterfall SSE queue."""
with contextlib.suppress(queue.Full):
_state.waterfall_queue.put_nowait({
'type': 'waterfall_error',
'message': message,
'timestamp': datetime.now().isoformat(),
})
def _downsample_bins(values: list[float], target: int) -> list[float]:
"""Downsample bins to a target length using simple averaging."""
if target <= 0 or len(values) <= target:
return values
out: list[float] = []
step = len(values) / target
for i in range(target):
start = int(i * step)
end = int((i + 1) * step)
if end <= start:
end = min(start + 1, len(values))
chunk = values[start:end]
if not chunk:
continue
out.append(sum(chunk) / len(chunk))
return out
# ============================================
# WATERFALL LOOP IMPLEMENTATIONS
# ============================================
def _waterfall_loop():
"""Continuous waterfall sweep loop emitting FFT data."""
sdr_type_str = _state.waterfall_config.get('sdr_type', 'rtlsdr')
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
if sdr_type == SDRType.RTL_SDR:
_waterfall_loop_rtl_power()
else:
_waterfall_loop_iq(sdr_type)
def _waterfall_loop_iq(sdr_type: SDRType):
"""Waterfall loop using rx_sdr IQ capture + FFT for HackRF/SoapySDR devices."""
start_freq = _state.waterfall_config['start_freq']
end_freq = _state.waterfall_config['end_freq']
gain = _state.waterfall_config['gain']
device = _state.waterfall_config['device']
interval = float(_state.waterfall_config.get('interval', 0.4))
# Use center frequency and sample rate to cover the requested span
center_mhz = (start_freq + end_freq) / 2.0
span_hz = (end_freq - start_freq) * 1e6
# Pick a sample rate that covers the span (minimum 2 MHz for HackRF)
sample_rate = max(2000000, int(span_hz))
# Cap to sensible maximum
sample_rate = min(sample_rate, 20000000)
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
builder = SDRFactory.get_builder(sdr_type)
cmd = builder.build_iq_capture_command(
device=sdr_device,
frequency_mhz=center_mhz,
sample_rate=sample_rate,
gain=float(gain),
)
fft_size = min(int(_state.waterfall_config.get('max_bins') or 1024), 4096)
try:
_state.waterfall_process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
# Detect immediate startup failures
time.sleep(0.35)
if _state.waterfall_process.poll() is not None:
stderr_text = ''
try:
if _state.waterfall_process.stderr:
stderr_text = _state.waterfall_process.stderr.read().decode('utf-8', errors='ignore').strip()
except Exception:
stderr_text = ''
msg = stderr_text or f'IQ capture exited early (code {_state.waterfall_process.returncode})'
logger.error(f"Waterfall startup failed: {msg}")
_queue_waterfall_error(msg)
return
if not _state.waterfall_process.stdout:
_queue_waterfall_error('IQ capture stdout unavailable')
return
# Read IQ samples and compute FFT
# CU8 format: interleaved unsigned 8-bit I/Q pairs
bytes_per_sample = 2 # 1 byte I + 1 byte Q
chunk_bytes = fft_size * bytes_per_sample
received_any = False
while _state.waterfall_running:
raw = _state.waterfall_process.stdout.read(chunk_bytes)
if not raw or len(raw) < chunk_bytes:
if _state.waterfall_process.poll() is not None:
break
continue
received_any = True
# Convert CU8 to complex float: center at 127.5
iq = struct.unpack(f'{fft_size * 2}B', raw)
# Compute power spectrum via FFT
real_parts = [(iq[i * 2] - 127.5) / 127.5 for i in range(fft_size)]
imag_parts = [(iq[i * 2 + 1] - 127.5) / 127.5 for i in range(fft_size)]
bins: list[float] = []
try:
# Try numpy if available for efficient FFT
import numpy as np
samples = np.array(real_parts, dtype=np.float32) + 1j * np.array(imag_parts, dtype=np.float32)
# Apply Hann window
window = np.hanning(fft_size)
samples *= window
spectrum = np.fft.fftshift(np.fft.fft(samples))
power_db = 10.0 * np.log10(np.abs(spectrum) ** 2 + 1e-10)
bins = power_db.tolist()
except ImportError:
# Fallback: compute magnitude without full FFT
# Just report raw magnitudes per sample as approximate power
for i in range(fft_size):
mag = math.sqrt(real_parts[i] ** 2 + imag_parts[i] ** 2)
power = 10.0 * math.log10(mag ** 2 + 1e-10)
bins.append(power)
max_bins = int(_state.waterfall_config.get('max_bins') or 0)
if max_bins > 0 and len(bins) > max_bins:
bins = _downsample_bins(bins, max_bins)
msg = {
'type': 'waterfall_sweep',
'start_freq': start_freq,
'end_freq': end_freq,
'bins': bins,
'timestamp': datetime.now().isoformat(),
}
try:
_state.waterfall_queue.put_nowait(msg)
except queue.Full:
with contextlib.suppress(queue.Empty):
_state.waterfall_queue.get_nowait()
with contextlib.suppress(queue.Full):
_state.waterfall_queue.put_nowait(msg)
# Throttle to respect interval
time.sleep(interval)
if _state.waterfall_running and not received_any:
_queue_waterfall_error(f'No IQ data received from {sdr_type.value}')
except Exception as e:
logger.error(f"Waterfall IQ loop error: {e}")
_queue_waterfall_error(f"Waterfall loop error: {e}")
finally:
_state.waterfall_running = False
if _state.waterfall_process and _state.waterfall_process.poll() is None:
try:
_state.waterfall_process.terminate()
_state.waterfall_process.wait(timeout=1)
except Exception:
with contextlib.suppress(Exception):
_state.waterfall_process.kill()
_state.waterfall_process = None
logger.info("Waterfall IQ loop stopped")
def _waterfall_loop_rtl_power():
"""Continuous rtl_power sweep loop emitting waterfall data."""
rtl_power_path = find_rtl_power()
if not rtl_power_path:
logger.error("rtl_power not found for waterfall")
_queue_waterfall_error('rtl_power not found')
_state.waterfall_running = False
return
start_hz = int(_state.waterfall_config['start_freq'] * 1e6)
end_hz = int(_state.waterfall_config['end_freq'] * 1e6)
bin_hz = int(_state.waterfall_config['bin_size'])
gain = _state.waterfall_config['gain']
device = _state.waterfall_config['device']
interval = float(_state.waterfall_config.get('interval', 0.4))
cmd = [
rtl_power_path,
'-f', f'{start_hz}:{end_hz}:{bin_hz}',
'-i', str(interval),
'-g', str(gain),
'-d', str(device),
]
try:
_state.waterfall_process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=1,
text=True,
)
# Detect immediate startup failures (e.g. device busy / no device).
time.sleep(0.35)
if _state.waterfall_process.poll() is not None:
stderr_text = ''
try:
if _state.waterfall_process.stderr:
stderr_text = _state.waterfall_process.stderr.read().strip()
except Exception:
stderr_text = ''
msg = stderr_text or f'rtl_power exited early (code {_state.waterfall_process.returncode})'
logger.error(f"Waterfall startup failed: {msg}")
_queue_waterfall_error(msg)
return
current_ts = None
all_bins: list[float] = []
sweep_start_hz = start_hz
sweep_end_hz = end_hz
received_any = False
if not _state.waterfall_process.stdout:
_queue_waterfall_error('rtl_power stdout unavailable')
return
for line in _state.waterfall_process.stdout:
if not _state.waterfall_running:
break
ts, seg_start, seg_end, bins = _parse_rtl_power_line(line)
if ts is None or not bins:
continue
received_any = True
if current_ts is None:
current_ts = ts
if ts != current_ts and all_bins:
max_bins = int(_state.waterfall_config.get('max_bins') or 0)
bins_to_send = all_bins
if max_bins > 0 and len(bins_to_send) > max_bins:
bins_to_send = _downsample_bins(bins_to_send, max_bins)
msg = {
'type': 'waterfall_sweep',
'start_freq': sweep_start_hz / 1e6,
'end_freq': sweep_end_hz / 1e6,
'bins': bins_to_send,
'timestamp': datetime.now().isoformat(),
}
try:
_state.waterfall_queue.put_nowait(msg)
except queue.Full:
with contextlib.suppress(queue.Empty):
_state.waterfall_queue.get_nowait()
with contextlib.suppress(queue.Full):
_state.waterfall_queue.put_nowait(msg)
all_bins = []
sweep_start_hz = start_hz
sweep_end_hz = end_hz
current_ts = ts
all_bins.extend(bins)
if seg_start is not None:
sweep_start_hz = min(sweep_start_hz, seg_start)
if seg_end is not None:
sweep_end_hz = max(sweep_end_hz, seg_end)
# Flush any remaining bins
if all_bins and _state.waterfall_running:
max_bins = int(_state.waterfall_config.get('max_bins') or 0)
bins_to_send = all_bins
if max_bins > 0 and len(bins_to_send) > max_bins:
bins_to_send = _downsample_bins(bins_to_send, max_bins)
msg = {
'type': 'waterfall_sweep',
'start_freq': sweep_start_hz / 1e6,
'end_freq': sweep_end_hz / 1e6,
'bins': bins_to_send,
'timestamp': datetime.now().isoformat(),
}
with contextlib.suppress(queue.Full):
_state.waterfall_queue.put_nowait(msg)
if _state.waterfall_running and not received_any:
_queue_waterfall_error('No waterfall FFT data received from rtl_power')
except Exception as e:
logger.error(f"Waterfall loop error: {e}")
_queue_waterfall_error(f"Waterfall loop error: {e}")
finally:
_state.waterfall_running = False
if _state.waterfall_process and _state.waterfall_process.poll() is None:
try:
_state.waterfall_process.terminate()
_state.waterfall_process.wait(timeout=1)
except Exception:
with contextlib.suppress(Exception):
_state.waterfall_process.kill()
_state.waterfall_process = None
logger.info("Waterfall loop stopped")
# ============================================
# WATERFALL API ENDPOINTS
# ============================================
@receiver_bp.route('/waterfall/start', methods=['POST'])
def start_waterfall() -> Response:
"""Start the waterfall/spectrogram display."""
with _state.waterfall_lock:
if _state.waterfall_running:
return jsonify({
'status': 'started',
'already_running': True,
'message': 'Waterfall already running',
'config': _state.waterfall_config,
})
data = request.json or {}
# Determine SDR type
sdr_type_str = data.get('sdr_type', 'rtlsdr')
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
sdr_type_str = sdr_type.value
# RTL-SDR uses rtl_power; other types use rx_sdr via IQ capture
if sdr_type == SDRType.RTL_SDR and not find_rtl_power():
return jsonify({'status': 'error', 'message': 'rtl_power not found'}), 503
try:
_state.waterfall_config['start_freq'] = float(data.get('start_freq', 88.0))
_state.waterfall_config['end_freq'] = float(data.get('end_freq', 108.0))
_state.waterfall_config['bin_size'] = int(data.get('bin_size', 10000))
_state.waterfall_config['gain'] = int(data.get('gain', 40))
_state.waterfall_config['device'] = int(data.get('device', 0))
_state.waterfall_config['sdr_type'] = sdr_type_str
if data.get('interval') is not None:
interval = float(data.get('interval', _state.waterfall_config['interval']))
if interval < 0.1 or interval > 5:
return jsonify({'status': 'error', 'message': 'interval must be between 0.1 and 5 seconds'}), 400
_state.waterfall_config['interval'] = interval
if data.get('max_bins') is not None:
max_bins = int(data.get('max_bins', _state.waterfall_config['max_bins']))
if max_bins < 64 or max_bins > 4096:
return jsonify({'status': 'error', 'message': 'max_bins must be between 64 and 4096'}), 400
_state.waterfall_config['max_bins'] = max_bins
except (ValueError, TypeError) as e:
return jsonify({'status': 'error', 'message': f'Invalid parameter: {e}'}), 400
if _state.waterfall_config['start_freq'] >= _state.waterfall_config['end_freq']:
return jsonify({'status': 'error', 'message': 'start_freq must be less than end_freq'}), 400
# Clear stale queue
try:
while True:
_state.waterfall_queue.get_nowait()
except queue.Empty:
pass
# Claim SDR device
error = app_module.claim_sdr_device(_state.waterfall_config['device'], 'waterfall', sdr_type_str)
if error:
return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409
_state.waterfall_active_device = _state.waterfall_config['device']
_state.waterfall_active_sdr_type = sdr_type_str
_state.waterfall_running = True
_state.waterfall_thread = threading.Thread(target=_waterfall_loop, daemon=True)
_state.waterfall_thread.start()
return jsonify({'status': 'started', 'config': _state.waterfall_config})
@receiver_bp.route('/waterfall/stop', methods=['POST'])
def stop_waterfall() -> Response:
"""Stop the waterfall display."""
_stop_waterfall_internal()
return jsonify({'status': 'stopped'})
@receiver_bp.route('/waterfall/stream')
def stream_waterfall() -> Response:
"""SSE stream for waterfall data."""
def _on_msg(msg: dict[str, Any]) -> None:
process_event('waterfall', msg, msg.get('type'))
response = Response(
sse_stream_fanout(
source_queue=_state.waterfall_queue,
channel_key='receiver_waterfall',
timeout=SSE_QUEUE_TIMEOUT,
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
on_message=_on_msg,
),
mimetype='text/event-stream',
)
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
return response
+30 -22
View File
@@ -11,20 +11,19 @@ Supports multiple connection types:
from __future__ import annotations
import queue
import time
from typing import Generator
from flask import Blueprint, jsonify, request, Response
from flask import Blueprint, Response, jsonify, request
from utils.logging import get_logger
from utils.sse import format_sse
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')
@@ -469,22 +468,15 @@ def stream_messages():
Returns:
SSE stream (text/event-stream)
"""
def generate() -> Generator[str, None, None]:
last_keepalive = time.time()
keepalive_interval = 30.0
while True:
try:
msg = _mesh_queue.get(timeout=1)
last_keepalive = time.time()
yield format_sse(msg)
except queue.Empty:
now = time.time()
if now - last_keepalive >= keepalive_interval:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream')
response = Response(
sse_stream_fanout(
source_queue=_mesh_queue,
channel_key='meshtastic',
timeout=1.0,
keepalive_interval=30.0,
),
mimetype='text/event-stream',
)
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
@@ -1051,3 +1043,19 @@ def request_store_forward():
'status': 'error',
'message': error or 'Failed to request S&F history'
}), 500
@meshtastic_bp.route('/topology')
def mesh_topology():
"""Return mesh network topology graph."""
if not is_meshtastic_available():
return api_error('Meshtastic SDK not installed', 400)
client = get_meshtastic_client()
if not client or not client.is_running:
return api_error('Not connected', 400)
return jsonify({
'status': 'success',
'topology': client.get_topology(),
})
+599
View File
@@ -0,0 +1,599 @@
"""WebSocket-based meteor scatter monitoring with waterfall display and ping detection.
Provides:
- WebSocket at /ws/meteor for binary waterfall frames (reuses waterfall_fft pipeline)
- SSE at /meteor/stream for detection events and stats
- REST endpoints for status, events, and export
"""
from __future__ import annotations
import json
import queue
import shutil
import socket
import subprocess
import threading
import time
from contextlib import suppress
from typing import Any
from flask import Blueprint, Flask, Response, jsonify, request
from utils.responses import api_error
try:
from flask_sock import Sock
WEBSOCKET_AVAILABLE = True
except ImportError:
WEBSOCKET_AVAILABLE = False
Sock = None
from utils.logging import get_logger
from utils.meteor_detector import MeteorDetector
from utils.process import register_process, safe_terminate, unregister_process
from utils.sdr import SDRFactory, SDRType
from utils.sdr.base import SDRCapabilities, SDRDevice
from utils.sse import sse_stream_fanout
from utils.validation import validate_device_index, validate_frequency, validate_gain
from utils.waterfall_fft import (
build_binary_frame,
compute_power_spectrum,
cu8_to_complex,
quantize_to_uint8,
)
logger = get_logger('intercept.meteor')
# Module-level shared state
_state_lock = threading.Lock()
_state: dict[str, Any] = {
'running': False,
'device': None,
'frequency_mhz': 0.0,
'sample_rate': 0,
}
_detector: MeteorDetector | None = None
_sse_queue: queue.Queue = queue.Queue(maxsize=500)
# Maximum bandwidth per SDR type (Hz)
MAX_BANDWIDTH = {
SDRType.RTL_SDR: 2400000,
SDRType.HACKRF: 20000000,
SDRType.LIME_SDR: 20000000,
SDRType.AIRSPY: 10000000,
SDRType.SDRPLAY: 2000000,
}
def _push_sse(data: dict[str, Any]) -> None:
"""Push a message to the SSE queue, dropping oldest if full."""
try:
_sse_queue.put_nowait(data)
except queue.Full:
try:
_sse_queue.get_nowait()
_sse_queue.put_nowait(data)
except (queue.Empty, queue.Full):
pass
def _resolve_sdr_type(sdr_type_str: str) -> SDRType:
mapping = {
'rtlsdr': SDRType.RTL_SDR,
'rtl_sdr': SDRType.RTL_SDR,
'hackrf': SDRType.HACKRF,
'limesdr': SDRType.LIME_SDR,
'airspy': SDRType.AIRSPY,
'sdrplay': SDRType.SDRPLAY,
}
return mapping.get(sdr_type_str.lower(), SDRType.RTL_SDR)
def _build_dummy_device(device_index: int, sdr_type: SDRType) -> SDRDevice:
builder = SDRFactory.get_builder(sdr_type)
caps = builder.get_capabilities()
return SDRDevice(
sdr_type=sdr_type,
index=device_index,
name=f'{sdr_type.value}-{device_index}',
serial='N/A',
driver=sdr_type.value,
capabilities=caps,
)
def _pick_sample_rate(span_hz: int, caps: SDRCapabilities, sdr_type: SDRType) -> int:
valid_rates = sorted({int(r) for r in caps.sample_rates if int(r) > 0})
if valid_rates:
return min(valid_rates, key=lambda rate: abs(rate - span_hz))
max_bw = MAX_BANDWIDTH.get(sdr_type, 2400000)
return max(62500, min(span_hz, max_bw))
# ── Blueprint for REST/SSE endpoints ──
meteor_bp = Blueprint('meteor', __name__, url_prefix='/meteor')
@meteor_bp.route('/status')
def meteor_status():
"""Return current meteor monitoring status."""
with _state_lock:
running = _state['running']
freq = _state['frequency_mhz']
device = _state['device']
sr = _state['sample_rate']
detector = _detector
stats = None
if detector:
stats = detector._build_stats(time.time())
return jsonify({
'running': running,
'frequency_mhz': freq,
'device': device,
'sample_rate': sr,
'stats': stats,
})
@meteor_bp.route('/stream')
def meteor_stream():
"""SSE endpoint for meteor detection events and stats."""
response = Response(
sse_stream_fanout(
source_queue=_sse_queue,
channel_key='meteor',
timeout=1.0,
keepalive_interval=30.0,
),
mimetype='text/event-stream',
)
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response
@meteor_bp.route('/events')
def meteor_events():
"""Return detected events as JSON."""
detector = _detector
if not detector:
return jsonify({'events': []})
limit = request.args.get('limit', 500, type=int)
return jsonify({'events': detector.get_events(limit=limit)})
@meteor_bp.route('/events/export')
def meteor_events_export():
"""Export events as CSV or JSON."""
detector = _detector
if not detector:
return api_error('No active session', 400)
fmt = request.args.get('format', 'json').lower()
if fmt == 'csv':
csv_data = detector.export_events_csv()
return Response(
csv_data,
mimetype='text/csv',
headers={'Content-Disposition': 'attachment; filename=meteor_events.csv'},
)
else:
json_data = detector.export_events_json()
return Response(
json_data,
mimetype='application/json',
headers={'Content-Disposition': 'attachment; filename=meteor_events.json'},
)
@meteor_bp.route('/events/clear', methods=['POST'])
def meteor_events_clear():
"""Clear all detected events."""
detector = _detector
if not detector:
return jsonify({'cleared': 0})
count = detector.clear_events()
return jsonify({'cleared': count})
# ── WebSocket handler ──
def init_meteor_websocket(app: Flask):
"""Initialize WebSocket meteor scatter streaming."""
global _detector
if not WEBSOCKET_AVAILABLE:
logger.warning("flask-sock not installed, WebSocket meteor disabled")
return
sock = Sock(app)
@sock.route('/ws/meteor')
def meteor_stream_ws(ws):
"""WebSocket endpoint for meteor scatter waterfall + detection."""
global _detector
logger.info("WebSocket meteor client connected")
import app as app_module
iq_process = None
reader_thread = None
stop_event = threading.Event()
claimed_device = None
claimed_sdr_type = 'rtlsdr'
send_queue: queue.Queue = queue.Queue(maxsize=120)
try:
while True:
# Drain send queue
while True:
try:
outgoing = send_queue.get_nowait()
except queue.Empty:
break
try:
ws.send(outgoing)
except Exception:
stop_event.set()
break
try:
msg = ws.receive(timeout=0.01)
except Exception as e:
err = str(e).lower()
if "closed" in err:
break
if "timed out" not in err:
logger.error(f"WebSocket receive error: {e}")
continue
if msg is None:
if not ws.connected:
break
if stop_event.is_set():
break
continue
try:
data = json.loads(msg)
except (json.JSONDecodeError, TypeError):
continue
cmd = data.get('cmd')
if cmd == 'start':
# Stop any existing capture
was_restarting = iq_process is not None
stop_event.set()
if reader_thread and reader_thread.is_alive():
reader_thread.join(timeout=2)
if iq_process:
safe_terminate(iq_process)
unregister_process(iq_process)
iq_process = None
if claimed_device is not None:
app_module.release_sdr_device(claimed_device, claimed_sdr_type)
claimed_device = None
with _state_lock:
_state['running'] = False
stop_event.clear()
while not send_queue.empty():
try:
send_queue.get_nowait()
except queue.Empty:
break
if was_restarting:
time.sleep(0.5)
# Parse config
try:
frequency_mhz = float(data.get('frequency_mhz', 143.05))
validate_frequency(frequency_mhz)
gain_raw = data.get('gain')
if gain_raw is None or str(gain_raw).lower() == 'auto':
gain = None
else:
gain = validate_gain(float(gain_raw))
device_index = validate_device_index(int(data.get('device', 0)))
sdr_type_str = data.get('sdr_type', 'rtlsdr')
sample_rate_req = int(data.get('sample_rate', 250000))
fft_size = int(data.get('fft_size', 1024))
fps = int(data.get('fps', 20))
avg_count = int(data.get('avg_count', 4))
ppm = data.get('ppm')
if ppm is not None:
ppm = int(ppm)
bias_t = bool(data.get('bias_t', False))
# Detection settings
snr_threshold = float(data.get('snr_threshold', 6.0))
min_duration = float(data.get('min_duration_ms', 50.0))
cooldown = float(data.get('cooldown_ms', 200.0))
freq_drift = float(data.get('freq_drift_tolerance_hz', 500.0))
except (TypeError, ValueError) as exc:
ws.send(json.dumps({
'status': 'error',
'message': f'Invalid configuration: {exc}',
}))
continue
# Clamp values
fft_size = max(256, min(4096, fft_size))
fps = max(5, min(30, fps))
avg_count = max(1, min(16, avg_count))
# Resolve SDR type and sample rate
sdr_type = _resolve_sdr_type(sdr_type_str)
builder = SDRFactory.get_builder(sdr_type)
caps = builder.get_capabilities()
sample_rate = _pick_sample_rate(sample_rate_req, caps, sdr_type)
# Compute frequency range
span_mhz = sample_rate / 1e6
start_freq = frequency_mhz - span_mhz / 2
end_freq = frequency_mhz + span_mhz / 2
# Claim SDR device
max_claim_attempts = 4 if was_restarting else 1
claim_err = None
for _attempt in range(max_claim_attempts):
claim_err = app_module.claim_sdr_device(device_index, 'meteor', sdr_type_str)
if not claim_err:
break
if _attempt < max_claim_attempts - 1:
time.sleep(0.4)
if claim_err:
ws.send(json.dumps({
'status': 'error',
'message': claim_err,
'error_type': 'DEVICE_BUSY',
}))
continue
claimed_device = device_index
claimed_sdr_type = sdr_type_str
# Build I/Q capture command
try:
device = _build_dummy_device(device_index, sdr_type)
iq_cmd = builder.build_iq_capture_command(
device=device,
frequency_mhz=frequency_mhz,
sample_rate=sample_rate,
gain=gain,
ppm=ppm,
bias_t=bias_t,
)
except NotImplementedError as e:
app_module.release_sdr_device(device_index, sdr_type_str)
claimed_device = None
ws.send(json.dumps({'status': 'error', 'message': str(e)}))
continue
# Check binary exists
if not shutil.which(iq_cmd[0]):
app_module.release_sdr_device(device_index, sdr_type_str)
claimed_device = None
ws.send(json.dumps({
'status': 'error',
'message': f'Required tool "{iq_cmd[0]}" not found.',
}))
continue
# Spawn I/Q capture
max_attempts = 3 if was_restarting else 1
try:
for attempt in range(max_attempts):
logger.info(
f"Starting meteor I/Q capture: {frequency_mhz:.6f} MHz, "
f"sr={sample_rate}, fft={fft_size}"
)
iq_process = subprocess.Popen(
iq_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=0,
)
register_process(iq_process)
time.sleep(0.3)
if iq_process.poll() is not None:
stderr_out = ''
if iq_process.stderr:
with suppress(Exception):
stderr_out = iq_process.stderr.read().decode('utf-8', errors='replace').strip()
unregister_process(iq_process)
iq_process = None
if attempt < max_attempts - 1:
time.sleep(0.5)
continue
detail = f": {stderr_out}" if stderr_out else ""
raise RuntimeError(f"I/Q process exited immediately{detail}")
break
except Exception as e:
logger.error(f"Failed to start meteor I/Q capture: {e}")
if iq_process:
safe_terminate(iq_process)
unregister_process(iq_process)
iq_process = None
app_module.release_sdr_device(device_index, sdr_type_str)
claimed_device = None
ws.send(json.dumps({
'status': 'error',
'message': f'Failed to start I/Q capture: {e}',
}))
continue
# Initialize detector
_detector = MeteorDetector(
snr_threshold_db=snr_threshold,
min_duration_ms=min_duration,
cooldown_ms=cooldown,
freq_drift_tolerance_hz=freq_drift,
)
with _state_lock:
_state['running'] = True
_state['device'] = device_index
_state['frequency_mhz'] = frequency_mhz
_state['sample_rate'] = sample_rate
# Send confirmation
ws.send(json.dumps({
'status': 'started',
'frequency_mhz': frequency_mhz,
'start_freq': start_freq,
'end_freq': end_freq,
'fft_size': fft_size,
'sample_rate': sample_rate,
'span_mhz': span_mhz,
}))
# Start FFT reader + detection thread
def fft_reader(
proc, _send_q, stop_evt, detector,
_fft_size, _avg_count, _fps, _sample_rate,
_start_freq, _end_freq, _freq_mhz,
):
required_fft_samples = _fft_size * _avg_count
timeslice_samples = max(required_fft_samples, int(_sample_rate / max(1, _fps)))
bytes_per_frame = timeslice_samples * 2
frame_interval = 1.0 / _fps
start_freq_hz = _start_freq * 1e6
end_freq_hz = _end_freq * 1e6
last_stats_push = 0.0
try:
while not stop_evt.is_set():
if proc.poll() is not None:
break
frame_start = time.monotonic()
# Read raw I/Q
raw = b''
remaining = bytes_per_frame
while remaining > 0 and not stop_evt.is_set():
chunk = proc.stdout.read(min(remaining, 65536))
if not chunk:
break
raw += chunk
remaining -= len(chunk)
if len(raw) < _fft_size * 2:
break
# FFT pipeline
samples = cu8_to_complex(raw)
fft_samples = samples[-required_fft_samples:] if len(samples) > required_fft_samples else samples
power_db = compute_power_spectrum(
fft_samples,
fft_size=_fft_size,
avg_count=_avg_count,
)
quantized = quantize_to_uint8(power_db)
frame = build_binary_frame(_start_freq, _end_freq, quantized)
# Send waterfall frame via WS
with suppress(queue.Full):
_send_q.put_nowait(frame)
# Run detection on raw dB spectrum
now = time.time()
stats, event = detector.process_frame(
power_db, start_freq_hz, end_freq_hz, now,
)
# Push event immediately via SSE
if event:
_push_sse({
'type': 'event',
'event': event.to_dict(),
})
# Also send as JSON via WS for immediate UI update
event_msg = json.dumps({
'type': 'detection',
'event': event.to_dict(),
})
with suppress(queue.Full):
_send_q.put_nowait(event_msg)
# Push stats every ~1s via SSE
if now - last_stats_push >= 1.0:
_push_sse(stats)
last_stats_push = now
# Pace to target FPS
elapsed = time.monotonic() - frame_start
sleep_time = frame_interval - elapsed
if sleep_time > 0:
stop_evt.wait(sleep_time)
except Exception as e:
logger.debug(f"Meteor FFT reader stopped: {e}")
reader_thread = threading.Thread(
target=fft_reader,
args=(
iq_process, send_queue, stop_event, _detector,
fft_size, avg_count, fps, sample_rate,
start_freq, end_freq, frequency_mhz,
),
daemon=True,
)
reader_thread.start()
elif cmd == 'update_threshold':
detector = _detector
if detector:
detector.update_settings(
snr_threshold_db=data.get('snr_threshold'),
min_duration_ms=data.get('min_duration_ms'),
cooldown_ms=data.get('cooldown_ms'),
freq_drift_tolerance_hz=data.get('freq_drift_tolerance_hz'),
)
ws.send(json.dumps({'status': 'threshold_updated'}))
elif cmd == 'stop':
stop_event.set()
if reader_thread and reader_thread.is_alive():
reader_thread.join(timeout=2)
reader_thread = None
if iq_process:
safe_terminate(iq_process)
unregister_process(iq_process)
iq_process = None
if claimed_device is not None:
app_module.release_sdr_device(claimed_device, claimed_sdr_type)
claimed_device = None
with _state_lock:
_state['running'] = False
_state['device'] = None
stop_event.clear()
ws.send(json.dumps({'status': 'stopped'}))
except Exception as e:
logger.info(f"WebSocket meteor closed: {e}")
finally:
stop_event.set()
if reader_thread and reader_thread.is_alive():
reader_thread.join(timeout=2)
if iq_process:
safe_terminate(iq_process)
unregister_process(iq_process)
if claimed_device is not None:
app_module.release_sdr_device(claimed_device, claimed_sdr_type)
with _state_lock:
_state['running'] = False
_state['device'] = None
with suppress(Exception):
ws.close()
with suppress(Exception):
ws.sock.shutdown(socket.SHUT_RDWR)
with suppress(Exception):
ws.sock.close()
logger.info("WebSocket meteor client disconnected")
+1017
View File
File diff suppressed because it is too large Load Diff
+17 -28
View File
@@ -2,17 +2,21 @@
Offline mode routes - Asset management and settings for offline operation.
"""
from flask import Blueprint, jsonify, request
from utils.database import get_setting, set_setting
import os
from flask import Blueprint, request
from utils.database import get_setting, set_setting
from utils.responses import api_error, api_success
offline_bp = Blueprint('offline', __name__, url_prefix='/offline')
# Default offline settings
OFFLINE_DEFAULTS = {
'offline.enabled': False,
'offline.assets_source': 'cdn',
'offline.fonts_source': 'cdn',
# Default to bundled assets/fonts to avoid third-party CDN privacy blocks.
'offline.assets_source': 'local',
'offline.fonts_source': 'local',
'offline.tile_provider': 'cartodb_dark_cyan',
'offline.tile_server_url': ''
}
@@ -63,10 +67,7 @@ def get_offline_settings():
def get_settings():
"""Get current offline settings."""
settings = get_offline_settings()
return jsonify({
'status': 'success',
'settings': settings
})
return api_success(data={'settings': settings})
@offline_bp.route('/settings', methods=['POST'])
@@ -74,14 +75,14 @@ def save_setting():
"""Save an offline setting."""
data = request.get_json()
if not data or 'key' not in data or 'value' not in data:
return jsonify({'status': 'error', 'message': 'Missing key or value'}), 400
return api_error('Missing key or value', 400)
key = data['key']
value = data['value']
# Validate key is an allowed setting
if key not in OFFLINE_DEFAULTS:
return jsonify({'status': 'error', 'message': f'Unknown setting: {key}'}), 400
return api_error(f'Unknown setting: {key}', 400)
# Validate value type matches default
default_type = type(OFFLINE_DEFAULTS[key])
@@ -93,18 +94,11 @@ def save_setting():
else:
value = default_type(value)
except (ValueError, TypeError):
return jsonify({
'status': 'error',
'message': f'Invalid value type for {key}'
}), 400
return api_error(f'Invalid value type for {key}', 400)
set_setting(key, value)
return jsonify({
'status': 'success',
'key': key,
'value': value
})
return api_success(data={'key': key, 'value': value})
@offline_bp.route('/status', methods=['GET'])
@@ -133,8 +127,7 @@ def get_status():
if not available:
all_available = False
return jsonify({
'status': 'success',
return api_success(data={
'all_available': all_available,
'assets': results,
'offline_enabled': get_setting('offline.enabled', False)
@@ -146,11 +139,11 @@ def check_asset():
"""Check if a specific asset file exists."""
path = request.args.get('path', '')
if not path:
return jsonify({'status': 'error', 'message': 'Missing path parameter'}), 400
return api_error('Missing path parameter', 400)
# Security: only allow checking within static/vendor
if not path.startswith('/static/vendor/'):
return jsonify({'status': 'error', 'message': 'Invalid path'}), 400
return api_error('Invalid path', 400)
# Remove leading slash and construct full path
relative_path = path.lstrip('/')
@@ -159,8 +152,4 @@ def check_asset():
exists = os.path.exists(full_path)
return jsonify({
'status': 'success',
'path': path,
'exists': exists
})
return api_success(data={'path': path, 'exists': exists})
+353
View File
@@ -0,0 +1,353 @@
"""Generic OOK signal decoder routes.
Captures raw OOK frames using rtl_433's flex decoder and streams decoded
bit/hex data to the browser for live ASCII interpretation. Supports
PWM, PPM, and Manchester modulation with fully configurable pulse timing.
"""
from __future__ import annotations
import contextlib
import os
import queue
import signal
import subprocess
import threading
from typing import Any
from flask import Blueprint, Response, jsonify, request
import app as app_module
from utils.event_pipeline import process_event
from utils.logging import sensor_logger as logger
from utils.ook import ook_parser_thread
from utils.process import register_process, safe_terminate, unregister_process
from utils.responses import api_error
from utils.sdr import SDRFactory, SDRType
from utils.sse import sse_stream_fanout
from utils.validation import (
validate_device_index,
validate_frequency,
validate_gain,
validate_positive_int,
validate_ppm,
validate_rtl_tcp_host,
validate_rtl_tcp_port,
)
ook_bp = Blueprint('ook', __name__)
# Track which device / SDR type is being used
ook_active_device: int | None = None
ook_active_sdr_type: str | None = None
# Parser thread state (avoids monkey-patching subprocess.Popen)
_ook_stop_event: threading.Event | None = None
_ook_parser_thread: threading.Thread | None = None
# Supported modulation schemes → rtl_433 flex decoder modulation string
_MODULATION_MAP = {
'pwm': 'OOK_PWM',
'ppm': 'OOK_PPM',
'manchester': 'OOK_MC_ZEROBIT',
}
def _validate_encoding(value: Any) -> str:
enc = str(value).lower().strip()
if enc not in _MODULATION_MAP:
raise ValueError(f"encoding must be one of: {', '.join(_MODULATION_MAP)}")
return enc
@ook_bp.route('/ook/start', methods=['POST'])
def start_ook() -> Response:
global ook_active_device, ook_active_sdr_type, _ook_stop_event, _ook_parser_thread
with app_module.ook_lock:
if app_module.ook_process:
# If the process exited/crashed, clean up stale state and allow restart
if app_module.ook_process.poll() is not None:
cleanup_ook(emit_status=False)
else:
return api_error('OOK decoder already running', 409)
data = request.json or {}
try:
freq = validate_frequency(data.get('frequency', '433.920'))
gain = validate_gain(data.get('gain', '0'))
ppm = validate_ppm(data.get('ppm', '0'))
device = validate_device_index(data.get('device', '0'))
except ValueError as e:
return api_error(str(e), 400)
try:
encoding = _validate_encoding(data.get('encoding', 'pwm'))
except ValueError as e:
return api_error(str(e), 400)
# OOK flex decoder timing parameters (server-side range validation)
try:
short_pulse = validate_positive_int(data.get('short_pulse', 300), 'short_pulse', max_val=100000)
long_pulse = validate_positive_int(data.get('long_pulse', 600), 'long_pulse', max_val=100000)
reset_limit = validate_positive_int(data.get('reset_limit', 8000), 'reset_limit', max_val=1000000)
gap_limit = validate_positive_int(data.get('gap_limit', 5000), 'gap_limit', max_val=1000000)
tolerance = validate_positive_int(data.get('tolerance', 150), 'tolerance', max_val=50000)
min_bits = validate_positive_int(data.get('min_bits', 8), 'min_bits', max_val=4096)
except ValueError as e:
return api_error(f'Invalid timing parameter: {e}', 400)
if min_bits < 1:
return api_error('min_bits must be >= 1', 400)
if short_pulse < 1 or long_pulse < 1:
return api_error('Pulse widths must be >= 1', 400)
deduplicate = bool(data.get('deduplicate', False))
# Parse SDR type early — needed for device claim
sdr_type_str = data.get('sdr_type', 'rtlsdr')
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
sdr_type_str = 'rtlsdr'
rtl_tcp_host = data.get('rtl_tcp_host') or None
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
if not rtl_tcp_host:
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'ook', sdr_type_str)
if error:
return api_error(error, 409, error_type='DEVICE_BUSY')
ook_active_device = device_int
ook_active_sdr_type = sdr_type_str
while not app_module.ook_queue.empty():
try:
app_module.ook_queue.get_nowait()
except queue.Empty:
break
if rtl_tcp_host:
try:
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
except ValueError as e:
return api_error(str(e), 400)
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
logger.info(f'Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}')
else:
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
builder = SDRFactory.get_builder(sdr_device.sdr_type)
bias_t = data.get('bias_t', False)
# Build base ISM command then replace protocol flags with flex decoder
cmd = builder.build_ism_command(
device=sdr_device,
frequency_mhz=freq,
gain=float(gain) if gain and gain != 0 else None,
ppm=int(ppm) if ppm and ppm != 0 else None,
bias_t=bias_t,
)
modulation = _MODULATION_MAP[encoding]
flex_spec = (
f'n=ook,m={modulation},'
f's={short_pulse},l={long_pulse},'
f'r={reset_limit},g={gap_limit},'
f't={tolerance},bits>={min_bits}'
)
# Strip any existing -R flags from the base command
filtered_cmd: list[str] = []
skip_next = False
for arg in cmd:
if skip_next:
skip_next = False
continue
if arg == '-R':
skip_next = True
continue
filtered_cmd.append(arg)
filtered_cmd.extend(['-M', 'level', '-R', '0', '-X', flex_spec])
full_cmd = ' '.join(filtered_cmd)
logger.info(f'OOK decoder running: {full_cmd}')
try:
rtl_process = subprocess.Popen(
filtered_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
start_new_session=True,
)
register_process(rtl_process)
_stderr_noise = ('bitbuffer_add_bit', 'row count limit')
def monitor_stderr() -> None:
for line in rtl_process.stderr:
err_text = line.decode('utf-8', errors='replace').strip()
if err_text and not any(n in err_text for n in _stderr_noise):
logger.debug(f'[rtl_433/ook] {err_text}')
stderr_thread = threading.Thread(target=monitor_stderr)
stderr_thread.daemon = True
stderr_thread.start()
stop_event = threading.Event()
parser_thread = threading.Thread(
target=ook_parser_thread,
args=(
rtl_process.stdout,
app_module.ook_queue,
stop_event,
encoding,
deduplicate,
),
)
parser_thread.daemon = True
parser_thread.start()
app_module.ook_process = rtl_process
_ook_stop_event = stop_event
_ook_parser_thread = parser_thread
try:
app_module.ook_queue.put_nowait({'type': 'status', 'text': 'started'})
except queue.Full:
logger.warning("OOK 'started' status dropped — queue full")
return jsonify({
'status': 'started',
'command': full_cmd,
'encoding': encoding,
'modulation': modulation,
'flex_spec': flex_spec,
'deduplicate': deduplicate,
})
except FileNotFoundError as e:
if ook_active_device is not None:
app_module.release_sdr_device(ook_active_device, ook_active_sdr_type or 'rtlsdr')
ook_active_device = None
ook_active_sdr_type = None
return api_error(f'Tool not found: {e.filename}', 400)
except Exception as e:
try:
rtl_process.terminate()
rtl_process.wait(timeout=2)
except Exception:
with contextlib.suppress(Exception):
rtl_process.kill()
unregister_process(rtl_process)
if ook_active_device is not None:
app_module.release_sdr_device(ook_active_device, ook_active_sdr_type or 'rtlsdr')
ook_active_device = None
ook_active_sdr_type = None
return api_error(str(e), 500)
def _close_pipe(pipe_obj) -> None:
"""Close a subprocess pipe, suppressing errors."""
if pipe_obj is not None:
with contextlib.suppress(Exception):
pipe_obj.close()
def cleanup_ook(*, emit_status: bool = True) -> None:
"""Full OOK cleanup: stop parser, terminate process, release SDR device.
Safe to call from ``stop_ook()`` and ``kill_all()``. Caller must hold
``app_module.ook_lock``.
"""
global ook_active_device, ook_active_sdr_type, _ook_stop_event, _ook_parser_thread
proc = app_module.ook_process
if not proc:
return
# Signal parser thread to stop
if _ook_stop_event:
_ook_stop_event.set()
# Close pipes so parser thread unblocks from readline()
_close_pipe(getattr(proc, 'stdout', None))
_close_pipe(getattr(proc, 'stderr', None))
# Kill the entire process group so child processes are cleaned up
try:
pgid = os.getpgid(proc.pid)
os.killpg(pgid, signal.SIGTERM)
try:
proc.wait(timeout=2)
except subprocess.TimeoutExpired:
os.killpg(pgid, signal.SIGKILL)
proc.wait(timeout=3)
except (ProcessLookupError, OSError):
# Process already dead — fall back to normal terminate
safe_terminate(proc)
unregister_process(proc)
app_module.ook_process = None
# Join parser thread with timeout
if _ook_parser_thread:
_ook_parser_thread.join(timeout=0.5)
_ook_stop_event = None
_ook_parser_thread = None
if ook_active_device is not None:
app_module.release_sdr_device(ook_active_device, ook_active_sdr_type or 'rtlsdr')
ook_active_device = None
ook_active_sdr_type = None
if emit_status:
try:
app_module.ook_queue.put_nowait({'type': 'status', 'text': 'stopped'})
except queue.Full:
logger.warning("OOK 'stopped' status dropped — queue full")
@ook_bp.route('/ook/stop', methods=['POST'])
def stop_ook() -> Response:
with app_module.ook_lock:
if app_module.ook_process:
cleanup_ook()
return jsonify({'status': 'stopped'})
return jsonify({'status': 'not_running'})
@ook_bp.route('/ook/status')
def ook_status() -> Response:
with app_module.ook_lock:
running = (
app_module.ook_process is not None
and app_module.ook_process.poll() is None
)
return jsonify({'running': running})
@ook_bp.route('/ook/stream')
def ook_stream() -> Response:
def _on_msg(msg: dict[str, Any]) -> None:
process_event('ook', msg, msg.get('type'))
response = Response(
sse_stream_fanout(
source_queue=app_module.ook_queue,
channel_key='ook',
timeout=1.0,
keepalive_interval=30.0,
on_message=_on_msg,
),
mimetype='text/event-stream',
)
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response
+99 -82
View File
@@ -2,38 +2,45 @@
from __future__ import annotations
import contextlib
import math
import os
import pathlib
import re
import pty
import queue
import re
import select
import struct
import subprocess
import threading
import time
from datetime import datetime
from typing import Any, Generator
from typing import Any
from flask import Blueprint, jsonify, request, Response
from flask import Blueprint, Response, jsonify, request
import app as app_module
from utils.logging import pager_logger as logger
from utils.validation import (
validate_frequency, validate_device_index, validate_gain, validate_ppm,
validate_rtl_tcp_host, validate_rtl_tcp_port
)
from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.process import safe_terminate, register_process, unregister_process
from utils.sdr import SDRFactory, SDRType, SDRValidationError
from utils.dependencies import get_tool_path
from utils.event_pipeline import process_event
from utils.logging import pager_logger as logger
from utils.process import register_process, unregister_process
from utils.responses import api_error
from utils.sdr import SDRFactory, SDRType
from utils.sse import sse_stream_fanout
from utils.validation import (
validate_device_index,
validate_frequency,
validate_gain,
validate_ppm,
validate_rtl_tcp_host,
validate_rtl_tcp_port,
)
pager_bp = Blueprint('pager', __name__)
# Track which device is being used
pager_active_device: int | None = None
pager_active_sdr_type: str | None = None
def parse_multimon_output(line: str) -> dict[str, str] | None:
@@ -54,6 +61,20 @@ def parse_multimon_output(line: str) -> dict[str, str] | None:
'message': pocsag_match.group(5).strip() or '[No Message]'
}
# POCSAG parsing - other content types (catch-all for non-Alpha/Numeric labels)
pocsag_other_match = re.match(
r'(POCSAG\d+):\s*Address:\s*(\d+)\s+Function:\s*(\d+)\s+(\w+):\s*(.*)',
line
)
if pocsag_other_match:
return {
'protocol': pocsag_other_match.group(1),
'address': pocsag_other_match.group(2),
'function': pocsag_other_match.group(3),
'msg_type': pocsag_other_match.group(4),
'message': pocsag_other_match.group(5).strip() or '[No Message]'
}
# POCSAG parsing - address only (no message content)
pocsag_addr_match = re.match(
r'(POCSAG\d+):\s*Address:\s*(\d+)\s+Function:\s*(\d+)\s*$',
@@ -108,6 +129,20 @@ def log_message(msg: dict[str, Any]) -> None:
logger.error(f"Failed to log message: {e}")
def _encode_scope_waveform(samples: tuple[int, ...], window_size: int = 256) -> list[int]:
"""Compress recent PCM samples into a signed 8-bit waveform for SSE."""
if not samples:
return []
window = samples[-window_size:] if len(samples) > window_size else samples
waveform: list[int] = []
for sample in window:
# Convert int16 PCM to int8 range for lightweight transport.
packed = int(round(sample / 256))
waveform.append(max(-127, min(127, packed)))
return waveform
def audio_relay_thread(
rtl_stdout,
multimon_stdin,
@@ -118,7 +153,7 @@ def audio_relay_thread(
Reads raw 16-bit LE PCM from *rtl_stdout*, writes every chunk straight
through to *multimon_stdin*, and every ~100 ms pushes an RMS / peak scope
event onto *output_queue*.
event plus a compact waveform sample onto *output_queue*.
"""
CHUNK = 4096 # bytes 2048 samples at 16-bit mono
INTERVAL = 0.1 # seconds between scope updates
@@ -152,16 +187,15 @@ def audio_relay_thread(
'type': 'scope',
'rms': rms,
'peak': peak,
'waveform': _encode_scope_waveform(samples),
})
except (struct.error, ValueError, queue.Full):
pass
except Exception as e:
logger.debug(f"Audio relay error: {e}")
finally:
try:
with contextlib.suppress(OSError):
multimon_stdin.close()
except OSError:
pass
def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
@@ -205,11 +239,9 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
except Exception as e:
app_module.output_queue.put({'type': 'error', 'text': str(e)})
finally:
global pager_active_device
try:
global pager_active_device, pager_active_sdr_type
with contextlib.suppress(OSError):
os.close(master_fd)
except OSError:
pass
# Signal relay thread to stop
with app_module.process_lock:
stop_relay = getattr(app_module.current_process, '_stop_relay', None)
@@ -224,27 +256,26 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
proc.terminate()
proc.wait(timeout=2)
except Exception:
try:
with contextlib.suppress(Exception):
proc.kill()
except Exception:
pass
unregister_process(proc)
app_module.output_queue.put({'type': 'status', 'text': 'stopped'})
with app_module.process_lock:
app_module.current_process = None
# Release SDR device
if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device)
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
pager_active_device = None
pager_active_sdr_type = None
@pager_bp.route('/start', methods=['POST'])
def start_decoding() -> Response:
global pager_active_device
global pager_active_device, pager_active_sdr_type
with app_module.process_lock:
if app_module.current_process:
return jsonify({'status': 'error', 'message': 'Already running'}), 409
return api_error('Already running', 409)
data = request.json or {}
@@ -255,7 +286,7 @@ def start_decoding() -> Response:
ppm = validate_ppm(data.get('ppm', '0'))
device = validate_device_index(data.get('device', '0'))
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
return api_error(str(e), 400)
squelch = data.get('squelch', '0')
try:
@@ -263,32 +294,33 @@ def start_decoding() -> Response:
if not 0 <= squelch <= 1000:
raise ValueError("Squelch must be between 0 and 1000")
except (ValueError, TypeError):
return jsonify({'status': 'error', 'message': 'Invalid squelch value'}), 400
return api_error('Invalid squelch value', 400)
# Check for rtl_tcp (remote SDR) connection
rtl_tcp_host = data.get('rtl_tcp_host')
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
# Get SDR type early so we can pass it to claim/release
sdr_type_str = data.get('sdr_type', 'rtlsdr')
# Claim local device if not using remote rtl_tcp
if not rtl_tcp_host:
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'pager')
error = app_module.claim_sdr_device(device_int, 'pager', sdr_type_str)
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
return api_error(error, 409, error_type='DEVICE_BUSY')
pager_active_device = device_int
pager_active_sdr_type = sdr_type_str
# Validate protocols
valid_protocols = ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX']
protocols = data.get('protocols', valid_protocols)
if not isinstance(protocols, list):
if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device)
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
pager_active_device = None
return jsonify({'status': 'error', 'message': 'Protocols must be a list'}), 400
pager_active_sdr_type = None
return api_error('Protocols must be a list', 400)
protocols = [p for p in protocols if p in valid_protocols]
if not protocols:
protocols = valid_protocols
@@ -312,8 +344,7 @@ def start_decoding() -> Response:
elif proto == 'FLEX':
decoders.extend(['-a', 'FLEX'])
# Get SDR type and build command via abstraction layer
sdr_type_str = data.get('sdr_type', 'rtlsdr')
# Build command via SDR abstraction layer
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
@@ -325,7 +356,7 @@ def start_decoding() -> Response:
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
return api_error(str(e), 400)
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
@@ -350,7 +381,7 @@ def start_decoding() -> Response:
multimon_path = get_tool_path('multimon-ng')
if not multimon_path:
return jsonify({'status': 'error', 'message': 'multimon-ng not found'}), 400
return api_error('multimon-ng not found', 400)
multimon_cmd = [multimon_path, '-t', 'raw'] + decoders + ['-f', 'alpha', '-']
full_cmd = ' '.join(rtl_cmd) + ' | ' + ' '.join(multimon_cmd)
@@ -422,35 +453,33 @@ def start_decoding() -> Response:
rtl_process.terminate()
rtl_process.wait(timeout=2)
except Exception:
try:
with contextlib.suppress(Exception):
rtl_process.kill()
except Exception:
pass
# Release device on failure
if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device)
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
pager_active_device = None
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'})
pager_active_sdr_type = None
return api_error(f'Tool not found: {e.filename}')
except Exception as e:
# Kill orphaned rtl_fm process if it was started
try:
rtl_process.terminate()
rtl_process.wait(timeout=2)
except Exception:
try:
with contextlib.suppress(Exception):
rtl_process.kill()
except Exception:
pass
# Release device on failure
if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device)
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
pager_active_device = None
return jsonify({'status': 'error', 'message': str(e)})
pager_active_sdr_type = None
return api_error(str(e))
@pager_bp.route('/stop', methods=['POST'])
def stop_decoding() -> Response:
global pager_active_device
global pager_active_device, pager_active_sdr_type
with app_module.process_lock:
if app_module.current_process:
@@ -464,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()
@@ -487,8 +512,9 @@ def stop_decoding() -> Response:
# Release device from registry
if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device)
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
pager_active_device = None
pager_active_sdr_type = None
return jsonify({'status': 'stopped'})
@@ -524,44 +550,35 @@ def toggle_logging() -> Response:
is_in_logs = str(requested_path).startswith(str(logs_dir))
if not (is_in_cwd or is_in_logs):
return jsonify({'status': 'error', 'message': 'Invalid log file path'}), 400
return api_error('Invalid log file path', 400)
# Ensure it's not a directory
if requested_path.is_dir():
return jsonify({'status': 'error', 'message': 'Log file path must be a file, not a directory'}), 400
return api_error('Log file path must be a file, not a directory', 400)
app_module.log_file_path = str(requested_path)
except (ValueError, OSError) as e:
logger.warning(f"Invalid log file path: {e}")
return jsonify({'status': 'error', 'message': 'Invalid log file path'}), 400
return api_error('Invalid log file path', 400)
return jsonify({'logging': app_module.logging_enabled, 'log_file': app_module.log_file_path})
@pager_bp.route('/stream')
def stream() -> Response:
import json
def _on_msg(msg: dict[str, Any]) -> None:
process_event('pager', msg, msg.get('type'))
def generate() -> Generator[str, None, None]:
last_keepalive = time.time()
keepalive_interval = 30.0 # Send keepalive every 30 seconds instead of 1 second
while True:
try:
msg = app_module.output_queue.get(timeout=1)
last_keepalive = time.time()
try:
process_event('pager', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg)
except queue.Empty:
now = time.time()
if now - last_keepalive >= keepalive_interval:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream')
response = Response(
sse_stream_fanout(
source_queue=app_module.output_queue,
channel_key='pager',
timeout=1.0,
keepalive_interval=30.0,
on_message=_on_msg,
),
mimetype='text/event-stream',
)
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
+709
View File
@@ -0,0 +1,709 @@
"""Radiosonde weather balloon tracking routes.
Uses radiosonde_auto_rx to automatically scan for and decode radiosonde
telemetry (position, altitude, temperature, humidity, pressure) on the
400-406 MHz band. Telemetry arrives as JSON over UDP.
"""
from __future__ import annotations
import contextlib
import json
import os
import queue
import shutil
import socket
import subprocess
import sys
import threading
import time
from typing import Any
from flask import Blueprint, Response, jsonify, request
import app as app_module
from utils.constants import (
MAX_RADIOSONDE_AGE_SECONDS,
PROCESS_TERMINATE_TIMEOUT,
RADIOSONDE_TERMINATE_TIMEOUT,
RADIOSONDE_UDP_PORT,
SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT,
)
from utils.gps import is_gpsd_running
from utils.logging import get_logger
from utils.responses import api_error, api_success
from utils.sdr import SDRFactory, SDRType
from utils.sse import sse_stream_fanout
from utils.validation import (
validate_device_index,
validate_gain,
validate_latitude,
validate_longitude,
)
logger = get_logger('intercept.radiosonde')
radiosonde_bp = Blueprint('radiosonde', __name__, url_prefix='/radiosonde')
# Track radiosonde state
radiosonde_running = False
radiosonde_active_device: int | None = None
radiosonde_active_sdr_type: str | None = None
# Active balloon data: serial -> telemetry dict
radiosonde_balloons: dict[str, dict[str, Any]] = {}
_balloons_lock = threading.Lock()
# UDP listener socket reference (so /stop can close it)
_udp_socket: socket.socket | None = None
# Common installation paths for radiosonde_auto_rx
AUTO_RX_PATHS = [
'/opt/radiosonde_auto_rx/auto_rx/auto_rx.py',
'/usr/local/bin/radiosonde_auto_rx',
'/opt/auto_rx/auto_rx.py',
]
def find_auto_rx() -> str | None:
"""Find radiosonde_auto_rx script/binary."""
# Check PATH first
path = shutil.which('radiosonde_auto_rx')
if path:
return path
# Check common locations
for p in AUTO_RX_PATHS:
if os.path.isfile(p) and os.access(p, os.X_OK):
return p
# Check for Python script (not executable but runnable)
for p in AUTO_RX_PATHS:
if os.path.isfile(p):
return p
return None
def generate_station_cfg(
freq_min: float = 400.0,
freq_max: float = 406.0,
gain: float = 40.0,
device_index: int = 0,
ppm: int = 0,
bias_t: bool = False,
udp_port: int = RADIOSONDE_UDP_PORT,
latitude: float = 0.0,
longitude: float = 0.0,
station_alt: float = 0.0,
gpsd_enabled: bool = False,
) -> str:
"""Generate a station.cfg for radiosonde_auto_rx and return the file path."""
cfg_dir = os.path.abspath(os.path.join('data', 'radiosonde'))
log_dir = os.path.join(cfg_dir, 'logs')
os.makedirs(log_dir, exist_ok=True)
cfg_path = os.path.join(cfg_dir, 'station.cfg')
# Full station.cfg based on radiosonde_auto_rx v1.8+ example config.
# All sections and keys included to avoid missing-key crashes.
cfg = f"""# Auto-generated by INTERCEPT for radiosonde_auto_rx
[sdr]
sdr_type = RTLSDR
sdr_quantity = 1
sdr_hostname = localhost
sdr_port = 5555
[sdr_1]
device_idx = {device_index}
ppm = {ppm}
gain = {gain}
bias = {str(bias_t)}
[search_params]
min_freq = {freq_min}
max_freq = {freq_max}
rx_timeout = 180
only_scan = []
never_scan = []
always_scan = []
always_decode = []
[location]
station_lat = {latitude}
station_lon = {longitude}
station_alt = {station_alt}
gpsd_enabled = {str(gpsd_enabled)}
gpsd_host = localhost
gpsd_port = 2947
[habitat]
uploader_callsign = INTERCEPT
upload_listener_position = False
uploader_antenna = unknown
[sondehub]
sondehub_enabled = False
sondehub_upload_rate = 15
sondehub_contact_email = none@none.com
[aprs]
aprs_enabled = False
aprs_user = N0CALL
aprs_pass = 00000
upload_rate = 30
aprs_server = radiosondy.info
aprs_port = 14580
station_beacon_enabled = False
station_beacon_rate = 30
station_beacon_comment = radiosonde_auto_rx
station_beacon_icon = /`
aprs_object_id = <id>
aprs_use_custom_object_id = False
aprs_custom_comment = <type> <freq>
[oziplotter]
ozi_enabled = False
ozi_update_rate = 5
ozi_host = 127.0.0.1
ozi_port = 8942
payload_summary_enabled = True
payload_summary_host = 127.0.0.1
payload_summary_port = {udp_port}
[email]
email_enabled = False
launch_notifications = True
landing_notifications = True
encrypted_sonde_notifications = True
landing_range_threshold = 30
landing_altitude_threshold = 1000
error_notifications = False
smtp_server = localhost
smtp_port = 25
smtp_authentication = None
smtp_login = None
smtp_password = None
from = sonde@localhost
to = none@none.com
subject = Sonde launch detected
[rotator]
rotator_enabled = False
update_rate = 30
rotation_threshold = 5.0
rotator_hostname = 127.0.0.1
rotator_port = 4533
rotator_homing_enabled = False
rotator_homing_delay = 10
rotator_home_azimuth = 0.0
rotator_home_elevation = 0.0
azimuth_only = False
[logging]
per_sonde_log = True
save_system_log = False
enable_debug_logging = False
save_cal_data = False
[web]
web_host = 127.0.0.1
web_port = 0
archive_age = 120
web_control = False
web_password = none
kml_refresh_rate = 10
[debugging]
save_detection_audio = False
save_decode_audio = False
save_decode_iq = False
save_raw_hex = False
[advanced]
search_step = 800
snr_threshold = 10
max_peaks = 10
min_distance = 1000
scan_dwell_time = 20
detect_dwell_time = 5
scan_delay = 10
quantization = 10000
decoder_spacing_limit = 15000
temporary_block_time = 120
max_async_scan_workers = 4
synchronous_upload = True
payload_id_valid = 3
sdr_fm_path = rtl_fm
sdr_power_path = rtl_power
ss_iq_path = ./ss_iq
ss_power_path = ./ss_power
[filtering]
max_altitude = 50000
max_radius_km = 1000
min_radius_km = 0
radius_temporary_block = False
sonde_time_threshold = 3
"""
try:
with open(cfg_path, 'w') as f:
f.write(cfg)
except OSError as e:
logger.error(f"Cannot write station.cfg to {cfg_path}: {e}")
raise RuntimeError(
f"Cannot write radiosonde config to {cfg_path}: {e}. "
f"Fix permissions with: sudo chown -R $(whoami) {cfg_dir}"
) from e
# When running as root via sudo, fix ownership so next non-root run
# can still read/write these files.
_fix_data_ownership(cfg_dir)
logger.info(f"Generated station.cfg at {cfg_path}")
return cfg_path
def _fix_data_ownership(path: str) -> None:
"""Recursively chown a path to the real (non-root) user when running via sudo."""
uid = os.environ.get('INTERCEPT_SUDO_UID')
gid = os.environ.get('INTERCEPT_SUDO_GID')
if uid is None or gid is None:
return
try:
uid_int, gid_int = int(uid), int(gid)
for dirpath, _dirnames, filenames in os.walk(path):
os.chown(dirpath, uid_int, gid_int)
for fname in filenames:
os.chown(os.path.join(dirpath, fname), uid_int, gid_int)
except OSError as e:
logger.warning(f"Could not fix ownership of {path}: {e}")
def parse_radiosonde_udp(udp_port: int) -> None:
"""Thread function: listen for radiosonde_auto_rx UDP JSON telemetry."""
global radiosonde_running, _udp_socket
logger.info(f"Radiosonde UDP listener started on port {udp_port}")
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(('0.0.0.0', udp_port))
sock.settimeout(2.0)
_udp_socket = sock
except OSError as e:
logger.error(f"Failed to bind UDP port {udp_port}: {e}")
return
while radiosonde_running:
try:
data, _addr = sock.recvfrom(4096)
except socket.timeout:
# Clean up stale balloons
_cleanup_stale_balloons()
continue
except OSError:
break
try:
msg = json.loads(data.decode('utf-8', errors='ignore'))
except (json.JSONDecodeError, UnicodeDecodeError):
continue
balloon = _process_telemetry(msg)
if balloon:
serial = balloon.get('id', '')
if serial:
with _balloons_lock:
radiosonde_balloons[serial] = balloon
with contextlib.suppress(queue.Full):
app_module.radiosonde_queue.put_nowait({
'type': 'balloon',
**balloon,
})
with contextlib.suppress(OSError):
sock.close()
_udp_socket = None
logger.info("Radiosonde UDP listener stopped")
def _process_telemetry(msg: dict) -> dict | None:
"""Extract relevant fields from a radiosonde_auto_rx UDP telemetry packet."""
# auto_rx broadcasts packets with a 'type' field
# Telemetry packets have type 'payload_summary' or individual sonde data
serial = msg.get('id') or msg.get('serial')
if not serial:
return None
balloon: dict[str, Any] = {'id': str(serial)}
# Sonde type (RS41, RS92, DFM, M10, etc.) — prefer subtype if available
if 'subtype' in msg:
balloon['sonde_type'] = msg['subtype']
elif 'type' in msg:
balloon['sonde_type'] = msg['type']
# Timestamp
if 'datetime' in msg:
balloon['datetime'] = msg['datetime']
# Position
for key in ('lat', 'latitude'):
if key in msg:
with contextlib.suppress(ValueError, TypeError):
balloon['lat'] = float(msg[key])
break
for key in ('lon', 'longitude'):
if key in msg:
with contextlib.suppress(ValueError, TypeError):
balloon['lon'] = float(msg[key])
break
# Altitude (metres)
if 'alt' in msg:
with contextlib.suppress(ValueError, TypeError):
balloon['alt'] = float(msg['alt'])
# Meteorological data
for field in ('temp', 'humidity', 'pressure'):
if field in msg:
with contextlib.suppress(ValueError, TypeError):
balloon[field] = float(msg[field])
# Velocity
if 'vel_h' in msg:
with contextlib.suppress(ValueError, TypeError):
balloon['vel_h'] = float(msg['vel_h'])
if 'vel_v' in msg:
with contextlib.suppress(ValueError, TypeError):
balloon['vel_v'] = float(msg['vel_v'])
if 'heading' in msg:
with contextlib.suppress(ValueError, TypeError):
balloon['heading'] = float(msg['heading'])
# GPS satellites
if 'sats' in msg:
with contextlib.suppress(ValueError, TypeError):
balloon['sats'] = int(msg['sats'])
# Battery voltage
if 'batt' in msg:
with contextlib.suppress(ValueError, TypeError):
balloon['batt'] = float(msg['batt'])
# Frequency
if 'freq' in msg:
with contextlib.suppress(ValueError, TypeError):
balloon['freq'] = float(msg['freq'])
balloon['last_seen'] = time.time()
return balloon
def _cleanup_stale_balloons() -> None:
"""Remove balloons not seen within the retention window."""
now = time.time()
with _balloons_lock:
stale = [
k for k, v in radiosonde_balloons.items()
if now - v.get('last_seen', 0) > MAX_RADIOSONDE_AGE_SECONDS
]
for k in stale:
del radiosonde_balloons[k]
@radiosonde_bp.route('/tools')
def check_tools():
"""Check for radiosonde decoding tools and hardware."""
auto_rx_path = find_auto_rx()
devices = SDRFactory.detect_devices()
has_rtlsdr = any(d.sdr_type == SDRType.RTL_SDR for d in devices)
return jsonify({
'auto_rx': auto_rx_path is not None,
'auto_rx_path': auto_rx_path,
'has_rtlsdr': has_rtlsdr,
'device_count': len(devices),
})
@radiosonde_bp.route('/status')
def radiosonde_status():
"""Get radiosonde tracking status."""
process_running = False
if app_module.radiosonde_process:
process_running = app_module.radiosonde_process.poll() is None
with _balloons_lock:
balloon_count = len(radiosonde_balloons)
balloons_snapshot = dict(radiosonde_balloons)
return jsonify({
'tracking_active': radiosonde_running,
'active_device': radiosonde_active_device,
'balloon_count': balloon_count,
'balloons': balloons_snapshot,
'queue_size': app_module.radiosonde_queue.qsize(),
'auto_rx_path': find_auto_rx(),
'process_running': process_running,
})
@radiosonde_bp.route('/start', methods=['POST'])
def start_radiosonde():
"""Start radiosonde tracking."""
global radiosonde_running, radiosonde_active_device, radiosonde_active_sdr_type
with app_module.radiosonde_lock:
if radiosonde_running:
return api_error('Radiosonde tracking already active', 409)
data = request.json or {}
# Validate inputs
try:
gain = float(validate_gain(data.get('gain', '40')))
device = validate_device_index(data.get('device', '0'))
except ValueError as e:
return api_error(str(e), 400)
freq_min = data.get('freq_min', 400.0)
freq_max = data.get('freq_max', 406.0)
try:
freq_min = float(freq_min)
freq_max = float(freq_max)
if not (380.0 <= freq_min <= 410.0) or not (380.0 <= freq_max <= 410.0):
raise ValueError("Frequency out of range")
if freq_min >= freq_max:
raise ValueError("Min frequency must be less than max")
except (ValueError, TypeError) as e:
return api_error(f'Invalid frequency range: {e}', 400)
bias_t = data.get('bias_t', False)
ppm = int(data.get('ppm', 0))
# Validate optional location
latitude = 0.0
longitude = 0.0
if data.get('latitude') is not None and data.get('longitude') is not None:
try:
latitude = validate_latitude(data['latitude'])
longitude = validate_longitude(data['longitude'])
except ValueError:
latitude = 0.0
longitude = 0.0
# Check if gpsd is available for live position updates
gpsd_enabled = is_gpsd_running()
# Find auto_rx
auto_rx_path = find_auto_rx()
if not auto_rx_path:
return api_error('radiosonde_auto_rx not found. Install from https://github.com/projecthorus/radiosonde_auto_rx', 400)
# Get SDR type
sdr_type_str = data.get('sdr_type', 'rtlsdr')
# Kill any existing process
if app_module.radiosonde_process:
try:
pgid = os.getpgid(app_module.radiosonde_process.pid)
os.killpg(pgid, 15)
app_module.radiosonde_process.wait(timeout=PROCESS_TERMINATE_TIMEOUT)
except (subprocess.TimeoutExpired, ProcessLookupError, OSError):
try:
pgid = os.getpgid(app_module.radiosonde_process.pid)
os.killpg(pgid, 9)
except (ProcessLookupError, OSError):
pass
app_module.radiosonde_process = None
logger.info("Killed existing radiosonde process")
# Claim SDR device
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'radiosonde', sdr_type_str)
if error:
return api_error(error, 409, error_type='DEVICE_BUSY')
# Generate config
try:
cfg_path = generate_station_cfg(
freq_min=freq_min,
freq_max=freq_max,
gain=gain,
device_index=device_int,
ppm=ppm,
bias_t=bias_t,
latitude=latitude,
longitude=longitude,
gpsd_enabled=gpsd_enabled,
)
except (OSError, RuntimeError) as e:
app_module.release_sdr_device(device_int, sdr_type_str)
logger.error(f"Failed to generate radiosonde config: {e}")
return api_error(str(e), 500)
# Build command - auto_rx -c expects the path to station.cfg
cfg_abs = os.path.abspath(cfg_path)
if auto_rx_path.endswith('.py'):
cmd = [sys.executable, auto_rx_path, '-c', cfg_abs]
else:
cmd = [auto_rx_path, '-c', cfg_abs]
# Set cwd to the auto_rx directory so 'from autorx.scan import ...' works
auto_rx_dir = os.path.dirname(os.path.abspath(auto_rx_path))
# Quick dependency check before launching the full process
if auto_rx_path.endswith('.py'):
dep_check = subprocess.run(
[sys.executable, '-c', 'import autorx.scan'],
cwd=auto_rx_dir,
capture_output=True,
timeout=10,
)
if dep_check.returncode != 0:
dep_error = dep_check.stderr.decode('utf-8', errors='ignore').strip()
logger.error(f"radiosonde_auto_rx dependency check failed:\n{dep_error}")
app_module.release_sdr_device(device_int, sdr_type_str)
return api_error(
'radiosonde_auto_rx dependencies not satisfied. '
f'Re-run setup.sh to install. Error: {dep_error[:500]}',
500,
)
try:
logger.info(f"Starting radiosonde_auto_rx: {' '.join(cmd)}")
app_module.radiosonde_process = subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
start_new_session=True,
cwd=auto_rx_dir,
)
# Wait briefly for process to start
time.sleep(2.0)
if app_module.radiosonde_process.poll() is not None:
app_module.release_sdr_device(device_int, sdr_type_str)
stderr_output = ''
if app_module.radiosonde_process.stderr:
with contextlib.suppress(Exception):
stderr_output = app_module.radiosonde_process.stderr.read().decode(
'utf-8', errors='ignore'
).strip()
if stderr_output:
logger.error(f"radiosonde_auto_rx stderr:\n{stderr_output}")
if stderr_output and (
'ImportError' in stderr_output
or 'ModuleNotFoundError' in stderr_output
):
error_msg = (
'radiosonde_auto_rx failed to start due to missing Python '
'dependencies. Re-run setup.sh or reinstall radiosonde_auto_rx.'
)
else:
error_msg = (
'radiosonde_auto_rx failed to start. '
'Check SDR device connection.'
)
if stderr_output:
error_msg += f' Error: {stderr_output[:500]}'
return api_error(error_msg, 500)
radiosonde_running = True
radiosonde_active_device = device_int
radiosonde_active_sdr_type = sdr_type_str
# Clear stale data
with _balloons_lock:
radiosonde_balloons.clear()
# Start UDP listener thread
udp_thread = threading.Thread(
target=parse_radiosonde_udp,
args=(RADIOSONDE_UDP_PORT,),
daemon=True,
)
udp_thread.start()
return jsonify({
'status': 'started',
'message': 'Radiosonde tracking started',
'device': device,
})
except Exception as e:
app_module.release_sdr_device(device_int, sdr_type_str)
logger.error(f"Failed to start radiosonde_auto_rx: {e}")
return api_error(str(e), 500)
@radiosonde_bp.route('/stop', methods=['POST'])
def stop_radiosonde():
"""Stop radiosonde tracking."""
global radiosonde_running, radiosonde_active_device, radiosonde_active_sdr_type, _udp_socket
with app_module.radiosonde_lock:
if app_module.radiosonde_process:
try:
pgid = os.getpgid(app_module.radiosonde_process.pid)
os.killpg(pgid, 15)
app_module.radiosonde_process.wait(timeout=RADIOSONDE_TERMINATE_TIMEOUT)
except (subprocess.TimeoutExpired, ProcessLookupError, OSError):
try:
pgid = os.getpgid(app_module.radiosonde_process.pid)
os.killpg(pgid, 9)
except (ProcessLookupError, OSError):
pass
app_module.radiosonde_process = None
logger.info("Radiosonde process stopped")
# Close UDP socket to unblock listener thread
if _udp_socket:
with contextlib.suppress(OSError):
_udp_socket.close()
_udp_socket = None
# Release SDR device
if radiosonde_active_device is not None:
app_module.release_sdr_device(
radiosonde_active_device,
radiosonde_active_sdr_type or 'rtlsdr',
)
radiosonde_running = False
radiosonde_active_device = None
radiosonde_active_sdr_type = None
with _balloons_lock:
radiosonde_balloons.clear()
return jsonify({'status': 'stopped'})
@radiosonde_bp.route('/stream')
def stream_radiosonde():
"""SSE stream for radiosonde telemetry."""
response = Response(
sse_stream_fanout(
source_queue=app_module.radiosonde_queue,
channel_key='radiosonde',
timeout=SSE_QUEUE_TIMEOUT,
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
),
mimetype='text/event-stream',
)
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
return response
@radiosonde_bp.route('/balloons')
def get_balloons():
"""Get current balloon data."""
with _balloons_lock:
return api_success(data={
'count': len(radiosonde_balloons),
'balloons': dict(radiosonde_balloons),
})
+85 -35
View File
@@ -2,11 +2,13 @@
from __future__ import annotations
import json
from pathlib import Path
from flask import Blueprint, jsonify, request, send_file
from flask import Blueprint, request, send_file
from utils.recording import get_recording_manager, RECORDING_ROOT
from utils.recording import RECORDING_ROOT, get_recording_manager
from utils.responses import api_error, api_success
recordings_bp = Blueprint('recordings', __name__, url_prefix='/recordings')
@@ -16,7 +18,7 @@ def start_recording():
data = request.get_json() or {}
mode = (data.get('mode') or '').strip()
if not mode:
return jsonify({'status': 'error', 'message': 'mode is required'}), 400
return api_error('mode is required', 400)
label = data.get('label')
metadata = data.get('metadata') if isinstance(data.get('metadata'), dict) else {}
@@ -24,16 +26,13 @@ def start_recording():
manager = get_recording_manager()
session = manager.start_recording(mode=mode, label=label, metadata=metadata)
return jsonify({
'status': 'success',
'session': {
'id': session.id,
'mode': session.mode,
'label': session.label,
'started_at': session.started_at.isoformat(),
'file_path': str(session.file_path),
}
})
return api_success(data={'session': {
'id': session.id,
'mode': session.mode,
'label': session.label,
'started_at': session.started_at.isoformat(),
'file_path': str(session.file_path),
}})
@recordings_bp.route('/stop', methods=['POST'])
@@ -45,29 +44,25 @@ def stop_recording():
manager = get_recording_manager()
session = manager.stop_recording(mode=mode, session_id=session_id)
if not session:
return jsonify({'status': 'error', 'message': 'No active recording found'}), 404
return api_error('No active recording found', 404)
return jsonify({
'status': 'success',
'session': {
'id': session.id,
'mode': session.mode,
'label': session.label,
'started_at': session.started_at.isoformat(),
'stopped_at': session.stopped_at.isoformat() if session.stopped_at else None,
'event_count': session.event_count,
'size_bytes': session.size_bytes,
'file_path': str(session.file_path),
}
})
return api_success(data={'session': {
'id': session.id,
'mode': session.mode,
'label': session.label,
'started_at': session.started_at.isoformat(),
'stopped_at': session.stopped_at.isoformat() if session.stopped_at else None,
'event_count': session.event_count,
'size_bytes': session.size_bytes,
'file_path': str(session.file_path),
}})
@recordings_bp.route('', methods=['GET'])
def list_recordings():
manager = get_recording_manager()
limit = request.args.get('limit', default=50, type=int)
return jsonify({
'status': 'success',
return api_success(data={
'recordings': manager.list_recordings(limit=limit),
'active': manager.get_active(),
})
@@ -78,8 +73,8 @@ def get_recording(session_id: str):
manager = get_recording_manager()
rec = manager.get_recording(session_id)
if not rec:
return jsonify({'status': 'error', 'message': 'Recording not found'}), 404
return jsonify({'status': 'success', 'recording': rec})
return api_error('Recording not found', 404)
return api_success(data={'recording': rec})
@recordings_bp.route('/<session_id>/download', methods=['GET'])
@@ -87,19 +82,19 @@ def download_recording(session_id: str):
manager = get_recording_manager()
rec = manager.get_recording(session_id)
if not rec:
return jsonify({'status': 'error', 'message': 'Recording not found'}), 404
return api_error('Recording not found', 404)
file_path = Path(rec['file_path'])
try:
resolved_root = RECORDING_ROOT.resolve()
resolved_file = file_path.resolve()
if resolved_root not in resolved_file.parents:
return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400
return api_error('Invalid recording path', 400)
except Exception:
return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400
return api_error('Invalid recording path', 400)
if not file_path.exists():
return jsonify({'status': 'error', 'message': 'Recording file missing'}), 404
return api_error('Recording file missing', 404)
return send_file(
file_path,
@@ -107,3 +102,58 @@ def download_recording(session_id: str):
as_attachment=True,
download_name=file_path.name,
)
@recordings_bp.route('/<session_id>/events', methods=['GET'])
def get_recording_events(session_id: str):
"""Return parsed events from a recording for in-app replay."""
manager = get_recording_manager()
rec = manager.get_recording(session_id)
if not rec:
return api_error('Recording not found', 404)
file_path = Path(rec['file_path'])
try:
resolved_root = RECORDING_ROOT.resolve()
resolved_file = file_path.resolve()
if resolved_root not in resolved_file.parents:
return api_error('Invalid recording path', 400)
except Exception:
return api_error('Invalid recording path', 400)
if not file_path.exists():
return api_error('Recording file missing', 404)
limit = max(1, min(5000, request.args.get('limit', default=500, type=int)))
offset = max(0, request.args.get('offset', default=0, type=int))
events: list[dict] = []
seen = 0
with file_path.open('r', encoding='utf-8', errors='replace') as fh:
for idx, line in enumerate(fh):
if idx < offset:
continue
if seen >= limit:
break
line = line.strip()
if not line:
continue
try:
events.append(json.loads(line))
seen += 1
except json.JSONDecodeError:
continue
return api_success(data={
'recording': {
'id': rec['id'],
'mode': rec['mode'],
'started_at': rec['started_at'],
'stopped_at': rec['stopped_at'],
'event_count': rec['event_count'],
},
'offset': offset,
'limit': limit,
'returned': len(events),
'events': events,
})
+80 -77
View File
@@ -2,24 +2,23 @@
from __future__ import annotations
import contextlib
import json
import queue
import subprocess
import threading
import time
from datetime import datetime
from typing import Generator
from flask import Blueprint, jsonify, request, Response
from flask import Blueprint, Response, jsonify, request
import app as app_module
from utils.logging import sensor_logger as logger
from utils.validation import (
validate_frequency, validate_device_index, validate_gain, validate_ppm
)
from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.process import safe_terminate, register_process, unregister_process
from utils.logging import sensor_logger as logger
from utils.process import register_process, unregister_process
from utils.responses import api_error
from utils.sse import sse_stream_fanout
from utils.validation import validate_device_index, validate_frequency, validate_gain, validate_ppm
rtlamr_bp = Blueprint('rtlamr', __name__)
@@ -29,6 +28,7 @@ rtl_tcp_lock = threading.Lock()
# Track which device is being used
rtlamr_active_device: int | None = None
rtlamr_active_sdr_type: str = 'rtlsdr'
def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
@@ -62,16 +62,14 @@ def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
except Exception as e:
app_module.rtlamr_queue.put({'type': 'error', 'text': str(e)})
finally:
global rtl_tcp_process, rtlamr_active_device
global rtl_tcp_process, rtlamr_active_device, rtlamr_active_sdr_type
# Ensure rtlamr process is terminated
try:
process.terminate()
process.wait(timeout=2)
except Exception:
try:
with contextlib.suppress(Exception):
process.kill()
except Exception:
pass
unregister_process(process)
# Kill companion rtl_tcp process
with rtl_tcp_lock:
@@ -80,10 +78,8 @@ def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
rtl_tcp_process.terminate()
rtl_tcp_process.wait(timeout=2)
except Exception:
try:
with contextlib.suppress(Exception):
rtl_tcp_process.kill()
except Exception:
pass
unregister_process(rtl_tcp_process)
rtl_tcp_process = None
app_module.rtlamr_queue.put({'type': 'status', 'text': 'stopped'})
@@ -91,19 +87,23 @@ def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
app_module.rtlamr_process = None
# Release SDR device
if rtlamr_active_device is not None:
app_module.release_sdr_device(rtlamr_active_device)
app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
rtlamr_active_device = None
@rtlamr_bp.route('/start_rtlamr', methods=['POST'])
def start_rtlamr() -> Response:
global rtl_tcp_process, rtlamr_active_device
global rtl_tcp_process, rtlamr_active_device, rtlamr_active_sdr_type
with app_module.rtlamr_lock:
if app_module.rtlamr_process:
return jsonify({'status': 'error', 'message': 'RTLAMR already running'}), 409
return api_error('RTLAMR already running', 409)
data = request.json or {}
sdr_type_str = data.get('sdr_type', 'rtlsdr')
if sdr_type_str != 'rtlsdr':
return api_error(f'{sdr_type_str.replace("_", " ").title()} is not yet supported for this mode. Please use an RTL-SDR device.', 400)
# Validate inputs
try:
@@ -112,19 +112,16 @@ def start_rtlamr() -> Response:
ppm = validate_ppm(data.get('ppm', '0'))
device = validate_device_index(data.get('device', '0'))
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
return api_error(str(e), 400)
# Check if device is available
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'rtlamr')
error = app_module.claim_sdr_device(device_int, 'rtlamr', sdr_type_str)
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
return api_error(error, 409, error_type='DEVICE_BUSY')
rtlamr_active_device = device_int
rtlamr_active_sdr_type = sdr_type_str
# Clear queue
while not app_module.rtlamr_queue.empty():
@@ -136,45 +133,49 @@ def start_rtlamr() -> Response:
# Get message type (default to scm)
msgtype = data.get('msgtype', 'scm')
output_format = data.get('format', 'json')
# Start rtl_tcp first
rtl_tcp_just_started = False
rtl_tcp_cmd_str = ''
with rtl_tcp_lock:
if not rtl_tcp_process:
logger.info("Starting rtl_tcp server...")
try:
rtl_tcp_cmd = ['rtl_tcp', '-a', '0.0.0.0']
# Add device index if not 0
if device and device != '0':
rtl_tcp_cmd.extend(['-d', str(device)])
# Add gain if not auto
if gain and gain != '0':
rtl_tcp_cmd.extend(['-g', str(gain)])
# Add PPM correction if not 0
if ppm and ppm != '0':
rtl_tcp_cmd.extend(['-p', str(ppm)])
rtl_tcp_process = subprocess.Popen(
rtl_tcp_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
register_process(rtl_tcp_process)
# Wait a moment for rtl_tcp to start
time.sleep(3)
logger.info(f"rtl_tcp started: {' '.join(rtl_tcp_cmd)}")
app_module.rtlamr_queue.put({'type': 'info', 'text': f'rtl_tcp: {" ".join(rtl_tcp_cmd)}'})
rtl_tcp_just_started = True
rtl_tcp_cmd_str = ' '.join(rtl_tcp_cmd)
except Exception as e:
logger.error(f"Failed to start rtl_tcp: {e}")
# Release SDR device on rtl_tcp failure
if rtlamr_active_device is not None:
app_module.release_sdr_device(rtlamr_active_device)
app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
rtlamr_active_device = None
return jsonify({'status': 'error', 'message': f'Failed to start rtl_tcp: {e}'}), 500
return api_error(f'Failed to start rtl_tcp: {e}', 500)
# Wait for rtl_tcp to start outside lock
if rtl_tcp_just_started:
time.sleep(3)
logger.info(f"rtl_tcp started: {rtl_tcp_cmd_str}")
app_module.rtlamr_queue.put({'type': 'info', 'text': f'rtl_tcp: {rtl_tcp_cmd_str}'})
# Build rtlamr command
cmd = [
@@ -184,16 +185,16 @@ def start_rtlamr() -> Response:
f'-format={output_format}',
f'-centerfreq={int(float(freq) * 1e6)}'
]
# Add filter options if provided
filterid = data.get('filterid')
if filterid:
cmd.append(f'-filterid={filterid}')
filtertype = data.get('filtertype')
if filtertype:
cmd.append(f'-filtertype={filtertype}')
# Unique messages only
if data.get('unique', True):
cmd.append('-unique=true')
@@ -238,9 +239,9 @@ def start_rtlamr() -> Response:
rtl_tcp_process.wait(timeout=2)
rtl_tcp_process = None
if rtlamr_active_device is not None:
app_module.release_sdr_device(rtlamr_active_device)
app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
rtlamr_active_device = None
return jsonify({'status': 'error', 'message': 'rtlamr not found. Install from https://github.com/bemasher/rtlamr'})
return api_error('rtlamr not found. Install from https://github.com/bemasher/rtlamr')
except Exception as e:
# If rtlamr fails, clean up rtl_tcp and release device
with rtl_tcp_lock:
@@ -249,38 +250,47 @@ def start_rtlamr() -> Response:
rtl_tcp_process.wait(timeout=2)
rtl_tcp_process = None
if rtlamr_active_device is not None:
app_module.release_sdr_device(rtlamr_active_device)
app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
rtlamr_active_device = None
return jsonify({'status': 'error', 'message': str(e)})
return api_error(str(e))
@rtlamr_bp.route('/stop_rtlamr', methods=['POST'])
def stop_rtlamr() -> Response:
global rtl_tcp_process, rtlamr_active_device
global rtl_tcp_process, rtlamr_active_device, rtlamr_active_sdr_type
# Grab process refs inside locks, clear state, then terminate outside
rtlamr_proc = None
with app_module.rtlamr_lock:
if app_module.rtlamr_process:
app_module.rtlamr_process.terminate()
try:
app_module.rtlamr_process.wait(timeout=2)
except subprocess.TimeoutExpired:
app_module.rtlamr_process.kill()
rtlamr_proc = app_module.rtlamr_process
app_module.rtlamr_process = None
if rtlamr_proc:
rtlamr_proc.terminate()
try:
rtlamr_proc.wait(timeout=2)
except subprocess.TimeoutExpired:
rtlamr_proc.kill()
# Also stop rtl_tcp
tcp_proc = None
with rtl_tcp_lock:
if rtl_tcp_process:
rtl_tcp_process.terminate()
try:
rtl_tcp_process.wait(timeout=2)
except subprocess.TimeoutExpired:
rtl_tcp_process.kill()
tcp_proc = rtl_tcp_process
rtl_tcp_process = None
logger.info("rtl_tcp stopped")
if tcp_proc:
tcp_proc.terminate()
try:
tcp_proc.wait(timeout=2)
except subprocess.TimeoutExpired:
tcp_proc.kill()
logger.info("rtl_tcp stopped")
# Release device from registry
if rtlamr_active_device is not None:
app_module.release_sdr_device(rtlamr_active_device)
app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
rtlamr_active_device = None
return jsonify({'status': 'stopped'})
@@ -288,26 +298,19 @@ def stop_rtlamr() -> Response:
@rtlamr_bp.route('/stream_rtlamr')
def stream_rtlamr() -> Response:
def generate() -> Generator[str, None, None]:
last_keepalive = time.time()
keepalive_interval = 30.0
def _on_msg(msg: dict[str, Any]) -> None:
process_event('rtlamr', msg, msg.get('type'))
while True:
try:
msg = app_module.rtlamr_queue.get(timeout=1)
last_keepalive = time.time()
try:
process_event('rtlamr', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg)
except queue.Empty:
now = time.time()
if now - last_keepalive >= keepalive_interval:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream')
response = Response(
sse_stream_fanout(
source_queue=app_module.rtlamr_queue,
channel_key='rtlamr',
timeout=1.0,
keepalive_interval=30.0,
on_message=_on_msg,
),
mimetype='text/event-stream',
)
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
+69 -32
View File
@@ -2,32 +2,39 @@
from __future__ import annotations
import json
import math
import urllib.request
from datetime import datetime, timedelta
from typing import Any, Optional
from urllib.parse import urlparse
import requests
from flask import Blueprint, jsonify, request, render_template, Response
from flask import Blueprint, jsonify, render_template, request
from config import SHARED_OBSERVER_LOCATION_ENABLED
from data.satellites import TLE_SATELLITES
from utils.database import (
get_tracked_satellites,
add_tracked_satellite,
bulk_add_tracked_satellites,
update_tracked_satellite,
get_tracked_satellites,
remove_tracked_satellite,
update_tracked_satellite,
)
from utils.logging import satellite_logger as logger
from utils.validation import validate_latitude, validate_longitude, validate_hours, validate_elevation
from utils.responses import api_error
from utils.validation import validate_elevation, validate_hours, validate_latitude, validate_longitude
satellite_bp = Blueprint('satellite', __name__, url_prefix='/satellite')
# Cache skyfield timescale to avoid re-downloading/re-parsing per request
_cached_timescale = None
def _get_timescale():
global _cached_timescale
if _cached_timescale is None:
from skyfield.api import load
_cached_timescale = load.timescale()
return _cached_timescale
# Maximum response size for external requests (1MB)
MAX_RESPONSE_SIZE = 1024 * 1024
@@ -75,7 +82,7 @@ def init_tle_auto_refresh():
logger.info("TLE auto-refresh scheduled")
def _fetch_iss_realtime(observer_lat: Optional[float] = None, observer_lon: Optional[float] = None) -> Optional[dict]:
def _fetch_iss_realtime(observer_lat: float | None = None, observer_lon: float | None = None) -> dict | None:
"""
Fetch real-time ISS position from external APIs.
@@ -166,9 +173,11 @@ def _fetch_iss_realtime(observer_lat: Optional[float] = None, observer_lon: Opti
@satellite_bp.route('/dashboard')
def satellite_dashboard():
"""Popout satellite tracking dashboard."""
embedded = request.args.get('embedded', 'false') == 'true'
return render_template(
'satellite_dashboard.html',
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
embedded=embedded,
)
@@ -176,8 +185,8 @@ def satellite_dashboard():
def predict_passes():
"""Calculate satellite passes using skyfield."""
try:
from skyfield.api import load, wgs84, EarthSatellite
from skyfield.almanac import find_discrete
from skyfield.api import EarthSatellite, wgs84
except ImportError:
return jsonify({
'status': 'error',
@@ -193,7 +202,7 @@ def predict_passes():
hours = validate_hours(data.get('hours', 24))
min_el = validate_elevation(data.get('minEl', 10))
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
return api_error(str(e), 400)
norad_to_name = {
25544: 'ISS',
@@ -217,7 +226,7 @@ def predict_passes():
}
name_to_norad = {v: k for k, v in norad_to_name.items()}
ts = load.timescale()
ts = _get_timescale()
observer = wgs84.latlon(lat, lon)
t0 = ts.now()
@@ -330,9 +339,9 @@ def predict_passes():
def get_satellite_position():
"""Get real-time positions of satellites."""
try:
from skyfield.api import load, wgs84, EarthSatellite
from skyfield.api import EarthSatellite, wgs84
except ImportError:
return jsonify({'status': 'error', 'message': 'skyfield not installed'}), 503
return api_error('skyfield not installed', 503)
data = request.json or {}
@@ -341,7 +350,7 @@ def get_satellite_position():
lat = validate_latitude(data.get('latitude', data.get('lat', 51.5074)))
lon = validate_longitude(data.get('longitude', data.get('lon', -0.1278)))
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
return api_error(str(e), 400)
sat_input = data.get('satellites', [])
include_track = bool(data.get('includeTrack', True))
@@ -359,7 +368,7 @@ def get_satellite_position():
else:
satellites.append(sat)
ts = load.timescale()
ts = _get_timescale()
observer = wgs84.latlon(lat, lon)
now = ts.now()
now_dt = now.utc_datetime()
@@ -466,7 +475,8 @@ def refresh_tle_data() -> list:
'NOAA 20 (JPSS-1)': 'NOAA-20',
'NOAA 21 (JPSS-2)': 'NOAA-21',
'METEOR-M 2': 'METEOR-M2',
'METEOR-M2 3': 'METEOR-M2-3'
'METEOR-M2 3': 'METEOR-M2-3',
'METEOR-M2 4': 'METEOR-M2-4'
}
updated = []
@@ -514,7 +524,7 @@ def update_tle():
})
except Exception as e:
logger.error(f"Error updating TLE data: {e}")
return jsonify({'status': 'error', 'message': 'TLE update failed'})
return api_error('TLE update failed')
@satellite_bp.route('/celestrak/<category>')
@@ -528,7 +538,7 @@ def fetch_celestrak(category):
]
if category not in valid_categories:
return jsonify({'status': 'error', 'message': f'Invalid category. Valid: {valid_categories}'})
return api_error(f'Invalid category. Valid: {valid_categories}')
try:
url = f'https://celestrak.org/NORAD/elements/gp.php?GROUP={category}&FORMAT=tle'
@@ -569,7 +579,7 @@ def fetch_celestrak(category):
except Exception as e:
logger.error(f"Error fetching CelesTrak data: {e}")
return jsonify({'status': 'error', 'message': 'Failed to fetch satellite data'})
return api_error('Failed to fetch satellite data')
# =============================================================================
@@ -588,14 +598,14 @@ def list_tracked_satellites():
def add_tracked_satellites_endpoint():
"""Add one or more tracked satellites."""
global _tle_cache
data = request.json
data = request.get_json(silent=True)
if not data:
return jsonify({'status': 'error', 'message': 'No data provided'}), 400
return api_error('No data provided', 400)
# Accept a single satellite dict or a list
sat_list = data if isinstance(data, list) else [data]
added = 0
normalized: list[dict] = []
for sat in sat_list:
norad_id = str(sat.get('norad_id', sat.get('norad', '')))
name = sat.get('name', '')
@@ -605,19 +615,46 @@ def add_tracked_satellites_endpoint():
tle2 = sat.get('tle_line2', sat.get('tle2'))
enabled = sat.get('enabled', True)
if add_tracked_satellite(norad_id, name, tle1, tle2, enabled):
added += 1
normalized.append({
'norad_id': norad_id,
'name': name,
'tle_line1': tle1,
'tle_line2': tle2,
'enabled': bool(enabled),
'builtin': False,
})
# Also inject into TLE cache if we have TLE data
if tle1 and tle2:
cache_key = name.replace(' ', '-').upper()
_tle_cache[cache_key] = (name, tle1, tle2)
return jsonify({
# Single inserts preserve previous behavior; list inserts use DB-level bulk path.
if len(normalized) == 1:
sat = normalized[0]
added = 1 if add_tracked_satellite(
sat['norad_id'],
sat['name'],
sat.get('tle_line1'),
sat.get('tle_line2'),
sat.get('enabled', True),
sat.get('builtin', False),
) else 0
else:
added = bulk_add_tracked_satellites(normalized)
response_payload = {
'status': 'success',
'added': added,
'satellites': get_tracked_satellites(),
})
'processed': len(normalized),
}
# Returning all tracked satellites for very large imports can stall the UI.
include_satellites = request.args.get('include_satellites', '').lower() == 'true'
if include_satellites or len(normalized) <= 32:
response_payload['satellites'] = get_tracked_satellites()
return jsonify(response_payload)
@satellite_bp.route('/tracked/<norad_id>', methods=['PUT'])
@@ -626,12 +663,12 @@ def update_tracked_satellite_endpoint(norad_id):
data = request.json or {}
enabled = data.get('enabled')
if enabled is None:
return jsonify({'status': 'error', 'message': 'Missing enabled field'}), 400
return api_error('Missing enabled field', 400)
ok = update_tracked_satellite(str(norad_id), bool(enabled))
if ok:
return jsonify({'status': 'success'})
return jsonify({'status': 'error', 'message': 'Satellite not found'}), 404
return api_error('Satellite not found', 404)
@satellite_bp.route('/tracked/<norad_id>', methods=['DELETE'])
@@ -641,4 +678,4 @@ def delete_tracked_satellite_endpoint(norad_id):
if ok:
return jsonify({'status': 'success', 'message': msg})
status_code = 403 if 'builtin' in msg.lower() else 404
return jsonify({'status': 'error', 'message': msg}), status_code
return api_error(msg, status_code)
+349 -285
View File
@@ -1,285 +1,349 @@
"""RTL_433 sensor monitoring routes."""
from __future__ import annotations
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
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 format_sse
from utils.event_pipeline import process_event
from utils.process import safe_terminate, register_process, unregister_process
from utils.sdr import SDRFactory, SDRType
sensor_bp = Blueprint('sensor', __name__)
# Track which device is being used
sensor_active_device: int | None = None
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
"""Stream rtl_433 JSON output to queue."""
try:
app_module.sensor_queue.put({'type': 'status', 'text': 'started'})
for line in iter(process.stdout.readline, b''):
line = line.decode('utf-8', errors='replace').strip()
if not line:
continue
try:
# rtl_433 outputs JSON objects, one per line
data = json.loads(line)
data['type'] = 'sensor'
app_module.sensor_queue.put(data)
# Push scope event when signal level data is present
rssi = data.get('rssi')
snr = data.get('snr')
noise = data.get('noise')
if rssi is not None or snr is not None:
try:
app_module.sensor_queue.put_nowait({
'type': 'scope',
'rssi': rssi if rssi is not None else 0,
'snr': snr if snr is not None else 0,
'noise': noise if noise is not None else 0,
})
except queue.Full:
pass
# Log if enabled
if app_module.logging_enabled:
try:
with open(app_module.log_file_path, 'a') as f:
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
f.write(f"{timestamp} | {data.get('model', 'Unknown')} | {json.dumps(data)}\n")
except Exception:
pass
except json.JSONDecodeError:
# Not JSON, send as raw
app_module.sensor_queue.put({'type': 'raw', 'text': line})
except Exception as e:
app_module.sensor_queue.put({'type': 'error', 'text': str(e)})
finally:
global sensor_active_device
# Ensure process is terminated
try:
process.terminate()
process.wait(timeout=2)
except Exception:
try:
process.kill()
except Exception:
pass
unregister_process(process)
app_module.sensor_queue.put({'type': 'status', 'text': 'stopped'})
with app_module.sensor_lock:
app_module.sensor_process = None
# Release SDR device
if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device)
sensor_active_device = None
@sensor_bp.route('/sensor/status')
def sensor_status() -> Response:
"""Check if sensor decoder is currently running."""
with app_module.sensor_lock:
running = app_module.sensor_process is not None and app_module.sensor_process.poll() is None
return jsonify({'running': running})
@sensor_bp.route('/start_sensor', methods=['POST'])
def start_sensor() -> Response:
global sensor_active_device
with app_module.sensor_lock:
if app_module.sensor_process:
return jsonify({'status': 'error', 'message': 'Sensor already running'}), 409
data = request.json or {}
# Validate inputs
try:
freq = validate_frequency(data.get('frequency', '433.92'))
gain = validate_gain(data.get('gain', '0'))
ppm = validate_ppm(data.get('ppm', '0'))
device = validate_device_index(data.get('device', '0'))
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
# Check for rtl_tcp (remote SDR) connection
rtl_tcp_host = data.get('rtl_tcp_host')
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
# Claim local device if not using remote rtl_tcp
if not rtl_tcp_host:
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'sensor')
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
sensor_active_device = device_int
# Clear queue
while not app_module.sensor_queue.empty():
try:
app_module.sensor_queue.get_nowait()
except queue.Empty:
break
# Get SDR type and build command via abstraction layer
sdr_type_str = data.get('sdr_type', 'rtlsdr')
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
if rtl_tcp_host:
# Validate and create network device
try:
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
else:
# Create local device object
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
builder = SDRFactory.get_builder(sdr_device.sdr_type)
# Build ISM band decoder command
bias_t = data.get('bias_t', False)
cmd = builder.build_ism_command(
device=sdr_device,
frequency_mhz=freq,
gain=float(gain) if gain and gain != 0 else None,
ppm=int(ppm) if ppm and ppm != 0 else None,
bias_t=bias_t
)
full_cmd = ' '.join(cmd)
logger.info(f"Running: {full_cmd}")
# Add signal level metadata so the frontend scope can display RSSI/SNR
# Disable stats reporting to suppress "row count limit 50 reached" warnings
cmd.extend(['-M', 'level', '-M', 'stats:0'])
try:
app_module.sensor_process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
register_process(app_module.sensor_process)
# Start output thread
thread = threading.Thread(target=stream_sensor_output, args=(app_module.sensor_process,))
thread.daemon = True
thread.start()
# Monitor stderr
# Filter noisy rtl_433 diagnostics that aren't useful to display
_stderr_noise = (
'bitbuffer_add_bit',
'row count limit',
)
def monitor_stderr():
for line in app_module.sensor_process.stderr:
err = line.decode('utf-8', errors='replace').strip()
if err and not any(noise in err for noise in _stderr_noise):
logger.debug(f"[rtl_433] {err}")
app_module.sensor_queue.put({'type': 'info', 'text': f'[rtl_433] {err}'})
stderr_thread = threading.Thread(target=monitor_stderr)
stderr_thread.daemon = True
stderr_thread.start()
app_module.sensor_queue.put({'type': 'info', 'text': f'Command: {full_cmd}'})
return jsonify({'status': 'started', 'command': full_cmd})
except FileNotFoundError:
# Release device on failure
if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device)
sensor_active_device = None
return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'})
except Exception as e:
# Release device on failure
if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device)
sensor_active_device = None
return jsonify({'status': 'error', 'message': str(e)})
@sensor_bp.route('/stop_sensor', methods=['POST'])
def stop_sensor() -> Response:
global sensor_active_device
with app_module.sensor_lock:
if app_module.sensor_process:
app_module.sensor_process.terminate()
try:
app_module.sensor_process.wait(timeout=2)
except subprocess.TimeoutExpired:
app_module.sensor_process.kill()
app_module.sensor_process = None
# Release device from registry
if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device)
sensor_active_device = None
return jsonify({'status': 'stopped'})
return jsonify({'status': 'not_running'})
@sensor_bp.route('/stream_sensor')
def stream_sensor() -> Response:
def generate() -> Generator[str, None, None]:
last_keepalive = time.time()
keepalive_interval = 30.0
while True:
try:
msg = app_module.sensor_queue.get(timeout=1)
last_keepalive = time.time()
try:
process_event('sensor', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg)
except queue.Empty:
now = time.time()
if now - last_keepalive >= keepalive_interval:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response
"""RTL_433 sensor monitoring routes."""
from __future__ import annotations
import contextlib
import json
import math
import queue
import subprocess
import threading
import time
from datetime import datetime
from typing import Any
from flask import Blueprint, Response, jsonify, request
import app as app_module
from utils.event_pipeline import process_event
from utils.logging import sensor_logger as logger
from utils.process import register_process, unregister_process
from utils.responses import api_error, api_success
from utils.sdr import SDRFactory, SDRType
from utils.sse import sse_stream_fanout
from utils.validation import (
validate_device_index,
validate_frequency,
validate_gain,
validate_ppm,
validate_rtl_tcp_host,
validate_rtl_tcp_port,
)
sensor_bp = Blueprint('sensor', __name__)
# Track which device is being used
sensor_active_device: int | None = None
sensor_active_sdr_type: str | None = None
# RSSI history per device (model_id -> list of (timestamp, rssi))
sensor_rssi_history: dict[str, list[tuple[float, float]]] = {}
_MAX_RSSI_HISTORY = 60
def _build_scope_waveform(rssi: float, snr: float, noise: float, points: int = 256) -> list[int]:
"""Synthesize a compact waveform from rtl_433 level metrics."""
points = max(32, min(points, 512))
# rssi is usually negative; stronger signals are closer to 0 dBm.
rssi_norm = min(max(abs(rssi) / 40.0, 0.0), 1.0)
snr_norm = min(max((snr + 5.0) / 35.0, 0.0), 1.0)
noise_norm = min(max(abs(noise) / 40.0, 0.0), 1.0)
amplitude = max(0.06, min(1.0, (0.6 * rssi_norm + 0.4 * snr_norm) - (0.22 * noise_norm)))
cycles = 3.0 + (snr_norm * 8.0)
harmonic = 0.25 + (0.35 * snr_norm)
hiss = 0.08 + (0.18 * noise_norm)
phase = (time.monotonic() * (1.4 + (snr_norm * 2.2))) % (2.0 * math.pi)
waveform: list[int] = []
for i in range(points):
t = i / (points - 1)
base = math.sin((2.0 * math.pi * cycles * t) + phase)
overtone = math.sin((2.0 * math.pi * (cycles * 2.4) * t) + (phase * 0.7))
noise_wobble = math.sin((2.0 * math.pi * (cycles * 7.0) * t) + (phase * 2.1))
sample = amplitude * (base + (harmonic * overtone) + (hiss * noise_wobble))
sample /= (1.0 + harmonic + hiss)
packed = int(round(max(-1.0, min(1.0, sample)) * 127.0))
waveform.append(max(-127, min(127, packed)))
return waveform
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
"""Stream rtl_433 JSON output to queue."""
try:
app_module.sensor_queue.put({'type': 'status', 'text': 'started'})
for line in iter(process.stdout.readline, b''):
line = line.decode('utf-8', errors='replace').strip()
if not line:
continue
try:
# rtl_433 outputs JSON objects, one per line
data = json.loads(line)
data['type'] = 'sensor'
app_module.sensor_queue.put(data)
# Track RSSI history per device
_model = data.get('model', '')
_dev_id = data.get('id', '')
_rssi_val = data.get('rssi')
if _rssi_val is not None and _model:
_hist_key = f"{_model}_{_dev_id}"
hist = sensor_rssi_history.setdefault(_hist_key, [])
hist.append((time.time(), float(_rssi_val)))
if len(hist) > _MAX_RSSI_HISTORY:
del hist[: len(hist) - _MAX_RSSI_HISTORY]
# Push scope event when signal level data is present
rssi = data.get('rssi')
snr = data.get('snr')
noise = data.get('noise')
if rssi is not None or snr is not None:
try:
rssi_value = float(rssi) if rssi is not None else 0.0
snr_value = float(snr) if snr is not None else 0.0
noise_value = float(noise) if noise is not None else 0.0
app_module.sensor_queue.put_nowait({
'type': 'scope',
'rssi': rssi_value,
'snr': snr_value,
'noise': noise_value,
'waveform': _build_scope_waveform(
rssi=rssi_value,
snr=snr_value,
noise=noise_value,
),
})
except (TypeError, ValueError, queue.Full):
pass
# Log if enabled
if app_module.logging_enabled:
try:
with open(app_module.log_file_path, 'a') as f:
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
f.write(f"{timestamp} | {data.get('model', 'Unknown')} | {json.dumps(data)}\n")
except Exception:
pass
except json.JSONDecodeError:
# Not JSON, send as raw
app_module.sensor_queue.put({'type': 'raw', 'text': line})
except Exception as e:
app_module.sensor_queue.put({'type': 'error', 'text': str(e)})
finally:
global sensor_active_device, sensor_active_sdr_type
# Ensure process is terminated
try:
process.terminate()
process.wait(timeout=2)
except Exception:
with contextlib.suppress(Exception):
process.kill()
unregister_process(process)
app_module.sensor_queue.put({'type': 'status', 'text': 'stopped'})
with app_module.sensor_lock:
app_module.sensor_process = None
# Release SDR device
if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
sensor_active_device = None
sensor_active_sdr_type = None
@sensor_bp.route('/sensor/status')
def sensor_status() -> Response:
"""Check if sensor decoder is currently running."""
with app_module.sensor_lock:
running = app_module.sensor_process is not None and app_module.sensor_process.poll() is None
return jsonify({'running': running})
@sensor_bp.route('/start_sensor', methods=['POST'])
def start_sensor() -> Response:
global sensor_active_device, sensor_active_sdr_type
with app_module.sensor_lock:
if app_module.sensor_process:
return api_error('Sensor already running', 409)
data = request.json or {}
# Validate inputs
try:
freq = validate_frequency(data.get('frequency', '433.92'))
gain = validate_gain(data.get('gain', '0'))
ppm = validate_ppm(data.get('ppm', '0'))
device = validate_device_index(data.get('device', '0'))
except ValueError as e:
return api_error(str(e), 400)
# Check for rtl_tcp (remote SDR) connection
rtl_tcp_host = data.get('rtl_tcp_host')
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
# Get SDR type early so we can pass it to claim/release
sdr_type_str = data.get('sdr_type', 'rtlsdr')
# Claim local device if not using remote rtl_tcp
if not rtl_tcp_host:
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'sensor', sdr_type_str)
if error:
return api_error(error, 409, error_type='DEVICE_BUSY')
sensor_active_device = device_int
sensor_active_sdr_type = sdr_type_str
# Clear queue
while not app_module.sensor_queue.empty():
try:
app_module.sensor_queue.get_nowait()
except queue.Empty:
break
# Build command via SDR abstraction layer
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
if rtl_tcp_host:
# Validate and create network device
try:
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
except ValueError as e:
return api_error(str(e), 400)
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
else:
# Create local device object
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
builder = SDRFactory.get_builder(sdr_device.sdr_type)
# Build ISM band decoder command
bias_t = data.get('bias_t', False)
cmd = builder.build_ism_command(
device=sdr_device,
frequency_mhz=freq,
gain=float(gain) if gain and gain != 0 else None,
ppm=int(ppm) if ppm and ppm != 0 else None,
bias_t=bias_t
)
full_cmd = ' '.join(cmd)
logger.info(f"Running: {full_cmd}")
# Add signal level metadata so the frontend scope can display RSSI/SNR
# Disable stats reporting to suppress "row count limit 50 reached" warnings
cmd.extend(['-M', 'level', '-M', 'stats:0'])
try:
app_module.sensor_process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
register_process(app_module.sensor_process)
# Start output thread
thread = threading.Thread(target=stream_sensor_output, args=(app_module.sensor_process,))
thread.daemon = True
thread.start()
# Monitor stderr
# Filter noisy rtl_433 diagnostics that aren't useful to display
_stderr_noise = (
'bitbuffer_add_bit',
'row count limit',
)
def monitor_stderr():
for line in app_module.sensor_process.stderr:
err = line.decode('utf-8', errors='replace').strip()
if err and not any(noise in err for noise in _stderr_noise):
logger.debug(f"[rtl_433] {err}")
app_module.sensor_queue.put({'type': 'info', 'text': f'[rtl_433] {err}'})
stderr_thread = threading.Thread(target=monitor_stderr)
stderr_thread.daemon = True
stderr_thread.start()
app_module.sensor_queue.put({'type': 'info', 'text': f'Command: {full_cmd}'})
return jsonify({'status': 'started', 'command': full_cmd})
except FileNotFoundError:
# Release device on failure
if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
sensor_active_device = None
sensor_active_sdr_type = None
return api_error('rtl_433 not found. Install with: brew install rtl_433')
except Exception as e:
# Release device on failure
if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
sensor_active_device = None
sensor_active_sdr_type = None
return api_error(str(e))
@sensor_bp.route('/stop_sensor', methods=['POST'])
def stop_sensor() -> Response:
global sensor_active_device, sensor_active_sdr_type
with app_module.sensor_lock:
if app_module.sensor_process:
app_module.sensor_process.terminate()
try:
app_module.sensor_process.wait(timeout=2)
except subprocess.TimeoutExpired:
app_module.sensor_process.kill()
app_module.sensor_process = None
# Release device from registry
if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
sensor_active_device = None
sensor_active_sdr_type = None
return jsonify({'status': 'stopped'})
return jsonify({'status': 'not_running'})
@sensor_bp.route('/stream_sensor')
def stream_sensor() -> Response:
def _on_msg(msg: dict[str, Any]) -> None:
process_event('sensor', msg, msg.get('type'))
response = Response(
sse_stream_fanout(
source_queue=app_module.sensor_queue,
channel_key='sensor',
timeout=1.0,
keepalive_interval=30.0,
on_message=_on_msg,
),
mimetype='text/event-stream',
)
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response
@sensor_bp.route('/sensor/rssi_history')
def get_rssi_history() -> Response:
"""Return RSSI history for all tracked sensor devices."""
result = {}
for key, entries in sensor_rssi_history.items():
result[key] = [{'t': round(t, 1), 'rssi': rssi} for t, rssi in entries]
return api_success(data={'devices': result})
+21 -71
View File
@@ -6,16 +6,17 @@ import os
import subprocess
import sys
from flask import Blueprint, jsonify, request, Response
from flask import Blueprint, Response, jsonify, request
from utils.database import (
get_setting,
set_setting,
delete_setting,
get_all_settings,
get_correlations,
get_setting,
set_setting,
)
from utils.logging import get_logger
from utils.responses import api_error, api_success
logger = get_logger('intercept.settings')
@@ -27,16 +28,10 @@ def get_settings() -> Response:
"""Get all settings."""
try:
settings = get_all_settings()
return jsonify({
'status': 'success',
'settings': settings
})
return api_success(data={'settings': settings})
except Exception as e:
logger.error(f"Error getting settings: {e}")
return jsonify({
'status': 'error',
'message': str(e)
}), 500
return api_error(str(e), 500)
@settings_bp.route('', methods=['POST'])
@@ -45,10 +40,7 @@ def save_settings() -> Response:
data = request.json or {}
if not data:
return jsonify({
'status': 'error',
'message': 'No settings provided'
}), 400
return api_error('No settings provided', 400)
try:
saved = []
@@ -60,16 +52,10 @@ def save_settings() -> Response:
set_setting(key, value)
saved.append(key)
return jsonify({
'status': 'success',
'saved': saved
})
return api_success(data={'saved': saved})
except Exception as e:
logger.error(f"Error saving settings: {e}")
return jsonify({
'status': 'error',
'message': str(e)
}), 500
return api_error(str(e), 500)
@settings_bp.route('/<key>', methods=['GET'])
@@ -83,17 +69,10 @@ def get_single_setting(key: str) -> Response:
'key': key
}), 404
return jsonify({
'status': 'success',
'key': key,
'value': value
})
return api_success(data={'key': key, 'value': value})
except Exception as e:
logger.error(f"Error getting setting {key}: {e}")
return jsonify({
'status': 'error',
'message': str(e)
}), 500
return api_error(str(e), 500)
@settings_bp.route('/<key>', methods=['PUT'])
@@ -103,24 +82,14 @@ def update_single_setting(key: str) -> Response:
value = data.get('value')
if value is None and 'value' not in data:
return jsonify({
'status': 'error',
'message': 'Value is required'
}), 400
return api_error('Value is required', 400)
try:
set_setting(key, value)
return jsonify({
'status': 'success',
'key': key,
'value': value
})
return api_success(data={'key': key, 'value': value})
except Exception as e:
logger.error(f"Error updating setting {key}: {e}")
return jsonify({
'status': 'error',
'message': str(e)
}), 500
return api_error(str(e), 500)
@settings_bp.route('/<key>', methods=['DELETE'])
@@ -129,11 +98,7 @@ def delete_single_setting(key: str) -> Response:
try:
deleted = delete_setting(key)
if deleted:
return jsonify({
'status': 'success',
'key': key,
'deleted': True
})
return api_success(data={'key': key, 'deleted': True})
else:
return jsonify({
'status': 'not_found',
@@ -141,10 +106,7 @@ def delete_single_setting(key: str) -> Response:
}), 404
except Exception as e:
logger.error(f"Error deleting setting {key}: {e}")
return jsonify({
'status': 'error',
'message': str(e)
}), 500
return api_error(str(e), 500)
# =============================================================================
@@ -158,16 +120,10 @@ def get_device_correlations() -> Response:
try:
correlations = get_correlations(min_confidence)
return jsonify({
'status': 'success',
'correlations': correlations
})
return api_success(data={'correlations': correlations})
except Exception as e:
logger.error(f"Error getting correlations: {e}")
return jsonify({
'status': 'error',
'message': str(e)
}), 500
return api_error(str(e), 500)
# =============================================================================
@@ -207,7 +163,7 @@ def check_dvb_driver_status() -> Response:
blacklist_contents = []
if blacklist_exists:
try:
with open(BLACKLIST_FILE, 'r') as f:
with open(BLACKLIST_FILE) as f:
blacklist_contents = [line.strip() for line in f if line.strip() and not line.startswith('#')]
except Exception:
pass
@@ -229,17 +185,11 @@ def check_dvb_driver_status() -> Response:
def blacklist_dvb_drivers() -> Response:
"""Blacklist DVB kernel drivers to prevent them from claiming RTL-SDR devices."""
if sys.platform != 'linux':
return jsonify({
'status': 'error',
'message': 'This feature is only available on Linux'
}), 400
return api_error('This feature is only available on Linux', 400)
# Check if we have permission (need to be running as root or with sudo)
if os.geteuid() != 0:
return jsonify({
'status': 'error',
'message': 'Root privileges required. Run the app with sudo or manually run: sudo modprobe -r dvb_usb_rtl28xxu rtl2832_sdr rtl2832 r820t'
}), 403
return api_error('Root privileges required. Run the app with sudo or manually run: sudo modprobe -r dvb_usb_rtl28xxu rtl2832_sdr rtl2832 r820t', 403)
errors = []
successes = []
+353
View File
@@ -0,0 +1,353 @@
"""Signal identification enrichment routes (SigID Wiki proxy lookup)."""
from __future__ import annotations
import json
import time
import urllib.parse
import urllib.request
from typing import Any
from flask import Blueprint, Response, jsonify, request
from utils.logging import get_logger
from utils.responses import api_error
logger = get_logger('intercept.signalid')
signalid_bp = Blueprint('signalid', __name__, url_prefix='/signalid')
SIGID_API_URL = 'https://www.sigidwiki.com/api.php'
SIGID_USER_AGENT = 'INTERCEPT-SignalID/1.0'
SIGID_TIMEOUT_SECONDS = 12
SIGID_CACHE_TTL_SECONDS = 600
_cache: dict[str, dict[str, Any]] = {}
def _cache_get(key: str) -> Any | None:
entry = _cache.get(key)
if not entry:
return None
if time.time() >= entry['expires']:
_cache.pop(key, None)
return None
return entry['data']
def _cache_set(key: str, data: Any, ttl_seconds: int = SIGID_CACHE_TTL_SECONDS) -> None:
_cache[key] = {
'data': data,
'expires': time.time() + ttl_seconds,
}
def _fetch_api_json(params: dict[str, str]) -> dict[str, Any] | None:
query = urllib.parse.urlencode(params, doseq=True)
url = f'{SIGID_API_URL}?{query}'
req = urllib.request.Request(url, headers={'User-Agent': SIGID_USER_AGENT})
try:
with urllib.request.urlopen(req, timeout=SIGID_TIMEOUT_SECONDS) as resp:
payload = resp.read().decode('utf-8', errors='replace')
data = json.loads(payload)
except Exception as exc:
logger.warning('SigID API request failed: %s', exc)
return None
if isinstance(data, dict) and data.get('error'):
logger.warning('SigID API returned error: %s', data.get('error'))
return None
return data if isinstance(data, dict) else None
def _ask_query(query: str) -> dict[str, Any] | None:
return _fetch_api_json({
'action': 'ask',
'query': query,
'format': 'json',
})
def _search_query(search_text: str, limit: int) -> dict[str, Any] | None:
return _fetch_api_json({
'action': 'query',
'list': 'search',
'srsearch': search_text,
'srlimit': str(limit),
'format': 'json',
})
def _to_float_list(values: Any) -> list[float]:
if not isinstance(values, list):
return []
out: list[float] = []
for value in values:
try:
out.append(float(value))
except (TypeError, ValueError):
continue
return out
def _to_text_list(values: Any) -> list[str]:
if not isinstance(values, list):
return []
out: list[str] = []
for value in values:
text = str(value or '').strip()
if text:
out.append(text)
return out
def _normalize_modes(values: list[str]) -> list[str]:
out: list[str] = []
for value in values:
for token in str(value).replace('/', ',').split(','):
mode = token.strip().upper()
if mode and mode not in out:
out.append(mode)
return out
def _extract_matches_from_ask(data: dict[str, Any]) -> list[dict[str, Any]]:
results = data.get('query', {}).get('results', {})
if not isinstance(results, dict):
return []
matches: list[dict[str, Any]] = []
for title, entry in results.items():
if not isinstance(entry, dict):
continue
printouts = entry.get('printouts', {})
if not isinstance(printouts, dict):
printouts = {}
frequencies_hz = _to_float_list(printouts.get('Frequencies'))
frequencies_mhz = [round(v / 1e6, 6) for v in frequencies_hz if v > 0]
modes = _normalize_modes(_to_text_list(printouts.get('Mode')))
modulations = _normalize_modes(_to_text_list(printouts.get('Modulation')))
match = {
'title': str(entry.get('fulltext') or title),
'url': str(entry.get('fullurl') or ''),
'frequencies_mhz': frequencies_mhz,
'modes': modes,
'modulations': modulations,
'source': 'SigID Wiki',
}
matches.append(match)
return matches
def _dedupe_matches(matches: list[dict[str, Any]]) -> list[dict[str, Any]]:
deduped: dict[str, dict[str, Any]] = {}
for match in matches:
key = f"{match.get('title', '')}|{match.get('url', '')}"
if key not in deduped:
deduped[key] = match
continue
# Merge frequencies/modes/modulations from duplicates.
existing = deduped[key]
for field in ('frequencies_mhz', 'modes', 'modulations'):
base = existing.get(field, [])
extra = match.get(field, [])
if not isinstance(base, list):
base = []
if not isinstance(extra, list):
extra = []
merged = list(base)
for item in extra:
if item not in merged:
merged.append(item)
existing[field] = merged
return list(deduped.values())
def _rank_matches(
matches: list[dict[str, Any]],
*,
frequency_mhz: float,
modulation: str,
) -> list[dict[str, Any]]:
target_hz = frequency_mhz * 1e6
wanted_mod = str(modulation or '').strip().upper()
def score(match: dict[str, Any]) -> tuple[int, float, str]:
score_value = 0
freqs_mhz = match.get('frequencies_mhz') or []
distances_hz: list[float] = []
for f_mhz in freqs_mhz:
try:
distances_hz.append(abs((float(f_mhz) * 1e6) - target_hz))
except (TypeError, ValueError):
continue
min_distance_hz = min(distances_hz) if distances_hz else 1e12
if min_distance_hz <= 100:
score_value += 120
elif min_distance_hz <= 1_000:
score_value += 90
elif min_distance_hz <= 10_000:
score_value += 70
elif min_distance_hz <= 100_000:
score_value += 40
if wanted_mod:
modes = [str(v).upper() for v in (match.get('modes') or [])]
modulations = [str(v).upper() for v in (match.get('modulations') or [])]
if wanted_mod in modes:
score_value += 25
if wanted_mod in modulations:
score_value += 25
title = str(match.get('title') or '')
title_lower = title.lower()
if 'unidentified' in title_lower or 'unknown' in title_lower:
score_value -= 10
return (score_value, min_distance_hz, title.lower())
ranked = sorted(matches, key=score, reverse=True)
for match in ranked:
try:
nearest = min(abs((float(f) * 1e6) - target_hz) for f in (match.get('frequencies_mhz') or []))
match['distance_hz'] = int(round(nearest))
except Exception:
match['distance_hz'] = None
return ranked
def _format_freq_variants_mhz(freq_mhz: float) -> list[str]:
variants = [
f'{freq_mhz:.6f}'.rstrip('0').rstrip('.'),
f'{freq_mhz:.4f}'.rstrip('0').rstrip('.'),
f'{freq_mhz:.3f}'.rstrip('0').rstrip('.'),
]
out: list[str] = []
for value in variants:
if value and value not in out:
out.append(value)
return out
def _lookup_sigidwiki_matches(frequency_mhz: float, modulation: str, limit: int) -> dict[str, Any]:
all_matches: list[dict[str, Any]] = []
exact_queries: list[str] = []
for freq_token in _format_freq_variants_mhz(frequency_mhz):
query = (
f'[[Category:Signal]][[Frequencies::{freq_token} MHz]]'
f'|?Frequencies|?Mode|?Modulation|limit={max(10, limit * 2)}'
)
exact_queries.append(query)
data = _ask_query(query)
if data:
all_matches.extend(_extract_matches_from_ask(data))
if all_matches:
break
search_used = False
if not all_matches:
search_used = True
search_terms = [f'{frequency_mhz:.4f} MHz']
if modulation:
search_terms.insert(0, f'{frequency_mhz:.4f} MHz {modulation.upper()}')
seen_titles: set[str] = set()
for term in search_terms:
search_data = _search_query(term, max(5, min(limit * 2, 10)))
search_results = search_data.get('query', {}).get('search', []) if isinstance(search_data, dict) else []
if not isinstance(search_results, list) or not search_results:
continue
for item in search_results:
title = str(item.get('title') or '').strip()
if not title or title in seen_titles:
continue
seen_titles.add(title)
page_query = f'[[{title}]]|?Frequencies|?Mode|?Modulation|limit=1'
page_data = _ask_query(page_query)
if page_data:
all_matches.extend(_extract_matches_from_ask(page_data))
if len(all_matches) >= max(limit * 3, 12):
break
if all_matches:
break
deduped = _dedupe_matches(all_matches)
ranked = _rank_matches(deduped, frequency_mhz=frequency_mhz, modulation=modulation)
return {
'matches': ranked[:limit],
'search_used': search_used,
'exact_queries': exact_queries,
}
@signalid_bp.route('/sigidwiki', methods=['POST'])
def sigidwiki_lookup() -> Response:
"""Lookup likely signal types from SigID Wiki by tuned frequency."""
payload = request.get_json(silent=True) or {}
freq_raw = payload.get('frequency_mhz')
if freq_raw is None:
return api_error('frequency_mhz is required', 400)
try:
frequency_mhz = float(freq_raw)
except (TypeError, ValueError):
return api_error('Invalid frequency_mhz', 400)
if frequency_mhz <= 0:
return api_error('frequency_mhz must be positive', 400)
modulation = str(payload.get('modulation') or '').strip().upper()
if modulation and len(modulation) > 16:
modulation = modulation[:16]
limit_raw = payload.get('limit', 8)
try:
limit = int(limit_raw)
except (TypeError, ValueError):
limit = 8
limit = max(1, min(limit, 20))
cache_key = f'{round(frequency_mhz, 6)}|{modulation}|{limit}'
cached = _cache_get(cache_key)
if cached is not None:
return jsonify({
'status': 'ok',
'source': 'sigidwiki',
'frequency_mhz': round(frequency_mhz, 6),
'modulation': modulation or None,
'cached': True,
**cached,
})
try:
lookup = _lookup_sigidwiki_matches(frequency_mhz, modulation, limit)
except Exception as exc:
logger.error('SigID lookup failed: %s', exc)
return api_error('SigID lookup failed', 502)
response_payload = {
'matches': lookup.get('matches', []),
'match_count': len(lookup.get('matches', [])),
'search_used': bool(lookup.get('search_used')),
'exact_queries': lookup.get('exact_queries', []),
}
_cache_set(cache_key, response_payload)
return jsonify({
'status': 'ok',
'source': 'sigidwiki',
'frequency_mhz': round(frequency_mhz, 6),
'modulation': modulation or None,
'cached': False,
**response_payload,
})
+340
View File
@@ -0,0 +1,340 @@
"""Space Weather routes - proxies NOAA SWPC, NASA SDO, and HamQSL data."""
from __future__ import annotations
import concurrent.futures
import json
import time
import urllib.error
import urllib.request
import xml.etree.ElementTree as ET
from typing import Any
from flask import Blueprint, Response, jsonify
from utils.logging import get_logger
from utils.responses import api_error
logger = get_logger('intercept.space_weather')
space_weather_bp = Blueprint('space_weather', __name__, url_prefix='/space-weather')
# ---------------------------------------------------------------------------
# TTL Cache
# ---------------------------------------------------------------------------
_cache: dict[str, dict[str, Any]] = {}
# Cache TTLs in seconds
TTL_REALTIME = 300 # 5 min for real-time data
TTL_FORECAST = 1800 # 30 min for forecasts
TTL_DAILY = 3600 # 1 hr for daily summaries
TTL_IMAGE = 600 # 10 min for images
def _cache_get(key: str) -> Any | None:
entry = _cache.get(key)
if entry and time.time() < entry['expires']:
return entry['data']
return None
def _cache_set(key: str, data: Any, ttl: int) -> None:
_cache[key] = {'data': data, 'expires': time.time() + ttl}
# ---------------------------------------------------------------------------
# HTTP helpers
# ---------------------------------------------------------------------------
_TIMEOUT = 15 # seconds
SWPC_BASE = 'https://services.swpc.noaa.gov'
SWPC_JSON = f'{SWPC_BASE}/products'
def _fetch_json(url: str, timeout: int = _TIMEOUT) -> Any | None:
try:
req = urllib.request.Request(url, headers={'User-Agent': 'INTERCEPT/1.0'})
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode())
except Exception as exc:
logger.warning('Failed to fetch %s: %s', url, exc)
return None
def _fetch_text(url: str, timeout: int = _TIMEOUT) -> str | None:
try:
req = urllib.request.Request(url, headers={'User-Agent': 'INTERCEPT/1.0'})
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.read().decode()
except Exception as exc:
logger.warning('Failed to fetch %s: %s', url, exc)
return None
def _fetch_bytes(url: str, timeout: int = _TIMEOUT) -> bytes | None:
try:
req = urllib.request.Request(url, headers={'User-Agent': 'INTERCEPT/1.0'})
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.read()
except Exception as exc:
logger.warning('Failed to fetch %s: %s', url, exc)
return None
# ---------------------------------------------------------------------------
# Data source fetchers
# ---------------------------------------------------------------------------
def _fetch_cached_json(cache_key: str, url: str, ttl: int) -> Any | None:
cached = _cache_get(cache_key)
if cached is not None:
return cached
data = _fetch_json(url)
if data is not None:
_cache_set(cache_key, data, ttl)
return data
def _fetch_kp_index() -> Any | None:
return _fetch_cached_json('kp_index', f'{SWPC_JSON}/noaa-planetary-k-index.json', TTL_REALTIME)
def _fetch_kp_forecast() -> Any | None:
return _fetch_cached_json('kp_forecast', f'{SWPC_JSON}/noaa-planetary-k-index-forecast.json', TTL_FORECAST)
def _fetch_scales() -> Any | None:
return _fetch_cached_json('scales', f'{SWPC_JSON}/noaa-scales.json', TTL_REALTIME)
def _fetch_flux() -> Any | None:
return _fetch_cached_json('flux', f'{SWPC_JSON}/10cm-flux-30-day.json', TTL_DAILY)
def _fetch_alerts() -> Any | None:
return _fetch_cached_json('alerts', f'{SWPC_JSON}/alerts.json', TTL_REALTIME)
def _fetch_solar_wind_plasma() -> Any | None:
return _fetch_cached_json('sw_plasma', f'{SWPC_JSON}/solar-wind/plasma-6-hour.json', TTL_REALTIME)
def _fetch_solar_wind_mag() -> Any | None:
return _fetch_cached_json('sw_mag', f'{SWPC_JSON}/solar-wind/mag-6-hour.json', TTL_REALTIME)
def _fetch_xrays() -> Any | None:
return _fetch_cached_json('xrays', f'{SWPC_BASE}/json/goes/primary/xrays-1-day.json', TTL_REALTIME)
def _fetch_xray_flares() -> Any | None:
return _fetch_cached_json('xray_flares', f'{SWPC_BASE}/json/goes/primary/xray-flares-7-day.json', TTL_REALTIME)
def _fetch_flare_probability() -> Any | None:
return _fetch_cached_json('flare_prob', f'{SWPC_BASE}/json/solar_probabilities.json', TTL_FORECAST)
def _fetch_solar_regions() -> Any | None:
return _fetch_cached_json('solar_regions', f'{SWPC_BASE}/json/solar_regions.json', TTL_DAILY)
def _fetch_sunspot_report() -> Any | None:
return _fetch_cached_json('sunspot_report', f'{SWPC_BASE}/json/sunspot_report.json', TTL_DAILY)
def _parse_hamqsl_xml(xml_text: str) -> dict[str, Any] | None:
"""Parse HamQSL solar XML into a dict of band conditions."""
try:
root = ET.fromstring(xml_text)
solar = root.find('.//solardata')
if solar is None:
return None
result: dict[str, Any] = {}
# Scalar fields
for tag in ('sfi', 'aindex', 'kindex', 'kindexnt', 'xray', 'sunspots',
'heliumline', 'protonflux', 'electonflux', 'aurora',
'normalization', 'latdegree', 'solarwind', 'magneticfield',
'calculatedconditions', 'calculatedvhfconditions',
'geomagfield', 'signalnoise', 'fof2', 'muffactor', 'muf'):
el = solar.find(tag)
if el is not None and el.text:
result[tag] = el.text.strip()
# Band conditions
bands: list[dict[str, str]] = []
for band_el in solar.findall('.//calculatedconditions/band'):
bands.append({
'name': band_el.get('name', ''),
'time': band_el.get('time', ''),
'condition': band_el.text.strip() if band_el.text else ''
})
result['bands'] = bands
# VHF conditions
vhf: list[dict[str, str]] = []
for phen_el in solar.findall('.//calculatedvhfconditions/phenomenon'):
vhf.append({
'name': phen_el.get('name', ''),
'location': phen_el.get('location', ''),
'condition': phen_el.text.strip() if phen_el.text else ''
})
result['vhf'] = vhf
return result
except ET.ParseError as exc:
logger.warning('Failed to parse HamQSL XML: %s', exc)
return None
def _fetch_band_conditions() -> dict[str, Any] | None:
cached = _cache_get('band_conditions')
if cached is not None:
return cached
xml_text = _fetch_text('https://www.hamqsl.com/solarxml.php')
if xml_text is None:
return None
data = _parse_hamqsl_xml(xml_text)
if data is not None:
_cache_set('band_conditions', data, TTL_FORECAST)
return data
# ---------------------------------------------------------------------------
# Image proxy whitelist
# ---------------------------------------------------------------------------
IMAGE_WHITELIST: dict[str, dict[str, str]] = {
# D-RAP absorption maps
'drap_global': {
'url': f'{SWPC_BASE}/images/animations/d-rap/global/latest.png',
'content_type': 'image/png',
},
'drap_5': {
'url': f'{SWPC_BASE}/images/d-rap/global_f05.png',
'content_type': 'image/png',
},
'drap_10': {
'url': f'{SWPC_BASE}/images/d-rap/global_f10.png',
'content_type': 'image/png',
},
'drap_15': {
'url': f'{SWPC_BASE}/images/d-rap/global_f15.png',
'content_type': 'image/png',
},
'drap_20': {
'url': f'{SWPC_BASE}/images/d-rap/global_f20.png',
'content_type': 'image/png',
},
'drap_25': {
'url': f'{SWPC_BASE}/images/d-rap/global_f25.png',
'content_type': 'image/png',
},
'drap_30': {
'url': f'{SWPC_BASE}/images/d-rap/global_f30.png',
'content_type': 'image/png',
},
# Aurora forecast
'aurora_north': {
'url': f'{SWPC_BASE}/images/animations/ovation/north/latest.jpg',
'content_type': 'image/jpeg',
},
# SDO solar imagery
'sdo_193': {
'url': 'https://sdo.gsfc.nasa.gov/assets/img/latest/latest_512_0193.jpg',
'content_type': 'image/jpeg',
},
'sdo_304': {
'url': 'https://sdo.gsfc.nasa.gov/assets/img/latest/latest_512_0304.jpg',
'content_type': 'image/jpeg',
},
'sdo_magnetogram': {
'url': 'https://sdo.gsfc.nasa.gov/assets/img/latest/latest_512_HMIBC.jpg',
'content_type': 'image/jpeg',
},
}
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@space_weather_bp.route('/data')
def get_data():
"""Return aggregated space weather data from all sources."""
fetchers = {
'kp_index': _fetch_kp_index,
'kp_forecast': _fetch_kp_forecast,
'scales': _fetch_scales,
'flux': _fetch_flux,
'alerts': _fetch_alerts,
'solar_wind_plasma': _fetch_solar_wind_plasma,
'solar_wind_mag': _fetch_solar_wind_mag,
'xrays': _fetch_xrays,
'xray_flares': _fetch_xray_flares,
'flare_probability': _fetch_flare_probability,
'solar_regions': _fetch_solar_regions,
'sunspot_report': _fetch_sunspot_report,
'band_conditions': _fetch_band_conditions,
}
data = {}
with concurrent.futures.ThreadPoolExecutor(max_workers=13) as executor:
futures = {executor.submit(fn): key for key, fn in fetchers.items()}
for future in concurrent.futures.as_completed(futures):
data[futures[future]] = future.result()
data['timestamp'] = time.time()
return jsonify(data)
@space_weather_bp.route('/image/<key>')
def get_image(key: str):
"""Proxy and cache whitelisted space weather images."""
entry = IMAGE_WHITELIST.get(key)
if not entry:
return api_error('Unknown image key', 404)
cache_key = f'img_{key}'
cached = _cache_get(cache_key)
if cached is not None:
return Response(cached, content_type=entry['content_type'],
headers={'Cache-Control': 'public, max-age=300'})
img_data = _fetch_bytes(entry['url'])
if img_data is None:
return api_error('Failed to fetch image', 502)
_cache_set(cache_key, img_data, TTL_IMAGE)
return Response(img_data, content_type=entry['content_type'],
headers={'Cache-Control': 'public, max-age=300'})
@space_weather_bp.route('/prefetch-images')
def prefetch_images():
"""Warm the image cache by fetching all whitelisted images in parallel."""
# Only fetch images not already cached
to_fetch = {}
for key, entry in IMAGE_WHITELIST.items():
cache_key = f'img_{key}'
if _cache_get(cache_key) is None:
to_fetch[key] = entry
if not to_fetch:
return jsonify({'status': 'all cached', 'count': 0})
def _fetch_and_cache(key: str, entry: dict) -> bool:
img_data = _fetch_bytes(entry['url'])
if img_data:
_cache_set(f'img_{key}', img_data, TTL_IMAGE)
return True
return False
fetched = 0
with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor:
futures = {
executor.submit(_fetch_and_cache, k, e): k
for k, e in to_fetch.items()
}
for future in concurrent.futures.as_completed(futures):
if future.result():
fetched += 1
return jsonify({'status': 'ok', 'fetched': fetched, 'cached': len(IMAGE_WHITELIST) - len(to_fetch)})
+3 -3
View File
@@ -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',
+206 -105
View File
@@ -6,32 +6,60 @@ ISS SSTV events occur during special commemorations and typically transmit on 14
from __future__ import annotations
import contextlib
import queue
import threading
import time
from pathlib import Path
from typing import Generator
from typing import Any
from flask import Blueprint, jsonify, request, Response, send_file
from flask import Blueprint, Response, jsonify, request, send_file
import app as app_module
from utils.logging import get_logger
from utils.sse import format_sse
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')
sstv_bp = Blueprint('sstv', __name__, url_prefix='/sstv')
# ISS SSTV runs on a fixed downlink; allow a small entry tolerance so users
# can type nearby values and still land on the canonical center frequency.
ISS_SSTV_MODULATION = 'fm'
ISS_SSTV_FREQUENCIES = (ISS_SSTV_FREQ,)
ISS_SSTV_FREQ_TOLERANCE_MHZ = 0.05
# Queue for SSE progress streaming
_sstv_queue: queue.Queue = queue.Queue(maxsize=100)
# ---------------------------------------------------------------------------
# Caching — ISS position (external API) and schedule (skyfield computation)
# ---------------------------------------------------------------------------
_iss_position_cache: dict | None = None
_iss_position_cache_time: float = 0
_iss_position_lock = threading.Lock()
ISS_POSITION_CACHE_TTL = 10 # seconds
_iss_schedule_cache: dict | None = None
_iss_schedule_cache_time: float = 0
_iss_schedule_cache_key: str | None = None
_iss_schedule_lock = threading.Lock()
ISS_SCHEDULE_CACHE_TTL = 900 # 15 minutes
# Reusable skyfield timescale (expensive to create)
_timescale = None
_timescale_lock = threading.Lock()
# Track which device is being used
sstv_active_device: int | None = None
sstv_active_sdr_type: str = 'rtlsdr'
def _progress_callback(data: dict) -> None:
@@ -46,6 +74,14 @@ def _progress_callback(data: dict) -> None:
pass
def _normalize_iss_frequency(frequency_mhz: float) -> float | None:
"""Snap near-match user input to a supported ISS SSTV center frequency."""
for supported in ISS_SSTV_FREQUENCIES:
if abs(frequency_mhz - supported) <= ISS_SSTV_FREQ_TOLERANCE_MHZ:
return supported
return None
@sstv_bp.route('/status')
def get_status():
"""
@@ -62,6 +98,7 @@ def get_status():
'decoder': decoder.decoder_available,
'running': decoder.is_running,
'iss_frequency': ISS_SSTV_FREQ,
'modulation': ISS_SSTV_MODULATION,
'image_count': len(decoder.get_images()),
'doppler_enabled': decoder.doppler_enabled,
}
@@ -82,6 +119,7 @@ def start_decoder():
JSON body (optional):
{
"frequency": 145.800, // Frequency in MHz (default: ISS 145.800)
"modulation": "fm", // ISS mode is FM-only
"device": 0, // RTL-SDR device index
"latitude": 40.7128, // Observer latitude for Doppler correction
"longitude": -74.0060 // Observer longitude for Doppler correction
@@ -106,6 +144,7 @@ def start_decoder():
return jsonify({
'status': 'already_running',
'frequency': ISS_SSTV_FREQ,
'modulation': ISS_SSTV_MODULATION,
'doppler_enabled': decoder.doppler_enabled
})
@@ -118,19 +157,38 @@ def start_decoder():
# Get parameters
data = request.get_json(silent=True) or {}
sdr_type_str = data.get('sdr_type', 'rtlsdr')
if sdr_type_str != 'rtlsdr':
return jsonify({
'status': 'error',
'message': f'{sdr_type_str.replace("_", " ").title()} is not yet supported for this mode. Please use an RTL-SDR device.'
}), 400
frequency = data.get('frequency', ISS_SSTV_FREQ)
modulation = str(data.get('modulation', ISS_SSTV_MODULATION)).strip().lower()
device_index = data.get('device', 0)
latitude = data.get('latitude')
longitude = data.get('longitude')
# Validate modulation (ISS mode is FM-only)
if modulation != ISS_SSTV_MODULATION:
return jsonify({
'status': 'error',
'message': f'Modulation must be {ISS_SSTV_MODULATION} for ISS SSTV mode'
}), 400
# Validate frequency
try:
frequency = float(frequency)
if not (100 <= frequency <= 500): # VHF range
normalized_frequency = _normalize_iss_frequency(frequency)
if normalized_frequency is None:
supported = ', '.join(f'{freq:.3f}' for freq in ISS_SSTV_FREQUENCIES)
return jsonify({
'status': 'error',
'message': 'Frequency must be between 100-500 MHz'
'message': f'Supported ISS SSTV frequency: {supported} MHz FM'
}), 400
frequency = normalized_frequency
except (TypeError, ValueError):
return jsonify({
'status': 'error',
@@ -162,9 +220,9 @@ def start_decoder():
longitude = None
# Claim SDR device
global sstv_active_device
global sstv_active_device, sstv_active_sdr_type
device_int = int(device_index)
error = app_module.claim_sdr_device(device_int, 'sstv')
error = app_module.claim_sdr_device(device_int, 'sstv', sdr_type_str)
if error:
return jsonify({
'status': 'error',
@@ -178,15 +236,18 @@ def start_decoder():
frequency=frequency,
device_index=device_index,
latitude=latitude,
longitude=longitude
longitude=longitude,
modulation=ISS_SSTV_MODULATION,
)
if success:
sstv_active_device = device_int
sstv_active_sdr_type = sdr_type_str
result = {
'status': 'started',
'frequency': frequency,
'modulation': ISS_SSTV_MODULATION,
'device': device_index,
'doppler_enabled': decoder.doppler_enabled
}
@@ -198,7 +259,7 @@ def start_decoder():
return jsonify(result)
else:
# Release device on failure
app_module.release_sdr_device(device_int)
app_module.release_sdr_device(device_int, sdr_type_str)
return jsonify({
'status': 'error',
'message': 'Failed to start decoder'
@@ -213,13 +274,13 @@ def stop_decoder():
Returns:
JSON confirmation.
"""
global sstv_active_device
global sstv_active_device, sstv_active_sdr_type
decoder = get_sstv_decoder()
decoder.stop()
# Release device from registry
if sstv_active_device is not None:
app_module.release_sdr_device(sstv_active_device)
app_module.release_sdr_device(sstv_active_device, sstv_active_sdr_type)
sstv_active_device = None
return jsonify({'status': 'stopped'})
@@ -298,16 +359,16 @@ def get_image(filename: str):
# Security: only allow alphanumeric filenames with .png extension
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
return api_error('Invalid filename', 400)
if not filename.endswith('.png'):
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
return api_error('Only PNG files supported', 400)
# Find image in decoder's output directory
image_path = decoder._output_dir / filename
if not image_path.exists():
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
return api_error('Image not found', 404)
return send_file(image_path, mimetype='image/png')
@@ -327,15 +388,15 @@ def download_image(filename: str):
# Security: only allow alphanumeric filenames with .png extension
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
return api_error('Invalid filename', 400)
if not filename.endswith('.png'):
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
return api_error('Only PNG files supported', 400)
image_path = decoder._output_dir / filename
if not image_path.exists():
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
return api_error('Image not found', 404)
return send_file(image_path, mimetype='image/png', as_attachment=True, download_name=filename)
@@ -355,15 +416,15 @@ def delete_image(filename: str):
# Security: only allow alphanumeric filenames with .png extension
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
return api_error('Invalid filename', 400)
if not filename.endswith('.png'):
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
return api_error('Only PNG files supported', 400)
if decoder.delete_image(filename):
return jsonify({'status': 'ok'})
else:
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
return api_error('Image not found', 404)
@sstv_bp.route('/images', methods=['DELETE'])
@@ -392,38 +453,42 @@ def stream_progress():
Returns:
SSE stream (text/event-stream)
"""
def generate() -> Generator[str, None, None]:
last_keepalive = time.time()
keepalive_interval = 30.0
def _on_msg(msg: dict[str, Any]) -> None:
process_event('sstv', msg, msg.get('type'))
while True:
try:
progress = _sstv_queue.get(timeout=1)
last_keepalive = time.time()
try:
process_event('sstv', progress, progress.get('type'))
except Exception:
pass
yield format_sse(progress)
except queue.Empty:
now = time.time()
if now - last_keepalive >= keepalive_interval:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream')
response = Response(
sse_stream_fanout(
source_queue=_sstv_queue,
channel_key='sstv',
timeout=1.0,
keepalive_interval=30.0,
on_message=_on_msg,
),
mimetype='text/event-stream',
)
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response
def _get_timescale():
"""Return a cached skyfield timescale (expensive to create)."""
global _timescale
with _timescale_lock:
if _timescale is None:
from skyfield.api import load
_timescale = load.timescale()
return _timescale
@sstv_bp.route('/iss-schedule')
def iss_schedule():
"""
Get ISS pass schedule for SSTV reception.
Calculates ISS passes directly using skyfield.
Results are cached for 15 minutes per rounded location.
Query parameters:
latitude: Observer latitude (required)
@@ -433,6 +498,8 @@ def iss_schedule():
Returns:
JSON with ISS pass schedule.
"""
global _iss_schedule_cache, _iss_schedule_cache_time, _iss_schedule_cache_key
lat = request.args.get('latitude', type=float)
lon = request.args.get('longitude', type=float)
hours = request.args.get('hours', 48, type=int)
@@ -443,10 +510,22 @@ def iss_schedule():
'message': 'latitude and longitude parameters required'
}), 400
# Cache key: rounded lat/lon (1 decimal place) so nearby locations share cache
cache_key = f"{round(lat, 1)}:{round(lon, 1)}:{hours}"
with _iss_schedule_lock:
now = time.time()
if (_iss_schedule_cache is not None
and cache_key == _iss_schedule_cache_key
and (now - _iss_schedule_cache_time) < ISS_SCHEDULE_CACHE_TTL):
return jsonify(_iss_schedule_cache)
try:
from skyfield.api import load, wgs84, EarthSatellite
from skyfield.almanac import find_discrete
from datetime import timedelta
from skyfield.almanac import find_discrete
from skyfield.api import EarthSatellite, wgs84
from data.satellites import TLE_SATELLITES
# Get ISS TLE
@@ -457,7 +536,7 @@ def iss_schedule():
'message': 'ISS TLE data not available'
}), 500
ts = load.timescale()
ts = _get_timescale()
satellite = EarthSatellite(iss_tle[1], iss_tle[2], iss_tle[0], ts)
observer = wgs84.latlon(lat, lon)
@@ -520,13 +599,21 @@ def iss_schedule():
i += 1
return jsonify({
result = {
'status': 'ok',
'passes': passes,
'count': len(passes),
'sstv_frequency': ISS_SSTV_FREQ,
'note': 'ISS SSTV events are not continuous. Check ARISS.org for scheduled events.'
})
}
# Update cache
with _iss_schedule_lock:
_iss_schedule_cache = result
_iss_schedule_cache_time = time.time()
_iss_schedule_cache_key = cache_key
return jsonify(result)
except ImportError:
return jsonify({
@@ -542,13 +629,65 @@ def iss_schedule():
}), 500
def _fetch_iss_position() -> dict | None:
"""Fetch raw ISS lat/lon/altitude from external APIs, with 10s cache."""
global _iss_position_cache, _iss_position_cache_time
with _iss_position_lock:
now = time.time()
if _iss_position_cache is not None and (now - _iss_position_cache_time) < ISS_POSITION_CACHE_TTL:
return _iss_position_cache
import requests
cached = None
# Try primary API: Where The ISS At
try:
response = requests.get('https://api.wheretheiss.at/v1/satellites/25544', timeout=3)
if response.status_code == 200:
data = response.json()
cached = {
'lat': float(data['latitude']),
'lon': float(data['longitude']),
'altitude': float(data.get('altitude', 420)),
'source': 'wheretheiss',
}
except Exception as e:
logger.warning(f"Where The ISS At API failed: {e}")
# Try fallback API: Open Notify
if cached is None:
try:
response = requests.get('http://api.open-notify.org/iss-now.json', timeout=3)
if response.status_code == 200:
data = response.json()
if data.get('message') == 'success':
cached = {
'lat': float(data['iss_position']['latitude']),
'lon': float(data['iss_position']['longitude']),
'altitude': 420,
'source': 'open-notify',
}
except Exception as e:
logger.warning(f"Open Notify API failed: {e}")
if cached is not None:
with _iss_position_lock:
_iss_position_cache = cached
_iss_position_cache_time = time.time()
return cached
@sstv_bp.route('/iss-position')
def iss_position():
"""
Get current ISS position from real-time API.
Uses the Open Notify API for accurate real-time position,
with fallback to "Where The ISS At" API.
Uses the "Where The ISS At" API for accurate real-time position,
with fallback to Open Notify API. Raw position is cached for 10 seconds;
observer-relative data (elevation/azimuth) is computed per-request.
Query parameters:
latitude: Observer latitude (optional, for elevation calc)
@@ -557,68 +696,32 @@ def iss_position():
Returns:
JSON with ISS current position.
"""
import requests
from datetime import datetime
observer_lat = request.args.get('latitude', type=float)
observer_lon = request.args.get('longitude', type=float)
# Try primary API: Where The ISS At
try:
response = requests.get('https://api.wheretheiss.at/v1/satellites/25544', timeout=5)
if response.status_code == 200:
data = response.json()
iss_lat = float(data['latitude'])
iss_lon = float(data['longitude'])
pos = _fetch_iss_position()
if pos is None:
return jsonify({
'status': 'error',
'message': 'Unable to fetch ISS position from real-time APIs'
}), 503
result = {
'status': 'ok',
'lat': iss_lat,
'lon': iss_lon,
'altitude': float(data.get('altitude', 420)),
'timestamp': datetime.utcnow().isoformat(),
'source': 'wheretheiss'
}
result = {
'status': 'ok',
'lat': pos['lat'],
'lon': pos['lon'],
'altitude': pos['altitude'],
'timestamp': datetime.utcnow().isoformat(),
'source': pos['source'],
}
# Calculate observer-relative data if location provided
if observer_lat is not None and observer_lon is not None:
result.update(_calculate_observer_data(iss_lat, iss_lon, observer_lat, observer_lon))
# Calculate observer-relative data if location provided
if observer_lat is not None and observer_lon is not None:
result.update(_calculate_observer_data(pos['lat'], pos['lon'], observer_lat, observer_lon))
return jsonify(result)
except Exception as e:
logger.warning(f"Where The ISS At API failed: {e}")
# Try fallback API: Open Notify
try:
response = requests.get('http://api.open-notify.org/iss-now.json', timeout=5)
if response.status_code == 200:
data = response.json()
if data.get('message') == 'success':
iss_lat = float(data['iss_position']['latitude'])
iss_lon = float(data['iss_position']['longitude'])
result = {
'status': 'ok',
'lat': iss_lat,
'lon': iss_lon,
'altitude': 420, # Approximate ISS altitude in km
'timestamp': datetime.utcnow().isoformat(),
'source': 'open-notify'
}
# Calculate observer-relative data if location provided
if observer_lat is not None and observer_lon is not None:
result.update(_calculate_observer_data(iss_lat, iss_lon, observer_lat, observer_lon))
return jsonify(result)
except Exception as e:
logger.warning(f"Open Notify API failed: {e}")
# Both APIs failed
return jsonify({
'status': 'error',
'message': 'Unable to fetch ISS position from real-time APIs'
}), 503
return jsonify(result)
def _calculate_observer_data(iss_lat: float, iss_lon: float, obs_lat: float, obs_lon: float) -> dict:
@@ -716,7 +819,5 @@ def decode_file():
finally:
# Clean up temp file
try:
with contextlib.suppress(Exception):
Path(tmp_path).unlink()
except Exception:
pass
+62 -72
View File
@@ -6,16 +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.logging import get_logger
from utils.sse import format_sse
import app as app_module
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,
)
@@ -27,6 +28,10 @@ sstv_general_bp = Blueprint('sstv_general', __name__, url_prefix='/sstv-general'
# Queue for SSE progress streaming
_sstv_general_queue: queue.Queue = queue.Queue(maxsize=100)
# Track which device is being used
_sstv_general_active_device: int | None = None
_sstv_general_active_sdr_type: str = 'rtlsdr'
# Predefined SSTV frequencies
SSTV_FREQUENCIES = [
{'band': '80 m', 'frequency': 3.845, 'modulation': 'lsb', 'notes': 'Common US SSTV calling frequency', 'type': 'Terrestrial HF'},
@@ -40,7 +45,7 @@ SSTV_FREQUENCIES = [
{'band': '15 m', 'frequency': 21.340, 'modulation': 'usb', 'notes': 'International calling frequency', 'type': 'Terrestrial HF'},
{'band': '10 m', 'frequency': 28.680, 'modulation': 'usb', 'notes': 'International calling frequency', 'type': 'Terrestrial HF'},
{'band': '6 m', 'frequency': 50.950, 'modulation': 'usb', 'notes': 'SSTV calling (less common)', 'type': 'Terrestrial VHF'},
{'band': '2 m', 'frequency': 145.625, 'modulation': 'fm', 'notes': 'Australia/common simplex (FM sometimes used)', 'type': 'Terrestrial VHF'},
{'band': '2 m', 'frequency': 145.500, 'modulation': 'fm', 'notes': 'Australia/common simplex (FM sometimes used)', 'type': 'Terrestrial VHF'},
{'band': '70 cm', 'frequency': 433.775, 'modulation': 'fm', 'notes': 'Australia/common simplex', 'type': 'Terrestrial UHF'},
]
@@ -97,10 +102,7 @@ def start_decoder():
decoder = get_general_sstv_decoder()
if decoder.decoder_available is None:
return jsonify({
'status': 'error',
'message': 'SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow',
}), 400
return api_error('SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow', 400)
if decoder.is_running:
return jsonify({
@@ -115,29 +117,25 @@ def start_decoder():
break
data = request.get_json(silent=True) or {}
sdr_type_str = data.get('sdr_type', 'rtlsdr')
if sdr_type_str != 'rtlsdr':
return api_error(f'{sdr_type_str.replace("_", " ").title()} is not yet supported for this mode. Please use an RTL-SDR device.', 400)
frequency = data.get('frequency')
modulation = data.get('modulation')
device_index = data.get('device', 0)
# Validate frequency
if frequency is None:
return jsonify({
'status': 'error',
'message': 'Frequency is required',
}), 400
return api_error('Frequency is required', 400)
try:
frequency = float(frequency)
if not (1 <= frequency <= 500):
return jsonify({
'status': 'error',
'message': 'Frequency must be between 1-500 MHz (HF requires upconverter for RTL-SDR)',
}), 400
return api_error('Frequency must be between 1-500 MHz (HF requires upconverter for RTL-SDR)', 400)
except (TypeError, ValueError):
return jsonify({
'status': 'error',
'message': 'Invalid frequency',
}), 400
return api_error('Invalid frequency', 400)
# Auto-detect modulation from frequency table if not specified
if not modulation:
@@ -145,10 +143,14 @@ def start_decoder():
# Validate modulation
if modulation not in ('fm', 'usb', 'lsb'):
return jsonify({
'status': 'error',
'message': 'Modulation must be fm, usb, or lsb',
}), 400
return api_error('Modulation must be fm, usb, or lsb', 400)
# Claim SDR device
global _sstv_general_active_device, _sstv_general_active_sdr_type
device_int = int(device_index)
error = app_module.claim_sdr_device(device_int, 'sstv_general', sdr_type_str)
if error:
return api_error(error, 409, error_type='DEVICE_BUSY')
# Set callback and start
decoder.set_callback(_progress_callback)
@@ -159,6 +161,8 @@ def start_decoder():
)
if success:
_sstv_general_active_device = device_int
_sstv_general_active_sdr_type = sdr_type_str
return jsonify({
'status': 'started',
'frequency': frequency,
@@ -166,17 +170,21 @@ def start_decoder():
'device': device_index,
})
else:
return jsonify({
'status': 'error',
'message': 'Failed to start decoder',
}), 500
app_module.release_sdr_device(device_int, sdr_type_str)
return api_error('Failed to start decoder', 500)
@sstv_general_bp.route('/stop', methods=['POST'])
def stop_decoder():
"""Stop general SSTV decoder."""
global _sstv_general_active_device, _sstv_general_active_sdr_type
decoder = get_general_sstv_decoder()
decoder.stop()
if _sstv_general_active_device is not None:
app_module.release_sdr_device(_sstv_general_active_device, _sstv_general_active_sdr_type)
_sstv_general_active_device = None
return jsonify({'status': 'stopped'})
@@ -204,15 +212,15 @@ def get_image(filename: str):
# Security: only allow alphanumeric filenames with .png extension
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
return api_error('Invalid filename', 400)
if not filename.endswith('.png'):
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
return api_error('Only PNG files supported', 400)
image_path = decoder._output_dir / filename
if not image_path.exists():
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
return api_error('Image not found', 404)
return send_file(image_path, mimetype='image/png')
@@ -224,15 +232,15 @@ def download_image(filename: str):
# Security: only allow alphanumeric filenames with .png extension
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
return api_error('Invalid filename', 400)
if not filename.endswith('.png'):
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
return api_error('Only PNG files supported', 400)
image_path = decoder._output_dir / filename
if not image_path.exists():
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
return api_error('Image not found', 404)
return send_file(image_path, mimetype='image/png', as_attachment=True, download_name=filename)
@@ -244,15 +252,15 @@ def delete_image(filename: str):
# Security: only allow alphanumeric filenames with .png extension
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
return api_error('Invalid filename', 400)
if not filename.endswith('.png'):
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
return api_error('Only PNG files supported', 400)
if decoder.delete_image(filename):
return jsonify({'status': 'ok'})
else:
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
return api_error('Image not found', 404)
@sstv_general_bp.route('/images', methods=['DELETE'])
@@ -266,26 +274,19 @@ def delete_all_images():
@sstv_general_bp.route('/stream')
def stream_progress():
"""SSE stream of SSTV decode progress."""
def generate() -> Generator[str, None, None]:
last_keepalive = time.time()
keepalive_interval = 30.0
def _on_msg(msg: dict[str, Any]) -> None:
process_event('sstv_general', msg, msg.get('type'))
while True:
try:
progress = _sstv_general_queue.get(timeout=1)
last_keepalive = time.time()
try:
process_event('sstv_general', progress, progress.get('type'))
except Exception:
pass
yield format_sse(progress)
except queue.Empty:
now = time.time()
if now - last_keepalive >= keepalive_interval:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream')
response = Response(
sse_stream_fanout(
source_queue=_sstv_general_queue,
channel_key='sstv_general',
timeout=1.0,
keepalive_interval=30.0,
on_message=_on_msg,
),
mimetype='text/event-stream',
)
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
@@ -296,18 +297,12 @@ def stream_progress():
def decode_file():
"""Decode SSTV from an uploaded audio file."""
if 'audio' not in request.files:
return jsonify({
'status': 'error',
'message': 'No audio file provided',
}), 400
return api_error('No audio file provided', 400)
audio_file = request.files['audio']
if not audio_file.filename:
return jsonify({
'status': 'error',
'message': 'No file selected',
}), 400
return api_error('No file selected', 400)
import tempfile
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp:
@@ -326,13 +321,8 @@ def decode_file():
except Exception as e:
logger.error(f"Error decoding file: {e}")
return jsonify({
'status': 'error',
'message': str(e),
}), 500
return api_error(str(e), 500)
finally:
try:
with contextlib.suppress(Exception):
Path(tmp_path).unlink()
except Exception:
pass
+156 -151
View File
@@ -6,23 +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.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.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')
@@ -34,6 +37,8 @@ _subghz_queue: queue.Queue = queue.Queue(maxsize=200)
def _event_callback(event: dict) -> None:
"""Forward SubGhzManager events to the SSE queue."""
with contextlib.suppress(Exception):
process_event('subghz', event, event.get('type'))
try:
_subghz_queue.put_nowait(event)
except queue.Full:
@@ -71,44 +76,44 @@ def _validate_serial(data: dict) -> str | None:
return None
def _validate_int(data: dict, key: str, default: int, min_val: int, max_val: int) -> int:
"""Validate integer parameter with bounds clamping."""
def _validate_int(data: dict, key: str, default: int, min_val: int, max_val: int) -> int:
"""Validate integer parameter with bounds clamping."""
try:
val = int(data.get(key, default))
return max(min_val, min(max_val, val))
except (ValueError, TypeError):
return default
def _validate_decode_profile(data: dict, default: str = 'weather') -> str:
profile = data.get('decode_profile', default)
if not isinstance(profile, str):
return default
profile = profile.strip().lower()
if profile in {'weather', 'all'}:
return profile
return default
def _validate_optional_float(data: dict, key: str) -> tuple[float | None, str | None]:
raw = data.get(key)
if raw is None or raw == '':
return None, None
try:
return float(raw), None
except (ValueError, TypeError):
return None, f'Invalid {key}'
def _validate_bool(data: dict, key: str, default: bool = False) -> bool:
raw = data.get(key, default)
if isinstance(raw, bool):
return raw
if isinstance(raw, (int, float)):
return bool(raw)
if isinstance(raw, str):
return raw.strip().lower() in {'1', 'true', 'yes', 'on', 'enabled'}
return default
return default
def _validate_decode_profile(data: dict, default: str = 'weather') -> str:
profile = data.get('decode_profile', default)
if not isinstance(profile, str):
return default
profile = profile.strip().lower()
if profile in {'weather', 'all'}:
return profile
return default
def _validate_optional_float(data: dict, key: str) -> tuple[float | None, str | None]:
raw = data.get(key)
if raw is None or raw == '':
return None, None
try:
return float(raw), None
except (ValueError, TypeError):
return None, f'Invalid {key}'
def _validate_bool(data: dict, key: str, default: bool = False) -> bool:
raw = data.get(key, default)
if isinstance(raw, bool):
return raw
if isinstance(raw, (int, float)):
return bool(raw)
if isinstance(raw, str):
return raw.strip().lower() in {'1', 'true', 'yes', 'on', 'enabled'}
return default
# ------------------------------------------------------------------
@@ -131,34 +136,34 @@ def get_presets():
# ------------------------------------------------------------------
@subghz_bp.route('/receive/start', methods=['POST'])
def start_receive():
def start_receive():
data = request.get_json(silent=True) or {}
freq_hz, err = _validate_frequency_hz(data)
if err:
return jsonify({'status': 'error', 'message': err}), 400
return api_error(err, 400)
sample_rate = _validate_int(data, 'sample_rate', 2000000, 2000000, 20000000)
lna_gain = _validate_int(data, 'lna_gain', 32, 0, SUBGHZ_LNA_GAIN_MAX)
vga_gain = _validate_int(data, 'vga_gain', 20, 0, SUBGHZ_VGA_GAIN_MAX)
trigger_enabled = _validate_bool(data, 'trigger_enabled', False)
trigger_pre_ms = _validate_int(data, 'trigger_pre_ms', 350, 50, 5000)
trigger_post_ms = _validate_int(data, 'trigger_post_ms', 700, 100, 10000)
device_serial = _validate_serial(data)
sample_rate = _validate_int(data, 'sample_rate', 2000000, 2000000, 20000000)
lna_gain = _validate_int(data, 'lna_gain', 32, 0, SUBGHZ_LNA_GAIN_MAX)
vga_gain = _validate_int(data, 'vga_gain', 20, 0, SUBGHZ_VGA_GAIN_MAX)
trigger_enabled = _validate_bool(data, 'trigger_enabled', False)
trigger_pre_ms = _validate_int(data, 'trigger_pre_ms', 350, 50, 5000)
trigger_post_ms = _validate_int(data, 'trigger_post_ms', 700, 100, 10000)
device_serial = _validate_serial(data)
manager = get_subghz_manager()
manager.set_callback(_event_callback)
result = manager.start_receive(
frequency_hz=freq_hz,
sample_rate=sample_rate,
lna_gain=lna_gain,
vga_gain=vga_gain,
trigger_enabled=trigger_enabled,
trigger_pre_ms=trigger_pre_ms,
trigger_post_ms=trigger_post_ms,
device_serial=device_serial,
)
result = manager.start_receive(
frequency_hz=freq_hz,
sample_rate=sample_rate,
lna_gain=lna_gain,
vga_gain=vga_gain,
trigger_enabled=trigger_enabled,
trigger_pre_ms=trigger_pre_ms,
trigger_post_ms=trigger_post_ms,
device_serial=device_serial,
)
status_code = 200 if result.get('status') != 'error' else 409
return jsonify(result), status_code
@@ -181,25 +186,25 @@ def start_decode():
freq_hz, err = _validate_frequency_hz(data)
if err:
return jsonify({'status': 'error', 'message': err}), 400
return api_error(err, 400)
sample_rate = _validate_int(data, 'sample_rate', 2000000, 2000000, 20000000)
lna_gain = _validate_int(data, 'lna_gain', 32, 0, SUBGHZ_LNA_GAIN_MAX)
vga_gain = _validate_int(data, 'vga_gain', 20, 0, SUBGHZ_VGA_GAIN_MAX)
decode_profile = _validate_decode_profile(data)
device_serial = _validate_serial(data)
sample_rate = _validate_int(data, 'sample_rate', 2000000, 2000000, 20000000)
lna_gain = _validate_int(data, 'lna_gain', 32, 0, SUBGHZ_LNA_GAIN_MAX)
vga_gain = _validate_int(data, 'vga_gain', 20, 0, SUBGHZ_VGA_GAIN_MAX)
decode_profile = _validate_decode_profile(data)
device_serial = _validate_serial(data)
manager = get_subghz_manager()
manager.set_callback(_event_callback)
result = manager.start_decode(
frequency_hz=freq_hz,
sample_rate=sample_rate,
lna_gain=lna_gain,
vga_gain=vga_gain,
decode_profile=decode_profile,
device_serial=device_serial,
)
sample_rate=sample_rate,
lna_gain=lna_gain,
vga_gain=vga_gain,
decode_profile=decode_profile,
device_serial=device_serial,
)
status_code = 200 if result.get('status') != 'error' else 409
return jsonify(result), status_code
@@ -222,33 +227,33 @@ def start_transmit():
capture_id = data.get('capture_id')
if not capture_id or not isinstance(capture_id, str):
return jsonify({'status': 'error', 'message': 'capture_id is required'}), 400
return api_error('capture_id is required', 400)
# Sanitize capture_id
if not capture_id.isalnum():
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
return api_error('Invalid capture_id', 400)
tx_gain = _validate_int(data, 'tx_gain', 20, 0, SUBGHZ_TX_VGA_GAIN_MAX)
max_duration = _validate_int(data, 'max_duration', 10, 1, SUBGHZ_TX_MAX_DURATION)
start_seconds, start_err = _validate_optional_float(data, 'start_seconds')
if start_err:
return jsonify({'status': 'error', 'message': start_err}), 400
duration_seconds, duration_err = _validate_optional_float(data, 'duration_seconds')
if duration_err:
return jsonify({'status': 'error', 'message': duration_err}), 400
device_serial = _validate_serial(data)
tx_gain = _validate_int(data, 'tx_gain', 20, 0, SUBGHZ_TX_VGA_GAIN_MAX)
max_duration = _validate_int(data, 'max_duration', 10, 1, SUBGHZ_TX_MAX_DURATION)
start_seconds, start_err = _validate_optional_float(data, 'start_seconds')
if start_err:
return api_error(start_err, 400)
duration_seconds, duration_err = _validate_optional_float(data, 'duration_seconds')
if duration_err:
return api_error(duration_err, 400)
device_serial = _validate_serial(data)
manager = get_subghz_manager()
manager.set_callback(_event_callback)
result = manager.transmit(
capture_id=capture_id,
tx_gain=tx_gain,
max_duration=max_duration,
start_seconds=start_seconds,
duration_seconds=duration_seconds,
device_serial=device_serial,
)
capture_id=capture_id,
tx_gain=tx_gain,
max_duration=max_duration,
start_seconds=start_seconds,
duration_seconds=duration_seconds,
device_serial=device_serial,
)
status_code = 200 if result.get('status') != 'error' else 400
return jsonify(result), status_code
@@ -273,11 +278,11 @@ def start_sweep():
freq_start = float(data.get('freq_start_mhz', 300))
freq_end = float(data.get('freq_end_mhz', 928))
if freq_start >= freq_end:
return jsonify({'status': 'error', 'message': 'freq_start must be less than freq_end'}), 400
return api_error('freq_start must be less than freq_end', 400)
if freq_start < SUBGHZ_FREQ_MIN_MHZ or freq_end > SUBGHZ_FREQ_MAX_MHZ:
return jsonify({'status': 'error', 'message': f'Frequency range: {SUBGHZ_FREQ_MIN_MHZ}-{SUBGHZ_FREQ_MAX_MHZ} MHz'}), 400
return api_error(f'Frequency range: {SUBGHZ_FREQ_MIN_MHZ}-{SUBGHZ_FREQ_MAX_MHZ} MHz', 400)
except (ValueError, TypeError):
return jsonify({'status': 'error', 'message': 'Invalid frequency range'}), 400
return api_error('Invalid frequency range', 400)
bin_width = _validate_int(data, 'bin_width', 100000, 10000, 5000000)
device_serial = _validate_serial(data)
@@ -321,94 +326,94 @@ def list_captures():
@subghz_bp.route('/captures/<capture_id>')
def get_capture(capture_id: str):
if not capture_id.isalnum():
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
return api_error('Invalid capture_id', 400)
manager = get_subghz_manager()
capture = manager.get_capture(capture_id)
if not capture:
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
return api_error('Capture not found', 404)
return jsonify({'status': 'ok', 'capture': capture.to_dict()})
@subghz_bp.route('/captures/<capture_id>/download')
def download_capture(capture_id: str):
if not capture_id.isalnum():
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
@subghz_bp.route('/captures/<capture_id>/download')
def download_capture(capture_id: str):
if not capture_id.isalnum():
return api_error('Invalid capture_id', 400)
manager = get_subghz_manager()
path = manager.get_capture_path(capture_id)
if not path:
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
return api_error('Capture not found', 404)
return send_file(
path,
mimetype='application/octet-stream',
as_attachment=True,
download_name=path.name,
)
@subghz_bp.route('/captures/<capture_id>/trim', methods=['POST'])
def trim_capture(capture_id: str):
if not capture_id.isalnum():
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
data = request.get_json(silent=True) or {}
start_seconds, start_err = _validate_optional_float(data, 'start_seconds')
if start_err:
return jsonify({'status': 'error', 'message': start_err}), 400
duration_seconds, duration_err = _validate_optional_float(data, 'duration_seconds')
if duration_err:
return jsonify({'status': 'error', 'message': duration_err}), 400
label = data.get('label', '')
if label is None:
label = ''
if not isinstance(label, str) or len(label) > 100:
return jsonify({'status': 'error', 'message': 'Label must be a string (max 100 chars)'}), 400
manager = get_subghz_manager()
result = manager.trim_capture(
capture_id=capture_id,
start_seconds=start_seconds,
duration_seconds=duration_seconds,
label=label,
)
if result.get('status') == 'ok':
return jsonify(result), 200
message = str(result.get('message') or 'Trim failed')
status_code = 404 if 'not found' in message.lower() else 400
return jsonify(result), status_code
@subghz_bp.route('/captures/<capture_id>', methods=['DELETE'])
def delete_capture(capture_id: str):
if not capture_id.isalnum():
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
as_attachment=True,
download_name=path.name,
)
@subghz_bp.route('/captures/<capture_id>/trim', methods=['POST'])
def trim_capture(capture_id: str):
if not capture_id.isalnum():
return api_error('Invalid capture_id', 400)
data = request.get_json(silent=True) or {}
start_seconds, start_err = _validate_optional_float(data, 'start_seconds')
if start_err:
return api_error(start_err, 400)
duration_seconds, duration_err = _validate_optional_float(data, 'duration_seconds')
if duration_err:
return api_error(duration_err, 400)
label = data.get('label', '')
if label is None:
label = ''
if not isinstance(label, str) or len(label) > 100:
return api_error('Label must be a string (max 100 chars)', 400)
manager = get_subghz_manager()
result = manager.trim_capture(
capture_id=capture_id,
start_seconds=start_seconds,
duration_seconds=duration_seconds,
label=label,
)
if result.get('status') == 'ok':
return jsonify(result), 200
message = str(result.get('message') or 'Trim failed')
status_code = 404 if 'not found' in message.lower() else 400
return jsonify(result), status_code
@subghz_bp.route('/captures/<capture_id>', methods=['DELETE'])
def delete_capture(capture_id: str):
if not capture_id.isalnum():
return api_error('Invalid capture_id', 400)
manager = get_subghz_manager()
if manager.delete_capture(capture_id):
return jsonify({'status': 'deleted', 'id': capture_id})
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
return api_error('Capture not found', 404)
@subghz_bp.route('/captures/<capture_id>', methods=['PATCH'])
def update_capture(capture_id: str):
if not capture_id.isalnum():
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
return api_error('Invalid capture_id', 400)
data = request.get_json(silent=True) or {}
label = data.get('label', '')
if not isinstance(label, str) or len(label) > 100:
return jsonify({'status': 'error', 'message': 'Label must be a string (max 100 chars)'}), 400
return api_error('Label must be a string (max 100 chars)', 400)
manager = get_subghz_manager()
if manager.update_capture_label(capture_id, label):
return jsonify({'status': 'updated', 'id': capture_id, 'label': label})
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
return api_error('Capture not found', 404)
# ------------------------------------------------------------------
+584
View File
@@ -0,0 +1,584 @@
"""System Health monitoring blueprint.
Provides real-time system metrics (CPU, memory, disk, temperatures,
network, battery, fans), active process status, SDR device enumeration,
location, and weather data via SSE streaming and REST endpoints.
"""
from __future__ import annotations
import contextlib
import os
import platform
import queue
import socket
import subprocess
import threading
import time
from pathlib import Path
from typing import Any
from flask import Blueprint, Response, jsonify, request
from utils.constants import SSE_KEEPALIVE_INTERVAL, SSE_QUEUE_TIMEOUT
from utils.logging import sensor_logger as logger
from utils.responses import api_error
from utils.sse import sse_stream_fanout
try:
import psutil
_HAS_PSUTIL = True
except ImportError:
psutil = None # type: ignore[assignment]
_HAS_PSUTIL = False
try:
import requests as _requests
except ImportError:
_requests = None # type: ignore[assignment]
system_bp = Blueprint('system', __name__, url_prefix='/system')
# ---------------------------------------------------------------------------
# Background metrics collector
# ---------------------------------------------------------------------------
_metrics_queue: queue.Queue = queue.Queue(maxsize=500)
_collector_started = False
_collector_lock = threading.Lock()
_app_start_time: float | None = None
# Weather cache
_weather_cache: dict[str, Any] = {}
_weather_cache_time: float = 0.0
_WEATHER_CACHE_TTL = 600 # 10 minutes
def _get_app_start_time() -> float:
"""Return the application start timestamp from the main app module."""
global _app_start_time
if _app_start_time is None:
try:
import app as app_module
_app_start_time = getattr(app_module, '_app_start_time', time.time())
except Exception:
_app_start_time = time.time()
return _app_start_time
def _get_app_version() -> str:
"""Return the application version string."""
try:
from config import VERSION
return VERSION
except Exception:
return 'unknown'
def _format_uptime(seconds: float) -> str:
"""Format seconds into a human-readable uptime string."""
days = int(seconds // 86400)
hours = int((seconds % 86400) // 3600)
minutes = int((seconds % 3600) // 60)
parts = []
if days > 0:
parts.append(f'{days}d')
if hours > 0:
parts.append(f'{hours}h')
parts.append(f'{minutes}m')
return ' '.join(parts)
def _collect_process_status() -> dict[str, bool]:
"""Return running/stopped status for each decoder process.
Mirrors the logic in app.py health_check().
"""
try:
import app as app_module
def _alive(attr: str) -> bool:
proc = getattr(app_module, attr, None)
if proc is None:
return False
try:
return proc.poll() is None
except Exception:
return False
processes: dict[str, bool] = {
'pager': _alive('current_process'),
'sensor': _alive('sensor_process'),
'adsb': _alive('adsb_process'),
'ais': _alive('ais_process'),
'acars': _alive('acars_process'),
'vdl2': _alive('vdl2_process'),
'aprs': _alive('aprs_process'),
'dsc': _alive('dsc_process'),
'morse': _alive('morse_process'),
}
# WiFi
try:
from app import _get_wifi_health
wifi_active, _, _ = _get_wifi_health()
processes['wifi'] = wifi_active
except Exception:
processes['wifi'] = False
# Bluetooth
try:
from app import _get_bluetooth_health
bt_active, _ = _get_bluetooth_health()
processes['bluetooth'] = bt_active
except Exception:
processes['bluetooth'] = False
# SubGHz
try:
from app import _get_subghz_active
processes['subghz'] = _get_subghz_active()
except Exception:
processes['subghz'] = False
return processes
except Exception:
return {}
def _collect_throttle_flags() -> str | None:
"""Read Raspberry Pi throttle flags via vcgencmd (Linux/Pi only)."""
try:
result = subprocess.run(
['vcgencmd', 'get_throttled'],
capture_output=True,
text=True,
timeout=2,
)
if result.returncode == 0 and 'throttled=' in result.stdout:
return result.stdout.strip().split('=', 1)[1]
except Exception:
pass
return None
def _collect_power_draw() -> float | None:
"""Read power draw in watts from sysfs (Linux only)."""
try:
power_supply = Path('/sys/class/power_supply')
if not power_supply.exists():
return None
for supply_dir in power_supply.iterdir():
power_file = supply_dir / 'power_now'
if power_file.exists():
val = int(power_file.read_text().strip())
return round(val / 1_000_000, 2) # microwatts to watts
except Exception:
pass
return None
def _collect_metrics() -> dict[str, Any]:
"""Gather a snapshot of system metrics."""
now = time.time()
start = _get_app_start_time()
uptime_seconds = round(now - start, 2)
metrics: dict[str, Any] = {
'type': 'system_metrics',
'timestamp': now,
'system': {
'hostname': socket.gethostname(),
'platform': platform.platform(),
'python': platform.python_version(),
'version': _get_app_version(),
'uptime_seconds': uptime_seconds,
'uptime_human': _format_uptime(uptime_seconds),
},
'processes': _collect_process_status(),
}
if _HAS_PSUTIL:
# CPU — overall + per-core + frequency
cpu_percent = psutil.cpu_percent(interval=None)
cpu_count = psutil.cpu_count() or 1
try:
load_1, load_5, load_15 = os.getloadavg()
except (OSError, AttributeError):
load_1 = load_5 = load_15 = 0.0
per_core = []
with contextlib.suppress(Exception):
per_core = psutil.cpu_percent(interval=None, percpu=True)
freq_data = None
with contextlib.suppress(Exception):
freq = psutil.cpu_freq()
if freq:
freq_data = {
'current': round(freq.current, 0),
'min': round(freq.min, 0),
'max': round(freq.max, 0),
}
metrics['cpu'] = {
'percent': cpu_percent,
'count': cpu_count,
'load_1': round(load_1, 2),
'load_5': round(load_5, 2),
'load_15': round(load_15, 2),
'per_core': per_core,
'freq': freq_data,
}
# Memory
mem = psutil.virtual_memory()
metrics['memory'] = {
'total': mem.total,
'used': mem.used,
'available': mem.available,
'percent': mem.percent,
}
swap = psutil.swap_memory()
metrics['swap'] = {
'total': swap.total,
'used': swap.used,
'percent': swap.percent,
}
# Disk — usage + I/O counters
try:
disk = psutil.disk_usage('/')
metrics['disk'] = {
'total': disk.total,
'used': disk.used,
'free': disk.free,
'percent': disk.percent,
'path': '/',
}
except Exception:
metrics['disk'] = None
disk_io = None
with contextlib.suppress(Exception):
dio = psutil.disk_io_counters()
if dio:
disk_io = {
'read_bytes': dio.read_bytes,
'write_bytes': dio.write_bytes,
'read_count': dio.read_count,
'write_count': dio.write_count,
}
metrics['disk_io'] = disk_io
# Temperatures
try:
temps = psutil.sensors_temperatures()
if temps:
temp_data: dict[str, list[dict[str, Any]]] = {}
for chip, entries in temps.items():
temp_data[chip] = [
{
'label': e.label or chip,
'current': e.current,
'high': e.high,
'critical': e.critical,
}
for e in entries
]
metrics['temperatures'] = temp_data
else:
metrics['temperatures'] = None
except (AttributeError, Exception):
metrics['temperatures'] = None
# Fans
fans_data = None
with contextlib.suppress(Exception):
fans = psutil.sensors_fans()
if fans:
fans_data = {}
for chip, entries in fans.items():
fans_data[chip] = [
{'label': e.label or chip, 'current': e.current}
for e in entries
]
metrics['fans'] = fans_data
# Battery
battery_data = None
with contextlib.suppress(Exception):
bat = psutil.sensors_battery()
if bat:
battery_data = {
'percent': bat.percent,
'plugged': bat.power_plugged,
'secs_left': bat.secsleft if bat.secsleft != psutil.POWER_TIME_UNLIMITED else None,
}
metrics['battery'] = battery_data
# Network interfaces
net_ifaces: list[dict[str, Any]] = []
with contextlib.suppress(Exception):
addrs = psutil.net_if_addrs()
stats = psutil.net_if_stats()
for iface_name in sorted(addrs.keys()):
if iface_name == 'lo':
continue
iface_info: dict[str, Any] = {'name': iface_name}
# Get addresses
for addr in addrs[iface_name]:
if addr.family == socket.AF_INET:
iface_info['ipv4'] = addr.address
elif addr.family == socket.AF_INET6:
iface_info.setdefault('ipv6', addr.address)
elif addr.family == psutil.AF_LINK:
iface_info['mac'] = addr.address
# Get stats
if iface_name in stats:
st = stats[iface_name]
iface_info['is_up'] = st.isup
iface_info['speed'] = st.speed # Mbps
iface_info['mtu'] = st.mtu
net_ifaces.append(iface_info)
metrics['network'] = {'interfaces': net_ifaces}
# Network I/O counters (raw — JS computes deltas)
net_io = None
with contextlib.suppress(Exception):
counters = psutil.net_io_counters(pernic=True)
if counters:
net_io = {}
for nic, c in counters.items():
if nic == 'lo':
continue
net_io[nic] = {
'bytes_sent': c.bytes_sent,
'bytes_recv': c.bytes_recv,
}
metrics['network']['io'] = net_io
# Connection count
conn_count = 0
with contextlib.suppress(Exception):
conn_count = len(psutil.net_connections())
metrics['network']['connections'] = conn_count
# Boot time
boot_ts = None
with contextlib.suppress(Exception):
boot_ts = psutil.boot_time()
metrics['boot_time'] = boot_ts
# Power / throttle (Pi-specific)
metrics['power'] = {
'throttled': _collect_throttle_flags(),
'draw_watts': _collect_power_draw(),
}
else:
metrics['cpu'] = None
metrics['memory'] = None
metrics['swap'] = None
metrics['disk'] = None
metrics['disk_io'] = None
metrics['temperatures'] = None
metrics['fans'] = None
metrics['battery'] = None
metrics['network'] = None
metrics['boot_time'] = None
metrics['power'] = None
return metrics
def _collector_loop() -> None:
"""Background thread that pushes metrics onto the queue every 3 seconds."""
# Seed psutil's CPU measurement so the first real read isn't 0%.
if _HAS_PSUTIL:
with contextlib.suppress(Exception):
psutil.cpu_percent(interval=None)
while True:
try:
metrics = _collect_metrics()
# Non-blocking put — drop oldest if full
try:
_metrics_queue.put_nowait(metrics)
except queue.Full:
with contextlib.suppress(queue.Empty):
_metrics_queue.get_nowait()
_metrics_queue.put_nowait(metrics)
except Exception as exc:
logger.debug('system metrics collection error: %s', exc)
time.sleep(3)
def _ensure_collector() -> None:
"""Start the background collector thread once."""
global _collector_started
if _collector_started:
return
with _collector_lock:
if _collector_started:
return
t = threading.Thread(target=_collector_loop, daemon=True, name='system-metrics-collector')
t.start()
_collector_started = True
logger.info('System metrics collector started')
def _get_observer_location() -> dict[str, Any]:
"""Get observer location from GPS state or config defaults."""
lat, lon, source = None, None, 'none'
gps_meta: dict[str, Any] = {}
# Try GPS via utils.gps
with contextlib.suppress(Exception):
from utils.gps import get_current_position
pos = get_current_position()
if pos and pos.fix_quality >= 2:
lat, lon, source = pos.latitude, pos.longitude, 'gps'
gps_meta['fix_quality'] = pos.fix_quality
gps_meta['satellites'] = pos.satellites
if pos.epx is not None and pos.epy is not None:
gps_meta['accuracy'] = round(max(pos.epx, pos.epy), 1)
if pos.altitude is not None:
gps_meta['altitude'] = round(pos.altitude, 1)
# Fall back to config env vars
if lat is None:
with contextlib.suppress(Exception):
from config import DEFAULT_LATITUDE, DEFAULT_LONGITUDE
if DEFAULT_LATITUDE != 0.0 or DEFAULT_LONGITUDE != 0.0:
lat, lon, source = DEFAULT_LATITUDE, DEFAULT_LONGITUDE, 'config'
# Fall back to hardcoded constants (London)
if lat is None:
with contextlib.suppress(Exception):
from utils.constants import DEFAULT_LATITUDE as CONST_LAT
from utils.constants import DEFAULT_LONGITUDE as CONST_LON
lat, lon, source = CONST_LAT, CONST_LON, 'default'
result: dict[str, Any] = {'lat': lat, 'lon': lon, 'source': source}
if gps_meta:
result['gps'] = gps_meta
return result
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@system_bp.route('/metrics')
def get_metrics() -> Response:
"""REST snapshot of current system metrics."""
_ensure_collector()
return jsonify(_collect_metrics())
@system_bp.route('/stream')
def stream_system() -> Response:
"""SSE stream for real-time system metrics."""
_ensure_collector()
response = Response(
sse_stream_fanout(
source_queue=_metrics_queue,
channel_key='system',
timeout=SSE_QUEUE_TIMEOUT,
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
),
mimetype='text/event-stream',
)
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
return response
@system_bp.route('/sdr_devices')
def get_sdr_devices() -> Response:
"""Enumerate all connected SDR devices (on-demand, not every tick)."""
try:
from utils.sdr.detection import detect_all_devices
devices = detect_all_devices()
result = []
for d in devices:
result.append({
'type': d.sdr_type.value if hasattr(d.sdr_type, 'value') else str(d.sdr_type),
'index': d.index,
'name': d.name,
'serial': d.serial or '',
'driver': d.driver or '',
})
return jsonify({'devices': result})
except Exception as exc:
logger.warning('SDR device detection failed: %s', exc)
return jsonify({'devices': [], 'error': str(exc)})
@system_bp.route('/location')
def get_location() -> Response:
"""Return observer location from GPS or config."""
return jsonify(_get_observer_location())
@system_bp.route('/weather')
def get_weather() -> Response:
"""Proxy weather from wttr.in, cached for 10 minutes."""
global _weather_cache, _weather_cache_time
now = time.time()
if _weather_cache and (now - _weather_cache_time) < _WEATHER_CACHE_TTL:
return jsonify(_weather_cache)
lat = request.args.get('lat', type=float)
lon = request.args.get('lon', type=float)
if lat is None or lon is None:
loc = _get_observer_location()
lat, lon = loc.get('lat'), loc.get('lon')
if lat is None or lon is None:
return api_error('No location available')
if _requests is None:
return api_error('requests library not available')
try:
resp = _requests.get(
f'https://wttr.in/{lat},{lon}?format=j1',
timeout=5,
headers={'User-Agent': 'INTERCEPT-SystemHealth/1.0'},
)
resp.raise_for_status()
data = resp.json()
current = data.get('current_condition', [{}])[0]
weather = {
'temp_c': current.get('temp_C'),
'temp_f': current.get('temp_F'),
'condition': current.get('weatherDesc', [{}])[0].get('value', ''),
'humidity': current.get('humidity'),
'wind_mph': current.get('windspeedMiles'),
'wind_dir': current.get('winddir16Point'),
'feels_like_c': current.get('FeelsLikeC'),
'visibility': current.get('visibility'),
'pressure': current.get('pressure'),
}
_weather_cache = weather
_weather_cache_time = now
return jsonify(weather)
except Exception as exc:
logger.debug('Weather fetch failed: %s', exc)
return api_error(str(exc))
-3964
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+270
View File
@@ -0,0 +1,270 @@
"""
TSCM Baseline Routes
Handles /baseline/*, /baselines endpoints.
"""
from __future__ import annotations
import json
import logging
from datetime import datetime
from flask import jsonify, request
from routes.tscm import (
_baseline_recorder,
tscm_bp,
)
from utils.database import (
delete_tscm_baseline,
get_active_tscm_baseline,
get_all_tscm_baselines,
get_tscm_baseline,
get_tscm_sweep,
set_active_tscm_baseline,
)
from utils.tscm.baseline import (
get_comparison_for_active_baseline,
)
logger = logging.getLogger('intercept.tscm')
@tscm_bp.route('/baseline/record', methods=['POST'])
def record_baseline():
"""Start recording a new baseline."""
data = request.get_json() or {}
name = data.get('name', f'Baseline {datetime.now().strftime("%Y-%m-%d %H:%M")}')
location = data.get('location')
description = data.get('description')
baseline_id = _baseline_recorder.start_recording(name, location, description)
return jsonify({
'status': 'success',
'message': 'Baseline recording started',
'baseline_id': baseline_id
})
@tscm_bp.route('/baseline/stop', methods=['POST'])
def stop_baseline():
"""Stop baseline recording."""
result = _baseline_recorder.stop_recording()
if 'error' in result:
return jsonify({'status': 'error', 'message': result['error']})
return jsonify({
'status': 'success',
'message': 'Baseline recording complete',
**result
})
@tscm_bp.route('/baseline/status')
def baseline_status():
"""Get baseline recording status."""
return jsonify(_baseline_recorder.get_recording_status())
@tscm_bp.route('/baselines')
def list_baselines():
"""List all baselines."""
baselines = get_all_tscm_baselines()
return jsonify({'status': 'success', 'baselines': baselines})
@tscm_bp.route('/baseline/<int:baseline_id>')
def get_baseline(baseline_id: int):
"""Get a specific baseline."""
baseline = get_tscm_baseline(baseline_id)
if not baseline:
return jsonify({'status': 'error', 'message': 'Baseline not found'}), 404
return jsonify({'status': 'success', 'baseline': baseline})
@tscm_bp.route('/baseline/<int:baseline_id>/activate', methods=['POST'])
def activate_baseline(baseline_id: int):
"""Set a baseline as active."""
success = set_active_tscm_baseline(baseline_id)
if not success:
return jsonify({'status': 'error', 'message': 'Baseline not found'}), 404
return jsonify({'status': 'success', 'message': 'Baseline activated'})
@tscm_bp.route('/baseline/<int:baseline_id>', methods=['DELETE'])
def remove_baseline(baseline_id: int):
"""Delete a baseline."""
success = delete_tscm_baseline(baseline_id)
if not success:
return jsonify({'status': 'error', 'message': 'Baseline not found'}), 404
return jsonify({'status': 'success', 'message': 'Baseline deleted'})
@tscm_bp.route('/baseline/active')
def get_active_baseline():
"""Get the currently active baseline."""
baseline = get_active_tscm_baseline()
if not baseline:
return jsonify({'status': 'success', 'baseline': None})
return jsonify({'status': 'success', 'baseline': baseline})
@tscm_bp.route('/baseline/compare', methods=['POST'])
def compare_against_baseline():
"""
Compare provided device data against the active baseline.
Expects JSON body with:
- wifi_devices: list of WiFi devices (optional)
- wifi_clients: list of WiFi clients (optional)
- bt_devices: list of Bluetooth devices (optional)
- rf_signals: list of RF signals (optional)
Returns comparison showing new, missing, and matching devices.
"""
data = request.get_json() or {}
wifi_devices = data.get('wifi_devices')
wifi_clients = data.get('wifi_clients')
bt_devices = data.get('bt_devices')
rf_signals = data.get('rf_signals')
# Use the convenience function that gets active baseline
comparison = get_comparison_for_active_baseline(
wifi_devices=wifi_devices,
wifi_clients=wifi_clients,
bt_devices=bt_devices,
rf_signals=rf_signals
)
if comparison is None:
return jsonify({
'status': 'error',
'message': 'No active baseline set'
}), 400
return jsonify({
'status': 'success',
'comparison': comparison
})
# =============================================================================
# Baseline Diff & Health Endpoints
# =============================================================================
@tscm_bp.route('/baseline/diff/<int:baseline_id>/<int:sweep_id>')
def get_baseline_diff(baseline_id: int, sweep_id: int):
"""
Get comprehensive diff between a baseline and a sweep.
Shows new devices, missing devices, changed characteristics,
and baseline health assessment.
"""
try:
from utils.tscm.advanced import calculate_baseline_diff
baseline = get_tscm_baseline(baseline_id)
if not baseline:
return jsonify({'status': 'error', 'message': 'Baseline not found'}), 404
sweep = get_tscm_sweep(sweep_id)
if not sweep:
return jsonify({'status': 'error', 'message': 'Sweep not found'}), 404
# Get current devices from sweep results
results = sweep.get('results', {})
if isinstance(results, str):
results = json.loads(results)
current_wifi = results.get('wifi_devices', [])
current_wifi_clients = results.get('wifi_clients', [])
current_bt = results.get('bt_devices', [])
current_rf = results.get('rf_signals', [])
diff = calculate_baseline_diff(
baseline=baseline,
current_wifi=current_wifi,
current_wifi_clients=current_wifi_clients,
current_bt=current_bt,
current_rf=current_rf,
sweep_id=sweep_id
)
return jsonify({
'status': 'success',
'diff': diff.to_dict()
})
except Exception as e:
logger.error(f"Get baseline diff error: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500
@tscm_bp.route('/baseline/<int:baseline_id>/health')
def get_baseline_health(baseline_id: int):
"""Get health assessment for a baseline."""
try:
baseline = get_tscm_baseline(baseline_id)
if not baseline:
return jsonify({'status': 'error', 'message': 'Baseline not found'}), 404
# Calculate age
created_at = baseline.get('created_at')
age_hours = 0
if created_at:
if isinstance(created_at, str):
created = datetime.fromisoformat(created_at.replace('Z', '+00:00'))
age_hours = (datetime.now() - created.replace(tzinfo=None)).total_seconds() / 3600
elif isinstance(created_at, datetime):
age_hours = (datetime.now() - created_at).total_seconds() / 3600
# Count devices
total_devices = (
len(baseline.get('wifi_networks', [])) +
len(baseline.get('bt_devices', [])) +
len(baseline.get('rf_frequencies', []))
)
# Determine health
health = 'healthy'
score = 1.0
reasons = []
if age_hours > 168:
health = 'stale'
score = 0.3
reasons.append(f'Baseline is {age_hours:.0f} hours old (over 1 week)')
elif age_hours > 72:
health = 'noisy'
score = 0.6
reasons.append(f'Baseline is {age_hours:.0f} hours old (over 3 days)')
if total_devices < 3:
score -= 0.2
reasons.append(f'Baseline has few devices ({total_devices})')
if health == 'healthy':
health = 'noisy'
return jsonify({
'status': 'success',
'health': {
'status': health,
'score': round(max(0, score), 2),
'age_hours': round(age_hours, 1),
'total_devices': total_devices,
'reasons': reasons,
}
})
except Exception as e:
logger.error(f"Get baseline health error: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500
+149
View File
@@ -0,0 +1,149 @@
"""
TSCM Case Management Routes
Handles /cases/* endpoints.
"""
from __future__ import annotations
import logging
from flask import jsonify, request
from routes.tscm import tscm_bp
logger = logging.getLogger('intercept.tscm')
@tscm_bp.route('/cases', methods=['GET'])
def list_cases():
"""List all TSCM cases."""
from utils.database import get_all_tscm_cases
status = request.args.get('status')
limit = request.args.get('limit', 50, type=int)
cases = get_all_tscm_cases(status=status, limit=limit)
return jsonify({
'status': 'success',
'count': len(cases),
'cases': cases
})
@tscm_bp.route('/cases', methods=['POST'])
def create_case():
"""Create a new TSCM case."""
from utils.database import create_tscm_case
data = request.get_json() or {}
name = data.get('name')
if not name:
return jsonify({'status': 'error', 'message': 'name is required'}), 400
case_id = create_tscm_case(
name=name,
description=data.get('description'),
location=data.get('location'),
priority=data.get('priority', 'normal'),
created_by=data.get('created_by'),
metadata=data.get('metadata')
)
return jsonify({
'status': 'success',
'message': 'Case created',
'case_id': case_id
})
@tscm_bp.route('/cases/<int:case_id>', methods=['GET'])
def get_case(case_id: int):
"""Get a TSCM case with all linked sweeps, threats, and notes."""
from utils.database import get_tscm_case
case = get_tscm_case(case_id)
if not case:
return jsonify({'status': 'error', 'message': 'Case not found'}), 404
return jsonify({
'status': 'success',
'case': case
})
@tscm_bp.route('/cases/<int:case_id>', methods=['PUT'])
def update_case(case_id: int):
"""Update a TSCM case."""
from utils.database import update_tscm_case
data = request.get_json() or {}
success = update_tscm_case(
case_id=case_id,
status=data.get('status'),
priority=data.get('priority'),
assigned_to=data.get('assigned_to'),
notes=data.get('notes')
)
if not success:
return jsonify({'status': 'error', 'message': 'Case not found'}), 404
return jsonify({
'status': 'success',
'message': 'Case updated'
})
@tscm_bp.route('/cases/<int:case_id>/sweeps/<int:sweep_id>', methods=['POST'])
def link_sweep_to_case(case_id: int, sweep_id: int):
"""Link a sweep to a case."""
from utils.database import add_sweep_to_case
success = add_sweep_to_case(case_id, sweep_id)
return jsonify({
'status': 'success' if success else 'error',
'message': 'Sweep linked to case' if success else 'Already linked or not found'
})
@tscm_bp.route('/cases/<int:case_id>/threats/<int:threat_id>', methods=['POST'])
def link_threat_to_case(case_id: int, threat_id: int):
"""Link a threat to a case."""
from utils.database import add_threat_to_case
success = add_threat_to_case(case_id, threat_id)
return jsonify({
'status': 'success' if success else 'error',
'message': 'Threat linked to case' if success else 'Already linked or not found'
})
@tscm_bp.route('/cases/<int:case_id>/notes', methods=['POST'])
def add_note_to_case(case_id: int):
"""Add a note to a case."""
from utils.database import add_case_note
data = request.get_json() or {}
content = data.get('content')
if not content:
return jsonify({'status': 'error', 'message': 'content is required'}), 400
note_id = add_case_note(
case_id=case_id,
content=content,
note_type=data.get('note_type', 'general'),
created_by=data.get('created_by')
)
return jsonify({
'status': 'success',
'message': 'Note added',
'note_id': note_id
})
+203
View File
@@ -0,0 +1,203 @@
"""
TSCM Meeting Window Routes
Handles /meeting/* endpoints for time correlation during sensitive periods.
"""
from __future__ import annotations
import logging
from datetime import datetime
from flask import jsonify, request
from routes.tscm import (
_current_sweep_id,
_emit_event,
tscm_bp,
)
from utils.tscm.correlation import get_correlation_engine
logger = logging.getLogger('intercept.tscm')
@tscm_bp.route('/meeting/start', methods=['POST'])
def start_meeting():
"""
Mark the start of a sensitive period (meeting, briefing, etc.).
Devices detected during this window will receive additional scoring
for meeting-correlated activity.
"""
correlation = get_correlation_engine()
correlation.start_meeting_window()
_emit_event('meeting_started', {
'timestamp': datetime.now().isoformat(),
'message': 'Sensitive period monitoring active'
})
return jsonify({
'status': 'success',
'message': 'Meeting window started - devices detected now will be flagged'
})
@tscm_bp.route('/meeting/end', methods=['POST'])
def end_meeting():
"""Mark the end of a sensitive period."""
correlation = get_correlation_engine()
correlation.end_meeting_window()
_emit_event('meeting_ended', {
'timestamp': datetime.now().isoformat()
})
return jsonify({
'status': 'success',
'message': 'Meeting window ended'
})
@tscm_bp.route('/meeting/status')
def meeting_status():
"""Check if currently in a meeting window."""
correlation = get_correlation_engine()
in_meeting = correlation.is_during_meeting()
return jsonify({
'status': 'success',
'in_meeting': in_meeting,
'windows': [
{
'start': start.isoformat(),
'end': end.isoformat() if end else None
}
for start, end in correlation.meeting_windows
]
})
# =============================================================================
# Meeting Window Enhanced Endpoints
# =============================================================================
@tscm_bp.route('/meeting/start-tracked', methods=['POST'])
def start_tracked_meeting():
"""
Start a tracked meeting window with database persistence.
Tracks devices first seen during meeting and behavior changes.
"""
from utils.database import start_meeting_window
from utils.tscm.advanced import get_timeline_manager
data = request.get_json() or {}
meeting_id = start_meeting_window(
sweep_id=_current_sweep_id,
name=data.get('name'),
location=data.get('location'),
notes=data.get('notes')
)
# Start meeting in correlation engine
correlation = get_correlation_engine()
correlation.start_meeting_window()
# Start in timeline manager
manager = get_timeline_manager()
manager.start_meeting_window()
_emit_event('meeting_started', {
'meeting_id': meeting_id,
'timestamp': datetime.now().isoformat(),
'name': data.get('name'),
})
return jsonify({
'status': 'success',
'message': 'Tracked meeting window started',
'meeting_id': meeting_id
})
@tscm_bp.route('/meeting/<int:meeting_id>/end', methods=['POST'])
def end_tracked_meeting(meeting_id: int):
"""End a tracked meeting window."""
from utils.database import end_meeting_window
from utils.tscm.advanced import get_timeline_manager
success = end_meeting_window(meeting_id)
if not success:
return jsonify({'status': 'error', 'message': 'Meeting not found or already ended'}), 404
# End in correlation engine
correlation = get_correlation_engine()
correlation.end_meeting_window()
# End in timeline manager
manager = get_timeline_manager()
manager.end_meeting_window()
_emit_event('meeting_ended', {
'meeting_id': meeting_id,
'timestamp': datetime.now().isoformat()
})
return jsonify({
'status': 'success',
'message': 'Meeting window ended'
})
@tscm_bp.route('/meeting/<int:meeting_id>/summary')
def get_meeting_summary_endpoint(meeting_id: int):
"""Get detailed summary of device activity during a meeting."""
try:
from routes.tscm import _current_sweep_id
from utils.database import get_meeting_windows
from utils.tscm.advanced import generate_meeting_summary, get_timeline_manager
# Get meeting window
windows = get_meeting_windows(_current_sweep_id or 0)
meeting = None
for w in windows:
if w.get('id') == meeting_id:
meeting = w
break
if not meeting:
return jsonify({'status': 'error', 'message': 'Meeting not found'}), 404
# Get timelines and profiles
manager = get_timeline_manager()
timelines = manager.get_all_timelines()
correlation = get_correlation_engine()
profiles = [p.to_dict() for p in correlation.device_profiles.values()]
summary = generate_meeting_summary(meeting, timelines, profiles)
return jsonify({
'status': 'success',
'summary': summary.to_dict()
})
except Exception as e:
logger.error(f"Get meeting summary error: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500
@tscm_bp.route('/meeting/active')
def get_active_meeting():
"""Get currently active meeting window."""
from utils.database import get_active_meeting_window
meeting = get_active_meeting_window(_current_sweep_id)
return jsonify({
'status': 'success',
'meeting': meeting,
'is_active': meeting is not None
})
+185
View File
@@ -0,0 +1,185 @@
"""
TSCM Schedule Routes
Handles /schedules/* endpoints for automated sweep scheduling.
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import Any
from flask import jsonify, request
from routes.tscm import (
_get_schedule_timezone,
_next_run_from_cron,
_start_sweep_internal,
tscm_bp,
)
from utils.database import (
create_tscm_schedule,
delete_tscm_schedule,
get_all_tscm_schedules,
get_tscm_schedule,
update_tscm_schedule,
)
logger = logging.getLogger('intercept.tscm')
@tscm_bp.route('/schedules', methods=['GET'])
def list_schedules():
"""List all TSCM sweep schedules."""
enabled_param = request.args.get('enabled')
enabled = None
if enabled_param is not None:
enabled = enabled_param.lower() in ('1', 'true', 'yes')
schedules = get_all_tscm_schedules(enabled=enabled, limit=200)
return jsonify({
'status': 'success',
'count': len(schedules),
'schedules': schedules,
})
@tscm_bp.route('/schedules', methods=['POST'])
def create_schedule():
"""Create a new sweep schedule."""
data = request.get_json() or {}
name = (data.get('name') or '').strip()
cron_expression = (data.get('cron_expression') or '').strip()
sweep_type = data.get('sweep_type', 'standard')
baseline_id = data.get('baseline_id')
zone_name = data.get('zone_name')
enabled = bool(data.get('enabled', True))
notify_on_threat = bool(data.get('notify_on_threat', True))
notify_email = data.get('notify_email')
if not name:
return jsonify({'status': 'error', 'message': 'Schedule name required'}), 400
if not cron_expression:
return jsonify({'status': 'error', 'message': 'cron_expression required'}), 400
next_run = None
if enabled:
try:
tz = _get_schedule_timezone(zone_name)
next_local = _next_run_from_cron(cron_expression, datetime.now(tz))
next_run = next_local.astimezone(timezone.utc).isoformat() if next_local else None
except Exception as e:
return jsonify({'status': 'error', 'message': f'Invalid cron: {e}'}), 400
schedule_id = create_tscm_schedule(
name=name,
cron_expression=cron_expression,
sweep_type=sweep_type,
baseline_id=baseline_id,
zone_name=zone_name,
enabled=enabled,
notify_on_threat=notify_on_threat,
notify_email=notify_email,
next_run=next_run,
)
schedule = get_tscm_schedule(schedule_id)
return jsonify({
'status': 'success',
'message': 'Schedule created',
'schedule': schedule
})
@tscm_bp.route('/schedules/<int:schedule_id>', methods=['PUT', 'PATCH'])
def update_schedule(schedule_id: int):
"""Update a sweep schedule."""
schedule = get_tscm_schedule(schedule_id)
if not schedule:
return jsonify({'status': 'error', 'message': 'Schedule not found'}), 404
data = request.get_json() or {}
updates: dict[str, Any] = {}
for key in ('name', 'cron_expression', 'sweep_type', 'baseline_id', 'zone_name', 'notify_email'):
if key in data:
updates[key] = data[key]
if 'baseline_id' in updates and updates['baseline_id'] in ('', None):
updates['baseline_id'] = None
if 'enabled' in data:
updates['enabled'] = 1 if data['enabled'] else 0
if 'notify_on_threat' in data:
updates['notify_on_threat'] = 1 if data['notify_on_threat'] else 0
# Recalculate next_run when cron/zone/enabled changes
if any(k in updates for k in ('cron_expression', 'zone_name', 'enabled')):
if updates.get('enabled', schedule.get('enabled', 1)):
cron_expr = updates.get('cron_expression', schedule.get('cron_expression', ''))
zone_name = updates.get('zone_name', schedule.get('zone_name'))
try:
tz = _get_schedule_timezone(zone_name)
next_local = _next_run_from_cron(cron_expr, datetime.now(tz))
updates['next_run'] = next_local.astimezone(timezone.utc).isoformat() if next_local else None
except Exception as e:
return jsonify({'status': 'error', 'message': f'Invalid cron: {e}'}), 400
else:
updates['next_run'] = None
if not updates:
return jsonify({'status': 'error', 'message': 'No updates provided'}), 400
update_tscm_schedule(schedule_id, **updates)
schedule = get_tscm_schedule(schedule_id)
return jsonify({'status': 'success', 'schedule': schedule})
@tscm_bp.route('/schedules/<int:schedule_id>', methods=['DELETE'])
def delete_schedule(schedule_id: int):
"""Delete a sweep schedule."""
success = delete_tscm_schedule(schedule_id)
if not success:
return jsonify({'status': 'error', 'message': 'Schedule not found'}), 404
return jsonify({'status': 'success', 'message': 'Schedule deleted'})
@tscm_bp.route('/schedules/<int:schedule_id>/run', methods=['POST'])
def run_schedule_now(schedule_id: int):
"""Trigger a scheduled sweep immediately."""
schedule = get_tscm_schedule(schedule_id)
if not schedule:
return jsonify({'status': 'error', 'message': 'Schedule not found'}), 404
result = _start_sweep_internal(
sweep_type=schedule.get('sweep_type') or 'standard',
baseline_id=schedule.get('baseline_id'),
wifi_enabled=True,
bt_enabled=True,
rf_enabled=True,
wifi_interface='',
bt_interface='',
sdr_device=None,
verbose_results=False,
)
if result.get('status') != 'success':
status_code = result.pop('http_status', 400)
return jsonify(result), status_code
# Update schedule run timestamps
cron_expr = schedule.get('cron_expression') or ''
tz = _get_schedule_timezone(schedule.get('zone_name'))
now_utc = datetime.now(timezone.utc)
try:
next_local = _next_run_from_cron(cron_expr, datetime.now(tz))
except Exception:
next_local = None
update_tscm_schedule(
schedule_id,
last_run=now_utc.isoformat(),
next_run=next_local.astimezone(timezone.utc).isoformat() if next_local else None,
)
return jsonify(result)
+426
View File
@@ -0,0 +1,426 @@
"""
TSCM Sweep Routes
Handles /sweep/*, /status, /devices, /presets/*, /feed/*,
/capabilities, and /sweep/<id>/capabilities endpoints.
"""
from __future__ import annotations
import logging
import os
import platform
import re
import subprocess
from typing import Any
from flask import Response, jsonify, request
from data.tscm_frequencies import get_all_sweep_presets, get_sweep_preset
from routes.tscm import (
_baseline_recorder,
_current_sweep_id,
_emit_event,
_start_sweep_internal,
_sweep_running,
tscm_bp,
tscm_queue,
)
from utils.database import get_tscm_sweep, update_tscm_sweep
from utils.event_pipeline import process_event
from utils.sse import sse_stream_fanout
logger = logging.getLogger('intercept.tscm')
@tscm_bp.route('/status')
def tscm_status():
"""Check if any TSCM operation is currently running."""
return jsonify({'running': _sweep_running})
@tscm_bp.route('/sweep/start', methods=['POST'])
def start_sweep():
"""Start a TSCM sweep."""
data = request.get_json() or {}
sweep_type = data.get('sweep_type', 'standard')
baseline_id = data.get('baseline_id')
if baseline_id in ('', None):
baseline_id = None
wifi_enabled = data.get('wifi', True)
bt_enabled = data.get('bluetooth', True)
rf_enabled = data.get('rf', True)
verbose_results = bool(data.get('verbose_results', False))
# Get interface selections
wifi_interface = data.get('wifi_interface', '')
bt_interface = data.get('bt_interface', '')
sdr_device = data.get('sdr_device')
result = _start_sweep_internal(
sweep_type=sweep_type,
baseline_id=baseline_id,
wifi_enabled=wifi_enabled,
bt_enabled=bt_enabled,
rf_enabled=rf_enabled,
wifi_interface=wifi_interface,
bt_interface=bt_interface,
sdr_device=sdr_device,
verbose_results=verbose_results,
)
http_status = result.pop('http_status', 200)
return jsonify(result), http_status
@tscm_bp.route('/sweep/stop', methods=['POST'])
def stop_sweep():
"""Stop the current TSCM sweep."""
import routes.tscm as _tscm_pkg
if not _tscm_pkg._sweep_running:
return jsonify({'status': 'error', 'message': 'No sweep running'})
_tscm_pkg._sweep_running = False
if _tscm_pkg._current_sweep_id:
update_tscm_sweep(_tscm_pkg._current_sweep_id, status='aborted', completed=True)
_emit_event('sweep_stopped', {'reason': 'user_requested'})
logger.info("TSCM sweep stopped by user")
return jsonify({'status': 'success', 'message': 'Sweep stopped'})
@tscm_bp.route('/sweep/status')
def sweep_status():
"""Get current sweep status."""
status = {
'running': _sweep_running,
'sweep_id': _current_sweep_id,
}
if _current_sweep_id:
sweep = get_tscm_sweep(_current_sweep_id)
if sweep:
status['sweep'] = sweep
return jsonify(status)
@tscm_bp.route('/sweep/stream')
def sweep_stream():
"""SSE stream for real-time sweep updates."""
def _on_msg(msg: dict[str, Any]) -> None:
process_event('tscm', msg, msg.get('type'))
return Response(
sse_stream_fanout(
source_queue=tscm_queue,
channel_key='tscm',
timeout=1.0,
keepalive_interval=30.0,
on_message=_on_msg,
),
mimetype='text/event-stream',
headers={
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no'
}
)
@tscm_bp.route('/devices')
def get_tscm_devices():
"""Get available scanning devices for TSCM sweeps."""
devices = {
'wifi_interfaces': [],
'bt_adapters': [],
'sdr_devices': []
}
# Detect WiFi interfaces
if platform.system() == 'Darwin': # macOS
try:
result = subprocess.run(
['networksetup', '-listallhardwareports'],
capture_output=True, text=True, timeout=5
)
lines = result.stdout.split('\n')
for i, line in enumerate(lines):
if 'Wi-Fi' in line or 'AirPort' in line:
# Get the hardware port name (e.g., "Wi-Fi")
port_name = line.replace('Hardware Port:', '').strip()
for j in range(i + 1, min(i + 3, len(lines))):
if 'Device:' in lines[j]:
device = lines[j].split('Device:')[1].strip()
devices['wifi_interfaces'].append({
'name': device,
'display_name': f'{port_name} ({device})',
'type': 'internal',
'monitor_capable': False
})
break
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
pass
else: # Linux
try:
result = subprocess.run(
['iw', 'dev'],
capture_output=True, text=True, timeout=5
)
current_iface = None
for line in result.stdout.split('\n'):
line = line.strip()
if line.startswith('Interface'):
current_iface = line.split()[1]
elif current_iface and 'type' in line:
iface_type = line.split()[-1]
devices['wifi_interfaces'].append({
'name': current_iface,
'display_name': f'Wireless ({current_iface}) - {iface_type}',
'type': iface_type,
'monitor_capable': True
})
current_iface = None
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
# Fall back to iwconfig
try:
result = subprocess.run(
['iwconfig'],
capture_output=True, text=True, timeout=5
)
for line in result.stdout.split('\n'):
if 'IEEE 802.11' in line:
iface = line.split()[0]
devices['wifi_interfaces'].append({
'name': iface,
'display_name': f'Wireless ({iface})',
'type': 'managed',
'monitor_capable': True
})
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
pass
# Detect Bluetooth adapters
if platform.system() == 'Linux':
try:
result = subprocess.run(
['hciconfig'],
capture_output=True, text=True, timeout=5
)
blocks = re.split(r'(?=^hci\d+:)', result.stdout, flags=re.MULTILINE)
for _idx, block in enumerate(blocks):
if block.strip():
first_line = block.split('\n')[0]
match = re.match(r'(hci\d+):', first_line)
if match:
iface_name = match.group(1)
is_up = 'UP RUNNING' in block or '\tUP ' in block
devices['bt_adapters'].append({
'name': iface_name,
'display_name': f'Bluetooth Adapter ({iface_name})',
'type': 'hci',
'status': 'up' if is_up else 'down'
})
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
# Try bluetoothctl as fallback
try:
result = subprocess.run(
['bluetoothctl', 'list'],
capture_output=True, text=True, timeout=5
)
for line in result.stdout.split('\n'):
if 'Controller' in line:
# Format: Controller XX:XX:XX:XX:XX:XX Name
parts = line.split()
if len(parts) >= 3:
addr = parts[1]
name = ' '.join(parts[2:]) if len(parts) > 2 else 'Bluetooth'
devices['bt_adapters'].append({
'name': addr,
'display_name': f'{name} ({addr[-8:]})',
'type': 'controller',
'status': 'available'
})
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
pass
elif platform.system() == 'Darwin':
# macOS has built-in Bluetooth - get more info via system_profiler
try:
result = subprocess.run(
['system_profiler', 'SPBluetoothDataType'],
capture_output=True, text=True, timeout=10
)
# Extract controller info
bt_name = 'Built-in Bluetooth'
bt_addr = ''
for line in result.stdout.split('\n'):
if 'Address:' in line:
bt_addr = line.split('Address:')[1].strip()
break
devices['bt_adapters'].append({
'name': 'default',
'display_name': f'{bt_name}' + (f' ({bt_addr[-8:]})' if bt_addr else ''),
'type': 'macos',
'status': 'available'
})
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
devices['bt_adapters'].append({
'name': 'default',
'display_name': 'Built-in Bluetooth',
'type': 'macos',
'status': 'available'
})
# Detect SDR devices
try:
from utils.sdr import SDRFactory
sdr_list = SDRFactory.detect_devices()
for sdr in sdr_list:
# SDRDevice is a dataclass with attributes, not a dict
sdr_type_name = sdr.sdr_type.value if hasattr(sdr.sdr_type, 'value') else str(sdr.sdr_type)
# Create a friendly display name
display_name = sdr.name
if sdr.serial and sdr.serial not in ('N/A', 'Unknown'):
display_name = f'{sdr.name} (SN: {sdr.serial[-8:]})'
devices['sdr_devices'].append({
'index': sdr.index,
'name': sdr.name,
'display_name': display_name,
'type': sdr_type_name,
'serial': sdr.serial,
'driver': sdr.driver
})
except ImportError:
logger.debug("SDR module not available")
except Exception as e:
logger.warning(f"Error detecting SDR devices: {e}")
# Check if running as root
from flask import current_app
running_as_root = current_app.config.get('RUNNING_AS_ROOT', os.geteuid() == 0)
warnings = []
if not running_as_root:
warnings.append({
'type': 'privileges',
'message': 'Not running as root. WiFi monitor mode and some Bluetooth features require sudo.',
'action': 'Run with: sudo -E venv/bin/python intercept.py'
})
return jsonify({
'status': 'success',
'devices': devices,
'running_as_root': running_as_root,
'warnings': warnings
})
# =============================================================================
# Preset Endpoints
# =============================================================================
@tscm_bp.route('/presets')
def list_presets():
"""List available sweep presets."""
presets = get_all_sweep_presets()
return jsonify({'status': 'success', 'presets': presets})
@tscm_bp.route('/presets/<preset_name>')
def get_preset(preset_name: str):
"""Get details for a specific preset."""
preset = get_sweep_preset(preset_name)
if not preset:
return jsonify({'status': 'error', 'message': 'Preset not found'}), 404
return jsonify({'status': 'success', 'preset': preset})
# =============================================================================
# Data Feed Endpoints (for adding data during sweeps/baselines)
# =============================================================================
@tscm_bp.route('/feed/wifi', methods=['POST'])
def feed_wifi():
"""Feed WiFi device data for baseline recording."""
data = request.get_json()
if data:
if data.get('is_client'):
_baseline_recorder.add_wifi_client(data)
else:
_baseline_recorder.add_wifi_device(data)
return jsonify({'status': 'success'})
@tscm_bp.route('/feed/bluetooth', methods=['POST'])
def feed_bluetooth():
"""Feed Bluetooth device data for baseline recording."""
data = request.get_json()
if data:
_baseline_recorder.add_bt_device(data)
return jsonify({'status': 'success'})
@tscm_bp.route('/feed/rf', methods=['POST'])
def feed_rf():
"""Feed RF signal data for baseline recording."""
data = request.get_json()
if data:
_baseline_recorder.add_rf_signal(data)
return jsonify({'status': 'success'})
# =============================================================================
# Capabilities & Coverage Endpoints
# =============================================================================
@tscm_bp.route('/capabilities')
def get_capabilities():
"""
Get current system capabilities for TSCM sweeping.
Returns what the system CAN and CANNOT detect based on OS,
privileges, adapters, and SDR hardware.
"""
try:
from utils.tscm.advanced import detect_sweep_capabilities
wifi_interface = request.args.get('wifi_interface', '')
bt_adapter = request.args.get('bt_adapter', '')
caps = detect_sweep_capabilities(
wifi_interface=wifi_interface,
bt_adapter=bt_adapter
)
return jsonify({
'status': 'success',
'capabilities': caps.to_dict()
})
except Exception as e:
logger.error(f"Get capabilities error: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500
@tscm_bp.route('/sweep/<int:sweep_id>/capabilities')
def get_sweep_stored_capabilities(sweep_id: int):
"""Get stored capabilities for a specific sweep."""
from utils.database import get_sweep_capabilities
caps = get_sweep_capabilities(sweep_id)
if not caps:
return jsonify({'status': 'error', 'message': 'No capabilities stored for this sweep'}), 404
return jsonify({
'status': 'success',
'capabilities': caps
})
+6 -20
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
from flask import Blueprint, Response, jsonify, request
from utils.logging import get_logger
from utils.responses import api_error
from utils.updater import (
check_for_updates,
dismiss_update,
@@ -39,10 +40,7 @@ def check_updates() -> Response:
return jsonify(result)
except Exception as e:
logger.error(f"Error checking for updates: {e}")
return jsonify({
'success': False,
'error': str(e)
}), 500
return api_error(str(e), 500)
@updater_bp.route('/status', methods=['GET'])
@@ -61,10 +59,7 @@ def update_status() -> Response:
return jsonify(result)
except Exception as e:
logger.error(f"Error getting update status: {e}")
return jsonify({
'success': False,
'error': str(e)
}), 500
return api_error(str(e), 500)
@updater_bp.route('/update', methods=['POST'])
@@ -100,10 +95,7 @@ def do_update() -> Response:
except Exception as e:
logger.error(f"Error performing update: {e}")
return jsonify({
'success': False,
'error': str(e)
}), 500
return api_error(str(e), 500)
@updater_bp.route('/dismiss', methods=['POST'])
@@ -124,20 +116,14 @@ def dismiss_notification() -> Response:
version = data.get('version')
if not version:
return jsonify({
'success': False,
'error': 'Version is required'
}), 400
return api_error('Version is required', 400)
try:
result = dismiss_update(version)
return jsonify(result)
except Exception as e:
logger.error(f"Error dismissing update: {e}")
return jsonify({
'success': False,
'error': str(e)
}), 500
return api_error(str(e), 500)
@updater_bp.route('/restart', methods=['POST'])
+411
View File
@@ -0,0 +1,411 @@
"""VDL2 aircraft datalink routes."""
from __future__ import annotations
import contextlib
import json
import os
import platform
import pty
import queue
import shutil
import subprocess
import threading
import time
from datetime import datetime
from typing import Any
from flask import Blueprint, Response, jsonify, request
import app as app_module
from utils.acars_translator import translate_message
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.flight_correlator import get_flight_correlator
from utils.logging import sensor_logger as logger
from utils.process import register_process, unregister_process
from utils.responses import api_error
from utils.sdr import SDRFactory, SDRType
from utils.sse import sse_stream_fanout
from utils.validation import validate_device_index, validate_gain, validate_ppm
vdl2_bp = Blueprint('vdl2', __name__, url_prefix='/vdl2')
# Default VDL2 frequencies (MHz) - common worldwide
DEFAULT_VDL2_FREQUENCIES = [
'136975000', # Primary worldwide
'136725000', # Europe
'136775000', # Europe
'136800000', # Multi-region
'136875000', # Multi-region
]
# Message counter for statistics
vdl2_message_count = 0
vdl2_last_message_time = None
# Track which device is being used
vdl2_active_device: int | None = None
vdl2_active_sdr_type: str | None = None
def find_dumpvdl2():
"""Find dumpvdl2 binary."""
return shutil.which('dumpvdl2')
def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) -> None:
"""Stream dumpvdl2 JSON output to queue."""
global vdl2_message_count, vdl2_last_message_time
try:
app_module.vdl2_queue.put({'type': 'status', 'status': 'started'})
# Use appropriate sentinel based on mode (text mode for pty on macOS)
sentinel = '' if is_text_mode else b''
for line in iter(process.stdout.readline, sentinel):
if is_text_mode:
line = line.strip()
else:
line = line.decode('utf-8', errors='replace').strip()
if not line:
continue
try:
data = json.loads(line)
# Add our metadata
data['type'] = 'vdl2'
data['timestamp'] = datetime.utcnow().isoformat() + 'Z'
# Enrich with translated ACARS label at top level (consistent with ACARS route)
try:
vdl2_inner = data.get('vdl2', data)
acars_payload = (vdl2_inner.get('avlc') or {}).get('acars')
if acars_payload and acars_payload.get('label'):
translation = translate_message({
'label': acars_payload.get('label'),
'text': acars_payload.get('msg_text', ''),
})
data['label_description'] = translation['label_description']
data['message_type'] = translation['message_type']
data['parsed'] = translation['parsed']
except Exception:
pass
# Update stats
vdl2_message_count += 1
vdl2_last_message_time = time.time()
app_module.vdl2_queue.put(data)
# Feed flight correlator
with contextlib.suppress(Exception):
get_flight_correlator().add_vdl2_message(data)
# Log if enabled
if app_module.logging_enabled:
try:
with open(app_module.log_file_path, 'a') as f:
ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
f.write(f"{ts} | VDL2 | {json.dumps(data)}\n")
except Exception:
pass
except json.JSONDecodeError:
# Not JSON - could be status message
if line:
logger.debug(f"dumpvdl2 non-JSON: {line[:100]}")
except Exception as e:
logger.error(f"VDL2 stream error: {e}")
app_module.vdl2_queue.put({'type': 'error', 'message': str(e)})
finally:
global vdl2_active_device, vdl2_active_sdr_type
# Ensure process is terminated
try:
process.terminate()
process.wait(timeout=2)
except Exception:
with contextlib.suppress(Exception):
process.kill()
unregister_process(process)
app_module.vdl2_queue.put({'type': 'status', 'status': 'stopped'})
with app_module.vdl2_lock:
app_module.vdl2_process = None
# Release SDR device
if vdl2_active_device is not None:
app_module.release_sdr_device(vdl2_active_device, vdl2_active_sdr_type or 'rtlsdr')
vdl2_active_device = None
vdl2_active_sdr_type = None
@vdl2_bp.route('/tools')
def check_vdl2_tools() -> Response:
"""Check for VDL2 decoding tools."""
has_dumpvdl2 = find_dumpvdl2() is not None
return jsonify({
'dumpvdl2': has_dumpvdl2,
'ready': has_dumpvdl2
})
@vdl2_bp.route('/status')
def vdl2_status() -> Response:
"""Get VDL2 decoder status."""
running = False
if app_module.vdl2_process:
running = app_module.vdl2_process.poll() is None
return jsonify({
'running': running,
'message_count': vdl2_message_count,
'last_message_time': vdl2_last_message_time,
'queue_size': app_module.vdl2_queue.qsize()
})
@vdl2_bp.route('/start', methods=['POST'])
def start_vdl2() -> Response:
"""Start VDL2 decoder."""
global vdl2_message_count, vdl2_last_message_time, vdl2_active_device, vdl2_active_sdr_type
with app_module.vdl2_lock:
if app_module.vdl2_process and app_module.vdl2_process.poll() is None:
return api_error('VDL2 decoder already running', 409)
# Check for dumpvdl2
dumpvdl2_path = find_dumpvdl2()
if not dumpvdl2_path:
return api_error('dumpvdl2 not found. Install from: https://github.com/szpajder/dumpvdl2', 400)
data = request.json or {}
# Validate inputs
try:
device = validate_device_index(data.get('device', '0'))
gain = validate_gain(data.get('gain', '40'))
ppm = validate_ppm(data.get('ppm', '0'))
except ValueError as e:
return api_error(str(e), 400)
# Resolve SDR type for device selection
sdr_type_str = data.get('sdr_type', 'rtlsdr')
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
# Check if device is available
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'vdl2', sdr_type_str)
if error:
return api_error(error, 409, error_type='DEVICE_BUSY')
vdl2_active_device = device_int
vdl2_active_sdr_type = sdr_type_str
# Get frequencies - use provided or defaults
# dumpvdl2 expects frequencies in Hz (integers)
frequencies = data.get('frequencies', DEFAULT_VDL2_FREQUENCIES)
if isinstance(frequencies, str):
frequencies = [f.strip() for f in frequencies.split(',')]
# Clear queue
while not app_module.vdl2_queue.empty():
try:
app_module.vdl2_queue.get_nowait()
except queue.Empty:
break
# Reset stats
vdl2_message_count = 0
vdl2_last_message_time = None
is_soapy = sdr_type not in (SDRType.RTL_SDR,)
# Build dumpvdl2 command
# dumpvdl2 --output decoded:json --rtlsdr <device> --gain <gain> --correction <ppm> <freq1> <freq2> ...
cmd = [dumpvdl2_path]
cmd.extend(['--output', 'decoded:json:file:path=-'])
if is_soapy:
# SoapySDR device
sdr_device = SDRFactory.create_default_device(sdr_type, index=device_int)
builder = SDRFactory.get_builder(sdr_type)
device_str = builder._build_device_string(sdr_device)
cmd.extend(['--soapysdr', device_str])
else:
cmd.extend(['--rtlsdr', str(device)])
# Add gain
if gain and str(gain) != '0':
cmd.extend(['--gain', str(gain)])
# Add PPM correction if specified
if ppm and str(ppm) != '0':
cmd.extend(['--correction', str(ppm)])
# Add frequencies (dumpvdl2 takes them as positional args in Hz)
cmd.extend(frequencies)
logger.info(f"Starting VDL2 decoder: {' '.join(cmd)}")
try:
is_text_mode = False
# On macOS, use pty to avoid stdout buffering issues
if platform.system() == 'Darwin':
master_fd, slave_fd = pty.openpty()
process = subprocess.Popen(
cmd,
stdout=slave_fd,
stderr=subprocess.PIPE,
start_new_session=True
)
os.close(slave_fd)
# Wrap master_fd as a text file for line-buffered reading
process.stdout = open(master_fd, buffering=1)
is_text_mode = True
else:
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
start_new_session=True
)
# Wait briefly to check if process started
time.sleep(PROCESS_START_WAIT)
if process.poll() is not None:
# Process died - release device
if vdl2_active_device is not None:
app_module.release_sdr_device(vdl2_active_device, vdl2_active_sdr_type or 'rtlsdr')
vdl2_active_device = None
vdl2_active_sdr_type = None
stderr = ''
if process.stderr:
stderr = process.stderr.read().decode('utf-8', errors='replace')
if stderr:
logger.error(f"dumpvdl2 stderr:\n{stderr}")
error_msg = 'dumpvdl2 failed to start'
if stderr:
error_msg += f': {stderr[:500]}'
logger.error(error_msg)
return api_error(error_msg, 500)
app_module.vdl2_process = process
register_process(process)
# Start output streaming thread
thread = threading.Thread(
target=stream_vdl2_output,
args=(process, is_text_mode),
daemon=True
)
thread.start()
return jsonify({
'status': 'started',
'frequencies': frequencies,
'device': device,
'gain': gain
})
except Exception as e:
# Release device on failure
if vdl2_active_device is not None:
app_module.release_sdr_device(vdl2_active_device, vdl2_active_sdr_type or 'rtlsdr')
vdl2_active_device = None
vdl2_active_sdr_type = None
logger.error(f"Failed to start VDL2 decoder: {e}")
return api_error(str(e), 500)
@vdl2_bp.route('/stop', methods=['POST'])
def stop_vdl2() -> Response:
"""Stop VDL2 decoder."""
global vdl2_active_device, vdl2_active_sdr_type
with app_module.vdl2_lock:
if not app_module.vdl2_process:
return api_error('VDL2 decoder not running', 400)
try:
app_module.vdl2_process.terminate()
app_module.vdl2_process.wait(timeout=PROCESS_TERMINATE_TIMEOUT)
except subprocess.TimeoutExpired:
app_module.vdl2_process.kill()
except Exception as e:
logger.error(f"Error stopping VDL2: {e}")
app_module.vdl2_process = None
# Release device from registry
if vdl2_active_device is not None:
app_module.release_sdr_device(vdl2_active_device, vdl2_active_sdr_type or 'rtlsdr')
vdl2_active_device = None
vdl2_active_sdr_type = None
return jsonify({'status': 'stopped'})
@vdl2_bp.route('/stream')
def stream_vdl2() -> Response:
"""SSE stream for VDL2 messages."""
def _on_msg(msg: dict[str, Any]) -> None:
process_event('vdl2', msg, msg.get('type'))
response = Response(
sse_stream_fanout(
source_queue=app_module.vdl2_queue,
channel_key='vdl2',
timeout=SSE_QUEUE_TIMEOUT,
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
on_message=_on_msg,
),
mimetype='text/event-stream',
)
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
return response
@vdl2_bp.route('/messages')
def get_vdl2_messages() -> Response:
"""Get recent VDL2 messages from correlator (for history reload)."""
limit = request.args.get('limit', 50, type=int)
limit = max(1, min(limit, 200))
msgs = get_flight_correlator().get_recent_messages('vdl2', limit)
return jsonify(msgs)
@vdl2_bp.route('/clear', methods=['POST'])
def clear_vdl2_messages() -> Response:
"""Clear stored VDL2 messages and reset counter."""
global vdl2_message_count, vdl2_last_message_time
get_flight_correlator().clear_vdl2()
vdl2_message_count = 0
vdl2_last_message_time = None
return jsonify({'status': 'cleared'})
@vdl2_bp.route('/frequencies')
def get_frequencies() -> Response:
"""Get default VDL2 frequencies."""
return jsonify({
'default': DEFAULT_VDL2_FREQUENCIES,
'regions': {
'north_america': ['136975000', '136100000', '136650000', '136700000', '136800000'],
'europe': ['136975000', '136675000', '136725000', '136775000', '136825000'],
'asia_pacific': ['136975000', '136900000'],
}
})
File diff suppressed because it is too large Load Diff
+104 -51
View File
@@ -8,16 +8,26 @@ from __future__ import annotations
import queue
from flask import Blueprint, jsonify, request, Response, send_file
from flask import Blueprint, Response, jsonify, request, send_file
from utils.logging import get_logger
from utils.responses import api_error
from utils.sse import sse_stream
from utils.validation import validate_device_index, validate_gain, validate_latitude, validate_longitude, validate_elevation
from utils.validation import (
validate_device_index,
validate_elevation,
validate_gain,
validate_latitude,
validate_longitude,
validate_rtl_tcp_host,
validate_rtl_tcp_port,
)
from utils.weather_sat import (
DEFAULT_SAMPLE_RATE,
WEATHER_SATELLITES,
CaptureProgress,
get_weather_sat_decoder,
is_weather_sat_available,
CaptureProgress,
WEATHER_SATELLITES,
)
logger = get_logger('intercept.weather_sat')
@@ -40,6 +50,35 @@ def _progress_callback(progress: CaptureProgress) -> None:
pass
def _release_weather_sat_device(device_index: int) -> None:
"""Release an SDR device only if weather-sat currently owns it."""
if device_index < 0:
return
try:
import app as app_module
except ImportError:
return
owner = None
get_status = getattr(app_module, 'get_sdr_device_status', None)
if callable(get_status):
try:
owner = get_status().get(device_index)
except Exception:
owner = None
if owner and owner != 'weather_sat':
logger.debug(
'Skipping SDR release for device %s owned by %s',
device_index,
owner,
)
return
app_module.release_sdr_device(device_index)
@weather_sat_bp.route('/status')
def get_status():
"""Get weather satellite decoder status.
@@ -106,6 +145,13 @@ def start_capture():
})
data = request.get_json(silent=True) or {}
sdr_type_str = data.get('sdr_type', 'rtlsdr')
if sdr_type_str != 'rtlsdr':
return jsonify({
'status': 'error',
'message': f'{sdr_type_str.replace("_", " ").title()} is not yet supported for this mode. Please use an RTL-SDR device.'
}), 400
# Validate satellite
satellite = data.get('satellite')
@@ -128,18 +174,26 @@ def start_capture():
bias_t = bool(data.get('bias_t', False))
# Claim SDR device
try:
import app as app_module
error = app_module.claim_sdr_device(device_index, 'weather_sat')
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error,
}), 409
except ImportError:
pass
# Check for rtl_tcp (remote SDR) connection
rtl_tcp_host = data.get('rtl_tcp_host')
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
if rtl_tcp_host:
try:
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
except ValueError as e:
return api_error(str(e), 400)
# Claim SDR device (skip for remote rtl_tcp)
if not rtl_tcp_host:
try:
import app as app_module
error = app_module.claim_sdr_device(device_index, 'weather_sat', sdr_type_str)
if error:
return api_error(error, 409, error_type='DEVICE_BUSY')
except ImportError:
pass
# Clear queue
while not _weather_sat_queue.empty():
@@ -152,19 +206,19 @@ def start_capture():
decoder.set_callback(_progress_callback)
def _release_device():
try:
import app as app_module
app_module.release_sdr_device(device_index)
except ImportError:
pass
if not rtl_tcp_host:
_release_weather_sat_device(device_index)
decoder.set_on_complete(_release_device)
success = decoder.start(
success, error_msg = decoder.start(
satellite=satellite,
device_index=device_index,
gain=gain,
sample_rate=DEFAULT_SAMPLE_RATE,
bias_t=bias_t,
rtl_tcp_host=rtl_tcp_host,
rtl_tcp_port=rtl_tcp_port,
)
if success:
@@ -181,7 +235,7 @@ def start_capture():
_release_device()
return jsonify({
'status': 'error',
'message': 'Failed to start capture'
'message': error_msg or 'Failed to start capture'
}), 500
@@ -283,7 +337,7 @@ def test_decode():
decoder.set_callback(_progress_callback)
decoder.set_on_complete(None)
success = decoder.start_from_file(
success, error_msg = decoder.start_from_file(
satellite=satellite,
input_file=input_file,
sample_rate=sample_rate,
@@ -302,7 +356,7 @@ def test_decode():
else:
return jsonify({
'status': 'error',
'message': 'Failed to start file decode'
'message': error_msg or 'Failed to start file decode'
}), 500
@@ -318,12 +372,7 @@ def stop_capture():
decoder.stop()
# Release SDR device
try:
import app as app_module
app_module.release_sdr_device(device_index)
except ImportError:
pass
_release_weather_sat_device(device_index)
return jsonify({'status': 'stopped'})
@@ -373,15 +422,15 @@ def get_image(filename: str):
# Security: only allow safe filenames
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
return api_error('Invalid filename', 400)
if not (filename.endswith('.png') or filename.endswith('.jpg') or filename.endswith('.jpeg')):
return jsonify({'status': 'error', 'message': 'Only PNG/JPG files supported'}), 400
return api_error('Only PNG/JPG files supported', 400)
image_path = decoder._output_dir / filename
if not image_path.exists():
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
return api_error('Image not found', 404)
mimetype = 'image/png' if filename.endswith('.png') else 'image/jpeg'
return send_file(image_path, mimetype=mimetype)
@@ -400,12 +449,12 @@ def delete_image(filename: str):
decoder = get_weather_sat_decoder()
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
return api_error('Invalid filename', 400)
if decoder.delete_image(filename):
return jsonify({'status': 'deleted', 'filename': filename})
else:
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
return api_error('Image not found', 404)
@weather_sat_bp.route('/images', methods=['DELETE'])
@@ -456,17 +505,14 @@ def get_passes():
raw_lon = request.args.get('longitude')
if raw_lat is None or raw_lon is None:
return jsonify({
'status': 'error',
'message': 'latitude and longitude parameters required'
}), 400
return api_error('latitude and longitude parameters required', 400)
try:
lat = validate_latitude(raw_lat)
lon = validate_longitude(raw_lon)
except ValueError as e:
logger.warning('Invalid coordinates in get_passes: %s', e)
return jsonify({'status': 'error', 'message': 'Invalid coordinates'}), 400
return api_error('Invalid coordinates', 400)
hours = max(1, min(request.args.get('hours', 24, type=int), 72))
min_elevation = max(0, min(request.args.get('min_elevation', 15, type=float), 90))
@@ -566,14 +612,21 @@ def enable_schedule():
scheduler = get_weather_sat_scheduler()
scheduler.set_callbacks(_progress_callback, _scheduler_event_callback)
result = scheduler.enable(
lat=lat,
lon=lon,
min_elevation=min_elev,
device=device,
gain=gain_val,
bias_t=bool(data.get('bias_t', False)),
)
try:
result = scheduler.enable(
lat=lat,
lon=lon,
min_elevation=min_elev,
device=device,
gain=gain_val,
bias_t=bool(data.get('bias_t', False)),
)
except Exception:
logger.exception("Failed to enable weather sat scheduler")
return jsonify({
'status': 'error',
'message': 'Failed to enable scheduler'
}), 500
return jsonify({'status': 'ok', **result})
@@ -617,10 +670,10 @@ def skip_pass(pass_id: str):
from utils.weather_sat_scheduler import get_weather_sat_scheduler
if not pass_id.replace('_', '').replace('-', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid pass ID'}), 400
return api_error('Invalid pass ID', 400)
scheduler = get_weather_sat_scheduler()
if scheduler.skip_pass(pass_id):
return jsonify({'status': 'skipped', 'pass_id': pass_id})
else:
return jsonify({'status': 'error', 'message': 'Pass not found or already processed'}), 404
return api_error('Pass not found or already processed', 404)
+20 -30
View File
@@ -9,9 +9,10 @@ import re
import struct
import threading
import time
from typing import Optional
from flask import Blueprint, Flask, jsonify, request, Response
from flask import Blueprint, Flask, Response, jsonify, request
from utils.responses import api_error, api_success
try:
from flask_sock import Sock
@@ -19,7 +20,9 @@ try:
except ImportError:
WEBSOCKET_AVAILABLE = False
from utils.kiwisdr import KiwiSDRClient, KIWI_SAMPLE_RATE, VALID_MODES, parse_host_port
import contextlib
from utils.kiwisdr import KIWI_SAMPLE_RATE, VALID_MODES, KiwiSDRClient, parse_host_port
from utils.logging import get_logger
logger = get_logger('intercept.websdr')
@@ -36,7 +39,7 @@ _cache_timestamp: float = 0
CACHE_TTL = 3600 # 1 hour
def _parse_gps_coord(coord_str: str) -> Optional[float]:
def _parse_gps_coord(coord_str: str) -> float | None:
"""Parse a GPS coordinate string like '51.5074' or '(-33.87)' into a float."""
if not coord_str:
return None
@@ -68,8 +71,8 @@ KIWI_DATA_URLS = [
def _fetch_kiwi_receivers() -> list[dict]:
"""Fetch the KiwiSDR receiver list from the public directory."""
import urllib.request
import json
import urllib.request
receivers = []
raw = None
@@ -226,8 +229,7 @@ def list_receivers() -> Response:
if r.get('freq_lo', 0) <= freq_khz <= r.get('freq_hi', 30000)
]
return jsonify({
'status': 'success',
return api_success(data={
'receivers': filtered[:100],
'total': len(filtered),
'cached_total': len(receivers),
@@ -242,7 +244,7 @@ def nearest_receivers() -> Response:
freq_khz = request.args.get('freq_khz', type=float)
if lat is None or lon is None:
return jsonify({'status': 'error', 'message': 'lat and lon are required'}), 400
return api_error('lat and lon are required', 400)
receivers = get_receivers()
@@ -264,10 +266,7 @@ def nearest_receivers() -> Response:
with_distance.sort(key=lambda x: x['distance_km'])
return jsonify({
'status': 'success',
'receivers': with_distance[:10],
})
return api_success(data={'receivers': with_distance[:10]})
@websdr_bp.route('/spy-station/<station_id>/receivers')
@@ -276,7 +275,7 @@ def spy_station_receivers(station_id: str) -> Response:
try:
from routes.spy_stations import STATIONS
except ImportError:
return jsonify({'status': 'error', 'message': 'Spy stations module not available'}), 503
return api_error('Spy stations module not available', 503)
# Find the station
station = None
@@ -286,7 +285,7 @@ def spy_station_receivers(station_id: str) -> Response:
break
if not station:
return jsonify({'status': 'error', 'message': 'Station not found'}), 404
return api_error('Station not found', 404)
# Get primary frequency
freq_khz = None
@@ -298,7 +297,7 @@ def spy_station_receivers(station_id: str) -> Response:
freq_khz = station['frequencies'][0].get('freq_khz')
if freq_khz is None:
return jsonify({'status': 'error', 'message': 'No frequency found for station'}), 404
return api_error('No frequency found for station', 404)
receivers = get_receivers()
@@ -308,8 +307,7 @@ def spy_station_receivers(station_id: str) -> Response:
if r.get('freq_lo', 0) <= freq_khz <= r.get('freq_hi', 30000) and r.get('available', True)
]
return jsonify({
'status': 'success',
return api_success(data={
'station': {
'id': station['id'],
'name': station.get('name', ''),
@@ -338,7 +336,7 @@ def websdr_status() -> Response:
# KIWISDR AUDIO PROXY
# ============================================
_kiwi_client: Optional[KiwiSDRClient] = None
_kiwi_client: KiwiSDRClient | None = None
_kiwi_lock = threading.Lock()
_kiwi_audio_queue: queue.Queue = queue.Queue(maxsize=200)
@@ -390,26 +388,18 @@ def _handle_kiwi_command(ws, cmd: str, data: dict) -> None:
try:
_kiwi_audio_queue.put_nowait(header + pcm_bytes)
except queue.Full:
try:
with contextlib.suppress(queue.Empty):
_kiwi_audio_queue.get_nowait()
except queue.Empty:
pass
try:
with contextlib.suppress(queue.Full):
_kiwi_audio_queue.put_nowait(header + pcm_bytes)
except queue.Full:
pass
def on_error(msg):
try:
with contextlib.suppress(Exception):
ws.send(json.dumps({'type': 'error', 'message': msg}))
except Exception:
pass
def on_disconnect():
try:
with contextlib.suppress(Exception):
ws.send(json.dumps({'type': 'disconnected'}))
except Exception:
pass
with _kiwi_lock:
_kiwi_client = KiwiSDRClient(

Some files were not shown because too many files have changed in this diff Show More