Compare commits

...

1155 Commits

Author SHA1 Message Date
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
Smittix 99d52eafe7 chore: Bump version to v2.18.0
Bluetooth enhancements (service data inspector, appearance codes, MAC
cluster tracking, behavioral flags, IRK badges, distance estimation),
ACARS SoapySDR multi-backend support, dump1090 stale process cleanup,
GPS error state, and proximity radar/signal card UI improvements.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 15:12:10 +00:00
Smittix 2a73318457 chore: Bump version to v2.17.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 22:00:32 +00:00
Smittix d8d08a8b1e feat: Add BT Locate and GPS modes with IRK auto-detection
New modes:
- BT Locate: SAR Bluetooth device location with GPS-tagged signal trail,
  RSSI-based proximity bands, audio alerts, and IRK auto-extraction from
  paired devices (macOS plist / Linux BlueZ)
- GPS: Real-time position tracking with live map, speed, heading, altitude,
  satellite info, and track recording via gpsd

Bug fixes:
- Fix ABBA deadlock between session lock and aggregator lock in BT Locate
- Fix bleak scan lifecycle tracking in BluetoothScanner (is_scanning property
  now cross-checks backend state)
- Fix map tile persistence when switching modes
- Use 15s max_age window for fresh detections in BT Locate poll loop

Documentation:
- Update README, FEATURES.md, USAGE.md, and GitHub Pages with new modes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 21:59:45 +00:00
Smittix c60769f795 Revise README for title and license updates
Updated project title, license, and acknowledgments in README.
2026-02-15 17:39:53 +00:00
Smittix 01f8324292 chore: Change license from MIT to Apache 2.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 17:38:27 +00:00
Smittix c66988cc1c fix: Add progress indicator for SatDump compilation in setup.sh
SatDump is a large C++ project that can take 10-30 minutes to compile.
Previously all build output was sent to /dev/null, making it appear
hung. Now shows a progress message every 30 seconds, sets time
expectations upfront, and displays the build log on failure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 13:54:47 +00:00
Smittix fac3d4359b fix: Patch acarsdec source for macOS Apple Silicon builds (fixes #136)
The upstream acarsdec uses pthread_tryjoin_np (a Linux-only GNU
extension) and has broken libacars linking on macOS. The setup script
now patches both issues at build time, along with the existing compiler
flag fix for ARM64.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 13:44:39 +00:00
Smittix d6f10d29ca fix: Correct DSC decoder phasing sequence handling, MMSI and position decoding
Strip ITU-R M.493 phasing symbols (120-126) after dot pattern sync before
decoding message content. Fix MMSI BCD digit trimming direction and correct
test symbol encodings for position and MMSI edge cases.
2026-02-15 09:58:05 +00:00
Smittix 332735cecf fix: Persist tracked satellites to database (fixes #135)
Satellites added via CelesTrak import or TLE paste are now stored in
SQLite and survive page reloads and app restarts. Adds CRUD API
endpoints and wires frontend sidebar + dashboard to use them.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 20:15:21 +00:00
Smittix b04e335f49 docs: Remove DMR references while feature is temporarily disabled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 23:37:42 +00:00
Smittix 75e50a1cd4 docs: Add Sub-GHz, APRS, DMR, weather sat, and other missing features to docs
Update README, FEATURES.md, USAGE.md, and GitHub Pages index.html with
all current modes including Sub-GHz analyzer, APRS, utility meters,
DMR digital voice, listening post, weather satellites, WebSDR, HF SSTV,
and AIS vessel tracking. Update mode count from 15+ to 20+.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 23:35:56 +00:00
Smittix 243a0f0e7f chore: Bump version to v2.16.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 23:32:15 +00:00
Smittix 7c3ec9e920 chore: commit all changes and remove large IQ captures from tracking
Add .gitignore entry for data/subghz/captures/ to prevent large
IQ recording files from being committed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 23:30:37 +00:00
Smittix 4639146f05 fix: Remove incomplete MLAT feature causing ImportError on startup
The partially-added MLAT support was out of sync between config and
routes, causing an ImportError when importing adsb_bp. Remove all MLAT
additions from config, template UI/JS, and docs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 20:55:21 +00:00
Smittix a354fee792 fix: Resolve listening post audio stuttering introduced in v2.15.0
Throttle audio waterfall rendering (50ms→200ms), eliminate per-frame
Array.from() allocation, drain stale pipe buffer before streaming,
increase chunk size to 8192, and remove debug logging from animation
hot paths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 20:24:51 +00:00
Smittix a1cb6b2692 feat: Add SatDump to setup.sh for local (non-Docker) installs
Weather satellite decoding (NOAA APT & Meteor LRPT) was added in the
Dockerfile but setup.sh had no SatDump support, leaving local installs
with a broken weather satellite mode. Adds build-from-source functions
for both Debian and macOS, a check_optional entry, and prompted install
steps in both platform installers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 09:15:53 +00:00
Smittix 8376415074 feat: Add weather satellite decoder (NOAA APT & Meteor LRPT)
feat: Add weather satellite decoder (NOAA APT & Meteor LRPT)   -  Alpha
2026-02-10 08:36:34 +00:00
Mitch Ross b25615317b Merge upstream/main: sync fork with latest DMR fixes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 19:40:25 -05:00
Mitch Ross 311d268b10 Explicitly remove libgtk-3-dev in Dockerfile cleanup step
Adds libgtk-3-dev to the apt-get remove list so it doesn't remain
in the final image. Runtime GTK libs stay for slowrx.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 19:09:50 -05:00
Mitch Ross 6581620cb0 Merge pull request #2 from mitchross/copilot/add-test-coverage-weather-satellite
[WIP] Add test coverage for weather satellite decoder modules
2026-02-09 16:58:09 -05:00
copilot-swe-agent[bot] aa963519e9 Fix str(e) in error responses, remove location modal, document GTK dependency
Co-authored-by: mitchross <6330506+mitchross@users.noreply.github.com>
2026-02-09 21:55:30 +00:00
copilot-swe-agent[bot] 4a6dddbb48 Add comprehensive test coverage for weather satellite modules
- Created test_weather_sat_routes.py with 42 tests for all endpoints
- Created test_weather_sat_decoder.py with 47 tests for WeatherSatDecoder class
- Created test_weather_sat_predict.py with 14 tests for pass prediction
- Created test_weather_sat_scheduler.py with 31 tests for auto-scheduler
- Total: 134 test functions across 14 test classes
- All tests follow existing patterns (mocking, fixtures, docstrings)
- Tests cover happy paths, error handling, and edge cases
- Mock all external subprocess calls and HTTP requests

Co-authored-by: mitchross <6330506+mitchross@users.noreply.github.com>
2026-02-09 21:50:22 +00:00
copilot-swe-agent[bot] f217230ef4 Initial plan 2026-02-09 21:41:46 +00:00
Mitch Ross e27b4d78cb Merge pull request #1 from mitchross/copilot/fix-security-issues
Address code review feedback for weather satellite decoder
2026-02-09 16:09:39 -05:00
copilot-swe-agent[bot] d41ba61aee Fix security issues, breaking changes, and code cleanup for weather satellite PR
Co-authored-by: mitchross <6330506+mitchross@users.noreply.github.com>
2026-02-09 20:58:26 +00:00
copilot-swe-agent[bot] 35cf01c11e Initial plan 2026-02-09 20:52:52 +00:00
Smittix 00c9a6fdd9 Fix DMR audio/text deadlock: start ffmpeg per-client, not at launch
Starting ffmpeg at decoder launch caused a pipe-buffer deadlock: ffmpeg
stdout filled up (~64KB on Linux) before the browser connected to the
audio stream, back-pressuring the entire pipeline and freezing dsd-fme
stderr output (no text data, no syncs, no calls).

New architecture: a mux thread always drains dsd-fme stdout to keep the
pipeline flowing. ffmpeg starts lazily per-client when /dmr/audio/stream
is requested (matching the listening post pattern). The mux forwards
decoded audio to the active ffmpeg with silence fill during voice gaps,
and discards audio when no client is connected.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 19:25:07 +00:00
Smittix fce66a6a60 Fix DMR audio stream failing with "no supported source found"
Digital voice is intermittent — dsd-fme only outputs PCM during active
voice transmissions. Without input, ffmpeg never wrote the WAV header
and the browser got an empty response. Add an audio bridge thread that
feeds 100ms silence chunks during voice gaps so ffmpeg always has input
and the browser receives a continuous WAV stream. Add auto-reconnect
on the frontend if the audio stream drops while the decoder is running.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 18:14:33 +00:00
Smittix b023e4cdc7 Add DMR audio output, frequency persistence, and bookmarks
Stream decoded digital voice audio to the browser via ffmpeg pipeline
(dsd-fme 8kHz PCM → ffmpeg → 44.1kHz WAV → chunked HTTP). Persist
frequency/protocol/gain/ppm settings in localStorage so they survive
page navigation. Add bookmark system for saving and recalling frequencies.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 18:05:27 +00:00
Smittix a8f2912b90 Fix waterfall-to-listen SDR busy race condition
Wait for server-side WebSocket stop confirmation before closing the
connection, ensuring the IQ process is fully terminated and the USB
device released. Add retry logic with back-off in the audio start
endpoint as defense-in-depth for any remaining timing gaps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 13:58:42 +00:00
Smittix a2a7ac8fec Fix banner filter eating dsd-fme data lines and add event log capture
The box-drawing character filter was dropping ANY line containing │ or ─,
including dsd-fme data lines that use these as column separators (e.g.
"DMR BS │ Slot 1 │ TG: 12345 │ SRC: 67890"). Now only filters lines
that are purely decorative (no alphanumeric content).

Also adds -J /dev/stderr so dsd-fme writes its event log to stderr
where we capture it, and debug logging of raw stderr lines.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 11:41:32 +00:00
Smittix 4e168ff502 Fix dsd-fme DMR flag (-fd is D-STAR, not DMR) and audio output
-fd means D-STAR in dsd-fme, not DMR — causing sync detection
(shared C4FM modulation) but no decoded data. DMR Simplex is -fs.
Also fix -o - (invalid in dsd-fme) to -o null for headless servers,
add D-STAR flag mapping, and handle TGT/SRC output format in parser.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 10:31:44 +00:00
Smittix 51aba87852 Bump version to 2.15.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 09:22:41 +00:00
Smittix 4c13e98091 Fix dsd-fme protocol flags, device label, and add tuning controls
dsd-fme remapped several flags from classic DSD: -fp is ProVoice (not
P25), -fi is NXDN48 (not D-Star), -fv doesn't exist. This caused P25
to trigger ProVoice decoding and D-Star to trigger NXDN48. Corrected
flag table and added C4FM modulation hints for better sync reliability.

Also fixes: device panel showing "DMR" regardless of protocol, signal
activity status flip-flopping between LISTENING and IDLE, and rtl_fm
squelch chopping the bitstream mid-frame. Adds PPM correction and
relax CRC controls for fine-tuning on marginal signals.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 08:44:23 +00:00
Mitch Ross 54c849ab60 Fix weather satellite decoder security, architecture, and race conditions
Security: replace path traversal-vulnerable str().startswith() with
is_relative_to(), anchor path checks to app root, strip filesystem
paths from error responses, add decoder-level path validation.

Architecture: use safe_terminate/register_process for subprocess
lifecycle, replace custom SSE generator with sse_stream(), use
centralized validate_* functions, remove unused app.py declarations.

Bugs: add thread-safe singleton locks, protect _images list across
threads, move blocking process.wait() to async daemon thread, fix
timezone handling for tz-aware datetimes, use full path for image
deduplication, guard TLE auto-refresh during tests, validate
scheduler parameters to avoid 500 errors.

Docker: pin SatDump to v1.2.2 and slowrx to ca6d7012, document
INTERCEPT_IMAGE fallback pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 21:29:45 -05:00
Mitch Ross 94ee22fdd4 Merge upstream/main: sync fork with conflict resolution
Resolve conflicts keeping local GSM tools in kill_all() process list
and weather satellite config settings while merging upstream changes
including GSM spy removal, DMR fixes, USB device probe, APRS crash
fix, and cross-module frequency routing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 20:06:41 -05:00
Smittix b96eb8ccba Fix DMR frontend/backend state desync causing 409 on start
When the backend has an active DMR session but the frontend lost track
(page refresh, broken flags causing silent running), clicking Start
returned 409 with no recovery path. Now the frontend resyncs on
"Already running" responses and checks backend status on tab activation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 23:20:02 +00:00
Smittix b8a80460bf Fix digital voice decoder producing no output due to wrong dsd-fme flags
The _DSD_FME_PROTOCOL_FLAGS dictionary had every protocol flag wrong,
causing dsd-fme (the preferred binary) to receive invalid or mismatched
-f flags. Also fix orphaned process leak on startup failure and add
centralized input validation for frequency/gain/device.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 23:12:54 +00:00
Smittix 7130c2d4c4 Add cross-module frequency routing from Listening Post to decoders
Enable sending discovered frequencies from the Listening Post scanner,
signal identification panel, and waterfall display directly to Pager,
433 Sensor, or RTLAMR decoder modes with one click.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 22:45:47 +00:00
Smittix 62c34c1e95 Fix settings modal overflowing viewport on smaller screens
Constrain modal height to viewport and make tab content scrollable
so the modal no longer falls off the bottom of the screen.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 22:25:58 +00:00
Smittix e413f54651 Add USB-level device probe to prevent cryptic rtl_fm crashes
When an external process (or stale handle from a crash) holds an SDR
device, claim_sdr_device() registry check passes but rtl_fm fails with
usb_claim_interface error -6. This adds a quick rtl_test probe inside
claim_sdr_device() so all modes get a clear error message before the
decoder pipeline is launched.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 22:24:02 +00:00
Smittix 1a4af214bf Fix APRS crash on large station count and station list overflow
- Fix infinite loop in updateAprsStationList: querySelectorAll returns a
  static NodeList so the while(cards.length > 50) loop never terminated,
  crashing the page. Use live childElementCount instead.
- Fix station list pushing map off-screen by adding overflow:hidden and
  min-height:0 to flex containers so only the station list scrolls.
- Cap backend aprs_stations dict at 500 entries with oldest-eviction to
  prevent unbounded memory growth.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 22:13:28 +00:00
Smittix c2891938ab Remove GSM spy functionality for legal compliance
Remove all GSM cellular intelligence features including tower scanning,
signal monitoring, rogue detection, crowd density analysis, and
OpenCellID integration across routes, templates, utils, tests, and
build configuration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 22:04:12 +00:00
Smittix 2bed35dd64 Fix signal handler deadlock and add satellite TLE data
Remove logging and cleanup_all_processes() from signal handler to
prevent deadlocks when another thread holds the logging or process lock.
Process cleanup is handled by the atexit handler instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 21:50:13 +00:00
Smittix 0c656cff2b Fix heatmap for towers with CID=0 or no geocoded coordinates
The monitored tower may have CID=0 (partially decoded cell) which
OpenCellID can't geocode, leaving it without coordinates. The heatmap
now falls back through: monitored tower by ARFCN, any geocoded tower,
then observer location. Also tracks the monitored ARFCN so the fallback
can find the right tower even when CID matching fails.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 21:27:36 +00:00
Smittix e03ba3f5ed Fix heatmap: add type coercion, LAC matching, debug logging, and user feedback
The heatmap silently failed when: CID types mismatched (string vs number),
LAC wasn't checked (wrong tower matched), or no data existed yet (button
showed ON with no layer). Now coerces CID/LAC to Number for comparison,
validates coordinates with parseFloat, logs match diagnostics to console,
and only shows ON when the layer is actually rendered.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 21:22:23 +00:00
Smittix c6ff8abf11 Add Leaflet.heat crowd density heatmap to GSM Spy dashboard
Adds a toggleable heatmap layer that visualizes crowd density data from
the existing /gsm_spy/crowd_density endpoint as a gradient overlay on the
map, with auto-refresh every 30s during active monitoring.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 20:55:06 +00:00
Smittix eff6ca3e87 Add 2G generation label to GSM band selector options
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 20:41:02 +00:00
Smittix 1a5b076a8d Fix grgsm_scanner crash on unsupported band names (GSM800, EGSM900_EXT)
Add explicit band name mapping from internal names to grgsm_scanner's
accepted -b values (GSM900, GSM850, DCS1800, PCS1900). Bands without
a valid grgsm_scanner equivalent (GSM800, EGSM900_EXT) are skipped
with a log message instead of crashing the scanner. Remove GSM800
from the dashboard band selector since it can't be scanned.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 20:39:12 +00:00
Smittix 90e88fc469 Fix tshark hex parsing and add API key settings UI
Parse tshark GSM field values with int(value, 0) instead of int(value)
to auto-detect hex 0x-prefixed output (e.g. 0x039e for TMSI/LAC/CID).
Without this, every tshark line with hex values fails to parse, causing
0 devices to be captured during monitoring.

Also add API Keys tab to Settings modal for configuring OpenCellID key
via the UI (in addition to env var), with status display and usage bar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 20:35:31 +00:00
Smittix 98f6d18bea Fix GSM dashboard counters, improve lists, add device detail modal
Wire SIGNALS/DEVICES/CROWD counters to monitor_heartbeat SSE data so
they update in real-time during monitoring. Redesign device list items
as richer cards with type badges, TA/distance, and observation counts.
Add clickable device detail modal with full device info and copy
support. Improve tower list with signal strength bars. Widen right
sidebar and bump list font sizes for readability.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 20:24:51 +00:00
Smittix 7d69cac7e7 Fix geocoding: validate API responses, clean poisoned cache, improve logging
- Cache lookup now requires non-NULL lat/lon — previously a row with
  NULL coordinates counted as a cache hit, returning {lat: None, lon: None}
  which the frontend silently ignored (tower in list but no map pin)
- API response handler validates lat/lon exist before caching, preventing
  error responses (status 200 with error body) from poisoning the cache
- On geocoding worker start, delete any existing poisoned cache rows
- Geocoding worker now logs "API key not configured" vs "rate limit
  reached" so the actual problem is visible in logs
- API error responses now log the response body for easier debugging
2026-02-08 19:55:00 +00:00
Smittix c6a8a4a492 Fix EGSM900 downlink frequency: 935 MHz not 925 MHz
The EGSM900 band table had start=925e6 but ARFCNs 0-124 use downlink
frequencies starting at 935 MHz (DL = 935 + 0.2*ARFCN). The 925 MHz
value is the E-GSM extension band (ARFCNs 975-1023).

This caused grgsm_livemon to tune 10 MHz too low — ARFCN 22 tuned to
929.4 MHz instead of 939.4 MHz, receiving no GSM frames and producing
zero GSMTAP packets for tshark to capture.

Also adds EGSM900_EXT band (ARFCNs 975-1023, DL 925.2-934.8 MHz)
and diagnostic logging in the monitor thread to track raw tshark
line counts vs parsed packets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 19:50:04 +00:00
Mitch Ross ca15e227cd add test harness 2026-02-08 14:45:12 -05:00
Smittix 391aff52ce Fix OpenCellID integration: CID=0 handling, API key check, tab parsing
- /lookup_cell and /detect_rogue rejected CID=0 towers because
  `all([..., cid])` is falsy when cid=0; use `is not None` checks
- can_use_api() now returns False when GSM_OPENCELLID_API_KEY is empty,
  preventing the geocoding worker from wasting daily quota on doomed calls
- /lookup_cell returns 503 with clear message when API key not configured
- parse_tshark_output uses rstrip('\n\r') instead of strip() to preserve
  leading empty tab-separated fields (strip() ate leading tabs, shifting
  all columns when the first field was empty)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 19:37:06 +00:00
Smittix 3dc16b392b Remove tshark -Y display filter that blocked all GSM packets
The display filter `gsm_a.tmsi || e212.imsi` was too restrictive —
paging requests use different field paths for TMSI so nothing matched.
The capture filter (-f 'udp port 4729') already limits to GSMTAP, and
the parser discards rows without TMSI/IMSI identifiers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 19:24:10 +00:00
Smittix 4d7be047da Fix tshark crash by skipping invalid fields instead of using fallbacks
When tshark field discovery finds no valid candidate for a logical field
(e.g. timing_advance, cellid), the old code fell back to the first
candidate name even though it was known to be invalid. This caused tshark
to exit immediately with "Some fields aren't valid".

Now fields resolve to None when no valid candidate exists, and the tshark
command is built using only validated fields. The parser dynamically maps
columns via field_order instead of assuming a fixed 5-column layout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 19:14:09 +00:00
Smittix 182e1f3239 Fix tshark field discovery to validate with actual extraction test
tshark -G fields lists fields that exist in the protocol tree but
aren't all valid for -T fields -e extraction. Changed discovery to
actually test candidates by running tshark -T fields -e <field> -r
/dev/null and parsing stderr for invalid field names. This correctly
identifies which fields work for extraction on the installed version.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 18:56:50 +00:00
Smittix 87782319f2 Auto-discover tshark field names for GSM protocol compatibility
tshark field names differ between Wireshark versions (3.x vs 4.x):
- 3.x: gsm_a.rr.timing_advance, gsm_a.tmsi, gsm_a.cellid
- 4.x: gsm_a_rr.timing_adv, gsm_a_dtap.tmsi, e212.ci

Added _discover_tshark_fields() that queries `tshark -G fields` to
find which field names are available on the installed version, then
uses the correct ones for the capture filter and field extraction.
Results are cached after first discovery.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 18:49:20 +00:00
Smittix 6b7f817aa6 Add live monitoring status overlay with heartbeat updates
Backend: monitor_thread sends periodic monitor_heartbeat events (every
5s) with elapsed time, packet count, and device count so the frontend
knows monitoring is active.

Frontend: new monitoring overlay replaces scan progress bar when
auto-monitor starts. Shows pulsing green indicator, ARFCN being
monitored, live elapsed timer, packet/device counts, and
"Listening..."/"Capturing" activity state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 18:41:30 +00:00
Smittix 82f442ffb8 Fix tshark capture: add GSMTAP filter, line buffering, stderr capture
- Add capture filter (-f 'udp port 4729') to only capture GSMTAP packets
- Add -l flag for line-buffered output on live capture
- Add early exit detection for tshark with stderr capture
- Add stderr reader thread in monitor_thread for ongoing tshark diagnostics
- Clean up grgsm_livemon if tshark fails to start

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 18:37:48 +00:00
Mitch Ross 1924203c19 Merge upstream/main: add gsm_spy blueprint 2026-02-08 13:15:20 -05:00
Smittix f18ed26005 Fix grgsm_livemon Qt crash in headless Docker container
Set QT_QPA_PLATFORM=offscreen for both grgsm_livemon and
grgsm_scanner to prevent SIGABRT when no X11 display is available.
grgsm_livemon uses GNU Radio which loads Qt plugins — without a
display, Qt aborts with "could not connect to display".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 18:13:15 +00:00
Smittix 897cea5b54 Move scan progress bar above map as prominent overlay
- Repositioned progress indicator from right sidebar to a full-width
  overlay at the top of the map panel
- Added animated spinning icon, glowing progress bar, blurred backdrop
- Centered layout with max-width constraint for readability
- Progress bar and status text more visible during active scans

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 18:04:25 +00:00
Smittix cd2d51ee40 Fix grgsm_livemon crash diagnostics and SSE race condition
- Add pre-flight checks (shutil.which) for grgsm_livemon and tshark
- Capture stderr when grgsm_livemon exits immediately (exit code 1)
- Start background stderr reader thread for ongoing livemon diagnostics
- Add idle_count grace period in SSE stream to handle scanner→monitor
  transition without premature disconnect
- Forward monitor failure errors to SSE for frontend display

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 18:03:03 +00:00
Smittix 39ed4bffba Auto-switch to monitor mode after scan for device tracking
The scanner and monitor are mutually exclusive (both need the SDR).
Previously auto-monitor tried to start mid-scan (causing device
conflicts) and required 3 towers (rarely achieved with weak signals).

Now after the first scan completes:
- If any towers were found, automatically stop scanner and start
  grgsm_livemon + tshark on the strongest tower's ARFCN
- SDR handoff is clean (scanner process has already exited)
- If monitor fails to start, scanner loop resumes
- Scanner thread's finally block preserves SDR allocation when
  monitor has taken over
- Frontend shows "Monitoring ARFCN X for devices..." status

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 17:42:10 +00:00
Smittix 6010c7d589 Add scan progress to frontend, fix Europe band defaults
- Forward scanner progress (%) and status to SSE stream
- Show progress bar and scan status in TRACKED TOWERS panel
- Send scan_complete event with tower count and duration
- Fix Europe BAND_CONFIG: only EGSM900 is recommended (GSM850/GSM800
  are rarely used in Europe and waste scan time)
- DCS1800 available but not recommended (RTL-SDR sensitivity is lower)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 17:28:54 +00:00
Smittix 01978730ba Relax CID=0 filter: allow partially decoded cells with valid MCC/MNC
CID=0 with valid MCC/MNC means the scanner found the cell but didn't
decode System Information 3/4 (which carries the Cell ID). These are
still valid towers worth displaying. Only filter when MCC=0 AND MNC=0
(truly unidentified signals).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 17:14:17 +00:00
Smittix 451eff83a8 Fix GSM Spy dashboard: stats, signal display, CID=0 filter, tower details
Backend:
- Filter out CID=0 and MCC=0 entries (ARFCNs with no decoded cell identity)

Frontend:
- Move stats update before coordinate check so towers always counted
- Fix signal_strength display using null check instead of || (0 is falsy)
- Show operator name, frequency, and status in tower detail panel
- Show "Located" indicator in tower list for geocoded towers
- Fix selectTower crash when tower has no coordinates
- Update placeholder text to "Select a tower from the list"
- Add try/catch to selectTower for error resilience

Tests:
- Add tests for CID=0 and MCC=0 filtering

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 17:04:04 +00:00
Smittix 7cb2efca30 Fix GSM Spy frontend: SSE state replay, field name mismatch, crash fix
- Send all existing towers on SSE connect (fixes data loss on reconnect)
- Fix tower.signal -> tower.signal_strength field name in frontend
- Fix TypeError crash in selectTower when tower has no coordinates
- Add Connection: keep-alive header to SSE response
- Add comprehensive console.log debugging for SSE data flow
- Handle error/disconnected SSE event types in frontend

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 16:57:39 +00:00
Smittix 33953fcf2b Add SSE stream logging to diagnose frontend data delivery
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 16:47:41 +00:00
Smittix 1eec4a2342 Fix stdout buffering: use PYTHONUNBUFFERED for grgsm_scanner
grgsm_scanner is a Python/GNU Radio script, so stdbuf has no effect.
Setting PYTHONUNBUFFERED=1 in the subprocess env forces Python to
flush stdout on every write, enabling real-time scan output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 16:36:13 +00:00
Smittix 2dc4940ca2 Fix grgsm_scanner stdout buffering and increase scan timeout
grgsm_scanner fully buffers stdout when piped, so scan results never
reach Python until the buffer fills or process exits. Wrapping with
stdbuf -oL forces line-buffered output for real-time data streaming.

Also increased scan timeout from 120s to 300s since scanning 4 bands
legitimately takes 2-3 minutes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 16:30:51 +00:00
Smittix cd5f1464b6 Switch gr-gsm source from ptrkrysik to bkerler fork
The ptrkrysik/gr-gsm repo uses SWIG which is incompatible with
GNU Radio 3.10+. The bkerler fork supports modern GNU Radio and
builds successfully on current Ubuntu/Debian systems.

Updated all references in Dockerfile, setup.sh, dependencies.py,
and error messages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 16:26:09 +00:00
Smittix 4aeb51a973 Set OSMO_FSM_DUP_CHECK_DISABLED for grgsm_scanner and grgsm_livemon
apt-packaged gr-gsm aborts with SIGABRT (-6) due to duplicate FSM
registration in libosmocore. Setting this env var suppresses the
fatal assertion, allowing grgsm_scanner to run normally.

Applied to both scanner and livemon subprocess spawns.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 16:02:01 +00:00
Smittix 15efe56762 Detect grgsm_scanner crash-on-startup and report to UI
grgsm_scanner exits in <300ms with osmo_fsm assertion error due to
libosmocore incompatibility. Added crash detection: if process exits
in <5s with non-zero code, counts as crash. After 3 crashes, stops
retrying and sends error to SSE stream so the UI can display it.

Also drains remaining queue items after process exits and logs exit
code and scan duration for diagnostics.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 16:00:31 +00:00
Smittix 995bc17418 Set GSM Spy logger to DEBUG level to override WARNING default
Global LOG_LEVEL defaults to WARNING, silencing all INFO/DEBUG logs.
GSM Spy needs verbose logging for scanner diagnostics. Override the
module logger level to DEBUG so scanner output is always visible.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 15:57:51 +00:00
Smittix c3dcf1401a Fix GSM Spy logger never configured - all log output was silenced
gsm_spy.py used logging.getLogger() directly which returns a bare
logger with no handler. The parent 'intercept' logger has
propagate=False, so all GSM Spy logs were silently dropped.

Now uses utils.logging.get_logger() which adds a stderr handler
and sets the log level, matching all other route modules.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 15:53:57 +00:00
Smittix 6f9873d47f Parse grgsm_scanner stderr output (GNU Radio outputs data to stderr)
grgsm_scanner (like many GNU Radio tools) writes scan results to
stderr, not stdout. The stderr reader was only logging at debug
level and discarding lines. Now feeds stderr into the parse queue.

Also added info-level logging for all scanner output lines to aid
debugging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 15:51:16 +00:00
Smittix 28185727e3 Fix grgsm_scanner output parser to match real output format
Parser expected pipe-delimited table rows but grgsm_scanner outputs
comma-separated key-value pairs like:
  ARFCN: 975, Freq: 925.2M, CID: 13522, LAC: 38722, MCC: 262, MNC: 1, Pwr: -58

This was the root cause of no data appearing in GSM Spy.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 15:48:57 +00:00
Smittix 48795f6ec3 Fix SDR device not released on GSM Spy stop
stop_scanner() cleared gsm_spy_active_device without calling
release_sdr_device(), so the device stayed claimed in the registry.
The scanner thread's finally block then saw None and skipped release.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 15:43:06 +00:00
Smittix f5021a0fdf Fix GSM band name mismatch between UI and backend
UI was sending GSM900 but backend REGIONAL_BANDS expects EGSM900
for Europe and Asia regions, causing validation rejection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 15:39:28 +00:00
Smittix 7312f330ed Add gr-gsm and tshark as auto-installed dependencies
GSM Spy was failing with FileNotFoundError because grgsm_scanner
wasn't installed. These tools are now installed automatically by
setup.sh (both Debian and macOS) and included in the Dockerfile,
matching how other tools like multimon-ng and ffmpeg are handled.

- setup.sh: Remove ask_yes_no prompts for gr-gsm and tshark, install
  unconditionally; add check_recommended tier for final summary
- Dockerfile: Add tshark to apt layer, add gr-gsm RUN layer with
  apt-then-source-build fallback, preseed debconf for tshark
- gsm_spy.py: Add shutil.which pre-check in start_scanner route,
  catch FileNotFoundError in scanner_thread to stop retry loop

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 15:35:15 +00:00
Smittix 2115bc551d Merge branch 'pr-124'
# Conflicts:
#	app.py
#	routes/__init__.py
#	utils/database.py
2026-02-08 15:04:17 +00:00
Smittix f6c19af33a Fix PR #124 remaining issues: XSS, state management, DB regression
- kill_all() now resets gsm_spy_scanner_running and related state so
  the scanner thread stops after killall
- scanner_thread sets flag to False instead of None on exit
- Restore alert_rules, alert_events, recording_sessions tables and
  wifi_clients column removed by PR in database.py
- Escape all server-sourced values in analysis modals with escapeHtml()
- Reset gsm_towers_found/gsm_devices_tracked on stop to prevent
  counter drift across sessions
- Replace raw terminate/kill with safe_terminate() in scanner_thread

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 15:02:14 +00:00
Smittix ebd9eb81f2 Add WATERFALL title label to function bar
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 14:30:39 +00:00
Smittix c87c01cdfe Load function-strip.css so waterfall bar renders horizontally
The function-strip CSS was never linked in index.html, causing all
strip items to render as unstyled stacked elements instead of a
horizontal flex bar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 14:29:09 +00:00
Smittix 19a94d4a84 Move waterfall controls to function bar and fix SDR claim race on tune
Move waterfall controls from the sidebar into a function-strip bar inside
#listeningPostVisuals so they sit directly above the waterfall canvas.
Also fix the "SDR device in use" error when clicking a waterfall frequency
to listen — the WebSocket waterfall's device claim wasn't being released
before the audio start request because the backend cleanup hadn't finished.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 14:25:36 +00:00
Smittix cca04918a9 Fix waterfall crash on zoom by reusing WebSocket and adding USB release retry
Zooming caused "I/Q capture process exited immediately" because the client
closed the WebSocket and opened a new one, racing with the old rtl_sdr
process releasing the USB device. Now zoom/retune sends a start command on
the existing WebSocket, and the server adds a USB release delay plus retry
loop when restarting capture within the same connection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 14:00:40 +00:00
Smittix 777b83f6e0 Fix waterfall showing solid yellow by auto-scaling FFT quantization
The FFT pipeline produces power values in the ~0-60 dB range for
normalized IQ data, but quantize_to_uint8 used a hardcoded range
of -90 to -20 dB. Every bin saturated to 255, producing a uniform
yellow waterfall with no signal differentiation.

Now auto-scales to the actual min/max of each frame so the full
colour palette is always used.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 13:45:07 +00:00
Smittix 455bc05c69 Shut down WebSocket socket to prevent Werkzeug HTTP response leak
After a WebSocket handler exits, flask-sock returns a Response to
Werkzeug which writes "HTTP/1.1 200 OK..." on the still-open socket.
Browsers see these HTTP bytes as a malformed WebSocket frame, causing
"Invalid frame header".

Now the handler explicitly closes the raw TCP socket after the
WebSocket close handshake, so Werkzeug's write harmlessly fails.
Applied to both waterfall and audio WebSocket handlers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 13:40:17 +00:00
Smittix 37842dc1ef Fix WebSocket handler exiting immediately on receive timeout
simple-websocket 1.1.0's receive(timeout=N) returns None on timeout
instead of raising TimeoutError. The handler treated None as
"connection closed" and broke out of the loop, causing Werkzeug to
write its HTTP 200 response on the still-open WebSocket socket.
The browser saw those HTTP bytes as an invalid WebSocket frame.

Now checks ws.connected to distinguish timeout (None + connected)
from actual close (None + not connected).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 13:21:25 +00:00
Smittix 01f3cc845b Add missing /sensor/status and /tscm/status endpoints
agents.js syncLocalModeStates() expects these endpoints to check
whether each mode is running locally. Both were missing, causing
404 errors on mode switch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 13:14:27 +00:00
Marc bdba56bef1 PR #124 fixed major and minor issues 2026-02-08 07:04:10 -06:00
Smittix a5ea632cc2 Fix WebSocket waterfall blocked by login redirect
The before_request require_login hook was returning a 302 redirect
for WebSocket upgrade requests, which browsers report as "Invalid
frame header". WebSocket requests don't always carry session cookies
reliably. Allow /ws/ paths through the login check since the page
that initiates these connections already requires authentication.

Also keeps the prior fix: serialize WebSocket sends through a queue
to avoid concurrent read/write on the non-thread-safe simple-websocket.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 13:03:34 +00:00
Smittix a3b81bead8 Fix WebSocket waterfall "Invalid frame header" by serializing sends
The fft_reader thread was calling ws.send() concurrently with
ws.receive() in the main loop. simple-websocket is not thread-safe
for simultaneous read/write, corrupting frame headers. Now the reader
thread enqueues frames and only the main loop touches the WebSocket.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 12:52:42 +00:00
Smittix 026337a350 Add real-time WebSocket waterfall with I/Q capture and server-side FFT
Replace the batch rtl_power SSE pipeline with continuous I/Q streaming
via WebSocket for smooth ~25fps waterfall display. The server captures
raw I/Q samples (rtl_sdr/rx_sdr), computes Hann-windowed FFT, and
sends compact binary frames (1035 bytes vs ~15KB JSON, 93% reduction).
Client falls back to existing SSE path if WebSocket is unavailable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 12:37:50 +00:00
Marc 44b1a74838 Fixes regarding for PR #124, also added vector images for towers and phones 2026-02-08 03:23:23 -06:00
Smittix 7aae2944d4 Add waterfall modulation auto-select and fix kill-all message
Waterfall clicks now auto-select the correct modulation for the frequency
band (e.g., WFM for FM broadcast, AM for airband) instead of using whatever
modulation was last selected. Adds a hover tooltip showing frequency and
suggested modulation. Fixes the kill-all notification to show a clean
"All processes stopped" message instead of listing "bluetooth_scanner".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 00:41:18 +00:00
Smittix 766a51753d Add real-time signal scope to both SSTV modes
Adds a phosphor-persistence waveform scope showing audio RMS/peak
levels during ISS SSTV and General SSTV decoding, matching the
existing pager scope pattern with a purple color scheme.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 00:28:33 +00:00
Smittix 92e5e7c6da Add real-time signal scope to 433MHz sensor mode
Enable -M level on rtl_433 to include RSSI/SNR in decoded JSON, extract
signal levels and push scope events to the SSE stream. Renders a green-
themed canvas oscilloscope showing signal strength pulses on packet decode
with amber SNR indicator and decay between packets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 00:00:01 +00:00
Smittix 154dc898ff Add real-time signal scope to pager mode
Tap the rtl_fm → multimon-ng audio pipeline via a relay thread to extract
RMS/peak amplitude levels and render a 60fps canvas oscilloscope during
pager decoding, giving visual feedback of RF activity before messages are
fully decoded.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 23:50:41 +00:00
Smittix beb38b6b98 Remove waterfall from all modes except listening post
Reverts IQ pipeline and removes syncWaterfallToFrequency calls from
pager, sensor, rtlamr, DMR, SSTV, and SSTV general modes. Waterfall
is now exclusive to listening post mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 23:29:56 +00:00
Smittix f04ba7f143 Add live waterfall during pager and sensor decoding via IQ pipeline
Replace rtl_fm/rtl_433 with rtl_sdr for raw IQ capture when available,
enabling a Python IQ processor to compute FFT for the waterfall while
simultaneously feeding decoded data to multimon-ng (pager) or rtl_433
(sensor). Falls back to the legacy pipeline when rtl_sdr is unavailable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 23:18:43 +00:00
Mitch Ross fd0953bfb5 up 2026-02-07 17:56:45 -05:00
Smittix b312eb20aa Resume waterfall after listen and sync to mode frequency 2026-02-07 22:40:00 +00:00
Smittix 8eb8a2fe97 Fix waterfall resume and add zoom controls 2026-02-07 22:13:50 +00:00
Mitch Ross 13be4302c3 Update index.html 2026-02-07 16:07:12 -05:00
Mitch Ross 5fd45d3e94 Merge remote-tracking branch 'upstream/main' 2026-02-07 16:03:32 -05:00
Smittix e88b815dc9 Add shared waterfall UI across SDR modes 2026-02-07 16:01:01 -05:00
Mitch Ross 556a4ffcc2 tweaks
1. utils/weather_sat.py — Added delete_all_images() method that globs for *.png, *.jpg, *.jpeg in the output dir, unlinks each, clears _images list, and returns the
  count.
  2. routes/weather_sat.py — Added DELETE /weather-sat/images route that calls decoder.delete_all_images() and returns {'status': 'ok', 'deleted': count}.
  3. static/js/modes/weather-satellite.js:
    - Added currentModalFilename state variable
    - renderGallery() now sorts images by timestamp descending, groups by date using toLocaleDateString(), renders date headers spanning the grid, and adds a delete
  overlay button on each card
    - showImage() accepts a filename param, stores it in currentModalFilename, and creates a modal toolbar with a delete button
    - Added deleteImage(filename) — confirm dialog → DELETE /weather-sat/images/{filename} → filter from array → re-render + close modal
    - Added deleteAllImages() — confirm dialog → DELETE /weather-sat/images → clear array → re-render
    - Exposed deleteImage, deleteAllImages, and _getModalFilename in public API
  4. static/css/modes/weather-satellite.css:
    - Added position: relative to .wxsat-image-card
    - .wxsat-image-actions — absolute top-right overlay, hidden by default, appears on card hover
    - .wxsat-image-actions button — dark background, turns red on hover
    - .wxsat-date-header — full-grid-width date separator with dimmed uppercase text
    - .wxsat-modal-toolbar — absolute top-left in modal for the delete button
    - .wxsat-modal-btn.delete — turns red on hover
    - .wxsat-gallery-clear-btn — subtle icon button, pushed right via margin-left: auto, turns red on hover
    - Updated .wxsat-gallery-header from justify-content: space-between to gap: 8px for proper 3-child layout
  5. templates/index.html — Added clear-all trash button with SVG icon in the gallery header, wired to WeatherSat.deleteAllImages().
2026-02-07 15:52:52 -05:00
Mitch Ross 03c5d33eb7 Fix race condition: set _running before starting reader thread
The reader thread loop checks self._running but it was being set to
True after _start_satdump() returned, which is after the thread
already started. The thread would see _running=False and exit
immediately without reading any SatDump output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 15:28:07 -05:00
Mitch Ross f9786aa75a Use PTY for SatDump output capture instead of pipe
SatDump writes to stderr via fwrite() with its custom logger. When
stderr is redirected to a pipe, C runtime fully buffers it. Neither
stdbuf nor bufsize settings help since SatDump doesn't use stdio for
output.

PTY (pseudo-terminal) makes SatDump think it's writing to a real
terminal, which disables buffering. Also strips ANSI escape codes
from the output and properly handles \r progress lines.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 15:17:03 -05:00
Mitch Ross b87623cf66 Update weather_sat.py 2026-02-07 15:06:58 -05:00
Mitch Ross 4d24e648ab Update weather_sat.py 2026-02-07 15:04:53 -05:00
Mitch Ross 99f42f66b2 Merge upstream main: add DMR, WebSDR, HF SSTV, alerts, recordings, waterfall
Merges upstream changes into fork while preserving weather satellite
(NOAA APT/Meteor LRPT via SatDump), rtlamr, multi-arch build, and
decoder console features from our branch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 14:29:09 -05:00
Smittix 3240b0788b Add shared waterfall UI across SDR modes 2026-02-07 19:18:57 +00:00
Smittix 3ab1501a90 Clamp waterfall interval to server minimum 2026-02-07 19:08:28 +00:00
Smittix 7e42e00449 Fix waterfall stop before direct listen 2026-02-07 19:06:06 +00:00
Smittix 51ea558e19 Allow listening with waterfall and speed up updates 2026-02-07 18:49:48 +00:00
Smittix 75bd3228e5 Improve waterfall rendering and add click-to-tune 2026-02-07 18:36:14 +00:00
Smittix 86e4ba7e29 Add alerts/recording, WiFi/TSCM updates, optimize waterfall 2026-02-07 18:29:58 +00:00
Smittix 4bbc00b765 Improve TSCM detection and include WiFi clients 2026-02-07 17:31:17 +00:00
Smittix 32b373bf2c Fix stalled audio pipeline cleanup and scanner stop race condition
- Kill audio pipeline when startup produces no data instead of leaving
  zombie processes running
- Skip unnecessary 1s USB release delay when no processes were active
- Remove racy fresh=1 pipeline restart from stream endpoint
- Await stopScanner() before starting direct listen to prevent race

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 15:39:51 +00:00
Smittix cdfc10c854 Make Postgres data path configurable for ADS-B history
Allow users to override the pgdata volume mount via PGDATA_PATH env var,
enabling external storage (e.g. USB) for ADS-B history. Defaults to
./pgdata for backwards compatibility.

Based on PR #88 by JamesIOmete, rebased cleanly onto main.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 15:35:32 +00:00
Smittix adb472956e Merge pull request #126 from suidroot/add_airspy_docker
Add Soapy Airspy package and airspy pages to Dockerfile
2026-02-07 15:31:08 +00:00
Smittix 60d3cff5e7 Fix SDR device lock-up from unreleased device registry on process crash
Stream threads for sensor, pager, acars, rtlamr, dmr, and dsc modes
never called release_sdr_device() when their SDR process crashed,
leaving devices permanently locked in the registry. Also fixes orphaned
companion processes (rtl_fm, rtl_tcp) not being killed on crash, start
path failures leaking processes, DMR stop handler missing lock, and
listening post/audio websocket pkill nuking all system-wide rtl_fm
processes. Wires up register_process()/unregister_process() so the
atexit/signal cleanup safety net actually works, and adds rtl_tcp,
rtl_power, rtlamr, ffmpeg to the killall endpoint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 15:18:15 +00:00
Smittix b208576068 Fix TSCM sweep KeyError on RiskLevel.NEEDS_REVIEW
The RiskLevel.NEEDS_REVIEW enum value was 'review' but the
devices_by_risk dict and all summary keys used 'needs_review',
causing a KeyError during sweep correlation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 15:02:57 +00:00
Smittix 1ee64efc81 Fix PD120 SSTV decode hang and false leader tone detection
Fix infinite CPU spin in PD120 decoding caused by a 1-sample rounding
mismatch between line_samples (24407) and the sum of sub-component
samples (24408). The feed() while loop would re-enter _decode_line()
endlessly when the buffer was too short by 1 sample. Added a stall
guard that breaks the loop when no progress is made.

Fix false "leader tone detected" in the signal monitor by requiring
the detected tone to dominate the other tone by 2x, matching the
approach already used by the VIS detector.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 14:55:22 +00:00
Smittix bb4ccc6355 Auto-bring WiFi interface up before TSCM scan
When the WiFi interface is down (e.g. USB adapter not activated),
scanning fails with "Network is down" errors. Now the scanner
proactively checks interface state via /sys/class/net and brings
it up using ip link (or ifconfig fallback) before attempting scans,
with a retry loop if the initial scan still fails.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 14:45:24 +00:00
Smittix 70e9611f02 Remove orphaned receiver count section from WebSDR sidebar
The Receiver Count section had no <h3> so it didn't get collapsible
panel styling, rendering as a small out-of-place rectangle. The count
is already shown in the main receiver list panel so this was redundant.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 14:38:07 +00:00
Smittix d05144bdb3 Fix WebSDR map black space with dynamic minZoom and background color
Static minZoom: 2 wasn't enough for tall containers. Now calculate
minZoom from actual container height so tiles always cover the visible
area. Also set map background to match CartoDB dark tile ocean color
so any remaining edge at extreme latitudes blends seamlessly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 14:32:16 +00:00
Smittix bfd92e3883 Constrain WebSDR map to prevent vertical black space
Add maxBounds to limit vertical panning to ±85° latitude and set
minZoom to 2 so tiles always cover the visible area. Prevents the
large black bands above and below the map tiles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 14:30:23 +00:00
Smittix 3b191dccd6 Remove raw output display and filter DSD startup banner lines
The dmrRawOutput div was rendering garbled box-drawing characters from
the dsd-fme ASCII art banner below the signal activity canvas. Remove
the div and filter banner lines (box-drawing chars, version info) in
the parser so they never become events.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 12:52:59 +00:00
Smittix c0eda84644 Fix signal activity panel dying after DSD startup banner
The stream thread used a blocking readline() with no timeout, so once
DSD finished outputting its startup banner there were no more events
until actual signal activity. The frontend decayed to zero and appeared
dead. If DSD crashed, the synthesizer state never transitioned to
'stopped' so there was no visual or textual indication of failure.

- Use select() with 1s timeout on DSD stderr to avoid indefinite block
- Send heartbeat events every 3s while decoder is alive but idle
- Detect DSD crashes: capture exit code and remaining stderr, send as
  'crashed' status with details and show notification to user
- Frontend properly transitions synthesizer to 'stopped' on process
  death (was only happening on user-initiated stop)
- Increase idle breathing amplitude so LISTENING state is clearly
  visible (0.12 +/- 0.06 vs old 0.05 +/- 0.035)
- Release device reservation on crash, not just user stop

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 12:49:35 +00:00
Smittix 19f382a31a Add device reservation to DMR mode and improve USB busy error message
DMR was missing checkDeviceAvailability/reserveDevice/releaseDevice
calls that other modes (SSTV, listening post) use, so the device
dropdown showed device 0 as available even when another process held
it. Also detect USB claim errors from rtl_fm and surface a clear
message telling the user to pick a different device.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 12:44:47 +00:00
Smittix 3b205db329 Fix DMR decoder signal activity and parsing for dsd-fme compatibility
The DSD stderr parser had regex ordering bugs that swallowed voice and
call events as bare slot events, and only matched classic dsd output
format (not dsd-fme). Unmatched lines were silently dropped, leaving
the signal activity panel with nothing to display.

- Reorder regex checks: TG/Src before voice before slot
- Support dsd-fme comma-separated format (TG: x, Src: y)
- Make bare slot regex strict (only standalone "Slot N" lines)
- Forward unmatched DSD lines as raw events for diagnostics
- Add LISTENING state to signal activity panel for raw output
- Show raw decoder output text below synthesizer canvas
- Fix test mocks for find_dsd() tuple return value

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 12:41:15 +00:00
Smittix d8c5491200 Fix APRS start crash from calling nonexistent reserve_sdr_device
The APRS route called app_module.reserve_sdr_device() which does not
exist, causing an AttributeError that Flask returned as an HTML error
page. The frontend then failed to parse it as JSON, showing
"Unexpected token '<'" to the user. Fixed to use claim_sdr_device()
which is the correct function used by all other modes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 12:31:48 +00:00
Smittix b9c8b1c730 Register SSTV mode with SDR device registry for device state panel
SSTV was not claiming/releasing SDR devices through the centralized
registry, so the device state panel always showed the device as idle
during SSTV use. Added claim_sdr_device/release_sdr_device on the
backend and reserveDevice/releaseDevice on the frontend, matching the
pattern used by all other modes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 12:28:37 +00:00
Smittix 684f17f507 Add delete and download functionality to SSTV image gallery
Users can now manage decoded SSTV images with download and delete actions
accessible from hover overlays on gallery cards, the full-size image modal
toolbar, and a "Clear All" button in the gallery header. Both ISS and
General SSTV modes are supported.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 12:12:26 +00:00
Smittix a0f64f6fa6 Stream partial decoded images during SSTV decode progress
The decode canvas was always black because nothing drew on it. Now the
backend encodes partial JPEG snapshots every 5% progress and the frontend
uses an <img> tag with in-place DOM updates instead of recreating innerHTML
on every SSE event.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 11:58:00 +00:00
Smittix 06c218c736 Add VIS detector state to signal monitor for decode diagnostics
Shows the current VIS detection state machine position (Idle, Leader,
Break, Start bit, Data bits, etc.) in the signal monitor. This helps
diagnose why decoding may not be starting - e.g. if the VIS detector
is stuck in Idle despite a leader tone being present, the signal may
not contain a valid VIS header.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 11:44:35 +00:00
Smittix 1e249a0eec Fix Doppler detecting events clobbering decode progress UI
The Doppler tracking thread emits detecting events every 5s from a
separate thread, unaware of decode state. The previous to_dict() change
included signal_level for ALL detecting events, causing the frontend to
replace the decode progress canvas with the signal monitor mid-decode.

Fix: use None as default for signal_level so only signal-metrics events
(which explicitly set the value) include the field. Also add a frontend
guard to ignore detecting events while the UI is in decoding state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 11:37:14 +00:00
Smittix 249fccadd3 Fix signal monitor not appearing by always emitting signal_level for detecting status
The to_dict() method was skipping signal_level when it was 0, so the
frontend never received the field and never rendered the monitor.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 11:28:34 +00:00
Smittix 82957ab162 Add real-time signal level monitor to SSTV decoder UI
Shows RMS audio level bar and SSTV tone classification (leader/sync/noise)
via SSE during detecting mode, replacing the static "Listening..." state
with actionable signal feedback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 11:25:01 +00:00
Smittix e8727358eb Log rtl_fm stderr when pipeline fails and surface error to UI
When rtl_fm exits unexpectedly, read its stderr output to diagnose
the failure (no device, permission denied, etc.) and include the
error message in both the server log and the SSE progress event
sent to the browser.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 11:12:38 +00:00
Smittix 28891f4709 Fix SSTV decoder thread lifecycle and VIS detection reliability
Three bugs preventing the live SSTV pipeline from working:

1. Race condition: self._running was set AFTER starting the decode
   thread, so the thread checked the flag, found it False, and exited
   immediately without ever processing audio.

2. Ghost running state: when the decode thread exited (e.g. rtl_fm
   died), self._running stayed True. The decoder reported as running
   but was dead, and subsequent start() calls returned without doing
   anything - permanently stuck until app restart.

3. VIS detection fragility: unclassifiable windows at tone transition
   boundaries (mixed energy from two tones) caused the state machine
   to reset from LEADER/BREAK states back to IDLE, dropping valid
   VIS headers on real signals.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 10:33:08 +00:00
Marc 297f971bd5 adding vector images for the towers and phones 2026-02-07 01:22:50 -06:00
Mitch Ross 4bf35cf786 up 2026-02-07 00:30:41 -05:00
Ben Mason 28e19b8898 Add Soapy Airspy package and airspy pages to Dockerfile 2026-02-06 17:00:52 -05:00
Mitch Ross 4ed7969e90 fixes 2026-02-06 15:05:04 -05:00
Smittix ef7d8cca9f Replace broken slowrx dependency with pure Python SSTV decoder
slowrx is a GTK GUI app that doesn't support CLI usage, so the SSTV
decoder was silently failing. This replaces it with a pure Python
implementation using numpy and Pillow that supports Robot36/72,
Martin1/2, Scottie1/2, and PD120/180 modes via VIS header auto-detection.

Key implementation details:
- Generalized Goertzel (DTFT) for exact-frequency tone detection
- Vectorized batch Goertzel for real-time pixel decoding performance
- Overlapping analysis windows for short-window frequency estimation
- VIS header detection state machine with parity validation
- Per-line sync re-synchronization for drift tolerance

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 19:47:02 +00:00
Mitch Ross 1683d98b90 up 2026-02-06 13:29:45 -05:00
Smittix ae9fe5d063 Bump version to 2.14.0 and update changelog/documentation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 18:28:27 +00:00
Smittix 6783a1cbc4 Fix DMR synthesizer canvas sizing to use element's own rendered rect
getBoundingClientRect on the canvas itself (sized via CSS width:100%)
instead of parentElement with arbitrary offset, preventing zero-width
canvas when flex layout timing varies.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 17:06:22 +00:00
Smittix 7fd7861b4b Add canvas-based visual synthesizer to DMR dashboard
Event-driven spring-physics bar visualization reacting to SSE events
(sync/call/voice) with HSL color coding and center-outward ripple effects.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 17:03:58 +00:00
Smittix 3e453a7b6d Capture rtl_fm stderr for pipeline error diagnostics
rtl_fm stderr was sent to DEVNULL, hiding the actual failure reason
(rc=1). Now captured and surfaced in the error response. Also drains
rtl_fm stderr during normal operation to prevent pipe blocking.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 16:41:49 +00:00
Smittix fbbf20d820 Fix dsd-fme audio output flag and add pipeline error diagnostics
Use -o - (stdout) instead of -o /dev/null for audio output, as
dsd-fme expects specific output targets. Remove -N flag which may
cause issues in headless mode. Add stderr capture on pipeline
failure for better error messages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 16:02:29 +00:00
Smittix 765404fdc2 Fix dsd-fme support with correct protocol flags and ncurses disable
dsd-fme uses different protocol flags than classic dsd (e.g. -fs for
DMR instead of -fd, -f1 for P25 instead of -fp). Add -N flag to
disable ncurses terminal which is required when reading from stdin pipe.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 15:52:48 +00:00
Smittix 67fa196a28 Fix DSD voice decoder detection for dsd-fme and PulseAudio error
Check for dsd-fme binary (common fork) before falling back to dsd.
Disable audio output with -o /dev/null to prevent PulseAudio
connection failures when running under sudo.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 15:44:31 +00:00
Smittix 4e3f0ad800 Add DMR digital voice, WebSDR, and listening post enhancements
- DMR/P25 digital voice decoder mode with DSD-FME integration
- WebSDR mode with KiwiSDR audio proxy and websocket-client support
- Listening post waterfall/spectrogram visualization and audio streaming
- Dockerfile updates for mbelib and DSD-FME build dependencies
- New tests for DMR, WebSDR, KiwiSDR, waterfall, and signal guess API
- Chart.js date adapter for time-scale axes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 15:38:08 +00:00
Smittix 4c67307951 Add terrestrial HF SSTV mode with predefined frequencies and modulation support
Adds a general-purpose SSTV decoder alongside the existing ISS SSTV mode,
supporting USB/LSB/FM modulation on common amateur radio HF/VHF/UHF
frequencies (14.230 MHz USB, 3.845 MHz LSB, etc.) with auto-detection
of modulation from preset frequency table.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 15:36:41 +00:00
Marc 18aa7fe669 Merge branch 'main' of https://github.com/xdep/intercept 2026-02-06 09:12:23 -06:00
Marc 8409a4469d removing test script from root project folder 2026-02-06 09:09:03 -06:00
Device b75492ec18 Merge branch 'smittix:main' into main 2026-02-06 16:05:05 +01:00
Marc fef8db6c00 Adding more available bands for europe as testing fase 2026-02-06 08:39:26 -06:00
Marc a70502fb77 endpoints return empty results gracefully instead of 400 errors 2026-02-06 08:33:42 -06:00
Marc e8a9afa221 fixing bands and how the gsm scanner loops with tshark 2026-02-06 08:27:25 -06:00
Smittix 8fca54e523 Fix APRS rtl_fm startup failure and SDR device conflicts (#122)
Add SDR device reservation to prevent conflicts with other modes, and
capture rtl_fm stderr so actual error messages are reported to the user
instead of a generic exit code.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 13:50:09 +00:00
Marc 8e9588c4ff Added ARFCN to Frequency Conversion 2026-02-06 07:45:32 -06:00
Marc 7bc1d5b643 Fixing the process routes and child processes part 2 2026-02-06 07:39:04 -06:00
Marc ef14f5f1a1 Fixing the process routes and child processes 2026-02-06 07:32:47 -06:00
Marc 7caa7247ef Adding device detection for SDR 2026-02-06 07:28:47 -06:00
Marc 04d9d2fd56 First GSM SPY addition 2026-02-06 07:15:33 -06:00
Smittix b4742f205a Update listening post handling 2026-02-06 09:50:49 +00:00
Mitch Ross ff36687f53 Merge branch 'smittix:main' into claude/docker-dual-sdr-config-6yro9 2026-02-05 19:33:49 -05:00
Mitch Ross b860a4309b Add weather satellite auto-scheduler, polar plot, ground track map, and rtlamr Docker support
- Fix SDR device stuck claimed on capture failure via on_complete callback
- Improve SatDump output parsing to emit all lines (throttled 2s) for real-time feedback
- Extract shared pass prediction into utils/weather_sat_predict.py with trajectory/ground track support
- Add auto-scheduler (utils/weather_sat_scheduler.py) using threading.Timer for unattended captures
- Add scheduler API endpoints (enable/disable/status/passes/skip) with SSE event notifications
- Add countdown timer (D/H/M/S) with imminent/active glow states
- Add 24h timeline bar with colored pass markers and current-time cursor
- Add canvas polar plot showing az/el trajectory arc with cardinal directions
- Add Leaflet ground track map with satellite path and observer marker
- Restructure to 3-column layout (passes | polar+map | gallery) with responsive stacking
- Add auto-schedule toggle in strip bar and sidebar
- Add rtlamr (Go utility meter decoder) to Dockerfile

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 19:32:12 -05:00
Mitch Ross f409222f8a Update Dockerfile 2026-02-05 17:16:49 -05:00
Mitch Ross 1c051933b7 Update Dockerfile 2026-02-05 17:12:33 -05:00
Claude c83a2ef56f Add antenna quick reference guides to all mode sidebar panels
Each SDR mode now includes frequency-specific antenna guidance:
- Pager: VHF/UHF dipole info for 153/929 MHz bands
- 433 MHz Sensors: quarter-wave ground plane for ISM band
- Utility Meters: 912 MHz stock antenna tips and upgrades
- APRS: 2m band dipole and commercial options for 144.39 MHz
- SSTV: V-dipole for ISS reception at 145.800 MHz
- AIS: marine VHF antenna for 162 MHz vessel tracking
- Listening Post: wideband discone recommendation with band table
- Meshtastic: LoRa 915/868 MHz antenna upgrades and placement
- ADS-B: 1090 MHz collinear, commercial options, LNA/placement

Each guide includes antenna type, element lengths, placement tips,
and a quick reference table with key specs for the mode.

https://claude.ai/code/session_01FjLTkyELaqh27U1wEXngFQ
2026-02-05 22:10:20 +00:00
Mitch Ross 6d1f8f022e Update CLAUDE.md 2026-02-05 17:07:39 -05:00
Claude 500ddf59fe Add multi-arch build support and detailed antenna guide
Multi-arch Docker builds:
- build-multiarch.sh: Cross-compile amd64+arm64 on x64 and push to
  registry, so RPi5 can docker pull instead of building natively
- docker-compose.yml: Add INTERCEPT_IMAGE env var to support pulling
  pre-built images from a registry instead of local build
- README.md: Docker build section rewritten with multi-arch workflow,
  registry pull instructions, and build script options

Weather satellite antenna guide (sidebar panel):
- V-Dipole: ASCII diagram, 53.4cm element length, 120 degree angle,
  materials, orientation, connection instructions
- Turnstile/Crossed Dipole: phasing coax length (37cm RG-58),
  reflector distance (52cm below), RHCP explanation
- QFH Quadrifilar Helix: design overview, materials, height (46cm),
  hemispherical gain pattern
- Placement & LNA: outdoor requirements, coax loss figures,
  LNA mounting position, Nooelec SAWbird+ recommendation, Bias-T
- Quick reference table: wavelength, quarter-wave, elevation,
  duration, polarization, APT/LRPT bandwidth

Also added Weather Satellites and ISS SSTV to README features list,
SatDump to acknowledgments.

https://claude.ai/code/session_01FjLTkyELaqh27U1wEXngFQ
2026-02-05 21:59:55 +00:00
Claude 5e4be0c279 Enable persistent data volume mount for Docker services
Uncomment and enable the ./data:/app/data volume mount on both the
basic and history service profiles. This persists decoded weather
satellite images, the SQLite database, and other data across
container rebuilds. Critical for Docker-only deployments.

https://claude.ai/code/session_01FjLTkyELaqh27U1wEXngFQ
2026-02-05 21:55:44 +00:00
Smittix 16f730db76 Merge pull request #97 from JonanOribe/fix-libs
Add optionals group to pyproject.toml and sync tests
2026-02-05 21:52:38 +00:00
Smittix 958d8d5f20 Add missing scapy to optionals group and fix missing newline at EOF
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 21:52:30 +00:00
Claude 7b68c19dc5 Add weather satellite decoder for NOAA APT and Meteor LRPT
New module for receiving and decoding weather satellite images using
SatDump CLI. Supports NOAA-15/18/19 (APT) and Meteor-M2-3 (LRPT)
with live SDR capture, pass prediction, and image gallery.

Backend:
- utils/weather_sat.py: SatDump process manager with image watcher
- routes/weather_sat.py: API endpoints (start/stop/images/passes/stream)
- SSE streaming for real-time capture progress
- Pass prediction using existing skyfield + TLE data
- SDR device registry integration (prevents conflicts)

Frontend:
- Sidebar panel with satellite selector and antenna build guide
  (V-dipole and QFH instructions for 137 MHz reception)
- Stats strip with status, frequency, mode, location inputs
- Split-panel layout: upcoming passes list + decoded image gallery
- Full-size image modal viewer
- SSE-driven progress updates during capture

Infrastructure:
- Dockerfile: Add SatDump build from source (headless CLI mode)
  with runtime deps (libpng, libtiff, libjemalloc, libvolk2, libnng)
- Config: WEATHER_SAT_GAIN, SAMPLE_RATE, MIN_ELEVATION, PREDICTION_HOURS
- Nav: Weather Sat entry in Space group (desktop + mobile)

https://claude.ai/code/session_01FjLTkyELaqh27U1wEXngFQ
2026-02-05 21:45:33 +00:00
Smittix 88f71c9b5e Fix updater settings panel error when updater.js is blocked
Add defensive typeof checks before referencing the Updater global in
loadUpdateStatus() and checkForUpdatesManual() so the settings panel
shows a helpful message instead of crashing. Also swap script load
order so updater.js loads before settings-manager.js.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 20:57:10 +00:00
Claude 780ba9c58b Update Docker config for dual-SDR setup and arm64 compatibility
- Add slowrx SSTV decoder build with required deps (libsndfile1,
  libgtk-3-dev, libasound2-dev, libfftw3-dev) for arm64/RPi5 support
- Enable USB device passthrough (/dev/bus/usb) on both service profiles
- Add 'basic' profile to main intercept service for explicit selection
- Fix intercept-history container_name conflict (was duplicating 'intercept')

https://claude.ai/code/session_01FjLTkyELaqh27U1wEXngFQ
2026-02-05 19:46:54 +00:00
Smittix 079ed216a8 Make Detected Threats panel items clickable to show device details
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 17:14:39 +00:00
Smittix 337c25f66b Use WiFi scanner singleton for TSCM device availability check
Replace fragile platform-specific WiFi detection with the same
scanner._detect_interfaces() used by the actual scanning code,
eliminating false "No wireless interfaces found" warnings.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 17:12:44 +00:00
Smittix eabb6b2951 Fix TSCM WiFi detection, SDR capabilities, layout, and correlation/cluster emission
- Use networksetup instead of deprecated airport utility for macOS WiFi detection
- Fix SDRDevice attribute access (use getattr instead of dict .get())
- Move Detected Threats panel next to RF Signals in 2-column grid
- Always run correlation/identity analysis at sweep end, even if stopped by user

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 16:41:10 +00:00
Smittix 5d4b19aef2 Fix TSCM sweep scan resilience and add per-device error isolation
The sweep loop's WiFi/BT/RF scan processing had unprotected
timeline_manager.add_observation() calls that could crash an entire
scan iteration, silently preventing all device events from reaching
the frontend. Additionally, scan interval timestamps were only updated
at the end of processing, causing tight retry loops on persistent errors.

- Wrap timeline observation calls in try/except for all three protocols
- Move last_*_scan timestamp updates immediately after scan completes
- Add per-device try/except so one bad device doesn't block others
- Emit sweep_progress after WiFi scan for real-time status visibility
- Log warning when WiFi scan returns 0 networks for easier diagnosis
- Add known_device and score_modifier fields to correlation engine
- Add TSCM scheduling, cases, known devices, and advanced WiFi indicators

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 16:07:34 +00:00
Smittix 11941bedad Swap ISS position API priority to avoid timeout delays
Open Notify API (api.open-notify.org) is frequently unreliable,
causing 5-second timeout delays on every ISS position request.
Promote wheretheiss.at as the primary API in both satellite.py
and sstv.py, demoting Open Notify to fallback.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 12:50:07 +00:00
Smittix 8ba47f3935 Fix radar blip flicker by deferring renders during hover
The innerHTML rebuild on every SSE event was destroying and recreating
DOM elements under the cursor, causing rapid mouseenter/mouseleave
cycling. Now defers DOM rebuilds while hovering and debounces rapid
update calls with a 200ms window.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 23:43:19 +00:00
Smittix 9dd8849b21 Fix proximity radar tooltip flicker on hover
Separate SVG translate positioning from CSS hover scale by nesting
device elements in two groups, preventing the CSS transform from
overriding the position and causing rapid mouseenter/mouseleave cycling.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 23:40:14 +00:00
Smittix 725d95c079 Update changelog and welcome page for v2.13.1
Add missing entries for v2.12.1, v2.13.0, and v2.13.1 to
CHANGELOG.md. Update config.py CHANGELOG highlights to reflect
UI overhaul, signal scanner rewrite, and WiFi client fix.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 23:37:04 +00:00
Smittix c5bd13ea52 Filter WiFi connected clients by selected access point
The /wifi/v2/clients endpoint was returning all clients regardless
of query parameters, because a duplicate route in wifi.py took
precedence over the filtered one in wifi_v2.py. Added bssid,
associated, and min_rssi filtering to the active route.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 23:31:54 +00:00
Smittix 9ecad43f76 Fix USB device contention when starting audio pipeline
Add retry mechanism (3 attempts) for usb_claim_interface errors when
the SDR device hasn't been fully released by a previous process. Also
kill rtl_power alongside rtl_fm during cleanup and increase the USB
release delay.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 23:13:22 +00:00
Smittix 953e94da44 Add SNR column to signal hits table
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 23:01:20 +00:00
Smittix 805fc69281 Set cyan-tinted map tiles as default 2026-02-04 22:31:02 +00:00
Smittix d620618bb8 Revamp UI styling to slate/cyan 2026-02-04 22:12:25 +00:00
Smittix 6c358fbfad Hide controls bar scrollbar
Change overflow-x: auto to overflow: hidden to remove
the unnecessary horizontal scrollbar.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 21:09:31 +00:00
Smittix a5599eb0d0 Use margin-top auto to push control items to bottom
More robust approach:
- align-items: stretch !important on controls-bar
- margin-top: auto on control-group-items to push to bottom
- Specific selector for controls-bar > control-group

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 21:06:51 +00:00
Smittix a8d25f9c01 Fix controls bar alignment with stretch + space-between
Use align-items: stretch on controls-bar to make all control
groups the same height, and justify-content: space-between on
control-group to push content to top/bottom within each box.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 20:58:07 +00:00
Smittix a09793b6ec Use flex-end alignment for controls bar bottom alignment
Change from stretch to flex-end to ensure control group
bottom edges stay aligned regardless of varying heights.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 20:53:59 +00:00
Smittix 675a3cdbfb Fix controls bar alignment in dashboard pages
Change align-items from center to stretch so control groups
of varying heights align at top and bottom instead of floating.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 20:28:09 +00:00
Smittix abc51a0dad Create CNAME 2026-02-04 20:08:08 +00:00
Smittix 24332a4e23 Release v2.13.1 - Help modal and navigation improvements
- Add help modal system with keyboard shortcuts reference
- Add Main Dashboard button in navigation bar
- Make settings modal accessible from all dashboards
- Dashboard CSS improvements and consistency fixes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 20:05:07 +00:00
Smittix ebc5754684 Update version in pyproject.toml to 2.13.0
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 17:48:09 +00:00
Smittix 340b300aa4 Release v2.13.0 - WiFi client display in AP detail drawer
Features:
- Display connected clients for access points in detail drawer
- Real-time client updates via SSE streaming
- Client cards show MAC, vendor, RSSI, probed SSIDs, and last seen
- Count badge in Connected Clients header

Other changes:
- Updated aircraft database
- CSS and template refinements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 17:44:23 +00:00
Smittix bf7026cc9f Merge branch 'codex/new-ui'
# Conflicts:
#	static/css/index.css
2026-02-04 15:11:24 +00:00
Smittix 1b04b52509 Sync scanner range from backend updates 2026-02-04 13:25:14 +00:00
Smittix fca334f472 Sync scanner range with backend config 2026-02-04 13:14:42 +00:00
Smittix d81d644319 Prefer progress data for scanner sweep 2026-02-04 13:11:02 +00:00
Smittix 400cf1114f Use frequency-based sweep display 2026-02-04 12:48:40 +00:00
Smittix fec38adc78 Stabilize scanner progress tracking 2026-02-04 12:42:30 +00:00
Smittix 993a7d2626 Stabilize sweep display and lower SNR default 2026-02-04 12:30:13 +00:00
Smittix dbe09411ac Stabilize sweep progress updates 2026-02-04 12:20:38 +00:00
Smittix 0afc47fcdd Ignore out-of-order scan updates 2026-02-04 12:17:36 +00:00
Smittix 4862b285a8 Order sweep updates to avoid progress jitter 2026-02-04 12:14:46 +00:00
Smittix 41dd1555d7 Emit sweep progress and clear scanner queue 2026-02-04 12:11:50 +00:00
Smittix 0cf3a25ac6 Ensure scanner releases SDR before listening 2026-02-04 12:07:30 +00:00
Smittix 3674b6e2d6 Stop rtl_power when starting listen 2026-02-04 12:04:50 +00:00
Smittix 4c9bcb00c3 Improve rtl_power line parsing 2026-02-04 12:03:01 +00:00
Smittix 2067d0bf84 Default squelch to zero and track SDR usage 2026-02-04 11:59:06 +00:00
Smittix c0fa59d10e Add SNR threshold control for power scan 2026-02-04 11:54:56 +00:00
Smittix 37add84d59 Switch scanner to rtl_power sweep 2026-02-04 11:52:39 +00:00
Smittix c23019b8c0 Advance scanner after dwell on signal 2026-02-04 11:44:19 +00:00
Smittix b4edd35f5f Tighten listening signal detection thresholds 2026-02-04 11:41:30 +00:00
Smittix 812f85b9a9 Log only interesting listening signals 2026-02-04 11:37:15 +00:00
Smittix 77888b7d88 Align scanner audio stream start 2026-02-04 11:27:10 +00:00
Smittix 4a38d7512d Align listening action button styles 2026-02-04 11:23:32 +00:00
Smittix 5d0df18dac Silence listen slow-start log 2026-02-04 11:19:44 +00:00
Smittix d18e38800e Retry listen playback without fallback 2026-02-04 11:12:46 +00:00
Smittix 76e595aaec Prompt user to enable audio playback 2026-02-04 11:10:34 +00:00
Smittix dfb9897fa1 Trigger user-initiated audio play on listen 2026-02-04 11:09:04 +00:00
Smittix 82ad784fcb Restart audio pipeline for fresh stream header 2026-02-04 11:04:43 +00:00
Smittix 4bd7077d64 Add listening audio probe diagnostics 2026-02-04 11:02:00 +00:00
Smittix 3f6b9cc5ef Force squelch open for listen audio 2026-02-04 11:00:20 +00:00
Smittix 0742647571 Stream listening audio as WAV 2026-02-04 10:56:57 +00:00
Smittix 33090419df Timeout audio stream if no first chunk 2026-02-04 10:53:03 +00:00
Smittix 4042d0e5f1 Allow listening audio endpoints without login 2026-02-04 10:46:49 +00:00
Smittix d3a0b41fba Flush ffmpeg audio stream packets 2026-02-04 10:06:45 +00:00
Smittix 2fefea5618 Add listening audio debug endpoint 2026-02-04 10:03:47 +00:00
Smittix d75f7c794f Retry listening audio stream fetch 2026-02-04 10:01:58 +00:00
Smittix 503b91ea87 Add fetch stream fallback for listening audio 2026-02-04 09:49:14 +00:00
Smittix 43db7c309d Add WebSocket audio fallback for listening 2026-02-04 09:46:34 +00:00
Smittix 6e57927409 Force audio stream load on listen 2026-02-04 09:39:47 +00:00
Smittix a404f5ded9 Send SDR settings for listening audio 2026-02-04 09:31:07 +00:00
Smittix f6a6aab623 Update URL on mode switch 2026-02-04 09:26:29 +00:00
Smittix 2cfbc0addc Apply JetBrains Mono tokens to standalone pages 2026-02-04 01:15:18 +00:00
Smittix 07d6ef984e Switch app font to JetBrains Mono 2026-02-04 01:10:42 +00:00
Smittix 50227ccae6 Use Terminus font across app 2026-02-04 00:56:22 +00:00
Smittix 8f3c636c61 Fix mode query routing from dashboard nav 2026-02-04 00:49:54 +00:00
Smittix 42761bbdbc Add global nav dropdown behavior 2026-02-04 00:47:05 +00:00
Smittix 0f2eba302c Add global nav styles 2026-02-04 00:45:00 +00:00
Smittix 83dd58721f Wire global navbar across pages 2026-02-04 00:37:41 +00:00
Smittix d658d0b81e Refine UI to clean professional style 2026-02-04 00:21:52 +00:00
Smittix e04113628a Fix dual scrollbar issue on main dashboard
Add overflow: hidden to html and body elements to prevent browser
window scrollbar while keeping internal content areas scrollable.

Fixes #119

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 00:10:47 +00:00
Smittix b1e92326b6 Fix multiple UI bugs and improve error handling
Issues fixed:
- #113: Display RTL-SDR serial numbers in device selector
- #112: Kill all processes now stops Bluetooth scans
- #111: BLE device list no longer overflows container bounds
- #109: WiFi scanner panels maintain minimum width (no more "imploding")
- #108: Radar device hover no longer causes violent shaking
- #106: "Use GPS" button now uses gpsd for USB GPS devices
- #105: Meter trend text no longer overlaps adjacent columns
- #104: dump1090 errors now provide specific troubleshooting guidance

Changes:
- app.py: Add Bluetooth cleanup to /killall endpoint
- routes/adsb.py: Parse dump1090 stderr for specific error messages
- templates/index.html: Show SDR serial numbers in device dropdown
- static/css/index.css: Fix WiFi/BT panel layouts with proper min-width
- static/css/components/signal-cards.css: Fix meter grid overflow
- static/css/components/proximity-viz.css: Fix radar hover transform
- static/css/settings.css: Add GPS detection spinner
- static/js/components/proximity-radar.js: Add invisible hit areas
- static/js/core/settings-manager.js: Use gpsd before browser geolocation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 21:45:40 +00:00
Smittix 9ac63bd75f Add application restart endpoint for post-update restarts
Adds POST /updater/restart endpoint that gracefully restarts the
application using os.execv. Cleans up all decoder processes and
global state before replacing the process with a fresh instance.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 22:28:32 +00:00
Smittix f795180c7d Release v2.12.1
Bug fixes and improvements.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 22:16:12 +00:00
Smittix d1f1ce1f4b Add SDR device status panel and ADS-B Bias-T toggle
- Add /devices/status endpoint showing which SDR is in use and by what mode
- Add real-time status panel on main dashboard with 5s auto-refresh
- Add Bias-T toggle to ADS-B dashboard with localStorage persistence
- Auto-detect correct dump1090 bias-t flag (--enable-biast vs unsupported)
- Standardize SDR device labels across all pages

Closes #102

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 21:36:27 +00:00
Smittix 334073089f Fix SDR device type not synced on page refresh
Initialize currentDeviceList from server-provided deviceList on page load
and auto-select the correct hardware type dropdown value. Previously the
device list was empty until "Refresh Devices" was clicked, causing the
hardware type dropdown to show incorrect values.

Fixes #99

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 21:26:23 +00:00
Smittix df634dc741 Fix Meshtastic connection type not restored on page refresh
Pass connection_type to updateConnectionUI() in checkStatus() so TCP
connections display correctly after browser refresh instead of defaulting
to Serial.

Fixes #98

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 21:23:56 +00:00
Smittix a76dfde02d Add SDR device registry to prevent decoder conflicts
Implements centralized tracking of SDR device allocation to prevent
multiple decoders from trying to use the same device simultaneously.

- Add sdr_device_registry with claim/release/status functions in app.py
- Update all SDR-based routes to claim devices on start and release on stop
- Return HTTP 409 with DEVICE_BUSY error when device is already in use
- Clear registry on /killall
- Skip device claims for remote connections (rtl_tcp, remote SBS)

Fixes #100
Fixes #101

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 21:05:21 +00:00
Jon Ander Oribe cc5ccf75a2 Add optionals group to pyproject.toml and sync tests
Introduces an 'optionals' dependency group in pyproject.toml. There was a discrepancy because they had been added to requirements.txt at some point during the last few commits but not to .toml. Update on test_requirements.py to include and validate these optional dependencies. Enhances test logic to ensure all main, dev, and optional dependencies are checked for environment consistency.
2026-02-01 17:03:00 +01:00
Smittix 36f8349bc7 Merge pull request #96 from alphafox02/fix-agent-bugs
Fix agent mode issues and WiFi deep scan polling
2026-02-01 12:57:02 +00:00
cemaxecuter 130a3a2d8e Don't stop agent scans when switching agents
When switching between agents in the UI, only stop the UI polling -
don't send a stop command to the agent. Agent scans should continue
running independently. When switching back, checkScanStatus() will
detect the running scan and resume polling.
2026-01-31 08:59:30 -05:00
cemaxecuter bd6fa27970 Detect existing monitor mode when loading agent interfaces
When refreshing agent WiFi interfaces, check if any interface has
type='monitor' and automatically set the monitor status to Active.
Previously the UI only showed Active when monitor was explicitly
enabled via the button.
2026-01-31 08:55:05 -05:00
cemaxecuter 630bc2971a Fix WiFi deep scan polling on agent - normalize scan_type value
Agent returns scan_type 'deepscan' but UI expected 'deep', causing the
polling to immediately stop when checking scan status on agent switch.
Now normalizes 'deepscan' to 'deep' in checkScanStatus.
2026-01-31 08:51:17 -05:00
cemaxecuter 7182f7803a Auto-refresh agent capabilities after monitor mode toggle
When monitor mode is toggled on a remote agent, the controller now
automatically refreshes the agent's capabilities and updates the
database. This keeps the UI interface list in sync without requiring
a manual refresh.
2026-01-31 08:48:32 -05:00
cemaxecuter a64a7c414c Invalidate capabilities cache after monitor mode toggle
After enabling/disabling monitor mode, clear the cached capabilities
so the next refresh shows the updated interface list (e.g., wlo1mon
instead of wlo1).
2026-01-31 08:17:07 -05:00
cemaxecuter f0cc396a6b Fix agent mode issues and WiFi deep scan polling
Agent fixes:
- Fix Ctrl+C hang by running cleanup in background thread
- Add force-exit on double Ctrl+C
- Improve exception handling in output reader threads to prevent
  bad file descriptor errors on shutdown
- Reduce cleanup timeouts for faster shutdown

Controller/UI fixes:
- Add URL validation for agent registration (check port, protocol)
- Show helpful message when agent is unreachable during registration
- Clarify API key field label (reserved for future use)
- Add client-side URL validation with user-friendly error messages

WiFi agent mode fixes:
- Add polling fallback for deep scan when push mode is disabled
- Polls /controller/agents/{id}/wifi/data every 2 seconds
- Detect running scans when switching to an agent
- Fix scan_mode detection (agent uses params.scan_type)
2026-01-31 08:10:32 -05:00
Smittix 5f588a5513 fix: Auto-detect RTL-SDR drivers and blacklist instead of prompting
- Skip RTL-SDR Blog driver prompt if rtl_test already exists
- Skip DVB blacklist prompt if blacklist file already exists
- Only prompt user when configuration is actually needed

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 23:08:08 +00:00
Smittix 599df7734b fix: Use Makefile instead of CMake for slowrx build
slowrx uses a simple Makefile, not CMake. Remove unnecessary cmake
dependency and fix the build process.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 23:02:07 +00:00
Smittix 49fa02142d feat: Add TCP connection support for Meshtastic
Allow connecting to WiFi-enabled Meshtastic devices via TCP/IP in
addition to USB/Serial connections. This enables remote monitoring
of mesh nodes that have WiFi capability (T-Beam, Heltec WiFi LoRa, etc).

- Add connection_type parameter ('serial' or 'tcp') to /meshtastic/start
- Add hostname parameter for TCP connections
- Update UI with connection type dropdown and hostname input field
- Show connection type in status responses

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 23:01:46 +00:00
Smittix 333dc00ee2 fix: Show build errors and add pkg-config for slowrx source builds
- Add pkg-config dependency for cmake to locate libraries
- Display cmake/make error output (last 20 lines) on failure
- Helps users troubleshoot slowrx build failures on Debian/Ubuntu/macOS

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 22:55:28 +00:00
Smittix 2bc71e44ad Merge branch 'upstream-shared-observer-location' 2026-01-30 22:52:17 +00:00
Smittix 92265da5fb fix: Add slowrx source build fallback for Debian/Ubuntu
If slowrx is not available via apt, build from source with required
dependencies (libfftw3-dev, libsndfile1-dev, libgtk-3-dev, libasound2-dev,
libpulse-dev).

Matches the existing fallback pattern used for macOS.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 22:43:39 +00:00
Smittix 9c1516c086 feat: Add real-time Doppler tracking for ISS SSTV reception
- Add DopplerTracker class using skyfield for satellite tracking
- Calculate and apply Doppler shift correction (up to ±3.5 kHz at 145.800 MHz)
- Background thread monitors shift and retunes rtl_fm when >500 Hz drift
- New /sstv/doppler endpoint for real-time Doppler info
- Start endpoint accepts latitude/longitude for automatic tracking

Also:
- Add slowrx installation to setup.sh (source build for macOS, apt for Debian)
- Sync observer location to dashboard-specific localStorage keys

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 22:40:27 +00:00
Smittix cd7940bdc2 fix: Add TPMS pressure field mappings for 433MHz sensor display
The sensor field mapping only handled pressure_hPa (weather station
barometric pressure), causing TPMS tire pressure data to not display.

Added mappings for TPMS-specific rtl_433 field names:
- pressure_PSI (common in US TPMS sensors)
- pressure_kPa
- tire_pressure_kPa
- flags/state (tire state indicators)

Fixes #95

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 22:39:01 +00:00
James Ward 4a5f3e1802 docs: document shared location and auto-start env vars 2026-01-30 10:55:01 -08:00
James Ward 1b5bf4c061 fix: make ADS-B auto-start opt-in 2026-01-30 10:51:35 -08:00
James Ward 384d02649a feat: add shared observer location with opt-out 2026-01-30 10:49:53 -08:00
Smittix d51da40a67 Refactor settings modal HTML structure 2026-01-30 17:12:28 +00:00
Smittix 3a6bd3711e release: v2.12.0 - ISS SSTV decoder, update notifications, UI improvements
- Add ISS SSTV decoder mode with real-time tracking globe
- Add GitHub update notifications for new releases
- Enhance Meshtastic with QR codes and telemetry display
- Add new Space category for satellite modes
- Fix SoapySDR detection, dump1090 builds, and Flask compatibility
- Update version numbers and changelog

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:45:28 +00:00
Smittix d28d371caf feat: UI improvements and Space category
- Add new "Space" category with Satellite and ISS SSTV modes
- Rename "Scanner" to "Listening Post"
- SSTV now uses global SDR device selector
- Meshtastic map markers more visible (stronger glow, larger size)
- CSS layout fixes using flex instead of fixed heights

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:25:31 +00:00
Smittix 05d96b6077 fix: SoapySDR module detection on macOS with Homebrew
Set DYLD_LIBRARY_PATH and SOAPY_SDR_ROOT environment variables when
running SoapySDRUtil on macOS so Homebrew-installed modules (HackRF,
LimeSDR, etc.) are properly detected.

Fixes #77

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:25:25 +00:00
Smittix f6197592bb fix: Resolve dump1090 build failure in Docker
Remove -Werror flag and add explicit RTLSDR=yes to prevent build
failures on newer GCC versions in Docker builds.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:22:32 +00:00
Smittix aca7f56808 fix: Ensure Flask 3.0+ in setup script
System apt packages may install Flask 2.x which is incompatible with
Werkzeug 3.x. Add explicit upgrade after pip install to ensure Flask 3.0+.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:20:21 +00:00
Smittix 872cc806eb fix: Make psycopg2 optional for Flask/Werkzeug compatibility
- Bump Flask requirement to >=3.0.0 (required for Werkzeug 3.x)
- Make psycopg2 import conditional in routes/adsb.py and utils/adsb_history.py
- ADS-B history features gracefully disabled when PostgreSQL libs unavailable

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:19:14 +00:00
Smittix 7b847e0541 fix: Resolve dump1090 build failures on Kali/newer GCC
- Strip -Werror from FlightAware Makefile before building to prevent
  GCC warnings being treated as fatal errors (fixes spinner[4] issue)
- Replace abandoned antirez/dump1090 fallback with actively-maintained
  wiedehopf/readsb

Fixes #92

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 17:58:58 +00:00
Smittix 17b46a13c2 feat: Auto-update TLE data on app startup
- Add refresh_tle_data() function for reusable TLE updates
- Automatically fetch fresh TLE from CelesTrak when app starts
- Runs in background thread to avoid slowing down startup
- Includes NOAA-20 and NOAA-21 in name mappings
- Gracefully handles failures (uses cached data if offline)
- Existing /update-tle endpoint now uses shared function

This ensures satellite tracking data is always fresh, fixing
inaccurate positions caused by stale TLE data.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 17:25:08 +00:00
Smittix ede3a5841b fix: Use real-time APIs for ISS position in satellite tracking
- Add _fetch_iss_realtime() helper function for real-time ISS position
- Satellite position endpoint now uses real-time API for ISS specifically
- Other satellites still use TLE-based calculations
- ISS orbit track still calculated from TLE (for future/past positions)
- Falls back between Open Notify and Where The ISS At APIs

This ensures the satellite dashboard shows accurate ISS position
while maintaining TLE-based tracking for other satellites.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 17:20:16 +00:00
Smittix 7270f827a9 fix: Use real-time APIs for ISS position instead of stale TLE
- Fetch live ISS position from Open Notify API (primary)
- Fallback to "Where The ISS At" API if primary fails
- Remove dependency on potentially outdated local TLE data
- Calculate observer elevation/azimuth using spherical geometry
- Both APIs are free and don't require authentication

This fixes the issue where the ISS position was incorrect due to
the local TLE data being almost a year out of date.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 17:18:20 +00:00
Smittix 468812bc09 feat: Replace SSTV map with Leaflet for accurate ISS tracking
- Use real Leaflet map with proper tile layers (same as satellite section)
- ISS marker with pulsing glow animation
- Ground track orbit line showing ISS path
- Map auto-pans to follow ISS position
- Simplified overlay showing position and next pass info
- Responsive layout that adapts to screen size
- Removed custom canvas rendering and continent data

The Leaflet map uses the same tile provider as other sections,
ensuring the ISS position is accurately displayed on a real map.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 17:15:32 +00:00
Smittix 7bef63aede feat: Replace 3D globe with accurate 2D world map
- Use simple equirectangular projection for guaranteed accuracy
- Direct linear mapping: lon to x, lat to y (no complex 3D math)
- Show ISS ground track orbit path
- Continent outlines rendered on flat map
- Canvas changed to 300x150 for proper 2:1 aspect ratio
- Updated CSS for rectangular map styling

The 2D map uses a straightforward coordinate transformation
that cannot produce incorrect positions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 17:08:33 +00:00
Smittix 21dec0d53a fix: Correct globe projection orientation
- Fix x-axis mirroring for proper globe viewing orientation
- Adjust rotation formula to use lon - rotation instead of lon + rotation
- Globe now correctly shows landmasses relative to ISS position

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 16:53:31 +00:00
Smittix 52997b3c78 fix: Correct ISS position projection on globe
- Use actual ISS coordinates with globe rotation instead of fixed lon=0
- Fix orbit trail to use actual longitude offsets from ISS position
- Trail now properly follows behind ISS based on orbital path

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 16:48:10 +00:00
Smittix 765e1384b5 fix: Replace simplified globe continents with accurate geography
Add geographically accurate continent outlines including:
- North America with proper coastline detail (Alaska, Florida, Gulf of Mexico)
- Greenland, Iceland, UK/Ireland as separate landmasses
- Central and South America with accurate shapes
- Europe with Scandinavia separated
- Africa with Madagascar
- Middle East/Arabian Peninsula
- Asia with India, Southeast Asia, Korea, Japan, Taiwan
- Philippines and Indonesia archipelago
- Australia and New Zealand
- Sri Lanka

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 16:44:22 +00:00
Smittix e18f85370f feat: Add world map with continents to ISS tracking globe
- Added simplified continent outlines (N/S America, Europe, Africa, Asia, Australia)
- Proper 3D orthographic projection with rotation
- Globe rotates to center on ISS position
- Green landmasses on blue ocean background
- ISS shown in yellow/orange with orbit trail
- Lat/lon grid lines properly projected on sphere

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 16:37:54 +00:00
Smittix a0604a43c0 fix: Globe now rotates to always show ISS position
- Globe view centers on ISS longitude so it's always visible
- Added console logging for debugging position updates
- Increased ISS marker size and glow for better visibility

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 16:35:30 +00:00
Smittix 9cb44c6273 fix: Add direct ISS position endpoint for globe tracking
- Add /sstv/iss-position endpoint that calculates ISS position directly
- Update JS to use new endpoint instead of /satellite/position
- Returns lat, lon, altitude, and optionally elevation/azimuth from observer

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 16:31:35 +00:00
Smittix eacf6d4970 fix: Direct ISS pass calculation instead of test_client
The test_client approach was failing silently. Now calculates ISS
passes directly using skyfield within the sstv route.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 16:29:15 +00:00
Smittix 07ae227cee feat: Add ISS tracking globe and location controls to SSTV mode
- Update TLE data with current orbital elements for accurate predictions
- Add location inputs (lat/lon) and GPS button to SSTV stats strip
- Add TLE update button to fetch latest orbital data from CelesTrak
- Add 3D globe visualization showing real-time ISS position
- Display ISS coordinates and altitude below globe
- Auto-refresh ISS position every 5 seconds
- Add NOAA-15, NOAA-18, NOAA-19 satellites to TLE data

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 16:22:24 +00:00
Smittix 18ef6218d8 fix: SSTV location settings and panel sizing
- Fix GPS button not working (pass button element to handler)
- Hide output element in SSTV mode to allow panels to fill space
- Add explicit height rules for SSTV panels to expand vertically

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 16:03:41 +00:00
Smittix 0c7ac816e9 feat: Add location settings for ISS pass predictions
- Add Location tab to settings modal with lat/lon inputs
- Add GPS detection button for auto-location
- Update SSTV to use saved location for ISS pass predictions
- Fix SSTV panels to use full screen width (remove max-width constraint)
- Improve ISS pass messages to guide users to location settings
- Add checked/last_check fields to update status response

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 15:36:37 +00:00
Smittix 8e204725b2 feat: Add ISS SSTV decoder mode
Add slow-scan television decoder for receiving images from ISS.
Includes new Space dropdown in navigation grouping Satellite and SSTV modes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 14:51:06 +00:00
Smittix 40acca20b2 feat: Add GitHub update notifications
- Check for new releases from GitHub API with 6-hour cache
- Show toast notification when updates are available
- Add Updates tab in settings for manual checks and preferences
- Support git-based updates with stash handling for local changes
- Persist dismissed versions to avoid repeated notifications

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 14:51:00 +00:00
Smittix ae804f92b2 feat: Enhance Meshtastic mode with QR code support
Add QR code generation for sharing Meshtastic channel configurations.
Add qrcode[pil] dependency for QR code generation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 14:50:53 +00:00
Smittix 0a6effccae fix: Pass bias-T setting to ADS-B and AIS dashboards
The bias-T checkbox on the main dashboard was not being passed to the
ADS-B and AIS tracking start requests. Added getBiasTEnabled() helper
to each dashboard that reads from shared localStorage, and updated all
start request bodies to include bias_t parameter.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 12:51:28 +00:00
Smittix 0cf73b1234 fix: Show disclaimer FIRST before welcome page
- Add inline script in <head> that checks localStorage before page renders
- If disclaimer not accepted, hide welcome page via injected CSS
- Show disclaimer modal on DOMContentLoaded
- After accepting, remove gate CSS and reveal welcome page
- User must accept disclaimer before they can access the application

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 10:40:15 +00:00
Smittix 8d354755f0 revert: Remove utility bar and fix disclaimer flash issue
Reverts the utility bar feature and disclaimer timing changes that
caused the disclaimer to flash on screen for users who had already
accepted it.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 10:31:39 +00:00
Smittix 166f598386 feat: Fix disclaimer timing and add utility bar to dashboards
- Show disclaimer BEFORE welcome page on first visit (was showing after)
- Add shared utility-bar.html partial with theme, animations, settings, help
- Include utility bar on Aircraft, Satellite, and Vessels dashboards
- Support ?settings=open and ?help=open URL params from dashboards

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 10:27:40 +00:00
Smittix 6e51739654 fix: Remove CSS filter that was inverting dark map tiles
The CSS filter (invert + hue-rotate) was previously used to make light
OSM tiles appear dark. Now that we use actual dark CARTO tiles, this
filter was inverting them back to light. Removed from all dashboards.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 10:14:07 +00:00
Smittix ec22823e59 feat: Centralize map tile management via Settings manager
- Add Settings.registerMap() to register maps for tile updates
- Add Settings.createTileLayer() to create tile layers from settings
- Update _updateMapTiles() to use registered maps
- Expose all maps to window object for settings manager access
- All dashboards now use Settings manager when available
- Tile provider changes in settings now apply immediately to all maps
- Use Fastly CDN for CARTO tiles (more reliable)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 10:00:25 +00:00
Smittix 87cd10194f fix: Add cache-busting parameter to tile URLs
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 09:49:47 +00:00
Smittix 933575b480 fix: Remove {r} from CARTO tile URLs for proper dark mode
The {r} retina parameter was causing CARTO to return light/gray tiles
instead of dark tiles. Removed {r} from all tile layer URLs.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 09:44:51 +00:00
Smittix a4218c0c33 fix: Meshtastic traceroute button and dark mode maps
- Fix traceroute button in Meshtastic popups using event delegation
  instead of inline onclick handlers (more reliable with Leaflet)
- Update all maps to use dark CARTO tiles for consistency:
  - ADS-B dashboard radar map
  - AIS dashboard vessel map
  - Satellite dashboard ground map
  - APRS map
  - Satellite ground track map in main UI
- Change settings manager default tile provider to cartodb_dark

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 09:33:48 +00:00
Smittix c67fa39e30 feat: Add pulsating ring effect for tracked aircraft/vessels
Makes it much clearer which vehicle is being tracked on the map by adding
two animated concentric rings that pulse outward from the selected marker.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 09:07:47 +00:00
Smittix 9f7dc8f995 fix: Adjust ADS-B dashboard height to prevent bottom controls cutoff
Increased viewport height offset from 95px to 115px to account for the
actual combined height of header and stats strip elements.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 09:03:04 +00:00
Smittix d1dd1ad4da fix: Make audio visualizer work without spectrum canvas
The audio visualizer was returning early if audioSpectrumCanvas didn't
exist, preventing the signal level from being fed to the synthesizer.
Now it continues to update currentSignalLevel even without the canvas.

Also added detailed logging to diagnose audio context issues.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 23:05:08 +00:00
Smittix c7fdea856d debug: Add signal level logging to synthesizer
Adds console logging and on-canvas display of signal level values to
help diagnose why synthesizer isn't responding to signals.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 23:01:33 +00:00
Smittix a7307dbf3a fix: Initialize audio visualizer when listening starts
The audio visualizer (Web Audio API analyzer) was not being initialized
when direct listening or scanner signal detection started, so the
synthesizer never received audio level data.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 23:00:50 +00:00
Smittix 55ff644a8a fix: Connect synthesizer visualization to actual signal levels
The synthesizer was showing a decorative animation unrelated to actual
signals. Now it responds to real RMS levels from scanner SSE events and
Web Audio API data during direct listening.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 22:57:24 +00:00
Smittix 3d90e03ca9 feat: Add Meshtastic telemetry display and traceroute visualization
Add full telemetry display in node popups including device metrics
(voltage, channel utilization, air TX) and environment sensors
(temperature, humidity, barometric pressure).

Add traceroute functionality with interactive visualization showing
hop paths and SNR values. Includes API endpoints for sending traceroutes
and retrieving results, plus a modal UI for displaying route information.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 22:52:19 +00:00
Smittix 069e87f9ba feat: Add GPS auto-connect for AIS dashboard via gpsd
Automatically connects to gpsd on page load if available. Updates
observer location in real-time with GPS indicator in top bar.
Includes auto-reconnect on visibility change.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 22:35:49 +00:00
Smittix f3c5d124b5 fix: Sync Meshtastic node count between map and top bar
The map was showing correct node count from API while the top bar
showed 0 because uniqueNodes Set was only populated from messages.
Now loadNodes() adds nodes to uniqueNodes and updates stats.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 22:35:44 +00:00
Smittix d821e19334 fix: Add serial port discovery for Meshtastic multi-port systems
When multiple serial ports are detected (e.g., /dev/ttyACM0 and /dev/ttyUSB0),
the Meshtastic SDK's auto-detect fails. This adds a /meshtastic/ports endpoint
to list available ports and populates the device dropdown, auto-selecting the
first port when multiple exist.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 22:24:00 +00:00
Smittix d15b4efc97 feat: Add meter grouping by device ID with consumption trends
Transform flat scrolling meter list into grouped view showing one card
per unique meter with:
- Consumption history tracking and delta from previous reading
- Trend sparkline visualization (color-coded for normal/elevated/spike)
- Consumption rate calculation (units/hour over 30-min window)
- Cards update in place instead of creating duplicates
- Alert sound only plays for new meters

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 21:56:43 +00:00
Smittix a3ad49a441 feat: Add device intelligence and manufacturer info for utility meters
- Add getMeterTypeInfo() with ERT endpoint type lookups for utility type
  (Electric/Gas/Water) and manufacturer (Itron, Landis+Gyr, Neptune, etc.)
- Hook addRtlamrReading into trackDevice() for Device Intelligence panel
- Add meter protocol handling to generateDeviceId()
- Display manufacturer and utility type on meter cards
- Show utility type as badge, manufacturer in meta row and details panel

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 21:26:18 +00:00
Smittix fb95e465a3 feat: Add logo link and fix welcome modal box heights
- Make logo clickable, opens GitHub Pages in new tab
- Match What's New box height to Select Mode box

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 20:45:17 +00:00
Smittix ab0a03b313 docs: Update main screenshot for v2.10.0
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 20:35:46 +00:00
Smittix f396ff7b66 feat: Add map marker highlighting for selected aircraft in ADSB
When clicking an aircraft in the sidebar, its map marker now shows
an enhanced white glow (10px) to distinguish it from other markers.
This matches the existing behavior in AIS mode.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 20:34:53 +00:00
Smittix 52cb47e5c9 refactor: Consolidate settings and dependencies into single modal
Merged the two gear icons in the header bar into one unified Settings modal.
Added a "Tools" tab to display dependency status, removing the separate
dependencies modal and button.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 20:30:08 +00:00
Smittix 003b44c62e docs: Update dashboard and main images for GitHub Pages
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 20:21:20 +00:00
Smittix 92caef5cb7 fix: Correct JetBrains status element ID in settings modal
The JavaScript checks for 'statusJetbrains' but the HTML had
'statusJetBrains' causing the status check to fail.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 20:16:15 +00:00
Smittix db304631f8 feat: Add Meshtastic, Ubertooth, and Offline Mode support
New Features:
- Meshtastic LoRa mesh network integration
  - Real-time message streaming via SSE
  - Channel configuration with encryption
  - Node information with RSSI/SNR metrics
- Ubertooth One BLE scanner backend
  - Passive capture across all 40 BLE channels
  - Raw advertising payload access
- Offline mode with bundled assets
  - Local Leaflet, Chart.js, and fonts
  - Multiple map tile providers
  - Settings modal for configuration

Technical Changes:
- New routes: meshtastic.py, offline.py
- New utils: ubertooth_scanner.py, meshtastic.py
- New CSS/JS for meshtastic and settings
- Updated dashboard templates with conditional asset loading
- Added context processor for offline settings

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 20:14:51 +00:00
Smittix eae1820fda feat: Add spinning globe background to welcome and login pages
Add animated SVG globe with rotating meridians as a subtle background
element on the welcome overlay and login pages.

Also removes unused signal-cards-mockup.html.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 23:24:50 +00:00
Smittix f70deb32a2 feat: Add back button to navigation on dashboard pages
Add browser history back button alongside existing dashboard links on
vessels, aircraft, network monitor, and remote agents pages.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 23:23:13 +00:00
Smittix 69eea1e895 docs: Add AIS vessel tracking screenshot to gallery
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 22:35:33 +00:00
Smittix bf4346b4ff docs: Add Remote Agents documentation and updated screenshots
- Update dashboard screenshot to v2.10.0
- Add Remote Agents screenshot to docs gallery
- Add Remote Agents feature card to GitHub Pages
- Add navigation links to DISTRIBUTED_AGENTS.md
- Add Remote Agents section to FEATURES.md and USAGE.md
- Link distributed agents docs from main README

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 22:32:28 +00:00
Smittix 7cde6a2068 fix: Improve Remote Agents page layout
- Fix header logo and title alignment using flexbox
- Move Refresh All button next to Register Agent button

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 22:21:20 +00:00
Smittix 84b424b02e feat: Add Meshtastic mesh network integration
Add support for connecting to Meshtastic LoRa mesh devices via USB/Serial.
Includes routes for device connection, channel configuration with encryption,
and SSE streaming of received messages.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 22:17:40 +00:00
Smittix 04b73596ea fix: Prevent sidebar section content from being cut off
Change .section overflow from hidden to visible so form elements
and buttons display fully within sidebar boxes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 22:16:21 +00:00
Smittix 3916276de8 Merge pull request #87 from alphafox02/feature/distributed-agents 2026-01-27 21:25:12 +00:00
cemaxecuter 077d46f319 Fix listen button not disabling when agent selected
- Add fallback direct DOM manipulation in agents.js selectAgent()
- Fix setListeningPostRunning to check agent mode before re-enabling button
- Add debug logging for button state changes
2026-01-27 11:44:30 -05:00
cemaxecuter a0fd6d9651 Disable listen button when any agent is selected 2026-01-27 11:31:20 -05:00
cemaxecuter 8d505eb848 Fix agent listening post step conversion from kHz to MHz 2026-01-27 11:27:58 -05:00
cemaxecuter 3f364f47e9 Fix listening post agent mode and UI sync
Agent scanner fixes:
- Use non-blocking I/O with select/fcntl to prevent blocking reads
- Pass dwell_time parameter through to scanner function
- Add freqs_scanned counter to status and data endpoints
- Improve SDR test process cleanup with kill() fallback

Frontend listening post fixes:
- Add setListeningPostRunning for UI sync when switching to agent
- Fix button ID (radioScanBtn not scannerStartBtn)
- Handle nested data structure from controller proxy
- Update freqs_scanned and signal_count from polling data
- Disable listen button for agent mode (audio can't stream over HTTP)

Add listening_post to agents.js uiSetters map for mode sync.

Live testing completed:
- Sensor mode: works via agent
- WiFi quick scan: works via agent
- Listening post: works via agent (AM airband, WFM broadcast tested)
- Signal detection: confirmed working via agent

Testing ongoing - modes not yet tested via agent:
- Pager, ADS-B, AIS, ACARS, APRS, DSC, RTL-AMR, TSCM, Bluetooth
2026-01-27 11:20:17 -05:00
cemaxecuter b92139f207 Add agent location selector to satellite dashboard
- Location dropdown in header to select observer position source
- Options: Local (browser GPS) or any registered agent with GPS
- Fetches agent GPS position via /controller/agents/{id}/status
- Satellite pass predictions calculated from agent's location
- Observer marker on map shows agent name in popup
- Status dot indicates GPS availability
2026-01-27 10:51:55 -05:00
cemaxecuter c7e9a0a493 Fix WiFi quick scan via agent and improve error messages
Agent fixes:
- Accept 'success' status for quick scans (not just 'started')
- WiFi quick scans return 'success' with results, not 'started'

Controller fixes:
- Pass through actual error messages from agent responses
- Previously showed generic "Agent returned error: 400"
- Now shows actual message like "Root privileges required for deep scan"
2026-01-27 10:42:29 -05:00
cemaxecuter 717dec4e54 Add agent ACARS f00b4r0 support and UI state sync
- Agent: Add _detect_acarsdec_fork() for f00b4r0/DragonOS support
- Agent: Use --output json:file, --rtlsdr, -m 256 for f00b4r0 fork
- UI: Add setAcarsRunning() to sync button state with agent
- UI: Add 'acars' to syncModeUI uiSetters map
2026-01-27 10:20:53 -05:00
cemaxecuter d3cb20cdae Support f00b4r0 acarsdec fork and fix ADS-B stop
ACARS (f00b4r0/DragonOS compatibility):
- Use --output json:file (not json:file:-) for stdout
- Use --rtlsdr instead of -r for device selection
- Use -m 256 for 3.2 MS/s sample rate (wider bandwidth for NA freqs)
- Properly detects fork by checking for --output in help

The f00b4r0 fork (used by DragonOS) has different CLI syntax than
TLeconte's original. Key differences:
  - TLeconte: -j -r <device>
  - f00b4r0:  --output json:file -m 256 --rtlsdr <device>

ADS-B stop fix:
- Add Content-Type header to stop fetch request
- Flask's request.json requires application/json content type
- Without this header, stop returns HTTP 415 and dump1090 keeps running
2026-01-27 10:10:32 -05:00
cemaxecuter 518da075de Support f00b4r0 acarsdec fork (DragonOS)
Add detection for f00b4r0/acarsdec which uses --output json:file:-
syntax instead of TLeconte's -j flag. Auto-detects fork by checking
for --output in help output.

Supports three acarsdec variants:
- TLeconte v4+: -j
- TLeconte v3.x: -o 4
- f00b4r0 (DragonOS): --output json:file:-
2026-01-27 09:55:57 -05:00
cemaxecuter fb31157fe9 Fix ADS-B dashboard for remote agents
- Fix device dropdown to use sdr_devices (same as agents.js fix)
- Keep dropdown/start button enabled in "All Agents" mode for control
- Disable airband controls for remote agents (audio not supported)
2026-01-27 09:54:08 -05:00
cemaxecuter a5f574062d Fix agent/local mode state sync and process cleanup
Agent fixes:
- Fix stop not killing secondary processes (pager_rtl, aprs_rtl, rtlamr_tcp)
- Modes using piped processes now properly terminate all child processes

UI state sync fixes:
- Add syncLocalModeStates() to check local status when switching to local
- Fix switchMode() to re-sync with agent/local when changing mode tabs
- Only stop local modes when actually in local mode
- UI now correctly reflects running state when switching agents or modes
2026-01-27 09:31:14 -05:00
cemaxecuter afccb6fe0a Fix agent mode UI state sync for pager, WiFi, and Bluetooth
- Fix device dropdown for agent mode by checking sdr_devices key
- Fix pager checkStatus() to use agent endpoint when in agent mode
- Fix WiFi checkScanStatus() to be agent-aware
- Fix Bluetooth checkScanStatus() to be agent-aware

These fixes prevent the UI from reverting to 'stopped' state when
the agent is actually running a mode.
2026-01-27 09:09:29 -05:00
cemaxecuter f916b9fa19 Add TSCM support to distributed agent with local mode parity
- Agent TSCM uses same ThreatDetector and CorrelationEngine as local mode
- Added baseline_id parameter support using get_tscm_baseline()
- Fixed RF scan stop_check to allow agent-specific stop events
- Fixed 'undefined MHz' display for WiFi devices (added essid fallback and null check)
- Fixed signal strength type conversion (string to int) for correlation engine
- Agent threat detection matches local mode behavior:
  - No baseline: detects anomaly/hidden_camera threats only
  - With baseline: also detects new_device threats
2026-01-27 08:47:02 -05:00
cemaxecuter d775ba5b3e Add real-time agent health monitoring and response utilities
Health Monitoring:
- Add /controller/agents/health endpoint for efficient bulk health checks
- Check all agents in one call with response time tracking
- Update agent status in real-time (30s interval)
- Show latency next to agent status in UI
- Add collapsible "All Agents Health" panel in sidebar
- Log console notifications when agents go online/offline

Response Utilities:
- Add unwrapAgentResponse() to consistently handle controller proxy format
- Add isAgentMode() and getCurrentAgentName() helpers
- Standardize error handling for agent responses

UI Improvements:
- Show response latency (ms) in agent selector dropdown
- Health panel shows status + running modes for each agent
- Better visual feedback for agent status changes
2026-01-26 12:19:20 -05:00
cemaxecuter 3372daca84 Add comprehensive agent mode tests and listening_post SDR check
- Add SDR availability check to listening_post mode startup
- Create tests/test_agent_modes.py with 29 comprehensive tests covering:
  - Mode lifecycle tests (start/stop for all modes)
  - SDR conflict detection (same device vs different device)
  - Process verification (immediate exit detection)
  - Data snapshot operations
  - Error handling (missing tools, invalid modes)
  - Cleanup verification (process termination, thread stopping)
  - Multi-mode simultaneous operation
  - GPS integration
2026-01-26 12:02:52 -05:00
cemaxecuter b72ddd7c19 Enhance distributed agent architecture with full mode support and reliability
Agent improvements:
- Add process verification (0.5s delay + poll check) for sensor, pager, APRS, DSC modes
- Prevents silent failures when SDR is busy or tools fail to start
- Returns clear error messages when subprocess exits immediately

Frontend agent integration:
- Add agent routing to all SDR modes (pager, sensor, RTLAMR, APRS, listening post, TSCM)
- Add agent routing to WiFi and Bluetooth modes with polling fallback
- Add agent routing to AIS and DSC dashboards
- Implement "Show All Agents" toggle for Bluetooth mode
- Add agent badges to device/network lists
- Handle controller proxy response format (nested 'result' field)

Controller enhancements:
- Add running_modes_detail endpoint showing device info per mode
- Support SDR conflict detection across modes

Documentation:
- Expand DISTRIBUTED_AGENTS.md with complete API reference
- Add troubleshooting guide and security considerations
- Document all supported modes with tools and data formats

UI/CSS:
- Add agent badge styling for remote vs local sources
- Add WiFi and Bluetooth table agent columns
2026-01-26 11:44:54 -05:00
cemaxecuter f980e2e76d Add distributed agent architecture for multi-node signal intelligence
Features:
- Standalone agent server (intercept_agent.py) for remote sensor nodes
- Controller API blueprint for agent management and data aggregation
- Push mechanism for agents to send data to controller
- Pull mechanism for controller to proxy requests to agents
- Multi-agent SSE stream for combined data view
- Agent management page at /controller/manage
- Agent selector dropdown in main UI
- GPS integration for location tagging
- API key authentication for secure agent communication
- Integration with Intercept's dependency checking system

New files:
- intercept_agent.py: Remote agent HTTP server
- intercept_agent.cfg: Agent configuration template
- routes/controller.py: Controller API endpoints
- utils/agent_client.py: HTTP client for agents
- utils/trilateration.py: Multi-agent position calculation
- static/js/core/agents.js: Frontend agent management
- templates/agents.html: Agent management page
- docs/DISTRIBUTED_AGENTS.md: System documentation

Modified:
- app.py: Register controller blueprint
- utils/database.py: Add agents and push_payloads tables
- templates/index.html: Add agent selector section
2026-01-26 06:14:42 -05:00
Smittix ada6d5f1f1 Merge pull request #86 from xdep/testing-branch 2026-01-25 19:44:31 +00:00
Marc 7c6416ac38 New svg style icons for the AIS vessel tracking map 2026-01-25 13:40:52 -06:00
Marc e833488425 JSON fix for AIS including latitude and longitude 2026-01-25 13:29:13 -06:00
Smittix 0b8863aaa9 Merge pull request #85 from xdep/main 2026-01-25 16:57:09 +00:00
Device 8d30c40fe2 Fixing the AIS-catcher parameter for data ingest
The -o 5 flag sets the console/stdout output format to JSON, but it does NOT configure the TCP server output format
2026-01-25 17:07:45 +01:00
Smittix d2f2c37531 docs: Update in-app help with new features
- Add Vessels/VHF DSC documentation to help modal
- Add Spy Stations mode to help modal
- Update Aircraft section to mention history feature
- Add Spy Stations icon to Mode Tab Icons

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 13:23:16 +00:00
Smittix b23a1636b0 Merge pull request #83 from JamesIOmete/local-fixes
Add ADS-B history persistence, session tracking, and reporting dashboard
2026-01-25 13:20:23 +00:00
Smittix a73a74d1fc docs: Simplify ADS-B history setup instructions
Update README to use Docker Compose profiles instead of environment variables.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 13:18:54 +00:00
Smittix d297f87115 fix: Make ADS-B history optional and add tests
- Use Docker Compose profiles to make Postgres optional
- Default `docker compose up` runs without history/Postgres
- Use `docker compose --profile history up` to enable history
- Add 11 unit tests for AdsbHistoryWriter and AdsbSnapshotWriter

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 13:18:33 +00:00
Smittix 88537c1119 fix: Restore flask-limiter and Werkzeug version pins
- Restore flask-limiter>=2.5.4 version constraint
- Restore Werkzeug>=3.1.5 dependency
- Group psycopg2-binary under ADS-B history section

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 13:12:26 +00:00
James Ward 141b34391d Document ADS-B history setup and reporting 2026-01-25 13:12:09 +00:00
James Ward 8b4b440b22 Add ADS-B history persistence and reporting UI 2026-01-25 13:11:43 +00:00
James Ward 0cccf3c9dd Document local-fixes rebase workflow 2026-01-25 13:11:43 +00:00
James Ward e532f67c85 Add flask-limiter dependency and allow /health without login 2026-01-25 13:11:43 +00:00
Smittix 7a2b90055a Merge pull request #84 from xdep/testing-branch
feat: Add VHF DSC Channel 70 monitoring and decoding for vessels page
2026-01-25 13:06:32 +00:00
Smittix ab2d7bfe50 docs: Update version to 2.10.0 and document DSC features
- Bump version to 2.10.0 in config.py and pyproject.toml
- Add scipy/numpy dependencies for DSC signal processing
- Add CHANGELOG entry for 2.10.0 release
- Update README.md features list with vessel tracking
- Add AIS Vessel Tracking and VHF DSC section to FEATURES.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 13:06:06 +00:00
Smittix 1e2810b85c fix: Correct octal literal in DSC position decoder
Change `00` to `0` in quadrant check to avoid confusion with octal syntax.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 13:05:14 +00:00
Smittix 164887f8a4 test: Add comprehensive tests for DSC functionality
- Add parser tests for MMSI country lookup, distress codes, format codes
- Add decoder tests for MMSI/position decoding, bit conversion
- Add database tests for DSC alerts CRUD operations
- Include constants validation tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 13:05:14 +00:00
Marc b4d3e65a3d feat: Add VHF DSC Channel 70 monitoring and decoding
- Implement DSC message decoding (Distress, Urgency, Safety, Routine)

- Add MMSI country identification via MID lookup

- Integrate position extraction and map markers for distress alerts

- Implement device conflict detection to prevent SDR collisions with AIS

- Add permanent storage for critical alerts and visual UI overlays
2026-01-25 13:05:14 +00:00
Smittix 3b238c3c8f Merge pull request #82 from d3mocide/main
Enhance Docker environment with missing SDR dependencies
2026-01-24 20:51:10 +00:00
William Shields 93111b93c5 Enhance Docker environment with missing SDR dependencies
Added build and runtime dependencies for AIS-catcher, readsb (SoapySDR enabled), direwolf, and hcxtools. Included rx_tools build from source. Updated dependency checker to potentialy verify SoapySDR modules.
2026-01-24 12:37:20 -08:00
Smittix 6a63c13cd8 Update docs for v2.10.0: AIS vessel tracking and Spy Stations
- Add AIS Vessel Tracking and Spy Stations to GitHub Pages site
- Update mode count from 10+ to 12+
- Add feature sections to FEATURES.md
- Update README.md features list and acknowledgments
- Add AIS-catcher and Priyom.org to acknowledgments
- Bump version to 2.10.0 in config.py
- Update CHANGELOG.md with new release notes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 18:33:05 +00:00
Smittix 3518f7fede Merge branch 'main' of https://github.com/smittix/intercept 2026-01-24 18:12:57 +00:00
Smittix 79fc2871c9 Improve UI labels and pager filter responsiveness
- Rename "Scanner" to "Listening Post" and "RTLAMR" to "Meters" for clarity
- Change pager filter input from onchange to oninput for real-time filtering

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 18:12:32 +00:00
Smittix 2d21ce9303 Merge pull request #79 from xdep/testing-branch
Merge vessel-tracking: Add AIS-based vessel tracking support + Spy stations (Diplomatic + number stations)
2026-01-24 18:10:41 +00:00
Marc 28e63a1029 Couple small fixes like vessel icon and double stations text in spy page 2026-01-24 10:10:46 -06:00
Marc cbfe46201e Adding Spystations page and 2 small fixed for the vessel page 2026-01-24 07:37:51 -06:00
Marc 1b0d39c5b0 Adding spy stations aka the number stations including diplomatic stations 2026-01-24 04:22:32 -06:00
Device 446a8f14cb Merge branch 'smittix:main' into testing-branch 2026-01-24 00:41:56 +01:00
Marc 57d448c003 Adjustment to dashboard style and 500 error 2026-01-23 16:00:13 -06:00
Smittix eabc73ff49 Update GitHub Pages screenshots
Replace TSCM and Bluetooth screenshots with updated versions, add WiFi Scanner screenshot to gallery.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 18:55:01 +00:00
Marc f724421ce7 Adding Vessels 2026-01-23 06:02:54 -06:00
Smittix 9134195eb1 Delete CNAME 2026-01-22 06:32:30 +00:00
Smittix ee6971284c Merge bluetooth-overhaul: Fix Bluetooth/WiFi TSCM scanning issues
- Fix bytes conversion errors in multiple Bluetooth scanner modules
- Add monitor mode detection for WiFi interfaces
- Auto-use deep scan (airodump-ng) for monitor mode interfaces
- Fix is_known_tracker to handle hex string manufacturer data
- Add debug logging for TSCM Bluetooth scanning
2026-01-21 23:42:12 +00:00
Smittix 098fab6aca Fix is_known_tracker to handle hex string manufacturer data
The function now accepts both bytes and hex string formats for
manufacturer_data, converting hex strings to bytes before processing.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 23:39:38 +00:00
Smittix bc2b2bf23b Add full traceback to Bluetooth scan error logging 2026-01-21 23:38:21 +00:00
Smittix eb5bf55aad Fix bytes conversion in TSCM BLE scanner
Handle various data types safely when converting manufacturer_data
in the TSCM-specific BLE scanner module.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 23:36:10 +00:00
Smittix 17a0dddf61 Fix bytes conversion in fallback Bluetooth scanner
Handle various data types safely when converting manufacturer_data
and service_data in the bleak fallback scanner.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 23:31:21 +00:00
Smittix f6bd38e3dc Add debug logging for TSCM Bluetooth scanning
Helps diagnose why Bluetooth devices appear in Bluetooth section
but not in TSCM section.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 23:28:45 +00:00
Smittix 12db4f5178 Auto-detect monitor mode and use deep scan in TSCM WiFi scanning
When a monitor mode interface (e.g., wlan0mon) is detected, automatically
use airodump-ng deep scan instead of quick scan which doesn't work with
monitor mode interfaces.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 23:27:05 +00:00
Smittix f01502ff32 Fix Bluetooth bytes conversion and WiFi monitor mode detection
- Fix "cannot convert 'str' object to bytes" error in BLE identity engine
  by adding robust _convert_to_bytes() helper that handles bytes, hex
  strings, bytearrays, and arrays
- Improve DBus scanner to safely handle various data types for
  manufacturer_data and service_data with proper error handling
- Add monitor mode interface detection in WiFi scanner to provide clear
  error message when quick scan is attempted on monitor mode interface

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 23:25:22 +00:00
Smittix 54a47b03c2 Make tracker detection panel scrollable
Add max-height and overflow-y to btTrackerList for better UX when
multiple trackers are detected.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 23:18:07 +00:00
Smittix 537171d788 Add comprehensive BLE tracker detection with signature engine
Implement reliable tracker detection for AirTag, Tile, Samsung SmartTag,
and other BLE trackers based on manufacturer data patterns, service UUIDs,
and advertising payload analysis.

Key changes:
- Add TrackerSignatureEngine with signatures for major tracker brands
- Device fingerprinting to track devices across MAC randomization
- Suspicious presence heuristics (persistence, following patterns)
- New API endpoints: /api/bluetooth/trackers, /diagnostics
- UI updates with tracker badges, confidence, and evidence display
- TSCM integration updated to use v2 tracker detection data
- Unit tests and smoke test scripts for validation

Detection is heuristic-based with confidence scoring (high/medium/low)
and evidence transparency. Backwards compatible with existing APIs.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 23:16:18 +00:00
Smittix f665203543 Improve WiFi quick scan error handling and Linux tool fallback
- Add fallback mechanism to try multiple tools (nmcli -> iw -> iwlist)
- Improve error messages for iw/iwlist with root privilege detection
- Enhance nmcli scanner to try without interface if specific scan fails
- Better error reporting in frontend showing actual backend errors
- Add logging throughout scan process for debugging

This fixes quick scan immediately failing on Linux systems by trying
multiple tools and providing meaningful error messages.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 22:57:51 +00:00
Smittix dfd4b0e89e Add WiFi v2 API endpoints for dual-mode scanning
- Add v2 capabilities, quick scan, deep scan, and status endpoints
- Add v2 networks, clients, probes, and channels endpoints
- Add v2 SSE stream, export (CSV/JSON), and baseline management
- Add recommendation_rank field to ChannelRecommendation model

The frontend was already wired up to call these v2 endpoints but they
were missing from the backend. This completes the WiFi module v2 API.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 22:53:02 +00:00
Smittix 45c10a8593 Improve quick scan error handling and user feedback
Frontend (wifi.js):
- Show helpful message when quick scan returns no networks
- Suggest using Deep Scan as fallback
- Better error messages with actionable suggestions

Backend (scanner.py):
- Add proper error messages from airport scan failures
- Add proper error messages from nmcli scan failures
- Handle timeouts and missing tools explicitly
- Raise RuntimeError with descriptive messages

These changes help users understand when quick scan tools (airport/nmcli)
aren't working and guide them to use Deep Scan instead.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 22:48:25 +00:00
Smittix d929c30882 Fix channel chart not showing utilization data
- Add calculateChannelStats() in wifi.js to compute stats from networks
- Add fallback to calculate stats when API doesn't provide them
- Add syncLegacyToChannelChart() to sync legacy WiFi data to v2 chart
- Call sync function every 2 seconds when in WiFi mode

The channel chart now updates from both v2 API data and legacy WiFi scans.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 22:42:22 +00:00
Smittix 0ca3066cfc Add null checks for legacy WiFi UI elements
- Add null checks in updateChannelRecommendation for removed elements
- Add null checks in updateProbeAnalysis for counter elements
- Prevents TypeError when legacy functions run with v2 layout

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 22:35:57 +00:00
Smittix 1d30ea2708 Fix WiFi table columns and channel chart overflow
Table fixes:
- Add BSSID column header to match data columns
- Remove vendor column from table rows (6 columns total)
- Update placeholder colspan to 6

Layout fixes:
- Use minmax() for right columns to allow shrinking
- Add overflow handling to layout container
- Add min-width: 0 to analysis panel for proper grid behavior
- Add overflow-x: auto to channel chart container

Channel chart fixes:
- Reduce bar width from 20px to 14px
- Reduce bar spacing from 4px to 2px
- Reduce padding for more compact display
- Use viewBox for responsive SVG scaling

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 22:30:04 +00:00
Smittix 6ae21e9e24 Complete WiFi UI overhaul with 3-column layout
Frontend:
- Replace legacy WiFi panels with clean 3-column layout
- Add sortable networks table with filter buttons (All/2.4G/5G/Open/Hidden)
- Add proximity radar panel with zone summary (Near/Mid/Far)
- Add channel analysis panel with band tabs (2.4/5 GHz)
- Add security overview with color-coded counts
- Add slide-up detail drawer for selected networks
- Remove all legacy hidden elements

CSS:
- New wifi-layout-container with status bar
- Networks table with sticky header and row selection
- Responsive grid layout (3-col -> 2-col -> 1-col)
- Zone summary styling with color-coded counts
- Detail drawer with grid layout

JavaScript:
- Update cacheDOM with new element IDs
- Update updateDetailPanel to use drawer structure
- Update updateStats to populate security counts and zones
- Add closeDetail function for drawer

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 22:24:02 +00:00
Smittix 5843b3dcc5 Replace legacy WiFi visualizations with v2 components
- Replace Network Radar canvas with v2 Proximity Radar component
- Replace verbose channel bar wrappers with v2 Channel Analysis panel
- Add filter buttons (All/Hidden/Open) and zone summary to radar
- Add band tabs (2.4/5 GHz) to channel chart
- Hide legacy elements for backwards compatibility

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 22:13:53 +00:00
Smittix 1cd367332b Add v2 WiFi visualization panels and initialization
- Add proximity radar panel with filter buttons (All/Hidden/Open/Strong)
- Add zone summary display (Immediate/Near/Far)
- Add channel analysis panel with 2.4/5 GHz band tabs
- Initialize WiFiMode when switching to WiFi mode

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 22:10:30 +00:00
Smittix 9515f5fd7a Add unified WiFi scanning module with dual-mode architecture
Backend:
- New utils/wifi/ package with models, scanner, parsers, channel analyzer
- Quick Scan mode using system tools (nmcli, iw, iwlist, airport)
- Deep Scan mode using airodump-ng with monitor mode
- Hidden SSID correlation engine
- Channel utilization analysis with recommendations
- v2 API endpoints at /wifi/v2/* with SSE streaming
- TSCM integration updated to use new scanner (backwards compatible)

Frontend:
- WiFi mode controller (wifi.js) with dual-mode support
- Channel utilization chart component (channel-chart.js)
- Updated wifi.html template with scan mode tabs and export

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 22:06:16 +00:00
Smittix e22f464300 Create CNAME 2026-01-21 21:53:19 +00:00
Smittix 3d0c505178 Fix TSCM section: timeline, RF scanning, and UI defaults
- Fix Signal Timeline not receiving events by using SignalTimeline.create()
  for TSCM mode to maintain backward compatibility with addEvent() calls
- Lower RF detection thresholds for RTL-SDR compatibility (6dB margin,
  -90dBm floor instead of 10dB/-70dBm)
- Reduce RF scan interval from 60s to 30s for quicker feedback
- Enable RF/SDR checkbox by default to match WiFi and Bluetooth
- Update status message when no signals detected

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 21:04:16 +00:00
Smittix a1f8377dd4 Fix TSCM Bluetooth scanner function signature mismatch
The unified get_tscm_bluetooth_snapshot() no longer accepts a bt_interface
parameter as it handles interface selection internally.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 20:36:28 +00:00
Smittix 588556c2a6 Link device list and proximity radar selection
Clicking a device in the list or a dot on the radar now highlights
both - the list row gets selected styling and the radar dot shows
an animated pulsing cyan ring for clear visual feedback.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 20:29:01 +00:00
Smittix af078aaae0 Remove redundant Bluetooth stats icons from header
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 20:24:33 +00:00
Smittix 9dccbb95e8 Move Tracker Detection and Signal Distribution to left of radar
- Restructured layout to put side panels (Tracker Detection, Signal
  Distribution) on the left side of the Proximity Radar
- Side panels now stack vertically with fixed 220px width
- Radar takes remaining horizontal space
- Fixes radar being cut off at bottom
- Fixes signal distribution content being cut off

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 20:19:33 +00:00
Smittix 226f08f62d Remove Baseline section, fix filters and radar layout
- Removed Baseline section from Bluetooth sidebar (no longer needed)
- Fixed device filter buttons not working (changed display to '' instead
  of 'block' to preserve flexbox layout)
- Fixed proximity radar being cut off by bottom panels:
  - Added overflow: hidden to radar panel
  - Constrained bottom panels to max-height: 120px
  - Made radar content respect parent boundaries

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 20:15:47 +00:00
Smittix 85159cbc44 Make device detail panel static and compact
- Panel is now always visible with fixed 140px height
- Shows "Select a device to view details" placeholder when empty
- Clicking a device populates the panel without layout shifts
- More compact design:
  - Smaller fonts and padding throughout
  - Combined Min/Max RSSI into single field
  - 4x2 stats grid with minimal spacing
  - Services shown inline as comma-separated text
- Panel no longer pushes proximity radar when populated

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 20:08:44 +00:00
Smittix 201fce0125 Replace modal with inline device detail panel above proximity radar
- Added detail panel that appears above the radar when a device is clicked
- Shows comprehensive device information:
  - Large RSSI display with visual bar and range indicator
  - Protocol, status, and flag badges
  - 8-column stats grid: Manufacturer, Mfr ID, Address Type, Seen count,
    Min/Max RSSI, First/Last seen timestamps
  - Service UUIDs list (when available)
  - Copy Address button
- Selected device is highlighted in the device list
- Close button (×) to dismiss the panel
- Cyan accent border and gradient header for visual distinction

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 20:04:49 +00:00
Smittix 3b8d4f3f74 Redesign Bluetooth device list with compact row-based layout
- Reduced card height from ~130px to ~55px (2.5x more devices visible)
- Added left color strip indicating signal strength at a glance
- Added visual RSSI bar alongside the dBm value
- Condensed info into two lines:
  - Primary: Protocol badge, device name, RSSI bar+value, status dot
  - Secondary: MAC address, manufacturer, seen count
- Blue glowing dot for new devices, green dot for known
- Hover effect highlights the row
- Click still opens full device details modal

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 19:58:18 +00:00
Smittix 852d109468 Fix signal distribution bars not filling container height
Added more specific CSS selectors (.bt-signal-dist .signal-bar) to
override conflicting styles from the WiFi signal icon bars.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 19:46:49 +00:00
Smittix c5eb63ae7f Improve Bluetooth panel layout, signal bars, and add device filtering
- Rearranged layout: Proximity Radar on top, Tracker Detection and
  Signal Distribution side-by-side below for better space usage
- Made signal distribution bars thicker (16px) with gradient styling
  for better visibility
- Added device filtering with buttons: All, New, Named, Strong signal
- Filter buttons show filtered count (e.g., "5/37") when active

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 19:43:09 +00:00
Smittix b0ab361ead Remove Signal History, FindMy Network, and Device Activity from Bluetooth panel
These features were removed as they were not providing useful functionality:
- Signal History heatmap
- Apple FindMy Network detection
- Device Activity timeline

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 19:36:27 +00:00
Smittix 7b2e1caa47 Remove ineffective Device Types section from Bluetooth panel
The device type classification relied on pattern matching against device
names (e.g., looking for "iphone" or "macbook"), but most Bluetooth devices
don't advertise with human-readable names that match these patterns,
resulting in nearly all devices being categorized as "Other".

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 19:31:33 +00:00
Smittix 7957176e59 Add proximity radar visualization and signal history heatmap
Backend:
- Add device_key.py for stable device identification (identity > public MAC > fingerprint)
- Add distance.py with DistanceEstimator class (path-loss formula, EMA smoothing, confidence scoring)
- Add ring_buffer.py for time-windowed RSSI observation storage
- Extend BTDeviceAggregate with proximity_band, estimated_distance_m, distance_confidence, rssi_ema
- Add new API endpoints: /proximity/snapshot, /heatmap/data, /devices/<key>/timeseries
- Update TSCM integration to include new proximity fields

Frontend:
- Add proximity-radar.js: SVG radar with concentric rings, device dots positioned by distance
- Add timeline-heatmap.js: RSSI history grid with time buckets and color-coded signal strength
- Update bluetooth.js to initialize and feed data to new components
- Replace zone counters with radar visualization and zone summary
- Add proximity-viz.css for component styling

Tests:
- Add test_bluetooth_proximity.py with unit tests for device key stability, EMA smoothing,
  distance estimation, band classification, and ring buffer functionality

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 19:25:33 +00:00
Smittix bd7c83b18c Replace radar with zone counts and add device detail modal
- Remove problematic canvas-based radar visualization
- Add simple proximity zone counters (Very Close, Close, Nearby, Far)
- Remove Selected Device panel from HTML
- Add device detail modal with full info display
- Modal shows RSSI, badges, manufacturer, signal stats, timestamps
- Modal closes on overlay click, close button, or Escape key
- Add CSS for modal styling with blur backdrop
- Simplify card rendering (no selection highlighting needed)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 19:02:55 +00:00
Smittix 27a0e095a3 Simplify proximity visualization to fix flashing
- Remove double buffering and timers (overcomplicated)
- Use requestAnimationFrame for smooth batched updates
- Simplify to single deviceAngles map for persistent positions
- Only redraw when device data actually changes
- Dots persist as long as device is in the devices map
- Much simpler code path reduces chance of bugs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 18:51:57 +00:00
Smittix e19315819d Fix proximity visualization flicker with double buffering
- Add offscreen canvas for double buffering
- Draw all elements to offscreen canvas first
- Copy to visible canvas in single operation
- Increase update intervals (150ms throttle, 2s refresh)
- Eliminates flashing when visualization redraws

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 18:44:03 +00:00
Smittix 002afe3690 Replace radar with clean zone-based proximity visualization
- Replace heatmap with concentric distance zones (Very Close, Close, Nearby, Far)
- Each zone has distinct color coding and shows device counts
- Device dots persist with smooth fading for stale devices (30s threshold)
- Random angle distribution prevents dot overlap
- Glow effect on dots with color based on signal strength
- Periodic refresh timer keeps visualization smooth during inactive periods
- Throttled updates prevent performance issues during rapid scanning
- Center "YOU" marker with subtle glow effect
- Shows instructional text when idle

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 18:38:07 +00:00
Smittix 9e31bc65db Overhaul Bluetooth UI: heatmap, device selection, and detection panels
- Replace proximity radar with persistent heatmap visualization using radial gradients
- Change device card click to populate Selected Device panel instead of modal
- Fix Device Types panel with proper categorization (phones, computers, audio, wearables)
- Add tracker detection for AirTag, Tile, SmartTag, Chipolo patterns
- Add Apple FindMy Network detection using manufacturer ID 0x004C
- Fix Signal Distribution histogram with Close/Medium/Far/Weak bands
- Make Device Activity timeline collapsible and collapsed by default
- Add contextual "No data" messages for all empty panels

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 18:19:40 +00:00
Smittix 898410b225 Fix null check for manufacturer_id in modal
Changed !== null to != null to catch both null and undefined values.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 18:11:13 +00:00
Smittix fe28a91d5c Add device modal, visualization panels, and radar updates
Features added:
- Click-to-open modal with comprehensive device details
  - Signal strength with min/max/median/confidence stats
  - Device info grid (address, type, manufacturer)
  - Observation stats (first/last seen, count)
  - Service UUIDs display
  - Copy address button

- Live visualization panel updates:
  - Device Types (phones, computers, audio, wearables, other)
  - Signal Distribution (strong/medium/weak with bars)
  - Tracker Detection list
  - FindMy devices list

- Proximity Radar canvas:
  - Plots devices by RSSI (closer = nearer center)
  - Color-coded by signal strength
  - Glow effect for visibility

- Improved device name display:
  - Shows broadcast name if available
  - Falls back to formatted address (AA:BB:...:EE:FF)

- Cards now clickable with hover effect
- Stats recalculated on each device update

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 18:08:49 +00:00
Smittix be58c00bc7 Fix device card rendering with pure inline styles
- Remove all CSS class dependencies from device cards
- Use data-bt-device-id attribute instead of class-based selectors
- Add comprehensive inline styles to each element
- Change container from grid to block layout
- Add detailed console logging for debugging
- Remove potential CSS conflicts from .signal-card class

This isolates the card rendering from any CSS that might be
hiding content (like overflow:hidden on .signal-card).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 18:01:05 +00:00
Smittix 91b07fe797 Fix card rendering - use string concat instead of template literals
- Change card HTML generation from template literals to string concatenation
- This avoids potential issues with special characters in device data
- Also disable legacy handleBtDeviceImmediate when BluetoothMode exists
- Use device_id as fallback name if name is missing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 17:52:32 +00:00
Smittix bac7f8d55c Fix device card rendering - disable legacy code and fix CSS
- Disable legacy addBtDeviceCard when BluetoothMode is active
- Clear device container when starting scan to remove legacy cards
- Fix grid CSS with explicit auto height and align-items: start
- Add visibility rules for all card body elements
- Reset devices map when clearing container

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 17:47:09 +00:00
Smittix bb660d02f5 Fix device card rendering with robust defaults and CSS fixes
- Add explicit default values for all card template variables
- Add try/catch for JSON.stringify
- Add !important CSS rules to ensure card body visibility
- Use ID selector for btDeviceListContent grid layout
- Add console logging for debugging device data

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 17:31:48 +00:00
Smittix e3d9349d4b Improve Bluetooth device card layout and modal
- Remove Details dropdown from device cards for cleaner look
- Add grid layout for device cards (responsive, auto-fill columns)
- Enhanced modal with full device details:
  - Large RSSI display with sparkline
  - Signal statistics (median, min, max, confidence)
  - Device info grid (address, type, protocol, manufacturer)
  - Observation timeline (first/last seen, count, rate)
  - Service UUIDs list
  - Behavioral analysis heuristics
- Copy JSON and Copy Address buttons in modal footer
- Escape key closes modal
- Responsive design for mobile

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 17:12:48 +00:00
Smittix 78642bcbb2 Fix device card rendering - handle DOM element not HTML string
DeviceCard.createDeviceCard() returns a DOM element, not an HTML string.
Use replaceWith() and prepend() instead of outerHTML and insertAdjacentHTML.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 17:04:46 +00:00
Smittix 48e3bf210a Fix Bluetooth device container - use btDeviceListContent instead of output
The Bluetooth mode uses its own layout container (btLayoutContainer) which
contains btDeviceListContent for device cards. The output element is hidden
for Bluetooth mode. Also adds device count updates and clears placeholder
when scanning starts.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 17:01:21 +00:00
Smittix e9d5fe35fb Add debug logging to Bluetooth frontend 2026-01-21 16:00:21 +00:00
Smittix 66f16d4a2d Fix SSE event names for frontend compatibility
The SSE stream was sending events without proper event names.
Frontend uses addEventListener('device_update', ...) which only
works with named events. Now maps internal event types to proper
SSE event names:
- device -> device_update
- status/started -> scan_started
- status/stopped -> scan_stopped

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 15:57:44 +00:00
Smittix 187347e64b Fix legacy code conflicts and Bleak deprecation warning
- Add null checks to legacy refreshBtInterfaces() function
- Redirect to BluetoothMode.checkCapabilities() when available
- Fix Bleak deprecation: use AdvertisementData.connectable instead of device.metadata

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 15:56:04 +00:00
Smittix 5016327bc2 Fix API/frontend field name mismatches
- Add 'available' alias for 'can_scan' in capabilities
- Add 'preferred_backend' alias for 'recommended_backend'
- Add 'id' field to adapter info for frontend compatibility

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 15:54:05 +00:00
Smittix ed460761ff Prioritize bleak over DBus for Flask compatibility
DBus/BlueZ requires a GLib main loop which Flask doesn't have.
Reordered backend priority: bleak > hcitool > bluetoothctl > dbus

Removed DBus option from UI since it won't work with Flask.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 15:50:03 +00:00
Smittix c49b1e03f2 Fix scanner fallback logic when DBus fails
The fallback wasn't being triggered because when mode='auto' was
replaced with the recommended backend ('dbus'), the fallback condition
failed. Now properly tracks original_mode to allow fallback.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 15:49:09 +00:00
Smittix 28d15d0ed5 Fix missing constants and test import names
- Add SUBPROCESS_TIMEOUT_SHORT to bluetooth constants
- Fix test imports to use correct constant names

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 15:44:53 +00:00
Smittix 54db023520 Overhaul Bluetooth scanning with DBus-based BlueZ integration
Major changes:
- Add utils/bluetooth/ package with DBus scanner, fallback scanners
  (bleak, hcitool, bluetoothctl), device aggregation, and heuristics
- New unified API at /api/bluetooth/ with REST endpoints and SSE streaming
- Device observation aggregation with RSSI statistics and range bands
- Behavioral heuristics: new, persistent, beacon-like, strong+stable
- Frontend components: DeviceCard, MessageCard, RSSISparkline
- TSCM integration via get_tscm_bluetooth_snapshot() helper
- Unit tests for aggregator, heuristics, and API endpoints

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 15:42:33 +00:00
Smittix 713c1a3470 Add supported platforms note to Quick Start section
Documents that iNTERCEPT is officially tested on Debian and Ubuntu,
with partial macOS support. Other distributions have not been fully tested.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 13:41:29 +00:00
Smittix 5bafb88377 Add meter reading mode and clickable screenshot lightbox 2026-01-21 12:19:39 +00:00
Smittix 95f3836edd Fix branding to iNTERCEPT 2026-01-21 12:12:19 +00:00
Smittix 0195553a62 Add feature screenshots to GitHub Pages 2026-01-21 12:10:59 +00:00
Smittix 5c7554d6cb Add GitHub Pages landing site 2026-01-21 11:50:05 +00:00
Smittix ec32b9237e Make pager and 433MHz cards clickable with details dialog
Replace the dropdown details panel with a clickable card that opens
a modal dialog showing all signal information including raw data.
Action buttons (Copy/Mute) now float on hover.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 10:58:18 +00:00
Smittix 3edd40de0d Fix 433MHz sensor cards not displaying
The updateCounts call was using pager-specific filter logic that
didn't match sensor card data attributes, causing cards to be hidden.
Now uses the sensor filter bar's own applyFilters method.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 10:45:05 +00:00
Smittix 88418b0850 Add keyboard controls and finer tuning steps for frequency tuning
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 10:19:40 +00:00
Smittix 1e59cfd2ea Remove GPS debug endpoint
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 09:52:00 +00:00
Smittix 42f2a6ef62 Add clickable station badges and integrate signal guessing engine
- Add clickable APRS station badges that display raw packet data in a modal
- Integrate SignalGuess into sensor mode cards for frequency identification
- Standardize UI language across timeline and signal components
- Update frequency band naming for consistency (e.g., "Wi-Fi 2.4GHz" → "2.4 GHz wireless band")

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 23:59:08 +00:00
Smittix 3e3bc0e857 Hide status bar in TSCM mode
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 23:47:26 +00:00
Smittix 290c5ff896 Add signal guessing engine for frequency identification
Implements heuristic-based signal identification that provides
plain-English guesses for detected signals based on frequency,
modulation, bandwidth, and burst behavior.

Features:
- Python backend engine (utils/signal_guess.py)
- JavaScript client-side engine with UI components
- Hedged language output (never claims certainty)
- UK/EU and US region support
- Confidence levels (LOW/MEDIUM/HIGH)
- 50+ unit tests for deterministic verification

Supported signal types: FM broadcast, airband, cellular/LTE,
ISM bands (433/868/915/2.4GHz), TPMS, amateur radio, marine VHF,
DAB, pager networks, weather satellites, ADS-B, and more.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 23:38:02 +00:00
Smittix 4c0d44a99d Improve mode card icon spacing and centering
- Increase margin-bottom from 6px to 12px for better spacing
- Add flexbox centering to properly align icons
- Bump icon size to 28px for better visual balance

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 23:19:15 +00:00
Smittix ef4adfe003 Add RTLAMR mode to menu and fix mode card icon visibility
- Add RTLAMR utility meter mode card to the mode selection grid
- Fix icons being nearly invisible by setting color to --text-secondary
- Add explicit 24x24px sizing for mode card SVG icons
- Add cyan highlight on hover for icons

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 23:16:52 +00:00
Smittix 30dfea57b9 Remove sensor-waterfall panels and default recon mode to off
- Remove waterfall UI panels from pager and 433MHz sections
- Remove associated JS functions (toggle, render, data tracking)
- Remove waterfall CSS styles
- Change recon mode to default to 'off' instead of 'on'

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 23:13:28 +00:00
Smittix a0d7f221c0 Add null checks to prevent errors on missing elements
- Add null checks in syncHeaderStats for header stat elements
- Add optional chaining for classList.toggle calls in switchMode
- Add null checks for style.display assignments in switchMode
- Prevents errors when page is accessed with unsupported mode params

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 23:05:43 +00:00
Smittix ee916d0022 Fix ActivityTimeline config property names for pager/sensor
- Change timeWindows to availableWindows (correct property name)
- Change defaultTimeWindow to defaultWindow (correct property name)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 23:03:27 +00:00
Smittix 156d832d2d Add ActivityTimeline to Pager and 433MHz sensor modes
- Add timeline container divs for pager and sensor modes
- Add timeline configurations in initializeModeTimeline()
- Show/hide timeline containers based on active mode
- Feed pager and sensor messages to their respective timelines

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 22:58:57 +00:00
Smittix abe3d42004 Remove duplicate header stats and fix icon rendering
- Remove duplicated message counters from header (keeping output panel stats)
- Remove syncHeaderStats function and its 500ms polling interval
- Fix icon CSS override that caused stroke-based SVGs to render as solid squares

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 22:52:15 +00:00
Smittix 3f38742dbe Refactor timeline as reusable ActivityTimeline component
- Extract signal-timeline into configurable activity-timeline.js
- Add visual modes: compact, enriched, summary
- Create data adapters for RF, Bluetooth, WiFi normalization
- Integrate timeline into Listening Post, Bluetooth, WiFi modes
- Preserve backward compatibility for existing TSCM code
- Add mode-specific configuration presets via adapters

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 22:46:16 +00:00
Smittix 2cb62d5f34 Standardize all icons to uniform inline SVG format
Replace emojis throughout the codebase with inline SVG icons using
the Icons utility. Remove decorative icons where text labels already
describe the content. Add classification dot CSS for risk indicators.

- Extend Icons utility with comprehensive SVG icon set
- Update navigation, header stats, and action buttons
- Update playback controls and volume icons
- Remove decorative device type and panel header emojis
- Clean up notifications and alert messages
- Add CSS for classification status dots

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 22:29:28 +00:00
Smittix 256c30e7cd Add minimal SVG icon system for signal types
Replace emoji icons with inline SVG for WiFi, Bluetooth, and RF/SDR
indicators. Icons are standard symbols (arc, rune, wave) designed for
screenshot legibility in reports.

- Add Icons utility object in utils.js with SVG generators
- Add icon CSS system with sizing variants and state animations
- Update TSCM scanner indicators and capabilities bar
- Remove decorative sensor type emojis (text labels suffice)
- Keep signal strength SVG bars (already implemented)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 22:05:58 +00:00
Smittix c92f60e0f3 Show signal indicator placeholder when no RSSI/SNR data available
Also check 'noise' field from rtl_433 output

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 21:47:07 +00:00
Smittix 9461cc2121 Add signal strength classification with confidence-safe language
Introduces standardized RSSI-to-label mapping (minimal/weak/moderate/strong/very_strong)
and duration-based confidence modifiers for client-facing reports and dashboards.

- New signal_classification.py module with hedged language generation
- Updated detector.py to use standardized signal descriptions
- Enhanced reports.py with signal classification in findings
- Added JS SignalClassification and signal indicator components
- CSS styles for signal strength bars and assessment panels

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 21:37:07 +00:00
Smittix 8a744eb55a Fix TSCM panels sizing - increase heights and add scroll
- Set panel height to 200px with overflow scroll
- Add padding-bottom for status bar clearance
- Make dashboard scrollable
- Remove flex constraints causing collapse

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 21:18:05 +00:00
Smittix 73188c2471 Fix TSCM panels being squashed by adding minimum heights
- Set min-height: 300px on main grid
- Set min-height: 120px on individual panels
- Set min-height: 80px on panel content
- Change dashboard from height: 100% to min-height: 100%

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 21:13:27 +00:00
Smittix 6e8de37135 Make timeline more compact to not hide other panels
- Reduce lanes max-height to 160px
- Reduce lane height to 36px
- Narrow label column to 130px
- Narrow stats column to 50px
- Smaller annotations (max 60px, 9px font)
- Hide legend completely (colors are self-explanatory)
- Reduce padding throughout

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 21:09:26 +00:00
Smittix bb010664ca Fix timeline text being squashed and unreadable
- Increase lane min-height from 28px to 44px
- Widen label column from 100px to 140px
- Increase font sizes (freq: 11px, name: 10px)
- Add proper line-height and gap between lines
- Increase lanes container max-height to 240px
- Add more padding to label and track areas

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 21:05:18 +00:00
Smittix ffc55efe1c Fix timeline overwhelming TSCM page with many signals
- Make timeline collapsible (starts collapsed by default)
- Add header stats showing signal counts when collapsed
- Limit displayed lanes to 15 (scroll for more)
- Constrain max-height to 180px with scrollbar
- Add automatic pruning of old signals (keeps max 100)
- Show "+N more signals" indicator when truncated
- Reduce annotations max-height to 80px
- Preserve flagged signals during pruning

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 21:01:22 +00:00
Smittix 8b42f4ac28 Add signal activity timeline visualization for TSCM mode
New lightweight timeline component that shows RF signal presence
over time without heavy waterfall rendering:

- Horizontal swimlanes for each frequency/signal source
- Bars show transmission duration with height = signal strength
- Status colors: blue=new, gray=baseline, orange=burst, red=flagged
- Pattern detection for regular interval transmissions
- Click to expand and see individual transmission ticks
- Right-click to flag signals for investigation
- Auto-annotations for new signals, bursts, and patterns
- Tooltip with signal details on hover
- Time window selector (5m to 2h)
- Filter controls (hide baseline, show only new/burst)

Integrated into TSCM mode:
- Timeline created when TSCM mode is selected
- WiFi, Bluetooth, and RF signals feed into timeline
- Clears on new sweep start

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 20:54:07 +00:00
Smittix 4c71a3bb92 Fix filter bar counts not updating on new messages
Update applyAllFilters to look for filter bars in all possible
containers (main filterBarContainer and aprsFilterBarContainer)
so counts update automatically when new messages arrive.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 20:35:38 +00:00
Smittix d88d5c4921 Apply signal card system across all message-bearing modes
- Extend signal cards to APRS, Sensors, and utility meter modes
- Add address tracking for automatic new/repeated/burst detection
- Create mode-specific filter bars with status and type filtering
- Add compact card variant for constrained layouts like APRS station list
- Add meter card type with consumption display and type-specific icons
- Refactor filter bar container to be shared across modes
- Add CSS for meter data display and distance display

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 20:29:30 +00:00
Smittix 5c62ae316a Add signal cards component system for pager UI
- Create reusable signal-cards.css with status variants, protocol badges,
  advanced panels, and filter bar styles
- Add signal-cards.js component for rendering pager message cards
- Integrate into pager mode with mute address, copy message, and
  expandable details functionality
- Include interactive mockup for design reference

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 20:03:38 +00:00
Smittix ed58681800 Fix setup.sh hanging on rtlamr prompt by using ask_yes_no helper
Replace raw read commands with ask_yes_no function for rtlamr
installation prompts on both macOS and Debian. The helper properly
handles non-interactive mode and missing TTY scenarios.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 18:16:05 +00:00
Smittix 90d2d42478 Merge branch 'main' of https://github.com/smittix/intercept 2026-01-20 18:07:40 +00:00
Smittix c88cf831fc Add verbose results option to TSCM sweeps and setup improvements
- Add verbose_results flag to store full device details in sweep results
- Add non-interactive mode (--non-interactive) to setup.sh
- Add ask_yes_no helper for interactive prompts with TTY detection
- Update reports.py to handle new results structure with fallbacks

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 18:07:33 +00:00
Smittix f6aed7deda Merge pull request #73 from JonanOribe/main 2026-01-20 15:19:05 +00:00
James Smith ce204ce413 Make rtlamr optional with interactive install prompt
- Add check_optional() function for non-critical tools
- Change rtlamr from required to optional tool
- Add install_rtlamr_from_source() that auto-installs Go and compiles rtlamr
- Prompt user during setup whether to install rtlamr
- Fixes setup failure for users who don't need utility meter monitoring
2026-01-20 13:16:14 +00:00
Jon Ander Oribe 1ef3e367eb Add new dependencies and sync requirement files
Added 'bleak', 'flask-sock', and 'requests' to pyproject.toml and updated requirements.txt to include 'Werkzeug' and 'bleak'. Introduced tests/test_requirements.py to ensure consistency between requirements files and the installed environment.
2026-01-20 10:20:13 +01:00
Smittix 7cd988b777 Merge pull request #72 from JonanOribe/main 2026-01-20 07:08:51 +00:00
Smittix aac88cdd29 Merge pull request #71 from SarahRoseLives/feature/rtlamr-support 2026-01-20 07:06:45 +00:00
Jon Ander Oribe 664ae5b5ce Update requirements.txt
Added flask-limiter>=2.5.4 to the requirements
2026-01-20 07:59:42 +01:00
Jon Ander Oribe d268e581bd Enhance login UX with JS feedback and update docs
Added a new login.js script to provide visual feedback and prevent double submission on the login form. Updated login.html to include the script and wire up the login button. Clarified credential configuration instructions in README.md.
2026-01-20 07:07:47 +01:00
SarahRose ecc8dad2e2 Add rtlamr utility meter monitoring support
- Added rtlamr mode for decoding utility meters (water, gas, electric)
- Starts rtl_tcp server first, then connects rtlamr to it
- Supports multiple message types: SCM, SCM+, IDM, NetIDM, R900, R900 BCD
- Added frequency presets for 912 MHz (NA) and 868 MHz (EU)
- Includes meter ID filtering and unique message options
- Updated setup.sh to check and install rtlamr and rtl_tcp
- Added UI components: navigation button, mode template, JavaScript functions
- Integrated into SDR/RF dropdown menu with lightning bolt icon
- Updates mode indicator with frequency when listening
- Added help documentation and requirements section
2026-01-19 21:42:01 -05:00
James Smith df025f0409 Widen ACARS sidebar and fix controls visibility
Increase sidebar width from 250px to 300px to prevent region dropdown
from being cut off. Add flex layout to keep header and controls visible
while messages area scrolls.
2026-01-19 21:18:41 +00:00
James Smith 5e4412879d Fix ACARS sidebar expanding page height
Add height constraints and overflow handling to keep the sidebar
static within viewport while allowing internal scrolling.
2026-01-19 21:12:39 +00:00
James Smith ce232e0512 Add flask-limiter to setup.sh dependency verification 2026-01-19 17:25:24 +00:00
Smittix 5d54449b21 Merge pull request #69 from JonanOribe/main 2026-01-19 07:00:54 +00:00
Jon Ander Oribe 04f003c9f0 Add rate limiting to login endpoint
Introduced Flask-Limiter to restrict login attempts to 5 per minute per IP, enhancing security against brute-force attacks. Updated error handling to display a user-friendly message when the rate limit is exceeded. Minor improvements to the login page, including clearer error messages and display of the user's IP address.
2026-01-19 07:20:29 +01:00
Smittix 9b55632c86 Remove legacy absolute positioning from nav buttons
The #depsBtn and #helpBtn had old right positioning rules
that conflicted with the flex layout, causing them to appear
in wrong positions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 19:29:21 +00:00
Smittix bd65679572 Tighten nav-utilities spacing
- Reduce nav-utilities gap from 16px to 12px
- Reduce nav-tools gap from 12px to 6px

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 19:25:57 +00:00
Smittix f93877d723 Restructure nav layout to fix utilities overlap
- Remove margin-left: auto from mode-nav-actions
- Set nav-utilities to use margin-left: auto for right alignment
- Increase gaps: nav-utilities 16px, nav-tools 12px

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 19:23:56 +00:00
Smittix 2b8b499e79 Fix nav-tools button overlap with increased gap and containment
- Increased gap between tool buttons from 4px to 8px
- Added min-width to prevent button shrinking
- Added overflow: hidden to contain absolutely positioned icons

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 19:20:18 +00:00
Smittix 69410fd7c2 Fix nav-utilities overlapping by removing competing auto margin
Both .mode-nav-actions and .nav-utilities had margin-left: auto,
causing them to compete for space in the flexbox layout.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 19:17:24 +00:00
Smittix 176014b706 Add New Zealand APRS frequency and custom frequency input
- Add New Zealand (144.575 MHz) to APRS region dropdown
- Add Argentina, Brazil, and China regions
- Add custom frequency input option for user-specified frequencies
- Custom frequency field shows/hides dynamically when selected
- Properly disable/enable custom frequency control during operation
- CSS improvements for nav element flex behavior

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-18 18:49:04 +00:00
Smittix 92984a7bae Update README.md 2026-01-18 18:38:11 +00:00
Smittix a5d433b516 Merge pull request #54 from smittix/feature/login-system 2026-01-18 17:02:25 +00:00
Smittix e30094e8fc Merge pull request #67 from JonanOribe/feature/login-system 2026-01-18 16:20:54 +00:00
Jon Ander Oribe f1b416bba5 Fancy logout button 2026-01-18 17:08:39 +01:00
Smittix ec0b8dbcf7 Merge pull request #66 from JonanOribe/feature/login-system
Feature/login system
2026-01-18 12:44:04 +00:00
Smittix 5bfa7bf651 Fix acarsdec flag detection using version parsing
The previous detection logic incorrectly matched '-o' in help text for
version 4.x, causing startup failures. Now properly detects version:
- Version 4.0+: uses -j for JSON stdout
- Version 3.x: uses -o 4 for JSON stdout

Parses version from acarsdec output (e.g., "Acarsdec v4.3.1" or
"Acarsdec/acarsserv 3.7") to determine the correct flag.

Fixes: "invalid option -- 'o'" error on modern acarsdec builds

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-18 12:36:49 +00:00
Smittix e204901d18 Fix acarsdec JSON flag detection to prevent startup failures
The get_acarsdec_json_flag() function was defaulting to the obsolete '-o'
flag when detection failed, causing "invalid option -- 'o'" errors with
modern acarsdec builds from TLeconte repository.

Changes:
- Try both -h and --help flags for better compatibility
- Improve -j flag detection patterns
- Default to -j (modern standard) instead of -o
- Only use -o if explicitly documented in help text

This fixes ACARS decoder startup failures on systems where acarsdec was
built from source using setup.sh.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-18 10:58:38 +00:00
Smittix 482d778bca Clean up whitespace and remove commented code in setup.sh
Remove commented DEBIAN_FRONTEND line and fix indentation in dump1090
installation section.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-18 10:58:38 +00:00
Jon Ander Oribe c4ad8f6c12 Update .gitignore 2026-01-18 09:36:02 +01:00
Jon Ander Oribe aa763b0f81 Redesign login page with improved UI and error display
Revamps the login page layout and styles for a more modern, 'hacker' terminal look. Adds animated background effects, updates the login box and input styling, and enhances error messages with a new format. Also removes the tracked intercept.db file and ensures it is ignored in .gitignore.
2026-01-18 09:03:17 +01:00
Jon Ander Oribe 58a825976d Merge branch 'main' into feature/login-system 2026-01-18 08:56:06 +01:00
Smittix e4e9e89451 Merge pull request #62 from RoyRock413/main 2026-01-17 09:57:27 +00:00
RoyRock413 2f2e56ff2e temporarily commented out ./data bind mount so compose could run 2026-01-16 21:11:12 -05:00
Smittix 2b29b5c86f Use dpkg force options to resolve librtlsdr package conflicts
Aggressively handle broken rtl-sdr package states:
- Use dpkg --force-remove-reinstreq to remove broken rtl-sdr
- Use dpkg --force-all to force remove librtlsdr2
- Run apt-get --fix-broken install after cleanup
- Improved detection of broken package states

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-16 17:17:02 +00:00
Smittix af1cb7c17b Fix librtlsdr2 dependency chain conflict
Remove all packages that depend on librtlsdr2 before upgrading:
- dump1090-mutability (will be rebuilt from source later)
- libgnuradio-osmosdr0.2.0t64
- rtl-433 (will be reinstalled)
- librtlsdr2 and rtl-sdr

This resolves the file conflict between librtlsdr2 (2.0.1) and librtlsdr0 (2.0.2).

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-16 17:14:54 +00:00
Smittix c5aa382527 Improve RTL-SDR package conflict handling
Fix broken package states by:
- Running apt --fix-broken install before attempting installation
- Removing both librtlsdr2 and rtl-sdr when conflict detected
- Cleaning up with autoremove
- Running dpkg --configure -a to fix partial installations

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-16 17:13:35 +00:00
Smittix 78f81eeccd Fix RTL-SDR package conflict on Debian/Ubuntu
Remove conflicting librtlsdr2 package before installing rtl-sdr to prevent dpkg errors when librtlsdr0 tries to overwrite shared library files.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-16 17:12:29 +00:00
Smittix 096763ad40 Reorganize TSCM menu with logical groupings
- Consolidate sweep config and scan sources into one section
- Group baseline recording and meeting window under "Advanced"
- Create 2x2 grid layout for tool buttons
- Use visual dividers instead of separate sections
- Keep all functionality and IDs intact

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 17:08:42 +00:00
Smittix 6354911c54 Revert TSCM menu changes - restore original layout
The simplified layout was causing display issues. Reverting to
the original working version.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 17:05:38 +00:00
Smittix a8bb56a109 Fix TSCM menu - remove collapsible sections
Show all controls directly instead of hiding them in collapsed
sections which was causing confusion.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 17:03:47 +00:00
Smittix 5047fee431 Simplify TSCM menu for better UX
Redesign the sidebar to be more minimal with collapsible sections
for Settings and Advanced options. Primary sweep action is now
prominently displayed, with tool buttons condensed to compact icons.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 17:01:29 +00:00
Smittix b63c7ab0fe Fix WiFi detection on modern macOS
The airport utility path doesn't exist on newer macOS versions.
Added fallback methods using networksetup and ifconfig to detect
WiFi availability.
2026-01-16 16:51:53 +00:00
Smittix c0c86ef601 Fix type comparison errors in TSCM detector
Signal/RSSI values from WiFi scans can be strings. Added safe
int conversion with try/except to prevent type comparison errors.
2026-01-16 16:50:07 +00:00
Smittix 69c765d44a Change TSCM disclaimer text color to white 2026-01-16 16:44:31 +00:00
Smittix 617ba859fb Fix known devices API: send 'protocol' instead of 'device_type'
The endpoint expects 'protocol' field but JS was sending 'device_type'

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 16:42:48 +00:00
Smittix 62db171ed6 Fix capabilities display and add 'Add to Known Devices' button
- Fix tscmShowCapabilities to parse nested API response structure
- Build can/cannot detect lists dynamically from actual capabilities
- Display system info, limitations, and disclaimer
- Add 'Add to Known Devices' button in device detail modal
- New tscmAddToKnownDevices function with custom name prompt

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 16:31:19 +00:00
Smittix 66b2f59ca0 Fix playbooks endpoint to return array format
- Change /tscm/playbooks to return array instead of dict
- Add id, name, category fields to each playbook for JS compatibility
- Fix tscmViewPlaybook JS to use correct field names (action/details/safety_note)
- Display when_to_escalate and documentation_required sections

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 16:23:09 +00:00
Smittix 6dbf2fda01 Add TSCM advanced features UI
- Add meeting window controls (start/end tracked meetings)
- Add quick actions: capabilities, known devices, cases, playbooks
- Add export options for PDF and JSON/CSV reports
- Add JavaScript functions to connect UI to backend API endpoints
- Add CSS for capabilities grid, modal headers, playbook styles

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 16:20:47 +00:00
Smittix 234f254f4f Add comprehensive TSCM advanced features
Implement 9 major TSCM feature enhancements:

1. Capability & Coverage Reality Panel - Exposes what sweeps can/cannot
   detect based on OS, privileges, adapters, and SDR limits

2. Baseline Diff & Health - Shows changes vs baseline with health scoring
   (healthy/noisy/stale) based on age and device churn

3. Per-Device Timelines - Time-bucketed observations with RSSI stability,
   movement patterns, and meeting correlation

4. Whitelist/Known-Good Registry + Case Grouping - Global and per-location
   device registry with case management for sweeps/threats/notes

5. Meeting-Window Summary Enhancements - Tracks devices first seen during
   meetings with scoring modifiers

6. Client-Ready PDF Report + Technical Annex - Executive summary, findings
   by risk tier, JSON/CSV annex export

7. WiFi Advanced Indicators - Evil twin detection, probe request tracking,
   deauth burst detection (auto-disables without monitor mode)

8. Bluetooth Risk Explainability - Proximity estimates, tracker brand
   explanations, human-readable risk descriptions

9. Operator Playbooks - Procedural guidance by risk level with steps,
   safety notes, and documentation requirements

All features include mandatory disclaimers, preserve existing architecture,
and follow TSCM best practices (no packet capture, no surveillance claims).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 16:06:18 +00:00
Smittix 3210fc0d20 Move time, theme, deps, and help to nav bar
Relocate header utilities (UTC clock, theme toggle, dependencies
button, help button) to the navigation bar. Elements are grouped
logically with the clock on its own and tool buttons together,
all aligned to the far right of the nav bar.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 15:27:52 +00:00
Smittix ac68e26c70 Use OpenStreetMap tiles for APRS map
Switch from CartoDB dark tiles to standard OpenStreetMap tiles
which show roads and more detail for tracking APRS stations.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 14:49:02 +00:00
Smittix ce0f581938 Add GPSD integration to APRS section
- Add GPS indicator to APRS function bar
- Add user location marker on APRS map (yellow dot)
- Calculate and display distance to APRS stations in miles
- Show distance in station list and marker popups
- Center map on GPS location when available
- Update distances dynamically as GPS position changes

Uses same gpsd auto-connect mechanism as ADS-B section.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 13:59:06 +00:00
Smittix fc48ff7d9f Add comprehensive APRS packet parsing support
- Add Mic-E position decoding (destination field encoding)
- Add compressed position format parsing (Base-91)
- Add complete telemetry parsing with analog/digital values
- Add telemetry definition messages (PARM, UNIT, EQNS, BITS)
- Add message ACK/REJ parsing and sequence numbers
- Add weather data parsing in position packets
- Add PHG (Power/Height/Gain/Directivity) parsing
- Add Direction Finding (DF) report parsing
- Add timestamp extraction from position packets
- Add third-party traffic, NMEA, user-defined format parsing
- Add bulletin, NWS alert, query, and capabilities parsing
- Expand weather fields (luminosity, snow, radiation, etc.)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 13:50:32 +00:00
Smittix af39d40847 Add APRS function bar similar to ADS-B stats strip
Move SDR configuration controls from sidebar to a horizontal function bar
above the map display for better visibility and accessibility. The bar
includes frequency/station/packet stats, region and gain controls, tool
status indicators, and start/stop buttons.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 12:55:19 +00:00
Smittix fb23766ed3 Fix APRS object and item position decoding
Objects (;) and items ()) were identified but position data was never
extracted, causing them to appear without location on the map. Added
parse_object() and parse_item() functions to properly extract name,
status, and coordinates.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 12:39:15 +00:00
Smittix bcb3147d1e Revert ISMS Listening Station implementation
Remove all ISMS (Intelligent Spectrum Monitoring Station) code including:
- GSM cell scanning with gr-gsm
- Spectrum monitoring via rtl_power
- OpenCelliD tower integration
- Baseline recording and comparison
- Setup script changes for gr-gsm/libosmocore

Reverts to pre-ISMS state (commit 4c1690d).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 12:24:16 +00:00
Smittix 940a43747b Use velichkov gr-gsm fork for GNU Radio 3.10+
The bkerler fork still uses SWIG. The velichkov fork has a
dedicated maint-3.10 branch with proper GNU Radio 3.10 support.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 11:56:48 +00:00
Smittix 16c74d10db Fix gr-gsm build for GNU Radio 3.10+
Use bkerler fork with pybind11 support for GNU Radio 3.10+ since
the original gr-gsm repo uses GrSwig which was removed in 3.10.

- Detect GNU Radio version and select appropriate fork
- Add pybind11-dev and python3-pybind11 to build dependencies
- Add python3-numpy to build dependencies
- Set CMAKE_PREFIX_PATH to find source-built libosmocore

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 11:55:23 +00:00
Smittix a99c3e3894 Force remove broken RTL-SDR packages, skip stock rtl-sdr
Use dpkg --force-remove-reinstreq to remove broken packages.
Skip stock rtl-sdr package entirely - RTL-SDR Blog drivers are
better and will be built from source instead.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 11:49:44 +00:00
Smittix e621647768 Fix broken packages before RTL-SDR installation
Run apt --fix-broken install and dpkg --configure -a to resolve
any lingering package conflicts before proceeding.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 11:49:21 +00:00
Smittix 5992156356 Fix RTL-SDR package conflicts in setup.sh
Remove conflicting librtlsdr packages before installing to avoid
dpkg errors when RTL-SDR Blog drivers conflict with stock packages.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 11:30:57 +00:00
Smittix bed0c5fb8d Build libosmocore from source if packages unavailable
Ubuntu 24.04 and newer don't have Osmocom packages in repos.
This builds libosmocore from source as a fallback.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 11:27:28 +00:00
Smittix 0362a1b4ea Add Osmocom repository for libosmocore packages
libosmocore packages are not in standard Ubuntu repos.
This adds the Osmocom repository before installing gr-gsm deps.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 11:25:44 +00:00
Smittix cf7c94f9d8 Improve gr-gsm installation error reporting in setup.sh
- Remove output suppression so errors are visible
- Add clearer error messages at each step
- Fix subshell isolation that was swallowing errors

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 11:24:21 +00:00
Smittix c044ecfba2 Improve GSM scan error handling when gr-gsm not installed
- Return 503 instead of 500 when grgsm_scanner not found
- Show clearer error message in UI when gr-gsm unavailable
- Update status display to show "Not Available" state

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 11:21:57 +00:00
Smittix 23a79a7ac5 Fix recursive showNotification call in ISMS module
Renamed local notification helper to ismsNotify to avoid
infinite recursion with global showNotification from audio.js.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 11:17:51 +00:00
Smittix 795dd3f235 Add ISMS mode card to landing page menu
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 11:14:36 +00:00
Smittix 35d138175e Add ISMS Listening Station with GSM cell detection
- Add spectrum monitoring via rtl_power with configurable presets
- Add OpenCelliD tower integration with Leaflet map display
- Add grgsm_scanner integration for passive GSM cell detection (alpha)
- Add rules engine for anomaly detection and findings
- Add baseline recording and comparison system
- Add setup.sh support for gr-gsm installation on Debian/Ubuntu

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 11:12:09 +00:00
Smittix 4c1690dd28 Fix false emergency alerts and ACARS compatibility
- Fix emergency alerts triggering for non-emergency squawk codes (VFR 1200/7000, etc.)
  by checking squawkInfo.type === 'emergency' before alerting
- Fix emergency filter to only show actual emergency squawk codes
- Add acarsdec version detection to support both -j (newer) and -o 4 (older) JSON flags

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 08:57:21 +00:00
Smittix 407d5c1d25 Add 8.33 kHz step option to listening post scanner
Add European airband 8.33 kHz channel spacing to the step selector
in the main listening post interface.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 23:12:23 +00:00
Smittix f46681fdbc Fix duplicate log messages and suppress SBS reconnection spam
- Add propagate=False to prevent child loggers from duplicating
  messages through parent handler
- Only log SBS connection errors once until successful reconnect

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 23:11:47 +00:00
Smittix 95e0309c63 Add configurable 8.33 kHz channel spacing for airband
When using custom frequency, a spacing selector appears allowing
choice between 25 kHz (standard) and 8.33 kHz (European) channel
spacing. The frequency step adjusts accordingly.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 23:02:52 +00:00
Smittix 819944cccf Change airband squelch default to 0 in ADS-B dashboard
Set squelch slider default from 20 to 0 for more sensitive airband
reception out of the box.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 22:59:21 +00:00
Smittix c595450310 Fix audio stream race condition with process reference
Capture local reference to audio_process at generator start to prevent
'NoneType' object has no attribute 'stdout' error when stop is called
concurrently from another request.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 22:29:15 +00:00
Smittix 4af61c8cb9 Add RTL-SDR Blog driver installation for V4 support
Build and install RTL-SDR Blog fork drivers during setup to provide
proper support for RTL-SDR Blog V4 devices (R828D tuner). These
drivers are backward compatible with V3 and other RTL-SDR devices.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 22:24:13 +00:00
Smittix 9f391527c2 Fix RTL-SDR device conflicts when running ADS-B and airband simultaneously
Problems fixed:
1. Added start_new_session=True to dump1090 Popen - creates proper process
   group for clean shutdown
2. Use os.killpg() to kill entire process group when stopping ADS-B -
   ensures child processes are terminated and device is released
3. Track active device index in adsb_active_device for debugging
4. Add device info to /adsb/status endpoint
5. Add logging when starting/stopping ADS-B with device info

These changes ensure the RTL-SDR device is properly released when ADS-B
stops, allowing another process (e.g., airband) to use a different device.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 22:13:30 +00:00
Smittix cd168da760 Add graphical signal meter for APRS decoding
Backend changes (routes/aprs.py):
- Remove -q h flag from direwolf to enable audio level output
- Add parse_audio_level() to extract levels from direwolf output
- Add rate-limiting (max 10 updates/sec, min 2-level change)
- Push meter events to SSE queue as type='meter'

Frontend changes:
- Add signal meter widget to APRS sidebar
- Horizontal bar gauge with gradient (green->cyan->yellow->red)
- Numeric level display (0-100)
- "BURST" indicator for levels >70
- Status text (weak/moderate/strong signal)
- "No RF activity" state after 5 seconds of silence
- CSS styles in static/css/modes/aprs.css

Also added UK region to dropdown (same freq as Europe: 144.800)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 22:03:08 +00:00
Smittix f4282cb608 Robust APRS decoder with deadlock fixes and spectrum scanning
Major improvements to APRS decoding reliability:

Process piping fixes (prevent deadlocks):
- rtl_fm stderr -> DEVNULL (was blocking on unbuffered stderr)
- decoder stderr -> STDOUT (merged, single stream to read)
- decoder uses text=True, bufsize=1 for line-buffered reading
- Proper EOF detection in stream thread

rtl_fm command improvements:
- Use -M nfm (narrowband FM) for APRS
- Add -E dc (DC blocking filter) for cleaner audio
- Add -A fast (fast AGC) for packet bursts
- Sample rate 22050 Hz matches direwolf -r 22050

Parsing robustness:
- Strip direwolf bracket prefixes like "[0.4] " before parsing
- Handle multimon-ng "AFSK1200:" prefix
- Better error handling for early process exit

New /aprs/spectrum endpoint:
- Runs rtl_power to scan around APRS frequency
- Returns peak detection, noise floor, signal analysis
- Provides advice for antenna/signal debugging
- Supports region selection and custom frequency

Also added UK to region list (same freq as Europe: 144.800 MHz)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 21:47:24 +00:00
Smittix 073134d6d3 Add direwolf config file for APRS decoding
Direwolf requires a config file to run. Create a minimal receive-only
config at startup that configures stdin input with AFSK1200 modem.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 21:10:27 +00:00
Smittix 4baefa61ac Fix direwolf not outputting decoded APRS packets
Changed -q d to -q h flag. The -q d option was suppressing APRS packet
descriptions (the decoded output we need), while -q h only suppresses
the audio level heard line.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 21:04:49 +00:00
Smittix 0d6d81fb69 Add direwolf installation to setup.sh
Adds direwolf (APRS decoder) installation for both Debian and macOS
platforms to support the APRS mode functionality.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 20:57:08 +00:00
Smittix c96a3ade6b Fix APRS direwolf command flags
- Change direwolf flags from -D 1 to correct flags for stdin input
- Add -n 1 (mono), -b 16 (16-bit), -t 0 (no PTT), -q d (quiet)
- Add -M fm for explicit FM demodulation in rtl_fm
- Add explicit stdout output (-) to rtl_fm command

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 20:24:09 +00:00
Smittix 81c9dd84b2 Merge pull request #61 from nechry/patch-1 2026-01-15 19:02:41 +00:00
Smittix fe67461f88 Add selectable ACARS frequencies
- Add checkboxes for each ACARS frequency in the selected region
- Users can now select one or multiple frequencies instead of all
- Frequencies stay checked when switching regions if they exist in both
- Falls back to all region frequencies if none selected

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 17:54:41 +00:00
Smittix aae60e2037 Add real-time squelch control and clean up diagnostic logging
- Add updateAirbandSquelch() to restart audio when squelch slider changes
- Remove verbose diagnostic logging from audio streaming
- Remove tee diagnostic for raw rtl_fm output
- Keep error logging for troubleshooting
- Simplify audio stream generator

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 17:17:15 +00:00
Smittix 97d5ec6b33 Add DVB driver conflict detection and auto-fix feature
- Add /settings/rtlsdr/driver-status endpoint to check for loaded DVB modules
- Add /settings/rtlsdr/blacklist-drivers endpoint to unload modules and create blacklist
- Show warning banner on dashboard when DVB conflict detected
- Provide "Fix Now" button to automatically resolve the issue
- Warn users that their RTL-SDR devices may not work until drivers are blacklisted

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 17:06:18 +00:00
Smittix 459bf2d8cd Add explicit stdout output flag to rtl_fm command
- Add '-' flag to explicitly specify stdout output
- Some rtl_fm versions/devices require this explicitly

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 16:56:05 +00:00
Smittix 43f0f1cbfc Add rtl_fm raw output capture for diagnostics
- Use tee to capture rtl_fm raw output to /tmp/rtl_fm_raw.bin
- Log raw file size during stream timeouts
- Helps determine if rtl_fm is producing any data at all

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 16:54:13 +00:00
Smittix a3fd6881df Add ffmpeg stderr logging to diagnose audio pipeline issues
- Capture both rtl_fm and ffmpeg stderr to separate log files
- Log ffmpeg errors at stream request and during timeouts
- Helps identify if ffmpeg is the source of zero-byte streaming

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 16:51:16 +00:00
Smittix b27a532bce Add detailed audio stream generator logging
- Log when generator starts
- Track iterations and bytes sent
- Log select timeouts to diagnose data flow issues

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 16:48:51 +00:00
Smittix 52f85669f8 Add diagnostic logging for SDR device audio debugging
- Log rtl_fm stderr to /tmp/rtl_fm_stderr.log instead of /dev/null
- Add detailed logging for audio start requests and parameters
- Log audio stream status and bytes transferred
- Help diagnose SDR1 airband audio issues

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 16:47:04 +00:00
Smittix a891160f98 Improve SDR device naming and fix airband audio display
- Show descriptive device names: RTL-SDR #0 (serial) instead of SDR 0
- Include last 4 digits of serial number for identification
- Add tooltip with full device name and serial
- Hide audio player element (no visible playback bar)
- Add debug logging for airband device selection

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 16:10:45 +00:00
Smittix 130bc8a51c Fix airband audio element - remove crossorigin, add controls
- Remove crossorigin="anonymous" attribute that may cause CORS issues
- Add controls attribute so user can manually play if autoplay blocked
- Show/hide audio player element when listening starts/stops
- Hide visualizer container on stop

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 16:03:51 +00:00
Smittix 4224418e6f Fix airband audio using async/await pattern from working code
- Convert startAirband to async function
- Add 300ms delay after backend start for stream readiness
- Properly reset audio element before connecting to stream
- Add both oncanplay and immediate play() for browser compatibility
- Add console logging for debugging
- Show visualizer container when audio starts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 15:54:28 +00:00
Smittix 4018f95723 Fix squawk button to use existing modal, fix airband audio playback
- Use existing showSquawkInfo() for squawk button instead of custom modal
- Fix airband audio by waiting for canplay event before calling play()
- Add proper audio state reset before starting new stream
- Remove unused showSquawkReference function

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 15:35:02 +00:00
Smittix e6c7a3eae4 Add radar overlay on map, fix squawk button and airband status
- Add "Radar" toggle in display controls to overlay radar effect on map
- Radar overlay shows sweep line, range rings, compass rose, center point
- Fix squawk button using addEventListener instead of inline onclick
- Add missing airbandStatus element to fix null error
- Improve squawk modal with click-outside-to-close

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 15:29:31 +00:00
Smittix 2e27efdfbf Fix status dot color, map tiles, and button issues
- Fix status dot to be red when inactive, green when tracking
- Add additional map invalidateSize call to fix missing tiles on load
- Add type="button" and z-index to strip buttons for proper click handling

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 15:21:50 +00:00
Smittix 6efa10643e Fix dashboard height calculation to account for stats strip
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 15:17:24 +00:00
Smittix 71e5803695 Add stats strip with tracking features to ADS-B dashboard
- Add slim statistics bar with live stats (aircraft count, max range,
  highest altitude, fastest speed, closest aircraft, countries, ACARS)
- Add session timer and report generation with JSON export
- Add signal quality indicator with visual dots
- Add squawk code reference modal
- Add flight lookup button (FlightAware integration)
- Add aircraft type icons (jet, helicopter, prop, military, glider)
- Move status indicator and UTC time from header to stats strip
- Reorganize controls bar into logical groups
- Add ICAO country allocation data for nationality detection

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 15:15:41 +00:00
Jean-François Auger 1107f0e534 Update setup.sh
look like repo for multimon-ng is wrong
2026-01-15 16:13:23 +01:00
Smittix 0b22d0aa1f Remove aircraft mode from main app, link to dashboard instead
- Remove aircraft.html partial and all aircraft mode JS code
- Navigation buttons now link directly to /adsb/dashboard
- Remove Leaflet MarkerCluster (only used for aircraft)
- Clean up help section aircraft references
- Remove checkAdsbTools function and related code
2026-01-15 14:47:27 +00:00
Smittix 353cd16021 Add volume control for airband listening on ADS-B dashboard
- Add volume slider with speaker icon next to squelch control
- Apply initial volume when audio starts
- Add updateAirbandVolume() function for real-time volume changes
2026-01-15 14:19:28 +00:00
Smittix ac6d1b570d Add clear SDR device selection for ADS-B and airband listening
- Add ADS-B device selector with label before START button
- Add Listen label for airband device selector
- Track which device is actively used for ADS-B tracking
- Disable ADS-B device selector while tracking is active
- Update device conflict detection to use actual selected device
- Consolidate device selector initialization into single function
- Remove duplicate device loading from initAirband()
2026-01-15 14:11:19 +00:00
Smittix 319ea2d01d Remove redundant SDR device selector from APRS configuration
APRS now uses the global device selector in the sidebar like other modes
2026-01-15 13:13:33 +00:00
Smittix 6fc64937fb Fix volume control not applying initial knob value
- Apply volume knob value when scanner audio starts on signal detection
- Apply volume knob value when direct listening starts
- Fix visualizer to use correct scannerAudioPlayer element ID
- Add console logging for volume changes
2026-01-15 12:40:31 +00:00
Smittix 323f24a470 Fix ADS-B mobile - use !important, wider breakpoint, remove overflow:hidden 2026-01-15 09:31:51 +00:00
Smittix d98bcc15b8 Fix ADS-B dashboard mobile - selected aircraft panel, back link, controls 2026-01-15 09:29:45 +00:00
Smittix fdd91485fc Fix ADS-B dashboard mobile layout - proper flex/grid handling 2026-01-15 09:26:16 +00:00
Smittix d510ba30f6 Fix mobile navigation and display issues
- Add APRS to mobile navigation bar (was missing)
- Fix CSS that was forcing aircraft visuals to always display
- Only apply flex layout to visuals when they are actually visible
- Fix ADS-B dashboard mobile layout with proper flex ordering
- Reset grid properties on mobile for proper stacking
- Hide ACARS sidebar on mobile (desktop only feature)
2026-01-15 09:22:35 +00:00
Smittix 4bb0c9b9a3 Fix ADS-B dashboard mobile layout and map rendering
- Add mobile CSS for dashboard to allow scrolling and proper stacking
- Set explicit height for map container on mobile (50vh min 300px)
- Remove sidebar max-height restriction on mobile
- Add map invalidateSize() on init, resize, and orientation change
- Fix controls bar wrapping and touch-friendly zoom controls
- Simplify header layout on mobile

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 09:15:01 +00:00
Smittix b3e67e5ef6 Fix mobile responsiveness and map loading issues
- Add comprehensive mobile CSS for viewport scrolling and layout stacking
- Fix Leaflet maps not rendering by adding explicit heights and invalidateSize() calls
- Add touch-friendly controls and proper touch-action for maps
- Simplify header on mobile, hide stats and reduce sizes
- Handle orientation changes and window resize for maps

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 09:12:29 +00:00
Smittix dec890104b Fix acarsdec JSON output flag for newer forks
Use -j instead of -o 4 for JSON output, which is the correct
flag for acarsdec v4.3.1+ (Thibaut Varene fork).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 08:03:57 +00:00
Smittix 5d8c435c5a Update README.md with new content and formatting 2026-01-15 07:58:01 +00:00
Smittix 3cf371242a Update README with new project information 2026-01-14 21:16:18 +00:00
Smittix aab7b508cc Add files via upload 2026-01-14 21:15:50 +00:00
Smittix 36def8f96a Update README with donation support and project info
Added donation link and updated project description.
2026-01-14 21:14:48 +00:00
Smittix 3c0a654f93 Add GitHub sponsor button for Buy Me a Coffee
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 21:10:18 +00:00
Smittix 77b4bc9ad4 Add save button to TSCM report
- Add Save Report button next to Print button
- Downloads report as HTML file with date-stamped filename
- Style both buttons consistently with flex container

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 20:37:43 +00:00
Smittix 9f39f1cc2f Add TSCM report generation feature
- Add Generate Report button to TSCM sidebar (appears after sweep)
- Implement generateTscmReport() function that creates professional HTML report
- Report includes: executive summary, device tables by risk level,
  indicators, recommendations, and disclaimers
- Track sweep start/end times for duration calculation
- Fix script tag escaping in template literal to prevent parsing issues

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 20:21:55 +00:00
Smittix f326be77cd Modularize index.html with CSS and HTML partials
- Extract inline CSS to static/css/modes/ (acars, aprs, tscm)
- Create HTML partials for all 9 modes in templates/partials/modes/
- Reduce index.html from 11,862 to 10,281 lines (~15% reduction)
- Use Jinja2 includes for cleaner template organization

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 20:07:38 +00:00
Smittix 7eba7dbaaa Hide output console in TSCM mode
Prevents pager and 433MHz sensor data from appearing in the TSCM
section, which has its own dedicated dashboard panels.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 19:22:50 +00:00
Smittix dc4434db84 Add mobile responsive design overhaul
- Add responsive.css with shared utilities (hamburger menu, touch targets, responsive typography)
- Add hamburger menu and mobile drawer navigation to main app
- Add horizontal scrolling mobile nav bar for mode switching
- Refactor index.css with mobile-first breakpoints
- Update adsb_dashboard.css for mobile layouts
- Update satellite_dashboard.css for mobile layouts
- Add mobile nav controller to app.js with drawer toggle
- Hide stats/taglines on small screens
- Unified breakpoints: 480px (phone), 768px (tablet), 1024px (desktop)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 18:30:15 +00:00
Smittix 0eed4a2649 Bump version to 2.9.5
- Update VERSION in config.py
- Add changelog entry for v2.9.5 highlights
- Update CHANGELOG.md with detailed release notes
- Update pyproject.toml version

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 18:00:44 +00:00
Smittix 7b49c95967 Add welcome page with changelog and mode selection
- Replace simple splash screen with comprehensive welcome page
- Show version number and latest changelog entries
- Add 9-button mode selection grid for direct navigation
- User can now choose which mode to start with
- Responsive layout adapts to mobile screens
- Flow: Welcome → Disclaimer (if needed) → Selected mode

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 17:26:23 +00:00
Smittix 30126b1709 Add dropdown navigation menus and fix TSCM baseline recording
- Convert flat mode nav buttons into dropdown menus by category (SDR/RF, Wireless, Security)
- Add CSS styles for dropdown animations and active state highlighting
- Fix baseline recording by feeding device data to recorder endpoints
- Remove redundant threat summary section from TSCM sidebar

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 16:51:45 +00:00
Smittix 66c7db73e2 Integrate device identity engine into TSCM sweep
- Import device identity functions (get_identity_engine, ingest_ble_dict, etc.)
- Initialize and clear identity engine at sweep start
- Feed BLE observations to identity engine during Bluetooth scan
- Feed WiFi observations to identity engine during WiFi scan
- Finalize sessions and emit identity_clusters event at sweep completion
- Include identity cluster statistics in sweep results

The device identity engine provides MAC-randomization resistant detection
by clustering observations using fingerprinting, timing patterns, and
RSSI trajectory analysis.
2026-01-14 16:34:49 +00:00
Smittix 07af3acb84 Fix rtl_433 bias-t flag and add TSCM enhancements
- Fix bias-t option in rtl_433 for RTL-SDR and HackRF:
  - rtl_433's -T flag is for timeout, not bias-t
  - RTL-SDR: Use :biast=1 suffix on device string
  - HackRF: Use bias_t=1 in SoapySDR device string
- Add "Listen (FM/AM)" buttons to TSCM RF signal details
  - Switches to Listening Post mode and tunes to frequency
- Fix device detail header padding to prevent protocol badge
  overlapping with close button
2026-01-14 16:27:07 +00:00
Smittix b2feccdb90 Improve TSCM modal close button visibility
- Add circular background to close button
- Use visible border and solid background color
- Increase z-index to ensure it's above content
- Add hover effect with red background
- Better positioning and sizing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 15:53:23 +00:00
Smittix db2f46b46e Add root privilege check and warning display
- Add startup check in app.py for root/sudo privileges
- Show warning in terminal if not running as root
- Add running_as_root flag to TSCM devices API response
- Display privilege warning in TSCM UI when not running as root
- Show command to run with sudo in the warning
- Add CSS styling for privilege warning banner

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 15:37:56 +00:00
Smittix ff7c768287 Fix RF scanning and add status feedback
- Remove requirement for sdr_device to be set before RF scanning
- Add RTL-SDR device detection check with rtl_test before scanning
- Lower signal detection threshold from -50dBm to -70dBm
- Lower noise floor threshold from 15dB to 10dB above noise
- Add rf_status event for frontend feedback when RF unavailable
- Show status message in RF panel explaining why scanning isn't working
- Add CSS styling for status messages
- Reset RF status message when sweep starts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 15:35:47 +00:00
Smittix 236fbf061c Fix TSCM modal readability issues
- Fix transparent modal background: use --bg-card instead of undefined --panel-bg
- Add box-shadow to modal for better visibility
- Fix reason text color: use --text-secondary instead of hard-to-read --text-muted
- Fix device details section headings and table labels
- Fix indicator tags, disclaimer text, and reasons list colors

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 15:25:53 +00:00
Smittix 21b0a153e8 Add MAC-randomization resistant device detection for TSCM
- New device_identity.py: Clusters BLE/WiFi observations into probable
  physical devices using passive fingerprinting (not MAC addresses)
- Fingerprinting based on manufacturer data, service UUIDs, capabilities,
  timing patterns, and RSSI trajectories
- Session tracking with automatic gap detection
- Risk indicators: stable RSSI, MAC rotation, ESP32 chipsets, audio-capable
- Full audit trail for all clustering decisions

- New ble_scanner.py: Cross-platform BLE scanning with bleak library
- Detects AirTags, Tile, SmartTags, ESP32 by manufacturer ID
- Fallback to system tools (btmgmt, hcitool, system_profiler)

- Added API endpoints for device identity clustering (/tscm/identity/*)
- Updated setup.sh with bleak dependency
- Updated documentation with TSCM features and hardware requirements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 15:19:20 +00:00
Smittix 35ca3f3a07 Add clickable score cards and fix findings panel
Features:
- Score cards (High Interest, Needs Review, etc.) are now clickable
- Clicking a card shows all devices in that category in a modal
- Can click through to see individual device details
- Correlations card shows cross-protocol matches

Fixes:
- Findings panel now shows devices with score >= 3 (was 6)
- Panel items color-coded by score (critical/high/medium)
- Sorted by score descending
- Fixed empty state message

UI:
- Added hover effects on clickable cards
- Added CSS for category device list
- Added protocol badges and mini indicators

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 14:58:28 +00:00
Smittix 87f72db8ad Add click-to-expand device details and fix score card updates
Features:
- Click any device to see detailed breakdown of why it was scored
- Modal shows score circle, risk level, recommended action
- Lists all indicators that contributed to the score
- Shows device-specific information (MAC, RSSI, etc.)
- Includes disclaimer about findings

Fixes:
- Score cards (High Interest, Needs Review, etc.) now update in real-time
- High-interest devices (score 6+) populate the Detected Threats panel
- Added updateTscmThreatCounts() calls when devices are added

UI:
- Device items now have cursor:pointer to indicate clickability
- Added CSS for modal, score circle, indicator list, etc.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 14:39:39 +00:00
Smittix 93b763865b Update TSCM with improved WiFi scanning, new scoring UI, and tracker detection
WiFi Scanning:
- Add 'iw' scan method as primary (sometimes works without root)
- Auto-detect wireless interface from /sys/class/net
- Better error logging for permission issues
- Fall back to iwlist if iw fails

UI Updates:
- Replace Critical/High/Medium/Low cards with new scoring model
- Now shows: High Interest (6+), Needs Review (3-5), Informational (0-2)
- Add Correlations count card
- Update counts based on device classification scores

Tracker Detection:
- Add detection for Apple AirTag (by OUI and name)
- Add detection for Tile trackers
- Add detection for Samsung SmartTag
- Add detection for ESP32/ESP8266 devices (Espressif chipset)
- Add generic chipset vendor detection
- New indicator types with appropriate scoring weights

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 14:28:54 +00:00
Smittix b15b5ad9ba Fix Bluetooth event type being overwritten by device type
The bt_device event was including 'type': device.get('type') which
overwrote the SSE event type 'bt_device' with 'ble', causing the
frontend to not recognize the events.

- Rename device type field from 'type' to 'device_type' in bt_device events
- Update frontend to use device_type for display

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 14:20:41 +00:00
Smittix 364600e545 Fix Linux device detection with more fallback methods
- Restore airodump-ng check for WiFi tools
- Add /sys/class/net/*/wireless fallback for WiFi detection
- Add /sys/class/bluetooth/hci* fallback for Bluetooth detection
- Add hciconfig to Bluetooth tool checks
- Add SubprocessError to exception handling
- Multiple fallback layers ensure detection works even with partial tools

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 14:10:04 +00:00
Smittix 23b2a2a0c0 Fix TSCM device detection for macOS
- Add macOS-specific WiFi detection using airport utility
- Add macOS-specific Bluetooth detection using system_profiler
- Add fallback to 'iw' command on Linux when iwconfig unavailable
- Properly handle platform differences for device availability checks

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 14:07:29 +00:00
Smittix ef6eec3cf8 Integrate TSCM correlation engine with sweep and add comprehensive reporting
- Integrate correlation engine into sweep loop for real-time device profiling
- Add API endpoints for findings (/tscm/findings, /tscm/findings/high-interest,
  /tscm/findings/correlations, /tscm/findings/device/<id>)
- Add meeting window endpoints (/tscm/meeting/start, /tscm/meeting/end, /tscm/meeting/status)
- Add comprehensive report generation endpoint (/tscm/report)
- Update frontend to display scores, indicators, and recommended actions
- Add correlation findings display and cross-protocol analysis
- Show sweep summary with assessment on completion
- Add client-safe legal disclaimers throughout UI and API responses
- Sort devices by score (highest first) for prioritized review

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 14:04:22 +00:00
Smittix 94f4682f2f Implement TSCM correlation engine and fix scanning issues
Correlation Engine (utils/tscm/correlation.py):
- Device profiles with comprehensive tracking
- Scoring model: 0-2 Informational, 3-5 Review, 6+ High Interest
- Cross-protocol correlation (BLE+RF, WiFi+RF, same vendor)
- Meeting window tracking for time correlation
- Device history for persistence detection
- Indicator types: unknown, audio-capable, persistent, cross-protocol, etc.

Bluetooth Scanning Fixes:
- Added multiple scan methods for Linux (hcitool, btmgmt, bluetoothctl)
- Fixed indentation issues in bluetoothctl scan
- Added comprehensive logging for debugging

RF Scanning Fixes:
- Added logging for each frequency band scan
- Better error reporting from rtl_power
- Increased timeout for reliability

Classification Updates:
- Green/Yellow/Red color coding with reasons
- Audio-capable device detection (microphone badge)
- Proper CSS styling for classification levels
2026-01-14 13:57:56 +00:00
Smittix f407a3cb54 Add TSCM device classification system
Classification levels:
- Green (Informational): Known devices in baseline, expected infrastructure
- Yellow (Needs Review): Unknown BLE devices, new WiFi APs, unidentified RF
- Red (High Interest): Persistent transmitters, audio-capable BLE, trackers,
  devices with repeat detections across scans

Features:
- Device history tracking for repeat detection (24-hour window)
- Audio-capable BLE detection (headphones, mics, speakers)
- Classification reasons shown under each device
- Color-coded indicators with visual styling
- Microphone badge for audio-capable BLE devices
2026-01-14 13:52:28 +00:00
Smittix c11c1200e2 Stream devices to dashboard in real-time during TSCM sweep
- Emit wifi_device, bt_device, rf_signal events as devices are found
- Add frontend handlers to populate device lists in real-time
- Add RF Signals panel to TSCM dashboard
- Dashboard now updates during sweep, not just at the end
2026-01-14 13:43:19 +00:00
Smittix 0acbf87dde Add RF scanning and improve TSCM device naming
- Add _scan_rf_signals() function using rtl_power to scan:
  - FM broadcast band (88-108 MHz) for potential bugs
  - 315/433/868/915 MHz ISM bands
  - 1.2 GHz video transmitter band
  - 2.4 GHz ISM band
- Integrate RF scanning into sweep with 60-second interval
- Add display_name field for all devices with friendly names
- Update frontend to use display_name in dropdowns
- Improve scan status display: '14 WiFi | 20 BT | 3 RF' instead of '14w 20b'
- Auto-select first SDR device when available
2026-01-14 13:40:13 +00:00
Smittix 153336d757 Fix TSCM SDR device detection
SDRFactory.detect_devices() returns SDRDevice dataclass objects,
not dictionaries. Fixed to access attributes directly instead of
using .get() method.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 13:28:07 +00:00
Smittix 570710c556 Implement TSCM device selection and actual scanning
- Add /tscm/devices endpoint to list available WiFi interfaces,
  Bluetooth adapters, and SDR devices
- Add _scan_wifi_networks() for actual WiFi scanning (macOS/Linux)
- Add _scan_bluetooth_devices() for actual Bluetooth scanning
- Update _run_sweep() to perform real scans with selected interfaces
- Add severity_counts tracking in progress events
- Fix frontend to correctly access device and severity data

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 13:23:26 +00:00
Smittix de13d5ea74 Restore lost features and unify button styling
- Restore APRS dynamic device selection and status bar
- Add ACARS status indicator with listening/receiving states
- Fix acars.py: use -o 4 for JSON, correct command order, add macOS pty fix
- Unify all start buttons (green) and stop buttons (red) across app
- Update help documentation with all modes (APRS, ACARS, Listening Post, TSCM)
- Add TSCM Alpha badge to sidebar

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 12:20:52 +00:00
Smittix f36e528086 Add TSCM counter-surveillance mode (Phase 1)
Features:
- New TSCM mode under Security navigation group
- Sweep presets: Quick, Standard, Full, Wireless Cameras, Body-Worn, GPS Trackers
- Device detection with warnings when WiFi/BT/SDR unavailable
- Baseline recording to capture environment "fingerprint"
- Threat detection for known trackers (AirTag, Tile, SmartTag, Chipolo)
- WiFi camera pattern detection
- Real-time SSE streaming for sweep progress
- Futuristic circular scanner progress visualization
- Unified threat dashboard with severity classification

New files:
- routes/tscm.py - TSCM Blueprint with REST API endpoints
- data/tscm_frequencies.py - Surveillance frequency database
- utils/tscm/baseline.py - BaselineRecorder and BaselineComparator
- utils/tscm/detector.py - ThreatDetector for WiFi, BT, RF analysis

Database:
- tscm_baselines, tscm_sweeps, tscm_threats, tscm_schedules tables

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:35:13 +00:00
Smittix 52ce930c31 Fix listening post frequency display - move MHz beside frequency
- Changed layout from stacked to inline using flexbox
- MHz now appears beside the frequency number (118.000 MHz)
- Uses align-items: baseline for proper text alignment
- Modulation badge (AM/FM) remains below on its own row
- Increased MHz font size slightly for better visibility

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:35:13 +00:00
Smittix bb694c9926 Revamp APRS layout and restore satellite modal
APRS Layout:
- Redesigned visualization panel with flexbox layout
- Map panel now takes 2/3 width with station list on right (1/3)
- Station list has proper min/max width (280-350px)
- Packet log at bottom with max height
- Better use of space for all screen sizes

Satellite Features:
- Restored satellite modal (was missing HTML, only JS existed)
- Add Satellite (TLE) button for manual TLE input
- Update from Celestrak button with category selection
- Categories: Space Stations, Weather, NOAA, GOES, Amateur,
  CubeSats, Starlink, OneWeb, Iridium NEXT, Visual, Geo, Resources
- Tracked satellites list in sidebar
- Modal tabs for TLE input vs Celestrak fetch

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:35:13 +00:00
Smittix a8c77c8db3 Fix APRS mode integration and remove orphaned signalMeter references
- Add APRS to switchMode modeMap, modeNames, and titles
- Add aprsMode classList toggle
- Add aprsVisuals display toggle
- Add APRS to recon panel hide condition
- Add APRS to RTL-SDR device section visibility
- Stop APRS scan when switching modes
- Remove aprsMode inline style (let CSS class handle visibility)
- Remove signalMeter reference (element was previously deleted)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:35:13 +00:00
Smittix 3263638c57 Add APRS amateur radio tracking feature
- Create routes/aprs.py with start/stop/stream endpoints for APRS decoding
- Support multiple regional frequencies (North America, Europe, Australia, etc.)
- Use direwolf (preferred) or multimon-ng as AFSK1200 decoder
- Parse APRS packets for position, weather, messages, and telemetry
- Add APRS visualization panel with Leaflet map and station tracking
- Include station list with callsigns, distance, and last heard time
- Add packet log with raw APRS data display
- Register APRS blueprint and add global state management
- Add direwolf and multimon-ng to dependency definitions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:35:13 +00:00
Smittix c30e5800df Remove unused signal meter div from output header
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:32:23 +00:00
Smittix 161e0d8ea8 Make dependency guidance consistent across all documentation
- Update acarsdec install instruction to point to ./setup.sh
- Add ACARS Messaging to README features list
- Add acarsdec to README acknowledgments section
- All sources now consistently recommend ./setup.sh for acarsdec

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:32:23 +00:00
Smittix 93f68aa29d Fix inaccurate dependency information
- Fix multimon-ng GitHub URL typo (EliasOewornal -> EliasOenal)
- Fix acarsdec install info (not in apt repos, must build from source)
- Add hcxdumptool to quick install command
- Add note about acarsdec requiring source build with link to setup.sh

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:32:23 +00:00
Smittix c5ce35ff13 Fix right sidebar grid-column to 3
With ACARS in column 1 and map in column 2, the right sidebar
needs to be in column 3.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:31:56 +00:00
Smittix 7069c8b636 Fix map display by updating grid-column to 2
ACARS is now in column 1, so main-display (map) needs to be in column 2.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:31:56 +00:00
Smittix 6149427753 Move ACARS panel to left of map in adsb/dashboard
- Reorder HTML: ACARS sidebar now comes before main-display
- Update grid: auto 1fr 300px (ACARS, Map, Sidebar)
- Swap borders: right border on sidebar, left border on button
- Button now on right side of ACARS panel (bordering map)
- Icon changed to left arrow (collapse direction)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:31:56 +00:00
Smittix 536b762f97 Fix ACARS toggle button visibility by using flexbox layout
The parent container has overflow:auto which clipped the absolutely
positioned button. Changed to simple flexbox approach where:
- Button is a normal flex child (always visible)
- Content div collapses to width:0 when collapsed
- Map expands to fill available space via flex

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:31:56 +00:00
Smittix b423dcedf7 Fix ACARS toggle button icon direction and positioning
- Fix checkAcarsTools error by removing orphaned function call
- Change toggle icon from left arrow to right arrow (indicates collapse direction)
- Fix button positioning to use left edge instead of right edge
- Button now correctly appears on left side of ACARS panel (bordering map)
- Both index.html and adsb_dashboard now behave consistently

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:31:56 +00:00
Smittix 16cd1fef2d Remove ACARS section from left sidebar menu
ACARS controls are now in the collapsible sidebar next to the map,
so the redundant section in the left settings panel is no longer needed.

- Remove ACARS Messaging section from aircraft mode settings
- Remove unused JS functions (toggleAcarsPanel, setAcarsRegion, etc.)
- Keep addAcarsToOutput helper used by main sidebar

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:31:56 +00:00
Smittix c94d0a642d Fix ACARS sidebar to collapse outward and expand map
- Change collapsed width from 32px to 0 so map expands fully
- Position collapse button as absolute overlay on map edge
- Button slides to edge of map when collapsed (right: 100%)
- Content fades out smoothly instead of abrupt hide
- Applied same fix to both adsb_dashboard.css and index.html

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:31:56 +00:00
Smittix 135390788d Add ACARS aircraft messaging feature with collapsible sidebar
- Add routes/acars.py with start/stop/stream endpoints for ACARS decoding
- Build acarsdec from source in Dockerfile (not available in Debian slim)
- Add acarsdec installation script to setup.sh for native installs
- Add ACARS to dependency checker in utils/dependencies.py
- Add collapsible ACARS sidebar next to map in aircraft tracking tab
- Add collapsible ACARS panel in ADS-B dashboard with same layout
- Include guidance about needing two SDRs for simultaneous ADS-B + ACARS
- Support regional frequency presets (N.America, Europe, Asia-Pacific)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:31:56 +00:00
Smittix 98e4e38809 Fix install hang on Linux Mint/Ubuntu with newer kernels
The soapysdr-tools package pulls in xtrx-dkms, which fails to compile
its kernel module on Kernel 6.14+ and causes apt to hang. Explicitly
exclude xtrx-dkms since most users don't have XTRX hardware.

Fixes #56

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:31:56 +00:00
Smittix 6d5a12a21f Fix dump1090 not found in Docker by building from source
The dump1090 packages are not available in Debian slim repos, causing
the Docker build to silently skip installation. This builds dump1090-fa
from FlightAware's source repository instead.

Fixes #46

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:31:56 +00:00
James Smith fe3b3b536c bug fixes and feature updates 2026-01-14 10:30:24 +00:00
James Smith aa8a6baac4 Fix macOS setup: build multimon-ng from source and improve UX
- Add install_multimon_ng_from_source_macos() since multimon-ng is not
  available in Homebrew core
- Fix brew_install() to properly check return code before printing success
- Show startup instructions before tool check so users see them on macOS
- Make missing Bluetooth tools a warning on macOS instead of hard failure
  (bluetoothctl/hcitool/hciconfig are Linux-only BlueZ utilities)
2026-01-12 13:15:59 +00:00
Smittix b0982249c3 Add device debug endpoint and fix RTL-SDR detection issues
- Add /devices/debug endpoint for detailed SDR detection diagnostics
- Add kernel driver blacklisting to setup.sh for Debian/Ubuntu
- Blacklists dvb_usb_rtl28xxu, rtl2832, rtl2830, r820t modules
- Update docs to use correct venv command: sudo -E venv/bin/python

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 22:14:36 +00:00
Smittix cf91c2484f Fix login system: add health route exemption, translate comments
- Add 'health' to allowed routes to prevent Docker healthcheck failures
- Translate Spanish comments to English for consistency
- Reset binary database file to avoid committing user data

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 20:39:44 +00:00
Smittix b3a8a69244 Merge pull request #52 from zielu92/patch-1
Update README.md with project details and features
2026-01-11 20:33:33 +00:00
Jon Ander Oribe f51b193876 Update config.py 2026-01-11 18:12:19 +01:00
Jon Ander Oribe 0846d1f360 Update login.html 2026-01-11 18:05:13 +01:00
Jon Ander Oribe dd56617c4c Implement user authentication with hashed passwords
Replaces hardcoded admin credentials with a users table in the database, storing hashed passwords and user roles. Updates the login logic in app.py to authenticate against the database using Werkzeug's password hashing utilities. Adds admin credential configuration to config.py and ensures a default admin user is created during database initialization.
2026-01-11 17:54:43 +01:00
Jon Ander Oribe 03ce847196 Logout button added 2026-01-11 14:56:25 +01:00
Jon Ander Oribe 1a7a33041c Styles improvement 2026-01-11 14:17:13 +01:00
Jon Ander Oribe 6da8b11301 Add login system with authentication and login page
Introduced a login system to restrict access to the application. Added session-based authentication in app.py, including login and logout routes, and a new login.html template for the login form. Updated .dockerignore to exclude .uv directory.
2026-01-11 14:06:55 +01:00
Michał Zieliński 8cd1ecffc4 Update README.md with project details and features
Fixed typo in Docker Compose
2026-01-11 12:54:14 +01:00
Smittix 7967b71405 Merge pull request #49 from armegia/armegia
In Ubuntu Desktop 25.10, dump1090 is dump1090-mutability. Because of this, cmd_exists dump1090 fails even after successful apt install. Added code to create a symbolic link from /usr/local/sbin/dump1090 to the dump1090-mutability if it exists
2026-01-10 19:22:12 +00:00
Antonio M cd0d5971e2 In Ubuntu Desktop 25.10, dump1090 is dump1090-mutability. Because of this, cmd_exists dump1090 fails even after successful apt install. Added code to create a symbolic link from /usr/local/sbin/dump1090 to the dump1090-mutability if it exists 2026-01-10 18:54:56 +01:00
Smittix b52b4db989 Merge pull request #48 from JonanOribe/main
Add satellite route tests and update .gitignore
2026-01-10 15:04:16 +00:00
Jon Ander Oribe ef5cfb4908 Add satellite route tests and update .gitignore
Added tests for satellite-related routes, including validation, error handling, and mocking of external dependencies. Updated .gitignore to exclude database files (*.db, *.sqlite3) in addition to lock files.
2026-01-10 14:03:49 +01:00
Smittix ee7781ee67 Merge pull request #47 from JonanOribe/main
Testing for Wifi
2026-01-10 11:11:07 +00:00
Jon Ander Oribe 8c5bb32ec6 Testing for Wifi 2026-01-10 07:35:28 +01:00
Smittix 007400d2a7 Release v2.9.0 - iNTERCEPT rebrand and UI overhaul
- Rebrand from INTERCEPT to iNTERCEPT
- New logo design with 'i' and signal wave brackets
- Add animated landing page with "See the Invisible" tagline
- Fix tuning dial audio issues with debouncing and restart prevention
- Fix Listening Post scanner with proper signal hit logging
- Update setup script for apt-based Python package installation
- Add Instagram promo video template
- Add full-size logo assets for external use

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 01:00:17 +00:00
Smittix 1f60e64217 Improve apt_install error handling in setup script
Show actual error output when apt-get fails instead of silently
failing. Now displays which packages failed, the last 10 lines of
apt output, and suggests a manual fix command.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 12:53:02 +00:00
Smittix 69de7e4afd Add HackRF/Airspy troubleshooting guide for ADS-B and Listening Post
Document how to use non-RTL-SDR devices:
- ADS-B via readsb with SoapySDR (Remote mode or build from source)
- Listening Post via rx_fm from soapysdr-tools

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 21:22:26 +00:00
Smittix 29025059af Add HackRF/Airspy/LimeSDR/SDRPlay support to Listening Post
The Listening Post module now uses the SDR abstraction layer to support
non-RTL-SDR devices via rx_fm (SoapySDR). Previously only rtl_fm worked.

- Add sdr_type parameter to /audio/start and /scanner/start endpoints
- Use appropriate command builder based on SDR type
- Update /tools endpoint to report rx_fm and supported SDR types

Fixes compatibility issue reported by DragonOS users with HackRF/Airspy.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 21:20:26 +00:00
Smittix 6229c25872 Add aircraft watchlist feature to ADS-B dashboard
- Add/remove callsigns, registrations, or ICAO codes to watch
- Alert notification and sound when watched aircraft detected
- Filter view to show only watched aircraft
- Visual highlighting with cyan border and star icon
- Watchlist persisted to localStorage

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 21:13:49 +00:00
Smittix 73ac74a9d6 Add clickable squawk code reference on aircraft dashboard
Click any aircraft's squawk code to see its meaning and a full
reference table of common codes. Emergency codes highlighted in red.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 21:03:42 +00:00
Smittix ebb1e233d8 Add tagline 'See the Invisible' to branding
Update browser titles and headers across all pages with the new
tagline. Add 'Signal Intelligence Platform' as subtitle.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 20:55:41 +00:00
Smittix e719e32c73 Add SDRPlay device support via SoapySDR
Adds support for SDRPlay RSP devices (RSPdx, RSP1A, RSPduo, etc.)
through the SoapySDR interface. Closes #44.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 20:55:41 +00:00
Smittix 46ab5fe78d Update CHANGELOG for version 2.0.0 2026-01-08 20:15:31 +00:00
Smittix dc467aef91 Update README with new features and installation steps 2026-01-08 17:44:08 +00:00
Smittix 0bc915fe1f Add files via upload 2026-01-08 17:43:49 +00:00
Smittix b7f9ad786a Delete static/images/screenshots/logo-banner.png 2026-01-08 17:43:35 +00:00
Smittix 6c80521cf8 Add files via upload 2026-01-08 17:39:51 +00:00
Smittix a174884269 Remove LoRa/ISM mode (redundant with 433MHz)
The LoRa mode was removed because:
- rtl_433 cannot decode actual LoRa (CSS modulation)
- The 433MHz mode already handles ISM band devices
- True LoRa decoding requires specialized tools like gr-lora

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 17:07:36 +00:00
Smittix f3b1865a79 Add LoRa/ISM band monitoring mode
- Add new LoRa backend route (routes/lora.py) with:
  - Frequency band definitions (EU868, US915, AU915, AS923, IN865, ISM433)
  - Start/stop/stream/status endpoints using rtl_433
  - Device pattern matching for LoRa/LPWAN devices
  - Signal quality calculation from RSSI

- Add LoRa frontend UI with:
  - Navigation button in SDR/RF group
  - Band selector with channel presets
  - Visualization layout (radar, device types, signal quality, activity log)
  - Device card list with selection details
  - Header stats for devices and signals

- Fix Bias-T toggle visibility for Listening Post and LoRa modes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 16:56:21 +00:00
Smittix 6c99651ac9 Move Bias-T toggle to prominent location in sidebar
Moved from bottom of sidebar to right after device capabilities,
before the Refresh Devices button. Now has orange gradient
background and is immediately visible without scrolling.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 16:43:53 +00:00
Smittix 0aaf888dd1 Make Bias-T toggle more prominent in sidebar
- Changed to orange styling to stand out
- Added "Power Settings" header with lightning icon
- Larger checkbox
- Added description text explaining purpose

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 16:40:30 +00:00
Smittix d947ce17a3 Hide waterfall and output console for Bluetooth mode
Bluetooth mode now has its own dedicated layout with device cards,
so the generic waterfall and output panels are hidden.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 14:57:26 +00:00
Smittix 97c957b70f Fix Bluetooth RSSI capture from initial device discovery
When a new device is discovered with bluetoothctl, extract and
capture the RSSI value from the discovery line instead of
discarding it.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 14:56:55 +00:00
Smittix 82830c86ac Fix listening post error, hide signal meter, improve BT detection
- Fix scannerStatus -> scannerStatusText element reference
- Hide global signal meter (individual panels show signal)
- Expand Bluetooth device classification patterns
- Add more audio, phone, wearable, computer patterns
- Add manufacturer-based device type inference

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 14:55:51 +00:00
Smittix d8e4189100 Fix listening post tuneToFrequency null element error
The code referenced 'scannerStatus' but the element ID is
'scannerStatusText'. Fixed all instances to use the correct ID.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 14:44:53 +00:00
Smittix 6bcde56525 Remove debug console.log statements
Clean up temporary debug logging from channel recommendation
and WiFi client card debugging.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 14:43:11 +00:00
Smittix 88ebe3c337 Redesign Bluetooth page to match WiFi layout
HTML:
- Create bt-layout-container with flex layout
- Left side: visualizations (radar, selected device, device types,
  tracker detection, signal distribution, FindMy detection)
- Right side: scrollable device card list

CSS:
- Add bt-layout-container styles matching wifi-layout-container
- Add bt-device-card styles with purple accent
- Add device type overview styles
- Add signal distribution bar styles
- Add responsive breakpoints

JavaScript:
- Update addBtDeviceCard to create cards in new device list
- Add selectBtDevice for device selection
- Add updateBtStatsPanels for device type and signal stats
- Add updateBtFindMyList for FindMy device tracking

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 14:41:56 +00:00
Smittix 5f4d1b05a8 Add debug logging for channel recommendation
Temporary console.log statements to diagnose why channel
recommendation always shows 1 and 36.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 14:37:33 +00:00
Smittix 370c46bddb Add CSS styling for WiFi client cards
- Add .wifi-client-card styles matching network card layout
- Purple border and subtle background for visual distinction
- Consistent font sizing with network cards

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 14:35:17 +00:00
Smittix 47b5e03bbb Add debug logging for WiFi client card issues
Temporary console.log statements to diagnose why clients
are not appearing in the device list.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 14:32:41 +00:00
Smittix 556ca59a99 Fix WiFi client updates, rogue AP detection, and channel recommendation
Backend:
- Send client updates when probes or signal change significantly
- Previously only new clients were reported, updates were ignored

Frontend:
- Add client cards to device list (was only showing networks)
- Fix rogue AP detection to check OUI - excludes legitimate mesh systems
- Improve channel recommendation with detailed usage breakdown
- Show per-channel interference counts for 2.4GHz
- Show unused channel count for 5GHz

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 14:27:15 +00:00
Smittix 81c5af474d Add visual rogue AP indicator for suspected evil twin detection
- Add rogueBssids Set to track all BSSIDs flagged as rogues
- Display red banner with pulsing animation on rogue network cards
- Apply red border and background tint to rogue AP cards
- Show prominent warning in selected device panel for rogue APs
- Change SSID color to red when viewing rogue AP details

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 14:18:53 +00:00
Smittix cdaee3f62f Fix hidden SSID, probe analysis, and device correlation
Hidden SSID fixes:
- Only track networks that are originally hidden (empty/Hidden ESSID)
- Reveal hidden SSIDs when network ESSID changes or client probes match
- Show count of hidden networks being monitored
- Show revealed SSIDs with checkmark

Probe analysis fixes:
- Call scheduleProbeAnalysisUpdate when client has probes
- Add periodic updateProbeAnalysis call every 2 seconds
- Properly trigger probe analysis from client handler

Other fixes:
- Remove drawNetworkGraph call (topology panel was removed)
- Add updateHiddenSsidDisplay to periodic updates
- Improve panel messages when no data available

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 13:56:41 +00:00
Smittix aab4288f67 Reorganize WiFi panels, remove PMKID, add aircrack integration
Layout changes:
- Security overview now next to network radar
- Channel utilization (2.4 GHz and 5 GHz) side by side
- Removed network topology panel
- Removed PMKID capture panel and functionality

Handshake improvements:
- Added "Crack with Aircrack-ng" button when handshake is captured
- Added /wifi/handshake/crack backend route
- Prompts for wordlist path with common defaults
- Shows password when found with notification
- 5 minute timeout with helpful error message

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 13:50:14 +00:00
Smittix bab49e4442 Improve WiFi layout and fix capture functionality
Layout changes:
- Move device list to right column beside visualizations
- Add dedicated WiFi device list panel with header and count
- Hide waterfall and generic output for WiFi mode
- Add responsive styles for smaller screens

Capture fixes:
- Fix handshake capture: add interface param, stop existing scan first
- Fix PMKID capture: add interface param, stop existing scan first
- Add proper error handling with try/catch for both capture functions
- Use monitorInterface variable when available

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 13:43:11 +00:00
Smittix 7608aca681 Hide waterfall canvas in WiFi mode
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 13:35:34 +00:00
Smittix 58907bdc4d Fix WiFi layout and remove redundant Target Signal panel
- Add min-height: 0 to output-panel to fix grid overflow scrolling
- Add min-height: 200px to output-content for device cards visibility
- Add max-height: 50vh to wifi-visuals to leave room for device list
- Make wifi-visuals scrollable when content exceeds max-height
- Remove Target Signal panel (redundant with Selected Device panel)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 13:33:12 +00:00
Smittix 8dfd92082c Move Selected Device panel to top of WiFi visuals
Moves the Selected Device panel to be the first element in the
WiFi visualizations grid so it's visible without scrolling.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 13:29:15 +00:00
Smittix e39304da90 Add aircraft image panel to ADS-B tab
- Add new panel to display aircraft photos when selected
- Fetch photos from planespotters API via /adsb/aircraft-photo endpoint
- Cache photos to avoid repeated API calls
- Show loading, no photo, and placeholder states appropriately
- Reduced map panel to span 2 to accommodate new image panel

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 13:26:47 +00:00
Smittix 31fd3f3f63 Add WiFi device selection and fix signal strength display
- Add Selected Device panel showing detailed info for networks/clients
- Make network cards clickable to view details
- Make probe analysis client entries clickable
- Fix signal strength to show "N/A" when airodump returns -1
- Add visual signal meter and action buttons to selected device panel

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 13:23:27 +00:00
Smittix e1ab24b36b Improve WiFi monitor mode and scan interface detection
- Verify monitor interface actually exists before returning success
- Check for common interface naming patterns (wlan0mon, wlan1mon, etc.)
- Add interface existence check before starting scan
- Show available interfaces in error messages for debugging
- Better logging of monitor mode and scan failures

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 13:11:23 +00:00
Smittix f5b92ddcf9 Fix ADS-B tab and improve WiFi scanning feedback
- Hide waterfall and output panels in aircraft mode (use dedicated visualization)
- Add aircraft list panel with clickable aircraft selection
- Add selected aircraft info panel showing altitude, speed, heading, squawk
- Add "Open Full Dashboard" button linking to dedicated aircraft radar
- Add debugging console logs and alert messages to WiFi scan function
- Better error feedback when WiFi interface not selected or scan fails

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 13:08:36 +00:00
Smittix d9ee87d4b4 Improve WiFi device selection visibility and error handling
- Added "Select Device" label to WiFi adapter dropdown
- Better error handling with user notifications when interfaces fail to load
- Shows loading state while detecting interfaces
- Clearer notification messages for found/missing interfaces

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 13:01:47 +00:00
Smittix 5e83db54ac Fix WiFi section scrolling to show channel recommendation and correlation
- Changed output-panel overflow from hidden to auto
- Allows scrolling to see all content including bottom panels

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:59:30 +00:00
Smittix de7b12a759 Streamline WiFi scanning with auto monitor mode and better device detection
- Auto-enable monitor mode when clicking Start Scanning (no manual step needed)
- Improved WiFi interface detection using airmon-ng for chipset info
- Added lsusb fallback for USB adapter identification
- Fixed interface display format in enableMonitorMode callback
- Better error handling and status notifications during scan startup

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:57:09 +00:00
Smittix 1236011174 Improve WiFi device identification, remove signal history, fix Listen button
- WiFi interfaces now show driver, chipset, and MAC address for easier identification
- Remove signal history feature from WiFi and Bluetooth sections (HTML, JS, CSS, API)
- Fix Listen button in Listening Post signal hits to properly tune to frequency
- Make stopAudio() async and improve tuneToFrequency() with proper awaits
- Fix Device Intelligence panel auto-expand and manufacturer display

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:53:38 +00:00
Smittix b60f2cdf81 Parse Bluetooth RSSI from bluetoothctl output
- Capture RSSI from [CHG] Device XX:XX RSSI: -XX lines
- Update device RSSI in real-time as bluetoothctl reports changes
- Auto-refresh selected device panel when RSSI updates
- Preserve RSSI when merging device updates

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:40:49 +00:00
Smittix 0c310ab068 Add Bluetooth selected device details panel
- New panel shows full device details when clicked
- Displays name, type, RSSI with signal bars, MAC, manufacturer
- Shows tracker/FindMy badges for detected trackers
- Buttons to enumerate services and copy MAC address
- Reorganized BT visualization layout

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:37:03 +00:00
Smittix a87f66cc0c Enlarge Selected Target panel for aircraft photos
- Increased max-height from 280px to 480px
- Added styling for photo container with cyan border
- Photo limited to 140px height with cover fit

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:34:12 +00:00
Smittix c05756357f Add requests to requirements.txt
Required for aircraft photo API calls.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:30:45 +00:00
Smittix f4b4b5febd Add Bluetooth device list and tracker panels
- Added clickable device list panel sorted by signal strength
- Added dedicated tracker detection panel for AirTags/Tiles
- Clicking a device selects it for signal tracking and targeting
- Devices show name, MAC, RSSI with color-coded signal strength

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:29:25 +00:00
Smittix 805290b17f Fix Listening Post audio issues
- Add auto-reconnect on audio player errors/stalls
- Fix tuneToFrequency to properly wait between stop/start
- Improve ffmpeg encoding: 96k bitrate, 44.1kHz output, low latency flags
- Increase stream chunk size for smoother playback

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:26:55 +00:00
Smittix fecc2237b8 Add aircraft photos from Planespotters.net
- Backend route to proxy photo requests from Planespotters API
- Frontend displays photo in Selected Target panel when available
- Photos are cached to avoid repeated API calls
- Clicking photo links to full image on Planespotters.net

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:22:29 +00:00
Smittix 471cc1ee94 Add bias_t parameter to build_adsb_command()
Completes bias-T support across all SDR command builder methods.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:17:02 +00:00
Smittix 41ebf59964 Remove bufsize=1 from sensor subprocess
Line buffering only works with text mode, not binary pipes.
Fixes RuntimeWarning about line buffering.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:15:14 +00:00
Smittix a5e9a3e1ce Add bias_t parameter to build_ism_command()
Same fix as build_fm_demod_command() - the parameter was being
passed but not defined in the method signatures.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:08:38 +00:00
Smittix 23689d9fe1 Add bias_t parameter to SDR command builders
The bias_t parameter was being passed to build_fm_demod_command()
but wasn't defined in the method signatures, causing an unexpected
keyword argument error.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:05:11 +00:00
Smittix 601d432fbf Add python3-venv to Debian package installs
Required for creating Python virtual environments on Ubuntu/Debian.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:00:23 +00:00
Smittix a21e9c508e Fix unbound variable error in dump1090 build
Wrap build in subshell to isolate EXIT trap, preventing
orig_dir unbound variable error at script exit.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 11:58:14 +00:00
Smittix 55b0c0509d Add progress bar to setup script
Shows step counter with visual progress bar during installation:
[3/15] ██████░░░░░░░░░░░░░░ 20% - Installing ffmpeg

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 11:55:17 +00:00
Smittix 563c6b79fa Suppress needrestart prompts on Ubuntu Server
Set DEBIAN_FRONTEND and NEEDRESTART_MODE to prevent the
"scanning processes/candidates/microcode" messages during apt installs.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 11:48:10 +00:00
Smittix 8d9e5f9d56 Add security hardening and bias-t support
Security improvements:
- Add interface name validation to prevent command injection
- Fix XSS vulnerability in pager message display
- Add security headers (X-Content-Type-Options, X-Frame-Options, etc.)
- Disable Werkzeug debug PIN
- Add security documentation

Features:
- Add bias-t power support for SDR dongles across all modes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 11:29:24 +00:00
Smittix c0f6ccaf2a Merge pull request #36 from JonanOribe/main
Thanks for the contribution! The Bluetooth tests look great and will help ensure stability as we continue development.
2026-01-08 10:38:04 +00:00
Jon Ander Oribe 9b3e4ec7fb Merge branch 'main' into main 2026-01-08 06:57:33 +01:00
Jon Ander Oribe 9d45eb21a4 Update .gitignore 2026-01-08 06:51:08 +01:00
Jon Ander Oribe bcf8fe59f5 Delete uv.lock 2026-01-08 06:47:15 +01:00
Jon Ander Oribe 5b411456c7 Update 2026-01-08 06:46:52 +01:00
Smittix 4432816934 Update README.md with project details and features 2026-01-07 21:45:09 +00:00
Smittix 5277537445 Update troubleshooting guide with new issues and solutions
Added additional troubleshooting steps for ADS-B mode and installation issues.
2026-01-07 21:44:22 +00:00
Smittix e73ce8cd8f Update README with new features and installation details 2026-01-07 21:38:54 +00:00
Smittix 120015d133 Update troubleshooting guide for clarity and details 2026-01-07 21:30:21 +00:00
Smittix f85cf61019 Revise hardware setup and installation instructions
Updated hardware setup documentation with new installation instructions and tool references.
2026-01-07 21:26:56 +00:00
Smittix 41226d173a Revise README for better clarity and organization
Updated README to improve clarity and structure, including installation instructions and troubleshooting.
2026-01-07 21:23:51 +00:00
Smittix 83244c85fe Add uv.lock to .gitignore
Prevent accidental commits of uv lock files since we use
requirements.txt for dependency management.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 20:46:14 +00:00
Smittix 27dd868d97 Add pager message filtering (closes #40)
Add ability to filter out unwanted pager messages from display:
- Hide "Tone Only" messages by default (toggle in UI)
- Custom keyword filter (comma-separated list)
- Filtered messages are still logged and counted, just hidden from view
- Filter settings persist in localStorage

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 20:14:46 +00:00
Smittix 45b35ea5b0 Consolidate setup scripts into single setup.sh
Merge the improved setup-dev.sh logic into setup.sh and remove the
separate dev script. The consolidated script includes:
- Stricter bash error handling (set -Eeuo pipefail)
- Cleaner output with info/ok/warn/fail helpers
- gpsd installation for GPS daemon support
- Required tools verification with hard fail
- Source build fallback for dump1090

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 20:07:40 +00:00
Smittix ac8b9f82cd Add gpsd installation to setup-dev.sh
Include gpsd daemon in the setup script for both macOS (via Homebrew)
and Debian/Ubuntu (via apt with gpsd-clients). Also add gpsd to the
required tools check.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 20:04:57 +00:00
Smittix 9d0e417f2a Simplify GPS to gpsd-only and streamline UI controls
Remove direct serial GPS dongle support in favor of gpsd daemon connectivity.
The UI now auto-connects to gpsd on page load and shows a GPS indicator when connected.
Simplify ADS-B dashboard controls bar for a cleaner, more compact layout.
Add setup-dev.sh for streamlined development environment setup.

- Remove GPSReader class and NMEA parsing (utils/gps.py)
- Consolidate to GPSDClient only with auto-connect endpoint
- Add GPS indicator with pulsing dot animation
- Compact controls bar with smaller fonts and tighter spacing
- Add aircraft database download banner/functionality

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 19:49:58 +00:00
Smittix 40369ccb7b Support /usr/sbin paths for aircrack-ng on Debian and add hcxtools
- Add get_tool_path() to check /usr/sbin and /sbin for tools
- Update wifi.py to use full paths for airmon-ng, airodump-ng, aireplay-ng, aircrack-ng
- Add hcxdumptool and hcxtools to setup.sh for Debian and macOS
- Update check_cmd() in setup.sh to also check /usr/sbin and /sbin

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 18:20:39 +00:00
Smittix 61ef3f7bdd Fix ADS-B tracking and aircraft database lookup
- Add debug stats (bytes_received, lines_received) to diagnose connection issues
- Capture stderr from dump1090 to show actual error messages on failure
- Add dump1090_running status to /adsb/status endpoint
- Fix aircraft_db.lookup() to handle Mictronics array format [reg, type, flags]
  instead of expecting dict format {r: reg, t: type}
- Add logging for first few SBS lines to help debug parsing issues

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 17:42:17 +00:00
Smittix bcb1a825d3 Prevent duplicate signal hits in Listening Post
Added duplicate detection to addSignalHit():
- Tracks recent signals by frequency in a Map
- Ignores same frequency within 5 seconds
- Auto-cleans entries older than 30 seconds

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 22:33:38 +00:00
Smittix 1f7a3fe664 Fix SDRType.LIMESDR -> SDRType.LIME_SDR typo
The enum is LIME_SDR (with underscore) but the code used LIMESDR,
causing an AttributeError on /adsb/tools endpoint.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 22:24:06 +00:00
Smittix dcd855896e Add pauses after each installation step for readability
Each package install now pauses for 1 second after completion
so the user can see what happened before it scrolls away.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 22:13:22 +00:00
Smittix 4778134ab6 Fix setup.sh - remove set -e, improve dump1090 build
- Changed set -e to set +e - handle errors explicitly instead
- set -e was causing silent early exits on any failure
- Improved dump1090 build with more dependencies
- Added BLADERF=no to skip optional BladeRF dependency
- Falls back to simpler antirez/dump1090 if FlightAware fails
- Better success/failure output for each package

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 22:03:12 +00:00
Smittix 300b19d1d6 Simplify Debian tool installation - always install everything
- Remove conditional MISSING_* checks - just install all tools
- apt-get will skip already-installed packages anyway
- Add verification step at end showing what actually got installed
- Better output showing success/failure for each package
- This fixes issues where flag-based logic was failing silently

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 22:00:27 +00:00
Smittix 945ae33361 Fix setup.sh to install system tools before Python deps
- Reorder: check tools -> install tools -> Python deps
- This ensures system tools install even if pip fails
- Make pip failures non-fatal (continue with warning)
- Auto-install python3-venv if needed on Debian
- Don't exit on venv creation failure, continue with tools

The previous order meant if pip failed (common on fresh installs),
the script would exit before installing rtl-sdr, multimon-ng, etc.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 21:57:57 +00:00
Smittix dbbcb6c5cc Add dump1090 build from source when not in repositories
On newer Debian versions (like Trixie), dump1090 isn't available
in the package repositories. Now automatically builds from the
FlightAware GitHub repo as a fallback:

- Installs build dependencies (build-essential, librtlsdr-dev, etc.)
- Clones from github.com/flightaware/dump1090
- Compiles and installs to /usr/local/bin

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 21:53:38 +00:00
Smittix 016959ad7c Make setup.sh fully automatic - no prompts required
- Remove all Y/n prompts for tool installation
- Install tools automatically when missing
- Show clear progress for each package being installed
- Show warnings if individual packages fail instead of silent failure
- Changed apt to apt-get for better script compatibility
- Auto-setup udev rules on Linux without prompting
- Auto-install Homebrew on macOS if missing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 21:52:19 +00:00
Smittix 7a9599786c Add error handling to checkStatus() to prevent console errors
The periodic /status check was throwing uncaught promise errors
when the server was unavailable or restarting.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 21:48:42 +00:00
Smittix fa537390c5 Fix audio stream errors and improve error handling
- Fix "no supported source" error by returning empty audio response
  instead of JSON when audio not running (browser can't parse JSON)
- Add wait loop in stream endpoint to handle race condition with start
- Add logging for rtl_fm and ffmpeg commands
- Capture stderr to log actual process errors
- Check if processes exit immediately and log reason
- Improved error messages for users

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 21:42:55 +00:00
Smittix bb24bdb06c Fix aircraft dashboard audio endpoints (404 error)
Changed /spectrum/audio/* to /listening/audio/* to match the
actual listening_post blueprint URL prefix.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 21:37:36 +00:00
Smittix b55100d5c3 Fix misleading 'No RTL-SDR devices found' warning in Listening Post
The warning was triggered when ffmpeg was missing, but the message
incorrectly said "No RTL-SDR devices found". Now properly shows
"ffmpeg not found" with installation instructions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 21:17:49 +00:00
Smittix 02cb9c751a Fix audio dependency to use ffmpeg instead of sox
The Listening Post actually uses ffmpeg for audio encoding, not sox.
Updated all documentation, setup scripts, and code to reflect this:

- Removed unused find_sox() function from listening_post.py
- Simplified tools endpoint to only check for ffmpeg
- Updated CHANGELOG, README, HARDWARE.md, Dockerfile
- Fixed setup.sh to check for ffmpeg
- Updated frontend warnings to mention ffmpeg

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 20:53:54 +00:00
Smittix 8555938f52 Add readsb warning for HackRF/LimeSDR ADS-B tracking
- Enhanced /adsb/tools endpoint to detect SoapySDR hardware and check for readsb
- Added UI warning in aircraft dashboard when HackRF/LimeSDR is detected but readsb is missing
- Warning includes expandable installation instructions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 20:46:36 +00:00
Smittix a2a3ea62f1 Fix SoapySDR detection and HackRF ADS-B error message
- Try multiple SoapySDR utility names: SoapySDRUtil, soapy_sdr_util, soapysdr-util
- Improve error message for HackRF/LimeSDR ADS-B to mention readsb requirement

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 20:38:35 +00:00
Smittix 0d5310eb4b Fix DataStore subscript access for ADS-B tracking
Add __getitem__, __setitem__, and __delitem__ methods to DataStore
class to support dict-style subscript notation (store[key]).

Fixes TypeError: 'DataStore' object is not subscriptable

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 20:35:51 +00:00
Smittix 5c6bd5d65a Release v2.0.0
- Add Listening Post mode with frequency scanner
- Add device correlation and settings system
- Overhaul documentation and setup script
- Update Dockerfile with all dependencies
- Add comprehensive test suite

See CHANGELOG.md for full details.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 18:55:08 +00:00
Smittix dcb1b4e3a6 Merge remote changes, keep Acknowledgments section 2026-01-06 17:35:22 +00:00
Smittix b5547d3fa9 Add Listening Post, improve setup and documentation
- Add Listening Post mode with frequency scanner and audio monitoring
- Add dependency warning for aircraft dashboard listen feature
- Auto-restart audio when switching frequencies
- Fix toolbar overflow on aircraft dashboard custom frequency
- Update setup script with full macOS/Debian support
- Clean up README and documentation for clarity
- Add sox and dump1090 to Dockerfile
- Add comprehensive tool reference to HARDWARE.md
- Add correlation, settings, and database utilities
- Add new test files for routes, validation, correlation, database

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 17:34:53 +00:00
Jon Ander Oribe a5a2692a5f Testing for Bluetooth & Black without running 2026-01-06 08:20:33 +01:00
Smittix 7a112c84be Update FEATURES.md 2026-01-05 23:29:13 +00:00
Smittix b3b3566a27 Update README.md 2026-01-05 23:22:51 +00:00
Smittix f77c501db6 Add files via upload 2026-01-05 23:17:43 +00:00
476 changed files with 226654 additions and 13536 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.
+21 -1
View File
@@ -1,6 +1,8 @@
# Git
# Git & CI
.git
.gitignore
.github
.claude
# Python
__pycache__
@@ -15,6 +17,7 @@ venv/
.eggs/
*.egg-info/
*.egg
.uv
# IDE
.idea/
@@ -28,10 +31,27 @@ tests/
.coverage
htmlcov/
.mypy_cache/
.ruff_cache
.DS_Store
tasks/
# Documentation
*.md
# Runtime data (mounted as volume)
instance/
data/
# Build scripts
build-multiarch.sh
# Logs
*.log
# Local Postgres data
pgdata/
pgdata.bak/
# Captured files (don't include in image)
*.cap
*.pcap
+39
View File
@@ -0,0 +1,39 @@
# =============================================================================
# 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
+1
View File
@@ -0,0 +1 @@
buy_me_a_coffee: smittix
+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
+39
View File
@@ -8,11 +8,16 @@ env/
venv/
.venv/
ENV/
uv.lock
# Logs
*.log
pager_messages.log
# Local data
downloads/
pgdata/
# IDE
.idea/
.vscode/
@@ -28,3 +33,37 @@ Thumbs.db
dist/
build/
*.egg-info/
# Package manager lock files & DB files
uv.lock
*.db
*.sqlite3
intercept.db
# Instance folder (contains database with user data)
instance/
# Agent configs with real credentials (keep template only)
intercept_agent_*.cfg
!intercept_agent.cfg
# Temporary files
/tmp/
*.tmp
# 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/
# Env files
.env
.env.*
!.env.example
# Local utility scripts
reset-sdr.*
+574
View File
@@ -0,0 +1,574 @@
# Changelog
All notable changes to iNTERCEPT will be documented in this file.
## [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
- **Real-time WebSocket Waterfall** - I/Q capture with server-side FFT
- Click-to-tune, zoom controls, and auto-scaling quantization
- Shared waterfall UI across SDR modes with function bar controls
- WebSocket frame serialization and connection reuse
- **Cross-Module Frequency Routing** - Tune from Listening Post directly to decoders
- **Pure Python SSTV Decoder** - Replaces broken slowrx C dependency
- Real-time decode progress with partial image streaming
- VIS detector state in signal monitor diagnostics
- Image gallery with delete and download functionality
- **Real-time Signal Scope** - Live signal visualization for pager, sensor, and SSTV modes
- **SSTV Image Gallery** - Delete and download decoded images
- **USB Device Probe** - Detect broken SDR devices before rtl_fm crashes
### Fixed
- DMR dsd-fme protocol flags, device label, and tuning controls
- DMR frontend/backend state desync causing 409 on start
- Digital voice decoder producing no output due to wrong dsd-fme flags
- SDR device lock-up from unreleased device registry on process crash
- APRS crash on large station count and station list overflow
- Settings modal overflowing viewport on smaller screens
- Waterfall crash on zoom by reusing WebSocket and adding USB release retry
- PD120 SSTV decode hang and false leader tone detection
- WebSocket waterfall blocked by login redirect
- TSCM sweep KeyError on RiskLevel.NEEDS_REVIEW
### Removed
- GSM Spy functionality removed for legal compliance
---
## [2.14.0] - 2026-02-06
### Added
- **DMR Digital Voice Decoder** - Decode DMR, P25, NXDN, and D-STAR protocols
- Integration with dsd-fme (Digital Speech Decoder - Florida Man Edition)
- Real-time SSE streaming of sync, call, voice, and slot events
- Call history table with talkgroup, source ID, and protocol tracking
- Protocol auto-detection or manual selection
- Pipeline error diagnostics with rtl_fm stderr capture
- **DMR Visual Synthesizer** - Canvas-based signal activity visualization
- Spring-physics animated bars reacting to SSE decoder events
- Color-coded by event type: cyan (sync), green (call), orange (voice)
- Center-outward ripple bursts on sync events
- Smooth decay and idle breathing animation
- Responsive canvas with window resize handling
- **HF SSTV General Mode** - Terrestrial slow-scan TV on shortwave frequencies
- Predefined HF SSTV frequencies (14.230, 21.340, 28.680 MHz, etc.)
- Modulation support for USB/LSB reception
- **WebSDR Integration** - Remote HF/shortwave listening via WebSDR servers
- **Listening Post Enhancements** - Improved signal scanner and audio handling
### Fixed
- APRS rtl_fm startup failure and SDR device conflicts
- DSD voice decoder detection for dsd-fme and PulseAudio errors
- dsd-fme protocol flags and ncurses disable for headless operation
- dsd-fme audio output flag for pipeline compatibility
- TSCM sweep scan resilience with per-device error isolation
- TSCM WiFi detection using scanner singleton for device availability
- TSCM correlation and cluster emission fixes
- Detected Threats panel items now clickable to show device details
- Proximity radar tooltip flicker on hover
- Radar blip flicker by deferring renders during hover
- ISS position API priority swap to avoid timeout delays
- Updater settings panel error when updater.js is blocked
- Missing scapy in optionals dependency group
---
## [2.13.1] - 2026-02-04
### Added
- **UI Overhaul** - Revamped styling with slate/cyan theme
- Switched app font to JetBrains Mono
- Global navigation bar across all dashboards
- Cyan-tinted map tiles as default
- **Signal Scanner Rewrite** - Switched to rtl_power sweep for better coverage
- SNR column added to signal hits table
- SNR threshold control for power scan
- Improved sweep progress tracking and stability
- Frequency-based sweep display with range syncing
- **Listening Post Audio** - WAV streaming with retry and fallback
- WebSocket audio fallback for listening
- User-initiated audio play prompt
- Audio pipeline restart for fresh stream headers
### Fixed
- WiFi connected clients panel now filters to selected AP instead of showing all clients
- USB device contention when starting audio pipeline
- Dual scrollbar issue on main dashboard
- Controls bar alignment in dashboard pages
- Mode query routing from dashboard nav
---
## [2.13.0] - 2026-02-04
### Added
- **WiFi Client Display** - Connected clients shown in AP detail drawer
- Real-time client updates via SSE streaming
- Probed SSID badges for connected clients
- Signal strength indicators and vendor identification
- **Help Modal** - Keyboard shortcuts reference system
- **Main Dashboard Button** - Quick navigation from any page
- **Settings Modal** - Accessible from all dashboards
### Changed
- Dashboard CSS improvements and consistency fixes
---
## [2.12.1] - 2026-02-02
### Added
- **SDR Device Registry** - Prevents decoder conflicts between concurrent modes
- **SDR Device Status Panel** - Shows connected SDR devices with ADS-B Bias-T toggle
- **Real-time Doppler Tracking** - ISS SSTV reception with Doppler correction
- **TCP Connection Support** - Meshtastic devices connectable over TCP
- **Shared Observer Location** - Configurable shared location with auto-start options
- **slowrx Source Build** - Fallback build for Debian/Ubuntu
### Fixed
- SDR device type not synced on page refresh
- Meshtastic connection type not restored on page refresh
- WiFi deep scan polling on agent with normalized scan_type value
- Auto-detect RTL-SDR drivers and blacklist instead of prompting
- TPMS pressure field mappings for 433MHz sensor display
- Agent capabilities cache invalidation after monitor mode toggle
---
## [2.12.0] - 2026-01-29
### Added
- **ISS SSTV Decoder Mode** - Receive Slow Scan Television transmissions from the ISS
- Real-time ISS tracking globe with accurate position via N2YO API
- Leaflet world map showing ISS ground track and current position
- Location settings for ISS pass predictions
- Integration with satellite tracking TLE data
- **GitHub Update Notifications** - Automatic new version alerts
- Checks for updates on app startup
- Unobtrusive notification when new releases are available
- Configurable check interval via settings
- **Meshtastic Enhancements**
- QR code support for easy device sharing
- Telemetry display with battery, voltage, and environmental data
- Traceroute visualization for mesh network topology
- Improved node synchronization between map and top bar
- **UI Improvements**
- New Space category for satellite and ISS-related modes
- Pulsating ring effect for tracked aircraft/vessels
- Map marker highlighting for selected aircraft in ADS-B
- Consolidated settings and dependencies into single modal
- **Auto-Update TLE Data** - Satellite tracking data updates automatically on app startup
- **GPS Auto-Connect** - AIS dashboard now connects to gpsd automatically
### Changed
- **Utility Meters** - Added device grouping by ID with consumption trends
- **Utility Meters** - Device intelligence and manufacturer information display
### Fixed
- **SoapySDR** - Module detection on macOS with Homebrew
- **dump1090** - Build failures in Docker containers
- **dump1090** - Build failures on Kali Linux and newer GCC versions
- **Flask** - Ensure Flask 3.0+ compatibility in setup script
- **psycopg2** - Now optional for Flask/Werkzeug compatibility
- **Bias-T** - Setting now properly passed to ADS-B and AIS dashboards
- **Dark Mode Maps** - Removed CSS filter that was inverting dark tiles
- **Map Tiles** - Fixed CARTO tile URLs and added cache-busting
- **Meshtastic** - Traceroute button and dark mode map fixes
- **ADS-B Dashboard** - Height adjustment to prevent bottom controls cutoff
- **Audio Visualizer** - Now works without spectrum canvas
---
## [2.11.0] - 2026-01-28
### Added
- **Meshtastic Mesh Network Integration** - LoRa mesh communication support
- Connect to Meshtastic devices (Heltec, T-Beam, RAK) via USB/Serial
- Real-time message streaming via SSE
- Channel configuration with encryption key support
- Node information display with signal metrics (RSSI, SNR)
- Message history with up to 500 messages
- **Ubertooth One BLE Scanner** - Advanced Bluetooth scanning
- Passive BLE packet capture across all 40 BLE channels
- Raw advertising payload access
- Integration with existing Bluetooth scanning modes
- Automatic detection of Ubertooth hardware
- **Offline Mode** - Run iNTERCEPT without internet connectivity
- Bundled Leaflet 1.9.4 (JS, CSS, marker images)
- Bundled Chart.js 4.4.1
- Bundled Inter and JetBrains Mono fonts (woff2)
- Local asset status checking and validation
- **Settings Modal** - New configuration interface accessible from navigation
- Offline tab: Toggle offline mode, configure asset sources
- Display tab: Theme and animation preferences
- About tab: Version info and links
- **Multiple Map Tile Providers** - Choose from:
- OpenStreetMap (default)
- CartoDB Dark
- CartoDB Positron (light)
- ESRI World Imagery
- Custom tile server URL
### Changed
- **Dashboard Templates** - Conditional asset loading based on offline settings
- **Bluetooth Scanner** - Added Ubertooth backend alongside BlueZ/DBus
- **Dependencies** - Added meshtastic SDK to requirements.txt
### Technical
- Added `routes/meshtastic.py` for Meshtastic API endpoints
- Added `utils/meshtastic.py` for device management
- Added `utils/bluetooth/ubertooth_scanner.py` for Ubertooth support
- Added `routes/offline.py` for offline mode API
- Added `static/js/core/settings-manager.js` for client-side settings
- Added `static/css/settings.css` for settings modal styles
- Added `static/css/modes/meshtastic.css` for Meshtastic UI
- Added `static/js/modes/meshtastic.js` for Meshtastic frontend
- Added `templates/partials/modes/meshtastic.html` for Meshtastic mode
- Added `templates/partials/settings-modal.html` for settings UI
- Added `static/vendor/` directory structure for bundled assets
---
## [2.10.0] - 2026-01-25
### Added
- **AIS Vessel Tracking** - Real-time ship tracking via AIS-catcher
- Full-screen dashboard with interactive maritime map
- Vessel details: name, MMSI, callsign, destination, ETA
- Navigation data: speed, course, heading, rate of turn
- Ship type classification and dimensions
- Multi-SDR support (RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay)
- **VHF DSC Channel 70 Monitoring** - Digital Selective Calling for maritime distress
- Real-time decoding of DSC messages (Distress, Urgency, Safety, Routine)
- MMSI country identification via Maritime Identification Digits (MID) lookup
- Position extraction and map markers for distress alerts
- Prominent visual overlay for DISTRESS and URGENCY alerts
- Permanent database storage for critical alerts with acknowledgement workflow
- **Spy Stations Database** - Number stations and diplomatic HF networks
- Comprehensive database from priyom.org
- Station profiles with frequencies, schedules, operators
- Filter by type (number/diplomatic), country, and mode
- Tune integration with Listening Post
- Famous stations: UVB-76, Cuban HM01, Israeli E17z
- **SDR Device Conflict Detection** - Prevents collisions between AIS and DSC
- **DSC Alert Summary** - Dashboard counts for unacknowledged distress/urgency alerts
- **AIS-catcher Installation** - Added to setup.sh for Debian and macOS
### Changed
- **UI Labels** - Renamed "Scanner" to "Listening Post" and "RTLAMR" to "Meters"
- **Pager Filter** - Changed from onchange to oninput for real-time filtering
- **Vessels Dashboard** - Now includes VHF DSC message panel alongside AIS tracking
- **Dependencies** - Added scipy and numpy for DSC signal processing
### Fixed
- **DSC Position Decoder** - Corrected octal literal in quadrant check
---
## [2.9.5] - 2026-01-14
### Added
- **MAC-Randomization Resistant Detection** - TSCM now identifies devices using randomized MAC addresses
- **Clickable Score Cards** - Click on threat scores to see detailed findings
- **Device Detail Expansion** - Click-to-expand device details in TSCM results
- **Root Privilege Check** - Warning display when running without required privileges
- **Real-time Device Streaming** - Devices stream to dashboard during TSCM sweep
### Changed
- **TSCM Correlation Engine** - Improved device correlation with comprehensive reporting
- **Device Classification System** - Enhanced threat classification and scoring
- **WiFi Scanning** - Improved scanning reliability and device naming
### Fixed
- **RF Scanning** - Fixed scanning issues with improved status feedback
- **TSCM Modal Readability** - Improved modal styling and close button visibility
- **Linux Device Detection** - Added more fallback methods for device detection
- **macOS Device Detection** - Fixed TSCM device detection on macOS
- **Bluetooth Event Type** - Fixed device type being overwritten
- **rtl_433 Bias-T Flag** - Corrected bias-t flag handling
---
## [2.9.0] - 2026-01-10
### Added
- **Landing Page** - Animated welcome screen with logo reveal and "See the Invisible" tagline
- **New Branding** - Redesigned logo featuring 'i' with signal wave brackets
- **Logo Assets** - Full-size SVG logos in `/static/img/` for external use
- **Instagram Promo** - Animated HTML promo video template in `/promo/` directory
- **Listening Post Scanner** - Fully functional frequency scanning with signal detection
- Scan button toggles between start/stop states
- Signal hits logged with Listen button to tune directly
- Proper 4-column display (Time, Frequency, Modulation, Action)
### Changed
- **Rebranding** - Application renamed from "INTERCEPT" to "iNTERCEPT"
- **Updated Tagline** - "Signal Intelligence & Counter Surveillance Platform"
- **Setup Script** - Now installs Python packages via apt first (more reliable on Debian/Ubuntu)
- Uses `--system-site-packages` for venv to leverage apt packages
- Added fallback logic when pip fails
- **Troubleshooting Docs** - Added sections for pip install issues and apt alternatives
### Fixed
- **Tuning Dial Audio** - Fixed audio stopping when using tuning knob
- Added restart prevention flags to avoid overlapping restarts
- Increased debounce time for smoother operation
- Added silent mode for programmatic value changes
- **Scanner Signal Hits** - Fixed table column count and colspan
- **Favicon** - Updated to new 'i' logo design
---
## [2.0.0] - 2026-01-06
### Added
- **Listening Post Mode** - New frequency scanner with automatic signal detection
- Scans frequency ranges and stops on detected signals
- Real-time audio monitoring with ffmpeg integration
- Skip button to continue scanning after signal detection
- Configurable dwell time, squelch, and step size
- Preset frequency bands (FM broadcast, Air band, Marine, etc.)
- Activity log of detected signals
- **Aircraft Dashboard Improvements**
- Dependency warning when rtl_fm or ffmpeg not installed
- Auto-restart audio when switching frequencies
- Fixed toolbar overflow with custom frequency input
- **Device Correlation** - Match WiFi and Bluetooth devices by manufacturer
- **Settings System** - SQLite-based persistent settings storage
- **Comprehensive Test Suite** - Added tests for routes, validation, correlation, database
### Changed
- **Documentation Overhaul**
- Simplified README with clear macOS and Debian installation steps
- Added Docker installation option
- Complete tool reference table in HARDWARE.md
- Removed redundant/confusing content
- **Setup Script Rewrite**
- Full macOS support with Homebrew auto-installation
- Improved Debian/Ubuntu package detection
- Added ffmpeg to tool checks
- Better error messages with platform-specific install commands
- **Dockerfile Updated**
- Added ffmpeg for Listening Post audio encoding
- Added dump1090 with fallback for different package names
### Fixed
- SoapySDR device detection for RTL-SDR and HackRF
- Aircraft dashboard toolbar layout when using custom frequency input
- Frequency switching now properly stops/restarts audio
### Technical
- Added `utils/constants.py` for centralized configuration values
- Added `utils/database.py` for SQLite settings storage
- Added `utils/correlation.py` for device correlation logic
- Added `routes/listening_post.py` for scanner endpoints
- Added `routes/settings.py` for settings API
- Added `routes/correlation.py` for correlation API
---
## [1.2.0] - 2026-12-29
### Added
- Airspy SDR support
- GPS coordinate persistence
- SoapySDR device detection improvements
### Fixed
- RTL-SDR and HackRF detection via SoapySDR
---
## [1.1.0] - 2026-12-18
### Added
- Satellite tracking with TLE data
- Full-screen dashboard for aircraft radar
- Full-screen dashboard for satellite tracking
---
## [1.0.0] - 2026-12-15
### Initial Release
- Pager decoding (POCSAG/FLEX)
- 433MHz sensor decoding
- ADS-B aircraft tracking
- WiFi reconnaissance
- Bluetooth scanning
- Multi-SDR support (RTL-SDR, LimeSDR, HackRF)
+178
View File
@@ -0,0 +1,178 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
INTERCEPT is a web-based Signal Intelligence (SIGINT) platform providing a unified Flask interface for software-defined radio (SDR) tools. It supports pager decoding, 433MHz sensors, ADS-B aircraft tracking, ACARS messaging, WiFi/Bluetooth scanning, satellite tracking, ISS SSTV decoding, AIS vessel tracking, weather satellite imagery (NOAA APT & Meteor LRPT), and Meshtastic mesh networking.
## Common Commands
### Docker (Primary)
```bash
# Build and run (basic profile)
docker compose --profile basic up -d
# Build and run with ADS-B history (Postgres)
docker compose --profile history up -d
# Rebuild after code changes
docker compose --profile basic up -d --build
# Multi-arch build (amd64 + arm64 for RPi)
./build-multiarch.sh
```
### Local Setup (Alternative)
```bash
# First-time setup (interactive wizard with install profiles)
./setup.sh
# Or headless full install
./setup.sh --non-interactive
# Or install specific profiles
./setup.sh --profile=core,weather
# Run with production server (gunicorn + gevent, handles concurrent SSE/WebSocket)
sudo ./start.sh
# Or for quick local dev (Flask dev server)
sudo -E venv/bin/python intercept.py
# Other setup utilities
./setup.sh --health-check # Verify installation
./setup.sh --postgres-setup # Set up ADS-B history database
./setup.sh --menu # Force interactive menu
```
### Testing
```bash
# Run all tests
pytest
# Run specific test file
pytest tests/test_bluetooth.py
# Run with coverage
pytest --cov=routes --cov=utils
# Run a specific test
pytest tests/test_bluetooth.py::test_function_name -v
```
### Linting and Formatting
```bash
# Lint with ruff
ruff check .
# Auto-fix linting issues
ruff check --fix .
# Format with black
black .
# Type checking
mypy .
```
## Architecture
### Entry Points
- `setup.sh` - Menu-driven installer with profile system (wizard, health check, PostgreSQL setup, env configurator, update, uninstall). Sources `.env` on startup via `start.sh`.
- `start.sh` - Production startup script (gunicorn + gevent auto-detection, CLI flags, HTTPS, `.env` sourcing, fallback to Flask dev server)
- `intercept.py` - Direct Flask dev server entry point (quick local development)
- `app.py` - Flask application initialization, global state management, process lifecycle, SSE streaming infrastructure, conditional gevent monkey-patch
### Route Blueprints (routes/)
Each signal type has its own Flask blueprint:
- `pager.py` - POCSAG/FLEX decoding via rtl_fm + multimon-ng
- `sensor.py` - 433MHz IoT sensors via rtl_433
- `adsb.py` - Aircraft tracking via dump1090 (SBS protocol on port 30003)
- `acars.py` - Aircraft datalink messages via acarsdec
- `wifi.py`, `wifi_v2.py` - WiFi scanning (legacy and unified APIs)
- `bluetooth.py`, `bluetooth_v2.py` - Bluetooth scanning (legacy and unified APIs)
- `satellite.py` - Pass prediction using TLE data
- `sstv.py` - ISS SSTV image decoding via slowrx
- `weather_sat.py` - NOAA APT & Meteor LRPT via SatDump
- `ais.py` - AIS vessel tracking and VHF DSC distress monitoring
- `aprs.py` - Amateur packet radio via direwolf
- `rtlamr.py` - Utility meter reading
- `meshtastic_routes.py` - Meshtastic LoRa mesh networking
### Core Utilities (utils/)
**SDR Abstraction Layer** (`utils/sdr/`):
- `SDRFactory` with factory pattern for multiple SDR types (RTL-SDR, LimeSDR, HackRF, Airspy, SDRPlay)
- Each type has a `CommandBuilder` for generating CLI commands
**Bluetooth Module** (`utils/bluetooth/`):
- Multi-backend: DBus/BlueZ primary, fallback for systems without BlueZ
- `aggregator.py` - Merges observations across time
- `tracker_signatures.py` - 47K+ known tracker fingerprints (AirTag, Tile, SmartTag)
- `heuristics.py` - Behavioral analysis for device classification
**TSCM (Counter-Surveillance)** (`utils/tscm/`):
- `baseline.py` - Snapshot "normal" RF environment
- `detector.py` - Compare current scan to baseline, flag anomalies
- `device_identity.py` - Track devices despite MAC randomization
- `correlation.py` - Cross-reference Bluetooth and WiFi observations
**WiFi Utilities** (`utils/wifi/`):
- Platform-agnostic scanner with parsers for airodump-ng, nmcli, iw, iwlist, airport (macOS)
- `channel_analyzer.py` - Frequency band analysis
**Weather Satellite** (`utils/weather_sat.py`):
- Singleton `WeatherSatDecoder` using SatDump CLI for NOAA APT and Meteor LRPT
- Subprocess management with stdout parsing, image watcher via rglob
- Pass prediction using skyfield TLE data
**SSTV Decoder** (`utils/sstv.py`):
- ISS SSTV reception via slowrx with Doppler tracking
- Singleton pattern, image gallery with timestamped filenames
### Key Patterns
**Server-Sent Events (SSE)**: All real-time features stream via SSE endpoints (`/stream_pager`, `/stream_sensor`, etc.). Pattern uses `queue.Queue` with timeout and keepalive messages. Under gunicorn + gevent, each SSE connection is a lightweight greenlet instead of an OS thread.
**Process Management**: External decoders run as subprocesses with output threads feeding queues. Use `safe_terminate()` for cleanup. Global locks prevent race conditions.
**Data Stores**: `DataStore` class with TTL-based automatic cleanup (WiFi: 10min, Bluetooth: 5min, Aircraft: 5min).
**Input Validation**: Centralized in `utils/validation.py` - always validate frequencies, gains, device indices before spawning processes.
### External Tool Integrations
| Tool | Purpose | Integration |
|------|---------|-------------|
| rtl_fm | FM demodulation | Subprocess, pipes to multimon-ng |
| multimon-ng | Pager decoding | Reads from rtl_fm stdout |
| rtl_433 | 433MHz sensors | JSON output parsing |
| dump1090 | ADS-B decoding | SBS protocol socket (port 30003) |
| acarsdec | ACARS messages | Output parsing |
| airmon-ng/airodump-ng | WiFi scanning | Monitor mode, CSV parsing |
| bluetoothctl/hcitool | Bluetooth | Fallback when DBus unavailable |
| slowrx | SSTV decoding | Subprocess with audio pipe |
| SatDump | Weather satellites | CLI live mode, NOAA APT + Meteor LRPT |
| AIS-catcher | AIS vessel tracking | JSON output parsing |
| direwolf | APRS | TNC modem for packet radio |
### Frontend Structure
- **Templates**: `templates/index.html` (main SPA), `templates/partials/modes/*.html` (sidebar panels), `templates/partials/nav.html` (global nav)
- **JS Modules**: `static/js/modes/*.js` - IIFE pattern per mode (e.g., `WeatherSat`, `SSTV`, `Meshtastic`)
- **CSS**: `static/css/modes/*.css` - scoped styles per mode, CSS variables for theming (`--bg-card`, `--accent-cyan`, `--font-mono`)
- **Mode Integration**: Each mode needs entries in `index.html` at ~12 points: CSS include, welcome card, partial include, visuals container, JS include, `validModes` set, `modeGroups` map, classList toggle, `modeNames`, visuals display toggle, titles, and init call in `switchMode()`
### Docker
- `Dockerfile` - Single-stage build with all SDR tools compiled from source (dump1090, AIS-catcher, slowrx, SatDump, etc.). CMD runs `start.sh` (gunicorn + gevent)
- `docker-compose.yml` - Two profiles: `basic` (standalone) and `history` (with Postgres for ADS-B)
- `build-multiarch.sh` - Multi-arch build script for amd64 + arm64 (RPi5)
- Data persisted via `./data:/app/data` volume mount
### Configuration
- `config.py` - Environment variable support with `INTERCEPT_` prefix (e.g., `INTERCEPT_PORT`, `INTERCEPT_WEATHER_SAT_GAIN`)
- Database: SQLite in `instance/` directory for settings, baselines, history
## Testing Notes
Tests use pytest with extensive mocking of external tools. Key fixtures in `tests/conftest.py`. Mock subprocess calls when testing decoder integration.
+254 -4
View File
@@ -1,12 +1,206 @@
# 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 \
&& 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 \
# 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"
LABEL description="Signal Intelligence Platform for SDR monitoring"
# Set working directory
WORKDIR /app
# Install system dependencies for RTL-SDR tools
# 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 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 \
@@ -14,13 +208,57 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
rtl-433 \
# Pager decoder
multimon-ng \
# Audio tools for Listening Post
ffmpeg \
# SSTV decoder runtime libs
libsndfile1 \
# SatDump runtime libs (weather satellite decoding)
libpng16-16 \
libtiff6 \
libjemalloc2 \
libvolk-bin \
libnng1 \
libzstd1 \
# WiFi tools (aircrack-ng suite)
aircrack-ng \
iw \
wireless-tools \
# Bluetooth tools
bluez \
# Cleanup
bluetooth \
# GPS support
gpsd \
gpsd-clients \
# APRS
direwolf \
# WiFi Extra
hcxdumptool \
hcxtools \
# SDR Hardware & SoapySDR
soapysdr-tools \
soapysdr-module-rtlsdr \
soapysdr-module-hackrf \
soapysdr-module-lms7 \
soapysdr-module-airspy \
airspy \
limesuite \
# Utilities
curl \
procps \
&& 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 .
RUN pip install --no-cache-dir -r requirements.txt
@@ -28,13 +266,25 @@ 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 /app/data/radiosonde/logs
# Expose web interface port
EXPOSE 5050
EXPOSE 5443
# Environment variables with defaults
ENV INTERCEPT_HOST=0.0.0.0 \
INTERCEPT_PORT=5050 \
INTERCEPT_LOG_LEVEL=INFO
INTERCEPT_LOG_LEVEL=INFO \
PYTHONUNBUFFERED=1
# Health check using the new endpoint
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"]
+196 -17
View File
@@ -1,21 +1,200 @@
MIT License
Copyright (c) 2025 smittix
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
1. Definitions.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by the Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding any notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. Please also get an OpenPGP
key and encrypt outgoing communications.
Copyright 2025 smittix
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+292 -75
View File
@@ -1,119 +1,319 @@
# 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">
<strong>Signal Intelligence Platform</strong><br>
A web-based front-end for signal intelligence tools.
Support the developer of this open-source project
</p>
<p align="center">
<img src="static/images/screenshots/screenshot2.png" alt="Screenshot">
<a href="https://www.buymeacoffee.com/smittix" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
</p>
<p align="center">
<strong>Signal Intelligence Platform</strong><br>
A web-based interface for software-defined radio tools.
</p>
<p align="center">
<img src="static/images/screenshots/intercept-main.png" alt="Screenshot">
</p>
---
## What is INTERCEPT?
INTERCEPT provides a unified web interface for signal intelligence tools:
## Features
- **Pager Decoding** - POCSAG/FLEX via rtl_fm + multimon-ng
- **433MHz Sensors** - Weather stations, TPMS, IoT via rtl_433
- **Aircraft Tracking** - ADS-B via dump1090 with real-time map
- **Satellite Tracking** - Pass prediction using TLE data
- **WiFi Recon** - Monitor mode scanning via aircrack-ng
- **Bluetooth Scanning** - Device discovery and tracker detection
- **433MHz Sensors** - Weather stations, TPMS, IoT devices via rtl_433
- **Sub-GHz Analyzer** - RF capture and protocol decoding for 300-928 MHz ISM bands via HackRF
- **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
- **ISS SSTV** - Slow-scan TV image reception from the International Space Station
- **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 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
---
## Community
## 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
```bash
git clone https://github.com/smittix/intercept.git
cd intercept
./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
```bash
git clone https://github.com/smittix/intercept.git
cd intercept
docker compose --profile basic up -d --build
```
> **Note:** Docker requires privileged mode for USB SDR access. SDR devices are passed through via `/dev/bus/usb`.
#### Multi-Architecture Builds (amd64 + arm64)
Cross-compile on an x64 machine and push to a registry. This is much faster than building natively on an RPi.
```bash
# One-time setup on your x64 build machine
docker run --privileged --rm tonistiigi/binfmt --install all
docker buildx create --name intercept-builder --use --bootstrap
# Build and push for both architectures
REGISTRY=ghcr.io/youruser ./build-multiarch.sh --push
# On the RPi5, just pull and run
INTERCEPT_IMAGE=ghcr.io/youruser/intercept:latest docker compose --profile basic up -d
```
Build script options:
| Flag | Description |
|------|-------------|
| `--push` | Push to container registry |
| `--load` | Load into local Docker (single platform only) |
| `--arm64-only` | Build arm64 only (for RPi deployment) |
| `--amd64-only` | Build amd64 only |
Environment variables: `REGISTRY`, `IMAGE_NAME`, `IMAGE_TAG`
#### Using a Pre-built Image
If you've pushed to a registry, you can skip building entirely on the target machine:
```bash
# Set in .env or export
INTERCEPT_IMAGE=ghcr.io/youruser/intercept:latest
# Then just run
docker compose --profile basic up -d
```
### Environment Configuration
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
docker compose --profile history up -d
```
Set the following environment variables (in `.env`):
```bash
INTERCEPT_ADSB_HISTORY_ENABLED=true
INTERCEPT_ADSB_DB_HOST=adsb_db
INTERCEPT_ADSB_DB_PORT=5432
INTERCEPT_ADSB_DB_NAME=intercept_adsb
INTERCEPT_ADSB_DB_USER=intercept
INTERCEPT_ADSB_DB_PASSWORD=intercept
```
To store Postgres data on external storage, set `PGDATA_PATH` (defaults to `./pgdata`):
```bash
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>
The credentials can be changed in the ADMIN_USERNAME & ADMIN_PASSWORD variables in config.py
---
## Hardware Requirements
| Hardware | Purpose | Price |
|----------|---------|-------|
| **RTL-SDR** | Required for all SDR features | ~$25-35 |
| **WiFi adapter** | Must support promiscuous (monitor) mode | ~$20-40 |
| **Bluetooth adapter** | Device scanning (usually built-in) | - |
| **GPS** | Any Linux supported GPS Unit | ~10 |
Most features work with a basic RTL-SDR dongle (RTL2832U + R820T2).
| :exclamation: Not using an RTL-SDR Device? |
|-----------------------------------------------
|Intercept supports any device that SoapySDR supports. You must however have the correct module for your device installed! For example if you have an SDRPlay device you'd need to install soapysdr-module-sdrplay.
| :exclamation: GPS Usage |
|-----------------------------------------------
|gpsd is needed for real time location. Intercept automatically checks to see if you're running gpsd in the background when any maps are rendered.
---
## Discord Server
<p align="center">
<a href="https://discord.gg/z3g3NJMe">Join our Discord</a>
<a href="https://discord.gg/EyeksEJmWE">Join our Discord</a>
</p>
---
## Quick Start
```bash
git clone https://github.com/smittix/intercept.git
cd intercept
./setup.sh
sudo python3 intercept.py
```
Open http://localhost:5050 in your browser.
<details>
<summary><strong>Alternative: Install with uv</strong></summary>
```bash
git clone https://github.com/smittix/intercept.git
cd intercept
uv venv
source .venv/bin/activate # On Windows: .venv\Scripts\activate
uv sync
sudo python3 intercept.py
```
</details>
> **Note:** Requires Python 3.9+ and external tools. See [Hardware & Installation](docs/HARDWARE.md).
---
## Requirements
- **Python 3.9+**
- **SDR Hardware** - RTL-SDR (~$25), LimeSDR, or HackRF
- **External Tools** - rtl-sdr, multimon-ng, rtl_433, dump1090, aircrack-ng
Quick install (Ubuntu/Debian):
```bash
sudo apt install rtl-sdr multimon-ng rtl-433 dump1090-mutability aircrack-ng bluez
```
See [Hardware & Installation](docs/HARDWARE.md) for full details.
---
## Documentation
| Document | Description |
|----------|-------------|
| [Features](docs/FEATURES.md) | Complete feature list for all modules |
| [Usage Guide](docs/USAGE.md) | Detailed instructions for each mode |
| [Troubleshooting](docs/TROUBLESHOOTING.md) | Solutions for common issues |
| [Hardware & Installation](docs/HARDWARE.md) | SDR hardware and tool installation |
---
## Development
This project was developed using AI as a coding partner, combining human direction with AI-assisted implementation. The goal: make Software Defined Radio more accessible by providing a clean, unified interface for common SDR tools.
Contributions and improvements welcome.
- [Usage Guide](docs/USAGE.md) - Detailed instructions for each mode
- [Distributed Agents](docs/DISTRIBUTED_AGENTS.md) - Remote sensor node deployment
- [Hardware Guide](docs/HARDWARE.md) - SDR hardware and advanced setup
- [Troubleshooting](docs/TROUBLESHOOTING.md) - Common issues and solutions
- [Security](docs/SECURITY.md) - Network security and best practices
---
## Disclaimer
**This software is for educational purposes only.**
This project was developed using AI as a coding partner, combining human direction with AI-assisted implementation. The goal: make Software Defined Radio more accessible by providing a clean, unified interface for common SDR tools.
**This software is for educational and authorized testing purposes only.**
- Only use with proper authorization
- Intercepting communications without consent may be illegal
- WiFi/Bluetooth attacks require explicit permission
- You are responsible for compliance with applicable laws
---
## License
MIT License - see [LICENSE](LICENSE)
Apache 2.0 License - see [LICENSE](LICENSE)
## Author
@@ -125,6 +325,23 @@ Created by **smittix** - [GitHub](https://github.com/smittix)
[multimon-ng](https://github.com/EliasOenal/multimon-ng) |
[rtl_433](https://github.com/merbanan/rtl_433) |
[dump1090](https://github.com/flightaware/dump1090) |
[AIS-catcher](https://github.com/jvde-github/AIS-catcher) |
[acarsdec](https://github.com/TLeconte/acarsdec) |
[direwolf](https://github.com/wb2osz/direwolf) |
[rtlamr](https://github.com/bemasher/rtlamr) |
[dumpvdl2](https://github.com/szpajder/dumpvdl2) |
[aircrack-ng](https://www.aircrack-ng.org/) |
[Leaflet.js](https://leafletjs.com/) |
[Celestrak](https://celestrak.org/)
[SatDump](https://github.com/SatDump/SatDump) |
[Celestrak](https://celestrak.org/) |
[Priyom.org](https://priyom.org/)
+1
View File
File diff suppressed because one or more lines are too long
+4
View File
@@ -0,0 +1,4 @@
{
"version": "2026-02-22_17194a71",
"downloaded": "2026-02-27T10:41:04.872620Z"
}
+1015 -70
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
#!/bin/bash
# DSC (Digital Selective Calling) decoder wrapper
# Invokes the Python DSC decoder module
# Get the directory where this script is located
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
# Set PYTHONPATH to include project root
export PYTHONPATH="${PROJECT_ROOT}:${PYTHONPATH}"
# Run the decoder module
exec python3 -m utils.dsc.decoder "$@"
+139
View File
@@ -0,0 +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 "============================================"
+348 -1
View File
@@ -7,7 +7,288 @@ import os
import sys
# Application version
VERSION = "1.2.0"
VERSION = "2.26.1"
# Changelog - latest release notes (shown on welcome screen)
CHANGELOG = [
{
"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",
"highlights": [
"Bluetooth: service data inspector, appearance codes, MAC cluster tracking, and behavioral flags",
"Bluetooth: IRK badge display, distance estimation with confidence, and signal stability metrics",
"ACARS: SoapySDR device support for SDRplay, LimeSDR, Airspy, and other non-RTL backends",
"ADS-B: stale dump1090 process cleanup via PID file tracking",
"GPS: error state indicator and UI refinements",
"Proximity radar and signal card UI improvements",
]
},
{
"version": "2.17.0",
"date": "February 2026",
"highlights": [
"BT Locate: SAR Bluetooth device location with GPS-tagged signal trail and proximity alerts",
"IRK auto-detection: extract Identity Resolving Keys from paired devices (macOS/Linux)",
"GPS mode: real-time position tracking with live map, speed, altitude, and satellite info",
"Bluetooth scanner lifecycle fix for bleak scan timeout tracking",
]
},
{
"version": "2.16.0",
"date": "February 2026",
"highlights": [
"Sub-GHz analyzer with real-time RF capture and protocol decoding",
"Weather satellite auto-scheduler with polar plot and ground track map",
"SatDump support for local (non-Docker) installs via setup.sh",
"Shared waterfall UI across SDR modes",
"Listening post audio stuttering fix and SDR race condition fixes",
"Multi-arch Docker build support (amd64 + arm64)",
]
},
{
"version": "2.15.0",
"date": "February 2026",
"highlights": [
"Real-time WebSocket waterfall with I/Q capture and server-side FFT",
"Cross-module frequency routing from Listening Post to decoders",
"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",
"SDR device lock-up fix from unreleased device registry on crash",
]
},
{
"version": "2.14.0",
"date": "February 2026",
"highlights": [
"HF SSTV general mode with predefined shortwave frequencies",
"WebSDR integration for remote HF/shortwave listening",
"Listening Post signal scanner and audio pipeline improvements",
"TSCM sweep resilience, WiFi detection, and correlation fixes",
"APRS rtl_fm startup and SDR device conflict fixes",
]
},
{
"version": "2.13.1",
"date": "February 2026",
"highlights": [
"UI overhaul with slate/cyan theme and JetBrains Mono font",
"Signal scanner rewritten with rtl_power sweep and SNR filtering",
"Listening Post audio streaming via WAV with retry/fallback",
"WiFi connected clients panel now filters to selected AP",
"Global navigation bar across all dashboards",
"Fixed USB device contention when starting audio pipeline",
]
},
{
"version": "2.13.0",
"date": "February 2026",
"highlights": [
"WiFi client display in AP detail drawer with real-time SSE updates",
"Help modal system with keyboard shortcuts reference",
"Global navbar and settings modal accessible from all dashboards",
"Probed SSID badges for connected clients",
]
},
{
"version": "2.12.1",
"date": "February 2026",
"highlights": [
"SDR device registry to prevent decoder conflicts",
"SDR device status panel and ADS-B Bias-T toggle",
"Real-time Doppler tracking for ISS SSTV reception",
"TCP connection support for Meshtastic",
"Shared observer location with auto-start options",
]
},
{
"version": "2.12.0",
"date": "January 2026",
"highlights": [
"ISS SSTV decoder with real-time ISS tracking globe",
"GitHub update notifications for new releases",
"Meshtastic QR code support and telemetry display",
"New Space category with reorganized UI",
]
},
{
"version": "2.11.0",
"date": "January 2026",
"highlights": [
"Meshtastic LoRa mesh network integration",
"Ubertooth One BLE scanning support",
"Offline mode with bundled assets",
"Settings modal with tile provider configuration",
]
},
{
"version": "2.10.0",
"date": "January 2026",
"highlights": [
"AIS vessel tracking with VHF DSC distress monitoring",
"Spy Stations database (number stations & diplomatic HF)",
"MMSI country identification and distress alert overlays",
"SDR device conflict detection for AIS/DSC",
]
},
{
"version": "2.9.5",
"date": "January 2026",
"highlights": [
"Enhanced TSCM with MAC-randomization resistant detection",
"Clickable score cards and device detail expansion",
"RF scanning improvements with status feedback",
"Root privilege check and warning display",
]
},
{
"version": "2.9.0",
"date": "January 2026",
"highlights": [
"New dropdown navigation menus for cleaner UI",
"TSCM baseline recording now captures device data",
"Device identity engine integration for threat detection",
"Welcome screen with mode selection",
]
},
{
"version": "2.8.0",
"date": "December 2025",
"highlights": [
"Added TSCM counter-surveillance mode",
"WiFi/Bluetooth device correlation engine",
"Tracker detection (AirTag, Tile, SmartTag)",
"Risk scoring and threat classification",
]
},
]
def _get_env(key: str, default: str) -> str:
@@ -52,6 +333,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')
@@ -75,12 +361,73 @@ BT_UPDATE_INTERVAL = _get_env_float('BT_UPDATE_INTERVAL', 2.0)
# ADS-B settings
ADSB_SBS_PORT = _get_env_int('ADSB_SBS_PORT', 30003)
ADSB_UPDATE_INTERVAL = _get_env_float('ADSB_UPDATE_INTERVAL', 1.0)
ADSB_AUTO_START = _get_env_bool('ADSB_AUTO_START', False)
ADSB_HISTORY_ENABLED = _get_env_bool('ADSB_HISTORY_ENABLED', False)
ADSB_DB_HOST = _get_env('ADSB_DB_HOST', 'localhost')
ADSB_DB_PORT = _get_env_int('ADSB_DB_PORT', 5432)
ADSB_DB_NAME = _get_env('ADSB_DB_NAME', 'intercept_adsb')
ADSB_DB_USER = _get_env('ADSB_DB_USER', 'intercept')
ADSB_DB_PASSWORD = _get_env('ADSB_DB_PASSWORD', 'intercept')
ADSB_HISTORY_BATCH_SIZE = _get_env_int('ADSB_HISTORY_BATCH_SIZE', 500)
ADSB_HISTORY_FLUSH_INTERVAL = _get_env_float('ADSB_HISTORY_FLUSH_INTERVAL', 1.0)
ADSB_HISTORY_QUEUE_SIZE = _get_env_int('ADSB_HISTORY_QUEUE_SIZE', 50000)
# Observer location settings
SHARED_OBSERVER_LOCATION_ENABLED = _get_env_bool('SHARED_OBSERVER_LOCATION', True)
DEFAULT_LATITUDE = _get_env_float('DEFAULT_LAT', 0.0)
DEFAULT_LONGITUDE = _get_env_float('DEFAULT_LON', 0.0)
# Satellite settings
SATELLITE_UPDATE_INTERVAL = _get_env_int('SATELLITE_UPDATE_INTERVAL', 30)
SATELLITE_TRAJECTORY_POINTS = _get_env_int('SATELLITE_TRAJECTORY_POINTS', 30)
SATELLITE_ORBIT_MINUTES = _get_env_int('SATELLITE_ORBIT_MINUTES', 45)
# Weather satellite settings
WEATHER_SAT_DEFAULT_GAIN = _get_env_float('WEATHER_SAT_GAIN', 40.0)
WEATHER_SAT_SAMPLE_RATE = _get_env_int('WEATHER_SAT_SAMPLE_RATE', 2400000)
WEATHER_SAT_MIN_ELEVATION = _get_env_float('WEATHER_SAT_MIN_ELEVATION', 15.0)
WEATHER_SAT_PREDICTION_HOURS = _get_env_int('WEATHER_SAT_PREDICTION_HOURS', 24)
WEATHER_SAT_SCHEDULE_REFRESH_MINUTES = _get_env_int('WEATHER_SAT_SCHEDULE_REFRESH_MINUTES', 30)
WEATHER_SAT_CAPTURE_BUFFER_SECONDS = _get_env_int('WEATHER_SAT_CAPTURE_BUFFER_SECONDS', 30)
# 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)
SUBGHZ_DEFAULT_LNA_GAIN = _get_env_int('SUBGHZ_LNA_GAIN', 32)
SUBGHZ_DEFAULT_VGA_GAIN = _get_env_int('SUBGHZ_VGA_GAIN', 20)
SUBGHZ_DEFAULT_TX_GAIN = _get_env_int('SUBGHZ_TX_GAIN', 20)
SUBGHZ_MAX_TX_DURATION = _get_env_int('SUBGHZ_MAX_TX_DURATION', 10)
SUBGHZ_SWEEP_START_MHZ = _get_env_float('SUBGHZ_SWEEP_START', 300.0)
SUBGHZ_SWEEP_END_MHZ = _get_env_float('SUBGHZ_SWEEP_END', 928.0)
# Radiosonde settings
RADIOSONDE_FREQ_MIN = _get_env_float('RADIOSONDE_FREQ_MIN', 400.0)
RADIOSONDE_FREQ_MAX = _get_env_float('RADIOSONDE_FREQ_MAX', 406.0)
RADIOSONDE_DEFAULT_GAIN = _get_env_float('RADIOSONDE_GAIN', 40.0)
RADIOSONDE_UDP_PORT = _get_env_int('RADIOSONDE_UDP_PORT', 55673)
# Update checking
GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept')
UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True)
UPDATE_CHECK_INTERVAL_HOURS = _get_env_int('UPDATE_CHECK_INTERVAL_HOURS', 6)
# Alerting
ALERT_WEBHOOK_URL = _get_env('ALERT_WEBHOOK_URL', '')
ALERT_WEBHOOK_SECRET = _get_env('ALERT_WEBHOOK_SECRET', '')
ALERT_WEBHOOK_TIMEOUT = _get_env_int('ALERT_WEBHOOK_TIMEOUT', 5)
# Admin credentials
ADMIN_USERNAME = _get_env('ADMIN_USERNAME', 'admin')
ADMIN_PASSWORD = _get_env('ADMIN_PASSWORD', 'admin')
def configure_logging() -> None:
"""Configure application logging."""
+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('_')}
+24 -10
View File
@@ -1,18 +1,32 @@
# TLE data for satellite tracking (updated periodically)
# To update: click "Update TLE" in satellite dashboard or SSTV mode
# Data source: CelesTrak (celestrak.org)
TLE_SATELLITES = {
'ISS': ('ISS (ZARYA)',
'1 25544U 98067A 24001.00000000 .00000000 00000-0 00000-0 0 0000',
'2 25544 51.6400 0.0000 0000000 0.0000 0.0000 15.50000000000000'),
'1 25544U 98067A 25029.51432176 .00020818 00000+0 36919-3 0 9991',
'2 25544 51.6400 157.5640 0002671 123.5041 236.6291 15.49988902492099'),
'NOAA-15': ('NOAA 15',
'1 25338U 98030A 25028.84157420 .00000535 00000+0 26168-3 0 9999',
'2 25338 98.5676 356.1853 0009968 282.2567 77.7505 14.26225252390049'),
'NOAA-18': ('NOAA 18',
'1 28654U 05018A 25028.87364583 .00000454 00000+0 25082-3 0 9996',
'2 28654 98.8801 59.1618 0013609 281.7181 78.2479 14.13003043 24668'),
'NOAA-19': ('NOAA 19',
'1 33591U 09005A 25028.82370718 .00000425 00000+0 24556-3 0 9998',
'2 33591 99.0905 25.2347 0013428 265.3457 94.6190 14.13019285827447'),
'NOAA-20': ('NOAA 20 (JPSS-1)',
'1 43013U 17073A 24001.00000000 .00000-0 00000-0 00000-0 0 0000',
'2 43013 98.7400 0.0000 0001000 0.0000 0.0000 14.19000000000000'),
'1 43013U 17073A 25028.83917428 .00000284 00000+0 15698-3 0 9995',
'2 43013 98.7104 59.9558 0001165 102.5891 257.5432 14.19571458378899'),
'NOAA-21': ('NOAA 21 (JPSS-2)',
'1 54234U 22150A 24001.00000000 .00000-0 00000-0 00000-0 0 0000',
'2 54234 98.7100 0.0000 0001000 0.0000 0.0000 14.19000000000000'),
'1 54234U 22150A 25028.86292604 .00000268 00000+0 14911-3 0 9995',
'2 54234 98.7064 59.6648 0001271 88.4689 271.6646 14.19545810114699'),
'METEOR-M2': ('METEOR-M 2',
'1 40069U 14037A 24001.00000000 .00000-0 00000-0 00000-0 0 0000',
'2 40069 98.5400 0.0000 0005000 0.0000 0.0000 14.21000000000000'),
'1 40069U 14037A 25028.47802083 .00000099 00000+0 69422-4 0 9990',
'2 40069 98.4752 356.8632 0003942 251.7291 108.3489 14.20719440555299'),
'METEOR-M2-3': ('METEOR-M2 3',
'1 57166U 23091A 24001.00000000 .00000-0 00000-0 00000-0 0 0000',
'2 57166 98.7700 0.0000 0002000 0.0000 0.0000 14.23000000000000'),
'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'),
}
+449
View File
@@ -0,0 +1,449 @@
"""
TSCM (Technical Surveillance Countermeasures) Frequency Database
Known surveillance device frequencies, sweep presets, and threat signatures
for counter-surveillance operations.
"""
from __future__ import annotations
# =============================================================================
# Known Surveillance Frequencies (MHz)
# =============================================================================
SURVEILLANCE_FREQUENCIES = {
'wireless_mics': [
{'start': 49.0, 'end': 50.0, 'name': '49 MHz Wireless Mics', 'risk': 'medium'},
{'start': 72.0, 'end': 76.0, 'name': 'VHF Low Band Mics', 'risk': 'medium'},
{'start': 170.0, 'end': 216.0, 'name': 'VHF High Band Wireless', 'risk': 'medium'},
{'start': 470.0, 'end': 698.0, 'name': 'UHF TV Band Wireless', 'risk': 'medium'},
{'start': 902.0, 'end': 928.0, 'name': '900 MHz ISM Wireless', 'risk': 'high'},
{'start': 1880.0, 'end': 1920.0, 'name': 'DECT Wireless', 'risk': 'high'},
],
'wireless_cameras': [
{'start': 900.0, 'end': 930.0, 'name': '900 MHz Video TX', 'risk': 'high'},
{'start': 1200.0, 'end': 1300.0, 'name': '1.2 GHz Video', 'risk': 'high'},
{'start': 2400.0, 'end': 2483.5, 'name': '2.4 GHz WiFi Cameras', 'risk': 'high'},
{'start': 5150.0, 'end': 5850.0, 'name': '5.8 GHz Video', 'risk': 'high'},
],
'gps_trackers': [
{'start': 824.0, 'end': 849.0, 'name': 'Cellular 850 Uplink', 'risk': 'high'},
{'start': 869.0, 'end': 894.0, 'name': 'Cellular 850 Downlink', 'risk': 'high'},
{'start': 1710.0, 'end': 1755.0, 'name': 'AWS Uplink', 'risk': 'high'},
{'start': 1850.0, 'end': 1910.0, 'name': 'PCS Uplink', 'risk': 'high'},
{'start': 1930.0, 'end': 1990.0, 'name': 'PCS Downlink', 'risk': 'high'},
],
'body_worn': [
{'start': 49.0, 'end': 50.0, 'name': '49 MHz Body Wires', 'risk': 'critical'},
{'start': 72.0, 'end': 76.0, 'name': 'VHF Low Band Wires', 'risk': 'critical'},
{'start': 150.0, 'end': 174.0, 'name': 'VHF High Band', 'risk': 'critical'},
{'start': 380.0, 'end': 400.0, 'name': 'TETRA Band', 'risk': 'high'},
{'start': 406.0, 'end': 420.0, 'name': 'Federal/Government', 'risk': 'critical'},
{'start': 450.0, 'end': 470.0, 'name': 'UHF Business Band', 'risk': 'high'},
],
'common_bugs': [
{'start': 88.0, 'end': 108.0, 'name': 'FM Broadcast Band Bugs', 'risk': 'low'},
{'start': 140.0, 'end': 150.0, 'name': 'Low VHF Bugs', 'risk': 'high'},
{'start': 418.0, 'end': 419.0, 'name': '418 MHz ISM', 'risk': 'medium'},
{'start': 433.0, 'end': 434.8, 'name': '433 MHz ISM Band', 'risk': 'medium'},
{'start': 868.0, 'end': 870.0, 'name': '868 MHz ISM (Europe)', 'risk': 'medium'},
{'start': 315.0, 'end': 316.0, 'name': '315 MHz ISM (US)', 'risk': 'medium'},
],
'ism_bands': [
{'start': 26.96, 'end': 27.41, 'name': 'CB Radio / ISM 27 MHz', 'risk': 'low'},
{'start': 40.66, 'end': 40.70, 'name': 'ISM 40 MHz', 'risk': 'low'},
{'start': 315.0, 'end': 316.0, 'name': 'ISM 315 MHz (US)', 'risk': 'medium'},
{'start': 433.05, 'end': 434.79, 'name': 'ISM 433 MHz (EU)', 'risk': 'medium'},
{'start': 868.0, 'end': 868.6, 'name': 'ISM 868 MHz (EU)', 'risk': 'medium'},
{'start': 902.0, 'end': 928.0, 'name': 'ISM 915 MHz (US)', 'risk': 'medium'},
{'start': 2400.0, 'end': 2483.5, 'name': 'ISM 2.4 GHz', 'risk': 'medium'},
],
}
# =============================================================================
# Sweep Presets
# =============================================================================
SWEEP_PRESETS = {
'quick': {
'name': 'Quick Scan',
'description': 'Fast 2-minute check of most common bug frequencies',
'duration_seconds': 120,
'ranges': [
{'start': 88.0, 'end': 108.0, 'step': 0.1, 'name': 'FM Band'},
{'start': 433.0, 'end': 435.0, 'step': 0.025, 'name': '433 MHz ISM'},
{'start': 868.0, 'end': 870.0, 'step': 0.025, 'name': '868 MHz ISM'},
],
'wifi': True,
'bluetooth': True,
'rf': True,
},
'standard': {
'name': 'Standard Sweep',
'description': 'Comprehensive 5-minute sweep of common surveillance bands',
'duration_seconds': 300,
'ranges': [
{'start': 25.0, 'end': 50.0, 'step': 0.1, 'name': 'HF/Low VHF'},
{'start': 88.0, 'end': 108.0, 'step': 0.1, 'name': 'FM Band'},
{'start': 140.0, 'end': 175.0, 'step': 0.025, 'name': 'VHF'},
{'start': 380.0, 'end': 450.0, 'step': 0.025, 'name': 'UHF Low'},
{'start': 868.0, 'end': 930.0, 'step': 0.05, 'name': 'ISM 868/915'},
],
'wifi': True,
'bluetooth': True,
'rf': True,
},
'full': {
'name': 'Full Spectrum',
'description': 'Complete 15-minute spectrum sweep (24 MHz - 1.7 GHz)',
'duration_seconds': 900,
'ranges': [
{'start': 24.0, 'end': 1700.0, 'step': 0.1, 'name': 'Full Spectrum'},
],
'wifi': True,
'bluetooth': True,
'rf': True,
},
'wireless_cameras': {
'name': 'Wireless Cameras',
'description': 'Focus on video transmission frequencies',
'duration_seconds': 180,
'ranges': [
{'start': 900.0, 'end': 930.0, 'step': 0.1, 'name': '900 MHz Video'},
{'start': 1200.0, 'end': 1300.0, 'step': 0.5, 'name': '1.2 GHz Video'},
],
'wifi': True, # WiFi cameras
'bluetooth': False,
'rf': True,
},
'body_worn': {
'name': 'Body-Worn Devices',
'description': 'Detect body wires and covert transmitters',
'duration_seconds': 240,
'ranges': [
{'start': 49.0, 'end': 50.0, 'step': 0.01, 'name': '49 MHz'},
{'start': 72.0, 'end': 76.0, 'step': 0.01, 'name': 'VHF Low'},
{'start': 150.0, 'end': 174.0, 'step': 0.0125, 'name': 'VHF High'},
{'start': 406.0, 'end': 420.0, 'step': 0.0125, 'name': 'Federal'},
{'start': 450.0, 'end': 470.0, 'step': 0.0125, 'name': 'UHF'},
],
'wifi': False,
'bluetooth': True, # BLE bugs
'rf': True,
},
'gps_trackers': {
'name': 'GPS Trackers',
'description': 'Detect cellular-based GPS tracking devices',
'duration_seconds': 180,
'ranges': [
{'start': 824.0, 'end': 894.0, 'step': 0.1, 'name': 'Cellular 850'},
{'start': 1850.0, 'end': 1990.0, 'step': 0.1, 'name': 'PCS Band'},
],
'wifi': False,
'bluetooth': True, # BLE trackers
'rf': True,
},
'bluetooth_only': {
'name': 'Bluetooth/BLE Trackers',
'description': 'Focus on BLE tracking devices (AirTag, Tile, etc.)',
'duration_seconds': 60,
'ranges': [],
'wifi': False,
'bluetooth': True,
'rf': False,
},
'wifi_only': {
'name': 'WiFi Devices',
'description': 'Scan for hidden WiFi cameras and access points',
'duration_seconds': 60,
'ranges': [],
'wifi': True,
'bluetooth': False,
'rf': False,
},
}
# =============================================================================
# Known Tracker Signatures
# =============================================================================
BLE_TRACKER_SIGNATURES = {
'apple_airtag': {
'name': 'Apple AirTag',
'company_id': 0x004C,
'patterns': ['findmy', 'airtag'],
'risk': 'high',
'description': 'Apple Find My network tracker',
},
'tile': {
'name': 'Tile Tracker',
'company_id': 0x00ED,
'patterns': ['tile'],
'oui_prefixes': ['C4:E7', 'DC:54', 'E6:43'],
'risk': 'high',
'description': 'Tile Bluetooth tracker',
},
'samsung_smarttag': {
'name': 'Samsung SmartTag',
'company_id': 0x0075,
'patterns': ['smarttag', 'smartthings'],
'risk': 'high',
'description': 'Samsung SmartThings tracker',
},
'chipolo': {
'name': 'Chipolo',
'company_id': 0x0A09,
'patterns': ['chipolo'],
'risk': 'high',
'description': 'Chipolo Bluetooth tracker',
},
'generic_beacon': {
'name': 'Unknown BLE Beacon',
'company_id': None,
'patterns': [],
'risk': 'medium',
'description': 'Unidentified BLE beacon device',
},
}
# =============================================================================
# Threat Classification
# =============================================================================
THREAT_TYPES = {
'new_device': {
'name': 'New Device',
'description': 'Device not present in baseline',
'default_severity': 'medium',
},
'tracker': {
'name': 'Tracking Device',
'description': 'Known BLE tracker detected',
'default_severity': 'high',
},
'unknown_signal': {
'name': 'Unknown Signal',
'description': 'Unidentified RF transmission',
'default_severity': 'medium',
},
'burst_transmission': {
'name': 'Burst Transmission',
'description': 'Intermittent/store-and-forward signal detected',
'default_severity': 'high',
},
'hidden_camera': {
'name': 'Potential Hidden Camera',
'description': 'WiFi camera or video transmitter detected',
'default_severity': 'critical',
},
'gsm_bug': {
'name': 'GSM/Cellular Bug',
'description': 'Cellular transmission in non-phone device context',
'default_severity': 'critical',
},
'rogue_ap': {
'name': 'Rogue Access Point',
'description': 'Unauthorized WiFi access point',
'default_severity': 'high',
},
'anomaly': {
'name': 'Signal Anomaly',
'description': 'Unusual signal pattern or behavior',
'default_severity': 'low',
},
}
SEVERITY_LEVELS = {
'critical': {
'level': 4,
'color': '#ff0000',
'description': 'Immediate action required - active surveillance likely',
},
'high': {
'level': 3,
'color': '#ff6600',
'description': 'Strong indicator of surveillance device',
},
'medium': {
'level': 2,
'color': '#ffcc00',
'description': 'Potential threat - requires investigation',
},
'low': {
'level': 1,
'color': '#00cc00',
'description': 'Minor anomaly - low probability of threat',
},
}
# =============================================================================
# WiFi Camera Detection Patterns
# =============================================================================
WIFI_CAMERA_PATTERNS = {
'ssid_patterns': [
'cam', 'camera', 'ipcam', 'webcam', 'dvr', 'nvr',
'hikvision', 'dahua', 'reolink', 'wyze', 'ring',
'arlo', 'nest', 'blink', 'eufy', 'yi',
],
'oui_manufacturers': [
'Hikvision',
'Dahua',
'Axis Communications',
'Hanwha Techwin',
'Vivotek',
'Ubiquiti',
'Wyze Labs',
'Amazon Technologies', # Ring
'Google', # Nest
],
'mac_prefixes': {
'C0:25:E9': 'TP-Link Camera',
'A4:DA:22': 'TP-Link Camera',
'78:8C:B5': 'TP-Link Camera',
'D4:6E:0E': 'TP-Link Camera',
'2C:AA:8E': 'Wyze Camera',
'AC:CF:85': 'Hikvision',
'54:C4:15': 'Hikvision',
'C0:56:E3': 'Hikvision',
'3C:EF:8C': 'Dahua',
'A0:BD:1D': 'Dahua',
'E4:24:6C': 'Dahua',
},
}
# =============================================================================
# Utility Functions
# =============================================================================
def get_frequency_risk(frequency_mhz: float) -> tuple[str, str]:
"""
Determine the risk level for a given frequency.
Returns:
Tuple of (risk_level, category_name)
"""
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']
return 'low', 'Unknown Band'
def get_sweep_preset(preset_name: str) -> dict | None:
"""Get a sweep preset by name."""
return SWEEP_PRESETS.get(preset_name)
def get_all_sweep_presets() -> dict:
"""Get all available sweep presets."""
return {
name: {
'name': preset['name'],
'description': preset['description'],
'duration_seconds': preset['duration_seconds'],
}
for name, preset in SWEEP_PRESETS.items()
}
def is_known_tracker(device_name: str | None, manufacturer_data: bytes | str | None = None) -> dict | None:
"""
Check if a BLE device matches known tracker signatures.
Args:
device_name: Device name to check against patterns
manufacturer_data: Manufacturer data as bytes or hex string
Returns:
Tracker info dict if match found, None otherwise
"""
if device_name:
name_lower = device_name.lower()
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
if manufacturer_data:
# Convert hex string to bytes if needed
mfr_bytes = manufacturer_data
if isinstance(manufacturer_data, str):
try:
mfr_bytes = bytes.fromhex(manufacturer_data)
except ValueError:
return None
if len(mfr_bytes) >= 2:
company_id = int.from_bytes(mfr_bytes[:2], 'little')
for _tracker_id, tracker_info in BLE_TRACKER_SIGNATURES.items():
if tracker_info.get('company_id') == company_id:
return tracker_info
return None
def is_potential_camera(ssid: str | None = None, mac: str | None = None, vendor: str | None = None) -> bool:
"""Check if a WiFi device might be a hidden camera."""
if ssid:
ssid_lower = ssid.lower()
for pattern in WIFI_CAMERA_PATTERNS['ssid_patterns']:
if pattern in ssid_lower:
return True
if mac:
mac_prefix = mac[:8].upper()
if mac_prefix in WIFI_CAMERA_PATTERNS['mac_prefixes']:
return True
if vendor:
vendor_lower = vendor.lower()
for manufacturer in WIFI_CAMERA_PATTERNS['oui_manufacturers']:
if manufacturer.lower() in vendor_lower:
return True
return False
def get_threat_severity(threat_type: str, context: dict | None = None) -> str:
"""
Determine threat severity based on type and context.
Args:
threat_type: Type of threat from THREAT_TYPES
context: Optional context dict with signal_strength, etc.
Returns:
Severity level string
"""
threat_info = THREAT_TYPES.get(threat_type, {})
base_severity = threat_info.get('default_severity', 'medium')
if context:
# Upgrade severity based on signal strength (closer = more concerning)
signal = context.get('signal_strength')
if signal and signal > -50: # Very strong signal
if base_severity == 'medium':
return 'high'
elif base_severity == 'high':
return 'critical'
return base_severity
+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"}
]
}
]
}
+141
View File
@@ -0,0 +1,141 @@
# 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
#
# Basic usage (pre-built image from registry):
# INTERCEPT_IMAGE=ghcr.io/user/intercept:latest docker compose --profile basic up -d
#
# With ADS-B history (Postgres):
# docker compose --profile history up -d
services:
intercept:
# When INTERCEPT_IMAGE is set, use that pre-built image; otherwise build locally
image: ${INTERCEPT_IMAGE:-intercept:latest}
build: .
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
devices:
- /dev/bus/usb:/dev/bus/usb
volumes:
# Persist decoded images and database across container rebuilds
- ./data:/app/data
# 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
# - INTERCEPT_ADSB_DB_HOST=adsb_db
# - INTERCEPT_ADSB_DB_PORT=5432
# - INTERCEPT_ADSB_DB_NAME=intercept_adsb
# - INTERCEPT_ADSB_DB_USER=intercept
# - INTERCEPT_ADSB_DB_PASSWORD=intercept
# ADS-B auto-start on dashboard load (default false)
- INTERCEPT_ADSB_AUTO_START=${INTERCEPT_ADSB_AUTO_START:-false}
# Shared observer location across modules
- INTERCEPT_SHARED_OBSERVER_LOCATION=${INTERCEPT_SHARED_OBSERVER_LOCATION:-true}
# Default observer coordinates (set to your location to skip the GPS prompt)
# - INTERCEPT_DEFAULT_LAT=${INTERCEPT_DEFAULT_LAT:-0}
# - INTERCEPT_DEFAULT_LON=${INTERCEPT_DEFAULT_LON:-0}
# Network mode for WiFi scanning (requires host network)
# network_mode: host
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:5050/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
# ADS-B history with Postgres persistence
# Enable with: docker compose --profile history up -d
intercept-history:
# Same image/build fallback pattern as above
image: ${INTERCEPT_IMAGE:-intercept:latest}
build: .
container_name: intercept-history
profiles:
- history
depends_on:
- 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
devices:
- /dev/bus/usb:/dev/bus/usb
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
- INTERCEPT_ADSB_DB_NAME=intercept_adsb
- INTERCEPT_ADSB_DB_USER=intercept
- INTERCEPT_ADSB_DB_PASSWORD=intercept
# ADS-B auto-start on dashboard load (default false)
- INTERCEPT_ADSB_AUTO_START=${INTERCEPT_ADSB_AUTO_START:-false}
# Shared observer location across modules
- INTERCEPT_SHARED_OBSERVER_LOCATION=${INTERCEPT_SHARED_OBSERVER_LOCATION:-true}
# Default observer coordinates (set to your location to skip the GPS prompt)
# - INTERCEPT_DEFAULT_LAT=${INTERCEPT_DEFAULT_LAT:-0}
# - INTERCEPT_DEFAULT_LON=${INTERCEPT_DEFAULT_LON:-0}
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:5050/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
adsb_db:
image: postgres:16-alpine
container_name: intercept-adsb-db
profiles:
- history
environment:
- TZ=${TZ:-UTC}
- POSTGRES_DB=intercept_adsb
- POSTGRES_USER=intercept
- POSTGRES_PASSWORD=intercept
volumes:
# Default local path (override with PGDATA_PATH for external storage)
- ${PGDATA_PATH:-./pgdata}:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U intercept -d intercept_adsb"]
interval: 10s
timeout: 5s
retries: 5
# Optional: Add volume for persistent SQLite database
# volumes:
# intercept-data:
View File
+1
View File
@@ -0,0 +1 @@
www.intercept-sigint.com
+506
View File
@@ -0,0 +1,506 @@
# Intercept Distributed Agent System
This document describes the distributed agent architecture that allows multiple remote sensor nodes to feed data into a central Intercept controller.
## Overview
The agent system uses a hub-and-spoke architecture where:
- **Controller**: The main Intercept instance that aggregates data from multiple agents
- **Agents**: Lightweight sensor nodes running on remote devices with SDR hardware
```
┌─────────────────────────────────┐
│ INTERCEPT CONTROLLER │
│ (port 5050) │
│ │
│ - Web UI with agent selector │
│ - /controller/manage page │
│ - Multi-agent SSE stream │
│ - Push data storage │
└─────────────────────────────────┘
▲ ▲ ▲
│ │ │
Push/Pull │ │ │ Push/Pull
│ │ │
┌────┴───┐ ┌────┴───┐ ┌────┴───┐
│ Agent │ │ Agent │ │ Agent │
│ :8020 │ │ :8020 │ │ :8020 │
│ │ │ │ │ │
│[RTL-SDR] │[HackRF] │ │[LimeSDR]
└────────┘ └────────┘ └────────┘
```
## Quick Start
### 1. Start the Controller
The controller is the main Intercept application:
```bash
cd intercept
./setup.sh # First-time setup (choose install profiles)
sudo ./start.sh # Production server on http://localhost:5050
```
### 2. Configure an Agent
Create a config file on the remote machine:
```ini
# intercept_agent.cfg
[agent]
name = sensor-node-1
port = 8020
allowed_ips =
allow_cors = false
[controller]
url = http://192.168.1.100:5050
api_key = your-secret-key-here
push_enabled = true
push_interval = 5
[modes]
pager = true
sensor = true
adsb = true
wifi = true
bluetooth = true
```
### 3. Start the Agent
```bash
python intercept_agent.py --config intercept_agent.cfg
# Runs on http://localhost:8020
```
### 4. Register the Agent
Go to `http://controller:5050/controller/manage` and add the agent:
- **Name**: sensor-node-1 (must match config)
- **Base URL**: http://agent-ip:8020
- **API Key**: your-secret-key-here (must match config)
## Architecture
### Data Flow
The system supports two data flow patterns:
#### Push (Agent → Controller)
Agents automatically push captured data to the controller:
1. Agent captures data (e.g., rtl_433 sensor readings)
2. Data is queued in the `ControllerPushClient`
3. Agent POSTs to `http://controller/controller/api/ingest`
4. Controller validates API key and stores in `push_payloads` table
5. Data is available via SSE stream at `/controller/stream/all`
```
Agent Controller
│ │
│ POST /controller/api/ingest │
│ Header: X-API-Key: secret │
│ Body: {agent_name, scan_type, │
│ payload, timestamp} │
│ ──────────────────────────────► │
│ │
│ 200 OK │
│ ◄────────────────────────────── │
```
#### Pull (Controller → Agent)
The controller can also pull data on-demand:
1. User selects agent in UI dropdown
2. User clicks "Start Listening"
3. Controller proxies request to agent
4. Agent starts the mode and returns status
5. Controller polls agent for data
```
Browser Controller Agent
│ │ │
│ POST /controller/ │ │
│ agents/1/sensor/start│ │
│ ─────────────────────► │ │
│ │ POST /sensor/start │
│ │ ────────────────────────► │
│ │ │
│ │ {status: started} │
│ │ ◄──────────────────────── │
│ {status: success} │ │
│ ◄───────────────────── │ │
```
### Authentication
API key authentication secures the push mechanism:
1. Agent config specifies `api_key` in `[controller]` section
2. Agent sends `X-API-Key` header with each push request
3. Controller looks up agent by name in database
4. Controller compares provided key with stored key
5. Mismatched keys return 401 Unauthorized
### Database Schema
Two tables support the agent system:
```sql
-- Registered agents
CREATE TABLE agents (
id INTEGER PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
base_url TEXT NOT NULL,
api_key TEXT,
capabilities TEXT, -- JSON: {pager: true, sensor: true, ...}
interfaces TEXT, -- JSON: {devices: [...]}
gps_coords TEXT, -- JSON: {lat, lon}
last_seen TIMESTAMP,
is_active BOOLEAN
);
-- Pushed data from agents
CREATE TABLE push_payloads (
id INTEGER PRIMARY KEY,
agent_id INTEGER,
scan_type TEXT, -- pager, sensor, adsb, wifi, etc.
payload TEXT, -- JSON data
received_at TIMESTAMP,
FOREIGN KEY (agent_id) REFERENCES agents(id)
);
```
## Agent REST API
The agent exposes these endpoints:
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/health` | GET | Health check (returns `{status: "healthy"}`) |
| `/capabilities` | GET | Available modes, devices, GPS status |
| `/status` | GET | Running modes, uptime, push status |
| `/{mode}/start` | POST | Start a mode (pager, sensor, adsb, etc.) |
| `/{mode}/stop` | POST | Stop a mode |
| `/{mode}/status` | GET | Mode-specific status |
| `/{mode}/data` | GET | Current data snapshot |
### Example: Start Sensor Mode
```bash
curl -X POST http://agent:8020/sensor/start \
-H "Content-Type: application/json" \
-d '{"frequency": 433.92, "device_index": 0}'
```
Response:
```json
{
"status": "started",
"mode": "sensor",
"command": "/usr/local/bin/rtl_433 -d 0 -f 433.92M -F json",
"gps_enabled": true
}
```
### Example: Get Capabilities
```bash
curl http://agent:8020/capabilities
```
Response:
```json
{
"modes": {
"pager": true,
"sensor": true,
"adsb": true,
"wifi": true,
"bluetooth": true
},
"devices": [
{
"index": 0,
"name": "RTLSDRBlog, Blog V4",
"sdr_type": "rtlsdr",
"capabilities": {
"freq_min_mhz": 24.0,
"freq_max_mhz": 1766.0
}
}
],
"gps": true,
"gps_position": {
"lat": 33.543,
"lon": -82.194,
"altitude": 70.0
},
"tool_details": {
"sensor": {
"name": "433MHz Sensors",
"ready": true,
"tools": {
"rtl_433": {"installed": true, "required": true}
}
}
}
}
```
## Supported Modes
All modes are fully implemented in the agent with the following tools and data formats:
| Mode | Tool(s) | Data Format | Notes |
|------|---------|-------------|-------|
| `sensor` | rtl_433 | JSON readings | ISM band devices (433/868/915 MHz) |
| `pager` | rtl_fm + multimon-ng | POCSAG/FLEX messages | Address, function, message content |
| `adsb` | dump1090 | SBS-format aircraft | ICAO, callsign, position, altitude |
| `ais` | AIS-catcher | JSON vessels | MMSI, position, speed, vessel info |
| `acars` | acarsdec | JSON messages | Aircraft tail, label, message text |
| `aprs` | rtl_fm + direwolf | APRS packets | Callsign, position, path |
| `wifi` | airodump-ng | Networks + clients | BSSID, ESSID, signal, clients |
| `bluetooth` | bluetoothctl | Device list | MAC, name, RSSI |
| `rtlamr` | rtl_tcp + rtlamr | Meter readings | Meter ID, consumption data |
| `dsc` | rtl_fm (+ dsc-decoder) | DSC messages | MMSI, distress category, position |
| `tscm` | WiFi/BT analysis | Anomaly reports | New/rogue devices detected |
| `satellite` | skyfield (TLE) | Pass predictions | No SDR required |
| `listening_post` | rtl_fm scanner | Signal detections | Frequency, modulation |
### Mode-Specific Notes
**Listening Post**: Full FFT streaming isn't practical over HTTP. Instead, the agent provides:
- Signal detection events when activity is found
- Current scanning frequency
- Activity log of detected signals
**TSCM**: Analyzes WiFi and Bluetooth data for anomalies:
- Builds baseline of known devices
- Reports new/unknown devices as anomalies
- No SDR required (uses WiFi/BT data)
**Satellite**: Pure computational mode:
- Calculates pass predictions from TLE data
- Requires observer location (lat/lon)
- No SDR required
**Audio Modes**: Modes requiring real-time audio (airband, listening_post audio) are limited via agents. Use rtl_tcp for remote audio streaming instead.
## Controller API
### Agent Management
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/controller/agents` | GET | List all agents |
| `/controller/agents` | POST | Register new agent |
| `/controller/agents/{id}` | GET | Get agent details |
| `/controller/agents/{id}` | DELETE | Remove agent |
| `/controller/agents/{id}?refresh=true` | GET | Refresh agent capabilities |
### Proxy Operations
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/controller/agents/{id}/{mode}/start` | POST | Start mode on agent |
| `/controller/agents/{id}/{mode}/stop` | POST | Stop mode on agent |
| `/controller/agents/{id}/{mode}/data` | GET | Get data from agent |
### Push Ingestion
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/controller/api/ingest` | POST | Receive pushed data from agents |
### SSE Streams
| Endpoint | Description |
|----------|-------------|
| `/controller/stream/all` | Combined stream from all agents |
## Frontend Integration
### Agent Selector
The main UI includes an agent dropdown in supported modes:
```html
<select id="agentSelect">
<option value="local">Local (This Device)</option>
<option value="1">● sensor-node-1</option>
</select>
```
When an agent is selected:
1. Device list updates to show agent's SDR devices
2. Start/Stop commands route through controller proxy
3. Data displays with agent name badge
### Multi-Agent Mode
Enable "Show All Agents" checkbox to:
- Connect to `/controller/stream/all` SSE
- Display combined data from all agents
- Show agent name badge on each data item
## GPS Integration
Agents can include GPS coordinates with captured data:
1. Agent connects to local `gpsd` daemon
2. GPS position included in `/capabilities` and `/status`
3. Each data snapshot includes `agent_gps` field
4. Controller can use GPS for trilateration (multiple agents)
## Configuration Reference
### Agent Config (`intercept_agent.cfg`)
```ini
[agent]
# Agent identity (must be unique across all agents)
name = sensor-node-1
# Port to listen on
port = 8020
# Restrict connections to specific IPs (comma-separated, empty = all)
allowed_ips =
# Enable CORS headers
allow_cors = false
[controller]
# Controller URL (required for push)
url = http://192.168.1.100:5050
# API key for authentication
api_key = your-secret-key
# Enable automatic data push
push_enabled = true
# Push interval in seconds
push_interval = 5
[modes]
# Enable/disable specific modes
pager = true
sensor = true
adsb = true
ais = true
wifi = true
bluetooth = true
```
## Troubleshooting
### Agent not appearing in controller
1. Check agent is running: `curl http://agent:8020/health`
2. Verify agent is registered in `/controller/manage`
3. Check API key matches between agent config and controller registration
4. Check network connectivity between agent and controller
### Push data not arriving
1. Check agent status: `curl http://agent:8020/status`
- Verify `push_enabled: true` and `push_connected: true`
2. Check controller logs for authentication errors
3. Verify API key matches
4. Check if mode is running and producing data
### Mode won't start on agent
1. Check capabilities: `curl http://agent:8020/capabilities`
2. Verify required tools are installed (check `tool_details`)
3. Check if SDR device is available (not in use by another process)
### No data from sensor mode
1. Verify rtl_433 is running: `ps aux | grep rtl_433`
2. Check sensor status: `curl http://agent:8020/sensor/status`
3. Note: Empty data is normal if no 433MHz devices are transmitting nearby
## Security Considerations
1. **API Keys**: Always use strong, unique API keys for each agent
2. **Network**: Consider running agents on a private network or VPN
3. **HTTPS**: For production, use HTTPS between agents and controller
4. **Firewall**: Restrict agent ports to controller IP only
5. **allowed_ips**: Use this config option to restrict agent connections
## Dashboard Integration
Agent support has been integrated into the following specialized dashboards:
### ADS-B Dashboard (`/adsb/dashboard`)
- Agent selector in header bar
- Routes tracking start/stop through agent proxy when remote agent selected
- Connects to multi-agent stream for data from remote agents
- Displays agent badge on aircraft from remote sources
- Updates observer location from agent's GPS coordinates
### AIS Dashboard (`/ais/dashboard`)
- Agent selector in header bar
- Routes AIS and DSC mode operations through agent proxy
- Connects to multi-agent stream for vessel data
- Displays agent badge on vessels from remote sources
- Updates observer location from agent's GPS coordinates
### Main Dashboard (`/`)
- Agent selector in sidebar
- Supports sensor, pager, WiFi, Bluetooth modes via agents
- SDR conflict detection with device-aware warnings
- Real-time sync with agent's running mode state
### Multi-SDR Agent Support
For agents with multiple SDR devices, the system now tracks which device each mode is using:
```json
{
"running_modes": ["sensor", "adsb"],
"running_modes_detail": {
"sensor": {"device": 0, "started_at": "2024-01-15T10:30:00Z"},
"adsb": {"device": 1, "started_at": "2024-01-15T10:35:00Z"}
}
}
```
This allows:
- Smart conflict detection (only warns if same device is in use)
- Display of which device each mode is using
- Parallel operation of multiple SDR modes on multi-SDR agents
### Agent Mode Warnings
When an agent has SDR modes running, the UI displays:
- Warning banner showing active modes with device numbers
- Stop buttons for each running mode
- Refresh button to re-sync with agent state
### Pages Without Agent Support
The following pages don't require SDR-based agent support:
- **Satellite Dashboard** (`/satellite/dashboard`) - Uses TLE orbital calculations, no SDR
- **History pages** - Display stored data, not live SDR streams
## Files
| File | Description |
|------|-------------|
| `intercept_agent.py` | Standalone agent server |
| `intercept_agent.cfg` | Agent configuration template |
| `routes/controller.py` | Controller API blueprint |
| `utils/agent_client.py` | HTTP client for agents |
| `utils/database.py` | Agent CRUD operations |
| `static/js/core/agents.js` | Frontend agent management |
| `templates/agents.html` | Agent management page |
| `templates/adsb_dashboard.html` | ADS-B page with agent integration |
| `templates/ais_dashboard.html` | AIS page with agent integration |
+410 -3
View File
@@ -16,6 +16,25 @@ Complete feature list for all modules.
- **Doorbells, remotes, and IoT devices**
- **Smart meters** and utility monitors
## Sub-GHz Analyzer
- **HackRF-based** signal capture and analysis for 300-928 MHz ISM bands
- **Protocol decoding** - identify and decode common Sub-GHz protocols
- **Signal replay/transmit** capabilities for authorized testing
- **Wideband spectrum analysis** with real-time visualization
- **I/Q capture** - record raw samples for offline analysis
## Spy Stations (Number Stations)
- **Comprehensive database** of active number stations and diplomatic networks
- **Station profiles** - frequencies, schedules, operators, descriptions
- **Filter by type** - number stations vs diplomatic networks
- **Filter by country** - Russia, Cuba, Israel, Poland, North Korea, etc.
- **Filter by mode** - USB, AM, CW, OFDM
- **Tune integration** - click to tune Listening Post to station frequency
- **Source links** - references to priyom.org for detailed information
- **Famous stations** - UVB-76 "The Buzzer", Cuban HM01, Israeli E17z
## ADS-B Aircraft Tracking
- **Real-time aircraft tracking** via dump1090 or rtl_adsb
@@ -26,11 +45,160 @@ Complete feature list for all modules.
- **Aircraft filtering** - show all, military only, civil only, or emergency only
- **Marker clustering** - group nearby aircraft at lower zoom levels
- **Reception statistics** - max range, message rate, busiest hour, total seen
- **Persistent ADS-B history** - optional Postgres-backed message and snapshot storage
- **History reporting dashboard** - session controls, aircraft timelines, and detail modal
- **Observer location** - manual input or GPS geolocation
- **Audio alerts** - notifications for military and emergency aircraft
- **Emergency squawk highlighting** - visual alerts for 7500/7600/7700
- **Aircraft details popup** - callsign, altitude, speed, heading, squawk, ICAO
<p align="center">
<img src="/static/images/screenshots/screenshot_radar.png" alt="Screenshot">
</p>
## AIS Vessel Tracking
- **Real-time vessel tracking** via AIS-catcher or rtl_ais
- **Full-screen dashboard** - dedicated popout with maritime map
- **Interactive Leaflet map** with OpenStreetMap tiles (dark-themed)
- **Vessel trails** - optional track history visualization
- **Vessel details popup** - name, MMSI, callsign, destination, ship type, speed, heading
- **Country identification** - flag lookup via Maritime Identification Digits (MID)
### VHF DSC Channel 70 Monitoring
Digital Selective Calling (DSC) monitoring on the international maritime distress frequency.
- **Real-time DSC decoding** - Distress, Urgency, Safety, and Routine messages
- **MMSI country lookup** - 180+ Maritime Identification Digit codes
- **Distress nature identification** - Fire, Flooding, Collision, Sinking, Piracy, MOB, etc.
- **Position extraction** - Automatic lat/lon parsing from distress messages
- **Map markers** - Distress positions plotted with pulsing alert markers
- **Visual alert overlay** - Prominent popup for DISTRESS and URGENCY messages
- **Audio alerts** - Notification sound for critical messages
- **Alert persistence** - Critical alerts stored permanently in database
- **Acknowledgement workflow** - Track response status with notes
- **SDR conflict detection** - Prevents device collisions with AIS tracking
- **Alert summary** - Dashboard counts for unacknowledged distress/urgency
## ACARS Messaging
- **Real-time ACARS decoding** via acarsdec
- **Aircraft datalink messages** - operational, weather, and position reports
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
- **Message filtering** - filter by message type, flight, or registration
## VDL2 (VHF Data Link Mode 2)
- **Real-time VDL2 decoding** via dumpvdl2 on standard VDL2 frequencies
- **ACARS-over-AVLC** message capture with full frame parsing
- **Signal analysis** - frequency, signal level, noise level, SNR, burst length
- **AVLC frame details** - source/destination addresses, frame type, command/response
- **Raw JSON inspection** - expandable raw message data for each frame
- **Multi-frequency monitoring** - simultaneous reception on multiple VDL2 channels
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
- **CSV/JSON export** - export captured messages for offline analysis
- **Integrated with ADS-B dashboard** - VDL2 messages linked to aircraft tracking
## CW/Morse Code Decoder
- **Custom Goertzel tone detection** for CW (continuous wave) Morse decoding
- **OOK/AM envelope detection** mode for on-off keying signals in ISM bands
- **HF frequency presets** for amateur CW bands (160m-10m)
- **ISM band presets** for OOK envelope mode (315 MHz, 433 MHz, 868 MHz, 915 MHz)
- **Real-time character and word output** with WPM estimation
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
## WeFax (Weather Fax)
- **HF weather fax reception** from marine and meteorological broadcast stations
- **Broadcast timeline** with scheduled transmission times by station
- **Auto-scheduler** for unattended capture of scheduled broadcasts
- **Image gallery** with timestamped decoded weather charts
- **Station presets** for major WeFax broadcasters worldwide
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
## Listening Post
- **Wideband frequency scanning** via rtl_power sweep with SNR filtering
- **Real-time audio monitoring** with FM and SSB demodulation
- **Cross-module frequency routing** from scanner to decoders
- **Waterfall spectrum display** for visual signal identification
- **Customizable frequency presets** and band bookmarks
- **Multi-SDR support** - RTL-SDR, LimeSDR, HackRF, Airspy, SDRplay
## Weather Satellites
- **NOAA APT** and **Meteor LRPT** image decoding via SatDump
- **Auto-scheduler** with pass prediction and automatic capture
- **Polar plot** - real-time satellite position on azimuth/elevation display
- **Ground track map** - orbit path with past/future trajectory
- **Image gallery** with timestamped decoded imagery
## WebSDR
- **KiwiSDR network integration** for remote HF/shortwave listening
- **WebSocket audio streaming** from remote receivers
- **Receiver discovery** with automatic caching
- **Frequency tuning** with band presets
## ISS SSTV
- **ISS SSTV image reception** on 145.800 MHz FM during special event transmissions
- **Real-time ISS tracking** with world map and pass predictions
- **Doppler correction** - optional lat/lon input for real-time frequency shift compensation
- **Next pass countdown** - time remaining until ISS is overhead
- **Image gallery** with timestamped decoded imagery
- **TLE updates** - fetch latest ISS orbital elements
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
## HF SSTV
- **Terrestrial SSTV decoding** across HF (80m-10m), VHF (6m, 2m), and UHF (70cm) bands
- **Predefined frequency lookup** for 13 active SSTV calling frequencies
- **Auto-modulation selection** - frequency table maps to correct mode (USB, LSB, FM)
- **Image gallery** with decoded transmissions
- **Common modes supported** - PD120, PD180, Martin1, Scottie1, Robot36
## APRS
- **Amateur packet radio** position reports and telemetry via direwolf
- **Region-specific frequencies** - 144.390 MHz (North America), 144.800 MHz (Europe), and more
- **Real-time position tracking** on interactive map
- **Message and telemetry display** from APRS network
## Utility Meter Reading
- **Smart meter monitoring** via rtl_amr for electric, gas, and water meters
- **Real-time JSON output** with meter ID, consumption, and signal data
- **Multiple meter protocol support** via rtl_tcp integration
## Space Weather
- **Real-time solar indices** - Solar Flux Index (SFI), Kp index, A-index, sunspot number
- **NOAA Space Weather Scales** - Geomagnetic storms (G), solar radiation (S), radio blackouts (R)
- **HF band conditions** - Day/night propagation from HamQSL for 80m through 10m bands
- **Solar wind monitoring** - Speed, density, and IMF Bz from DSCOVR satellite
- **X-ray flux chart** - GOES X-ray data with flare class scale (A/B/C/M/X)
- **Flare probability** - 1-day and 3-day C/M/X-class flare forecasts
- **Solar imagery** - NASA SDO 193A, 304A, and magnetogram images
- **D-RAP absorption maps** - HF radio absorption at 5-30 MHz frequency bands
- **Aurora forecast** - OVATION aurora oval visualization
- **SWPC alerts** - Real-time space weather alerts and warnings
- **Active solar regions** - Current sunspot region data with location and area
- **Auto-refresh** - 5-minute polling with manual refresh option
- **No SDR required** - Data fetched from NOAA SWPC, NASA SDO, and HamQSL public APIs
## Radiosonde Weather Balloon Tracking
- **400-406 MHz reception** via radiosonde_auto_rx for weather balloon telemetry
- **Frequency presets** for common radiosonde bands
- **Real-time telemetry** - altitude, temperature, humidity, pressure, GPS position
- **Interactive map** with balloon trajectory and burst point prediction
- **Station location** with configurable observer position
- **Distance tracking** - real-time distance-to-balloon calculation
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
## Satellite Tracking
- **Full-screen dashboard** - dedicated popout with polar plot and ground track
@@ -43,6 +211,13 @@ Complete feature list for all modules.
- **Telemetry panel** - real-time azimuth, elevation, range, velocity
- **Multiple satellite tracking** simultaneously
<p align="center">
<img src="/static/images/screenshots/screenshot_sat.png" alt="Screenshot">
</p>
<p align="center">
<img src="/static/images/screenshots/screenshot_sat_2.png" alt="Screenshot">
</p>
## WiFi Reconnaissance
- **Monitor mode** management via airmon-ng
@@ -64,17 +239,207 @@ Complete feature list for all modules.
## Bluetooth Scanning
- **BLE and Classic** Bluetooth device scanning
- **Multiple scan modes** - hcitool, bluetoothctl
- **Multiple scan modes** - hcitool, bluetoothctl, bleak
- **Tracker detection** - AirTag, Tile, Samsung SmartTag, Chipolo
- **Device classification** - phones, audio, wearables, computers
- **Manufacturer lookup** via OUI database
- **Manufacturer lookup** via OUI database and Bluetooth Company IDs
- **Proximity radar** visualization
- **Device type breakdown** chart
## BT Locate (SAR Bluetooth Device Location)
Search and rescue Bluetooth device location with GPS-tagged signal trail mapping.
### Core Features
- **Target tracking** - Locate devices by MAC address, name pattern, or IRK (Identity Resolving Key)
- **RPA resolution** - Resolve BLE Resolvable Private Addresses using IRK for tracking devices with randomized addresses
- **IRK auto-detection** - Extract IRKs from paired devices on macOS and Linux
- **GPS-tagged signal trail** - Every detection is tagged with GPS coordinates for trail mapping
- **Proximity bands** - IMMEDIATE (<1m), NEAR (1-5m), FAR (>5m) with color-coded HUD
- **RSSI history chart** - Real-time signal strength sparkline for trend analysis
- **Distance estimation** - Log-distance path loss model with environment presets
- **Audio proximity alerts** - Web Audio API tones that increase in pitch as signal strengthens
- **Hand-off from Bluetooth mode** - One-click transfer of a device from BT scanner to BT Locate
### Environment Presets
- **Open Field** (n=2.0) - Free space path loss
- **Outdoor** (n=2.2) - Typical outdoor environment
- **Indoor** (n=3.0) - Indoor with walls and obstacles
### Map & Trail
- Interactive Leaflet map with GPS trail visualization
- Trail points color-coded by proximity band
- Polyline connecting detection points for path visualization
- Supports user-configured tile providers
### Requirements
- Bluetooth adapter (built-in or USB)
- GPS receiver (optional, falls back to manual coordinates)
## WiFi Locate
Locate a WiFi access point by BSSID using real-time signal strength tracking.
### Core Features
- **Target by BSSID** - Enter any MAC address or hand off from the WiFi scanner
- **Real-time signal meter** - Large dBm display with color-coded strength (good/medium/weak)
- **20-segment signal bar** - Visual proximity indicator with red/yellow/green segments
- **RSSI history chart** - Canvas sparkline showing signal trend over time
- **Distance estimation** - Log-distance path loss model with configurable environment presets
- **Audio proximity alerts** - Web Audio API tones that increase in pitch and frequency as signal strengthens
- **Signal lost detection** - 30-second timeout with visual overlay when target disappears
- **Hand-off from WiFi mode** - One-click transfer from WiFi detail drawer to WiFi Locate
- **Stats tracking** - Current, min, max, and average RSSI across session
### Environment Presets
- **Open Field** (n=2.0) - Free space path loss
- **Outdoor** (n=2.8) - Typical outdoor environment (default)
- **Indoor** (n=3.5) - Indoor with walls and obstacles
### Mode Transition
- WiFi scan is preserved when switching between WiFi and WiFi Locate modes
- Deep scan auto-starts if not already running
### Requirements
- WiFi adapter capable of monitor mode
- aircrack-ng suite for deep scanning
## GPS Mode
Real-time GPS position tracking with live map visualization.
### Features
- **Live position tracking** - Real-time latitude, longitude, altitude display
- **Interactive map** - Current position on Leaflet map with track history
- **Speed and heading** - Real-time speed (km/h) and compass heading
- **Satellite info** - Number of satellites in view and fix quality
- **Track recording** - Record GPS tracks with export capability
- **Accuracy display** - Horizontal and vertical position accuracy (EPX/EPY)
### Requirements
- USB GPS receiver connected via gpsd
- gpsd daemon running (`sudo gpsd /dev/ttyUSB0 -F /var/run/gpsd.sock`)
## TSCM Counter-Surveillance Mode
Technical Surveillance Countermeasures (TSCM) screening for detecting wireless surveillance indicators.
### Wireless Sweep Features
- **BLE scanning** with manufacturer data detection (AirTags, Tile, SmartTags, ESP32)
- **WiFi scanning** for rogue APs, hidden SSIDs, camera devices
- **RF spectrum analysis** (RTL-SDR or HackRF) - FM bugs, ISM bands, video transmitters
- **Cross-protocol correlation** - links devices across BLE/WiFi/RF
- **Baseline comparison** - detect new/unknown devices vs known environment
### MAC-Randomization Resistant Detection
- **Device fingerprinting** based on advertisement payloads, not MAC addresses
- **Behavioral clustering** - groups observations into probable physical devices
- **Session tracking** - monitors device presence windows
- **Timing pattern analysis** - detects characteristic advertising intervals
- **RSSI trajectory correlation** - identifies co-located devices
### Risk Assessment
- **Three-tier scoring model**:
- Informational (0-2): Known or expected devices
- Needs Review (3-5): Unusual devices requiring assessment
- High Interest (6+): Multiple indicators warrant investigation
- **Risk indicators**: Stable RSSI, audio-capable, ESP32 chipsets, hidden identity, MAC rotation
- **Audit trail** - full evidence chain for each link/flag
- **Client-safe disclaimers** - findings are indicators, not confirmed surveillance
### Limitations (Documented)
- Cannot detect non-transmitting devices
- False positives/negatives expected
- Results require professional verification
- No cryptographic de-randomization
- Passive screening only (no active probing by default)
## Meshtastic Mesh Networks
Integration with Meshtastic LoRa mesh networking devices for decentralized communication.
### Device Support
- **Heltec** - LoRa32 series
- **T-Beam** - TTGO T-Beam with GPS
- **RAK** - WisBlock series
- Any Meshtastic-compatible device via USB/Serial
### Features
- **Real-time messaging** - Stream messages as they arrive
- **Channel configuration** - Set encryption keys and channel names
- **Node information** - View connected nodes with signal metrics
- **Message history** - Up to 500 messages retained
- **Signal quality** - RSSI and SNR for each message
- **Hop tracking** - See message hop count
### Requirements
- Physical Meshtastic device connected via USB
- Meshtastic Python SDK (`pip install meshtastic`)
## Ubertooth One BLE Scanning
Advanced Bluetooth Low Energy scanning using Ubertooth One hardware.
### Capabilities
- **40-channel scanning** - Capture BLE advertisements across all channels
- **Raw payload access** - Full advertising data for analysis
- **Passive sniffing** - No active scanning required
- **MAC address extraction** - Public and random address types
- **RSSI measurement** - Signal strength for proximity estimation
### Integration
- Works alongside standard BlueZ/DBus Bluetooth scanning
- Automatically detected when ubertooth-btle is available
- Falls back to standard adapter if Ubertooth not present
### Requirements
- Ubertooth One hardware
- ubertooth-btle command-line tool installed
- libubertooth library
## Remote Agents (Distributed SIGINT)
Deploy lightweight sensor nodes across multiple locations and aggregate data to a central controller.
### Architecture
- **Hub-and-spoke model** - Central controller with multiple remote agents
- **Push and Pull modes** - Agents can push data automatically or respond to on-demand requests
- **API key authentication** - Secure communication between agents and controller
### Agent Features
- **Standalone deployment** - Run on Raspberry Pi, mini PCs, or any Linux device with SDR
- **All modes supported** - Pager, sensor, ADS-B, AIS, WiFi, Bluetooth, and more
- **GPS integration** - Automatic location tagging from USB GPS receivers
- **Multi-SDR support** - Run multiple modes simultaneously on agents with multiple SDRs
- **Capability discovery** - Controller auto-detects available modes and devices
### Controller Features
- **Agent management UI** - Register, test, and remove agents from `/controller/manage`
- **Real-time status** - Health monitoring with online/offline indicators
- **Unified data stream** - Aggregate data from all agents via SSE
- **Dashboard integration** - Agent selector in ADS-B, AIS, and main dashboards
- **Device conflict detection** - Smart warnings when SDR is in use
### Use Cases
- **Wide-area monitoring** - Cover larger geographic areas with distributed sensors
- **Remote installations** - Deploy sensors in locations without direct access
- **Redundancy** - Multiple nodes for reliable coverage
- **Triangulation** - Use multiple GPS-enabled agents for signal location
## System Health
- **Telemetry dashboard** with real-time system metrics
- **Process monitoring** for all running SDR tools and decoders
- **CPU, memory, and disk usage** tracking
- **SDR device status** overview
- **No SDR required** - monitors system health independently
## User Interface
- **Mode-specific header stats** - real-time badges showing key metrics per mode
- **UTC clock** - always visible in header for time-critical operations
- **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
@@ -92,19 +457,61 @@ Complete feature list for all modules.
| ? | Open help (when not typing) |
| Escape | Close help/modals |
## Offline Mode
Run iNTERCEPT without internet connectivity by using bundled local assets.
### Bundled Assets
- **Leaflet 1.9.4** - Map library with marker images
- **Chart.js 4.4.1** - Signal strength graphs
- **Inter font** - Primary UI font (400, 500, 600, 700 weights)
- **JetBrains Mono font** - Monospace/code font (400, 500, 600, 700 weights)
### Settings Modal
Access via the gear icon in the navigation bar:
- **Offline Tab** - Toggle offline mode, configure asset sources (CDN vs local)
- **Display Tab** - Theme and animation preferences
- **About Tab** - Version info and links
### Map Tile Providers
Choose from multiple tile sources for maps:
- **OpenStreetMap** - Default, general purpose
- **CartoDB Dark** - Dark themed, matches UI
- **CartoDB Positron** - Light themed
- **ESRI World Imagery** - Satellite imagery
- **Custom URL** - Connect to your own tile server (e.g., local OpenStreetMap tile cache)
### Local Asset Status
The settings modal shows availability status for each bundled asset:
- Green "Available" badge when asset is present
- Red "Missing" badge when asset is not found
- Click "Check Assets" to refresh status
### Use Cases
- **Air-gapped environments** - Run on isolated networks
- **Field deployments** - Operate without reliable internet
- **Local tile servers** - Use pre-cached map tiles for specific regions
- **Reduced latency** - Faster loading with local assets
## General
- **Web-based interface** - no desktop app needed
- **Production server** - gunicorn + gevent via `start.sh` for concurrent SSE/WebSocket handling (falls back to Flask dev server)
- **Live message streaming** via Server-Sent Events (SSE)
- **Audio alerts** with mute toggle
- **Message export** to CSV/JSON
- **Signal activity meter** and waterfall display
- **Message logging** to file with timestamps
- **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
- **Disclaimer acceptance** on first use
- **Auto-stop** when switching between modes
+346 -93
View File
@@ -1,93 +1,107 @@
# Hardware & Installation
# Hardware & Advanced Setup
## Supported SDR Hardware
| Hardware | Frequency Range | Gain Range | TX | Price | Notes |
|----------|-----------------|------------|-----|-------|-------|
| **RTL-SDR** | 24 - 1766 MHz | 0 - 50 dB | No | ~$25 | Most common, budget-friendly |
| **LimeSDR** | 0.1 - 3800 MHz | 0 - 73 dB | Yes | ~$300 | Wide range, requires SoapySDR |
| **HackRF** | 1 - 6000 MHz | 0 - 62 dB | Yes | ~$300 | Ultra-wide range, requires SoapySDR |
| Hardware | Frequency Range | Price | Notes |
|----------|-----------------|-------|-------|
| **RTL-SDR** | 24 - 1766 MHz | ~$25-35 | Recommended for beginners |
| **LimeSDR** | 0.1 - 3800 MHz | ~$300 | Wide range, requires SoapySDR |
| **HackRF** | 1 - 6000 MHz | ~$300 | Ultra-wide range, requires SoapySDR |
INTERCEPT automatically detects connected devices and shows hardware-specific capabilities in the UI.
INTERCEPT automatically detects connected devices.
## Requirements
---
### Hardware
- **SDR Device** - RTL-SDR, LimeSDR, or HackRF
- **WiFi adapter** capable of monitor mode (for WiFi features)
- **Bluetooth adapter** (for Bluetooth features)
- **GPS dongle** (optional, for precise location)
## Quick Install
### Software
- **Python 3.9+** required
- External tools (see installation below)
### Recommended: Use the Setup Script
## Tool Installation
The setup script provides an interactive menu with install profiles for selective installation:
### Core SDR Tools
| Tool | macOS | Ubuntu/Debian | Purpose |
|------|-------|---------------|---------|
| rtl-sdr | `brew install librtlsdr` | `sudo apt install rtl-sdr` | RTL-SDR support |
| multimon-ng | `brew install multimon-ng` | `sudo apt install multimon-ng` | Pager decoding |
| rtl_433 | `brew install rtl_433` | `sudo apt install rtl-433` | 433MHz sensors |
| dump1090 | `brew install dump1090-mutability` | `sudo apt install dump1090-mutability` | ADS-B aircraft |
| aircrack-ng | `brew install aircrack-ng` | `sudo apt install aircrack-ng` | WiFi reconnaissance |
| bluez | Built-in (limited) | `sudo apt install bluez bluetooth` | Bluetooth scanning |
### LimeSDR / HackRF Support (Optional)
| Tool | macOS | Ubuntu/Debian | Purpose |
|------|-------|---------------|---------|
| SoapySDR | `brew install soapysdr` | `sudo apt install soapysdr-tools` | Universal SDR abstraction |
| LimeSDR | `brew install limesuite soapylms7` | `sudo apt install limesuite soapysdr-module-lms7` | LimeSDR support |
| HackRF | `brew install hackrf soapyhackrf` | `sudo apt install hackrf soapysdr-module-hackrf` | HackRF support |
| readsb | Build from source | Build from source | ADS-B with SoapySDR |
> **Note:** RTL-SDR works out of the box. LimeSDR and HackRF require SoapySDR plus the hardware-specific driver.
## Quick Install Commands
### Ubuntu/Debian
> [!NOTE]
> Known Issue: On the latest version of Debian (Trixie) and those distros that use it dump1090 is not available in the repsitories and will need to be built from source until the developers release it.
```bash
# Core tools
sudo apt update
sudo apt install rtl-sdr multimon-ng rtl-433 dump1090-mutability aircrack-ng bluez bluetooth
# LimeSDR (optional)
sudo apt install soapysdr-tools limesuite soapysdr-module-lms7
# HackRF (optional)
sudo apt install hackrf soapysdr-module-hackrf
git clone https://github.com/smittix/intercept.git
cd intercept
./setup.sh
```
### macOS (Homebrew)
```bash
# Core tools
brew install librtlsdr multimon-ng rtl_433 dump1090-mutability aircrack-ng
On first run, a guided wizard walks you through profile selection:
# LimeSDR (optional)
| 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
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# Core tools (required)
brew install python@3.11 librtlsdr multimon-ng rtl_433 ffmpeg
# ADS-B aircraft tracking
brew install dump1090-mutability
# WiFi tools (optional)
brew install aircrack-ng
# LimeSDR support (optional)
brew install soapysdr limesuite soapylms7
# HackRF (optional)
# HackRF support (optional)
brew install hackrf soapyhackrf
```
### Arch Linux
```bash
# Core tools
sudo pacman -S rtl-sdr multimon-ng
yay -S rtl_433 dump1090
### Manual Install: Debian / Ubuntu / Raspberry Pi OS
# LimeSDR/HackRF (optional)
sudo pacman -S soapysdr limesuite hackrf
```bash
# Update package lists
sudo apt update
# Core tools (required)
sudo apt install -y python3 python3-pip python3-venv python3-skyfield
sudo apt install -y rtl-sdr multimon-ng rtl-433 ffmpeg
# ADS-B aircraft tracking
sudo apt install -y dump1090-mutability
# Alternative: dump1090-fa (FlightAware version)
# WiFi tools (optional)
sudo apt install -y aircrack-ng
# Bluetooth tools (optional)
sudo apt install -y bluez bluetooth
# LimeSDR support (optional)
sudo apt install -y soapysdr-tools limesuite soapysdr-module-lms7
# HackRF support (optional)
sudo apt install -y hackrf soapysdr-module-hackrf
```
## Linux udev Rules
---
If your SDR isn't detected, add udev rules:
## RTL-SDR Setup (Linux)
### Add udev rules
If your RTL-SDR isn't detected, create udev rules:
```bash
sudo bash -c 'cat > /etc/udev/rules.d/20-rtlsdr.rules << EOF
@@ -99,9 +113,9 @@ sudo udevadm control --reload-rules
sudo udevadm trigger
```
Then unplug and replug your device.
Then unplug and replug your RTL-SDR.
## Blacklist DVB-T Driver (Linux)
### Blacklist DVB-T driver
The default DVB-T driver conflicts with rtl-sdr:
@@ -110,57 +124,296 @@ echo "blacklist dvb_usb_rtl28xxu" | sudo tee /etc/modprobe.d/blacklist-rtl.conf
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 what's installed:
### Check dependencies
```bash
python3 intercept.py --check-deps
```
Test SDR detection:
### Test SDR detection
```bash
# RTL-SDR
rtl_test
# LimeSDR/HackRF
# LimeSDR/HackRF (via SoapySDR)
SoapySDRUtil --find
```
## Python Dependencies
---
### Option 1: setup.sh (Recommended)
## Python Environment
### Using setup.sh (Recommended)
```bash
./setup.sh
```
This creates a virtual environment and installs dependencies automatically.
### Option 2: pip
The setup wizard automatically:
- Detects your OS (macOS, Debian/Ubuntu, DragonOS)
- Lets you choose install profiles (Core, Maritime, Weather, Security, Full, Custom)
- Creates a virtual environment with system site-packages
- Installs Python dependencies (core + optional)
- Runs a health check to verify everything works
After initial setup, use the menu to manage your environment:
- **Install / Add Modules** — add tools you didn't install initially
- **System Health Check** — verify all tools and dependencies
- **Environment Configurator** — set `INTERCEPT_*` variables interactively
- **Update Tools** — rebuild source-built tools (dump1090, SatDump, etc.)
- **View Status** — see what's installed at a glance
### Manual setup
```bash
python3 -m venv .venv
source .venv/bin/activate
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
```
### Option 3: uv (Fast alternative)
[uv](https://github.com/astral-sh/uv) is a fast Python package installer.
---
## Running INTERCEPT
After installation:
```bash
# Install uv (if not already installed)
curl -LsSf https://astral.sh/uv/install.sh | sh
sudo ./start.sh
# Create venv and install deps
uv venv
source .venv/bin/activate # On Windows: .venv\Scripts\activate
uv sync
# Custom port
sudo ./start.sh -p 8080
# Or just install deps in existing environment
uv pip install -r requirements.txt
# HTTPS
sudo ./start.sh --https
```
### Option 4: pip with pyproject.toml
Open **http://localhost:5050** in your browser.
---
## Complete Tool Reference
| Tool | Package (Debian) | Package (macOS) | Required For |
|------|------------------|-----------------|--------------|
| `rtl_fm` | rtl-sdr | librtlsdr | Pager, Listening Post |
| `rtl_test` | rtl-sdr | librtlsdr | SDR detection |
| `multimon-ng` | multimon-ng | multimon-ng | Pager decoding |
| `rtl_433` | rtl-433 | rtl_433 | 433MHz sensors |
| `dump1090` | dump1090-mutability | dump1090-mutability | ADS-B tracking |
| `ffmpeg` | ffmpeg | ffmpeg | Listening Post audio |
| `airmon-ng` | aircrack-ng | aircrack-ng | WiFi monitor mode |
| `airodump-ng` | aircrack-ng | aircrack-ng | WiFi scanning |
| `aireplay-ng` | aircrack-ng | aircrack-ng | WiFi deauth (optional) |
| `hcitool` | bluez | N/A | Bluetooth scanning |
| `bluetoothctl` | bluez | N/A | Bluetooth control |
| `hciconfig` | bluez | N/A | Bluetooth config |
### Optional tools:
| Tool | Package (Debian) | Package (macOS) | Purpose |
|------|------------------|-----------------|---------|
| `ffmpeg` | ffmpeg | ffmpeg | Alternative audio encoder |
| `SoapySDRUtil` | soapysdr-tools | soapysdr | LimeSDR/HackRF support |
| `LimeUtil` | limesuite | limesuite | LimeSDR native tools |
| `hackrf_info` | hackrf | hackrf | HackRF native tools |
### Python dependencies (requirements.txt):
| Package | Purpose |
|---------|---------|
| `flask` | Web server |
| `skyfield` | Satellite tracking |
| `bleak` | BLE scanning with manufacturer data (TSCM) |
---
## dump1090 Notes
### Package names vary by distribution:
- `dump1090-mutability` - Most common
- `dump1090-fa` - FlightAware version (recommended)
- `dump1090` - Generic
### Not in repositories (Debian Trixie)?
Install FlightAware's version:
https://flightaware.com/adsb/piaware/install
Or build from source:
https://github.com/flightaware/dump1090
---
## TSCM Mode Requirements
TSCM (Technical Surveillance Countermeasures) mode requires specific hardware for full functionality:
### BLE Scanning (Tracker Detection)
- Any Bluetooth adapter supported by your OS
- `bleak` Python library for manufacturer data detection
- Detects: AirTags, Tile, SmartTags, ESP32/ESP8266 devices
```bash
pip install . # Install as package
pip install -e . # Install in editable mode (for development)
pip install -e ".[dev]" # Include dev dependencies
# Install bleak
pip install bleak>=0.21.0
# Or via apt (Debian/Ubuntu)
sudo apt install python3-bleak
```
### RF Spectrum Analysis
- **RTL-SDR dongle** (required for RF sweeps)
- `rtl_power` command from `rtl-sdr` package
Frequency bands scanned:
| Band | Frequency | Purpose |
|------|-----------|---------|
| FM Broadcast | 88-108 MHz | FM bugs |
| 315 MHz ISM | 315 MHz | US wireless devices |
| 433 MHz ISM | 433-434 MHz | EU wireless devices |
| 868 MHz ISM | 868-869 MHz | EU IoT devices |
| 915 MHz ISM | 902-928 MHz | US IoT devices |
| 1.2 GHz | 1200-1300 MHz | Video transmitters |
| 2.4 GHz ISM | 2400-2500 MHz | WiFi/BT/Video |
```bash
# Linux
sudo apt install rtl-sdr
# macOS
brew install librtlsdr
```
### WiFi Scanning
- Standard WiFi adapter (managed mode for basic scanning)
- Monitor mode capable adapter for advanced features
- `aircrack-ng` suite for monitor mode management
---
## Notes
- **Bluetooth on macOS**: Uses bleak library (CoreBluetooth backend), bluez tools not needed
- **WiFi on macOS**: Monitor mode has limited support, full functionality on Linux
- **System tools**: `iw`, `iwconfig`, `rfkill`, `ip` are pre-installed on most Linux systems
- **TSCM on macOS**: BLE and WiFi scanning work; RF spectrum requires RTL-SDR
+88
View File
@@ -0,0 +1,88 @@
# Security Considerations
INTERCEPT is designed as a **local signal intelligence tool** for personal use on trusted networks. This document outlines security considerations and best practices.
## Network Binding
By default, INTERCEPT binds to `0.0.0.0:5050`, making it accessible from any network interface. This is convenient for accessing the web UI from other devices on your local network, but has security implications:
### Recommendations
1. **Firewall Rules**: If you don't need remote access, configure your firewall to block external access to port 5050:
```bash
# Linux (iptables)
sudo iptables -A INPUT -p tcp --dport 5050 -s 127.0.0.1 -j ACCEPT
sudo iptables -A INPUT -p tcp --dport 5050 -j DROP
# macOS (pf)
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 or use the CLI flag:
```bash
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.
## Authentication
INTERCEPT does **not** include authentication. This is by design for ease of use as a personal tool. If you need to expose INTERCEPT to untrusted networks:
1. Use a reverse proxy (nginx, Caddy) with authentication
2. Use a VPN to access your home network
3. Use SSH port forwarding: `ssh -L 5050:localhost:5050 your-server`
## Security Headers
INTERCEPT includes the following security headers on all responses:
| Header | Value | Purpose |
|--------|-------|---------|
| `X-Content-Type-Options` | `nosniff` | Prevent MIME type sniffing |
| `X-Frame-Options` | `SAMEORIGIN` | Prevent clickjacking |
| `X-XSS-Protection` | `1; mode=block` | Enable browser XSS filter |
| `Referrer-Policy` | `strict-origin-when-cross-origin` | Control referrer information |
| `Permissions-Policy` | `geolocation=(self), microphone=()` | Restrict browser features |
## Input Validation
All user inputs are validated before use:
- **Network interface names**: Validated against strict regex pattern
- **Bluetooth interface names**: Must match `hciX` format
- **MAC addresses**: Validated format
- **Frequencies**: Validated range and format
- **File paths**: Protected against directory traversal
- **HTML output**: All user-provided content is escaped
## Subprocess Execution
INTERCEPT executes external tools (rtl_fm, airodump-ng, etc.) via subprocess. Security measures:
- **No shell execution**: All subprocess calls use list arguments, not shell strings
- **Input validation**: All user-provided arguments are validated before use
- **Process isolation**: Each tool runs in its own process with limited permissions
## Debug Mode
Debug mode is **disabled by default**. If enabled via `INTERCEPT_DEBUG=true`:
- The Werkzeug debugger PIN is disabled (not needed for local tool)
- Additional logging is enabled
- Stack traces are shown on errors
**Never run in debug mode on untrusted networks.**
## Reporting Security Issues
If you discover a security vulnerability, please report it by:
1. Opening a GitHub issue (for non-sensitive issues)
2. Emailing the maintainer directly (for sensitive issues)
Please include:
- Description of the vulnerability
- Steps to reproduce
- Potential impact
- Suggested fix (if any)
+248 -32
View File
@@ -14,6 +14,37 @@ pip install -r requirements.txt
python3 -m pip install -r requirements.txt
```
### pip install fails for flask or skyfield
On newer Debian/Ubuntu systems, pip may fail with permission errors or dependency conflicts. **Use apt instead:**
```bash
# Install Python packages via apt (recommended for Debian/Ubuntu)
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 ./start.sh
```
### "error: externally-managed-environment" (pip blocked)
This is PEP 668 protection on Ubuntu 23.04+, Debian 12+, and similar systems. Solutions:
```bash
# Option 1: Use apt packages (recommended)
sudo apt install python3-flask python3-requests python3-serial python3-skyfield
python3 -m venv --system-site-packages venv
source venv/bin/activate
# Option 2: Use pipx for isolated install
pipx install flask
# Option 3: Force pip (not recommended)
pip install --break-system-packages flask
```
### "TypeError: 'type' object is not subscriptable"
This error occurs on Python 3.7 or 3.8. **INTERCEPT requires Python 3.9 or later.**
@@ -30,24 +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
```
### "externally-managed-environment" error (Ubuntu 23.04+, Debian 12+)
### Alternative: Use the setup script
Modern systems use PEP 668 to protect system Python. Use a virtual environment:
The setup script handles all installation automatically, including apt packages and source builds:
```bash
# Option 1: Virtual environment (recommended)
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
sudo venv/bin/python intercept.py
# Option 2: Use the setup script (auto-creates venv if needed)
./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
@@ -101,11 +129,204 @@ Then unplug and replug your RTL-SDR.
3. Check for other applications: `lsof | grep rtl`
### LimeSDR/HackRF not detected
Ensure the correct SoapySDR module for your hardware is installed first
1. Verify SoapySDR is installed: `SoapySDRUtil --info`
2. Check driver is loaded: `SoapySDRUtil --find`
3. May need udev rules or run as root
### Using HackRF/Airspy/LimeSDR with ADS-B
For non-RTL-SDR devices, ADS-B requires `readsb` compiled with SoapySDR support (standard dump1090 won't work).
**Option 1: Run readsb separately and connect via Remote mode**
1. Start readsb with your device:
```bash
# HackRF
readsb --device-type soapysdr --device driver=hackrf --net --quiet
# Airspy
readsb --device-type soapysdr --device driver=airspy --net --quiet
# LimeSDR
readsb --device-type soapysdr --device driver=lime --net --quiet
```
2. In Intercept's ADS-B dashboard:
- Check the **"Remote"** checkbox
- Enter Host: `localhost` and Port: `30003`
- Click **START**
3. Intercept will connect to readsb's SBS output on port 30003
**Option 2: Install readsb with SoapySDR support**
On Debian/Ubuntu:
```bash
# Install dependencies
sudo apt install build-essential debhelper librtlsdr-dev pkg-config \
libncurses5-dev libbladerf-dev libhackrf-dev liblimesuite-dev libsoapysdr-dev
# Clone and build
git clone https://github.com/wiedehopf/readsb.git
cd readsb
dpkg-buildpackage -b --no-sign
sudo dpkg -i ../readsb_*.deb
```
### Using HackRF/Airspy with Listening Post
The Listening Post requires `rx_fm` from SoapySDR utilities for non-RTL-SDR devices.
```bash
# Install SoapySDR utilities (includes rx_fm)
sudo apt install soapysdr-tools
# Verify rx_fm is available
which rx_fm
```
If `rx_fm` is installed, select your device from the SDR dropdown in the Listening Post - HackRF, Airspy, LimeSDR, and SDRPlay are all supported.
### Setting up Icecast for Listening Post Audio
The Listening Post uses Icecast for low-latency audio streaming (2-10 second latency). Intercept will automatically start Icecast when you begin listening, but you must install and configure it first.
**Install Icecast:**
```bash
# Ubuntu/Debian
sudo apt install icecast2
# macOS
brew install icecast
```
**Configure Icecast:**
During installation on Debian/Ubuntu, you'll be prompted to configure. Otherwise, edit `/etc/icecast2/icecast.xml`:
```xml
<icecast>
<authentication>
<!-- Source password - used by ffmpeg to send audio -->
<source-password>hackme</source-password>
<!-- Admin password for web interface -->
<admin-password>your-admin-password</admin-password>
</authentication>
<hostname>localhost</hostname>
<listen-socket>
<port>8000</port>
</listen-socket>
</icecast>
```
**Start Icecast:**
```bash
# Ubuntu/Debian (as service)
sudo systemctl enable icecast2
sudo systemctl start icecast2
# Or run directly
icecast -c /etc/icecast2/icecast.xml
# macOS
brew services start icecast
# Or: icecast -c /usr/local/etc/icecast.xml
```
**Verify Icecast is running:**
- Open http://localhost:8000 in your browser
- You should see the Icecast status page
**Configure Intercept (optional):**
The default configuration expects Icecast on `127.0.0.1:8000` with source password `hackme` and mount point `/listen.mp3`. To change these, modify the scanner config in your API calls or update the defaults in `routes/listening_post.py`:
```python
scanner_config = {
# ... other settings ...
'icecast_host': '127.0.0.1',
'icecast_port': 8000,
'icecast_mount': '/listen.mp3',
'icecast_source_password': 'hackme',
}
```
**Troubleshooting Icecast:**
- **"Connection refused" errors**: Ensure Icecast is running on the configured port
- **"Authentication failed"**: Check the source password matches between Icecast config and Intercept
- **No audio playing**: Check Icecast status page (http://localhost:8000) to verify the mount point is active
- **High latency**: Ensure nginx/reverse proxy isn't buffering - add `proxy_buffering off;` to nginx config
### Audio Streaming Issues - Detailed Debugging
If the Listening Post shows "Icecast mount not active" errors or audio doesn't play:
**1. Check the console output for errors**
Intercept now logs detailed error output. Look for lines starting with `[AUDIO]`:
```
[AUDIO] SDR errors: ... # Problems with rtl_fm/rx_fm (SDR not connected, device busy)
[AUDIO] FFmpeg errors: ... # Problems with ffmpeg (wrong password, codec issues)
```
**2. Verify SDR is connected and working**
```bash
# For RTL-SDR
rtl_test -t
# You should see: "Found 1 device(s)"
# If not, check USB connection and drivers
```
**3. Check Icecast password (macOS Homebrew)**
On macOS with Homebrew, the Icecast config is at `/opt/homebrew/etc/icecast.xml`. Check the source password:
```bash
grep source-password /opt/homebrew/etc/icecast.xml
```
If it's different from `hackme`, update it in the Listening Post Icecast config panel, or change the Icecast config and restart:
```bash
brew services restart icecast
```
**4. Verify ffmpeg has required codecs**
```bash
# Check MP3 encoder is available
ffmpeg -encoders 2>/dev/null | grep mp3
# Should show: libmp3lame
# If not, reinstall ffmpeg with all codecs:
# macOS: brew reinstall ffmpeg
# Linux: sudo apt install ffmpeg
```
**5. Test the pipeline manually**
Try running the audio pipeline directly to see errors:
```bash
# Test rtl_fm (should produce raw audio data)
rtl_fm -M am -f 118000000 -s 24000 -r 24000 -g 40 2>&1 | head -c 1000 | xxd | head
# Test ffmpeg to Icecast (replace PASSWORD with your source password)
rtl_fm -M am -f 118000000 -s 24000 -r 24000 -g 40 2>/dev/null | \
ffmpeg -f s16le -ar 24000 -ac 1 -i pipe:0 -c:a libmp3lame -b:a 64k \
-f mp3 -content_type audio/mpeg icecast://source:PASSWORD@127.0.0.1:8000/listen.mp3
```
**6. Common error messages and solutions**
| Error | Cause | Solution |
|-------|-------|----------|
| `No supported devices found` | SDR not connected | Plug in SDR, check USB |
| `Device or resource busy` | Another process using SDR | Click "Kill All Processes" |
| `401 Unauthorized` | Wrong Icecast password | Check password in Icecast config |
| `Connection refused` | Icecast not running | Start Icecast service |
| `Encoder libmp3lame not found` | ffmpeg missing codec | Reinstall ffmpeg with codecs |
## WiFi Issues
### Monitor mode fails
@@ -118,9 +339,7 @@ Then unplug and replug your RTL-SDR.
Run INTERCEPT with sudo:
```bash
sudo python3 intercept.py
# Or with venv:
sudo venv/bin/python intercept.py
sudo ./start.sh
```
### Interface not found after enabling monitor mode
@@ -146,21 +365,6 @@ Run with sudo or add your user to the bluetooth group:
sudo usermod -a -G bluetooth $USER
```
## GPS Issues
### GPS dongle not detected
1. Install pyserial: `pip install pyserial`
2. Check device is connected:
- Linux: `ls /dev/ttyUSB* /dev/ttyACM*`
- macOS: `ls /dev/tty.usb*`
3. Add user to dialout group (Linux):
```bash
sudo usermod -a -G dialout $USER
```
4. Most GPS dongles use 9600 baud (default in INTERCEPT)
5. GPS needs clear sky view to get a fix
## Decoding Issues
### No messages appearing (Pager mode)
@@ -170,15 +374,27 @@ sudo usermod -a -G bluetooth $USER
3. Check pager services are active in your area
4. Ensure antenna is connected
### Cannot install dump1090 in Debian (ADS-B mode)
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)
1. Verify dump1090 or readsb is installed
1. Verify dump1090 is installed
2. Check antenna is connected (1090 MHz antenna recommended)
3. Ensure clear view of sky
4. Set correct observer location for range calculations
4. Set correct observer location for range calculations or use gpsd
### Satellite passes not calculating
1. Ensure skyfield is installed: `pip install skyfield`
1. Ensure skyfield is installed: `apt install python3-skyfield`
2. Check TLE data is valid and recent
3. Verify observer location is set correctly
+617
View File
@@ -0,0 +1,617 @@
# iNTERCEPT UI Guide
This guide documents the UI design system, components, and patterns used in iNTERCEPT.
## Table of Contents
1. [Design Tokens](#design-tokens)
2. [Base Templates](#base-templates)
3. [Navigation](#navigation)
4. [Components](#components)
5. [Adding a New Module Page](#adding-a-new-module-page)
6. [Adding a New Dashboard](#adding-a-new-dashboard)
---
## Design Tokens
All design tokens are defined in `static/css/core/variables.css`. Import this file first in any stylesheet.
### Colors
```css
/* Backgrounds (layered depth) */
--bg-primary: #0a0c10; /* Darkest - page background */
--bg-secondary: #0f1218; /* Panels, sidebars */
--bg-tertiary: #151a23; /* Cards, elevated elements */
--bg-card: #121620; /* Card backgrounds */
--bg-elevated: #1a202c; /* Hover states, modals */
/* Accent Colors */
--accent-cyan: #4a9eff; /* Primary action color */
--accent-green: #22c55e; /* Success, online status */
--accent-red: #ef4444; /* Error, danger, stop */
--accent-orange: #f59e0b; /* Warning */
--accent-amber: #d4a853; /* Secondary highlight */
/* Text Hierarchy */
--text-primary: #e8eaed; /* Main content */
--text-secondary: #9ca3af; /* Secondary content */
--text-dim: #4b5563; /* Disabled, placeholder */
--text-muted: #374151; /* Barely visible */
/* Status Colors */
--status-online: #22c55e;
--status-warning: #f59e0b;
--status-error: #ef4444;
--status-offline: #6b7280;
```
### Spacing Scale
```css
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-16: 64px;
```
### Typography
```css
/* Font Families */
--font-sans: 'Inter', -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', monospace;
/* Font Sizes */
--text-xs: 10px;
--text-sm: 12px;
--text-base: 14px;
--text-lg: 16px;
--text-xl: 18px;
--text-2xl: 20px;
--text-3xl: 24px;
--text-4xl: 30px;
```
### Border Radius
```css
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-full: 9999px;
```
### Light Theme
The design system supports light/dark themes via `data-theme` attribute:
```html
<html data-theme="dark"> <!-- or "light" -->
```
Toggle with JavaScript:
```javascript
document.documentElement.setAttribute('data-theme', 'light');
```
---
## Base Templates
### `templates/layout/base.html`
The main base template for standard pages. Use for pages with sidebar + content layout.
```html
{% extends 'layout/base.html' %}
{% block title %}My Page Title{% endblock %}
{% block styles %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/my-page.css') }}">
{% endblock %}
{% block navigation %}
{% set active_mode = 'mymode' %}
{% include 'partials/nav.html' %}
{% endblock %}
{% block sidebar %}
<div class="app-sidebar">
<!-- Sidebar content -->
</div>
{% endblock %}
{% block content %}
<div class="page-container">
<h1>Page Title</h1>
<!-- Page content -->
</div>
{% endblock %}
{% block scripts %}
<script>
// Page-specific JavaScript
</script>
{% endblock %}
```
### `templates/layout/base_dashboard.html`
Extended base for full-screen dashboards (maps, visualizations).
```html
{% extends 'layout/base_dashboard.html' %}
{% set active_mode = 'mydashboard' %}
{% block dashboard_title %}MY DASHBOARD{% endblock %}
{% block styles %}
{{ super() }}
<link rel="stylesheet" href="{{ url_for('static', filename='css/my_dashboard.css') }}">
{% endblock %}
{% block stats_strip %}
<div class="stats-strip">
<!-- Stats bar content -->
</div>
{% endblock %}
{% block dashboard_content %}
<div class="dashboard-map-container">
<!-- Main visualization -->
</div>
<div class="dashboard-sidebar">
<!-- Sidebar panels -->
</div>
{% endblock %}
```
---
## Navigation
### Including Navigation
```html
{% set active_mode = 'pager' %}
{% include 'partials/nav.html' %}
```
### Valid `active_mode` Values
| Mode | Description |
|------|-------------|
| `pager` | Pager decoding |
| `sensor` | 433MHz sensors |
| `rtlamr` | Utility meters |
| `adsb` | Aircraft tracking |
| `ais` | Vessel tracking |
| `aprs` | Amateur radio |
| `wifi` | WiFi scanning |
| `bluetooth` | Bluetooth scanning |
| `tscm` | Counter-surveillance |
| `satellite` | Satellite tracking |
| `sstv` | ISS SSTV |
| `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:
- **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
---
## Components
### Card / Panel
```html
{% call card(title='PANEL TITLE', indicator=true, indicator_active=false) %}
<p>Panel content here</p>
{% endcall %}
```
Or manually:
```html
<div class="panel">
<div class="panel-header">
<span>PANEL TITLE</span>
<div class="panel-indicator active"></div>
</div>
<div class="panel-content">
<p>Content here</p>
</div>
</div>
```
### Empty State
```html
{% include 'components/empty_state.html' with context %}
{# Or with variables: #}
{% with title='No data yet', description='Start scanning to see results', action_text='Start Scan', action_onclick='startScan()' %}
{% include 'components/empty_state.html' %}
{% endwith %}
```
### Loading State
```html
{# Inline spinner #}
{% include 'components/loading.html' %}
{# With text #}
{% with text='Loading data...', size='lg' %}
{% include 'components/loading.html' %}
{% endwith %}
{# Full overlay #}
{% with overlay=true, text='Please wait...' %}
{% include 'components/loading.html' %}
{% endwith %}
```
### Status Badge
```html
{% with status='online', text='Connected', id='connectionStatus' %}
{% include 'components/status_badge.html' %}
{% endwith %}
```
Status values: `online`, `offline`, `warning`, `error`, `inactive`
### Buttons
```html
<!-- Primary action -->
<button class="btn btn-primary">Start Tracking</button>
<!-- Secondary action -->
<button class="btn btn-secondary">Cancel</button>
<!-- Danger action -->
<button class="btn btn-danger">Stop</button>
<!-- Ghost/subtle -->
<button class="btn btn-ghost">Settings</button>
<!-- Sizes -->
<button class="btn btn-primary btn-sm">Small</button>
<button class="btn btn-primary btn-lg">Large</button>
<!-- Icon button -->
<button class="btn btn-icon btn-secondary">
<span class="icon">...</span>
</button>
```
### Badges
```html
<span class="badge">Default</span>
<span class="badge badge-primary">Primary</span>
<span class="badge badge-success">Online</span>
<span class="badge badge-warning">Warning</span>
<span class="badge badge-danger">Error</span>
```
### Form Groups
```html
<div class="form-group">
<label for="frequency">Frequency (MHz)</label>
<input type="text" id="frequency" value="153.350">
<span class="form-help">Enter frequency in MHz</span>
</div>
<div class="form-group">
<label for="gain">Gain</label>
<select id="gain">
<option value="auto">Auto</option>
<option value="30">30 dB</option>
</select>
</div>
<label class="form-check">
<input type="checkbox" id="alerts">
<span>Enable alerts</span>
</label>
```
### Stats Strip
Used in dashboards for horizontal statistics display:
```html
<div class="stats-strip">
<div class="stats-strip-inner">
<div class="strip-stat">
<span class="strip-value" id="count">0</span>
<span class="strip-label">COUNT</span>
</div>
<div class="strip-divider"></div>
<div class="strip-status">
<div class="status-dot active" id="statusDot"></div>
<span id="statusText">TRACKING</span>
</div>
<div class="strip-time" id="utcTime">--:--:-- UTC</div>
</div>
</div>
```
---
## Adding a New Module Page
### 1. Create the Route
In `routes/mymodule.py`:
```python
from flask import Blueprint, render_template
mymodule_bp = Blueprint('mymodule', __name__, url_prefix='/mymodule')
@mymodule_bp.route('/dashboard')
def dashboard():
return render_template('mymodule_dashboard.html',
offline_settings=get_offline_settings())
```
### 2. Register the Blueprint
In `routes/__init__.py`:
```python
from routes.mymodule import mymodule_bp
app.register_blueprint(mymodule_bp)
```
### 3. Create the Template
Option A: Simple page extending base.html
```html
{% extends 'layout/base.html' %}
{% set active_mode = 'mymodule' %}
{% block title %}My Module{% endblock %}
{% block navigation %}
{% include 'partials/nav.html' %}
{% endblock %}
{% block content %}
<!-- Your content -->
{% endblock %}
```
Option B: Full-screen dashboard
```html
{% extends 'layout/base_dashboard.html' %}
{% set active_mode = 'mymodule' %}
{% block dashboard_title %}MY MODULE{% endblock %}
{% block dashboard_content %}
<!-- Your dashboard content -->
{% endblock %}
```
### 4. Add to Navigation
In `templates/partials/nav.html`, add your module to the appropriate group:
```html
<button class="mode-nav-btn {% if active_mode == 'mymodule' %}active{% endif %}"
onclick="switchMode('mymodule')">
<span class="nav-icon icon"><!-- SVG icon --></span>
<span class="nav-label">My Module</span>
</button>
```
Or if it's a dashboard link:
```html
<a href="/mymodule/dashboard"
class="mode-nav-btn {% if active_mode == 'mymodule' %}active{% endif %}"
style="text-decoration: none;">
<span class="nav-icon icon"><!-- SVG icon --></span>
<span class="nav-label">My Module</span>
</a>
```
### 5. Create Stylesheet
In `static/css/mymodule.css`:
```css
/**
* My Module Styles
*/
@import url('./core/variables.css');
/* Your styles using design tokens */
.mymodule-container {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: var(--space-4);
}
```
---
## Adding a New Dashboard
For full-screen dashboards like ADSB, AIS, or Satellite:
### 1. Create the Template
```html
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MY DASHBOARD // iNTERCEPT</title>
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
<!-- Design tokens (required) -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/variables.css') }}">
<!-- Fonts -->
{% if offline_settings.fonts_source == 'local' %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
{% else %}
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
{% endif %}
<!-- External libraries if needed -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<!-- Dashboard styles -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/mydashboard.css') }}">
</head>
<body>
<!-- Background effects -->
<div class="radar-bg"></div>
<div class="scanline"></div>
<!-- Header -->
<header class="header">
<div class="logo">
<a href="/" style="color: inherit; text-decoration: none;">
MY DASHBOARD
<span>// iNTERCEPT</span>
</a>
</div>
<div class="status-bar">
<a href="#" onclick="history.back(); return false;" class="back-link">Back</a>
<a href="/" class="back-link">Main Dashboard</a>
</div>
</header>
<!-- Unified Navigation -->
{% set active_mode = 'mydashboard' %}
{% include 'partials/nav.html' %}
<!-- Stats Strip -->
<div class="stats-strip">
<!-- Stats content -->
</div>
<!-- Main Dashboard Content -->
<main class="dashboard">
<!-- Your dashboard layout -->
</main>
<script>
// Dashboard JavaScript
</script>
</body>
</html>
```
### 2. Create the Stylesheet
```css
/**
* My Dashboard Styles
*/
@import url('./core/variables.css');
:root {
/* Dashboard-specific aliases */
--bg-dark: var(--bg-primary);
--bg-panel: var(--bg-secondary);
--bg-card: var(--bg-tertiary);
--grid-line: rgba(74, 158, 255, 0.08);
}
/* Your dashboard styles */
```
---
## Best Practices
### DO
- Use design tokens for all colors, spacing, and typography
- Include the nav partial on all pages for consistent navigation
- Set `active_mode` before including the nav partial
- Use semantic component classes (`btn`, `panel`, `badge`, etc.)
- Support both light and dark themes
- Test on mobile viewports
### DON'T
- Hardcode color values - use CSS variables
- Create new color variations without adding to tokens
- Duplicate navigation markup - use the partial
- Skip the favicon and design tokens imports
- Use inline styles for layout (use utility classes)
---
## File Structure
```
templates/
├── layout/
│ ├── base.html # Standard page base
│ └── base_dashboard.html # Dashboard page base
├── partials/
│ ├── nav.html # Unified navigation
│ ├── page_header.html # Page title component
│ └── settings-modal.html # Settings modal
├── components/
│ ├── card.html # Panel/card component
│ ├── empty_state.html # Empty state placeholder
│ ├── loading.html # Loading spinner
│ ├── stats_strip.html # Stats bar component
│ └── status_badge.html # Status indicator
├── index.html # Main dashboard
├── adsb_dashboard.html # Aircraft tracking
├── ais_dashboard.html # Vessel tracking
└── satellite_dashboard.html # Satellite tracking
static/css/
├── core/
│ ├── variables.css # Design tokens
│ ├── base.css # Reset & typography
│ ├── components.css # Component styles
│ └── layout.css # Layout styles
├── index.css # Main dashboard styles
├── adsb_dashboard.css # Aircraft dashboard
├── ais_dashboard.css # Vessel dashboard
├── satellite_dashboard.css # Satellite dashboard
└── responsive.css # Responsive breakpoints
```
+460 -1
View File
@@ -57,6 +57,48 @@ INTERCEPT automatically detects known trackers:
- Samsung SmartTag
- Chipolo
## Sub-GHz Analyzer
1. **Connect HackRF** - Plug in your HackRF One device
2. **Set Frequency** - Enter a frequency in the 300-928 MHz ISM range or use a preset
3. **Start Capture** - Click "Start Capture" to begin signal analysis
4. **View Spectrum** - Real-time spectrum visualization of the selected band
5. **Protocol Decoding** - Identified protocols are displayed with decoded data
### Supported Protocols
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
2. **Set Frequency Range** - Define start and end frequencies for scanning
3. **Start Scanning** - Click "Start Scan" for wideband sweep
4. **View Signals** - Discovered signals are listed with frequency and SNR
5. **Tune In** - Click a signal to tune the audio demodulator
6. **Listen** - Real-time audio plays in your browser
### Demodulation Modes
- **FM** - Narrowband and wideband FM
- **SSB** - Upper and lower sideband for amateur radio and shortwave
## Aircraft Mode (ADS-B)
1. **Select Hardware** - Choose your SDR type (RTL-SDR uses dump1090, others use readsb)
@@ -65,6 +107,8 @@ INTERCEPT automatically detects known trackers:
- **Manual Entry** - Type coordinates directly
- **Browser GPS** - Use browser's built-in geolocation (requires HTTPS)
- **USB GPS Dongle** - Connect a USB GPS receiver for continuous updates
- **Shared Location** - By default, the observer location is shared across modules
(disable with `INTERCEPT_SHARED_OBSERVER_LOCATION=false`)
4. **Start Tracking** - Click "Start Tracking" to begin ADS-B reception
5. **View Map** - Aircraft appear on the interactive Leaflet map
6. **Click Aircraft** - Click markers for detailed information
@@ -72,6 +116,9 @@ INTERCEPT automatically detects known trackers:
8. **Filter Aircraft** - Use dropdown to show all, military, civil, or emergency only
9. **Full Dashboard** - Click "Full Screen Dashboard" for dedicated radar view
> Note: ADS-B auto-start is disabled by default. To enable auto-start on dashboard load,
> set `INTERCEPT_ADSB_AUTO_START=true`.
### Emergency Squawks
The system highlights aircraft transmitting emergency squawks:
@@ -79,6 +126,85 @@ 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.
### Enable History
Set the following environment variables (Docker recommended):
| Variable | Default | Description |
|----------|---------|-------------|
| `INTERCEPT_ADSB_HISTORY_ENABLED` | `false` | Enables history storage and reporting |
| `INTERCEPT_ADSB_DB_HOST` | `localhost` | Postgres host (use `adsb_db` in Docker) |
| `INTERCEPT_ADSB_DB_PORT` | `5432` | Postgres port |
| `INTERCEPT_ADSB_DB_NAME` | `intercept_adsb` | Database name |
| `INTERCEPT_ADSB_DB_USER` | `intercept` | Database user |
| `INTERCEPT_ADSB_DB_PASSWORD` | `intercept` | Database password |
### Other ADS-B Settings
| Variable | Default | Description |
|----------|---------|-------------|
| `INTERCEPT_ADSB_AUTO_START` | `false` | Auto-start ADS-B tracking when the dashboard loads |
| `INTERCEPT_SHARED_OBSERVER_LOCATION` | `true` | Share observer location across ADS-B/AIS/SSTV/Satellite modules |
**Local install example**
```bash
INTERCEPT_ADSB_AUTO_START=true \
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
sudo ./start.sh
```
**Docker example (.env)**
```bash
INTERCEPT_ADSB_AUTO_START=true
INTERCEPT_SHARED_OBSERVER_LOCATION=false
```
### Docker Setup
`docker-compose.yml` includes an `adsb_db` service and a persistent volume for history storage:
```bash
docker compose --profile history up -d
```
To store Postgres data on external storage, set `PGDATA_PATH` (defaults to `./pgdata`):
```bash
PGDATA_PATH=/mnt/usbpi1/intercept/pgdata
```
### Using the History Dashboard
1. Open **/adsb/history**
2. Use **Start Tracking** to run ADS-B in headless mode
3. View aircraft history and timelines
4. Stop tracking when desired (session history is recorded)
If the History dashboard shows **HISTORY DISABLED**, enable `INTERCEPT_ADSB_HISTORY_ENABLED=true` and ensure Postgres is running.
## Satellite Mode
1. **Set Location** - Choose location source:
@@ -98,6 +224,321 @@ The system highlights aircraft transmitting emergency squawks:
3. Choose a category (Amateur, Weather, ISS, Starlink, etc.)
4. Select satellites to add
## Weather Satellites
1. **Set Location** - Enter observer coordinates or use GPS
2. **Select Satellite** - Choose NOAA (APT) or Meteor (LRPT)
3. **View Passes** - Upcoming passes shown with polar plot and ground track
4. **Start Capture** - Click "Start Capture" when a satellite is overhead, or enable auto-scheduler
5. **View Images** - Decoded imagery appears in the gallery
### Auto-Scheduler
Enable the auto-scheduler to automatically capture passes:
- Calculates upcoming NOAA and Meteor passes for your location
- 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
2. **Start Tracking** - Click "Start Tracking" to monitor AIS frequencies (161.975/162.025 MHz)
3. **View Map** - Vessels appear on the interactive maritime map
4. **Click Vessels** - View name, MMSI, callsign, destination, speed, heading
5. **Full Dashboard** - Click "Full Screen Dashboard" for dedicated maritime view
### VHF DSC Channel 70
Digital Selective Calling monitoring runs alongside AIS:
- Distress, Urgency, Safety, and Routine messages
- 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
2. **Set Frequency** - Defaults to regional APRS frequency (144.390 MHz NA, 144.800 MHz EU)
3. **Start Decoding** - Click "Start Decoding" to begin packet radio reception via direwolf
4. **View Map** - Station positions appear on the interactive map
5. **View Messages** - Position reports, telemetry, and messages displayed in real time
## Utility Meters
1. **Start Monitoring** - Click "Start" to begin meter broadcast reception via rtl_amr
2. **View Meters** - Decoded meter data appears with meter ID, type, and consumption
3. **Filter** - Filter by meter type (electric, gas, water) or meter ID
## BT Locate (SAR Device Location)
1. **Set Target** - Enter one or more target identifiers:
- **MAC Address** - Exact Bluetooth address (AA:BB:CC:DD:EE:FF)
- **Name Pattern** - Substring match (e.g., "iPhone", "Galaxy")
- **IRK** - 32-character hex Identity Resolving Key for RPA resolution
- **Detect IRKs** - Click "Detect" to auto-extract IRKs from paired devices
2. **Choose Environment** - Select the RF environment preset:
- **Open Field** (n=2.0) - Best for open areas with line-of-sight
- **Outdoor** (n=2.2) - Default, works well in most outdoor settings
- **Indoor** (n=3.0) - For buildings with walls and obstacles
3. **Start Locate** - Click "Start Locate" to begin tracking
4. **Monitor HUD** - The proximity display shows:
- Proximity band (IMMEDIATE / NEAR / FAR)
- Estimated distance in meters
- Raw RSSI and smoothed RSSI average
- Detection count and GPS-tagged points
5. **Follow the Signal** - Move towards stronger signal (higher RSSI / closer distance)
6. **Audio Alerts** - Enable audio for proximity tones that increase in pitch as you get closer
7. **Review Trail** - Check the map for GPS-tagged detection trail
### Hand-off from Bluetooth Mode
1. Open Bluetooth scanning mode and find the target device
2. Click the "Locate" button on the device card
3. BT Locate opens with the device pre-filled
4. Click "Start Locate" to begin tracking
### Tips
- For devices with address randomization (iPhones, modern Android), use the IRK method
- Click "Detect" next to the IRK field to auto-extract IRKs from paired devices
- 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
2. **View Map** - Your position appears on the interactive map with a track trail
3. **Monitor Stats** - Speed, heading, altitude, and satellite count displayed in real-time
4. **Record Track** - Enable track recording to save your path
### Tips
- Ensure gpsd is running: `sudo gpsd /dev/ttyUSB0 -F /var/run/gpsd.sock`
- 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
2. **Start** - Click "Start" to connect to the mesh network
3. **View Messages** - Real-time message stream from the mesh
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.
### Setting Up an Agent
1. **Install INTERCEPT** on the remote machine
2. **Create config file** (`intercept_agent.cfg`):
```ini
[agent]
name = sensor-node-1
port = 8020
[controller]
url = http://192.168.1.100:5050
api_key = your-secret-key
push_enabled = true
[modes]
pager = true
sensor = true
adsb = true
```
3. **Start the agent**:
```bash
python intercept_agent.py --config intercept_agent.cfg
```
### Registering Agents in the Controller
1. Navigate to `/controller/manage` in the main INTERCEPT instance
2. Enter agent details:
- **Name**: Must match config file (e.g., `sensor-node-1`)
- **Base URL**: Agent address (e.g., `http://192.168.1.50:8020`)
- **API Key**: Must match config file
3. Click "Register Agent"
4. Use "Test" to verify connectivity
### Using Remote Agents
Once registered, agents appear in mode dropdowns:
1. **Select agent** from the dropdown in supported modes
2. **Start mode** - Commands are proxied to the remote agent
3. **View data** - Data streams back to your browser via SSE
### Multi-Agent Streaming
Enable "Show All Agents" to aggregate data from all registered agents simultaneously.
For complete documentation, see [Distributed Agents Guide](DISTRIBUTED_AGENTS.md).
## Configuration
INTERCEPT can be configured via environment variables:
@@ -110,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 python3 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
+18
View File
@@ -0,0 +1,18 @@
title: iNTERCEPT
description: Signal Intelligence Platform - A web-based interface for software-defined radio tools
url: https://smittix.github.io
baseurl: /intercept
# Build settings
include:
- _headers
# Exclude files from build
exclude:
- README.md
- SECURITY.md
- TROUBLESHOOTING.md
- USAGE.md
- FEATURES.md
- HARDWARE.md
- DISTRIBUTED_AGENTS.md
Binary file not shown.

After

Width:  |  Height:  |  Size: 466 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 837 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 790 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 694 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 853 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 929 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 698 KiB

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: 692 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 791 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 811 KiB

+824
View File
@@ -0,0 +1,824 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>iNTERCEPT - Signal Intelligence Platform</title>
<meta name="description" content="A web-based interface for software-defined radio tools. Pager decoding, ADS-B tracking, WiFi scanning, and more.">
<link rel="stylesheet" href="style.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<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>
<div class="nav-links">
<a href="#features">Features</a>
<a href="#screenshots">Screenshots</a>
<a href="#installation">Install</a>
<a href="https://github.com/smittix/intercept/blob/main/docs/DISTRIBUTED_AGENTS.md">Remote Agents</a>
<a href="https://github.com/smittix/intercept" class="nav-btn" target="_blank">GitHub</a>
</div>
</div>
</nav>
<header class="hero">
<div class="hero-content">
<div class="hero-badge">Open Source SIGINT Platform</div>
<h1>iNTERCEPT</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>
<a href="https://github.com/smittix/intercept" class="btn btn-secondary" target="_blank">View on GitHub</a>
</div>
<div class="hero-stats">
<div class="stat">
<span class="stat-value">34</span>
<span class="stat-label">Modes</span>
</div>
<div class="stat">
<span class="stat-value">200+</span>
<span class="stat-label">Protocols</span>
</div>
<div class="stat">
<span class="stat-value">$25</span>
<span class="stat-label">Min Hardware</span>
</div>
</div>
</div>
<div class="hero-image">
<img src="images/dashboard.png" alt="iNTERCEPT Dashboard">
</div>
</header>
<section id="features" class="features">
<div class="container">
<h2>Capabilities</h2>
<p class="section-subtitle">Everything you need for signal intelligence in one interface</p>
<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>
<section id="screenshots" class="screenshots">
<div class="container">
<h2>See It In Action</h2>
<p class="section-subtitle">A clean, modern interface for complex RF operations</p>
<div class="screenshot-gallery">
<div class="screenshot-item">
<img src="images/dashboard.png" alt="Main Dashboard">
<span class="screenshot-label">Dashboard</span>
</div>
<div class="screenshot-item">
<img src="images/tscm.png" alt="TSCM Counter-Surveillance">
<span class="screenshot-label">TSCM Counter-Surveillance</span>
</div>
<div class="screenshot-item">
<img src="images/bluetooth.png" alt="Bluetooth Scanner">
<span class="screenshot-label">Bluetooth Scanner</span>
</div>
<div class="screenshot-item">
<img src="images/wifi.png" alt="WiFi Scanner">
<span class="screenshot-label">WiFi Scanner</span>
</div>
<div class="screenshot-item">
<img src="images/scanner.png" alt="Listening Post">
<span class="screenshot-label">Listening Post</span>
</div>
<div class="screenshot-item">
<img src="images/sensors.png" alt="433MHz Sensor Monitor">
<span class="screenshot-label">433MHz Sensors</span>
</div>
<div class="screenshot-item">
<img src="images/tscm-detail.png" alt="Device Detail Dialog">
<span class="screenshot-label">Device Analysis</span>
</div>
<div class="screenshot-item">
<img src="images/remote-agents.png" alt="Remote Agents Management">
<span class="screenshot-label">Remote Agents</span>
</div>
<div class="screenshot-item">
<img src="images/ais.png" alt="AIS Vessel Tracking">
<span class="screenshot-label">AIS Vessel Tracking</span>
</div>
<div class="screenshot-item">
<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>
<section id="installation" class="installation">
<div class="container">
<h2>Quick Start</h2>
<p class="section-subtitle">Get up and running in minutes</p>
<div class="platform-note">
<p><strong>Supported Platforms:</strong> Officially tested on Debian and Ubuntu. Partial support for macOS. Other distributions have not been fully tested.</p>
</div>
<div class="install-options">
<div class="install-card">
<h3>Standard Installation</h3>
<div class="code-block">
<pre><code>git clone https://github.com/smittix/intercept.git
cd intercept
./setup.sh # Interactive wizard with install profiles
sudo ./start.sh</code></pre>
</div>
<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">
<h3>Docker</h3>
<div class="code-block">
<pre><code>git clone https://github.com/smittix/intercept.git
cd intercept
docker compose --profile basic up -d --build</code></pre>
</div>
<p class="install-note">Requires privileged mode for USB SDR access</p>
</div>
</div>
<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>
<section class="hardware">
<div class="container">
<h2>Hardware</h2>
<p class="section-subtitle">Minimal hardware, maximum capability</p>
<div class="hardware-grid">
<div class="hardware-card required">
<div class="hardware-tag">Required</div>
<h3>RTL-SDR</h3>
<p>Core SDR functionality for all radio features</p>
<span class="price">~$25-35</span>
</div>
<div class="hardware-card optional">
<div class="hardware-tag">Optional</div>
<h3>WiFi Adapter</h3>
<p>Monitor mode support for WiFi scanning</p>
<span class="price">~$20-40</span>
</div>
<div class="hardware-card optional">
<div class="hardware-tag">Optional</div>
<h3>GPS Receiver</h3>
<p>Real-time location for mapping features</p>
<span class="price">~$10</span>
</div>
</div>
<p class="hardware-note">iNTERCEPT also supports HackRF, LimeSDR, Airspy, and SDRplay via SoapySDR</p>
</div>
</section>
<section class="cta">
<div class="container">
<h2>Ready to start intercepting?</h2>
<p>Join the community and start exploring the RF spectrum</p>
<div class="cta-buttons">
<a href="https://github.com/smittix/intercept" class="btn btn-primary" target="_blank">Get iNTERCEPT</a>
<a href="https://discord.gg/EyeksEJmWE" class="btn btn-secondary" target="_blank">Join Discord</a>
</div>
</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>
<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> · Apache 2.0 License</p>
<p class="disclaimer">For educational and authorized testing purposes only.</p>
</div>
</div>
</footer>
<!-- Lightbox Modal -->
<div id="lightbox" class="lightbox">
<span class="lightbox-close">&times;</span>
<img class="lightbox-img" id="lightbox-img" src="" alt="">
<div class="lightbox-caption" id="lightbox-caption"></div>
</div>
<script>
// Lightbox functionality
const lightbox = document.getElementById('lightbox');
const lightboxImg = document.getElementById('lightbox-img');
const lightboxCaption = document.getElementById('lightbox-caption');
const closeBtn = document.querySelector('.lightbox-close');
document.querySelectorAll('.screenshot-item').forEach(item => {
item.addEventListener('click', () => {
const img = item.querySelector('img');
const label = item.querySelector('.screenshot-label');
lightbox.classList.add('active');
lightboxImg.src = img.src;
lightboxCaption.textContent = label.textContent;
document.body.style.overflow = 'hidden';
});
});
function closeLightbox() {
lightbox.classList.remove('active');
document.body.style.overflow = '';
}
closeBtn.addEventListener('click', closeLightbox);
lightbox.addEventListener('click', (e) => {
if (e.target === lightbox) closeLightbox();
});
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>
+943
View File
@@ -0,0 +1,943 @@
/* INTERCEPT GitHub Pages - Dark Theme */
:root {
--bg-primary: #0a0a0f;
--bg-secondary: #12121a;
--bg-card: #1a1a24;
--bg-card-hover: #22222e;
--text-primary: #f0f0f5;
--text-secondary: #8888a0;
--text-muted: #5c5c70;
--accent: #00d4aa;
--accent-hover: #00f0c0;
--accent-glow: rgba(0, 212, 170, 0.2);
--border: #2a2a38;
--code-bg: #0d0d14;
--gradient-start: #00d4aa;
--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;
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 24px;
}
/* Navigation */
.navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
background: rgba(10, 10, 15, 0.9);
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border);
}
.nav-container {
max-width: 1200px;
margin: 0 auto;
padding: 16px 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.nav-logo {
font-family: 'JetBrains Mono', monospace;
font-size: 1.25rem;
font-weight: 600;
color: var(--accent);
text-decoration: none;
letter-spacing: 2px;
}
.nav-links {
display: flex;
align-items: center;
gap: 32px;
}
.nav-links a {
color: var(--text-secondary);
text-decoration: none;
font-size: 0.9rem;
font-weight: 500;
transition: color 0.2s;
}
.nav-links a:hover {
color: var(--text-primary);
}
.nav-btn {
background: var(--accent);
color: var(--bg-primary) !important;
padding: 8px 20px;
border-radius: 6px;
font-weight: 600;
}
.nav-btn:hover {
background: var(--accent-hover);
}
/* Hero */
.hero {
min-height: 100vh;
display: grid;
grid-template-columns: 1fr 1fr;
align-items: center;
gap: 60px;
padding: 120px 24px 80px;
max-width: 1400px;
margin: 0 auto;
}
.hero-badge {
display: inline-block;
font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem;
color: var(--accent);
background: var(--accent-glow);
padding: 6px 14px;
border-radius: 20px;
border: 1px solid var(--accent);
margin-bottom: 24px;
letter-spacing: 1px;
text-transform: uppercase;
}
.hero h1 {
font-family: 'JetBrains Mono', monospace;
font-size: 4.5rem;
font-weight: 700;
letter-spacing: 8px;
margin-bottom: 24px;
background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.hero-subtitle {
font-size: 1.25rem;
color: var(--text-secondary);
margin-bottom: 40px;
max-width: 500px;
line-height: 1.8;
}
.hero-buttons {
display: flex;
gap: 16px;
margin-bottom: 60px;
}
.btn {
display: inline-block;
padding: 14px 32px;
border-radius: 8px;
font-weight: 600;
text-decoration: none;
transition: all 0.2s;
font-size: 0.95rem;
}
.btn-primary {
background: var(--accent);
color: var(--bg-primary);
}
.btn-primary:hover {
background: var(--accent-hover);
transform: translateY(-2px);
box-shadow: 0 8px 30px var(--accent-glow);
}
.btn-secondary {
background: transparent;
color: var(--text-primary);
border: 1px solid var(--border);
}
.btn-secondary:hover {
border-color: var(--text-secondary);
background: var(--bg-card);
}
.hero-stats {
display: flex;
gap: 48px;
}
.stat {
display: flex;
flex-direction: column;
}
.stat-value {
font-family: 'JetBrains Mono', monospace;
font-size: 2rem;
font-weight: 600;
color: var(--accent);
}
.stat-label {
font-size: 0.85rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 1px;
}
.hero-image {
position: relative;
}
.hero-image img {
width: 100%;
border-radius: 12px;
border: 1px solid var(--border);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
/* Sections */
section {
padding: 100px 0;
}
section h2 {
font-family: 'JetBrains Mono', monospace;
font-size: 2.5rem;
font-weight: 600;
text-align: center;
margin-bottom: 16px;
letter-spacing: 2px;
}
.section-subtitle {
text-align: center;
color: var(--text-secondary);
font-size: 1.1rem;
margin-bottom: 60px;
}
/* Features */
.features {
background: var(--bg-secondary);
}
/* 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 {
background: var(--bg-card-hover);
border-color: var(--accent);
transform: translateY(-4px);
}
.feature-icon {
width: 36px;
height: 36px;
margin-bottom: 16px;
color: var(--accent);
}
.feature-icon svg {
width: 100%;
height: 100%;
}
.feature-card h3 {
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 12px;
color: var(--text-primary);
}
.feature-card p {
font-size: 0.9rem;
color: var(--text-secondary);
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;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
}
.screenshot-item {
position: relative;
border-radius: 12px;
overflow: hidden;
border: 1px solid var(--border);
transition: all 0.3s;
cursor: pointer;
}
.screenshot-item:hover {
border-color: var(--accent);
transform: translateY(-4px);
box-shadow: 0 12px 40px rgba(0, 212, 170, 0.15);
}
.screenshot-item img {
width: 100%;
height: auto;
display: block;
}
.screenshot-label {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 16px;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.9));
font-size: 0.9rem;
color: var(--text-primary);
font-weight: 500;
}
/* Lightbox */
.lightbox {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.95);
z-index: 1000;
justify-content: center;
align-items: center;
flex-direction: column;
padding: 20px;
}
.lightbox.active {
display: flex;
}
.lightbox-close {
position: absolute;
top: 20px;
right: 30px;
font-size: 40px;
color: var(--text-primary);
cursor: pointer;
transition: color 0.2s;
z-index: 1001;
}
.lightbox-close:hover {
color: var(--accent);
}
.lightbox-img {
max-width: 90%;
max-height: 80vh;
border-radius: 8px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.lightbox-caption {
margin-top: 20px;
font-size: 1.1rem;
color: var(--text-secondary);
font-weight: 500;
}
/* Installation */
.installation {
background: var(--bg-secondary);
}
.install-options {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 32px;
margin-bottom: 48px;
}
.install-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 32px;
}
.install-card h3 {
font-size: 1.2rem;
margin-bottom: 20px;
color: var(--text-primary);
}
.code-block {
background: var(--code-bg);
border-radius: 8px;
padding: 20px;
overflow-x: auto;
margin-bottom: 16px;
}
.code-block pre {
margin: 0;
}
.code-block code {
font-family: 'JetBrains Mono', monospace;
font-size: 0.85rem;
color: var(--accent);
line-height: 1.8;
}
.install-note {
font-size: 0.85rem;
color: var(--text-muted);
}
.platform-note {
background: var(--bg-card);
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
border-radius: 8px;
padding: 16px 20px;
margin-bottom: 32px;
}
.platform-note p {
font-size: 0.9rem;
color: var(--text-secondary);
margin: 0;
}
.platform-note strong {
color: var(--text-primary);
}
.post-install {
text-align: center;
padding: 32px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
}
.post-install p {
margin-bottom: 8px;
color: var(--text-secondary);
}
.post-install code {
font-family: 'JetBrains Mono', monospace;
background: var(--code-bg);
padding: 4px 10px;
border-radius: 4px;
color: var(--accent);
}
/* Hardware */
.hardware-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
margin-bottom: 32px;
}
.hardware-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 32px;
text-align: center;
position: relative;
}
.hardware-tag {
position: absolute;
top: 16px;
right: 16px;
font-size: 0.7rem;
padding: 4px 10px;
border-radius: 12px;
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 600;
}
.hardware-card.required .hardware-tag {
background: var(--accent-glow);
color: var(--accent);
border: 1px solid var(--accent);
}
.hardware-card.optional .hardware-tag {
background: rgba(136, 136, 160, 0.1);
color: var(--text-secondary);
border: 1px solid var(--border);
}
.hardware-card h3 {
font-size: 1.2rem;
margin-bottom: 12px;
margin-top: 8px;
}
.hardware-card p {
font-size: 0.9rem;
color: var(--text-secondary);
margin-bottom: 16px;
}
.hardware-card .price {
font-family: 'JetBrains Mono', monospace;
font-size: 1.5rem;
color: var(--accent);
font-weight: 600;
}
.hardware-note {
text-align: center;
color: var(--text-muted);
font-size: 0.9rem;
}
/* CTA */
.cta {
background: linear-gradient(135deg, rgba(0, 212, 170, 0.1), rgba(0, 136, 255, 0.1));
text-align: center;
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
}
.cta h2 {
margin-bottom: 16px;
}
.cta p {
color: var(--text-secondary);
margin-bottom: 32px;
}
.cta-buttons {
display: flex;
justify-content: center;
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);
padding: 60px 0 32px;
}
.footer-content {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 32px;
border-bottom: 1px solid var(--border);
margin-bottom: 32px;
}
.footer-logo {
font-family: 'JetBrains Mono', monospace;
font-size: 1.25rem;
font-weight: 600;
color: var(--accent);
letter-spacing: 2px;
}
.footer-brand p {
color: var(--text-muted);
font-size: 0.9rem;
margin-top: 8px;
}
.footer-links {
display: flex;
gap: 32px;
}
.footer-links a {
color: var(--text-secondary);
text-decoration: none;
font-size: 0.9rem;
transition: color 0.2s;
}
.footer-links a:hover {
color: var(--accent);
}
.footer-bottom {
text-align: center;
}
.footer-bottom p {
color: var(--text-muted);
font-size: 0.85rem;
margin-bottom: 8px;
}
.footer-bottom a {
color: var(--accent);
text-decoration: none;
}
.disclaimer {
font-style: italic;
opacity: 0.7;
}
/* Responsive */
@media (max-width: 1024px) {
.hero {
grid-template-columns: 1fr;
text-align: center;
padding-top: 100px;
}
.hero-subtitle {
max-width: 100%;
}
.hero-buttons {
justify-content: center;
}
.hero-stats {
justify-content: center;
}
.hero-image {
order: -1;
max-width: 600px;
margin: 0 auto;
}
.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;
}
.hardware-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.hero h1 {
font-size: 2.5rem;
letter-spacing: 4px;
}
.hero-stats {
flex-direction: column;
gap: 24px;
}
.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;
}
.footer-content {
flex-direction: column;
gap: 24px;
text-align: center;
}
.cta-buttons {
flex-direction: column;
align-items: center;
}
}
+30
View File
@@ -0,0 +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"
+18 -6
View File
@@ -1,8 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" fill="#000"/>
<path d="M50 5 L90 27.5 L90 72.5 L50 95 L10 72.5 L10 27.5 Z" stroke="#00d4ff" stroke-width="3" fill="none"/>
<path d="M30 50 Q40 35, 50 50 Q60 65, 70 50" stroke="#00d4ff" stroke-width="4" fill="none" stroke-linecap="round"/>
<path d="M35 50 Q42 40, 50 50 Q58 60, 65 50" stroke="#00ff88" stroke-width="3" fill="none" stroke-linecap="round"/>
<path d="M40 50 Q45 45, 50 50 Q55 55, 60 50" stroke="#ffffff" stroke-width="2" fill="none" stroke-linecap="round"/>
<circle cx="50" cy="50" r="4" fill="#00d4ff"/>
<!-- Background -->
<rect width="100" height="100" fill="#0a0a0f"/>
<!-- Signal brackets - left side -->
<path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="4" fill="none" stroke-linecap="round" opacity="0.5"/>
<path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="3.5" fill="none" stroke-linecap="round" opacity="0.7"/>
<path d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round"/>
<!-- Signal brackets - right side -->
<path d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="4" fill="none" stroke-linecap="round" opacity="0.5"/>
<path d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="3.5" fill="none" stroke-linecap="round" opacity="0.7"/>
<path d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round"/>
<!-- The 'i' letter -->
<circle cx="50" cy="22" r="7" fill="#00ff88"/>
<rect x="43" y="35" width="14" height="45" rx="2" fill="#00d4ff"/>
<rect x="36" y="35" width="28" height="5" rx="1" fill="#00d4ff"/>
<rect x="36" y="75" width="28" height="5" rx="1" fill="#00d4ff"/>
</svg>

Before

Width:  |  Height:  |  Size: 639 B

After

Width:  |  Height:  |  Size: 1.2 KiB

+210
View File
@@ -0,0 +1,210 @@
DMSP 5D-3 F16 (USA 172)
1 28054U 03048A 26037.66410905 .00000171 00000+0 11311-3 0 9991
2 28054 99.0018 60.5544 0007736 150.6435 318.8272 14.14449870151032
METEOSAT-9 (MSG-2)
1 28912U 05049B 26037.20698824 .00000122 00000+0 00000+0 0 9990
2 28912 9.0646 55.4438 0001292 220.3216 340.7358 1.00280364 5681
DMSP 5D-3 F17 (USA 191)
1 29522U 06050A 26037.63495522 .00000221 00000+0 13641-3 0 9997
2 29522 98.7406 46.8646 0011088 71.3269 288.9107 14.14949568993957
FENGYUN 3A
1 32958U 08026A 26037.29889977 .00000162 00000+0 97205-4 0 9995
2 32958 98.6761 340.6748 0009336 139.4536 220.7337 14.19536323916838
GOES 14
1 35491U 09033A 26037.59737599 .00000128 00000+0 00000+0 0 9998
2 35491 1.3510 84.7861 0001663 279.3774 203.6871 1.00112472 5283
DMSP 5D-3 F18 (USA 210)
1 35951U 09057A 26037.59574243 .00000344 00000+0 20119-3 0 9997
2 35951 98.8912 18.7405 0010014 262.2671 97.7365 14.14814612841124
EWS-G2 (GOES 15)
1 36411U 10008A 26037.42417604 .00000037 00000+0 00000+0 0 9998
2 36411 0.9477 85.6904 0004764 200.6178 64.5237 1.00275731 58322
COMS 1
1 36744U 10032A 26037.66884865 -.00000343 00000+0 00000+0 0 9998
2 36744 4.4730 77.2684 0001088 239.9858 188.4845 1.00274368 49786
FENGYUN 3B
1 37214U 10059A 26037.62488625 .00000510 00000+0 28715-3 0 9992
2 37214 98.9821 82.9728 0021838 194.4193 280.6049 14.14810700788968
SUOMI NPP
1 37849U 11061A 26037.58885771 .00000151 00000+0 92735-4 0 9993
2 37849 98.7835 339.4455 0001677 23.1332 336.9919 14.19534335739918
METEOSAT-10 (MSG-3)
1 38552U 12035B 26037.34062893 -.00000007 00000+0 00000+0 0 9993
2 38552 4.3618 61.5789 0002324 286.1065 271.3938 1.00272839 49549
METOP-B
1 38771U 12049A 26037.61376690 .00000161 00000+0 93652-4 0 9994
2 38771 98.6708 91.6029 0002456 28.4142 331.7169 14.21434029694718
INSAT-3D
1 39216U 13038B 26037.58021591 -.00000338 00000+0 00000+0 0 9998
2 39216 1.5890 84.3012 0001719 220.0673 170.6954 1.00273812 45771
FENGYUN 3C
1 39260U 13052A 26037.57879946 .00000181 00000+0 11337-3 0 9991
2 39260 98.4839 17.5531 0015475 42.6626 317.5748 14.15718213640089
METEOR-M 2
1 40069U 14037A 26037.57010537 .00000364 00000+0 18579-3 0 9995
2 40069 98.4979 18.0359 0006835 60.5067 299.6792 14.21415164600761
HIMAWARI-8
1 40267U 14060A 26037.58238259 -.00000273 00000+0 00000+0 0 9991
2 40267 0.0457 252.0286 0000958 31.3580 203.5957 1.00278490 41450
FENGYUN 2G
1 40367U 14090A 26037.64556289 -.00000299 00000+0 00000+0 0 9996
2 40367 5.3089 74.4184 0001565 198.1345 195.9683 1.00263067 40698
METEOSAT-11 (MSG-4)
1 40732U 15034A 26037.62779616 .00000065 00000+0 00000+0 0 9990
2 40732 2.8728 71.8294 0001180 241.7344 58.8290 1.00268087 5909
ELEKTRO-L 2
1 41105U 15074A 26037.40900929 -.00000118 00000+0 00000+0 0 9998
2 41105 6.3653 72.1489 0003612 229.0998 328.0297 1.00272232 37198
INSAT-3DR
1 41752U 16054A 26037.65505200 -.00000075 00000+0 00000+0 0 9997
2 41752 0.0554 93.8053 0013744 184.8269 167.9427 1.00271627 34504
HIMAWARI-9
1 41836U 16064A 26037.58238259 -.00000273 00000+0 00000+0 0 9990
2 41836 0.0124 137.0088 0001068 210.1850 139.9064 1.00271322 33905
GOES 16
1 41866U 16071A 26037.60517604 -.00000089 00000+0 00000+0 0 9993
2 41866 0.1490 94.1417 0002832 199.6896 316.0413 1.00271854 33798
FENGYUN 4A
1 41882U 16077A 26037.65041625 -.00000356 00000+0 00000+0 0 9994
2 41882 1.9907 81.7886 0006284 132.9819 279.8453 1.00276098 33627
CYGFM05
1 41884U 16078A 26037.42561482 .00027408 00000+0 46309-3 0 9992
2 41884 34.9596 42.6579 0007295 332.2973 27.7361 15.50585086508404
CYGFM04
1 41885U 16078B 26037.34428483 .00032519 00000+0 49575-3 0 9994
2 41885 34.9348 16.2836 0005718 359.2189 0.8525 15.53424088508589
CYGFM02
1 41886U 16078C 26037.35007768 .00035591 00000+0 50564-3 0 9998
2 41886 34.9436 13.7490 0006836 2.8379 357.2383 15.55324468508720
CYGFM01
1 41887U 16078D 26037.39685921 .00028560 00000+0 47572-3 0 9999
2 41887 34.9425 44.8029 0007415 323.1915 36.8298 15.50976884508344
CYGFM08
1 41888U 16078E 26037.34185185 .00031327 00000+0 49606-3 0 9997
2 41888 34.9457 27.4597 0008083 350.5361 9.5208 15.52364941508578
CYGFM07
1 41890U 16078G 26037.32199955 .00032204 00000+0 49829-3 0 9990
2 41890 34.9475 16.2411 0005914 7.0804 353.0002 15.53017084508593
CYGFM03
1 41891U 16078H 26037.35550653 .00031487 00000+0 48940-3 0 9995
2 41891 34.9430 17.9804 0005939 349.1458 10.9136 15.52895386508574
FENGYUN 3D
1 43010U 17072A 26037.62659924 .00000092 00000+0 65298-4 0 9990
2 43010 98.9980 9.7978 0002479 69.6779 290.4663 14.19704535426460
NOAA 20 (JPSS-1)
1 43013U 17073A 26037.60336371 .00000124 00000+0 79520-4 0 9999
2 43013 98.7658 338.3064 0000377 14.6433 345.4754 14.19527655425942
GOES 17
1 43226U 18022A 26037.60794939 -.00000180 00000+0 00000+0 0 9993
2 43226 0.6016 88.1527 0002754 213.0089 324.8756 1.00269924 29115
FENGYUN 2H
1 43491U 18050A 26037.66161282 -.00000125 00000+0 00000+0 0 9992
2 43491 2.6948 80.6967 0002145 171.8276 201.3055 1.00274855 28134
METOP-C
1 43689U 18087A 26037.63948662 .00000167 00000+0 96262-4 0 9998
2 43689 98.6834 99.5280 0001629 143.8933 216.2355 14.21510040376280
GEO-KOMPSAT-2A
1 43823U 18100A 26037.57995591 .00000000 00000+0 00000+0 0 9996
2 43823 0.0152 95.1913 0001141 313.4173 65.1318 1.00271011 26327
METEOR-M2 2
1 44387U 19038A 26037.58492015 .00000244 00000+0 12531-3 0 9993
2 44387 98.9044 23.0180 0002141 55.2566 304.8814 14.24320728342700
ARKTIKA-M 1
1 47719U 21016A 26035.90384421 -.00000136 00000+0 00000+0 0 9994
2 47719 63.1930 76.4940 7230705 269.3476 15.2984 2.00623094 36131
FENGYUN 3E
1 49008U 21062A 26037.62586080 .00000245 00000+0 13631-3 0 9992
2 49008 98.7499 42.4910 0002627 96.2819 263.8657 14.19890127238058
GOES 18
1 51850U 22021A 26037.59876267 .00000098 00000+0 00000+0 0 9999
2 51850 0.0198 91.3546 0000843 290.2366 193.6737 1.00273310 5288
NOAA 21 (JPSS-2)
1 54234U 22150A 26037.56792604 .00000152 00000+0 92800-4 0 9995
2 54234 98.7521 338.1972 0001388 169.8161 190.3044 14.19543641168012
METEOSAT-12 (MTG-I1)
1 54743U 22170C 26037.62580281 -.00000006 00000+0 00000+0 0 9990
2 54743 0.7119 25.1556 0002027 273.4388 63.0828 1.00270670 11667
TIANMU-1 03
1 55973U 23039A 26037.63298084 .00025307 00000+0 57478-3 0 9994
2 55973 97.5143 206.9374 0002852 198.5193 161.5950 15.43014921160671
TIANMU-1 04
1 55974U 23039B 26037.59957323 .00027172 00000+0 60888-3 0 9999
2 55974 97.5075 206.0729 0003605 196.0743 164.0390 15.43399931160675
TIANMU-1 05
1 55975U 23039C 26037.60840428 .00024975 00000+0 56836-3 0 9995
2 55975 97.5122 206.5750 0002421 224.3240 135.7814 15.42959696160653
TIANMU-1 06
1 55976U 23039D 26037.60004198 .00024821 00000+0 55598-3 0 9996
2 55976 97.5133 207.0788 0002810 218.0193 142.0857 15.43432906160673
FENGYUN 3G
1 56232U 23055A 26037.30935013 .00046475 00000+0 74423-3 0 9993
2 56232 49.9940 300.8928 0009962 237.3703 122.6303 15.52544991159665
METEOR-M2 3
1 57166U 23091A 26037.62090481 .00000022 00000+0 28455-4 0 9999
2 57166 98.6282 95.1607 0004003 174.5474 185.5750 14.24034408135931
TIANMU-1 07
1 57399U 23101A 26037.63242936 .00011510 00000+0 41012-3 0 9991
2 57399 97.2786 91.2606 0002747 218.4597 141.6448 15.29074661141694
TIANMU-1 08
1 57400U 23101B 26037.66743594 .00011474 00000+0 41016-3 0 9996
2 57400 97.2774 91.0783 0004440 227.8102 132.2762 15.28966110141699
TIANMU-1 09
1 57401U 23101C 26037.65072558 .00011360 00000+0 40433-3 0 9997
2 57401 97.2732 90.5514 0003773 229.5297 130.5615 15.29113177141698
TIANMU-1 10
1 57402U 23101D 26037.61974057 .00011836 00000+0 42113-3 0 9994
2 57402 97.2810 91.4302 0005461 233.7620 126.3116 15.29106286141685
FENGYUN 3F
1 57490U 23111A 26037.61228373 .00000135 00000+0 84019-4 0 9997
2 57490 98.6988 109.9815 0001494 99.6638 260.4707 14.19912110130332
ARKTIKA-M 2
1 58584U 23198A 26037.15964049 .00000160 00000+0 00000+0 0 9994
2 58584 63.2225 168.8508 6872222 267.8808 18.8364 2.00612776 15698
TIANMU-1 11
1 58645U 23205A 26037.58628093 .00009545 00000+0 37951-3 0 9999
2 58645 97.3574 61.2485 0010997 103.8713 256.3749 15.25445149117601
TIANMU-1 12
1 58646U 23205B 26037.61705312 .00010066 00000+0 40129-3 0 9995
2 58646 97.3561 61.0663 0009308 89.8253 270.4052 15.25355570117590
TIANMU-1 13
1 58647U 23205C 26037.64894829 .00010029 00000+0 39925-3 0 9992
2 58647 97.3589 61.3229 0009456 74.8265 285.4018 15.25403883117592
TIANMU-1 14
1 58648U 23205D 26037.63305929 .00009719 00000+0 38718-3 0 9993
2 58648 97.3523 60.6045 0010314 77.9995 282.2399 15.25381326117592
TIANMU-1 19
1 58660U 23208A 26037.58812600 .00016491 00000+0 58449-3 0 9991
2 58660 97.4377 153.5627 0006125 66.0574 294.1307 15.29155961117352
TIANMU-1 20
1 58661U 23208B 26037.59661536 .00016638 00000+0 56823-3 0 9990
2 58661 97.4315 154.0738 0008420 72.4906 287.7255 15.30347593117439
TIANMU-1 21
1 58662U 23208C 26037.56944589 .00017161 00000+0 55253-3 0 9998
2 58662 97.4367 156.2063 0008160 67.8039 292.4068 15.32247056117540
TIANMU-1 22
1 58663U 23208D 26037.59847459 .00015396 00000+0 55169-3 0 9994
2 58663 97.4371 153.6033 0005010 87.2275 272.9538 15.28818503117364
TIANMU-1 15
1 58700U 24004A 26037.63062994 .00009739 00000+0 38850-3 0 9991
2 58700 97.4651 223.9243 0008449 88.7599 271.4607 15.25356935115862
TIANMU-1 16
1 58701U 24004B 26037.61474986 .00010691 00000+0 42590-3 0 9993
2 58701 97.4590 223.2544 0006831 91.0928 269.1093 15.25387104115863
TIANMU-1 17
1 58702U 24004C 26037.59783649 .00011079 00000+0 44078-3 0 9994
2 58702 97.4624 223.6760 0006020 92.0871 268.1056 15.25425175115852
TIANMU-1 18
1 58703U 24004D 26037.64767373 .00010786 00000+0 42976-3 0 9996
2 58703 97.4642 223.9320 0005432 91.0134 269.1726 15.25387870115860
INSAT-3DS
1 58990U 24033A 26037.64159978 -.00000153 00000+0 00000+0 0 9998
2 58990 0.0277 242.2492 0001855 99.2205 108.3003 1.00271452 45758
METEOR-M2 4
1 59051U 24039A 26037.62796654 .00000070 00000+0 51194-4 0 9991
2 59051 98.6849 358.6843 0006923 178.9165 181.2029 14.22412185100701
GOES 19
1 60133U 24119A 26037.61098274 -.00000246 00000+0 00000+0 0 9996
2 60133 0.0027 288.6290 0001204 74.2636 278.5881 1.00270967 5651
FENGYUN 3H
1 65815U 25219A 26037.60879211 .00000151 00000+0 91464-4 0 9990
2 65815 98.6649 341.0050 0001596 86.5100 273.6260 14.19924132 18857
+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:
+59
View File
@@ -0,0 +1,59 @@
# =============================================================================
# INTERCEPT AGENT CONFIGURATION
# =============================================================================
# This file configures the Intercept remote agent.
# Copy this file and customize for your deployment.
[agent]
# Agent name (used to identify this node in the controller)
# Default: system hostname
name = sensor-node-1
# HTTP server port
# Default: 8020
port = 8020
# Comma-separated list of allowed client IPs (empty = allow all)
# Example: 192.168.1.100, 192.168.1.101, 10.0.0.0/8
allowed_ips =
# Enable CORS headers for browser-based clients
# Default: false
allow_cors = false
[controller]
# Controller URL for push mode
# Example: http://192.168.1.100:5050
url =
# API key for controller authentication (shared secret)
api_key =
# Enable automatic push of scan data to controller
# Default: false
push_enabled = false
# Push interval in seconds (minimum time between pushes)
# Default: 5
push_interval = 5
[modes]
# Enable/disable specific modes on this agent
# Set to false to disable a mode even if tools are available
# Default: all true
pager = true
sensor = true
adsb = true
ais = true
acars = true
aprs = true
wifi = true
bluetooth = true
dsc = true
rtlamr = true
tscm = true
satellite = true
listening_post = true
+4149
View File
File diff suppressed because it is too large Load Diff
+898
View File
@@ -0,0 +1,898 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>iNTERCEPT Promo</title>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--cyan: #00d4ff;
--green: #00ff88;
--red: #ff3366;
--purple: #a855f7;
--orange: #ff9500;
--bg: #0a0a0f;
--bg-secondary: #12121a;
}
html, body {
width: 100%;
height: 100%;
background: #000;
overflow: hidden;
}
body {
display: flex;
align-items: center;
justify-content: center;
font-family: 'Inter', sans-serif;
}
/* Container maintains 9:16 aspect ratio and scales to fit */
.video-frame {
position: relative;
width: min(100vw, calc(100vh * 9 / 16));
height: min(100vh, calc(100vw * 16 / 9));
max-width: 1080px;
max-height: 1920px;
background: var(--bg);
color: #fff;
overflow: hidden;
/* Scale font size based on container width */
font-size: min(16px, calc(100vw * 16 / 1080));
}
/* Animated background grid */
.grid-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image:
linear-gradient(rgba(0, 212, 255, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 212, 255, 0.03) 1px, transparent 1px);
background-size: 30px 30px;
animation: gridMove 20s linear infinite;
}
@keyframes gridMove {
0% { transform: translate(0, 0); }
100% { transform: translate(30px, 30px); }
}
/* Scanning line effect */
.scanline {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 2px;
background: linear-gradient(90deg, transparent, var(--cyan), transparent);
animation: scan 3s linear infinite;
opacity: 0.7;
z-index: 100;
}
@keyframes scan {
0% { top: 0; }
100% { top: 100%; }
}
/* Glowing orbs background */
.orb {
position: absolute;
border-radius: 50%;
filter: blur(50px);
opacity: 0.25;
animation: orbFloat 8s ease-in-out infinite;
}
.orb-1 {
width: 200px;
height: 200px;
background: var(--cyan);
top: 10%;
left: -10%;
animation-delay: 0s;
}
.orb-2 {
width: 150px;
height: 150px;
background: var(--purple);
bottom: 20%;
right: -5%;
animation-delay: 2s;
}
.orb-3 {
width: 120px;
height: 120px;
background: var(--green);
bottom: 40%;
left: 20%;
animation-delay: 4s;
}
@keyframes orbFloat {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(30px, -30px) scale(1.1); }
}
/* Main content container */
.container {
position: relative;
z-index: 10;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
}
/* Scene management */
.scene {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
opacity: 0;
visibility: hidden;
transition: opacity 0.8s ease, visibility 0.8s ease;
}
.scene.active {
opacity: 1;
visibility: visible;
}
/* Scene 1: Logo reveal */
.logo-container {
text-align: center;
}
.logo-svg {
width: 140px;
height: 140px;
margin-bottom: 20px;
filter: drop-shadow(0 0 40px rgba(0, 212, 255, 0.5));
}
.logo-svg .signal-wave {
opacity: 0;
animation: signalReveal 0.5s ease forwards;
}
.logo-svg .signal-wave-1 { animation-delay: 0.5s; }
.logo-svg .signal-wave-2 { animation-delay: 0.7s; }
.logo-svg .signal-wave-3 { animation-delay: 0.9s; }
.logo-svg .signal-wave-4 { animation-delay: 0.5s; }
.logo-svg .signal-wave-5 { animation-delay: 0.7s; }
.logo-svg .signal-wave-6 { animation-delay: 0.9s; }
@keyframes signalReveal {
0% { opacity: 0; transform: scale(0.8); }
100% { opacity: 1; transform: scale(1); }
}
.logo-svg .logo-i {
opacity: 0;
animation: logoReveal 0.8s ease forwards 0.2s;
}
@keyframes logoReveal {
0% { opacity: 0; transform: translateY(20px); }
100% { opacity: 1; transform: translateY(0); }
}
.logo-svg .logo-dot {
animation: dotPulse 1.5s ease-in-out infinite 1s;
}
@keyframes dotPulse {
0%, 100% { filter: drop-shadow(0 0 5px rgba(0, 255, 136, 0.5)); }
50% { filter: drop-shadow(0 0 25px rgba(0, 255, 136, 1)); }
}
.title {
font-family: 'JetBrains Mono', monospace;
font-size: 42px;
font-weight: 700;
letter-spacing: 0.15em;
margin-bottom: 10px;
opacity: 0;
animation: titleReveal 1s ease forwards 1.2s;
}
@keyframes titleReveal {
0% { opacity: 0; transform: translateY(20px); letter-spacing: 0.3em; }
100% { opacity: 1; transform: translateY(0); letter-spacing: 0.15em; }
}
.tagline {
font-family: 'JetBrains Mono', monospace;
font-size: 18px;
color: var(--cyan);
letter-spacing: 0.1em;
opacity: 0;
animation: taglineReveal 0.8s ease forwards 1.8s;
}
@keyframes taglineReveal {
0% { opacity: 0; }
100% { opacity: 1; }
}
.subtitle {
font-size: 12px;
color: #888;
margin-top: 15px;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0;
animation: subtitleReveal 0.8s ease forwards 2.2s;
}
@keyframes subtitleReveal {
0% { opacity: 0; transform: translateY(20px); }
100% { opacity: 1; transform: translateY(0); }
}
/* Scene 2: Features */
.features-scene {
text-align: center;
}
.feature-title {
font-family: 'JetBrains Mono', monospace;
font-size: 24px;
color: var(--cyan);
margin-bottom: 30px;
text-transform: uppercase;
letter-spacing: 0.1em;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 15px;
width: 100%;
max-width: 100%;
}
.feature-card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(0, 212, 255, 0.2);
border-radius: 12px;
padding: 15px;
text-align: center;
opacity: 0;
transform: translateY(20px);
animation: featureReveal 0.6s ease forwards;
}
.feature-card:nth-child(1) { animation-delay: 0.2s; }
.feature-card:nth-child(2) { animation-delay: 0.4s; }
.feature-card:nth-child(3) { animation-delay: 0.6s; }
.feature-card:nth-child(4) { animation-delay: 0.8s; }
@keyframes featureReveal {
0% { opacity: 0; transform: translateY(20px); }
100% { opacity: 1; transform: translateY(0); }
}
.feature-icon {
font-size: 36px;
margin-bottom: 8px;
}
.feature-name {
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
font-weight: 600;
margin-bottom: 4px;
}
.feature-desc {
font-size: 11px;
color: #888;
}
/* Scene 3: Modes showcase */
.modes-scene {
text-align: center;
}
.mode-showcase {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.mode-item {
display: flex;
align-items: center;
gap: 12px;
background: rgba(255, 255, 255, 0.02);
border-left: 3px solid var(--cyan);
padding: 10px 15px;
border-radius: 0 8px 8px 0;
opacity: 0;
transform: translateX(-30px);
animation: modeSlide 0.5s ease forwards;
}
.mode-item:nth-child(1) { animation-delay: 0.1s; border-color: var(--cyan); }
.mode-item:nth-child(2) { animation-delay: 0.2s; border-color: var(--green); }
.mode-item:nth-child(3) { animation-delay: 0.3s; border-color: var(--purple); }
.mode-item:nth-child(4) { animation-delay: 0.4s; border-color: var(--orange); }
.mode-item:nth-child(5) { animation-delay: 0.5s; border-color: var(--red); }
.mode-item:nth-child(6) { animation-delay: 0.6s; border-color: #00ffcc; }
.mode-item:nth-child(7) { animation-delay: 0.7s; border-color: #ff66cc; }
@keyframes modeSlide {
0% { opacity: 0; transform: translateX(-30px); }
100% { opacity: 1; transform: translateX(0); }
}
.mode-icon {
font-size: 22px;
width: 35px;
flex-shrink: 0;
}
.mode-info {
text-align: left;
}
.mode-name {
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
font-weight: 600;
margin-bottom: 2px;
}
.mode-desc {
font-size: 10px;
color: #666;
}
/* Scene 4: UI Preview */
.ui-scene {
text-align: center;
}
.ui-preview {
width: 100%;
max-width: 100%;
background: var(--bg-secondary);
border-radius: 12px;
border: 1px solid rgba(0, 212, 255, 0.3);
overflow: hidden;
box-shadow: 0 0 60px rgba(0, 212, 255, 0.2);
}
.ui-header {
background: rgba(0, 0, 0, 0.5);
padding: 10px 15px;
display: flex;
align-items: center;
gap: 10px;
border-bottom: 1px solid rgba(0, 212, 255, 0.2);
}
.ui-logo-small {
width: 24px;
height: 24px;
}
.ui-title {
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
font-weight: 600;
}
.ui-body {
padding: 12px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.ui-card {
background: rgba(0, 0, 0, 0.3);
border-radius: 8px;
padding: 10px;
border: 1px solid rgba(255, 255, 255, 0.05);
}
.ui-card-header {
font-size: 8px;
color: var(--cyan);
text-transform: uppercase;
letter-spacing: 0.1em;
margin-bottom: 6px;
}
.ui-stat {
font-family: 'JetBrains Mono', monospace;
font-size: 22px;
font-weight: 700;
color: var(--green);
}
.ui-stat.cyan { color: var(--cyan); }
.ui-stat.orange { color: var(--orange); }
.ui-console {
grid-column: span 3;
background: rgba(0, 0, 0, 0.5);
border-radius: 8px;
padding: 10px;
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
text-align: left;
border: 1px solid rgba(0, 212, 255, 0.1);
}
.console-line {
margin-bottom: 4px;
opacity: 0;
animation: consoleLine 0.3s ease forwards;
}
.console-line:nth-child(1) { animation-delay: 0.5s; }
.console-line:nth-child(2) { animation-delay: 0.8s; }
.console-line:nth-child(3) { animation-delay: 1.1s; }
.console-line:nth-child(4) { animation-delay: 1.4s; }
.console-line:nth-child(5) { animation-delay: 1.7s; }
@keyframes consoleLine {
0% { opacity: 0; transform: translateX(-10px); }
100% { opacity: 1; transform: translateX(0); }
}
.console-time { color: #666; }
.console-type { color: var(--cyan); }
.console-msg { color: var(--green); }
.console-freq { color: var(--orange); }
/* Scene 5: CTA */
.cta-scene {
text-align: center;
}
.cta-logo {
width: 100px;
height: 100px;
margin-bottom: 20px;
animation: ctaLogoPulse 2s ease-in-out infinite;
}
@keyframes ctaLogoPulse {
0%, 100% { filter: drop-shadow(0 0 20px rgba(0, 212, 255, 0.5)); transform: scale(1); }
50% { filter: drop-shadow(0 0 40px rgba(0, 212, 255, 0.8)); transform: scale(1.05); }
}
.cta-title {
font-family: 'JetBrains Mono', monospace;
font-size: 36px;
font-weight: 700;
letter-spacing: 0.1em;
margin-bottom: 12px;
}
.cta-tagline {
font-size: 18px;
color: var(--cyan);
margin-bottom: 30px;
}
.cta-btn {
display: inline-block;
padding: 12px 30px;
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
font-weight: 600;
color: #000;
background: var(--cyan);
border-radius: 30px;
text-transform: uppercase;
letter-spacing: 0.1em;
animation: ctaBtnPulse 1.5s ease-in-out infinite;
}
@keyframes ctaBtnPulse {
0%, 100% { box-shadow: 0 0 20px rgba(0, 212, 255, 0.5); }
50% { box-shadow: 0 0 40px rgba(0, 212, 255, 0.8); }
}
.cta-url {
margin-top: 20px;
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
color: #666;
}
/* Typing cursor effect */
.typing-cursor {
display: inline-block;
width: 3px;
height: 1em;
background: var(--cyan);
margin-left: 5px;
animation: blink 0.8s infinite;
}
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
/* Progress bar */
.progress-bar {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 8px;
z-index: 1000;
}
.progress-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
transition: all 0.3s ease;
}
.progress-dot.active {
background: var(--cyan);
box-shadow: 0 0 10px var(--cyan);
}
/* Decorative elements */
.corner-decoration {
position: absolute;
width: 40px;
height: 40px;
border: 1px solid rgba(0, 212, 255, 0.3);
}
.corner-tl {
top: 15px;
left: 15px;
border-right: none;
border-bottom: none;
}
.corner-tr {
top: 15px;
right: 15px;
border-left: none;
border-bottom: none;
}
.corner-bl {
bottom: 50px;
left: 15px;
border-right: none;
border-top: none;
}
.corner-br {
bottom: 50px;
right: 15px;
border-left: none;
border-top: none;
}
</style>
</head>
<body>
<div class="video-frame">
<!-- Background elements -->
<div class="grid-bg"></div>
<div class="scanline"></div>
<div class="orb orb-1"></div>
<div class="orb orb-2"></div>
<div class="orb orb-3"></div>
<!-- Corner decorations -->
<div class="corner-decoration corner-tl"></div>
<div class="corner-decoration corner-tr"></div>
<div class="corner-decoration corner-bl"></div>
<div class="corner-decoration corner-br"></div>
<!-- Scene 1: Logo Reveal -->
<div class="scene active" id="scene1">
<div class="logo-container">
<svg class="logo-svg" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Signal brackets - left side -->
<path class="signal-wave signal-wave-1" d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
<path class="signal-wave signal-wave-2" d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
<path class="signal-wave signal-wave-3" d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
<!-- Signal brackets - right side -->
<path class="signal-wave signal-wave-4" d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
<path class="signal-wave signal-wave-5" d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
<path class="signal-wave signal-wave-6" d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
<!-- The 'i' letter -->
<g class="logo-i">
<circle class="logo-dot" cx="50" cy="22" r="6" fill="#00ff88"/>
<rect x="44" y="35" width="12" height="45" rx="2" fill="#00d4ff"/>
<rect x="38" y="35" width="24" height="4" rx="1" fill="#00d4ff"/>
<rect x="38" y="76" width="24" height="4" rx="1" fill="#00d4ff"/>
</g>
</svg>
<h1 class="title">iNTERCEPT</h1>
<p class="tagline">// See the Invisible</p>
<p class="subtitle">Signal Intelligence & Counter Surveillance</p>
</div>
</div>
<!-- Scene 2: Features Grid -->
<div class="scene" id="scene2">
<div class="features-scene">
<h2 class="feature-title">Capabilities</h2>
<div class="feature-grid">
<div class="feature-card">
<div class="feature-icon">📡</div>
<div class="feature-name">SDR Scanning</div>
<div class="feature-desc">Multi-band reception</div>
</div>
<div class="feature-card">
<div class="feature-icon">🔐</div>
<div class="feature-name">Decryption</div>
<div class="feature-desc">Signal analysis</div>
</div>
<div class="feature-card">
<div class="feature-icon">🛰️</div>
<div class="feature-name">Tracking</div>
<div class="feature-desc">Real-time monitoring</div>
</div>
<div class="feature-card">
<div class="feature-icon">🔍</div>
<div class="feature-name">Detection</div>
<div class="feature-desc">Counter surveillance</div>
</div>
</div>
</div>
</div>
<!-- Scene 3: Modes List -->
<div class="scene" id="scene3">
<div class="modes-scene">
<div class="mode-showcase">
<div class="mode-item">
<div class="mode-icon">📟</div>
<div class="mode-info">
<div class="mode-name">PAGER</div>
<div class="mode-desc">POCSAG & FLEX decoding</div>
</div>
</div>
<div class="mode-item">
<div class="mode-icon">✈️</div>
<div class="mode-info">
<div class="mode-name">ADS-B</div>
<div class="mode-desc">Aircraft tracking</div>
</div>
</div>
<div class="mode-item">
<div class="mode-icon">📻</div>
<div class="mode-info">
<div class="mode-name">LISTENING POST</div>
<div class="mode-desc">RF monitoring & scanning</div>
</div>
</div>
<div class="mode-item">
<div class="mode-icon">📶</div>
<div class="mode-info">
<div class="mode-name">WiFi</div>
<div class="mode-desc">Network reconnaissance</div>
</div>
</div>
<div class="mode-item">
<div class="mode-icon">🔵</div>
<div class="mode-info">
<div class="mode-name">BLUETOOTH</div>
<div class="mode-desc">Device & tracker detection</div>
</div>
</div>
<div class="mode-item">
<div class="mode-icon">🌡️</div>
<div class="mode-info">
<div class="mode-name">SENSORS</div>
<div class="mode-desc">433MHz IoT decoding</div>
</div>
</div>
<div class="mode-item">
<div class="mode-icon">🛰️</div>
<div class="mode-info">
<div class="mode-name">SATELLITE</div>
<div class="mode-desc">Pass prediction & tracking</div>
</div>
</div>
</div>
</div>
</div>
<!-- Scene 4: UI Preview -->
<div class="scene" id="scene4">
<div class="ui-scene">
<div class="ui-preview">
<div class="ui-header">
<svg class="ui-logo-small" viewBox="0 0 100 100" fill="none">
<path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
<path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
<path d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
<path d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
<path d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
<path d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
<circle cx="50" cy="22" r="6" fill="#00ff88"/>
<rect x="44" y="35" width="12" height="45" rx="2" fill="#00d4ff"/>
<rect x="38" y="35" width="24" height="4" rx="1" fill="#00d4ff"/>
<rect x="38" y="76" width="24" height="4" rx="1" fill="#00d4ff"/>
</svg>
<span class="ui-title">iNTERCEPT</span>
</div>
<div class="ui-body">
<div class="ui-card">
<div class="ui-card-header">Messages</div>
<div class="ui-stat">2,847</div>
</div>
<div class="ui-card">
<div class="ui-card-header">Aircraft</div>
<div class="ui-stat cyan">42</div>
</div>
<div class="ui-card">
<div class="ui-card-header">Devices</div>
<div class="ui-stat orange">156</div>
</div>
<div class="ui-console">
<div class="console-line">
<span class="console-time">[14:32:07]</span>
<span class="console-type"> POCSAG </span>
<span class="console-msg">Signal intercepted</span>
<span class="console-freq"> 153.350 MHz</span>
</div>
<div class="console-line">
<span class="console-time">[14:32:09]</span>
<span class="console-type"> ADS-B </span>
<span class="console-msg">Aircraft detected: BA284</span>
<span class="console-freq"> FL350</span>
</div>
<div class="console-line">
<span class="console-time">[14:32:11]</span>
<span class="console-type"> BT </span>
<span class="console-msg">AirTag detected nearby</span>
<span class="console-freq"> -42 dBm</span>
</div>
<div class="console-line">
<span class="console-time">[14:32:14]</span>
<span class="console-type"> SENSOR </span>
<span class="console-msg">Temperature: 22.4C</span>
<span class="console-freq"> 433.92 MHz</span>
</div>
<div class="console-line">
<span class="console-time">[14:32:16]</span>
<span class="console-type"> SCAN </span>
<span class="console-msg">Signal found</span>
<span class="console-freq"> 145.500 MHz</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Scene 5: CTA -->
<div class="scene" id="scene5">
<div class="cta-scene">
<svg class="cta-logo" viewBox="0 0 100 100" fill="none">
<path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
<path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
<path d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
<path d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
<path d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
<path d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
<circle cx="50" cy="22" r="6" fill="#00ff88"/>
<rect x="44" y="35" width="12" height="45" rx="2" fill="#00d4ff"/>
<rect x="38" y="35" width="24" height="4" rx="1" fill="#00d4ff"/>
<rect x="38" y="76" width="24" height="4" rx="1" fill="#00d4ff"/>
</svg>
<h2 class="cta-title">iNTERCEPT</h2>
<p class="cta-tagline">See the Invisible</p>
<div class="cta-btn">Open Source</div>
<p class="cta-url">github.com/yourrepo/intercept</p>
</div>
</div>
<!-- Progress dots -->
<div class="progress-bar">
<div class="progress-dot active" data-scene="1"></div>
<div class="progress-dot" data-scene="2"></div>
<div class="progress-dot" data-scene="3"></div>
<div class="progress-dot" data-scene="4"></div>
<div class="progress-dot" data-scene="5"></div>
</div>
</div><!-- end video-frame -->
<script>
// Scene timing (in milliseconds)
const sceneTiming = [
{ scene: 1, duration: 4000 }, // Logo reveal
{ scene: 2, duration: 4000 }, // Features
{ scene: 3, duration: 5000 }, // Modes
{ scene: 4, duration: 5000 }, // UI Preview
{ scene: 5, duration: 4000 }, // CTA
];
let currentScene = 0;
function showScene(index) {
// Hide all scenes
document.querySelectorAll('.scene').forEach(s => s.classList.remove('active'));
document.querySelectorAll('.progress-dot').forEach(d => d.classList.remove('active'));
// Show current scene
const scene = document.getElementById(`scene${index + 1}`);
if (scene) {
scene.classList.add('active');
document.querySelector(`.progress-dot[data-scene="${index + 1}"]`).classList.add('active');
}
}
function nextScene() {
currentScene++;
if (currentScene >= sceneTiming.length) {
currentScene = 0; // Loop back to start
}
showScene(currentScene);
setTimeout(nextScene, sceneTiming[currentScene].duration);
}
// Start the animation sequence
setTimeout(nextScene, sceneTiming[0].duration);
// Keyboard controls for manual navigation
document.addEventListener('keydown', (e) => {
if (e.key === 'ArrowRight') {
currentScene = (currentScene + 1) % sceneTiming.length;
showScene(currentScene);
} else if (e.key === 'ArrowLeft') {
currentScene = (currentScene - 1 + sceneTiming.length) % sceneTiming.length;
showScene(currentScene);
} else if (e.key === ' ') {
// Spacebar to pause/resume could be added here
}
});
// Click on progress dots to jump to scene
document.querySelectorAll('.progress-dot').forEach(dot => {
dot.addEventListener('click', () => {
currentScene = parseInt(dot.dataset.scene) - 1;
showScene(currentScene);
});
});
</script>
</body>
</html>
+45 -4
View File
@@ -1,10 +1,10 @@
[project]
name = "intercept"
version = "1.2.0"
version = "2.26.1"
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
readme = "README.md"
requires-python = ">=3.9"
license = {text = "MIT"}
license = {text = "Apache-2.0"}
authors = [
{name = "Intercept Contributors"}
]
@@ -14,7 +14,7 @@ classifiers = [
"Environment :: Web Environment",
"Framework :: Flask",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"License :: OSI Approved :: Apache Software License",
"Operating System :: POSIX :: Linux",
"Operating System :: MacOS",
"Programming Language :: Python :: 3",
@@ -26,9 +26,15 @@ classifiers = [
"Topic :: System :: Networking :: Monitoring",
]
dependencies = [
"flask>=2.0.0",
"flask>=3.0.0",
"skyfield>=1.45",
"pyserial>=3.5",
"Werkzeug>=3.1.5",
"flask-limiter>=2.5.4",
"bleak>=0.21.0",
"flask-sock",
"websocket-client>=1.6.0",
"requests>=2.28.0",
]
[project.urls]
@@ -40,12 +46,23 @@ Issues = "https://github.com/smittix/intercept/issues"
dev = [
"pytest>=7.0.0",
"pytest-cov>=4.0.0",
"pytest-mock>=3.15.1",
"ruff>=0.1.0",
"black>=23.0.0",
"mypy>=1.0.0",
"types-flask>=1.1.0",
]
optionals = [
"scipy>=1.10.0",
"qrcode[pil]>=7.4",
"numpy>=1.24.0",
"Pillow>=9.0.0",
"meshtastic>=2.0.0",
"psycopg2-binary>=2.9.9",
"scapy>=2.4.5",
]
[project.scripts]
intercept = "intercept:main"
@@ -76,8 +93,32 @@ ignore = [
"B008", # do not perform function calls in argument defaults
"B905", # zip without explicit strict
"SIM108", # use ternary operator instead of if-else
"SIM102", # collapsible if statements
"SIM105", # use contextlib.suppress (stylistic, not a bug)
"SIM115", # use context manager for open (not always applicable)
"SIM116", # use dict instead of if/elif chain (stylistic)
"SIM117", # combine nested with statements (stylistic)
"E402", # module-level import not at top (needed for conditional imports)
"E741", # ambiguous variable name
"E721", # type comparison (use isinstance)
"E722", # bare except
"B904", # raise from within except (stylistic)
"B007", # unused loop variable (use _ prefix)
"B023", # function definition doesn't bind loop variable
"F601", # membership test with duplicate items
"F821", # undefined name (too many false positives with conditional imports)
"UP035", # deprecated typing imports
]
[tool.ruff.lint.per-file-ignores]
"__init__.py" = ["F401"] # re-exports in __init__.py are intentional
"utils/bluetooth/capability_check.py" = ["F401"] # imports used for availability checking
"utils/bluetooth/fallback_scanner.py" = ["F401"] # imports used for availability checking
"utils/tscm/ble_scanner.py" = ["F401"] # imports used for availability checking
"utils/wifi/deauth_detector.py" = ["F401"] # imports used for availability checking
"routes/dsc.py" = ["F401"] # imports used for availability checking
"intercept_agent.py" = ["F401"] # conditional imports
[tool.ruff.lint.isort]
known-first-party = ["app", "config", "routes", "utils", "data"]
+1
View File
@@ -4,6 +4,7 @@
# Testing
pytest>=7.0.0
pytest-cov>=4.0.0
pytest-mock>=3.15.1
# Code quality
ruff>=0.1.0
+41 -1
View File
@@ -1,15 +1,55 @@
# Core dependencies
flask>=2.0.0
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
# ADS-B history (optional - only needed for Postgres persistence)
psycopg2-binary>=2.9.9
# BLE scanning with manufacturer data detection (optional - for TSCM)
bleak>=0.21.0
# Satellite tracking (optional - only needed for satellite features)
skyfield>=1.45
# DSC decoding and SSTV decoding (DSP pipeline)
scipy>=1.10.0
numpy>=1.24.0
# SSTV image output (optional - needed for SSTV image decoding)
Pillow>=9.0.0
# GPS dongle support (optional - only needed for USB GPS receivers)
pyserial>=3.5
# Meshtastic mesh network support (optional - only needed for Meshtastic features)
meshtastic>=2.0.0
# Deauthentication attack detection (optional - for WiFi TSCM)
scapy>=2.4.5
# QR code generation for Meshtastic channels (optional)
qrcode[pil]>=7.4
# BLE RPA resolution for BT Locate (optional - for SAR device tracking)
cryptography>=41.0.0
# Development dependencies (install with: pip install -r requirements-dev.txt)
# pytest>=7.0.0
# pytest-cov>=4.0.0
# ruff>=0.1.0
# black>=23.0.0
# mypy>=1.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
+87 -5
View File
@@ -1,19 +1,101 @@
# 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 .wifi import wifi_bp
from .bluetooth import bluetooth_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 .satellite import satellite_bp
from .ais import ais_bp
from .alerts import alerts_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)
app.register_blueprint(rtlamr_bp)
app.register_blueprint(wifi_bp)
app.register_blueprint(wifi_v2_bp) # New unified WiFi API
app.register_blueprint(bluetooth_bp)
app.register_blueprint(bluetooth_v2_bp) # New unified Bluetooth API
app.register_blueprint(adsb_bp)
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(receiver_bp)
app.register_blueprint(meshtastic_bp)
app.register_blueprint(tscm_bp)
app.register_blueprint(spy_stations_bp)
app.register_blueprint(controller_bp) # Remote agent controller
app.register_blueprint(offline_bp) # Offline mode settings
app.register_blueprint(updater_bp) # GitHub update checking
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(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
if hasattr(app_module, 'tscm_queue') and hasattr(app_module, 'tscm_lock'):
init_tscm_state(app_module.tscm_queue, app_module.tscm_lock)
+470
View File
@@ -0,0 +1,470 @@
"""ACARS aircraft messaging 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
acars_bp = Blueprint('acars', __name__, url_prefix='/acars')
# Default VHF ACARS frequencies (MHz) - North America primary
DEFAULT_ACARS_FREQUENCIES = [
'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
acars_message_count = 0
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():
"""Find acarsdec binary."""
return shutil.which('acarsdec')
def get_acarsdec_json_flag(acarsdec_path: str) -> str:
"""Detect which JSON output flag acarsdec supports.
Different forks use different flags:
- TLeconte v4.0+: uses -j for JSON stdout
- TLeconte v3.x: uses -o 4 for JSON stdout
- f00b4r0 fork (DragonOS): uses --output json:file:- for JSON stdout
"""
try:
# Get help/version by running acarsdec with no args (shows usage)
result = subprocess.run(
[acarsdec_path],
capture_output=True,
text=True,
timeout=5
)
output = result.stdout + result.stderr
import re
# Check for f00b4r0 fork signature: uses --output instead of -j/-o
# f00b4r0's help shows "--output" for output configuration
if '--output' in output or 'json:file:' in output.lower():
logger.debug("Detected f00b4r0 acarsdec fork (--output syntax)")
return '--output'
# Parse version from output like "Acarsdec v4.3.1" or "Acarsdec/acarsserv 3.7"
version_match = re.search(r'acarsdec[^\d]*v?(\d+)\.(\d+)', output, re.IGNORECASE)
if version_match:
major = int(version_match.group(1))
# Version 4.0+ uses -j for JSON stdout
if major >= 4:
return '-j'
# Version 3.x uses -o for output mode
else:
return '-o'
except Exception as e:
logger.debug(f"Could not detect acarsdec version: {e}")
# Default to -j (TLeconte modern standard)
return '-j'
def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -> None:
"""Stream acarsdec JSON output to queue."""
global acars_message_count, acars_last_message_time
try:
app_module.acars_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:
# acarsdec -o 4 outputs JSON, one message per line
data = json.loads(line)
# Add our metadata
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:
with open(app_module.log_file_path, 'a') as f:
ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
f.write(f"{ts} | ACARS | {json.dumps(data)}\n")
except Exception:
pass
except json.JSONDecodeError:
# Not JSON - could be status message
if line:
logger.debug(f"acarsdec non-JSON: {line[:100]}")
except Exception as e:
logger.error(f"ACARS stream error: {e}")
app_module.acars_queue.put({'type': 'error', 'message': str(e)})
finally:
global acars_active_device, acars_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.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, acars_active_sdr_type or 'rtlsdr')
acars_active_device = None
acars_active_sdr_type = None
@acars_bp.route('/tools')
def check_acars_tools() -> Response:
"""Check for ACARS decoding tools."""
has_acarsdec = find_acarsdec() is not None
return jsonify({
'acarsdec': has_acarsdec,
'ready': has_acarsdec
})
@acars_bp.route('/status')
def acars_status() -> Response:
"""Get ACARS decoder status."""
running = False
if app_module.acars_process:
running = app_module.acars_process.poll() is None
return jsonify({
'running': running,
'message_count': acars_message_count,
'last_message_time': acars_last_message_time,
'queue_size': app_module.acars_queue.qsize()
})
@acars_bp.route('/start', methods=['POST'])
def start_acars() -> Response:
"""Start ACARS decoder."""
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 api_error('ACARS decoder already running', 409)
# Check for acarsdec
acarsdec_path = find_acarsdec()
if not acarsdec_path:
return api_error('acarsdec not found. Install with: sudo apt install acarsdec', 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')
# Check if device is available
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'acars', sdr_type_str)
if error:
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)
if isinstance(frequencies, str):
frequencies = [f.strip() for f in frequencies.split(',')]
# Clear queue
while not app_module.acars_queue.empty():
try:
app_module.acars_queue.get_nowait()
except queue.Empty:
break
# Reset stats
acars_message_count = 0
acars_last_message_time = None
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
is_soapy = sdr_type not in (SDRType.RTL_SDR,)
# Build acarsdec command
# Different forks have different syntax:
# - TLeconte v4+: acarsdec -j -g <gain> -p <ppm> -r <device> <freq1> <freq2> ...
# - TLeconte v3: acarsdec -o 4 -g <gain> -p <ppm> -r <device> <freq1> <freq2> ...
# - f00b4r0 (DragonOS): acarsdec --output json:file:- -g <gain> -p <ppm> -r <device> <freq1> ...
# SoapySDR devices: TLeconte uses -d <device_string>, f00b4r0 uses --soapysdr <device_string>
# Note: gain/ppm must come BEFORE -r/-d
json_flag = get_acarsdec_json_flag(acarsdec_path)
cmd = [acarsdec_path]
if json_flag == '--output':
# f00b4r0 fork: --output json:file (no path = stdout)
cmd.extend(['--output', 'json:file'])
elif json_flag == '-j':
cmd.append('-j') # JSON output (TLeconte v4+)
else:
cmd.extend(['-o', '4']) # JSON output (TLeconte v3.x)
# Add gain if not auto (must be before -r/-d)
if gain and str(gain) != '0':
cmd.extend(['-g', str(gain)])
# Add PPM correction if specified (must be before -r/-d)
if ppm and str(ppm) != '0':
cmd.extend(['-p', str(ppm)])
# Add device and frequencies
if is_soapy:
# SoapySDR device (SDRplay, LimeSDR, Airspy, etc.)
sdr_device = SDRFactory.create_default_device(sdr_type, index=device_int)
# Build SoapySDR driver string (e.g., "driver=sdrplay,serial=...")
builder = SDRFactory.get_builder(sdr_type)
device_str = builder._build_device_string(sdr_device)
if json_flag == '--output':
cmd.extend(['-m', '256'])
cmd.extend(['--soapysdr', device_str])
else:
cmd.extend(['-d', device_str])
elif json_flag == '--output':
# f00b4r0 fork RTL-SDR: --rtlsdr <device>
# Use 3.2 MS/s sample rate for wider bandwidth (handles NA frequency span)
cmd.extend(['-m', '256'])
cmd.extend(['--rtlsdr', str(device)])
else:
# TLeconte fork RTL-SDR: -r <device>
cmd.extend(['-r', str(device)])
cmd.extend(frequencies)
logger.info(f"Starting ACARS 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 acars_active_device is not None:
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')
if stderr:
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 api_error(error_msg, 500)
app_module.acars_process = process
register_process(process)
# Start output streaming thread
thread = threading.Thread(
target=stream_acars_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 acars_active_device is not None:
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 api_error(str(e), 500)
@acars_bp.route('/stop', methods=['POST'])
def stop_acars() -> Response:
"""Stop ACARS decoder."""
global acars_active_device, acars_active_sdr_type
with app_module.acars_lock:
if not app_module.acars_process:
return api_error('ACARS decoder not running', 400)
try:
app_module.acars_process.terminate()
app_module.acars_process.wait(timeout=PROCESS_TERMINATE_TIMEOUT)
except subprocess.TimeoutExpired:
app_module.acars_process.kill()
except Exception as e:
logger.error(f"Error stopping ACARS: {e}")
app_module.acars_process = None
# Release device from registry
if acars_active_device is not None:
app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr')
acars_active_device = None
acars_active_sdr_type = None
return jsonify({'status': 'stopped'})
@acars_bp.route('/stream')
def stream_acars() -> Response:
"""SSE stream for ACARS messages."""
def _on_msg(msg: dict[str, Any]) -> None:
process_event('acars', msg, msg.get('type'))
response = Response(
sse_stream_fanout(
source_queue=app_module.acars_queue,
channel_key='acars',
timeout=SSE_QUEUE_TIMEOUT,
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
on_message=_on_msg,
),
mimetype='text/event-stream',
)
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
return response
@acars_bp.route('/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': ['131.550', '130.025', '129.125', '131.725', '131.825'],
'europe': ['131.525', '131.725', '131.550'],
'asia_pacific': ['131.550', '131.450'],
}
})
+1700 -380
View File
File diff suppressed because it is too large Load Diff
+546
View File
@@ -0,0 +1,546 @@
"""AIS vessel tracking routes."""
from __future__ import annotations
import contextlib
import json
import os
import queue
import shutil
import socket
import subprocess
import threading
import time
from flask import Blueprint, Response, jsonify, render_template, request
import app as app_module
from config import SHARED_OBSERVER_LOCATION_ENABLED
from utils.constants import (
AIS_RECONNECT_DELAY,
AIS_SOCKET_TIMEOUT,
AIS_TCP_PORT,
AIS_TERMINATE_TIMEOUT,
AIS_UPDATE_INTERVAL,
PROCESS_TERMINATE_TIMEOUT,
SOCKET_BUFFER_SIZE,
SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_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')
ais_bp = Blueprint('ais', __name__, url_prefix='/ais')
# Track AIS state
ais_running = False
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
AIS_CATCHER_PATHS = [
'/usr/local/bin/AIS-catcher',
'/usr/bin/AIS-catcher',
'/opt/homebrew/bin/AIS-catcher',
'/opt/homebrew/bin/aiscatcher',
]
def find_ais_catcher():
"""Find AIS-catcher binary, checking PATH and common locations."""
# First try PATH
for name in ['AIS-catcher', 'aiscatcher']:
path = shutil.which(name)
if path:
return path
# Check common installation paths
for path in AIS_CATCHER_PATHS:
if os.path.isfile(path) and os.access(path, os.X_OK):
return path
return None
def parse_ais_stream(port: int):
"""Parse JSON data from AIS-catcher TCP server."""
global ais_running, ais_connected, ais_messages_received, ais_last_message_time, _ais_error_logged
logger.info(f"AIS stream parser started, connecting to localhost:{port}")
ais_connected = True
ais_messages_received = 0
_ais_error_logged = True
while ais_running:
sock = None
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(AIS_SOCKET_TIMEOUT)
sock.connect(('localhost', port))
ais_connected = True
_ais_error_logged = True
logger.info("Connected to AIS-catcher TCP server")
buffer = ""
last_update = time.time()
pending_updates = set()
while ais_running:
try:
data = sock.recv(SOCKET_BUFFER_SIZE).decode('utf-8', errors='ignore')
if not data:
logger.warning("AIS connection closed (no data)")
break
buffer += data
while '\n' in buffer:
line, buffer = buffer.split('\n', 1)
line = line.strip()
if not line:
continue
try:
msg = json.loads(line)
vessel = process_ais_message(msg)
if vessel:
mmsi = vessel.get('mmsi')
if mmsi:
app_module.ais_vessels.set(mmsi, vessel)
pending_updates.add(mmsi)
ais_messages_received += 1
ais_last_message_time = time.time()
except json.JSONDecodeError:
if ais_messages_received < 5:
logger.debug(f"Invalid JSON: {line[:100]}")
# Batch updates
now = time.time()
if now - last_update >= AIS_UPDATE_INTERVAL:
for mmsi in pending_updates:
if mmsi in app_module.ais_vessels:
_vessel_snap = app_module.ais_vessels[mmsi]
with contextlib.suppress(queue.Full):
app_module.ais_queue.put_nowait({
'type': 'vessel',
**_vessel_snap
})
# 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
ais_connected = False
except OSError as e:
ais_connected = False
if not _ais_error_logged:
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")
def process_ais_message(msg: dict) -> dict | None:
"""Process AIS-catcher JSON message and extract vessel data."""
# AIS-catcher outputs different message types
# We're interested in position reports and static data
mmsi = msg.get('mmsi')
if not mmsi:
return None
mmsi = str(mmsi)
# Get existing vessel data or create new
vessel = app_module.ais_vessels.get(mmsi) or {'mmsi': mmsi}
# Extract common fields
# AIS-catcher JSON_FULL uses 'longitude'/'latitude', but some versions use 'lon'/'lat'
lat_val = msg.get('latitude') or msg.get('lat')
lon_val = msg.get('longitude') or msg.get('lon')
if lat_val is not None and lon_val is not None:
try:
lat = float(lat_val)
lon = float(lon_val)
# Validate coordinates (AIS uses 181 for unavailable)
if -90 <= lat <= 90 and -180 <= lon <= 180:
vessel['lat'] = lat
vessel['lon'] = lon
except (ValueError, TypeError):
pass
# Speed over ground (knots)
if 'speed' in msg:
try:
speed = float(msg['speed'])
if speed < 102.3: # 102.3 = not available
vessel['speed'] = round(speed, 1)
except (ValueError, TypeError):
pass
# Course over ground (degrees)
if 'course' in msg:
try:
course = float(msg['course'])
if course < 360: # 360 = not available
vessel['course'] = round(course, 1)
except (ValueError, TypeError):
pass
# True heading (degrees)
if 'heading' in msg:
try:
heading = int(msg['heading'])
if heading < 511: # 511 = not available
vessel['heading'] = heading
except (ValueError, TypeError):
pass
# Navigation status
if 'status' in msg:
vessel['nav_status'] = msg['status']
if 'status_text' in msg:
vessel['nav_status_text'] = msg['status_text']
# Vessel name (from Type 5 or Type 24 messages)
if 'shipname' in msg:
name = msg['shipname'].strip().strip('@')
if name:
vessel['name'] = name
# Callsign
if 'callsign' in msg:
callsign = msg['callsign'].strip().strip('@')
if callsign:
vessel['callsign'] = callsign
# Ship type
if 'shiptype' in msg:
vessel['ship_type'] = msg['shiptype']
if 'shiptype_text' in msg:
vessel['ship_type_text'] = msg['shiptype_text']
# Destination
if 'destination' in msg:
dest = msg['destination'].strip().strip('@')
if dest:
vessel['destination'] = dest
# ETA
if 'eta' in msg:
vessel['eta'] = msg['eta']
# Dimensions
if 'to_bow' in msg and 'to_stern' in msg:
try:
length = int(msg['to_bow']) + int(msg['to_stern'])
if length > 0:
vessel['length'] = length
except (ValueError, TypeError):
pass
if 'to_port' in msg and 'to_starboard' in msg:
try:
width = int(msg['to_port']) + int(msg['to_starboard'])
if width > 0:
vessel['width'] = width
except (ValueError, TypeError):
pass
# Draught
if 'draught' in msg:
try:
draught = float(msg['draught'])
if draught > 0:
vessel['draught'] = draught
except (ValueError, TypeError):
pass
# Rate of turn
if 'turn' in msg:
try:
turn = float(msg['turn'])
if -127 <= turn <= 127: # Valid range
vessel['rate_of_turn'] = turn
except (ValueError, TypeError):
pass
# Message type for debugging
if 'type' in msg:
vessel['last_msg_type'] = msg['type']
# 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
@ais_bp.route('/tools')
def check_ais_tools():
"""Check for AIS decoding tools and hardware."""
has_ais_catcher = find_ais_catcher() is not None
# Check what SDR hardware is detected
devices = SDRFactory.detect_devices()
has_rtlsdr = any(d.sdr_type == SDRType.RTL_SDR for d in devices)
return jsonify({
'ais_catcher': has_ais_catcher,
'ais_catcher_path': find_ais_catcher(),
'has_rtlsdr': has_rtlsdr,
'device_count': len(devices)
})
@ais_bp.route('/status')
def ais_status():
"""Get AIS tracking status for debugging."""
process_running = False
if app_module.ais_process:
process_running = app_module.ais_process.poll() is None
return jsonify({
'tracking_active': ais_running,
'active_device': ais_active_device,
'connected': ais_connected,
'messages_received': ais_messages_received,
'last_message_time': ais_last_message_time,
'vessel_count': len(app_module.ais_vessels),
'vessels': dict(app_module.ais_vessels),
'queue_size': app_module.ais_queue.qsize(),
'ais_catcher_path': find_ais_catcher(),
'process_running': process_running
})
@ais_bp.route('/start', methods=['POST'])
def start_ais():
"""Start AIS tracking."""
global ais_running, ais_active_device, ais_active_sdr_type
with app_module.ais_lock:
if ais_running:
return api_error('AIS tracking already active', 409)
data = request.json or {}
# Validate inputs
try:
gain = int(validate_gain(data.get('gain', '40')))
device = validate_device_index(data.get('device', '0'))
except ValueError as e:
return api_error(str(e), 400)
# Find AIS-catcher
ais_catcher_path = find_ais_catcher()
if not ais_catcher_path:
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')
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
# Kill any existing process
if app_module.ais_process:
try:
pgid = os.getpgid(app_module.ais_process.pid)
os.killpg(pgid, 15)
app_module.ais_process.wait(timeout=PROCESS_TERMINATE_TIMEOUT)
except (subprocess.TimeoutExpired, ProcessLookupError, OSError):
try:
pgid = os.getpgid(app_module.ais_process.pid)
os.killpg(pgid, 9)
except (ProcessLookupError, OSError):
pass
app_module.ais_process = None
logger.info("Killed existing AIS process")
# Check if device is available
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'ais', sdr_type_str)
if error:
return api_error(error, 409, error_type='DEVICE_BUSY')
# Build command using SDR abstraction
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
builder = SDRFactory.get_builder(sdr_type)
bias_t = data.get('bias_t', False)
tcp_port = AIS_TCP_PORT
cmd = builder.build_ais_command(
device=sdr_device,
gain=float(gain),
bias_t=bias_t,
tcp_port=tcp_port
)
# Use the found AIS-catcher path
cmd[0] = ais_catcher_path
try:
logger.info(f"Starting AIS-catcher with device {device}: {' '.join(cmd)}")
app_module.ais_process = subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
start_new_session=True
)
# Wait for process to start
time.sleep(2.0)
if app_module.ais_process.poll() is not None:
# Release device on failure
app_module.release_sdr_device(device_int, sdr_type_str)
stderr_output = ''
if app_module.ais_process.stderr:
with contextlib.suppress(Exception):
stderr_output = app_module.ais_process.stderr.read().decode('utf-8', errors='ignore').strip()
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[: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)
thread.start()
return jsonify({
'status': 'started',
'message': 'AIS tracking started',
'device': device,
'port': tcp_port
})
except Exception as e:
# Release device on failure
app_module.release_sdr_device(device_int, sdr_type_str)
logger.error(f"Failed to start AIS-catcher: {e}")
return api_error(str(e), 500)
@ais_bp.route('/stop', methods=['POST'])
def stop_ais():
"""Stop AIS tracking."""
global ais_running, ais_active_device, ais_active_sdr_type
with app_module.ais_lock:
if app_module.ais_process:
try:
pgid = os.getpgid(app_module.ais_process.pid)
os.killpg(pgid, 15)
app_module.ais_process.wait(timeout=AIS_TERMINATE_TIMEOUT)
except (subprocess.TimeoutExpired, ProcessLookupError, OSError):
try:
pgid = os.getpgid(app_module.ais_process.pid)
os.killpg(pgid, 9)
except (ProcessLookupError, OSError):
pass
app_module.ais_process = None
logger.info("AIS process stopped")
# Release device from registry
if ais_active_device is not None:
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'})
@ais_bp.route('/stream')
def stream_ais():
"""SSE stream for AIS vessels."""
def _on_msg(msg: dict[str, Any]) -> None:
process_event('ais', msg, msg.get('type'))
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,
)
+75
View File
@@ -0,0 +1,75 @@
"""Alerting API endpoints."""
from __future__ import annotations
from collections.abc import Generator
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')
@alerts_bp.route('/rules', methods=['GET'])
def list_rules():
manager = get_alert_manager()
include_disabled = request.args.get('all') in ('1', 'true', 'yes')
return api_success(data={'rules': manager.list_rules(include_disabled=include_disabled)})
@alerts_bp.route('/rules', methods=['POST'])
def create_rule():
data = request.get_json() or {}
if not isinstance(data.get('match', {}), dict):
return api_error('match must be a JSON object', 400)
manager = get_alert_manager()
rule_id = manager.add_rule(data)
return api_success(data={'rule_id': rule_id})
@alerts_bp.route('/rules/<int:rule_id>', methods=['PUT', 'PATCH'])
def update_rule(rule_id: int):
data = request.get_json() or {}
manager = get_alert_manager()
ok = manager.update_rule(rule_id, data)
if not ok:
return api_error('Rule not found or no changes', 404)
return api_success()
@alerts_bp.route('/rules/<int:rule_id>', methods=['DELETE'])
def delete_rule(rule_id: int):
manager = get_alert_manager()
ok = manager.delete_rule(rule_id)
if not ok:
return api_error('Rule not found', 404)
return api_success()
@alerts_bp.route('/events', methods=['GET'])
def list_events():
manager = get_alert_manager()
limit = request.args.get('limit', default=100, type=int)
mode = request.args.get('mode')
severity = request.args.get('severity')
events = manager.list_events(limit=limit, mode=mode, severity=severity)
return api_success(data={'events': events})
@alerts_bp.route('/stream', methods=['GET'])
def stream_alerts() -> Response:
manager = get_alert_manager()
def generate() -> Generator[str, None, None]:
for event in manager.stream_events(timeout=1.0):
yield format_sse(event)
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response
+2173
View File
File diff suppressed because it is too large Load Diff
+269
View File
@@ -0,0 +1,269 @@
"""WebSocket-based audio streaming for SDR."""
import json
import shutil
import socket
import subprocess
import threading
import time
from flask import Flask
# Try to import flask-sock
try:
from flask_sock import Sock
WEBSOCKET_AVAILABLE = True
except ImportError:
WEBSOCKET_AVAILABLE = False
Sock = None
import contextlib
from utils.logging import get_logger
logger = get_logger('intercept.audio_ws')
# Global state
audio_process = None
rtl_process = None
process_lock = threading.Lock()
current_config = {
'frequency': 118.0,
'modulation': 'am',
'squelch': 0,
'gain': 40,
'device': 0
}
def find_rtl_fm():
return shutil.which('rtl_fm')
def find_ffmpeg():
return shutil.which('ffmpeg')
def _rtl_fm_demod_mode(modulation):
"""Map UI modulation names to rtl_fm demod tokens."""
mod = str(modulation or '').lower().strip()
return 'wbfm' if mod == 'wfm' else mod
def kill_audio_processes():
"""Kill any running audio processes."""
global audio_process, rtl_process
if audio_process:
try:
audio_process.terminate()
audio_process.wait(timeout=0.5)
except:
with contextlib.suppress(BaseException):
audio_process.kill()
audio_process = None
if rtl_process:
try:
rtl_process.terminate()
rtl_process.wait(timeout=0.5)
except:
with contextlib.suppress(BaseException):
rtl_process.kill()
rtl_process = None
time.sleep(0.3)
def start_audio_stream(config):
"""Start rtl_fm + ffmpeg pipeline, return the ffmpeg process."""
global audio_process, rtl_process, current_config
kill_audio_processes()
rtl_fm = find_rtl_fm()
ffmpeg = find_ffmpeg()
if not rtl_fm or not ffmpeg:
logger.error("rtl_fm or ffmpeg not found")
return None
current_config.update(config)
freq = config.get('frequency', 118.0)
mod = config.get('modulation', 'am')
squelch = config.get('squelch', 0)
gain = config.get('gain', 40)
device = config.get('device', 0)
# Sample rates based on modulation
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
freq_hz = int(freq * 1e6)
rtl_cmd = [
rtl_fm,
'-M', _rtl_fm_demod_mode(mod),
'-f', str(freq_hz),
'-s', str(sample_rate),
'-r', str(resample_rate),
'-g', str(gain),
'-d', str(device),
'-l', str(squelch),
]
# Encode to MP3 for browser compatibility
ffmpeg_cmd = [
ffmpeg,
'-hide_banner',
'-loglevel', 'error',
'-f', 's16le',
'-ar', str(resample_rate),
'-ac', '1',
'-i', 'pipe:0',
'-acodec', 'libmp3lame',
'-b:a', '128k',
'-f', 'mp3',
'-flush_packets', '1',
'pipe:1'
]
try:
logger.info(f"Starting rtl_fm: {freq} MHz, {mod}")
rtl_process = subprocess.Popen(
rtl_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL
)
audio_process = subprocess.Popen(
ffmpeg_cmd,
stdin=rtl_process.stdout,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
bufsize=0
)
rtl_process.stdout.close()
# Check processes started
time.sleep(0.2)
if rtl_process.poll() is not None or audio_process.poll() is not None:
logger.error("Audio process failed to start")
kill_audio_processes()
return None
return audio_process
except Exception as e:
logger.error(f"Failed to start audio: {e}")
kill_audio_processes()
return None
def init_audio_websocket(app: Flask):
"""Initialize WebSocket audio streaming."""
if not WEBSOCKET_AVAILABLE:
logger.warning("flask-sock not installed, WebSocket audio disabled")
return
sock = Sock(app)
@sock.route('/ws/audio')
def audio_stream(ws):
"""WebSocket endpoint for audio streaming."""
logger.info("WebSocket audio client connected")
proc = None
streaming = False
try:
while True:
# Check for messages from client (non-blocking with timeout)
try:
msg = ws.receive(timeout=0.01)
if msg:
data = json.loads(msg)
cmd = data.get('cmd')
if cmd == 'start':
config = data.get('config', {})
logger.info(f"Starting audio: {config}")
with process_lock:
proc = start_audio_stream(config)
if proc:
streaming = True
ws.send(json.dumps({'status': 'started'}))
else:
ws.send(json.dumps({'status': 'error', 'message': 'Failed to start'}))
elif cmd == 'stop':
logger.info("Stopping audio")
streaming = False
with process_lock:
kill_audio_processes()
proc = None
ws.send(json.dumps({'status': 'stopped'}))
elif cmd == 'tune':
# Change frequency/modulation - restart stream
config = data.get('config', {})
logger.info(f"Retuning: {config}")
with process_lock:
proc = start_audio_stream(config)
if proc:
streaming = True
ws.send(json.dumps({'status': 'tuned'}))
else:
streaming = False
ws.send(json.dumps({'status': 'error', 'message': 'Failed to tune'}))
except TimeoutError:
pass
except Exception as e:
msg = str(e).lower()
if "connection closed" in msg:
logger.info("WebSocket closed by client")
break
if "timed out" not in msg:
logger.error(f"WebSocket receive error: {e}")
# Stream audio data if active
if streaming and proc and proc.poll() is None:
try:
chunk = proc.stdout.read(4096)
if chunk:
ws.send(chunk)
except Exception as e:
logger.error(f"Audio read error: {e}")
streaming = False
elif streaming:
# Process died
streaming = False
ws.send(json.dumps({'status': 'error', 'message': 'Audio process died'}))
else:
time.sleep(0.01)
except Exception as e:
logger.info(f"WebSocket closed: {e}")
finally:
with process_lock:
kill_audio_processes()
# Complete WebSocket close handshake, then shut down the
# raw socket so Werkzeug cannot write its HTTP 200 response
# on top of the WebSocket stream.
with contextlib.suppress(Exception):
ws.close()
with contextlib.suppress(Exception):
ws.sock.shutdown(socket.SHUT_RDWR)
with contextlib.suppress(Exception):
ws.sock.close()
logger.info("WebSocket audio client disconnected")
+143 -56
View File
@@ -2,8 +2,7 @@
from __future__ import annotations
import fcntl
import json
import contextlib
import os
import platform
import pty
@@ -13,61 +12,118 @@ 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 data.oui import OUI_DATABASE, get_manufacturer, load_oui_database
from data.patterns import AIRTAG_PREFIXES, SAMSUNG_TRACKER, TILE_PREFIXES
from utils.constants import (
SUBPROCESS_TIMEOUT_SHORT,
)
from utils.dependencies import check_tool
from utils.event_pipeline import process_event
from utils.logging import bluetooth_logger as logger
from utils.sse import format_sse
from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer
from data.patterns import AIRTAG_PREFIXES, TILE_PREFIXES, SAMSUNG_TRACKER
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."""
name_lower = (name or '').lower()
mfr_lower = (manufacturer or '').lower()
# Audio devices - check name patterns first
audio_patterns = [
'airpod', 'earbud', 'headphone', 'headset', 'speaker', 'audio', 'beats', 'bose',
'jbl', 'sony wh', 'sony wf', 'sennheiser', 'jabra', 'soundcore', 'anker', 'buds',
'earphone', 'pod', 'soundbar', 'skullcandy', 'marshall', 'b&o', 'bang', 'olufsen'
'earphone', 'pod', 'soundbar', 'skullcandy', 'marshall', 'b&o', 'bang', 'olufsen',
'powerbeats', 'soundlink', 'soundsport', 'quietcomfort', 'qc35', 'qc45', 'nc700',
'wh-1000', 'wf-1000', 'linkbuds', 'freebuds', 'galaxy buds', 'pixel buds',
'echo dot', 'homepod', 'sonos', 'ue boom', 'flip', 'charge', 'xtreme', 'pulse'
]
if any(x in name_lower for x in audio_patterns):
return 'audio'
# Wearables
wearable_patterns = [
'watch', 'band', 'fitbit', 'garmin', 'mi band', 'miband', 'amazfit',
'galaxy watch', 'gear', 'versa', 'sense', 'charge', 'inspire'
'galaxy watch', 'gear', 'versa', 'sense', 'charge', 'inspire', 'fenix',
'forerunner', 'venu', 'vivoactive', 'instinct', 'apple watch', 'gt 2', 'gt2'
]
if any(x in name_lower for x in wearable_patterns):
return 'wearable'
# Phones - check name patterns
phone_patterns = [
'iphone', 'galaxy', 'pixel', 'phone', 'android', 'oneplus', 'huawei', 'xiaomi'
'iphone', 'galaxy', 'pixel', 'phone', 'android', 'oneplus', 'huawei', 'xiaomi',
'redmi', 'poco', 'realme', 'oppo', 'vivo', 'motorola', 'nokia', 'lg-', 'sm-',
'moto g', 'moto e', 'note', 'ultra', 'pro max', 's21', 's22', 's23', 's24'
]
if any(x in name_lower for x in phone_patterns):
return 'phone'
tracker_patterns = ['airtag', 'tile', 'smarttag', 'chipolo', 'find my']
# Trackers
tracker_patterns = ['airtag', 'tile', 'smarttag', 'chipolo', 'find my', 'findmy']
if any(x in name_lower for x in tracker_patterns):
return 'tracker'
input_patterns = ['keyboard', 'mouse', 'controller', 'gamepad', 'remote']
# Input devices
input_patterns = ['keyboard', 'mouse', 'controller', 'gamepad', 'remote', 'trackpad',
'magic keyboard', 'magic mouse', 'magic trackpad', 'mx master', 'mx keys',
'logitech k', 'logitech m', 'razer', 'dualshock', 'dualsense', 'xbox']
if any(x in name_lower for x in input_patterns):
return 'input'
if mfr_lower in ['bose', 'jbl', 'sony', 'sennheiser', 'jabra', 'beats']:
# Computers/laptops
computer_patterns = ['macbook', 'imac', 'mac pro', 'mac mini', 'dell', 'hp ', 'lenovo',
'thinkpad', 'surface', 'chromebook', 'laptop', 'desktop', 'pc']
if any(x in name_lower for x in computer_patterns):
return 'computer'
# Check manufacturer for device type inference
audio_manufacturers = ['bose', 'jbl', 'sony', 'sennheiser', 'jabra', 'beats',
'bang & olufsen', 'audio-technica', 'skullcandy', 'anker', 'plantronics']
if mfr_lower in audio_manufacturers:
return 'audio'
if mfr_lower in ['fitbit', 'garmin']:
wearable_manufacturers = ['fitbit', 'garmin']
if mfr_lower in wearable_manufacturers:
return 'wearable'
if mfr_lower == 'tile':
return 'tracker'
phone_manufacturers = ['samsung', 'xiaomi', 'huawei', 'oneplus', 'google', 'oppo', 'vivo', 'realme']
if mfr_lower in phone_manufacturers:
return 'phone'
computer_manufacturers = ['dell', 'hp', 'lenovo', 'microsoft', 'intel']
if mfr_lower in computer_manufacturers:
return 'computer'
# Check device class if available
if device_class:
major_class = (device_class >> 8) & 0x1F
if major_class == 1:
@@ -113,7 +169,7 @@ def detect_bt_interfaces():
if platform.system() == 'Linux':
try:
result = subprocess.run(['hciconfig'], capture_output=True, text=True, timeout=5)
result = subprocess.run(['hciconfig'], capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_SHORT)
blocks = re.split(r'(?=^hci\d+:)', result.stdout, flags=re.MULTILINE)
for block in blocks:
if block.strip():
@@ -127,8 +183,12 @@ def detect_bt_interfaces():
'type': 'hci',
'status': 'up' if is_up else 'down'
})
except Exception:
pass
except FileNotFoundError:
logger.debug("hciconfig not found")
except subprocess.TimeoutExpired:
logger.warning("hciconfig timed out")
except subprocess.SubprocessError as e:
logger.warning(f"Error running hciconfig: {e}")
elif platform.system() == 'Darwin':
interfaces.append({
@@ -203,18 +263,43 @@ def stream_bt_scan(process, scan_mode):
line = re.sub(r'\r', '', line)
if 'Device' in line:
# Check for RSSI update: [CHG] Device XX:XX:XX RSSI: -65
rssi_match = re.search(r'([0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}).*RSSI:\s*(-?\d+)', line)
if rssi_match:
mac = rssi_match.group(1).upper()
rssi = int(rssi_match.group(2))
if mac in app_module.bt_devices:
app_module.bt_devices[mac]['rssi'] = rssi
app_module.bt_devices[mac]['last_seen'] = time.time()
# Send RSSI update
app_module.bt_queue.put({
**app_module.bt_devices[mac],
'type': 'device',
'device_type': app_module.bt_devices[mac].get('type', 'other'),
'action': 'update',
})
continue
# Check for new device: [NEW] Device XX:XX:XX Name
match = re.search(r'([0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2})\s*(.*)', line)
if match:
mac = match.group(1).upper()
name = match.group(2).strip()
# Extract RSSI from name if present
rssi_in_name = re.search(r'RSSI:\s*(-?\d+)', name)
initial_rssi = int(rssi_in_name.group(1)) if rssi_in_name else None
# Remove "RSSI: -XX" from name
name = re.sub(r'\s*RSSI:\s*-?\d+\s*', '', name).strip()
manufacturer = get_manufacturer(mac)
device = {
'mac': mac,
'name': name or '[Unknown]',
'manufacturer': manufacturer,
'type': classify_bt_device(name, None, None, manufacturer),
'rssi': None,
'rssi': initial_rssi,
'last_seen': time.time()
}
@@ -234,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)})
@@ -255,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')
@@ -283,15 +366,20 @@ 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
data = request.json
scan_mode = data.get('mode', 'hcitool')
interface = data.get('interface', 'hci0')
scan_ble = data.get('scan_ble', True)
# Validate Bluetooth interface name
try:
interface = validate_bluetooth_interface(data.get('interface', 'hci0'))
except ValueError as e:
return api_error(str(e), 400)
app_module.bt_interface = interface
app_module.bt_devices = {}
@@ -332,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
@@ -349,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'])
@@ -373,7 +461,12 @@ def stop_bt_scan():
def reset_bt_adapter():
"""Reset Bluetooth adapter."""
data = request.json
interface = data.get('interface', 'hci0')
# Validate Bluetooth interface name
try:
interface = validate_bluetooth_interface(data.get('interface', 'hci0'))
except ValueError as e:
return api_error(str(e), 400)
with app_module.bt_lock:
if app_module.bt_process:
@@ -381,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:
@@ -403,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'])
@@ -418,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(
@@ -443,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')
@@ -470,22 +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()
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'
File diff suppressed because it is too large Load Diff
+308
View File
@@ -0,0 +1,308 @@
"""
BT Locate Bluetooth SAR Device Location Flask Blueprint.
Provides endpoints for managing locate sessions, streaming detection events,
and retrieving GPS-tagged signal trails.
"""
from __future__ import annotations
import logging
from collections.abc import Generator
from flask import Blueprint, Response, jsonify, request
from utils.bluetooth.irk_extractor import get_paired_irks
from utils.bt_locate import (
Environment,
LocateTarget,
get_locate_session,
resolve_rpa,
start_locate_session,
stop_locate_session,
)
from utils.responses import api_error
from utils.sse import format_sse
logger = logging.getLogger('intercept.bt_locate')
bt_locate_bp = Blueprint('bt_locate', __name__, url_prefix='/bt_locate')
@bt_locate_bp.route('/start', methods=['POST'])
def start_session():
"""
Start a locate session.
Request JSON:
- mac_address: Target MAC address (optional)
- name_pattern: Target name substring (optional)
- irk_hex: Identity Resolving Key hex string (optional)
- device_id: Device ID from Bluetooth scanner (optional)
- device_key: Stable device key from Bluetooth scanner (optional)
- fingerprint_id: Payload fingerprint ID from Bluetooth scanner (optional)
- known_name: Hand-off device name (optional)
- known_manufacturer: Hand-off manufacturer (optional)
- last_known_rssi: Hand-off last RSSI (optional)
- environment: 'FREE_SPACE', 'OUTDOOR', 'INDOOR', 'CUSTOM' (default: OUTDOOR)
- custom_exponent: Path loss exponent for CUSTOM environment (optional)
Returns:
JSON with session status.
"""
data = request.get_json() or {}
# Build target
target = LocateTarget(
mac_address=data.get('mac_address'),
name_pattern=data.get('name_pattern'),
irk_hex=data.get('irk_hex'),
device_id=data.get('device_id'),
device_key=data.get('device_key'),
fingerprint_id=data.get('fingerprint_id'),
known_name=data.get('known_name'),
known_manufacturer=data.get('known_manufacturer'),
last_known_rssi=data.get('last_known_rssi'),
)
# At least one identifier required
if not any([
target.mac_address,
target.name_pattern,
target.irk_hex,
target.device_id,
target.device_key,
target.fingerprint_id,
]):
return 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 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 api_error('custom_exponent must be a number', 400)
# Fallback coordinates when GPS is unavailable (from user settings)
fallback_lat = None
fallback_lon = None
if data.get('fallback_lat') is not None and data.get('fallback_lon') is not None:
try:
fallback_lat = float(data['fallback_lat'])
fallback_lon = float(data['fallback_lon'])
except (ValueError, TypeError):
pass
logger.info(
f"Starting locate session: target={target.to_dict()}, "
f"env={environment.name}, fallback=({fallback_lat}, {fallback_lon})"
)
try:
session = start_locate_session(
target, environment, custom_exponent, fallback_lat, fallback_lon
)
except RuntimeError as exc:
logger.warning(f"Unable to start BT Locate session: {exc}")
return api_error('Bluetooth scanner could not be started. Check adapter permissions/capabilities.', 503)
except Exception as exc:
logger.exception(f"Unexpected error starting BT Locate session: {exc}")
return api_error('Failed to start locate session', 500)
return jsonify({
'status': 'started',
'session': session.get_status(),
})
@bt_locate_bp.route('/stop', methods=['POST'])
def stop_session():
"""Stop the active locate session."""
session = get_locate_session()
if not session:
return jsonify({'status': 'no_session'})
stop_locate_session()
return jsonify({'status': 'stopped'})
@bt_locate_bp.route('/status', methods=['GET'])
def get_status():
"""Get locate session status."""
session = get_locate_session()
if not session:
return jsonify({
'active': False,
'target': None,
})
include_debug = str(request.args.get('debug', '')).lower() in ('1', 'true', 'yes')
return jsonify(session.get_status(include_debug=include_debug))
@bt_locate_bp.route('/trail', methods=['GET'])
def get_trail():
"""Get detection trail data."""
session = get_locate_session()
if not session:
return jsonify({'trail': [], 'gps_trail': []})
return jsonify({
'trail': session.get_trail(),
'gps_trail': session.get_gps_trail(),
})
@bt_locate_bp.route('/stream', methods=['GET'])
def stream_detections():
"""SSE stream of detection events."""
def event_generator() -> Generator[str, None, None]:
while True:
# Re-fetch session each iteration in case it changes
s = get_locate_session()
if not s:
yield format_sse({'type': 'session_ended'}, event='session_ended')
return
try:
event = s.event_queue.get(timeout=2.0)
yield format_sse(event, event='detection')
except Exception:
yield format_sse({}, event='ping')
return Response(
event_generator(),
mimetype='text/event-stream',
headers={
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no',
}
)
@bt_locate_bp.route('/resolve_rpa', methods=['POST'])
def test_resolve_rpa():
"""
Test if an IRK resolves to a given address.
Request JSON:
- irk_hex: 16-byte IRK as hex string
- address: BLE address string
Returns:
JSON with resolution result.
"""
data = request.get_json() or {}
irk_hex = data.get('irk_hex', '')
address = data.get('address', '')
if not irk_hex or not address:
return api_error('irk_hex and address are required', 400)
try:
irk = bytes.fromhex(irk_hex)
except ValueError:
return api_error('Invalid IRK hex string', 400)
if len(irk) != 16:
return api_error('IRK must be exactly 16 bytes (32 hex characters)', 400)
result = resolve_rpa(irk, address)
return jsonify({
'resolved': result,
'irk_hex': irk_hex,
'address': address,
})
@bt_locate_bp.route('/environment', methods=['POST'])
def set_environment():
"""Update the environment on the active session."""
session = get_locate_session()
if not session:
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 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):
custom_exponent = None
session.set_environment(environment, custom_exponent)
return jsonify({
'status': 'updated',
'environment': environment.name,
'path_loss_exponent': session.estimator.n,
})
@bt_locate_bp.route('/debug', methods=['GET'])
def debug_matching():
"""Debug endpoint showing scanner devices and match results."""
session = get_locate_session()
if not session:
return api_error('no session')
scanner = session._scanner
if not scanner:
return api_error('no scanner')
devices = scanner.get_devices(max_age_seconds=30)
return jsonify({
'target': session.target.to_dict(),
'device_count': len(devices),
'devices': [
{
'device_id': d.device_id,
'address': d.address,
'name': d.name,
'rssi': d.rssi_current,
'matches': session.target.matches(d),
}
for d in devices
],
})
@bt_locate_bp.route('/paired_irks', methods=['GET'])
def paired_irks():
"""Return paired Bluetooth devices that have IRKs."""
try:
devices = get_paired_irks()
except Exception as e:
logger.exception("Failed to read paired IRKs")
return jsonify({'devices': [], 'error': str(e)})
return jsonify({'devices': devices})
@bt_locate_bp.route('/clear_trail', methods=['POST'])
def clear_trail():
"""Clear the detection trail."""
session = get_locate_session()
if not session:
return jsonify({'status': 'no_session'})
session.clear_trail()
return jsonify({'status': 'cleared'})
+884
View File
@@ -0,0 +1,884 @@
"""
Controller routes for managing remote Intercept agents.
This blueprint provides:
- Agent CRUD operations
- Proxy endpoints to forward requests to agents
- Push data ingestion endpoint
- Multi-agent SSE stream
"""
from __future__ import annotations
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,
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,
estimate_location_from_observations,
)
logger = logging.getLogger('intercept.controller')
controller_bp = Blueprint('controller', __name__, url_prefix='/controller')
# Multi-agent SSE fanout state (per-client queues).
_agent_stream_subscribers: set[queue.Queue] = set()
_agent_stream_subscribers_lock = threading.Lock()
_AGENT_STREAM_CLIENT_QUEUE_SIZE = 500
def _broadcast_agent_data(payload: dict) -> None:
"""Fan out an ingested payload to all active /controller/stream/all clients."""
with _agent_stream_subscribers_lock:
subscribers = tuple(_agent_stream_subscribers)
for subscriber in subscribers:
try:
subscriber.put_nowait(payload)
except queue.Full:
try:
subscriber.get_nowait()
subscriber.put_nowait(payload)
except (queue.Empty, queue.Full):
continue
# =============================================================================
# Agent CRUD
# =============================================================================
@controller_bp.route('/agents', methods=['GET'])
def get_agents():
"""List all registered agents."""
active_only = request.args.get('active_only', 'true').lower() == 'true'
agents = list_agents(active_only=active_only)
# Optionally refresh status for each agent
refresh = request.args.get('refresh', 'false').lower() == 'true'
if refresh:
for agent in agents:
try:
client = create_client_from_agent(agent)
agent['healthy'] = client.health_check()
except Exception:
agent['healthy'] = False
return jsonify({
'status': 'success',
'agents': agents,
'count': len(agents)
})
@controller_bp.route('/agents', methods=['POST'])
def register_agent():
"""
Register a new remote agent.
Expected JSON body:
{
"name": "sensor-node-1",
"base_url": "http://192.168.1.50:8020",
"api_key": "optional-shared-secret",
"description": "Optional description"
}
"""
data = request.json or {}
# Validate required fields
name = data.get('name', '').strip()
base_url = data.get('base_url', '').strip()
if not name:
return api_error('Agent name is required', 400)
if not base_url:
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 api_error('URL must start with http:// or https://', 400)
if not parsed.netloc:
return api_error('Invalid URL format', 400)
except Exception:
return api_error('Invalid URL format', 400)
# Check if agent already exists
existing = get_agent_by_name(name)
if existing:
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
client = AgentClient(base_url, api_key=api_key)
capabilities = None
interfaces = None
try:
caps = client.get_capabilities()
capabilities = caps.get('modes', {})
interfaces = {'devices': caps.get('devices', [])}
except (AgentHTTPError, AgentConnectionError) as e:
logger.warning(f"Could not fetch capabilities from {base_url}: {e}")
# Create agent
try:
agent_id = create_agent(
name=name,
base_url=base_url,
api_key=api_key,
description=data.get('description'),
capabilities=capabilities,
interfaces=interfaces
)
# Update last_seen since we just connected
if capabilities is not None:
update_agent(agent_id, update_last_seen=True)
agent = get_agent(agent_id)
message = 'Agent registered successfully'
if capabilities is None:
message += ' (could not connect - agent may be offline)'
return jsonify({
'status': 'success',
'message': message,
'agent': agent
}), 201
except Exception as e:
logger.exception("Failed to create agent")
return api_error(str(e), 500)
@controller_bp.route('/agents/<int:agent_id>', methods=['GET'])
def get_agent_detail(agent_id: int):
"""Get details of a specific agent."""
agent = get_agent(agent_id)
if not agent:
return api_error('Agent not found', 404)
# Optionally refresh from agent
refresh = request.args.get('refresh', 'false').lower() == 'true'
if refresh:
try:
client = create_client_from_agent(agent)
metadata = client.refresh_metadata()
if metadata['healthy']:
caps = metadata['capabilities'] or {}
# Store full interfaces structure (wifi, bt, sdr)
agent_interfaces = caps.get('interfaces', {})
# Fallback: also include top-level devices for backwards compatibility
if not agent_interfaces.get('sdr_devices') and caps.get('devices'):
agent_interfaces['sdr_devices'] = caps.get('devices', [])
update_agent(
agent_id,
capabilities=caps.get('modes'),
interfaces=agent_interfaces,
update_last_seen=True
)
agent = get_agent(agent_id)
agent['healthy'] = True
else:
agent['healthy'] = False
except Exception:
agent['healthy'] = False
return jsonify({'status': 'success', 'agent': agent})
@controller_bp.route('/agents/<int:agent_id>', methods=['PUT', 'PATCH'])
def update_agent_detail(agent_id: int):
"""Update an agent's details."""
agent = get_agent(agent_id)
if not agent:
return api_error('Agent not found', 404)
data = request.json or {}
# Update allowed fields
update_agent(
agent_id,
base_url=data.get('base_url'),
description=data.get('description'),
api_key=data.get('api_key'),
is_active=data.get('is_active')
)
agent = get_agent(agent_id)
return jsonify({'status': 'success', 'agent': agent})
@controller_bp.route('/agents/<int:agent_id>', methods=['DELETE'])
def remove_agent(agent_id: int):
"""Delete an agent."""
agent = get_agent(agent_id)
if not agent:
return api_error('Agent not found', 404)
delete_agent(agent_id)
return jsonify({'status': 'success', 'message': 'Agent deleted'})
@controller_bp.route('/agents/<int:agent_id>/refresh', methods=['POST'])
def refresh_agent_metadata(agent_id: int):
"""Refresh an agent's capabilities and status."""
agent = get_agent(agent_id)
if not agent:
return api_error('Agent not found', 404)
try:
client = create_client_from_agent(agent)
metadata = client.refresh_metadata()
if metadata['healthy']:
caps = metadata['capabilities'] or {}
# Store full interfaces structure (wifi, bt, sdr)
agent_interfaces = caps.get('interfaces', {})
# Fallback: also include top-level devices for backwards compatibility
if not agent_interfaces.get('sdr_devices') and caps.get('devices'):
agent_interfaces['sdr_devices'] = caps.get('devices', [])
update_agent(
agent_id,
capabilities=caps.get('modes'),
interfaces=agent_interfaces,
update_last_seen=True
)
agent = get_agent(agent_id)
return jsonify({
'status': 'success',
'agent': agent,
'metadata': metadata
})
else:
return api_error('Agent is not reachable', 503)
except (AgentHTTPError, AgentConnectionError) as e:
return api_error(f'Failed to reach agent: {e}', 503)
# =============================================================================
# Agent Status - Get running state
# =============================================================================
@controller_bp.route('/agents/<int:agent_id>/status', methods=['GET'])
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 api_error('Agent not found', 404)
try:
client = create_client_from_agent(agent)
status = client.get_status()
return jsonify({
'status': 'success',
'agent_id': agent_id,
'agent_name': agent['name'],
'agent_status': status
})
except (AgentHTTPError, AgentConnectionError) as e:
return api_error(f'Failed to reach agent: {e}', 503)
@controller_bp.route('/agents/health', methods=['GET'])
def check_all_agents_health():
"""
Check health of all registered agents in one call.
More efficient than checking each agent individually.
Returns health status, response time, and running modes for each agent.
"""
agents_list = list_agents(active_only=True)
results = []
for agent in agents_list:
result = {
'id': agent['id'],
'name': agent['name'],
'healthy': False,
'response_time_ms': None,
'running_modes': [],
'error': None
}
try:
client = create_client_from_agent(agent)
# Time the health check
start_time = time.time()
is_healthy = client.health_check()
response_time = (time.time() - start_time) * 1000
result['healthy'] = is_healthy
result['response_time_ms'] = round(response_time, 1)
if is_healthy:
# Update last_seen in database
update_agent(agent['id'], update_last_seen=True)
# Also fetch running modes
try:
status = client.get_status()
result['running_modes'] = status.get('running_modes', [])
result['running_modes_detail'] = status.get('running_modes_detail', {})
except Exception:
pass # Status fetch is optional
except AgentConnectionError as e:
result['error'] = f'Connection failed: {str(e)}'
except AgentHTTPError as e:
result['error'] = f'HTTP error: {str(e)}'
except Exception as e:
result['error'] = str(e)
results.append(result)
return jsonify({
'status': 'success',
'timestamp': datetime.now(timezone.utc).isoformat(),
'agents': results,
'total': len(results),
'healthy_count': sum(1 for r in results if r['healthy'])
})
# =============================================================================
# Proxy Operations - Forward requests to agents
# =============================================================================
@controller_bp.route('/agents/<int:agent_id>/<mode>/start', methods=['POST'])
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 api_error('Agent not found', 404)
params = request.json or {}
try:
client = create_client_from_agent(agent)
result = client.start_mode(mode, params)
# Update last_seen
update_agent(agent_id, update_last_seen=True)
return jsonify({
'status': 'success',
'agent_id': agent_id,
'mode': mode,
'result': result
})
except AgentConnectionError as e:
return api_error(f'Cannot connect to agent: {e}', 503)
except AgentHTTPError as e:
return api_error(f'Agent error: {e}', 502)
@controller_bp.route('/agents/<int:agent_id>/<mode>/stop', methods=['POST'])
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 api_error('Agent not found', 404)
try:
client = create_client_from_agent(agent)
result = client.stop_mode(mode)
update_agent(agent_id, update_last_seen=True)
return jsonify({
'status': 'success',
'agent_id': agent_id,
'mode': mode,
'result': result
})
except AgentConnectionError as e:
return api_error(f'Cannot connect to agent: {e}', 503)
except AgentHTTPError as e:
return api_error(f'Agent error: {e}', 502)
@controller_bp.route('/agents/<int:agent_id>/<mode>/status', methods=['GET'])
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 api_error('Agent not found', 404)
try:
client = create_client_from_agent(agent)
result = client.get_mode_status(mode)
return jsonify({
'status': 'success',
'agent_id': agent_id,
'mode': mode,
'result': result
})
except (AgentHTTPError, AgentConnectionError) as e:
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 api_error('Agent not found', 404)
try:
client = create_client_from_agent(agent)
result = client.get_mode_data(mode)
# Tag data with agent info
result['agent_id'] = agent_id
result['agent_name'] = agent['name']
return jsonify({
'status': 'success',
'agent_id': agent_id,
'agent_name': agent['name'],
'mode': mode,
'data': result
})
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 {}
try:
client = create_client_from_agent(agent)
result = client.post('/wifi/monitor', data)
# Refresh agent capabilities after monitor mode toggle so UI stays in sync
if result.get('status') == 'success':
try:
metadata = client.refresh_metadata()
if metadata.get('healthy'):
caps = metadata.get('capabilities') or {}
agent_interfaces = caps.get('interfaces', {})
if not agent_interfaces.get('sdr_devices') and caps.get('devices'):
agent_interfaces['sdr_devices'] = caps.get('devices', [])
update_agent(
agent_id,
capabilities=caps.get('modes'),
interfaces=agent_interfaces,
update_last_seen=True
)
except Exception:
pass # Non-fatal if refresh fails
return jsonify({
'status': result.get('status', 'error'),
'agent_id': agent_id,
'agent_name': agent['name'],
'monitor_interface': result.get('monitor_interface'),
'message': result.get('message')
})
except AgentConnectionError as e:
return api_error(f'Cannot connect to agent: {e}', 503)
except AgentHTTPError as e:
return api_error(f'Agent error: {e}', 502)
# =============================================================================
# Push Data Ingestion
# =============================================================================
@controller_bp.route('/api/ingest', methods=['POST'])
def ingest_push_data():
"""
Receive pushed data from remote agents.
Expected JSON body:
{
"agent_name": "sensor-node-1",
"scan_type": "adsb",
"interface": "rtlsdr0",
"payload": {...},
"received_at": "2024-01-15T10:30:00Z"
}
Expected header:
X-API-Key: shared-secret (if agent has api_key configured)
"""
data = request.json
if not data:
return api_error('No data provided', 400)
agent_name = data.get('agent_name')
if not agent_name:
return api_error('agent_name required', 400)
# Find agent
agent = get_agent_by_name(agent_name)
if not agent:
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 api_error('Invalid API key', 401)
# Store payload
try:
payload_id = store_push_payload(
agent_id=agent['id'],
scan_type=data.get('scan_type', 'unknown'),
payload=data.get('payload', {}),
interface=data.get('interface'),
received_at=data.get('received_at')
)
# Emit to SSE stream (fanout to all connected clients)
_broadcast_agent_data({
'type': 'agent_data',
'agent_id': agent['id'],
'agent_name': agent_name,
'scan_type': data.get('scan_type'),
'interface': data.get('interface'),
'payload': data.get('payload'),
'received_at': data.get('received_at') or datetime.now(timezone.utc).isoformat()
})
return jsonify({
'status': 'accepted',
'payload_id': payload_id
}), 202
except Exception as e:
logger.exception("Failed to store push payload")
return api_error(str(e), 500)
@controller_bp.route('/api/payloads', methods=['GET'])
def get_payloads():
"""Get recent push payloads."""
agent_id = request.args.get('agent_id', type=int)
scan_type = request.args.get('scan_type')
limit = request.args.get('limit', 100, type=int)
payloads = get_recent_payloads(
agent_id=agent_id,
scan_type=scan_type,
limit=min(limit, 1000)
)
return jsonify({
'status': 'success',
'payloads': payloads,
'count': len(payloads)
})
# =============================================================================
# Multi-Agent SSE Stream
# =============================================================================
@controller_bp.route('/stream/all')
def stream_all_agents():
"""
Combined SSE stream for data from all agents.
This endpoint streams push data as it arrives from agents.
Each message is tagged with agent_id and agent_name.
"""
client_queue: queue.Queue = queue.Queue(maxsize=_AGENT_STREAM_CLIENT_QUEUE_SIZE)
with _agent_stream_subscribers_lock:
_agent_stream_subscribers.add(client_queue)
def generate() -> Generator[str, None, None]:
last_keepalive = time.time()
keepalive_interval = 30.0
try:
while True:
try:
msg = client_queue.get(timeout=1.0)
last_keepalive = time.time()
yield format_sse(msg)
except queue.Empty:
now = time.time()
if now - last_keepalive >= keepalive_interval:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
finally:
with _agent_stream_subscribers_lock:
_agent_stream_subscribers.discard(client_queue)
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
# =============================================================================
# Agent Management Page
# =============================================================================
@controller_bp.route('/manage')
def agent_management_page():
"""Render the agent management page."""
from flask import render_template
from config import VERSION
return render_template('agents.html', version=VERSION)
@controller_bp.route('/monitor')
def network_monitor_page():
"""Render the network monitor page for multi-agent aggregated view."""
from flask import render_template
return render_template('network_monitor.html')
# =============================================================================
# Device Location Estimation (Trilateration)
# =============================================================================
# Global device location tracker
device_tracker = DeviceLocationTracker(
trilateration=Trilateration(
path_loss_model=PathLossModel('outdoor'),
min_observations=2
),
observation_window_seconds=120.0, # 2 minute window
min_observations=2
)
@controller_bp.route('/api/location/observe', methods=['POST'])
def add_location_observation():
"""
Add an observation for device location estimation.
Expected JSON body:
{
"device_id": "AA:BB:CC:DD:EE:FF",
"agent_name": "sensor-node-1",
"agent_lat": 40.7128,
"agent_lon": -74.0060,
"rssi": -55,
"frequency_mhz": 2400 (optional)
}
Returns location estimate if enough data, null otherwise.
"""
data = request.json or {}
required = ['device_id', 'agent_name', 'agent_lat', 'agent_lon', 'rssi']
for field in required:
if field not in data:
return api_error(f'Missing required field: {field}', 400)
# Look up agent GPS from database if not provided
agent_lat = data.get('agent_lat')
agent_lon = data.get('agent_lon')
if agent_lat is None or agent_lon is None:
agent = get_agent_by_name(data['agent_name'])
if agent and agent.get('gps_coords'):
coords = agent['gps_coords']
agent_lat = coords.get('lat') or coords.get('latitude')
agent_lon = coords.get('lon') or coords.get('longitude')
if agent_lat is None or agent_lon is None:
return api_error('Agent GPS coordinates required', 400)
estimate = device_tracker.add_observation(
device_id=data['device_id'],
agent_name=data['agent_name'],
agent_lat=float(agent_lat),
agent_lon=float(agent_lon),
rssi=float(data['rssi']),
frequency_mhz=data.get('frequency_mhz')
)
return jsonify({
'status': 'success',
'device_id': data['device_id'],
'location': estimate.to_dict() if estimate else None
})
@controller_bp.route('/api/location/estimate', methods=['POST'])
def estimate_location():
"""
Estimate device location from provided observations.
Expected JSON body:
{
"observations": [
{"agent_lat": 40.7128, "agent_lon": -74.0060, "rssi": -55, "agent_name": "node-1"},
{"agent_lat": 40.7135, "agent_lon": -74.0055, "rssi": -70, "agent_name": "node-2"},
{"agent_lat": 40.7120, "agent_lon": -74.0050, "rssi": -62, "agent_name": "node-3"}
],
"environment": "outdoor" (optional: outdoor, indoor, free_space)
}
"""
data = request.json or {}
observations = data.get('observations', [])
if len(observations) < 2:
return api_error('At least 2 observations required', 400)
environment = data.get('environment', 'outdoor')
try:
result = estimate_location_from_observations(observations, environment)
return jsonify({
'status': 'success' if result else 'insufficient_data',
'location': result
})
except Exception as e:
logger.exception("Location estimation failed")
return api_error(str(e), 500)
@controller_bp.route('/api/location/<device_id>', methods=['GET'])
def get_device_location(device_id: str):
"""Get the latest location estimate for a device."""
estimate = device_tracker.get_location(device_id)
if not estimate:
return jsonify({
'status': 'not_found',
'device_id': device_id,
'location': None
})
return jsonify({
'status': 'success',
'device_id': device_id,
'location': estimate.to_dict()
})
@controller_bp.route('/api/location/all', methods=['GET'])
def get_all_locations():
"""Get all current device location estimates."""
locations = device_tracker.get_all_locations()
return jsonify({
'status': 'success',
'count': len(locations),
'devices': {
device_id: estimate.to_dict()
for device_id, estimate in locations.items()
}
})
@controller_bp.route('/api/location/near', methods=['GET'])
def get_devices_near():
"""
Find devices near a location.
Query params:
lat: latitude
lon: longitude
radius: radius in meters (default 100)
"""
try:
lat = float(request.args.get('lat', 0))
lon = float(request.args.get('lon', 0))
radius = float(request.args.get('radius', 100))
except (ValueError, TypeError):
return api_error('Invalid coordinates', 400)
results = device_tracker.get_devices_near(lat, lon, radius)
return jsonify({
'status': 'success',
'center': {'lat': lat, 'lon': lon},
'radius_meters': radius,
'count': len(results),
'devices': [
{'device_id': device_id, 'location': estimate.to_dict()}
for device_id, estimate in results
]
})
+97
View File
@@ -0,0 +1,97 @@
"""Device correlation routes."""
from __future__ import annotations
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')
correlation_bp = Blueprint('correlation', __name__, url_prefix='/correlation')
@correlation_bp.route('', methods=['GET'])
def get_device_correlations() -> Response:
"""
Get device correlations between WiFi and Bluetooth.
Query params:
min_confidence: Minimum confidence threshold (default 0.5)
include_historical: Include database correlations (default true)
"""
min_confidence = request.args.get('min_confidence', 0.5, type=float)
include_historical = request.args.get('include_historical', 'true').lower() == 'true'
try:
# Get current device data
wifi_devices = dict(app_module.wifi_networks)
wifi_devices.update(dict(app_module.wifi_clients))
bt_devices = dict(app_module.bt_devices)
# Calculate correlations
correlations = get_correlations(
wifi_devices=wifi_devices,
bt_devices=bt_devices,
min_confidence=min_confidence,
include_historical=include_historical
)
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 api_error(str(e), 500)
@correlation_bp.route('/analyze', methods=['POST'])
def analyze_correlation() -> Response:
"""
Analyze specific device pair for correlation.
Request body:
wifi_mac: WiFi device MAC address
bt_mac: Bluetooth device MAC address
"""
data = request.json or {}
wifi_mac = data.get('wifi_mac')
bt_mac = data.get('bt_mac')
if not wifi_mac or not bt_mac:
return api_error('wifi_mac and bt_mac are required', 400)
try:
# Get device data
wifi_device = app_module.wifi_networks.get(wifi_mac)
if not wifi_device:
wifi_device = app_module.wifi_clients.get(wifi_mac)
bt_device = app_module.bt_devices.get(bt_mac)
if not wifi_device:
return api_error(f'WiFi device {wifi_mac} not found', 404)
if not bt_device:
return api_error(f'Bluetooth device {bt_mac} not found', 404)
# Calculate correlation for this specific pair
correlations = get_correlations(
wifi_devices={wifi_mac: wifi_device},
bt_devices={bt_mac: bt_device},
min_confidence=0.0, # Show even low confidence for analysis
include_historical=True
)
if correlations:
return api_success(data={'correlation': correlations[0]})
else:
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 api_error(str(e), 500)
+646
View File
@@ -0,0 +1,646 @@
"""VHF DSC (Digital Selective Calling) routes.
DSC operates on VHF Channel 70 (156.525 MHz) for maritime
distress and safety communications per ITU-R M.493.
"""
from __future__ import annotations
import contextlib
import logging
import os
import pty
import queue
import select
import shutil
import subprocess
import threading
import time
from typing import Any
from flask import Blueprint, Response, jsonify, request
import app as app_module
from utils.constants import (
DSC_SAMPLE_RATE,
DSC_TERMINATE_TIMEOUT,
DSC_VHF_FREQUENCY_MHZ,
)
from utils.database import (
acknowledge_dsc_alert,
get_dsc_alert,
get_dsc_alert_summary,
get_dsc_alerts,
store_dsc_alert,
)
from utils.dependencies import get_tool_path
from utils.dsc.parser import parse_dsc_message
from utils.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')
dsc_bp = Blueprint('dsc', __name__, url_prefix='/dsc')
# Module state (track if running independent of process state)
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:
"""Get path to DSC decoder."""
# Check for our custom decoder
project_bin = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'bin', 'dsc-decoder')
if os.path.isfile(project_bin) and os.access(project_bin, os.X_OK):
return project_bin
# Check system PATH
system_decoder = shutil.which('dsc-decoder')
if system_decoder:
return system_decoder
return None
def _check_dsc_tools() -> dict:
"""Check availability of DSC decoding tools."""
rtl_fm_path = get_tool_path('rtl_fm')
decoder_path = _get_dsc_decoder_path()
# Check for scipy/numpy (needed for decoder)
scipy_available = False
try:
import numpy
import scipy
scipy_available = True
except ImportError:
pass
return {
'rtl_fm': {
'available': rtl_fm_path is not None,
'path': rtl_fm_path
},
'dsc_decoder': {
'available': decoder_path is not None,
'path': decoder_path
},
'scipy': {
'available': scipy_available,
'note': 'Required for DSC signal processing'
},
'ready': rtl_fm_path is not None and decoder_path is not None and scipy_available
}
def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> None:
"""
Stream DSC decoder output to queue using PTY for unbuffered output.
Args:
master_fd: PTY master file descriptor
decoder_process: Decoder subprocess
"""
global dsc_running
try:
app_module.dsc_queue.put({'type': 'status', 'status': 'started'})
buffer = ""
while dsc_running:
try:
ready, _, _ = select.select([master_fd], [], [], 1.0)
except Exception:
break
if ready:
try:
data = os.read(master_fd, 1024)
if not data:
break
buffer += data.decode('utf-8', errors='replace')
while '\n' in buffer:
line, buffer = buffer.split('\n', 1)
line = line.strip()
if not line:
continue
# Parse DSC message
parsed = parse_dsc_message(line)
if parsed:
# Generate unique message ID
msg_id = f"{parsed['source_mmsi']}_{int(time.time() * 1000)}"
parsed['id'] = msg_id
# Store in transient DataStore
app_module.dsc_messages.set(msg_id, parsed)
# Queue for SSE
try:
app_module.dsc_queue.put_nowait(parsed)
except queue.Full:
logger.warning("DSC queue full, dropping message")
# Store critical alerts permanently
if parsed.get('is_critical'):
_store_critical_alert(parsed)
else:
# Raw output for debugging
app_module.dsc_queue.put({
'type': 'raw',
'text': line
})
except OSError:
break
# Check if process is still running
if decoder_process.poll() is not None:
break
except Exception as e:
logger.error(f"DSC decoder error: {e}")
app_module.dsc_queue.put({
'type': 'error',
'error': str(e)
})
finally:
global dsc_active_device, dsc_active_sdr_type
with contextlib.suppress(OSError):
os.close(master_fd)
dsc_running = False
# Cleanup both processes
with app_module.dsc_lock:
rtl_proc = app_module.dsc_rtl_process
for proc in [rtl_proc, decoder_process]:
if proc:
try:
proc.terminate()
proc.wait(timeout=2)
except Exception:
with contextlib.suppress(Exception):
proc.kill()
unregister_process(proc)
app_module.dsc_queue.put({'type': 'status', 'status': 'stopped'})
with app_module.dsc_lock:
app_module.dsc_process = None
app_module.dsc_rtl_process = None
# Release SDR device
if dsc_active_device is not None:
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:
"""Store critical DSC alert (DISTRESS/URGENCY) to database."""
try:
store_dsc_alert(
source_mmsi=msg.get('source_mmsi', ''),
format_code=str(msg.get('format_code', '')),
category=msg.get('category', 'UNKNOWN'),
source_name=msg.get('source_name'),
dest_mmsi=msg.get('dest_mmsi'),
nature_of_distress=msg.get('nature_of_distress'),
latitude=msg.get('latitude'),
longitude=msg.get('longitude'),
raw_message=msg.get('raw_message')
)
logger.info(f"Stored {msg.get('category')} alert from {msg.get('source_mmsi')}")
except Exception as e:
logger.error(f"Failed to store DSC alert: {e}")
def monitor_rtl_stderr(process: subprocess.Popen) -> None:
"""Monitor rtl_fm stderr for errors."""
global dsc_running
try:
for line in process.stderr:
if not dsc_running:
break
err_text = line.decode('utf-8', errors='replace').strip()
if err_text:
logger.debug(f"[RTL_FM] {err_text}")
# Check for device busy error
if 'usb_claim_interface' in err_text.lower():
app_module.dsc_queue.put({
'type': 'error',
'error': 'SDR device busy',
'error_type': 'DEVICE_BUSY',
'suggestion': 'Use a different SDR device or stop other SDR processes'
})
# Check for other common errors
if 'no supported devices' in err_text.lower():
app_module.dsc_queue.put({
'type': 'error',
'error': 'No SDR device found',
'error_type': 'NO_DEVICE'
})
except Exception:
pass
@dsc_bp.route('/status')
def get_status() -> Response:
"""Get DSC decoder status."""
global dsc_running
with app_module.dsc_lock:
running = (
dsc_running and
app_module.dsc_process is not None and
app_module.dsc_process.poll() is None
)
# Get message counts
message_count = len(app_module.dsc_messages)
alert_summary = get_dsc_alert_summary()
return jsonify({
'running': running,
'frequency': DSC_VHF_FREQUENCY_MHZ,
'message_count': message_count,
'alerts': alert_summary
})
@dsc_bp.route('/tools')
def check_tools() -> Response:
"""Check DSC decoder tool availability."""
tools = _check_dsc_tools()
return jsonify(tools)
@dsc_bp.route('/start', methods=['POST'])
def start_decoding() -> Response:
"""Start DSC decoder."""
global dsc_running
with app_module.dsc_lock:
if app_module.dsc_process and app_module.dsc_process.poll() is None:
return jsonify({
'status': 'error',
'message': 'DSC decoder already running'
}), 409
# Check tools
tools = _check_dsc_tools()
if not tools['ready']:
missing = []
if not tools['rtl_fm']['available']:
missing.append('rtl_fm')
if not tools['dsc_decoder']['available']:
missing.append('dsc-decoder')
if not tools['scipy']['available']:
missing.append('scipy/numpy')
return jsonify({
'status': 'error',
'message': f'Missing required tools: {", ".join(missing)}'
}), 400
data = request.json or {}
# Validate device
try:
device = validate_device_index(data.get('device', '0'))
except ValueError as e:
return jsonify({
'status': 'error',
'message': str(e)
}), 400
# Validate gain
try:
gain = validate_gain(data.get('gain', '40'))
except ValueError as e:
return jsonify({
'status': 'error',
'message': str(e)
}), 400
# Get SDR type from request
sdr_type_str = data.get('sdr_type', 'rtlsdr')
# 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():
try:
app_module.dsc_queue.get_nowait()
except queue.Empty:
break
# Build rtl_fm command via SDR abstraction layer
decoder_path = tools['dsc_decoder']['path']
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]
full_cmd = ' '.join(rtl_cmd) + ' | ' + ' '.join(decoder_cmd)
logger.info(f"Starting DSC decoder: {full_cmd}")
try:
# Start rtl_fm subprocess
rtl_process = subprocess.Popen(
rtl_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
register_process(rtl_process)
# Start stderr monitor thread
stderr_thread = threading.Thread(
target=monitor_rtl_stderr,
args=(rtl_process,),
daemon=True
)
stderr_thread.start()
# Create PTY for decoder output
master_fd, slave_fd = pty.openpty()
# Start decoder subprocess
decoder_process = subprocess.Popen(
decoder_cmd,
stdin=rtl_process.stdout,
stdout=slave_fd,
stderr=slave_fd,
close_fds=True
)
register_process(decoder_process)
os.close(slave_fd)
rtl_process.stdout.close()
# Store process references
app_module.dsc_process = decoder_process
app_module.dsc_rtl_process = rtl_process
dsc_running = True
# Start output streaming thread
output_thread = threading.Thread(
target=stream_dsc_decoder,
args=(master_fd, decoder_process),
daemon=True
)
output_thread.start()
return jsonify({
'status': 'started',
'frequency': DSC_VHF_FREQUENCY_MHZ,
'device': device,
'gain': gain,
'command': full_cmd
})
except FileNotFoundError as e:
# Kill orphaned rtl_fm process
try:
rtl_process.terminate()
rtl_process.wait(timeout=2)
except Exception:
with contextlib.suppress(Exception):
rtl_process.kill()
# Release device on failure
if dsc_active_device is not None:
app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
dsc_active_device = None
dsc_active_sdr_type = None
return jsonify({
'status': 'error',
'message': f'Tool not found: {e.filename}'
}), 400
except Exception as e:
# Kill orphaned rtl_fm process if it was started
try:
rtl_process.terminate()
rtl_process.wait(timeout=2)
except Exception:
with contextlib.suppress(Exception):
rtl_process.kill()
# Release device on failure
if dsc_active_device is not None:
app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
dsc_active_device = None
dsc_active_sdr_type = None
logger.error(f"Failed to start DSC decoder: {e}")
return jsonify({
'status': 'error',
'message': str(e)
}), 500
@dsc_bp.route('/stop', methods=['POST'])
def stop_decoding() -> Response:
"""Stop DSC decoder."""
global dsc_running, dsc_active_device, dsc_active_sdr_type
with app_module.dsc_lock:
if not app_module.dsc_process:
return jsonify({'status': 'not_running'})
dsc_running = False
# Terminate rtl_fm process first
if app_module.dsc_rtl_process:
try:
app_module.dsc_rtl_process.terminate()
app_module.dsc_rtl_process.wait(timeout=DSC_TERMINATE_TIMEOUT)
except subprocess.TimeoutExpired:
with contextlib.suppress(OSError):
app_module.dsc_rtl_process.kill()
except OSError:
pass
# Terminate decoder process
if app_module.dsc_process:
try:
app_module.dsc_process.terminate()
app_module.dsc_process.wait(timeout=DSC_TERMINATE_TIMEOUT)
except subprocess.TimeoutExpired:
with contextlib.suppress(OSError):
app_module.dsc_process.kill()
except OSError:
pass
app_module.dsc_process = None
app_module.dsc_rtl_process = None
# Release device from registry
if dsc_active_device is not None:
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'})
@dsc_bp.route('/stream')
def stream() -> Response:
"""SSE stream for real-time DSC messages."""
def _on_msg(msg: dict[str, Any]) -> None:
process_event('dsc', msg, msg.get('type'))
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'
return response
@dsc_bp.route('/messages')
def get_messages() -> Response:
"""Get current DSC messages from transient store."""
messages = list(app_module.dsc_messages.values())
# Sort by timestamp (newest first)
messages.sort(key=lambda m: m.get('timestamp', ''), reverse=True)
return jsonify({
'count': len(messages),
'messages': messages
})
@dsc_bp.route('/alerts')
def get_alerts_endpoint() -> Response:
"""Get stored DSC alerts (paginated)."""
# Parse query params
category = request.args.get('category')
acknowledged = request.args.get('acknowledged')
limit = min(int(request.args.get('limit', 50)), 200)
offset = int(request.args.get('offset', 0))
# Convert acknowledged param
ack_filter = None
if acknowledged is not None:
ack_filter = acknowledged.lower() in ('true', '1', 'yes')
alerts = get_dsc_alerts(
category=category,
acknowledged=ack_filter,
limit=limit,
offset=offset
)
summary = get_dsc_alert_summary()
return jsonify({
'alerts': alerts,
'count': len(alerts),
'summary': summary,
'pagination': {
'limit': limit,
'offset': offset
}
})
@dsc_bp.route('/alerts/<int:alert_id>')
def get_alert(alert_id: int) -> Response:
"""Get a specific DSC alert by ID."""
alert = get_dsc_alert(alert_id)
if not alert:
return jsonify({
'status': 'error',
'message': 'Alert not found'
}), 404
return jsonify(alert)
@dsc_bp.route('/alerts/<int:alert_id>/acknowledge', methods=['POST'])
def acknowledge_alert(alert_id: int) -> Response:
"""Acknowledge a DSC alert."""
data = request.json or {}
notes = data.get('notes')
success = acknowledge_dsc_alert(alert_id, notes)
if not success:
return jsonify({
'status': 'error',
'message': 'Alert not found'
}), 404
return jsonify({
'status': 'acknowledged',
'alert_id': alert_id
})
@dsc_bp.route('/alerts/summary')
def get_alerts_summary() -> Response:
"""Get summary of unacknowledged DSC alerts."""
summary = get_dsc_alert_summary()
return jsonify(summary)
+135 -209
View File
@@ -1,27 +1,25 @@
"""GPS dongle routes for USB GPS device support."""
"""GPS routes for gpsd daemon support."""
from __future__ import annotations
import queue
import threading
import time
from typing import Generator
from flask import Blueprint, jsonify, request, Response
from flask import Blueprint, Response, jsonify
from utils.logging import get_logger
from utils.sse import format_sse
from utils.gps import (
detect_gps_devices,
is_serial_available,
get_gps_reader,
start_gps,
start_gpsd,
stop_gps,
get_current_position,
GPSPosition,
GPSDClient,
GPSSkyData,
detect_gps_devices,
get_current_position,
get_gps_reader,
is_gpsd_running,
start_gpsd,
start_gpsd_daemon,
stop_gps,
stop_gpsd_daemon,
)
from utils.logging import get_logger
from utils.sse import sse_stream_fanout
logger = get_logger('intercept.gps')
@@ -34,197 +32,130 @@ _gps_queue: queue.Queue = queue.Queue(maxsize=100)
def _position_callback(position: GPSPosition) -> None:
"""Callback to queue position updates for SSE stream."""
try:
_gps_queue.put_nowait(position.to_dict())
_gps_queue.put_nowait({'type': 'position', **position.to_dict()})
except queue.Full:
# Discard oldest if queue is full
try:
_gps_queue.get_nowait()
_gps_queue.put_nowait(position.to_dict())
_gps_queue.put_nowait({'type': 'position', **position.to_dict()})
except queue.Empty:
pass
@gps_bp.route('/available')
def check_gps_available():
"""Check if GPS dongle support is available."""
return jsonify({
'available': is_serial_available(),
'message': None if is_serial_available() else 'pyserial not installed - run: pip install pyserial'
})
@gps_bp.route('/gpsd/check')
def check_gpsd_available():
"""Check if gpsd is reachable."""
import socket
host = request.args.get('host', 'localhost')
port = int(request.args.get('port', 2947))
def _sky_callback(sky: GPSSkyData) -> None:
"""Callback to queue sky data updates for SSE stream."""
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(2.0)
sock.connect((host, port))
sock.close()
_gps_queue.put_nowait({'type': 'sky', **sky.to_dict()})
except queue.Full:
try:
_gps_queue.get_nowait()
_gps_queue.put_nowait({'type': 'sky', **sky.to_dict()})
except queue.Empty:
pass
@gps_bp.route('/auto-connect', methods=['POST'])
def auto_connect_gps():
"""
Automatically connect to gpsd if available.
Called on page load to seamlessly enable GPS if gpsd is running.
If gpsd is not running, attempts to detect GPS devices and start gpsd.
Returns current status if already connected.
"""
# 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({
'available': True,
'host': host,
'port': port,
'message': f'gpsd reachable at {host}:{port}'
'status': 'connected',
'source': 'gpsd',
'has_fix': position is not None,
'position': position.to_dict() if position else None,
'sky': sky.to_dict() if sky else None,
})
except Exception as e:
host = 'localhost'
port = 2947
# If gpsd isn't running, try to detect a device and start it
if not is_gpsd_running(host, port):
devices = detect_gps_devices()
if not devices:
return jsonify({
'status': 'unavailable',
'message': 'No GPS device detected'
})
# Try to start gpsd with the first detected device
device_path = devices[0]['path']
success, msg = start_gpsd_daemon(device_path, host, port)
if not success:
return jsonify({
'status': 'unavailable',
'message': msg,
'devices': devices,
})
logger.info(f"Auto-started gpsd on {device_path}")
# Clear the queue
while not _gps_queue.empty():
try:
_gps_queue.get_nowait()
except queue.Empty:
break
# Start the gpsd client
success = start_gpsd(host, port,
callback=_position_callback,
sky_callback=_sky_callback)
if success:
return jsonify({
'available': False,
'host': host,
'port': port,
'message': f'Cannot connect to gpsd at {host}:{port}: {e}'
'status': 'connected',
'source': 'gpsd',
'has_fix': False,
'position': None,
'sky': None,
})
else:
return jsonify({
'status': 'unavailable',
'message': 'Failed to connect to gpsd'
})
@gps_bp.route('/devices')
def list_gps_devices():
"""List available GPS serial devices."""
if not is_serial_available():
return jsonify({
'status': 'error',
'message': 'pyserial not installed'
}), 503
"""List detected GPS serial devices."""
devices = detect_gps_devices()
return jsonify({
'status': 'ok',
'devices': devices
'devices': devices,
'gpsd_running': is_gpsd_running(),
})
@gps_bp.route('/start', methods=['POST'])
def start_gps_reader():
"""Start GPS reader on specified device."""
if not is_serial_available():
return jsonify({
'status': 'error',
'message': 'pyserial not installed'
}), 503
# Check if already running
reader = get_gps_reader()
if reader and reader.is_running:
return jsonify({
'status': 'error',
'message': 'GPS reader already running'
}), 409
data = request.json or {}
device_path = data.get('device')
baudrate = data.get('baudrate', 9600)
if not device_path:
return jsonify({
'status': 'error',
'message': 'Device path required'
}), 400
# Validate baudrate
valid_baudrates = [4800, 9600, 19200, 38400, 57600, 115200]
if baudrate not in valid_baudrates:
return jsonify({
'status': 'error',
'message': f'Invalid baudrate. Valid options: {valid_baudrates}'
}), 400
# Clear the queue
while not _gps_queue.empty():
try:
_gps_queue.get_nowait()
except queue.Empty:
break
# Start the GPS reader with callback pre-registered (avoids race condition)
success = start_gps(device_path, baudrate, callback=_position_callback)
if success:
return jsonify({
'status': 'started',
'device': device_path,
'baudrate': baudrate,
'source': 'serial'
})
else:
reader = get_gps_reader()
error = reader.error if reader else 'Unknown error'
return jsonify({
'status': 'error',
'message': f'Failed to start GPS reader: {error}'
}), 500
@gps_bp.route('/gpsd/start', methods=['POST'])
def start_gpsd_client():
"""Start GPS client connected to gpsd."""
# Check if already running
reader = get_gps_reader()
if reader and reader.is_running:
return jsonify({
'status': 'error',
'message': 'GPS reader already running'
}), 409
data = request.json or {}
host = data.get('host', 'localhost')
port = data.get('port', 2947)
# Validate port
try:
port = int(port)
if not (1 <= port <= 65535):
raise ValueError("Port out of range")
except (ValueError, TypeError):
return jsonify({
'status': 'error',
'message': 'Invalid port number'
}), 400
# Clear the queue
while not _gps_queue.empty():
try:
_gps_queue.get_nowait()
except queue.Empty:
break
# Start the gpsd client with callback pre-registered
success = start_gpsd(host, port, callback=_position_callback)
if success:
return jsonify({
'status': 'started',
'host': host,
'port': port,
'source': 'gpsd'
})
else:
reader = get_gps_reader()
error = reader.error if reader else 'Unknown error'
return jsonify({
'status': 'error',
'message': f'Failed to connect to gpsd: {error}'
}), 500
@gps_bp.route('/stop', methods=['POST'])
def stop_gps_reader():
"""Stop GPS reader."""
"""Stop GPS client and gpsd daemon if we started it."""
reader = get_gps_reader()
if reader:
reader.remove_callback(_position_callback)
reader.remove_sky_callback(_sky_callback)
stop_gps()
stop_gpsd_daemon()
return jsonify({'status': 'stopped'})
@gps_bp.route('/status')
def get_gps_status():
"""Get current GPS reader status."""
"""Get current GPS client status."""
reader = get_gps_reader()
if not reader:
@@ -232,15 +163,18 @@ def get_gps_status():
'running': False,
'device': None,
'position': None,
'sky': None,
'error': None,
'message': 'GPS reader not started'
'message': 'GPS client not started'
})
position = reader.position
sky = reader.sky
return jsonify({
'running': reader.is_running,
'device': reader.device_path,
'position': position.to_dict() if position else None,
'sky': sky.to_dict() if sky else None,
'last_update': reader.last_update.isoformat() if reader.last_update else None,
'error': reader.error,
'message': 'Waiting for GPS fix - ensure GPS has clear view of sky' if reader.is_running and not position else None
@@ -262,7 +196,7 @@ def get_position():
if not reader or not reader.is_running:
return jsonify({
'status': 'error',
'message': 'GPS reader not running'
'message': 'GPS client not running'
}), 400
else:
return jsonify({
@@ -271,51 +205,43 @@ def get_position():
})
@gps_bp.route('/debug')
def debug_gps():
"""Debug endpoint showing GPS reader state."""
@gps_bp.route('/satellites')
def get_satellites():
"""Get current satellite sky view data."""
reader = get_gps_reader()
if not reader:
if not reader or not reader.is_running:
return jsonify({
'reader': None,
'message': 'No GPS reader initialized'
'status': 'waiting',
'running': False,
'message': 'GPS client not running'
})
position = reader.position
source = 'gpsd' if isinstance(reader, GPSDClient) else 'serial'
return jsonify({
'running': reader.is_running,
'source': source,
'device': reader.device_path,
'baudrate': reader.baudrate,
'has_position': position is not None,
'position': position.to_dict() if position else None,
'last_update': reader.last_update.isoformat() if reader.last_update else None,
'error': reader.error,
'callbacks_registered': len(reader._callbacks),
})
sky = reader.sky
if sky:
return jsonify({
'status': 'ok',
'sky': sky.to_dict()
})
else:
return jsonify({
'status': 'waiting',
'message': 'Waiting for satellite data'
})
@gps_bp.route('/stream')
def stream_gps():
"""SSE stream of GPS position updates."""
def generate() -> Generator[str, None, None]:
last_keepalive = time.time()
keepalive_interval = 30.0
while True:
try:
position = _gps_queue.get(timeout=1)
last_keepalive = time.time()
yield format_sse({'type': 'position', **position})
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')
"""SSE stream of GPS position and sky updates."""
response = Response(
sse_stream_fanout(
source_queue=_gps_queue,
channel_key='gps',
timeout=1.0,
keepalive_interval=30.0,
),
mimetype='text/event-stream',
)
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
+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
+1061
View File
File diff suppressed because it is too large Load Diff
+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
+155
View File
@@ -0,0 +1,155 @@
"""
Offline mode routes - Asset management and settings for offline operation.
"""
import os
from flask import Blueprint, request
from utils.database import get_setting, set_setting
from utils.responses import api_error, api_success
offline_bp = Blueprint('offline', __name__, url_prefix='/offline')
# Default offline settings
OFFLINE_DEFAULTS = {
'offline.enabled': False,
# Default to bundled assets/fonts to avoid third-party CDN privacy blocks.
'offline.assets_source': 'local',
'offline.fonts_source': 'local',
'offline.tile_provider': 'cartodb_dark_cyan',
'offline.tile_server_url': ''
}
# Asset paths to check
ASSET_PATHS = {
'leaflet': [
'static/vendor/leaflet/leaflet.js',
'static/vendor/leaflet/leaflet.css'
],
'chartjs': [
'static/vendor/chartjs/chart.umd.min.js'
],
'inter': [
'static/vendor/fonts/Inter-Regular.woff2',
'static/vendor/fonts/Inter-Medium.woff2',
'static/vendor/fonts/Inter-SemiBold.woff2',
'static/vendor/fonts/Inter-Bold.woff2'
],
'jetbrains': [
'static/vendor/fonts/JetBrainsMono-Regular.woff2',
'static/vendor/fonts/JetBrainsMono-Medium.woff2',
'static/vendor/fonts/JetBrainsMono-SemiBold.woff2',
'static/vendor/fonts/JetBrainsMono-Bold.woff2'
],
'leaflet_images': [
'static/vendor/leaflet/images/marker-icon.png',
'static/vendor/leaflet/images/marker-icon-2x.png',
'static/vendor/leaflet/images/marker-shadow.png',
'static/vendor/leaflet/images/layers.png',
'static/vendor/leaflet/images/layers-2x.png'
],
'leaflet_heat': [
'static/vendor/leaflet-heat/leaflet-heat.js'
]
}
def get_offline_settings():
"""Get all offline settings with defaults."""
settings = {}
for key, default in OFFLINE_DEFAULTS.items():
settings[key] = get_setting(key, default)
return settings
@offline_bp.route('/settings', methods=['GET'])
def get_settings():
"""Get current offline settings."""
settings = get_offline_settings()
return api_success(data={'settings': settings})
@offline_bp.route('/settings', methods=['POST'])
def save_setting():
"""Save an offline setting."""
data = request.get_json()
if not data or 'key' not in data or 'value' not in data:
return api_error('Missing key or value', 400)
key = data['key']
value = data['value']
# Validate key is an allowed setting
if key not in OFFLINE_DEFAULTS:
return api_error(f'Unknown setting: {key}', 400)
# Validate value type matches default
default_type = type(OFFLINE_DEFAULTS[key])
if not isinstance(value, default_type):
# Try to convert
try:
if default_type == bool:
value = str(value).lower() in ('true', '1', 'yes')
else:
value = default_type(value)
except (ValueError, TypeError):
return api_error(f'Invalid value type for {key}', 400)
set_setting(key, value)
return api_success(data={'key': key, 'value': value})
@offline_bp.route('/status', methods=['GET'])
def get_status():
"""Check status of local assets."""
# Get the app root directory
app_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
results = {}
all_available = True
for asset_name, paths in ASSET_PATHS.items():
available = True
missing = []
for path in paths:
full_path = os.path.join(app_root, path)
if not os.path.exists(full_path):
available = False
missing.append(path)
results[asset_name] = {
'available': available,
'missing': missing if not available else []
}
if not available:
all_available = False
return api_success(data={
'all_available': all_available,
'assets': results,
'offline_enabled': get_setting('offline.enabled', False)
})
@offline_bp.route('/check-asset', methods=['GET'])
def check_asset():
"""Check if a specific asset file exists."""
path = request.args.get('path', '')
if not path:
return api_error('Missing path parameter', 400)
# Security: only allow checking within static/vendor
if not path.startswith('/static/vendor/'):
return api_error('Invalid path', 400)
# Remove leading slash and construct full path
relative_path = path.lstrip('/')
app_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
full_path = os.path.join(app_root, relative_path)
exists = os.path.exists(full_path)
return api_success(data={'path': path, 'exists': exists})
+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
+235 -55
View File
@@ -2,32 +2,46 @@
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.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_frequency, validate_device_index, validate_gain, validate_ppm,
validate_rtl_tcp_host, validate_rtl_tcp_port
validate_device_index,
validate_frequency,
validate_gain,
validate_ppm,
validate_rtl_tcp_host,
validate_rtl_tcp_port,
)
from utils.sse import format_sse
from utils.process import safe_terminate, register_process
from utils.sdr import SDRFactory, SDRType, SDRValidationError
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:
"""Parse multimon-ng output line."""
@@ -47,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*$',
@@ -101,6 +129,75 @@ 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,
output_queue: queue.Queue,
stop_event: threading.Event,
) -> None:
"""Relay audio from rtl_fm to multimon-ng while computing signal levels.
Reads raw 16-bit LE PCM from *rtl_stdout*, writes every chunk straight
through to *multimon_stdin*, and every ~100 ms pushes an RMS / peak scope
event plus a compact waveform sample onto *output_queue*.
"""
CHUNK = 4096 # bytes 2048 samples at 16-bit mono
INTERVAL = 0.1 # seconds between scope updates
last_scope = time.monotonic()
try:
while not stop_event.is_set():
data = rtl_stdout.read(CHUNK)
if not data:
break
# Forward audio untouched
try:
multimon_stdin.write(data)
multimon_stdin.flush()
except (BrokenPipeError, OSError):
break
# Compute scope levels every ~100 ms
now = time.monotonic()
if now - last_scope >= INTERVAL:
last_scope = now
try:
n_samples = len(data) // 2
if n_samples == 0:
continue
samples = struct.unpack(f'<{n_samples}h', data[:n_samples * 2])
peak = max(abs(s) for s in samples)
rms = int(math.sqrt(sum(s * s for s in samples) / n_samples))
output_queue.put_nowait({
'type': 'scope',
'rms': rms,
'peak': peak,
'waveform': _encode_scope_waveform(samples),
})
except (struct.error, ValueError, queue.Full):
pass
except Exception as e:
logger.debug(f"Audio relay error: {e}")
finally:
with contextlib.suppress(OSError):
multimon_stdin.close()
def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
"""Stream decoder output to queue using PTY for unbuffered output."""
try:
@@ -142,21 +239,43 @@ 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:
try:
global pager_active_device, pager_active_sdr_type
with contextlib.suppress(OSError):
os.close(master_fd)
except OSError:
pass
process.wait()
# Signal relay thread to stop
with app_module.process_lock:
stop_relay = getattr(app_module.current_process, '_stop_relay', None)
if stop_relay:
stop_relay.set()
# Cleanup companion rtl_fm process and decoder
with app_module.process_lock:
rtl_proc = getattr(app_module.current_process, '_rtl_process', None)
for proc in [rtl_proc, process]:
if proc:
try:
proc.terminate()
proc.wait(timeout=2)
except Exception:
with contextlib.suppress(Exception):
proc.kill()
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, 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, 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 {}
@@ -167,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:
@@ -175,13 +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', sdr_type_str)
if error:
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):
return jsonify({'status': 'error', 'message': 'Protocols must be a list'}), 400
if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
pager_active_device = None
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
@@ -205,24 +344,19 @@ 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:
sdr_type = SDRType.RTL_SDR
# 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:
# 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
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}")
@@ -233,6 +367,7 @@ def start_decoding() -> Response:
builder = SDRFactory.get_builder(sdr_device.sdr_type)
# Build FM demodulation command
bias_t = data.get('bias_t', False)
rtl_cmd = builder.build_fm_demod_command(
device=sdr_device,
frequency_mhz=freq,
@@ -240,10 +375,14 @@ def start_decoding() -> Response:
gain=float(gain) if gain and gain != '0' else None,
ppm=int(ppm) if ppm and ppm != '0' else None,
modulation='fm',
squelch=squelch if squelch and squelch != 0 else None
squelch=squelch if squelch and squelch != 0 else None,
bias_t=bias_t
)
multimon_cmd = ['multimon-ng', '-t', 'raw'] + decoders + ['-f', 'alpha', '-']
multimon_path = get_tool_path('multimon-ng')
if not multimon_path:
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)
logger.info(f"Running: {full_cmd}")
@@ -255,6 +394,7 @@ def start_decoding() -> Response:
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
register_process(rtl_process)
# Start a thread to monitor rtl_fm stderr for errors
def monitor_rtl_stderr():
@@ -273,18 +413,30 @@ def start_decoding() -> Response:
multimon_process = subprocess.Popen(
multimon_cmd,
stdin=rtl_process.stdout,
stdin=subprocess.PIPE,
stdout=slave_fd,
stderr=slave_fd,
close_fds=True
)
register_process(multimon_process)
os.close(slave_fd)
rtl_process.stdout.close()
# Spawn audio relay thread between rtl_fm and multimon-ng
stop_relay = threading.Event()
relay = threading.Thread(
target=audio_relay_thread,
args=(rtl_process.stdout, multimon_process.stdin,
app_module.output_queue, stop_relay),
)
relay.daemon = True
relay.start()
app_module.current_process = multimon_process
app_module.current_process._rtl_process = rtl_process
app_module.current_process._master_fd = master_fd
app_module.current_process._stop_relay = stop_relay
app_module.current_process._relay_thread = relay
# Start output thread with PTY master fd
thread = threading.Thread(target=stream_decoder, args=(master_fd, multimon_process))
@@ -296,32 +448,58 @@ def start_decoding() -> Response:
return jsonify({'status': 'started', 'command': full_cmd})
except FileNotFoundError as e:
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'})
# Kill orphaned rtl_fm process
try:
rtl_process.terminate()
rtl_process.wait(timeout=2)
except Exception:
with contextlib.suppress(Exception):
rtl_process.kill()
# Release device on failure
if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
pager_active_device = None
pager_active_sdr_type = None
return api_error(f'Tool not found: {e.filename}')
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)})
# Kill orphaned rtl_fm process if it was started
try:
rtl_process.terminate()
rtl_process.wait(timeout=2)
except Exception:
with contextlib.suppress(Exception):
rtl_process.kill()
# Release device on failure
if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
pager_active_device = None
pager_active_sdr_type = None
return api_error(str(e))
@pager_bp.route('/stop', methods=['POST'])
def stop_decoding() -> Response:
global pager_active_device, pager_active_sdr_type
with app_module.process_lock:
if app_module.current_process:
# Signal audio relay thread to stop
if hasattr(app_module.current_process, '_stop_relay'):
app_module.current_process._stop_relay.set()
# Kill rtl_fm process first
if hasattr(app_module.current_process, '_rtl_process'):
try:
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()
@@ -331,6 +509,13 @@ def stop_decoding() -> Response:
app_module.current_process.kill()
app_module.current_process = None
# Release device from registry
if pager_active_device is not None:
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'})
return jsonify({'status': 'not_running'})
@@ -365,40 +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()
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),
})
+159
View File
@@ -0,0 +1,159 @@
"""Session recording API endpoints."""
from __future__ import annotations
import json
from pathlib import Path
from flask import Blueprint, request, send_file
from utils.recording import RECORDING_ROOT, get_recording_manager
from utils.responses import api_error, api_success
recordings_bp = Blueprint('recordings', __name__, url_prefix='/recordings')
@recordings_bp.route('/start', methods=['POST'])
def start_recording():
data = request.get_json() or {}
mode = (data.get('mode') or '').strip()
if not mode:
return api_error('mode is required', 400)
label = data.get('label')
metadata = data.get('metadata') if isinstance(data.get('metadata'), dict) else {}
manager = get_recording_manager()
session = manager.start_recording(mode=mode, label=label, metadata=metadata)
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'])
def stop_recording():
data = request.get_json() or {}
mode = data.get('mode')
session_id = data.get('id')
manager = get_recording_manager()
session = manager.stop_recording(mode=mode, session_id=session_id)
if not session:
return api_error('No active recording found', 404)
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 api_success(data={
'recordings': manager.list_recordings(limit=limit),
'active': manager.get_active(),
})
@recordings_bp.route('/<session_id>', methods=['GET'])
def get_recording(session_id: str):
manager = get_recording_manager()
rec = manager.get_recording(session_id)
if not rec:
return api_error('Recording not found', 404)
return api_success(data={'recording': rec})
@recordings_bp.route('/<session_id>/download', methods=['GET'])
def download_recording(session_id: str):
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)
return send_file(
file_path,
mimetype='application/x-ndjson',
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,
})
+317
View File
@@ -0,0 +1,317 @@
"""RTLAMR utility meter monitoring routes."""
from __future__ import annotations
import contextlib
import json
import queue
import subprocess
import threading
import time
from datetime import datetime
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
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__)
# Store rtl_tcp process separately
rtl_tcp_process = None
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:
"""Stream rtlamr JSON output to queue."""
try:
app_module.rtlamr_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:
# rtlamr outputs JSON objects, one per line
data = json.loads(line)
data['type'] = 'rtlamr'
app_module.rtlamr_queue.put(data)
# 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} | RTLAMR | {json.dumps(data)}\n")
except Exception:
pass
except json.JSONDecodeError:
# Not JSON, send as raw
app_module.rtlamr_queue.put({'type': 'raw', 'text': line})
except Exception as e:
app_module.rtlamr_queue.put({'type': 'error', 'text': str(e)})
finally:
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:
with contextlib.suppress(Exception):
process.kill()
unregister_process(process)
# Kill companion rtl_tcp process
with rtl_tcp_lock:
if rtl_tcp_process:
try:
rtl_tcp_process.terminate()
rtl_tcp_process.wait(timeout=2)
except Exception:
with contextlib.suppress(Exception):
rtl_tcp_process.kill()
unregister_process(rtl_tcp_process)
rtl_tcp_process = None
app_module.rtlamr_queue.put({'type': 'status', 'text': 'stopped'})
with app_module.rtlamr_lock:
app_module.rtlamr_process = None
# Release SDR device
if rtlamr_active_device is not None:
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, rtlamr_active_sdr_type
with app_module.rtlamr_lock:
if app_module.rtlamr_process:
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:
freq = validate_frequency(data.get('frequency', '912.0'))
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 if device is available
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'rtlamr', sdr_type_str)
if error:
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():
try:
app_module.rtlamr_queue.get_nowait()
except queue.Empty:
break
# 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)
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, rtlamr_active_sdr_type)
rtlamr_active_device = None
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 = [
'rtlamr',
'-server=127.0.0.1:1234',
f'-msgtype={msgtype}',
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')
full_cmd = ' '.join(cmd)
logger.info(f"Running: {full_cmd}")
try:
app_module.rtlamr_process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
register_process(app_module.rtlamr_process)
# Start output thread
thread = threading.Thread(target=stream_rtlamr_output, args=(app_module.rtlamr_process,))
thread.daemon = True
thread.start()
# Monitor stderr
def monitor_stderr():
for line in app_module.rtlamr_process.stderr:
err = line.decode('utf-8', errors='replace').strip()
if err:
logger.debug(f"[rtlamr] {err}")
app_module.rtlamr_queue.put({'type': 'info', 'text': f'[rtlamr] {err}'})
stderr_thread = threading.Thread(target=monitor_stderr)
stderr_thread.daemon = True
stderr_thread.start()
app_module.rtlamr_queue.put({'type': 'info', 'text': f'Command: {full_cmd}'})
return jsonify({'status': 'started', 'command': full_cmd})
except FileNotFoundError:
# If rtlamr fails, clean up rtl_tcp and release device
with rtl_tcp_lock:
if rtl_tcp_process:
rtl_tcp_process.terminate()
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, rtlamr_active_sdr_type)
rtlamr_active_device = None
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:
if rtl_tcp_process:
rtl_tcp_process.terminate()
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, rtlamr_active_sdr_type)
rtlamr_active_device = None
return api_error(str(e))
@rtlamr_bp.route('/stop_rtlamr', methods=['POST'])
def stop_rtlamr() -> Response:
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:
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:
tcp_proc = rtl_tcp_process
rtl_tcp_process = None
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, rtlamr_active_sdr_type)
rtlamr_active_device = None
return jsonify({'status': 'stopped'})
@rtlamr_bp.route('/stream_rtlamr')
def stream_rtlamr() -> Response:
def _on_msg(msg: dict[str, Any]) -> None:
process_event('rtlamr', msg, msg.get('type'))
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'
return response
+345 -64
View File
@@ -2,20 +2,39 @@
from __future__ import annotations
import json
import math
import urllib.request
from datetime import datetime, timedelta
from typing import Any
from urllib.parse import urlparse
from flask import Blueprint, jsonify, request, render_template, Response
import requests
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 (
add_tracked_satellite,
bulk_add_tracked_satellites,
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
@@ -26,18 +45,148 @@ ALLOWED_TLE_HOSTS = ['celestrak.org', 'celestrak.com', 'www.celestrak.org', 'www
_tle_cache = dict(TLE_SATELLITES)
def _load_db_satellites_into_cache():
"""Load user-tracked satellites from DB into the TLE cache."""
global _tle_cache
try:
db_sats = get_tracked_satellites()
loaded = 0
for sat in db_sats:
if sat['tle_line1'] and sat['tle_line2']:
# Use a cache key derived from name (sanitised)
cache_key = sat['name'].replace(' ', '-').upper()
if cache_key not in _tle_cache:
_tle_cache[cache_key] = (sat['name'], sat['tle_line1'], sat['tle_line2'])
loaded += 1
if loaded:
logger.info(f"Loaded {loaded} user-tracked satellites into TLE cache")
except Exception as e:
logger.warning(f"Failed to load DB satellites into TLE cache: {e}")
def init_tle_auto_refresh():
"""Initialize TLE auto-refresh. Called by app.py after initialization."""
import threading
def _auto_refresh_tle():
try:
_load_db_satellites_into_cache()
updated = refresh_tle_data()
if updated:
logger.info(f"Auto-refreshed TLE data for: {', '.join(updated)}")
except Exception as e:
logger.warning(f"Auto TLE refresh failed: {e}")
# Start auto-refresh in background
threading.Timer(2.0, _auto_refresh_tle).start()
logger.info("TLE auto-refresh scheduled")
def _fetch_iss_realtime(observer_lat: float | None = None, observer_lon: float | None = None) -> dict | None:
"""
Fetch real-time ISS position from external APIs.
Returns position data dict or None if all APIs fail.
"""
iss_lat = None
iss_lon = None
iss_alt = 420 # Default altitude in km
source = None
# 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'])
iss_alt = float(data.get('altitude', 420))
source = 'wheretheiss'
except Exception as e:
logger.debug(f"Where The ISS At API failed: {e}")
# Try fallback API: Open Notify
if iss_lat is None:
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'])
source = 'open-notify'
except Exception as e:
logger.debug(f"Open Notify API failed: {e}")
if iss_lat is None:
return None
result = {
'satellite': 'ISS',
'lat': iss_lat,
'lon': iss_lon,
'altitude': iss_alt,
'source': source
}
# Calculate observer-relative data if location provided
if observer_lat is not None and observer_lon is not None:
# Earth radius in km
earth_radius = 6371
# Convert to radians
lat1 = math.radians(observer_lat)
lat2 = math.radians(iss_lat)
lon1 = math.radians(observer_lon)
lon2 = math.radians(iss_lon)
# Haversine for ground distance
dlat = lat2 - lat1
dlon = lon2 - lon1
a = math.sin(dlat/2)**2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon/2)**2
c = 2 * math.asin(math.sqrt(a))
ground_distance = earth_radius * c
# Calculate slant range
slant_range = math.sqrt(ground_distance**2 + iss_alt**2)
# Calculate elevation angle (simplified)
if ground_distance > 0:
elevation = math.degrees(math.atan2(iss_alt - (ground_distance**2 / (2 * earth_radius)), ground_distance))
else:
elevation = 90.0
# Calculate azimuth
y = math.sin(dlon) * math.cos(lat2)
x = math.cos(lat1) * math.sin(lat2) - math.sin(lat1) * math.cos(lat2) * math.cos(dlon)
azimuth = math.degrees(math.atan2(y, x))
azimuth = (azimuth + 360) % 360
result['elevation'] = round(elevation, 1)
result['azimuth'] = round(azimuth, 1)
result['distance'] = round(slant_range, 1)
result['visible'] = elevation > 0
return result
@satellite_bp.route('/dashboard')
def satellite_dashboard():
"""Popout satellite tracking dashboard."""
return render_template('satellite_dashboard.html')
embedded = request.args.get('embedded', 'false') == 'true'
return render_template(
'satellite_dashboard.html',
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
embedded=embedded,
)
@satellite_bp.route('/predict', methods=['POST'])
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',
@@ -53,19 +202,15 @@ 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',
25338: 'NOAA-15',
28654: 'NOAA-18',
33591: 'NOAA-19',
43013: 'NOAA-20',
40069: 'METEOR-M2',
57166: 'METEOR-M2-3'
}
sat_input = data.get('satellites', ['ISS', 'NOAA-15', 'NOAA-18', 'NOAA-19'])
sat_input = data.get('satellites', ['ISS', 'METEOR-M2', 'METEOR-M2-3'])
satellites = []
for sat in sat_input:
if isinstance(sat, int) and sat in norad_to_name:
@@ -76,16 +221,12 @@ def predict_passes():
passes = []
colors = {
'ISS': '#00ffff',
'NOAA-15': '#00ff00',
'NOAA-18': '#ff6600',
'NOAA-19': '#ff3366',
'NOAA-20': '#00ffaa',
'METEOR-M2': '#9370DB',
'METEOR-M2-3': '#ff00ff'
}
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()
@@ -198,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 {}
@@ -209,17 +350,13 @@ 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))
norad_to_name = {
25544: 'ISS',
25338: 'NOAA-15',
28654: 'NOAA-18',
33591: 'NOAA-19',
43013: 'NOAA-20',
40069: 'METEOR-M2',
57166: 'METEOR-M2-3'
}
@@ -231,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()
@@ -239,6 +376,35 @@ def get_satellite_position():
positions = []
for sat_name in satellites:
# Special handling for ISS - use real-time API for accurate position
if sat_name == 'ISS':
iss_data = _fetch_iss_realtime(lat, lon)
if iss_data:
# Add orbit track if requested (using TLE for track prediction)
if include_track and 'ISS' in _tle_cache:
try:
tle_data = _tle_cache['ISS']
satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts)
orbit_track = []
for minutes_offset in range(-45, 46, 1):
t_point = ts.utc(now_dt + timedelta(minutes=minutes_offset))
try:
geo = satellite.at(t_point)
sp = wgs84.subpoint(geo)
orbit_track.append({
'lat': float(sp.latitude.degrees),
'lon': float(sp.longitude.degrees),
'past': minutes_offset < 0
})
except Exception:
continue
iss_data['track'] = orbit_track
except Exception:
pass
positions.append(iss_data)
continue
# Other satellites - use TLE data
if sat_name not in _tle_cache:
continue
@@ -292,58 +458,73 @@ def get_satellite_position():
})
@satellite_bp.route('/update-tle', methods=['POST'])
def update_tle():
"""Update TLE data from CelesTrak."""
def refresh_tle_data() -> list:
"""
Refresh TLE data from CelesTrak.
This can be called at startup or periodically to keep TLE data fresh.
Returns list of satellite names that were updated.
"""
global _tle_cache
try:
name_mappings = {
'ISS (ZARYA)': 'ISS',
'NOAA 15': 'NOAA-15',
'NOAA 18': 'NOAA-18',
'NOAA 19': 'NOAA-19',
'METEOR-M 2': 'METEOR-M2',
'METEOR-M2 3': 'METEOR-M2-3'
}
name_mappings = {
'ISS (ZARYA)': 'ISS',
'NOAA 15': 'NOAA-15',
'NOAA 18': 'NOAA-18',
'NOAA 19': 'NOAA-19',
'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 4': 'METEOR-M2-4'
}
updated = []
updated = []
for group in ['stations', 'weather']:
url = f'https://celestrak.org/NORAD/elements/gp.php?GROUP={group}&FORMAT=tle'
try:
with urllib.request.urlopen(url, timeout=10) as response:
content = response.read().decode('utf-8')
lines = content.strip().split('\n')
for group in ['stations', 'weather', 'noaa']:
url = f'https://celestrak.org/NORAD/elements/gp.php?GROUP={group}&FORMAT=tle'
try:
with urllib.request.urlopen(url, timeout=15) as response:
content = response.read().decode('utf-8')
lines = content.strip().split('\n')
i = 0
while i + 2 < len(lines):
name = lines[i].strip()
line1 = lines[i + 1].strip()
line2 = lines[i + 2].strip()
i = 0
while i + 2 < len(lines):
name = lines[i].strip()
line1 = lines[i + 1].strip()
line2 = lines[i + 2].strip()
if not (line1.startswith('1 ') and line2.startswith('2 ')):
i += 1
continue
if not (line1.startswith('1 ') and line2.startswith('2 ')):
i += 1
continue
internal_name = name_mappings.get(name, name)
internal_name = name_mappings.get(name, name)
if internal_name in _tle_cache:
_tle_cache[internal_name] = (name, line1, line2)
if internal_name in _tle_cache:
_tle_cache[internal_name] = (name, line1, line2)
if internal_name not in updated:
updated.append(internal_name)
i += 3
except Exception as e:
logger.error(f"Error fetching {group}: {e}")
continue
i += 3
except Exception as e:
logger.warning(f"Error fetching TLE group {group}: {e}")
continue
return updated
@satellite_bp.route('/update-tle', methods=['POST'])
def update_tle():
"""Update TLE data from CelesTrak (API endpoint)."""
try:
updated = refresh_tle_data()
return jsonify({
'status': 'success',
'updated': updated
})
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)})
logger.error(f"Error updating TLE data: {e}")
return api_error('TLE update failed')
@satellite_bp.route('/celestrak/<category>')
@@ -357,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'
@@ -397,4 +578,104 @@ def fetch_celestrak(category):
})
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)})
logger.error(f"Error fetching CelesTrak data: {e}")
return api_error('Failed to fetch satellite data')
# =============================================================================
# Tracked Satellites CRUD
# =============================================================================
@satellite_bp.route('/tracked', methods=['GET'])
def list_tracked_satellites():
"""Return all tracked satellites from the database."""
enabled_only = request.args.get('enabled', '').lower() == 'true'
sats = get_tracked_satellites(enabled_only=enabled_only)
return jsonify({'status': 'success', 'satellites': sats})
@satellite_bp.route('/tracked', methods=['POST'])
def add_tracked_satellites_endpoint():
"""Add one or more tracked satellites."""
global _tle_cache
data = request.get_json(silent=True)
if not data:
return api_error('No data provided', 400)
# Accept a single satellite dict or a list
sat_list = data if isinstance(data, list) else [data]
normalized: list[dict] = []
for sat in sat_list:
norad_id = str(sat.get('norad_id', sat.get('norad', '')))
name = sat.get('name', '')
if not norad_id or not name:
continue
tle1 = sat.get('tle_line1', sat.get('tle1'))
tle2 = sat.get('tle_line2', sat.get('tle2'))
enabled = sat.get('enabled', True)
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)
# 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,
'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'])
def update_tracked_satellite_endpoint(norad_id):
"""Update the enabled state of a tracked satellite."""
data = request.json or {}
enabled = data.get('enabled')
if enabled is None:
return api_error('Missing enabled field', 400)
ok = update_tracked_satellite(str(norad_id), bool(enabled))
if ok:
return jsonify({'status': 'success'})
return api_error('Satellite not found', 404)
@satellite_bp.route('/tracked/<norad_id>', methods=['DELETE'])
def delete_tracked_satellite_endpoint(norad_id):
"""Remove a tracked satellite by NORAD ID."""
ok, msg = remove_tracked_satellite(str(norad_id))
if ok:
return jsonify({'status': 'success', 'message': msg})
status_code = 403 if 'builtin' in msg.lower() else 404
return api_error(msg, status_code)
+349 -198
View File
@@ -1,198 +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.process import safe_terminate, register_process
from utils.sdr import SDRFactory, SDRType
sensor_bp = Blueprint('sensor', __name__)
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)
# 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:
process.wait()
app_module.sensor_queue.put({'type': 'status', 'text': 'stopped'})
with app_module.sensor_lock:
app_module.sensor_process = None
@sensor_bp.route('/start_sensor', methods=['POST'])
def start_sensor() -> Response:
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
# 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
# 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:
# 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
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
)
full_cmd = ' '.join(cmd)
logger.info(f"Running: {full_cmd}")
try:
app_module.sensor_process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=1
)
# Start output thread
thread = threading.Thread(target=stream_sensor_output, args=(app_module.sensor_process,))
thread.daemon = True
thread.start()
# Monitor stderr
def monitor_stderr():
for line in app_module.sensor_process.stderr:
err = line.decode('utf-8', errors='replace').strip()
if err:
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:
return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'})
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)})
@sensor_bp.route('/stop_sensor', methods=['POST'])
def stop_sensor() -> Response:
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
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()
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})

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