Compare commits

...

255 Commits

Author SHA1 Message Date
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
Mitch Ross 1c681b6777 Merge branch 'smittix:main' into main 2026-02-22 21:35:05 -05: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
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
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
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
190 changed files with 41542 additions and 11639 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.
+32 -2
View File
@@ -1,2 +1,32 @@
# Uncomment and set to use external storage for ADS-B history # =============================================================================
# PGDATA_PATH=/mnt/external/intercept/pgdata # INTERCEPT CONTROLLER (.env)
# =============================================================================
# Copy to .env and edit for your setup
# Container timezone (e.g. America/New_York, Europe/London, Australia/Sydney)
TZ=UTC
# 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
+6 -4
View File
@@ -18,10 +18,6 @@ pager_messages.log
downloads/ downloads/
pgdata/ pgdata/
# Local data
downloads/
pgdata/
# IDE # IDE
.idea/ .idea/
.vscode/ .vscode/
@@ -58,6 +54,9 @@ intercept_agent_*.cfg
# Weather satellite runtime data (decoded images, samples, SatDump output) # Weather satellite runtime data (decoded images, samples, SatDump output)
data/weather_sat/ data/weather_sat/
# Radiosonde runtime data (station config, logs)
data/radiosonde/
# SDR capture files (large IQ recordings) # SDR capture files (large IQ recordings)
data/subghz/captures/ data/subghz/captures/
@@ -65,3 +64,6 @@ data/subghz/captures/
.env .env
.env.* .env.*
!.env.example !.env.example
# Local utility scripts
reset-sdr.*
+50
View File
@@ -2,6 +2,56 @@
All notable changes to iNTERCEPT will be documented in this file. All notable changes to iNTERCEPT will be documented in this file.
## [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 ## [2.22.3] - 2026-02-23
### Fixed ### Fixed
+21 -9
View File
@@ -25,15 +25,25 @@ docker compose --profile basic up -d --build
### Local Setup (Alternative) ### Local Setup (Alternative)
```bash ```bash
# Initial setup (installs dependencies and configures SDR tools) # First-time setup (interactive wizard with install profiles)
./setup.sh ./setup.sh
# Run the application (requires sudo for SDR/network access) # Or headless full install
./setup.sh --non-interactive
# Or install specific profiles
./setup.sh --profile=core,weather
# Run with production server (gunicorn + gevent, handles concurrent SSE/WebSocket)
sudo ./start.sh
# Or for quick local dev (Flask dev server)
sudo -E venv/bin/python intercept.py sudo -E venv/bin/python intercept.py
# Or activate venv first # Other setup utilities
source venv/bin/activate ./setup.sh --health-check # Verify installation
sudo -E python intercept.py ./setup.sh --postgres-setup # Set up ADS-B history database
./setup.sh --menu # Force interactive menu
``` ```
### Testing ### Testing
@@ -69,8 +79,10 @@ mypy .
## Architecture ## Architecture
### Entry Points ### Entry Points
- `intercept.py` - Main entry point script - `setup.sh` - Menu-driven installer with profile system (wizard, health check, PostgreSQL setup, env configurator, update, uninstall). Sources `.env` on startup via `start.sh`.
- `app.py` - Flask application initialization, global state management, process lifecycle, SSE streaming infrastructure - `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/) ### Route Blueprints (routes/)
Each signal type has its own Flask blueprint: Each signal type has its own Flask blueprint:
@@ -121,7 +133,7 @@ Each signal type has its own Flask blueprint:
### Key Patterns ### Key Patterns
**Server-Sent Events (SSE)**: All real-time features stream via SSE endpoints (`/stream_pager`, `/stream_sensor`, etc.). Pattern uses `queue.Queue` with timeout and keepalive messages. **Server-Sent Events (SSE)**: All real-time features stream via SSE endpoints (`/stream_pager`, `/stream_sensor`, etc.). Pattern uses `queue.Queue` with timeout and keepalive messages. Under gunicorn + gevent, each SSE connection is a lightweight greenlet instead of an OS thread.
**Process Management**: External decoders run as subprocesses with output threads feeding queues. Use `safe_terminate()` for cleanup. Global locks prevent race conditions. **Process Management**: External decoders run as subprocesses with output threads feeding queues. Use `safe_terminate()` for cleanup. Global locks prevent race conditions.
@@ -152,7 +164,7 @@ Each signal type has its own Flask blueprint:
- **Mode Integration**: Each mode needs entries in `index.html` at ~12 points: CSS include, welcome card, partial include, visuals container, JS include, `validModes` set, `modeGroups` map, classList toggle, `modeNames`, visuals display toggle, titles, and init call in `switchMode()` - **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 ### Docker
- `Dockerfile` - Single-stage build with all SDR tools compiled from source (dump1090, AIS-catcher, slowrx, SatDump, etc.) - `Dockerfile` - Single-stage build with all SDR tools compiled from source (dump1090, AIS-catcher, slowrx, SatDump, etc.). CMD runs `start.sh` (gunicorn + gevent)
- `docker-compose.yml` - Two profiles: `basic` (standalone) and `history` (with Postgres for ADS-B) - `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) - `build-multiarch.sh` - Multi-arch build script for amd64 + arm64 (RPi5)
- Data persisted via `./data:/app/data` volume mount - Data persisted via `./data:/app/data` volume mount
+18 -3
View File
@@ -91,7 +91,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
zlib1g-dev \ zlib1g-dev \
libzmq3-dev \ libzmq3-dev \
libpulse-dev \ libpulse-dev \
libfftw3-dev \ libfftw3-bin \
liblapack-dev \ liblapack-dev \
libglib2.0-dev \ libglib2.0-dev \
libxml2-dev \ libxml2-dev \
@@ -200,6 +200,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& make install \ && make install \
&& ldconfig \ && ldconfig \
&& rm -rf /tmp/hackrf \ && rm -rf /tmp/hackrf \
# Install radiosonde_auto_rx (weather balloon decoder)
&& 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 /opt/radiosonde_auto_rx/auto_rx \
&& cp -r . /opt/radiosonde_auto_rx/auto_rx/ \
&& chmod +x /opt/radiosonde_auto_rx/auto_rx/auto_rx.py \
&& cd /tmp \
&& rm -rf /tmp/radiosonde_auto_rx \
# Build rtlamr (utility meter decoder - requires Go) # Build rtlamr (utility meter decoder - requires Go)
&& cd /tmp \ && cd /tmp \
&& curl -fsSL "https://go.dev/dl/go1.22.5.linux-$(dpkg --print-architecture).tar.gz" | tar -C /usr/local -xz \ && curl -fsSL "https://go.dev/dl/go1.22.5.linux-$(dpkg --print-architecture).tar.gz" | tar -C /usr/local -xz \
@@ -245,11 +256,15 @@ RUN pip install --no-cache-dir -r requirements.txt
# Copy application code # Copy application code
COPY . . 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 # Create data directory for persistence
RUN mkdir -p /app/data /app/data/weather_sat RUN mkdir -p /app/data /app/data/weather_sat /app/data/radiosonde/logs
# Expose web interface port # Expose web interface port
EXPOSE 5050 EXPOSE 5050
EXPOSE 5443
# Environment variables with defaults # Environment variables with defaults
ENV INTERCEPT_HOST=0.0.0.0 \ ENV INTERCEPT_HOST=0.0.0.0 \
@@ -262,4 +277,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -sf http://localhost:5050/health || exit 1 CMD curl -sf http://localhost:5050/health || exit 1
# Run the application # Run the application
CMD ["python", "intercept.py"] CMD ["/bin/bash", "start.sh"]
+115 -32
View File
@@ -45,6 +45,7 @@ Support the developer of this open-source project
- **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng - **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng
- **Bluetooth Scanning** - Device discovery and tracker detection (with Ubertooth support) - **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 - **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 - **GPS** - Real-time GPS position tracking with live map, speed, altitude, and satellite info
- **TSCM** - Counter-surveillance with RF baseline comparison and threat detection - **TSCM** - Counter-surveillance with RF baseline comparison and threat detection
- **Meshtastic** - LoRa mesh network integration - **Meshtastic** - LoRa mesh network integration
@@ -55,14 +56,85 @@ Support the developer of this open-source project
--- ---
## Installation / Debian / Ubuntu / MacOS ## CW / Morse Decoder Notes
Live backend:
- Uses `rtl_fm` piped into `multimon-ng` (`MORSE_CW`) for real-time decode.
Recommended baseline settings:
- **Tone**: `700 Hz`
- **Bandwidth**: `200 Hz` (use `100 Hz` for crowded bands, `400 Hz` for drifting signals)
- **Threshold Mode**: `Auto`
- **WPM Mode**: `Auto`
Auto Tone Track behavior:
- Continuously measures nearby tone energy around the configured CW pitch.
- Steers the detector toward the strongest valid CW tone when signal-to-noise is sufficient.
- Use **Hold Tone Lock** to freeze tracking once the desired signal is centered.
Troubleshooting (no decode / noisy decode):
- Confirm demod path is **USB/CW-compatible** and frequency is tuned correctly.
- If multiple SDRs are connected and the selected one has no PCM output, Morse startup now auto-tries other detected SDR devices and reports the active device/serial in status logs.
- Match **tone** and **bandwidth** to the actual sidetone/pitch.
- Try **Threshold Auto** first; if needed, switch to manual threshold and recalibrate.
- Use **Reset/Calibrate** after major frequency or band condition changes.
- Raise **Minimum Signal Gate** to suppress random noise keying.
---
## Installation / Debian / Ubuntu / macOS
### Quick Start
**1. Clone and run:**
```bash ```bash
git clone https://github.com/smittix/intercept.git git clone https://github.com/smittix/intercept.git
cd intercept cd intercept
./setup.sh ./setup.sh # Interactive menu (first run launches setup wizard)
sudo -E venv/bin/python intercept.py 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 ### Docker
@@ -114,16 +186,40 @@ INTERCEPT_IMAGE=ghcr.io/youruser/intercept:latest
docker compose --profile basic up -d docker compose --profile basic up -d
``` ```
### ADS-B History (Optional) ### Environment Configuration
The ADS-B history feature persists aircraft messages to Postgres for long-term analysis. Use the **Environment Configurator** (menu option 5) to interactively set any `INTERCEPT_*` variable. Settings are saved to a `.env` file that `start.sh` sources automatically on startup.
You can also create or edit `.env` manually:
```bash
# .env (auto-loaded by start.sh)
INTERCEPT_PORT=5050
INTERCEPT_ADSB_AUTO_START=true
INTERCEPT_DEFAULT_LAT=51.5074
INTERCEPT_DEFAULT_LON=-0.1278
```
### ADS-B History (Optional)
The ADS-B history feature persists aircraft messages to PostgreSQL for long-term analysis.
**Automated setup (local install):**
```bash
./setup.sh --postgres-setup
# Or use menu option 3: Database Setup
```
This will install PostgreSQL if needed, create the database/user/tables, and write the connection settings to `.env`.
**Docker:**
```bash ```bash
# Start with ADS-B history and Postgres
docker compose --profile history up -d docker compose --profile history up -d
``` ```
Set the following environment variables (for example in a `.env` file): Set the following environment variables (in `.env`):
```bash ```bash
INTERCEPT_ADSB_HISTORY_ENABLED=true INTERCEPT_ADSB_HISTORY_ENABLED=true
@@ -134,30 +230,6 @@ INTERCEPT_ADSB_DB_USER=intercept
INTERCEPT_ADSB_DB_PASSWORD=intercept INTERCEPT_ADSB_DB_PASSWORD=intercept
``` ```
### Other ADS-B Settings
Set these as environment variables for either local installs or Docker:
| Variable | Default | Description |
|----------|---------|-------------|
| `INTERCEPT_ADSB_AUTO_START` | `false` | Auto-start ADS-B tracking when the dashboard loads |
| `INTERCEPT_SHARED_OBSERVER_LOCATION` | `true` | Share observer location across ADS-B/AIS/SSTV/Satellite modules |
**Local install example**
```bash
INTERCEPT_ADSB_AUTO_START=true \
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
sudo -E venv/bin/python intercept.py
```
**Docker example (.env)**
```bash
INTERCEPT_ADSB_AUTO_START=true
INTERCEPT_SHARED_OBSERVER_LOCATION=false
```
To store Postgres data on external storage, set `PGDATA_PATH` (defaults to `./pgdata`): To store Postgres data on external storage, set `PGDATA_PATH` (defaults to `./pgdata`):
```bash ```bash
@@ -166,6 +238,17 @@ PGDATA_PATH=/mnt/usbpi1/intercept/pgdata
Then open **/adsb/history** for the reporting dashboard. 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 ### Open the Interface
After starting, open **http://localhost:5050** in your browser. The username and password is <b>admin</b>:<b>admin</b> After starting, open **http://localhost:5050** in your browser. The username and password is <b>admin</b>:<b>admin</b>
+1 -1
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,4 +1,4 @@
{ {
"version": "2026-02-15_ae16bb62", "version": "2026-02-22_17194a71",
"downloaded": "2026-02-20T00:29:06.228007Z" "downloaded": "2026-02-27T10:41:04.872620Z"
} }
+282 -105
View File
@@ -42,8 +42,12 @@ from utils.constants import (
QUEUE_MAX_SIZE, QUEUE_MAX_SIZE,
) )
import logging import logging
from flask_limiter import Limiter try:
from flask_limiter.util import get_remote_address from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
_has_limiter = True
except ImportError:
_has_limiter = False
# Track application start time for uptime calculation # Track application start time for uptime calculation
import time as _time import time as _time
_app_start_time = _time.time() _app_start_time = _time.time()
@@ -54,11 +58,24 @@ app = Flask(__name__)
app.secret_key = "signals_intelligence_secret" # Required for flash messages app.secret_key = "signals_intelligence_secret" # Required for flash messages
# Set up rate limiting # Set up rate limiting
limiter = Limiter( if _has_limiter:
key_func=get_remote_address, # Identifies the user by their IP limiter = Limiter(
app=app, key_func=get_remote_address,
storage_uri="memory://", # Use RAM memory (change to redis:// etc. for distributed setups) app=app,
) storage_uri="memory://",
)
else:
logging.getLogger('intercept').warning(
"flask-limiter not installed rate limiting disabled. "
"Install with: pip install flask-limiter"
)
class _NoopLimiter:
"""Stub so @limiter.limit() decorators are silently ignored."""
def limit(self, *a, **kw):
def decorator(f):
return f
return decorator
limiter = _NoopLimiter()
# Disable Werkzeug debugger PIN (not needed for local development tool) # Disable Werkzeug debugger PIN (not needed for local development tool)
os.environ['WERKZEUG_DEBUG_PIN'] = 'off' os.environ['WERKZEUG_DEBUG_PIN'] = 'off'
@@ -198,6 +215,26 @@ tscm_lock = threading.Lock()
subghz_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) subghz_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
subghz_lock = threading.Lock() subghz_lock = threading.Lock()
# Radiosonde weather balloon tracking
radiosonde_process = None
radiosonde_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
radiosonde_lock = threading.Lock()
# CW/Morse code decoder
morse_process = None
morse_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
morse_lock = threading.Lock()
# Meteor scatter detection
meteor_process = None
meteor_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
meteor_lock = threading.Lock()
# Generic OOK signal decoder
ook_process = None
ook_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
ook_lock = threading.Lock()
# Deauth Attack Detection # Deauth Attack Detection
deauth_detector = None deauth_detector = None
deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
@@ -252,12 +289,12 @@ cleanup_manager.register(deauth_alerts)
# SDR DEVICE REGISTRY # SDR DEVICE REGISTRY
# ============================================ # ============================================
# Tracks which mode is using which SDR device to prevent conflicts # Tracks which mode is using which SDR device to prevent conflicts
# Key: device_index (int), Value: mode_name (str) # Key: "sdr_type:device_index" (str), Value: mode_name (str)
sdr_device_registry: dict[int, str] = {} sdr_device_registry: dict[str, str] = {}
sdr_device_registry_lock = threading.Lock() sdr_device_registry_lock = threading.Lock()
def claim_sdr_device(device_index: int, mode_name: str) -> str | None: def claim_sdr_device(device_index: int, mode_name: str, sdr_type: str = 'rtlsdr') -> str | None:
"""Claim an SDR device for a mode. """Claim an SDR device for a mode.
Checks the in-app registry first, then probes the USB device to Checks the in-app registry first, then probes the USB device to
@@ -267,43 +304,48 @@ def claim_sdr_device(device_index: int, mode_name: str) -> str | None:
Args: Args:
device_index: The SDR device index to claim device_index: The SDR device index to claim
mode_name: Name of the mode claiming the device (e.g., 'sensor', 'rtlamr') mode_name: Name of the mode claiming the device (e.g., 'sensor', 'rtlamr')
sdr_type: SDR type string (e.g., 'rtlsdr', 'hackrf', 'limesdr')
Returns: Returns:
Error message if device is in use, None if successfully claimed Error message if device is in use, None if successfully claimed
""" """
key = f"{sdr_type}:{device_index}"
with sdr_device_registry_lock: with sdr_device_registry_lock:
if device_index in sdr_device_registry: if key in sdr_device_registry:
in_use_by = sdr_device_registry[device_index] in_use_by = sdr_device_registry[key]
return f'SDR device {device_index} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.' return f'SDR device {sdr_type}:{device_index} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.'
# Probe the USB device to catch external processes holding the handle # Probe the USB device to catch external processes holding the handle
try: if sdr_type == 'rtlsdr':
from utils.sdr.detection import probe_rtlsdr_device try:
usb_error = probe_rtlsdr_device(device_index) from utils.sdr.detection import probe_rtlsdr_device
if usb_error: usb_error = probe_rtlsdr_device(device_index)
return usb_error if usb_error:
except Exception: return usb_error
pass # If probe fails, let the caller proceed normally except Exception:
pass # If probe fails, let the caller proceed normally
sdr_device_registry[device_index] = mode_name sdr_device_registry[key] = mode_name
return None return None
def release_sdr_device(device_index: int) -> None: def release_sdr_device(device_index: int, sdr_type: str = 'rtlsdr') -> None:
"""Release an SDR device from the registry. """Release an SDR device from the registry.
Args: Args:
device_index: The SDR device index to release device_index: The SDR device index to release
sdr_type: SDR type string (e.g., 'rtlsdr', 'hackrf', 'limesdr')
""" """
key = f"{sdr_type}:{device_index}"
with sdr_device_registry_lock: with sdr_device_registry_lock:
sdr_device_registry.pop(device_index, None) sdr_device_registry.pop(key, None)
def get_sdr_device_status() -> dict[int, str]: def get_sdr_device_status() -> dict[str, str]:
"""Get current SDR device allocations. """Get current SDR device allocations.
Returns: Returns:
Dictionary mapping device indices to mode names Dictionary mapping 'sdr_type:device_index' keys to mode names
""" """
with sdr_device_registry_lock: with sdr_device_registry_lock:
return dict(sdr_device_registry) return dict(sdr_device_registry)
@@ -424,8 +466,9 @@ def get_devices_status() -> Response:
result = [] result = []
for device in devices: for device in devices:
d = device.to_dict() d = device.to_dict()
d['in_use'] = device.index in registry key = f"{device.sdr_type.value}:{device.index}"
d['used_by'] = registry.get(device.index) d['in_use'] = key in registry
d['used_by'] = registry.get(key)
result.append(d) result.append(d)
return jsonify(result) return jsonify(result)
@@ -680,6 +723,29 @@ def _get_subghz_active() -> bool:
return False return False
def _get_singleton_running(module_path: str, getter_name: str, attr: str) -> bool:
"""Safely check if a singleton-based mode is running without creating instances."""
try:
import importlib
mod = importlib.import_module(module_path)
getter = getattr(mod, getter_name)
instance = getter()
if instance is None:
return False
return bool(getattr(instance, attr, False))
except Exception:
return False
def _get_tscm_active() -> bool:
"""Check if a TSCM sweep is running."""
try:
from routes.tscm import _sweep_running
return bool(_sweep_running)
except Exception:
return False
def _get_bluetooth_health() -> tuple[bool, int]: def _get_bluetooth_health() -> tuple[bool, int]:
"""Return Bluetooth active state and best-effort device count.""" """Return Bluetooth active state and best-effort device count."""
legacy_running = bt_process is not None and (bt_process.poll() is None if bt_process else False) legacy_running = bt_process is not None and (bt_process.poll() is None if bt_process else False)
@@ -755,7 +821,18 @@ def health_check() -> Response:
'wifi': wifi_active, 'wifi': wifi_active,
'bluetooth': bt_active, 'bluetooth': bt_active,
'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False), 'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False),
'radiosonde': radiosonde_process is not None and (radiosonde_process.poll() is None if radiosonde_process else False),
'morse': morse_process is not None and (morse_process.poll() is None if morse_process else False),
'subghz': _get_subghz_active(), 'subghz': _get_subghz_active(),
'rtlamr': rtlamr_process is not None and (rtlamr_process.poll() is None if rtlamr_process else False),
'meshtastic': _get_singleton_running('utils.meshtastic', 'get_meshtastic_client', 'is_running'),
'sstv': _get_singleton_running('utils.sstv', 'get_sstv_decoder', 'is_running'),
'weathersat': _get_singleton_running('utils.weather_sat', 'get_weather_sat_decoder', 'is_running'),
'wefax': _get_singleton_running('utils.wefax', 'get_wefax_decoder', 'is_running'),
'sstv_general': _get_singleton_running('utils.sstv', 'get_general_sstv_decoder', 'is_running'),
'tscm': _get_tscm_active(),
'gps': _get_singleton_running('utils.gps', 'get_gps_reader', 'is_running'),
'bt_locate': _get_singleton_running('utils.bt_locate', 'get_locate_session', 'is_active'),
}, },
'data': { 'data': {
'aircraft_count': len(adsb_aircraft), 'aircraft_count': len(adsb_aircraft),
@@ -772,12 +849,13 @@ def health_check() -> Response:
def kill_all() -> Response: def kill_all() -> Response:
"""Kill all decoder, WiFi, and Bluetooth processes.""" """Kill all decoder, WiFi, and Bluetooth processes."""
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
global vdl2_process global vdl2_process, morse_process, radiosonde_process, ook_process
global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_process global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_process
# Import adsb and ais modules to reset their state # Import modules to reset their state
from routes import adsb as adsb_module from routes import adsb as adsb_module
from routes import ais as ais_module from routes import ais as ais_module
from routes import radiosonde as radiosonde_module
from utils.bluetooth import reset_bluetooth_scanner from utils.bluetooth import reset_bluetooth_scanner
killed = [] killed = []
@@ -787,7 +865,8 @@ def kill_all() -> Response:
'dump1090', 'acarsdec', 'dumpvdl2', 'direwolf', 'AIS-catcher', 'dump1090', 'acarsdec', 'dumpvdl2', 'direwolf', 'AIS-catcher',
'hcitool', 'bluetoothctl', 'satdump', 'hcitool', 'bluetoothctl', 'satdump',
'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg', 'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg',
'hackrf_transfer', 'hackrf_sweep' 'hackrf_transfer', 'hackrf_sweep',
'auto_rx'
] ]
for proc in processes_to_kill: for proc in processes_to_kill:
@@ -817,6 +896,11 @@ def kill_all() -> Response:
ais_process = None ais_process = None
ais_module.ais_running = False ais_module.ais_running = False
# Reset Radiosonde state
with radiosonde_lock:
radiosonde_process = None
radiosonde_module.radiosonde_running = False
# Reset ACARS state # Reset ACARS state
with acars_lock: with acars_lock:
acars_process = None acars_process = None
@@ -825,6 +909,21 @@ def kill_all() -> Response:
with vdl2_lock: with vdl2_lock:
vdl2_process = None vdl2_process = None
# Reset Morse state
with morse_lock:
morse_process = None
# Reset OOK state (full cleanup: parser thread, pipes, SDR release)
with ook_lock:
try:
from routes.ook import cleanup_ook
cleanup_ook(emit_status=False)
except Exception:
if ook_process:
safe_terminate(ook_process)
unregister_process(ook_process)
ook_process = None
# Reset APRS state # Reset APRS state
with aprs_lock: with aprs_lock:
aprs_process = None aprs_process = None
@@ -869,6 +968,139 @@ def kill_all() -> Response:
return jsonify({'status': 'killed', 'processes': killed}) return jsonify({'status': 'killed', 'processes': killed})
def _ensure_self_signed_cert(cert_dir: str) -> tuple:
"""Generate a self-signed certificate if one doesn't already exist.
Returns (cert_path, key_path) tuple.
"""
cert_path = os.path.join(cert_dir, 'intercept.crt')
key_path = os.path.join(cert_dir, 'intercept.key')
if os.path.exists(cert_path) and os.path.exists(key_path):
print(f"Using existing SSL certificate: {cert_path}")
return cert_path, key_path
os.makedirs(cert_dir, exist_ok=True)
print("Generating self-signed SSL certificate...")
import subprocess
result = subprocess.run([
'openssl', 'req', '-x509', '-newkey', 'rsa:2048',
'-keyout', key_path, '-out', cert_path,
'-days', '365', '-nodes',
'-subj', '/CN=intercept/O=INTERCEPT/C=US',
], capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f"Failed to generate SSL certificate: {result.stderr}")
print(f"SSL certificate generated: {cert_path}")
return cert_path, key_path
_app_initialized = False
def _init_app() -> None:
"""Initialize blueprints, database, and websockets.
Safe to call multiple times — subsequent calls are no-ops.
Called automatically at module level for gunicorn, and also
from main() for the Flask dev server path.
Heavy/network operations (TLE updates, process cleanup) are
deferred to a background thread so the worker can serve
requests immediately.
"""
global _app_initialized
if _app_initialized:
return
_app_initialized = True
import os
# Initialize database for settings storage
from utils.database import init_db
init_db()
# Register blueprints (essential — without these, all routes 404)
from routes import register_blueprints
register_blueprints(app)
# Initialize WebSocket for audio streaming
try:
from routes.audio_websocket import init_audio_websocket
init_audio_websocket(app)
except ImportError:
pass
# Initialize KiwiSDR WebSocket audio proxy
try:
from routes.websdr import init_websdr_audio
init_websdr_audio(app)
except ImportError:
pass
# Initialize WebSocket for waterfall streaming
try:
from routes.waterfall_websocket import init_waterfall_websocket
init_waterfall_websocket(app)
except ImportError:
pass
# Initialize WebSocket for meteor scatter monitoring
try:
from routes.meteor_websocket import init_meteor_websocket
init_meteor_websocket(app)
except ImportError:
pass
# Defer heavy/network operations so the worker can serve requests immediately
import threading
def _deferred_init():
"""Run heavy initialization after a short delay."""
import time
time.sleep(1) # Let the worker start serving first
# Clean up stale processes from previous runs
try:
cleanup_stale_processes()
cleanup_stale_dump1090()
except Exception as e:
logger.warning(f"Stale process cleanup failed: {e}")
# Register and start database cleanup
try:
from utils.database import (
cleanup_old_signal_history,
cleanup_old_timeline_entries,
cleanup_old_dsc_alerts,
cleanup_old_payloads
)
cleanup_manager.register_db_cleanup(cleanup_old_signal_history, interval_multiplier=1440)
cleanup_manager.register_db_cleanup(cleanup_old_timeline_entries, interval_multiplier=1440)
cleanup_manager.register_db_cleanup(cleanup_old_dsc_alerts, interval_multiplier=1440)
cleanup_manager.register_db_cleanup(cleanup_old_payloads, interval_multiplier=1440)
cleanup_manager.start()
except Exception as e:
logger.warning(f"Cleanup manager init failed: {e}")
# Initialize TLE auto-refresh (must be after blueprint registration)
try:
from routes.satellite import init_tle_auto_refresh
if not os.environ.get('TESTING'):
init_tle_auto_refresh()
except Exception as e:
logger.warning(f"Failed to initialize TLE auto-refresh: {e}")
threading.Thread(target=_deferred_init, daemon=True).start()
# Auto-initialize when imported (e.g. by gunicorn)
_init_app()
def main() -> None: def main() -> None:
"""Main entry point.""" """Main entry point."""
import argparse import argparse
@@ -895,6 +1127,12 @@ def main() -> None:
default=config.DEBUG, default=config.DEBUG,
help='Enable debug mode' help='Enable debug mode'
) )
parser.add_argument(
'--https',
action='store_true',
default=config.HTTPS,
help='Enable HTTPS with self-signed certificate'
)
parser.add_argument( parser.add_argument(
'--check-deps', '--check-deps',
action='store_true', action='store_true',
@@ -944,83 +1182,21 @@ def main() -> None:
print("Running as root - full capabilities enabled") print("Running as root - full capabilities enabled")
print() print()
# Clean up any stale processes from previous runs # Ensure app is initialized (no-op if already done by module-level call)
cleanup_stale_processes() _init_app()
cleanup_stale_dump1090()
# Initialize database for settings storage # Configure SSL if HTTPS is enabled
from utils.database import init_db ssl_context = None
init_db() if args.https:
cert_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data', 'certs')
if config.SSL_CERT and config.SSL_KEY:
ssl_context = (config.SSL_CERT, config.SSL_KEY)
print(f"Using provided SSL certificate: {config.SSL_CERT}")
else:
ssl_context = _ensure_self_signed_cert(cert_dir)
# Register database cleanup functions protocol = 'https' if ssl_context else 'http'
from utils.database import ( print(f"Open {protocol}://localhost:{args.port} in your browser")
cleanup_old_signal_history,
cleanup_old_timeline_entries,
cleanup_old_dsc_alerts,
cleanup_old_payloads
)
cleanup_manager.register_db_cleanup(cleanup_old_signal_history, interval_multiplier=1440) # Every 24 hours
cleanup_manager.register_db_cleanup(cleanup_old_timeline_entries, interval_multiplier=1440) # Every 24 hours
cleanup_manager.register_db_cleanup(cleanup_old_dsc_alerts, interval_multiplier=1440) # Every 24 hours
cleanup_manager.register_db_cleanup(cleanup_old_payloads, interval_multiplier=1440) # Every 24 hours
# Start automatic cleanup of stale data entries
cleanup_manager.start()
# Register blueprints
from routes import register_blueprints
register_blueprints(app)
# Initialize TLE auto-refresh (must be after blueprint registration)
try:
from routes.satellite import init_tle_auto_refresh
import os
if not os.environ.get('TESTING'):
init_tle_auto_refresh()
except Exception as e:
logger.warning(f"Failed to initialize TLE auto-refresh: {e}")
# Update TLE data in background thread (non-blocking)
def update_tle_background():
try:
from routes.satellite import refresh_tle_data
print("Updating satellite TLE data from CelesTrak...")
updated = refresh_tle_data()
if updated:
print(f"TLE data updated for: {', '.join(updated)}")
else:
print("TLE update: No satellites updated (may be offline)")
except Exception as e:
print(f"TLE update failed (will use cached data): {e}")
tle_thread = threading.Thread(target=update_tle_background, daemon=True)
tle_thread.start()
# Initialize WebSocket for audio streaming
try:
from routes.audio_websocket import init_audio_websocket
init_audio_websocket(app)
print("WebSocket audio streaming enabled")
except ImportError as e:
print(f"WebSocket audio disabled (install flask-sock): {e}")
# Initialize KiwiSDR WebSocket audio proxy
try:
from routes.websdr import init_websdr_audio
init_websdr_audio(app)
print("KiwiSDR audio proxy enabled")
except ImportError as e:
print(f"KiwiSDR audio proxy disabled: {e}")
# Initialize WebSocket for waterfall streaming
try:
from routes.waterfall_websocket import init_waterfall_websocket
init_waterfall_websocket(app)
print("WebSocket waterfall streaming enabled")
except ImportError as e:
print(f"WebSocket waterfall disabled: {e}")
print(f"Open http://localhost:{args.port} in your browser")
print() print()
print("Press Ctrl+C to stop") print("Press Ctrl+C to stop")
print() print()
@@ -1032,4 +1208,5 @@ def main() -> None:
debug=args.debug, debug=args.debug,
threaded=True, threaded=True,
load_dotenv=False, load_dotenv=False,
ssl_context=ssl_context,
) )
+47 -2
View File
@@ -7,10 +7,36 @@ import os
import sys import sys
# Application version # Application version
VERSION = "2.22.3" VERSION = "2.24.0"
# Changelog - latest release notes (shown on welcome screen) # Changelog - latest release notes (shown on welcome screen)
CHANGELOG = [ CHANGELOG = [
{
"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", "version": "2.22.3",
"date": "February 2026", "date": "February 2026",
@@ -280,6 +306,11 @@ PORT = _get_env_int('PORT', 5050)
DEBUG = _get_env_bool('DEBUG', False) DEBUG = _get_env_bool('DEBUG', False)
THREADED = _get_env_bool('THREADED', True) 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 RTL-SDR settings
DEFAULT_GAIN = _get_env('DEFAULT_GAIN', '40') DEFAULT_GAIN = _get_env('DEFAULT_GAIN', '40')
DEFAULT_DEVICE = _get_env('DEFAULT_DEVICE', '0') DEFAULT_DEVICE = _get_env('DEFAULT_DEVICE', '0')
@@ -326,12 +357,20 @@ SATELLITE_ORBIT_MINUTES = _get_env_int('SATELLITE_ORBIT_MINUTES', 45)
# Weather satellite settings # Weather satellite settings
WEATHER_SAT_DEFAULT_GAIN = _get_env_float('WEATHER_SAT_GAIN', 40.0) WEATHER_SAT_DEFAULT_GAIN = _get_env_float('WEATHER_SAT_GAIN', 40.0)
WEATHER_SAT_SAMPLE_RATE = _get_env_int('WEATHER_SAT_SAMPLE_RATE', 1000000) WEATHER_SAT_SAMPLE_RATE = _get_env_int('WEATHER_SAT_SAMPLE_RATE', 2400000)
WEATHER_SAT_MIN_ELEVATION = _get_env_float('WEATHER_SAT_MIN_ELEVATION', 15.0) WEATHER_SAT_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_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_SCHEDULE_REFRESH_MINUTES = _get_env_int('WEATHER_SAT_SCHEDULE_REFRESH_MINUTES', 30)
WEATHER_SAT_CAPTURE_BUFFER_SECONDS = _get_env_int('WEATHER_SAT_CAPTURE_BUFFER_SECONDS', 30) WEATHER_SAT_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 transceiver settings (HackRF)
SUBGHZ_DEFAULT_FREQUENCY = _get_env_float('SUBGHZ_FREQUENCY', 433.92) SUBGHZ_DEFAULT_FREQUENCY = _get_env_float('SUBGHZ_FREQUENCY', 433.92)
SUBGHZ_DEFAULT_SAMPLE_RATE = _get_env_int('SUBGHZ_SAMPLE_RATE', 2000000) SUBGHZ_DEFAULT_SAMPLE_RATE = _get_env_int('SUBGHZ_SAMPLE_RATE', 2000000)
@@ -342,6 +381,12 @@ SUBGHZ_MAX_TX_DURATION = _get_env_int('SUBGHZ_MAX_TX_DURATION', 10)
SUBGHZ_SWEEP_START_MHZ = _get_env_float('SUBGHZ_SWEEP_START', 300.0) SUBGHZ_SWEEP_START_MHZ = _get_env_float('SUBGHZ_SWEEP_START', 300.0)
SUBGHZ_SWEEP_END_MHZ = _get_env_float('SUBGHZ_SWEEP_END', 928.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 # Update checking
GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept') GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept')
UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True) UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True)
+3
View File
@@ -26,4 +26,7 @@ TLE_SATELLITES = {
'METEOR-M2-3': ('METEOR-M2 3', 'METEOR-M2-3': ('METEOR-M2 3',
'1 57166U 23091A 25028.81539352 .00000157 00000+0 94432-4 0 9993', '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'), '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'),
} }
+733
View File
@@ -0,0 +1,733 @@
{
"stations": [
{
"name": "USCG Kodiak",
"callsign": "NOJ",
"country": "US",
"city": "Kodiak, AK",
"coordinates": [57.78, -152.50],
"frequencies": [
{"khz": 2054, "description": "Night"},
{"khz": 4298, "description": "Primary"},
{"khz": 8459, "description": "Day"},
{"khz": 12412.5, "description": "Extended"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "03:40", "duration_min": 148, "content": "Chart Series 1"},
{"utc": "09:50", "duration_min": 138, "content": "Chart Series 2"},
{"utc": "15:40", "duration_min": 148, "content": "Chart Series 3"},
{"utc": "21:50", "duration_min": 98, "content": "Chart Series 4"}
]
},
{
"name": "USCG Boston",
"callsign": "NMF",
"country": "US",
"city": "Boston, MA",
"coordinates": [42.36, -71.04],
"frequencies": [
{"khz": 4235, "description": "Night"},
{"khz": 6340.5, "description": "Primary"},
{"khz": 9110, "description": "Day"},
{"khz": 12750, "description": "Extended"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "02:30", "duration_min": 20, "content": "Wind/Wave Analysis"},
{"utc": "04:38", "duration_min": 20, "content": "Sea State Analysis"},
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "09:30", "duration_min": 20, "content": "48-Hour Surface Prog"},
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "14:00", "duration_min": 20, "content": "24-Hour Surface Prog"},
{"utc": "16:00", "duration_min": 20, "content": "Sea State Analysis"},
{"utc": "18:10", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "22:00", "duration_min": 20, "content": "Satellite Image"}
]
},
{
"name": "USCG New Orleans",
"callsign": "NMG",
"country": "US",
"city": "New Orleans, LA",
"coordinates": [29.95, -90.07],
"frequencies": [
{"khz": 4317.9, "description": "Night"},
{"khz": 8503.9, "description": "Primary"},
{"khz": 12789.9, "description": "Day"},
{"khz": 17146.4, "description": "Extended"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "03:00", "duration_min": 20, "content": "24-Hour Surface Prog"},
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "09:00", "duration_min": 20, "content": "Sea State Analysis"},
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "15:00", "duration_min": 20, "content": "48-Hour Surface Prog"},
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "21:00", "duration_min": 20, "content": "Tropical Analysis"}
]
},
{
"name": "USCG Pt. Reyes",
"callsign": "NMC",
"country": "US",
"city": "Pt. Reyes, CA",
"coordinates": [38.07, -122.97],
"frequencies": [
{"khz": 4346, "description": "Night"},
{"khz": 8682, "description": "Primary"},
{"khz": 12786, "description": "Day"},
{"khz": 17151.2, "description": "Extended"},
{"khz": 22527, "description": "DX"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "01:40", "duration_min": 20, "content": "Wind/Wave Analysis"},
{"utc": "06:55", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "11:20", "duration_min": 20, "content": "48-Hour Surface Prog"},
{"utc": "14:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "18:40", "duration_min": 20, "content": "Sea State Analysis"},
{"utc": "23:20", "duration_min": 20, "content": "Satellite Image"}
]
},
{
"name": "USCG Honolulu",
"callsign": "KVM70",
"country": "US",
"city": "Honolulu, HI",
"coordinates": [21.31, -157.86],
"frequencies": [
{"khz": 9982.5, "description": "Primary"},
{"khz": 11090, "description": "Day"},
{"khz": 16135, "description": "Extended"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "05:19", "duration_min": 20, "content": "Surface Prog"},
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "17:19", "duration_min": 20, "content": "Sea State Analysis"}
]
},
{
"name": "RN Northwood",
"callsign": "GYA",
"country": "GB",
"city": "Northwood, London",
"coordinates": [51.63, -0.42],
"frequencies": [
{"khz": 2618.5, "description": "Night"},
{"khz": 3280.5, "description": "Night Alt"},
{"khz": 4610, "description": "Primary"},
{"khz": 6834, "description": "Day Alt"},
{"khz": 8040, "description": "Day"},
{"khz": 11086.5, "description": "Extended"},
{"khz": 12390, "description": "Persian Gulf"},
{"khz": 18261, "description": "DX"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "03:30", "duration_min": 20, "content": "24-Hour Surface Prog"},
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "08:00", "duration_min": 20, "content": "Sea State Forecast"},
{"utc": "09:30", "duration_min": 20, "content": "Extended Forecast"},
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "15:30", "duration_min": 20, "content": "48-Hour Surface Prog"},
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "19:00", "duration_min": 20, "content": "Wave Period Forecast"},
{"utc": "21:30", "duration_min": 20, "content": "Extended Forecast"}
]
},
{
"name": "DWD Hamburg/Pinneberg",
"callsign": "DDH",
"country": "DE",
"city": "Pinneberg",
"coordinates": [53.66, 9.80],
"frequencies": [
{"khz": 3855, "description": "Night (DDH3, 10kW)"},
{"khz": 7880, "description": "Primary (DDK3, 20kW)"},
{"khz": 13882.5, "description": "Day (DDK6, 20kW)"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "04:30", "duration_min": 20, "content": "Surface Analysis N. Atlantic"},
{"utc": "07:15", "duration_min": 20, "content": "Surface Prog"},
{"utc": "09:30", "duration_min": 20, "content": "Surface Analysis Europe"},
{"utc": "10:07", "duration_min": 20, "content": "Sea State North Sea"},
{"utc": "13:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "15:20", "duration_min": 20, "content": "Extended Prog"},
{"utc": "15:40", "duration_min": 20, "content": "Sea Ice Chart"},
{"utc": "16:30", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "21:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "21:15", "duration_min": 20, "content": "Surface Prog"}
]
},
{
"name": "JMA Tokyo",
"callsign": "JMH",
"country": "JP",
"city": "Tokyo",
"coordinates": [35.69, 139.69],
"frequencies": [
{"khz": 3622.5, "description": "Night"},
{"khz": 7795, "description": "Primary"},
{"khz": 13988.5, "description": "Day"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "01:30", "duration_min": 20, "content": "24-Hour Prog"},
{"utc": "03:00", "duration_min": 20, "content": "Satellite Image"},
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "07:30", "duration_min": 20, "content": "Wave Analysis"},
{"utc": "09:00", "duration_min": 20, "content": "Satellite Image"},
{"utc": "10:19", "duration_min": 20, "content": "Tropical Cyclone Info"},
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "15:00", "duration_min": 20, "content": "Satellite Image"},
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "21:00", "duration_min": 20, "content": "48-Hour Prog"}
]
},
{
"name": "Kyodo News Tokyo",
"callsign": "JJC",
"country": "JP",
"city": "Tokyo",
"coordinates": [35.69, 139.69],
"frequencies": [
{"khz": 4316, "description": "Night"},
{"khz": 8467.5, "description": "Primary"},
{"khz": 12745.5, "description": "Day"},
{"khz": 16971, "description": "Extended"},
{"khz": 17069.6, "description": "DX"},
{"khz": 22542, "description": "DX 2"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "00:00", "duration_min": 20, "content": "Press Photo/News Fax"},
{"utc": "04:00", "duration_min": 20, "content": "Press Photo/News Fax"},
{"utc": "08:00", "duration_min": 20, "content": "Press Photo/News Fax"},
{"utc": "12:00", "duration_min": 20, "content": "Press Photo/News Fax"},
{"utc": "16:00", "duration_min": 20, "content": "Press Photo/News Fax"},
{"utc": "20:00", "duration_min": 20, "content": "Press Photo/News Fax"}
]
},
{
"name": "Kagoshima Fisheries",
"callsign": "JFX",
"country": "JP",
"city": "Kagoshima",
"coordinates": [31.60, 130.56],
"frequencies": [
{"khz": 4274, "description": "Night"},
{"khz": 8658, "description": "Primary"},
{"khz": 13074, "description": "Day"},
{"khz": 16907.5, "description": "Extended"},
{"khz": 22559.6, "description": "DX"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "00:00", "duration_min": 20, "content": "Sea Surface Temp"},
{"utc": "04:00", "duration_min": 20, "content": "Fishing Forecast"},
{"utc": "08:00", "duration_min": 20, "content": "Sea Surface Temp"},
{"utc": "12:00", "duration_min": 20, "content": "Current Chart"},
{"utc": "16:00", "duration_min": 20, "content": "Fishing Forecast"},
{"utc": "20:00", "duration_min": 20, "content": "Sea Surface Temp"}
]
},
{
"name": "KMA Seoul",
"callsign": "HLL2",
"country": "KR",
"city": "Seoul",
"coordinates": [37.57, 126.98],
"frequencies": [
{"khz": 3585, "description": "Night"},
{"khz": 5857.5, "description": "Primary"},
{"khz": 7433.5, "description": "Day"},
{"khz": 9165, "description": "Extended"},
{"khz": 13570, "description": "DX"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "03:00", "duration_min": 20, "content": "24-Hour Prog"},
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "09:00", "duration_min": 20, "content": "Satellite Image"},
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "15:00", "duration_min": 20, "content": "Sea State Analysis"},
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "21:00", "duration_min": 20, "content": "48-Hour Prog"}
]
},
{
"name": "Taipei Met",
"callsign": "BMF",
"country": "TW",
"city": "Taipei",
"coordinates": [25.03, 121.57],
"frequencies": [
{"khz": 4616, "description": "Primary"},
{"khz": 8140, "description": "Day"},
{"khz": 13900, "description": "Extended"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}
]
},
{
"name": "Bangkok Met",
"callsign": "HSW64",
"country": "TH",
"city": "Bangkok",
"coordinates": [13.76, 100.50],
"frequencies": [
{"khz": 7396.8, "description": "Primary"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}
]
},
{
"name": "Shanghai Met",
"callsign": "XSG",
"country": "CN",
"city": "Shanghai",
"coordinates": [31.23, 121.47],
"frequencies": [
{"khz": 4170, "description": "Night"},
{"khz": 8302, "description": "Primary"},
{"khz": 12382, "description": "Day"},
{"khz": 16559, "description": "Extended"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}
]
},
{
"name": "Guangzhou Radio",
"callsign": "XSQ",
"country": "CN",
"city": "Guangzhou",
"coordinates": [23.13, 113.26],
"frequencies": [
{"khz": 4199.8, "description": "Night"},
{"khz": 8412.5, "description": "Primary"},
{"khz": 12629.3, "description": "Day"},
{"khz": 16826.3, "description": "Extended"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}
]
},
{
"name": "Singapore Met",
"callsign": "9VF",
"country": "SG",
"city": "Singapore",
"coordinates": [1.35, 103.82],
"frequencies": [
{"khz": 16035, "description": "Primary"},
{"khz": 17430, "description": "Alternate"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}
]
},
{
"name": "New Delhi Met",
"callsign": "ATP",
"country": "IN",
"city": "New Delhi",
"coordinates": [28.61, 77.21],
"frequencies": [
{"khz": 7405, "description": "Night"},
{"khz": 14842, "description": "Day"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}
]
},
{
"name": "Murmansk Met",
"callsign": "RBW",
"country": "RU",
"city": "Murmansk",
"coordinates": [68.97, 33.09],
"frequencies": [
{"khz": 6445.5, "description": "Night"},
{"khz": 7907, "description": "Primary"},
{"khz": 8444, "description": "Day"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "07:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "08:00", "duration_min": 20, "content": "Ice Chart"},
{"utc": "14:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "14:30", "duration_min": 20, "content": "Surface Prog"},
{"utc": "20:00", "duration_min": 20, "content": "Surface Analysis"}
]
},
{
"name": "St. Petersburg Met",
"callsign": "RDD78",
"country": "RU",
"city": "St. Petersburg",
"coordinates": [59.93, 30.32],
"frequencies": [
{"khz": 2640, "description": "Night"},
{"khz": 4212, "description": "Primary"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}
]
},
{
"name": "Athens Met",
"callsign": "SVJ4",
"country": "GR",
"city": "Athens",
"coordinates": [37.97, 23.73],
"frequencies": [
{"khz": 4482.9, "description": "Night"},
{"khz": 8106.9, "description": "Primary"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis Med"},
{"utc": "09:00", "duration_min": 20, "content": "Surface Prog"},
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis Med"},
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis Med"}
]
},
{
"name": "Charleville Met",
"callsign": "VMC",
"country": "AU",
"city": "Charleville, QLD",
"coordinates": [-26.41, 146.24],
"frequencies": [
{"khz": 2628, "description": "Night"},
{"khz": 5100, "description": "Primary"},
{"khz": 11030, "description": "Day"},
{"khz": 13920, "description": "Extended"},
{"khz": 20469, "description": "DX"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "00:00", "duration_min": 20, "content": "MSLP Analysis"},
{"utc": "03:00", "duration_min": 20, "content": "Prognosis"},
{"utc": "06:00", "duration_min": 20, "content": "MSLP Analysis"},
{"utc": "09:00", "duration_min": 20, "content": "Sea/Swell Chart"},
{"utc": "12:00", "duration_min": 20, "content": "MSLP Analysis"},
{"utc": "18:00", "duration_min": 20, "content": "MSLP Analysis"},
{"utc": "19:00", "duration_min": 20, "content": "Prognosis"}
]
},
{
"name": "Wiluna Met",
"callsign": "VMW",
"country": "AU",
"city": "Wiluna, WA",
"coordinates": [-26.59, 120.23],
"frequencies": [
{"khz": 5755, "description": "Night"},
{"khz": 7535, "description": "Primary"},
{"khz": 10555, "description": "Day"},
{"khz": 15615, "description": "Extended"},
{"khz": 18060, "description": "DX"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "00:00", "duration_min": 20, "content": "MSLP Analysis"},
{"utc": "06:00", "duration_min": 20, "content": "MSLP Analysis"},
{"utc": "11:00", "duration_min": 20, "content": "Prognosis"},
{"utc": "18:00", "duration_min": 20, "content": "MSLP Analysis"},
{"utc": "21:00", "duration_min": 20, "content": "Sea/Swell Chart"}
]
},
{
"name": "NZ MetService",
"callsign": "ZKLF",
"country": "NZ",
"city": "Auckland",
"coordinates": [-36.85, 174.76],
"frequencies": [
{"khz": 3247.4, "description": "Night"},
{"khz": 5807, "description": "Primary"},
{"khz": 9459, "description": "Day"},
{"khz": 13550.5, "description": "Extended"},
{"khz": 16340.1, "description": "DX"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}
]
},
{
"name": "CFH Halifax",
"callsign": "CFH",
"country": "CA",
"city": "Halifax, NS",
"coordinates": [44.65, -63.57],
"frequencies": [
{"khz": 4271, "description": "Night"},
{"khz": 6496.4, "description": "Primary"},
{"khz": 10536, "description": "Day"},
{"khz": 13510, "description": "Extended"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "03:00", "duration_min": 20, "content": "Surface Prog"},
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "22:22", "duration_min": 20, "content": "Ice Chart"},
{"utc": "23:01", "duration_min": 20, "content": "Surface Analysis"}
]
},
{
"name": "CCG Iqaluit",
"callsign": "VFF",
"country": "CA",
"city": "Iqaluit, NU",
"coordinates": [63.75, -68.52],
"frequencies": [
{"khz": 3253, "description": "Night"},
{"khz": 7710, "description": "Day"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "00:10", "duration_min": 20, "content": "Ice Chart"},
{"utc": "05:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "07:00", "duration_min": 20, "content": "Ice Chart"},
{"utc": "10:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "11:00", "duration_min": 20, "content": "Ice Chart"},
{"utc": "21:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "23:30", "duration_min": 20, "content": "Ice Chart"}
]
},
{
"name": "CCG Inuvik",
"callsign": "VFA",
"country": "CA",
"city": "Inuvik, NT",
"coordinates": [68.36, -133.72],
"frequencies": [
{"khz": 4292, "description": "Night"},
{"khz": 8457.8, "description": "Primary"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "02:00", "duration_min": 20, "content": "Ice Chart"},
{"utc": "16:30", "duration_min": 20, "content": "Ice Chart"}
]
},
{
"name": "CCG Sydney",
"callsign": "VCO",
"country": "CA",
"city": "Sydney, NS",
"coordinates": [46.14, -60.19],
"frequencies": [
{"khz": 4416, "description": "Night"},
{"khz": 6915.1, "description": "Primary"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "11:21", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "11:42", "duration_min": 20, "content": "Surface Prog"},
{"utc": "17:41", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "22:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "23:31", "duration_min": 20, "content": "Surface Prog"}
]
},
{
"name": "Cape Naval",
"callsign": "ZSJ",
"country": "ZA",
"city": "Cape Town",
"coordinates": [-33.92, 18.42],
"frequencies": [
{"khz": 4014, "description": "Night"},
{"khz": 7508, "description": "Primary"},
{"khz": 13538, "description": "Day"},
{"khz": 18238, "description": "Extended"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "04:30", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "05:00", "duration_min": 20, "content": "Sea State"},
{"utc": "06:30", "duration_min": 20, "content": "Surface Prog"},
{"utc": "07:30", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "08:00", "duration_min": 20, "content": "Satellite Image"},
{"utc": "10:30", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "11:00", "duration_min": 20, "content": "Sea State"},
{"utc": "15:30", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "15:40", "duration_min": 20, "content": "Surface Prog"},
{"utc": "22:30", "duration_min": 20, "content": "Surface Analysis"}
]
},
{
"name": "Valparaiso Naval",
"callsign": "CBV",
"country": "CL",
"city": "Valparaiso",
"coordinates": [-33.05, -71.62],
"frequencies": [
{"khz": 4228, "description": "Night"},
{"khz": 8677, "description": "Primary"},
{"khz": 17146.4, "description": "Day"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "11:15", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "11:30", "duration_min": 20, "content": "Surface Prog"},
{"utc": "16:30", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "16:45", "duration_min": 20, "content": "Sea State"},
{"utc": "19:15", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "19:30", "duration_min": 20, "content": "Surface Prog"},
{"utc": "22:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "23:10", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "23:25", "duration_min": 20, "content": "Sea State"}
]
},
{
"name": "Magallanes Naval",
"callsign": "CBM",
"country": "CL",
"city": "Punta Arenas",
"coordinates": [-53.16, -70.91],
"frequencies": [
{"khz": 4322, "description": "Night"},
{"khz": 8696, "description": "Primary"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "01:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "13:00", "duration_min": 20, "content": "Surface Analysis"}
]
},
{
"name": "Rio de Janeiro Naval",
"callsign": "PWZ33",
"country": "BR",
"city": "Rio de Janeiro",
"coordinates": [-22.91, -43.17],
"frequencies": [
{"khz": 12665, "description": "Primary"},
{"khz": 16978, "description": "Day"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "07:45", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "16:30", "duration_min": 20, "content": "Surface Analysis"}
]
},
{
"name": "Dakar Met",
"callsign": "6VU",
"country": "SN",
"city": "Dakar",
"coordinates": [14.69, -17.44],
"frequencies": [
{"khz": 13667.5, "description": "Primary"},
{"khz": 19750, "description": "Day"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"}
]
},
{
"name": "Misaki Fisheries",
"callsign": "JFC",
"country": "JP",
"city": "Miura",
"coordinates": [35.14, 139.62],
"frequencies": [
{"khz": 8616, "description": "Primary"},
{"khz": 13074, "description": "Day"},
{"khz": 17231, "description": "Extended"}
],
"ioc": 576,
"lpm": 120,
"schedule": [
{"utc": "00:00", "duration_min": 20, "content": "Sea Surface Temp"},
{"utc": "06:00", "duration_min": 20, "content": "Current Chart"},
{"utc": "12:00", "duration_min": 20, "content": "Fishing Forecast"},
{"utc": "18:00", "duration_min": 20, "content": "Sea Surface Temp"}
]
}
]
}
+15
View File
@@ -1,6 +1,8 @@
# INTERCEPT - Signal Intelligence Platform # INTERCEPT - Signal Intelligence Platform
# Docker Compose configuration for easy deployment # Docker Compose configuration for easy deployment
# #
# Uses gunicorn + gevent production server via start.sh (handles concurrent SSE/WebSocket)
#
# Basic usage (build locally): # Basic usage (build locally):
# docker compose --profile basic up -d --build # docker compose --profile basic up -d --build
# #
@@ -18,6 +20,8 @@ services:
container_name: intercept container_name: intercept
ports: ports:
- "5050:5050" - "5050:5050"
# Uncomment for HTTPS support (set INTERCEPT_HTTPS=true below)
# - "5443:5443"
# Privileged mode required for USB SDR device access # Privileged mode required for USB SDR device access
privileged: true privileged: true
# USB device mapping for all USB devices # USB device mapping for all USB devices
@@ -29,9 +33,13 @@ services:
# Optional: mount logs directory # Optional: mount logs directory
# - ./logs:/app/logs # - ./logs:/app/logs
environment: environment:
- TZ=${TZ:-UTC}
- INTERCEPT_HOST=0.0.0.0 - INTERCEPT_HOST=0.0.0.0
- INTERCEPT_PORT=5050 - INTERCEPT_PORT=5050
- INTERCEPT_LOG_LEVEL=INFO - INTERCEPT_LOG_LEVEL=INFO
# HTTPS support (auto-generates self-signed cert)
# - INTERCEPT_HTTPS=true
# - INTERCEPT_PORT=5443
# ADS-B history is disabled by default # ADS-B history is disabled by default
# To enable, use: docker compose --profile history up -d # To enable, use: docker compose --profile history up -d
# - INTERCEPT_ADSB_HISTORY_ENABLED=true # - INTERCEPT_ADSB_HISTORY_ENABLED=true
@@ -70,6 +78,8 @@ services:
- adsb_db - adsb_db
ports: ports:
- "5050:5050" - "5050:5050"
# Uncomment for HTTPS support (set INTERCEPT_HTTPS=true below)
# - "5443:5443"
# Privileged mode required for USB SDR device access # Privileged mode required for USB SDR device access
privileged: true privileged: true
# USB device mapping for all USB devices # USB device mapping for all USB devices
@@ -78,9 +88,13 @@ services:
volumes: volumes:
- ./data:/app/data - ./data:/app/data
environment: environment:
- TZ=${TZ:-UTC}
- INTERCEPT_HOST=0.0.0.0 - INTERCEPT_HOST=0.0.0.0
- INTERCEPT_PORT=5050 - INTERCEPT_PORT=5050
- INTERCEPT_LOG_LEVEL=INFO - INTERCEPT_LOG_LEVEL=INFO
# HTTPS support (auto-generates self-signed cert)
# - INTERCEPT_HTTPS=true
# - INTERCEPT_PORT=5443
- INTERCEPT_ADSB_HISTORY_ENABLED=true - INTERCEPT_ADSB_HISTORY_ENABLED=true
- INTERCEPT_ADSB_DB_HOST=adsb_db - INTERCEPT_ADSB_DB_HOST=adsb_db
- INTERCEPT_ADSB_DB_PORT=5432 - INTERCEPT_ADSB_DB_PORT=5432
@@ -108,6 +122,7 @@ services:
profiles: profiles:
- history - history
environment: environment:
- TZ=${TZ:-UTC}
- POSTGRES_DB=intercept_adsb - POSTGRES_DB=intercept_adsb
- POSTGRES_USER=intercept - POSTGRES_USER=intercept
- POSTGRES_PASSWORD=intercept - POSTGRES_PASSWORD=intercept
+2 -2
View File
@@ -38,8 +38,8 @@ The controller is the main Intercept application:
```bash ```bash
cd intercept cd intercept
python app.py ./setup.sh # First-time setup (choose install profiles)
# Runs on http://localhost:5050 sudo ./start.sh # Production server on http://localhost:5050
``` ```
### 2. Configure an Agent ### 2. Configure an Agent
+72 -2
View File
@@ -100,11 +100,30 @@ Digital Selective Calling (DSC) monitoring on the international maritime distres
- **CSV/JSON export** - export captured messages for offline analysis - **CSV/JSON export** - export captured messages for offline analysis
- **Integrated with ADS-B dashboard** - VDL2 messages linked to aircraft tracking - **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 ## Listening Post
- **Wideband frequency scanning** via rtl_power sweep with SNR filtering - **Wideband frequency scanning** via rtl_power sweep with SNR filtering
- **Real-time audio monitoring** with FM and SSB demodulation - **Real-time audio monitoring** with FM and SSB demodulation
- **Cross-module frequency routing** from scanner to decoders - **Cross-module frequency routing** from scanner to decoders
- **Waterfall spectrum display** for visual signal identification
- **Customizable frequency presets** and band bookmarks - **Customizable frequency presets** and band bookmarks
- **Multi-SDR support** - RTL-SDR, LimeSDR, HackRF, Airspy, SDRplay - **Multi-SDR support** - RTL-SDR, LimeSDR, HackRF, Airspy, SDRplay
@@ -170,6 +189,16 @@ Digital Selective Calling (DSC) monitoring on the international maritime distres
- **Auto-refresh** - 5-minute polling with manual refresh option - **Auto-refresh** - 5-minute polling with manual refresh option
- **No SDR required** - Data fetched from NOAA SWPC, NASA SDO, and HamQSL public APIs - **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 ## Satellite Tracking
- **Full-screen dashboard** - dedicated popout with polar plot and ground track - **Full-screen dashboard** - dedicated popout with polar plot and ground track
@@ -247,6 +276,34 @@ Search and rescue Bluetooth device location with GPS-tagged signal trail mapping
- Bluetooth adapter (built-in or USB) - Bluetooth adapter (built-in or USB)
- GPS receiver (optional, falls back to manual coordinates) - 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 ## GPS Mode
Real-time GPS position tracking with live map visualization. Real-time GPS position tracking with live map visualization.
@@ -270,7 +327,7 @@ Technical Surveillance Countermeasures (TSCM) screening for detecting wireless s
### Wireless Sweep Features ### Wireless Sweep Features
- **BLE scanning** with manufacturer data detection (AirTags, Tile, SmartTags, ESP32) - **BLE scanning** with manufacturer data detection (AirTags, Tile, SmartTags, ESP32)
- **WiFi scanning** for rogue APs, hidden SSIDs, camera devices - **WiFi scanning** for rogue APs, hidden SSIDs, camera devices
- **RF spectrum analysis** (requires RTL-SDR) - FM bugs, ISM bands, video transmitters - **RF spectrum analysis** (RTL-SDR or HackRF) - FM bugs, ISM bands, video transmitters
- **Cross-protocol correlation** - links devices across BLE/WiFi/RF - **Cross-protocol correlation** - links devices across BLE/WiFi/RF
- **Baseline comparison** - detect new/unknown devices vs known environment - **Baseline comparison** - detect new/unknown devices vs known environment
@@ -369,6 +426,14 @@ Deploy lightweight sensor nodes across multiple locations and aggregate data to
- **Redundancy** - Multiple nodes for reliable coverage - **Redundancy** - Multiple nodes for reliable coverage
- **Triangulation** - Use multiple GPS-enabled agents for signal location - **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 ## User Interface
- **Mode-specific header stats** - real-time badges showing key metrics per mode - **Mode-specific header stats** - real-time badges showing key metrics per mode
@@ -429,14 +494,19 @@ The settings modal shows availability status for each bundled asset:
## General ## General
- **Web-based interface** - no desktop app needed - **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) - **Live message streaming** via Server-Sent Events (SSE)
- **Audio alerts** with mute toggle - **Audio alerts** with mute toggle
- **Message export** to CSV/JSON - **Message export** to CSV/JSON
- **Signal activity meter** and waterfall display - **Signal activity meter** and waterfall display
- **Message logging** to file with timestamps - **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 - **Automatic device detection** across all supported hardware
- **Hardware-specific validation** - frequency/gain ranges per device type - **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** - **Configurable gain and PPM correction**
- **Device intelligence** dashboard with tracking - **Device intelligence** dashboard with tracking
- **GPS dongle support** - USB GPS receivers for precise observer location - **GPS dongle support** - USB GPS receivers for precise observer location
+172 -9
View File
@@ -14,7 +14,39 @@ INTERCEPT automatically detects connected devices.
## Quick Install ## Quick Install
### macOS (Homebrew) ### Recommended: Use the Setup Script
The setup script provides an interactive menu with install profiles for selective installation:
```bash
git clone https://github.com/smittix/intercept.git
cd intercept
./setup.sh
```
On first run, a guided wizard walks you through profile selection:
| Profile | What it installs |
|---------|-----------------|
| Core SIGINT | rtl_sdr, multimon-ng, rtl_433, dump1090, acarsdec, dumpvdl2, ffmpeg, gpsd |
| Maritime & Radio | AIS-catcher, direwolf |
| Weather & Space | SatDump, radiosonde_auto_rx |
| RF Security | aircrack-ng, HackRF, BlueZ, hcxtools, Ubertooth, SoapySDR |
| Full SIGINT | All of the above |
For headless/CI installs:
```bash
./setup.sh --non-interactive # Install everything
./setup.sh --profile=core,maritime # Install specific profiles
```
After installation, use the menu to manage your setup:
```bash
./setup.sh # Opens interactive menu
./setup.sh --health-check # Verify installation
```
### Manual Install: macOS (Homebrew)
```bash ```bash
# Install Homebrew if needed # Install Homebrew if needed
@@ -36,7 +68,7 @@ brew install soapysdr limesuite soapylms7
brew install hackrf soapyhackrf brew install hackrf soapyhackrf
``` ```
### Debian / Ubuntu / Raspberry Pi OS ### Manual Install: Debian / Ubuntu / Raspberry Pi OS
```bash ```bash
# Update package lists # Update package lists
@@ -94,6 +126,126 @@ sudo modprobe -r dvb_usb_rtl28xxu
--- ---
## Multiple RTL-SDR Dongles
If you're running two (or more) RTL-SDR dongles on the same machine, they ship with the same default serial number so Linux can't tell them apart reliably. Follow these steps to give each a unique identity.
### Step 1: Blacklist the DVB-T driver
Already covered above, but make sure this is done first — the kernel's DVB driver will grab the dongles before librtlsdr can:
```bash
echo "blacklist dvb_usb_rtl28xxu" | sudo tee /etc/modprobe.d/blacklist-rtl.conf
sudo modprobe -r dvb_usb_rtl28xxu
```
### Step 2: Burn unique serial numbers
Each dongle has an EEPROM that stores a serial number. By default they're all `00000001`. You need to give each one a unique serial.
**Plug in only the first dongle**, then:
```bash
rtl_eeprom -d 0 -s 00000001
```
**Unplug it, plug in the second dongle**, then:
```bash
rtl_eeprom -d 0 -s 00000002
```
> Pick any 8-digit hex serials you like. The `-d 0` means "device index 0" (the only one plugged in).
Unplug and replug both dongles after writing.
### Step 3: Verify
With both plugged in:
```bash
rtl_test -t
```
You should see:
```
0: Realtek, RTL2838UHIDIR, SN: 00000001
1: Realtek, RTL2838UHIDIR, SN: 00000002
```
**Tip:** If you don't know which physical dongle has which serial, unplug one and run `rtl_test -t` — the one still detected is the one still plugged in.
### Step 4: Udev rules with stable symlinks
Create rules that give each dongle a persistent name based on its serial:
```bash
sudo bash -c 'cat > /etc/udev/rules.d/20-rtlsdr.rules << EOF
# RTL-SDR dongles - permissions and stable symlinks by serial
SUBSYSTEM=="usb", ATTR{idVendor}=="0bda", ATTR{idProduct}=="2838", MODE="0666"
SUBSYSTEM=="usb", ATTR{idVendor}=="0bda", ATTR{idProduct}=="2832", MODE="0666"
# Symlinks by serial — change names/serials to match your hardware
SUBSYSTEM=="usb", ATTR{idVendor}=="0bda", ATTRS{serial}=="00000001", SYMLINK+="sdr-dongle1"
SUBSYSTEM=="usb", ATTR{idVendor}=="0bda", ATTRS{serial}=="00000002", SYMLINK+="sdr-dongle2"
EOF'
sudo udevadm control --reload-rules
sudo udevadm trigger
```
After replugging, you'll have `/dev/sdr-dongle1` and `/dev/sdr-dongle2`.
### Step 5: USB power (Raspberry Pi)
Two dongles can draw more current than the Pi allows by default:
```bash
# In /boot/firmware/config.txt, add:
usb_max_current_enable=1
```
Disable USB autosuspend so dongles don't get powered off:
```bash
# In /etc/default/grub or kernel cmdline, add:
usbcore.autosuspend=-1
```
Or via udev:
```bash
echo 'ACTION=="add", SUBSYSTEM=="usb", ATTR{power/autosuspend}="-1"' | \
sudo tee /etc/udev/rules.d/50-usb-autosuspend.rules
```
### Step 6: Docker access
Your `docker-compose.yml` needs privileged mode and USB passthrough:
```yaml
services:
intercept:
privileged: true
volumes:
- /dev/bus/usb:/dev/bus/usb
```
INTERCEPT auto-detects both dongles inside the container via `rtl_test -t` and addresses them by device index (`-d 0`, `-d 1`).
### Quick reference
| Step | What | Why |
|------|------|-----|
| Blacklist DVB | `/etc/modprobe.d/blacklist-rtl.conf` | Kernel won't steal the dongles |
| Burn serials | `rtl_eeprom -d 0 -s <serial>` | Unique identity per dongle |
| Udev rules | `/etc/udev/rules.d/20-rtlsdr.rules` | Permissions + stable `/dev/sdr-*` names |
| USB power | `config.txt` + autosuspend off | Enough current for two dongles on a Pi |
| Docker | `privileged: true` + USB volume | Container sees both dongles |
---
## Verify Installation ## Verify Installation
### Check dependencies ### Check dependencies
@@ -119,11 +271,19 @@ SoapySDRUtil --find
./setup.sh ./setup.sh
``` ```
This automatically: The setup wizard automatically:
- Detects your OS - Detects your OS (macOS, Debian/Ubuntu, DragonOS)
- Creates a virtual environment if needed (for PEP 668 systems) - Lets you choose install profiles (Core, Maritime, Weather, Security, Full, Custom)
- Installs Python dependencies - Creates a virtual environment with system site-packages
- Checks for required tools - 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 ### Manual setup
```bash ```bash
@@ -139,10 +299,13 @@ pip install -r requirements.txt
After installation: After installation:
```bash ```bash
sudo -E venv/bin/python intercept.py sudo ./start.sh
# Custom port # Custom port
INTERCEPT_PORT=8080 sudo -E venv/bin/python intercept.py sudo ./start.sh -p 8080
# HTTPS
sudo ./start.sh --https
``` ```
Open **http://localhost:5050** in your browser. Open **http://localhost:5050** in your browser.
+2 -3
View File
@@ -18,10 +18,9 @@ By default, INTERCEPT binds to `0.0.0.0:5050`, making it accessible from any net
echo "block in on en0 proto tcp from any to any port 5050" | sudo pfctl -ef - echo "block in on en0 proto tcp from any to any port 5050" | sudo pfctl -ef -
``` ```
2. **Bind to Localhost**: For local-only access, set the host environment variable: 2. **Bind to Localhost**: For local-only access, set the host or use the CLI flag:
```bash ```bash
export INTERCEPT_HOST=127.0.0.1 sudo ./start.sh -H 127.0.0.1
python intercept.py
``` ```
3. **Trusted Networks Only**: Only run INTERCEPT on networks you trust. The application has no authentication mechanism. 3. **Trusted Networks Only**: Only run INTERCEPT on networks you trust. The application has no authentication mechanism.
+17 -7
View File
@@ -25,7 +25,7 @@ sudo apt install python3-flask python3-requests python3-serial python3-skyfield
# Then create venv with system packages # Then create venv with system packages
python3 -m venv --system-site-packages venv python3 -m venv --system-site-packages venv
source venv/bin/activate source venv/bin/activate
sudo venv/bin/python intercept.py sudo ./start.sh
``` ```
### "error: externally-managed-environment" (pip blocked) ### "error: externally-managed-environment" (pip blocked)
@@ -61,18 +61,21 @@ sudo apt install python3.11 python3.11-venv python3-pip
python3.11 -m venv venv python3.11 -m venv venv
source venv/bin/activate source venv/bin/activate
pip install -r requirements.txt pip install -r requirements.txt
sudo venv/bin/python intercept.py sudo ./start.sh
``` ```
### Alternative: Use the setup script ### Alternative: Use the setup script
The setup script handles all installation automatically, including apt packages: The setup script handles all installation automatically, including apt packages and source builds:
```bash ```bash
chmod +x setup.sh ./setup.sh # Interactive wizard (first run) or menu
./setup.sh ./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" ### "pip: command not found"
```bash ```bash
@@ -336,7 +339,7 @@ rtl_fm -M am -f 118000000 -s 24000 -r 24000 -g 40 2>/dev/null | \
Run INTERCEPT with sudo: Run INTERCEPT with sudo:
```bash ```bash
sudo -E venv/bin/python intercept.py sudo ./start.sh
``` ```
### Interface not found after enabling monitor mode ### Interface not found after enabling monitor mode
@@ -373,7 +376,14 @@ sudo usermod -a -G bluetooth $USER
### Cannot install dump1090 in Debian (ADS-B mode) ### Cannot install dump1090 in Debian (ADS-B mode)
On newer Debian versions, dump1090 may not be in repositories. The recommended action is to build from source or use the setup.sh script which will do it for you. On newer Debian versions, dump1090 may not be in repositories. Use the setup script which builds it from source automatically:
```bash
./setup.sh # Select Core SIGINT profile, or
./setup.sh --profile=core # Install core tools including dump1090
```
The setup menu's **Install / Add Modules** option also lets you install dump1090 individually via the Custom tool checklist.
### No aircraft appearing (ADS-B mode) ### No aircraft appearing (ADS-B mode)
+2 -1
View File
@@ -212,6 +212,7 @@ Extended base for full-screen dashboards (maps, visualizations).
| `websdr` | WebSDR | | `websdr` | WebSDR |
| `subghz` | Sub-GHz analyzer | | `subghz` | Sub-GHz analyzer |
| `bt_locate` | BT Locate | | `bt_locate` | BT Locate |
| `wifi_locate` | WiFi Locate |
| `analytics` | Analytics dashboard | | `analytics` | Analytics dashboard |
| `spaceweather` | Space weather | | `spaceweather` | Space weather |
### Navigation Groups ### Navigation Groups
@@ -220,7 +221,7 @@ The navigation is organized into groups:
- **Signals**: Pager, 433MHz, Meters, Listening Post, SubGHz - **Signals**: Pager, 433MHz, Meters, Listening Post, SubGHz
- **Tracking**: Aircraft, Vessels, APRS, GPS - **Tracking**: Aircraft, Vessels, APRS, GPS
- **Space**: Satellite, ISS SSTV, Weather Sat, HF SSTV, Space Weather - **Space**: Satellite, ISS SSTV, Weather Sat, HF SSTV, Space Weather
- **Wireless**: WiFi, Bluetooth, BT Locate, Meshtastic - **Wireless**: WiFi, Bluetooth, BT Locate, WiFi Locate, Meshtastic
- **Intel**: TSCM, Analytics, Spy Stations, WebSDR - **Intel**: TSCM, Analytics, Spy Stations, WebSDR
--- ---
+53 -2
View File
@@ -172,7 +172,7 @@ Set the following environment variables (Docker recommended):
```bash ```bash
INTERCEPT_ADSB_AUTO_START=true \ INTERCEPT_ADSB_AUTO_START=true \
INTERCEPT_SHARED_OBSERVER_LOCATION=false \ INTERCEPT_SHARED_OBSERVER_LOCATION=false \
sudo -E venv/bin/python intercept.py sudo ./start.sh
``` ```
**Docker example (.env)** **Docker example (.env)**
@@ -377,6 +377,39 @@ Digital Selective Calling monitoring runs alongside AIS:
- The RSSI chart shows signal trend over time — use it to determine if you're getting closer - 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 - 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 ## GPS Mode
1. **Start GPS** - Click "Start" to connect to gpsd and begin position tracking 1. **Start GPS** - Click "Start" to connect to gpsd and begin position tracking
@@ -518,10 +551,28 @@ INTERCEPT can be configured via environment variables:
| `INTERCEPT_LOG_LEVEL` | `WARNING` | Log level (DEBUG, INFO, WARNING, ERROR) | | `INTERCEPT_LOG_LEVEL` | `WARNING` | Log level (DEBUG, INFO, WARNING, ERROR) |
| `INTERCEPT_DEFAULT_GAIN` | `40` | Default RTL-SDR gain | | `INTERCEPT_DEFAULT_GAIN` | `40` | Default RTL-SDR gain |
Example: `INTERCEPT_PORT=8080 sudo -E venv/bin/python intercept.py` Example: `INTERCEPT_PORT=8080 sudo ./start.sh`
## Command-line Options ## 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 python3 intercept.py --help
+30 -4
View File
@@ -36,7 +36,7 @@
</div> </div>
<div class="hero-stats"> <div class="hero-stats">
<div class="stat"> <div class="stat">
<span class="stat-value">25+</span> <span class="stat-value">30+</span>
<span class="stat-label">Modes</span> <span class="stat-label">Modes</span>
</div> </div>
<div class="stat"> <div class="stat">
@@ -92,6 +92,11 @@
<h3>Listening Post</h3> <h3>Listening Post</h3>
<p>Frequency scanner with real-time audio monitoring, fine-tuning controls, and customizable frequency presets.</p> <p>Frequency scanner with real-time audio monitoring, fine-tuning controls, and customizable frequency presets.</p>
</div> </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-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> <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> <h3>WebSDR</h3>
@@ -152,11 +157,21 @@
<h3>HF SSTV</h3> <h3>HF SSTV</h3>
<p>Terrestrial SSTV on shortwave frequencies. Decode amateur radio image transmissions across HF, VHF, and UHF bands.</p> <p>Terrestrial SSTV on shortwave frequencies. Decode amateur radio image transmissions across HF, VHF, and UHF bands.</p>
</div> </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-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> <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> <h3>GPS Tracking</h3>
<p>Real-time GPS position tracking with live map, speed, heading, altitude, satellite info, and track recording.</p> <p>Real-time GPS position tracking with live map, speed, heading, altitude, satellite info, and track recording.</p>
</div> </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-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> <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> <h3>Space Weather</h3>
@@ -177,6 +192,11 @@
<h3>BT Locate</h3> <h3>BT Locate</h3>
<p>SAR Bluetooth device location with GPS-tagged signal trail mapping, IRK-based RPA resolution, and proximity audio alerts.</p> <p>SAR Bluetooth device location with GPS-tagged signal trail mapping, IRK-based RPA resolution, and proximity audio alerts.</p>
</div> </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-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> <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> <h3>TSCM</h3>
@@ -197,6 +217,11 @@
<h3>Offline Mode</h3> <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> <p>Run without internet using bundled assets. Choose from multiple map tile providers or use your own local tile server.</p>
</div> </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> </div>
<button class="carousel-arrow carousel-arrow-right" aria-label="Scroll right">&#8250;</button> <button class="carousel-arrow carousel-arrow-right" aria-label="Scroll right">&#8250;</button>
</div> </div>
@@ -310,10 +335,10 @@
<div class="code-block"> <div class="code-block">
<pre><code>git clone https://github.com/smittix/intercept.git <pre><code>git clone https://github.com/smittix/intercept.git
cd intercept cd intercept
./setup.sh ./setup.sh # Interactive wizard with install profiles
sudo -E venv/bin/python intercept.py</code></pre> sudo ./start.sh</code></pre>
</div> </div>
<p class="install-note">Requires Python 3.9+ and RTL-SDR drivers</p> <p class="install-note">Menu-driven setup: choose Core, Maritime, Weather, Security, or Full SIGINT profiles. Headless mode: <code>./setup.sh --non-interactive</code></p>
</div> </div>
<div class="install-card"> <div class="install-card">
@@ -330,6 +355,7 @@ docker compose --profile basic up -d --build</code></pre>
<div class="post-install"> <div class="post-install">
<p>After starting, open <code>http://localhost:5050</code> in your browser.</p> <p>After starting, open <code>http://localhost:5050</code> in your browser.</p>
<p>Default credentials: <code>admin</code> / <code>admin</code></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>
</div> </div>
</section> </section>
+63
View File
@@ -0,0 +1,63 @@
"""Gunicorn configuration for INTERCEPT."""
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):
try:
_orig(self)
except AssertionError:
pass
_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
+11
View File
@@ -2862,6 +2862,17 @@ class ModeManager:
def _parse_aprs_packet(self, line: str) -> dict | None: def _parse_aprs_packet(self, line: str) -> dict | None:
"""Parse APRS packet from direwolf or multimon-ng.""" """Parse APRS packet from direwolf or multimon-ng."""
if not line:
return None
# Normalize common decoder prefixes before parsing.
# multimon-ng: "AFSK1200: ..."
# direwolf: "[0.4] ...", "[0L] ..."
line = line.strip()
if line.startswith('AFSK1200:'):
line = line[9:].strip()
line = re.sub(r'^(?:\[[^\]]+\]\s*)+', '', line)
match = re.match(r'([A-Z0-9-]+)>([^:]+):(.+)', line) match = re.match(r'([A-Z0-9-]+)>([^:]+):(.+)', line)
if not match: if not match:
return None return None
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "intercept" name = "intercept"
version = "2.22.3" version = "2.24.0"
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth" description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
readme = "README.md" readme = "README.md"
requires-python = ">=3.9" requires-python = ">=3.9"
+7
View File
@@ -44,3 +44,10 @@ cryptography>=41.0.0
# WebSocket support for in-app audio streaming (KiwiSDR, Listening Post) # WebSocket support for in-app audio streaming (KiwiSDR, Listening Post)
flask-sock flask-sock
websocket-client>=1.6.0 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
+12
View File
@@ -16,8 +16,12 @@ def register_blueprints(app):
from .gps import gps_bp from .gps import gps_bp
from .listening_post import receiver_bp from .listening_post import receiver_bp
from .meshtastic import meshtastic_bp from .meshtastic import meshtastic_bp
from .meteor_websocket import meteor_bp
from .morse import morse_bp
from .ook import ook_bp
from .offline import offline_bp from .offline import offline_bp
from .pager import pager_bp from .pager import pager_bp
from .radiosonde import radiosonde_bp
from .recordings import recordings_bp from .recordings import recordings_bp
from .rtlamr import rtlamr_bp from .rtlamr import rtlamr_bp
from .satellite import satellite_bp from .satellite import satellite_bp
@@ -29,10 +33,12 @@ def register_blueprints(app):
from .sstv import sstv_bp from .sstv import sstv_bp
from .sstv_general import sstv_general_bp from .sstv_general import sstv_general_bp
from .subghz import subghz_bp from .subghz import subghz_bp
from .system import system_bp
from .tscm import init_tscm_state, tscm_bp from .tscm import init_tscm_state, tscm_bp
from .updater import updater_bp from .updater import updater_bp
from .vdl2 import vdl2_bp from .vdl2 import vdl2_bp
from .weather_sat import weather_sat_bp from .weather_sat import weather_sat_bp
from .wefax import wefax_bp
from .websdr import websdr_bp from .websdr import websdr_bp
from .wifi import wifi_bp from .wifi import wifi_bp
from .wifi_v2 import wifi_v2_bp from .wifi_v2 import wifi_v2_bp
@@ -71,6 +77,12 @@ def register_blueprints(app):
app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking
app.register_blueprint(space_weather_bp) # Space weather monitoring app.register_blueprint(space_weather_bp) # Space weather monitoring
app.register_blueprint(signalid_bp) # External signal ID enrichment 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
# Initialize TSCM state with queue and lock from app # Initialize TSCM state with queue and lock from app
import app as app_module import app as app_module
+66 -25
View File
@@ -13,30 +13,35 @@ import subprocess
import threading import threading
import time import time
from datetime import datetime from datetime import datetime
from typing import Generator from typing import Any, Generator
from flask import Blueprint, jsonify, request, Response from flask import Blueprint, Response, jsonify, request
import app as app_module import app as app_module
from utils.logging import sensor_logger as logger from utils.acars_translator import translate_message
from utils.validation import validate_device_index, validate_gain, validate_ppm
from utils.sdr import SDRFactory, SDRType
from utils.sse import sse_stream_fanout
from utils.event_pipeline import process_event
from utils.constants import ( from utils.constants import (
PROCESS_START_WAIT,
PROCESS_TERMINATE_TIMEOUT, PROCESS_TERMINATE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL, SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT, SSE_QUEUE_TIMEOUT,
PROCESS_START_WAIT,
) )
from utils.event_pipeline import process_event
from utils.flight_correlator import get_flight_correlator
from utils.logging import sensor_logger as logger
from utils.process import register_process, unregister_process from utils.process import register_process, unregister_process
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') acars_bp = Blueprint('acars', __name__, url_prefix='/acars')
# Default VHF ACARS frequencies (MHz) - common worldwide # Default VHF ACARS frequencies (MHz) - North America primary
DEFAULT_ACARS_FREQUENCIES = [ DEFAULT_ACARS_FREQUENCIES = [
'131.725', # North America '131.550', # Primary worldwide / North America
'131.825', # 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 # Message counter for statistics
@@ -45,6 +50,7 @@ acars_last_message_time = None
# Track which device is being used # Track which device is being used
acars_active_device: int | None = None acars_active_device: int | None = None
acars_active_sdr_type: str | None = None
def find_acarsdec(): def find_acarsdec():
@@ -120,6 +126,15 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
data['type'] = 'acars' data['type'] = 'acars'
data['timestamp'] = datetime.utcnow().isoformat() + 'Z' 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 # Update stats
acars_message_count += 1 acars_message_count += 1
acars_last_message_time = time.time() acars_last_message_time = time.time()
@@ -128,7 +143,6 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
# Feed flight correlator # Feed flight correlator
try: try:
from utils.flight_correlator import get_flight_correlator
get_flight_correlator().add_acars_message(data) get_flight_correlator().add_acars_message(data)
except Exception: except Exception:
pass pass
@@ -151,7 +165,7 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
logger.error(f"ACARS stream error: {e}") logger.error(f"ACARS stream error: {e}")
app_module.acars_queue.put({'type': 'error', 'message': str(e)}) app_module.acars_queue.put({'type': 'error', 'message': str(e)})
finally: finally:
global acars_active_device global acars_active_device, acars_active_sdr_type
# Ensure process is terminated # Ensure process is terminated
try: try:
process.terminate() process.terminate()
@@ -167,8 +181,9 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
app_module.acars_process = None app_module.acars_process = None
# Release SDR device # Release SDR device
if acars_active_device is not None: if acars_active_device is not None:
app_module.release_sdr_device(acars_active_device) app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr')
acars_active_device = None acars_active_device = None
acars_active_sdr_type = None
@acars_bp.route('/tools') @acars_bp.route('/tools')
@@ -200,7 +215,7 @@ def acars_status() -> Response:
@acars_bp.route('/start', methods=['POST']) @acars_bp.route('/start', methods=['POST'])
def start_acars() -> Response: def start_acars() -> Response:
"""Start ACARS decoder.""" """Start ACARS decoder."""
global acars_message_count, acars_last_message_time, acars_active_device global acars_message_count, acars_last_message_time, acars_active_device, acars_active_sdr_type
with app_module.acars_lock: with app_module.acars_lock:
if app_module.acars_process and app_module.acars_process.poll() is None: if app_module.acars_process and app_module.acars_process.poll() is None:
@@ -227,9 +242,12 @@ def start_acars() -> Response:
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return jsonify({'status': 'error', 'message': str(e)}), 400
# Resolve SDR type for device selection
sdr_type_str = data.get('sdr_type', 'rtlsdr')
# Check if device is available # Check if device is available
device_int = int(device) device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'acars') error = app_module.claim_sdr_device(device_int, 'acars', sdr_type_str)
if error: if error:
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
@@ -238,6 +256,7 @@ def start_acars() -> Response:
}), 409 }), 409
acars_active_device = device_int acars_active_device = device_int
acars_active_sdr_type = sdr_type_str
# Get frequencies - use provided or defaults # Get frequencies - use provided or defaults
frequencies = data.get('frequencies', DEFAULT_ACARS_FREQUENCIES) frequencies = data.get('frequencies', DEFAULT_ACARS_FREQUENCIES)
@@ -255,8 +274,6 @@ def start_acars() -> Response:
acars_message_count = 0 acars_message_count = 0
acars_last_message_time = None acars_last_message_time = None
# Resolve SDR type for device selection
sdr_type_str = data.get('sdr_type', 'rtlsdr')
try: try:
sdr_type = SDRType(sdr_type_str) sdr_type = SDRType(sdr_type_str)
except ValueError: except ValueError:
@@ -343,14 +360,17 @@ def start_acars() -> Response:
if process.poll() is not None: if process.poll() is not None:
# Process died - release device # Process died - release device
if acars_active_device is not None: if acars_active_device is not None:
app_module.release_sdr_device(acars_active_device) app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr')
acars_active_device = None acars_active_device = None
acars_active_sdr_type = None
stderr = '' stderr = ''
if process.stderr: if process.stderr:
stderr = process.stderr.read().decode('utf-8', errors='replace') stderr = process.stderr.read().decode('utf-8', errors='replace')
error_msg = f'acarsdec failed to start'
if stderr: if stderr:
error_msg += f': {stderr[:200]}' logger.error(f"acarsdec stderr:\n{stderr}")
error_msg = 'acarsdec failed to start'
if stderr:
error_msg += f': {stderr[:500]}'
logger.error(error_msg) logger.error(error_msg)
return jsonify({'status': 'error', 'message': error_msg}), 500 return jsonify({'status': 'error', 'message': error_msg}), 500
@@ -375,8 +395,9 @@ def start_acars() -> Response:
except Exception as e: except Exception as e:
# Release device on failure # Release device on failure
if acars_active_device is not None: if acars_active_device is not None:
app_module.release_sdr_device(acars_active_device) app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr')
acars_active_device = None acars_active_device = None
acars_active_sdr_type = None
logger.error(f"Failed to start ACARS decoder: {e}") logger.error(f"Failed to start ACARS decoder: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500 return jsonify({'status': 'error', 'message': str(e)}), 500
@@ -384,7 +405,7 @@ def start_acars() -> Response:
@acars_bp.route('/stop', methods=['POST']) @acars_bp.route('/stop', methods=['POST'])
def stop_acars() -> Response: def stop_acars() -> Response:
"""Stop ACARS decoder.""" """Stop ACARS decoder."""
global acars_active_device global acars_active_device, acars_active_sdr_type
with app_module.acars_lock: with app_module.acars_lock:
if not app_module.acars_process: if not app_module.acars_process:
@@ -405,8 +426,9 @@ def stop_acars() -> Response:
# Release device from registry # Release device from registry
if acars_active_device is not None: if acars_active_device is not None:
app_module.release_sdr_device(acars_active_device) app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr')
acars_active_device = None acars_active_device = None
acars_active_sdr_type = None
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
@@ -432,13 +454,32 @@ def stream_acars() -> Response:
return response 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') @acars_bp.route('/frequencies')
def get_frequencies() -> Response: def get_frequencies() -> Response:
"""Get default ACARS frequencies.""" """Get default ACARS frequencies."""
return jsonify({ return jsonify({
'default': DEFAULT_ACARS_FREQUENCIES, 'default': DEFAULT_ACARS_FREQUENCIES,
'regions': { 'regions': {
'north_america': ['131.725', '131.825'], 'north_america': ['131.550', '130.025', '129.125', '131.725', '131.825'],
'europe': ['131.525', '131.725', '131.550'], 'europe': ['131.525', '131.725', '131.550'],
'asia_pacific': ['131.550', '131.450'], 'asia_pacific': ['131.550', '131.450'],
} }
+540 -45
View File
@@ -3,6 +3,8 @@
from __future__ import annotations from __future__ import annotations
import json import json
import csv
import io
import os import os
import queue import queue
import shutil import shutil
@@ -10,11 +12,10 @@ import socket
import subprocess import subprocess
import threading import threading
import time import time
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Generator from typing import Any, Generator
from flask import Blueprint, jsonify, request, Response, render_template from flask import Blueprint, Response, jsonify, make_response, render_template, request
from flask import make_response
# psycopg2 is optional - only needed for PostgreSQL history persistence # psycopg2 is optional - only needed for PostgreSQL history persistence
try: try:
@@ -28,39 +29,38 @@ except ImportError:
import app as app_module import app as app_module
from config import ( from config import (
ADSB_AUTO_START,
ADSB_DB_HOST, ADSB_DB_HOST,
ADSB_DB_NAME, ADSB_DB_NAME,
ADSB_DB_PASSWORD, ADSB_DB_PASSWORD,
ADSB_DB_PORT, ADSB_DB_PORT,
ADSB_DB_USER, ADSB_DB_USER,
ADSB_AUTO_START,
ADSB_HISTORY_ENABLED, ADSB_HISTORY_ENABLED,
SHARED_OBSERVER_LOCATION_ENABLED, SHARED_OBSERVER_LOCATION_ENABLED,
) )
from utils.logging import adsb_logger as logger from utils import aircraft_db
from utils.process import write_dump1090_pid, clear_dump1090_pid, cleanup_stale_dump1090 from utils.acars_translator import translate_message
from utils.validation import ( from utils.adsb_history import _ensure_adsb_schema, adsb_history_writer, adsb_snapshot_writer
validate_device_index, validate_gain,
validate_rtl_tcp_host, validate_rtl_tcp_port
)
from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.sdr import SDRFactory, SDRType
from utils.constants import ( from utils.constants import (
ADSB_SBS_PORT, ADSB_SBS_PORT,
ADSB_TERMINATE_TIMEOUT, ADSB_TERMINATE_TIMEOUT,
PROCESS_TERMINATE_TIMEOUT,
SBS_SOCKET_TIMEOUT,
SBS_RECONNECT_DELAY,
SOCKET_BUFFER_SIZE,
SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT,
SOCKET_CONNECT_TIMEOUT,
ADSB_UPDATE_INTERVAL, ADSB_UPDATE_INTERVAL,
DUMP1090_START_WAIT, DUMP1090_START_WAIT,
PROCESS_TERMINATE_TIMEOUT,
SBS_RECONNECT_DELAY,
SBS_SOCKET_TIMEOUT,
SOCKET_BUFFER_SIZE,
SOCKET_CONNECT_TIMEOUT,
SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT,
) )
from utils import aircraft_db from utils.event_pipeline import process_event
from utils.adsb_history import adsb_history_writer, adsb_snapshot_writer, _ensure_adsb_schema from utils.flight_correlator import get_flight_correlator
from utils.logging import adsb_logger as logger
from utils.process import cleanup_stale_dump1090, clear_dump1090_pid, write_dump1090_pid
from utils.sdr import SDRFactory, SDRType
from utils.sse import format_sse
from utils.validation import validate_device_index, validate_gain, validate_rtl_tcp_host, validate_rtl_tcp_port
adsb_bp = Blueprint('adsb', __name__, url_prefix='/adsb') adsb_bp = Blueprint('adsb', __name__, url_prefix='/adsb')
@@ -72,6 +72,7 @@ adsb_last_message_time = None
adsb_bytes_received = 0 adsb_bytes_received = 0
adsb_lines_received = 0 adsb_lines_received = 0
adsb_active_device = None # Track which device index is being used adsb_active_device = None # Track which device index is being used
adsb_active_sdr_type: str | None = None
_sbs_error_logged = False # Suppress repeated connection error logs _sbs_error_logged = False # Suppress repeated connection error logs
# Track ICAOs already looked up in aircraft database (avoid repeated lookups) # Track ICAOs already looked up in aircraft database (avoid repeated lookups)
@@ -196,6 +197,40 @@ def _ensure_history_schema() -> None:
logger.warning("ADS-B schema check failed: %s", exc) logger.warning("ADS-B schema check failed: %s", exc)
MILITARY_ICAO_RANGES = [
(0xADF7C0, 0xADFFFF), # US
(0xAE0000, 0xAEFFFF), # US
(0x3F4000, 0x3F7FFF), # FR
(0x43C000, 0x43CFFF), # UK
(0x3D0000, 0x3DFFFF), # DE
(0x501C00, 0x501FFF), # NATO
]
MILITARY_CALLSIGN_PREFIXES = (
'REACH', 'JAKE', 'DOOM', 'IRON', 'HAWK', 'VIPER', 'COBRA', 'THUNDER',
'SHADOW', 'NIGHT', 'STEEL', 'GRIM', 'REAPER', 'BLADE', 'STRIKE',
'RCH', 'CNV', 'MCH', 'EVAC', 'TOPCAT', 'ASCOT', 'RRR', 'HRK',
'NAVY', 'ARMY', 'USAF', 'RAF', 'RCAF', 'RAAF', 'IAF', 'PAF',
)
def _is_military_aircraft(icao: str, callsign: str | None) -> bool:
"""Return True if the ICAO hex or callsign indicates a military aircraft."""
try:
hex_val = int(icao, 16)
for start, end in MILITARY_ICAO_RANGES:
if start <= hex_val <= end:
return True
except (ValueError, TypeError):
pass
if callsign:
upper = callsign.upper().strip()
for prefix in MILITARY_CALLSIGN_PREFIXES:
if upper.startswith(prefix):
return True
return False
def _parse_int_param(value: str | None, default: int, min_value: int | None = None, max_value: int | None = None) -> int: def _parse_int_param(value: str | None, default: int, min_value: int | None = None, max_value: int | None = None) -> int:
try: try:
parsed = int(value) if value is not None else default parsed = int(value) if value is not None else default
@@ -208,6 +243,137 @@ def _parse_int_param(value: str | None, default: int, min_value: int | None = No
return parsed return parsed
def _parse_iso_datetime(value: Any) -> datetime | None:
if not isinstance(value, str):
return None
cleaned = value.strip()
if not cleaned:
return None
if cleaned.endswith('Z'):
cleaned = f"{cleaned[:-1]}+00:00"
try:
parsed = datetime.fromisoformat(cleaned)
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def _parse_export_scope(
args: Any,
) -> tuple[str, int, datetime | None, datetime | None]:
scope = str(args.get('scope') or 'window').strip().lower()
if scope not in {'window', 'all', 'custom'}:
scope = 'window'
since_minutes = _parse_int_param(args.get('since_minutes'), 1440, 1, 525600)
start = _parse_iso_datetime(args.get('start'))
end = _parse_iso_datetime(args.get('end'))
if scope == 'custom' and (start is None or end is None or end <= start):
scope = 'window'
return scope, since_minutes, start, end
def _add_time_filter(
*,
where_parts: list[str],
params: list[Any],
scope: str,
timestamp_field: str,
since_minutes: int,
start: datetime | None,
end: datetime | None,
) -> None:
if scope == 'all':
return
if scope == 'custom' and start is not None and end is not None:
where_parts.append(f"{timestamp_field} >= %s AND {timestamp_field} < %s")
params.extend([start, end])
return
where_parts.append(f"{timestamp_field} >= NOW() - INTERVAL %s")
params.append(f'{since_minutes} minutes')
def _serialize_export_value(value: Any) -> Any:
if isinstance(value, datetime):
return value.isoformat()
return value
def _rows_to_serializable(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [{key: _serialize_export_value(value) for key, value in row.items()} for row in rows]
def _build_export_csv(
*,
exported_at: str,
scope: str,
since_minutes: int | None,
icao: str,
search: str,
classification: str,
messages: list[dict[str, Any]],
snapshots: list[dict[str, Any]],
sessions: list[dict[str, Any]],
export_type: str,
) -> str:
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(['Exported At', exported_at])
writer.writerow(['Scope', scope])
if since_minutes is not None:
writer.writerow(['Since Minutes', since_minutes])
if icao:
writer.writerow(['ICAO Filter', icao])
if search:
writer.writerow(['Search Filter', search])
if classification != 'all':
writer.writerow(['Classification', classification])
writer.writerow([])
def write_section(title: str, rows: list[dict[str, Any]], columns: list[str]) -> None:
writer.writerow([title])
writer.writerow(columns)
for row in rows:
writer.writerow([_serialize_export_value(row.get(col)) for col in columns])
writer.writerow([])
if export_type in {'messages', 'all'}:
write_section(
'Messages',
messages,
[
'received_at', 'msg_time', 'logged_time', 'icao', 'msg_type', 'callsign',
'altitude', 'speed', 'heading', 'vertical_rate', 'lat', 'lon', 'squawk',
'session_id', 'aircraft_id', 'flight_id', 'source_host', 'raw_line',
],
)
if export_type in {'snapshots', 'all'}:
write_section(
'Snapshots',
snapshots,
[
'captured_at', 'icao', 'callsign', 'registration', 'type_code', 'type_desc',
'altitude', 'speed', 'heading', 'vertical_rate', 'lat', 'lon', 'squawk',
'source_host',
],
)
if export_type in {'sessions', 'all'}:
write_section(
'Sessions',
sessions,
[
'id', 'started_at', 'ended_at', 'device_index', 'sdr_type', 'remote_host',
'remote_port', 'start_source', 'stop_source', 'started_by', 'stopped_by', 'notes',
],
)
return output.getvalue()
def _broadcast_adsb_update(payload: dict[str, Any]) -> None: def _broadcast_adsb_update(payload: dict[str, Any]) -> None:
"""Fan out a payload to all active ADS-B SSE subscribers.""" """Fan out a payload to all active ADS-B SSE subscribers."""
with _adsb_stream_subscribers_lock: with _adsb_stream_subscribers_lock:
@@ -365,6 +531,7 @@ def parse_sbs_stream(service_addr):
_sbs_error_logged = False _sbs_error_logged = False
while adsb_using_service: while adsb_using_service:
sock = None
try: try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(SBS_SOCKET_TIMEOUT) sock.settimeout(SBS_SOCKET_TIMEOUT)
@@ -587,7 +754,6 @@ def parse_sbs_stream(service_addr):
continue continue
flush_pending_updates(force=True) flush_pending_updates(force=True)
sock.close()
adsb_connected = False adsb_connected = False
except OSError as e: except OSError as e:
adsb_connected = False adsb_connected = False
@@ -595,6 +761,12 @@ def parse_sbs_stream(service_addr):
logger.warning(f"SBS connection error: {e}, reconnecting...") logger.warning(f"SBS connection error: {e}, reconnecting...")
_sbs_error_logged = True _sbs_error_logged = True
time.sleep(SBS_RECONNECT_DELAY) time.sleep(SBS_RECONNECT_DELAY)
finally:
if sock:
try:
sock.close()
except OSError:
pass
adsb_connected = False adsb_connected = False
logger.info("SBS stream parser stopped") logger.info("SBS stream parser stopped")
@@ -674,7 +846,7 @@ def adsb_session():
@adsb_bp.route('/start', methods=['POST']) @adsb_bp.route('/start', methods=['POST'])
def start_adsb(): def start_adsb():
"""Start ADS-B tracking.""" """Start ADS-B tracking."""
global adsb_using_service, adsb_active_device global adsb_using_service, adsb_active_device, adsb_active_sdr_type
with app_module.adsb_lock: with app_module.adsb_lock:
if adsb_using_service: if adsb_using_service:
@@ -685,7 +857,7 @@ def start_adsb():
'session': session 'session': session
}), 409 }), 409
data = request.json or {} data = request.get_json(silent=True) or {}
start_source = data.get('source') start_source = data.get('source')
started_by = request.remote_addr started_by = request.remote_addr
@@ -757,6 +929,7 @@ def start_adsb():
sdr_type = SDRType(sdr_type_str) sdr_type = SDRType(sdr_type_str)
except ValueError: except ValueError:
sdr_type = SDRType.RTL_SDR sdr_type = SDRType.RTL_SDR
sdr_type_str = sdr_type.value
# For RTL-SDR, use dump1090. For other hardware, need readsb with SoapySDR # For RTL-SDR, use dump1090. For other hardware, need readsb with SoapySDR
if sdr_type == SDRType.RTL_SDR: if sdr_type == SDRType.RTL_SDR:
@@ -787,7 +960,7 @@ def start_adsb():
# Check if device is available before starting local dump1090 # Check if device is available before starting local dump1090
device_int = int(device) device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'adsb') error = app_module.claim_sdr_device(device_int, 'adsb', sdr_type_str)
if error: if error:
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
@@ -795,6 +968,10 @@ def start_adsb():
'message': error 'message': error
}), 409 }), 409
# Track claimed device immediately so stop_adsb() can always release it
adsb_active_device = device
adsb_active_sdr_type = sdr_type_str
# Create device object and build command via abstraction layer # Create device object and build command via abstraction layer
sdr_device = SDRFactory.create_default_device(sdr_type, index=device) sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
builder = SDRFactory.get_builder(sdr_type) builder = SDRFactory.get_builder(sdr_type)
@@ -807,9 +984,8 @@ def start_adsb():
bias_t=bias_t bias_t=bias_t
) )
# For RTL-SDR, ensure we use the found dump1090 path # Ensure we use the resolved binary path for all SDR types
if sdr_type == SDRType.RTL_SDR: cmd[0] = dump1090_path
cmd[0] = dump1090_path
try: try:
logger.info(f"Starting dump1090 with device index {device}: {' '.join(cmd)}") logger.info(f"Starting dump1090 with device index {device}: {' '.join(cmd)}")
@@ -821,11 +997,24 @@ def start_adsb():
) )
write_dump1090_pid(app_module.adsb_process.pid) write_dump1090_pid(app_module.adsb_process.pid)
time.sleep(DUMP1090_START_WAIT) # Poll for dump1090 readiness instead of blind sleep
dump1090_ready = False
poll_interval = 0.1
elapsed = 0.0
while elapsed < DUMP1090_START_WAIT:
if app_module.adsb_process.poll() is not None:
break # Process exited early — handle below
if check_dump1090_service():
dump1090_ready = True
break
time.sleep(poll_interval)
elapsed += poll_interval
if app_module.adsb_process.poll() is not None: if app_module.adsb_process.poll() is not None:
# Process exited - release device and get error message # Process exited - release device and get error message
app_module.release_sdr_device(device_int) app_module.release_sdr_device(device_int, sdr_type_str)
adsb_active_device = None
adsb_active_sdr_type = None
stderr_output = '' stderr_output = ''
if app_module.adsb_process.stderr: if app_module.adsb_process.stderr:
try: try:
@@ -837,12 +1026,22 @@ def start_adsb():
error_type = 'START_FAILED' error_type = 'START_FAILED'
stderr_lower = stderr_output.lower() stderr_lower = stderr_output.lower()
sdr_label = sdr_type.value
if 'usb_claim_interface' in stderr_lower or 'libusb_error_busy' in stderr_lower or 'device or resource busy' in stderr_lower: if 'usb_claim_interface' in stderr_lower or 'libusb_error_busy' in stderr_lower or 'device or resource busy' in stderr_lower:
error_msg = 'SDR device is busy. Another process may be using it.' error_msg = 'SDR device is busy. Another process may be using it.'
suggestion = 'Try: 1) Stop other SDR applications, 2) Run "pkill -f rtl_" to kill stale processes, or 3) Remove and reinsert the SDR device.' suggestion = 'Try: 1) Stop other SDR applications, 2) Run "pkill -f rtl_" to kill stale processes, or 3) Remove and reinsert the SDR device.'
error_type = 'DEVICE_BUSY' error_type = 'DEVICE_BUSY'
elif 'no hackrf boards found' in stderr_lower or 'hackrf_open' in stderr_lower:
error_msg = f'{sdr_label} device not found.'
suggestion = 'Ensure the HackRF is connected. Try removing and reinserting the device.'
error_type = 'DEVICE_NOT_FOUND'
elif 'soapysdr not found' in stderr_lower or 'soapy' in stderr_lower and 'not found' in stderr_lower:
error_msg = f'SoapySDR driver not found for {sdr_label}.'
suggestion = f'Install SoapySDR and the {sdr_label} module (e.g., soapysdr-module-hackrf).'
error_type = 'DRIVER_NOT_FOUND'
elif 'no supported devices' in stderr_lower or 'no rtl-sdr' in stderr_lower or 'failed to open' in stderr_lower: elif 'no supported devices' in stderr_lower or 'no rtl-sdr' in stderr_lower or 'failed to open' in stderr_lower:
error_msg = 'RTL-SDR device not found.' error_msg = f'{sdr_label} device not found.'
suggestion = 'Ensure the device is connected. Try removing and reinserting the SDR.' suggestion = 'Ensure the device is connected. Try removing and reinserting the SDR.'
error_type = 'DEVICE_NOT_FOUND' error_type = 'DEVICE_NOT_FOUND'
elif 'kernel driver is active' in stderr_lower or 'dvb' in stderr_lower: elif 'kernel driver is active' in stderr_lower or 'dvb' in stderr_lower:
@@ -850,14 +1049,14 @@ def start_adsb():
suggestion = 'Blacklist the DVB drivers: Go to Settings > Hardware > "Blacklist DVB Drivers" or run "sudo rmmod dvb_usb_rtl28xxu".' suggestion = 'Blacklist the DVB drivers: Go to Settings > Hardware > "Blacklist DVB Drivers" or run "sudo rmmod dvb_usb_rtl28xxu".'
error_type = 'KERNEL_DRIVER' error_type = 'KERNEL_DRIVER'
elif 'permission' in stderr_lower or 'access' in stderr_lower: elif 'permission' in stderr_lower or 'access' in stderr_lower:
error_msg = 'Permission denied accessing RTL-SDR device.' error_msg = f'Permission denied accessing {sdr_label} device.'
suggestion = 'Run Intercept with sudo, or add udev rules for RTL-SDR devices.' suggestion = f'Run Intercept with sudo, or add udev rules for {sdr_label} devices.'
error_type = 'PERMISSION_DENIED' error_type = 'PERMISSION_DENIED'
elif sdr_type == SDRType.RTL_SDR: elif sdr_type == SDRType.RTL_SDR:
error_msg = 'dump1090 failed to start.' error_msg = 'dump1090 failed to start.'
suggestion = 'Try removing and reinserting the SDR device, or check if another application is using it.' suggestion = 'Try removing and reinserting the SDR device, or check if another application is using it.'
else: else:
error_msg = f'ADS-B decoder failed to start for {sdr_type.value}.' error_msg = f'ADS-B decoder failed to start for {sdr_label}.'
suggestion = 'Ensure readsb is installed with SoapySDR support and the device is connected.' suggestion = 'Ensure readsb is installed with SoapySDR support and the device is connected.'
full_msg = f'{error_msg} {suggestion}' full_msg = f'{error_msg} {suggestion}'
@@ -870,8 +1069,37 @@ def start_adsb():
'message': full_msg 'message': full_msg
}) })
# dump1090 is still running but SBS port never came up — device may be
# held by a stale process from a previous mode. Kill it so the USB
# device is released and report a clear error to the frontend.
if not dump1090_ready:
logger.warning("dump1090 running but SBS port not available after %.1fs — killing", DUMP1090_START_WAIT)
try:
pgid = os.getpgid(app_module.adsb_process.pid)
os.killpg(pgid, 15)
app_module.adsb_process.wait(timeout=ADSB_TERMINATE_TIMEOUT)
except (subprocess.TimeoutExpired, ProcessLookupError, OSError):
try:
pgid = os.getpgid(app_module.adsb_process.pid)
os.killpg(pgid, 9)
except (ProcessLookupError, OSError):
pass
app_module.adsb_process = None
clear_dump1090_pid()
app_module.release_sdr_device(device_int, sdr_type_str)
adsb_active_device = None
adsb_active_sdr_type = None
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': (
'SDR device did not become ready in time. '
'Another mode may still be releasing the device. '
'Please wait a moment and try again.'
),
})
adsb_using_service = True adsb_using_service = True
adsb_active_device = device # Track which device is being used
thread = threading.Thread(target=parse_sbs_stream, args=(f'localhost:{ADSB_SBS_PORT}',), daemon=True) thread = threading.Thread(target=parse_sbs_stream, args=(f'localhost:{ADSB_SBS_PORT}',), daemon=True)
thread.start() thread.start()
@@ -891,15 +1119,17 @@ def start_adsb():
}) })
except Exception as e: except Exception as e:
# Release device on failure # Release device on failure
app_module.release_sdr_device(device_int) app_module.release_sdr_device(device_int, sdr_type_str)
adsb_active_device = None
adsb_active_sdr_type = None
return jsonify({'status': 'error', 'message': str(e)}) return jsonify({'status': 'error', 'message': str(e)})
@adsb_bp.route('/stop', methods=['POST']) @adsb_bp.route('/stop', methods=['POST'])
def stop_adsb(): def stop_adsb():
"""Stop ADS-B tracking.""" """Stop ADS-B tracking."""
global adsb_using_service, adsb_active_device global adsb_using_service, adsb_active_device, adsb_active_sdr_type
data = request.json or {} data = request.get_json(silent=True) or {}
stop_source = data.get('source') stop_source = data.get('source')
stopped_by = request.remote_addr stopped_by = request.remote_addr
@@ -923,10 +1153,11 @@ def stop_adsb():
# Release device from registry # Release device from registry
if adsb_active_device is not None: if adsb_active_device is not None:
app_module.release_sdr_device(adsb_active_device) app_module.release_sdr_device(adsb_active_device, adsb_active_sdr_type or 'rtlsdr')
adsb_using_service = False adsb_using_service = False
adsb_active_device = None adsb_active_device = None
adsb_active_sdr_type = None
app_module.adsb_aircraft.clear() app_module.adsb_aircraft.clear()
_looked_up_icaos.clear() _looked_up_icaos.clear()
@@ -1005,7 +1236,7 @@ def adsb_history_summary():
return jsonify({'error': 'ADS-B history is disabled'}), 503 return jsonify({'error': 'ADS-B history is disabled'}), 503
_ensure_history_schema() _ensure_history_schema()
since_minutes = _parse_int_param(request.args.get('since_minutes'), 60, 1, 10080) since_minutes = _parse_int_param(request.args.get('since_minutes'), 1440, 1, 10080)
window = f'{since_minutes} minutes' window = f'{since_minutes} minutes'
sql = """ sql = """
@@ -1035,7 +1266,7 @@ def adsb_history_aircraft():
return jsonify({'error': 'ADS-B history is disabled'}), 503 return jsonify({'error': 'ADS-B history is disabled'}), 503
_ensure_history_schema() _ensure_history_schema()
since_minutes = _parse_int_param(request.args.get('since_minutes'), 60, 1, 10080) since_minutes = _parse_int_param(request.args.get('since_minutes'), 1440, 1, 10080)
limit = _parse_int_param(request.args.get('limit'), 200, 1, 2000) limit = _parse_int_param(request.args.get('limit'), 200, 1, 2000)
search = (request.args.get('search') or '').strip() search = (request.args.get('search') or '').strip()
window = f'{since_minutes} minutes' window = f'{since_minutes} minutes'
@@ -1089,7 +1320,7 @@ def adsb_history_timeline():
if not icao: if not icao:
return jsonify({'error': 'icao is required'}), 400 return jsonify({'error': 'icao is required'}), 400
since_minutes = _parse_int_param(request.args.get('since_minutes'), 60, 1, 10080) since_minutes = _parse_int_param(request.args.get('since_minutes'), 1440, 1, 10080)
limit = _parse_int_param(request.args.get('limit'), 2000, 1, 20000) limit = _parse_int_param(request.args.get('limit'), 2000, 1, 20000)
window = f'{since_minutes} minutes' window = f'{since_minutes} minutes'
@@ -1145,6 +1376,256 @@ def adsb_history_messages():
return jsonify({'error': 'History database unavailable'}), 503 return jsonify({'error': 'History database unavailable'}), 503
@adsb_bp.route('/history/export')
def adsb_history_export():
"""Export ADS-B history data in CSV or JSON format."""
if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE:
return jsonify({'error': 'ADS-B history is disabled'}), 503
_ensure_history_schema()
export_format = str(request.args.get('format') or 'csv').strip().lower()
export_type = str(request.args.get('type') or 'all').strip().lower()
if export_format not in {'csv', 'json'}:
return jsonify({'error': 'format must be csv or json'}), 400
if export_type not in {'messages', 'snapshots', 'sessions', 'all'}:
return jsonify({'error': 'type must be messages, snapshots, sessions, or all'}), 400
scope, since_minutes, start, end = _parse_export_scope(request.args)
icao = (request.args.get('icao') or '').strip().upper()
search = (request.args.get('search') or '').strip()
classification = str(request.args.get('classification') or 'all').strip().lower()
if classification not in {'all', 'military', 'civilian'}:
classification = 'all'
pattern = f'%{search}%'
snapshots: list[dict[str, Any]] = []
messages: list[dict[str, Any]] = []
sessions: list[dict[str, Any]] = []
def _filter_by_classification(
rows: list[dict[str, Any]],
icao_key: str = 'icao',
callsign_key: str = 'callsign',
) -> list[dict[str, Any]]:
if classification == 'all':
return rows
want_military = classification == 'military'
return [
r for r in rows
if _is_military_aircraft(r.get(icao_key, ''), r.get(callsign_key)) == want_military
]
try:
with _get_history_connection() as conn:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
if export_type in {'snapshots', 'all'}:
snapshot_where: list[str] = []
snapshot_params: list[Any] = []
_add_time_filter(
where_parts=snapshot_where,
params=snapshot_params,
scope=scope,
timestamp_field='captured_at',
since_minutes=since_minutes,
start=start,
end=end,
)
if icao:
snapshot_where.append("icao = %s")
snapshot_params.append(icao)
if search:
snapshot_where.append("(icao ILIKE %s OR callsign ILIKE %s OR registration ILIKE %s)")
snapshot_params.extend([pattern, pattern, pattern])
snapshot_sql = """
SELECT captured_at, icao, callsign, registration, type_code, type_desc,
altitude, speed, heading, vertical_rate, lat, lon, squawk, source_host
FROM adsb_snapshots
"""
if snapshot_where:
snapshot_sql += " WHERE " + " AND ".join(snapshot_where)
snapshot_sql += " ORDER BY captured_at DESC"
cur.execute(snapshot_sql, tuple(snapshot_params))
snapshots = _filter_by_classification(cur.fetchall())
if export_type in {'messages', 'all'}:
message_where: list[str] = []
message_params: list[Any] = []
_add_time_filter(
where_parts=message_where,
params=message_params,
scope=scope,
timestamp_field='received_at',
since_minutes=since_minutes,
start=start,
end=end,
)
if icao:
message_where.append("icao = %s")
message_params.append(icao)
if search:
message_where.append("(icao ILIKE %s OR callsign ILIKE %s)")
message_params.extend([pattern, pattern])
message_sql = """
SELECT received_at, msg_time, logged_time, icao, msg_type, callsign,
altitude, speed, heading, vertical_rate, lat, lon, squawk,
session_id, aircraft_id, flight_id, source_host, raw_line
FROM adsb_messages
"""
if message_where:
message_sql += " WHERE " + " AND ".join(message_where)
message_sql += " ORDER BY received_at DESC"
cur.execute(message_sql, tuple(message_params))
messages = _filter_by_classification(cur.fetchall())
if export_type in {'sessions', 'all'}:
session_where: list[str] = []
session_params: list[Any] = []
if scope == 'custom' and start is not None and end is not None:
session_where.append("COALESCE(ended_at, %s) >= %s AND started_at < %s")
session_params.extend([end, start, end])
elif scope == 'window':
session_where.append("COALESCE(ended_at, NOW()) >= NOW() - INTERVAL %s")
session_params.append(f'{since_minutes} minutes')
session_sql = """
SELECT id, started_at, ended_at, device_index, sdr_type, remote_host,
remote_port, start_source, stop_source, started_by, stopped_by, notes
FROM adsb_sessions
"""
if session_where:
session_sql += " WHERE " + " AND ".join(session_where)
session_sql += " ORDER BY started_at DESC"
cur.execute(session_sql, tuple(session_params))
sessions = cur.fetchall()
except Exception as exc:
logger.warning("ADS-B history export failed: %s", exc)
return jsonify({'error': 'History database unavailable'}), 503
exported_at = datetime.now(timezone.utc).isoformat()
timestamp = datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')
filename_scope = 'all' if scope == 'all' else ('custom' if scope == 'custom' else f'{since_minutes}m')
filename = f'adsb_history_{export_type}_{filename_scope}_{timestamp}.{export_format}'
if export_format == 'json':
payload = {
'exported_at': exported_at,
'format': export_format,
'type': export_type,
'scope': scope,
'since_minutes': None if scope != 'window' else since_minutes,
'filters': {
'icao': icao or None,
'search': search or None,
'classification': classification,
'start': start.isoformat() if start else None,
'end': end.isoformat() if end else None,
},
'counts': {
'messages': len(messages),
'snapshots': len(snapshots),
'sessions': len(sessions),
},
'messages': _rows_to_serializable(messages),
'snapshots': _rows_to_serializable(snapshots),
'sessions': _rows_to_serializable(sessions),
}
response = Response(
json.dumps(payload, indent=2, default=str),
mimetype='application/json',
)
response.headers['Content-Disposition'] = f'attachment; filename={filename}'
return response
csv_data = _build_export_csv(
exported_at=exported_at,
scope=scope,
since_minutes=since_minutes if scope == 'window' else None,
icao=icao,
search=search,
classification=classification,
messages=messages,
snapshots=snapshots,
sessions=sessions,
export_type=export_type,
)
response = Response(csv_data, mimetype='text/csv')
response.headers['Content-Disposition'] = f'attachment; filename={filename}'
return response
@adsb_bp.route('/history/prune', methods=['POST'])
def adsb_history_prune():
"""Delete ADS-B history for a selected time range or entire dataset."""
if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE:
return jsonify({'error': 'ADS-B history is disabled'}), 503
_ensure_history_schema()
payload = request.get_json(silent=True) or {}
mode = str(payload.get('mode') or 'range').strip().lower()
if mode not in {'range', 'all'}:
return jsonify({'error': 'mode must be range or all'}), 400
try:
with _get_history_connection() as conn:
with conn.cursor() as cur:
deleted = {'messages': 0, 'snapshots': 0}
if mode == 'all':
cur.execute("DELETE FROM adsb_messages")
deleted['messages'] = max(0, cur.rowcount or 0)
cur.execute("DELETE FROM adsb_snapshots")
deleted['snapshots'] = max(0, cur.rowcount or 0)
return jsonify({
'status': 'ok',
'mode': 'all',
'deleted': deleted,
'total_deleted': deleted['messages'] + deleted['snapshots'],
})
start = _parse_iso_datetime(payload.get('start'))
end = _parse_iso_datetime(payload.get('end'))
if start is None or end is None:
return jsonify({'error': 'start and end ISO datetime values are required'}), 400
if end <= start:
return jsonify({'error': 'end must be after start'}), 400
if end - start > timedelta(days=31):
return jsonify({'error': 'range cannot exceed 31 days'}), 400
cur.execute(
"""
DELETE FROM adsb_messages
WHERE received_at >= %s
AND received_at < %s
""",
(start, end),
)
deleted['messages'] = max(0, cur.rowcount or 0)
cur.execute(
"""
DELETE FROM adsb_snapshots
WHERE captured_at >= %s
AND captured_at < %s
""",
(start, end),
)
deleted['snapshots'] = max(0, cur.rowcount or 0)
return jsonify({
'status': 'ok',
'mode': 'range',
'start': start.isoformat(),
'end': end.isoformat(),
'deleted': deleted,
'total_deleted': deleted['messages'] + deleted['snapshots'],
})
except Exception as exc:
logger.warning("ADS-B history prune failed: %s", exc)
return jsonify({'error': 'History database unavailable'}), 503
# ============================================ # ============================================
# AIRCRAFT DATABASE MANAGEMENT # AIRCRAFT DATABASE MANAGEMENT
# ============================================ # ============================================
@@ -1224,7 +1705,21 @@ def get_aircraft_messages(icao: str):
aircraft = app_module.adsb_aircraft.get(icao.upper()) aircraft = app_module.adsb_aircraft.get(icao.upper())
callsign = aircraft.get('callsign') if aircraft else None callsign = aircraft.get('callsign') if aircraft else None
registration = aircraft.get('registration') if aircraft else None
messages = get_flight_correlator().get_messages_for_aircraft(
icao=icao.upper(), callsign=callsign, registration=registration
)
# Backfill translation on messages missing label_description
try:
for msg in messages.get('acars', []):
if not msg.get('label_description'):
translation = translate_message(msg)
msg['label_description'] = translation['label_description']
msg['message_type'] = translation['message_type']
msg['parsed'] = translation['parsed']
except Exception:
pass
from utils.flight_correlator import get_flight_correlator
messages = get_flight_correlator().get_messages_for_aircraft(icao=icao.upper(), callsign=callsign)
return jsonify({'status': 'success', 'icao': icao.upper(), **messages}) return jsonify({'status': 'success', 'icao': icao.upper(), **messages})
+19 -8
View File
@@ -44,6 +44,7 @@ ais_connected = False
ais_messages_received = 0 ais_messages_received = 0
ais_last_message_time = None ais_last_message_time = None
ais_active_device = None ais_active_device = None
ais_active_sdr_type: str | None = None
_ais_error_logged = True _ais_error_logged = True
# Common installation paths for AIS-catcher # Common installation paths for AIS-catcher
@@ -79,6 +80,7 @@ def parse_ais_stream(port: int):
_ais_error_logged = True _ais_error_logged = True
while ais_running: while ais_running:
sock = None
try: try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(AIS_SOCKET_TIMEOUT) sock.settimeout(AIS_SOCKET_TIMEOUT)
@@ -151,7 +153,6 @@ def parse_ais_stream(port: int):
except socket.timeout: except socket.timeout:
continue continue
sock.close()
ais_connected = False ais_connected = False
except OSError as e: except OSError as e:
ais_connected = False ais_connected = False
@@ -159,6 +160,12 @@ def parse_ais_stream(port: int):
logger.warning(f"AIS connection error: {e}, reconnecting...") logger.warning(f"AIS connection error: {e}, reconnecting...")
_ais_error_logged = True _ais_error_logged = True
time.sleep(AIS_RECONNECT_DELAY) time.sleep(AIS_RECONNECT_DELAY)
finally:
if sock:
try:
sock.close()
except OSError:
pass
ais_connected = False ais_connected = False
logger.info("AIS stream parser stopped") logger.info("AIS stream parser stopped")
@@ -350,7 +357,7 @@ def ais_status():
@ais_bp.route('/start', methods=['POST']) @ais_bp.route('/start', methods=['POST'])
def start_ais(): def start_ais():
"""Start AIS tracking.""" """Start AIS tracking."""
global ais_running, ais_active_device global ais_running, ais_active_device, ais_active_sdr_type
with app_module.ais_lock: with app_module.ais_lock:
if ais_running: if ais_running:
@@ -397,7 +404,7 @@ def start_ais():
# Check if device is available # Check if device is available
device_int = int(device) device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'ais') error = app_module.claim_sdr_device(device_int, 'ais', sdr_type_str)
if error: if error:
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
@@ -436,20 +443,23 @@ def start_ais():
if app_module.ais_process.poll() is not None: if app_module.ais_process.poll() is not None:
# Release device on failure # Release device on failure
app_module.release_sdr_device(device_int) app_module.release_sdr_device(device_int, sdr_type_str)
stderr_output = '' stderr_output = ''
if app_module.ais_process.stderr: if app_module.ais_process.stderr:
try: try:
stderr_output = app_module.ais_process.stderr.read().decode('utf-8', errors='ignore').strip() stderr_output = app_module.ais_process.stderr.read().decode('utf-8', errors='ignore').strip()
except Exception: except Exception:
pass pass
if stderr_output:
logger.error(f"AIS-catcher stderr:\n{stderr_output}")
error_msg = 'AIS-catcher failed to start. Check SDR device connection.' error_msg = 'AIS-catcher failed to start. Check SDR device connection.'
if stderr_output: if stderr_output:
error_msg += f' Error: {stderr_output[:200]}' error_msg += f' Error: {stderr_output[:500]}'
return jsonify({'status': 'error', 'message': error_msg}), 500 return jsonify({'status': 'error', 'message': error_msg}), 500
ais_running = True ais_running = True
ais_active_device = device ais_active_device = device
ais_active_sdr_type = sdr_type_str
# Start TCP parser thread # Start TCP parser thread
thread = threading.Thread(target=parse_ais_stream, args=(tcp_port,), daemon=True) thread = threading.Thread(target=parse_ais_stream, args=(tcp_port,), daemon=True)
@@ -463,7 +473,7 @@ def start_ais():
}) })
except Exception as e: except Exception as e:
# Release device on failure # Release device on failure
app_module.release_sdr_device(device_int) app_module.release_sdr_device(device_int, sdr_type_str)
logger.error(f"Failed to start AIS-catcher: {e}") logger.error(f"Failed to start AIS-catcher: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500 return jsonify({'status': 'error', 'message': str(e)}), 500
@@ -471,7 +481,7 @@ def start_ais():
@ais_bp.route('/stop', methods=['POST']) @ais_bp.route('/stop', methods=['POST'])
def stop_ais(): def stop_ais():
"""Stop AIS tracking.""" """Stop AIS tracking."""
global ais_running, ais_active_device global ais_running, ais_active_device, ais_active_sdr_type
with app_module.ais_lock: with app_module.ais_lock:
if app_module.ais_process: if app_module.ais_process:
@@ -490,10 +500,11 @@ def stop_ais():
# Release device from registry # Release device from registry
if ais_active_device is not None: if ais_active_device is not None:
app_module.release_sdr_device(ais_active_device) app_module.release_sdr_device(ais_active_device, ais_active_sdr_type or 'rtlsdr')
ais_running = False ais_running = False
ais_active_device = None ais_active_device = None
ais_active_sdr_type = None
app_module.ais_vessels.clear() app_module.ais_vessels.clear()
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
+348 -112
View File
@@ -5,8 +5,10 @@ from __future__ import annotations
import csv import csv
import json import json
import os import os
import pty
import queue import queue
import re import re
import select
import shutil import shutil
import subprocess import subprocess
import tempfile import tempfile
@@ -14,13 +16,19 @@ import threading
import time import time
from datetime import datetime from datetime import datetime
from subprocess import PIPE, STDOUT from subprocess import PIPE, STDOUT
from typing import Generator, Optional from typing import Any, Generator, Optional
from flask import Blueprint, jsonify, request, Response from flask import Blueprint, jsonify, request, Response
import app as app_module import app as app_module
from utils.logging import sensor_logger as logger from utils.logging import sensor_logger as logger
from utils.validation import validate_device_index, validate_gain, validate_ppm from utils.validation import (
validate_device_index,
validate_gain,
validate_ppm,
validate_rtl_tcp_host,
validate_rtl_tcp_port,
)
from utils.sse import sse_stream_fanout from utils.sse import sse_stream_fanout
from utils.event_pipeline import process_event from utils.event_pipeline import process_event
from utils.sdr import SDRFactory, SDRType from utils.sdr import SDRFactory, SDRType
@@ -35,6 +43,7 @@ aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs')
# Track which SDR device is being used # Track which SDR device is being used
aprs_active_device: int | None = None aprs_active_device: int | None = None
aprs_active_sdr_type: str | None = None
# APRS frequencies by region (MHz) # APRS frequencies by region (MHz)
APRS_FREQUENCIES = { APRS_FREQUENCIES = {
@@ -103,12 +112,35 @@ ADEVICE stdin null
CHANNEL 0 CHANNEL 0
MYCALL N0CALL MYCALL N0CALL
MODEM 1200 MODEM 1200
FIX_BITS 1
AGWPORT 0
KISSPORT 0
""" """
with open(DIREWOLF_CONFIG_PATH, 'w') as f: with open(DIREWOLF_CONFIG_PATH, 'w') as f:
f.write(config) f.write(config)
return DIREWOLF_CONFIG_PATH return DIREWOLF_CONFIG_PATH
def normalize_aprs_output_line(line: str) -> str:
"""Normalize a decoder output line to raw APRS packet format.
Handles common decoder prefixes:
- multimon-ng: ``AFSK1200: ...``
- direwolf tags: ``[0.4] ...``, ``[0L] ...``, etc.
"""
if not line:
return ''
normalized = line.strip()
if normalized.startswith('AFSK1200:'):
normalized = normalized[9:].strip()
# Strip one or more leading bracket tags emitted by decoders.
# Examples: [0.4], [0L], [NONE]
normalized = re.sub(r'^(?:\[[^\]]+\]\s*)+', '', normalized)
return normalized
def parse_aprs_packet(raw_packet: str) -> Optional[dict]: def parse_aprs_packet(raw_packet: str) -> Optional[dict]:
"""Parse APRS packet into structured data. """Parse APRS packet into structured data.
@@ -125,10 +157,15 @@ def parse_aprs_packet(raw_packet: str) -> Optional[dict]:
- User-defined formats - User-defined formats
""" """
try: try:
raw_packet = normalize_aprs_output_line(raw_packet)
if not raw_packet:
return None
# Basic APRS packet format: CALLSIGN>PATH:DATA # Basic APRS packet format: CALLSIGN>PATH:DATA
# Example: N0CALL-9>APRS,TCPIP*:@092345z4903.50N/07201.75W_090/000g005t077 # Example: N0CALL-9>APRS,TCPIP*:@092345z4903.50N/07201.75W_090/000g005t077
match = re.match(r'^([A-Z0-9-]+)>([^:]+):(.+)$', raw_packet, re.IGNORECASE) # Source callsigns can include tactical suffixes like "/1" on some stations.
match = re.match(r'^([A-Z0-9/\-]+)>([^:]+):(.+)$', raw_packet, re.IGNORECASE)
if not match: if not match:
return None return None
@@ -444,6 +481,109 @@ def parse_position(data: str) -> Optional[dict]:
return result return result
# Legacy/no-decimal variant occasionally seen in degraded decodes:
# DDMMN/DDDMMW (symbol chars still present between/after coords).
nodot_match = re.match(
r'^(\d{2})(\d{2})([NS])(.)(\d{3})(\d{2})([EW])(.)?',
data
)
if nodot_match:
lat_deg = int(nodot_match.group(1))
lat_min = float(nodot_match.group(2))
lat_dir = nodot_match.group(3)
symbol_table = nodot_match.group(4)
lon_deg = int(nodot_match.group(5))
lon_min = float(nodot_match.group(6))
lon_dir = nodot_match.group(7)
symbol_code = nodot_match.group(8) or ''
lat = lat_deg + lat_min / 60.0
if lat_dir == 'S':
lat = -lat
lon = lon_deg + lon_min / 60.0
if lon_dir == 'W':
lon = -lon
result = {
'lat': round(lat, 6),
'lon': round(lon, 6),
'symbol': symbol_table + symbol_code,
}
remaining = data[13:] if len(data) > 13 else ''
cs_match = re.search(r'(\d{3})/(\d{3})', remaining)
if cs_match:
result['course'] = int(cs_match.group(1))
result['speed'] = int(cs_match.group(2))
alt_match = re.search(r'/A=(-?\d+)', remaining)
if alt_match:
result['altitude'] = int(alt_match.group(1))
return result
# Fallback: tolerate APRS ambiguity spaces in minute fields.
# Example: 4903. N/07201. W
if len(data) >= 18:
lat_field = data[0:7]
lat_dir = data[7]
symbol_table = data[8] if len(data) > 8 else ''
lon_field = data[9:17] if len(data) >= 17 else ''
lon_dir = data[17] if len(data) > 17 else ''
symbol_code = data[18] if len(data) > 18 else ''
if (
len(lat_field) == 7
and len(lon_field) == 8
and lat_dir in ('N', 'S')
and lon_dir in ('E', 'W')
):
lat_deg_txt = lat_field[:2]
lat_min_txt = lat_field[2:].replace(' ', '0')
lon_deg_txt = lon_field[:3]
lon_min_txt = lon_field[3:].replace(' ', '0')
if (
lat_deg_txt.isdigit()
and lon_deg_txt.isdigit()
and re.match(r'^\d{2}\.\d+$', lat_min_txt)
and re.match(r'^\d{2}\.\d+$', lon_min_txt)
):
lat_deg = int(lat_deg_txt)
lon_deg = int(lon_deg_txt)
lat_min = float(lat_min_txt)
lon_min = float(lon_min_txt)
lat = lat_deg + lat_min / 60.0
if lat_dir == 'S':
lat = -lat
lon = lon_deg + lon_min / 60.0
if lon_dir == 'W':
lon = -lon
result = {
'lat': round(lat, 6),
'lon': round(lon, 6),
'symbol': symbol_table + symbol_code,
}
# Keep same extension parsing behavior as primary branch.
remaining = data[19:] if len(data) > 19 else ''
cs_match = re.search(r'(\d{3})/(\d{3})', remaining)
if cs_match:
result['course'] = int(cs_match.group(1))
result['speed'] = int(cs_match.group(2))
alt_match = re.search(r'/A=(-?\d+)', remaining)
if alt_match:
result['altitude'] = int(alt_match.group(1))
return result
except Exception as e: except Exception as e:
logger.debug(f"Failed to parse position: {e}") logger.debug(f"Failed to parse position: {e}")
@@ -1309,19 +1449,23 @@ def should_send_meter_update(level: int) -> bool:
return False return False
def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subprocess.Popen) -> None: def stream_aprs_output(master_fd: int, rtl_process: subprocess.Popen, decoder_process: subprocess.Popen) -> None:
"""Stream decoded APRS packets and audio level meter to queue. """Stream decoded APRS packets and audio level meter to queue.
This function reads from the decoder's stdout (text mode, line-buffered). Reads from a PTY master fd to get line-buffered output from the decoder,
The decoder's stderr is merged into stdout (STDOUT) to avoid deadlocks. avoiding the 15-minute pipe buffering delay. Uses select() + os.read()
rtl_fm's stderr is captured via PIPE with a monitor thread. to poll the PTY (same pattern as pager.py).
Outputs two types of messages to the queue: Outputs two types of messages to the queue:
- type='aprs': Decoded APRS packets - type='aprs': Decoded APRS packets
- type='meter': Audio level meter readings (rate-limited) - type='meter': Audio level meter readings (rate-limited)
""" """
global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations
global _last_meter_time, _last_meter_level global _last_meter_time, _last_meter_level, aprs_active_device, aprs_active_sdr_type
# Capture the device claimed by THIS session so the finally block only
# releases our own device, not one claimed by a subsequent start.
my_device = aprs_active_device
# Reset meter state # Reset meter state
_last_meter_time = 0.0 _last_meter_time = 0.0
@@ -1330,93 +1474,114 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
try: try:
app_module.aprs_queue.put({'type': 'status', 'status': 'started'}) app_module.aprs_queue.put({'type': 'status', 'status': 'started'})
# Read line-by-line in text mode. Empty string '' signals EOF. # Read from PTY using select() for non-blocking reads.
for line in iter(decoder_process.stdout.readline, ''): # PTY forces the decoder to line-buffer, so output arrives immediately
line = line.strip() # instead of waiting for a full 4-8KB pipe buffer to fill.
if not line: buffer = ""
continue while True:
try:
ready, _, _ = select.select([master_fd], [], [], 1.0)
except Exception:
break
# Check for audio level line first (for signal meter) if ready:
audio_level = parse_audio_level(line) try:
if audio_level is not None: data = os.read(master_fd, 1024)
if should_send_meter_update(audio_level): if not data:
meter_msg = { break
'type': 'meter', buffer += data.decode('utf-8', errors='replace')
'level': audio_level, except OSError:
'ts': datetime.utcnow().isoformat() + 'Z' break
}
app_module.aprs_queue.put(meter_msg)
continue # Audio level lines are not packets
# multimon-ng prefixes decoded packets with "AFSK1200: " while '\n' in buffer:
if line.startswith('AFSK1200:'): line, buffer = buffer.split('\n', 1)
line = line[9:].strip() line = line.strip()
if not line:
continue
# direwolf often prefixes packets with "[0.4] " or similar audio level indicator # Check for audio level line first (for signal meter)
# Strip any leading bracket prefix like "[0.4] " before parsing audio_level = parse_audio_level(line)
line = re.sub(r'^\[\d+\.\d+\]\s*', '', line) if audio_level is not None:
if should_send_meter_update(audio_level):
meter_msg = {
'type': 'meter',
'level': audio_level,
'ts': datetime.utcnow().isoformat() + 'Z'
}
app_module.aprs_queue.put(meter_msg)
continue # Audio level lines are not packets
# Skip non-packet lines (APRS format: CALL>PATH:DATA) # Normalize decoder prefixes (multimon/direwolf) before parsing.
if '>' not in line or ':' not in line: line = normalize_aprs_output_line(line)
continue
packet = parse_aprs_packet(line) # Skip non-packet lines (APRS format: CALL>PATH:DATA)
if packet: if '>' not in line or ':' not in line:
aprs_packet_count += 1 continue
aprs_last_packet_time = time.time()
# Track unique stations packet = parse_aprs_packet(line)
callsign = packet.get('callsign') if packet:
if callsign and callsign not in aprs_stations: aprs_packet_count += 1
aprs_station_count += 1 aprs_last_packet_time = time.time()
# Update station data # Track unique stations
if callsign: callsign = packet.get('callsign')
aprs_stations[callsign] = { if callsign and callsign not in aprs_stations:
'callsign': callsign, aprs_station_count += 1
'lat': packet.get('lat'),
'lon': packet.get('lon'),
'symbol': packet.get('symbol'),
'last_seen': packet.get('timestamp'),
'packet_type': packet.get('packet_type'),
}
# Geofence check
_aprs_lat = packet.get('lat')
_aprs_lon = packet.get('lon')
if _aprs_lat and _aprs_lon:
try:
from utils.geofence import get_geofence_manager
for _gf_evt in get_geofence_manager().check_position(
callsign, 'aprs_station', _aprs_lat, _aprs_lon,
{'callsign': callsign}
):
process_event('aprs', _gf_evt, 'geofence')
except Exception:
pass
# Evict oldest stations when limit is exceeded
if len(aprs_stations) > APRS_MAX_STATIONS:
oldest = min(
aprs_stations,
key=lambda k: aprs_stations[k].get('last_seen', ''),
)
del aprs_stations[oldest]
app_module.aprs_queue.put(packet) # Update station data, preserving last known coordinates when
# packets do not contain position fields.
if callsign:
existing = aprs_stations.get(callsign, {})
packet_lat = packet.get('lat')
packet_lon = packet.get('lon')
aprs_stations[callsign] = {
'callsign': callsign,
'lat': packet_lat if packet_lat is not None else existing.get('lat'),
'lon': packet_lon if packet_lon is not None else existing.get('lon'),
'symbol': packet.get('symbol') or existing.get('symbol'),
'last_seen': packet.get('timestamp'),
'packet_type': packet.get('packet_type'),
}
# Geofence check
_aprs_lat = packet_lat
_aprs_lon = packet_lon
if _aprs_lat is not None and _aprs_lon is not None:
try:
from utils.geofence import get_geofence_manager
for _gf_evt in get_geofence_manager().check_position(
callsign, 'aprs_station', _aprs_lat, _aprs_lon,
{'callsign': callsign}
):
process_event('aprs', _gf_evt, 'geofence')
except Exception:
pass
# Evict oldest stations when limit is exceeded
if len(aprs_stations) > APRS_MAX_STATIONS:
oldest = min(
aprs_stations,
key=lambda k: aprs_stations[k].get('last_seen', ''),
)
del aprs_stations[oldest]
# Log if enabled app_module.aprs_queue.put(packet)
if app_module.logging_enabled:
try: # Log if enabled
with open(app_module.log_file_path, 'a') as f: if app_module.logging_enabled:
ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S') try:
f.write(f"{ts} | APRS | {json.dumps(packet)}\n") with open(app_module.log_file_path, 'a') as f:
except Exception: ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
pass f.write(f"{ts} | APRS | {json.dumps(packet)}\n")
except Exception:
pass
except Exception as e: except Exception as e:
logger.error(f"APRS stream error: {e}") logger.error(f"APRS stream error: {e}")
app_module.aprs_queue.put({'type': 'error', 'message': str(e)}) app_module.aprs_queue.put({'type': 'error', 'message': str(e)})
finally: finally:
global aprs_active_device try:
os.close(master_fd)
except OSError:
pass
app_module.aprs_queue.put({'type': 'status', 'status': 'stopped'}) app_module.aprs_queue.put({'type': 'status', 'status': 'stopped'})
# Cleanup processes # Cleanup processes
for proc in [rtl_process, decoder_process]: for proc in [rtl_process, decoder_process]:
@@ -1428,10 +1593,11 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
proc.kill() proc.kill()
except Exception: except Exception:
pass pass
# Release SDR device # Release SDR device — only if it's still ours (not reclaimed by a new start)
if aprs_active_device is not None: if my_device is not None and aprs_active_device == my_device:
app_module.release_sdr_device(aprs_active_device) app_module.release_sdr_device(my_device, aprs_active_sdr_type or 'rtlsdr')
aprs_active_device = None aprs_active_device = None
aprs_active_sdr_type = None
@aprs_bp.route('/tools') @aprs_bp.route('/tools')
@@ -1478,11 +1644,29 @@ def get_stations() -> Response:
}) })
@aprs_bp.route('/data')
def aprs_data() -> Response:
"""Get APRS data snapshot for remote controller polling compatibility."""
running = False
if app_module.aprs_process:
running = app_module.aprs_process.poll() is None
return jsonify({
'status': 'success',
'running': running,
'stations': list(aprs_stations.values()),
'count': len(aprs_stations),
'packet_count': aprs_packet_count,
'station_count': aprs_station_count,
'last_packet_time': aprs_last_packet_time,
})
@aprs_bp.route('/start', methods=['POST']) @aprs_bp.route('/start', methods=['POST'])
def start_aprs() -> Response: def start_aprs() -> Response:
"""Start APRS decoder.""" """Start APRS decoder."""
global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations
global aprs_active_device global aprs_active_device, aprs_active_sdr_type
with app_module.aprs_lock: with app_module.aprs_lock:
if app_module.aprs_process and app_module.aprs_process.poll() is None: if app_module.aprs_process and app_module.aprs_process.poll() is None:
@@ -1511,6 +1695,10 @@ def start_aprs() -> Response:
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return jsonify({'status': 'error', 'message': str(e)}), 400
# Check for rtl_tcp (remote SDR) connection
rtl_tcp_host = data.get('rtl_tcp_host')
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower() sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower()
try: try:
sdr_type = SDRType(sdr_type_str) sdr_type = SDRType(sdr_type_str)
@@ -1530,15 +1718,17 @@ def start_aprs() -> Response:
'message': f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.' 'message': f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.'
}), 400 }), 400
# Reserve SDR device to prevent conflicts with other modes # Reserve SDR device to prevent conflicts (skip for remote rtl_tcp)
error = app_module.claim_sdr_device(device, 'aprs') if not rtl_tcp_host:
if error: error = app_module.claim_sdr_device(device, 'aprs', sdr_type_str)
return jsonify({ if error:
'status': 'error', return jsonify({
'error_type': 'DEVICE_BUSY', 'status': 'error',
'message': error 'error_type': 'DEVICE_BUSY',
}), 409 'message': error
aprs_active_device = device }), 409
aprs_active_device = device
aprs_active_sdr_type = sdr_type_str
# Get frequency for region # Get frequency for region
region = data.get('region', 'north_america') region = data.get('region', 'north_america')
@@ -1562,8 +1752,17 @@ def start_aprs() -> Response:
# Build FM demod command for APRS (AFSK1200 @ 22050 Hz) via SDR abstraction. # Build FM demod command for APRS (AFSK1200 @ 22050 Hz) via SDR abstraction.
try: try:
sdr_device = SDRFactory.create_default_device(sdr_type, index=device) if rtl_tcp_host:
builder = SDRFactory.get_builder(sdr_type) 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:
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
builder = SDRFactory.get_builder(sdr_device.sdr_type)
rtl_cmd = builder.build_fm_demod_command( rtl_cmd = builder.build_fm_demod_command(
device=sdr_device, device=sdr_device,
frequency_mhz=float(frequency), frequency_mhz=float(frequency),
@@ -1580,8 +1779,9 @@ def start_aprs() -> Response:
rtl_cmd = rtl_cmd[:-1] + ['-E', 'dc', '-A', 'fast', '-'] rtl_cmd = rtl_cmd[:-1] + ['-E', 'dc', '-A', 'fast', '-']
except Exception as e: except Exception as e:
if aprs_active_device is not None: if aprs_active_device is not None:
app_module.release_sdr_device(aprs_active_device) app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
aprs_active_device = None aprs_active_device = None
aprs_active_sdr_type = None
return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500 return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500
# Build decoder command # Build decoder command
@@ -1635,19 +1835,25 @@ def start_aprs() -> Response:
rtl_stderr_thread = threading.Thread(target=monitor_rtl_stderr, daemon=True) rtl_stderr_thread = threading.Thread(target=monitor_rtl_stderr, daemon=True)
rtl_stderr_thread.start() rtl_stderr_thread.start()
# Create a pseudo-terminal for decoder output. PTY forces the
# decoder to line-buffer its stdout, avoiding the 15-minute delay
# caused by full pipe buffering (~4-8KB) on small APRS packets.
master_fd, slave_fd = pty.openpty()
# Start decoder with stdin wired to rtl_fm's stdout. # Start decoder with stdin wired to rtl_fm's stdout.
# Use text mode with line buffering for reliable line-by-line reading. # stdout/stderr go to the PTY slave so output is line-buffered.
# Merge stderr into stdout to avoid blocking on unbuffered stderr.
decoder_process = subprocess.Popen( decoder_process = subprocess.Popen(
decoder_cmd, decoder_cmd,
stdin=rtl_process.stdout, stdin=rtl_process.stdout,
stdout=PIPE, stdout=slave_fd,
stderr=STDOUT, stderr=slave_fd,
text=True, close_fds=True,
bufsize=1,
start_new_session=True start_new_session=True
) )
# Close slave fd in parent — decoder owns it now.
os.close(slave_fd)
# Close rtl_fm's stdout in parent so decoder owns it exclusively. # Close rtl_fm's stdout in parent so decoder owns it exclusively.
# This ensures proper EOF propagation when rtl_fm terminates. # This ensures proper EOF propagation when rtl_fm terminates.
rtl_process.stdout.close() rtl_process.stdout.close()
@@ -1664,43 +1870,63 @@ def start_aprs() -> Response:
stderr_output = remaining.decode('utf-8', errors='replace').strip() stderr_output = remaining.decode('utf-8', errors='replace').strip()
except Exception: except Exception:
pass pass
if stderr_output:
logger.error(f"rtl_fm stderr:\n{stderr_output}")
error_msg = f'rtl_fm failed to start (exit code {rtl_process.returncode})' error_msg = f'rtl_fm failed to start (exit code {rtl_process.returncode})'
if stderr_output: if stderr_output:
error_msg += f': {stderr_output[:200]}' error_msg += f': {stderr_output[:500]}'
logger.error(error_msg) logger.error(error_msg)
try:
os.close(master_fd)
except OSError:
pass
try: try:
decoder_process.kill() decoder_process.kill()
except Exception: except Exception:
pass pass
if aprs_active_device is not None: if aprs_active_device is not None:
app_module.release_sdr_device(aprs_active_device) app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
aprs_active_device = None aprs_active_device = None
aprs_active_sdr_type = None
return jsonify({'status': 'error', 'message': error_msg}), 500 return jsonify({'status': 'error', 'message': error_msg}), 500
if decoder_process.poll() is not None: if decoder_process.poll() is not None:
# Decoder exited early - capture any output # Decoder exited early - capture any output from PTY
error_output = decoder_process.stdout.read()[:500] if decoder_process.stdout else '' error_output = ''
try:
ready, _, _ = select.select([master_fd], [], [], 0.5)
if ready:
raw = os.read(master_fd, 500)
error_output = raw.decode('utf-8', errors='replace')
except Exception:
pass
error_msg = f'{decoder_name} failed to start' error_msg = f'{decoder_name} failed to start'
if error_output: if error_output:
error_msg += f': {error_output}' error_msg += f': {error_output}'
logger.error(error_msg) logger.error(error_msg)
try:
os.close(master_fd)
except OSError:
pass
try: try:
rtl_process.kill() rtl_process.kill()
except Exception: except Exception:
pass pass
if aprs_active_device is not None: if aprs_active_device is not None:
app_module.release_sdr_device(aprs_active_device) app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
aprs_active_device = None aprs_active_device = None
aprs_active_sdr_type = None
return jsonify({'status': 'error', 'message': error_msg}), 500 return jsonify({'status': 'error', 'message': error_msg}), 500
# Store references for status checks and cleanup # Store references for status checks and cleanup
app_module.aprs_process = decoder_process app_module.aprs_process = decoder_process
app_module.aprs_rtl_process = rtl_process app_module.aprs_rtl_process = rtl_process
app_module.aprs_master_fd = master_fd
# Start background thread to read decoder output and push to queue # Start background thread to read decoder output and push to queue
thread = threading.Thread( thread = threading.Thread(
target=stream_aprs_output, target=stream_aprs_output,
args=(rtl_process, decoder_process), args=(master_fd, rtl_process, decoder_process),
daemon=True daemon=True
) )
thread.start() thread.start()
@@ -1717,15 +1943,16 @@ def start_aprs() -> Response:
except Exception as e: except Exception as e:
logger.error(f"Failed to start APRS decoder: {e}") logger.error(f"Failed to start APRS decoder: {e}")
if aprs_active_device is not None: if aprs_active_device is not None:
app_module.release_sdr_device(aprs_active_device) app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
aprs_active_device = None aprs_active_device = None
aprs_active_sdr_type = None
return jsonify({'status': 'error', 'message': str(e)}), 500 return jsonify({'status': 'error', 'message': str(e)}), 500
@aprs_bp.route('/stop', methods=['POST']) @aprs_bp.route('/stop', methods=['POST'])
def stop_aprs() -> Response: def stop_aprs() -> Response:
"""Stop APRS decoder.""" """Stop APRS decoder."""
global aprs_active_device global aprs_active_device, aprs_active_sdr_type
with app_module.aprs_lock: with app_module.aprs_lock:
processes_to_stop = [] processes_to_stop = []
@@ -1751,14 +1978,23 @@ def stop_aprs() -> Response:
except Exception as e: except Exception as e:
logger.error(f"Error stopping APRS process: {e}") logger.error(f"Error stopping APRS process: {e}")
# Close PTY master fd
if hasattr(app_module, 'aprs_master_fd') and app_module.aprs_master_fd is not None:
try:
os.close(app_module.aprs_master_fd)
except OSError:
pass
app_module.aprs_master_fd = None
app_module.aprs_process = None app_module.aprs_process = None
if hasattr(app_module, 'aprs_rtl_process'): if hasattr(app_module, 'aprs_rtl_process'):
app_module.aprs_rtl_process = None app_module.aprs_rtl_process = None
# Release SDR device # Release SDR device
if aprs_active_device is not None: if aprs_active_device is not None:
app_module.release_sdr_device(aprs_active_device) app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
aprs_active_device = None aprs_active_device = None
aprs_active_sdr_type = None
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
+1 -1
View File
@@ -261,7 +261,7 @@ def start_scan():
# Check if already scanning # Check if already scanning
if scanner.is_scanning: if scanner.is_scanning:
return jsonify({ return jsonify({
'status': 'already_running', 'status': 'already_scanning',
'scan_status': scanner.get_status().to_dict() 'scan_status': scanner.get_status().to_dict()
}) })
+66 -32
View File
@@ -37,7 +37,12 @@ from utils.database import (
from utils.dsc.parser import parse_dsc_message from utils.dsc.parser import parse_dsc_message
from utils.sse import sse_stream_fanout from utils.sse import sse_stream_fanout
from utils.event_pipeline import process_event from utils.event_pipeline import process_event
from utils.validation import validate_device_index, validate_gain from utils.validation import (
validate_device_index,
validate_gain,
validate_rtl_tcp_host,
validate_rtl_tcp_port,
)
from utils.sdr import SDRFactory, SDRType from utils.sdr import SDRFactory, SDRType
from utils.dependencies import get_tool_path from utils.dependencies import get_tool_path
from utils.process import register_process, unregister_process from utils.process import register_process, unregister_process
@@ -51,6 +56,7 @@ dsc_running = False
# Track which device is being used # Track which device is being used
dsc_active_device: int | None = None dsc_active_device: int | None = None
dsc_active_sdr_type: str | None = None
def _get_dsc_decoder_path() -> str | None: def _get_dsc_decoder_path() -> str | None:
@@ -171,7 +177,7 @@ def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> Non
'error': str(e) 'error': str(e)
}) })
finally: finally:
global dsc_active_device global dsc_active_device, dsc_active_sdr_type
try: try:
os.close(master_fd) os.close(master_fd)
except OSError: except OSError:
@@ -197,8 +203,9 @@ def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> Non
app_module.dsc_rtl_process = None app_module.dsc_rtl_process = None
# Release SDR device # Release SDR device
if dsc_active_device is not None: if dsc_active_device is not None:
app_module.release_sdr_device(dsc_active_device) app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
dsc_active_device = None dsc_active_device = None
dsc_active_sdr_type = None
def _store_critical_alert(msg: dict) -> None: def _store_critical_alert(msg: dict) -> None:
@@ -331,18 +338,32 @@ def start_decoding() -> Response:
'message': str(e) 'message': str(e)
}), 400 }), 400
# Check if device is available using centralized registry # Get SDR type from request
global dsc_active_device sdr_type_str = data.get('sdr_type', 'rtlsdr')
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'dsc')
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
dsc_active_device = device_int # Check for rtl_tcp (remote SDR) connection
rtl_tcp_host = data.get('rtl_tcp_host')
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
# Check if device is available using centralized registry (skip for remote rtl_tcp)
global dsc_active_device, dsc_active_sdr_type
if not rtl_tcp_host:
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'dsc', sdr_type_str)
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
dsc_active_device = device_int
dsc_active_sdr_type = sdr_type_str
# Clear queue # Clear queue
while not app_module.dsc_queue.empty(): while not app_module.dsc_queue.empty():
@@ -351,22 +372,32 @@ def start_decoding() -> Response:
except queue.Empty: except queue.Empty:
break break
# Build rtl_fm command # Build rtl_fm command via SDR abstraction layer
rtl_fm_path = tools['rtl_fm']['path']
decoder_path = tools['dsc_decoder']['path'] decoder_path = tools['dsc_decoder']['path']
# rtl_fm command for DSC decoding if rtl_tcp_host:
# DSC uses narrow FM at 156.525 MHz with 48kHz sample rate try:
rtl_cmd = [ rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
rtl_fm_path, rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
'-f', f'{DSC_VHF_FREQUENCY_MHZ}M', except ValueError as e:
'-s', str(DSC_SAMPLE_RATE), return jsonify({'status': 'error', 'message': str(e)}), 400
'-d', str(device), sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
'-g', str(gain), logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
'-M', 'fm', # FM demodulation else:
'-l', '0', # No squelch for DSC sdr_device = SDRFactory.create_default_device(sdr_type, index=int(device))
'-E', 'dc' # DC blocking filter
] 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 command
decoder_cmd = [decoder_path] decoder_cmd = [decoder_path]
@@ -440,8 +471,9 @@ def start_decoding() -> Response:
pass pass
# Release device on failure # Release device on failure
if dsc_active_device is not None: if dsc_active_device is not None:
app_module.release_sdr_device(dsc_active_device) app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
dsc_active_device = None dsc_active_device = None
dsc_active_sdr_type = None
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'message': f'Tool not found: {e.filename}' 'message': f'Tool not found: {e.filename}'
@@ -458,8 +490,9 @@ def start_decoding() -> Response:
pass pass
# Release device on failure # Release device on failure
if dsc_active_device is not None: if dsc_active_device is not None:
app_module.release_sdr_device(dsc_active_device) app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
dsc_active_device = None dsc_active_device = None
dsc_active_sdr_type = None
logger.error(f"Failed to start DSC decoder: {e}") logger.error(f"Failed to start DSC decoder: {e}")
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
@@ -470,7 +503,7 @@ def start_decoding() -> Response:
@dsc_bp.route('/stop', methods=['POST']) @dsc_bp.route('/stop', methods=['POST'])
def stop_decoding() -> Response: def stop_decoding() -> Response:
"""Stop DSC decoder.""" """Stop DSC decoder."""
global dsc_running, dsc_active_device global dsc_running, dsc_active_device, dsc_active_sdr_type
with app_module.dsc_lock: with app_module.dsc_lock:
if not app_module.dsc_process: if not app_module.dsc_process:
@@ -509,8 +542,9 @@ def stop_decoding() -> Response:
# Release device from registry # Release device from registry
if dsc_active_device is not None: if dsc_active_device is not None:
app_module.release_sdr_device(dsc_active_device) app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
dsc_active_device = None dsc_active_device = None
dsc_active_sdr_type = None
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
+6 -2
View File
@@ -68,6 +68,9 @@ def auto_connect_gps():
# Check if already running # Check if already running
reader = get_gps_reader() reader = get_gps_reader()
if reader and reader.is_running: 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 position = reader.position
sky = reader.sky sky = reader.sky
return jsonify({ return jsonify({
@@ -211,9 +214,10 @@ def get_satellites():
if not reader or not reader.is_running: if not reader or not reader.is_running:
return jsonify({ return jsonify({
'status': 'error', 'status': 'waiting',
'running': False,
'message': 'GPS client not running' 'message': 'GPS client not running'
}), 400 })
sky = reader.sky sky = reader.sky
if sky: if sky:
+604 -329
View File
File diff suppressed because it is too large Load Diff
+597
View File
@@ -0,0 +1,597 @@
"""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
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 jsonify({'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")
+1016
View File
File diff suppressed because it is too large Load Diff
+356
View File
@@ -0,0 +1,356 @@
"""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.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 jsonify({'status': 'error', 'message': '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 jsonify({'status': 'error', 'message': str(e)}), 400
try:
encoding = _validate_encoding(data.get('encoding', 'pwm'))
except ValueError as e:
return jsonify({'status': 'error', 'message': 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 jsonify({'status': 'error', 'message': f'Invalid timing parameter: {e}'}), 400
if min_bits < 1:
return jsonify({'status': 'error', 'message': 'min_bits must be >= 1'}), 400
if short_pulse < 1 or long_pulse < 1:
return jsonify({'status': 'error', 'message': '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 jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error,
}), 409
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 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:
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 jsonify({'status': 'error', 'message': 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 jsonify({'status': 'error', 'message': 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
+34 -11
View File
@@ -34,6 +34,7 @@ pager_bp = Blueprint('pager', __name__)
# Track which device is being used # Track which device is being used
pager_active_device: int | None = None pager_active_device: int | None = None
pager_active_sdr_type: str | None = None
def parse_multimon_output(line: str) -> dict[str, str] | None: def parse_multimon_output(line: str) -> dict[str, str] | None:
@@ -54,6 +55,20 @@ def parse_multimon_output(line: str) -> dict[str, str] | None:
'message': pocsag_match.group(5).strip() or '[No Message]' '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 parsing - address only (no message content)
pocsag_addr_match = re.match( pocsag_addr_match = re.match(
r'(POCSAG\d+):\s*Address:\s*(\d+)\s+Function:\s*(\d+)\s*$', r'(POCSAG\d+):\s*Address:\s*(\d+)\s+Function:\s*(\d+)\s*$',
@@ -220,7 +235,7 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
except Exception as e: except Exception as e:
app_module.output_queue.put({'type': 'error', 'text': str(e)}) app_module.output_queue.put({'type': 'error', 'text': str(e)})
finally: finally:
global pager_active_device global pager_active_device, pager_active_sdr_type
try: try:
os.close(master_fd) os.close(master_fd)
except OSError: except OSError:
@@ -249,13 +264,14 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
app_module.current_process = None app_module.current_process = None
# Release SDR device # Release SDR device
if pager_active_device is not None: if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device) app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
pager_active_device = None pager_active_device = None
pager_active_sdr_type = None
@pager_bp.route('/start', methods=['POST']) @pager_bp.route('/start', methods=['POST'])
def start_decoding() -> Response: def start_decoding() -> Response:
global pager_active_device global pager_active_device, pager_active_sdr_type
with app_module.process_lock: with app_module.process_lock:
if app_module.current_process: if app_module.current_process:
@@ -284,10 +300,13 @@ def start_decoding() -> Response:
rtl_tcp_host = data.get('rtl_tcp_host') rtl_tcp_host = data.get('rtl_tcp_host')
rtl_tcp_port = data.get('rtl_tcp_port', 1234) 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 # Claim local device if not using remote rtl_tcp
if not rtl_tcp_host: if not rtl_tcp_host:
device_int = int(device) device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'pager') error = app_module.claim_sdr_device(device_int, 'pager', sdr_type_str)
if error: if error:
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
@@ -295,14 +314,16 @@ def start_decoding() -> Response:
'message': error 'message': error
}), 409 }), 409
pager_active_device = device_int pager_active_device = device_int
pager_active_sdr_type = sdr_type_str
# Validate protocols # Validate protocols
valid_protocols = ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX'] valid_protocols = ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX']
protocols = data.get('protocols', valid_protocols) protocols = data.get('protocols', valid_protocols)
if not isinstance(protocols, list): if not isinstance(protocols, list):
if pager_active_device is not None: if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device) app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
pager_active_device = None pager_active_device = None
pager_active_sdr_type = None
return jsonify({'status': 'error', 'message': 'Protocols must be a list'}), 400 return jsonify({'status': 'error', 'message': 'Protocols must be a list'}), 400
protocols = [p for p in protocols if p in valid_protocols] protocols = [p for p in protocols if p in valid_protocols]
if not protocols: if not protocols:
@@ -327,8 +348,7 @@ def start_decoding() -> Response:
elif proto == 'FLEX': elif proto == 'FLEX':
decoders.extend(['-a', 'FLEX']) decoders.extend(['-a', 'FLEX'])
# Get SDR type and build command via abstraction layer # Build command via SDR abstraction layer
sdr_type_str = data.get('sdr_type', 'rtlsdr')
try: try:
sdr_type = SDRType(sdr_type_str) sdr_type = SDRType(sdr_type_str)
except ValueError: except ValueError:
@@ -443,8 +463,9 @@ def start_decoding() -> Response:
pass pass
# Release device on failure # Release device on failure
if pager_active_device is not None: if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device) app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
pager_active_device = None pager_active_device = None
pager_active_sdr_type = None
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'}) return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'})
except Exception as e: except Exception as e:
# Kill orphaned rtl_fm process if it was started # Kill orphaned rtl_fm process if it was started
@@ -458,14 +479,15 @@ def start_decoding() -> Response:
pass pass
# Release device on failure # Release device on failure
if pager_active_device is not None: if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device) app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
pager_active_device = None pager_active_device = None
pager_active_sdr_type = None
return jsonify({'status': 'error', 'message': str(e)}) return jsonify({'status': 'error', 'message': str(e)})
@pager_bp.route('/stop', methods=['POST']) @pager_bp.route('/stop', methods=['POST'])
def stop_decoding() -> Response: def stop_decoding() -> Response:
global pager_active_device global pager_active_device, pager_active_sdr_type
with app_module.process_lock: with app_module.process_lock:
if app_module.current_process: if app_module.current_process:
@@ -502,8 +524,9 @@ def stop_decoding() -> Response:
# Release device from registry # Release device from registry
if pager_active_device is not None: if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device) app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
pager_active_device = None pager_active_device = None
pager_active_sdr_type = None
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
+748
View File
@@ -0,0 +1,748 @@
"""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 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.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
try:
app_module.radiosonde_queue.put_nowait({
'type': 'balloon',
**balloon,
})
except queue.Full:
pass
try:
sock.close()
except OSError:
pass
_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:
try:
balloon['lat'] = float(msg[key])
except (ValueError, TypeError):
pass
break
for key in ('lon', 'longitude'):
if key in msg:
try:
balloon['lon'] = float(msg[key])
except (ValueError, TypeError):
pass
break
# Altitude (metres)
if 'alt' in msg:
try:
balloon['alt'] = float(msg['alt'])
except (ValueError, TypeError):
pass
# Meteorological data
for field in ('temp', 'humidity', 'pressure'):
if field in msg:
try:
balloon[field] = float(msg[field])
except (ValueError, TypeError):
pass
# Velocity
if 'vel_h' in msg:
try:
balloon['vel_h'] = float(msg['vel_h'])
except (ValueError, TypeError):
pass
if 'vel_v' in msg:
try:
balloon['vel_v'] = float(msg['vel_v'])
except (ValueError, TypeError):
pass
if 'heading' in msg:
try:
balloon['heading'] = float(msg['heading'])
except (ValueError, TypeError):
pass
# GPS satellites
if 'sats' in msg:
try:
balloon['sats'] = int(msg['sats'])
except (ValueError, TypeError):
pass
# Battery voltage
if 'batt' in msg:
try:
balloon['batt'] = float(msg['batt'])
except (ValueError, TypeError):
pass
# Frequency
if 'freq' in msg:
try:
balloon['freq'] = float(msg['freq'])
except (ValueError, TypeError):
pass
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 jsonify({
'status': 'already_running',
'message': '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 jsonify({'status': 'error', 'message': 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 jsonify({'status': 'error', 'message': 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 jsonify({
'status': 'error',
'message': '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 jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error,
}), 409
# 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 jsonify({'status': 'error', 'message': 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 jsonify({
'status': 'error',
'message': (
'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:
try:
stderr_output = app_module.radiosonde_process.stderr.read().decode(
'utf-8', errors='ignore'
).strip()
except Exception:
pass
if stderr_output:
logger.error(f"radiosonde_auto_rx stderr:\n{stderr_output}")
if stderr_output and (
'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 jsonify({'status': 'error', 'message': 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 jsonify({'status': 'error', 'message': 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:
try:
_udp_socket.close()
except OSError:
pass
_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 jsonify({
'status': 'success',
'count': len(radiosonde_balloons),
'balloons': dict(radiosonde_balloons),
})
+48 -26
View File
@@ -29,6 +29,7 @@ rtl_tcp_lock = threading.Lock()
# Track which device is being used # Track which device is being used
rtlamr_active_device: int | None = None rtlamr_active_device: int | None = None
rtlamr_active_sdr_type: str = 'rtlsdr'
def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None: def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
@@ -62,7 +63,7 @@ def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
except Exception as e: except Exception as e:
app_module.rtlamr_queue.put({'type': 'error', 'text': str(e)}) app_module.rtlamr_queue.put({'type': 'error', 'text': str(e)})
finally: finally:
global rtl_tcp_process, rtlamr_active_device global rtl_tcp_process, rtlamr_active_device, rtlamr_active_sdr_type
# Ensure rtlamr process is terminated # Ensure rtlamr process is terminated
try: try:
process.terminate() process.terminate()
@@ -91,19 +92,26 @@ def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
app_module.rtlamr_process = None app_module.rtlamr_process = None
# Release SDR device # Release SDR device
if rtlamr_active_device is not None: if rtlamr_active_device is not None:
app_module.release_sdr_device(rtlamr_active_device) app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
rtlamr_active_device = None rtlamr_active_device = None
@rtlamr_bp.route('/start_rtlamr', methods=['POST']) @rtlamr_bp.route('/start_rtlamr', methods=['POST'])
def start_rtlamr() -> Response: def start_rtlamr() -> Response:
global rtl_tcp_process, rtlamr_active_device global rtl_tcp_process, rtlamr_active_device, rtlamr_active_sdr_type
with app_module.rtlamr_lock: with app_module.rtlamr_lock:
if app_module.rtlamr_process: if app_module.rtlamr_process:
return jsonify({'status': 'error', 'message': 'RTLAMR already running'}), 409 return jsonify({'status': 'error', 'message': 'RTLAMR already running'}), 409
data = request.json or {} data = request.json or {}
sdr_type_str = data.get('sdr_type', 'rtlsdr')
if sdr_type_str != 'rtlsdr':
return jsonify({
'status': 'error',
'message': f'{sdr_type_str.replace("_", " ").title()} is not yet supported for this mode. Please use an RTL-SDR device.'
}), 400
# Validate inputs # Validate inputs
try: try:
@@ -116,7 +124,7 @@ def start_rtlamr() -> Response:
# Check if device is available # Check if device is available
device_int = int(device) device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'rtlamr') error = app_module.claim_sdr_device(device_int, 'rtlamr', sdr_type_str)
if error: if error:
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
@@ -125,6 +133,7 @@ def start_rtlamr() -> Response:
}), 409 }), 409
rtlamr_active_device = device_int rtlamr_active_device = device_int
rtlamr_active_sdr_type = sdr_type_str
# Clear queue # Clear queue
while not app_module.rtlamr_queue.empty(): while not app_module.rtlamr_queue.empty():
@@ -138,6 +147,8 @@ def start_rtlamr() -> Response:
output_format = data.get('format', 'json') output_format = data.get('format', 'json')
# Start rtl_tcp first # Start rtl_tcp first
rtl_tcp_just_started = False
rtl_tcp_cmd_str = ''
with rtl_tcp_lock: with rtl_tcp_lock:
if not rtl_tcp_process: if not rtl_tcp_process:
logger.info("Starting rtl_tcp server...") logger.info("Starting rtl_tcp server...")
@@ -162,20 +173,22 @@ def start_rtlamr() -> Response:
stderr=subprocess.PIPE stderr=subprocess.PIPE
) )
register_process(rtl_tcp_process) register_process(rtl_tcp_process)
rtl_tcp_just_started = True
# Wait a moment for rtl_tcp to start rtl_tcp_cmd_str = ' '.join(rtl_tcp_cmd)
time.sleep(3)
logger.info(f"rtl_tcp started: {' '.join(rtl_tcp_cmd)}")
app_module.rtlamr_queue.put({'type': 'info', 'text': f'rtl_tcp: {" ".join(rtl_tcp_cmd)}'})
except Exception as e: except Exception as e:
logger.error(f"Failed to start rtl_tcp: {e}") logger.error(f"Failed to start rtl_tcp: {e}")
# Release SDR device on rtl_tcp failure # Release SDR device on rtl_tcp failure
if rtlamr_active_device is not None: if rtlamr_active_device is not None:
app_module.release_sdr_device(rtlamr_active_device) app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
rtlamr_active_device = None rtlamr_active_device = None
return jsonify({'status': 'error', 'message': f'Failed to start rtl_tcp: {e}'}), 500 return jsonify({'status': 'error', 'message': 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 # Build rtlamr command
cmd = [ cmd = [
'rtlamr', 'rtlamr',
@@ -238,7 +251,7 @@ def start_rtlamr() -> Response:
rtl_tcp_process.wait(timeout=2) rtl_tcp_process.wait(timeout=2)
rtl_tcp_process = None rtl_tcp_process = None
if rtlamr_active_device is not None: if rtlamr_active_device is not None:
app_module.release_sdr_device(rtlamr_active_device) app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
rtlamr_active_device = None rtlamr_active_device = None
return jsonify({'status': 'error', 'message': 'rtlamr not found. Install from https://github.com/bemasher/rtlamr'}) return jsonify({'status': 'error', 'message': 'rtlamr not found. Install from https://github.com/bemasher/rtlamr'})
except Exception as e: except Exception as e:
@@ -249,38 +262,47 @@ def start_rtlamr() -> Response:
rtl_tcp_process.wait(timeout=2) rtl_tcp_process.wait(timeout=2)
rtl_tcp_process = None rtl_tcp_process = None
if rtlamr_active_device is not None: if rtlamr_active_device is not None:
app_module.release_sdr_device(rtlamr_active_device) app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
rtlamr_active_device = None rtlamr_active_device = None
return jsonify({'status': 'error', 'message': str(e)}) return jsonify({'status': 'error', 'message': str(e)})
@rtlamr_bp.route('/stop_rtlamr', methods=['POST']) @rtlamr_bp.route('/stop_rtlamr', methods=['POST'])
def stop_rtlamr() -> Response: def stop_rtlamr() -> Response:
global rtl_tcp_process, rtlamr_active_device global rtl_tcp_process, rtlamr_active_device, rtlamr_active_sdr_type
# Grab process refs inside locks, clear state, then terminate outside
rtlamr_proc = None
with app_module.rtlamr_lock: with app_module.rtlamr_lock:
if app_module.rtlamr_process: if app_module.rtlamr_process:
app_module.rtlamr_process.terminate() rtlamr_proc = app_module.rtlamr_process
try:
app_module.rtlamr_process.wait(timeout=2)
except subprocess.TimeoutExpired:
app_module.rtlamr_process.kill()
app_module.rtlamr_process = None 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 # Also stop rtl_tcp
tcp_proc = None
with rtl_tcp_lock: with rtl_tcp_lock:
if rtl_tcp_process: if rtl_tcp_process:
rtl_tcp_process.terminate() tcp_proc = rtl_tcp_process
try:
rtl_tcp_process.wait(timeout=2)
except subprocess.TimeoutExpired:
rtl_tcp_process.kill()
rtl_tcp_process = None rtl_tcp_process = None
logger.info("rtl_tcp stopped")
if tcp_proc:
tcp_proc.terminate()
try:
tcp_proc.wait(timeout=2)
except subprocess.TimeoutExpired:
tcp_proc.kill()
logger.info("rtl_tcp stopped")
# Release device from registry # Release device from registry
if rtlamr_active_device is not None: if rtlamr_active_device is not None:
app_module.release_sdr_device(rtlamr_active_device) app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
rtlamr_active_device = None rtlamr_active_device = None
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
+17 -5
View File
@@ -28,6 +28,17 @@ from utils.validation import validate_latitude, validate_longitude, validate_hou
satellite_bp = Blueprint('satellite', __name__, url_prefix='/satellite') 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) # Maximum response size for external requests (1MB)
MAX_RESPONSE_SIZE = 1024 * 1024 MAX_RESPONSE_SIZE = 1024 * 1024
@@ -178,7 +189,7 @@ def satellite_dashboard():
def predict_passes(): def predict_passes():
"""Calculate satellite passes using skyfield.""" """Calculate satellite passes using skyfield."""
try: try:
from skyfield.api import load, wgs84, EarthSatellite from skyfield.api import wgs84, EarthSatellite
from skyfield.almanac import find_discrete from skyfield.almanac import find_discrete
except ImportError: except ImportError:
return jsonify({ return jsonify({
@@ -219,7 +230,7 @@ def predict_passes():
} }
name_to_norad = {v: k for k, v in norad_to_name.items()} name_to_norad = {v: k for k, v in norad_to_name.items()}
ts = load.timescale() ts = _get_timescale()
observer = wgs84.latlon(lat, lon) observer = wgs84.latlon(lat, lon)
t0 = ts.now() t0 = ts.now()
@@ -332,7 +343,7 @@ def predict_passes():
def get_satellite_position(): def get_satellite_position():
"""Get real-time positions of satellites.""" """Get real-time positions of satellites."""
try: try:
from skyfield.api import load, wgs84, EarthSatellite from skyfield.api import wgs84, EarthSatellite
except ImportError: except ImportError:
return jsonify({'status': 'error', 'message': 'skyfield not installed'}), 503 return jsonify({'status': 'error', 'message': 'skyfield not installed'}), 503
@@ -361,7 +372,7 @@ def get_satellite_position():
else: else:
satellites.append(sat) satellites.append(sat)
ts = load.timescale() ts = _get_timescale()
observer = wgs84.latlon(lat, lon) observer = wgs84.latlon(lat, lon)
now = ts.now() now = ts.now()
now_dt = now.utc_datetime() now_dt = now.utc_datetime()
@@ -468,7 +479,8 @@ def refresh_tle_data() -> list:
'NOAA 20 (JPSS-1)': 'NOAA-20', 'NOAA 20 (JPSS-1)': 'NOAA-20',
'NOAA 21 (JPSS-2)': 'NOAA-21', 'NOAA 21 (JPSS-2)': 'NOAA-21',
'METEOR-M 2': 'METEOR-M2', 'METEOR-M 2': 'METEOR-M2',
'METEOR-M2 3': 'METEOR-M2-3' 'METEOR-M2 3': 'METEOR-M2-3',
'METEOR-M2 4': 'METEOR-M2-4'
} }
updated = [] updated = []
+18 -10
View File
@@ -28,6 +28,7 @@ sensor_bp = Blueprint('sensor', __name__)
# Track which device is being used # Track which device is being used
sensor_active_device: int | None = None sensor_active_device: int | None = None
sensor_active_sdr_type: str | None = None
# RSSI history per device (model_id -> list of (timestamp, rssi)) # RSSI history per device (model_id -> list of (timestamp, rssi))
sensor_rssi_history: dict[str, list[tuple[float, float]]] = {} sensor_rssi_history: dict[str, list[tuple[float, float]]] = {}
@@ -129,7 +130,7 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
except Exception as e: except Exception as e:
app_module.sensor_queue.put({'type': 'error', 'text': str(e)}) app_module.sensor_queue.put({'type': 'error', 'text': str(e)})
finally: finally:
global sensor_active_device global sensor_active_device, sensor_active_sdr_type
# Ensure process is terminated # Ensure process is terminated
try: try:
process.terminate() process.terminate()
@@ -145,8 +146,9 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
app_module.sensor_process = None app_module.sensor_process = None
# Release SDR device # Release SDR device
if sensor_active_device is not None: if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device) app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
sensor_active_device = None sensor_active_device = None
sensor_active_sdr_type = None
@sensor_bp.route('/sensor/status') @sensor_bp.route('/sensor/status')
@@ -159,7 +161,7 @@ def sensor_status() -> Response:
@sensor_bp.route('/start_sensor', methods=['POST']) @sensor_bp.route('/start_sensor', methods=['POST'])
def start_sensor() -> Response: def start_sensor() -> Response:
global sensor_active_device global sensor_active_device, sensor_active_sdr_type
with app_module.sensor_lock: with app_module.sensor_lock:
if app_module.sensor_process: if app_module.sensor_process:
@@ -180,10 +182,13 @@ def start_sensor() -> Response:
rtl_tcp_host = data.get('rtl_tcp_host') rtl_tcp_host = data.get('rtl_tcp_host')
rtl_tcp_port = data.get('rtl_tcp_port', 1234) 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 # Claim local device if not using remote rtl_tcp
if not rtl_tcp_host: if not rtl_tcp_host:
device_int = int(device) device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'sensor') error = app_module.claim_sdr_device(device_int, 'sensor', sdr_type_str)
if error: if error:
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
@@ -191,6 +196,7 @@ def start_sensor() -> Response:
'message': error 'message': error
}), 409 }), 409
sensor_active_device = device_int sensor_active_device = device_int
sensor_active_sdr_type = sdr_type_str
# Clear queue # Clear queue
while not app_module.sensor_queue.empty(): while not app_module.sensor_queue.empty():
@@ -199,8 +205,7 @@ def start_sensor() -> Response:
except queue.Empty: except queue.Empty:
break break
# Get SDR type and build command via abstraction layer # Build command via SDR abstraction layer
sdr_type_str = data.get('sdr_type', 'rtlsdr')
try: try:
sdr_type = SDRType(sdr_type_str) sdr_type = SDRType(sdr_type_str)
except ValueError: except ValueError:
@@ -277,20 +282,22 @@ def start_sensor() -> Response:
except FileNotFoundError: except FileNotFoundError:
# Release device on failure # Release device on failure
if sensor_active_device is not None: if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device) app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
sensor_active_device = None sensor_active_device = None
sensor_active_sdr_type = None
return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'}) return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'})
except Exception as e: except Exception as e:
# Release device on failure # Release device on failure
if sensor_active_device is not None: if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device) app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
sensor_active_device = None sensor_active_device = None
sensor_active_sdr_type = None
return jsonify({'status': 'error', 'message': str(e)}) return jsonify({'status': 'error', 'message': str(e)})
@sensor_bp.route('/stop_sensor', methods=['POST']) @sensor_bp.route('/stop_sensor', methods=['POST'])
def stop_sensor() -> Response: def stop_sensor() -> Response:
global sensor_active_device global sensor_active_device, sensor_active_sdr_type
with app_module.sensor_lock: with app_module.sensor_lock:
if app_module.sensor_process: if app_module.sensor_process:
@@ -303,8 +310,9 @@ def stop_sensor() -> Response:
# Release device from registry # Release device from registry
if sensor_active_device is not None: if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device) app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
sensor_active_device = None sensor_active_device = None
sensor_active_sdr_type = None
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
+54 -15
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import concurrent.futures
import json import json
import time import time
import urllib.error import urllib.error
@@ -259,22 +260,27 @@ IMAGE_WHITELIST: dict[str, dict[str, str]] = {
@space_weather_bp.route('/data') @space_weather_bp.route('/data')
def get_data(): def get_data():
"""Return aggregated space weather data from all sources.""" """Return aggregated space weather data from all sources."""
data = { fetchers = {
'kp_index': _fetch_kp_index(), 'kp_index': _fetch_kp_index,
'kp_forecast': _fetch_kp_forecast(), 'kp_forecast': _fetch_kp_forecast,
'scales': _fetch_scales(), 'scales': _fetch_scales,
'flux': _fetch_flux(), 'flux': _fetch_flux,
'alerts': _fetch_alerts(), 'alerts': _fetch_alerts,
'solar_wind_plasma': _fetch_solar_wind_plasma(), 'solar_wind_plasma': _fetch_solar_wind_plasma,
'solar_wind_mag': _fetch_solar_wind_mag(), 'solar_wind_mag': _fetch_solar_wind_mag,
'xrays': _fetch_xrays(), 'xrays': _fetch_xrays,
'xray_flares': _fetch_xray_flares(), 'xray_flares': _fetch_xray_flares,
'flare_probability': _fetch_flare_probability(), 'flare_probability': _fetch_flare_probability,
'solar_regions': _fetch_solar_regions(), 'solar_regions': _fetch_solar_regions,
'sunspot_report': _fetch_sunspot_report(), 'sunspot_report': _fetch_sunspot_report,
'band_conditions': _fetch_band_conditions(), 'band_conditions': _fetch_band_conditions,
'timestamp': time.time(),
} }
data = {}
with concurrent.futures.ThreadPoolExecutor(max_workers=13) as executor:
futures = {executor.submit(fn): key for key, fn in fetchers.items()}
for future in concurrent.futures.as_completed(futures):
data[futures[future]] = future.result()
data['timestamp'] = time.time()
return jsonify(data) return jsonify(data)
@@ -298,3 +304,36 @@ def get_image(key: str):
_cache_set(cache_key, img_data, TTL_IMAGE) _cache_set(cache_key, img_data, TTL_IMAGE)
return Response(img_data, content_type=entry['content_type'], return Response(img_data, content_type=entry['content_type'],
headers={'Cache-Control': 'public, max-age=300'}) headers={'Cache-Control': 'public, max-age=300'})
@space_weather_bp.route('/prefetch-images')
def prefetch_images():
"""Warm the image cache by fetching all whitelisted images in parallel."""
# Only fetch images not already cached
to_fetch = {}
for key, entry in IMAGE_WHITELIST.items():
cache_key = f'img_{key}'
if _cache_get(cache_key) is None:
to_fetch[key] = entry
if not to_fetch:
return jsonify({'status': 'all cached', 'count': 0})
def _fetch_and_cache(key: str, entry: dict) -> bool:
img_data = _fetch_bytes(entry['url'])
if img_data:
_cache_set(f'img_{key}', img_data, TTL_IMAGE)
return True
return False
fetched = 0
with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor:
futures = {
executor.submit(_fetch_and_cache, k, e): k
for k, e in to_fetch.items()
}
for future in concurrent.futures.as_completed(futures):
if future.result():
fetched += 1
return jsonify({'status': 'ok', 'fetched': fetched, 'cached': len(IMAGE_WHITELIST) - len(to_fetch)})
+142 -66
View File
@@ -7,9 +7,10 @@ ISS SSTV events occur during special commemorations and typically transmit on 14
from __future__ import annotations from __future__ import annotations
import queue import queue
import threading
import time import time
from pathlib import Path from pathlib import Path
from typing import Generator from typing import Any
from flask import Blueprint, jsonify, request, Response, send_file from flask import Blueprint, jsonify, request, Response, send_file
@@ -36,8 +37,27 @@ ISS_SSTV_FREQ_TOLERANCE_MHZ = 0.05
# Queue for SSE progress streaming # Queue for SSE progress streaming
_sstv_queue: queue.Queue = queue.Queue(maxsize=100) _sstv_queue: queue.Queue = queue.Queue(maxsize=100)
# ---------------------------------------------------------------------------
# Caching — ISS position (external API) and schedule (skyfield computation)
# ---------------------------------------------------------------------------
_iss_position_cache: dict | None = None
_iss_position_cache_time: float = 0
_iss_position_lock = threading.Lock()
ISS_POSITION_CACHE_TTL = 10 # seconds
_iss_schedule_cache: dict | None = None
_iss_schedule_cache_time: float = 0
_iss_schedule_cache_key: str | None = None
_iss_schedule_lock = threading.Lock()
ISS_SCHEDULE_CACHE_TTL = 900 # 15 minutes
# Reusable skyfield timescale (expensive to create)
_timescale = None
_timescale_lock = threading.Lock()
# Track which device is being used # Track which device is being used
sstv_active_device: int | None = None sstv_active_device: int | None = None
sstv_active_sdr_type: str = 'rtlsdr'
def _progress_callback(data: dict) -> None: def _progress_callback(data: dict) -> None:
@@ -135,6 +155,14 @@ def start_decoder():
# Get parameters # Get parameters
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
sdr_type_str = data.get('sdr_type', 'rtlsdr')
if sdr_type_str != 'rtlsdr':
return jsonify({
'status': 'error',
'message': f'{sdr_type_str.replace("_", " ").title()} is not yet supported for this mode. Please use an RTL-SDR device.'
}), 400
frequency = data.get('frequency', ISS_SSTV_FREQ) frequency = data.get('frequency', ISS_SSTV_FREQ)
modulation = str(data.get('modulation', ISS_SSTV_MODULATION)).strip().lower() modulation = str(data.get('modulation', ISS_SSTV_MODULATION)).strip().lower()
device_index = data.get('device', 0) device_index = data.get('device', 0)
@@ -190,9 +218,9 @@ def start_decoder():
longitude = None longitude = None
# Claim SDR device # Claim SDR device
global sstv_active_device global sstv_active_device, sstv_active_sdr_type
device_int = int(device_index) device_int = int(device_index)
error = app_module.claim_sdr_device(device_int, 'sstv') error = app_module.claim_sdr_device(device_int, 'sstv', sdr_type_str)
if error: if error:
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
@@ -212,6 +240,7 @@ def start_decoder():
if success: if success:
sstv_active_device = device_int sstv_active_device = device_int
sstv_active_sdr_type = sdr_type_str
result = { result = {
'status': 'started', 'status': 'started',
@@ -228,7 +257,7 @@ def start_decoder():
return jsonify(result) return jsonify(result)
else: else:
# Release device on failure # Release device on failure
app_module.release_sdr_device(device_int) app_module.release_sdr_device(device_int, sdr_type_str)
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'message': 'Failed to start decoder' 'message': 'Failed to start decoder'
@@ -243,13 +272,13 @@ def stop_decoder():
Returns: Returns:
JSON confirmation. JSON confirmation.
""" """
global sstv_active_device global sstv_active_device, sstv_active_sdr_type
decoder = get_sstv_decoder() decoder = get_sstv_decoder()
decoder.stop() decoder.stop()
# Release device from registry # Release device from registry
if sstv_active_device is not None: if sstv_active_device is not None:
app_module.release_sdr_device(sstv_active_device) app_module.release_sdr_device(sstv_active_device, sstv_active_sdr_type)
sstv_active_device = None sstv_active_device = None
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
@@ -441,12 +470,23 @@ def stream_progress():
return response return response
def _get_timescale():
"""Return a cached skyfield timescale (expensive to create)."""
global _timescale
with _timescale_lock:
if _timescale is None:
from skyfield.api import load
_timescale = load.timescale()
return _timescale
@sstv_bp.route('/iss-schedule') @sstv_bp.route('/iss-schedule')
def iss_schedule(): def iss_schedule():
""" """
Get ISS pass schedule for SSTV reception. Get ISS pass schedule for SSTV reception.
Calculates ISS passes directly using skyfield. Calculates ISS passes directly using skyfield.
Results are cached for 15 minutes per rounded location.
Query parameters: Query parameters:
latitude: Observer latitude (required) latitude: Observer latitude (required)
@@ -456,6 +496,8 @@ def iss_schedule():
Returns: Returns:
JSON with ISS pass schedule. JSON with ISS pass schedule.
""" """
global _iss_schedule_cache, _iss_schedule_cache_time, _iss_schedule_cache_key
lat = request.args.get('latitude', type=float) lat = request.args.get('latitude', type=float)
lon = request.args.get('longitude', type=float) lon = request.args.get('longitude', type=float)
hours = request.args.get('hours', 48, type=int) hours = request.args.get('hours', 48, type=int)
@@ -466,8 +508,18 @@ def iss_schedule():
'message': 'latitude and longitude parameters required' 'message': 'latitude and longitude parameters required'
}), 400 }), 400
# Cache key: rounded lat/lon (1 decimal place) so nearby locations share cache
cache_key = f"{round(lat, 1)}:{round(lon, 1)}:{hours}"
with _iss_schedule_lock:
now = time.time()
if (_iss_schedule_cache is not None
and cache_key == _iss_schedule_cache_key
and (now - _iss_schedule_cache_time) < ISS_SCHEDULE_CACHE_TTL):
return jsonify(_iss_schedule_cache)
try: try:
from skyfield.api import load, wgs84, EarthSatellite from skyfield.api import wgs84, EarthSatellite
from skyfield.almanac import find_discrete from skyfield.almanac import find_discrete
from datetime import timedelta from datetime import timedelta
from data.satellites import TLE_SATELLITES from data.satellites import TLE_SATELLITES
@@ -480,7 +532,7 @@ def iss_schedule():
'message': 'ISS TLE data not available' 'message': 'ISS TLE data not available'
}), 500 }), 500
ts = load.timescale() ts = _get_timescale()
satellite = EarthSatellite(iss_tle[1], iss_tle[2], iss_tle[0], ts) satellite = EarthSatellite(iss_tle[1], iss_tle[2], iss_tle[0], ts)
observer = wgs84.latlon(lat, lon) observer = wgs84.latlon(lat, lon)
@@ -543,13 +595,21 @@ def iss_schedule():
i += 1 i += 1
return jsonify({ result = {
'status': 'ok', 'status': 'ok',
'passes': passes, 'passes': passes,
'count': len(passes), 'count': len(passes),
'sstv_frequency': ISS_SSTV_FREQ, 'sstv_frequency': ISS_SSTV_FREQ,
'note': 'ISS SSTV events are not continuous. Check ARISS.org for scheduled events.' 'note': 'ISS SSTV events are not continuous. Check ARISS.org for scheduled events.'
}) }
# Update cache
with _iss_schedule_lock:
_iss_schedule_cache = result
_iss_schedule_cache_time = time.time()
_iss_schedule_cache_key = cache_key
return jsonify(result)
except ImportError: except ImportError:
return jsonify({ return jsonify({
@@ -565,13 +625,65 @@ def iss_schedule():
}), 500 }), 500
def _fetch_iss_position() -> dict | None:
"""Fetch raw ISS lat/lon/altitude from external APIs, with 10s cache."""
global _iss_position_cache, _iss_position_cache_time
with _iss_position_lock:
now = time.time()
if _iss_position_cache is not None and (now - _iss_position_cache_time) < ISS_POSITION_CACHE_TTL:
return _iss_position_cache
import requests
cached = None
# Try primary API: Where The ISS At
try:
response = requests.get('https://api.wheretheiss.at/v1/satellites/25544', timeout=3)
if response.status_code == 200:
data = response.json()
cached = {
'lat': float(data['latitude']),
'lon': float(data['longitude']),
'altitude': float(data.get('altitude', 420)),
'source': 'wheretheiss',
}
except Exception as e:
logger.warning(f"Where The ISS At API failed: {e}")
# Try fallback API: Open Notify
if cached is None:
try:
response = requests.get('http://api.open-notify.org/iss-now.json', timeout=3)
if response.status_code == 200:
data = response.json()
if data.get('message') == 'success':
cached = {
'lat': float(data['iss_position']['latitude']),
'lon': float(data['iss_position']['longitude']),
'altitude': 420,
'source': 'open-notify',
}
except Exception as e:
logger.warning(f"Open Notify API failed: {e}")
if cached is not None:
with _iss_position_lock:
_iss_position_cache = cached
_iss_position_cache_time = time.time()
return cached
@sstv_bp.route('/iss-position') @sstv_bp.route('/iss-position')
def iss_position(): def iss_position():
""" """
Get current ISS position from real-time API. Get current ISS position from real-time API.
Uses the Open Notify API for accurate real-time position, Uses the "Where The ISS At" API for accurate real-time position,
with fallback to "Where The ISS At" API. with fallback to Open Notify API. Raw position is cached for 10 seconds;
observer-relative data (elevation/azimuth) is computed per-request.
Query parameters: Query parameters:
latitude: Observer latitude (optional, for elevation calc) latitude: Observer latitude (optional, for elevation calc)
@@ -580,68 +692,32 @@ def iss_position():
Returns: Returns:
JSON with ISS current position. JSON with ISS current position.
""" """
import requests
from datetime import datetime from datetime import datetime
observer_lat = request.args.get('latitude', type=float) observer_lat = request.args.get('latitude', type=float)
observer_lon = request.args.get('longitude', type=float) observer_lon = request.args.get('longitude', type=float)
# Try primary API: Where The ISS At pos = _fetch_iss_position()
try: if pos is None:
response = requests.get('https://api.wheretheiss.at/v1/satellites/25544', timeout=5) return jsonify({
if response.status_code == 200: 'status': 'error',
data = response.json() 'message': 'Unable to fetch ISS position from real-time APIs'
iss_lat = float(data['latitude']) }), 503
iss_lon = float(data['longitude'])
result = { result = {
'status': 'ok', 'status': 'ok',
'lat': iss_lat, 'lat': pos['lat'],
'lon': iss_lon, 'lon': pos['lon'],
'altitude': float(data.get('altitude', 420)), 'altitude': pos['altitude'],
'timestamp': datetime.utcnow().isoformat(), 'timestamp': datetime.utcnow().isoformat(),
'source': 'wheretheiss' 'source': pos['source'],
} }
# Calculate observer-relative data if location provided # Calculate observer-relative data if location provided
if observer_lat is not None and observer_lon is not None: if observer_lat is not None and observer_lon is not None:
result.update(_calculate_observer_data(iss_lat, iss_lon, observer_lat, observer_lon)) result.update(_calculate_observer_data(pos['lat'], pos['lon'], observer_lat, observer_lon))
return jsonify(result) return jsonify(result)
except Exception as e:
logger.warning(f"Where The ISS At API failed: {e}")
# Try fallback API: Open Notify
try:
response = requests.get('http://api.open-notify.org/iss-now.json', timeout=5)
if response.status_code == 200:
data = response.json()
if data.get('message') == 'success':
iss_lat = float(data['iss_position']['latitude'])
iss_lon = float(data['iss_position']['longitude'])
result = {
'status': 'ok',
'lat': iss_lat,
'lon': iss_lon,
'altitude': 420, # Approximate ISS altitude in km
'timestamp': datetime.utcnow().isoformat(),
'source': 'open-notify'
}
# Calculate observer-relative data if location provided
if observer_lat is not None and observer_lon is not None:
result.update(_calculate_observer_data(iss_lat, iss_lon, observer_lat, observer_lon))
return jsonify(result)
except Exception as e:
logger.warning(f"Open Notify API failed: {e}")
# Both APIs failed
return jsonify({
'status': 'error',
'message': 'Unable to fetch ISS position from real-time APIs'
}), 503
def _calculate_observer_data(iss_lat: float, iss_lon: float, obs_lat: float, obs_lon: float) -> dict: def _calculate_observer_data(iss_lat: float, iss_lon: float, obs_lat: float, obs_lon: float) -> dict:
+15 -5
View File
@@ -30,6 +30,7 @@ _sstv_general_queue: queue.Queue = queue.Queue(maxsize=100)
# Track which device is being used # Track which device is being used
_sstv_general_active_device: int | None = None _sstv_general_active_device: int | None = None
_sstv_general_active_sdr_type: str = 'rtlsdr'
# Predefined SSTV frequencies # Predefined SSTV frequencies
SSTV_FREQUENCIES = [ SSTV_FREQUENCIES = [
@@ -119,6 +120,14 @@ def start_decoder():
break break
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
sdr_type_str = data.get('sdr_type', 'rtlsdr')
if sdr_type_str != 'rtlsdr':
return jsonify({
'status': 'error',
'message': f'{sdr_type_str.replace("_", " ").title()} is not yet supported for this mode. Please use an RTL-SDR device.'
}), 400
frequency = data.get('frequency') frequency = data.get('frequency')
modulation = data.get('modulation') modulation = data.get('modulation')
device_index = data.get('device', 0) device_index = data.get('device', 0)
@@ -155,9 +164,9 @@ def start_decoder():
}), 400 }), 400
# Claim SDR device # Claim SDR device
global _sstv_general_active_device global _sstv_general_active_device, _sstv_general_active_sdr_type
device_int = int(device_index) device_int = int(device_index)
error = app_module.claim_sdr_device(device_int, 'sstv_general') error = app_module.claim_sdr_device(device_int, 'sstv_general', sdr_type_str)
if error: if error:
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
@@ -175,6 +184,7 @@ def start_decoder():
if success: if success:
_sstv_general_active_device = device_int _sstv_general_active_device = device_int
_sstv_general_active_sdr_type = sdr_type_str
return jsonify({ return jsonify({
'status': 'started', 'status': 'started',
'frequency': frequency, 'frequency': frequency,
@@ -182,7 +192,7 @@ def start_decoder():
'device': device_index, 'device': device_index,
}) })
else: else:
app_module.release_sdr_device(device_int) app_module.release_sdr_device(device_int, sdr_type_str)
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'message': 'Failed to start decoder', 'message': 'Failed to start decoder',
@@ -192,12 +202,12 @@ def start_decoder():
@sstv_general_bp.route('/stop', methods=['POST']) @sstv_general_bp.route('/stop', methods=['POST'])
def stop_decoder(): def stop_decoder():
"""Stop general SSTV decoder.""" """Stop general SSTV decoder."""
global _sstv_general_active_device global _sstv_general_active_device, _sstv_general_active_sdr_type
decoder = get_general_sstv_decoder() decoder = get_general_sstv_decoder()
decoder.stop() decoder.stop()
if _sstv_general_active_device is not None: if _sstv_general_active_device is not None:
app_module.release_sdr_device(_sstv_general_active_device) app_module.release_sdr_device(_sstv_general_active_device, _sstv_general_active_sdr_type)
_sstv_general_active_device = None _sstv_general_active_device = None
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
+583
View File
@@ -0,0 +1,583 @@
"""System Health monitoring blueprint.
Provides real-time system metrics (CPU, memory, disk, temperatures,
network, battery, fans), active process status, SDR device enumeration,
location, and weather data via SSE streaming and REST endpoints.
"""
from __future__ import annotations
import contextlib
import os
import platform
import queue
import socket
import subprocess
import threading
import time
from pathlib import Path
from typing import Any
from flask import Blueprint, Response, jsonify, request
from utils.constants import SSE_KEEPALIVE_INTERVAL, SSE_QUEUE_TIMEOUT
from utils.logging import sensor_logger as logger
from utils.sse import sse_stream_fanout
try:
import psutil
_HAS_PSUTIL = True
except ImportError:
psutil = None # type: ignore[assignment]
_HAS_PSUTIL = False
try:
import requests as _requests
except ImportError:
_requests = None # type: ignore[assignment]
system_bp = Blueprint('system', __name__, url_prefix='/system')
# ---------------------------------------------------------------------------
# Background metrics collector
# ---------------------------------------------------------------------------
_metrics_queue: queue.Queue = queue.Queue(maxsize=500)
_collector_started = False
_collector_lock = threading.Lock()
_app_start_time: float | None = None
# Weather cache
_weather_cache: dict[str, Any] = {}
_weather_cache_time: float = 0.0
_WEATHER_CACHE_TTL = 600 # 10 minutes
def _get_app_start_time() -> float:
"""Return the application start timestamp from the main app module."""
global _app_start_time
if _app_start_time is None:
try:
import app as app_module
_app_start_time = getattr(app_module, '_app_start_time', time.time())
except Exception:
_app_start_time = time.time()
return _app_start_time
def _get_app_version() -> str:
"""Return the application version string."""
try:
from config import VERSION
return VERSION
except Exception:
return 'unknown'
def _format_uptime(seconds: float) -> str:
"""Format seconds into a human-readable uptime string."""
days = int(seconds // 86400)
hours = int((seconds % 86400) // 3600)
minutes = int((seconds % 3600) // 60)
parts = []
if days > 0:
parts.append(f'{days}d')
if hours > 0:
parts.append(f'{hours}h')
parts.append(f'{minutes}m')
return ' '.join(parts)
def _collect_process_status() -> dict[str, bool]:
"""Return running/stopped status for each decoder process.
Mirrors the logic in app.py health_check().
"""
try:
import app as app_module
def _alive(attr: str) -> bool:
proc = getattr(app_module, attr, None)
if proc is None:
return False
try:
return proc.poll() is None
except Exception:
return False
processes: dict[str, bool] = {
'pager': _alive('current_process'),
'sensor': _alive('sensor_process'),
'adsb': _alive('adsb_process'),
'ais': _alive('ais_process'),
'acars': _alive('acars_process'),
'vdl2': _alive('vdl2_process'),
'aprs': _alive('aprs_process'),
'dsc': _alive('dsc_process'),
'morse': _alive('morse_process'),
}
# WiFi
try:
from app import _get_wifi_health
wifi_active, _, _ = _get_wifi_health()
processes['wifi'] = wifi_active
except Exception:
processes['wifi'] = False
# Bluetooth
try:
from app import _get_bluetooth_health
bt_active, _ = _get_bluetooth_health()
processes['bluetooth'] = bt_active
except Exception:
processes['bluetooth'] = False
# SubGHz
try:
from app import _get_subghz_active
processes['subghz'] = _get_subghz_active()
except Exception:
processes['subghz'] = False
return processes
except Exception:
return {}
def _collect_throttle_flags() -> str | None:
"""Read Raspberry Pi throttle flags via vcgencmd (Linux/Pi only)."""
try:
result = subprocess.run(
['vcgencmd', 'get_throttled'],
capture_output=True,
text=True,
timeout=2,
)
if result.returncode == 0 and 'throttled=' in result.stdout:
return result.stdout.strip().split('=', 1)[1]
except Exception:
pass
return None
def _collect_power_draw() -> float | None:
"""Read power draw in watts from sysfs (Linux only)."""
try:
power_supply = Path('/sys/class/power_supply')
if not power_supply.exists():
return None
for supply_dir in power_supply.iterdir():
power_file = supply_dir / 'power_now'
if power_file.exists():
val = int(power_file.read_text().strip())
return round(val / 1_000_000, 2) # microwatts to watts
except Exception:
pass
return None
def _collect_metrics() -> dict[str, Any]:
"""Gather a snapshot of system metrics."""
now = time.time()
start = _get_app_start_time()
uptime_seconds = round(now - start, 2)
metrics: dict[str, Any] = {
'type': 'system_metrics',
'timestamp': now,
'system': {
'hostname': socket.gethostname(),
'platform': platform.platform(),
'python': platform.python_version(),
'version': _get_app_version(),
'uptime_seconds': uptime_seconds,
'uptime_human': _format_uptime(uptime_seconds),
},
'processes': _collect_process_status(),
}
if _HAS_PSUTIL:
# CPU — overall + per-core + frequency
cpu_percent = psutil.cpu_percent(interval=None)
cpu_count = psutil.cpu_count() or 1
try:
load_1, load_5, load_15 = os.getloadavg()
except (OSError, AttributeError):
load_1 = load_5 = load_15 = 0.0
per_core = []
with contextlib.suppress(Exception):
per_core = psutil.cpu_percent(interval=None, percpu=True)
freq_data = None
with contextlib.suppress(Exception):
freq = psutil.cpu_freq()
if freq:
freq_data = {
'current': round(freq.current, 0),
'min': round(freq.min, 0),
'max': round(freq.max, 0),
}
metrics['cpu'] = {
'percent': cpu_percent,
'count': cpu_count,
'load_1': round(load_1, 2),
'load_5': round(load_5, 2),
'load_15': round(load_15, 2),
'per_core': per_core,
'freq': freq_data,
}
# Memory
mem = psutil.virtual_memory()
metrics['memory'] = {
'total': mem.total,
'used': mem.used,
'available': mem.available,
'percent': mem.percent,
}
swap = psutil.swap_memory()
metrics['swap'] = {
'total': swap.total,
'used': swap.used,
'percent': swap.percent,
}
# Disk — usage + I/O counters
try:
disk = psutil.disk_usage('/')
metrics['disk'] = {
'total': disk.total,
'used': disk.used,
'free': disk.free,
'percent': disk.percent,
'path': '/',
}
except Exception:
metrics['disk'] = None
disk_io = None
with contextlib.suppress(Exception):
dio = psutil.disk_io_counters()
if dio:
disk_io = {
'read_bytes': dio.read_bytes,
'write_bytes': dio.write_bytes,
'read_count': dio.read_count,
'write_count': dio.write_count,
}
metrics['disk_io'] = disk_io
# Temperatures
try:
temps = psutil.sensors_temperatures()
if temps:
temp_data: dict[str, list[dict[str, Any]]] = {}
for chip, entries in temps.items():
temp_data[chip] = [
{
'label': e.label or chip,
'current': e.current,
'high': e.high,
'critical': e.critical,
}
for e in entries
]
metrics['temperatures'] = temp_data
else:
metrics['temperatures'] = None
except (AttributeError, Exception):
metrics['temperatures'] = None
# Fans
fans_data = None
with contextlib.suppress(Exception):
fans = psutil.sensors_fans()
if fans:
fans_data = {}
for chip, entries in fans.items():
fans_data[chip] = [
{'label': e.label or chip, 'current': e.current}
for e in entries
]
metrics['fans'] = fans_data
# Battery
battery_data = None
with contextlib.suppress(Exception):
bat = psutil.sensors_battery()
if bat:
battery_data = {
'percent': bat.percent,
'plugged': bat.power_plugged,
'secs_left': bat.secsleft if bat.secsleft != psutil.POWER_TIME_UNLIMITED else None,
}
metrics['battery'] = battery_data
# Network interfaces
net_ifaces: list[dict[str, Any]] = []
with contextlib.suppress(Exception):
addrs = psutil.net_if_addrs()
stats = psutil.net_if_stats()
for iface_name in sorted(addrs.keys()):
if iface_name == 'lo':
continue
iface_info: dict[str, Any] = {'name': iface_name}
# Get addresses
for addr in addrs[iface_name]:
if addr.family == socket.AF_INET:
iface_info['ipv4'] = addr.address
elif addr.family == socket.AF_INET6:
iface_info.setdefault('ipv6', addr.address)
elif addr.family == psutil.AF_LINK:
iface_info['mac'] = addr.address
# Get stats
if iface_name in stats:
st = stats[iface_name]
iface_info['is_up'] = st.isup
iface_info['speed'] = st.speed # Mbps
iface_info['mtu'] = st.mtu
net_ifaces.append(iface_info)
metrics['network'] = {'interfaces': net_ifaces}
# Network I/O counters (raw — JS computes deltas)
net_io = None
with contextlib.suppress(Exception):
counters = psutil.net_io_counters(pernic=True)
if counters:
net_io = {}
for nic, c in counters.items():
if nic == 'lo':
continue
net_io[nic] = {
'bytes_sent': c.bytes_sent,
'bytes_recv': c.bytes_recv,
}
metrics['network']['io'] = net_io
# Connection count
conn_count = 0
with contextlib.suppress(Exception):
conn_count = len(psutil.net_connections())
metrics['network']['connections'] = conn_count
# Boot time
boot_ts = None
with contextlib.suppress(Exception):
boot_ts = psutil.boot_time()
metrics['boot_time'] = boot_ts
# Power / throttle (Pi-specific)
metrics['power'] = {
'throttled': _collect_throttle_flags(),
'draw_watts': _collect_power_draw(),
}
else:
metrics['cpu'] = None
metrics['memory'] = None
metrics['swap'] = None
metrics['disk'] = None
metrics['disk_io'] = None
metrics['temperatures'] = None
metrics['fans'] = None
metrics['battery'] = None
metrics['network'] = None
metrics['boot_time'] = None
metrics['power'] = None
return metrics
def _collector_loop() -> None:
"""Background thread that pushes metrics onto the queue every 3 seconds."""
# Seed psutil's CPU measurement so the first real read isn't 0%.
if _HAS_PSUTIL:
with contextlib.suppress(Exception):
psutil.cpu_percent(interval=None)
while True:
try:
metrics = _collect_metrics()
# Non-blocking put — drop oldest if full
try:
_metrics_queue.put_nowait(metrics)
except queue.Full:
with contextlib.suppress(queue.Empty):
_metrics_queue.get_nowait()
_metrics_queue.put_nowait(metrics)
except Exception as exc:
logger.debug('system metrics collection error: %s', exc)
time.sleep(3)
def _ensure_collector() -> None:
"""Start the background collector thread once."""
global _collector_started
if _collector_started:
return
with _collector_lock:
if _collector_started:
return
t = threading.Thread(target=_collector_loop, daemon=True, name='system-metrics-collector')
t.start()
_collector_started = True
logger.info('System metrics collector started')
def _get_observer_location() -> dict[str, Any]:
"""Get observer location from GPS state or config defaults."""
lat, lon, source = None, None, 'none'
gps_meta: dict[str, Any] = {}
# Try GPS via utils.gps
with contextlib.suppress(Exception):
from utils.gps import get_current_position
pos = get_current_position()
if pos and pos.fix_quality >= 2:
lat, lon, source = pos.latitude, pos.longitude, 'gps'
gps_meta['fix_quality'] = pos.fix_quality
gps_meta['satellites'] = pos.satellites
if pos.epx is not None and pos.epy is not None:
gps_meta['accuracy'] = round(max(pos.epx, pos.epy), 1)
if pos.altitude is not None:
gps_meta['altitude'] = round(pos.altitude, 1)
# Fall back to config env vars
if lat is None:
with contextlib.suppress(Exception):
from config import DEFAULT_LATITUDE, DEFAULT_LONGITUDE
if DEFAULT_LATITUDE != 0.0 or DEFAULT_LONGITUDE != 0.0:
lat, lon, source = DEFAULT_LATITUDE, DEFAULT_LONGITUDE, 'config'
# Fall back to hardcoded constants (London)
if lat is None:
with contextlib.suppress(Exception):
from utils.constants import DEFAULT_LATITUDE as CONST_LAT
from utils.constants import DEFAULT_LONGITUDE as CONST_LON
lat, lon, source = CONST_LAT, CONST_LON, 'default'
result: dict[str, Any] = {'lat': lat, 'lon': lon, 'source': source}
if gps_meta:
result['gps'] = gps_meta
return result
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@system_bp.route('/metrics')
def get_metrics() -> Response:
"""REST snapshot of current system metrics."""
_ensure_collector()
return jsonify(_collect_metrics())
@system_bp.route('/stream')
def stream_system() -> Response:
"""SSE stream for real-time system metrics."""
_ensure_collector()
response = Response(
sse_stream_fanout(
source_queue=_metrics_queue,
channel_key='system',
timeout=SSE_QUEUE_TIMEOUT,
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
),
mimetype='text/event-stream',
)
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
return response
@system_bp.route('/sdr_devices')
def get_sdr_devices() -> Response:
"""Enumerate all connected SDR devices (on-demand, not every tick)."""
try:
from utils.sdr.detection import detect_all_devices
devices = detect_all_devices()
result = []
for d in devices:
result.append({
'type': d.sdr_type.value if hasattr(d.sdr_type, 'value') else str(d.sdr_type),
'index': d.index,
'name': d.name,
'serial': d.serial or '',
'driver': d.driver or '',
})
return jsonify({'devices': result})
except Exception as exc:
logger.warning('SDR device detection failed: %s', exc)
return jsonify({'devices': [], 'error': str(exc)})
@system_bp.route('/location')
def get_location() -> Response:
"""Return observer location from GPS or config."""
return jsonify(_get_observer_location())
@system_bp.route('/weather')
def get_weather() -> Response:
"""Proxy weather from wttr.in, cached for 10 minutes."""
global _weather_cache, _weather_cache_time
now = time.time()
if _weather_cache and (now - _weather_cache_time) < _WEATHER_CACHE_TTL:
return jsonify(_weather_cache)
lat = request.args.get('lat', type=float)
lon = request.args.get('lon', type=float)
if lat is None or lon is None:
loc = _get_observer_location()
lat, lon = loc.get('lat'), loc.get('lon')
if lat is None or lon is None:
return jsonify({'error': 'No location available'})
if _requests is None:
return jsonify({'error': 'requests library not available'})
try:
resp = _requests.get(
f'https://wttr.in/{lat},{lon}?format=j1',
timeout=5,
headers={'User-Agent': 'INTERCEPT-SystemHealth/1.0'},
)
resp.raise_for_status()
data = resp.json()
current = data.get('current_condition', [{}])[0]
weather = {
'temp_c': current.get('temp_C'),
'temp_f': current.get('temp_F'),
'condition': current.get('weatherDesc', [{}])[0].get('value', ''),
'humidity': current.get('humidity'),
'wind_mph': current.get('windspeedMiles'),
'wind_dir': current.get('winddir16Point'),
'feels_like_c': current.get('FeelsLikeC'),
'visibility': current.get('visibility'),
'pressure': current.get('pressure'),
}
_weather_cache = weather
_weather_cache_time = now
return jsonify(weather)
except Exception as exc:
logger.debug('Weather fetch failed: %s', exc)
return jsonify({'error': str(exc)})
+71 -44
View File
@@ -1345,7 +1345,7 @@ def _scan_rf_signals(
sweep_ranges: list[dict] | None = None sweep_ranges: list[dict] | None = None
) -> list[dict]: ) -> list[dict]:
""" """
Scan for RF signals using SDR (rtl_power). Scan for RF signals using SDR (rtl_power or hackrf_sweep).
Scans common surveillance frequency bands: Scans common surveillance frequency bands:
- 88-108 MHz: FM broadcast (potential FM bugs) - 88-108 MHz: FM broadcast (potential FM bugs)
@@ -1375,39 +1375,50 @@ def _scan_rf_signals(
logger.info(f"Starting RF scan (device={sdr_device})") logger.info(f"Starting RF scan (device={sdr_device})")
# Detect available SDR devices and sweep tools
rtl_power_path = shutil.which('rtl_power') rtl_power_path = shutil.which('rtl_power')
if not rtl_power_path: hackrf_sweep_path = shutil.which('hackrf_sweep')
logger.warning("rtl_power not found in PATH, RF scanning unavailable")
sdr_type = None
sweep_tool_path = None
try:
from utils.sdr import SDRFactory
from utils.sdr.base import SDRType
devices = SDRFactory.detect_devices()
rtlsdr_available = any(d.sdr_type == SDRType.RTL_SDR for d in devices)
hackrf_available = any(d.sdr_type == SDRType.HACKRF for d in devices)
except ImportError:
rtlsdr_available = False
hackrf_available = False
# Pick the best available SDR + sweep tool combo
if rtlsdr_available and rtl_power_path:
sdr_type = 'rtlsdr'
sweep_tool_path = rtl_power_path
logger.info(f"Using RTL-SDR with rtl_power at: {rtl_power_path}")
elif hackrf_available and hackrf_sweep_path:
sdr_type = 'hackrf'
sweep_tool_path = hackrf_sweep_path
logger.info(f"Using HackRF with hackrf_sweep at: {hackrf_sweep_path}")
elif rtl_power_path:
# Tool exists but no device detected — try anyway (detection may have failed)
sdr_type = 'rtlsdr'
sweep_tool_path = rtl_power_path
logger.info(f"No SDR detected but rtl_power found, attempting RTL-SDR scan")
elif hackrf_sweep_path:
sdr_type = 'hackrf'
sweep_tool_path = hackrf_sweep_path
logger.info(f"No SDR detected but hackrf_sweep found, attempting HackRF scan")
if not sweep_tool_path:
logger.warning("No supported sweep tool found (rtl_power or hackrf_sweep)")
_emit_event('rf_status', { _emit_event('rf_status', {
'status': 'error', 'status': 'error',
'message': 'rtl_power not installed. Install rtl-sdr package for RF scanning.', 'message': 'No SDR sweep tool installed. Install rtl-sdr (rtl_power) or HackRF (hackrf_sweep) for RF scanning.',
}) })
return signals return signals
logger.info(f"Found rtl_power at: {rtl_power_path}")
# Test if RTL-SDR device is accessible
rtl_test_path = shutil.which('rtl_test')
if rtl_test_path:
try:
test_result = subprocess.run(
[rtl_test_path, '-t'],
capture_output=True,
text=True,
timeout=5
)
if 'No supported devices found' in test_result.stderr or test_result.returncode != 0:
logger.warning("No RTL-SDR device found")
_emit_event('rf_status', {
'status': 'error',
'message': 'No RTL-SDR device connected. Connect an RTL-SDR dongle for RF scanning.',
})
return signals
except subprocess.TimeoutExpired:
pass # Device might be busy, continue anyway
except Exception as e:
logger.debug(f"rtl_test check failed: {e}")
# Define frequency bands to scan (in Hz) # Define frequency bands to scan (in Hz)
# Format: (start_freq, end_freq, bin_size, description) # Format: (start_freq, end_freq, bin_size, description)
scan_bands: list[tuple[int, int, int, str]] = [] scan_bands: list[tuple[int, int, int, str]] = []
@@ -1448,7 +1459,7 @@ def _scan_rf_signals(
try: try:
# Build device argument # Build device argument
device_arg = ['-d', str(sdr_device if sdr_device is not None else 0)] device_idx = sdr_device if sdr_device is not None else 0
# Scan each band and look for strong signals # Scan each band and look for strong signals
for start_freq, end_freq, bin_size, band_name in scan_bands: for start_freq, end_freq, bin_size, band_name in scan_bands:
@@ -1458,15 +1469,27 @@ def _scan_rf_signals(
logger.info(f"Scanning {band_name} ({start_freq/1e6:.1f}-{end_freq/1e6:.1f} MHz)") logger.info(f"Scanning {band_name} ({start_freq/1e6:.1f}-{end_freq/1e6:.1f} MHz)")
try: try:
# Run rtl_power for a quick sweep of this band # Build sweep command based on SDR type
cmd = [ if sdr_type == 'hackrf':
rtl_power_path, cmd = [
'-f', f'{start_freq}:{end_freq}:{bin_size}', sweep_tool_path,
'-g', '40', # Gain '-f', f'{int(start_freq / 1e6)}:{int(end_freq / 1e6)}',
'-i', '1', # Integration interval (1 second) '-w', str(bin_size),
'-1', # Single shot mode '-1', # Single sweep
'-c', '20%', # Crop 20% of edges ]
] + device_arg + [tmp_path] output_mode = 'stdout'
else:
cmd = [
sweep_tool_path,
'-f', f'{start_freq}:{end_freq}:{bin_size}',
'-g', '40', # Gain
'-i', '1', # Integration interval (1 second)
'-1', # Single shot mode
'-c', '20%', # Crop 20% of edges
'-d', str(device_idx),
tmp_path,
]
output_mode = 'file'
logger.debug(f"Running: {' '.join(cmd)}") logger.debug(f"Running: {' '.join(cmd)}")
@@ -1478,9 +1501,14 @@ def _scan_rf_signals(
) )
if result.returncode != 0: if result.returncode != 0:
logger.warning(f"rtl_power returned {result.returncode}: {result.stderr}") logger.warning(f"{os.path.basename(sweep_tool_path)} returned {result.returncode}: {result.stderr}")
# Parse the CSV output # For HackRF, write stdout CSV data to temp file for unified parsing
if output_mode == 'stdout' and result.stdout:
with open(tmp_path, 'w') as f:
f.write(result.stdout)
# Parse the CSV output (same format for both rtl_power and hackrf_sweep)
if os.path.exists(tmp_path) and os.path.getsize(tmp_path) > 0: if os.path.exists(tmp_path) and os.path.getsize(tmp_path) > 0:
with open(tmp_path, 'r') as f: with open(tmp_path, 'r') as f:
for line in f: for line in f:
@@ -1488,13 +1516,12 @@ def _scan_rf_signals(
if len(parts) >= 7: if len(parts) >= 7:
try: try:
# CSV format: date, time, hz_low, hz_high, hz_step, samples, db_values... # CSV format: date, time, hz_low, hz_high, hz_step, samples, db_values...
hz_low = int(parts[2]) hz_low = int(parts[2].strip())
hz_high = int(parts[3]) hz_high = int(parts[3].strip())
hz_step = float(parts[4]) hz_step = float(parts[4].strip())
db_values = [float(x) for x in parts[6:] if x.strip()] db_values = [float(x) for x in parts[6:] if x.strip()]
# Find peaks above noise floor # Find peaks above noise floor
# RTL-SDR dongles have higher noise figures, so use permissive thresholds
noise_floor = sum(db_values) / len(db_values) if db_values else -100 noise_floor = sum(db_values) / len(db_values) if db_values else -100
threshold = noise_floor + 6 # Signal must be 6dB above noise threshold = noise_floor + 6 # Signal must be 6dB above noise
+69 -25
View File
@@ -13,23 +13,25 @@ import subprocess
import threading import threading
import time import time
from datetime import datetime from datetime import datetime
from typing import Generator from typing import Any, Generator
from flask import Blueprint, jsonify, request, Response from flask import Blueprint, Response, jsonify, request
import app as app_module import app as app_module
from utils.logging import sensor_logger as logger from utils.acars_translator import translate_message
from utils.validation import validate_device_index, validate_gain, validate_ppm
from utils.sdr import SDRFactory, SDRType
from utils.sse import sse_stream_fanout
from utils.event_pipeline import process_event
from utils.constants import ( from utils.constants import (
PROCESS_START_WAIT,
PROCESS_TERMINATE_TIMEOUT, PROCESS_TERMINATE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL, SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT, SSE_QUEUE_TIMEOUT,
PROCESS_START_WAIT,
) )
from utils.event_pipeline import process_event
from utils.flight_correlator import get_flight_correlator
from utils.logging import sensor_logger as logger
from utils.process import register_process, unregister_process from utils.process import register_process, unregister_process
from utils.sdr import SDRFactory, SDRType
from utils.sse import sse_stream_fanout
from utils.validation import validate_device_index, validate_gain, validate_ppm
vdl2_bp = Blueprint('vdl2', __name__, url_prefix='/vdl2') vdl2_bp = Blueprint('vdl2', __name__, url_prefix='/vdl2')
@@ -48,6 +50,7 @@ vdl2_last_message_time = None
# Track which device is being used # Track which device is being used
vdl2_active_device: int | None = None vdl2_active_device: int | None = None
vdl2_active_sdr_type: str | None = None
def find_dumpvdl2(): def find_dumpvdl2():
@@ -79,6 +82,21 @@ def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) ->
data['type'] = 'vdl2' data['type'] = 'vdl2'
data['timestamp'] = datetime.utcnow().isoformat() + 'Z' data['timestamp'] = datetime.utcnow().isoformat() + 'Z'
# Enrich with translated ACARS label at top level (consistent with ACARS route)
try:
vdl2_inner = data.get('vdl2', data)
acars_payload = (vdl2_inner.get('avlc') or {}).get('acars')
if acars_payload and acars_payload.get('label'):
translation = translate_message({
'label': acars_payload.get('label'),
'text': acars_payload.get('msg_text', ''),
})
data['label_description'] = translation['label_description']
data['message_type'] = translation['message_type']
data['parsed'] = translation['parsed']
except Exception:
pass
# Update stats # Update stats
vdl2_message_count += 1 vdl2_message_count += 1
vdl2_last_message_time = time.time() vdl2_last_message_time = time.time()
@@ -87,7 +105,6 @@ def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) ->
# Feed flight correlator # Feed flight correlator
try: try:
from utils.flight_correlator import get_flight_correlator
get_flight_correlator().add_vdl2_message(data) get_flight_correlator().add_vdl2_message(data)
except Exception: except Exception:
pass pass
@@ -110,7 +127,7 @@ def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) ->
logger.error(f"VDL2 stream error: {e}") logger.error(f"VDL2 stream error: {e}")
app_module.vdl2_queue.put({'type': 'error', 'message': str(e)}) app_module.vdl2_queue.put({'type': 'error', 'message': str(e)})
finally: finally:
global vdl2_active_device global vdl2_active_device, vdl2_active_sdr_type
# Ensure process is terminated # Ensure process is terminated
try: try:
process.terminate() process.terminate()
@@ -126,8 +143,9 @@ def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) ->
app_module.vdl2_process = None app_module.vdl2_process = None
# Release SDR device # Release SDR device
if vdl2_active_device is not None: if vdl2_active_device is not None:
app_module.release_sdr_device(vdl2_active_device) app_module.release_sdr_device(vdl2_active_device, vdl2_active_sdr_type or 'rtlsdr')
vdl2_active_device = None vdl2_active_device = None
vdl2_active_sdr_type = None
@vdl2_bp.route('/tools') @vdl2_bp.route('/tools')
@@ -159,7 +177,7 @@ def vdl2_status() -> Response:
@vdl2_bp.route('/start', methods=['POST']) @vdl2_bp.route('/start', methods=['POST'])
def start_vdl2() -> Response: def start_vdl2() -> Response:
"""Start VDL2 decoder.""" """Start VDL2 decoder."""
global vdl2_message_count, vdl2_last_message_time, vdl2_active_device global vdl2_message_count, vdl2_last_message_time, vdl2_active_device, vdl2_active_sdr_type
with app_module.vdl2_lock: with app_module.vdl2_lock:
if app_module.vdl2_process and app_module.vdl2_process.poll() is None: if app_module.vdl2_process and app_module.vdl2_process.poll() is None:
@@ -186,9 +204,16 @@ def start_vdl2() -> Response:
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return jsonify({'status': 'error', 'message': str(e)}), 400
# Resolve SDR type for device selection
sdr_type_str = data.get('sdr_type', 'rtlsdr')
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
# Check if device is available # Check if device is available
device_int = int(device) device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'vdl2') error = app_module.claim_sdr_device(device_int, 'vdl2', sdr_type_str)
if error: if error:
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
@@ -197,6 +222,7 @@ def start_vdl2() -> Response:
}), 409 }), 409
vdl2_active_device = device_int vdl2_active_device = device_int
vdl2_active_sdr_type = sdr_type_str
# Get frequencies - use provided or defaults # Get frequencies - use provided or defaults
# dumpvdl2 expects frequencies in Hz (integers) # dumpvdl2 expects frequencies in Hz (integers)
@@ -215,13 +241,6 @@ def start_vdl2() -> Response:
vdl2_message_count = 0 vdl2_message_count = 0
vdl2_last_message_time = None vdl2_last_message_time = None
# Resolve SDR type for device selection
sdr_type_str = data.get('sdr_type', 'rtlsdr')
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
is_soapy = sdr_type not in (SDRType.RTL_SDR,) is_soapy = sdr_type not in (SDRType.RTL_SDR,)
# Build dumpvdl2 command # Build dumpvdl2 command
@@ -281,14 +300,17 @@ def start_vdl2() -> Response:
if process.poll() is not None: if process.poll() is not None:
# Process died - release device # Process died - release device
if vdl2_active_device is not None: if vdl2_active_device is not None:
app_module.release_sdr_device(vdl2_active_device) app_module.release_sdr_device(vdl2_active_device, vdl2_active_sdr_type or 'rtlsdr')
vdl2_active_device = None vdl2_active_device = None
vdl2_active_sdr_type = None
stderr = '' stderr = ''
if process.stderr: if process.stderr:
stderr = process.stderr.read().decode('utf-8', errors='replace') stderr = process.stderr.read().decode('utf-8', errors='replace')
if stderr:
logger.error(f"dumpvdl2 stderr:\n{stderr}")
error_msg = 'dumpvdl2 failed to start' error_msg = 'dumpvdl2 failed to start'
if stderr: if stderr:
error_msg += f': {stderr[:200]}' error_msg += f': {stderr[:500]}'
logger.error(error_msg) logger.error(error_msg)
return jsonify({'status': 'error', 'message': error_msg}), 500 return jsonify({'status': 'error', 'message': error_msg}), 500
@@ -313,8 +335,9 @@ def start_vdl2() -> Response:
except Exception as e: except Exception as e:
# Release device on failure # Release device on failure
if vdl2_active_device is not None: if vdl2_active_device is not None:
app_module.release_sdr_device(vdl2_active_device) app_module.release_sdr_device(vdl2_active_device, vdl2_active_sdr_type or 'rtlsdr')
vdl2_active_device = None vdl2_active_device = None
vdl2_active_sdr_type = None
logger.error(f"Failed to start VDL2 decoder: {e}") logger.error(f"Failed to start VDL2 decoder: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500 return jsonify({'status': 'error', 'message': str(e)}), 500
@@ -322,7 +345,7 @@ def start_vdl2() -> Response:
@vdl2_bp.route('/stop', methods=['POST']) @vdl2_bp.route('/stop', methods=['POST'])
def stop_vdl2() -> Response: def stop_vdl2() -> Response:
"""Stop VDL2 decoder.""" """Stop VDL2 decoder."""
global vdl2_active_device global vdl2_active_device, vdl2_active_sdr_type
with app_module.vdl2_lock: with app_module.vdl2_lock:
if not app_module.vdl2_process: if not app_module.vdl2_process:
@@ -343,8 +366,9 @@ def stop_vdl2() -> Response:
# Release device from registry # Release device from registry
if vdl2_active_device is not None: if vdl2_active_device is not None:
app_module.release_sdr_device(vdl2_active_device) app_module.release_sdr_device(vdl2_active_device, vdl2_active_sdr_type or 'rtlsdr')
vdl2_active_device = None vdl2_active_device = None
vdl2_active_sdr_type = None
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
@@ -370,6 +394,26 @@ def stream_vdl2() -> Response:
return response return response
@vdl2_bp.route('/messages')
def get_vdl2_messages() -> Response:
"""Get recent VDL2 messages from correlator (for history reload)."""
limit = request.args.get('limit', 50, type=int)
limit = max(1, min(limit, 200))
msgs = get_flight_correlator().get_recent_messages('vdl2', limit)
return jsonify(msgs)
@vdl2_bp.route('/clear', methods=['POST'])
def clear_vdl2_messages() -> Response:
"""Clear stored VDL2 messages and reset counter."""
global vdl2_message_count, vdl2_last_message_time
get_flight_correlator().clear_vdl2()
vdl2_message_count = 0
vdl2_last_message_time = None
return jsonify({'status': 'cleared'})
@vdl2_bp.route('/frequencies') @vdl2_bp.route('/frequencies')
def get_frequencies() -> Response: def get_frequencies() -> Response:
"""Get default VDL2 frequencies.""" """Get default VDL2 frequencies."""
+130 -35
View File
@@ -1,7 +1,10 @@
"""WebSocket-based waterfall streaming with I/Q capture and server-side FFT.""" """WebSocket-based waterfall streaming with I/Q capture and server-side FFT."""
from __future__ import annotations
import json import json
import queue import queue
import shutil
import socket import socket
import subprocess import subprocess
import threading import threading
@@ -34,7 +37,7 @@ logger = get_logger('intercept.waterfall_ws')
AUDIO_SAMPLE_RATE = 48000 AUDIO_SAMPLE_RATE = 48000
_shared_state_lock = threading.Lock() _shared_state_lock = threading.Lock()
_shared_audio_queue: queue.Queue[bytes] = queue.Queue(maxsize=80) _shared_audio_queue: queue.Queue[bytes] = queue.Queue(maxsize=20)
_shared_state: dict[str, Any] = { _shared_state: dict[str, Any] = {
'running': False, 'running': False,
'device': None, 'device': None,
@@ -46,6 +49,10 @@ _shared_state: dict[str, Any] = {
'monitor_modulation': 'wfm', 'monitor_modulation': 'wfm',
'monitor_squelch': 0, 'monitor_squelch': 0,
} }
# Generation counter to prevent stale WebSocket handlers from clobbering
# shared state set by a newer handler (e.g. old handler's finally block
# running after a new connection has already started capture).
_capture_generation: int = 0
# Maximum bandwidth per SDR type (Hz) # Maximum bandwidth per SDR type (Hz)
MAX_BANDWIDTH = { MAX_BANDWIDTH = {
@@ -72,8 +79,23 @@ def _set_shared_capture_state(
center_mhz: float | None = None, center_mhz: float | None = None,
span_mhz: float | None = None, span_mhz: float | None = None,
sample_rate: int | None = None, sample_rate: int | None = None,
) -> None: generation: int | None = None,
) -> int:
"""Update shared capture state.
Returns the current generation counter. When *running* is True and
*generation* is None the counter is bumped; callers should capture
the returned value and pass it back when setting running=False so
that stale handlers cannot clobber a newer session.
"""
global _capture_generation
with _shared_state_lock: with _shared_state_lock:
if not running and generation is not None:
# Only allow the matching generation to clear the state.
if generation != _capture_generation:
return _capture_generation
if running and generation is None:
_capture_generation += 1
_shared_state['running'] = bool(running) _shared_state['running'] = bool(running)
_shared_state['device'] = device if running else None _shared_state['device'] = device if running else None
if center_mhz is not None: if center_mhz is not None:
@@ -84,8 +106,10 @@ def _set_shared_capture_state(
_shared_state['sample_rate'] = int(sample_rate) _shared_state['sample_rate'] = int(sample_rate)
if not running: if not running:
_shared_state['monitor_enabled'] = False _shared_state['monitor_enabled'] = False
gen = _capture_generation
if not running: if not running:
_clear_shared_audio_queue() _clear_shared_audio_queue()
return gen
def _set_shared_monitor( def _set_shared_monitor(
@@ -96,16 +120,20 @@ def _set_shared_monitor(
squelch: int | None = None, squelch: int | None = None,
) -> None: ) -> None:
was_enabled = False was_enabled = False
freq_changed = False
with _shared_state_lock: with _shared_state_lock:
was_enabled = bool(_shared_state.get('monitor_enabled')) was_enabled = bool(_shared_state.get('monitor_enabled'))
_shared_state['monitor_enabled'] = bool(enabled) _shared_state['monitor_enabled'] = bool(enabled)
if frequency_mhz is not None: if frequency_mhz is not None:
old_freq = float(_shared_state.get('monitor_freq_mhz', 0.0) or 0.0)
_shared_state['monitor_freq_mhz'] = float(frequency_mhz) _shared_state['monitor_freq_mhz'] = float(frequency_mhz)
if abs(float(frequency_mhz) - old_freq) > 1e-6:
freq_changed = True
if modulation is not None: if modulation is not None:
_shared_state['monitor_modulation'] = str(modulation).lower().strip() _shared_state['monitor_modulation'] = str(modulation).lower().strip()
if squelch is not None: if squelch is not None:
_shared_state['monitor_squelch'] = max(0, min(100, int(squelch))) _shared_state['monitor_squelch'] = max(0, min(100, int(squelch)))
if was_enabled and not enabled: if (was_enabled and not enabled) or (enabled and freq_changed):
_clear_shared_audio_queue() _clear_shared_audio_queue()
@@ -187,18 +215,21 @@ def _demodulate_monitor_audio(
monitor_freq_mhz: float, monitor_freq_mhz: float,
modulation: str, modulation: str,
squelch: int, squelch: int,
) -> bytes | None: rotator_phase: float = 0.0,
) -> tuple[bytes | None, float]:
if samples.size < 32 or sample_rate <= 0: if samples.size < 32 or sample_rate <= 0:
return None return None, float(rotator_phase)
fs = float(sample_rate) fs = float(sample_rate)
freq_offset_hz = (float(monitor_freq_mhz) - float(center_mhz)) * 1e6 freq_offset_hz = (float(monitor_freq_mhz) - float(center_mhz)) * 1e6
nyquist = fs * 0.5 nyquist = fs * 0.5
if abs(freq_offset_hz) > nyquist * 0.98: if abs(freq_offset_hz) > nyquist * 0.98:
return None return None, float(rotator_phase)
n = np.arange(samples.size, dtype=np.float32) phase_inc = (2.0 * np.pi * freq_offset_hz) / fs
rotator = np.exp(-1j * (2.0 * np.pi * freq_offset_hz / fs) * n) n = np.arange(samples.size, dtype=np.float64)
rotator = np.exp(-1j * (float(rotator_phase) + phase_inc * n)).astype(np.complex64)
next_phase = float((float(rotator_phase) + phase_inc * samples.size) % (2.0 * np.pi))
shifted = samples * rotator shifted = samples * rotator
mod = str(modulation or 'wfm').lower().strip() mod = str(modulation or 'wfm').lower().strip()
@@ -207,11 +238,11 @@ def _demodulate_monitor_audio(
if pre_decim > 1: if pre_decim > 1:
usable = (shifted.size // pre_decim) * pre_decim usable = (shifted.size // pre_decim) * pre_decim
if usable < pre_decim: if usable < pre_decim:
return None return None, next_phase
shifted = shifted[:usable].reshape(-1, pre_decim).mean(axis=1) shifted = shifted[:usable].reshape(-1, pre_decim).mean(axis=1)
fs1 = fs / pre_decim fs1 = fs / pre_decim
if shifted.size < 16: if shifted.size < 16:
return None return None, next_phase
if mod in ('wfm', 'fm'): if mod in ('wfm', 'fm'):
audio = np.angle(shifted[1:] * np.conj(shifted[:-1])).astype(np.float32) audio = np.angle(shifted[1:] * np.conj(shifted[:-1])).astype(np.float32)
@@ -226,7 +257,7 @@ def _demodulate_monitor_audio(
audio = np.real(shifted).astype(np.float32) audio = np.real(shifted).astype(np.float32)
if audio.size < 8: if audio.size < 8:
return None return None, next_phase
audio = audio - float(np.mean(audio)) audio = audio - float(np.mean(audio))
@@ -238,7 +269,7 @@ def _demodulate_monitor_audio(
out_len = int(audio.size * AUDIO_SAMPLE_RATE / fs1) out_len = int(audio.size * AUDIO_SAMPLE_RATE / fs1)
if out_len < 32: if out_len < 32:
return None return None, next_phase
x_old = np.linspace(0.0, 1.0, audio.size, endpoint=False, dtype=np.float32) x_old = np.linspace(0.0, 1.0, audio.size, endpoint=False, dtype=np.float32)
x_new = np.linspace(0.0, 1.0, out_len, endpoint=False, dtype=np.float32) x_new = np.linspace(0.0, 1.0, out_len, endpoint=False, dtype=np.float32)
audio = np.interp(x_new, x_old, audio).astype(np.float32) audio = np.interp(x_new, x_old, audio).astype(np.float32)
@@ -253,7 +284,7 @@ def _demodulate_monitor_audio(
audio = audio * min(20.0, 0.85 / peak) audio = audio * min(20.0, 0.85 / peak)
pcm = np.clip(audio, -1.0, 1.0) pcm = np.clip(audio, -1.0, 1.0)
return (pcm * 32767.0).astype(np.int16).tobytes() return (pcm * 32767.0).astype(np.int16).tobytes(), next_phase
def _parse_center_freq_mhz(payload: dict[str, Any]) -> float: def _parse_center_freq_mhz(payload: dict[str, Any]) -> float:
@@ -336,6 +367,8 @@ def init_waterfall_websocket(app: Flask):
reader_thread = None reader_thread = None
stop_event = threading.Event() stop_event = threading.Event()
claimed_device = None claimed_device = None
claimed_sdr_type = 'rtlsdr'
my_generation = None # tracks which capture generation this handler owns
capture_center_mhz = 0.0 capture_center_mhz = 0.0
capture_start_freq = 0.0 capture_start_freq = 0.0
capture_end_freq = 0.0 capture_end_freq = 0.0
@@ -384,6 +417,10 @@ def init_waterfall_websocket(app: Flask):
cmd = data.get('cmd') cmd = data.get('cmd')
if cmd == 'start': if cmd == 'start':
shared_before = get_shared_capture_status()
keep_monitor_enabled = bool(shared_before.get('monitor_enabled'))
keep_monitor_modulation = str(shared_before.get('monitor_modulation', 'wfm'))
keep_monitor_squelch = int(shared_before.get('monitor_squelch', 0) or 0)
# Stop any existing capture # Stop any existing capture
was_restarting = iq_process is not None was_restarting = iq_process is not None
stop_event.set() stop_event.set()
@@ -394,9 +431,11 @@ def init_waterfall_websocket(app: Flask):
unregister_process(iq_process) unregister_process(iq_process)
iq_process = None iq_process = None
if claimed_device is not None: if claimed_device is not None:
app_module.release_sdr_device(claimed_device) app_module.release_sdr_device(claimed_device, claimed_sdr_type)
claimed_device = None claimed_device = None
_set_shared_capture_state(running=False) claimed_sdr_type = 'rtlsdr'
_set_shared_capture_state(running=False, generation=my_generation)
my_generation = None
stop_event.clear() stop_event.clear()
# Flush stale frames from previous capture # Flush stale frames from previous capture
while not send_queue.empty(): while not send_queue.empty():
@@ -411,6 +450,12 @@ def init_waterfall_websocket(app: Flask):
# Parse config # Parse config
try: try:
center_freq_mhz = _parse_center_freq_mhz(data) center_freq_mhz = _parse_center_freq_mhz(data)
requested_vfo_mhz = float(
data.get(
'vfo_freq_mhz',
data.get('frequency_mhz', center_freq_mhz),
)
)
span_mhz = _parse_span_mhz(data) span_mhz = _parse_span_mhz(data)
gain_raw = data.get('gain') gain_raw = data.get('gain')
if gain_raw is None or str(gain_raw).lower() == 'auto': if gain_raw is None or str(gain_raw).lower() == 'auto':
@@ -461,9 +506,20 @@ def init_waterfall_websocket(app: Flask):
effective_span_mhz = sample_rate / 1e6 effective_span_mhz = sample_rate / 1e6
start_freq = center_freq_mhz - effective_span_mhz / 2 start_freq = center_freq_mhz - effective_span_mhz / 2
end_freq = center_freq_mhz + effective_span_mhz / 2 end_freq = center_freq_mhz + effective_span_mhz / 2
target_vfo_mhz = requested_vfo_mhz
if not (start_freq <= target_vfo_mhz <= end_freq):
target_vfo_mhz = center_freq_mhz
# Claim the device # Claim the device (retry when restarting to allow
claim_err = app_module.claim_sdr_device(device_index, 'waterfall') # the kernel time to release the USB handle).
max_claim_attempts = 4 if was_restarting else 1
claim_err = None
for _claim_attempt in range(max_claim_attempts):
claim_err = app_module.claim_sdr_device(device_index, 'waterfall', sdr_type_str)
if not claim_err:
break
if _claim_attempt < max_claim_attempts - 1:
time.sleep(0.4)
if claim_err: if claim_err:
ws.send(json.dumps({ ws.send(json.dumps({
'status': 'error', 'status': 'error',
@@ -472,6 +528,7 @@ def init_waterfall_websocket(app: Flask):
})) }))
continue continue
claimed_device = device_index claimed_device = device_index
claimed_sdr_type = sdr_type_str
# Build I/Q capture command # Build I/Q capture command
try: try:
@@ -485,14 +542,26 @@ def init_waterfall_websocket(app: Flask):
bias_t=bias_t, bias_t=bias_t,
) )
except NotImplementedError as e: except NotImplementedError as e:
app_module.release_sdr_device(device_index) app_module.release_sdr_device(device_index, sdr_type_str)
claimed_device = None claimed_device = None
claimed_sdr_type = 'rtlsdr'
ws.send(json.dumps({ ws.send(json.dumps({
'status': 'error', 'status': 'error',
'message': str(e), 'message': str(e),
})) }))
continue continue
# Pre-flight: check the capture binary exists
if not shutil.which(iq_cmd[0]):
app_module.release_sdr_device(device_index, sdr_type_str)
claimed_device = None
claimed_sdr_type = 'rtlsdr'
ws.send(json.dumps({
'status': 'error',
'message': f'Required tool "{iq_cmd[0]}" not found. Install SoapySDR tools (rx_sdr).',
}))
continue
# Spawn I/Q capture process (retry to handle USB release lag) # Spawn I/Q capture process (retry to handle USB release lag)
max_attempts = 3 if was_restarting else 1 max_attempts = 3 if was_restarting else 1
try: try:
@@ -505,7 +574,7 @@ def init_waterfall_websocket(app: Flask):
iq_process = subprocess.Popen( iq_process = subprocess.Popen(
iq_cmd, iq_cmd,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, stderr=subprocess.PIPE,
bufsize=0, bufsize=0,
) )
register_process(iq_process) register_process(iq_process)
@@ -513,17 +582,23 @@ def init_waterfall_websocket(app: Flask):
# Brief check that process started # Brief check that process started
time.sleep(0.3) time.sleep(0.3)
if iq_process.poll() is not None: 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) unregister_process(iq_process)
iq_process = None iq_process = None
if attempt < max_attempts - 1: if attempt < max_attempts - 1:
logger.info( logger.info(
f"I/Q process exited immediately, " f"I/Q process exited immediately, "
f"retrying ({attempt + 1}/{max_attempts})..." f"retrying ({attempt + 1}/{max_attempts})..."
+ (f" stderr: {stderr_out}" if stderr_out else "")
) )
time.sleep(0.5) time.sleep(0.5)
continue continue
detail = f": {stderr_out}" if stderr_out else ""
raise RuntimeError( raise RuntimeError(
"I/Q capture process exited immediately" f"I/Q capture process exited immediately{detail}"
) )
break # Process started successfully break # Process started successfully
except Exception as e: except Exception as e:
@@ -532,8 +607,9 @@ def init_waterfall_websocket(app: Flask):
safe_terminate(iq_process) safe_terminate(iq_process)
unregister_process(iq_process) unregister_process(iq_process)
iq_process = None iq_process = None
app_module.release_sdr_device(device_index) app_module.release_sdr_device(device_index, sdr_type_str)
claimed_device = None claimed_device = None
claimed_sdr_type = 'rtlsdr'
ws.send(json.dumps({ ws.send(json.dumps({
'status': 'error', 'status': 'error',
'message': f'Failed to start I/Q capture: {e}', 'message': f'Failed to start I/Q capture: {e}',
@@ -545,7 +621,7 @@ def init_waterfall_websocket(app: Flask):
capture_end_freq = end_freq capture_end_freq = end_freq
capture_span_mhz = effective_span_mhz capture_span_mhz = effective_span_mhz
_set_shared_capture_state( my_generation = _set_shared_capture_state(
running=True, running=True,
device=device_index, device=device_index,
center_mhz=center_freq_mhz, center_mhz=center_freq_mhz,
@@ -553,10 +629,10 @@ def init_waterfall_websocket(app: Flask):
sample_rate=sample_rate, sample_rate=sample_rate,
) )
_set_shared_monitor( _set_shared_monitor(
enabled=False, enabled=keep_monitor_enabled,
frequency_mhz=center_freq_mhz, frequency_mhz=target_vfo_mhz,
modulation='wfm', modulation=keep_monitor_modulation,
squelch=0, squelch=keep_monitor_squelch,
) )
# Send started confirmation # Send started confirmation
@@ -570,7 +646,7 @@ def init_waterfall_websocket(app: Flask):
'effective_span_mhz': effective_span_mhz, 'effective_span_mhz': effective_span_mhz,
'db_min': db_min, 'db_min': db_min,
'db_max': db_max, 'db_max': db_max,
'vfo_freq_mhz': center_freq_mhz, 'vfo_freq_mhz': target_vfo_mhz,
})) }))
# Start reader thread — puts frames on queue, never calls ws.send() # Start reader thread — puts frames on queue, never calls ws.send()
@@ -585,6 +661,8 @@ def init_waterfall_websocket(app: Flask):
timeslice_samples = max(required_fft_samples, int(_sample_rate / max(1, _fps))) timeslice_samples = max(required_fft_samples, int(_sample_rate / max(1, _fps)))
bytes_per_frame = timeslice_samples * 2 bytes_per_frame = timeslice_samples * 2
frame_interval = 1.0 / _fps frame_interval = 1.0 / _fps
monitor_rotator_phase = 0.0
last_monitor_offset_hz = None
try: try:
while not stop_evt.is_set(): while not stop_evt.is_set():
@@ -629,16 +707,30 @@ def init_waterfall_websocket(app: Flask):
monitor_cfg = _snapshot_monitor_config() monitor_cfg = _snapshot_monitor_config()
if monitor_cfg: if monitor_cfg:
audio_chunk = _demodulate_monitor_audio( center_mhz_cfg = float(monitor_cfg.get('center_mhz', _center_mhz))
monitor_mhz_cfg = float(monitor_cfg.get('monitor_freq_mhz', _center_mhz))
offset_hz = (monitor_mhz_cfg - center_mhz_cfg) * 1e6
if (
last_monitor_offset_hz is None
or abs(offset_hz - last_monitor_offset_hz) > 1.0
):
monitor_rotator_phase = 0.0
last_monitor_offset_hz = offset_hz
audio_chunk, monitor_rotator_phase = _demodulate_monitor_audio(
samples=samples, samples=samples,
sample_rate=_sample_rate, sample_rate=_sample_rate,
center_mhz=monitor_cfg.get('center_mhz', _center_mhz), center_mhz=center_mhz_cfg,
monitor_freq_mhz=monitor_cfg.get('monitor_freq_mhz', _center_mhz), monitor_freq_mhz=monitor_mhz_cfg,
modulation=monitor_cfg.get('modulation', 'wfm'), modulation=monitor_cfg.get('modulation', 'wfm'),
squelch=int(monitor_cfg.get('squelch', 0)), squelch=int(monitor_cfg.get('squelch', 0)),
rotator_phase=monitor_rotator_phase,
) )
if audio_chunk: if audio_chunk:
_push_shared_audio_chunk(audio_chunk) _push_shared_audio_chunk(audio_chunk)
else:
monitor_rotator_phase = 0.0
last_monitor_offset_hz = None
# Pace to target FPS # Pace to target FPS
elapsed = time.monotonic() - frame_start elapsed = time.monotonic() - frame_start
@@ -720,16 +812,19 @@ def init_waterfall_websocket(app: Flask):
unregister_process(iq_process) unregister_process(iq_process)
iq_process = None iq_process = None
if claimed_device is not None: if claimed_device is not None:
app_module.release_sdr_device(claimed_device) app_module.release_sdr_device(claimed_device, claimed_sdr_type)
claimed_device = None claimed_device = None
_set_shared_capture_state(running=False) claimed_sdr_type = 'rtlsdr'
_set_shared_capture_state(running=False, generation=my_generation)
my_generation = None
stop_event.clear() stop_event.clear()
ws.send(json.dumps({'status': 'stopped'})) ws.send(json.dumps({'status': 'stopped'}))
except Exception as e: except Exception as e:
logger.info(f"WebSocket waterfall closed: {e}") logger.info(f"WebSocket waterfall closed: {e}")
finally: finally:
# Cleanup # Cleanup — use generation guard so a stale handler cannot
# clobber shared state owned by a newer WS connection.
stop_event.set() stop_event.set()
if reader_thread and reader_thread.is_alive(): if reader_thread and reader_thread.is_alive():
reader_thread.join(timeout=2) reader_thread.join(timeout=2)
@@ -737,8 +832,8 @@ def init_waterfall_websocket(app: Flask):
safe_terminate(iq_process) safe_terminate(iq_process)
unregister_process(iq_process) unregister_process(iq_process)
if claimed_device is not None: if claimed_device is not None:
app_module.release_sdr_device(claimed_device) app_module.release_sdr_device(claimed_device, claimed_sdr_type)
_set_shared_capture_state(running=False) _set_shared_capture_state(running=False, generation=my_generation)
# Complete WebSocket close handshake, then shut down the # Complete WebSocket close handshake, then shut down the
# raw socket so Werkzeug cannot write its HTTP 200 response # raw socket so Werkzeug cannot write its HTTP 200 response
# on top of the WebSocket stream (which browsers see as # on top of the WebSocket stream (which browsers see as
+72 -28
View File
@@ -12,12 +12,13 @@ from flask import Blueprint, jsonify, request, Response, send_file
from utils.logging import get_logger from utils.logging import get_logger
from utils.sse import sse_stream from utils.sse import sse_stream
from utils.validation import validate_device_index, validate_gain, validate_latitude, validate_longitude, validate_elevation from utils.validation import validate_device_index, validate_gain, validate_latitude, validate_longitude, validate_elevation, validate_rtl_tcp_host, validate_rtl_tcp_port
from utils.weather_sat import ( from utils.weather_sat import (
get_weather_sat_decoder, get_weather_sat_decoder,
is_weather_sat_available, is_weather_sat_available,
CaptureProgress, CaptureProgress,
WEATHER_SATELLITES, WEATHER_SATELLITES,
DEFAULT_SAMPLE_RATE,
) )
logger = get_logger('intercept.weather_sat') logger = get_logger('intercept.weather_sat')
@@ -40,6 +41,35 @@ def _progress_callback(progress: CaptureProgress) -> None:
pass pass
def _release_weather_sat_device(device_index: int) -> None:
"""Release an SDR device only if weather-sat currently owns it."""
if device_index < 0:
return
try:
import app as app_module
except ImportError:
return
owner = None
get_status = getattr(app_module, 'get_sdr_device_status', None)
if callable(get_status):
try:
owner = get_status().get(device_index)
except Exception:
owner = None
if owner and owner != 'weather_sat':
logger.debug(
'Skipping SDR release for device %s owned by %s',
device_index,
owner,
)
return
app_module.release_sdr_device(device_index)
@weather_sat_bp.route('/status') @weather_sat_bp.route('/status')
def get_status(): def get_status():
"""Get weather satellite decoder status. """Get weather satellite decoder status.
@@ -106,6 +136,13 @@ def start_capture():
}) })
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
sdr_type_str = data.get('sdr_type', 'rtlsdr')
if sdr_type_str != 'rtlsdr':
return jsonify({
'status': 'error',
'message': f'{sdr_type_str.replace("_", " ").title()} is not yet supported for this mode. Please use an RTL-SDR device.'
}), 400
# Validate satellite # Validate satellite
satellite = data.get('satellite') satellite = data.get('satellite')
@@ -128,18 +165,30 @@ def start_capture():
bias_t = bool(data.get('bias_t', False)) bias_t = bool(data.get('bias_t', False))
# Claim SDR device # Check for rtl_tcp (remote SDR) connection
try: rtl_tcp_host = data.get('rtl_tcp_host')
import app as app_module rtl_tcp_port = data.get('rtl_tcp_port', 1234)
error = app_module.claim_sdr_device(device_index, 'weather_sat')
if error: if rtl_tcp_host:
return jsonify({ try:
'status': 'error', rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
'error_type': 'DEVICE_BUSY', rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
'message': error, except ValueError as e:
}), 409 return jsonify({'status': 'error', 'message': str(e)}), 400
except ImportError:
pass # Claim SDR device (skip for remote rtl_tcp)
if not rtl_tcp_host:
try:
import app as app_module
error = app_module.claim_sdr_device(device_index, 'weather_sat', sdr_type_str)
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error,
}), 409
except ImportError:
pass
# Clear queue # Clear queue
while not _weather_sat_queue.empty(): while not _weather_sat_queue.empty():
@@ -152,19 +201,19 @@ def start_capture():
decoder.set_callback(_progress_callback) decoder.set_callback(_progress_callback)
def _release_device(): def _release_device():
try: if not rtl_tcp_host:
import app as app_module _release_weather_sat_device(device_index)
app_module.release_sdr_device(device_index)
except ImportError:
pass
decoder.set_on_complete(_release_device) decoder.set_on_complete(_release_device)
success = decoder.start( success, error_msg = decoder.start(
satellite=satellite, satellite=satellite,
device_index=device_index, device_index=device_index,
gain=gain, gain=gain,
sample_rate=DEFAULT_SAMPLE_RATE,
bias_t=bias_t, bias_t=bias_t,
rtl_tcp_host=rtl_tcp_host,
rtl_tcp_port=rtl_tcp_port,
) )
if success: if success:
@@ -181,7 +230,7 @@ def start_capture():
_release_device() _release_device()
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'message': 'Failed to start capture' 'message': error_msg or 'Failed to start capture'
}), 500 }), 500
@@ -283,7 +332,7 @@ def test_decode():
decoder.set_callback(_progress_callback) decoder.set_callback(_progress_callback)
decoder.set_on_complete(None) decoder.set_on_complete(None)
success = decoder.start_from_file( success, error_msg = decoder.start_from_file(
satellite=satellite, satellite=satellite,
input_file=input_file, input_file=input_file,
sample_rate=sample_rate, sample_rate=sample_rate,
@@ -302,7 +351,7 @@ def test_decode():
else: else:
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'message': 'Failed to start file decode' 'message': error_msg or 'Failed to start file decode'
}), 500 }), 500
@@ -318,12 +367,7 @@ def stop_capture():
decoder.stop() decoder.stop()
# Release SDR device _release_weather_sat_device(device_index)
try:
import app as app_module
app_module.release_sdr_device(device_index)
except ImportError:
pass
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
+518
View File
@@ -0,0 +1,518 @@
"""WeFax (Weather Fax) decoder routes.
Provides endpoints for decoding HF weather fax transmissions from
maritime/aviation weather services worldwide.
"""
from __future__ import annotations
import queue
from flask import Blueprint, Response, jsonify, request, send_file
import app as app_module
from utils.logging import get_logger
from utils.sdr import SDRType
from utils.sse import sse_stream_fanout
from utils.validation import validate_frequency
from utils.wefax import get_wefax_decoder
from utils.wefax_stations import (
WEFAX_USB_ALIGNMENT_OFFSET_KHZ,
get_current_broadcasts,
get_station,
load_stations,
resolve_tuning_frequency_khz,
)
logger = get_logger('intercept.wefax')
wefax_bp = Blueprint('wefax', __name__, url_prefix='/wefax')
# SSE progress queue
_wefax_queue: queue.Queue = queue.Queue(maxsize=100)
# Track active SDR device
wefax_active_device: int | None = None
wefax_active_sdr_type: str | None = None
def _progress_callback(data: dict) -> None:
"""Callback to queue progress updates for SSE stream."""
global wefax_active_device, wefax_active_sdr_type
try:
_wefax_queue.put_nowait(data)
except queue.Full:
try:
_wefax_queue.get_nowait()
_wefax_queue.put_nowait(data)
except queue.Empty:
pass
# Ensure manually claimed SDR devices are always released when a
# decode session ends on its own (complete/error/stopped).
if (
isinstance(data, dict)
and data.get('type') == 'wefax_progress'
and data.get('status') in ('complete', 'error', 'stopped')
and wefax_active_device is not None
):
app_module.release_sdr_device(wefax_active_device, wefax_active_sdr_type or 'rtlsdr')
wefax_active_device = None
wefax_active_sdr_type = None
@wefax_bp.route('/status')
def get_status():
"""Get WeFax decoder status."""
decoder = get_wefax_decoder()
return jsonify({
'available': True,
'running': decoder.is_running,
'image_count': len(decoder.get_images()),
})
@wefax_bp.route('/start', methods=['POST'])
def start_decoder():
"""Start WeFax decoder.
JSON body:
{
"frequency_khz": 4298,
"station": "NOJ",
"device": 0,
"gain": 40,
"ioc": 576,
"lpm": 120,
"direct_sampling": true,
"frequency_reference": "auto" // auto, carrier, or dial
}
"""
decoder = get_wefax_decoder()
if decoder.is_running:
return jsonify({
'status': 'already_running',
'message': 'WeFax decoder is already running',
})
# Clear queue
while not _wefax_queue.empty():
try:
_wefax_queue.get_nowait()
except queue.Empty:
break
data = request.get_json(silent=True) or {}
# Validate frequency (required)
frequency_khz = data.get('frequency_khz')
if frequency_khz is None:
return jsonify({
'status': 'error',
'message': 'frequency_khz is required',
}), 400
try:
frequency_khz = float(frequency_khz)
# WeFax operates on HF: 2-30 MHz (2000-30000 kHz)
freq_mhz = frequency_khz / 1000.0
validate_frequency(freq_mhz, min_mhz=2.0, max_mhz=30.0)
except (TypeError, ValueError) as e:
return jsonify({
'status': 'error',
'message': f'Invalid frequency: {e}',
}), 400
station = str(data.get('station', '')).strip()
device_index = data.get('device', 0)
gain = float(data.get('gain', 40.0))
ioc = int(data.get('ioc', 576))
lpm = int(data.get('lpm', 120))
direct_sampling = bool(data.get('direct_sampling', True))
frequency_reference = str(data.get('frequency_reference', 'auto')).strip().lower()
sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower()
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
if not frequency_reference:
frequency_reference = 'auto'
try:
tuned_frequency_khz, resolved_reference, usb_offset_applied = (
resolve_tuning_frequency_khz(
listed_frequency_khz=frequency_khz,
station_callsign=station,
frequency_reference=frequency_reference,
)
)
tuned_mhz = tuned_frequency_khz / 1000.0
validate_frequency(tuned_mhz, min_mhz=2.0, max_mhz=30.0)
except ValueError as e:
return jsonify({
'status': 'error',
'message': f'Invalid frequency settings: {e}',
}), 400
# Validate IOC and LPM
if ioc not in (288, 576):
return jsonify({
'status': 'error',
'message': 'IOC must be 288 or 576',
}), 400
if lpm not in (60, 120):
return jsonify({
'status': 'error',
'message': 'LPM must be 60 or 120',
}), 400
# Claim SDR device
global wefax_active_device, wefax_active_sdr_type
device_int = int(device_index)
error = app_module.claim_sdr_device(device_int, 'wefax', sdr_type_str)
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error,
}), 409
# Set callback and start
decoder.set_callback(_progress_callback)
success = decoder.start(
frequency_khz=tuned_frequency_khz,
station=station,
device_index=device_int,
gain=gain,
ioc=ioc,
lpm=lpm,
direct_sampling=direct_sampling,
sdr_type=sdr_type_str,
)
if success:
wefax_active_device = device_int
wefax_active_sdr_type = sdr_type_str
return jsonify({
'status': 'started',
'frequency_khz': frequency_khz,
'tuned_frequency_khz': tuned_frequency_khz,
'frequency_reference': resolved_reference,
'usb_offset_applied': usb_offset_applied,
'usb_offset_khz': (
WEFAX_USB_ALIGNMENT_OFFSET_KHZ if usb_offset_applied else 0.0
),
'station': station,
'ioc': ioc,
'lpm': lpm,
'device': device_int,
})
else:
app_module.release_sdr_device(device_int, sdr_type_str)
return jsonify({
'status': 'error',
'message': 'Failed to start decoder',
}), 500
@wefax_bp.route('/stop', methods=['POST'])
def stop_decoder():
"""Stop WeFax decoder."""
global wefax_active_device, wefax_active_sdr_type
decoder = get_wefax_decoder()
decoder.stop()
if wefax_active_device is not None:
app_module.release_sdr_device(wefax_active_device, wefax_active_sdr_type or 'rtlsdr')
wefax_active_device = None
wefax_active_sdr_type = None
return jsonify({'status': 'stopped'})
@wefax_bp.route('/stream')
def stream_progress():
"""SSE stream of WeFax decode progress."""
response = Response(
sse_stream_fanout(
source_queue=_wefax_queue,
channel_key='wefax',
timeout=1.0,
keepalive_interval=30.0,
),
mimetype='text/event-stream',
)
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response
@wefax_bp.route('/images')
def list_images():
"""Get list of decoded WeFax images."""
decoder = get_wefax_decoder()
images = decoder.get_images()
limit = request.args.get('limit', type=int)
if limit and limit > 0:
images = images[-limit:]
return jsonify({
'status': 'ok',
'images': [img.to_dict() for img in images],
'count': len(images),
})
@wefax_bp.route('/images/<filename>')
def get_image(filename: str):
"""Get a decoded WeFax image file."""
decoder = get_wefax_decoder()
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
if not filename.endswith('.png'):
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
image_path = decoder._output_dir / filename
if not image_path.exists():
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
return send_file(image_path, mimetype='image/png')
@wefax_bp.route('/images/<filename>', methods=['DELETE'])
def delete_image(filename: str):
"""Delete a decoded WeFax image."""
decoder = get_wefax_decoder()
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
if not filename.endswith('.png'):
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
if decoder.delete_image(filename):
return jsonify({'status': 'ok'})
else:
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
@wefax_bp.route('/images', methods=['DELETE'])
def delete_all_images():
"""Delete all decoded WeFax images."""
decoder = get_wefax_decoder()
count = decoder.delete_all_images()
return jsonify({'status': 'ok', 'deleted': count})
# ========================
# Auto-Scheduler Endpoints
# ========================
def _scheduler_event_callback(event: dict) -> None:
"""Forward scheduler events to the SSE queue."""
try:
_wefax_queue.put_nowait(event)
except queue.Full:
try:
_wefax_queue.get_nowait()
_wefax_queue.put_nowait(event)
except queue.Empty:
pass
@wefax_bp.route('/schedule/enable', methods=['POST'])
def enable_schedule():
"""Enable auto-scheduling of WeFax broadcast captures.
JSON body:
{
"station": "NOJ",
"frequency_khz": 4298,
"device": 0,
"gain": 40,
"ioc": 576,
"lpm": 120,
"direct_sampling": true,
"frequency_reference": "auto" // auto, carrier, or dial
}
Returns:
JSON with scheduler status.
"""
from utils.wefax_scheduler import get_wefax_scheduler
data = request.get_json(silent=True) or {}
station = str(data.get('station', '')).strip()
if not station:
return jsonify({
'status': 'error',
'message': 'station is required',
}), 400
frequency_khz = data.get('frequency_khz')
if frequency_khz is None:
return jsonify({
'status': 'error',
'message': 'frequency_khz is required',
}), 400
try:
frequency_khz = float(frequency_khz)
freq_mhz = frequency_khz / 1000.0
validate_frequency(freq_mhz, min_mhz=2.0, max_mhz=30.0)
except (TypeError, ValueError) as e:
return jsonify({
'status': 'error',
'message': f'Invalid frequency: {e}',
}), 400
device = int(data.get('device', 0))
gain = float(data.get('gain', 40.0))
ioc = int(data.get('ioc', 576))
lpm = int(data.get('lpm', 120))
direct_sampling = bool(data.get('direct_sampling', True))
frequency_reference = str(data.get('frequency_reference', 'auto')).strip().lower()
if not frequency_reference:
frequency_reference = 'auto'
try:
tuned_frequency_khz, resolved_reference, usb_offset_applied = (
resolve_tuning_frequency_khz(
listed_frequency_khz=frequency_khz,
station_callsign=station,
frequency_reference=frequency_reference,
)
)
tuned_mhz = tuned_frequency_khz / 1000.0
validate_frequency(tuned_mhz, min_mhz=2.0, max_mhz=30.0)
except ValueError as e:
return jsonify({
'status': 'error',
'message': f'Invalid frequency settings: {e}',
}), 400
scheduler = get_wefax_scheduler()
scheduler.set_callbacks(_progress_callback, _scheduler_event_callback)
try:
result = scheduler.enable(
station=station,
frequency_khz=tuned_frequency_khz,
device=device,
gain=gain,
ioc=ioc,
lpm=lpm,
direct_sampling=direct_sampling,
)
except Exception:
logger.exception("Failed to enable WeFax scheduler")
return jsonify({
'status': 'error',
'message': 'Failed to enable scheduler',
}), 500
return jsonify({
'status': 'ok',
**result,
'frequency_khz': frequency_khz,
'tuned_frequency_khz': tuned_frequency_khz,
'frequency_reference': resolved_reference,
'usb_offset_applied': usb_offset_applied,
'usb_offset_khz': (
WEFAX_USB_ALIGNMENT_OFFSET_KHZ if usb_offset_applied else 0.0
),
})
@wefax_bp.route('/schedule/disable', methods=['POST'])
def disable_schedule():
"""Disable auto-scheduling."""
from utils.wefax_scheduler import get_wefax_scheduler
scheduler = get_wefax_scheduler()
result = scheduler.disable()
return jsonify(result)
@wefax_bp.route('/schedule/status')
def schedule_status():
"""Get current scheduler state."""
from utils.wefax_scheduler import get_wefax_scheduler
scheduler = get_wefax_scheduler()
return jsonify(scheduler.get_status())
@wefax_bp.route('/schedule/broadcasts')
def schedule_broadcasts():
"""List scheduled broadcasts."""
from utils.wefax_scheduler import get_wefax_scheduler
scheduler = get_wefax_scheduler()
broadcasts = scheduler.get_broadcasts()
return jsonify({
'status': 'ok',
'broadcasts': broadcasts,
'count': len(broadcasts),
})
@wefax_bp.route('/schedule/skip/<broadcast_id>', methods=['POST'])
def skip_broadcast(broadcast_id: str):
"""Skip a scheduled broadcast."""
from utils.wefax_scheduler import get_wefax_scheduler
if not broadcast_id.replace('_', '').replace('-', '').isalnum():
return jsonify({
'status': 'error',
'message': 'Invalid broadcast ID',
}), 400
scheduler = get_wefax_scheduler()
if scheduler.skip_broadcast(broadcast_id):
return jsonify({'status': 'skipped', 'broadcast_id': broadcast_id})
else:
return jsonify({
'status': 'error',
'message': 'Broadcast not found or already processed',
}), 404
@wefax_bp.route('/stations')
def list_stations():
"""Get all WeFax stations from the database."""
stations = load_stations()
return jsonify({
'status': 'ok',
'stations': stations,
'count': len(stations),
})
@wefax_bp.route('/stations/<callsign>')
def station_detail(callsign: str):
"""Get station detail including current schedule info."""
station = get_station(callsign)
if not station:
return jsonify({
'status': 'error',
'message': f'Station {callsign} not found',
}), 404
current = get_current_broadcasts(callsign)
return jsonify({
'status': 'ok',
'station': station,
'current_broadcasts': current,
})
+1726 -472
View File
File diff suppressed because it is too large Load Diff
Executable
+202
View File
@@ -0,0 +1,202 @@
#!/usr/bin/env bash
# INTERCEPT - Production Startup Script
#
# Starts INTERCEPT with gunicorn + gevent for production use.
# Falls back to Flask dev server if gunicorn is not installed.
#
# Requires sudo for SDR, WiFi monitor mode, and Bluetooth access.
#
# Usage:
# sudo ./start.sh # Default: 0.0.0.0:5050
# sudo ./start.sh -p 8080 # Custom port
# sudo ./start.sh --https # HTTPS with self-signed cert
# sudo ./start.sh --debug # Debug mode (Flask dev server)
# sudo ./start.sh --check-deps # Check dependencies and exit
set -euo pipefail
# ── Resolve Python from venv or system ───────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# ── Load .env if present ──────────────────────────────────────────────────────
if [[ -f "$SCRIPT_DIR/.env" ]]; then
set -a
source "$SCRIPT_DIR/.env"
set +a
fi
if [[ -x "$SCRIPT_DIR/venv/bin/python" ]]; then
PYTHON="$SCRIPT_DIR/venv/bin/python"
elif [[ -n "${VIRTUAL_ENV:-}" && -x "$VIRTUAL_ENV/bin/python" ]]; then
PYTHON="$VIRTUAL_ENV/bin/python"
else
PYTHON="$(command -v python3 || command -v python)"
fi
# ── Defaults (can be overridden by env vars or CLI flags) ────────────────────
HOST="${INTERCEPT_HOST:-0.0.0.0}"
PORT="${INTERCEPT_PORT:-5050}"
DEBUG=0
HTTPS=0
CHECK_DEPS=0
# ── Parse CLI arguments ─────────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case "$1" in
-p|--port)
PORT="$2"
shift 2
;;
-H|--host)
HOST="$2"
shift 2
;;
-d|--debug)
DEBUG=1
shift
;;
--https)
HTTPS=1
shift
;;
--check-deps)
CHECK_DEPS=1
shift
;;
-h|--help)
echo "Usage: start.sh [OPTIONS]"
echo ""
echo "Options:"
echo " -p, --port PORT Port to listen on (default: 5050)"
echo " -H, --host HOST Host to bind to (default: 0.0.0.0)"
echo " -d, --debug Run in debug mode (Flask dev server)"
echo " --https Enable HTTPS with self-signed certificate"
echo " --check-deps Check dependencies and exit"
echo " -h, --help Show this help message"
exit 0
;;
*)
echo "Unknown option: $1" >&2
exit 1
;;
esac
done
# ── Export for config.py ─────────────────────────────────────────────────────
export INTERCEPT_HOST="$HOST"
export INTERCEPT_PORT="$PORT"
# ── Fix ownership of user data dirs when run via sudo ────────────────────────
# When invoked via sudo the server process runs as root, so every file it
# creates (configs, logs, database) ends up owned by root. On the *next*
# startup we fix that retroactively, and we also pre-create known runtime
# directories so they get correct ownership from the start.
if [[ "$(id -u)" -eq 0 && -n "${SUDO_USER:-}" ]]; then
# Pre-create directories that routes may need at runtime
mkdir -p "$SCRIPT_DIR/instance" \
"$SCRIPT_DIR/data/radiosonde/logs" \
"$SCRIPT_DIR/data/weather_sat"
for dir in instance data certs; do
if [[ -d "$SCRIPT_DIR/$dir" ]]; then
chown -R "$SUDO_USER" "$SCRIPT_DIR/$dir"
fi
done
# Export real user identity so Python can chown runtime-created files
export INTERCEPT_SUDO_UID="$(id -u "$SUDO_USER")"
export INTERCEPT_SUDO_GID="$(id -g "$SUDO_USER")"
fi
# ── Dependency check (delegate to intercept.py) ─────────────────────────────
if [[ "$CHECK_DEPS" -eq 1 ]]; then
exec "$PYTHON" intercept.py --check-deps
fi
# ── Debug mode always uses Flask dev server ──────────────────────────────────
if [[ "$DEBUG" -eq 1 ]]; then
echo "[INTERCEPT] Starting in debug mode (Flask dev server)..."
export INTERCEPT_DEBUG=1
exec "$PYTHON" intercept.py --host "$HOST" --port "$PORT" --debug
fi
# ── HTTPS certificate generation ────────────────────────────────────────────
CERT_DIR="certs"
CERT_FILE="$CERT_DIR/intercept.crt"
KEY_FILE="$CERT_DIR/intercept.key"
if [[ "$HTTPS" -eq 1 ]]; then
if [[ ! -f "$CERT_FILE" || ! -f "$KEY_FILE" ]]; then
echo "[INTERCEPT] Generating self-signed SSL certificate..."
mkdir -p "$CERT_DIR"
openssl req -x509 -newkey rsa:2048 \
-keyout "$KEY_FILE" -out "$CERT_FILE" \
-days 365 -nodes \
-subj '/CN=intercept/O=INTERCEPT/C=US' 2>/dev/null
echo "[INTERCEPT] SSL certificate generated: $CERT_FILE"
else
echo "[INTERCEPT] Using existing SSL certificate: $CERT_FILE"
fi
fi
# ── Detect gunicorn + gevent ─────────────────────────────────────────────────
HAS_GUNICORN=0
HAS_GEVENT=0
if "$PYTHON" -c "import gunicorn" 2>/dev/null; then
HAS_GUNICORN=1
fi
if "$PYTHON" -c "import gevent" 2>/dev/null; then
HAS_GEVENT=1
fi
# ── Resolve LAN address for display ──────────────────────────────────────────
if [[ "$HOST" == "0.0.0.0" ]]; then
LAN_IP=$(hostname -I 2>/dev/null | awk '{print $1}')
LAN_IP="${LAN_IP:-localhost}"
else
LAN_IP="$HOST"
fi
PROTO="http"
[[ "$HTTPS" -eq 1 ]] && PROTO="https"
# ── Start the server ─────────────────────────────────────────────────────────
if [[ "$HAS_GUNICORN" -eq 1 && "$HAS_GEVENT" -eq 1 ]]; then
echo "[INTERCEPT] Starting production server (gunicorn + gevent)..."
echo "[INTERCEPT] Listening on ${PROTO}://${LAN_IP}:${PORT}"
GUNICORN_ARGS=(
-c "$SCRIPT_DIR/gunicorn.conf.py"
-k gevent
-w 1
--timeout 300
--graceful-timeout 5
--worker-connections 1000
--bind "${HOST}:${PORT}"
--access-logfile -
--error-logfile -
)
if [[ "$HTTPS" -eq 1 ]]; then
GUNICORN_ARGS+=(--certfile "$CERT_FILE" --keyfile "$KEY_FILE")
echo "[INTERCEPT] HTTPS enabled"
fi
exec "$PYTHON" -m gunicorn "${GUNICORN_ARGS[@]}" app:app
else
if [[ "$HAS_GUNICORN" -eq 0 ]]; then
echo "[INTERCEPT] gunicorn not found — falling back to Flask dev server"
fi
if [[ "$HAS_GEVENT" -eq 0 ]]; then
echo "[INTERCEPT] gevent not found — falling back to Flask dev server"
fi
echo "[INTERCEPT] Install with: pip install gunicorn gevent"
echo ""
FLASK_ARGS=(--host "$HOST" --port "$PORT")
if [[ "$HTTPS" -eq 1 ]]; then
FLASK_ARGS+=(--https)
fi
exec "$PYTHON" intercept.py "${FLASK_ARGS[@]}"
fi
+9 -9
View File
@@ -414,7 +414,7 @@ body {
.acars-sidebar .acars-btn { .acars-sidebar .acars-btn {
background: var(--accent-green); background: var(--accent-green);
border: none; border: none;
color: #fff; color: var(--text-inverse);
padding: 6px 10px; padding: 6px 10px;
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
@@ -575,7 +575,7 @@ body {
.vdl2-sidebar .vdl2-btn { .vdl2-sidebar .vdl2-btn {
background: var(--accent-green); background: var(--accent-green);
border: none; border: none;
color: #fff; color: var(--text-inverse);
padding: 6px 10px; padding: 6px 10px;
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
@@ -1246,7 +1246,7 @@ body {
.control-group select { .control-group select {
padding: 4px 8px; padding: 4px 8px;
background: rgba(0, 0, 0, 0.3); background: var(--bg-dark);
border: 1px solid rgba(74, 158, 255, 0.3); border: 1px solid rgba(74, 158, 255, 0.3);
border-radius: 4px; border-radius: 4px;
color: var(--accent-cyan); color: var(--accent-cyan);
@@ -1347,7 +1347,7 @@ body {
padding: 6px 16px; padding: 6px 16px;
border: none; border: none;
background: var(--accent-green); background: var(--accent-green);
color: #fff; color: var(--text-inverse);
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
@@ -1365,7 +1365,7 @@ body {
.start-btn.active { .start-btn.active {
background: var(--accent-red); background: var(--accent-red);
color: #fff; color: var(--text-inverse);
} }
.start-btn.active:hover { .start-btn.active:hover {
@@ -1497,7 +1497,7 @@ body {
padding: 6px 12px; padding: 6px 12px;
background: var(--accent-green); background: var(--accent-green);
border: none; border: none;
color: #fff; color: var(--text-inverse);
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
font-size: 11px; font-size: 11px;
@@ -1518,7 +1518,7 @@ body {
.airband-btn.active { .airband-btn.active {
background: var(--accent-red); background: var(--accent-red);
color: #fff; color: var(--text-inverse);
} }
.airband-btn.active:hover { .airband-btn.active:hover {
@@ -1912,7 +1912,7 @@ body {
.strip-report-btn { .strip-report-btn {
background: linear-gradient(135deg, var(--accent-cyan) 0%, #2b6fb8 100%); background: linear-gradient(135deg, var(--accent-cyan) 0%, #2b6fb8 100%);
border: none; border: none;
color: white; color: var(--text-inverse);
padding: 8px 12px; padding: 8px 12px;
border-radius: 4px; border-radius: 4px;
font-size: 10px; font-size: 10px;
@@ -2224,7 +2224,7 @@ body {
.strip-btn.primary { .strip-btn.primary {
background: linear-gradient(135deg, var(--accent-cyan) 0%, #2b6fb8 100%); background: linear-gradient(135deg, var(--accent-cyan) 0%, #2b6fb8 100%);
border: none; border: none;
color: white; color: var(--text-inverse);
} }
.strip-btn.primary:hover { .strip-btn.primary:hover {
+90
View File
@@ -269,6 +269,21 @@ body {
min-width: 160px; min-width: 160px;
} }
.data-control-group {
min-width: 320px;
}
.data-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.data-actions input[type="date"] {
min-width: 150px;
}
.primary-btn { .primary-btn {
background: var(--accent-cyan); background: var(--accent-cyan);
border: none; border: none;
@@ -285,6 +300,31 @@ body {
box-shadow: 0 6px 14px rgba(74, 158, 255, 0.3); box-shadow: 0 6px 14px rgba(74, 158, 255, 0.3);
} }
.primary-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.warn-btn {
background: var(--accent-amber);
color: #0a0c10;
}
.warn-btn:hover {
box-shadow: 0 6px 14px rgba(214, 168, 94, 0.3);
}
.danger-btn {
background: #d84f63;
color: #f8fafc;
}
.danger-btn:hover {
box-shadow: 0 6px 14px rgba(216, 79, 99, 0.35);
}
.status-pill { .status-pill {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 11px; font-size: 11px;
@@ -296,6 +336,16 @@ body {
letter-spacing: 1px; letter-spacing: 1px;
} }
.status-pill.ok {
border-color: var(--accent-green);
color: var(--accent-green);
}
.status-pill.error {
border-color: #d84f63;
color: #d84f63;
}
.content-grid { .content-grid {
display: grid; display: grid;
grid-template-columns: minmax(300px, 1fr) minmax(320px, 1fr); grid-template-columns: minmax(300px, 1fr) minmax(320px, 1fr);
@@ -364,6 +414,37 @@ body {
background: rgba(74, 158, 255, 0.1); background: rgba(74, 158, 255, 0.1);
} }
.aircraft-row.military {
background: rgba(85, 107, 47, 0.12);
}
.aircraft-row.military:hover {
background: rgba(85, 107, 47, 0.22);
}
.mil-badge,
.civ-badge {
display: inline-block;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.8px;
padding: 2px 6px;
border-radius: 3px;
text-transform: uppercase;
}
.mil-badge {
background: rgba(85, 107, 47, 0.35);
color: #a3b86c;
border: 1px solid rgba(85, 107, 47, 0.6);
}
.civ-badge {
background: rgba(74, 158, 255, 0.15);
color: var(--text-dim);
border: 1px solid rgba(74, 158, 255, 0.25);
}
.mono { .mono {
font-family: var(--font-mono); font-family: var(--font-mono);
} }
@@ -614,6 +695,15 @@ body {
min-width: 100%; min-width: 100%;
} }
.data-actions {
width: 100%;
}
.data-actions input[type="date"],
.data-actions .primary-btn {
width: 100%;
}
.panel { .panel {
min-height: 320px; min-height: 320px;
} }
+6 -6
View File
@@ -394,7 +394,7 @@ body {
.strip-btn.primary { .strip-btn.primary {
background: linear-gradient(135deg, var(--accent-cyan) 0%, #2b6fb8 100%); background: linear-gradient(135deg, var(--accent-cyan) 0%, #2b6fb8 100%);
border: none; border: none;
color: white; color: var(--text-inverse);
} }
/* Main dashboard grid - Mobile first */ /* Main dashboard grid - Mobile first */
@@ -779,7 +779,7 @@ body {
.control-group select { .control-group select {
padding: 4px 8px; padding: 4px 8px;
background: rgba(0, 0, 0, 0.3); background: var(--bg-dark);
border: 1px solid rgba(74, 158, 255, 0.3); border: 1px solid rgba(74, 158, 255, 0.3);
border-radius: 4px; border-radius: 4px;
color: var(--accent-cyan); color: var(--accent-cyan);
@@ -812,7 +812,7 @@ body {
padding: 6px 16px; padding: 6px 16px;
border: none; border: none;
background: var(--accent-green); background: var(--accent-green);
color: #fff; color: var(--text-inverse);
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
@@ -830,7 +830,7 @@ body {
.start-btn.active { .start-btn.active {
background: var(--accent-red); background: var(--accent-red);
color: #fff; color: var(--text-inverse);
} }
.start-btn.active:hover { .start-btn.active:hover {
@@ -1282,7 +1282,7 @@ body {
.dsc-distress-alert button { .dsc-distress-alert button {
background: var(--accent-red); background: var(--accent-red);
border: none; border: none;
color: white; color: var(--text-inverse);
padding: 10px 24px; padding: 10px 24px;
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 12px; font-size: 12px;
@@ -1313,7 +1313,7 @@ body {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 14px; font-size: 14px;
color: white; color: var(--text-inverse);
border: 2px solid white; border: 2px solid white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
} }
+1 -1
View File
@@ -136,7 +136,7 @@
.activity-timeline-btn.active { .activity-timeline-btn.active {
background: var(--timeline-accent); background: var(--timeline-accent);
color: #000; color: var(--text-inverse);
border-color: var(--timeline-accent); border-color: var(--timeline-accent);
} }
+10 -10
View File
@@ -185,7 +185,7 @@
.function-strip .strip-btn.primary { .function-strip .strip-btn.primary {
background: linear-gradient(135deg, var(--accent-green) 0%, #10b981 100%); background: linear-gradient(135deg, var(--accent-green) 0%, #10b981 100%);
border: none; border: none;
color: #000; color: var(--text-inverse);
} }
.function-strip .strip-btn.primary:hover:not(:disabled) { .function-strip .strip-btn.primary:hover:not(:disabled) {
@@ -195,7 +195,7 @@
.function-strip .strip-btn.stop { .function-strip .strip-btn.stop {
background: linear-gradient(135deg, var(--accent-red) 0%, #dc2626 100%); background: linear-gradient(135deg, var(--accent-red) 0%, #dc2626 100%);
border: none; border: none;
color: #fff; color: var(--text-inverse);
} }
.function-strip .strip-btn.stop:hover:not(:disabled) { .function-strip .strip-btn.stop:hover:not(:disabled) {
@@ -304,7 +304,7 @@
border-color: rgba(0, 122, 255, 0.3); border-color: rgba(0, 122, 255, 0.3);
} }
.function-strip.bt-strip .strip-value { .function-strip.bt-strip .strip-value {
color: #0a84ff; color: var(--accent-blue, #0a84ff);
} }
.function-strip.wifi-strip .strip-stat { .function-strip.wifi-strip .strip-stat {
@@ -332,24 +332,24 @@
border-color: rgba(255, 59, 48, 0.6); border-color: rgba(255, 59, 48, 0.6);
} }
.function-strip.tscm-strip .strip-value { .function-strip.tscm-strip .strip-value {
color: #ef4444; /* Explicit red color */ color: var(--accent-red);
} }
.function-strip.tscm-strip .strip-label { .function-strip.tscm-strip .strip-label {
color: #9ca3af; /* Explicit light gray */ color: var(--text-secondary);
} }
.function-strip.tscm-strip .strip-select { .function-strip.tscm-strip .strip-select {
color: #e8eaed; /* Explicit white for selects */ color: var(--text-primary);
background: rgba(0, 0, 0, 0.4); background: rgba(0, 0, 0, 0.4);
} }
.function-strip.tscm-strip .strip-btn { .function-strip.tscm-strip .strip-btn {
color: #e8eaed; /* Explicit white for buttons */ color: var(--text-primary);
} }
.function-strip.tscm-strip .strip-tool { .function-strip.tscm-strip .strip-tool {
color: #e8eaed; /* Explicit white for tool indicators */ color: var(--text-primary);
} }
.function-strip.tscm-strip .strip-time, .function-strip.tscm-strip .strip-time,
.function-strip.tscm-strip .strip-status span { .function-strip.tscm-strip .strip-status span {
color: #9ca3af; /* Explicit gray for status/time */ color: var(--text-secondary);
} }
.function-strip.rtlamr-strip .strip-stat { .function-strip.rtlamr-strip .strip-stat {
@@ -361,7 +361,7 @@
border-color: rgba(175, 82, 222, 0.3); border-color: rgba(175, 82, 222, 0.3);
} }
.function-strip.rtlamr-strip .strip-value { .function-strip.rtlamr-strip .strip-value {
color: #af52de; color: var(--accent-purple, #af52de);
} }
.function-strip.listening-strip .strip-stat { .function-strip.listening-strip .strip-stat {
+11 -11
View File
@@ -52,19 +52,19 @@
.bt-radar-filter-btn:hover { .bt-radar-filter-btn:hover {
background: var(--bg-hover, #333) !important; background: var(--bg-hover, #333) !important;
color: #fff !important; color: var(--text-primary) !important;
} }
.bt-radar-filter-btn.active { .bt-radar-filter-btn.active {
background: #00d4ff !important; background: var(--accent-cyan) !important;
color: #000 !important; color: var(--text-inverse) !important;
border-color: #00d4ff !important; border-color: var(--accent-cyan) !important;
} }
#btRadarPauseBtn.active { #btRadarPauseBtn.active {
background: #f97316 !important; background: var(--accent-orange) !important;
color: #000 !important; color: var(--text-inverse) !important;
border-color: #f97316 !important; border-color: var(--accent-orange) !important;
} }
/* ============================================ /* ============================================
@@ -120,9 +120,9 @@
} }
.heatmap-btn.active { .heatmap-btn.active {
background: #f97316; background: var(--accent-orange);
color: #000; color: var(--text-inverse);
border-color: #f97316; border-color: var(--accent-orange);
} }
.timeline-heatmap-content { .timeline-heatmap-content {
@@ -141,7 +141,7 @@
} }
.heatmap-error { .heatmap-error {
color: #ef4444; color: var(--accent-red);
} }
.heatmap-grid { .heatmap-grid {
+14 -14
View File
@@ -279,19 +279,19 @@
.signal-proto-badge.aprs { .signal-proto-badge.aprs {
background: rgba(6, 182, 212, 0.15); background: rgba(6, 182, 212, 0.15);
color: #06b6d4; color: var(--proto-aprs, #06b6d4);
border-color: rgba(6, 182, 212, 0.25); border-color: rgba(6, 182, 212, 0.25);
} }
.signal-proto-badge.ais { .signal-proto-badge.ais {
background: rgba(139, 92, 246, 0.15); background: rgba(139, 92, 246, 0.15);
color: #8b5cf6; color: var(--proto-ais, #8b5cf6);
border-color: rgba(139, 92, 246, 0.25); border-color: rgba(139, 92, 246, 0.25);
} }
.signal-proto-badge.acars { .signal-proto-badge.acars {
background: rgba(236, 72, 153, 0.15); background: rgba(236, 72, 153, 0.15);
color: #ec4899; color: var(--proto-acars, #ec4899);
border-color: rgba(236, 72, 153, 0.25); border-color: rgba(236, 72, 153, 0.25);
} }
@@ -976,25 +976,25 @@
/* Meter protocol badges */ /* Meter protocol badges */
.signal-proto-badge.meter { .signal-proto-badge.meter {
background: rgba(234, 179, 8, 0.15); background: rgba(234, 179, 8, 0.15);
color: #eab308; color: var(--accent-yellow, #eab308);
border-color: rgba(234, 179, 8, 0.25); border-color: rgba(234, 179, 8, 0.25);
} }
.signal-proto-badge.meter.electric { .signal-proto-badge.meter.electric {
background: rgba(234, 179, 8, 0.15); background: rgba(234, 179, 8, 0.15);
color: #eab308; color: var(--accent-yellow, #eab308);
border-color: rgba(234, 179, 8, 0.25); border-color: rgba(234, 179, 8, 0.25);
} }
.signal-proto-badge.meter.gas { .signal-proto-badge.meter.gas {
background: rgba(249, 115, 22, 0.15); background: rgba(249, 115, 22, 0.15);
color: #f97316; color: var(--accent-orange, #f97316);
border-color: rgba(249, 115, 22, 0.25); border-color: rgba(249, 115, 22, 0.25);
} }
.signal-proto-badge.meter.water { .signal-proto-badge.meter.water {
background: rgba(59, 130, 246, 0.15); background: rgba(59, 130, 246, 0.15);
color: #3b82f6; color: var(--signal-new, #3b82f6);
border-color: rgba(59, 130, 246, 0.25); border-color: rgba(59, 130, 246, 0.25);
} }
@@ -1060,12 +1060,12 @@
.meter-delta.positive { .meter-delta.positive {
background: rgba(34, 197, 94, 0.15); background: rgba(34, 197, 94, 0.15);
color: #22c55e; color: var(--accent-green);
} }
.meter-delta.negative { .meter-delta.negative {
background: rgba(239, 68, 68, 0.15); background: rgba(239, 68, 68, 0.15);
color: #ef4444; color: var(--accent-red);
} }
/* Sparkline container */ /* Sparkline container */
@@ -1431,7 +1431,7 @@
.signal-station-clickable:hover { .signal-station-clickable:hover {
background: var(--accent-purple); background: var(--accent-purple);
color: #000; color: var(--text-inverse);
transform: scale(1.05); transform: scale(1.05);
box-shadow: 0 0 8px rgba(138, 43, 226, 0.4); box-shadow: 0 0 8px rgba(138, 43, 226, 0.4);
} }
@@ -1587,14 +1587,14 @@
background: var(--accent-purple, #8a2be2); background: var(--accent-purple, #8a2be2);
border: none; border: none;
border-radius: 4px; border-radius: 4px;
color: #fff; color: var(--text-inverse);
cursor: pointer; cursor: pointer;
transition: all 0.15s ease; transition: all 0.15s ease;
} }
.station-raw-copy-btn:hover { .station-raw-copy-btn:hover {
background: var(--accent-cyan, #00d4ff); background: var(--accent-cyan, #00d4ff);
color: #000; color: var(--text-inverse);
} }
/* ============================================ /* ============================================
@@ -1794,14 +1794,14 @@
background: var(--accent-purple, #8a2be2); background: var(--accent-purple, #8a2be2);
border: none; border: none;
border-radius: 4px; border-radius: 4px;
color: #fff; color: var(--text-inverse);
cursor: pointer; cursor: pointer;
transition: all 0.15s ease; transition: all 0.15s ease;
} }
.signal-details-copy-btn:hover { .signal-details-copy-btn:hover {
background: var(--accent-cyan, #00d4ff); background: var(--accent-cyan, #00d4ff);
color: #000; color: var(--text-inverse);
} }
/* Signal Details Content Sections */ /* Signal Details Content Sections */
+1 -1
View File
@@ -103,7 +103,7 @@
.signal-timeline-btn.active { .signal-timeline-btn.active {
background: var(--accent-cyan, #4a9eff); background: var(--accent-cyan, #4a9eff);
color: #000; color: var(--text-inverse);
border-color: var(--accent-cyan, #4a9eff); border-color: var(--accent-cyan, #4a9eff);
} }
+39
View File
@@ -0,0 +1,39 @@
/**
* Signal Waveform Component
* Animated SVG bar waveform for indicating live signal activity
*/
.signal-waveform {
display: inline-flex;
align-items: center;
vertical-align: middle;
}
.signal-waveform-svg {
display: block;
}
.signal-waveform-bar {
will-change: height, y;
transition: fill 0.3s ease;
}
/* Idle breathing animation */
.signal-waveform.idle .signal-waveform-bar {
animation: signal-waveform-breathe 2.5s ease-in-out infinite;
}
.signal-waveform.idle .signal-waveform-bar:nth-child(even) {
animation-delay: -1.25s;
}
@keyframes signal-waveform-breathe {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.7; }
}
/* Active state - disable CSS breathing, JS drives heights */
.signal-waveform.active .signal-waveform-bar {
animation: none;
opacity: 1;
}
+2 -2
View File
@@ -122,7 +122,7 @@
.update-toast-btn-primary { .update-toast-btn-primary {
background: var(--accent-green, #22c55e); background: var(--accent-green, #22c55e);
color: #000; color: var(--text-inverse);
} }
.update-toast-btn-primary:hover { .update-toast-btn-primary:hover {
@@ -561,7 +561,7 @@
.update-modal-btn-primary { .update-modal-btn-primary {
background: var(--accent-green, #22c55e); background: var(--accent-green, #22c55e);
color: #000; color: var(--text-inverse);
} }
.update-modal-btn-primary:hover:not(:disabled) { .update-modal-btn-primary:hover:not(:disabled) {
+24 -1
View File
@@ -323,7 +323,7 @@
} }
.setup-btn.primary { .setup-btn.primary {
color: #fff; color: var(--text-inverse);
background: var(--accent-cyan, #4aa3ff); background: var(--accent-cyan, #4aa3ff);
border-color: var(--accent-cyan, #4aa3ff); border-color: var(--accent-cyan, #4aa3ff);
} }
@@ -406,6 +406,29 @@
color: var(--text-primary, #e6edf5); color: var(--text-primary, #e6edf5);
} }
/* ---- Light theme overrides ---- */
[data-theme="light"] .run-state-chip {
background: linear-gradient(180deg, rgba(233, 238, 245, 0.9), rgba(225, 232, 242, 0.92));
border-color: rgba(31, 95, 168, 0.18);
color: var(--text-secondary);
}
[data-theme="light"] .run-state-chip.active {
border-color: rgba(31, 95, 168, 0.4);
color: var(--text-primary);
}
[data-theme="light"] .run-state-btn {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.95), rgba(241, 244, 249, 0.97));
color: var(--accent-cyan);
border-color: rgba(31, 95, 168, 0.25);
}
[data-theme="light"] .run-state-btn:hover {
background: rgba(31, 95, 168, 0.08);
border-color: rgba(31, 95, 168, 0.45);
}
@media (max-width: 920px) { @media (max-width: 920px) {
.run-state-strip { .run-state-strip {
flex-direction: column; flex-direction: column;
+2 -2
View File
@@ -75,7 +75,7 @@
.btn-danger { .btn-danger {
background: var(--accent-red); background: var(--accent-red);
color: white; color: var(--text-inverse);
border-color: var(--accent-red); border-color: var(--accent-red);
} }
@@ -86,7 +86,7 @@
.btn-success { .btn-success {
background: var(--accent-green); background: var(--accent-green);
color: white; color: var(--text-inverse);
border-color: var(--accent-green); border-color: var(--accent-green);
} }
+33
View File
@@ -61,6 +61,18 @@
--status-offline: #6f7f94; --status-offline: #6f7f94;
--status-info: #4aa3ff; --status-info: #4aa3ff;
/* Severity colors */
--severity-critical: #ff3366;
--severity-high: #ff9933;
--severity-medium: #ffcc00;
--severity-low: #00ff88;
/* Data visualization neon colors */
--neon-green: #00ff88;
--neon-yellow: #ffcc00;
--neon-orange: #ff8800;
--neon-red: #ff3366;
/* Subtle grid/pattern */ /* Subtle grid/pattern */
--grid-line: rgba(74, 163, 255, 0.1); --grid-line: rgba(74, 163, 255, 0.1);
--grid-dot: rgba(255, 255, 255, 0.03); --grid-dot: rgba(255, 255, 255, 0.03);
@@ -184,6 +196,27 @@
--accent-orange-dim: rgba(181, 134, 58, 0.12); --accent-orange-dim: rgba(181, 134, 58, 0.12);
--accent-amber: #b5863a; --accent-amber: #b5863a;
--accent-amber-dim: rgba(181, 134, 58, 0.12); --accent-amber-dim: rgba(181, 134, 58, 0.12);
--accent-yellow: #9a8420;
--accent-purple: #6b5ba8;
/* Status colors - light theme */
--status-online: #1f8a57;
--status-warning: #b5863a;
--status-error: #c74444;
--status-offline: #6b7c93;
--status-info: #1f5fa8;
/* Severity colors */
--severity-critical: #c74444;
--severity-high: #b5863a;
--severity-medium: #9a8420;
--severity-low: #1f8a57;
/* Data visualization neon replacements */
--neon-green: #1a8a50;
--neon-yellow: #9a8420;
--neon-orange: #b5863a;
--neon-red: #c74444;
--text-primary: #122034; --text-primary: #122034;
--text-secondary: #3a4a5f; --text-secondary: #3a4a5f;
+68
View File
@@ -382,6 +382,74 @@
transform: rotate(90deg); transform: rotate(90deg);
} }
/* ---- Light theme overrides ---- */
[data-theme="light"] .mode-nav {
background: linear-gradient(180deg, rgba(240, 244, 250, 0.97) 0%, rgba(232, 238, 247, 0.95) 100%);
}
[data-theme="light"] .mode-nav-btn:hover {
background: rgba(220, 230, 244, 0.8);
}
[data-theme="light"] .mode-nav-btn.active {
background: rgba(220, 230, 244, 0.9);
color: var(--text-primary);
}
[data-theme="light"] .mode-nav-dropdown-btn:hover,
[data-theme="light"] .mode-nav-dropdown.open .mode-nav-dropdown-btn,
[data-theme="light"] .mode-nav-dropdown.has-active .mode-nav-dropdown-btn {
background: rgba(220, 230, 244, 0.9);
color: var(--text-primary);
}
[data-theme="light"] .mode-nav-dropdown-menu {
background: rgba(248, 250, 253, 0.99);
box-shadow: 0 16px 36px rgba(18, 40, 66, 0.15);
}
[data-theme="light"] .mode-nav-dropdown-menu .mode-nav-btn:hover {
background: rgba(220, 230, 244, 0.85);
}
[data-theme="light"] .mode-nav-dropdown-menu .mode-nav-btn.active {
background: rgba(220, 230, 244, 0.95);
color: var(--text-primary);
}
[data-theme="light"] .nav-tool-btn {
background: rgba(235, 241, 250, 0.7);
border-color: rgba(31, 95, 168, 0.12);
}
[data-theme="light"] .nav-tool-btn:hover {
background: rgba(220, 230, 244, 0.9);
box-shadow: 0 4px 10px rgba(18, 40, 66, 0.1);
}
[data-theme="light"] .nav-action-btn {
background: rgba(235, 241, 250, 0.85);
border-color: rgba(31, 95, 168, 0.14);
}
[data-theme="light"] .nav-action-btn:hover {
background: rgba(220, 230, 244, 0.95);
box-shadow: 0 6px 14px rgba(18, 40, 66, 0.1);
}
[data-theme="light"] a.nav-dashboard-btn,
[data-theme="light"] a.nav-dashboard-btn:link,
[data-theme="light"] a.nav-dashboard-btn:visited {
background: rgba(235, 241, 250, 0.7) !important;
border-color: rgba(31, 95, 168, 0.12) !important;
color: var(--text-secondary) !important;
}
[data-theme="light"] a.nav-dashboard-btn:hover {
background: rgba(220, 230, 244, 0.9) !important;
box-shadow: 0 4px 10px rgba(18, 40, 66, 0.1);
}
/* Effects/animations toggle icon states */ /* Effects/animations toggle icon states */
.nav-tool-btn .icon-effects-off { .nav-tool-btn .icon-effects-off {
display: none; display: none;
+154 -36
View File
@@ -202,10 +202,38 @@ body {
} }
.welcome-container { .welcome-container {
position: relative;
width: 90%; width: 90%;
max-width: 900px; max-width: 900px;
z-index: 1; z-index: 1;
animation: welcomeFadeIn 0.8s ease-out; animation: welcomeFadeIn 0.8s ease-out;
max-height: calc(100vh - 40px);
overflow: hidden;
}
.welcome-settings-btn {
position: absolute;
top: 12px;
right: 12px;
z-index: 2;
background: none;
border: none;
cursor: pointer;
padding: 6px;
border-radius: 6px;
color: var(--text-dim, rgba(255, 255, 255, 0.3));
transition: color 0.2s, background 0.2s;
}
.welcome-settings-btn:hover {
color: var(--accent-cyan, #00d4ff);
background: rgba(255, 255, 255, 0.05);
}
.welcome-settings-btn svg {
width: 20px;
height: 20px;
display: block;
} }
@keyframes welcomeFadeIn { @keyframes welcomeFadeIn {
@@ -232,6 +260,7 @@ body {
.welcome-logo { .welcome-logo {
animation: logoPulse 3s ease-in-out infinite; animation: logoPulse 3s ease-in-out infinite;
will-change: filter;
} }
@keyframes logoPulse { @keyframes logoPulse {
@@ -332,6 +361,7 @@ body {
padding: 20px; padding: 20px;
max-height: calc(100vh - 300px); max-height: calc(100vh - 300px);
overflow-y: auto; overflow-y: auto;
scrollbar-gutter: stable;
} }
.changelog-release { .changelog-release {
@@ -1559,6 +1589,7 @@ header h1 .tagline {
overflow: hidden; overflow: hidden;
padding: 12px; padding: 12px;
position: relative; position: relative;
flex-shrink: 0;
} }
.section h3 { .section h3 {
@@ -1744,11 +1775,12 @@ header h1 .tagline {
} }
.run-btn { .run-btn {
flex-shrink: 0;
width: 100%; width: 100%;
padding: 12px; padding: 12px;
background: var(--accent-green); background: var(--accent-green);
border: none; border: none;
color: #fff; color: var(--text-inverse);
font-family: var(--font-sans); font-family: var(--font-sans);
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
@@ -1777,15 +1809,16 @@ header h1 .tagline {
.run-btn.active, .run-btn.active,
.stop-btn { .stop-btn {
background: var(--accent-red); background: var(--accent-red);
color: #fff; color: var(--text-inverse);
} }
.stop-btn { .stop-btn {
flex-shrink: 0;
width: 100%; width: 100%;
padding: 12px; padding: 12px;
background: var(--accent-red); background: var(--accent-red);
border: none; border: none;
color: #fff; color: var(--text-inverse);
font-family: var(--font-sans); font-family: var(--font-sans);
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
@@ -2604,7 +2637,7 @@ header h1 .tagline {
} }
.aircraft-popup .value { .aircraft-popup .value {
color: #fff; color: var(--text-primary);
} }
.leaflet-popup-content-wrapper { .leaflet-popup-content-wrapper {
@@ -3599,7 +3632,7 @@ header h1 .tagline {
.wifi-filter-btn.active { .wifi-filter-btn.active {
background: var(--accent-cyan); background: var(--accent-cyan);
color: #000; color: var(--text-inverse);
border-color: var(--accent-cyan); border-color: var(--accent-cyan);
} }
@@ -3842,7 +3875,7 @@ header h1 .tagline {
.channel-band-tab.active { .channel-band-tab.active {
background: var(--accent-cyan); background: var(--accent-cyan);
color: #000; color: var(--text-inverse);
border-color: var(--accent-cyan); border-color: var(--accent-cyan);
} }
@@ -4517,7 +4550,7 @@ header h1 .tagline {
.bt-detail-btn:hover { .bt-detail-btn:hover {
background: var(--accent-cyan); background: var(--accent-cyan);
border-color: var(--accent-cyan); border-color: var(--accent-cyan);
color: #000; color: var(--text-inverse);
} }
.bt-detail-btn.active { .bt-detail-btn.active {
@@ -4540,7 +4573,7 @@ header h1 .tagline {
} }
.bt-inspector-toggle:hover { .bt-inspector-toggle:hover {
color: #fff; color: var(--text-primary);
} }
.bt-inspector-arrow { .bt-inspector-arrow {
@@ -4806,7 +4839,7 @@ header h1 .tagline {
.bt-filter-btn.active { .bt-filter-btn.active {
background: var(--accent-purple); background: var(--accent-purple);
border-color: var(--accent-purple); border-color: var(--accent-purple);
color: white; color: var(--text-inverse);
} }
.bt-tracker-item { .bt-tracker-item {
@@ -5364,7 +5397,7 @@ header h1 .tagline {
.bt-modal-btn-primary { .bt-modal-btn-primary {
background: var(--accent-cyan, #00d4ff); background: var(--accent-cyan, #00d4ff);
border: none; border: none;
color: #000; color: var(--text-inverse);
font-weight: 600; font-weight: 600;
} }
@@ -5495,7 +5528,7 @@ header h1 .tagline {
.channel-label { .channel-label {
font-size: 8px; font-size: 8px;
color: #fff; color: var(--text-primary);
margin-top: 3px; margin-top: 3px;
} }
@@ -5722,7 +5755,7 @@ body::before {
.disclaimer-modal .accept-btn { .disclaimer-modal .accept-btn {
background: var(--accent-cyan); background: var(--accent-cyan);
color: #000; color: var(--text-inverse);
border: none; border: none;
padding: 12px 40px; padding: 12px 40px;
font-family: var(--font-sans); font-family: var(--font-sans);
@@ -5813,7 +5846,7 @@ body::before {
/* WPS Indicator */ /* WPS Indicator */
.wps-enabled { .wps-enabled {
background: #ff6600; background: #ff6600;
color: #000; color: var(--text-inverse);
padding: 2px 6px; padding: 2px 6px;
border-radius: 3px; border-radius: 3px;
font-size: 9px; font-size: 9px;
@@ -5822,7 +5855,7 @@ body::before {
/* Rogue AP Indicator */ /* Rogue AP Indicator */
.rogue-indicator { .rogue-indicator {
background: linear-gradient(90deg, #ff0000, #cc0000); background: linear-gradient(90deg, #ff0000, #cc0000);
color: #fff; color: var(--text-inverse);
padding: 4px 8px; padding: 4px 8px;
margin: -10px -10px 8px -10px; margin: -10px -10px 8px -10px;
font-size: 10px; font-size: 10px;
@@ -5841,7 +5874,7 @@ body::before {
/* PMKID Capture */ /* PMKID Capture */
.pmkid-btn { .pmkid-btn {
background: linear-gradient(135deg, #9933ff, #6600cc); background: linear-gradient(135deg, #9933ff, #6600cc);
color: #fff; color: var(--text-inverse);
} }
.pmkid-btn:hover { .pmkid-btn:hover {
@@ -5856,7 +5889,7 @@ body::before {
.findmy-badge { .findmy-badge {
background: linear-gradient(135deg, #007aff, #5856d6); background: linear-gradient(135deg, #007aff, #5856d6);
color: #fff; color: var(--text-inverse);
padding: 2px 8px; padding: 2px 8px;
border-radius: 10px; border-radius: 10px;
font-size: 10px; font-size: 10px;
@@ -5933,7 +5966,7 @@ body::before {
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
background: #ff0000; background: #ff0000;
color: #fff; color: var(--text-inverse);
padding: 15px 30px; padding: 15px 30px;
border-radius: 8px; border-radius: 8px;
font-weight: bold; font-weight: bold;
@@ -5981,7 +6014,7 @@ body::before {
.military-badge { .military-badge {
background: #556b2f; background: #556b2f;
color: #fff; color: var(--text-inverse);
padding: 2px 6px; padding: 2px 6px;
border-radius: 3px; border-radius: 3px;
font-size: 9px; font-size: 9px;
@@ -5996,7 +6029,7 @@ body::before {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-weight: bold; font-weight: bold;
color: #000; color: var(--text-inverse);
border: 2px solid var(--accent-cyan); border: 2px solid var(--accent-cyan);
} }
@@ -6682,7 +6715,7 @@ body::before {
.radio-action-btn.scan { .radio-action-btn.scan {
background: var(--accent-green); background: var(--accent-green);
border-color: var(--accent-green); border-color: var(--accent-green);
color: #fff; color: var(--text-inverse);
} }
.radio-action-btn.scan:hover { .radio-action-btn.scan:hover {
@@ -6702,7 +6735,7 @@ body::before {
.radio-action-btn.pause { .radio-action-btn.pause {
background: var(--accent-orange); background: var(--accent-orange);
border-color: var(--accent-orange); border-color: var(--accent-orange);
color: #000; color: var(--text-inverse);
} }
.radio-action-btn.pause:disabled { .radio-action-btn.pause:disabled {
@@ -6928,7 +6961,7 @@ body::before {
.radio-action-btn.listen { .radio-action-btn.listen {
background: var(--accent-green); background: var(--accent-green);
border-color: var(--accent-green); border-color: var(--accent-green);
color: #000; color: var(--text-inverse);
} }
.radio-action-btn.scan:hover:not(:disabled), .radio-action-btn.scan:hover:not(:disabled),
@@ -7204,6 +7237,15 @@ body[data-mode="tscm"] {
--mode-ambient-bottom: rgba(181, 134, 58, 0.04); --mode-ambient-bottom: rgba(181, 134, 58, 0.04);
} }
[data-theme="light"] {
--visual-surface-soft: linear-gradient(180deg, rgba(245, 248, 252, 0.95) 0%, rgba(235, 240, 248, 0.97) 100%);
--visual-surface-panel: linear-gradient(160deg, rgba(248, 250, 253, 0.96) 0%, rgba(240, 244, 250, 0.97) 100%);
--visual-edge-cyan: rgba(31, 95, 168, 0.22);
--visual-edge-green: rgba(31, 138, 87, 0.18);
--visual-glow-soft: 0 10px 24px rgba(18, 40, 66, 0.08);
--visual-glow-cyan: 0 0 16px rgba(31, 95, 168, 0.1);
}
.mode-nav { .mode-nav {
background: linear-gradient(180deg, rgba(22, 33, 48, 0.96) 0%, rgba(14, 22, 33, 0.98) 100%); background: linear-gradient(180deg, rgba(22, 33, 48, 0.96) 0%, rgba(14, 22, 33, 0.98) 100%);
border-bottom-color: rgba(74, 163, 255, 0.24); border-bottom-color: rgba(74, 163, 255, 0.24);
@@ -7267,7 +7309,6 @@ body[data-mode="tscm"] {
.section:hover { .section:hover {
border-color: var(--visual-edge-cyan); border-color: var(--visual-edge-cyan);
box-shadow: var(--visual-glow-cyan), inset 0 1px 0 rgba(255, 255, 255, 0.06); box-shadow: var(--visual-glow-cyan), inset 0 1px 0 rgba(255, 255, 255, 0.06);
transform: translateY(-1px);
} }
.section h3 { .section h3 {
@@ -7367,24 +7408,101 @@ body[data-mode="tscm"] {
} }
} }
[data-theme="light"] .run-state-strip, [data-theme="light"] .mode-nav {
[data-theme="light"] .main-content, background: linear-gradient(180deg, rgba(240, 244, 250, 0.97) 0%, rgba(232, 238, 247, 0.98) 100%);
[data-theme="light"] .section, border-bottom-color: rgba(31, 95, 168, 0.18);
[data-theme="light"] #mainNav.mode-nav, }
[data-theme="light"] .output-header,
[data-theme="light"] .status-bar, [data-theme="light"] #mainNav.mode-nav {
[data-theme="light"] .status-indicator, background: linear-gradient(180deg, rgba(245, 248, 253, 0.98) 0%, rgba(238, 243, 250, 0.99) 100%);
[data-theme="light"] .control-group { border-color: rgba(31, 95, 168, 0.16);
box-shadow: 0 10px 24px rgba(18, 40, 66, 0.08); box-shadow: 0 10px 24px rgba(18, 40, 66, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.7);
}
[data-theme="light"] .run-state-strip {
background: linear-gradient(180deg, rgba(245, 248, 253, 0.97) 0%, rgba(238, 243, 250, 0.98) 100%);
border-color: rgba(31, 95, 168, 0.18);
box-shadow: 0 10px 24px rgba(18, 40, 66, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.7);
}
[data-theme="light"] .main-content {
border-color: rgba(31, 95, 168, 0.16);
box-shadow: 0 10px 24px rgba(18, 40, 66, 0.08), inset 0 0 0 1px rgba(255, 255, 255, 0.5);
}
[data-theme="light"] .sidebar {
border-right-color: rgba(31, 95, 168, 0.16);
}
[data-theme="light"] .section {
border-color: rgba(31, 95, 168, 0.18);
box-shadow: 0 2px 8px rgba(18, 40, 66, 0.06);
}
[data-theme="light"] .section:hover {
border-color: rgba(31, 95, 168, 0.3);
box-shadow: 0 4px 14px rgba(18, 40, 66, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.6);
}
[data-theme="light"] .section h3 {
background: linear-gradient(180deg, rgba(235, 241, 250, 0.92) 0%, rgba(228, 236, 248, 0.94) 100%);
border-bottom-color: rgba(31, 95, 168, 0.14);
}
[data-theme="light"] .section h3::after {
background: rgba(245, 248, 253, 0.95);
border-color: rgba(31, 95, 168, 0.18);
}
[data-theme="light"] .form-group input,
[data-theme="light"] .form-group select {
background: rgba(255, 255, 255, 0.85);
border-color: rgba(31, 95, 168, 0.18);
} }
[data-theme="light"] .section,
[data-theme="light"] .stats > div,
[data-theme="light"] .message,
[data-theme="light"] .preset-btn, [data-theme="light"] .preset-btn,
[data-theme="light"] .control-btn, [data-theme="light"] .control-btn,
[data-theme="light"] .clear-btn { [data-theme="light"] .clear-btn {
border-color: rgba(31, 95, 168, 0.26); border-color: rgba(31, 95, 168, 0.22);
background: linear-gradient(180deg, rgba(245, 248, 253, 0.92) 0%, rgba(238, 243, 250, 0.94) 100%);
}
[data-theme="light"] .output-panel {
background: linear-gradient(180deg, rgba(250, 252, 255, 0.99) 0%, rgba(245, 248, 253, 0.99) 100%);
}
[data-theme="light"] .output-header {
background: linear-gradient(180deg, rgba(238, 243, 250, 0.96) 0%, rgba(232, 238, 247, 0.98) 100%);
border-bottom-color: rgba(31, 95, 168, 0.16);
box-shadow: 0 10px 24px rgba(18, 40, 66, 0.08);
}
[data-theme="light"] .output-content {
background: linear-gradient(180deg, rgba(250, 252, 255, 0.7) 0%, rgba(250, 252, 255, 0.95) 100%);
}
[data-theme="light"] .stats > div {
border-color: rgba(31, 95, 168, 0.18);
background: linear-gradient(180deg, rgba(245, 248, 253, 0.85) 0%, rgba(240, 244, 250, 0.88) 100%);
}
[data-theme="light"] .message {
border-color: rgba(31, 95, 168, 0.2);
background: linear-gradient(180deg, rgba(248, 250, 253, 0.88) 0%, rgba(242, 246, 252, 0.9) 100%);
box-shadow: 0 4px 12px rgba(18, 40, 66, 0.08);
}
[data-theme="light"] .status-bar {
border-top-color: rgba(31, 95, 168, 0.18);
background: linear-gradient(180deg, rgba(242, 246, 252, 0.97) 0%, rgba(235, 240, 248, 0.98) 100%);
box-shadow: 0 10px 24px rgba(18, 40, 66, 0.08);
}
[data-theme="light"] .status-indicator,
[data-theme="light"] .control-group {
border-color: rgba(31, 95, 168, 0.16);
background: linear-gradient(180deg, rgba(245, 248, 253, 0.82) 0%, rgba(240, 244, 250, 0.85) 100%);
box-shadow: 0 10px 24px rgba(18, 40, 66, 0.08);
} }
[data-animations="off"] .mode-content.active { [data-animations="off"] .mode-content.active {
+24
View File
@@ -89,3 +89,27 @@
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(74, 158, 255, 0.7); } 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(74, 158, 255, 0.7); }
50% { opacity: 0.6; box-shadow: 0 0 6px 3px rgba(74, 158, 255, 0.3); } 50% { opacity: 0.6; box-shadow: 0 0 6px 3px rgba(74, 158, 255, 0.3); }
} }
/* ACARS Standalone Message Feed */
.acars-message-feed {
scrollbar-width: thin;
scrollbar-color: var(--border-color) transparent;
}
.acars-message-feed::-webkit-scrollbar {
width: 4px;
}
.acars-message-feed::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 2px;
}
.acars-feed-card {
transition: background 0.15s;
}
.acars-feed-card:hover {
background: rgba(74, 158, 255, 0.05);
}
/* Clickable ACARS sidebar messages (linked to tracked aircraft) */
.acars-message-item[style*="cursor: pointer"]:hover {
background: rgba(74, 158, 255, 0.1);
}
+3 -3
View File
@@ -60,7 +60,7 @@
gap: 4px; gap: 4px;
} }
.aprs-strip .strip-select { .aprs-strip .strip-select {
background: rgba(0,0,0,0.3); background: var(--bg-dark);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
color: var(--text-primary); color: var(--text-primary);
padding: 4px 8px; padding: 4px 8px;
@@ -132,7 +132,7 @@
.aprs-strip .strip-btn.primary { .aprs-strip .strip-btn.primary {
background: linear-gradient(135deg, var(--accent-green) 0%, #10b981 100%); background: linear-gradient(135deg, var(--accent-green) 0%, #10b981 100%);
border: none; border: none;
color: #000; color: var(--text-inverse);
} }
.aprs-strip .strip-btn.primary:hover:not(:disabled) { .aprs-strip .strip-btn.primary:hover:not(:disabled) {
filter: brightness(1.1); filter: brightness(1.1);
@@ -140,7 +140,7 @@
.aprs-strip .strip-btn.stop { .aprs-strip .strip-btn.stop {
background: linear-gradient(135deg, var(--accent-red) 0%, #dc2626 100%); background: linear-gradient(135deg, var(--accent-red) 0%, #dc2626 100%);
border: none; border: none;
color: #fff; color: var(--text-inverse);
} }
.aprs-strip .strip-btn.stop:hover:not(:disabled) { .aprs-strip .strip-btn.stop:hover:not(:disabled) {
filter: brightness(1.1); filter: brightness(1.1);
+50 -1
View File
@@ -151,8 +151,17 @@
overflow: hidden; overflow: hidden;
} }
.gps-sky-globe {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
#gpsSkyCanvas { #gpsSkyCanvas {
display: block; position: absolute;
inset: 0;
display: none;
width: 100%; width: 100%;
height: 100%; height: 100%;
cursor: grab; cursor: grab;
@@ -166,10 +175,50 @@
.gps-sky-overlay { .gps-sky-overlay {
position: absolute; position: absolute;
inset: 0; inset: 0;
display: none;
pointer-events: none; pointer-events: none;
font-family: var(--font-mono); font-family: var(--font-mono);
} }
.gps-skyview-canvas-wrap.gps-sky-fallback .gps-sky-globe {
display: none;
}
.gps-skyview-canvas-wrap.gps-sky-fallback #gpsSkyCanvas,
.gps-skyview-canvas-wrap.gps-sky-fallback .gps-sky-overlay {
display: block;
}
.gps-globe-sat-icon {
--sat-size: 18px;
--sat-color: #8ea6bd;
width: var(--sat-size);
height: var(--sat-size);
transform: translate(-50%, -50%);
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
border: 1px solid var(--sat-color);
background: radial-gradient(circle at 35% 30%, rgba(255, 255, 255, 0.22), rgba(7, 14, 23, 0.82) 72%);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35), 0 0 12px var(--sat-color);
}
.gps-globe-sat-icon img {
width: 76%;
height: 76%;
object-fit: contain;
}
.gps-globe-sat-icon.used {
opacity: 0.98;
}
.gps-globe-sat-icon.unused {
opacity: 0.72;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35), 0 0 6px var(--sat-color);
}
.gps-sky-label { .gps-sky-label {
position: absolute; position: absolute;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
+6 -6
View File
@@ -303,7 +303,7 @@
.mesh-strip-btn.disconnect { .mesh-strip-btn.disconnect {
background: var(--accent-red, #ff3366); background: var(--accent-red, #ff3366);
color: white; color: var(--text-inverse);
} }
.mesh-strip-btn.disconnect:hover { .mesh-strip-btn.disconnect:hover {
@@ -478,7 +478,7 @@
0 2px 8px rgba(0, 0, 0, 0.6), 0 2px 8px rgba(0, 0, 0, 0.6),
0 0 20px 8px rgba(0, 255, 255, 0.7), /* Strong outer glow */ 0 0 20px 8px rgba(0, 255, 255, 0.7), /* Strong outer glow */
inset 0 0 8px rgba(255, 255, 255, 0.3); /* Inner highlight */ inset 0 0 8px rgba(255, 255, 255, 0.3); /* Inner highlight */
color: #000; color: var(--text-inverse);
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 11px; font-size: 11px;
font-weight: bold; font-weight: bold;
@@ -1088,7 +1088,7 @@
border-radius: 4px; border-radius: 4px;
padding: 10px 14px; padding: 10px 14px;
cursor: pointer; cursor: pointer;
color: #000; color: var(--text-inverse);
transition: all 0.15s ease; transition: all 0.15s ease;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1231,7 +1231,7 @@
background: var(--accent-cyan); background: var(--accent-cyan);
border: none; border: none;
border-radius: 4px; border-radius: 4px;
color: #000; color: var(--text-inverse);
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
@@ -1413,7 +1413,7 @@
.mesh-position-btn:hover, .mesh-position-btn:hover,
.mesh-telemetry-btn:hover { .mesh-telemetry-btn:hover {
background: var(--accent-cyan); background: var(--accent-cyan);
color: #000; color: var(--text-inverse);
border-color: var(--accent-cyan); border-color: var(--accent-cyan);
} }
@@ -1435,7 +1435,7 @@
.mesh-qr-btn:hover { .mesh-qr-btn:hover {
background: var(--accent-cyan); background: var(--accent-cyan);
color: #000; color: var(--text-inverse);
border-color: var(--accent-cyan); border-color: var(--accent-cyan);
} }
+473
View File
@@ -0,0 +1,473 @@
/* Meteor Scatter Mode Styles */
.meteor-visuals-container {
--ms-border: rgba(92, 255, 170, 0.24);
--ms-surface: linear-gradient(180deg, rgba(12, 19, 31, 0.97) 0%, rgba(5, 9, 17, 0.98) 100%);
--ms-accent: #6bffb8;
--ms-accent-dim: rgba(107, 255, 184, 0.13);
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
background: radial-gradient(circle at 14% -18%, rgba(107, 255, 184, 0.15) 0%, rgba(107, 255, 184, 0) 38%),
radial-gradient(circle at 86% -26%, rgba(255, 200, 54, 0.12) 0%, rgba(255, 200, 54, 0) 36%),
#03070f;
border: 1px solid var(--ms-border);
border-radius: 10px;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.03), 0 10px 34px rgba(2, 8, 22, 0.55);
position: relative;
}
/* ── Headline Bar ── */
.ms-headline {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
padding: 8px 12px;
background: rgba(8, 14, 25, 0.86);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
flex-shrink: 0;
}
.ms-headline-left,
.ms-headline-right {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.ms-headline-tag {
border-radius: 999px;
padding: 1px 8px;
border: 1px solid rgba(107, 255, 184, 0.45);
background: var(--ms-accent-dim);
color: var(--ms-accent);
font-size: 10px;
font-family: var(--font-mono, monospace);
letter-spacing: 0.06em;
text-transform: uppercase;
white-space: nowrap;
}
.ms-headline-tag.idle {
border-color: rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.06);
color: var(--text-dim, #667);
}
.ms-headline-tag.detecting {
border-color: rgba(255, 215, 0, 0.5);
background: rgba(255, 215, 0, 0.12);
color: #ffd700;
animation: ms-pulse 1s ease-in-out infinite;
}
.ms-headline-sub {
font-size: 11px;
color: var(--text-dim);
font-family: var(--font-mono, monospace);
white-space: nowrap;
text-transform: uppercase;
letter-spacing: 0.08em;
}
/* ── Stats Strip ── */
.ms-stats-strip {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 8px;
padding: 8px 12px;
background: var(--ms-surface);
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
flex-shrink: 0;
transition: box-shadow 0.3s ease;
}
.ms-stat-cell {
display: flex;
flex-direction: column;
gap: 2px;
padding: 4px 8px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 6px;
}
.ms-stat-label {
font-size: 9px;
color: var(--text-dim, #667);
font-family: var(--font-mono, monospace);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.ms-stat-value {
font-size: 13px;
color: var(--text-primary, #eee);
font-family: var(--font-mono, monospace);
font-weight: 600;
}
.ms-stat-value.highlight {
color: var(--ms-accent);
}
/* ── Canvas Areas ── */
.ms-spectrum-wrap {
position: relative;
height: 80px;
flex-shrink: 0;
background: rgba(0, 0, 0, 0.3);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
overflow: hidden;
}
.ms-spectrum-wrap canvas {
width: 100%;
height: 100%;
display: block;
}
.ms-waterfall-wrap {
position: relative;
flex: 1;
min-height: 200px;
background: #000;
overflow: hidden;
}
.ms-waterfall-wrap canvas {
width: 100%;
height: 100%;
display: block;
}
/* Starfield canvas behind the waterfall */
.ms-starfield-canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 1;
opacity: 0.6;
}
.ms-timeline-wrap {
position: relative;
height: 60px;
flex-shrink: 0;
background: rgba(0, 0, 0, 0.3);
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.ms-timeline-wrap canvas {
width: 100%;
height: 100%;
display: block;
}
/* ── Events Panel ── */
.ms-events-panel {
flex-shrink: 0;
max-height: 200px;
overflow: hidden;
display: flex;
flex-direction: column;
background: rgba(8, 14, 25, 0.9);
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.ms-events-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 12px;
background: rgba(255, 255, 255, 0.03);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.ms-events-title {
font-size: 10px;
color: var(--text-dim, #667);
font-family: var(--font-mono, monospace);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.ms-events-count {
font-size: 10px;
color: var(--ms-accent);
font-family: var(--font-mono, monospace);
}
.ms-events-scroll {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
.ms-events-table {
width: 100%;
border-collapse: collapse;
font-family: var(--font-mono, monospace);
font-size: 10px;
}
.ms-events-table thead {
position: sticky;
top: 0;
z-index: 1;
}
.ms-events-table th {
padding: 4px 8px;
text-align: left;
color: var(--text-dim, #667);
font-weight: 500;
background: rgba(8, 14, 25, 0.95);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 9px;
}
.ms-events-table td {
padding: 3px 8px;
color: var(--text-secondary, #aab);
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
}
.ms-events-table tr:hover td {
background: rgba(107, 255, 184, 0.04);
}
.ms-events-table .ms-snr-strong {
color: var(--ms-accent);
font-weight: 600;
}
.ms-events-table .ms-snr-moderate {
color: #ffd782;
}
.ms-events-table .ms-snr-weak {
color: var(--text-dim, #667);
}
.ms-tag {
display: inline-block;
padding: 0 4px;
border-radius: 3px;
font-size: 9px;
margin-right: 3px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.04);
color: var(--text-dim, #667);
}
.ms-tag.strong {
border-color: rgba(107, 255, 184, 0.3);
background: rgba(107, 255, 184, 0.08);
color: var(--ms-accent);
}
.ms-tag.moderate {
border-color: rgba(255, 215, 130, 0.3);
background: rgba(255, 215, 130, 0.08);
color: #ffd782;
}
/* ── Ping Highlight Animation (Enhanced) ── */
@keyframes ms-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes ms-ping-flash {
0% {
box-shadow: inset 0 0 30px rgba(107, 255, 184, 0.4),
0 0 15px rgba(107, 255, 184, 0.2);
border-color: rgba(107, 255, 184, 0.6);
}
50% {
box-shadow: inset 0 0 10px rgba(107, 255, 184, 0.15),
0 0 5px rgba(107, 255, 184, 0.08);
border-color: rgba(107, 255, 184, 0.35);
}
100% {
box-shadow: inset 0 0 0 rgba(107, 255, 184, 0),
0 0 0 rgba(107, 255, 184, 0);
border-color: var(--ms-border);
}
}
.ms-ping-flash {
animation: ms-ping-flash 0.7s ease-out;
}
/* Stats strip glow on detection */
@keyframes ms-stats-glow {
0% {
box-shadow: inset 0 0 20px rgba(107, 255, 184, 0.15);
}
100% {
box-shadow: inset 0 0 0 rgba(107, 255, 184, 0);
}
}
.ms-stats-glow {
animation: ms-stats-glow 0.6s ease-out;
}
/* Ping counter bounce */
@keyframes ms-counter-bounce {
0% { transform: scale(1); }
30% { transform: scale(1.35); color: #fff; }
60% { transform: scale(0.95); }
100% { transform: scale(1); }
}
.ms-counter-bounce {
animation: ms-counter-bounce 0.4s ease-out;
display: inline-block;
}
/* ── Particle Burst ── */
@keyframes ms-particle-burst {
0% {
opacity: 1;
transform: translate(0, 0) scale(1);
}
100% {
opacity: 0;
transform: translate(var(--dx, 30px), var(--dy, -30px)) scale(0.3);
}
}
.ms-particle {
position: absolute;
border-radius: 50%;
background: var(--ms-accent);
box-shadow: 0 0 4px var(--ms-accent), 0 0 8px rgba(107, 255, 184, 0.3);
pointer-events: none;
z-index: 5;
animation: ms-particle-burst 0.5s ease-out forwards;
}
/* ── Signal Meter ── */
.ms-signal-meter {
display: flex;
align-items: center;
gap: 4px;
}
.ms-signal-meter-label {
font-size: 8px;
color: var(--text-dim, #667);
font-family: var(--font-mono, monospace);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.ms-signal-meter-bars {
display: flex;
align-items: flex-end;
gap: 1px;
height: 16px;
}
.ms-signal-bar {
width: 3px;
background: rgba(255, 255, 255, 0.12);
border-radius: 1px;
transition: background 0.1s ease, height 0.1s ease;
}
.ms-signal-bar[data-idx="0"] { height: 4px; }
.ms-signal-bar[data-idx="1"] { height: 6px; }
.ms-signal-bar[data-idx="2"] { height: 7px; }
.ms-signal-bar[data-idx="3"] { height: 9px; }
.ms-signal-bar[data-idx="4"] { height: 10px; }
.ms-signal-bar[data-idx="5"] { height: 12px; }
.ms-signal-bar[data-idx="6"] { height: 14px; }
.ms-signal-bar[data-idx="7"] { height: 16px; }
.ms-signal-bar.active {
background: var(--ms-accent);
box-shadow: 0 0 3px rgba(107, 255, 184, 0.4);
}
.ms-signal-bar.active[data-idx="5"],
.ms-signal-bar.active[data-idx="6"],
.ms-signal-bar.active[data-idx="7"] {
background: #ffd700;
box-shadow: 0 0 3px rgba(255, 215, 0, 0.4);
}
.ms-signal-bar.peak {
background: rgba(255, 100, 100, 0.7);
box-shadow: 0 0 3px rgba(255, 100, 100, 0.3);
}
/* ── Empty State ── */
.ms-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
gap: 12px;
color: var(--text-dim, #667);
font-family: var(--font-mono, monospace);
font-size: 12px;
text-align: center;
padding: 40px;
}
.ms-empty-state .ms-empty-icon {
font-size: 40px;
opacity: 0.4;
}
.ms-empty-state .ms-empty-text {
font-size: 11px;
opacity: 0.6;
max-width: 280px;
line-height: 1.5;
}
/* ── Responsive ── */
@media (max-width: 900px) {
.ms-stats-strip {
grid-template-columns: repeat(3, 1fr);
}
.ms-events-panel {
max-height: 150px;
}
}
@media (max-width: 600px) {
.ms-stats-strip {
grid-template-columns: repeat(2, 1fr);
}
.ms-spectrum-wrap {
height: 60px;
}
.ms-timeline-wrap {
height: 40px;
}
}
+201
View File
@@ -0,0 +1,201 @@
/* Morse Code / CW Decoder Styles */
.morse-mode-help,
.morse-help-text {
font-size: 11px;
color: var(--text-dim);
}
.morse-help-text {
margin-top: 4px;
display: block;
}
.morse-hf-note {
font-size: 11px;
color: #ffaa00;
line-height: 1.5;
}
.morse-presets {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.morse-actions-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.morse-file-row {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.morse-file-row input[type='file'] {
width: 100%;
max-width: 100%;
}
.morse-status {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--text-dim);
}
.morse-status #morseCharCount {
margin-left: auto;
}
.morse-ref-grid {
transition: max-height 0.3s ease, opacity 0.3s ease;
max-height: 560px;
opacity: 1;
overflow: hidden;
font-family: var(--font-mono);
font-size: 10px;
line-height: 1.8;
columns: 2;
column-gap: 12px;
color: var(--text-dim);
}
.morse-ref-grid.collapsed {
max-height: 0;
opacity: 0;
}
.morse-ref-toggle {
font-size: 10px;
color: var(--text-dim);
}
.morse-ref-divider {
margin-top: 4px;
border-top: 1px solid var(--border-color);
padding-top: 4px;
}
.morse-decoded-panel {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 12px;
min-height: 120px;
max-height: 400px;
overflow-y: auto;
font-family: var(--font-mono);
font-size: 18px;
line-height: 1.6;
color: var(--text-primary);
word-wrap: break-word;
}
.morse-decoded-panel:empty::before {
content: 'Decoded text will appear here...';
color: var(--text-dim);
font-size: 14px;
font-style: italic;
}
.morse-char {
display: inline;
animation: morseFadeIn 0.3s ease-out;
position: relative;
}
@keyframes morseFadeIn {
from {
opacity: 0;
color: var(--accent-cyan);
}
to {
opacity: 1;
color: var(--text-primary);
}
}
.morse-word-space {
display: inline;
width: 0.5em;
}
.morse-raw-panel {
margin-top: 8px;
padding: 8px;
border: 1px solid #1a1a2e;
border-radius: 4px;
background: #080812;
}
.morse-raw-label {
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #667;
margin-bottom: 4px;
}
.morse-raw-text {
min-height: 30px;
max-height: 90px;
overflow-y: auto;
font-family: var(--font-mono);
font-size: 11px;
color: #8fd0ff;
white-space: pre-wrap;
word-break: break-word;
}
.morse-metrics-panel {
margin-top: 8px;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
font-size: 10px;
color: #7a8694;
}
.morse-metrics-panel span {
padding: 4px 6px;
border-radius: 4px;
border: 1px solid #1a1a2e;
background: #080811;
font-family: var(--font-mono);
}
.morse-status-bar {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 11px;
color: var(--text-dim);
padding: 6px 0;
border-top: 1px solid var(--border-color);
margin-top: 8px;
gap: 8px;
flex-wrap: wrap;
}
.morse-status-bar .status-item {
display: flex;
align-items: center;
gap: 4px;
}
@media (max-width: 768px) {
.morse-metrics-panel {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.morse-file-row {
flex-direction: column;
align-items: stretch;
}
}
+110
View File
@@ -0,0 +1,110 @@
/* OOK Signal Decoder Styles */
.ook-presets {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.ook-preset-add {
margin-top: 6px;
display: flex;
gap: 4px;
}
.ook-preset-add input {
width: 80px;
background: var(--bg-tertiary, #111);
border: 1px solid var(--border-color, #222);
border-radius: 3px;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 11px;
padding: 3px 6px;
}
.ook-preset-hint {
font-size: 9px;
color: var(--text-muted, #555);
}
.ook-encoding-btns {
display: flex;
gap: 4px;
}
.ook-encoding-btns .preset-btn {
flex: 1;
}
.ook-timing-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.ook-timing-presets {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 4px;
}
.ook-status-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--text-dim);
}
.ook-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-dim);
}
.ook-warning {
font-size: 11px;
color: #ffaa00;
line-height: 1.5;
}
.ook-command-display {
display: none;
margin-top: 8px;
}
.ook-command-label {
font-size: 10px;
color: var(--text-muted, #555);
text-transform: uppercase;
letter-spacing: 1px;
}
.ook-command-text {
margin: 0;
padding: 6px 8px;
background: var(--bg-deep, #0a0a0a);
border: 1px solid var(--border-color, #1a2e1a);
border-radius: 4px;
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-dim);
white-space: pre-wrap;
word-break: break-all;
line-height: 1.5;
}
.ook-dedup-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.ook-dedup-label input[type="checkbox"] {
width: auto;
margin: 0;
}
+233
View File
@@ -0,0 +1,233 @@
/* ============================================
RADIOSONDE MODE Scoped Styles
============================================ */
/* Visuals container */
.radiosonde-visuals-container {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
min-height: 0;
overflow: hidden;
padding: 8px;
}
/* Map container */
#radiosondeMapContainer {
flex: 1;
min-height: 300px;
border-radius: 6px;
border: 1px solid var(--border-color);
background: var(--bg-primary);
}
/* Card container below map */
.radiosonde-card-container {
display: flex;
flex-wrap: wrap;
gap: 8px;
max-height: 200px;
overflow-y: auto;
padding: 4px 0;
}
/* Individual balloon card */
.radiosonde-card {
background: var(--bg-card, #1a1e2e);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 10px 12px;
cursor: pointer;
flex: 1 1 280px;
min-width: 260px;
max-width: 400px;
transition: border-color 0.2s ease, background 0.2s ease;
}
.radiosonde-card:hover {
border-color: var(--accent-cyan);
background: rgba(0, 204, 255, 0.04);
}
.radiosonde-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 1px solid var(--border-color);
}
.radiosonde-serial {
font-family: var(--font-mono, 'JetBrains Mono', monospace);
font-size: 13px;
font-weight: 600;
color: var(--accent-cyan);
letter-spacing: 0.5px;
}
.radiosonde-type {
font-family: var(--font-mono, 'JetBrains Mono', monospace);
font-size: 10px;
font-weight: 600;
color: var(--text-dim);
background: rgba(255, 255, 255, 0.06);
padding: 2px 6px;
border-radius: 3px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Telemetry stat grid */
.radiosonde-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
}
.radiosonde-stat {
display: flex;
flex-direction: column;
align-items: center;
padding: 4px;
}
.radiosonde-stat-value {
font-family: var(--font-mono, 'JetBrains Mono', monospace);
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
}
.radiosonde-stat-label {
font-size: 9px;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.8px;
margin-top: 2px;
}
/* Leaflet popup overrides for radiosonde */
#radiosondeMapContainer .leaflet-popup-content-wrapper {
background: var(--bg-card, #1a1e2e);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
font-family: var(--font-mono, 'JetBrains Mono', monospace);
font-size: 11px;
}
#radiosondeMapContainer .leaflet-popup-tip {
background: var(--bg-card, #1a1e2e);
border: 1px solid var(--border-color);
}
/* Scrollbar for card container */
.radiosonde-card-container::-webkit-scrollbar {
width: 4px;
}
.radiosonde-card-container::-webkit-scrollbar-track {
background: transparent;
}
.radiosonde-card-container::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 2px;
}
/* Stats strip above map */
.radiosonde-strip {
background: linear-gradient(180deg, var(--bg-panel) 0%, var(--bg-dark) 100%);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 6px 12px;
margin-bottom: 8px;
overflow-x: auto;
flex-shrink: 0;
}
.radiosonde-strip-inner {
display: flex;
align-items: center;
gap: 8px;
min-width: max-content;
}
.radiosonde-strip .strip-stat {
display: flex;
flex-direction: column;
align-items: center;
padding: 4px 10px;
background: rgba(0, 229, 255, 0.05);
border: 1px solid rgba(0, 229, 255, 0.15);
border-radius: 4px;
min-width: 55px;
}
.radiosonde-strip .strip-stat:hover {
background: rgba(0, 229, 255, 0.1);
border-color: rgba(0, 229, 255, 0.3);
}
.radiosonde-strip .strip-value {
font-family: var(--font-mono);
font-size: 14px;
font-weight: 600;
color: var(--accent-cyan);
line-height: 1.2;
}
.radiosonde-strip .strip-label {
font-size: 8px;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 1px;
}
.radiosonde-strip .strip-divider {
width: 1px;
height: 28px;
background: var(--border-color);
margin: 0 4px;
}
.radiosonde-strip .strip-status {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
font-size: 10px;
font-weight: 600;
color: var(--text-secondary);
}
.radiosonde-strip .status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-muted);
}
.radiosonde-strip .status-dot.tracking {
background: var(--accent-green);
animation: radiosonde-strip-pulse 1.5s ease-in-out infinite;
}
@keyframes radiosonde-strip-pulse {
0%, 100% { opacity: 1; box-shadow: 0 0 4px 2px currentColor; }
50% { opacity: 0.6; box-shadow: none; }
}
.radiosonde-strip .strip-waveform {
display: flex;
align-items: center;
}
/* Responsive: stack cards on narrow screens */
@media (max-width: 600px) {
.radiosonde-card {
flex: 1 1 100%;
max-width: 100%;
}
.radiosonde-stats {
grid-template-columns: repeat(2, 1fr);
}
}
+14 -11
View File
@@ -51,10 +51,10 @@
} }
.sw-header-value.accent-cyan { color: var(--accent-cyan); } .sw-header-value.accent-cyan { color: var(--accent-cyan); }
.sw-header-value.accent-green { color: #00ff88; } .sw-header-value.accent-green { color: var(--neon-green); }
.sw-header-value.accent-yellow { color: #ffcc00; } .sw-header-value.accent-yellow { color: var(--neon-yellow); }
.sw-header-value.accent-orange { color: #ff8800; } .sw-header-value.accent-orange { color: var(--neon-orange); }
.sw-header-value.accent-red { color: #ff3366; } .sw-header-value.accent-red { color: var(--neon-red); }
/* Refresh controls in strip */ /* Refresh controls in strip */
.sw-strip-controls { .sw-strip-controls {
@@ -126,12 +126,15 @@
} }
/* Scale severity colors */ /* Scale severity colors */
.sw-scale-0 { color: #00ff88; border-color: #00ff8833; } .sw-scale-0 { color: var(--neon-green); border-color: #00ff8833; }
.sw-scale-1 { color: #88ff00; border-color: #88ff0033; } .sw-scale-1 { color: #88ff00; border-color: #88ff0033; }
.sw-scale-2 { color: #ffcc00; border-color: #ffcc0033; } .sw-scale-2 { color: var(--neon-yellow); border-color: #ffcc0033; }
.sw-scale-3 { color: #ff8800; border-color: #ff880033; } .sw-scale-3 { color: var(--neon-orange); border-color: #ff880033; }
.sw-scale-4 { color: #ff4400; border-color: #ff440033; } .sw-scale-4 { color: #ff4400; border-color: #ff440033; }
.sw-scale-5 { color: #ff0044; border-color: #ff004433; } .sw-scale-5 { color: var(--neon-red); border-color: #ff004433; }
[data-theme="light"] .sw-scale-1 { color: #5a8a00; }
[data-theme="light"] .sw-scale-4 { color: #cc3300; }
/* HF Band conditions grid */ /* HF Band conditions grid */
.sw-band-panel { .sw-band-panel {
@@ -180,9 +183,9 @@
font-weight: 600; font-weight: 600;
} }
.sw-band-good { color: #00ff88; background: #00ff8815; } .sw-band-good { color: var(--neon-green); background: #00ff8815; }
.sw-band-fair { color: #ffcc00; background: #ffcc0015; } .sw-band-fair { color: var(--neon-yellow); background: #ffcc0015; }
.sw-band-poor { color: #ff3366; background: #ff336615; } .sw-band-poor { color: var(--neon-red); background: #ff336615; }
/* 2-column dashboard grid */ /* 2-column dashboard grid */
.sw-dashboard-grid { .sw-dashboard-grid {
+2 -2
View File
@@ -264,7 +264,7 @@
.spy-freq-clickable:hover { .spy-freq-clickable:hover {
background: var(--accent-cyan); background: var(--accent-cyan);
color: #000; color: var(--text-inverse);
border-color: var(--accent-cyan); border-color: var(--accent-cyan);
} }
@@ -278,7 +278,7 @@
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.03em; letter-spacing: 0.03em;
color: #000; color: var(--text-inverse);
background: var(--accent-green); background: var(--accent-green);
border: none; border: none;
padding: 8px 14px; padding: 8px 14px;
+1 -1
View File
@@ -105,7 +105,7 @@
.sstv-general-strip-btn.stop { .sstv-general-strip-btn.stop {
background: var(--accent-red, #ff3366); background: var(--accent-red, #ff3366);
color: white; color: var(--text-inverse);
} }
.sstv-general-strip-btn.stop:hover { .sstv-general-strip-btn.stop:hover {
+3 -3
View File
@@ -117,7 +117,7 @@
.sstv-strip-btn.stop { .sstv-strip-btn.stop {
background: var(--accent-red, #ff3366); background: var(--accent-red, #ff3366);
color: white; color: var(--text-inverse);
} }
.sstv-strip-btn.stop:hover { .sstv-strip-btn.stop:hover {
@@ -192,7 +192,7 @@
.sstv-strip-btn.gps:hover { .sstv-strip-btn.gps:hover {
background: var(--accent-green); background: var(--accent-green);
color: #000; color: var(--text-inverse);
border-color: var(--accent-green); border-color: var(--accent-green);
} }
@@ -207,7 +207,7 @@
.sstv-strip-btn.update-tle:hover { .sstv-strip-btn.update-tle:hover {
background: var(--accent-orange); background: var(--accent-orange);
color: #000; color: var(--text-inverse);
border-color: var(--accent-orange); border-color: var(--accent-orange);
} }
+38 -38
View File
@@ -25,7 +25,7 @@
} }
.subghz-device-dot.connected { .subghz-device-dot.connected {
background: #00ff88; background: var(--neon-green);
box-shadow: 0 0 6px rgba(0, 255, 136, 0.4); box-shadow: 0 0 6px rgba(0, 255, 136, 0.4);
} }
@@ -34,7 +34,7 @@
} }
.subghz-device-dot.unknown { .subghz-device-dot.unknown {
background: #ffaa00; background: var(--neon-orange);
} }
.subghz-device-label { .subghz-device-label {
@@ -62,7 +62,7 @@
.subghz-tool-badge.available { .subghz-tool-badge.available {
border-color: rgba(0, 255, 136, 0.3); border-color: rgba(0, 255, 136, 0.3);
color: #00ff88; color: var(--neon-green);
background: rgba(0, 255, 136, 0.05); background: rgba(0, 255, 136, 0.05);
} }
@@ -94,7 +94,7 @@
.subghz-preset-btn:hover { .subghz-preset-btn:hover {
background: var(--accent-cyan, #00d4ff); background: var(--accent-cyan, #00d4ff);
color: #000; color: var(--text-inverse);
border-color: var(--accent-cyan, #00d4ff); border-color: var(--accent-cyan, #00d4ff);
} }
@@ -220,7 +220,7 @@
} }
.subghz-status-dot.rx { .subghz-status-dot.rx {
background: #00ff88; background: var(--neon-green);
animation: subghz-pulse 1.5s ease-in-out infinite; animation: subghz-pulse 1.5s ease-in-out infinite;
} }
@@ -235,7 +235,7 @@
} }
.subghz-status-dot.sweep { .subghz-status-dot.sweep {
background: #ffaa00; background: var(--neon-orange);
animation: subghz-pulse 1s ease-in-out infinite; animation: subghz-pulse 1s ease-in-out infinite;
} }
@@ -308,7 +308,7 @@
.subghz-btn.start { .subghz-btn.start {
background: var(--accent-green, #22c55e); background: var(--accent-green, #22c55e);
border-color: var(--accent-green, #22c55e); border-color: var(--accent-green, #22c55e);
color: #fff; color: var(--text-inverse);
font-weight: 600; font-weight: 600;
} }
@@ -321,7 +321,7 @@
.subghz-btn.stop { .subghz-btn.stop {
background: var(--accent-red, #ff4444); background: var(--accent-red, #ff4444);
border-color: var(--accent-red, #ff4444); border-color: var(--accent-red, #ff4444);
color: #fff; color: var(--text-inverse);
font-weight: 600; font-weight: 600;
} }
@@ -405,7 +405,7 @@
padding: 1px 6px; padding: 1px 6px;
border-radius: 999px; border-radius: 999px;
border: 1px solid rgba(255, 170, 0, 0.55); border: 1px solid rgba(255, 170, 0, 0.55);
color: #ffaa00; color: var(--neon-orange);
font-size: 9px; font-size: 9px;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.4px; letter-spacing: 0.4px;
@@ -423,14 +423,14 @@
} }
.subghz-capture-burst-flag { .subghz-capture-burst-flag {
color: #ffaa00; color: var(--neon-orange);
} }
.subghz-capture-burst-count { .subghz-capture-burst-count {
padding: 1px 6px; padding: 1px 6px;
border-radius: 999px; border-radius: 999px;
border: 1px solid rgba(255, 170, 0, 0.55); border: 1px solid rgba(255, 170, 0, 0.55);
color: #ffaa00; color: var(--neon-orange);
background: rgba(255, 170, 0, 0.12); background: rgba(255, 170, 0, 0.12);
} }
@@ -462,7 +462,7 @@
.subghz-capture-tag.hint { .subghz-capture-tag.hint {
border-color: rgba(0, 255, 136, 0.5); border-color: rgba(0, 255, 136, 0.5);
color: #00ff88; color: var(--neon-green);
background: rgba(0, 255, 136, 0.12); background: rgba(0, 255, 136, 0.12);
} }
@@ -892,7 +892,7 @@
.subghz-tx-confirm-btn { .subghz-tx-confirm-btn {
background: var(--accent-red, #ff4444); background: var(--accent-red, #ff4444);
color: #fff; color: var(--text-inverse);
border-color: var(--accent-red, #ff4444) !important; border-color: var(--accent-red, #ff4444) !important;
} }
@@ -956,7 +956,7 @@
} }
.subghz-sweep-tooltip .tip-power { .subghz-sweep-tooltip .tip-power {
color: #ffaa00; color: var(--neon-orange);
} }
/* Right-click context menu */ /* Right-click context menu */
@@ -1038,8 +1038,8 @@
.subghz-action-btn.tune:hover { .subghz-action-btn.tune:hover {
background: rgba(0, 255, 136, 0.12); background: rgba(0, 255, 136, 0.12);
border-color: #00ff88; border-color: var(--neon-green);
color: #00ff88; color: var(--neon-green);
} }
.subghz-action-btn.decode:hover { .subghz-action-btn.decode:hover {
@@ -1050,8 +1050,8 @@
.subghz-action-btn.capture:hover { .subghz-action-btn.capture:hover {
background: rgba(255, 170, 0, 0.12); background: rgba(255, 170, 0, 0.12);
border-color: #ffaa00; border-color: var(--neon-orange);
color: #ffaa00; color: var(--neon-orange);
} }
/* Peak list in sidebar */ /* Peak list in sidebar */
@@ -1088,7 +1088,7 @@
} }
.subghz-peak-item:hover { .subghz-peak-item:hover {
border-color: #ffaa00; border-color: var(--neon-orange);
} }
.subghz-peak-item .peak-freq { .subghz-peak-item .peak-freq {
@@ -1096,7 +1096,7 @@
} }
.subghz-peak-item .peak-power { .subghz-peak-item .peak-power {
color: #ffaa00; color: var(--neon-orange);
} }
/* ===== Stats Strip ===== */ /* ===== Stats Strip ===== */
@@ -1147,10 +1147,10 @@
animation: subghz-pulse 1.5s ease-in-out infinite; animation: subghz-pulse 1.5s ease-in-out infinite;
} }
.subghz-strip-dot.rx { background: #00ff88; } .subghz-strip-dot.rx { background: var(--neon-green); }
.subghz-strip-dot.decode { background: #00d4ff; } .subghz-strip-dot.decode { background: #00d4ff; }
.subghz-strip-dot.tx { background: #ff4444; } .subghz-strip-dot.tx { background: #ff4444; }
.subghz-strip-dot.sweep { background: #ffaa00; } .subghz-strip-dot.sweep { background: var(--neon-orange); }
.subghz-strip-status-text { .subghz-strip-status-text {
color: var(--text-secondary, #999); color: var(--text-secondary, #999);
@@ -1170,8 +1170,8 @@
} }
.subghz-strip-value.accent-cyan { color: var(--accent-cyan, #00d4ff); } .subghz-strip-value.accent-cyan { color: var(--accent-cyan, #00d4ff); }
.subghz-strip-value.accent-green { color: #00ff88; } .subghz-strip-value.accent-green { color: var(--neon-green); }
.subghz-strip-value.accent-orange { color: #ffaa00; } .subghz-strip-value.accent-orange { color: var(--neon-orange); }
.subghz-strip-label { .subghz-strip-label {
color: var(--text-dim, #666); color: var(--text-dim, #666);
@@ -1225,7 +1225,7 @@
} }
.subghz-strip-device-dot.connected { .subghz-strip-device-dot.connected {
background: #00ff88; background: var(--neon-green);
} }
.subghz-strip-device-dot.disconnected { .subghz-strip-device-dot.disconnected {
@@ -1233,7 +1233,7 @@
} }
.subghz-strip-device-dot.unknown { .subghz-strip-device-dot.unknown {
background: #ffaa00; background: var(--neon-orange);
} }
/* ===== Signal Console ===== */ /* ===== Signal Console ===== */
@@ -1279,7 +1279,7 @@
} }
.subghz-phase-step.completed { .subghz-phase-step.completed {
color: #00ff88; color: var(--neon-green);
} }
.subghz-phase-step.error { .subghz-phase-step.error {
@@ -1317,12 +1317,12 @@
.subghz-burst-indicator.active { .subghz-burst-indicator.active {
border-color: rgba(255, 170, 0, 0.6); border-color: rgba(255, 170, 0, 0.6);
color: #ffaa00; color: var(--neon-orange);
background: rgba(255, 170, 0, 0.12); background: rgba(255, 170, 0, 0.12);
} }
.subghz-burst-indicator.active .subghz-burst-dot { .subghz-burst-indicator.active .subghz-burst-dot {
background: #ffaa00; background: var(--neon-orange);
box-shadow: 0 0 10px rgba(255, 170, 0, 0.7); box-shadow: 0 0 10px rgba(255, 170, 0, 0.7);
animation: subghz-pulse 0.45s ease-in-out infinite; animation: subghz-pulse 0.45s ease-in-out infinite;
} }
@@ -1383,8 +1383,8 @@
.subghz-log-msg { color: var(--text-secondary, #999); } .subghz-log-msg { color: var(--text-secondary, #999); }
.subghz-log-msg.info { color: var(--accent-cyan, #00d4ff); } .subghz-log-msg.info { color: var(--accent-cyan, #00d4ff); }
.subghz-log-msg.success { color: #00ff88; } .subghz-log-msg.success { color: var(--neon-green); }
.subghz-log-msg.warn { color: #ffaa00; } .subghz-log-msg.warn { color: var(--neon-orange); }
.subghz-log-msg.error { color: var(--accent-red, #ff4444); } .subghz-log-msg.error { color: var(--accent-red, #ff4444); }
/* ===== Action Hub ===== */ /* ===== Action Hub ===== */
@@ -1450,12 +1450,12 @@
.subghz-hub-card--cyan .subghz-hub-icon { color: var(--accent-cyan, #00d4ff); } .subghz-hub-card--cyan .subghz-hub-icon { color: var(--accent-cyan, #00d4ff); }
.subghz-hub-card--green { border-color: rgba(0, 255, 136, 0.2); } .subghz-hub-card--green { border-color: rgba(0, 255, 136, 0.2); }
.subghz-hub-card--green:hover { border-color: #00ff88; background: rgba(0, 255, 136, 0.05); } .subghz-hub-card--green:hover { border-color: var(--neon-green); background: rgba(0, 255, 136, 0.05); }
.subghz-hub-card--green .subghz-hub-icon { color: #00ff88; } .subghz-hub-card--green .subghz-hub-icon { color: var(--neon-green); }
.subghz-hub-card--orange { border-color: rgba(255, 170, 0, 0.2); } .subghz-hub-card--orange { border-color: rgba(255, 170, 0, 0.2); }
.subghz-hub-card--orange:hover { border-color: #ffaa00; background: rgba(255, 170, 0, 0.05); } .subghz-hub-card--orange:hover { border-color: var(--neon-orange); background: rgba(255, 170, 0, 0.05); }
.subghz-hub-card--orange .subghz-hub-icon { color: #ffaa00; } .subghz-hub-card--orange .subghz-hub-icon { color: var(--neon-orange); }
.subghz-hub-card--red { border-color: rgba(255, 68, 68, 0.25); } .subghz-hub-card--red { border-color: rgba(255, 68, 68, 0.25); }
.subghz-hub-card--red:hover { border-color: #ff4444; background: rgba(255, 68, 68, 0.08); } .subghz-hub-card--red:hover { border-color: #ff4444; background: rgba(255, 68, 68, 0.08); }
@@ -1707,7 +1707,7 @@
.subghz-rx-hint-confidence { .subghz-rx-hint-confidence {
font-size: 10px; font-size: 10px;
color: #00ff88; color: var(--neon-green);
border: 1px solid rgba(0, 255, 136, 0.35); border: 1px solid rgba(0, 255, 136, 0.35);
border-radius: 999px; border-radius: 999px;
padding: 1px 8px; padding: 1px 8px;
@@ -1729,7 +1729,7 @@
} }
.subghz-rx-burst-pill.active { .subghz-rx-burst-pill.active {
color: #ffaa00; color: var(--neon-orange);
border-color: rgba(255, 170, 0, 0.7); border-color: rgba(255, 170, 0, 0.7);
background: rgba(255, 170, 0, 0.15); background: rgba(255, 170, 0, 0.15);
} }
@@ -1866,7 +1866,7 @@
} }
.subghz-wf-pause-btn.paused { .subghz-wf-pause-btn.paused {
color: #ffaa00; color: var(--neon-orange);
border-color: rgba(255, 170, 0, 0.6); border-color: rgba(255, 170, 0, 0.6);
} }
+579
View File
@@ -0,0 +1,579 @@
/* System Health Mode Styles — Enhanced Dashboard */
.sys-dashboard {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
padding: 16px;
width: 100%;
box-sizing: border-box;
}
/* Group headers span full width */
.sys-group-header {
grid-column: 1 / -1;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--accent-cyan, #00d4ff);
border-bottom: 1px solid rgba(0, 212, 255, 0.2);
padding-bottom: 6px;
margin-top: 8px;
}
.sys-group-header:first-child {
margin-top: 0;
}
/* Cards */
.sys-card {
background: var(--bg-card, #1a1a2e);
border: 1px solid var(--border-color, #2a2a4a);
border-radius: 6px;
padding: 16px;
}
.sys-card-wide {
grid-column: span 2;
}
.sys-card-full {
grid-column: 1 / -1;
}
.sys-card-header {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-dim, #8888aa);
margin-bottom: 12px;
display: flex;
align-items: center;
justify-content: space-between;
}
.sys-card-body {
font-size: 12px;
color: var(--text-primary, #e0e0ff);
font-family: var(--font-mono, 'JetBrains Mono', monospace);
}
.sys-card-detail {
font-size: 11px;
color: var(--text-dim, #8888aa);
margin-top: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Metric Bars */
.sys-metric-bar-wrap {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.sys-metric-bar-label {
font-size: 10px;
color: var(--text-dim, #8888aa);
min-width: 40px;
text-transform: uppercase;
}
.sys-metric-bar {
flex: 1;
height: 8px;
background: var(--bg-primary, #0d0d1a);
border-radius: 4px;
overflow: hidden;
}
.sys-metric-bar-fill {
height: 100%;
border-radius: 4px;
transition: width 0.4s ease;
}
.sys-metric-bar-fill.ok {
background: var(--accent-green, #00ff88);
}
.sys-metric-bar-fill.warn {
background: var(--accent-yellow, #ffcc00);
}
.sys-metric-bar-fill.crit {
background: var(--accent-red, #ff3366);
}
.sys-metric-bar-value {
font-size: 12px;
font-weight: 700;
min-width: 36px;
text-align: right;
font-family: var(--font-mono, 'JetBrains Mono', monospace);
}
.sys-metric-na {
color: var(--text-dim, #8888aa);
font-style: italic;
font-size: 11px;
}
/* SVG Arc Gauge */
.sys-gauge-wrap {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 12px;
}
.sys-gauge-arc {
position: relative;
width: 110px;
height: 110px;
flex-shrink: 0;
}
.sys-gauge-arc svg {
width: 100%;
height: 100%;
}
.sys-gauge-arc .arc-bg {
fill: none;
stroke: var(--bg-primary, #0d0d1a);
stroke-width: 8;
stroke-linecap: round;
}
.sys-gauge-arc .arc-fill {
fill: none;
stroke-width: 8;
stroke-linecap: round;
transition: stroke-dashoffset 0.6s ease, stroke 0.3s ease;
filter: drop-shadow(0 0 4px currentColor);
}
.sys-gauge-arc .arc-fill.ok { stroke: var(--accent-green, #00ff88); }
.sys-gauge-arc .arc-fill.warn { stroke: var(--accent-yellow, #ffcc00); }
.sys-gauge-arc .arc-fill.crit { stroke: var(--accent-red, #ff3366); }
.sys-gauge-label {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 22px;
font-weight: 700;
font-family: var(--font-mono, 'JetBrains Mono', monospace);
color: var(--text-primary, #e0e0ff);
}
.sys-gauge-details {
flex: 1;
font-size: 12px;
}
/* Per-core bars */
.sys-core-bars {
display: flex;
gap: 4px;
align-items: flex-end;
height: 48px;
margin-top: 12px;
}
.sys-core-bar {
flex: 1;
background: var(--bg-primary, #0d0d1a);
border-radius: 3px;
position: relative;
min-width: 6px;
max-width: 32px;
height: 100%;
}
.sys-core-bar-fill {
position: absolute;
bottom: 0;
left: 0;
right: 0;
border-radius: 2px;
transition: height 0.4s ease, background 0.3s ease;
}
/* Temperature sparkline */
.sys-sparkline-wrap {
margin: 8px 0;
}
.sys-sparkline-wrap svg {
width: 100%;
height: 40px;
}
.sys-sparkline-line {
fill: none;
stroke: var(--accent-cyan, #00d4ff);
stroke-width: 1.5;
filter: drop-shadow(0 0 2px rgba(0, 212, 255, 0.4));
}
.sys-sparkline-area {
fill: url(#sparkGradient);
opacity: 0.3;
}
.sys-temp-big {
font-size: 28px;
font-weight: 700;
font-family: var(--font-mono, 'JetBrains Mono', monospace);
color: var(--text-primary, #e0e0ff);
margin-bottom: 4px;
}
/* Network interface rows */
.sys-net-iface {
padding: 6px 0;
border-bottom: 1px solid var(--border-color, #2a2a4a);
}
.sys-net-iface:last-child {
border-bottom: none;
}
.sys-net-iface-name {
font-weight: 700;
color: var(--accent-cyan, #00d4ff);
font-size: 11px;
}
.sys-net-iface-ip {
font-family: var(--font-mono, 'JetBrains Mono', monospace);
font-size: 12px;
color: var(--text-primary, #e0e0ff);
}
.sys-net-iface-detail {
font-size: 10px;
color: var(--text-dim, #8888aa);
}
/* Bandwidth arrows */
.sys-bandwidth {
display: flex;
gap: 12px;
margin-top: 4px;
font-family: var(--font-mono, 'JetBrains Mono', monospace);
font-size: 11px;
}
.sys-bw-up {
color: var(--accent-green, #00ff88);
}
.sys-bw-down {
color: var(--accent-cyan, #00d4ff);
}
/* Globe container — compact vertical layout */
.sys-location-inner {
display: flex;
flex-direction: column;
gap: 10px;
align-items: center;
}
.sys-globe-wrap {
width: 200px;
height: 200px;
flex-shrink: 0;
background: #000;
border-radius: 8px;
overflow: hidden;
position: relative;
}
.sys-location-details {
width: 100%;
display: flex;
flex-direction: column;
gap: 6px;
}
/* GPS status indicator */
.sys-gps-status {
display: flex;
align-items: center;
gap: 6px;
font-size: 10px;
color: var(--text-dim, #8888aa);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.sys-gps-dot {
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
}
.sys-gps-dot.fix-3d {
background: var(--accent-green, #00ff88);
box-shadow: 0 0 4px rgba(0, 255, 136, 0.4);
}
.sys-gps-dot.fix-2d {
background: var(--accent-yellow, #ffcc00);
box-shadow: 0 0 4px rgba(255, 204, 0, 0.4);
}
.sys-gps-dot.no-fix {
background: var(--text-dim, #555);
}
.sys-location-coords {
font-family: var(--font-mono, 'JetBrains Mono', monospace);
font-size: 13px;
color: var(--text-primary, #e0e0ff);
}
.sys-location-source {
font-size: 10px;
color: var(--text-dim, #8888aa);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Weather overlay */
.sys-weather {
margin-top: auto;
padding: 10px;
background: rgba(0, 0, 0, 0.3);
border-radius: 6px;
border: 1px solid var(--border-color, #2a2a4a);
}
.sys-weather-temp {
font-size: 24px;
font-weight: 700;
font-family: var(--font-mono, 'JetBrains Mono', monospace);
color: var(--text-primary, #e0e0ff);
}
.sys-weather-condition {
font-size: 12px;
color: var(--text-dim, #8888aa);
margin-top: 2px;
}
.sys-weather-detail {
font-size: 10px;
color: var(--text-dim, #8888aa);
margin-top: 2px;
}
/* Disk I/O indicators */
.sys-disk-io {
display: flex;
gap: 16px;
margin-top: 8px;
font-family: var(--font-mono, 'JetBrains Mono', monospace);
font-size: 11px;
}
.sys-disk-io-read {
color: var(--accent-cyan, #00d4ff);
}
.sys-disk-io-write {
color: var(--accent-green, #00ff88);
}
/* Process grid — dot-matrix style */
.sys-process-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4px 12px;
}
.sys-process-item {
display: flex;
align-items: center;
gap: 6px;
padding: 3px 0;
}
.sys-process-name {
font-size: 12px;
}
.sys-process-dot {
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.sys-process-dot.running {
background: var(--accent-green, #00ff88);
box-shadow: 0 0 4px rgba(0, 255, 136, 0.4);
}
.sys-process-dot.stopped {
background: var(--text-dim, #555);
}
.sys-process-summary {
margin-top: 8px;
font-size: 11px;
color: var(--text-dim, #8888aa);
}
/* SDR Devices */
.sys-sdr-device {
padding: 6px 0;
border-bottom: 1px solid var(--border-color, #2a2a4a);
}
.sys-sdr-device:last-child {
border-bottom: none;
}
.sys-rescan-btn {
font-size: 9px;
padding: 2px 8px;
background: transparent;
border: 1px solid var(--border-color, #2a2a4a);
color: var(--accent-cyan, #00d4ff);
border-radius: 3px;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.sys-rescan-btn:hover {
background: var(--bg-primary, #0d0d1a);
}
/* System info — vertical layout to fill card */
.sys-info-grid {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 12px;
color: var(--text-dim, #8888aa);
}
.sys-info-item {
display: flex;
justify-content: space-between;
padding: 2px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
}
.sys-info-item:last-child {
border-bottom: none;
}
.sys-info-item strong {
color: var(--text-primary, #e0e0ff);
font-weight: 600;
}
/* Battery indicator */
.sys-battery-inline {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
}
/* Sidebar Quick Grid */
.sys-quick-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.sys-quick-item {
padding: 6px 8px;
background: var(--bg-primary, #0d0d1a);
border: 1px solid var(--border-color, #2a2a4a);
border-radius: 4px;
text-align: center;
}
.sys-quick-label {
display: block;
font-size: 9px;
color: var(--text-dim, #8888aa);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 2px;
}
.sys-quick-value {
display: block;
font-size: 14px;
font-weight: 700;
font-family: var(--font-mono, 'JetBrains Mono', monospace);
color: var(--text-primary, #e0e0ff);
}
/* Color-coded quick values */
.sys-val-ok {
color: var(--accent-green, #00ff88) !important;
}
.sys-val-warn {
color: var(--accent-yellow, #ffcc00) !important;
}
.sys-val-crit {
color: var(--accent-red, #ff3366) !important;
}
/* Responsive */
@media (max-width: 768px) {
.sys-dashboard {
grid-template-columns: 1fr;
padding: 8px;
gap: 10px;
}
.sys-card-wide,
.sys-card-full {
grid-column: 1;
}
.sys-globe-wrap {
width: 100%;
height: 180px;
}
.sys-process-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 1024px) and (min-width: 769px) {
.sys-dashboard {
grid-template-columns: repeat(2, 1fr);
}
.sys-card-wide {
grid-column: span 2;
}
.sys-card-full {
grid-column: 1 / -1;
}
}
+49 -7
View File
@@ -81,7 +81,7 @@
} }
.tscm-panel-header .badge { .tscm-panel-header .badge {
background: var(--primary-color); background: var(--primary-color);
color: #fff; color: var(--text-inverse);
padding: 2px 8px; padding: 2px 8px;
border-radius: 10px; border-radius: 10px;
font-size: 10px; font-size: 10px;
@@ -175,7 +175,7 @@
background: var(--accent-green); background: var(--accent-green);
border: none; border: none;
border-radius: 4px; border-radius: 4px;
color: #000; color: var(--text-inverse);
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
@@ -411,7 +411,7 @@
.tscm-modal-close:hover { .tscm-modal-close:hover {
background: var(--accent-red); background: var(--accent-red);
border-color: var(--accent-red); border-color: var(--accent-red);
color: #fff; color: var(--text-inverse);
} }
.device-detail-header { .device-detail-header {
padding: 16px; padding: 16px;
@@ -654,10 +654,10 @@
border-radius: 3px; border-radius: 3px;
text-transform: uppercase; text-transform: uppercase;
} }
.tscm-threat-item.critical .tscm-threat-severity { background: #ff3366; color: #fff; } .tscm-threat-item.critical .tscm-threat-severity { background: var(--severity-critical); color: var(--text-inverse); }
.tscm-threat-item.high .tscm-threat-severity { background: #ff9933; color: #000; } .tscm-threat-item.high .tscm-threat-severity { background: var(--severity-high); color: var(--text-inverse); }
.tscm-threat-item.medium .tscm-threat-severity { background: #ffcc00; color: #000; } .tscm-threat-item.medium .tscm-threat-severity { background: var(--severity-medium); color: var(--text-inverse); }
.tscm-threat-item.low .tscm-threat-severity { background: #00ff88; color: #000; } .tscm-threat-item.low .tscm-threat-severity { background: var(--severity-low); color: var(--text-inverse); }
.tscm-threat-details { .tscm-threat-details {
font-size: 11px; font-size: 11px;
color: var(--text-muted); color: var(--text-muted);
@@ -1691,3 +1691,45 @@
flex-wrap: wrap; flex-wrap: wrap;
margin-top: 6px; margin-top: 6px;
} }
/* Light theme overrides */
[data-theme="light"] .threat-card.critical { border-color: var(--severity-critical); color: var(--severity-critical); }
[data-theme="light"] .threat-card.high { border-color: var(--severity-high); color: var(--severity-high); }
[data-theme="light"] .threat-card.medium { border-color: var(--severity-medium); color: var(--severity-medium); }
[data-theme="light"] .threat-card.low { border-color: var(--severity-low); color: var(--severity-low); }
[data-theme="light"] .tscm-threat-item.critical { border-color: var(--severity-critical); }
[data-theme="light"] .tscm-threat-item.high { border-color: var(--severity-high); }
[data-theme="light"] .tscm-threat-item.medium { border-color: var(--severity-medium); }
[data-theme="light"] .tscm-threat-item.low { border-color: var(--severity-low); }
[data-theme="light"] .score-badge.score-high { color: #cc2222; }
[data-theme="light"] .score-badge.score-medium { color: #9a8420; }
[data-theme="light"] .score-badge.score-low { color: #1a8a50; }
[data-theme="light"] .score-circle.high { border-color: #cc2222; }
[data-theme="light"] .score-circle.medium { border-color: #9a8420; }
[data-theme="light"] .score-circle.low { border-color: #1a8a50; }
[data-theme="light"] .score-circle.high .score-value { color: #cc2222; }
[data-theme="light"] .score-circle.medium .score-value { color: #9a8420; }
[data-theme="light"] .score-circle.low .score-value { color: #1a8a50; }
[data-theme="light"] .cap-status.available { color: #1a8a50; }
[data-theme="light"] .cap-status.limited { color: #9a8420; }
[data-theme="light"] .cap-status.unavailable { color: #cc2222; }
[data-theme="light"] .cap-detail-status.available { color: #1a8a50; }
[data-theme="light"] .cap-detail-status.limited { color: #9a8420; }
[data-theme="light"] .cap-detail-status.unavailable { color: #cc2222; }
[data-theme="light"] .health-badge.healthy { color: #1a8a50; }
[data-theme="light"] .health-badge.noisy { color: #9a8420; }
[data-theme="light"] .health-badge.stale { color: #cc2222; }
[data-theme="light"] .tscm-assessment.high-interest { color: #cc2222; border-color: #cc2222; }
[data-theme="light"] .tscm-assessment.needs-review { color: #9a8420; border-color: #9a8420; }
[data-theme="light"] .tscm-assessment.informational { color: #1a8a50; border-color: #1a8a50; }
[data-theme="light"] .summary-stat.high-interest .count { color: #cc2222; }
[data-theme="light"] .summary-stat.needs-review .count { color: #9a8420; }
[data-theme="light"] .summary-stat.informational .count { color: #1a8a50; }
[data-theme="light"] .known-badge { color: #1a8a50; }
[data-theme="light"] .tracker-badge { color: #cc2244; }
[data-theme="light"] .tscm-correlations { border-color: var(--accent-orange); }
[data-theme="light"] .tscm-correlations h4 { color: var(--accent-orange); }
[data-theme="light"] .case-status.open { color: #1a8a50; }
[data-theme="light"] .playbook-risk.high_interest { color: #cc2222; }
[data-theme="light"] .playbook-risk.needs_review { color: #9a8420; }
[data-theme="light"] .playbook-risk.informational { color: #1a8a50; }
+14 -3
View File
@@ -128,7 +128,7 @@
} }
.wxsat-schedule-toggle input:checked + .wxsat-toggle-label { .wxsat-schedule-toggle input:checked + .wxsat-toggle-label {
color: #00ff88; color: var(--accent-green);
} }
/* ===== Location inputs in strip ===== */ /* ===== Location inputs in strip ===== */
@@ -695,7 +695,7 @@
.wxsat-image-actions button:hover { .wxsat-image-actions button:hover {
background: rgba(255, 68, 68, 0.9); background: rgba(255, 68, 68, 0.9);
color: #fff; color: var(--text-inverse);
} }
.wxsat-image-preview { .wxsat-image-preview {
@@ -901,7 +901,7 @@
.wxsat-modal-btn.delete:hover { .wxsat-modal-btn.delete:hover {
background: rgba(255, 68, 68, 0.9); background: rgba(255, 68, 68, 0.9);
border-color: #ff4444; border-color: #ff4444;
color: #fff; color: var(--text-inverse);
} }
/* Gallery clear-all button */ /* Gallery clear-all button */
@@ -1166,3 +1166,14 @@
.wxsat-collapse-icon.collapsed { .wxsat-collapse-icon.collapsed {
transform: rotate(-90deg); transform: rotate(-90deg);
} }
/* Light theme overrides */
[data-theme="light"] .wxsat-strip-dot.capturing { background: var(--accent-green); }
[data-theme="light"] .wxsat-strip-dot.decoding { background: var(--accent-cyan); }
[data-theme="light"] .wxsat-phase-step.active { color: var(--accent-green); border-color: var(--accent-green); }
[data-theme="light"] .wxsat-console-entry.wxsat-log-signal { border-left-color: var(--accent-green); color: var(--accent-green); }
[data-theme="light"] .wxsat-console-entry.wxsat-log-save { border-left-color: var(--accent-orange); color: var(--accent-orange); }
[data-theme="light"] .wxsat-console-entry.wxsat-log-error { border-left-color: var(--accent-red); color: var(--accent-red); }
[data-theme="light"] .wxsat-console-entry.wxsat-log-warning { border-left-color: var(--accent-orange); color: var(--accent-orange); }
[data-theme="light"] .wxsat-pass-mode.lrpt { color: var(--accent-green); }
[data-theme="light"] .wxsat-pass-quality.excellent { color: var(--accent-green); }
+687
View File
@@ -0,0 +1,687 @@
/* ============================================
WeFax (Weather Fax) Mode Styles
Amber/gold theme (#ffaa00) for HF
============================================ */
/* Place WeFax sidebar panel above the shared SDR Device section
while keeping the collapse button at the very top. */
#wefaxMode.active {
order: -1;
}
.sidebar:has(#wefaxMode.active) > .sidebar-collapse-btn {
order: -2;
}
/* --- Stats Strip --- */
.wefax-stats-strip {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 14px;
background: var(--bg-card, #0e1117);
border: 1px solid var(--border-color, #1e2a3a);
border-radius: 6px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.wefax-strip-group {
display: flex;
align-items: center;
gap: 10px;
}
.wefax-strip-status {
display: flex;
align-items: center;
gap: 6px;
}
.wefax-strip-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #444;
flex-shrink: 0;
}
.wefax-strip-dot.scanning { background: #ffaa00; animation: wefax-pulse 1.5s ease-in-out infinite; }
.wefax-strip-dot.phasing { background: #ffcc44; animation: wefax-pulse 0.8s ease-in-out infinite; }
.wefax-strip-dot.receiving { background: #00cc66; animation: wefax-pulse 1s ease-in-out infinite; }
.wefax-strip-dot.complete { background: #00cc66; }
.wefax-strip-dot.error { background: #f44; }
@keyframes wefax-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.wefax-strip-status-text {
font-family: var(--font-mono, 'JetBrains Mono', monospace);
font-size: 11px;
color: var(--text-primary, #e0e0e0);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.wefax-strip-btn {
padding: 4px 12px;
border: 1px solid var(--border-color, #1e2a3a);
border-radius: 4px;
font-family: var(--font-mono, monospace);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
cursor: pointer;
background: var(--bg-primary, #161b22);
color: var(--text-primary, #e0e0e0);
display: inline-flex;
align-items: center;
gap: 4px;
transition: all 0.15s ease;
}
.wefax-strip-btn.start { color: #ffaa00; border-color: #ffaa0044; }
.wefax-strip-btn.start:hover { background: #ffaa0015; border-color: #ffaa00; }
.wefax-strip-btn.start.wefax-strip-btn-error {
border-color: #ffaa00;
color: #ffaa00;
box-shadow: 0 0 8px rgba(255, 170, 0, 0.3);
animation: wefax-pulse 0.6s ease-in-out 3;
}
.wefax-strip-btn.stop { color: #f44; border-color: #f4444444; }
.wefax-strip-btn.stop:hover { background: #f4441a; border-color: #f44; }
.wefax-strip-divider {
width: 1px;
height: 20px;
background: var(--border-color, #1e2a3a);
}
.wefax-strip-stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 1px;
}
.wefax-strip-value {
font-family: var(--font-mono, monospace);
font-size: 13px;
font-weight: 600;
color: var(--text-primary, #e0e0e0);
font-variant-numeric: tabular-nums;
}
.wefax-strip-value.accent-amber { color: #ffaa00; }
.wefax-strip-label {
font-family: var(--font-mono, monospace);
font-size: 8px;
color: var(--text-dim, #555);
text-transform: uppercase;
letter-spacing: 1px;
}
/* --- Schedule Toggle --- */
.wefax-schedule-toggle {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 10px;
font-family: var(--font-mono, 'JetBrains Mono', monospace);
color: var(--text-dim, #666);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.wefax-schedule-toggle input[type="checkbox"] {
width: 14px;
height: 14px;
cursor: pointer;
accent-color: #ffaa00;
}
.wefax-schedule-toggle input:checked + span {
color: #ffaa00;
}
/* --- Visuals Container --- */
.wefax-visuals-container {
display: flex;
flex-direction: column;
gap: 0;
width: 100%;
}
/* --- Main Row --- */
.wefax-main-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
/* --- Schedule Timeline --- */
.wefax-schedule-panel {
background: var(--bg-card, #0e1117);
border: 1px solid var(--border-color, #1e2a3a);
border-radius: 6px;
margin-bottom: 12px;
overflow: hidden;
}
.wefax-schedule-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--border-color, #1e2a3a);
}
.wefax-schedule-title {
font-family: var(--font-mono, monospace);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
color: #ffaa00;
}
.wefax-schedule-list {
display: flex;
flex-direction: column;
max-height: 200px;
overflow-y: auto;
}
.wefax-schedule-entry {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 12px;
border-bottom: 1px solid var(--border-color, #1e2a3a)11;
font-family: var(--font-mono, monospace);
font-size: 11px;
}
.wefax-schedule-entry:last-child { border-bottom: none; }
.wefax-schedule-entry.active {
background: #ffaa0010;
border-left: 3px solid #ffaa00;
}
.wefax-schedule-entry.upcoming {
background: #ffaa0008;
}
.wefax-schedule-entry.past {
opacity: 0.4;
}
.wefax-schedule-time {
color: #ffaa00;
min-width: 45px;
font-variant-numeric: tabular-nums;
}
.wefax-schedule-content {
color: var(--text-primary, #e0e0e0);
flex: 1;
}
.wefax-schedule-badge {
font-size: 9px;
padding: 1px 6px;
border-radius: 3px;
background: var(--border-color, #1e2a3a);
color: var(--text-dim, #555);
}
.wefax-schedule-badge.live {
background: #ffaa0030;
color: #ffaa00;
font-weight: 600;
}
.wefax-schedule-badge.soon {
background: #ffaa0015;
color: #ffcc66;
}
.wefax-schedule-empty {
padding: 16px;
text-align: center;
color: var(--text-dim, #555);
font-size: 11px;
font-family: var(--font-mono, monospace);
}
/* --- Live Section --- */
.wefax-live-section {
background: var(--bg-card, #0e1117);
border: 1px solid var(--border-color, #1e2a3a);
border-radius: 6px;
overflow: hidden;
}
.wefax-live-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--border-color, #1e2a3a);
}
.wefax-live-title {
font-family: var(--font-mono, monospace);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
color: #ffaa00;
}
.wefax-live-content {
padding: 12px;
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
}
.wefax-idle-state {
text-align: center;
color: var(--text-dim, #555);
}
.wefax-idle-state svg {
width: 48px;
height: 48px;
color: #ffaa0033;
margin-bottom: 12px;
}
.wefax-idle-state h4 {
margin: 0 0 4px;
color: var(--text-primary, #e0e0e0);
font-size: 13px;
}
.wefax-idle-state p {
margin: 0;
font-size: 11px;
}
.wefax-live-preview {
max-width: 100%;
max-height: 400px;
border-radius: 4px;
image-rendering: pixelated;
}
/* --- Gallery Section --- */
.wefax-gallery-section {
background: var(--bg-card, #0e1117);
border: 1px solid var(--border-color, #1e2a3a);
border-radius: 6px;
overflow: hidden;
}
.wefax-gallery-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--border-color, #1e2a3a);
}
.wefax-gallery-title {
font-family: var(--font-mono, monospace);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
color: #ffaa00;
}
.wefax-gallery-controls {
display: flex;
align-items: center;
gap: 8px;
}
.wefax-gallery-count {
font-family: var(--font-mono, monospace);
font-size: 11px;
color: var(--text-dim, #555);
}
.wefax-gallery-clear-btn {
font-family: var(--font-mono, monospace);
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.5px;
background: none;
border: 1px solid var(--border-color, #1e2a3a);
color: var(--text-dim, #555);
padding: 2px 8px;
border-radius: 3px;
cursor: pointer;
}
.wefax-gallery-clear-btn:hover {
border-color: #f44;
color: #f44;
}
.wefax-gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 8px;
padding: 10px;
max-height: 500px;
overflow-y: auto;
}
.wefax-gallery-empty {
padding: 24px;
text-align: center;
color: var(--text-dim, #555);
font-size: 11px;
font-family: var(--font-mono, monospace);
grid-column: 1 / -1;
}
.wefax-gallery-item {
position: relative;
background: var(--bg-primary, #161b22);
border: 1px solid var(--border-color, #1e2a3a);
border-radius: 4px;
overflow: hidden;
}
.wefax-gallery-item img {
width: 100%;
aspect-ratio: 4/3;
object-fit: cover;
cursor: pointer;
display: block;
}
.wefax-gallery-item img:hover {
opacity: 0.85;
}
.wefax-gallery-meta {
padding: 4px 6px;
display: flex;
flex-direction: column;
gap: 1px;
font-family: var(--font-mono, monospace);
font-size: 9px;
color: var(--text-dim, #555);
}
.wefax-gallery-actions {
position: absolute;
top: 4px;
right: 4px;
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.15s;
}
.wefax-gallery-item:hover .wefax-gallery-actions {
opacity: 1;
}
.wefax-gallery-action {
width: 22px;
height: 22px;
border-radius: 3px;
border: none;
background: rgba(0, 0, 0, 0.7);
color: #ccc;
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
text-decoration: none;
}
.wefax-gallery-action:hover { color: #fff; }
.wefax-gallery-action.delete:hover { color: #f44; }
/* --- Countdown Bar + Timeline --- */
.wefax-countdown-bar {
display: flex;
align-items: center;
gap: 16px;
padding: 10px 16px;
background: var(--bg-secondary, #141820);
border: 1px solid var(--border-color, #1e2a3a);
border-radius: 6px;
margin-bottom: 12px;
}
.wefax-countdown-next {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.wefax-countdown-boxes {
display: flex;
gap: 4px;
}
.wefax-countdown-box {
display: flex;
flex-direction: column;
align-items: center;
padding: 4px 8px;
background: var(--bg-primary, #0d1117);
border: 1px solid var(--border-color, #2a3040);
border-radius: 4px;
min-width: 40px;
}
.wefax-countdown-box.imminent {
border-color: #ffaa00;
box-shadow: 0 0 8px rgba(255, 170, 0, 0.2);
}
.wefax-countdown-box.active {
border-color: #ffaa00;
box-shadow: 0 0 8px rgba(255, 170, 0, 0.3);
animation: wefax-glow 1.5s ease-in-out infinite;
}
@keyframes wefax-glow {
0%, 100% { box-shadow: 0 0 8px rgba(255, 170, 0, 0.3); }
50% { box-shadow: 0 0 16px rgba(255, 170, 0, 0.5); }
}
.wefax-cd-value {
font-size: 16px;
font-weight: 700;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
color: var(--text-primary, #e0e0e0);
line-height: 1;
}
.wefax-cd-unit {
font-size: 8px;
color: var(--text-dim, #666);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 2px;
}
.wefax-countdown-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.wefax-countdown-content {
font-size: 12px;
font-weight: 600;
color: #ffaa00;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
}
.wefax-countdown-detail {
font-size: 10px;
color: var(--text-dim, #666);
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
}
.wefax-timeline {
flex: 1;
position: relative;
height: 36px;
min-width: 200px;
}
.wefax-timeline-track {
position: absolute;
top: 4px;
left: 0;
right: 0;
height: 16px;
background: var(--bg-primary, #0d1117);
border: 1px solid var(--border-color, #2a3040);
border-radius: 3px;
overflow: hidden;
}
.wefax-timeline-broadcast {
position: absolute;
top: 0;
height: 100%;
background: rgba(255, 170, 0, 0.5);
border-radius: 2px;
cursor: default;
opacity: 0.8;
min-width: 2px;
}
.wefax-timeline-broadcast:hover {
opacity: 1;
}
.wefax-timeline-broadcast.active {
background: rgba(255, 170, 0, 0.85);
border: 1px solid #ffaa00;
}
.wefax-timeline-cursor {
position: absolute;
top: 2px;
width: 2px;
height: 20px;
background: #ff4444;
border-radius: 1px;
z-index: 2;
}
.wefax-timeline-labels {
position: absolute;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: space-between;
font-size: 8px;
color: var(--text-dim, #666);
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
}
/* --- Image Modal --- */
.wefax-image-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.9);
display: none;
align-items: center;
justify-content: center;
z-index: 10000;
padding: 40px;
}
.wefax-image-modal.show {
display: flex;
}
.wefax-image-modal img {
max-width: 100%;
max-height: 100%;
border: 1px solid var(--border-color);
border-radius: 4px;
}
.wefax-modal-toolbar {
position: absolute;
top: 20px;
right: 60px;
display: flex;
gap: 8px;
z-index: 1;
}
.wefax-modal-btn {
display: flex;
align-items: center;
gap: 6px;
font-family: var(--font-mono);
font-size: 10px;
padding: 6px 12px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
color: var(--text-inverse);
cursor: pointer;
transition: all 0.15s;
text-transform: uppercase;
}
.wefax-modal-btn:hover {
background: rgba(255, 255, 255, 0.2);
}
.wefax-modal-btn.delete:hover {
background: var(--accent-red, #ff3366);
border-color: var(--accent-red, #ff3366);
}
.wefax-modal-close {
position: absolute;
top: 20px;
right: 20px;
background: none;
border: none;
color: white;
font-size: 32px;
cursor: pointer;
opacity: 0.7;
transition: opacity 0.15s;
z-index: 1;
}
.wefax-modal-close:hover {
opacity: 1;
}
/* --- Responsive --- */
@media (max-width: 768px) {
.wefax-main-row {
grid-template-columns: 1fr;
}
}
+385
View File
@@ -0,0 +1,385 @@
/* WiFi Locate Mode Styles */
/* Environment preset grid */
.wfl-env-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 6px;
}
.wfl-env-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 8px 4px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
color: var(--text-secondary);
}
.wfl-env-btn:hover {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.2);
}
.wfl-env-btn.active {
background: rgba(0, 255, 136, 0.1);
border-color: var(--accent-green, #00ff88);
color: var(--text-primary);
}
.wfl-env-icon {
font-size: 18px;
line-height: 1;
}
.wfl-env-label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
}
.wfl-env-n {
font-size: 9px;
font-family: var(--font-mono);
color: var(--text-dim);
}
/* ============================================
VISUALS CONTAINER
============================================ */
.wfl-visuals-container {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
min-height: 0;
overflow: hidden;
padding: 8px;
}
/* ============================================
PROXIMITY HUD
============================================ */
.wfl-hud {
display: flex;
flex-direction: column;
gap: 12px;
flex: 1;
padding: 16px;
position: relative;
background: rgba(0, 0, 0, 0.5);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
overflow: hidden;
}
.wfl-hud-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-bottom: 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.wfl-hud-target {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
}
.wfl-target-ssid {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.wfl-target-bssid {
font-size: 11px;
font-family: var(--font-mono);
color: var(--text-dim);
}
.wfl-hud-audio-toggle {
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
color: var(--text-secondary);
cursor: pointer;
white-space: nowrap;
}
.wfl-hud-audio-toggle input[type="checkbox"] {
margin: 0;
}
.wfl-hud-stop-btn {
padding: 5px 12px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #ff3366;
background: rgba(255, 51, 102, 0.1);
border: 1px solid rgba(255, 51, 102, 0.3);
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.wfl-hud-stop-btn:hover {
background: rgba(255, 51, 102, 0.2);
border-color: #ff3366;
}
/* ============================================
RSSI DISPLAY big dBm number
============================================ */
.wfl-rssi-display {
font-size: 64px;
font-weight: 800;
font-family: var(--font-mono);
text-align: center;
line-height: 1;
color: var(--text-dim);
transition: color 0.3s;
padding: 8px 0;
}
.wfl-rssi-display.good {
color: #22c55e;
text-shadow: 0 0 20px rgba(34, 197, 94, 0.3);
}
.wfl-rssi-display.medium {
color: #eab308;
text-shadow: 0 0 20px rgba(234, 179, 8, 0.2);
}
.wfl-rssi-display.weak {
color: #ef4444;
text-shadow: 0 0 20px rgba(239, 68, 68, 0.2);
}
/* ============================================
DISTANCE ESTIMATE
============================================ */
.wfl-distance {
text-align: center;
font-size: 16px;
font-family: var(--font-mono);
color: var(--text-secondary);
margin-top: -4px;
}
/* ============================================
SIGNAL BAR 20 horizontal segments
============================================ */
.wfl-bar-container {
display: flex;
gap: 3px;
padding: 8px 0;
align-items: center;
justify-content: center;
}
.wfl-bar-segment {
width: 100%;
height: 28px;
flex: 1;
border-radius: 2px;
background: rgba(255, 255, 255, 0.06);
transition: background 0.15s;
}
.wfl-bar-segment.active:nth-child(-n+7) {
background: #ef4444;
box-shadow: 0 0 6px rgba(239, 68, 68, 0.3);
}
.wfl-bar-segment.active:nth-child(n+8):nth-child(-n+14) {
background: #eab308;
box-shadow: 0 0 6px rgba(234, 179, 8, 0.3);
}
.wfl-bar-segment.active:nth-child(n+15) {
background: #22c55e;
box-shadow: 0 0 6px rgba(34, 197, 94, 0.3);
}
/* ============================================
RSSI CHART canvas wrapper
============================================ */
.wfl-rssi-chart-container {
height: 120px;
background: rgba(0, 0, 0, 0.3);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
padding: 8px;
position: relative;
flex-shrink: 0;
}
.wfl-rssi-chart-container .wfl-chart-label {
position: absolute;
top: 4px;
left: 8px;
font-size: 9px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 1px;
}
#wflRssiChart {
width: 100%;
height: 100%;
}
/* ============================================
STATS 4-column grid
============================================ */
.wfl-stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
padding: 8px 0;
}
.wfl-stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.wfl-stat-value {
font-size: 16px;
font-weight: 700;
font-family: var(--font-mono);
color: var(--text-primary);
}
.wfl-stat-label {
font-size: 9px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* ============================================
SIGNAL LOST OVERLAY
============================================ */
.wfl-signal-lost {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.7);
color: #ef4444;
font-size: 24px;
font-weight: 800;
font-family: var(--font-mono);
letter-spacing: 4px;
text-transform: uppercase;
z-index: 10;
animation: wfl-pulse 2s ease-in-out infinite;
}
@keyframes wfl-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* ============================================
WAITING STATE
============================================ */
.wfl-waiting {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
color: var(--text-dim);
font-size: 13px;
text-align: center;
padding: 20px;
}
/* ============================================
LOCATE BUTTON WiFi detail drawer
============================================ */
.wfl-locate-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--accent-green, #00ff88);
background: rgba(0, 255, 136, 0.1);
border: 1px solid rgba(0, 255, 136, 0.3);
border-radius: 3px;
cursor: pointer;
transition: all 0.2s;
}
.wfl-locate-btn:hover {
background: rgba(0, 255, 136, 0.2);
border-color: var(--accent-green, #00ff88);
}
.wfl-locate-btn svg {
width: 10px;
height: 10px;
}
/* ============================================
RESPONSIVE
============================================ */
@media (max-width: 900px) {
.wfl-rssi-display {
font-size: 48px;
}
.wfl-bar-segment {
height: 22px;
}
.wfl-stats {
grid-template-columns: repeat(2, 1fr);
}
.wfl-hud-header {
flex-wrap: wrap;
gap: 8px;
}
.wfl-rssi-chart-container {
height: 90px;
}
}
+51
View File
@@ -322,6 +322,57 @@
flex-shrink: 0; flex-shrink: 0;
} }
/* ============== MOBILE NAV GROUPS ============== */
.mobile-nav-group {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.mobile-nav-group-label {
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-tertiary, #6b7280);
padding: 4px 6px 4px 8px;
white-space: nowrap;
border-left: 2px solid var(--border-color, #1f2937);
flex-shrink: 0;
}
.mobile-nav-group:first-child .mobile-nav-group-label {
border-left: none;
padding-left: 0;
}
/* ============== MOBILE NAV UTILITIES ============== */
.mobile-nav-utils {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
padding-left: 8px;
border-left: 2px solid var(--accent-cyan, #4a9eff);
}
/* Hide mobile nav utilities on desktop (desktop has .nav-utilities) */
@media (min-width: 1024px) {
.mobile-nav-utils {
display: none;
}
}
/* ============== TABLET: WRAP MOBILE NAV ============== */
@media (min-width: 768px) and (max-width: 1023px) {
.mobile-nav-bar {
flex-wrap: wrap;
overflow-x: visible;
justify-content: center;
}
}
/* Hide mobile nav bar on desktop */ /* Hide mobile nav bar on desktop */
@media (min-width: 1024px) { @media (min-width: 1024px) {
.mobile-nav-bar { .mobile-nav-bar {
+1 -1
View File
@@ -666,7 +666,7 @@ body {
.btn.primary { .btn.primary {
background: var(--accent-green); background: var(--accent-green);
color: #fff; color: var(--text-inverse);
margin-left: auto; margin-left: auto;
} }
+2 -2
View File
@@ -262,7 +262,7 @@
.toggle-switch input:checked + .toggle-slider:before { .toggle-switch input:checked + .toggle-slider:before {
transform: translateX(20px); transform: translateX(20px);
background-color: white; background-color: var(--text-inverse);
} }
.toggle-switch input:focus + .toggle-slider { .toggle-switch input:focus + .toggle-slider {
@@ -430,7 +430,7 @@
background: linear-gradient(135deg, var(--accent-amber, #d4a853) 0%, var(--accent-orange, #f59e0b) 100%); background: linear-gradient(135deg, var(--accent-amber, #d4a853) 0%, var(--accent-orange, #f59e0b) 100%);
border: none; border: none;
border-radius: 6px; border-radius: 6px;
color: #000; color: var(--text-inverse);
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
text-decoration: none; text-decoration: none;
+50
View File
@@ -0,0 +1,50 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-labelledby="title desc">
<title id="title">Satellite</title>
<desc id="desc">Professional satellite icon with solar panels and body</desc>
<defs>
<linearGradient id="panelGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#344a5f"/>
<stop offset="55%" stop-color="#233547"/>
<stop offset="100%" stop-color="#1a2734"/>
</linearGradient>
<linearGradient id="panelGrid" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#4f6a85" stop-opacity="0.7"/>
<stop offset="100%" stop-color="#2b3f53" stop-opacity="0.1"/>
</linearGradient>
<linearGradient id="bodyGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#8ea0b2"/>
<stop offset="45%" stop-color="#6f8193"/>
<stop offset="100%" stop-color="#536475"/>
</linearGradient>
<linearGradient id="dishGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#7e91a4"/>
<stop offset="100%" stop-color="#556779"/>
</linearGradient>
</defs>
<g stroke-linecap="round" stroke-linejoin="round">
<rect x="8" y="42" width="38" height="44" rx="2.5" fill="url(#panelGradient)" stroke="#607891" stroke-width="2"/>
<rect x="12" y="46" width="30" height="36" rx="1.5" fill="url(#panelGrid)" stroke="#405669" stroke-width="1.2"/>
<path d="M21 46v36M30 46v36M12 55h30M12 64h30M12 73h30" stroke="#4a6278" stroke-width="0.9" opacity="0.82"/>
<rect x="82" y="42" width="38" height="44" rx="2.5" fill="url(#panelGradient)" stroke="#607891" stroke-width="2"/>
<rect x="86" y="46" width="30" height="36" rx="1.5" fill="url(#panelGrid)" stroke="#405669" stroke-width="1.2"/>
<path d="M95 46v36M104 46v36M86 55h30M86 64h30M86 73h30" stroke="#4a6278" stroke-width="0.9" opacity="0.82"/>
<line x1="46" y1="64" x2="52" y2="64" stroke="#8fa4b9" stroke-width="3"/>
<line x1="76" y1="64" x2="82" y2="64" stroke="#8fa4b9" stroke-width="3"/>
<rect x="52" y="40" width="24" height="48" rx="4" fill="url(#bodyGradient)" stroke="#91a5b8" stroke-width="2"/>
<rect x="55" y="53" width="18" height="4" rx="1.5" fill="#d2dce6" opacity="0.48"/>
<rect x="55" y="62" width="18" height="4" rx="1.5" fill="#d2dce6" opacity="0.42"/>
<path d="M64 24c6 0 11 5 11 11s-5 11-11 11-11-5-11-11 5-11 11-11Z" fill="url(#dishGradient)" stroke="#95aac0" stroke-width="2"/>
<circle cx="64" cy="35" r="3.2" fill="#d7e2ed" opacity="0.7"/>
<path d="M58 26c2.2-2.4 9.8-2.4 12 0" fill="none" stroke="#a7b8c8" stroke-width="1.5"/>
<line x1="64" y1="46" x2="64" y2="51" stroke="#9fb2c6" stroke-width="2"/>
<path d="M57 88L64 101L71 88Z" fill="url(#dishGradient)" stroke="#8fa4b8" stroke-width="1.8"/>
<line x1="64" y1="101" x2="64" y2="108" stroke="#8fa4b8" stroke-width="2"/>
<circle cx="64" cy="110" r="2.8" fill="#b9c9d8"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

+27
View File
@@ -1492,6 +1492,7 @@ const SignalCards = (function() {
muted.push(address); muted.push(address);
localStorage.setItem('mutedAddresses', JSON.stringify(muted)); localStorage.setItem('mutedAddresses', JSON.stringify(muted));
showToast(`Source ${address} hidden from view`); showToast(`Source ${address} hidden from view`);
updateMutedIndicator();
// Hide existing cards with this address // Hide existing cards with this address
document.querySelectorAll(`.signal-card[data-address="${address}"], .signal-card[data-callsign="${address}"], .signal-card[data-sensor-id="${address}"]`).forEach(card => { document.querySelectorAll(`.signal-card[data-address="${address}"], .signal-card[data-callsign="${address}"], .signal-card[data-sensor-id="${address}"]`).forEach(card => {
@@ -1510,6 +1511,30 @@ const SignalCards = (function() {
return muted.includes(address); return muted.includes(address);
} }
/**
* Unmute all addresses and refresh display
*/
function unmuteAll() {
localStorage.setItem('mutedAddresses', '[]');
updateMutedIndicator();
showToast('All sources unmuted');
// Reload to re-display previously muted messages
location.reload();
}
/**
* Update the muted address count indicator in the sidebar
*/
function updateMutedIndicator() {
const muted = JSON.parse(localStorage.getItem('mutedAddresses') || '[]');
const info = document.getElementById('mutedAddressInfo');
const count = document.getElementById('mutedAddressCount');
if (info && count) {
count.textContent = muted.length;
info.style.display = muted.length > 0 ? 'block' : 'none';
}
}
/** /**
* Show location on map (for APRS) * Show location on map (for APRS)
*/ */
@@ -2262,6 +2287,8 @@ const SignalCards = (function() {
copyMessage, copyMessage,
muteAddress, muteAddress,
isAddressMuted, isAddressMuted,
unmuteAll,
updateMutedIndicator,
showOnMap, showOnMap,
showStationRawData, showStationRawData,
showSignalDetails, showSignalDetails,
+184
View File
@@ -0,0 +1,184 @@
/**
* Signal Waveform Component
* Animated SVG bar waveform showing live signal activity.
* Flat/breathing when idle, oscillates on incoming data.
*/
const SignalWaveform = (function() {
'use strict';
const DEFAULT_CONFIG = {
width: 200,
height: 40,
barCount: 24,
color: '#00e5ff',
decayMs: 3000,
idleAmplitude: 0.05,
};
class Live {
constructor(container, config = {}) {
this.container = typeof container === 'string'
? document.querySelector(container)
: container;
this.config = { ...DEFAULT_CONFIG, ...config };
this.lastPingTime = 0;
this.pingTimestamps = [];
this.animFrameId = null;
this.phase = 0;
this.targetHeights = [];
this.currentHeights = [];
this.stopped = false;
this._buildSvg();
this._startLoop();
}
/** Signal that a telemetry message arrived */
ping() {
const now = performance.now();
this.lastPingTime = now;
this.pingTimestamps.push(now);
this.stopped = false;
// Randomise target heights on each ping
this._randomiseTargets();
}
/** Transition to idle */
stop() {
this.stopped = true;
this.lastPingTime = 0;
this.pingTimestamps = [];
}
/** Tear down animation loop and DOM */
destroy() {
if (this.animFrameId) {
cancelAnimationFrame(this.animFrameId);
this.animFrameId = null;
}
if (this.container) {
this.container.innerHTML = '';
}
}
// -- Private --
_buildSvg() {
if (!this.container) return;
const { width, height, barCount, color } = this.config;
const gap = 1;
const barWidth = (width - gap * (barCount - 1)) / barCount;
const minH = height * this.config.idleAmplitude;
const ns = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(ns, 'svg');
svg.setAttribute('class', 'signal-waveform-svg');
svg.setAttribute('width', width);
svg.setAttribute('height', height);
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
this.bars = [];
for (let i = 0; i < barCount; i++) {
const rect = document.createElementNS(ns, 'rect');
const x = i * (barWidth + gap);
rect.setAttribute('x', x.toFixed(1));
rect.setAttribute('y', (height - minH).toFixed(1));
rect.setAttribute('width', barWidth.toFixed(1));
rect.setAttribute('height', minH.toFixed(1));
rect.setAttribute('rx', '1');
rect.setAttribute('fill', color);
rect.setAttribute('class', 'signal-waveform-bar');
svg.appendChild(rect);
this.bars.push(rect);
this.currentHeights.push(minH);
this.targetHeights.push(minH);
}
const wrapper = document.createElement('div');
wrapper.className = 'signal-waveform idle';
wrapper.appendChild(svg);
this.container.innerHTML = '';
this.container.appendChild(wrapper);
this.wrapperEl = wrapper;
}
_randomiseTargets() {
const { height } = this.config;
const amplitude = this._getAmplitude();
for (let i = 0; i < this.config.barCount; i++) {
// Sine envelope with randomisation
const envelope = Math.sin(Math.PI * i / (this.config.barCount - 1));
const rand = 0.4 + Math.random() * 0.6;
this.targetHeights[i] = Math.max(
height * this.config.idleAmplitude,
height * amplitude * envelope * rand
);
}
}
_getAmplitude() {
if (this.stopped) return this.config.idleAmplitude;
const now = performance.now();
const elapsed = now - this.lastPingTime;
if (this.lastPingTime === 0 || elapsed > this.config.decayMs) {
return this.config.idleAmplitude;
}
// Prune old timestamps (keep last 5s)
const cutoff = now - 5000;
this.pingTimestamps = this.pingTimestamps.filter(t => t > cutoff);
// Base amplitude from ping frequency (more pings = higher amplitude)
const freq = this.pingTimestamps.length / 5; // pings per second
const freqAmp = Math.min(1, 0.3 + freq * 0.35);
// Decay factor
const decay = 1 - (elapsed / this.config.decayMs);
return Math.max(this.config.idleAmplitude, freqAmp * decay);
}
_startLoop() {
const tick = () => {
this.animFrameId = requestAnimationFrame(tick);
this._update();
};
this.animFrameId = requestAnimationFrame(tick);
}
_update() {
if (!this.bars || !this.bars.length) return;
const { height } = this.config;
const minH = height * this.config.idleAmplitude;
const amplitude = this._getAmplitude();
const isActive = amplitude > this.config.idleAmplitude * 1.5;
// Toggle CSS class for breathing vs active
if (this.wrapperEl) {
this.wrapperEl.classList.toggle('idle', !isActive);
this.wrapperEl.classList.toggle('active', isActive);
}
// When idle, slowly drift targets with phase
if (!isActive) {
this.phase += 0.02;
for (let i = 0; i < this.bars.length; i++) {
this.targetHeights[i] = minH;
}
}
// Lerp current toward target
const lerp = isActive ? 0.18 : 0.06;
for (let i = 0; i < this.bars.length; i++) {
this.currentHeights[i] += (this.targetHeights[i] - this.currentHeights[i]) * lerp;
const h = Math.max(minH, this.currentHeights[i]);
this.bars[i].setAttribute('height', h.toFixed(1));
this.bars[i].setAttribute('y', (height - h).toFixed(1));
}
}
}
return { Live };
})();
window.SignalWaveform = SignalWaveform;
+9 -1
View File
@@ -7,6 +7,7 @@ const AlertCenter = (function() {
let rules = []; let rules = [];
let eventSource = null; let eventSource = null;
let reconnectTimer = null; let reconnectTimer = null;
let lastConnectionWarningAt = 0;
function init() { function init() {
loadRules(); loadRules();
@@ -31,7 +32,14 @@ const AlertCenter = (function() {
}; };
eventSource.onerror = function() { eventSource.onerror = function() {
console.warn('[Alerts] SSE connection error'); const now = Date.now();
const offline = (typeof window.isOffline === 'function' && window.isOffline()) ||
(typeof navigator !== 'undefined' && navigator.onLine === false);
const shouldLog = !offline && !document.hidden && (now - lastConnectionWarningAt) > 15000;
if (shouldLog) {
lastConnectionWarningAt = now;
console.warn('[Alerts] SSE connection error; retrying');
}
if (reconnectTimer) clearTimeout(reconnectTimer); if (reconnectTimer) clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(connect, 2500); reconnectTimer = setTimeout(connect, 2500);
}; };
+19
View File
@@ -8,6 +8,7 @@ const CheatSheets = (function () {
wifi: { title: 'WiFi Scanner', icon: '📡', hardware: 'WiFi adapter (monitor mode)', description: 'Scans WiFi networks and clients via airodump-ng or nmcli.', whatToExpect: 'SSIDs, BSSIDs, channel, signal strength, encryption type.', tips: ['Run airmon-ng check kill before monitoring', 'Proximity radar shows signal strength', 'TSCM baseline detects rogue APs'] }, wifi: { title: 'WiFi Scanner', icon: '📡', hardware: 'WiFi adapter (monitor mode)', description: 'Scans WiFi networks and clients via airodump-ng or nmcli.', whatToExpect: 'SSIDs, BSSIDs, channel, signal strength, encryption type.', tips: ['Run airmon-ng check kill before monitoring', 'Proximity radar shows signal strength', 'TSCM baseline detects rogue APs'] },
bluetooth: { title: 'Bluetooth Scanner', icon: '🔵', hardware: 'Built-in or USB Bluetooth adapter', description: 'Scans BLE and classic Bluetooth devices. Identifies trackers.', whatToExpect: 'Device names, MACs, RSSI, manufacturer, tracker type.', tips: ['Proximity radar shows device distance', 'Known tracker DB has 47K+ fingerprints', 'Use BT Locate to physically find a tracker'] }, bluetooth: { title: 'Bluetooth Scanner', icon: '🔵', hardware: 'Built-in or USB Bluetooth adapter', description: 'Scans BLE and classic Bluetooth devices. Identifies trackers.', whatToExpect: 'Device names, MACs, RSSI, manufacturer, tracker type.', tips: ['Proximity radar shows device distance', 'Known tracker DB has 47K+ fingerprints', 'Use BT Locate to physically find a tracker'] },
bt_locate: { title: 'BT Locate (SAR)', icon: '🎯', hardware: 'Bluetooth adapter + optional GPS', description: 'SAR Bluetooth locator. Tracks RSSI over time to triangulate position.', whatToExpect: 'RSSI chart, proximity band (IMMEDIATE/NEAR/FAR), GPS trail.', tips: ['Handoff from Bluetooth mode to lock onto a device', 'Indoor n=3.0 gives better distance estimates', 'Follow the heat trail toward stronger signal'] }, bt_locate: { title: 'BT Locate (SAR)', icon: '🎯', hardware: 'Bluetooth adapter + optional GPS', description: 'SAR Bluetooth locator. Tracks RSSI over time to triangulate position.', whatToExpect: 'RSSI chart, proximity band (IMMEDIATE/NEAR/FAR), GPS trail.', tips: ['Handoff from Bluetooth mode to lock onto a device', 'Indoor n=3.0 gives better distance estimates', 'Follow the heat trail toward stronger signal'] },
wifi_locate: { title: 'WiFi Locate', icon: '📶', hardware: 'WiFi adapter (monitor mode)', description: 'Locate a WiFi AP by BSSID with real-time signal strength tracking.', whatToExpect: 'Big dBm meter, signal bar, RSSI chart, distance estimate, proximity beeps.', tips: ['Handoff from WiFi mode — click Locate on any network', 'Deep scan required for continuous RSSI updates', 'Indoor n=3.5 gives better distance estimates indoors', 'Enable audio for proximity tones that speed up as you get closer'] },
meshtastic: { title: 'Meshtastic', icon: '🕸️', hardware: 'Meshtastic LoRa node (USB)', description: 'Monitors Meshtastic LoRa mesh network messages and positions.', whatToExpect: 'Text messages, node map, telemetry.', tips: ['Default channel must match your mesh', 'Long-Fast has best range', 'GPS nodes appear on map automatically'] }, meshtastic: { title: 'Meshtastic', icon: '🕸️', hardware: 'Meshtastic LoRa node (USB)', description: 'Monitors Meshtastic LoRa mesh network messages and positions.', whatToExpect: 'Text messages, node map, telemetry.', tips: ['Default channel must match your mesh', 'Long-Fast has best range', 'GPS nodes appear on map automatically'] },
adsb: { title: 'ADS-B Aircraft', icon: '✈️', hardware: 'RTL-SDR + 1090MHz antenna', description: 'Tracks aircraft via ADS-B Mode S transponders using dump1090.', whatToExpect: 'Flight numbers, positions, altitude, speed, squawk codes.', tips: ['1090MHz — use a dedicated antenna', 'Emergency squawks: 7500 hijack, 7600 radio fail, 7700 emergency', 'Full Dashboard shows map view'] }, adsb: { title: 'ADS-B Aircraft', icon: '✈️', hardware: 'RTL-SDR + 1090MHz antenna', description: 'Tracks aircraft via ADS-B Mode S transponders using dump1090.', whatToExpect: 'Flight numbers, positions, altitude, speed, squawk codes.', tips: ['1090MHz — use a dedicated antenna', 'Emergency squawks: 7500 hijack, 7600 radio fail, 7700 emergency', 'Full Dashboard shows map view'] },
ais: { title: 'AIS Vessels', icon: '🚢', hardware: 'RTL-SDR + VHF antenna (162 MHz)', description: 'Tracks marine vessels via AIS using AIS-catcher.', whatToExpect: 'MMSI, vessel names, positions, speed, heading, cargo type.', tips: ['VHF antenna centered at 162MHz works best', 'DSC distress alerts appear in red', 'Coastline range ~40 nautical miles'] }, ais: { title: 'AIS Vessels', icon: '🚢', hardware: 'RTL-SDR + VHF antenna (162 MHz)', description: 'Tracks marine vessels via AIS using AIS-catcher.', whatToExpect: 'MMSI, vessel names, positions, speed, heading, cargo type.', tips: ['VHF antenna centered at 162MHz works best', 'DSC distress alerts appear in red', 'Coastline range ~40 nautical miles'] },
@@ -24,6 +25,24 @@ const CheatSheets = (function () {
subghz: { title: 'SubGHz Transceiver', icon: '📡', hardware: 'HackRF One', description: 'Transmit and receive sub-GHz RF signals for IoT and industrial protocols.', whatToExpect: 'Raw signal capture, replay, and protocol analysis.', tips: ['Only use on licensed frequencies', 'Capture mode records raw IQ for replay', 'Common: garage doors, keyfobs, 315/433/868/915 MHz'] }, subghz: { title: 'SubGHz Transceiver', icon: '📡', hardware: 'HackRF One', description: 'Transmit and receive sub-GHz RF signals for IoT and industrial protocols.', whatToExpect: 'Raw signal capture, replay, and protocol analysis.', tips: ['Only use on licensed frequencies', 'Capture mode records raw IQ for replay', 'Common: garage doors, keyfobs, 315/433/868/915 MHz'] },
rtlamr: { title: 'Utility Meter Reader', icon: '⚡', hardware: 'RTL-SDR dongle', description: 'Reads AMI/AMR smart utility meter broadcasts via rtlamr.', whatToExpect: 'Meter IDs, consumption readings, interval data.', tips: ['Most meters broadcast on 915 MHz', 'MSG types 5, 7, 13, 21 most common', 'Consumption data is read-only public broadcast'] }, rtlamr: { title: 'Utility Meter Reader', icon: '⚡', hardware: 'RTL-SDR dongle', description: 'Reads AMI/AMR smart utility meter broadcasts via rtlamr.', whatToExpect: 'Meter IDs, consumption readings, interval data.', tips: ['Most meters broadcast on 915 MHz', 'MSG types 5, 7, 13, 21 most common', 'Consumption data is read-only public broadcast'] },
waterfall: { title: 'Spectrum Waterfall', icon: '🌊', hardware: 'RTL-SDR or HackRF (WebSocket)', description: 'Full-screen real-time FFT spectrum waterfall display.', whatToExpect: 'Color-coded signal intensity scrolling over time.', tips: ['Turbo palette has best contrast for weak signals', 'Peak hold shows max power in red', 'Hover over waterfall to see frequency'] }, waterfall: { title: 'Spectrum Waterfall', icon: '🌊', hardware: 'RTL-SDR or HackRF (WebSocket)', description: 'Full-screen real-time FFT spectrum waterfall display.', whatToExpect: 'Color-coded signal intensity scrolling over time.', tips: ['Turbo palette has best contrast for weak signals', 'Peak hold shows max power in red', 'Hover over waterfall to see frequency'] },
radiosonde: { title: 'Radiosonde Tracker', icon: '🎈', hardware: 'RTL-SDR dongle', description: 'Tracks weather balloons via radiosonde telemetry using radiosonde_auto_rx.', whatToExpect: 'Position, altitude, temperature, humidity, pressure from active sondes.', tips: ['Sondes transmit on 400406 MHz', 'Set your region to narrow the scan range', 'Gain 40 dB is a good starting point'] },
morse: { title: 'CW/Morse Decoder', icon: '📡', hardware: 'RTL-SDR + HF antenna (or upconverter)', description: 'Decodes CW Morse code via Goertzel tone detection or OOK envelope detection.', whatToExpect: 'Decoded Morse characters, WPM estimate, signal level.', tips: ['CW Tone mode for HF amateur bands (e.g. 7.030, 14.060 MHz)', 'OOK Envelope mode for ISM/UHF signals', 'Use band presets for quick tuning to CW sub-bands'] },
meteor: { title: 'Meteor Scatter', icon: '☄️', hardware: 'RTL-SDR + VHF antenna (143 MHz)', description: 'Monitors VHF beacon reflections from meteor ionization trails.', whatToExpect: 'Waterfall display with transient ping detections and event logging.', tips: ['GRAVES radar at 143.050 MHz is the primary target', 'Use a Yagi pointed south (from Europe) for best results', 'Peak activity during annual meteor showers (Perseids, Geminids)'] },
ook: {
title: 'OOK Signal Decoder',
icon: '📡',
hardware: 'RTL-SDR dongle',
description: 'Decodes raw On-Off Keying (OOK) signals via rtl_433 flex decoder. Captures frames with configurable pulse timing and displays raw bits, hex, and ASCII — useful for reverse-engineering unknown ISM-band protocols.',
whatToExpect: 'Decoded bit sequences, hex payloads, and ASCII interpretation. Each frame shows bit count, timestamp, and optional RSSI.',
tips: [
'<strong>Identifying modulation</strong> — <em>PWM</em>: pulse widths vary (short=0, long=1), gaps constant — most common for ISM remotes/sensors. <em>PPM</em>: pulses constant, gap widths encode data. <em>Manchester</em>: self-clocking, equal-width pulses, data in transitions.',
'<strong>Finding pulse timing</strong> — Run <code>rtl_433 -f 433.92M -A</code> in a terminal to auto-analyze signals. It prints detected pulse widths (short/long) and gap timings. Use those values in the Short/Long Pulse fields.',
'<strong>Common ISM timings</strong> — 300/600µs (weather stations, door sensors), 400/800µs (car keyfobs), 500/1500µs (garage doors, doorbells), 500µs Manchester (tire pressure monitors).',
'<strong>Frequencies to try</strong> — 315 MHz (North America keyfobs), 433.920 MHz (global ISM), 868 MHz (Europe ISM), 915 MHz (US ISM/meters).',
'<strong>Troubleshooting</strong> — Garbled output? Try halving or doubling pulse timings. No frames? Increase tolerance (±200300µs). Too many frames? Enable deduplication. Wrong characters? Toggle MSB/LSB bit order.',
'<strong>Tolerance &amp; reset</strong> — Tolerance is how much timing can drift (±150µs default). Reset limit is the silence gap that ends a frame (8000µs). Lower gap limit if frames are merging together.',
]
},
}; };
function show(mode) { function show(mode) {
+40 -6
View File
@@ -2,12 +2,13 @@ const RunState = (function() {
'use strict'; 'use strict';
const REFRESH_MS = 5000; const REFRESH_MS = 5000;
const CHIP_MODES = ['pager', 'sensor', 'wifi', 'bluetooth', 'adsb', 'ais', 'acars', 'vdl2', 'aprs', 'dsc', 'subghz']; const CHIP_MODES = ['pager', 'sensor', 'wifi', 'bluetooth', 'adsb', 'ais', 'acars', 'vdl2', 'aprs', 'dsc', 'subghz', 'radiosonde', 'morse', 'rtlamr', 'meshtastic', 'sstv', 'weathersat', 'wefax', 'sstv_general', 'tscm', 'gps', 'bt_locate', 'meteor'];
const MODE_ALIASES = { const MODE_ALIASES = {
bt: 'bluetooth', bt: 'bluetooth',
bt_locate: 'bluetooth',
btlocate: 'bluetooth', btlocate: 'bluetooth',
aircraft: 'adsb', aircraft: 'adsb',
sonde: 'radiosonde',
weather_sat: 'weathersat',
}; };
const modeLabels = { const modeLabels = {
@@ -22,6 +23,18 @@ const RunState = (function() {
aprs: 'APRS', aprs: 'APRS',
dsc: 'DSC', dsc: 'DSC',
subghz: 'SubGHz', subghz: 'SubGHz',
radiosonde: 'Sonde',
morse: 'Morse',
rtlamr: 'Meter',
meshtastic: 'Mesh',
sstv: 'SSTV',
weathersat: 'WxSat',
wefax: 'WeFax',
sstv_general: 'HF SSTV',
tscm: 'TSCM',
gps: 'GPS',
bt_locate: 'BT Loc',
meteor: 'Meteor',
}; };
let refreshTimer = null; let refreshTimer = null;
@@ -92,8 +105,9 @@ const RunState = (function() {
renderHealth(data); renderHealth(data);
} catch (err) { } catch (err) {
renderHealth(null, err); renderHealth(null, err);
const transient = isTransientFailure(err);
const now = Date.now(); const now = Date.now();
if (typeof reportActionableError === 'function' && (now - lastErrorToastAt) > 30000) { if (!transient && typeof reportActionableError === 'function' && (now - lastErrorToastAt) > 30000) {
lastErrorToastAt = now; lastErrorToastAt = now;
reportActionableError('Run State', err, { persistent: false }); reportActionableError('Run State', err, { persistent: false });
} }
@@ -180,6 +194,17 @@ const RunState = (function() {
if (normalized.includes('aprs')) return 'aprs'; if (normalized.includes('aprs')) return 'aprs';
if (normalized.includes('dsc')) return 'dsc'; if (normalized.includes('dsc')) return 'dsc';
if (normalized.includes('subghz')) return 'subghz'; if (normalized.includes('subghz')) return 'subghz';
if (normalized.includes('radiosonde') || normalized.includes('sonde')) return 'radiosonde';
if (normalized.includes('morse')) return 'morse';
if (normalized.includes('meter') || normalized.includes('rtlamr')) return 'rtlamr';
if (normalized.includes('meshtastic') || normalized.includes('mesh')) return 'meshtastic';
if (normalized.includes('hf sstv') || normalized.includes('sstv general')) return 'sstv_general';
if (normalized.includes('sstv')) return 'sstv';
if (normalized.includes('weather') && normalized.includes('sat')) return 'weathersat';
if (normalized.includes('wefax') || normalized.includes('weather fax')) return 'wefax';
if (normalized.includes('tscm')) return 'tscm';
if (normalized.includes('gps')) return 'gps';
if (normalized.includes('bt loc')) return 'bt_locate';
if (normalized.includes('433')) return 'sensor'; if (normalized.includes('433')) return 'sensor';
return 'pager'; return 'pager';
} }
@@ -195,9 +220,7 @@ const RunState = (function() {
processes.bluetooth = Boolean( processes.bluetooth = Boolean(
processes.bluetooth || processes.bluetooth ||
processes.bt || processes.bt ||
processes.bt_scan || processes.bt_scan
processes.btlocate ||
processes.bt_locate
); );
processes.wifi = Boolean( processes.wifi = Boolean(
processes.wifi || processes.wifi ||
@@ -214,6 +237,17 @@ const RunState = (function() {
return String(err); return String(err);
} }
function isTransientFailure(err) {
if (typeof window.isTransientOrOffline === 'function' && window.isTransientOrOffline(err)) {
return true;
}
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
return true;
}
const text = extractMessage(err).toLowerCase();
return text.includes('failed to fetch') || text.includes('network') || text.includes('timeout');
}
function getLastHealth() { function getLastHealth() {
return lastHealth; return lastHealth;
} }
+7 -4
View File
@@ -1281,6 +1281,7 @@ function loadVoiceAlertConfig() {
const pager = document.getElementById('voiceCfgPager'); const pager = document.getElementById('voiceCfgPager');
const tscm = document.getElementById('voiceCfgTscm'); const tscm = document.getElementById('voiceCfgTscm');
const tracker = document.getElementById('voiceCfgTracker'); const tracker = document.getElementById('voiceCfgTracker');
const military = document.getElementById('voiceCfgAdsbMilitary');
const squawk = document.getElementById('voiceCfgSquawk'); const squawk = document.getElementById('voiceCfgSquawk');
const rate = document.getElementById('voiceCfgRate'); const rate = document.getElementById('voiceCfgRate');
const pitch = document.getElementById('voiceCfgPitch'); const pitch = document.getElementById('voiceCfgPitch');
@@ -1290,6 +1291,7 @@ function loadVoiceAlertConfig() {
if (pager) pager.checked = cfg.streams.pager !== false; if (pager) pager.checked = cfg.streams.pager !== false;
if (tscm) tscm.checked = cfg.streams.tscm !== false; if (tscm) tscm.checked = cfg.streams.tscm !== false;
if (tracker) tracker.checked = cfg.streams.bluetooth !== false; if (tracker) tracker.checked = cfg.streams.bluetooth !== false;
if (military) military.checked = cfg.streams.adsb_military !== false;
if (squawk) squawk.checked = cfg.streams.squawks !== false; if (squawk) squawk.checked = cfg.streams.squawks !== false;
if (rate) rate.value = cfg.rate; if (rate) rate.value = cfg.rate;
if (pitch) pitch.value = cfg.pitch; if (pitch) pitch.value = cfg.pitch;
@@ -1314,10 +1316,11 @@ function saveVoiceAlertConfig() {
pitch: parseFloat(document.getElementById('voiceCfgPitch')?.value) || 0.9, pitch: parseFloat(document.getElementById('voiceCfgPitch')?.value) || 0.9,
voiceName: document.getElementById('voiceCfgVoice')?.value || '', voiceName: document.getElementById('voiceCfgVoice')?.value || '',
streams: { streams: {
pager: !!document.getElementById('voiceCfgPager')?.checked, pager: !!document.getElementById('voiceCfgPager')?.checked,
tscm: !!document.getElementById('voiceCfgTscm')?.checked, tscm: !!document.getElementById('voiceCfgTscm')?.checked,
bluetooth: !!document.getElementById('voiceCfgTracker')?.checked, bluetooth: !!document.getElementById('voiceCfgTracker')?.checked,
squawks: !!document.getElementById('voiceCfgSquawk')?.checked, adsb_military: !!document.getElementById('voiceCfgAdsbMilitary')?.checked,
squawks: !!document.getElementById('voiceCfgSquawk')?.checked,
}, },
}); });
} }
+39 -2
View File
@@ -208,9 +208,31 @@ const AppFeedback = (function() {
return state; return state;
} }
function isOffline() {
return typeof navigator !== 'undefined' && navigator.onLine === false;
}
function isTransientNetworkError(error) {
const text = String(extractMessage(error) || '').toLowerCase();
if (!text) return false;
return text.includes('networkerror') ||
text.includes('failed to fetch') ||
text.includes('network request failed') ||
text.includes('load failed') ||
text.includes('err_network_io_suspended') ||
text.includes('network io suspended') ||
text.includes('the network connection was lost') ||
text.includes('connection reset') ||
text.includes('timeout');
}
function isTransientOrOffline(error) {
return isOffline() || isTransientNetworkError(error);
}
function isNetworkError(message) { function isNetworkError(message) {
const text = String(message || '').toLowerCase(); return isTransientNetworkError(message);
return text.includes('networkerror') || text.includes('failed to fetch') || text.includes('timeout');
} }
function isSettingsError(message) { function isSettingsError(message) {
@@ -224,6 +246,9 @@ const AppFeedback = (function() {
reportError, reportError,
removeToast, removeToast,
renderCollectionState, renderCollectionState,
isOffline,
isTransientNetworkError,
isTransientOrOffline,
}; };
})(); })();
@@ -243,6 +268,18 @@ window.renderCollectionState = function(container, options) {
return AppFeedback.renderCollectionState(container, options); return AppFeedback.renderCollectionState(container, options);
}; };
window.isOffline = function() {
return AppFeedback.isOffline();
};
window.isTransientNetworkError = function(error) {
return AppFeedback.isTransientNetworkError(error);
};
window.isTransientOrOffline = function(error) {
return AppFeedback.isTransientOrOffline(error);
};
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
AppFeedback.init(); AppFeedback.init();
}); });
+7 -1
View File
@@ -20,7 +20,13 @@ const VoiceAlerts = (function () {
rate: 1.1, rate: 1.1,
pitch: 0.9, pitch: 0.9,
voiceName: '', voiceName: '',
streams: { pager: true, tscm: true, bluetooth: true }, streams: {
pager: true,
tscm: true,
bluetooth: true,
adsb_military: true,
squawks: true,
},
}; };
function _toNumberInRange(value, fallback, min, max) { function _toNumberInRange(value, fallback, min, max) {

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