Compare commits

...

58 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Affected functions:
- install_dump1090_from_source_debian
- install_acarsdec_from_source_debian
- install_dumpvdl2_from_source_debian
- install_aiscatcher_from_source_debian

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

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

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

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

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

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

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

Closes #139

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:11:33 +00:00
114 changed files with 13946 additions and 3558 deletions
+26
View File
@@ -2,6 +2,32 @@
All notable changes to iNTERCEPT will be documented in this file. All notable changes to iNTERCEPT will be documented in this file.
## [2.21.1] - 2026-02-20
### Fixed
- BT Locate map first-load rendering race that could cause blank/late map initialization
- BT Locate mode switch timing so Leaflet invalidation runs after panel visibility settles
- BT Locate trail restore startup latency by batching historical GPS point rendering
---
## [2.21.0] - 2026-02-20
### Added
- Analytics panels for operational insights and temporal pattern analysis
### Changed
- Global map theme refresh with improved contrast and cross-dashboard consistency
- Cross-app UX refinements for accessibility, mode consistency, and render performance
- BT Locate enhancements including improved continuity, smoothing, and confidence reporting
### Fixed
- Weather satellite auto-scheduler and Mercator tracking reliability issues
- Bluetooth/WiFi runtime health issues affecting scanner continuity
- ADS-B SSE multi-client fanout stability and remote VDL2 streaming reliability
---
## [2.15.0] - 2026-02-09 ## [2.15.0] - 2026-02-09
### Added ### Added
+1
View File
@@ -48,6 +48,7 @@ Support the developer of this open-source project
- **GPS** - Real-time GPS position tracking with live map, speed, altitude, and satellite info - **GPS** - Real-time GPS position tracking with live map, speed, altitude, and satellite info
- **TSCM** - Counter-surveillance with RF baseline comparison and threat detection - **TSCM** - Counter-surveillance with RF baseline comparison and threat detection
- **Meshtastic** - LoRa mesh network integration - **Meshtastic** - LoRa mesh network integration
- **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
+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"
} }
+61 -5
View File
@@ -671,10 +671,66 @@ def _get_dmr_active() -> bool:
return False 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') @app.route('/health')
def health_check() -> Response: def health_check() -> Response:
"""Health check endpoint for monitoring.""" """Health check endpoint for monitoring."""
import time import time
bt_active, bt_device_count = _get_bluetooth_health()
wifi_active, wifi_network_count, wifi_client_count = _get_wifi_health()
return jsonify({ return jsonify({
'status': 'healthy', 'status': 'healthy',
'version': VERSION, 'version': VERSION,
@@ -687,8 +743,8 @@ def health_check() -> Response:
'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),
'vdl2': vdl2_process is not None and (vdl2_process.poll() is None if vdl2_process else False), 'vdl2': vdl2_process is not None and (vdl2_process.poll() is None if vdl2_process else False),
'aprs': aprs_process is not None and (aprs_process.poll() is None if aprs_process else False), 'aprs': aprs_process is not None and (aprs_process.poll() is None if aprs_process else False),
'wifi': wifi_process is not None and (wifi_process.poll() is None if wifi_process else False), 'wifi': wifi_active,
'bluetooth': bt_process is not None and (bt_process.poll() is None if bt_process else False), 'bluetooth': bt_active,
'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False), 'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False),
'dmr': _get_dmr_active(), 'dmr': _get_dmr_active(),
'subghz': _get_subghz_active(), 'subghz': _get_subghz_active(),
@@ -696,9 +752,9 @@ def health_check() -> Response:
'data': { 'data': {
'aircraft_count': len(adsb_aircraft), 'aircraft_count': len(adsb_aircraft),
'vessel_count': len(ais_vessels), 'vessel_count': len(ais_vessels),
'wifi_networks_count': len(wifi_networks), 'wifi_networks_count': wifi_network_count,
'wifi_clients_count': len(wifi_clients), 'wifi_clients_count': wifi_client_count,
'bt_devices_count': len(bt_devices), 'bt_devices_count': bt_device_count,
'dsc_messages_count': len(dsc_messages), 'dsc_messages_count': len(dsc_messages),
} }
}) })
+33 -1
View File
@@ -7,10 +7,42 @@ import os
import sys import sys
# Application version # Application version
VERSION = "2.19.0" VERSION = "2.21.1"
# Changelog - latest release notes (shown on welcome screen) # Changelog - latest release notes (shown on welcome screen)
CHANGELOG = [ CHANGELOG = [
{
"version": "2.21.1",
"date": "February 2026",
"highlights": [
"BT Locate map first-load fix with render stabilization retries during initial mode open",
"BT Locate trail restore optimization for faster startup when historical GPS points exist",
"BT Locate mode-switch map invalidation timing fix to prevent delayed/blank map render",
]
},
{
"version": "2.21.0",
"date": "February 2026",
"highlights": [
"Global map theme refresh with improved contrast and cross-dashboard consistency",
"Cross-app UX updates for accessibility, mode consistency, and render performance",
"Weather satellite reliability fixes for auto-scheduler and Mercator pass tracking",
"Bluetooth/WiFi runtime health fixes with BT Locate continuity and confidence improvements",
"ADS-B/VDL2 streaming reliability upgrades for multi-client SSE fanout and remote decoding",
"Analytics enhancements with operational insights and temporal pattern panels",
]
},
{
"version": "2.20.0",
"date": "February 2026",
"highlights": [
"Space Weather mode: real-time solar and geomagnetic monitoring from NOAA SWPC, NASA SDO, and HamQSL",
"Kp index, solar wind, X-ray flux charts with Chart.js visualization",
"HF band conditions, D-RAP absorption maps, aurora forecast, and solar imagery",
"NOAA Space Weather Scales (G/S/R), flare probability, and active solar regions",
"No SDR hardware required — all data from public APIs with server-side caching",
]
},
{ {
"version": "2.19.0", "version": "2.19.0",
"date": "February 2026", "date": "February 2026",
+16
View File
@@ -165,6 +165,22 @@ Digital Selective Calling (DSC) monitoring on the international maritime distres
- **Real-time JSON output** with meter ID, consumption, and signal data - **Real-time JSON output** with meter ID, consumption, and signal data
- **Multiple meter protocol support** via rtl_tcp integration - **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
+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
--- ---
+19
View File
@@ -239,6 +239,25 @@ Enable the auto-scheduler to automatically capture passes:
- Starts SatDump at the correct time and frequency - Starts SatDump at the correct time and frequency
- Decoded images are saved with timestamps - 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 ## AIS Vessel Tracking
1. **Select Hardware** - Choose your SDR type 1. **Select Hardware** - Choose your SDR type
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 570 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

+36 -18
View File
@@ -61,72 +61,73 @@
<div class="carousel-filters"> <div class="carousel-filters">
<button class="filter-btn active" data-filter="all">All</button> <button class="filter-btn active" data-filter="all">All</button>
<button class="filter-btn" data-filter="sdr">SDR / RF</button> <button class="filter-btn" data-filter="signals">Signals</button>
<button class="filter-btn" data-filter="aviation">Aviation & Maritime</button> <button class="filter-btn" data-filter="tracking">Tracking</button>
<button class="filter-btn" data-filter="space">Space & Satellite</button> <button class="filter-btn" data-filter="space">Space</button>
<button class="filter-btn" data-filter="wireless">Wireless & Security</button> <button class="filter-btn" data-filter="wireless">Wireless</button>
<button class="filter-btn" data-filter="intel">Intel</button>
<button class="filter-btn" data-filter="platform">Platform</button> <button class="filter-btn" data-filter="platform">Platform</button>
</div> </div>
<div class="carousel-wrapper"> <div class="carousel-wrapper">
<button class="carousel-arrow carousel-arrow-left" aria-label="Scroll left">&#8249;</button> <button class="carousel-arrow carousel-arrow-left" aria-label="Scroll left">&#8249;</button>
<div class="carousel-track"> <div class="carousel-track">
<div class="feature-card" data-category="sdr"> <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> <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> <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> <p>Decode POCSAG and FLEX pager messages using rtl_fm and multimon-ng. Monitor emergency services and legacy paging systems.</p>
</div> </div>
<div class="feature-card" data-category="sdr"> <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> <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> <h3>433MHz Sensors</h3>
<p>Decode 200+ protocols including weather stations, TPMS, smart home devices, and IoT sensors via rtl_433.</p> <p>Decode 200+ protocols including weather stations, TPMS, smart home devices, and IoT sensors via rtl_433.</p>
</div> </div>
<div class="feature-card" data-category="sdr"> <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> <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> <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> <p>HackRF-based signal capture and protocol decoding for 300-928 MHz ISM bands with spectrum analysis and replay.</p>
</div> </div>
<div class="feature-card" data-category="sdr"> <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> <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> <h3>Listening Post</h3>
<p>Frequency scanner with real-time audio monitoring, fine-tuning controls, and customizable frequency presets.</p> <p>Frequency scanner with real-time audio monitoring, fine-tuning controls, and customizable frequency presets.</p>
</div> </div>
<div class="feature-card" data-category="sdr"> <div class="feature-card" data-category="intel">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></div> <div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></div>
<h3>WebSDR</h3> <h3>WebSDR</h3>
<p>Remote HF/shortwave listening via the KiwiSDR network. Access receivers worldwide with real-time audio streaming.</p> <p>Remote HF/shortwave listening via the KiwiSDR network. Access receivers worldwide with real-time audio streaming.</p>
</div> </div>
<div class="feature-card" data-category="sdr"> <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> <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> <h3>Spy Stations</h3>
<p>Number stations and diplomatic HF network database. Frequencies, schedules, and background info from priyom.org.</p> <p>Number stations and diplomatic HF network database. Frequencies, schedules, and background info from priyom.org.</p>
</div> </div>
<div class="feature-card" data-category="sdr"> <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> <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> <h3>APRS</h3>
<p>Amateur packet radio position reports and telemetry via direwolf. Track amateur radio operators on an interactive map.</p> <p>Amateur packet radio position reports and telemetry via direwolf. Track amateur radio operators on an interactive map.</p>
</div> </div>
<div class="feature-card" data-category="sdr"> <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> <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> <h3>Utility Meters</h3>
<p>Smart meter monitoring via rtl_amr. Receive electric, gas, and water meter broadcasts in real time.</p> <p>Smart meter monitoring via rtl_amr. Receive electric, gas, and water meter broadcasts in real time.</p>
</div> </div>
<div class="feature-card" data-category="aviation"> <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> <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> <h3>Aircraft Tracking</h3>
<p>Real-time ADS-B tracking with interactive maps, aircraft photos, emergency squawk detection, and range visualization.</p> <p>Real-time ADS-B tracking with interactive maps, aircraft photos, emergency squawk detection, and range visualization.</p>
</div> </div>
<div class="feature-card" data-category="aviation"> <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> <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> <h3>ACARS</h3>
<p>Aircraft datalink messages via acarsdec. Decode operational, weather, and position reports from commercial flights.</p> <p>Aircraft datalink messages via acarsdec. Decode operational, weather, and position reports from commercial flights.</p>
</div> </div>
<div class="feature-card" data-category="aviation"> <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> <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> <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> <p>VHF Data Link Mode 2 aircraft datalink decoding via dumpvdl2. Real-time ACARS-over-AVLC message capture with signal analysis.</p>
</div> </div>
<div class="feature-card" data-category="aviation"> <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> <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> <h3>Vessel Tracking</h3>
<p>Real-time AIS ship tracking via AIS-catcher. Monitor maritime traffic with vessel details, course, speed, and destination.</p> <p>Real-time AIS ship tracking via AIS-catcher. Monitor maritime traffic with vessel details, course, speed, and destination.</p>
@@ -151,11 +152,16 @@
<h3>HF SSTV</h3> <h3>HF SSTV</h3>
<p>Terrestrial SSTV on shortwave frequencies. Decode amateur radio image transmissions across HF, VHF, and UHF bands.</p> <p>Terrestrial SSTV on shortwave frequencies. Decode amateur radio image transmissions across HF, VHF, and UHF bands.</p>
</div> </div>
<div class="feature-card" data-category="space"> <div class="feature-card" data-category="tracking">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/><path d="M12 8l4 4-4 4"/></svg></div> <div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/><path d="M12 8l4 4-4 4"/></svg></div>
<h3>GPS Tracking</h3> <h3>GPS Tracking</h3>
<p>Real-time GPS position tracking with live map, speed, heading, altitude, satellite info, and track recording.</p> <p>Real-time GPS position tracking with live map, speed, heading, altitude, satellite info, and track recording.</p>
</div> </div>
<div class="feature-card" data-category="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-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> <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> <h3>WiFi Scanning</h3>
@@ -171,7 +177,7 @@
<h3>BT Locate</h3> <h3>BT Locate</h3>
<p>SAR Bluetooth device location with GPS-tagged signal trail mapping, IRK-based RPA resolution, and proximity audio alerts.</p> <p>SAR Bluetooth device location with GPS-tagged signal trail mapping, IRK-based RPA resolution, and proximity audio alerts.</p>
</div> </div>
<div class="feature-card" data-category="wireless"> <div class="feature-card" data-category="intel">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s-8-4.5-8-11.8A8 8 0 0 1 12 2a8 8 0 0 1 8 8.2c0 7.3-8 11.8-8 11.8z"/><circle cx="12" cy="10" r="3"/><path d="M12 2v3"/><path d="M4.93 4.93l2.12 2.12"/><path d="M20 12h-3"/></svg></div> <div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s-8-4.5-8-11.8A8 8 0 0 1 12 2a8 8 0 0 1 8 8.2c0 7.3-8 11.8-8 11.8z"/><circle cx="12" cy="10" r="3"/><path d="M12 2v3"/><path d="M4.93 4.93l2.12 2.12"/><path d="M20 12h-3"/></svg></div>
<h3>TSCM</h3> <h3>TSCM</h3>
<p>Counter-surveillance with baseline recording, threat detection, device correlation, and risk scoring.</p> <p>Counter-surveillance with baseline recording, threat detection, device correlation, and risk scoring.</p>
@@ -257,6 +263,10 @@
<img src="images/websdr.png" alt="WebSDR Remote Listening"> <img src="images/websdr.png" alt="WebSDR Remote Listening">
<span class="screenshot-label">WebSDR</span> <span class="screenshot-label">WebSDR</span>
</div> </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"> <div class="screenshot-item">
<img src="images/vdl2.png" alt="VDL2 Aircraft Datalink"> <img src="images/vdl2.png" alt="VDL2 Aircraft Datalink">
<span class="screenshot-label">VDL2 Aircraft Datalink</span> <span class="screenshot-label">VDL2 Aircraft Datalink</span>
@@ -265,6 +275,14 @@
<img src="images/weather-satellite.png" alt="Weather Satellite Decoder"> <img src="images/weather-satellite.png" alt="Weather Satellite Decoder">
<span class="screenshot-label">Weather Satellite</span> <span class="screenshot-label">Weather Satellite</span>
</div> </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"> <div class="screenshot-item">
<img src="images/satellite-tracker.png" alt="Satellite Tracker"> <img src="images/satellite-tracker.png" alt="Satellite Tracker">
<span class="screenshot-label">Satellite Tracker</span> <span class="screenshot-label">Satellite Tracker</span>
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "intercept" name = "intercept"
version = "2.19.0" version = "2.21.1"
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"
+4
View File
@@ -35,6 +35,8 @@ def register_blueprints(app):
from .recordings import recordings_bp from .recordings import recordings_bp
from .subghz import subghz_bp from .subghz import subghz_bp
from .bt_locate import bt_locate_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)
@@ -69,6 +71,8 @@ def register_blueprints(app):
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(subghz_bp) # SubGHz transceiver (HackRF)
app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking
app.register_blueprint(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
+23 -25
View File
@@ -21,7 +21,7 @@ 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.sdr import SDRFactory, SDRType from utils.sdr import SDRFactory, SDRType
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 (
PROCESS_TERMINATE_TIMEOUT, PROCESS_TERMINATE_TIMEOUT,
@@ -35,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
@@ -129,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:
@@ -410,25 +414,19 @@ def stop_acars() -> Response:
@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()
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
@@ -440,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'],
} }
+102 -15
View File
@@ -77,6 +77,11 @@ _sbs_error_logged = False # Suppress repeated connection error logs
# Track ICAOs already looked up in aircraft database (avoid repeated lookups) # Track ICAOs already looked up in aircraft database (avoid repeated lookups)
_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()
@@ -203,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
@@ -439,6 +469,12 @@ def parse_sbs_stream(service_addr):
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
@@ -456,6 +492,14 @@ def parse_sbs_stream(service_addr):
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)
@@ -467,7 +511,7 @@ def parse_sbs_stream(service_addr):
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
}) })
@@ -488,6 +532,19 @@ def parse_sbs_stream(service_addr):
'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
@@ -553,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
@@ -844,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'
@@ -1103,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})
+55 -20
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 (
@@ -124,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
@@ -282,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
@@ -478,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'})
+28 -19
View File
@@ -21,7 +21,7 @@ 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.sdr import SDRFactory, SDRType
from utils.constants import ( from utils.constants import (
@@ -47,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
@@ -1378,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(
@@ -1751,25 +1766,19 @@ def stop_aprs() -> Response:
@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()
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
+13 -20
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
@@ -556,26 +556,19 @@ 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
while True: response = Response(
try: sse_stream_fanout(
msg = app_module.bt_queue.get(timeout=1) source_queue=app_module.bt_queue,
last_keepalive = time.time() channel_key='bluetooth',
try: timeout=1.0,
process_event('bluetooth', 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'
+18 -2
View File
@@ -38,6 +38,8 @@ def start_session():
- name_pattern: Target name substring (optional) - name_pattern: Target name substring (optional)
- irk_hex: Identity Resolving Key hex string (optional) - irk_hex: Identity Resolving Key hex string (optional)
- device_id: Device ID from Bluetooth scanner (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_name: Hand-off device name (optional)
- known_manufacturer: Hand-off manufacturer (optional) - known_manufacturer: Hand-off manufacturer (optional)
- last_known_rssi: Hand-off last RSSI (optional) - last_known_rssi: Hand-off last RSSI (optional)
@@ -55,14 +57,28 @@ def start_session():
name_pattern=data.get('name_pattern'), name_pattern=data.get('name_pattern'),
irk_hex=data.get('irk_hex'), irk_hex=data.get('irk_hex'),
device_id=data.get('device_id'), 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_name=data.get('known_name'),
known_manufacturer=data.get('known_manufacturer'), known_manufacturer=data.get('known_manufacturer'),
last_known_rssi=data.get('last_known_rssi'), last_known_rssi=data.get('last_known_rssi'),
) )
# At least one identifier required # At least one identifier required
if not any([target.mac_address, target.name_pattern, target.irk_hex, target.device_id]): if not any([
return jsonify({'error': 'At least one target identifier required (mac_address, name_pattern, irk_hex, or device_id)'}), 400 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 # Parse environment
env_str = data.get('environment', 'OUTDOOR').upper() env_str = data.get('environment', 'OUTDOOR').upper()
+49 -25
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
@@ -38,8 +39,26 @@ 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',
@@ -681,20 +697,28 @@ def stream_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.
""" """
client_queue: queue.Queue = queue.Queue(maxsize=_AGENT_STREAM_CLIENT_QUEUE_SIZE)
with _agent_stream_subscribers_lock:
_agent_stream_subscribers.add(client_queue)
def generate() -> Generator[str, None, None]: def generate() -> Generator[str, None, None]:
last_keepalive = time.time() last_keepalive = time.time()
keepalive_interval = 30.0 keepalive_interval = 30.0
while True: try:
try: while True:
msg = agent_data_queue.get(timeout=1.0) try:
last_keepalive = time.time() msg = client_queue.get(timeout=1.0)
yield format_sse(msg) last_keepalive = time.time()
except queue.Empty: yield format_sse(msg)
now = time.time() except queue.Empty:
if now - last_keepalive >= keepalive_interval: now = time.time()
yield format_sse({'type': 'keepalive'}) if now - last_keepalive >= keepalive_interval:
last_keepalive = now 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'
+13 -18
View File
@@ -17,7 +17,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.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
@@ -735,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'
+10 -17
View File
@@ -21,7 +21,7 @@ from utils.gps import (
stop_gpsd_daemon, stop_gpsd_daemon,
) )
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
logger = get_logger('intercept.gps') logger = get_logger('intercept.gps')
@@ -231,22 +231,15 @@ def get_satellites():
@gps_bp.route('/stream') @gps_bp.route('/stream')
def stream_gps(): def stream_gps():
"""SSE stream of GPS position and sky updates.""" """SSE stream of GPS position and sky updates."""
def generate() -> Generator[str, None, None]: response = Response(
last_keepalive = time.time() sse_stream_fanout(
keepalive_interval = 30.0 source_queue=_gps_queue,
channel_key='gps',
while True: timeout=1.0,
try: keepalive_interval=30.0,
data = _gps_queue.get(timeout=1) ),
last_keepalive = time.time() mimetype='text/event-stream',
yield format_sse(data) )
except queue.Empty:
now = time.time()
if now - last_keepalive >= keepalive_interval:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream')
response.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'
+25 -36
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,
@@ -1182,25 +1182,19 @@ 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()
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
@@ -1834,24 +1828,19 @@ def stop_waterfall() -> Response:
@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:
msg = waterfall_queue.get(timeout=SSE_QUEUE_TIMEOUT)
last_keepalive = time.time()
try:
process_event('waterfall', 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=waterfall_queue,
channel_key='listening_waterfall',
timeout=SSE_QUEUE_TIMEOUT,
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
on_message=_on_msg,
),
mimetype='text/event-stream',
)
response.headers['Cache-Control'] = 'no-cache' response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no' response.headers['X-Accel-Buffering'] = 'no'
return response return response
+26 -17
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,
@@ -469,22 +469,15 @@ 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:
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'
@@ -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(),
})
+13 -22
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
@@ -540,28 +540,19 @@ def toggle_logging() -> Response:
@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]: response = Response(
last_keepalive = time.time() sse_stream_fanout(
keepalive_interval = 30.0 # Send keepalive every 30 seconds instead of 1 second source_queue=app_module.output_queue,
channel_key='pager',
while True: timeout=1.0,
try: keepalive_interval=30.0,
msg = app_module.output_queue.get(timeout=1) on_message=_on_msg,
last_keepalive = time.time() ),
try: mimetype='text/event-stream',
process_event('pager', msg, msg.get('type')) )
except Exception:
pass
yield format_sse(msg)
except queue.Empty:
now = time.time()
if now - last_keepalive >= keepalive_interval:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream')
response.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'
+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'
+37 -20
View File
@@ -18,7 +18,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 from utils.sdr import SDRFactory, SDRType
@@ -28,6 +28,10 @@ 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
# 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:
"""Stream rtl_433 JSON output to queue.""" """Stream rtl_433 JSON output to queue."""
@@ -45,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')
@@ -259,27 +274,29 @@ def stop_sensor() -> Response:
@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
while True: response = Response(
try: sse_stream_fanout(
msg = app_module.sensor_queue.get(timeout=1) source_queue=app_module.sensor_queue,
last_keepalive = time.time() channel_key='sensor',
try: timeout=1.0,
process_event('sensor', 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'
return response return response
@sensor_bp.route('/sensor/rssi_history')
def get_rssi_history() -> Response:
"""Return RSSI history for all tracked sensor devices."""
result = {}
for key, entries in sensor_rssi_history.items():
result[key] = [{'t': round(t, 1), 'rssi': rssi} for t, rssi in entries]
return jsonify({'status': 'success', 'devices': result})
+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'})
+46 -23
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
} }
@@ -392,26 +422,19 @@ 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
while True: response = Response(
try: sse_stream_fanout(
progress = _sstv_queue.get(timeout=1) source_queue=_sstv_queue,
last_keepalive = time.time() channel_key='sstv',
try: timeout=1.0,
process_event('sstv', 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'
+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'
+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',
+55 -29
View File
@@ -2,7 +2,11 @@
from __future__ import annotations from __future__ import annotations
import io
import json import json
import os
import platform
import pty
import queue import queue
import shutil import shutil
import subprocess import subprocess
@@ -17,7 +21,7 @@ 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.sdr import SDRFactory, SDRType from utils.sdr import SDRFactory, SDRType
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 (
PROCESS_TERMINATE_TIMEOUT, PROCESS_TERMINATE_TIMEOUT,
@@ -51,15 +55,20 @@ def find_dumpvdl2():
return shutil.which('dumpvdl2') return shutil.which('dumpvdl2')
def stream_vdl2_output(process: subprocess.Popen) -> None: def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) -> None:
"""Stream dumpvdl2 JSON output to queue.""" """Stream dumpvdl2 JSON output to queue."""
global vdl2_message_count, vdl2_last_message_time global vdl2_message_count, vdl2_last_message_time
try: try:
app_module.vdl2_queue.put({'type': 'status', 'status': 'started'}) app_module.vdl2_queue.put({'type': 'status', 'status': 'started'})
for line in iter(process.stdout.readline, b''): # Use appropriate sentinel based on mode (text mode for pty on macOS)
line = line.decode('utf-8', errors='replace').strip() 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: if not line:
continue continue
@@ -76,6 +85,13 @@ def stream_vdl2_output(process: subprocess.Popen) -> None:
app_module.vdl2_queue.put(data) 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 # Log if enabled
if app_module.logging_enabled: if app_module.logging_enabled:
try: try:
@@ -236,12 +252,28 @@ def start_vdl2() -> Response:
logger.info(f"Starting VDL2 decoder: {' '.join(cmd)}") logger.info(f"Starting VDL2 decoder: {' '.join(cmd)}")
try: try:
process = subprocess.Popen( is_text_mode = False
cmd,
stdout=subprocess.PIPE, # On macOS, use pty to avoid stdout buffering issues
stderr=subprocess.PIPE, if platform.system() == 'Darwin':
start_new_session=True 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 # Wait briefly to check if process started
time.sleep(PROCESS_START_WAIT) time.sleep(PROCESS_START_WAIT)
@@ -266,7 +298,7 @@ def start_vdl2() -> Response:
# Start output streaming thread # Start output streaming thread
thread = threading.Thread( thread = threading.Thread(
target=stream_vdl2_output, target=stream_vdl2_output,
args=(process,), args=(process, is_text_mode),
daemon=True daemon=True
) )
thread.start() thread.start()
@@ -320,25 +352,19 @@ def stop_vdl2() -> Response:
@vdl2_bp.route('/stream') @vdl2_bp.route('/stream')
def stream_vdl2() -> Response: def stream_vdl2() -> Response:
"""SSE stream for VDL2 messages.""" """SSE stream for VDL2 messages."""
def generate() -> Generator[str, None, None]: def _on_msg(msg: dict[str, Any]) -> None:
last_keepalive = time.time() process_event('vdl2', msg, msg.get('type'))
while True: response = Response(
try: sse_stream_fanout(
msg = app_module.vdl2_queue.get(timeout=SSE_QUEUE_TIMEOUT) source_queue=app_module.vdl2_queue,
last_keepalive = time.time() channel_key='vdl2',
try: timeout=SSE_QUEUE_TIMEOUT,
process_event('vdl2', 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
+15 -8
View File
@@ -566,14 +566,21 @@ def enable_schedule():
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)),
)
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}) return jsonify({'status': 'ok', **result})
+22 -37
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 (
@@ -1135,26 +1135,19 @@ 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
while True: response = Response(
try: sse_stream_fanout(
msg = app_module.wifi_queue.get(timeout=1) source_queue=app_module.wifi_queue,
last_keepalive = time.time() channel_key='wifi',
try: timeout=1.0,
process_event('wifi', 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'
@@ -1557,23 +1550,15 @@ 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)
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'
+96 -21
View File
@@ -325,9 +325,8 @@ install_python_deps() {
# (C extension packages like scipy/numpy can fail on newer Python versions # (C extension packages like scipy/numpy can fail on newer Python versions
# and cause pip to roll back pure-Python packages like flask) # and cause pip to roll back pure-Python packages like flask)
info "Installing core packages..." info "Installing core packages..."
$PIP install "flask>=3.0.0" "flask-limiter>=2.5.4" "requests>=2.28.0" \ $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>&1 \ "Werkzeug>=3.1.5" "pyserial>=3.5" "flask-sock" "websocket-client>=1.6.0" 2>/dev/null || true
| tail -5 || true
# Verify critical packages # Verify critical packages
$PY -c "import flask; import requests; from flask_limiter import Limiter" 2>/dev/null || { $PY -c "import flask; import requests; from flask_limiter import Limiter" 2>/dev/null || {
@@ -761,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)"
@@ -787,6 +798,7 @@ install_satdump_from_source_debian() {
echo '#pragma GCC diagnostic ignored "-Wdeprecated"' echo '#pragma GCC diagnostic ignored "-Wdeprecated"'
echo '#pragma GCC diagnostic ignored "-Wdeprecated-declarations"' echo '#pragma GCC diagnostic ignored "-Wdeprecated-declarations"'
cat "$lua_utils" cat "$lua_utils"
echo # ensure the file ends with a newline before the closing pragma
echo '#pragma GCC diagnostic pop' echo '#pragma GCC diagnostic pop'
} > "${lua_utils}.patched" && mv "${lua_utils}.patched" "$lua_utils" } > "${lua_utils}.patched" && mv "${lua_utils}.patched" "$lua_utils"
fi fi
@@ -1079,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 \
@@ -1091,22 +1106,44 @@ 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 || true 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)."
) )
@@ -1278,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."
@@ -1287,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() {
@@ -1311,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
@@ -1391,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"
@@ -1486,6 +1552,15 @@ 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
+21
View File
@@ -27,6 +27,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);
+21
View File
@@ -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);
+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;
} }
+26 -11
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;
@@ -722,7 +724,20 @@
.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 */
+10
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);
@@ -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);
+732 -11
View File
@@ -1556,7 +1556,7 @@ header h1 .tagline {
background: var(--bg-tertiary); background: var(--bg-tertiary);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 6px; border-radius: 6px;
overflow: visible; overflow: hidden;
padding: 12px; padding: 12px;
position: relative; position: relative;
} }
@@ -3586,6 +3586,7 @@ header h1 .tagline {
.wifi-networks-table-wrapper { .wifi-networks-table-wrapper {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain;
} }
.wifi-networks-table { .wifi-networks-table {
@@ -3694,6 +3695,22 @@ header h1 .tagline {
color: var(--text-dim); color: var(--text-dim);
} }
.app-collection-state-row td {
text-align: center;
padding: 0;
}
.app-collection-state {
color: var(--text-dim);
padding: 16px 12px;
font-size: 11px;
text-align: center;
}
.app-collection-state.is-loading {
color: var(--accent-cyan);
}
/* WiFi Radar Panel (CENTER) */ /* WiFi Radar Panel (CENTER) */
.wifi-radar-panel { .wifi-radar-panel {
display: flex; display: flex;
@@ -4060,8 +4077,8 @@ header h1 .tagline {
/* Bluetooth Layout Container */ /* Bluetooth Layout Container */
.bt-layout-container { .bt-layout-container {
display: flex; display: flex;
gap: 15px; gap: 12px;
padding: 15px; padding: 12px;
background: var(--bg-secondary); background: var(--bg-secondary);
margin: 0 15px 10px 15px; margin: 0 15px 10px 15px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@@ -4075,20 +4092,21 @@ header h1 .tagline {
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
min-width: 0; min-width: 0;
overflow-y: auto; /* scroll rather than squash when detail panel + radar exceed available height */
} }
.bt-main-area { .bt-main-area {
display: flex; display: flex;
gap: 12px; gap: 12px;
flex: 1; flex: 1;
min-height: 0; min-height: 420px;
} }
.bt-side-panels { .bt-side-panels {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
width: 220px; width: 300px;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -4096,6 +4114,21 @@ header h1 .tagline {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
display: flex;
flex-direction: column;
}
.bt-tracker-panel h5 {
margin-bottom: 8px;
}
.bt-tracker-list {
font-size: 11px;
flex: 1;
min-height: 0;
overflow-y: auto;
padding-right: 2px;
overscroll-behavior: contain;
} }
.bt-radar-panel { .bt-radar-panel {
@@ -4106,6 +4139,90 @@ header h1 .tagline {
flex-direction: column; flex-direction: column;
} }
#btRadarControls {
gap: 6px;
}
.bt-radar-filter-btn,
#btRadarPauseBtn {
min-width: 84px;
padding: 4px 10px;
font-size: 10px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-dim);
cursor: pointer;
transition: all 0.2s ease;
}
.bt-radar-filter-btn:hover,
#btRadarPauseBtn:hover {
color: var(--text-primary);
border-color: var(--accent-cyan);
}
.bt-radar-filter-btn.active,
#btRadarPauseBtn.active {
color: #0f172a;
background: var(--accent-cyan);
border-color: var(--accent-cyan);
}
.bt-zone-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin-top: 12px;
font-size: 11px;
}
.bt-zone-card {
text-align: center;
border-radius: 6px;
padding: 8px 6px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
}
.bt-zone-card.immediate {
border-color: rgba(34, 197, 94, 0.35);
}
.bt-zone-card.near {
border-color: rgba(234, 179, 8, 0.35);
}
.bt-zone-card.far {
border-color: rgba(239, 68, 68, 0.35);
}
.bt-zone-value {
display: block;
font-size: 19px;
font-weight: 700;
}
.bt-zone-card.immediate .bt-zone-value {
color: #22c55e;
}
.bt-zone-card.near .bt-zone-value {
color: #eab308;
}
.bt-zone-card.far .bt-zone-value {
color: #ef4444;
}
.bt-zone-label {
color: var(--text-dim);
font-size: 10px;
margin-top: 3px;
text-transform: uppercase;
letter-spacing: 0.4px;
}
.bt-radar-panel #btProximityRadar { .bt-radar-panel #btProximityRadar {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
@@ -4247,6 +4364,70 @@ header h1 .tagline {
color: #9ca3af; color: #9ca3af;
} }
.bt-detail-badge.tracker-high {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.bt-detail-badge.tracker-medium {
background: rgba(249, 115, 22, 0.2);
color: #f97316;
}
.bt-detail-badge.tracker-low {
background: rgba(234, 179, 8, 0.2);
color: #eab308;
}
.bt-detail-tracker-analysis {
background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.25);
border-radius: 6px;
padding: 8px 10px;
margin-bottom: 8px;
}
.bt-analysis-header {
color: #fca5a5;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.35px;
text-transform: uppercase;
margin-bottom: 6px;
}
.bt-analysis-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
font-size: 10px;
margin-top: 4px;
}
.bt-analysis-label {
color: var(--text-dim);
font-size: 9px;
}
.bt-analysis-section {
margin-top: 6px;
}
.bt-evidence-list {
margin: 4px 0 0 0;
padding-left: 14px;
color: var(--text-primary);
font-size: 10px;
}
.bt-analysis-warning {
margin-top: 8px;
color: #fca5a5;
font-size: 9px;
line-height: 1.35;
}
.bt-detail-grid { .bt-detail-grid {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);
@@ -4282,6 +4463,8 @@ header h1 .tagline {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 6px;
flex-wrap: wrap;
} }
.bt-detail-services { .bt-detail-services {
@@ -4437,8 +4620,8 @@ header h1 .tagline {
border-left-color: var(--accent-purple) !important; border-left-color: var(--accent-purple) !important;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 280px; min-width: 330px;
max-width: 320px; max-width: 420px;
max-height: 100%; max-height: 100%;
background: var(--bg-primary); background: var(--bg-primary);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@@ -4450,6 +4633,9 @@ header h1 .tagline {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
min-height: 0; min-height: 0;
padding: 8px 10px 12px;
background: var(--bg-primary);
overscroll-behavior: contain;
} }
.bt-device-list .wifi-device-list-header { .bt-device-list .wifi-device-list-header {
@@ -4459,6 +4645,10 @@ header h1 .tagline {
padding: 10px 12px; padding: 10px 12px;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
flex-shrink: 0; flex-shrink: 0;
position: sticky;
top: 0;
z-index: 4;
background: var(--bg-primary);
} }
.bt-device-list .wifi-device-list-header h5 { .bt-device-list .wifi-device-list-header h5 {
@@ -4468,6 +4658,101 @@ header h1 .tagline {
font-weight: 600; font-weight: 600;
} }
.bt-list-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 6px;
padding: 8px 12px;
border-bottom: 1px solid var(--border-color);
background: var(--bg-primary);
}
.bt-summary-item {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 5px 6px;
min-width: 0;
}
.bt-summary-label {
display: block;
font-size: 8px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.35px;
}
.bt-summary-value {
display: block;
font-size: 11px;
font-weight: 700;
color: var(--text-primary);
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bt-list-signal-strip {
padding: 8px 12px;
border-bottom: 1px solid var(--border-color);
background: var(--bg-primary);
}
.bt-list-signal-title {
font-size: 9px;
font-weight: 600;
letter-spacing: 0.45px;
text-transform: uppercase;
color: var(--text-dim);
margin-bottom: 6px;
}
.bt-signal-dist-compact {
gap: 6px;
padding: 0;
}
.bt-signal-dist-compact .signal-range {
gap: 8px;
}
.bt-signal-dist-compact .signal-range span:first-child {
width: 50px;
font-size: 9px;
}
.bt-signal-dist-compact .signal-range span:last-child {
width: 22px;
font-size: 10px;
}
.bt-signal-dist-compact .signal-bar-bg {
height: 10px;
}
.bt-device-toolbar {
padding: 8px 12px;
border-bottom: 1px solid var(--border-color);
background: var(--bg-primary);
}
.bt-device-search {
width: 100%;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg-tertiary);
color: var(--text-primary);
font-size: 11px;
padding: 7px 8px;
}
.bt-device-search:focus {
outline: none;
border-color: var(--accent-cyan);
}
/* Bluetooth Device Filters */ /* Bluetooth Device Filters */
.bt-device-filters { .bt-device-filters {
display: flex; display: flex;
@@ -4476,6 +4761,10 @@ header h1 .tagline {
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
flex-wrap: wrap; flex-wrap: wrap;
flex-shrink: 0; flex-shrink: 0;
background: var(--bg-primary);
position: sticky;
top: 44px;
z-index: 3;
} }
.bt-filter-btn { .bt-filter-btn {
@@ -4500,6 +4789,112 @@ header h1 .tagline {
color: white; color: white;
} }
.bt-tracker-item {
padding: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
transition: background 0.15s ease;
cursor: pointer;
}
.bt-tracker-item:hover {
background: rgba(239, 68, 68, 0.08);
}
.bt-tracker-item:focus-visible {
outline: 1px solid var(--accent-cyan);
outline-offset: -1px;
}
.bt-tracker-row-top {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.bt-tracker-left,
.bt-tracker-right {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.bt-tracker-confidence {
font-size: 9px;
padding: 2px 5px;
border-radius: 3px;
font-weight: 700;
letter-spacing: 0.2px;
}
.bt-tracker-confidence-high .bt-tracker-confidence {
color: #ef4444;
background: rgba(239, 68, 68, 0.2);
}
.bt-tracker-confidence-medium .bt-tracker-confidence {
color: #f97316;
background: rgba(249, 115, 22, 0.2);
}
.bt-tracker-confidence-low .bt-tracker-confidence {
color: #eab308;
background: rgba(234, 179, 8, 0.2);
}
.bt-tracker-type {
font-size: 11px;
color: var(--text-primary);
font-weight: 500;
}
.bt-tracker-risk {
font-size: 9px;
font-weight: 700;
}
.bt-risk-high {
color: #ef4444;
}
.bt-risk-medium {
color: #f97316;
}
.bt-risk-low {
color: var(--text-dim);
}
.bt-tracker-rssi,
.bt-tracker-seen {
font-size: 10px;
color: var(--text-dim);
}
.bt-tracker-row-bottom {
display: flex;
justify-content: space-between;
margin-top: 3px;
gap: 10px;
}
.bt-tracker-address {
font-size: 9px;
color: var(--text-dim);
font-family: var(--font-mono);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bt-tracker-evidence {
margin-top: 3px;
font-size: 9px;
color: var(--text-dim);
font-style: italic;
}
/* Bluetooth Signal Distribution */ /* Bluetooth Signal Distribution */
.bt-signal-dist { .bt-signal-dist {
display: flex; display: flex;
@@ -4569,11 +4964,20 @@ header h1 .tagline {
transition: all 0.15s ease; transition: all 0.15s ease;
} }
.bt-device-row:last-child {
margin-bottom: 0;
}
.bt-device-row:hover { .bt-device-row:hover {
background: rgba(0, 212, 255, 0.05); background: rgba(0, 212, 255, 0.05);
border-color: var(--accent-cyan); border-color: var(--accent-cyan);
} }
.bt-device-row:focus-visible {
outline: 1px solid var(--accent-cyan);
outline-offset: 1px;
}
.bt-row-main { .bt-row-main {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -4711,6 +5115,50 @@ header h1 .tagline {
padding: 4px 4px 0 42px; padding: 4px 4px 0 42px;
} }
/* Locate action on Bluetooth device rows (must be in index.css so it styles in scanner mode) */
.bt-row-actions .bt-locate-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 28px;
padding: 5px 10px;
font-size: 10px;
line-height: 1;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--accent-green, #38c180);
background: linear-gradient(180deg, rgba(56, 193, 128, 0.2), rgba(56, 193, 128, 0.12));
border: 1px solid rgba(56, 193, 128, 0.42);
border-radius: 999px;
cursor: pointer;
white-space: nowrap;
transition: background 0.18s ease, border-color 0.18s ease, transform 0.18s ease, box-shadow 0.18s ease;
}
.bt-row-actions .bt-locate-btn:hover {
background: linear-gradient(180deg, rgba(56, 193, 128, 0.28), rgba(56, 193, 128, 0.18));
border-color: rgba(56, 193, 128, 0.72);
box-shadow: 0 0 0 1px rgba(56, 193, 128, 0.2), 0 6px 16px rgba(20, 80, 54, 0.35);
transform: translateY(-1px);
}
.bt-row-actions .bt-locate-btn:active {
transform: translateY(0);
}
.bt-row-actions .bt-locate-btn svg {
width: 12px;
height: 12px;
stroke: currentColor;
flex-shrink: 0;
}
.bt-device-filter-state {
margin-top: 8px;
}
/* Bluetooth Device Modal */ /* Bluetooth Device Modal */
.bt-modal-overlay { .bt-modal-overlay {
position: fixed; position: fixed;
@@ -4921,14 +5369,31 @@ header h1 .tagline {
min-height: 0; min-height: 0;
} }
.bt-layout-container .wifi-visuals { .bt-layout-container .bt-visuals-column {
max-height: 50vh; max-height: 50vh;
} }
.bt-main-area {
min-height: 0;
}
.bt-side-panels {
width: 100%;
}
.bt-tracker-list {
max-height: 280px;
}
.bt-device-list { .bt-device-list {
width: 100%; width: 100%;
min-width: auto; min-width: auto;
max-height: 300px; max-width: none;
max-height: 320px;
}
.bt-list-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
} }
} }
@@ -5208,8 +5673,11 @@ body::before {
} }
.disclaimer-modal .warning-icon { .disclaimer-modal .warning-icon {
font-size: 48px; display: block;
margin-bottom: 15px; width: 48px;
height: 48px;
margin: 0 auto 15px;
color: var(--accent-red);
} }
.disclaimer-modal p { .disclaimer-modal p {
@@ -6656,3 +7124,256 @@ body::before {
[data-animations="off"] .welcome-logo { [data-animations="off"] .welcome-logo {
animation: none !important; animation: none !important;
} }
/* ============================================
VISUAL REFRESH OVERRIDES
============================================ */
:root {
--visual-surface-soft: linear-gradient(180deg, rgba(18, 28, 40, 0.9) 0%, rgba(10, 16, 24, 0.95) 100%);
--visual-surface-panel: linear-gradient(160deg, rgba(20, 33, 48, 0.95) 0%, rgba(11, 18, 27, 0.96) 100%);
--visual-edge-cyan: rgba(74, 163, 255, 0.34);
--visual-edge-green: rgba(56, 193, 128, 0.28);
--visual-glow-soft: 0 14px 30px rgba(0, 0, 0, 0.32);
--visual-glow-cyan: 0 0 24px rgba(74, 163, 255, 0.16);
--mode-ambient-left: rgba(74, 163, 255, 0.12);
--mode-ambient-right: rgba(56, 193, 128, 0.08);
--mode-ambient-bottom: rgba(214, 168, 94, 0.05);
--top-rail-gutter: 12px;
--top-rail-gap: 6px;
--top-rail-height: 44px;
}
body {
background-image:
radial-gradient(1200px 560px at 8% -10%, var(--mode-ambient-left), transparent 60%),
radial-gradient(900px 520px at 92% -18%, var(--mode-ambient-right), transparent 60%),
radial-gradient(800px 440px at 50% 130%, var(--mode-ambient-bottom), transparent 65%),
var(--noise-image),
linear-gradient(var(--grid-line) 1px, transparent 1px),
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
background-size: auto, auto, auto, 40px 40px, 48px 48px, 48px 48px;
}
body[data-mode="wifi"],
body[data-mode="bluetooth"],
body[data-mode="bt_locate"] {
--mode-ambient-left: rgba(56, 193, 128, 0.14);
--mode-ambient-right: rgba(74, 163, 255, 0.08);
}
body[data-mode="satellite"],
body[data-mode="weathersat"],
body[data-mode="sstv"],
body[data-mode="sstv_general"] {
--mode-ambient-left: rgba(74, 163, 255, 0.14);
--mode-ambient-right: rgba(143, 123, 214, 0.09);
--mode-ambient-bottom: rgba(56, 193, 128, 0.05);
}
body[data-mode="analytics"],
body[data-mode="spystations"],
body[data-mode="tscm"] {
--mode-ambient-left: rgba(214, 168, 94, 0.12);
--mode-ambient-right: rgba(74, 163, 255, 0.08);
}
[data-theme="light"] body {
--mode-ambient-left: rgba(31, 95, 168, 0.09);
--mode-ambient-right: rgba(31, 138, 87, 0.05);
--mode-ambient-bottom: rgba(181, 134, 58, 0.04);
}
.mode-nav {
background: linear-gradient(180deg, rgba(22, 33, 48, 0.96) 0%, rgba(14, 22, 33, 0.98) 100%);
border-bottom-color: rgba(74, 163, 255, 0.24);
}
#mainNav.mode-nav {
margin: var(--top-rail-gap) var(--top-rail-gutter) 0;
padding: 0 12px;
min-height: var(--top-rail-height);
height: var(--top-rail-height);
border: 1px solid rgba(74, 163, 255, 0.22);
border-radius: 10px;
box-shadow: var(--visual-glow-soft), inset 0 1px 0 rgba(255, 255, 255, 0.03);
}
.run-state-strip {
margin: 8px var(--top-rail-gutter) 0;
border-color: rgba(74, 163, 255, 0.3);
background: linear-gradient(180deg, rgba(20, 31, 44, 0.96) 0%, rgba(12, 19, 29, 0.97) 100%);
box-shadow: var(--visual-glow-soft), inset 0 1px 0 rgba(255, 255, 255, 0.04);
min-height: var(--top-rail-height);
padding: 6px 12px;
border-radius: 10px;
}
.run-state-strip .run-state-chip {
min-height: 22px;
padding: 2px 8px;
}
.run-state-strip .run-state-btn {
min-height: 26px;
padding: 4px 10px;
}
.run-state-strip .run-state-right {
gap: 6px;
}
.main-content {
margin: 0 12px;
border: 1px solid rgba(74, 163, 255, 0.22);
border-radius: 10px;
box-shadow: var(--visual-glow-soft), inset 0 0 0 1px rgba(255, 255, 255, 0.02);
backdrop-filter: blur(6px);
}
.sidebar {
background: var(--visual-surface-soft);
border-right-color: rgba(74, 163, 255, 0.22);
}
.section {
background: var(--visual-surface-panel);
border-color: rgba(74, 163, 255, 0.22);
border-radius: 8px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
}
.section:hover {
border-color: var(--visual-edge-cyan);
box-shadow: var(--visual-glow-cyan), inset 0 1px 0 rgba(255, 255, 255, 0.06);
transform: translateY(-1px);
}
.section h3 {
background: linear-gradient(180deg, rgba(28, 44, 63, 0.88) 0%, rgba(20, 31, 44, 0.9) 100%);
border-bottom-color: rgba(74, 163, 255, 0.2);
}
.section h3::before {
background: linear-gradient(180deg, var(--accent-cyan) 0%, var(--accent-green) 100%);
}
.section h3::after {
background: rgba(12, 18, 28, 0.9);
border: 1px solid rgba(74, 163, 255, 0.24);
}
.form-group input,
.form-group select {
background: rgba(8, 13, 20, 0.72);
border-color: rgba(74, 163, 255, 0.2);
border-radius: 6px;
}
.preset-btn,
.control-btn,
.clear-btn,
.run-btn,
.stop-btn {
border-radius: 7px;
}
.preset-btn,
.control-btn,
.clear-btn {
border-color: rgba(74, 163, 255, 0.24);
background: linear-gradient(180deg, rgba(16, 24, 35, 0.88) 0%, rgba(10, 15, 24, 0.9) 100%);
}
.output-panel {
background: linear-gradient(180deg, rgba(8, 13, 19, 0.98) 0%, rgba(7, 11, 18, 0.99) 100%);
}
.output-header {
background: linear-gradient(180deg, rgba(18, 28, 42, 0.95) 0%, rgba(13, 21, 31, 0.98) 100%);
border-bottom-color: rgba(74, 163, 255, 0.22);
}
.output-content {
background: linear-gradient(180deg, rgba(8, 13, 19, 0.6) 0%, rgba(8, 13, 19, 0.9) 100%);
}
.stats > div {
border-color: rgba(74, 163, 255, 0.2);
background: linear-gradient(180deg, rgba(19, 28, 40, 0.8) 0%, rgba(12, 18, 27, 0.82) 100%);
}
.message {
border-color: rgba(74, 163, 255, 0.26);
border-left-width: 4px;
border-radius: 8px;
background: linear-gradient(180deg, rgba(21, 31, 44, 0.8) 0%, rgba(15, 23, 33, 0.82) 100%);
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.24);
}
.status-bar {
position: sticky;
bottom: 0;
z-index: 9;
border-top-color: rgba(74, 163, 255, 0.24);
background: linear-gradient(180deg, rgba(17, 26, 39, 0.96) 0%, rgba(10, 16, 24, 0.97) 100%);
backdrop-filter: blur(7px);
}
.status-indicator,
.control-group {
border-color: rgba(74, 163, 255, 0.2);
background: linear-gradient(180deg, rgba(15, 23, 34, 0.78) 0%, rgba(9, 14, 23, 0.8) 100%);
border-radius: 6px;
}
.status-dot.running {
box-shadow: 0 0 0 4px rgba(56, 193, 128, 0.15), 0 0 14px rgba(56, 193, 128, 0.4);
}
.mode-content.active {
animation: modePanelEntrance 220ms ease both;
}
@keyframes modePanelEntrance {
from {
opacity: 0;
transform: translateY(5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
[data-theme="light"] .run-state-strip,
[data-theme="light"] .main-content,
[data-theme="light"] .section,
[data-theme="light"] #mainNav.mode-nav,
[data-theme="light"] .output-header,
[data-theme="light"] .status-bar,
[data-theme="light"] .status-indicator,
[data-theme="light"] .control-group {
box-shadow: 0 10px 24px rgba(18, 40, 66, 0.08);
}
[data-theme="light"] .section,
[data-theme="light"] .stats > div,
[data-theme="light"] .message,
[data-theme="light"] .preset-btn,
[data-theme="light"] .control-btn,
[data-theme="light"] .clear-btn {
border-color: rgba(31, 95, 168, 0.26);
}
[data-animations="off"] .mode-content.active {
animation: none !important;
}
@media (max-width: 1023px) {
.run-state-strip {
margin-left: 8px;
margin-right: 8px;
}
}
+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;
}
+130
View File
@@ -170,6 +170,23 @@
flex-shrink: 0; 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 { .btl-hud-audio-toggle {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -256,6 +273,7 @@
.btl-map-container { .btl-map-container {
flex: 1; flex: 1;
min-height: 250px; min-height: 250px;
position: relative;
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);
@@ -267,6 +285,94 @@
background: #1a1a2e; 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 { .btl-rssi-chart-container {
height: 100px; height: 100px;
background: rgba(0, 0, 0, 0.3); background: rgba(0, 0, 0, 0.3);
@@ -426,5 +532,29 @@
flex-direction: row; flex-direction: row;
width: 100%; width: 100%;
justify-content: center; 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;
} }
} }
+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;
}
}
+86 -1
View File
@@ -510,8 +510,93 @@
} }
.wxsat-ground-map { .wxsat-ground-map {
position: relative;
height: 200px; height: 200px;
background: var(--bg-primary, #0d1117); 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 ===== */
+30 -2
View File
@@ -428,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;
@@ -444,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;
@@ -465,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 ============== */
+48
View File
@@ -479,6 +479,54 @@
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) { @media (max-width: 960px) {
.settings-tabs { .settings-tabs {
+2 -2
View File
@@ -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)) {
+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,
+6 -6
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 };
})(); })();
+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();
});
+436 -52
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);
@@ -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();
});
+36 -8
View File
@@ -81,6 +81,7 @@ const Updater = {
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'); const toast = document.createElement('div');
toast.className = 'update-toast'; toast.className = 'update-toast';
@@ -99,7 +100,7 @@ const Updater = {
<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()">
@@ -177,6 +178,9 @@ const Updater = {
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 safeLatestVersion = this._escape(data.latest_version || '');
const safeReleaseUrl = this._safeUrl(data.release_url || '');
const modal = document.createElement('div'); const modal = document.createElement('div');
modal.className = 'update-modal-overlay'; modal.className = 'update-modal-overlay';
@@ -203,7 +207,7 @@ const Updater = {
<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">
@@ -213,7 +217,7 @@ const Updater = {
</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>
@@ -251,7 +255,7 @@ const Updater = {
<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"/>
@@ -361,6 +365,8 @@ const Updater = {
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.');
const safeDetails = data.details ? this._escape(String(data.details).substring(0, 200)) : '';
if (success) { if (success) {
if (data.updated) { if (data.updated) {
@@ -390,7 +396,7 @@ const Updater = {
<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 {
@@ -406,7 +412,7 @@ const Updater = {
</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 {
@@ -421,8 +427,8 @@ const Updater = {
</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>
`; `;
} }
@@ -467,6 +473,28 @@ const Updater = {
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,
};
})();
+231 -98
View File
@@ -28,7 +28,7 @@ const BluetoothMode = (function() {
}; };
// Zone counts for proximity display // Zone counts for proximity display
let zoneCounts = { veryClose: 0, close: 0, nearby: 0, far: 0 }; let zoneCounts = { immediate: 0, near: 0, far: 0 };
// New visualization components // New visualization components
let radarInitialized = false; let radarInitialized = false;
@@ -36,6 +36,13 @@ const BluetoothMode = (function() {
// Device list filter // Device list filter
let currentDeviceFilter = 'all'; let currentDeviceFilter = 'all';
let currentSearchTerm = '';
let visibleDeviceCount = 0;
let pendingDeviceFlush = false;
let selectedDeviceNeedsRefresh = false;
let filterListenersBound = false;
let listListenersBound = false;
const pendingDeviceIds = new Set();
// Agent support // Agent support
let showAllAgentsMode = false; let showAllAgentsMode = false;
@@ -111,6 +118,7 @@ const BluetoothMode = (function() {
// Initialize device list filters // Initialize device list filters
initDeviceFilters(); initDeviceFilters();
initListInteractions();
// Set initial panel states // Set initial panel states
updateVisualizationPanels(); updateVisualizationPanels();
@@ -120,24 +128,62 @@ const BluetoothMode = (function() {
* Initialize device list filter buttons * Initialize device list filter buttons
*/ */
function initDeviceFilters() { function initDeviceFilters() {
if (filterListenersBound) return;
const filterContainer = document.getElementById('btDeviceFilters'); const filterContainer = document.getElementById('btDeviceFilters');
if (!filterContainer) return; if (filterContainer) {
filterContainer.addEventListener('click', (e) => {
const btn = e.target.closest('.bt-filter-btn');
if (!btn) return;
filterContainer.addEventListener('click', (e) => { const filter = btn.dataset.filter;
const btn = e.target.closest('.bt-filter-btn'); if (!filter) return;
if (!btn) return;
const filter = btn.dataset.filter; // Update active state
if (!filter) return; filterContainer.querySelectorAll('.bt-filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
// Update active state // Apply filter
filterContainer.querySelectorAll('.bt-filter-btn').forEach(b => b.classList.remove('active')); currentDeviceFilter = filter;
btn.classList.add('active'); applyDeviceFilter();
});
}
// Apply filter const searchInput = document.getElementById('btDeviceSearch');
currentDeviceFilter = filter; if (searchInput) {
applyDeviceFilter(); searchInput.addEventListener('input', () => {
}); currentSearchTerm = searchInput.value.trim().toLowerCase();
applyDeviceFilter();
});
}
filterListenersBound = true;
}
function initListInteractions() {
if (listListenersBound) return;
if (deviceContainer) {
deviceContainer.addEventListener('click', (event) => {
const locateBtn = event.target.closest('.bt-locate-btn[data-locate-id]');
if (locateBtn) {
event.preventDefault();
locateById(locateBtn.dataset.locateId);
return;
}
const row = event.target.closest('.bt-device-row[data-bt-device-id]');
if (!row) return;
selectDevice(row.dataset.btDeviceId);
});
}
const trackerList = document.getElementById('btTrackerList');
if (trackerList) {
trackerList.addEventListener('click', (event) => {
const row = event.target.closest('.bt-tracker-item[data-device-id]');
if (!row) return;
selectDevice(row.dataset.deviceId);
});
}
listListenersBound = true;
} }
/** /**
@@ -147,34 +193,53 @@ const BluetoothMode = (function() {
if (!deviceContainer) return; if (!deviceContainer) return;
const cards = deviceContainer.querySelectorAll('[data-bt-device-id]'); const cards = deviceContainer.querySelectorAll('[data-bt-device-id]');
let visibleCount = 0;
cards.forEach(card => { cards.forEach(card => {
const isNew = card.dataset.isNew === 'true'; const isNew = card.dataset.isNew === 'true';
const hasName = card.dataset.hasName === 'true'; const hasName = card.dataset.hasName === 'true';
const rssi = parseInt(card.dataset.rssi) || -100; const rssi = parseInt(card.dataset.rssi) || -100;
const isTracker = card.dataset.isTracker === 'true'; const isTracker = card.dataset.isTracker === 'true';
const searchHaystack = (card.dataset.search || '').toLowerCase();
let visible = true; let matchesFilter = true;
switch (currentDeviceFilter) { switch (currentDeviceFilter) {
case 'new': case 'new':
visible = isNew; matchesFilter = isNew;
break; break;
case 'named': case 'named':
visible = hasName; matchesFilter = hasName;
break; break;
case 'strong': case 'strong':
visible = rssi >= -70; matchesFilter = rssi >= -70;
break; break;
case 'trackers': case 'trackers':
visible = isTracker; matchesFilter = isTracker;
break; break;
case 'all': case 'all':
default: default:
visible = true; matchesFilter = true;
} }
const matchesSearch = !currentSearchTerm || searchHaystack.includes(currentSearchTerm);
const visible = matchesFilter && matchesSearch;
card.style.display = visible ? '' : 'none'; card.style.display = visible ? '' : 'none';
if (visible) visibleCount++;
}); });
visibleDeviceCount = visibleCount;
let stateEl = deviceContainer.querySelector('.bt-device-filter-state');
if (visibleCount === 0 && devices.size > 0) {
if (!stateEl) {
stateEl = document.createElement('div');
stateEl.className = 'bt-device-filter-state app-collection-state is-empty';
deviceContainer.appendChild(stateEl);
}
stateEl.textContent = 'No devices match current filters';
} else if (stateEl) {
stateEl.remove();
}
// Update visible count // Update visible count
updateFilteredCount(); updateFilteredCount();
} }
@@ -186,12 +251,8 @@ const BluetoothMode = (function() {
const countEl = document.getElementById('btDeviceListCount'); const countEl = document.getElementById('btDeviceListCount');
if (!countEl || !deviceContainer) return; if (!countEl || !deviceContainer) return;
if (currentDeviceFilter === 'all') { const hasFilter = currentDeviceFilter !== 'all' || currentSearchTerm.length > 0;
countEl.textContent = devices.size; countEl.textContent = hasFilter ? `${visibleDeviceCount}/${devices.size}` : devices.size;
} else {
const visible = deviceContainer.querySelectorAll('[data-bt-device-id]:not([style*="display: none"])').length;
countEl.textContent = visible + '/' + devices.size;
}
} }
/** /**
@@ -309,28 +370,18 @@ const BluetoothMode = (function() {
* Update proximity zone counts (simple HTML, no canvas) * Update proximity zone counts (simple HTML, no canvas)
*/ */
function updateProximityZones() { function updateProximityZones() {
zoneCounts = { veryClose: 0, close: 0, nearby: 0, far: 0 }; zoneCounts = { immediate: 0, near: 0, far: 0 };
devices.forEach(device => { devices.forEach(device => {
const rssi = device.rssi_current; const rssi = device.rssi_current;
if (rssi == null) return; if (rssi == null) return;
if (rssi >= -40) zoneCounts.veryClose++; if (rssi >= -50) zoneCounts.immediate++;
else if (rssi >= -55) zoneCounts.close++; else if (rssi >= -70) zoneCounts.near++;
else if (rssi >= -70) zoneCounts.nearby++;
else zoneCounts.far++; else zoneCounts.far++;
}); });
// Update DOM elements updateProximityZoneCounts(zoneCounts);
const veryCloseEl = document.getElementById('btZoneVeryClose');
const closeEl = document.getElementById('btZoneClose');
const nearbyEl = document.getElementById('btZoneNearby');
const farEl = document.getElementById('btZoneFar');
if (veryCloseEl) veryCloseEl.textContent = zoneCounts.veryClose;
if (closeEl) closeEl.textContent = zoneCounts.close;
if (nearbyEl) nearbyEl.textContent = zoneCounts.nearby;
if (farEl) farEl.textContent = zoneCounts.far;
} }
// Currently selected device // Currently selected device
@@ -916,9 +967,20 @@ const BluetoothMode = (function() {
if (stopBtn) stopBtn.style.display = scanning ? 'block' : 'none'; if (stopBtn) stopBtn.style.display = scanning ? 'block' : 'none';
if (scanning && deviceContainer) { if (scanning && deviceContainer) {
deviceContainer.innerHTML = ''; pendingDeviceIds.clear();
selectedDeviceNeedsRefresh = false;
pendingDeviceFlush = false;
if (typeof renderCollectionState === 'function') {
renderCollectionState(deviceContainer, { type: 'loading', message: 'Scanning for Bluetooth devices...' });
} else {
deviceContainer.innerHTML = '';
}
devices.clear(); devices.clear();
resetStats(); resetStats();
} else if (!scanning && deviceContainer && devices.size === 0) {
if (typeof renderCollectionState === 'function') {
renderCollectionState(deviceContainer, { type: 'empty', message: 'Start scanning to discover Bluetooth devices' });
}
} }
const statusDot = document.getElementById('statusDot'); const statusDot = document.getElementById('statusDot');
@@ -934,8 +996,10 @@ const BluetoothMode = (function() {
weak: 0, weak: 0,
trackers: [] trackers: []
}; };
visibleDeviceCount = 0;
updateVisualizationPanels(); updateVisualizationPanels();
updateProximityZones(); updateProximityZones();
updateFilteredCount();
// Clear radar // Clear radar
if (radarInitialized && typeof ProximityRadar !== 'undefined') { if (radarInitialized && typeof ProximityRadar !== 'undefined') {
@@ -1084,14 +1148,40 @@ const BluetoothMode = (function() {
function handleDeviceUpdate(device) { function handleDeviceUpdate(device) {
devices.set(device.device_id, device); devices.set(device.device_id, device);
renderDevice(device); pendingDeviceIds.add(device.device_id);
updateDeviceCount(); if (selectedDeviceId === device.device_id) {
updateStatsFromDevices(); selectedDeviceNeedsRefresh = true;
updateVisualizationPanels(); }
updateProximityZones(); scheduleDeviceFlush();
}
// Update new proximity radar function scheduleDeviceFlush() {
updateRadar(); if (pendingDeviceFlush) return;
pendingDeviceFlush = true;
requestAnimationFrame(() => {
pendingDeviceFlush = false;
pendingDeviceIds.forEach((deviceId) => {
const device = devices.get(deviceId);
if (device) {
renderDevice(device, false);
}
});
pendingDeviceIds.clear();
applyDeviceFilter();
updateDeviceCount();
updateStatsFromDevices();
updateVisualizationPanels();
updateProximityZones();
updateRadar();
if (selectedDeviceNeedsRefresh && selectedDeviceId && devices.has(selectedDeviceId)) {
showDeviceDetail(selectedDeviceId);
}
selectedDeviceNeedsRefresh = false;
});
} }
/** /**
@@ -1144,13 +1234,41 @@ const BluetoothMode = (function() {
if (mediumCount) mediumCount.textContent = deviceStats.medium; if (mediumCount) mediumCount.textContent = deviceStats.medium;
if (weakCount) weakCount.textContent = deviceStats.weak; if (weakCount) weakCount.textContent = deviceStats.weak;
// Device summary strip
const totalEl = document.getElementById('btSummaryTotal');
const newEl = document.getElementById('btSummaryNew');
const trackersEl = document.getElementById('btSummaryTrackers');
const strongestEl = document.getElementById('btSummaryStrongest');
if (totalEl || newEl || trackersEl || strongestEl) {
let newCount = 0;
let strongest = null;
devices.forEach(d => {
if (!d.in_baseline) newCount++;
if (d.rssi_current != null) {
strongest = strongest == null ? d.rssi_current : Math.max(strongest, d.rssi_current);
}
});
if (totalEl) totalEl.textContent = devices.size;
if (newEl) newEl.textContent = newCount;
if (trackersEl) trackersEl.textContent = deviceStats.trackers.length;
if (strongestEl) strongestEl.textContent = strongest == null ? '--' : `${strongest} dBm`;
}
// Tracker Detection - Enhanced display with confidence and evidence // Tracker Detection - Enhanced display with confidence and evidence
const trackerList = document.getElementById('btTrackerList'); const trackerList = document.getElementById('btTrackerList');
if (trackerList) { if (trackerList) {
if (devices.size === 0) { if (devices.size === 0) {
trackerList.innerHTML = '<div style="color:#666;padding:10px;text-align:center;font-size:11px;">Start scanning to detect trackers</div>'; if (typeof renderCollectionState === 'function') {
renderCollectionState(trackerList, { type: 'empty', message: 'Start scanning to detect trackers' });
} else {
trackerList.innerHTML = '<div class="app-collection-state is-empty">Start scanning to detect trackers</div>';
}
} else if (deviceStats.trackers.length === 0) { } else if (deviceStats.trackers.length === 0) {
trackerList.innerHTML = '<div style="color:#22c55e;padding:10px;text-align:center;font-size:11px;">No trackers detected</div>'; if (typeof renderCollectionState === 'function') {
renderCollectionState(trackerList, { type: 'empty', message: 'No trackers detected' });
} else {
trackerList.innerHTML = '<div class="app-collection-state is-empty">No trackers detected</div>';
}
} else { } else {
// Sort by risk score (highest first), then confidence // Sort by risk score (highest first), then confidence
const sortedTrackers = [...deviceStats.trackers].sort((a, b) => { const sortedTrackers = [...deviceStats.trackers].sort((a, b) => {
@@ -1162,48 +1280,38 @@ const BluetoothMode = (function() {
return confB - confA; return confB - confA;
}); });
trackerList.innerHTML = sortedTrackers.map(t => { trackerList.innerHTML = sortedTrackers.map((t) => {
// Get tracker type badge color based on confidence
const confidence = t.tracker_confidence || 'low'; const confidence = t.tracker_confidence || 'low';
const confColor = confidence === 'high' ? '#ef4444' :
confidence === 'medium' ? '#f97316' : '#eab308';
const confBg = confidence === 'high' ? 'rgba(239,68,68,0.2)' :
confidence === 'medium' ? 'rgba(249,115,22,0.2)' : 'rgba(234,179,8,0.2)';
// Risk score indicator
const riskScore = t.risk_score || 0; const riskScore = t.risk_score || 0;
const riskColor = riskScore >= 0.5 ? '#ef4444' : riskScore >= 0.3 ? '#f97316' : '#666';
// Tracker type label
const trackerType = t.tracker_name || t.tracker_type || 'Unknown Tracker'; const trackerType = t.tracker_name || t.tracker_type || 'Unknown Tracker';
// Build evidence tooltip (first 2 items)
const evidence = (t.tracker_evidence || []).slice(0, 2); const evidence = (t.tracker_evidence || []).slice(0, 2);
const evidenceHtml = evidence.length > 0 const evidenceHtml = evidence.length > 0
? '<div style="font-size:9px;color:#888;margin-top:3px;font-style:italic;">' + ? `<div class="bt-tracker-evidence">${evidence.map((e) => `${escapeHtml(e)}`).join('<br>')}</div>`
evidence.map(e => '• ' + escapeHtml(e)).join('<br>') + : '';
'</div>' const riskClass = riskScore >= 0.5 ? 'high' : riskScore >= 0.3 ? 'medium' : 'low';
const riskHtml = riskScore >= 0.3
? `<span class="bt-tracker-risk bt-risk-${riskClass}">RISK ${Math.round(riskScore * 100)}%</span>`
: ''; : '';
const deviceIdEscaped = escapeHtml(t.device_id).replace(/'/g, "\\'"); return `
<div class="bt-tracker-item bt-tracker-confidence-${escapeHtml(confidence)}" data-device-id="${escapeAttr(t.device_id)}" role="button" tabindex="0" data-keyboard-activate="true">
return '<div class="bt-tracker-item" style="padding:8px;border-bottom:1px solid rgba(255,255,255,0.05);cursor:pointer;" onclick="BluetoothMode.selectDevice(\'' + deviceIdEscaped + '\')">' + <div class="bt-tracker-row-top">
'<div style="display:flex;justify-content:space-between;align-items:center;">' + <div class="bt-tracker-left">
'<div style="display:flex;align-items:center;gap:6px;">' + <span class="bt-tracker-confidence">${escapeHtml(confidence.toUpperCase())}</span>
'<span style="background:' + confBg + ';color:' + confColor + ';font-size:9px;padding:2px 5px;border-radius:3px;font-weight:600;">' + confidence.toUpperCase() + '</span>' + <span class="bt-tracker-type">${escapeHtml(trackerType)}</span>
'<span style="color:#fff;font-size:11px;">' + escapeHtml(trackerType) + '</span>' + </div>
'</div>' + <div class="bt-tracker-right">
'<div style="display:flex;align-items:center;gap:8px;">' + ${riskHtml}
(riskScore >= 0.3 ? '<span style="color:' + riskColor + ';font-size:9px;font-weight:600;">RISK ' + Math.round(riskScore * 100) + '%</span>' : '') + <span class="bt-tracker-rssi">${t.rssi_current != null ? t.rssi_current : '--'} dBm</span>
'<span style="color:#666;font-size:10px;">' + (t.rssi_current || '--') + ' dBm</span>' + </div>
'</div>' + </div>
'</div>' + <div class="bt-tracker-row-bottom">
'<div style="display:flex;justify-content:space-between;margin-top:3px;">' + <span class="bt-tracker-address">${escapeHtml(t.address_type === 'uuid' ? formatAddress(t) : (t.address || '--'))}</span>
'<span style="font-size:9px;color:#888;font-family:monospace;">' + (t.address_type === 'uuid' ? formatAddress(t) : t.address) + '</span>' + <span class="bt-tracker-seen">Seen ${t.seen_count || 0}x</span>
'<span style="font-size:9px;color:#666;">Seen ' + (t.seen_count || 0) + 'x</span>' + </div>
'</div>' + ${evidenceHtml}
evidenceHtml + </div>
'</div>'; `;
}).join(''); }).join('');
} }
} }
@@ -1214,12 +1322,14 @@ const BluetoothMode = (function() {
updateFilteredCount(); updateFilteredCount();
} }
function renderDevice(device) { function renderDevice(device, reapplyFilter = true) {
if (!deviceContainer) { if (!deviceContainer) {
deviceContainer = document.getElementById('btDeviceListContent'); deviceContainer = document.getElementById('btDeviceListContent');
if (!deviceContainer) return; if (!deviceContainer) return;
} }
deviceContainer.querySelectorAll('.app-collection-state, .bt-device-filter-state').forEach((el) => el.remove());
const escapedId = CSS.escape(device.device_id); const escapedId = CSS.escape(device.device_id);
const existingCard = deviceContainer.querySelector('[data-bt-device-id="' + escapedId + '"]'); const existingCard = deviceContainer.querySelector('[data-bt-device-id="' + escapedId + '"]');
const cardHtml = createSimpleDeviceCard(device); const cardHtml = createSimpleDeviceCard(device);
@@ -1230,8 +1340,7 @@ const BluetoothMode = (function() {
deviceContainer.insertAdjacentHTML('afterbegin', cardHtml); deviceContainer.insertAdjacentHTML('afterbegin', cardHtml);
} }
// Re-apply filter after rendering if (reapplyFilter) {
if (currentDeviceFilter !== 'all') {
applyDeviceFilter(); applyDeviceFilter();
} }
} }
@@ -1259,7 +1368,14 @@ const BluetoothMode = (function() {
const addr = escapeHtml(isUuidAddress(device) ? formatAddress(device) : (device.address || 'Unknown')); const addr = escapeHtml(isUuidAddress(device) ? formatAddress(device) : (device.address || 'Unknown'));
const mfr = device.manufacturer_name ? escapeHtml(device.manufacturer_name) : ''; const mfr = device.manufacturer_name ? escapeHtml(device.manufacturer_name) : '';
const seenCount = device.seen_count || 0; const seenCount = device.seen_count || 0;
const deviceIdEscaped = escapeHtml(device.device_id).replace(/'/g, "\\'"); const searchIndex = [
displayName,
device.address,
device.manufacturer_name,
device.tracker_name,
device.tracker_type,
agentName
].filter(Boolean).join(' ').toLowerCase();
// Protocol badge - compact // Protocol badge - compact
const protoBadge = protocol === 'ble' const protoBadge = protocol === 'ble'
@@ -1346,7 +1462,7 @@ const BluetoothMode = (function() {
const borderColor = isTracker && trackerConfidence === 'high' ? '#ef4444' : const borderColor = isTracker && trackerConfidence === 'high' ? '#ef4444' :
isTracker ? '#f97316' : rssiColor; isTracker ? '#f97316' : rssiColor;
return '<div class="bt-device-row' + (isTracker ? ' is-tracker' : '') + '" data-bt-device-id="' + escapeHtml(device.device_id) + '" data-is-new="' + isNew + '" data-has-name="' + hasName + '" data-rssi="' + (rssi || -100) + '" data-is-tracker="' + isTracker + '" onclick="BluetoothMode.selectDevice(\'' + deviceIdEscaped + '\')" style="border-left-color:' + borderColor + ';">' + return '<div class="bt-device-row' + (isTracker ? ' is-tracker' : '') + '" data-bt-device-id="' + escapeAttr(device.device_id) + '" data-is-new="' + isNew + '" data-has-name="' + hasName + '" data-rssi="' + (rssi || -100) + '" data-is-tracker="' + isTracker + '" data-search="' + escapeAttr(searchIndex) + '" role="button" tabindex="0" data-keyboard-activate="true" style="border-left-color:' + borderColor + ';">' +
'<div class="bt-row-main">' + '<div class="bt-row-main">' +
'<div class="bt-row-left">' + '<div class="bt-row-left">' +
protoBadge + protoBadge +
@@ -1367,7 +1483,7 @@ const BluetoothMode = (function() {
'</div>' + '</div>' +
'<div class="bt-row-secondary">' + secondaryInfo + '</div>' + '<div class="bt-row-secondary">' + secondaryInfo + '</div>' +
'<div class="bt-row-actions">' + '<div class="bt-row-actions">' +
'<button class="bt-locate-btn" data-locate-id="' + escapeHtml(device.device_id) + '" onclick="event.stopPropagation(); BluetoothMode.locateById(this.dataset.locateId)">' + '<button type="button" class="bt-locate-btn" data-locate-id="' + escapeAttr(device.device_id) + '">' +
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>' + '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>' +
'Locate</button>' + 'Locate</button>' +
'</div>' + '</div>' +
@@ -1390,6 +1506,10 @@ const BluetoothMode = (function() {
return div.innerHTML; return div.innerHTML;
} }
function escapeAttr(text) {
return escapeHtml(text).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
async function setBaseline() { async function setBaseline() {
try { try {
const response = await fetch('/api/bluetooth/baseline/set', { method: 'POST' }); const response = await fetch('/api/bluetooth/baseline/set', { method: 'POST' });
@@ -1499,15 +1619,19 @@ const BluetoothMode = (function() {
*/ */
function clearData() { function clearData() {
devices.clear(); devices.clear();
pendingDeviceIds.clear();
pendingDeviceFlush = false;
selectedDeviceNeedsRefresh = false;
resetStats(); resetStats();
clearSelection();
if (deviceContainer) { if (deviceContainer) {
deviceContainer.innerHTML = ''; if (typeof renderCollectionState === 'function') {
renderCollectionState(deviceContainer, { type: 'empty', message: 'Start scanning to discover Bluetooth devices' });
} else {
deviceContainer.innerHTML = '';
}
} }
updateDeviceCount();
updateProximityZones();
updateRadar();
} }
/** /**
@@ -1548,7 +1672,15 @@ const BluetoothMode = (function() {
// Re-render device list // Re-render device list
if (deviceContainer) { if (deviceContainer) {
deviceContainer.innerHTML = ''; deviceContainer.innerHTML = '';
devices.forEach(device => renderDevice(device)); devices.forEach(device => renderDevice(device, false));
applyDeviceFilter();
if (devices.size === 0 && typeof renderCollectionState === 'function') {
renderCollectionState(deviceContainer, { type: 'empty', message: 'No devices for current agent' });
}
}
if (selectedDeviceId && !devices.has(selectedDeviceId)) {
clearSelection();
} }
updateDeviceCount(); updateDeviceCount();
@@ -1586,6 +1718,7 @@ const BluetoothMode = (function() {
if (typeof BtLocate !== 'undefined') { if (typeof BtLocate !== 'undefined') {
BtLocate.handoff({ BtLocate.handoff({
device_id: device.device_id, device_id: device.device_id,
device_key: device.device_key || null,
mac_address: device.address, mac_address: device.address,
address_type: device.address_type || null, address_type: device.address_type || null,
irk_hex: device.irk_hex || null, irk_hex: device.irk_hex || null,
@@ -1594,7 +1727,7 @@ const BluetoothMode = (function() {
last_known_rssi: device.rssi_current, last_known_rssi: device.rssi_current,
tx_power: device.tx_power || null, tx_power: device.tx_power || null,
appearance_name: device.appearance_name || null, appearance_name: device.appearance_name || null,
fingerprint_id: device.fingerprint_id || null, fingerprint_id: device.fingerprint_id || device.fingerprint?.id || null,
mac_cluster_count: device.mac_cluster_count || 0 mac_cluster_count: device.mac_cluster_count || 0
}); });
} }
File diff suppressed because it is too large Load Diff
+23 -8
View File
@@ -23,6 +23,16 @@ const GPS = (function() {
function init() { function init() {
drawEmptySkyView(); drawEmptySkyView();
connect(); connect();
// Redraw sky view when theme changes
const observer = new MutationObserver(() => {
if (lastSky) {
drawSkyView(lastSky.satellites || []);
} else {
drawEmptySkyView();
}
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
} }
function connect() { function connect() {
@@ -316,13 +326,18 @@ const GPS = (function() {
ctx.clearRect(0, 0, w, h); ctx.clearRect(0, 0, w, h);
const cs = getComputedStyle(document.documentElement);
const bgColor = cs.getPropertyValue('--bg-card').trim() || '#0d1117';
const gridColor = cs.getPropertyValue('--border-color').trim() || '#2a3040';
const dimColor = cs.getPropertyValue('--text-dim').trim() || '#555';
const secondaryColor = cs.getPropertyValue('--text-secondary').trim() || '#888';
// Background // Background
const bgStyle = getComputedStyle(document.documentElement).getPropertyValue('--bg-card').trim(); ctx.fillStyle = bgColor;
ctx.fillStyle = bgStyle || '#0d1117';
ctx.fillRect(0, 0, w, h); ctx.fillRect(0, 0, w, h);
// Elevation rings (0, 30, 60, 90) // Elevation rings (0, 30, 60, 90)
ctx.strokeStyle = '#2a3040'; ctx.strokeStyle = gridColor;
ctx.lineWidth = 0.5; ctx.lineWidth = 0.5;
[90, 60, 30].forEach(el => { [90, 60, 30].forEach(el => {
const gr = r * (1 - el / 90); const gr = r * (1 - el / 90);
@@ -330,7 +345,7 @@ const GPS = (function() {
ctx.arc(cx, cy, gr, 0, Math.PI * 2); ctx.arc(cx, cy, gr, 0, Math.PI * 2);
ctx.stroke(); ctx.stroke();
// Label // Label
ctx.fillStyle = '#555'; ctx.fillStyle = dimColor;
ctx.font = '9px Roboto Condensed, monospace'; ctx.font = '9px Roboto Condensed, monospace';
ctx.textAlign = 'left'; ctx.textAlign = 'left';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
@@ -338,14 +353,14 @@ const GPS = (function() {
}); });
// Horizon circle // Horizon circle
ctx.strokeStyle = '#3a4050'; ctx.strokeStyle = gridColor;
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.beginPath(); ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.stroke(); ctx.stroke();
// Cardinal directions // Cardinal directions
ctx.fillStyle = '#888'; ctx.fillStyle = secondaryColor;
ctx.font = 'bold 11px Roboto Condensed, monospace'; ctx.font = 'bold 11px Roboto Condensed, monospace';
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
@@ -355,7 +370,7 @@ const GPS = (function() {
ctx.fillText('W', cx - r - 12, cy); ctx.fillText('W', cx - r - 12, cy);
// Crosshairs // Crosshairs
ctx.strokeStyle = '#2a3040'; ctx.strokeStyle = gridColor;
ctx.lineWidth = 0.5; ctx.lineWidth = 0.5;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(cx, cy - r); ctx.moveTo(cx, cy - r);
@@ -365,7 +380,7 @@ const GPS = (function() {
ctx.stroke(); ctx.stroke();
// Zenith dot // Zenith dot
ctx.fillStyle = '#333'; ctx.fillStyle = dimColor;
ctx.beginPath(); ctx.beginPath();
ctx.arc(cx, cy, 2, 0, Math.PI * 2); ctx.arc(cx, cy, 2, 0, Math.PI * 2);
ctx.fill(); ctx.fill();
+1 -1
View File
@@ -401,7 +401,7 @@ const Meshtastic = (function() {
// Position is nested in the response // Position is nested in the response
const pos = info.position; const pos = info.position;
if (pos && pos.latitude && pos.longitude) { if (pos && pos.latitude !== undefined && pos.latitude !== null && pos.longitude !== undefined && pos.longitude !== null) {
if (posRow) posRow.style.display = 'flex'; if (posRow) posRow.style.display = 'flex';
if (posEl) posEl.textContent = `${pos.latitude.toFixed(5)}, ${pos.longitude.toFixed(5)}`; if (posEl) posEl.textContent = `${pos.latitude.toFixed(5)}, ${pos.longitude.toFixed(5)}`;
} else { } else {
+677
View File
@@ -0,0 +1,677 @@
/**
* Space Weather Mode IIFE module
* Polls /space-weather/data every 5 min, renders dashboard with Chart.js
*/
const SpaceWeather = (function () {
'use strict';
let _initialized = false;
let _pollTimer = null;
let _autoRefresh = true;
const POLL_INTERVAL = 5 * 60 * 1000; // 5 min
// Chart.js instances
let _kpChart = null;
let _windChart = null;
let _xrayChart = null;
// Current image selections
let _solarImageKey = 'sdo_193';
let _drapFreq = 'drap_global';
// -------------------------------------------------------------------
// Public API
// -------------------------------------------------------------------
function init() {
if (!_initialized) {
_initialized = true;
}
refresh();
_startAutoRefresh();
}
function destroy() {
_stopAutoRefresh();
_destroyCharts();
_initialized = false;
}
function refresh() {
_fetchData();
}
function selectSolarImage(key) {
_solarImageKey = key;
_updateSolarImageTabs();
const frame = document.getElementById('swSolarImageFrame');
if (frame) {
frame.innerHTML = '<div class="sw-loading">Loading</div>';
const img = new Image();
img.onload = function () { frame.innerHTML = ''; frame.appendChild(img); };
img.onerror = function () { frame.innerHTML = '<div class="sw-empty">Failed to load image</div>'; };
img.src = '/space-weather/image/' + key + '?t=' + Date.now();
img.alt = key;
}
}
function selectDrapFreq(key) {
_drapFreq = key;
_updateDrapTabs();
const frame = document.getElementById('swDrapImageFrame');
if (frame) {
frame.innerHTML = '<div class="sw-loading">Loading</div>';
const img = new Image();
img.onload = function () { frame.innerHTML = ''; frame.appendChild(img); };
img.onerror = function () { frame.innerHTML = '<div class="sw-empty">Failed to load image</div>'; };
img.src = '/space-weather/image/' + key + '?t=' + Date.now();
img.alt = key;
}
}
function toggleAutoRefresh() {
const cb = document.getElementById('swAutoRefresh');
_autoRefresh = cb ? cb.checked : !_autoRefresh;
if (_autoRefresh) _startAutoRefresh();
else _stopAutoRefresh();
}
// -------------------------------------------------------------------
// Polling
// -------------------------------------------------------------------
function _startAutoRefresh() {
_stopAutoRefresh();
if (_autoRefresh) {
_pollTimer = setInterval(_fetchData, POLL_INTERVAL);
}
}
function _stopAutoRefresh() {
if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; }
}
function _fetchData() {
fetch('/space-weather/data')
.then(function (r) { return r.json(); })
.then(function (data) {
_renderAll(data);
_updateTimestamp();
})
.catch(function (err) {
console.warn('SpaceWeather fetch error:', err);
});
}
// -------------------------------------------------------------------
// Master render
// -------------------------------------------------------------------
function _renderAll(data) {
_renderHeaderStrip(data);
_renderScales(data);
_renderBandConditions(data);
_renderKpChart(data);
_renderWindChart(data);
_renderXrayChart(data);
_renderFlareProb(data);
_renderSolarImage();
_renderDrapImage();
_renderAuroraImage();
_renderAlerts(data);
_renderRegions(data);
_updateSidebar(data);
}
// -------------------------------------------------------------------
// Header strip
// -------------------------------------------------------------------
function _renderHeaderStrip(data) {
var sfi = '--', kp = '--', aIndex = '--', ssn = '--', wind = '--', bz = '--';
// SFI from band_conditions (HamQSL) or flux
if (data.band_conditions && data.band_conditions.sfi) {
sfi = data.band_conditions.sfi;
} else if (data.flux && data.flux.length > 1) {
var last = data.flux[data.flux.length - 1];
sfi = last[1] || '--';
}
// Kp from kp_index
if (data.kp_index && data.kp_index.length > 1) {
var lastKp = data.kp_index[data.kp_index.length - 1];
kp = lastKp[1] || '--';
}
// A-index from band_conditions
if (data.band_conditions && data.band_conditions.aindex) {
aIndex = data.band_conditions.aindex;
}
// Sunspot number
if (data.band_conditions && data.band_conditions.sunspots) {
ssn = data.band_conditions.sunspots;
}
// Solar wind speed — last non-null entry
if (data.solar_wind_plasma && data.solar_wind_plasma.length > 1) {
for (var i = data.solar_wind_plasma.length - 1; i >= 1; i--) {
if (data.solar_wind_plasma[i][2]) {
wind = Math.round(parseFloat(data.solar_wind_plasma[i][2]));
break;
}
}
}
// IMF Bz — last non-null entry
if (data.solar_wind_mag && data.solar_wind_mag.length > 1) {
for (var j = data.solar_wind_mag.length - 1; j >= 1; j--) {
if (data.solar_wind_mag[j][3]) {
bz = parseFloat(data.solar_wind_mag[j][3]).toFixed(1);
break;
}
}
}
_setText('swStripSfi', sfi);
_setText('swStripKp', kp);
_setText('swStripA', aIndex);
_setText('swStripSsn', ssn);
_setText('swStripWind', wind !== '--' ? wind + ' km/s' : '--');
_setText('swStripBz', bz !== '--' ? bz + ' nT' : '--');
// Color Kp by severity
var kpEl = document.getElementById('swStripKp');
if (kpEl) {
var kpNum = parseFloat(kp);
kpEl.className = 'sw-header-value';
if (kpNum >= 7) kpEl.classList.add('accent-red');
else if (kpNum >= 5) kpEl.classList.add('accent-orange');
else if (kpNum >= 4) kpEl.classList.add('accent-yellow');
else kpEl.classList.add('accent-green');
}
// Color Bz — negative is bad
var bzEl = document.getElementById('swStripBz');
if (bzEl) {
var bzNum = parseFloat(bz);
bzEl.className = 'sw-header-value';
if (bzNum < -10) bzEl.classList.add('accent-red');
else if (bzNum < -5) bzEl.classList.add('accent-orange');
else if (bzNum < 0) bzEl.classList.add('accent-yellow');
else bzEl.classList.add('accent-green');
}
}
// -------------------------------------------------------------------
// NOAA Scales
// -------------------------------------------------------------------
function _renderScales(data) {
if (!data.scales) return;
var s = data.scales;
// Structure: { "0": { R: {Scale, Text}, S: {Scale, Text}, G: {Scale, Text} }, ... }
// Key "0" = current conditions
var current = s['0'];
if (!current) return;
var scaleMap = {
'G': { el: 'swScaleG', label: 'Geomagnetic Storms' },
'S': { el: 'swScaleS', label: 'Solar Radiation' },
'R': { el: 'swScaleR', label: 'Radio Blackouts' }
};
['G', 'S', 'R'].forEach(function (k) {
var info = scaleMap[k];
var scaleData = current[k];
var val = '0', text = info.label;
if (scaleData) {
val = String(scaleData.Scale || '0').replace(/[^0-9]/g, '') || '0';
if (scaleData.Text && scaleData.Text !== 'none') {
text = scaleData.Text;
}
}
var el = document.getElementById(info.el);
if (el) {
el.querySelector('.sw-scale-value').textContent = k + val;
el.querySelector('.sw-scale-value').className = 'sw-scale-value sw-scale-' + val;
var descEl = el.querySelector('.sw-scale-desc');
if (descEl) descEl.textContent = text;
}
});
}
// -------------------------------------------------------------------
// Band conditions
// -------------------------------------------------------------------
function _renderBandConditions(data) {
var grid = document.getElementById('swBandGrid');
if (!grid) return;
if (!data.band_conditions || !data.band_conditions.bands || data.band_conditions.bands.length === 0) {
grid.innerHTML = '<div class="sw-empty" style="grid-column:1/-1">No band data available</div>';
return;
}
// Group by band name, collect day/night
var bands = {};
data.band_conditions.bands.forEach(function (b) {
if (!bands[b.name]) bands[b.name] = {};
bands[b.name][b.time.toLowerCase()] = b.condition;
});
var html = '<div class="sw-band-header">Band</div><div class="sw-band-header" style="text-align:center">Day</div><div class="sw-band-header" style="text-align:center">Night</div>';
Object.keys(bands).forEach(function (name) {
html += '<div class="sw-band-name">' + name + '</div>';
['day', 'night'].forEach(function (t) {
var cond = bands[name][t] || '--';
var cls = 'sw-band-cond';
var cl = cond.toLowerCase();
if (cl === 'good') cls += ' sw-band-good';
else if (cl === 'fair') cls += ' sw-band-fair';
else if (cl === 'poor') cls += ' sw-band-poor';
html += '<div class="' + cls + '">' + cond + '</div>';
});
});
grid.innerHTML = html;
}
// -------------------------------------------------------------------
// Kp bar chart
// -------------------------------------------------------------------
function _renderKpChart(data) {
var canvas = document.getElementById('swKpChart');
if (!canvas) return;
if (!data.kp_index || data.kp_index.length < 2) return;
var rows = data.kp_index.slice(1); // skip header
var labels = [];
var values = [];
var colors = [];
// Take last 24 entries
var subset = rows.slice(-24);
subset.forEach(function (r) {
var dt = r[0] || '';
labels.push(dt.slice(5, 16)); // MM-DD HH:MM
var v = parseFloat(r[1]) || 0;
values.push(v);
if (v >= 7) colors.push('#ff3366');
else if (v >= 5) colors.push('#ff8800');
else if (v >= 4) colors.push('#ffcc00');
else colors.push('#00ff88');
});
if (_kpChart) { _kpChart.destroy(); _kpChart = null; }
_kpChart = new Chart(canvas, {
type: 'bar',
data: {
labels: labels,
datasets: [{
data: values,
backgroundColor: colors,
borderWidth: 0,
barPercentage: 0.8
}]
},
options: _chartOpts('Kp', 0, 9, false)
});
}
// -------------------------------------------------------------------
// Solar wind chart
// -------------------------------------------------------------------
function _renderWindChart(data) {
var canvas = document.getElementById('swWindChart');
if (!canvas) return;
if (!data.solar_wind_plasma || data.solar_wind_plasma.length < 2) return;
var rows = data.solar_wind_plasma.slice(1);
var labels = [];
var speedData = [];
var densityData = [];
// Sample every 3rd point to avoid overcrowding
for (var i = 0; i < rows.length; i += 3) {
var r = rows[i];
labels.push(r[0] ? r[0].slice(11, 16) : '');
speedData.push(r[2] ? parseFloat(r[2]) : null);
densityData.push(r[1] ? parseFloat(r[1]) : null);
}
if (_windChart) { _windChart.destroy(); _windChart = null; }
_windChart = new Chart(canvas, {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: 'Speed (km/s)',
data: speedData,
borderColor: '#00ccff',
backgroundColor: '#00ccff22',
borderWidth: 1.5,
pointRadius: 0,
fill: true,
tension: 0.3,
yAxisID: 'y'
},
{
label: 'Density (p/cm³)',
data: densityData,
borderColor: '#ff8800',
borderWidth: 1,
pointRadius: 0,
borderDash: [4, 2],
tension: 0.3,
yAxisID: 'y1'
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: true, position: 'top', labels: { color: '#888', font: { size: 10 }, boxWidth: 12, padding: 8 } }
},
scales: {
x: { display: true, ticks: { color: '#555', font: { size: 9 }, maxTicksLimit: 8 }, grid: { color: '#ffffff08' } },
y: { display: true, position: 'left', ticks: { color: '#00ccff', font: { size: 9 } }, grid: { color: '#ffffff08' }, title: { display: false } },
y1: { display: true, position: 'right', ticks: { color: '#ff8800', font: { size: 9 } }, grid: { drawOnChartArea: false } }
},
interaction: { mode: 'index', intersect: false }
}
});
}
// -------------------------------------------------------------------
// X-ray flux chart
// -------------------------------------------------------------------
function _renderXrayChart(data) {
var canvas = document.getElementById('swXrayChart');
if (!canvas) return;
if (!data.xrays || data.xrays.length < 2) return;
// New format: array of objects with time_tag, flux, energy
// Filter to short-wavelength (0.1-0.8nm) only
var filtered = data.xrays.filter(function (r) {
return r.energy && r.energy === '0.1-0.8nm';
});
if (filtered.length === 0) filtered = data.xrays;
var labels = [];
var values = [];
// Sample every 3rd point
for (var i = 0; i < filtered.length; i += 3) {
var r = filtered[i];
var tag = r.time_tag || '';
labels.push(tag.slice(11, 16));
values.push(r.flux ? parseFloat(r.flux) : null);
}
if (_xrayChart) { _xrayChart.destroy(); _xrayChart = null; }
_xrayChart = new Chart(canvas, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: 'X-Ray Flux (W/m²)',
data: values,
borderColor: '#ff3366',
backgroundColor: '#ff336622',
borderWidth: 1.5,
pointRadius: 0,
fill: true,
tension: 0.3
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false }
},
scales: {
x: { display: true, ticks: { color: '#555', font: { size: 9 }, maxTicksLimit: 8 }, grid: { color: '#ffffff08' } },
y: {
display: true,
type: 'logarithmic',
ticks: {
color: '#888',
font: { size: 9 },
callback: function (v) {
if (v >= 1e-4) return 'X';
if (v >= 1e-5) return 'M';
if (v >= 1e-6) return 'C';
if (v >= 1e-7) return 'B';
if (v >= 1e-8) return 'A';
return '';
}
},
grid: { color: '#ffffff08' }
}
}
}
});
}
// -------------------------------------------------------------------
// Flare probability
// -------------------------------------------------------------------
function _renderFlareProb(data) {
var el = document.getElementById('swFlareProb');
if (!el) return;
if (!data.flare_probability || data.flare_probability.length === 0) {
el.innerHTML = '<div class="sw-empty">No flare data</div>';
return;
}
// New format: array of objects with date, c_class_1_day, m_class_1_day, x_class_1_day, etc.
var latest = data.flare_probability.slice(-3);
var html = '<table class="sw-prob-table"><thead><tr>';
html += '<th>Date</th><th>C 1-day</th><th>M 1-day</th><th>X 1-day</th><th>Proton</th>';
html += '</tr></thead><tbody>';
latest.forEach(function (row) {
html += '<tr>';
html += '<td>' + _escHtml(row.date || '--') + '</td>';
html += '<td>' + _escHtml(row.c_class_1_day || '--') + '%</td>';
html += '<td>' + _escHtml(row.m_class_1_day || '--') + '%</td>';
html += '<td>' + _escHtml(row.x_class_1_day || '--') + '%</td>';
html += '<td>' + _escHtml(row['10mev_protons_1_day'] || '--') + '%</td>';
html += '</tr>';
});
html += '</tbody></table>';
el.innerHTML = html;
}
// -------------------------------------------------------------------
// Images
// -------------------------------------------------------------------
function _renderSolarImage() {
selectSolarImage(_solarImageKey);
}
function _renderDrapImage() {
selectDrapFreq(_drapFreq);
}
function _renderAuroraImage() {
var frame = document.getElementById('swAuroraFrame');
if (!frame) return;
var img = new Image();
img.onload = function () { frame.innerHTML = ''; frame.appendChild(img); };
img.onerror = function () { frame.innerHTML = '<div class="sw-empty">Failed to load aurora image</div>'; };
img.src = '/space-weather/image/aurora_north?t=' + Date.now();
img.alt = 'Aurora Forecast';
}
function _updateSolarImageTabs() {
document.querySelectorAll('.sw-solar-tab').forEach(function (btn) {
btn.classList.toggle('active', btn.dataset.key === _solarImageKey);
});
}
function _updateDrapTabs() {
document.querySelectorAll('.sw-drap-freq-btn').forEach(function (btn) {
btn.classList.toggle('active', btn.dataset.key === _drapFreq);
});
}
// -------------------------------------------------------------------
// Alerts
// -------------------------------------------------------------------
function _renderAlerts(data) {
var el = document.getElementById('swAlertsList');
if (!el) return;
if (!data.alerts || data.alerts.length === 0) {
el.innerHTML = '<div class="sw-empty">No active alerts</div>';
return;
}
var html = '';
// Show latest 10
var items = data.alerts.slice(0, 10);
items.forEach(function (a) {
var msg = a.message || a.product_text || '';
// Truncate long messages
if (msg.length > 300) msg = msg.substring(0, 300) + '...';
html += '<div class="sw-alert-item">';
html += '<div class="sw-alert-type">' + _escHtml(a.product_id || 'Alert') + '</div>';
html += '<div class="sw-alert-time">' + _escHtml(a.issue_datetime || '') + '</div>';
html += '<div class="sw-alert-msg">' + _escHtml(msg) + '</div>';
html += '</div>';
});
el.innerHTML = html;
}
// -------------------------------------------------------------------
// Active regions
// -------------------------------------------------------------------
function _renderRegions(data) {
var el = document.getElementById('swRegionsBody');
if (!el) return;
if (!data.solar_regions || data.solar_regions.length === 0) {
el.innerHTML = '<tr><td colspan="5" class="sw-empty">No active regions</td></tr>';
return;
}
// New format: array of objects with region, observed_date, location, longitude, area, etc.
// De-duplicate by region number (keep latest observed_date per region)
var byRegion = {};
data.solar_regions.forEach(function (r) {
var key = r.region || '';
if (!byRegion[key] || (r.observed_date > byRegion[key].observed_date)) {
byRegion[key] = r;
}
});
var regions = Object.values(byRegion);
var html = '';
regions.forEach(function (r) {
html += '<tr>';
html += '<td>' + _escHtml(String(r.region || '')) + '</td>';
html += '<td>' + _escHtml(r.observed_date || '') + '</td>';
html += '<td>' + _escHtml(r.location || '') + '</td>';
html += '<td>' + _escHtml(String(r.longitude || '')) + '</td>';
html += '<td>' + _escHtml(String(r.area || '')) + '</td>';
html += '</tr>';
});
el.innerHTML = html;
}
// -------------------------------------------------------------------
// Sidebar quick status
// -------------------------------------------------------------------
function _updateSidebar(data) {
var sfi = '--', kp = '--', aIdx = '--', ssn = '--', wind = '--', bz = '--';
if (data.band_conditions) {
if (data.band_conditions.sfi) sfi = data.band_conditions.sfi;
if (data.band_conditions.aindex) aIdx = data.band_conditions.aindex;
if (data.band_conditions.sunspots) ssn = data.band_conditions.sunspots;
}
if (data.kp_index && data.kp_index.length > 1) {
kp = data.kp_index[data.kp_index.length - 1][1] || '--';
}
if (data.solar_wind_plasma && data.solar_wind_plasma.length > 1) {
for (var i = data.solar_wind_plasma.length - 1; i >= 1; i--) {
if (data.solar_wind_plasma[i][2]) {
wind = Math.round(parseFloat(data.solar_wind_plasma[i][2])) + ' km/s';
break;
}
}
}
if (data.solar_wind_mag && data.solar_wind_mag.length > 1) {
for (var j = data.solar_wind_mag.length - 1; j >= 1; j--) {
if (data.solar_wind_mag[j][3]) {
bz = parseFloat(data.solar_wind_mag[j][3]).toFixed(1) + ' nT';
break;
}
}
}
_setText('swSidebarSfi', sfi);
_setText('swSidebarKp', kp);
_setText('swSidebarA', aIdx);
_setText('swSidebarSsn', ssn);
_setText('swSidebarWind', wind);
_setText('swSidebarBz', bz);
}
// -------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------
function _setText(id, text) {
var el = document.getElementById(id);
if (el) el.textContent = text;
}
function _escHtml(s) {
var d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function _updateTimestamp() {
var el = document.getElementById('swLastUpdate');
if (el) el.textContent = 'Updated: ' + new Date().toLocaleTimeString();
}
function _chartOpts(yLabel, yMin, yMax, showLegend) {
return {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: !!showLegend, labels: { color: '#888', font: { size: 10 } } }
},
scales: {
x: { display: true, ticks: { color: '#555', font: { size: 9 }, maxRotation: 45, maxTicksLimit: 8 }, grid: { color: '#ffffff08' } },
y: { display: true, min: yMin, max: yMax, ticks: { color: '#888', font: { size: 9 }, stepSize: 1 }, grid: { color: '#ffffff08' } }
}
};
}
function _destroyCharts() {
if (_kpChart) { _kpChart.destroy(); _kpChart = null; }
if (_windChart) { _windChart.destroy(); _windChart = null; }
if (_xrayChart) { _xrayChart.destroy(); _xrayChart = null; }
}
// -------------------------------------------------------------------
// Expose public API
// -------------------------------------------------------------------
return {
init: init,
destroy: destroy,
refresh: refresh,
selectSolarImage: selectSolarImage,
selectDrapFreq: selectDrapFreq,
toggleAutoRefresh: toggleAutoRefresh
};
})();
+1 -1
View File
@@ -91,7 +91,7 @@ const SSTVGeneral = (function() {
const deviceSelect = document.getElementById('deviceSelect'); const deviceSelect = document.getElementById('deviceSelect');
const frequency = parseFloat(freqInput?.value || '14.230'); const frequency = parseFloat(freqInput?.value || '14.230');
const modulation = modSelect?.value || 'usb'; const modulation = modSelect?.value || 'fm';
const device = parseInt(deviceSelect?.value || '0', 10); const device = parseInt(deviceSelect?.value || '0', 10);
updateStatusUI('connecting', 'Starting...'); updateStatusUI('connecting', 'Starting...');
+54 -6
View File
@@ -17,9 +17,11 @@ const SSTV = (function() {
let issUpdateInterval = null; let issUpdateInterval = null;
let countdownInterval = null; let countdownInterval = null;
let nextPassData = null; let nextPassData = null;
let pendingMapInvalidate = false;
// ISS frequency // ISS frequency
const ISS_FREQ = 145.800; const ISS_FREQ = 145.800;
const ISS_MODULATION = 'fm';
// Signal scope state // Signal scope state
let sstvScopeCtx = null; let sstvScopeCtx = null;
@@ -44,6 +46,22 @@ const SSTV = (function() {
initMap(); initMap();
startIssTracking(); startIssTracking();
startCountdown(); startCountdown();
// Ensure Leaflet recomputes dimensions after the SSTV pane becomes visible.
setTimeout(() => invalidateMap(), 80);
setTimeout(() => invalidateMap(), 260);
}
function isMapContainerVisible() {
if (!issMap || typeof issMap.getContainer !== 'function') return false;
const container = issMap.getContainer();
if (!container) return false;
if (container.offsetWidth <= 0 || container.offsetHeight <= 0) return false;
if (container.style && container.style.display === 'none') return false;
if (typeof window.getComputedStyle === 'function') {
const style = window.getComputedStyle(container);
if (style.display === 'none' || style.visibility === 'hidden') return false;
}
return true;
} }
/** /**
@@ -219,6 +237,14 @@ const SSTV = (function() {
opacity: 0.6, opacity: 0.6,
dashArray: '5, 5' dashArray: '5, 5'
}).addTo(issMap); }).addTo(issMap);
issMap.on('resize moveend zoomend', () => {
if (pendingMapInvalidate) invalidateMap();
});
// Initial layout passes for first-time mode load.
setTimeout(() => invalidateMap(), 40);
setTimeout(() => invalidateMap(), 180);
} }
/** /**
@@ -430,6 +456,7 @@ const SSTV = (function() {
*/ */
function updateMap() { function updateMap() {
if (!issMap || !issPosition) return; if (!issMap || !issPosition) return;
if (pendingMapInvalidate) invalidateMap();
const lat = issPosition.lat; const lat = issPosition.lat;
const lon = issPosition.lon; const lon = issPosition.lon;
@@ -489,8 +516,12 @@ const SSTV = (function() {
issTrackLine.setLatLngs(segments.length > 0 ? segments : []); issTrackLine.setLatLngs(segments.length > 0 ? segments : []);
} }
// Pan map to follow ISS // Pan map to follow ISS only when the map pane is currently renderable.
issMap.panTo([lat, lon], { animate: true, duration: 0.5 }); if (isMapContainerVisible()) {
issMap.panTo([lat, lon], { animate: true, duration: 0.5 });
} else {
pendingMapInvalidate = true;
}
} }
/** /**
@@ -544,7 +575,7 @@ const SSTV = (function() {
const response = await fetch('/sstv/start', { const response = await fetch('/sstv/start', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ frequency, device }) body: JSON.stringify({ frequency, modulation: ISS_MODULATION, device })
}); });
const data = await response.json(); const data = await response.json();
@@ -554,9 +585,11 @@ const SSTV = (function() {
if (typeof reserveDevice === 'function') { if (typeof reserveDevice === 'function') {
reserveDevice(device, 'sstv'); reserveDevice(device, 'sstv');
} }
updateStatusUI('listening', `${frequency} MHz`); const tunedFrequency = Number(data.frequency || frequency);
const modulationText = String(data.modulation || ISS_MODULATION).toUpperCase();
updateStatusUI('listening', `${tunedFrequency.toFixed(3)} MHz ${modulationText}`);
startStream(); startStream();
showNotification('SSTV', `Listening on ${frequency} MHz`); showNotification('SSTV', `Listening on ${tunedFrequency.toFixed(3)} MHz ${modulationText}`);
} else { } else {
updateStatusUI('idle', 'Start failed'); updateStatusUI('idle', 'Start failed');
showStatusMessage(data.message || 'Failed to start decoder', 'error'); showStatusMessage(data.message || 'Failed to start decoder', 'error');
@@ -1310,6 +1343,20 @@ const SSTV = (function() {
} }
} }
/**
* Invalidate ISS map size after pane/layout changes.
*/
function invalidateMap() {
if (!issMap) return false;
if (!isMapContainerVisible()) {
pendingMapInvalidate = true;
return false;
}
issMap.invalidateSize({ pan: false, animate: false });
pendingMapInvalidate = false;
return true;
}
// Public API // Public API
return { return {
init, init,
@@ -1325,7 +1372,8 @@ const SSTV = (function() {
useGPS, useGPS,
updateTLE, updateTLE,
stopIssTracking, stopIssTracking,
stopCountdown stopCountdown,
invalidateMap
}; };
})(); })();
+492 -73
View File
@@ -1,7 +1,7 @@
/** /**
* Weather Satellite Mode * Weather Satellite Mode
* NOAA APT and Meteor LRPT decoder interface with auto-scheduler, * NOAA APT and Meteor LRPT decoder interface with auto-scheduler,
* polar plot, ground track map, countdown, and timeline. * polar plot, styled real-world map, countdown, and timeline.
*/ */
const WeatherSat = (function() { const WeatherSat = (function() {
@@ -16,12 +16,16 @@ const WeatherSat = (function() {
let schedulerEnabled = false; let schedulerEnabled = false;
let groundMap = null; let groundMap = null;
let groundTrackLayer = null; let groundTrackLayer = null;
let groundOverlayLayer = null;
let groundGridLayer = null;
let satCrosshairMarker = null;
let observerMarker = null; let observerMarker = null;
let consoleEntries = []; let consoleEntries = [];
let consoleCollapsed = false; let consoleCollapsed = false;
let currentPhase = 'idle'; let currentPhase = 'idle';
let consoleAutoHideTimer = null; let consoleAutoHideTimer = null;
let currentModalFilename = null; let currentModalFilename = null;
let locationListenersAttached = false;
/** /**
* Initialize the Weather Satellite mode * Initialize the Weather Satellite mode
@@ -36,6 +40,39 @@ const WeatherSat = (function() {
initGroundMap(); initGroundMap();
} }
/**
* Get observer coordinates from shared location or local storage.
*/
function getObserverCoords() {
let lat;
let lon;
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
const shared = ObserverLocation.getShared();
lat = Number(shared?.lat);
lon = Number(shared?.lon);
} else {
lat = Number(localStorage.getItem('observerLat'));
lon = Number(localStorage.getItem('observerLon'));
}
if (!isFinite(lat) || !isFinite(lon)) return null;
if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null;
return { lat, lon };
}
/**
* Center the ground map on current observer coordinates when available.
*/
function centerGroundMapOnObserver(zoom = 1) {
if (!groundMap) return;
const observer = getObserverCoords();
if (!observer) return;
const lat = Math.max(-85, Math.min(85, observer.lat));
const lon = normalizeLon(observer.lon);
groundMap.setView([lat, lon], zoom, { animate: false });
}
/** /**
* Load observer location into input fields * Load observer location into input fields
*/ */
@@ -54,8 +91,13 @@ const WeatherSat = (function() {
if (latInput && storedLat) latInput.value = storedLat; if (latInput && storedLat) latInput.value = storedLat;
if (lonInput && storedLon) lonInput.value = storedLon; if (lonInput && storedLon) lonInput.value = storedLon;
if (latInput) latInput.addEventListener('change', saveLocationFromInputs); // Only attach listeners once — re-calling init() on mode switch must not
if (lonInput) lonInput.addEventListener('change', saveLocationFromInputs); // accumulate duplicate listeners that fire loadPasses() multiple times.
if (!locationListenersAttached) {
if (latInput) latInput.addEventListener('change', saveLocationFromInputs);
if (lonInput) lonInput.addEventListener('change', saveLocationFromInputs);
locationListenersAttached = true;
}
} }
/** /**
@@ -77,6 +119,7 @@ const WeatherSat = (function() {
localStorage.setItem('observerLon', lon.toString()); localStorage.setItem('observerLon', lon.toString());
} }
loadPasses(); loadPasses();
centerGroundMapOnObserver(1);
} }
} }
@@ -115,6 +158,7 @@ const WeatherSat = (function() {
btn.disabled = false; btn.disabled = false;
showNotification('Weather Sat', 'Location updated'); showNotification('Weather Sat', 'Location updated');
loadPasses(); loadPasses();
centerGroundMapOnObserver(1);
}, },
(err) => { (err) => {
btn.innerHTML = originalText; btn.innerHTML = originalText;
@@ -399,16 +443,20 @@ const WeatherSat = (function() {
addConsoleEntry('Capture complete', 'signal'); addConsoleEntry('Capture complete', 'signal');
updatePhaseIndicator('complete'); updatePhaseIndicator('complete');
if (consoleAutoHideTimer) clearTimeout(consoleAutoHideTimer);
consoleAutoHideTimer = setTimeout(() => showConsole(false), 30000); consoleAutoHideTimer = setTimeout(() => showConsole(false), 30000);
} }
} else if (data.status === 'error') { } else if (data.status === 'error') {
isRunning = false;
if (!schedulerEnabled) stopStream();
updateStatusUI('idle', 'Error'); updateStatusUI('idle', 'Error');
showNotification('Weather Sat', data.message || 'Capture error'); showNotification('Weather Sat', data.message || 'Capture error');
if (captureStatus) captureStatus.classList.remove('active'); if (captureStatus) captureStatus.classList.remove('active');
if (data.message) addConsoleEntry(data.message, 'error'); if (data.message) addConsoleEntry(data.message, 'error');
updatePhaseIndicator('error'); updatePhaseIndicator('error');
if (consoleAutoHideTimer) clearTimeout(consoleAutoHideTimer);
consoleAutoHideTimer = setTimeout(() => showConsole(false), 15000); consoleAutoHideTimer = setTimeout(() => showConsole(false), 15000);
} }
} }
@@ -424,8 +472,17 @@ const WeatherSat = (function() {
updateStatusUI('capturing', `Auto: ${p.name || p.satellite} ${p.frequency} MHz`); updateStatusUI('capturing', `Auto: ${p.name || p.satellite} ${p.frequency} MHz`);
showNotification('Weather Sat', `Auto-capture started: ${p.name || p.satellite}`); showNotification('Weather Sat', `Auto-capture started: ${p.name || p.satellite}`);
} else if (data.type === 'schedule_capture_complete') { } else if (data.type === 'schedule_capture_complete') {
showNotification('Weather Sat', `Auto-capture complete: ${(data.pass || {}).name || ''}`); const p = data.pass || {};
showNotification('Weather Sat', `Auto-capture complete: ${p.name || ''}`);
// Reset UI — the decoder's stop() doesn't emit a progress complete event
// when called internally by the scheduler, so we handle it here.
isRunning = false;
updateStatusUI('idle', 'Auto-capture complete');
const captureStatus = document.getElementById('wxsatCaptureStatus');
if (captureStatus) captureStatus.classList.remove('active');
updatePhaseIndicator('complete');
loadImages(); loadImages();
loadPasses();
} else if (data.type === 'schedule_capture_skipped') { } else if (data.type === 'schedule_capture_skipped') {
const reason = data.reason || 'unknown'; const reason = data.reason || 'unknown';
const p = data.pass || {}; const p = data.pass || {};
@@ -442,6 +499,26 @@ const WeatherSat = (function() {
return `${m}:${s.toString().padStart(2, '0')}`; return `${m}:${s.toString().padStart(2, '0')}`;
} }
/**
* Parse pass timestamps, accepting legacy malformed UTC strings (+00:00Z).
*/
function parsePassDate(value) {
if (!value || typeof value !== 'string') return null;
let parsed = new Date(value);
if (!Number.isNaN(parsed.getTime())) {
return parsed;
}
// Backward-compatible cleanup for accidentally double-suffixed UTC timestamps.
parsed = new Date(value.replace(/\+00:00Z$/, 'Z'));
if (!Number.isNaN(parsed.getTime())) {
return parsed;
}
return null;
}
/** /**
* Load pass predictions (with trajectory + ground track) * Load pass predictions (with trajectory + ground track)
*/ */
@@ -459,7 +536,12 @@ const WeatherSat = (function() {
} }
if (!storedLat || !storedLon) { if (!storedLat || !storedLon) {
passes = [];
selectedPassIndex = -1;
renderPasses([]); renderPasses([]);
renderTimeline([]);
updateCountdownFromPasses();
updateGroundTrack(null);
return; return;
} }
@@ -470,12 +552,16 @@ const WeatherSat = (function() {
if (data.status === 'ok') { if (data.status === 'ok') {
passes = data.passes || []; passes = data.passes || [];
selectedPassIndex = -1;
renderPasses(passes); renderPasses(passes);
renderTimeline(passes); renderTimeline(passes);
updateCountdownFromPasses(); updateCountdownFromPasses();
// Auto-select first pass // Always select the first upcoming pass so the polar plot
if (passes.length > 0 && selectedPassIndex < 0) { // and ground track reflect the current list after every refresh.
if (passes.length > 0) {
selectPass(0); selectPass(0);
} else {
updateGroundTrack(null);
} }
} }
} catch (err) { } catch (err) {
@@ -532,13 +618,15 @@ const WeatherSat = (function() {
const modeClass = pass.mode === 'APT' ? 'apt' : 'lrpt'; const modeClass = pass.mode === 'APT' ? 'apt' : 'lrpt';
const timeStr = pass.startTime || '--'; const timeStr = pass.startTime || '--';
const now = new Date(); const now = new Date();
const passStart = new Date(pass.startTimeISO); const passStart = parsePassDate(pass.startTimeISO);
const diffMs = passStart - now; const diffMs = passStart ? passStart - now : NaN;
const diffMins = Math.floor(diffMs / 60000); const diffMins = Number.isFinite(diffMs) ? Math.floor(diffMs / 60000) : NaN;
const isSelected = idx === selectedPassIndex; const isSelected = idx === selectedPassIndex;
let countdown = ''; let countdown = '--';
if (diffMs < 0) { if (!Number.isFinite(diffMs)) {
countdown = '--';
} else if (diffMs < 0) {
countdown = 'NOW'; countdown = 'NOW';
} else if (diffMins < 60) { } else if (diffMins < 60) {
countdown = `in ${diffMins}m`; countdown = `in ${diffMins}m`;
@@ -702,79 +790,336 @@ const WeatherSat = (function() {
// ======================== // ========================
/** /**
* Initialize Leaflet ground track map * Initialize styled real-world map panel.
*/ */
function initGroundMap() { async function initGroundMap() {
const container = document.getElementById('wxsatGroundMap'); const container = document.getElementById('wxsatGroundMap');
if (!container || groundMap) return; if (!container) return;
if (typeof L === 'undefined') return; if (typeof L === 'undefined') return;
const observer = getObserverCoords();
const defaultCenter = observer
? [Math.max(-85, Math.min(85, observer.lat)), normalizeLon(observer.lon)]
: [12, 0];
const defaultZoom = 1;
groundMap = L.map(container, { if (!groundMap) {
center: [20, 0], groundMap = L.map(container, {
zoom: 2, center: defaultCenter,
zoomControl: false, zoom: defaultZoom,
attributionControl: false, minZoom: 1,
}); maxZoom: 7,
zoomControl: false,
attributionControl: false,
worldCopyJump: true,
preferCanvas: true,
});
// Check tile provider from settings if (typeof Settings !== 'undefined' && Settings.createTileLayer) {
let tileUrl = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'; await Settings.init();
try { Settings.createTileLayer().addTo(groundMap);
const provider = localStorage.getItem('tileProvider'); Settings.registerMap(groundMap);
if (provider === 'osm') { } else {
tileUrl = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'; L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
subdomains: 'abcd',
maxZoom: 18,
noWrap: false,
crossOrigin: true,
className: 'tile-layer-cyan',
}).addTo(groundMap);
} }
} catch (e) {}
L.tileLayer(tileUrl, { maxZoom: 10 }).addTo(groundMap); groundGridLayer = L.layerGroup().addTo(groundMap);
addStyledGridOverlay(groundGridLayer);
groundTrackLayer = L.layerGroup().addTo(groundMap); groundTrackLayer = L.layerGroup().addTo(groundMap);
groundOverlayLayer = L.layerGroup().addTo(groundMap);
}
// Delayed invalidation to fix sizing setTimeout(() => {
setTimeout(() => { if (groundMap) groundMap.invalidateSize(); }, 200); if (!groundMap) return;
groundMap.invalidateSize(false);
groundMap.setView(defaultCenter, defaultZoom, { animate: false });
updateGroundTrack(getSelectedPass());
}, 140);
} }
/** /**
* Update ground track on the map * Update map panel subtitle.
*/
function updateProjectionInfo(text) {
const infoEl = document.getElementById('wxsatMapInfo');
if (infoEl) infoEl.textContent = text || '--';
}
/**
* Normalize longitude to [-180, 180).
*/
function normalizeLon(value) {
const lon = Number(value);
if (!isFinite(lon)) return 0;
return ((((lon + 180) % 360) + 360) % 360) - 180;
}
/**
* Build track segments that do not cross the date line.
*/
function buildTrackSegments(track) {
const segments = [];
let currentSegment = [];
track.forEach((point) => {
const lat = Number(point?.lat);
const lon = normalizeLon(point?.lon);
if (!isFinite(lat) || !isFinite(lon)) return;
if (currentSegment.length > 0) {
const prevLon = currentSegment[currentSegment.length - 1][1];
if (Math.abs(lon - prevLon) > 180) {
if (currentSegment.length > 1) segments.push(currentSegment);
currentSegment = [];
}
}
currentSegment.push([lat, lon]);
});
if (currentSegment.length > 1) segments.push(currentSegment);
return segments;
}
/**
* Draw a subtle graticule over the base map for a cyber/wireframe look.
*/
function addStyledGridOverlay(layer) {
if (!layer || typeof L === 'undefined') return;
layer.clearLayers();
for (let lon = -180; lon <= 180; lon += 30) {
const line = [];
for (let lat = -85; lat <= 85; lat += 5) line.push([lat, lon]);
L.polyline(line, {
color: '#4ed2ff',
weight: lon % 60 === 0 ? 1.1 : 0.8,
opacity: lon % 60 === 0 ? 0.2 : 0.12,
interactive: false,
lineCap: 'round',
}).addTo(layer);
}
for (let lat = -75; lat <= 75; lat += 15) {
const line = [];
for (let lon = -180; lon <= 180; lon += 5) line.push([lat, lon]);
L.polyline(line, {
color: '#5be7ff',
weight: lat % 30 === 0 ? 1.1 : 0.8,
opacity: lat % 30 === 0 ? 0.2 : 0.12,
interactive: false,
lineCap: 'round',
}).addTo(layer);
}
}
function clearSatelliteCrosshair() {
if (!groundOverlayLayer || !satCrosshairMarker) return;
groundOverlayLayer.removeLayer(satCrosshairMarker);
satCrosshairMarker = null;
}
function createSatelliteCrosshairIcon() {
return L.divIcon({
className: 'wxsat-crosshair-icon',
iconSize: [30, 30],
iconAnchor: [15, 15],
html: `
<div class="wxsat-crosshair-marker">
<span class="wxsat-crosshair-h"></span>
<span class="wxsat-crosshair-v"></span>
<span class="wxsat-crosshair-ring"></span>
<span class="wxsat-crosshair-dot"></span>
</div>
`,
});
}
/**
* Update selected ground track and redraw map overlays.
*/ */
function updateGroundTrack(pass) { function updateGroundTrack(pass) {
if (!groundMap || !groundTrackLayer) return; if (!groundMap || !groundTrackLayer) return;
groundTrackLayer.clearLayers(); groundTrackLayer.clearLayers();
observerMarker = null;
const track = pass.groundTrack; if (!pass) {
if (!track || track.length === 0) return; clearSatelliteCrosshair();
updateProjectionInfo('--');
return;
}
const color = pass.mode === 'LRPT' ? '#00ff88' : '#00d4ff'; const track = pass?.groundTrack;
if (!Array.isArray(track) || track.length === 0) {
clearSatelliteCrosshair();
updateProjectionInfo(`${pass.name || pass.satellite || '--'} --`);
return;
}
// Draw polyline const color = pass.mode === 'LRPT' ? '#27ffc6' : '#58ddff';
const latlngs = track.map(p => [p.lat, p.lon]); const glowClass = pass.mode === 'LRPT' ? 'wxsat-pass-track lrpt' : 'wxsat-pass-track apt';
L.polyline(latlngs, { color, weight: 2, opacity: 0.8 }).addTo(groundTrackLayer); const segments = buildTrackSegments(track);
const validPoints = track
.map((point) => [Number(point?.lat), normalizeLon(point?.lon)])
.filter((point) => isFinite(point[0]) && isFinite(point[1]));
// Start marker segments.forEach((segment) => {
L.circleMarker(latlngs[0], { L.polyline(segment, {
radius: 5, color: '#00ff88', fillColor: '#00ff88', fillOpacity: 1, weight: 0, color,
}).addTo(groundTrackLayer); weight: 2.3,
opacity: 0.9,
className: glowClass,
interactive: false,
lineJoin: 'round',
}).addTo(groundTrackLayer);
});
// End marker if (validPoints.length > 0) {
L.circleMarker(latlngs[latlngs.length - 1], { L.circleMarker(validPoints[0], {
radius: 5, color: '#ff4444', fillColor: '#ff4444', fillOpacity: 1, weight: 0, radius: 4.5,
}).addTo(groundTrackLayer); color: '#00ffa2',
fillColor: '#00ffa2',
fillOpacity: 0.95,
weight: 0,
interactive: false,
}).addTo(groundTrackLayer);
// Observer marker L.circleMarker(validPoints[validPoints.length - 1], {
const lat = parseFloat(localStorage.getItem('observerLat')); radius: 4.5,
const lon = parseFloat(localStorage.getItem('observerLon')); color: '#ff5e5e',
if (!isNaN(lat) && !isNaN(lon)) { fillColor: '#ff5e5e',
L.circleMarker([lat, lon], { fillOpacity: 0.95,
radius: 6, color: '#ffbb00', fillColor: '#ffbb00', fillOpacity: 0.8, weight: 1, weight: 0,
interactive: false,
}).addTo(groundTrackLayer); }).addTo(groundTrackLayer);
} }
// Fit bounds let obsLat;
try { let obsLon;
const bounds = L.latLngBounds(latlngs); if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
if (!isNaN(lat) && !isNaN(lon)) bounds.extend([lat, lon]); const shared = ObserverLocation.getShared();
groundMap.fitBounds(bounds, { padding: [20, 20] }); obsLat = shared?.lat;
} catch (e) {} obsLon = shared?.lon;
} else {
obsLat = parseFloat(localStorage.getItem('observerLat'));
obsLon = parseFloat(localStorage.getItem('observerLon'));
}
if (isFinite(obsLat) && isFinite(obsLon)) {
observerMarker = L.circleMarker([obsLat, obsLon], {
radius: 5.5,
color: '#ffd45b',
fillColor: '#ffd45b',
fillOpacity: 0.8,
weight: 1,
className: 'wxsat-observer-marker',
interactive: false,
}).addTo(groundTrackLayer);
}
updateSatelliteCrosshair(pass);
}
function getSelectedPass() {
if (selectedPassIndex < 0 || selectedPassIndex >= passes.length) return null;
return passes[selectedPassIndex];
}
function getSatellitePositionForPass(pass, atTime = new Date()) {
const track = pass?.groundTrack;
if (!Array.isArray(track) || track.length === 0) return null;
const first = track[0];
if (track.length === 1) {
const lat = Number(first.lat);
const lon = Number(first.lon);
if (!isFinite(lat) || !isFinite(lon)) return null;
return { lat, lon };
}
const start = parsePassDate(pass.startTimeISO);
const end = parsePassDate(pass.endTimeISO);
let fraction = 0;
if (start && end && end > start) {
const totalMs = end.getTime() - start.getTime();
const elapsedMs = atTime.getTime() - start.getTime();
fraction = Math.max(0, Math.min(1, elapsedMs / totalMs));
}
const lastIndex = track.length - 1;
const idxFloat = fraction * lastIndex;
const idx0 = Math.floor(idxFloat);
const idx1 = Math.min(lastIndex, idx0 + 1);
const t = idxFloat - idx0;
const p0 = track[idx0];
const p1 = track[idx1];
const lat0 = Number(p0?.lat);
const lon0 = Number(p0?.lon);
const lat1 = Number(p1?.lat);
const lon1 = Number(p1?.lon);
if (!isFinite(lat0) || !isFinite(lon0) || !isFinite(lat1) || !isFinite(lon1)) {
return null;
}
return {
lat: lat0 + ((lat1 - lat0) * t),
lon: lon0 + ((lon1 - lon0) * t),
};
}
function updateSatelliteCrosshair(pass) {
if (!groundMap || !groundOverlayLayer || typeof L === 'undefined') return;
if (!pass) {
clearSatelliteCrosshair();
updateProjectionInfo('--');
return;
}
const position = getSatellitePositionForPass(pass);
if (!position) {
clearSatelliteCrosshair();
updateProjectionInfo(`${pass.name || pass.satellite || '--'} --`);
return;
}
const latlng = [position.lat, normalizeLon(position.lon)];
if (!satCrosshairMarker) {
satCrosshairMarker = L.marker(latlng, {
icon: createSatelliteCrosshairIcon(),
interactive: false,
keyboard: false,
zIndexOffset: 900,
}).addTo(groundOverlayLayer);
} else {
satCrosshairMarker.setLatLng(latlng);
}
const infoText =
`${pass.name || pass.satellite || 'Satellite'} ` +
`${position.lat.toFixed(2)}°, ${normalizeLon(position.lon).toFixed(2)}°`;
updateProjectionInfo(infoText);
if (!satCrosshairMarker.getTooltip()) {
satCrosshairMarker.bindTooltip(infoText, {
direction: 'top',
offset: [0, -12],
opacity: 0.92,
className: 'wxsat-map-tooltip',
});
} else {
satCrosshairMarker.setTooltipContent(infoText);
}
} }
// ======================== // ========================
@@ -798,8 +1143,11 @@ const WeatherSat = (function() {
let isActive = false; let isActive = false;
for (const pass of passes) { for (const pass of passes) {
const start = new Date(pass.startTimeISO); const start = parsePassDate(pass.startTimeISO);
const end = new Date(pass.endTimeISO); const end = parsePassDate(pass.endTimeISO);
if (!start || !end) {
continue;
}
if (end > now) { if (end > now) {
nextPass = pass; nextPass = pass;
isActive = start <= now; isActive = start <= now;
@@ -828,7 +1176,19 @@ const WeatherSat = (function() {
return; return;
} }
const target = new Date(nextPass.startTimeISO); const target = parsePassDate(nextPass.startTimeISO);
if (!target) {
if (daysEl) daysEl.textContent = '--';
if (hoursEl) hoursEl.textContent = '--';
if (minsEl) minsEl.textContent = '--';
if (secsEl) secsEl.textContent = '--';
if (satEl) satEl.textContent = '--';
if (detailEl) detailEl.textContent = 'Invalid pass time';
if (boxes) boxes.querySelectorAll('.wxsat-countdown-box').forEach(b => {
b.classList.remove('imminent', 'active');
});
return;
}
let diffMs = target - now; let diffMs = target - now;
if (isActive) { if (isActive) {
@@ -862,6 +1222,11 @@ const WeatherSat = (function() {
b.classList.toggle('active', isActive); b.classList.toggle('active', isActive);
}); });
} }
// Keep timeline cursor in sync
updateTimelineCursor();
// Keep selected satellite marker synchronized with time progression.
updateSatelliteCrosshair(getSelectedPass());
} }
// ======================== // ========================
@@ -885,8 +1250,9 @@ const WeatherSat = (function() {
const dayMs = 24 * 60 * 60 * 1000; const dayMs = 24 * 60 * 60 * 1000;
passList.forEach((pass, idx) => { passList.forEach((pass, idx) => {
const start = new Date(pass.startTimeISO); const start = parsePassDate(pass.startTimeISO);
const end = new Date(pass.endTimeISO); const end = parsePassDate(pass.endTimeISO);
if (!start || !end) return;
const startPct = Math.max(0, Math.min(100, ((start - dayStart) / dayMs) * 100)); const startPct = Math.max(0, Math.min(100, ((start - dayStart) / dayMs) * 100));
const endPct = Math.max(0, Math.min(100, ((end - dayStart) / dayMs) * 100)); const endPct = Math.max(0, Math.min(100, ((end - dayStart) / dayMs) * 100));
@@ -926,12 +1292,13 @@ const WeatherSat = (function() {
/** /**
* Toggle auto-scheduler * Toggle auto-scheduler
*/ */
async function toggleScheduler() { async function toggleScheduler(source) {
const checked = source?.checked ?? false;
const stripCheckbox = document.getElementById('wxsatAutoSchedule'); const stripCheckbox = document.getElementById('wxsatAutoSchedule');
const sidebarCheckbox = document.getElementById('wxsatSidebarAutoSchedule'); const sidebarCheckbox = document.getElementById('wxsatSidebarAutoSchedule');
const checked = stripCheckbox?.checked || sidebarCheckbox?.checked;
// Sync both checkboxes // Sync both checkboxes to the source of truth
if (stripCheckbox) stripCheckbox.checked = checked; if (stripCheckbox) stripCheckbox.checked = checked;
if (sidebarCheckbox) sidebarCheckbox.checked = checked; if (sidebarCheckbox) sidebarCheckbox.checked = checked;
@@ -946,8 +1313,15 @@ const WeatherSat = (function() {
* Enable auto-scheduler * Enable auto-scheduler
*/ */
async function enableScheduler() { async function enableScheduler() {
const lat = parseFloat(localStorage.getItem('observerLat')); let lat, lon;
const lon = parseFloat(localStorage.getItem('observerLon')); if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
const shared = ObserverLocation.getShared();
lat = shared?.lat;
lon = shared?.lon;
} else {
lat = parseFloat(localStorage.getItem('observerLat'));
lon = parseFloat(localStorage.getItem('observerLon'));
}
if (isNaN(lat) || isNaN(lon)) { if (isNaN(lat) || isNaN(lon)) {
showNotification('Weather Sat', 'Set observer location first'); showNotification('Weather Sat', 'Set observer location first');
@@ -975,13 +1349,28 @@ const WeatherSat = (function() {
}), }),
}); });
const data = await response.json(); let data = {};
try {
data = await response.json();
} catch (err) {
data = {};
}
if (!response.ok || !data || data.enabled !== true) {
schedulerEnabled = false;
updateSchedulerUI({ enabled: false, scheduled_count: 0 });
showNotification('Weather Sat', data.message || 'Failed to enable auto-scheduler');
return;
}
schedulerEnabled = true; schedulerEnabled = true;
updateSchedulerUI(data); updateSchedulerUI(data);
startStream(); startStream();
showNotification('Weather Sat', `Auto-scheduler enabled (${data.scheduled_count || 0} passes)`); showNotification('Weather Sat', `Auto-scheduler enabled (${data.scheduled_count || 0} passes)`);
} catch (err) { } catch (err) {
console.error('Failed to enable scheduler:', err); console.error('Failed to enable scheduler:', err);
schedulerEnabled = false;
updateSchedulerUI({ enabled: false, scheduled_count: 0 });
showNotification('Weather Sat', 'Failed to enable auto-scheduler'); showNotification('Weather Sat', 'Failed to enable auto-scheduler');
} }
} }
@@ -991,7 +1380,11 @@ const WeatherSat = (function() {
*/ */
async function disableScheduler() { async function disableScheduler() {
try { try {
await fetch('/weather-sat/schedule/disable', { method: 'POST' }); const response = await fetch('/weather-sat/schedule/disable', { method: 'POST' });
if (!response.ok) {
showNotification('Weather Sat', 'Failed to disable auto-scheduler');
return;
}
schedulerEnabled = false; schedulerEnabled = false;
updateSchedulerUI({ enabled: false }); updateSchedulerUI({ enabled: false });
if (!isRunning) stopStream(); if (!isRunning) stopStream();
@@ -1007,6 +1400,7 @@ const WeatherSat = (function() {
async function checkSchedulerStatus() { async function checkSchedulerStatus() {
try { try {
const response = await fetch('/weather-sat/schedule/status'); const response = await fetch('/weather-sat/schedule/status');
if (!response.ok) return;
const data = await response.json(); const data = await response.json();
schedulerEnabled = data.enabled; schedulerEnabled = data.enabled;
updateSchedulerUI(data); updateSchedulerUI(data);
@@ -1259,9 +1653,14 @@ const WeatherSat = (function() {
* Invalidate ground map size (call after container becomes visible) * Invalidate ground map size (call after container becomes visible)
*/ */
function invalidateMap() { function invalidateMap() {
if (groundMap) { setTimeout(() => {
setTimeout(() => groundMap.invalidateSize(), 100); if (!groundMap) {
} initGroundMap();
return;
}
groundMap.invalidateSize(false);
updateGroundTrack(getSelectedPass());
}, 100);
} }
// ======================== // ========================
@@ -1366,9 +1765,29 @@ const WeatherSat = (function() {
} }
} }
/**
* Suspend background activity when leaving the mode.
* Closes the SSE stream and stops the countdown interval so they don't
* keep running while another mode is active. The stream is re-opened
* by init() or startStream() when the mode is next entered.
*/
function suspend() {
if (countdownInterval) {
clearInterval(countdownInterval);
countdownInterval = null;
}
// Only close the stream if nothing is actively capturing/scheduling —
// if a capture or scheduler is running we want it to continue on the
// server and the stream will reconnect on next init().
if (!isRunning && !schedulerEnabled) {
stopStream();
}
}
// Public API // Public API
return { return {
init, init,
suspend,
start, start,
stop, stop,
startPass, startPass,
+13 -6
View File
@@ -27,7 +27,7 @@ const KIWI_SAMPLE_RATE = 12000;
// ============== INITIALIZATION ============== // ============== INITIALIZATION ==============
function initWebSDR() { async function initWebSDR() {
if (websdrInitialized) { if (websdrInitialized) {
if (websdrMap) { if (websdrMap) {
setTimeout(() => websdrMap.invalidateSize(), 100); setTimeout(() => websdrMap.invalidateSize(), 100);
@@ -51,11 +51,18 @@ function initWebSDR() {
maxBoundsViscosity: 1.0, maxBoundsViscosity: 1.0,
}); });
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { if (typeof Settings !== 'undefined' && Settings.createTileLayer) {
attribution: '&copy; OpenStreetMap contributors &copy; CARTO', await Settings.init();
subdomains: 'abcd', Settings.createTileLayer().addTo(websdrMap);
maxZoom: 19, Settings.registerMap(websdrMap);
}).addTo(websdrMap); } else {
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; OpenStreetMap contributors &copy; CARTO',
subdomains: 'abcd',
maxZoom: 19,
className: 'tile-layer-cyan',
}).addTo(websdrMap);
}
// Match background to tile ocean color so any remaining edge is seamless // Match background to tile ocean color so any remaining edge is seamless
mapEl.style.background = '#1a1d29'; mapEl.style.background = '#1a1d29';
+144 -38
View File
@@ -124,6 +124,19 @@ const WiFiMode = (function() {
let selectedNetwork = null; let selectedNetwork = null;
let currentFilter = 'all'; let currentFilter = 'all';
let currentSort = { field: 'rssi', order: 'desc' }; let currentSort = { field: 'rssi', order: 'desc' };
let renderFramePending = false;
const pendingRender = {
table: false,
stats: false,
radar: false,
chart: false,
detail: false,
};
const listenersBound = {
scanTabs: false,
filters: false,
sort: false,
};
// Agent state // Agent state
let showAllAgentsMode = false; // Show combined results from all agents let showAllAgentsMode = false; // Show combined results from all agents
@@ -156,6 +169,7 @@ const WiFiMode = (function() {
initSortControls(); initSortControls();
initProximityRadar(); initProximityRadar();
initChannelChart(); initChannelChart();
scheduleRender({ table: true, stats: true, radar: true, chart: true });
// Check if already scanning // Check if already scanning
checkScanStatus(); checkScanStatus();
@@ -365,12 +379,14 @@ const WiFiMode = (function() {
// ========================================================================== // ==========================================================================
function initScanModeTabs() { function initScanModeTabs() {
if (listenersBound.scanTabs) return;
if (elements.scanModeQuick) { if (elements.scanModeQuick) {
elements.scanModeQuick.addEventListener('click', () => setScanMode('quick')); elements.scanModeQuick.addEventListener('click', () => setScanMode('quick'));
} }
if (elements.scanModeDeep) { if (elements.scanModeDeep) {
elements.scanModeDeep.addEventListener('click', () => setScanMode('deep')); elements.scanModeDeep.addEventListener('click', () => setScanMode('deep'));
} }
listenersBound.scanTabs = true;
} }
function setScanMode(mode) { function setScanMode(mode) {
@@ -698,10 +714,7 @@ const WiFiMode = (function() {
} }
// Update UI // Update UI
updateNetworkTable(); scheduleRender({ table: true, stats: true, radar: true, chart: true });
updateStats();
updateProximityRadar();
updateChannelChart();
// Callbacks // Callbacks
result.access_points.forEach(ap => { result.access_points.forEach(ap => {
@@ -912,17 +925,20 @@ const WiFiMode = (function() {
function handleNetworkUpdate(network) { function handleNetworkUpdate(network) {
networks.set(network.bssid, network); networks.set(network.bssid, network);
updateNetworkRow(network); scheduleRender({
updateStats(); table: true,
updateProximityRadar(); stats: true,
updateChannelChart(); radar: true,
chart: true,
detail: selectedNetwork === network.bssid,
});
if (onNetworkUpdate) onNetworkUpdate(network); if (onNetworkUpdate) onNetworkUpdate(network);
} }
function handleClientUpdate(client) { function handleClientUpdate(client) {
clients.set(client.mac, client); clients.set(client.mac, client);
updateStats(); scheduleRender({ stats: true });
// Update client display if this client belongs to the selected network // Update client display if this client belongs to the selected network
updateClientInList(client); updateClientInList(client);
@@ -944,7 +960,10 @@ const WiFiMode = (function() {
if (network) { if (network) {
network.revealed_essid = revealedSsid; network.revealed_essid = revealedSsid;
network.display_name = `${revealedSsid} (revealed)`; network.display_name = `${revealedSsid} (revealed)`;
updateNetworkRow(network); scheduleRender({
table: true,
detail: selectedNetwork === bssid,
});
// Show notification // Show notification
showInfo(`Hidden SSID revealed: ${revealedSsid}`); showInfo(`Hidden SSID revealed: ${revealedSsid}`);
@@ -956,6 +975,7 @@ const WiFiMode = (function() {
// ========================================================================== // ==========================================================================
function initNetworkFilters() { function initNetworkFilters() {
if (listenersBound.filters) return;
if (!elements.networkFilters) return; if (!elements.networkFilters) return;
elements.networkFilters.addEventListener('click', (e) => { elements.networkFilters.addEventListener('click', (e) => {
@@ -964,6 +984,7 @@ const WiFiMode = (function() {
setNetworkFilter(filter); setNetworkFilter(filter);
} }
}); });
listenersBound.filters = true;
} }
function setNetworkFilter(filter) { function setNetworkFilter(filter) {
@@ -980,6 +1001,7 @@ const WiFiMode = (function() {
} }
function initSortControls() { function initSortControls() {
if (listenersBound.sort) return;
if (!elements.networkTable) return; if (!elements.networkTable) return;
elements.networkTable.addEventListener('click', (e) => { elements.networkTable.addEventListener('click', (e) => {
@@ -995,6 +1017,44 @@ const WiFiMode = (function() {
updateNetworkTable(); updateNetworkTable();
} }
}); });
if (elements.networkTableBody) {
elements.networkTableBody.addEventListener('click', (e) => {
const row = e.target.closest('tr[data-bssid]');
if (!row) return;
selectNetwork(row.dataset.bssid);
});
}
listenersBound.sort = true;
}
function scheduleRender(flags = {}) {
pendingRender.table = pendingRender.table || Boolean(flags.table);
pendingRender.stats = pendingRender.stats || Boolean(flags.stats);
pendingRender.radar = pendingRender.radar || Boolean(flags.radar);
pendingRender.chart = pendingRender.chart || Boolean(flags.chart);
pendingRender.detail = pendingRender.detail || Boolean(flags.detail);
if (renderFramePending) return;
renderFramePending = true;
requestAnimationFrame(() => {
renderFramePending = false;
if (pendingRender.table) updateNetworkTable();
if (pendingRender.stats) updateStats();
if (pendingRender.radar) updateProximityRadar();
if (pendingRender.chart) updateChannelChart();
if (pendingRender.detail && selectedNetwork) {
updateDetailPanel(selectedNetwork, { refreshClients: false });
}
pendingRender.table = false;
pendingRender.stats = false;
pendingRender.radar = false;
pendingRender.chart = false;
pendingRender.detail = false;
});
} }
function updateNetworkTable() { function updateNetworkTable() {
@@ -1054,19 +1114,41 @@ const WiFiMode = (function() {
} }
}); });
if (filtered.length === 0) {
let message = 'Start scanning to discover networks';
let type = 'empty';
if (isScanning) {
message = 'Scanning for networks...';
type = 'loading';
} else if (networks.size > 0) {
message = 'No networks match current filters';
}
if (typeof renderCollectionState === 'function') {
renderCollectionState(elements.networkTableBody, {
type,
message,
columns: 7,
});
} else {
elements.networkTableBody.innerHTML = `<tr class="wifi-network-placeholder"><td colspan="7"><div class="placeholder-text">${escapeHtml(message)}</div></td></tr>`;
}
return;
}
// Render table // Render table
elements.networkTableBody.innerHTML = filtered.map(n => createNetworkRow(n)).join(''); elements.networkTableBody.innerHTML = filtered.map(n => createNetworkRow(n)).join('');
} }
function createNetworkRow(network) { function createNetworkRow(network) {
const rssi = network.rssi_current; const rssi = network.rssi_current;
const security = network.security || 'Unknown';
const signalClass = rssi >= -50 ? 'signal-strong' : const signalClass = rssi >= -50 ? 'signal-strong' :
rssi >= -70 ? 'signal-medium' : rssi >= -70 ? 'signal-medium' :
rssi >= -85 ? 'signal-weak' : 'signal-very-weak'; rssi >= -85 ? 'signal-weak' : 'signal-very-weak';
const securityClass = network.security === 'Open' ? 'security-open' : const securityClass = security === 'Open' ? 'security-open' :
network.security === 'WEP' ? 'security-wep' : security === 'WEP' ? 'security-wep' :
network.security.includes('WPA3') ? 'security-wpa3' : 'security-wpa'; security.includes('WPA3') ? 'security-wpa3' : 'security-wpa';
const hiddenBadge = network.is_hidden ? '<span class="badge badge-hidden">Hidden</span>' : ''; const hiddenBadge = network.is_hidden ? '<span class="badge badge-hidden">Hidden</span>' : '';
const newBadge = network.is_new ? '<span class="badge badge-new">New</span>' : ''; const newBadge = network.is_new ? '<span class="badge badge-new">New</span>' : '';
@@ -1078,7 +1160,10 @@ const WiFiMode = (function() {
return ` return `
<tr class="wifi-network-row ${network.bssid === selectedNetwork ? 'selected' : ''}" <tr class="wifi-network-row ${network.bssid === selectedNetwork ? 'selected' : ''}"
data-bssid="${escapeHtml(network.bssid)}" data-bssid="${escapeHtml(network.bssid)}"
onclick="WiFiMode.selectNetwork('${escapeHtml(network.bssid)}')"> role="button"
tabindex="0"
data-keyboard-activate="true"
aria-label="Select network ${escapeHtml(network.display_name || network.essid || '[Hidden]')}">
<td class="col-essid"> <td class="col-essid">
<span class="essid">${escapeHtml(network.display_name || network.essid || '[Hidden]')}</span> <span class="essid">${escapeHtml(network.display_name || network.essid || '[Hidden]')}</span>
${hiddenBadge}${newBadge} ${hiddenBadge}${newBadge}
@@ -1086,10 +1171,10 @@ const WiFiMode = (function() {
<td class="col-bssid"><code>${escapeHtml(network.bssid)}</code></td> <td class="col-bssid"><code>${escapeHtml(network.bssid)}</code></td>
<td class="col-channel">${network.channel || '-'}</td> <td class="col-channel">${network.channel || '-'}</td>
<td class="col-rssi"> <td class="col-rssi">
<span class="rssi-value ${signalClass}">${rssi !== null ? rssi : '-'}</span> <span class="rssi-value ${signalClass}">${rssi != null ? rssi : '-'}</span>
</td> </td>
<td class="col-security"> <td class="col-security">
<span class="security-badge ${securityClass}">${escapeHtml(network.security)}</span> <span class="security-badge ${securityClass}">${escapeHtml(security)}</span>
</td> </td>
<td class="col-clients">${network.client_count || 0}</td> <td class="col-clients">${network.client_count || 0}</td>
<td class="col-agent"> <td class="col-agent">
@@ -1100,13 +1185,10 @@ const WiFiMode = (function() {
} }
function updateNetworkRow(network) { function updateNetworkRow(network) {
const row = elements.networkTableBody?.querySelector(`tr[data-bssid="${network.bssid}"]`); scheduleRender({
if (row) { table: true,
row.outerHTML = createNetworkRow(network); detail: selectedNetwork === network.bssid,
} else { });
// Add new row
updateNetworkTable();
}
} }
function selectNetwork(bssid) { function selectNetwork(bssid) {
@@ -1130,7 +1212,8 @@ const WiFiMode = (function() {
// Detail Panel // Detail Panel
// ========================================================================== // ==========================================================================
function updateDetailPanel(bssid) { function updateDetailPanel(bssid, options = {}) {
const { refreshClients = true } = options;
if (!elements.detailDrawer) return; if (!elements.detailDrawer) return;
const network = networks.get(bssid); const network = networks.get(bssid);
@@ -1177,7 +1260,9 @@ const WiFiMode = (function() {
elements.detailDrawer.classList.add('open'); elements.detailDrawer.classList.add('open');
// Fetch and display clients for this network // Fetch and display clients for this network
fetchClientsForNetwork(network.bssid); if (refreshClients) {
fetchClientsForNetwork(network.bssid);
}
} }
function closeDetail() { function closeDetail() {
@@ -1196,6 +1281,12 @@ const WiFiMode = (function() {
async function fetchClientsForNetwork(bssid) { async function fetchClientsForNetwork(bssid) {
if (!elements.detailClientList) return; if (!elements.detailClientList) return;
const listContainer = elements.detailClientList.querySelector('.wifi-client-list');
if (listContainer && typeof renderCollectionState === 'function') {
renderCollectionState(listContainer, { type: 'loading', message: 'Loading clients...' });
elements.detailClientList.style.display = 'block';
}
try { try {
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
@@ -1209,8 +1300,12 @@ const WiFiMode = (function() {
} }
if (!response.ok) { if (!response.ok) {
// Hide client list on error if (listContainer && typeof renderCollectionState === 'function') {
elements.detailClientList.style.display = 'none'; renderCollectionState(listContainer, { type: 'empty', message: 'Client list unavailable' });
elements.detailClientList.style.display = 'block';
} else {
elements.detailClientList.style.display = 'none';
}
return; return;
} }
@@ -1223,11 +1318,23 @@ const WiFiMode = (function() {
renderClientList(clientList, bssid); renderClientList(clientList, bssid);
elements.detailClientList.style.display = 'block'; elements.detailClientList.style.display = 'block';
} else { } else {
elements.detailClientList.style.display = 'none'; const countBadge = document.getElementById('wifiClientCountBadge');
if (countBadge) countBadge.textContent = '0';
if (listContainer && typeof renderCollectionState === 'function') {
renderCollectionState(listContainer, { type: 'empty', message: 'No associated clients' });
elements.detailClientList.style.display = 'block';
} else {
elements.detailClientList.style.display = 'none';
}
} }
} catch (error) { } catch (error) {
console.debug('[WiFiMode] Error fetching clients:', error); console.debug('[WiFiMode] Error fetching clients:', error);
elements.detailClientList.style.display = 'none'; if (listContainer && typeof renderCollectionState === 'function') {
renderCollectionState(listContainer, { type: 'empty', message: 'Client list unavailable' });
elements.detailClientList.style.display = 'block';
} else {
elements.detailClientList.style.display = 'none';
}
} }
} }
@@ -1592,11 +1699,10 @@ const WiFiMode = (function() {
probeRequests = []; probeRequests = [];
channelStats = []; channelStats = [];
recommendations = []; recommendations = [];
if (selectedNetwork) {
updateNetworkTable(); closeDetail();
updateStats(); }
updateProximityRadar(); scheduleRender({ table: true, stats: true, radar: true, chart: true });
updateChannelChart();
} }
/** /**
@@ -1643,10 +1749,10 @@ const WiFiMode = (function() {
} }
}); });
clientsToRemove.forEach(mac => clients.delete(mac)); clientsToRemove.forEach(mac => clients.delete(mac));
if (selectedNetwork && !networks.has(selectedNetwork)) {
updateNetworkTable(); closeDetail();
updateStats(); }
updateProximityRadar(); scheduleRender({ table: true, stats: true, radar: true, chart: true });
} }
/** /**
+116 -99
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" class="{% if offline_settings.tile_provider in ['cartodb_dark', 'cartodb_dark_cyan'] %}map-cyber-enabled{% endif %}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -22,7 +22,7 @@
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/variables.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/core/variables.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/global-nav.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/global-nav.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}?v={{ version }}&r=maptheme17">
<link rel="stylesheet" href="{{ url_for('static', filename='css/help-modal.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/help-modal.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/adsb_dashboard.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/adsb_dashboard.css') }}">
<script> <script>
@@ -599,7 +599,7 @@
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 };
@@ -985,7 +985,7 @@
} }
// Distance calculation // Distance calculation
if (ac.lat && ac.lon) { if (ac.lat !== undefined && ac.lat !== null && ac.lon !== undefined && ac.lon !== null) {
const distance = calculateDistanceNm( const distance = calculateDistanceNm(
observerLocation.lat, observerLocation.lon, observerLocation.lat, observerLocation.lon,
ac.lat, ac.lon ac.lat, ac.lon
@@ -1037,7 +1037,7 @@
fastest = ac.speed; fastest = ac.speed;
fastestIcao = icao; fastestIcao = icao;
} }
if (ac.lat && ac.lon) { if (ac.lat !== undefined && ac.lat !== null && ac.lon !== undefined && ac.lon !== null) {
const dist = calculateDistanceNm( const dist = calculateDistanceNm(
observerLocation.lat, observerLocation.lon, observerLocation.lat, observerLocation.lon,
ac.lat, ac.lon ac.lat, ac.lon
@@ -1345,21 +1345,41 @@ ACARS: ${r.statistics.acarsMessages} messages`;
} }
trailLines[icao] = []; trailLines[icao] = [];
// Create gradient segments // Group consecutive same-altitude-color points into single polylines.
// This reduces layer count from O(trail length) to O(color band changes),
// which is typically 1-2 polylines per aircraft instead of up to 99.
const now = Date.now(); const now = Date.now();
for (let i = 1; i < trail.length; i++) { let runColor = getAltitudeColor(trail[0].alt);
const p1 = trail[i-1]; let runPoints = [[trail[0].lat, trail[0].lon]];
const p2 = trail[i]; let runEndTime = trail[0].time;
const age = (now - p2.time) / 1000; // seconds
const opacity = Math.max(0.2, 1 - (age / 120)); // Fade over 2 minutes
const color = getAltitudeColor(p2.alt); for (let i = 1; i < trail.length; i++) {
const line = L.polyline([[p1.lat, p1.lon], [p2.lat, p2.lon]], { const p = trail[i];
color: color, const color = getAltitudeColor(p.alt);
weight: 2,
opacity: opacity if (color !== runColor) {
}).addTo(radarMap); // Flush the current color run as one polyline
trailLines[icao].push(line); if (runPoints.length >= 2) {
const opacity = Math.max(0.2, 1 - ((now - runEndTime) / 1000 / 120));
trailLines[icao].push(
L.polyline(runPoints, { color: runColor, weight: 2, opacity }).addTo(radarMap)
);
}
// Start a new run, sharing the junction point for visual continuity
runColor = color;
runPoints = [[trail[i-1].lat, trail[i-1].lon], [p.lat, p.lon]];
} else {
runPoints.push([p.lat, p.lon]);
}
runEndTime = p.time;
}
// Flush the final run
if (runPoints.length >= 2) {
const opacity = Math.max(0.2, 1 - ((now - runEndTime) / 1000 / 120));
trailLines[icao].push(
L.polyline(runPoints, { color: runColor, weight: 2, opacity }).addTo(radarMap)
);
} }
} }
@@ -1397,19 +1417,19 @@ ACARS: ${r.statistics.acarsMessages} messages`;
const meters = nm * 1852; const meters = nm * 1852;
const circle = L.circle([observerLocation.lat, observerLocation.lon], { const circle = L.circle([observerLocation.lat, observerLocation.lon], {
radius: meters, radius: meters,
color: '#00ff88', color: '#4a9eff',
fillColor: 'transparent', fillColor: 'transparent',
fillOpacity: 0, fillOpacity: 0,
weight: 1, weight: 1,
opacity: 0.4, opacity: 0.3,
dashArray: '5, 5' dashArray: '4 4'
}); });
const labelLat = observerLocation.lat + (nm * 0.0166); const labelLat = observerLocation.lat + (nm * 0.0166);
const label = L.marker([labelLat, observerLocation.lon], { const label = L.marker([labelLat, observerLocation.lon], {
icon: L.divIcon({ icon: L.divIcon({
className: 'range-label', className: 'range-label',
html: `<span style="color: #00ff88; font-size: 10px; background: rgba(0,0,0,0.7); padding: 1px 4px; border-radius: 2px;">${Math.round(nm)} nm</span>`, html: `<span style="color: #4a9eff; font-size: 10px; background: rgba(0,0,0,0.7); padding: 1px 4px; border-radius: 2px;">${Math.round(nm)} nm</span>`,
iconSize: [40, 12], iconSize: [40, 12],
iconAnchor: [20, 6] iconAnchor: [20, 6]
}) })
@@ -1535,7 +1555,7 @@ ACARS: ${r.statistics.acarsMessages} messages`;
gpsEventSource.onmessage = (event) => { gpsEventSource.onmessage = (event) => {
try { try {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
if (data.type === 'position' && data.latitude && data.longitude) { if (data.type === 'position' && data.latitude !== undefined && data.latitude !== null && data.longitude !== undefined && data.longitude !== null) {
updateLocationFromGps(data); updateLocationFromGps(data);
} }
} catch (e) { } catch (e) {
@@ -1607,7 +1627,7 @@ ACARS: ${r.statistics.acarsMessages} messages`;
Object.keys(markerState).forEach(icao => delete markerState[icao]); Object.keys(markerState).forEach(icao => delete markerState[icao]);
pendingMarkerUpdates.clear(); pendingMarkerUpdates.clear();
Object.keys(aircraft).forEach(icao => { Object.keys(aircraft).forEach(icao => {
if (aircraft[icao].lat && aircraft[icao].lon) { if (aircraft[icao].lat !== undefined && aircraft[icao].lat !== null && aircraft[icao].lon !== undefined && aircraft[icao].lon !== null) {
pendingMarkerUpdates.add(icao); pendingMarkerUpdates.add(icao);
} }
}); });
@@ -2536,7 +2556,7 @@ sudo make install</code>
updateStatistics(icao, aircraft[icao]); updateStatistics(icao, aircraft[icao]);
// Record trail point // Record trail point
if (data.lat && data.lon) { if (data.lat !== undefined && data.lat !== null && data.lon !== undefined && data.lon !== null) {
recordTrailPoint(icao, data.lat, data.lon, data.altitude); recordTrailPoint(icao, data.lat, data.lon, data.altitude);
if (showTrails) { if (showTrails) {
updateTrailLine(icao); updateTrailLine(icao);
@@ -2551,7 +2571,7 @@ sudo make install</code>
function updateMarkerImmediate(icao) { function updateMarkerImmediate(icao) {
const ac = aircraft[icao]; const ac = aircraft[icao];
if (!ac || !ac.lat || !ac.lon) return; if (!ac || ac.lat === undefined || ac.lat === null || ac.lon === undefined || ac.lon === null) return;
if (!passesFilter(icao, ac)) { if (!passesFilter(icao, ac)) {
if (markers[icao]) { if (markers[icao]) {
@@ -2788,7 +2808,7 @@ sudo make install</code>
updateFlightLookupBtn(); updateFlightLookupBtn();
const ac = aircraft[icao]; const ac = aircraft[icao];
if (ac && ac.lat && ac.lon) { if (ac && ac.lat !== undefined && ac.lat !== null && ac.lon !== undefined && ac.lon !== null) {
radarMap.setView([ac.lat, ac.lon], 10); radarMap.setView([ac.lat, ac.lon], 10);
} }
} }
@@ -3332,7 +3352,7 @@ sudo make install</code>
let acarsMessageCount = 0; let acarsMessageCount = 0;
let acarsSidebarCollapsed = localStorage.getItem('acarsSidebarCollapsed') !== 'false'; let acarsSidebarCollapsed = localStorage.getItem('acarsSidebarCollapsed') !== 'false';
let acarsFrequencies = { let acarsFrequencies = {
'na': ['129.125', '130.025', '130.450', '131.550'], 'na': ['131.725', '131.825'],
'eu': ['131.525', '131.725', '131.550'], 'eu': ['131.525', '131.725', '131.550'],
'ap': ['131.550', '131.450'] 'ap': ['131.550', '131.450']
}; };
@@ -3359,6 +3379,22 @@ sudo make install</code>
sidebar.classList.add('collapsed'); sidebar.classList.add('collapsed');
} }
updateAcarsFreqCheckboxes(); updateAcarsFreqCheckboxes();
// Check if ACARS is already running (e.g. after page reload)
fetch('/acars/status')
.then(r => r.json())
.then(data => {
if (data.running) {
isAcarsRunning = true;
acarsMessageCount = data.message_count || 0;
document.getElementById('acarsCount').textContent = acarsMessageCount;
document.getElementById('acarsToggleBtn').textContent = '■ STOP ACARS';
document.getElementById('acarsToggleBtn').classList.add('active');
document.getElementById('acarsPanelIndicator').classList.add('active');
startAcarsStream(false);
}
})
.catch(() => {});
}); });
function updateAcarsFreqCheckboxes() { function updateAcarsFreqCheckboxes() {
@@ -3542,6 +3578,11 @@ sudo make install</code>
acarsEventSource.onerror = function() { acarsEventSource.onerror = function() {
console.error('ACARS stream error'); console.error('ACARS stream error');
setTimeout(() => {
if (isAcarsRunning) {
startAcarsStream(acarsCurrentAgent !== null);
}
}, 2000);
}; };
// Start polling fallback for agent mode // Start polling fallback for agent mode
@@ -3838,87 +3879,47 @@ sudo make install</code>
function startVdl2Stream(isAgentMode = false) { function startVdl2Stream(isAgentMode = false) {
if (vdl2EventSource) vdl2EventSource.close(); if (vdl2EventSource) vdl2EventSource.close();
// Use different stream endpoint for agent mode // For remote agent mode, stream directly from the selected agent via controller proxy.
const streamUrl = isAgentMode ? '/controller/stream/all' : '/vdl2/stream'; // This does not depend on push ingestion being enabled.
const streamUrl = isAgentMode && vdl2CurrentAgent !== null
? `/controller/agents/${vdl2CurrentAgent}/vdl2/stream`
: '/vdl2/stream';
vdl2EventSource = new EventSource(streamUrl); vdl2EventSource = new EventSource(streamUrl);
vdl2EventSource.onmessage = function(e) { vdl2EventSource.onmessage = function(e) {
const data = JSON.parse(e.data); const data = JSON.parse(e.data);
let message = null;
if (isAgentMode) { // Backward compatibility: handle wrapped multi-agent payloads if encountered.
// Handle multi-agent stream format if (data.scan_type === 'vdl2' && data.payload && data.payload.type === 'vdl2') {
if (data.scan_type === 'vdl2' && data.payload) { message = data.payload;
const payload = data.payload; if (isAgentMode) {
if (payload.type === 'vdl2') { message.agent_name = data.agent_name || message.agent_name || 'Remote Agent';
vdl2MessageCount++;
if (typeof stats !== 'undefined') stats.vdl2Messages = (stats.vdl2Messages || 0) + 1;
document.getElementById('vdl2Count').textContent = vdl2MessageCount;
document.getElementById('stripVdl2').textContent = vdl2MessageCount;
payload.agent_name = data.agent_name;
addVdl2Message(payload);
}
} }
} else { } else if (data.type === 'vdl2') {
// Local stream format message = data;
if (data.type === 'vdl2') { if (isAgentMode && !message.agent_name) {
vdl2MessageCount++; message.agent_name = 'Remote Agent';
if (typeof stats !== 'undefined') stats.vdl2Messages = (stats.vdl2Messages || 0) + 1;
document.getElementById('vdl2Count').textContent = vdl2MessageCount;
document.getElementById('stripVdl2').textContent = vdl2MessageCount;
addVdl2Message(data);
} }
} }
if (message) {
vdl2MessageCount++;
if (typeof stats !== 'undefined') stats.vdl2Messages = (stats.vdl2Messages || 0) + 1;
document.getElementById('vdl2Count').textContent = vdl2MessageCount;
document.getElementById('stripVdl2').textContent = vdl2MessageCount;
addVdl2Message(message);
}
}; };
vdl2EventSource.onerror = function() { vdl2EventSource.onerror = function() {
console.error('VDL2 stream error'); console.error('VDL2 stream error');
}; setTimeout(() => {
if (isVdl2Running) {
// Start polling fallback for agent mode startVdl2Stream(vdl2CurrentAgent !== null);
if (isAgentMode) {
startVdl2Polling();
}
}
// Track last VDL2 message count for polling
let lastVdl2MessageCount = 0;
function startVdl2Polling() {
if (vdl2PollTimer) return;
lastVdl2MessageCount = 0;
const pollInterval = 2000;
vdl2PollTimer = setInterval(async () => {
if (!isVdl2Running || !vdl2CurrentAgent) {
clearInterval(vdl2PollTimer);
vdl2PollTimer = null;
return;
}
try {
const response = await fetch(`/controller/agents/${vdl2CurrentAgent}/vdl2/data`);
if (!response.ok) return;
const data = await response.json();
const result = data.result || data;
const messages = result.data || [];
if (messages.length > lastVdl2MessageCount) {
const newMessages = messages.slice(lastVdl2MessageCount);
newMessages.forEach(msg => {
vdl2MessageCount++;
if (typeof stats !== 'undefined') stats.vdl2Messages = (stats.vdl2Messages || 0) + 1;
document.getElementById('vdl2Count').textContent = vdl2MessageCount;
document.getElementById('stripVdl2').textContent = vdl2MessageCount;
msg.agent_name = result.agent_name || 'Remote Agent';
addVdl2Message(msg);
});
lastVdl2MessageCount = messages.length;
} }
} catch (err) { }, 2000);
console.error('VDL2 polling error:', err); };
}
}, pollInterval);
} }
function escapeHtml(str) { function escapeHtml(str) {
@@ -4163,7 +4164,7 @@ sudo make install</code>
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
// Populate VDL2 device selector // Populate VDL2 device selector and check running status
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
fetch('/devices') fetch('/devices')
.then(r => r.json()) .then(r => r.json())
@@ -4185,6 +4186,22 @@ sudo make install</code>
} }
} }
}); });
// Check if VDL2 is already running (e.g. after page reload)
fetch('/vdl2/status')
.then(r => r.json())
.then(data => {
if (data.running) {
isVdl2Running = true;
vdl2MessageCount = data.message_count || 0;
document.getElementById('vdl2Count').textContent = vdl2MessageCount;
document.getElementById('vdl2ToggleBtn').innerHTML = '&#9632; STOP VDL2';
document.getElementById('vdl2ToggleBtn').classList.add('active');
document.getElementById('vdl2PanelIndicator').classList.add('active');
startVdl2Stream(false);
}
})
.catch(() => {});
}); });
// ============================================ // ============================================
@@ -4865,7 +4882,7 @@ sudo make install</code>
<!-- Help Modal --> <!-- Help Modal -->
{% include 'partials/help-modal.html' %} {% include 'partials/help-modal.html' %}
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}"></script> <script src="{{ url_for('static', filename='js/core/settings-manager.js') }}?v={{ version }}&r=maptheme17"></script>
<!-- Agent Manager --> <!-- Agent Manager -->
<script src="{{ url_for('static', filename='js/core/agents.js') }}"></script> <script src="{{ url_for('static', filename='js/core/agents.js') }}"></script>
@@ -5107,7 +5124,7 @@ sudo make install</code>
const gps = typeof data.agent.gps_coords === 'string' const gps = typeof data.agent.gps_coords === 'string'
? JSON.parse(data.agent.gps_coords) ? JSON.parse(data.agent.gps_coords)
: data.agent.gps_coords; : data.agent.gps_coords;
if (gps.lat && gps.lon) { if (gps.lat !== undefined && gps.lat !== null && gps.lon !== undefined && gps.lon !== null) {
document.getElementById('obsLat').value = gps.lat.toFixed(4); document.getElementById('obsLat').value = gps.lat.toFixed(4);
document.getElementById('obsLon').value = gps.lon.toFixed(4); document.getElementById('obsLon').value = gps.lon.toFixed(4);
updateObserverLoc(); updateObserverLoc();
+3 -3
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" class="{% if offline_settings.tile_provider in ['cartodb_dark', 'cartodb_dark_cyan'] %}map-cyber-enabled{% endif %}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -11,7 +11,7 @@
{% endif %} {% endif %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/global-nav.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/global-nav.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}?v={{ version }}&r=maptheme17">
<link rel="stylesheet" href="{{ url_for('static', filename='css/help-modal.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/help-modal.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/adsb_history.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/adsb_history.css') }}">
</head> </head>
@@ -778,7 +778,7 @@
<!-- Help Modal --> <!-- Help Modal -->
{% include 'partials/help-modal.html' %} {% include 'partials/help-modal.html' %}
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}"></script> <script src="{{ url_for('static', filename='js/core/settings-manager.js') }}?v={{ version }}&r=maptheme17"></script>
<script src="{{ url_for('static', filename='js/core/global-nav.js') }}"></script> <script src="{{ url_for('static', filename='js/core/global-nav.js') }}"></script>
</body> </body>
</html> </html>
+3 -3
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" class="{% if offline_settings.tile_provider in ['cartodb_dark', 'cartodb_dark_cyan'] %}map-cyber-enabled{% endif %}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
@@ -11,7 +11,7 @@
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/global-nav.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/global-nav.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/agents.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/agents.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}?v={{ version }}&r=maptheme17">
<link rel="stylesheet" href="{{ url_for('static', filename='css/help-modal.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/help-modal.css') }}">
<style> <style>
.agents-container { .agents-container {
@@ -562,7 +562,7 @@
<!-- Help Modal --> <!-- Help Modal -->
{% include 'partials/help-modal.html' %} {% include 'partials/help-modal.html' %}
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}"></script> <script src="{{ url_for('static', filename='js/core/settings-manager.js') }}?v={{ version }}&r=maptheme17"></script>
<script src="{{ url_for('static', filename='js/core/global-nav.js') }}"></script> <script src="{{ url_for('static', filename='js/core/global-nav.js') }}"></script>
</body> </body>
</html> </html>
+7 -7
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" class="{% if offline_settings.tile_provider in ['cartodb_dark', 'cartodb_dark_cyan'] %}map-cyber-enabled{% endif %}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -23,7 +23,7 @@
<link rel="stylesheet" href="{{ url_for('static', filename='css/ais_dashboard.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/ais_dashboard.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/global-nav.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/global-nav.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}?v={{ version }}&r=maptheme17">
<link rel="stylesheet" href="{{ url_for('static', filename='css/help-modal.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/help-modal.css') }}">
<script> <script>
window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }}; window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }};
@@ -750,7 +750,7 @@
stats.fastestSpeed = data.speed; stats.fastestSpeed = data.speed;
} }
if (data.lat && data.lon) { if (data.lat !== undefined && data.lat !== null && data.lon !== undefined && data.lon !== null) {
const dist = calculateDistance(observerLocation.lat, observerLocation.lon, data.lat, data.lon); const dist = calculateDistance(observerLocation.lat, observerLocation.lon, data.lat, data.lon);
if (dist > stats.maxRange) stats.maxRange = dist; if (dist > stats.maxRange) stats.maxRange = dist;
if (dist < stats.closestDistance) stats.closestDistance = dist; if (dist < stats.closestDistance) stats.closestDistance = dist;
@@ -1019,7 +1019,7 @@
gpsEventSource.onmessage = (event) => { gpsEventSource.onmessage = (event) => {
try { try {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
if (data.type === 'position' && data.latitude && data.longitude) { if (data.type === 'position' && data.latitude !== undefined && data.latitude !== null && data.longitude !== undefined && data.longitude !== null) {
updateLocationFromGps(data); updateLocationFromGps(data);
} }
} catch (e) { } catch (e) {
@@ -1345,7 +1345,7 @@
} }
// Add position marker if coordinates present // Add position marker if coordinates present
if (data.latitude && data.longitude) { if (data.latitude !== undefined && data.latitude !== null && data.longitude !== undefined && data.longitude !== null) {
addDscPositionMarker(data); addDscPositionMarker(data);
} }
@@ -1562,7 +1562,7 @@
<!-- Help Modal --> <!-- Help Modal -->
{% include 'partials/help-modal.html' %} {% include 'partials/help-modal.html' %}
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}"></script> <script src="{{ url_for('static', filename='js/core/settings-manager.js') }}?v={{ version }}&r=maptheme17"></script>
<!-- Agent Manager --> <!-- Agent Manager -->
<script src="{{ url_for('static', filename='js/core/agents.js') }}"></script> <script src="{{ url_for('static', filename='js/core/agents.js') }}"></script>
@@ -1600,7 +1600,7 @@
const gps = typeof data.agent.gps_coords === 'string' const gps = typeof data.agent.gps_coords === 'string'
? JSON.parse(data.agent.gps_coords) ? JSON.parse(data.agent.gps_coords)
: data.agent.gps_coords; : data.agent.gps_coords;
if (gps.lat && gps.lon) { if (gps.lat !== undefined && gps.lat !== null && gps.lon !== undefined && gps.lon !== null) {
document.getElementById('obsLat').value = gps.lat.toFixed(4); document.getElementById('obsLat').value = gps.lat.toFixed(4);
document.getElementById('obsLon').value = gps.lon.toFixed(4); document.getElementById('obsLon').value = gps.lon.toFixed(4);
updateObserverLoc(); updateObserverLoc();
+546 -1020
View File
File diff suppressed because it is too large Load Diff
+11 -1
View File
@@ -133,7 +133,17 @@
const currentTheme = html.getAttribute('data-theme') || 'dark'; const currentTheme = html.getAttribute('data-theme') || 'dark';
const newTheme = currentTheme === 'dark' ? 'light' : 'dark'; const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
html.setAttribute('data-theme', newTheme); html.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme); localStorage.setItem('intercept-theme', newTheme);
const btn = document.getElementById('themeToggle');
if (btn) btn.textContent = newTheme === 'light' ? '🌙' : '☀️';
// Persist to server
fetch('/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ theme: newTheme })
}).catch(() => {});
} }
// Apply saved theme // Apply saved theme
+5 -5
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" class="{% if offline_settings.tile_provider in ['cartodb_dark', 'cartodb_dark_cyan'] %}map-cyber-enabled{% endif %}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -12,7 +12,7 @@
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/agents.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/agents.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/global-nav.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/global-nav.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}?v={{ version }}&r=maptheme17">
<link rel="stylesheet" href="{{ url_for('static', filename='css/help-modal.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/help-modal.css') }}">
<style> <style>
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
@@ -787,7 +787,7 @@
entry.rssiByAgent.forEach((info, agentName) => { entry.rssiByAgent.forEach((info, agentName) => {
const gps = agentGPS.get(agentName); const gps = agentGPS.get(agentName);
if (gps && gps.lat && gps.lon) { if (gps && gps.lat !== undefined && gps.lat !== null && gps.lon !== undefined && gps.lon !== null) {
observations.push({ observations.push({
agent_name: agentName, agent_name: agentName,
agent_lat: gps.lat, agent_lat: gps.lat,
@@ -1073,7 +1073,7 @@
const coords = agent.gps_coords; const coords = agent.gps_coords;
const lat = coords.lat || coords.latitude; const lat = coords.lat || coords.latitude;
const lon = coords.lon || coords.longitude; const lon = coords.lon || coords.longitude;
if (lat && lon) { if (lat !== undefined && lat !== null && lon !== undefined && lon !== null) {
agentGPS.set(agent.name, { lat, lon }); agentGPS.set(agent.name, { lat, lon });
addLogEntry('gps', `Agent "${agent.name}" GPS: ${lat.toFixed(4)}, ${lon.toFixed(4)}`); addLogEntry('gps', `Agent "${agent.name}" GPS: ${lat.toFixed(4)}, ${lon.toFixed(4)}`);
} }
@@ -1117,7 +1117,7 @@
<!-- Help Modal --> <!-- Help Modal -->
{% include 'partials/help-modal.html' %} {% include 'partials/help-modal.html' %}
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}"></script> <script src="{{ url_for('static', filename='js/core/settings-manager.js') }}?v={{ version }}&r=maptheme17"></script>
<script src="{{ url_for('static', filename='js/core/global-nav.js') }}"></script> <script src="{{ url_for('static', filename='js/core/global-nav.js') }}"></script>
</body> </body>
</html> </html>
+52 -20
View File
@@ -4,20 +4,20 @@
#} #}
<!-- Help Modal --> <!-- Help Modal -->
<div id="helpModal" class="help-modal" onclick="if(event.target === this) hideHelp()"> <div id="helpModal" class="help-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="helpModalTitle" onclick="if(event.target === this) hideHelp()">
<div class="help-content"> <div class="help-content" tabindex="-1">
<button class="help-close" onclick="hideHelp()">&times;</button> <button type="button" class="help-close" onclick="hideHelp()" aria-label="Close help">&times;</button>
<h2>iNTERCEPT Help</h2> <h2 id="helpModalTitle">iNTERCEPT Help</h2>
<div class="help-tabs"> <div class="help-tabs" role="tablist" aria-label="Help sections">
<button class="help-tab active" data-tab="icons" onclick="switchHelpTab('icons')">Icons</button> <button type="button" class="help-tab active" data-tab="icons" onclick="switchHelpTab('icons')" role="tab" aria-controls="help-icons" aria-selected="true">Icons</button>
<button class="help-tab" data-tab="modes" onclick="switchHelpTab('modes')">Modes</button> <button type="button" class="help-tab" data-tab="modes" onclick="switchHelpTab('modes')" role="tab" aria-controls="help-modes" aria-selected="false">Modes</button>
<button class="help-tab" data-tab="wifi" onclick="switchHelpTab('wifi')">WiFi</button> <button type="button" class="help-tab" data-tab="wifi" onclick="switchHelpTab('wifi')" role="tab" aria-controls="help-wifi" aria-selected="false">WiFi</button>
<button class="help-tab" data-tab="tips" onclick="switchHelpTab('tips')">Tips</button> <button type="button" class="help-tab" data-tab="tips" onclick="switchHelpTab('tips')" role="tab" aria-controls="help-tips" aria-selected="false">Tips</button>
</div> </div>
<!-- Icons Section --> <!-- Icons Section -->
<div id="help-icons" class="help-section active"> <div id="help-icons" class="help-section active" role="tabpanel">
<h3>Stats Bar Icons</h3> <h3>Stats Bar Icons</h3>
<div class="icon-grid"> <div class="icon-grid">
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg></span><span class="desc">POCSAG messages decoded</span></div> <div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg></span><span class="desc">POCSAG messages decoded</span></div>
@@ -57,11 +57,12 @@
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></span><span class="desc">Weather Sat - NOAA &amp; Meteor imagery</span></div> <div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></span><span class="desc">Weather Sat - NOAA &amp; Meteor imagery</span></div>
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg></span><span class="desc">HF SSTV - Shortwave image decoder</span></div> <div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg></span><span class="desc">HF SSTV - Shortwave image decoder</span></div>
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg></span><span class="desc">GPS - GNSS signal analysis</span></div> <div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg></span><span class="desc">GPS - GNSS signal analysis</span></div>
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg></span><span class="desc">Space Weather - Solar &amp; geomagnetic data</span></div>
</div> </div>
</div> </div>
<!-- Modes Section --> <!-- Modes Section -->
<div id="help-modes" class="help-section"> <div id="help-modes" class="help-section" role="tabpanel" hidden>
<h3>Pager Mode</h3> <h3>Pager Mode</h3>
<ul class="tip-list"> <ul class="tip-list">
<li>Decodes POCSAG and FLEX pager signals using RTL-SDR</li> <li>Decodes POCSAG and FLEX pager signals using RTL-SDR</li>
@@ -197,6 +198,15 @@
<li>Useful for evaluating GNSS reception and interference</li> <li>Useful for evaluating GNSS reception and interference</li>
</ul> </ul>
<h3>Space Weather Mode</h3>
<ul class="tip-list">
<li>Real-time solar and geomagnetic data from NOAA SWPC, NASA SDO, and HamQSL</li>
<li>Solar indices (SFI, Kp, A-index, sunspot number) and NOAA G/S/R scales</li>
<li>HF band conditions, X-ray flux, solar wind speed, and flare probability</li>
<li>Solar imagery (SDO 193/304/Magnetogram), D-RAP absorption maps, aurora forecast</li>
<li>No SDR hardware required &mdash; all data from public APIs</li>
</ul>
<h3>WiFi Mode</h3> <h3>WiFi Mode</h3>
<ul class="tip-list"> <ul class="tip-list">
<li>Requires a WiFi adapter capable of monitor mode</li> <li>Requires a WiFi adapter capable of monitor mode</li>
@@ -244,7 +254,7 @@
</div> </div>
<!-- WiFi Section --> <!-- WiFi Section -->
<div id="help-wifi" class="help-section"> <div id="help-wifi" class="help-section" role="tabpanel" hidden>
<h3>Monitor Mode</h3> <h3>Monitor Mode</h3>
<ul class="tip-list"> <ul class="tip-list">
<li><strong>Enable Monitor:</strong> Puts WiFi adapter in monitor mode for passive scanning</li> <li><strong>Enable Monitor:</strong> Puts WiFi adapter in monitor mode for passive scanning</li>
@@ -292,7 +302,7 @@
</div> </div>
<!-- Tips Section --> <!-- Tips Section -->
<div id="help-tips" class="help-section"> <div id="help-tips" class="help-section" role="tabpanel" hidden>
<h3>General Tips</h3> <h3>General Tips</h3>
<ul class="tip-list"> <ul class="tip-list">
<li><strong>Collapsible sections:</strong> Click any section header (&nabla;) to collapse/expand</li> <li><strong>Collapsible sections:</strong> Click any section header (&nabla;) to collapse/expand</li>
@@ -330,6 +340,7 @@
<li><strong>Weather Sat:</strong> RTL-SDR, SatDump</li> <li><strong>Weather Sat:</strong> RTL-SDR, SatDump</li>
<li><strong>HF SSTV:</strong> RTL-SDR or SoapySDR-compatible hardware, slowrx</li> <li><strong>HF SSTV:</strong> RTL-SDR or SoapySDR-compatible hardware, slowrx</li>
<li><strong>GPS:</strong> RTL-SDR or GPS-capable SDR</li> <li><strong>GPS:</strong> RTL-SDR or GPS-capable SDR</li>
<li><strong>Space Weather:</strong> Internet connection (public APIs)</li>
<li><strong>WiFi:</strong> Monitor-mode adapter, aircrack-ng suite</li> <li><strong>WiFi:</strong> Monitor-mode adapter, aircrack-ng suite</li>
<li><strong>Bluetooth:</strong> Bluetooth adapter, bluez (hcitool/bluetoothctl)</li> <li><strong>Bluetooth:</strong> Bluetooth adapter, bluez (hcitool/bluetoothctl)</li>
<li><strong>BT Locate:</strong> Bluetooth adapter, bluez</li> <li><strong>BT Locate:</strong> Bluetooth adapter, bluez</li>
@@ -350,27 +361,45 @@
<script> <script>
// Help modal functions - defined here so all pages have them // Help modal functions - defined here so all pages have them
(function() { (function() {
let lastHelpFocusEl = null;
// Only define if not already defined (index.html defines its own) // Only define if not already defined (index.html defines its own)
if (typeof window.showHelp === 'undefined') { if (typeof window.showHelp === 'undefined') {
window.showHelp = function() { window.showHelp = function() {
document.getElementById('helpModal').classList.add('active'); const modal = document.getElementById('helpModal');
lastHelpFocusEl = document.activeElement;
modal.classList.add('active');
modal.setAttribute('aria-hidden', 'false');
const content = modal.querySelector('.help-content');
if (content) content.focus();
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
}; };
} }
if (typeof window.hideHelp === 'undefined') { if (typeof window.hideHelp === 'undefined') {
window.hideHelp = function() { window.hideHelp = function() {
document.getElementById('helpModal').classList.remove('active'); const modal = document.getElementById('helpModal');
modal.classList.remove('active');
modal.setAttribute('aria-hidden', 'true');
document.body.style.overflow = ''; document.body.style.overflow = '';
if (lastHelpFocusEl && typeof lastHelpFocusEl.focus === 'function') {
lastHelpFocusEl.focus();
}
}; };
} }
if (typeof window.switchHelpTab === 'undefined') { if (typeof window.switchHelpTab === 'undefined') {
window.switchHelpTab = function(tab) { window.switchHelpTab = function(tab) {
document.querySelectorAll('.help-tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.help-tab').forEach(t => {
document.querySelectorAll('.help-section').forEach(s => s.classList.remove('active')); const isActive = t.dataset.tab === tab;
document.querySelector('.help-tab[data-tab="' + tab + '"]').classList.add('active'); t.classList.toggle('active', isActive);
document.getElementById('help-' + tab).classList.add('active'); t.setAttribute('aria-selected', isActive ? 'true' : 'false');
});
document.querySelectorAll('.help-section').forEach(s => {
const isActive = s.id === ('help-' + tab);
s.classList.toggle('active', isActive);
s.hidden = !isActive;
});
}; };
} }
@@ -378,7 +407,10 @@
if (!window._helpKeyboardSetup) { if (!window._helpKeyboardSetup) {
window._helpKeyboardSetup = true; window._helpKeyboardSetup = true;
document.addEventListener('keydown', function(e) { document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') hideHelp(); if (e.key === 'Escape') {
const modal = document.getElementById('helpModal');
if (modal && modal.classList.contains('active')) hideHelp();
}
// Open help with F1 or ? key (when not typing in an input) // Open help with F1 or ? key (when not typing in an input)
var helpModal = document.getElementById('helpModal'); var helpModal = document.getElementById('helpModal');
if (helpModal && (e.key === 'F1' || (e.key === '?' && !e.target.matches('input, textarea, select'))) && !helpModal.classList.contains('active')) { if (helpModal && (e.key === 'F1' || (e.key === '?' && !e.target.matches('input, textarea, select'))) && !helpModal.classList.contains('active')) {
+3 -3
View File
@@ -56,8 +56,8 @@
<strong style="color: var(--accent-cyan); font-size: 12px;">Quick Reference</strong> <strong style="color: var(--accent-cyan); font-size: 12px;">Quick Reference</strong>
<table style="width: 100%; margin-top: 6px; font-size: 10px; border-collapse: collapse;"> <table style="width: 100%; margin-top: 6px; font-size: 10px; border-collapse: collapse;">
<tr style="border-bottom: 1px solid var(--border-color);"> <tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">Primary (worldwide)</td> <td style="padding: 3px 4px; color: var(--text-dim);">Primary (N. America)</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">131.550 MHz</td> <td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">131.725 / 131.825 MHz</td>
</tr> </tr>
<tr style="border-bottom: 1px solid var(--border-color);"> <tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">Quarter-wave length</td> <td style="padding: 3px 4px; color: var(--text-dim);">Quarter-wave length</td>
@@ -89,7 +89,7 @@
let acarsMainMsgCount = 0; let acarsMainMsgCount = 0;
const acarsMainFrequencies = { const acarsMainFrequencies = {
'na': ['129.125', '130.025', '130.450', '131.550'], 'na': ['131.725', '131.825'],
'eu': ['131.525', '131.725', '131.550'], 'eu': ['131.525', '131.725', '131.550'],
'ap': ['131.550', '131.450'] 'ap': ['131.550', '131.450']
}; };
+211
View File
@@ -0,0 +1,211 @@
<!-- ANALYTICS MODE -->
<div id="analyticsMode" class="mode-content">
{# Analytics Dashboard Sidebar Panel #}
<div class="section">
<h3 class="section-header collapsible" onclick="toggleSection(this)">
<span>Summary</span>
<span class="collapse-icon">&#9660;</span>
</h3>
<div class="section-content">
<div class="analytics-grid" id="analyticsSummaryCards">
<div class="analytics-card" data-mode="adsb">
<div class="card-count" id="analyticsCountAdsb">0</div>
<div class="card-label">Aircraft</div>
<div class="card-sparkline" id="analyticsSparkAdsb"></div>
</div>
<div class="analytics-card" data-mode="ais">
<div class="card-count" id="analyticsCountAis">0</div>
<div class="card-label">Vessels</div>
<div class="card-sparkline" id="analyticsSparkAis"></div>
</div>
<div class="analytics-card" data-mode="wifi">
<div class="card-count" id="analyticsCountWifi">0</div>
<div class="card-label">WiFi</div>
<div class="card-sparkline" id="analyticsSparkWifi"></div>
</div>
<div class="analytics-card" data-mode="bluetooth">
<div class="card-count" id="analyticsCountBt">0</div>
<div class="card-label">Bluetooth</div>
<div class="card-sparkline" id="analyticsSparkBt"></div>
</div>
<div class="analytics-card" data-mode="dsc">
<div class="card-count" id="analyticsCountDsc">0</div>
<div class="card-label">DSC</div>
<div class="card-sparkline" id="analyticsSparkDsc"></div>
</div>
<div class="analytics-card" data-mode="acars">
<div class="card-count" id="analyticsCountAcars">0</div>
<div class="card-label">ACARS</div>
<div class="card-sparkline" id="analyticsSparkAcars"></div>
</div>
<div class="analytics-card" data-mode="vdl2">
<div class="card-count" id="analyticsCountVdl2">0</div>
<div class="card-label">VDL2</div>
<div class="card-sparkline" id="analyticsSparkVdl2"></div>
</div>
<div class="analytics-card" data-mode="aprs">
<div class="card-count" id="analyticsCountAprs">0</div>
<div class="card-label">APRS</div>
<div class="card-sparkline" id="analyticsSparkAprs"></div>
</div>
<div class="analytics-card" data-mode="meshtastic">
<div class="card-count" id="analyticsCountMesh">0</div>
<div class="card-label">Mesh</div>
<div class="card-sparkline" id="analyticsSparkMesh"></div>
</div>
</div>
</div>
</div>
<div class="section">
<h3 class="section-header collapsible" onclick="toggleSection(this)">
<span>Operational Insights</span>
<span class="collapse-icon">&#9660;</span>
</h3>
<div class="section-content">
<div class="analytics-insight-grid" id="analyticsInsights">
<div class="analytics-empty">Insights loading...</div>
</div>
<div class="analytics-top-changes">
<div class="analytics-section-header">Top Changes</div>
<div id="analyticsTopChanges">
<div class="analytics-empty">No change signals yet</div>
</div>
</div>
</div>
</div>
<div class="section">
<h3 class="section-header collapsible" onclick="toggleSection(this)">
<span>Mode Health</span>
<span class="collapse-icon">&#9660;</span>
</h3>
<div class="section-content">
<div class="analytics-health" id="analyticsHealth"></div>
</div>
</div>
<div class="section" id="analyticsSquawkSection" style="display:none;">
<h3 class="section-header collapsible" onclick="toggleSection(this)">
<span>Emergency Squawks</span>
<span class="collapse-icon">&#9660;</span>
</h3>
<div class="section-content">
<div class="squawk-emergency" id="analyticsSquawkPanel">
<div class="squawk-title">Active Emergency Codes</div>
<div id="analyticsSquawkList"></div>
</div>
</div>
</div>
<div class="section">
<h3 class="section-header collapsible" onclick="toggleSection(this)">
<span>Temporal Patterns</span>
<span class="collapse-icon">&#9660;</span>
</h3>
<div class="section-content">
<div id="analyticsPatternList">
<div class="analytics-empty">No recurring patterns detected</div>
</div>
</div>
</div>
<div class="section">
<h3 class="section-header collapsible" onclick="toggleSection(this)">
<span>Recent Alerts</span>
<span class="collapse-icon">&#9660;</span>
</h3>
<div class="section-content">
<div class="analytics-alert-feed" id="analyticsAlertFeed">
<div class="analytics-empty">No recent alerts</div>
</div>
</div>
</div>
<div class="section">
<h3 class="section-header collapsible" onclick="toggleSection(this)">
<span>Correlations</span>
<span class="collapse-icon">&#9660;</span>
</h3>
<div class="section-content">
<div id="analyticsCorrelations">
<div class="analytics-empty">No correlations detected</div>
</div>
</div>
</div>
<div class="section">
<h3 class="section-header collapsible" onclick="toggleSection(this)">
<span>Geofences</span>
<span class="collapse-icon">&#9660;</span>
</h3>
<div class="section-content">
<div id="analyticsGeofenceList"></div>
<button class="btn btn-sm" onclick="Analytics.addGeofence()" style="margin-top:8px; font-size:10px; padding:4px 10px; background:var(--accent-cyan); color:#fff; border:none; border-radius:4px; cursor:pointer;">
+ Add Zone
</button>
</div>
</div>
<div class="section">
<h3 class="section-header collapsible" onclick="toggleSection(this)">
<span>Target View</span>
<span class="collapse-icon">&#9660;</span>
</h3>
<div class="section-content">
<div class="analytics-target-toolbar">
<input id="analyticsTargetQuery" type="text" placeholder="Search callsign, ICAO, MMSI, MAC, SSID, node..." onkeydown="if(event.key==='Enter'){Analytics.searchTarget();}">
<button onclick="Analytics.searchTarget()">Search</button>
</div>
<div id="analyticsTargetSummary" class="analytics-target-summary">Search to correlate entities across modes</div>
<div id="analyticsTargetResults">
<div class="analytics-empty">No target selected</div>
</div>
</div>
</div>
<div class="section">
<h3 class="section-header collapsible" onclick="toggleSection(this)">
<span>Session Replay</span>
<span class="collapse-icon">&#9660;</span>
</h3>
<div class="section-content">
<div class="analytics-replay-toolbar">
<select id="analyticsReplaySelect"></select>
<button onclick="Analytics.loadReplay()">Load</button>
<button onclick="Analytics.playReplay()">Play</button>
<button onclick="Analytics.pauseReplay()">Pause</button>
<button onclick="Analytics.stepReplay()">Step</button>
</div>
<div id="analyticsReplayMeta" class="analytics-target-summary">No replay loaded</div>
<div id="analyticsReplayTimeline">
<div class="analytics-empty">Select a recording to replay key events</div>
</div>
</div>
</div>
<div class="section">
<h3 class="section-header collapsible" onclick="toggleSection(this)">
<span>Export Data</span>
<span class="collapse-icon">&#9660;</span>
</h3>
<div class="section-content">
<div class="export-controls">
<select id="exportMode">
<option value="adsb">ADS-B</option>
<option value="ais">AIS</option>
<option value="wifi">WiFi</option>
<option value="bluetooth">Bluetooth</option>
<option value="dsc">DSC</option>
</select>
<select id="exportFormat">
<option value="json">JSON</option>
<option value="csv">CSV</option>
</select>
<button onclick="Analytics.exportData()">Export</button>
</div>
</div>
</div>
</div>
+1
View File
@@ -52,6 +52,7 @@
<!-- Message Container for status cards --> <!-- Message Container for status cards -->
<div id="btMessageContainer"></div> <div id="btMessageContainer"></div>
<div id="btBaselineStatus" style="margin-top: 6px; font-size: 10px; color: var(--text-dim);">No baseline</div>
<button class="run-btn" id="startBtBtn" onclick="btStartScan()"> <button class="run-btn" id="startBtBtn" onclick="btStartScan()">
Start Scanning Start Scanning
@@ -0,0 +1,71 @@
<!-- SPACE WEATHER MODE -->
<div id="spaceWeatherMode" class="mode-content">
<div class="section">
<h3>Space Weather Monitor</h3>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
Real-time solar and geomagnetic data from NOAA SWPC, NASA SDO, and HamQSL.
No SDR hardware required — data is fetched from public APIs.
</p>
</div>
<div class="section">
<h3>Quick Status</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 6px; font-size: 11px; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;">
<div style="display: flex; justify-content: space-between; padding: 4px 6px; background: var(--bg-primary); border-radius: 3px;">
<span style="color: var(--text-dim);">SFI</span>
<span id="swSidebarSfi" style="color: var(--accent-cyan); font-weight: 600;">--</span>
</div>
<div style="display: flex; justify-content: space-between; padding: 4px 6px; background: var(--bg-primary); border-radius: 3px;">
<span style="color: var(--text-dim);">Kp</span>
<span id="swSidebarKp" style="color: var(--accent-cyan); font-weight: 600;">--</span>
</div>
<div style="display: flex; justify-content: space-between; padding: 4px 6px; background: var(--bg-primary); border-radius: 3px;">
<span style="color: var(--text-dim);">A-Index</span>
<span id="swSidebarA" style="color: var(--accent-cyan); font-weight: 600;">--</span>
</div>
<div style="display: flex; justify-content: space-between; padding: 4px 6px; background: var(--bg-primary); border-radius: 3px;">
<span style="color: var(--text-dim);">SSN</span>
<span id="swSidebarSsn" style="color: var(--accent-cyan); font-weight: 600;">--</span>
</div>
<div style="display: flex; justify-content: space-between; padding: 4px 6px; background: var(--bg-primary); border-radius: 3px;">
<span style="color: var(--text-dim);">Wind</span>
<span id="swSidebarWind" style="color: var(--accent-cyan); font-weight: 600;">--</span>
</div>
<div style="display: flex; justify-content: space-between; padding: 4px 6px; background: var(--bg-primary); border-radius: 3px;">
<span style="color: var(--text-dim);">Bz</span>
<span id="swSidebarBz" style="color: var(--accent-cyan); font-weight: 600;">--</span>
</div>
</div>
</div>
<div class="section">
<h3>Refresh</h3>
<button class="mode-btn" onclick="SpaceWeather.refresh()" style="width: 100%; margin-bottom: 8px;">
Refresh Now
</button>
<div class="form-group">
<label style="display: flex; align-items: center; gap: 6px;">
<input type="checkbox" id="swAutoRefresh" checked onchange="SpaceWeather.toggleAutoRefresh()" style="width: auto;">
Auto-refresh (5 min)
</label>
</div>
<div id="swLastUpdate" style="font-size: 10px; color: var(--text-dim); font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; margin-top: 4px;">
Not yet loaded
</div>
</div>
<div class="section">
<h3>Resources</h3>
<div style="display: flex; flex-direction: column; gap: 6px;">
<a href="https://www.swpc.noaa.gov/" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
NOAA Space Weather
</a>
<a href="https://sdo.gsfc.nasa.gov/" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
NASA SDO
</a>
<a href="https://www.hamqsl.com/solar.html" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
HamQSL Solar Data
</a>
</div>
</div>
</div>
+3 -2
View File
@@ -43,7 +43,8 @@
<option value="50.950|usb">50.950 MHz USB - SSTV calling</option> <option value="50.950|usb">50.950 MHz USB - SSTV calling</option>
</optgroup> </optgroup>
<optgroup label="2 m (VHF)"> <optgroup label="2 m (VHF)">
<option value="145.625|fm">145.625 MHz FM - Simplex</option> <option value="145.500|fm">145.500 MHz FM - Simplex</option>
<option value="145.880|fm">145.880 MHz FM - SONATE-2 (Martin M1)</option>
</optgroup> </optgroup>
<optgroup label="70 cm (UHF)"> <optgroup label="70 cm (UHF)">
<option value="433.775|fm">433.775 MHz FM - Simplex</option> <option value="433.775|fm">433.775 MHz FM - Simplex</option>
@@ -57,9 +58,9 @@
<div class="form-group"> <div class="form-group">
<label>Modulation</label> <label>Modulation</label>
<select id="sstvGeneralModulation" style="width: 100%; padding: 6px 8px; font-family: var(--font-mono); font-size: 11px; background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 4px; color: var(--text-primary);"> <select id="sstvGeneralModulation" style="width: 100%; padding: 6px 8px; font-family: var(--font-mono); font-size: 11px; background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 4px; color: var(--text-primary);">
<option value="fm" selected>FM (Frequency Modulation)</option>
<option value="usb">USB (Upper Sideband)</option> <option value="usb">USB (Upper Sideband)</option>
<option value="lsb">LSB (Lower Sideband)</option> <option value="lsb">LSB (Lower Sideband)</option>
<option value="fm">FM (Frequency Modulation)</option>
</select> </select>
</div> </div>
</div> </div>
@@ -226,7 +226,7 @@
</p> </p>
<div class="form-group"> <div class="form-group">
<label style="display: flex; align-items: center; gap: 6px;"> <label style="display: flex; align-items: center; gap: 6px;">
<input type="checkbox" id="wxsatSidebarAutoSchedule" onchange="WeatherSat.toggleScheduler()" style="width: auto;"> <input type="checkbox" id="wxsatSidebarAutoSchedule" onchange="WeatherSat.toggleScheduler(this)" style="width: auto;">
Enable Auto-Capture Enable Auto-Capture
</label> </label>
</div> </div>
+82 -59
View File
@@ -17,17 +17,17 @@
{% macro mode_item(mode, label, icon_svg, href=None) -%} {% macro mode_item(mode, label, icon_svg, href=None) -%}
{%- set is_active = 'active' if active_mode == mode else '' -%} {%- set is_active = 'active' if active_mode == mode else '' -%}
{%- if href %} {%- if href %}
<a href="{{ href }}" class="mode-nav-btn {{ is_active }}" style="text-decoration: none;"> <a href="{{ href }}" class="mode-nav-btn {{ is_active }}" style="text-decoration: none;" data-mode="{{ mode }}" data-mode-label="{{ label }}" aria-label="{{ label }} mode">
<span class="nav-icon icon">{{ icon_svg | safe }}</span> <span class="nav-icon icon">{{ icon_svg | safe }}</span>
<span class="nav-label">{{ label }}</span> <span class="nav-label">{{ label }}</span>
</a> </a>
{%- elif is_index_page %} {%- elif is_index_page %}
<button class="mode-nav-btn {{ is_active }}" onclick="switchMode('{{ mode }}')"> <button type="button" class="mode-nav-btn {{ is_active }}" data-mode="{{ mode }}" data-mode-label="{{ label }}" aria-label="{{ label }} mode" onclick="switchMode('{{ mode }}')">
<span class="nav-icon icon">{{ icon_svg | safe }}</span> <span class="nav-icon icon">{{ icon_svg | safe }}</span>
<span class="nav-label">{{ label }}</span> <span class="nav-label">{{ label }}</span>
</button> </button>
{%- else %} {%- else %}
<a href="/?mode={{ mode }}" class="mode-nav-btn {{ is_active }}" style="text-decoration: none;"> <a href="/?mode={{ mode }}" class="mode-nav-btn {{ is_active }}" style="text-decoration: none;" data-mode="{{ mode }}" data-mode-label="{{ label }}" aria-label="{{ label }} mode">
<span class="nav-icon icon">{{ icon_svg | safe }}</span> <span class="nav-icon icon">{{ icon_svg | safe }}</span>
<span class="nav-label">{{ label }}</span> <span class="nav-label">{{ label }}</span>
</a> </a>
@@ -37,15 +37,15 @@
{% macro mobile_item(mode, label, icon_svg, href=None) -%} {% macro mobile_item(mode, label, icon_svg, href=None) -%}
{%- set is_active = 'active' if active_mode == mode else '' -%} {%- set is_active = 'active' if active_mode == mode else '' -%}
{%- if href %} {%- if href %}
<a href="{{ href }}" class="mobile-nav-btn {{ is_active }}" style="text-decoration: none;"> <a href="{{ href }}" class="mobile-nav-btn {{ is_active }}" style="text-decoration: none;" data-mode="{{ mode }}" data-mode-label="{{ label }}" aria-label="{{ label }} mode">
<span class="icon icon--sm">{{ icon_svg | safe }}</span> {{ label }} <span class="icon icon--sm">{{ icon_svg | safe }}</span> {{ label }}
</a> </a>
{%- elif is_index_page %} {%- elif is_index_page %}
<button class="mobile-nav-btn {{ is_active }}" data-mode="{{ mode }}" onclick="switchMode('{{ mode }}')"> <button type="button" class="mobile-nav-btn {{ is_active }}" data-mode="{{ mode }}" data-mode-label="{{ label }}" aria-label="{{ label }} mode" onclick="switchMode('{{ mode }}')">
<span class="icon icon--sm">{{ icon_svg | safe }}</span> {{ label }} <span class="icon icon--sm">{{ icon_svg | safe }}</span> {{ label }}
</button> </button>
{%- else %} {%- else %}
<a href="/?mode={{ mode }}" class="mobile-nav-btn {{ is_active }}" style="text-decoration: none;"> <a href="/?mode={{ mode }}" class="mobile-nav-btn {{ is_active }}" style="text-decoration: none;" data-mode="{{ mode }}" data-mode-label="{{ label }}" aria-label="{{ label }} mode">
<span class="icon icon--sm">{{ icon_svg | safe }}</span> {{ label }} <span class="icon icon--sm">{{ icon_svg | safe }}</span> {{ label }}
</a> </a>
{%- endif %} {%- endif %}
@@ -53,11 +53,11 @@
{# Desktop Navigation - uses existing CSS class names for compatibility #} {# Desktop Navigation - uses existing CSS class names for compatibility #}
<nav class="mode-nav" id="mainNav"> <nav class="mode-nav" id="mainNav">
{# SDR / RF Group #} {# Signals Group #}
<div class="mode-nav-dropdown" data-group="sdr"> <div class="mode-nav-dropdown" data-group="signals">
<button class="mode-nav-dropdown-btn"{% if is_index_page %} onclick="toggleNavDropdown('sdr')"{% endif %}> <button type="button" class="mode-nav-dropdown-btn"{% if is_index_page %} onclick="toggleNavDropdown('signals')"{% endif %}>
<span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49m11.31-2.82a10 10 0 0 1 0 14.14m-14.14 0a10 10 0 0 1 0-14.14"/></svg></span> <span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h4l3-8 3 16 3-8h4"/><path d="M22 12h-1"/><path d="M1 12h1"/></svg></span>
<span class="nav-label">SDR / RF</span> <span class="nav-label">Signals</span>
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span> <span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
</button> </button>
@@ -65,50 +65,30 @@
{{ mode_item('pager', 'Pager', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg>') }} {{ mode_item('pager', 'Pager', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg>') }}
{{ mode_item('sensor', '433MHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg>') }} {{ mode_item('sensor', '433MHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg>') }}
{{ mode_item('rtlamr', 'Meters', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>') }} {{ mode_item('rtlamr', 'Meters', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>') }}
{{ mode_item('adsb', 'Aircraft', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg>', '/adsb/dashboard') }}
{{ mode_item('ais', 'Vessels', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></svg>', '/ais/dashboard') }}
{{ mode_item('aprs', 'APRS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg>') }}
{{ mode_item('listening', 'Listening Post', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>') }} {{ mode_item('listening', 'Listening Post', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>') }}
{{ mode_item('spystations', 'Spy Stations', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
{{ mode_item('meshtastic', 'Meshtastic', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>') }}
{{ mode_item('websdr', 'WebSDR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><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>') }}
{{ mode_item('subghz', 'SubGHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h6l3-9 3 18 3-9h5"/></svg>') }} {{ mode_item('subghz', 'SubGHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h6l3-9 3 18 3-9h5"/></svg>') }}
</div> </div>
</div> </div>
{# Wireless Group #} {# Tracking Group #}
<div class="mode-nav-dropdown" data-group="wireless"> <div class="mode-nav-dropdown" data-group="tracking">
<button class="mode-nav-dropdown-btn"{% if is_index_page %} onclick="toggleNavDropdown('wireless')"{% endif %}> <button type="button" class="mode-nav-dropdown-btn"{% if is_index_page %} onclick="toggleNavDropdown('tracking')"{% endif %}>
<span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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" fill="currentColor" stroke="none"/></svg></span> <span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" 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></span>
<span class="nav-label">Wireless</span> <span class="nav-label">Tracking</span>
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span> <span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
</button> </button>
<div class="mode-nav-dropdown-menu"> <div class="mode-nav-dropdown-menu">
{{ mode_item('wifi', 'WiFi', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" 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" fill="currentColor" stroke="none"/></svg>') }} {{ mode_item('adsb', 'Aircraft', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg>', '/adsb/dashboard') }}
{{ mode_item('bluetooth', 'Bluetooth', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg>') }} {{ mode_item('ais', 'Vessels', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></svg>', '/ais/dashboard') }}
{{ mode_item('bt_locate', 'BT Locate', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/><path d="M9.5 8.5l3 3 2-4-2 4-3 3"/></svg>') }} {{ mode_item('aprs', 'APRS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg>') }}
</div> {{ mode_item('gps', 'GPS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>') }}
</div>
{# Security Group #}
<div class="mode-nav-dropdown" data-group="security">
<button class="mode-nav-dropdown-btn"{% if is_index_page %} onclick="toggleNavDropdown('security')"{% endif %}>
<span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span>
<span class="nav-label">Security</span>
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
</button>
<div class="mode-nav-dropdown-menu">
{{ mode_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }}
</div> </div>
</div> </div>
{# Space Group #} {# Space Group #}
<div class="mode-nav-dropdown" data-group="space"> <div class="mode-nav-dropdown" data-group="space">
<button class="mode-nav-dropdown-btn"{% if is_index_page %} onclick="toggleNavDropdown('space')"{% endif %}> <button type="button" class="mode-nav-dropdown-btn"{% if is_index_page %} onclick="toggleNavDropdown('space')"{% endif %}>
<span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/></svg></span> <span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/></svg></span>
<span class="nav-label">Space</span> <span class="nav-label">Space</span>
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span> <span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
@@ -123,7 +103,39 @@
{{ mode_item('sstv', 'ISS SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M3 9h2"/><path d="M19 9h2"/><path d="M3 15h2"/><path d="M19 15h2"/></svg>') }} {{ mode_item('sstv', 'ISS SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M3 9h2"/><path d="M19 9h2"/><path d="M3 15h2"/><path d="M19 15h2"/></svg>') }}
{{ mode_item('weathersat', 'Weather Sat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }} {{ mode_item('weathersat', 'Weather Sat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
{{ mode_item('sstv_general', 'HF SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg>') }} {{ mode_item('sstv_general', 'HF SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg>') }}
{{ mode_item('gps', 'GPS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>') }} {{ mode_item('spaceweather', 'Space Weather', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>') }}
</div>
</div>
{# Wireless Group #}
<div class="mode-nav-dropdown" data-group="wireless">
<button type="button" class="mode-nav-dropdown-btn"{% if is_index_page %} onclick="toggleNavDropdown('wireless')"{% endif %}>
<span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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" fill="currentColor" stroke="none"/></svg></span>
<span class="nav-label">Wireless</span>
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
</button>
<div class="mode-nav-dropdown-menu">
{{ mode_item('wifi', 'WiFi', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" 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" fill="currentColor" stroke="none"/></svg>') }}
{{ mode_item('bluetooth', 'Bluetooth', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg>') }}
{{ mode_item('bt_locate', 'BT Locate', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/><path d="M9.5 8.5l3 3 2-4-2 4-3 3"/></svg>') }}
{{ mode_item('meshtastic', 'Meshtastic', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>') }}
</div>
</div>
{# Intel Group #}
<div class="mode-nav-dropdown" data-group="intel">
<button type="button" class="mode-nav-dropdown-btn"{% if is_index_page %} onclick="toggleNavDropdown('intel')"{% endif %}>
<span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></span>
<span class="nav-label">Intel</span>
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
</button>
<div class="mode-nav-dropdown-menu">
{{ mode_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }}
{{ mode_item('analytics', 'Analytics', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/></svg>') }}
{{ mode_item('spystations', 'Spy Stations', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
{{ mode_item('websdr', 'WebSDR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
</div> </div>
</div> </div>
@@ -148,11 +160,11 @@
</a> </a>
<div class="nav-divider"></div> <div class="nav-divider"></div>
<div class="nav-tools"> <div class="nav-tools">
<button class="nav-tool-btn" onclick="toggleAnimations()" title="Toggle Animations"> <button type="button" class="nav-tool-btn" onclick="toggleAnimations()" title="Toggle Animations" aria-label="Toggle animations">
<span class="icon-effects-on icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg></span> <span class="icon-effects-on icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg></span>
<span class="icon-effects-off icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/><line x1="2" y1="2" x2="22" y2="22"/></svg></span> <span class="icon-effects-off icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/><line x1="2" y1="2" x2="22" y2="22"/></svg></span>
</button> </button>
<button class="nav-tool-btn" onclick="toggleTheme()" title="Toggle Light/Dark Theme"> <button type="button" class="nav-tool-btn" onclick="toggleTheme()" title="Toggle Light/Dark Theme" aria-label="Toggle theme">
<span class="icon-moon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg></span> <span class="icon-moon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg></span>
<span class="icon-sun icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg></span> <span class="icon-sun icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg></span>
</button> </button>
@@ -162,11 +174,11 @@
<a href="/controller/manage" class="nav-tool-btn" title="Manage Remote Agents" style="text-decoration: none;"> <a href="/controller/manage" class="nav-tool-btn" title="Manage Remote Agents" style="text-decoration: none;">
<span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg></span> <span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg></span>
</a> </a>
<button class="nav-tool-btn" onclick="showSettings()" title="Settings"> <button type="button" class="nav-tool-btn" onclick="showSettings()" title="Settings" aria-label="Open settings">
<span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span> <span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
</button> </button>
<button class="nav-tool-btn" onclick="showHelp()" title="Help & Documentation">?</button> <button type="button" class="nav-tool-btn" onclick="showHelp()" title="Help & Documentation" aria-label="Open help">?</button>
<button class="nav-tool-btn" onclick="logout(event)" title="Logout"> <button type="button" class="nav-tool-btn" onclick="logout(event)" title="Logout" aria-label="Logout">
<span class="power-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg></span> <span class="power-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg></span>
</button> </button>
</div> </div>
@@ -175,18 +187,18 @@
{# Mobile Navigation Bar #} {# Mobile Navigation Bar #}
<nav class="mobile-nav-bar" id="mobileNavBar"> <nav class="mobile-nav-bar" id="mobileNavBar">
{# Signals #}
{{ mobile_item('pager', 'Pager', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg>') }} {{ mobile_item('pager', 'Pager', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg>') }}
{{ mobile_item('sensor', '433MHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg>') }} {{ mobile_item('sensor', '433MHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg>') }}
{{ mobile_item('rtlamr', 'Meters', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>') }} {{ mobile_item('rtlamr', 'Meters', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>') }}
{{ mobile_item('listening', 'Scanner', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>') }}
{{ mobile_item('subghz', 'SubGHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12h6l3-9 3 18 3-9h5"/></svg>') }}
{# Tracking #}
{{ mobile_item('adsb', 'Aircraft', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg>', '/adsb/dashboard') }} {{ mobile_item('adsb', 'Aircraft', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg>', '/adsb/dashboard') }}
{{ mobile_item('ais', 'Vessels', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></svg>', '/ais/dashboard') }} {{ mobile_item('ais', 'Vessels', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></svg>', '/ais/dashboard') }}
{{ mobile_item('aprs', 'APRS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg>') }} {{ mobile_item('aprs', 'APRS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg>') }}
{{ mobile_item('wifi', 'WiFi', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor"/></svg>') }} {{ mobile_item('gps', 'GPS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>') }}
{{ mobile_item('bluetooth', 'BT', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg>') }} {# Space #}
{{ mobile_item('bt_locate', 'Locate', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>') }}
{{ mobile_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }}
{% if is_index_page %} {% if is_index_page %}
{{ mobile_item('satellite', 'Sat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/></svg>') }} {{ mobile_item('satellite', 'Sat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/></svg>') }}
{% else %} {% else %}
@@ -195,12 +207,17 @@
{{ mobile_item('sstv', 'SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/></svg>') }} {{ mobile_item('sstv', 'SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/></svg>') }}
{{ mobile_item('weathersat', 'WxSat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }} {{ mobile_item('weathersat', 'WxSat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
{{ mobile_item('sstv_general', 'HF SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/></svg>') }} {{ mobile_item('sstv_general', 'HF SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/></svg>') }}
{{ mobile_item('gps', 'GPS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>') }} {{ mobile_item('spaceweather', 'SpaceWx', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/></svg>') }}
{{ mobile_item('listening', 'Scanner', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>') }} {# Wireless #}
{{ mobile_item('spystations', 'Spy', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }} {{ mobile_item('wifi', 'WiFi', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor"/></svg>') }}
{{ mobile_item('bluetooth', 'BT', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg>') }}
{{ mobile_item('bt_locate', 'Locate', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>') }}
{{ mobile_item('meshtastic', 'Mesh', '<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="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>') }} {{ mobile_item('meshtastic', 'Mesh', '<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="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>') }}
{# Intel #}
{{ mobile_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }}
{{ mobile_item('analytics', 'Analytics', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/></svg>') }}
{{ mobile_item('spystations', 'Spy', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
{{ mobile_item('websdr', 'WebSDR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }} {{ mobile_item('websdr', 'WebSDR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
{{ mobile_item('subghz', 'SubGHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12h6l3-9 3 18 3-9h5"/></svg>') }}
</nav> </nav>
{# JavaScript stub for pages that don't have switchMode defined #} {# JavaScript stub for pages that don't have switchMode defined #}
@@ -240,7 +257,8 @@
const current = html.getAttribute('data-animations') || 'on'; const current = html.getAttribute('data-animations') || 'on';
const next = current === 'on' ? 'off' : 'on'; const next = current === 'on' ? 'off' : 'on';
html.setAttribute('data-animations', next); html.setAttribute('data-animations', next);
localStorage.setItem('animations', next); localStorage.setItem('intercept-animations', next);
localStorage.removeItem('animations');
}; };
} }
@@ -299,7 +317,12 @@
document.documentElement.setAttribute('data-theme', savedTheme); document.documentElement.setAttribute('data-theme', savedTheme);
} }
const savedAnimations = localStorage.getItem('intercept-animations'); const legacyAnimations = localStorage.getItem('animations');
const savedAnimations = localStorage.getItem('intercept-animations') || legacyAnimations;
if (legacyAnimations && !localStorage.getItem('intercept-animations')) {
localStorage.setItem('intercept-animations', legacyAnimations);
localStorage.removeItem('animations');
}
if (savedAnimations) { if (savedAnimations) {
document.documentElement.setAttribute('data-animations', savedAnimations); document.documentElement.setAttribute('data-animations', savedAnimations);
} }
+89 -16
View File
@@ -1,27 +1,27 @@
<!-- Settings Modal --> <!-- Settings Modal -->
<div id="settingsModal" class="settings-modal" onclick="if(event.target === this) hideSettings()"> <div id="settingsModal" class="settings-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="settingsModalTitle" onclick="if(event.target === this) hideSettings()">
<div class="settings-content"> <div class="settings-content" tabindex="-1">
<div class="settings-header"> <div class="settings-header">
<h2> <h2 id="settingsModalTitle">
<span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span> <span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
Settings Settings
</h2> </h2>
<button class="settings-close" onclick="hideSettings()">&times;</button> <button type="button" class="settings-close" onclick="hideSettings()" aria-label="Close settings">&times;</button>
</div> </div>
<div class="settings-tabs"> <div class="settings-tabs" role="tablist" aria-label="Settings sections">
<button class="settings-tab active" data-tab="offline" onclick="switchSettingsTab('offline')">Offline</button> <button type="button" class="settings-tab active" data-tab="offline" onclick="switchSettingsTab('offline')" role="tab" aria-controls="settings-offline" aria-selected="true">Offline</button>
<button class="settings-tab" data-tab="location" onclick="switchSettingsTab('location')">Location</button> <button type="button" class="settings-tab" data-tab="location" onclick="switchSettingsTab('location')" role="tab" aria-controls="settings-location" aria-selected="false">Location</button>
<button class="settings-tab" data-tab="display" onclick="switchSettingsTab('display')">Display</button> <button type="button" class="settings-tab" data-tab="display" onclick="switchSettingsTab('display')" role="tab" aria-controls="settings-display" aria-selected="false">Display</button>
<button class="settings-tab" data-tab="updates" onclick="switchSettingsTab('updates')">Updates</button> <button type="button" class="settings-tab" data-tab="updates" onclick="switchSettingsTab('updates')" role="tab" aria-controls="settings-updates" aria-selected="false">Updates</button>
<button class="settings-tab" data-tab="tools" onclick="switchSettingsTab('tools')">Tools</button> <button type="button" class="settings-tab" data-tab="tools" onclick="switchSettingsTab('tools')" role="tab" aria-controls="settings-tools" aria-selected="false">Tools</button>
<button class="settings-tab" data-tab="alerts" onclick="switchSettingsTab('alerts')">Alerts</button> <button type="button" class="settings-tab" data-tab="alerts" onclick="switchSettingsTab('alerts')" role="tab" aria-controls="settings-alerts" aria-selected="false">Alerts</button>
<button class="settings-tab" data-tab="recording" onclick="switchSettingsTab('recording')">Recording</button> <button type="button" class="settings-tab" data-tab="recording" onclick="switchSettingsTab('recording')" role="tab" aria-controls="settings-recording" aria-selected="false">Recording</button>
<button class="settings-tab" data-tab="about" onclick="switchSettingsTab('about')">About</button> <button type="button" class="settings-tab" data-tab="about" onclick="switchSettingsTab('about')" role="tab" aria-controls="settings-about" aria-selected="false">About</button>
</div> </div>
<!-- Offline Section --> <!-- Offline Section -->
<div id="settings-offline" class="settings-section active"> <div id="settings-offline" class="settings-section active" role="tabpanel">
<div class="settings-group"> <div class="settings-group">
<div class="settings-group-title">Offline Mode</div> <div class="settings-group-title">Offline Mode</div>
@@ -72,9 +72,9 @@
<span class="settings-label-desc">Map background imagery</span> <span class="settings-label-desc">Map background imagery</span>
</div> </div>
<select id="tileProvider" class="settings-select" onchange="Settings.setTileProvider(this.value)"> <select id="tileProvider" class="settings-select" onchange="Settings.setTileProvider(this.value)">
<option value="openstreetmap">OpenStreetMap</option> <option value="cartodb_dark_cyan">Intercept Default</option>
<option value="cartodb_dark">CartoDB Dark</option> <option value="cartodb_dark">CartoDB Dark</option>
<option value="cartodb_dark_cyan">CartoDB Dark (Cyan Tint)</option> <option value="openstreetmap">OpenStreetMap</option>
<option value="cartodb_light">CartoDB Positron</option> <option value="cartodb_light">CartoDB Positron</option>
<option value="esri_world">ESRI World Imagery</option> <option value="esri_world">ESRI World Imagery</option>
<option value="custom">Custom URL</option> <option value="custom">Custom URL</option>
@@ -291,6 +291,72 @@
</div> </div>
</div> </div>
<div class="settings-group">
<div class="settings-group-title">Rule Builder</div>
<div class="settings-row" style="border-bottom: none; padding-top: 0;">
<div class="settings-label">
<span class="settings-label-text">Rule Name</span>
<span class="settings-label-desc">Human-friendly title for this alert</span>
</div>
<input type="text" id="alertsRuleName" class="settings-input" placeholder="New alert rule" style="width: 220px;">
</div>
<div class="settings-row" style="border-bottom: none;">
<div class="settings-label">
<span class="settings-label-text">Mode</span>
<span class="settings-label-desc">Filter to a specific mode or all</span>
</div>
<select id="alertsRuleMode" class="settings-select" style="width: 220px;">
<option value="">All modes</option>
<option value="pager">Pager</option>
<option value="sensor">433 Sensors</option>
<option value="wifi">WiFi</option>
<option value="bluetooth">Bluetooth</option>
<option value="adsb">ADS-B</option>
<option value="ais">AIS</option>
<option value="acars">ACARS</option>
<option value="vdl2">VDL2</option>
<option value="aprs">APRS</option>
<option value="dsc">DSC</option>
<option value="meshtastic">Meshtastic</option>
</select>
</div>
<div class="settings-row" style="border-bottom: none;">
<div class="settings-label">
<span class="settings-label-text">Event Type</span>
<span class="settings-label-desc">Optional event type (for example <code>device_update</code>)</span>
</div>
<input type="text" id="alertsRuleEventType" class="settings-input" placeholder="Optional" style="width: 220px;">
</div>
<div class="settings-row" style="border-bottom: none;">
<div class="settings-label">
<span class="settings-label-text">Match Filter</span>
<span class="settings-label-desc">Optional key/value exact match (for example <code>address</code> + MAC)</span>
</div>
<div style="display:flex; gap:8px;">
<input type="text" id="alertsRuleMatchKey" class="settings-input" placeholder="key" style="width: 100px;">
<input type="text" id="alertsRuleMatchValue" class="settings-input" placeholder="value" style="width: 112px;">
</div>
</div>
<div class="settings-row" style="border-bottom: none;">
<div class="settings-label">
<span class="settings-label-text">Severity</span>
<span class="settings-label-desc">Controls priority coloring and notifications</span>
</div>
<select id="alertsRuleSeverity" class="settings-select" style="width: 220px;">
<option value="low">Low</option>
<option value="medium" selected>Medium</option>
<option value="high">High</option>
<option value="critical">Critical</option>
</select>
</div>
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-top: 8px;">
<button class="check-assets-btn" onclick="AlertCenter.saveRule()">Save Rule</button>
<button class="check-assets-btn" onclick="AlertCenter.clearRuleForm()">Clear</button>
<button class="check-assets-btn" onclick="AlertCenter.loadRules()">Refresh Rules</button>
</div>
<input type="hidden" id="alertsRuleEditingId" value="">
</div>
<div class="settings-group"> <div class="settings-group">
<div class="settings-group-title">Quick Rules</div> <div class="settings-group-title">Quick Rules</div>
<div style="display: flex; gap: 10px; flex-wrap: wrap;"> <div style="display: flex; gap: 10px; flex-wrap: wrap;">
@@ -301,6 +367,13 @@
Use Bluetooth device details to add specific device watchlist alerts. Use Bluetooth device details to add specific device watchlist alerts.
</div> </div>
</div> </div>
<div class="settings-group">
<div class="settings-group-title">Active Rules</div>
<div id="alertsRulesList" class="settings-feed">
<div class="settings-feed-empty">No rules yet</div>
</div>
</div>
</div> </div>
<!-- Recording Section --> <!-- Recording Section -->
+47 -28
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" class="{% if offline_settings.tile_provider in ['cartodb_dark', 'cartodb_dark_cyan'] %}map-cyber-enabled{% endif %}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -22,7 +22,7 @@
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/variables.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/core/variables.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/global-nav.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/global-nav.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}?v={{ version }}&r=maptheme17">
<link rel="stylesheet" href="{{ url_for('static', filename='css/help-modal.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/help-modal.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/satellite_dashboard.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/satellite_dashboard.css') }}">
<script> <script>
@@ -609,11 +609,17 @@
const cy = canvas.height / 2; const cy = canvas.height / 2;
const radius = Math.max(10, Math.min(cx, cy) - 40); const radius = Math.max(10, Math.min(cx, cy) - 40);
ctx.fillStyle = '#0a0a0f'; const cs = getComputedStyle(document.documentElement);
const bgColor = cs.getPropertyValue('--bg-card').trim() || '#0a0a0f';
const accentCyan = cs.getPropertyValue('--accent-cyan').trim() || '#00d4ff';
const accentRed = cs.getPropertyValue('--accent-red').trim() || '#ff4444';
const textDim = cs.getPropertyValue('--text-dim').trim() || 'rgba(0,212,255,0.4)';
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillRect(0, 0, canvas.width, canvas.height);
// Elevation rings // Elevation rings
ctx.strokeStyle = 'rgba(0, 212, 255, 0.15)'; ctx.strokeStyle = accentCyan + '26'; // ~15% opacity
ctx.lineWidth = 1; ctx.lineWidth = 1;
for (let el = 30; el <= 90; el += 30) { for (let el = 30; el <= 90; el += 30) {
const r = radius * (1 - el / 90); const r = radius * (1 - el / 90);
@@ -621,20 +627,20 @@
ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.stroke(); ctx.stroke();
ctx.fillStyle = 'rgba(0, 212, 255, 0.4)'; ctx.fillStyle = textDim;
ctx.font = '10px Roboto Condensed'; ctx.font = '10px Roboto Condensed';
ctx.fillText(el + '°', cx + 5, cy - r + 12); ctx.fillText(el + '°', cx + 5, cy - r + 12);
} }
// Horizon // Horizon
ctx.strokeStyle = 'rgba(0, 212, 255, 0.3)'; ctx.strokeStyle = accentCyan + '4D'; // ~30% opacity
ctx.lineWidth = 2; ctx.lineWidth = 2;
ctx.beginPath(); ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2); ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.stroke(); ctx.stroke();
// Cardinal lines // Cardinal lines
ctx.strokeStyle = 'rgba(0, 212, 255, 0.1)'; ctx.strokeStyle = accentCyan + '1A'; // ~10% opacity
ctx.lineWidth = 1; ctx.lineWidth = 1;
for (let az = 0; az < 360; az += 45) { for (let az = 0; az < 360; az += 45) {
const angle = (az - 90) * Math.PI / 180; const angle = (az - 90) * Math.PI / 180;
@@ -647,10 +653,10 @@
// Cardinal labels // Cardinal labels
ctx.font = 'bold 14px Orbitron'; ctx.font = 'bold 14px Orbitron';
const labels = [ const labels = [
{ text: 'N', az: 0, color: '#ff4444' }, { text: 'N', az: 0, color: accentRed },
{ text: 'E', az: 90, color: '#00d4ff' }, { text: 'E', az: 90, color: accentCyan },
{ text: 'S', az: 180, color: '#00d4ff' }, { text: 'S', az: 180, color: accentCyan },
{ text: 'W', az: 270, color: '#00d4ff' } { text: 'W', az: 270, color: accentCyan }
]; ];
labels.forEach(l => { labels.forEach(l => {
const angle = (l.az - 90) * Math.PI / 180; const angle = (l.az - 90) * Math.PI / 180;
@@ -978,10 +984,18 @@
const cy = canvas.height / 2; const cy = canvas.height / 2;
const radius = Math.max(10, Math.min(cx, cy) - 40); const radius = Math.max(10, Math.min(cx, cy) - 40);
ctx.fillStyle = '#0a0a0f'; const cs = getComputedStyle(document.documentElement);
const bgColor = cs.getPropertyValue('--bg-card').trim() || '#0a0a0f';
const accentCyan = cs.getPropertyValue('--accent-cyan').trim() || '#00d4ff';
const accentRed = cs.getPropertyValue('--accent-red').trim() || '#ff4444';
const accentGreen = cs.getPropertyValue('--accent-green').trim() || '#00ff88';
const textPrimary = cs.getPropertyValue('--text-primary').trim() || '#fff';
const textDim = cs.getPropertyValue('--text-dim').trim() || 'rgba(0,212,255,0.4)';
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = 'rgba(0, 212, 255, 0.15)'; ctx.strokeStyle = accentCyan + '26';
ctx.lineWidth = 1; ctx.lineWidth = 1;
for (let elRing = 30; elRing <= 90; elRing += 30) { for (let elRing = 30; elRing <= 90; elRing += 30) {
const r = radius * (1 - elRing / 90); const r = radius * (1 - elRing / 90);
@@ -989,18 +1003,18 @@
ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.stroke(); ctx.stroke();
ctx.fillStyle = 'rgba(0, 212, 255, 0.4)'; ctx.fillStyle = textDim;
ctx.font = '10px Roboto Condensed'; ctx.font = '10px Roboto Condensed';
ctx.fillText(elRing + '°', cx + 5, cy - r + 12); ctx.fillText(elRing + '°', cx + 5, cy - r + 12);
} }
ctx.strokeStyle = 'rgba(0, 212, 255, 0.3)'; ctx.strokeStyle = accentCyan + '4D';
ctx.lineWidth = 2; ctx.lineWidth = 2;
ctx.beginPath(); ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2); ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.stroke(); ctx.stroke();
ctx.strokeStyle = 'rgba(0, 212, 255, 0.1)'; ctx.strokeStyle = accentCyan + '1A';
ctx.lineWidth = 1; ctx.lineWidth = 1;
for (let azLine = 0; azLine < 360; azLine += 45) { for (let azLine = 0; azLine < 360; azLine += 45) {
const angle = (azLine - 90) * Math.PI / 180; const angle = (azLine - 90) * Math.PI / 180;
@@ -1012,10 +1026,10 @@
ctx.font = 'bold 14px Orbitron'; ctx.font = 'bold 14px Orbitron';
const labels = [ const labels = [
{ text: 'N', az: 0, color: '#ff4444' }, { text: 'N', az: 0, color: accentRed },
{ text: 'E', az: 90, color: '#00d4ff' }, { text: 'E', az: 90, color: accentCyan },
{ text: 'S', az: 180, color: '#00d4ff' }, { text: 'S', az: 180, color: accentCyan },
{ text: 'W', az: 270, color: '#00d4ff' } { text: 'W', az: 270, color: accentCyan }
]; ];
labels.forEach(l => { labels.forEach(l => {
const angle = (l.az - 90) * Math.PI / 180; const angle = (l.az - 90) * Math.PI / 180;
@@ -1048,21 +1062,21 @@
ctx.arc(x, y, 10, 0, Math.PI * 2); ctx.arc(x, y, 10, 0, Math.PI * 2);
ctx.fillStyle = color; ctx.fillStyle = color;
ctx.fill(); ctx.fill();
ctx.strokeStyle = '#fff'; ctx.strokeStyle = textPrimary;
ctx.lineWidth = 3; ctx.lineWidth = 3;
ctx.stroke(); ctx.stroke();
ctx.font = 'bold 11px Orbitron'; ctx.font = 'bold 11px Orbitron';
ctx.fillStyle = '#fff'; ctx.fillStyle = textPrimary;
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.fillText(satellites[selectedSatellite]?.name || 'SAT', x, y - 20); ctx.fillText(satellites[selectedSatellite]?.name || 'SAT', x, y - 20);
ctx.font = '10px Roboto Condensed'; ctx.font = '10px Roboto Condensed';
ctx.fillStyle = el > 0 ? '#00ff88' : '#ff4444'; ctx.fillStyle = el > 0 ? accentGreen : accentRed;
ctx.fillText(el.toFixed(1) + '°', x, y + 25); ctx.fillText(el.toFixed(1) + '°', x, y + 25);
} else { } else {
ctx.font = '12px Rajdhani'; ctx.font = '12px Rajdhani';
ctx.fillStyle = '#ff4444'; ctx.fillStyle = accentRed;
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.fillText('BELOW HORIZON', cx, cy + radius + 35); ctx.fillText('BELOW HORIZON', cx, cy + radius + 35);
} }
@@ -1076,6 +1090,11 @@
const cy = canvas.height / 2; const cy = canvas.height / 2;
const radius = Math.max(10, Math.min(cx, cy) - 40); const radius = Math.max(10, Math.min(cx, cy) - 40);
const cs = getComputedStyle(document.documentElement);
const accentRed = cs.getPropertyValue('--accent-red').trim() || '#ff4444';
const accentGreen = cs.getPropertyValue('--accent-green').trim() || '#00ff88';
const textPrimary = cs.getPropertyValue('--text-primary').trim() || '#fff';
if (el > -5) { if (el > -5) {
const posEl = Math.max(0, el); const posEl = Math.max(0, el);
const r = radius * (1 - posEl / 90); const r = radius * (1 - posEl / 90);
@@ -1097,17 +1116,17 @@
ctx.arc(x, y, 10, 0, Math.PI * 2); ctx.arc(x, y, 10, 0, Math.PI * 2);
ctx.fillStyle = color; ctx.fillStyle = color;
ctx.fill(); ctx.fill();
ctx.strokeStyle = '#fff'; ctx.strokeStyle = textPrimary;
ctx.lineWidth = 3; ctx.lineWidth = 3;
ctx.stroke(); ctx.stroke();
ctx.font = 'bold 11px Orbitron'; ctx.font = 'bold 11px Orbitron';
ctx.fillStyle = '#fff'; ctx.fillStyle = textPrimary;
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.fillText(satellites[selectedSatellite]?.name || 'SAT', x, y - 20); ctx.fillText(satellites[selectedSatellite]?.name || 'SAT', x, y - 20);
ctx.font = '10px Roboto Condensed'; ctx.font = '10px Roboto Condensed';
ctx.fillStyle = el > 0 ? '#00ff88' : '#ff4444'; ctx.fillStyle = el > 0 ? accentGreen : accentRed;
ctx.fillText(el.toFixed(1) + '°', x, y + 25); ctx.fillText(el.toFixed(1) + '°', x, y + 25);
} }
} }
@@ -1119,7 +1138,7 @@
<!-- Help Modal --> <!-- Help Modal -->
{% include 'partials/help-modal.html' %} {% include 'partials/help-modal.html' %}
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}"></script> <script src="{{ url_for('static', filename='js/core/settings-manager.js') }}?v={{ version }}&r=maptheme17"></script>
<script src="{{ url_for('static', filename='js/core/global-nav.js') }}"></script> <script src="{{ url_for('static', filename='js/core/global-nav.js') }}"></script>
</body> </body>
</html> </html>
+202
View File
@@ -0,0 +1,202 @@
"""Tests for analytics endpoints, export, and squawk detection."""
import json
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
@pytest.fixture(scope='session')
def app():
"""Create application for testing."""
import app as app_module
import utils.database as db_mod
from routes import register_blueprints
app_module.app.config['TESTING'] = True
# Use temp directory for test database
tmp_dir = Path(tempfile.mkdtemp())
db_mod.DB_DIR = tmp_dir
db_mod.DB_PATH = tmp_dir / 'test_intercept.db'
# Reset thread-local connection so it picks up new path
if hasattr(db_mod._local, 'connection') and db_mod._local.connection:
db_mod._local.connection.close()
db_mod._local.connection = None
db_mod.init_db()
if 'pager' not in app_module.app.blueprints:
register_blueprints(app_module.app)
return app_module.app
@pytest.fixture
def client(app):
client = app.test_client()
# Set session login to bypass require_login before_request hook
with client.session_transaction() as sess:
sess['logged_in'] = True
return client
class TestAnalyticsSummary:
"""Tests for /analytics/summary endpoint."""
def test_summary_returns_json(self, client):
response = client.get('/analytics/summary')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert 'counts' in data
assert 'health' in data
assert 'squawks' in data
def test_summary_counts_structure(self, client):
response = client.get('/analytics/summary')
data = json.loads(response.data)
counts = data['counts']
assert 'adsb' in counts
assert 'ais' in counts
assert 'wifi' in counts
assert 'bluetooth' in counts
assert 'dsc' in counts
# All should be integers
for val in counts.values():
assert isinstance(val, int)
def test_summary_health_structure(self, client):
response = client.get('/analytics/summary')
data = json.loads(response.data)
health = data['health']
# Should have process statuses
assert 'pager' in health
assert 'sensor' in health
assert 'adsb' in health
# Each should have a running flag
for mode_info in health.values():
if isinstance(mode_info, dict) and 'running' in mode_info:
assert isinstance(mode_info['running'], bool)
class TestAnalyticsExport:
"""Tests for /analytics/export/<mode> endpoint."""
def test_export_adsb_json(self, client):
response = client.get('/analytics/export/adsb?format=json')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert data['mode'] == 'adsb'
assert 'data' in data
assert isinstance(data['data'], list)
def test_export_adsb_csv(self, client):
response = client.get('/analytics/export/adsb?format=csv')
assert response.status_code == 200
assert response.content_type.startswith('text/csv')
assert 'Content-Disposition' in response.headers
def test_export_invalid_mode(self, client):
response = client.get('/analytics/export/invalid_mode')
assert response.status_code == 400
data = json.loads(response.data)
assert data['status'] == 'error'
def test_export_wifi_json(self, client):
response = client.get('/analytics/export/wifi?format=json')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert data['mode'] == 'wifi'
class TestAnalyticsSquawks:
"""Tests for squawk detection."""
def test_squawks_endpoint(self, client):
response = client.get('/analytics/squawks')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert isinstance(data['squawks'], list)
def test_get_emergency_squawks_detects_7700(self):
from utils.analytics import get_emergency_squawks
# Mock the adsb_aircraft DataStore
mock_store = MagicMock()
mock_store.items.return_value = [
('ABC123', {'squawk': '7700', 'callsign': 'TEST01', 'altitude': 35000}),
('DEF456', {'squawk': '1200', 'callsign': 'TEST02'}),
]
with patch('utils.analytics.app_module') as mock_app:
mock_app.adsb_aircraft = mock_store
squawks = get_emergency_squawks()
assert len(squawks) == 1
assert squawks[0]['squawk'] == '7700'
assert squawks[0]['meaning'] == 'General Emergency'
assert squawks[0]['icao'] == 'ABC123'
class TestGeofenceCRUD:
"""Tests for geofence CRUD endpoints."""
def test_list_geofences(self, client):
response = client.get('/analytics/geofences')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert isinstance(data['zones'], list)
def test_create_geofence(self, client):
response = client.post('/analytics/geofences',
data=json.dumps({
'name': 'Test Zone',
'lat': 51.5074,
'lon': -0.1278,
'radius_m': 500,
}),
content_type='application/json')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert 'zone_id' in data
def test_create_geofence_missing_fields(self, client):
response = client.post('/analytics/geofences',
data=json.dumps({'name': 'No coords'}),
content_type='application/json')
assert response.status_code == 400
def test_create_geofence_invalid_coords(self, client):
response = client.post('/analytics/geofences',
data=json.dumps({
'name': 'Bad',
'lat': 100,
'lon': 0,
'radius_m': 100,
}),
content_type='application/json')
assert response.status_code == 400
def test_delete_geofence_not_found(self, client):
response = client.delete('/analytics/geofences/99999')
assert response.status_code == 404
class TestAnalyticsActivity:
"""Tests for /analytics/activity endpoint."""
def test_activity_returns_sparklines(self, client):
response = client.get('/analytics/activity')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert 'sparklines' in data
assert isinstance(data['sparklines'], dict)
+99
View File
@@ -0,0 +1,99 @@
"""Tests for FlightCorrelator: ACARS/VDL2 message matching."""
import pytest
from utils.flight_correlator import FlightCorrelator
class TestFlightCorrelator:
"""Test ACARS/VDL2 message matching by callsign."""
@pytest.fixture(autouse=True)
def setup(self):
self.correlator = FlightCorrelator(max_messages=100)
def test_add_acars_message(self):
self.correlator.add_acars_message({
'flight': 'BAW123', 'tail': 'G-ABCD', 'text': 'Hello',
})
assert self.correlator.acars_count == 1
def test_add_vdl2_message(self):
self.correlator.add_vdl2_message({
'flight': 'DLH456', 'text': 'World',
})
assert self.correlator.vdl2_count == 1
def test_match_by_callsign(self):
self.correlator.add_acars_message({
'flight': 'BAW123', 'text': 'msg1',
})
self.correlator.add_acars_message({
'flight': 'DLH456', 'text': 'msg2',
})
result = self.correlator.get_messages_for_aircraft(callsign='BAW123')
assert len(result['acars']) == 1
assert result['acars'][0]['text'] == 'msg1'
def test_match_by_icao(self):
self.correlator.add_vdl2_message({
'icao': 'ABC123', 'text': 'vdl2 msg',
})
result = self.correlator.get_messages_for_aircraft(icao='ABC123')
assert len(result['vdl2']) == 1
assert result['vdl2'][0]['text'] == 'vdl2 msg'
def test_no_match_returns_empty(self):
self.correlator.add_acars_message({'flight': 'BAW123', 'text': 'msg'})
result = self.correlator.get_messages_for_aircraft(callsign='NOMATCH')
assert result['acars'] == []
assert result['vdl2'] == []
def test_empty_search_returns_empty(self):
result = self.correlator.get_messages_for_aircraft()
assert result == {'acars': [], 'vdl2': []}
def test_ring_buffer_limit(self):
correlator = FlightCorrelator(max_messages=5)
for i in range(10):
correlator.add_acars_message({'flight': f'FL{i}', 'text': f'msg{i}'})
assert correlator.acars_count == 5
# First 5 messages should have been evicted
result = correlator.get_messages_for_aircraft(callsign='FL0')
assert len(result['acars']) == 0
# Last message should still be there
result = correlator.get_messages_for_aircraft(callsign='FL9')
assert len(result['acars']) == 1
def test_case_insensitive_matching(self):
self.correlator.add_acars_message({'flight': 'baw123', 'text': 'lowercase'})
result = self.correlator.get_messages_for_aircraft(callsign='BAW123')
assert len(result['acars']) == 1
def test_match_by_tail_field(self):
self.correlator.add_acars_message({
'tail': 'G-ABCD', 'text': 'tail match',
})
result = self.correlator.get_messages_for_aircraft(callsign='G-ABCD')
assert len(result['acars']) == 1
def test_internal_fields_not_returned(self):
self.correlator.add_acars_message({'flight': 'TEST', 'text': 'msg'})
result = self.correlator.get_messages_for_aircraft(callsign='TEST')
msg = result['acars'][0]
assert '_corr_time' not in msg
def test_both_acars_and_vdl2_returned(self):
self.correlator.add_acars_message({'flight': 'UAL789', 'text': 'acars'})
self.correlator.add_vdl2_message({'flight': 'UAL789', 'text': 'vdl2'})
result = self.correlator.get_messages_for_aircraft(callsign='UAL789')
assert len(result['acars']) == 1
assert len(result['vdl2']) == 1
+114
View File
@@ -0,0 +1,114 @@
"""Tests for geofence haversine, enter/exit detection, and persistence."""
from unittest.mock import MagicMock, patch
import pytest
class TestHaversineDistance:
"""Test haversine_distance accuracy."""
def test_same_point_zero_distance(self):
from utils.geofence import haversine_distance
assert haversine_distance(51.5, -0.1, 51.5, -0.1) == 0.0
def test_known_distance_london_paris(self):
from utils.geofence import haversine_distance
# London to Paris ~340km
dist = haversine_distance(51.5074, -0.1278, 48.8566, 2.3522)
assert 340_000 < dist < 345_000
def test_short_distance(self):
from utils.geofence import haversine_distance
# Two points ~111m apart (0.001 degrees latitude at equator)
dist = haversine_distance(0.0, 0.0, 0.001, 0.0)
assert 100 < dist < 120
def test_antipodal_distance(self):
from utils.geofence import haversine_distance
# North pole to south pole ~20015km
dist = haversine_distance(90.0, 0.0, -90.0, 0.0)
assert 20_000_000 < dist < 20_050_000
class TestGeofenceManager:
"""Test enter/exit detection logic."""
@pytest.fixture(autouse=True)
def _setup(self):
"""Provide a fresh GeofenceManager with mocked DB."""
from utils.geofence import GeofenceManager
with patch('utils.geofence._ensure_table'), patch('utils.geofence.get_db') as mock_db:
# Mock the context manager
mock_conn = MagicMock()
mock_db.return_value.__enter__ = MagicMock(return_value=mock_conn)
mock_db.return_value.__exit__ = MagicMock(return_value=False)
self.manager = GeofenceManager()
# Override list_zones to return test data
self._zones = []
self.manager.list_zones = lambda: self._zones
def test_no_zones_returns_empty(self):
events = self.manager.check_position('TEST1', 'aircraft', 51.5, -0.1)
assert events == []
def test_enter_event(self):
self._zones = [{
'id': 1, 'name': 'London', 'lat': 51.5074, 'lon': -0.1278,
'radius_m': 10000, 'alert_on': 'enter_exit',
}]
# First position inside zone
events = self.manager.check_position('AC1', 'aircraft', 51.5074, -0.1278)
assert len(events) == 1
assert events[0]['type'] == 'geofence_enter'
assert events[0]['zone_name'] == 'London'
def test_no_duplicate_enter(self):
self._zones = [{
'id': 1, 'name': 'London', 'lat': 51.5074, 'lon': -0.1278,
'radius_m': 10000, 'alert_on': 'enter_exit',
}]
# First enter
self.manager.check_position('AC1', 'aircraft', 51.5074, -0.1278)
# Second check still inside - should not fire enter again
events = self.manager.check_position('AC1', 'aircraft', 51.508, -0.128)
assert len(events) == 0
def test_exit_event(self):
self._zones = [{
'id': 1, 'name': 'London', 'lat': 51.5074, 'lon': -0.1278,
'radius_m': 1000, 'alert_on': 'enter_exit',
}]
# Enter
self.manager.check_position('AC1', 'aircraft', 51.5074, -0.1278)
# Exit (far away)
events = self.manager.check_position('AC1', 'aircraft', 52.0, 0.0)
assert len(events) == 1
assert events[0]['type'] == 'geofence_exit'
def test_enter_only_mode(self):
self._zones = [{
'id': 1, 'name': 'London', 'lat': 51.5074, 'lon': -0.1278,
'radius_m': 1000, 'alert_on': 'enter',
}]
# Enter
events = self.manager.check_position('AC1', 'aircraft', 51.5074, -0.1278)
assert len(events) == 1
assert events[0]['type'] == 'geofence_enter'
# Exit should not fire
events = self.manager.check_position('AC1', 'aircraft', 52.0, 0.0)
assert len(events) == 0
def test_metadata_included_in_event(self):
self._zones = [{
'id': 1, 'name': 'Zone', 'lat': 0.0, 'lon': 0.0,
'radius_m': 100000, 'alert_on': 'enter_exit',
}]
events = self.manager.check_position(
'AC1', 'aircraft', 0.0, 0.0,
metadata={'callsign': 'TEST01', 'altitude': 35000}
)
assert events[0]['callsign'] == 'TEST01'
assert events[0]['altitude'] == 35000
+153 -4
View File
@@ -354,15 +354,15 @@ class TestVISDetector:
assert mode_name == 'Scottie1' assert mode_name == 'Scottie1'
def test_detect_pd120(self): def test_detect_pd120(self):
"""Should detect PD120 VIS code (93).""" """Should detect PD120 VIS code (95)."""
detector = VISDetector() detector = VISDetector()
header = generate_vis_header(93) # PD120 header = generate_vis_header(95) # PD120
audio = np.concatenate([np.zeros(2400), header, np.zeros(2400)]) audio = np.concatenate([np.zeros(2400), header, np.zeros(2400)])
result = detector.feed(audio) result = detector.feed(audio)
assert result is not None assert result is not None
vis_code, mode_name = result vis_code, mode_name = result
assert vis_code == 93 assert vis_code == 95
assert mode_name == 'PD120' assert mode_name == 'PD120'
def test_noise_rejection(self): def test_noise_rejection(self):
@@ -395,6 +395,121 @@ class TestVISDetector:
assert vis_code == 8 assert vis_code == 8
assert mode_name == 'Robot36' assert mode_name == 'Robot36'
def test_noisy_leader_detection(self):
"""Should detect VIS despite intermittent None windows in leader.
Simulates HF fading by inserting short silence gaps (which produce
ambiguous tone classification) into the leader tone.
"""
detector = VISDetector()
parts = []
# Build leader1 with gaps: 50ms tone, 10ms silence, repeated
# Total ~300ms of leader with interruptions
for _ in range(6):
parts.append(generate_tone(FREQ_LEADER, 0.050))
parts.append(np.zeros(int(SAMPLE_RATE * 0.010))) # 10ms gap
# Break (1200 Hz, 10ms)
parts.append(generate_tone(FREQ_SYNC, 0.010))
# Leader 2 (clean)
parts.append(generate_tone(FREQ_LEADER, 0.300))
# Start bit + data bits + parity + stop (standard for Robot36 = VIS 8)
parts.append(generate_tone(FREQ_SYNC, 0.030)) # start bit
vis_code = 8
ones_count = 0
for i in range(8):
bit = (vis_code >> i) & 1
if bit:
ones_count += 1
parts.append(generate_tone(FREQ_VIS_BIT_1, 0.030))
else:
parts.append(generate_tone(FREQ_VIS_BIT_0, 0.030))
parity = ones_count % 2
parts.append(generate_tone(
FREQ_VIS_BIT_1 if parity else FREQ_VIS_BIT_0, 0.030))
parts.append(generate_tone(FREQ_SYNC, 0.030)) # stop bit
audio = np.concatenate([np.zeros(2400)] + parts + [np.zeros(2400)])
result = detector.feed(audio)
assert result is not None
assert result[0] == 8
assert result[1] == 'Robot36'
def test_vis_error_correction_parity_bit(self):
"""Should recover when only the parity bit is corrupted."""
detector = VISDetector()
# Generate Martin1 header (VIS 44) but flip the parity bit
parts = []
parts.append(generate_tone(FREQ_LEADER, 0.300))
parts.append(generate_tone(FREQ_SYNC, 0.010))
parts.append(generate_tone(FREQ_LEADER, 0.300))
parts.append(generate_tone(FREQ_SYNC, 0.030)) # start bit
vis_code = 44 # Martin1
ones_count = 0
for i in range(8):
bit = (vis_code >> i) & 1
if bit:
ones_count += 1
parts.append(generate_tone(FREQ_VIS_BIT_1, 0.030))
else:
parts.append(generate_tone(FREQ_VIS_BIT_0, 0.030))
# Wrong parity (flip it)
correct_parity = ones_count % 2
wrong_parity = 1 - correct_parity
parts.append(generate_tone(
FREQ_VIS_BIT_1 if wrong_parity else FREQ_VIS_BIT_0, 0.030))
parts.append(generate_tone(FREQ_SYNC, 0.030)) # stop bit
audio = np.concatenate([np.zeros(2400)] + parts + [np.zeros(2400)])
result = detector.feed(audio)
assert result is not None
assert result[0] == 44
assert result[1] == 'Martin1'
def test_vis_error_correction_data_bit(self):
"""Should recover Martin1 when one data bit is flipped by HF noise.
Simulates: Martin1 (VIS 44) transmitted correctly, but bit 0 is
corrupted during reception. The parity bit is received correctly
(computed for the original code 44), so parity check fails error
correction tries flipping each data bit and finds VIS 44.
"""
detector = VISDetector()
original_code = 44 # Martin1
corrupted_code = 44 ^ 1 # flip bit 0 → 45
parts = []
parts.append(generate_tone(FREQ_LEADER, 0.300))
parts.append(generate_tone(FREQ_SYNC, 0.010))
parts.append(generate_tone(FREQ_LEADER, 0.300))
parts.append(generate_tone(FREQ_SYNC, 0.030)) # start bit
# Transmit corrupted data bits
for i in range(8):
bit = (corrupted_code >> i) & 1
if bit:
parts.append(generate_tone(FREQ_VIS_BIT_1, 0.030))
else:
parts.append(generate_tone(FREQ_VIS_BIT_0, 0.030))
# Parity bit computed for the ORIGINAL code (received correctly)
original_ones = bin(original_code).count('1')
parity = original_ones % 2
parts.append(generate_tone(
FREQ_VIS_BIT_1 if parity else FREQ_VIS_BIT_0, 0.030))
parts.append(generate_tone(FREQ_SYNC, 0.030)) # stop bit
audio = np.concatenate([np.zeros(2400)] + parts + [np.zeros(2400)])
result = detector.feed(audio)
assert result is not None
assert result[0] == 44
assert result[1] == 'Martin1'
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Mode spec tests # Mode spec tests
@@ -405,7 +520,7 @@ class TestModes:
def test_all_vis_codes_have_modes(self): def test_all_vis_codes_have_modes(self):
"""All defined VIS codes should have matching mode specs.""" """All defined VIS codes should have matching mode specs."""
for vis_code in [8, 12, 44, 40, 60, 56, 93, 95]: for vis_code in [8, 12, 44, 40, 60, 56, 95, 97, 99, 98, 96, 76]:
mode = get_mode(vis_code) mode = get_mode(vis_code)
assert mode is not None, f"No mode for VIS code {vis_code}" assert mode is not None, f"No mode for VIS code {vis_code}"
@@ -570,6 +685,40 @@ class TestImageDecoder:
assert img is not None assert img is not None
assert img.size == (320, 240) assert img.size == (320, 240)
def test_slant_correction_wraps_rows_without_blank_wedge(self):
"""Slant correction should rotate rows, not introduce black fill."""
PIL = pytest.importorskip('PIL')
from utils.sstv.image_decoder import SSTVImageDecoder
decoder = SSTVImageDecoder(SCOTTIE_1)
decoder._sync_deviations = [float(i * 4) for i in range(SCOTTIE_1.height)]
source = np.full((SCOTTIE_1.height, SCOTTIE_1.width, 3), 128, dtype=np.uint8)
img = PIL.Image.fromarray(source, 'RGB')
corrected = decoder._apply_slant_correction(img)
corrected_arr = np.array(corrected)
# If correction clips/fills, zeros appear. Circular shift should preserve all values.
assert corrected_arr.min() == 128
assert corrected_arr.max() == 128
def test_slant_correction_skips_implausible_drift(self):
"""Very large estimated drift should be treated as a bad fit and ignored."""
PIL = pytest.importorskip('PIL')
from utils.sstv.image_decoder import SSTVImageDecoder
decoder = SSTVImageDecoder(SCOTTIE_1)
decoder._sync_deviations = [float(i * 40) for i in range(SCOTTIE_1.height)]
source = np.full((SCOTTIE_1.height, SCOTTIE_1.width, 3), 177, dtype=np.uint8)
img = PIL.Image.fromarray(source, 'RGB')
corrected = decoder._apply_slant_correction(img)
# Implausible slope should return original image unchanged.
assert np.array_equal(np.array(corrected), source)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# SSTVDecoder orchestrator tests # SSTVDecoder orchestrator tests
+129
View File
@@ -0,0 +1,129 @@
"""Tests for ISS SSTV route behavior."""
from __future__ import annotations
import json
from unittest.mock import MagicMock, patch
import pytest
from utils.sstv import ISS_SSTV_FREQ
def _login_session(client) -> None:
"""Mark the Flask test session as authenticated."""
with client.session_transaction() as sess:
sess['logged_in'] = True
sess['username'] = 'test'
sess['role'] = 'admin'
class TestSSTVRoutes:
"""ISS SSTV route tests."""
def test_status_reports_fm_modulation(self, client):
"""GET /sstv/status should report the fixed ISS modulation."""
_login_session(client)
mock_decoder = MagicMock()
mock_decoder.decoder_available = 'python-sstv'
mock_decoder.is_running = False
mock_decoder.get_images.return_value = []
mock_decoder.doppler_enabled = False
mock_decoder.last_doppler_info = None
with patch('routes.sstv.is_sstv_available', return_value=True), \
patch('routes.sstv.get_sstv_decoder', return_value=mock_decoder):
response = client.get('/sstv/status')
assert response.status_code == 200
data = response.get_json()
assert data['available'] is True
assert data['modulation'] == 'fm'
assert data['iss_frequency'] == ISS_SSTV_FREQ
def test_start_uses_fm_and_normalizes_supported_iss_frequency(self, client):
"""POST /sstv/start should enforce FM and snap near ISS values."""
_login_session(client)
mock_decoder = MagicMock()
mock_decoder.is_running = False
mock_decoder.start.return_value = True
mock_decoder.doppler_enabled = False
mock_decoder.last_doppler_info = None
payload = {
'frequency': ISS_SSTV_FREQ + 0.02, # Within tolerance; should normalize.
'modulation': 'FM',
'device': 0,
}
with patch('routes.sstv.is_sstv_available', return_value=True), \
patch('routes.sstv.get_sstv_decoder', return_value=mock_decoder), \
patch('routes.sstv.app_module.claim_sdr_device', return_value=None):
response = client.post(
'/sstv/start',
data=json.dumps(payload),
content_type='application/json',
)
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'started'
assert data['modulation'] == 'fm'
assert data['frequency'] == pytest.approx(ISS_SSTV_FREQ)
mock_decoder.start.assert_called_once()
call_kwargs = mock_decoder.start.call_args.kwargs
assert call_kwargs['modulation'] == 'fm'
assert call_kwargs['frequency'] == pytest.approx(ISS_SSTV_FREQ)
def test_start_rejects_non_fm_modulation(self, client):
"""POST /sstv/start should reject non-FM modulation requests."""
_login_session(client)
mock_decoder = MagicMock()
mock_decoder.is_running = False
payload = {
'frequency': ISS_SSTV_FREQ,
'modulation': 'usb',
'device': 0,
}
with patch('routes.sstv.is_sstv_available', return_value=True), \
patch('routes.sstv.get_sstv_decoder', return_value=mock_decoder):
response = client.post(
'/sstv/start',
data=json.dumps(payload),
content_type='application/json',
)
assert response.status_code == 400
data = response.get_json()
assert data['status'] == 'error'
assert 'Modulation must be fm' in data['message']
mock_decoder.start.assert_not_called()
def test_start_rejects_non_iss_frequency(self, client):
"""POST /sstv/start should reject unsupported non-ISS frequencies."""
_login_session(client)
mock_decoder = MagicMock()
mock_decoder.is_running = False
payload = {
'frequency': 14.230,
'modulation': 'fm',
'device': 0,
}
with patch('routes.sstv.is_sstv_available', return_value=True), \
patch('routes.sstv.get_sstv_decoder', return_value=mock_decoder):
response = client.post(
'/sstv/start',
data=json.dumps(payload),
content_type='application/json',
)
assert response.status_code == 400
data = response.get_json()
assert data['status'] == 'error'
assert 'Supported ISS SSTV frequency' in data['message']
mock_decoder.start.assert_not_called()
+19 -2
View File
@@ -7,10 +7,10 @@ and ground track generation.
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from unittest.mock import patch, MagicMock from unittest.mock import MagicMock, patch
import pytest import pytest
from utils.weather_sat_predict import predict_passes from utils.weather_sat_predict import _format_utc_iso, predict_passes
class TestPredictPasses: class TestPredictPasses:
@@ -673,3 +673,20 @@ class TestPassDataStructure:
with patch.dict('sys.modules', {'skyfield': None, 'skyfield.api': None}): with patch.dict('sys.modules', {'skyfield': None, 'skyfield.api': None}):
with pytest.raises((ImportError, AttributeError)): with pytest.raises((ImportError, AttributeError)):
predict_passes(lat=51.5, lon=-0.1) predict_passes(lat=51.5, lon=-0.1)
class TestTimestampFormatting:
"""Tests for UTC timestamp serialization helpers."""
def test_format_utc_iso_from_aware_datetime(self):
"""Aware UTC datetimes should not get a duplicate UTC suffix."""
dt = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
value = _format_utc_iso(dt)
assert value == '2024-01-01T12:00:00Z'
assert '+00:00Z' not in value
def test_format_utc_iso_from_naive_datetime(self):
"""Naive datetimes should be treated as UTC and serialized consistently."""
dt = datetime(2024, 1, 1, 12, 0, 0)
value = _format_utc_iso(dt)
assert value == '2024-01-01T12:00:00Z'
+59 -5
View File
@@ -16,6 +16,7 @@ from utils.weather_sat_scheduler import (
WeatherSatScheduler, WeatherSatScheduler,
ScheduledPass, ScheduledPass,
get_weather_sat_scheduler, get_weather_sat_scheduler,
_parse_utc_iso,
) )
@@ -327,7 +328,7 @@ class TestWeatherSatScheduler:
assert len(passes) == 1 assert len(passes) == 1
assert passes[0]['id'] == 'NOAA-18_202401011200' assert passes[0]['id'] == 'NOAA-18_202401011200'
@patch('utils.weather_sat_scheduler.predict_passes') @patch('utils.weather_sat_predict.predict_passes')
@patch('threading.Timer') @patch('threading.Timer')
def test_refresh_passes(self, mock_timer, mock_predict): def test_refresh_passes(self, mock_timer, mock_predict):
"""_refresh_passes() should schedule future passes.""" """_refresh_passes() should schedule future passes."""
@@ -361,7 +362,7 @@ class TestWeatherSatScheduler:
assert scheduler._passes[0].satellite == 'NOAA-18' assert scheduler._passes[0].satellite == 'NOAA-18'
mock_timer_instance.start.assert_called() mock_timer_instance.start.assert_called()
@patch('utils.weather_sat_scheduler.predict_passes') @patch('utils.weather_sat_predict.predict_passes')
def test_refresh_passes_skip_past(self, mock_predict): def test_refresh_passes_skip_past(self, mock_predict):
"""_refresh_passes() should skip passes that already started.""" """_refresh_passes() should skip passes that already started."""
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
@@ -389,7 +390,42 @@ class TestWeatherSatScheduler:
# Should not schedule past passes # Should not schedule past passes
assert len(scheduler._passes) == 0 assert len(scheduler._passes) == 0
@patch('utils.weather_sat_scheduler.predict_passes') @patch('utils.weather_sat_predict.predict_passes')
@patch('threading.Timer')
def test_refresh_passes_active_window_triggers_immediately(self, mock_timer, mock_predict):
"""_refresh_passes() should trigger immediately during an active pass window."""
now = datetime.now(timezone.utc)
active_pass = {
'id': 'NOAA-18_ACTIVE',
'satellite': 'NOAA-18',
'name': 'NOAA 18',
'frequency': 137.9125,
'mode': 'APT',
'startTimeISO': (now - timedelta(minutes=2)).isoformat(),
'endTimeISO': (now + timedelta(minutes=8)).isoformat(),
'maxEl': 45.0,
'duration': 10.0,
'quality': 'good',
}
mock_predict.return_value = [active_pass]
pass_timer = MagicMock()
refresh_timer = MagicMock()
mock_timer.side_effect = [pass_timer, refresh_timer]
scheduler = WeatherSatScheduler()
scheduler._enabled = True
scheduler._lat = 51.5
scheduler._lon = -0.1
scheduler._refresh_passes()
assert len(scheduler._passes) == 1
first_delay = mock_timer.call_args_list[0][0][0]
assert first_delay == pytest.approx(0.0, abs=0.01)
pass_timer.start.assert_called_once()
@patch('utils.weather_sat_predict.predict_passes')
def test_refresh_passes_disabled(self, mock_predict): def test_refresh_passes_disabled(self, mock_predict):
"""_refresh_passes() should do nothing when disabled.""" """_refresh_passes() should do nothing when disabled."""
scheduler = WeatherSatScheduler() scheduler = WeatherSatScheduler()
@@ -399,7 +435,7 @@ class TestWeatherSatScheduler:
mock_predict.assert_not_called() mock_predict.assert_not_called()
@patch('utils.weather_sat_scheduler.predict_passes') @patch('utils.weather_sat_predict.predict_passes')
def test_refresh_passes_error_handling(self, mock_predict): def test_refresh_passes_error_handling(self, mock_predict):
"""_refresh_passes() should handle prediction errors.""" """_refresh_passes() should handle prediction errors."""
mock_predict.side_effect = Exception('TLE error') mock_predict.side_effect = Exception('TLE error')
@@ -716,10 +752,28 @@ class TestSchedulerConfiguration:
assert WEATHER_SAT_CAPTURE_BUFFER_SECONDS >= 0 assert WEATHER_SAT_CAPTURE_BUFFER_SECONDS >= 0
class TestUtcIsoParsing:
"""Tests for UTC ISO timestamp parsing."""
def test_parse_utc_iso_with_z_suffix(self):
"""_parse_utc_iso should handle Z timestamps."""
dt = _parse_utc_iso('2026-02-19T12:34:56Z')
assert dt.tzinfo == timezone.utc
assert dt.hour == 12
assert dt.minute == 34
assert dt.second == 56
def test_parse_utc_iso_with_legacy_suffix(self):
"""_parse_utc_iso should handle legacy +00:00Z timestamps."""
dt = _parse_utc_iso('2026-02-19T12:34:56+00:00Z')
assert dt.tzinfo == timezone.utc
assert dt.hour == 12
class TestSchedulerIntegration: class TestSchedulerIntegration:
"""Integration tests for scheduler.""" """Integration tests for scheduler."""
@patch('utils.weather_sat_scheduler.predict_passes') @patch('utils.weather_sat_predict.predict_passes')
@patch('utils.weather_sat_scheduler.get_weather_sat_decoder') @patch('utils.weather_sat_scheduler.get_weather_sat_decoder')
@patch('threading.Timer') @patch('threading.Timer')
def test_full_scheduling_cycle(self, mock_timer, mock_get_decoder, mock_predict): def test_full_scheduling_cycle(self, mock_timer, mock_get_decoder, mock_predict):
+231
View File
@@ -0,0 +1,231 @@
"""Cross-mode analytics: activity tracking, summaries, and emergency squawk detection."""
from __future__ import annotations
import contextlib
import time
from collections import deque
from typing import Any
import app as app_module
class ModeActivityTracker:
"""Track device counts per mode in time-bucketed ring buffer for sparklines."""
def __init__(self, max_buckets: int = 60, bucket_interval: float = 5.0):
self._max_buckets = max_buckets
self._bucket_interval = bucket_interval
self._history: dict[str, deque] = {}
self._last_record_time = 0.0
def record(self) -> None:
"""Snapshot current counts for all modes."""
now = time.time()
if now - self._last_record_time < self._bucket_interval:
return
self._last_record_time = now
counts = _get_mode_counts()
for mode, count in counts.items():
if mode not in self._history:
self._history[mode] = deque(maxlen=self._max_buckets)
self._history[mode].append(count)
def get_sparkline(self, mode: str) -> list[int]:
"""Return sparkline array for a mode."""
self.record()
return list(self._history.get(mode, []))
def get_all_sparklines(self) -> dict[str, list[int]]:
"""Return sparkline arrays for all tracked modes."""
self.record()
return {mode: list(values) for mode, values in self._history.items()}
# Singleton
_tracker: ModeActivityTracker | None = None
def get_activity_tracker() -> ModeActivityTracker:
global _tracker
if _tracker is None:
_tracker = ModeActivityTracker()
return _tracker
def _safe_len(attr_name: str) -> int:
"""Safely get len() of an app_module attribute."""
try:
return len(getattr(app_module, attr_name))
except Exception:
return 0
def _safe_route_attr(module_path: str, attr_name: str, default: int = 0) -> int:
"""Safely read a module-level counter from a route file."""
try:
import importlib
mod = importlib.import_module(module_path)
return int(getattr(mod, attr_name, default))
except Exception:
return default
def _get_mode_counts() -> dict[str, int]:
"""Read current entity counts from all available data sources."""
counts: dict[str, int] = {}
# ADS-B aircraft (DataStore)
counts['adsb'] = _safe_len('adsb_aircraft')
# AIS vessels (DataStore)
counts['ais'] = _safe_len('ais_vessels')
# WiFi: prefer v2 scanner, fall back to legacy DataStore
wifi_count = 0
try:
from utils.wifi.scanner import _scanner_instance as wifi_scanner
if wifi_scanner is not None:
wifi_count = len(wifi_scanner.access_points)
except Exception:
pass
if wifi_count == 0:
wifi_count = _safe_len('wifi_networks')
counts['wifi'] = wifi_count
# Bluetooth: prefer v2 scanner, fall back to legacy DataStore
bt_count = 0
try:
from utils.bluetooth.scanner import _scanner_instance as bt_scanner
if bt_scanner is not None:
bt_count = len(bt_scanner.get_devices())
except Exception:
pass
if bt_count == 0:
bt_count = _safe_len('bt_devices')
counts['bluetooth'] = bt_count
# DSC messages (DataStore)
counts['dsc'] = _safe_len('dsc_messages')
# ACARS message count (route-level counter)
counts['acars'] = _safe_route_attr('routes.acars', 'acars_message_count')
# VDL2 message count (route-level counter)
counts['vdl2'] = _safe_route_attr('routes.vdl2', 'vdl2_message_count')
# APRS stations (route-level dict)
try:
import routes.aprs as aprs_mod
counts['aprs'] = len(getattr(aprs_mod, 'aprs_stations', {}))
except Exception:
counts['aprs'] = 0
# Meshtastic recent messages (route-level list)
try:
import routes.meshtastic as mesh_route
counts['meshtastic'] = len(getattr(mesh_route, '_recent_messages', []))
except Exception:
counts['meshtastic'] = 0
return counts
def get_cross_mode_summary() -> dict[str, Any]:
"""Return counts dict for all available data sources."""
counts = _get_mode_counts()
wifi_clients_count = 0
try:
from utils.wifi.scanner import _scanner_instance as wifi_scanner
if wifi_scanner is not None:
wifi_clients_count = len(wifi_scanner.clients)
except Exception:
pass
if wifi_clients_count == 0:
wifi_clients_count = _safe_len('wifi_clients')
counts['wifi_clients'] = wifi_clients_count
return counts
def get_mode_health() -> dict[str, dict]:
"""Check process refs and SDR status for each mode."""
health: dict[str, dict] = {}
process_map = {
'pager': 'current_process',
'sensor': 'sensor_process',
'adsb': 'adsb_process',
'ais': 'ais_process',
'acars': 'acars_process',
'vdl2': 'vdl2_process',
'aprs': 'aprs_process',
'wifi': 'wifi_process',
'bluetooth': 'bt_process',
'dsc': 'dsc_process',
'rtlamr': 'rtlamr_process',
'dmr': 'dmr_process',
}
for mode, attr in process_map.items():
proc = getattr(app_module, attr, None)
running = proc is not None and (proc.poll() is None if proc else False)
health[mode] = {'running': running}
# Override WiFi/BT health with v2 scanner status if available
try:
from utils.wifi.scanner import _scanner_instance as wifi_scanner
if wifi_scanner is not None and wifi_scanner.is_scanning:
health['wifi'] = {'running': True}
except Exception:
pass
try:
from utils.bluetooth.scanner import _scanner_instance as bt_scanner
if bt_scanner is not None and bt_scanner.is_scanning:
health['bluetooth'] = {'running': True}
except Exception:
pass
# Meshtastic: check client connection status
try:
from utils.meshtastic import get_meshtastic_client
client = get_meshtastic_client()
health['meshtastic'] = {'running': client._interface is not None}
except Exception:
health['meshtastic'] = {'running': False}
try:
sdr_status = app_module.get_sdr_device_status()
health['sdr_devices'] = {str(k): v for k, v in sdr_status.items()}
except Exception:
health['sdr_devices'] = {}
return health
EMERGENCY_SQUAWKS = {
'7700': 'General Emergency',
'7600': 'Comms Failure',
'7500': 'Hijack',
}
def get_emergency_squawks() -> list[dict]:
"""Iterate adsb_aircraft DataStore for emergency squawk codes."""
emergencies: list[dict] = []
try:
for icao, aircraft in app_module.adsb_aircraft.items():
sq = str(aircraft.get('squawk', '')).strip()
if sq in EMERGENCY_SQUAWKS:
emergencies.append({
'icao': icao,
'callsign': aircraft.get('callsign', ''),
'squawk': sq,
'meaning': EMERGENCY_SQUAWKS[sq],
'altitude': aircraft.get('altitude'),
'lat': aircraft.get('lat'),
'lon': aircraft.get('lon'),
})
except Exception:
pass
return emergencies
+27 -2
View File
@@ -88,6 +88,8 @@ class LocateTarget:
name_pattern: str | None = None name_pattern: str | None = None
irk_hex: str | None = None irk_hex: str | None = None
device_id: str | None = None device_id: str | None = None
device_key: str | None = None
fingerprint_id: str | None = None
# Hand-off metadata from Bluetooth mode # Hand-off metadata from Bluetooth mode
known_name: str | None = None known_name: str | None = None
known_manufacturer: str | None = None known_manufacturer: str | None = None
@@ -95,6 +97,10 @@ class LocateTarget:
def matches(self, device: BTDeviceAggregate) -> bool: def matches(self, device: BTDeviceAggregate) -> bool:
"""Check if a device matches this target.""" """Check if a device matches this target."""
# Match by stable device key (survives MAC randomization for many devices)
if self.device_key and getattr(device, 'device_key', None) == self.device_key:
return True
# Match by device_id (exact) # Match by device_id (exact)
if self.device_id and device.device_id == self.device_id: if self.device_id and device.device_id == self.device_id:
return True return True
@@ -113,6 +119,13 @@ class LocateTarget:
if dev_addr == target_addr: if dev_addr == target_addr:
return True return True
# Match by payload fingerprint (guard against low-stability generic fingerprints)
if self.fingerprint_id:
dev_fp = getattr(device, 'payload_fingerprint_id', None)
dev_fp_stability = getattr(device, 'payload_fingerprint_stability', 0.0) or 0.0
if dev_fp and dev_fp == self.fingerprint_id and dev_fp_stability >= 0.35:
return True
# Match by RPA resolution # Match by RPA resolution
if self.irk_hex: if self.irk_hex:
try: try:
@@ -126,8 +139,18 @@ class LocateTarget:
if self.name_pattern and device.name and self.name_pattern.lower() in device.name.lower(): if self.name_pattern and device.name and self.name_pattern.lower() in device.name.lower():
return True return True
# Match by known_name from handoff (exact name match) # Match by known_name from handoff (exact or loose normalized match)
return bool(self.known_name and device.name and self.known_name.lower() == device.name.lower()) if self.known_name and device.name:
target_name = self.known_name.strip().lower()
device_name = device.name.strip().lower()
if target_name and (
target_name == device_name
or target_name in device_name
or device_name in target_name
):
return True
return False
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {
@@ -135,6 +158,8 @@ class LocateTarget:
'name_pattern': self.name_pattern, 'name_pattern': self.name_pattern,
'irk_hex': self.irk_hex, 'irk_hex': self.irk_hex,
'device_id': self.device_id, 'device_id': self.device_id,
'device_key': self.device_key,
'fingerprint_id': self.fingerprint_id,
'known_name': self.known_name, 'known_name': self.known_name,
'known_manufacturer': self.known_manufacturer, 'known_manufacturer': self.known_manufacturer,
'last_known_rssi': self.last_known_rssi, 'last_known_rssi': self.last_known_rssi,

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