Compare commits

..

151 Commits

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 20:27:35 +00:00
111 changed files with 24791 additions and 7132 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.
+40 -1
View File
@@ -2,11 +2,50 @@
All notable changes to iNTERCEPT will be documented in this file.
## [2.22.2] - 2026-02-23
## [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
### 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
### Fixed
- ADS-B device release leak and startup performance regression
- ADS-B probe incorrectly treating "No devices found" as success
- USB claim race condition after SDR probe
- SDR device registry collision when multiple SDR types present
- APRS 15-minute startup delay caused by pipe buffering
- APRS map centering at [0,0] when GPS unavailable
- DSC decoder ITU-R M.493 compliance issues
- Weather satellite 0dB SNR — increased sample rate for Meteor LRPT
- SSE fanout backlog causing delayed updates across all modes
- SSE reconnect packet loss during client reconnection
- Waterfall monitor tuning race conditions
- Mode FOUC (flash of unstyled content) on initial navigation
- Various Morse decoder stability and lifecycle fixes
---
## [2.22.3] - 2026-02-23
### Fixed
- Waterfall control panel rendered as unstyled text for up to 20 seconds on first visit — CSS is now loaded eagerly with the rest of the page assets
- WebSDR globe failed to render on first page load — initialization now waits for a layout frame before mounting the WebGL renderer, ensuring the container has non-zero dimensions
- Waterfall monitor audio took minutes to start — `_waitForPlayback` now only reports success on actual audio playback (`playing`/`timeupdate`), not from the WAV header alone (`loadeddata`/`canplay`)
- Waterfall monitor could not be stopped — `stopMonitor()` now pauses audio and updates the UI immediately instead of waiting for the backend stop request (which blocked for 1+ seconds during SDR process cleanup)
- Stopping the waterfall no longer shows a stale "WebSocket closed before ready" message — the `onclose` handler now detects intentional closes
---
+13 -1
View File
@@ -200,6 +200,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& make install \
&& ldconfig \
&& 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 \
&& 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)
&& cd /tmp \
&& curl -fsSL "https://go.dev/dl/go1.22.5.linux-$(dpkg --print-architecture).tar.gz" | tar -C /usr/local -xz \
@@ -246,10 +257,11 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Create data directory for persistence
RUN mkdir -p /app/data /app/data/weather_sat
RUN mkdir -p /app/data /app/data/weather_sat /app/data/radiosonde/logs
# Expose web interface port
EXPOSE 5050
EXPOSE 5443
# Environment variables with defaults
ENV INTERCEPT_HOST=0.0.0.0 \
+32 -6
View File
@@ -50,12 +50,38 @@ Support the developer of this open-source project
- **Meshtastic** - LoRa mesh network integration
- **Space Weather** - Real-time solar and geomagnetic data from NOAA SWPC, NASA SDO, and HamQSL (no SDR required)
- **Spy Stations** - Number stations and diplomatic HF network database
- **Remote Agents** - Distributed SIGINT with remote sensor nodes
- **Offline Mode** - Bundled assets for air-gapped/field deployments
---
## Installation / Debian / Ubuntu / MacOS
- **Remote Agents** - Distributed SIGINT with remote sensor nodes
- **Offline Mode** - Bundled assets for air-gapped/field deployments
---
## CW / Morse Decoder Notes
Live backend:
- Uses `rtl_fm` piped into `multimon-ng` (`MORSE_CW`) for real-time decode.
Recommended baseline settings:
- **Tone**: `700 Hz`
- **Bandwidth**: `200 Hz` (use `100 Hz` for crowded bands, `400 Hz` for drifting signals)
- **Threshold Mode**: `Auto`
- **WPM Mode**: `Auto`
Auto Tone Track behavior:
- Continuously measures nearby tone energy around the configured CW pitch.
- Steers the detector toward the strongest valid CW tone when signal-to-noise is sufficient.
- Use **Hold Tone Lock** to freeze tracking once the desired signal is centered.
Troubleshooting (no decode / noisy decode):
- Confirm demod path is **USB/CW-compatible** and frequency is tuned correctly.
- If multiple SDRs are connected and the selected one has no PCM output, Morse startup now auto-tries other detected SDR devices and reports the active device/serial in status logs.
- Match **tone** and **bandwidth** to the actual sidetone/pitch.
- Try **Threshold Auto** first; if needed, switch to manual threshold and recalibrate.
- Use **Reset/Calibrate** after major frequency or band condition changes.
- Raise **Minimum Signal Gate** to suppress random noise keying.
---
## Installation / Debian / Ubuntu / MacOS
**1. Clone and run:**
```bash
+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",
"downloaded": "2026-02-20T00:29:06.228007Z"
"version": "2026-02-22_17194a71",
"downloaded": "2026-02-27T10:41:04.872620Z"
}
+101 -24
View File
@@ -198,6 +198,16 @@ tscm_lock = threading.Lock()
subghz_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
subghz_lock = threading.Lock()
# Radiosonde weather balloon tracking
radiosonde_process = None
radiosonde_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
radiosonde_lock = threading.Lock()
# CW/Morse code decoder
morse_process = None
morse_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
morse_lock = threading.Lock()
# Deauth Attack Detection
deauth_detector = None
deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
@@ -252,12 +262,12 @@ cleanup_manager.register(deauth_alerts)
# SDR DEVICE REGISTRY
# ============================================
# Tracks which mode is using which SDR device to prevent conflicts
# Key: device_index (int), Value: mode_name (str)
sdr_device_registry: dict[int, str] = {}
# Key: "sdr_type:device_index" (str), Value: mode_name (str)
sdr_device_registry: dict[str, str] = {}
sdr_device_registry_lock = threading.Lock()
def claim_sdr_device(device_index: int, mode_name: str) -> str | None:
def claim_sdr_device(device_index: int, mode_name: str, sdr_type: str = 'rtlsdr') -> str | None:
"""Claim an SDR device for a mode.
Checks the in-app registry first, then probes the USB device to
@@ -267,43 +277,48 @@ def claim_sdr_device(device_index: int, mode_name: str) -> str | None:
Args:
device_index: The SDR device index to claim
mode_name: Name of the mode claiming the device (e.g., 'sensor', 'rtlamr')
sdr_type: SDR type string (e.g., 'rtlsdr', 'hackrf', 'limesdr')
Returns:
Error message if device is in use, None if successfully claimed
"""
key = f"{sdr_type}:{device_index}"
with sdr_device_registry_lock:
if device_index in sdr_device_registry:
in_use_by = sdr_device_registry[device_index]
return f'SDR device {device_index} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.'
if key in sdr_device_registry:
in_use_by = sdr_device_registry[key]
return f'SDR device {sdr_type}:{device_index} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.'
# Probe the USB device to catch external processes holding the handle
try:
from utils.sdr.detection import probe_rtlsdr_device
usb_error = probe_rtlsdr_device(device_index)
if usb_error:
return usb_error
except Exception:
pass # If probe fails, let the caller proceed normally
if sdr_type == 'rtlsdr':
try:
from utils.sdr.detection import probe_rtlsdr_device
usb_error = probe_rtlsdr_device(device_index)
if usb_error:
return usb_error
except Exception:
pass # If probe fails, let the caller proceed normally
sdr_device_registry[device_index] = mode_name
sdr_device_registry[key] = mode_name
return None
def release_sdr_device(device_index: int) -> None:
def release_sdr_device(device_index: int, sdr_type: str = 'rtlsdr') -> None:
"""Release an SDR device from the registry.
Args:
device_index: The SDR device index to release
sdr_type: SDR type string (e.g., 'rtlsdr', 'hackrf', 'limesdr')
"""
key = f"{sdr_type}:{device_index}"
with sdr_device_registry_lock:
sdr_device_registry.pop(device_index, None)
sdr_device_registry.pop(key, None)
def get_sdr_device_status() -> dict[int, str]:
def get_sdr_device_status() -> dict[str, str]:
"""Get current SDR device allocations.
Returns:
Dictionary mapping device indices to mode names
Dictionary mapping 'sdr_type:device_index' keys to mode names
"""
with sdr_device_registry_lock:
return dict(sdr_device_registry)
@@ -424,8 +439,9 @@ def get_devices_status() -> Response:
result = []
for device in devices:
d = device.to_dict()
d['in_use'] = device.index in registry
d['used_by'] = registry.get(device.index)
key = f"{device.sdr_type.value}:{device.index}"
d['in_use'] = key in registry
d['used_by'] = registry.get(key)
result.append(d)
return jsonify(result)
@@ -755,6 +771,8 @@ def health_check() -> Response:
'wifi': wifi_active,
'bluetooth': bt_active,
'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(),
},
'data': {
@@ -772,12 +790,13 @@ def health_check() -> Response:
def kill_all() -> Response:
"""Kill all decoder, WiFi, and Bluetooth processes."""
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
global vdl2_process
global vdl2_process, morse_process, radiosonde_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 ais as ais_module
from routes import radiosonde as radiosonde_module
from utils.bluetooth import reset_bluetooth_scanner
killed = []
@@ -787,7 +806,8 @@ def kill_all() -> Response:
'dump1090', 'acarsdec', 'dumpvdl2', 'direwolf', 'AIS-catcher',
'hcitool', 'bluetoothctl', 'satdump',
'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg',
'hackrf_transfer', 'hackrf_sweep'
'hackrf_transfer', 'hackrf_sweep',
'auto_rx'
]
for proc in processes_to_kill:
@@ -817,6 +837,11 @@ def kill_all() -> Response:
ais_process = None
ais_module.ais_running = False
# Reset Radiosonde state
with radiosonde_lock:
radiosonde_process = None
radiosonde_module.radiosonde_running = False
# Reset ACARS state
with acars_lock:
acars_process = None
@@ -825,6 +850,10 @@ def kill_all() -> Response:
with vdl2_lock:
vdl2_process = None
# Reset Morse state
with morse_lock:
morse_process = None
# Reset APRS state
with aprs_lock:
aprs_process = None
@@ -869,6 +898,36 @@ def kill_all() -> Response:
return jsonify({'status': 'killed', 'processes': killed})
def _ensure_self_signed_cert(cert_dir: str) -> tuple:
"""Generate a self-signed certificate if one doesn't already exist.
Returns (cert_path, key_path) tuple.
"""
cert_path = os.path.join(cert_dir, 'intercept.crt')
key_path = os.path.join(cert_dir, 'intercept.key')
if os.path.exists(cert_path) and os.path.exists(key_path):
print(f"Using existing SSL certificate: {cert_path}")
return cert_path, key_path
os.makedirs(cert_dir, exist_ok=True)
print("Generating self-signed SSL certificate...")
import subprocess
result = subprocess.run([
'openssl', 'req', '-x509', '-newkey', 'rsa:2048',
'-keyout', key_path, '-out', cert_path,
'-days', '365', '-nodes',
'-subj', '/CN=intercept/O=INTERCEPT/C=US',
], capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f"Failed to generate SSL certificate: {result.stderr}")
print(f"SSL certificate generated: {cert_path}")
return cert_path, key_path
def main() -> None:
"""Main entry point."""
import argparse
@@ -895,6 +954,12 @@ def main() -> None:
default=config.DEBUG,
help='Enable debug mode'
)
parser.add_argument(
'--https',
action='store_true',
default=config.HTTPS,
help='Enable HTTPS with self-signed certificate'
)
parser.add_argument(
'--check-deps',
action='store_true',
@@ -1020,7 +1085,18 @@ def main() -> None:
except ImportError as e:
print(f"WebSocket waterfall disabled: {e}")
print(f"Open http://localhost:{args.port} in your browser")
# Configure SSL if HTTPS is enabled
ssl_context = None
if args.https:
cert_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data', 'certs')
if config.SSL_CERT and config.SSL_KEY:
ssl_context = (config.SSL_CERT, config.SSL_KEY)
print(f"Using provided SSL certificate: {config.SSL_CERT}")
else:
ssl_context = _ensure_self_signed_cert(cert_dir)
protocol = 'https' if ssl_context else 'http'
print(f"Open {protocol}://localhost:{args.port} in your browser")
print()
print("Press Ctrl+C to stop")
print()
@@ -1032,4 +1108,5 @@ def main() -> None:
debug=args.debug,
threaded=True,
load_dotenv=False,
ssl_context=ssl_context,
)
+40 -3
View File
@@ -7,16 +7,34 @@ import os
import sys
# Application version
VERSION = "2.22.2"
VERSION = "2.23.0"
# Changelog - latest release notes (shown on welcome screen)
CHANGELOG = [
{
"version": "2.22.2",
"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",
"Multi-SDR support for WeFax, tool path overrides, native Homebrew detection",
"GPS mode upgraded to textured 3D globe",
"Destroy lifecycle added to all mode modules to prevent resource leaks",
"Dozens of bug fixes across ADS-B, APRS, SSE, Morse, waterfall, and more",
]
},
{
"version": "2.22.3",
"date": "February 2026",
"highlights": [
"Waterfall control panel no longer shows as unstyled text on first visit",
"WebSDR globe renders correctly on first page load without requiring a refresh",
"Waterfall monitor audio no longer takes minutes to start — playback detection now waits for real audio data instead of just the WAV header",
"Waterfall monitor stop is now instant — audio pauses and UI updates immediately instead of waiting for backend cleanup",
"Stopping the waterfall no longer shows a stale 'WebSocket closed before ready' message",
]
},
{
@@ -277,6 +295,11 @@ PORT = _get_env_int('PORT', 5050)
DEBUG = _get_env_bool('DEBUG', False)
THREADED = _get_env_bool('THREADED', True)
# HTTPS / SSL settings
HTTPS = _get_env_bool('HTTPS', False)
SSL_CERT = _get_env('SSL_CERT', '')
SSL_KEY = _get_env('SSL_KEY', '')
# Default RTL-SDR settings
DEFAULT_GAIN = _get_env('DEFAULT_GAIN', '40')
DEFAULT_DEVICE = _get_env('DEFAULT_DEVICE', '0')
@@ -323,12 +346,20 @@ SATELLITE_ORBIT_MINUTES = _get_env_int('SATELLITE_ORBIT_MINUTES', 45)
# Weather satellite settings
WEATHER_SAT_DEFAULT_GAIN = _get_env_float('WEATHER_SAT_GAIN', 40.0)
WEATHER_SAT_SAMPLE_RATE = _get_env_int('WEATHER_SAT_SAMPLE_RATE', 1000000)
WEATHER_SAT_SAMPLE_RATE = _get_env_int('WEATHER_SAT_SAMPLE_RATE', 2400000)
WEATHER_SAT_MIN_ELEVATION = _get_env_float('WEATHER_SAT_MIN_ELEVATION', 15.0)
WEATHER_SAT_PREDICTION_HOURS = _get_env_int('WEATHER_SAT_PREDICTION_HOURS', 24)
WEATHER_SAT_SCHEDULE_REFRESH_MINUTES = _get_env_int('WEATHER_SAT_SCHEDULE_REFRESH_MINUTES', 30)
WEATHER_SAT_CAPTURE_BUFFER_SECONDS = _get_env_int('WEATHER_SAT_CAPTURE_BUFFER_SECONDS', 30)
# WeFax (Weather Fax) settings
WEFAX_DEFAULT_GAIN = _get_env_float('WEFAX_GAIN', 40.0)
WEFAX_SAMPLE_RATE = _get_env_int('WEFAX_SAMPLE_RATE', 22050)
WEFAX_DEFAULT_IOC = _get_env_int('WEFAX_IOC', 576)
WEFAX_DEFAULT_LPM = _get_env_int('WEFAX_LPM', 120)
WEFAX_SCHEDULE_REFRESH_MINUTES = _get_env_int('WEFAX_SCHEDULE_REFRESH_MINUTES', 30)
WEFAX_CAPTURE_BUFFER_SECONDS = _get_env_int('WEFAX_CAPTURE_BUFFER_SECONDS', 30)
# SubGHz transceiver settings (HackRF)
SUBGHZ_DEFAULT_FREQUENCY = _get_env_float('SUBGHZ_FREQUENCY', 433.92)
SUBGHZ_DEFAULT_SAMPLE_RATE = _get_env_int('SUBGHZ_SAMPLE_RATE', 2000000)
@@ -339,6 +370,12 @@ SUBGHZ_MAX_TX_DURATION = _get_env_int('SUBGHZ_MAX_TX_DURATION', 10)
SUBGHZ_SWEEP_START_MHZ = _get_env_float('SUBGHZ_SWEEP_START', 300.0)
SUBGHZ_SWEEP_END_MHZ = _get_env_float('SUBGHZ_SWEEP_END', 928.0)
# Radiosonde settings
RADIOSONDE_FREQ_MIN = _get_env_float('RADIOSONDE_FREQ_MIN', 400.0)
RADIOSONDE_FREQ_MAX = _get_env_float('RADIOSONDE_FREQ_MAX', 406.0)
RADIOSONDE_DEFAULT_GAIN = _get_env_float('RADIOSONDE_GAIN', 40.0)
RADIOSONDE_UDP_PORT = _get_env_int('RADIOSONDE_UDP_PORT', 55673)
# Update checking
GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept')
UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True)
+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"}
]
}
]
}
+10
View File
@@ -18,6 +18,8 @@ services:
container_name: intercept
ports:
- "5050:5050"
# Uncomment for HTTPS support (set INTERCEPT_HTTPS=true below)
# - "5443:5443"
# Privileged mode required for USB SDR device access
privileged: true
# USB device mapping for all USB devices
@@ -32,6 +34,9 @@ services:
- INTERCEPT_HOST=0.0.0.0
- INTERCEPT_PORT=5050
- INTERCEPT_LOG_LEVEL=INFO
# HTTPS support (auto-generates self-signed cert)
# - INTERCEPT_HTTPS=true
# - INTERCEPT_PORT=5443
# ADS-B history is disabled by default
# To enable, use: docker compose --profile history up -d
# - INTERCEPT_ADSB_HISTORY_ENABLED=true
@@ -70,6 +75,8 @@ services:
- adsb_db
ports:
- "5050:5050"
# Uncomment for HTTPS support (set INTERCEPT_HTTPS=true below)
# - "5443:5443"
# Privileged mode required for USB SDR device access
privileged: true
# USB device mapping for all USB devices
@@ -81,6 +88,9 @@ services:
- INTERCEPT_HOST=0.0.0.0
- INTERCEPT_PORT=5050
- INTERCEPT_LOG_LEVEL=INFO
# HTTPS support (auto-generates self-signed cert)
# - INTERCEPT_HTTPS=true
# - INTERCEPT_PORT=5443
- INTERCEPT_ADSB_HISTORY_ENABLED=true
- INTERCEPT_ADSB_DB_HOST=adsb_db
- INTERCEPT_ADSB_DB_PORT=5432
+43 -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
- **Integrated with ADS-B dashboard** - VDL2 messages linked to aircraft tracking
## CW/Morse Code Decoder
- **Custom Goertzel tone detection** for CW (continuous wave) Morse decoding
- **OOK/AM envelope detection** mode for on-off keying signals in ISM bands
- **HF frequency presets** for amateur CW bands (160m-10m)
- **ISM band presets** for OOK envelope mode (315 MHz, 433 MHz, 868 MHz, 915 MHz)
- **Real-time character and word output** with WPM estimation
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
## WeFax (Weather Fax)
- **HF weather fax reception** from marine and meteorological broadcast stations
- **Broadcast timeline** with scheduled transmission times by station
- **Auto-scheduler** for unattended capture of scheduled broadcasts
- **Image gallery** with timestamped decoded weather charts
- **Station presets** for major WeFax broadcasters worldwide
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
## Listening Post
- **Wideband frequency scanning** via rtl_power sweep with SNR filtering
- **Real-time audio monitoring** with FM and SSB demodulation
- **Cross-module frequency routing** from scanner to decoders
- **Waterfall spectrum display** for visual signal identification
- **Customizable frequency presets** and band bookmarks
- **Multi-SDR support** - RTL-SDR, LimeSDR, HackRF, Airspy, SDRplay
@@ -170,6 +189,16 @@ Digital Selective Calling (DSC) monitoring on the international maritime distres
- **Auto-refresh** - 5-minute polling with manual refresh option
- **No SDR required** - Data fetched from NOAA SWPC, NASA SDO, and HamQSL public APIs
## Radiosonde Weather Balloon Tracking
- **400-406 MHz reception** via radiosonde_auto_rx for weather balloon telemetry
- **Frequency presets** for common radiosonde bands
- **Real-time telemetry** - altitude, temperature, humidity, pressure, GPS position
- **Interactive map** with balloon trajectory and burst point prediction
- **Station location** with configurable observer position
- **Distance tracking** - real-time distance-to-balloon calculation
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
## Satellite Tracking
- **Full-screen dashboard** - dedicated popout with polar plot and ground track
@@ -270,7 +299,7 @@ Technical Surveillance Countermeasures (TSCM) screening for detecting wireless s
### Wireless Sweep Features
- **BLE scanning** with manufacturer data detection (AirTags, Tile, SmartTags, ESP32)
- **WiFi scanning** for rogue APs, hidden SSIDs, camera devices
- **RF spectrum analysis** (requires RTL-SDR) - FM bugs, ISM bands, video transmitters
- **RF spectrum analysis** (RTL-SDR or HackRF) - FM bugs, ISM bands, video transmitters
- **Cross-protocol correlation** - links devices across BLE/WiFi/RF
- **Baseline comparison** - detect new/unknown devices vs known environment
@@ -369,6 +398,14 @@ Deploy lightweight sensor nodes across multiple locations and aggregate data to
- **Redundancy** - Multiple nodes for reliable coverage
- **Triangulation** - Use multiple GPS-enabled agents for signal location
## System Health
- **Telemetry dashboard** with real-time system metrics
- **Process monitoring** for all running SDR tools and decoders
- **CPU, memory, and disk usage** tracking
- **SDR device status** overview
- **No SDR required** - monitors system health independently
## User Interface
- **Mode-specific header stats** - real-time badges showing key metrics per mode
@@ -434,9 +471,13 @@ The settings modal shows availability status for each bundled asset:
- **Message export** to CSV/JSON
- **Signal activity meter** and waterfall display
- **Message logging** to file with timestamps
- **Multi-SDR hardware support** - RTL-SDR, LimeSDR, HackRF
- **HTTPS support** via `INTERCEPT_HTTPS` configuration for secure deployments
- **Voice alerts** for configurable event notifications across modes
- **Multi-SDR hardware support** - RTL-SDR, LimeSDR, HackRF, Airspy, SDRplay
- **Automatic device detection** across all supported hardware
- **Hardware-specific validation** - frequency/gain ranges per device type
- **Tool path overrides** via `INTERCEPT_*_PATH` environment variables
- **Native Homebrew detection** for Apple Silicon tool paths
- **Configurable gain and PPM correction**
- **Device intelligence** dashboard with tracking
- **GPS dongle support** - USB GPS receivers for precise observer location
+21 -1
View File
@@ -36,7 +36,7 @@
</div>
<div class="hero-stats">
<div class="stat">
<span class="stat-value">25+</span>
<span class="stat-value">30+</span>
<span class="stat-label">Modes</span>
</div>
<div class="stat">
@@ -92,6 +92,11 @@
<h3>Listening Post</h3>
<p>Frequency scanner with real-time audio monitoring, fine-tuning controls, and customizable frequency presets.</p>
</div>
<div class="feature-card" data-category="signals">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h2"/><path d="M8 12h1"/><path d="M11 12h2"/><path d="M15 12h1"/><path d="M18 12h2"/><circle cx="6" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="18" cy="12" r="1"/><path d="M4 8h16"/><path d="M4 16h16"/></svg></div>
<h3>CW/Morse Decoder</h3>
<p>Morse code decoding with custom Goertzel tone detection for CW and OOK/AM envelope detection for ISM band signals.</p>
</div>
<div class="feature-card" data-category="intel">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></div>
<h3>WebSDR</h3>
@@ -152,11 +157,21 @@
<h3>HF SSTV</h3>
<p>Terrestrial SSTV on shortwave frequencies. Decode amateur radio image transmissions across HF, VHF, and UHF bands.</p>
</div>
<div class="feature-card" data-category="space">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 15h18"/><path d="M3 9h18"/><path d="M6 3v18"/><path d="M18 3v18"/><path d="M9 6h6"/></svg></div>
<h3>WeFax</h3>
<p>HF weather fax decoder with broadcast timeline, auto-scheduler, and image gallery for marine weather charts.</p>
</div>
<div class="feature-card" data-category="tracking">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/><path d="M12 8l4 4-4 4"/></svg></div>
<h3>GPS Tracking</h3>
<p>Real-time GPS position tracking with live map, speed, heading, altitude, satellite info, and track recording.</p>
</div>
<div class="feature-card" data-category="tracking">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v6"/><path d="M12 22v-6"/><circle cx="12" cy="12" r="4"/><path d="M8 12H2"/><path d="M22 12h-6"/><path d="M12 8a20 20 0 0 1 0 8"/><path d="M7 4l2 3"/><path d="M17 20l-2-3"/></svg></div>
<h3>Radiosonde</h3>
<p>Weather balloon tracking on 400-406 MHz via radiosonde_auto_rx. Real-time telemetry, trajectory map, and station distance.</p>
</div>
<div class="feature-card" data-category="space">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg></div>
<h3>Space Weather</h3>
@@ -197,6 +212,11 @@
<h3>Offline Mode</h3>
<p>Run without internet using bundled assets. Choose from multiple map tile providers or use your own local tile server.</p>
</div>
<div class="feature-card" data-category="platform">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg></div>
<h3>System Health</h3>
<p>Real-time telemetry dashboard with process monitoring, system metrics, and SDR device status overview.</p>
</div>
</div>
<button class="carousel-arrow carousel-arrow-right" aria-label="Scroll right">&#8250;</button>
</div>
+16 -5
View File
@@ -2860,11 +2860,22 @@ class ModeManager:
pass
logger.info("APRS reader stopped")
def _parse_aprs_packet(self, line: str) -> dict | None:
"""Parse APRS packet from direwolf or multimon-ng."""
match = re.match(r'([A-Z0-9-]+)>([^:]+):(.+)', line)
if not match:
return None
def _parse_aprs_packet(self, line: str) -> dict | None:
"""Parse APRS packet from direwolf or multimon-ng."""
if not line:
return None
# Normalize common decoder prefixes before parsing.
# multimon-ng: "AFSK1200: ..."
# direwolf: "[0.4] ...", "[0L] ..."
line = line.strip()
if line.startswith('AFSK1200:'):
line = line[9:].strip()
line = re.sub(r'^(?:\[[^\]]+\]\s*)+', '', line)
match = re.match(r'([A-Z0-9-]+)>([^:]+):(.+)', line)
if not match:
return None
callsign = match.group(1)
path = match.group(2)
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "intercept"
version = "2.21.1"
version = "2.23.0"
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
readme = "README.md"
requires-python = ">=3.9"
+3
View File
@@ -44,3 +44,6 @@ cryptography>=41.0.0
# WebSocket support for in-app audio streaming (KiwiSDR, Listening Post)
flask-sock
websocket-client>=1.6.0
# System health monitoring (optional - graceful fallback if unavailable)
psutil>=5.9.0
+13 -5
View File
@@ -14,10 +14,12 @@ def register_blueprints(app):
from .correlation import correlation_bp
from .dsc import dsc_bp
from .gps import gps_bp
from .listening_post import receiver_bp
from .listening_post import receiver_bp
from .meshtastic import meshtastic_bp
from .morse import morse_bp
from .offline import offline_bp
from .pager import pager_bp
from .radiosonde import radiosonde_bp
from .recordings import recordings_bp
from .rtlamr import rtlamr_bp
from .satellite import satellite_bp
@@ -29,10 +31,12 @@ def register_blueprints(app):
from .sstv import sstv_bp
from .sstv_general import sstv_general_bp
from .subghz import subghz_bp
from .system import system_bp
from .tscm import init_tscm_state, tscm_bp
from .updater import updater_bp
from .vdl2 import vdl2_bp
from .weather_sat import weather_sat_bp
from .wefax import wefax_bp
from .websdr import websdr_bp
from .wifi import wifi_bp
from .wifi_v2 import wifi_v2_bp
@@ -54,7 +58,7 @@ def register_blueprints(app):
app.register_blueprint(gps_bp)
app.register_blueprint(settings_bp)
app.register_blueprint(correlation_bp)
app.register_blueprint(receiver_bp)
app.register_blueprint(receiver_bp)
app.register_blueprint(meshtastic_bp)
app.register_blueprint(tscm_bp)
app.register_blueprint(spy_stations_bp)
@@ -68,9 +72,13 @@ def register_blueprints(app):
app.register_blueprint(alerts_bp) # Cross-mode alerts
app.register_blueprint(recordings_bp) # Session recordings
app.register_blueprint(subghz_bp) # SubGHz transceiver (HackRF)
app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking
app.register_blueprint(space_weather_bp) # Space weather monitoring
app.register_blueprint(signalid_bp) # External signal ID enrichment
app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking
app.register_blueprint(space_weather_bp) # Space weather monitoring
app.register_blueprint(signalid_bp) # External signal ID enrichment
app.register_blueprint(wefax_bp) # WeFax HF weather fax decoder
app.register_blueprint(morse_bp) # CW/Morse code decoder
app.register_blueprint(radiosonde_bp) # Radiosonde weather balloon tracking
app.register_blueprint(system_bp) # System health monitoring
# Initialize TSCM state with queue and lock from app
import app as app_module
+37 -30
View File
@@ -21,7 +21,7 @@ import app as app_module
from utils.logging import sensor_logger as logger
from utils.validation import validate_device_index, validate_gain, validate_ppm
from utils.sdr import SDRFactory, SDRType
from utils.sse import sse_stream_fanout
from utils.sse import sse_stream_fanout
from utils.event_pipeline import process_event
from utils.constants import (
PROCESS_TERMINATE_TIMEOUT,
@@ -45,6 +45,7 @@ acars_last_message_time = None
# Track which device is being used
acars_active_device: int | None = None
acars_active_sdr_type: str | None = None
def find_acarsdec():
@@ -151,7 +152,7 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
logger.error(f"ACARS stream error: {e}")
app_module.acars_queue.put({'type': 'error', 'message': str(e)})
finally:
global acars_active_device
global acars_active_device, acars_active_sdr_type
# Ensure process is terminated
try:
process.terminate()
@@ -167,8 +168,9 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
app_module.acars_process = None
# Release SDR device
if acars_active_device is not None:
app_module.release_sdr_device(acars_active_device)
app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr')
acars_active_device = None
acars_active_sdr_type = None
@acars_bp.route('/tools')
@@ -200,7 +202,7 @@ def acars_status() -> Response:
@acars_bp.route('/start', methods=['POST'])
def start_acars() -> Response:
"""Start ACARS decoder."""
global acars_message_count, acars_last_message_time, acars_active_device
global acars_message_count, acars_last_message_time, acars_active_device, acars_active_sdr_type
with app_module.acars_lock:
if app_module.acars_process and app_module.acars_process.poll() is None:
@@ -227,9 +229,12 @@ def start_acars() -> Response:
except ValueError as e:
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
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'acars')
error = app_module.claim_sdr_device(device_int, 'acars', sdr_type_str)
if error:
return jsonify({
'status': 'error',
@@ -238,6 +243,7 @@ def start_acars() -> Response:
}), 409
acars_active_device = device_int
acars_active_sdr_type = sdr_type_str
# Get frequencies - use provided or defaults
frequencies = data.get('frequencies', DEFAULT_ACARS_FREQUENCIES)
@@ -255,8 +261,6 @@ def start_acars() -> Response:
acars_message_count = 0
acars_last_message_time = None
# Resolve SDR type for device selection
sdr_type_str = data.get('sdr_type', 'rtlsdr')
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
@@ -343,8 +347,9 @@ def start_acars() -> Response:
if process.poll() is not None:
# Process died - release device
if acars_active_device is not None:
app_module.release_sdr_device(acars_active_device)
app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr')
acars_active_device = None
acars_active_sdr_type = None
stderr = ''
if process.stderr:
stderr = process.stderr.read().decode('utf-8', errors='replace')
@@ -375,8 +380,9 @@ def start_acars() -> Response:
except Exception as e:
# Release device on failure
if acars_active_device is not None:
app_module.release_sdr_device(acars_active_device)
app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr')
acars_active_device = None
acars_active_sdr_type = None
logger.error(f"Failed to start ACARS decoder: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500
@@ -384,7 +390,7 @@ def start_acars() -> Response:
@acars_bp.route('/stop', methods=['POST'])
def stop_acars() -> Response:
"""Stop ACARS decoder."""
global acars_active_device
global acars_active_device, acars_active_sdr_type
with app_module.acars_lock:
if not app_module.acars_process:
@@ -405,31 +411,32 @@ def stop_acars() -> Response:
# Release device from registry
if acars_active_device is not None:
app_module.release_sdr_device(acars_active_device)
app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr')
acars_active_device = None
acars_active_sdr_type = None
return jsonify({'status': 'stopped'})
@acars_bp.route('/stream')
def stream_acars() -> Response:
"""SSE stream for ACARS messages."""
def _on_msg(msg: dict[str, Any]) -> None:
process_event('acars', msg, msg.get('type'))
response = Response(
sse_stream_fanout(
source_queue=app_module.acars_queue,
channel_key='acars',
timeout=SSE_QUEUE_TIMEOUT,
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
on_message=_on_msg,
),
mimetype='text/event-stream',
)
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
return response
@acars_bp.route('/stream')
def stream_acars() -> Response:
"""SSE stream for ACARS messages."""
def _on_msg(msg: dict[str, Any]) -> None:
process_event('acars', msg, msg.get('type'))
response = Response(
sse_stream_fanout(
source_queue=app_module.acars_queue,
channel_key='acars',
timeout=SSE_QUEUE_TIMEOUT,
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
on_message=_on_msg,
),
mimetype='text/event-stream',
)
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
return response
@acars_bp.route('/frequencies')
+31 -10
View File
@@ -72,6 +72,7 @@ adsb_last_message_time = None
adsb_bytes_received = 0
adsb_lines_received = 0
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
# Track ICAOs already looked up in aircraft database (avoid repeated lookups)
@@ -674,7 +675,7 @@ def adsb_session():
@adsb_bp.route('/start', methods=['POST'])
def start_adsb():
"""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:
if adsb_using_service:
@@ -685,7 +686,7 @@ def start_adsb():
'session': session
}), 409
data = request.json or {}
data = request.get_json(silent=True) or {}
start_source = data.get('source')
started_by = request.remote_addr
@@ -757,6 +758,7 @@ def start_adsb():
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
sdr_type_str = sdr_type.value
# For RTL-SDR, use dump1090. For other hardware, need readsb with SoapySDR
if sdr_type == SDRType.RTL_SDR:
@@ -787,7 +789,7 @@ def start_adsb():
# Check if device is available before starting local dump1090
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:
return jsonify({
'status': 'error',
@@ -795,6 +797,10 @@ def start_adsb():
'message': error
}), 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
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
builder = SDRFactory.get_builder(sdr_type)
@@ -821,11 +827,24 @@ def start_adsb():
)
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:
# 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 = ''
if app_module.adsb_process.stderr:
try:
@@ -871,7 +890,6 @@ def start_adsb():
})
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.start()
@@ -891,15 +909,17 @@ def start_adsb():
})
except Exception as e:
# Release device on failure
app_module.release_sdr_device(device_int)
app_module.release_sdr_device(device_int, sdr_type_str)
adsb_active_device = None
adsb_active_sdr_type = None
return jsonify({'status': 'error', 'message': str(e)})
@adsb_bp.route('/stop', methods=['POST'])
def stop_adsb():
"""Stop ADS-B tracking."""
global adsb_using_service, adsb_active_device
data = request.json or {}
global adsb_using_service, adsb_active_device, adsb_active_sdr_type
data = request.get_json(silent=True) or {}
stop_source = data.get('source')
stopped_by = request.remote_addr
@@ -923,10 +943,11 @@ def stop_adsb():
# Release device from registry
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_active_device = None
adsb_active_sdr_type = None
app_module.adsb_aircraft.clear()
_looked_up_icaos.clear()
+9 -6
View File
@@ -44,6 +44,7 @@ ais_connected = False
ais_messages_received = 0
ais_last_message_time = None
ais_active_device = None
ais_active_sdr_type: str | None = None
_ais_error_logged = True
# Common installation paths for AIS-catcher
@@ -350,7 +351,7 @@ def ais_status():
@ais_bp.route('/start', methods=['POST'])
def start_ais():
"""Start AIS tracking."""
global ais_running, ais_active_device
global ais_running, ais_active_device, ais_active_sdr_type
with app_module.ais_lock:
if ais_running:
@@ -397,7 +398,7 @@ def start_ais():
# Check if device is available
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'ais')
error = app_module.claim_sdr_device(device_int, 'ais', sdr_type_str)
if error:
return jsonify({
'status': 'error',
@@ -436,7 +437,7 @@ def start_ais():
if app_module.ais_process.poll() is not None:
# Release device on failure
app_module.release_sdr_device(device_int)
app_module.release_sdr_device(device_int, sdr_type_str)
stderr_output = ''
if app_module.ais_process.stderr:
try:
@@ -450,6 +451,7 @@ def start_ais():
ais_running = True
ais_active_device = device
ais_active_sdr_type = sdr_type_str
# Start TCP parser thread
thread = threading.Thread(target=parse_ais_stream, args=(tcp_port,), daemon=True)
@@ -463,7 +465,7 @@ def start_ais():
})
except Exception as e:
# Release device on failure
app_module.release_sdr_device(device_int)
app_module.release_sdr_device(device_int, sdr_type_str)
logger.error(f"Failed to start AIS-catcher: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500
@@ -471,7 +473,7 @@ def start_ais():
@ais_bp.route('/stop', methods=['POST'])
def stop_ais():
"""Stop AIS tracking."""
global ais_running, ais_active_device
global ais_running, ais_active_device, ais_active_sdr_type
with app_module.ais_lock:
if app_module.ais_process:
@@ -490,10 +492,11 @@ def stop_ais():
# Release device from registry
if ais_active_device is not None:
app_module.release_sdr_device(ais_active_device)
app_module.release_sdr_device(ais_active_device, ais_active_sdr_type or 'rtlsdr')
ais_running = False
ais_active_device = None
ais_active_sdr_type = None
app_module.ais_vessels.clear()
return jsonify({'status': 'stopped'})
+334 -120
View File
@@ -5,8 +5,10 @@ from __future__ import annotations
import csv
import json
import os
import pty
import queue
import re
import select
import shutil
import subprocess
import tempfile
@@ -14,14 +16,14 @@ import threading
import time
from datetime import datetime
from subprocess import PIPE, STDOUT
from typing import Generator, Optional
from typing import Any, Generator, Optional
from flask import Blueprint, jsonify, request, Response
import app as app_module
from utils.logging import sensor_logger as logger
from utils.validation import validate_device_index, validate_gain, validate_ppm
from utils.sse import sse_stream_fanout
from utils.sse import sse_stream_fanout
from utils.event_pipeline import process_event
from utils.sdr import SDRFactory, SDRType
from utils.constants import (
@@ -35,6 +37,7 @@ aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs')
# Track which SDR device is being used
aprs_active_device: int | None = None
aprs_active_sdr_type: str | None = None
# APRS frequencies by region (MHz)
APRS_FREQUENCIES = {
@@ -103,12 +106,35 @@ ADEVICE stdin null
CHANNEL 0
MYCALL N0CALL
MODEM 1200
FIX_BITS 1
AGWPORT 0
KISSPORT 0
"""
with open(DIREWOLF_CONFIG_PATH, 'w') as f:
f.write(config)
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]:
"""Parse APRS packet into structured data.
@@ -125,10 +151,15 @@ def parse_aprs_packet(raw_packet: str) -> Optional[dict]:
- User-defined formats
"""
try:
raw_packet = normalize_aprs_output_line(raw_packet)
if not raw_packet:
return None
# Basic APRS packet format: CALLSIGN>PATH:DATA
# 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:
return None
@@ -444,6 +475,109 @@ def parse_position(data: str) -> Optional[dict]:
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:
logger.debug(f"Failed to parse position: {e}")
@@ -1309,19 +1443,23 @@ def should_send_meter_update(level: int) -> bool:
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.
This function reads from the decoder's stdout (text mode, line-buffered).
The decoder's stderr is merged into stdout (STDOUT) to avoid deadlocks.
rtl_fm's stderr is captured via PIPE with a monitor thread.
Reads from a PTY master fd to get line-buffered output from the decoder,
avoiding the 15-minute pipe buffering delay. Uses select() + os.read()
to poll the PTY (same pattern as pager.py).
Outputs two types of messages to the queue:
- type='aprs': Decoded APRS packets
- type='meter': Audio level meter readings (rate-limited)
"""
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
_last_meter_time = 0.0
@@ -1330,93 +1468,114 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
try:
app_module.aprs_queue.put({'type': 'status', 'status': 'started'})
# Read line-by-line in text mode. Empty string '' signals EOF.
for line in iter(decoder_process.stdout.readline, ''):
line = line.strip()
if not line:
continue
# Read from PTY using select() for non-blocking reads.
# PTY forces the decoder to line-buffer, so output arrives immediately
# instead of waiting for a full 4-8KB pipe buffer to fill.
buffer = ""
while True:
try:
ready, _, _ = select.select([master_fd], [], [], 1.0)
except Exception:
break
# Check for audio level line first (for signal meter)
audio_level = parse_audio_level(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
if ready:
try:
data = os.read(master_fd, 1024)
if not data:
break
buffer += data.decode('utf-8', errors='replace')
except OSError:
break
# multimon-ng prefixes decoded packets with "AFSK1200: "
if line.startswith('AFSK1200:'):
line = line[9:].strip()
while '\n' in buffer:
line, buffer = buffer.split('\n', 1)
line = line.strip()
if not line:
continue
# direwolf often prefixes packets with "[0.4] " or similar audio level indicator
# Strip any leading bracket prefix like "[0.4] " before parsing
line = re.sub(r'^\[\d+\.\d+\]\s*', '', line)
# Check for audio level line first (for signal meter)
audio_level = parse_audio_level(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)
if '>' not in line or ':' not in line:
continue
# Normalize decoder prefixes (multimon/direwolf) before parsing.
line = normalize_aprs_output_line(line)
packet = parse_aprs_packet(line)
if packet:
aprs_packet_count += 1
aprs_last_packet_time = time.time()
# Skip non-packet lines (APRS format: CALL>PATH:DATA)
if '>' not in line or ':' not in line:
continue
# Track unique stations
callsign = packet.get('callsign')
if callsign and callsign not in aprs_stations:
aprs_station_count += 1
packet = parse_aprs_packet(line)
if packet:
aprs_packet_count += 1
aprs_last_packet_time = time.time()
# Update station data
if callsign:
aprs_stations[callsign] = {
'callsign': callsign,
'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]
# Track unique stations
callsign = packet.get('callsign')
if callsign and callsign not in aprs_stations:
aprs_station_count += 1
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
if app_module.logging_enabled:
try:
with open(app_module.log_file_path, 'a') as f:
ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
f.write(f"{ts} | APRS | {json.dumps(packet)}\n")
except Exception:
pass
app_module.aprs_queue.put(packet)
# Log if enabled
if app_module.logging_enabled:
try:
with open(app_module.log_file_path, 'a') as f:
ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
f.write(f"{ts} | APRS | {json.dumps(packet)}\n")
except Exception:
pass
except Exception as e:
logger.error(f"APRS stream error: {e}")
app_module.aprs_queue.put({'type': 'error', 'message': str(e)})
finally:
global aprs_active_device
try:
os.close(master_fd)
except OSError:
pass
app_module.aprs_queue.put({'type': 'status', 'status': 'stopped'})
# Cleanup processes
for proc in [rtl_process, decoder_process]:
@@ -1428,10 +1587,11 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
proc.kill()
except Exception:
pass
# Release SDR device
if aprs_active_device is not None:
app_module.release_sdr_device(aprs_active_device)
# Release SDR device — only if it's still ours (not reclaimed by a new start)
if my_device is not None and aprs_active_device == my_device:
app_module.release_sdr_device(my_device, aprs_active_sdr_type or 'rtlsdr')
aprs_active_device = None
aprs_active_sdr_type = None
@aprs_bp.route('/tools')
@@ -1478,11 +1638,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'])
def start_aprs() -> Response:
"""Start APRS decoder."""
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:
if app_module.aprs_process and app_module.aprs_process.poll() is None:
@@ -1531,7 +1709,7 @@ def start_aprs() -> Response:
}), 400
# Reserve SDR device to prevent conflicts with other modes
error = app_module.claim_sdr_device(device, 'aprs')
error = app_module.claim_sdr_device(device, 'aprs', sdr_type_str)
if error:
return jsonify({
'status': 'error',
@@ -1539,6 +1717,7 @@ def start_aprs() -> Response:
'message': error
}), 409
aprs_active_device = device
aprs_active_sdr_type = sdr_type_str
# Get frequency for region
region = data.get('region', 'north_america')
@@ -1580,8 +1759,9 @@ def start_aprs() -> Response:
rtl_cmd = rtl_cmd[:-1] + ['-E', 'dc', '-A', 'fast', '-']
except Exception as e:
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_sdr_type = None
return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500
# Build decoder command
@@ -1635,19 +1815,25 @@ def start_aprs() -> Response:
rtl_stderr_thread = threading.Thread(target=monitor_rtl_stderr, daemon=True)
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.
# Use text mode with line buffering for reliable line-by-line reading.
# Merge stderr into stdout to avoid blocking on unbuffered stderr.
# stdout/stderr go to the PTY slave so output is line-buffered.
decoder_process = subprocess.Popen(
decoder_cmd,
stdin=rtl_process.stdout,
stdout=PIPE,
stderr=STDOUT,
text=True,
bufsize=1,
stdout=slave_fd,
stderr=slave_fd,
close_fds=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.
# This ensures proper EOF propagation when rtl_fm terminates.
rtl_process.stdout.close()
@@ -1668,39 +1854,57 @@ def start_aprs() -> Response:
if stderr_output:
error_msg += f': {stderr_output[:200]}'
logger.error(error_msg)
try:
os.close(master_fd)
except OSError:
pass
try:
decoder_process.kill()
except Exception:
pass
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_sdr_type = None
return jsonify({'status': 'error', 'message': error_msg}), 500
if decoder_process.poll() is not None:
# Decoder exited early - capture any output
error_output = decoder_process.stdout.read()[:500] if decoder_process.stdout else ''
# Decoder exited early - capture any output from PTY
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'
if error_output:
error_msg += f': {error_output}'
logger.error(error_msg)
try:
os.close(master_fd)
except OSError:
pass
try:
rtl_process.kill()
except Exception:
pass
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_sdr_type = None
return jsonify({'status': 'error', 'message': error_msg}), 500
# Store references for status checks and cleanup
app_module.aprs_process = decoder_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
thread = threading.Thread(
target=stream_aprs_output,
args=(rtl_process, decoder_process),
args=(master_fd, rtl_process, decoder_process),
daemon=True
)
thread.start()
@@ -1717,15 +1921,16 @@ def start_aprs() -> Response:
except Exception as e:
logger.error(f"Failed to start APRS decoder: {e}")
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_sdr_type = None
return jsonify({'status': 'error', 'message': str(e)}), 500
@aprs_bp.route('/stop', methods=['POST'])
def stop_aprs() -> Response:
"""Stop APRS decoder."""
global aprs_active_device
global aprs_active_device, aprs_active_sdr_type
with app_module.aprs_lock:
processes_to_stop = []
@@ -1751,37 +1956,46 @@ def stop_aprs() -> Response:
except Exception as e:
logger.error(f"Error stopping APRS process: {e}")
# Close PTY master fd
if hasattr(app_module, 'aprs_master_fd') and app_module.aprs_master_fd is not None:
try:
os.close(app_module.aprs_master_fd)
except OSError:
pass
app_module.aprs_master_fd = None
app_module.aprs_process = None
if hasattr(app_module, 'aprs_rtl_process'):
app_module.aprs_rtl_process = None
# Release SDR device
if aprs_active_device is not None:
app_module.release_sdr_device(aprs_active_device)
app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
aprs_active_device = None
aprs_active_sdr_type = None
return jsonify({'status': 'stopped'})
@aprs_bp.route('/stream')
def stream_aprs() -> Response:
"""SSE stream for APRS packets."""
def _on_msg(msg: dict[str, Any]) -> None:
process_event('aprs', msg, msg.get('type'))
response = Response(
sse_stream_fanout(
source_queue=app_module.aprs_queue,
channel_key='aprs',
timeout=SSE_QUEUE_TIMEOUT,
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
on_message=_on_msg,
),
mimetype='text/event-stream',
)
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
return response
@aprs_bp.route('/stream')
def stream_aprs() -> Response:
"""SSE stream for APRS packets."""
def _on_msg(msg: dict[str, Any]) -> None:
process_event('aprs', msg, msg.get('type'))
response = Response(
sse_stream_fanout(
source_queue=app_module.aprs_queue,
channel_key='aprs',
timeout=SSE_QUEUE_TIMEOUT,
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
on_message=_on_msg,
),
mimetype='text/event-stream',
)
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
return response
@aprs_bp.route('/frequencies')
+17 -8
View File
@@ -51,6 +51,7 @@ dsc_running = False
# Track which device is being used
dsc_active_device: int | None = None
dsc_active_sdr_type: str | None = None
def _get_dsc_decoder_path() -> str | None:
@@ -171,7 +172,7 @@ def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> Non
'error': str(e)
})
finally:
global dsc_active_device
global dsc_active_device, dsc_active_sdr_type
try:
os.close(master_fd)
except OSError:
@@ -197,8 +198,9 @@ def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> Non
app_module.dsc_rtl_process = None
# Release SDR device
if dsc_active_device is not None:
app_module.release_sdr_device(dsc_active_device)
app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
dsc_active_device = None
dsc_active_sdr_type = None
def _store_critical_alert(msg: dict) -> None:
@@ -331,10 +333,13 @@ def start_decoding() -> Response:
'message': str(e)
}), 400
# Get SDR type from request
sdr_type_str = data.get('sdr_type', 'rtlsdr')
# Check if device is available using centralized registry
global dsc_active_device
global dsc_active_device, dsc_active_sdr_type
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'dsc')
error = app_module.claim_sdr_device(device_int, 'dsc', sdr_type_str)
if error:
return jsonify({
'status': 'error',
@@ -343,6 +348,7 @@ def start_decoding() -> Response:
}), 409
dsc_active_device = device_int
dsc_active_sdr_type = sdr_type_str
# Clear queue
while not app_module.dsc_queue.empty():
@@ -440,8 +446,9 @@ def start_decoding() -> Response:
pass
# Release device on failure
if dsc_active_device is not None:
app_module.release_sdr_device(dsc_active_device)
app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
dsc_active_device = None
dsc_active_sdr_type = None
return jsonify({
'status': 'error',
'message': f'Tool not found: {e.filename}'
@@ -458,8 +465,9 @@ def start_decoding() -> Response:
pass
# Release device on failure
if dsc_active_device is not None:
app_module.release_sdr_device(dsc_active_device)
app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
dsc_active_device = None
dsc_active_sdr_type = None
logger.error(f"Failed to start DSC decoder: {e}")
return jsonify({
'status': 'error',
@@ -470,7 +478,7 @@ def start_decoding() -> Response:
@dsc_bp.route('/stop', methods=['POST'])
def stop_decoding() -> Response:
"""Stop DSC decoder."""
global dsc_running, dsc_active_device
global dsc_running, dsc_active_device, dsc_active_sdr_type
with app_module.dsc_lock:
if not app_module.dsc_process:
@@ -509,8 +517,9 @@ def stop_decoding() -> Response:
# Release device from registry
if dsc_active_device is not None:
app_module.release_sdr_device(dsc_active_device)
app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
dsc_active_device = None
dsc_active_sdr_type = None
return jsonify({'status': 'stopped'})
+27 -23
View File
@@ -65,14 +65,17 @@ def auto_connect_gps():
If gpsd is not running, attempts to detect GPS devices and start gpsd.
Returns current status if already connected.
"""
# Check if already running
reader = get_gps_reader()
if reader and reader.is_running:
position = reader.position
sky = reader.sky
return jsonify({
'status': 'connected',
'source': 'gpsd',
# Check if already running
reader = get_gps_reader()
if reader and reader.is_running:
# Ensure stream callbacks are attached for this process.
reader.add_callback(_position_callback)
reader.add_sky_callback(_sky_callback)
position = reader.position
sky = reader.sky
return jsonify({
'status': 'connected',
'source': 'gpsd',
'has_fix': position is not None,
'position': position.to_dict() if position else None,
'sky': sky.to_dict() if sky else None,
@@ -204,21 +207,22 @@ def get_position():
})
@gps_bp.route('/satellites')
def get_satellites():
"""Get current satellite sky view data."""
reader = get_gps_reader()
if not reader or not reader.is_running:
return jsonify({
'status': 'error',
'message': 'GPS client not running'
}), 400
sky = reader.sky
if sky:
return jsonify({
'status': 'ok',
@gps_bp.route('/satellites')
def get_satellites():
"""Get current satellite sky view data."""
reader = get_gps_reader()
if not reader or not reader.is_running:
return jsonify({
'status': 'waiting',
'running': False,
'message': 'GPS client not running'
})
sky = reader.sky
if sky:
return jsonify({
'status': 'ok',
'sky': sky.to_dict()
})
else:
+805 -699
View File
File diff suppressed because it is too large Load Diff
+996
View File
@@ -0,0 +1,996 @@
"""CW/Morse code decoder routes."""
from __future__ import annotations
import contextlib
import queue
import subprocess
import tempfile
import threading
import time
from pathlib import Path
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.morse import (
decode_morse_wav_file,
morse_decoder_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_ppm,
)
morse_bp = Blueprint('morse', __name__)
class _FilteredQueue:
"""Suppress decoder-thread 'stopped' events that race with route lifecycle."""
def __init__(self, inner: queue.Queue) -> None:
self._inner = inner
def put_nowait(self, item: Any) -> None:
if isinstance(item, dict) and item.get('type') == 'status' and item.get('status') == 'stopped':
return
self._inner.put_nowait(item)
def put(self, item: Any, **kwargs: Any) -> None:
if isinstance(item, dict) and item.get('type') == 'status' and item.get('status') == 'stopped':
return
self._inner.put(item, **kwargs)
# Track which device is being used
morse_active_device: int | None = None
morse_active_sdr_type: str | None = None
# Runtime lifecycle state.
MORSE_IDLE = 'idle'
MORSE_STARTING = 'starting'
MORSE_RUNNING = 'running'
MORSE_STOPPING = 'stopping'
MORSE_ERROR = 'error'
morse_state = MORSE_IDLE
morse_state_message = 'Idle'
morse_state_since = time.monotonic()
morse_last_error = ''
morse_runtime_config: dict[str, Any] = {}
morse_session_id = 0
morse_decoder_worker: threading.Thread | None = None
morse_stderr_worker: threading.Thread | None = None
morse_stop_event: threading.Event | None = None
morse_control_queue: queue.Queue | None = None
def _set_state(state: str, message: str = '', *, enqueue: bool = True, extra: dict[str, Any] | None = None) -> None:
"""Update lifecycle state and optionally emit a status queue event."""
global morse_state, morse_state_message, morse_state_since
morse_state = state
morse_state_message = message or state
morse_state_since = time.monotonic()
if not enqueue:
return
payload: dict[str, Any] = {
'type': 'status',
'status': state,
'state': state,
'message': morse_state_message,
'session_id': morse_session_id,
'timestamp': time.strftime('%H:%M:%S'),
}
if extra:
payload.update(extra)
with contextlib.suppress(queue.Full):
app_module.morse_queue.put_nowait(payload)
def _drain_queue(q: queue.Queue) -> None:
while not q.empty():
try:
q.get_nowait()
except queue.Empty:
break
def _join_thread(worker: threading.Thread | None, timeout_s: float) -> bool:
if worker is None:
return True
worker.join(timeout=timeout_s)
return not worker.is_alive()
def _close_pipe(pipe_obj: Any) -> None:
if pipe_obj is None:
return
with contextlib.suppress(Exception):
pipe_obj.close()
def _queue_morse_event(payload: dict[str, Any]) -> None:
with contextlib.suppress(queue.Full):
app_module.morse_queue.put_nowait(payload)
def _bool_value(value: Any, default: bool = False) -> bool:
if isinstance(value, bool):
return value
if value is None:
return default
text = str(value).strip().lower()
if text in {'1', 'true', 'yes', 'on'}:
return True
if text in {'0', 'false', 'no', 'off'}:
return False
return default
def _float_value(value: Any, default: float) -> float:
try:
return float(value)
except (TypeError, ValueError):
return float(default)
def _validate_tone_freq(value: Any) -> float:
"""Validate CW tone frequency (300-1200 Hz)."""
try:
freq = float(value)
if not 300 <= freq <= 1200:
raise ValueError('Tone frequency must be between 300 and 1200 Hz')
return freq
except (ValueError, TypeError) as e:
raise ValueError(f'Invalid tone frequency: {value}') from e
def _validate_wpm(value: Any) -> int:
"""Validate words per minute (5-50)."""
try:
wpm = int(value)
if not 5 <= wpm <= 50:
raise ValueError('WPM must be between 5 and 50')
return wpm
except (ValueError, TypeError) as e:
raise ValueError(f'Invalid WPM: {value}') from e
def _validate_bandwidth(value: Any) -> int:
try:
bw = int(value)
if bw not in (50, 100, 200, 400):
raise ValueError('Bandwidth must be one of 50, 100, 200, 400 Hz')
return bw
except (TypeError, ValueError) as e:
raise ValueError(f'Invalid bandwidth: {value}') from e
def _validate_threshold_mode(value: Any) -> str:
mode = str(value or 'auto').strip().lower()
if mode not in {'auto', 'manual'}:
raise ValueError('threshold_mode must be auto or manual')
return mode
def _validate_wpm_mode(value: Any) -> str:
mode = str(value or 'auto').strip().lower()
if mode not in {'auto', 'manual'}:
raise ValueError('wpm_mode must be auto or manual')
return mode
def _validate_threshold_multiplier(value: Any) -> float:
try:
multiplier = float(value)
if not 1.1 <= multiplier <= 8.0:
raise ValueError('threshold_multiplier must be between 1.1 and 8.0')
return multiplier
except (TypeError, ValueError) as e:
raise ValueError(f'Invalid threshold multiplier: {value}') from e
def _validate_non_negative_float(value: Any, field_name: str) -> float:
try:
parsed = float(value)
if parsed < 0:
raise ValueError(f'{field_name} must be non-negative')
return parsed
except (TypeError, ValueError) as e:
raise ValueError(f'Invalid {field_name}: {value}') from e
def _validate_signal_gate(value: Any) -> float:
try:
gate = float(value)
if not 0.0 <= gate <= 1.0:
raise ValueError('signal_gate must be between 0.0 and 1.0')
return gate
except (TypeError, ValueError) as e:
raise ValueError(f'Invalid signal gate: {value}') from e
def _validate_detect_mode(value: Any) -> str:
"""Validate detection mode ('goertzel' or 'envelope')."""
mode = str(value or 'goertzel').lower().strip()
if mode not in ('goertzel', 'envelope'):
raise ValueError("detect_mode must be 'goertzel' or 'envelope'")
return mode
def _snapshot_live_resources() -> list[str]:
alive: list[str] = []
if morse_decoder_worker and morse_decoder_worker.is_alive():
alive.append('decoder_thread')
if morse_stderr_worker and morse_stderr_worker.is_alive():
alive.append('stderr_thread')
if app_module.morse_process and app_module.morse_process.poll() is None:
alive.append('rtl_process')
return alive
@morse_bp.route('/morse/start', methods=['POST'])
def start_morse() -> Response:
global morse_active_device, morse_active_sdr_type, morse_decoder_worker, morse_stderr_worker
global morse_stop_event, morse_control_queue, morse_runtime_config
global morse_last_error, morse_session_id
data = request.json or {}
# Validate detect_mode first — it determines frequency limits.
try:
detect_mode = _validate_detect_mode(data.get('detect_mode', 'goertzel'))
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
freq_max = 1766.0 if detect_mode == 'envelope' else 30.0
try:
freq = validate_frequency(data.get('frequency', '14.060'), min_mhz=0.5, max_mhz=freq_max)
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:
tone_freq = _validate_tone_freq(data.get('tone_freq', '700'))
wpm = _validate_wpm(data.get('wpm', '15'))
bandwidth_hz = _validate_bandwidth(data.get('bandwidth_hz', '200'))
threshold_mode = _validate_threshold_mode(data.get('threshold_mode', 'auto'))
wpm_mode = _validate_wpm_mode(data.get('wpm_mode', 'auto'))
threshold_multiplier = _validate_threshold_multiplier(data.get('threshold_multiplier', '2.8'))
manual_threshold = _validate_non_negative_float(data.get('manual_threshold', '0'), 'manual threshold')
threshold_offset = _validate_non_negative_float(data.get('threshold_offset', '0'), 'threshold offset')
min_signal_gate = _validate_signal_gate(data.get('signal_gate', '0'))
auto_tone_track = _bool_value(data.get('auto_tone_track', True), True)
tone_lock = _bool_value(data.get('tone_lock', False), False)
wpm_lock = _bool_value(data.get('wpm_lock', False), False)
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
sdr_type_str = data.get('sdr_type', 'rtlsdr')
with app_module.morse_lock:
if morse_state in {MORSE_STARTING, MORSE_RUNNING, MORSE_STOPPING}:
return jsonify({
'status': 'error',
'message': f'Morse decoder is {morse_state}',
'state': morse_state,
}), 409
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'morse', sdr_type_str)
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error,
}), 409
morse_active_device = device_int
morse_active_sdr_type = sdr_type_str
morse_last_error = ''
morse_session_id += 1
_drain_queue(app_module.morse_queue)
_set_state(MORSE_STARTING, 'Starting decoder...')
# Envelope mode (OOK/AM): use AM demod, higher sample rate for better
# envelope resolution. Goertzel mode (HF CW): use USB demod.
if detect_mode == 'envelope':
sample_rate = 48000
modulation = 'am'
else:
sample_rate = 22050
modulation = 'usb'
bias_t = _bool_value(data.get('bias_t', False), False)
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
requested_device_index = int(device)
active_device_index = requested_device_index
builder = SDRFactory.get_builder(sdr_type)
device_catalog: dict[int, dict[str, str]] = {}
candidate_device_indices: list[int] = [requested_device_index]
with contextlib.suppress(Exception):
detected_devices = SDRFactory.detect_devices()
same_type_devices = [d for d in detected_devices if d.sdr_type == sdr_type]
for d in same_type_devices:
device_catalog[d.index] = {
'name': str(d.name or f'SDR {d.index}'),
'serial': str(d.serial or 'Unknown'),
}
for d in sorted(same_type_devices, key=lambda dev: dev.index):
if d.index not in candidate_device_indices:
candidate_device_indices.append(d.index)
def _device_label(device_index: int) -> str:
meta = device_catalog.get(device_index, {})
serial = str(meta.get('serial') or 'Unknown')
name = str(meta.get('name') or f'SDR {device_index}')
return f'device {device_index} ({name}, SN: {serial})'
def _build_rtl_cmd(device_index: int, direct_sampling_mode: int | None) -> list[str]:
# Envelope mode tunes directly to center freq (no tone offset).
if detect_mode == 'envelope':
tuned_frequency_mhz = max(0.5, float(freq))
else:
tuned_frequency_mhz = max(0.5, float(freq) - (float(tone_freq) / 1_000_000.0))
sdr_device = SDRFactory.create_default_device(sdr_type, index=device_index)
fm_kwargs: dict[str, Any] = {
'device': sdr_device,
'frequency_mhz': tuned_frequency_mhz,
'sample_rate': sample_rate,
'gain': float(gain) if gain and gain != '0' else None,
'ppm': int(ppm) if ppm and ppm != '0' else None,
'modulation': modulation,
'bias_t': bias_t,
}
if direct_sampling_mode in (1, 2):
fm_kwargs['direct_sampling'] = int(direct_sampling_mode)
cmd = list(builder.build_fm_demod_command(**fm_kwargs))
if cmd and cmd[-1] != '-':
cmd.append('-')
return cmd
can_try_direct_sampling = bool(
sdr_type == SDRType.RTL_SDR
and detect_mode != 'envelope' # direct sampling is HF-only
and float(freq) < 24.0
)
direct_sampling_attempts: list[int | None] = [2, 1, None] if can_try_direct_sampling else [None]
runtime_config: dict[str, Any] = {
'sample_rate': sample_rate,
'detect_mode': detect_mode,
'modulation': modulation,
'rf_frequency_mhz': float(freq),
'tuned_frequency_mhz': max(0.5, float(freq)) if detect_mode == 'envelope' else max(0.5, float(freq) - (float(tone_freq) / 1_000_000.0)),
'tone_freq': tone_freq,
'wpm': wpm,
'bandwidth_hz': bandwidth_hz,
'auto_tone_track': auto_tone_track,
'tone_lock': tone_lock,
'threshold_mode': threshold_mode,
'manual_threshold': manual_threshold,
'threshold_multiplier': threshold_multiplier,
'threshold_offset': threshold_offset,
'wpm_mode': wpm_mode,
'wpm_lock': wpm_lock,
'min_signal_gate': min_signal_gate,
'source': 'rtl_fm',
'requested_device': requested_device_index,
'active_device': active_device_index,
'device_serial': str(device_catalog.get(active_device_index, {}).get('serial') or 'Unknown'),
'candidate_devices': list(candidate_device_indices),
}
active_rtl_process: subprocess.Popen[bytes] | None = None
active_stop_event: threading.Event | None = None
active_control_queue: queue.Queue | None = None
active_decoder_thread: threading.Thread | None = None
active_stderr_thread: threading.Thread | None = None
rtl_process: subprocess.Popen[bytes] | None = None
stop_event: threading.Event | None = None
control_queue: queue.Queue | None = None
decoder_thread: threading.Thread | None = None
stderr_thread: threading.Thread | None = None
def _cleanup_attempt(
rtl_proc: subprocess.Popen[bytes] | None,
stop_evt: threading.Event | None,
control_q: queue.Queue | None,
decoder_worker: threading.Thread | None,
stderr_worker: threading.Thread | None,
) -> None:
if stop_evt is not None:
stop_evt.set()
if control_q is not None:
with contextlib.suppress(queue.Full):
control_q.put_nowait({'cmd': 'shutdown'})
if rtl_proc is not None:
_close_pipe(getattr(rtl_proc, 'stdout', None))
_close_pipe(getattr(rtl_proc, 'stderr', None))
if rtl_proc is not None:
safe_terminate(rtl_proc, timeout=0.4)
unregister_process(rtl_proc)
_join_thread(decoder_worker, timeout_s=0.35)
_join_thread(stderr_worker, timeout_s=0.35)
full_cmd = ''
attempt_errors: list[str] = []
try:
startup_succeeded = False
for device_pos, candidate_device_index in enumerate(candidate_device_indices, start=1):
if candidate_device_index != active_device_index:
prev_device = active_device_index
claim_error = app_module.claim_sdr_device(candidate_device_index, 'morse', sdr_type_str)
if claim_error:
msg = f'{_device_label(candidate_device_index)} unavailable: {claim_error}'
attempt_errors.append(msg)
logger.warning('Morse startup device fallback skipped: %s', msg)
_queue_morse_event({'type': 'info', 'text': f'[morse] {msg}'})
continue
if prev_device is not None:
app_module.release_sdr_device(prev_device, morse_active_sdr_type or 'rtlsdr')
active_device_index = candidate_device_index
with app_module.morse_lock:
morse_active_device = active_device_index
_queue_morse_event({
'type': 'info',
'text': (
f'[morse] switching to {_device_label(active_device_index)} '
f'({device_pos}/{len(candidate_device_indices)})'
),
})
runtime_config['active_device'] = active_device_index
runtime_config['device_serial'] = str(
device_catalog.get(active_device_index, {}).get('serial') or 'Unknown'
)
runtime_config.pop('startup_waiting', None)
runtime_config.pop('startup_warning', None)
for attempt_index, direct_sampling_mode in enumerate(direct_sampling_attempts, start=1):
rtl_process = None
stop_event = None
control_queue = None
decoder_thread = None
stderr_thread = None
rtl_cmd = _build_rtl_cmd(active_device_index, direct_sampling_mode)
direct_mode_label = direct_sampling_mode if direct_sampling_mode is not None else 'none'
full_cmd = ' '.join(rtl_cmd)
logger.info(
'Morse decoder attempt device=%s (%s/%s) rf=%.6f tuned=%.6f direct_mode=%s (%s/%s): %s',
active_device_index,
device_pos,
len(candidate_device_indices),
float(freq),
float(runtime_config.get('tuned_frequency_mhz', freq)),
direct_mode_label,
attempt_index,
len(direct_sampling_attempts),
full_cmd,
)
_queue_morse_event({'type': 'info', 'text': f'[cmd] {full_cmd}'})
rtl_process = subprocess.Popen(
rtl_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=0,
)
register_process(rtl_process)
stop_event = threading.Event()
control_queue = queue.Queue(maxsize=16)
pcm_ready_event = threading.Event()
stderr_lines: list[str] = []
def monitor_stderr(
proc: subprocess.Popen[bytes] = rtl_process,
proc_stop_event: threading.Event = stop_event,
capture_lines: list[str] = stderr_lines,
) -> None:
stderr_stream = proc.stderr
if stderr_stream is None:
return
try:
while not proc_stop_event.is_set():
line = stderr_stream.readline()
if not line:
if proc.poll() is not None:
break
time.sleep(0.02)
continue
err_text = line.decode('utf-8', errors='replace').strip()
if not err_text:
continue
if len(capture_lines) >= 40:
del capture_lines[:10]
capture_lines.append(err_text)
_queue_morse_event({'type': 'info', 'text': f'[rtl_fm] {err_text}'})
except (ValueError, OSError):
return
except Exception:
return
stderr_thread = threading.Thread(target=monitor_stderr, daemon=True, name='morse-stderr')
stderr_thread.start()
if rtl_process.stdout is None:
raise RuntimeError('rtl_fm stdout unavailable')
decoder_thread = threading.Thread(
target=morse_decoder_thread,
kwargs={
'rtl_stdout': rtl_process.stdout,
'output_queue': _FilteredQueue(app_module.morse_queue),
'stop_event': stop_event,
'sample_rate': sample_rate,
'tone_freq': tone_freq,
'wpm': wpm,
'decoder_config': runtime_config,
'control_queue': control_queue,
'pcm_ready_event': pcm_ready_event,
},
daemon=True,
name='morse-decoder',
)
decoder_thread.start()
startup_deadline = time.monotonic() + 4.0
startup_ok = False
startup_error = ''
while time.monotonic() < startup_deadline:
if pcm_ready_event.is_set():
startup_ok = True
break
if rtl_process.poll() is not None:
startup_error = f'rtl_fm exited during startup (code {rtl_process.returncode})'
break
time.sleep(0.05)
if not startup_ok:
if not startup_error:
startup_error = 'No PCM samples received within startup timeout'
if stderr_lines:
startup_error = f'{startup_error}; stderr: {stderr_lines[-1]}'
is_last_device = device_pos == len(candidate_device_indices)
is_last_attempt = attempt_index == len(direct_sampling_attempts)
if (
is_last_device
and is_last_attempt
and rtl_process.poll() is None
):
startup_ok = True
runtime_config['startup_waiting'] = True
runtime_config['startup_warning'] = startup_error
logger.warning(
'Morse startup continuing without PCM on %s: %s',
_device_label(active_device_index),
startup_error,
)
_queue_morse_event({
'type': 'info',
'text': '[morse] waiting for PCM stream...',
})
if startup_ok:
runtime_config['direct_sampling_mode'] = direct_sampling_mode
runtime_config['direct_sampling'] = (
int(direct_sampling_mode) if direct_sampling_mode is not None else 0
)
runtime_config['command'] = full_cmd
runtime_config['active_device'] = active_device_index
active_rtl_process = rtl_process
active_stop_event = stop_event
active_control_queue = control_queue
active_decoder_thread = decoder_thread
active_stderr_thread = stderr_thread
startup_succeeded = True
break
attempt_errors.append(
f'{_device_label(active_device_index)} '
f'attempt {attempt_index}/{len(direct_sampling_attempts)} '
f'(source=rtl_fm direct_mode={direct_mode_label}): {startup_error}'
)
logger.warning('Morse startup attempt failed: %s', attempt_errors[-1])
_queue_morse_event({'type': 'info', 'text': f'[morse] startup attempt failed: {startup_error}'})
_cleanup_attempt(
rtl_process,
stop_event,
control_queue,
decoder_thread,
stderr_thread,
)
rtl_process = None
stop_event = None
control_queue = None
decoder_thread = None
stderr_thread = None
if startup_succeeded:
break
if device_pos < len(candidate_device_indices):
next_device = candidate_device_indices[device_pos]
_queue_morse_event({
'type': 'status',
'state': MORSE_STARTING,
'status': MORSE_STARTING,
'message': (
f'No PCM on {_device_label(active_device_index)}. '
f'Trying {_device_label(next_device)}...'
),
'session_id': morse_session_id,
'timestamp': time.strftime('%H:%M:%S'),
})
if (
active_rtl_process is None
or active_stop_event is None
or active_control_queue is None
or active_decoder_thread is None
or active_stderr_thread is None
):
msg = (
f'SDR capture started but no PCM stream was received from '
f'{_device_label(active_device_index)}.'
)
if attempt_errors:
msg += ' ' + ' | '.join(attempt_errors)
logger.error('Morse startup failed: %s', msg)
with app_module.morse_lock:
if morse_active_device is not None:
app_module.release_sdr_device(morse_active_device, morse_active_sdr_type or 'rtlsdr')
morse_active_device = None
morse_active_sdr_type = None
morse_last_error = msg
_set_state(MORSE_ERROR, msg)
_set_state(MORSE_IDLE, 'Idle')
return jsonify({'status': 'error', 'message': msg}), 500
with app_module.morse_lock:
app_module.morse_process = active_rtl_process
app_module.morse_process._stop_decoder = active_stop_event
app_module.morse_process._decoder_thread = active_decoder_thread
app_module.morse_process._stderr_thread = active_stderr_thread
app_module.morse_process._control_queue = active_control_queue
morse_stop_event = active_stop_event
morse_control_queue = active_control_queue
morse_decoder_worker = active_decoder_thread
morse_stderr_worker = active_stderr_thread
morse_runtime_config = dict(runtime_config)
_set_state(MORSE_RUNNING, 'Listening')
return jsonify({
'status': 'started',
'state': MORSE_RUNNING,
'command': full_cmd,
'detect_mode': detect_mode,
'modulation': modulation,
'tone_freq': tone_freq,
'wpm': wpm,
'config': runtime_config,
'session_id': morse_session_id,
})
except FileNotFoundError as e:
_cleanup_attempt(
rtl_process if rtl_process is not None else active_rtl_process,
stop_event if stop_event is not None else active_stop_event,
control_queue if control_queue is not None else active_control_queue,
decoder_thread if decoder_thread is not None else active_decoder_thread,
stderr_thread if stderr_thread is not None else active_stderr_thread,
)
with app_module.morse_lock:
if morse_active_device is not None:
app_module.release_sdr_device(morse_active_device, morse_active_sdr_type or 'rtlsdr')
morse_active_device = None
morse_active_sdr_type = None
morse_last_error = f'Tool not found: {e.filename}'
_set_state(MORSE_ERROR, morse_last_error)
_set_state(MORSE_IDLE, 'Idle')
return jsonify({'status': 'error', 'message': morse_last_error}), 400
except Exception as e:
_cleanup_attempt(
rtl_process if rtl_process is not None else active_rtl_process,
stop_event if stop_event is not None else active_stop_event,
control_queue if control_queue is not None else active_control_queue,
decoder_thread if decoder_thread is not None else active_decoder_thread,
stderr_thread if stderr_thread is not None else active_stderr_thread,
)
with app_module.morse_lock:
if morse_active_device is not None:
app_module.release_sdr_device(morse_active_device, morse_active_sdr_type or 'rtlsdr')
morse_active_device = None
morse_active_sdr_type = None
morse_last_error = str(e)
_set_state(MORSE_ERROR, morse_last_error)
_set_state(MORSE_IDLE, 'Idle')
return jsonify({'status': 'error', 'message': str(e)}), 500
@morse_bp.route('/morse/stop', methods=['POST'])
def stop_morse() -> Response:
global morse_active_device, morse_active_sdr_type, morse_decoder_worker, morse_stderr_worker
global morse_stop_event, morse_control_queue
stop_started = time.perf_counter()
with app_module.morse_lock:
if morse_state == MORSE_STOPPING:
return jsonify({'status': 'stopping', 'state': MORSE_STOPPING}), 202
rtl_proc = app_module.morse_process
stop_event = morse_stop_event or getattr(rtl_proc, '_stop_decoder', None)
decoder_thread = morse_decoder_worker or getattr(rtl_proc, '_decoder_thread', None)
stderr_thread = morse_stderr_worker or getattr(rtl_proc, '_stderr_thread', None)
control_queue = morse_control_queue or getattr(rtl_proc, '_control_queue', None)
active_device = morse_active_device
active_sdr_type = morse_active_sdr_type
if (
not rtl_proc
and not stop_event
and not decoder_thread
and not stderr_thread
):
_set_state(MORSE_IDLE, 'Idle', enqueue=False)
return jsonify({'status': 'not_running', 'state': MORSE_IDLE})
_set_state(MORSE_STOPPING, 'Stopping decoder...')
app_module.morse_process = None
morse_stop_event = None
morse_control_queue = None
morse_decoder_worker = None
morse_stderr_worker = None
cleanup_steps: list[str] = []
def _mark(step: str) -> None:
cleanup_steps.append(step)
logger.debug(f'[morse.stop] {step}')
_mark('enter stop')
if stop_event is not None:
stop_event.set()
_mark('stop_event set')
if control_queue is not None:
with contextlib.suppress(queue.Full):
control_queue.put_nowait({'cmd': 'shutdown'})
_mark('control_queue shutdown signal sent')
if rtl_proc is not None:
_close_pipe(getattr(rtl_proc, 'stdout', None))
_close_pipe(getattr(rtl_proc, 'stderr', None))
_mark('rtl_fm pipes closed')
if rtl_proc is not None:
safe_terminate(rtl_proc, timeout=0.6)
unregister_process(rtl_proc)
_mark('rtl_fm process terminated')
decoder_joined = _join_thread(decoder_thread, timeout_s=0.45)
stderr_joined = _join_thread(stderr_thread, timeout_s=0.45)
_mark(f'decoder thread joined={decoder_joined}')
_mark(f'stderr thread joined={stderr_joined}')
if active_device is not None:
app_module.release_sdr_device(active_device, active_sdr_type or 'rtlsdr')
_mark(f'SDR device {active_device} released')
stop_ms = round((time.perf_counter() - stop_started) * 1000.0, 1)
alive_after = []
if not decoder_joined:
alive_after.append('decoder_thread')
if not stderr_joined:
alive_after.append('stderr_thread')
if rtl_proc is not None and rtl_proc.poll() is None:
alive_after.append('rtl_process')
with app_module.morse_lock:
morse_active_device = None
morse_active_sdr_type = None
_set_state(MORSE_IDLE, 'Stopped', extra={
'stop_ms': stop_ms,
'cleanup_steps': cleanup_steps,
'alive': alive_after,
})
with contextlib.suppress(queue.Full):
app_module.morse_queue.put_nowait({
'type': 'status',
'status': 'stopped',
'state': MORSE_IDLE,
'stop_ms': stop_ms,
'cleanup_steps': cleanup_steps,
'alive': alive_after,
'timestamp': time.strftime('%H:%M:%S'),
})
if stop_ms > 500.0 or alive_after:
logger.warning(
'[morse.stop] slow/partial cleanup: stop_ms=%s alive=%s steps=%s',
stop_ms,
','.join(alive_after) if alive_after else 'none',
'; '.join(cleanup_steps),
)
else:
logger.info('[morse.stop] cleanup complete in %sms', stop_ms)
return jsonify({
'status': 'stopped',
'state': MORSE_IDLE,
'stop_ms': stop_ms,
'alive': alive_after,
'cleanup_steps': cleanup_steps,
})
@morse_bp.route('/morse/calibrate', methods=['POST'])
def calibrate_morse() -> Response:
"""Reset decoder threshold/timing estimators without restarting the process."""
with app_module.morse_lock:
if morse_state != MORSE_RUNNING or morse_control_queue is None:
return jsonify({
'status': 'not_running',
'state': morse_state,
'message': 'Morse decoder is not running',
}), 409
with contextlib.suppress(queue.Full):
morse_control_queue.put_nowait({'cmd': 'reset'})
with contextlib.suppress(queue.Full):
app_module.morse_queue.put_nowait({
'type': 'info',
'text': '[morse] Calibration reset requested',
})
return jsonify({'status': 'ok', 'state': morse_state})
@morse_bp.route('/morse/decode-file', methods=['POST'])
def decode_morse_file() -> Response:
"""Decode Morse from an uploaded WAV file."""
if 'audio' not in request.files:
return jsonify({'status': 'error', 'message': 'No audio file provided'}), 400
audio_file = request.files['audio']
if not audio_file.filename:
return jsonify({'status': 'error', 'message': 'No file selected'}), 400
# Parse optional tuning/decoder parameters from form fields.
form = request.form or {}
try:
tone_freq = _validate_tone_freq(form.get('tone_freq', '700'))
wpm = _validate_wpm(form.get('wpm', '15'))
bandwidth_hz = _validate_bandwidth(form.get('bandwidth_hz', '200'))
threshold_mode = _validate_threshold_mode(form.get('threshold_mode', 'auto'))
wpm_mode = _validate_wpm_mode(form.get('wpm_mode', 'auto'))
threshold_multiplier = _validate_threshold_multiplier(form.get('threshold_multiplier', '2.8'))
manual_threshold = _validate_non_negative_float(form.get('manual_threshold', '0'), 'manual threshold')
threshold_offset = _validate_non_negative_float(form.get('threshold_offset', '0'), 'threshold offset')
signal_gate = _validate_signal_gate(form.get('signal_gate', '0'))
auto_tone_track = _bool_value(form.get('auto_tone_track', 'true'), True)
tone_lock = _bool_value(form.get('tone_lock', 'false'), False)
wpm_lock = _bool_value(form.get('wpm_lock', 'false'), False)
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp:
audio_file.save(tmp.name)
tmp_path = Path(tmp.name)
try:
result = decode_morse_wav_file(
tmp_path,
sample_rate=8000,
tone_freq=tone_freq,
wpm=wpm,
bandwidth_hz=bandwidth_hz,
auto_tone_track=auto_tone_track,
tone_lock=tone_lock,
threshold_mode=threshold_mode,
manual_threshold=manual_threshold,
threshold_multiplier=threshold_multiplier,
threshold_offset=threshold_offset,
wpm_mode=wpm_mode,
wpm_lock=wpm_lock,
min_signal_gate=signal_gate,
)
text = str(result.get('text', ''))
raw = str(result.get('raw', ''))
metrics = result.get('metrics', {})
return jsonify({
'status': 'ok',
'text': text,
'raw': raw,
'char_count': len(text.replace(' ', '')),
'word_count': len([w for w in text.split(' ') if w]),
'metrics': metrics,
})
except Exception as e:
logger.error(f'Morse decode-file error: {e}')
return jsonify({'status': 'error', 'message': str(e)}), 500
finally:
with contextlib.suppress(Exception):
tmp_path.unlink(missing_ok=True)
@morse_bp.route('/morse/status')
def morse_status() -> Response:
with app_module.morse_lock:
running = (
app_module.morse_process is not None
and app_module.morse_process.poll() is None
and morse_state in {MORSE_RUNNING, MORSE_STARTING, MORSE_STOPPING}
)
since_ms = round((time.monotonic() - morse_state_since) * 1000.0, 1)
return jsonify({
'running': running,
'state': morse_state,
'message': morse_state_message,
'since_ms': since_ms,
'session_id': morse_session_id,
'config': morse_runtime_config,
'alive': _snapshot_live_resources(),
'error': morse_last_error,
})
@morse_bp.route('/morse/stream')
def morse_stream() -> Response:
def _on_msg(msg: dict[str, Any]) -> None:
process_event('morse', msg, msg.get('type'))
response = Response(
sse_stream_fanout(
source_queue=app_module.morse_queue,
channel_key='morse',
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
+84 -75
View File
@@ -24,7 +24,7 @@ from utils.validation import (
validate_frequency, validate_device_index, validate_gain, validate_ppm,
validate_rtl_tcp_host, validate_rtl_tcp_port
)
from utils.sse import sse_stream_fanout
from utils.sse import sse_stream_fanout
from utils.event_pipeline import process_event
from utils.process import safe_terminate, register_process, unregister_process
from utils.sdr import SDRFactory, SDRType, SDRValidationError
@@ -34,6 +34,7 @@ pager_bp = Blueprint('pager', __name__)
# Track which device is being used
pager_active_device: int | None = None
pager_active_sdr_type: str | None = None
def parse_multimon_output(line: str) -> dict[str, str] | None:
@@ -96,7 +97,7 @@ def parse_multimon_output(line: str) -> dict[str, str] | None:
return None
def log_message(msg: dict[str, Any]) -> None:
def log_message(msg: dict[str, Any]) -> None:
"""Log a message to file if logging is enabled."""
if not app_module.logging_enabled:
return
@@ -104,39 +105,39 @@ def log_message(msg: dict[str, Any]) -> None:
with open(app_module.log_file_path, 'a') as f:
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
f.write(f"{timestamp} | {msg.get('protocol', 'UNKNOWN')} | {msg.get('address', '')} | {msg.get('message', '')}\n")
except Exception as e:
logger.error(f"Failed to log message: {e}")
def _encode_scope_waveform(samples: tuple[int, ...], window_size: int = 256) -> list[int]:
"""Compress recent PCM samples into a signed 8-bit waveform for SSE."""
if not samples:
return []
window = samples[-window_size:] if len(samples) > window_size else samples
waveform: list[int] = []
for sample in window:
# Convert int16 PCM to int8 range for lightweight transport.
packed = int(round(sample / 256))
waveform.append(max(-127, min(127, packed)))
return waveform
def audio_relay_thread(
rtl_stdout,
multimon_stdin,
output_queue: queue.Queue,
stop_event: threading.Event,
) -> None:
"""Relay audio from rtl_fm to multimon-ng while computing signal levels.
Reads raw 16-bit LE PCM from *rtl_stdout*, writes every chunk straight
through to *multimon_stdin*, and every ~100 ms pushes an RMS / peak scope
event plus a compact waveform sample onto *output_queue*.
"""
CHUNK = 4096 # bytes 2048 samples at 16-bit mono
INTERVAL = 0.1 # seconds between scope updates
last_scope = time.monotonic()
except Exception as e:
logger.error(f"Failed to log message: {e}")
def _encode_scope_waveform(samples: tuple[int, ...], window_size: int = 256) -> list[int]:
"""Compress recent PCM samples into a signed 8-bit waveform for SSE."""
if not samples:
return []
window = samples[-window_size:] if len(samples) > window_size else samples
waveform: list[int] = []
for sample in window:
# Convert int16 PCM to int8 range for lightweight transport.
packed = int(round(sample / 256))
waveform.append(max(-127, min(127, packed)))
return waveform
def audio_relay_thread(
rtl_stdout,
multimon_stdin,
output_queue: queue.Queue,
stop_event: threading.Event,
) -> None:
"""Relay audio from rtl_fm to multimon-ng while computing signal levels.
Reads raw 16-bit LE PCM from *rtl_stdout*, writes every chunk straight
through to *multimon_stdin*, and every ~100 ms pushes an RMS / peak scope
event plus a compact waveform sample onto *output_queue*.
"""
CHUNK = 4096 # bytes 2048 samples at 16-bit mono
INTERVAL = 0.1 # seconds between scope updates
last_scope = time.monotonic()
try:
while not stop_event.is_set():
@@ -160,16 +161,16 @@ def audio_relay_thread(
if n_samples == 0:
continue
samples = struct.unpack(f'<{n_samples}h', data[:n_samples * 2])
peak = max(abs(s) for s in samples)
rms = int(math.sqrt(sum(s * s for s in samples) / n_samples))
output_queue.put_nowait({
'type': 'scope',
'rms': rms,
'peak': peak,
'waveform': _encode_scope_waveform(samples),
})
except (struct.error, ValueError, queue.Full):
pass
peak = max(abs(s) for s in samples)
rms = int(math.sqrt(sum(s * s for s in samples) / n_samples))
output_queue.put_nowait({
'type': 'scope',
'rms': rms,
'peak': peak,
'waveform': _encode_scope_waveform(samples),
})
except (struct.error, ValueError, queue.Full):
pass
except Exception as e:
logger.debug(f"Audio relay error: {e}")
finally:
@@ -220,7 +221,7 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
except Exception as e:
app_module.output_queue.put({'type': 'error', 'text': str(e)})
finally:
global pager_active_device
global pager_active_device, pager_active_sdr_type
try:
os.close(master_fd)
except OSError:
@@ -249,13 +250,14 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
app_module.current_process = None
# Release SDR device
if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device)
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
pager_active_device = None
pager_active_sdr_type = None
@pager_bp.route('/start', methods=['POST'])
def start_decoding() -> Response:
global pager_active_device
global pager_active_device, pager_active_sdr_type
with app_module.process_lock:
if app_module.current_process:
@@ -284,10 +286,13 @@ def start_decoding() -> Response:
rtl_tcp_host = data.get('rtl_tcp_host')
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
# Get SDR type early so we can pass it to claim/release
sdr_type_str = data.get('sdr_type', 'rtlsdr')
# Claim local device if not using remote rtl_tcp
if not rtl_tcp_host:
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'pager')
error = app_module.claim_sdr_device(device_int, 'pager', sdr_type_str)
if error:
return jsonify({
'status': 'error',
@@ -295,14 +300,16 @@ def start_decoding() -> Response:
'message': error
}), 409
pager_active_device = device_int
pager_active_sdr_type = sdr_type_str
# Validate protocols
valid_protocols = ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX']
protocols = data.get('protocols', valid_protocols)
if not isinstance(protocols, list):
if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device)
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
pager_active_device = None
pager_active_sdr_type = None
return jsonify({'status': 'error', 'message': 'Protocols must be a list'}), 400
protocols = [p for p in protocols if p in valid_protocols]
if not protocols:
@@ -327,8 +334,7 @@ def start_decoding() -> Response:
elif proto == 'FLEX':
decoders.extend(['-a', 'FLEX'])
# Get SDR type and build command via abstraction layer
sdr_type_str = data.get('sdr_type', 'rtlsdr')
# Build command via SDR abstraction layer
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
@@ -443,8 +449,9 @@ def start_decoding() -> Response:
pass
# Release device on failure
if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device)
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
pager_active_device = None
pager_active_sdr_type = None
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'})
except Exception as e:
# Kill orphaned rtl_fm process if it was started
@@ -458,14 +465,15 @@ def start_decoding() -> Response:
pass
# Release device on failure
if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device)
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
pager_active_device = None
pager_active_sdr_type = None
return jsonify({'status': 'error', 'message': str(e)})
@pager_bp.route('/stop', methods=['POST'])
def stop_decoding() -> Response:
global pager_active_device
global pager_active_device, pager_active_sdr_type
with app_module.process_lock:
if app_module.current_process:
@@ -502,8 +510,9 @@ def stop_decoding() -> Response:
# Release device from registry
if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device)
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
pager_active_device = None
pager_active_sdr_type = None
return jsonify({'status': 'stopped'})
@@ -553,22 +562,22 @@ def toggle_logging() -> Response:
return jsonify({'logging': app_module.logging_enabled, 'log_file': app_module.log_file_path})
@pager_bp.route('/stream')
def stream() -> Response:
def _on_msg(msg: dict[str, Any]) -> None:
process_event('pager', msg, msg.get('type'))
response = Response(
sse_stream_fanout(
source_queue=app_module.output_queue,
channel_key='pager',
timeout=1.0,
keepalive_interval=30.0,
on_message=_on_msg,
),
mimetype='text/event-stream',
)
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response
@pager_bp.route('/stream')
def stream() -> Response:
def _on_msg(msg: dict[str, Any]) -> None:
process_event('pager', msg, msg.get('type'))
response = Response(
sse_stream_fanout(
source_queue=app_module.output_queue,
channel_key='pager',
timeout=1.0,
keepalive_interval=30.0,
on_message=_on_msg,
),
mimetype='text/event-stream',
)
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response
+682
View File
@@ -0,0 +1,682 @@
"""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
"""
with open(cfg_path, 'w') as f:
f.write(cfg)
logger.info(f"Generated station.cfg at {cfg_path}")
return cfg_path
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.)
if 'type' in msg:
balloon['sonde_type'] = msg['type']
if 'subtype' in msg:
balloon['sonde_type'] = msg['subtype']
# 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
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,
)
# Build command - auto_rx -c expects a file path, not a directory
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))
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
error_msg = 'radiosonde_auto_rx failed to start. Check SDR device connection.'
if stderr_output:
error_msg += f' Error: {stderr_output[:200]}'
return jsonify({'status': 'error', 'message': error_msg}), 500
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),
})
+34 -21
View File
@@ -138,36 +138,34 @@ def start_rtlamr() -> Response:
output_format = data.get('format', 'json')
# Start rtl_tcp first
rtl_tcp_just_started = False
rtl_tcp_cmd_str = ''
with rtl_tcp_lock:
if not rtl_tcp_process:
logger.info("Starting rtl_tcp server...")
try:
rtl_tcp_cmd = ['rtl_tcp', '-a', '0.0.0.0']
# Add device index if not 0
if device and device != '0':
rtl_tcp_cmd.extend(['-d', str(device)])
# Add gain if not auto
if gain and gain != '0':
rtl_tcp_cmd.extend(['-g', str(gain)])
# Add PPM correction if not 0
if ppm and ppm != '0':
rtl_tcp_cmd.extend(['-p', str(ppm)])
rtl_tcp_process = subprocess.Popen(
rtl_tcp_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
register_process(rtl_tcp_process)
# Wait a moment for rtl_tcp to start
time.sleep(3)
logger.info(f"rtl_tcp started: {' '.join(rtl_tcp_cmd)}")
app_module.rtlamr_queue.put({'type': 'info', 'text': f'rtl_tcp: {" ".join(rtl_tcp_cmd)}'})
rtl_tcp_just_started = True
rtl_tcp_cmd_str = ' '.join(rtl_tcp_cmd)
except Exception as e:
logger.error(f"Failed to start rtl_tcp: {e}")
# Release SDR device on rtl_tcp failure
@@ -176,6 +174,12 @@ def start_rtlamr() -> Response:
rtlamr_active_device = None
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
cmd = [
'rtlamr',
@@ -258,25 +262,34 @@ def start_rtlamr() -> Response:
def stop_rtlamr() -> Response:
global rtl_tcp_process, rtlamr_active_device
# Grab process refs inside locks, clear state, then terminate outside
rtlamr_proc = None
with app_module.rtlamr_lock:
if app_module.rtlamr_process:
app_module.rtlamr_process.terminate()
try:
app_module.rtlamr_process.wait(timeout=2)
except subprocess.TimeoutExpired:
app_module.rtlamr_process.kill()
rtlamr_proc = app_module.rtlamr_process
app_module.rtlamr_process = None
if rtlamr_proc:
rtlamr_proc.terminate()
try:
rtlamr_proc.wait(timeout=2)
except subprocess.TimeoutExpired:
rtlamr_proc.kill()
# Also stop rtl_tcp
tcp_proc = None
with rtl_tcp_lock:
if rtl_tcp_process:
rtl_tcp_process.terminate()
try:
rtl_tcp_process.wait(timeout=2)
except subprocess.TimeoutExpired:
rtl_tcp_process.kill()
tcp_proc = rtl_tcp_process
rtl_tcp_process = None
logger.info("rtl_tcp stopped")
if tcp_proc:
tcp_proc.terminate()
try:
tcp_proc.wait(timeout=2)
except subprocess.TimeoutExpired:
tcp_proc.kill()
logger.info("rtl_tcp stopped")
# Release device from registry
if rtlamr_active_device is not None:
+265 -257
View File
@@ -1,5 +1,5 @@
"""RTL_433 sensor monitoring routes."""
"""RTL_433 sensor monitoring routes."""
from __future__ import annotations
import json
@@ -10,25 +10,26 @@ import threading
import time
from datetime import datetime
from typing import Any, Generator
from flask import Blueprint, jsonify, request, Response
import app as app_module
from utils.logging import sensor_logger as logger
from utils.validation import (
validate_frequency, validate_device_index, validate_gain, validate_ppm,
validate_rtl_tcp_host, validate_rtl_tcp_port
)
from flask import Blueprint, jsonify, request, Response
import app as app_module
from utils.logging import sensor_logger as logger
from utils.validation import (
validate_frequency, validate_device_index, validate_gain, validate_ppm,
validate_rtl_tcp_host, validate_rtl_tcp_port
)
from utils.sse import sse_stream_fanout
from utils.event_pipeline import process_event
from utils.process import safe_terminate, register_process, unregister_process
from utils.sdr import SDRFactory, SDRType
sensor_bp = Blueprint('sensor', __name__)
# Track which device is being used
sensor_active_device: int | None = None
from utils.event_pipeline import process_event
from utils.process import safe_terminate, register_process, unregister_process
from utils.sdr import SDRFactory, SDRType
sensor_bp = Blueprint('sensor', __name__)
# Track which device is being used
sensor_active_device: int | None = None
sensor_active_sdr_type: str | None = None
# RSSI history per device (model_id -> list of (timestamp, rssi))
sensor_rssi_history: dict[str, list[tuple[float, float]]] = {}
_MAX_RSSI_HISTORY = 60
@@ -65,36 +66,36 @@ def _build_scope_waveform(rssi: float, snr: float, noise: float, points: int = 2
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
"""Stream rtl_433 JSON output to queue."""
try:
app_module.sensor_queue.put({'type': 'status', 'text': 'started'})
for line in iter(process.stdout.readline, b''):
line = line.decode('utf-8', errors='replace').strip()
if not line:
continue
try:
# rtl_433 outputs JSON objects, one per line
data = json.loads(line)
data['type'] = 'sensor'
app_module.sensor_queue.put(data)
# Track RSSI history per device
_model = data.get('model', '')
_dev_id = data.get('id', '')
_rssi_val = data.get('rssi')
if _rssi_val is not None and _model:
_hist_key = f"{_model}_{_dev_id}"
hist = sensor_rssi_history.setdefault(_hist_key, [])
hist.append((time.time(), float(_rssi_val)))
if len(hist) > _MAX_RSSI_HISTORY:
del hist[: len(hist) - _MAX_RSSI_HISTORY]
# Push scope event when signal level data is present
rssi = data.get('rssi')
snr = data.get('snr')
noise = data.get('noise')
"""Stream rtl_433 JSON output to queue."""
try:
app_module.sensor_queue.put({'type': 'status', 'text': 'started'})
for line in iter(process.stdout.readline, b''):
line = line.decode('utf-8', errors='replace').strip()
if not line:
continue
try:
# rtl_433 outputs JSON objects, one per line
data = json.loads(line)
data['type'] = 'sensor'
app_module.sensor_queue.put(data)
# Track RSSI history per device
_model = data.get('model', '')
_dev_id = data.get('id', '')
_rssi_val = data.get('rssi')
if _rssi_val is not None and _model:
_hist_key = f"{_model}_{_dev_id}"
hist = sensor_rssi_history.setdefault(_hist_key, [])
hist.append((time.time(), float(_rssi_val)))
if len(hist) > _MAX_RSSI_HISTORY:
del hist[: len(hist) - _MAX_RSSI_HISTORY]
# Push scope event when signal level data is present
rssi = data.get('rssi')
snr = data.get('snr')
noise = data.get('noise')
if rssi is not None or snr is not None:
try:
rssi_value = float(rssi) if rssi is not None else 0.0
@@ -113,204 +114,211 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
})
except (TypeError, ValueError, queue.Full):
pass
# Log if enabled
if app_module.logging_enabled:
try:
with open(app_module.log_file_path, 'a') as f:
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
f.write(f"{timestamp} | {data.get('model', 'Unknown')} | {json.dumps(data)}\n")
except Exception:
pass
except json.JSONDecodeError:
# Not JSON, send as raw
app_module.sensor_queue.put({'type': 'raw', 'text': line})
except Exception as e:
app_module.sensor_queue.put({'type': 'error', 'text': str(e)})
finally:
global sensor_active_device
# Ensure process is terminated
try:
process.terminate()
process.wait(timeout=2)
except Exception:
try:
process.kill()
except Exception:
pass
unregister_process(process)
app_module.sensor_queue.put({'type': 'status', 'text': 'stopped'})
with app_module.sensor_lock:
app_module.sensor_process = None
# Release SDR device
if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device)
sensor_active_device = None
@sensor_bp.route('/sensor/status')
def sensor_status() -> Response:
"""Check if sensor decoder is currently running."""
with app_module.sensor_lock:
running = app_module.sensor_process is not None and app_module.sensor_process.poll() is None
return jsonify({'running': running})
@sensor_bp.route('/start_sensor', methods=['POST'])
def start_sensor() -> Response:
global sensor_active_device
with app_module.sensor_lock:
if app_module.sensor_process:
return jsonify({'status': 'error', 'message': 'Sensor already running'}), 409
data = request.json or {}
# Validate inputs
try:
freq = validate_frequency(data.get('frequency', '433.92'))
gain = validate_gain(data.get('gain', '0'))
ppm = validate_ppm(data.get('ppm', '0'))
device = validate_device_index(data.get('device', '0'))
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
# Check for rtl_tcp (remote SDR) connection
rtl_tcp_host = data.get('rtl_tcp_host')
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
# Claim local device if not using remote rtl_tcp
if not rtl_tcp_host:
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'sensor')
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
sensor_active_device = device_int
# Clear queue
while not app_module.sensor_queue.empty():
try:
app_module.sensor_queue.get_nowait()
except queue.Empty:
break
# Get SDR type and build command via abstraction layer
sdr_type_str = data.get('sdr_type', 'rtlsdr')
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
if rtl_tcp_host:
# Validate and create network device
try:
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
else:
# Create local device object
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
builder = SDRFactory.get_builder(sdr_device.sdr_type)
# Build ISM band decoder command
bias_t = data.get('bias_t', False)
cmd = builder.build_ism_command(
device=sdr_device,
frequency_mhz=freq,
gain=float(gain) if gain and gain != 0 else None,
ppm=int(ppm) if ppm and ppm != 0 else None,
bias_t=bias_t
)
full_cmd = ' '.join(cmd)
logger.info(f"Running: {full_cmd}")
# Add signal level metadata so the frontend scope can display RSSI/SNR
# Disable stats reporting to suppress "row count limit 50 reached" warnings
cmd.extend(['-M', 'level', '-M', 'stats:0'])
try:
app_module.sensor_process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
register_process(app_module.sensor_process)
# Start output thread
thread = threading.Thread(target=stream_sensor_output, args=(app_module.sensor_process,))
thread.daemon = True
thread.start()
# Monitor stderr
# Filter noisy rtl_433 diagnostics that aren't useful to display
_stderr_noise = (
'bitbuffer_add_bit',
'row count limit',
)
def monitor_stderr():
for line in app_module.sensor_process.stderr:
err = line.decode('utf-8', errors='replace').strip()
if err and not any(noise in err for noise in _stderr_noise):
logger.debug(f"[rtl_433] {err}")
app_module.sensor_queue.put({'type': 'info', 'text': f'[rtl_433] {err}'})
stderr_thread = threading.Thread(target=monitor_stderr)
stderr_thread.daemon = True
stderr_thread.start()
app_module.sensor_queue.put({'type': 'info', 'text': f'Command: {full_cmd}'})
return jsonify({'status': 'started', 'command': full_cmd})
except FileNotFoundError:
# Release device on failure
if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device)
sensor_active_device = None
return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'})
except Exception as e:
# Release device on failure
if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device)
sensor_active_device = None
return jsonify({'status': 'error', 'message': str(e)})
@sensor_bp.route('/stop_sensor', methods=['POST'])
def stop_sensor() -> Response:
global sensor_active_device
with app_module.sensor_lock:
if app_module.sensor_process:
app_module.sensor_process.terminate()
try:
app_module.sensor_process.wait(timeout=2)
except subprocess.TimeoutExpired:
app_module.sensor_process.kill()
app_module.sensor_process = None
# Release device from registry
if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device)
sensor_active_device = None
return jsonify({'status': 'stopped'})
return jsonify({'status': 'not_running'})
# Log if enabled
if app_module.logging_enabled:
try:
with open(app_module.log_file_path, 'a') as f:
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
f.write(f"{timestamp} | {data.get('model', 'Unknown')} | {json.dumps(data)}\n")
except Exception:
pass
except json.JSONDecodeError:
# Not JSON, send as raw
app_module.sensor_queue.put({'type': 'raw', 'text': line})
except Exception as e:
app_module.sensor_queue.put({'type': 'error', 'text': str(e)})
finally:
global sensor_active_device, sensor_active_sdr_type
# Ensure process is terminated
try:
process.terminate()
process.wait(timeout=2)
except Exception:
try:
process.kill()
except Exception:
pass
unregister_process(process)
app_module.sensor_queue.put({'type': 'status', 'text': 'stopped'})
with app_module.sensor_lock:
app_module.sensor_process = None
# Release SDR device
if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
sensor_active_device = None
sensor_active_sdr_type = None
@sensor_bp.route('/sensor/status')
def sensor_status() -> Response:
"""Check if sensor decoder is currently running."""
with app_module.sensor_lock:
running = app_module.sensor_process is not None and app_module.sensor_process.poll() is None
return jsonify({'running': running})
@sensor_bp.route('/start_sensor', methods=['POST'])
def start_sensor() -> Response:
global sensor_active_device, sensor_active_sdr_type
with app_module.sensor_lock:
if app_module.sensor_process:
return jsonify({'status': 'error', 'message': 'Sensor already running'}), 409
data = request.json or {}
# Validate inputs
try:
freq = validate_frequency(data.get('frequency', '433.92'))
gain = validate_gain(data.get('gain', '0'))
ppm = validate_ppm(data.get('ppm', '0'))
device = validate_device_index(data.get('device', '0'))
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
# Check for rtl_tcp (remote SDR) connection
rtl_tcp_host = data.get('rtl_tcp_host')
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
# Get SDR type early so we can pass it to claim/release
sdr_type_str = data.get('sdr_type', 'rtlsdr')
# Claim local device if not using remote rtl_tcp
if not rtl_tcp_host:
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'sensor', sdr_type_str)
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
sensor_active_device = device_int
sensor_active_sdr_type = sdr_type_str
# Clear queue
while not app_module.sensor_queue.empty():
try:
app_module.sensor_queue.get_nowait()
except queue.Empty:
break
# Build command via SDR abstraction layer
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
if rtl_tcp_host:
# Validate and create network device
try:
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
else:
# Create local device object
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
builder = SDRFactory.get_builder(sdr_device.sdr_type)
# Build ISM band decoder command
bias_t = data.get('bias_t', False)
cmd = builder.build_ism_command(
device=sdr_device,
frequency_mhz=freq,
gain=float(gain) if gain and gain != 0 else None,
ppm=int(ppm) if ppm and ppm != 0 else None,
bias_t=bias_t
)
full_cmd = ' '.join(cmd)
logger.info(f"Running: {full_cmd}")
# Add signal level metadata so the frontend scope can display RSSI/SNR
# Disable stats reporting to suppress "row count limit 50 reached" warnings
cmd.extend(['-M', 'level', '-M', 'stats:0'])
try:
app_module.sensor_process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
register_process(app_module.sensor_process)
# Start output thread
thread = threading.Thread(target=stream_sensor_output, args=(app_module.sensor_process,))
thread.daemon = True
thread.start()
# Monitor stderr
# Filter noisy rtl_433 diagnostics that aren't useful to display
_stderr_noise = (
'bitbuffer_add_bit',
'row count limit',
)
def monitor_stderr():
for line in app_module.sensor_process.stderr:
err = line.decode('utf-8', errors='replace').strip()
if err and not any(noise in err for noise in _stderr_noise):
logger.debug(f"[rtl_433] {err}")
app_module.sensor_queue.put({'type': 'info', 'text': f'[rtl_433] {err}'})
stderr_thread = threading.Thread(target=monitor_stderr)
stderr_thread.daemon = True
stderr_thread.start()
app_module.sensor_queue.put({'type': 'info', 'text': f'Command: {full_cmd}'})
return jsonify({'status': 'started', 'command': full_cmd})
except FileNotFoundError:
# Release device on failure
if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
sensor_active_device = None
sensor_active_sdr_type = None
return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'})
except Exception as e:
# Release device on failure
if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
sensor_active_device = None
sensor_active_sdr_type = None
return jsonify({'status': 'error', 'message': str(e)})
@sensor_bp.route('/stop_sensor', methods=['POST'])
def stop_sensor() -> Response:
global sensor_active_device, sensor_active_sdr_type
with app_module.sensor_lock:
if app_module.sensor_process:
app_module.sensor_process.terminate()
try:
app_module.sensor_process.wait(timeout=2)
except subprocess.TimeoutExpired:
app_module.sensor_process.kill()
app_module.sensor_process = None
# Release device from registry
if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
sensor_active_device = None
sensor_active_sdr_type = None
return jsonify({'status': 'stopped'})
return jsonify({'status': 'not_running'})
@sensor_bp.route('/stream_sensor')
def stream_sensor() -> Response:
def _on_msg(msg: dict[str, Any]) -> None:
@@ -330,12 +338,12 @@ def stream_sensor() -> Response:
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response
@sensor_bp.route('/sensor/rssi_history')
def get_rssi_history() -> Response:
"""Return RSSI history for all tracked sensor devices."""
result = {}
for key, entries in sensor_rssi_history.items():
result[key] = [{'t': round(t, 1), 'rssi': rssi} for t, rssi in entries]
return jsonify({'status': 'success', 'devices': result})
@sensor_bp.route('/sensor/rssi_history')
def get_rssi_history() -> Response:
"""Return RSSI history for all tracked sensor devices."""
result = {}
for key, entries in sensor_rssi_history.items():
result[key] = [{'t': round(t, 1), 'rssi': rssi} for t, rssi in entries]
return jsonify({'status': 'success', 'devices': result})
+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
) -> 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:
- 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})")
# Detect available SDR devices and sweep tools
rtl_power_path = shutil.which('rtl_power')
if not rtl_power_path:
logger.warning("rtl_power not found in PATH, RF scanning unavailable")
hackrf_sweep_path = shutil.which('hackrf_sweep')
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', {
'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
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)
# Format: (start_freq, end_freq, bin_size, description)
scan_bands: list[tuple[int, int, int, str]] = []
@@ -1448,7 +1459,7 @@ def _scan_rf_signals(
try:
# 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
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)")
try:
# Run rtl_power for a quick sweep of this band
cmd = [
rtl_power_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
] + device_arg + [tmp_path]
# Build sweep command based on SDR type
if sdr_type == 'hackrf':
cmd = [
sweep_tool_path,
'-f', f'{int(start_freq / 1e6)}:{int(end_freq / 1e6)}',
'-w', str(bin_size),
'-1', # Single sweep
]
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)}")
@@ -1478,9 +1501,14 @@ def _scan_rf_signals(
)
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:
with open(tmp_path, 'r') as f:
for line in f:
@@ -1488,13 +1516,12 @@ def _scan_rf_signals(
if len(parts) >= 7:
try:
# CSV format: date, time, hz_low, hz_high, hz_step, samples, db_values...
hz_low = int(parts[2])
hz_high = int(parts[3])
hz_step = float(parts[4])
hz_low = int(parts[2].strip())
hz_high = int(parts[3].strip())
hz_step = float(parts[4].strip())
db_values = [float(x) for x in parts[6:] if x.strip()]
# 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
threshold = noise_floor + 6 # Signal must be 6dB above noise
+97 -91
View File
@@ -1,17 +1,17 @@
"""VDL2 aircraft datalink routes."""
from __future__ import annotations
import io
import json
import os
import platform
import pty
import queue
import shutil
import subprocess
import threading
import time
from __future__ import annotations
import io
import json
import os
import platform
import pty
import queue
import shutil
import subprocess
import threading
import time
from datetime import datetime
from typing import Generator
@@ -21,7 +21,7 @@ import app as app_module
from utils.logging import sensor_logger as logger
from utils.validation import validate_device_index, validate_gain, validate_ppm
from utils.sdr import SDRFactory, SDRType
from utils.sse import sse_stream_fanout
from utils.sse import sse_stream_fanout
from utils.event_pipeline import process_event
from utils.constants import (
PROCESS_TERMINATE_TIMEOUT,
@@ -48,6 +48,7 @@ vdl2_last_message_time = None
# Track which device is being used
vdl2_active_device: int | None = None
vdl2_active_sdr_type: str | None = None
def find_dumpvdl2():
@@ -55,22 +56,22 @@ def find_dumpvdl2():
return shutil.which('dumpvdl2')
def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) -> None:
"""Stream dumpvdl2 JSON output to queue."""
global vdl2_message_count, vdl2_last_message_time
try:
app_module.vdl2_queue.put({'type': 'status', 'status': 'started'})
# Use appropriate sentinel based on mode (text mode for pty on macOS)
sentinel = '' if is_text_mode else b''
for line in iter(process.stdout.readline, sentinel):
if is_text_mode:
line = line.strip()
else:
line = line.decode('utf-8', errors='replace').strip()
if not line:
continue
def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) -> None:
"""Stream dumpvdl2 JSON output to queue."""
global vdl2_message_count, vdl2_last_message_time
try:
app_module.vdl2_queue.put({'type': 'status', 'status': 'started'})
# Use appropriate sentinel based on mode (text mode for pty on macOS)
sentinel = '' if is_text_mode else b''
for line in iter(process.stdout.readline, sentinel):
if is_text_mode:
line = line.strip()
else:
line = line.decode('utf-8', errors='replace').strip()
if not line:
continue
try:
data = json.loads(line)
@@ -110,7 +111,7 @@ def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) ->
logger.error(f"VDL2 stream error: {e}")
app_module.vdl2_queue.put({'type': 'error', 'message': str(e)})
finally:
global vdl2_active_device
global vdl2_active_device, vdl2_active_sdr_type
# Ensure process is terminated
try:
process.terminate()
@@ -126,8 +127,9 @@ def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) ->
app_module.vdl2_process = None
# Release SDR device
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_sdr_type = None
@vdl2_bp.route('/tools')
@@ -159,7 +161,7 @@ def vdl2_status() -> Response:
@vdl2_bp.route('/start', methods=['POST'])
def start_vdl2() -> Response:
"""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:
if app_module.vdl2_process and app_module.vdl2_process.poll() is None:
@@ -186,9 +188,16 @@ def start_vdl2() -> Response:
except ValueError as e:
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
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:
return jsonify({
'status': 'error',
@@ -197,6 +206,7 @@ def start_vdl2() -> Response:
}), 409
vdl2_active_device = device_int
vdl2_active_sdr_type = sdr_type_str
# Get frequencies - use provided or defaults
# dumpvdl2 expects frequencies in Hz (integers)
@@ -215,13 +225,6 @@ def start_vdl2() -> Response:
vdl2_message_count = 0
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,)
# Build dumpvdl2 command
@@ -252,28 +255,28 @@ def start_vdl2() -> Response:
logger.info(f"Starting VDL2 decoder: {' '.join(cmd)}")
try:
is_text_mode = False
# On macOS, use pty to avoid stdout buffering issues
if platform.system() == 'Darwin':
master_fd, slave_fd = pty.openpty()
process = subprocess.Popen(
cmd,
stdout=slave_fd,
stderr=subprocess.PIPE,
start_new_session=True
)
os.close(slave_fd)
# Wrap master_fd as a text file for line-buffered reading
process.stdout = io.open(master_fd, 'r', buffering=1)
is_text_mode = True
else:
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
start_new_session=True
)
is_text_mode = False
# On macOS, use pty to avoid stdout buffering issues
if platform.system() == 'Darwin':
master_fd, slave_fd = pty.openpty()
process = subprocess.Popen(
cmd,
stdout=slave_fd,
stderr=subprocess.PIPE,
start_new_session=True
)
os.close(slave_fd)
# Wrap master_fd as a text file for line-buffered reading
process.stdout = io.open(master_fd, 'r', buffering=1)
is_text_mode = True
else:
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
start_new_session=True
)
# Wait briefly to check if process started
time.sleep(PROCESS_START_WAIT)
@@ -281,8 +284,9 @@ def start_vdl2() -> Response:
if process.poll() is not None:
# Process died - release device
if vdl2_active_device is not None:
app_module.release_sdr_device(vdl2_active_device)
app_module.release_sdr_device(vdl2_active_device, vdl2_active_sdr_type or 'rtlsdr')
vdl2_active_device = None
vdl2_active_sdr_type = None
stderr = ''
if process.stderr:
stderr = process.stderr.read().decode('utf-8', errors='replace')
@@ -295,12 +299,12 @@ def start_vdl2() -> Response:
app_module.vdl2_process = process
register_process(process)
# Start output streaming thread
thread = threading.Thread(
target=stream_vdl2_output,
args=(process, is_text_mode),
daemon=True
)
# Start output streaming thread
thread = threading.Thread(
target=stream_vdl2_output,
args=(process, is_text_mode),
daemon=True
)
thread.start()
return jsonify({
@@ -313,8 +317,9 @@ def start_vdl2() -> Response:
except Exception as e:
# Release device on failure
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_sdr_type = None
logger.error(f"Failed to start VDL2 decoder: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500
@@ -322,7 +327,7 @@ def start_vdl2() -> Response:
@vdl2_bp.route('/stop', methods=['POST'])
def stop_vdl2() -> Response:
"""Stop VDL2 decoder."""
global vdl2_active_device
global vdl2_active_device, vdl2_active_sdr_type
with app_module.vdl2_lock:
if not app_module.vdl2_process:
@@ -343,31 +348,32 @@ def stop_vdl2() -> Response:
# Release device from registry
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_sdr_type = None
return jsonify({'status': 'stopped'})
@vdl2_bp.route('/stream')
def stream_vdl2() -> Response:
"""SSE stream for VDL2 messages."""
def _on_msg(msg: dict[str, Any]) -> None:
process_event('vdl2', msg, msg.get('type'))
response = Response(
sse_stream_fanout(
source_queue=app_module.vdl2_queue,
channel_key='vdl2',
timeout=SSE_QUEUE_TIMEOUT,
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
on_message=_on_msg,
),
mimetype='text/event-stream',
)
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
return response
@vdl2_bp.route('/stream')
def stream_vdl2() -> Response:
"""SSE stream for VDL2 messages."""
def _on_msg(msg: dict[str, Any]) -> None:
process_event('vdl2', msg, msg.get('type'))
response = Response(
sse_stream_fanout(
source_queue=app_module.vdl2_queue,
channel_key='vdl2',
timeout=SSE_QUEUE_TIMEOUT,
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
on_message=_on_msg,
),
mimetype='text/event-stream',
)
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
return response
@vdl2_bp.route('/frequencies')
+232 -137
View File
@@ -1,7 +1,10 @@
"""WebSocket-based waterfall streaming with I/Q capture and server-side FFT."""
from __future__ import annotations
import json
import queue
import shutil
import socket
import subprocess
import threading
@@ -11,14 +14,14 @@ from typing import Any
import numpy as np
from flask import Flask
try:
from flask_sock import Sock
WEBSOCKET_AVAILABLE = True
except ImportError:
WEBSOCKET_AVAILABLE = False
Sock = None
try:
from flask_sock import Sock
WEBSOCKET_AVAILABLE = True
except ImportError:
WEBSOCKET_AVAILABLE = False
Sock = None
from utils.logging import get_logger
from utils.process import register_process, safe_terminate, unregister_process
from utils.sdr import SDRFactory, SDRType
@@ -34,7 +37,7 @@ logger = get_logger('intercept.waterfall_ws')
AUDIO_SAMPLE_RATE = 48000
_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] = {
'running': False,
'device': None,
@@ -46,7 +49,11 @@ _shared_state: dict[str, Any] = {
'monitor_modulation': 'wfm',
'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)
MAX_BANDWIDTH = {
SDRType.RTL_SDR: 2400000,
@@ -72,8 +79,23 @@ def _set_shared_capture_state(
center_mhz: float | None = None,
span_mhz: float | 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:
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['device'] = device if running else None
if center_mhz is not None:
@@ -84,8 +106,10 @@ def _set_shared_capture_state(
_shared_state['sample_rate'] = int(sample_rate)
if not running:
_shared_state['monitor_enabled'] = False
gen = _capture_generation
if not running:
_clear_shared_audio_queue()
return gen
def _set_shared_monitor(
@@ -96,16 +120,20 @@ def _set_shared_monitor(
squelch: int | None = None,
) -> None:
was_enabled = False
freq_changed = False
with _shared_state_lock:
was_enabled = bool(_shared_state.get('monitor_enabled'))
_shared_state['monitor_enabled'] = bool(enabled)
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)
if abs(float(frequency_mhz) - old_freq) > 1e-6:
freq_changed = True
if modulation is not None:
_shared_state['monitor_modulation'] = str(modulation).lower().strip()
if squelch is not None:
_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()
@@ -187,18 +215,21 @@ def _demodulate_monitor_audio(
monitor_freq_mhz: float,
modulation: str,
squelch: int,
) -> bytes | None:
rotator_phase: float = 0.0,
) -> tuple[bytes | None, float]:
if samples.size < 32 or sample_rate <= 0:
return None
return None, float(rotator_phase)
fs = float(sample_rate)
freq_offset_hz = (float(monitor_freq_mhz) - float(center_mhz)) * 1e6
nyquist = fs * 0.5
if abs(freq_offset_hz) > nyquist * 0.98:
return None
return None, float(rotator_phase)
n = np.arange(samples.size, dtype=np.float32)
rotator = np.exp(-1j * (2.0 * np.pi * freq_offset_hz / fs) * n)
phase_inc = (2.0 * np.pi * freq_offset_hz) / fs
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
mod = str(modulation or 'wfm').lower().strip()
@@ -207,11 +238,11 @@ def _demodulate_monitor_audio(
if pre_decim > 1:
usable = (shifted.size // pre_decim) * pre_decim
if usable < pre_decim:
return None
return None, next_phase
shifted = shifted[:usable].reshape(-1, pre_decim).mean(axis=1)
fs1 = fs / pre_decim
if shifted.size < 16:
return None
return None, next_phase
if mod in ('wfm', 'fm'):
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)
if audio.size < 8:
return None
return None, next_phase
audio = audio - float(np.mean(audio))
@@ -238,7 +269,7 @@ def _demodulate_monitor_audio(
out_len = int(audio.size * AUDIO_SAMPLE_RATE / fs1)
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_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)
@@ -253,7 +284,7 @@ def _demodulate_monitor_audio(
audio = audio * min(20.0, 0.85 / peak)
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:
@@ -290,100 +321,106 @@ def _pick_sample_rate(span_hz: int, caps: SDRCapabilities, sdr_type: SDRType) ->
def _resolve_sdr_type(sdr_type_str: str) -> SDRType:
"""Convert client sdr_type string to SDRType enum."""
mapping = {
'rtlsdr': SDRType.RTL_SDR,
'rtl_sdr': SDRType.RTL_SDR,
'hackrf': SDRType.HACKRF,
'limesdr': SDRType.LIME_SDR,
'lime_sdr': 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:
"""Build a minimal SDRDevice for command building."""
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 init_waterfall_websocket(app: Flask):
"""Initialize WebSocket waterfall streaming."""
if not WEBSOCKET_AVAILABLE:
logger.warning("flask-sock not installed, WebSocket waterfall disabled")
return
sock = Sock(app)
@sock.route('/ws/waterfall')
def waterfall_stream(ws):
"""WebSocket endpoint for real-time waterfall streaming."""
logger.info("WebSocket waterfall client connected")
# Import app module for device claiming
import app as app_module
mapping = {
'rtlsdr': SDRType.RTL_SDR,
'rtl_sdr': SDRType.RTL_SDR,
'hackrf': SDRType.HACKRF,
'limesdr': SDRType.LIME_SDR,
'lime_sdr': 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:
"""Build a minimal SDRDevice for command building."""
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 init_waterfall_websocket(app: Flask):
"""Initialize WebSocket waterfall streaming."""
if not WEBSOCKET_AVAILABLE:
logger.warning("flask-sock not installed, WebSocket waterfall disabled")
return
sock = Sock(app)
@sock.route('/ws/waterfall')
def waterfall_stream(ws):
"""WebSocket endpoint for real-time waterfall streaming."""
logger.info("WebSocket waterfall client connected")
# Import app module for device claiming
import app as app_module
iq_process = None
reader_thread = None
stop_event = threading.Event()
claimed_device = None
claimed_sdr_type = 'rtlsdr'
my_generation = None # tracks which capture generation this handler owns
capture_center_mhz = 0.0
capture_start_freq = 0.0
capture_end_freq = 0.0
capture_span_mhz = 0.0
# Queue for outgoing messages — only the main loop touches ws.send()
send_queue = queue.Queue(maxsize=120)
try:
while True:
# Drain send queue first (non-blocking)
while True:
try:
outgoing = send_queue.get_nowait()
except queue.Empty:
break
try:
ws.send(outgoing)
except Exception:
stop_event.set()
break
try:
try:
while True:
# Drain send queue first (non-blocking)
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:
# simple-websocket returns None on timeout AND on
# close; check ws.connected to tell them apart.
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')
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:
# simple-websocket returns None on timeout AND on
# close; check ws.connected to tell them apart.
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':
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
was_restarting = iq_process is not None
stop_event.set()
@@ -394,9 +431,11 @@ def init_waterfall_websocket(app: Flask):
unregister_process(iq_process)
iq_process = 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
_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()
# Flush stale frames from previous capture
while not send_queue.empty():
@@ -411,6 +450,12 @@ def init_waterfall_websocket(app: Flask):
# Parse config
try:
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)
gain_raw = data.get('gain')
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
start_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_err = app_module.claim_sdr_device(device_index, 'waterfall')
# Claim the device (retry when restarting to allow
# 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:
ws.send(json.dumps({
'status': 'error',
@@ -472,6 +528,7 @@ def init_waterfall_websocket(app: Flask):
}))
continue
claimed_device = device_index
claimed_sdr_type = sdr_type_str
# Build I/Q capture command
try:
@@ -485,14 +542,26 @@ def init_waterfall_websocket(app: Flask):
bias_t=bias_t,
)
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_sdr_type = 'rtlsdr'
ws.send(json.dumps({
'status': 'error',
'message': str(e),
}))
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)
max_attempts = 3 if was_restarting else 1
try:
@@ -505,7 +574,7 @@ def init_waterfall_websocket(app: Flask):
iq_process = subprocess.Popen(
iq_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
stderr=subprocess.PIPE,
bufsize=0,
)
register_process(iq_process)
@@ -513,17 +582,23 @@ def init_waterfall_websocket(app: Flask):
# Brief check that process started
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:
logger.info(
f"I/Q process exited immediately, "
f"retrying ({attempt + 1}/{max_attempts})..."
+ (f" stderr: {stderr_out}" if stderr_out else "")
)
time.sleep(0.5)
continue
detail = f": {stderr_out}" if stderr_out else ""
raise RuntimeError(
"I/Q capture process exited immediately"
f"I/Q capture process exited immediately{detail}"
)
break # Process started successfully
except Exception as e:
@@ -532,8 +607,9 @@ def init_waterfall_websocket(app: Flask):
safe_terminate(iq_process)
unregister_process(iq_process)
iq_process = None
app_module.release_sdr_device(device_index)
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'Failed to start I/Q capture: {e}',
@@ -545,7 +621,7 @@ def init_waterfall_websocket(app: Flask):
capture_end_freq = end_freq
capture_span_mhz = effective_span_mhz
_set_shared_capture_state(
my_generation = _set_shared_capture_state(
running=True,
device=device_index,
center_mhz=center_freq_mhz,
@@ -553,10 +629,10 @@ def init_waterfall_websocket(app: Flask):
sample_rate=sample_rate,
)
_set_shared_monitor(
enabled=False,
frequency_mhz=center_freq_mhz,
modulation='wfm',
squelch=0,
enabled=keep_monitor_enabled,
frequency_mhz=target_vfo_mhz,
modulation=keep_monitor_modulation,
squelch=keep_monitor_squelch,
)
# Send started confirmation
@@ -570,7 +646,7 @@ def init_waterfall_websocket(app: Flask):
'effective_span_mhz': effective_span_mhz,
'db_min': db_min,
'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()
@@ -585,6 +661,8 @@ def init_waterfall_websocket(app: Flask):
timeslice_samples = max(required_fft_samples, int(_sample_rate / max(1, _fps)))
bytes_per_frame = timeslice_samples * 2
frame_interval = 1.0 / _fps
monitor_rotator_phase = 0.0
last_monitor_offset_hz = None
try:
while not stop_evt.is_set():
@@ -629,16 +707,30 @@ def init_waterfall_websocket(app: Flask):
monitor_cfg = _snapshot_monitor_config()
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,
sample_rate=_sample_rate,
center_mhz=monitor_cfg.get('center_mhz', _center_mhz),
monitor_freq_mhz=monitor_cfg.get('monitor_freq_mhz', _center_mhz),
center_mhz=center_mhz_cfg,
monitor_freq_mhz=monitor_mhz_cfg,
modulation=monitor_cfg.get('modulation', 'wfm'),
squelch=int(monitor_cfg.get('squelch', 0)),
rotator_phase=monitor_rotator_phase,
)
if audio_chunk:
_push_shared_audio_chunk(audio_chunk)
else:
monitor_rotator_phase = 0.0
last_monitor_offset_hz = None
# Pace to target FPS
elapsed = time.monotonic() - frame_start
@@ -716,33 +808,36 @@ def init_waterfall_websocket(app: Flask):
reader_thread.join(timeout=2)
reader_thread = None
if iq_process:
safe_terminate(iq_process)
unregister_process(iq_process)
iq_process = None
safe_terminate(iq_process)
unregister_process(iq_process)
iq_process = 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
_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()
ws.send(json.dumps({'status': 'stopped'}))
except Exception as e:
logger.info(f"WebSocket waterfall closed: {e}")
except Exception as e:
logger.info(f"WebSocket waterfall closed: {e}")
finally:
# Cleanup
# Cleanup — use generation guard so a stale handler cannot
# clobber shared state owned by a newer WS connection.
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 iq_process:
safe_terminate(iq_process)
unregister_process(iq_process)
if claimed_device is not None:
app_module.release_sdr_device(claimed_device)
_set_shared_capture_state(running=False)
app_module.release_sdr_device(claimed_device, claimed_sdr_type)
_set_shared_capture_state(running=False, generation=my_generation)
# Complete WebSocket close handshake, then shut down the
# raw socket so Werkzeug cannot write its HTTP 200 response
# on top of the WebSocket stream (which browsers see as
# "Invalid frame header").
# on top of the WebSocket stream (which browsers see as
# "Invalid frame header").
with suppress(Exception):
ws.close()
with suppress(Exception):
+57 -35
View File
@@ -18,6 +18,7 @@ from utils.weather_sat import (
is_weather_sat_available,
CaptureProgress,
WEATHER_SATELLITES,
DEFAULT_SAMPLE_RATE,
)
logger = get_logger('intercept.weather_sat')
@@ -40,6 +41,35 @@ def _progress_callback(progress: CaptureProgress) -> None:
pass
def _release_weather_sat_device(device_index: int) -> None:
"""Release an SDR device only if weather-sat currently owns it."""
if device_index < 0:
return
try:
import app as app_module
except ImportError:
return
owner = None
get_status = getattr(app_module, 'get_sdr_device_status', None)
if callable(get_status):
try:
owner = get_status().get(device_index)
except Exception:
owner = None
if owner and owner != 'weather_sat':
logger.debug(
'Skipping SDR release for device %s owned by %s',
device_index,
owner,
)
return
app_module.release_sdr_device(device_index)
@weather_sat_bp.route('/status')
def get_status():
"""Get weather satellite decoder status.
@@ -152,18 +182,15 @@ def start_capture():
decoder.set_callback(_progress_callback)
def _release_device():
try:
import app as app_module
app_module.release_sdr_device(device_index)
except ImportError:
pass
_release_weather_sat_device(device_index)
decoder.set_on_complete(_release_device)
success = decoder.start(
success, error_msg = decoder.start(
satellite=satellite,
device_index=device_index,
gain=gain,
sample_rate=DEFAULT_SAMPLE_RATE,
bias_t=bias_t,
)
@@ -181,7 +208,7 @@ def start_capture():
_release_device()
return jsonify({
'status': 'error',
'message': 'Failed to start capture'
'message': error_msg or 'Failed to start capture'
}), 500
@@ -283,7 +310,7 @@ def test_decode():
decoder.set_callback(_progress_callback)
decoder.set_on_complete(None)
success = decoder.start_from_file(
success, error_msg = decoder.start_from_file(
satellite=satellite,
input_file=input_file,
sample_rate=sample_rate,
@@ -302,7 +329,7 @@ def test_decode():
else:
return jsonify({
'status': 'error',
'message': 'Failed to start file decode'
'message': error_msg or 'Failed to start file decode'
}), 500
@@ -318,12 +345,7 @@ def stop_capture():
decoder.stop()
# Release SDR device
try:
import app as app_module
app_module.release_sdr_device(device_index)
except ImportError:
pass
_release_weather_sat_device(device_index)
return jsonify({'status': 'stopped'})
@@ -563,26 +585,26 @@ def enable_schedule():
'message': 'Invalid parameter value'
}), 400
scheduler = get_weather_sat_scheduler()
scheduler.set_callbacks(_progress_callback, _scheduler_event_callback)
try:
result = scheduler.enable(
lat=lat,
lon=lon,
min_elevation=min_elev,
device=device,
gain=gain_val,
bias_t=bool(data.get('bias_t', False)),
)
except Exception as e:
logger.exception("Failed to enable weather sat scheduler")
return jsonify({
'status': 'error',
'message': 'Failed to enable scheduler'
}), 500
return jsonify({'status': 'ok', **result})
scheduler = get_weather_sat_scheduler()
scheduler.set_callbacks(_progress_callback, _scheduler_event_callback)
try:
result = scheduler.enable(
lat=lat,
lon=lon,
min_elevation=min_elev,
device=device,
gain=gain_val,
bias_t=bool(data.get('bias_t', False)),
)
except Exception as e:
logger.exception("Failed to enable weather sat scheduler")
return jsonify({
'status': 'error',
'message': 'Failed to enable scheduler'
}), 500
return jsonify({'status': 'ok', **result})
@weather_sat_bp.route('/schedule/disable', methods=['POST'])
+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,
})
+78 -2
View File
@@ -229,6 +229,7 @@ check_tools() {
check_optional "dumpvdl2" "VDL2 decoder" dumpvdl2
check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher
check_optional "satdump" "Weather satellite decoder (NOAA/Meteor)" satdump
check_optional "auto_rx.py" "Radiosonde weather balloon decoder" auto_rx.py
echo
info "GPS:"
check_required "gpsd" "GPS daemon" gpsd
@@ -816,6 +817,53 @@ WRAPPER
)
}
install_radiosonde_auto_rx() {
info "Installing radiosonde_auto_rx (weather balloon decoder)..."
local install_dir="/opt/radiosonde_auto_rx"
local project_dir="$(pwd)"
(
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
info "Cloning radiosonde_auto_rx..."
if ! git clone --depth 1 https://github.com/projecthorus/radiosonde_auto_rx.git "$tmp_dir/radiosonde_auto_rx"; then
warn "Failed to clone radiosonde_auto_rx"
exit 1
fi
info "Installing Python dependencies..."
cd "$tmp_dir/radiosonde_auto_rx/auto_rx"
# Use project venv pip to avoid PEP 668 externally-managed-environment errors
if [ -x "$project_dir/venv/bin/pip" ]; then
"$project_dir/venv/bin/pip" install --quiet -r requirements.txt || {
warn "Failed to install radiosonde_auto_rx Python dependencies"
exit 1
}
else
pip3 install --quiet --break-system-packages -r requirements.txt 2>/dev/null \
|| pip3 install --quiet -r requirements.txt || {
warn "Failed to install radiosonde_auto_rx Python dependencies"
exit 1
}
fi
info "Building radiosonde_auto_rx C decoders..."
if ! bash build.sh; then
warn "Failed to build radiosonde_auto_rx decoders"
exit 1
fi
info "Installing to ${install_dir}..."
refresh_sudo
$SUDO mkdir -p "$install_dir/auto_rx"
$SUDO cp -r . "$install_dir/auto_rx/"
$SUDO chmod +x "$install_dir/auto_rx/auto_rx.py"
ok "radiosonde_auto_rx installed to ${install_dir}"
)
}
install_macos_packages() {
need_sudo
@@ -825,7 +873,7 @@ install_macos_packages() {
sudo -v || { fail "sudo authentication failed"; exit 1; }
fi
TOTAL_STEPS=21
TOTAL_STEPS=22
CURRENT_STEP=0
progress "Checking Homebrew"
@@ -912,6 +960,20 @@ install_macos_packages() {
ok "SatDump already installed"
fi
progress "Installing radiosonde_auto_rx (optional)"
if ! cmd_exists auto_rx.py && [ ! -f /opt/radiosonde_auto_rx/auto_rx/auto_rx.py ] \
|| { [ -f /opt/radiosonde_auto_rx/auto_rx/auto_rx.py ] && [ ! -f /opt/radiosonde_auto_rx/auto_rx/dft_detect ]; }; then
echo
info "radiosonde_auto_rx is used for weather balloon (radiosonde) tracking."
if ask_yes_no "Do you want to install radiosonde_auto_rx?"; then
install_radiosonde_auto_rx || warn "radiosonde_auto_rx installation failed. Radiosonde tracking will not be available."
else
warn "Skipping radiosonde_auto_rx. You can install it later if needed."
fi
else
ok "radiosonde_auto_rx already installed"
fi
progress "Installing aircrack-ng"
brew_install aircrack-ng
@@ -1303,7 +1365,7 @@ install_debian_packages() {
export NEEDRESTART_MODE=a
fi
TOTAL_STEPS=27
TOTAL_STEPS=28
CURRENT_STEP=0
progress "Updating APT package lists"
@@ -1485,6 +1547,20 @@ install_debian_packages() {
ok "SatDump already installed"
fi
progress "Installing radiosonde_auto_rx (optional)"
if ! cmd_exists auto_rx.py && [ ! -f /opt/radiosonde_auto_rx/auto_rx/auto_rx.py ] \
|| { [ -f /opt/radiosonde_auto_rx/auto_rx/auto_rx.py ] && [ ! -f /opt/radiosonde_auto_rx/auto_rx/dft_detect ]; }; then
echo
info "radiosonde_auto_rx is used for weather balloon (radiosonde) tracking."
if ask_yes_no "Do you want to install radiosonde_auto_rx?"; then
install_radiosonde_auto_rx || warn "radiosonde_auto_rx installation failed. Radiosonde tracking will not be available."
else
warn "Skipping radiosonde_auto_rx. You can install it later if needed."
fi
else
ok "radiosonde_auto_rx already installed"
fi
progress "Configuring udev rules"
setup_udev_rules_debian
+1 -1
View File
@@ -1246,7 +1246,7 @@ body {
.control-group select {
padding: 4px 8px;
background: rgba(0, 0, 0, 0.3);
background: var(--bg-dark);
border: 1px solid rgba(74, 158, 255, 0.3);
border-radius: 4px;
color: var(--accent-cyan);
+1 -1
View File
@@ -779,7 +779,7 @@ body {
.control-group select {
padding: 4px 8px;
background: rgba(0, 0, 0, 0.3);
background: var(--bg-dark);
border: 1px solid rgba(74, 158, 255, 0.3);
border-radius: 4px;
color: var(--accent-cyan);
+33
View File
@@ -202,10 +202,38 @@ body {
}
.welcome-container {
position: relative;
width: 90%;
max-width: 900px;
z-index: 1;
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 {
@@ -232,6 +260,7 @@ body {
.welcome-logo {
animation: logoPulse 3s ease-in-out infinite;
will-change: filter;
}
@keyframes logoPulse {
@@ -332,6 +361,7 @@ body {
padding: 20px;
max-height: calc(100vh - 300px);
overflow-y: auto;
scrollbar-gutter: stable;
}
.changelog-release {
@@ -1559,6 +1589,7 @@ header h1 .tagline {
overflow: hidden;
padding: 12px;
position: relative;
flex-shrink: 0;
}
.section h3 {
@@ -1744,6 +1775,7 @@ header h1 .tagline {
}
.run-btn {
flex-shrink: 0;
width: 100%;
padding: 12px;
background: var(--accent-green);
@@ -1781,6 +1813,7 @@ header h1 .tagline {
}
.stop-btn {
flex-shrink: 0;
width: 100%;
padding: 12px;
background: var(--accent-red);
+1 -1
View File
@@ -60,7 +60,7 @@
gap: 4px;
}
.aprs-strip .strip-select {
background: rgba(0,0,0,0.3);
background: var(--bg-dark);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 4px 8px;
+50 -1
View File
@@ -151,8 +151,17 @@
overflow: hidden;
}
.gps-sky-globe {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
#gpsSkyCanvas {
display: block;
position: absolute;
inset: 0;
display: none;
width: 100%;
height: 100%;
cursor: grab;
@@ -166,10 +175,50 @@
.gps-sky-overlay {
position: absolute;
inset: 0;
display: none;
pointer-events: none;
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 {
position: absolute;
transform: translate(-50%, -50%);
+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;
}
}
+152
View File
@@ -0,0 +1,152 @@
/* ============================================
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;
}
/* 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);
}
}
+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;
}
}
+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: white;
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;
}
}
+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

+9 -1
View File
@@ -7,6 +7,7 @@ const AlertCenter = (function() {
let rules = [];
let eventSource = null;
let reconnectTimer = null;
let lastConnectionWarningAt = 0;
function init() {
loadRules();
@@ -31,7 +32,14 @@ const AlertCenter = (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);
reconnectTimer = setTimeout(connect, 2500);
};
+13 -1
View File
@@ -92,8 +92,9 @@ const RunState = (function() {
renderHealth(data);
} catch (err) {
renderHealth(null, err);
const transient = isTransientFailure(err);
const now = Date.now();
if (typeof reportActionableError === 'function' && (now - lastErrorToastAt) > 30000) {
if (!transient && typeof reportActionableError === 'function' && (now - lastErrorToastAt) > 30000) {
lastErrorToastAt = now;
reportActionableError('Run State', err, { persistent: false });
}
@@ -214,6 +215,17 @@ const RunState = (function() {
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() {
return lastHealth;
}
+7 -4
View File
@@ -1281,6 +1281,7 @@ function loadVoiceAlertConfig() {
const pager = document.getElementById('voiceCfgPager');
const tscm = document.getElementById('voiceCfgTscm');
const tracker = document.getElementById('voiceCfgTracker');
const military = document.getElementById('voiceCfgAdsbMilitary');
const squawk = document.getElementById('voiceCfgSquawk');
const rate = document.getElementById('voiceCfgRate');
const pitch = document.getElementById('voiceCfgPitch');
@@ -1290,6 +1291,7 @@ function loadVoiceAlertConfig() {
if (pager) pager.checked = cfg.streams.pager !== false;
if (tscm) tscm.checked = cfg.streams.tscm !== 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 (rate) rate.value = cfg.rate;
if (pitch) pitch.value = cfg.pitch;
@@ -1314,10 +1316,11 @@ function saveVoiceAlertConfig() {
pitch: parseFloat(document.getElementById('voiceCfgPitch')?.value) || 0.9,
voiceName: document.getElementById('voiceCfgVoice')?.value || '',
streams: {
pager: !!document.getElementById('voiceCfgPager')?.checked,
tscm: !!document.getElementById('voiceCfgTscm')?.checked,
bluetooth: !!document.getElementById('voiceCfgTracker')?.checked,
squawks: !!document.getElementById('voiceCfgSquawk')?.checked,
pager: !!document.getElementById('voiceCfgPager')?.checked,
tscm: !!document.getElementById('voiceCfgTscm')?.checked,
bluetooth: !!document.getElementById('voiceCfgTracker')?.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;
}
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) {
const text = String(message || '').toLowerCase();
return text.includes('networkerror') || text.includes('failed to fetch') || text.includes('timeout');
return isTransientNetworkError(message);
}
function isSettingsError(message) {
@@ -224,6 +246,9 @@ const AppFeedback = (function() {
reportError,
removeToast,
renderCollectionState,
isOffline,
isTransientNetworkError,
isTransientOrOffline,
};
})();
@@ -243,6 +268,18 @@ window.renderCollectionState = function(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', () => {
AppFeedback.init();
});
+7 -1
View File
@@ -20,7 +20,13 @@ const VoiceAlerts = (function () {
rate: 1.1,
pitch: 0.9,
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) {
File diff suppressed because it is too large Load Diff
+35
View File
@@ -1909,7 +1909,42 @@ const BtLocate = (function() {
handleDetection,
invalidateMap,
fetchPairedIrks,
destroy,
};
/**
* Destroy close SSE stream and clear all timers for clean mode switching.
*/
function destroy() {
if (eventSource) {
eventSource.close();
eventSource = null;
}
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
if (durationTimer) {
clearInterval(durationTimer);
durationTimer = null;
}
if (mapStabilizeTimer) {
clearInterval(mapStabilizeTimer);
mapStabilizeTimer = null;
}
if (queuedDetectionTimer) {
clearTimeout(queuedDetectionTimer);
queuedDetectionTimer = null;
}
if (crosshairResetTimer) {
clearTimeout(crosshairResetTimer);
crosshairResetTimer = null;
}
if (beepTimer) {
clearInterval(beepTimer);
beepTimer = null;
}
}
})();
window.BtLocate = BtLocate;
+530 -68
View File
@@ -9,22 +9,45 @@ const GPS = (function() {
let lastPosition = null;
let lastSky = null;
let skyPollTimer = null;
let statusPollTimer = null;
let themeObserver = null;
let skyRenderer = null;
let skyRendererInitAttempted = false;
// Constellation color map
const CONST_COLORS = {
'GPS': '#00d4ff',
'GLONASS': '#00ff88',
let skyRendererInitPromise = null;
// Constellation color map
const CONST_COLORS = {
'GPS': '#00d4ff',
'GLONASS': '#00ff88',
'Galileo': '#ff8800',
'BeiDou': '#ff4466',
'SBAS': '#ffdd00',
'QZSS': '#cc66ff',
};
'SBAS': '#ffdd00',
'QZSS': '#cc66ff',
};
const CONST_ALTITUDES = {
'GPS': 0.28,
'GLONASS': 0.27,
'Galileo': 0.29,
'BeiDou': 0.30,
'SBAS': 0.34,
'QZSS': 0.31,
};
const GPS_GLOBE_SCRIPT_URLS = [
'https://cdn.jsdelivr.net/npm/globe.gl@2.33.1/dist/globe.gl.min.js',
];
const GPS_GLOBE_TEXTURE_URL = '/static/images/globe/earth-dark.jpg';
const GPS_SATELLITE_ICON_URL = '/static/images/globe/satellite-icon.svg';
function init() {
initSkyRenderer();
const initPromise = initSkyRenderer();
if (initPromise && typeof initPromise.then === 'function') {
initPromise.then(() => {
if (lastSky) drawSkyView(lastSky.satellites || []);
else drawEmptySkyView();
}).catch(() => {});
}
drawEmptySkyView();
if (!connected) connect();
@@ -48,26 +71,397 @@ const GPS = (function() {
}
function initSkyRenderer() {
if (skyRendererInitAttempted) return;
if (skyRendererInitPromise) return skyRendererInitPromise;
skyRendererInitAttempted = true;
const canvas = document.getElementById('gpsSkyCanvas');
if (!canvas) return;
let fallbackRenderer = null;
const fallbackCanvas = document.getElementById('gpsSkyCanvas');
const fallbackOverlay = document.getElementById('gpsSkyOverlay');
const overlay = document.getElementById('gpsSkyOverlay');
try {
skyRenderer = createWebGlSkyRenderer(canvas, overlay);
} catch (err) {
skyRenderer = null;
console.warn('GPS sky WebGL renderer failed, falling back to 2D', err);
// Show an immediate fallback while the globe library loads.
setSkyCanvasFallbackMode(true);
if (fallbackCanvas) {
try {
fallbackRenderer = createWebGlSkyRenderer(fallbackCanvas, fallbackOverlay);
skyRenderer = fallbackRenderer;
} catch (err) {
fallbackRenderer = null;
skyRenderer = null;
console.warn('GPS sky WebGL renderer failed, falling back to 2D', err);
}
}
skyRendererInitPromise = (async function() {
const globeContainer = document.getElementById('gpsSkyGlobe');
if (globeContainer) {
try {
const globeRenderer = await createGlobeSkyRenderer(globeContainer);
if (globeRenderer) {
if (fallbackRenderer && fallbackRenderer !== globeRenderer && typeof fallbackRenderer.destroy === 'function') {
fallbackRenderer.destroy();
}
setSkyCanvasFallbackMode(false);
skyRenderer = globeRenderer;
return skyRenderer;
}
} catch (err) {
console.warn('GPS globe renderer failed, falling back to canvas renderer', err);
}
}
setSkyCanvasFallbackMode(true);
if (!fallbackRenderer && fallbackCanvas) {
try {
fallbackRenderer = createWebGlSkyRenderer(fallbackCanvas, fallbackOverlay);
} catch (err) {
fallbackRenderer = null;
console.warn('GPS sky WebGL renderer failed, falling back to 2D', err);
}
}
skyRenderer = fallbackRenderer;
return skyRenderer;
})();
return skyRendererInitPromise;
}
function setSkyCanvasFallbackMode(enabled) {
const wrap = document.getElementById('gpsSkyViewWrap');
if (wrap) {
wrap.classList.toggle('gps-sky-fallback', !!enabled);
}
}
function connect() {
updateConnectionUI(false, false, 'connecting');
fetch('/gps/auto-connect', { method: 'POST' })
.then(r => r.json())
.then(data => {
function isSkyCanvasFallbackEnabled() {
const wrap = document.getElementById('gpsSkyViewWrap');
return !wrap || wrap.classList.contains('gps-sky-fallback');
}
function getObserverCoords() {
const posLat = Number(lastPosition && lastPosition.latitude);
const posLon = Number(lastPosition && lastPosition.longitude);
if (Number.isFinite(posLat) && Number.isFinite(posLon)) {
return { lat: posLat, lon: normalizeLon(posLon) };
}
if (typeof observerLocation === 'object' && observerLocation) {
const obsLat = Number(observerLocation.lat);
const obsLon = Number(observerLocation.lon);
if (Number.isFinite(obsLat) && Number.isFinite(obsLon)) {
return { lat: obsLat, lon: normalizeLon(obsLon) };
}
}
return null;
}
async function ensureGpsGlobeLibrary() {
if (typeof window.Globe === 'function') return true;
const webglSupportFn = (typeof isWebglSupported === 'function') ? isWebglSupported : localWebglSupportCheck;
if (!webglSupportFn()) return false;
if (typeof ensureWebsdrGlobeLibrary === 'function') {
try {
const ready = await ensureWebsdrGlobeLibrary();
if (ready && typeof window.Globe === 'function') return true;
} catch (_) {}
}
for (const src of GPS_GLOBE_SCRIPT_URLS) {
await loadGpsGlobeScript(src);
}
return typeof window.Globe === 'function';
}
function loadGpsGlobeScript(src) {
const state = getSharedGlobeScriptState();
if (!state.promises[src]) {
state.promises[src] = loadSharedGlobeScript(src);
}
return state.promises[src].catch((error) => {
delete state.promises[src];
throw error;
});
}
function getSharedGlobeScriptState() {
const key = '__interceptGlobeScriptState';
if (!window[key]) {
window[key] = {
promises: Object.create(null),
};
}
return window[key];
}
function loadSharedGlobeScript(src) {
return new Promise((resolve, reject) => {
const selector = [
`script[data-intercept-globe-src="${src}"]`,
`script[data-websdr-src="${src}"]`,
`script[data-gps-globe-src="${src}"]`,
`script[src="${src}"]`,
].join(', ');
const existing = document.querySelector(selector);
if (existing) {
if (existing.dataset.loaded === 'true') {
resolve();
return;
}
if (existing.dataset.failed === 'true') {
existing.remove();
} else {
existing.addEventListener('load', () => resolve(), { once: true });
existing.addEventListener('error', () => reject(new Error(`Failed to load ${src}`)), { once: true });
return;
}
}
const script = document.createElement('script');
script.src = src;
script.async = true;
script.crossOrigin = 'anonymous';
script.dataset.interceptGlobeSrc = src;
script.dataset.gpsGlobeSrc = src;
script.onload = () => {
script.dataset.loaded = 'true';
resolve();
};
script.onerror = () => {
script.dataset.failed = 'true';
reject(new Error(`Failed to load ${src}`));
};
document.head.appendChild(script);
});
}
function localWebglSupportCheck() {
try {
const canvas = document.createElement('canvas');
return !!(canvas.getContext('webgl') || canvas.getContext('experimental-webgl'));
} catch (_) {
return false;
}
}
async function createGlobeSkyRenderer(container) {
const ready = await ensureGpsGlobeLibrary();
if (!ready || typeof window.Globe !== 'function') return null;
let layoutAttempts = 0;
while ((!container.clientWidth || !container.clientHeight) && layoutAttempts < 4) {
await new Promise(resolve => requestAnimationFrame(resolve));
layoutAttempts += 1;
}
if (!container.clientWidth || !container.clientHeight) return null;
container.innerHTML = '';
container.style.background = 'radial-gradient(circle at 32% 18%, rgba(16, 45, 70, 0.92), rgba(4, 9, 16, 0.96) 58%, rgba(2, 4, 9, 0.99) 100%)';
container.style.cursor = 'grab';
const globe = window.Globe()(container)
.backgroundColor('rgba(0,0,0,0)')
.globeImageUrl(GPS_GLOBE_TEXTURE_URL)
.showAtmosphere(true)
.atmosphereColor('#3bb9ff')
.atmosphereAltitude(0.17)
.pointRadius('radius')
.pointAltitude('altitude')
.pointColor('color')
.pointLabel(point => point.label || '')
.pointsTransitionDuration(0)
.htmlAltitude('altitude')
.htmlElementsData([])
.htmlElement((sat) => createSatelliteIconElement(sat));
const controls = globe.controls();
if (controls) {
controls.autoRotate = false;
controls.enablePan = false;
controls.minDistance = 130;
controls.maxDistance = 420;
controls.rotateSpeed = 0.8;
controls.zoomSpeed = 0.8;
}
let destroyed = false;
let lastSatellites = [];
let hasInitialView = false;
const resizeObserver = (typeof ResizeObserver !== 'undefined')
? new ResizeObserver(() => resizeGlobe())
: null;
if (resizeObserver) resizeObserver.observe(container);
function resizeGlobe() {
if (destroyed) return;
const width = container.clientWidth;
const height = container.clientHeight;
if (!width || !height) return;
globe.width(width);
globe.height(height);
}
function renderGlobe() {
if (destroyed) return;
resizeGlobe();
const observer = getObserverCoords();
const points = [];
const satelliteIcons = [];
if (observer) {
points.push({
lat: observer.lat,
lng: observer.lon,
altitude: 0.012,
radius: 0.34,
color: '#ffffff',
label: '<div style="padding:4px 6px; font-size:11px; background:rgba(5,13,20,0.92); border:1px solid rgba(255,255,255,0.28); border-radius:4px;">Observer</div>',
});
}
lastSatellites.forEach((sat) => {
const azimuth = Number(sat.azimuth);
const elevation = Number(sat.elevation);
if (!observer || !Number.isFinite(azimuth) || !Number.isFinite(elevation)) return;
const color = CONST_COLORS[sat.constellation] || CONST_COLORS.GPS;
const shellAltitude = getSatelliteShellAltitude(sat.constellation, elevation);
const footprint = projectSkyTrackToEarth(observer.lat, observer.lon, azimuth, elevation);
satelliteIcons.push({
lat: footprint.lat,
lng: footprint.lon,
altitude: shellAltitude,
color: color,
used: !!sat.used,
sizePx: sat.used ? 20 : 17,
title: buildSatelliteTitle(sat),
iconUrl: GPS_SATELLITE_ICON_URL,
});
});
globe.pointsData(points);
globe.htmlElementsData(satelliteIcons);
if (observer && !hasInitialView) {
globe.pointOfView({ lat: observer.lat, lng: observer.lon, altitude: 1.6 }, 950);
hasInitialView = true;
}
}
function createSatelliteIconElement(sat) {
const marker = document.createElement('div');
marker.className = `gps-globe-sat-icon ${sat.used ? 'used' : 'unused'}`;
marker.style.setProperty('--sat-color', sat.color || '#9fb2c5');
marker.style.setProperty('--sat-size', `${Math.max(12, Number(sat.sizePx) || 18)}px`);
marker.title = sat.title || 'Satellite';
const img = document.createElement('img');
img.src = sat.iconUrl || GPS_SATELLITE_ICON_URL;
img.alt = 'Satellite';
img.decoding = 'async';
img.draggable = false;
marker.appendChild(img);
return marker;
}
function setSatellites(satellites) {
lastSatellites = Array.isArray(satellites) ? satellites : [];
renderGlobe();
}
function requestRender() {
renderGlobe();
}
function destroy() {
destroyed = true;
if (resizeObserver) {
try {
resizeObserver.disconnect();
} catch (_) {}
}
container.innerHTML = '';
}
setSatellites([]);
return {
setSatellites: setSatellites,
requestRender: requestRender,
destroy: destroy,
};
}
function buildSatelliteTitle(sat) {
const constellation = String(sat.constellation || 'GPS');
const prn = String(sat.prn || '--');
const elevation = Number.isFinite(Number(sat.elevation)) ? `${Number(sat.elevation).toFixed(1)}\u00b0` : '--';
const azimuth = Number.isFinite(Number(sat.azimuth)) ? `${Number(sat.azimuth).toFixed(1)}\u00b0` : '--';
const snr = Number.isFinite(Number(sat.snr)) ? `${Math.round(Number(sat.snr))} dB-Hz` : 'n/a';
const used = sat.used ? 'USED IN FIX' : 'TRACKED';
return `${constellation} PRN ${prn} | El ${elevation} | Az ${azimuth} | SNR ${snr} | ${used}`;
}
function getSatelliteShellAltitude(constellation, elevation) {
const base = CONST_ALTITUDES[constellation] || CONST_ALTITUDES.GPS;
const el = Math.max(0, Math.min(90, Number(elevation) || 0));
const horizonFactor = 1 - (el / 90);
return base + (horizonFactor * 0.04);
}
function projectSkyTrackToEarth(observerLat, observerLon, azimuth, elevation) {
const el = Math.max(0, Math.min(90, Number(elevation) || 0));
const horizonFactor = 1 - (el / 90);
const angularDistance = 76 * Math.pow(horizonFactor, 1.08);
return destinationPoint(observerLat, observerLon, azimuth, angularDistance);
}
function destinationPoint(latDeg, lonDeg, bearingDeg, distanceDeg) {
const lat1 = degToRad(latDeg);
const lon1 = degToRad(lonDeg);
const bearing = degToRad(bearingDeg);
const distance = degToRad(distanceDeg);
const sinLat1 = Math.sin(lat1);
const cosLat1 = Math.cos(lat1);
const sinDist = Math.sin(distance);
const cosDist = Math.cos(distance);
const sinLat2 = (sinLat1 * cosDist) + (cosLat1 * sinDist * Math.cos(bearing));
const lat2 = Math.asin(Math.max(-1, Math.min(1, sinLat2)));
const y = Math.sin(bearing) * sinDist * cosLat1;
const x = cosDist - (sinLat1 * Math.sin(lat2));
const lon2 = lon1 + Math.atan2(y, x);
return {
lat: radToDeg(lat2),
lon: normalizeLon(radToDeg(lon2)),
};
}
function normalizeLon(lon) {
let normalized = (lon + 540) % 360;
normalized = normalized < 0 ? normalized + 360 : normalized;
return normalized - 180;
}
function radToDeg(rad) {
return rad * 180 / Math.PI;
}
function connect() {
updateConnectionUI(false, false, 'connecting');
fetch('/gps/auto-connect', { method: 'POST' })
.then(r => r.json())
.then(data => {
if (data.status === 'connected') {
connected = true;
updateConnectionUI(true, data.has_fix);
@@ -78,16 +472,18 @@ const GPS = (function() {
if (data.sky) {
lastSky = data.sky;
updateSkyUI(data.sky);
}
subscribeToStream();
startSkyPolling();
// Ensure the global GPS stream is running
if (typeof startGpsStream === 'function' && !gpsEventSource) {
startGpsStream();
}
} else {
connected = false;
updateConnectionUI(false, false, 'error', data.message || 'gpsd not available');
}
subscribeToStream();
startSkyPolling();
startStatusPolling();
// Ensure the global GPS stream is running
const hasGlobalGpsStream = typeof gpsEventSource !== 'undefined' && !!gpsEventSource;
if (typeof startGpsStream === 'function' && !hasGlobalGpsStream) {
startGpsStream();
}
} else {
connected = false;
updateConnectionUI(false, false, 'error', data.message || 'gpsd not available');
}
})
.catch(() => {
@@ -96,36 +492,40 @@ const GPS = (function() {
});
}
function disconnect() {
unsubscribeFromStream();
stopSkyPolling();
fetch('/gps/stop', { method: 'POST' })
.then(() => {
connected = false;
updateConnectionUI(false);
});
function disconnect() {
unsubscribeFromStream();
stopSkyPolling();
stopStatusPolling();
fetch('/gps/stop', { method: 'POST' })
.then(() => {
connected = false;
updateConnectionUI(false);
});
}
function onGpsStreamData(data) {
if (!connected) return;
if (data.type === 'position') {
lastPosition = data;
updatePositionUI(data);
updateConnectionUI(true, true);
} else if (data.type === 'sky') {
lastSky = data;
updateSkyUI(data);
}
}
function startSkyPolling() {
stopSkyPolling();
// Poll satellite data every 5 seconds as a reliable fallback
// SSE stream may miss sky updates due to queue contention with position messages
pollSatellites();
skyPollTimer = setInterval(pollSatellites, 5000);
}
if (data.type === 'position') {
lastPosition = data;
updatePositionUI(data);
updateConnectionUI(true, true);
if (lastSky && skyRenderer) {
drawSkyView(lastSky.satellites || []);
}
} else if (data.type === 'sky') {
lastSky = data;
updateSkyUI(data);
}
}
function startSkyPolling() {
stopSkyPolling();
// Poll satellite data every 5 seconds as a reliable fallback
// SSE stream may miss sky updates due to queue contention with position messages
pollSatellites();
skyPollTimer = setInterval(pollSatellites, 5000);
}
function stopSkyPolling() {
if (skyPollTimer) {
clearInterval(skyPollTimer);
@@ -133,18 +533,62 @@ const GPS = (function() {
}
}
function pollSatellites() {
if (!connected) return;
fetch('/gps/satellites')
.then(r => r.json())
.then(data => {
function pollSatellites() {
if (!connected) return;
fetch('/gps/satellites')
.then(r => r.json())
.then(data => {
if (data.status === 'ok' && data.sky) {
lastSky = data.sky;
updateSkyUI(data.sky);
}
})
.catch(() => {});
}
})
.catch(() => {});
}
function startStatusPolling() {
stopStatusPolling();
// Poll full status as a fallback when SSE is unavailable or blocked.
pollStatus();
statusPollTimer = setInterval(pollStatus, 2000);
}
function stopStatusPolling() {
if (statusPollTimer) {
clearInterval(statusPollTimer);
statusPollTimer = null;
}
}
function pollStatus() {
if (!connected) return;
fetch('/gps/status')
.then(r => r.json())
.then(data => {
if (!connected || !data) return;
if (data.running !== true) {
connected = false;
stopSkyPolling();
stopStatusPolling();
updateConnectionUI(false, false, 'error', data.message || 'GPS disconnected');
return;
}
if (data.position) {
lastPosition = data.position;
updatePositionUI(data.position);
updateConnectionUI(true, true);
} else {
updateConnectionUI(true, false);
}
if (data.sky) {
lastSky = data.sky;
updateSkyUI(data.sky);
}
})
.catch(() => {});
}
function subscribeToStream() {
// Subscribe to the global GPS stream instead of opening a separate SSE connection
@@ -294,8 +738,11 @@ const GPS = (function() {
return;
}
if (!isSkyCanvasFallbackEnabled()) return;
const canvas = document.getElementById('gpsSkyCanvas');
if (!canvas) return;
resize2DFallbackCanvas(canvas);
drawSkyViewBase2D(canvas);
}
@@ -311,9 +758,12 @@ const GPS = (function() {
return;
}
if (!isSkyCanvasFallbackEnabled()) return;
const canvas = document.getElementById('gpsSkyCanvas');
if (!canvas) return;
resize2DFallbackCanvas(canvas);
drawSkyViewBase2D(canvas);
const ctx = canvas.getContext('2d');
@@ -428,6 +878,15 @@ const GPS = (function() {
ctx.fill();
}
function resize2DFallbackCanvas(canvas) {
const cssWidth = Math.max(1, Math.floor(canvas.clientWidth || 400));
const cssHeight = Math.max(1, Math.floor(canvas.clientHeight || 400));
if (canvas.width !== cssWidth || canvas.height !== cssHeight) {
canvas.width = cssWidth;
canvas.height = cssHeight;
}
}
function createWebGlSkyRenderer(canvas, overlay) {
const gl = canvas.getContext('webgl', { antialias: true, alpha: false, depth: true });
if (!gl) return null;
@@ -1076,6 +1535,7 @@ const GPS = (function() {
function destroy() {
unsubscribeFromStream();
stopSkyPolling();
stopStatusPolling();
if (themeObserver) {
themeObserver.disconnect();
themeObserver = null;
@@ -1085,6 +1545,8 @@ const GPS = (function() {
skyRenderer = null;
}
skyRendererInitAttempted = false;
skyRendererInitPromise = null;
setSkyCanvasFallbackMode(false);
}
return {
+20 -12
View File
@@ -117,13 +117,13 @@ const Meshtastic = (function() {
Settings.createTileLayer().addTo(meshMap);
Settings.registerMap(meshMap);
} else {
L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
maxZoom: 19,
subdomains: 'abcd',
className: 'tile-layer-cyan'
}).addTo(meshMap);
}
L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
maxZoom: 19,
subdomains: 'abcd',
className: 'tile-layer-cyan'
}).addTo(meshMap);
}
// Handle resize
setTimeout(() => {
@@ -401,10 +401,10 @@ const Meshtastic = (function() {
// Position is nested in the response
const pos = info.position;
if (pos && pos.latitude !== undefined && pos.latitude !== null && pos.longitude !== undefined && pos.longitude !== null) {
if (posRow) posRow.style.display = 'flex';
if (posEl) posEl.textContent = `${pos.latitude.toFixed(5)}, ${pos.longitude.toFixed(5)}`;
} else {
if (pos && pos.latitude !== undefined && pos.latitude !== null && pos.longitude !== undefined && pos.longitude !== null) {
if (posRow) posRow.style.display = 'flex';
if (posEl) posEl.textContent = `${pos.latitude.toFixed(5)}, ${pos.longitude.toFixed(5)}`;
} else {
if (posRow) posRow.style.display = 'none';
}
}
@@ -2295,7 +2295,8 @@ const Meshtastic = (function() {
// Store & Forward
showStoreForwardModal,
requestStoreForward,
closeStoreForwardModal
closeStoreForwardModal,
destroy
};
/**
@@ -2306,6 +2307,13 @@ const Meshtastic = (function() {
setTimeout(() => meshMap.invalidateSize(), 100);
}
}
/**
* Destroy tear down SSE, timers, and event listeners for clean mode switching.
*/
function destroy() {
stopStream();
}
})();
// Initialize when DOM is ready (will be called by selectMode)
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -515,6 +515,13 @@ const SpyStations = (function() {
}
}
/**
* Destroy no-op placeholder for consistent lifecycle interface.
*/
function destroy() {
// SpyStations has no background timers or streams to clean up.
}
// Public API
return {
init,
@@ -524,7 +531,8 @@ const SpyStations = (function() {
showDetails,
closeDetails,
showHelp,
closeHelp
closeHelp,
destroy
};
})();
+9 -1
View File
@@ -858,6 +858,13 @@ const SSTVGeneral = (function() {
}
}
/**
* Destroy close SSE stream and stop scope animation for clean mode switching.
*/
function destroy() {
stopStream();
}
// Public API
return {
init,
@@ -869,6 +876,7 @@ const SSTVGeneral = (function() {
deleteImage,
deleteAllImages,
downloadImage,
selectPreset
selectPreset,
destroy
};
})();
+99 -86
View File
@@ -12,12 +12,12 @@ const SSTV = (function() {
let progress = 0;
let issMap = null;
let issMarker = null;
let issTrackLine = null;
let issPosition = null;
let issUpdateInterval = null;
let countdownInterval = null;
let nextPassData = null;
let pendingMapInvalidate = false;
let issTrackLine = null;
let issPosition = null;
let issUpdateInterval = null;
let countdownInterval = null;
let nextPassData = null;
let pendingMapInvalidate = false;
// ISS frequency
const ISS_FREQ = 145.800;
@@ -38,31 +38,31 @@ const SSTV = (function() {
/**
* Initialize the SSTV mode
*/
function init() {
checkStatus();
loadImages();
loadLocationInputs();
loadIssSchedule();
initMap();
startIssTracking();
startCountdown();
// Ensure Leaflet recomputes dimensions after the SSTV pane becomes visible.
setTimeout(() => invalidateMap(), 80);
setTimeout(() => invalidateMap(), 260);
}
function isMapContainerVisible() {
if (!issMap || typeof issMap.getContainer !== 'function') return false;
const container = issMap.getContainer();
if (!container) return false;
if (container.offsetWidth <= 0 || container.offsetHeight <= 0) return false;
if (container.style && container.style.display === 'none') return false;
if (typeof window.getComputedStyle === 'function') {
const style = window.getComputedStyle(container);
if (style.display === 'none' || style.visibility === 'hidden') return false;
}
return true;
}
function init() {
checkStatus();
loadImages();
loadLocationInputs();
loadIssSchedule();
initMap();
startIssTracking();
startCountdown();
// Ensure Leaflet recomputes dimensions after the SSTV pane becomes visible.
setTimeout(() => invalidateMap(), 80);
setTimeout(() => invalidateMap(), 260);
}
function isMapContainerVisible() {
if (!issMap || typeof issMap.getContainer !== 'function') return false;
const container = issMap.getContainer();
if (!container) return false;
if (container.offsetWidth <= 0 || container.offsetHeight <= 0) return false;
if (container.style && container.style.display === 'none') return false;
if (typeof window.getComputedStyle === 'function') {
const style = window.getComputedStyle(container);
if (style.display === 'none' || style.visibility === 'hidden') return false;
}
return true;
}
/**
* Load location into input fields
@@ -189,9 +189,9 @@ const SSTV = (function() {
/**
* Initialize Leaflet map for ISS tracking
*/
async function initMap() {
const mapContainer = document.getElementById('sstvIssMap');
if (!mapContainer || issMap) return;
async function initMap() {
const mapContainer = document.getElementById('sstvIssMap');
if (!mapContainer || issMap) return;
// Create map
issMap = L.map('sstvIssMap', {
@@ -231,21 +231,21 @@ const SSTV = (function() {
issMarker = L.marker([0, 0], { icon: issIcon }).addTo(issMap);
// Create ground track line
issTrackLine = L.polyline([], {
color: '#00d4ff',
weight: 2,
opacity: 0.6,
dashArray: '5, 5'
}).addTo(issMap);
issMap.on('resize moveend zoomend', () => {
if (pendingMapInvalidate) invalidateMap();
});
// Initial layout passes for first-time mode load.
setTimeout(() => invalidateMap(), 40);
setTimeout(() => invalidateMap(), 180);
}
issTrackLine = L.polyline([], {
color: '#00d4ff',
weight: 2,
opacity: 0.6,
dashArray: '5, 5'
}).addTo(issMap);
issMap.on('resize moveend zoomend', () => {
if (pendingMapInvalidate) invalidateMap();
});
// Initial layout passes for first-time mode load.
setTimeout(() => invalidateMap(), 40);
setTimeout(() => invalidateMap(), 180);
}
/**
* Start ISS position tracking
@@ -454,9 +454,9 @@ const SSTV = (function() {
/**
* Update map with ISS position
*/
function updateMap() {
if (!issMap || !issPosition) return;
if (pendingMapInvalidate) invalidateMap();
function updateMap() {
if (!issMap || !issPosition) return;
if (pendingMapInvalidate) invalidateMap();
const lat = issPosition.lat;
const lon = issPosition.lon;
@@ -516,13 +516,13 @@ const SSTV = (function() {
issTrackLine.setLatLngs(segments.length > 0 ? segments : []);
}
// Pan map to follow ISS only when the map pane is currently renderable.
if (isMapContainerVisible()) {
issMap.panTo([lat, lon], { animate: true, duration: 0.5 });
} else {
pendingMapInvalidate = true;
}
}
// Pan map to follow ISS only when the map pane is currently renderable.
if (isMapContainerVisible()) {
issMap.panTo([lat, lon], { animate: true, duration: 0.5 });
} else {
pendingMapInvalidate = true;
}
}
/**
* Check current decoder status
@@ -1335,27 +1335,27 @@ const SSTV = (function() {
/**
* Show status message
*/
function showStatusMessage(message, type) {
if (typeof showNotification === 'function') {
showNotification('SSTV', message);
} else {
console.log(`[SSTV ${type}] ${message}`);
}
}
/**
* Invalidate ISS map size after pane/layout changes.
*/
function invalidateMap() {
if (!issMap) return false;
if (!isMapContainerVisible()) {
pendingMapInvalidate = true;
return false;
}
issMap.invalidateSize({ pan: false, animate: false });
pendingMapInvalidate = false;
return true;
}
function showStatusMessage(message, type) {
if (typeof showNotification === 'function') {
showNotification('SSTV', message);
} else {
console.log(`[SSTV ${type}] ${message}`);
}
}
/**
* Invalidate ISS map size after pane/layout changes.
*/
function invalidateMap() {
if (!issMap) return false;
if (!isMapContainerVisible()) {
pendingMapInvalidate = true;
return false;
}
issMap.invalidateSize({ pan: false, animate: false });
pendingMapInvalidate = false;
return true;
}
// Public API
return {
@@ -1370,12 +1370,25 @@ const SSTV = (function() {
deleteAllImages,
downloadImage,
useGPS,
updateTLE,
stopIssTracking,
stopCountdown,
invalidateMap
};
})();
updateTLE,
stopIssTracking,
stopCountdown,
invalidateMap,
destroy
};
/**
* Destroy close SSE stream and clear ISS tracking/countdown timers for clean mode switching.
*/
function destroy() {
if (eventSource) {
eventSource.close();
eventSource = null;
}
stopIssTracking();
stopCountdown();
}
})();
// Initialize when DOM is ready (will be called by selectMode)
document.addEventListener('DOMContentLoaded', function() {
+904
View File
@@ -0,0 +1,904 @@
/**
* System Health Enhanced Dashboard IIFE module
*
* Streams real-time system metrics via SSE with rich visualizations:
* SVG arc gauge, per-core bars, temperature sparkline, network bandwidth,
* disk I/O, 3D globe, weather, and process grid.
*/
const SystemHealth = (function () {
'use strict';
let eventSource = null;
let connected = false;
let lastMetrics = null;
// Temperature sparkline ring buffer (last 20 readings)
const SPARKLINE_SIZE = 20;
let tempHistory = [];
// Network I/O delta tracking
let prevNetIo = null;
let prevNetTimestamp = null;
// Disk I/O delta tracking
let prevDiskIo = null;
let prevDiskTimestamp = null;
// Location & weather state
let locationData = null;
let weatherData = null;
let weatherTimer = null;
let globeInstance = null;
let globeDestroyed = false;
const GLOBE_SCRIPT_URL = 'https://cdn.jsdelivr.net/npm/globe.gl@2.33.1/dist/globe.gl.min.js';
const GLOBE_TEXTURE_URL = '/static/images/globe/earth-dark.jpg';
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
function formatBytes(bytes) {
if (bytes == null) return '--';
var units = ['B', 'KB', 'MB', 'GB', 'TB'];
var i = 0;
var val = bytes;
while (val >= 1024 && i < units.length - 1) { val /= 1024; i++; }
return val.toFixed(1) + ' ' + units[i];
}
function formatRate(bytesPerSec) {
if (bytesPerSec == null) return '--';
return formatBytes(bytesPerSec) + '/s';
}
function barClass(pct) {
if (pct >= 85) return 'crit';
if (pct >= 60) return 'warn';
return 'ok';
}
function barHtml(pct, label) {
if (pct == null) return '<span class="sys-metric-na">N/A</span>';
var cls = barClass(pct);
var rounded = Math.round(pct);
return '<div class="sys-metric-bar-wrap">' +
(label ? '<span class="sys-metric-bar-label">' + label + '</span>' : '') +
'<div class="sys-metric-bar"><div class="sys-metric-bar-fill ' + cls + '" style="width:' + rounded + '%"></div></div>' +
'<span class="sys-metric-bar-value">' + rounded + '%</span>' +
'</div>';
}
function escHtml(s) {
var d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
// -----------------------------------------------------------------------
// SVG Arc Gauge
// -----------------------------------------------------------------------
function arcGaugeSvg(pct) {
var radius = 36;
var cx = 45, cy = 45;
var startAngle = -225;
var endAngle = 45;
var totalAngle = endAngle - startAngle; // 270 degrees
var fillAngle = startAngle + (totalAngle * Math.min(pct, 100) / 100);
function polarToCart(angle) {
var r = angle * Math.PI / 180;
return { x: cx + radius * Math.cos(r), y: cy + radius * Math.sin(r) };
}
var bgStart = polarToCart(startAngle);
var bgEnd = polarToCart(endAngle);
var fillEnd = polarToCart(fillAngle);
var largeArcBg = totalAngle > 180 ? 1 : 0;
var fillArc = (fillAngle - startAngle) > 180 ? 1 : 0;
var cls = barClass(pct);
return '<svg viewBox="0 0 90 90">' +
'<path class="arc-bg" d="M ' + bgStart.x + ' ' + bgStart.y +
' A ' + radius + ' ' + radius + ' 0 ' + largeArcBg + ' 1 ' + bgEnd.x + ' ' + bgEnd.y + '"/>' +
'<path class="arc-fill ' + cls + '" d="M ' + bgStart.x + ' ' + bgStart.y +
' A ' + radius + ' ' + radius + ' 0 ' + fillArc + ' 1 ' + fillEnd.x + ' ' + fillEnd.y + '"/>' +
'</svg>';
}
// -----------------------------------------------------------------------
// Temperature Sparkline
// -----------------------------------------------------------------------
function sparklineSvg(values) {
if (!values || values.length < 2) return '';
var w = 200, h = 40;
var min = Math.min.apply(null, values);
var max = Math.max.apply(null, values);
var range = max - min || 1;
var step = w / (values.length - 1);
var points = values.map(function (v, i) {
var x = Math.round(i * step);
var y = Math.round(h - ((v - min) / range) * (h - 4) - 2);
return x + ',' + y;
});
var areaPoints = points.join(' ') + ' ' + w + ',' + h + ' 0,' + h;
return '<svg viewBox="0 0 ' + w + ' ' + h + '" preserveAspectRatio="none">' +
'<defs><linearGradient id="sparkGradient" x1="0" y1="0" x2="0" y2="1">' +
'<stop offset="0%" stop-color="var(--accent-cyan, #00d4ff)" stop-opacity="0.3"/>' +
'<stop offset="100%" stop-color="var(--accent-cyan, #00d4ff)" stop-opacity="0.0"/>' +
'</linearGradient></defs>' +
'<polygon class="sys-sparkline-area" points="' + areaPoints + '"/>' +
'<polyline class="sys-sparkline-line" points="' + points.join(' ') + '"/>' +
'</svg>';
}
// -----------------------------------------------------------------------
// Rendering — CPU Card
// -----------------------------------------------------------------------
function renderCpuCard(m) {
var el = document.getElementById('sysCardCpu');
if (!el) return;
var cpu = m.cpu;
if (!cpu) { el.innerHTML = '<div class="sys-card-body"><span class="sys-metric-na">psutil not available</span></div>'; return; }
var pct = Math.round(cpu.percent);
var coreHtml = '';
if (cpu.per_core && cpu.per_core.length) {
coreHtml = '<div class="sys-core-bars">';
cpu.per_core.forEach(function (c) {
var cls = barClass(c);
var h = Math.max(3, Math.round(c / 100 * 48));
coreHtml += '<div class="sys-core-bar"><div class="sys-core-bar-fill ' + cls +
'" style="height:' + h + 'px;background:var(--accent-' +
(cls === 'ok' ? 'green' : cls === 'warn' ? 'yellow' : 'red') +
', #00ff88)"></div></div>';
});
coreHtml += '</div>';
}
var freqHtml = '';
if (cpu.freq) {
var freqGhz = (cpu.freq.current / 1000).toFixed(2);
freqHtml = '<div class="sys-card-detail">Freq: ' + freqGhz + ' GHz</div>';
}
el.innerHTML =
'<div class="sys-card-header">CPU</div>' +
'<div class="sys-card-body">' +
'<div class="sys-gauge-wrap">' +
'<div class="sys-gauge-arc">' + arcGaugeSvg(pct) +
'<div class="sys-gauge-label">' + pct + '%</div></div>' +
'<div class="sys-gauge-details">' +
'<div class="sys-card-detail">Load: ' + cpu.load_1 + ' / ' + cpu.load_5 + ' / ' + cpu.load_15 + '</div>' +
'<div class="sys-card-detail">Cores: ' + cpu.count + '</div>' +
freqHtml +
'</div></div>' +
coreHtml +
'</div>';
}
// -----------------------------------------------------------------------
// Memory Card
// -----------------------------------------------------------------------
function renderMemoryCard(m) {
var el = document.getElementById('sysCardMemory');
if (!el) return;
var mem = m.memory;
if (!mem) { el.innerHTML = '<div class="sys-card-body"><span class="sys-metric-na">N/A</span></div>'; return; }
var swap = m.swap || {};
el.innerHTML =
'<div class="sys-card-header">Memory</div>' +
'<div class="sys-card-body">' +
barHtml(mem.percent, 'RAM') +
'<div class="sys-card-detail">' + formatBytes(mem.used) + ' / ' + formatBytes(mem.total) + '</div>' +
(swap.total > 0 ? barHtml(swap.percent, 'Swap') +
'<div class="sys-card-detail">' + formatBytes(swap.used) + ' / ' + formatBytes(swap.total) + '</div>' : '') +
'</div>';
}
// -----------------------------------------------------------------------
// Temperature & Power Card
// -----------------------------------------------------------------------
function _extractPrimaryTemp(temps) {
if (!temps) return null;
var preferred = ['cpu_thermal', 'coretemp', 'k10temp', 'acpitz', 'soc_thermal'];
for (var i = 0; i < preferred.length; i++) {
if (temps[preferred[i]] && temps[preferred[i]].length) return temps[preferred[i]][0];
}
for (var key in temps) {
if (temps[key] && temps[key].length) return temps[key][0];
}
return null;
}
function renderTempCard(m) {
var el = document.getElementById('sysCardTemp');
if (!el) return;
var temp = _extractPrimaryTemp(m.temperatures);
var html = '<div class="sys-card-header">Temperature &amp; Power</div><div class="sys-card-body">';
if (temp) {
// Update sparkline history
tempHistory.push(temp.current);
if (tempHistory.length > SPARKLINE_SIZE) tempHistory.shift();
html += '<div class="sys-temp-big">' + Math.round(temp.current) + '&deg;C</div>';
html += '<div class="sys-sparkline-wrap">' + sparklineSvg(tempHistory) + '</div>';
// Additional sensors
if (m.temperatures) {
for (var chip in m.temperatures) {
m.temperatures[chip].forEach(function (s) {
html += '<div class="sys-card-detail">' + escHtml(s.label) + ': ' + Math.round(s.current) + '&deg;C</div>';
});
}
}
} else {
html += '<span class="sys-metric-na">No temperature sensors</span>';
}
// Fans
if (m.fans) {
for (var fChip in m.fans) {
m.fans[fChip].forEach(function (f) {
html += '<div class="sys-card-detail">Fan ' + escHtml(f.label) + ': ' + f.current + ' RPM</div>';
});
}
}
// Battery
if (m.battery) {
html += '<div class="sys-card-detail" style="margin-top:8px">' +
'Battery: ' + Math.round(m.battery.percent) + '%' +
(m.battery.plugged ? ' (plugged)' : '') + '</div>';
}
// Throttle flags (Pi)
if (m.power && m.power.throttled) {
html += '<div class="sys-card-detail" style="color:var(--accent-yellow,#ffcc00)">Throttle: 0x' + m.power.throttled + '</div>';
}
// Power draw
if (m.power && m.power.draw_watts != null) {
html += '<div class="sys-card-detail">Power: ' + m.power.draw_watts + ' W</div>';
}
html += '</div>';
el.innerHTML = html;
}
// -----------------------------------------------------------------------
// Disk Card
// -----------------------------------------------------------------------
function renderDiskCard(m) {
var el = document.getElementById('sysCardDisk');
if (!el) return;
var disk = m.disk;
if (!disk) { el.innerHTML = '<div class="sys-card-header">Disk &amp; Storage</div><div class="sys-card-body"><span class="sys-metric-na">N/A</span></div>'; return; }
var html = '<div class="sys-card-header">Disk &amp; Storage</div><div class="sys-card-body">';
html += barHtml(disk.percent, '');
html += '<div class="sys-card-detail">' + formatBytes(disk.used) + ' / ' + formatBytes(disk.total) + '</div>';
// Disk I/O rates
if (m.disk_io && prevDiskIo && prevDiskTimestamp) {
var dt = (m.timestamp - prevDiskTimestamp);
if (dt > 0) {
var readRate = (m.disk_io.read_bytes - prevDiskIo.read_bytes) / dt;
var writeRate = (m.disk_io.write_bytes - prevDiskIo.write_bytes) / dt;
var readIops = Math.round((m.disk_io.read_count - prevDiskIo.read_count) / dt);
var writeIops = Math.round((m.disk_io.write_count - prevDiskIo.write_count) / dt);
html += '<div class="sys-disk-io">' +
'<span class="sys-disk-io-read">R: ' + formatRate(Math.max(0, readRate)) + '</span>' +
'<span class="sys-disk-io-write">W: ' + formatRate(Math.max(0, writeRate)) + '</span>' +
'</div>';
html += '<div class="sys-card-detail">IOPS: ' + Math.max(0, readIops) + 'r / ' + Math.max(0, writeIops) + 'w</div>';
}
}
if (m.disk_io) {
prevDiskIo = m.disk_io;
prevDiskTimestamp = m.timestamp;
}
html += '</div>';
el.innerHTML = html;
}
// -----------------------------------------------------------------------
// Network Card
// -----------------------------------------------------------------------
function renderNetworkCard(m) {
var el = document.getElementById('sysCardNetwork');
if (!el) return;
var net = m.network;
if (!net) { el.innerHTML = '<div class="sys-card-header">Network</div><div class="sys-card-body"><span class="sys-metric-na">N/A</span></div>'; return; }
var html = '<div class="sys-card-header">Network</div><div class="sys-card-body">';
// Interfaces
var ifaces = net.interfaces || [];
if (ifaces.length === 0) {
html += '<span class="sys-metric-na">No interfaces</span>';
} else {
ifaces.forEach(function (iface) {
html += '<div class="sys-net-iface">';
html += '<div class="sys-net-iface-name">' + escHtml(iface.name) +
(iface.is_up ? '' : ' <span style="color:var(--text-dim)">(down)</span>') + '</div>';
if (iface.ipv4) html += '<div class="sys-net-iface-ip">' + escHtml(iface.ipv4) + '</div>';
var details = [];
if (iface.mac) details.push('MAC: ' + iface.mac);
if (iface.speed) details.push(iface.speed + ' Mbps');
if (details.length) html += '<div class="sys-net-iface-detail">' + escHtml(details.join(' | ')) + '</div>';
// Bandwidth for this interface
if (net.io && net.io[iface.name] && prevNetIo && prevNetIo[iface.name] && prevNetTimestamp) {
var dt = (m.timestamp - prevNetTimestamp);
if (dt > 0) {
var prev = prevNetIo[iface.name];
var cur = net.io[iface.name];
var upRate = (cur.bytes_sent - prev.bytes_sent) / dt;
var downRate = (cur.bytes_recv - prev.bytes_recv) / dt;
html += '<div class="sys-bandwidth">' +
'<span class="sys-bw-up">&uarr; ' + formatRate(Math.max(0, upRate)) + '</span>' +
'<span class="sys-bw-down">&darr; ' + formatRate(Math.max(0, downRate)) + '</span>' +
'</div>';
}
}
html += '</div>';
});
}
// Connection count
if (net.connections != null) {
html += '<div class="sys-card-detail" style="margin-top:8px">Connections: ' + net.connections + '</div>';
}
// Save for next delta
if (net.io) {
prevNetIo = net.io;
prevNetTimestamp = m.timestamp;
}
html += '</div>';
el.innerHTML = html;
}
// -----------------------------------------------------------------------
// Location & Weather Card
// -----------------------------------------------------------------------
function renderLocationCard() {
var el = document.getElementById('sysCardLocation');
if (!el) return;
// Preserve the globe DOM node if it already has a canvas
var existingGlobe = document.getElementById('sysGlobeContainer');
var savedGlobe = null;
if (existingGlobe && existingGlobe.querySelector('canvas')) {
savedGlobe = existingGlobe;
existingGlobe.parentNode.removeChild(existingGlobe);
}
var html = '<div class="sys-card-header">Location &amp; Weather</div><div class="sys-card-body">';
html += '<div class="sys-location-inner">';
// Globe placeholder (will be replaced with saved node or initialized fresh)
if (!savedGlobe) {
html += '<div class="sys-globe-wrap" id="sysGlobeContainer"></div>';
} else {
html += '<div id="sysGlobePlaceholder"></div>';
}
// Details below globe
html += '<div class="sys-location-details">';
if (locationData && locationData.lat != null) {
html += '<div class="sys-location-coords">' +
locationData.lat.toFixed(4) + '&deg;' + (locationData.lat >= 0 ? 'N' : 'S') + ', ' +
locationData.lon.toFixed(4) + '&deg;' + (locationData.lon >= 0 ? 'E' : 'W') + '</div>';
// GPS status indicator
if (locationData.source === 'gps' && locationData.gps) {
var gps = locationData.gps;
var fixLabel = gps.fix_quality === 3 ? '3D Fix' : '2D Fix';
var dotCls = gps.fix_quality === 3 ? 'fix-3d' : 'fix-2d';
html += '<div class="sys-gps-status">' +
'<span class="sys-gps-dot ' + dotCls + '"></span> ' + fixLabel;
if (gps.satellites != null) html += ' &middot; ' + gps.satellites + ' sats';
if (gps.accuracy != null) html += ' &middot; &plusmn;' + gps.accuracy + 'm';
html += '</div>';
} else {
html += '<div class="sys-location-source">Source: ' + escHtml(locationData.source || 'unknown') + '</div>';
}
} else {
html += '<div class="sys-location-coords" style="color:var(--text-dim)">No location</div>';
}
// Weather
if (weatherData && !weatherData.error) {
html += '<div class="sys-weather">';
html += '<div class="sys-weather-temp">' + (weatherData.temp_c || '--') + '&deg;C</div>';
html += '<div class="sys-weather-condition">' + escHtml(weatherData.condition || '') + '</div>';
var details = [];
if (weatherData.humidity) details.push('Humidity: ' + weatherData.humidity + '%');
if (weatherData.wind_mph) details.push('Wind: ' + weatherData.wind_mph + ' mph ' + (weatherData.wind_dir || ''));
if (weatherData.feels_like_c) details.push('Feels like: ' + weatherData.feels_like_c + '°C');
details.forEach(function (d) {
html += '<div class="sys-weather-detail">' + escHtml(d) + '</div>';
});
html += '</div>';
} else if (weatherData && weatherData.error) {
html += '<div class="sys-weather"><div class="sys-weather-condition" style="color:var(--text-dim)">Weather unavailable</div></div>';
}
html += '</div>'; // .sys-location-details
html += '</div>'; // .sys-location-inner
html += '</div>';
el.innerHTML = html;
// Re-insert saved globe or initialize fresh
if (savedGlobe) {
var placeholder = document.getElementById('sysGlobePlaceholder');
if (placeholder) placeholder.parentNode.replaceChild(savedGlobe, placeholder);
} else {
requestAnimationFrame(function () { initGlobe(); });
}
}
// -----------------------------------------------------------------------
// Globe (reuses globe.gl from GPS mode)
// -----------------------------------------------------------------------
function ensureGlobeLibrary() {
return new Promise(function (resolve, reject) {
if (typeof window.Globe === 'function') { resolve(true); return; }
// Check if script already exists
var existing = document.querySelector(
'script[data-intercept-globe-src="' + GLOBE_SCRIPT_URL + '"], ' +
'script[src="' + GLOBE_SCRIPT_URL + '"]'
);
if (existing) {
if (existing.dataset.loaded === 'true') { resolve(true); return; }
if (existing.dataset.failed === 'true') { resolve(false); return; }
existing.addEventListener('load', function () { resolve(true); }, { once: true });
existing.addEventListener('error', function () { resolve(false); }, { once: true });
return;
}
var script = document.createElement('script');
script.src = GLOBE_SCRIPT_URL;
script.async = true;
script.crossOrigin = 'anonymous';
script.dataset.interceptGlobeSrc = GLOBE_SCRIPT_URL;
script.onload = function () { script.dataset.loaded = 'true'; resolve(true); };
script.onerror = function () { script.dataset.failed = 'true'; resolve(false); };
document.head.appendChild(script);
});
}
function initGlobe() {
var container = document.getElementById('sysGlobeContainer');
if (!container || globeDestroyed) return;
// Don't reinitialize if globe canvas is still alive in this container
if (globeInstance && container.querySelector('canvas')) return;
// Clear stale reference if canvas was destroyed by innerHTML replacement
if (globeInstance && !container.querySelector('canvas')) {
globeInstance = null;
}
ensureGlobeLibrary().then(function (ready) {
if (!ready || typeof window.Globe !== 'function' || globeDestroyed) return;
// Wait for layout — container may have 0 dimensions right after
// display:none is removed by switchMode(). Use RAF retry like GPS mode.
var attempts = 0;
function tryInit() {
if (globeDestroyed) return;
container = document.getElementById('sysGlobeContainer');
if (!container) return;
if ((!container.clientWidth || !container.clientHeight) && attempts < 8) {
attempts++;
requestAnimationFrame(tryInit);
return;
}
if (!container.clientWidth || !container.clientHeight) return;
container.innerHTML = '';
container.style.background = 'radial-gradient(circle, rgba(10,20,40,0.9), rgba(2,4,8,0.98) 70%)';
try {
globeInstance = window.Globe()(container)
.backgroundColor('rgba(0,0,0,0)')
.globeImageUrl(GLOBE_TEXTURE_URL)
.showAtmosphere(true)
.atmosphereColor('#3bb9ff')
.atmosphereAltitude(0.12)
.pointsData([])
.pointRadius(0.8)
.pointAltitude(0.01)
.pointColor(function () { return '#00d4ff'; });
var controls = globeInstance.controls();
if (controls) {
controls.autoRotate = true;
controls.autoRotateSpeed = 0.5;
controls.enablePan = false;
controls.minDistance = 120;
controls.maxDistance = 300;
}
// Size the globe
globeInstance.width(container.clientWidth);
globeInstance.height(container.clientHeight);
updateGlobePosition();
} catch (e) {
// Globe.gl / WebGL init failed — show static fallback
container.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;width:100%;height:100%;color:var(--text-dim);font-size:11px;">Globe unavailable</div>';
}
}
requestAnimationFrame(tryInit);
});
}
function updateGlobePosition() {
if (!globeInstance || !locationData || locationData.lat == null) return;
// Observer point
globeInstance.pointsData([{
lat: locationData.lat,
lng: locationData.lon,
size: 0.8,
color: '#00d4ff',
}]);
// Snap view
globeInstance.pointOfView({ lat: locationData.lat, lng: locationData.lon, altitude: 2.0 }, 1000);
// Stop auto-rotate when we have a fix
var controls = globeInstance.controls();
if (controls) controls.autoRotate = false;
}
function destroyGlobe() {
globeDestroyed = true;
if (globeInstance) {
var container = document.getElementById('sysGlobeContainer');
if (container) container.innerHTML = '';
globeInstance = null;
}
}
// -----------------------------------------------------------------------
// SDR Card
// -----------------------------------------------------------------------
function renderSdrCard(devices) {
var el = document.getElementById('sysCardSdr');
if (!el) return;
var html = '<div class="sys-card-header">SDR Devices <button class="sys-rescan-btn" onclick="SystemHealth.refreshSdr()">Rescan</button></div>';
html += '<div class="sys-card-body">';
if (!devices || !devices.length) {
html += '<span class="sys-metric-na">No devices found</span>';
} else {
devices.forEach(function (d) {
html += '<div class="sys-sdr-device">' +
'<span class="sys-process-dot running"></span> ' +
'<strong>' + escHtml(d.type) + ' #' + d.index + '</strong>' +
'<div class="sys-card-detail">' + escHtml(d.name || 'Unknown') + '</div>' +
(d.serial ? '<div class="sys-card-detail">S/N: ' + escHtml(d.serial) + '</div>' : '') +
'</div>';
});
}
html += '</div>';
el.innerHTML = html;
}
// -----------------------------------------------------------------------
// Process Card
// -----------------------------------------------------------------------
function renderProcessCard(m) {
var el = document.getElementById('sysCardProcesses');
if (!el) return;
var procs = m.processes || {};
var keys = Object.keys(procs).sort();
var html = '<div class="sys-card-header">Active Processes</div><div class="sys-card-body">';
if (!keys.length) {
html += '<span class="sys-metric-na">No data</span>';
} else {
var running = 0, stopped = 0;
html += '<div class="sys-process-grid">';
keys.forEach(function (k) {
var isRunning = procs[k];
if (isRunning) running++; else stopped++;
var dotCls = isRunning ? 'running' : 'stopped';
var label = k.charAt(0).toUpperCase() + k.slice(1);
html += '<div class="sys-process-item">' +
'<span class="sys-process-dot ' + dotCls + '"></span> ' +
'<span class="sys-process-name">' + escHtml(label) + '</span>' +
'</div>';
});
html += '</div>';
html += '<div class="sys-process-summary">' + running + ' running / ' + stopped + ' idle</div>';
}
html += '</div>';
el.innerHTML = html;
}
// -----------------------------------------------------------------------
// System Info Card
// -----------------------------------------------------------------------
function renderSystemInfoCard(m) {
var el = document.getElementById('sysCardInfo');
if (!el) return;
var sys = m.system || {};
var html = '<div class="sys-card-header">System Info</div><div class="sys-card-body"><div class="sys-info-grid">';
html += '<div class="sys-info-item"><strong>Host</strong><span>' + escHtml(sys.hostname || '--') + '</span></div>';
html += '<div class="sys-info-item"><strong>OS</strong><span>' + escHtml((sys.platform || '--').replace(/-with-glibc[\d.]+/, '')) + '</span></div>';
html += '<div class="sys-info-item"><strong>Python</strong><span>' + escHtml(sys.python || '--') + '</span></div>';
html += '<div class="sys-info-item"><strong>App</strong><span>v' + escHtml(sys.version || '--') + '</span></div>';
html += '<div class="sys-info-item"><strong>Uptime</strong><span>' + escHtml(sys.uptime_human || '--') + '</span></div>';
if (m.boot_time) {
var bootDate = new Date(m.boot_time * 1000);
html += '<div class="sys-info-item"><strong>Boot</strong><span>' + escHtml(bootDate.toLocaleString()) + '</span></div>';
}
if (m.network && m.network.connections != null) {
html += '<div class="sys-info-item"><strong>Connections</strong><span>' + m.network.connections + '</span></div>';
}
html += '</div></div>';
el.innerHTML = html;
}
// -----------------------------------------------------------------------
// Sidebar Updates
// -----------------------------------------------------------------------
function updateSidebarQuickStats(m) {
var cpuEl = document.getElementById('sysQuickCpu');
var tempEl = document.getElementById('sysQuickTemp');
var ramEl = document.getElementById('sysQuickRam');
var diskEl = document.getElementById('sysQuickDisk');
if (cpuEl) cpuEl.textContent = m.cpu ? Math.round(m.cpu.percent) + '%' : '--';
if (ramEl) ramEl.textContent = m.memory ? Math.round(m.memory.percent) + '%' : '--';
if (diskEl) diskEl.textContent = m.disk ? Math.round(m.disk.percent) + '%' : '--';
var temp = _extractPrimaryTemp(m.temperatures);
if (tempEl) tempEl.innerHTML = temp ? Math.round(temp.current) + '&deg;C' : '--';
// Color-code values
[cpuEl, ramEl, diskEl].forEach(function (el) {
if (!el) return;
var val = parseInt(el.textContent);
el.classList.remove('sys-val-ok', 'sys-val-warn', 'sys-val-crit');
if (!isNaN(val)) el.classList.add('sys-val-' + barClass(val));
});
}
function updateSidebarProcesses(m) {
var el = document.getElementById('sysProcessList');
if (!el) return;
var procs = m.processes || {};
var keys = Object.keys(procs).sort();
if (!keys.length) { el.textContent = 'No data'; return; }
var running = keys.filter(function (k) { return procs[k]; });
var stopped = keys.filter(function (k) { return !procs[k]; });
el.innerHTML =
(running.length ? '<span style="color: var(--accent-green, #00ff88);">' + running.length + ' running</span>' : '') +
(running.length && stopped.length ? ' &middot; ' : '') +
(stopped.length ? '<span style="color: var(--text-dim);">' + stopped.length + ' stopped</span>' : '');
}
function updateSidebarNetwork(m) {
var el = document.getElementById('sysQuickNet');
if (!el || !m.network) return;
var ifaces = m.network.interfaces || [];
var ips = [];
ifaces.forEach(function (iface) {
if (iface.ipv4 && iface.is_up) {
ips.push(iface.name + ': ' + iface.ipv4);
}
});
el.textContent = ips.length ? ips.join(', ') : '--';
}
function updateSidebarBattery(m) {
var section = document.getElementById('sysQuickBatterySection');
var el = document.getElementById('sysQuickBattery');
if (!section || !el) return;
if (m.battery) {
section.style.display = '';
el.textContent = Math.round(m.battery.percent) + '%' + (m.battery.plugged ? ' (plugged)' : '');
} else {
section.style.display = 'none';
}
}
function updateSidebarLocation() {
var el = document.getElementById('sysQuickLocation');
if (!el) return;
if (locationData && locationData.lat != null) {
el.textContent = locationData.lat.toFixed(4) + ', ' + locationData.lon.toFixed(4) + ' (' + locationData.source + ')';
} else {
el.textContent = 'No location';
}
}
// -----------------------------------------------------------------------
// Render all
// -----------------------------------------------------------------------
function renderAll(m) {
renderCpuCard(m);
renderMemoryCard(m);
renderTempCard(m);
renderDiskCard(m);
renderNetworkCard(m);
renderProcessCard(m);
renderSystemInfoCard(m);
updateSidebarQuickStats(m);
updateSidebarProcesses(m);
updateSidebarNetwork(m);
updateSidebarBattery(m);
}
// -----------------------------------------------------------------------
// Location & Weather Fetching
// -----------------------------------------------------------------------
function fetchLocation() {
fetch('/system/location')
.then(function (r) { return r.json(); })
.then(function (data) {
// If server only has default/none, check client-side saved location
if ((data.source === 'default' || data.source === 'none') &&
window.ObserverLocation && ObserverLocation.getShared) {
var shared = ObserverLocation.getShared();
if (shared && shared.lat && shared.lon) {
data.lat = shared.lat;
data.lon = shared.lon;
data.source = 'manual';
}
}
locationData = data;
updateSidebarLocation();
renderLocationCard();
if (data.lat != null) fetchWeather();
})
.catch(function () {
renderLocationCard();
});
}
function fetchWeather() {
if (!locationData || locationData.lat == null) return;
fetch('/system/weather?lat=' + locationData.lat + '&lon=' + locationData.lon)
.then(function (r) { return r.json(); })
.then(function (data) {
weatherData = data;
renderLocationCard();
})
.catch(function () {});
}
// -----------------------------------------------------------------------
// SSE Connection
// -----------------------------------------------------------------------
function connect() {
if (eventSource) return;
eventSource = new EventSource('/system/stream');
eventSource.onmessage = function (e) {
try {
var data = JSON.parse(e.data);
if (data.type === 'keepalive') return;
lastMetrics = data;
renderAll(data);
} catch (_) { /* ignore parse errors */ }
};
eventSource.onopen = function () {
connected = true;
};
eventSource.onerror = function () {
connected = false;
};
}
function disconnect() {
if (eventSource) {
eventSource.close();
eventSource = null;
}
connected = false;
}
// -----------------------------------------------------------------------
// SDR Devices
// -----------------------------------------------------------------------
function refreshSdr() {
var sidebarEl = document.getElementById('sysSdrList');
if (sidebarEl) sidebarEl.innerHTML = 'Scanning&hellip;';
var cardEl = document.getElementById('sysCardSdr');
if (cardEl) cardEl.innerHTML = '<div class="sys-card-header">SDR Devices</div><div class="sys-card-body">Scanning&hellip;</div>';
fetch('/system/sdr_devices')
.then(function (r) { return r.json(); })
.then(function (data) {
var devices = data.devices || [];
renderSdrCard(devices);
// Update sidebar
if (sidebarEl) {
if (!devices.length) {
sidebarEl.innerHTML = '<span style="color: var(--text-dim);">No SDR devices found</span>';
} else {
var html = '';
devices.forEach(function (d) {
html += '<div style="margin-bottom: 4px;"><span class="sys-process-dot running"></span> ' +
escHtml(d.type) + ' #' + d.index + ' &mdash; ' + escHtml(d.name || 'Unknown') + '</div>';
});
sidebarEl.innerHTML = html;
}
}
})
.catch(function () {
if (sidebarEl) sidebarEl.innerHTML = '<span style="color: var(--accent-red, #ff3366);">Detection failed</span>';
renderSdrCard([]);
});
}
// -----------------------------------------------------------------------
// Public API
// -----------------------------------------------------------------------
function init() {
globeDestroyed = false;
connect();
refreshSdr();
fetchLocation();
// Refresh weather every 10 minutes
weatherTimer = setInterval(function () {
fetchWeather();
}, 600000);
}
function destroy() {
disconnect();
destroyGlobe();
if (weatherTimer) {
clearInterval(weatherTimer);
weatherTimer = null;
}
}
return {
init: init,
destroy: destroy,
refreshSdr: refreshSdr,
};
})();
+300 -100
View File
@@ -14,11 +14,11 @@ const Waterfall = (function () {
let _sseStartConfigKey = '';
let _active = false;
let _running = false;
let _listenersAttached = false;
let _controlListenersAttached = false;
let _retuneTimer = null;
let _monitorRetuneTimer = null;
let _pendingMonitorRetune = false;
let _peakHold = false;
let _showAnnotations = true;
@@ -36,6 +36,7 @@ const Waterfall = (function () {
let _startMhz = 98.8;
let _endMhz = 101.2;
let _lastEffectiveSpan = 2.4;
let _monitorFreqMhz = 100.0;
let _monitoring = false;
@@ -44,12 +45,15 @@ const Waterfall = (function () {
let _startingMonitor = false;
let _monitorSource = 'process';
let _pendingSharedMonitorRearm = false;
let _pendingCaptureVfoMhz = null;
let _pendingMonitorTuneMhz = null;
let _audioConnectNonce = 0;
let _audioAnalyser = null;
let _audioContext = null;
let _audioSourceNode = null;
let _smeterRaf = null;
let _audioUnlockRequired = false;
let _lastTouchTuneAt = 0;
let _devices = [];
let _scanRunning = false;
@@ -900,19 +904,27 @@ const Waterfall = (function () {
resolve(ok);
};
const onReady = () => finish(true);
// Only treat actual playback as success. `loadeddata` and
// `canplay` fire when just the WAV header arrives — before any
// real audio samples have been decoded — which caused the
// monitor to report "started" while the stream was still silent.
const onReady = () => {
if (player.currentTime > 0 || (!player.paused && player.readyState >= 4)) {
finish(true);
}
};
const onFail = () => finish(false);
const events = ['playing', 'timeupdate', 'canplay', 'loadeddata'];
const events = ['playing', 'timeupdate'];
const failEvents = ['error', 'abort', 'stalled', 'ended'];
events.forEach((evt) => player.addEventListener(evt, onReady));
failEvents.forEach((evt) => player.addEventListener(evt, onFail));
timer = setTimeout(() => {
finish(!player.paused && (player.currentTime > 0 || player.readyState >= 2));
finish(!player.paused && player.currentTime > 0);
}, timeoutMs);
if (!player.paused && (player.currentTime > 0 || player.readyState >= 2)) {
if (!player.paused && player.currentTime > 0) {
finish(true);
}
});
@@ -1174,6 +1186,8 @@ const Waterfall = (function () {
function _scanTuneTo(freqMhz) {
const clamped = _clamp(freqMhz, 0.001, 6000.0);
_monitorFreqMhz = clamped;
_pendingCaptureVfoMhz = clamped;
_pendingMonitorTuneMhz = clamped;
_updateFreqDisplay();
if (_monitoring && !_isSharedMonitorActive()) {
@@ -1752,15 +1766,24 @@ const Waterfall = (function () {
return _startMhz + frac * (_endMhz - _startMhz);
}
function _clientXFromEvent(event) {
if (event && Number.isFinite(event.clientX)) return event.clientX;
const touch = event?.changedTouches?.[0] || event?.touches?.[0];
if (touch && Number.isFinite(touch.clientX)) return touch.clientX;
return null;
}
function _showTooltip(canvas, event) {
const tooltip = document.getElementById('wfTooltip');
if (!tooltip) return;
const freq = _freqAtX(canvas, event.clientX);
const clientX = _clientXFromEvent(event);
if (!Number.isFinite(clientX)) return;
const freq = _freqAtX(canvas, clientX);
const wrap = document.querySelector('.wf-waterfall-canvas-wrap');
if (wrap) {
const rect = wrap.getBoundingClientRect();
tooltip.style.left = `${event.clientX - rect.left}px`;
tooltip.style.left = `${clientX - rect.left}px`;
tooltip.style.transform = 'translateX(-50%)';
tooltip.style.top = '4px';
}
@@ -1789,9 +1812,29 @@ const Waterfall = (function () {
function _queueMonitorRetune(delayMs) {
if (!_monitoring) return;
clearTimeout(_monitorRetuneTimer);
_monitorRetuneTimer = setTimeout(() => {
// If a monitor start is already in-flight, invalidate it so the
// latest click/retune request wins.
if (_startingMonitor) {
_audioConnectNonce += 1;
_pendingMonitorRetune = true;
}
const runRetune = () => {
if (!_monitoring) return;
if (_startingMonitor) {
// Keep trying until the in-flight monitor start fully exits.
_monitorRetuneTimer = setTimeout(runRetune, 90);
return;
}
_pendingMonitorRetune = false;
_startMonitorInternal({ wasRunningWaterfall: false, retuneOnly: true }).catch(() => {});
}, delayMs);
};
_monitorRetuneTimer = setTimeout(
runRetune,
_startingMonitor ? Math.max(delayMs, 220) : delayMs
);
}
function _isSharedMonitorActive() {
@@ -1835,12 +1878,17 @@ const Waterfall = (function () {
if (input) input.value = clamped.toFixed(4);
_monitorFreqMhz = clamped;
_pendingCaptureVfoMhz = clamped;
_pendingMonitorTuneMhz = clamped;
const currentSpan = _endMhz - _startMhz;
const configuredSpan = _clamp(_currentSpan(), 0.05, 30.0);
const activeSpan = Number.isFinite(currentSpan) && currentSpan > 0 ? currentSpan : configuredSpan;
const edgeMargin = activeSpan * 0.08;
const withinCapture = clamped >= (_startMhz + edgeMargin) && clamped <= (_endMhz - edgeMargin);
const needsRetune = !withinCapture;
const sharedMonitor = _isSharedMonitorActive();
// While monitoring audio, force a capture recenter/restart for each
// click so monitor retunes are deterministic across the full span.
const needsRetune = !withinCapture || _monitoring;
if (needsRetune) {
_startMhz = clamped - configuredSpan / 2;
@@ -1850,7 +1898,6 @@ const Waterfall = (function () {
_updateFreqDisplay();
}
const sharedMonitor = _isSharedMonitorActive();
if (_monitoring) {
if (!sharedMonitor) {
_queueMonitorRetune(immediate ? 35 : 140);
@@ -1890,6 +1937,9 @@ const Waterfall = (function () {
if (!msg || msg.status !== 'retune_required') return false;
_setStatus(msg.message || 'Retuning SDR capture...');
if (Number.isFinite(msg.vfo_freq_mhz)) {
_monitorFreqMhz = Number(msg.vfo_freq_mhz);
_pendingCaptureVfoMhz = _monitorFreqMhz;
_pendingMonitorTuneMhz = _monitorFreqMhz;
const input = document.getElementById('wfCenterFreq');
if (input) input.value = Number(msg.vfo_freq_mhz).toFixed(4);
}
@@ -1915,25 +1965,42 @@ const Waterfall = (function () {
}
function _clickTune(canvas, event) {
const target = _freqAtX(canvas, event.clientX);
const clientX = _clientXFromEvent(event);
if (!Number.isFinite(clientX)) return;
const target = _freqAtX(canvas, clientX);
if (!Number.isFinite(target)) return;
_setAndTune(target, true);
}
function _bindCanvasInteraction(canvas) {
if (!canvas) return;
if (canvas.dataset.wfInteractive === '1') return;
canvas.dataset.wfInteractive = '1';
canvas.style.cursor = 'crosshair';
canvas.addEventListener('mousemove', (e) => _showTooltip(canvas, e));
canvas.addEventListener('mouseleave', _hideTooltip);
canvas.addEventListener('click', (e) => {
// Mobile touch emits a synthetic click shortly after touchend.
if (Date.now() - _lastTouchTuneAt < 450) return;
_clickTune(canvas, e);
});
canvas.addEventListener('wheel', _handleCanvasWheel, { passive: false });
canvas.addEventListener('touchmove', (e) => {
_showTooltip(canvas, e);
}, { passive: true });
canvas.addEventListener('touchend', (e) => {
_lastTouchTuneAt = Date.now();
_clickTune(canvas, e);
_hideTooltip();
e.preventDefault();
}, { passive: false });
canvas.addEventListener('touchcancel', _hideTooltip);
}
function _setupCanvasInteraction() {
if (_listenersAttached) return;
_listenersAttached = true;
const bindCanvas = (canvas) => {
if (!canvas) return;
canvas.style.cursor = 'crosshair';
canvas.addEventListener('mousemove', (e) => _showTooltip(canvas, e));
canvas.addEventListener('mouseleave', _hideTooltip);
canvas.addEventListener('click', (e) => _clickTune(canvas, e));
canvas.addEventListener('wheel', _handleCanvasWheel, { passive: false });
};
bindCanvas(_wfCanvas);
bindCanvas(_specCanvas);
_bindCanvasInteraction(_wfCanvas);
_bindCanvasInteraction(_specCanvas);
}
function _setupResizeHandle() {
@@ -2153,7 +2220,6 @@ const Waterfall = (function () {
const spanMhz = _clamp(_currentSpan(), 0.05, 30.0);
_startMhz = centerMhz - spanMhz / 2;
_endMhz = centerMhz + spanMhz / 2;
_monitorFreqMhz = centerMhz;
_peakLine = null;
_drawFreqAxis();
@@ -2182,11 +2248,15 @@ const Waterfall = (function () {
function _sendWsStartCmd() {
if (!_ws || _ws.readyState !== WebSocket.OPEN) return;
const cfg = _waterfallRequestConfig();
const targetVfoMhz = Number.isFinite(_pendingCaptureVfoMhz)
? _pendingCaptureVfoMhz
: (Number.isFinite(_monitorFreqMhz) ? _monitorFreqMhz : cfg.centerMhz);
const payload = {
cmd: 'start',
center_freq_mhz: cfg.centerMhz,
center_freq: cfg.centerMhz,
vfo_freq_mhz: targetVfoMhz,
span_mhz: cfg.spanMhz,
gain: cfg.gain,
sdr_type: cfg.device.sdrType,
@@ -2435,7 +2505,10 @@ const Waterfall = (function () {
_scanAwaitingCapture = false;
_scanStartPending = false;
_scanRestartAttempts = 0;
if (Number.isFinite(msg.vfo_freq_mhz)) {
if (Number.isFinite(_pendingCaptureVfoMhz)) {
_monitorFreqMhz = _pendingCaptureVfoMhz;
_pendingCaptureVfoMhz = null;
} else if (Number.isFinite(msg.vfo_freq_mhz)) {
_monitorFreqMhz = Number(msg.vfo_freq_mhz);
}
if (Number.isFinite(msg.start_freq) && Number.isFinite(msg.end_freq)) {
@@ -2443,24 +2516,44 @@ const Waterfall = (function () {
_endMhz = msg.end_freq;
_drawFreqAxis();
}
if (Number.isFinite(msg.effective_span_mhz)) {
_lastEffectiveSpan = msg.effective_span_mhz;
const spanEl = document.getElementById('wfSpanMhz');
if (spanEl) spanEl.value = msg.effective_span_mhz;
}
_setStatus(`Streaming ${_startMhz.toFixed(4)} - ${_endMhz.toFixed(4)} MHz`);
_setVisualStatus('RUNNING');
if (_pendingSharedMonitorRearm && _monitoring && _monitorSource === 'waterfall') {
if (_monitoring) {
_pendingSharedMonitorRearm = false;
// After any capture restart, always retune monitor
// audio to the current VFO frequency.
_queueMonitorRetune(_monitorSource === 'waterfall' ? 120 : 80);
} else if (_pendingSharedMonitorRearm) {
_pendingSharedMonitorRearm = false;
_queueMonitorRetune(120);
}
} else if (msg.status === 'tuned') {
if (_onRetuneRequired(msg)) return;
if (Number.isFinite(msg.vfo_freq_mhz)) {
if (Number.isFinite(_pendingCaptureVfoMhz)) {
_monitorFreqMhz = _pendingCaptureVfoMhz;
_pendingCaptureVfoMhz = null;
} else if (Number.isFinite(msg.vfo_freq_mhz)) {
_monitorFreqMhz = Number(msg.vfo_freq_mhz);
}
_updateFreqDisplay();
_setStatus(`Tuned ${_monitorFreqMhz.toFixed(4)} MHz`);
if (_monitoring && _monitorSource === 'waterfall') {
const mode = _getMonitorMode().toUpperCase();
_setMonitorState(`Monitoring ${_monitorFreqMhz.toFixed(4)} MHz ${mode} via shared IQ`);
_setStatus(`Audio monitor active on ${_monitorFreqMhz.toFixed(4)} MHz (${mode})`);
_setVisualStatus('MONITOR');
}
if (!_monitoring) _setVisualStatus('RUNNING');
} else if (_onRetuneRequired(msg)) {
return;
} else if (msg.status === 'stopped') {
_running = false;
_pendingCaptureVfoMhz = null;
_pendingMonitorTuneMhz = null;
_scanAwaitingCapture = false;
_scanStartPending = false;
_scanRestartAttempts = 0;
@@ -2472,7 +2565,24 @@ const Waterfall = (function () {
_setVisualStatus('STOPPED');
} else if (msg.status === 'error') {
_running = false;
_pendingCaptureVfoMhz = null;
_pendingMonitorTuneMhz = null;
_scanStartPending = false;
_pendingSharedMonitorRearm = false;
// Reset span input to last known good value so an
// invalid span doesn't persist across restart (#150).
const spanEl = document.getElementById('wfSpanMhz');
if (spanEl) spanEl.value = _lastEffectiveSpan;
// If the monitor was using the shared IQ stream that
// just failed, tear down the stale monitor state so
// the button becomes clickable again after restart.
if (_monitoring && _monitorSource === 'waterfall') {
clearTimeout(_monitorRetuneTimer);
_monitoring = false;
_monitorSource = 'process';
_syncMonitorButtons();
_setMonitorState('Monitor stopped (waterfall error)');
}
if (_scanRunning) {
_scanAwaitingCapture = true;
_setScanState(msg.message || 'Waterfall retune error, retrying...', true);
@@ -2509,7 +2619,7 @@ const Waterfall = (function () {
player.load();
}
async function _attachMonitorAudio(nonce) {
async function _attachMonitorAudio(nonce, streamToken = null) {
const player = document.getElementById('wfAudioPlayer');
if (!player) {
return { ok: false, reason: 'player_missing', message: 'Audio player is unavailable.' };
@@ -2528,7 +2638,10 @@ const Waterfall = (function () {
}
await _pauseMonitorAudioElement();
player.src = `/receiver/audio/stream?fresh=1&t=${Date.now()}-${attempt}`;
const tokenQuery = (streamToken !== null && streamToken !== undefined && String(streamToken).length > 0)
? `&request_token=${encodeURIComponent(String(streamToken))}`
: '';
player.src = `/receiver/audio/stream?fresh=1&t=${Date.now()}-${attempt}${tokenQuery}`;
player.load();
try {
@@ -2571,6 +2684,7 @@ const Waterfall = (function () {
}
if (attempt < maxAttempts) {
_setMonitorState(`Waiting for audio stream (attempt ${attempt}/${maxAttempts})...`);
await _wait(220 * attempt);
continue;
}
@@ -2583,25 +2697,6 @@ const Waterfall = (function () {
};
}
function _deviceKey(device) {
if (!device) return '';
return `${device.sdrType || ''}:${device.deviceIndex || 0}`;
}
function _findAlternateDevice(currentDevice) {
const currentKey = _deviceKey(currentDevice);
for (const d of _devices) {
const candidate = {
sdrType: String(d.sdr_type || 'rtlsdr'),
deviceIndex: parseInt(d.index, 10) || 0,
};
if (_deviceKey(candidate) !== currentKey) {
return candidate;
}
}
return null;
}
async function _requestAudioStart({
frequency,
modulation,
@@ -2609,6 +2704,7 @@ const Waterfall = (function () {
gain,
device,
biasT,
requestToken,
}) {
const response = await fetch('/receiver/audio/start', {
method: 'POST',
@@ -2621,6 +2717,7 @@ const Waterfall = (function () {
device: device.deviceIndex,
sdr_type: device.sdrType,
bias_t: biasT,
request_token: requestToken,
}),
});
@@ -2640,7 +2737,10 @@ const Waterfall = (function () {
if (monitorBtn) {
monitorBtn.textContent = _monitoring ? 'Stop Monitor' : 'Monitor';
monitorBtn.classList.toggle('is-active', _monitoring);
monitorBtn.disabled = _startingMonitor;
// Allow clicking Stop Monitor during retunes (monitor already
// active, just reconnecting audio). Only disable when starting
// from scratch so users can't double-click Start.
monitorBtn.disabled = _startingMonitor && !_monitoring;
}
if (muteBtn) {
@@ -2660,7 +2760,19 @@ const Waterfall = (function () {
_resumeWaterfallAfterMonitor = !!wasRunningWaterfall;
}
const centerMhz = _currentCenter();
const liveCenterMhz = _currentCenter();
// Keep an explicit pending tune target so retunes cannot fall
// back to a stale frequency during capture restart churn.
const requestedTuneMhz = Number.isFinite(_pendingMonitorTuneMhz)
? _pendingMonitorTuneMhz
: (
Number.isFinite(_pendingCaptureVfoMhz)
? _pendingCaptureVfoMhz
: (Number.isFinite(_monitorFreqMhz) ? _monitorFreqMhz : liveCenterMhz)
);
const centerMhz = retuneOnly
? (Number.isFinite(liveCenterMhz) ? liveCenterMhz : requestedTuneMhz)
: liveCenterMhz;
const mode = document.getElementById('wfMonitorMode')?.value || 'wfm';
const squelch = parseInt(document.getElementById('wfMonitorSquelch')?.value, 10) || 0;
const sliderGain = parseInt(document.getElementById('wfMonitorGain')?.value, 10);
@@ -2669,57 +2781,98 @@ const Waterfall = (function () {
? sliderGain
: (Number.isFinite(fallbackGain) ? Math.round(fallbackGain) : 40);
const selectedDevice = _selectedDevice();
const altDevice = _running ? _findAlternateDevice(selectedDevice) : null;
let monitorDevice = altDevice || selectedDevice;
// Always target the currently selected SDR for monitor start/retune.
// This keeps waterfall-shared monitor tuning deterministic and avoids
// retuning a different receiver than the one driving the display.
let monitorDevice = selectedDevice;
const biasT = !!document.getElementById('wfBiasT')?.checked;
const usingSecondaryDevice = !!altDevice;
// Use a high monotonic token so backend start ordering remains
// valid across page reloads (local nonces reset to small values).
const requestToken = Math.trunc((Date.now() * 4096) + (nonce & 0x0fff));
_monitorFreqMhz = centerMhz;
if (!retuneOnly) {
_monitorFreqMhz = centerMhz;
} else if (Number.isFinite(centerMhz)) {
_monitorFreqMhz = centerMhz;
_pendingMonitorTuneMhz = centerMhz;
_pendingCaptureVfoMhz = centerMhz;
}
_drawFreqAxis();
_stopSmeter();
_setUnlockVisible(false);
_audioUnlockRequired = false;
if (usingSecondaryDevice) {
if (retuneOnly && _monitoring) {
_setMonitorState(`Retuning ${centerMhz.toFixed(4)} MHz ${mode.toUpperCase()}...`);
} else {
_setMonitorState(
`Starting ${centerMhz.toFixed(4)} MHz ${mode.toUpperCase()} on `
+ `${monitorDevice.sdrType.toUpperCase()} #${monitorDevice.deviceIndex}...`
);
} else {
_setMonitorState(`Starting ${centerMhz.toFixed(4)} MHz ${mode.toUpperCase()}...`);
}
let { response, payload } = await _requestAudioStart({
frequency: centerMhz,
modulation: mode,
squelch,
gain,
device: monitorDevice,
biasT,
});
// Use live _monitorFreqMhz for retunes so that any user
// clicks that changed the VFO during the async setup are
// picked up rather than overridden.
const requestAudioStartResynced = async (deviceForRequest) => {
let startResult = await _requestAudioStart({
frequency: centerMhz,
modulation: mode,
squelch,
gain,
device: deviceForRequest,
biasT,
requestToken,
});
const startPayload = startResult?.payload || {};
const isStale = startPayload.superseded === true || startPayload.status === 'stale';
if (isStale) {
const currentToken = Number(startPayload.current_token);
if (Number.isFinite(currentToken) && currentToken >= 0) {
startResult = await _requestAudioStart({
frequency: centerMhz,
modulation: mode,
squelch,
gain,
device: deviceForRequest,
biasT,
requestToken: currentToken + 1,
});
}
}
return startResult;
};
let { response, payload } = await requestAudioStartResynced(monitorDevice);
if (nonce !== _audioConnectNonce) return;
const busy = payload?.error_type === 'DEVICE_BUSY' || response.status === 409;
if (
busy
&& _running
&& !usingSecondaryDevice
&& !retuneOnly
) {
const staleStart = payload?.superseded === true || payload?.status === 'stale';
if (staleStart) {
// If the backend still reports stale after token resync,
// schedule a fresh retune so monitor audio does not stay on
// an older station indefinitely.
if (_monitoring) {
const liveMode = _getMonitorMode().toUpperCase();
_setMonitorState(`Monitoring ${_monitorFreqMhz.toFixed(4)} MHz ${liveMode}`);
_setStatus(`Audio monitor active on ${_monitorFreqMhz.toFixed(4)} MHz (${liveMode})`);
_setVisualStatus('MONITOR');
_queueMonitorRetune(90);
}
return;
}
const busy = payload?.error_type === 'DEVICE_BUSY' || (response.status === 409 && !staleStart);
if (busy && _running && !retuneOnly) {
_setMonitorState('Audio device busy, pausing waterfall and retrying monitor...');
await stop({ keepStatus: true });
_resumeWaterfallAfterMonitor = true;
await _wait(220);
monitorDevice = selectedDevice;
({ response, payload } = await _requestAudioStart({
frequency: centerMhz,
modulation: mode,
squelch,
gain,
device: monitorDevice,
biasT,
}));
({ response, payload } = await requestAudioStartResynced(monitorDevice));
if (nonce !== _audioConnectNonce) return;
if (payload?.superseded === true || payload?.status === 'stale') {
if (_monitoring) _queueMonitorRetune(90);
return;
}
}
if (!response.ok || payload.status !== 'started') {
@@ -2738,9 +2891,16 @@ const Waterfall = (function () {
return;
}
const attach = await _attachMonitorAudio(nonce);
const attach = await _attachMonitorAudio(nonce, payload?.request_token);
if (nonce !== _audioConnectNonce) return;
_monitorSource = payload?.source === 'waterfall' ? 'waterfall' : 'process';
const pendingTuneMismatch = (
Number.isFinite(_pendingMonitorTuneMhz)
&& Math.abs(_pendingMonitorTuneMhz - centerMhz) >= 1e-6
);
if (!pendingTuneMismatch) {
_pendingMonitorTuneMhz = null;
}
if (!attach.ok) {
if (attach.reason === 'autoplay_blocked') {
@@ -2749,6 +2909,7 @@ const Waterfall = (function () {
_setMonitorState(`Monitoring ${centerMhz.toFixed(4)} MHz ${mode.toUpperCase()} (audio locked)`);
_setStatus('Monitor started but browser blocked playback. Click Unlock Audio.');
_setVisualStatus('MONITOR');
if (pendingTuneMismatch) _queueMonitorRetune(45);
return;
}
@@ -2775,20 +2936,36 @@ const Waterfall = (function () {
_monitoring = true;
_syncMonitorButtons();
_startSmeter(attach.player);
// Use live VFO for display — user may have clicked a new
// frequency while the retune was reconnecting audio.
const displayMhz = retuneOnly ? _monitorFreqMhz : centerMhz;
if (_monitorSource === 'waterfall') {
_setMonitorState(
`Monitoring ${centerMhz.toFixed(4)} MHz ${mode.toUpperCase()} via shared IQ`
);
} else if (usingSecondaryDevice) {
_setMonitorState(
`Monitoring ${centerMhz.toFixed(4)} MHz ${mode.toUpperCase()} `
+ `via ${monitorDevice.sdrType.toUpperCase()} #${monitorDevice.deviceIndex}`
`Monitoring ${displayMhz.toFixed(4)} MHz ${mode.toUpperCase()} via shared IQ`
);
} else {
_setMonitorState(`Monitoring ${centerMhz.toFixed(4)} MHz ${mode.toUpperCase()}`);
_setMonitorState(
`Monitoring ${displayMhz.toFixed(4)} MHz ${mode.toUpperCase()} `
+ `via ${monitorDevice.sdrType.toUpperCase()} #${monitorDevice.deviceIndex}`
);
}
_setStatus(`Audio monitor active on ${centerMhz.toFixed(4)} MHz (${mode.toUpperCase()})`);
_setStatus(`Audio monitor active on ${displayMhz.toFixed(4)} MHz (${mode.toUpperCase()})`);
_setVisualStatus('MONITOR');
if (pendingTuneMismatch) {
_queueMonitorRetune(45);
}
// After a retune reconnect, sync the backend to the latest
// VFO in case the user clicked a new frequency while the
// audio stream was reconnecting.
if (
!pendingTuneMismatch
&& retuneOnly
&& _monitorSource === 'waterfall'
&& _ws
&& _ws.readyState === WebSocket.OPEN
) {
_sendWsTuneCmd();
}
} catch (err) {
if (nonce !== _audioConnectNonce) return;
_monitoring = false;
@@ -2812,13 +2989,11 @@ const Waterfall = (function () {
async function stopMonitor({ resumeWaterfall = false } = {}) {
clearTimeout(_monitorRetuneTimer);
_audioConnectNonce += 1;
_pendingMonitorRetune = false;
try {
await fetch('/receiver/audio/stop', { method: 'POST' });
} catch (_) {
// Ignore backend stop errors
}
// Immediately pause audio and update the UI so the user gets instant
// feedback. The backend cleanup (which can block for 1-2 s while the
// SDR process group is reaped) happens afterwards.
_stopSmeter();
_setUnlockVisible(false);
_audioUnlockRequired = false;
@@ -2827,6 +3002,8 @@ const Waterfall = (function () {
_monitoring = false;
_monitorSource = 'process';
_pendingSharedMonitorRearm = false;
_pendingCaptureVfoMhz = null;
_pendingMonitorTuneMhz = null;
_syncMonitorButtons();
_setMonitorState('No audio monitor');
@@ -2836,6 +3013,13 @@ const Waterfall = (function () {
_setVisualStatus('READY');
}
// Backend stop is fire-and-forget; UI is already updated above.
try {
await fetch('/receiver/audio/stop', { method: 'POST' });
} catch (_) {
// Ignore backend stop errors
}
if (resumeWaterfall && _active) {
_resumeWaterfallAfterMonitor = false;
await start();
@@ -2983,6 +3167,8 @@ const Waterfall = (function () {
};
_ws.onclose = () => {
// stop() sets _ws = null before the async onclose fires.
if (!_ws) return;
if (!_wsOpened && _active) {
// Wait for timeout-based fallback; avoid flapping to SSE on brief close/retry.
_setStatus('WebSocket closed before ready. Waiting to retry/fallback...');
@@ -3006,9 +3192,19 @@ const Waterfall = (function () {
async function stop({ keepStatus = false } = {}) {
stopScan('Scan stopped', { silent: keepStatus });
clearTimeout(_retuneTimer);
clearTimeout(_monitorRetuneTimer);
_clearWsFallbackTimer();
_wsOpened = false;
_pendingSharedMonitorRearm = false;
_pendingCaptureVfoMhz = null;
_pendingMonitorTuneMhz = null;
// Reset in-flight monitor start flag so the button is not left
// disabled after a waterfall stop/restart cycle.
if (_startingMonitor) {
_audioConnectNonce += 1;
_startingMonitor = false;
_syncMonitorButtons();
}
if (_ws) {
try {
@@ -3076,7 +3272,8 @@ const Waterfall = (function () {
function stepFreq(multiplier) {
const step = _getNumber('wfStepSize', 0.1);
_setAndTune(_currentCenter() + multiplier * step, true);
// Coalesce rapid step-button presses into one final retune.
_setAndTune(_currentCenter() + multiplier * step, false);
}
function zoomBy(factor) {
@@ -3259,6 +3456,7 @@ const Waterfall = (function () {
_active = false;
clearTimeout(_retuneTimer);
clearTimeout(_monitorRetuneTimer);
_pendingMonitorRetune = false;
stopScan('Scan stopped', { silent: true });
_lastBins = null;
@@ -3280,6 +3478,8 @@ const Waterfall = (function () {
_setUnlockVisible(false);
_audioUnlockRequired = false;
_pendingSharedMonitorRearm = false;
_pendingCaptureVfoMhz = null;
_pendingMonitorTuneMhz = null;
_sseStartConfigKey = '';
_sseStartPromise = null;
}
File diff suppressed because it is too large Load Diff
+38 -2
View File
@@ -19,7 +19,6 @@ let websdrResizeHooked = false;
let websdrGlobeFallbackNotified = false;
const WEBSDR_GLOBE_SCRIPT_URLS = [
'https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js',
'https://cdn.jsdelivr.net/npm/globe.gl@2.33.1/dist/globe.gl.min.js',
];
const WEBSDR_GLOBE_TEXTURE_URL = '/static/images/globe/earth-dark.jpg';
@@ -186,8 +185,34 @@ async function ensureWebsdrGlobeLibrary() {
}
function loadWebsdrScript(src) {
const state = getSharedGlobeScriptState();
if (!state.promises[src]) {
state.promises[src] = loadSharedGlobeScript(src);
}
return state.promises[src].catch((error) => {
delete state.promises[src];
throw error;
});
}
function getSharedGlobeScriptState() {
const key = '__interceptGlobeScriptState';
if (!window[key]) {
window[key] = {
promises: Object.create(null),
};
}
return window[key];
}
function loadSharedGlobeScript(src) {
return new Promise((resolve, reject) => {
const selector = `script[data-websdr-src="${src}"]`;
const selector = [
`script[data-intercept-globe-src="${src}"]`,
`script[data-websdr-src="${src}"]`,
`script[data-gps-globe-src="${src}"]`,
`script[src="${src}"]`,
].join(', ');
const existing = document.querySelector(selector);
if (existing) {
@@ -208,6 +233,7 @@ function loadWebsdrScript(src) {
script.src = src;
script.async = true;
script.crossOrigin = 'anonymous';
script.dataset.interceptGlobeSrc = src;
script.dataset.websdrSrc = src;
script.onload = () => {
script.dataset.loaded = 'true';
@@ -979,6 +1005,15 @@ function escapeHtmlWebsdr(str) {
// ============== EXPORTS ==============
/**
* Destroy disconnect audio and clear S-meter timer for clean mode switching.
*/
function destroyWebSDR() {
disconnectFromReceiver();
}
const WebSDR = { destroy: destroyWebSDR };
window.initWebSDR = initWebSDR;
window.searchReceivers = searchReceivers;
window.selectReceiver = selectReceiver;
@@ -989,3 +1024,4 @@ window.disconnectFromReceiver = disconnectFromReceiver;
window.tuneKiwi = tuneKiwi;
window.tuneFromBar = tuneFromBar;
window.setKiwiVolume = setKiwiVolume;
window.WebSDR = WebSDR;
File diff suppressed because it is too large Load Diff
+395 -374
View File
@@ -28,9 +28,9 @@ const WiFiMode = (function() {
maxProbes: 1000,
};
// ==========================================================================
// Agent Support
// ==========================================================================
// ==========================================================================
// Agent Support
// ==========================================================================
/**
* Get the API base URL, routing through agent proxy if agent is selected.
@@ -59,49 +59,49 @@ const WiFiMode = (function() {
/**
* Check for agent mode conflicts before starting WiFi scan.
*/
function checkAgentConflicts() {
if (typeof currentAgent === 'undefined' || currentAgent === 'local') {
return true;
}
if (typeof checkAgentModeConflict === 'function') {
return checkAgentModeConflict('wifi');
}
return true;
}
function getChannelPresetList(preset) {
switch (preset) {
case '2.4-common':
return '1,6,11';
case '2.4-all':
return '1,2,3,4,5,6,7,8,9,10,11,12,13';
case '5-low':
return '36,40,44,48';
case '5-mid':
return '52,56,60,64';
case '5-high':
return '149,153,157,161,165';
default:
return '';
}
}
function buildChannelConfig() {
const preset = document.getElementById('wifiChannelPreset')?.value || '';
const listInput = document.getElementById('wifiChannelList')?.value || '';
const singleInput = document.getElementById('wifiChannel')?.value || '';
const listValue = listInput.trim();
const presetValue = getChannelPresetList(preset);
const channels = listValue || presetValue || '';
const channel = channels ? null : (singleInput.trim() ? parseInt(singleInput.trim()) : null);
return {
channels: channels || null,
channel: Number.isFinite(channel) ? channel : null,
};
}
function checkAgentConflicts() {
if (typeof currentAgent === 'undefined' || currentAgent === 'local') {
return true;
}
if (typeof checkAgentModeConflict === 'function') {
return checkAgentModeConflict('wifi');
}
return true;
}
function getChannelPresetList(preset) {
switch (preset) {
case '2.4-common':
return '1,6,11';
case '2.4-all':
return '1,2,3,4,5,6,7,8,9,10,11,12,13';
case '5-low':
return '36,40,44,48';
case '5-mid':
return '52,56,60,64';
case '5-high':
return '149,153,157,161,165';
default:
return '';
}
}
function buildChannelConfig() {
const preset = document.getElementById('wifiChannelPreset')?.value || '';
const listInput = document.getElementById('wifiChannelList')?.value || '';
const singleInput = document.getElementById('wifiChannel')?.value || '';
const listValue = listInput.trim();
const presetValue = getChannelPresetList(preset);
const channels = listValue || presetValue || '';
const channel = channels ? null : (singleInput.trim() ? parseInt(singleInput.trim()) : null);
return {
channels: channels || null,
channel: Number.isFinite(channel) ? channel : null,
};
}
// ==========================================================================
// State
@@ -120,23 +120,23 @@ const WiFiMode = (function() {
let channelStats = [];
let recommendations = [];
// UI state
let selectedNetwork = null;
let currentFilter = 'all';
let currentSort = { field: 'rssi', order: 'desc' };
let renderFramePending = false;
const pendingRender = {
table: false,
stats: false,
radar: false,
chart: false,
detail: false,
};
const listenersBound = {
scanTabs: false,
filters: false,
sort: false,
};
// UI state
let selectedNetwork = null;
let currentFilter = 'all';
let currentSort = { field: 'rssi', order: 'desc' };
let renderFramePending = false;
const pendingRender = {
table: false,
stats: false,
radar: false,
chart: false,
detail: false,
};
const listenersBound = {
scanTabs: false,
filters: false,
sort: false,
};
// Agent state
let showAllAgentsMode = false; // Show combined results from all agents
@@ -165,11 +165,11 @@ const WiFiMode = (function() {
// Initialize components
initScanModeTabs();
initNetworkFilters();
initSortControls();
initProximityRadar();
initChannelChart();
scheduleRender({ table: true, stats: true, radar: true, chart: true });
initNetworkFilters();
initSortControls();
initProximityRadar();
initChannelChart();
scheduleRender({ table: true, stats: true, radar: true, chart: true });
// Check if already scanning
checkScanStatus();
@@ -378,16 +378,16 @@ const WiFiMode = (function() {
// Scan Mode Tabs
// ==========================================================================
function initScanModeTabs() {
if (listenersBound.scanTabs) return;
if (elements.scanModeQuick) {
elements.scanModeQuick.addEventListener('click', () => setScanMode('quick'));
}
if (elements.scanModeDeep) {
elements.scanModeDeep.addEventListener('click', () => setScanMode('deep'));
}
listenersBound.scanTabs = true;
}
function initScanModeTabs() {
if (listenersBound.scanTabs) return;
if (elements.scanModeQuick) {
elements.scanModeQuick.addEventListener('click', () => setScanMode('quick'));
}
if (elements.scanModeDeep) {
elements.scanModeDeep.addEventListener('click', () => setScanMode('deep'));
}
listenersBound.scanTabs = true;
}
function setScanMode(mode) {
scanMode = mode;
@@ -511,10 +511,10 @@ const WiFiMode = (function() {
setScanning(true, 'deep');
try {
const iface = elements.interfaceSelect?.value || null;
const band = document.getElementById('wifiBand')?.value || 'all';
const channelConfig = buildChannelConfig();
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
const iface = elements.interfaceSelect?.value || null;
const band = document.getElementById('wifiBand')?.value || 'all';
const channelConfig = buildChannelConfig();
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
let response;
if (isAgentMode) {
@@ -523,25 +523,25 @@ const WiFiMode = (function() {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
interface: iface,
scan_type: 'deep',
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
channel: channelConfig.channel,
channels: channelConfig.channels,
}),
});
} else {
response = await fetch(`${CONFIG.apiBase}/scan/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
interface: iface,
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
channel: channelConfig.channel,
channels: channelConfig.channels,
}),
});
}
interface: iface,
scan_type: 'deep',
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
channel: channelConfig.channel,
channels: channelConfig.channels,
}),
});
} else {
response = await fetch(`${CONFIG.apiBase}/scan/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
interface: iface,
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
channel: channelConfig.channel,
channels: channelConfig.channels,
}),
});
}
if (!response.ok) {
const error = await response.json();
@@ -572,8 +572,8 @@ const WiFiMode = (function() {
}
}
async function stopScan() {
console.log('[WiFiMode] Stopping scan...');
async function stopScan() {
console.log('[WiFiMode] Stopping scan...');
// Stop polling
if (pollTimer) {
@@ -585,41 +585,41 @@ const WiFiMode = (function() {
stopAgentDeepScanPolling();
// Close event stream
if (eventSource) {
eventSource.close();
eventSource = null;
}
// Update UI immediately so mode transitions are responsive even if the
// backend needs extra time to terminate subprocesses.
setScanning(false);
// Stop scan on server (local or agent)
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
const timeoutMs = isAgentMode ? 8000 : 2200;
const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null;
const timeoutId = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
try {
if (isAgentMode) {
await fetch(`/controller/agents/${currentAgent}/wifi/stop`, {
method: 'POST',
...(controller ? { signal: controller.signal } : {}),
});
} else if (scanMode === 'deep') {
await fetch(`${CONFIG.apiBase}/scan/stop`, {
method: 'POST',
...(controller ? { signal: controller.signal } : {}),
});
}
} catch (error) {
console.warn('[WiFiMode] Error stopping scan:', error);
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}
if (eventSource) {
eventSource.close();
eventSource = null;
}
// Update UI immediately so mode transitions are responsive even if the
// backend needs extra time to terminate subprocesses.
setScanning(false);
// Stop scan on server (local or agent)
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
const timeoutMs = isAgentMode ? 8000 : 2200;
const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null;
const timeoutId = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
try {
if (isAgentMode) {
await fetch(`/controller/agents/${currentAgent}/wifi/stop`, {
method: 'POST',
...(controller ? { signal: controller.signal } : {}),
});
} else if (scanMode === 'deep') {
await fetch(`${CONFIG.apiBase}/scan/stop`, {
method: 'POST',
...(controller ? { signal: controller.signal } : {}),
});
}
} catch (error) {
console.warn('[WiFiMode] Error stopping scan:', error);
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}
function setScanning(scanning, mode = null) {
isScanning = scanning;
@@ -713,10 +713,10 @@ const WiFiMode = (function() {
}, CONFIG.pollInterval);
}
function processQuickScanResult(result) {
// Update networks
result.access_points.forEach(ap => {
networks.set(ap.bssid, ap);
function processQuickScanResult(result) {
// Update networks
result.access_points.forEach(ap => {
networks.set(ap.bssid, ap);
});
// Update channel stats (calculate from networks if not provided by API)
@@ -724,12 +724,12 @@ const WiFiMode = (function() {
recommendations = result.recommendations || [];
// If no channel stats from API, calculate from networks
if (channelStats.length === 0 && networks.size > 0) {
channelStats = calculateChannelStats();
}
// Update UI
scheduleRender({ table: true, stats: true, radar: true, chart: true });
if (channelStats.length === 0 && networks.size > 0) {
channelStats = calculateChannelStats();
}
// Update UI
scheduleRender({ table: true, stats: true, radar: true, chart: true });
// Callbacks
result.access_points.forEach(ap => {
@@ -938,25 +938,25 @@ const WiFiMode = (function() {
}
}
function handleNetworkUpdate(network) {
networks.set(network.bssid, network);
scheduleRender({
table: true,
stats: true,
radar: true,
chart: true,
detail: selectedNetwork === network.bssid,
});
if (onNetworkUpdate) onNetworkUpdate(network);
}
function handleClientUpdate(client) {
clients.set(client.mac, client);
scheduleRender({ stats: true });
// Update client display if this client belongs to the selected network
updateClientInList(client);
function handleNetworkUpdate(network) {
networks.set(network.bssid, network);
scheduleRender({
table: true,
stats: true,
radar: true,
chart: true,
detail: selectedNetwork === network.bssid,
});
if (onNetworkUpdate) onNetworkUpdate(network);
}
function handleClientUpdate(client) {
clients.set(client.mac, client);
scheduleRender({ stats: true });
// Update client display if this client belongs to the selected network
updateClientInList(client);
if (onClientUpdate) onClientUpdate(client);
}
@@ -970,37 +970,37 @@ const WiFiMode = (function() {
if (onProbeRequest) onProbeRequest(probe);
}
function handleHiddenRevealed(bssid, revealedSsid) {
const network = networks.get(bssid);
if (network) {
network.revealed_essid = revealedSsid;
network.display_name = `${revealedSsid} (revealed)`;
scheduleRender({
table: true,
detail: selectedNetwork === bssid,
});
// Show notification
showInfo(`Hidden SSID revealed: ${revealedSsid}`);
}
}
function handleHiddenRevealed(bssid, revealedSsid) {
const network = networks.get(bssid);
if (network) {
network.revealed_essid = revealedSsid;
network.display_name = `${revealedSsid} (revealed)`;
scheduleRender({
table: true,
detail: selectedNetwork === bssid,
});
// Show notification
showInfo(`Hidden SSID revealed: ${revealedSsid}`);
}
}
// ==========================================================================
// Network Table
// ==========================================================================
function initNetworkFilters() {
if (listenersBound.filters) return;
if (!elements.networkFilters) return;
elements.networkFilters.addEventListener('click', (e) => {
if (e.target.matches('.wifi-filter-btn')) {
const filter = e.target.dataset.filter;
setNetworkFilter(filter);
}
});
listenersBound.filters = true;
}
function initNetworkFilters() {
if (listenersBound.filters) return;
if (!elements.networkFilters) return;
elements.networkFilters.addEventListener('click', (e) => {
if (e.target.matches('.wifi-filter-btn')) {
const filter = e.target.dataset.filter;
setNetworkFilter(filter);
}
});
listenersBound.filters = true;
}
function setNetworkFilter(filter) {
currentFilter = filter;
@@ -1015,11 +1015,11 @@ const WiFiMode = (function() {
updateNetworkTable();
}
function initSortControls() {
if (listenersBound.sort) return;
if (!elements.networkTable) return;
elements.networkTable.addEventListener('click', (e) => {
function initSortControls() {
if (listenersBound.sort) return;
if (!elements.networkTable) return;
elements.networkTable.addEventListener('click', (e) => {
const th = e.target.closest('th[data-sort]');
if (th) {
const field = th.dataset.sort;
@@ -1029,54 +1029,54 @@ const WiFiMode = (function() {
currentSort.field = field;
currentSort.order = 'desc';
}
updateNetworkTable();
}
});
if (elements.networkTableBody) {
elements.networkTableBody.addEventListener('click', (e) => {
const row = e.target.closest('tr[data-bssid]');
if (!row) return;
selectNetwork(row.dataset.bssid);
});
}
listenersBound.sort = true;
}
function scheduleRender(flags = {}) {
pendingRender.table = pendingRender.table || Boolean(flags.table);
pendingRender.stats = pendingRender.stats || Boolean(flags.stats);
pendingRender.radar = pendingRender.radar || Boolean(flags.radar);
pendingRender.chart = pendingRender.chart || Boolean(flags.chart);
pendingRender.detail = pendingRender.detail || Boolean(flags.detail);
if (renderFramePending) return;
renderFramePending = true;
requestAnimationFrame(() => {
renderFramePending = false;
if (pendingRender.table) updateNetworkTable();
if (pendingRender.stats) updateStats();
if (pendingRender.radar) updateProximityRadar();
if (pendingRender.chart) updateChannelChart();
if (pendingRender.detail && selectedNetwork) {
updateDetailPanel(selectedNetwork, { refreshClients: false });
}
pendingRender.table = false;
pendingRender.stats = false;
pendingRender.radar = false;
pendingRender.chart = false;
pendingRender.detail = false;
});
}
function updateNetworkTable() {
if (!elements.networkTableBody) return;
// Filter networks
let filtered = Array.from(networks.values());
updateNetworkTable();
}
});
if (elements.networkTableBody) {
elements.networkTableBody.addEventListener('click', (e) => {
const row = e.target.closest('tr[data-bssid]');
if (!row) return;
selectNetwork(row.dataset.bssid);
});
}
listenersBound.sort = true;
}
function scheduleRender(flags = {}) {
pendingRender.table = pendingRender.table || Boolean(flags.table);
pendingRender.stats = pendingRender.stats || Boolean(flags.stats);
pendingRender.radar = pendingRender.radar || Boolean(flags.radar);
pendingRender.chart = pendingRender.chart || Boolean(flags.chart);
pendingRender.detail = pendingRender.detail || Boolean(flags.detail);
if (renderFramePending) return;
renderFramePending = true;
requestAnimationFrame(() => {
renderFramePending = false;
if (pendingRender.table) updateNetworkTable();
if (pendingRender.stats) updateStats();
if (pendingRender.radar) updateProximityRadar();
if (pendingRender.chart) updateChannelChart();
if (pendingRender.detail && selectedNetwork) {
updateDetailPanel(selectedNetwork, { refreshClients: false });
}
pendingRender.table = false;
pendingRender.stats = false;
pendingRender.radar = false;
pendingRender.chart = false;
pendingRender.detail = false;
});
}
function updateNetworkTable() {
if (!elements.networkTableBody) return;
// Filter networks
let filtered = Array.from(networks.values());
switch (currentFilter) {
case 'hidden':
@@ -1126,44 +1126,44 @@ const WiFiMode = (function() {
return bVal > aVal ? 1 : bVal < aVal ? -1 : 0;
} else {
return aVal > bVal ? 1 : aVal < bVal ? -1 : 0;
}
});
if (filtered.length === 0) {
let message = 'Start scanning to discover networks';
let type = 'empty';
if (isScanning) {
message = 'Scanning for networks...';
type = 'loading';
} else if (networks.size > 0) {
message = 'No networks match current filters';
}
if (typeof renderCollectionState === 'function') {
renderCollectionState(elements.networkTableBody, {
type,
message,
columns: 7,
});
} else {
elements.networkTableBody.innerHTML = `<tr class="wifi-network-placeholder"><td colspan="7"><div class="placeholder-text">${escapeHtml(message)}</div></td></tr>`;
}
return;
}
// Render table
elements.networkTableBody.innerHTML = filtered.map(n => createNetworkRow(n)).join('');
}
}
});
function createNetworkRow(network) {
const rssi = network.rssi_current;
const security = network.security || 'Unknown';
const signalClass = rssi >= -50 ? 'signal-strong' :
rssi >= -70 ? 'signal-medium' :
rssi >= -85 ? 'signal-weak' : 'signal-very-weak';
const securityClass = security === 'Open' ? 'security-open' :
security === 'WEP' ? 'security-wep' :
security.includes('WPA3') ? 'security-wpa3' : 'security-wpa';
if (filtered.length === 0) {
let message = 'Start scanning to discover networks';
let type = 'empty';
if (isScanning) {
message = 'Scanning for networks...';
type = 'loading';
} else if (networks.size > 0) {
message = 'No networks match current filters';
}
if (typeof renderCollectionState === 'function') {
renderCollectionState(elements.networkTableBody, {
type,
message,
columns: 7,
});
} else {
elements.networkTableBody.innerHTML = `<tr class="wifi-network-placeholder"><td colspan="7"><div class="placeholder-text">${escapeHtml(message)}</div></td></tr>`;
}
return;
}
// Render table
elements.networkTableBody.innerHTML = filtered.map(n => createNetworkRow(n)).join('');
}
function createNetworkRow(network) {
const rssi = network.rssi_current;
const security = network.security || 'Unknown';
const signalClass = rssi >= -50 ? 'signal-strong' :
rssi >= -70 ? 'signal-medium' :
rssi >= -85 ? 'signal-weak' : 'signal-very-weak';
const securityClass = security === 'Open' ? 'security-open' :
security === 'WEP' ? 'security-wep' :
security.includes('WPA3') ? 'security-wpa3' : 'security-wpa';
const hiddenBadge = network.is_hidden ? '<span class="badge badge-hidden">Hidden</span>' : '';
const newBadge = network.is_new ? '<span class="badge badge-new">New</span>' : '';
@@ -1172,25 +1172,25 @@ const WiFiMode = (function() {
const agentName = network._agent || 'Local';
const agentClass = agentName === 'Local' ? 'agent-local' : 'agent-remote';
return `
<tr class="wifi-network-row ${network.bssid === selectedNetwork ? 'selected' : ''}"
data-bssid="${escapeHtml(network.bssid)}"
role="button"
tabindex="0"
data-keyboard-activate="true"
aria-label="Select network ${escapeHtml(network.display_name || network.essid || '[Hidden]')}">
<td class="col-essid">
<span class="essid">${escapeHtml(network.display_name || network.essid || '[Hidden]')}</span>
${hiddenBadge}${newBadge}
</td>
return `
<tr class="wifi-network-row ${network.bssid === selectedNetwork ? 'selected' : ''}"
data-bssid="${escapeHtml(network.bssid)}"
role="button"
tabindex="0"
data-keyboard-activate="true"
aria-label="Select network ${escapeHtml(network.display_name || network.essid || '[Hidden]')}">
<td class="col-essid">
<span class="essid">${escapeHtml(network.display_name || network.essid || '[Hidden]')}</span>
${hiddenBadge}${newBadge}
</td>
<td class="col-bssid"><code>${escapeHtml(network.bssid)}</code></td>
<td class="col-channel">${network.channel || '-'}</td>
<td class="col-rssi">
<span class="rssi-value ${signalClass}">${rssi != null ? rssi : '-'}</span>
</td>
<td class="col-security">
<span class="security-badge ${securityClass}">${escapeHtml(security)}</span>
</td>
<td class="col-rssi">
<span class="rssi-value ${signalClass}">${rssi != null ? rssi : '-'}</span>
</td>
<td class="col-security">
<span class="security-badge ${securityClass}">${escapeHtml(security)}</span>
</td>
<td class="col-clients">${network.client_count || 0}</td>
<td class="col-agent">
<span class="agent-badge ${agentClass}">${escapeHtml(agentName)}</span>
@@ -1199,12 +1199,12 @@ const WiFiMode = (function() {
`;
}
function updateNetworkRow(network) {
scheduleRender({
table: true,
detail: selectedNetwork === network.bssid,
});
}
function updateNetworkRow(network) {
scheduleRender({
table: true,
detail: selectedNetwork === network.bssid,
});
}
function selectNetwork(bssid) {
selectedNetwork = bssid;
@@ -1227,9 +1227,9 @@ const WiFiMode = (function() {
// Detail Panel
// ==========================================================================
function updateDetailPanel(bssid, options = {}) {
const { refreshClients = true } = options;
if (!elements.detailDrawer) return;
function updateDetailPanel(bssid, options = {}) {
const { refreshClients = true } = options;
if (!elements.detailDrawer) return;
const network = networks.get(bssid);
if (!network) {
@@ -1274,11 +1274,11 @@ const WiFiMode = (function() {
// Show the drawer
elements.detailDrawer.classList.add('open');
// Fetch and display clients for this network
if (refreshClients) {
fetchClientsForNetwork(network.bssid);
}
}
// Fetch and display clients for this network
if (refreshClients) {
fetchClientsForNetwork(network.bssid);
}
}
function closeDetail() {
selectedNetwork = null;
@@ -1294,18 +1294,18 @@ const WiFiMode = (function() {
// Client Display
// ==========================================================================
async function fetchClientsForNetwork(bssid) {
if (!elements.detailClientList) return;
const listContainer = elements.detailClientList.querySelector('.wifi-client-list');
if (listContainer && typeof renderCollectionState === 'function') {
renderCollectionState(listContainer, { type: 'loading', message: 'Loading clients...' });
elements.detailClientList.style.display = 'block';
}
try {
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
let response;
async function fetchClientsForNetwork(bssid) {
if (!elements.detailClientList) return;
const listContainer = elements.detailClientList.querySelector('.wifi-client-list');
if (listContainer && typeof renderCollectionState === 'function') {
renderCollectionState(listContainer, { type: 'loading', message: 'Loading clients...' });
elements.detailClientList.style.display = 'block';
}
try {
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
let response;
if (isAgentMode) {
// Route through agent proxy
@@ -1314,44 +1314,44 @@ const WiFiMode = (function() {
response = await fetch(`${CONFIG.apiBase}/clients?bssid=${encodeURIComponent(bssid)}&associated=true`);
}
if (!response.ok) {
if (listContainer && typeof renderCollectionState === 'function') {
renderCollectionState(listContainer, { type: 'empty', message: 'Client list unavailable' });
elements.detailClientList.style.display = 'block';
} else {
elements.detailClientList.style.display = 'none';
}
return;
}
if (!response.ok) {
if (listContainer && typeof renderCollectionState === 'function') {
renderCollectionState(listContainer, { type: 'empty', message: 'Client list unavailable' });
elements.detailClientList.style.display = 'block';
} else {
elements.detailClientList.style.display = 'none';
}
return;
}
const data = await response.json();
// Handle agent response format (may be nested in 'result')
const result = isAgentMode && data.result ? data.result : data;
const clientList = result.clients || [];
if (clientList.length > 0) {
renderClientList(clientList, bssid);
elements.detailClientList.style.display = 'block';
} else {
const countBadge = document.getElementById('wifiClientCountBadge');
if (countBadge) countBadge.textContent = '0';
if (listContainer && typeof renderCollectionState === 'function') {
renderCollectionState(listContainer, { type: 'empty', message: 'No associated clients' });
elements.detailClientList.style.display = 'block';
} else {
elements.detailClientList.style.display = 'none';
}
}
} catch (error) {
console.debug('[WiFiMode] Error fetching clients:', error);
if (listContainer && typeof renderCollectionState === 'function') {
renderCollectionState(listContainer, { type: 'empty', message: 'Client list unavailable' });
elements.detailClientList.style.display = 'block';
} else {
elements.detailClientList.style.display = 'none';
}
}
}
if (clientList.length > 0) {
renderClientList(clientList, bssid);
elements.detailClientList.style.display = 'block';
} else {
const countBadge = document.getElementById('wifiClientCountBadge');
if (countBadge) countBadge.textContent = '0';
if (listContainer && typeof renderCollectionState === 'function') {
renderCollectionState(listContainer, { type: 'empty', message: 'No associated clients' });
elements.detailClientList.style.display = 'block';
} else {
elements.detailClientList.style.display = 'none';
}
}
} catch (error) {
console.debug('[WiFiMode] Error fetching clients:', error);
if (listContainer && typeof renderCollectionState === 'function') {
renderCollectionState(listContainer, { type: 'empty', message: 'Client list unavailable' });
elements.detailClientList.style.display = 'block';
} else {
elements.detailClientList.style.display = 'none';
}
}
}
function renderClientList(clientList, bssid) {
const container = elements.detailClientList?.querySelector('.wifi-client-list');
@@ -1708,16 +1708,16 @@ const WiFiMode = (function() {
/**
* Clear all collected data.
*/
function clearData() {
networks.clear();
clients.clear();
probeRequests = [];
channelStats = [];
recommendations = [];
if (selectedNetwork) {
closeDetail();
}
scheduleRender({ table: true, stats: true, radar: true, chart: true });
function clearData() {
networks.clear();
clients.clear();
probeRequests = [];
channelStats = [];
recommendations = [];
if (selectedNetwork) {
closeDetail();
}
scheduleRender({ table: true, stats: true, radar: true, chart: true });
}
/**
@@ -1763,12 +1763,12 @@ const WiFiMode = (function() {
clientsToRemove.push(mac);
}
});
clientsToRemove.forEach(mac => clients.delete(mac));
if (selectedNetwork && !networks.has(selectedNetwork)) {
closeDetail();
}
scheduleRender({ table: true, stats: true, radar: true, chart: true });
}
clientsToRemove.forEach(mac => clients.delete(mac));
if (selectedNetwork && !networks.has(selectedNetwork)) {
closeDetail();
}
scheduleRender({ table: true, stats: true, radar: true, chart: true });
}
/**
* Refresh WiFi interfaces from current agent.
@@ -1811,7 +1811,28 @@ const WiFiMode = (function() {
onNetworkUpdate: (cb) => { onNetworkUpdate = cb; },
onClientUpdate: (cb) => { onClientUpdate = cb; },
onProbeRequest: (cb) => { onProbeRequest = cb; },
// Lifecycle
destroy,
};
/**
* Destroy close SSE stream and clear polling timers for clean mode switching.
*/
function destroy() {
if (eventSource) {
eventSource.close();
eventSource = null;
}
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
if (agentPollTimer) {
clearInterval(agentPollTimer);
agentPollTimer = null;
}
}
})();
// Auto-initialize when DOM is ready
+198 -75
View File
@@ -419,6 +419,7 @@
let agentPollTimer = null; // Polling fallback for agent mode
let isTracking = false;
let currentFilter = 'all';
// ICAO -> { emergency: bool, watchlist: bool, military: bool }
let alertedAircraft = {};
let alertsEnabled = true;
let detectionSoundEnabled = localStorage.getItem('adsb_detectionSound') !== 'false'; // Default on
@@ -668,24 +669,64 @@
}
}
function speakAircraftAlert(kind, icao, ac, detail) {
if (typeof VoiceAlerts === 'undefined' || typeof VoiceAlerts.speak !== 'function') return;
const cfg = (typeof VoiceAlerts.getConfig === 'function')
? VoiceAlerts.getConfig()
: { streams: {} };
const streams = cfg && cfg.streams ? cfg.streams : {};
const callsign = (ac && ac.callsign ? String(ac.callsign).trim() : '') || icao;
if (kind === 'emergency') {
if (streams.squawks === false) return;
const squawk = detail && detail.squawk ? ` squawk ${detail.squawk}.` : '.';
const meaning = detail && detail.name ? ` ${detail.name}.` : '';
VoiceAlerts.speak(`Aircraft emergency: ${callsign}.${squawk}${meaning}`, VoiceAlerts.PRIORITY.HIGH);
return;
}
if (kind === 'military') {
if (streams.adsb_military === false) return;
const country = detail && detail.country ? ` ${detail.country}.` : '';
VoiceAlerts.speak(`Military aircraft detected: ${callsign}.${country}`, VoiceAlerts.PRIORITY.HIGH);
}
}
function checkAndAlertAircraft(icao, ac) {
if (alertedAircraft[icao]) return;
if (!alertedAircraft[icao]) {
alertedAircraft[icao] = { emergency: false, watchlist: false, military: false };
}
const alertState = alertedAircraft[icao];
const militaryInfo = isMilitaryAircraft(icao, ac.callsign);
const squawkInfo = checkSquawkCode(ac);
const onWatchlist = isOnWatchlist(ac);
if (squawkInfo && squawkInfo.type === 'emergency') {
alertedAircraft[icao] = 'emergency';
playAlertSound('emergency');
showAlertBanner(`EMERGENCY: ${squawkInfo.name} - ${ac.callsign || icao}`, '#ff0000');
} else if (onWatchlist) {
alertedAircraft[icao] = 'watchlist';
if (!alertState.emergency) {
alertState.emergency = true;
playAlertSound('emergency');
showAlertBanner(`EMERGENCY: ${squawkInfo.name} - ${ac.callsign || icao}`, '#ff0000');
speakAircraftAlert('emergency', icao, ac, {
squawk: ac.squawk,
name: squawkInfo.name,
});
}
return;
}
if (onWatchlist && !alertState.watchlist) {
alertState.watchlist = true;
playAlertSound('military'); // Use military sound for watchlist
showAlertBanner(`WATCHLIST: ${ac.callsign || ac.registration || icao} detected!`, '#00d4ff');
} else if (militaryInfo.military) {
alertedAircraft[icao] = 'military';
} else if (militaryInfo.military && !alertState.military) {
alertState.military = true;
playAlertSound('military');
showAlertBanner(`MILITARY: ${ac.callsign || icao}${militaryInfo.country ? ' (' + militaryInfo.country + ')' : ''}`, '#556b2f');
speakAircraftAlert('military', icao, ac, {
country: militaryInfo.country || null,
});
}
}
@@ -1711,31 +1752,37 @@ ACARS: ${r.statistics.acarsMessages} messages`;
airbandSelect.innerHTML = '';
if (devices.length === 0) {
adsbSelect.innerHTML = '<option value="0">No SDR found</option>';
airbandSelect.innerHTML = '<option value="0">No SDR found</option>';
adsbSelect.innerHTML = '<option value="rtlsdr:0">No SDR found</option>';
airbandSelect.innerHTML = '<option value="rtlsdr:0">No SDR found</option>';
airbandSelect.disabled = true;
} else {
devices.forEach((dev, i) => {
const idx = dev.index !== undefined ? dev.index : i;
const sdrType = dev.sdr_type || 'rtlsdr';
const compositeVal = `${sdrType}:${idx}`;
const displayName = `SDR ${idx}: ${dev.name}`;
// Add to ADS-B selector
const adsbOpt = document.createElement('option');
adsbOpt.value = idx;
adsbOpt.value = compositeVal;
adsbOpt.dataset.sdrType = sdrType;
adsbOpt.dataset.index = idx;
adsbOpt.textContent = displayName;
adsbSelect.appendChild(adsbOpt);
// Add to Airband selector
const airbandOpt = document.createElement('option');
airbandOpt.value = idx;
airbandOpt.value = compositeVal;
airbandOpt.dataset.sdrType = sdrType;
airbandOpt.dataset.index = idx;
airbandOpt.textContent = displayName;
airbandSelect.appendChild(airbandOpt);
});
// Default: ADS-B uses first device, Airband uses second (if available)
adsbSelect.value = devices[0].index !== undefined ? devices[0].index : 0;
adsbSelect.value = adsbSelect.options[0]?.value || 'rtlsdr:0';
if (devices.length > 1) {
airbandSelect.value = devices[1].index !== undefined ? devices[1].index : 1;
airbandSelect.value = airbandSelect.options[1]?.value || airbandSelect.options[0]?.value || 'rtlsdr:0';
}
// Show warning if only one device
@@ -1746,8 +1793,8 @@ ACARS: ${r.statistics.acarsMessages} messages`;
}
})
.catch(() => {
document.getElementById('adsbDeviceSelect').innerHTML = '<option value="0">Error</option>';
document.getElementById('airbandDeviceSelect').innerHTML = '<option value="0">Error</option>';
document.getElementById('adsbDeviceSelect').innerHTML = '<option value="rtlsdr:0">Error</option>';
document.getElementById('airbandDeviceSelect').innerHTML = '<option value="rtlsdr:0">Error</option>';
});
}
@@ -2110,11 +2157,14 @@ sudo make install</code>
}
}
// Get selected ADS-B device
const adsbDevice = parseInt(document.getElementById('adsbDeviceSelect').value) || 0;
// Get selected ADS-B device (composite value "sdr_type:index")
const adsbSelectVal = document.getElementById('adsbDeviceSelect').value || 'rtlsdr:0';
const [adsbSdrType, adsbDeviceIdx] = adsbSelectVal.includes(':') ? adsbSelectVal.split(':') : ['rtlsdr', adsbSelectVal];
const adsbDevice = parseInt(adsbDeviceIdx) || 0;
const requestBody = {
device: adsbDevice,
sdr_type: adsbSdrType,
bias_t: getBiasTEnabled()
};
if (remoteConfig) {
@@ -2173,21 +2223,44 @@ sudo make install</code>
}
} else {
try {
// Route stop through agent proxy if using remote agent
const url = useAgent
? `/controller/agents/${adsbCurrentAgent}/adsb/stop`
// Route stop through the source that actually started tracking.
const stopSource = adsbTrackingSource || (useAgent ? adsbCurrentAgent : 'local');
const stopViaAgent = stopSource !== null && stopSource !== undefined && stopSource !== 'local';
const url = stopViaAgent
? `/controller/agents/${stopSource}/adsb/stop`
: '/adsb/stop';
await fetch(url, {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
body: JSON.stringify({ source: 'adsb_dashboard' })
});
const text = await response.text();
let data = {};
if (text) {
try {
data = JSON.parse(text);
} catch (e) {
throw new Error(`Invalid response: ${text}`);
}
}
const result = stopViaAgent && data.result ? data.result : data;
const stopped = response.ok && (
result.status === 'stopped' ||
result.status === 'success' ||
data.status === 'success'
);
if (!stopped) {
throw new Error(result.message || data.message || `HTTP ${response.status}`);
}
// Update agent running modes tracking
if (useAgent && typeof agentRunningModes !== 'undefined') {
if (stopViaAgent && typeof agentRunningModes !== 'undefined') {
agentRunningModes = agentRunningModes.filter(m => m !== 'adsb');
}
} catch (err) {}
} catch (err) {
alert('Failed to stop ADS-B: ' + err.message);
return;
}
stopEventStream();
isTracking = false;
@@ -2242,11 +2315,13 @@ sudo make install</code>
}
const sessionDevice = session.device_index;
const sessionSdrType = session.sdr_type || 'rtlsdr';
if (sessionDevice !== null && sessionDevice !== undefined) {
adsbActiveDevice = sessionDevice;
const adsbSelect = document.getElementById('adsbDeviceSelect');
if (adsbSelect) {
adsbSelect.value = sessionDevice;
// Use composite value to select the correct device+type
adsbSelect.value = `${sessionSdrType}:${sessionDevice}`;
}
}
@@ -2340,16 +2415,17 @@ sudo make install</code>
function startEventStream() {
if (eventSource) eventSource.close();
const useAgent = typeof adsbCurrentAgent !== 'undefined' && adsbCurrentAgent !== 'local';
const activeSource = (isTracking && adsbTrackingSource) ? adsbTrackingSource : adsbCurrentAgent;
const useAgent = typeof activeSource !== 'undefined' && activeSource !== null && activeSource !== 'local';
const streamUrl = useAgent ? '/controller/stream/all' : '/adsb/stream';
console.log(`[ADS-B] startEventStream called - adsbCurrentAgent=${adsbCurrentAgent}, useAgent=${useAgent}, streamUrl=${streamUrl}`);
console.log(`[ADS-B] startEventStream called - activeSource=${activeSource}, useAgent=${useAgent}, streamUrl=${streamUrl}`);
eventSource = new EventSource(streamUrl);
// Get agent name for filtering multi-agent stream
let targetAgentName = null;
if (useAgent && typeof agents !== 'undefined') {
const agent = agents.find(a => a.id == adsbCurrentAgent);
const agent = agents.find(a => a.id == activeSource);
targetAgentName = agent ? agent.name : null;
}
@@ -3598,8 +3674,9 @@ sudo make install</code>
function startAcars() {
const acarsSelect = document.getElementById('acarsDeviceSelect');
const device = acarsSelect.value;
const sdr_type = acarsSelect.selectedOptions[0]?.dataset.sdrType || 'rtlsdr';
const compositeVal = acarsSelect.value || 'rtlsdr:0';
const [sdr_type, deviceIdx] = compositeVal.includes(':') ? compositeVal.split(':') : ['rtlsdr', compositeVal];
const device = deviceIdx;
const frequencies = getAcarsRegionFreqs();
// Check if using agent mode
@@ -3831,18 +3908,21 @@ sudo make install</code>
const select = document.getElementById('acarsDeviceSelect');
select.innerHTML = '';
if (devices.length === 0) {
select.innerHTML = '<option value="0">No SDR detected</option>';
select.innerHTML = '<option value="rtlsdr:0">No SDR detected</option>';
} else {
devices.forEach((d, i) => {
const opt = document.createElement('option');
opt.value = d.index || i;
opt.dataset.sdrType = d.sdr_type || 'rtlsdr';
opt.textContent = `SDR ${d.index || i}: ${d.name || d.type || 'SDR'}`;
const sdrType = d.sdr_type || 'rtlsdr';
const idx = d.index !== undefined ? d.index : i;
opt.value = `${sdrType}:${idx}`;
opt.dataset.sdrType = sdrType;
opt.dataset.index = idx;
opt.textContent = `SDR ${idx}: ${d.name || d.type || 'SDR'}`;
select.appendChild(opt);
});
// Default to device 1 if available (device 0 likely used for ADS-B)
if (devices.length > 1) {
select.value = '1';
select.value = select.options[1]?.value || select.options[0]?.value;
}
}
});
@@ -3933,8 +4013,9 @@ sudo make install</code>
function startVdl2() {
const vdl2Select = document.getElementById('vdl2DeviceSelect');
const device = vdl2Select.value;
const sdr_type = vdl2Select.selectedOptions[0]?.dataset.sdrType || 'rtlsdr';
const compositeVal = vdl2Select.value || 'rtlsdr:0';
const [sdr_type, deviceIdx] = compositeVal.includes(':') ? compositeVal.split(':') : ['rtlsdr', compositeVal];
const device = deviceIdx;
const frequencies = getVdl2RegionFreqs();
// Check if using agent mode
@@ -3982,30 +4063,56 @@ sudo make install</code>
.catch(err => alert('VDL2 Error: ' + err));
}
function stopVdl2() {
const isAgentMode = vdl2CurrentAgent !== null;
async function stopVdl2() {
const sourceAgentId = vdl2CurrentAgent;
const isAgentMode = sourceAgentId !== null && sourceAgentId !== undefined;
const endpoint = isAgentMode
? `/controller/agents/${vdl2CurrentAgent}/vdl2/stop`
? `/controller/agents/${sourceAgentId}/vdl2/stop`
: '/vdl2/stop';
fetch(endpoint, { method: 'POST' })
.then(r => r.json())
.then(() => {
isVdl2Running = false;
vdl2CurrentAgent = null;
document.getElementById('vdl2ToggleBtn').innerHTML = '&#9654; START VDL2';
document.getElementById('vdl2ToggleBtn').classList.remove('active');
document.getElementById('vdl2PanelIndicator').classList.remove('active');
if (vdl2EventSource) {
vdl2EventSource.close();
vdl2EventSource = null;
}
// Clear polling timer
if (vdl2PollTimer) {
clearInterval(vdl2PollTimer);
vdl2PollTimer = null;
}
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source: 'adsb_dashboard' })
});
const text = await response.text();
let data = {};
if (text) {
try {
data = JSON.parse(text);
} catch (e) {
throw new Error(`Invalid response: ${text}`);
}
}
const result = isAgentMode && data.result ? data.result : data;
const stopped = response.ok && (
result.status === 'stopped' ||
result.status === 'success' ||
data.status === 'success'
);
if (!stopped) {
throw new Error(result.message || data.message || `HTTP ${response.status}`);
}
isVdl2Running = false;
vdl2CurrentAgent = null;
document.getElementById('vdl2ToggleBtn').innerHTML = '&#9654; START VDL2';
document.getElementById('vdl2ToggleBtn').classList.remove('active');
document.getElementById('vdl2PanelIndicator').classList.remove('active');
if (vdl2EventSource) {
vdl2EventSource.close();
vdl2EventSource = null;
}
// Clear polling timer
if (vdl2PollTimer) {
clearInterval(vdl2PollTimer);
vdl2PollTimer = null;
}
} catch (err) {
alert('Failed to stop VDL2: ' + err.message);
}
}
// Sync VDL2 UI state (called by syncModeUI in agents.js)
@@ -4023,6 +4130,7 @@ sudo make install</code>
startVdl2Stream(agentId !== null);
}
} else {
vdl2CurrentAgent = null;
btn.innerHTML = '&#9654; START VDL2';
btn.classList.remove('active');
if (indicator) indicator.classList.remove('active');
@@ -4327,17 +4435,20 @@ sudo make install</code>
const select = document.getElementById('vdl2DeviceSelect');
select.innerHTML = '';
if (devices.length === 0) {
select.innerHTML = '<option value="0">No SDR detected</option>';
select.innerHTML = '<option value="rtlsdr:0">No SDR detected</option>';
} else {
devices.forEach((d, i) => {
const opt = document.createElement('option');
opt.value = d.index || i;
opt.dataset.sdrType = d.sdr_type || 'rtlsdr';
opt.textContent = `SDR ${d.index || i}: ${d.name || d.type || 'SDR'}`;
const sdrType = d.sdr_type || 'rtlsdr';
const idx = d.index !== undefined ? d.index : i;
opt.value = `${sdrType}:${idx}`;
opt.dataset.sdrType = sdrType;
opt.dataset.index = idx;
opt.textContent = `SDR ${idx}: ${d.name || d.type || 'SDR'}`;
select.appendChild(opt);
});
if (devices.length > 1) {
select.value = '1';
select.value = select.options[1]?.value || select.options[0]?.value;
}
}
});
@@ -5037,7 +5148,13 @@ sudo make install</code>
<!-- Help Modal -->
{% include 'partials/help-modal.html' %}
<script src="{{ url_for('static', filename='js/core/voice-alerts.js') }}?v={{ version }}&r=adsbvoice1"></script>
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}?v={{ version }}&r=maptheme17"></script>
<script>
window.addEventListener('DOMContentLoaded', () => {
if (typeof VoiceAlerts !== 'undefined') VoiceAlerts.init();
});
</script>
<!-- Agent Manager -->
<script src="{{ url_for('static', filename='js/core/agents.js') }}"></script>
@@ -5169,24 +5286,27 @@ sudo make install</code>
if (running) {
isTracking = true;
const normalizedSource = source === null || source === undefined
? (adsbTrackingSource || 'local')
: source;
// If source is an agent ID (not 'local' and not null), update adsbCurrentAgent
// This ensures startEventStream uses the correct routing
if (source && source !== 'local') {
adsbCurrentAgent = source;
if (normalizedSource !== 'local') {
adsbCurrentAgent = normalizedSource;
// Also update the dropdown to match
const agentSelect = document.getElementById('agentSelect');
if (agentSelect) {
agentSelect.value = source;
agentSelect.value = normalizedSource;
}
// Update global agent state too
if (typeof currentAgent !== 'undefined') {
currentAgent = source;
currentAgent = normalizedSource;
}
console.log(`[ADS-B] Updated adsbCurrentAgent to ${source}`);
console.log(`[ADS-B] Updated adsbCurrentAgent to ${normalizedSource}`);
}
adsbTrackingSource = source || adsbCurrentAgent; // Track which source is active
adsbTrackingSource = normalizedSource; // Track which source is active
// Update button
if (btn) {
@@ -5206,9 +5326,9 @@ sudo make install</code>
if (agentSelect) agentSelect.disabled = true;
// Start data stream for the active source
const isAgentSource = adsbCurrentAgent !== 'local';
const isAgentSource = normalizedSource !== 'local';
if (isAgentSource) {
console.log(`[ADS-B] Starting data stream from agent ${adsbCurrentAgent}`);
console.log(`[ADS-B] Starting data stream from agent ${normalizedSource}`);
startEventStream();
drawRangeRings();
startSessionTimer();
@@ -5302,13 +5422,16 @@ sudo make install</code>
select.innerHTML = '';
if (devices.length === 0) {
select.innerHTML = '<option value="0">No SDR found</option>';
select.innerHTML = '<option value="rtlsdr:0">No SDR found</option>';
} else {
devices.forEach(device => {
const opt = document.createElement('option');
opt.value = device.index;
opt.dataset.sdrType = device.sdr_type || 'rtlsdr';
opt.textContent = `SDR ${device.index}: ${device.name || device.type || 'SDR'}`;
const sdrType = device.sdr_type || 'rtlsdr';
const idx = device.index !== undefined ? device.index : 0;
opt.value = `${sdrType}:${idx}`;
opt.dataset.sdrType = sdrType;
opt.dataset.index = idx;
opt.textContent = `SDR ${idx}: ${device.name || device.type || 'SDR'}`;
select.appendChild(opt);
});
}
+664 -125
View File
File diff suppressed because it is too large Load Diff
+197
View File
@@ -0,0 +1,197 @@
<!-- MORSE CODE MODE -->
<div id="morseMode" class="mode-content">
<div class="section">
<h3>CW/Morse Decoder</h3>
<p class="info-text morse-mode-help">
Decode CW (continuous wave) Morse code. Supports HF amateur bands (USB + Goertzel tone
detection) and ISM/UHF OOK signals (AM + envelope detection).
</p>
</div>
<div class="section">
<h3>Detection Mode</h3>
<div class="form-group">
<div style="display: flex; gap: 4px;">
<button class="preset-btn morseDetectBtn" id="morseDetectGoertzel"
onclick="MorseMode.setDetectMode('goertzel')"
style="flex: 1; background: var(--accent); color: #000;">CW Tone</button>
<button class="preset-btn morseDetectBtn" id="morseDetectEnvelope"
onclick="MorseMode.setDetectMode('envelope')"
style="flex: 1;">OOK Envelope</button>
</div>
<input type="hidden" id="morseDetectMode" value="goertzel">
<p id="morseDetectHint" class="info-text" style="font-size: 10px; color: var(--text-dim); margin-top: 4px;">
CW Tone: HF bands, USB demod, Goertzel filter. For amateur CW.
</p>
</div>
</div>
<div class="section">
<h3>Frequency</h3>
<div class="form-group">
<label>Frequency (MHz)</label>
<input type="number" id="morseFrequency" value="14.060" step="0.001" min="0.5" max="1766" placeholder="e.g., 14.060">
<span class="help-text morse-help-text">Enter CW center frequency in MHz (e.g., 7.030 for 40m).</span>
</div>
<div class="form-group">
<label>Band Presets</label>
<div class="morse-presets" id="morseHFPresets">
<button class="preset-btn" onclick="MorseMode.setFreq(3.560)">80m</button>
<button class="preset-btn" onclick="MorseMode.setFreq(7.030)">40m</button>
<button class="preset-btn" onclick="MorseMode.setFreq(10.116)">30m</button>
<button class="preset-btn" onclick="MorseMode.setFreq(14.060)">20m</button>
<button class="preset-btn" onclick="MorseMode.setFreq(18.080)">17m</button>
<button class="preset-btn" onclick="MorseMode.setFreq(21.060)">15m</button>
<button class="preset-btn" onclick="MorseMode.setFreq(24.910)">12m</button>
<button class="preset-btn" onclick="MorseMode.setFreq(28.060)">10m</button>
</div>
<div class="morse-presets" id="morseISMPresets" style="display: none; flex-wrap: wrap; gap: 4px;">
<button class="preset-btn" onclick="MorseMode.setFreq(315.000)">315</button>
<button class="preset-btn" onclick="MorseMode.setFreq(433.300)">433.3</button>
<button class="preset-btn" onclick="MorseMode.setFreq(433.920)">433.9</button>
<button class="preset-btn" onclick="MorseMode.setFreq(868.000)">868</button>
<button class="preset-btn" onclick="MorseMode.setFreq(915.000)">915</button>
</div>
</div>
</div>
<div class="section">
<h3>Device</h3>
<div class="form-group">
<label>Gain (dB)</label>
<input type="number" id="morseGain" value="40" step="1" min="0" max="60">
</div>
<div class="form-group">
<label>PPM Correction</label>
<input type="number" id="morsePPM" value="0" step="1" min="-200" max="200">
</div>
</div>
<div class="section" id="morseToneFreqGroup">
<h3>CW Detector</h3>
<div class="form-group">
<label>Tone Frequency: <span id="morseToneFreqLabel">700</span> Hz</label>
<input type="range" id="morseToneFreq" value="700" min="300" max="1200" step="10"
oninput="MorseMode.updateToneLabel(this.value)">
</div>
<div class="form-group">
<label>Bandwidth</label>
<select id="morseBandwidth">
<option value="50">50 Hz</option>
<option value="100">100 Hz</option>
<option value="200" selected>200 Hz</option>
<option value="400">400 Hz</option>
</select>
</div>
<div class="form-group checkbox-group">
<label><input type="checkbox" id="morseAutoToneTrack" checked> Auto Tone Track</label>
<label><input type="checkbox" id="morseToneLock"> Hold Tone Lock</label>
</div>
</div>
<div class="section">
<h3>Threshold + WPM</h3>
<div class="form-group">
<label>Threshold Mode</label>
<select id="morseThresholdMode" onchange="MorseMode.onThresholdModeChange()">
<option value="auto" selected>Auto</option>
<option value="manual">Manual</option>
</select>
</div>
<div class="form-group" id="morseThresholdAutoRow">
<label>Threshold Multiplier</label>
<input type="number" id="morseThresholdMultiplier" value="2.8" min="1.1" max="8" step="0.1">
</div>
<div class="form-group" id="morseThresholdOffsetRow">
<label>Threshold Offset</label>
<input type="number" id="morseThresholdOffset" value="0" min="0" step="0.1">
</div>
<div class="form-group" id="morseManualThresholdRow" style="display: none;">
<label>Manual Threshold</label>
<input type="number" id="morseManualThreshold" value="0" min="0" step="0.1">
</div>
<div class="form-group">
<label>Minimum Signal Gate</label>
<input type="number" id="morseSignalGate" value="0.05" min="0" max="1" step="0.01">
</div>
<div class="form-group">
<label>WPM Mode</label>
<select id="morseWpmMode" onchange="MorseMode.onWpmModeChange()">
<option value="auto" selected>Auto</option>
<option value="manual">Manual</option>
</select>
</div>
<div class="form-group" id="morseWpmManualRow" style="display: none;">
<label>Manual Speed: <span id="morseWpmLabel">15</span> WPM</label>
<input type="range" id="morseWpm" value="15" min="5" max="50" step="1"
oninput="MorseMode.updateWpmLabel(this.value)">
</div>
<div class="form-group checkbox-group">
<label><input type="checkbox" id="morseWpmLock"> Lock WPM Estimator</label>
</div>
</div>
<div class="section">
<h3>Output</h3>
<div class="form-group checkbox-group">
<label><input type="checkbox" id="morseShowRaw" checked> Show Raw Morse</label>
<label><input type="checkbox" id="morseShowDiag"> Show Decoder Logs</label>
</div>
<div class="morse-actions-row">
<button class="btn btn-sm btn-ghost" id="morseCalibrateBtn" onclick="MorseMode.calibrate()">Reset / Calibrate</button>
</div>
</div>
<div class="section">
<h3>Decode WAV File</h3>
<div class="morse-file-row">
<input type="file" id="morseFileInput" accept="audio/wav,.wav">
<button class="btn btn-sm btn-ghost" id="morseDecodeFileBtn" onclick="MorseMode.decodeFile()">Decode File</button>
</div>
<span class="help-text morse-help-text">Runs the same CW decoder pipeline against uploaded WAV audio.</span>
</div>
<div class="section">
<h3 style="cursor: pointer;" onclick="this.parentElement.querySelector('.morse-ref-grid').classList.toggle('collapsed')">
Morse Reference <span class="morse-ref-toggle">(click to toggle)</span>
</h3>
<div class="morse-ref-grid collapsed">
<div>A .-</div><div>B -...</div><div>C -.-.</div><div>D -..</div>
<div>E .</div><div>F ..-.</div><div>G --.</div><div>H ....</div>
<div>I ..</div><div>J .---</div><div>K -.-</div><div>L .-..</div>
<div>M --</div><div>N -.</div><div>O ---</div><div>P .--.</div>
<div>Q --.-</div><div>R .-.</div><div>S ...</div><div>T -</div>
<div>U ..-</div><div>V ...-</div><div>W .--</div><div>X -..-</div>
<div>Y -.--</div><div>Z --..</div>
<div class="morse-ref-divider">0 -----</div>
<div class="morse-ref-divider">1 .----</div>
<div>2 ..---</div><div>3 ...--</div><div>4 ....-</div>
<div>5 .....</div><div>6 -....</div><div>7 --...</div>
<div>8 ---..</div><div>9 ----.</div>
</div>
</div>
<div class="section">
<div class="morse-status">
<span id="morseStatusIndicator" class="status-dot"></span>
<span id="morseStatusText">Standby</span>
<span id="morseCharCount">0 chars</span>
</div>
</div>
<div class="section" id="morseHFNote">
<p class="info-text morse-hf-note">
CW on HF (1-30 MHz) requires an HF-capable SDR path (direct sampling or upconverter)
and an appropriate antenna.
</p>
</div>
<div class="section" id="morseEnvelopeNote" style="display: none;">
<p class="info-text" style="font-size: 11px; color: #ffaa00; line-height: 1.5;">
OOK Envelope mode uses AM demodulation to detect carrier on/off keying.
Suitable for ISM-band (315/433/868/915 MHz) Morse transmitters.
</p>
</div>
<button class="run-btn" id="morseStartBtn" onclick="MorseMode.start()">Start Decoder</button>
<button class="stop-btn" id="morseStopBtn" onclick="MorseMode.stop()" style="display: none;">Stop Decoder</button>
</div>
+456
View File
@@ -0,0 +1,456 @@
<!-- RADIOSONDE WEATHER BALLOON TRACKING MODE -->
<div id="radiosondeMode" class="mode-content">
<div class="section">
<h3>Radiosonde Decoder</h3>
<div class="info-text" style="margin-bottom: 15px;">
Track weather balloons via radiosonde telemetry on 400&ndash;406 MHz. Decodes position, altitude, temperature, humidity, and pressure.
</div>
</div>
<div class="section">
<h3>Settings</h3>
<div class="form-group">
<label>Region / Frequency Band</label>
<select id="radiosondeRegionSelect" onchange="updateRadiosondeFreqRange()">
<option value="global" selected>Global (400&ndash;406 MHz)</option>
<option value="eu">Europe (400&ndash;403 MHz)</option>
<option value="us">US (400&ndash;406 MHz)</option>
<option value="au">Australia (400&ndash;403 MHz)</option>
<option value="custom">Custom&hellip;</option>
</select>
</div>
<div class="form-group" id="radiosondeCustomFreqGroup" style="display: none;">
<label>Frequency Range (MHz)</label>
<div style="display: flex; gap: 8px; align-items: center;">
<input type="number" id="radiosondeFreqMin" value="400.0" min="380" max="410" step="0.1" style="width: 50%;" placeholder="Min">
<span style="color: var(--text-dim);">&ndash;</span>
<input type="number" id="radiosondeFreqMax" value="406.0" min="380" max="410" step="0.1" style="width: 50%;" placeholder="Max">
</div>
</div>
<div class="form-group">
<label>Gain (dB, 0 = auto)</label>
<input type="number" id="radiosondeGainInput" value="40" min="0" max="50" placeholder="0-50">
</div>
</div>
<div class="section">
<h3>Status</h3>
<div id="radiosondeStatusDisplay" class="info-text">
<p>Status: <span id="radiosondeStatusText" style="color: var(--accent-yellow);">Standby</span></p>
<p>Balloons: <span id="radiosondeBalloonCount">0</span></p>
<p>Last update: <span id="radiosondeLastUpdate">&mdash;</span></p>
</div>
</div>
<!-- Antenna Guide -->
<div class="section">
<h3>Antenna Guide</h3>
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
<p style="margin-bottom: 8px; color: var(--accent-cyan); font-weight: 600;">
400 MHz meteorological band &mdash; stock SDR antenna may work for nearby launches
</p>
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
<strong style="color: var(--accent-cyan); font-size: 12px;">Simple Quarter-Wave</strong>
<ul style="margin: 6px 0 0 14px; padding: 0;">
<li><strong style="color: var(--text-primary);">Element length:</strong> ~18.7 cm (quarter-wave at 400 MHz)</li>
<li><strong style="color: var(--text-primary);">Material:</strong> Wire or copper rod</li>
<li><strong style="color: var(--text-primary);">Orientation:</strong> Vertical</li>
<li><strong style="color: var(--text-primary);">Placement:</strong> Outdoors, as high as possible with clear sky view</li>
</ul>
</div>
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
<strong style="color: var(--accent-cyan); font-size: 12px;">Tips</strong>
<ul style="margin: 6px 0 0 14px; padding: 0;">
<li><strong style="color: var(--text-primary);">Range:</strong> 200+ km with LNA and good antenna placement</li>
<li><strong style="color: var(--text-primary);">LNA:</strong> Recommended &mdash; mount near antenna for best results</li>
<li><strong style="color: var(--text-primary);">Launches:</strong> Typically 2&times;/day at 00Z and 12Z from weather stations</li>
</ul>
</div>
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px;">
<strong style="color: var(--accent-cyan); font-size: 12px;">Quick Reference</strong>
<table style="width: 100%; margin-top: 6px; font-size: 10px; border-collapse: collapse;">
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">Frequency band</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">400&ndash;406 MHz</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">Quarter-wave length</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">18.7 cm</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">Common types</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">RS41, RS92, DFM, M10</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">Max altitude</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">~35 km (115,000 ft)</td>
</tr>
<tr>
<td style="padding: 3px 4px; color: var(--text-dim);">Flight duration</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">~90 min ascent</td>
</tr>
</table>
</div>
</div>
</div>
<button class="run-btn" id="startRadiosondeBtn" onclick="startRadiosondeTracking()">
Start Radiosonde Tracking
</button>
<button class="stop-btn" id="stopRadiosondeBtn" onclick="stopRadiosondeTracking()" style="display: none;">
Stop Radiosonde Tracking
</button>
</div>
<script>
let radiosondeEventSource = null;
let radiosondeBalloons = {};
function updateRadiosondeFreqRange() {
const region = document.getElementById('radiosondeRegionSelect').value;
const customGroup = document.getElementById('radiosondeCustomFreqGroup');
const minInput = document.getElementById('radiosondeFreqMin');
const maxInput = document.getElementById('radiosondeFreqMax');
const presets = {
global: [400.0, 406.0],
eu: [400.0, 403.0],
us: [400.0, 406.0],
au: [400.0, 403.0],
};
if (region === 'custom') {
customGroup.style.display = 'block';
} else {
customGroup.style.display = 'none';
if (presets[region]) {
minInput.value = presets[region][0];
maxInput.value = presets[region][1];
}
}
}
function startRadiosondeTracking() {
const gain = document.getElementById('radiosondeGainInput').value || '40';
const device = document.getElementById('deviceSelect')?.value || '0';
const freqMin = parseFloat(document.getElementById('radiosondeFreqMin').value) || 400.0;
const freqMax = parseFloat(document.getElementById('radiosondeFreqMax').value) || 406.0;
fetch('/radiosonde/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
device,
gain,
freq_min: freqMin,
freq_max: freqMax,
bias_t: typeof getBiasTEnabled === 'function' ? getBiasTEnabled() : false,
latitude: radiosondeStationLocation.lat,
longitude: radiosondeStationLocation.lon,
})
})
.then(r => r.json())
.then(data => {
if (data.status === 'started' || data.status === 'already_running') {
document.getElementById('startRadiosondeBtn').style.display = 'none';
document.getElementById('stopRadiosondeBtn').style.display = 'block';
document.getElementById('radiosondeStatusText').textContent = 'Tracking';
document.getElementById('radiosondeStatusText').style.color = 'var(--accent-green)';
startRadiosondeSSE();
} else {
alert(data.message || 'Failed to start radiosonde tracking');
}
})
.catch(err => alert('Error: ' + err.message));
}
function stopRadiosondeTracking() {
// Update UI immediately so the user sees feedback
document.getElementById('startRadiosondeBtn').style.display = 'block';
document.getElementById('stopRadiosondeBtn').style.display = 'none';
document.getElementById('radiosondeStatusText').textContent = 'Stopping...';
document.getElementById('radiosondeStatusText').style.color = 'var(--accent-yellow)';
if (radiosondeEventSource) {
radiosondeEventSource.close();
radiosondeEventSource = null;
}
fetch('/radiosonde/stop', { method: 'POST' })
.then(r => r.json())
.then(() => {
document.getElementById('radiosondeStatusText').textContent = 'Standby';
document.getElementById('radiosondeBalloonCount').textContent = '0';
document.getElementById('radiosondeLastUpdate').textContent = '\u2014';
radiosondeBalloons = {};
// Clear map markers
if (typeof radiosondeMap !== 'undefined' && radiosondeMap) {
radiosondeMarkers.forEach(m => radiosondeMap.removeLayer(m));
radiosondeMarkers.clear();
radiosondeTracks.forEach(t => radiosondeMap.removeLayer(t));
radiosondeTracks.clear();
}
})
.catch(() => {
document.getElementById('radiosondeStatusText').textContent = 'Standby';
});
}
function startRadiosondeSSE() {
if (radiosondeEventSource) radiosondeEventSource.close();
radiosondeEventSource = new EventSource('/radiosonde/stream');
radiosondeEventSource.onmessage = function(e) {
try {
const data = JSON.parse(e.data);
if (data.type === 'balloon') {
radiosondeBalloons[data.id] = data;
document.getElementById('radiosondeBalloonCount').textContent = Object.keys(radiosondeBalloons).length;
const now = new Date();
document.getElementById('radiosondeLastUpdate').textContent =
now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
updateRadiosondeMap(data);
updateRadiosondeCards();
}
} catch (err) {}
};
radiosondeEventSource.onerror = function() {
setTimeout(() => {
if (document.getElementById('stopRadiosondeBtn').style.display === 'block') {
startRadiosondeSSE();
}
}, 2000);
};
}
// Map management
let radiosondeMap = null;
let radiosondeMarkers = new Map();
let radiosondeTracks = new Map();
let radiosondeTrackPoints = new Map();
let radiosondeStationLocation = { lat: 0, lon: 0 };
let radiosondeStationMarker = null;
function initRadiosondeMap() {
if (radiosondeMap) return;
const container = document.getElementById('radiosondeMapContainer');
if (!container) return;
// Resolve observer location
if (window.ObserverLocation && ObserverLocation.getForModule) {
radiosondeStationLocation = ObserverLocation.getForModule('radiosonde_observerLocation');
}
const hasLocation = radiosondeStationLocation.lat !== 0 || radiosondeStationLocation.lon !== 0;
radiosondeMap = L.map('radiosondeMapContainer', {
center: hasLocation ? [radiosondeStationLocation.lat, radiosondeStationLocation.lon] : [40, -95],
zoom: hasLocation ? 7 : 4,
zoomControl: true,
});
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; OpenStreetMap &copy; CARTO',
maxZoom: 18,
}).addTo(radiosondeMap);
// Add station marker if we have a location
if (hasLocation) {
radiosondeStationMarker = L.circleMarker(
[radiosondeStationLocation.lat, radiosondeStationLocation.lon], {
radius: 8,
fillColor: '#00e5ff',
color: '#00e5ff',
weight: 2,
opacity: 1,
fillOpacity: 0.5,
}).addTo(radiosondeMap);
radiosondeStationMarker.bindTooltip('Station', { permanent: false, direction: 'top' });
}
// Try GPS for live position updates
fetch('/gps/position')
.then(r => r.json())
.then(data => {
if (data.status === 'ok' && data.position && data.position.latitude != null) {
radiosondeStationLocation = { lat: data.position.latitude, lon: data.position.longitude };
const ll = [data.position.latitude, data.position.longitude];
if (radiosondeStationMarker) {
radiosondeStationMarker.setLatLng(ll);
} else {
radiosondeStationMarker = L.circleMarker(ll, {
radius: 8,
fillColor: '#00e5ff',
color: '#00e5ff',
weight: 2,
opacity: 1,
fillOpacity: 0.5,
}).addTo(radiosondeMap);
radiosondeStationMarker.bindTooltip('Station (GPS)', { permanent: false, direction: 'top' });
}
if (!radiosondeMap._gpsInitialized) {
radiosondeMap.setView(ll, 7);
radiosondeMap._gpsInitialized = true;
}
// Re-render cards with updated distances
updateRadiosondeCards();
}
})
.catch(() => {});
}
function updateRadiosondeMap(balloon) {
if (!radiosondeMap || !balloon.lat || !balloon.lon) return;
const id = balloon.id;
const latlng = [balloon.lat, balloon.lon];
// Altitude-based colour coding
const alt = balloon.alt || 0;
let colour;
if (alt < 5000) colour = '#00ff88';
else if (alt < 15000) colour = '#00ccff';
else if (alt < 25000) colour = '#ff9900';
else colour = '#ff3366';
// Update or create marker
if (radiosondeMarkers.has(id)) {
radiosondeMarkers.get(id).setLatLng(latlng);
} else {
const marker = L.circleMarker(latlng, {
radius: 7,
color: colour,
fillColor: colour,
fillOpacity: 0.8,
weight: 2,
}).addTo(radiosondeMap);
radiosondeMarkers.set(id, marker);
}
// Update marker colour based on altitude
radiosondeMarkers.get(id).setStyle({ color: colour, fillColor: colour });
// Build popup content
const altStr = alt ? `${Math.round(alt).toLocaleString()} m` : '--';
const tempStr = balloon.temp != null ? `${balloon.temp.toFixed(1)} °C` : '--';
const humStr = balloon.humidity != null ? `${balloon.humidity.toFixed(0)}%` : '--';
const velStr = balloon.vel_v != null ? `${balloon.vel_v.toFixed(1)} m/s` : '--';
let distStr = '';
if ((radiosondeStationLocation.lat !== 0 || radiosondeStationLocation.lon !== 0) && balloon.lat && balloon.lon) {
const distM = radiosondeMap.distance(
[radiosondeStationLocation.lat, radiosondeStationLocation.lon], latlng);
distStr = `Dist: ${(distM / 1000).toFixed(1)} km<br>`;
}
radiosondeMarkers.get(id).bindPopup(
`<strong>${id}</strong><br>` +
`Type: ${balloon.sonde_type || '--'}<br>` +
`Alt: ${altStr}<br>` +
`Temp: ${tempStr} | Hum: ${humStr}<br>` +
`Vert: ${velStr}<br>` +
distStr +
(balloon.freq ? `Freq: ${balloon.freq.toFixed(3)} MHz` : '')
);
// Track polyline
if (!radiosondeTrackPoints.has(id)) {
radiosondeTrackPoints.set(id, []);
}
radiosondeTrackPoints.get(id).push(latlng);
if (radiosondeTracks.has(id)) {
radiosondeTracks.get(id).setLatLngs(radiosondeTrackPoints.get(id));
} else {
const track = L.polyline(radiosondeTrackPoints.get(id), {
color: colour,
weight: 2,
opacity: 0.6,
dashArray: '4 4',
}).addTo(radiosondeMap);
radiosondeTracks.set(id, track);
}
// Auto-centre on first balloon
if (radiosondeMarkers.size === 1) {
radiosondeMap.setView(latlng, 8);
}
}
function updateRadiosondeCards() {
const container = document.getElementById('radiosondeCardContainer');
if (!container) return;
const sorted = Object.values(radiosondeBalloons).sort((a, b) => (b.alt || 0) - (a.alt || 0));
const hasStation = radiosondeStationLocation.lat !== 0 || radiosondeStationLocation.lon !== 0;
container.innerHTML = sorted.map(b => {
const alt = b.alt ? `${Math.round(b.alt).toLocaleString()} m` : '--';
const temp = b.temp != null ? `${b.temp.toFixed(1)}°C` : '--';
const hum = b.humidity != null ? `${b.humidity.toFixed(0)}%` : '--';
const press = b.pressure != null ? `${b.pressure.toFixed(1)} hPa` : '--';
const vel = b.vel_v != null ? `${b.vel_v > 0 ? '+' : ''}${b.vel_v.toFixed(1)} m/s` : '--';
const freq = b.freq ? `${b.freq.toFixed(3)} MHz` : '--';
let dist = '--';
if (hasStation && b.lat && b.lon && radiosondeMap) {
const distM = radiosondeMap.distance(
[radiosondeStationLocation.lat, radiosondeStationLocation.lon],
[b.lat, b.lon]);
dist = `${(distM / 1000).toFixed(1)} km`;
}
return `
<div class="radiosonde-card" onclick="radiosondeMap && radiosondeMap.setView([${b.lat || 0}, ${b.lon || 0}], 10)">
<div class="radiosonde-card-header">
<span class="radiosonde-serial">${b.id}</span>
<span class="radiosonde-type">${b.sonde_type || '??'}</span>
</div>
<div class="radiosonde-stats">
<div class="radiosonde-stat">
<span class="radiosonde-stat-value">${alt}</span>
<span class="radiosonde-stat-label">ALT</span>
</div>
<div class="radiosonde-stat">
<span class="radiosonde-stat-value">${temp}</span>
<span class="radiosonde-stat-label">TEMP</span>
</div>
<div class="radiosonde-stat">
<span class="radiosonde-stat-value">${hum}</span>
<span class="radiosonde-stat-label">HUM</span>
</div>
<div class="radiosonde-stat">
<span class="radiosonde-stat-value">${press}</span>
<span class="radiosonde-stat-label">PRESS</span>
</div>
<div class="radiosonde-stat">
<span class="radiosonde-stat-value">${vel}</span>
<span class="radiosonde-stat-label">VERT</span>
</div>
<div class="radiosonde-stat">
<span class="radiosonde-stat-value">${freq}</span>
<span class="radiosonde-stat-label">FREQ</span>
</div>
<div class="radiosonde-stat">
<span class="radiosonde-stat-value">${dist}</span>
<span class="radiosonde-stat-label">DIST</span>
</div>
</div>
</div>
`;
}).join('');
}
// Check initial status on load
fetch('/radiosonde/status')
.then(r => r.json())
.then(data => {
if (data.tracking_active) {
document.getElementById('startRadiosondeBtn').style.display = 'none';
document.getElementById('stopRadiosondeBtn').style.display = 'block';
document.getElementById('radiosondeStatusText').textContent = 'Tracking';
document.getElementById('radiosondeStatusText').style.color = 'var(--accent-green)';
document.getElementById('radiosondeBalloonCount').textContent = data.balloon_count || 0;
startRadiosondeSSE();
}
})
.catch(() => {});
</script>
+70
View File
@@ -0,0 +1,70 @@
<!-- SYSTEM HEALTH MODE -->
<div id="systemMode" class="mode-content">
<div class="section">
<h3>System Health</h3>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
Real-time monitoring of host resources, active decoders, and SDR hardware.
Auto-connects when entering this mode.
</p>
</div>
<!-- Quick Status Grid -->
<div class="section">
<h3>Quick Status</h3>
<div class="sys-quick-grid">
<div class="sys-quick-item">
<span class="sys-quick-label">CPU</span>
<span class="sys-quick-value" id="sysQuickCpu">--%</span>
</div>
<div class="sys-quick-item">
<span class="sys-quick-label">Temp</span>
<span class="sys-quick-value" id="sysQuickTemp">--&deg;C</span>
</div>
<div class="sys-quick-item">
<span class="sys-quick-label">RAM</span>
<span class="sys-quick-value" id="sysQuickRam">--%</span>
</div>
<div class="sys-quick-item">
<span class="sys-quick-label">Disk</span>
<span class="sys-quick-value" id="sysQuickDisk">--%</span>
</div>
</div>
</div>
<!-- Network & Location -->
<div class="section">
<h3>Network</h3>
<div id="sysQuickNet" style="font-size: 11px; color: var(--text-dim);">--</div>
</div>
<!-- Battery (shown only when available) -->
<div class="section" id="sysQuickBatterySection" style="display: none;">
<h3>Battery</h3>
<div id="sysQuickBattery" style="font-size: 11px; color: var(--text-dim);">--</div>
</div>
<!-- Location -->
<div class="section">
<h3>Location</h3>
<div id="sysQuickLocation" style="font-size: 11px; color: var(--text-dim);">--</div>
</div>
<!-- SDR Devices -->
<div class="section">
<h3>SDR Devices</h3>
<div id="sysSdrList" style="font-size: 11px; color: var(--text-dim);">
Scanning&hellip;
</div>
<button class="run-btn" style="width: 100%; margin-top: 8px;" onclick="SystemHealth.refreshSdr()">
Rescan SDR
</button>
</div>
<!-- Active Processes -->
<div class="section">
<h3>Active Processes</h3>
<div id="sysProcessList" style="font-size: 11px; color: var(--text-dim);">
Waiting for data&hellip;
</div>
</div>
</div>
+26 -14
View File
@@ -169,20 +169,32 @@
.catch(err => alert('Error: ' + err.message));
}
function stopVdl2Mode() {
fetch('/vdl2/stop', { method: 'POST' })
.then(r => r.json())
.then(() => {
document.getElementById('startVdl2Btn').style.display = 'block';
document.getElementById('stopVdl2Btn').style.display = 'none';
document.getElementById('vdl2StatusText').textContent = 'Standby';
document.getElementById('vdl2StatusText').style.color = 'var(--accent-yellow)';
if (vdl2MainEventSource) {
vdl2MainEventSource.close();
vdl2MainEventSource = null;
}
});
}
function stopVdl2Mode() {
fetch('/vdl2/stop', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source: 'vdl2_mode' })
})
.then(async (r) => {
const text = await r.text();
const data = text ? JSON.parse(text) : {};
if (!r.ok || (data.status !== 'stopped' && data.status !== 'success')) {
throw new Error(data.message || `HTTP ${r.status}`);
}
return data;
})
.then(() => {
document.getElementById('startVdl2Btn').style.display = 'block';
document.getElementById('stopVdl2Btn').style.display = 'none';
document.getElementById('vdl2StatusText').textContent = 'Standby';
document.getElementById('vdl2StatusText').style.color = 'var(--accent-yellow)';
if (vdl2MainEventSource) {
vdl2MainEventSource.close();
vdl2MainEventSource = null;
}
})
.catch(err => alert('Failed to stop VDL2: ' + err.message));
}
function startVdl2MainSSE() {
if (vdl2MainEventSource) vdl2MainEventSource.close();
+129
View File
@@ -0,0 +1,129 @@
<!-- WEFAX MODE -->
<div id="wefaxMode" class="mode-content">
<div class="section">
<h3>WeFax Decoder</h3>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
Decode HF weather fax (radiofax) from maritime and aviation weather services.
Stations broadcast weather charts on fixed schedules via HF radio.
</p>
</div>
<div class="section">
<h3>Station</h3>
<div class="form-group">
<label>Station</label>
<select id="wefaxStation" onchange="WeFax.onStationChange()">
<option value="">Select a station...</option>
</select>
</div>
<div class="form-group">
<label>Frequency (kHz)</label>
<select id="wefaxFrequency">
<option value="">Select station first</option>
</select>
</div>
</div>
<div class="section">
<h3>Settings</h3>
<div class="form-group">
<label>IOC (Index of Cooperation)</label>
<select id="wefaxIOC">
<option value="576" selected>576 (Standard)</option>
<option value="288">288 (Half)</option>
</select>
</div>
<div class="form-group">
<label>LPM (Lines Per Minute)</label>
<select id="wefaxLPM">
<option value="120" selected>120 (Standard)</option>
<option value="60">60 (Slow)</option>
</select>
</div>
<div class="form-group">
<label>Gain (dB)</label>
<input type="number" id="wefaxGain" value="40" step="1" min="0" max="50">
</div>
<div class="form-group" style="display: flex; align-items: center; gap: 8px;">
<input type="checkbox" id="wefaxDirectSampling" checked>
<label for="wefaxDirectSampling" style="margin: 0; cursor: pointer;">Direct Sampling (Q-branch, required for HF)</label>
</div>
<div class="form-group" style="display: flex; align-items: center; gap: 8px;">
<input type="checkbox" id="wefaxAutoUsbAlign" checked>
<label for="wefaxAutoUsbAlign" style="margin: 0; cursor: pointer;">Auto USB align listed carrier frequencies (-1.9 kHz)</label>
</div>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-top: -4px;">
Disable this if your source already provides USB dial frequencies.
</p>
</div>
<div class="section">
<h3>Auto Capture</h3>
<div class="form-group" style="display: flex; align-items: center; gap: 8px;">
<input type="checkbox" id="wefaxSidebarAutoSchedule"
onchange="WeFax.toggleScheduler(this)">
<label for="wefaxSidebarAutoSchedule" style="margin: 0; cursor: pointer;">Auto-capture scheduled broadcasts</label>
</div>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-top: 4px;">
Automatically decode at scheduled broadcast times.
</p>
</div>
<!-- Antenna Guide -->
<div class="section">
<h3>HF Antenna Guide</h3>
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
<p style="margin-bottom: 8px; color: #ffaa00; font-weight: 600;">
HF band (2&ndash;30 MHz) &mdash; requires HF antenna + direct sampling SDR
</p>
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
<strong style="color: #ffaa00; font-size: 12px;">Requirements</strong>
<ul style="margin: 6px 0 0 14px; padding: 0;">
<li><strong style="color: var(--text-primary);">SDR:</strong> RTL-SDR (direct sampling), HackRF, LimeSDR, Airspy, or SDRPlay</li>
<li><strong style="color: var(--text-primary);">Antenna:</strong> Long wire (10m+), random wire, or dipole for target band</li>
<li><strong style="color: var(--text-primary);">Mode:</strong> USB (Upper Sideband) demodulation</li>
<li><strong style="color: var(--text-primary);">Signals:</strong> Moderate &mdash; HF propagation varies by time of day</li>
</ul>
</div>
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px;">
<strong style="color: #ffaa00; font-size: 12px;">Quick Reference</strong>
<table style="width: 100%; margin-top: 6px; font-size: 10px; border-collapse: collapse;">
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">Protocol</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">AM Facsimile (ITU-T T.4)</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">Carrier</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">1900 Hz</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">Deviation</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">&plusmn;400 Hz</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">Black / White</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">1500 / 2300 Hz</td>
</tr>
<tr>
<td style="padding: 3px 4px; color: var(--text-dim);">Start / Stop tone</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">300 / 450 Hz</td>
</tr>
</table>
</div>
</div>
</div>
<div class="section">
<h3>Resources</h3>
<div style="display: flex; flex-direction: column; gap: 6px;">
<a href="https://www.weather.gov/marine/radiofax_charts" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
NWS Radiofax Charts
</a>
<a href="https://www.nws.noaa.gov/os/marine/rfax.pdf" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
NOAA Radiofax Schedule (PDF)
</a>
</div>
</div>
</div>
+20
View File
@@ -67,6 +67,7 @@
{{ mode_item('rtlamr', 'Meters', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>') }}
{{ mode_item('subghz', 'SubGHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h6l3-9 3 18 3-9h5"/></svg>') }}
{{ mode_item('waterfall', 'Waterfall', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h4l3-8 3 16 3-8h4"/><path d="M2 18h20" opacity="0.4"/><path d="M2 21h20" opacity="0.2"/></svg>') }}
{{ mode_item('morse', 'Morse', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="2" y1="12" x2="5" y2="12"/><line x1="7" y1="12" x2="13" y2="12"/><line x1="15" y1="12" x2="18" y2="12"/><line x1="20" y1="12" x2="22" y2="12"/></svg>') }}
</div>
</div>
@@ -83,6 +84,7 @@
{{ mode_item('ais', 'Vessels', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></svg>', '/ais/dashboard') }}
{{ mode_item('aprs', 'APRS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg>') }}
{{ mode_item('gps', 'GPS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>') }}
{{ mode_item('radiosonde', 'Radiosonde', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v6"/><circle cx="12" cy="12" r="4"/><path d="M12 16v6"/><path d="M4.93 4.93l4.24 4.24"/><path d="M14.83 14.83l4.24 4.24"/></svg>') }}
</div>
</div>
@@ -102,6 +104,7 @@
{% endif %}
{{ mode_item('sstv', 'ISS SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M3 9h2"/><path d="M19 9h2"/><path d="M3 15h2"/><path d="M19 15h2"/></svg>') }}
{{ mode_item('weathersat', 'Weather Sat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><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>') }}
{{ mode_item('wefax', 'WeFax', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>') }}
{{ mode_item('sstv_general', 'HF SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg>') }}
{{ mode_item('spaceweather', 'Space Weather', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" 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>
@@ -138,6 +141,19 @@
</div>
</div>
{# System Group #}
<div class="mode-nav-dropdown" data-group="system">
<button type="button" class="mode-nav-dropdown-btn"{% if is_index_page %} onclick="toggleNavDropdown('system')"{% endif %}>
<span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg></span>
<span class="nav-label">System</span>
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
</button>
<div class="mode-nav-dropdown-menu">
{{ mode_item('system', 'Health', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>') }}
</div>
</div>
{# Dynamic dashboard button (shown when in satellite mode) #}
<div class="mode-nav-actions">
<a href="/satellite/dashboard" target="_blank" class="nav-action-btn" id="satelliteDashboardBtn" style="display: none;">
@@ -200,6 +216,7 @@
{{ mobile_item('sensor', '433MHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg>') }}
{{ mobile_item('rtlamr', 'Meters', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>') }}
{{ mobile_item('subghz', 'SubGHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12h6l3-9 3 18 3-9h5"/></svg>') }}
{{ mobile_item('morse', 'Morse', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="2" y1="12" x2="5" y2="12"/><line x1="7" y1="12" x2="13" y2="12"/><line x1="15" y1="12" x2="18" y2="12"/><line x1="20" y1="12" x2="22" y2="12"/></svg>') }}
{# Tracking #}
{{ mobile_item('adsb', 'Aircraft', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg>', '/adsb/dashboard') }}
{{ mobile_item('ais', 'Vessels', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></svg>', '/ais/dashboard') }}
@@ -213,6 +230,7 @@
{% endif %}
{{ mobile_item('sstv', 'SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/></svg>') }}
{{ mobile_item('weathersat', 'WxSat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><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>') }}
{{ mobile_item('wefax', 'WeFax', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>') }}
{{ mobile_item('sstv_general', 'HF SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/></svg>') }}
{{ mobile_item('spaceweather', 'SpaceWx', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/></svg>') }}
{# Wireless #}
@@ -226,6 +244,8 @@
{{ mobile_item('websdr', 'WebSDR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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>') }}
{# New modes #}
{{ mobile_item('waterfall', 'Waterfall', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12h4l3-8 3 16 3-8h4"/></svg>') }}
{# System #}
{{ mobile_item('system', 'System', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>') }}
</nav>
{# JavaScript stub for pages that don't have switchMode defined #}
+11
View File
@@ -323,6 +323,17 @@
</label>
</div>
<div class="settings-row">
<div class="settings-label">
<span class="settings-label-text">Military Aircraft</span>
<span class="settings-label-desc">Speak when military aircraft are detected</span>
</div>
<label class="toggle-switch">
<input type="checkbox" id="voiceCfgAdsbMilitary" checked onchange="saveVoiceAlertConfig()">
<span class="toggle-slider"></span>
</label>
</div>
<div class="settings-row">
<div class="settings-label">
<span class="settings-label-text">Emergency Squawks</span>
+6 -1
View File
@@ -971,7 +971,12 @@
}
}
} catch (err) {
console.error('Position update error:', err);
const transient = (typeof window.isTransientOrOffline === 'function' && window.isTransientOrOffline(err)) ||
(typeof navigator !== 'undefined' && navigator.onLine === false) ||
/failed to fetch|network io suspended|networkerror|timeout/i.test(String((err && err.message) || err || ''));
if (!transient) {
console.error('Position update error:', err);
}
}
}
+51
View File
@@ -0,0 +1,51 @@
"""APRS packet parser regression tests."""
from __future__ import annotations
import pytest
from routes.aprs import parse_aprs_packet
_BASE_PACKET = "N0CALL-9>APRS,TCPIP*:@092345z4903.50N/07201.75W_090/000g005t077"
@pytest.mark.parametrize(
"line",
[
_BASE_PACKET,
f"[0.4] {_BASE_PACKET}",
f"[0L] {_BASE_PACKET}",
f"AFSK1200: {_BASE_PACKET}",
f"AFSK1200: [0L] {_BASE_PACKET}",
],
)
def test_parse_aprs_packet_accepts_decoder_prefix_variants(line: str) -> None:
packet = parse_aprs_packet(line)
assert packet is not None
assert packet["callsign"] == "N0CALL-9"
assert packet["type"] == "aprs"
def test_parse_aprs_packet_accepts_callsign_with_tactical_suffix() -> None:
packet = parse_aprs_packet("CALL/1>APRS:!4903.50N/07201.75W-Test")
assert packet is not None
assert packet["callsign"] == "CALL/1"
assert packet["lat"] == pytest.approx(49.058333, rel=0, abs=1e-6)
assert packet["lon"] == pytest.approx(-72.029167, rel=0, abs=1e-6)
def test_parse_aprs_packet_handles_ambiguous_uncompressed_position() -> None:
packet = parse_aprs_packet("KJ7ABC-7>APRS,WIDE1-1:!4903. N/07201. W-Test")
assert packet is not None
assert packet["packet_type"] == "position"
assert packet["lat"] == pytest.approx(49.05, rel=0, abs=1e-6)
assert packet["lon"] == pytest.approx(-72.016667, rel=0, abs=1e-6)
def test_parse_aprs_packet_handles_no_decimal_position_variant() -> None:
packet = parse_aprs_packet("KJ7ABC-7>APRS,WIDE1-1:!4903N/07201W-Test")
assert packet is not None
assert packet["packet_type"] == "position"
assert packet["lat"] == pytest.approx(49.05, rel=0, abs=1e-6)
assert packet["lon"] == pytest.approx(-72.016667, rel=0, abs=1e-6)
+353 -50
View File
@@ -1,8 +1,8 @@
"""Tests for DSC (Digital Selective Calling) utilities."""
import json
import pytest
from datetime import datetime
class TestDSCParser:
@@ -88,17 +88,15 @@ class TestDSCParser:
assert get_distress_nature_text('invalid') == 'invalid'
def test_get_format_text(self):
"""Test format code to text conversion."""
"""Test format code to text conversion per ITU-R M.493."""
from utils.dsc.parser import get_format_text
assert get_format_text(100) == 'DISTRESS'
assert get_format_text(102) == 'ALL_SHIPS'
assert get_format_text(106) == 'DISTRESS_ACK'
assert get_format_text(108) == 'DISTRESS_RELAY'
assert get_format_text(112) == 'INDIVIDUAL'
assert get_format_text(116) == 'ROUTINE'
assert get_format_text(118) == 'SAFETY'
assert get_format_text(120) == 'URGENCY'
assert get_format_text(114) == 'INDIVIDUAL_ACK'
assert get_format_text(116) == 'GROUP'
assert get_format_text(120) == 'DISTRESS'
assert get_format_text(123) == 'ALL_SHIPS_URGENCY_SAFETY'
def test_get_format_text_unknown(self):
"""Test format code returns unknown for invalid codes."""
@@ -107,6 +105,15 @@ class TestDSCParser:
result = get_format_text(999)
assert 'UNKNOWN' in result
def test_get_format_text_removed_codes(self):
"""Test that non-ITU format codes are no longer recognized."""
from utils.dsc.parser import get_format_text
# These were previously defined but are not ITU-R M.493 specifiers
for code in [100, 104, 106, 108, 110, 118]:
result = get_format_text(code)
assert 'UNKNOWN' in result
def test_get_telecommand_text(self):
"""Test telecommand code to text conversion."""
from utils.dsc.parser import get_telecommand_text
@@ -124,14 +131,13 @@ class TestDSCParser:
assert get_category_priority('DISTRESS') == 0
assert get_category_priority('distress') == 0
# Urgency is lower
assert get_category_priority('URGENCY') == 3
# Urgency/safety
assert get_category_priority('ALL_SHIPS_URGENCY_SAFETY') == 2
# Safety is lower still
assert get_category_priority('SAFETY') == 4
# Routine is lowest
assert get_category_priority('ROUTINE') == 5
# Routine-level
assert get_category_priority('ALL_SHIPS') == 5
assert get_category_priority('GROUP') == 5
assert get_category_priority('INDIVIDUAL') == 5
# Unknown gets default high number
assert get_category_priority('UNKNOWN') == 10
@@ -182,19 +188,20 @@ class TestDSCParser:
assert classify_mmsi('812345678') == 'unknown'
def test_parse_dsc_message_distress(self):
"""Test parsing a distress message."""
"""Test parsing a distress message with ITU format 120."""
from utils.dsc.parser import parse_dsc_message
raw = json.dumps({
'type': 'dsc',
'format': 100,
'format': 120,
'source_mmsi': '232123456',
'dest_mmsi': '000000000',
'dest_mmsi': '002320001',
'category': 'DISTRESS',
'nature': 101,
'position': {'lat': 51.5, 'lon': -0.1},
'telecommand1': 100,
'timestamp': '2025-01-15T12:00:00Z'
'timestamp': '2025-01-15T12:00:00Z',
'raw': '120002032123456101100127',
})
msg = parse_dsc_message(raw)
@@ -210,26 +217,49 @@ class TestDSCParser:
assert msg['is_critical'] is True
assert msg['priority'] == 0
def test_parse_dsc_message_routine(self):
"""Test parsing a routine message."""
def test_parse_dsc_message_group(self):
"""Test parsing a group call message."""
from utils.dsc.parser import parse_dsc_message
raw = json.dumps({
'type': 'dsc',
'format': 116,
'source_mmsi': '366000001',
'category': 'ROUTINE',
'timestamp': '2025-01-15T12:00:00Z'
'dest_mmsi': '023200001',
'category': 'GROUP',
'timestamp': '2025-01-15T12:00:00Z',
'raw': '116023200001366000001117',
})
msg = parse_dsc_message(raw)
assert msg is not None
assert msg['category'] == 'ROUTINE'
assert msg['category'] == 'GROUP'
assert msg['source_country'] == 'USA'
assert msg['is_critical'] is False
assert msg['priority'] == 5
def test_parse_dsc_message_individual(self):
"""Test parsing an individual call message."""
from utils.dsc.parser import parse_dsc_message
raw = json.dumps({
'type': 'dsc',
'format': 112,
'source_mmsi': '366000001',
'dest_mmsi': '232123456',
'category': 'INDIVIDUAL',
'telecommand1': 100,
'timestamp': '2025-01-15T12:00:00Z',
'raw': '112232123456366000001100122',
})
msg = parse_dsc_message(raw)
assert msg is not None
assert msg['category'] == 'INDIVIDUAL'
assert msg['is_critical'] is False
def test_parse_dsc_message_invalid_json(self):
"""Test parsing rejects invalid JSON."""
from utils.dsc.parser import parse_dsc_message
@@ -262,6 +292,171 @@ class TestDSCParser:
assert parse_dsc_message(None) is None
assert parse_dsc_message(' ') is None
def test_parse_dsc_message_rejects_non_itu_format(self):
"""Test parser rejects records with non-ITU format specifier."""
from utils.dsc.parser import parse_dsc_message
for bad_format in [100, 104, 106, 108, 110, 118, 999]:
raw = json.dumps({
'type': 'dsc',
'format': bad_format,
'source_mmsi': '232123456',
'category': 'ROUTINE',
'raw': '120232123456100127',
})
assert parse_dsc_message(raw) is None, f"Format {bad_format} should be rejected"
def test_parse_dsc_message_rejects_telecommand_out_of_range(self):
"""Test parser rejects records with telecommand out of 100-127 range."""
from utils.dsc.parser import parse_dsc_message
raw = json.dumps({
'type': 'dsc',
'format': 120,
'source_mmsi': '232123456',
'dest_mmsi': '002320001',
'category': 'DISTRESS',
'telecommand1': 200,
'timestamp': '2025-01-15T12:00:00Z',
'raw': '120002032123456200127',
})
assert parse_dsc_message(raw) is None
def test_parse_dsc_message_accepts_zero_telecommand(self):
"""Test parser does not drop telecommand with value 100 (truthiness fix)."""
from utils.dsc.parser import parse_dsc_message
raw = json.dumps({
'type': 'dsc',
'format': 112,
'source_mmsi': '232123456',
'dest_mmsi': '366000001',
'category': 'INDIVIDUAL',
'telecommand1': 100,
'telecommand2': 100,
'timestamp': '2025-01-15T12:00:00Z',
'raw': '112366000001232123456100100122',
})
msg = parse_dsc_message(raw)
assert msg is not None
assert msg['telecommand1'] == 100
assert msg['telecommand2'] == 100
def test_parse_dsc_message_validates_raw_field(self):
"""Test parser validates raw field structure."""
from utils.dsc.parser import parse_dsc_message
# Non-digit raw field
raw = json.dumps({
'type': 'dsc',
'format': 120,
'source_mmsi': '232123456',
'category': 'DISTRESS',
'raw': '12abc',
})
assert parse_dsc_message(raw) is None
# Raw field length not divisible by 3
raw = json.dumps({
'type': 'dsc',
'format': 120,
'source_mmsi': '232123456',
'category': 'DISTRESS',
'raw': '1201',
})
assert parse_dsc_message(raw) is None
# Raw field with non-EOS last token
raw = json.dumps({
'type': 'dsc',
'format': 120,
'source_mmsi': '232123456',
'category': 'DISTRESS',
'raw': '120100',
})
assert parse_dsc_message(raw) is None
def test_parse_dsc_message_accepts_valid_eos_in_raw(self):
"""Test parser accepts all three valid EOS values in raw field."""
from utils.dsc.parser import parse_dsc_message
for eos in [117, 122, 127]:
raw = json.dumps({
'type': 'dsc',
'format': 120,
'source_mmsi': '232123456',
'dest_mmsi': '002320001',
'category': 'DISTRESS',
'timestamp': '2025-01-15T12:00:00Z',
'raw': f'120002032123456{eos:03d}',
})
msg = parse_dsc_message(raw)
assert msg is not None, f"EOS {eos} should be accepted"
def test_parse_dsc_message_rejects_invalid_mmsi(self):
"""Test parser rejects invalid MMSI values."""
from utils.dsc.parser import parse_dsc_message
# All-zeros MMSI
raw = json.dumps({
'type': 'dsc',
'format': 120,
'source_mmsi': '000000000',
'category': 'DISTRESS',
'raw': '120000000000127',
})
assert parse_dsc_message(raw) is None
# Short MMSI
raw = json.dumps({
'type': 'dsc',
'format': 120,
'source_mmsi': '12345',
'category': 'DISTRESS',
'raw': '120127',
})
assert parse_dsc_message(raw) is None
def test_parse_dsc_message_nature_zero_not_dropped(self):
"""Test that nature code 0 is not dropped by truthiness check."""
from utils.dsc.parser import parse_dsc_message
raw = json.dumps({
'type': 'dsc',
'format': 120,
'source_mmsi': '232123456',
'dest_mmsi': '002320001',
'category': 'DISTRESS',
'nature': 0,
'timestamp': '2025-01-15T12:00:00Z',
'raw': '120002032123456000127',
})
msg = parse_dsc_message(raw)
assert msg is not None
assert msg['nature_code'] == 0
def test_parse_dsc_message_channel_zero_not_dropped(self):
"""Test that channel value 0 is not dropped by truthiness check."""
from utils.dsc.parser import parse_dsc_message
raw = json.dumps({
'type': 'dsc',
'format': 112,
'source_mmsi': '232123456',
'dest_mmsi': '366000001',
'category': 'INDIVIDUAL',
'channel': 0,
'telecommand1': 100,
'timestamp': '2025-01-15T12:00:00Z',
'raw': '112366000001232123456100122',
})
msg = parse_dsc_message(raw)
assert msg is not None
assert msg['channel'] == 0
def test_format_dsc_for_display(self):
"""Test message formatting for display."""
from utils.dsc.parser import format_dsc_for_display
@@ -320,18 +515,16 @@ class TestDSCDecoder:
assert result == '002320001'
def test_decode_mmsi_short_symbols(self, decoder):
"""Test MMSI decoding handles short symbol list."""
"""Test MMSI decoding returns None for short symbol list."""
result = decoder._decode_mmsi([1, 2, 3])
assert result == '000000000'
assert result is None
def test_decode_mmsi_invalid_symbols(self, decoder):
"""Test MMSI decoding handles invalid symbol values."""
# Symbols > 99 should be treated as 0
"""Test MMSI decoding returns None for out-of-range symbols."""
# Symbols > 99 should cause decode to fail
symbols = [100, 32, 12, 34, 56]
result = decoder._decode_mmsi(symbols)
# First symbol (100) becomes 00, padded result "0032123456",
# trim leading pad digit -> "032123456"
assert result == '032123456'
assert result is None
def test_decode_position_northeast(self, decoder):
"""Test position decoding for NE quadrant."""
@@ -382,8 +575,9 @@ class TestDSCDecoder:
def test_bits_to_symbol(self, decoder):
"""Test bit to symbol conversion."""
# Symbol value is first 7 bits (LSB first)
# Value 100 = 0b1100100 -> bits [0,0,1,0,0,1,1, x,x,x]
bits = [0, 0, 1, 0, 0, 1, 1, 0, 0, 0]
# Value 100 = 0b1100100 -> bits [0,0,1,0,0,1,1] -> 3 ones
# Check bits must make total even -> need 1 more one -> [1,0,0]
bits = [0, 0, 1, 0, 0, 1, 1, 1, 0, 0]
result = decoder._bits_to_symbol(bits)
assert result == 100
@@ -393,14 +587,14 @@ class TestDSCDecoder:
assert result == -1
def test_detect_dot_pattern(self, decoder):
"""Test dot pattern detection."""
# Dot pattern is alternating 1010101...
decoder.bit_buffer = [1, 0] * 25 # 50 alternating bits
"""Test dot pattern detection with 200+ alternating bits."""
# Dot pattern requires at least 200 bits / 100 alternations
decoder.bit_buffer = [1, 0] * 110 # 220 alternating bits
assert decoder._detect_dot_pattern() is True
def test_detect_dot_pattern_insufficient(self, decoder):
"""Test dot pattern not detected with insufficient alternations."""
decoder.bit_buffer = [1, 0] * 5 # Only 10 bits
decoder.bit_buffer = [1, 0] * 40 # Only 80 bits, below 200 threshold
assert decoder._detect_dot_pattern() is False
def test_detect_dot_pattern_not_alternating(self, decoder):
@@ -408,22 +602,107 @@ class TestDSCDecoder:
decoder.bit_buffer = [1, 1, 1, 1, 0, 0, 0, 0] * 5
assert decoder._detect_dot_pattern() is False
def test_bounded_phasing_strip(self, decoder):
"""Test that >7 phasing symbols causes decode to return None."""
# Build message bits: 10 phasing symbols (120) + format + data
# Each symbol is 10 bits. Phasing symbol 120 = 0b1111000 LSB first
# 120 in 7 bits LSB-first: 0,0,0,1,1,1,1 + 3 check bits
# 120 = 0b1111000 -> LSB first: 0,0,0,1,1,1,1 -> ones=4 (even) -> check [0,0,0]
phasing_bits = [0, 0, 0, 1, 1, 1, 1, 0, 0, 0] # symbol 120
# 10 phasing symbols (>7 max)
decoder.message_bits = phasing_bits * 10
# Add some non-phasing symbols after (enough for a message)
# Symbol 112 (INDIVIDUAL) = 0b1110000 LSB-first: 0,0,0,0,1,1,1 -> ones=3 (odd) -> need odd check
# For simplicity, just add enough bits for the decoder to attempt
for _ in range(20):
decoder.message_bits.extend([0, 0, 0, 0, 1, 1, 1, 1, 0, 0])
result = decoder._try_decode_message()
assert result is None
def test_eos_minimum_length(self, decoder):
"""Test that EOS found too early in the symbol stream is skipped."""
# Build a message where EOS appears at position 5 (< MIN_SYMBOLS_FOR_FORMAT=12)
# This should not be accepted as a valid message end
# Symbol 127 (EOS) = 0b1111111 LSB-first: 1,1,1,1,1,1,1 -> ones=7 (odd) -> check needs 1 one
# Use a simple approach: create symbols directly via _try_decode_message
# Create 5 normal symbols + EOS at position 5 — should be skipped
# Followed by more symbols and a real EOS at position 15
from utils.dsc.decoder import DSCDecoder
d = DSCDecoder()
# Build symbols manually: we need _try_decode_message to find EOS too early
# Symbol 112 = format code. We'll build 10 bits per symbol.
# Since check bit validation is now active, we need valid check bits.
# Symbol value 10 = 0b0001010 LSB-first: 0,1,0,1,0,0,0, ones=2 (even) -> check [0,0,0]
sym_10 = [0, 1, 0, 1, 0, 0, 0, 0, 0, 0]
# Symbol 127 (EOS) = 0b1111111, ones=7 (odd) -> check needs odd total -> [1,0,0]
sym_eos = [1, 1, 1, 1, 1, 1, 1, 1, 0, 0]
# 5 normal symbols + early EOS (should be skipped) + 8 more normal + real EOS
d.message_bits = sym_10 * 5 + sym_eos + sym_10 * 8 + sym_eos
result = d._try_decode_message()
# The early EOS at index 5 should be skipped; the one at index 14
# is past MIN_SYMBOLS_FOR_FORMAT so it can be accepted.
# But the message content is garbage, so _decode_symbols will likely
# return None for other reasons. The key test: it doesn't return a
# message truncated at position 5.
# Just verify no crash and either None or a valid longer message
# (not truncated at the early EOS)
assert result is None or len(result.get('raw', '')) > 18
def test_bits_to_symbol_check_bit_validation(self, decoder):
"""Test that _bits_to_symbol rejects symbols with invalid check bits."""
# Symbol 100 = 0b1100100 LSB-first: 0,0,1,0,0,1,1
# ones in data = 3, need total even -> check bits need 1 one
# Valid: [0,0,1,0,0,1,1, 1,0,0] -> total ones = 4 (even) -> valid
valid_bits = [0, 0, 1, 0, 0, 1, 1, 1, 0, 0]
assert decoder._bits_to_symbol(valid_bits) == 100
# Invalid: flip one check bit -> total ones = 5 (odd) -> invalid
invalid_bits = [0, 0, 1, 0, 0, 1, 1, 0, 0, 0]
assert decoder._bits_to_symbol(invalid_bits) == -1
def test_safety_is_critical(self):
"""Test that SAFETY category is marked as critical."""
import json
from utils.dsc.parser import parse_dsc_message
raw = json.dumps({
'type': 'dsc',
'format': 123,
'source_mmsi': '232123456',
'category': 'SAFETY',
'timestamp': '2025-01-15T12:00:00Z',
'raw': '123232123456100122',
})
msg = parse_dsc_message(raw)
assert msg is not None
assert msg['is_critical'] is True
class TestDSCConstants:
"""Tests for DSC constants."""
def test_format_codes_completeness(self):
"""Test that all standard format codes are defined."""
"""Test that all ITU-R M.493 format specifiers are defined."""
from utils.dsc.constants import FORMAT_CODES
# ITU-R M.493 format codes
assert 100 in FORMAT_CODES # DISTRESS
assert 102 in FORMAT_CODES # ALL_SHIPS
assert 106 in FORMAT_CODES # DISTRESS_ACK
assert 112 in FORMAT_CODES # INDIVIDUAL
assert 116 in FORMAT_CODES # ROUTINE
assert 118 in FORMAT_CODES # SAFETY
assert 120 in FORMAT_CODES # URGENCY
# ITU-R M.493 format specifiers (and only these)
expected_keys = {102, 112, 114, 116, 120, 123}
assert set(FORMAT_CODES.keys()) == expected_keys
def test_valid_format_specifiers_set(self):
"""Test VALID_FORMAT_SPECIFIERS matches FORMAT_CODES keys."""
from utils.dsc.constants import FORMAT_CODES, VALID_FORMAT_SPECIFIERS
assert set(FORMAT_CODES.keys()) == VALID_FORMAT_SPECIFIERS
def test_valid_eos_symbols(self):
"""Test VALID_EOS contains the three ITU-defined EOS symbols."""
from utils.dsc.constants import VALID_EOS
assert {117, 122, 127} == VALID_EOS
def test_distress_nature_codes_completeness(self):
"""Test that all distress nature codes are defined."""
@@ -458,13 +737,37 @@ class TestDSCConstants:
assert VHF_CHANNELS[70] == 156.525
def test_dsc_modulation_parameters(self):
"""Test DSC modulation constants."""
"""Test DSC modulation constants per ITU-R M.493."""
from utils.dsc.constants import (
DSC_BAUD_RATE,
DSC_MARK_FREQ,
DSC_SPACE_FREQ,
)
assert DSC_BAUD_RATE == 100
assert DSC_MARK_FREQ == 1800
assert DSC_SPACE_FREQ == 1200
assert DSC_BAUD_RATE == 1200
assert DSC_MARK_FREQ == 2100
assert DSC_SPACE_FREQ == 1300
def test_telecommand_codes_full(self):
"""Test TELECOMMAND_CODES_FULL covers 0-127 range."""
from utils.dsc.constants import TELECOMMAND_CODES_FULL
assert len(TELECOMMAND_CODES_FULL) == 128
# Known codes map correctly
assert TELECOMMAND_CODES_FULL[100] == 'F3E_G3E_ALL'
assert TELECOMMAND_CODES_FULL[107] == 'DISTRESS_ACK'
# Unknown codes map to "UNKNOWN"
assert TELECOMMAND_CODES_FULL[0] == 'UNKNOWN'
assert TELECOMMAND_CODES_FULL[99] == 'UNKNOWN'
def test_telecommand_formats(self):
"""Test TELECOMMAND_FORMATS contains correct format codes."""
from utils.dsc.constants import TELECOMMAND_FORMATS
assert {112, 114, 116, 120, 123} == TELECOMMAND_FORMATS
def test_min_symbols_for_format(self):
"""Test MIN_SYMBOLS_FOR_FORMAT constant."""
from utils.dsc.constants import MIN_SYMBOLS_FOR_FORMAT
assert MIN_SYMBOLS_FOR_FORMAT == 12
+78
View File
@@ -0,0 +1,78 @@
"""Tests for GPS route behavior and gps client callback management."""
from routes import gps as gps_routes
from utils.gps import GPSDClient
def test_gpsd_client_add_callback_deduplicates():
"""Adding the same position callback twice should only register once."""
client = GPSDClient()
def callback(_position):
return None
client.add_callback(callback)
client.add_callback(callback)
assert client._callbacks.count(callback) == 1
def test_gpsd_client_add_sky_callback_deduplicates():
"""Adding the same sky callback twice should only register once."""
client = GPSDClient()
def callback(_sky):
return None
client.add_sky_callback(callback)
client.add_sky_callback(callback)
assert client._sky_callbacks.count(callback) == 1
def test_auto_connect_attaches_callbacks_when_reader_already_running(client, monkeypatch):
"""Auto-connect should re-attach stream callbacks for an already-running reader."""
class FakeReader:
is_running = True
position = None
sky = None
def __init__(self):
self.position_callbacks = []
self.sky_callbacks = []
def add_callback(self, callback):
self.position_callbacks.append(callback)
def add_sky_callback(self, callback):
self.sky_callbacks.append(callback)
reader = FakeReader()
monkeypatch.setattr(gps_routes, 'get_gps_reader', lambda: reader)
with client.session_transaction() as sess:
sess['logged_in'] = True
response = client.post('/gps/auto-connect')
payload = response.get_json()
assert response.status_code == 200
assert payload['status'] == 'connected'
assert reader.position_callbacks == [gps_routes._position_callback]
assert reader.sky_callbacks == [gps_routes._sky_callback]
def test_satellites_returns_waiting_when_reader_not_running(client, monkeypatch):
"""Satellite endpoint should return a non-error waiting state when reader is down."""
monkeypatch.setattr(gps_routes, 'get_gps_reader', lambda: None)
with client.session_transaction() as sess:
sess['logged_in'] = True
response = client.get('/gps/satellites')
payload = response.get_json()
assert response.status_code == 200
assert payload['status'] == 'waiting'
assert payload['running'] is False
+631
View File
@@ -0,0 +1,631 @@
"""Tests for Morse code decoder pipeline and lifecycle routes."""
from __future__ import annotations
import io
import math
import os
import queue
import struct
import threading
import time
import wave
from collections import Counter
import app as app_module
import routes.morse as morse_routes
from utils.morse import (
CHAR_TO_MORSE,
MORSE_TABLE,
EnvelopeDetector,
GoertzelFilter,
MorseDecoder,
decode_morse_wav_file,
morse_decoder_thread,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _login_session(client) -> None:
"""Mark the Flask test session as authenticated."""
with client.session_transaction() as sess:
sess['logged_in'] = True
sess['username'] = 'test'
sess['role'] = 'admin'
def generate_tone(freq: float, duration: float, sample_rate: int = 8000, amplitude: float = 0.8) -> bytes:
"""Generate a pure sine wave as 16-bit LE PCM bytes."""
n_samples = int(sample_rate * duration)
samples = []
for i in range(n_samples):
t = i / sample_rate
val = int(amplitude * 32767 * math.sin(2 * math.pi * freq * t))
samples.append(max(-32768, min(32767, val)))
return struct.pack(f'<{len(samples)}h', *samples)
def generate_silence(duration: float, sample_rate: int = 8000) -> bytes:
"""Generate silence as 16-bit LE PCM bytes."""
n_samples = int(sample_rate * duration)
return b'\x00\x00' * n_samples
def generate_morse_audio(text: str, wpm: int = 15, tone_freq: float = 700.0, sample_rate: int = 8000) -> bytes:
"""Generate synthetic CW PCM for the given text."""
dit_dur = 1.2 / wpm
dah_dur = 3 * dit_dur
element_gap = dit_dur
char_gap = 3 * dit_dur
word_gap = 7 * dit_dur
audio = b''
words = text.upper().split()
for wi, word in enumerate(words):
for ci, char in enumerate(word):
morse = CHAR_TO_MORSE.get(char)
if morse is None:
continue
for ei, element in enumerate(morse):
if element == '.':
audio += generate_tone(tone_freq, dit_dur, sample_rate)
elif element == '-':
audio += generate_tone(tone_freq, dah_dur, sample_rate)
if ei < len(morse) - 1:
audio += generate_silence(element_gap, sample_rate)
if ci < len(word) - 1:
audio += generate_silence(char_gap, sample_rate)
if wi < len(words) - 1:
audio += generate_silence(word_gap, sample_rate)
# Leading/trailing silence for threshold settling.
return generate_silence(0.3, sample_rate) + audio + generate_silence(0.3, sample_rate)
def write_wav(path, pcm_bytes: bytes, sample_rate: int = 8000) -> None:
"""Write mono 16-bit PCM bytes to a WAV file."""
with wave.open(str(path), 'wb') as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(sample_rate)
wf.writeframes(pcm_bytes)
def decode_text_from_events(events) -> str:
out = []
for ev in events:
if ev.get('type') == 'morse_char':
out.append(str(ev.get('char', '')))
elif ev.get('type') == 'morse_space':
out.append(' ')
return ''.join(out)
# ---------------------------------------------------------------------------
# Unit tests
# ---------------------------------------------------------------------------
class TestMorseTable:
def test_morse_table_contains_letters_and_digits(self):
chars = set(MORSE_TABLE.values())
for ch in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789':
assert ch in chars
def test_round_trip_morse_lookup(self):
for morse, char in MORSE_TABLE.items():
if char in CHAR_TO_MORSE:
assert CHAR_TO_MORSE[char] == morse
class TestToneDetector:
def test_goertzel_prefers_target_frequency(self):
gf = GoertzelFilter(target_freq=700.0, sample_rate=8000, block_size=160)
on_tone = [0.8 * math.sin(2 * math.pi * 700.0 * i / 8000.0) for i in range(160)]
off_tone = [0.8 * math.sin(2 * math.pi * 1500.0 * i / 8000.0) for i in range(160)]
assert gf.magnitude(on_tone) > gf.magnitude(off_tone) * 3.0
class TestEnvelopeDetector:
def test_magnitude_of_silence_is_near_zero(self):
det = EnvelopeDetector(block_size=160)
silence = [0.0] * 160
assert det.magnitude(silence) < 1e-6
def test_magnitude_of_constant_amplitude(self):
det = EnvelopeDetector(block_size=160)
loud = [0.8] * 160
mag = det.magnitude(loud)
assert abs(mag - 0.8) < 0.01
def test_magnitude_of_sine_wave(self):
det = EnvelopeDetector(block_size=160)
samples = [0.5 * math.sin(2 * math.pi * 700 * i / 8000.0) for i in range(160)]
mag = det.magnitude(samples)
# RMS of a sine at amplitude 0.5 is 0.5/sqrt(2) ~ 0.354
assert 0.30 < mag < 0.40
def test_magnitude_with_numpy_array(self):
import numpy as np
det = EnvelopeDetector(block_size=100)
arr = np.ones(100, dtype=np.float64) * 0.6
assert abs(det.magnitude(arr) - 0.6) < 0.01
def test_empty_samples_returns_zero(self):
det = EnvelopeDetector(block_size=0)
assert det.magnitude([]) == 0.0
class TestEnvelopeMorseDecoder:
def test_envelope_decoder_detects_ook_elements(self):
"""Verify envelope mode can distinguish on/off keying."""
sample_rate = 48000
wpm = 15
dit_dur = 1.2 / wpm
def ook_on(duration):
n = int(sample_rate * duration)
return struct.pack(f'<{n}h', *([int(0.7 * 32767)] * n))
def ook_off(duration):
n = int(sample_rate * duration)
return b'\x00\x00' * n
# Generate dit-dah (A = .-)
audio = (
ook_off(0.3)
+ ook_on(dit_dur)
+ ook_off(dit_dur)
+ ook_on(3 * dit_dur)
+ ook_off(0.5)
)
decoder = MorseDecoder(
sample_rate=sample_rate,
tone_freq=700.0,
wpm=wpm,
detect_mode='envelope',
)
events = decoder.process_block(audio)
events.extend(decoder.flush())
elements = [e['element'] for e in events if e.get('type') == 'morse_element']
assert '.' in elements
assert '-' in elements
def test_envelope_metrics_have_zero_snr(self):
"""Envelope mode metrics should report zero SNR fields."""
decoder = MorseDecoder(
sample_rate=8000,
detect_mode='envelope',
)
metrics = decoder.get_metrics()
assert metrics['detect_mode'] == 'envelope'
assert metrics['snr'] == 0.0
assert metrics['noise_ref'] == 0.0
def test_goertzel_mode_unchanged(self):
"""Default goertzel mode still works as before."""
decoder = MorseDecoder(sample_rate=8000, wpm=15)
assert decoder.detect_mode == 'goertzel'
metrics = decoder.get_metrics()
assert 'detect_mode' in metrics
assert metrics['detect_mode'] == 'goertzel'
class TestTimingAndWpmEstimator:
def test_timing_classifier_distinguishes_dit_and_dah(self):
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15)
dit = 1.2 / 15.0
dah = dit * 3.0
audio = (
generate_silence(0.35)
+ generate_tone(700.0, dit)
+ generate_silence(dit * 1.5)
+ generate_tone(700.0, dah)
+ generate_silence(0.35)
)
events = decoder.process_block(audio)
events.extend(decoder.flush())
elements = [e['element'] for e in events if e.get('type') == 'morse_element']
assert '.' in elements
assert '-' in elements
def test_wpm_estimator_sanity(self):
target_wpm = 18
audio = generate_morse_audio('PARIS PARIS PARIS', wpm=target_wpm)
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=12, wpm_mode='auto')
events = decoder.process_block(audio)
events.extend(decoder.flush())
metrics = decoder.get_metrics()
assert metrics['wpm'] >= 10.0
assert metrics['wpm'] <= 35.0
# ---------------------------------------------------------------------------
# Decoder thread tests
# ---------------------------------------------------------------------------
class TestMorseDecoderThread:
def test_thread_emits_waiting_heartbeat_on_no_data(self):
stop_event = threading.Event()
output_queue = queue.Queue(maxsize=64)
read_fd, write_fd = os.pipe()
read_file = os.fdopen(read_fd, 'rb', 0)
worker = threading.Thread(
target=morse_decoder_thread,
args=(read_file, output_queue, stop_event),
daemon=True,
)
worker.start()
got_waiting = False
deadline = time.monotonic() + 3.5
while time.monotonic() < deadline:
try:
msg = output_queue.get(timeout=0.3)
except queue.Empty:
continue
if msg.get('type') == 'scope' and msg.get('waiting'):
got_waiting = True
break
stop_event.set()
os.close(write_fd)
read_file.close()
worker.join(timeout=2.0)
assert got_waiting is True
assert not worker.is_alive()
def test_thread_produces_character_events(self):
stop_event = threading.Event()
output_queue = queue.Queue(maxsize=512)
audio = generate_morse_audio('SOS', wpm=15)
worker = threading.Thread(
target=morse_decoder_thread,
args=(io.BytesIO(audio), output_queue, stop_event),
daemon=True,
)
worker.start()
worker.join(timeout=4.0)
events = []
while not output_queue.empty():
events.append(output_queue.get_nowait())
chars = [e for e in events if e.get('type') == 'morse_char']
assert len(chars) >= 1
# ---------------------------------------------------------------------------
# Route lifecycle regression
# ---------------------------------------------------------------------------
class TestMorseLifecycleRoutes:
def _reset_route_state(self):
with app_module.morse_lock:
app_module.morse_process = None
while not app_module.morse_queue.empty():
try:
app_module.morse_queue.get_nowait()
except queue.Empty:
break
morse_routes.morse_active_device = None
morse_routes.morse_decoder_worker = None
morse_routes.morse_stderr_worker = None
morse_routes.morse_relay_worker = None
morse_routes.morse_stop_event = None
morse_routes.morse_control_queue = None
morse_routes.morse_runtime_config = {}
morse_routes.morse_last_error = ''
morse_routes.morse_state = morse_routes.MORSE_IDLE
morse_routes.morse_state_message = 'Idle'
def test_start_stop_reaches_idle_and_releases_resources(self, client, monkeypatch):
_login_session(client)
self._reset_route_state()
released_devices = []
monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode, sdr_type='rtlsdr': None)
monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx, sdr_type='rtlsdr': released_devices.append(idx))
class DummyDevice:
sdr_type = morse_routes.SDRType.RTL_SDR
class DummyBuilder:
def build_fm_demod_command(self, **kwargs):
return ['rtl_fm', '-f', '14060000', '-']
monkeypatch.setattr(morse_routes.SDRFactory, 'create_default_device', staticmethod(lambda sdr_type, index: DummyDevice()))
monkeypatch.setattr(morse_routes.SDRFactory, 'get_builder', staticmethod(lambda sdr_type: DummyBuilder()))
monkeypatch.setattr(morse_routes.SDRFactory, 'detect_devices', staticmethod(lambda: []))
pcm = generate_morse_audio('E', wpm=15, sample_rate=22050)
class FakeRtlProc:
def __init__(self, payload: bytes):
self.stdout = io.BytesIO(payload)
self.stderr = io.BytesIO(b'')
self.returncode = None
def poll(self):
return self.returncode
def terminate(self):
self.returncode = 0
def wait(self, timeout=None):
self.returncode = 0
return 0
def kill(self):
self.returncode = -9
def fake_popen(cmd, *args, **kwargs):
return FakeRtlProc(pcm)
monkeypatch.setattr(morse_routes.subprocess, 'Popen', fake_popen)
monkeypatch.setattr(morse_routes, 'register_process', lambda _proc: None)
monkeypatch.setattr(morse_routes, 'unregister_process', lambda _proc: None)
monkeypatch.setattr(
morse_routes,
'safe_terminate',
lambda proc, timeout=0.0: setattr(proc, 'returncode', 0),
)
start_resp = client.post('/morse/start', json={
'frequency': '14.060',
'gain': '20',
'ppm': '0',
'device': '0',
'tone_freq': '700',
'wpm': '15',
})
assert start_resp.status_code == 200
assert start_resp.get_json()['status'] == 'started'
status_resp = client.get('/morse/status')
assert status_resp.status_code == 200
assert status_resp.get_json()['state'] in {'running', 'starting', 'stopping', 'idle'}
stop_resp = client.post('/morse/stop')
assert stop_resp.status_code == 200
stop_data = stop_resp.get_json()
assert stop_data['status'] == 'stopped'
assert stop_data['state'] == 'idle'
assert stop_data['alive'] == []
final_status = client.get('/morse/status').get_json()
assert final_status['running'] is False
assert final_status['state'] == 'idle'
assert 0 in released_devices
def test_start_retries_after_early_process_exit(self, client, monkeypatch):
_login_session(client)
self._reset_route_state()
released_devices = []
monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode, sdr_type='rtlsdr': None)
monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx, sdr_type='rtlsdr': released_devices.append(idx))
class DummyDevice:
sdr_type = morse_routes.SDRType.RTL_SDR
class DummyBuilder:
def build_fm_demod_command(self, **kwargs):
cmd = ['rtl_fm', '-f', '14.060M', '-M', 'usb', '-s', '22050']
if kwargs.get('direct_sampling') is not None:
cmd.extend(['--direct', str(kwargs['direct_sampling'])])
cmd.append('-')
return cmd
monkeypatch.setattr(morse_routes.SDRFactory, 'create_default_device', staticmethod(lambda sdr_type, index: DummyDevice()))
monkeypatch.setattr(morse_routes.SDRFactory, 'get_builder', staticmethod(lambda sdr_type: DummyBuilder()))
monkeypatch.setattr(morse_routes.SDRFactory, 'detect_devices', staticmethod(lambda: []))
pcm = generate_morse_audio('E', wpm=15, sample_rate=22050)
rtl_cmds = []
class FakeRtlProc:
def __init__(self, stdout_bytes: bytes, returncode: int | None):
self.stdout = io.BytesIO(stdout_bytes)
self.stderr = io.BytesIO(b'')
self.returncode = returncode
def poll(self):
return self.returncode
def terminate(self):
self.returncode = 0
def wait(self, timeout=None):
self.returncode = 0
return 0
def kill(self):
self.returncode = -9
def fake_popen(cmd, *args, **kwargs):
rtl_cmds.append(cmd)
if len(rtl_cmds) == 1:
return FakeRtlProc(b'', 1)
return FakeRtlProc(pcm, None)
monkeypatch.setattr(morse_routes.subprocess, 'Popen', fake_popen)
monkeypatch.setattr(morse_routes, 'register_process', lambda _proc: None)
monkeypatch.setattr(morse_routes, 'unregister_process', lambda _proc: None)
monkeypatch.setattr(
morse_routes,
'safe_terminate',
lambda proc, timeout=0.0: setattr(proc, 'returncode', 0),
)
start_resp = client.post('/morse/start', json={
'frequency': '14.060',
'gain': '20',
'ppm': '0',
'device': '0',
'tone_freq': '700',
'wpm': '15',
})
assert start_resp.status_code == 200
assert start_resp.get_json()['status'] == 'started'
assert len(rtl_cmds) >= 2
assert rtl_cmds[0][0] == 'rtl_fm'
assert '--direct' in rtl_cmds[0]
assert '2' in rtl_cmds[0]
assert rtl_cmds[1][0] == 'rtl_fm'
assert '--direct' in rtl_cmds[1]
assert '1' in rtl_cmds[1]
stop_resp = client.post('/morse/stop')
assert stop_resp.status_code == 200
assert stop_resp.get_json()['status'] == 'stopped'
assert 0 in released_devices
def test_start_falls_back_to_next_device_when_selected_device_has_no_pcm(self, client, monkeypatch):
_login_session(client)
self._reset_route_state()
released_devices = []
monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode, sdr_type='rtlsdr': None)
monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx, sdr_type='rtlsdr': released_devices.append(idx))
class DummyDevice:
def __init__(self, index: int):
self.sdr_type = morse_routes.SDRType.RTL_SDR
self.index = index
class DummyDetected:
def __init__(self, index: int, serial: str):
self.sdr_type = morse_routes.SDRType.RTL_SDR
self.index = index
self.name = f'RTL {index}'
self.serial = serial
class DummyBuilder:
def build_fm_demod_command(self, **kwargs):
cmd = ['rtl_fm', '-d', str(kwargs['device'].index), '-f', '14.060M', '-M', 'usb', '-s', '22050']
if kwargs.get('direct_sampling') is not None:
cmd.extend(['--direct', str(kwargs['direct_sampling'])])
cmd.append('-')
return cmd
monkeypatch.setattr(
morse_routes.SDRFactory,
'create_default_device',
staticmethod(lambda sdr_type, index: DummyDevice(int(index))),
)
monkeypatch.setattr(morse_routes.SDRFactory, 'get_builder', staticmethod(lambda sdr_type: DummyBuilder()))
monkeypatch.setattr(
morse_routes.SDRFactory,
'detect_devices',
staticmethod(lambda: [DummyDetected(0, 'AAA00000'), DummyDetected(1, 'BBB11111')]),
)
pcm = generate_morse_audio('E', wpm=15, sample_rate=22050)
class FakeRtlProc:
def __init__(self, stdout_bytes: bytes, returncode: int | None):
self.stdout = io.BytesIO(stdout_bytes)
self.stderr = io.BytesIO(b'')
self.returncode = returncode
def poll(self):
return self.returncode
def terminate(self):
self.returncode = 0
def wait(self, timeout=None):
self.returncode = 0
return 0
def kill(self):
self.returncode = -9
def fake_popen(cmd, *args, **kwargs):
try:
dev = int(cmd[cmd.index('-d') + 1])
except Exception:
dev = 0
if dev == 0:
return FakeRtlProc(b'', 1)
return FakeRtlProc(pcm, None)
monkeypatch.setattr(morse_routes.subprocess, 'Popen', fake_popen)
monkeypatch.setattr(morse_routes, 'register_process', lambda _proc: None)
monkeypatch.setattr(morse_routes, 'unregister_process', lambda _proc: None)
monkeypatch.setattr(
morse_routes,
'safe_terminate',
lambda proc, timeout=0.0: setattr(proc, 'returncode', 0),
)
start_resp = client.post('/morse/start', json={
'frequency': '14.060',
'gain': '20',
'ppm': '0',
'device': '0',
'tone_freq': '700',
'wpm': '15',
})
assert start_resp.status_code == 200
start_data = start_resp.get_json()
assert start_data['status'] == 'started'
assert start_data['config']['active_device'] == 1
assert start_data['config']['device_serial'] == 'BBB11111'
assert 0 in released_devices
stop_resp = client.post('/morse/stop')
assert stop_resp.status_code == 200
assert stop_resp.get_json()['status'] == 'stopped'
assert 1 in released_devices
# ---------------------------------------------------------------------------
# Integration: synthetic CW -> WAV decode
# ---------------------------------------------------------------------------
class TestMorseIntegration:
def test_decode_morse_wav_contains_expected_phrase(self, tmp_path):
wav_path = tmp_path / 'cq_test_123.wav'
pcm = generate_morse_audio('CQ TEST 123', wpm=15, tone_freq=700.0)
write_wav(wav_path, pcm, sample_rate=8000)
result = decode_morse_wav_file(
wav_path,
sample_rate=8000,
tone_freq=700.0,
wpm=15,
bandwidth_hz=200,
auto_tone_track=True,
threshold_mode='auto',
wpm_mode='auto',
min_signal_gate=0.0,
)
decoded = ' '.join(str(result.get('text', '')).split())
assert 'CQ TEST 123' in decoded
events = result.get('events', [])
event_counts = Counter(e.get('type') for e in events)
assert event_counts['morse_char'] >= len('CQTEST123')
+57
View File
@@ -0,0 +1,57 @@
"""Tests for SSE fanout queue behavior."""
from __future__ import annotations
import queue
import time
import uuid
import pytest
from utils.sse import subscribe_fanout_queue
def _channel_key(prefix: str) -> str:
return f"{prefix}-{uuid.uuid4()}"
def test_fanout_drains_source_queue_without_subscribers() -> None:
"""Queued messages should be dropped while no SSE clients are connected."""
source = queue.Queue()
channel_key = _channel_key("sse-idle")
# Start fanout distributor, then remove the only subscriber.
_, unsubscribe = subscribe_fanout_queue(source, channel_key=channel_key, source_timeout=0.01)
unsubscribe()
source.put({"type": "aprs", "callsign": "N0CALL"})
time.sleep(0.05)
assert source.qsize() == 0
def test_fanout_does_not_replay_stale_message_after_re_subscribe() -> None:
"""A message queued while disconnected should not be replayed on reconnect."""
source = queue.Queue()
channel_key = _channel_key("sse-resub")
_, unsubscribe = subscribe_fanout_queue(source, channel_key=channel_key, source_timeout=0.01)
unsubscribe()
source.put({"type": "aprs", "callsign": "K1ABC"})
subscriber, unsubscribe2 = subscribe_fanout_queue(
source,
channel_key=channel_key,
source_timeout=0.01,
)
try:
with pytest.raises(queue.Empty):
subscriber.get(timeout=0.1)
live = {"type": "aprs", "callsign": "LIVE01"}
source.put(live)
got = subscriber.get(timeout=0.25)
finally:
unsubscribe2()
assert got == live
+214 -188
View File
@@ -76,12 +76,12 @@ class TestReceive:
mock_proc.stderr = MagicMock()
mock_proc.stderr.readline = MagicMock(return_value=b'')
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \
patch('subprocess.Popen', return_value=mock_proc), \
patch.object(manager, 'check_hackrf_device', return_value=True), \
patch('utils.subghz.register_process'):
manager._hackrf_available = None
result = manager.start_receive(
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \
patch('subprocess.Popen', return_value=mock_proc), \
patch.object(manager, 'check_hackrf_device', return_value=True), \
patch('utils.subghz.register_process'):
manager._hackrf_available = None
result = manager.start_receive(
frequency_hz=433920000,
sample_rate=2000000,
lna_gain=32,
@@ -92,9 +92,14 @@ class TestReceive:
assert manager.active_mode == 'rx'
def test_start_receive_already_running(self, manager):
import time as _time
mock_proc = MagicMock()
mock_proc.poll.return_value = None
manager._rx_process = mock_proc
# Pre-lock device checks now run before active_mode guard
manager._hackrf_available = True
manager._hackrf_device_cache = True
manager._hackrf_device_cache_ts = _time.time()
result = manager.start_receive(frequency_hz=433920000)
assert result['status'] == 'error'
@@ -104,10 +109,10 @@ class TestReceive:
result = manager.stop_receive()
assert result['status'] == 'not_running'
def test_stop_receive_creates_metadata(self, manager, tmp_data_dir):
# Create a fake IQ file
iq_file = tmp_data_dir / 'captures' / 'test.iq'
iq_file.write_bytes(b'\x00' * 1024)
def test_stop_receive_creates_metadata(self, manager, tmp_data_dir):
# Create a fake IQ file
iq_file = tmp_data_dir / 'captures' / 'test.iq'
iq_file.write_bytes(b'\x00' * 1024)
mock_proc = MagicMock()
mock_proc.poll.return_value = None
@@ -115,10 +120,10 @@ class TestReceive:
manager._rx_file = iq_file
manager._rx_frequency_hz = 433920000
manager._rx_sample_rate = 2000000
manager._rx_lna_gain = 32
manager._rx_vga_gain = 20
manager._rx_start_time = 1000.0
manager._rx_bursts = [{'start_seconds': 1.23, 'duration_seconds': 0.15, 'peak_level': 42}]
manager._rx_lna_gain = 32
manager._rx_vga_gain = 20
manager._rx_start_time = 1000.0
manager._rx_bursts = [{'start_seconds': 1.23, 'duration_seconds': 0.15, 'peak_level': 42}]
with patch('utils.subghz.safe_terminate'), \
patch('time.time', return_value=1005.0):
@@ -131,10 +136,10 @@ class TestReceive:
# Verify JSON sidecar was written
meta_path = iq_file.with_suffix('.json')
assert meta_path.exists()
meta = json.loads(meta_path.read_text())
assert meta['frequency_hz'] == 433920000
assert isinstance(meta.get('bursts'), list)
assert meta['bursts'][0]['peak_level'] == 42
meta = json.loads(meta_path.read_text())
assert meta['frequency_hz'] == 433920000
assert isinstance(meta.get('bursts'), list)
assert meta['bursts'][0]['peak_level'] == 42
class TestTxSafety:
@@ -165,13 +170,13 @@ class TestTxSafety:
result = manager.transmit(capture_id='abc123')
assert result['status'] == 'error'
def test_transmit_capture_not_found(self, manager):
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \
patch.object(manager, 'check_hackrf_device', return_value=True):
manager._hackrf_available = None
result = manager.transmit(capture_id='nonexistent')
assert result['status'] == 'error'
assert 'not found' in result['message']
def test_transmit_capture_not_found(self, manager):
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \
patch.object(manager, 'check_hackrf_device', return_value=True):
manager._hackrf_available = None
result = manager.transmit(capture_id='nonexistent')
assert result['status'] == 'error'
assert 'not found' in result['message']
def test_transmit_out_of_band_rejected(self, manager, tmp_data_dir):
# Create a capture with out-of-band frequency
@@ -188,64 +193,79 @@ class TestTxSafety:
meta_path.write_text(json.dumps(meta))
(tmp_data_dir / 'captures' / 'test.iq').write_bytes(b'\x00' * 100)
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \
patch.object(manager, 'check_hackrf_device', return_value=True):
manager._hackrf_available = None
result = manager.transmit(capture_id='test123')
assert result['status'] == 'error'
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \
patch.object(manager, 'check_hackrf_device', return_value=True):
manager._hackrf_available = None
result = manager.transmit(capture_id='test123')
assert result['status'] == 'error'
assert 'outside allowed TX bands' in result['message']
def test_transmit_already_running(self, manager):
mock_proc = MagicMock()
mock_proc.poll.return_value = None
manager._rx_process = mock_proc
result = manager.transmit(capture_id='test123')
assert result['status'] == 'error'
assert 'Already running' in result['message']
def test_transmit_segment_extracts_range(self, manager, tmp_data_dir):
meta = {
'id': 'seg001',
'filename': 'seg.iq',
'frequency_hz': 433920000,
'sample_rate': 1000,
'lna_gain': 24,
'vga_gain': 20,
'timestamp': '2026-01-01T00:00:00Z',
'duration_seconds': 1.0,
'size_bytes': 2000,
}
(tmp_data_dir / 'captures' / 'seg.json').write_text(json.dumps(meta))
(tmp_data_dir / 'captures' / 'seg.iq').write_bytes(bytes(range(200)) * 10)
mock_proc = MagicMock()
mock_proc.poll.return_value = None
mock_timer = MagicMock()
mock_timer.start = MagicMock()
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \
patch.object(manager, 'check_hackrf_device', return_value=True), \
patch('subprocess.Popen', return_value=mock_proc), \
patch('utils.subghz.register_process'), \
patch('threading.Timer', return_value=mock_timer), \
patch('threading.Thread') as mock_thread_cls:
mock_thread = MagicMock()
mock_thread.start = MagicMock()
mock_thread_cls.return_value = mock_thread
manager._hackrf_available = None
result = manager.transmit(
capture_id='seg001',
start_seconds=0.2,
duration_seconds=0.3,
)
assert result['status'] == 'transmitting'
assert result['segment'] is not None
assert result['segment']['duration_seconds'] == pytest.approx(0.3, abs=0.01)
assert manager._tx_temp_file is not None
assert manager._tx_temp_file.exists()
def test_transmit_already_running(self, manager, tmp_data_dir):
import time as _time
mock_proc = MagicMock()
mock_proc.poll.return_value = None
manager._rx_process = mock_proc
# Pre-lock device checks now run before active_mode guard
manager._hackrf_available = True
manager._hackrf_device_cache = True
manager._hackrf_device_cache_ts = _time.time()
# Capture lookup also runs pre-lock now; provide a valid capture + IQ file
meta = {
'id': 'test123',
'filename': 'test.iq',
'frequency_hz': 433920000,
'sample_rate': 2000000,
'timestamp': '2025-01-01T00:00:00',
}
(tmp_data_dir / 'captures' / 'test.json').write_text(json.dumps(meta))
(tmp_data_dir / 'captures' / 'test.iq').write_bytes(b'\x00' * 64)
result = manager.transmit(capture_id='test123')
assert result['status'] == 'error'
assert 'Already running' in result['message']
def test_transmit_segment_extracts_range(self, manager, tmp_data_dir):
meta = {
'id': 'seg001',
'filename': 'seg.iq',
'frequency_hz': 433920000,
'sample_rate': 1000,
'lna_gain': 24,
'vga_gain': 20,
'timestamp': '2026-01-01T00:00:00Z',
'duration_seconds': 1.0,
'size_bytes': 2000,
}
(tmp_data_dir / 'captures' / 'seg.json').write_text(json.dumps(meta))
(tmp_data_dir / 'captures' / 'seg.iq').write_bytes(bytes(range(200)) * 10)
mock_proc = MagicMock()
mock_proc.poll.return_value = None
mock_timer = MagicMock()
mock_timer.start = MagicMock()
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \
patch.object(manager, 'check_hackrf_device', return_value=True), \
patch('subprocess.Popen', return_value=mock_proc), \
patch('utils.subghz.register_process'), \
patch('threading.Timer', return_value=mock_timer), \
patch('threading.Thread') as mock_thread_cls:
mock_thread = MagicMock()
mock_thread.start = MagicMock()
mock_thread_cls.return_value = mock_thread
manager._hackrf_available = None
result = manager.transmit(
capture_id='seg001',
start_seconds=0.2,
duration_seconds=0.3,
)
assert result['status'] == 'transmitting'
assert result['segment'] is not None
assert result['segment']['duration_seconds'] == pytest.approx(0.3, abs=0.01)
assert manager._tx_temp_file is not None
assert manager._tx_temp_file.exists()
class TestCaptureLibrary:
@@ -311,11 +331,11 @@ class TestCaptureLibrary:
def test_delete_capture_not_found(self, manager):
assert manager.delete_capture('nonexistent') is False
def test_update_label(self, manager, tmp_data_dir):
meta = {
'id': 'lbl001',
'filename': 'label_test.iq',
'frequency_hz': 433920000,
def test_update_label(self, manager, tmp_data_dir):
meta = {
'id': 'lbl001',
'filename': 'label_test.iq',
'frequency_hz': 433920000,
'sample_rate': 2000000,
'timestamp': '2026-01-01T00:00:00Z',
'label': '',
@@ -324,10 +344,10 @@ class TestCaptureLibrary:
meta_path.write_text(json.dumps(meta))
assert manager.update_capture_label('lbl001', 'Garage Remote') is True
updated = json.loads(meta_path.read_text())
assert updated['label'] == 'Garage Remote'
assert updated['label_source'] == 'manual'
updated = json.loads(meta_path.read_text())
assert updated['label'] == 'Garage Remote'
assert updated['label_source'] == 'manual'
def test_update_label_not_found(self, manager):
assert manager.update_capture_label('nonexistent', 'test') is False
@@ -348,100 +368,100 @@ class TestCaptureLibrary:
assert path is not None
assert path.name == 'path_test.iq'
def test_get_capture_path_not_found(self, manager):
assert manager.get_capture_path('nonexistent') is None
def test_trim_capture_manual_segment(self, manager, tmp_data_dir):
captures_dir = tmp_data_dir / 'captures'
iq_path = captures_dir / 'trim_src.iq'
iq_path.write_bytes(bytes(range(200)) * 20) # 4000 bytes at 1000 sps => 2.0s
(captures_dir / 'trim_src.json').write_text(json.dumps({
'id': 'trim001',
'filename': 'trim_src.iq',
'frequency_hz': 433920000,
'sample_rate': 1000,
'lna_gain': 24,
'vga_gain': 20,
'timestamp': '2026-01-01T00:00:00Z',
'duration_seconds': 2.0,
'size_bytes': 4000,
'label': 'Weather Burst',
'bursts': [
{
'start_seconds': 0.55,
'duration_seconds': 0.2,
'peak_level': 67,
'fingerprint': 'abc123',
'modulation_hint': 'OOK/ASK',
'modulation_confidence': 0.9,
}
],
}))
result = manager.trim_capture(
capture_id='trim001',
start_seconds=0.5,
duration_seconds=0.4,
)
assert result['status'] == 'ok'
assert result['capture']['id'] != 'trim001'
assert result['capture']['size_bytes'] == 800
assert result['capture']['label'].endswith('(Trim)')
trimmed_iq = captures_dir / result['capture']['filename']
assert trimmed_iq.exists()
trimmed_meta = trimmed_iq.with_suffix('.json')
assert trimmed_meta.exists()
def test_trim_capture_auto_burst(self, manager, tmp_data_dir):
captures_dir = tmp_data_dir / 'captures'
iq_path = captures_dir / 'auto_src.iq'
iq_path.write_bytes(bytes(range(100)) * 40) # 4000 bytes
(captures_dir / 'auto_src.json').write_text(json.dumps({
'id': 'trim002',
'filename': 'auto_src.iq',
'frequency_hz': 433920000,
'sample_rate': 1000,
'lna_gain': 24,
'vga_gain': 20,
'timestamp': '2026-01-01T00:00:00Z',
'duration_seconds': 2.0,
'size_bytes': 4000,
'bursts': [
{'start_seconds': 0.2, 'duration_seconds': 0.1, 'peak_level': 12},
{'start_seconds': 1.2, 'duration_seconds': 0.25, 'peak_level': 88},
],
}))
result = manager.trim_capture(capture_id='trim002')
assert result['status'] == 'ok'
assert result['segment']['auto_selected'] is True
assert result['capture']['duration_seconds'] > 0.25
def test_list_captures_groups_same_fingerprint(self, manager, tmp_data_dir):
cap_a = {
'id': 'grp001',
'filename': 'a.iq',
'frequency_hz': 433920000,
'sample_rate': 2000000,
'timestamp': '2026-01-01T00:00:00Z',
'dominant_fingerprint': 'deadbeefcafebabe',
}
cap_b = {
'id': 'grp002',
'filename': 'b.iq',
'frequency_hz': 433920000,
'sample_rate': 2000000,
'timestamp': '2026-01-01T00:01:00Z',
'dominant_fingerprint': 'deadbeefcafebabe',
}
(tmp_data_dir / 'captures' / 'a.json').write_text(json.dumps(cap_a))
(tmp_data_dir / 'captures' / 'b.json').write_text(json.dumps(cap_b))
captures = manager.list_captures()
assert len(captures) == 2
assert all(c.fingerprint_group.startswith('SIG-') for c in captures)
assert all(c.fingerprint_group_size == 2 for c in captures)
def test_get_capture_path_not_found(self, manager):
assert manager.get_capture_path('nonexistent') is None
def test_trim_capture_manual_segment(self, manager, tmp_data_dir):
captures_dir = tmp_data_dir / 'captures'
iq_path = captures_dir / 'trim_src.iq'
iq_path.write_bytes(bytes(range(200)) * 20) # 4000 bytes at 1000 sps => 2.0s
(captures_dir / 'trim_src.json').write_text(json.dumps({
'id': 'trim001',
'filename': 'trim_src.iq',
'frequency_hz': 433920000,
'sample_rate': 1000,
'lna_gain': 24,
'vga_gain': 20,
'timestamp': '2026-01-01T00:00:00Z',
'duration_seconds': 2.0,
'size_bytes': 4000,
'label': 'Weather Burst',
'bursts': [
{
'start_seconds': 0.55,
'duration_seconds': 0.2,
'peak_level': 67,
'fingerprint': 'abc123',
'modulation_hint': 'OOK/ASK',
'modulation_confidence': 0.9,
}
],
}))
result = manager.trim_capture(
capture_id='trim001',
start_seconds=0.5,
duration_seconds=0.4,
)
assert result['status'] == 'ok'
assert result['capture']['id'] != 'trim001'
assert result['capture']['size_bytes'] == 800
assert result['capture']['label'].endswith('(Trim)')
trimmed_iq = captures_dir / result['capture']['filename']
assert trimmed_iq.exists()
trimmed_meta = trimmed_iq.with_suffix('.json')
assert trimmed_meta.exists()
def test_trim_capture_auto_burst(self, manager, tmp_data_dir):
captures_dir = tmp_data_dir / 'captures'
iq_path = captures_dir / 'auto_src.iq'
iq_path.write_bytes(bytes(range(100)) * 40) # 4000 bytes
(captures_dir / 'auto_src.json').write_text(json.dumps({
'id': 'trim002',
'filename': 'auto_src.iq',
'frequency_hz': 433920000,
'sample_rate': 1000,
'lna_gain': 24,
'vga_gain': 20,
'timestamp': '2026-01-01T00:00:00Z',
'duration_seconds': 2.0,
'size_bytes': 4000,
'bursts': [
{'start_seconds': 0.2, 'duration_seconds': 0.1, 'peak_level': 12},
{'start_seconds': 1.2, 'duration_seconds': 0.25, 'peak_level': 88},
],
}))
result = manager.trim_capture(capture_id='trim002')
assert result['status'] == 'ok'
assert result['segment']['auto_selected'] is True
assert result['capture']['duration_seconds'] > 0.25
def test_list_captures_groups_same_fingerprint(self, manager, tmp_data_dir):
cap_a = {
'id': 'grp001',
'filename': 'a.iq',
'frequency_hz': 433920000,
'sample_rate': 2000000,
'timestamp': '2026-01-01T00:00:00Z',
'dominant_fingerprint': 'deadbeefcafebabe',
}
cap_b = {
'id': 'grp002',
'filename': 'b.iq',
'frequency_hz': 433920000,
'sample_rate': 2000000,
'timestamp': '2026-01-01T00:01:00Z',
'dominant_fingerprint': 'deadbeefcafebabe',
}
(tmp_data_dir / 'captures' / 'a.json').write_text(json.dumps(cap_a))
(tmp_data_dir / 'captures' / 'b.json').write_text(json.dumps(cap_b))
captures = manager.list_captures()
assert len(captures) == 2
assert all(c.fingerprint_group.startswith('SIG-') for c in captures)
assert all(c.fingerprint_group_size == 2 for c in captures)
class TestSweep:
@@ -452,6 +472,7 @@ class TestSweep:
assert result['status'] == 'error'
def test_start_sweep_success(self, manager):
import time as _time
mock_proc = MagicMock()
mock_proc.poll.return_value = None
mock_proc.stdout = MagicMock()
@@ -460,6 +481,8 @@ class TestSweep:
patch('subprocess.Popen', return_value=mock_proc), \
patch('utils.subghz.register_process'):
manager._sweep_available = None
manager._hackrf_device_cache = True
manager._hackrf_device_cache_ts = _time.time()
result = manager.start_sweep(freq_start_mhz=300, freq_end_mhz=928)
assert result['status'] == 'started'
@@ -517,8 +540,11 @@ class TestDecode:
with patch('shutil.which', return_value='/usr/bin/tool'), \
patch('subprocess.Popen', side_effect=popen_side_effect) as mock_popen, \
patch('utils.subghz.register_process'):
import time as _time
manager._hackrf_available = None
manager._rtl433_available = None
manager._hackrf_device_cache = True
manager._hackrf_device_cache_ts = _time.time()
result = manager.start_decode(
frequency_hz=433920000,
sample_rate=2000000,
@@ -536,10 +562,10 @@ class TestDecode:
assert '-r' in hackrf_cmd
# Verify rtl_433 command
rtl433_cmd = mock_popen.call_args_list[1][0][0]
assert rtl433_cmd[0] == 'rtl_433'
assert '-r' in rtl433_cmd
assert 'cs8:-' in rtl433_cmd
rtl433_cmd = mock_popen.call_args_list[1][0][0]
assert rtl433_cmd[0] == 'rtl_433'
assert '-r' in rtl433_cmd
assert 'cs8:-' in rtl433_cmd
# Both processes tracked
assert manager._decode_hackrf_process is mock_hackrf_proc
+230
View File
@@ -0,0 +1,230 @@
"""Tests for the System Health monitoring blueprint."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
def _login(client):
"""Mark the Flask test session as authenticated."""
with client.session_transaction() as sess:
sess['logged_in'] = True
sess['username'] = 'test'
sess['role'] = 'admin'
def test_metrics_returns_expected_keys(client):
"""GET /system/metrics returns top-level metric keys."""
_login(client)
resp = client.get('/system/metrics')
assert resp.status_code == 200
data = resp.get_json()
assert 'system' in data
assert 'processes' in data
assert 'cpu' in data
assert 'memory' in data
assert 'disk' in data
assert data['system']['hostname']
assert 'version' in data['system']
assert 'uptime_seconds' in data['system']
assert 'uptime_human' in data['system']
def test_metrics_enhanced_keys(client):
"""GET /system/metrics returns enhanced metric keys."""
_login(client)
resp = client.get('/system/metrics')
assert resp.status_code == 200
data = resp.get_json()
# New enhanced keys
assert 'network' in data
assert 'disk_io' in data
assert 'boot_time' in data
assert 'battery' in data
assert 'fans' in data
assert 'power' in data
# CPU should have per_core and freq
if data['cpu'] is not None:
assert 'per_core' in data['cpu']
assert 'freq' in data['cpu']
# Network should have interfaces and connections
if data['network'] is not None:
assert 'interfaces' in data['network']
assert 'connections' in data['network']
assert 'io' in data['network']
def test_metrics_without_psutil(client):
"""Metrics degrade gracefully when psutil is unavailable."""
_login(client)
import routes.system as mod
orig = mod._HAS_PSUTIL
mod._HAS_PSUTIL = False
try:
resp = client.get('/system/metrics')
assert resp.status_code == 200
data = resp.get_json()
# These fields should be None without psutil
assert data['cpu'] is None
assert data['memory'] is None
assert data['disk'] is None
assert data['network'] is None
assert data['disk_io'] is None
assert data['battery'] is None
assert data['boot_time'] is None
assert data['power'] is None
finally:
mod._HAS_PSUTIL = orig
def test_sdr_devices_returns_list(client):
"""GET /system/sdr_devices returns a devices list."""
_login(client)
mock_device = MagicMock()
mock_device.sdr_type = MagicMock()
mock_device.sdr_type.value = 'rtlsdr'
mock_device.index = 0
mock_device.name = 'Generic RTL2832U'
mock_device.serial = '00000001'
mock_device.driver = 'rtlsdr'
with patch('utils.sdr.detection.detect_all_devices', return_value=[mock_device]):
resp = client.get('/system/sdr_devices')
assert resp.status_code == 200
data = resp.get_json()
assert 'devices' in data
assert len(data['devices']) == 1
assert data['devices'][0]['type'] == 'rtlsdr'
assert data['devices'][0]['name'] == 'Generic RTL2832U'
def test_sdr_devices_handles_detection_failure(client):
"""SDR detection failure returns empty list with error."""
_login(client)
with patch('utils.sdr.detection.detect_all_devices', side_effect=RuntimeError('no devices')):
resp = client.get('/system/sdr_devices')
assert resp.status_code == 200
data = resp.get_json()
assert data['devices'] == []
assert 'error' in data
def test_stream_returns_sse_content_type(client):
"""GET /system/stream returns text/event-stream."""
_login(client)
resp = client.get('/system/stream')
assert resp.status_code == 200
assert 'text/event-stream' in resp.content_type
def test_location_returns_shape(client):
"""GET /system/location returns lat/lon/source shape."""
_login(client)
resp = client.get('/system/location')
assert resp.status_code == 200
data = resp.get_json()
assert 'lat' in data
assert 'lon' in data
assert 'source' in data
def test_location_from_gps(client):
"""Location endpoint returns GPS data when fix available."""
_login(client)
mock_pos = MagicMock()
mock_pos.fix_quality = 3
mock_pos.latitude = 51.5074
mock_pos.longitude = -0.1278
mock_pos.satellites = 12
mock_pos.epx = 2.5
mock_pos.epy = 3.1
mock_pos.altitude = 45.0
with patch('routes.system.get_current_position', return_value=mock_pos, create=True):
# Patch the import inside the function
import routes.system as mod
original = mod._get_observer_location
def _patched():
with patch('utils.gps.get_current_position', return_value=mock_pos):
return original()
mod._get_observer_location = _patched
try:
resp = client.get('/system/location')
finally:
mod._get_observer_location = original
assert resp.status_code == 200
data = resp.get_json()
assert data['source'] == 'gps'
assert data['lat'] == 51.5074
assert data['lon'] == -0.1278
assert data['gps']['fix_quality'] == 3
assert data['gps']['satellites'] == 12
assert data['gps']['accuracy'] == 3.1
assert data['gps']['altitude'] == 45.0
def test_location_falls_back_to_defaults(client):
"""Location endpoint returns constants defaults when GPS and config unavailable."""
_login(client)
resp = client.get('/system/location')
assert resp.status_code == 200
data = resp.get_json()
assert 'source' in data
# Should get location from config or default constants
assert data['lat'] is not None
assert data['lon'] is not None
assert data['source'] in ('config', 'default')
def test_weather_requires_location(client):
"""Weather endpoint returns error when no location available."""
_login(client)
# Without lat/lon params and no GPS state or config
resp = client.get('/system/weather')
assert resp.status_code == 200
data = resp.get_json()
# Either returns weather or error (depending on config)
assert 'error' in data or 'temp_c' in data
def test_weather_with_mocked_response(client):
"""Weather endpoint returns parsed weather data with mocked HTTP."""
_login(client)
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = {
'current_condition': [{
'temp_C': '22',
'temp_F': '72',
'weatherDesc': [{'value': 'Clear'}],
'humidity': '45',
'windspeedMiles': '8',
'winddir16Point': 'NW',
'FeelsLikeC': '20',
'visibility': '10',
'pressure': '1013',
}]
}
mock_resp.raise_for_status = MagicMock()
import routes.system as mod
# Clear cache
mod._weather_cache.clear()
mod._weather_cache_time = 0.0
with patch('routes.system._requests') as mock_requests:
mock_requests.get.return_value = mock_resp
resp = client.get('/system/weather?lat=40.7&lon=-74.0')
assert resp.status_code == 200
data = resp.get_json()
assert data['temp_c'] == '22'
assert data['condition'] == 'Clear'
assert data['humidity'] == '45'
assert data['wind_mph'] == '8'
+20 -12
View File
@@ -73,9 +73,10 @@ class TestWeatherSatDecoder:
callback = MagicMock()
decoder.set_callback(callback)
success = decoder.start(satellite='NOAA-18', device_index=0, gain=40.0)
success, error_msg = decoder.start(satellite='NOAA-18', device_index=0, gain=40.0)
assert success is False
assert error_msg is not None
callback.assert_called()
progress = callback.call_args[0][0]
assert progress.status == 'error'
@@ -88,7 +89,7 @@ class TestWeatherSatDecoder:
callback = MagicMock()
decoder.set_callback(callback)
success = decoder.start(satellite='FAKE-SAT', device_index=0, gain=40.0)
success, error_msg = decoder.start(satellite='FAKE-SAT', device_index=0, gain=40.0)
assert success is False
callback.assert_called()
@@ -113,7 +114,7 @@ class TestWeatherSatDecoder:
callback = MagicMock()
decoder.set_callback(callback)
success = decoder.start(
success, error_msg = decoder.start(
satellite='NOAA-18',
device_index=0,
gain=40.0,
@@ -121,6 +122,7 @@ class TestWeatherSatDecoder:
)
assert success is True
assert error_msg is None
assert decoder.is_running is True
assert decoder.current_satellite == 'NOAA-18'
assert decoder.current_frequency == 137.9125
@@ -138,13 +140,15 @@ class TestWeatherSatDecoder:
@patch('pty.openpty')
def test_start_already_running(self, mock_pty, mock_popen):
"""start() should return True when already running."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
with patch('shutil.which', return_value='/usr/bin/satdump'), \
patch('utils.weather_sat.WeatherSatDecoder._resolve_device_id', return_value='0'):
decoder = WeatherSatDecoder()
decoder._running = True
success = decoder.start(satellite='NOAA-18', device_index=0, gain=40.0)
success, error_msg = decoder.start(satellite='NOAA-18', device_index=0, gain=40.0)
assert success is True
assert error_msg is None
mock_popen.assert_not_called()
@patch('subprocess.Popen')
@@ -159,9 +163,10 @@ class TestWeatherSatDecoder:
callback = MagicMock()
decoder.set_callback(callback)
success = decoder.start(satellite='NOAA-18', device_index=0, gain=40.0)
success, error_msg = decoder.start(satellite='NOAA-18', device_index=0, gain=40.0)
assert success is False
assert error_msg is not None
assert decoder.is_running is False
callback.assert_called()
progress = callback.call_args[0][0]
@@ -174,12 +179,13 @@ class TestWeatherSatDecoder:
callback = MagicMock()
decoder.set_callback(callback)
success = decoder.start_from_file(
success, error_msg = decoder.start_from_file(
satellite='NOAA-18',
input_file='data/test.wav',
)
assert success is False
assert error_msg is not None
callback.assert_called()
@patch('subprocess.Popen')
@@ -199,19 +205,21 @@ class TestWeatherSatDecoder:
mock_pty.return_value = (10, 11)
mock_process = MagicMock()
mock_process.poll.return_value = None # Process still running
mock_popen.return_value = mock_process
decoder = WeatherSatDecoder()
callback = MagicMock()
decoder.set_callback(callback)
success = decoder.start_from_file(
success, error_msg = decoder.start_from_file(
satellite='NOAA-18',
input_file='data/test.wav',
sample_rate=1000000,
)
assert success is True
assert error_msg is None
assert decoder.is_running is True
assert decoder.current_satellite == 'NOAA-18'
@@ -235,7 +243,7 @@ class TestWeatherSatDecoder:
callback = MagicMock()
decoder.set_callback(callback)
success = decoder.start_from_file(
success, error_msg = decoder.start_from_file(
satellite='NOAA-18',
input_file='/etc/passwd',
)
@@ -258,7 +266,7 @@ class TestWeatherSatDecoder:
callback = MagicMock()
decoder.set_callback(callback)
success = decoder.start_from_file(
success, error_msg = decoder.start_from_file(
satellite='NOAA-18',
input_file='data/missing.wav',
)
@@ -425,12 +433,12 @@ class TestWeatherSatDecoder:
@patch('subprocess.run')
def test_resolve_device_id_fallback(self, mock_run):
"""_resolve_device_id() should fall back to index string."""
"""_resolve_device_id() should return None when no serial found."""
mock_run.side_effect = FileNotFoundError
serial = WeatherSatDecoder._resolve_device_id(0)
assert serial == '0'
assert serial is None
def test_parse_product_name_rgb(self):
"""_parse_product_name() should identify RGB composite."""
+121
View File
@@ -0,0 +1,121 @@
"""Targeted regression tests for recent weather-satellite hardening fixes."""
from __future__ import annotations
import re
from unittest.mock import MagicMock, patch
import pytest
from utils.weather_sat import WeatherSatDecoder
@pytest.fixture
def authed_client(client):
"""Return a logged-in test client for authenticated weather-sat routes."""
with client.session_transaction() as session:
session['logged_in'] = True
return client
class TestWeatherSatRouteReleaseGuards:
"""Regression tests for safe SDR release behavior in weather-sat routes."""
def test_stop_does_not_release_device_owned_by_other_mode(self, authed_client):
"""POST /weather-sat/stop should not release a foreign-owned SDR device."""
mock_decoder = MagicMock()
mock_decoder.device_index = 2
with patch('routes.weather_sat.get_weather_sat_decoder', return_value=mock_decoder), \
patch('app.get_sdr_device_status', return_value={2: 'wifi'}), \
patch('app.release_sdr_device') as mock_release:
response = authed_client.post('/weather-sat/stop')
assert response.status_code == 200
assert response.get_json()['status'] == 'stopped'
mock_decoder.stop.assert_called_once()
mock_release.assert_not_called()
def test_stop_releases_device_owned_by_weather_sat(self, authed_client):
"""POST /weather-sat/stop should release SDR when weather-sat owns it."""
mock_decoder = MagicMock()
mock_decoder.device_index = 2
with patch('routes.weather_sat.get_weather_sat_decoder', return_value=mock_decoder), \
patch('app.get_sdr_device_status', return_value={2: 'weather_sat'}), \
patch('app.release_sdr_device') as mock_release:
response = authed_client.post('/weather-sat/stop')
assert response.status_code == 200
assert response.get_json()['status'] == 'stopped'
mock_decoder.stop.assert_called_once()
mock_release.assert_called_once_with(2)
def test_stop_skips_release_for_offline_decode_index(self, authed_client):
"""POST /weather-sat/stop should not release when decoder index is -1."""
mock_decoder = MagicMock()
mock_decoder.device_index = -1
with patch('routes.weather_sat.get_weather_sat_decoder', return_value=mock_decoder), \
patch('app.release_sdr_device') as mock_release:
response = authed_client.post('/weather-sat/stop')
assert response.status_code == 200
assert response.get_json()['status'] == 'stopped'
mock_decoder.stop.assert_called_once()
mock_release.assert_not_called()
class TestWeatherSatDecoderRegressions:
"""Regression tests for decoder filename and offline-device handling."""
def test_scan_output_dir_preserves_extension_and_sanitizes_filename(self, tmp_path):
"""Copied image names should stay safe and preserve JPG/JPEG extensions."""
output_dir = tmp_path / 'weather_sat_out'
capture_dir = tmp_path / 'capture'
capture_dir.mkdir(parents=True)
source_image = capture_dir / 'channel 3 (raw).jpeg'
source_image.write_bytes(b'\xff\xd8\xff' + b'\x00' * 2048)
with patch('shutil.which', return_value='/usr/bin/satdump'):
decoder = WeatherSatDecoder(output_dir=output_dir)
decoder._capture_output_dir = capture_dir
decoder._current_satellite = 'METEOR-M2-4'
decoder._current_mode = 'LRPT'
decoder._current_frequency = 137.9
decoder._scan_output_dir(set())
assert len(decoder._images) == 1
image = decoder._images[0]
assert image.filename.endswith('.jpeg')
assert re.fullmatch(r'[A-Za-z0-9_.-]+', image.filename)
assert (output_dir / image.filename).is_file()
def test_start_from_file_keeps_device_index_unclaimed(self, tmp_path):
"""Offline file decode should not claim or persist an SDR device index."""
with patch('shutil.which', return_value='/usr/bin/satdump'), \
patch('pathlib.Path.is_file', return_value=True), \
patch('pathlib.Path.resolve') as mock_resolve, \
patch.object(WeatherSatDecoder, '_start_satdump_offline') as mock_start:
resolved = MagicMock()
resolved.is_relative_to.return_value = True
mock_resolve.return_value = resolved
decoder = WeatherSatDecoder(output_dir=tmp_path / 'weather_sat_out')
success, error_msg = decoder.start_from_file(
satellite='METEOR-M2-3',
input_file='data/weather_sat/samples/sample.wav',
sample_rate=1_000_000,
)
assert success is True
assert error_msg is None
assert decoder.device_index == -1
mock_start.assert_called_once()
decoder.stop()
assert decoder.device_index == -1
+4 -4
View File
@@ -73,7 +73,7 @@ class TestWeatherSatRoutes:
mock_decoder = MagicMock()
mock_decoder.is_running = False
mock_decoder.start.return_value = True
mock_decoder.start.return_value = (True, None)
mock_get.return_value = mock_decoder
payload = {
@@ -233,7 +233,7 @@ class TestWeatherSatRoutes:
mock_decoder = MagicMock()
mock_decoder.is_running = False
mock_decoder.start.return_value = False
mock_decoder.start.return_value = (False, 'SatDump exited immediately (code 1)')
mock_get.return_value = mock_decoder
payload = {'satellite': 'NOAA-18'}
@@ -246,7 +246,7 @@ class TestWeatherSatRoutes:
assert response.status_code == 500
data = response.get_json()
assert data['status'] == 'error'
assert 'Failed to start capture' in data['message']
assert 'SatDump exited immediately' in data['message']
def test_test_decode_success(self, client):
"""POST /weather-sat/test-decode successfully starts file decode."""
@@ -262,7 +262,7 @@ class TestWeatherSatRoutes:
mock_decoder = MagicMock()
mock_decoder.is_running = False
mock_decoder.start_from_file.return_value = True
mock_decoder.start_from_file.return_value = (True, None)
mock_get.return_value = mock_decoder
payload = {
+3 -3
View File
@@ -546,7 +546,7 @@ class TestWeatherSatScheduler:
mock_decoder = MagicMock()
mock_decoder.is_running = False
mock_decoder.start.return_value = True
mock_decoder.start.return_value = (True, None)
mock_get.return_value = mock_decoder
mock_timer_instance = MagicMock()
@@ -590,7 +590,7 @@ class TestWeatherSatScheduler:
mock_decoder = MagicMock()
mock_decoder.is_running = False
mock_decoder.start.return_value = False
mock_decoder.start.return_value = (False, 'Start failed')
mock_get.return_value = mock_decoder
pass_data = {
@@ -798,7 +798,7 @@ class TestSchedulerIntegration:
mock_decoder = MagicMock()
mock_decoder.is_running = False
mock_decoder.start.return_value = True
mock_decoder.start.return_value = (True, None)
mock_get_decoder.return_value = mock_decoder
scheduler = WeatherSatScheduler()
+591
View File
@@ -0,0 +1,591 @@
"""Tests for WeFax (Weather Fax) routes, decoder, and station loader."""
from __future__ import annotations
import json
import math
from pathlib import Path
from unittest.mock import MagicMock, patch
import numpy as np
def _login_session(client) -> None:
"""Mark the Flask test session as authenticated."""
with client.session_transaction() as sess:
sess['logged_in'] = True
sess['username'] = 'test'
sess['role'] = 'admin'
# ---------------------------------------------------------------------------
# Station database tests
# ---------------------------------------------------------------------------
class TestWeFaxStations:
"""WeFax station database tests."""
def test_load_stations_returns_list(self):
"""load_stations() should return a non-empty list."""
from utils.wefax_stations import load_stations
stations = load_stations()
assert isinstance(stations, list)
assert len(stations) >= 10
def test_station_has_required_fields(self):
"""Each station must have required fields."""
from utils.wefax_stations import load_stations
required = {'name', 'callsign', 'country', 'city', 'coordinates',
'frequencies', 'ioc', 'lpm', 'schedule'}
for station in load_stations():
missing = required - set(station.keys())
assert not missing, f"Station {station.get('callsign', '?')} missing: {missing}"
def test_get_station_by_callsign(self):
"""get_station() should return correct station."""
from utils.wefax_stations import get_station
station = get_station('NOJ')
assert station is not None
assert station['callsign'] == 'NOJ'
assert station['country'] == 'US'
def test_get_station_case_insensitive(self):
"""get_station() should be case-insensitive."""
from utils.wefax_stations import get_station
assert get_station('noj') is not None
def test_get_station_not_found(self):
"""get_station() should return None for unknown callsign."""
from utils.wefax_stations import get_station
assert get_station('XXXXX') is None
def test_resolve_tuning_frequency_auto_uses_carrier_for_known_station(self):
"""Known station frequencies default to carrier-list behavior in auto mode."""
from utils.wefax_stations import resolve_tuning_frequency_khz
tuned, reference, offset_applied = resolve_tuning_frequency_khz(
listed_frequency_khz=4298.0,
station_callsign='NOJ',
frequency_reference='auto',
)
assert math.isclose(tuned, 4296.1, abs_tol=1e-6)
assert reference == 'carrier'
assert offset_applied is True
def test_resolve_tuning_frequency_auto_preserves_unknown_station_input(self):
"""Ad-hoc frequencies (no station metadata) should be treated as dial."""
from utils.wefax_stations import resolve_tuning_frequency_khz
tuned, reference, offset_applied = resolve_tuning_frequency_khz(
listed_frequency_khz=4298.0,
station_callsign='',
frequency_reference='auto',
)
assert math.isclose(tuned, 4298.0, abs_tol=1e-6)
assert reference == 'dial'
assert offset_applied is False
def test_resolve_tuning_frequency_dial_override(self):
"""Explicit dial reference must bypass USB alignment."""
from utils.wefax_stations import resolve_tuning_frequency_khz
tuned, reference, offset_applied = resolve_tuning_frequency_khz(
listed_frequency_khz=4298.0,
station_callsign='NOJ',
frequency_reference='dial',
)
assert math.isclose(tuned, 4298.0, abs_tol=1e-6)
assert reference == 'dial'
assert offset_applied is False
def test_resolve_tuning_frequency_rejects_invalid_reference(self):
"""Invalid frequency reference values should raise a validation error."""
from utils.wefax_stations import resolve_tuning_frequency_khz
try:
resolve_tuning_frequency_khz(
listed_frequency_khz=4298.0,
station_callsign='NOJ',
frequency_reference='invalid',
)
assert False, "Expected ValueError for invalid frequency_reference"
except ValueError as exc:
assert 'frequency_reference' in str(exc)
def test_station_frequencies_have_khz(self):
"""Each frequency entry must have 'khz' and 'description'."""
from utils.wefax_stations import load_stations
for station in load_stations():
for freq in station['frequencies']:
assert 'khz' in freq, f"{station['callsign']} missing khz"
assert 'description' in freq, f"{station['callsign']} missing description"
assert isinstance(freq['khz'], (int, float))
assert freq['khz'] > 0
def test_schedule_format(self):
"""Schedule entries must have utc, duration_min, content."""
from utils.wefax_stations import load_stations
for station in load_stations():
for entry in station['schedule']:
assert 'utc' in entry
assert 'duration_min' in entry
assert 'content' in entry
# UTC format: HH:MM
parts = entry['utc'].split(':')
assert len(parts) == 2
assert 0 <= int(parts[0]) <= 23
assert 0 <= int(parts[1]) <= 59
def test_get_current_broadcasts(self):
"""get_current_broadcasts() should return up to 3 entries."""
from utils.wefax_stations import get_current_broadcasts
broadcasts = get_current_broadcasts('NOJ')
assert isinstance(broadcasts, list)
assert len(broadcasts) <= 3
for b in broadcasts:
assert 'utc' in b
assert 'content' in b
# ---------------------------------------------------------------------------
# Decoder unit tests
# ---------------------------------------------------------------------------
class TestWeFaxDecoder:
"""WeFax decoder DSP and data class tests."""
def test_freq_to_pixel_black(self):
"""1500 Hz should map to 0 (black)."""
from utils.wefax import _freq_to_pixel
assert _freq_to_pixel(1500.0) == 0
def test_freq_to_pixel_white(self):
"""2300 Hz should map to 255 (white)."""
from utils.wefax import _freq_to_pixel
assert _freq_to_pixel(2300.0) == 255
def test_freq_to_pixel_mid(self):
"""1900 Hz (carrier) should map to ~128."""
from utils.wefax import _freq_to_pixel
val = _freq_to_pixel(1900.0)
assert 120 <= val <= 135
def test_freq_to_pixel_clamp_low(self):
"""Below 1500 Hz should clamp to 0."""
from utils.wefax import _freq_to_pixel
assert _freq_to_pixel(1000.0) == 0
def test_freq_to_pixel_clamp_high(self):
"""Above 2300 Hz should clamp to 255."""
from utils.wefax import _freq_to_pixel
assert _freq_to_pixel(3000.0) == 255
def test_ioc_576_pixel_count(self):
"""IOC 576 should give pi*576 ≈ 1809 pixels per line."""
pixels = int(math.pi * 576)
assert pixels == 1809
def test_ioc_288_pixel_count(self):
"""IOC 288 should give pi*288 ≈ 904 pixels per line."""
pixels = int(math.pi * 288)
assert pixels == 904
def test_goertzel_mag_detects_tone(self):
"""Goertzel should detect a pure tone."""
from utils.wefax import _goertzel_mag
sr = 22050
freq = 1900.0
t = np.arange(sr) / sr
samples = np.sin(2 * np.pi * freq * t)
mag = _goertzel_mag(samples[:2205], freq, sr)
# Should be significantly non-zero for a matching tone
assert mag > 1.0
def test_goertzel_mag_rejects_wrong_freq(self):
"""Goertzel should be much weaker for non-matching frequency."""
from utils.wefax import _goertzel_mag
sr = 22050
t = np.arange(sr) / sr
samples = np.sin(2 * np.pi * 1900.0 * t)
mag_match = _goertzel_mag(samples[:2205], 1900.0, sr)
mag_off = _goertzel_mag(samples[:2205], 300.0, sr)
assert mag_match > mag_off * 5
def test_detect_tone_start(self):
"""detect_tone should identify a 300 Hz start tone."""
from utils.wefax import _detect_tone
sr = 22050
t = np.arange(sr) / sr
samples = np.sin(2 * np.pi * 300.0 * t)
assert _detect_tone(samples[:2205], 300.0, sr, threshold=2.0)
def test_wefax_image_to_dict(self):
"""WeFaxImage.to_dict() should produce expected format."""
from datetime import datetime, timezone
from utils.wefax import WeFaxImage
img = WeFaxImage(
filename='test.png',
path=Path('/tmp/test.png'),
station='NOJ',
frequency_khz=4298,
timestamp=datetime(2026, 1, 1, tzinfo=timezone.utc),
ioc=576,
lpm=120,
size_bytes=1234,
)
d = img.to_dict()
assert d['filename'] == 'test.png'
assert d['station'] == 'NOJ'
assert d['frequency_khz'] == 4298
assert d['ioc'] == 576
assert d['url'] == '/wefax/images/test.png'
def test_wefax_progress_to_dict(self):
"""WeFaxProgress.to_dict() should produce expected format."""
from utils.wefax import WeFaxProgress
p = WeFaxProgress(
status='receiving',
station='NOJ',
message='Receiving: 100 lines',
progress_percent=50,
line_count=100,
)
d = p.to_dict()
assert d['type'] == 'wefax_progress'
assert d['status'] == 'receiving'
assert d['progress'] == 50
assert d['station'] == 'NOJ'
assert d['line_count'] == 100
def test_singleton_returns_same_instance(self, tmp_path):
"""get_wefax_decoder() should return a singleton."""
from utils.wefax import WeFaxDecoder
# Use __new__ to avoid __init__ creating dirs
d1 = WeFaxDecoder.__new__(WeFaxDecoder)
# Test the module-level singleton pattern
import utils.wefax as wefax_mod
original = wefax_mod._decoder
try:
wefax_mod._decoder = d1
assert wefax_mod.get_wefax_decoder() is d1
assert wefax_mod.get_wefax_decoder() is d1
finally:
wefax_mod._decoder = original
# ---------------------------------------------------------------------------
# Route tests
# ---------------------------------------------------------------------------
class TestWeFaxRoutes:
"""WeFax route endpoint tests."""
def test_status(self, client):
"""GET /wefax/status should return decoder status."""
_login_session(client)
mock_decoder = MagicMock()
mock_decoder.is_running = False
mock_decoder.get_images.return_value = []
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder):
response = client.get('/wefax/status')
assert response.status_code == 200
data = response.get_json()
assert data['available'] is True
assert data['running'] is False
def test_stations_list(self, client):
"""GET /wefax/stations should return station list."""
_login_session(client)
response = client.get('/wefax/stations')
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'ok'
assert data['count'] >= 10
def test_station_detail(self, client):
"""GET /wefax/stations/NOJ should return station detail."""
_login_session(client)
response = client.get('/wefax/stations/NOJ')
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'ok'
assert data['station']['callsign'] == 'NOJ'
assert 'current_broadcasts' in data
def test_station_not_found(self, client):
"""GET /wefax/stations/XXXXX should return 404."""
_login_session(client)
response = client.get('/wefax/stations/XXXXX')
assert response.status_code == 404
def test_start_requires_frequency(self, client):
"""POST /wefax/start without frequency should fail."""
_login_session(client)
mock_decoder = MagicMock()
mock_decoder.is_running = False
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder):
response = client.post(
'/wefax/start',
data=json.dumps({}),
content_type='application/json',
)
assert response.status_code == 400
data = response.get_json()
assert data['status'] == 'error'
def test_start_validates_frequency_range(self, client):
"""POST /wefax/start with out-of-range frequency should fail."""
_login_session(client)
mock_decoder = MagicMock()
mock_decoder.is_running = False
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder):
response = client.post(
'/wefax/start',
data=json.dumps({'frequency_khz': 100}), # 0.1 MHz - too low
content_type='application/json',
)
assert response.status_code == 400
def test_start_validates_ioc(self, client):
"""POST /wefax/start with invalid IOC should fail."""
_login_session(client)
mock_decoder = MagicMock()
mock_decoder.is_running = False
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder):
response = client.post(
'/wefax/start',
data=json.dumps({'frequency_khz': 4298, 'ioc': 999}),
content_type='application/json',
)
assert response.status_code == 400
data = response.get_json()
assert 'IOC' in data['message']
def test_start_validates_lpm(self, client):
"""POST /wefax/start with invalid LPM should fail."""
_login_session(client)
mock_decoder = MagicMock()
mock_decoder.is_running = False
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder):
response = client.post(
'/wefax/start',
data=json.dumps({'frequency_khz': 4298, 'lpm': 999}),
content_type='application/json',
)
assert response.status_code == 400
data = response.get_json()
assert 'LPM' in data['message']
def test_start_success(self, client):
"""POST /wefax/start with valid params should succeed."""
_login_session(client)
mock_decoder = MagicMock()
mock_decoder.is_running = False
mock_decoder.start.return_value = True
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder), \
patch('routes.wefax.app_module.claim_sdr_device', return_value=None):
response = client.post(
'/wefax/start',
data=json.dumps({
'frequency_khz': 4298,
'station': 'NOJ',
'device': 0,
'ioc': 576,
'lpm': 120,
}),
content_type='application/json',
)
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'started'
assert data['frequency_khz'] == 4298
assert data['usb_offset_applied'] is True
assert math.isclose(data['tuned_frequency_khz'], 4296.1, abs_tol=1e-6)
assert data['frequency_reference'] == 'carrier'
assert data['station'] == 'NOJ'
mock_decoder.start.assert_called_once()
start_kwargs = mock_decoder.start.call_args.kwargs
assert math.isclose(start_kwargs['frequency_khz'], 4296.1, abs_tol=1e-6)
def test_start_respects_dial_reference_override(self, client):
"""POST /wefax/start with dial reference should not apply USB offset."""
_login_session(client)
mock_decoder = MagicMock()
mock_decoder.is_running = False
mock_decoder.start.return_value = True
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder), \
patch('routes.wefax.app_module.claim_sdr_device', return_value=None):
response = client.post(
'/wefax/start',
data=json.dumps({
'frequency_khz': 4298,
'station': 'NOJ',
'device': 0,
'frequency_reference': 'dial',
}),
content_type='application/json',
)
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'started'
assert data['usb_offset_applied'] is False
assert math.isclose(data['tuned_frequency_khz'], 4298.0, abs_tol=1e-6)
assert data['frequency_reference'] == 'dial'
start_kwargs = mock_decoder.start.call_args.kwargs
assert math.isclose(start_kwargs['frequency_khz'], 4298.0, abs_tol=1e-6)
def test_start_device_busy(self, client):
"""POST /wefax/start should return 409 when device is busy."""
_login_session(client)
mock_decoder = MagicMock()
mock_decoder.is_running = False
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder), \
patch('routes.wefax.app_module.claim_sdr_device',
return_value='Device 0 in use by pager'):
response = client.post(
'/wefax/start',
data=json.dumps({'frequency_khz': 4298}),
content_type='application/json',
)
assert response.status_code == 409
data = response.get_json()
assert data['error_type'] == 'DEVICE_BUSY'
def test_stop(self, client):
"""POST /wefax/stop should stop the decoder."""
_login_session(client)
mock_decoder = MagicMock()
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder):
response = client.post('/wefax/stop')
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'stopped'
mock_decoder.stop.assert_called_once()
def test_images_list(self, client):
"""GET /wefax/images should return image list."""
_login_session(client)
mock_decoder = MagicMock()
mock_decoder.get_images.return_value = []
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder):
response = client.get('/wefax/images')
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'ok'
assert data['count'] == 0
def test_delete_image_invalid_filename(self, client):
"""DELETE /wefax/images/<filename> should reject invalid filenames."""
_login_session(client)
mock_decoder = MagicMock()
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder):
# Use a filename with special chars that won't be split by Flask routing
response = client.delete('/wefax/images/te$t!file.png')
assert response.status_code == 400
def test_delete_image_wrong_extension(self, client):
"""DELETE /wefax/images/<filename> should reject non-PNG."""
_login_session(client)
mock_decoder = MagicMock()
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder):
response = client.delete('/wefax/images/test.jpg')
assert response.status_code == 400
def test_schedule_enable_applies_usb_alignment(self, client):
"""Scheduler should receive tuned USB dial frequency in auto mode."""
_login_session(client)
mock_scheduler = MagicMock()
mock_scheduler.enable.return_value = {
'enabled': True,
'scheduled_count': 2,
'total_broadcasts': 2,
}
with patch('utils.wefax_scheduler.get_wefax_scheduler', return_value=mock_scheduler):
response = client.post(
'/wefax/schedule/enable',
data=json.dumps({
'station': 'NOJ',
'frequency_khz': 4298,
'device': 0,
}),
content_type='application/json',
)
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'ok'
assert data['usb_offset_applied'] is True
assert math.isclose(data['tuned_frequency_khz'], 4296.1, abs_tol=1e-6)
enable_kwargs = mock_scheduler.enable.call_args.kwargs
assert math.isclose(enable_kwargs['frequency_khz'], 4296.1, abs_tol=1e-6)
class TestWeFaxProgressCallback:
"""Regression tests for WeFax route-level progress callback behavior."""
def test_terminal_progress_releases_active_device(self):
"""Terminal decoder events must release any manually claimed SDR."""
import routes.wefax as wefax_routes
original_device = wefax_routes.wefax_active_device
try:
wefax_routes.wefax_active_device = 3
with patch('routes.wefax.app_module.release_sdr_device') as mock_release:
wefax_routes._progress_callback({
'type': 'wefax_progress',
'status': 'error',
'message': 'decode failed',
})
mock_release.assert_called_once_with(3, 'rtlsdr')
assert wefax_routes.wefax_active_device is None
finally:
wefax_routes.wefax_active_device = original_device
def test_non_terminal_progress_does_not_release_active_device(self):
"""Non-terminal progress updates must not release SDR ownership."""
import routes.wefax as wefax_routes
original_device = wefax_routes.wefax_active_device
try:
wefax_routes.wefax_active_device = 4
with patch('routes.wefax.app_module.release_sdr_device') as mock_release:
wefax_routes._progress_callback({
'type': 'wefax_progress',
'status': 'receiving',
'line_count': 120,
})
mock_release.assert_not_called()
assert wefax_routes.wefax_active_device == 4
finally:
wefax_routes.wefax_active_device = original_device
+159
View File
@@ -0,0 +1,159 @@
"""Tests for WeFax auto-scheduler behavior and regressions."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock, patch
from utils.wefax_scheduler import ScheduledBroadcast, WeFaxScheduler
class TestWeFaxScheduler:
"""WeFaxScheduler regression tests."""
@patch('threading.Timer')
def test_refresh_reschedules_same_utc_slot_next_day(self, mock_timer):
"""Completed broadcasts must not block the next day's same UTC slot."""
scheduler = WeFaxScheduler()
scheduler._enabled = True
scheduler._station = 'USCG Kodiak'
scheduler._callsign = 'NOJ'
scheduler._frequency_khz = 4298.0
now = datetime.now(timezone.utc)
utc_time = (now - timedelta(hours=2)).strftime('%H:%M')
today = now.date().isoformat()
prior = ScheduledBroadcast(
station='USCG Kodiak',
callsign='NOJ',
frequency_khz=4298.0,
utc_time=utc_time,
duration_min=20,
content='Chart',
occurrence_date=today,
)
prior.status = 'complete'
scheduler._broadcasts = [prior]
mock_timer.return_value = MagicMock()
with patch('utils.wefax_scheduler.get_station', return_value={
'name': 'USCG Kodiak',
'schedule': [{
'utc': utc_time,
'duration_min': 20,
'content': 'Chart',
}],
}):
scheduler._refresh_schedule()
capture_calls = [
c for c in mock_timer.call_args_list
if len(c.args) >= 2 and getattr(c.args[1], '__name__', '') == '_execute_capture'
]
assert capture_calls, "Expected a capture timer for the next-day occurrence"
scheduled = [b for b in scheduler._broadcasts if b.status == 'scheduled']
assert len(scheduled) == 1
assert scheduled[0].occurrence_date != today
def test_execute_capture_stops_immediately_if_window_elapsed(self):
"""If stop delay computes to <= 0, capture should close out immediately."""
scheduler = WeFaxScheduler()
scheduler._enabled = True
scheduler._callsign = 'NOJ'
scheduler._frequency_khz = 4298.0
scheduler._device = 0
scheduler._gain = 40.0
scheduler._ioc = 576
scheduler._lpm = 120
scheduler._direct_sampling = True
now = datetime.now(timezone.utc)
sb = ScheduledBroadcast(
station='USCG Kodiak',
callsign='NOJ',
frequency_khz=4298.0,
utc_time=now.strftime('%H:%M'),
duration_min=0,
content='Late chart',
occurrence_date=now.date().isoformat(),
)
sb.status = 'scheduled'
mock_decoder = MagicMock()
mock_decoder.is_running = False
mock_decoder.start.return_value = True
with patch('utils.wefax_scheduler.get_wefax_decoder', return_value=mock_decoder), \
patch('utils.wefax_scheduler.WEFAX_CAPTURE_BUFFER_SECONDS', 0), \
patch('app.claim_sdr_device', return_value=None), \
patch.object(scheduler, '_stop_capture') as mock_stop_capture:
scheduler._execute_capture_inner(sb)
mock_stop_capture.assert_called_once()
@patch('threading.Timer')
def test_terminal_progress_releases_scheduler_device_early(self, mock_timer):
"""Scheduler captures must release SDR as soon as terminal progress arrives."""
scheduler = WeFaxScheduler()
scheduler._enabled = True
scheduler._callsign = 'NOJ'
scheduler._frequency_khz = 4298.0
scheduler._device = 0
scheduler._gain = 40.0
scheduler._ioc = 576
scheduler._lpm = 120
scheduler._direct_sampling = True
sb = ScheduledBroadcast(
station='USCG Kodiak',
callsign='NOJ',
frequency_khz=4298.0,
utc_time='12:00',
duration_min=20,
content='Chart',
occurrence_date='2026-01-01',
)
sb.status = 'scheduled'
mock_decoder = MagicMock()
mock_decoder.is_running = False
mock_decoder.start.return_value = True
mock_timer.return_value = MagicMock()
with patch('utils.wefax_scheduler.get_wefax_decoder', return_value=mock_decoder), \
patch('app.claim_sdr_device', return_value=None), \
patch('app.release_sdr_device') as mock_release:
scheduler._execute_capture_inner(sb)
progress_cb = mock_decoder.set_callback.call_args[0][0]
progress_cb({
'type': 'wefax_progress',
'status': 'error',
'message': 'rtl_fm failed',
})
mock_release.assert_called_once_with(0)
assert sb.status == 'skipped'
def test_stop_capture_non_capturing_only_releases(self):
"""_stop_capture should be idempotent when capture already ended."""
scheduler = WeFaxScheduler()
sb = ScheduledBroadcast(
station='USCG Kodiak',
callsign='NOJ',
frequency_khz=4298.0,
utc_time='12:00',
duration_min=20,
content='Chart',
occurrence_date='2026-01-01',
)
sb.status = 'complete'
release_fn = MagicMock()
with patch('utils.wefax_scheduler.get_wefax_decoder') as mock_get_decoder:
scheduler._stop_capture(sb, release_fn)
release_fn.assert_called_once()
mock_get_decoder.assert_not_called()
+14
View File
@@ -300,6 +300,20 @@ SUBGHZ_PRESETS = {
}
# =============================================================================
# RADIOSONDE (Weather Balloon Tracking)
# =============================================================================
# UDP port for radiosonde_auto_rx telemetry broadcast
RADIOSONDE_UDP_PORT = 55673
# Radiosonde process termination timeout
RADIOSONDE_TERMINATE_TIMEOUT = 5
# Maximum age for balloon data before cleanup (30 min — balloons move slowly)
MAX_RADIOSONDE_AGE_SECONDS = 1800
# =============================================================================
# DEAUTH ATTACK DETECTION
# =============================================================================
+48
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import logging
import os
import platform
import shutil
import subprocess
from typing import Any
@@ -11,6 +12,14 @@ logger = logging.getLogger('intercept.dependencies')
# Additional paths to search for tools (e.g., /usr/sbin on Debian)
EXTRA_TOOL_PATHS = ['/usr/sbin', '/sbin']
# Tools installed to non-standard locations (not on PATH)
KNOWN_TOOL_PATHS: dict[str, list[str]] = {
'auto_rx.py': [
'/opt/radiosonde_auto_rx/auto_rx/auto_rx.py',
'/opt/auto_rx/auto_rx.py',
],
}
def check_tool(name: str) -> bool:
"""Check if a tool is installed."""
@@ -19,6 +28,26 @@ def check_tool(name: str) -> bool:
def get_tool_path(name: str) -> str | None:
"""Get the full path to a tool, checking standard PATH and extra locations."""
# Optional explicit override, e.g. INTERCEPT_RTL_FM_PATH=/opt/homebrew/bin/rtl_fm
env_key = f"INTERCEPT_{name.upper().replace('-', '_')}_PATH"
env_path = os.environ.get(env_key)
if env_path and os.path.isfile(env_path) and os.access(env_path, os.X_OK):
return env_path
# Prefer native Homebrew binaries on Apple Silicon to avoid mixing Rosetta
# /usr/local tools with arm64 Python/runtime.
if platform.system() == 'Darwin':
machine = platform.machine().lower()
preferred_paths: list[str] = []
if machine in {'arm64', 'aarch64'}:
preferred_paths.append('/opt/homebrew/bin')
preferred_paths.append('/usr/local/bin')
for base in preferred_paths:
full_path = os.path.join(base, name)
if os.path.isfile(full_path) and os.access(full_path, os.X_OK):
return full_path
# First check standard PATH
path = shutil.which(name)
if path:
@@ -30,6 +59,11 @@ def get_tool_path(name: str) -> str | None:
if os.path.isfile(full_path) and os.access(full_path, os.X_OK):
return full_path
# Check known non-standard install locations
for known_path in KNOWN_TOOL_PATHS.get(name, []):
if os.path.isfile(known_path):
return known_path
return None
@@ -426,6 +460,20 @@ TOOL_DEPENDENCIES = {
}
}
},
'radiosonde': {
'name': 'Radiosonde Tracking',
'tools': {
'auto_rx.py': {
'required': True,
'description': 'Radiosonde weather balloon decoder',
'install': {
'apt': 'Run ./setup.sh (clones from GitHub)',
'brew': 'Run ./setup.sh (clones from GitHub)',
'manual': 'https://github.com/projecthorus/radiosonde_auto_rx'
}
}
}
},
'tscm': {
'name': 'TSCM Counter-Surveillance',
'tools': {
+26 -21
View File
@@ -14,30 +14,26 @@ from __future__ import annotations
# =============================================================================
FORMAT_CODES = {
100: 'DISTRESS', # All ships distress alert
102: 'ALL_SHIPS', # All ships call
104: 'GROUP', # Group call
106: 'DISTRESS_ACK', # Distress acknowledgement
108: 'DISTRESS_RELAY', # Distress relay
110: 'GEOGRAPHIC', # Geographic area call
112: 'INDIVIDUAL', # Individual call
114: 'INDIVIDUAL_ACK', # Individual acknowledgement
116: 'ROUTINE', # Routine call
118: 'SAFETY', # Safety call
120: 'URGENCY', # Urgency call
102: 'ALL_SHIPS', # All ships call
112: 'INDIVIDUAL', # Individual call
114: 'INDIVIDUAL_ACK', # Individual acknowledgement
116: 'GROUP', # Group call (including geographic area)
120: 'DISTRESS', # Distress alert
123: 'ALL_SHIPS_URGENCY_SAFETY', # All ships urgency/safety
}
# Valid ITU-R M.493 format specifiers
VALID_FORMAT_SPECIFIERS = {102, 112, 114, 116, 120, 123}
# Valid EOS (End of Sequence) symbols per ITU-R M.493
VALID_EOS = {117, 122, 127}
# Category priority (lower = higher priority)
CATEGORY_PRIORITY = {
'DISTRESS': 0,
'DISTRESS_ACK': 1,
'DISTRESS_RELAY': 2,
'URGENCY': 3,
'SAFETY': 4,
'ROUTINE': 5,
'ALL_SHIPS_URGENCY_SAFETY': 2,
'ALL_SHIPS': 5,
'GROUP': 5,
'GEOGRAPHIC': 5,
'INDIVIDUAL': 5,
'INDIVIDUAL_ACK': 5,
}
@@ -93,6 +89,15 @@ TELECOMMAND_CODES = {
201: 'POLL_RESPONSE', # Poll response
}
# Full 0-127 telecommand lookup (maps unknown codes to "UNKNOWN")
TELECOMMAND_CODES_FULL = {i: TELECOMMAND_CODES.get(i, "UNKNOWN") for i in range(128)}
# Format codes that carry telecommand fields
TELECOMMAND_FORMATS = {112, 114, 116, 120, 123}
# Minimum symbols (after phasing strip) before an EOS can be accepted
MIN_SYMBOLS_FOR_FORMAT = 12
# =============================================================================
# DSC Symbol Definitions
@@ -453,11 +458,11 @@ VHF_CHANNELS = {
# DSC Modulation Parameters
# =============================================================================
DSC_BAUD_RATE = 100 # 100 baud per ITU-R M.493
DSC_BAUD_RATE = 1200 # 1200 bps per ITU-R M.493
# FSK tone frequencies (Hz)
DSC_MARK_FREQ = 1800 # B (mark) - binary 1
DSC_SPACE_FREQ = 1200 # Y (space) - binary 0
# FSK tone frequencies (Hz) on 1700 Hz subcarrier
DSC_MARK_FREQ = 2100 # B (mark) - binary 1
DSC_SPACE_FREQ = 1300 # Y (space) - binary 0
# Audio sample rate for decoding
DSC_AUDIO_SAMPLE_RATE = 48000
+60 -32
View File
@@ -5,9 +5,9 @@ DSC (Digital Selective Calling) decoder.
Decodes VHF DSC signals per ITU-R M.493. Reads 48kHz 16-bit signed
audio from stdin (from rtl_fm) and outputs JSON messages to stdout.
DSC uses 100 baud FSK with:
- Mark (1): 1800 Hz
- Space (0): 1200 Hz
DSC uses 1200 bps FSK on a 1700 Hz subcarrier with:
- Mark (1): 2100 Hz
- Space (0): 1300 Hz
Frame structure:
1. Dot pattern: 200 bits alternating 1/0 for synchronization
@@ -42,6 +42,9 @@ from .constants import (
DSC_AUDIO_SAMPLE_RATE,
FORMAT_CODES,
DISTRESS_NATURE_CODES,
VALID_EOS,
TELECOMMAND_FORMATS,
MIN_SYMBOLS_FOR_FORMAT,
)
# Configure logging
@@ -57,7 +60,7 @@ class DSCDecoder:
"""
DSC FSK decoder.
Demodulates 100 baud FSK audio and decodes DSC protocol.
Demodulates 1200 bps FSK audio and decodes DSC protocol.
"""
def __init__(self, sample_rate: int = DSC_AUDIO_SAMPLE_RATE):
@@ -66,13 +69,13 @@ class DSCDecoder:
self.samples_per_bit = sample_rate // self.baud_rate
# FSK frequencies
self.mark_freq = DSC_MARK_FREQ # 1800 Hz = binary 1
self.space_freq = DSC_SPACE_FREQ # 1200 Hz = binary 0
self.mark_freq = DSC_MARK_FREQ # 2100 Hz = binary 1
self.space_freq = DSC_SPACE_FREQ # 1300 Hz = binary 0
# Bandpass filter for DSC band (1100-1900 Hz)
# Bandpass filter for DSC band (1100-2300 Hz)
nyq = sample_rate / 2
low = 1100 / nyq
high = 1900 / nyq
high = 2300 / nyq
self.bp_b, self.bp_a = scipy_signal.butter(4, [low, high], btype='band')
# Build FSK correlators
@@ -221,13 +224,14 @@ class DSCDecoder:
Detect DSC dot pattern for synchronization.
The dot pattern is at least 200 alternating bits (1010101...).
We look for at least 20 consecutive alternations.
We require at least 100 consecutive alternations to avoid
false sync triggers from noise.
"""
if len(self.bit_buffer) < 40:
if len(self.bit_buffer) < 200:
return False
# Check last 40 bits for alternating pattern
last_bits = self.bit_buffer[-40:]
# Check last 200 bits for alternating pattern
last_bits = self.bit_buffer[-200:]
alternations = 0
for i in range(1, len(last_bits)):
@@ -236,7 +240,7 @@ class DSCDecoder:
else:
alternations = 0
if alternations >= 20:
if alternations >= 100:
return True
return False
@@ -262,27 +266,37 @@ class DSCDecoder:
if end <= len(self.message_bits):
symbol_bits = self.message_bits[start:end]
symbol_value = self._bits_to_symbol(symbol_bits)
if symbol_value == -1:
logger.debug("DSC symbol check bit failure, aborting decode")
return None
symbols.append(symbol_value)
# Strip phasing sequence (RX/DX symbols 120-126) from the
# start of the message. Per ITU-R M.493, after the dot pattern
# there are 7 phasing symbols before the format specifier.
# Bound to max 7 — if more are present, this is a bad sync.
msg_start = 0
for i, sym in enumerate(symbols):
if 120 <= sym <= 126:
msg_start = i + 1
else:
break
if msg_start > 7:
logger.debug("DSC bad sync: >7 phasing symbols stripped")
return None
symbols = symbols[msg_start:]
if len(symbols) < 5:
return None
# Look for EOS (End of Sequence) - symbol 127
# Look for EOS (End of Sequence) - symbols 117, 122, or 127
# EOS must appear after at least MIN_SYMBOLS_FOR_FORMAT symbols
eos_found = False
eos_index = -1
for i, sym in enumerate(symbols):
if sym == 127: # EOS symbol
if sym in VALID_EOS:
if i < MIN_SYMBOLS_FOR_FORMAT:
continue # Too early — not a real EOS
eos_found = True
eos_index = i
break
@@ -299,7 +313,9 @@ class DSCDecoder:
Convert 10 bits to symbol value.
DSC uses 10-bit symbols: 7 information bits + 3 error bits.
We extract the 7-bit value.
The 3 check bits provide parity such that the total number of
'1' bits across all 10 bits should be even (even parity).
Returns -1 if the check bits are invalid.
"""
if len(bits) != 10:
return -1
@@ -310,6 +326,11 @@ class DSCDecoder:
if bits[i]:
value |= (1 << i)
# Validate check bits: total number of 1s should be even
ones = sum(bits)
if ones % 2 != 0:
return -1
return value
def _decode_symbols(self, symbols: list[int]) -> dict | None:
@@ -337,26 +358,31 @@ class DSCDecoder:
format_code = symbols[0]
format_text = FORMAT_CODES.get(format_code, f'UNKNOWN-{format_code}')
# Determine category from format
category = 'ROUTINE'
if format_code == 100:
# Derive category from format specifier per ITU-R M.493
if format_code == 120:
category = 'DISTRESS'
elif format_code == 106:
category = 'DISTRESS_ACK'
elif format_code == 108:
category = 'DISTRESS_RELAY'
elif format_code == 118:
category = 'SAFETY'
elif format_code == 120:
category = 'URGENCY'
elif format_code == 123:
category = 'ALL_SHIPS_URGENCY_SAFETY'
elif format_code == 102:
category = 'ALL_SHIPS'
elif format_code == 116:
category = 'GROUP'
elif format_code == 112:
category = 'INDIVIDUAL'
elif format_code == 114:
category = 'INDIVIDUAL_ACK'
else:
category = FORMAT_CODES.get(format_code, 'UNKNOWN')
# Decode MMSI from symbols 1-5 (destination/address)
dest_mmsi = self._decode_mmsi(symbols[1:6])
if dest_mmsi is None:
return None
# Decode self-ID from symbols 6-10 (source)
source_mmsi = self._decode_mmsi(symbols[6:11])
if source_mmsi is None:
return None
message = {
'type': 'dsc',
@@ -385,8 +411,9 @@ class DSCDecoder:
if position:
message['position'] = position
# Telecommand fields (usually last two before EOS)
if len(remaining) >= 2:
# Telecommand fields (last two before EOS) — only for formats
# that carry telecommand fields per ITU-R M.493
if format_code in TELECOMMAND_FORMATS and len(remaining) >= 2:
message['telecommand1'] = remaining[-2]
message['telecommand2'] = remaining[-1]
@@ -400,20 +427,21 @@ class DSCDecoder:
logger.warning(f"DSC decode error: {e}")
return None
def _decode_mmsi(self, symbols: list[int]) -> str:
def _decode_mmsi(self, symbols: list[int]) -> str | None:
"""
Decode MMSI from 5 DSC symbols.
Each symbol represents 2 BCD digits (00-99).
5 symbols = 10 digits, but MMSI is 9 digits (first symbol has leading 0).
Returns None if any symbol is out of valid BCD range.
"""
if len(symbols) < 5:
return '000000000'
return None
digits = []
for sym in symbols:
if sym < 0 or sym > 99:
sym = 0
return None
# Each symbol is 2 BCD digits
digits.append(f'{sym:02d}')
+63 -9
View File
@@ -19,6 +19,8 @@ from .constants import (
TELECOMMAND_CODES,
CATEGORY_PRIORITY,
MID_COUNTRY_MAP,
VALID_FORMAT_SPECIFIERS,
VALID_EOS,
)
logger = logging.getLogger('intercept.dsc.parser')
@@ -139,13 +141,62 @@ def parse_dsc_message(raw_line: str) -> dict[str, Any] | None:
if 'source_mmsi' not in data:
return None
# ITU-R M.493 validation: format specifier must be valid
format_code = data.get('format')
if format_code not in VALID_FORMAT_SPECIFIERS:
logger.debug(f"Rejected DSC: invalid format specifier {format_code}")
return None
# Validate MMSIs
source_mmsi = str(data.get('source_mmsi', ''))
if not validate_mmsi(source_mmsi):
logger.debug(f"Rejected DSC: invalid source MMSI {source_mmsi}")
return None
dest_mmsi_val = data.get('dest_mmsi')
if dest_mmsi_val is not None:
dest_mmsi_str = str(dest_mmsi_val)
if not validate_mmsi(dest_mmsi_str):
logger.debug(f"Rejected DSC: invalid dest MMSI {dest_mmsi_str}")
return None
# Validate raw field structure if present
raw = data.get('raw')
if raw is not None:
raw_str = str(raw)
if not re.match(r'^\d+$', raw_str):
logger.debug("Rejected DSC: raw field contains non-digits")
return None
if len(raw_str) % 3 != 0:
logger.debug("Rejected DSC: raw field length not divisible by 3")
return None
# Last 3-digit token must be a valid EOS symbol
if len(raw_str) >= 3:
last_token = int(raw_str[-3:])
if last_token not in VALID_EOS:
logger.debug(f"Rejected DSC: raw EOS token {last_token} not valid")
return None
# Validate telecommand values if present (must be 100-127)
for tc_field in ('telecommand1', 'telecommand2'):
tc_val = data.get(tc_field)
if tc_val is not None:
try:
tc_int = int(tc_val)
except (ValueError, TypeError):
logger.debug(f"Rejected DSC: invalid {tc_field} value {tc_val}")
return None
if tc_int < 100 or tc_int > 127:
logger.debug(f"Rejected DSC: {tc_field} {tc_int} out of range 100-127")
return None
# Build parsed message
msg = {
'type': 'dsc_message',
'source_mmsi': str(data.get('source_mmsi', '')),
'dest_mmsi': str(data.get('dest_mmsi', '')) if data.get('dest_mmsi') else None,
'format_code': data.get('format'),
'format_text': get_format_text(data.get('format', 0)),
'source_mmsi': source_mmsi,
'dest_mmsi': str(data.get('dest_mmsi', '')) if data.get('dest_mmsi') is not None else None,
'format_code': format_code,
'format_text': get_format_text(format_code),
'category': data.get('category', 'UNKNOWN').upper(),
'timestamp': data.get('timestamp') or datetime.utcnow().isoformat(),
}
@@ -156,7 +207,7 @@ def parse_dsc_message(raw_line: str) -> dict[str, Any] | None:
msg['source_country'] = country
# Add distress nature if present
if 'nature' in data and data['nature']:
if data.get('nature') is not None:
msg['nature_code'] = data['nature']
msg['nature_of_distress'] = get_distress_nature_text(data['nature'])
@@ -173,16 +224,16 @@ def parse_dsc_message(raw_line: str) -> dict[str, Any] | None:
pass
# Add telecommand info
if 'telecommand1' in data and data['telecommand1']:
if data.get('telecommand1') is not None:
msg['telecommand1'] = data['telecommand1']
msg['telecommand1_text'] = get_telecommand_text(data['telecommand1'])
if 'telecommand2' in data and data['telecommand2']:
if data.get('telecommand2') is not None:
msg['telecommand2'] = data['telecommand2']
msg['telecommand2_text'] = get_telecommand_text(data['telecommand2'])
# Add channel if present
if 'channel' in data and data['channel']:
if data.get('channel') is not None:
msg['channel'] = data['channel']
# Add EOS (End of Sequence) info
@@ -197,7 +248,10 @@ def parse_dsc_message(raw_line: str) -> dict[str, Any] | None:
msg['priority'] = get_category_priority(msg['category'])
# Mark if this is a critical alert
msg['is_critical'] = msg['category'] in ('DISTRESS', 'DISTRESS_ACK', 'DISTRESS_RELAY', 'URGENCY')
msg['is_critical'] = msg['category'] in (
'DISTRESS', 'DISTRESS_ACK', 'DISTRESS_RELAY',
'URGENCY', 'SAFETY', 'ALL_SHIPS_URGENCY_SAFETY',
)
return msg
+8 -6
View File
@@ -194,18 +194,20 @@ class GPSDClient:
"""Return gpsd connection info."""
return f"gpsd://{self.host}:{self.port}"
def add_callback(self, callback: Callable[[GPSPosition], None]) -> None:
"""Add a callback to be called on position updates."""
self._callbacks.append(callback)
def add_callback(self, callback: Callable[[GPSPosition], None]) -> None:
"""Add a callback to be called on position updates."""
if callback not in self._callbacks:
self._callbacks.append(callback)
def remove_callback(self, callback: Callable[[GPSPosition], None]) -> None:
"""Remove a position update callback."""
if callback in self._callbacks:
self._callbacks.remove(callback)
def add_sky_callback(self, callback: Callable[[GPSSkyData], None]) -> None:
"""Add a callback to be called on sky data updates."""
self._sky_callbacks.append(callback)
def add_sky_callback(self, callback: Callable[[GPSSkyData], None]) -> None:
"""Add a callback to be called on sky data updates."""
if callback not in self._sky_callbacks:
self._sky_callbacks.append(callback)
def remove_sky_callback(self, callback: Callable[[GPSSkyData], None]) -> None:
"""Remove a sky data update callback."""
+59 -40
View File
@@ -376,63 +376,82 @@ class MeshtasticClient:
self._error = "Meshtastic SDK not installed. Install with: pip install meshtastic"
return False
# Quick check under lock — bail if already running
with self._lock:
if self._running:
return True
try:
# Subscribe to message events before connecting
pub.subscribe(self._on_receive, "meshtastic.receive")
pub.subscribe(self._on_connection, "meshtastic.connection.established")
pub.subscribe(self._on_disconnect, "meshtastic.connection.lost")
# Create interface outside lock (blocking I/O: serial/TCP connect)
new_interface = None
new_device_path = None
new_connection_type = None
try:
# Subscribe to message events before connecting
pub.subscribe(self._on_receive, "meshtastic.receive")
pub.subscribe(self._on_connection, "meshtastic.connection.established")
pub.subscribe(self._on_disconnect, "meshtastic.connection.lost")
# Connect based on connection type
if connection_type == 'tcp':
if not hostname:
self._error = "Hostname is required for TCP connections"
self._cleanup_subscriptions()
return False
self._interface = meshtastic.tcp_interface.TCPInterface(hostname=hostname)
self._device_path = hostname
self._connection_type = 'tcp'
logger.info(f"Connected to Meshtastic device via TCP: {hostname}")
if connection_type == 'tcp':
if not hostname:
self._error = "Hostname is required for TCP connections"
self._cleanup_subscriptions()
return False
new_interface = meshtastic.tcp_interface.TCPInterface(hostname=hostname)
new_device_path = hostname
new_connection_type = 'tcp'
logger.info(f"Connected to Meshtastic device via TCP: {hostname}")
else:
if device:
new_interface = meshtastic.serial_interface.SerialInterface(device)
new_device_path = device
else:
# Serial connection (default)
if device:
self._interface = meshtastic.serial_interface.SerialInterface(device)
self._device_path = device
else:
# Auto-discover
self._interface = meshtastic.serial_interface.SerialInterface()
self._device_path = "auto"
self._connection_type = 'serial'
logger.info(f"Connected to Meshtastic device via serial: {self._device_path}")
new_interface = meshtastic.serial_interface.SerialInterface()
new_device_path = "auto"
new_connection_type = 'serial'
logger.info(f"Connected to Meshtastic device via serial: {new_device_path}")
except Exception as e:
self._error = str(e)
logger.error(f"Failed to connect to Meshtastic: {e}")
self._cleanup_subscriptions()
return False
self._running = True
self._error = None
# Install interface under lock
with self._lock:
if self._running:
# Another thread connected while we were connecting — discard ours
if new_interface:
try:
new_interface.close()
except Exception:
pass
return True
except Exception as e:
self._error = str(e)
logger.error(f"Failed to connect to Meshtastic: {e}")
self._cleanup_subscriptions()
return False
self._interface = new_interface
self._device_path = new_device_path
self._connection_type = new_connection_type
self._running = True
self._error = None
return True
def disconnect(self) -> None:
"""Disconnect from the Meshtastic device."""
iface_to_close = None
with self._lock:
if self._interface:
try:
self._interface.close()
except Exception as e:
logger.warning(f"Error closing Meshtastic interface: {e}")
self._interface = None
iface_to_close = self._interface
self._interface = None
self._cleanup_subscriptions()
self._running = False
self._device_path = None
self._connection_type = None
logger.info("Disconnected from Meshtastic device")
# Close interface outside lock (blocking I/O)
if iface_to_close:
try:
iface_to_close.close()
except Exception as e:
logger.warning(f"Error closing Meshtastic interface: {e}")
logger.info("Disconnected from Meshtastic device")
def _cleanup_subscriptions(self) -> None:
"""Unsubscribe from pubsub topics."""
+1388
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -76,6 +76,10 @@ def safe_terminate(process: subprocess.Popen | None, timeout: float = 2.0) -> bo
return True
except subprocess.TimeoutExpired:
process.kill()
try:
process.wait(timeout=3)
except subprocess.TimeoutExpired:
pass
unregister_process(process)
return True
except Exception as e:
+16 -7
View File
@@ -112,6 +112,8 @@ class ProcessMonitor:
def _check_all_processes(self) -> None:
"""Check health of all registered processes."""
# Collect crashed processes under lock, handle restarts outside
crashed: list[tuple[str, ProcessInfo]] = []
with self._lock:
for name, info in list(self.processes.items()):
if not info.enabled:
@@ -126,10 +128,14 @@ class ProcessMonitor:
logger.warning(
f"Process '{name}' terminated with code {return_code}"
)
self._handle_crash(name, info)
crashed.append((name, info))
# Handle restarts outside lock (involves sleeps and callbacks)
for name, info in crashed:
self._handle_crash(name, info)
def _handle_crash(self, name: str, info: ProcessInfo) -> None:
"""Handle a crashed process."""
"""Handle a crashed process. Must be called WITHOUT holding self._lock."""
if info.restart_callback is None:
logger.info(f"No restart callback for '{name}', skipping auto-restart")
return
@@ -139,7 +145,8 @@ class ProcessMonitor:
f"Process '{name}' exceeded max restarts ({info.max_restarts}), "
"disabling auto-restart"
)
info.enabled = False
with self._lock:
info.enabled = False
return
# Calculate backoff with exponential increase
@@ -149,18 +156,20 @@ class ProcessMonitor:
f"(attempt {info.restart_count + 1}/{info.max_restarts})"
)
# Wait for backoff period
# Wait for backoff period outside lock
time.sleep(backoff)
# Attempt restart
try:
info.restart_callback()
info.restart_count += 1
info.last_restart = datetime.now()
with self._lock:
info.restart_count += 1
info.last_restart = datetime.now()
logger.info(f"Successfully restarted '{name}'")
except Exception as e:
logger.error(f"Failed to restart '{name}': {e}")
info.restart_count += 1
with self._lock:
info.restart_count += 1
def get_status(self) -> Dict[str, Any]:
"""
+6 -2
View File
@@ -10,6 +10,8 @@ from __future__ import annotations
from typing import Optional
from utils.dependencies import get_tool_path
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
@@ -74,8 +76,9 @@ class AirspyCommandBuilder(CommandBuilder):
"""
device_str = self._build_device_string(device)
rx_fm_path = get_tool_path('rx_fm') or 'rx_fm'
cmd = [
'rx_fm',
rx_fm_path,
'-d', device_str,
'-f', f'{frequency_mhz}M',
'-M', modulation,
@@ -203,8 +206,9 @@ class AirspyCommandBuilder(CommandBuilder):
device_str = self._build_device_string(device)
freq_hz = int(frequency_mhz * 1e6)
rx_sdr_path = get_tool_path('rx_sdr') or 'rx_sdr'
cmd = [
'rx_sdr',
rx_sdr_path,
'-d', device_str,
'-f', str(freq_hz),
'-s', str(sample_rate),
+129 -87
View File
@@ -6,31 +6,31 @@ Detects RTL-SDR devices via rtl_test and other SDR hardware via SoapySDR.
from __future__ import annotations
import logging
import re
import shutil
import subprocess
import time
from typing import Optional
import logging
import re
import shutil
import subprocess
import time
from typing import Optional
from .base import SDRCapabilities, SDRDevice, SDRType
logger = logging.getLogger(__name__)
# Cache HackRF detection results so polling endpoints don't repeatedly run
# hackrf_info while the device is actively streaming in SubGHz mode.
_hackrf_cache: list[SDRDevice] = []
_hackrf_cache_ts: float = 0.0
_HACKRF_CACHE_TTL_SECONDS = 3.0
def _hackrf_probe_blocked() -> bool:
"""Return True when probing HackRF would interfere with an active stream."""
try:
from utils.subghz import get_subghz_manager
return get_subghz_manager().active_mode in {'rx', 'decode', 'tx', 'sweep'}
except Exception:
return False
logger = logging.getLogger(__name__)
# Cache HackRF detection results so polling endpoints don't repeatedly run
# hackrf_info while the device is actively streaming in SubGHz mode.
_hackrf_cache: list[SDRDevice] = []
_hackrf_cache_ts: float = 0.0
_HACKRF_CACHE_TTL_SECONDS = 3.0
def _hackrf_probe_blocked() -> bool:
"""Return True when probing HackRF would interfere with an active stream."""
try:
from utils.subghz import get_subghz_manager
return get_subghz_manager().active_mode in {'rx', 'decode', 'tx', 'sweep'}
except Exception:
return False
def _check_tool(name: str) -> bool:
@@ -112,21 +112,21 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
lib_paths = ['/usr/local/lib', '/opt/homebrew/lib']
current_ld = env.get('DYLD_LIBRARY_PATH', '')
env['DYLD_LIBRARY_PATH'] = ':'.join(lib_paths + [current_ld] if current_ld else lib_paths)
result = subprocess.run(
['rtl_test', '-t'],
capture_output=True,
text=True,
encoding='utf-8',
errors='replace',
timeout=5,
env=env
)
output = result.stderr + result.stdout
# Parse device info from rtl_test output
# Format: "0: Realtek, RTL2838UHIDIR, SN: 00000001"
# Require a non-empty serial to avoid matching malformed lines like "SN:".
device_pattern = r'(\d+):\s+(.+?),\s*SN:\s*(\S+)\s*$'
result = subprocess.run(
['rtl_test', '-t'],
capture_output=True,
text=True,
encoding='utf-8',
errors='replace',
timeout=5,
env=env
)
output = result.stderr + result.stdout
# Parse device info from rtl_test output
# Format: "0: Realtek, RTL2838UHIDIR, SN: 00000001"
# Require a non-empty serial to avoid matching malformed lines like "SN:".
device_pattern = r'(\d+):\s+(.+?),\s*SN:\s*(\S+)\s*$'
from .rtlsdr import RTLSDRCommandBuilder
@@ -134,14 +134,14 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
line = line.strip()
match = re.match(device_pattern, line)
if match:
devices.append(SDRDevice(
sdr_type=SDRType.RTL_SDR,
index=int(match.group(1)),
name=match.group(2).strip().rstrip(','),
serial=match.group(3),
driver='rtlsdr',
capabilities=RTLSDRCommandBuilder.CAPABILITIES
))
devices.append(SDRDevice(
sdr_type=SDRType.RTL_SDR,
index=int(match.group(1)),
name=match.group(2).strip().rstrip(','),
serial=match.group(3),
driver='rtlsdr',
capabilities=RTLSDRCommandBuilder.CAPABILITIES
))
# Fallback: if we found devices but couldn't parse details
if not devices:
@@ -314,29 +314,29 @@ def _add_soapy_device(
))
def detect_hackrf_devices() -> list[SDRDevice]:
"""
Detect HackRF devices using native hackrf_info tool.
Fallback for when SoapySDR is not available.
"""
global _hackrf_cache, _hackrf_cache_ts
now = time.time()
# While HackRF is actively streaming in SubGHz mode, skip probe calls.
# Re-running hackrf_info during active RX/TX can disrupt the USB stream.
if _hackrf_probe_blocked():
return list(_hackrf_cache)
if _hackrf_cache and (now - _hackrf_cache_ts) < _HACKRF_CACHE_TTL_SECONDS:
return list(_hackrf_cache)
devices: list[SDRDevice] = []
if not _check_tool('hackrf_info'):
_hackrf_cache = devices
_hackrf_cache_ts = now
return devices
def detect_hackrf_devices() -> list[SDRDevice]:
"""
Detect HackRF devices using native hackrf_info tool.
Fallback for when SoapySDR is not available.
"""
global _hackrf_cache, _hackrf_cache_ts
now = time.time()
# While HackRF is actively streaming in SubGHz mode, skip probe calls.
# Re-running hackrf_info during active RX/TX can disrupt the USB stream.
if _hackrf_probe_blocked():
return list(_hackrf_cache)
if _hackrf_cache and (now - _hackrf_cache_ts) < _HACKRF_CACHE_TTL_SECONDS:
return list(_hackrf_cache)
devices: list[SDRDevice] = []
if not _check_tool('hackrf_info'):
_hackrf_cache = devices
_hackrf_cache_ts = now
return devices
try:
result = subprocess.run(
@@ -374,12 +374,12 @@ def detect_hackrf_devices() -> list[SDRDevice]:
capabilities=HackRFCommandBuilder.CAPABILITIES
))
except Exception as e:
logger.debug(f"HackRF detection error: {e}")
_hackrf_cache = list(devices)
_hackrf_cache_ts = now
return devices
except Exception as e:
logger.debug(f"HackRF detection error: {e}")
_hackrf_cache = list(devices)
_hackrf_cache_ts = now
return devices
def probe_rtlsdr_device(device_index: int) -> str | None:
@@ -413,31 +413,73 @@ def probe_rtlsdr_device(device_index: int) -> str | None:
lib_paths + [current_ld] if current_ld else lib_paths
)
result = subprocess.run(
# Use Popen with early termination instead of run() with full timeout.
# rtl_test prints device info to stderr quickly, then keeps running
# its test loop. We kill it as soon as we see success or failure.
proc = subprocess.Popen(
['rtl_test', '-d', str(device_index), '-t'],
capture_output=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=3,
env=env,
)
output = result.stderr + result.stdout
if 'usb_claim_interface' in output or 'Failed to open' in output:
import select
error_found = False
device_found = False
deadline = time.monotonic() + 3.0
try:
while time.monotonic() < deadline:
remaining = deadline - time.monotonic()
if remaining <= 0:
break
# Wait for stderr output with timeout
ready, _, _ = select.select(
[proc.stderr], [], [], min(remaining, 0.1)
)
if ready:
line = proc.stderr.readline()
if not line:
break # EOF — process closed stderr
# Check for no-device messages first (before success check,
# since "No supported devices found" also contains "Found" + "device")
if 'no supported devices' in line.lower() or 'no matching devices' in line.lower():
error_found = True
break
if 'usb_claim_interface' in line or 'Failed to open' in line:
error_found = True
break
if 'Found' in line and 'device' in line.lower():
# Device opened successfully — no need to wait longer
device_found = True
break
if proc.poll() is not None:
break # Process exited
if not device_found and not error_found and proc.poll() is not None and proc.returncode != 0:
# rtl_test exited with error and we never saw a success message
error_found = True
finally:
try:
proc.kill()
except OSError:
pass
proc.wait()
if device_found:
# Allow the kernel to fully release the USB interface
# before the caller opens the device with dump1090/rtl_fm/etc.
time.sleep(0.5)
if error_found:
logger.warning(
f"RTL-SDR device {device_index} USB probe failed: "
f"device busy or unavailable"
)
return (
f'SDR device {device_index} is busy at the USB level'
f'another process outside INTERCEPT may be using it. '
f'Check for stale rtl_fm/rtl_433/dump1090 processes, '
f'or try a different device.'
f'SDR device {device_index} is not available'
f'check that the RTL-SDR is connected and not in use by another process.'
)
except subprocess.TimeoutExpired:
# rtl_test opened the device successfully and is running the
# test — that means the device *is* available.
pass
except Exception as e:
logger.debug(f"RTL-SDR probe error for device {device_index}: {e}")
+6 -2
View File
@@ -9,6 +9,8 @@ from __future__ import annotations
from typing import Optional
from utils.dependencies import get_tool_path
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
@@ -70,8 +72,9 @@ class HackRFCommandBuilder(CommandBuilder):
"""
device_str = self._build_device_string(device)
rx_fm_path = get_tool_path('rx_fm') or 'rx_fm'
cmd = [
'rx_fm',
rx_fm_path,
'-d', device_str,
'-f', f'{frequency_mhz}M',
'-M', modulation,
@@ -203,8 +206,9 @@ class HackRFCommandBuilder(CommandBuilder):
device_str = self._build_device_string(device)
freq_hz = int(frequency_mhz * 1e6)
rx_sdr_path = get_tool_path('rx_sdr') or 'rx_sdr'
cmd = [
'rx_sdr',
rx_sdr_path,
'-d', device_str,
'-f', str(freq_hz),
'-s', str(sample_rate),

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