Compare commits

..

96 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Affected functions:
- install_dump1090_from_source_debian
- install_acarsdec_from_source_debian
- install_dumpvdl2_from_source_debian
- install_aiscatcher_from_source_debian

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

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

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

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

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

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

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

Closes #139

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 15:38:00 +00:00
Smittix 99d52eafe7 chore: Bump version to v2.18.0
Bluetooth enhancements (service data inspector, appearance codes, MAC
cluster tracking, behavioral flags, IRK badges, distance estimation),
ACARS SoapySDR multi-backend support, dump1090 stale process cleanup,
GPS error state, and proximity radar/signal card UI improvements.

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 20:24:51 +00:00
184 changed files with 34434 additions and 6237 deletions
+3
View File
@@ -58,6 +58,9 @@ intercept_agent_*.cfg
# Weather satellite runtime data (decoded images, samples, SatDump output) # Weather satellite runtime data (decoded images, samples, SatDump output)
data/weather_sat/ data/weather_sat/
# SDR capture files (large IQ recordings)
data/subghz/captures/
# Env files # Env files
.env .env
.env.* .env.*
+22 -5
View File
@@ -1,10 +1,27 @@
# Changelog # Changelog
All notable changes to iNTERCEPT will be documented in this file. All notable changes to iNTERCEPT will be documented in this file.
## [2.15.0] - 2026-02-09 ## [2.21.0] - 2026-02-20
### Added ### Added
- Analytics panels for operational insights and temporal pattern analysis
### Changed
- Global map theme refresh with improved contrast and cross-dashboard consistency
- Cross-app UX refinements for accessibility, mode consistency, and render performance
- BT Locate enhancements including improved continuity, smoothing, and confidence reporting
### Fixed
- Weather satellite auto-scheduler and Mercator tracking reliability issues
- Bluetooth/WiFi runtime health issues affecting scanner continuity
- ADS-B SSE multi-client fanout stability and remote VDL2 streaming reliability
---
## [2.15.0] - 2026-02-09
### Added
- **Real-time WebSocket Waterfall** - I/Q capture with server-side FFT - **Real-time WebSocket Waterfall** - I/Q capture with server-side FFT
- Click-to-tune, zoom controls, and auto-scaling quantization - Click-to-tune, zoom controls, and auto-scaling quantization
- Shared waterfall UI across SDR modes with function bar controls - Shared waterfall UI across SDR modes with function bar controls
+23 -1
View File
@@ -41,6 +41,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
bluez \ bluez \
bluetooth \ bluetooth \
# GPS support # GPS support
gpsd \
gpsd-clients \ gpsd-clients \
# Utilities # Utilities
# APRS # APRS
@@ -94,6 +95,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libfftw3-dev \ libfftw3-dev \
liblapack-dev \ liblapack-dev \
libcodec2-dev \ libcodec2-dev \
libglib2.0-dev \
libxml2-dev \
# Build dump1090 # Build dump1090
&& cd /tmp \ && cd /tmp \
&& git clone --depth 1 https://github.com/flightaware/dump1090.git \ && git clone --depth 1 https://github.com/flightaware/dump1090.git \
@@ -136,10 +139,29 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& git clone --depth 1 https://github.com/TLeconte/acarsdec.git \ && git clone --depth 1 https://github.com/TLeconte/acarsdec.git \
&& cd acarsdec \ && cd acarsdec \
&& mkdir build && cd build \ && mkdir build && cd build \
&& cmake .. -Drtl=ON \ && cmake .. -Drtl=ON -DCMAKE_POLICY_VERSION_MINIMUM=3.5 \
&& make \ && make \
&& cp acarsdec /usr/bin/acarsdec \ && cp acarsdec /usr/bin/acarsdec \
&& rm -rf /tmp/acarsdec \ && rm -rf /tmp/acarsdec \
# Build libacars (required by dumpvdl2)
&& cd /tmp \
&& git clone --depth 1 https://github.com/szpajder/libacars.git \
&& cd libacars \
&& mkdir build && cd build \
&& cmake .. \
&& make \
&& make install \
&& ldconfig \
&& rm -rf /tmp/libacars \
# Build dumpvdl2 (VDL2 aircraft datalink decoder)
&& cd /tmp \
&& git clone --depth 1 https://github.com/szpajder/dumpvdl2.git \
&& cd dumpvdl2 \
&& mkdir build && cd build \
&& cmake .. \
&& make \
&& cp src/dumpvdl2 /usr/bin/dumpvdl2 \
&& rm -rf /tmp/dumpvdl2 \
# Build slowrx (SSTV decoder) — pinned to known-good commit # Build slowrx (SSTV decoder) — pinned to known-good commit
&& cd /tmp \ && cd /tmp \
&& git clone https://github.com/windytan/slowrx.git \ && git clone https://github.com/windytan/slowrx.git \
+196 -17
View File
@@ -1,21 +1,200 @@
MIT License
Copyright (c) 2025 smittix Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
Permission is hereby granted, free of charge, to any person obtaining a copy TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all 1. Definitions.
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR "License" shall mean the terms and conditions for use, reproduction,
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, and distribution as defined by Sections 1 through 9 of this document.
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER "Licensor" shall mean the copyright owner or entity authorized by
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, the copyright owner that is granting the License.
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. "Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by the Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding any notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. Please also get an OpenPGP
key and encrypt outgoing communications.
Copyright 2025 smittix
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+17 -7
View File
@@ -28,21 +28,27 @@ Support the developer of this open-source project
- **Pager Decoding** - POCSAG/FLEX via rtl_fm + multimon-ng - **Pager Decoding** - POCSAG/FLEX via rtl_fm + multimon-ng
- **433MHz Sensors** - Weather stations, TPMS, IoT devices via rtl_433 - **433MHz Sensors** - Weather stations, TPMS, IoT devices via rtl_433
- **Sub-GHz Analyzer** - RF capture and protocol decoding for 300-928 MHz ISM bands via HackRF
- **Aircraft Tracking** - ADS-B via dump1090 with real-time map and radar - **Aircraft Tracking** - ADS-B via dump1090 with real-time map and radar
- **Vessel Tracking** - AIS ship tracking with VHF DSC distress monitoring - **Vessel Tracking** - AIS ship tracking with VHF DSC distress monitoring
- **ACARS Messaging** - Aircraft datalink messages via acarsdec - **ACARS Messaging** - Aircraft datalink messages via acarsdec
- **DMR Digital Voice** - DMR/P25/NXDN/D-STAR decoding via dsd-fme with visual synthesizer - **VDL2** - VHF Data Link Mode 2 aircraft datalink decoding via dumpvdl2
- **Listening Post** - Frequency scanner with audio monitoring - **Listening Post** - Wideband frequency scanner with real-time audio monitoring
- **Weather Satellites** - NOAA APT and Meteor LRPT image decoding via SatDump - **Weather Satellites** - NOAA APT and Meteor LRPT image decoding via SatDump with auto-scheduler
- **WebSDR** - Remote HF/shortwave listening via WebSDR servers - **WebSDR** - Remote HF/shortwave listening via KiwiSDR network
- **ISS SSTV** - Slow-scan TV image reception from the International Space Station - **ISS SSTV** - Slow-scan TV image reception from the International Space Station
- **HF SSTV** - Terrestrial SSTV on shortwave frequencies - **HF SSTV** - Terrestrial SSTV on shortwave frequencies (80m-10m, VHF, UHF)
- **Satellite Tracking** - Pass prediction using TLE data - **APRS** - Amateur packet radio position reports and telemetry via direwolf
- **Satellite Tracking** - Pass prediction with polar plot and ground track map
- **Utility Meters** - Electric, gas, and water meter reading via rtl_amr
- **ADS-B History** - Persistent aircraft history with reporting dashboard (Postgres optional) - **ADS-B History** - Persistent aircraft history with reporting dashboard (Postgres optional)
- **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng - **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng
- **Bluetooth Scanning** - Device discovery and tracker detection (with Ubertooth support) - **Bluetooth Scanning** - Device discovery and tracker detection (with Ubertooth support)
- **BT Locate** - SAR Bluetooth device location with GPS-tagged signal trail mapping and proximity alerts
- **GPS** - Real-time GPS position tracking with live map, speed, altitude, and satellite info
- **TSCM** - Counter-surveillance with RF baseline comparison and threat detection - **TSCM** - Counter-surveillance with RF baseline comparison and threat detection
- **Meshtastic** - LoRa mesh network integration - **Meshtastic** - LoRa mesh network integration
- **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 - **Spy Stations** - Number stations and diplomatic HF network database
- **Remote Agents** - Distributed SIGINT with remote sensor nodes - **Remote Agents** - Distributed SIGINT with remote sensor nodes
- **Offline Mode** - Bundled assets for air-gapped/field deployments - **Offline Mode** - Bundled assets for air-gapped/field deployments
@@ -224,7 +230,7 @@ This project was developed using AI as a coding partner, combining human directi
## License ## License
MIT License - see [LICENSE](LICENSE) Apache 2.0 License - see [LICENSE](LICENSE)
## Author ## Author
@@ -238,6 +244,9 @@ Created by **smittix** - [GitHub](https://github.com/smittix)
[dump1090](https://github.com/flightaware/dump1090) | [dump1090](https://github.com/flightaware/dump1090) |
[AIS-catcher](https://github.com/jvde-github/AIS-catcher) | [AIS-catcher](https://github.com/jvde-github/AIS-catcher) |
[acarsdec](https://github.com/TLeconte/acarsdec) | [acarsdec](https://github.com/TLeconte/acarsdec) |
[direwolf](https://github.com/wb2osz/direwolf) |
[rtl_amr](https://github.com/bemasher/rtlamr) |
[dumpvdl2](https://github.com/szpajder/dumpvdl2) |
[aircrack-ng](https://www.aircrack-ng.org/) | [aircrack-ng](https://www.aircrack-ng.org/) |
[Leaflet.js](https://leafletjs.com/) | [Leaflet.js](https://leafletjs.com/) |
[SatDump](https://github.com/SatDump/SatDump) | [SatDump](https://github.com/SatDump/SatDump) |
@@ -252,3 +261,4 @@ Created by **smittix** - [GitHub](https://github.com/smittix)
+1 -1
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,4 +1,4 @@
{ {
"version": "2026-02-01_ba81b697", "version": "2026-02-15_ae16bb62",
"downloaded": "2026-02-04T17:06:54.806043Z" "downloaded": "2026-02-20T00:29:06.228007Z"
} }
+180 -98
View File
@@ -29,7 +29,7 @@ from flask import Flask, render_template, jsonify, send_file, Response, request,
from werkzeug.security import check_password_hash from werkzeug.security import check_password_hash
from config import VERSION, CHANGELOG, SHARED_OBSERVER_LOCATION_ENABLED, DEFAULT_LATITUDE, DEFAULT_LONGITUDE from config import VERSION, CHANGELOG, SHARED_OBSERVER_LOCATION_ENABLED, DEFAULT_LATITUDE, DEFAULT_LONGITUDE
from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
from utils.process import cleanup_stale_processes from utils.process import cleanup_stale_processes, cleanup_stale_dump1090
from utils.sdr import SDRFactory from utils.sdr import SDRFactory
from utils.cleanup import DataStore, cleanup_manager from utils.cleanup import DataStore, cleanup_manager
from utils.constants import ( from utils.constants import (
@@ -150,6 +150,11 @@ acars_process = None
acars_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) acars_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
acars_lock = threading.Lock() acars_lock = threading.Lock()
# VDL2 aircraft datalink
vdl2_process = None
vdl2_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
vdl2_lock = threading.Lock()
# APRS amateur radio tracking # APRS amateur radio tracking
aprs_process = None aprs_process = None
aprs_rtl_process = None aprs_rtl_process = None
@@ -182,6 +187,10 @@ dmr_lock = threading.Lock()
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
tscm_lock = threading.Lock() tscm_lock = threading.Lock()
# SubGHz Transceiver (HackRF)
subghz_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
subghz_lock = threading.Lock()
# Deauth Attack Detection # Deauth Attack Detection
deauth_detector = None deauth_detector = None
deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
@@ -235,77 +244,62 @@ cleanup_manager.register(deauth_alerts)
# ============================================ # ============================================
# SDR DEVICE REGISTRY # SDR DEVICE REGISTRY
# ============================================ # ============================================
# Tracks which mode is using which SDR device to prevent conflicts # Tracks which mode is using which SDR device to prevent conflicts
# Key: device_index (int), Value: {sdr_type: mode_name} # Key: device_index (int), Value: mode_name (str)
sdr_device_registry: dict[int, dict[str, str]] = {} sdr_device_registry: dict[int, str] = {}
sdr_device_registry_lock = threading.Lock() sdr_device_registry_lock = threading.Lock()
def claim_sdr_device(device_index: int, mode_name: str, sdr_type: str = 'rtlsdr') -> str | None: def claim_sdr_device(device_index: int, mode_name: str) -> str | None:
"""Claim an SDR device for a mode. """Claim an SDR device for a mode.
Checks the in-app registry first, then probes the USB device to Checks the in-app registry first, then probes the USB device to
catch stale handles held by external processes (e.g. a leftover catch stale handles held by external processes (e.g. a leftover
rtl_fm from a previous crash). rtl_fm from a previous crash).
Args: Args:
device_index: The SDR device index to claim device_index: The SDR device index to claim
mode_name: Name of the mode claiming the device (e.g., 'sensor', 'rtlamr') mode_name: Name of the mode claiming the device (e.g., 'sensor', 'rtlamr')
sdr_type: SDR hardware type (e.g., 'rtlsdr', 'hackrf')
Returns: Returns:
Error message if device is in use, None if successfully claimed Error message if device is in use, None if successfully claimed
""" """
sdr_type_key = str(sdr_type or 'rtlsdr').lower() with sdr_device_registry_lock:
if device_index in sdr_device_registry:
with sdr_device_registry_lock: in_use_by = sdr_device_registry[device_index]
device_entry = sdr_device_registry.get(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 sdr_type_key in device_entry:
in_use_by = device_entry[sdr_type_key] # Probe the USB device to catch external processes holding the handle
return f'SDR device {device_index} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.' try:
from utils.sdr.detection import probe_rtlsdr_device
# Probe the USB device to catch external processes holding the handle usb_error = probe_rtlsdr_device(device_index)
# Only relevant for RTL-SDR devices if usb_error:
if sdr_type_key == 'rtlsdr': return usb_error
try: except Exception:
from utils.sdr.detection import probe_rtlsdr_device pass # If probe fails, let the caller proceed normally
usb_error = probe_rtlsdr_device(device_index)
if usb_error: sdr_device_registry[device_index] = mode_name
return usb_error return None
except Exception:
pass # If probe fails, let the caller proceed normally
def release_sdr_device(device_index: int) -> None:
if device_index not in sdr_device_registry: """Release an SDR device from the registry.
sdr_device_registry[device_index] = {}
sdr_device_registry[device_index][sdr_type_key] = mode_name
return None
def release_sdr_device(device_index: int, sdr_type: str = 'rtlsdr') -> None:
"""Release an SDR device from the registry.
Args: Args:
device_index: The SDR device index to release device_index: The SDR device index to release
sdr_type: SDR hardware type (e.g., 'rtlsdr', 'hackrf') """
""" with sdr_device_registry_lock:
sdr_type_key = str(sdr_type or 'rtlsdr').lower() sdr_device_registry.pop(device_index, None)
with sdr_device_registry_lock:
entry = sdr_device_registry.get(device_index)
if not entry:
return
entry.pop(sdr_type_key, None)
if not entry:
sdr_device_registry.pop(device_index, None)
def get_sdr_device_status() -> dict[int, dict[str, str]]: def get_sdr_device_status() -> dict[int, str]:
"""Get current SDR device allocations. """Get current SDR device allocations.
Returns: Returns:
Dictionary mapping device indices to {sdr_type: mode_name} Dictionary mapping device indices to mode names
""" """
with sdr_device_registry_lock: with sdr_device_registry_lock:
return {idx: dict(modes) for idx, modes in sdr_device_registry.items()} return dict(sdr_device_registry)
# ============================================ # ============================================
@@ -403,20 +397,17 @@ def get_devices() -> Response:
@app.route('/devices/status') @app.route('/devices/status')
def get_devices_status() -> Response: def get_devices_status() -> Response:
"""Get all SDR devices with usage status.""" """Get all SDR devices with usage status."""
devices = SDRFactory.detect_devices() devices = SDRFactory.detect_devices()
registry = get_sdr_device_status() registry = get_sdr_device_status()
result = [] result = []
for device in devices: for device in devices:
d = device.to_dict() d = device.to_dict()
sdr_type_key = device.sdr_type.value if hasattr(device.sdr_type, 'value') else str(device.sdr_type) d['in_use'] = device.index in registry
sdr_type_key = str(sdr_type_key).lower() d['used_by'] = registry.get(device.index)
device_registry = registry.get(device.index, {}) result.append(d)
d['in_use'] = sdr_type_key in device_registry
d['used_by'] = device_registry.get(sdr_type_key)
result.append(d)
return jsonify(result) return jsonify(result)
@@ -661,41 +652,119 @@ def export_bluetooth() -> Response:
}) })
@app.route('/health') def _get_subghz_active() -> bool:
def health_check() -> Response: """Check if SubGHz manager has an active process."""
"""Health check endpoint for monitoring.""" try:
import time from utils.subghz import get_subghz_manager
return jsonify({ return get_subghz_manager().active_mode != 'idle'
'status': 'healthy', except Exception:
'version': VERSION, return False
'uptime_seconds': round(time.time() - _app_start_time, 2),
'processes': {
def _get_dmr_active() -> bool:
"""Check if Digital Voice decoder has an active process."""
try:
from routes import dmr as dmr_module
proc = dmr_module.dmr_dsd_process
return bool(dmr_module.dmr_running and proc and proc.poll() is None)
except Exception:
return False
def _get_bluetooth_health() -> tuple[bool, int]:
"""Return Bluetooth active state and best-effort device count."""
legacy_running = bt_process is not None and (bt_process.poll() is None if bt_process else False)
scanner_running = False
scanner_count = 0
try:
from utils.bluetooth.scanner import _scanner_instance as bt_scanner
if bt_scanner is not None:
scanner_running = bool(bt_scanner.is_scanning)
scanner_count = int(bt_scanner.device_count)
except Exception:
scanner_running = False
scanner_count = 0
locate_running = False
try:
from utils.bt_locate import get_locate_session
session = get_locate_session()
if session and getattr(session, 'active', False):
scanner = getattr(session, '_scanner', None)
locate_running = bool(scanner and scanner.is_scanning)
except Exception:
locate_running = False
return (legacy_running or scanner_running or locate_running), max(len(bt_devices), scanner_count)
def _get_wifi_health() -> tuple[bool, int, int]:
"""Return WiFi active state and best-effort network/client counts."""
legacy_running = wifi_process is not None and (wifi_process.poll() is None if wifi_process else False)
scanner_running = False
scanner_networks = 0
scanner_clients = 0
try:
from utils.wifi.scanner import _scanner_instance as wifi_scanner
if wifi_scanner is not None:
status = wifi_scanner.get_status()
scanner_running = bool(status.is_scanning)
scanner_networks = int(status.networks_found or 0)
scanner_clients = int(status.clients_found or 0)
except Exception:
scanner_running = False
scanner_networks = 0
scanner_clients = 0
return (
legacy_running or scanner_running,
max(len(wifi_networks), scanner_networks),
max(len(wifi_clients), scanner_clients),
)
@app.route('/health')
def health_check() -> Response:
"""Health check endpoint for monitoring."""
import time
bt_active, bt_device_count = _get_bluetooth_health()
wifi_active, wifi_network_count, wifi_client_count = _get_wifi_health()
return jsonify({
'status': 'healthy',
'version': VERSION,
'uptime_seconds': round(time.time() - _app_start_time, 2),
'processes': {
'pager': current_process is not None and (current_process.poll() is None if current_process else False), 'pager': current_process is not None and (current_process.poll() is None if current_process else False),
'sensor': sensor_process is not None and (sensor_process.poll() is None if sensor_process else False), 'sensor': sensor_process is not None and (sensor_process.poll() is None if sensor_process else False),
'adsb': adsb_process is not None and (adsb_process.poll() is None if adsb_process else False), 'adsb': adsb_process is not None and (adsb_process.poll() is None if adsb_process else False),
'ais': ais_process is not None and (ais_process.poll() is None if ais_process else False), 'ais': ais_process is not None and (ais_process.poll() is None if ais_process else False),
'acars': acars_process is not None and (acars_process.poll() is None if acars_process else False), 'acars': acars_process is not None and (acars_process.poll() is None if acars_process else False),
'aprs': aprs_process is not None and (aprs_process.poll() is None if aprs_process else False), 'vdl2': vdl2_process is not None and (vdl2_process.poll() is None if vdl2_process else False),
'wifi': wifi_process is not None and (wifi_process.poll() is None if wifi_process else False), 'aprs': aprs_process is not None and (aprs_process.poll() is None if aprs_process else False),
'bluetooth': bt_process is not None and (bt_process.poll() is None if bt_process else False), 'wifi': wifi_active,
'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False), 'bluetooth': bt_active,
'dmr': dmr_process is not None and (dmr_process.poll() is None if dmr_process else False), 'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False),
}, 'dmr': _get_dmr_active(),
'data': { 'subghz': _get_subghz_active(),
'aircraft_count': len(adsb_aircraft), },
'vessel_count': len(ais_vessels), 'data': {
'wifi_networks_count': len(wifi_networks), 'aircraft_count': len(adsb_aircraft),
'wifi_clients_count': len(wifi_clients), 'vessel_count': len(ais_vessels),
'bt_devices_count': len(bt_devices), 'wifi_networks_count': wifi_network_count,
'dsc_messages_count': len(dsc_messages), 'wifi_clients_count': wifi_client_count,
} 'bt_devices_count': bt_device_count,
}) 'dsc_messages_count': len(dsc_messages),
}
})
@app.route('/killall', methods=['POST']) @app.route('/killall', methods=['POST'])
def kill_all() -> Response: def kill_all() -> Response:
"""Kill all decoder, WiFi, and Bluetooth processes.""" """Kill all decoder, WiFi, and Bluetooth processes."""
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
global vdl2_process
global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_process global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_process
global dmr_process, dmr_rtl_process global dmr_process, dmr_rtl_process
@@ -708,9 +777,10 @@ def kill_all() -> Response:
processes_to_kill = [ processes_to_kill = [
'rtl_fm', 'multimon-ng', 'rtl_433', 'rtl_fm', 'multimon-ng', 'rtl_433',
'airodump-ng', 'aireplay-ng', 'airmon-ng', 'airodump-ng', 'aireplay-ng', 'airmon-ng',
'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher', 'dump1090', 'acarsdec', 'dumpvdl2', 'direwolf', 'AIS-catcher',
'hcitool', 'bluetoothctl', 'satdump', 'dsd', 'hcitool', 'bluetoothctl', 'satdump', 'dsd',
'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg' 'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg',
'hackrf_transfer', 'hackrf_sweep'
] ]
for proc in processes_to_kill: for proc in processes_to_kill:
@@ -744,6 +814,10 @@ def kill_all() -> Response:
with acars_lock: with acars_lock:
acars_process = None acars_process = None
# Reset VDL2 state
with vdl2_lock:
vdl2_process = None
# Reset APRS state # Reset APRS state
with aprs_lock: with aprs_lock:
aprs_process = None aprs_process = None
@@ -779,6 +853,13 @@ def kill_all() -> Response:
except Exception: except Exception:
pass pass
# Reset SubGHz state
try:
from utils.subghz import get_subghz_manager
get_subghz_manager().stop_all()
except Exception:
pass
# Clear SDR device registry # Clear SDR device registry
with sdr_device_registry_lock: with sdr_device_registry_lock:
sdr_device_registry.clear() sdr_device_registry.clear()
@@ -863,6 +944,7 @@ def main() -> None:
# Clean up any stale processes from previous runs # Clean up any stale processes from previous runs
cleanup_stale_processes() cleanup_stale_processes()
cleanup_stale_dump1090()
# Initialize database for settings storage # Initialize database for settings storage
from utils.database import init_db from utils.database import init_db
+83 -3
View File
@@ -6,11 +6,81 @@ import logging
import os import os
import sys import sys
# Application version # Application version
VERSION = "2.15.0" VERSION = "2.21.0"
# Changelog - latest release notes (shown on welcome screen) # Changelog - latest release notes (shown on welcome screen)
CHANGELOG = [ CHANGELOG = [
{
"version": "2.21.0",
"date": "February 2026",
"highlights": [
"Global map theme refresh with improved contrast and cross-dashboard consistency",
"Cross-app UX updates for accessibility, mode consistency, and render performance",
"Weather satellite reliability fixes for auto-scheduler and Mercator pass tracking",
"Bluetooth/WiFi runtime health fixes with BT Locate continuity and confidence improvements",
"ADS-B/VDL2 streaming reliability upgrades for multi-client SSE fanout and remote decoding",
"Analytics enhancements with operational insights and temporal pattern panels",
]
},
{
"version": "2.20.0",
"date": "February 2026",
"highlights": [
"Space Weather mode: real-time solar and geomagnetic monitoring from NOAA SWPC, NASA SDO, and HamQSL",
"Kp index, solar wind, X-ray flux charts with Chart.js visualization",
"HF band conditions, D-RAP absorption maps, aurora forecast, and solar imagery",
"NOAA Space Weather Scales (G/S/R), flare probability, and active solar regions",
"No SDR hardware required — all data from public APIs with server-side caching",
]
},
{
"version": "2.19.0",
"date": "February 2026",
"highlights": [
"VDL2 mode with modal message viewer, consolidated into ADS-B dashboard",
"ADS-B: trails enabled by default, radar modes removed, CSV export added",
"Bundled Roboto Condensed font for offline mode with SVG icon overhaul",
"Help modal updated with all modes and correct SVG icons",
"Setup script overhauled for reliability and macOS compatibility",
"GPS fix for preserving satellites across DOP-only SKY messages",
"Fix gpsd deadlock causing GPS connect to hang",
]
},
{
"version": "2.18.0",
"date": "February 2026",
"highlights": [
"Bluetooth: service data inspector, appearance codes, MAC cluster tracking, and behavioral flags",
"Bluetooth: IRK badge display, distance estimation with confidence, and signal stability metrics",
"ACARS: SoapySDR device support for SDRplay, LimeSDR, Airspy, and other non-RTL backends",
"ADS-B: stale dump1090 process cleanup via PID file tracking",
"GPS: error state indicator and UI refinements",
"Proximity radar and signal card UI improvements",
]
},
{
"version": "2.17.0",
"date": "February 2026",
"highlights": [
"BT Locate: SAR Bluetooth device location with GPS-tagged signal trail and proximity alerts",
"IRK auto-detection: extract Identity Resolving Keys from paired devices (macOS/Linux)",
"GPS mode: real-time position tracking with live map, speed, altitude, and satellite info",
"Bluetooth scanner lifecycle fix for bleak scan timeout tracking",
]
},
{
"version": "2.16.0",
"date": "February 2026",
"highlights": [
"Sub-GHz analyzer with real-time RF capture and protocol decoding",
"Weather satellite auto-scheduler with polar plot and ground track map",
"SatDump support for local (non-Docker) installs via setup.sh",
"Shared waterfall UI across SDR modes",
"Listening post audio stuttering fix and SDR race condition fixes",
"Multi-arch Docker build support (amd64 + arm64)",
]
},
{ {
"version": "2.15.0", "version": "2.15.0",
"date": "February 2026", "date": "February 2026",
@@ -227,6 +297,16 @@ WEATHER_SAT_PREDICTION_HOURS = _get_env_int('WEATHER_SAT_PREDICTION_HOURS', 24)
WEATHER_SAT_SCHEDULE_REFRESH_MINUTES = _get_env_int('WEATHER_SAT_SCHEDULE_REFRESH_MINUTES', 30) WEATHER_SAT_SCHEDULE_REFRESH_MINUTES = _get_env_int('WEATHER_SAT_SCHEDULE_REFRESH_MINUTES', 30)
WEATHER_SAT_CAPTURE_BUFFER_SECONDS = _get_env_int('WEATHER_SAT_CAPTURE_BUFFER_SECONDS', 30) WEATHER_SAT_CAPTURE_BUFFER_SECONDS = _get_env_int('WEATHER_SAT_CAPTURE_BUFFER_SECONDS', 30)
# SubGHz transceiver settings (HackRF)
SUBGHZ_DEFAULT_FREQUENCY = _get_env_float('SUBGHZ_FREQUENCY', 433.92)
SUBGHZ_DEFAULT_SAMPLE_RATE = _get_env_int('SUBGHZ_SAMPLE_RATE', 2000000)
SUBGHZ_DEFAULT_LNA_GAIN = _get_env_int('SUBGHZ_LNA_GAIN', 32)
SUBGHZ_DEFAULT_VGA_GAIN = _get_env_int('SUBGHZ_VGA_GAIN', 20)
SUBGHZ_DEFAULT_TX_GAIN = _get_env_int('SUBGHZ_TX_GAIN', 20)
SUBGHZ_MAX_TX_DURATION = _get_env_int('SUBGHZ_MAX_TX_DURATION', 10)
SUBGHZ_SWEEP_START_MHZ = _get_env_float('SUBGHZ_SWEEP_START', 300.0)
SUBGHZ_SWEEP_END_MHZ = _get_env_float('SUBGHZ_SWEEP_END', 928.0)
# Update checking # Update checking
GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept') GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept')
UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True) UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True)
+143
View File
@@ -16,6 +16,14 @@ Complete feature list for all modules.
- **Doorbells, remotes, and IoT devices** - **Doorbells, remotes, and IoT devices**
- **Smart meters** and utility monitors - **Smart meters** and utility monitors
## Sub-GHz Analyzer
- **HackRF-based** signal capture and analysis for 300-928 MHz ISM bands
- **Protocol decoding** - identify and decode common Sub-GHz protocols
- **Signal replay/transmit** capabilities for authorized testing
- **Wideband spectrum analysis** with real-time visualization
- **I/Q capture** - record raw samples for offline analysis
## AIS Vessel Tracking ## AIS Vessel Tracking
- **Real-time vessel tracking** via AIS-catcher on 161.975/162.025 MHz - **Real-time vessel tracking** via AIS-catcher on 161.975/162.025 MHz
@@ -84,6 +92,95 @@ Digital Selective Calling (DSC) monitoring on the international maritime distres
- **SDR conflict detection** - Prevents device collisions with AIS tracking - **SDR conflict detection** - Prevents device collisions with AIS tracking
- **Alert summary** - Dashboard counts for unacknowledged distress/urgency - **Alert summary** - Dashboard counts for unacknowledged distress/urgency
## ACARS Messaging
- **Real-time ACARS decoding** via acarsdec
- **Aircraft datalink messages** - operational, weather, and position reports
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
- **Message filtering** - filter by message type, flight, or registration
## VDL2 (VHF Data Link Mode 2)
- **Real-time VDL2 decoding** via dumpvdl2 on standard VDL2 frequencies
- **ACARS-over-AVLC** message capture with full frame parsing
- **Signal analysis** - frequency, signal level, noise level, SNR, burst length
- **AVLC frame details** - source/destination addresses, frame type, command/response
- **Raw JSON inspection** - expandable raw message data for each frame
- **Multi-frequency monitoring** - simultaneous reception on multiple VDL2 channels
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
- **CSV/JSON export** - export captured messages for offline analysis
- **Integrated with ADS-B dashboard** - VDL2 messages linked to aircraft tracking
## 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
- **Customizable frequency presets** and band bookmarks
- **Multi-SDR support** - RTL-SDR, LimeSDR, HackRF, Airspy, SDRplay
## Weather Satellites
- **NOAA APT** and **Meteor LRPT** image decoding via SatDump
- **Auto-scheduler** with pass prediction and automatic capture
- **Polar plot** - real-time satellite position on azimuth/elevation display
- **Ground track map** - orbit path with past/future trajectory
- **Image gallery** with timestamped decoded imagery
## WebSDR
- **KiwiSDR network integration** for remote HF/shortwave listening
- **WebSocket audio streaming** from remote receivers
- **Receiver discovery** with automatic caching
- **Frequency tuning** with band presets
## ISS SSTV
- **ISS SSTV image reception** on 145.800 MHz FM during special event transmissions
- **Real-time ISS tracking** with world map and pass predictions
- **Doppler correction** - optional lat/lon input for real-time frequency shift compensation
- **Next pass countdown** - time remaining until ISS is overhead
- **Image gallery** with timestamped decoded imagery
- **TLE updates** - fetch latest ISS orbital elements
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
## HF SSTV
- **Terrestrial SSTV decoding** across HF (80m-10m), VHF (6m, 2m), and UHF (70cm) bands
- **Predefined frequency lookup** for 13 active SSTV calling frequencies
- **Auto-modulation selection** - frequency table maps to correct mode (USB, LSB, FM)
- **Image gallery** with decoded transmissions
- **Common modes supported** - PD120, PD180, Martin1, Scottie1, Robot36
## APRS
- **Amateur packet radio** position reports and telemetry via direwolf
- **Region-specific frequencies** - 144.390 MHz (North America), 144.800 MHz (Europe), and more
- **Real-time position tracking** on interactive map
- **Message and telemetry display** from APRS network
## Utility Meter Reading
- **Smart meter monitoring** via rtl_amr for electric, gas, and water meters
- **Real-time JSON output** with meter ID, consumption, and signal data
- **Multiple meter protocol support** via rtl_tcp integration
## Space Weather
- **Real-time solar indices** - Solar Flux Index (SFI), Kp index, A-index, sunspot number
- **NOAA Space Weather Scales** - Geomagnetic storms (G), solar radiation (S), radio blackouts (R)
- **HF band conditions** - Day/night propagation from HamQSL for 80m through 10m bands
- **Solar wind monitoring** - Speed, density, and IMF Bz from DSCOVR satellite
- **X-ray flux chart** - GOES X-ray data with flare class scale (A/B/C/M/X)
- **Flare probability** - 1-day and 3-day C/M/X-class flare forecasts
- **Solar imagery** - NASA SDO 193A, 304A, and magnetogram images
- **D-RAP absorption maps** - HF radio absorption at 5-30 MHz frequency bands
- **Aurora forecast** - OVATION aurora oval visualization
- **SWPC alerts** - Real-time space weather alerts and warnings
- **Active solar regions** - Current sunspot region data with location and area
- **Auto-refresh** - 5-minute polling with manual refresh option
- **No SDR required** - Data fetched from NOAA SWPC, NASA SDO, and HamQSL public APIs
## Satellite Tracking ## Satellite Tracking
- **Full-screen dashboard** - dedicated popout with polar plot and ground track - **Full-screen dashboard** - dedicated popout with polar plot and ground track
@@ -131,6 +228,52 @@ Digital Selective Calling (DSC) monitoring on the international maritime distres
- **Proximity radar** visualization - **Proximity radar** visualization
- **Device type breakdown** chart - **Device type breakdown** chart
## BT Locate (SAR Bluetooth Device Location)
Search and rescue Bluetooth device location with GPS-tagged signal trail mapping.
### Core Features
- **Target tracking** - Locate devices by MAC address, name pattern, or IRK (Identity Resolving Key)
- **RPA resolution** - Resolve BLE Resolvable Private Addresses using IRK for tracking devices with randomized addresses
- **IRK auto-detection** - Extract IRKs from paired devices on macOS and Linux
- **GPS-tagged signal trail** - Every detection is tagged with GPS coordinates for trail mapping
- **Proximity bands** - IMMEDIATE (<1m), NEAR (1-5m), FAR (>5m) with color-coded HUD
- **RSSI history chart** - Real-time signal strength sparkline for trend analysis
- **Distance estimation** - Log-distance path loss model with environment presets
- **Audio proximity alerts** - Web Audio API tones that increase in pitch as signal strengthens
- **Hand-off from Bluetooth mode** - One-click transfer of a device from BT scanner to BT Locate
### Environment Presets
- **Open Field** (n=2.0) - Free space path loss
- **Outdoor** (n=2.2) - Typical outdoor environment
- **Indoor** (n=3.0) - Indoor with walls and obstacles
### Map & Trail
- Interactive Leaflet map with GPS trail visualization
- Trail points color-coded by proximity band
- Polyline connecting detection points for path visualization
- Supports user-configured tile providers
### Requirements
- Bluetooth adapter (built-in or USB)
- GPS receiver (optional, falls back to manual coordinates)
## GPS Mode
Real-time GPS position tracking with live map visualization.
### Features
- **Live position tracking** - Real-time latitude, longitude, altitude display
- **Interactive map** - Current position on Leaflet map with track history
- **Speed and heading** - Real-time speed (km/h) and compass heading
- **Satellite info** - Number of satellites in view and fix quality
- **Track recording** - Record GPS tracks with export capability
- **Accuracy display** - Horizontal and vertical position accuracy (EPX/EPY)
### Requirements
- USB GPS receiver connected via gpsd
- gpsd daemon running (`sudo gpsd /dev/ttyUSB0 -F /var/run/gpsd.sock`)
## TSCM Counter-Surveillance Mode ## TSCM Counter-Surveillance Mode
Technical Surveillance Countermeasures (TSCM) screening for detecting wireless surveillance indicators. Technical Surveillance Countermeasures (TSCM) screening for detecting wireless surveillance indicators.
+14 -4
View File
@@ -206,14 +206,24 @@ Extended base for full-screen dashboards (maps, visualizations).
| `listening` | Listening post | | `listening` | Listening post |
| `spystations` | Spy stations | | `spystations` | Spy stations |
| `meshtastic` | Mesh networking | | `meshtastic` | Mesh networking |
| `weathersat` | Weather satellites |
| `sstv_general` | HF SSTV |
| `gps` | GPS tracking |
| `websdr` | WebSDR |
| `subghz` | Sub-GHz analyzer |
| `bt_locate` | BT Locate |
| `analytics` | Analytics dashboard |
| `spaceweather` | Space weather |
| `dmr` | DMR/P25 digital voice |
### Navigation Groups ### Navigation Groups
The navigation is organized into groups: The navigation is organized into groups:
- **SDR / RF**: Pager, 433MHz, Meters, Aircraft, Vessels, APRS, Listening Post, Spy Stations, Meshtastic - **Signals**: Pager, 433MHz, Meters, Listening Post, SubGHz
- **Wireless**: WiFi, Bluetooth - **Tracking**: Aircraft, Vessels, APRS, GPS
- **Security**: TSCM - **Space**: Satellite, ISS SSTV, Weather Sat, HF SSTV, Space Weather
- **Space**: Satellite, ISS SSTV - **Wireless**: WiFi, Bluetooth, BT Locate, Meshtastic
- **Intel**: TSCM, Analytics, Spy Stations, WebSDR
--- ---
+367 -76
View File
@@ -57,92 +57,153 @@ INTERCEPT automatically detects known trackers:
- Samsung SmartTag - Samsung SmartTag
- Chipolo - Chipolo
## Sub-GHz Analyzer
1. **Connect HackRF** - Plug in your HackRF One device
2. **Set Frequency** - Enter a frequency in the 300-928 MHz ISM range or use a preset
3. **Start Capture** - Click "Start Capture" to begin signal analysis
4. **View Spectrum** - Real-time spectrum visualization of the selected band
5. **Protocol Decoding** - Identified protocols are displayed with decoded data
### Supported Protocols
Common ISM band protocols including garage doors, key fobs, weather stations, and IoT devices in the 300-928 MHz range.
## VDL2 (Aircraft Datalink)
1. **Select Hardware** - Choose your SDR type
2. **Select Device** - Choose your SDR device
3. **Set Frequencies** - Default VDL2 frequencies are pre-configured (136.975, 136.725, 136.775 MHz etc.)
4. **Start Decoding** - Click "Start" to begin VDL2 reception via dumpvdl2
5. **View Messages** - AVLC frames appear with source/destination, signal levels, and decoded content
6. **Inspect Details** - Click a message to view full AVLC frame details and raw JSON
7. **Export** - Use CSV or JSON export buttons to save captured messages
### Tips
- VDL2 is most active near airports and along flight corridors
- Multiple frequencies can be monitored simultaneously for better coverage
- VDL2 data is also accessible from the ADS-B dashboard
## Listening Post
1. **Select Hardware** - Choose your SDR type
2. **Set Frequency Range** - Define start and end frequencies for scanning
3. **Start Scanning** - Click "Start Scan" for wideband sweep
4. **View Signals** - Discovered signals are listed with frequency and SNR
5. **Tune In** - Click a signal to tune the audio demodulator
6. **Listen** - Real-time audio plays in your browser
### Demodulation Modes
- **FM** - Narrowband and wideband FM
- **SSB** - Upper and lower sideband for amateur radio and shortwave
## Aircraft Mode (ADS-B) ## Aircraft Mode (ADS-B)
1. **Select Hardware** - Choose your SDR type (RTL-SDR uses dump1090, others use readsb) 1. **Select Hardware** - Choose your SDR type (RTL-SDR uses dump1090, others use readsb)
2. **Check Tools** - Ensure dump1090 or readsb is installed 2. **Check Tools** - Ensure dump1090 or readsb is installed
3. **Set Location** - Choose location source: 3. **Set Location** - Choose location source:
- **Manual Entry** - Type coordinates directly - **Manual Entry** - Type coordinates directly
- **Browser GPS** - Use browser's built-in geolocation (requires HTTPS) - **Browser GPS** - Use browser's built-in geolocation (requires HTTPS)
- **USB GPS Dongle** - Connect a USB GPS receiver for continuous updates - **USB GPS Dongle** - Connect a USB GPS receiver for continuous updates
- **Shared Location** - By default, the observer location is shared across modules - **Shared Location** - By default, the observer location is shared across modules
(disable with `INTERCEPT_SHARED_OBSERVER_LOCATION=false`) (disable with `INTERCEPT_SHARED_OBSERVER_LOCATION=false`)
4. **Start Tracking** - Click "Start Tracking" to begin ADS-B reception 4. **Start Tracking** - Click "Start Tracking" to begin ADS-B reception
5. **View Map** - Aircraft appear on the interactive Leaflet map 5. **View Map** - Aircraft appear on the interactive Leaflet map
6. **Click Aircraft** - Click markers for detailed information 6. **Click Aircraft** - Click markers for detailed information
7. **Display Options** - Toggle callsigns, altitude, trails, range rings, clustering 7. **Display Options** - Toggle callsigns, altitude, trails, range rings, clustering
8. **Filter Aircraft** - Use dropdown to show all, military, civil, or emergency only 8. **Filter Aircraft** - Use dropdown to show all, military, civil, or emergency only
9. **Full Dashboard** - Click "Full Screen Dashboard" for dedicated radar view 9. **Full Dashboard** - Click "Full Screen Dashboard" for dedicated radar view
> Note: ADS-B auto-start is disabled by default. To enable auto-start on dashboard load, > Note: ADS-B auto-start is disabled by default. To enable auto-start on dashboard load,
> set `INTERCEPT_ADSB_AUTO_START=true`. > set `INTERCEPT_ADSB_AUTO_START=true`.
### Emergency Squawks ### Emergency Squawks
The system highlights aircraft transmitting emergency squawks: The system highlights aircraft transmitting emergency squawks:
- **7500** - Hijack - **7500** - Hijack
- **7600** - Radio failure - **7600** - Radio failure
- **7700** - General emergency - **7700** - General emergency
## ADS-B History (Optional) ## ACARS Messaging
The history dashboard persists aircraft messages and per-aircraft snapshots to Postgres for long-running tracking and reporting. 1. **Select Hardware** - Choose your SDR type
2. **Select Device** - Choose your SDR device
### Enable History 3. **Select Region** - Choose North America, Europe, or Asia-Pacific to auto-populate frequencies
4. **Select Frequencies** - Check one or more ACARS frequencies (131.550 MHz primary worldwide, 130.025 MHz secondary USA/Canada, etc.)
Set the following environment variables (Docker recommended): 5. **Adjust Gain** - Set gain (0 for auto, or 0-50 dB)
6. **Start Decoding** - Click "Start" to begin ACARS reception via acarsdec
| Variable | Default | Description | 7. **View Messages** - Aircraft messages appear in real-time with flight ID, registration, and content
|----------|---------|-------------|
| `INTERCEPT_ADSB_HISTORY_ENABLED` | `false` | Enables history storage and reporting | ### Tips
| `INTERCEPT_ADSB_DB_HOST` | `localhost` | Postgres host (use `adsb_db` in Docker) |
| `INTERCEPT_ADSB_DB_PORT` | `5432` | Postgres port | - A vertical polarization antenna works best for ACARS
| `INTERCEPT_ADSB_DB_NAME` | `intercept_adsb` | Database name | - Quarter-wave dipole: 57 cm per element at 130 MHz
| `INTERCEPT_ADSB_DB_USER` | `intercept` | Database user | - Stock SDR antenna may work at close range near airports
| `INTERCEPT_ADSB_DB_PASSWORD` | `intercept` | Database password | - Outdoor placement with clear sky view significantly improves reception
### Other ADS-B Settings ## ADS-B History (Optional)
| Variable | Default | Description | The history dashboard persists aircraft messages and per-aircraft snapshots to Postgres for long-running tracking and reporting.
|----------|---------|-------------|
| `INTERCEPT_ADSB_AUTO_START` | `false` | Auto-start ADS-B tracking when the dashboard loads | ### Enable History
| `INTERCEPT_SHARED_OBSERVER_LOCATION` | `true` | Share observer location across ADS-B/AIS/SSTV/Satellite modules |
Set the following environment variables (Docker recommended):
**Local install example**
| Variable | Default | Description |
```bash |----------|---------|-------------|
INTERCEPT_ADSB_AUTO_START=true \ | `INTERCEPT_ADSB_HISTORY_ENABLED` | `false` | Enables history storage and reporting |
INTERCEPT_SHARED_OBSERVER_LOCATION=false \ | `INTERCEPT_ADSB_DB_HOST` | `localhost` | Postgres host (use `adsb_db` in Docker) |
python app.py | `INTERCEPT_ADSB_DB_PORT` | `5432` | Postgres port |
``` | `INTERCEPT_ADSB_DB_NAME` | `intercept_adsb` | Database name |
| `INTERCEPT_ADSB_DB_USER` | `intercept` | Database user |
**Docker example (.env)** | `INTERCEPT_ADSB_DB_PASSWORD` | `intercept` | Database password |
```bash ### Other ADS-B Settings
INTERCEPT_ADSB_AUTO_START=true
INTERCEPT_SHARED_OBSERVER_LOCATION=false | Variable | Default | Description |
``` |----------|---------|-------------|
| `INTERCEPT_ADSB_AUTO_START` | `false` | Auto-start ADS-B tracking when the dashboard loads |
### Docker Setup | `INTERCEPT_SHARED_OBSERVER_LOCATION` | `true` | Share observer location across ADS-B/AIS/SSTV/Satellite modules |
`docker-compose.yml` includes an `adsb_db` service and a persistent volume for history storage: **Local install example**
```bash ```bash
docker compose --profile history up -d INTERCEPT_ADSB_AUTO_START=true \
``` INTERCEPT_SHARED_OBSERVER_LOCATION=false \
python app.py
To store Postgres data on external storage, set `PGDATA_PATH` (defaults to `./pgdata`): ```
```bash **Docker example (.env)**
PGDATA_PATH=/mnt/usbpi1/intercept/pgdata
``` ```bash
INTERCEPT_ADSB_AUTO_START=true
### Using the History Dashboard INTERCEPT_SHARED_OBSERVER_LOCATION=false
```
1. Open **/adsb/history**
2. Use **Start Tracking** to run ADS-B in headless mode ### Docker Setup
3. View aircraft history and timelines
4. Stop tracking when desired (session history is recorded) `docker-compose.yml` includes an `adsb_db` service and a persistent volume for history storage:
```bash
docker compose --profile history up -d
```
To store Postgres data on external storage, set `PGDATA_PATH` (defaults to `./pgdata`):
```bash
PGDATA_PATH=/mnt/usbpi1/intercept/pgdata
```
### Using the History Dashboard
1. Open **/adsb/history**
2. Use **Start Tracking** to run ADS-B in headless mode
3. View aircraft history and timelines
4. Stop tracking when desired (session history is recorded)
If the History dashboard shows **HISTORY DISABLED**, enable `INTERCEPT_ADSB_HISTORY_ENABLED=true` and ensure Postgres is running.
## Satellite Mode ## Satellite Mode
@@ -163,6 +224,236 @@ PGDATA_PATH=/mnt/usbpi1/intercept/pgdata
3. Choose a category (Amateur, Weather, ISS, Starlink, etc.) 3. Choose a category (Amateur, Weather, ISS, Starlink, etc.)
4. Select satellites to add 4. Select satellites to add
## Weather Satellites
1. **Set Location** - Enter observer coordinates or use GPS
2. **Select Satellite** - Choose NOAA (APT) or Meteor (LRPT)
3. **View Passes** - Upcoming passes shown with polar plot and ground track
4. **Start Capture** - Click "Start Capture" when a satellite is overhead, or enable auto-scheduler
5. **View Images** - Decoded imagery appears in the gallery
### Auto-Scheduler
Enable the auto-scheduler to automatically capture passes:
- Calculates upcoming NOAA and Meteor passes for your location
- Starts SatDump at the correct time and frequency
- Decoded images are saved with timestamps
## Space Weather
1. **Switch to Space Weather mode** - Select "Space Weather" from the Space navigation group
2. **View Dashboard** - Solar indices, NOAA scales, band conditions, and charts load automatically
3. **Solar Imagery** - Toggle between SDO 193A, 304A, and Magnetogram views
4. **D-RAP Maps** - Select frequency (5-30 MHz) to view HF radio absorption maps
5. **Aurora Forecast** - View the OVATION aurora oval for the northern hemisphere
6. **Alerts** - Review current SWPC space weather alerts and warnings
7. **Active Regions** - View solar active region data (number, location, area)
8. **Refresh** - Data auto-refreshes every 5 minutes, or click "Refresh Now"
### Tips
- No SDR hardware required — all data comes from public APIs (NOAA SWPC, NASA SDO, HamQSL)
- Check HF band conditions before operating on shortwave frequencies
- Kp >= 5 indicates geomagnetic storm conditions that may affect HF propagation
- D-RAP maps show where HF absorption is highest — useful for path planning
- Solar imagery updates approximately every 15 minutes from NASA SDO
## AIS Vessel Tracking
1. **Select Hardware** - Choose your SDR type
2. **Start Tracking** - Click "Start Tracking" to monitor AIS frequencies (161.975/162.025 MHz)
3. **View Map** - Vessels appear on the interactive maritime map
4. **Click Vessels** - View name, MMSI, callsign, destination, speed, heading
5. **Full Dashboard** - Click "Full Screen Dashboard" for dedicated maritime view
### VHF DSC Channel 70
Digital Selective Calling monitoring runs alongside AIS:
- Distress, Urgency, Safety, and Routine messages
- Distress positions plotted with pulsing alert markers
- Audio alerts for critical messages
## WebSDR
1. **Set Frequency** - Enter a frequency in kHz (e.g., 6500 for 6.5 MHz)
2. **Select Mode** - Choose demodulation mode (USB, LSB, AM, CW)
3. **Find Receivers** - Click "Find Receivers" to discover available KiwiSDR nodes worldwide
4. **Select Receiver** - Click a receiver from the list to connect
5. **Listen** - Audio streams in real-time via WebSocket
6. **Adjust Volume** - Use the volume slider and monitor the S-meter
7. **Spy Station Presets** - Use the quick-tune buttons to jump to known number station frequencies
### Tips
- Requires an internet connection to access the KiwiSDR network
- Receiver list is cached for 1 hour to reduce API load
- Receivers are sorted by distance from your location
- Integrated spy station presets allow quick tuning to SIGINT targets
## ISS SSTV
1. **Select Hardware** - Choose your SDR type
2. **Select Device** - Choose your SDR device
3. **Set Frequency** - Default is 145.800 MHz (ISS downlink)
4. **Set Location** - Enter lat/lon for Doppler correction and pass prediction
5. **Update TLE** - Click "Update TLE" to fetch latest ISS orbital elements
6. **Wait for Pass** - The next pass countdown shows when ISS will be overhead
7. **Start Decoding** - Click "Start" to begin SSTV reception
8. **View Images** - Decoded SSTV images appear in the gallery with timestamps
### Tips
- A V-dipole or better antenna is required (stock antenna will not work)
- V-dipole construction: 51 cm per element at 145.8 MHz, 120-degree angle between elements
- ISS SSTV events occur during special anniversaries and missions — check ARISS for schedules
- Best passes have elevation > 30 degrees above horizon
- Doppler shift tracking dramatically improves reception quality
- Common SSTV modes: PD120, PD180, Martin1, Scottie1
- Outdoor antenna placement with clear sky view is essential
## HF SSTV
1. **Select Hardware** - Choose your SDR type
2. **Select Device** - Choose your SDR device
3. **Select Frequency** - Choose from 13 preset frequencies or enter a custom one
4. **Modulation** - Auto-selected based on frequency (USB for HF, FM for VHF/UHF)
5. **Start Decoding** - Click "Start" to begin SSTV reception
6. **View Images** - Decoded amateur radio images appear in the gallery
### Tips
- HF frequencies (3-30 MHz) require an upconverter with RTL-SDR
- VHF/UHF frequencies (145 MHz, 433 MHz) work directly with RTL-SDR
- Most popular frequency: 14.230 MHz USB (20m band) with regular activity
- Weekend activity peaks on most HF bands
- Amateur license is not required to receive (listen-only)
## APRS
1. **Select Hardware** - Choose your SDR type
2. **Set Frequency** - Defaults to regional APRS frequency (144.390 MHz NA, 144.800 MHz EU)
3. **Start Decoding** - Click "Start Decoding" to begin packet radio reception via direwolf
4. **View Map** - Station positions appear on the interactive map
5. **View Messages** - Position reports, telemetry, and messages displayed in real time
## Utility Meters
1. **Start Monitoring** - Click "Start" to begin meter broadcast reception via rtl_amr
2. **View Meters** - Decoded meter data appears with meter ID, type, and consumption
3. **Filter** - Filter by meter type (electric, gas, water) or meter ID
## BT Locate (SAR Device Location)
1. **Set Target** - Enter one or more target identifiers:
- **MAC Address** - Exact Bluetooth address (AA:BB:CC:DD:EE:FF)
- **Name Pattern** - Substring match (e.g., "iPhone", "Galaxy")
- **IRK** - 32-character hex Identity Resolving Key for RPA resolution
- **Detect IRKs** - Click "Detect" to auto-extract IRKs from paired devices
2. **Choose Environment** - Select the RF environment preset:
- **Open Field** (n=2.0) - Best for open areas with line-of-sight
- **Outdoor** (n=2.2) - Default, works well in most outdoor settings
- **Indoor** (n=3.0) - For buildings with walls and obstacles
3. **Start Locate** - Click "Start Locate" to begin tracking
4. **Monitor HUD** - The proximity display shows:
- Proximity band (IMMEDIATE / NEAR / FAR)
- Estimated distance in meters
- Raw RSSI and smoothed RSSI average
- Detection count and GPS-tagged points
5. **Follow the Signal** - Move towards stronger signal (higher RSSI / closer distance)
6. **Audio Alerts** - Enable audio for proximity tones that increase in pitch as you get closer
7. **Review Trail** - Check the map for GPS-tagged detection trail
### Hand-off from Bluetooth Mode
1. Open Bluetooth scanning mode and find the target device
2. Click the "Locate" button on the device card
3. BT Locate opens with the device pre-filled
4. Click "Start Locate" to begin tracking
### Tips
- For devices with address randomization (iPhones, modern Android), use the IRK method
- Click "Detect" next to the IRK field to auto-extract IRKs from paired devices
- The RSSI chart shows signal trend over time — use it to determine if you're getting closer
- Clear the trail when starting a new search area
## GPS Mode
1. **Start GPS** - Click "Start" to connect to gpsd and begin position tracking
2. **View Map** - Your position appears on the interactive map with a track trail
3. **Monitor Stats** - Speed, heading, altitude, and satellite count displayed in real-time
4. **Record Track** - Enable track recording to save your path
### Tips
- Ensure gpsd is running: `sudo gpsd /dev/ttyUSB0 -F /var/run/gpsd.sock`
- GPS fix may take 30-60 seconds after cold start
- Accuracy improves with more satellites in view
## TSCM (Counter-Surveillance)
1. **Select Sweep Type** - Choose from Quick Scan (2 min), Standard (5 min), Full Sweep (15 min), or presets for Wireless Cameras, Body-Worn Devices, or GPS Trackers
2. **Select Scan Sources** - Toggle WiFi, Bluetooth, and/or RF/SDR scanning and select the appropriate interfaces
3. **Select Baseline** - Optionally choose a previously recorded baseline to compare against
4. **Start Sweep** - Click "Start Sweep" to begin scanning
5. **Review Results** - Detected devices are classified and scored by threat level
6. **Record Baseline** - In a known clean environment, record a baseline for future comparison
7. **Export Report** - Generate PDF report, JSON annex, or CSV data
### Threat Levels
- **Informational (0-2)** - Known or expected devices
- **Needs Review (3-5)** - Unusual devices requiring assessment
- **High Interest (6+)** - Multiple indicators warrant investigation
### Tips
- Record a baseline in a known clean environment before conducting sweeps
- Use the meeting window feature to flag new RF signatures during sensitive periods
- Full functionality requires WiFi adapter, Bluetooth adapter, and SDR hardware
- Threat detection uses a database of 47K+ known tracker fingerprints
## Spy Stations
1. **Browse Database** - View the full list of documented number stations and diplomatic networks
2. **Filter by Type** - Toggle between Number Stations and Diplomatic Networks
3. **Filter by Country** - Select specific countries (Russia, Cuba, Israel, Poland, etc.)
4. **Filter by Mode** - Filter by demodulation mode (USB, AM, CW, OFDM)
5. **View Details** - Click "Details" on a station card for full information
6. **Tune In** - Click "Tune In" to route the station frequency to the Listening Post or WebSDR
### Tips
- Data sourced from priyom.org (non-profit monitoring community)
- Most activity is on HF bands (3-30 MHz) — propagation varies by time of day
- Notable stations: UVB-76 "The Buzzer" (4625 kHz), E06 English Man, HM01 Cuban Numbers
- Legal to monitor in most countries (check local regulations)
- No decryption or content decoding is included — this is a reference database
## Meshtastic
1. **Connect Device** - Plug in a Meshtastic device via USB or connect via TCP
2. **Start** - Click "Start" to connect to the mesh network
3. **View Messages** - Real-time message stream from the mesh
4. **View Nodes** - Connected nodes displayed with signal metrics (RSSI, SNR)
5. **Send Messages** - Type messages to broadcast on the mesh
## Offline Mode
1. **Open Settings** - Click the gear icon in the navigation bar
2. **Offline Tab** - Toggle "Offline Mode" to enable local assets
3. **Configure Sources** - Switch assets and fonts from CDN to local
4. **Set Tile Provider** - Choose a map tile provider or enter a custom tile server URL
5. **Check Assets** - Click "Check Assets" to verify all local files are present
### Tips
- Download required assets: Leaflet JS/CSS, Chart.js, Inter and JetBrains Mono fonts
- Assets are stored in the `static/vendor/` directory
- For maps, you need a local tile server (e.g., self-hosted OpenStreetMap tiles)
- Missing assets fail gracefully with console warnings
- Useful for air-gapped environments, field deployments, or reducing latency
## Remote Agents (Distributed SIGINT) ## Remote Agents (Distributed SIGINT)
Deploy lightweight sensor nodes across multiple locations and aggregate data to a central controller. Deploy lightweight sensor nodes across multiple locations and aggregate data to a central controller.
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 694 KiB

After

Width:  |  Height:  |  Size: 790 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 853 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 570 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 876 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 455 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 886 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

+548 -91
View File
@@ -11,6 +11,7 @@
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
</head> </head>
<body> <body>
<canvas id="bg-canvas"></canvas>
<nav class="navbar"> <nav class="navbar">
<div class="nav-container"> <div class="nav-container">
<a href="#" class="nav-logo">iNTERCEPT</a> <a href="#" class="nav-logo">iNTERCEPT</a>
@@ -35,7 +36,7 @@
</div> </div>
<div class="hero-stats"> <div class="hero-stats">
<div class="stat"> <div class="stat">
<span class="stat-value">15+</span> <span class="stat-value">25+</span>
<span class="stat-label">Modes</span> <span class="stat-label">Modes</span>
</div> </div>
<div class="stat"> <div class="stat">
@@ -58,97 +59,149 @@
<h2>Capabilities</h2> <h2>Capabilities</h2>
<p class="section-subtitle">Everything you need for signal intelligence in one interface</p> <p class="section-subtitle">Everything you need for signal intelligence in one interface</p>
<div class="features-grid"> <div class="carousel-filters">
<div class="feature-card"> <button class="filter-btn active" data-filter="all">All</button>
<div class="feature-icon">📟</div> <button class="filter-btn" data-filter="signals">Signals</button>
<h3>Pager Decoding</h3> <button class="filter-btn" data-filter="tracking">Tracking</button>
<p>Decode POCSAG and FLEX pager messages using rtl_fm and multimon-ng. Monitor emergency services and legacy paging systems.</p> <button class="filter-btn" data-filter="space">Space</button>
</div> <button class="filter-btn" data-filter="wireless">Wireless</button>
<button class="filter-btn" data-filter="intel">Intel</button>
<div class="feature-card"> <button class="filter-btn" data-filter="platform">Platform</button>
<div class="feature-icon">✈️</div>
<h3>Aircraft Tracking</h3>
<p>Real-time ADS-B tracking with interactive maps, aircraft photos, emergency squawk detection, and range visualization.</p>
</div>
<div class="feature-card">
<div class="feature-icon">📡</div>
<h3>433MHz Sensors</h3>
<p>Decode 200+ protocols including weather stations, TPMS, smart home devices, and IoT sensors via rtl_433.</p>
</div>
<div class="feature-card">
<div class="feature-icon">📻</div>
<h3>Listening Post</h3>
<p>Frequency scanner with real-time audio monitoring, fine-tuning controls, and customizable frequency presets.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🛰️</div>
<h3>Satellite Tracking</h3>
<p>Track satellites with TLE data, sky plots, ground track visualization, and pass predictions for your location.</p>
</div>
<div class="feature-card">
<div class="feature-icon">📶</div>
<h3>WiFi Scanning</h3>
<p>Monitor mode reconnaissance via aircrack-ng. Network discovery, client tracking, and handshake capture.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🔵</div>
<h3>Bluetooth Scanning</h3>
<p>Device discovery with tracker detection for AirTags, Tile, Samsung SmartTag, and other Bluetooth devices.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🛡️</div>
<h3>TSCM</h3>
<p>Counter-surveillance with baseline recording, threat detection, device correlation, and risk scoring.</p>
</div>
<div class="feature-card">
<div class="feature-icon"></div>
<h3>Meter Reading</h3>
<p>Intercept smart utility meters via rtl_amr. Monitor electricity, gas, and water meter transmissions.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🚢</div>
<h3>Vessel Tracking</h3>
<p>Real-time AIS ship tracking via AIS-catcher. Monitor maritime traffic with vessel details, course, speed, and destination.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🔢</div>
<h3>Spy Stations</h3>
<p>Number stations and diplomatic HF network database. Frequencies, schedules, and background info from priyom.org.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🌐</div>
<h3>Remote Agents</h3>
<p>Distributed signal intelligence with remote sensor nodes. Deploy agents across multiple locations and aggregate data to a central controller.</p>
</div>
<div class="feature-card">
<div class="feature-icon">📴</div>
<h3>Offline Mode</h3>
<p>Run without internet using bundled assets. Choose from multiple map tile providers or use your own local tile server.</p>
</div>
<div class="feature-card">
<div class="feature-icon">📡</div>
<h3>Meshtastic</h3>
<p>LoRa mesh network integration. Connect to Meshtastic devices for decentralized, long-range communication monitoring.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🖼️</div>
<h3>ISS SSTV</h3>
<p>Receive Slow Scan Television from the ISS. Real-time tracking globe, pass predictions, and image decoding.</p>
</div>
</div> </div>
<div class="carousel-wrapper">
<button class="carousel-arrow carousel-arrow-left" aria-label="Scroll left">&#8249;</button>
<div class="carousel-track">
<div class="feature-card" data-category="signals">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="6" y1="9" x2="6" y2="15"/><line x1="10" y1="9" x2="10" y2="15"/><line x1="14" y1="11" x2="18" y2="11"/><line x1="14" y1="13" x2="18" y2="13"/></svg></div>
<h3>Pager Decoding</h3>
<p>Decode POCSAG and FLEX pager messages using rtl_fm and multimon-ng. Monitor emergency services and legacy paging systems.</p>
</div>
<div class="feature-card" data-category="signals">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v4"/><path d="M12 18v4"/><circle cx="12" cy="12" r="4"/><path d="M4.93 4.93l2.83 2.83"/><path d="M16.24 16.24l2.83 2.83"/><path d="M2 12h4"/><path d="M18 12h4"/><path d="M4.93 19.07l2.83-2.83"/><path d="M16.24 7.76l2.83-2.83"/></svg></div>
<h3>433MHz Sensors</h3>
<p>Decode 200+ protocols including weather stations, TPMS, smart home devices, and IoT sensors via rtl_433.</p>
</div>
<div class="feature-card" data-category="signals">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h4l3-9 6 18 3-9h4"/></svg></div>
<h3>Sub-GHz Analyzer</h3>
<p>HackRF-based signal capture and protocol decoding for 300-928 MHz ISM bands with spectrum analysis and replay.</p>
</div>
<div class="feature-card" data-category="signals">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 18.5a6.5 6.5 0 1 1 0-13"/><path d="M17 12h5"/><path d="M12 7V2"/><circle cx="12" cy="12" r="2"/><path d="M8.5 8.5L5 5"/></svg></div>
<h3>Listening Post</h3>
<p>Frequency scanner with real-time audio monitoring, fine-tuning controls, and customizable frequency presets.</p>
</div>
<div class="feature-card" data-category="intel">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></div>
<h3>WebSDR</h3>
<p>Remote HF/shortwave listening via the KiwiSDR network. Access receivers worldwide with real-time audio streaming.</p>
</div>
<div class="feature-card" data-category="intel">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><path d="M8 8h2v2H8z"/><path d="M14 8h2v2h-2z"/><path d="M8 14h2v2H8z"/><path d="M14 14h2v2h-2z"/><path d="M11 8h2v2h-2z"/><path d="M11 11h2v2h-2z"/></svg></div>
<h3>Spy Stations</h3>
<p>Number stations and diplomatic HF network database. Frequencies, schedules, and background info from priyom.org.</p>
</div>
<div class="feature-card" data-category="tracking">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg></div>
<h3>APRS</h3>
<p>Amateur packet radio position reports and telemetry via direwolf. Track amateur radio operators on an interactive map.</p>
</div>
<div class="feature-card" data-category="signals">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg></div>
<h3>Utility Meters</h3>
<p>Smart meter monitoring via rtl_amr. Receive electric, gas, and water meter broadcasts in real time.</p>
</div>
<div class="feature-card" data-category="tracking">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17.8 19.2L16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z"/></svg></div>
<h3>Aircraft Tracking</h3>
<p>Real-time ADS-B tracking with interactive maps, aircraft photos, emergency squawk detection, and range visualization.</p>
</div>
<div class="feature-card" data-category="tracking">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 8h10"/><path d="M7 12h6"/><path d="M7 16h8"/></svg></div>
<h3>ACARS</h3>
<p>Aircraft datalink messages via acarsdec. Decode operational, weather, and position reports from commercial flights.</p>
</div>
<div class="feature-card" data-category="tracking">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="M5 12h14"/><circle cx="12" cy="12" r="9"/><path d="M3.5 9h17"/><path d="M3.5 15h17"/></svg></div>
<h3>VDL2</h3>
<p>VHF Data Link Mode 2 aircraft datalink decoding via dumpvdl2. Real-time ACARS-over-AVLC message capture with signal analysis.</p>
</div>
<div class="feature-card" data-category="tracking">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 20l4-4h3l4-7 2 4h2l5-9"/><path d="M22 20H2"/><path d="M6 16v4"/></svg></div>
<h3>Vessel Tracking</h3>
<p>Real-time AIS ship tracking via AIS-catcher. Monitor maritime traffic with vessel details, course, speed, and destination.</p>
</div>
<div class="feature-card" data-category="space">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 1v4"/><path d="M12 19v4"/><path d="M5 5l2 2"/><path d="M17 17l2 2"/><path d="M1 12h4"/><path d="M19 12h4"/><path d="M5 19l2-2"/><path d="M17 7l2-2"/><ellipse cx="12" cy="12" rx="10" ry="4" transform="rotate(45 12 12)"/></svg></div>
<h3>Satellite Tracking</h3>
<p>Track satellites with TLE data, sky plots, ground track visualization, and pass predictions for your location.</p>
</div>
<div class="feature-card" data-category="space">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/><circle cx="12" cy="12" r="4"/><path d="M16 12a4 4 0 0 0-4-4"/></svg></div>
<h3>Weather Satellites</h3>
<p>NOAA APT and Meteor LRPT image decoding via SatDump with auto-scheduler, polar plot, and ground track map.</p>
</div>
<div class="feature-card" data-category="space">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg></div>
<h3>ISS SSTV</h3>
<p>Receive Slow Scan Television from the ISS. Real-time tracking globe, pass predictions, and image decoding.</p>
</div>
<div class="feature-card" data-category="space">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 3v18"/></svg></div>
<h3>HF SSTV</h3>
<p>Terrestrial SSTV on shortwave frequencies. Decode amateur radio image transmissions across HF, VHF, and UHF bands.</p>
</div>
<div class="feature-card" data-category="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="space">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg></div>
<h3>Space Weather</h3>
<p>Real-time solar and geomagnetic monitoring. Kp index, HF band conditions, solar imagery, D-RAP maps, and aurora forecasts from NOAA SWPC.</p>
</div>
<div class="feature-card" data-category="wireless">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1"/></svg></div>
<h3>WiFi Scanning</h3>
<p>Monitor mode reconnaissance via aircrack-ng. Network discovery, client tracking, and handshake capture.</p>
</div>
<div class="feature-card" data-category="wireless">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="6"/><path d="M12 16v5"/><path d="M8 21h8"/><path d="M9.5 7.5L12 10l2.5-2.5"/></svg></div>
<h3>Bluetooth Scanning</h3>
<p>Device discovery with tracker detection for AirTags, Tile, Samsung SmartTag, and other Bluetooth devices.</p>
</div>
<div class="feature-card" data-category="wireless">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><circle cx="12" cy="12" r="7" stroke-dasharray="4 2"/><circle cx="12" cy="12" r="11" stroke-dasharray="2 3"/><line x1="12" y1="1" x2="12" y2="3"/></svg></div>
<h3>BT Locate</h3>
<p>SAR Bluetooth device location with GPS-tagged signal trail mapping, IRK-based RPA resolution, and proximity audio alerts.</p>
</div>
<div class="feature-card" data-category="intel">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s-8-4.5-8-11.8A8 8 0 0 1 12 2a8 8 0 0 1 8 8.2c0 7.3-8 11.8-8 11.8z"/><circle cx="12" cy="10" r="3"/><path d="M12 2v3"/><path d="M4.93 4.93l2.12 2.12"/><path d="M20 12h-3"/></svg></div>
<h3>TSCM</h3>
<p>Counter-surveillance with baseline recording, threat detection, device correlation, and risk scoring.</p>
</div>
<div class="feature-card" data-category="wireless">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg></div>
<h3>Meshtastic</h3>
<p>LoRa mesh network integration. Connect to Meshtastic devices for decentralized, long-range communication monitoring.</p>
</div>
<div class="feature-card" data-category="platform">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/><circle cx="9" cy="10" r="1.5"/><circle cx="15" cy="10" r="1.5"/><path d="M5 10h2"/><path d="M17 10h2"/></svg></div>
<h3>Remote Agents</h3>
<p>Distributed signal intelligence with remote sensor nodes. Deploy agents across multiple locations and aggregate data to a central controller.</p>
</div>
<div class="feature-card" data-category="platform">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18.36 6.64A9 9 0 0 1 20.77 15"/><path d="M6.16 6.16a9 9 0 0 0-2.57 8.84"/><path d="M12 2v4"/><path d="M2 12h4"/><line x1="2" y1="2" x2="22" y2="22"/><circle cx="12" cy="12" r="3"/></svg></div>
<h3>Offline Mode</h3>
<p>Run without internet using bundled assets. Choose from multiple map tile providers or use your own local tile server.</p>
</div>
</div>
<button class="carousel-arrow carousel-arrow-right" aria-label="Scroll right">&#8250;</button>
</div>
<div class="carousel-indicators" id="carousel-indicators"></div>
</div> </div>
</section> </section>
@@ -194,6 +247,50 @@
<img src="images/ais.png" alt="AIS Vessel Tracking"> <img src="images/ais.png" alt="AIS Vessel Tracking">
<span class="screenshot-label">AIS Vessel Tracking</span> <span class="screenshot-label">AIS Vessel Tracking</span>
</div> </div>
<div class="screenshot-item">
<img src="images/bt-locate.png" alt="BT Locate SAR Tracker">
<span class="screenshot-label">BT Locate — SAR Tracker</span>
</div>
<div class="screenshot-item">
<img src="images/spy-stations.png" alt="Spy Stations Database">
<span class="screenshot-label">Spy Stations</span>
</div>
<div class="screenshot-item">
<img src="images/gps.png" alt="GPS Receiver">
<span class="screenshot-label">GPS Receiver</span>
</div>
<div class="screenshot-item">
<img src="images/websdr.png" alt="WebSDR Remote Listening">
<span class="screenshot-label">WebSDR</span>
</div>
<div class="screenshot-item">
<img src="images/aprs.png" alt="APRS Tracker">
<span class="screenshot-label">APRS Tracker</span>
</div>
<div class="screenshot-item">
<img src="images/vdl2.png" alt="VDL2 Aircraft Datalink">
<span class="screenshot-label">VDL2 Aircraft Datalink</span>
</div>
<div class="screenshot-item">
<img src="images/weather-satellite.png" alt="Weather Satellite Decoder">
<span class="screenshot-label">Weather Satellite</span>
</div>
<div class="screenshot-item">
<img src="images/space-weather-1.png" alt="Space Weather Dashboard">
<span class="screenshot-label">Space Weather</span>
</div>
<div class="screenshot-item">
<img src="images/space-weather-2.png" alt="Space Weather Solar Imagery">
<span class="screenshot-label">Space Weather — Solar &amp; Aurora</span>
</div>
<div class="screenshot-item">
<img src="images/satellite-tracker.png" alt="Satellite Tracker">
<span class="screenshot-label">Satellite Tracker</span>
</div>
<div class="screenshot-item">
<img src="images/iss-sstv.png" alt="ISS SSTV Decoder">
<span class="screenshot-label">ISS SSTV</span>
</div>
</div> </div>
</div> </div>
</section> </section>
@@ -278,6 +375,36 @@ docker compose up -d</code></pre>
</div> </div>
</section> </section>
<section class="support">
<div class="container">
<h2>Support & Contact</h2>
<p class="section-subtitle">Help keep iNTERCEPT alive or get in touch</p>
<div class="support-grid">
<a href="https://www.buymeacoffee.com/smittix" target="_blank" class="support-card support-coffee">
<div class="support-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 8h1a4 4 0 0 1 0 8h-1"/><path d="M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V8z"/><line x1="6" y1="2" x2="6" y2="4"/><line x1="10" y1="2" x2="10" y2="4"/><line x1="14" y1="2" x2="14" y2="4"/></svg></div>
<h3>Buy Me a Coffee</h3>
<p>Support development with a one-time donation</p>
</a>
<a href="#" id="email-card" class="support-card" onclick="return false;">
<div class="support-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M22 4L12 13 2 4"/></svg></div>
<h3>Email</h3>
<p id="email-text">Click to reveal</p>
</a>
<a href="https://discord.gg/EyeksEJmWE" target="_blank" class="support-card">
<div class="support-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><circle cx="12" cy="12" r="10"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></div>
<h3>Discord</h3>
<p>Join the community for help and discussion</p>
</a>
<a href="https://github.com/smittix/intercept/issues" target="_blank" class="support-card">
<div class="support-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></div>
<h3>Report an Issue</h3>
<p>Bug reports and feature requests on GitHub</p>
</a>
</div>
</div>
</section>
<footer class="footer"> <footer class="footer">
<div class="container"> <div class="container">
<div class="footer-content"> <div class="footer-content">
@@ -288,6 +415,8 @@ docker compose up -d</code></pre>
<div class="footer-links"> <div class="footer-links">
<a href="https://github.com/smittix/intercept" target="_blank">GitHub</a> <a href="https://github.com/smittix/intercept" target="_blank">GitHub</a>
<a href="https://discord.gg/EyeksEJmWE" target="_blank">Discord</a> <a href="https://discord.gg/EyeksEJmWE" target="_blank">Discord</a>
<a href="#" id="footer-email">Email</a>
<a href="https://www.buymeacoffee.com/smittix" target="_blank">Donate</a>
<a href="https://github.com/smittix/intercept/blob/main/docs/USAGE.md">Documentation</a> <a href="https://github.com/smittix/intercept/blob/main/docs/USAGE.md">Documentation</a>
<a href="https://github.com/smittix/intercept/blob/main/docs/DISTRIBUTED_AGENTS.md">Remote Agents</a> <a href="https://github.com/smittix/intercept/blob/main/docs/DISTRIBUTED_AGENTS.md">Remote Agents</a>
</div> </div>
@@ -336,6 +465,334 @@ docker compose up -d</code></pre>
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeLightbox(); if (e.key === 'Escape') closeLightbox();
}); });
// Carousel functionality
(function() {
const track = document.querySelector('.carousel-track');
const cards = Array.from(track.querySelectorAll('.feature-card'));
const leftArrow = document.querySelector('.carousel-arrow-left');
const rightArrow = document.querySelector('.carousel-arrow-right');
const filterBtns = document.querySelectorAll('.filter-btn');
const indicatorContainer = document.getElementById('carousel-indicators');
const SCROLL_AMOUNT = 300;
function updateArrows() {
leftArrow.disabled = track.scrollLeft <= 0;
rightArrow.disabled = track.scrollLeft + track.clientWidth >= track.scrollWidth - 2;
}
function buildIndicators() {
const visible = cards.filter(c => !c.classList.contains('hidden'));
const totalWidth = visible.length * 300;
const pages = Math.max(1, Math.ceil(totalWidth / track.clientWidth));
indicatorContainer.innerHTML = '';
for (let i = 0; i < pages; i++) {
const dot = document.createElement('button');
dot.className = 'carousel-dot' + (i === 0 ? ' active' : '');
dot.addEventListener('click', () => {
track.scrollTo({ left: (track.scrollWidth / pages) * i, behavior: 'smooth' });
});
indicatorContainer.appendChild(dot);
}
}
function updateIndicators() {
const dots = indicatorContainer.querySelectorAll('.carousel-dot');
if (!dots.length) return;
const ratio = track.scrollLeft / Math.max(1, track.scrollWidth - track.clientWidth);
const idx = Math.round(ratio * (dots.length - 1));
dots.forEach((d, i) => d.classList.toggle('active', i === idx));
}
leftArrow.addEventListener('click', () => {
track.scrollBy({ left: -SCROLL_AMOUNT, behavior: 'smooth' });
});
rightArrow.addEventListener('click', () => {
track.scrollBy({ left: SCROLL_AMOUNT, behavior: 'smooth' });
});
track.addEventListener('scroll', () => {
updateArrows();
updateIndicators();
});
filterBtns.forEach(btn => {
btn.addEventListener('click', () => {
filterBtns.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const filter = btn.dataset.filter;
cards.forEach(card => {
if (filter === 'all' || card.dataset.category === filter) {
card.classList.remove('hidden');
} else {
card.classList.add('hidden');
}
});
track.scrollTo({ left: 0 });
buildIndicators();
updateArrows();
});
});
buildIndicators();
updateArrows();
window.addEventListener('resize', () => { buildIndicators(); updateArrows(); });
})();
// Obfuscated email - assembled at runtime to defeat scrapers
(function() {
const p = ['smittix', 'outlook', 'com'];
const addr = p[0] + '@' + p[1] + '.' + p[2];
const card = document.getElementById('email-card');
const text = document.getElementById('email-text');
const footerLink = document.getElementById('footer-email');
let revealed = false;
card.addEventListener('click', function(e) {
e.preventDefault();
if (!revealed) {
text.textContent = addr;
revealed = true;
} else {
window.location.href = 'mail' + 'to:' + addr;
}
});
footerLink.addEventListener('click', function(e) {
e.preventDefault();
window.location.href = 'mail' + 'to:' + addr;
});
})();
</script>
<script>
// Animated satellite & signal background
(function() {
const canvas = document.getElementById('bg-canvas');
const ctx = canvas.getContext('2d');
let w, h, dpr;
let orbits = [];
let pulses = [];
let particles = [];
let mouse = { x: -1000, y: -1000 };
function resize() {
dpr = Math.min(window.devicePixelRatio || 1, 2);
w = window.innerWidth;
h = document.documentElement.scrollHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
canvas.style.width = w + 'px';
canvas.style.height = h + 'px';
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// Orbital paths with satellites
function createOrbits() {
orbits = [];
const count = Math.max(4, Math.floor(w / 300));
for (let i = 0; i < count; i++) {
const cx = Math.random() * w;
const cy = Math.random() * h;
const rx = 120 + Math.random() * 280;
const ry = 40 + Math.random() * 100;
const tilt = (Math.random() - 0.5) * 1.2;
const speed = (0.0002 + Math.random() * 0.0004) * (Math.random() > 0.5 ? 1 : -1);
const sats = [];
const satCount = 1 + Math.floor(Math.random() * 2);
for (let j = 0; j < satCount; j++) {
sats.push({ angle: Math.random() * Math.PI * 2, pulseTimer: 0 });
}
orbits.push({ cx, cy, rx, ry, tilt, speed, sats });
}
}
// Floating signal particles (tiny dots drifting upward)
function createParticles() {
particles = [];
const count = Math.max(30, Math.floor((w * h) / 25000));
for (let i = 0; i < count; i++) {
particles.push({
x: Math.random() * w,
y: Math.random() * h,
vy: -(0.08 + Math.random() * 0.15),
vx: (Math.random() - 0.5) * 0.1,
size: 0.5 + Math.random() * 1.2,
alpha: 0.1 + Math.random() * 0.25,
flicker: Math.random() * Math.PI * 2,
});
}
}
function spawnPulse(x, y) {
pulses.push({ x, y, r: 2, maxR: 50 + Math.random() * 40, alpha: 0.35 });
}
function drawOrbitPath(orbit) {
ctx.save();
ctx.translate(orbit.cx, orbit.cy);
ctx.rotate(orbit.tilt);
ctx.beginPath();
ctx.ellipse(0, 0, orbit.rx, orbit.ry, 0, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(0, 212, 170, 0.04)';
ctx.lineWidth = 1;
ctx.stroke();
ctx.restore();
}
function drawSatellite(orbit, sat, dt) {
sat.angle += orbit.speed * dt;
const cos = Math.cos(orbit.tilt);
const sin = Math.sin(orbit.tilt);
const ex = orbit.rx * Math.cos(sat.angle);
const ey = orbit.ry * Math.sin(sat.angle);
const sx = orbit.cx + ex * cos - ey * sin;
const sy = orbit.cy + ex * sin + ey * cos;
// Satellite dot
ctx.beginPath();
ctx.arc(sx, sy, 2, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(0, 212, 170, 0.7)';
ctx.fill();
// Faint glow
ctx.beginPath();
ctx.arc(sx, sy, 6, 0, Math.PI * 2);
const g = ctx.createRadialGradient(sx, sy, 0, sx, sy, 6);
g.addColorStop(0, 'rgba(0, 212, 170, 0.15)');
g.addColorStop(1, 'rgba(0, 212, 170, 0)');
ctx.fillStyle = g;
ctx.fill();
// Periodic signal pulse
sat.pulseTimer += dt;
if (sat.pulseTimer > 3000 + Math.random() * 500) {
sat.pulseTimer = 0;
spawnPulse(sx, sy);
}
}
function drawPulses(dt) {
for (let i = pulses.length - 1; i >= 0; i--) {
const p = pulses[i];
p.r += dt * 0.025;
p.alpha = 0.35 * (1 - p.r / p.maxR);
if (p.r >= p.maxR) { pulses.splice(i, 1); continue; }
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.strokeStyle = `rgba(0, 212, 170, ${p.alpha})`;
ctx.lineWidth = 1;
ctx.stroke();
// Second ring
if (p.r > 12) {
ctx.beginPath();
ctx.arc(p.x, p.y, p.r * 0.6, 0, Math.PI * 2);
ctx.strokeStyle = `rgba(0, 136, 255, ${p.alpha * 0.5})`;
ctx.stroke();
}
}
}
function drawParticles(dt, time) {
for (const p of particles) {
p.y += p.vy * dt * 0.06;
p.x += p.vx * dt * 0.06;
p.flicker += dt * 0.002;
if (p.y < -10) { p.y = h + 10; p.x = Math.random() * w; }
if (p.x < -10) p.x = w + 10;
if (p.x > w + 10) p.x = -10;
const flick = p.alpha * (0.6 + 0.4 * Math.sin(p.flicker));
// Mouse interaction - subtle brighten
const dx = p.x - mouse.x;
const dy = p.y - mouse.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const boost = dist < 150 ? 0.3 * (1 - dist / 150) : 0;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fillStyle = `rgba(0, 212, 170, ${Math.min(flick + boost, 0.6)})`;
ctx.fill();
}
}
// Faint grid lines (signal grid)
function drawGrid(time) {
ctx.strokeStyle = 'rgba(0, 212, 170, 0.015)';
ctx.lineWidth = 1;
const spacing = 120;
const offset = (time * 0.005) % spacing;
for (let x = -spacing + offset; x < w + spacing; x += spacing) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, h);
ctx.stroke();
}
for (let y = -spacing + offset * 0.7; y < h + spacing; y += spacing) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(w, y);
ctx.stroke();
}
}
let last = 0;
function animate(now) {
const dt = last ? Math.min(now - last, 50) : 16;
last = now;
ctx.clearRect(0, 0, w, h);
drawGrid(now);
for (const orbit of orbits) {
drawOrbitPath(orbit);
for (const sat of orbit.sats) {
drawSatellite(orbit, sat, dt);
}
}
drawPulses(dt);
drawParticles(dt, now);
requestAnimationFrame(animate);
}
// Track mouse for particle interaction
document.addEventListener('mousemove', (e) => {
mouse.x = e.clientX;
mouse.y = e.clientY + window.scrollY;
});
// Resize handling
let resizeTimer;
function handleResize() {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
resize();
createOrbits();
createParticles();
}, 200);
}
// Keep canvas height synced with document
const ro = new ResizeObserver(() => { handleResize(); });
ro.observe(document.documentElement);
window.addEventListener('resize', handleResize);
resize();
createOrbits();
createParticles();
requestAnimationFrame(animate);
})();
</script> </script>
</body> </body>
</html> </html>
+258 -9
View File
@@ -17,6 +17,22 @@
--gradient-end: #0088ff; --gradient-end: #0088ff;
} }
/* Animated background canvas */
#bg-canvas {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
pointer-events: none;
}
body > *:not(#bg-canvas) {
position: relative;
z-index: 1;
}
* { * {
margin: 0; margin: 0;
padding: 0; padding: 0;
@@ -245,18 +261,74 @@ section h2 {
background: var(--bg-secondary); background: var(--bg-secondary);
} }
.features-grid { /* Category filter tabs */
display: grid; .carousel-filters {
grid-template-columns: repeat(4, 1fr); display: flex;
gap: 24px; justify-content: center;
gap: 8px;
margin-bottom: 40px;
flex-wrap: wrap;
}
.filter-btn {
font-family: 'JetBrains Mono', monospace;
font-size: 0.8rem;
font-weight: 500;
padding: 8px 20px;
border-radius: 20px;
border: 1px solid var(--border);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.25s;
letter-spacing: 0.5px;
}
.filter-btn:hover {
border-color: var(--accent);
color: var(--text-primary);
}
.filter-btn.active {
background: var(--accent);
color: var(--bg-primary);
border-color: var(--accent);
}
/* Carousel */
.carousel-wrapper {
position: relative;
padding: 0 56px;
}
.carousel-track {
display: flex;
gap: 20px;
overflow-x: auto;
scroll-behavior: smooth;
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
padding: 8px 0 16px;
}
.carousel-track::-webkit-scrollbar {
display: none;
} }
.feature-card { .feature-card {
flex: 0 0 280px;
scroll-snap-align: start;
background: var(--bg-card); background: var(--bg-card);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 12px; border-radius: 12px;
padding: 32px 24px; padding: 32px 24px;
transition: all 0.3s; transition: all 0.3s;
min-height: 200px;
}
.feature-card.hidden {
display: none;
} }
.feature-card:hover { .feature-card:hover {
@@ -266,8 +338,15 @@ section h2 {
} }
.feature-icon { .feature-icon {
font-size: 2rem; width: 36px;
height: 36px;
margin-bottom: 16px; margin-bottom: 16px;
color: var(--accent);
}
.feature-icon svg {
width: 100%;
height: 100%;
} }
.feature-card h3 { .feature-card h3 {
@@ -283,6 +362,81 @@ section h2 {
line-height: 1.7; line-height: 1.7;
} }
/* Carousel arrows */
.carousel-arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 44px;
height: 44px;
border-radius: 50%;
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--text-primary);
font-size: 1.5rem;
cursor: pointer;
transition: all 0.25s;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
line-height: 1;
}
.carousel-arrow:hover {
background: var(--bg-card-hover);
border-color: var(--accent);
color: var(--accent);
}
.carousel-arrow:disabled {
opacity: 0.3;
cursor: default;
}
.carousel-arrow:disabled:hover {
background: var(--bg-card);
border-color: var(--border);
color: var(--text-primary);
}
.carousel-arrow-left {
left: 0;
}
.carousel-arrow-right {
right: 0;
}
/* Carousel indicators */
.carousel-indicators {
display: flex;
justify-content: center;
gap: 8px;
margin-top: 28px;
}
.carousel-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--border);
border: none;
cursor: pointer;
transition: all 0.25s;
padding: 0;
}
.carousel-dot.active {
background: var(--accent);
width: 24px;
border-radius: 4px;
}
.carousel-dot:hover {
background: var(--text-muted);
}
/* Screenshots */ /* Screenshots */
.screenshot-gallery { .screenshot-gallery {
display: grid; display: grid;
@@ -550,6 +704,72 @@ section h2 {
gap: 16px; gap: 16px;
} }
/* Support & Contact */
.support {
background: var(--bg-secondary);
}
.support-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 24px;
}
.support-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 32px 24px;
text-align: center;
text-decoration: none;
transition: all 0.3s;
display: block;
}
.support-card:hover {
background: var(--bg-card-hover);
border-color: var(--accent);
transform: translateY(-4px);
}
.support-card.support-coffee {
border-color: rgba(255, 193, 59, 0.3);
}
.support-card.support-coffee:hover {
border-color: #ffc13b;
box-shadow: 0 8px 30px rgba(255, 193, 59, 0.1);
}
.support-card.support-coffee .support-icon {
color: #ffc13b;
}
.support-icon {
width: 36px;
height: 36px;
margin: 0 auto 16px;
color: var(--accent);
}
.support-icon svg {
width: 100%;
height: 100%;
}
.support-card h3 {
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 8px;
color: var(--text-primary);
}
.support-card p {
font-size: 0.85rem;
color: var(--text-secondary);
line-height: 1.6;
}
/* Footer */ /* Footer */
.footer { .footer {
background: var(--bg-secondary); background: var(--bg-secondary);
@@ -641,14 +861,22 @@ section h2 {
margin: 0 auto; margin: 0 auto;
} }
.features-grid { .carousel-wrapper {
grid-template-columns: repeat(2, 1fr); padding: 0 48px;
}
.feature-card {
flex: 0 0 260px;
} }
.screenshot-gallery { .screenshot-gallery {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
.support-grid {
grid-template-columns: repeat(2, 1fr);
}
.install-options { .install-options {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -669,14 +897,35 @@ section h2 {
gap: 24px; gap: 24px;
} }
.features-grid { .carousel-wrapper {
grid-template-columns: 1fr; padding: 0 4px;
}
.carousel-arrow {
display: none;
}
.feature-card {
flex: 0 0 260px;
}
.carousel-filters {
gap: 6px;
}
.filter-btn {
font-size: 0.7rem;
padding: 6px 14px;
} }
.screenshot-gallery { .screenshot-gallery {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.support-grid {
grid-template-columns: 1fr;
}
.nav-links { .nav-links {
display: none; display: none;
} }
+285 -310
View File
@@ -673,14 +673,13 @@ class ModeManager:
def get_status(self) -> dict: def get_status(self) -> dict:
"""Get overall agent status.""" """Get overall agent status."""
# Build running modes with device info for multi-SDR tracking # Build running modes with device info for multi-SDR tracking
running_modes_detail = {} running_modes_detail = {}
for mode, info in self.running_modes.items(): for mode, info in self.running_modes.items():
params = info.get('params', {}) params = info.get('params', {})
running_modes_detail[mode] = { running_modes_detail[mode] = {
'started_at': info.get('started_at'), 'started_at': info.get('started_at'),
'device': params.get('device', params.get('device_index', 0)), 'device': params.get('device', params.get('device_index', 0)),
'sdr_type': str(params.get('sdr_type', 'rtlsdr')).lower(), }
}
status = { status = {
'running_modes': list(self.running_modes.keys()), 'running_modes': list(self.running_modes.keys()),
@@ -699,24 +698,22 @@ class ModeManager:
# Modes that use RTL-SDR devices # Modes that use RTL-SDR devices
SDR_MODES = {'adsb', 'sensor', 'pager', 'ais', 'acars', 'dsc', 'rtlamr', 'listening_post'} SDR_MODES = {'adsb', 'sensor', 'pager', 'ais', 'acars', 'dsc', 'rtlamr', 'listening_post'}
def get_sdr_in_use(self, device: int = 0, sdr_type: str = 'rtlsdr') -> str | None: def get_sdr_in_use(self, device: int = 0) -> str | None:
"""Check if an SDR device is in use by another mode. """Check if an SDR device is in use by another mode.
Returns the mode name using the device, or None if available. Returns the mode name using the device, or None if available.
""" """
sdr_type_key = str(sdr_type or 'rtlsdr').lower() for mode, info in self.running_modes.items():
for mode, info in self.running_modes.items(): if mode in self.SDR_MODES:
if mode in self.SDR_MODES: mode_device = info.get('params', {}).get('device', 0)
mode_device = info.get('params', {}).get('device', 0) # Normalize to int for comparison
mode_sdr_type = str(info.get('params', {}).get('sdr_type', 'rtlsdr')).lower() try:
# Normalize to int for comparison mode_device = int(mode_device)
try: except (ValueError, TypeError):
mode_device = int(mode_device) mode_device = 0
except (ValueError, TypeError): if mode_device == device:
mode_device = 0 return mode
if mode_device == device and mode_sdr_type == sdr_type_key: return None
return mode
return None
def start_mode(self, mode: str, params: dict) -> dict: def start_mode(self, mode: str, params: dict) -> dict:
"""Start a mode with given parameters.""" """Start a mode with given parameters."""
@@ -728,19 +725,18 @@ class ModeManager:
return {'status': 'error', 'message': f'{mode} not available (missing tools)'} return {'status': 'error', 'message': f'{mode} not available (missing tools)'}
# Check SDR device conflicts for SDR-based modes # Check SDR device conflicts for SDR-based modes
if mode in self.SDR_MODES: if mode in self.SDR_MODES:
device = params.get('device', 0) device = params.get('device', 0)
try: try:
device = int(device) device = int(device)
except (ValueError, TypeError): except (ValueError, TypeError):
device = 0 device = 0
sdr_type = str(params.get('sdr_type', 'rtlsdr')).lower() in_use_by = self.get_sdr_in_use(device)
in_use_by = self.get_sdr_in_use(device, sdr_type) if in_use_by:
if in_use_by: return {
return { 'status': 'error',
'status': 'error', 'message': f'SDR device {device} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.'
'message': f'SDR device {device} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.' }
}
# Initialize lock if needed # Initialize lock if needed
if mode not in self.locks: if mode not in self.locks:
@@ -842,15 +838,15 @@ class ModeManager:
data['data'] = list(getattr(self, 'ais_vessels', {}).values()) data['data'] = list(getattr(self, 'ais_vessels', {}).values())
elif mode == 'aprs': elif mode == 'aprs':
data['data'] = list(getattr(self, 'aprs_stations', {}).values()) data['data'] = list(getattr(self, 'aprs_stations', {}).values())
elif mode == 'tscm': elif mode == 'tscm':
data['data'] = { data['data'] = {
'anomalies': getattr(self, 'tscm_anomalies', []), 'anomalies': getattr(self, 'tscm_anomalies', []),
'baseline': getattr(self, 'tscm_baseline', {}), 'baseline': getattr(self, 'tscm_baseline', {}),
'wifi_devices': list(self.wifi_networks.values()), 'wifi_devices': list(self.wifi_networks.values()),
'wifi_clients': list(getattr(self, 'tscm_wifi_clients', {}).values()), 'wifi_clients': list(getattr(self, 'tscm_wifi_clients', {}).values()),
'bt_devices': list(self.bluetooth_devices.values()), 'bt_devices': list(self.bluetooth_devices.values()),
'rf_signals': getattr(self, 'tscm_rf_signals', []), 'rf_signals': getattr(self, 'tscm_rf_signals', []),
} }
elif mode == 'listening_post': elif mode == 'listening_post':
data['data'] = { data['data'] = {
'activity': getattr(self, 'listening_post_activity', []), 'activity': getattr(self, 'listening_post_activity', []),
@@ -1101,37 +1097,32 @@ class ModeManager:
if mode in self.data_snapshots: if mode in self.data_snapshots:
del self.data_snapshots[mode] del self.data_snapshots[mode]
# Mode-specific cleanup # Mode-specific cleanup
if mode == 'adsb': if mode == 'adsb':
self.adsb_aircraft.clear() self.adsb_aircraft.clear()
if 'adsb_mlat' in self.output_threads: elif mode == 'wifi':
thread = self.output_threads['adsb_mlat']
if thread and thread.is_alive():
thread.join(timeout=1)
del self.output_threads['adsb_mlat']
elif mode == 'wifi':
self.wifi_networks.clear() self.wifi_networks.clear()
self.wifi_clients.clear() self.wifi_clients.clear()
elif mode == 'bluetooth': elif mode == 'bluetooth':
self.bluetooth_devices.clear() self.bluetooth_devices.clear()
elif mode == 'tscm': elif mode == 'tscm':
# Clean up TSCM sub-threads # Clean up TSCM sub-threads
for sub_thread_name in ['tscm_wifi', 'tscm_bt', 'tscm_rf']: for sub_thread_name in ['tscm_wifi', 'tscm_bt', 'tscm_rf']:
if sub_thread_name in self.output_threads: if sub_thread_name in self.output_threads:
thread = self.output_threads[sub_thread_name] thread = self.output_threads[sub_thread_name]
if thread and thread.is_alive(): if thread and thread.is_alive():
thread.join(timeout=2) thread.join(timeout=2)
del self.output_threads[sub_thread_name] del self.output_threads[sub_thread_name]
# Clear TSCM data # Clear TSCM data
self.tscm_anomalies = [] self.tscm_anomalies = []
self.tscm_baseline = {} self.tscm_baseline = {}
self.tscm_rf_signals = [] self.tscm_rf_signals = []
self.tscm_wifi_clients = {} self.tscm_wifi_clients = {}
# Clear reported threat tracking sets # Clear reported threat tracking sets
if hasattr(self, '_tscm_reported_wifi'): if hasattr(self, '_tscm_reported_wifi'):
self._tscm_reported_wifi.clear() self._tscm_reported_wifi.clear()
if hasattr(self, '_tscm_reported_bt'): if hasattr(self, '_tscm_reported_bt'):
self._tscm_reported_bt.clear() self._tscm_reported_bt.clear()
elif mode == 'dsc': elif mode == 'dsc':
# Clear DSC data # Clear DSC data
if hasattr(self, 'dsc_messages'): if hasattr(self, 'dsc_messages'):
@@ -1320,20 +1311,14 @@ class ModeManager:
"""Start dump1090 ADS-B mode using Intercept's utilities.""" """Start dump1090 ADS-B mode using Intercept's utilities."""
gain = params.get('gain', '40') gain = params.get('gain', '40')
device = params.get('device', '0') device = params.get('device', '0')
bias_t = params.get('bias_t', False) bias_t = params.get('bias_t', False)
sdr_type_str = params.get('sdr_type', 'rtlsdr') sdr_type_str = params.get('sdr_type', 'rtlsdr')
remote_sbs_host = params.get('remote_sbs_host') remote_sbs_host = params.get('remote_sbs_host')
remote_sbs_port = params.get('remote_sbs_port', 30003) remote_sbs_port = params.get('remote_sbs_port', 30003)
mlat_sbs_host = params.get('mlat_sbs_host')
mlat_sbs_port = params.get('mlat_sbs_port', 30105)
# If remote SBS host provided, just connect to it # If remote SBS host provided, just connect to it
if remote_sbs_host: if remote_sbs_host:
result = self._start_adsb_sbs_connection(remote_sbs_host, remote_sbs_port, source_tag='adsb', thread_name='adsb') return self._start_adsb_sbs_connection(remote_sbs_host, remote_sbs_port)
if mlat_sbs_host:
self._start_adsb_sbs_connection(mlat_sbs_host, mlat_sbs_port, source_tag='mlat', thread_name='adsb_mlat')
result['mlat_source'] = f'{mlat_sbs_host}:{mlat_sbs_port}'
return result
# Check if dump1090 already running on port 30003 # Check if dump1090 already running on port 30003
try: try:
@@ -1341,13 +1326,9 @@ class ModeManager:
sock.settimeout(1.0) sock.settimeout(1.0)
result = sock.connect_ex(('localhost', 30003)) result = sock.connect_ex(('localhost', 30003))
sock.close() sock.close()
if result == 0: if result == 0:
logger.info("dump1090 already running, connecting to SBS port") logger.info("dump1090 already running, connecting to SBS port")
result = self._start_adsb_sbs_connection('localhost', 30003, source_tag='adsb', thread_name='adsb') return self._start_adsb_sbs_connection('localhost', 30003)
if mlat_sbs_host:
self._start_adsb_sbs_connection(mlat_sbs_host, mlat_sbs_port, source_tag='mlat', thread_name='adsb_mlat')
result['mlat_source'] = f'{mlat_sbs_host}:{mlat_sbs_port}'
return result
except Exception: except Exception:
pass pass
@@ -1399,16 +1380,12 @@ class ModeManager:
# Wait for dump1090 to start # Wait for dump1090 to start
time.sleep(2) time.sleep(2)
if proc.poll() is not None: if proc.poll() is not None:
stderr = proc.stderr.read().decode('utf-8', errors='ignore') stderr = proc.stderr.read().decode('utf-8', errors='ignore')
return {'status': 'error', 'message': f'dump1090 failed to start: {stderr[:200]}'} return {'status': 'error', 'message': f'dump1090 failed to start: {stderr[:200]}'}
# Connect to SBS port # Connect to SBS port
result = self._start_adsb_sbs_connection('localhost', 30003, source_tag='adsb', thread_name='adsb') return self._start_adsb_sbs_connection('localhost', 30003)
if mlat_sbs_host:
self._start_adsb_sbs_connection(mlat_sbs_host, mlat_sbs_port, source_tag='mlat', thread_name='adsb_mlat')
result['mlat_source'] = f'{mlat_sbs_host}:{mlat_sbs_port}'
return result
except FileNotFoundError: except FileNotFoundError:
return {'status': 'error', 'message': 'dump1090 not found'} return {'status': 'error', 'message': 'dump1090 not found'}
@@ -1437,27 +1414,27 @@ class ModeManager:
return path return path
return None return None
def _start_adsb_sbs_connection(self, host: str, port: int, *, source_tag: str = 'adsb', thread_name: str = 'adsb') -> dict: def _start_adsb_sbs_connection(self, host: str, port: int) -> dict:
"""Connect to SBS port and start parsing.""" """Connect to SBS port and start parsing."""
thread = threading.Thread( thread = threading.Thread(
target=self._adsb_sbs_reader, target=self._adsb_sbs_reader,
args=(host, port, source_tag), args=(host, port),
daemon=True daemon=True
) )
thread.start() thread.start()
self.output_threads[thread_name] = thread self.output_threads['adsb'] = thread
return { return {
'status': 'started', 'status': 'started',
'mode': 'adsb', 'mode': 'adsb',
'sbs_source': f'{host}:{port}', 'sbs_source': f'{host}:{port}',
'gps_enabled': gps_manager.is_running 'gps_enabled': gps_manager.is_running
} }
def _adsb_sbs_reader(self, host: str, port: int, source_tag: str = 'adsb'): def _adsb_sbs_reader(self, host: str, port: int):
"""Read and parse SBS data from dump1090.""" """Read and parse SBS data from dump1090."""
mode = 'adsb' mode = 'adsb'
stop_event = self.stop_events.get(mode) stop_event = self.stop_events.get(mode)
retry_count = 0 retry_count = 0
max_retries = 5 max_retries = 5
@@ -1466,8 +1443,8 @@ class ModeManager:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(5.0) sock.settimeout(5.0)
sock.connect((host, port)) sock.connect((host, port))
logger.info(f"Connected to SBS at {host}:{port} ({source_tag})") logger.info(f"Connected to SBS at {host}:{port}")
retry_count = 0 retry_count = 0
buffer = "" buffer = ""
sock.settimeout(1.0) sock.settimeout(1.0)
@@ -1481,7 +1458,7 @@ class ModeManager:
while '\n' in buffer: while '\n' in buffer:
line, buffer = buffer.split('\n', 1) line, buffer = buffer.split('\n', 1)
self._parse_sbs_line(line.strip(), source_tag) self._parse_sbs_line(line.strip())
except socket.timeout: except socket.timeout:
continue continue
@@ -1498,10 +1475,10 @@ class ModeManager:
logger.info("ADS-B SBS reader stopped") logger.info("ADS-B SBS reader stopped")
def _parse_sbs_line(self, line: str, source_tag: str = 'adsb'): def _parse_sbs_line(self, line: str):
"""Parse SBS format line and update aircraft dict.""" """Parse SBS format line and update aircraft dict."""
if not line: if not line:
return return
parts = line.split(',') parts = line.split(',')
if len(parts) < 11 or parts[0] != 'MSG': if len(parts) < 11 or parts[0] != 'MSG':
@@ -1526,14 +1503,12 @@ class ModeManager:
if callsign: if callsign:
aircraft['callsign'] = callsign aircraft['callsign'] = callsign
elif msg_type == '3' and len(parts) > 15: elif msg_type == '3' and len(parts) > 15:
if parts[11]: if parts[11]:
aircraft['altitude'] = int(float(parts[11])) aircraft['altitude'] = int(float(parts[11]))
if parts[14] and parts[15]: if parts[14] and parts[15]:
aircraft['lat'] = float(parts[14]) aircraft['lat'] = float(parts[14])
aircraft['lon'] = float(parts[15]) aircraft['lon'] = float(parts[15])
if source_tag:
aircraft['position_source'] = source_tag
elif msg_type == '4' and len(parts) > 16: elif msg_type == '4' and len(parts) > 16:
if parts[12]: if parts[12]:
@@ -1567,10 +1542,10 @@ class ModeManager:
def _start_wifi(self, params: dict) -> dict: def _start_wifi(self, params: dict) -> dict:
"""Start WiFi scanning using Intercept's UnifiedWiFiScanner.""" """Start WiFi scanning using Intercept's UnifiedWiFiScanner."""
interface = params.get('interface') interface = params.get('interface')
channel = params.get('channel') channel = params.get('channel')
channels = params.get('channels') channels = params.get('channels')
band = params.get('band', 'abg') band = params.get('band', 'abg')
scan_type = params.get('scan_type', 'deep') scan_type = params.get('scan_type', 'deep')
# Handle quick scan - returns results synchronously # Handle quick scan - returns results synchronously
if scan_type == 'quick': if scan_type == 'quick':
@@ -1599,21 +1574,21 @@ class ModeManager:
else: else:
scan_band = 'all' scan_band = 'all'
channel_list = None channel_list = None
if channels: if channels:
if isinstance(channels, str): if isinstance(channels, str):
channel_list = [c.strip() for c in channels.split(',') if c.strip()] channel_list = [c.strip() for c in channels.split(',') if c.strip()]
elif isinstance(channels, (list, tuple, set)): elif isinstance(channels, (list, tuple, set)):
channel_list = list(channels) channel_list = list(channels)
else: else:
channel_list = [channels] channel_list = [channels]
try: try:
channel_list = [int(c) for c in channel_list] channel_list = [int(c) for c in channel_list]
except (TypeError, ValueError): except (TypeError, ValueError):
return {'status': 'error', 'message': 'Invalid channels'} return {'status': 'error', 'message': 'Invalid channels'}
# Start deep scan # Start deep scan
if scanner.start_deep_scan(interface=interface, band=scan_band, channel=channel, channels=channel_list): if scanner.start_deep_scan(interface=interface, band=scan_band, channel=channel, channels=channel_list):
# Start thread to sync data to agent's dictionaries # Start thread to sync data to agent's dictionaries
thread = threading.Thread( thread = threading.Thread(
target=self._wifi_data_sync, target=self._wifi_data_sync,
@@ -1632,12 +1607,12 @@ class ModeManager:
else: else:
return {'status': 'error', 'message': scanner.get_status().error or 'Failed to start deep scan'} return {'status': 'error', 'message': scanner.get_status().error or 'Failed to start deep scan'}
except ImportError: except ImportError:
# Fallback to direct airodump-ng # Fallback to direct airodump-ng
return self._start_wifi_fallback(interface, channel, band, channels) return self._start_wifi_fallback(interface, channel, band, channels)
except Exception as e: except Exception as e:
logger.error(f"WiFi scanner error: {e}") logger.error(f"WiFi scanner error: {e}")
return {'status': 'error', 'message': str(e)} return {'status': 'error', 'message': str(e)}
def _wifi_data_sync(self, scanner): def _wifi_data_sync(self, scanner):
"""Sync WiFi scanner data to agent's data structures.""" """Sync WiFi scanner data to agent's data structures."""
@@ -1671,14 +1646,14 @@ class ModeManager:
if hasattr(self, '_wifi_scanner_instance') and self._wifi_scanner_instance: if hasattr(self, '_wifi_scanner_instance') and self._wifi_scanner_instance:
self._wifi_scanner_instance.stop_deep_scan() self._wifi_scanner_instance.stop_deep_scan()
def _start_wifi_fallback( def _start_wifi_fallback(
self, self,
interface: str | None, interface: str | None,
channel: int | None, channel: int | None,
band: str, band: str,
channels: list[int] | str | None = None, channels: list[int] | str | None = None,
) -> dict: ) -> dict:
"""Fallback WiFi deep scan using airodump-ng directly.""" """Fallback WiFi deep scan using airodump-ng directly."""
if not interface: if not interface:
return {'status': 'error', 'message': 'WiFi interface required'} return {'status': 'error', 'message': 'WiFi interface required'}
@@ -1705,23 +1680,23 @@ class ModeManager:
cmd = [airodump_path, '-w', csv_path, '--output-format', output_formats, '--band', band] cmd = [airodump_path, '-w', csv_path, '--output-format', output_formats, '--band', band]
if gps_manager.is_running: if gps_manager.is_running:
cmd.append('--gpsd') cmd.append('--gpsd')
channel_list = None channel_list = None
if channels: if channels:
if isinstance(channels, str): if isinstance(channels, str):
channel_list = [c.strip() for c in channels.split(',') if c.strip()] channel_list = [c.strip() for c in channels.split(',') if c.strip()]
elif isinstance(channels, (list, tuple, set)): elif isinstance(channels, (list, tuple, set)):
channel_list = list(channels) channel_list = list(channels)
else: else:
channel_list = [channels] channel_list = [channels]
try: try:
channel_list = [int(c) for c in channel_list] channel_list = [int(c) for c in channel_list]
except (TypeError, ValueError): except (TypeError, ValueError):
return {'status': 'error', 'message': 'Invalid channels'} return {'status': 'error', 'message': 'Invalid channels'}
if channel_list: if channel_list:
cmd.extend(['-c', ','.join(str(c) for c in channel_list)]) cmd.extend(['-c', ','.join(str(c) for c in channel_list)])
elif channel: elif channel:
cmd.extend(['-c', str(channel)]) cmd.extend(['-c', str(channel)])
cmd.append(interface) cmd.append(interface)
try: try:
@@ -2047,7 +2022,7 @@ class ModeManager:
'agent_gps': gps_manager.position 'agent_gps': gps_manager.position
} }
scanner.set_on_device_updated(on_device_updated) scanner.add_device_callback(on_device_updated)
# Start scanning # Start scanning
if scanner.start_scan(mode=mode_param, duration_s=duration): if scanner.start_scan(mode=mode_param, duration_s=duration):
@@ -3173,21 +3148,21 @@ class ModeManager:
self.tscm_baseline = {} self.tscm_baseline = {}
if not hasattr(self, 'tscm_anomalies'): if not hasattr(self, 'tscm_anomalies'):
self.tscm_anomalies = [] self.tscm_anomalies = []
if not hasattr(self, 'tscm_rf_signals'): if not hasattr(self, 'tscm_rf_signals'):
self.tscm_rf_signals = [] self.tscm_rf_signals = []
if not hasattr(self, 'tscm_wifi_clients'): if not hasattr(self, 'tscm_wifi_clients'):
self.tscm_wifi_clients = {} self.tscm_wifi_clients = {}
self.tscm_anomalies.clear() self.tscm_anomalies.clear()
self.tscm_wifi_clients.clear() self.tscm_wifi_clients.clear()
# Get params for what to scan # Get params for what to scan
scan_wifi = params.get('wifi', True) scan_wifi = params.get('wifi', True)
scan_bt = params.get('bluetooth', True) scan_bt = params.get('bluetooth', True)
scan_rf = params.get('rf', True) scan_rf = params.get('rf', True)
wifi_interface = params.get('wifi_interface') or params.get('interface') wifi_interface = params.get('wifi_interface') or params.get('interface')
bt_adapter = params.get('bt_interface') or params.get('adapter', 'hci0') bt_adapter = params.get('bt_interface') or params.get('adapter', 'hci0')
sdr_device = params.get('sdr_device', params.get('device', 0)) sdr_device = params.get('sdr_device', params.get('device', 0))
sweep_type = params.get('sweep_type') sweep_type = params.get('sweep_type')
# Get baseline_id for comparison (same as local mode) # Get baseline_id for comparison (same as local mode)
baseline_id = params.get('baseline_id') baseline_id = params.get('baseline_id')
@@ -3195,11 +3170,11 @@ class ModeManager:
started_scans = [] started_scans = []
# Start the combined TSCM scanner thread using existing Intercept functions # Start the combined TSCM scanner thread using existing Intercept functions
thread = threading.Thread( thread = threading.Thread(
target=self._tscm_scanner_thread, target=self._tscm_scanner_thread,
args=(scan_wifi, scan_bt, scan_rf, wifi_interface, bt_adapter, sdr_device, baseline_id, sweep_type), args=(scan_wifi, scan_bt, scan_rf, wifi_interface, bt_adapter, sdr_device, baseline_id, sweep_type),
daemon=True daemon=True
) )
thread.start() thread.start()
self.output_threads['tscm'] = thread self.output_threads['tscm'] = thread
@@ -3218,9 +3193,9 @@ class ModeManager:
'scanning': started_scans 'scanning': started_scans
} }
def _tscm_scanner_thread(self, scan_wifi: bool, scan_bt: bool, scan_rf: bool, def _tscm_scanner_thread(self, scan_wifi: bool, scan_bt: bool, scan_rf: bool,
wifi_interface: str | None, bt_adapter: str, sdr_device: int, wifi_interface: str | None, bt_adapter: str, sdr_device: int,
baseline_id: int | None = None, sweep_type: str | None = None): baseline_id: int | None = None, sweep_type: str | None = None):
"""Combined TSCM scanner using existing Intercept functions. """Combined TSCM scanner using existing Intercept functions.
NOTE: This matches local mode behavior exactly: NOTE: This matches local mode behavior exactly:
@@ -3233,20 +3208,20 @@ class ModeManager:
stop_event = self.stop_events.get(mode) stop_event = self.stop_events.get(mode)
# Import existing Intercept TSCM functions # Import existing Intercept TSCM functions
from routes.tscm import _scan_wifi_networks, _scan_wifi_clients, _scan_bluetooth_devices, _scan_rf_signals from routes.tscm import _scan_wifi_networks, _scan_wifi_clients, _scan_bluetooth_devices, _scan_rf_signals
logger.info("TSCM imports successful") logger.info("TSCM imports successful")
sweep_ranges = None sweep_ranges = None
if sweep_type: if sweep_type:
try: try:
from data.tscm_frequencies import get_sweep_preset, SWEEP_PRESETS from data.tscm_frequencies import get_sweep_preset, SWEEP_PRESETS
preset = get_sweep_preset(sweep_type) or SWEEP_PRESETS.get('standard') preset = get_sweep_preset(sweep_type) or SWEEP_PRESETS.get('standard')
sweep_ranges = preset.get('ranges') if preset else None sweep_ranges = preset.get('ranges') if preset else None
except Exception: except Exception:
sweep_ranges = None sweep_ranges = None
# Load baseline if specified (same as local mode) # Load baseline if specified (same as local mode)
baseline = None baseline = None
if baseline_id and HAS_BASELINE_DB and get_tscm_baseline: if baseline_id and HAS_BASELINE_DB and get_tscm_baseline:
baseline = get_tscm_baseline(baseline_id) baseline = get_tscm_baseline(baseline_id)
if baseline: if baseline:
@@ -3267,9 +3242,9 @@ class ModeManager:
self._tscm_correlation = None self._tscm_correlation = None
# Track devices seen during this sweep (like local mode's all_wifi/all_bt dicts) # Track devices seen during this sweep (like local mode's all_wifi/all_bt dicts)
seen_wifi = {} seen_wifi = {}
seen_wifi_clients = {} seen_wifi_clients = {}
seen_bt = {} seen_bt = {}
last_rf_scan = 0 last_rf_scan = 0
rf_scan_interval = 30 rf_scan_interval = 30
@@ -3315,63 +3290,63 @@ class ModeManager:
enriched['is_new'] = not classification.get('in_baseline', False) enriched['is_new'] = not classification.get('in_baseline', False)
enriched['reasons'] = classification.get('reasons', []) enriched['reasons'] = classification.get('reasons', [])
if self._tscm_correlation: if self._tscm_correlation:
profile = self._tscm_correlation.analyze_wifi_device(enriched) profile = self._tscm_correlation.analyze_wifi_device(enriched)
enriched['classification'] = profile.risk_level.value enriched['classification'] = profile.risk_level.value
enriched['score'] = profile.total_score enriched['score'] = profile.total_score
enriched['score_modifier'] = profile.score_modifier enriched['score_modifier'] = profile.score_modifier
enriched['known_device'] = profile.known_device enriched['known_device'] = profile.known_device
enriched['known_device_name'] = profile.known_device_name enriched['known_device_name'] = profile.known_device_name
enriched['indicators'] = [ enriched['indicators'] = [
{'type': i.type.value, 'desc': i.description} {'type': i.type.value, 'desc': i.description}
for i in profile.indicators for i in profile.indicators
] ]
enriched['recommended_action'] = profile.recommended_action enriched['recommended_action'] = profile.recommended_action
self.wifi_networks[bssid] = enriched self.wifi_networks[bssid] = enriched
# WiFi clients (monitor mode only) # WiFi clients (monitor mode only)
try: try:
wifi_clients = _scan_wifi_clients(wifi_interface or '') wifi_clients = _scan_wifi_clients(wifi_interface or '')
for client in wifi_clients: for client in wifi_clients:
mac = (client.get('mac') or '').upper() mac = (client.get('mac') or '').upper()
if not mac or mac in seen_wifi_clients: if not mac or mac in seen_wifi_clients:
continue continue
seen_wifi_clients[mac] = client seen_wifi_clients[mac] = client
rssi_val = client.get('rssi_current') rssi_val = client.get('rssi_current')
if rssi_val is None: if rssi_val is None:
rssi_val = client.get('rssi_median') or client.get('rssi_ema') rssi_val = client.get('rssi_median') or client.get('rssi_ema')
client_device = { client_device = {
'mac': mac, 'mac': mac,
'vendor': client.get('vendor'), 'vendor': client.get('vendor'),
'name': client.get('vendor') or 'WiFi Client', 'name': client.get('vendor') or 'WiFi Client',
'rssi': rssi_val, 'rssi': rssi_val,
'associated_bssid': client.get('associated_bssid'), 'associated_bssid': client.get('associated_bssid'),
'probed_ssids': client.get('probed_ssids', []), 'probed_ssids': client.get('probed_ssids', []),
'probe_count': client.get('probe_count', len(client.get('probed_ssids', []))), 'probe_count': client.get('probe_count', len(client.get('probed_ssids', []))),
'is_client': True, 'is_client': True,
} }
if self._tscm_correlation: if self._tscm_correlation:
profile = self._tscm_correlation.analyze_wifi_device(client_device) profile = self._tscm_correlation.analyze_wifi_device(client_device)
client_device['classification'] = profile.risk_level.value client_device['classification'] = profile.risk_level.value
client_device['score'] = profile.total_score client_device['score'] = profile.total_score
client_device['score_modifier'] = profile.score_modifier client_device['score_modifier'] = profile.score_modifier
client_device['known_device'] = profile.known_device client_device['known_device'] = profile.known_device
client_device['known_device_name'] = profile.known_device_name client_device['known_device_name'] = profile.known_device_name
client_device['indicators'] = [ client_device['indicators'] = [
{'type': i.type.value, 'desc': i.description} {'type': i.type.value, 'desc': i.description}
for i in profile.indicators for i in profile.indicators
] ]
client_device['recommended_action'] = profile.recommended_action client_device['recommended_action'] = profile.recommended_action
self.tscm_wifi_clients[mac] = client_device self.tscm_wifi_clients[mac] = client_device
except Exception as e: except Exception as e:
logger.debug(f"WiFi client scan error: {e}") logger.debug(f"WiFi client scan error: {e}")
except Exception as e: except Exception as e:
logger.debug(f"WiFi scan error: {e}") logger.debug(f"WiFi scan error: {e}")
# Bluetooth scan using Intercept's function (same as local mode) # Bluetooth scan using Intercept's function (same as local mode)
if scan_bt: if scan_bt:
@@ -3405,18 +3380,18 @@ class ModeManager:
enriched['is_new'] = not classification.get('in_baseline', False) enriched['is_new'] = not classification.get('in_baseline', False)
enriched['reasons'] = classification.get('reasons', []) enriched['reasons'] = classification.get('reasons', [])
if self._tscm_correlation: if self._tscm_correlation:
profile = self._tscm_correlation.analyze_bluetooth_device(enriched) profile = self._tscm_correlation.analyze_bluetooth_device(enriched)
enriched['classification'] = profile.risk_level.value enriched['classification'] = profile.risk_level.value
enriched['score'] = profile.total_score enriched['score'] = profile.total_score
enriched['score_modifier'] = profile.score_modifier enriched['score_modifier'] = profile.score_modifier
enriched['known_device'] = profile.known_device enriched['known_device'] = profile.known_device
enriched['known_device_name'] = profile.known_device_name enriched['known_device_name'] = profile.known_device_name
enriched['indicators'] = [ enriched['indicators'] = [
{'type': i.type.value, 'desc': i.description} {'type': i.type.value, 'desc': i.description}
for i in profile.indicators for i in profile.indicators
] ]
enriched['recommended_action'] = profile.recommended_action enriched['recommended_action'] = profile.recommended_action
self.bluetooth_devices[mac] = enriched self.bluetooth_devices[mac] = enriched
except Exception as e: except Exception as e:
@@ -3427,11 +3402,11 @@ class ModeManager:
try: try:
# Pass a stop check that uses our stop_event (not the module's _sweep_running) # Pass a stop check that uses our stop_event (not the module's _sweep_running)
agent_stop_check = lambda: stop_event and stop_event.is_set() agent_stop_check = lambda: stop_event and stop_event.is_set()
rf_signals = _scan_rf_signals( rf_signals = _scan_rf_signals(
sdr_device, sdr_device,
stop_check=agent_stop_check, stop_check=agent_stop_check,
sweep_ranges=sweep_ranges sweep_ranges=sweep_ranges
) )
# Analyze each RF signal like local mode does # Analyze each RF signal like local mode does
analyzed_signals = [] analyzed_signals = []
@@ -3451,17 +3426,17 @@ class ModeManager:
analyzed['reasons'] = classification.get('reasons', []) analyzed['reasons'] = classification.get('reasons', [])
# Use correlation engine for scoring (same as local mode) # Use correlation engine for scoring (same as local mode)
if hasattr(self, '_tscm_correlation') and self._tscm_correlation: if hasattr(self, '_tscm_correlation') and self._tscm_correlation:
profile = self._tscm_correlation.analyze_rf_signal(signal) profile = self._tscm_correlation.analyze_rf_signal(signal)
analyzed['classification'] = profile.risk_level.value analyzed['classification'] = profile.risk_level.value
analyzed['score'] = profile.total_score analyzed['score'] = profile.total_score
analyzed['score_modifier'] = profile.score_modifier analyzed['score_modifier'] = profile.score_modifier
analyzed['known_device'] = profile.known_device analyzed['known_device'] = profile.known_device
analyzed['known_device_name'] = profile.known_device_name analyzed['known_device_name'] = profile.known_device_name
analyzed['indicators'] = [ analyzed['indicators'] = [
{'type': i.type.value, 'desc': i.description} {'type': i.type.value, 'desc': i.description}
for i in profile.indicators for i in profile.indicators
] ]
analyzed['is_threat'] = is_threat analyzed['is_threat'] = is_threat
analyzed_signals.append(analyzed) analyzed_signals.append(analyzed)
+3 -3
View File
@@ -1,10 +1,10 @@
[project] [project]
name = "intercept" name = "intercept"
version = "2.15.0" version = "2.21.0"
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth" description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
readme = "README.md" readme = "README.md"
requires-python = ">=3.9" requires-python = ">=3.9"
license = {text = "MIT"} license = {text = "Apache-2.0"}
authors = [ authors = [
{name = "Intercept Contributors"} {name = "Intercept Contributors"}
] ]
@@ -14,7 +14,7 @@ classifiers = [
"Environment :: Web Environment", "Environment :: Web Environment",
"Framework :: Flask", "Framework :: Flask",
"Intended Audience :: Developers", "Intended Audience :: Developers",
"License :: OSI Approved :: MIT License", "License :: OSI Approved :: Apache Software License",
"Operating System :: POSIX :: Linux", "Operating System :: POSIX :: Linux",
"Operating System :: MacOS", "Operating System :: MacOS",
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
+3
View File
@@ -32,6 +32,9 @@ scapy>=2.4.5
# QR code generation for Meshtastic channels (optional) # QR code generation for Meshtastic channels (optional)
qrcode[pil]>=7.4 qrcode[pil]>=7.4
# BLE RPA resolution for BT Locate (optional - for SAR device tracking)
cryptography>=41.0.0
# Development dependencies (install with: pip install -r requirements-dev.txt) # Development dependencies (install with: pip install -r requirements-dev.txt)
# pytest>=7.0.0 # pytest>=7.0.0
# pytest-cov>=4.0.0 # pytest-cov>=4.0.0
+10
View File
@@ -13,6 +13,7 @@ def register_blueprints(app):
from .ais import ais_bp from .ais import ais_bp
from .dsc import dsc_bp from .dsc import dsc_bp
from .acars import acars_bp from .acars import acars_bp
from .vdl2 import vdl2_bp
from .aprs import aprs_bp from .aprs import aprs_bp
from .satellite import satellite_bp from .satellite import satellite_bp
from .gps import gps_bp from .gps import gps_bp
@@ -32,6 +33,10 @@ def register_blueprints(app):
from .websdr import websdr_bp from .websdr import websdr_bp
from .alerts import alerts_bp from .alerts import alerts_bp
from .recordings import recordings_bp from .recordings import recordings_bp
from .subghz import subghz_bp
from .bt_locate import bt_locate_bp
from .analytics import analytics_bp
from .space_weather import space_weather_bp
app.register_blueprint(pager_bp) app.register_blueprint(pager_bp)
app.register_blueprint(sensor_bp) app.register_blueprint(sensor_bp)
@@ -44,6 +49,7 @@ def register_blueprints(app):
app.register_blueprint(ais_bp) app.register_blueprint(ais_bp)
app.register_blueprint(dsc_bp) # VHF DSC maritime distress app.register_blueprint(dsc_bp) # VHF DSC maritime distress
app.register_blueprint(acars_bp) app.register_blueprint(acars_bp)
app.register_blueprint(vdl2_bp)
app.register_blueprint(aprs_bp) app.register_blueprint(aprs_bp)
app.register_blueprint(satellite_bp) app.register_blueprint(satellite_bp)
app.register_blueprint(gps_bp) app.register_blueprint(gps_bp)
@@ -63,6 +69,10 @@ def register_blueprints(app):
app.register_blueprint(websdr_bp) # HF/Shortwave WebSDR app.register_blueprint(websdr_bp) # HF/Shortwave WebSDR
app.register_blueprint(alerts_bp) # Cross-mode alerts app.register_blueprint(alerts_bp) # Cross-mode alerts
app.register_blueprint(recordings_bp) # Session recordings 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(analytics_bp) # Cross-mode analytics dashboard
app.register_blueprint(space_weather_bp) # Space weather monitoring
# Initialize TSCM state with queue and lock from app # Initialize TSCM state with queue and lock from app
import app as app_module import app as app_module
+59 -38
View File
@@ -20,8 +20,9 @@ from flask import Blueprint, jsonify, request, Response
import app as app_module import app as app_module
from utils.logging import sensor_logger as logger from utils.logging import sensor_logger as logger
from utils.validation import validate_device_index, validate_gain, validate_ppm from utils.validation import validate_device_index, validate_gain, validate_ppm
from utils.sse import format_sse from utils.sdr import SDRFactory, SDRType
from utils.event_pipeline import process_event from utils.sse import sse_stream_fanout
from utils.event_pipeline import process_event
from utils.constants import ( from utils.constants import (
PROCESS_TERMINATE_TIMEOUT, PROCESS_TERMINATE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL, SSE_KEEPALIVE_INTERVAL,
@@ -34,11 +35,8 @@ acars_bp = Blueprint('acars', __name__, url_prefix='/acars')
# Default VHF ACARS frequencies (MHz) - common worldwide # Default VHF ACARS frequencies (MHz) - common worldwide
DEFAULT_ACARS_FREQUENCIES = [ DEFAULT_ACARS_FREQUENCIES = [
'131.550', # Primary worldwide '131.725', # North America
'130.025', # Secondary USA/Canada '131.825', # North America
'129.125', # USA
'131.525', # Europe
'131.725', # Europe secondary
] ]
# Message counter for statistics # Message counter for statistics
@@ -128,6 +126,13 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
app_module.acars_queue.put(data) app_module.acars_queue.put(data)
# Feed flight correlator
try:
from utils.flight_correlator import get_flight_correlator
get_flight_correlator().add_acars_message(data)
except Exception:
pass
# Log if enabled # Log if enabled
if app_module.logging_enabled: if app_module.logging_enabled:
try: try:
@@ -250,12 +255,22 @@ def start_acars() -> Response:
acars_message_count = 0 acars_message_count = 0
acars_last_message_time = None acars_last_message_time = None
# Resolve SDR type for device selection
sdr_type_str = data.get('sdr_type', 'rtlsdr')
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
is_soapy = sdr_type not in (SDRType.RTL_SDR,)
# Build acarsdec command # Build acarsdec command
# Different forks have different syntax: # Different forks have different syntax:
# - TLeconte v4+: acarsdec -j -g <gain> -p <ppm> -r <device> <freq1> <freq2> ... # - TLeconte v4+: acarsdec -j -g <gain> -p <ppm> -r <device> <freq1> <freq2> ...
# - TLeconte v3: acarsdec -o 4 -g <gain> -p <ppm> -r <device> <freq1> <freq2> ... # - TLeconte v3: acarsdec -o 4 -g <gain> -p <ppm> -r <device> <freq1> <freq2> ...
# - f00b4r0 (DragonOS): acarsdec --output json:file:- -g <gain> -p <ppm> -r <device> <freq1> ... # - f00b4r0 (DragonOS): acarsdec --output json:file:- -g <gain> -p <ppm> -r <device> <freq1> ...
# Note: gain/ppm must come BEFORE -r # SoapySDR devices: TLeconte uses -d <device_string>, f00b4r0 uses --soapysdr <device_string>
# Note: gain/ppm must come BEFORE -r/-d
json_flag = get_acarsdec_json_flag(acarsdec_path) json_flag = get_acarsdec_json_flag(acarsdec_path)
cmd = [acarsdec_path] cmd = [acarsdec_path]
if json_flag == '--output': if json_flag == '--output':
@@ -266,21 +281,33 @@ def start_acars() -> Response:
else: else:
cmd.extend(['-o', '4']) # JSON output (TLeconte v3.x) cmd.extend(['-o', '4']) # JSON output (TLeconte v3.x)
# Add gain if not auto (must be before -r) # Add gain if not auto (must be before -r/-d)
if gain and str(gain) != '0': if gain and str(gain) != '0':
cmd.extend(['-g', str(gain)]) cmd.extend(['-g', str(gain)])
# Add PPM correction if specified (must be before -r) # Add PPM correction if specified (must be before -r/-d)
if ppm and str(ppm) != '0': if ppm and str(ppm) != '0':
cmd.extend(['-p', str(ppm)]) cmd.extend(['-p', str(ppm)])
# Add device and frequencies # Add device and frequencies
# f00b4r0 uses --rtlsdr <device>, TLeconte uses -r <device> if is_soapy:
if json_flag == '--output': # SoapySDR device (SDRplay, LimeSDR, Airspy, etc.)
sdr_device = SDRFactory.create_default_device(sdr_type, index=device_int)
# Build SoapySDR driver string (e.g., "driver=sdrplay,serial=...")
builder = SDRFactory.get_builder(sdr_type)
device_str = builder._build_device_string(sdr_device)
if json_flag == '--output':
cmd.extend(['-m', '256'])
cmd.extend(['--soapysdr', device_str])
else:
cmd.extend(['-d', device_str])
elif json_flag == '--output':
# f00b4r0 fork RTL-SDR: --rtlsdr <device>
# Use 3.2 MS/s sample rate for wider bandwidth (handles NA frequency span) # Use 3.2 MS/s sample rate for wider bandwidth (handles NA frequency span)
cmd.extend(['-m', '256']) cmd.extend(['-m', '256'])
cmd.extend(['--rtlsdr', str(device)]) cmd.extend(['--rtlsdr', str(device)])
else: else:
# TLeconte fork RTL-SDR: -r <device>
cmd.extend(['-r', str(device)]) cmd.extend(['-r', str(device)])
cmd.extend(frequencies) cmd.extend(frequencies)
@@ -384,31 +411,25 @@ def stop_acars() -> Response:
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
@acars_bp.route('/stream') @acars_bp.route('/stream')
def stream_acars() -> Response: def stream_acars() -> Response:
"""SSE stream for ACARS messages.""" """SSE stream for ACARS messages."""
def generate() -> Generator[str, None, None]: def _on_msg(msg: dict[str, Any]) -> None:
last_keepalive = time.time() process_event('acars', msg, msg.get('type'))
while True: response = Response(
try: sse_stream_fanout(
msg = app_module.acars_queue.get(timeout=SSE_QUEUE_TIMEOUT) source_queue=app_module.acars_queue,
last_keepalive = time.time() channel_key='acars',
try: timeout=SSE_QUEUE_TIMEOUT,
process_event('acars', msg, msg.get('type')) keepalive_interval=SSE_KEEPALIVE_INTERVAL,
except Exception: on_message=_on_msg,
pass ),
yield format_sse(msg) mimetype='text/event-stream',
except queue.Empty: )
now = time.time() response.headers['Cache-Control'] = 'no-cache'
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL: response.headers['X-Accel-Buffering'] = 'no'
yield format_sse({'type': 'keepalive'}) return response
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
return response
@acars_bp.route('/frequencies') @acars_bp.route('/frequencies')
@@ -417,7 +438,7 @@ def get_frequencies() -> Response:
return jsonify({ return jsonify({
'default': DEFAULT_ACARS_FREQUENCIES, 'default': DEFAULT_ACARS_FREQUENCIES,
'regions': { 'regions': {
'north_america': ['129.125', '130.025', '130.450', '131.550'], 'north_america': ['131.725', '131.825'],
'europe': ['131.525', '131.725', '131.550'], 'europe': ['131.525', '131.725', '131.550'],
'asia_pacific': ['131.550', '131.450'], 'asia_pacific': ['131.550', '131.450'],
} }
+134 -98
View File
@@ -35,12 +35,10 @@ from config import (
ADSB_DB_USER, ADSB_DB_USER,
ADSB_AUTO_START, ADSB_AUTO_START,
ADSB_HISTORY_ENABLED, ADSB_HISTORY_ENABLED,
ADSB_MLAT_ENABLED,
ADSB_MLAT_SBS_HOST,
ADSB_MLAT_SBS_PORT,
SHARED_OBSERVER_LOCATION_ENABLED, SHARED_OBSERVER_LOCATION_ENABLED,
) )
from utils.logging import adsb_logger as logger from utils.logging import adsb_logger as logger
from utils.process import write_dump1090_pid, clear_dump1090_pid, cleanup_stale_dump1090
from utils.validation import ( from utils.validation import (
validate_device_index, validate_gain, validate_device_index, validate_gain,
validate_rtl_tcp_host, validate_rtl_tcp_port validate_rtl_tcp_host, validate_rtl_tcp_port
@@ -74,14 +72,16 @@ adsb_last_message_time = None
adsb_bytes_received = 0 adsb_bytes_received = 0
adsb_lines_received = 0 adsb_lines_received = 0
adsb_active_device = None # Track which device index is being used adsb_active_device = None # Track which device index is being used
adsb_active_sdr_type = None
_sbs_error_logged = False # Suppress repeated connection error logs _sbs_error_logged = False # Suppress repeated connection error logs
adsb_connected_sources: set[str] = set()
_adsb_connection_lock = threading.Lock()
# Track ICAOs already looked up in aircraft database (avoid repeated lookups) # Track ICAOs already looked up in aircraft database (avoid repeated lookups)
_looked_up_icaos: set[str] = set() _looked_up_icaos: set[str] = set()
# Per-client SSE queues for ADS-B stream fanout.
_adsb_stream_subscribers: set[queue.Queue] = set()
_adsb_stream_subscribers_lock = threading.Lock()
_ADSB_STREAM_CLIENT_QUEUE_SIZE = 500
# Load aircraft database at module init # Load aircraft database at module init
aircraft_db.load_database() aircraft_db.load_database()
@@ -208,6 +208,31 @@ def _parse_int_param(value: str | None, default: int, min_value: int | None = No
return parsed return parsed
def _broadcast_adsb_update(payload: dict[str, Any]) -> None:
"""Fan out a payload to all active ADS-B SSE subscribers."""
with _adsb_stream_subscribers_lock:
subscribers = tuple(_adsb_stream_subscribers)
for subscriber in subscribers:
try:
subscriber.put_nowait(payload)
except queue.Full:
# Drop oldest queued event for that client and try once more.
try:
subscriber.get_nowait()
subscriber.put_nowait(payload)
except (queue.Empty, queue.Full):
# Client queue remains saturated; skip this payload.
continue
def _adsb_stream_queue_depth() -> int:
"""Best-effort aggregate queue depth across connected ADS-B SSE clients."""
with _adsb_stream_subscribers_lock:
subscribers = tuple(_adsb_stream_subscribers)
return sum(subscriber.qsize() for subscriber in subscribers)
def _get_active_session() -> dict[str, Any] | None: def _get_active_session() -> dict[str, Any] | None:
if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE: if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE:
return None return None
@@ -324,29 +349,7 @@ def check_dump1090_service():
return None return None
def _reset_adsb_state() -> None: def parse_sbs_stream(service_addr):
global adsb_connected, adsb_messages_received, adsb_last_message_time, adsb_bytes_received, adsb_lines_received, _sbs_error_logged
adsb_connected = False
adsb_messages_received = 0
adsb_last_message_time = None
adsb_bytes_received = 0
adsb_lines_received = 0
_sbs_error_logged = False
with _adsb_connection_lock:
adsb_connected_sources.clear()
def _set_adsb_connected(source_key: str, connected: bool) -> None:
global adsb_connected
with _adsb_connection_lock:
if connected:
adsb_connected_sources.add(source_key)
else:
adsb_connected_sources.discard(source_key)
adsb_connected = bool(adsb_connected_sources)
def parse_sbs_stream(service_addr: str, source_tag: str | None = None):
"""Parse SBS format data from dump1090 SBS port.""" """Parse SBS format data from dump1090 SBS port."""
global adsb_using_service, adsb_connected, adsb_messages_received, adsb_last_message_time, adsb_bytes_received, adsb_lines_received, _sbs_error_logged global adsb_using_service, adsb_connected, adsb_messages_received, adsb_last_message_time, adsb_bytes_received, adsb_lines_received, _sbs_error_logged
@@ -355,23 +358,26 @@ def parse_sbs_stream(service_addr: str, source_tag: str | None = None):
host, port = service_addr.split(':') host, port = service_addr.split(':')
port = int(port) port = int(port)
source_label = source_tag or 'adsb'
logger.info(f"SBS stream parser started ({source_label}), connecting to {host}:{port}") logger.info(f"SBS stream parser started, connecting to {host}:{port}")
adsb_connected = False
adsb_messages_received = 0
_sbs_error_logged = False
while adsb_using_service: while adsb_using_service:
try: try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(SBS_SOCKET_TIMEOUT) sock.settimeout(SBS_SOCKET_TIMEOUT)
sock.connect((host, port)) sock.connect((host, port))
_set_adsb_connected(service_addr, True) adsb_connected = True
_sbs_error_logged = False # Reset so we log next error _sbs_error_logged = False # Reset so we log next error
logger.info(f"Connected to SBS stream ({source_label})") logger.info("Connected to SBS stream")
buffer = "" buffer = ""
last_update = time.time() last_update = time.time()
pending_updates = set() pending_updates = set()
local_lines_received = 0 adsb_bytes_received = 0
adsb_lines_received = 0
while adsb_using_service: while adsb_using_service:
try: try:
@@ -389,14 +395,13 @@ def parse_sbs_stream(service_addr: str, source_tag: str | None = None):
continue continue
adsb_lines_received += 1 adsb_lines_received += 1
local_lines_received += 1
# Log first few lines for debugging # Log first few lines for debugging
if local_lines_received <= 3: if adsb_lines_received <= 3:
logger.info(f"SBS line ({source_label}) {local_lines_received}: {line[:100]}") logger.info(f"SBS line {adsb_lines_received}: {line[:100]}")
parts = line.split(',') parts = line.split(',')
if len(parts) < 11 or parts[0] != 'MSG': if len(parts) < 11 or parts[0] != 'MSG':
if local_lines_received <= 5: if adsb_lines_received <= 5:
logger.debug(f"Skipping non-MSG line: {line[:50]}") logger.debug(f"Skipping non-MSG line: {line[:50]}")
continue continue
@@ -447,8 +452,6 @@ def parse_sbs_stream(service_addr: str, source_tag: str | None = None):
try: try:
aircraft['lat'] = float(parts[14]) aircraft['lat'] = float(parts[14])
aircraft['lon'] = float(parts[15]) aircraft['lon'] = float(parts[15])
if source_label:
aircraft['position_source'] = source_label
except (ValueError, TypeError): except (ValueError, TypeError):
pass pass
@@ -466,6 +469,12 @@ def parse_sbs_stream(service_addr: str, source_tag: str | None = None):
if parts[16]: if parts[16]:
try: try:
aircraft['vertical_rate'] = int(float(parts[16])) aircraft['vertical_rate'] = int(float(parts[16]))
if abs(aircraft['vertical_rate']) > 4000:
process_event('adsb', {
'type': 'vertical_rate_anomaly', 'icao': icao,
'callsign': aircraft.get('callsign', ''),
'vertical_rate': aircraft['vertical_rate'],
}, 'vertical_rate_anomaly')
except (ValueError, TypeError): except (ValueError, TypeError):
pass pass
@@ -483,6 +492,14 @@ def parse_sbs_stream(service_addr: str, source_tag: str | None = None):
elif msg_type == '6' and len(parts) > 17: elif msg_type == '6' and len(parts) > 17:
if parts[17]: if parts[17]:
aircraft['squawk'] = parts[17] aircraft['squawk'] = parts[17]
sq = parts[17].strip()
_EMERGENCY_SQUAWKS = {'7700': 'General Emergency', '7600': 'Comms Failure', '7500': 'Hijack'}
if sq in _EMERGENCY_SQUAWKS:
process_event('adsb', {
'type': 'squawk_emergency', 'icao': icao,
'callsign': aircraft.get('callsign', ''),
'squawk': sq, 'meaning': _EMERGENCY_SQUAWKS[sq],
}, 'squawk_emergency')
app_module.adsb_aircraft.set(icao, aircraft) app_module.adsb_aircraft.set(icao, aircraft)
pending_updates.add(icao) pending_updates.add(icao)
@@ -494,7 +511,7 @@ def parse_sbs_stream(service_addr: str, source_tag: str | None = None):
for update_icao in pending_updates: for update_icao in pending_updates:
if update_icao in app_module.adsb_aircraft: if update_icao in app_module.adsb_aircraft:
snapshot = app_module.adsb_aircraft[update_icao] snapshot = app_module.adsb_aircraft[update_icao]
app_module.adsb_queue.put({ _broadcast_adsb_update({
'type': 'aircraft', 'type': 'aircraft',
**snapshot **snapshot
}) })
@@ -515,6 +532,19 @@ def parse_sbs_stream(service_addr: str, source_tag: str | None = None):
'source_host': service_addr, 'source_host': service_addr,
'snapshot': snapshot, 'snapshot': snapshot,
}) })
# Geofence check
_gf_lat = snapshot.get('lat')
_gf_lon = snapshot.get('lon')
if _gf_lat is not None and _gf_lon is not None:
try:
from utils.geofence import get_geofence_manager
for _gf_evt in get_geofence_manager().check_position(
update_icao, 'aircraft', _gf_lat, _gf_lon,
{'callsign': snapshot.get('callsign'), 'altitude': snapshot.get('altitude')}
):
process_event('adsb', _gf_evt, 'geofence')
except Exception:
pass
pending_updates.clear() pending_updates.clear()
last_update = now last_update = now
@@ -522,26 +552,18 @@ def parse_sbs_stream(service_addr: str, source_tag: str | None = None):
continue continue
sock.close() sock.close()
_set_adsb_connected(service_addr, False) adsb_connected = False
except OSError as e: except OSError as e:
_set_adsb_connected(service_addr, False) adsb_connected = False
if not _sbs_error_logged: if not _sbs_error_logged:
logger.warning(f"SBS connection error: {e}, reconnecting...") logger.warning(f"SBS connection error: {e}, reconnecting...")
_sbs_error_logged = True _sbs_error_logged = True
time.sleep(SBS_RECONNECT_DELAY) time.sleep(SBS_RECONNECT_DELAY)
_set_adsb_connected(service_addr, False) adsb_connected = False
logger.info("SBS stream parser stopped") logger.info("SBS stream parser stopped")
def _start_mlat_stream(host: str, port: int) -> str:
mlat_addr = f"{host}:{port}"
logger.info(f"Connecting to MLAT SBS at {mlat_addr}")
thread = threading.Thread(target=parse_sbs_stream, args=(mlat_addr, 'mlat'), daemon=True)
thread.start()
return mlat_addr
@adsb_bp.route('/tools') @adsb_bp.route('/tools')
def check_adsb_tools(): def check_adsb_tools():
"""Check for ADS-B decoding tools and hardware.""" """Check for ADS-B decoding tools and hardware."""
@@ -588,7 +610,7 @@ def adsb_status():
'last_message_time': adsb_last_message_time, 'last_message_time': adsb_last_message_time,
'aircraft_count': len(app_module.adsb_aircraft), 'aircraft_count': len(app_module.adsb_aircraft),
'aircraft': dict(app_module.adsb_aircraft), # Full aircraft data 'aircraft': dict(app_module.adsb_aircraft), # Full aircraft data
'queue_size': app_module.adsb_queue.qsize(), 'queue_size': _adsb_stream_queue_depth(),
'dump1090_path': find_dump1090(), 'dump1090_path': find_dump1090(),
'dump1090_running': dump1090_running, 'dump1090_running': dump1090_running,
'port_30003_open': check_dump1090_service() is not None 'port_30003_open': check_dump1090_service() is not None
@@ -616,7 +638,7 @@ def adsb_session():
@adsb_bp.route('/start', methods=['POST']) @adsb_bp.route('/start', methods=['POST'])
def start_adsb(): def start_adsb():
"""Start ADS-B tracking.""" """Start ADS-B tracking."""
global adsb_using_service, adsb_active_device, adsb_active_sdr_type global adsb_using_service, adsb_active_device
with app_module.adsb_lock: with app_module.adsb_lock:
if adsb_using_service: if adsb_using_service:
@@ -637,22 +659,10 @@ def start_adsb():
device = validate_device_index(data.get('device', '0')) device = validate_device_index(data.get('device', '0'))
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return jsonify({'status': 'error', 'message': str(e)}), 400
_reset_adsb_state()
# Check for remote SBS connection (e.g., remote dump1090) # Check for remote SBS connection (e.g., remote dump1090)
remote_sbs_host = data.get('remote_sbs_host') remote_sbs_host = data.get('remote_sbs_host')
remote_sbs_port = data.get('remote_sbs_port', 30003) remote_sbs_port = data.get('remote_sbs_port', 30003)
mlat_sbs_host = (data.get('mlat_sbs_host') or '').strip()
mlat_sbs_port = data.get('mlat_sbs_port', ADSB_MLAT_SBS_PORT)
if not mlat_sbs_host and ADSB_MLAT_ENABLED and ADSB_MLAT_SBS_HOST:
mlat_sbs_host = ADSB_MLAT_SBS_HOST
mlat_sbs_port = ADSB_MLAT_SBS_PORT
if mlat_sbs_host:
try:
mlat_sbs_host = validate_rtl_tcp_host(mlat_sbs_host)
mlat_sbs_port = validate_rtl_tcp_port(mlat_sbs_port)
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
if remote_sbs_host: if remote_sbs_host:
# Validate and connect to remote dump1090 SBS output # Validate and connect to remote dump1090 SBS output
@@ -665,10 +675,8 @@ def start_adsb():
remote_addr = f"{remote_sbs_host}:{remote_sbs_port}" remote_addr = f"{remote_sbs_host}:{remote_sbs_port}"
logger.info(f"Connecting to remote dump1090 SBS at {remote_addr}") logger.info(f"Connecting to remote dump1090 SBS at {remote_addr}")
adsb_using_service = True adsb_using_service = True
thread = threading.Thread(target=parse_sbs_stream, args=(remote_addr, 'adsb'), daemon=True) thread = threading.Thread(target=parse_sbs_stream, args=(remote_addr,), daemon=True)
thread.start() thread.start()
if mlat_sbs_host:
_start_mlat_stream(mlat_sbs_host, mlat_sbs_port)
session = _record_session_start( session = _record_session_start(
device_index=device, device_index=device,
sdr_type='remote', sdr_type='remote',
@@ -683,15 +691,16 @@ def start_adsb():
'session': session 'session': session
}) })
# Kill any stale app-spawned dump1090 from a previous run before checking the port
cleanup_stale_dump1090()
# Check if dump1090 is already running externally (e.g., user started it manually) # Check if dump1090 is already running externally (e.g., user started it manually)
existing_service = check_dump1090_service() existing_service = check_dump1090_service()
if existing_service: if existing_service:
logger.info(f"Found existing dump1090 service at {existing_service}") logger.info(f"Found existing dump1090 service at {existing_service}")
adsb_using_service = True adsb_using_service = True
thread = threading.Thread(target=parse_sbs_stream, args=(existing_service, 'adsb'), daemon=True) thread = threading.Thread(target=parse_sbs_stream, args=(existing_service,), daemon=True)
thread.start() thread.start()
if mlat_sbs_host:
_start_mlat_stream(mlat_sbs_host, mlat_sbs_port)
session = _record_session_start( session = _record_session_start(
device_index=device, device_index=device,
sdr_type='external', sdr_type='external',
@@ -737,11 +746,12 @@ def start_adsb():
except (ProcessLookupError, OSError): except (ProcessLookupError, OSError):
pass pass
app_module.adsb_process = None app_module.adsb_process = None
clear_dump1090_pid()
logger.info("Killed stale ADS-B process") logger.info("Killed stale ADS-B process")
# Check if device is available before starting local dump1090 # Check if device is available before starting local dump1090
device_int = int(device) device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'adsb', sdr_type.value) error = app_module.claim_sdr_device(device_int, 'adsb')
if error: if error:
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
@@ -773,12 +783,13 @@ def start_adsb():
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
start_new_session=True # Create new process group for clean shutdown start_new_session=True # Create new process group for clean shutdown
) )
write_dump1090_pid(app_module.adsb_process.pid)
time.sleep(DUMP1090_START_WAIT) time.sleep(DUMP1090_START_WAIT)
if app_module.adsb_process.poll() is not None: if app_module.adsb_process.poll() is not None:
# Process exited - release device and get error message # Process exited - release device and get error message
app_module.release_sdr_device(device_int, sdr_type.value) app_module.release_sdr_device(device_int)
stderr_output = '' stderr_output = ''
if app_module.adsb_process.stderr: if app_module.adsb_process.stderr:
try: try:
@@ -824,12 +835,9 @@ def start_adsb():
}) })
adsb_using_service = True adsb_using_service = True
adsb_active_device = device # Track which device index is being used adsb_active_device = device # Track which device is being used
adsb_active_sdr_type = sdr_type.value thread = threading.Thread(target=parse_sbs_stream, args=(f'localhost:{ADSB_SBS_PORT}',), daemon=True)
thread = threading.Thread(target=parse_sbs_stream, args=(f'localhost:{ADSB_SBS_PORT}', 'adsb'), daemon=True)
thread.start() thread.start()
if mlat_sbs_host:
_start_mlat_stream(mlat_sbs_host, mlat_sbs_port)
session = _record_session_start( session = _record_session_start(
device_index=device, device_index=device,
@@ -847,14 +855,14 @@ def start_adsb():
}) })
except Exception as e: except Exception as e:
# Release device on failure # Release device on failure
app_module.release_sdr_device(device_int, sdr_type.value) app_module.release_sdr_device(device_int)
return jsonify({'status': 'error', 'message': str(e)}) return jsonify({'status': 'error', 'message': str(e)})
@adsb_bp.route('/stop', methods=['POST']) @adsb_bp.route('/stop', methods=['POST'])
def stop_adsb(): def stop_adsb():
"""Stop ADS-B tracking.""" """Stop ADS-B tracking."""
global adsb_using_service, adsb_active_device, adsb_active_sdr_type global adsb_using_service, adsb_active_device
data = request.json or {} data = request.json or {}
stop_source = data.get('source') stop_source = data.get('source')
stopped_by = request.remote_addr stopped_by = request.remote_addr
@@ -874,16 +882,15 @@ def stop_adsb():
except (ProcessLookupError, OSError): except (ProcessLookupError, OSError):
pass pass
app_module.adsb_process = None app_module.adsb_process = None
clear_dump1090_pid()
logger.info("ADS-B process stopped") logger.info("ADS-B process stopped")
# Release device from registry # Release device from registry
if adsb_active_device is not None: if adsb_active_device is not None:
app_module.release_sdr_device(adsb_active_device, adsb_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(adsb_active_device)
adsb_using_service = False adsb_using_service = False
adsb_active_device = None adsb_active_device = None
adsb_active_sdr_type = None
_reset_adsb_state()
app_module.adsb_aircraft.clear() app_module.adsb_aircraft.clear()
_looked_up_icaos.clear() _looked_up_icaos.clear()
@@ -894,23 +901,39 @@ def stop_adsb():
@adsb_bp.route('/stream') @adsb_bp.route('/stream')
def stream_adsb(): def stream_adsb():
"""SSE stream for ADS-B aircraft.""" """SSE stream for ADS-B aircraft."""
client_queue: queue.Queue = queue.Queue(maxsize=_ADSB_STREAM_CLIENT_QUEUE_SIZE)
with _adsb_stream_subscribers_lock:
_adsb_stream_subscribers.add(client_queue)
# Prime new clients with current known aircraft so they don't wait for the
# next positional update before rendering.
for snapshot in list(app_module.adsb_aircraft.values()):
try:
client_queue.put_nowait({'type': 'aircraft', **snapshot})
except queue.Full:
break
def generate(): def generate():
last_keepalive = time.time() last_keepalive = time.time()
while True: try:
try: while True:
msg = app_module.adsb_queue.get(timeout=SSE_QUEUE_TIMEOUT)
last_keepalive = time.time()
try: try:
process_event('adsb', msg, msg.get('type')) msg = client_queue.get(timeout=SSE_QUEUE_TIMEOUT)
except Exception: last_keepalive = time.time()
pass try:
yield format_sse(msg) process_event('adsb', msg, msg.get('type'))
except queue.Empty: except Exception:
now = time.time() pass
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL: yield format_sse(msg)
yield format_sse({'type': 'keepalive'}) except queue.Empty:
last_keepalive = now now = time.time()
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
finally:
with _adsb_stream_subscribers_lock:
_adsb_stream_subscribers.discard(client_queue)
response = Response(generate(), mimetype='text/event-stream') response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache' response.headers['Cache-Control'] = 'no-cache'
@@ -925,7 +948,6 @@ def adsb_dashboard():
'adsb_dashboard.html', 'adsb_dashboard.html',
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED, shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
adsb_auto_start=ADSB_AUTO_START, adsb_auto_start=ADSB_AUTO_START,
adsb_mlat_enabled=ADSB_MLAT_ENABLED,
) )
@@ -1154,3 +1176,17 @@ def aircraft_photo(registration: str):
except Exception as e: except Exception as e:
logger.debug(f"Error fetching aircraft photo: {e}") logger.debug(f"Error fetching aircraft photo: {e}")
return jsonify({'success': False, 'error': str(e)}), 500 return jsonify({'success': False, 'error': str(e)}), 500
@adsb_bp.route('/aircraft/<icao>/messages')
def get_aircraft_messages(icao: str):
"""Get correlated ACARS/VDL2 messages for an aircraft."""
if not icao or not all(c in '0123456789ABCDEFabcdef' for c in icao):
return jsonify({'status': 'error', 'message': 'Invalid ICAO'}), 400
aircraft = app_module.adsb_aircraft.get(icao.upper())
callsign = aircraft.get('callsign') if aircraft else None
from utils.flight_correlator import get_flight_correlator
messages = get_flight_correlator().get_messages_for_aircraft(icao=icao.upper(), callsign=callsign)
return jsonify({'status': 'success', 'icao': icao.upper(), **messages})
+60 -28
View File
@@ -18,7 +18,7 @@ import app as app_module
from config import SHARED_OBSERVER_LOCATION_ENABLED from config import SHARED_OBSERVER_LOCATION_ENABLED
from utils.logging import get_logger from utils.logging import get_logger
from utils.validation import validate_device_index, validate_gain from utils.validation import validate_device_index, validate_gain
from utils.sse import format_sse from utils.sse import sse_stream_fanout
from utils.event_pipeline import process_event from utils.event_pipeline import process_event
from utils.sdr import SDRFactory, SDRType from utils.sdr import SDRFactory, SDRType
from utils.constants import ( from utils.constants import (
@@ -44,7 +44,6 @@ ais_connected = False
ais_messages_received = 0 ais_messages_received = 0
ais_last_message_time = None ais_last_message_time = None
ais_active_device = None ais_active_device = None
ais_active_sdr_type = None
_ais_error_logged = True _ais_error_logged = True
# Common installation paths for AIS-catcher # Common installation paths for AIS-catcher
@@ -125,13 +124,27 @@ def parse_ais_stream(port: int):
if now - last_update >= AIS_UPDATE_INTERVAL: if now - last_update >= AIS_UPDATE_INTERVAL:
for mmsi in pending_updates: for mmsi in pending_updates:
if mmsi in app_module.ais_vessels: if mmsi in app_module.ais_vessels:
_vessel_snap = app_module.ais_vessels[mmsi]
try: try:
app_module.ais_queue.put_nowait({ app_module.ais_queue.put_nowait({
'type': 'vessel', 'type': 'vessel',
**app_module.ais_vessels[mmsi] **_vessel_snap
}) })
except queue.Full: except queue.Full:
pass pass
# Geofence check
_v_lat = _vessel_snap.get('lat')
_v_lon = _vessel_snap.get('lon')
if _v_lat and _v_lon:
try:
from utils.geofence import get_geofence_manager
for _gf_evt in get_geofence_manager().check_position(
mmsi, 'vessel', _v_lat, _v_lon,
{'name': _vessel_snap.get('name'), 'ship_type': _vessel_snap.get('ship_type_text')}
):
process_event('ais', _gf_evt, 'geofence')
except Exception:
pass
pending_updates.clear() pending_updates.clear()
last_update = now last_update = now
@@ -283,6 +296,16 @@ def process_ais_message(msg: dict) -> dict | None:
# Timestamp # Timestamp
vessel['last_seen'] = time.time() vessel['last_seen'] = time.time()
# Check for DSC DISTRESS matching this MMSI
try:
for _dsc_key, _dsc_msg in app_module.dsc_messages.items():
if (str(_dsc_msg.get('source_mmsi', '')) == mmsi
and _dsc_msg.get('category', '').upper() == 'DISTRESS'):
vessel['dsc_distress'] = True
break
except Exception:
pass
return vessel return vessel
@@ -327,7 +350,7 @@ def ais_status():
@ais_bp.route('/start', methods=['POST']) @ais_bp.route('/start', methods=['POST'])
def start_ais(): def start_ais():
"""Start AIS tracking.""" """Start AIS tracking."""
global ais_running, ais_active_device, ais_active_sdr_type global ais_running, ais_active_device
with app_module.ais_lock: with app_module.ais_lock:
if ais_running: if ais_running:
@@ -374,7 +397,7 @@ def start_ais():
# Check if device is available # Check if device is available
device_int = int(device) device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'ais', sdr_type.value) error = app_module.claim_sdr_device(device_int, 'ais')
if error: if error:
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
@@ -413,7 +436,7 @@ def start_ais():
if app_module.ais_process.poll() is not None: if app_module.ais_process.poll() is not None:
# Release device on failure # Release device on failure
app_module.release_sdr_device(device_int, sdr_type.value) app_module.release_sdr_device(device_int)
stderr_output = '' stderr_output = ''
if app_module.ais_process.stderr: if app_module.ais_process.stderr:
try: try:
@@ -427,7 +450,6 @@ def start_ais():
ais_running = True ais_running = True
ais_active_device = device ais_active_device = device
ais_active_sdr_type = sdr_type.value
# Start TCP parser thread # Start TCP parser thread
thread = threading.Thread(target=parse_ais_stream, args=(tcp_port,), daemon=True) thread = threading.Thread(target=parse_ais_stream, args=(tcp_port,), daemon=True)
@@ -441,7 +463,7 @@ def start_ais():
}) })
except Exception as e: except Exception as e:
# Release device on failure # Release device on failure
app_module.release_sdr_device(device_int, sdr_type.value) app_module.release_sdr_device(device_int)
logger.error(f"Failed to start AIS-catcher: {e}") logger.error(f"Failed to start AIS-catcher: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500 return jsonify({'status': 'error', 'message': str(e)}), 500
@@ -468,11 +490,10 @@ def stop_ais():
# Release device from registry # Release device from registry
if ais_active_device is not None: if ais_active_device is not None:
app_module.release_sdr_device(ais_active_device, ais_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(ais_active_device)
ais_running = False ais_running = False
ais_active_device = None ais_active_device = None
ais_active_sdr_type = None
app_module.ais_vessels.clear() app_module.ais_vessels.clear()
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
@@ -481,30 +502,41 @@ def stop_ais():
@ais_bp.route('/stream') @ais_bp.route('/stream')
def stream_ais(): def stream_ais():
"""SSE stream for AIS vessels.""" """SSE stream for AIS vessels."""
def generate() -> Generator[str, None, None]: def _on_msg(msg: dict[str, Any]) -> None:
last_keepalive = time.time() process_event('ais', msg, msg.get('type'))
while True: response = Response(
try: sse_stream_fanout(
msg = app_module.ais_queue.get(timeout=SSE_QUEUE_TIMEOUT) source_queue=app_module.ais_queue,
last_keepalive = time.time() channel_key='ais',
try: timeout=SSE_QUEUE_TIMEOUT,
process_event('ais', msg, msg.get('type')) keepalive_interval=SSE_KEEPALIVE_INTERVAL,
except Exception: on_message=_on_msg,
pass ),
yield format_sse(msg) mimetype='text/event-stream',
except queue.Empty: )
now = time.time()
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache' response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no' response.headers['X-Accel-Buffering'] = 'no'
return response return response
@ais_bp.route('/vessel/<mmsi>/dsc')
def get_vessel_dsc(mmsi: str):
"""Get DSC messages associated with a vessel MMSI."""
if not mmsi or not mmsi.isdigit():
return jsonify({'status': 'error', 'message': 'Invalid MMSI'}), 400
matches = []
try:
for key, msg in app_module.dsc_messages.items():
if str(msg.get('source_mmsi', '')) == mmsi:
matches.append(dict(msg))
except Exception:
pass
return jsonify({'status': 'success', 'mmsi': mmsi, 'dsc_messages': matches})
@ais_bp.route('/dashboard') @ais_bp.route('/dashboard')
def ais_dashboard(): def ais_dashboard():
"""Popout AIS dashboard.""" """Popout AIS dashboard."""
+528
View File
@@ -0,0 +1,528 @@
"""Analytics dashboard: cross-mode summary, activity sparklines, export, geofence CRUD."""
from __future__ import annotations
import csv
import io
import json
from datetime import datetime, timezone
from typing import Any
from flask import Blueprint, Response, jsonify, request
import app as app_module
from utils.analytics import (
get_activity_tracker,
get_cross_mode_summary,
get_emergency_squawks,
get_mode_health,
)
from utils.alerts import get_alert_manager
from utils.flight_correlator import get_flight_correlator
from utils.geofence import get_geofence_manager
from utils.temporal_patterns import get_pattern_detector
analytics_bp = Blueprint('analytics', __name__, url_prefix='/analytics')
# Map mode names to DataStore attribute(s)
MODE_STORES: dict[str, list[str]] = {
'adsb': ['adsb_aircraft'],
'ais': ['ais_vessels'],
'wifi': ['wifi_networks', 'wifi_clients'],
'bluetooth': ['bt_devices'],
'dsc': ['dsc_messages'],
}
@analytics_bp.route('/summary')
def analytics_summary():
"""Return cross-mode counts, health, and emergency squawks."""
return jsonify({
'status': 'success',
'counts': get_cross_mode_summary(),
'health': get_mode_health(),
'squawks': get_emergency_squawks(),
'flight_messages': {
'acars': get_flight_correlator().acars_count,
'vdl2': get_flight_correlator().vdl2_count,
},
})
@analytics_bp.route('/activity')
def analytics_activity():
"""Return sparkline arrays for each mode."""
tracker = get_activity_tracker()
return jsonify({
'status': 'success',
'sparklines': tracker.get_all_sparklines(),
})
@analytics_bp.route('/squawks')
def analytics_squawks():
"""Return current emergency squawk codes from ADS-B."""
return jsonify({
'status': 'success',
'squawks': get_emergency_squawks(),
})
@analytics_bp.route('/patterns')
def analytics_patterns():
"""Return detected temporal patterns."""
return jsonify({
'status': 'success',
'patterns': get_pattern_detector().get_all_patterns(),
})
@analytics_bp.route('/target')
def analytics_target():
"""Search entities across multiple modes for a target-centric view."""
query = (request.args.get('q') or '').strip()
requested_limit = request.args.get('limit', default=120, type=int) or 120
limit = max(1, min(500, requested_limit))
if not query:
return jsonify({
'status': 'success',
'query': '',
'results': [],
'mode_counts': {},
})
needle = query.lower()
results: list[dict[str, Any]] = []
mode_counts: dict[str, int] = {}
def push(mode: str, entity_id: str, title: str, subtitle: str, last_seen: str | None = None) -> None:
if len(results) >= limit:
return
results.append({
'mode': mode,
'id': entity_id,
'title': title,
'subtitle': subtitle,
'last_seen': last_seen,
})
mode_counts[mode] = mode_counts.get(mode, 0) + 1
# ADS-B
for icao, aircraft in app_module.adsb_aircraft.items():
if not isinstance(aircraft, dict):
continue
fields = [
icao,
aircraft.get('icao'),
aircraft.get('hex'),
aircraft.get('callsign'),
aircraft.get('registration'),
aircraft.get('flight'),
]
if not _matches_query(needle, fields):
continue
title = str(aircraft.get('callsign') or icao or 'Aircraft').strip()
subtitle = f"ICAO {aircraft.get('icao') or icao} | Alt {aircraft.get('altitude', '--')} | Speed {aircraft.get('speed', '--')}"
push('adsb', str(icao), title, subtitle, aircraft.get('lastSeen') or aircraft.get('last_seen'))
if len(results) >= limit:
break
# AIS
if len(results) < limit:
for mmsi, vessel in app_module.ais_vessels.items():
if not isinstance(vessel, dict):
continue
fields = [
mmsi,
vessel.get('mmsi'),
vessel.get('name'),
vessel.get('shipname'),
vessel.get('callsign'),
vessel.get('imo'),
]
if not _matches_query(needle, fields):
continue
vessel_name = vessel.get('name') or vessel.get('shipname') or mmsi or 'Vessel'
subtitle = f"MMSI {vessel.get('mmsi') or mmsi} | Type {vessel.get('ship_type') or vessel.get('type') or '--'}"
push('ais', str(mmsi), str(vessel_name), subtitle, vessel.get('lastSeen') or vessel.get('last_seen'))
if len(results) >= limit:
break
# WiFi networks and clients
if len(results) < limit:
for bssid, net in app_module.wifi_networks.items():
if not isinstance(net, dict):
continue
fields = [bssid, net.get('bssid'), net.get('ssid'), net.get('vendor')]
if not _matches_query(needle, fields):
continue
title = str(net.get('ssid') or net.get('bssid') or bssid or 'WiFi Network')
subtitle = f"BSSID {net.get('bssid') or bssid} | CH {net.get('channel', '--')} | RSSI {net.get('signal', '--')}"
push('wifi', str(bssid), title, subtitle, net.get('lastSeen') or net.get('last_seen'))
if len(results) >= limit:
break
if len(results) < limit:
for client_mac, client in app_module.wifi_clients.items():
if not isinstance(client, dict):
continue
fields = [client_mac, client.get('mac'), client.get('bssid'), client.get('ssid'), client.get('vendor')]
if not _matches_query(needle, fields):
continue
title = str(client.get('mac') or client_mac or 'WiFi Client')
subtitle = f"BSSID {client.get('bssid') or '--'} | Probe {client.get('ssid') or '--'}"
push('wifi', str(client_mac), title, subtitle, client.get('lastSeen') or client.get('last_seen'))
if len(results) >= limit:
break
# Bluetooth
if len(results) < limit:
for address, dev in app_module.bt_devices.items():
if not isinstance(dev, dict):
continue
fields = [
address,
dev.get('address'),
dev.get('mac'),
dev.get('name'),
dev.get('manufacturer'),
dev.get('vendor'),
]
if not _matches_query(needle, fields):
continue
title = str(dev.get('name') or dev.get('address') or address or 'Bluetooth Device')
subtitle = f"MAC {dev.get('address') or address} | RSSI {dev.get('rssi', '--')} | Vendor {dev.get('manufacturer') or dev.get('vendor') or '--'}"
push('bluetooth', str(address), title, subtitle, dev.get('lastSeen') or dev.get('last_seen'))
if len(results) >= limit:
break
# DSC recent messages
if len(results) < limit:
for msg_id, msg in app_module.dsc_messages.items():
if not isinstance(msg, dict):
continue
fields = [
msg_id,
msg.get('mmsi'),
msg.get('from_mmsi'),
msg.get('to_mmsi'),
msg.get('from_callsign'),
msg.get('to_callsign'),
msg.get('category'),
]
if not _matches_query(needle, fields):
continue
title = str(msg.get('from_mmsi') or msg.get('mmsi') or msg_id or 'DSC Message')
subtitle = f"To {msg.get('to_mmsi') or '--'} | Cat {msg.get('category') or '--'} | Freq {msg.get('frequency') or '--'}"
push('dsc', str(msg_id), title, subtitle, msg.get('timestamp') or msg.get('lastSeen') or msg.get('last_seen'))
if len(results) >= limit:
break
return jsonify({
'status': 'success',
'query': query,
'results': results,
'mode_counts': mode_counts,
})
@analytics_bp.route('/insights')
def analytics_insights():
"""Return actionable insight cards and top changes."""
counts = get_cross_mode_summary()
tracker = get_activity_tracker()
sparklines = tracker.get_all_sparklines()
squawks = get_emergency_squawks()
patterns = get_pattern_detector().get_all_patterns()
alerts = get_alert_manager().list_events(limit=120)
top_changes = _compute_mode_changes(sparklines)
busiest_mode, busiest_count = _get_busiest_mode(counts)
critical_1h = _count_recent_alerts(alerts, severities={'critical', 'high'}, max_age_seconds=3600)
recurring_emitters = sum(1 for p in patterns if float(p.get('confidence') or 0.0) >= 0.7)
cards = []
if top_changes:
lead = top_changes[0]
direction = 'up' if lead['delta'] >= 0 else 'down'
cards.append({
'id': 'fastest_change',
'title': 'Fastest Change',
'value': f"{lead['mode_label']} ({lead['signed_delta']})",
'label': 'last window vs prior',
'severity': 'high' if lead['delta'] > 0 else 'low',
'detail': f"Traffic is trending {direction} in {lead['mode_label']}.",
})
else:
cards.append({
'id': 'fastest_change',
'title': 'Fastest Change',
'value': 'Insufficient data',
'label': 'wait for activity history',
'severity': 'low',
'detail': 'Sparklines need more samples to score momentum.',
})
cards.append({
'id': 'busiest_mode',
'title': 'Busiest Mode',
'value': f"{busiest_mode} ({busiest_count})",
'label': 'current observed entities',
'severity': 'medium' if busiest_count > 0 else 'low',
'detail': 'Highest live entity count across monitoring modes.',
})
cards.append({
'id': 'critical_alerts',
'title': 'Critical Alerts (1h)',
'value': str(critical_1h),
'label': 'critical/high severities',
'severity': 'critical' if critical_1h > 0 else 'low',
'detail': 'Prioritize triage if this count is non-zero.',
})
cards.append({
'id': 'emergency_squawks',
'title': 'Emergency Squawks',
'value': str(len(squawks)),
'label': 'active ADS-B emergency codes',
'severity': 'critical' if squawks else 'low',
'detail': 'Immediate aviation anomalies currently visible.',
})
cards.append({
'id': 'recurring_emitters',
'title': 'Recurring Emitters',
'value': str(recurring_emitters),
'label': 'pattern confidence >= 0.70',
'severity': 'medium' if recurring_emitters > 0 else 'low',
'detail': 'Potentially stationary or periodic emitters detected.',
})
return jsonify({
'status': 'success',
'generated_at': datetime.now(timezone.utc).isoformat(),
'cards': cards,
'top_changes': top_changes[:5],
})
def _compute_mode_changes(sparklines: dict[str, list[int]]) -> list[dict]:
mode_labels = {
'adsb': 'ADS-B',
'ais': 'AIS',
'wifi': 'WiFi',
'bluetooth': 'Bluetooth',
'dsc': 'DSC',
'acars': 'ACARS',
'vdl2': 'VDL2',
'aprs': 'APRS',
'meshtastic': 'Meshtastic',
}
rows = []
for mode, samples in (sparklines or {}).items():
if not isinstance(samples, list) or len(samples) < 4:
continue
window = max(2, min(12, len(samples) // 2))
recent = samples[-window:]
previous = samples[-(window * 2):-window]
if not previous:
continue
recent_avg = sum(recent) / len(recent)
prev_avg = sum(previous) / len(previous)
delta = round(recent_avg - prev_avg, 1)
rows.append({
'mode': mode,
'mode_label': mode_labels.get(mode, mode.upper()),
'delta': delta,
'signed_delta': ('+' if delta >= 0 else '') + str(delta),
'recent_avg': round(recent_avg, 1),
'previous_avg': round(prev_avg, 1),
'direction': 'up' if delta > 0 else ('down' if delta < 0 else 'flat'),
})
rows.sort(key=lambda r: abs(r['delta']), reverse=True)
return rows
def _matches_query(needle: str, values: list[Any]) -> bool:
for value in values:
if value is None:
continue
if needle in str(value).lower():
return True
return False
def _count_recent_alerts(alerts: list[dict], severities: set[str], max_age_seconds: int) -> int:
now = datetime.now(timezone.utc)
count = 0
for event in alerts:
sev = str(event.get('severity') or '').lower()
if sev not in severities:
continue
created_raw = event.get('created_at')
if not created_raw:
continue
try:
created = datetime.fromisoformat(str(created_raw).replace('Z', '+00:00'))
except ValueError:
continue
if created.tzinfo is None:
created = created.replace(tzinfo=timezone.utc)
age = (now - created).total_seconds()
if 0 <= age <= max_age_seconds:
count += 1
return count
def _get_busiest_mode(counts: dict[str, int]) -> tuple[str, int]:
mode_labels = {
'adsb': 'ADS-B',
'ais': 'AIS',
'wifi': 'WiFi',
'bluetooth': 'Bluetooth',
'dsc': 'DSC',
'acars': 'ACARS',
'vdl2': 'VDL2',
'aprs': 'APRS',
'meshtastic': 'Meshtastic',
}
filtered = {k: int(v or 0) for k, v in (counts or {}).items() if k in mode_labels}
if not filtered:
return ('None', 0)
mode = max(filtered, key=filtered.get)
return (mode_labels.get(mode, mode.upper()), filtered[mode])
@analytics_bp.route('/export/<mode>')
def analytics_export(mode: str):
"""Export current DataStore contents as JSON or CSV."""
fmt = request.args.get('format', 'json').lower()
if mode == 'sensor':
# Sensor doesn't use DataStore; return recent queue-based data
return jsonify({'status': 'success', 'data': [], 'message': 'Sensor data is stream-only'})
store_names = MODE_STORES.get(mode)
if not store_names:
return jsonify({'status': 'error', 'message': f'Unknown mode: {mode}'}), 400
all_items: list[dict] = []
# Try v2 scanners first for wifi/bluetooth
if mode == 'wifi':
try:
from utils.wifi.scanner import _scanner_instance as wifi_scanner
if wifi_scanner is not None:
for ap in wifi_scanner.access_points:
all_items.append(ap.to_dict())
for client in wifi_scanner.clients:
item = client.to_dict()
item['_store'] = 'wifi_clients'
all_items.append(item)
except Exception:
pass
elif mode == 'bluetooth':
try:
from utils.bluetooth.scanner import _scanner_instance as bt_scanner
if bt_scanner is not None:
for dev in bt_scanner.get_devices():
all_items.append(dev.to_dict())
except Exception:
pass
# Fall back to legacy DataStores if v2 scanners yielded nothing
if not all_items:
for store_name in store_names:
store = getattr(app_module, store_name, None)
if store is None:
continue
for key, value in store.items():
item = dict(value) if isinstance(value, dict) else {'id': key, 'value': value}
item.setdefault('_store', store_name)
all_items.append(item)
if fmt == 'csv':
if not all_items:
output = ''
else:
# Collect all keys across items
fieldnames: list[str] = []
seen: set[str] = set()
for item in all_items:
for k in item:
if k not in seen:
fieldnames.append(k)
seen.add(k)
buf = io.StringIO()
writer = csv.DictWriter(buf, fieldnames=fieldnames, extrasaction='ignore')
writer.writeheader()
for item in all_items:
# Serialize non-scalar values
row = {}
for k in fieldnames:
v = item.get(k)
if isinstance(v, (dict, list)):
row[k] = json.dumps(v)
else:
row[k] = v
writer.writerow(row)
output = buf.getvalue()
response = Response(output, mimetype='text/csv')
response.headers['Content-Disposition'] = f'attachment; filename={mode}_export.csv'
return response
# Default: JSON
return jsonify({'status': 'success', 'mode': mode, 'count': len(all_items), 'data': all_items})
# =========================================================================
# Geofence CRUD
# =========================================================================
@analytics_bp.route('/geofences')
def list_geofences():
return jsonify({
'status': 'success',
'zones': get_geofence_manager().list_zones(),
})
@analytics_bp.route('/geofences', methods=['POST'])
def create_geofence():
data = request.get_json() or {}
name = data.get('name')
lat = data.get('lat')
lon = data.get('lon')
radius_m = data.get('radius_m')
if not all([name, lat is not None, lon is not None, radius_m is not None]):
return jsonify({'status': 'error', 'message': 'name, lat, lon, radius_m are required'}), 400
try:
lat = float(lat)
lon = float(lon)
radius_m = float(radius_m)
except (TypeError, ValueError):
return jsonify({'status': 'error', 'message': 'lat, lon, radius_m must be numbers'}), 400
if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
return jsonify({'status': 'error', 'message': 'Invalid coordinates'}), 400
if radius_m <= 0:
return jsonify({'status': 'error', 'message': 'radius_m must be positive'}), 400
alert_on = data.get('alert_on', 'enter_exit')
zone_id = get_geofence_manager().add_zone(name, lat, lon, radius_m, alert_on)
return jsonify({'status': 'success', 'zone_id': zone_id})
@analytics_bp.route('/geofences/<int:zone_id>', methods=['DELETE'])
def delete_geofence(zone_id: int):
ok = get_geofence_manager().delete_zone(zone_id)
if not ok:
return jsonify({'status': 'error', 'message': 'Zone not found'}), 404
return jsonify({'status': 'success'})
+87 -56
View File
@@ -21,8 +21,9 @@ from flask import Blueprint, jsonify, request, Response
import app as app_module import app as app_module
from utils.logging import sensor_logger as logger from utils.logging import sensor_logger as logger
from utils.validation import validate_device_index, validate_gain, validate_ppm from utils.validation import validate_device_index, validate_gain, validate_ppm
from utils.sse import format_sse from utils.sse import sse_stream_fanout
from utils.event_pipeline import process_event from utils.event_pipeline import process_event
from utils.sdr import SDRFactory, SDRType
from utils.constants import ( from utils.constants import (
PROCESS_TERMINATE_TIMEOUT, PROCESS_TERMINATE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL, SSE_KEEPALIVE_INTERVAL,
@@ -46,6 +47,8 @@ APRS_FREQUENCIES = {
'brazil': '145.570', 'brazil': '145.570',
'japan': '144.640', 'japan': '144.640',
'china': '144.640', 'china': '144.640',
'iss': '145.825',
'sonate2': '145.825',
} }
# Statistics # Statistics
@@ -77,6 +80,11 @@ def find_rtl_fm() -> Optional[str]:
return shutil.which('rtl_fm') return shutil.which('rtl_fm')
def find_rx_fm() -> Optional[str]:
"""Find SoapySDR rx_fm binary."""
return shutil.which('rx_fm')
def find_rtl_power() -> Optional[str]: def find_rtl_power() -> Optional[str]:
"""Find rtl_power binary for spectrum scanning.""" """Find rtl_power binary for spectrum scanning."""
return shutil.which('rtl_power') return shutil.which('rtl_power')
@@ -1372,6 +1380,19 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
'last_seen': packet.get('timestamp'), 'last_seen': packet.get('timestamp'),
'packet_type': packet.get('packet_type'), '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 # Evict oldest stations when limit is exceeded
if len(aprs_stations) > APRS_MAX_STATIONS: if len(aprs_stations) > APRS_MAX_STATIONS:
oldest = min( oldest = min(
@@ -1417,14 +1438,17 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
def check_aprs_tools() -> Response: def check_aprs_tools() -> Response:
"""Check for APRS decoding tools.""" """Check for APRS decoding tools."""
has_rtl_fm = find_rtl_fm() is not None has_rtl_fm = find_rtl_fm() is not None
has_rx_fm = find_rx_fm() is not None
has_direwolf = find_direwolf() is not None has_direwolf = find_direwolf() is not None
has_multimon = find_multimon_ng() is not None has_multimon = find_multimon_ng() is not None
has_fm_demod = has_rtl_fm or has_rx_fm
return jsonify({ return jsonify({
'rtl_fm': has_rtl_fm, 'rtl_fm': has_rtl_fm,
'rx_fm': has_rx_fm,
'direwolf': has_direwolf, 'direwolf': has_direwolf,
'multimon_ng': has_multimon, 'multimon_ng': has_multimon,
'ready': has_rtl_fm and (has_direwolf or has_multimon), 'ready': has_fm_demod and (has_direwolf or has_multimon),
'decoder': 'direwolf' if has_direwolf else ('multimon-ng' if has_multimon else None) 'decoder': 'direwolf' if has_direwolf else ('multimon-ng' if has_multimon else None)
}) })
@@ -1467,14 +1491,6 @@ def start_aprs() -> Response:
'message': 'APRS decoder already running' 'message': 'APRS decoder already running'
}), 409 }), 409
# Check for required tools
rtl_fm_path = find_rtl_fm()
if not rtl_fm_path:
return jsonify({
'status': 'error',
'message': 'rtl_fm not found. Install with: sudo apt install rtl-sdr'
}), 400
# Check for decoder (prefer direwolf, fallback to multimon-ng) # Check for decoder (prefer direwolf, fallback to multimon-ng)
direwolf_path = find_direwolf() direwolf_path = find_direwolf()
multimon_path = find_multimon_ng() multimon_path = find_multimon_ng()
@@ -1495,6 +1511,25 @@ def start_aprs() -> Response:
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return jsonify({'status': 'error', 'message': str(e)}), 400
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 sdr_type == SDRType.RTL_SDR:
if find_rtl_fm() is None:
return jsonify({
'status': 'error',
'message': 'rtl_fm not found. Install with: sudo apt install rtl-sdr'
}), 400
else:
if find_rx_fm() is None:
return jsonify({
'status': 'error',
'message': f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.'
}), 400
# Reserve SDR device to prevent conflicts with other modes # 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')
if error: if error:
@@ -1525,28 +1560,29 @@ def start_aprs() -> Response:
aprs_last_packet_time = None aprs_last_packet_time = None
aprs_stations = {} aprs_stations = {}
# Build rtl_fm command for APRS (narrowband FM at 22050 Hz for AFSK1200) # Build FM demod command for APRS (AFSK1200 @ 22050 Hz) via SDR abstraction.
freq_hz = f"{float(frequency)}M" try:
rtl_cmd = [ sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
rtl_fm_path, builder = SDRFactory.get_builder(sdr_type)
'-f', freq_hz, rtl_cmd = builder.build_fm_demod_command(
'-M', 'nfm', # Narrowband FM for APRS device=sdr_device,
'-s', '22050', # Sample rate matching direwolf -r 22050 frequency_mhz=float(frequency),
'-E', 'dc', # Enable DC blocking filter for cleaner audio sample_rate=22050,
'-A', 'fast', # Fast AGC for packet bursts gain=float(gain) if gain and str(gain) != '0' else None,
'-d', str(device), ppm=int(ppm) if ppm and str(ppm) != '0' else None,
] modulation='nfm' if sdr_type == SDRType.RTL_SDR else 'fm',
squelch=None,
bias_t=bool(data.get('bias_t', False)),
)
# Gain: 0 means auto, otherwise set specific gain if sdr_type == SDRType.RTL_SDR and rtl_cmd and rtl_cmd[-1] == '-':
if gain and str(gain) != '0': # APRS benefits from DC blocking + fast AGC on rtl_fm.
rtl_cmd.extend(['-g', str(gain)]) rtl_cmd = rtl_cmd[:-1] + ['-E', 'dc', '-A', 'fast', '-']
except Exception as e:
# PPM frequency correction if aprs_active_device is not None:
if ppm and str(ppm) != '0': app_module.release_sdr_device(aprs_active_device)
rtl_cmd.extend(['-p', str(ppm)]) aprs_active_device = None
return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500
# Output raw audio to stdout
rtl_cmd.append('-')
# Build decoder command # Build decoder command
if direwolf_path: if direwolf_path:
@@ -1674,6 +1710,7 @@ def start_aprs() -> Response:
'frequency': frequency, 'frequency': frequency,
'region': region, 'region': region,
'device': device, 'device': device,
'sdr_type': sdr_type.value,
'decoder': decoder_name 'decoder': decoder_name
}) })
@@ -1726,31 +1763,25 @@ def stop_aprs() -> Response:
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
@aprs_bp.route('/stream') @aprs_bp.route('/stream')
def stream_aprs() -> Response: def stream_aprs() -> Response:
"""SSE stream for APRS packets.""" """SSE stream for APRS packets."""
def generate() -> Generator[str, None, None]: def _on_msg(msg: dict[str, Any]) -> None:
last_keepalive = time.time() process_event('aprs', msg, msg.get('type'))
while True: response = Response(
try: sse_stream_fanout(
msg = app_module.aprs_queue.get(timeout=SSE_QUEUE_TIMEOUT) source_queue=app_module.aprs_queue,
last_keepalive = time.time() channel_key='aprs',
try: timeout=SSE_QUEUE_TIMEOUT,
process_event('aprs', msg, msg.get('type')) keepalive_interval=SSE_KEEPALIVE_INTERVAL,
except Exception: on_message=_on_msg,
pass ),
yield format_sse(msg) mimetype='text/event-stream',
except queue.Empty: )
now = time.time() response.headers['Cache-Control'] = 'no-cache'
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL: response.headers['X-Accel-Buffering'] = 'no'
yield format_sse({'type': 'keepalive'}) return response
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
return response
@aprs_bp.route('/frequencies') @aprs_bp.route('/frequencies')
+19 -13
View File
@@ -37,11 +37,17 @@ def find_rtl_fm():
return shutil.which('rtl_fm') return shutil.which('rtl_fm')
def find_ffmpeg(): def find_ffmpeg():
return shutil.which('ffmpeg') return shutil.which('ffmpeg')
def kill_audio_processes(): def _rtl_fm_demod_mode(modulation):
"""Map UI modulation names to rtl_fm demod tokens."""
mod = str(modulation or '').lower().strip()
return 'wbfm' if mod == 'wfm' else mod
def kill_audio_processes():
"""Kill any running audio processes.""" """Kill any running audio processes."""
global audio_process, rtl_process global audio_process, rtl_process
@@ -104,14 +110,14 @@ def start_audio_stream(config):
freq_hz = int(freq * 1e6) freq_hz = int(freq * 1e6)
rtl_cmd = [ rtl_cmd = [
rtl_fm, rtl_fm,
'-M', mod, '-M', _rtl_fm_demod_mode(mod),
'-f', str(freq_hz), '-f', str(freq_hz),
'-s', str(sample_rate), '-s', str(sample_rate),
'-r', str(resample_rate), '-r', str(resample_rate),
'-g', str(gain), '-g', str(gain),
'-d', str(device), '-d', str(device),
'-l', str(squelch), '-l', str(squelch),
] ]
+20 -27
View File
@@ -20,7 +20,7 @@ from flask import Blueprint, jsonify, request, Response
import app as app_module import app as app_module
from utils.dependencies import check_tool from utils.dependencies import check_tool
from utils.logging import bluetooth_logger as logger from utils.logging import bluetooth_logger as logger
from utils.sse import format_sse from utils.sse import sse_stream_fanout
from utils.event_pipeline import process_event from utils.event_pipeline import process_event
from utils.validation import validate_bluetooth_interface from utils.validation import validate_bluetooth_interface
from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer
@@ -553,30 +553,23 @@ def get_bt_devices():
}) })
@bluetooth_bp.route('/stream') @bluetooth_bp.route('/stream')
def stream_bt(): def stream_bt():
"""SSE stream for Bluetooth events.""" """SSE stream for Bluetooth events."""
def generate(): def _on_msg(msg: dict[str, Any]) -> None:
last_keepalive = time.time() process_event('bluetooth', msg, msg.get('type'))
keepalive_interval = 30.0
response = Response(
while True: sse_stream_fanout(
try: source_queue=app_module.bt_queue,
msg = app_module.bt_queue.get(timeout=1) channel_key='bluetooth',
last_keepalive = time.time() timeout=1.0,
try: keepalive_interval=30.0,
process_event('bluetooth', msg, msg.get('type')) on_message=_on_msg,
except Exception: ),
pass mimetype='text/event-stream',
yield format_sse(msg) )
except queue.Empty: response.headers['Cache-Control'] = 'no-cache'
now = time.time() response.headers['X-Accel-Buffering'] = 'no'
if now - last_keepalive >= keepalive_interval: response.headers['Connection'] = 'keep-alive'
yield format_sse({'type': 'keepalive'})
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response return response
+178 -178
View File
@@ -7,40 +7,40 @@ aggregation, and heuristics.
from __future__ import annotations from __future__ import annotations
import csv import csv
import io import io
import json import json
import logging import logging
import threading import threading
import time import time
from datetime import datetime from datetime import datetime
from typing import Generator from typing import Generator
from flask import Blueprint, Response, jsonify, request, session from flask import Blueprint, Response, jsonify, request, session
from utils.bluetooth import ( from utils.bluetooth import (
BluetoothScanner, BluetoothScanner,
BTDeviceAggregate, BTDeviceAggregate,
get_bluetooth_scanner, get_bluetooth_scanner,
check_capabilities, check_capabilities,
RANGE_UNKNOWN, RANGE_UNKNOWN,
TrackerType, TrackerType,
TrackerConfidence, TrackerConfidence,
get_tracker_engine, get_tracker_engine,
) )
from utils.database import get_db from utils.database import get_db
from utils.sse import format_sse from utils.sse import format_sse
from utils.event_pipeline import process_event from utils.event_pipeline import process_event
logger = logging.getLogger('intercept.bluetooth_v2') logger = logging.getLogger('intercept.bluetooth_v2')
# Blueprint # Blueprint
bluetooth_v2_bp = Blueprint('bluetooth_v2', __name__, url_prefix='/api/bluetooth') bluetooth_v2_bp = Blueprint('bluetooth_v2', __name__, url_prefix='/api/bluetooth')
# Seen-before tracking # Seen-before tracking
_bt_seen_cache: set[str] = set() _bt_seen_cache: set[str] = set()
_bt_session_seen: set[str] = set() _bt_session_seen: set[str] = set()
_bt_seen_lock = threading.Lock() _bt_seen_lock = threading.Lock()
# ============================================================================= # =============================================================================
# DATABASE FUNCTIONS # DATABASE FUNCTIONS
@@ -172,20 +172,20 @@ def get_all_baselines() -> list[dict]:
return [dict(row) for row in cursor] return [dict(row) for row in cursor]
def save_observation_history(device: BTDeviceAggregate) -> None: def save_observation_history(device: BTDeviceAggregate) -> None:
"""Save device observation to history.""" """Save device observation to history."""
with get_db() as conn: with get_db() as conn:
conn.execute(''' conn.execute('''
INSERT INTO bt_observation_history (device_id, rssi, seen_count) INSERT INTO bt_observation_history (device_id, rssi, seen_count)
VALUES (?, ?, ?) VALUES (?, ?, ?)
''', (device.device_id, device.rssi_current, device.seen_count)) ''', (device.device_id, device.rssi_current, device.seen_count))
def load_seen_device_ids() -> set[str]: def load_seen_device_ids() -> set[str]:
"""Load distinct device IDs from history for seen-before tracking.""" """Load distinct device IDs from history for seen-before tracking."""
with get_db() as conn: with get_db() as conn:
cursor = conn.execute('SELECT DISTINCT device_id FROM bt_observation_history') cursor = conn.execute('SELECT DISTINCT device_id FROM bt_observation_history')
return {row['device_id'] for row in cursor} return {row['device_id'] for row in cursor}
# ============================================================================= # =============================================================================
@@ -206,7 +206,7 @@ def get_capabilities():
@bluetooth_v2_bp.route('/scan/start', methods=['POST']) @bluetooth_v2_bp.route('/scan/start', methods=['POST'])
def start_scan(): def start_scan():
""" """
Start Bluetooth scanning. Start Bluetooth scanning.
@@ -229,49 +229,49 @@ def start_scan():
rssi_threshold = data.get('rssi_threshold', -100) rssi_threshold = data.get('rssi_threshold', -100)
# Validate mode # Validate mode
valid_modes = ('auto', 'dbus', 'bleak', 'hcitool', 'bluetoothctl') valid_modes = ('auto', 'dbus', 'bleak', 'hcitool', 'bluetoothctl', 'ubertooth')
if mode not in valid_modes: if mode not in valid_modes:
return jsonify({'error': f'Invalid mode. Must be one of: {valid_modes}'}), 400 return jsonify({'error': f'Invalid mode. Must be one of: {valid_modes}'}), 400
# Get scanner instance # Get scanner instance
scanner = get_bluetooth_scanner(adapter_id) scanner = get_bluetooth_scanner(adapter_id)
# Initialize database tables if needed # Initialize database tables if needed
init_bt_tables() init_bt_tables()
def _handle_seen_before(device: BTDeviceAggregate) -> None: def _handle_seen_before(device: BTDeviceAggregate) -> None:
try: try:
with _bt_seen_lock: with _bt_seen_lock:
device.seen_before = device.device_id in _bt_seen_cache device.seen_before = device.device_id in _bt_seen_cache
if device.device_id not in _bt_session_seen: if device.device_id not in _bt_session_seen:
save_observation_history(device) save_observation_history(device)
_bt_session_seen.add(device.device_id) _bt_session_seen.add(device.device_id)
except Exception as e: except Exception as e:
logger.debug(f"BT seen-before update failed: {e}") logger.debug(f"BT seen-before update failed: {e}")
# Setup seen-before callback # Setup seen-before callback
if scanner._on_device_updated is None: if _handle_seen_before not in scanner._on_device_updated_callbacks:
scanner._on_device_updated = _handle_seen_before scanner.add_device_callback(_handle_seen_before)
# Ensure cache is initialized # Ensure cache is initialized
with _bt_seen_lock: with _bt_seen_lock:
if not _bt_seen_cache: if not _bt_seen_cache:
_bt_seen_cache.update(load_seen_device_ids()) _bt_seen_cache.update(load_seen_device_ids())
# Check if already scanning # Check if already scanning
if scanner.is_scanning: if scanner.is_scanning:
return jsonify({ return jsonify({
'status': 'already_running', 'status': 'already_running',
'scan_status': scanner.get_status().to_dict() 'scan_status': scanner.get_status().to_dict()
}) })
# Refresh seen-before cache and reset session set for a new scan # Refresh seen-before cache and reset session set for a new scan
with _bt_seen_lock: with _bt_seen_lock:
_bt_seen_cache.clear() _bt_seen_cache.clear()
_bt_seen_cache.update(load_seen_device_ids()) _bt_seen_cache.update(load_seen_device_ids())
_bt_session_seen.clear() _bt_session_seen.clear()
# Load active baseline if exists # Load active baseline if exists
baseline_id = get_active_baseline_id() baseline_id = get_active_baseline_id()
if baseline_id: if baseline_id:
device_ids = get_baseline_device_ids(baseline_id) device_ids = get_baseline_device_ids(baseline_id)
@@ -896,15 +896,15 @@ def stream_events():
else: else:
return event_type, event return event_type, event
def event_generator() -> Generator[str, None, None]: def event_generator() -> Generator[str, None, None]:
"""Generate SSE events from scanner.""" """Generate SSE events from scanner."""
for event in scanner.stream_events(timeout=1.0): for event in scanner.stream_events(timeout=1.0):
event_name, event_data = map_event_type(event) event_name, event_data = map_event_type(event)
try: try:
process_event('bluetooth', event_data, event_name) process_event('bluetooth', event_data, event_name)
except Exception: except Exception:
pass pass
yield format_sse(event_data, event=event_name) yield format_sse(event_data, event=event_name)
return Response( return Response(
event_generator(), event_generator(),
@@ -988,34 +988,34 @@ def get_tscm_bluetooth_snapshot(duration: int = 8) -> list[dict]:
devices = scanner.get_devices() devices = scanner.get_devices()
logger.info(f"TSCM snapshot: get_devices() returned {len(devices)} devices") logger.info(f"TSCM snapshot: get_devices() returned {len(devices)} devices")
# Convert to TSCM format with tracker detection data # Convert to TSCM format with tracker detection data
tscm_devices = [] tscm_devices = []
for device in devices: for device in devices:
manufacturer_name = device.manufacturer_name manufacturer_name = device.manufacturer_name
if (not manufacturer_name) or str(manufacturer_name).lower().startswith('unknown'): if (not manufacturer_name) or str(manufacturer_name).lower().startswith('unknown'):
if device.address and not device.is_randomized_mac: if device.address and not device.is_randomized_mac:
try: try:
from data.oui import get_manufacturer from data.oui import get_manufacturer
oui_vendor = get_manufacturer(device.address) oui_vendor = get_manufacturer(device.address)
if oui_vendor and oui_vendor != 'Unknown': if oui_vendor and oui_vendor != 'Unknown':
manufacturer_name = oui_vendor manufacturer_name = oui_vendor
except Exception: except Exception:
pass pass
device_data = { device_data = {
'mac': device.address, 'mac': device.address,
'address_type': device.address_type, 'address_type': device.address_type,
'device_key': device.device_key, 'device_key': device.device_key,
'name': device.name or 'Unknown', 'name': device.name or 'Unknown',
'rssi': device.rssi_current or -100, 'rssi': device.rssi_current or -100,
'rssi_median': device.rssi_median, 'rssi_median': device.rssi_median,
'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None, 'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None,
'type': _classify_device_type(device), 'type': _classify_device_type(device),
'manufacturer': manufacturer_name, 'manufacturer': manufacturer_name,
'manufacturer_id': device.manufacturer_id, 'manufacturer_id': device.manufacturer_id,
'manufacturer_data': device.manufacturer_bytes.hex() if device.manufacturer_bytes else None, 'manufacturer_data': device.manufacturer_bytes.hex() if device.manufacturer_bytes else None,
'protocol': device.protocol, 'protocol': device.protocol,
'first_seen': device.first_seen.isoformat(), 'first_seen': device.first_seen.isoformat(),
'last_seen': device.last_seen.isoformat(), 'last_seen': device.last_seen.isoformat(),
'seen_count': device.seen_count, 'seen_count': device.seen_count,
'range_band': device.range_band, 'range_band': device.range_band,
@@ -1229,38 +1229,38 @@ def get_device_timeseries(device_key: str):
return jsonify(result) return jsonify(result)
def _classify_device_type(device: BTDeviceAggregate) -> str: def _classify_device_type(device: BTDeviceAggregate) -> str:
"""Classify device type from available data.""" """Classify device type from available data."""
name_lower = (device.name or '').lower() name_lower = (device.name or '').lower()
manufacturer_lower = (device.manufacturer_name or '').lower() manufacturer_lower = (device.manufacturer_name or '').lower()
service_uuids = device.service_uuids or [] service_uuids = device.service_uuids or []
if (not manufacturer_lower) or manufacturer_lower.startswith('unknown'): if (not manufacturer_lower) or manufacturer_lower.startswith('unknown'):
if device.address and not device.is_randomized_mac: if device.address and not device.is_randomized_mac:
try: try:
from data.oui import get_manufacturer from data.oui import get_manufacturer
oui_vendor = get_manufacturer(device.address) oui_vendor = get_manufacturer(device.address)
if oui_vendor and oui_vendor != 'Unknown': if oui_vendor and oui_vendor != 'Unknown':
manufacturer_lower = oui_vendor.lower() manufacturer_lower = oui_vendor.lower()
except Exception: except Exception:
pass pass
def normalize_uuid(uuid: str) -> str: def normalize_uuid(uuid: str) -> str:
if not uuid: if not uuid:
return '' return ''
value = str(uuid).lower().strip() value = str(uuid).lower().strip()
if value.startswith('0x'): if value.startswith('0x'):
value = value[2:] value = value[2:]
# Bluetooth Base UUID normalization (16-bit UUIDs) # Bluetooth Base UUID normalization (16-bit UUIDs)
if value.endswith('-0000-1000-8000-00805f9b34fb') and len(value) >= 8: if value.endswith('-0000-1000-8000-00805f9b34fb') and len(value) >= 8:
return value[4:8] return value[4:8]
if len(value) == 4: if len(value) == 4:
return value return value
return value return value
# Check by name patterns # Check by name patterns
if any(x in name_lower for x in ['airpods', 'headphone', 'earbuds', 'buds', 'beats']): if any(x in name_lower for x in ['airpods', 'headphone', 'earbuds', 'buds', 'beats']):
return 'audio' return 'audio'
if any(x in name_lower for x in ['watch', 'band', 'fitbit', 'garmin']): if any(x in name_lower for x in ['watch', 'band', 'fitbit', 'garmin']):
return 'wearable' return 'wearable'
if any(x in name_lower for x in ['iphone', 'pixel', 'galaxy', 'phone']): if any(x in name_lower for x in ['iphone', 'pixel', 'galaxy', 'phone']):
@@ -1269,41 +1269,41 @@ def _classify_device_type(device: BTDeviceAggregate) -> str:
return 'computer' return 'computer'
if any(x in name_lower for x in ['mouse', 'keyboard', 'trackpad']): if any(x in name_lower for x in ['mouse', 'keyboard', 'trackpad']):
return 'peripheral' return 'peripheral'
if any(x in name_lower for x in ['tile', 'airtag', 'smarttag', 'chipolo']): if any(x in name_lower for x in ['tile', 'airtag', 'smarttag', 'chipolo']):
return 'tracker' return 'tracker'
if any(x in name_lower for x in ['speaker', 'sonos', 'echo', 'home']): if any(x in name_lower for x in ['speaker', 'sonos', 'echo', 'home']):
return 'speaker' return 'speaker'
if any(x in name_lower for x in ['tv', 'chromecast', 'roku', 'firestick']): if any(x in name_lower for x in ['tv', 'chromecast', 'roku', 'firestick']):
return 'media' return 'media'
# Tracker signals (metadata or Find My service) # Tracker signals (metadata or Find My service)
if getattr(device, 'is_tracker', False) or getattr(device, 'tracker_type', None): if getattr(device, 'is_tracker', False) or getattr(device, 'tracker_type', None):
return 'tracker' return 'tracker'
normalized_uuids = {normalize_uuid(u) for u in service_uuids if u} normalized_uuids = {normalize_uuid(u) for u in service_uuids if u}
if 'fd6f' in normalized_uuids: if 'fd6f' in normalized_uuids:
return 'tracker' return 'tracker'
# Service UUIDs (GATT / classic) # Service UUIDs (GATT / classic)
audio_uuids = {'110b', '110a', '111e', '111f', '1108', '1203'} audio_uuids = {'110b', '110a', '111e', '111f', '1108', '1203'}
wearable_uuids = {'180d', '1814', '1816'} wearable_uuids = {'180d', '1814', '1816'}
hid_uuids = {'1812'} hid_uuids = {'1812'}
beacon_uuids = {'feaa', 'feab', 'feb1', 'febe'} beacon_uuids = {'feaa', 'feab', 'feb1', 'febe'}
if normalized_uuids & audio_uuids: if normalized_uuids & audio_uuids:
return 'audio' return 'audio'
if normalized_uuids & hid_uuids: if normalized_uuids & hid_uuids:
return 'peripheral' return 'peripheral'
if normalized_uuids & wearable_uuids: if normalized_uuids & wearable_uuids:
return 'wearable' return 'wearable'
if normalized_uuids & beacon_uuids: if normalized_uuids & beacon_uuids:
return 'beacon' return 'beacon'
# Check by manufacturer # Check by manufacturer
if 'apple' in manufacturer_lower: if 'apple' in manufacturer_lower:
return 'apple_device' return 'apple_device'
if 'samsung' in manufacturer_lower: if 'samsung' in manufacturer_lower:
return 'samsung_device' return 'samsung_device'
# Check by class of device # Check by class of device
if device.major_class: if device.major_class:
+300
View File
@@ -0,0 +1,300 @@
"""
BT Locate Bluetooth SAR Device Location Flask Blueprint.
Provides endpoints for managing locate sessions, streaming detection events,
and retrieving GPS-tagged signal trails.
"""
from __future__ import annotations
import logging
from collections.abc import Generator
from flask import Blueprint, Response, jsonify, request
from utils.bluetooth.irk_extractor import get_paired_irks
from utils.bt_locate import (
Environment,
LocateTarget,
get_locate_session,
resolve_rpa,
start_locate_session,
stop_locate_session,
)
from utils.sse import format_sse
logger = logging.getLogger('intercept.bt_locate')
bt_locate_bp = Blueprint('bt_locate', __name__, url_prefix='/bt_locate')
@bt_locate_bp.route('/start', methods=['POST'])
def start_session():
"""
Start a locate session.
Request JSON:
- mac_address: Target MAC address (optional)
- name_pattern: Target name substring (optional)
- irk_hex: Identity Resolving Key hex string (optional)
- device_id: Device ID from Bluetooth scanner (optional)
- device_key: Stable device key from Bluetooth scanner (optional)
- fingerprint_id: Payload fingerprint ID from Bluetooth scanner (optional)
- known_name: Hand-off device name (optional)
- known_manufacturer: Hand-off manufacturer (optional)
- last_known_rssi: Hand-off last RSSI (optional)
- environment: 'FREE_SPACE', 'OUTDOOR', 'INDOOR', 'CUSTOM' (default: OUTDOOR)
- custom_exponent: Path loss exponent for CUSTOM environment (optional)
Returns:
JSON with session status.
"""
data = request.get_json() or {}
# Build target
target = LocateTarget(
mac_address=data.get('mac_address'),
name_pattern=data.get('name_pattern'),
irk_hex=data.get('irk_hex'),
device_id=data.get('device_id'),
device_key=data.get('device_key'),
fingerprint_id=data.get('fingerprint_id'),
known_name=data.get('known_name'),
known_manufacturer=data.get('known_manufacturer'),
last_known_rssi=data.get('last_known_rssi'),
)
# At least one identifier required
if not any([
target.mac_address,
target.name_pattern,
target.irk_hex,
target.device_id,
target.device_key,
target.fingerprint_id,
]):
return jsonify({
'error': (
'At least one target identifier required '
'(mac_address, name_pattern, irk_hex, device_id, device_key, or fingerprint_id)'
)
}), 400
# Parse environment
env_str = data.get('environment', 'OUTDOOR').upper()
try:
environment = Environment[env_str]
except KeyError:
return jsonify({'error': f'Invalid environment: {env_str}'}), 400
custom_exponent = data.get('custom_exponent')
if custom_exponent is not None:
try:
custom_exponent = float(custom_exponent)
except (ValueError, TypeError):
return jsonify({'error': 'custom_exponent must be a number'}), 400
# Fallback coordinates when GPS is unavailable (from user settings)
fallback_lat = None
fallback_lon = None
if data.get('fallback_lat') is not None and data.get('fallback_lon') is not None:
try:
fallback_lat = float(data['fallback_lat'])
fallback_lon = float(data['fallback_lon'])
except (ValueError, TypeError):
pass
logger.info(
f"Starting locate session: target={target.to_dict()}, "
f"env={environment.name}, fallback=({fallback_lat}, {fallback_lon})"
)
session = start_locate_session(
target, environment, custom_exponent, fallback_lat, fallback_lon
)
return jsonify({
'status': 'started',
'session': session.get_status(),
})
@bt_locate_bp.route('/stop', methods=['POST'])
def stop_session():
"""Stop the active locate session."""
session = get_locate_session()
if not session:
return jsonify({'status': 'no_session'})
stop_locate_session()
return jsonify({'status': 'stopped'})
@bt_locate_bp.route('/status', methods=['GET'])
def get_status():
"""Get locate session status."""
session = get_locate_session()
if not session:
return jsonify({
'active': False,
'target': None,
})
return jsonify(session.get_status())
@bt_locate_bp.route('/trail', methods=['GET'])
def get_trail():
"""Get detection trail data."""
session = get_locate_session()
if not session:
return jsonify({'trail': [], 'gps_trail': []})
return jsonify({
'trail': session.get_trail(),
'gps_trail': session.get_gps_trail(),
})
@bt_locate_bp.route('/stream', methods=['GET'])
def stream_detections():
"""SSE stream of detection events."""
def event_generator() -> Generator[str, None, None]:
while True:
# Re-fetch session each iteration in case it changes
s = get_locate_session()
if not s:
yield format_sse({'type': 'session_ended'}, event='session_ended')
return
try:
event = s.event_queue.get(timeout=2.0)
yield format_sse(event, event='detection')
except Exception:
yield format_sse({}, event='ping')
return Response(
event_generator(),
mimetype='text/event-stream',
headers={
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no',
}
)
@bt_locate_bp.route('/resolve_rpa', methods=['POST'])
def test_resolve_rpa():
"""
Test if an IRK resolves to a given address.
Request JSON:
- irk_hex: 16-byte IRK as hex string
- address: BLE address string
Returns:
JSON with resolution result.
"""
data = request.get_json() or {}
irk_hex = data.get('irk_hex', '')
address = data.get('address', '')
if not irk_hex or not address:
return jsonify({'error': 'irk_hex and address are required'}), 400
try:
irk = bytes.fromhex(irk_hex)
except ValueError:
return jsonify({'error': 'Invalid IRK hex string'}), 400
if len(irk) != 16:
return jsonify({'error': 'IRK must be exactly 16 bytes (32 hex characters)'}), 400
result = resolve_rpa(irk, address)
return jsonify({
'resolved': result,
'irk_hex': irk_hex,
'address': address,
})
@bt_locate_bp.route('/environment', methods=['POST'])
def set_environment():
"""Update the environment on the active session."""
session = get_locate_session()
if not session:
return jsonify({'error': 'no active session'}), 400
data = request.get_json() or {}
env_str = data.get('environment', '').upper()
try:
environment = Environment[env_str]
except KeyError:
return jsonify({'error': f'Invalid environment: {env_str}'}), 400
custom_exponent = data.get('custom_exponent')
if custom_exponent is not None:
try:
custom_exponent = float(custom_exponent)
except (ValueError, TypeError):
custom_exponent = None
session.set_environment(environment, custom_exponent)
return jsonify({
'status': 'updated',
'environment': environment.name,
'path_loss_exponent': session.estimator.n,
})
@bt_locate_bp.route('/debug', methods=['GET'])
def debug_matching():
"""Debug endpoint showing scanner devices and match results."""
session = get_locate_session()
if not session:
return jsonify({'error': 'no session'})
scanner = session._scanner
if not scanner:
return jsonify({'error': 'no scanner'})
devices = scanner.get_devices(max_age_seconds=30)
return jsonify({
'target': session.target.to_dict(),
'device_count': len(devices),
'devices': [
{
'device_id': d.device_id,
'address': d.address,
'name': d.name,
'rssi': d.rssi_current,
'matches': session.target.matches(d),
}
for d in devices
],
})
@bt_locate_bp.route('/paired_irks', methods=['GET'])
def paired_irks():
"""Return paired Bluetooth devices that have IRKs."""
try:
devices = get_paired_irks()
except Exception as e:
logger.exception("Failed to read paired IRKs")
return jsonify({'devices': [], 'error': str(e)})
return jsonify({'devices': devices})
@bt_locate_bp.route('/clear_trail', methods=['POST'])
def clear_trail():
"""Clear the detection trail."""
session = get_locate_session()
if not session:
return jsonify({'status': 'no_session'})
session.clear_trail()
return jsonify({'status': 'cleared'})
+56 -32
View File
@@ -13,6 +13,7 @@ from __future__ import annotations
import json import json
import logging import logging
import queue import queue
import threading
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Generator from typing import Generator
@@ -36,10 +37,28 @@ from utils.trilateration import (
logger = logging.getLogger('intercept.controller') logger = logging.getLogger('intercept.controller')
controller_bp = Blueprint('controller', __name__, url_prefix='/controller') controller_bp = Blueprint('controller', __name__, url_prefix='/controller')
# Multi-agent data queue for combined SSE stream # Multi-agent SSE fanout state (per-client queues).
agent_data_queue: queue.Queue = queue.Queue(maxsize=1000) _agent_stream_subscribers: set[queue.Queue] = set()
_agent_stream_subscribers_lock = threading.Lock()
_AGENT_STREAM_CLIENT_QUEUE_SIZE = 500
def _broadcast_agent_data(payload: dict) -> None:
"""Fan out an ingested payload to all active /controller/stream/all clients."""
with _agent_stream_subscribers_lock:
subscribers = tuple(_agent_stream_subscribers)
for subscriber in subscribers:
try:
subscriber.put_nowait(payload)
except queue.Full:
try:
subscriber.get_nowait()
subscriber.put_nowait(payload)
except (queue.Empty, queue.Full):
continue
# ============================================================================= # =============================================================================
@@ -625,19 +644,16 @@ def ingest_push_data():
received_at=data.get('received_at') received_at=data.get('received_at')
) )
# Emit to SSE stream # Emit to SSE stream (fanout to all connected clients)
try: _broadcast_agent_data({
agent_data_queue.put_nowait({ 'type': 'agent_data',
'type': 'agent_data', 'agent_id': agent['id'],
'agent_id': agent['id'], 'agent_name': agent_name,
'agent_name': agent_name, 'scan_type': data.get('scan_type'),
'scan_type': data.get('scan_type'), 'interface': data.get('interface'),
'interface': data.get('interface'), 'payload': data.get('payload'),
'payload': data.get('payload'), 'received_at': data.get('received_at') or datetime.now(timezone.utc).isoformat()
'received_at': data.get('received_at') or datetime.now(timezone.utc).isoformat() })
})
except queue.Full:
logger.warning("Agent data queue full, data may be lost")
return jsonify({ return jsonify({
'status': 'accepted', 'status': 'accepted',
@@ -674,27 +690,35 @@ def get_payloads():
# ============================================================================= # =============================================================================
@controller_bp.route('/stream/all') @controller_bp.route('/stream/all')
def stream_all_agents(): def stream_all_agents():
""" """
Combined SSE stream for data from all agents. Combined SSE stream for data from all agents.
This endpoint streams push data as it arrives from agents. This endpoint streams push data as it arrives from agents.
Each message is tagged with agent_id and agent_name. Each message is tagged with agent_id and agent_name.
""" """
def generate() -> Generator[str, None, None]: client_queue: queue.Queue = queue.Queue(maxsize=_AGENT_STREAM_CLIENT_QUEUE_SIZE)
last_keepalive = time.time() with _agent_stream_subscribers_lock:
keepalive_interval = 30.0 _agent_stream_subscribers.add(client_queue)
while True: def generate() -> Generator[str, None, None]:
try: last_keepalive = time.time()
msg = agent_data_queue.get(timeout=1.0) keepalive_interval = 30.0
last_keepalive = time.time()
yield format_sse(msg) try:
except queue.Empty: while True:
now = time.time() try:
if now - last_keepalive >= keepalive_interval: msg = client_queue.get(timeout=1.0)
yield format_sse({'type': 'keepalive'}) last_keepalive = time.time()
last_keepalive = now yield format_sse(msg)
except queue.Empty:
now = time.time()
if now - last_keepalive >= keepalive_interval:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
finally:
with _agent_stream_subscribers_lock:
_agent_stream_subscribers.discard(client_queue)
response = Response(generate(), mimetype='text/event-stream') response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache' response.headers['Cache-Control'] = 'no-cache'
+153 -255
View File
@@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
import os import os
import json
import queue import queue
import re import re
import select import select
@@ -18,10 +17,11 @@ from flask import Blueprint, jsonify, request, Response
import app as app_module import app as app_module
from utils.logging import get_logger from utils.logging import get_logger
from utils.sse import format_sse from utils.sse import sse_stream_fanout
from utils.event_pipeline import process_event from utils.event_pipeline import process_event
from utils.process import register_process, unregister_process from utils.process import register_process, unregister_process
from utils.validation import validate_frequency, validate_gain, validate_device_index, validate_ppm from utils.validation import validate_frequency, validate_gain, validate_device_index, validate_ppm
from utils.sdr import SDRFactory, SDRType
from utils.constants import ( from utils.constants import (
SSE_QUEUE_TIMEOUT, SSE_QUEUE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL, SSE_KEEPALIVE_INTERVAL,
@@ -45,14 +45,14 @@ dmr_lock = threading.Lock()
dmr_queue: queue.Queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) dmr_queue: queue.Queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
dmr_active_device: Optional[int] = None dmr_active_device: Optional[int] = None
# Audio mux: the sole reader of dsd-fme stdout. Writes to an ffmpeg # Audio mux: the sole reader of dsd-fme stdout. Fans out bytes to all
# stdin when a streaming client is connected, discards otherwise. # active ffmpeg stdin sinks when streaming clients are connected.
# This prevents dsd-fme from blocking on stdout (which would also # This prevents dsd-fme from blocking on stdout (which would also
# freeze stderr / text data output). # freeze stderr / text data output).
_active_ffmpeg_stdin: Optional[object] = None # set by stream endpoint _ffmpeg_sinks: set[object] = set()
_ffmpeg_sinks_lock = threading.Lock()
VALID_PROTOCOLS = ['auto', 'dmr', 'p25', 'p25p2', 'nxdn', 'dstar', 'provoice'] VALID_PROTOCOLS = ['auto', 'dmr', 'p25', 'nxdn', 'dstar', 'provoice']
VALID_DEMODS = ['nfm', 'fm']
# Classic dsd flags # Classic dsd flags
_DSD_PROTOCOL_FLAGS = { _DSD_PROTOCOL_FLAGS = {
@@ -70,10 +70,9 @@ _DSD_PROTOCOL_FLAGS = {
# -fi = NXDN48 (NOT D-Star), -f1 = P25 Phase 1, # -fi = NXDN48 (NOT D-Star), -f1 = P25 Phase 1,
# -ft = XDMA multi-protocol decoder # -ft = XDMA multi-protocol decoder
_DSD_FME_PROTOCOL_FLAGS = { _DSD_FME_PROTOCOL_FLAGS = {
'auto': ['-ft'], # XDMA: auto-detect DMR/P25/YSF 'auto': ['-fa'], # Broad auto: P25 (P1/P2), DMR, D-STAR, YSF, X2-TDMA
'dmr': ['-fs'], # DMR Simplex (-fd is D-STAR in dsd-fme!) 'dmr': ['-fs'], # DMR Simplex (-fd is D-STAR in dsd-fme!)
'p25': ['-f1'], # P25 Phase 1 (-fp is ProVoice in dsd-fme!) 'p25': ['-ft'], # P25 P1/P2 coverage (also includes DMR in dsd-fme)
'p25p2': ['-f2'], # P25 Phase 2
'nxdn': ['-fn'], # NXDN96 'nxdn': ['-fn'], # NXDN96
'dstar': ['-fd'], # D-STAR (-fd in dsd-fme, NOT DMR!) 'dstar': ['-fd'], # D-STAR (-fd in dsd-fme, NOT DMR!)
'provoice': ['-fp'], # ProVoice (-fp in dsd-fme, not -fv) 'provoice': ['-fp'], # ProVoice (-fp in dsd-fme, not -fv)
@@ -83,8 +82,6 @@ _DSD_FME_PROTOCOL_FLAGS = {
# sync reliability vs letting dsd-fme auto-detect modulation type. # sync reliability vs letting dsd-fme auto-detect modulation type.
_DSD_FME_MODULATION = { _DSD_FME_MODULATION = {
'dmr': ['-mc'], # C4FM 'dmr': ['-mc'], # C4FM
'p25': ['-mc'], # C4FM (Phase 1)
'p25p2': ['-mq'], # CQPSK (Phase 2)
'nxdn': ['-mc'], # C4FM 'nxdn': ['-mc'], # C4FM
} }
@@ -113,83 +110,16 @@ def find_rtl_fm() -> str | None:
return shutil.which('rtl_fm') return shutil.which('rtl_fm')
def find_rx_fm() -> str | None:
"""Find SoapySDR rx_fm binary."""
return shutil.which('rx_fm')
def find_ffmpeg() -> str | None: def find_ffmpeg() -> str | None:
"""Find ffmpeg for audio encoding.""" """Find ffmpeg for audio encoding."""
return shutil.which('ffmpeg') return shutil.which('ffmpeg')
def _coerce_int(value) -> int | None:
try:
return int(value)
except (TypeError, ValueError):
return None
def _parse_dsd_json(payload: dict, ts: str) -> dict | None:
"""Parse JSON output lines from dsd-fme into events."""
event_type = str(payload.get('type') or payload.get('event') or payload.get('msg') or payload.get('kind') or '').lower()
nested = payload.get('data') if isinstance(payload.get('data'), dict) else {}
def first_of(keys):
for obj in (payload, nested):
for key in keys:
if key in obj and obj[key] is not None:
return obj[key]
return None
talkgroup = _coerce_int(first_of([
'tg', 'tgt', 'talkgroup', 'talk_group', 'tgid',
'group', 'group_id', 'groupId', 'dst', 'dest',
'destination', 'target'
]))
source = _coerce_int(first_of([
'src', 'source', 'src_id', 'source_id', 'sourceId',
'uid', 'unit', 'radio', 'rid', 'radio_id', 'radioId'
]))
slot = _coerce_int(first_of(['slot', 'timeslot', 'time_slot', 'ts']))
nac = first_of(['nac'])
protocol = first_of(['protocol', 'mode', 'system', 'sys', 'network'])
if talkgroup is not None and source is not None:
event = {
'type': 'call',
'talkgroup': talkgroup,
'source_id': source,
'timestamp': ts,
}
if slot is not None:
event['slot'] = slot
if protocol:
event['protocol'] = str(protocol)
return event
if nac is not None:
return {'type': 'nac', 'nac': str(nac), 'timestamp': ts}
if 'sync' in event_type:
return {
'type': 'sync',
'protocol': str(protocol or event_type),
'timestamp': ts,
}
voice_flag = first_of(['voice', 'voice_frame', 'voiceFrame'])
if 'voice' in event_type or voice_flag is True:
event = {
'type': 'voice',
'detail': str(first_of(['detail', 'text']) or event_type or 'voice'),
'timestamp': ts,
}
if slot is not None:
event['slot'] = slot
return event
if protocol:
return {'type': 'sync', 'protocol': str(protocol), 'timestamp': ts}
return None
def parse_dsd_output(line: str) -> dict | None: def parse_dsd_output(line: str) -> dict | None:
"""Parse a line of DSD stderr output into a structured event. """Parse a line of DSD stderr output into a structured event.
@@ -200,46 +130,6 @@ def parse_dsd_output(line: str) -> dict | None:
if not line: if not line:
return None return None
ts = datetime.now().strftime('%H:%M:%S')
# Frame-level error / OK indicators (useful for quality metrics)
if re.search(r'\bDUID\s+ERR\b', line, re.IGNORECASE):
return {
'type': 'frame_error',
'kind': 'duid',
'detail': line[:200],
'timestamp': ts,
}
if re.search(r'\bR-?S\s+ERR\b', line, re.IGNORECASE):
return {
'type': 'frame_error',
'kind': 'rs',
'detail': line[:200],
'timestamp': ts,
}
if re.search(r'\bP25p2\b.*\b4V\b', line, re.IGNORECASE):
return {
'type': 'frame_ok',
'kind': 'p25p2',
'timestamp': ts,
}
# If dsd-fme is emitting JSON (via -J), parse it first.
if line.startswith('{') and line.endswith('}'):
try:
payload = json.loads(line)
except json.JSONDecodeError:
payload = None
if isinstance(payload, dict):
parsed = _parse_dsd_json(payload, ts)
if parsed:
return parsed
return {
'type': 'raw',
'text': line[:200],
'timestamp': ts,
}
# Skip DSD/dsd-fme startup banner lines (ASCII art, version info, etc.) # Skip DSD/dsd-fme startup banner lines (ASCII art, version info, etc.)
# Only filter lines that are purely decorative — dsd-fme uses box-drawing # Only filter lines that are purely decorative — dsd-fme uses box-drawing
# characters (│, ─) as column separators in DATA lines, so we must not # characters (│, ─) as column separators in DATA lines, so we must not
@@ -250,6 +140,8 @@ def parse_dsd_output(line: str) -> dict | None:
if re.match(r'^\s*(Build Version|MBElib|CODEC2|Audio (Out|In)|Decoding )', line): if re.match(r'^\s*(Build Version|MBElib|CODEC2|Audio (Out|In)|Decoding )', line):
return None return None
ts = datetime.now().strftime('%H:%M:%S')
# Sync detection: "Sync: +DMR (data)" or "Sync: +P25 Phase 1" # Sync detection: "Sync: +DMR (data)" or "Sync: +P25 Phase 1"
sync_match = re.match(r'Sync:\s*\+?(\S+.*)', line) sync_match = re.match(r'Sync:\s*\+?(\S+.*)', line)
if sync_match: if sync_match:
@@ -328,14 +220,66 @@ _HEARTBEAT_INTERVAL = 3.0 # seconds between heartbeats when decoder is idle
_SILENCE_CHUNK = b'\x00' * 1600 _SILENCE_CHUNK = b'\x00' * 1600
def _register_audio_sink(sink: object) -> None:
"""Register an ffmpeg stdin sink for mux fanout."""
with _ffmpeg_sinks_lock:
_ffmpeg_sinks.add(sink)
def _unregister_audio_sink(sink: object) -> None:
"""Remove an ffmpeg stdin sink from mux fanout."""
with _ffmpeg_sinks_lock:
_ffmpeg_sinks.discard(sink)
def _get_audio_sinks() -> tuple[object, ...]:
"""Snapshot current audio sinks for lock-free iteration."""
with _ffmpeg_sinks_lock:
return tuple(_ffmpeg_sinks)
def _stop_process(proc: Optional[subprocess.Popen]) -> None:
"""Terminate and unregister a subprocess if present."""
if not proc:
return
if proc.poll() is None:
try:
proc.terminate()
proc.wait(timeout=2)
except Exception:
try:
proc.kill()
except Exception:
pass
unregister_process(proc)
def _reset_runtime_state(*, release_device: bool) -> None:
"""Reset process + runtime state and optionally release SDR ownership."""
global dmr_rtl_process, dmr_dsd_process
global dmr_running, dmr_has_audio, dmr_active_device
_stop_process(dmr_dsd_process)
_stop_process(dmr_rtl_process)
dmr_rtl_process = None
dmr_dsd_process = None
dmr_running = False
dmr_has_audio = False
with _ffmpeg_sinks_lock:
_ffmpeg_sinks.clear()
if release_device and dmr_active_device is not None:
app_module.release_sdr_device(dmr_active_device)
dmr_active_device = None
def _dsd_audio_mux(dsd_stdout): def _dsd_audio_mux(dsd_stdout):
"""Mux thread: sole reader of dsd-fme stdout. """Mux thread: sole reader of dsd-fme stdout.
Always drains dsd-fme's audio output to prevent the process from Always drains dsd-fme's audio output to prevent the process from
blocking on stdout writes (which would also freeze stderr / text blocking on stdout writes (which would also freeze stderr / text
data). When an audio streaming client is connected, forwards audio data). When streaming clients are connected, forwards data to all
to its ffmpeg stdin with silence fill during voice gaps. When no active ffmpeg stdin sinks with silence fill during voice gaps.
client is connected, simply discards the data.
""" """
try: try:
while dmr_running: while dmr_running:
@@ -344,22 +288,22 @@ def _dsd_audio_mux(dsd_stdout):
data = os.read(dsd_stdout.fileno(), 4096) data = os.read(dsd_stdout.fileno(), 4096)
if not data: if not data:
break break
sink = _active_ffmpeg_stdin sinks = _get_audio_sinks()
if sink: for sink in sinks:
try: try:
sink.write(data) sink.write(data)
sink.flush() sink.flush()
except (BrokenPipeError, OSError, ValueError): except (BrokenPipeError, OSError, ValueError):
pass _unregister_audio_sink(sink)
else: else:
# No audio from decoder — feed silence if client connected # No audio from decoder — feed silence if client connected
sink = _active_ffmpeg_stdin sinks = _get_audio_sinks()
if sink: for sink in sinks:
try: try:
sink.write(_SILENCE_CHUNK) sink.write(_SILENCE_CHUNK)
sink.flush() sink.flush()
except (BrokenPipeError, OSError, ValueError): except (BrokenPipeError, OSError, ValueError):
pass _unregister_audio_sink(sink)
except (OSError, ValueError): except (OSError, ValueError):
pass pass
@@ -430,7 +374,11 @@ def stream_dsd_output(rtl_process: subprocess.Popen, dsd_process: subprocess.Pop
logger.error(f"DSD stream error: {e}") logger.error(f"DSD stream error: {e}")
finally: finally:
global dmr_active_device, dmr_rtl_process, dmr_dsd_process global dmr_active_device, dmr_rtl_process, dmr_dsd_process
global dmr_has_audio
dmr_running = False dmr_running = False
dmr_has_audio = False
with _ffmpeg_sinks_lock:
_ffmpeg_sinks.clear()
# Capture exit info for diagnostics # Capture exit info for diagnostics
rc = dsd_process.poll() rc = dsd_process.poll()
reason = 'stopped' reason = 'stopped'
@@ -445,18 +393,8 @@ def stream_dsd_output(rtl_process: subprocess.Popen, dsd_process: subprocess.Pop
pass pass
logger.warning(f"DSD process exited with code {rc}: {detail}") logger.warning(f"DSD process exited with code {rc}: {detail}")
# Cleanup decoder + demod processes # Cleanup decoder + demod processes
for proc in [dsd_process, rtl_process]: _stop_process(dsd_process)
if proc and proc.poll() is None: _stop_process(rtl_process)
try:
proc.terminate()
proc.wait(timeout=2)
except Exception:
try:
proc.kill()
except Exception:
pass
if proc:
unregister_process(proc)
dmr_rtl_process = None dmr_rtl_process = None
dmr_dsd_process = None dmr_dsd_process = None
_queue_put({'type': 'status', 'text': reason, 'exit_code': rc, 'detail': detail}) _queue_put({'type': 'status', 'text': reason, 'exit_code': rc, 'detail': detail})
@@ -476,12 +414,14 @@ def check_tools() -> Response:
"""Check for required tools.""" """Check for required tools."""
dsd_path, _ = find_dsd() dsd_path, _ = find_dsd()
rtl_fm = find_rtl_fm() rtl_fm = find_rtl_fm()
rx_fm = find_rx_fm()
ffmpeg = find_ffmpeg() ffmpeg = find_ffmpeg()
return jsonify({ return jsonify({
'dsd': dsd_path is not None, 'dsd': dsd_path is not None,
'rtl_fm': rtl_fm is not None, 'rtl_fm': rtl_fm is not None,
'rx_fm': rx_fm is not None,
'ffmpeg': ffmpeg is not None, 'ffmpeg': ffmpeg is not None,
'available': dsd_path is not None and rtl_fm is not None, 'available': dsd_path is not None and (rtl_fm is not None or rx_fm is not None),
'protocols': VALID_PROTOCOLS, 'protocols': VALID_PROTOCOLS,
}) })
@@ -492,18 +432,10 @@ def start_dmr() -> Response:
global dmr_rtl_process, dmr_dsd_process, dmr_thread global dmr_rtl_process, dmr_dsd_process, dmr_thread
global dmr_running, dmr_has_audio, dmr_active_device global dmr_running, dmr_has_audio, dmr_active_device
with dmr_lock:
if dmr_running:
return jsonify({'status': 'error', 'message': 'Already running'}), 409
dsd_path, is_fme = find_dsd() dsd_path, is_fme = find_dsd()
if not dsd_path: if not dsd_path:
return jsonify({'status': 'error', 'message': 'dsd not found. Install dsd-fme or dsd.'}), 503 return jsonify({'status': 'error', 'message': 'dsd not found. Install dsd-fme or dsd.'}), 503
rtl_fm_path = find_rtl_fm()
if not rtl_fm_path:
return jsonify({'status': 'error', 'message': 'rtl_fm not found. Install rtl-sdr tools.'}), 503
data = request.json or {} data = request.json or {}
try: try:
@@ -512,19 +444,27 @@ def start_dmr() -> Response:
device = validate_device_index(data.get('device', 0)) device = validate_device_index(data.get('device', 0))
protocol = str(data.get('protocol', 'auto')).lower() protocol = str(data.get('protocol', 'auto')).lower()
ppm = validate_ppm(data.get('ppm', 0)) ppm = validate_ppm(data.get('ppm', 0))
fine_tune = int(data.get('fineTune', 0) or 0)
demod = str(data.get('demod', 'nfm')).lower()
except (ValueError, TypeError) as e: except (ValueError, TypeError) as e:
return jsonify({'status': 'error', 'message': f'Invalid parameter: {e}'}), 400 return jsonify({'status': 'error', 'message': f'Invalid parameter: {e}'}), 400
sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower()
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
if protocol not in VALID_PROTOCOLS: if protocol not in VALID_PROTOCOLS:
return jsonify({'status': 'error', 'message': f'Invalid protocol. Use: {", ".join(VALID_PROTOCOLS)}'}), 400 return jsonify({'status': 'error', 'message': f'Invalid protocol. Use: {", ".join(VALID_PROTOCOLS)}'}), 400
if demod not in VALID_DEMODS:
return jsonify({'status': 'error', 'message': f'Invalid demod. Use: {", ".join(VALID_DEMODS)}'}), 400 if sdr_type == SDRType.RTL_SDR:
if protocol == 'p25p2' and not is_fme: if not find_rtl_fm():
return jsonify({'status': 'error', 'message': 'P25 Phase 2 requires dsd-fme.'}), 400 return jsonify({'status': 'error', 'message': 'rtl_fm not found. Install rtl-sdr tools.'}), 503
if abs(fine_tune) > 20000: else:
return jsonify({'status': 'error', 'message': 'Fine tune offset too large (max +/- 20000 Hz).'}), 400 if not find_rx_fm():
return jsonify({
'status': 'error',
'message': f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.'
}), 503
# Clear stale queue # Clear stale queue
try: try:
@@ -533,32 +473,45 @@ def start_dmr() -> Response:
except queue.Empty: except queue.Empty:
pass pass
# Reserve running state before we start claiming resources/processes
# so concurrent /start requests cannot race each other.
with dmr_lock:
if dmr_running:
return jsonify({'status': 'error', 'message': 'Already running'}), 409
dmr_running = True
dmr_has_audio = False
# Claim SDR device — use protocol name so the device panel shows # Claim SDR device — use protocol name so the device panel shows
# "D-STAR", "P25", etc. instead of always "DMR" # "D-STAR", "P25", etc. instead of always "DMR"
mode_label = protocol.upper() if protocol != 'auto' else 'DMR' mode_label = protocol.upper() if protocol != 'auto' else 'DMR'
error = app_module.claim_sdr_device(device, mode_label) error = app_module.claim_sdr_device(device, mode_label)
if error: if error:
with dmr_lock:
dmr_running = False
return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409 return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409
dmr_active_device = device dmr_active_device = device
freq_hz = int((frequency * 1e6) + fine_tune) # Build FM demodulation command via SDR abstraction.
try:
# Build rtl_fm command (48kHz sample rate for DSD). sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
# Squelch disabled (-l 0): rtl_fm's squelch chops the bitstream builder = SDRFactory.get_builder(sdr_type)
# mid-frame, destroying DSD sync. The decoder handles silence rtl_cmd = builder.build_fm_demod_command(
# internally via its own frame-sync detection. device=sdr_device,
rtl_cmd = [ frequency_mhz=frequency,
rtl_fm_path, sample_rate=48000,
'-M', demod, gain=float(gain) if gain > 0 else None,
'-f', str(freq_hz), ppm=int(ppm) if ppm != 0 else None,
'-s', '48000', modulation='fm',
'-g', str(gain), squelch=None,
'-d', str(device), bias_t=bool(data.get('bias_t', False)),
'-l', '0', )
] if sdr_type == SDRType.RTL_SDR:
if ppm != 0: # Keep squelch fully open for digital bitstreams.
rtl_cmd.extend(['-p', str(ppm)]) rtl_cmd.extend(['-l', '0'])
except Exception as e:
_reset_runtime_state(release_device=True)
return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500
# Build DSD command # Build DSD command
# Audio output: pipe decoded audio (8kHz s16le PCM) to stdout for # Audio output: pipe decoded audio (8kHz s16le PCM) to stdout for
@@ -606,9 +559,6 @@ def start_dmr() -> Response:
# Allow rtl_fm to send directly to dsd # Allow rtl_fm to send directly to dsd
dmr_rtl_process.stdout.close() dmr_rtl_process.stdout.close()
# Mark running before starting mux so it doesn't exit immediately.
dmr_running = True
# Start mux thread: always drains dsd-fme stdout to prevent the # Start mux thread: always drains dsd-fme stdout to prevent the
# process from blocking (which would freeze stderr / text data). # process from blocking (which would freeze stderr / text data).
# ffmpeg is started lazily per-client in /dmr/audio/stream. # ffmpeg is started lazily per-client in /dmr/audio/stream.
@@ -633,26 +583,8 @@ def start_dmr() -> Response:
if dmr_dsd_process.stderr: if dmr_dsd_process.stderr:
dsd_err = dmr_dsd_process.stderr.read().decode('utf-8', errors='replace')[:500] dsd_err = dmr_dsd_process.stderr.read().decode('utf-8', errors='replace')[:500]
logger.error(f"DSD pipeline died: rtl_fm rc={rtl_rc} err={rtl_err!r}, dsd rc={dsd_rc} err={dsd_err!r}") logger.error(f"DSD pipeline died: rtl_fm rc={rtl_rc} err={rtl_err!r}, dsd rc={dsd_rc} err={dsd_err!r}")
# Terminate surviving processes and unregister all # Terminate surviving processes and release resources.
dmr_running = False _reset_runtime_state(release_device=True)
dmr_has_audio = False
for proc in [dmr_dsd_process, dmr_rtl_process]:
if proc and proc.poll() is None:
try:
proc.terminate()
proc.wait(timeout=2)
except Exception:
try:
proc.kill()
except Exception:
pass
if proc:
unregister_process(proc)
dmr_rtl_process = None
dmr_dsd_process = None
if dmr_active_device is not None:
app_module.release_sdr_device(dmr_active_device)
dmr_active_device = None
# Surface a clear error to the user # Surface a clear error to the user
detail = rtl_err.strip() or dsd_err.strip() detail = rtl_err.strip() or dsd_err.strip()
if 'usb_claim_interface' in rtl_err or 'Failed to open' in rtl_err: if 'usb_claim_interface' in rtl_err or 'Failed to open' in rtl_err:
@@ -684,48 +616,21 @@ def start_dmr() -> Response:
'status': 'started', 'status': 'started',
'frequency': frequency, 'frequency': frequency,
'protocol': protocol, 'protocol': protocol,
'sdr_type': sdr_type.value,
'has_audio': dmr_has_audio, 'has_audio': dmr_has_audio,
}) })
except Exception as e: except Exception as e:
logger.error(f"Failed to start DMR: {e}") logger.error(f"Failed to start DMR: {e}")
dmr_running = False _reset_runtime_state(release_device=True)
dmr_has_audio = False
if dmr_active_device is not None:
app_module.release_sdr_device(dmr_active_device)
dmr_active_device = None
return jsonify({'status': 'error', 'message': str(e)}), 500 return jsonify({'status': 'error', 'message': str(e)}), 500
@dmr_bp.route('/stop', methods=['POST']) @dmr_bp.route('/stop', methods=['POST'])
def stop_dmr() -> Response: def stop_dmr() -> Response:
"""Stop digital voice decoding.""" """Stop digital voice decoding."""
global dmr_rtl_process, dmr_dsd_process
global dmr_running, dmr_has_audio, dmr_active_device
with dmr_lock: with dmr_lock:
dmr_running = False _reset_runtime_state(release_device=True)
dmr_has_audio = False
for proc in [dmr_dsd_process, dmr_rtl_process]:
if proc and proc.poll() is None:
try:
proc.terminate()
proc.wait(timeout=2)
except Exception:
try:
proc.kill()
except Exception:
pass
if proc:
unregister_process(proc)
dmr_rtl_process = None
dmr_dsd_process = None
if dmr_active_device is not None:
app_module.release_sdr_device(dmr_active_device)
dmr_active_device = None
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
@@ -752,8 +657,6 @@ def stream_dmr_audio() -> Response:
it, back-pressuring the entire pipeline and freezing stderr/text it, back-pressuring the entire pipeline and freezing stderr/text
data output). data output).
""" """
global _active_ffmpeg_stdin
if not dmr_running or not dmr_has_audio: if not dmr_running or not dmr_has_audio:
return Response(b'', mimetype='audio/wav', status=204) return Response(b'', mimetype='audio/wav', status=204)
@@ -780,11 +683,10 @@ def stream_dmr_audio() -> Response:
args=(audio_proc,), daemon=True, args=(audio_proc,), daemon=True,
).start() ).start()
# Tell the mux thread to start writing to this ffmpeg if audio_proc.stdin:
_active_ffmpeg_stdin = audio_proc.stdin _register_audio_sink(audio_proc.stdin)
def generate(): def generate():
global _active_ffmpeg_stdin
try: try:
while dmr_running and audio_proc.poll() is None: while dmr_running and audio_proc.poll() is None:
ready, _, _ = select.select([audio_proc.stdout], [], [], 2.0) ready, _, _ = select.select([audio_proc.stdout], [], [], 2.0)
@@ -803,7 +705,8 @@ def stream_dmr_audio() -> Response:
logger.error(f"DMR audio stream error: {e}") logger.error(f"DMR audio stream error: {e}")
finally: finally:
# Disconnect mux → ffmpeg, then clean up # Disconnect mux → ffmpeg, then clean up
_active_ffmpeg_stdin = None if audio_proc.stdin:
_unregister_audio_sink(audio_proc.stdin)
try: try:
audio_proc.stdin.close() audio_proc.stdin.close()
except Exception: except Exception:
@@ -832,24 +735,19 @@ def stream_dmr_audio() -> Response:
@dmr_bp.route('/stream') @dmr_bp.route('/stream')
def stream_dmr() -> Response: def stream_dmr() -> Response:
"""SSE stream for DMR decoder events.""" """SSE stream for DMR decoder events."""
def generate() -> Generator[str, None, None]: def _on_msg(msg: dict[str, Any]) -> None:
last_keepalive = time.time() process_event('dmr', msg, msg.get('type'))
while True:
try:
msg = dmr_queue.get(timeout=SSE_QUEUE_TIMEOUT)
last_keepalive = time.time()
try:
process_event('dmr', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg)
except queue.Empty:
now = time.time()
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream') response = Response(
sse_stream_fanout(
source_queue=dmr_queue,
channel_key='dmr',
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['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no' response.headers['X-Accel-Buffering'] = 'no'
return response return response
+13 -20
View File
@@ -35,7 +35,7 @@ from utils.database import (
get_dsc_alert_summary, get_dsc_alert_summary,
) )
from utils.dsc.parser import parse_dsc_message from utils.dsc.parser import parse_dsc_message
from utils.sse import format_sse from utils.sse import sse_stream_fanout
from utils.event_pipeline import process_event from utils.event_pipeline import process_event
from utils.validation import validate_device_index, validate_gain from utils.validation import validate_device_index, validate_gain
from utils.sdr import SDRFactory, SDRType from utils.sdr import SDRFactory, SDRType
@@ -518,26 +518,19 @@ def stop_decoding() -> Response:
@dsc_bp.route('/stream') @dsc_bp.route('/stream')
def stream() -> Response: def stream() -> Response:
"""SSE stream for real-time DSC messages.""" """SSE stream for real-time DSC messages."""
def generate() -> Generator[str, None, None]: def _on_msg(msg: dict[str, Any]) -> None:
last_keepalive = time.time() process_event('dsc', msg, msg.get('type'))
keepalive_interval = 30.0
while True: response = Response(
try: sse_stream_fanout(
msg = app_module.dsc_queue.get(timeout=1) source_queue=app_module.dsc_queue,
last_keepalive = time.time() channel_key='dsc',
try: timeout=1.0,
process_event('dsc', msg, msg.get('type')) keepalive_interval=30.0,
except Exception: on_message=_on_msg,
pass ),
yield format_sse(msg) mimetype='text/event-stream',
except queue.Empty: )
now = time.time()
if now - last_keepalive >= keepalive_interval:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache' response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no' response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive' response.headers['Connection'] = 'keep-alive'
+108 -48
View File
@@ -4,19 +4,24 @@ from __future__ import annotations
import queue import queue
import time import time
from typing import Generator from collections.abc import Generator
from flask import Blueprint, jsonify, request, Response from flask import Blueprint, Response, jsonify
from utils.logging import get_logger
from utils.sse import format_sse
from utils.gps import ( from utils.gps import (
get_gps_reader,
start_gpsd,
stop_gps,
get_current_position,
GPSPosition, GPSPosition,
GPSSkyData,
detect_gps_devices,
get_current_position,
get_gps_reader,
is_gpsd_running,
start_gpsd,
start_gpsd_daemon,
stop_gps,
stop_gpsd_daemon,
) )
from utils.logging import get_logger
from utils.sse import sse_stream_fanout
logger = get_logger('intercept.gps') logger = get_logger('intercept.gps')
@@ -29,12 +34,24 @@ _gps_queue: queue.Queue = queue.Queue(maxsize=100)
def _position_callback(position: GPSPosition) -> None: def _position_callback(position: GPSPosition) -> None:
"""Callback to queue position updates for SSE stream.""" """Callback to queue position updates for SSE stream."""
try: try:
_gps_queue.put_nowait(position.to_dict()) _gps_queue.put_nowait({'type': 'position', **position.to_dict()})
except queue.Full: except queue.Full:
# Discard oldest if queue is full # Discard oldest if queue is full
try: try:
_gps_queue.get_nowait() _gps_queue.get_nowait()
_gps_queue.put_nowait(position.to_dict()) _gps_queue.put_nowait({'type': 'position', **position.to_dict()})
except queue.Empty:
pass
def _sky_callback(sky: GPSSkyData) -> None:
"""Callback to queue sky data updates for SSE stream."""
try:
_gps_queue.put_nowait({'type': 'sky', **sky.to_dict()})
except queue.Full:
try:
_gps_queue.get_nowait()
_gps_queue.put_nowait({'type': 'sky', **sky.to_dict()})
except queue.Empty: except queue.Empty:
pass pass
@@ -45,36 +62,44 @@ def auto_connect_gps():
Automatically connect to gpsd if available. Automatically connect to gpsd if available.
Called on page load to seamlessly enable GPS if gpsd is running. Called on page load to seamlessly enable GPS if gpsd is running.
If gpsd is not running, attempts to detect GPS devices and start gpsd.
Returns current status if already connected. Returns current status if already connected.
""" """
import socket
# Check if already running # Check if already running
reader = get_gps_reader() reader = get_gps_reader()
if reader and reader.is_running: if reader and reader.is_running:
position = reader.position position = reader.position
sky = reader.sky
return jsonify({ return jsonify({
'status': 'connected', 'status': 'connected',
'source': 'gpsd', 'source': 'gpsd',
'has_fix': position is not None, 'has_fix': position is not None,
'position': position.to_dict() if position else None 'position': position.to_dict() if position else None,
'sky': sky.to_dict() if sky else None,
}) })
# Try to connect to gpsd on localhost:2947
host = 'localhost' host = 'localhost'
port = 2947 port = 2947
# First check if gpsd is reachable # If gpsd isn't running, try to detect a device and start it
try: if not is_gpsd_running(host, port):
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) devices = detect_gps_devices()
sock.settimeout(1.0) if not devices:
sock.connect((host, port)) return jsonify({
sock.close() 'status': 'unavailable',
except Exception: 'message': 'No GPS device detected'
return jsonify({ })
'status': 'unavailable',
'message': 'gpsd not running' # Try to start gpsd with the first detected device
}) device_path = devices[0]['path']
success, msg = start_gpsd_daemon(device_path, host, port)
if not success:
return jsonify({
'status': 'unavailable',
'message': msg,
'devices': devices,
})
logger.info(f"Auto-started gpsd on {device_path}")
# Clear the queue # Clear the queue
while not _gps_queue.empty(): while not _gps_queue.empty():
@@ -84,14 +109,17 @@ def auto_connect_gps():
break break
# Start the gpsd client # Start the gpsd client
success = start_gpsd(host, port, callback=_position_callback) success = start_gpsd(host, port,
callback=_position_callback,
sky_callback=_sky_callback)
if success: if success:
return jsonify({ return jsonify({
'status': 'connected', 'status': 'connected',
'source': 'gpsd', 'source': 'gpsd',
'has_fix': False, 'has_fix': False,
'position': None 'position': None,
'sky': None,
}) })
else: else:
return jsonify({ return jsonify({
@@ -100,14 +128,26 @@ def auto_connect_gps():
}) })
@gps_bp.route('/devices')
def list_gps_devices():
"""List detected GPS serial devices."""
devices = detect_gps_devices()
return jsonify({
'devices': devices,
'gpsd_running': is_gpsd_running(),
})
@gps_bp.route('/stop', methods=['POST']) @gps_bp.route('/stop', methods=['POST'])
def stop_gps_reader(): def stop_gps_reader():
"""Stop GPS client.""" """Stop GPS client and gpsd daemon if we started it."""
reader = get_gps_reader() reader = get_gps_reader()
if reader: if reader:
reader.remove_callback(_position_callback) reader.remove_callback(_position_callback)
reader.remove_sky_callback(_sky_callback)
stop_gps() stop_gps()
stop_gpsd_daemon()
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
@@ -122,15 +162,18 @@ def get_gps_status():
'running': False, 'running': False,
'device': None, 'device': None,
'position': None, 'position': None,
'sky': None,
'error': None, 'error': None,
'message': 'GPS client not started' 'message': 'GPS client not started'
}) })
position = reader.position position = reader.position
sky = reader.sky
return jsonify({ return jsonify({
'running': reader.is_running, 'running': reader.is_running,
'device': reader.device_path, 'device': reader.device_path,
'position': position.to_dict() if position else None, 'position': position.to_dict() if position else None,
'sky': sky.to_dict() if sky else None,
'last_update': reader.last_update.isoformat() if reader.last_update else None, 'last_update': reader.last_update.isoformat() if reader.last_update else None,
'error': reader.error, 'error': reader.error,
'message': 'Waiting for GPS fix - ensure GPS has clear view of sky' if reader.is_running and not position else None 'message': 'Waiting for GPS fix - ensure GPS has clear view of sky' if reader.is_running and not position else None
@@ -161,26 +204,43 @@ def get_position():
}) })
@gps_bp.route('/stream') @gps_bp.route('/satellites')
def stream_gps(): def get_satellites():
"""SSE stream of GPS position updates.""" """Get current satellite sky view data."""
def generate() -> Generator[str, None, None]: reader = get_gps_reader()
last_keepalive = time.time()
keepalive_interval = 30.0
while True: if not reader or not reader.is_running:
try: return jsonify({
position = _gps_queue.get(timeout=1) 'status': 'error',
last_keepalive = time.time() 'message': 'GPS client not running'
yield format_sse({'type': 'position', **position}) }), 400
except queue.Empty:
now = time.time()
if now - last_keepalive >= keepalive_interval:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream') sky = reader.sky
response.headers['Cache-Control'] = 'no-cache' if sky:
response.headers['X-Accel-Buffering'] = 'no' return jsonify({
response.headers['Connection'] = 'keep-alive' 'status': 'ok',
'sky': sky.to_dict()
})
else:
return jsonify({
'status': 'waiting',
'message': 'Waiting for satellite data'
})
@gps_bp.route('/stream')
def stream_gps():
"""SSE stream of GPS position and sky updates."""
response = Response(
sse_stream_fanout(
source_queue=_gps_queue,
channel_key='gps',
timeout=1.0,
keepalive_interval=30.0,
),
mimetype='text/event-stream',
)
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response return response
+172 -176
View File
@@ -19,7 +19,7 @@ from flask import Blueprint, jsonify, request, Response
import app as app_module import app as app_module
from utils.logging import get_logger from utils.logging import get_logger
from utils.sse import format_sse from utils.sse import sse_stream_fanout
from utils.event_pipeline import process_event from utils.event_pipeline import process_event
from utils.constants import ( from utils.constants import (
SSE_QUEUE_TIMEOUT, SSE_QUEUE_TIMEOUT,
@@ -46,15 +46,13 @@ audio_modulation = 'fm'
# Scanner state # Scanner state
scanner_thread: Optional[threading.Thread] = None scanner_thread: Optional[threading.Thread] = None
scanner_running = False scanner_running = False
scanner_lock = threading.Lock() scanner_lock = threading.Lock()
scanner_paused = False scanner_paused = False
scanner_current_freq = 0.0 scanner_current_freq = 0.0
scanner_active_device: Optional[int] = None scanner_active_device: Optional[int] = None
scanner_active_sdr_type: Optional[str] = None listening_active_device: Optional[int] = None
listening_active_device: Optional[int] = None scanner_power_process: Optional[subprocess.Popen] = None
listening_active_sdr_type: Optional[str] = None
scanner_power_process: Optional[subprocess.Popen] = None
scanner_config = { scanner_config = {
'start_freq': 88.0, 'start_freq': 88.0,
'end_freq': 108.0, 'end_freq': 108.0,
@@ -104,15 +102,21 @@ def find_ffmpeg() -> str | None:
return shutil.which('ffmpeg') return shutil.which('ffmpeg')
VALID_MODULATIONS = ['fm', 'wfm', 'am', 'usb', 'lsb'] VALID_MODULATIONS = ['fm', 'wfm', 'am', 'usb', 'lsb']
def normalize_modulation(value: str) -> str: def normalize_modulation(value: str) -> str:
"""Normalize and validate modulation string.""" """Normalize and validate modulation string."""
mod = str(value or '').lower().strip() mod = str(value or '').lower().strip()
if mod not in VALID_MODULATIONS: if mod not in VALID_MODULATIONS:
raise ValueError(f'Invalid modulation. Use: {", ".join(VALID_MODULATIONS)}') raise ValueError(f'Invalid modulation. Use: {", ".join(VALID_MODULATIONS)}')
return mod return mod
def _rtl_fm_demod_mode(modulation: str) -> str:
"""Map UI modulation names to rtl_fm demod tokens."""
mod = str(modulation or '').lower().strip()
return 'wbfm' if mod == 'wfm' else mod
@@ -209,14 +213,14 @@ def scanner_loop():
resample_rate = 24000 resample_rate = 24000
# Don't use squelch in rtl_fm - we want to analyze raw audio # Don't use squelch in rtl_fm - we want to analyze raw audio
rtl_cmd = [ rtl_cmd = [
rtl_fm_path, rtl_fm_path,
'-M', mod, '-M', _rtl_fm_demod_mode(mod),
'-f', str(freq_hz), '-f', str(freq_hz),
'-s', str(sample_rate), '-s', str(sample_rate),
'-r', str(resample_rate), '-r', str(resample_rate),
'-g', str(gain), '-g', str(gain),
'-d', str(device), '-d', str(device),
] ]
# Add bias-t flag if enabled (for external LNA power) # Add bias-t flag if enabled (for external LNA power)
if scanner_config.get('bias_t', False): if scanner_config.get('bias_t', False):
@@ -681,14 +685,14 @@ def _start_audio_stream(frequency: float, modulation: str):
return return
freq_hz = int(frequency * 1e6) freq_hz = int(frequency * 1e6)
sdr_cmd = [ sdr_cmd = [
rtl_fm_path, rtl_fm_path,
'-M', modulation, '-M', _rtl_fm_demod_mode(modulation),
'-f', str(freq_hz), '-f', str(freq_hz),
'-s', str(sample_rate), '-s', str(sample_rate),
'-r', str(resample_rate), '-r', str(resample_rate),
'-g', str(scanner_config['gain']), '-g', str(scanner_config['gain']),
'-d', str(scanner_config['device']), '-d', str(scanner_config['device']),
'-l', str(scanner_config['squelch']), '-l', str(scanner_config['squelch']),
] ]
if scanner_config.get('bias_t', False): if scanner_config.get('bias_t', False):
@@ -938,8 +942,7 @@ def check_tools() -> Response:
@listening_post_bp.route('/scanner/start', methods=['POST']) @listening_post_bp.route('/scanner/start', methods=['POST'])
def start_scanner() -> Response: def start_scanner() -> Response:
"""Start the frequency scanner.""" """Start the frequency scanner."""
global scanner_thread, scanner_running, scanner_config, scanner_active_device, listening_active_device global scanner_thread, scanner_running, scanner_config, scanner_active_device, listening_active_device
global scanner_active_sdr_type, listening_active_sdr_type
with scanner_lock: with scanner_lock:
if scanner_running: if scanner_running:
@@ -1005,23 +1008,21 @@ def start_scanner() -> Response:
'message': 'rtl_power not found. Install rtl-sdr tools.' 'message': 'rtl_power not found. Install rtl-sdr tools.'
}), 503 }), 503
# Release listening device if active # Release listening device if active
if listening_active_device is not None: if listening_active_device is not None:
app_module.release_sdr_device(listening_active_device, listening_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(listening_active_device)
listening_active_device = None listening_active_device = None
listening_active_sdr_type = None # Claim device for scanner
# Claim device for scanner error = app_module.claim_sdr_device(scanner_config['device'], 'scanner')
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner', sdr_type) if error:
if error: return jsonify({
return jsonify({ 'status': 'error',
'status': 'error', 'error_type': 'DEVICE_BUSY',
'error_type': 'DEVICE_BUSY', 'message': error
'message': error }), 409
}), 409 scanner_active_device = scanner_config['device']
scanner_active_device = scanner_config['device'] scanner_running = True
scanner_active_sdr_type = sdr_type scanner_thread = threading.Thread(target=scanner_loop_power, daemon=True)
scanner_running = True scanner_thread.start()
scanner_thread = threading.Thread(target=scanner_loop_power, daemon=True)
scanner_thread.start()
else: else:
if sdr_type == 'rtlsdr': if sdr_type == 'rtlsdr':
if not find_rtl_fm(): if not find_rtl_fm():
@@ -1035,19 +1036,17 @@ def start_scanner() -> Response:
'status': 'error', 'status': 'error',
'message': f'rx_fm not found. Install SoapySDR utilities for {sdr_type}.' 'message': f'rx_fm not found. Install SoapySDR utilities for {sdr_type}.'
}), 503 }), 503
if listening_active_device is not None: if listening_active_device is not None:
app_module.release_sdr_device(listening_active_device, listening_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(listening_active_device)
listening_active_device = None listening_active_device = None
listening_active_sdr_type = None error = app_module.claim_sdr_device(scanner_config['device'], 'scanner')
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner', sdr_type) if error:
if error: return jsonify({
return jsonify({ 'status': 'error',
'status': 'error', 'error_type': 'DEVICE_BUSY',
'error_type': 'DEVICE_BUSY', 'message': error
'message': error }), 409
}), 409 scanner_active_device = scanner_config['device']
scanner_active_device = scanner_config['device']
scanner_active_sdr_type = sdr_type
scanner_running = True scanner_running = True
scanner_thread = threading.Thread(target=scanner_loop, daemon=True) scanner_thread = threading.Thread(target=scanner_loop, daemon=True)
@@ -1060,9 +1059,9 @@ def start_scanner() -> Response:
@listening_post_bp.route('/scanner/stop', methods=['POST']) @listening_post_bp.route('/scanner/stop', methods=['POST'])
def stop_scanner() -> Response: def stop_scanner() -> Response:
"""Stop the frequency scanner.""" """Stop the frequency scanner."""
global scanner_running, scanner_active_device, scanner_power_process, scanner_active_sdr_type global scanner_running, scanner_active_device, scanner_power_process
scanner_running = False scanner_running = False
_stop_audio_stream() _stop_audio_stream()
@@ -1076,10 +1075,9 @@ def stop_scanner() -> Response:
except Exception: except Exception:
pass pass
scanner_power_process = None scanner_power_process = None
if scanner_active_device is not None: if scanner_active_device is not None:
app_module.release_sdr_device(scanner_active_device, scanner_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(scanner_active_device)
scanner_active_device = None scanner_active_device = None
scanner_active_sdr_type = None
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
@@ -1181,31 +1179,25 @@ def scanner_status() -> Response:
}) })
@listening_post_bp.route('/scanner/stream') @listening_post_bp.route('/scanner/stream')
def stream_scanner_events() -> Response: def stream_scanner_events() -> Response:
"""SSE stream for scanner events.""" """SSE stream for scanner events."""
def generate() -> Generator[str, None, None]: def _on_msg(msg: dict[str, Any]) -> None:
last_keepalive = time.time() process_event('listening_scanner', msg, msg.get('type'))
while True: response = Response(
try: sse_stream_fanout(
msg = scanner_queue.get(timeout=SSE_QUEUE_TIMEOUT) source_queue=scanner_queue,
last_keepalive = time.time() channel_key='listening_scanner',
try: timeout=SSE_QUEUE_TIMEOUT,
process_event('listening_scanner', msg, msg.get('type')) keepalive_interval=SSE_KEEPALIVE_INTERVAL,
except Exception: on_message=_on_msg,
pass ),
yield format_sse(msg) mimetype='text/event-stream',
except queue.Empty: )
now = time.time() response.headers['Cache-Control'] = 'no-cache'
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL: response.headers['X-Accel-Buffering'] = 'no'
yield format_sse({'type': 'keepalive'}) return response
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
return response
@listening_post_bp.route('/scanner/log') @listening_post_bp.route('/scanner/log')
@@ -1250,16 +1242,14 @@ def get_presets() -> Response:
@listening_post_bp.route('/audio/start', methods=['POST']) @listening_post_bp.route('/audio/start', methods=['POST'])
def start_audio() -> Response: def start_audio() -> Response:
"""Start audio at specific frequency (manual mode).""" """Start audio at specific frequency (manual mode)."""
global scanner_running, scanner_active_device, listening_active_device, scanner_power_process, scanner_thread global scanner_running, scanner_active_device, listening_active_device, scanner_power_process, scanner_thread
global scanner_active_sdr_type, listening_active_sdr_type, waterfall_active_sdr_type
# Stop scanner if running # Stop scanner if running
if scanner_running: if scanner_running:
scanner_running = False scanner_running = False
if scanner_active_device is not None: if scanner_active_device is not None:
app_module.release_sdr_device(scanner_active_device, scanner_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(scanner_active_device)
scanner_active_device = None scanner_active_device = None
scanner_active_sdr_type = None
if scanner_thread and scanner_thread.is_alive(): if scanner_thread and scanner_thread.is_alive():
try: try:
scanner_thread.join(timeout=2.0) scanner_thread.join(timeout=2.0)
@@ -1316,19 +1306,18 @@ def start_audio() -> Response:
scanner_config['sdr_type'] = sdr_type scanner_config['sdr_type'] = sdr_type
# Stop waterfall if it's using the same SDR (SSE path) # Stop waterfall if it's using the same SDR (SSE path)
if waterfall_running and waterfall_active_device == device and waterfall_active_sdr_type == sdr_type: if waterfall_running and waterfall_active_device == device:
_stop_waterfall_internal() _stop_waterfall_internal()
time.sleep(0.2) time.sleep(0.2)
# Claim device for listening audio. The WebSocket waterfall handler # Claim device for listening audio. The WebSocket waterfall handler
# may still be tearing down its IQ capture process (thread join + # may still be tearing down its IQ capture process (thread join +
# safe_terminate can take several seconds), so we retry with back-off # safe_terminate can take several seconds), so we retry with back-off
# to give the USB device time to be fully released. # to give the USB device time to be fully released.
if listening_active_device is None or listening_active_device != device: if listening_active_device is None or listening_active_device != device:
if listening_active_device is not None: if listening_active_device is not None:
app_module.release_sdr_device(listening_active_device, listening_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(listening_active_device)
listening_active_device = None listening_active_device = None
listening_active_sdr_type = None
error = None error = None
max_claim_attempts = 6 max_claim_attempts = 6
@@ -1336,13 +1325,13 @@ def start_audio() -> Response:
# Force-release a stale waterfall registry entry on each # Force-release a stale waterfall registry entry on each
# attempt — the WebSocket handler may not have finished # attempt — the WebSocket handler may not have finished
# cleanup yet. # cleanup yet.
device_status = app_module.get_sdr_device_status() device_status = app_module.get_sdr_device_status()
if device_status.get(device, {}).get(sdr_type) == 'waterfall': if device_status.get(device) == 'waterfall':
app_module.release_sdr_device(device, sdr_type) app_module.release_sdr_device(device)
error = app_module.claim_sdr_device(device, 'listening', sdr_type) error = app_module.claim_sdr_device(device, 'listening')
if not error: if not error:
break break
if attempt < max_claim_attempts - 1: if attempt < max_claim_attempts - 1:
logger.debug( logger.debug(
f"Device claim attempt {attempt + 1}/{max_claim_attempts} " f"Device claim attempt {attempt + 1}/{max_claim_attempts} "
@@ -1356,8 +1345,7 @@ def start_audio() -> Response:
'error_type': 'DEVICE_BUSY', 'error_type': 'DEVICE_BUSY',
'message': error 'message': error
}), 409 }), 409
listening_active_device = device listening_active_device = device
listening_active_sdr_type = sdr_type
_start_audio_stream(frequency, modulation) _start_audio_stream(frequency, modulation)
@@ -1375,15 +1363,14 @@ def start_audio() -> Response:
@listening_post_bp.route('/audio/stop', methods=['POST']) @listening_post_bp.route('/audio/stop', methods=['POST'])
def stop_audio() -> Response: def stop_audio() -> Response:
"""Stop audio.""" """Stop audio."""
global listening_active_device, listening_active_sdr_type global listening_active_device
_stop_audio_stream() _stop_audio_stream()
if listening_active_device is not None: if listening_active_device is not None:
app_module.release_sdr_device(listening_active_device, listening_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(listening_active_device)
listening_active_device = None listening_active_device = None
listening_active_sdr_type = None return jsonify({'status': 'stopped'})
return jsonify({'status': 'stopped'})
@listening_post_bp.route('/audio/status') @listening_post_bp.route('/audio/status')
@@ -1469,13 +1456,30 @@ def stream_audio() -> Response:
if not proc or not proc.stdout: if not proc or not proc.stdout:
return return
try: try:
# First byte timeout to avoid hanging clients forever # Drain stale audio that accumulated in the pipe buffer
# between pipeline start and stream connection. Keep the
# first chunk (contains WAV header) and discard the rest
# so the browser starts close to real-time.
header_chunk = None
while True:
ready, _, _ = select.select([proc.stdout], [], [], 0)
if not ready:
break
chunk = proc.stdout.read(8192)
if not chunk:
break
if header_chunk is None:
header_chunk = chunk
if header_chunk:
yield header_chunk
# Stream real-time audio
first_chunk_deadline = time.time() + 3.0 first_chunk_deadline = time.time() + 3.0
while audio_running and proc.poll() is None: while audio_running and proc.poll() is None:
# Use select to avoid blocking forever # Use select to avoid blocking forever
ready, _, _ = select.select([proc.stdout], [], [], 2.0) ready, _, _ = select.select([proc.stdout], [], [], 2.0)
if ready: if ready:
chunk = proc.stdout.read(4096) chunk = proc.stdout.read(8192)
if chunk: if chunk:
yield chunk yield chunk
else: else:
@@ -1560,10 +1564,9 @@ waterfall_process: Optional[subprocess.Popen] = None
waterfall_thread: Optional[threading.Thread] = None waterfall_thread: Optional[threading.Thread] = None
waterfall_running = False waterfall_running = False
waterfall_lock = threading.Lock() waterfall_lock = threading.Lock()
waterfall_queue: queue.Queue = queue.Queue(maxsize=200) waterfall_queue: queue.Queue = queue.Queue(maxsize=200)
waterfall_active_device: Optional[int] = None waterfall_active_device: Optional[int] = None
waterfall_active_sdr_type: Optional[str] = None waterfall_config = {
waterfall_config = {
'start_freq': 88.0, 'start_freq': 88.0,
'end_freq': 108.0, 'end_freq': 108.0,
'bin_size': 10000, 'bin_size': 10000,
@@ -1737,9 +1740,9 @@ def _waterfall_loop():
logger.info("Waterfall loop stopped") logger.info("Waterfall loop stopped")
def _stop_waterfall_internal() -> None: def _stop_waterfall_internal() -> None:
"""Stop the waterfall display and release resources.""" """Stop the waterfall display and release resources."""
global waterfall_running, waterfall_process, waterfall_active_device, waterfall_active_sdr_type global waterfall_running, waterfall_process, waterfall_active_device
waterfall_running = False waterfall_running = False
if waterfall_process and waterfall_process.poll() is None: if waterfall_process and waterfall_process.poll() is None:
@@ -1753,16 +1756,15 @@ def _stop_waterfall_internal() -> None:
pass pass
waterfall_process = None waterfall_process = None
if waterfall_active_device is not None: if waterfall_active_device is not None:
app_module.release_sdr_device(waterfall_active_device, waterfall_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(waterfall_active_device)
waterfall_active_device = None waterfall_active_device = None
waterfall_active_sdr_type = None
@listening_post_bp.route('/waterfall/start', methods=['POST']) @listening_post_bp.route('/waterfall/start', methods=['POST'])
def start_waterfall() -> Response: def start_waterfall() -> Response:
"""Start the waterfall/spectrogram display.""" """Start the waterfall/spectrogram display."""
global waterfall_thread, waterfall_running, waterfall_config, waterfall_active_device, waterfall_active_sdr_type global waterfall_thread, waterfall_running, waterfall_config, waterfall_active_device
with waterfall_lock: with waterfall_lock:
if waterfall_running: if waterfall_running:
@@ -1803,12 +1805,11 @@ def start_waterfall() -> Response:
pass pass
# Claim SDR device # Claim SDR device
error = app_module.claim_sdr_device(waterfall_config['device'], 'waterfall', 'rtlsdr') error = app_module.claim_sdr_device(waterfall_config['device'], 'waterfall')
if error: if error:
return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409 return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409
waterfall_active_device = waterfall_config['device'] waterfall_active_device = waterfall_config['device']
waterfall_active_sdr_type = 'rtlsdr'
waterfall_running = True waterfall_running = True
waterfall_thread = threading.Thread(target=_waterfall_loop, daemon=True) waterfall_thread = threading.Thread(target=_waterfall_loop, daemon=True)
waterfall_thread.start() waterfall_thread.start()
@@ -1824,30 +1825,25 @@ def stop_waterfall() -> Response:
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
@listening_post_bp.route('/waterfall/stream') @listening_post_bp.route('/waterfall/stream')
def stream_waterfall() -> Response: def stream_waterfall() -> Response:
"""SSE stream for waterfall data.""" """SSE stream for waterfall data."""
def generate() -> Generator[str, None, None]: def _on_msg(msg: dict[str, Any]) -> None:
last_keepalive = time.time() process_event('waterfall', msg, msg.get('type'))
while True:
try: response = Response(
msg = waterfall_queue.get(timeout=SSE_QUEUE_TIMEOUT) sse_stream_fanout(
last_keepalive = time.time() source_queue=waterfall_queue,
try: channel_key='listening_waterfall',
process_event('waterfall', msg, msg.get('type')) timeout=SSE_QUEUE_TIMEOUT,
except Exception: keepalive_interval=SSE_KEEPALIVE_INTERVAL,
pass on_message=_on_msg,
yield format_sse(msg) ),
except queue.Empty: mimetype='text/event-stream',
now = time.time() )
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL: response.headers['Cache-Control'] = 'no-cache'
yield format_sse({'type': 'keepalive'}) response.headers['X-Accel-Buffering'] = 'no'
last_keepalive = now return response
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
return response
def _downsample_bins(values: list[float], target: int) -> list[float]: def _downsample_bins(values: list[float], target: int) -> list[float]:
"""Downsample bins to a target length using simple averaging.""" """Downsample bins to a target length using simple averaging."""
if target <= 0 or len(values) <= target: if target <= 0 or len(values) <= target:
+31 -22
View File
@@ -17,7 +17,7 @@ from typing import Generator
from flask import Blueprint, jsonify, request, Response from flask import Blueprint, jsonify, request, Response
from utils.logging import get_logger from utils.logging import get_logger
from utils.sse import format_sse from utils.sse import sse_stream_fanout
from utils.meshtastic import ( from utils.meshtastic import (
get_meshtastic_client, get_meshtastic_client,
start_meshtastic, start_meshtastic,
@@ -453,8 +453,8 @@ def get_messages():
}) })
@meshtastic_bp.route('/stream') @meshtastic_bp.route('/stream')
def stream_messages(): def stream_messages():
""" """
SSE stream of Meshtastic messages. SSE stream of Meshtastic messages.
@@ -469,25 +469,18 @@ def stream_messages():
Returns: Returns:
SSE stream (text/event-stream) SSE stream (text/event-stream)
""" """
def generate() -> Generator[str, None, None]: response = Response(
last_keepalive = time.time() sse_stream_fanout(
keepalive_interval = 30.0 source_queue=_mesh_queue,
channel_key='meshtastic',
while True: timeout=1.0,
try: keepalive_interval=30.0,
msg = _mesh_queue.get(timeout=1) ),
last_keepalive = time.time() mimetype='text/event-stream',
yield format_sse(msg) )
except queue.Empty: response.headers['Cache-Control'] = 'no-cache'
now = time.time() response.headers['X-Accel-Buffering'] = 'no'
if now - last_keepalive >= keepalive_interval: response.headers['Connection'] = 'keep-alive'
yield format_sse({'type': 'keepalive'})
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response return response
@@ -1051,3 +1044,19 @@ def request_store_forward():
'status': 'error', 'status': 'error',
'message': error or 'Failed to request S&F history' 'message': error or 'Failed to request S&F history'
}), 500 }), 500
@meshtastic_bp.route('/topology')
def mesh_topology():
"""Return mesh network topology graph."""
if not is_meshtastic_available():
return jsonify({'status': 'error', 'message': 'Meshtastic SDK not installed'}), 400
client = get_meshtastic_client()
if not client or not client.is_running:
return jsonify({'status': 'error', 'message': 'Not connected'}), 400
return jsonify({
'status': 'success',
'topology': client.get_topology(),
})
+81 -97
View File
@@ -24,7 +24,7 @@ from utils.validation import (
validate_frequency, validate_device_index, validate_gain, validate_ppm, validate_frequency, validate_device_index, validate_gain, validate_ppm,
validate_rtl_tcp_host, validate_rtl_tcp_port validate_rtl_tcp_host, validate_rtl_tcp_port
) )
from utils.sse import format_sse from utils.sse import sse_stream_fanout
from utils.event_pipeline import process_event from utils.event_pipeline import process_event
from utils.process import safe_terminate, register_process, unregister_process from utils.process import safe_terminate, register_process, unregister_process
from utils.sdr import SDRFactory, SDRType, SDRValidationError from utils.sdr import SDRFactory, SDRType, SDRValidationError
@@ -32,9 +32,8 @@ from utils.dependencies import get_tool_path
pager_bp = Blueprint('pager', __name__) pager_bp = Blueprint('pager', __name__)
# Track which device is being used # Track which device is being used
pager_active_device: int | None = None pager_active_device: int | None = None
pager_active_sdr_type: str | None = None
def parse_multimon_output(line: str) -> dict[str, str] | None: def parse_multimon_output(line: str) -> dict[str, str] | None:
@@ -206,7 +205,7 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
except Exception as e: except Exception as e:
app_module.output_queue.put({'type': 'error', 'text': str(e)}) app_module.output_queue.put({'type': 'error', 'text': str(e)})
finally: finally:
global pager_active_device, pager_active_sdr_type global pager_active_device
try: try:
os.close(master_fd) os.close(master_fd)
except OSError: except OSError:
@@ -234,15 +233,14 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
with app_module.process_lock: with app_module.process_lock:
app_module.current_process = None app_module.current_process = None
# Release SDR device # Release SDR device
if pager_active_device is not None: if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(pager_active_device)
pager_active_device = None pager_active_device = None
pager_active_sdr_type = None
@pager_bp.route('/start', methods=['POST']) @pager_bp.route('/start', methods=['POST'])
def start_decoding() -> Response: def start_decoding() -> Response:
global pager_active_device, pager_active_sdr_type global pager_active_device
with app_module.process_lock: with app_module.process_lock:
if app_module.current_process: if app_module.current_process:
@@ -264,42 +262,33 @@ def start_decoding() -> Response:
squelch = int(squelch) squelch = int(squelch)
if not 0 <= squelch <= 1000: if not 0 <= squelch <= 1000:
raise ValueError("Squelch must be between 0 and 1000") raise ValueError("Squelch must be between 0 and 1000")
except (ValueError, TypeError): except (ValueError, TypeError):
return jsonify({'status': 'error', 'message': 'Invalid squelch value'}), 400 return jsonify({'status': 'error', 'message': 'Invalid squelch value'}), 400
# Get SDR type and build command via abstraction layer # Check for rtl_tcp (remote SDR) connection
sdr_type_str = data.get('sdr_type', 'rtlsdr') rtl_tcp_host = data.get('rtl_tcp_host')
try: rtl_tcp_port = data.get('rtl_tcp_port', 1234)
sdr_type = SDRType(sdr_type_str)
except ValueError: # Claim local device if not using remote rtl_tcp
sdr_type = SDRType.RTL_SDR if not rtl_tcp_host:
device_int = int(device)
# Check for rtl_tcp (remote SDR) connection error = app_module.claim_sdr_device(device_int, 'pager')
rtl_tcp_host = data.get('rtl_tcp_host') if error:
rtl_tcp_port = data.get('rtl_tcp_port', 1234) return jsonify({
'status': 'error',
# Claim local device if not using remote rtl_tcp 'error_type': 'DEVICE_BUSY',
if not rtl_tcp_host: 'message': error
device_int = int(device) }), 409
error = app_module.claim_sdr_device(device_int, 'pager', sdr_type.value) pager_active_device = device_int
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
pager_active_device = device_int
pager_active_sdr_type = sdr_type.value
# Validate protocols # Validate protocols
valid_protocols = ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX'] valid_protocols = ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX']
protocols = data.get('protocols', valid_protocols) protocols = data.get('protocols', valid_protocols)
if not isinstance(protocols, list): if not isinstance(protocols, list):
if pager_active_device is not None: if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(pager_active_device)
pager_active_device = None pager_active_device = None
pager_active_sdr_type = None return jsonify({'status': 'error', 'message': 'Protocols must be a list'}), 400
return jsonify({'status': 'error', 'message': 'Protocols must be a list'}), 400
protocols = [p for p in protocols if p in valid_protocols] protocols = [p for p in protocols if p in valid_protocols]
if not protocols: if not protocols:
protocols = valid_protocols protocols = valid_protocols
@@ -323,7 +312,14 @@ def start_decoding() -> Response:
elif proto == 'FLEX': elif proto == 'FLEX':
decoders.extend(['-a', 'FLEX']) decoders.extend(['-a', 'FLEX'])
if rtl_tcp_host: # 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 # Validate and create network device
try: try:
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host) rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
@@ -420,23 +416,22 @@ def start_decoding() -> Response:
return jsonify({'status': 'started', 'command': full_cmd}) return jsonify({'status': 'started', 'command': full_cmd})
except FileNotFoundError as e: except FileNotFoundError as e:
# Kill orphaned rtl_fm process # Kill orphaned rtl_fm process
try: try:
rtl_process.terminate() rtl_process.terminate()
rtl_process.wait(timeout=2) rtl_process.wait(timeout=2)
except Exception: except Exception:
try: try:
rtl_process.kill() rtl_process.kill()
except Exception: except Exception:
pass pass
# Release device on failure # Release device on failure
if pager_active_device is not None: if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(pager_active_device)
pager_active_device = None pager_active_device = None
pager_active_sdr_type = None return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'})
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'}) except Exception as e:
except Exception as e:
# Kill orphaned rtl_fm process if it was started # Kill orphaned rtl_fm process if it was started
try: try:
rtl_process.terminate() rtl_process.terminate()
@@ -446,17 +441,16 @@ def start_decoding() -> Response:
rtl_process.kill() rtl_process.kill()
except Exception: except Exception:
pass pass
# Release device on failure # Release device on failure
if pager_active_device is not None: if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(pager_active_device)
pager_active_device = None pager_active_device = None
pager_active_sdr_type = None return jsonify({'status': 'error', 'message': str(e)})
return jsonify({'status': 'error', 'message': str(e)})
@pager_bp.route('/stop', methods=['POST']) @pager_bp.route('/stop', methods=['POST'])
def stop_decoding() -> Response: def stop_decoding() -> Response:
global pager_active_device, pager_active_sdr_type global pager_active_device
with app_module.process_lock: with app_module.process_lock:
if app_module.current_process: if app_module.current_process:
@@ -491,11 +485,10 @@ def stop_decoding() -> Response:
app_module.current_process = None app_module.current_process = None
# Release device from registry # Release device from registry
if pager_active_device is not None: if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(pager_active_device)
pager_active_device = None pager_active_device = None
pager_active_sdr_type = None
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
@@ -545,31 +538,22 @@ def toggle_logging() -> Response:
return jsonify({'logging': app_module.logging_enabled, 'log_file': app_module.log_file_path}) return jsonify({'logging': app_module.logging_enabled, 'log_file': app_module.log_file_path})
@pager_bp.route('/stream') @pager_bp.route('/stream')
def stream() -> Response: def stream() -> Response:
import json def _on_msg(msg: dict[str, Any]) -> None:
process_event('pager', msg, msg.get('type'))
def generate() -> Generator[str, None, None]:
last_keepalive = time.time() response = Response(
keepalive_interval = 30.0 # Send keepalive every 30 seconds instead of 1 second sse_stream_fanout(
source_queue=app_module.output_queue,
while True: channel_key='pager',
try: timeout=1.0,
msg = app_module.output_queue.get(timeout=1) keepalive_interval=30.0,
last_keepalive = time.time() on_message=_on_msg,
try: ),
process_event('pager', msg, msg.get('type')) mimetype='text/event-stream',
except Exception: )
pass response.headers['Cache-Control'] = 'no-cache'
yield format_sse(msg) response.headers['X-Accel-Buffering'] = 'no'
except queue.Empty: response.headers['Connection'] = 'keep-alive'
now = time.time() return response
if now - last_keepalive >= keepalive_interval:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response
+57
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import json
from pathlib import Path from pathlib import Path
from flask import Blueprint, jsonify, request, send_file from flask import Blueprint, jsonify, request, send_file
@@ -107,3 +108,59 @@ def download_recording(session_id: str):
as_attachment=True, as_attachment=True,
download_name=file_path.name, download_name=file_path.name,
) )
@recordings_bp.route('/<session_id>/events', methods=['GET'])
def get_recording_events(session_id: str):
"""Return parsed events from a recording for in-app replay."""
manager = get_recording_manager()
rec = manager.get_recording(session_id)
if not rec:
return jsonify({'status': 'error', 'message': 'Recording not found'}), 404
file_path = Path(rec['file_path'])
try:
resolved_root = RECORDING_ROOT.resolve()
resolved_file = file_path.resolve()
if resolved_root not in resolved_file.parents:
return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400
except Exception:
return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400
if not file_path.exists():
return jsonify({'status': 'error', 'message': 'Recording file missing'}), 404
limit = max(1, min(5000, request.args.get('limit', default=500, type=int)))
offset = max(0, request.args.get('offset', default=0, type=int))
events: list[dict] = []
seen = 0
with file_path.open('r', encoding='utf-8', errors='replace') as fh:
for idx, line in enumerate(fh):
if idx < offset:
continue
if seen >= limit:
break
line = line.strip()
if not line:
continue
try:
events.append(json.loads(line))
seen += 1
except json.JSONDecodeError:
continue
return jsonify({
'status': 'success',
'recording': {
'id': rec['id'],
'mode': rec['mode'],
'started_at': rec['started_at'],
'stopped_at': rec['stopped_at'],
'event_count': rec['event_count'],
},
'offset': offset,
'limit': limit,
'returned': len(events),
'events': events,
})
+13 -20
View File
@@ -17,7 +17,7 @@ from utils.logging import sensor_logger as logger
from utils.validation import ( from utils.validation import (
validate_frequency, validate_device_index, validate_gain, validate_ppm validate_frequency, validate_device_index, validate_gain, validate_ppm
) )
from utils.sse import format_sse from utils.sse import sse_stream_fanout
from utils.event_pipeline import process_event from utils.event_pipeline import process_event
from utils.process import safe_terminate, register_process, unregister_process from utils.process import safe_terminate, register_process, unregister_process
@@ -288,26 +288,19 @@ def stop_rtlamr() -> Response:
@rtlamr_bp.route('/stream_rtlamr') @rtlamr_bp.route('/stream_rtlamr')
def stream_rtlamr() -> Response: def stream_rtlamr() -> Response:
def generate() -> Generator[str, None, None]: def _on_msg(msg: dict[str, Any]) -> None:
last_keepalive = time.time() process_event('rtlamr', msg, msg.get('type'))
keepalive_interval = 30.0
while True: response = Response(
try: sse_stream_fanout(
msg = app_module.rtlamr_queue.get(timeout=1) source_queue=app_module.rtlamr_queue,
last_keepalive = time.time() channel_key='rtlamr',
try: timeout=1.0,
process_event('rtlamr', msg, msg.get('type')) keepalive_interval=30.0,
except Exception: on_message=_on_msg,
pass ),
yield format_sse(msg) mimetype='text/event-stream',
except queue.Empty: )
now = time.time()
if now - last_keepalive >= keepalive_interval:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache' response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no' response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive' response.headers['Connection'] = 'keep-alive'
+102 -15
View File
@@ -16,6 +16,13 @@ from flask import Blueprint, jsonify, request, render_template, Response
from config import SHARED_OBSERVER_LOCATION_ENABLED from config import SHARED_OBSERVER_LOCATION_ENABLED
from data.satellites import TLE_SATELLITES from data.satellites import TLE_SATELLITES
from utils.database import (
get_tracked_satellites,
add_tracked_satellite,
bulk_add_tracked_satellites,
update_tracked_satellite,
remove_tracked_satellite,
)
from utils.logging import satellite_logger as logger from utils.logging import satellite_logger as logger
from utils.validation import validate_latitude, validate_longitude, validate_hours, validate_elevation from utils.validation import validate_latitude, validate_longitude, validate_hours, validate_elevation
@@ -31,18 +38,38 @@ ALLOWED_TLE_HOSTS = ['celestrak.org', 'celestrak.com', 'www.celestrak.org', 'www
_tle_cache = dict(TLE_SATELLITES) _tle_cache = dict(TLE_SATELLITES)
def _load_db_satellites_into_cache():
"""Load user-tracked satellites from DB into the TLE cache."""
global _tle_cache
try:
db_sats = get_tracked_satellites()
loaded = 0
for sat in db_sats:
if sat['tle_line1'] and sat['tle_line2']:
# Use a cache key derived from name (sanitised)
cache_key = sat['name'].replace(' ', '-').upper()
if cache_key not in _tle_cache:
_tle_cache[cache_key] = (sat['name'], sat['tle_line1'], sat['tle_line2'])
loaded += 1
if loaded:
logger.info(f"Loaded {loaded} user-tracked satellites into TLE cache")
except Exception as e:
logger.warning(f"Failed to load DB satellites into TLE cache: {e}")
def init_tle_auto_refresh(): def init_tle_auto_refresh():
"""Initialize TLE auto-refresh. Called by app.py after initialization.""" """Initialize TLE auto-refresh. Called by app.py after initialization."""
import threading import threading
def _auto_refresh_tle(): def _auto_refresh_tle():
try: try:
_load_db_satellites_into_cache()
updated = refresh_tle_data() updated = refresh_tle_data()
if updated: if updated:
logger.info(f"Auto-refreshed TLE data for: {', '.join(updated)}") logger.info(f"Auto-refreshed TLE data for: {', '.join(updated)}")
except Exception as e: except Exception as e:
logger.warning(f"Auto TLE refresh failed: {e}") logger.warning(f"Auto TLE refresh failed: {e}")
# Start auto-refresh in background # Start auto-refresh in background
threading.Timer(2.0, _auto_refresh_tle).start() threading.Timer(2.0, _auto_refresh_tle).start()
logger.info("TLE auto-refresh scheduled") logger.info("TLE auto-refresh scheduled")
@@ -170,15 +197,11 @@ def predict_passes():
norad_to_name = { norad_to_name = {
25544: 'ISS', 25544: 'ISS',
25338: 'NOAA-15',
28654: 'NOAA-18',
33591: 'NOAA-19',
43013: 'NOAA-20',
40069: 'METEOR-M2', 40069: 'METEOR-M2',
57166: 'METEOR-M2-3' 57166: 'METEOR-M2-3'
} }
sat_input = data.get('satellites', ['ISS', 'NOAA-15', 'NOAA-18', 'NOAA-19']) sat_input = data.get('satellites', ['ISS', 'METEOR-M2', 'METEOR-M2-3'])
satellites = [] satellites = []
for sat in sat_input: for sat in sat_input:
if isinstance(sat, int) and sat in norad_to_name: if isinstance(sat, int) and sat in norad_to_name:
@@ -189,10 +212,6 @@ def predict_passes():
passes = [] passes = []
colors = { colors = {
'ISS': '#00ffff', 'ISS': '#00ffff',
'NOAA-15': '#00ff00',
'NOAA-18': '#ff6600',
'NOAA-19': '#ff3366',
'NOAA-20': '#00ffaa',
'METEOR-M2': '#9370DB', 'METEOR-M2': '#9370DB',
'METEOR-M2-3': '#ff00ff' 'METEOR-M2-3': '#ff00ff'
} }
@@ -329,10 +348,6 @@ def get_satellite_position():
norad_to_name = { norad_to_name = {
25544: 'ISS', 25544: 'ISS',
25338: 'NOAA-15',
28654: 'NOAA-18',
33591: 'NOAA-19',
43013: 'NOAA-20',
40069: 'METEOR-M2', 40069: 'METEOR-M2',
57166: 'METEOR-M2-3' 57166: 'METEOR-M2-3'
} }
@@ -555,3 +570,75 @@ def fetch_celestrak(category):
except Exception as e: except Exception as e:
logger.error(f"Error fetching CelesTrak data: {e}") logger.error(f"Error fetching CelesTrak data: {e}")
return jsonify({'status': 'error', 'message': 'Failed to fetch satellite data'}) return jsonify({'status': 'error', 'message': 'Failed to fetch satellite data'})
# =============================================================================
# Tracked Satellites CRUD
# =============================================================================
@satellite_bp.route('/tracked', methods=['GET'])
def list_tracked_satellites():
"""Return all tracked satellites from the database."""
enabled_only = request.args.get('enabled', '').lower() == 'true'
sats = get_tracked_satellites(enabled_only=enabled_only)
return jsonify({'status': 'success', 'satellites': sats})
@satellite_bp.route('/tracked', methods=['POST'])
def add_tracked_satellites_endpoint():
"""Add one or more tracked satellites."""
global _tle_cache
data = request.json
if not data:
return jsonify({'status': 'error', 'message': 'No data provided'}), 400
# Accept a single satellite dict or a list
sat_list = data if isinstance(data, list) else [data]
added = 0
for sat in sat_list:
norad_id = str(sat.get('norad_id', sat.get('norad', '')))
name = sat.get('name', '')
if not norad_id or not name:
continue
tle1 = sat.get('tle_line1', sat.get('tle1'))
tle2 = sat.get('tle_line2', sat.get('tle2'))
enabled = sat.get('enabled', True)
if add_tracked_satellite(norad_id, name, tle1, tle2, enabled):
added += 1
# Also inject into TLE cache if we have TLE data
if tle1 and tle2:
cache_key = name.replace(' ', '-').upper()
_tle_cache[cache_key] = (name, tle1, tle2)
return jsonify({
'status': 'success',
'added': added,
'satellites': get_tracked_satellites(),
})
@satellite_bp.route('/tracked/<norad_id>', methods=['PUT'])
def update_tracked_satellite_endpoint(norad_id):
"""Update the enabled state of a tracked satellite."""
data = request.json or {}
enabled = data.get('enabled')
if enabled is None:
return jsonify({'status': 'error', 'message': 'Missing enabled field'}), 400
ok = update_tracked_satellite(str(norad_id), bool(enabled))
if ok:
return jsonify({'status': 'success'})
return jsonify({'status': 'error', 'message': 'Satellite not found'}), 404
@satellite_bp.route('/tracked/<norad_id>', methods=['DELETE'])
def delete_tracked_satellite_endpoint(norad_id):
"""Remove a tracked satellite by NORAD ID."""
ok, msg = remove_tracked_satellite(str(norad_id))
if ok:
return jsonify({'status': 'success', 'message': msg})
status_code = 403 if 'builtin' in msg.lower() else 404
return jsonify({'status': 'error', 'message': msg}), status_code
+93 -76
View File
@@ -18,16 +18,19 @@ from utils.validation import (
validate_frequency, validate_device_index, validate_gain, validate_ppm, validate_frequency, validate_device_index, validate_gain, validate_ppm,
validate_rtl_tcp_host, validate_rtl_tcp_port validate_rtl_tcp_host, validate_rtl_tcp_port
) )
from utils.sse import format_sse from utils.sse import sse_stream_fanout
from utils.event_pipeline import process_event from utils.event_pipeline import process_event
from utils.process import safe_terminate, register_process, unregister_process from utils.process import safe_terminate, register_process, unregister_process
from utils.sdr import SDRFactory, SDRType from utils.sdr import SDRFactory, SDRType
sensor_bp = Blueprint('sensor', __name__) sensor_bp = Blueprint('sensor', __name__)
# Track which device is being used # Track which device is being used
sensor_active_device: int | None = None sensor_active_device: int | None = None
sensor_active_sdr_type: str | None = None
# RSSI history per device (model_id -> list of (timestamp, rssi))
sensor_rssi_history: dict[str, list[tuple[float, float]]] = {}
_MAX_RSSI_HISTORY = 60
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None: def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
@@ -46,6 +49,17 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
data['type'] = 'sensor' data['type'] = 'sensor'
app_module.sensor_queue.put(data) 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 # Push scope event when signal level data is present
rssi = data.get('rssi') rssi = data.get('rssi')
snr = data.get('snr') snr = data.get('snr')
@@ -76,7 +90,7 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
except Exception as e: except Exception as e:
app_module.sensor_queue.put({'type': 'error', 'text': str(e)}) app_module.sensor_queue.put({'type': 'error', 'text': str(e)})
finally: finally:
global sensor_active_device, sensor_active_sdr_type global sensor_active_device
# Ensure process is terminated # Ensure process is terminated
try: try:
process.terminate() process.terminate()
@@ -91,10 +105,9 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
with app_module.sensor_lock: with app_module.sensor_lock:
app_module.sensor_process = None app_module.sensor_process = None
# Release SDR device # Release SDR device
if sensor_active_device is not None: if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(sensor_active_device)
sensor_active_device = None sensor_active_device = None
sensor_active_sdr_type = None
@sensor_bp.route('/sensor/status') @sensor_bp.route('/sensor/status')
@@ -106,8 +119,8 @@ def sensor_status() -> Response:
@sensor_bp.route('/start_sensor', methods=['POST']) @sensor_bp.route('/start_sensor', methods=['POST'])
def start_sensor() -> Response: def start_sensor() -> Response:
global sensor_active_device, sensor_active_sdr_type global sensor_active_device
with app_module.sensor_lock: with app_module.sensor_lock:
if app_module.sensor_process: if app_module.sensor_process:
@@ -124,29 +137,21 @@ def start_sensor() -> Response:
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return jsonify({'status': 'error', 'message': str(e)}), 400
# Get SDR type and build command via abstraction layer # Check for rtl_tcp (remote SDR) connection
sdr_type_str = data.get('sdr_type', 'rtlsdr') rtl_tcp_host = data.get('rtl_tcp_host')
try: rtl_tcp_port = data.get('rtl_tcp_port', 1234)
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
# Check for rtl_tcp (remote SDR) connection
rtl_tcp_host = data.get('rtl_tcp_host')
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
# Claim local device if not using remote rtl_tcp # Claim local device if not using remote rtl_tcp
if not rtl_tcp_host: if not rtl_tcp_host:
device_int = int(device) device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'sensor', sdr_type.value) error = app_module.claim_sdr_device(device_int, 'sensor')
if error: if error:
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'error_type': 'DEVICE_BUSY', 'error_type': 'DEVICE_BUSY',
'message': error 'message': error
}), 409 }), 409
sensor_active_device = device_int sensor_active_device = device_int
sensor_active_sdr_type = sdr_type.value
# Clear queue # Clear queue
while not app_module.sensor_queue.empty(): while not app_module.sensor_queue.empty():
@@ -155,7 +160,14 @@ def start_sensor() -> Response:
except queue.Empty: except queue.Empty:
break break
if rtl_tcp_host: # 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 # Validate and create network device
try: try:
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host) rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
@@ -202,10 +214,16 @@ def start_sensor() -> Response:
thread.start() thread.start()
# Monitor stderr # Monitor stderr
# Filter noisy rtl_433 diagnostics that aren't useful to display
_stderr_noise = (
'bitbuffer_add_bit',
'row count limit',
)
def monitor_stderr(): def monitor_stderr():
for line in app_module.sensor_process.stderr: for line in app_module.sensor_process.stderr:
err = line.decode('utf-8', errors='replace').strip() err = line.decode('utf-8', errors='replace').strip()
if err: if err and not any(noise in err for noise in _stderr_noise):
logger.debug(f"[rtl_433] {err}") logger.debug(f"[rtl_433] {err}")
app_module.sensor_queue.put({'type': 'info', 'text': f'[rtl_433] {err}'}) app_module.sensor_queue.put({'type': 'info', 'text': f'[rtl_433] {err}'})
@@ -217,25 +235,23 @@ def start_sensor() -> Response:
return jsonify({'status': 'started', 'command': full_cmd}) return jsonify({'status': 'started', 'command': full_cmd})
except FileNotFoundError: except FileNotFoundError:
# Release device on failure # Release device on failure
if sensor_active_device is not None: if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(sensor_active_device)
sensor_active_device = None sensor_active_device = None
sensor_active_sdr_type = None return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'})
return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'}) except Exception as e:
except Exception as e: # Release device on failure
# Release device on failure if sensor_active_device is not None:
if sensor_active_device is not None: app_module.release_sdr_device(sensor_active_device)
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr') sensor_active_device = None
sensor_active_device = None return jsonify({'status': 'error', 'message': str(e)})
sensor_active_sdr_type = None
return jsonify({'status': 'error', 'message': str(e)})
@sensor_bp.route('/stop_sensor', methods=['POST']) @sensor_bp.route('/stop_sensor', methods=['POST'])
def stop_sensor() -> Response: def stop_sensor() -> Response:
global sensor_active_device, sensor_active_sdr_type global sensor_active_device
with app_module.sensor_lock: with app_module.sensor_lock:
if app_module.sensor_process: if app_module.sensor_process:
@@ -247,39 +263,40 @@ def stop_sensor() -> Response:
app_module.sensor_process = None app_module.sensor_process = None
# Release device from registry # Release device from registry
if sensor_active_device is not None: if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(sensor_active_device)
sensor_active_device = None sensor_active_device = None
sensor_active_sdr_type = None
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
return jsonify({'status': 'not_running'}) return jsonify({'status': 'not_running'})
@sensor_bp.route('/stream_sensor') @sensor_bp.route('/stream_sensor')
def stream_sensor() -> Response: def stream_sensor() -> Response:
def generate() -> Generator[str, None, None]: def _on_msg(msg: dict[str, Any]) -> None:
last_keepalive = time.time() process_event('sensor', msg, msg.get('type'))
keepalive_interval = 30.0
response = Response(
sse_stream_fanout(
source_queue=app_module.sensor_queue,
channel_key='sensor',
timeout=1.0,
keepalive_interval=30.0,
on_message=_on_msg,
),
mimetype='text/event-stream',
)
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response
while True:
try:
msg = app_module.sensor_queue.get(timeout=1)
last_keepalive = time.time()
try:
process_event('sensor', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg)
except queue.Empty:
now = time.time()
if now - last_keepalive >= keepalive_interval:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream') @sensor_bp.route('/sensor/rssi_history')
response.headers['Cache-Control'] = 'no-cache' def get_rssi_history() -> Response:
response.headers['X-Accel-Buffering'] = 'no' """Return RSSI history for all tracked sensor devices."""
response.headers['Connection'] = 'keep-alive' result = {}
return response 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})
+300
View File
@@ -0,0 +1,300 @@
"""Space Weather routes - proxies NOAA SWPC, NASA SDO, and HamQSL data."""
from __future__ import annotations
import json
import time
import urllib.error
import urllib.request
import xml.etree.ElementTree as ET
from typing import Any
from flask import Blueprint, Response, jsonify
from utils.logging import get_logger
logger = get_logger('intercept.space_weather')
space_weather_bp = Blueprint('space_weather', __name__, url_prefix='/space-weather')
# ---------------------------------------------------------------------------
# TTL Cache
# ---------------------------------------------------------------------------
_cache: dict[str, dict[str, Any]] = {}
# Cache TTLs in seconds
TTL_REALTIME = 300 # 5 min for real-time data
TTL_FORECAST = 1800 # 30 min for forecasts
TTL_DAILY = 3600 # 1 hr for daily summaries
TTL_IMAGE = 600 # 10 min for images
def _cache_get(key: str) -> Any | None:
entry = _cache.get(key)
if entry and time.time() < entry['expires']:
return entry['data']
return None
def _cache_set(key: str, data: Any, ttl: int) -> None:
_cache[key] = {'data': data, 'expires': time.time() + ttl}
# ---------------------------------------------------------------------------
# HTTP helpers
# ---------------------------------------------------------------------------
_TIMEOUT = 15 # seconds
SWPC_BASE = 'https://services.swpc.noaa.gov'
SWPC_JSON = f'{SWPC_BASE}/products'
def _fetch_json(url: str, timeout: int = _TIMEOUT) -> Any | None:
try:
req = urllib.request.Request(url, headers={'User-Agent': 'INTERCEPT/1.0'})
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode())
except Exception as exc:
logger.warning('Failed to fetch %s: %s', url, exc)
return None
def _fetch_text(url: str, timeout: int = _TIMEOUT) -> str | None:
try:
req = urllib.request.Request(url, headers={'User-Agent': 'INTERCEPT/1.0'})
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.read().decode()
except Exception as exc:
logger.warning('Failed to fetch %s: %s', url, exc)
return None
def _fetch_bytes(url: str, timeout: int = _TIMEOUT) -> bytes | None:
try:
req = urllib.request.Request(url, headers={'User-Agent': 'INTERCEPT/1.0'})
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.read()
except Exception as exc:
logger.warning('Failed to fetch %s: %s', url, exc)
return None
# ---------------------------------------------------------------------------
# Data source fetchers
# ---------------------------------------------------------------------------
def _fetch_cached_json(cache_key: str, url: str, ttl: int) -> Any | None:
cached = _cache_get(cache_key)
if cached is not None:
return cached
data = _fetch_json(url)
if data is not None:
_cache_set(cache_key, data, ttl)
return data
def _fetch_kp_index() -> Any | None:
return _fetch_cached_json('kp_index', f'{SWPC_JSON}/noaa-planetary-k-index.json', TTL_REALTIME)
def _fetch_kp_forecast() -> Any | None:
return _fetch_cached_json('kp_forecast', f'{SWPC_JSON}/noaa-planetary-k-index-forecast.json', TTL_FORECAST)
def _fetch_scales() -> Any | None:
return _fetch_cached_json('scales', f'{SWPC_JSON}/noaa-scales.json', TTL_REALTIME)
def _fetch_flux() -> Any | None:
return _fetch_cached_json('flux', f'{SWPC_JSON}/10cm-flux-30-day.json', TTL_DAILY)
def _fetch_alerts() -> Any | None:
return _fetch_cached_json('alerts', f'{SWPC_JSON}/alerts.json', TTL_REALTIME)
def _fetch_solar_wind_plasma() -> Any | None:
return _fetch_cached_json('sw_plasma', f'{SWPC_JSON}/solar-wind/plasma-6-hour.json', TTL_REALTIME)
def _fetch_solar_wind_mag() -> Any | None:
return _fetch_cached_json('sw_mag', f'{SWPC_JSON}/solar-wind/mag-6-hour.json', TTL_REALTIME)
def _fetch_xrays() -> Any | None:
return _fetch_cached_json('xrays', f'{SWPC_BASE}/json/goes/primary/xrays-1-day.json', TTL_REALTIME)
def _fetch_xray_flares() -> Any | None:
return _fetch_cached_json('xray_flares', f'{SWPC_BASE}/json/goes/primary/xray-flares-7-day.json', TTL_REALTIME)
def _fetch_flare_probability() -> Any | None:
return _fetch_cached_json('flare_prob', f'{SWPC_BASE}/json/solar_probabilities.json', TTL_FORECAST)
def _fetch_solar_regions() -> Any | None:
return _fetch_cached_json('solar_regions', f'{SWPC_BASE}/json/solar_regions.json', TTL_DAILY)
def _fetch_sunspot_report() -> Any | None:
return _fetch_cached_json('sunspot_report', f'{SWPC_BASE}/json/sunspot_report.json', TTL_DAILY)
def _parse_hamqsl_xml(xml_text: str) -> dict[str, Any] | None:
"""Parse HamQSL solar XML into a dict of band conditions."""
try:
root = ET.fromstring(xml_text)
solar = root.find('.//solardata')
if solar is None:
return None
result: dict[str, Any] = {}
# Scalar fields
for tag in ('sfi', 'aindex', 'kindex', 'kindexnt', 'xray', 'sunspots',
'heliumline', 'protonflux', 'electonflux', 'aurora',
'normalization', 'latdegree', 'solarwind', 'magneticfield',
'calculatedconditions', 'calculatedvhfconditions',
'geomagfield', 'signalnoise', 'fof2', 'muffactor', 'muf'):
el = solar.find(tag)
if el is not None and el.text:
result[tag] = el.text.strip()
# Band conditions
bands: list[dict[str, str]] = []
for band_el in solar.findall('.//calculatedconditions/band'):
bands.append({
'name': band_el.get('name', ''),
'time': band_el.get('time', ''),
'condition': band_el.text.strip() if band_el.text else ''
})
result['bands'] = bands
# VHF conditions
vhf: list[dict[str, str]] = []
for phen_el in solar.findall('.//calculatedvhfconditions/phenomenon'):
vhf.append({
'name': phen_el.get('name', ''),
'location': phen_el.get('location', ''),
'condition': phen_el.text.strip() if phen_el.text else ''
})
result['vhf'] = vhf
return result
except ET.ParseError as exc:
logger.warning('Failed to parse HamQSL XML: %s', exc)
return None
def _fetch_band_conditions() -> dict[str, Any] | None:
cached = _cache_get('band_conditions')
if cached is not None:
return cached
xml_text = _fetch_text('https://www.hamqsl.com/solarxml.php')
if xml_text is None:
return None
data = _parse_hamqsl_xml(xml_text)
if data is not None:
_cache_set('band_conditions', data, TTL_FORECAST)
return data
# ---------------------------------------------------------------------------
# Image proxy whitelist
# ---------------------------------------------------------------------------
IMAGE_WHITELIST: dict[str, dict[str, str]] = {
# D-RAP absorption maps
'drap_global': {
'url': f'{SWPC_BASE}/images/animations/d-rap/global/latest.png',
'content_type': 'image/png',
},
'drap_5': {
'url': f'{SWPC_BASE}/images/d-rap/global_f05.png',
'content_type': 'image/png',
},
'drap_10': {
'url': f'{SWPC_BASE}/images/d-rap/global_f10.png',
'content_type': 'image/png',
},
'drap_15': {
'url': f'{SWPC_BASE}/images/d-rap/global_f15.png',
'content_type': 'image/png',
},
'drap_20': {
'url': f'{SWPC_BASE}/images/d-rap/global_f20.png',
'content_type': 'image/png',
},
'drap_25': {
'url': f'{SWPC_BASE}/images/d-rap/global_f25.png',
'content_type': 'image/png',
},
'drap_30': {
'url': f'{SWPC_BASE}/images/d-rap/global_f30.png',
'content_type': 'image/png',
},
# Aurora forecast
'aurora_north': {
'url': f'{SWPC_BASE}/images/animations/ovation/north/latest.jpg',
'content_type': 'image/jpeg',
},
# SDO solar imagery
'sdo_193': {
'url': 'https://sdo.gsfc.nasa.gov/assets/img/latest/latest_512_0193.jpg',
'content_type': 'image/jpeg',
},
'sdo_304': {
'url': 'https://sdo.gsfc.nasa.gov/assets/img/latest/latest_512_0304.jpg',
'content_type': 'image/jpeg',
},
'sdo_magnetogram': {
'url': 'https://sdo.gsfc.nasa.gov/assets/img/latest/latest_512_HMIBC.jpg',
'content_type': 'image/jpeg',
},
}
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@space_weather_bp.route('/data')
def get_data():
"""Return aggregated space weather data from all sources."""
data = {
'kp_index': _fetch_kp_index(),
'kp_forecast': _fetch_kp_forecast(),
'scales': _fetch_scales(),
'flux': _fetch_flux(),
'alerts': _fetch_alerts(),
'solar_wind_plasma': _fetch_solar_wind_plasma(),
'solar_wind_mag': _fetch_solar_wind_mag(),
'xrays': _fetch_xrays(),
'xray_flares': _fetch_xray_flares(),
'flare_probability': _fetch_flare_probability(),
'solar_regions': _fetch_solar_regions(),
'sunspot_report': _fetch_sunspot_report(),
'band_conditions': _fetch_band_conditions(),
'timestamp': time.time(),
}
return jsonify(data)
@space_weather_bp.route('/image/<key>')
def get_image(key: str):
"""Proxy and cache whitelisted space weather images."""
entry = IMAGE_WHITELIST.get(key)
if not entry:
return jsonify({'error': 'Unknown image key'}), 404
cache_key = f'img_{key}'
cached = _cache_get(cache_key)
if cached is not None:
return Response(cached, content_type=entry['content_type'],
headers={'Cache-Control': 'public, max-age=300'})
img_data = _fetch_bytes(entry['url'])
if img_data is None:
return jsonify({'error': 'Failed to fetch image'}), 502
_cache_set(cache_key, img_data, TTL_IMAGE)
return Response(img_data, content_type=entry['content_type'],
headers={'Cache-Control': 'public, max-age=300'})
+52 -29
View File
@@ -15,7 +15,7 @@ from flask import Blueprint, jsonify, request, Response, send_file
import app as app_module import app as app_module
from utils.logging import get_logger from utils.logging import get_logger
from utils.sse import format_sse from utils.sse import sse_stream_fanout
from utils.event_pipeline import process_event from utils.event_pipeline import process_event
from utils.sstv import ( from utils.sstv import (
get_sstv_decoder, get_sstv_decoder,
@@ -27,6 +27,12 @@ logger = get_logger('intercept.sstv')
sstv_bp = Blueprint('sstv', __name__, url_prefix='/sstv') sstv_bp = Blueprint('sstv', __name__, url_prefix='/sstv')
# ISS SSTV runs on a fixed downlink; allow a small entry tolerance so users
# can type nearby values and still land on the canonical center frequency.
ISS_SSTV_MODULATION = 'fm'
ISS_SSTV_FREQUENCIES = (ISS_SSTV_FREQ,)
ISS_SSTV_FREQ_TOLERANCE_MHZ = 0.05
# Queue for SSE progress streaming # Queue for SSE progress streaming
_sstv_queue: queue.Queue = queue.Queue(maxsize=100) _sstv_queue: queue.Queue = queue.Queue(maxsize=100)
@@ -46,6 +52,14 @@ def _progress_callback(data: dict) -> None:
pass pass
def _normalize_iss_frequency(frequency_mhz: float) -> float | None:
"""Snap near-match user input to a supported ISS SSTV center frequency."""
for supported in ISS_SSTV_FREQUENCIES:
if abs(frequency_mhz - supported) <= ISS_SSTV_FREQ_TOLERANCE_MHZ:
return supported
return None
@sstv_bp.route('/status') @sstv_bp.route('/status')
def get_status(): def get_status():
""" """
@@ -62,6 +76,7 @@ def get_status():
'decoder': decoder.decoder_available, 'decoder': decoder.decoder_available,
'running': decoder.is_running, 'running': decoder.is_running,
'iss_frequency': ISS_SSTV_FREQ, 'iss_frequency': ISS_SSTV_FREQ,
'modulation': ISS_SSTV_MODULATION,
'image_count': len(decoder.get_images()), 'image_count': len(decoder.get_images()),
'doppler_enabled': decoder.doppler_enabled, 'doppler_enabled': decoder.doppler_enabled,
} }
@@ -82,6 +97,7 @@ def start_decoder():
JSON body (optional): JSON body (optional):
{ {
"frequency": 145.800, // Frequency in MHz (default: ISS 145.800) "frequency": 145.800, // Frequency in MHz (default: ISS 145.800)
"modulation": "fm", // ISS mode is FM-only
"device": 0, // RTL-SDR device index "device": 0, // RTL-SDR device index
"latitude": 40.7128, // Observer latitude for Doppler correction "latitude": 40.7128, // Observer latitude for Doppler correction
"longitude": -74.0060 // Observer longitude for Doppler correction "longitude": -74.0060 // Observer longitude for Doppler correction
@@ -106,6 +122,7 @@ def start_decoder():
return jsonify({ return jsonify({
'status': 'already_running', 'status': 'already_running',
'frequency': ISS_SSTV_FREQ, 'frequency': ISS_SSTV_FREQ,
'modulation': ISS_SSTV_MODULATION,
'doppler_enabled': decoder.doppler_enabled 'doppler_enabled': decoder.doppler_enabled
}) })
@@ -119,18 +136,29 @@ def start_decoder():
# Get parameters # Get parameters
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
frequency = data.get('frequency', ISS_SSTV_FREQ) frequency = data.get('frequency', ISS_SSTV_FREQ)
modulation = str(data.get('modulation', ISS_SSTV_MODULATION)).strip().lower()
device_index = data.get('device', 0) device_index = data.get('device', 0)
latitude = data.get('latitude') latitude = data.get('latitude')
longitude = data.get('longitude') longitude = data.get('longitude')
# Validate modulation (ISS mode is FM-only)
if modulation != ISS_SSTV_MODULATION:
return jsonify({
'status': 'error',
'message': f'Modulation must be {ISS_SSTV_MODULATION} for ISS SSTV mode'
}), 400
# Validate frequency # Validate frequency
try: try:
frequency = float(frequency) frequency = float(frequency)
if not (100 <= frequency <= 500): # VHF range normalized_frequency = _normalize_iss_frequency(frequency)
if normalized_frequency is None:
supported = ', '.join(f'{freq:.3f}' for freq in ISS_SSTV_FREQUENCIES)
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'message': 'Frequency must be between 100-500 MHz' 'message': f'Supported ISS SSTV frequency: {supported} MHz FM'
}), 400 }), 400
frequency = normalized_frequency
except (TypeError, ValueError): except (TypeError, ValueError):
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
@@ -178,7 +206,8 @@ def start_decoder():
frequency=frequency, frequency=frequency,
device_index=device_index, device_index=device_index,
latitude=latitude, latitude=latitude,
longitude=longitude longitude=longitude,
modulation=ISS_SSTV_MODULATION,
) )
if success: if success:
@@ -187,6 +216,7 @@ def start_decoder():
result = { result = {
'status': 'started', 'status': 'started',
'frequency': frequency, 'frequency': frequency,
'modulation': ISS_SSTV_MODULATION,
'device': device_index, 'device': device_index,
'doppler_enabled': decoder.doppler_enabled 'doppler_enabled': decoder.doppler_enabled
} }
@@ -379,8 +409,8 @@ def delete_all_images():
return jsonify({'status': 'ok', 'deleted': count}) return jsonify({'status': 'ok', 'deleted': count})
@sstv_bp.route('/stream') @sstv_bp.route('/stream')
def stream_progress(): def stream_progress():
""" """
SSE stream of SSTV decode progress. SSE stream of SSTV decode progress.
@@ -392,29 +422,22 @@ def stream_progress():
Returns: Returns:
SSE stream (text/event-stream) SSE stream (text/event-stream)
""" """
def generate() -> Generator[str, None, None]: def _on_msg(msg: dict[str, Any]) -> None:
last_keepalive = time.time() process_event('sstv', msg, msg.get('type'))
keepalive_interval = 30.0
response = Response(
while True: sse_stream_fanout(
try: source_queue=_sstv_queue,
progress = _sstv_queue.get(timeout=1) channel_key='sstv',
last_keepalive = time.time() timeout=1.0,
try: keepalive_interval=30.0,
process_event('sstv', progress, progress.get('type')) on_message=_on_msg,
except Exception: ),
pass mimetype='text/event-stream',
yield format_sse(progress) )
except queue.Empty: response.headers['Cache-Control'] = 'no-cache'
now = time.time() response.headers['X-Accel-Buffering'] = 'no'
if now - last_keepalive >= keepalive_interval: response.headers['Connection'] = 'keep-alive'
yield format_sse({'type': 'keepalive'})
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response return response
+37 -21
View File
@@ -13,8 +13,9 @@ from pathlib import Path
from flask import Blueprint, Response, jsonify, request, send_file from flask import Blueprint, Response, jsonify, request, send_file
import app as app_module
from utils.logging import get_logger from utils.logging import get_logger
from utils.sse import format_sse from utils.sse import sse_stream_fanout
from utils.event_pipeline import process_event from utils.event_pipeline import process_event
from utils.sstv import ( from utils.sstv import (
get_general_sstv_decoder, get_general_sstv_decoder,
@@ -27,6 +28,9 @@ sstv_general_bp = Blueprint('sstv_general', __name__, url_prefix='/sstv-general'
# Queue for SSE progress streaming # Queue for SSE progress streaming
_sstv_general_queue: queue.Queue = queue.Queue(maxsize=100) _sstv_general_queue: queue.Queue = queue.Queue(maxsize=100)
# Track which device is being used
_sstv_general_active_device: int | None = None
# Predefined SSTV frequencies # Predefined SSTV frequencies
SSTV_FREQUENCIES = [ SSTV_FREQUENCIES = [
{'band': '80 m', 'frequency': 3.845, 'modulation': 'lsb', 'notes': 'Common US SSTV calling frequency', 'type': 'Terrestrial HF'}, {'band': '80 m', 'frequency': 3.845, 'modulation': 'lsb', 'notes': 'Common US SSTV calling frequency', 'type': 'Terrestrial HF'},
@@ -40,7 +44,7 @@ SSTV_FREQUENCIES = [
{'band': '15 m', 'frequency': 21.340, 'modulation': 'usb', 'notes': 'International calling frequency', 'type': 'Terrestrial HF'}, {'band': '15 m', 'frequency': 21.340, 'modulation': 'usb', 'notes': 'International calling frequency', 'type': 'Terrestrial HF'},
{'band': '10 m', 'frequency': 28.680, 'modulation': 'usb', 'notes': 'International calling frequency', 'type': 'Terrestrial HF'}, {'band': '10 m', 'frequency': 28.680, 'modulation': 'usb', 'notes': 'International calling frequency', 'type': 'Terrestrial HF'},
{'band': '6 m', 'frequency': 50.950, 'modulation': 'usb', 'notes': 'SSTV calling (less common)', 'type': 'Terrestrial VHF'}, {'band': '6 m', 'frequency': 50.950, 'modulation': 'usb', 'notes': 'SSTV calling (less common)', 'type': 'Terrestrial VHF'},
{'band': '2 m', 'frequency': 145.625, 'modulation': 'fm', 'notes': 'Australia/common simplex (FM sometimes used)', 'type': 'Terrestrial VHF'}, {'band': '2 m', 'frequency': 145.500, 'modulation': 'fm', 'notes': 'Australia/common simplex (FM sometimes used)', 'type': 'Terrestrial VHF'},
{'band': '70 cm', 'frequency': 433.775, 'modulation': 'fm', 'notes': 'Australia/common simplex', 'type': 'Terrestrial UHF'}, {'band': '70 cm', 'frequency': 433.775, 'modulation': 'fm', 'notes': 'Australia/common simplex', 'type': 'Terrestrial UHF'},
] ]
@@ -150,6 +154,17 @@ def start_decoder():
'message': 'Modulation must be fm, usb, or lsb', 'message': 'Modulation must be fm, usb, or lsb',
}), 400 }), 400
# Claim SDR device
global _sstv_general_active_device
device_int = int(device_index)
error = app_module.claim_sdr_device(device_int, 'sstv_general')
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error,
}), 409
# Set callback and start # Set callback and start
decoder.set_callback(_progress_callback) decoder.set_callback(_progress_callback)
success = decoder.start( success = decoder.start(
@@ -159,6 +174,7 @@ def start_decoder():
) )
if success: if success:
_sstv_general_active_device = device_int
return jsonify({ return jsonify({
'status': 'started', 'status': 'started',
'frequency': frequency, 'frequency': frequency,
@@ -166,6 +182,7 @@ def start_decoder():
'device': device_index, 'device': device_index,
}) })
else: else:
app_module.release_sdr_device(device_int)
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'message': 'Failed to start decoder', 'message': 'Failed to start decoder',
@@ -175,8 +192,14 @@ def start_decoder():
@sstv_general_bp.route('/stop', methods=['POST']) @sstv_general_bp.route('/stop', methods=['POST'])
def stop_decoder(): def stop_decoder():
"""Stop general SSTV decoder.""" """Stop general SSTV decoder."""
global _sstv_general_active_device
decoder = get_general_sstv_decoder() decoder = get_general_sstv_decoder()
decoder.stop() decoder.stop()
if _sstv_general_active_device is not None:
app_module.release_sdr_device(_sstv_general_active_device)
_sstv_general_active_device = None
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
@@ -266,26 +289,19 @@ def delete_all_images():
@sstv_general_bp.route('/stream') @sstv_general_bp.route('/stream')
def stream_progress(): def stream_progress():
"""SSE stream of SSTV decode progress.""" """SSE stream of SSTV decode progress."""
def generate() -> Generator[str, None, None]: def _on_msg(msg: dict[str, Any]) -> None:
last_keepalive = time.time() process_event('sstv_general', msg, msg.get('type'))
keepalive_interval = 30.0
while True: response = Response(
try: sse_stream_fanout(
progress = _sstv_general_queue.get(timeout=1) source_queue=_sstv_general_queue,
last_keepalive = time.time() channel_key='sstv_general',
try: timeout=1.0,
process_event('sstv_general', progress, progress.get('type')) keepalive_interval=30.0,
except Exception: on_message=_on_msg,
pass ),
yield format_sse(progress) mimetype='text/event-stream',
except queue.Empty: )
now = time.time()
if now - last_keepalive >= keepalive_interval:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache' response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no' response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive' response.headers['Connection'] = 'keep-alive'
+424
View File
@@ -0,0 +1,424 @@
"""SubGHz transceiver routes.
Provides endpoints for HackRF-based SubGHz signal capture, protocol decoding,
signal replay/transmit, and wideband spectrum analysis.
"""
from __future__ import annotations
import queue
from flask import Blueprint, jsonify, request, Response, send_file
from utils.logging import get_logger
from utils.sse import sse_stream
from utils.subghz import get_subghz_manager
from utils.constants import (
SUBGHZ_FREQ_MIN_MHZ,
SUBGHZ_FREQ_MAX_MHZ,
SUBGHZ_LNA_GAIN_MAX,
SUBGHZ_VGA_GAIN_MAX,
SUBGHZ_TX_VGA_GAIN_MAX,
SUBGHZ_TX_MAX_DURATION,
SUBGHZ_SAMPLE_RATES,
SUBGHZ_PRESETS,
)
logger = get_logger('intercept.subghz')
subghz_bp = Blueprint('subghz', __name__, url_prefix='/subghz')
# SSE queue for streaming events to frontend
_subghz_queue: queue.Queue = queue.Queue(maxsize=200)
def _event_callback(event: dict) -> None:
"""Forward SubGhzManager events to the SSE queue."""
try:
_subghz_queue.put_nowait(event)
except queue.Full:
try:
_subghz_queue.get_nowait()
_subghz_queue.put_nowait(event)
except queue.Empty:
pass
def _validate_frequency_hz(data: dict, key: str = 'frequency_hz') -> tuple[int | None, str | None]:
"""Validate frequency in Hz from request data. Returns (freq_hz, error_msg)."""
raw = data.get(key)
if raw is None:
return None, f'{key} is required'
try:
freq_hz = int(raw)
freq_mhz = freq_hz / 1_000_000
if not (SUBGHZ_FREQ_MIN_MHZ <= freq_mhz <= SUBGHZ_FREQ_MAX_MHZ):
return None, f'Frequency must be between {SUBGHZ_FREQ_MIN_MHZ}-{SUBGHZ_FREQ_MAX_MHZ} MHz'
return freq_hz, None
except (ValueError, TypeError):
return None, f'Invalid {key}'
def _validate_serial(data: dict) -> str | None:
"""Extract and validate optional HackRF device serial."""
serial = data.get('device_serial', '')
if not serial or not isinstance(serial, str):
return None
# HackRF serials are hex strings
serial = serial.strip()
if serial and all(c in '0123456789abcdefABCDEF' for c in serial):
return serial
return None
def _validate_int(data: dict, key: str, default: int, min_val: int, max_val: int) -> int:
"""Validate integer parameter with bounds clamping."""
try:
val = int(data.get(key, default))
return max(min_val, min(max_val, val))
except (ValueError, TypeError):
return default
def _validate_decode_profile(data: dict, default: str = 'weather') -> str:
profile = data.get('decode_profile', default)
if not isinstance(profile, str):
return default
profile = profile.strip().lower()
if profile in {'weather', 'all'}:
return profile
return default
def _validate_optional_float(data: dict, key: str) -> tuple[float | None, str | None]:
raw = data.get(key)
if raw is None or raw == '':
return None, None
try:
return float(raw), None
except (ValueError, TypeError):
return None, f'Invalid {key}'
def _validate_bool(data: dict, key: str, default: bool = False) -> bool:
raw = data.get(key, default)
if isinstance(raw, bool):
return raw
if isinstance(raw, (int, float)):
return bool(raw)
if isinstance(raw, str):
return raw.strip().lower() in {'1', 'true', 'yes', 'on', 'enabled'}
return default
# ------------------------------------------------------------------
# STATUS
# ------------------------------------------------------------------
@subghz_bp.route('/status')
def get_status():
manager = get_subghz_manager()
return jsonify(manager.get_status())
@subghz_bp.route('/presets')
def get_presets():
return jsonify({'presets': SUBGHZ_PRESETS, 'sample_rates': SUBGHZ_SAMPLE_RATES})
# ------------------------------------------------------------------
# RECEIVE
# ------------------------------------------------------------------
@subghz_bp.route('/receive/start', methods=['POST'])
def start_receive():
data = request.get_json(silent=True) or {}
freq_hz, err = _validate_frequency_hz(data)
if err:
return jsonify({'status': 'error', 'message': err}), 400
sample_rate = _validate_int(data, 'sample_rate', 2000000, 2000000, 20000000)
lna_gain = _validate_int(data, 'lna_gain', 32, 0, SUBGHZ_LNA_GAIN_MAX)
vga_gain = _validate_int(data, 'vga_gain', 20, 0, SUBGHZ_VGA_GAIN_MAX)
trigger_enabled = _validate_bool(data, 'trigger_enabled', False)
trigger_pre_ms = _validate_int(data, 'trigger_pre_ms', 350, 50, 5000)
trigger_post_ms = _validate_int(data, 'trigger_post_ms', 700, 100, 10000)
device_serial = _validate_serial(data)
manager = get_subghz_manager()
manager.set_callback(_event_callback)
result = manager.start_receive(
frequency_hz=freq_hz,
sample_rate=sample_rate,
lna_gain=lna_gain,
vga_gain=vga_gain,
trigger_enabled=trigger_enabled,
trigger_pre_ms=trigger_pre_ms,
trigger_post_ms=trigger_post_ms,
device_serial=device_serial,
)
status_code = 200 if result.get('status') != 'error' else 409
return jsonify(result), status_code
@subghz_bp.route('/receive/stop', methods=['POST'])
def stop_receive():
manager = get_subghz_manager()
result = manager.stop_receive()
return jsonify(result)
# ------------------------------------------------------------------
# DECODE
# ------------------------------------------------------------------
@subghz_bp.route('/decode/start', methods=['POST'])
def start_decode():
data = request.get_json(silent=True) or {}
freq_hz, err = _validate_frequency_hz(data)
if err:
return jsonify({'status': 'error', 'message': err}), 400
sample_rate = _validate_int(data, 'sample_rate', 2000000, 2000000, 20000000)
lna_gain = _validate_int(data, 'lna_gain', 32, 0, SUBGHZ_LNA_GAIN_MAX)
vga_gain = _validate_int(data, 'vga_gain', 20, 0, SUBGHZ_VGA_GAIN_MAX)
decode_profile = _validate_decode_profile(data)
device_serial = _validate_serial(data)
manager = get_subghz_manager()
manager.set_callback(_event_callback)
result = manager.start_decode(
frequency_hz=freq_hz,
sample_rate=sample_rate,
lna_gain=lna_gain,
vga_gain=vga_gain,
decode_profile=decode_profile,
device_serial=device_serial,
)
status_code = 200 if result.get('status') != 'error' else 409
return jsonify(result), status_code
@subghz_bp.route('/decode/stop', methods=['POST'])
def stop_decode():
manager = get_subghz_manager()
result = manager.stop_decode()
return jsonify(result)
# ------------------------------------------------------------------
# TRANSMIT
# ------------------------------------------------------------------
@subghz_bp.route('/transmit', methods=['POST'])
def start_transmit():
data = request.get_json(silent=True) or {}
capture_id = data.get('capture_id')
if not capture_id or not isinstance(capture_id, str):
return jsonify({'status': 'error', 'message': 'capture_id is required'}), 400
# Sanitize capture_id
if not capture_id.isalnum():
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
tx_gain = _validate_int(data, 'tx_gain', 20, 0, SUBGHZ_TX_VGA_GAIN_MAX)
max_duration = _validate_int(data, 'max_duration', 10, 1, SUBGHZ_TX_MAX_DURATION)
start_seconds, start_err = _validate_optional_float(data, 'start_seconds')
if start_err:
return jsonify({'status': 'error', 'message': start_err}), 400
duration_seconds, duration_err = _validate_optional_float(data, 'duration_seconds')
if duration_err:
return jsonify({'status': 'error', 'message': duration_err}), 400
device_serial = _validate_serial(data)
manager = get_subghz_manager()
manager.set_callback(_event_callback)
result = manager.transmit(
capture_id=capture_id,
tx_gain=tx_gain,
max_duration=max_duration,
start_seconds=start_seconds,
duration_seconds=duration_seconds,
device_serial=device_serial,
)
status_code = 200 if result.get('status') != 'error' else 400
return jsonify(result), status_code
@subghz_bp.route('/transmit/stop', methods=['POST'])
def stop_transmit():
manager = get_subghz_manager()
result = manager.stop_transmit()
return jsonify(result)
# ------------------------------------------------------------------
# SWEEP
# ------------------------------------------------------------------
@subghz_bp.route('/sweep/start', methods=['POST'])
def start_sweep():
data = request.get_json(silent=True) or {}
try:
freq_start = float(data.get('freq_start_mhz', 300))
freq_end = float(data.get('freq_end_mhz', 928))
if freq_start >= freq_end:
return jsonify({'status': 'error', 'message': 'freq_start must be less than freq_end'}), 400
if freq_start < SUBGHZ_FREQ_MIN_MHZ or freq_end > SUBGHZ_FREQ_MAX_MHZ:
return jsonify({'status': 'error', 'message': f'Frequency range: {SUBGHZ_FREQ_MIN_MHZ}-{SUBGHZ_FREQ_MAX_MHZ} MHz'}), 400
except (ValueError, TypeError):
return jsonify({'status': 'error', 'message': 'Invalid frequency range'}), 400
bin_width = _validate_int(data, 'bin_width', 100000, 10000, 5000000)
device_serial = _validate_serial(data)
manager = get_subghz_manager()
manager.set_callback(_event_callback)
result = manager.start_sweep(
freq_start_mhz=freq_start,
freq_end_mhz=freq_end,
bin_width=bin_width,
device_serial=device_serial,
)
status_code = 200 if result.get('status') != 'error' else 409
return jsonify(result), status_code
@subghz_bp.route('/sweep/stop', methods=['POST'])
def stop_sweep():
manager = get_subghz_manager()
result = manager.stop_sweep()
return jsonify(result)
# ------------------------------------------------------------------
# CAPTURES LIBRARY
# ------------------------------------------------------------------
@subghz_bp.route('/captures')
def list_captures():
manager = get_subghz_manager()
captures = manager.list_captures()
return jsonify({
'status': 'ok',
'captures': [c.to_dict() for c in captures],
'count': len(captures),
})
@subghz_bp.route('/captures/<capture_id>')
def get_capture(capture_id: str):
if not capture_id.isalnum():
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
manager = get_subghz_manager()
capture = manager.get_capture(capture_id)
if not capture:
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
return jsonify({'status': 'ok', 'capture': capture.to_dict()})
@subghz_bp.route('/captures/<capture_id>/download')
def download_capture(capture_id: str):
if not capture_id.isalnum():
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
manager = get_subghz_manager()
path = manager.get_capture_path(capture_id)
if not path:
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
return send_file(
path,
mimetype='application/octet-stream',
as_attachment=True,
download_name=path.name,
)
@subghz_bp.route('/captures/<capture_id>/trim', methods=['POST'])
def trim_capture(capture_id: str):
if not capture_id.isalnum():
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
data = request.get_json(silent=True) or {}
start_seconds, start_err = _validate_optional_float(data, 'start_seconds')
if start_err:
return jsonify({'status': 'error', 'message': start_err}), 400
duration_seconds, duration_err = _validate_optional_float(data, 'duration_seconds')
if duration_err:
return jsonify({'status': 'error', 'message': duration_err}), 400
label = data.get('label', '')
if label is None:
label = ''
if not isinstance(label, str) or len(label) > 100:
return jsonify({'status': 'error', 'message': 'Label must be a string (max 100 chars)'}), 400
manager = get_subghz_manager()
result = manager.trim_capture(
capture_id=capture_id,
start_seconds=start_seconds,
duration_seconds=duration_seconds,
label=label,
)
if result.get('status') == 'ok':
return jsonify(result), 200
message = str(result.get('message') or 'Trim failed')
status_code = 404 if 'not found' in message.lower() else 400
return jsonify(result), status_code
@subghz_bp.route('/captures/<capture_id>', methods=['DELETE'])
def delete_capture(capture_id: str):
if not capture_id.isalnum():
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
manager = get_subghz_manager()
if manager.delete_capture(capture_id):
return jsonify({'status': 'deleted', 'id': capture_id})
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
@subghz_bp.route('/captures/<capture_id>', methods=['PATCH'])
def update_capture(capture_id: str):
if not capture_id.isalnum():
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
data = request.get_json(silent=True) or {}
label = data.get('label', '')
if not isinstance(label, str) or len(label) > 100:
return jsonify({'status': 'error', 'message': 'Label must be a string (max 100 chars)'}), 400
manager = get_subghz_manager()
if manager.update_capture_label(capture_id, label):
return jsonify({'status': 'updated', 'id': capture_id, 'label': label})
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
# ------------------------------------------------------------------
# SSE STREAM
# ------------------------------------------------------------------
@subghz_bp.route('/stream')
def stream():
response = Response(sse_stream(_subghz_queue), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response
+10 -16
View File
@@ -61,6 +61,7 @@ from utils.tscm.device_identity import (
ingest_wifi_dict, ingest_wifi_dict,
) )
from utils.event_pipeline import process_event from utils.event_pipeline import process_event
from utils.sse import sse_stream_fanout
# Import unified Bluetooth scanner helper for TSCM integration # Import unified Bluetooth scanner helper for TSCM integration
try: try:
@@ -629,24 +630,17 @@ def sweep_status():
@tscm_bp.route('/sweep/stream') @tscm_bp.route('/sweep/stream')
def sweep_stream(): def sweep_stream():
"""SSE stream for real-time sweep updates.""" """SSE stream for real-time sweep updates."""
def generate(): def _on_msg(msg: dict[str, Any]) -> None:
while True: process_event('tscm', msg, msg.get('type'))
try:
if tscm_queue:
msg = tscm_queue.get(timeout=1)
try:
process_event('tscm', msg, msg.get('type'))
except Exception:
pass
yield f"data: {json.dumps(msg)}\n\n"
else:
time.sleep(1)
yield f"data: {json.dumps({'type': 'keepalive'})}\n\n"
except queue.Empty:
yield f"data: {json.dumps({'type': 'keepalive'})}\n\n"
return Response( return Response(
generate(), sse_stream_fanout(
source_queue=tscm_queue,
channel_key='tscm',
timeout=1.0,
keepalive_interval=30.0,
on_message=_on_msg,
),
mimetype='text/event-stream', mimetype='text/event-stream',
headers={ headers={
'Cache-Control': 'no-cache', 'Cache-Control': 'no-cache',
+383
View File
@@ -0,0 +1,383 @@
"""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 datetime import datetime
from typing import Generator
from flask import Blueprint, jsonify, request, Response
import app as app_module
from utils.logging import sensor_logger as logger
from utils.validation import validate_device_index, validate_gain, validate_ppm
from utils.sdr import SDRFactory, SDRType
from utils.sse import sse_stream_fanout
from utils.event_pipeline import process_event
from utils.constants import (
PROCESS_TERMINATE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT,
PROCESS_START_WAIT,
)
from utils.process import register_process, unregister_process
vdl2_bp = Blueprint('vdl2', __name__, url_prefix='/vdl2')
# Default VDL2 frequencies (MHz) - common worldwide
DEFAULT_VDL2_FREQUENCIES = [
'136975000', # Primary worldwide
'136725000', # Europe
'136775000', # Europe
'136800000', # Multi-region
'136875000', # Multi-region
]
# Message counter for statistics
vdl2_message_count = 0
vdl2_last_message_time = None
# Track which device is being used
vdl2_active_device: int | None = None
def find_dumpvdl2():
"""Find dumpvdl2 binary."""
return shutil.which('dumpvdl2')
def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) -> None:
"""Stream dumpvdl2 JSON output to queue."""
global vdl2_message_count, vdl2_last_message_time
try:
app_module.vdl2_queue.put({'type': 'status', 'status': 'started'})
# Use appropriate sentinel based on mode (text mode for pty on macOS)
sentinel = '' if is_text_mode else b''
for line in iter(process.stdout.readline, sentinel):
if is_text_mode:
line = line.strip()
else:
line = line.decode('utf-8', errors='replace').strip()
if not line:
continue
try:
data = json.loads(line)
# Add our metadata
data['type'] = 'vdl2'
data['timestamp'] = datetime.utcnow().isoformat() + 'Z'
# Update stats
vdl2_message_count += 1
vdl2_last_message_time = time.time()
app_module.vdl2_queue.put(data)
# Feed flight correlator
try:
from utils.flight_correlator import get_flight_correlator
get_flight_correlator().add_vdl2_message(data)
except Exception:
pass
# Log if enabled
if app_module.logging_enabled:
try:
with open(app_module.log_file_path, 'a') as f:
ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
f.write(f"{ts} | VDL2 | {json.dumps(data)}\n")
except Exception:
pass
except json.JSONDecodeError:
# Not JSON - could be status message
if line:
logger.debug(f"dumpvdl2 non-JSON: {line[:100]}")
except Exception as e:
logger.error(f"VDL2 stream error: {e}")
app_module.vdl2_queue.put({'type': 'error', 'message': str(e)})
finally:
global vdl2_active_device
# Ensure process is terminated
try:
process.terminate()
process.wait(timeout=2)
except Exception:
try:
process.kill()
except Exception:
pass
unregister_process(process)
app_module.vdl2_queue.put({'type': 'status', 'status': 'stopped'})
with app_module.vdl2_lock:
app_module.vdl2_process = None
# Release SDR device
if vdl2_active_device is not None:
app_module.release_sdr_device(vdl2_active_device)
vdl2_active_device = None
@vdl2_bp.route('/tools')
def check_vdl2_tools() -> Response:
"""Check for VDL2 decoding tools."""
has_dumpvdl2 = find_dumpvdl2() is not None
return jsonify({
'dumpvdl2': has_dumpvdl2,
'ready': has_dumpvdl2
})
@vdl2_bp.route('/status')
def vdl2_status() -> Response:
"""Get VDL2 decoder status."""
running = False
if app_module.vdl2_process:
running = app_module.vdl2_process.poll() is None
return jsonify({
'running': running,
'message_count': vdl2_message_count,
'last_message_time': vdl2_last_message_time,
'queue_size': app_module.vdl2_queue.qsize()
})
@vdl2_bp.route('/start', methods=['POST'])
def start_vdl2() -> Response:
"""Start VDL2 decoder."""
global vdl2_message_count, vdl2_last_message_time, vdl2_active_device
with app_module.vdl2_lock:
if app_module.vdl2_process and app_module.vdl2_process.poll() is None:
return jsonify({
'status': 'error',
'message': 'VDL2 decoder already running'
}), 409
# Check for dumpvdl2
dumpvdl2_path = find_dumpvdl2()
if not dumpvdl2_path:
return jsonify({
'status': 'error',
'message': 'dumpvdl2 not found. Install from: https://github.com/szpajder/dumpvdl2'
}), 400
data = request.json or {}
# Validate inputs
try:
device = validate_device_index(data.get('device', '0'))
gain = validate_gain(data.get('gain', '40'))
ppm = validate_ppm(data.get('ppm', '0'))
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
# Check if device is available
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'vdl2')
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
vdl2_active_device = device_int
# Get frequencies - use provided or defaults
# dumpvdl2 expects frequencies in Hz (integers)
frequencies = data.get('frequencies', DEFAULT_VDL2_FREQUENCIES)
if isinstance(frequencies, str):
frequencies = [f.strip() for f in frequencies.split(',')]
# Clear queue
while not app_module.vdl2_queue.empty():
try:
app_module.vdl2_queue.get_nowait()
except queue.Empty:
break
# Reset stats
vdl2_message_count = 0
vdl2_last_message_time = None
# 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
# dumpvdl2 --output decoded:json --rtlsdr <device> --gain <gain> --correction <ppm> <freq1> <freq2> ...
cmd = [dumpvdl2_path]
cmd.extend(['--output', 'decoded:json:file:path=-'])
if is_soapy:
# SoapySDR device
sdr_device = SDRFactory.create_default_device(sdr_type, index=device_int)
builder = SDRFactory.get_builder(sdr_type)
device_str = builder._build_device_string(sdr_device)
cmd.extend(['--soapysdr', device_str])
else:
cmd.extend(['--rtlsdr', str(device)])
# Add gain
if gain and str(gain) != '0':
cmd.extend(['--gain', str(gain)])
# Add PPM correction if specified
if ppm and str(ppm) != '0':
cmd.extend(['--correction', str(ppm)])
# Add frequencies (dumpvdl2 takes them as positional args in Hz)
cmd.extend(frequencies)
logger.info(f"Starting VDL2 decoder: {' '.join(cmd)}")
try:
is_text_mode = False
# On macOS, use pty to avoid stdout buffering issues
if platform.system() == 'Darwin':
master_fd, slave_fd = pty.openpty()
process = subprocess.Popen(
cmd,
stdout=slave_fd,
stderr=subprocess.PIPE,
start_new_session=True
)
os.close(slave_fd)
# Wrap master_fd as a text file for line-buffered reading
process.stdout = 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)
if process.poll() is not None:
# Process died - release device
if vdl2_active_device is not None:
app_module.release_sdr_device(vdl2_active_device)
vdl2_active_device = None
stderr = ''
if process.stderr:
stderr = process.stderr.read().decode('utf-8', errors='replace')
error_msg = 'dumpvdl2 failed to start'
if stderr:
error_msg += f': {stderr[:200]}'
logger.error(error_msg)
return jsonify({'status': 'error', 'message': error_msg}), 500
app_module.vdl2_process = process
register_process(process)
# Start output streaming thread
thread = threading.Thread(
target=stream_vdl2_output,
args=(process, is_text_mode),
daemon=True
)
thread.start()
return jsonify({
'status': 'started',
'frequencies': frequencies,
'device': device,
'gain': gain
})
except Exception as e:
# Release device on failure
if vdl2_active_device is not None:
app_module.release_sdr_device(vdl2_active_device)
vdl2_active_device = None
logger.error(f"Failed to start VDL2 decoder: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500
@vdl2_bp.route('/stop', methods=['POST'])
def stop_vdl2() -> Response:
"""Stop VDL2 decoder."""
global vdl2_active_device
with app_module.vdl2_lock:
if not app_module.vdl2_process:
return jsonify({
'status': 'error',
'message': 'VDL2 decoder not running'
}), 400
try:
app_module.vdl2_process.terminate()
app_module.vdl2_process.wait(timeout=PROCESS_TERMINATE_TIMEOUT)
except subprocess.TimeoutExpired:
app_module.vdl2_process.kill()
except Exception as e:
logger.error(f"Error stopping VDL2: {e}")
app_module.vdl2_process = None
# Release device from registry
if vdl2_active_device is not None:
app_module.release_sdr_device(vdl2_active_device)
vdl2_active_device = 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('/frequencies')
def get_frequencies() -> Response:
"""Get default VDL2 frequencies."""
return jsonify({
'default': DEFAULT_VDL2_FREQUENCIES,
'regions': {
'north_america': ['136975000', '136100000', '136650000', '136700000', '136800000'],
'europe': ['136975000', '136675000', '136725000', '136775000', '136825000'],
'asia_pacific': ['136975000', '136900000'],
}
})
+39 -46
View File
@@ -83,11 +83,10 @@ def init_waterfall_websocket(app: Flask):
# Import app module for device claiming # Import app module for device claiming
import app as app_module import app as app_module
iq_process = None iq_process = None
reader_thread = None reader_thread = None
stop_event = threading.Event() stop_event = threading.Event()
claimed_device = None claimed_device = None
claimed_sdr_type = None
# Queue for outgoing messages — only the main loop touches ws.send() # Queue for outgoing messages — only the main loop touches ws.send()
send_queue = queue.Queue(maxsize=120) send_queue = queue.Queue(maxsize=120)
@@ -137,14 +136,13 @@ def init_waterfall_websocket(app: Flask):
stop_event.set() stop_event.set()
if reader_thread and reader_thread.is_alive(): if reader_thread and reader_thread.is_alive():
reader_thread.join(timeout=2) reader_thread.join(timeout=2)
if iq_process: if iq_process:
safe_terminate(iq_process) safe_terminate(iq_process)
unregister_process(iq_process) unregister_process(iq_process)
iq_process = None iq_process = None
if claimed_device is not None: if claimed_device is not None:
app_module.release_sdr_device(claimed_device, claimed_sdr_type or 'rtlsdr') app_module.release_sdr_device(claimed_device)
claimed_device = None claimed_device = None
claimed_sdr_type = None
stop_event.clear() stop_event.clear()
# Flush stale frames from previous capture # Flush stale frames from previous capture
while not send_queue.empty(): while not send_queue.empty():
@@ -187,16 +185,15 @@ def init_waterfall_websocket(app: Flask):
end_freq = center_freq + effective_span_mhz / 2 end_freq = center_freq + effective_span_mhz / 2
# Claim the device # Claim the device
claim_err = app_module.claim_sdr_device(device_index, 'waterfall', sdr_type.value) claim_err = app_module.claim_sdr_device(device_index, 'waterfall')
if claim_err: if claim_err:
ws.send(json.dumps({ ws.send(json.dumps({
'status': 'error', 'status': 'error',
'message': claim_err, 'message': claim_err,
'error_type': 'DEVICE_BUSY', 'error_type': 'DEVICE_BUSY',
})) }))
continue continue
claimed_device = device_index claimed_device = device_index
claimed_sdr_type = sdr_type.value
# Build I/Q capture command # Build I/Q capture command
try: try:
@@ -210,12 +207,11 @@ def init_waterfall_websocket(app: Flask):
ppm=ppm, ppm=ppm,
bias_t=bias_t, bias_t=bias_t,
) )
except NotImplementedError as e: except NotImplementedError as e:
app_module.release_sdr_device(device_index, sdr_type.value) app_module.release_sdr_device(device_index)
claimed_device = None claimed_device = None
claimed_sdr_type = None ws.send(json.dumps({
ws.send(json.dumps({ 'status': 'error',
'status': 'error',
'message': str(e), 'message': str(e),
})) }))
continue continue
@@ -259,11 +255,10 @@ def init_waterfall_websocket(app: Flask):
safe_terminate(iq_process) safe_terminate(iq_process)
unregister_process(iq_process) unregister_process(iq_process)
iq_process = None iq_process = None
app_module.release_sdr_device(device_index, sdr_type.value) app_module.release_sdr_device(device_index)
claimed_device = None claimed_device = None
claimed_sdr_type = None ws.send(json.dumps({
ws.send(json.dumps({ 'status': 'error',
'status': 'error',
'message': f'Failed to start I/Q capture: {e}', 'message': f'Failed to start I/Q capture: {e}',
})) }))
continue continue
@@ -350,16 +345,15 @@ def init_waterfall_websocket(app: Flask):
if reader_thread and reader_thread.is_alive(): if reader_thread and reader_thread.is_alive():
reader_thread.join(timeout=2) reader_thread.join(timeout=2)
reader_thread = None reader_thread = None
if iq_process: if iq_process:
safe_terminate(iq_process) safe_terminate(iq_process)
unregister_process(iq_process) unregister_process(iq_process)
iq_process = None iq_process = None
if claimed_device is not None: if claimed_device is not None:
app_module.release_sdr_device(claimed_device, claimed_sdr_type or 'rtlsdr') app_module.release_sdr_device(claimed_device)
claimed_device = None claimed_device = None
claimed_sdr_type = None stop_event.clear()
stop_event.clear() ws.send(json.dumps({'status': 'stopped'}))
ws.send(json.dumps({'status': 'stopped'}))
except Exception as e: except Exception as e:
logger.info(f"WebSocket waterfall closed: {e}") logger.info(f"WebSocket waterfall closed: {e}")
@@ -368,12 +362,11 @@ def init_waterfall_websocket(app: Flask):
stop_event.set() stop_event.set()
if reader_thread and reader_thread.is_alive(): if reader_thread and reader_thread.is_alive():
reader_thread.join(timeout=2) reader_thread.join(timeout=2)
if iq_process: if iq_process:
safe_terminate(iq_process) safe_terminate(iq_process)
unregister_process(iq_process) unregister_process(iq_process)
if claimed_device is not None: if claimed_device is not None:
app_module.release_sdr_device(claimed_device, claimed_sdr_type or 'rtlsdr') app_module.release_sdr_device(claimed_device)
claimed_sdr_type = None
# Complete WebSocket close handshake, then shut down the # Complete WebSocket close handshake, then shut down the
# raw socket so Werkzeug cannot write its HTTP 200 response # raw socket so Werkzeug cannot write its HTTP 200 response
# on top of the WebSocket stream (which browsers see as # on top of the WebSocket stream (which browsers see as
+20 -13
View File
@@ -563,19 +563,26 @@ def enable_schedule():
'message': 'Invalid parameter value' 'message': 'Invalid parameter value'
}), 400 }), 400
scheduler = get_weather_sat_scheduler() scheduler = get_weather_sat_scheduler()
scheduler.set_callbacks(_progress_callback, _scheduler_event_callback) scheduler.set_callbacks(_progress_callback, _scheduler_event_callback)
result = scheduler.enable( try:
lat=lat, result = scheduler.enable(
lon=lon, lat=lat,
min_elevation=min_elev, lon=lon,
device=device, min_elevation=min_elev,
gain=gain_val, device=device,
bias_t=bool(data.get('bias_t', False)), gain=gain_val,
) bias_t=bool(data.get('bias_t', False)),
)
return jsonify({'status': 'ok', **result}) 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']) @weather_sat_bp.route('/schedule/disable', methods=['POST'])
+35 -50
View File
@@ -20,7 +20,7 @@ from utils.dependencies import check_tool, get_tool_path
from utils.logging import wifi_logger as logger from utils.logging import wifi_logger as logger
from utils.process import is_valid_mac, is_valid_channel from utils.process import is_valid_mac, is_valid_channel
from utils.validation import validate_wifi_channel, validate_mac_address, validate_network_interface from utils.validation import validate_wifi_channel, validate_mac_address, validate_network_interface
from utils.sse import format_sse from utils.sse import format_sse, sse_stream_fanout
from utils.event_pipeline import process_event from utils.event_pipeline import process_event
from data.oui import get_manufacturer from data.oui import get_manufacturer
from utils.constants import ( from utils.constants import (
@@ -1132,33 +1132,26 @@ def get_wifi_networks():
}) })
@wifi_bp.route('/stream') @wifi_bp.route('/stream')
def stream_wifi(): def stream_wifi():
"""SSE stream for WiFi events.""" """SSE stream for WiFi events."""
def generate(): def _on_msg(msg: dict[str, Any]) -> None:
last_keepalive = time.time() process_event('wifi', msg, msg.get('type'))
keepalive_interval = 30.0
response = Response(
while True: sse_stream_fanout(
try: source_queue=app_module.wifi_queue,
msg = app_module.wifi_queue.get(timeout=1) channel_key='wifi',
last_keepalive = time.time() timeout=1.0,
try: keepalive_interval=30.0,
process_event('wifi', msg, msg.get('type')) on_message=_on_msg,
except Exception: ),
pass mimetype='text/event-stream',
yield format_sse(msg) )
except queue.Empty: response.headers['Cache-Control'] = 'no-cache'
now = time.time() response.headers['X-Accel-Buffering'] = 'no'
if now - last_keepalive >= keepalive_interval: response.headers['Connection'] = 'keep-alive'
yield format_sse({'type': 'keepalive'}) return response
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response
# ============================================================================= # =============================================================================
@@ -1545,8 +1538,8 @@ def v2_deauth_status():
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
@wifi_bp.route('/v2/deauth/stream') @wifi_bp.route('/v2/deauth/stream')
def v2_deauth_stream(): def v2_deauth_stream():
""" """
SSE stream for real-time deauth alerts. SSE stream for real-time deauth alerts.
@@ -1557,26 +1550,18 @@ def v2_deauth_stream():
- deauth_error: An error occurred - deauth_error: An error occurred
- keepalive: Periodic keepalive - keepalive: Periodic keepalive
""" """
def generate(): response = Response(
last_keepalive = time.time() sse_stream_fanout(
keepalive_interval = SSE_KEEPALIVE_INTERVAL source_queue=app_module.deauth_detector_queue,
channel_key='wifi_deauth',
while True: timeout=SSE_QUEUE_TIMEOUT,
try: keepalive_interval=SSE_KEEPALIVE_INTERVAL,
# Try to get from the dedicated deauth queue ),
msg = app_module.deauth_detector_queue.get(timeout=SSE_QUEUE_TIMEOUT) mimetype='text/event-stream',
last_keepalive = time.time() )
yield format_sse(msg) response.headers['Cache-Control'] = 'no-cache'
except queue.Empty: response.headers['X-Accel-Buffering'] = 'no'
now = time.time() response.headers['Connection'] = 'keep-alive'
if now - last_keepalive >= keepalive_interval:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response return response
+423 -80
View File
@@ -137,6 +137,14 @@ need_sudo() {
fi fi
} }
# Refresh sudo credential cache so long-running builds don't trigger
# mid-compilation password prompts (which can fail due to TTY issues
# inside subshells). Safe to call multiple times.
refresh_sudo() {
[[ -z "${SUDO:-}" ]] && return 0
sudo -v 2>/dev/null || true
}
detect_os() { detect_os() {
if [[ "${OSTYPE:-}" == "darwin"* ]]; then if [[ "${OSTYPE:-}" == "darwin"* ]]; then
OS="macos" OS="macos"
@@ -214,8 +222,11 @@ check_tools() {
check_required "multimon-ng" "Pager decoder" multimon-ng check_required "multimon-ng" "Pager decoder" multimon-ng
check_required "rtl_433" "433MHz sensor decoder" rtl_433 rtl433 check_required "rtl_433" "433MHz sensor decoder" rtl_433 rtl433
check_optional "rtlamr" "Utility meter decoder (requires Go)" rtlamr check_optional "rtlamr" "Utility meter decoder (requires Go)" rtlamr
check_optional "hackrf_transfer" "HackRF SubGHz transceiver" hackrf_transfer
check_optional "hackrf_sweep" "HackRF spectrum analyzer" hackrf_sweep
check_required "dump1090" "ADS-B decoder" dump1090 check_required "dump1090" "ADS-B decoder" dump1090
check_required "acarsdec" "ACARS decoder" acarsdec check_required "acarsdec" "ACARS decoder" acarsdec
check_optional "dumpvdl2" "VDL2 decoder" dumpvdl2
check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher
check_optional "satdump" "Weather satellite decoder (NOAA/Meteor)" satdump check_optional "satdump" "Weather satellite decoder (NOAA/Meteor)" satdump
echo echo
@@ -302,28 +313,40 @@ install_python_deps() {
# shellcheck disable=SC1091 # shellcheck disable=SC1091
source venv/bin/activate source venv/bin/activate
local PIP="venv/bin/python -m pip"
local PY="venv/bin/python"
python -m pip install --upgrade pip setuptools wheel >/dev/null 2>&1 || true $PIP install --upgrade pip setuptools wheel >/dev/null 2>&1 || true
ok "Upgraded pip tooling" ok "Upgraded pip tooling"
progress "Installing Python dependencies" progress "Installing Python dependencies"
# Try pip install, but don't fail if apt packages already satisfied deps
if ! python -m pip install -r requirements.txt 2>/dev/null; then
warn "Some pip packages failed - checking if apt packages cover them..."
# Verify critical packages are available
python -c "import flask; import requests; from flask_limiter import Limiter" 2>/dev/null || {
fail "Critical Python packages (flask, requests, flask-limiter) not installed"
echo "Try: pip install flask requests flask-limiter"
exit 1
}
ok "Core Python dependencies available"
else
ok "Python dependencies installed"
fi
# Ensure Flask 3.0+ is installed (required for Werkzeug 3.x compatibility) # Install critical packages first to avoid all-or-nothing failures
# System apt packages may have older Flask 2.x which is incompatible # (C extension packages like scipy/numpy can fail on newer Python versions
python -m pip install --upgrade "flask>=3.0.0" >/dev/null 2>&1 || true # and cause pip to roll back pure-Python packages like flask)
info "Installing core packages..."
$PIP install --quiet "flask>=3.0.0" "flask-limiter>=2.5.4" "requests>=2.28.0" \
"Werkzeug>=3.1.5" "pyserial>=3.5" "flask-sock" "websocket-client>=1.6.0" 2>/dev/null || true
# Verify critical packages
$PY -c "import flask; import requests; from flask_limiter import Limiter" 2>/dev/null || {
fail "Critical Python packages (flask, requests, flask-limiter) not installed"
echo "Try: venv/bin/pip install flask requests flask-limiter"
exit 1
}
ok "Core Python packages installed"
# Install optional packages individually (some may fail on newer Python)
info "Installing optional packages..."
for pkg in "numpy>=1.24.0" "scipy>=1.10.0" "Pillow>=9.0.0" "skyfield>=1.45" \
"bleak>=0.21.0" "psycopg2-binary>=2.9.9" "meshtastic>=2.0.0" \
"scapy>=2.4.5" "qrcode[pil]>=7.4" "cryptography>=41.0.0"; do
pkg_name="${pkg%%>=*}"
if ! $PIP install "$pkg" 2>/dev/null; then
warn "${pkg_name} failed to install (optional - related features may be unavailable)"
fi
done
ok "Optional packages processed"
echo echo
} }
@@ -386,7 +409,7 @@ install_rtlamr_from_source() {
if [[ -w /usr/local/bin ]]; then if [[ -w /usr/local/bin ]]; then
ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr
else else
sudo ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr $SUDO ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr
fi fi
else else
$SUDO ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr $SUDO ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr
@@ -428,7 +451,8 @@ install_multimon_ng_from_source_macos() {
if [[ -w /usr/local/bin ]]; then if [[ -w /usr/local/bin ]]; then
install -m 0755 multimon-ng /usr/local/bin/multimon-ng install -m 0755 multimon-ng /usr/local/bin/multimon-ng
else else
sudo install -m 0755 multimon-ng /usr/local/bin/multimon-ng refresh_sudo
$SUDO install -m 0755 multimon-ng /usr/local/bin/multimon-ng
fi fi
ok "multimon-ng installed successfully from source" ok "multimon-ng installed successfully from source"
) )
@@ -469,7 +493,8 @@ install_dsd_from_source() {
if [[ -w /usr/local/lib ]]; then if [[ -w /usr/local/lib ]]; then
make install >/dev/null 2>&1 make install >/dev/null 2>&1
else else
sudo make install >/dev/null 2>&1 refresh_sudo
$SUDO make install >/dev/null 2>&1
fi fi
else else
$SUDO make install >/dev/null 2>&1 $SUDO make install >/dev/null 2>&1
@@ -505,7 +530,8 @@ install_dsd_from_source() {
if [[ -w /usr/local/bin ]]; then if [[ -w /usr/local/bin ]]; then
install -m 0755 dsd-fme /usr/local/bin/dsd 2>/dev/null || install -m 0755 dsd /usr/local/bin/dsd 2>/dev/null || true install -m 0755 dsd-fme /usr/local/bin/dsd 2>/dev/null || install -m 0755 dsd /usr/local/bin/dsd 2>/dev/null || true
else else
sudo install -m 0755 dsd-fme /usr/local/bin/dsd 2>/dev/null || sudo install -m 0755 dsd /usr/local/bin/dsd 2>/dev/null || true refresh_sudo
$SUDO install -m 0755 dsd-fme /usr/local/bin/dsd 2>/dev/null || $SUDO install -m 0755 dsd /usr/local/bin/dsd 2>/dev/null || true
fi fi
else else
$SUDO make install >/dev/null 2>&1 \ $SUDO make install >/dev/null 2>&1 \
@@ -543,7 +569,8 @@ install_dump1090_from_source_macos() {
if [[ -w /usr/local/bin ]]; then if [[ -w /usr/local/bin ]]; then
install -m 0755 dump1090 /usr/local/bin/dump1090 install -m 0755 dump1090 /usr/local/bin/dump1090
else else
sudo install -m 0755 dump1090 /usr/local/bin/dump1090 refresh_sudo
$SUDO install -m 0755 dump1090 /usr/local/bin/dump1090
fi fi
ok "dump1090 installed successfully from source" ok "dump1090 installed successfully from source"
else else
@@ -569,18 +596,129 @@ install_acarsdec_from_source_macos() {
|| { warn "Failed to clone acarsdec"; exit 1; } || { warn "Failed to clone acarsdec"; exit 1; }
cd "$tmp_dir/acarsdec" cd "$tmp_dir/acarsdec"
# Fix compiler flags for macOS Apple Silicon (ARM64)
# -march=native can fail with Apple Clang on M-series chips
# -Ofast is deprecated in modern Clang
if [[ "$(uname -m)" == "arm64" ]]; then
sed -i '' 's/-Ofast -march=native/-O3 -ffast-math/g' CMakeLists.txt
info "Patched compiler flags for Apple Silicon (arm64)"
fi
# Fix pthread_tryjoin_np (Linux-only GNU extension) for macOS
# Replace with pthread_join which provides equivalent behavior
if grep -q 'pthread_tryjoin_np' rtl.c 2>/dev/null; then
sed -i '' 's/pthread_tryjoin_np(\([^,]*\), NULL)/pthread_join(\1, NULL)/g' rtl.c
info "Patched pthread_tryjoin_np for macOS compatibility"
fi
# Fix libacars linking on macOS (upstream issue #112)
# Use LIBACARS_LINK_LIBRARIES (full path) instead of LIBACARS_LIBRARIES (name only)
if grep -q 'LIBACARS_LIBRARIES' CMakeLists.txt 2>/dev/null; then
sed -i '' 's/${LIBACARS_LIBRARIES}/${LIBACARS_LINK_LIBRARIES}/g' CMakeLists.txt
info "Patched libacars linking for macOS"
fi
mkdir -p build && cd build mkdir -p build && cd build
# Set Homebrew paths for Apple Silicon (/opt/homebrew) or Intel (/usr/local)
HOMEBREW_PREFIX="$(brew --prefix)"
export PKG_CONFIG_PATH="${HOMEBREW_PREFIX}/lib/pkgconfig:${PKG_CONFIG_PATH:-}"
export CMAKE_PREFIX_PATH="${HOMEBREW_PREFIX}"
info "Compiling acarsdec..." info "Compiling acarsdec..."
if cmake .. -Drtl=ON >/dev/null 2>&1 && make >/dev/null 2>&1; then build_log="$tmp_dir/acarsdec-build.log"
if cmake .. -Drtl=ON \
-DCMAKE_POLICY_VERSION_MINIMUM=3.5 \
-DCMAKE_C_FLAGS="-I${HOMEBREW_PREFIX}/include" \
-DCMAKE_EXE_LINKER_FLAGS="-L${HOMEBREW_PREFIX}/lib" \
>"$build_log" 2>&1 \
&& make >>"$build_log" 2>&1; then
if [[ -w /usr/local/bin ]]; then if [[ -w /usr/local/bin ]]; then
install -m 0755 acarsdec /usr/local/bin/acarsdec install -m 0755 acarsdec /usr/local/bin/acarsdec
else else
sudo install -m 0755 acarsdec /usr/local/bin/acarsdec refresh_sudo
$SUDO install -m 0755 acarsdec /usr/local/bin/acarsdec
fi fi
ok "acarsdec installed successfully from source" ok "acarsdec installed successfully from source"
else else
warn "Failed to build acarsdec. ACARS decoding will not be available." warn "Failed to build acarsdec. ACARS decoding will not be available."
warn "Build log (last 30 lines):"
tail -30 "$build_log" | while IFS= read -r line; do warn " $line"; done
fi
)
}
install_dumpvdl2_from_source_macos() {
info "Building dumpvdl2 from source (with libacars dependency)..."
brew_install cmake
brew_install librtlsdr
brew_install pkg-config
brew_install glib
(
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
HOMEBREW_PREFIX="$(brew --prefix)"
export PKG_CONFIG_PATH="${HOMEBREW_PREFIX}/lib/pkgconfig:${PKG_CONFIG_PATH:-}"
export CMAKE_PREFIX_PATH="${HOMEBREW_PREFIX}"
# Build libacars first
info "Cloning libacars..."
git clone --depth 1 https://github.com/szpajder/libacars.git "$tmp_dir/libacars" >/dev/null 2>&1 \
|| { warn "Failed to clone libacars"; exit 1; }
cd "$tmp_dir/libacars"
mkdir -p build && cd build
info "Compiling libacars..."
build_log="$tmp_dir/libacars-build.log"
if cmake .. \
-DCMAKE_C_FLAGS="-I${HOMEBREW_PREFIX}/include" \
-DCMAKE_EXE_LINKER_FLAGS="-L${HOMEBREW_PREFIX}/lib" \
>"$build_log" 2>&1 \
&& make >>"$build_log" 2>&1; then
if [[ -w /usr/local/lib ]]; then
make install >>"$build_log" 2>&1
else
refresh_sudo
$SUDO make install >>"$build_log" 2>&1
fi
ok "libacars installed"
else
warn "Failed to build libacars."
tail -20 "$build_log" | while IFS= read -r line; do warn " $line"; done
exit 1
fi
# Build dumpvdl2
info "Cloning dumpvdl2..."
git clone --depth 1 https://github.com/szpajder/dumpvdl2.git "$tmp_dir/dumpvdl2" >/dev/null 2>&1 \
|| { warn "Failed to clone dumpvdl2"; exit 1; }
cd "$tmp_dir/dumpvdl2"
mkdir -p build && cd build
info "Compiling dumpvdl2..."
build_log="$tmp_dir/dumpvdl2-build.log"
if cmake .. \
-DCMAKE_C_FLAGS="-I${HOMEBREW_PREFIX}/include" \
-DCMAKE_EXE_LINKER_FLAGS="-L${HOMEBREW_PREFIX}/lib" \
>"$build_log" 2>&1 \
&& make >>"$build_log" 2>&1; then
if [[ -w /usr/local/bin ]]; then
install -m 0755 src/dumpvdl2 /usr/local/bin/dumpvdl2
else
refresh_sudo
$SUDO install -m 0755 src/dumpvdl2 /usr/local/bin/dumpvdl2
fi
ok "dumpvdl2 installed successfully from source"
else
warn "Failed to build dumpvdl2. VDL2 decoding will not be available."
warn "Build log (last 30 lines):"
tail -30 "$build_log" | while IFS= read -r line; do warn " $line"; done
fi fi
) )
} }
@@ -609,7 +747,8 @@ install_aiscatcher_from_source_macos() {
if [[ -w /usr/local/bin ]]; then if [[ -w /usr/local/bin ]]; then
install -m 0755 AIS-catcher /usr/local/bin/AIS-catcher install -m 0755 AIS-catcher /usr/local/bin/AIS-catcher
else else
sudo install -m 0755 AIS-catcher /usr/local/bin/AIS-catcher refresh_sudo
$SUDO install -m 0755 AIS-catcher /usr/local/bin/AIS-catcher
fi fi
ok "AIS-catcher installed successfully from source" ok "AIS-catcher installed successfully from source"
else else
@@ -621,11 +760,23 @@ install_aiscatcher_from_source_macos() {
install_satdump_from_source_debian() { install_satdump_from_source_debian() {
info "Building SatDump v1.2.2 from source (weather satellite decoder)..." info "Building SatDump v1.2.2 from source (weather satellite decoder)..."
# Core deps — hard-fail if missing
apt_install build-essential git cmake pkg-config \ apt_install build-essential git cmake pkg-config \
libpng-dev libtiff-dev libjemalloc-dev libvolk-dev libnng-dev \ libpng-dev libtiff-dev libzstd-dev \
libzstd-dev libsoapysdr-dev libhackrf-dev liblimesuite-dev \
libsqlite3-dev libcurl4-openssl-dev zlib1g-dev libzmq3-dev libfftw3-dev libsqlite3-dev libcurl4-openssl-dev zlib1g-dev libzmq3-dev libfftw3-dev
# libvolk: package name differs between distros
# Ubuntu / Debian Trixie+: libvolk-dev
# Raspberry Pi OS Bookworm / Debian Bookworm: libvolk2-dev
apt_try_install_any libvolk-dev libvolk2-dev \
|| warn "libvolk not found — SatDump will build without VOLK acceleration"
# Optional SDR hardware libs — soft-fail so missing hardware doesn't abort
for pkg in libjemalloc-dev libnng-dev libsoapysdr-dev libhackrf-dev liblimesuite-dev; do
$SUDO apt-get install -y --no-install-recommends "$pkg" >/dev/null 2>&1 \
|| warn "${pkg} not available — skipping (SatDump can build without it)"
done
# Run in subshell to isolate EXIT trap # Run in subshell to isolate EXIT trap
( (
tmp_dir="$(mktemp -d)" tmp_dir="$(mktemp -d)"
@@ -636,11 +787,42 @@ install_satdump_from_source_debian() {
|| { warn "Failed to clone SatDump"; exit 1; } || { warn "Failed to clone SatDump"; exit 1; }
cd "$tmp_dir/SatDump" cd "$tmp_dir/SatDump"
# Patch: fix deprecated std::allocator usage for newer compilers
# GCC 13+ errors on deprecated allocator members in sol2.
# Pragmas must go in lua_utils.cpp (the instantiation site), not sol.hpp (definition site).
lua_utils="src-core/common/lua/lua_utils.cpp"
if [ -f "$lua_utils" ]; then
{
echo '#pragma GCC diagnostic push'
echo '#pragma GCC diagnostic ignored "-Wdeprecated"'
echo '#pragma GCC diagnostic ignored "-Wdeprecated-declarations"'
cat "$lua_utils"
echo # ensure the file ends with a newline before the closing pragma
echo '#pragma GCC diagnostic pop'
} > "${lua_utils}.patched" && mv "${lua_utils}.patched" "$lua_utils"
fi
mkdir -p build && cd build mkdir -p build && cd build
info "Compiling SatDump (this may take a while)..." info "Compiling SatDump (this is a large C++ project and may take 10-30 minutes)..."
if cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF -DCMAKE_INSTALL_LIBDIR=lib .. >/dev/null 2>&1 \ build_log="$tmp_dir/satdump-build.log"
&& make -j "$(nproc)" >/dev/null 2>&1; then
# Show periodic progress while building so the user knows it's not hung
(
while true; do
sleep 30
if [ -f "$build_log" ]; then
local_lines=$(wc -l < "$build_log" 2>/dev/null || echo 0)
printf " [*] Still compiling SatDump... (%s lines of build output so far)\n" "$local_lines"
fi
done
) &
progress_pid=$!
if cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF -DCMAKE_INSTALL_LIBDIR=lib .. >"$build_log" 2>&1 \
&& make -j "$(nproc)" >>"$build_log" 2>&1; then
kill $progress_pid 2>/dev/null; wait $progress_pid 2>/dev/null
$SUDO make install >/dev/null 2>&1 $SUDO make install >/dev/null 2>&1
$SUDO ldconfig $SUDO ldconfig
@@ -657,54 +839,86 @@ install_satdump_from_source_debian() {
ok "SatDump installed successfully." ok "SatDump installed successfully."
else else
kill $progress_pid 2>/dev/null; wait $progress_pid 2>/dev/null
warn "Failed to build SatDump from source. Weather satellite decoding will not be available." warn "Failed to build SatDump from source. Weather satellite decoding will not be available."
warn "Build log (last 30 lines):"
tail -30 "$build_log" | while IFS= read -r line; do warn " $line"; done
fi fi
) )
} }
install_satdump_from_source_macos() { install_satdump_macos() {
info "Building SatDump v1.2.2 from source (weather satellite decoder)..." info "Installing SatDump v1.2.2 from pre-built release (weather satellite decoder)..."
brew_install cmake # Determine architecture
brew_install libpng local arch
brew_install libtiff arch="$(uname -m)"
brew_install jemalloc local dmg_name
brew_install libvolk if [ "$arch" = "arm64" ]; then
brew_install nng dmg_name="SatDump-macOS-Silicon.dmg"
brew_install zstd else
brew_install soapysdr dmg_name="SatDump-macOS-Intel.dmg"
brew_install hackrf fi
brew_install fftw
local dmg_url="https://github.com/SatDump/SatDump/releases/download/1.2.2/${dmg_name}"
local install_dir="/usr/local/lib/satdump"
# Run in subshell to isolate EXIT trap # Run in subshell to isolate EXIT trap
( (
tmp_dir="$(mktemp -d)" tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT trap 'hdiutil detach "$tmp_dir/mnt" -quiet 2>/dev/null || true; rm -rf "$tmp_dir"' EXIT
info "Cloning SatDump v1.2.2..." info "Downloading ${dmg_name}..."
git clone --depth 1 --branch 1.2.2 https://github.com/SatDump/SatDump.git "$tmp_dir/SatDump" >/dev/null 2>&1 \ if ! curl -sL -o "$tmp_dir/satdump.dmg" "$dmg_url"; then
|| { warn "Failed to clone SatDump"; exit 1; } warn "Failed to download SatDump. Weather satellite decoding will not be available."
exit 1
fi
cd "$tmp_dir/SatDump" info "Installing SatDump..."
mkdir -p build && cd build # Mount the DMG
hdiutil attach "$tmp_dir/satdump.dmg" -nobrowse -quiet -mountpoint "$tmp_dir/mnt" \
|| { warn "Failed to mount SatDump DMG"; exit 1; }
info "Compiling SatDump (this may take a while)..." local app_dir="$tmp_dir/mnt/SatDump.app"
if cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF .. >/dev/null 2>&1 \ if [ ! -d "$app_dir" ]; then
&& make -j "$(sysctl -n hw.ncpu)" >/dev/null 2>&1; then warn "SatDump.app not found in DMG"
if [[ -w /usr/local/bin ]]; then exit 1
make install >/dev/null 2>&1 fi
else
sudo make install >/dev/null 2>&1 # Install: copy app contents to /usr/local/lib/satdump
fi refresh_sudo
ok "SatDump installed successfully." $SUDO mkdir -p "$install_dir"
$SUDO cp -R "$app_dir/Contents/MacOS/"* "$install_dir/"
$SUDO cp -R "$app_dir/Contents/Resources/"* "$install_dir/"
# Create wrapper script so satdump can find its resources via @executable_path
$SUDO tee /usr/local/bin/satdump >/dev/null <<'WRAPPER'
#!/bin/sh
exec /usr/local/lib/satdump/satdump "$@"
WRAPPER
$SUDO chmod +x /usr/local/bin/satdump
hdiutil detach "$tmp_dir/mnt" -quiet 2>/dev/null
# Verify installation
if /usr/local/lib/satdump/satdump 2>&1 | grep -q "Usage"; then
ok "SatDump v1.2.2 installed successfully."
else else
warn "Failed to build SatDump from source. Weather satellite decoding will not be available." warn "SatDump installed but may not work correctly."
fi fi
) )
} }
install_macos_packages() { install_macos_packages() {
TOTAL_STEPS=19 need_sudo
# Prime sudo credentials upfront so builds don't prompt mid-compilation
if [[ -n "${SUDO:-}" ]]; then
info "Some tools require sudo to install. You may be prompted for your password."
sudo -v || { fail "sudo authentication failed"; exit 1; }
fi
TOTAL_STEPS=22
CURRENT_STEP=0 CURRENT_STEP=0
progress "Checking Homebrew" progress "Checking Homebrew"
@@ -746,6 +960,9 @@ install_macos_packages() {
progress "Installing rtl_433" progress "Installing rtl_433"
brew_install rtl_433 brew_install rtl_433
progress "Installing HackRF tools"
brew_install hackrf
progress "Installing rtlamr (optional)" progress "Installing rtlamr (optional)"
# rtlamr is optional - used for utility meter monitoring # rtlamr is optional - used for utility meter monitoring
if ! cmd_exists rtlamr; then if ! cmd_exists rtlamr; then
@@ -774,6 +991,13 @@ install_macos_packages() {
ok "acarsdec already installed" ok "acarsdec already installed"
fi fi
progress "Installing dumpvdl2"
if ! cmd_exists dumpvdl2; then
install_dumpvdl2_from_source_macos || warn "dumpvdl2 not available. VDL2 decoding will not be available."
else
ok "dumpvdl2 already installed"
fi
progress "Installing AIS-catcher" progress "Installing AIS-catcher"
if ! cmd_exists AIS-catcher && ! cmd_exists aiscatcher; then if ! cmd_exists AIS-catcher && ! cmd_exists aiscatcher; then
(brew_install aiscatcher) || install_aiscatcher_from_source_macos || warn "AIS-catcher not available" (brew_install aiscatcher) || install_aiscatcher_from_source_macos || warn "AIS-catcher not available"
@@ -786,7 +1010,7 @@ install_macos_packages() {
echo echo
info "SatDump is used for weather satellite imagery (NOAA APT & Meteor LRPT)." info "SatDump is used for weather satellite imagery (NOAA APT & Meteor LRPT)."
if ask_yes_no "Do you want to install SatDump?"; then if ask_yes_no "Do you want to install SatDump?"; then
install_satdump_from_source_macos || warn "SatDump build failed. Weather satellite decoding will not be available." install_satdump_macos || warn "SatDump installation failed. Weather satellite decoding will not be available."
else else
warn "Skipping SatDump installation. You can install it later if needed." warn "Skipping SatDump installation. You can install it later if needed."
fi fi
@@ -867,10 +1091,13 @@ install_dump1090_from_source_debian() {
librtlsdr-dev libusb-1.0-0-dev \ librtlsdr-dev libusb-1.0-0-dev \
libncurses-dev tcl-dev python3-dev libncurses-dev tcl-dev python3-dev
local JOBS
JOBS="$(nproc 2>/dev/null || echo 1)"
# Run in subshell to isolate EXIT trap # Run in subshell to isolate EXIT trap
( (
tmp_dir="$(mktemp -d)" tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT trap '{ [[ -n "${progress_pid:-}" ]] && kill "$progress_pid" 2>/dev/null && wait "$progress_pid" 2>/dev/null || true; }; rm -rf "$tmp_dir"' EXIT
info "Cloning FlightAware dump1090..." info "Cloning FlightAware dump1090..."
git clone --depth 1 https://github.com/flightaware/dump1090.git "$tmp_dir/dump1090" >/dev/null 2>&1 \ git clone --depth 1 https://github.com/flightaware/dump1090.git "$tmp_dir/dump1090" >/dev/null 2>&1 \
@@ -878,23 +1105,45 @@ install_dump1090_from_source_debian() {
cd "$tmp_dir/dump1090" cd "$tmp_dir/dump1090"
# Remove -Werror to prevent build failures on newer GCC versions # Remove -Werror to prevent build failures on newer GCC versions
sed -i 's/-Werror//g' Makefile 2>/dev/null || sed -i '' 's/-Werror//g' Makefile sed -i 's/-Werror//g' Makefile 2>/dev/null || true
info "Compiling FlightAware dump1090..." info "Compiling FlightAware dump1090 (using ${JOBS} CPU cores)..."
if make BLADERF=no RTLSDR=yes >/dev/null 2>&1; then build_log="$tmp_dir/dump1090-build.log"
(while true; do sleep 20; printf " [*] Still compiling dump1090...\n"; done) &
progress_pid=$!
if make -j "$JOBS" BLADERF=no RTLSDR=yes >"$build_log" 2>&1; then
kill "$progress_pid" 2>/dev/null; wait "$progress_pid" 2>/dev/null || true; progress_pid=
$SUDO install -m 0755 dump1090 /usr/local/bin/dump1090 $SUDO install -m 0755 dump1090 /usr/local/bin/dump1090
ok "dump1090 installed successfully (FlightAware)." ok "dump1090 installed successfully (FlightAware)."
exit 0 exit 0
fi fi
kill "$progress_pid" 2>/dev/null; wait "$progress_pid" 2>/dev/null || true; progress_pid=
warn "FlightAware build failed. Falling back to wiedehopf/readsb..." warn "FlightAware build failed. Falling back to wiedehopf/readsb..."
warn "Build log (last 20 lines):"
tail -20 "$build_log" | while IFS= read -r line; do warn " $line"; done
rm -rf "$tmp_dir/dump1090" rm -rf "$tmp_dir/dump1090"
git clone --depth 1 https://github.com/wiedehopf/readsb.git "$tmp_dir/dump1090" >/dev/null 2>&1 \ git clone --depth 1 https://github.com/wiedehopf/readsb.git "$tmp_dir/dump1090" >/dev/null 2>&1 \
|| { fail "Failed to clone wiedehopf/readsb"; exit 1; } || { fail "Failed to clone wiedehopf/readsb"; exit 1; }
cd "$tmp_dir/dump1090" cd "$tmp_dir/dump1090"
info "Compiling readsb..." info "Compiling readsb (using ${JOBS} CPU cores)..."
make RTLSDR=yes >/dev/null 2>&1 || { fail "Failed to build readsb from source (required)."; exit 1; } build_log="$tmp_dir/readsb-build.log"
(while true; do sleep 20; printf " [*] Still compiling readsb...\n"; done) &
progress_pid=$!
if ! make -j "$JOBS" RTLSDR=yes >"$build_log" 2>&1; then
kill "$progress_pid" 2>/dev/null; wait "$progress_pid" 2>/dev/null || true; progress_pid=
warn "Build log (last 20 lines):"
tail -20 "$build_log" | while IFS= read -r line; do warn " $line"; done
fail "Failed to build readsb from source (required)."
exit 1
fi
kill "$progress_pid" 2>/dev/null; wait "$progress_pid" 2>/dev/null || true; progress_pid=
$SUDO install -m 0755 readsb /usr/local/bin/dump1090 $SUDO install -m 0755 readsb /usr/local/bin/dump1090
ok "dump1090 installed successfully (via readsb)." ok "dump1090 installed successfully (via readsb)."
) )
@@ -919,7 +1168,7 @@ install_acarsdec_from_source_debian() {
mkdir -p build && cd build mkdir -p build && cd build
info "Compiling acarsdec..." info "Compiling acarsdec..."
if cmake .. -Drtl=ON >/dev/null 2>&1 && make >/dev/null 2>&1; then if cmake .. -Drtl=ON -DCMAKE_POLICY_VERSION_MINIMUM=3.5 >/dev/null 2>&1 && make >/dev/null 2>&1; then
$SUDO install -m 0755 acarsdec /usr/local/bin/acarsdec $SUDO install -m 0755 acarsdec /usr/local/bin/acarsdec
ok "acarsdec installed successfully." ok "acarsdec installed successfully."
else else
@@ -928,6 +1177,52 @@ install_acarsdec_from_source_debian() {
) )
} }
install_dumpvdl2_from_source_debian() {
info "Building dumpvdl2 from source (with libacars dependency)..."
apt_install build-essential git cmake \
librtlsdr-dev libusb-1.0-0-dev libglib2.0-dev libxml2-dev
(
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
# Build libacars first
info "Cloning libacars..."
git clone --depth 1 https://github.com/szpajder/libacars.git "$tmp_dir/libacars" >/dev/null 2>&1 \
|| { warn "Failed to clone libacars"; exit 1; }
cd "$tmp_dir/libacars"
mkdir -p build && cd build
info "Compiling libacars..."
if cmake .. >/dev/null 2>&1 && make >/dev/null 2>&1; then
$SUDO make install >/dev/null 2>&1
$SUDO ldconfig
ok "libacars installed"
else
warn "Failed to build libacars."
exit 1
fi
# Build dumpvdl2
info "Cloning dumpvdl2..."
git clone --depth 1 https://github.com/szpajder/dumpvdl2.git "$tmp_dir/dumpvdl2" >/dev/null 2>&1 \
|| { warn "Failed to clone dumpvdl2"; exit 1; }
cd "$tmp_dir/dumpvdl2"
mkdir -p build && cd build
info "Compiling dumpvdl2..."
if cmake .. >/dev/null 2>&1 && make >/dev/null 2>&1; then
$SUDO install -m 0755 src/dumpvdl2 /usr/local/bin/dumpvdl2
ok "dumpvdl2 installed successfully."
else
warn "Failed to build dumpvdl2 from source. VDL2 decoding will not be available."
fi
)
}
install_aiscatcher_from_source_debian() { install_aiscatcher_from_source_debian() {
info "AIS-catcher not available via APT. Building from source..." info "AIS-catcher not available via APT. Building from source..."
@@ -1020,6 +1315,17 @@ install_rtlsdr_blog_drivers_debian() {
$SUDO udevadm trigger || true $SUDO udevadm trigger || true
fi fi
# Make the Blog drivers' library take priority over the apt-installed
# librtlsdr. Removing apt packages is too destructive (dump1090-mutability
# and other tools depend on librtlsdr0 and get swept out). Instead,
# prepend /usr/local/lib to ldconfig's search path — files named 00-*
# sort before the distro's aarch64-linux-gnu.conf — so ldconfig lists
# /usr/local/lib/librtlsdr.so.0 first and the dynamic linker uses it.
if [[ -d /etc/ld.so.conf.d ]]; then
echo '/usr/local/lib' | $SUDO tee /etc/ld.so.conf.d/00-local-first.conf >/dev/null
fi
$SUDO ldconfig
ok "RTL-SDR Blog drivers installed successfully." ok "RTL-SDR Blog drivers installed successfully."
info "These drivers provide improved support for RTL-SDR Blog V4 and other devices." info "These drivers provide improved support for RTL-SDR Blog V4 and other devices."
warn "Unplug and replug your RTL-SDR devices for the new drivers to take effect." warn "Unplug and replug your RTL-SDR devices for the new drivers to take effect."
@@ -1029,6 +1335,7 @@ install_rtlsdr_blog_drivers_debian() {
warn "See: https://github.com/rtlsdrblog/rtl-sdr-blog" warn "See: https://github.com/rtlsdrblog/rtl-sdr-blog"
fi fi
) )
} }
setup_udev_rules_debian() { setup_udev_rules_debian() {
@@ -1053,24 +1360,35 @@ blacklist_kernel_drivers_debian() {
if [[ -f "$blacklist_file" ]]; then if [[ -f "$blacklist_file" ]]; then
ok "RTL-SDR kernel driver blacklist already present" ok "RTL-SDR kernel driver blacklist already present"
return 0 else
fi info "Blacklisting conflicting DVB kernel drivers..."
$SUDO tee "$blacklist_file" >/dev/null <<'EOF'
info "Blacklisting conflicting DVB kernel drivers..."
$SUDO tee "$blacklist_file" >/dev/null <<'EOF'
# Blacklist DVB-T drivers to allow rtl-sdr to access RTL2832U devices # Blacklist DVB-T drivers to allow rtl-sdr to access RTL2832U devices
blacklist dvb_usb_rtl28xxu blacklist dvb_usb_rtl28xxu
blacklist rtl2832 blacklist rtl2832
blacklist rtl2830 blacklist rtl2830
blacklist r820t blacklist r820t
EOF EOF
fi
# Unload modules if currently loaded # Always unload modules if currently loaded — this must happen even on
# re-runs where the blacklist file already exists, since the modules may
# still be live from the current boot (e.g. blacklist wasn't in initramfs).
local unloaded=false
for mod in dvb_usb_rtl28xxu rtl2832 rtl2830 r820t; do for mod in dvb_usb_rtl28xxu rtl2832 rtl2830 r820t; do
if lsmod | grep -q "^$mod"; then if lsmod | grep -q "^$mod"; then
$SUDO modprobe -r "$mod" 2>/dev/null || true $SUDO modprobe -r "$mod" 2>/dev/null || true
unloaded=true
fi fi
done done
$unloaded && info "Unloaded conflicting DVB kernel modules from current session."
# Bake the blacklist into the initramfs so it survives reboots on
# Raspberry Pi OS / Debian (without this the modules can reload on boot).
if cmd_exists update-initramfs; then
info "Updating initramfs to persist driver blacklist across reboots..."
$SUDO update-initramfs -u >/dev/null 2>&1 || true
fi
ok "Kernel drivers blacklisted. Unplug/replug your RTL-SDR if connected." ok "Kernel drivers blacklisted. Unplug/replug your RTL-SDR if connected."
echo echo
@@ -1091,7 +1409,7 @@ install_debian_packages() {
export NEEDRESTART_MODE=a export NEEDRESTART_MODE=a
fi fi
TOTAL_STEPS=26 TOTAL_STEPS=28
CURRENT_STEP=0 CURRENT_STEP=0
progress "Updating APT package lists" progress "Updating APT package lists"
@@ -1133,12 +1451,18 @@ install_debian_packages() {
apt_install_if_missing rtl-sdr apt_install_if_missing rtl-sdr
progress "RTL-SDR Blog drivers" progress "RTL-SDR Blog drivers (V4 support)"
if cmd_exists rtl_test; then if $IS_DRAGONOS; then
ok "RTL-SDR drivers already installed" info "DragonOS: skipping RTL-SDR Blog driver install (pre-configured)."
else else
info "RTL-SDR drivers not found, installing RTL-SDR Blog drivers..." echo
install_rtlsdr_blog_drivers_debian info "RTL-SDR Blog drivers add V4 (R828D tuner) support and bias-tee improvements."
info "They are backward-compatible with all RTL-SDR devices."
if ask_yes_no "Install RTL-SDR Blog drivers? (recommended for V4 users, safe for all)" "y"; then
install_rtlsdr_blog_drivers_debian
else
warn "Skipping RTL-SDR Blog drivers. V4 devices may not work correctly."
fi
fi fi
progress "Installing multimon-ng" progress "Installing multimon-ng"
@@ -1169,6 +1493,9 @@ install_debian_packages() {
progress "Installing rtl_433" progress "Installing rtl_433"
apt_try_install_any rtl-433 rtl433 || warn "rtl-433 not available" apt_try_install_any rtl-433 rtl433 || warn "rtl-433 not available"
progress "Installing HackRF tools"
apt_install hackrf || warn "hackrf tools not available"
progress "Installing rtlamr (optional)" progress "Installing rtlamr (optional)"
# rtlamr is optional - used for utility meter monitoring # rtlamr is optional - used for utility meter monitoring
if ! cmd_exists rtlamr; then if ! cmd_exists rtlamr; then
@@ -1225,12 +1552,21 @@ install_debian_packages() {
$SUDO apt-get install -y python3-bleak >/dev/null 2>&1 || true $SUDO apt-get install -y python3-bleak >/dev/null 2>&1 || true
progress "Installing dump1090" progress "Installing dump1090"
# Remove any stale symlink left from a previous run where dump1090-mutability
# was later uninstalled — cmd_exists finds the broken symlink and skips the
# real install, leaving dump1090 seemingly present but non-functional.
local dump1090_path
dump1090_path="$(command -v dump1090 2>/dev/null || true)"
if [[ -n "$dump1090_path" ]] && [[ ! -x "$dump1090_path" ]]; then
info "Removing broken dump1090 symlink: $dump1090_path"
$SUDO rm -f "$dump1090_path"
fi
if ! cmd_exists dump1090 && ! cmd_exists dump1090-mutability; then if ! cmd_exists dump1090 && ! cmd_exists dump1090-mutability; then
apt_try_install_any dump1090-fa dump1090-mutability dump1090 || true apt_try_install_any dump1090-fa dump1090-mutability dump1090 || true
fi fi
if ! cmd_exists dump1090; then if ! cmd_exists dump1090; then
if cmd_exists dump1090-mutability; then if cmd_exists dump1090-mutability; then
$SUDO ln -s $(which dump1090-mutability) /usr/local/sbin/dump1090 $SUDO ln -s "$(which dump1090-mutability)" /usr/local/sbin/dump1090
fi fi
fi fi
cmd_exists dump1090 || install_dump1090_from_source_debian cmd_exists dump1090 || install_dump1090_from_source_debian
@@ -1241,6 +1577,13 @@ install_debian_packages() {
fi fi
cmd_exists acarsdec || install_acarsdec_from_source_debian cmd_exists acarsdec || install_acarsdec_from_source_debian
progress "Installing dumpvdl2"
if ! cmd_exists dumpvdl2; then
install_dumpvdl2_from_source_debian || warn "dumpvdl2 not available. VDL2 decoding will not be available."
else
ok "dumpvdl2 already installed"
fi
progress "Installing AIS-catcher" progress "Installing AIS-catcher"
if ! cmd_exists AIS-catcher && ! cmd_exists aiscatcher; then if ! cmd_exists AIS-catcher && ! cmd_exists aiscatcher; then
install_aiscatcher_from_source_debian install_aiscatcher_from_source_debian
+431 -97
View File
@@ -5,8 +5,8 @@
} }
:root { :root {
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace; --font-sans: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace; --font-mono: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
--bg-dark: #0b1118; --bg-dark: #0b1118;
--bg-panel: #101823; --bg-panel: #101823;
--bg-card: #151f2b; --bg-card: #151f2b;
@@ -27,12 +27,36 @@
--radar-bg: #101823; --radar-bg: #101823;
} }
[data-theme="light"] {
--bg-dark: #f4f7fb;
--bg-panel: #e9eef5;
--bg-card: #ffffff;
--border-color: #d1d9e6;
--border-glow: #1f5fa8;
--text-primary: #122034;
--text-secondary: #3a4a5f;
--text-dim: #6b7c93;
--accent-green: #1f8a57;
--accent-cyan: #1f5fa8;
--accent-orange: #b5863a;
--accent-red: #c74444;
--accent-yellow: #b5863a;
--accent-amber: #b5863a;
--grid-line: rgba(31, 95, 168, 0.12);
--noise-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='40'%20height='40'%20viewBox='0%200%2040%2040'%3E%3Cg%20fill='%23000000'%20fill-opacity='0.05'%3E%3Ccircle%20cx='3'%20cy='5'%20r='1'/%3E%3Ccircle%20cx='11'%20cy='9'%20r='1'/%3E%3Ccircle%20cx='18'%20cy='3'%20r='1'/%3E%3Ccircle%20cx='26'%20cy='12'%20r='1'/%3E%3Ccircle%20cx='34'%20cy='6'%20r='1'/%3E%3Ccircle%20cx='7'%20cy='19'%20r='1'/%3E%3Ccircle%20cx='15'%20cy='24'%20r='1'/%3E%3Ccircle%20cx='28'%20cy='22'%20r='1'/%3E%3Ccircle%20cx='36'%20cy='18'%20r='1'/%3E%3Ccircle%20cx='5'%20cy='33'%20r='1'/%3E%3Ccircle%20cx='19'%20cy='32'%20r='1'/%3E%3Ccircle%20cx='31'%20cy='34'%20r='1'/%3E%3C/g%3E%3C/svg%3E");
--radar-cyan: #1f5fa8;
--radar-bg: #e9eef5;
}
body { body {
font-family: var(--font-sans); font-family: var(--font-sans);
background: var(--bg-dark); background: var(--bg-dark);
color: var(--text-primary); color: var(--text-primary);
min-height: 100vh; height: 100dvh;
overflow-x: hidden; height: 100vh; /* Fallback */
display: flex;
flex-direction: column;
overflow: hidden;
} }
/* Animated radar sweep background */ /* Animated radar sweep background */
@@ -227,16 +251,14 @@ body {
} }
/* Main dashboard grid - Mobile first */ /* Main dashboard grid - Mobile first */
/* Header ~52px + Nav 44px + Stats strip ~55px = ~151px, using 160px for safety */
.dashboard { .dashboard {
position: relative; position: relative;
z-index: 10; z-index: 10;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0; gap: 0;
height: calc(100dvh - 160px); flex: 1;
height: calc(100vh - 160px); /* Fallback */ min-height: 0;
min-height: 400px;
} }
/* Tablet: Two-column layout */ /* Tablet: Two-column layout */
@@ -249,13 +271,29 @@ body {
} }
} }
/* Desktop: Full layout with ACARS */ /* Desktop: Full layout with ACARS/VDL2 + map + sidebar */
@media (min-width: 1024px) { @media (min-width: 1024px) {
.dashboard { .dashboard {
grid-template-columns: auto 1fr 300px; grid-template-columns: auto 1fr 300px;
} }
} }
/* Left sidebars wrapper (ACARS + VDL2) */
.left-sidebars {
display: none;
}
@media (min-width: 1024px) {
.left-sidebars {
display: flex;
flex-direction: row;
grid-column: 1;
grid-row: 1;
height: 100%;
overflow: hidden;
}
}
/* ACARS sidebar (left of map) - Collapsible */ /* ACARS sidebar (left of map) - Collapsible */
.acars-sidebar { .acars-sidebar {
display: none; display: none;
@@ -267,12 +305,10 @@ body {
min-height: 0; min-height: 0;
} }
/* Show ACARS sidebar on desktop */ /* Show ACARS sidebar inside wrapper */
@media (min-width: 1024px) { .left-sidebars .acars-sidebar {
.acars-sidebar { display: flex;
display: flex; height: 100%;
max-height: calc(100dvh - 160px);
}
} }
.acars-collapse-btn { .acars-collapse-btn {
@@ -419,6 +455,335 @@ body {
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: translateY(0); }
} }
/* VDL2 sidebar (left of map, after ACARS) - Collapsible */
.vdl2-sidebar {
display: none;
background: var(--bg-panel);
border-right: 1px solid var(--border-color);
flex-direction: row;
overflow: hidden;
height: 100%;
min-height: 0;
}
/* Show VDL2 sidebar inside wrapper */
.left-sidebars .vdl2-sidebar {
display: flex;
height: 100%;
}
.vdl2-collapse-btn {
width: 28px;
min-width: 28px;
background: var(--bg-card);
border: none;
border-left: 1px solid var(--border-color);
color: var(--accent-cyan);
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 0;
transition: background 0.2s;
}
.vdl2-collapse-btn:hover {
background: rgba(74, 158, 255, 0.2);
}
.vdl2-collapse-label {
writing-mode: vertical-rl;
text-orientation: mixed;
font-size: 9px;
font-weight: 600;
letter-spacing: 1px;
text-transform: uppercase;
}
.vdl2-sidebar.collapsed .vdl2-collapse-label {
display: block;
}
.vdl2-sidebar:not(.collapsed) .vdl2-collapse-label {
display: none;
}
#vdl2CollapseIcon {
font-size: 10px;
transition: transform 0.3s;
}
.vdl2-sidebar.collapsed #vdl2CollapseIcon {
transform: rotate(180deg);
}
.vdl2-sidebar-content {
width: 300px;
display: flex;
flex-direction: column;
overflow: hidden;
transition: width 0.3s ease, opacity 0.2s ease;
height: 100%;
min-height: 0;
}
.vdl2-sidebar.collapsed .vdl2-sidebar-content {
width: 0;
opacity: 0;
pointer-events: none;
}
.vdl2-sidebar .panel {
flex: 1;
display: flex;
flex-direction: column;
border: none;
border-radius: 0;
min-height: 0;
overflow: hidden;
}
.vdl2-sidebar .panel::before {
display: none;
}
.vdl2-sidebar .panel-header {
flex-shrink: 0;
}
.vdl2-sidebar #vdl2PanelContent {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.vdl2-sidebar .vdl2-info,
.vdl2-sidebar .vdl2-controls {
flex-shrink: 0;
}
.vdl2-sidebar .vdl2-messages {
flex: 1;
overflow-y: auto;
min-height: 0;
}
.vdl2-sidebar .vdl2-btn {
background: var(--accent-green);
border: none;
color: #fff;
padding: 6px 10px;
font-size: 10px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
text-transform: uppercase;
letter-spacing: 1px;
border-radius: 4px;
}
.vdl2-sidebar .vdl2-btn:hover {
background: #1db954;
box-shadow: 0 0 10px rgba(34, 197, 94, 0.3);
}
.vdl2-sidebar .vdl2-btn.active {
background: var(--accent-red);
}
.vdl2-sidebar .vdl2-btn.active:hover {
background: #dc2626;
box-shadow: 0 0 10px rgba(239, 68, 68, 0.3);
}
.vdl2-message-item {
padding: 8px 10px;
border-bottom: 1px solid var(--border-color);
font-size: 10px;
animation: fadeIn 0.3s ease;
cursor: pointer;
transition: background 0.2s;
}
.vdl2-message-item:hover {
background: rgba(74, 158, 255, 0.08);
}
/* VDL2 Message Modal */
.vdl2-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
animation: vdl2ModalFadeIn 0.15s ease;
}
@keyframes vdl2ModalFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.vdl2-modal {
background: var(--bg-panel, #1a1a2e);
border: 1px solid var(--accent-cyan, #4a9eff);
border-radius: 8px;
width: 520px;
max-width: 90vw;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 0 1px var(--accent-cyan, #4a9eff);
animation: vdl2ModalSlideIn 0.15s ease;
}
@keyframes vdl2ModalSlideIn {
from { opacity: 0; transform: scale(0.95) translateY(10px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.vdl2-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 18px;
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.vdl2-modal-title {
font-size: 14px;
font-weight: 700;
color: var(--accent-cyan, #4a9eff);
letter-spacing: 0.5px;
}
.vdl2-modal-time {
font-size: 11px;
color: var(--text-muted);
}
.vdl2-modal-close {
background: none;
border: 1px solid var(--border-color);
color: var(--text-muted);
width: 28px;
height: 28px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
margin-left: 12px;
}
.vdl2-modal-close:hover {
background: rgba(239, 68, 68, 0.15);
border-color: var(--accent-red, #ef4444);
color: var(--accent-red, #ef4444);
}
.vdl2-modal-body {
padding: 16px 18px;
overflow-y: auto;
flex: 1;
min-height: 0;
}
.vdl2-modal-section {
margin-bottom: 14px;
}
.vdl2-modal-section:last-child {
margin-bottom: 0;
}
.vdl2-modal-section-title {
font-size: 9px;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 1.5px;
margin-bottom: 8px;
}
.vdl2-modal-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px 16px;
}
.vdl2-modal-field {
display: flex;
flex-direction: column;
gap: 1px;
}
.vdl2-modal-field-label {
font-size: 10px;
color: var(--text-dim);
}
.vdl2-modal-field-value {
font-size: 12px;
color: var(--text-primary);
font-weight: 500;
}
.vdl2-modal-msg-body {
padding: 10px 12px;
background: rgba(0, 0, 0, 0.25);
border-radius: 4px;
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-primary);
white-space: pre-wrap;
word-break: break-word;
line-height: 1.5;
max-height: 250px;
overflow-y: auto;
}
.vdl2-modal-raw-toggle {
display: inline-block;
margin-top: 10px;
font-size: 10px;
color: var(--accent-cyan, #4a9eff);
cursor: pointer;
opacity: 0.7;
transition: opacity 0.15s;
}
.vdl2-modal-raw-toggle:hover {
opacity: 1;
}
.vdl2-modal-raw-json {
display: none;
margin-top: 8px;
padding: 10px 12px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid var(--border-color);
border-radius: 4px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-dim);
white-space: pre-wrap;
word-break: break-all;
max-height: 300px;
overflow-y: auto;
line-height: 1.4;
}
/* Panels */ /* Panels */
.panel { .panel {
background: var(--bg-panel); background: var(--bg-panel);
@@ -495,6 +860,8 @@ body {
position: relative; position: relative;
flex: 1; flex: 1;
min-height: 300px; min-height: 300px;
min-width: 0;
overflow: hidden;
} }
@media (min-width: 768px) { @media (min-width: 768px) {
@@ -526,42 +893,6 @@ body {
display: block; display: block;
} }
#radarOverlayCanvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 500;
display: none;
}
#radarOverlayCanvas.active {
display: block;
}
#radarScope {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: none;
background: var(--radar-bg);
}
#radarScope.active {
display: flex;
justify-content: center;
align-items: center;
}
#radarCanvas {
max-width: 100%;
max-height: 100%;
}
/* Right sidebar - Mobile first */ /* Right sidebar - Mobile first */
.sidebar { .sidebar {
display: flex; display: flex;
@@ -588,51 +919,21 @@ body {
} }
} }
/* View toggle */
.view-toggle {
display: flex;
padding: 10px;
gap: 8px;
background: var(--bg-panel);
border-bottom: 1px solid rgba(74, 158, 255, 0.2);
}
.view-btn {
flex: 1;
padding: 10px;
border: 1px solid rgba(74, 158, 255, 0.3);
background: transparent;
color: var(--text-secondary);
font-family: 'Orbitron', monospace;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 2px;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
}
.view-btn:hover {
border-color: var(--accent-cyan);
color: var(--accent-cyan);
}
.view-btn.active {
background: var(--accent-cyan);
border-color: var(--accent-cyan);
color: var(--bg-dark);
}
/* Selected aircraft panel */ /* Selected aircraft panel */
.selected-aircraft { .selected-aircraft {
flex-shrink: 0; flex-shrink: 0;
max-height: 480px; max-height: 280px;
overflow-y: auto; overflow-y: auto;
} }
@media (min-height: 900px) {
.selected-aircraft {
max-height: 340px;
}
}
.selected-info { .selected-info {
padding: 12px; padding: 8px;
} }
#aircraftPhotoContainer { #aircraftPhotoContainer {
@@ -640,7 +941,7 @@ body {
} }
#aircraftPhotoContainer img { #aircraftPhotoContainer img {
max-height: 140px; max-height: 100px;
width: 100%; width: 100%;
object-fit: cover; object-fit: cover;
border-radius: 6px; border-radius: 6px;
@@ -649,24 +950,24 @@ body {
.selected-callsign { .selected-callsign {
font-family: 'Orbitron', monospace; font-family: 'Orbitron', monospace;
font-size: 20px; font-size: 16px;
font-weight: 700; font-weight: 700;
color: var(--accent-cyan); color: var(--accent-cyan);
text-shadow: 0 0 15px var(--accent-cyan); text-shadow: 0 0 15px var(--accent-cyan);
text-align: center; text-align: center;
margin-bottom: 12px; margin-bottom: 6px;
} }
.telemetry-grid { .telemetry-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
gap: 6px; gap: 4px;
} }
.telemetry-item { .telemetry-item {
background: rgba(0, 0, 0, 0.3); background: rgba(0, 0, 0, 0.3);
border-radius: 4px; border-radius: 4px;
padding: 8px; padding: 5px 8px;
border-left: 2px solid var(--accent-cyan); border-left: 2px solid var(--accent-cyan);
} }
@@ -776,9 +1077,10 @@ body {
gap: 8px; gap: 8px;
padding: 8px 15px; padding: 8px 15px;
background: var(--bg-panel); background: var(--bg-panel);
border-top: 1px solid rgba(74, 158, 255, 0.3); border-top: none;
font-size: 11px; font-size: 11px;
overflow: hidden; overflow-x: auto;
overflow-y: hidden;
} }
.controls-bar > .control-group { .controls-bar > .control-group {
@@ -907,6 +1209,15 @@ body {
.control-group.airband-group { .control-group.airband-group {
background: rgba(245, 158, 11, 0.05); background: rgba(245, 158, 11, 0.05);
border-color: rgba(245, 158, 11, 0.2); border-color: rgba(245, 158, 11, 0.2);
flex: 1 1 auto;
min-width: 0;
}
.control-group.airband-group > .control-group-items {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 5px;
} }
.control-group.airband-group .control-group-label { .control-group.airband-group .control-group-label {
@@ -1010,6 +1321,7 @@ body {
/* Custom scrollbar */ /* Custom scrollbar */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 6px; width: 6px;
height: 6px;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
@@ -1021,6 +1333,15 @@ body {
border-radius: 3px; border-radius: 3px;
} }
/* Hide scrollbar on controls bar */
.controls-bar::-webkit-scrollbar {
display: none;
}
.controls-bar {
scrollbar-width: none;
}
/* No aircraft message */ /* No aircraft message */
.no-aircraft { .no-aircraft {
text-align: center; text-align: center;
@@ -1289,7 +1610,7 @@ body {
display: flex !important; display: flex !important;
flex-direction: column !important; flex-direction: column !important;
height: auto !important; height: auto !important;
min-height: calc(100dvh - 160px); min-height: 400px;
overflow-y: auto !important; overflow-y: auto !important;
overflow-x: hidden; overflow-x: hidden;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
@@ -1489,6 +1810,10 @@ body {
margin-top: 1px; margin-top: 1px;
} }
.strip-stat.source-stat .strip-value {
font-size: 14px;
}
.strip-stat.session-stat { .strip-stat.session-stat {
background: rgba(34, 197, 94, 0.05); background: rgba(34, 197, 94, 0.05);
border-color: rgba(34, 197, 94, 0.2); border-color: rgba(34, 197, 94, 0.2);
@@ -1779,6 +2104,9 @@ body {
.strip-btn { .strip-btn {
position: relative; position: relative;
z-index: 10; z-index: 10;
display: inline-flex;
align-items: center;
gap: 4px;
background: rgba(74, 158, 255, 0.1); background: rgba(74, 158, 255, 0.1);
border: 1px solid rgba(74, 158, 255, 0.2); border: 1px solid rgba(74, 158, 255, 0.2);
color: var(--text-primary); color: var(--text-primary);
@@ -1789,6 +2117,12 @@ body {
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
white-space: nowrap; white-space: nowrap;
text-decoration: none;
}
.strip-btn svg {
flex-shrink: 0;
opacity: 0.7;
} }
.strip-btn:hover:not(:disabled) { .strip-btn:hover:not(:disabled) {
+2 -2
View File
@@ -5,8 +5,8 @@
} }
:root { :root {
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace; --font-sans: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace; --font-mono: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
--bg-dark: #0b1118; --bg-dark: #0b1118;
--bg-panel: #101823; --bg-panel: #101823;
--bg-card: #151f2b; --bg-card: #151f2b;
+27 -6
View File
@@ -8,8 +8,8 @@
} }
:root { :root {
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace; --font-sans: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace; --font-mono: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
--bg-dark: #0b1118; --bg-dark: #0b1118;
--bg-panel: #101823; --bg-panel: #101823;
--bg-card: #151f2b; --bg-card: #151f2b;
@@ -30,6 +30,27 @@
--radar-bg: #101823; --radar-bg: #101823;
} }
[data-theme="light"] {
--bg-dark: #f4f7fb;
--bg-panel: #e9eef5;
--bg-card: #ffffff;
--border-color: #d1d9e6;
--border-glow: #1f5fa8;
--text-primary: #122034;
--text-secondary: #3a4a5f;
--text-dim: #6b7c93;
--accent-green: #1f8a57;
--accent-cyan: #1f5fa8;
--accent-orange: #b5863a;
--accent-red: #c74444;
--accent-yellow: #b5863a;
--accent-amber: #b5863a;
--grid-line: rgba(31, 95, 168, 0.12);
--noise-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='40'%20height='40'%20viewBox='0%200%2040%2040'%3E%3Cg%20fill='%23000000'%20fill-opacity='0.05'%3E%3Ccircle%20cx='3'%20cy='5'%20r='1'/%3E%3Ccircle%20cx='11'%20cy='9'%20r='1'/%3E%3Ccircle%20cx='18'%20cy='3'%20r='1'/%3E%3Ccircle%20cx='26'%20cy='12'%20r='1'/%3E%3Ccircle%20cx='34'%20cy='6'%20r='1'/%3E%3Ccircle%20cx='7'%20cy='19'%20r='1'/%3E%3Ccircle%20cx='15'%20cy='24'%20r='1'/%3E%3Ccircle%20cx='28'%20cy='22'%20r='1'/%3E%3Ccircle%20cx='36'%20cy='18'%20r='1'/%3E%3Ccircle%20cx='5'%20cy='33'%20r='1'/%3E%3Ccircle%20cx='19'%20cy='32'%20r='1'/%3E%3Ccircle%20cx='31'%20cy='34'%20r='1'/%3E%3C/g%3E%3C/svg%3E");
--radar-cyan: #1f5fa8;
--radar-bg: #e9eef5;
}
body { body {
font-family: var(--font-sans); font-family: var(--font-sans);
background: var(--bg-dark); background: var(--bg-dark);
@@ -496,7 +517,7 @@ body {
padding: 10px 15px; padding: 10px 15px;
background: rgba(74, 158, 255, 0.05); background: rgba(74, 158, 255, 0.05);
border-bottom: 1px solid rgba(74, 158, 255, 0.1); border-bottom: 1px solid rgba(74, 158, 255, 0.1);
font-family: 'Orbitron', 'Space Mono', monospace; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 11px; font-size: 11px;
font-weight: 500; font-weight: 500;
letter-spacing: 2px; letter-spacing: 2px;
@@ -568,7 +589,7 @@ body {
} }
.vessel-name { .vessel-name {
font-family: 'Orbitron', 'Space Mono', monospace; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 16px; font-size: 16px;
font-weight: 700; font-weight: 700;
color: var(--accent-cyan); color: var(--accent-cyan);
@@ -662,7 +683,7 @@ body {
} }
.vessel-item-name { .vessel-item-name {
font-family: 'Orbitron', 'Space Mono', monospace; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
color: var(--accent-cyan); color: var(--accent-cyan);
@@ -1223,7 +1244,7 @@ body {
} }
.dsc-distress-alert .dsc-alert-header { .dsc-distress-alert .dsc-alert-header {
font-family: 'Orbitron', 'Space Mono', monospace; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 24px; font-size: 24px;
font-weight: 700; font-weight: 700;
color: var(--accent-red); color: var(--accent-red);
+440
View File
@@ -0,0 +1,440 @@
/* Shared UX platform components: run-state strip, command palette, setup assistant, and toasts */
.run-state-strip {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 8px 14px;
margin: 6px 12px 0;
border: 1px solid rgba(74, 163, 255, 0.32);
border-radius: 8px;
background: linear-gradient(180deg, rgba(19, 30, 44, 0.96), rgba(11, 18, 28, 0.97));
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.run-state-left {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
min-width: 0;
}
#runStateChips {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.run-state-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-dim, #8697aa);
font-weight: 600;
}
.run-state-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 7px;
border-radius: 999px;
border: 1px solid rgba(74, 163, 255, 0.25);
background: linear-gradient(180deg, rgba(17, 26, 38, 0.82), rgba(12, 18, 28, 0.84));
font-size: 10px;
color: var(--text-secondary, #b1c2d4);
white-space: nowrap;
}
.run-state-chip .dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #667788;
box-shadow: 0 0 0 0 rgba(102, 119, 136, 0.45);
}
.run-state-chip.running .dot {
background: var(--accent-green, #28c27a);
box-shadow: 0 0 0 4px rgba(40, 194, 122, 0.16), 0 0 12px rgba(40, 194, 122, 0.35);
}
.run-state-chip.active {
border-color: rgba(74, 163, 255, 0.65);
color: var(--text-primary, #e6edf5);
box-shadow: inset 0 0 0 1px rgba(74, 163, 255, 0.18);
}
.run-state-right {
display: inline-flex;
align-items: center;
gap: 8px;
}
.run-state-value {
font-size: 10px;
color: var(--text-dim, #8697aa);
}
.run-state-btn {
background: linear-gradient(180deg, rgba(17, 27, 41, 0.9), rgba(10, 16, 25, 0.92));
color: var(--accent-cyan, #4aa3ff);
border: 1px solid rgba(74, 163, 255, 0.45);
border-radius: 6px;
font-size: 10px;
padding: 4px 8px;
cursor: pointer;
transition: background 0.16s ease, border-color 0.16s ease, transform 0.16s ease;
}
.run-state-btn:hover {
background: rgba(74, 163, 255, 0.14);
border-color: rgba(74, 163, 255, 0.7);
transform: translateY(-1px);
}
.command-palette-overlay {
position: fixed;
inset: 0;
display: none;
align-items: flex-start;
justify-content: center;
padding: 10vh 18px 0;
z-index: 25000;
background: rgba(4, 8, 14, 0.65);
backdrop-filter: blur(3px);
}
.command-palette-overlay.open {
display: flex;
}
.command-palette {
width: min(760px, 100%);
border: 1px solid rgba(74, 163, 255, 0.32);
border-radius: 12px;
background: linear-gradient(180deg, rgba(16, 26, 39, 0.98), rgba(10, 17, 27, 0.98));
box-shadow: 0 26px 60px rgba(0, 0, 0, 0.56), inset 0 1px 0 rgba(255, 255, 255, 0.04);
overflow: hidden;
}
.command-palette-header {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-bottom: 1px solid var(--border-color, #1e2d3d);
}
.command-palette-input {
width: 100%;
border: none;
outline: none;
background: transparent;
color: var(--text-primary, #e6edf5);
font-size: 14px;
padding: 2px 0;
}
.command-palette-hint {
font-size: 10px;
color: var(--text-dim, #8697aa);
white-space: nowrap;
}
.command-palette-list {
max-height: min(62vh, 520px);
overflow-y: auto;
}
.command-palette-item {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 12px;
border: none;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
background: transparent;
color: var(--text-secondary, #b1c2d4);
cursor: pointer;
text-align: left;
}
.command-palette-item:last-child {
border-bottom: none;
}
.command-palette-item .meta {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.command-palette-item .title {
color: var(--text-primary, #e6edf5);
font-size: 12px;
font-weight: 600;
}
.command-palette-item .desc {
color: var(--text-dim, #8697aa);
font-size: 10px;
}
.command-palette-item .kbd {
font-size: 9px;
color: var(--text-dim, #8697aa);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 4px;
padding: 1px 5px;
}
.command-palette-item.active,
.command-palette-item:hover,
.command-palette-item:focus-visible {
background: rgba(74, 163, 255, 0.12);
outline: none;
}
.command-palette-empty {
padding: 22px 16px;
color: var(--text-dim, #8697aa);
font-size: 11px;
text-align: center;
}
.setup-overlay {
position: fixed;
inset: 0;
display: none;
align-items: center;
justify-content: center;
z-index: 26000;
background: rgba(4, 8, 14, 0.72);
backdrop-filter: blur(4px);
padding: 14px;
}
.setup-overlay.open {
display: flex;
}
.setup-modal {
width: min(760px, 100%);
max-height: 84vh;
overflow-y: auto;
border: 1px solid var(--border-color, #1e2d3d);
border-radius: 12px;
background: #101926;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.6);
}
.setup-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
border-bottom: 1px solid var(--border-color, #1e2d3d);
padding: 14px;
}
.setup-title {
font-size: 16px;
margin: 0;
color: var(--text-primary, #e6edf5);
}
.setup-subtitle {
margin: 4px 0 0;
font-size: 11px;
color: var(--text-dim, #8697aa);
}
.setup-close {
background: transparent;
border: none;
color: var(--text-dim, #8697aa);
font-size: 22px;
cursor: pointer;
line-height: 1;
}
.setup-content {
padding: 14px;
display: grid;
gap: 10px;
}
.setup-step {
border: 1px solid var(--border-color, #1e2d3d);
border-radius: 8px;
padding: 10px;
background: rgba(255, 255, 255, 0.02);
}
.setup-step-header {
display: flex;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.setup-step-title {
font-size: 12px;
color: var(--text-primary, #e6edf5);
font-weight: 600;
}
.setup-step-status {
font-size: 10px;
color: var(--text-dim, #8697aa);
}
.setup-step-status.done {
color: var(--accent-green, #28c27a);
}
.setup-step-desc {
font-size: 11px;
color: var(--text-secondary, #b1c2d4);
margin: 0 0 8px;
}
.setup-step-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.setup-btn {
padding: 6px 10px;
border-radius: 6px;
border: 1px solid var(--border-color, #1e2d3d);
background: var(--bg-tertiary, #121f2d);
color: var(--text-secondary, #b1c2d4);
font-size: 11px;
cursor: pointer;
}
.setup-btn.primary {
color: #fff;
background: var(--accent-cyan, #4aa3ff);
border-color: var(--accent-cyan, #4aa3ff);
}
.setup-footer {
padding: 12px 14px;
border-top: 1px solid var(--border-color, #1e2d3d);
display: flex;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
.setup-footer-note {
color: var(--text-dim, #8697aa);
font-size: 10px;
}
.app-toast-stack {
position: fixed;
right: 14px;
bottom: 16px;
z-index: 25500;
display: flex;
flex-direction: column;
gap: 8px;
max-width: min(380px, calc(100vw - 24px));
}
.app-toast {
border: 1px solid var(--border-color, #1e2d3d);
border-left: 3px solid var(--accent-cyan, #4aa3ff);
border-radius: 8px;
background: rgba(15, 24, 35, 0.97);
color: var(--text-secondary, #b1c2d4);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.35);
padding: 8px 10px;
font-size: 11px;
}
.app-toast.error {
border-left-color: var(--accent-red, #e25d5d);
}
.app-toast.warning {
border-left-color: var(--accent-orange, #d6a85e);
}
.app-toast-title {
font-size: 11px;
color: var(--text-primary, #e6edf5);
font-weight: 600;
margin-bottom: 4px;
}
.app-toast-msg {
color: var(--text-secondary, #b1c2d4);
}
.app-toast-actions {
margin-top: 7px;
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.app-toast-actions button {
border: 1px solid var(--border-color, #1e2d3d);
border-radius: 4px;
background: var(--bg-tertiary, #132133);
color: var(--text-secondary, #b1c2d4);
font-size: 10px;
padding: 3px 6px;
cursor: pointer;
}
.app-toast-actions button:hover {
border-color: rgba(74, 163, 255, 0.5);
color: var(--text-primary, #e6edf5);
}
@media (max-width: 920px) {
.run-state-strip {
flex-direction: column;
align-items: stretch;
}
.run-state-right {
justify-content: space-between;
}
}
@media (max-width: 640px) {
.command-palette-overlay {
padding: 8vh 10px 0;
}
.command-palette-item {
padding: 9px 10px;
}
.setup-header,
.setup-content,
.setup-footer {
padding: 10px;
}
.app-toast-stack {
left: 10px;
right: 10px;
max-width: none;
}
}
+5 -3
View File
@@ -28,14 +28,16 @@ body {
color: var(--text-primary); color: var(--text-primary);
background-color: var(--bg-primary); background-color: var(--bg-primary);
background-image: background-image:
radial-gradient(1200px 620px at 8% -12%, var(--ambient-top-left), transparent 62%),
radial-gradient(980px 560px at 92% -16%, var(--ambient-top-right), transparent 64%),
radial-gradient(900px 520px at 50% 126%, var(--ambient-bottom), transparent 68%),
var(--noise-image), var(--noise-image),
radial-gradient(circle at 15% 0%, var(--grid-dot), transparent 45%),
linear-gradient(180deg, var(--grid-dot), transparent 35%),
linear-gradient(var(--grid-line) 1px, transparent 1px), linear-gradient(var(--grid-line) 1px, transparent 1px),
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px); linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
background-size: 40px 40px, auto, auto, 48px 48px, 48px 48px; background-size: auto, auto, auto, 40px 40px, 48px 48px, 48px 48px;
background-attachment: fixed; background-attachment: fixed;
min-height: 100vh; min-height: 100vh;
font-variant-numeric: tabular-nums;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
+29 -14
View File
@@ -123,11 +123,12 @@
CARDS / PANELS CARDS / PANELS
============================================ */ ============================================ */
.card { .card {
background: linear-gradient(180deg, var(--bg-card) 0%, var(--bg-secondary) 100%); background: var(--surface-panel-gradient);
border: 1px solid var(--border-color); border: 1px solid rgba(74, 163, 255, 0.24);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
overflow: hidden; overflow: hidden;
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.04);
backdrop-filter: blur(5px);
} }
.card-header { .card-header {
@@ -135,8 +136,8 @@
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: var(--space-3) var(--space-4); padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid rgba(74, 163, 255, 0.18);
background: var(--bg-secondary); background: linear-gradient(180deg, rgba(25, 38, 55, 0.88) 0%, rgba(17, 27, 40, 0.9) 100%);
position: relative; position: relative;
} }
@@ -160,11 +161,12 @@
/* Panel variant (used in dashboards) */ /* Panel variant (used in dashboards) */
.panel { .panel {
background: linear-gradient(180deg, var(--bg-card) 0%, var(--bg-secondary) 100%); background: var(--surface-panel-gradient);
border: 1px solid var(--border-color); border: 1px solid rgba(74, 163, 255, 0.24);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
overflow: hidden; overflow: hidden;
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.04);
backdrop-filter: blur(5px);
} }
@supports (clip-path: polygon(0 0)) { @supports (clip-path: polygon(0 0)) {
@@ -190,8 +192,8 @@
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: var(--space-2) var(--space-3); padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid rgba(74, 163, 255, 0.18);
background: linear-gradient(180deg, var(--bg-elevated) 0%, var(--bg-secondary) 100%); background: linear-gradient(180deg, rgba(25, 38, 55, 0.88) 0%, rgba(17, 27, 40, 0.9) 100%);
font-size: var(--text-xs); font-size: var(--text-xs);
font-weight: var(--font-semibold); font-weight: var(--font-semibold);
text-transform: uppercase; text-transform: uppercase;
@@ -720,10 +722,23 @@
transform var(--transition-base); transform var(--transition-base);
} }
.card:hover, .card:hover,
.panel:hover { .panel:hover {
border-color: var(--border-light); border-color: var(--border-glow);
} box-shadow: var(--shadow-md), var(--shadow-glow), inset 0 1px 0 rgba(255, 255, 255, 0.06);
transform: translateY(-1px);
}
[data-theme="light"] .card,
[data-theme="light"] .panel {
border-color: rgba(31, 95, 168, 0.24);
}
[data-theme="light"] .card-header,
[data-theme="light"] .panel-header {
border-bottom-color: rgba(31, 95, 168, 0.2);
background: linear-gradient(180deg, rgba(243, 247, 252, 0.96) 0%, rgba(233, 239, 247, 0.95) 100%);
}
/* Stats strip value highlight on hover */ /* Stats strip value highlight on hover */
.strip-stat { .strip-stat {
+12 -2
View File
@@ -16,6 +16,11 @@
--bg-card: #121a25; --bg-card: #121a25;
--bg-elevated: #1b2734; --bg-elevated: #1b2734;
--bg-overlay: rgba(8, 13, 20, 0.75); --bg-overlay: rgba(8, 13, 20, 0.75);
--surface-glass: rgba(16, 25, 37, 0.82);
--surface-panel-gradient: linear-gradient(160deg, rgba(20, 32, 47, 0.94) 0%, rgba(11, 18, 27, 0.96) 100%);
--ambient-top-left: rgba(74, 163, 255, 0.14);
--ambient-top-right: rgba(56, 193, 128, 0.09);
--ambient-bottom: rgba(214, 168, 94, 0.06);
/* Background aliases for components */ /* Background aliases for components */
--bg-dark: var(--bg-primary); --bg-dark: var(--bg-primary);
@@ -78,8 +83,8 @@
/* ============================================ /* ============================================
TYPOGRAPHY TYPOGRAPHY
============================================ */ ============================================ */
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace; --font-sans: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace; --font-mono: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
/* Font sizes */ /* Font sizes */
--text-xs: 10px; --text-xs: 10px;
@@ -158,6 +163,11 @@
--bg-card: #ffffff; --bg-card: #ffffff;
--bg-elevated: #f1f4f9; --bg-elevated: #f1f4f9;
--bg-overlay: rgba(244, 247, 251, 0.92); --bg-overlay: rgba(244, 247, 251, 0.92);
--surface-glass: rgba(255, 255, 255, 0.84);
--surface-panel-gradient: linear-gradient(160deg, rgba(255, 255, 255, 0.96) 0%, rgba(241, 245, 251, 0.97) 100%);
--ambient-top-left: rgba(31, 95, 168, 0.1);
--ambient-top-right: rgba(31, 138, 87, 0.06);
--ambient-bottom: rgba(181, 134, 58, 0.05);
/* Background aliases for components */ /* Background aliases for components */
--bg-dark: var(--bg-primary); --bg-dark: var(--bg-primary);
+9 -7
View File
@@ -1,18 +1,20 @@
/* Local font declarations for offline mode */ /* Local font declarations for offline mode */
/* Roboto Condensed - variable font, one file covers all weights */
/* Space Mono - Console font */
@font-face { @font-face {
font-family: 'Space Mono'; font-family: 'Roboto Condensed';
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 300 700;
font-display: swap; font-display: swap;
src: url('/static/vendor/fonts/SpaceMono-Regular.woff2') format('woff2'); src: url('/static/vendor/fonts/RobotoCondensed-Latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
} }
@font-face { @font-face {
font-family: 'Space Mono'; font-family: 'Roboto Condensed';
font-style: normal; font-style: normal;
font-weight: 700; font-weight: 300 700;
font-display: swap; font-display: swap;
src: url('/static/vendor/fonts/SpaceMono-Bold.woff2') format('woff2'); src: url('/static/vendor/fonts/RobotoCondensed-LatinExt.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
} }
+2 -2
View File
@@ -30,7 +30,7 @@
border-bottom: 1px solid var(--border-color, #202833); border-bottom: 1px solid var(--border-color, #202833);
padding: 0 20px; padding: 0 20px;
position: relative; position: relative;
z-index: 100; z-index: 1100;
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
} }
@@ -434,6 +434,6 @@ a.nav-dashboard-btn:hover {
} }
.nav-dashboard-btn .nav-label { .nav-dashboard-btn .nav-label {
font-family: var(--font-mono, 'JetBrains Mono', monospace); font-family: var(--font-mono, 'Roboto Condensed', 'Arial Narrow', sans-serif);
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
+49 -27
View File
@@ -14,6 +14,7 @@
z-index: 10000; z-index: 10000;
overflow-y: auto; overflow-y: auto;
padding: 40px 20px; padding: 40px 20px;
font-family: var(--font-mono, 'Roboto Condensed', 'Arial Narrow', sans-serif);
} }
.help-modal.active { .help-modal.active {
@@ -26,37 +27,41 @@
background: var(--bg-card, var(--bg-secondary, #0f1218)); background: var(--bg-card, var(--bg-secondary, #0f1218));
border: 1px solid var(--border-color, #1f2937); border: 1px solid var(--border-color, #1f2937);
border-radius: 8px; border-radius: 8px;
padding: 30px; padding: 24px;
position: relative; position: relative;
} }
.help-content h2 { .help-content h2 {
color: var(--accent-cyan, #4a9eff); color: var(--accent-cyan, #4a9eff);
margin-bottom: 20px; margin-bottom: 16px;
font-size: 24px; font-size: 15px;
letter-spacing: 2px; letter-spacing: 2px;
text-transform: uppercase;
font-weight: 600;
} }
.help-content h3 { .help-content h3 {
color: var(--text-primary, #e8eaed); color: var(--text-primary, #e8eaed);
margin: 25px 0 15px 0; margin: 20px 0 10px 0;
font-size: 14px; font-size: 11px;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 1px; letter-spacing: 1px;
border-bottom: 1px solid var(--border-color, #1f2937); border-bottom: 1px solid var(--border-color, #1f2937);
padding-bottom: 8px; padding-bottom: 6px;
font-weight: 600;
} }
.help-close { .help-close {
position: absolute; position: absolute;
top: 15px; top: 12px;
right: 15px; right: 12px;
background: none; background: none;
border: none; border: none;
color: var(--text-dim, #4b5563); color: var(--text-dim, #4b5563);
font-size: 24px; font-size: 20px;
cursor: pointer; cursor: pointer;
transition: color 0.2s; transition: color 0.2s;
line-height: 1;
} }
.help-close:hover { .help-close:hover {
@@ -66,43 +71,54 @@
.help-modal .icon-grid { .help-modal .icon-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px; gap: 8px;
margin: 15px 0; margin: 10px 0;
} }
.help-modal .icon-item { .help-modal .icon-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 8px;
padding: 10px; padding: 6px 8px;
background: var(--bg-primary, #0a0c10); background: var(--bg-primary, #0a0c10);
border: 1px solid var(--border-color, #1f2937); border: 1px solid var(--border-color, #1f2937);
border-radius: 4px; border-radius: 4px;
font-size: 12px; font-size: 11px;
} }
.help-modal .icon-item .icon { .help-modal .icon-item .icon {
font-size: 18px; width: 20px;
width: 30px; height: 20px;
text-align: center; flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.help-modal .icon-item .icon svg {
width: 16px;
height: 16px;
} }
.help-modal .icon-item .desc { .help-modal .icon-item .desc {
color: var(--text-secondary, #9ca3af); color: var(--text-secondary, #9ca3af);
font-size: 10.5px;
line-height: 1.3;
} }
.help-modal .tip-list { .help-modal .tip-list {
list-style: none; list-style: none;
padding: 0; padding: 0;
margin: 15px 0; margin: 10px 0;
} }
.help-modal .tip-list li { .help-modal .tip-list li {
padding: 8px 0; padding: 5px 0;
padding-left: 20px; padding-left: 16px;
position: relative; position: relative;
color: var(--text-secondary, #9ca3af); color: var(--text-secondary, #9ca3af);
font-size: 13px; font-size: 11px;
line-height: 1.5;
border-bottom: 1px solid var(--border-color, #1f2937); border-bottom: 1px solid var(--border-color, #1f2937);
} }
@@ -118,10 +134,15 @@
font-weight: bold; font-weight: bold;
} }
.help-modal .tip-list li strong {
color: var(--text-primary, #e8eaed);
font-weight: 600;
}
.help-tabs { .help-tabs {
display: flex; display: flex;
gap: 0; gap: 0;
margin-bottom: 20px; margin-bottom: 16px;
border: 1px solid var(--border-color, #1f2937); border: 1px solid var(--border-color, #1f2937);
border-radius: 4px; border-radius: 4px;
overflow: hidden; overflow: hidden;
@@ -129,12 +150,13 @@
.help-tab { .help-tab {
flex: 1; flex: 1;
padding: 10px; padding: 8px;
background: var(--bg-primary, #0a0c10); background: var(--bg-primary, #0a0c10);
border: none; border: none;
color: var(--text-secondary, #9ca3af); color: var(--text-secondary, #9ca3af);
cursor: pointer; cursor: pointer;
font-size: 11px; font-family: var(--font-mono, 'Roboto Condensed', 'Arial Narrow', sans-serif);
font-size: 10px;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 1px; letter-spacing: 1px;
transition: all 0.15s ease; transition: all 0.15s ease;
@@ -176,9 +198,9 @@
/* Ensure code tags are styled */ /* Ensure code tags are styled */
.help-modal code { .help-modal code {
background: var(--bg-tertiary, #151a23); background: var(--bg-tertiary, #151a23);
padding: 2px 6px; padding: 1px 5px;
border-radius: 3px; border-radius: 3px;
font-family: var(--font-mono, 'JetBrains Mono', monospace); font-family: var(--font-mono, 'Roboto Condensed', 'Arial Narrow', sans-serif);
font-size: 11px; font-size: 10.5px;
color: var(--accent-cyan, #4a9eff); color: var(--accent-cyan, #4a9eff);
} }
+986 -26
View File
File diff suppressed because it is too large Load Diff
+500
View File
@@ -0,0 +1,500 @@
/* Analytics Dashboard Styles */
/* Analytics is a sidebar-only mode — hide the output panel and expand the sidebar */
@media (min-width: 1024px) {
.main-content.analytics-active {
grid-template-columns: 1fr !important;
}
.main-content.analytics-active > .output-panel {
display: none !important;
}
.main-content.analytics-active > .sidebar {
max-width: 100% !important;
width: 100% !important;
}
.main-content.analytics-active .sidebar-collapse-btn {
display: none !important;
}
}
@media (max-width: 1023px) {
.main-content.analytics-active > .output-panel {
display: none !important;
}
}
.analytics-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: var(--space-3, 12px);
margin-bottom: var(--space-4, 16px);
}
.analytics-insight-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
gap: var(--space-3, 12px);
}
.analytics-insight-card {
background: var(--bg-card, #151f2b);
border: 1px solid var(--border-color, #1e2d3d);
border-radius: var(--radius-md, 8px);
padding: 10px;
display: flex;
flex-direction: column;
gap: 4px;
}
.analytics-insight-card.low {
border-color: rgba(90, 106, 122, 0.5);
}
.analytics-insight-card.medium {
border-color: rgba(74, 163, 255, 0.45);
}
.analytics-insight-card.high {
border-color: rgba(214, 168, 94, 0.55);
}
.analytics-insight-card.critical {
border-color: rgba(226, 93, 93, 0.65);
}
.analytics-insight-card .insight-title {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-dim, #5a6a7a);
}
.analytics-insight-card .insight-value {
font-size: 16px;
font-weight: 700;
color: var(--text-primary, #e0e6ed);
line-height: 1.2;
}
.analytics-insight-card .insight-label {
font-size: 10px;
color: var(--text-secondary, #9aabba);
}
.analytics-insight-card .insight-detail {
font-size: 10px;
color: var(--text-dim, #5a6a7a);
}
.analytics-top-changes {
margin-top: 12px;
}
.analytics-change-row {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 0;
border-bottom: 1px solid var(--border-color, #1e2d3d);
font-size: 10px;
}
.analytics-change-row:last-child {
border-bottom: none;
}
.analytics-change-row .mode {
min-width: 84px;
color: var(--text-primary, #e0e6ed);
font-weight: 600;
}
.analytics-change-row .delta {
min-width: 48px;
font-family: var(--font-mono, monospace);
}
.analytics-change-row .delta.up {
color: var(--accent-green, #38c180);
}
.analytics-change-row .delta.down {
color: var(--accent-red, #e25d5d);
}
.analytics-change-row .delta.flat {
color: var(--text-dim, #5a6a7a);
}
.analytics-change-row .avg {
color: var(--text-dim, #5a6a7a);
}
.analytics-card {
background: var(--bg-card, #151f2b);
border: 1px solid var(--border-color, #1e2d3d);
border-radius: var(--radius-md, 8px);
padding: var(--space-3, 12px);
text-align: center;
transition: var(--transition-fast, 150ms ease);
}
.analytics-card:hover {
border-color: var(--accent-cyan, #4aa3ff);
}
.analytics-card .card-count {
font-size: var(--text-2xl, 24px);
font-weight: 700;
color: var(--text-primary, #e0e6ed);
line-height: 1.2;
}
.analytics-card .card-label {
font-size: var(--text-xs, 10px);
color: var(--text-dim, #5a6a7a);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-top: var(--space-1, 4px);
}
.analytics-card .card-sparkline {
height: 24px;
margin-top: var(--space-2, 8px);
}
.analytics-card .card-sparkline svg {
width: 100%;
height: 100%;
}
.analytics-card .card-sparkline polyline {
fill: none;
stroke: var(--accent-cyan, #4aa3ff);
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
}
/* Health indicators */
.analytics-health {
display: flex;
flex-wrap: wrap;
gap: var(--space-2, 8px);
margin-bottom: var(--space-4, 16px);
}
.health-item {
display: flex;
align-items: center;
gap: var(--space-1, 4px);
font-size: var(--text-xs, 10px);
color: var(--text-dim, #5a6a7a);
text-transform: uppercase;
}
.health-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent-red, #e25d5d);
}
.health-dot.running {
background: var(--accent-green, #38c180);
}
/* Emergency squawk panel */
.squawk-emergency {
background: rgba(226, 93, 93, 0.1);
border: 1px solid var(--accent-red, #e25d5d);
border-radius: var(--radius-md, 8px);
padding: var(--space-3, 12px);
margin-bottom: var(--space-3, 12px);
}
.squawk-emergency .squawk-title {
color: var(--accent-red, #e25d5d);
font-weight: 700;
font-size: var(--text-sm, 12px);
text-transform: uppercase;
margin-bottom: var(--space-2, 8px);
}
.squawk-emergency .squawk-item {
font-size: var(--text-sm, 12px);
color: var(--text-primary, #e0e6ed);
padding: var(--space-1, 4px) 0;
border-bottom: 1px solid rgba(226, 93, 93, 0.2);
}
.squawk-emergency .squawk-item:last-child {
border-bottom: none;
}
/* Alert feed */
.analytics-alert-feed {
max-height: 200px;
overflow-y: auto;
margin-bottom: var(--space-4, 16px);
}
.analytics-alert-item {
display: flex;
align-items: flex-start;
gap: var(--space-2, 8px);
padding: var(--space-2, 8px);
border-bottom: 1px solid var(--border-color, #1e2d3d);
font-size: var(--text-xs, 10px);
}
.analytics-alert-item .alert-severity {
padding: 1px 6px;
border-radius: var(--radius-sm, 4px);
font-weight: 600;
text-transform: uppercase;
font-size: 9px;
white-space: nowrap;
}
.alert-severity.critical { background: var(--accent-red, #e25d5d); color: #fff; }
.alert-severity.high { background: var(--accent-orange, #d6a85e); color: #000; }
.alert-severity.medium { background: var(--accent-cyan, #4aa3ff); color: #fff; }
.alert-severity.low { background: var(--border-color, #1e2d3d); color: var(--text-dim, #5a6a7a); }
/* Correlation panel */
.analytics-correlation-pair {
display: flex;
align-items: center;
gap: var(--space-2, 8px);
padding: var(--space-2, 8px);
border-bottom: 1px solid var(--border-color, #1e2d3d);
font-size: var(--text-xs, 10px);
}
.analytics-correlation-pair .confidence-bar {
height: 4px;
background: var(--bg-secondary, #101823);
border-radius: 2px;
flex: 1;
max-width: 60px;
}
.analytics-correlation-pair .confidence-fill {
height: 100%;
background: var(--accent-green, #38c180);
border-radius: 2px;
}
.analytics-pattern-item {
padding: 8px;
border-bottom: 1px solid var(--border-color, #1e2d3d);
display: flex;
flex-direction: column;
gap: 4px;
}
.analytics-pattern-item:last-child {
border-bottom: none;
}
.analytics-pattern-item .pattern-main {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.analytics-pattern-item .pattern-mode {
font-size: 10px;
font-weight: 600;
color: var(--text-primary, #e0e6ed);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.analytics-pattern-item .pattern-device {
font-size: 10px;
color: var(--text-dim, #5a6a7a);
font-family: var(--font-mono, monospace);
}
.analytics-pattern-item .pattern-meta {
display: flex;
gap: 10px;
font-size: 10px;
color: var(--text-dim, #5a6a7a);
flex-wrap: wrap;
}
.analytics-pattern-item .pattern-confidence {
color: var(--accent-green, #38c180);
font-weight: 600;
}
/* Geofence zone list */
.geofence-zone-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-2, 8px);
border-bottom: 1px solid var(--border-color, #1e2d3d);
font-size: var(--text-xs, 10px);
}
.geofence-zone-item .zone-name {
font-weight: 600;
color: var(--text-primary, #e0e6ed);
}
.geofence-zone-item .zone-radius {
color: var(--text-dim, #5a6a7a);
}
.geofence-zone-item .zone-delete {
cursor: pointer;
color: var(--accent-red, #e25d5d);
padding: 2px 6px;
border: 1px solid var(--accent-red, #e25d5d);
border-radius: var(--radius-sm, 4px);
background: transparent;
font-size: 9px;
}
/* Export controls */
.export-controls {
display: flex;
gap: var(--space-2, 8px);
align-items: center;
flex-wrap: wrap;
}
.export-controls select,
.export-controls button {
font-size: var(--text-xs, 10px);
padding: var(--space-1, 4px) var(--space-2, 8px);
background: var(--bg-card, #151f2b);
color: var(--text-primary, #e0e6ed);
border: 1px solid var(--border-color, #1e2d3d);
border-radius: var(--radius-sm, 4px);
}
.export-controls button {
cursor: pointer;
background: var(--accent-cyan, #4aa3ff);
color: #fff;
border-color: var(--accent-cyan, #4aa3ff);
}
.export-controls button:hover {
opacity: 0.9;
}
/* Section headers */
.analytics-section-header {
font-size: var(--text-xs, 10px);
font-weight: 600;
color: var(--text-dim, #5a6a7a);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: var(--space-2, 8px);
padding-bottom: var(--space-1, 4px);
border-bottom: 1px solid var(--border-color, #1e2d3d);
}
/* Empty state */
.analytics-empty {
text-align: center;
color: var(--text-dim, #5a6a7a);
font-size: var(--text-xs, 10px);
padding: var(--space-4, 16px);
font-style: italic;
}
.analytics-target-toolbar,
.analytics-replay-toolbar {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
margin-bottom: 10px;
}
.analytics-target-toolbar input {
flex: 1;
min-width: 220px;
background: var(--bg-card, #151f2b);
color: var(--text-primary, #e0e6ed);
border: 1px solid var(--border-color, #1e2d3d);
border-radius: 4px;
padding: 6px 8px;
font-size: 11px;
}
.analytics-target-toolbar button,
.analytics-replay-toolbar button,
.analytics-replay-toolbar select {
font-size: 10px;
padding: 5px 9px;
border-radius: 4px;
border: 1px solid var(--border-color, #1e2d3d);
background: var(--bg-card, #151f2b);
color: var(--text-primary, #e0e6ed);
}
.analytics-target-toolbar button,
.analytics-replay-toolbar button {
cursor: pointer;
background: rgba(74, 163, 255, 0.2);
border-color: rgba(74, 163, 255, 0.45);
}
.analytics-target-summary {
font-size: 10px;
color: var(--text-dim, #5a6a7a);
margin-bottom: 8px;
}
.analytics-target-item,
.analytics-replay-item {
border-bottom: 1px solid var(--border-color, #1e2d3d);
padding: 7px 0;
display: grid;
gap: 4px;
}
.analytics-target-item:last-child,
.analytics-replay-item:last-child {
border-bottom: none;
}
.analytics-target-item .title,
.analytics-replay-item .title {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
font-size: 11px;
color: var(--text-primary, #e0e6ed);
font-weight: 600;
}
.analytics-target-item .mode,
.analytics-replay-item .mode {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.05em;
border: 1px solid rgba(74, 163, 255, 0.35);
color: var(--accent-cyan, #4aa3ff);
border-radius: 4px;
padding: 1px 6px;
}
.analytics-target-item .meta,
.analytics-replay-item .meta {
font-size: 10px;
color: var(--text-dim, #5a6a7a);
display: flex;
gap: 10px;
flex-wrap: wrap;
}
+47
View File
@@ -326,3 +326,50 @@
.aprs-meter-status.no-signal { .aprs-meter-status.no-signal {
color: var(--accent-yellow); color: var(--accent-yellow);
} }
/* APRS map markers (flat SVG icons) */
.aprs-map-marker-wrap {
background: transparent;
border: none;
}
.aprs-map-marker {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 7px 2px 5px;
border-radius: 999px;
border: 1px solid rgba(74, 158, 255, 0.35);
background: rgba(10, 18, 28, 0.88);
color: var(--text-primary);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
}
.aprs-map-marker-icon {
display: inline-flex;
width: 14px;
height: 14px;
color: var(--accent-cyan);
}
.aprs-map-marker-icon svg {
width: 14px;
height: 14px;
display: block;
fill: currentColor;
}
.aprs-map-marker-label {
font-size: 9px;
font-weight: 600;
line-height: 1;
letter-spacing: 0.02em;
}
.aprs-map-marker.vehicle .aprs-map-marker-icon {
color: var(--accent-green);
}
.aprs-map-marker.tower .aprs-map-marker-icon {
color: var(--accent-cyan);
}
+560
View File
@@ -0,0 +1,560 @@
/* BT Locate Mode Styles */
/* Environment preset grid */
.btl-env-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 6px;
}
.btl-env-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 8px 4px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
color: var(--text-secondary);
}
.btl-env-btn:hover {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.2);
}
.btl-env-btn.active {
background: rgba(0, 255, 136, 0.1);
border-color: var(--accent-green, #00ff88);
color: var(--text-primary);
}
.btl-env-icon {
font-size: 18px;
line-height: 1;
}
.btl-env-label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
}
.btl-env-n {
font-size: 9px;
font-family: var(--font-mono);
color: var(--text-dim);
}
/* ============================================
PROXIMITY HUD main visuals area
============================================ */
.btl-hud {
display: flex;
flex-direction: column;
gap: 0;
background: rgba(0, 0, 0, 0.5);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
flex-shrink: 0;
overflow: hidden;
}
.btl-hud-top {
display: flex;
align-items: center;
gap: 20px;
padding: 14px 20px;
}
.btl-hud-band {
font-size: 22px;
font-weight: 800;
font-family: var(--font-mono);
letter-spacing: 2px;
padding: 14px 20px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.03);
border: 2px solid rgba(255, 255, 255, 0.1);
color: var(--text-dim);
text-align: center;
min-width: 130px;
transition: all 0.3s;
flex-shrink: 0;
}
.btl-hud-band.immediate {
color: #ef4444;
border-color: #ef4444;
background: rgba(239, 68, 68, 0.15);
box-shadow: 0 0 20px rgba(239, 68, 68, 0.2);
animation: btl-pulse 1s ease-in-out infinite;
}
.btl-hud-band.near {
color: #f97316;
border-color: #f97316;
background: rgba(249, 115, 22, 0.12);
box-shadow: 0 0 15px rgba(249, 115, 22, 0.15);
animation: btl-pulse 2s ease-in-out infinite;
}
.btl-hud-band.far {
color: #eab308;
border-color: #eab308;
background: rgba(234, 179, 8, 0.1);
box-shadow: 0 0 10px rgba(234, 179, 8, 0.1);
}
@keyframes btl-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.btl-hud-metrics {
display: flex;
gap: 20px;
flex: 1;
align-items: flex-start;
}
.btl-hud-separator {
width: 1px;
height: 40px;
background: rgba(255, 255, 255, 0.08);
align-self: center;
flex-shrink: 0;
}
.btl-hud-metric {
display: flex;
flex-direction: column;
align-items: center;
min-width: 60px;
}
.btl-hud-metric-lg .btl-hud-value {
font-size: 28px;
}
.btl-hud-value {
font-size: 22px;
font-weight: 700;
font-family: var(--font-mono);
color: var(--text-primary);
line-height: 1.1;
}
.btl-hud-unit {
font-size: 10px;
color: var(--text-dim);
font-family: var(--font-mono);
}
.btl-hud-label {
font-size: 9px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 2px;
}
.btl-hud-controls {
display: flex;
flex-direction: column;
gap: 6px;
flex-shrink: 0;
}
.btl-hud-export-row {
display: flex;
gap: 5px;
align-items: center;
}
.btl-hud-export-format {
min-width: 62px;
padding: 3px 6px;
font-size: 10px;
font-family: var(--font-mono);
color: var(--text-secondary);
background: rgba(0, 0, 0, 0.45);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 4px;
}
.btl-hud-audio-toggle {
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
color: var(--text-secondary);
cursor: pointer;
white-space: nowrap;
}
.btl-hud-audio-toggle input[type="checkbox"] {
margin: 0;
}
.btl-hud-clear-btn {
padding: 4px 10px;
font-size: 10px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
color: var(--text-dim);
cursor: pointer;
transition: all 0.2s;
}
.btl-hud-clear-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--text-secondary);
}
/* Bottom info bar */
.btl-hud-bottom {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 20px;
background: rgba(0, 0, 0, 0.3);
border-top: 1px solid rgba(255, 255, 255, 0.05);
}
.btl-hud-info {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.btl-hud-info-item {
font-size: 10px;
font-family: var(--font-mono);
color: var(--text-dim);
}
.btl-hud-info-sep {
color: rgba(255, 255, 255, 0.15);
font-size: 10px;
}
.btl-hud-diag {
display: none;
font-size: 9px;
color: var(--text-dim);
font-family: var(--font-mono);
opacity: 0.5;
white-space: nowrap;
}
.btl-hud-diag:not(:empty) {
display: block;
}
/* ============================================
VISUALS AREA map + chart
============================================ */
.btl-visuals-container {
display: flex;
flex-direction: column;
gap: 8px;
height: 100%;
padding: 8px;
}
.btl-map-container {
flex: 1;
min-height: 250px;
position: relative;
border-radius: 8px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
}
#btLocateMap {
width: 100%;
height: 100%;
background: #1a1a2e;
}
.btl-map-overlay-controls {
position: absolute;
top: 10px;
right: 10px;
z-index: 450;
display: flex;
flex-direction: column;
gap: 4px;
padding: 7px 8px;
border-radius: 7px;
background: rgba(0, 0, 0, 0.6);
border: 1px solid rgba(255, 255, 255, 0.15);
backdrop-filter: blur(4px);
}
.btl-map-overlay-toggle {
display: flex;
align-items: center;
gap: 5px;
font-size: 10px;
color: var(--text-secondary);
font-family: var(--font-mono);
cursor: pointer;
white-space: nowrap;
}
.btl-map-overlay-toggle input[type="checkbox"] {
margin: 0;
}
.btl-map-overlay-toggle input[type="checkbox"]:disabled + span {
opacity: 0.45;
}
.btl-map-heat-legend {
position: absolute;
left: 10px;
bottom: 10px;
z-index: 430;
min-width: 120px;
padding: 6px 8px;
border-radius: 7px;
background: rgba(0, 0, 0, 0.6);
border: 1px solid rgba(255, 255, 255, 0.14);
backdrop-filter: blur(4px);
}
.btl-map-heat-label {
display: block;
font-size: 9px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.7px;
margin-bottom: 4px;
}
.btl-map-heat-bar {
height: 7px;
border-radius: 4px;
background: linear-gradient(90deg, #2563eb 0%, #16a34a 40%, #f59e0b 70%, #ef4444 100%);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.btl-map-heat-scale {
display: flex;
justify-content: space-between;
margin-top: 3px;
font-size: 8px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.btl-map-track-stats {
position: absolute;
right: 10px;
bottom: 10px;
z-index: 430;
padding: 5px 8px;
border-radius: 7px;
background: rgba(0, 0, 0, 0.6);
border: 1px solid rgba(255, 255, 255, 0.14);
color: var(--text-secondary);
font-size: 10px;
font-family: var(--font-mono);
backdrop-filter: blur(4px);
}
.btl-rssi-chart-container {
height: 100px;
background: rgba(0, 0, 0, 0.3);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
padding: 8px;
position: relative;
flex-shrink: 0;
}
.btl-rssi-chart-container .btl-chart-label {
position: absolute;
top: 4px;
left: 8px;
font-size: 9px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 1px;
}
#btLocateRssiChart {
width: 100%;
height: 100%;
}
/* ============================================
LOCATE BUTTON Bluetooth device cards
============================================ */
.bt-locate-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--accent-green, #00ff88);
background: rgba(0, 255, 136, 0.1);
border: 1px solid rgba(0, 255, 136, 0.3);
border-radius: 3px;
cursor: pointer;
transition: all 0.2s;
}
.bt-locate-btn:hover {
background: rgba(0, 255, 136, 0.2);
border-color: var(--accent-green, #00ff88);
}
.bt-locate-btn svg {
width: 10px;
height: 10px;
}
/* ============================================
IRK DETECT BUTTON + DEVICE PICKER
============================================ */
.btl-detect-irk-btn {
padding: 5px 10px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--accent-cyan, #00d4ff);
background: rgba(0, 212, 255, 0.1);
border: 1px solid rgba(0, 212, 255, 0.3);
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
flex-shrink: 0;
}
.btl-detect-irk-btn:hover {
background: rgba(0, 212, 255, 0.2);
border-color: var(--accent-cyan, #00d4ff);
}
.btl-detect-irk-btn:disabled {
opacity: 0.5;
cursor: wait;
}
.btl-irk-picker {
margin-top: 6px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
overflow: hidden;
}
.btl-irk-picker-status {
padding: 8px 10px;
font-size: 10px;
color: var(--text-dim);
text-align: center;
}
.btl-irk-picker-list {
max-height: 160px;
overflow-y: auto;
}
.btl-irk-picker-item {
padding: 7px 10px;
cursor: pointer;
transition: background 0.15s;
border-top: 1px solid rgba(255, 255, 255, 0.05);
}
.btl-irk-picker-item:first-child {
border-top: none;
}
.btl-irk-picker-item:hover {
background: rgba(0, 255, 136, 0.08);
}
.btl-irk-picker-name {
font-size: 11px;
font-weight: 600;
color: var(--text-primary);
}
.btl-irk-picker-meta {
font-size: 9px;
font-family: var(--font-mono);
color: var(--text-dim);
margin-top: 1px;
}
/* ============================================
RESPONSIVE stack HUD vertically on narrow
============================================ */
@media (max-width: 900px) {
.btl-hud {
flex-wrap: wrap;
gap: 10px;
}
.btl-hud-band {
min-width: unset;
width: 100%;
font-size: 20px;
}
.btl-hud-metrics {
width: 100%;
justify-content: space-around;
}
.btl-hud-controls {
flex-direction: row;
width: 100%;
justify-content: center;
flex-wrap: wrap;
}
.btl-hud-export-row {
width: 100%;
justify-content: center;
}
.btl-map-overlay-controls {
top: 8px;
right: 8px;
gap: 3px;
padding: 6px 7px;
}
.btl-map-heat-legend {
left: 8px;
bottom: 8px;
}
.btl-map-track-stats {
right: 8px;
bottom: 8px;
font-size: 9px;
}
}
+337
View File
@@ -0,0 +1,337 @@
/* GPS Mode Styles */
/* Sidebar info grid */
.gps-info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.gps-info-item {
display: flex;
flex-direction: column;
gap: 2px;
padding: 4px 6px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 3px;
}
.gps-info-label {
font-size: 9px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.gps-info-value {
font-size: 12px;
color: var(--text-primary);
font-weight: 600;
}
.gps-mono {
font-family: var(--font-mono);
}
/* Connection status */
.gps-connection-status {
display: flex;
align-items: center;
gap: 6px;
}
.gps-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-dim);
flex-shrink: 0;
}
.gps-status-dot.connected {
background: #00ff88;
box-shadow: 0 0 6px rgba(0, 255, 136, 0.4);
}
.gps-status-dot.waiting {
background: #ffaa00;
box-shadow: 0 0 6px rgba(255, 170, 0, 0.4);
}
.gps-status-dot.error {
background: #ff4444;
box-shadow: 0 0 6px rgba(255, 68, 68, 0.4);
}
.gps-status-text {
font-size: 11px;
color: var(--text-secondary);
font-family: var(--font-mono);
}
/* Fix badge */
.gps-fix-badge {
display: inline-block;
padding: 1px 6px;
border-radius: 3px;
font-size: 10px;
font-weight: 700;
font-family: var(--font-mono);
}
.gps-fix-badge.no-fix {
background: rgba(255, 68, 68, 0.2);
color: #ff4444;
border: 1px solid rgba(255, 68, 68, 0.3);
}
.gps-fix-badge.fix-2d {
background: rgba(255, 170, 0, 0.2);
color: #ffaa00;
border: 1px solid rgba(255, 170, 0, 0.3);
}
.gps-fix-badge.fix-3d {
background: rgba(0, 255, 136, 0.2);
color: #00ff88;
border: 1px solid rgba(0, 255, 136, 0.3);
}
/* DOP quality indicators */
.gps-dop-good { color: #00ff88; }
.gps-dop-moderate { color: #ffaa00; }
.gps-dop-poor { color: #ff4444; }
/* ===== Visuals Panel ===== */
.gps-visuals-container {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
height: 100%;
overflow-y: auto;
}
/* Top row: sky view + position info */
.gps-visuals-top {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
/* Sky View */
.gps-skyview-panel {
flex: 1;
min-width: 320px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 12px;
}
.gps-skyview-panel h4 {
margin: 0 0 8px 0;
font-size: 11px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.gps-skyview-canvas-wrap {
display: flex;
justify-content: center;
align-items: center;
}
#gpsSkyCanvas {
max-width: 100%;
height: auto;
}
/* Position info panel */
.gps-position-panel {
flex: 1;
min-width: 280px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.gps-position-panel h4 {
margin: 0;
font-size: 11px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.gps-pos-big {
font-family: var(--font-mono);
font-size: 20px;
font-weight: 700;
color: var(--accent-cyan);
line-height: 1.3;
}
.gps-pos-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 0;
border-bottom: 1px solid var(--border-color);
}
.gps-pos-row:last-child {
border-bottom: none;
}
.gps-pos-label {
font-size: 10px;
color: var(--text-dim);
text-transform: uppercase;
}
.gps-pos-value {
font-family: var(--font-mono);
font-size: 13px;
color: var(--text-primary);
font-weight: 600;
}
/* Signal Strength Bars */
.gps-signal-panel {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 12px;
}
.gps-signal-panel h4 {
margin: 0 0 8px 0;
font-size: 11px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.gps-signal-bars {
display: flex;
align-items: flex-end;
gap: 3px;
height: 140px;
padding: 0 4px;
overflow-x: auto;
}
.gps-signal-bar-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
min-width: 18px;
height: 100%;
justify-content: flex-end;
}
.gps-signal-bar {
width: 14px;
border-radius: 2px 2px 0 0;
min-height: 2px;
transition: height 0.3s ease;
}
.gps-signal-bar.unused {
opacity: 0.4;
}
.gps-signal-prn {
font-size: 8px;
font-family: var(--font-mono);
color: var(--text-dim);
writing-mode: horizontal-tb;
}
.gps-signal-snr {
font-size: 7px;
font-family: var(--font-mono);
color: var(--text-secondary);
}
/* Constellation colors */
.gps-const-gps { background-color: #00d4ff; }
.gps-const-glonass { background-color: #00ff88; }
.gps-const-galileo { background-color: #ff8800; }
.gps-const-beidou { background-color: #ff4466; }
.gps-const-sbas { background-color: #ffdd00; }
.gps-const-qzss { background-color: #cc66ff; }
/* Legend */
.gps-legend {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--border-color);
}
.gps-legend-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 9px;
color: var(--text-dim);
}
.gps-legend-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
/* Empty state */
.gps-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 40px;
color: var(--text-dim);
text-align: center;
}
.gps-empty-state svg {
width: 48px;
height: 48px;
opacity: 0.3;
}
.gps-empty-state p {
font-size: 12px;
max-width: 300px;
line-height: 1.5;
}
/* Responsive */
@media (max-width: 768px) {
.gps-visuals-top {
flex-direction: column;
}
.gps-skyview-panel,
.gps-position-panel {
min-width: unset;
}
.gps-pos-big {
font-size: 16px;
}
}
+467
View File
@@ -0,0 +1,467 @@
/* Space Weather Mode Styles */
/* Main container */
.sw-visuals-container {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
height: 100%;
overflow-y: auto;
}
/* Header metrics strip */
.sw-header-strip {
display: flex;
align-items: center;
gap: 2px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 8px 12px;
flex-wrap: wrap;
}
.sw-header-stat {
display: flex;
flex-direction: column;
align-items: center;
padding: 4px 12px;
min-width: 60px;
}
.sw-header-stat + .sw-header-stat {
border-left: 1px solid var(--border-color);
}
.sw-header-value {
font-family: var(--font-mono, 'Roboto Condensed', monospace);
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.2;
}
.sw-header-label {
font-size: 9px;
font-weight: 600;
letter-spacing: 0.5px;
color: var(--text-dim);
text-transform: uppercase;
}
.sw-header-value.accent-cyan { color: var(--accent-cyan); }
.sw-header-value.accent-green { color: #00ff88; }
.sw-header-value.accent-yellow { color: #ffcc00; }
.sw-header-value.accent-orange { color: #ff8800; }
.sw-header-value.accent-red { color: #ff3366; }
/* Refresh controls in strip */
.sw-strip-controls {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
padding-left: 12px;
}
.sw-refresh-btn {
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-secondary);
padding: 4px 10px;
border-radius: 4px;
font-size: 11px;
cursor: pointer;
font-family: var(--font-mono, 'Roboto Condensed', monospace);
transition: border-color 0.2s, color 0.2s;
}
.sw-refresh-btn:hover {
border-color: var(--accent-cyan);
color: var(--accent-cyan);
}
.sw-last-update {
font-size: 10px;
color: var(--text-dim);
font-family: var(--font-mono, 'Roboto Condensed', monospace);
}
/* NOAA G/S/R Scale cards */
.sw-scales-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.sw-scale-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 10px;
text-align: center;
}
.sw-scale-label {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.5px;
color: var(--text-dim);
text-transform: uppercase;
margin-bottom: 4px;
}
.sw-scale-value {
font-family: var(--font-mono, 'Roboto Condensed', monospace);
font-size: 24px;
font-weight: 700;
line-height: 1.2;
}
.sw-scale-desc {
font-size: 10px;
color: var(--text-dim);
margin-top: 2px;
}
/* Scale severity colors */
.sw-scale-0 { color: #00ff88; border-color: #00ff8833; }
.sw-scale-1 { color: #88ff00; border-color: #88ff0033; }
.sw-scale-2 { color: #ffcc00; border-color: #ffcc0033; }
.sw-scale-3 { color: #ff8800; border-color: #ff880033; }
.sw-scale-4 { color: #ff4400; border-color: #ff440033; }
.sw-scale-5 { color: #ff0044; border-color: #ff004433; }
/* HF Band conditions grid */
.sw-band-panel {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 12px;
}
.sw-band-title {
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
}
.sw-band-grid {
display: grid;
grid-template-columns: auto repeat(2, 1fr);
gap: 4px 8px;
font-size: 11px;
font-family: var(--font-mono, 'Roboto Condensed', monospace);
}
.sw-band-header {
font-size: 10px;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
padding-bottom: 4px;
border-bottom: 1px solid var(--border-color);
}
.sw-band-name {
color: var(--text-secondary);
font-weight: 500;
}
.sw-band-cond {
text-align: center;
padding: 2px 6px;
border-radius: 3px;
font-size: 10px;
font-weight: 600;
}
.sw-band-good { color: #00ff88; background: #00ff8815; }
.sw-band-fair { color: #ffcc00; background: #ffcc0015; }
.sw-band-poor { color: #ff3366; background: #ff336615; }
/* 2-column dashboard grid */
.sw-dashboard-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
/* Chart containers */
.sw-chart-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 12px;
}
.sw-chart-title {
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
}
.sw-chart-wrap {
position: relative;
height: 180px;
}
.sw-chart-wrap canvas {
width: 100% !important;
height: 100% !important;
}
/* Flare probability table */
.sw-prob-table {
width: 100%;
border-collapse: collapse;
font-size: 11px;
font-family: var(--font-mono, 'Roboto Condensed', monospace);
}
.sw-prob-table th {
font-size: 10px;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
text-align: left;
padding: 4px 6px;
border-bottom: 1px solid var(--border-color);
}
.sw-prob-table td {
padding: 4px 6px;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-color);
}
/* Solar image gallery */
.sw-image-panel {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 12px;
}
.sw-image-tabs {
display: flex;
gap: 4px;
margin-bottom: 8px;
}
.sw-image-tab {
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-dim);
padding: 4px 10px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
cursor: pointer;
font-family: var(--font-mono, 'Roboto Condensed', monospace);
transition: all 0.2s;
}
.sw-image-tab:hover {
border-color: var(--text-secondary);
color: var(--text-secondary);
}
.sw-image-tab.active {
border-color: var(--accent-cyan);
color: var(--accent-cyan);
background: var(--accent-cyan)10;
}
.sw-image-frame {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
background: var(--bg-primary);
border-radius: 4px;
overflow: hidden;
}
.sw-image-frame img {
max-width: 100%;
max-height: 400px;
object-fit: contain;
border-radius: 4px;
}
/* D-RAP frequency selector */
.sw-drap-freqs {
display: flex;
gap: 4px;
margin-bottom: 8px;
flex-wrap: wrap;
}
.sw-drap-freq-btn {
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-dim);
padding: 3px 8px;
border-radius: 3px;
font-size: 10px;
cursor: pointer;
font-family: var(--font-mono, 'Roboto Condensed', monospace);
transition: all 0.2s;
}
.sw-drap-freq-btn:hover {
border-color: var(--text-secondary);
color: var(--text-secondary);
}
.sw-drap-freq-btn.active {
border-color: var(--accent-cyan);
color: var(--accent-cyan);
}
/* Alerts list */
.sw-alerts-panel {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 12px;
max-height: 300px;
overflow-y: auto;
}
.sw-alert-item {
padding: 8px;
border-bottom: 1px solid var(--border-color);
font-size: 11px;
font-family: var(--font-mono, 'Roboto Condensed', monospace);
}
.sw-alert-item:last-child {
border-bottom: none;
}
.sw-alert-type {
font-weight: 600;
color: var(--accent-cyan);
font-size: 10px;
text-transform: uppercase;
margin-bottom: 2px;
}
.sw-alert-time {
font-size: 10px;
color: var(--text-dim);
margin-bottom: 4px;
}
.sw-alert-msg {
color: var(--text-secondary);
line-height: 1.4;
white-space: pre-wrap;
font-size: 10px;
}
/* Active regions table */
.sw-regions-panel {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 12px;
max-height: 300px;
overflow-y: auto;
}
.sw-regions-table {
width: 100%;
border-collapse: collapse;
font-size: 10px;
font-family: var(--font-mono, 'Roboto Condensed', monospace);
}
.sw-regions-table th {
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
text-align: left;
padding: 4px 6px;
border-bottom: 1px solid var(--border-color);
position: sticky;
top: 0;
background: var(--bg-card);
}
.sw-regions-table td {
padding: 4px 6px;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-color);
}
/* Empty / loading states */
.sw-empty {
text-align: center;
padding: 20px;
color: var(--text-dim);
font-size: 11px;
font-family: var(--font-mono, 'Roboto Condensed', monospace);
}
.sw-loading {
text-align: center;
padding: 20px;
color: var(--text-dim);
font-size: 11px;
}
.sw-loading::after {
content: '';
display: inline-block;
width: 12px;
height: 12px;
border: 2px solid var(--border-color);
border-top-color: var(--accent-cyan);
border-radius: 50%;
animation: sw-spin 0.8s linear infinite;
margin-left: 8px;
vertical-align: middle;
}
@keyframes sw-spin {
to { transform: rotate(360deg); }
}
/* Full-width card */
.sw-full-width {
grid-column: 1 / -1;
}
/* Responsive */
@media (max-width: 768px) {
.sw-dashboard-grid {
grid-template-columns: 1fr;
}
.sw-scales-row {
grid-template-columns: 1fr;
}
.sw-header-strip {
gap: 0;
}
.sw-header-stat {
padding: 4px 8px;
min-width: 50px;
}
.sw-header-value {
font-size: 14px;
}
}
+3 -1
View File
@@ -340,7 +340,9 @@
MODE VISIBILITY - Ensure sidebar shows when active MODE VISIBILITY - Ensure sidebar shows when active
============================================ */ ============================================ */
#spystationsMode.active { #spystationsMode.active {
display: block !important; display: flex !important;
flex-direction: column;
gap: 10px;
} }
/* ============================================ /* ============================================
+3 -1
View File
@@ -7,7 +7,9 @@
MODE VISIBILITY MODE VISIBILITY
============================================ */ ============================================ */
#sstvGeneralMode.active { #sstvGeneralMode.active {
display: block !important; display: flex !important;
flex-direction: column;
gap: 10px;
} }
/* ============================================ /* ============================================
+3 -1
View File
@@ -7,7 +7,9 @@
MODE VISIBILITY MODE VISIBILITY
============================================ */ ============================================ */
#sstvMode.active { #sstvMode.active {
display: block !important; display: flex !important;
flex-direction: column;
gap: 10px;
} }
/* ============================================ /* ============================================
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
/* VDL2 Mode Styles */
/* VDL2 Status Indicator */
.vdl2-status-dot.listening {
background: var(--accent-cyan) !important;
animation: vdl2-pulse 1.5s ease-in-out infinite;
}
.vdl2-status-dot.receiving {
background: var(--accent-green) !important;
}
.vdl2-status-dot.error {
background: var(--accent-red) !important;
}
@keyframes vdl2-pulse {
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(74, 158, 255, 0.7); }
50% { opacity: 0.6; box-shadow: 0 0 6px 3px rgba(74, 158, 255, 0.3); }
}
/* VDL2 message animation */
.vdl2-msg {
padding: 6px 8px;
border-bottom: 1px solid var(--border-color);
animation: vdl2FadeIn 0.3s ease;
}
.vdl2-msg:hover {
background: rgba(74, 158, 255, 0.05);
}
@keyframes vdl2FadeIn {
from { opacity: 0; transform: translateY(-3px); }
to { opacity: 1; transform: translateY(0); }
}
+111 -26
View File
@@ -49,7 +49,7 @@
.wxsat-strip-status-text { .wxsat-strip-status-text {
font-size: 12px; font-size: 12px;
color: var(--text-secondary, #999); color: var(--text-secondary, #999);
font-family: 'JetBrains Mono', monospace; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
} }
.wxsat-strip-btn { .wxsat-strip-btn {
@@ -59,7 +59,7 @@
background: transparent; background: transparent;
color: var(--text-primary, #e0e0e0); color: var(--text-primary, #e0e0e0);
font-size: 11px; font-size: 11px;
font-family: 'JetBrains Mono', monospace; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
} }
@@ -92,7 +92,7 @@
.wxsat-strip-value { .wxsat-strip-value {
font-size: 13px; font-size: 13px;
font-family: 'JetBrains Mono', monospace; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
color: var(--text-primary, #e0e0e0); color: var(--text-primary, #e0e0e0);
} }
@@ -114,7 +114,7 @@
gap: 6px; gap: 6px;
cursor: pointer; cursor: pointer;
font-size: 10px; font-size: 10px;
font-family: 'JetBrains Mono', monospace; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
color: var(--text-dim, #666); color: var(--text-dim, #666);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
@@ -146,7 +146,7 @@
border-radius: 3px; border-radius: 3px;
color: var(--text-primary, #e0e0e0); color: var(--text-primary, #e0e0e0);
font-size: 11px; font-size: 11px;
font-family: 'JetBrains Mono', monospace; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
} }
.wxsat-loc-input:focus { .wxsat-loc-input:focus {
@@ -225,7 +225,7 @@
.wxsat-cd-value { .wxsat-cd-value {
font-size: 16px; font-size: 16px;
font-weight: 700; font-weight: 700;
font-family: 'JetBrains Mono', monospace; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
color: var(--text-primary, #e0e0e0); color: var(--text-primary, #e0e0e0);
line-height: 1; line-height: 1;
} }
@@ -248,13 +248,13 @@
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
color: var(--accent-cyan, #00d4ff); color: var(--accent-cyan, #00d4ff);
font-family: 'JetBrains Mono', monospace; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
} }
.wxsat-countdown-detail { .wxsat-countdown-detail {
font-size: 10px; font-size: 10px;
color: var(--text-dim, #666); color: var(--text-dim, #666);
font-family: 'JetBrains Mono', monospace; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
} }
/* ===== Timeline ===== */ /* ===== Timeline ===== */
@@ -314,7 +314,7 @@
justify-content: space-between; justify-content: space-between;
font-size: 8px; font-size: 8px;
color: var(--text-dim, #666); color: var(--text-dim, #666);
font-family: 'JetBrains Mono', monospace; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
} }
/* ===== Pass Predictions Panel ===== */ /* ===== Pass Predictions Panel ===== */
@@ -349,7 +349,7 @@
.wxsat-passes-count { .wxsat-passes-count {
font-size: 11px; font-size: 11px;
color: var(--accent-cyan, #00d4ff); color: var(--accent-cyan, #00d4ff);
font-family: 'JetBrains Mono', monospace; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
} }
.wxsat-passes-list { .wxsat-passes-list {
@@ -387,7 +387,7 @@
background: rgba(255, 187, 0, 0.15); background: rgba(255, 187, 0, 0.15);
color: #ffbb00; color: #ffbb00;
margin-left: 6px; margin-left: 6px;
font-family: 'JetBrains Mono', monospace; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
@@ -409,7 +409,7 @@
font-size: 10px; font-size: 10px;
padding: 2px 6px; padding: 2px 6px;
border-radius: 3px; border-radius: 3px;
font-family: 'JetBrains Mono', monospace; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
} }
.wxsat-pass-mode.apt { .wxsat-pass-mode.apt {
@@ -428,7 +428,7 @@
gap: 4px; gap: 4px;
font-size: 11px; font-size: 11px;
color: var(--text-dim, #666); color: var(--text-dim, #666);
font-family: 'JetBrains Mono', monospace; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
} }
.wxsat-pass-detail-label { .wxsat-pass-detail-label {
@@ -499,7 +499,7 @@
.wxsat-panel-subtitle { .wxsat-panel-subtitle {
font-size: 10px; font-size: 10px;
color: var(--accent-cyan, #00d4ff); color: var(--accent-cyan, #00d4ff);
font-family: 'JetBrains Mono', monospace; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
} }
#wxsatPolarCanvas { #wxsatPolarCanvas {
@@ -509,10 +509,95 @@
max-height: 300px; max-height: 300px;
} }
.wxsat-ground-map { .wxsat-ground-map {
height: 200px; position: relative;
background: var(--bg-primary, #0d1117); height: 200px;
} overflow: hidden;
background: linear-gradient(180deg, #061329 0%, #050d1a 54%, #061325 100%);
}
.wxsat-ground-map .leaflet-container {
width: 100%;
height: 100%;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
}
.leaflet-container.map-theme-cyber .leaflet-overlay-pane path.wxsat-pass-track {
filter: drop-shadow(0 0 5px rgba(91, 240, 255, 0.35));
}
.leaflet-container.map-theme-cyber .leaflet-overlay-pane path.wxsat-pass-track.lrpt {
filter: drop-shadow(0 0 6px rgba(0, 255, 190, 0.35));
}
.wxsat-crosshair-icon {
background: transparent;
border: none;
}
.wxsat-crosshair-marker {
position: relative;
width: 30px;
height: 30px;
}
.wxsat-crosshair-h,
.wxsat-crosshair-v,
.wxsat-crosshair-ring,
.wxsat-crosshair-dot {
position: absolute;
display: block;
}
.wxsat-crosshair-h {
top: 50%;
left: 2px;
right: 2px;
height: 1px;
background: rgba(255, 93, 93, 0.95);
transform: translateY(-50%);
}
.wxsat-crosshair-v {
left: 50%;
top: 2px;
bottom: 2px;
width: 1px;
background: rgba(255, 93, 93, 0.95);
transform: translateX(-50%);
}
.wxsat-crosshair-ring {
inset: 6px;
border: 1.5px solid rgba(255, 93, 93, 0.95);
border-radius: 50%;
box-shadow: 0 0 10px rgba(255, 93, 93, 0.55);
}
.wxsat-crosshair-dot {
width: 5px;
height: 5px;
left: 50%;
top: 50%;
border-radius: 50%;
background: #ffa0a0;
box-shadow: 0 0 6px rgba(255, 100, 100, 0.65);
transform: translate(-50%, -50%);
}
.wxsat-map-tooltip {
background: rgba(5, 15, 32, 0.92);
border: 1px solid rgba(102, 229, 255, 0.65);
border-radius: 4px;
color: #8fe8ff;
box-shadow: 0 0 12px rgba(0, 210, 255, 0.24);
font-size: 10px;
letter-spacing: 0.25px;
}
.wxsat-map-tooltip.leaflet-tooltip-top:before {
border-top-color: rgba(102, 229, 255, 0.65);
}
/* ===== Image Gallery Panel ===== */ /* ===== Image Gallery Panel ===== */
.wxsat-gallery-panel { .wxsat-gallery-panel {
@@ -547,7 +632,7 @@
.wxsat-gallery-count { .wxsat-gallery-count {
font-size: 11px; font-size: 11px;
color: var(--accent-cyan, #00d4ff); color: var(--accent-cyan, #00d4ff);
font-family: 'JetBrains Mono', monospace; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
} }
.wxsat-gallery-grid { .wxsat-gallery-grid {
@@ -636,7 +721,7 @@
.wxsat-image-product { .wxsat-image-product {
font-size: 10px; font-size: 10px;
color: var(--accent-cyan, #00d4ff); color: var(--accent-cyan, #00d4ff);
font-family: 'JetBrains Mono', monospace; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
} }
.wxsat-image-timestamp { .wxsat-image-timestamp {
@@ -649,7 +734,7 @@
.wxsat-date-header { .wxsat-date-header {
grid-column: 1 / -1; grid-column: 1 / -1;
font-size: 11px; font-size: 11px;
font-family: 'JetBrains Mono', monospace; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
color: var(--text-dim, #666); color: var(--text-dim, #666);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
@@ -708,7 +793,7 @@
.wxsat-capture-message { .wxsat-capture-message {
font-size: 11px; font-size: 11px;
color: var(--text-secondary, #999); color: var(--text-secondary, #999);
font-family: 'JetBrains Mono', monospace; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -719,7 +804,7 @@
.wxsat-capture-elapsed { .wxsat-capture-elapsed {
font-size: 11px; font-size: 11px;
color: var(--text-dim, #666); color: var(--text-dim, #666);
font-family: 'JetBrains Mono', monospace; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -785,7 +870,7 @@
border-radius: 4px; border-radius: 4px;
color: var(--text-secondary, #999); color: var(--text-secondary, #999);
font-size: 12px; font-size: 12px;
font-family: 'JetBrains Mono', monospace; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
text-align: center; text-align: center;
} }
@@ -941,7 +1026,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
font-family: 'JetBrains Mono', monospace; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
} }
.wxsat-phase-step { .wxsat-phase-step {
@@ -1012,7 +1097,7 @@
max-height: 160px; max-height: 160px;
padding: 6px 12px; padding: 6px 12px;
background: var(--bg-primary, #0d1117); background: var(--bg-primary, #0d1117);
font-family: 'JetBrains Mono', monospace; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 10px; font-size: 10px;
line-height: 1.6; line-height: 1.6;
} }
+58 -3
View File
@@ -114,7 +114,7 @@
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
width: min(320px, 85vw); width: min(360px, 100vw);
height: 100dvh; height: 100dvh;
height: 100vh; /* Fallback */ height: 100vh; /* Fallback */
background: var(--bg-secondary, #0f1218); background: var(--bg-secondary, #0f1218);
@@ -381,6 +381,33 @@
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
.sidebar {
padding: 10px;
gap: 10px;
}
.output-panel {
min-height: 58vh;
}
.output-header {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.header-controls {
width: 100%;
gap: 8px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
padding-bottom: 2px;
}
.header-controls .stats {
min-width: max-content;
}
/* Container should not clip content */ /* Container should not clip content */
.container { .container {
overflow: visible; overflow: visible;
@@ -401,7 +428,7 @@
/* Visual panels should be scrollable, not clipped */ /* Visual panels should be scrollable, not clipped */
.wifi-visuals, .wifi-visuals,
.bt-visuals { .bt-visuals-column {
max-height: none !important; max-height: none !important;
overflow: visible !important; overflow: visible !important;
margin-bottom: 15px; margin-bottom: 15px;
@@ -417,7 +444,7 @@
/* Visual panels should stack in single column on mobile when visible */ /* Visual panels should stack in single column on mobile when visible */
.wifi-visuals, .wifi-visuals,
.bt-visuals { .bt-visuals-column {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
@@ -438,6 +465,34 @@
.wifi-visual-panel { .wifi-visual-panel {
grid-column: auto !important; grid-column: auto !important;
} }
.bt-main-area {
flex-direction: column !important;
min-height: auto !important;
}
.bt-side-panels {
width: 100% !important;
flex-direction: column !important;
}
.bt-detail-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
}
.bt-row-secondary {
padding-left: 0 !important;
white-space: normal !important;
}
.bt-row-actions {
padding-left: 0 !important;
justify-content: flex-start !important;
}
.bt-list-summary {
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
}
} }
/* ============== MOBILE MAP FIXES ============== */ /* ============== MOBILE MAP FIXES ============== */
+2 -2
View File
@@ -5,8 +5,8 @@
} }
:root { :root {
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace; --font-sans: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace; --font-mono: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
--bg-dark: #0b1118; --bg-dark: #0b1118;
--bg-panel: #101823; --bg-panel: #101823;
--bg-card: #151f2b; --bg-card: #151f2b;
+76 -4
View File
@@ -24,7 +24,7 @@
background: var(--bg-dark, #0a0a0f); background: var(--bg-dark, #0a0a0f);
border: 1px solid var(--border-color, #1a1a2e); border: 1px solid var(--border-color, #1a1a2e);
border-radius: 8px; border-radius: 8px;
max-width: 600px; max-width: 900px;
width: 100%; width: 100%;
max-height: calc(100vh - 80px); max-height: calc(100vh - 80px);
display: flex; display: flex;
@@ -74,22 +74,28 @@
/* Settings Tabs */ /* Settings Tabs */
.settings-tabs { .settings-tabs {
display: flex; display: grid;
grid-template-columns: repeat(8, minmax(0, 1fr));
border-bottom: 1px solid var(--border-color, #1a1a2e); border-bottom: 1px solid var(--border-color, #1a1a2e);
padding: 0 20px; padding: 0 20px;
gap: 4px; gap: 0;
} }
.settings-tab { .settings-tab {
background: none; background: none;
border: none; border: none;
padding: 12px 16px; padding: 12px 10px;
color: var(--text-muted, #666); color: var(--text-muted, #666);
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
position: relative; position: relative;
transition: color 0.2s; transition: color 0.2s;
min-width: 0;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.settings-tab:hover { .settings-tab:hover {
@@ -473,7 +479,61 @@
filter: sepia(0.35) hue-rotate(185deg) saturate(1.75) brightness(1.06) contrast(1.05); filter: sepia(0.35) hue-rotate(185deg) saturate(1.75) brightness(1.06) contrast(1.05);
} }
/* Global Leaflet map theme: cyber overlay */
.leaflet-container.map-theme-cyber {
position: relative;
background: #020813;
isolation: isolate;
}
.leaflet-container.map-theme-cyber .leaflet-tile-pane {
filter: sepia(0.74) hue-rotate(176deg) saturate(1.72) brightness(1.05) contrast(1.08);
opacity: 1;
}
/* Hard global fallback: enforce cyber tint on all Leaflet tile images */
html.map-cyber-enabled .leaflet-container .leaflet-tile {
filter: sepia(0.74) hue-rotate(176deg) saturate(1.72) brightness(1.05) contrast(1.08) !important;
}
/* Hard global fallback: cyber glow + grid overlay */
html.map-cyber-enabled .leaflet-container {
position: relative;
isolation: isolate;
}
html.map-cyber-enabled .leaflet-container::before {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
z-index: 620;
background:
radial-gradient(95% 78% at 50% 44%, rgba(18, 170, 255, 0.17), rgba(18, 170, 255, 0) 64%),
linear-gradient(180deg, rgba(24, 118, 255, 0.045), rgba(24, 118, 255, 0));
}
html.map-cyber-enabled .leaflet-container::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
z-index: 621;
opacity: 0.42;
mix-blend-mode: screen;
background-image:
linear-gradient(rgba(78, 188, 255, 0.14) 1px, transparent 1px),
linear-gradient(90deg, rgba(78, 188, 255, 0.14) 1px, transparent 1px);
background-size: 52px 52px, 52px 52px;
}
/* Responsive */ /* Responsive */
@media (max-width: 960px) {
.settings-tabs {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@media (max-width: 640px) { @media (max-width: 640px) {
.settings-modal.active { .settings-modal.active {
padding: 20px 10px; padding: 20px 10px;
@@ -483,6 +543,18 @@
max-width: 100%; max-width: 100%;
} }
.settings-tabs {
padding: 0 10px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.settings-tab {
padding: 10px 6px;
font-size: 11px;
white-space: normal;
line-height: 1.2;
}
.settings-row { .settings-row {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
+144 -25
View File
@@ -36,6 +36,7 @@ const ProximityRadar = (function() {
let isHovered = false; let isHovered = false;
let renderPending = false; let renderPending = false;
let renderTimer = null; let renderTimer = null;
let interactionLockUntil = 0; // timestamp: suppress renders briefly after click
/** /**
* Initialize the radar component * Initialize the radar component
@@ -119,6 +120,36 @@ const ProximityRadar = (function() {
svg = container.querySelector('svg'); svg = container.querySelector('svg');
// Event delegation on the devices group (survives innerHTML rebuilds)
const devicesGroup = svg.querySelector('.radar-devices');
devicesGroup.addEventListener('click', (e) => {
const deviceEl = e.target.closest('.radar-device');
if (!deviceEl) return;
const deviceKey = deviceEl.getAttribute('data-device-key');
if (onDeviceClick && deviceKey) {
// Lock out re-renders briefly so the DOM stays stable after click
interactionLockUntil = Date.now() + 500;
onDeviceClick(deviceKey);
}
});
devicesGroup.addEventListener('mouseenter', (e) => {
if (e.target.closest('.radar-device')) {
isHovered = true;
}
}, true); // capture phase so we catch enter on child elements
devicesGroup.addEventListener('mouseleave', (e) => {
if (e.target.closest('.radar-device')) {
isHovered = false;
if (renderPending) {
renderPending = false;
renderDevices();
}
}
}, true);
// Add sweep animation // Add sweep animation
animateSweep(); animateSweep();
} }
@@ -165,8 +196,8 @@ const ProximityRadar = (function() {
devices.set(device.device_key, device); devices.set(device.device_key, device);
}); });
// Defer render while user is hovering to prevent DOM rebuild flicker // Defer render while user is hovering or interacting to prevent DOM rebuild flicker
if (isHovered) { if (isHovered || Date.now() < interactionLockUntil) {
renderPending = true; renderPending = true;
return; return;
} }
@@ -229,7 +260,7 @@ const ProximityRadar = (function() {
style="cursor: pointer;"> style="cursor: pointer;">
<!-- Invisible hit area to prevent hover flicker --> <!-- Invisible hit area to prevent hover flicker -->
<circle class="radar-device-hitarea" r="${hitAreaSize}" fill="transparent" /> <circle class="radar-device-hitarea" r="${hitAreaSize}" fill="transparent" />
${isSelected ? `<circle r="${dotSize + 8}" fill="none" stroke="#00d4ff" stroke-width="2" stroke-opacity="0.8"> ${isSelected ? `<circle class="radar-select-ring" r="${dotSize + 8}" fill="none" stroke="#00d4ff" stroke-width="2" stroke-opacity="0.8">
<animate attributeName="r" values="${dotSize + 6};${dotSize + 10};${dotSize + 6}" dur="1.5s" repeatCount="indefinite"/> <animate attributeName="r" values="${dotSize + 6};${dotSize + 10};${dotSize + 6}" dur="1.5s" repeatCount="indefinite"/>
<animate attributeName="stroke-opacity" values="0.8;0.4;0.8" dur="1.5s" repeatCount="indefinite"/> <animate attributeName="stroke-opacity" values="0.8;0.4;0.8" dur="1.5s" repeatCount="indefinite"/>
</circle>` : ''} </circle>` : ''}
@@ -244,24 +275,6 @@ const ProximityRadar = (function() {
}).join(''); }).join('');
devicesGroup.innerHTML = dots; devicesGroup.innerHTML = dots;
// Attach event handlers
devicesGroup.querySelectorAll('.radar-device').forEach(el => {
el.addEventListener('click', (e) => {
const deviceKey = el.getAttribute('data-device-key');
if (onDeviceClick && deviceKey) {
onDeviceClick(deviceKey);
}
});
el.addEventListener('mouseenter', () => { isHovered = true; });
el.addEventListener('mouseleave', () => {
isHovered = false;
if (renderPending) {
renderPending = false;
renderDevices();
}
});
});
} }
/** /**
@@ -345,19 +358,125 @@ const ProximityRadar = (function() {
} }
/** /**
* Highlight a specific device on the radar * Highlight a specific device on the radar (in-place update, no full re-render)
*/ */
function highlightDevice(deviceKey) { function highlightDevice(deviceKey) {
const prev = selectedDeviceKey;
selectedDeviceKey = deviceKey; selectedDeviceKey = deviceKey;
renderDevices();
if (!svg) { return; }
const devicesGroup = svg.querySelector('.radar-devices');
if (!devicesGroup) { return; }
// Remove highlight from previously selected node
if (prev && prev !== deviceKey) {
const oldEl = devicesGroup.querySelector(`.radar-device[data-device-key="${CSS.escape(prev)}"]`);
if (oldEl) {
oldEl.classList.remove('selected');
// Remove animated selection ring
const ring = oldEl.querySelector('.radar-select-ring');
if (ring) ring.remove();
// Restore dot opacity
const dot = oldEl.querySelector('circle:not(.radar-device-hitarea):not(.radar-select-ring)');
if (dot && dot.getAttribute('fill') !== 'none' && dot.getAttribute('fill') !== 'transparent') {
const device = devices.get(prev);
const confidence = device ? (device.distance_confidence || 0.5) : 0.5;
dot.setAttribute('fill-opacity', 0.4 + confidence * 0.5);
dot.setAttribute('stroke', dot.getAttribute('fill'));
dot.setAttribute('stroke-width', '1');
}
}
}
// Add highlight to newly selected node
if (deviceKey) {
const newEl = devicesGroup.querySelector(`.radar-device[data-device-key="${CSS.escape(deviceKey)}"]`);
if (newEl) {
applySelectionToElement(newEl, deviceKey);
} else {
// Node not in DOM yet; full render needed on next cycle
renderDevices();
}
}
} }
/** /**
* Clear device highlighting * Apply selection styling to a radar device element in-place
*/
function applySelectionToElement(el, deviceKey) {
el.classList.add('selected');
const device = devices.get(deviceKey);
const confidence = device ? (device.distance_confidence || 0.5) : 0.5;
const dotSize = CONFIG.dotMinSize + (CONFIG.dotMaxSize - CONFIG.dotMinSize) * confidence;
// Update dot styling
const dot = el.querySelector('circle:not(.radar-device-hitarea):not(.radar-select-ring)');
if (dot && dot.getAttribute('fill') !== 'none' && dot.getAttribute('fill') !== 'transparent') {
dot.setAttribute('fill-opacity', '1');
dot.setAttribute('stroke', '#00d4ff');
dot.setAttribute('stroke-width', '2');
}
// Add animated selection ring if not already present
if (!el.querySelector('.radar-select-ring')) {
const ns = 'http://www.w3.org/2000/svg';
const ring = document.createElementNS(ns, 'circle');
ring.classList.add('radar-select-ring');
ring.setAttribute('r', dotSize + 8);
ring.setAttribute('fill', 'none');
ring.setAttribute('stroke', '#00d4ff');
ring.setAttribute('stroke-width', '2');
ring.setAttribute('stroke-opacity', '0.8');
const animR = document.createElementNS(ns, 'animate');
animR.setAttribute('attributeName', 'r');
animR.setAttribute('values', `${dotSize + 6};${dotSize + 10};${dotSize + 6}`);
animR.setAttribute('dur', '1.5s');
animR.setAttribute('repeatCount', 'indefinite');
ring.appendChild(animR);
const animO = document.createElementNS(ns, 'animate');
animO.setAttribute('attributeName', 'stroke-opacity');
animO.setAttribute('values', '0.8;0.4;0.8');
animO.setAttribute('dur', '1.5s');
animO.setAttribute('repeatCount', 'indefinite');
ring.appendChild(animO);
// Insert after the hit area
const hitArea = el.querySelector('.radar-device-hitarea');
if (hitArea && hitArea.nextSibling) {
el.insertBefore(ring, hitArea.nextSibling);
} else {
el.insertBefore(ring, el.firstChild);
}
}
}
/**
* Clear device highlighting (in-place update, no full re-render)
*/ */
function clearHighlight() { function clearHighlight() {
const prev = selectedDeviceKey;
selectedDeviceKey = null; selectedDeviceKey = null;
renderDevices();
if (!svg || !prev) { return; }
const devicesGroup = svg.querySelector('.radar-devices');
if (!devicesGroup) { return; }
const oldEl = devicesGroup.querySelector(`.radar-device[data-device-key="${CSS.escape(prev)}"]`);
if (oldEl) {
oldEl.classList.remove('selected');
const ring = oldEl.querySelector('.radar-select-ring');
if (ring) ring.remove();
const dot = oldEl.querySelector('circle:not(.radar-device-hitarea):not(.radar-select-ring)');
if (dot && dot.getAttribute('fill') !== 'none' && dot.getAttribute('fill') !== 'transparent') {
const device = devices.get(prev);
const confidence = device ? (device.distance_confidence || 0.5) : 0.5;
dot.setAttribute('fill-opacity', 0.4 + confidence * 0.5);
dot.setAttribute('stroke', dot.getAttribute('fill'));
dot.setAttribute('stroke-width', '1');
}
}
} }
/** /**
+7 -1
View File
@@ -302,7 +302,13 @@ const SignalCards = (function() {
*/ */
function formatRelativeTime(timestamp) { function formatRelativeTime(timestamp) {
if (!timestamp) return ''; if (!timestamp) return '';
const date = new Date(timestamp); let date = new Date(timestamp);
// Handle time-only strings like "HH:MM:SS" (from pager/sensor backends)
if (isNaN(date.getTime()) && /^\d{1,2}:\d{2}(:\d{2})?$/.test(timestamp)) {
const today = new Date();
date = new Date(today.toDateString() + ' ' + timestamp);
}
if (isNaN(date.getTime())) return timestamp;
const now = new Date(); const now = new Date();
const diff = Math.floor((now - date) / 1000); const diff = Math.floor((now - date) / 1000);
+11 -10
View File
@@ -423,7 +423,7 @@ async function syncAgentModeStates(agentId) {
}); });
// Also check modes that might need to be marked as stopped // Also check modes that might need to be marked as stopped
const allModes = ['sensor', 'pager', 'adsb', 'wifi', 'bluetooth', 'ais', 'dsc', 'acars', 'aprs', 'rtlamr', 'tscm', 'satellite', 'listening_post']; const allModes = ['sensor', 'pager', 'adsb', 'wifi', 'bluetooth', 'ais', 'dsc', 'acars', 'vdl2', 'aprs', 'rtlamr', 'tscm', 'satellite', 'listening_post'];
allModes.forEach(mode => { allModes.forEach(mode => {
if (!agentRunningModes.includes(mode)) { if (!agentRunningModes.includes(mode)) {
syncModeUI(mode, false, agentId); syncModeUI(mode, false, agentId);
@@ -485,7 +485,7 @@ async function syncLocalModeStates() {
*/ */
function showAgentModeWarnings(runningModes, modesDetail = {}) { function showAgentModeWarnings(runningModes, modesDetail = {}) {
// SDR modes that can't run simultaneously on same device // SDR modes that can't run simultaneously on same device
const sdrModes = ['sensor', 'pager', 'adsb', 'ais', 'acars', 'aprs', 'rtlamr', 'listening_post', 'tscm', 'dsc']; const sdrModes = ['sensor', 'pager', 'adsb', 'ais', 'acars', 'vdl2', 'aprs', 'rtlamr', 'listening_post', 'tscm', 'dsc'];
const runningSdrModes = runningModes.filter(m => sdrModes.includes(m)); const runningSdrModes = runningModes.filter(m => sdrModes.includes(m));
let warning = document.getElementById('agentModeWarning'); let warning = document.getElementById('agentModeWarning');
@@ -621,7 +621,7 @@ function checkAgentModeConflict(modeToStart, deviceToUse = null) {
return false; return false;
} }
const sdrModes = ['sensor', 'pager', 'adsb', 'ais', 'acars', 'aprs', 'rtlamr', 'listening_post', 'tscm', 'dsc']; const sdrModes = ['sensor', 'pager', 'adsb', 'ais', 'acars', 'vdl2', 'aprs', 'rtlamr', 'listening_post', 'tscm', 'dsc'];
// If we're trying to start an SDR mode // If we're trying to start an SDR mode
if (sdrModes.includes(modeToStart)) { if (sdrModes.includes(modeToStart)) {
@@ -704,6 +704,7 @@ function syncModeUI(mode, isRunning, agentId = null) {
'wifi': 'setWiFiRunning', 'wifi': 'setWiFiRunning',
'bluetooth': 'setBluetoothRunning', 'bluetooth': 'setBluetoothRunning',
'acars': 'setAcarsRunning', 'acars': 'setAcarsRunning',
'vdl2': 'setVdl2Running',
'listening_post': 'setListeningPostRunning' 'listening_post': 'setListeningPostRunning'
}; };
@@ -865,12 +866,12 @@ function connectAgentStream(mode, onMessage) {
} }
let streamUrl; let streamUrl;
if (currentAgent === 'local') { if (currentAgent === 'local') {
streamUrl = `/${mode}/stream`; streamUrl = `/${mode}/stream`;
} else { } else {
// For remote agents, proxy SSE through controller // For remote agents, proxy SSE through controller
streamUrl = `/controller/agents/${currentAgent}/${mode}/stream`; streamUrl = `/controller/agents/${currentAgent}/${mode}/stream`;
} }
agentEventSource = new EventSource(streamUrl); agentEventSource = new EventSource(streamUrl);
@@ -878,7 +879,7 @@ function connectAgentStream(mode, onMessage) {
try { try {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
onMessage(data); onMessage(data);
} catch (e) { } catch (e) {
console.error('Error parsing SSE message:', e); console.error('Error parsing SSE message:', e);
} }
+247 -37
View File
@@ -1,11 +1,12 @@
const AlertCenter = (function() { const AlertCenter = (function() {
'use strict'; 'use strict';
const TRACKER_RULE_NAME = 'Tracker Detected';
let alerts = []; let alerts = [];
let rules = []; let rules = [];
let eventSource = null; let eventSource = null;
let reconnectTimer = null;
const TRACKER_RULE_NAME = 'Tracker Detected';
function init() { function init() {
loadRules(); loadRules();
@@ -17,6 +18,7 @@ const AlertCenter = (function() {
if (eventSource) { if (eventSource) {
eventSource.close(); eventSource.close();
} }
eventSource = new EventSource('/alerts/stream'); eventSource = new EventSource('/alerts/stream');
eventSource.onmessage = function(e) { eventSource.onmessage = function(e) {
try { try {
@@ -27,21 +29,26 @@ const AlertCenter = (function() {
console.error('[Alerts] SSE parse error', err); console.error('[Alerts] SSE parse error', err);
} }
}; };
eventSource.onerror = function() { eventSource.onerror = function() {
console.warn('[Alerts] SSE connection error'); console.warn('[Alerts] SSE connection error');
if (reconnectTimer) clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(connect, 2500);
}; };
} }
function handleAlert(alert) { function handleAlert(alert) {
alerts.unshift(alert); alerts.unshift(alert);
alerts = alerts.slice(0, 50); alerts = alerts.slice(0, 60);
updateFeedUI(); updateFeedUI();
if (typeof showNotification === 'function') { const severity = String(alert.severity || '').toLowerCase();
const severity = (alert.severity || '').toLowerCase(); if (typeof showNotification === 'function' && ['high', 'critical'].includes(severity)) {
if (['high', 'critical'].includes(severity)) { showNotification(alert.title || 'Alert', alert.message || 'Alert triggered');
showNotification(alert.title || 'Alert', alert.message || 'Alert triggered'); }
}
if (typeof showAppToast === 'function' && ['high', 'critical'].includes(severity)) {
showAppToast(alert.title || 'Alert', alert.message || 'Alert triggered', 'warning');
} }
} }
@@ -56,7 +63,7 @@ const AlertCenter = (function() {
return; return;
} }
list.innerHTML = alerts.map(alert => { list.innerHTML = alerts.map((alert) => {
const title = escapeHtml(alert.title || 'Alert'); const title = escapeHtml(alert.title || 'Alert');
const message = escapeHtml(alert.message || ''); const message = escapeHtml(alert.message || '');
const severity = escapeHtml(alert.severity || 'medium'); const severity = escapeHtml(alert.severity || 'medium');
@@ -74,27 +81,218 @@ const AlertCenter = (function() {
}).join(''); }).join('');
} }
function renderRulesUI() {
const list = document.getElementById('alertsRulesList');
if (!list) return;
if (!rules.length) {
list.innerHTML = '<div class="settings-feed-empty">No rules yet</div>';
return;
}
list.innerHTML = rules.map((rule) => {
const enabled = Boolean(rule.enabled);
const mode = rule.mode || 'all';
const eventType = rule.event_type || 'any';
const severity = (rule.severity || 'medium').toUpperCase();
const match = formatMatch(rule.match);
const statusText = enabled ? 'ENABLED' : 'DISABLED';
return `
<div class="settings-feed-item" style="border-left: 2px solid ${enabled ? 'var(--accent-green)' : 'var(--text-dim)'};">
<div class="settings-feed-title" style="display:flex; gap:8px; align-items:center; justify-content:space-between;">
<span>${escapeHtml(rule.name || 'Rule')}</span>
<span style="color: var(--text-dim); font-size: 10px;">${statusText}</span>
</div>
<div class="settings-feed-meta">Mode: ${escapeHtml(mode)} | Event: ${escapeHtml(eventType)} | Severity: ${escapeHtml(severity)}</div>
<div class="settings-feed-meta">Match: ${escapeHtml(match)}</div>
<div style="display:flex; gap:8px; margin-top: 8px;">
<button class="preset-btn" style="font-size: 10px; padding: 3px 8px;" onclick="AlertCenter.editRule(${Number(rule.id)})">Edit</button>
<button class="preset-btn" style="font-size: 10px; padding: 3px 8px;" onclick="AlertCenter.toggleRule(${Number(rule.id)}, ${enabled ? 'false' : 'true'})">${enabled ? 'Disable' : 'Enable'}</button>
<button class="preset-btn" style="font-size: 10px; padding: 3px 8px; border-color: var(--accent-red); color: var(--accent-red);" onclick="AlertCenter.deleteRule(${Number(rule.id)})">Delete</button>
</div>
</div>
`;
}).join('');
}
function formatMatch(match) {
if (!match || typeof match !== 'object' || !Object.keys(match).length) {
return 'none';
}
const [k, v] = Object.entries(match)[0];
return `${k}=${v}`;
}
function loadFeed() { function loadFeed() {
fetch('/alerts/events?limit=20') fetch('/alerts/events?limit=30')
.then(r => r.json()) .then((r) => r.json())
.then(data => { .then((data) => {
if (data.status === 'success') { if (data.status === 'success') {
alerts = data.events || []; alerts = data.events || [];
updateFeedUI(); updateFeedUI();
} }
}) })
.catch(err => console.error('[Alerts] Load feed failed', err)); .catch((err) => console.error('[Alerts] Load feed failed', err));
} }
function loadRules() { function loadRules() {
fetch('/alerts/rules?all=1') return fetch('/alerts/rules?all=1')
.then(r => r.json()) .then((r) => r.json())
.then(data => { .then((data) => {
if (data.status === 'success') { if (data.status === 'success') {
rules = data.rules || []; rules = data.rules || [];
renderRulesUI();
} }
}) })
.catch(err => console.error('[Alerts] Load rules failed', err)); .catch((err) => {
console.error('[Alerts] Load rules failed', err);
if (typeof reportActionableError === 'function') {
reportActionableError('Alert Rules', err, { onRetry: loadRules });
}
});
}
function saveRule() {
const editingId = getEditingRuleId();
const payload = buildRulePayload();
if (!payload.name) {
payload.name = payload.mode ? `${payload.mode} alert` : 'Alert Rule';
}
const url = editingId ? `/alerts/rules/${editingId}` : '/alerts/rules';
const method = editingId ? 'PATCH' : 'POST';
fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
.then((r) => r.json())
.then((data) => {
if (data.status !== 'success') {
throw new Error(data.message || 'Failed to save rule');
}
clearRuleForm();
return loadRules();
})
.then(() => {
if (typeof showAppToast === 'function') {
showAppToast('Alerts', editingId ? 'Rule updated' : 'Rule created', 'info');
}
})
.catch((err) => {
if (typeof reportActionableError === 'function') {
reportActionableError('Save Alert Rule', err);
}
});
}
function buildRulePayload() {
const nameEl = document.getElementById('alertsRuleName');
const modeEl = document.getElementById('alertsRuleMode');
const eventTypeEl = document.getElementById('alertsRuleEventType');
const keyEl = document.getElementById('alertsRuleMatchKey');
const valueEl = document.getElementById('alertsRuleMatchValue');
const severityEl = document.getElementById('alertsRuleSeverity');
const match = {};
const key = keyEl ? String(keyEl.value || '').trim() : '';
const value = valueEl ? String(valueEl.value || '').trim() : '';
if (key && value) {
match[key] = value;
}
return {
name: nameEl ? String(nameEl.value || '').trim() : 'Alert Rule',
mode: modeEl ? String(modeEl.value || '').trim() || null : null,
event_type: eventTypeEl ? String(eventTypeEl.value || '').trim() || null : null,
match,
severity: severityEl ? String(severityEl.value || 'medium') : 'medium',
enabled: true,
notify: { webhook: true },
};
}
function clearRuleForm() {
setField('alertsRuleName', '');
setField('alertsRuleMode', '');
setField('alertsRuleEventType', '');
setField('alertsRuleMatchKey', '');
setField('alertsRuleMatchValue', '');
setField('alertsRuleSeverity', 'medium');
setField('alertsRuleEditingId', '');
}
function editRule(ruleId) {
const rule = rules.find((r) => Number(r.id) === Number(ruleId));
if (!rule) return;
const matchEntries = Object.entries(rule.match || {});
const firstMatch = matchEntries.length ? matchEntries[0] : ['', ''];
setField('alertsRuleName', rule.name || '');
setField('alertsRuleMode', rule.mode || '');
setField('alertsRuleEventType', rule.event_type || '');
setField('alertsRuleMatchKey', firstMatch[0] || '');
setField('alertsRuleMatchValue', firstMatch[1] == null ? '' : String(firstMatch[1]));
setField('alertsRuleSeverity', rule.severity || 'medium');
setField('alertsRuleEditingId', String(rule.id));
}
function toggleRule(ruleId, enabled) {
fetch(`/alerts/rules/${ruleId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: Boolean(enabled) }),
})
.then((r) => r.json())
.then((data) => {
if (data.status !== 'success') {
throw new Error(data.message || 'Failed to update rule');
}
return loadRules();
})
.catch((err) => {
if (typeof reportActionableError === 'function') {
reportActionableError('Toggle Alert Rule', err);
}
});
}
function deleteRule(ruleId) {
if (!confirm('Delete this alert rule?')) return;
fetch(`/alerts/rules/${ruleId}`, { method: 'DELETE' })
.then((r) => r.json())
.then((data) => {
if (data.status !== 'success') {
throw new Error(data.message || 'Failed to delete rule');
}
if (Number(getEditingRuleId()) === Number(ruleId)) {
clearRuleForm();
}
return loadRules();
})
.catch((err) => {
if (typeof reportActionableError === 'function') {
reportActionableError('Delete Alert Rule', err);
}
});
}
function getEditingRuleId() {
const el = document.getElementById('alertsRuleEditingId');
if (!el || !el.value) return null;
const parsed = Number(el.value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
function setField(id, value) {
const el = document.getElementById(id);
if (!el) return;
el.value = value;
} }
function enableTrackerAlerts() { function enableTrackerAlerts() {
@@ -106,17 +304,18 @@ const AlertCenter = (function() {
} }
function ensureTrackerRule(enabled) { function ensureTrackerRule(enabled) {
loadRules(); loadRules().then(() => {
setTimeout(() => { const existing = rules.find((r) => r.name === TRACKER_RULE_NAME);
const existing = rules.find(r => r.name === TRACKER_RULE_NAME);
if (existing) { if (existing) {
fetch(`/alerts/rules/${existing.id}`, { return fetch(`/alerts/rules/${existing.id}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }) body: JSON.stringify({ enabled }),
}).then(() => loadRules()); }).then(() => loadRules());
} else if (enabled) { }
fetch('/alerts/rules', {
if (enabled) {
return fetch('/alerts/rules', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -126,44 +325,49 @@ const AlertCenter = (function() {
match: { is_tracker: true }, match: { is_tracker: true },
severity: 'high', severity: 'high',
enabled: true, enabled: true,
notify: { webhook: true } notify: { webhook: true },
}) }),
}).then(() => loadRules()); }).then(() => loadRules());
} }
}, 150); return null;
});
} }
function addBluetoothWatchlist(address, name) { function addBluetoothWatchlist(address, name) {
if (!address) return; if (!address) return;
const existing = rules.find(r => r.mode === 'bluetooth' && r.match && r.match.address === address); const upper = String(address).toUpperCase();
if (existing) { const existing = rules.find((r) => r.mode === 'bluetooth' && r.match && String(r.match.address || '').toUpperCase() === upper);
return; if (existing) return;
}
fetch('/alerts/rules', { fetch('/alerts/rules', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
name: name ? `Watchlist ${name}` : `Watchlist ${address}`, name: name ? `Watchlist ${name}` : `Watchlist ${upper}`,
mode: 'bluetooth', mode: 'bluetooth',
event_type: 'device_update', event_type: 'device_update',
match: { address: address }, match: { address: upper },
severity: 'medium', severity: 'medium',
enabled: true, enabled: true,
notify: { webhook: true } notify: { webhook: true },
}) }),
}).then(() => loadRules()); }).then(() => loadRules());
} }
function removeBluetoothWatchlist(address) { function removeBluetoothWatchlist(address) {
if (!address) return; if (!address) return;
const existing = rules.find(r => r.mode === 'bluetooth' && r.match && r.match.address === address); const upper = String(address).toUpperCase();
const existing = rules.find((r) => r.mode === 'bluetooth' && r.match && String(r.match.address || '').toUpperCase() === upper);
if (!existing) return; if (!existing) return;
fetch(`/alerts/rules/${existing.id}`, { method: 'DELETE' }) fetch(`/alerts/rules/${existing.id}`, { method: 'DELETE' })
.then(() => loadRules()); .then(() => loadRules());
} }
function isWatchlisted(address) { function isWatchlisted(address) {
return rules.some(r => r.mode === 'bluetooth' && r.match && r.match.address === address && r.enabled); if (!address) return false;
const upper = String(address).toUpperCase();
return rules.some((r) => r.mode === 'bluetooth' && r.match && String(r.match.address || '').toUpperCase() === upper && r.enabled);
} }
function escapeHtml(str) { function escapeHtml(str) {
@@ -179,6 +383,12 @@ const AlertCenter = (function() {
return { return {
init, init,
loadFeed, loadFeed,
loadRules,
saveRule,
clearRuleForm,
editRule,
toggleRule,
deleteRule,
enableTrackerAlerts, enableTrackerAlerts,
disableTrackerAlerts, disableTrackerAlerts,
addBluetoothWatchlist, addBluetoothWatchlist,
+13 -11
View File
@@ -36,12 +36,12 @@ let observerLocation = (function() {
return ObserverLocation.getForModule('observerLocation'); return ObserverLocation.getForModule('observerLocation');
} }
const saved = localStorage.getItem('observerLocation'); const saved = localStorage.getItem('observerLocation');
if (saved) { if (saved) {
try { try {
const parsed = JSON.parse(saved); const parsed = JSON.parse(saved);
if (parsed.lat && parsed.lon) return parsed; if (parsed.lat !== undefined && parsed.lat !== null && parsed.lon !== undefined && parsed.lon !== null) return parsed;
} catch (e) {} } catch (e) {}
} }
return { lat: 51.5074, lon: -0.1278 }; return { lat: 51.5074, lon: -0.1278 };
})(); })();
@@ -373,7 +373,7 @@ function showInfo(text) {
const infoEl = document.createElement('div'); const infoEl = document.createElement('div');
infoEl.className = 'info-msg'; infoEl.className = 'info-msg';
infoEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #0a0a0a; border: 1px solid #1a1a1a; border-left: 2px solid #00d4ff; font-family: "Space Mono", monospace; font-size: 11px; color: #888; word-break: break-all;'; infoEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #0a0a0a; border: 1px solid #1a1a1a; border-left: 2px solid #00d4ff; font-family: "Roboto Condensed", "Arial Narrow", sans-serif; font-size: 11px; color: #888; word-break: break-all;';
infoEl.textContent = text; infoEl.textContent = text;
output.insertBefore(infoEl, output.firstChild); output.insertBefore(infoEl, output.firstChild);
} }
@@ -387,7 +387,7 @@ function showError(text) {
const errorEl = document.createElement('div'); const errorEl = document.createElement('div');
errorEl.className = 'error-msg'; errorEl.className = 'error-msg';
errorEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #1a0a0a; border: 1px solid #2a1a1a; border-left: 2px solid #ff3366; font-family: "Space Mono", monospace; font-size: 11px; color: #ff6688; word-break: break-all;'; errorEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #1a0a0a; border: 1px solid #2a1a1a; border-left: 2px solid #ff3366; font-family: "Roboto Condensed", "Arial Narrow", sans-serif; font-size: 11px; color: #ff6688; word-break: break-all;';
errorEl.textContent = '⚠ ' + text; errorEl.textContent = '⚠ ' + text;
output.insertBefore(errorEl, output.firstChild); output.insertBefore(errorEl, output.firstChild);
} }
@@ -488,10 +488,12 @@ function initApp() {
}); });
}); });
// Collapse all sections by default (except SDR Device which is first) // Collapse sidebar menu sections by default, but skip headerless utility blocks.
document.querySelectorAll('.section').forEach((section, index) => { document.querySelectorAll('.sidebar .section').forEach((section) => {
if (index > 0) { if (section.querySelector('h3')) {
section.classList.add('collapsed'); section.classList.add('collapsed');
} else {
section.classList.remove('collapsed');
} }
}); });
+354
View File
@@ -0,0 +1,354 @@
const CommandPalette = (function() {
'use strict';
let overlayEl = null;
let inputEl = null;
let listEl = null;
let isOpen = false;
let activeIndex = 0;
let filteredItems = [];
const fallbackModeCommands = [
{ mode: 'pager', label: 'Pager' },
{ mode: 'sensor', label: '433MHz Sensors' },
{ mode: 'rtlamr', label: 'Meters' },
{ mode: 'listening', label: 'Listening Post' },
{ mode: 'subghz', label: 'SubGHz' },
{ mode: 'aprs', label: 'APRS' },
{ mode: 'wifi', label: 'WiFi Scanner' },
{ mode: 'bluetooth', label: 'Bluetooth Scanner' },
{ mode: 'bt_locate', label: 'BT Locate' },
{ mode: 'satellite', label: 'Satellite' },
{ mode: 'sstv', label: 'ISS SSTV' },
{ mode: 'weathersat', label: 'Weather Sat' },
{ mode: 'sstv_general', label: 'HF SSTV' },
{ mode: 'gps', label: 'GPS' },
{ mode: 'meshtastic', label: 'Meshtastic' },
{ mode: 'dmr', label: 'Digital Voice' },
{ mode: 'websdr', label: 'WebSDR' },
{ mode: 'analytics', label: 'Analytics' },
{ mode: 'spaceweather', label: 'Space Weather' },
];
function getModeCommands() {
const commands = [];
const seenModes = new Set();
const catalog = window.interceptModeCatalog;
if (catalog && typeof catalog === 'object') {
for (const [mode, meta] of Object.entries(catalog)) {
if (!mode || seenModes.has(mode)) continue;
const label = String((meta && meta.label) || mode).trim();
commands.push({ mode, label });
seenModes.add(mode);
}
if (commands.length > 0) return commands;
}
const navNodes = document.querySelectorAll('.mode-nav-btn[data-mode], .mobile-nav-btn[data-mode]');
navNodes.forEach((node) => {
if (node.tagName === 'A') {
const href = String(node.getAttribute('href') || '');
if (href.includes('/dashboard')) return;
}
const mode = String(node.dataset.mode || '').trim();
if (!mode || seenModes.has(mode)) return;
const label = String(node.dataset.modeLabel || node.textContent || mode).trim();
commands.push({ mode, label });
seenModes.add(mode);
});
if (commands.length > 0) return commands;
return fallbackModeCommands.slice();
}
function init() {
buildDOM();
registerHotkeys();
renderItems('');
}
function buildDOM() {
overlayEl = document.createElement('div');
overlayEl.className = 'command-palette-overlay';
overlayEl.id = 'commandPaletteOverlay';
overlayEl.addEventListener('click', (event) => {
if (event.target === overlayEl) close();
});
const palette = document.createElement('div');
palette.className = 'command-palette';
const header = document.createElement('div');
header.className = 'command-palette-header';
inputEl = document.createElement('input');
inputEl.className = 'command-palette-input';
inputEl.type = 'text';
inputEl.autocomplete = 'off';
inputEl.placeholder = 'Search commands and modes...';
inputEl.setAttribute('aria-label', 'Command Palette Search');
inputEl.addEventListener('input', () => {
renderItems(inputEl.value || '');
});
inputEl.addEventListener('keydown', onInputKeyDown);
const hint = document.createElement('span');
hint.className = 'command-palette-hint';
hint.textContent = 'Esc close';
header.appendChild(inputEl);
header.appendChild(hint);
listEl = document.createElement('div');
listEl.className = 'command-palette-list';
listEl.id = 'commandPaletteList';
palette.appendChild(header);
palette.appendChild(listEl);
overlayEl.appendChild(palette);
document.body.appendChild(overlayEl);
}
function registerHotkeys() {
document.addEventListener('keydown', (event) => {
const cmdK = (event.key.toLowerCase() === 'k') && (event.ctrlKey || event.metaKey);
if (cmdK) {
event.preventDefault();
if (isOpen) {
close();
} else {
open();
}
return;
}
if (!isOpen) return;
if (event.key === 'Escape') {
event.preventDefault();
close();
}
});
}
function onInputKeyDown(event) {
if (event.key === 'ArrowDown') {
event.preventDefault();
activeIndex = Math.min(activeIndex + 1, Math.max(filteredItems.length - 1, 0));
renderSelection();
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
activeIndex = Math.max(activeIndex - 1, 0);
renderSelection();
return;
}
if (event.key === 'Enter') {
event.preventDefault();
const item = filteredItems[activeIndex];
if (item && typeof item.run === 'function') {
item.run();
}
close();
}
}
function getCommands() {
const commands = [
{
title: 'Open Settings',
description: 'Open global settings panel',
keyword: 'settings configure tools',
shortcut: 'S',
run: () => {
if (typeof showSettings === 'function') showSettings();
}
},
{
title: 'Settings: Alerts',
description: 'Open alert rules and feed',
keyword: 'settings alerts rule',
run: () => openSettingsTab('alerts')
},
{
title: 'Settings: Recording',
description: 'Open recording manager',
keyword: 'settings recording replay',
run: () => openSettingsTab('recording')
},
{
title: 'Settings: Location',
description: 'Configure observer location',
keyword: 'settings location gps lat lon',
run: () => openSettingsTab('location')
},
{
title: 'View Aircraft Dashboard',
description: 'Open dedicated ADS-B dashboard page',
keyword: 'aircraft adsb dashboard',
run: () => { window.location.href = '/adsb/dashboard'; }
},
{
title: 'View Vessel Dashboard',
description: 'Open dedicated AIS dashboard page',
keyword: 'vessel ais dashboard',
run: () => { window.location.href = '/ais/dashboard'; }
},
{
title: 'Kill All Running Processes',
description: 'Stop all decoders and scans',
keyword: 'kill stop processes emergency',
run: () => {
if (typeof killAll === 'function') {
killAll();
} else if (typeof fetch === 'function') {
fetch('/killall', { method: 'POST' });
}
}
},
{
title: 'Toggle Sidebar Width',
description: 'Collapse or expand the left sidebar',
keyword: 'sidebar collapse layout',
run: () => {
if (typeof toggleMainSidebarCollapse === 'function') {
toggleMainSidebarCollapse();
}
}
},
];
for (const modeEntry of getModeCommands()) {
commands.push({
title: `Switch Mode: ${modeEntry.label}`,
description: 'Navigate directly to mode',
keyword: `mode ${modeEntry.mode} ${modeEntry.label.toLowerCase()}`,
run: () => goToMode(modeEntry.mode),
});
}
return commands;
}
function renderItems(query) {
const q = String(query || '').trim().toLowerCase();
const allItems = getCommands();
filteredItems = allItems.filter((item) => {
if (!q) return true;
const haystack = `${item.title} ${item.description || ''} ${item.keyword || ''}`.toLowerCase();
return haystack.includes(q);
}).slice(0, 80);
activeIndex = 0;
listEl.innerHTML = '';
if (filteredItems.length === 0) {
const empty = document.createElement('div');
empty.className = 'command-palette-empty';
empty.textContent = 'No matching commands';
listEl.appendChild(empty);
return;
}
filteredItems.forEach((item, idx) => {
const row = document.createElement('button');
row.type = 'button';
row.className = 'command-palette-item';
row.dataset.index = String(idx);
row.addEventListener('click', () => {
item.run();
close();
});
const meta = document.createElement('span');
meta.className = 'meta';
const title = document.createElement('span');
title.className = 'title';
title.textContent = item.title;
meta.appendChild(title);
const desc = document.createElement('span');
desc.className = 'desc';
desc.textContent = item.description || '';
meta.appendChild(desc);
row.appendChild(meta);
if (item.shortcut) {
const kbd = document.createElement('span');
kbd.className = 'kbd';
kbd.textContent = item.shortcut;
row.appendChild(kbd);
}
listEl.appendChild(row);
});
renderSelection();
}
function renderSelection() {
const rows = listEl.querySelectorAll('.command-palette-item');
rows.forEach((row) => {
const idx = Number(row.dataset.index || 0);
row.classList.toggle('active', idx === activeIndex);
});
const activeRow = listEl.querySelector(`.command-palette-item[data-index="${activeIndex}"]`);
if (activeRow) {
activeRow.scrollIntoView({ block: 'nearest' });
}
}
function goToMode(mode) {
const welcome = document.getElementById('welcomePage');
if (welcome && getComputedStyle(welcome).display !== 'none') {
welcome.style.display = 'none';
}
if (typeof switchMode === 'function') {
switchMode(mode, { updateUrl: true });
}
}
function openSettingsTab(tab) {
if (typeof showSettings === 'function') {
showSettings();
}
if (typeof switchSettingsTab === 'function') {
switchSettingsTab(tab);
}
}
function open() {
if (!overlayEl) return;
isOpen = true;
overlayEl.classList.add('open');
renderItems('');
inputEl.value = '';
requestAnimationFrame(() => {
inputEl.focus();
});
}
function close() {
if (!overlayEl) return;
isOpen = false;
overlayEl.classList.remove('open');
}
return {
init,
open,
close,
};
})();
document.addEventListener('DOMContentLoaded', () => {
CommandPalette.init();
});
+373
View File
@@ -0,0 +1,373 @@
const FirstRunSetup = (function() {
'use strict';
const COMPLETE_KEY = 'intercept.setup.complete.v1';
const DEFAULT_MODE_KEY = 'intercept.default_mode';
let overlayEl = null;
let depsStatusEl = null;
let locationStatusEl = null;
let notifyStatusEl = null;
let modeStatusEl = null;
let modeSelectEl = null;
let dependencyReady = null;
function init() {
buildDOM();
maybeShow();
}
function maybeShow() {
if (localStorage.getItem(COMPLETE_KEY) === 'true') return;
if (localStorage.getItem('disclaimerAccepted') === 'true') {
open();
refreshStatuses();
return;
}
let attempts = 0;
const waitTimer = setInterval(() => {
attempts += 1;
if (localStorage.getItem(COMPLETE_KEY) === 'true') {
clearInterval(waitTimer);
return;
}
if (localStorage.getItem('disclaimerAccepted') === 'true') {
clearInterval(waitTimer);
open();
refreshStatuses();
}
if (attempts > 30) {
clearInterval(waitTimer);
}
}, 1000);
}
function buildDOM() {
overlayEl = document.createElement('div');
overlayEl.id = 'firstRunSetupOverlay';
overlayEl.className = 'setup-overlay';
const modal = document.createElement('div');
modal.className = 'setup-modal';
const header = document.createElement('div');
header.className = 'setup-header';
const headingWrap = document.createElement('div');
const title = document.createElement('h2');
title.className = 'setup-title';
title.textContent = 'Quick Setup';
headingWrap.appendChild(title);
const subtitle = document.createElement('p');
subtitle.className = 'setup-subtitle';
subtitle.textContent = 'Complete these checks once so all modes work reliably.';
headingWrap.appendChild(subtitle);
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'setup-close';
closeBtn.textContent = '×';
closeBtn.setAttribute('aria-label', 'Close setup assistant');
closeBtn.addEventListener('click', close);
header.appendChild(headingWrap);
header.appendChild(closeBtn);
const content = document.createElement('div');
content.className = 'setup-content';
const depsStep = createStep(
'Dependencies',
'Verify required tools are installed for enabled modes.',
(statusEl, actionsEl) => {
depsStatusEl = statusEl;
const checkBtn = buildButton('Recheck', () => checkDependencies());
const openToolsBtn = buildButton('Open Tools', () => {
if (typeof showSettings === 'function') showSettings();
if (typeof switchSettingsTab === 'function') switchSettingsTab('tools');
});
actionsEl.appendChild(checkBtn);
actionsEl.appendChild(openToolsBtn);
}
);
const locationStep = createStep(
'Observer Location',
'Set latitude/longitude for pass prediction and mapping features.',
(statusEl, actionsEl) => {
locationStatusEl = statusEl;
actionsEl.appendChild(buildButton('Open Location', () => {
if (typeof showSettings === 'function') showSettings();
if (typeof switchSettingsTab === 'function') switchSettingsTab('location');
}));
actionsEl.appendChild(buildButton('Recheck', refreshStatuses));
}
);
const notifyStep = createStep(
'Desktop Alerts',
'Allow notifications so high-priority alerts are visible when the tab is hidden.',
(statusEl, actionsEl) => {
notifyStatusEl = statusEl;
actionsEl.appendChild(buildButton('Enable Notifications', requestNotifications));
}
);
const modeStep = createStep(
'Default Start Mode',
'Choose which mode should be selected by default.',
(statusEl, actionsEl) => {
modeStatusEl = statusEl;
modeSelectEl = document.createElement('select');
modeSelectEl.className = 'setup-btn';
const modes = [
['pager', 'Pager'],
['sensor', '433MHz'],
['rtlamr', 'Meters'],
['listening', 'Listening Post'],
['wifi', 'WiFi'],
['bluetooth', 'Bluetooth'],
['bt_locate', 'BT Locate'],
['aprs', 'APRS'],
['satellite', 'Satellite'],
['sstv', 'ISS SSTV'],
['weathersat', 'Weather Sat'],
['sstv_general', 'HF SSTV'],
['analytics', 'Analytics'],
];
for (const [value, label] of modes) {
const opt = document.createElement('option');
opt.value = value;
opt.textContent = label;
modeSelectEl.appendChild(opt);
}
const savedDefaultMode = localStorage.getItem(DEFAULT_MODE_KEY);
if (savedDefaultMode) {
modeSelectEl.value = savedDefaultMode;
}
actionsEl.appendChild(modeSelectEl);
actionsEl.appendChild(buildButton('Save', () => {
const selected = modeSelectEl.value || 'pager';
localStorage.setItem(DEFAULT_MODE_KEY, selected);
refreshStatuses();
if (typeof showAppToast === 'function') {
showAppToast('Default Mode Saved', `New sessions will default to ${selected}.`, 'info');
}
}));
}
);
content.appendChild(depsStep);
content.appendChild(locationStep);
content.appendChild(notifyStep);
content.appendChild(modeStep);
const footer = document.createElement('div');
footer.className = 'setup-footer';
const note = document.createElement('span');
note.className = 'setup-footer-note';
note.textContent = 'You can reopen these options anytime in Settings.';
const footerActions = document.createElement('div');
footerActions.style.display = 'inline-flex';
footerActions.style.gap = '8px';
const laterBtn = buildButton('Remind Me Later', close);
const completeBtn = buildButton('Mark Setup Complete', completeSetup, true);
completeBtn.id = 'setupCompleteBtn';
footerActions.appendChild(laterBtn);
footerActions.appendChild(completeBtn);
footer.appendChild(note);
footer.appendChild(footerActions);
modal.appendChild(header);
modal.appendChild(content);
modal.appendChild(footer);
overlayEl.appendChild(modal);
document.body.appendChild(overlayEl);
}
function createStep(title, description, initActions) {
const root = document.createElement('div');
root.className = 'setup-step';
const header = document.createElement('div');
header.className = 'setup-step-header';
const titleEl = document.createElement('span');
titleEl.className = 'setup-step-title';
titleEl.textContent = title;
const statusEl = document.createElement('span');
statusEl.className = 'setup-step-status';
statusEl.textContent = 'Pending';
header.appendChild(titleEl);
header.appendChild(statusEl);
const descEl = document.createElement('p');
descEl.className = 'setup-step-desc';
descEl.textContent = description;
const actionsEl = document.createElement('div');
actionsEl.className = 'setup-step-actions';
if (typeof initActions === 'function') {
initActions(statusEl, actionsEl);
}
root.appendChild(header);
root.appendChild(descEl);
root.appendChild(actionsEl);
return root;
}
function buildButton(label, onClick, primary) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = `setup-btn${primary ? ' primary' : ''}`;
btn.textContent = label;
btn.addEventListener('click', onClick);
return btn;
}
async function checkDependencies() {
if (depsStatusEl) depsStatusEl.textContent = 'Checking...';
try {
const response = await fetch('/dependencies');
const data = await response.json();
if (data.status !== 'success') {
dependencyReady = false;
} else {
const modes = Object.values(data.modes || {});
dependencyReady = modes.every((modeInfo) => Boolean(modeInfo.ready));
}
} catch (err) {
dependencyReady = false;
if (typeof reportActionableError === 'function') {
reportActionableError('Dependency Check', err, {
onRetry: checkDependencies,
});
}
}
refreshStatuses();
}
function refreshStatuses() {
const hasLocation = hasValidLocation();
const notifications = notificationStatus();
const hasDefaultMode = Boolean(localStorage.getItem(DEFAULT_MODE_KEY));
setStatus(locationStatusEl, hasLocation, hasLocation ? 'Configured' : 'Not set');
setStatus(notifyStatusEl, notifications.ready, notifications.label);
setStatus(modeStatusEl, hasDefaultMode, hasDefaultMode ? localStorage.getItem(DEFAULT_MODE_KEY) : 'Not set');
if (dependencyReady === null) {
checkDependencies();
return;
}
setStatus(depsStatusEl, dependencyReady, dependencyReady ? 'Ready' : 'Missing tools');
const doneCount = Number(dependencyReady) + Number(hasLocation) + Number(notifications.ready) + Number(hasDefaultMode);
const completeBtn = document.getElementById('setupCompleteBtn');
if (completeBtn) {
completeBtn.textContent = doneCount >= 3 ? 'Mark Setup Complete' : 'Complete Anyway';
}
}
function setStatus(el, done, label) {
if (!el) return;
el.classList.toggle('done', Boolean(done));
el.textContent = String(label || (done ? 'Done' : 'Pending'));
}
function hasValidLocation() {
const rawLat = localStorage.getItem('observerLat');
const rawLon = localStorage.getItem('observerLon');
if (rawLat === null || rawLon === null || rawLat === '' || rawLon === '') {
return false;
}
const lat = Number(rawLat);
const lon = Number(rawLon);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false;
return lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180;
}
function notificationStatus() {
if (!('Notification' in window)) {
return { ready: true, label: 'Unsupported (optional)' };
}
if (Notification.permission === 'granted') {
return { ready: true, label: 'Enabled' };
}
if (Notification.permission === 'denied') {
return { ready: false, label: 'Blocked in browser' };
}
return { ready: false, label: 'Permission needed' };
}
async function requestNotifications() {
if (!('Notification' in window)) {
refreshStatuses();
return;
}
try {
await Notification.requestPermission();
} catch (err) {
if (typeof reportActionableError === 'function') {
reportActionableError('Notifications', err);
}
}
refreshStatuses();
}
function completeSetup() {
localStorage.setItem(COMPLETE_KEY, 'true');
close();
if (typeof showAppToast === 'function') {
showAppToast('Setup Complete', 'You can revisit these options in Settings.', 'info');
}
}
function open() {
if (!overlayEl) return;
overlayEl.classList.add('open');
}
function close() {
if (!overlayEl) return;
overlayEl.classList.remove('open');
}
return {
init,
open,
close,
refreshStatuses,
completeSetup,
};
})();
document.addEventListener('DOMContentLoaded', () => {
FirstRunSetup.init();
});
+16 -1
View File
@@ -96,7 +96,10 @@ const RecordingUI = (function() {
<div class="settings-feed-item"> <div class="settings-feed-item">
<div class="settings-feed-title"> <div class="settings-feed-title">
<span>${escapeHtml(rec.mode)}${rec.label ? `${escapeHtml(rec.label)}` : ''}</span> <span>${escapeHtml(rec.mode)}${rec.label ? `${escapeHtml(rec.label)}` : ''}</span>
<button class="preset-btn" style="font-size: 9px; padding: 2px 6px;" onclick="RecordingUI.download('${rec.id}')">Download</button> <div style="display:flex; gap:6px;">
<button class="preset-btn" style="font-size: 9px; padding: 2px 6px;" onclick="RecordingUI.openReplay('${rec.id}')">Replay</button>
<button class="preset-btn" style="font-size: 9px; padding: 2px 6px;" onclick="RecordingUI.download('${rec.id}')">Download</button>
</div>
</div> </div>
<div class="settings-feed-meta">${new Date(rec.started_at).toLocaleString()}${rec.stopped_at ? `${new Date(rec.stopped_at).toLocaleString()}` : ''}</div> <div class="settings-feed-meta">${new Date(rec.started_at).toLocaleString()}${rec.stopped_at ? `${new Date(rec.stopped_at).toLocaleString()}` : ''}</div>
<div class="settings-feed-meta">Events: ${rec.event_count || 0} ${(rec.size_bytes || 0) / 1024.0 > 0 ? (rec.size_bytes / 1024).toFixed(1) + ' KB' : '0 KB'}</div> <div class="settings-feed-meta">Events: ${rec.event_count || 0} ${(rec.size_bytes || 0) / 1024.0 > 0 ? (rec.size_bytes / 1024).toFixed(1) + ' KB' : '0 KB'}</div>
@@ -109,6 +112,17 @@ const RecordingUI = (function() {
window.open(`/recordings/${sessionId}/download`, '_blank'); window.open(`/recordings/${sessionId}/download`, '_blank');
} }
function openReplay(sessionId) {
if (!sessionId) return;
localStorage.setItem('analyticsReplaySession', sessionId);
if (typeof hideSettings === 'function') hideSettings();
if (typeof switchMode === 'function') {
switchMode('analytics', { updateUrl: true });
return;
}
window.location.href = '/?mode=analytics';
}
function escapeHtml(str) { function escapeHtml(str) {
if (!str) return ''; if (!str) return '';
return String(str) return String(str)
@@ -126,6 +140,7 @@ const RecordingUI = (function() {
stop, stop,
stopById, stopById,
download, download,
openReplay,
}; };
})(); })();
+240
View File
@@ -0,0 +1,240 @@
const RunState = (function() {
'use strict';
const REFRESH_MS = 5000;
const CHIP_MODES = ['pager', 'sensor', 'wifi', 'bluetooth', 'adsb', 'ais', 'acars', 'vdl2', 'aprs', 'dsc', 'dmr', 'subghz'];
const MODE_ALIASES = {
bt: 'bluetooth',
bt_locate: 'bluetooth',
btlocate: 'bluetooth',
aircraft: 'adsb',
};
const modeLabels = {
pager: 'Pager',
sensor: '433',
wifi: 'WiFi',
bluetooth: 'BT',
adsb: 'ADS-B',
ais: 'AIS',
acars: 'ACARS',
vdl2: 'VDL2',
aprs: 'APRS',
dsc: 'DSC',
dmr: 'DMR',
subghz: 'SubGHz',
};
let refreshTimer = null;
let activeMode = null;
let lastHealth = null;
let lastErrorToastAt = 0;
function init() {
const root = document.getElementById('runStateStrip');
if (!root) return;
wireActions();
wrapModeSwitch();
activeMode = inferCurrentMode();
renderHealth(null);
refresh();
if (!refreshTimer) {
refreshTimer = window.setInterval(refresh, REFRESH_MS);
}
document.addEventListener('visibilitychange', () => {
if (!document.hidden) refresh();
});
}
function wireActions() {
const refreshBtn = document.getElementById('runStateRefreshBtn');
if (refreshBtn) {
refreshBtn.addEventListener('click', () => refresh());
}
const settingsBtn = document.getElementById('runStateSettingsBtn');
if (settingsBtn) {
settingsBtn.addEventListener('click', () => {
if (typeof showSettings === 'function') {
showSettings();
if (typeof switchSettingsTab === 'function') {
switchSettingsTab('tools');
}
}
});
}
}
function wrapModeSwitch() {
if (typeof window.switchMode !== 'function') return;
if (window.switchMode.__runStateWrapped) return;
const original = window.switchMode;
const wrapped = function(mode) {
if (mode) {
activeMode = normalizeMode(String(mode));
}
const result = original.apply(this, arguments);
markActiveChip();
return result;
};
wrapped.__runStateWrapped = true;
window.switchMode = wrapped;
}
async function refresh() {
try {
const response = await fetch('/health');
const data = await response.json();
lastHealth = data;
renderHealth(data);
} catch (err) {
renderHealth(null, err);
const now = Date.now();
if (typeof reportActionableError === 'function' && (now - lastErrorToastAt) > 30000) {
lastErrorToastAt = now;
reportActionableError('Run State', err, { persistent: false });
}
}
}
function renderHealth(data, err) {
const chipsContainer = document.getElementById('runStateChips');
const summaryEl = document.getElementById('runStateSummary');
if (!chipsContainer || !summaryEl) return;
chipsContainer.innerHTML = '';
if (!data || data.status !== 'healthy') {
const offline = buildChip('API', false);
offline.classList.add('active');
chipsContainer.appendChild(offline);
summaryEl.textContent = err ? `Health unavailable: ${extractMessage(err)}` : 'Health unavailable';
return;
}
const processes = normalizeProcesses(data.processes || {});
for (const mode of CHIP_MODES) {
const isRunning = Boolean(processes[mode]);
chipsContainer.appendChild(buildChip(modeLabels[mode] || mode.toUpperCase(), isRunning, mode));
}
const counts = data.data || {};
summaryEl.textContent = `Aircraft ${counts.aircraft_count || 0} | Vessels ${counts.vessel_count || 0} | WiFi ${counts.wifi_networks_count || 0} | BT ${counts.bt_devices_count || 0}`;
markActiveChip();
}
function buildChip(label, running, mode) {
const chip = document.createElement('span');
chip.className = `run-state-chip${running ? ' running' : ''}`;
if (mode) {
chip.dataset.mode = mode;
}
const dot = document.createElement('span');
dot.className = 'dot';
chip.appendChild(dot);
const text = document.createElement('span');
text.textContent = label;
chip.appendChild(text);
return chip;
}
function markActiveChip() {
if (!activeMode) {
activeMode = inferCurrentMode();
}
document.querySelectorAll('#runStateChips .run-state-chip').forEach((chip) => {
chip.classList.remove('active');
if (chip.dataset.mode && chip.dataset.mode === normalizeMode(activeMode)) {
chip.classList.add('active');
}
});
}
function inferCurrentMode() {
const modeParam = new URLSearchParams(window.location.search).get('mode');
if (modeParam) return normalizeMode(modeParam);
if (typeof window.currentMode === 'string' && window.currentMode) {
return normalizeMode(window.currentMode);
}
const indicator = document.getElementById('activeModeIndicator');
if (!indicator) return 'pager';
const text = indicator.textContent || '';
const normalized = text.toLowerCase();
if (normalized.includes('wifi')) return 'wifi';
if (normalized.includes('bluetooth')) return 'bluetooth';
if (normalized.includes('bt locate')) return 'bluetooth';
if (normalized.includes('ads-b')) return 'adsb';
if (normalized.includes('ais')) return 'ais';
if (normalized.includes('acars')) return 'acars';
if (normalized.includes('vdl2')) return 'vdl2';
if (normalized.includes('aprs')) return 'aprs';
if (normalized.includes('dsc')) return 'dsc';
if (normalized.includes('subghz')) return 'subghz';
if (normalized.includes('dmr')) return 'dmr';
if (normalized.includes('433')) return 'sensor';
return 'pager';
}
function normalizeMode(mode) {
const value = String(mode || '').trim().toLowerCase();
if (!value) return 'pager';
return MODE_ALIASES[value] || value;
}
function normalizeProcesses(raw) {
const processes = Object.assign({}, raw || {});
processes.bluetooth = Boolean(
processes.bluetooth ||
processes.bt ||
processes.bt_scan ||
processes.btlocate ||
processes.bt_locate
);
processes.wifi = Boolean(
processes.wifi ||
processes.wifi_scan ||
processes.wlan
);
return processes;
}
function extractMessage(err) {
if (!err) return 'Unknown error';
if (typeof err === 'string') return err;
if (err.message) return err.message;
return String(err);
}
function getLastHealth() {
return lastHealth;
}
function destroy() {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
}
return {
init,
refresh,
destroy,
getLastHealth,
};
})();
document.addEventListener('DOMContentLoaded', () => {
RunState.init();
});
+438 -54
View File
@@ -22,15 +22,16 @@ const Settings = {
cartodb_dark: { cartodb_dark: {
url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', url: '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>', attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
subdomains: 'abcd' subdomains: 'abcd',
mapTheme: 'cyber',
options: {}
}, },
cartodb_dark_cyan: { cartodb_dark_cyan: {
url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', url: '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>', attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
subdomains: 'abcd', subdomains: 'abcd',
options: { mapTheme: 'cyber',
className: 'tile-layer-cyan' options: {}
}
}, },
cartodb_light: { cartodb_light: {
url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/light_all/{z}/{x}/{y}.png', url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/light_all/{z}/{x}/{y}.png',
@@ -50,26 +51,153 @@ const Settings = {
// Current settings cache // Current settings cache
_cache: {}, _cache: {},
// Init guard to prevent concurrent fetch races across pages/modes
_initialized: false,
_initPromise: null,
_themeObserver: null,
_themeObserverStarted: false,
_themeObserverRaf: null,
/**
* Check if a tile provider key is valid.
* @param {string} provider
* @returns {boolean}
*/
_isKnownTileProvider(provider) {
if (typeof provider !== 'string') return false;
const key = provider.trim();
return key === 'custom' || Object.prototype.hasOwnProperty.call(this.tileProviders, key);
},
/**
* Normalize tile provider values from storage/UI.
* @param {string} provider
* @returns {string}
*/
_normalizeTileProvider(provider) {
if (typeof provider !== 'string') return this.defaults['offline.tile_provider'];
const key = provider.trim();
if (this._isKnownTileProvider(key)) return key;
return this.defaults['offline.tile_provider'];
},
/**
* Persist and retrieve preferred map theme behavior for dark Carto tiles.
* Helps keep Cyber style enabled even if server-side tile provider drifts.
*/
_getMapThemePreference() {
if (typeof localStorage === 'undefined') return 'cyber';
const pref = localStorage.getItem('intercept_map_theme_pref');
if (pref === 'none' || pref === 'cyber') return pref;
return 'cyber';
},
_setMapThemePreference(pref) {
if (typeof localStorage === 'undefined') return;
if (pref !== 'none' && pref !== 'cyber') return;
localStorage.setItem('intercept_map_theme_pref', pref);
},
/**
* Whether Cyber map theme should be considered active globally.
* @param {Object} [config]
* @returns {boolean}
*/
_isCyberThemeEnabled(config) {
const resolvedConfig = config || this.getTileConfig();
return this._getMapThemeClass(resolvedConfig) === 'map-theme-cyber';
},
/**
* Toggle root class used for hard global Leaflet theming.
* @param {Object} [config]
*/
_syncRootMapThemeClass(config) {
if (typeof document === 'undefined' || !document.documentElement) return;
const enabled = this._isCyberThemeEnabled(config);
document.documentElement.classList.toggle('map-cyber-enabled', enabled);
},
/**
* Prefer localStorage tile settings when available to avoid stale server values.
*/
_applyLocalTileOverrides() {
const stored = localStorage.getItem('intercept_settings');
if (!stored) return;
try {
const local = JSON.parse(stored) || {};
const localProvider = this._normalizeTileProvider(local['offline.tile_provider']);
if (localProvider) {
this._cache['offline.tile_provider'] = localProvider;
}
if (typeof local['offline.tile_server_url'] === 'string') {
this._cache['offline.tile_server_url'] = local['offline.tile_server_url'];
}
} catch (e) {
// Ignore malformed local settings and keep current cache.
}
},
/** /**
* Initialize settings - load from server/localStorage * Initialize settings - load from server/localStorage
*/ */
async init() { async init(options = {}) {
try { const force = Boolean(options && options.force);
const response = await fetch('/offline/settings');
if (response.ok) { if (!force && this._initialized) {
const data = await response.json(); return this._cache;
this._cache = { ...this.defaults, ...data.settings };
} else {
// Fall back to localStorage
this._loadFromLocalStorage();
}
} catch (e) {
console.warn('Failed to load settings from server, using localStorage:', e);
this._loadFromLocalStorage();
} }
this._updateUI(); if (!force && this._initPromise) {
return this._cache; return this._initPromise;
}
this._initPromise = (async () => {
try {
const response = await fetch('/offline/settings');
if (response.ok) {
const data = await response.json();
this._cache = { ...this.defaults, ...data.settings };
} else {
// Fall back to localStorage
this._loadFromLocalStorage();
}
} catch (e) {
console.warn('Failed to load settings from server, using localStorage:', e);
this._loadFromLocalStorage();
}
this._applyLocalTileOverrides();
this._cache['offline.tile_provider'] = this._normalizeTileProvider(this._cache['offline.tile_provider']);
// If dark Carto was restored by stale server settings but user prefers Cyber,
// keep the visible provider aligned with Cyber selection.
if (this._cache['offline.tile_provider'] === 'cartodb_dark' && this._getMapThemePreference() === 'cyber') {
this._cache['offline.tile_provider'] = 'cartodb_dark_cyan';
}
this._updateUI();
// Re-apply map theme to already-registered maps in case init happened after map creation.
const allMaps = this._collectMaps();
if (allMaps.length > 0) {
const config = this.getTileConfig();
allMaps.forEach((map) => this._applyMapTheme(map, config));
}
const activeConfig = this.getTileConfig();
this._syncRootMapThemeClass(activeConfig);
this._applyThemeToAllContainers(activeConfig);
this._ensureThemeObserver();
this._initialized = true;
return this._cache;
})();
try {
return await this._initPromise;
} finally {
this._initPromise = null;
}
}, },
/** /**
@@ -99,11 +227,14 @@ const Settings = {
// Save to server // Save to server
try { try {
await fetch('/offline/settings', { const response = await fetch('/offline/settings', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, value }) body: JSON.stringify({ key, value })
}); });
if (!response.ok) {
throw new Error(`Save failed (${response.status})`);
}
} catch (e) { } catch (e) {
console.warn('Failed to save setting to server:', e); console.warn('Failed to save setting to server:', e);
} }
@@ -152,6 +283,16 @@ const Settings = {
* Set tile provider * Set tile provider
*/ */
async setTileProvider(provider) { async setTileProvider(provider) {
provider = this._normalizeTileProvider(provider);
if (provider === 'cartodb_dark_cyan') {
this._setMapThemePreference('cyber');
} else if (provider === 'cartodb_dark') {
this._setMapThemePreference('none');
} else {
this._setMapThemePreference('none');
}
await this._save('offline.tile_provider', provider); await this._save('offline.tile_provider', provider);
// Show/hide custom URL input // Show/hide custom URL input
@@ -160,10 +301,11 @@ const Settings = {
customRow.style.display = provider === 'custom' ? 'block' : 'none'; customRow.style.display = provider === 'custom' ? 'block' : 'none';
} }
// If not custom and we have a map, update tiles immediately // Update tiles immediately for all providers.
if (provider !== 'custom') { this._updateMapTiles();
this._updateMapTiles(); const activeConfig = this.getTileConfig();
} this._syncRootMapThemeClass(activeConfig);
this._applyThemeToAllContainers(activeConfig);
}, },
/** /**
@@ -178,7 +320,7 @@ const Settings = {
* Get current tile configuration * Get current tile configuration
*/ */
getTileConfig() { getTileConfig() {
const provider = this.get('offline.tile_provider'); const provider = this._normalizeTileProvider(this.get('offline.tile_provider'));
if (provider === 'custom') { if (provider === 'custom') {
const customUrl = this.get('offline.tile_server_url'); const customUrl = this.get('offline.tile_server_url');
@@ -189,7 +331,170 @@ const Settings = {
}; };
} }
return this.tileProviders[provider] || this.tileProviders.cartodb_dark; const config = this.tileProviders[provider] || this.tileProviders.cartodb_dark;
// Robust fallback: if dark Carto is active and Cyber is preferred,
// keep Cyber theme enabled even when provider temporarily reverts.
if (provider === 'cartodb_dark' && this._getMapThemePreference() === 'cyber') {
return { ...config, mapTheme: 'cyber' };
}
return config;
},
/**
* Resolve map theme class from tile config.
* @param {Object} config
* @returns {string|null}
*/
_getMapThemeClass(config) {
if (!config || !config.mapTheme) return null;
if (config.mapTheme === 'cyber') return 'map-theme-cyber';
return null;
},
/**
* Apply or clear map theme styles for a Leaflet container.
* @param {HTMLElement} container
* @param {Object} [config]
*/
_applyThemeToContainer(container, config) {
if (!container || !container.classList) return;
const tilePane = container.querySelector('.leaflet-tile-pane');
container.querySelectorAll('.intercept-map-theme-overlay').forEach((el) => el.remove());
if (tilePane && tilePane.style) {
tilePane.style.filter = '';
tilePane.style.opacity = '';
tilePane.style.willChange = '';
}
if (container.style) {
container.style.background = '';
}
container.classList.remove('map-theme-cyber');
const resolvedConfig = config || this.getTileConfig();
const themeClass = this._getMapThemeClass(resolvedConfig);
if (!themeClass) return;
container.classList.add(themeClass);
if (container.style) {
container.style.background = '#020813';
}
if (tilePane && tilePane.style) {
tilePane.style.filter = 'sepia(0.74) hue-rotate(176deg) saturate(1.72) brightness(1.05) contrast(1.08)';
tilePane.style.opacity = '1';
tilePane.style.willChange = 'filter';
}
// Grid/glow overlays are rendered via CSS pseudo elements on
// `html.map-cyber-enabled .leaflet-container` for consistent stacking.
},
/**
* Apply/remove map theme class on a Leaflet map container.
* @param {L.Map} map
* @param {Object} [config]
*/
_applyMapTheme(map, config) {
if (!map || typeof map.getContainer !== 'function') return;
const container = map.getContainer();
this._applyThemeToContainer(container, config);
},
/**
* Apply current map theme to all rendered Leaflet containers.
* Covers maps that were not explicitly registered with Settings.
* @param {Object} [config]
*/
_applyThemeToAllContainers(config) {
if (typeof document === 'undefined') return;
const containers = document.querySelectorAll('.leaflet-container');
if (!containers.length) return;
const resolvedConfig = config || this.getTileConfig();
this._syncRootMapThemeClass(resolvedConfig);
containers.forEach((container) => this._applyThemeToContainer(container, resolvedConfig));
},
/**
* Watch the DOM for new Leaflet maps and apply current theme automatically.
*/
_ensureThemeObserver() {
if (this._themeObserverStarted || typeof MutationObserver === 'undefined') return;
if (typeof document === 'undefined' || !document.body) return;
const scheduleApply = () => {
if (this._themeObserverRaf && typeof cancelAnimationFrame === 'function') {
cancelAnimationFrame(this._themeObserverRaf);
}
if (typeof requestAnimationFrame === 'function') {
this._themeObserverRaf = requestAnimationFrame(() => {
this._themeObserverRaf = null;
this._applyThemeToAllContainers(this.getTileConfig());
});
} else {
this._applyThemeToAllContainers(this.getTileConfig());
}
};
this._themeObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (!mutation.addedNodes || mutation.addedNodes.length === 0) continue;
for (const node of mutation.addedNodes) {
if (!(node instanceof Element)) continue;
if (node.classList.contains('leaflet-container') || node.querySelector('.leaflet-container')) {
scheduleApply();
return;
}
}
}
});
this._themeObserver.observe(document.body, {
childList: true,
subtree: true
});
this._themeObserverStarted = true;
},
/**
* Collect all known map instances.
* @returns {L.Map[]}
*/
_collectMaps() {
const windowMaps = [
window.map,
window.leafletMap,
window.aprsMap,
window.radarMap,
window.vesselMap,
window.groundMap,
window.groundTrackMap,
window.meshMap,
window.issMap
].filter(m => m && typeof m.eachLayer === 'function');
return [...new Set([...this._registeredMaps, ...windowMaps])];
},
/**
* Keep map theme stable if map internals or layers are refreshed.
* @param {L.Map} map - Leaflet map instance
*/
_attachMapThemeHooks(map) {
if (!map || typeof map.on !== 'function' || map._interceptThemeHookBound) return;
const reapplyTheme = () => this._applyMapTheme(map);
const hookEvents = ['layeradd', 'layerremove', 'zoomend', 'resize', 'load'];
hookEvents.forEach((eventName) => map.on(eventName, reapplyTheme));
map._interceptThemeHookBound = true;
map._interceptThemeHookHandler = reapplyTheme;
}, },
/** /**
@@ -200,6 +505,18 @@ const Settings = {
if (map && typeof map.eachLayer === 'function' && !this._registeredMaps.includes(map)) { if (map && typeof map.eachLayer === 'function' && !this._registeredMaps.includes(map)) {
this._registeredMaps.push(map); this._registeredMaps.push(map);
} }
this._ensureThemeObserver();
this._attachMapThemeHooks(map);
this._applyMapTheme(map);
this._applyThemeToAllContainers(this.getTileConfig());
// Some maps create tile DOM asynchronously; re-apply after first paint.
if (typeof window !== 'undefined' && typeof window.setTimeout === 'function') {
window.setTimeout(() => {
this._applyMapTheme(map);
this._applyThemeToAllContainers(this.getTileConfig());
}, 120);
}
}, },
/** /**
@@ -211,6 +528,15 @@ const Settings = {
if (idx > -1) { if (idx > -1) {
this._registeredMaps.splice(idx, 1); this._registeredMaps.splice(idx, 1);
} }
if (map && map._interceptThemeHookBound && typeof map.off === 'function') {
const handler = map._interceptThemeHookHandler;
['layeradd', 'layerremove', 'zoomend', 'resize', 'load'].forEach((eventName) => {
map.off(eventName, handler);
});
delete map._interceptThemeHookBound;
delete map._interceptThemeHookHandler;
}
}, },
/** /**
@@ -323,31 +649,29 @@ const Settings = {
if (customRow) { if (customRow) {
customRow.style.display = this.get('offline.tile_provider') === 'custom' ? 'block' : 'none'; customRow.style.display = this.get('offline.tile_provider') === 'custom' ? 'block' : 'none';
} }
// Theme select
const themeSelect = document.getElementById('themeSelect');
if (themeSelect) {
themeSelect.value = localStorage.getItem('intercept-theme') || 'dark';
}
// Animations toggle
const animationsEnabled = document.getElementById('animationsEnabled');
if (animationsEnabled) {
animationsEnabled.checked = localStorage.getItem('intercept-animations') !== 'off';
}
}, },
/** /**
* Update map tiles on all known maps * Update map tiles on all known maps
*/ */
_updateMapTiles() { _updateMapTiles() {
// Combine registered maps with common window map variables const allMaps = this._collectMaps();
const windowMaps = [
window.map,
window.leafletMap,
window.aprsMap,
window.radarMap,
window.vesselMap,
window.groundMap,
window.groundTrackMap,
window.meshMap,
window.issMap
].filter(m => m && typeof m.eachLayer === 'function');
// Combine with registered maps, removing duplicates
const allMaps = [...new Set([...this._registeredMaps, ...windowMaps])];
if (allMaps.length === 0) return; if (allMaps.length === 0) return;
const config = this.getTileConfig(); const config = this.getTileConfig();
this._syncRootMapThemeClass(config);
allMaps.forEach(map => { allMaps.forEach(map => {
// Remove existing tile layers // Remove existing tile layers
@@ -368,7 +692,10 @@ const Settings = {
} }
L.tileLayer(config.url, options).addTo(map); L.tileLayer(config.url, options).addTo(map);
this._applyMapTheme(map, config);
}); });
this._applyThemeToAllContainers(config);
}, },
/** /**
@@ -423,10 +750,16 @@ const Settings = {
}; };
// Settings modal functions // Settings modal functions
let lastSettingsFocusEl = null;
function showSettings() { function showSettings() {
const modal = document.getElementById('settingsModal'); const modal = document.getElementById('settingsModal');
if (modal) { if (modal) {
lastSettingsFocusEl = document.activeElement;
modal.classList.add('active'); modal.classList.add('active');
modal.setAttribute('aria-hidden', 'false');
const content = modal.querySelector('.settings-content');
if (content) content.focus();
Settings.init().then(() => { Settings.init().then(() => {
Settings.checkAssets(); Settings.checkAssets();
}); });
@@ -437,18 +770,27 @@ function hideSettings() {
const modal = document.getElementById('settingsModal'); const modal = document.getElementById('settingsModal');
if (modal) { if (modal) {
modal.classList.remove('active'); modal.classList.remove('active');
modal.setAttribute('aria-hidden', 'true');
if (lastSettingsFocusEl && typeof lastSettingsFocusEl.focus === 'function') {
lastSettingsFocusEl.focus();
}
} }
} }
function switchSettingsTab(tabName) { function switchSettingsTab(tabName) {
// Update tab buttons // Update tab buttons
document.querySelectorAll('.settings-tab').forEach(tab => { document.querySelectorAll('.settings-tab').forEach(tab => {
tab.classList.toggle('active', tab.dataset.tab === tabName); const isActive = tab.dataset.tab === tabName;
tab.classList.toggle('active', isActive);
tab.setAttribute('aria-selected', isActive ? 'true' : 'false');
}); });
// Update sections // Update sections
document.querySelectorAll('.settings-section').forEach(section => { document.querySelectorAll('.settings-section').forEach(section => {
section.classList.toggle('active', section.id === `settings-${tabName}`); const isActive = section.id === `settings-${tabName}`;
section.classList.toggle('active', isActive);
section.hidden = !isActive;
section.setAttribute('role', 'tabpanel');
}); });
// Load tools/dependencies when that tab is selected // Load tools/dependencies when that tab is selected
@@ -545,11 +887,6 @@ function loadSettingsTools() {
}); });
} }
// Initialize settings on page load
document.addEventListener('DOMContentLoaded', () => {
Settings.init();
});
// ============================================================================= // =============================================================================
// Location Settings Functions // Location Settings Functions
// ============================================================================= // =============================================================================
@@ -582,7 +919,7 @@ function loadObserverLocation() {
} }
// Sync dashboard-specific location keys for backward compatibility // Sync dashboard-specific location keys for backward compatibility
if (lat && lon) { if (lat !== undefined && lat !== null && lat !== '' && lon !== undefined && lon !== null && lon !== '') {
const locationObj = JSON.stringify({ lat: parseFloat(lat), lon: parseFloat(lon) }); const locationObj = JSON.stringify({ lat: parseFloat(lat), lon: parseFloat(lon) });
if (!localStorage.getItem('observerLocation')) { if (!localStorage.getItem('observerLocation')) {
localStorage.setItem('observerLocation', locationObj); localStorage.setItem('observerLocation', locationObj);
@@ -833,11 +1170,11 @@ function renderUpdateStatus(data) {
<div style="display: grid; gap: 8px; font-size: 12px;"> <div style="display: grid; gap: 8px; font-size: 12px;">
<div style="display: flex; justify-content: space-between;"> <div style="display: flex; justify-content: space-between;">
<span style="color: var(--text-dim);">Current Version</span> <span style="color: var(--text-dim);">Current Version</span>
<span style="font-family: 'Space Mono', monospace; color: var(--text-primary);">v${data.current_version}</span> <span style="font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; color: var(--text-primary);">v${data.current_version}</span>
</div> </div>
<div style="display: flex; justify-content: space-between;"> <div style="display: flex; justify-content: space-between;">
<span style="color: var(--text-dim);">Latest Version</span> <span style="color: var(--text-dim);">Latest Version</span>
<span style="font-family: 'Space Mono', monospace; color: ${data.update_available ? 'var(--accent-green)' : 'var(--text-primary)'};">v${data.latest_version}</span> <span style="font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; color: ${data.update_available ? 'var(--accent-green)' : 'var(--text-primary)'};">v${data.latest_version}</span>
</div> </div>
${data.last_check ? ` ${data.last_check ? `
<div style="display: flex; justify-content: space-between;"> <div style="display: flex; justify-content: space-between;">
@@ -907,12 +1244,17 @@ const _originalSwitchSettingsTab = typeof switchSettingsTab !== 'undefined' ? sw
function switchSettingsTab(tabName) { function switchSettingsTab(tabName) {
// Update tab buttons // Update tab buttons
document.querySelectorAll('.settings-tab').forEach(tab => { document.querySelectorAll('.settings-tab').forEach(tab => {
tab.classList.toggle('active', tab.dataset.tab === tabName); const isActive = tab.dataset.tab === tabName;
tab.classList.toggle('active', isActive);
tab.setAttribute('aria-selected', isActive ? 'true' : 'false');
}); });
// Update sections // Update sections
document.querySelectorAll('.settings-section').forEach(section => { document.querySelectorAll('.settings-section').forEach(section => {
section.classList.toggle('active', section.id === `settings-${tabName}`); const isActive = section.id === `settings-${tabName}`;
section.classList.toggle('active', isActive);
section.hidden = !isActive;
section.setAttribute('role', 'tabpanel');
}); });
// Load content based on tab // Load content based on tab
@@ -983,3 +1325,45 @@ function toggleApiKeyVisibility() {
if (!input) return; if (!input) return;
input.type = input.type === 'password' ? 'text' : 'password'; input.type = input.type === 'password' ? 'text' : 'password';
} }
/**
* Set theme preference from the Display settings tab
*/
function setThemePreference(value) {
document.documentElement.setAttribute('data-theme', value);
localStorage.setItem('intercept-theme', value);
const btn = document.getElementById('themeToggle');
if (btn) {
btn.textContent = value === 'light' ? '🌙' : '☀️';
}
fetch('/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ theme: value })
}).catch(() => {});
}
/**
* Set animations preference from the Display settings tab
*/
function setAnimationsEnabled(enabled) {
if (enabled) {
document.documentElement.removeAttribute('data-animations');
} else {
document.documentElement.setAttribute('data-animations', 'off');
}
localStorage.setItem('intercept-animations', enabled ? 'on' : 'off');
}
if (!window._settingsEscapeHandlerBound) {
window._settingsEscapeHandlerBound = true;
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape') return;
const modal = document.getElementById('settingsModal');
if (modal && modal.classList.contains('active')) {
hideSettings();
}
});
}
+248
View File
@@ -0,0 +1,248 @@
const AppFeedback = (function() {
'use strict';
let stackEl = null;
let nextToastId = 1;
function init() {
ensureStack();
installGlobalHandlers();
}
function ensureStack() {
if (stackEl && document.body.contains(stackEl)) return stackEl;
stackEl = document.getElementById('appToastStack');
if (!stackEl) {
stackEl = document.createElement('div');
stackEl.id = 'appToastStack';
stackEl.className = 'app-toast-stack';
document.body.appendChild(stackEl);
}
return stackEl;
}
function toast(options) {
const opts = options || {};
const type = normalizeType(opts.type);
const id = nextToastId++;
const durationMs = Number.isFinite(opts.durationMs) ? opts.durationMs : 6500;
const root = document.createElement('div');
root.className = `app-toast ${type}`;
root.dataset.toastId = String(id);
const titleEl = document.createElement('div');
titleEl.className = 'app-toast-title';
titleEl.textContent = String(opts.title || defaultTitle(type));
root.appendChild(titleEl);
const msgEl = document.createElement('div');
msgEl.className = 'app-toast-msg';
msgEl.textContent = String(opts.message || '');
root.appendChild(msgEl);
const actions = Array.isArray(opts.actions) ? opts.actions.filter(Boolean).slice(0, 3) : [];
if (actions.length > 0) {
const actionsEl = document.createElement('div');
actionsEl.className = 'app-toast-actions';
for (const action of actions) {
const btn = document.createElement('button');
btn.type = 'button';
btn.textContent = String(action.label || 'Action');
btn.addEventListener('click', () => {
try {
if (typeof action.onClick === 'function') {
action.onClick();
}
} finally {
removeToast(id);
}
});
actionsEl.appendChild(btn);
}
root.appendChild(actionsEl);
}
ensureStack().appendChild(root);
if (durationMs > 0) {
window.setTimeout(() => {
removeToast(id);
}, durationMs);
}
return id;
}
function removeToast(id) {
if (!stackEl) return;
const toastEl = stackEl.querySelector(`[data-toast-id="${id}"]`);
if (!toastEl) return;
toastEl.remove();
}
function reportError(context, error, options) {
const opts = options || {};
const message = extractMessage(error);
const actions = [];
if (isSettingsError(message)) {
actions.push({
label: 'Open Settings',
onClick: () => {
if (typeof showSettings === 'function') {
showSettings();
}
}
});
}
if (isNetworkError(message)) {
actions.push({
label: 'Retry',
onClick: () => {
if (typeof opts.onRetry === 'function') {
opts.onRetry();
}
}
});
}
if (typeof opts.extraAction === 'function' && opts.extraActionLabel) {
actions.push({
label: String(opts.extraActionLabel),
onClick: opts.extraAction,
});
}
return toast({
type: 'error',
title: context || 'Action Failed',
message,
actions,
durationMs: opts.persistent ? 0 : 8500,
});
}
function installGlobalHandlers() {
window.addEventListener('error', (event) => {
const target = event && event.target;
if (target && (target.tagName === 'IMG' || target.tagName === 'SCRIPT')) {
return;
}
const message = extractMessage(event && event.error) || String(event.message || 'Unknown error');
if (shouldIgnore(message)) return;
toast({
type: 'warning',
title: 'Unhandled Error',
message,
});
});
window.addEventListener('unhandledrejection', (event) => {
const message = extractMessage(event && event.reason);
if (shouldIgnore(message)) return;
toast({
type: 'warning',
title: 'Promise Rejection',
message,
});
});
}
function normalizeType(type) {
const t = String(type || 'info').toLowerCase();
if (t === 'error' || t === 'warning') return t;
return 'info';
}
function defaultTitle(type) {
if (type === 'error') return 'Error';
if (type === 'warning') return 'Warning';
return 'Notice';
}
function extractMessage(error) {
if (!error) return 'Unknown error';
if (typeof error === 'string') return error;
if (error instanceof Error) return error.message || error.name;
if (typeof error.message === 'string') return error.message;
return String(error);
}
function shouldIgnore(message) {
const text = String(message || '').toLowerCase();
return text.includes('script error') || text.includes('resizeobserver loop limit exceeded');
}
function renderCollectionState(container, options) {
if (!container) return null;
const opts = options || {};
const type = String(opts.type || 'empty').toLowerCase();
const message = String(opts.message || (type === 'loading' ? 'Loading...' : 'No data available'));
const className = opts.className || `app-collection-state is-${type}`;
container.innerHTML = '';
if (container.tagName === 'TBODY') {
const row = document.createElement('tr');
row.className = 'app-collection-state-row';
const cell = document.createElement('td');
const columns = Number.isFinite(opts.columns) ? opts.columns : 1;
cell.colSpan = Math.max(1, columns);
const state = document.createElement('div');
state.className = className;
state.textContent = message;
cell.appendChild(state);
row.appendChild(cell);
container.appendChild(row);
return row;
}
const state = document.createElement('div');
state.className = className;
state.textContent = message;
container.appendChild(state);
return state;
}
function isNetworkError(message) {
const text = String(message || '').toLowerCase();
return text.includes('networkerror') || text.includes('failed to fetch') || text.includes('timeout');
}
function isSettingsError(message) {
const text = String(message || '').toLowerCase();
return text.includes('permission') || text.includes('denied') || text.includes('dependency') || text.includes('tool');
}
return {
init,
toast,
reportError,
removeToast,
renderCollectionState,
};
})();
window.showAppToast = function(title, message, type) {
return AppFeedback.toast({
title,
message,
type,
});
};
window.reportActionableError = function(context, error, options) {
return AppFeedback.reportError(context, error, options);
};
window.renderCollectionState = function(container, options) {
return AppFeedback.renderCollectionState(container, options);
};
document.addEventListener('DOMContentLoaded', () => {
AppFeedback.init();
});
+103 -75
View File
@@ -78,13 +78,14 @@ const Updater = {
* Show update toast notification * Show update toast notification
* @param {Object} data - Update data from server * @param {Object} data - Update data from server
*/ */
showUpdateToast(data) { showUpdateToast(data) {
// Remove existing toast if present // Remove existing toast if present
this.hideToast(); this.hideToast();
const latestVersion = this._escape(data.latest_version || '');
const toast = document.createElement('div');
toast.className = 'update-toast'; const toast = document.createElement('div');
toast.innerHTML = ` toast.className = 'update-toast';
toast.innerHTML = `
<div class="update-toast-indicator"></div> <div class="update-toast-indicator"></div>
<div class="update-toast-content"> <div class="update-toast-content">
<div class="update-toast-header"> <div class="update-toast-header">
@@ -97,11 +98,11 @@ const Updater = {
</span> </span>
<span class="update-toast-title">Update Available</span> <span class="update-toast-title">Update Available</span>
<button class="update-toast-close" onclick="Updater.dismissUpdate()">&times;</button> <button class="update-toast-close" onclick="Updater.dismissUpdate()">&times;</button>
</div> </div>
<div class="update-toast-body"> <div class="update-toast-body">
Version <strong>${data.latest_version}</strong> is ready Version <strong>${latestVersion}</strong> is ready
</div> </div>
<div class="update-toast-actions"> <div class="update-toast-actions">
<button class="update-toast-btn update-toast-btn-primary" onclick="Updater.showUpdateModal()"> <button class="update-toast-btn update-toast-btn-primary" onclick="Updater.showUpdateModal()">
View Details View Details
</button> </button>
@@ -172,14 +173,17 @@ const Updater = {
return; return;
} }
// Remove existing modal if present // Remove existing modal if present
this.hideModal(); this.hideModal();
const data = this._updateData; const data = this._updateData;
const releaseNotes = this._formatReleaseNotes(data.release_notes || 'No release notes available.'); const releaseNotes = this._formatReleaseNotes(data.release_notes || 'No release notes available.');
const safeCurrentVersion = this._escape(data.current_version || '');
const modal = document.createElement('div'); const safeLatestVersion = this._escape(data.latest_version || '');
modal.className = 'update-modal-overlay'; const safeReleaseUrl = this._safeUrl(data.release_url || '');
const modal = document.createElement('div');
modal.className = 'update-modal-overlay';
modal.onclick = (e) => { modal.onclick = (e) => {
if (e.target === modal) this.hideModal(); if (e.target === modal) this.hideModal();
}; };
@@ -201,21 +205,21 @@ const Updater = {
</div> </div>
<div class="update-modal-body"> <div class="update-modal-body">
<div class="update-version-info"> <div class="update-version-info">
<div class="update-version-current"> <div class="update-version-current">
<span class="update-version-label">Current</span> <span class="update-version-label">Current</span>
<span class="update-version-value">v${data.current_version}</span> <span class="update-version-value">v${safeCurrentVersion}</span>
</div> </div>
<div class="update-version-arrow"> <div class="update-version-arrow">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="5" y1="12" x2="19" y2="12"/> <line x1="5" y1="12" x2="19" y2="12"/>
<polyline points="12 5 19 12 12 19"/> <polyline points="12 5 19 12 12 19"/>
</svg> </svg>
</div> </div>
<div class="update-version-latest"> <div class="update-version-latest">
<span class="update-version-label">Latest</span> <span class="update-version-label">Latest</span>
<span class="update-version-value update-version-new">v${data.latest_version}</span> <span class="update-version-value update-version-new">v${safeLatestVersion}</span>
</div> </div>
</div> </div>
<div class="update-section"> <div class="update-section">
<div class="update-section-title">Release Notes</div> <div class="update-section-title">Release Notes</div>
@@ -249,11 +253,11 @@ const Updater = {
</div> </div>
<div class="update-result" id="updateResult" style="display: none;"></div> <div class="update-result" id="updateResult" style="display: none;"></div>
</div> </div>
<div class="update-modal-footer"> <div class="update-modal-footer">
<a href="${data.release_url || '#'}" target="_blank" class="update-modal-link" ${!data.release_url ? 'style="display:none"' : ''}> <a href="${safeReleaseUrl || '#'}" target="_blank" class="update-modal-link" ${!safeReleaseUrl ? 'style="display:none"' : ''}>
View on GitHub View on GitHub
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/> <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/> <polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/> <line x1="10" y1="14" x2="21" y2="3"/>
@@ -357,14 +361,16 @@ const Updater = {
/** /**
* Show update result * Show update result
*/ */
_showResult(resultEl, success, data, isManual = false) { _showResult(resultEl, success, data, isManual = false) {
if (!resultEl) return; if (!resultEl) return;
resultEl.style.display = 'block'; resultEl.style.display = 'block';
const safeMessage = this._escape(data.message || data.error || 'An error occurred during the update.');
if (success) { const safeDetails = data.details ? this._escape(String(data.details).substring(0, 200)) : '';
if (data.updated) {
let message = '<strong>Update successful!</strong><br>Please restart the application to complete the update.'; if (success) {
if (data.updated) {
let message = '<strong>Update successful!</strong><br>Please restart the application to complete the update.';
if (data.requirements_changed) { if (data.requirements_changed) {
message += '<br><br><strong>Dependencies changed!</strong> Run:<br><code>pip install -r requirements.txt</code>'; message += '<br><br><strong>Dependencies changed!</strong> Run:<br><code>pip install -r requirements.txt</code>';
@@ -380,22 +386,22 @@ const Updater = {
</div> </div>
<div class="update-result-text">${message}</div> <div class="update-result-text">${message}</div>
`; `;
} else { } else {
resultEl.className = 'update-result update-result-info'; resultEl.className = 'update-result update-result-info';
resultEl.innerHTML = ` resultEl.innerHTML = `
<div class="update-result-icon"> <div class="update-result-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/> <circle cx="12" cy="12" r="10"/>
<line x1="12" y1="16" x2="12" y2="12"/> <line x1="12" y1="16" x2="12" y2="12"/>
<line x1="12" y1="8" x2="12.01" y2="8"/> <line x1="12" y1="8" x2="12.01" y2="8"/>
</svg> </svg>
</div> </div>
<div class="update-result-text">${data.message || 'Already up to date.'}</div> <div class="update-result-text">${this._escape(data.message || 'Already up to date.')}</div>
`; `;
} }
} else { } else {
if (isManual) { if (isManual) {
resultEl.className = 'update-result update-result-warning'; resultEl.className = 'update-result update-result-warning';
resultEl.innerHTML = ` resultEl.innerHTML = `
<div class="update-result-icon"> <div class="update-result-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -403,14 +409,14 @@ const Updater = {
<line x1="12" y1="9" x2="12" y2="13"/> <line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="17" x2="12.01" y2="17"/> <line x1="12" y1="17" x2="12.01" y2="17"/>
</svg> </svg>
</div> </div>
<div class="update-result-text"> <div class="update-result-text">
<strong>Manual update required</strong><br> <strong>Manual update required</strong><br>
${data.message || 'Please download the latest release from GitHub.'} ${safeMessage || 'Please download the latest release from GitHub.'}
</div> </div>
`; `;
} else { } else {
resultEl.className = 'update-result update-result-error'; resultEl.className = 'update-result update-result-error';
resultEl.innerHTML = ` resultEl.innerHTML = `
<div class="update-result-icon"> <div class="update-result-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -418,16 +424,16 @@ const Updater = {
<line x1="15" y1="9" x2="9" y2="15"/> <line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/> <line x1="9" y1="9" x2="15" y2="15"/>
</svg> </svg>
</div> </div>
<div class="update-result-text"> <div class="update-result-text">
<strong>Update failed</strong><br> <strong>Update failed</strong><br>
${data.message || data.error || 'An error occurred during the update.'} ${safeMessage}
${data.details ? '<br><code style="font-size: 10px; margin-top: 8px; display: block;">' + data.details.substring(0, 200) + '</code>' : ''} ${safeDetails ? '<br><code style="font-size: 10px; margin-top: 8px; display: block;">' + safeDetails + '</code>' : ''}
</div> </div>
`; `;
} }
} }
}, },
/** /**
* Format release notes (basic markdown to HTML) * Format release notes (basic markdown to HTML)
@@ -461,11 +467,33 @@ const Updater = {
// Line breaks // Line breaks
.replace(/\n/g, '<br>'); .replace(/\n/g, '<br>');
// Wrap list items // Wrap list items
html = html.replace(/(<li>.*<\/li>)+/g, '<ul>$&</ul>'); html = html.replace(/(<li>.*<\/li>)+/g, '<ul>$&</ul>');
return '<p>' + html + '</p>'; return '<p>' + html + '</p>';
}, },
_escape(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
},
_safeUrl(url) {
if (!url) return '';
try {
const parsed = new URL(url, window.location.origin);
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
return parsed.href;
}
} catch (e) {
return '';
}
return '';
},
/** /**
* Manual trigger for settings panel * Manual trigger for settings panel
+549
View File
@@ -0,0 +1,549 @@
/**
* Analytics Dashboard Module
* Cross-mode summary, sparklines, alerts, correlations, target view, and replay.
*/
const Analytics = (function () {
'use strict';
let refreshTimer = null;
let replayTimer = null;
let replaySessions = [];
let replayEvents = [];
let replayIndex = 0;
function init() {
refresh();
loadReplaySessions();
if (!refreshTimer) {
refreshTimer = setInterval(refresh, 5000);
}
}
function destroy() {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
pauseReplay();
}
function refresh() {
Promise.all([
fetch('/analytics/summary').then(r => r.json()).catch(() => null),
fetch('/analytics/activity').then(r => r.json()).catch(() => null),
fetch('/analytics/insights').then(r => r.json()).catch(() => null),
fetch('/analytics/patterns').then(r => r.json()).catch(() => null),
fetch('/alerts/events?limit=20').then(r => r.json()).catch(() => null),
fetch('/correlation').then(r => r.json()).catch(() => null),
fetch('/analytics/geofences').then(r => r.json()).catch(() => null),
]).then(([summary, activity, insights, patterns, alerts, correlations, geofences]) => {
if (summary) renderSummary(summary);
if (activity) renderSparklines(activity.sparklines || {});
if (insights) renderInsights(insights);
if (patterns) renderPatterns(patterns.patterns || []);
if (alerts) renderAlerts(alerts.events || []);
if (correlations) renderCorrelations(correlations);
if (geofences) renderGeofences(geofences.zones || []);
});
}
function renderSummary(data) {
const counts = data.counts || {};
_setText('analyticsCountAdsb', counts.adsb || 0);
_setText('analyticsCountAis', counts.ais || 0);
_setText('analyticsCountWifi', counts.wifi || 0);
_setText('analyticsCountBt', counts.bluetooth || 0);
_setText('analyticsCountDsc', counts.dsc || 0);
_setText('analyticsCountAcars', counts.acars || 0);
_setText('analyticsCountVdl2', counts.vdl2 || 0);
_setText('analyticsCountAprs', counts.aprs || 0);
_setText('analyticsCountMesh', counts.meshtastic || 0);
const health = data.health || {};
const container = document.getElementById('analyticsHealth');
if (container) {
let html = '';
const modeLabels = {
pager: 'Pager', sensor: '433MHz', adsb: 'ADS-B', ais: 'AIS',
acars: 'ACARS', vdl2: 'VDL2', aprs: 'APRS', wifi: 'WiFi',
bluetooth: 'BT', dsc: 'DSC', meshtastic: 'Mesh'
};
for (const [mode, info] of Object.entries(health)) {
if (mode === 'sdr_devices') continue;
const running = info && info.running;
const label = modeLabels[mode] || mode;
html += '<div class="health-item"><span class="health-dot' + (running ? ' running' : '') + '"></span>' + _esc(label) + '</div>';
}
container.innerHTML = html;
}
const squawks = data.squawks || [];
const sqSection = document.getElementById('analyticsSquawkSection');
const sqList = document.getElementById('analyticsSquawkList');
if (sqSection && sqList) {
if (squawks.length > 0) {
sqSection.style.display = '';
sqList.innerHTML = squawks.map(s =>
'<div class="squawk-item"><strong>' + _esc(s.squawk) + '</strong> ' +
_esc(s.meaning) + ' - ' + _esc(s.callsign || s.icao) + '</div>'
).join('');
} else {
sqSection.style.display = 'none';
}
}
}
function renderSparklines(sparklines) {
const map = {
adsb: 'analyticsSparkAdsb',
ais: 'analyticsSparkAis',
wifi: 'analyticsSparkWifi',
bluetooth: 'analyticsSparkBt',
dsc: 'analyticsSparkDsc',
acars: 'analyticsSparkAcars',
vdl2: 'analyticsSparkVdl2',
aprs: 'analyticsSparkAprs',
meshtastic: 'analyticsSparkMesh',
};
for (const [mode, elId] of Object.entries(map)) {
const el = document.getElementById(elId);
if (!el) continue;
const data = sparklines[mode] || [];
if (data.length < 2) {
el.innerHTML = '';
continue;
}
const max = Math.max(...data, 1);
const w = 100;
const h = 24;
const step = w / (data.length - 1);
const points = data.map((v, i) =>
(i * step).toFixed(1) + ',' + (h - (v / max) * (h - 2)).toFixed(1)
).join(' ');
el.innerHTML = '<svg viewBox="0 0 ' + w + ' ' + h + '" preserveAspectRatio="none"><polyline points="' + points + '"/></svg>';
}
}
function renderInsights(data) {
const cards = data.cards || [];
const topChanges = data.top_changes || [];
const cardsEl = document.getElementById('analyticsInsights');
const changesEl = document.getElementById('analyticsTopChanges');
if (cardsEl) {
if (!cards.length) {
cardsEl.innerHTML = '<div class="analytics-empty">No insight data available</div>';
} else {
cardsEl.innerHTML = cards.map(c => {
const sev = _esc(c.severity || 'low');
const title = _esc(c.title || 'Insight');
const value = _esc(c.value || '--');
const label = _esc(c.label || '');
const detail = _esc(c.detail || '');
return '<div class="analytics-insight-card ' + sev + '">' +
'<div class="insight-title">' + title + '</div>' +
'<div class="insight-value">' + value + '</div>' +
'<div class="insight-label">' + label + '</div>' +
'<div class="insight-detail">' + detail + '</div>' +
'</div>';
}).join('');
}
}
if (changesEl) {
if (!topChanges.length) {
changesEl.innerHTML = '<div class="analytics-empty">No change signals yet</div>';
} else {
changesEl.innerHTML = topChanges.map(item => {
const mode = _esc(item.mode_label || item.mode || '');
const deltaRaw = Number(item.delta || 0);
const trendClass = deltaRaw > 0 ? 'up' : (deltaRaw < 0 ? 'down' : 'flat');
const delta = _esc(item.signed_delta || String(deltaRaw));
const recentAvg = _esc(item.recent_avg);
const prevAvg = _esc(item.previous_avg);
return '<div class="analytics-change-row">' +
'<span class="mode">' + mode + '</span>' +
'<span class="delta ' + trendClass + '">' + delta + '</span>' +
'<span class="avg">avg ' + recentAvg + ' vs ' + prevAvg + '</span>' +
'</div>';
}).join('');
}
}
}
function renderPatterns(patterns) {
const container = document.getElementById('analyticsPatternList');
if (!container) return;
if (!patterns || patterns.length === 0) {
container.innerHTML = '<div class="analytics-empty">No recurring patterns detected</div>';
return;
}
const modeLabels = {
adsb: 'ADS-B', ais: 'AIS', wifi: 'WiFi', bluetooth: 'Bluetooth',
dsc: 'DSC', acars: 'ACARS', vdl2: 'VDL2', aprs: 'APRS', meshtastic: 'Meshtastic',
};
const sorted = patterns
.slice()
.sort((a, b) => (b.confidence || 0) - (a.confidence || 0))
.slice(0, 20);
container.innerHTML = sorted.map(p => {
const confidencePct = Math.round((Number(p.confidence || 0)) * 100);
const mode = modeLabels[p.mode] || (p.mode || '--').toUpperCase();
const period = _humanPeriod(Number(p.period_seconds || 0));
const occurrences = Number(p.occurrences || 0);
const deviceId = _shortId(p.device_id || '--');
return '<div class="analytics-pattern-item">' +
'<div class="pattern-main">' +
'<span class="pattern-mode">' + _esc(mode) + '</span>' +
'<span class="pattern-device">' + _esc(deviceId) + '</span>' +
'</div>' +
'<div class="pattern-meta">' +
'<span>Period: ' + _esc(period) + '</span>' +
'<span>Hits: ' + _esc(occurrences) + '</span>' +
'<span class="pattern-confidence">' + _esc(confidencePct) + '%</span>' +
'</div>' +
'</div>';
}).join('');
}
function renderAlerts(events) {
const container = document.getElementById('analyticsAlertFeed');
if (!container) return;
if (!events || events.length === 0) {
container.innerHTML = '<div class="analytics-empty">No recent alerts</div>';
return;
}
container.innerHTML = events.slice(0, 20).map(e => {
const sev = e.severity || 'medium';
const title = e.title || e.event_type || 'Alert';
const time = e.created_at ? new Date(e.created_at).toLocaleTimeString() : '';
return '<div class="analytics-alert-item">' +
'<span class="alert-severity ' + _esc(sev) + '">' + _esc(sev) + '</span>' +
'<span>' + _esc(title) + '</span>' +
'<span style="margin-left:auto;color:var(--text-dim)">' + _esc(time) + '</span>' +
'</div>';
}).join('');
}
function renderCorrelations(data) {
const container = document.getElementById('analyticsCorrelations');
if (!container) return;
const pairs = (data && data.correlations) || [];
if (pairs.length === 0) {
container.innerHTML = '<div class="analytics-empty">No correlations detected</div>';
return;
}
container.innerHTML = pairs.slice(0, 20).map(p => {
const conf = Math.round((p.confidence || 0) * 100);
return '<div class="analytics-correlation-pair">' +
'<span>' + _esc(p.wifi_mac || '') + '</span>' +
'<span style="color:var(--text-dim)">&#8596;</span>' +
'<span>' + _esc(p.bt_mac || '') + '</span>' +
'<div class="confidence-bar"><div class="confidence-fill" style="width:' + conf + '%"></div></div>' +
'<span style="color:var(--text-dim)">' + conf + '%</span>' +
'</div>';
}).join('');
}
function renderGeofences(zones) {
const container = document.getElementById('analyticsGeofenceList');
if (!container) return;
if (!zones || zones.length === 0) {
container.innerHTML = '<div class="analytics-empty">No geofence zones defined</div>';
return;
}
container.innerHTML = zones.map(z =>
'<div class="geofence-zone-item">' +
'<span class="zone-name">' + _esc(z.name) + '</span>' +
'<span class="zone-radius">' + z.radius_m + 'm</span>' +
'<button class="zone-delete" onclick="Analytics.deleteGeofence(' + z.id + ')">DEL</button>' +
'</div>'
).join('');
}
function addGeofence() {
const name = prompt('Zone name:');
if (!name) return;
const lat = parseFloat(prompt('Latitude:', '0'));
const lon = parseFloat(prompt('Longitude:', '0'));
const radius = parseFloat(prompt('Radius (meters):', '1000'));
if (isNaN(lat) || isNaN(lon) || isNaN(radius)) {
alert('Invalid input');
return;
}
fetch('/analytics/geofences', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, lat, lon, radius_m: radius }),
})
.then(r => r.json())
.then(() => refresh());
}
function deleteGeofence(id) {
if (!confirm('Delete this geofence zone?')) return;
fetch('/analytics/geofences/' + id, { method: 'DELETE' })
.then(r => r.json())
.then(() => refresh());
}
function exportData(mode) {
const m = mode || (document.getElementById('exportMode') || {}).value || 'adsb';
const f = (document.getElementById('exportFormat') || {}).value || 'json';
window.open('/analytics/export/' + encodeURIComponent(m) + '?format=' + encodeURIComponent(f), '_blank');
}
function searchTarget() {
const input = document.getElementById('analyticsTargetQuery');
const summaryEl = document.getElementById('analyticsTargetSummary');
const q = (input && input.value || '').trim();
if (!q) {
if (summaryEl) summaryEl.textContent = 'Enter a search value to correlate entities';
renderTargetResults([]);
return;
}
fetch('/analytics/target?q=' + encodeURIComponent(q) + '&limit=120')
.then((r) => r.json())
.then((data) => {
const results = data.results || [];
if (summaryEl) {
const modeCounts = data.mode_counts || {};
const bits = Object.entries(modeCounts).map(([mode, count]) => `${mode}: ${count}`).join(' | ');
summaryEl.textContent = `${results.length} results${bits ? ' | ' + bits : ''}`;
}
renderTargetResults(results);
})
.catch((err) => {
if (summaryEl) summaryEl.textContent = 'Search failed';
if (typeof reportActionableError === 'function') {
reportActionableError('Target View Search', err, { onRetry: searchTarget });
}
});
}
function renderTargetResults(results) {
const container = document.getElementById('analyticsTargetResults');
if (!container) return;
if (!results || !results.length) {
container.innerHTML = '<div class="analytics-empty">No matching entities</div>';
return;
}
container.innerHTML = results.map((item) => {
const title = _esc(item.title || item.id || 'Entity');
const subtitle = _esc(item.subtitle || '');
const mode = _esc(item.mode || 'unknown');
const confidence = item.confidence != null ? `Confidence ${_esc(Math.round(Number(item.confidence) * 100))}%` : '';
const lastSeen = _esc(item.last_seen || '');
return '<div class="analytics-target-item">' +
'<div class="title"><span class="mode">' + mode + '</span><span>' + title + '</span></div>' +
'<div class="meta"><span>' + subtitle + '</span>' +
(lastSeen ? '<span>Last seen ' + lastSeen + '</span>' : '') +
(confidence ? '<span>' + confidence + '</span>' : '') +
'</div>' +
'</div>';
}).join('');
}
function loadReplaySessions() {
const select = document.getElementById('analyticsReplaySelect');
if (!select) return;
fetch('/recordings?limit=60')
.then((r) => r.json())
.then((data) => {
replaySessions = (data.recordings || []).filter((rec) => Number(rec.event_count || 0) > 0);
if (!replaySessions.length) {
select.innerHTML = '<option value="">No recordings</option>';
return;
}
select.innerHTML = replaySessions.map((rec) => {
const label = `${rec.mode} | ${(rec.label || 'session')} | ${new Date(rec.started_at).toLocaleString()}`;
return `<option value="${_esc(rec.id)}">${_esc(label)}</option>`;
}).join('');
const pendingReplay = localStorage.getItem('analyticsReplaySession');
if (pendingReplay && replaySessions.some((rec) => rec.id === pendingReplay)) {
select.value = pendingReplay;
localStorage.removeItem('analyticsReplaySession');
loadReplay();
}
})
.catch((err) => {
if (typeof reportActionableError === 'function') {
reportActionableError('Load Replay Sessions', err, { onRetry: loadReplaySessions });
}
});
}
function loadReplay() {
pauseReplay();
replayEvents = [];
replayIndex = 0;
const select = document.getElementById('analyticsReplaySelect');
const meta = document.getElementById('analyticsReplayMeta');
const timeline = document.getElementById('analyticsReplayTimeline');
if (!select || !meta || !timeline) return;
const id = select.value;
if (!id) {
meta.textContent = 'Select a recording';
timeline.innerHTML = '<div class="analytics-empty">No recording selected</div>';
return;
}
meta.textContent = 'Loading replay events...';
fetch('/recordings/' + encodeURIComponent(id) + '/events?limit=600')
.then((r) => r.json())
.then((data) => {
replayEvents = data.events || [];
replayIndex = 0;
if (!replayEvents.length) {
meta.textContent = 'No events found in selected recording';
timeline.innerHTML = '<div class="analytics-empty">No events to replay</div>';
return;
}
const rec = replaySessions.find((s) => s.id === id);
const mode = rec ? rec.mode : (data.recording && data.recording.mode) || 'unknown';
meta.textContent = `${replayEvents.length} events loaded | mode ${mode}`;
renderReplayWindow();
})
.catch((err) => {
meta.textContent = 'Replay load failed';
if (typeof reportActionableError === 'function') {
reportActionableError('Load Replay', err, { onRetry: loadReplay });
}
});
}
function playReplay() {
if (!replayEvents.length) {
loadReplay();
return;
}
if (replayTimer) return;
replayTimer = setInterval(() => {
if (replayIndex >= replayEvents.length - 1) {
pauseReplay();
return;
}
replayIndex += 1;
renderReplayWindow();
}, 260);
}
function pauseReplay() {
if (replayTimer) {
clearInterval(replayTimer);
replayTimer = null;
}
}
function stepReplay() {
if (!replayEvents.length) {
loadReplay();
return;
}
pauseReplay();
replayIndex = Math.min(replayIndex + 1, replayEvents.length - 1);
renderReplayWindow();
}
function renderReplayWindow() {
const timeline = document.getElementById('analyticsReplayTimeline');
const meta = document.getElementById('analyticsReplayMeta');
if (!timeline || !meta) return;
const total = replayEvents.length;
if (!total) {
timeline.innerHTML = '<div class="analytics-empty">No events to replay</div>';
return;
}
const start = Math.max(0, replayIndex - 15);
const end = Math.min(total, replayIndex + 20);
const windowed = replayEvents.slice(start, end);
timeline.innerHTML = windowed.map((row, i) => {
const absolute = start + i;
const active = absolute === replayIndex;
const eventType = _esc(row.event_type || 'event');
const mode = _esc(row.mode || '--');
const ts = _esc(row.timestamp ? new Date(row.timestamp).toLocaleTimeString() : '--');
const detail = summarizeReplayEvent(row.event || {});
return '<div class="analytics-replay-item" style="opacity:' + (active ? '1' : '0.65') + ';">' +
'<div class="title"><span class="mode">' + mode + '</span><span>' + eventType + '</span></div>' +
'<div class="meta"><span>' + ts + '</span><span>' + _esc(detail) + '</span></div>' +
'</div>';
}).join('');
meta.textContent = `Event ${replayIndex + 1}/${total}`;
}
function summarizeReplayEvent(event) {
if (!event || typeof event !== 'object') return 'No details';
if (event.callsign) return `Callsign ${event.callsign}`;
if (event.icao) return `ICAO ${event.icao}`;
if (event.ssid) return `SSID ${event.ssid}`;
if (event.bssid) return `BSSID ${event.bssid}`;
if (event.address) return `Address ${event.address}`;
if (event.name) return `Name ${event.name}`;
const keys = Object.keys(event);
if (!keys.length) return 'No fields';
return `${keys[0]}=${String(event[keys[0]]).slice(0, 40)}`;
}
function _setText(id, val) {
const el = document.getElementById(id);
if (el) el.textContent = val;
}
function _esc(s) {
if (typeof s !== 'string') s = String(s == null ? '' : s);
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function _shortId(value) {
const text = String(value || '');
if (text.length <= 18) return text;
return text.slice(0, 8) + '...' + text.slice(-6);
}
function _humanPeriod(seconds) {
if (!isFinite(seconds) || seconds <= 0) return '--';
if (seconds < 60) return Math.round(seconds) + 's';
const mins = seconds / 60;
if (mins < 60) return mins.toFixed(mins < 10 ? 1 : 0) + 'm';
const hours = mins / 60;
return hours.toFixed(hours < 10 ? 1 : 0) + 'h';
}
return {
init,
destroy,
refresh,
addGeofence,
deleteGeofence,
exportData,
searchTarget,
loadReplay,
playReplay,
pauseReplay,
stepReplay,
loadReplaySessions,
};
})();
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

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