Compare commits

..

130 Commits

Author SHA1 Message Date
Smittix 026337a350 Add real-time WebSocket waterfall with I/Q capture and server-side FFT
Replace the batch rtl_power SSE pipeline with continuous I/Q streaming
via WebSocket for smooth ~25fps waterfall display. The server captures
raw I/Q samples (rtl_sdr/rx_sdr), computes Hann-windowed FFT, and
sends compact binary frames (1035 bytes vs ~15KB JSON, 93% reduction).
Client falls back to existing SSE path if WebSocket is unavailable.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 10:33:08 +00:00
Ben Mason 28e19b8898 Add Soapy Airspy package and airspy pages to Dockerfile 2026-02-06 17:00:52 -05:00
Smittix ef7d8cca9f Replace broken slowrx dependency with pure Python SSTV decoder
slowrx is a GTK GUI app that doesn't support CLI usage, so the SSTV
decoder was silently failing. This replaces it with a pure Python
implementation using numpy and Pillow that supports Robot36/72,
Martin1/2, Scottie1/2, and PD120/180 modes via VIS header auto-detection.

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 15:36:41 +00:00
Smittix 8fca54e523 Fix APRS rtl_fm startup failure and SDR device conflicts (#122)
Add SDR device reservation to prevent conflicts with other modes, and
capture rtl_fm stderr so actual error messages are reported to the user
instead of a generic exit code.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 13:50:09 +00:00
Smittix b4742f205a Update listening post handling 2026-02-06 09:50:49 +00:00
Smittix 16f730db76 Merge pull request #97 from JonanOribe/fix-libs
Add optionals group to pyproject.toml and sync tests
2026-02-05 21:52:38 +00:00
Smittix 958d8d5f20 Add missing scapy to optionals group and fix missing newline at EOF
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 21:52:30 +00:00
Smittix 88f71c9b5e Fix updater settings panel error when updater.js is blocked
Add defensive typeof checks before referencing the Updater global in
loadUpdateStatus() and checkForUpdatesManual() so the settings panel
shows a helpful message instead of crashing. Also swap script load
order so updater.js loads before settings-manager.js.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes #119

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

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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 22:28:32 +00:00
Jon Ander Oribe cc5ccf75a2 Add optionals group to pyproject.toml and sync tests
Introduces an 'optionals' dependency group in pyproject.toml. There was a discrepancy because they had been added to requirements.txt at some point during the last few commits but not to .toml. Update on test_requirements.py to include and validate these optional dependencies. Enhances test logic to ensure all main, dev, and optional dependencies are checked for environment consistency.
2026-02-01 17:03:00 +01:00
153 changed files with 52742 additions and 28225 deletions
+1
View File
@@ -35,6 +35,7 @@ htmlcov/
# Local Postgres data
pgdata/
pgdata.bak/
# Captured files (don't include in image)
*.cap
+2
View File
@@ -0,0 +1,2 @@
# Uncomment and set to use external storage for ADS-B history
# PGDATA_PATH=/mnt/external/intercept/pgdata
+5
View File
@@ -54,3 +54,8 @@ intercept_agent_*.cfg
# Temporary files
/tmp/
*.tmp
# Env files
.env
.env.*
!.env.example
+100
View File
@@ -2,6 +2,106 @@
All notable changes to iNTERCEPT will be documented in this file.
## [2.14.0] - 2026-02-06
### Added
- **DMR Digital Voice Decoder** - Decode DMR, P25, NXDN, and D-STAR protocols
- Integration with dsd-fme (Digital Speech Decoder - Florida Man Edition)
- Real-time SSE streaming of sync, call, voice, and slot events
- Call history table with talkgroup, source ID, and protocol tracking
- Protocol auto-detection or manual selection
- Pipeline error diagnostics with rtl_fm stderr capture
- **DMR Visual Synthesizer** - Canvas-based signal activity visualization
- Spring-physics animated bars reacting to SSE decoder events
- Color-coded by event type: cyan (sync), green (call), orange (voice)
- Center-outward ripple bursts on sync events
- Smooth decay and idle breathing animation
- Responsive canvas with window resize handling
- **HF SSTV General Mode** - Terrestrial slow-scan TV on shortwave frequencies
- Predefined HF SSTV frequencies (14.230, 21.340, 28.680 MHz, etc.)
- Modulation support for USB/LSB reception
- **WebSDR Integration** - Remote HF/shortwave listening via WebSDR servers
- **Listening Post Enhancements** - Improved signal scanner and audio handling
### Fixed
- APRS rtl_fm startup failure and SDR device conflicts
- DSD voice decoder detection for dsd-fme and PulseAudio errors
- dsd-fme protocol flags and ncurses disable for headless operation
- dsd-fme audio output flag for pipeline compatibility
- TSCM sweep scan resilience with per-device error isolation
- TSCM WiFi detection using scanner singleton for device availability
- TSCM correlation and cluster emission fixes
- Detected Threats panel items now clickable to show device details
- Proximity radar tooltip flicker on hover
- Radar blip flicker by deferring renders during hover
- ISS position API priority swap to avoid timeout delays
- Updater settings panel error when updater.js is blocked
- Missing scapy in optionals dependency group
---
## [2.13.1] - 2026-02-04
### Added
- **UI Overhaul** - Revamped styling with slate/cyan theme
- Switched app font to JetBrains Mono
- Global navigation bar across all dashboards
- Cyan-tinted map tiles as default
- **Signal Scanner Rewrite** - Switched to rtl_power sweep for better coverage
- SNR column added to signal hits table
- SNR threshold control for power scan
- Improved sweep progress tracking and stability
- Frequency-based sweep display with range syncing
- **Listening Post Audio** - WAV streaming with retry and fallback
- WebSocket audio fallback for listening
- User-initiated audio play prompt
- Audio pipeline restart for fresh stream headers
### Fixed
- WiFi connected clients panel now filters to selected AP instead of showing all clients
- USB device contention when starting audio pipeline
- Dual scrollbar issue on main dashboard
- Controls bar alignment in dashboard pages
- Mode query routing from dashboard nav
---
## [2.13.0] - 2026-02-04
### Added
- **WiFi Client Display** - Connected clients shown in AP detail drawer
- Real-time client updates via SSE streaming
- Probed SSID badges for connected clients
- Signal strength indicators and vendor identification
- **Help Modal** - Keyboard shortcuts reference system
- **Main Dashboard Button** - Quick navigation from any page
- **Settings Modal** - Accessible from all dashboards
### Changed
- Dashboard CSS improvements and consistency fixes
---
## [2.12.1] - 2026-02-02
### Added
- **SDR Device Registry** - Prevents decoder conflicts between concurrent modes
- **SDR Device Status Panel** - Shows connected SDR devices with ADS-B Bias-T toggle
- **Real-time Doppler Tracking** - ISS SSTV reception with Doppler correction
- **TCP Connection Support** - Meshtastic devices connectable over TCP
- **Shared Observer Location** - Configurable shared location with auto-start options
- **slowrx Source Build** - Fallback build for Debian/Ubuntu
### Fixed
- SDR device type not synced on page refresh
- Meshtastic connection type not restored on page refresh
- WiFi deep scan polling on agent with normalized scan_type value
- Auto-detect RTL-SDR drivers and blacklist instead of prompting
- TPMS pressure field mappings for 433MHz sensor display
- Agent capabilities cache invalidation after monitor mode toggle
---
## [2.12.0] - 2026-01-29
### Added
+31
View File
@@ -41,6 +41,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
soapysdr-module-rtlsdr \
soapysdr-module-hackrf \
soapysdr-module-lms7 \
soapysdr-module-airspy \
airspy \
limesuite \
hackrf \
# Utilities
@@ -63,6 +65,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libcurl4-openssl-dev \
zlib1g-dev \
libzmq3-dev \
libpulse-dev \
libfftw3-dev \
liblapack-dev \
libcodec2-dev \
# Build dump1090
&& cd /tmp \
&& git clone --depth 1 https://github.com/flightaware/dump1090.git \
@@ -109,6 +115,27 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& make \
&& cp acarsdec /usr/bin/acarsdec \
&& rm -rf /tmp/acarsdec \
# Build mbelib (required by DSD)
&& cd /tmp \
&& git clone https://github.com/lwvmobile/mbelib.git \
&& cd mbelib \
&& (git checkout ambe_tones || true) \
&& mkdir build && cd build \
&& cmake .. \
&& make -j$(nproc) \
&& make install \
&& ldconfig \
&& rm -rf /tmp/mbelib \
# Build DSD-FME (Digital Speech Decoder for DMR/P25)
&& cd /tmp \
&& git clone --depth 1 https://github.com/lwvmobile/dsd-fme.git \
&& cd dsd-fme \
&& mkdir build && cd build \
&& cmake .. \
&& make -j$(nproc) \
&& make install \
&& ldconfig \
&& rm -rf /tmp/dsd-fme \
# Cleanup build tools to reduce image size
&& apt-get remove -y \
build-essential \
@@ -124,6 +151,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libcurl4-openssl-dev \
zlib1g-dev \
libzmq3-dev \
libpulse-dev \
libfftw3-dev \
liblapack-dev \
libcodec2-dev \
&& apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/*
+58 -53
View File
@@ -31,11 +31,16 @@ Support the developer of this open-source project
- **Aircraft Tracking** - ADS-B via dump1090 with real-time map and radar
- **Vessel Tracking** - AIS ship tracking with VHF DSC distress monitoring
- **ACARS Messaging** - Aircraft datalink messages via acarsdec
- **DMR Digital Voice** - DMR/P25/NXDN/D-STAR decoding via dsd-fme with visual synthesizer
- **Listening Post** - Frequency scanner with audio monitoring
- **WebSDR** - Remote HF/shortwave listening via WebSDR servers
- **ISS SSTV** - Receive slow-scan TV from the International Space Station
- **HF SSTV** - Terrestrial SSTV on shortwave frequencies
- **Satellite Tracking** - Pass prediction using TLE data
- **ADS-B History** - Persistent aircraft history with reporting dashboard (Postgres optional)
- **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng
- **Bluetooth Scanning** - Device discovery and tracker detection (with Ubertooth support)
- **TSCM** - Counter-surveillance with RF baseline comparison and threat detection
- **Meshtastic** - LoRa mesh network integration
- **Spy Stations** - Number stations and diplomatic HF network database
- **Remote Agents** - Distributed SIGINT with remote sensor nodes
@@ -63,59 +68,59 @@ cd intercept
docker compose up -d
```
> **Note:** Docker requires privileged mode for USB SDR access. See `docker-compose.yml` for configuration options.
### ADS-B History (Optional)
The ADS-B history feature persists aircraft messages to Postgres for long-term analysis.
```bash
# Start with ADS-B history and Postgres
docker compose --profile history up -d
```
Set the following environment variables (for example in a `.env` file):
```bash
INTERCEPT_ADSB_HISTORY_ENABLED=true
INTERCEPT_ADSB_DB_HOST=adsb_db
INTERCEPT_ADSB_DB_PORT=5432
INTERCEPT_ADSB_DB_NAME=intercept_adsb
INTERCEPT_ADSB_DB_USER=intercept
INTERCEPT_ADSB_DB_PASSWORD=intercept
```
### Other ADS-B Settings
Set these as environment variables for either local installs or Docker:
| Variable | Default | Description |
|----------|---------|-------------|
| `INTERCEPT_ADSB_AUTO_START` | `false` | Auto-start ADS-B tracking when the dashboard loads |
| `INTERCEPT_SHARED_OBSERVER_LOCATION` | `true` | Share observer location across ADS-B/AIS/SSTV/Satellite modules |
**Local install example**
```bash
INTERCEPT_ADSB_AUTO_START=true \
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
python app.py
```
**Docker example (.env)**
```bash
INTERCEPT_ADSB_AUTO_START=true
INTERCEPT_SHARED_OBSERVER_LOCATION=false
```
To store Postgres data on external storage, set `PGDATA_PATH` (defaults to `./pgdata`):
```bash
PGDATA_PATH=/mnt/usbpi1/intercept/pgdata
```
Then open **/adsb/history** for the reporting dashboard.
> **Note:** Docker requires privileged mode for USB SDR access. See `docker-compose.yml` for configuration options.
### ADS-B History (Optional)
The ADS-B history feature persists aircraft messages to Postgres for long-term analysis.
```bash
# Start with ADS-B history and Postgres
docker compose --profile history up -d
```
Set the following environment variables (for example in a `.env` file):
```bash
INTERCEPT_ADSB_HISTORY_ENABLED=true
INTERCEPT_ADSB_DB_HOST=adsb_db
INTERCEPT_ADSB_DB_PORT=5432
INTERCEPT_ADSB_DB_NAME=intercept_adsb
INTERCEPT_ADSB_DB_USER=intercept
INTERCEPT_ADSB_DB_PASSWORD=intercept
```
### Other ADS-B Settings
Set these as environment variables for either local installs or Docker:
| Variable | Default | Description |
|----------|---------|-------------|
| `INTERCEPT_ADSB_AUTO_START` | `false` | Auto-start ADS-B tracking when the dashboard loads |
| `INTERCEPT_SHARED_OBSERVER_LOCATION` | `true` | Share observer location across ADS-B/AIS/SSTV/Satellite modules |
**Local install example**
```bash
INTERCEPT_ADSB_AUTO_START=true \
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
python app.py
```
**Docker example (.env)**
```bash
INTERCEPT_ADSB_AUTO_START=true
INTERCEPT_SHARED_OBSERVER_LOCATION=false
```
To store Postgres data on external storage, set `PGDATA_PATH` (defaults to `./pgdata`):
```bash
PGDATA_PATH=/mnt/usbpi1/intercept/pgdata
```
Then open **/adsb/history** for the reporting dashboard.
### Open the Interface
+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-01-11_fae1348c",
"downloaded": "2026-01-12T15:55:42.769654Z"
"version": "2026-02-01_ba81b697",
"downloaded": "2026-02-04T17:06:54.806043Z"
}
+60 -4
View File
@@ -105,7 +105,7 @@ def inject_offline_settings():
'enabled': get_setting('offline.enabled', False),
'assets_source': get_setting('offline.assets_source', 'cdn'),
'fonts_source': get_setting('offline.fonts_source', 'cdn'),
'tile_provider': get_setting('offline.tile_provider', 'openstreetmap'),
'tile_provider': get_setting('offline.tile_provider', 'cartodb_dark_cyan'),
'tile_server_url': get_setting('offline.tile_server_url', '')
}
}
@@ -172,6 +172,12 @@ dsc_rtl_process = None
dsc_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
dsc_lock = threading.Lock()
# DMR / Digital Voice
dmr_process = None
dmr_rtl_process = None
dmr_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
dmr_lock = threading.Lock()
# TSCM (Technical Surveillance Countermeasures)
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
tscm_lock = threading.Lock()
@@ -282,6 +288,10 @@ def require_login():
# Routes that don't require login (to avoid infinite redirect loop)
allowed_routes = ['login', 'static', 'favicon', 'health', 'health_check']
# Allow audio streaming endpoints without session auth
if request.path.startswith('/listening/audio/'):
return None
# Controller API endpoints use API key auth, not session auth
# Allow agent push/pull endpoints without session login
if request.path.startswith('/controller/'):
@@ -631,6 +641,7 @@ def health_check() -> Response:
'wifi': wifi_process is not None and (wifi_process.poll() is None if wifi_process else False),
'bluetooth': bt_process is not None and (bt_process.poll() is None if bt_process else False),
'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False),
'dmr': dmr_process is not None and (dmr_process.poll() is None if dmr_process else False),
},
'data': {
'aircraft_count': len(adsb_aircraft),
@@ -645,19 +656,23 @@ def health_check() -> Response:
@app.route('/killall', methods=['POST'])
def kill_all() -> Response:
"""Kill all decoder and WiFi processes."""
"""Kill all decoder, WiFi, and Bluetooth processes."""
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process
global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_process
global dmr_process, dmr_rtl_process
# Import adsb and ais modules to reset their state
from routes import adsb as adsb_module
from routes import ais as ais_module
from utils.bluetooth import reset_bluetooth_scanner
killed = []
processes_to_kill = [
'rtl_fm', 'multimon-ng', 'rtl_433',
'airodump-ng', 'aireplay-ng', 'airmon-ng',
'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher'
'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher',
'hcitool', 'bluetoothctl', 'dsd',
'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg',
]
for proc in processes_to_kill:
@@ -701,6 +716,31 @@ def kill_all() -> Response:
dsc_process = None
dsc_rtl_process = None
# Reset DMR state
with dmr_lock:
dmr_process = None
dmr_rtl_process = None
# Reset Bluetooth state (legacy)
with bt_lock:
if bt_process:
try:
bt_process.terminate()
bt_process.wait(timeout=2)
except Exception:
try:
bt_process.kill()
except Exception:
pass
bt_process = None
# Reset Bluetooth v2 scanner
try:
reset_bluetooth_scanner()
killed.append('bluetooth')
except Exception:
pass
# Clear SDR device registry
with sdr_device_registry_lock:
sdr_device_registry.clear()
@@ -821,6 +861,22 @@ def main() -> None:
except ImportError as e:
print(f"WebSocket audio disabled (install flask-sock): {e}")
# Initialize KiwiSDR WebSocket audio proxy
try:
from routes.websdr import init_websdr_audio
init_websdr_audio(app)
print("KiwiSDR audio proxy enabled")
except ImportError as e:
print(f"KiwiSDR audio proxy disabled: {e}")
# Initialize WebSocket for waterfall streaming
try:
from routes.waterfall_websocket import init_waterfall_websocket
init_waterfall_websocket(app)
print("WebSocket waterfall streaming enabled")
except ImportError as e:
print(f"WebSocket waterfall disabled: {e}")
print(f"Open http://localhost:{args.port} in your browser")
print()
print("Press Ctrl+C to stop")
+54 -10
View File
@@ -7,15 +7,54 @@ import os
import sys
# Application version
VERSION = "2.12.1"
VERSION = "2.14.0"
# Changelog - latest release notes (shown on welcome screen)
CHANGELOG = [
{
"version": "2.14.0",
"date": "February 2026",
"highlights": [
"DMR/P25/NXDN/D-STAR digital voice decoder with dsd-fme",
"DMR visual synthesizer with event-driven spring-physics bars",
"HF SSTV general mode with predefined shortwave frequencies",
"WebSDR integration for remote HF/shortwave listening",
"Listening Post signal scanner and audio pipeline improvements",
"TSCM sweep resilience, WiFi detection, and correlation fixes",
"APRS rtl_fm startup and SDR device conflict fixes",
]
},
{
"version": "2.13.1",
"date": "February 2026",
"highlights": [
"UI overhaul with slate/cyan theme and JetBrains Mono font",
"Signal scanner rewritten with rtl_power sweep and SNR filtering",
"Listening Post audio streaming via WAV with retry/fallback",
"WiFi connected clients panel now filters to selected AP",
"Global navigation bar across all dashboards",
"Fixed USB device contention when starting audio pipeline",
]
},
{
"version": "2.13.0",
"date": "February 2026",
"highlights": [
"WiFi client display in AP detail drawer with real-time SSE updates",
"Help modal system with keyboard shortcuts reference",
"Global navbar and settings modal accessible from all dashboards",
"Probed SSID badges for connected clients",
]
},
{
"version": "2.12.1",
"date": "February 2026",
"highlights": [
"Bug fixes and improvements",
"SDR device registry to prevent decoder conflicts",
"SDR device status panel and ADS-B Bias-T toggle",
"Real-time Doppler tracking for ISS SSTV reception",
"TCP connection support for Meshtastic",
"Shared observer location with auto-start options",
]
},
{
@@ -165,14 +204,19 @@ SATELLITE_UPDATE_INTERVAL = _get_env_int('SATELLITE_UPDATE_INTERVAL', 30)
SATELLITE_TRAJECTORY_POINTS = _get_env_int('SATELLITE_TRAJECTORY_POINTS', 30)
SATELLITE_ORBIT_MINUTES = _get_env_int('SATELLITE_ORBIT_MINUTES', 45)
# Update checking
GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept')
UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True)
UPDATE_CHECK_INTERVAL_HOURS = _get_env_int('UPDATE_CHECK_INTERVAL_HOURS', 6)
# Admin credentials
ADMIN_USERNAME = _get_env('ADMIN_USERNAME', 'admin')
ADMIN_PASSWORD = _get_env('ADMIN_PASSWORD', 'admin')
# Update checking
GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept')
UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True)
UPDATE_CHECK_INTERVAL_HOURS = _get_env_int('UPDATE_CHECK_INTERVAL_HOURS', 6)
# Alerting
ALERT_WEBHOOK_URL = _get_env('ALERT_WEBHOOK_URL', '')
ALERT_WEBHOOK_SECRET = _get_env('ALERT_WEBHOOK_SECRET', '')
ALERT_WEBHOOK_TIMEOUT = _get_env_int('ALERT_WEBHOOK_TIMEOUT', 5)
# Admin credentials
ADMIN_USERNAME = _get_env('ADMIN_USERNAME', 'admin')
ADMIN_PASSWORD = _get_env('ADMIN_PASSWORD', 'admin')
def configure_logging() -> None:
"""Configure application logging."""
+2 -1
View File
@@ -94,7 +94,8 @@ services:
- POSTGRES_USER=intercept
- POSTGRES_PASSWORD=intercept
volumes:
- ./pgdata:/var/lib/postgresql/data
# Default local path (override with PGDATA_PATH for external storage)
- ${PGDATA_PATH:-./pgdata}:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U intercept -d intercept_adsb"]
+1
View File
@@ -0,0 +1 @@
www.intercept-sigint.com
+608
View File
@@ -0,0 +1,608 @@
# iNTERCEPT UI Guide
This guide documents the UI design system, components, and patterns used in iNTERCEPT.
## Table of Contents
1. [Design Tokens](#design-tokens)
2. [Base Templates](#base-templates)
3. [Navigation](#navigation)
4. [Components](#components)
5. [Adding a New Module Page](#adding-a-new-module-page)
6. [Adding a New Dashboard](#adding-a-new-dashboard)
---
## Design Tokens
All design tokens are defined in `static/css/core/variables.css`. Import this file first in any stylesheet.
### Colors
```css
/* Backgrounds (layered depth) */
--bg-primary: #0a0c10; /* Darkest - page background */
--bg-secondary: #0f1218; /* Panels, sidebars */
--bg-tertiary: #151a23; /* Cards, elevated elements */
--bg-card: #121620; /* Card backgrounds */
--bg-elevated: #1a202c; /* Hover states, modals */
/* Accent Colors */
--accent-cyan: #4a9eff; /* Primary action color */
--accent-green: #22c55e; /* Success, online status */
--accent-red: #ef4444; /* Error, danger, stop */
--accent-orange: #f59e0b; /* Warning */
--accent-amber: #d4a853; /* Secondary highlight */
/* Text Hierarchy */
--text-primary: #e8eaed; /* Main content */
--text-secondary: #9ca3af; /* Secondary content */
--text-dim: #4b5563; /* Disabled, placeholder */
--text-muted: #374151; /* Barely visible */
/* Status Colors */
--status-online: #22c55e;
--status-warning: #f59e0b;
--status-error: #ef4444;
--status-offline: #6b7280;
```
### Spacing Scale
```css
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-16: 64px;
```
### Typography
```css
/* Font Families */
--font-sans: 'Inter', -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', monospace;
/* Font Sizes */
--text-xs: 10px;
--text-sm: 12px;
--text-base: 14px;
--text-lg: 16px;
--text-xl: 18px;
--text-2xl: 20px;
--text-3xl: 24px;
--text-4xl: 30px;
```
### Border Radius
```css
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-full: 9999px;
```
### Light Theme
The design system supports light/dark themes via `data-theme` attribute:
```html
<html data-theme="dark"> <!-- or "light" -->
```
Toggle with JavaScript:
```javascript
document.documentElement.setAttribute('data-theme', 'light');
```
---
## Base Templates
### `templates/layout/base.html`
The main base template for standard pages. Use for pages with sidebar + content layout.
```html
{% extends 'layout/base.html' %}
{% block title %}My Page Title{% endblock %}
{% block styles %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/my-page.css') }}">
{% endblock %}
{% block navigation %}
{% set active_mode = 'mymode' %}
{% include 'partials/nav.html' %}
{% endblock %}
{% block sidebar %}
<div class="app-sidebar">
<!-- Sidebar content -->
</div>
{% endblock %}
{% block content %}
<div class="page-container">
<h1>Page Title</h1>
<!-- Page content -->
</div>
{% endblock %}
{% block scripts %}
<script>
// Page-specific JavaScript
</script>
{% endblock %}
```
### `templates/layout/base_dashboard.html`
Extended base for full-screen dashboards (maps, visualizations).
```html
{% extends 'layout/base_dashboard.html' %}
{% set active_mode = 'mydashboard' %}
{% block dashboard_title %}MY DASHBOARD{% endblock %}
{% block styles %}
{{ super() }}
<link rel="stylesheet" href="{{ url_for('static', filename='css/my_dashboard.css') }}">
{% endblock %}
{% block stats_strip %}
<div class="stats-strip">
<!-- Stats bar content -->
</div>
{% endblock %}
{% block dashboard_content %}
<div class="dashboard-map-container">
<!-- Main visualization -->
</div>
<div class="dashboard-sidebar">
<!-- Sidebar panels -->
</div>
{% endblock %}
```
---
## Navigation
### Including Navigation
```html
{% set active_mode = 'pager' %}
{% include 'partials/nav.html' %}
```
### Valid `active_mode` Values
| Mode | Description |
|------|-------------|
| `pager` | Pager decoding |
| `sensor` | 433MHz sensors |
| `rtlamr` | Utility meters |
| `adsb` | Aircraft tracking |
| `ais` | Vessel tracking |
| `aprs` | Amateur radio |
| `wifi` | WiFi scanning |
| `bluetooth` | Bluetooth scanning |
| `tscm` | Counter-surveillance |
| `satellite` | Satellite tracking |
| `sstv` | ISS SSTV |
| `listening` | Listening post |
| `spystations` | Spy stations |
| `meshtastic` | Mesh networking |
### Navigation Groups
The navigation is organized into groups:
- **SDR / RF**: Pager, 433MHz, Meters, Aircraft, Vessels, APRS, Listening Post, Spy Stations, Meshtastic
- **Wireless**: WiFi, Bluetooth
- **Security**: TSCM
- **Space**: Satellite, ISS SSTV
---
## Components
### Card / Panel
```html
{% call card(title='PANEL TITLE', indicator=true, indicator_active=false) %}
<p>Panel content here</p>
{% endcall %}
```
Or manually:
```html
<div class="panel">
<div class="panel-header">
<span>PANEL TITLE</span>
<div class="panel-indicator active"></div>
</div>
<div class="panel-content">
<p>Content here</p>
</div>
</div>
```
### Empty State
```html
{% include 'components/empty_state.html' with context %}
{# Or with variables: #}
{% with title='No data yet', description='Start scanning to see results', action_text='Start Scan', action_onclick='startScan()' %}
{% include 'components/empty_state.html' %}
{% endwith %}
```
### Loading State
```html
{# Inline spinner #}
{% include 'components/loading.html' %}
{# With text #}
{% with text='Loading data...', size='lg' %}
{% include 'components/loading.html' %}
{% endwith %}
{# Full overlay #}
{% with overlay=true, text='Please wait...' %}
{% include 'components/loading.html' %}
{% endwith %}
```
### Status Badge
```html
{% with status='online', text='Connected', id='connectionStatus' %}
{% include 'components/status_badge.html' %}
{% endwith %}
```
Status values: `online`, `offline`, `warning`, `error`, `inactive`
### Buttons
```html
<!-- Primary action -->
<button class="btn btn-primary">Start Tracking</button>
<!-- Secondary action -->
<button class="btn btn-secondary">Cancel</button>
<!-- Danger action -->
<button class="btn btn-danger">Stop</button>
<!-- Ghost/subtle -->
<button class="btn btn-ghost">Settings</button>
<!-- Sizes -->
<button class="btn btn-primary btn-sm">Small</button>
<button class="btn btn-primary btn-lg">Large</button>
<!-- Icon button -->
<button class="btn btn-icon btn-secondary">
<span class="icon">...</span>
</button>
```
### Badges
```html
<span class="badge">Default</span>
<span class="badge badge-primary">Primary</span>
<span class="badge badge-success">Online</span>
<span class="badge badge-warning">Warning</span>
<span class="badge badge-danger">Error</span>
```
### Form Groups
```html
<div class="form-group">
<label for="frequency">Frequency (MHz)</label>
<input type="text" id="frequency" value="153.350">
<span class="form-help">Enter frequency in MHz</span>
</div>
<div class="form-group">
<label for="gain">Gain</label>
<select id="gain">
<option value="auto">Auto</option>
<option value="30">30 dB</option>
</select>
</div>
<label class="form-check">
<input type="checkbox" id="alerts">
<span>Enable alerts</span>
</label>
```
### Stats Strip
Used in dashboards for horizontal statistics display:
```html
<div class="stats-strip">
<div class="stats-strip-inner">
<div class="strip-stat">
<span class="strip-value" id="count">0</span>
<span class="strip-label">COUNT</span>
</div>
<div class="strip-divider"></div>
<div class="strip-status">
<div class="status-dot active" id="statusDot"></div>
<span id="statusText">TRACKING</span>
</div>
<div class="strip-time" id="utcTime">--:--:-- UTC</div>
</div>
</div>
```
---
## Adding a New Module Page
### 1. Create the Route
In `routes/mymodule.py`:
```python
from flask import Blueprint, render_template
mymodule_bp = Blueprint('mymodule', __name__, url_prefix='/mymodule')
@mymodule_bp.route('/dashboard')
def dashboard():
return render_template('mymodule_dashboard.html',
offline_settings=get_offline_settings())
```
### 2. Register the Blueprint
In `routes/__init__.py`:
```python
from routes.mymodule import mymodule_bp
app.register_blueprint(mymodule_bp)
```
### 3. Create the Template
Option A: Simple page extending base.html
```html
{% extends 'layout/base.html' %}
{% set active_mode = 'mymodule' %}
{% block title %}My Module{% endblock %}
{% block navigation %}
{% include 'partials/nav.html' %}
{% endblock %}
{% block content %}
<!-- Your content -->
{% endblock %}
```
Option B: Full-screen dashboard
```html
{% extends 'layout/base_dashboard.html' %}
{% set active_mode = 'mymodule' %}
{% block dashboard_title %}MY MODULE{% endblock %}
{% block dashboard_content %}
<!-- Your dashboard content -->
{% endblock %}
```
### 4. Add to Navigation
In `templates/partials/nav.html`, add your module to the appropriate group:
```html
<button class="mode-nav-btn {% if active_mode == 'mymodule' %}active{% endif %}"
onclick="switchMode('mymodule')">
<span class="nav-icon icon"><!-- SVG icon --></span>
<span class="nav-label">My Module</span>
</button>
```
Or if it's a dashboard link:
```html
<a href="/mymodule/dashboard"
class="mode-nav-btn {% if active_mode == 'mymodule' %}active{% endif %}"
style="text-decoration: none;">
<span class="nav-icon icon"><!-- SVG icon --></span>
<span class="nav-label">My Module</span>
</a>
```
### 5. Create Stylesheet
In `static/css/mymodule.css`:
```css
/**
* My Module Styles
*/
@import url('./core/variables.css');
/* Your styles using design tokens */
.mymodule-container {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: var(--space-4);
}
```
---
## Adding a New Dashboard
For full-screen dashboards like ADSB, AIS, or Satellite:
### 1. Create the Template
```html
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MY DASHBOARD // iNTERCEPT</title>
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
<!-- Design tokens (required) -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/variables.css') }}">
<!-- Fonts -->
{% if offline_settings.fonts_source == 'local' %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
{% else %}
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
{% endif %}
<!-- External libraries if needed -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<!-- Dashboard styles -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/mydashboard.css') }}">
</head>
<body>
<!-- Background effects -->
<div class="radar-bg"></div>
<div class="scanline"></div>
<!-- Header -->
<header class="header">
<div class="logo">
<a href="/" style="color: inherit; text-decoration: none;">
MY DASHBOARD
<span>// iNTERCEPT</span>
</a>
</div>
<div class="status-bar">
<a href="#" onclick="history.back(); return false;" class="back-link">Back</a>
<a href="/" class="back-link">Main Dashboard</a>
</div>
</header>
<!-- Unified Navigation -->
{% set active_mode = 'mydashboard' %}
{% include 'partials/nav.html' %}
<!-- Stats Strip -->
<div class="stats-strip">
<!-- Stats content -->
</div>
<!-- Main Dashboard Content -->
<main class="dashboard">
<!-- Your dashboard layout -->
</main>
<script>
// Dashboard JavaScript
</script>
</body>
</html>
```
### 2. Create the Stylesheet
```css
/**
* My Dashboard Styles
*/
@import url('./core/variables.css');
:root {
/* Dashboard-specific aliases */
--bg-dark: var(--bg-primary);
--bg-panel: var(--bg-secondary);
--bg-card: var(--bg-tertiary);
--grid-line: rgba(74, 158, 255, 0.08);
}
/* Your dashboard styles */
```
---
## Best Practices
### DO
- Use design tokens for all colors, spacing, and typography
- Include the nav partial on all pages for consistent navigation
- Set `active_mode` before including the nav partial
- Use semantic component classes (`btn`, `panel`, `badge`, etc.)
- Support both light and dark themes
- Test on mobile viewports
### DON'T
- Hardcode color values - use CSS variables
- Create new color variations without adding to tokens
- Duplicate navigation markup - use the partial
- Skip the favicon and design tokens imports
- Use inline styles for layout (use utility classes)
---
## File Structure
```
templates/
├── layout/
│ ├── base.html # Standard page base
│ └── base_dashboard.html # Dashboard page base
├── partials/
│ ├── nav.html # Unified navigation
│ ├── page_header.html # Page title component
│ └── settings-modal.html # Settings modal
├── components/
│ ├── card.html # Panel/card component
│ ├── empty_state.html # Empty state placeholder
│ ├── loading.html # Loading spinner
│ ├── stats_strip.html # Stats bar component
│ └── status_badge.html # Status indicator
├── index.html # Main dashboard
├── adsb_dashboard.html # Aircraft tracking
├── ais_dashboard.html # Vessel tracking
└── satellite_dashboard.html # Satellite tracking
static/css/
├── core/
│ ├── variables.css # Design tokens
│ ├── base.css # Reset & typography
│ ├── components.css # Component styles
│ └── layout.css # Layout styles
├── index.css # Main dashboard styles
├── adsb_dashboard.css # Aircraft dashboard
├── ais_dashboard.css # Vessel dashboard
├── satellite_dashboard.css # Satellite dashboard
└── responsive.css # Responsive breakpoints
```
+198 -93
View File
@@ -838,14 +838,15 @@ class ModeManager:
data['data'] = list(getattr(self, 'ais_vessels', {}).values())
elif mode == 'aprs':
data['data'] = list(getattr(self, 'aprs_stations', {}).values())
elif mode == 'tscm':
data['data'] = {
'anomalies': getattr(self, 'tscm_anomalies', []),
'baseline': getattr(self, 'tscm_baseline', {}),
'wifi_devices': list(self.wifi_networks.values()),
'bt_devices': list(self.bluetooth_devices.values()),
'rf_signals': getattr(self, 'tscm_rf_signals', []),
}
elif mode == 'tscm':
data['data'] = {
'anomalies': getattr(self, 'tscm_anomalies', []),
'baseline': getattr(self, 'tscm_baseline', {}),
'wifi_devices': list(self.wifi_networks.values()),
'wifi_clients': list(getattr(self, 'tscm_wifi_clients', {}).values()),
'bt_devices': list(self.bluetooth_devices.values()),
'rf_signals': getattr(self, 'tscm_rf_signals', []),
}
elif mode == 'listening_post':
data['data'] = {
'activity': getattr(self, 'listening_post_activity', []),
@@ -1104,23 +1105,24 @@ class ModeManager:
self.wifi_clients.clear()
elif mode == 'bluetooth':
self.bluetooth_devices.clear()
elif mode == 'tscm':
# Clean up TSCM sub-threads
for sub_thread_name in ['tscm_wifi', 'tscm_bt', 'tscm_rf']:
if sub_thread_name in self.output_threads:
thread = self.output_threads[sub_thread_name]
if thread and thread.is_alive():
thread.join(timeout=2)
del self.output_threads[sub_thread_name]
# Clear TSCM data
self.tscm_anomalies = []
self.tscm_baseline = {}
self.tscm_rf_signals = []
# Clear reported threat tracking sets
if hasattr(self, '_tscm_reported_wifi'):
self._tscm_reported_wifi.clear()
if hasattr(self, '_tscm_reported_bt'):
self._tscm_reported_bt.clear()
elif mode == 'tscm':
# Clean up TSCM sub-threads
for sub_thread_name in ['tscm_wifi', 'tscm_bt', 'tscm_rf']:
if sub_thread_name in self.output_threads:
thread = self.output_threads[sub_thread_name]
if thread and thread.is_alive():
thread.join(timeout=2)
del self.output_threads[sub_thread_name]
# Clear TSCM data
self.tscm_anomalies = []
self.tscm_baseline = {}
self.tscm_rf_signals = []
self.tscm_wifi_clients = {}
# Clear reported threat tracking sets
if hasattr(self, '_tscm_reported_wifi'):
self._tscm_reported_wifi.clear()
if hasattr(self, '_tscm_reported_bt'):
self._tscm_reported_bt.clear()
elif mode == 'dsc':
# Clear DSC data
if hasattr(self, 'dsc_messages'):
@@ -1540,9 +1542,10 @@ class ModeManager:
def _start_wifi(self, params: dict) -> dict:
"""Start WiFi scanning using Intercept's UnifiedWiFiScanner."""
interface = params.get('interface')
channel = params.get('channel')
band = params.get('band', 'abg')
scan_type = params.get('scan_type', 'deep')
channel = params.get('channel')
channels = params.get('channels')
band = params.get('band', 'abg')
scan_type = params.get('scan_type', 'deep')
# Handle quick scan - returns results synchronously
if scan_type == 'quick':
@@ -1571,8 +1574,21 @@ class ModeManager:
else:
scan_band = 'all'
# Start deep scan
if scanner.start_deep_scan(interface=interface, band=scan_band, channel=channel):
channel_list = None
if channels:
if isinstance(channels, str):
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
elif isinstance(channels, (list, tuple, set)):
channel_list = list(channels)
else:
channel_list = [channels]
try:
channel_list = [int(c) for c in channel_list]
except (TypeError, ValueError):
return {'status': 'error', 'message': 'Invalid channels'}
# Start deep scan
if scanner.start_deep_scan(interface=interface, band=scan_band, channel=channel, channels=channel_list):
# Start thread to sync data to agent's dictionaries
thread = threading.Thread(
target=self._wifi_data_sync,
@@ -1591,12 +1607,12 @@ class ModeManager:
else:
return {'status': 'error', 'message': scanner.get_status().error or 'Failed to start deep scan'}
except ImportError:
# Fallback to direct airodump-ng
return self._start_wifi_fallback(interface, channel, band)
except Exception as e:
logger.error(f"WiFi scanner error: {e}")
return {'status': 'error', 'message': str(e)}
except ImportError:
# Fallback to direct airodump-ng
return self._start_wifi_fallback(interface, channel, band, channels)
except Exception as e:
logger.error(f"WiFi scanner error: {e}")
return {'status': 'error', 'message': str(e)}
def _wifi_data_sync(self, scanner):
"""Sync WiFi scanner data to agent's data structures."""
@@ -1630,8 +1646,14 @@ class ModeManager:
if hasattr(self, '_wifi_scanner_instance') and self._wifi_scanner_instance:
self._wifi_scanner_instance.stop_deep_scan()
def _start_wifi_fallback(self, interface: str | None, channel: int | None, band: str) -> dict:
"""Fallback WiFi deep scan using airodump-ng directly."""
def _start_wifi_fallback(
self,
interface: str | None,
channel: int | None,
band: str,
channels: list[int] | str | None = None,
) -> dict:
"""Fallback WiFi deep scan using airodump-ng directly."""
if not interface:
return {'status': 'error', 'message': 'WiFi interface required'}
@@ -1658,8 +1680,23 @@ class ModeManager:
cmd = [airodump_path, '-w', csv_path, '--output-format', output_formats, '--band', band]
if gps_manager.is_running:
cmd.append('--gpsd')
if channel:
cmd.extend(['-c', str(channel)])
channel_list = None
if channels:
if isinstance(channels, str):
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
elif isinstance(channels, (list, tuple, set)):
channel_list = list(channels)
else:
channel_list = [channels]
try:
channel_list = [int(c) for c in channel_list]
except (TypeError, ValueError):
return {'status': 'error', 'message': 'Invalid channels'}
if channel_list:
cmd.extend(['-c', ','.join(str(c) for c in channel_list)])
elif channel:
cmd.extend(['-c', str(channel)])
cmd.append(interface)
try:
@@ -3111,17 +3148,21 @@ class ModeManager:
self.tscm_baseline = {}
if not hasattr(self, 'tscm_anomalies'):
self.tscm_anomalies = []
if not hasattr(self, 'tscm_rf_signals'):
self.tscm_rf_signals = []
self.tscm_anomalies.clear()
if not hasattr(self, 'tscm_rf_signals'):
self.tscm_rf_signals = []
if not hasattr(self, 'tscm_wifi_clients'):
self.tscm_wifi_clients = {}
self.tscm_anomalies.clear()
self.tscm_wifi_clients.clear()
# Get params for what to scan
scan_wifi = params.get('wifi', True)
scan_bt = params.get('bluetooth', True)
scan_rf = params.get('rf', True)
wifi_interface = params.get('wifi_interface') or params.get('interface')
bt_adapter = params.get('bt_interface') or params.get('adapter', 'hci0')
sdr_device = params.get('sdr_device', params.get('device', 0))
scan_rf = params.get('rf', True)
wifi_interface = params.get('wifi_interface') or params.get('interface')
bt_adapter = params.get('bt_interface') or params.get('adapter', 'hci0')
sdr_device = params.get('sdr_device', params.get('device', 0))
sweep_type = params.get('sweep_type')
# Get baseline_id for comparison (same as local mode)
baseline_id = params.get('baseline_id')
@@ -3129,11 +3170,11 @@ class ModeManager:
started_scans = []
# Start the combined TSCM scanner thread using existing Intercept functions
thread = threading.Thread(
target=self._tscm_scanner_thread,
args=(scan_wifi, scan_bt, scan_rf, wifi_interface, bt_adapter, sdr_device, baseline_id),
daemon=True
)
thread = threading.Thread(
target=self._tscm_scanner_thread,
args=(scan_wifi, scan_bt, scan_rf, wifi_interface, bt_adapter, sdr_device, baseline_id, sweep_type),
daemon=True
)
thread.start()
self.output_threads['tscm'] = thread
@@ -3152,9 +3193,9 @@ class ModeManager:
'scanning': started_scans
}
def _tscm_scanner_thread(self, scan_wifi: bool, scan_bt: bool, scan_rf: bool,
wifi_interface: str | None, bt_adapter: str, sdr_device: int,
baseline_id: int | None = None):
def _tscm_scanner_thread(self, scan_wifi: bool, scan_bt: bool, scan_rf: bool,
wifi_interface: str | None, bt_adapter: str, sdr_device: int,
baseline_id: int | None = None, sweep_type: str | None = None):
"""Combined TSCM scanner using existing Intercept functions.
NOTE: This matches local mode behavior exactly:
@@ -3167,11 +3208,20 @@ class ModeManager:
stop_event = self.stop_events.get(mode)
# Import existing Intercept TSCM functions
from routes.tscm import _scan_wifi_networks, _scan_bluetooth_devices, _scan_rf_signals
logger.info("TSCM imports successful")
# Load baseline if specified (same as local mode)
baseline = None
from routes.tscm import _scan_wifi_networks, _scan_wifi_clients, _scan_bluetooth_devices, _scan_rf_signals
logger.info("TSCM imports successful")
sweep_ranges = None
if sweep_type:
try:
from data.tscm_frequencies import get_sweep_preset, SWEEP_PRESETS
preset = get_sweep_preset(sweep_type) or SWEEP_PRESETS.get('standard')
sweep_ranges = preset.get('ranges') if preset else None
except Exception:
sweep_ranges = None
# Load baseline if specified (same as local mode)
baseline = None
if baseline_id and HAS_BASELINE_DB and get_tscm_baseline:
baseline = get_tscm_baseline(baseline_id)
if baseline:
@@ -3192,8 +3242,9 @@ class ModeManager:
self._tscm_correlation = None
# Track devices seen during this sweep (like local mode's all_wifi/all_bt dicts)
seen_wifi = {}
seen_bt = {}
seen_wifi = {}
seen_wifi_clients = {}
seen_bt = {}
last_rf_scan = 0
rf_scan_interval = 30
@@ -3239,19 +3290,63 @@ class ModeManager:
enriched['is_new'] = not classification.get('in_baseline', False)
enriched['reasons'] = classification.get('reasons', [])
if self._tscm_correlation:
profile = self._tscm_correlation.analyze_wifi_device(enriched)
enriched['classification'] = profile.risk_level.value
enriched['score'] = profile.total_score
enriched['indicators'] = [
{'type': i.type.value, 'desc': i.description}
for i in profile.indicators
]
enriched['recommended_action'] = profile.recommended_action
self.wifi_networks[bssid] = enriched
except Exception as e:
logger.debug(f"WiFi scan error: {e}")
if self._tscm_correlation:
profile = self._tscm_correlation.analyze_wifi_device(enriched)
enriched['classification'] = profile.risk_level.value
enriched['score'] = profile.total_score
enriched['score_modifier'] = profile.score_modifier
enriched['known_device'] = profile.known_device
enriched['known_device_name'] = profile.known_device_name
enriched['indicators'] = [
{'type': i.type.value, 'desc': i.description}
for i in profile.indicators
]
enriched['recommended_action'] = profile.recommended_action
self.wifi_networks[bssid] = enriched
# WiFi clients (monitor mode only)
try:
wifi_clients = _scan_wifi_clients(wifi_interface or '')
for client in wifi_clients:
mac = (client.get('mac') or '').upper()
if not mac or mac in seen_wifi_clients:
continue
seen_wifi_clients[mac] = client
rssi_val = client.get('rssi_current')
if rssi_val is None:
rssi_val = client.get('rssi_median') or client.get('rssi_ema')
client_device = {
'mac': mac,
'vendor': client.get('vendor'),
'name': client.get('vendor') or 'WiFi Client',
'rssi': rssi_val,
'associated_bssid': client.get('associated_bssid'),
'probed_ssids': client.get('probed_ssids', []),
'probe_count': client.get('probe_count', len(client.get('probed_ssids', []))),
'is_client': True,
}
if self._tscm_correlation:
profile = self._tscm_correlation.analyze_wifi_device(client_device)
client_device['classification'] = profile.risk_level.value
client_device['score'] = profile.total_score
client_device['score_modifier'] = profile.score_modifier
client_device['known_device'] = profile.known_device
client_device['known_device_name'] = profile.known_device_name
client_device['indicators'] = [
{'type': i.type.value, 'desc': i.description}
for i in profile.indicators
]
client_device['recommended_action'] = profile.recommended_action
self.tscm_wifi_clients[mac] = client_device
except Exception as e:
logger.debug(f"WiFi client scan error: {e}")
except Exception as e:
logger.debug(f"WiFi scan error: {e}")
# Bluetooth scan using Intercept's function (same as local mode)
if scan_bt:
@@ -3285,15 +3380,18 @@ class ModeManager:
enriched['is_new'] = not classification.get('in_baseline', False)
enriched['reasons'] = classification.get('reasons', [])
if self._tscm_correlation:
profile = self._tscm_correlation.analyze_bluetooth_device(enriched)
enriched['classification'] = profile.risk_level.value
enriched['score'] = profile.total_score
enriched['indicators'] = [
{'type': i.type.value, 'desc': i.description}
for i in profile.indicators
]
enriched['recommended_action'] = profile.recommended_action
if self._tscm_correlation:
profile = self._tscm_correlation.analyze_bluetooth_device(enriched)
enriched['classification'] = profile.risk_level.value
enriched['score'] = profile.total_score
enriched['score_modifier'] = profile.score_modifier
enriched['known_device'] = profile.known_device
enriched['known_device_name'] = profile.known_device_name
enriched['indicators'] = [
{'type': i.type.value, 'desc': i.description}
for i in profile.indicators
]
enriched['recommended_action'] = profile.recommended_action
self.bluetooth_devices[mac] = enriched
except Exception as e:
@@ -3304,7 +3402,11 @@ class ModeManager:
try:
# Pass a stop check that uses our stop_event (not the module's _sweep_running)
agent_stop_check = lambda: stop_event and stop_event.is_set()
rf_signals = _scan_rf_signals(sdr_device, stop_check=agent_stop_check)
rf_signals = _scan_rf_signals(
sdr_device,
stop_check=agent_stop_check,
sweep_ranges=sweep_ranges
)
# Analyze each RF signal like local mode does
analyzed_signals = []
@@ -3324,14 +3426,17 @@ class ModeManager:
analyzed['reasons'] = classification.get('reasons', [])
# Use correlation engine for scoring (same as local mode)
if hasattr(self, '_tscm_correlation') and self._tscm_correlation:
profile = self._tscm_correlation.analyze_rf_signal(signal)
analyzed['classification'] = profile.risk_level.value
analyzed['score'] = profile.total_score
analyzed['indicators'] = [
{'type': i.type.value, 'desc': i.description}
for i in profile.indicators
]
if hasattr(self, '_tscm_correlation') and self._tscm_correlation:
profile = self._tscm_correlation.analyze_rf_signal(signal)
analyzed['classification'] = profile.risk_level.value
analyzed['score'] = profile.total_score
analyzed['score_modifier'] = profile.score_modifier
analyzed['known_device'] = profile.known_device
analyzed['known_device_name'] = profile.known_device_name
analyzed['indicators'] = [
{'type': i.type.value, 'desc': i.description}
for i in profile.indicators
]
analyzed['is_threat'] = is_threat
analyzed_signals.append(analyzed)
+12 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "intercept"
version = "2.12.1"
version = "2.14.0"
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
readme = "README.md"
requires-python = ">=3.9"
@@ -33,6 +33,7 @@ dependencies = [
"flask-limiter>=2.5.4",
"bleak>=0.21.0",
"flask-sock",
"websocket-client>=1.6.0",
"requests>=2.28.0",
]
@@ -52,6 +53,16 @@ dev = [
"types-flask>=1.1.0",
]
optionals = [
"scipy>=1.10.0",
"qrcode[pil]>=7.4",
"numpy>=1.24.0",
"Pillow>=9.0.0",
"meshtastic>=2.0.0",
"psycopg2-binary>=2.9.9",
"scapy>=2.4.5",
]
[project.scripts]
intercept = "intercept:main"
+6 -1
View File
@@ -13,10 +13,13 @@ bleak>=0.21.0
# Satellite tracking (optional - only needed for satellite features)
skyfield>=1.45
# DSC decoding (optional - only needed for VHF DSC maritime distress)
# DSC decoding and SSTV decoding (DSP pipeline)
scipy>=1.10.0
numpy>=1.24.0
# SSTV image output (optional - needed for SSTV image decoding)
Pillow>=9.0.0
# GPS dongle support (optional - only needed for USB GPS receivers)
pyserial>=3.5
@@ -35,4 +38,6 @@ qrcode[pil]>=7.4
# ruff>=0.1.0
# black>=23.0.0
# mypy>=1.0.0
# WebSocket support for in-app audio streaming (KiwiSDR, Listening Post)
flask-sock
websocket-client>=1.6.0
+10
View File
@@ -26,6 +26,11 @@ def register_blueprints(app):
from .offline import offline_bp
from .updater import updater_bp
from .sstv import sstv_bp
from .sstv_general import sstv_general_bp
from .dmr import dmr_bp
from .websdr import websdr_bp
from .alerts import alerts_bp
from .recordings import recordings_bp
app.register_blueprint(pager_bp)
app.register_blueprint(sensor_bp)
@@ -51,6 +56,11 @@ def register_blueprints(app):
app.register_blueprint(offline_bp) # Offline mode settings
app.register_blueprint(updater_bp) # GitHub update checking
app.register_blueprint(sstv_bp) # ISS SSTV decoder
app.register_blueprint(sstv_general_bp) # General terrestrial SSTV
app.register_blueprint(dmr_bp) # DMR / P25 / Digital Voice
app.register_blueprint(websdr_bp) # HF/Shortwave WebSDR
app.register_blueprint(alerts_bp) # Cross-mode alerts
app.register_blueprint(recordings_bp) # Session recordings
# Initialize TSCM state with queue and lock from app
import app as app_module
+26 -4
View File
@@ -20,13 +20,15 @@ from flask import Blueprint, jsonify, request, Response
import app as app_module
from utils.logging import sensor_logger as logger
from utils.validation import validate_device_index, validate_gain, validate_ppm
from utils.sse import format_sse
from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.constants import (
PROCESS_TERMINATE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT,
PROCESS_START_WAIT,
)
from utils.process import register_process, unregister_process
acars_bp = Blueprint('acars', __name__, url_prefix='/acars')
@@ -144,9 +146,24 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
logger.error(f"ACARS stream error: {e}")
app_module.acars_queue.put({'type': 'error', 'message': str(e)})
finally:
global acars_active_device
# Ensure process is terminated
try:
process.terminate()
process.wait(timeout=2)
except Exception:
try:
process.kill()
except Exception:
pass
unregister_process(process)
app_module.acars_queue.put({'type': 'status', 'status': 'stopped'})
with app_module.acars_lock:
app_module.acars_process = None
# Release SDR device
if acars_active_device is not None:
app_module.release_sdr_device(acars_active_device)
acars_active_device = None
@acars_bp.route('/tools')
@@ -311,6 +328,7 @@ def start_acars() -> Response:
return jsonify({'status': 'error', 'message': error_msg}), 500
app_module.acars_process = process
register_process(process)
# Start output streaming thread
thread = threading.Thread(
@@ -374,9 +392,13 @@ def stream_acars() -> Response:
while True:
try:
msg = app_module.acars_queue.get(timeout=SSE_QUEUE_TIMEOUT)
last_keepalive = time.time()
yield format_sse(msg)
msg = app_module.acars_queue.get(timeout=SSE_QUEUE_TIMEOUT)
last_keepalive = time.time()
try:
process_event('acars', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg)
except queue.Empty:
now = time.time()
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
+41 -9
View File
@@ -43,6 +43,7 @@ from utils.validation import (
validate_rtl_tcp_host, validate_rtl_tcp_port
)
from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.sdr import SDRFactory, SDRType
from utils.constants import (
ADSB_SBS_PORT,
@@ -732,16 +733,43 @@ def start_adsb():
stderr_output = app_module.adsb_process.stderr.read().decode('utf-8', errors='ignore').strip()
except Exception:
pass
if sdr_type == SDRType.RTL_SDR:
error_msg = 'dump1090 failed to start. Check RTL-SDR device permissions or if another process is using it.'
if stderr_output:
error_msg += f' Error: {stderr_output[:200]}'
return jsonify({'status': 'error', 'message': error_msg})
# Parse stderr to provide specific guidance
error_type = 'START_FAILED'
stderr_lower = stderr_output.lower()
if 'usb_claim_interface' in stderr_lower or 'libusb_error_busy' in stderr_lower or 'device or resource busy' in stderr_lower:
error_msg = 'SDR device is busy. Another process may be using it.'
suggestion = 'Try: 1) Stop other SDR applications, 2) Run "pkill -f rtl_" to kill stale processes, or 3) Remove and reinsert the SDR device.'
error_type = 'DEVICE_BUSY'
elif 'no supported devices' in stderr_lower or 'no rtl-sdr' in stderr_lower or 'failed to open' in stderr_lower:
error_msg = 'RTL-SDR device not found.'
suggestion = 'Ensure the device is connected. Try removing and reinserting the SDR.'
error_type = 'DEVICE_NOT_FOUND'
elif 'kernel driver is active' in stderr_lower or 'dvb' in stderr_lower:
error_msg = 'Kernel DVB-T driver is blocking the device.'
suggestion = 'Blacklist the DVB drivers: Go to Settings > Hardware > "Blacklist DVB Drivers" or run "sudo rmmod dvb_usb_rtl28xxu".'
error_type = 'KERNEL_DRIVER'
elif 'permission' in stderr_lower or 'access' in stderr_lower:
error_msg = 'Permission denied accessing RTL-SDR device.'
suggestion = 'Run Intercept with sudo, or add udev rules for RTL-SDR devices.'
error_type = 'PERMISSION_DENIED'
elif sdr_type == SDRType.RTL_SDR:
error_msg = 'dump1090 failed to start.'
suggestion = 'Try removing and reinserting the SDR device, or check if another application is using it.'
else:
error_msg = f'ADS-B decoder failed to start for {sdr_type.value}. Ensure readsb is installed with SoapySDR support and the device is connected.'
if stderr_output:
error_msg += f' Error: {stderr_output[:200]}'
return jsonify({'status': 'error', 'message': error_msg})
error_msg = f'ADS-B decoder failed to start for {sdr_type.value}.'
suggestion = 'Ensure readsb is installed with SoapySDR support and the device is connected.'
full_msg = f'{error_msg} {suggestion}'
if stderr_output and len(stderr_output) < 300:
full_msg += f' (Details: {stderr_output})'
return jsonify({
'status': 'error',
'error_type': error_type,
'message': full_msg
})
adsb_using_service = True
adsb_active_device = device # Track which device is being used
@@ -816,6 +844,10 @@ def stream_adsb():
try:
msg = app_module.adsb_queue.get(timeout=SSE_QUEUE_TIMEOUT)
last_keepalive = time.time()
try:
process_event('adsb', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg)
except queue.Empty:
now = time.time()
+5
View File
@@ -19,6 +19,7 @@ from config import SHARED_OBSERVER_LOCATION_ENABLED
from utils.logging import get_logger
from utils.validation import validate_device_index, validate_gain
from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.sdr import SDRFactory, SDRType
from utils.constants import (
AIS_TCP_PORT,
@@ -484,6 +485,10 @@ def stream_ais():
try:
msg = app_module.ais_queue.get(timeout=SSE_QUEUE_TIMEOUT)
last_keepalive = time.time()
try:
process_event('ais', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg)
except queue.Empty:
now = time.time()
+76
View File
@@ -0,0 +1,76 @@
"""Alerting API endpoints."""
from __future__ import annotations
import queue
import time
from typing import Generator
from flask import Blueprint, Response, jsonify, request
from utils.alerts import get_alert_manager
from utils.sse import format_sse
alerts_bp = Blueprint('alerts', __name__, url_prefix='/alerts')
@alerts_bp.route('/rules', methods=['GET'])
def list_rules():
manager = get_alert_manager()
include_disabled = request.args.get('all') in ('1', 'true', 'yes')
return jsonify({'status': 'success', 'rules': manager.list_rules(include_disabled=include_disabled)})
@alerts_bp.route('/rules', methods=['POST'])
def create_rule():
data = request.get_json() or {}
if not isinstance(data.get('match', {}), dict):
return jsonify({'status': 'error', 'message': 'match must be a JSON object'}), 400
manager = get_alert_manager()
rule_id = manager.add_rule(data)
return jsonify({'status': 'success', 'rule_id': rule_id})
@alerts_bp.route('/rules/<int:rule_id>', methods=['PUT', 'PATCH'])
def update_rule(rule_id: int):
data = request.get_json() or {}
manager = get_alert_manager()
ok = manager.update_rule(rule_id, data)
if not ok:
return jsonify({'status': 'error', 'message': 'Rule not found or no changes'}), 404
return jsonify({'status': 'success'})
@alerts_bp.route('/rules/<int:rule_id>', methods=['DELETE'])
def delete_rule(rule_id: int):
manager = get_alert_manager()
ok = manager.delete_rule(rule_id)
if not ok:
return jsonify({'status': 'error', 'message': 'Rule not found'}), 404
return jsonify({'status': 'success'})
@alerts_bp.route('/events', methods=['GET'])
def list_events():
manager = get_alert_manager()
limit = request.args.get('limit', default=100, type=int)
mode = request.args.get('mode')
severity = request.args.get('severity')
events = manager.list_events(limit=limit, mode=mode, severity=severity)
return jsonify({'status': 'success', 'events': events})
@alerts_bp.route('/stream', methods=['GET'])
def stream_alerts() -> Response:
manager = get_alert_manager()
def generate() -> Generator[str, None, None]:
for event in manager.stream_events(timeout=1.0):
yield format_sse(event)
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response
+68 -9
View File
@@ -13,7 +13,7 @@ import tempfile
import threading
import time
from datetime import datetime
from subprocess import DEVNULL, PIPE, STDOUT
from subprocess import PIPE, STDOUT
from typing import Generator, Optional
from flask import Blueprint, jsonify, request, Response
@@ -21,7 +21,8 @@ from flask import Blueprint, jsonify, request, Response
import app as app_module
from utils.logging import sensor_logger as logger
from utils.validation import validate_device_index, validate_gain, validate_ppm
from utils.sse import format_sse
from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.constants import (
PROCESS_TERMINATE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL,
@@ -31,6 +32,9 @@ from utils.constants import (
aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs')
# Track which SDR device is being used
aprs_active_device: int | None = None
# APRS frequencies by region (MHz)
APRS_FREQUENCIES = {
'north_america': '144.390',
@@ -1301,7 +1305,7 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
This function reads from the decoder's stdout (text mode, line-buffered).
The decoder's stderr is merged into stdout (STDOUT) to avoid deadlocks.
rtl_fm's stderr is sent to DEVNULL for the same reason.
rtl_fm's stderr is captured via PIPE with a monitor thread.
Outputs two types of messages to the queue:
- type='aprs': Decoded APRS packets
@@ -1383,6 +1387,7 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
logger.error(f"APRS stream error: {e}")
app_module.aprs_queue.put({'type': 'error', 'message': str(e)})
finally:
global aprs_active_device
app_module.aprs_queue.put({'type': 'status', 'status': 'stopped'})
# Cleanup processes
for proc in [rtl_process, decoder_process]:
@@ -1394,6 +1399,10 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
proc.kill()
except Exception:
pass
# Release SDR device
if aprs_active_device is not None:
app_module.release_sdr_device(aprs_active_device)
aprs_active_device = None
@aprs_bp.route('/tools')
@@ -1441,6 +1450,7 @@ def get_stations() -> Response:
def start_aprs() -> Response:
"""Start APRS decoder."""
global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations
global aprs_active_device
with app_module.aprs_lock:
if app_module.aprs_process and app_module.aprs_process.poll() is None:
@@ -1477,6 +1487,16 @@ def start_aprs() -> Response:
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
# Reserve SDR device to prevent conflicts with other modes
error = app_module.claim_sdr_device(device, 'aprs')
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
aprs_active_device = device
# Get frequency for region
region = data.get('region', 'north_america')
frequency = APRS_FREQUENCIES.get(region, '144.390')
@@ -1552,15 +1572,25 @@ def start_aprs() -> Response:
try:
# Start rtl_fm with stdout piped to decoder.
# stderr goes to DEVNULL to prevent blocking (rtl_fm logs to stderr).
# stderr is captured via PIPE so errors are reported to the user.
# NOTE: RTL-SDR Blog V4 may show offset-tuned frequency in logs - this is normal.
rtl_process = subprocess.Popen(
rtl_cmd,
stdout=PIPE,
stderr=DEVNULL,
stderr=PIPE,
start_new_session=True
)
# Start a thread to monitor rtl_fm stderr for errors
def monitor_rtl_stderr():
for line in rtl_process.stderr:
err_text = line.decode('utf-8', errors='replace').strip()
if err_text:
logger.debug(f"[RTL_FM] {err_text}")
rtl_stderr_thread = threading.Thread(target=monitor_rtl_stderr, daemon=True)
rtl_stderr_thread.start()
# Start decoder with stdin wired to rtl_fm's stdout.
# Use text mode with line buffering for reliable line-by-line reading.
# Merge stderr into stdout to avoid blocking on unbuffered stderr.
@@ -1582,13 +1612,25 @@ def start_aprs() -> Response:
time.sleep(PROCESS_START_WAIT)
if rtl_process.poll() is not None:
# rtl_fm exited early - something went wrong
# rtl_fm exited early - capture stderr for diagnostics
stderr_output = ''
try:
remaining = rtl_process.stderr.read()
if remaining:
stderr_output = remaining.decode('utf-8', errors='replace').strip()
except Exception:
pass
error_msg = f'rtl_fm failed to start (exit code {rtl_process.returncode})'
if stderr_output:
error_msg += f': {stderr_output[:200]}'
logger.error(error_msg)
try:
decoder_process.kill()
except Exception:
pass
if aprs_active_device is not None:
app_module.release_sdr_device(aprs_active_device)
aprs_active_device = None
return jsonify({'status': 'error', 'message': error_msg}), 500
if decoder_process.poll() is not None:
@@ -1602,6 +1644,9 @@ def start_aprs() -> Response:
rtl_process.kill()
except Exception:
pass
if aprs_active_device is not None:
app_module.release_sdr_device(aprs_active_device)
aprs_active_device = None
return jsonify({'status': 'error', 'message': error_msg}), 500
# Store references for status checks and cleanup
@@ -1626,12 +1671,17 @@ def start_aprs() -> Response:
except Exception as e:
logger.error(f"Failed to start APRS decoder: {e}")
if aprs_active_device is not None:
app_module.release_sdr_device(aprs_active_device)
aprs_active_device = None
return jsonify({'status': 'error', 'message': str(e)}), 500
@aprs_bp.route('/stop', methods=['POST'])
def stop_aprs() -> Response:
"""Stop APRS decoder."""
global aprs_active_device
with app_module.aprs_lock:
processes_to_stop = []
@@ -1660,6 +1710,11 @@ def stop_aprs() -> Response:
if hasattr(app_module, 'aprs_rtl_process'):
app_module.aprs_rtl_process = None
# Release SDR device
if aprs_active_device is not None:
app_module.release_sdr_device(aprs_active_device)
aprs_active_device = None
return jsonify({'status': 'stopped'})
@@ -1671,9 +1726,13 @@ def stream_aprs() -> Response:
while True:
try:
msg = app_module.aprs_queue.get(timeout=SSE_QUEUE_TIMEOUT)
last_keepalive = time.time()
yield format_sse(msg)
msg = app_module.aprs_queue.get(timeout=SSE_QUEUE_TIMEOUT)
last_keepalive = time.time()
try:
process_event('aprs', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg)
except queue.Empty:
now = time.time()
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
+5 -7
View File
@@ -66,12 +66,6 @@ def kill_audio_processes():
pass
rtl_process = None
# Kill any orphaned processes
try:
subprocess.run(['pkill', '-9', '-f', 'rtl_fm'], capture_output=True, timeout=1)
except:
pass
time.sleep(0.3)
@@ -229,7 +223,11 @@ def init_audio_websocket(app: Flask):
except TimeoutError:
pass
except Exception as e:
if "timed out" not in str(e).lower():
msg = str(e).lower()
if "connection closed" in msg:
logger.info("WebSocket closed by client")
break
if "timed out" not in msg:
logger.error(f"WebSocket receive error: {e}")
# Stream audio data if active
+12 -7
View File
@@ -18,10 +18,11 @@ from typing import Any, Generator
from flask import Blueprint, jsonify, request, Response
import app as app_module
from utils.dependencies import check_tool
from utils.logging import bluetooth_logger as logger
from utils.sse import format_sse
from utils.validation import validate_bluetooth_interface
from utils.dependencies import check_tool
from utils.logging import bluetooth_logger as logger
from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.validation import validate_bluetooth_interface
from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer
from data.patterns import AIRTAG_PREFIXES, TILE_PREFIXES, SAMSUNG_TRACKER
from utils.constants import (
@@ -561,9 +562,13 @@ def stream_bt():
while True:
try:
msg = app_module.bt_queue.get(timeout=1)
last_keepalive = time.time()
yield format_sse(msg)
msg = app_module.bt_queue.get(timeout=1)
last_keepalive = time.time()
try:
process_event('bluetooth', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg)
except queue.Empty:
now = time.time()
if now - last_keepalive >= keepalive_interval:
+177 -75
View File
@@ -7,32 +7,40 @@ aggregation, and heuristics.
from __future__ import annotations
import csv
import io
import json
import logging
import csv
import io
import json
import logging
import threading
import time
from datetime import datetime
from typing import Generator
from flask import Blueprint, Response, jsonify, request, session
from utils.bluetooth import (
BluetoothScanner,
BTDeviceAggregate,
get_bluetooth_scanner,
check_capabilities,
RANGE_UNKNOWN,
from utils.bluetooth import (
BluetoothScanner,
BTDeviceAggregate,
get_bluetooth_scanner,
check_capabilities,
RANGE_UNKNOWN,
TrackerType,
TrackerConfidence,
get_tracker_engine,
)
from utils.database import get_db
from utils.sse import format_sse
)
from utils.database import get_db
from utils.sse import format_sse
from utils.event_pipeline import process_event
logger = logging.getLogger('intercept.bluetooth_v2')
# Blueprint
bluetooth_v2_bp = Blueprint('bluetooth_v2', __name__, url_prefix='/api/bluetooth')
bluetooth_v2_bp = Blueprint('bluetooth_v2', __name__, url_prefix='/api/bluetooth')
# Seen-before tracking
_bt_seen_cache: set[str] = set()
_bt_session_seen: set[str] = set()
_bt_seen_lock = threading.Lock()
# =============================================================================
# DATABASE FUNCTIONS
@@ -164,13 +172,20 @@ def get_all_baselines() -> list[dict]:
return [dict(row) for row in cursor]
def save_observation_history(device: BTDeviceAggregate) -> None:
"""Save device observation to history."""
with get_db() as conn:
conn.execute('''
INSERT INTO bt_observation_history (device_id, rssi, seen_count)
VALUES (?, ?, ?)
''', (device.device_id, device.rssi_current, device.seen_count))
def save_observation_history(device: BTDeviceAggregate) -> None:
"""Save device observation to history."""
with get_db() as conn:
conn.execute('''
INSERT INTO bt_observation_history (device_id, rssi, seen_count)
VALUES (?, ?, ?)
''', (device.device_id, device.rssi_current, device.seen_count))
def load_seen_device_ids() -> set[str]:
"""Load distinct device IDs from history for seen-before tracking."""
with get_db() as conn:
cursor = conn.execute('SELECT DISTINCT device_id FROM bt_observation_history')
return {row['device_id'] for row in cursor}
# =============================================================================
@@ -191,7 +206,7 @@ def get_capabilities():
@bluetooth_v2_bp.route('/scan/start', methods=['POST'])
def start_scan():
def start_scan():
"""
Start Bluetooth scanning.
@@ -221,17 +236,42 @@ def start_scan():
# Get scanner instance
scanner = get_bluetooth_scanner(adapter_id)
# Check if already scanning
if scanner.is_scanning:
return jsonify({
'status': 'already_running',
'scan_status': scanner.get_status().to_dict()
})
# Initialize database tables if needed
init_bt_tables()
# Load active baseline if exists
# Initialize database tables if needed
init_bt_tables()
def _handle_seen_before(device: BTDeviceAggregate) -> None:
try:
with _bt_seen_lock:
device.seen_before = device.device_id in _bt_seen_cache
if device.device_id not in _bt_session_seen:
save_observation_history(device)
_bt_session_seen.add(device.device_id)
except Exception as e:
logger.debug(f"BT seen-before update failed: {e}")
# Setup seen-before callback
if scanner._on_device_updated is None:
scanner._on_device_updated = _handle_seen_before
# Ensure cache is initialized
with _bt_seen_lock:
if not _bt_seen_cache:
_bt_seen_cache.update(load_seen_device_ids())
# Check if already scanning
if scanner.is_scanning:
return jsonify({
'status': 'already_running',
'scan_status': scanner.get_status().to_dict()
})
# Refresh seen-before cache and reset session set for a new scan
with _bt_seen_lock:
_bt_seen_cache.clear()
_bt_seen_cache.update(load_seen_device_ids())
_bt_session_seen.clear()
# Load active baseline if exists
baseline_id = get_active_baseline_id()
if baseline_id:
device_ids = get_baseline_device_ids(baseline_id)
@@ -856,11 +896,15 @@ def stream_events():
else:
return event_type, event
def event_generator() -> Generator[str, None, None]:
"""Generate SSE events from scanner."""
for event in scanner.stream_events(timeout=1.0):
event_name, event_data = map_event_type(event)
yield format_sse(event_data, event=event_name)
def event_generator() -> Generator[str, None, None]:
"""Generate SSE events from scanner."""
for event in scanner.stream_events(timeout=1.0):
event_name, event_data = map_event_type(event)
try:
process_event('bluetooth', event_data, event_name)
except Exception:
pass
yield format_sse(event_data, event=event_name)
return Response(
event_generator(),
@@ -944,23 +988,34 @@ def get_tscm_bluetooth_snapshot(duration: int = 8) -> list[dict]:
devices = scanner.get_devices()
logger.info(f"TSCM snapshot: get_devices() returned {len(devices)} devices")
# Convert to TSCM format with tracker detection data
tscm_devices = []
for device in devices:
device_data = {
'mac': device.address,
'address_type': device.address_type,
'device_key': device.device_key,
'name': device.name or 'Unknown',
'rssi': device.rssi_current or -100,
'rssi_median': device.rssi_median,
'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None,
'type': _classify_device_type(device),
'manufacturer': device.manufacturer_name,
'manufacturer_id': device.manufacturer_id,
'manufacturer_data': device.manufacturer_bytes.hex() if device.manufacturer_bytes else None,
'protocol': device.protocol,
'first_seen': device.first_seen.isoformat(),
# Convert to TSCM format with tracker detection data
tscm_devices = []
for device in devices:
manufacturer_name = device.manufacturer_name
if (not manufacturer_name) or str(manufacturer_name).lower().startswith('unknown'):
if device.address and not device.is_randomized_mac:
try:
from data.oui import get_manufacturer
oui_vendor = get_manufacturer(device.address)
if oui_vendor and oui_vendor != 'Unknown':
manufacturer_name = oui_vendor
except Exception:
pass
device_data = {
'mac': device.address,
'address_type': device.address_type,
'device_key': device.device_key,
'name': device.name or 'Unknown',
'rssi': device.rssi_current or -100,
'rssi_median': device.rssi_median,
'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None,
'type': _classify_device_type(device),
'manufacturer': manufacturer_name,
'manufacturer_id': device.manufacturer_id,
'manufacturer_data': device.manufacturer_bytes.hex() if device.manufacturer_bytes else None,
'protocol': device.protocol,
'first_seen': device.first_seen.isoformat(),
'last_seen': device.last_seen.isoformat(),
'seen_count': device.seen_count,
'range_band': device.range_band,
@@ -1174,14 +1229,38 @@ def get_device_timeseries(device_key: str):
return jsonify(result)
def _classify_device_type(device: BTDeviceAggregate) -> str:
"""Classify device type from available data."""
name_lower = (device.name or '').lower()
manufacturer_lower = (device.manufacturer_name or '').lower()
# Check by name patterns
if any(x in name_lower for x in ['airpods', 'headphone', 'earbuds', 'buds', 'beats']):
return 'audio'
def _classify_device_type(device: BTDeviceAggregate) -> str:
"""Classify device type from available data."""
name_lower = (device.name or '').lower()
manufacturer_lower = (device.manufacturer_name or '').lower()
service_uuids = device.service_uuids or []
if (not manufacturer_lower) or manufacturer_lower.startswith('unknown'):
if device.address and not device.is_randomized_mac:
try:
from data.oui import get_manufacturer
oui_vendor = get_manufacturer(device.address)
if oui_vendor and oui_vendor != 'Unknown':
manufacturer_lower = oui_vendor.lower()
except Exception:
pass
def normalize_uuid(uuid: str) -> str:
if not uuid:
return ''
value = str(uuid).lower().strip()
if value.startswith('0x'):
value = value[2:]
# Bluetooth Base UUID normalization (16-bit UUIDs)
if value.endswith('-0000-1000-8000-00805f9b34fb') and len(value) >= 8:
return value[4:8]
if len(value) == 4:
return value
return value
# Check by name patterns
if any(x in name_lower for x in ['airpods', 'headphone', 'earbuds', 'buds', 'beats']):
return 'audio'
if any(x in name_lower for x in ['watch', 'band', 'fitbit', 'garmin']):
return 'wearable'
if any(x in name_lower for x in ['iphone', 'pixel', 'galaxy', 'phone']):
@@ -1190,18 +1269,41 @@ def _classify_device_type(device: BTDeviceAggregate) -> str:
return 'computer'
if any(x in name_lower for x in ['mouse', 'keyboard', 'trackpad']):
return 'peripheral'
if any(x in name_lower for x in ['tile', 'airtag', 'smarttag', 'chipolo']):
return 'tracker'
if any(x in name_lower for x in ['speaker', 'sonos', 'echo', 'home']):
return 'speaker'
if any(x in name_lower for x in ['tv', 'chromecast', 'roku', 'firestick']):
return 'media'
# Check by manufacturer
if 'apple' in manufacturer_lower:
return 'apple_device'
if 'samsung' in manufacturer_lower:
return 'samsung_device'
if any(x in name_lower for x in ['tile', 'airtag', 'smarttag', 'chipolo']):
return 'tracker'
if any(x in name_lower for x in ['speaker', 'sonos', 'echo', 'home']):
return 'speaker'
if any(x in name_lower for x in ['tv', 'chromecast', 'roku', 'firestick']):
return 'media'
# Tracker signals (metadata or Find My service)
if getattr(device, 'is_tracker', False) or getattr(device, 'tracker_type', None):
return 'tracker'
normalized_uuids = {normalize_uuid(u) for u in service_uuids if u}
if 'fd6f' in normalized_uuids:
return 'tracker'
# Service UUIDs (GATT / classic)
audio_uuids = {'110b', '110a', '111e', '111f', '1108', '1203'}
wearable_uuids = {'180d', '1814', '1816'}
hid_uuids = {'1812'}
beacon_uuids = {'feaa', 'feab', 'feb1', 'febe'}
if normalized_uuids & audio_uuids:
return 'audio'
if normalized_uuids & hid_uuids:
return 'peripheral'
if normalized_uuids & wearable_uuids:
return 'wearable'
if normalized_uuids & beacon_uuids:
return 'beacon'
# Check by manufacturer
if 'apple' in manufacturer_lower:
return 'apple_device'
if 'samsung' in manufacturer_lower:
return 'samsung_device'
# Check by class of device
if device.major_class:
+69 -26
View File
@@ -10,14 +10,16 @@ This blueprint provides:
from __future__ import annotations
import json
import logging
import queue
import time
from datetime import datetime, timezone
from typing import Generator
from flask import Blueprint, jsonify, request, Response
import json
import logging
import queue
import time
from datetime import datetime, timezone
from typing import Generator
import requests
from flask import Blueprint, jsonify, request, Response
from utils.database import (
create_agent, get_agent, get_agent_by_name, list_agents,
@@ -450,12 +452,12 @@ def proxy_mode_status(agent_id: int, mode: str):
}), 502
@controller_bp.route('/agents/<int:agent_id>/<mode>/data', methods=['GET'])
def proxy_mode_data(agent_id: int, mode: str):
"""Get current data from a remote agent."""
agent = get_agent(agent_id)
if not agent:
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
@controller_bp.route('/agents/<int:agent_id>/<mode>/data', methods=['GET'])
def proxy_mode_data(agent_id: int, mode: str):
"""Get current data from a remote agent."""
agent = get_agent(agent_id)
if not agent:
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
try:
client = create_client_from_agent(agent)
@@ -473,18 +475,59 @@ def proxy_mode_data(agent_id: int, mode: str):
'data': result
})
except (AgentHTTPError, AgentConnectionError) as e:
return jsonify({
'status': 'error',
'message': f'Agent error: {e}'
}), 502
@controller_bp.route('/agents/<int:agent_id>/wifi/monitor', methods=['POST'])
def proxy_wifi_monitor(agent_id: int):
"""Toggle monitor mode on a remote agent's WiFi interface."""
agent = get_agent(agent_id)
if not agent:
except (AgentHTTPError, AgentConnectionError) as e:
return jsonify({
'status': 'error',
'message': f'Agent error: {e}'
}), 502
@controller_bp.route('/agents/<int:agent_id>/<mode>/stream')
def proxy_mode_stream(agent_id: int, mode: str):
"""Proxy SSE stream from a remote agent."""
agent = get_agent(agent_id)
if not agent:
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
client = create_client_from_agent(agent)
query = request.query_string.decode('utf-8')
url = f"{client.base_url}/{mode}/stream"
if query:
url = f"{url}?{query}"
headers = {'Accept': 'text/event-stream'}
if agent.get('api_key'):
headers['X-API-Key'] = agent['api_key']
def generate() -> Generator[str, None, None]:
try:
with requests.get(url, headers=headers, stream=True, timeout=(5, 3600)) as resp:
resp.raise_for_status()
for chunk in resp.iter_content(chunk_size=1024):
if not chunk:
continue
yield chunk.decode('utf-8', errors='ignore')
except Exception as e:
logger.error(f"SSE proxy error for agent {agent_id}/{mode}: {e}")
yield format_sse({
'type': 'error',
'message': str(e),
'agent_id': agent_id,
'mode': mode,
})
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response
@controller_bp.route('/agents/<int:agent_id>/wifi/monitor', methods=['POST'])
def proxy_wifi_monitor(agent_id: int):
"""Toggle monitor mode on a remote agent's WiFi interface."""
agent = get_agent(agent_id)
if not agent:
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
data = request.json or {}
+513
View File
@@ -0,0 +1,513 @@
"""DMR / P25 / Digital Voice decoding routes."""
from __future__ import annotations
import os
import queue
import re
import select
import shutil
import subprocess
import threading
import time
from datetime import datetime
from typing import Generator, Optional
from flask import Blueprint, jsonify, request, Response
import app as app_module
from utils.logging import get_logger
from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.process import register_process, unregister_process
from utils.constants import (
SSE_QUEUE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL,
QUEUE_MAX_SIZE,
)
logger = get_logger('intercept.dmr')
dmr_bp = Blueprint('dmr', __name__, url_prefix='/dmr')
# ============================================
# GLOBAL STATE
# ============================================
dmr_rtl_process: Optional[subprocess.Popen] = None
dmr_dsd_process: Optional[subprocess.Popen] = None
dmr_thread: Optional[threading.Thread] = None
dmr_running = False
dmr_lock = threading.Lock()
dmr_queue: queue.Queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
dmr_active_device: Optional[int] = None
VALID_PROTOCOLS = ['auto', 'dmr', 'p25', 'nxdn', 'dstar', 'provoice']
# Classic dsd flags
_DSD_PROTOCOL_FLAGS = {
'auto': [],
'dmr': ['-fd'],
'p25': ['-fp'],
'nxdn': ['-fn'],
'dstar': ['-fi'],
'provoice': ['-fv'],
}
# dsd-fme uses different flag names
_DSD_FME_PROTOCOL_FLAGS = {
'auto': ['-ft'],
'dmr': ['-fs'],
'p25': ['-f1'],
'nxdn': ['-fi'],
'dstar': [],
'provoice': ['-fp'],
}
# ============================================
# HELPERS
# ============================================
def find_dsd() -> tuple[str | None, bool]:
"""Find DSD (Digital Speech Decoder) binary.
Checks for dsd-fme first (common fork), then falls back to dsd.
Returns (path, is_fme) tuple.
"""
path = shutil.which('dsd-fme')
if path:
return path, True
path = shutil.which('dsd')
if path:
return path, False
return None, False
def find_rtl_fm() -> str | None:
"""Find rtl_fm binary."""
return shutil.which('rtl_fm')
def parse_dsd_output(line: str) -> dict | None:
"""Parse a line of DSD stderr output into a structured event.
Handles output from both classic ``dsd`` and ``dsd-fme`` which use
different formatting for talkgroup / source / voice frame lines.
"""
line = line.strip()
if not line:
return None
# Skip DSD/dsd-fme startup banner lines (ASCII art, version info, etc.)
# These contain box-drawing characters or are pure decoration.
if re.search(r'[╔╗╚╝║═██▀▄╗╝╩╦╠╣╬│┤├┘└┐┌─┼█▓▒░]', line):
return None
if re.match(r'^\s*(Build Version|MBElib|CODEC2|Audio (Out|In)|Decoding )', line):
return None
ts = datetime.now().strftime('%H:%M:%S')
# Sync detection: "Sync: +DMR (data)" or "Sync: +P25 Phase 1"
sync_match = re.match(r'Sync:\s*\+?(\S+.*)', line)
if sync_match:
return {
'type': 'sync',
'protocol': sync_match.group(1).strip(),
'timestamp': ts,
}
# Talkgroup and Source — check BEFORE slot so "Slot 1 Voice LC, TG: …"
# is captured as a call event rather than a bare slot event.
# Classic dsd: "TG: 12345 Src: 67890"
# dsd-fme: "TG: 12345, Src: 67890" or "Talkgroup: 12345, Source: 67890"
tg_match = re.search(
r'(?:TG|Talkgroup)[:\s]+(\d+)[,\s]+(?:Src|Source)[:\s]+(\d+)', line, re.IGNORECASE
)
if tg_match:
result = {
'type': 'call',
'talkgroup': int(tg_match.group(1)),
'source_id': int(tg_match.group(2)),
'timestamp': ts,
}
# Extract slot if present on the same line
slot_inline = re.search(r'Slot\s*(\d+)', line)
if slot_inline:
result['slot'] = int(slot_inline.group(1))
return result
# P25 NAC (Network Access Code) — check before voice/slot
nac_match = re.search(r'NAC[:\s]+([0-9A-Fa-f]+)', line)
if nac_match:
return {
'type': 'nac',
'nac': nac_match.group(1),
'timestamp': ts,
}
# Voice frame detection — check BEFORE bare slot match
# Classic dsd: "Voice" keyword in frame lines
# dsd-fme: "voice" or "Voice LC" or "VOICE" in output
if re.search(r'\bvoice\b', line, re.IGNORECASE):
result = {
'type': 'voice',
'detail': line,
'timestamp': ts,
}
slot_inline = re.search(r'Slot\s*(\d+)', line)
if slot_inline:
result['slot'] = int(slot_inline.group(1))
return result
# Bare slot info (only when line is *just* slot info, not voice/call)
slot_match = re.match(r'\s*Slot\s*(\d+)\s*$', line)
if slot_match:
return {
'type': 'slot',
'slot': int(slot_match.group(1)),
'timestamp': ts,
}
# dsd-fme status lines we can surface: "TDMA", "CACH", "PI", "BS", etc.
# Also catches "Closing", "Input", and other lifecycle lines.
# Forward as raw so the frontend can show decoder is alive.
return {
'type': 'raw',
'text': line[:200],
'timestamp': ts,
}
_HEARTBEAT_INTERVAL = 3.0 # seconds between heartbeats when decoder is idle
def _queue_put(event: dict):
"""Put an event on the DMR queue, dropping oldest if full."""
try:
dmr_queue.put_nowait(event)
except queue.Full:
try:
dmr_queue.get_nowait()
except queue.Empty:
pass
try:
dmr_queue.put_nowait(event)
except queue.Full:
pass
def stream_dsd_output(rtl_process: subprocess.Popen, dsd_process: subprocess.Popen):
"""Read DSD stderr output and push parsed events to the queue.
Uses select() with a timeout so we can send periodic heartbeat
events while readline() would otherwise block indefinitely during
silence (no signal being decoded).
"""
global dmr_running
try:
_queue_put({'type': 'status', 'text': 'started'})
last_heartbeat = time.time()
while dmr_running:
if dsd_process.poll() is not None:
break
# Wait up to 1s for data on stderr instead of blocking forever
ready, _, _ = select.select([dsd_process.stderr], [], [], 1.0)
if ready:
line = dsd_process.stderr.readline()
if not line:
if dsd_process.poll() is not None:
break
continue
text = line.decode('utf-8', errors='replace').strip()
if not text:
continue
parsed = parse_dsd_output(text)
if parsed:
_queue_put(parsed)
last_heartbeat = time.time()
else:
# No stderr output — send heartbeat so frontend knows
# decoder is still alive and listening
now = time.time()
if now - last_heartbeat >= _HEARTBEAT_INTERVAL:
_queue_put({
'type': 'heartbeat',
'timestamp': datetime.now().strftime('%H:%M:%S'),
})
last_heartbeat = now
except Exception as e:
logger.error(f"DSD stream error: {e}")
finally:
global dmr_active_device, dmr_rtl_process, dmr_dsd_process
dmr_running = False
# Capture exit info for diagnostics
rc = dsd_process.poll()
reason = 'stopped'
detail = ''
if rc is not None and rc != 0:
reason = 'crashed'
try:
remaining = dsd_process.stderr.read(1024)
if remaining:
detail = remaining.decode('utf-8', errors='replace').strip()[:200]
except Exception:
pass
logger.warning(f"DSD process exited with code {rc}: {detail}")
# Cleanup both processes
for proc in [dsd_process, rtl_process]:
if proc and proc.poll() is None:
try:
proc.terminate()
proc.wait(timeout=2)
except Exception:
try:
proc.kill()
except Exception:
pass
if proc:
unregister_process(proc)
dmr_rtl_process = None
dmr_dsd_process = None
_queue_put({'type': 'status', 'text': reason, 'exit_code': rc, 'detail': detail})
# Release SDR device
if dmr_active_device is not None:
app_module.release_sdr_device(dmr_active_device)
dmr_active_device = None
logger.info("DSD stream thread stopped")
# ============================================
# API ENDPOINTS
# ============================================
@dmr_bp.route('/tools')
def check_tools() -> Response:
"""Check for required tools."""
dsd_path, _ = find_dsd()
rtl_fm = find_rtl_fm()
return jsonify({
'dsd': dsd_path is not None,
'rtl_fm': rtl_fm is not None,
'available': dsd_path is not None and rtl_fm is not None,
'protocols': VALID_PROTOCOLS,
})
@dmr_bp.route('/start', methods=['POST'])
def start_dmr() -> Response:
"""Start digital voice decoding."""
global dmr_rtl_process, dmr_dsd_process, dmr_thread, dmr_running, dmr_active_device
with dmr_lock:
if dmr_running:
return jsonify({'status': 'error', 'message': 'Already running'}), 409
dsd_path, is_fme = find_dsd()
if not dsd_path:
return jsonify({'status': 'error', 'message': 'dsd not found. Install dsd-fme or dsd.'}), 503
rtl_fm_path = find_rtl_fm()
if not rtl_fm_path:
return jsonify({'status': 'error', 'message': 'rtl_fm not found. Install rtl-sdr tools.'}), 503
data = request.json or {}
try:
frequency = float(data.get('frequency', 462.5625))
gain = int(data.get('gain', 40))
device = int(data.get('device', 0))
protocol = str(data.get('protocol', 'auto')).lower()
except (ValueError, TypeError) as e:
return jsonify({'status': 'error', 'message': f'Invalid parameter: {e}'}), 400
if frequency <= 0:
return jsonify({'status': 'error', 'message': 'Frequency must be positive'}), 400
if protocol not in VALID_PROTOCOLS:
return jsonify({'status': 'error', 'message': f'Invalid protocol. Use: {", ".join(VALID_PROTOCOLS)}'}), 400
# Clear stale queue
try:
while True:
dmr_queue.get_nowait()
except queue.Empty:
pass
# Claim SDR device
error = app_module.claim_sdr_device(device, 'dmr')
if error:
return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409
dmr_active_device = device
freq_hz = int(frequency * 1e6)
# Build rtl_fm command (48kHz sample rate for DSD)
rtl_cmd = [
rtl_fm_path,
'-M', 'fm',
'-f', str(freq_hz),
'-s', '48000',
'-g', str(gain),
'-d', str(device),
'-l', '1', # squelch level
]
# Build DSD command
# Use -o - to send decoded audio to stdout (piped to DEVNULL)
# instead of PulseAudio which may not be available under sudo
dsd_cmd = [dsd_path, '-i', '-', '-o', '-']
if is_fme:
dsd_cmd.extend(_DSD_FME_PROTOCOL_FLAGS.get(protocol, []))
else:
dsd_cmd.extend(_DSD_PROTOCOL_FLAGS.get(protocol, []))
try:
dmr_rtl_process = subprocess.Popen(
rtl_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
register_process(dmr_rtl_process)
dmr_dsd_process = subprocess.Popen(
dsd_cmd,
stdin=dmr_rtl_process.stdout,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
)
register_process(dmr_dsd_process)
# Allow rtl_fm to send directly to dsd
dmr_rtl_process.stdout.close()
time.sleep(0.3)
rtl_rc = dmr_rtl_process.poll()
dsd_rc = dmr_dsd_process.poll()
if rtl_rc is not None or dsd_rc is not None:
# Process died — capture stderr for diagnostics
rtl_err = ''
if dmr_rtl_process.stderr:
rtl_err = dmr_rtl_process.stderr.read().decode('utf-8', errors='replace')[:500]
dsd_err = ''
if dmr_dsd_process.stderr:
dsd_err = dmr_dsd_process.stderr.read().decode('utf-8', errors='replace')[:500]
logger.error(f"DSD pipeline died: rtl_fm rc={rtl_rc} err={rtl_err!r}, dsd rc={dsd_rc} err={dsd_err!r}")
if dmr_active_device is not None:
app_module.release_sdr_device(dmr_active_device)
dmr_active_device = None
# Surface a clear error to the user
detail = rtl_err.strip() or dsd_err.strip()
if 'usb_claim_interface' in rtl_err or 'Failed to open' in rtl_err:
msg = f'SDR device {device} is busy — it may be in use by another mode or process. Try a different device.'
elif detail:
msg = f'Failed to start DSD pipeline: {detail}'
else:
msg = 'Failed to start DSD pipeline'
return jsonify({'status': 'error', 'message': msg}), 500
# Drain rtl_fm stderr in background to prevent pipe blocking
def _drain_rtl_stderr(proc):
try:
for line in proc.stderr:
pass
except Exception:
pass
threading.Thread(target=_drain_rtl_stderr, args=(dmr_rtl_process,), daemon=True).start()
dmr_running = True
dmr_thread = threading.Thread(
target=stream_dsd_output,
args=(dmr_rtl_process, dmr_dsd_process),
daemon=True,
)
dmr_thread.start()
return jsonify({
'status': 'started',
'frequency': frequency,
'protocol': protocol,
})
except Exception as e:
logger.error(f"Failed to start DMR: {e}")
if dmr_active_device is not None:
app_module.release_sdr_device(dmr_active_device)
dmr_active_device = None
return jsonify({'status': 'error', 'message': str(e)}), 500
@dmr_bp.route('/stop', methods=['POST'])
def stop_dmr() -> Response:
"""Stop digital voice decoding."""
global dmr_rtl_process, dmr_dsd_process, dmr_running, dmr_active_device
with dmr_lock:
dmr_running = False
for proc in [dmr_dsd_process, dmr_rtl_process]:
if proc and proc.poll() is None:
try:
proc.terminate()
proc.wait(timeout=2)
except Exception:
try:
proc.kill()
except Exception:
pass
if proc:
unregister_process(proc)
dmr_rtl_process = None
dmr_dsd_process = None
if dmr_active_device is not None:
app_module.release_sdr_device(dmr_active_device)
dmr_active_device = None
return jsonify({'status': 'stopped'})
@dmr_bp.route('/status')
def dmr_status() -> Response:
"""Get DMR decoder status."""
return jsonify({
'running': dmr_running,
'device': dmr_active_device,
})
@dmr_bp.route('/stream')
def stream_dmr() -> Response:
"""SSE stream for DMR decoder events."""
def generate() -> Generator[str, None, None]:
last_keepalive = time.time()
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.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
return response
+45 -2
View File
@@ -36,9 +36,11 @@ from utils.database import (
)
from utils.dsc.parser import parse_dsc_message
from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.validation import validate_device_index, validate_gain
from utils.sdr import SDRFactory, SDRType
from utils.dependencies import get_tool_path
from utils.process import register_process, unregister_process
logger = logging.getLogger('intercept.dsc')
@@ -169,17 +171,34 @@ def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> Non
'error': str(e)
})
finally:
global dsc_active_device
try:
os.close(master_fd)
except OSError:
pass
decoder_process.wait()
dsc_running = False
# Cleanup both processes
with app_module.dsc_lock:
rtl_proc = app_module.dsc_rtl_process
for proc in [rtl_proc, decoder_process]:
if proc:
try:
proc.terminate()
proc.wait(timeout=2)
except Exception:
try:
proc.kill()
except Exception:
pass
unregister_process(proc)
app_module.dsc_queue.put({'type': 'status', 'status': 'stopped'})
with app_module.dsc_lock:
app_module.dsc_process = None
app_module.dsc_rtl_process = None
# Release SDR device
if dsc_active_device is not None:
app_module.release_sdr_device(dsc_active_device)
dsc_active_device = None
def _store_critical_alert(msg: dict) -> None:
@@ -362,6 +381,7 @@ def start_decoding() -> Response:
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
register_process(rtl_process)
# Start stderr monitor thread
stderr_thread = threading.Thread(
@@ -382,6 +402,7 @@ def start_decoding() -> Response:
stderr=slave_fd,
close_fds=True
)
register_process(decoder_process)
os.close(slave_fd)
rtl_process.stdout.close()
@@ -408,6 +429,15 @@ def start_decoding() -> Response:
})
except FileNotFoundError as e:
# Kill orphaned rtl_fm process
try:
rtl_process.terminate()
rtl_process.wait(timeout=2)
except Exception:
try:
rtl_process.kill()
except Exception:
pass
# Release device on failure
if dsc_active_device is not None:
app_module.release_sdr_device(dsc_active_device)
@@ -417,6 +447,15 @@ def start_decoding() -> Response:
'message': f'Tool not found: {e.filename}'
}), 400
except Exception as e:
# Kill orphaned rtl_fm process if it was started
try:
rtl_process.terminate()
rtl_process.wait(timeout=2)
except Exception:
try:
rtl_process.kill()
except Exception:
pass
# Release device on failure
if dsc_active_device is not None:
app_module.release_sdr_device(dsc_active_device)
@@ -487,6 +526,10 @@ def stream() -> Response:
try:
msg = app_module.dsc_queue.get(timeout=1)
last_keepalive = time.time()
try:
process_event('dsc', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg)
except queue.Empty:
now = time.time()
+1007 -98
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -13,7 +13,7 @@ OFFLINE_DEFAULTS = {
'offline.enabled': False,
'offline.assets_source': 'cdn',
'offline.fonts_source': 'cdn',
'offline.tile_provider': 'openstreetmap',
'offline.tile_provider': 'cartodb_dark_cyan',
'offline.tile_server_url': ''
}
+125 -4
View File
@@ -2,12 +2,14 @@
from __future__ import annotations
import math
import os
import pathlib
import re
import pty
import queue
import select
import struct
import subprocess
import threading
import time
@@ -23,7 +25,8 @@ from utils.validation import (
validate_rtl_tcp_host, validate_rtl_tcp_port
)
from utils.sse import format_sse
from utils.process import safe_terminate, register_process
from utils.event_pipeline import process_event
from utils.process import safe_terminate, register_process, unregister_process
from utils.sdr import SDRFactory, SDRType, SDRValidationError
from utils.dependencies import get_tool_path
@@ -105,6 +108,62 @@ def log_message(msg: dict[str, Any]) -> None:
logger.error(f"Failed to log message: {e}")
def audio_relay_thread(
rtl_stdout,
multimon_stdin,
output_queue: queue.Queue,
stop_event: threading.Event,
) -> None:
"""Relay audio from rtl_fm to multimon-ng while computing signal levels.
Reads raw 16-bit LE PCM from *rtl_stdout*, writes every chunk straight
through to *multimon_stdin*, and every ~100 ms pushes an RMS / peak scope
event onto *output_queue*.
"""
CHUNK = 4096 # bytes 2048 samples at 16-bit mono
INTERVAL = 0.1 # seconds between scope updates
last_scope = time.monotonic()
try:
while not stop_event.is_set():
data = rtl_stdout.read(CHUNK)
if not data:
break
# Forward audio untouched
try:
multimon_stdin.write(data)
multimon_stdin.flush()
except (BrokenPipeError, OSError):
break
# Compute scope levels every ~100 ms
now = time.monotonic()
if now - last_scope >= INTERVAL:
last_scope = now
try:
n_samples = len(data) // 2
if n_samples == 0:
continue
samples = struct.unpack(f'<{n_samples}h', data[:n_samples * 2])
peak = max(abs(s) for s in samples)
rms = int(math.sqrt(sum(s * s for s in samples) / n_samples))
output_queue.put_nowait({
'type': 'scope',
'rms': rms,
'peak': peak,
})
except (struct.error, ValueError, queue.Full):
pass
except Exception as e:
logger.debug(f"Audio relay error: {e}")
finally:
try:
multimon_stdin.close()
except OSError:
pass
def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
"""Stream decoder output to queue using PTY for unbuffered output."""
try:
@@ -146,14 +205,37 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
except Exception as e:
app_module.output_queue.put({'type': 'error', 'text': str(e)})
finally:
global pager_active_device
try:
os.close(master_fd)
except OSError:
pass
process.wait()
# Signal relay thread to stop
with app_module.process_lock:
stop_relay = getattr(app_module.current_process, '_stop_relay', None)
if stop_relay:
stop_relay.set()
# Cleanup companion rtl_fm process and decoder
with app_module.process_lock:
rtl_proc = getattr(app_module.current_process, '_rtl_process', None)
for proc in [rtl_proc, process]:
if proc:
try:
proc.terminate()
proc.wait(timeout=2)
except Exception:
try:
proc.kill()
except Exception:
pass
unregister_process(proc)
app_module.output_queue.put({'type': 'status', 'text': 'stopped'})
with app_module.process_lock:
app_module.current_process = None
# Release SDR device
if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device)
pager_active_device = None
@pager_bp.route('/start', methods=['POST'])
@@ -281,6 +363,7 @@ def start_decoding() -> Response:
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
register_process(rtl_process)
# Start a thread to monitor rtl_fm stderr for errors
def monitor_rtl_stderr():
@@ -299,18 +382,30 @@ def start_decoding() -> Response:
multimon_process = subprocess.Popen(
multimon_cmd,
stdin=rtl_process.stdout,
stdin=subprocess.PIPE,
stdout=slave_fd,
stderr=slave_fd,
close_fds=True
)
register_process(multimon_process)
os.close(slave_fd)
rtl_process.stdout.close()
# Spawn audio relay thread between rtl_fm and multimon-ng
stop_relay = threading.Event()
relay = threading.Thread(
target=audio_relay_thread,
args=(rtl_process.stdout, multimon_process.stdin,
app_module.output_queue, stop_relay),
)
relay.daemon = True
relay.start()
app_module.current_process = multimon_process
app_module.current_process._rtl_process = rtl_process
app_module.current_process._master_fd = master_fd
app_module.current_process._stop_relay = stop_relay
app_module.current_process._relay_thread = relay
# Start output thread with PTY master fd
thread = threading.Thread(target=stream_decoder, args=(master_fd, multimon_process))
@@ -322,12 +417,30 @@ def start_decoding() -> Response:
return jsonify({'status': 'started', 'command': full_cmd})
except FileNotFoundError as e:
# Kill orphaned rtl_fm process
try:
rtl_process.terminate()
rtl_process.wait(timeout=2)
except Exception:
try:
rtl_process.kill()
except Exception:
pass
# Release device on failure
if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device)
pager_active_device = None
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'})
except Exception as e:
# Kill orphaned rtl_fm process if it was started
try:
rtl_process.terminate()
rtl_process.wait(timeout=2)
except Exception:
try:
rtl_process.kill()
except Exception:
pass
# Release device on failure
if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device)
@@ -341,6 +454,10 @@ def stop_decoding() -> Response:
with app_module.process_lock:
if app_module.current_process:
# Signal audio relay thread to stop
if hasattr(app_module.current_process, '_stop_relay'):
app_module.current_process._stop_relay.set()
# Kill rtl_fm process first
if hasattr(app_module.current_process, '_rtl_process'):
try:
@@ -433,6 +550,10 @@ def stream() -> Response:
try:
msg = app_module.output_queue.get(timeout=1)
last_keepalive = time.time()
try:
process_event('pager', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg)
except queue.Empty:
now = time.time()
+109
View File
@@ -0,0 +1,109 @@
"""Session recording API endpoints."""
from __future__ import annotations
from pathlib import Path
from flask import Blueprint, jsonify, request, send_file
from utils.recording import get_recording_manager, RECORDING_ROOT
recordings_bp = Blueprint('recordings', __name__, url_prefix='/recordings')
@recordings_bp.route('/start', methods=['POST'])
def start_recording():
data = request.get_json() or {}
mode = (data.get('mode') or '').strip()
if not mode:
return jsonify({'status': 'error', 'message': 'mode is required'}), 400
label = data.get('label')
metadata = data.get('metadata') if isinstance(data.get('metadata'), dict) else {}
manager = get_recording_manager()
session = manager.start_recording(mode=mode, label=label, metadata=metadata)
return jsonify({
'status': 'success',
'session': {
'id': session.id,
'mode': session.mode,
'label': session.label,
'started_at': session.started_at.isoformat(),
'file_path': str(session.file_path),
}
})
@recordings_bp.route('/stop', methods=['POST'])
def stop_recording():
data = request.get_json() or {}
mode = data.get('mode')
session_id = data.get('id')
manager = get_recording_manager()
session = manager.stop_recording(mode=mode, session_id=session_id)
if not session:
return jsonify({'status': 'error', 'message': 'No active recording found'}), 404
return jsonify({
'status': 'success',
'session': {
'id': session.id,
'mode': session.mode,
'label': session.label,
'started_at': session.started_at.isoformat(),
'stopped_at': session.stopped_at.isoformat() if session.stopped_at else None,
'event_count': session.event_count,
'size_bytes': session.size_bytes,
'file_path': str(session.file_path),
}
})
@recordings_bp.route('', methods=['GET'])
def list_recordings():
manager = get_recording_manager()
limit = request.args.get('limit', default=50, type=int)
return jsonify({
'status': 'success',
'recordings': manager.list_recordings(limit=limit),
'active': manager.get_active(),
})
@recordings_bp.route('/<session_id>', methods=['GET'])
def get_recording(session_id: str):
manager = get_recording_manager()
rec = manager.get_recording(session_id)
if not rec:
return jsonify({'status': 'error', 'message': 'Recording not found'}), 404
return jsonify({'status': 'success', 'recording': rec})
@recordings_bp.route('/<session_id>/download', methods=['GET'])
def download_recording(session_id: str):
manager = get_recording_manager()
rec = manager.get_recording(session_id)
if not rec:
return 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
return send_file(
file_path,
mimetype='application/x-ndjson',
as_attachment=True,
download_name=file_path.name,
)
+41 -3
View File
@@ -18,7 +18,8 @@ from utils.validation import (
validate_frequency, validate_device_index, validate_gain, validate_ppm
)
from utils.sse import format_sse
from utils.process import safe_terminate, register_process
from utils.event_pipeline import process_event
from utils.process import safe_terminate, register_process, unregister_process
rtlamr_bp = Blueprint('rtlamr', __name__)
@@ -61,10 +62,37 @@ def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
except Exception as e:
app_module.rtlamr_queue.put({'type': 'error', 'text': str(e)})
finally:
process.wait()
global rtl_tcp_process, rtlamr_active_device
# Ensure rtlamr process is terminated
try:
process.terminate()
process.wait(timeout=2)
except Exception:
try:
process.kill()
except Exception:
pass
unregister_process(process)
# Kill companion rtl_tcp process
with rtl_tcp_lock:
if rtl_tcp_process:
try:
rtl_tcp_process.terminate()
rtl_tcp_process.wait(timeout=2)
except Exception:
try:
rtl_tcp_process.kill()
except Exception:
pass
unregister_process(rtl_tcp_process)
rtl_tcp_process = None
app_module.rtlamr_queue.put({'type': 'status', 'text': 'stopped'})
with app_module.rtlamr_lock:
app_module.rtlamr_process = None
# Release SDR device
if rtlamr_active_device is not None:
app_module.release_sdr_device(rtlamr_active_device)
rtlamr_active_device = None
@rtlamr_bp.route('/start_rtlamr', methods=['POST'])
@@ -133,7 +161,8 @@ def start_rtlamr() -> Response:
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
register_process(rtl_tcp_process)
# Wait a moment for rtl_tcp to start
time.sleep(3)
@@ -141,6 +170,10 @@ def start_rtlamr() -> Response:
app_module.rtlamr_queue.put({'type': 'info', 'text': f'rtl_tcp: {" ".join(rtl_tcp_cmd)}'})
except Exception as e:
logger.error(f"Failed to start rtl_tcp: {e}")
# Release SDR device on rtl_tcp failure
if rtlamr_active_device is not None:
app_module.release_sdr_device(rtlamr_active_device)
rtlamr_active_device = None
return jsonify({'status': 'error', 'message': f'Failed to start rtl_tcp: {e}'}), 500
# Build rtlamr command
@@ -174,6 +207,7 @@ def start_rtlamr() -> Response:
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
register_process(app_module.rtlamr_process)
# Start output thread
thread = threading.Thread(target=stream_rtlamr_output, args=(app_module.rtlamr_process,))
@@ -262,6 +296,10 @@ def stream_rtlamr() -> Response:
try:
msg = app_module.rtlamr_queue.get(timeout=1)
last_keepalive = time.time()
try:
process_event('rtlamr', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg)
except queue.Empty:
now = time.time()
+23 -23
View File
@@ -11,9 +11,9 @@ from urllib.parse import urlparse
import requests
from flask import Blueprint, jsonify, request, render_template, Response
from config import SHARED_OBSERVER_LOCATION_ENABLED
from flask import Blueprint, jsonify, request, render_template, Response
from config import SHARED_OBSERVER_LOCATION_ENABLED
from data.satellites import TLE_SATELLITES
from utils.logging import satellite_logger as logger
@@ -42,30 +42,30 @@ def _fetch_iss_realtime(observer_lat: Optional[float] = None, observer_lon: Opti
iss_alt = 420 # Default altitude in km
source = None
# Try primary API: Open Notify
# Try primary API: Where The ISS At
try:
response = requests.get('http://api.open-notify.org/iss-now.json', timeout=5)
response = requests.get('https://api.wheretheiss.at/v1/satellites/25544', timeout=5)
if response.status_code == 200:
data = response.json()
if data.get('message') == 'success':
iss_lat = float(data['iss_position']['latitude'])
iss_lon = float(data['iss_position']['longitude'])
source = 'open-notify'
iss_lat = float(data['latitude'])
iss_lon = float(data['longitude'])
iss_alt = float(data.get('altitude', 420))
source = 'wheretheiss'
except Exception as e:
logger.debug(f"Open Notify API failed: {e}")
logger.debug(f"Where The ISS At API failed: {e}")
# Try fallback API: Where The ISS At
# Try fallback API: Open Notify
if iss_lat is None:
try:
response = requests.get('https://api.wheretheiss.at/v1/satellites/25544', timeout=5)
response = requests.get('http://api.open-notify.org/iss-now.json', timeout=5)
if response.status_code == 200:
data = response.json()
iss_lat = float(data['latitude'])
iss_lon = float(data['longitude'])
iss_alt = float(data.get('altitude', 420))
source = 'wheretheiss'
if data.get('message') == 'success':
iss_lat = float(data['iss_position']['latitude'])
iss_lon = float(data['iss_position']['longitude'])
source = 'open-notify'
except Exception as e:
logger.debug(f"Where The ISS At API failed: {e}")
logger.debug(f"Open Notify API failed: {e}")
if iss_lat is None:
return None
@@ -120,12 +120,12 @@ def _fetch_iss_realtime(observer_lat: Optional[float] = None, observer_lon: Opti
@satellite_bp.route('/dashboard')
def satellite_dashboard():
"""Popout satellite tracking dashboard."""
return render_template(
'satellite_dashboard.html',
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
)
def satellite_dashboard():
"""Popout satellite tracking dashboard."""
return render_template(
'satellite_dashboard.html',
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
)
@satellite_bp.route('/predict', methods=['POST'])
+40 -2
View File
@@ -19,7 +19,8 @@ from utils.validation import (
validate_rtl_tcp_host, validate_rtl_tcp_port
)
from utils.sse import format_sse
from utils.process import safe_terminate, register_process
from utils.event_pipeline import process_event
from utils.process import safe_terminate, register_process, unregister_process
from utils.sdr import SDRFactory, SDRType
sensor_bp = Blueprint('sensor', __name__)
@@ -44,6 +45,21 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
data['type'] = 'sensor'
app_module.sensor_queue.put(data)
# Push scope event when signal level data is present
rssi = data.get('rssi')
snr = data.get('snr')
noise = data.get('noise')
if rssi is not None or snr is not None:
try:
app_module.sensor_queue.put_nowait({
'type': 'scope',
'rssi': rssi if rssi is not None else 0,
'snr': snr if snr is not None else 0,
'noise': noise if noise is not None else 0,
})
except queue.Full:
pass
# Log if enabled
if app_module.logging_enabled:
try:
@@ -59,10 +75,24 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
except Exception as e:
app_module.sensor_queue.put({'type': 'error', 'text': str(e)})
finally:
process.wait()
global sensor_active_device
# Ensure process is terminated
try:
process.terminate()
process.wait(timeout=2)
except Exception:
try:
process.kill()
except Exception:
pass
unregister_process(process)
app_module.sensor_queue.put({'type': 'status', 'text': 'stopped'})
with app_module.sensor_lock:
app_module.sensor_process = None
# Release SDR device
if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device)
sensor_active_device = None
@sensor_bp.route('/start_sensor', methods=['POST'])
@@ -143,12 +173,16 @@ def start_sensor() -> Response:
full_cmd = ' '.join(cmd)
logger.info(f"Running: {full_cmd}")
# Add signal level metadata so the frontend scope can display RSSI/SNR
cmd.extend(['-M', 'level'])
try:
app_module.sensor_process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
register_process(app_module.sensor_process)
# Start output thread
thread = threading.Thread(target=stream_sensor_output, args=(app_module.sensor_process,))
@@ -218,6 +252,10 @@ def stream_sensor() -> Response:
try:
msg = app_module.sensor_queue.get(timeout=1)
last_keepalive = time.time()
try:
process_event('sensor', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg)
except queue.Empty:
now = time.time()
+129 -33
View File
@@ -13,14 +13,14 @@ from typing import Generator
from flask import Blueprint, jsonify, request, Response, send_file
import app as app_module
from utils.logging import get_logger
from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.sstv import (
get_sstv_decoder,
is_sstv_available,
ISS_SSTV_FREQ,
DecodeProgress,
DopplerInfo,
)
logger = get_logger('intercept.sstv')
@@ -30,15 +30,18 @@ sstv_bp = Blueprint('sstv', __name__, url_prefix='/sstv')
# Queue for SSE progress streaming
_sstv_queue: queue.Queue = queue.Queue(maxsize=100)
# Track which device is being used
sstv_active_device: int | None = None
def _progress_callback(progress: DecodeProgress) -> None:
"""Callback to queue progress updates for SSE stream."""
def _progress_callback(data: dict) -> None:
"""Callback to queue progress/scope updates for SSE stream."""
try:
_sstv_queue.put_nowait(progress.to_dict())
_sstv_queue.put_nowait(data)
except queue.Full:
try:
_sstv_queue.get_nowait()
_sstv_queue.put_nowait(progress.to_dict())
_sstv_queue.put_nowait(data)
except queue.Empty:
pass
@@ -94,7 +97,7 @@ def start_decoder():
if not is_sstv_available():
return jsonify({
'status': 'error',
'message': 'SSTV decoder not available. Install slowrx: apt install slowrx'
'message': 'SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow'
}), 400
decoder = get_sstv_decoder()
@@ -158,6 +161,17 @@ def start_decoder():
latitude = None
longitude = None
# Claim SDR device
global sstv_active_device
device_int = int(device_index)
error = app_module.claim_sdr_device(device_int, 'sstv')
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
# Set callback and start
decoder.set_callback(_progress_callback)
success = decoder.start(
@@ -168,6 +182,8 @@ def start_decoder():
)
if success:
sstv_active_device = device_int
result = {
'status': 'started',
'frequency': frequency,
@@ -181,6 +197,8 @@ def start_decoder():
return jsonify(result)
else:
# Release device on failure
app_module.release_sdr_device(device_int)
return jsonify({
'status': 'error',
'message': 'Failed to start decoder'
@@ -195,8 +213,15 @@ def stop_decoder():
Returns:
JSON confirmation.
"""
global sstv_active_device
decoder = get_sstv_decoder()
decoder.stop()
# Release device from registry
if sstv_active_device is not None:
app_module.release_sdr_device(sstv_active_device)
sstv_active_device = None
return jsonify({'status': 'stopped'})
@@ -287,6 +312,73 @@ def get_image(filename: str):
return send_file(image_path, mimetype='image/png')
@sstv_bp.route('/images/<filename>/download')
def download_image(filename: str):
"""
Download a decoded SSTV image file.
Args:
filename: Image filename
Returns:
Image file as attachment or 404.
"""
decoder = get_sstv_decoder()
# Security: only allow alphanumeric filenames with .png extension
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
if not filename.endswith('.png'):
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
image_path = decoder._output_dir / filename
if not image_path.exists():
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
return send_file(image_path, mimetype='image/png', as_attachment=True, download_name=filename)
@sstv_bp.route('/images/<filename>', methods=['DELETE'])
def delete_image(filename: str):
"""
Delete a decoded SSTV image.
Args:
filename: Image filename
Returns:
JSON confirmation.
"""
decoder = get_sstv_decoder()
# Security: only allow alphanumeric filenames with .png extension
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
if not filename.endswith('.png'):
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
if decoder.delete_image(filename):
return jsonify({'status': 'ok'})
else:
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
@sstv_bp.route('/images', methods=['DELETE'])
def delete_all_images():
"""
Delete all decoded SSTV images.
Returns:
JSON with count of deleted images.
"""
decoder = get_sstv_decoder()
count = decoder.delete_all_images()
return jsonify({'status': 'ok', 'deleted': count})
@sstv_bp.route('/stream')
def stream_progress():
"""
@@ -308,6 +400,10 @@ def stream_progress():
try:
progress = _sstv_queue.get(timeout=1)
last_keepalive = time.time()
try:
process_event('sstv', progress, progress.get('type'))
except Exception:
pass
yield format_sse(progress)
except queue.Empty:
now = time.time()
@@ -467,7 +563,32 @@ def iss_position():
observer_lat = request.args.get('latitude', type=float)
observer_lon = request.args.get('longitude', type=float)
# Try primary API: Open Notify
# Try primary API: Where The ISS At
try:
response = requests.get('https://api.wheretheiss.at/v1/satellites/25544', timeout=5)
if response.status_code == 200:
data = response.json()
iss_lat = float(data['latitude'])
iss_lon = float(data['longitude'])
result = {
'status': 'ok',
'lat': iss_lat,
'lon': iss_lon,
'altitude': float(data.get('altitude', 420)),
'timestamp': datetime.utcnow().isoformat(),
'source': 'wheretheiss'
}
# Calculate observer-relative data if location provided
if observer_lat is not None and observer_lon is not None:
result.update(_calculate_observer_data(iss_lat, iss_lon, observer_lat, observer_lon))
return jsonify(result)
except Exception as e:
logger.warning(f"Where The ISS At API failed: {e}")
# Try fallback API: Open Notify
try:
response = requests.get('http://api.open-notify.org/iss-now.json', timeout=5)
if response.status_code == 200:
@@ -493,31 +614,6 @@ def iss_position():
except Exception as e:
logger.warning(f"Open Notify API failed: {e}")
# Try fallback API: Where The ISS At
try:
response = requests.get('https://api.wheretheiss.at/v1/satellites/25544', timeout=5)
if response.status_code == 200:
data = response.json()
iss_lat = float(data['latitude'])
iss_lon = float(data['longitude'])
result = {
'status': 'ok',
'lat': iss_lat,
'lon': iss_lon,
'altitude': float(data.get('altitude', 420)),
'timestamp': datetime.utcnow().isoformat(),
'source': 'wheretheiss'
}
# Calculate observer-relative data if location provided
if observer_lat is not None and observer_lon is not None:
result.update(_calculate_observer_data(iss_lat, iss_lon, observer_lat, observer_lon))
return jsonify(result)
except Exception as e:
logger.warning(f"Where The ISS At API failed: {e}")
# Both APIs failed
return jsonify({
'status': 'error',
+338
View File
@@ -0,0 +1,338 @@
"""General SSTV (Slow-Scan Television) decoder routes.
Provides endpoints for decoding terrestrial SSTV images on common HF/VHF/UHF
frequencies used by amateur radio operators worldwide.
"""
from __future__ import annotations
import queue
import time
from collections.abc import Generator
from pathlib import Path
from flask import Blueprint, Response, jsonify, request, send_file
from utils.logging import get_logger
from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.sstv import (
get_general_sstv_decoder,
)
logger = get_logger('intercept.sstv_general')
sstv_general_bp = Blueprint('sstv_general', __name__, url_prefix='/sstv-general')
# Queue for SSE progress streaming
_sstv_general_queue: queue.Queue = queue.Queue(maxsize=100)
# Predefined 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.730, 'modulation': 'lsb', 'notes': 'Europe primary (analog/digital variants)', 'type': 'Terrestrial HF'},
{'band': '40 m', 'frequency': 7.171, 'modulation': 'lsb', 'notes': 'Common international/US/EU SSTV activity', 'type': 'Terrestrial HF'},
{'band': '40 m', 'frequency': 7.040, 'modulation': 'lsb', 'notes': 'Alternative US/Europe calling', 'type': 'Terrestrial HF'},
{'band': '30 m', 'frequency': 10.132, 'modulation': 'usb', 'notes': 'Narrowband SSTV (e.g., MP73-N digital)', 'type': 'Terrestrial HF'},
{'band': '20 m', 'frequency': 14.230, 'modulation': 'usb', 'notes': 'Most popular international SSTV frequency', 'type': 'Terrestrial HF'},
{'band': '20 m', 'frequency': 14.233, 'modulation': 'usb', 'notes': 'Digital SSTV calling / alternative activity', 'type': 'Terrestrial HF'},
{'band': '20 m', 'frequency': 14.240, 'modulation': 'usb', 'notes': 'Europe alternative', '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': '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': '70 cm', 'frequency': 433.775, 'modulation': 'fm', 'notes': 'Australia/common simplex', 'type': 'Terrestrial UHF'},
]
# Build a lookup for auto-detecting modulation from frequency
_FREQ_MODULATION_MAP = {entry['frequency']: entry['modulation'] for entry in SSTV_FREQUENCIES}
def _progress_callback(data: dict) -> None:
"""Callback to queue progress/scope updates for SSE stream."""
try:
_sstv_general_queue.put_nowait(data)
except queue.Full:
try:
_sstv_general_queue.get_nowait()
_sstv_general_queue.put_nowait(data)
except queue.Empty:
pass
@sstv_general_bp.route('/frequencies')
def get_frequencies():
"""Return the predefined SSTV frequency table."""
return jsonify({
'status': 'ok',
'frequencies': SSTV_FREQUENCIES,
})
@sstv_general_bp.route('/status')
def get_status():
"""Get general SSTV decoder status."""
decoder = get_general_sstv_decoder()
return jsonify({
'available': decoder.decoder_available is not None,
'decoder': decoder.decoder_available,
'running': decoder.is_running,
'image_count': len(decoder.get_images()),
})
@sstv_general_bp.route('/start', methods=['POST'])
def start_decoder():
"""
Start general SSTV decoder.
JSON body:
{
"frequency": 14.230, // Frequency in MHz (required)
"modulation": "usb", // fm, usb, or lsb (auto-detected from frequency table if omitted)
"device": 0 // RTL-SDR device index
}
"""
decoder = get_general_sstv_decoder()
if decoder.decoder_available is None:
return jsonify({
'status': 'error',
'message': 'SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow',
}), 400
if decoder.is_running:
return jsonify({
'status': 'already_running',
})
# Clear queue
while not _sstv_general_queue.empty():
try:
_sstv_general_queue.get_nowait()
except queue.Empty:
break
data = request.get_json(silent=True) or {}
frequency = data.get('frequency')
modulation = data.get('modulation')
device_index = data.get('device', 0)
# Validate frequency
if frequency is None:
return jsonify({
'status': 'error',
'message': 'Frequency is required',
}), 400
try:
frequency = float(frequency)
if not (1 <= frequency <= 500):
return jsonify({
'status': 'error',
'message': 'Frequency must be between 1-500 MHz (HF requires upconverter for RTL-SDR)',
}), 400
except (TypeError, ValueError):
return jsonify({
'status': 'error',
'message': 'Invalid frequency',
}), 400
# Auto-detect modulation from frequency table if not specified
if not modulation:
modulation = _FREQ_MODULATION_MAP.get(frequency, 'usb')
# Validate modulation
if modulation not in ('fm', 'usb', 'lsb'):
return jsonify({
'status': 'error',
'message': 'Modulation must be fm, usb, or lsb',
}), 400
# Set callback and start
decoder.set_callback(_progress_callback)
success = decoder.start(
frequency=frequency,
device_index=device_index,
modulation=modulation,
)
if success:
return jsonify({
'status': 'started',
'frequency': frequency,
'modulation': modulation,
'device': device_index,
})
else:
return jsonify({
'status': 'error',
'message': 'Failed to start decoder',
}), 500
@sstv_general_bp.route('/stop', methods=['POST'])
def stop_decoder():
"""Stop general SSTV decoder."""
decoder = get_general_sstv_decoder()
decoder.stop()
return jsonify({'status': 'stopped'})
@sstv_general_bp.route('/images')
def list_images():
"""Get list of decoded SSTV images."""
decoder = get_general_sstv_decoder()
images = decoder.get_images()
limit = request.args.get('limit', type=int)
if limit and limit > 0:
images = images[-limit:]
return jsonify({
'status': 'ok',
'images': [img.to_dict() for img in images],
'count': len(images),
})
@sstv_general_bp.route('/images/<filename>')
def get_image(filename: str):
"""Get a decoded SSTV image file."""
decoder = get_general_sstv_decoder()
# Security: only allow alphanumeric filenames with .png extension
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
if not filename.endswith('.png'):
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
image_path = decoder._output_dir / filename
if not image_path.exists():
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
return send_file(image_path, mimetype='image/png')
@sstv_general_bp.route('/images/<filename>/download')
def download_image(filename: str):
"""Download a decoded SSTV image file."""
decoder = get_general_sstv_decoder()
# Security: only allow alphanumeric filenames with .png extension
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
if not filename.endswith('.png'):
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
image_path = decoder._output_dir / filename
if not image_path.exists():
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
return send_file(image_path, mimetype='image/png', as_attachment=True, download_name=filename)
@sstv_general_bp.route('/images/<filename>', methods=['DELETE'])
def delete_image(filename: str):
"""Delete a decoded SSTV image."""
decoder = get_general_sstv_decoder()
# Security: only allow alphanumeric filenames with .png extension
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
if not filename.endswith('.png'):
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
if decoder.delete_image(filename):
return jsonify({'status': 'ok'})
else:
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
@sstv_general_bp.route('/images', methods=['DELETE'])
def delete_all_images():
"""Delete all decoded SSTV images."""
decoder = get_general_sstv_decoder()
count = decoder.delete_all_images()
return jsonify({'status': 'ok', 'deleted': count})
@sstv_general_bp.route('/stream')
def stream_progress():
"""SSE stream of SSTV decode progress."""
def generate() -> Generator[str, None, None]:
last_keepalive = time.time()
keepalive_interval = 30.0
while True:
try:
progress = _sstv_general_queue.get(timeout=1)
last_keepalive = time.time()
try:
process_event('sstv_general', progress, progress.get('type'))
except Exception:
pass
yield format_sse(progress)
except queue.Empty:
now = time.time()
if now - last_keepalive >= keepalive_interval:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response
@sstv_general_bp.route('/decode-file', methods=['POST'])
def decode_file():
"""Decode SSTV from an uploaded audio file."""
if 'audio' not in request.files:
return jsonify({
'status': 'error',
'message': 'No audio file provided',
}), 400
audio_file = request.files['audio']
if not audio_file.filename:
return jsonify({
'status': 'error',
'message': 'No file selected',
}), 400
import tempfile
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp:
audio_file.save(tmp.name)
tmp_path = tmp.name
try:
decoder = get_general_sstv_decoder()
images = decoder.decode_file(tmp_path)
return jsonify({
'status': 'ok',
'images': [img.to_dict() for img in images],
'count': len(images),
})
except Exception as e:
logger.error(f"Error decoding file: {e}")
return jsonify({
'status': 'error',
'message': str(e),
}), 500
finally:
try:
Path(tmp_path).unlink()
except Exception:
pass
+3958 -3290
View File
File diff suppressed because it is too large Load Diff
+42 -2
View File
@@ -2,14 +2,15 @@
from __future__ import annotations
from flask import Blueprint, jsonify, request, Response
from flask import Blueprint, Response, jsonify, request
from utils.logging import get_logger
from utils.updater import (
check_for_updates,
get_update_status,
dismiss_update,
get_update_status,
perform_update,
restart_application,
)
logger = get_logger('intercept.routes.updater')
@@ -137,3 +138,42 @@ def dismiss_notification() -> Response:
'success': False,
'error': str(e)
}), 500
@updater_bp.route('/restart', methods=['POST'])
def restart_app() -> Response:
"""
Restart the application.
This endpoint triggers a graceful restart of the application:
1. Stops all running decoder processes
2. Cleans up global state
3. Replaces the current process with a fresh instance
The response may not be received by the client since the process
is replaced immediately. Clients should poll /health until the
server responds again.
Returns:
JSON with restart status (may not be delivered)
"""
import threading
logger.info("Restart requested via API")
# Send response before restarting
# Use a short delay to allow the response to be sent
def delayed_restart():
import time
time.sleep(0.5) # Allow response to be sent
restart_application()
# Start restart in a background thread so we can return a response
restart_thread = threading.Thread(target=delayed_restart, daemon=False)
restart_thread.start()
return jsonify({
'success': True,
'message': 'Application is restarting. Please wait...',
'action': 'restart'
})
+326
View File
@@ -0,0 +1,326 @@
"""WebSocket-based waterfall streaming with I/Q capture and server-side FFT."""
import json
import subprocess
import threading
import time
from flask import Flask
try:
from flask_sock import Sock
WEBSOCKET_AVAILABLE = True
except ImportError:
WEBSOCKET_AVAILABLE = False
Sock = None
from utils.logging import get_logger
from utils.process import safe_terminate, register_process, unregister_process
from utils.waterfall_fft import (
build_binary_frame,
compute_power_spectrum,
cu8_to_complex,
quantize_to_uint8,
)
from utils.sdr import SDRFactory, SDRType
from utils.sdr.base import SDRCapabilities, SDRDevice
logger = get_logger('intercept.waterfall_ws')
# Maximum bandwidth per SDR type (Hz)
MAX_BANDWIDTH = {
SDRType.RTL_SDR: 2400000,
SDRType.HACKRF: 20000000,
SDRType.LIME_SDR: 20000000,
SDRType.AIRSPY: 10000000,
SDRType.SDRPLAY: 2000000,
}
def _resolve_sdr_type(sdr_type_str: str) -> SDRType:
"""Convert client sdr_type string to SDRType enum."""
mapping = {
'rtlsdr': SDRType.RTL_SDR,
'rtl_sdr': SDRType.RTL_SDR,
'hackrf': SDRType.HACKRF,
'limesdr': SDRType.LIME_SDR,
'lime_sdr': SDRType.LIME_SDR,
'airspy': SDRType.AIRSPY,
'sdrplay': SDRType.SDRPLAY,
}
return mapping.get(sdr_type_str.lower(), SDRType.RTL_SDR)
def _build_dummy_device(device_index: int, sdr_type: SDRType) -> SDRDevice:
"""Build a minimal SDRDevice for command building."""
builder = SDRFactory.get_builder(sdr_type)
caps = builder.get_capabilities()
return SDRDevice(
sdr_type=sdr_type,
index=device_index,
name=f'{sdr_type.value}-{device_index}',
serial='N/A',
driver=sdr_type.value,
capabilities=caps,
)
def init_waterfall_websocket(app: Flask):
"""Initialize WebSocket waterfall streaming."""
if not WEBSOCKET_AVAILABLE:
logger.warning("flask-sock not installed, WebSocket waterfall disabled")
return
sock = Sock(app)
@sock.route('/ws/waterfall')
def waterfall_stream(ws):
"""WebSocket endpoint for real-time waterfall streaming."""
logger.info("WebSocket waterfall client connected")
# Import app module for device claiming
import app as app_module
iq_process = None
reader_thread = None
stop_event = threading.Event()
claimed_device = None
try:
while True:
try:
msg = ws.receive(timeout=0.1)
except TimeoutError:
if stop_event.is_set():
break
continue
except Exception as e:
if "closed" in str(e).lower():
break
if "timed out" not in str(e).lower():
logger.error(f"WebSocket receive error: {e}")
continue
if msg is None:
break
try:
data = json.loads(msg)
except (json.JSONDecodeError, TypeError):
continue
cmd = data.get('cmd')
if cmd == 'start':
# Stop any existing capture
stop_event.set()
if reader_thread and reader_thread.is_alive():
reader_thread.join(timeout=2)
if iq_process:
safe_terminate(iq_process)
unregister_process(iq_process)
iq_process = None
if claimed_device is not None:
app_module.release_sdr_device(claimed_device)
claimed_device = None
stop_event.clear()
# Parse config
center_freq = float(data.get('center_freq', 100.0))
span_mhz = float(data.get('span_mhz', 2.0))
gain = data.get('gain')
if gain is not None:
gain = float(gain)
device_index = int(data.get('device', 0))
sdr_type_str = data.get('sdr_type', 'rtlsdr')
fft_size = int(data.get('fft_size', 1024))
fps = int(data.get('fps', 25))
avg_count = int(data.get('avg_count', 4))
ppm = data.get('ppm')
if ppm is not None:
ppm = int(ppm)
bias_t = bool(data.get('bias_t', False))
# Clamp FFT size to valid powers of 2
fft_size = max(256, min(8192, fft_size))
# Resolve SDR type and bandwidth
sdr_type = _resolve_sdr_type(sdr_type_str)
max_bw = MAX_BANDWIDTH.get(sdr_type, 2400000)
span_hz = int(span_mhz * 1e6)
sample_rate = min(span_hz, max_bw)
# Compute effective frequency range
effective_span_mhz = sample_rate / 1e6
start_freq = center_freq - effective_span_mhz / 2
end_freq = center_freq + effective_span_mhz / 2
# Claim the device
claim_err = app_module.claim_sdr_device(device_index, 'waterfall')
if claim_err:
ws.send(json.dumps({
'status': 'error',
'message': claim_err,
'error_type': 'DEVICE_BUSY',
}))
continue
claimed_device = device_index
# Build I/Q capture command
try:
builder = SDRFactory.get_builder(sdr_type)
device = _build_dummy_device(device_index, sdr_type)
iq_cmd = builder.build_iq_capture_command(
device=device,
frequency_mhz=center_freq,
sample_rate=sample_rate,
gain=gain,
ppm=ppm,
bias_t=bias_t,
)
except NotImplementedError as e:
app_module.release_sdr_device(device_index)
claimed_device = None
ws.send(json.dumps({
'status': 'error',
'message': str(e),
}))
continue
# Spawn I/Q capture process
try:
logger.info(
f"Starting I/Q capture: {center_freq} MHz, "
f"span={effective_span_mhz:.1f} MHz, "
f"sr={sample_rate}, fft={fft_size}"
)
iq_process = subprocess.Popen(
iq_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
bufsize=0,
)
register_process(iq_process)
# Brief check that process started
time.sleep(0.2)
if iq_process.poll() is not None:
raise RuntimeError("I/Q capture process exited immediately")
except Exception as e:
logger.error(f"Failed to start I/Q capture: {e}")
if iq_process:
safe_terminate(iq_process)
unregister_process(iq_process)
iq_process = None
app_module.release_sdr_device(device_index)
claimed_device = None
ws.send(json.dumps({
'status': 'error',
'message': f'Failed to start I/Q capture: {e}',
}))
continue
# Send started confirmation
ws.send(json.dumps({
'status': 'started',
'start_freq': start_freq,
'end_freq': end_freq,
'fft_size': fft_size,
'sample_rate': sample_rate,
}))
# Start reader thread
def fft_reader(
proc, ws_ref, stop_evt,
_fft_size, _avg_count, _fps,
_start_freq, _end_freq,
):
"""Read I/Q from subprocess, compute FFT, send binary frames."""
bytes_per_frame = _fft_size * _avg_count * 2
frame_interval = 1.0 / _fps
try:
while not stop_evt.is_set():
if proc.poll() is not None:
break
frame_start = time.monotonic()
# Read raw I/Q bytes
raw = b''
remaining = bytes_per_frame
while remaining > 0 and not stop_evt.is_set():
chunk = proc.stdout.read(min(remaining, 65536))
if not chunk:
break
raw += chunk
remaining -= len(chunk)
if len(raw) < _fft_size * 2:
break
# Process FFT pipeline
samples = cu8_to_complex(raw)
power_db = compute_power_spectrum(
samples,
fft_size=_fft_size,
avg_count=_avg_count,
)
quantized = quantize_to_uint8(power_db)
frame = build_binary_frame(
_start_freq, _end_freq, quantized,
)
try:
ws_ref.send(frame)
except Exception:
break
# Pace to target FPS
elapsed = time.monotonic() - frame_start
sleep_time = frame_interval - elapsed
if sleep_time > 0:
stop_evt.wait(sleep_time)
except Exception as e:
logger.debug(f"FFT reader stopped: {e}")
reader_thread = threading.Thread(
target=fft_reader,
args=(
iq_process, ws, stop_event,
fft_size, avg_count, fps,
start_freq, end_freq,
),
daemon=True,
)
reader_thread.start()
elif cmd == 'stop':
stop_event.set()
if reader_thread and reader_thread.is_alive():
reader_thread.join(timeout=2)
reader_thread = None
if iq_process:
safe_terminate(iq_process)
unregister_process(iq_process)
iq_process = None
if claimed_device is not None:
app_module.release_sdr_device(claimed_device)
claimed_device = None
stop_event.clear()
ws.send(json.dumps({'status': 'stopped'}))
except Exception as e:
logger.info(f"WebSocket waterfall closed: {e}")
finally:
# Cleanup
stop_event.set()
if reader_thread and reader_thread.is_alive():
reader_thread.join(timeout=2)
if iq_process:
safe_terminate(iq_process)
unregister_process(iq_process)
if claimed_device is not None:
app_module.release_sdr_device(claimed_device)
logger.info("WebSocket waterfall client disconnected")
+504
View File
@@ -0,0 +1,504 @@
"""HF/Shortwave WebSDR Integration - KiwiSDR network access."""
from __future__ import annotations
import json
import math
import queue
import re
import struct
import threading
import time
from typing import Optional
from flask import Blueprint, Flask, jsonify, request, Response
try:
from flask_sock import Sock
WEBSOCKET_AVAILABLE = True
except ImportError:
WEBSOCKET_AVAILABLE = False
from utils.kiwisdr import KiwiSDRClient, KIWI_SAMPLE_RATE, VALID_MODES, parse_host_port
from utils.logging import get_logger
logger = get_logger('intercept.websdr')
websdr_bp = Blueprint('websdr', __name__, url_prefix='/websdr')
# ============================================
# RECEIVER CACHE
# ============================================
_receiver_cache: list[dict] = []
_cache_lock = threading.Lock()
_cache_timestamp: float = 0
CACHE_TTL = 3600 # 1 hour
def _parse_gps_coord(coord_str: str) -> Optional[float]:
"""Parse a GPS coordinate string like '51.5074' or '(-33.87)' into a float."""
if not coord_str:
return None
# Remove parentheses and whitespace
cleaned = coord_str.strip().strip('()').strip()
try:
return float(cleaned)
except (ValueError, TypeError):
return None
def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Calculate distance in km between two GPS coordinates."""
R = 6371 # Earth radius in km
dlat = math.radians(lat2 - lat1)
dlon = math.radians(lon2 - lon1)
a = (math.sin(dlat / 2) ** 2 +
math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) *
math.sin(dlon / 2) ** 2)
c = 2 * math.asin(math.sqrt(a))
return R * c
KIWI_DATA_URLS = [
'https://rx.skywavelinux.com/kiwisdr_com.js',
'http://rx.linkfanel.net/kiwisdr_com.js',
]
def _fetch_kiwi_receivers() -> list[dict]:
"""Fetch the KiwiSDR receiver list from the public directory."""
import urllib.request
import json
receivers = []
raw = None
# Try each data source until one works
for data_url in KIWI_DATA_URLS:
try:
req = urllib.request.Request(data_url, headers={
'User-Agent': 'INTERCEPT-SIGINT/1.0',
})
with urllib.request.urlopen(req, timeout=20) as resp:
raw = resp.read().decode('utf-8', errors='replace')
if raw and len(raw) > 100:
logger.info(f"Fetched KiwiSDR data from {data_url}")
break
raw = None
except Exception as e:
logger.warning(f"Failed to fetch from {data_url}: {e}")
continue
if not raw:
logger.error("All KiwiSDR data sources failed")
return receivers
# The JS file contains: var kiwisdr_com = [ {...}, {...}, ... ];
# Extract the JSON array
match = re.search(r'var\s+kiwisdr_com\s*=\s*(\[.*\])\s*;?', raw, re.DOTALL)
if not match:
# Try bare array
match = re.search(r'(\[\s*\{.*\}\s*\])', raw, re.DOTALL)
if not match:
logger.warning("Could not find receiver array in KiwiSDR data")
return receivers
arr_str = match.group(1)
# Parse JSON
try:
raw_list = json.loads(arr_str)
except json.JSONDecodeError:
# Fix common JS → JSON issues (trailing commas)
fixed = re.sub(r',\s*}', '}', arr_str)
fixed = re.sub(r',\s*]', ']', fixed)
try:
raw_list = json.loads(fixed)
except json.JSONDecodeError:
logger.error("Failed to parse KiwiSDR JSON")
return receivers
for entry in raw_list:
if not isinstance(entry, dict):
continue
# Skip offline receivers
if entry.get('offline') == 'yes' or entry.get('status') != 'active':
continue
name = entry.get('name', 'Unknown')
url = entry.get('url', '')
gps = entry.get('gps', '')
antenna = entry.get('antenna', '')
location = entry.get('loc', '')
# Parse users (strings in actual data)
try:
users = int(entry.get('users', 0))
except (ValueError, TypeError):
users = 0
try:
users_max = int(entry.get('users_max', 4))
except (ValueError, TypeError):
users_max = 4
# Parse bands field: "0-30000000" (Hz) → freq_lo/freq_hi in kHz
bands_str = entry.get('bands', '0-30000000')
freq_lo = 0
freq_hi = 30000
if bands_str and '-' in str(bands_str):
try:
parts = str(bands_str).split('-')
freq_lo = int(parts[0]) / 1000 # Hz to kHz
freq_hi = int(parts[1]) / 1000 # Hz to kHz
except (ValueError, IndexError):
pass
# Parse GPS: "(51.317266, -2.950479)" format
lat, lon = None, None
if gps:
parts = str(gps).replace('(', '').replace(')', '').split(',')
if len(parts) >= 2:
lat = _parse_gps_coord(parts[0])
lon = _parse_gps_coord(parts[1])
if not url:
continue
# Ensure URL has protocol
if not url.startswith('http'):
url = 'http://' + url
receivers.append({
'name': name,
'url': url.rstrip('/'),
'lat': lat,
'lon': lon,
'location': location,
'users': users,
'users_max': users_max,
'antenna': antenna,
'bands': bands_str,
'freq_lo': freq_lo,
'freq_hi': freq_hi,
'available': users < users_max,
})
return receivers
def get_receivers(force_refresh: bool = False) -> list[dict]:
"""Get cached receiver list, refreshing if stale."""
global _receiver_cache, _cache_timestamp
with _cache_lock:
now = time.time()
if force_refresh or not _receiver_cache or (now - _cache_timestamp) > CACHE_TTL:
logger.info("Refreshing KiwiSDR receiver list...")
_receiver_cache = _fetch_kiwi_receivers()
_cache_timestamp = now
logger.info(f"Loaded {len(_receiver_cache)} KiwiSDR receivers")
return _receiver_cache
# ============================================
# API ENDPOINTS
# ============================================
@websdr_bp.route('/receivers')
def list_receivers() -> Response:
"""List KiwiSDR receivers, with optional filters."""
freq_khz = request.args.get('freq_khz', type=float)
available = request.args.get('available', type=str)
refresh = request.args.get('refresh', type=str)
receivers = get_receivers(force_refresh=(refresh == 'true'))
filtered = receivers
if available == 'true':
filtered = [r for r in filtered if r.get('available', True)]
if freq_khz is not None:
filtered = [
r for r in filtered
if r.get('freq_lo', 0) <= freq_khz <= r.get('freq_hi', 30000)
]
return jsonify({
'status': 'success',
'receivers': filtered[:100],
'total': len(filtered),
'cached_total': len(receivers),
})
@websdr_bp.route('/receivers/nearest')
def nearest_receivers() -> Response:
"""Find receivers nearest to a given location."""
lat = request.args.get('lat', type=float)
lon = request.args.get('lon', type=float)
freq_khz = request.args.get('freq_khz', type=float)
if lat is None or lon is None:
return jsonify({'status': 'error', 'message': 'lat and lon are required'}), 400
receivers = get_receivers()
# Filter by frequency if specified
if freq_khz is not None:
receivers = [
r for r in receivers
if r.get('freq_lo', 0) <= freq_khz <= r.get('freq_hi', 30000)
]
# Calculate distances and sort
with_distance = []
for r in receivers:
if r.get('lat') is not None and r.get('lon') is not None:
dist = _haversine(lat, lon, r['lat'], r['lon'])
entry = dict(r)
entry['distance_km'] = round(dist, 1)
with_distance.append(entry)
with_distance.sort(key=lambda x: x['distance_km'])
return jsonify({
'status': 'success',
'receivers': with_distance[:10],
})
@websdr_bp.route('/spy-station/<station_id>/receivers')
def spy_station_receivers(station_id: str) -> Response:
"""Find receivers that can tune to a spy station's frequency."""
try:
from routes.spy_stations import STATIONS
except ImportError:
return jsonify({'status': 'error', 'message': 'Spy stations module not available'}), 503
# Find the station
station = None
for s in STATIONS:
if s.get('id') == station_id:
station = s
break
if not station:
return jsonify({'status': 'error', 'message': 'Station not found'}), 404
# Get primary frequency
freq_khz = None
for f in station.get('frequencies', []):
if f.get('primary'):
freq_khz = f.get('freq_khz')
break
if freq_khz is None and station.get('frequencies'):
freq_khz = station['frequencies'][0].get('freq_khz')
if freq_khz is None:
return jsonify({'status': 'error', 'message': 'No frequency found for station'}), 404
receivers = get_receivers()
# Filter receivers that cover this frequency and are available
matching = [
r for r in receivers
if r.get('freq_lo', 0) <= freq_khz <= r.get('freq_hi', 30000) and r.get('available', True)
]
return jsonify({
'status': 'success',
'station': {
'id': station['id'],
'name': station.get('name', ''),
'nickname': station.get('nickname', ''),
'freq_khz': freq_khz,
'mode': station.get('mode', 'USB'),
},
'receivers': matching[:20],
'total': len(matching),
})
@websdr_bp.route('/status')
def websdr_status() -> Response:
"""Get WebSDR connection and cache status."""
return jsonify({
'status': 'ok',
'cached_receivers': len(_receiver_cache),
'cache_age_seconds': round(time.time() - _cache_timestamp, 0) if _cache_timestamp > 0 else None,
'cache_ttl': CACHE_TTL,
'audio_connected': _kiwi_client is not None and _kiwi_client.connected if _kiwi_client else False,
})
# ============================================
# KIWISDR AUDIO PROXY
# ============================================
_kiwi_client: Optional[KiwiSDRClient] = None
_kiwi_lock = threading.Lock()
_kiwi_audio_queue: queue.Queue = queue.Queue(maxsize=200)
def _disconnect_kiwi() -> None:
"""Disconnect active KiwiSDR client."""
global _kiwi_client
with _kiwi_lock:
if _kiwi_client:
_kiwi_client.disconnect()
_kiwi_client = None
# Drain audio queue
while not _kiwi_audio_queue.empty():
try:
_kiwi_audio_queue.get_nowait()
except queue.Empty:
break
def _handle_kiwi_command(ws, cmd: str, data: dict) -> None:
"""Handle a command from the browser client."""
global _kiwi_client
if cmd == 'connect':
receiver_url = data.get('url', '')
host = data.get('host', '')
port = int(data.get('port', 8073))
freq_khz = float(data.get('freq_khz', 7000))
mode = data.get('mode', 'am').lower()
password = data.get('password', '')
# Parse host/port from URL if provided
if receiver_url and not host:
host, port = parse_host_port(receiver_url)
if mode not in VALID_MODES:
ws.send(json.dumps({'type': 'error', 'message': f'Invalid mode: {mode}'}))
return
if not host or ';' in host or '&' in host or '|' in host:
ws.send(json.dumps({'type': 'error', 'message': 'Invalid host'}))
return
_disconnect_kiwi()
def on_audio(pcm_bytes, smeter):
# Package: 2 bytes smeter (big-endian int16) + PCM data
header = struct.pack('>h', smeter)
try:
_kiwi_audio_queue.put_nowait(header + pcm_bytes)
except queue.Full:
try:
_kiwi_audio_queue.get_nowait()
except queue.Empty:
pass
try:
_kiwi_audio_queue.put_nowait(header + pcm_bytes)
except queue.Full:
pass
def on_error(msg):
try:
ws.send(json.dumps({'type': 'error', 'message': msg}))
except Exception:
pass
def on_disconnect():
try:
ws.send(json.dumps({'type': 'disconnected'}))
except Exception:
pass
with _kiwi_lock:
_kiwi_client = KiwiSDRClient(
host=host, port=port,
on_audio=on_audio,
on_error=on_error,
on_disconnect=on_disconnect,
password=password,
)
success = _kiwi_client.connect(freq_khz, mode)
if success:
ws.send(json.dumps({
'type': 'connected',
'host': host,
'port': port,
'freq_khz': freq_khz,
'mode': mode,
'sample_rate': KIWI_SAMPLE_RATE,
}))
else:
ws.send(json.dumps({'type': 'error', 'message': 'Connection to KiwiSDR failed'}))
_disconnect_kiwi()
elif cmd == 'tune':
freq_khz = float(data.get('freq_khz', 0))
mode = data.get('mode', '').lower() or None
with _kiwi_lock:
if _kiwi_client and _kiwi_client.connected:
success = _kiwi_client.tune(
freq_khz,
mode or _kiwi_client.mode
)
if success:
ws.send(json.dumps({
'type': 'tuned',
'freq_khz': freq_khz,
'mode': mode or _kiwi_client.mode,
}))
else:
ws.send(json.dumps({'type': 'error', 'message': 'Retune failed'}))
else:
ws.send(json.dumps({'type': 'error', 'message': 'Not connected'}))
elif cmd == 'disconnect':
_disconnect_kiwi()
ws.send(json.dumps({'type': 'disconnected'}))
def init_websdr_audio(app: Flask) -> None:
"""Initialize WebSocket audio proxy for KiwiSDR. Called from app.py."""
if not WEBSOCKET_AVAILABLE:
logger.warning("flask-sock not installed, KiwiSDR audio proxy disabled")
return
sock = Sock(app)
@sock.route('/ws/kiwi-audio')
def kiwi_audio_stream(ws):
"""WebSocket endpoint: proxy audio between browser and KiwiSDR."""
logger.info("KiwiSDR audio client connected")
try:
while True:
# Check for commands from browser
try:
msg = ws.receive(timeout=0.005)
if msg:
data = json.loads(msg)
cmd = data.get('cmd', '')
_handle_kiwi_command(ws, cmd, data)
except TimeoutError:
pass
except Exception as e:
if 'closed' in str(e).lower():
break
if 'timed out' not in str(e).lower():
logger.error(f"KiwiSDR WS receive error: {e}")
# Forward audio from KiwiSDR to browser
try:
audio_data = _kiwi_audio_queue.get_nowait()
ws.send(audio_data)
except queue.Empty:
time.sleep(0.005)
except Exception as e:
logger.info(f"KiwiSDR WS closed: {e}")
finally:
_disconnect_kiwi()
logger.info("KiwiSDR audio client disconnected")
+120 -37
View File
@@ -17,11 +17,12 @@ from flask import Blueprint, jsonify, request, Response
import app as app_module
from utils.dependencies import check_tool, get_tool_path
from utils.logging import wifi_logger as logger
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.sse import format_sse
from data.oui import get_manufacturer
from utils.logging import wifi_logger as logger
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.sse import format_sse
from utils.event_pipeline import process_event
from data.oui import get_manufacturer
from utils.constants import (
WIFI_TERMINATE_TIMEOUT,
PMKID_TERMINATE_TIMEOUT,
@@ -46,8 +47,33 @@ from utils.constants import (
wifi_bp = Blueprint('wifi', __name__, url_prefix='/wifi')
# PMKID process state
pmkid_process = None
pmkid_lock = threading.Lock()
pmkid_process = None
pmkid_lock = threading.Lock()
def _parse_channel_list(raw_channels: Any) -> list[int] | None:
"""Parse a channel list from string/list input."""
if raw_channels in (None, '', []):
return None
if isinstance(raw_channels, str):
parts = [p.strip() for p in re.split(r'[\s,]+', raw_channels) if p.strip()]
elif isinstance(raw_channels, (list, tuple, set)):
parts = list(raw_channels)
else:
parts = [raw_channels]
channels: list[int] = []
seen = set()
for part in parts:
if part in (None, ''):
continue
ch = validate_wifi_channel(part)
if ch not in seen:
channels.append(ch)
seen.add(ch)
return channels or None
def detect_wifi_interfaces():
@@ -607,8 +633,9 @@ def start_wifi_scan():
return jsonify({'status': 'error', 'message': 'Scan already running'})
data = request.json
channel = data.get('channel')
band = data.get('band', 'abg')
channel = data.get('channel')
channels = data.get('channels')
band = data.get('band', 'abg')
# Use provided interface or fall back to stored monitor interface
interface = data.get('interface')
@@ -658,8 +685,17 @@ def start_wifi_scan():
interface
]
if channel:
cmd.extend(['-c', str(channel)])
channel_list = None
if channels:
try:
channel_list = _parse_channel_list(channels)
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
if channel_list:
cmd.extend(['-c', ','.join(str(c) for c in channel_list)])
elif channel:
cmd.extend(['-c', str(channel)])
logger.info(f"Running: {' '.join(cmd)}")
@@ -851,32 +887,53 @@ def check_handshake_status():
return jsonify({'status': 'stopped', 'file_exists': False, 'handshake_found': False})
file_size = os.path.getsize(capture_file)
handshake_found = False
handshake_found = False
handshake_valid: bool | None = None
handshake_checked = False
handshake_reason: str | None = None
try:
if target_bssid and is_valid_mac(target_bssid):
aircrack_path = get_tool_path('aircrack-ng')
if aircrack_path:
result = subprocess.run(
[aircrack_path, '-a', '2', '-b', target_bssid, capture_file],
capture_output=True, text=True, timeout=10
)
output = result.stdout + result.stderr
if '1 handshake' in output or ('handshake' in output.lower() and 'wpa' in output.lower()):
if '0 handshake' not in output:
handshake_found = True
if target_bssid and is_valid_mac(target_bssid):
aircrack_path = get_tool_path('aircrack-ng')
if aircrack_path:
result = subprocess.run(
[aircrack_path, '-a', '2', '-b', target_bssid, capture_file],
capture_output=True, text=True, timeout=10
)
output = result.stdout + result.stderr
output_lower = output.lower()
handshake_checked = True
if 'no valid wpa handshakes found' in output_lower:
handshake_valid = False
handshake_reason = 'No valid WPA handshake found'
elif '0 handshake' in output_lower:
handshake_valid = False
elif '1 handshake' in output_lower or ('handshake' in output_lower and 'wpa' in output_lower):
handshake_valid = True
else:
handshake_valid = False
except subprocess.TimeoutExpired:
pass
except Exception as e:
logger.error(f"Error checking handshake: {e}")
return jsonify({
'status': 'running' if app_module.wifi_process and app_module.wifi_process.poll() is None else 'stopped',
'file_exists': True,
'file_size': file_size,
'file': capture_file,
'handshake_found': handshake_found
})
except Exception as e:
logger.error(f"Error checking handshake: {e}")
if handshake_valid:
handshake_found = True
normalized_bssid = target_bssid.upper() if target_bssid else None
if normalized_bssid and normalized_bssid not in app_module.wifi_handshakes:
app_module.wifi_handshakes.append(normalized_bssid)
return jsonify({
'status': 'running' if app_module.wifi_process and app_module.wifi_process.poll() is None else 'stopped',
'file_exists': True,
'file_size': file_size,
'file': capture_file,
'handshake_found': handshake_found,
'handshake_valid': handshake_valid,
'handshake_checked': handshake_checked,
'handshake_reason': handshake_reason
})
@wifi_bp.route('/pmkid/capture', methods=['POST'])
@@ -1084,9 +1141,13 @@ def stream_wifi():
while True:
try:
msg = app_module.wifi_queue.get(timeout=1)
last_keepalive = time.time()
yield format_sse(msg)
msg = app_module.wifi_queue.get(timeout=1)
last_keepalive = time.time()
try:
process_event('wifi', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg)
except queue.Empty:
now = time.time()
if now - last_keepalive >= keepalive_interval:
@@ -1240,10 +1301,32 @@ def v2_get_networks():
@wifi_bp.route('/v2/clients')
def v2_get_clients():
"""Get all discovered clients."""
"""Get discovered clients with optional filtering."""
try:
scanner = get_wifi_scanner()
clients = scanner.clients
# Filter by association status
associated = request.args.get('associated')
if associated == 'true':
clients = [c for c in clients if c.is_associated]
elif associated == 'false':
clients = [c for c in clients if not c.is_associated]
# Filter by associated BSSID
bssid = request.args.get('bssid')
if bssid:
clients = [c for c in clients if c.associated_bssid == bssid.upper()]
# Filter by minimum RSSI
min_rssi = request.args.get('min_rssi')
if min_rssi:
try:
min_rssi = int(min_rssi)
clients = [c for c in clients if c.rssi_current and c.rssi_current >= min_rssi]
except ValueError:
pass
return jsonify({
'clients': [c.to_dict() for c in clients],
'total': len(clients),
+50 -28
View File
@@ -16,14 +16,16 @@ from typing import Generator
from flask import Blueprint, jsonify, request, Response
from utils.wifi import (
get_wifi_scanner,
analyze_channels,
get_hidden_correlator,
SCAN_MODE_QUICK,
SCAN_MODE_DEEP,
)
from utils.sse import format_sse
from utils.wifi import (
get_wifi_scanner,
analyze_channels,
get_hidden_correlator,
SCAN_MODE_QUICK,
SCAN_MODE_DEEP,
)
from utils.sse import format_sse
from utils.validation import validate_wifi_channel
from utils.event_pipeline import process_event
logger = logging.getLogger(__name__)
@@ -85,28 +87,44 @@ def start_deep_scan():
Requires monitor mode interface and root privileges.
Request body:
interface: Monitor mode interface (e.g., 'wlan0mon')
band: Band to scan ('2.4', '5', 'all')
channel: Optional specific channel to monitor
Request body:
interface: Monitor mode interface (e.g., 'wlan0mon')
band: Band to scan ('2.4', '5', 'all')
channel: Optional specific channel to monitor
channels: Optional list or comma-separated channels to monitor
"""
data = request.get_json() or {}
interface = data.get('interface')
band = data.get('band', 'all')
channel = data.get('channel')
if channel:
try:
channel = int(channel)
except ValueError:
return jsonify({'error': 'Invalid channel'}), 400
channel = data.get('channel')
channels = data.get('channels')
channel_list = None
if channels:
if isinstance(channels, str):
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
elif isinstance(channels, (list, tuple, set)):
channel_list = list(channels)
else:
channel_list = [channels]
try:
channel_list = [validate_wifi_channel(c) for c in channel_list]
except (TypeError, ValueError):
return jsonify({'error': 'Invalid channels'}), 400
if channel:
try:
channel = validate_wifi_channel(channel)
except ValueError:
return jsonify({'error': 'Invalid channel'}), 400
scanner = get_wifi_scanner()
success = scanner.start_deep_scan(
interface=interface,
band=band,
channel=channel,
)
success = scanner.start_deep_scan(
interface=interface,
band=band,
channel=channel,
channels=channel_list,
)
if success:
return jsonify({
@@ -388,10 +406,14 @@ def event_stream():
- keepalive: Periodic keepalive
"""
def generate() -> Generator[str, None, None]:
scanner = get_wifi_scanner()
for event in scanner.get_event_stream():
yield format_sse(event)
scanner = get_wifi_scanner()
for event in scanner.get_event_stream():
try:
process_event('wifi', event, event.get('type'))
except Exception:
pass
yield format_sse(event)
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
+232 -86
View File
@@ -204,12 +204,14 @@ check_tools() {
check_required "dump1090" "ADS-B decoder" dump1090
check_required "acarsdec" "ACARS decoder" acarsdec
check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher
check_optional "slowrx" "SSTV decoder (ISS images)" slowrx
echo
info "GPS:"
check_required "gpsd" "GPS daemon" gpsd
echo
info "Digital Voice:"
check_optional "dsd" "Digital Speech Decoder (DMR/P25)" dsd dsd-fme
echo
info "Audio:"
check_required "ffmpeg" "Audio encoder/decoder" ffmpeg
@@ -386,48 +388,6 @@ install_rtlamr_from_source() {
fi
}
install_slowrx_from_source_macos() {
info "slowrx not available via Homebrew. Building from source..."
# Ensure build dependencies are installed
brew_install cmake
brew_install fftw
brew_install libsndfile
brew_install gtk+3
brew_install pkg-config
(
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
info "Cloning slowrx..."
git clone --depth 1 https://github.com/windytan/slowrx.git "$tmp_dir/slowrx" >/dev/null 2>&1 \
|| { warn "Failed to clone slowrx"; exit 1; }
cd "$tmp_dir/slowrx"
info "Compiling slowrx..."
mkdir -p build && cd build
local cmake_log make_log
cmake_log=$(cmake .. 2>&1) || {
warn "cmake failed for slowrx:"
echo "$cmake_log" | tail -20
exit 1
}
make_log=$(make 2>&1) || {
warn "make failed for slowrx:"
echo "$make_log" | tail -20
exit 1
}
# Install to /usr/local/bin
if [[ -w /usr/local/bin ]]; then
install -m 0755 slowrx /usr/local/bin/slowrx
else
sudo install -m 0755 slowrx /usr/local/bin/slowrx
fi
ok "slowrx installed successfully from source"
)
}
install_multimon_ng_from_source_macos() {
info "multimon-ng not available via Homebrew. Building from source..."
@@ -460,8 +420,192 @@ install_multimon_ng_from_source_macos() {
)
}
install_dsd_from_source() {
info "Building DSD (Digital Speech Decoder) from source..."
info "This requires mbelib (vocoder library) as a prerequisite."
if [[ "$OS" == "macos" ]]; then
brew_install cmake
brew_install libsndfile
brew_install ncurses
brew_install fftw
brew_install codec2
brew_install librtlsdr
brew_install pulseaudio || true
else
apt_install build-essential git cmake libsndfile1-dev libpulse-dev \
libfftw3-dev liblapack-dev libncurses-dev librtlsdr-dev libcodec2-dev
fi
(
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
# Step 1: Build and install mbelib (required dependency)
info "Building mbelib (vocoder library)..."
git clone https://github.com/lwvmobile/mbelib.git "$tmp_dir/mbelib" >/dev/null 2>&1 \
|| { warn "Failed to clone mbelib"; exit 1; }
cd "$tmp_dir/mbelib"
git checkout ambe_tones >/dev/null 2>&1 || true
mkdir -p build && cd build
if cmake .. >/dev/null 2>&1 && make -j "$(nproc 2>/dev/null || sysctl -n hw.ncpu)" >/dev/null 2>&1; then
if [[ "$OS" == "macos" ]]; then
if [[ -w /usr/local/lib ]]; then
make install >/dev/null 2>&1
else
sudo make install >/dev/null 2>&1
fi
else
$SUDO make install >/dev/null 2>&1
$SUDO ldconfig 2>/dev/null || true
fi
ok "mbelib installed"
else
warn "Failed to build mbelib. Cannot build DSD without it."
exit 1
fi
# Step 2: Build dsd-fme (or fall back to original dsd)
info "Building dsd-fme..."
git clone --depth 1 https://github.com/lwvmobile/dsd-fme.git "$tmp_dir/dsd-fme" >/dev/null 2>&1 \
|| { warn "Failed to clone dsd-fme, trying original DSD...";
git clone --depth 1 https://github.com/szechyjs/dsd.git "$tmp_dir/dsd-fme" >/dev/null 2>&1 \
|| { warn "Failed to clone DSD"; exit 1; }; }
cd "$tmp_dir/dsd-fme"
mkdir -p build && cd build
# On macOS, help cmake find Homebrew ncurses
local cmake_flags=""
if [[ "$OS" == "macos" ]]; then
local ncurses_prefix
ncurses_prefix="$(brew --prefix ncurses 2>/dev/null || echo /opt/homebrew/opt/ncurses)"
cmake_flags="-DCMAKE_PREFIX_PATH=$ncurses_prefix"
fi
info "Compiling DSD..."
if cmake .. $cmake_flags >/dev/null 2>&1 && make -j "$(nproc 2>/dev/null || sysctl -n hw.ncpu)" >/dev/null 2>&1; then
if [[ "$OS" == "macos" ]]; then
if [[ -w /usr/local/bin ]]; then
install -m 0755 dsd-fme /usr/local/bin/dsd 2>/dev/null || install -m 0755 dsd /usr/local/bin/dsd 2>/dev/null || true
else
sudo install -m 0755 dsd-fme /usr/local/bin/dsd 2>/dev/null || sudo install -m 0755 dsd /usr/local/bin/dsd 2>/dev/null || true
fi
else
$SUDO make install >/dev/null 2>&1 \
|| $SUDO install -m 0755 dsd-fme /usr/local/bin/dsd 2>/dev/null \
|| $SUDO install -m 0755 dsd /usr/local/bin/dsd 2>/dev/null \
|| true
$SUDO ldconfig 2>/dev/null || true
fi
ok "DSD installed successfully"
else
warn "Failed to build DSD from source. DMR/P25 decoding will not be available."
fi
)
}
install_dump1090_from_source_macos() {
info "dump1090 not available via Homebrew. Building from source..."
brew_install cmake
brew_install librtlsdr
brew_install pkg-config
(
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
info "Cloning FlightAware dump1090..."
git clone --depth 1 https://github.com/flightaware/dump1090.git "$tmp_dir/dump1090" >/dev/null 2>&1 \
|| { warn "Failed to clone dump1090"; exit 1; }
cd "$tmp_dir/dump1090"
sed -i '' 's/-Werror//g' Makefile 2>/dev/null || true
info "Compiling dump1090..."
if make BLADERF=no RTLSDR=yes 2>&1 | tail -5; then
if [[ -w /usr/local/bin ]]; then
install -m 0755 dump1090 /usr/local/bin/dump1090
else
sudo install -m 0755 dump1090 /usr/local/bin/dump1090
fi
ok "dump1090 installed successfully from source"
else
warn "Failed to build dump1090. ADS-B decoding will not be available."
fi
)
}
install_acarsdec_from_source_macos() {
info "acarsdec not available via Homebrew. Building from source..."
brew_install cmake
brew_install librtlsdr
brew_install libsndfile
brew_install pkg-config
(
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
info "Cloning acarsdec..."
git clone --depth 1 https://github.com/TLeconte/acarsdec.git "$tmp_dir/acarsdec" >/dev/null 2>&1 \
|| { warn "Failed to clone acarsdec"; exit 1; }
cd "$tmp_dir/acarsdec"
mkdir -p build && cd build
info "Compiling acarsdec..."
if cmake .. -Drtl=ON >/dev/null 2>&1 && make >/dev/null 2>&1; then
if [[ -w /usr/local/bin ]]; then
install -m 0755 acarsdec /usr/local/bin/acarsdec
else
sudo install -m 0755 acarsdec /usr/local/bin/acarsdec
fi
ok "acarsdec installed successfully from source"
else
warn "Failed to build acarsdec. ACARS decoding will not be available."
fi
)
}
install_aiscatcher_from_source_macos() {
info "AIS-catcher not available via Homebrew. Building from source..."
brew_install cmake
brew_install librtlsdr
brew_install curl
brew_install pkg-config
(
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
info "Cloning AIS-catcher..."
git clone --depth 1 https://github.com/jvde-github/AIS-catcher.git "$tmp_dir/AIS-catcher" >/dev/null 2>&1 \
|| { warn "Failed to clone AIS-catcher"; exit 1; }
cd "$tmp_dir/AIS-catcher"
mkdir -p build && cd build
info "Compiling AIS-catcher..."
if cmake .. >/dev/null 2>&1 && make >/dev/null 2>&1; then
if [[ -w /usr/local/bin ]]; then
install -m 0755 AIS-catcher /usr/local/bin/AIS-catcher
else
sudo install -m 0755 AIS-catcher /usr/local/bin/AIS-catcher
fi
ok "AIS-catcher installed successfully from source"
else
warn "Failed to build AIS-catcher. AIS vessel tracking will not be available."
fi
)
}
install_macos_packages() {
TOTAL_STEPS=16
TOTAL_STEPS=17
CURRENT_STEP=0
progress "Checking Homebrew"
@@ -481,11 +625,20 @@ install_macos_packages() {
progress "Installing direwolf (APRS decoder)"
(brew_install direwolf) || warn "direwolf not available via Homebrew"
progress "Installing slowrx (SSTV decoder)"
if ! cmd_exists slowrx; then
install_slowrx_from_source_macos || warn "slowrx build failed - ISS SSTV decoding will not be available"
progress "SSTV decoder"
ok "SSTV uses built-in pure Python decoder (no external tools needed)"
progress "Installing DSD (Digital Speech Decoder, optional)"
if ! cmd_exists dsd && ! cmd_exists dsd-fme; then
echo
info "DSD is used for DMR, P25, NXDN, and D-STAR digital voice decoding."
if ask_yes_no "Do you want to install DSD?"; then
install_dsd_from_source || warn "DSD build failed. DMR/P25 decoding will not be available."
else
warn "Skipping DSD installation. DMR/P25 decoding will not be available."
fi
else
ok "slowrx already installed"
ok "DSD already installed"
fi
progress "Installing ffmpeg"
@@ -509,14 +662,22 @@ install_macos_packages() {
fi
progress "Installing dump1090"
(brew_install dump1090-mutability) || warn "dump1090 not available via Homebrew"
if ! cmd_exists dump1090; then
(brew_install dump1090-mutability) || install_dump1090_from_source_macos || warn "dump1090 not available"
else
ok "dump1090 already installed"
fi
progress "Installing acarsdec"
(brew_install acarsdec) || warn "acarsdec not available via Homebrew"
if ! cmd_exists acarsdec; then
(brew_install acarsdec) || install_acarsdec_from_source_macos || warn "acarsdec not available"
else
ok "acarsdec already installed"
fi
progress "Installing AIS-catcher"
if ! cmd_exists AIS-catcher && ! cmd_exists aiscatcher; then
(brew_install aiscatcher) || warn "AIS-catcher not available via Homebrew"
(brew_install aiscatcher) || install_aiscatcher_from_source_macos || warn "AIS-catcher not available"
else
ok "AIS-catcher already installed"
fi
@@ -683,37 +844,6 @@ install_aiscatcher_from_source_debian() {
)
}
install_slowrx_from_source_debian() {
info "slowrx not available via APT. Building from source..."
# slowrx uses a simple Makefile, not CMake
apt_install build-essential git pkg-config \
libfftw3-dev libsndfile1-dev libgtk-3-dev libasound2-dev libpulse-dev
# Run in subshell to isolate EXIT trap
(
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
info "Cloning slowrx..."
git clone --depth 1 https://github.com/windytan/slowrx.git "$tmp_dir/slowrx" >/dev/null 2>&1 \
|| { warn "Failed to clone slowrx"; exit 1; }
cd "$tmp_dir/slowrx"
info "Compiling slowrx..."
local make_log
make_log=$(make 2>&1) || {
warn "make failed for slowrx:"
echo "$make_log" | tail -20
warn "ISS SSTV decoding will not be available."
exit 1
}
$SUDO install -m 0755 slowrx /usr/local/bin/slowrx
ok "slowrx installed successfully."
)
}
install_ubertooth_from_source_debian() {
info "Building Ubertooth from source..."
@@ -849,7 +979,7 @@ install_debian_packages() {
export NEEDRESTART_MODE=a
fi
TOTAL_STEPS=21
TOTAL_STEPS=22
CURRENT_STEP=0
progress "Updating APT package lists"
@@ -905,8 +1035,21 @@ install_debian_packages() {
progress "Installing direwolf (APRS decoder)"
apt_install direwolf || true
progress "Installing slowrx (SSTV decoder)"
apt_install slowrx || cmd_exists slowrx || install_slowrx_from_source_debian
progress "SSTV decoder"
ok "SSTV uses built-in pure Python decoder (no external tools needed)"
progress "Installing DSD (Digital Speech Decoder, optional)"
if ! cmd_exists dsd && ! cmd_exists dsd-fme; then
echo
info "DSD is used for DMR, P25, NXDN, and D-STAR digital voice decoding."
if ask_yes_no "Do you want to install DSD?"; then
install_dsd_from_source || warn "DSD build failed. DMR/P25 decoding will not be available."
else
warn "Skipping DSD installation. DMR/P25 decoding will not be available."
fi
else
ok "DSD already installed"
fi
progress "Installing ffmpeg"
apt_install ffmpeg
@@ -1092,4 +1235,7 @@ main() {
}
main "$@"
# Clear traps before exiting to prevent spurious errors during cleanup
trap - ERR EXIT
exit 0
+122 -56
View File
@@ -5,27 +5,30 @@
}
:root {
--bg-dark: #0a0c10;
--bg-panel: #0f1218;
--bg-card: #151a23;
--border-color: #1f2937;
--border-glow: #4a9eff;
--text-primary: #e8eaed;
--text-secondary: #9ca3af;
--text-dim: #4b5563;
--accent-green: #22c55e;
--accent-cyan: #4a9eff;
--accent-orange: #f59e0b;
--accent-red: #ef4444;
--accent-yellow: #eab308;
--accent-amber: #d4a853;
--grid-line: rgba(74, 158, 255, 0.08);
--radar-cyan: #4a9eff;
--radar-bg: #0f1218;
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
--bg-dark: #0b1118;
--bg-panel: #101823;
--bg-card: #151f2b;
--border-color: #263246;
--border-glow: #4aa3ff;
--text-primary: #d7e0ee;
--text-secondary: #9fb0c7;
--text-dim: #6f7f94;
--accent-green: #38c180;
--accent-cyan: #4aa3ff;
--accent-orange: #d6a85e;
--accent-red: #e25d5d;
--accent-yellow: #e1c26b;
--accent-amber: #d6a85e;
--grid-line: rgba(74, 163, 255, 0.1);
--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='%23ffffff'%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: #4aa3ff;
--radar-bg: #101823;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
font-family: var(--font-sans);
background: var(--bg-dark);
color: var(--text-primary);
min-height: 100vh;
@@ -40,9 +43,10 @@ body {
right: 0;
bottom: 0;
background-image:
var(--noise-image),
linear-gradient(var(--grid-line) 1px, transparent 1px),
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
background-size: 50px 50px;
background-size: 40px 40px, 50px 50px, 50px 50px;
pointer-events: none;
z-index: 0;
}
@@ -55,10 +59,12 @@ body {
right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
color: var(--accent-cyan);
animation: scan 6s linear infinite;
pointer-events: none;
z-index: 1000;
opacity: 0.3;
opacity: 0.25;
box-shadow: 0 0 8px currentColor;
}
@keyframes scan {
@@ -71,7 +77,7 @@ body {
}
}
/* Header - Mobile first */
/* Header */
.header {
position: relative;
z-index: 10;
@@ -81,20 +87,31 @@ body {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 8px;
flex-wrap: nowrap;
gap: 12px;
min-height: 52px;
}
.header::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
opacity: 0.6;
pointer-events: none;
}
@media (min-width: 768px) {
.header {
padding: 12px 20px;
flex-wrap: nowrap;
}
}
.logo {
font-family: 'Inter', sans-serif;
font-family: var(--font-sans);
font-size: 16px;
font-weight: 700;
letter-spacing: 2px;
@@ -126,14 +143,52 @@ body {
letter-spacing: 2px;
}
}
}
.status-bar {
display: flex;
gap: 20px;
gap: 12px;
align-items: center;
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 11px;
flex-wrap: nowrap;
}
.agent-selector-compact {
display: flex;
align-items: center;
gap: 8px;
}
.agent-selector-compact .agent-select-sm {
padding: 4px 8px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-primary);
font-size: 11px;
font-family: var(--font-mono);
}
.agent-selector-compact .agent-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent-green);
box-shadow: 0 0 6px var(--accent-green);
}
.agent-selector-compact .agent-status-dot.offline {
background: var(--accent-red);
box-shadow: 0 0 6px var(--accent-red);
}
.agent-selector-compact .show-all-label {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--text-secondary);
cursor: pointer;
}
.status-dot {
@@ -172,15 +227,15 @@ body {
}
/* Main dashboard grid - Mobile first */
/* Header ~55px + Stats strip ~55px = ~110px, using 115px for safety */
/* Header ~52px + Nav 44px + Stats strip ~55px = ~151px, using 160px for safety */
.dashboard {
position: relative;
z-index: 10;
display: flex;
flex-direction: column;
gap: 0;
height: calc(100dvh - 115px);
height: calc(100vh - 115px); /* Fallback */
height: calc(100dvh - 160px);
height: calc(100vh - 160px); /* Fallback */
min-height: 400px;
}
@@ -216,7 +271,7 @@ body {
@media (min-width: 1024px) {
.acars-sidebar {
display: flex;
max-height: calc(100dvh - 115px);
max-height: calc(100dvh - 160px);
}
}
@@ -624,7 +679,7 @@ body {
}
.telemetry-value {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 12px;
color: var(--accent-cyan);
}
@@ -680,7 +735,7 @@ body {
}
.aircraft-icao {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 9px;
color: var(--text-secondary);
background: rgba(74, 158, 255, 0.1);
@@ -700,7 +755,7 @@ body {
}
.aircraft-detail-value {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
color: var(--accent-cyan);
font-size: 11px;
}
@@ -716,14 +771,31 @@ body {
grid-column: 1 / -1;
grid-row: 2;
display: flex;
align-items: center;
align-items: stretch !important;
flex-wrap: nowrap;
gap: 8px;
padding: 8px 15px;
background: var(--bg-panel);
border-top: 1px solid rgba(74, 158, 255, 0.3);
font-size: 11px;
overflow-x: auto;
overflow: hidden;
}
.controls-bar > .control-group {
flex: 0 0 auto;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
gap: 4px;
padding: 6px 10px;
background: rgba(74, 158, 255, 0.03);
border: 1px solid rgba(74, 158, 255, 0.1);
border-radius: 6px;
}
.controls-bar > .control-group > .control-group-items {
margin-top: auto;
}
.controls-bar label {
@@ -790,7 +862,7 @@ body {
border: 1px solid rgba(74, 158, 255, 0.3);
border-radius: 4px;
color: var(--accent-cyan);
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 10px;
}
@@ -801,7 +873,7 @@ body {
border: 1px solid rgba(74, 158, 255, 0.3);
border-radius: 4px;
color: var(--accent-cyan);
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 10px;
}
@@ -879,7 +951,7 @@ body {
border: none;
background: var(--accent-green);
color: #fff;
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
@@ -911,7 +983,7 @@ body {
border: 1px solid rgba(74, 158, 255, 0.3);
border-radius: 4px;
color: var(--accent-cyan);
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 10px;
cursor: pointer;
}
@@ -1023,7 +1095,7 @@ body {
cursor: pointer;
font-size: 11px;
font-weight: 600;
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
display: flex;
align-items: center;
gap: 5px;
@@ -1057,7 +1129,7 @@ body {
}
.airband-status {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 10px;
padding: 0 8px;
color: var(--text-muted);
@@ -1217,7 +1289,7 @@ body {
display: flex !important;
flex-direction: column !important;
height: auto !important;
min-height: calc(100dvh - 115px);
min-height: calc(100dvh - 160px);
overflow-y: auto !important;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
@@ -1286,12 +1358,6 @@ body {
padding: 6px 8px;
}
/* Status bar - compact on mobile */
.status-bar {
flex-wrap: wrap;
gap: 6px;
}
/* Strip time smaller on mobile */
.strip-time {
font-size: 10px;
@@ -1407,7 +1473,7 @@ body {
}
.strip-value {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 14px;
font-weight: 600;
color: var(--accent-cyan);
@@ -1433,7 +1499,7 @@ body {
}
.strip-report-btn {
background: linear-gradient(135deg, var(--accent-cyan) 0%, #2563eb 100%);
background: linear-gradient(135deg, var(--accent-cyan) 0%, #2b6fb8 100%);
border: none;
color: white;
padding: 8px 12px;
@@ -1545,7 +1611,7 @@ body {
.report-grid span:nth-child(even) {
color: var(--text-primary);
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
}
.report-highlights {
@@ -1736,7 +1802,7 @@ body {
}
.strip-btn.primary {
background: linear-gradient(135deg, var(--accent-cyan) 0%, #2563eb 100%);
background: linear-gradient(135deg, var(--accent-cyan) 0%, #2b6fb8 100%);
border: none;
color: white;
}
@@ -1784,7 +1850,7 @@ body {
font-size: 11px;
font-weight: 500;
color: var(--accent-cyan);
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
padding-left: 8px;
border-left: 1px solid rgba(74, 158, 255, 0.2);
white-space: nowrap;
@@ -1938,7 +2004,7 @@ body {
}
.squawk-code {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-weight: 700;
color: var(--accent-cyan);
font-size: 12px;
+38 -20
View File
@@ -5,38 +5,42 @@
}
:root {
--bg-dark: #0a0c10;
--bg-panel: #0f1218;
--bg-card: #141a24;
--border-color: #1f2937;
--border-glow: rgba(74, 158, 255, 0.6);
--text-primary: #e8eaed;
--text-secondary: #9ca3af;
--text-dim: #4b5563;
--accent-cyan: #4a9eff;
--accent-green: #22c55e;
--accent-amber: #d4a853;
--grid-line: rgba(74, 158, 255, 0.08);
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
--bg-dark: #0b1118;
--bg-panel: #101823;
--bg-card: #151f2b;
--border-color: #263246;
--border-glow: rgba(74, 163, 255, 0.4);
--text-primary: #d7e0ee;
--text-secondary: #9fb0c7;
--text-dim: #6f7f94;
--accent-cyan: #4aa3ff;
--accent-green: #38c180;
--accent-amber: #d6a85e;
--grid-line: rgba(74, 163, 255, 0.1);
--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='%23ffffff'%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");
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
font-family: var(--font-sans);
background: var(--bg-dark);
color: var(--text-primary);
min-height: 100vh;
}
.mono {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
}
.radar-bg {
position: fixed;
inset: 0;
background-image:
var(--noise-image),
linear-gradient(var(--grid-line) 1px, transparent 1px),
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
background-size: 50px 50px;
background-size: 40px 40px, 50px 50px, 50px 50px;
pointer-events: none;
z-index: 0;
}
@@ -48,10 +52,12 @@ body {
right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
color: var(--accent-cyan);
animation: scan 6s linear infinite;
pointer-events: none;
z-index: 1;
opacity: 0.3;
opacity: 0.25;
box-shadow: 0 0 8px currentColor;
}
@keyframes scan {
@@ -72,6 +78,18 @@ body {
gap: 12px;
}
.header::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
opacity: 0.6;
pointer-events: none;
}
.logo {
font-size: 18px;
font-weight: 700;
@@ -91,7 +109,7 @@ body {
display: flex;
align-items: center;
gap: 12px;
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 11px;
}
@@ -268,7 +286,7 @@ body {
}
.status-pill {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 11px;
padding: 8px 12px;
border-radius: 999px;
@@ -306,7 +324,7 @@ body {
}
.panel-meta {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 11px;
color: var(--accent-cyan);
}
@@ -347,7 +365,7 @@ body {
}
.mono {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
}
.empty-row td,
+331 -343
View File
@@ -1,343 +1,331 @@
/*
* Agents Management CSS
* Styles for the remote agent management interface
*/
/* CSS Variables (inherited from main theme) */
:root {
--bg-primary: #0a0a0f;
--bg-secondary: #12121a;
--text-primary: #e0e0e0;
--text-secondary: #888;
--border-color: #1a1a2e;
--accent-cyan: #00d4ff;
--accent-green: #00ff88;
--accent-red: #ff3366;
--accent-orange: #ff9f1c;
}
/* Agent indicator in navigation */
.agent-indicator {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: rgba(0, 212, 255, 0.1);
border: 1px solid rgba(0, 212, 255, 0.3);
border-radius: 20px;
cursor: pointer;
transition: all 0.2s;
}
.agent-indicator:hover {
background: rgba(0, 212, 255, 0.2);
border-color: var(--accent-cyan);
}
.agent-indicator-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent-green);
box-shadow: 0 0 6px var(--accent-green);
}
.agent-indicator-dot.remote {
background: var(--accent-cyan);
box-shadow: 0 0 6px var(--accent-cyan);
}
.agent-indicator-dot.multiple {
background: var(--accent-orange);
box-shadow: 0 0 6px var(--accent-orange);
}
.agent-indicator-label {
font-size: 11px;
color: var(--text-primary);
font-family: 'JetBrains Mono', monospace;
}
.agent-indicator-count {
font-size: 10px;
padding: 2px 6px;
background: rgba(0, 212, 255, 0.2);
border-radius: 10px;
color: var(--accent-cyan);
}
/* Agent selector dropdown */
.agent-selector {
position: relative;
}
.agent-selector-dropdown {
position: absolute;
top: 100%;
right: 0;
margin-top: 8px;
min-width: 280px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
z-index: 1000;
display: none;
}
.agent-selector-dropdown.show {
display: block;
}
.agent-selector-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 15px;
border-bottom: 1px solid var(--border-color);
}
.agent-selector-header h4 {
margin: 0;
font-size: 12px;
color: var(--accent-cyan);
text-transform: uppercase;
letter-spacing: 1px;
}
.agent-selector-manage {
font-size: 11px;
color: var(--accent-cyan);
text-decoration: none;
}
.agent-selector-manage:hover {
text-decoration: underline;
}
.agent-selector-list {
max-height: 300px;
overflow-y: auto;
}
.agent-selector-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 15px;
cursor: pointer;
transition: background 0.2s;
border-bottom: 1px solid var(--border-color);
}
.agent-selector-item:last-child {
border-bottom: none;
}
.agent-selector-item:hover {
background: rgba(0, 212, 255, 0.1);
}
.agent-selector-item.selected {
background: rgba(0, 212, 255, 0.15);
border-left: 3px solid var(--accent-cyan);
}
.agent-selector-item.local {
border-left: 3px solid var(--accent-green);
}
.agent-selector-item-status {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.agent-selector-item-status.online {
background: var(--accent-green);
}
.agent-selector-item-status.offline {
background: var(--accent-red);
}
.agent-selector-item-info {
flex: 1;
min-width: 0;
}
.agent-selector-item-name {
font-size: 13px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.agent-selector-item-url {
font-size: 10px;
color: var(--text-secondary);
font-family: 'JetBrains Mono', monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.agent-selector-item-check {
color: var(--accent-green);
opacity: 0;
}
.agent-selector-item.selected .agent-selector-item-check {
opacity: 1;
}
/* Agent badge in data displays */
.agent-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
font-size: 10px;
background: rgba(0, 212, 255, 0.1);
color: var(--accent-cyan);
border-radius: 10px;
font-family: 'JetBrains Mono', monospace;
}
.agent-badge.local,
.agent-badge.agent-local {
background: rgba(0, 255, 136, 0.1);
color: var(--accent-green);
}
.agent-badge.agent-remote {
background: rgba(0, 212, 255, 0.1);
color: var(--accent-cyan);
}
/* WiFi table agent column */
.wifi-networks-table .col-agent {
width: 100px;
text-align: center;
}
.wifi-networks-table th.col-agent {
font-size: 10px;
}
/* Bluetooth table agent column */
.bt-devices-table .col-agent {
width: 100px;
text-align: center;
}
.agent-badge-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
/* Agent column in data tables */
.data-table .agent-col {
width: 120px;
max-width: 120px;
}
/* Multi-agent stream indicator */
.multi-agent-indicator {
position: fixed;
bottom: 20px;
left: 20px;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 20px;
font-size: 11px;
color: var(--text-secondary);
z-index: 100;
}
.multi-agent-indicator.active {
border-color: var(--accent-cyan);
color: var(--accent-cyan);
}
.multi-agent-indicator-pulse {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent-cyan);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(0.8); }
}
/* Agent connection status toast */
.agent-toast {
position: fixed;
top: 80px;
right: 20px;
padding: 10px 15px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
font-size: 12px;
z-index: 1001;
animation: slideInRight 0.3s ease;
}
.agent-toast.connected {
border-color: var(--accent-green);
color: var(--accent-green);
}
.agent-toast.disconnected {
border-color: var(--accent-red);
color: var(--accent-red);
}
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Responsive adjustments */
@media (max-width: 768px) {
.agent-indicator {
padding: 4px 8px;
}
.agent-indicator-label {
display: none;
}
.agent-selector-dropdown {
position: fixed;
top: auto;
bottom: 0;
left: 0;
right: 0;
margin: 0;
border-radius: 16px 16px 0 0;
max-height: 60vh;
}
.agents-grid {
grid-template-columns: 1fr;
}
}
/*
* Agents Management CSS
* Styles for the remote agent management interface
* Inherits CSS variables from core/variables.css
*/
/* Agent indicator in navigation */
.agent-indicator {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: rgba(0, 212, 255, 0.1);
border: 1px solid rgba(0, 212, 255, 0.3);
border-radius: 20px;
cursor: pointer;
transition: all 0.2s;
}
.agent-indicator:hover {
background: rgba(0, 212, 255, 0.2);
border-color: var(--accent-cyan);
}
.agent-indicator-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent-green);
box-shadow: 0 0 6px var(--accent-green);
}
.agent-indicator-dot.remote {
background: var(--accent-cyan);
box-shadow: 0 0 6px var(--accent-cyan);
}
.agent-indicator-dot.multiple {
background: var(--accent-orange);
box-shadow: 0 0 6px var(--accent-orange);
}
.agent-indicator-label {
font-size: 11px;
color: var(--text-primary);
font-family: var(--font-mono);
}
.agent-indicator-count {
font-size: 10px;
padding: 2px 6px;
background: rgba(0, 212, 255, 0.2);
border-radius: 10px;
color: var(--accent-cyan);
}
/* Agent selector dropdown */
.agent-selector {
position: relative;
}
.agent-selector-dropdown {
position: absolute;
top: 100%;
right: 0;
margin-top: 8px;
min-width: 280px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
z-index: 1000;
display: none;
}
.agent-selector-dropdown.show {
display: block;
}
.agent-selector-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 15px;
border-bottom: 1px solid var(--border-color);
}
.agent-selector-header h4 {
margin: 0;
font-size: 12px;
color: var(--accent-cyan);
text-transform: uppercase;
letter-spacing: 1px;
}
.agent-selector-manage {
font-size: 11px;
color: var(--accent-cyan);
text-decoration: none;
}
.agent-selector-manage:hover {
text-decoration: underline;
}
.agent-selector-list {
max-height: 300px;
overflow-y: auto;
}
.agent-selector-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 15px;
cursor: pointer;
transition: background 0.2s;
border-bottom: 1px solid var(--border-color);
}
.agent-selector-item:last-child {
border-bottom: none;
}
.agent-selector-item:hover {
background: rgba(0, 212, 255, 0.1);
}
.agent-selector-item.selected {
background: rgba(0, 212, 255, 0.15);
border-left: 3px solid var(--accent-cyan);
}
.agent-selector-item.local {
border-left: 3px solid var(--accent-green);
}
.agent-selector-item-status {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.agent-selector-item-status.online {
background: var(--accent-green);
}
.agent-selector-item-status.offline {
background: var(--accent-red);
}
.agent-selector-item-info {
flex: 1;
min-width: 0;
}
.agent-selector-item-name {
font-size: 13px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.agent-selector-item-url {
font-size: 10px;
color: var(--text-secondary);
font-family: var(--font-mono);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.agent-selector-item-check {
color: var(--accent-green);
opacity: 0;
}
.agent-selector-item.selected .agent-selector-item-check {
opacity: 1;
}
/* Agent badge in data displays */
.agent-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
font-size: 10px;
background: rgba(0, 212, 255, 0.1);
color: var(--accent-cyan);
border-radius: 10px;
font-family: var(--font-mono);
}
.agent-badge.local,
.agent-badge.agent-local {
background: rgba(0, 255, 136, 0.1);
color: var(--accent-green);
}
.agent-badge.agent-remote {
background: rgba(0, 212, 255, 0.1);
color: var(--accent-cyan);
}
/* WiFi table agent column */
.wifi-networks-table .col-agent {
width: 100px;
text-align: center;
}
.wifi-networks-table th.col-agent {
font-size: 10px;
}
/* Bluetooth table agent column */
.bt-devices-table .col-agent {
width: 100px;
text-align: center;
}
.agent-badge-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
/* Agent column in data tables */
.data-table .agent-col {
width: 120px;
max-width: 120px;
}
/* Multi-agent stream indicator */
.multi-agent-indicator {
position: fixed;
bottom: 20px;
left: 20px;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 20px;
font-size: 11px;
color: var(--text-secondary);
z-index: 100;
}
.multi-agent-indicator.active {
border-color: var(--accent-cyan);
color: var(--accent-cyan);
}
.multi-agent-indicator-pulse {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent-cyan);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(0.8); }
}
/* Agent connection status toast */
.agent-toast {
position: fixed;
top: 80px;
right: 20px;
padding: 10px 15px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
font-size: 12px;
z-index: 1001;
animation: slideInRight 0.3s ease;
}
.agent-toast.connected {
border-color: var(--accent-green);
color: var(--accent-green);
}
.agent-toast.disconnected {
border-color: var(--accent-red);
color: var(--accent-red);
}
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Responsive adjustments */
@media (max-width: 768px) {
.agent-indicator {
padding: 4px 8px;
}
.agent-indicator-label {
display: none;
}
.agent-selector-dropdown {
position: fixed;
top: auto;
bottom: 0;
left: 0;
right: 0;
margin: 0;
border-radius: 16px 16px 0 0;
max-height: 60vh;
}
.agents-grid {
grid-template-columns: 1fr;
}
}
+126 -51
View File
@@ -8,27 +8,30 @@
}
:root {
--bg-dark: #0a0c10;
--bg-panel: #0f1218;
--bg-card: #151a23;
--border-color: #1f2937;
--border-glow: #4a9eff;
--text-primary: #e8eaed;
--text-secondary: #9ca3af;
--text-dim: #4b5563;
--accent-green: #22c55e;
--accent-cyan: #4a9eff;
--accent-orange: #f59e0b;
--accent-red: #ef4444;
--accent-yellow: #eab308;
--accent-amber: #d4a853;
--grid-line: rgba(74, 158, 255, 0.08);
--radar-cyan: #4a9eff;
--radar-bg: #0f1218;
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
--bg-dark: #0b1118;
--bg-panel: #101823;
--bg-card: #151f2b;
--border-color: #263246;
--border-glow: #4aa3ff;
--text-primary: #d7e0ee;
--text-secondary: #9fb0c7;
--text-dim: #6f7f94;
--accent-green: #38c180;
--accent-cyan: #4aa3ff;
--accent-orange: #d6a85e;
--accent-red: #e25d5d;
--accent-yellow: #e1c26b;
--accent-amber: #d6a85e;
--grid-line: rgba(74, 163, 255, 0.1);
--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='%23ffffff'%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: #4aa3ff;
--radar-bg: #101823;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
font-family: var(--font-sans);
background: var(--bg-dark);
color: var(--text-primary);
min-height: 100vh;
@@ -43,9 +46,10 @@ body {
right: 0;
bottom: 0;
background-image:
var(--noise-image),
linear-gradient(var(--grid-line) 1px, transparent 1px),
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
background-size: 50px 50px;
background-size: 40px 40px, 50px 50px, 50px 50px;
pointer-events: none;
z-index: 0;
}
@@ -58,10 +62,12 @@ body {
right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
color: var(--accent-cyan);
animation: scan 6s linear infinite;
pointer-events: none;
z-index: 1000;
opacity: 0.3;
opacity: 0.25;
box-shadow: 0 0 8px currentColor;
}
@keyframes scan {
@@ -89,6 +95,18 @@ body {
min-height: 52px;
}
.header::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
opacity: 0.6;
pointer-events: none;
}
@media (min-width: 768px) {
.header {
padding: 12px 20px;
@@ -97,7 +115,7 @@ body {
}
.logo {
font-family: 'Inter', sans-serif;
font-family: var(--font-sans);
font-size: 16px;
font-weight: 700;
letter-spacing: 2px;
@@ -132,10 +150,49 @@ body {
.status-bar {
display: flex;
gap: 20px;
gap: 12px;
align-items: center;
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 11px;
flex-wrap: nowrap;
}
.agent-selector-compact {
display: flex;
align-items: center;
gap: 8px;
}
.agent-selector-compact .agent-select-sm {
padding: 4px 8px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-primary);
font-size: 11px;
font-family: var(--font-mono);
}
.agent-selector-compact .agent-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent-green);
box-shadow: 0 0 6px var(--accent-green);
}
.agent-selector-compact .agent-status-dot.offline {
background: var(--accent-red);
box-shadow: 0 0 6px var(--accent-red);
}
.agent-selector-compact .show-all-label {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--text-secondary);
cursor: pointer;
}
.back-link {
@@ -183,7 +240,7 @@ body {
}
.strip-value {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 14px;
font-weight: 600;
color: var(--accent-cyan);
@@ -287,7 +344,7 @@ body {
font-size: 11px;
font-weight: 500;
color: var(--accent-cyan);
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
padding-left: 8px;
border-left: 1px solid rgba(74, 158, 255, 0.2);
white-space: nowrap;
@@ -314,20 +371,21 @@ body {
}
.strip-btn.primary {
background: linear-gradient(135deg, var(--accent-cyan) 0%, #2563eb 100%);
background: linear-gradient(135deg, var(--accent-cyan) 0%, #2b6fb8 100%);
border: none;
color: white;
}
/* Main dashboard grid - Mobile first */
/* Header ~52px + Nav 44px + Stats strip ~55px = ~151px, using 160px for safety */
.dashboard {
position: relative;
z-index: 10;
display: flex;
flex-direction: column;
gap: 0;
height: calc(100dvh - 95px);
height: calc(100vh - 95px);
height: calc(100dvh - 160px);
height: calc(100vh - 160px);
min-height: 400px;
}
@@ -367,7 +425,7 @@ body {
/* Leaflet overrides - Dark map styling */
.leaflet-container {
background: var(--bg-dark) !important;
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
}
/* Using actual dark tiles now - no filter needed */
@@ -438,7 +496,7 @@ body {
padding: 10px 15px;
background: rgba(74, 158, 255, 0.05);
border-bottom: 1px solid rgba(74, 158, 255, 0.1);
font-family: 'Orbitron', 'JetBrains Mono', monospace;
font-family: 'Orbitron', 'Space Mono', monospace;
font-size: 11px;
font-weight: 500;
letter-spacing: 2px;
@@ -510,7 +568,7 @@ body {
}
.vessel-name {
font-family: 'Orbitron', 'JetBrains Mono', monospace;
font-family: 'Orbitron', 'Space Mono', monospace;
font-size: 16px;
font-weight: 700;
color: var(--accent-cyan);
@@ -518,7 +576,7 @@ body {
}
.vessel-mmsi {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-secondary);
background: rgba(74, 158, 255, 0.1);
@@ -548,7 +606,7 @@ body {
}
.detail-value {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 12px;
color: var(--accent-cyan);
}
@@ -604,20 +662,20 @@ body {
}
.vessel-item-name {
font-family: 'Orbitron', 'JetBrains Mono', monospace;
font-family: 'Orbitron', 'Space Mono', monospace;
font-size: 12px;
font-weight: 600;
color: var(--accent-cyan);
}
.vessel-item-type {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 9px;
color: var(--text-secondary);
}
.vessel-item-speed {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 11px;
color: var(--accent-cyan);
text-align: right;
@@ -628,14 +686,31 @@ body {
grid-column: 1 / -1;
grid-row: 2;
display: flex;
align-items: center;
align-items: stretch !important;
flex-wrap: nowrap;
gap: 8px;
padding: 8px 15px;
background: var(--bg-panel);
border-top: 1px solid rgba(74, 158, 255, 0.3);
font-size: 11px;
overflow-x: auto;
overflow: hidden;
}
.controls-bar > .control-group {
flex: 0 0 auto;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
gap: 4px;
padding: 6px 10px;
background: rgba(74, 158, 255, 0.03);
border: 1px solid rgba(74, 158, 255, 0.1);
border-radius: 6px;
}
.controls-bar > .control-group > .control-group-items {
margin-top: auto;
}
.control-group {
@@ -687,7 +762,7 @@ body {
border: 1px solid rgba(74, 158, 255, 0.3);
border-radius: 4px;
color: var(--accent-cyan);
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 10px;
}
@@ -698,7 +773,7 @@ body {
border: 1px solid rgba(74, 158, 255, 0.3);
border-radius: 4px;
color: var(--accent-cyan);
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 10px;
}
@@ -717,7 +792,7 @@ body {
border: none;
background: var(--accent-green);
color: #fff;
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
@@ -834,7 +909,7 @@ body {
display: flex !important;
flex-direction: column !important;
height: auto !important;
min-height: calc(100dvh - 95px);
min-height: calc(100dvh - 160px);
overflow-y: auto !important;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
@@ -1004,7 +1079,7 @@ body {
padding: 6px 12px;
background: rgba(0, 0, 0, 0.2);
border-bottom: 1px solid rgba(245, 158, 11, 0.1);
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 9px;
}
@@ -1079,7 +1154,7 @@ body {
}
.dsc-message-category {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
@@ -1096,13 +1171,13 @@ body {
}
.dsc-message-time {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 9px;
color: var(--text-dim);
}
.dsc-message-mmsi {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 11px;
color: var(--accent-orange);
}
@@ -1120,7 +1195,7 @@ body {
}
.dsc-message-pos {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 9px;
color: var(--text-secondary);
}
@@ -1148,7 +1223,7 @@ body {
}
.dsc-distress-alert .dsc-alert-header {
font-family: 'Orbitron', 'JetBrains Mono', monospace;
font-family: 'Orbitron', 'Space Mono', monospace;
font-size: 24px;
font-weight: 700;
color: var(--accent-red);
@@ -1157,7 +1232,7 @@ body {
}
.dsc-distress-alert .dsc-alert-mmsi {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 16px;
color: var(--accent-cyan);
margin-bottom: 8px;
@@ -1177,7 +1252,7 @@ body {
}
.dsc-distress-alert .dsc-alert-position {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 14px;
color: var(--accent-cyan);
margin-bottom: 16px;
@@ -1188,7 +1263,7 @@ body {
border: none;
color: white;
padding: 10px 24px;
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+371
View File
@@ -0,0 +1,371 @@
/* Function Strip (Action Bar) - Shared across modes
* Based on APRS strip pattern, reusable for Pager, Sensor, Bluetooth, WiFi, TSCM, etc.
*/
.function-strip {
background: linear-gradient(180deg, var(--bg-panel) 0%, var(--bg-dark) 100%);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 8px 12px;
margin-bottom: 10px;
overflow: visible;
min-height: 44px;
}
.function-strip-inner {
display: flex;
align-items: center;
gap: 8px;
min-width: max-content;
}
/* Stats */
.function-strip .strip-stat {
display: flex;
flex-direction: column;
align-items: center;
padding: 6px 10px;
background: rgba(74, 158, 255, 0.05);
border: 1px solid rgba(74, 158, 255, 0.15);
border-radius: 4px;
min-width: 55px;
}
.function-strip .strip-stat:hover {
background: rgba(74, 158, 255, 0.1);
border-color: rgba(74, 158, 255, 0.3);
}
.function-strip .strip-value {
font-family: var(--font-mono);
font-size: 14px;
font-weight: 600;
color: var(--accent-cyan);
line-height: 1.2;
}
.function-strip .strip-label {
font-size: 8px;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 1px;
}
.function-strip .strip-divider {
width: 1px;
height: 28px;
background: var(--border-color);
margin: 0 4px;
}
/* Signal stat coloring */
.function-strip .signal-stat.good .strip-value { color: var(--accent-green); }
.function-strip .signal-stat.warning .strip-value { color: var(--accent-yellow); }
.function-strip .signal-stat.poor .strip-value { color: var(--accent-red); }
/* Controls */
.function-strip .strip-control {
display: flex;
align-items: center;
gap: 4px;
}
.function-strip .strip-select {
background: rgba(0,0,0,0.3);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 4px 8px;
border-radius: 4px;
font-size: 10px;
cursor: pointer;
}
.function-strip .strip-select:hover {
border-color: var(--accent-cyan);
}
.function-strip .strip-select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.function-strip .strip-input-label {
font-size: 9px;
color: var(--text-muted);
font-weight: 600;
}
.function-strip .strip-input {
background: rgba(0,0,0,0.3);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 4px 6px;
border-radius: 4px;
font-size: 10px;
width: 50px;
text-align: center;
}
.function-strip .strip-input:hover,
.function-strip .strip-input:focus {
border-color: var(--accent-cyan);
outline: none;
}
.function-strip .strip-input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Wider input for frequency values */
.function-strip .strip-input.wide {
width: 70px;
}
/* Tool Status Indicators */
.function-strip .strip-tools {
display: flex;
gap: 4px;
}
.function-strip .strip-tool {
font-size: 9px;
font-weight: 600;
padding: 3px 6px;
border-radius: 3px;
background: rgba(255, 59, 48, 0.2);
color: var(--accent-red);
border: 1px solid rgba(255, 59, 48, 0.3);
}
.function-strip .strip-tool.ok {
background: rgba(0, 255, 136, 0.1);
color: var(--accent-green);
border-color: rgba(0, 255, 136, 0.3);
}
.function-strip .strip-tool.warn {
background: rgba(255, 193, 7, 0.2);
color: var(--accent-yellow);
border-color: rgba(255, 193, 7, 0.3);
}
/* Buttons */
.function-strip .strip-btn {
background: rgba(74, 158, 255, 0.1);
border: 1px solid rgba(74, 158, 255, 0.2);
color: var(--text-primary);
padding: 6px 12px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.function-strip .strip-btn:hover:not(:disabled) {
background: rgba(74, 158, 255, 0.2);
border-color: rgba(74, 158, 255, 0.4);
}
.function-strip .strip-btn.primary {
background: linear-gradient(135deg, var(--accent-green) 0%, #10b981 100%);
border: none;
color: #000;
}
.function-strip .strip-btn.primary:hover:not(:disabled) {
filter: brightness(1.1);
}
.function-strip .strip-btn.stop {
background: linear-gradient(135deg, var(--accent-red) 0%, #dc2626 100%);
border: none;
color: #fff;
}
.function-strip .strip-btn.stop:hover:not(:disabled) {
filter: brightness(1.1);
}
.function-strip .strip-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Status indicator */
.function-strip .strip-status {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: rgba(0,0,0,0.2);
border-radius: 4px;
font-size: 10px;
font-weight: 600;
color: var(--text-secondary);
}
.function-strip .status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-muted);
}
.function-strip .status-dot.inactive {
background: var(--text-muted);
}
.function-strip .status-dot.active,
.function-strip .status-dot.scanning,
.function-strip .status-dot.decoding {
background: var(--accent-cyan);
animation: strip-pulse 1.5s ease-in-out infinite;
}
.function-strip .status-dot.listening,
.function-strip .status-dot.tracking,
.function-strip .status-dot.receiving {
background: var(--accent-green);
animation: strip-pulse 1.5s ease-in-out infinite;
}
.function-strip .status-dot.sweeping {
background: var(--accent-orange);
animation: strip-pulse 1s ease-in-out infinite;
}
.function-strip .status-dot.error {
background: var(--accent-red);
}
@keyframes strip-pulse {
0%, 100% { opacity: 1; box-shadow: 0 0 4px 2px currentColor; }
50% { opacity: 0.6; box-shadow: none; }
}
/* Time display */
.function-strip .strip-time {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-muted);
padding: 4px 8px;
background: rgba(0,0,0,0.2);
border-radius: 4px;
white-space: nowrap;
}
/* Mode-specific accent colors */
.function-strip.pager-strip .strip-stat {
background: rgba(255, 193, 7, 0.05);
border-color: rgba(255, 193, 7, 0.15);
}
.function-strip.pager-strip .strip-stat:hover {
background: rgba(255, 193, 7, 0.1);
border-color: rgba(255, 193, 7, 0.3);
}
.function-strip.pager-strip .strip-value {
color: var(--accent-yellow);
}
.function-strip.sensor-strip .strip-stat {
background: rgba(0, 255, 136, 0.05);
border-color: rgba(0, 255, 136, 0.15);
}
.function-strip.sensor-strip .strip-stat:hover {
background: rgba(0, 255, 136, 0.1);
border-color: rgba(0, 255, 136, 0.3);
}
.function-strip.sensor-strip .strip-value {
color: var(--accent-green);
}
.function-strip.bt-strip .strip-stat {
background: rgba(0, 122, 255, 0.05);
border-color: rgba(0, 122, 255, 0.15);
}
.function-strip.bt-strip .strip-stat:hover {
background: rgba(0, 122, 255, 0.1);
border-color: rgba(0, 122, 255, 0.3);
}
.function-strip.bt-strip .strip-value {
color: #0a84ff;
}
.function-strip.wifi-strip .strip-stat {
background: rgba(255, 149, 0, 0.05);
border-color: rgba(255, 149, 0, 0.15);
}
.function-strip.wifi-strip .strip-stat:hover {
background: rgba(255, 149, 0, 0.1);
border-color: rgba(255, 149, 0, 0.3);
}
.function-strip.wifi-strip .strip-value {
color: var(--accent-orange);
}
.function-strip.tscm-strip {
margin-top: 4px; /* Extra clearance to prevent top clipping */
}
.function-strip.tscm-strip .strip-stat {
background: rgba(255, 59, 48, 0.15);
border: 1px solid rgba(255, 59, 48, 0.4);
}
.function-strip.tscm-strip .strip-stat:hover {
background: rgba(255, 59, 48, 0.25);
border-color: rgba(255, 59, 48, 0.6);
}
.function-strip.tscm-strip .strip-value {
color: #ef4444; /* Explicit red color */
}
.function-strip.tscm-strip .strip-label {
color: #9ca3af; /* Explicit light gray */
}
.function-strip.tscm-strip .strip-select {
color: #e8eaed; /* Explicit white for selects */
background: rgba(0, 0, 0, 0.4);
}
.function-strip.tscm-strip .strip-btn {
color: #e8eaed; /* Explicit white for buttons */
}
.function-strip.tscm-strip .strip-tool {
color: #e8eaed; /* Explicit white for tool indicators */
}
.function-strip.tscm-strip .strip-time,
.function-strip.tscm-strip .strip-status span {
color: #9ca3af; /* Explicit gray for status/time */
}
.function-strip.rtlamr-strip .strip-stat {
background: rgba(175, 82, 222, 0.05);
border-color: rgba(175, 82, 222, 0.15);
}
.function-strip.rtlamr-strip .strip-stat:hover {
background: rgba(175, 82, 222, 0.1);
border-color: rgba(175, 82, 222, 0.3);
}
.function-strip.rtlamr-strip .strip-value {
color: #af52de;
}
.function-strip.listening-strip .strip-stat {
background: rgba(74, 158, 255, 0.05);
border-color: rgba(74, 158, 255, 0.15);
}
.function-strip.listening-strip .strip-stat:hover {
background: rgba(74, 158, 255, 0.1);
border-color: rgba(74, 158, 255, 0.3);
}
.function-strip.listening-strip .strip-value {
color: var(--accent-cyan);
}
/* Threat-colored stats for TSCM */
.function-strip .strip-stat.threat-high .strip-value { color: var(--accent-red); }
.function-strip .strip-stat.threat-review .strip-value { color: var(--accent-orange); }
.function-strip .strip-stat.threat-info .strip-value { color: var(--accent-cyan); }
+9 -1
View File
@@ -14,10 +14,18 @@
.radar-device {
transition: transform 0.2s ease;
transform-origin: center center;
cursor: pointer;
}
.radar-device:hover {
transform: scale(1.3);
transform: scale(1.2);
}
/* Invisible larger hit area to prevent hover flicker */
.radar-device-hitarea {
fill: transparent;
pointer-events: all;
}
.radar-dot-pulse circle:first-child {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+431
View File
@@ -0,0 +1,431 @@
/**
* INTERCEPT Base Styles
* Reset, typography, and foundational element styles
* Requires: variables.css to be imported first
*/
/* ============================================
CSS RESET
============================================ */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
-webkit-text-size-adjust: 100%;
-moz-tab-size: 4;
tab-size: 4;
}
body {
font-family: var(--font-sans);
font-size: var(--text-base);
line-height: var(--leading-normal);
color: var(--text-primary);
background-color: var(--bg-primary);
background-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(90deg, var(--grid-line) 1px, transparent 1px);
background-size: 40px 40px, auto, auto, 48px 48px, 48px 48px;
background-attachment: fixed;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ============================================
TYPOGRAPHY
============================================ */
h1, h2, h3, h4, h5, h6 {
font-weight: var(--font-semibold);
line-height: var(--leading-tight);
color: var(--text-primary);
letter-spacing: 0.01em;
}
h1 { font-size: var(--text-4xl); }
h2 { font-size: var(--text-3xl); }
h3 { font-size: var(--text-2xl); }
h4 { font-size: var(--text-xl); }
h5 { font-size: var(--text-lg); }
h6 { font-size: var(--text-base); }
p {
margin-bottom: var(--space-4);
}
a {
color: var(--accent-cyan);
text-decoration: none;
transition: color var(--transition-fast);
}
a:hover {
color: var(--accent-cyan-hover);
}
a:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
}
strong, b {
font-weight: var(--font-semibold);
}
small {
font-size: var(--text-sm);
}
code, kbd, pre, samp {
font-family: var(--font-mono);
font-size: 0.9em;
}
code {
background: var(--bg-elevated);
border: 1px solid var(--border-color);
padding: 2px 6px;
border-radius: var(--radius-sm);
}
pre {
background: var(--bg-elevated);
border: 1px solid var(--border-color);
padding: var(--space-4);
border-radius: var(--radius-md);
overflow-x: auto;
}
pre code {
background: none;
padding: 0;
}
/* ============================================
FORM ELEMENTS
============================================ */
button,
input,
select,
textarea {
font-family: inherit;
font-size: inherit;
line-height: inherit;
color: inherit;
}
button {
cursor: pointer;
border: none;
background: none;
}
button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
input,
select,
textarea {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-3);
color: var(--text-primary);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
input:focus,
select:focus,
textarea:focus {
outline: none;
border-color: var(--accent-cyan);
box-shadow: 0 0 0 2px var(--accent-cyan-dim);
}
input::placeholder,
textarea::placeholder {
color: var(--text-dim);
}
select {
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239fb0c7' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
padding-right: 28px;
}
input[type="checkbox"],
input[type="radio"] {
width: 16px;
height: 16px;
padding: 0;
cursor: pointer;
accent-color: var(--accent-cyan);
}
label {
display: block;
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--text-secondary);
margin-bottom: var(--space-1);
}
/* ============================================
TABLES
============================================ */
table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
}
th,
td {
padding: var(--space-2) var(--space-3);
text-align: left;
border-bottom: 1px solid var(--border-color);
}
th {
font-weight: var(--font-semibold);
color: var(--text-secondary);
background: var(--bg-tertiary);
text-transform: uppercase;
font-size: var(--text-xs);
letter-spacing: 0.05em;
}
tr:hover td {
background: var(--bg-elevated);
}
/* ============================================
LISTS
============================================ */
ul, ol {
padding-left: var(--space-6);
margin-bottom: var(--space-4);
}
li {
margin-bottom: var(--space-1);
}
/* ============================================
UTILITY CLASSES
============================================ */
/* Text colors */
.text-primary { color: var(--text-primary); }
.text-secondary { color: var(--text-secondary); }
.text-muted { color: var(--text-muted); }
.text-cyan { color: var(--accent-cyan); }
.text-green { color: var(--accent-green); }
.text-red { color: var(--accent-red); }
.text-orange { color: var(--accent-orange); }
.text-amber { color: var(--accent-amber); }
/* Font utilities */
.font-mono { font-family: var(--font-mono); }
.font-medium { font-weight: var(--font-medium); }
.font-semibold { font-weight: var(--font-semibold); }
.font-bold { font-weight: var(--font-bold); }
/* Text sizes */
.text-xs { font-size: var(--text-xs); }
.text-sm { font-size: var(--text-sm); }
.text-base { font-size: var(--text-base); }
.text-lg { font-size: var(--text-lg); }
.text-xl { font-size: var(--text-xl); }
/* Display */
.hidden { display: none !important; }
.block { display: block; }
.inline-block { display: inline-block; }
.flex { display: flex; }
.inline-flex { display: inline-flex; }
.grid { display: grid; }
/* Flexbox */
.items-center { align-items: center; }
.justify-center { justify-content: center; }
.justify-between { justify-content: space-between; }
.flex-1 { flex: 1; }
.gap-1 { gap: var(--space-1); }
.gap-2 { gap: var(--space-2); }
.gap-3 { gap: var(--space-3); }
.gap-4 { gap: var(--space-4); }
/* Spacing */
.m-0 { margin: 0; }
.mt-2 { margin-top: var(--space-2); }
.mt-4 { margin-top: var(--space-4); }
.mb-2 { margin-bottom: var(--space-2); }
.mb-4 { margin-bottom: var(--space-4); }
.p-2 { padding: var(--space-2); }
.p-3 { padding: var(--space-3); }
.p-4 { padding: var(--space-4); }
/* Borders */
.rounded { border-radius: var(--radius-md); }
.rounded-lg { border-radius: var(--radius-lg); }
.border { border: 1px solid var(--border-color); }
/* Truncate text */
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Screen reader only */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* ============================================
SCROLLBAR STYLING
============================================ */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
}
::-webkit-scrollbar-thumb {
background: var(--border-light);
border-radius: var(--radius-full);
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-dim);
}
/* Firefox scrollbar */
* {
scrollbar-width: thin;
scrollbar-color: var(--border-light) var(--bg-secondary);
}
/* ============================================
SELECTION
============================================ */
::selection {
background: var(--accent-cyan-dim);
color: var(--text-primary);
}
/* ============================================
UX POLISH - TRANSITIONS & INTERACTIONS
============================================ */
/* Smooth page transitions */
html {
scroll-behavior: smooth;
}
/* Better focus ring for all interactive elements */
:focus-visible {
outline: 2px solid var(--accent-cyan);
outline-offset: 2px;
}
/* Remove focus ring for mouse users */
:focus:not(:focus-visible) {
outline: none;
}
/* Active state feedback */
button:active:not(:disabled),
a:active,
[role="button"]:active {
transform: scale(0.98);
}
/* Smooth transitions for all interactive elements */
button,
a,
input,
select,
textarea,
[role="button"] {
transition:
color var(--transition-fast),
background-color var(--transition-fast),
border-color var(--transition-fast),
box-shadow var(--transition-fast),
transform var(--transition-fast),
opacity var(--transition-fast);
}
/* Subtle hover lift effect for cards and panels */
.card:hover,
.panel:hover {
box-shadow: var(--shadow-md);
}
/* Link underline on hover */
a:hover {
text-decoration: underline;
text-underline-offset: 2px;
}
/* Skip link for accessibility */
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: var(--accent-cyan);
color: var(--bg-primary);
padding: var(--space-2) var(--space-4);
z-index: 9999;
transition: top var(--transition-fast);
}
.skip-link:focus {
top: 0;
}
/* Reduced motion preference */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* High contrast mode support */
@media (prefers-contrast: high) {
:root {
--border-color: #4b5563;
--text-secondary: #d1d5db;
}
}
+842
View File
@@ -0,0 +1,842 @@
/**
* INTERCEPT UI Components
* Reusable component styles for buttons, cards, badges, etc.
* Requires: variables.css and base.css
*/
/* ============================================
BUTTONS
============================================ */
/* Base button */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
font-size: var(--text-sm);
font-weight: var(--font-medium);
border-radius: var(--radius-md);
border: 1px solid transparent;
cursor: pointer;
transition: all var(--transition-fast);
white-space: nowrap;
text-decoration: none;
font-family: var(--font-mono);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.btn:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Button variants */
.btn-primary {
background: var(--accent-cyan);
color: var(--text-inverse);
border-color: var(--accent-cyan);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-cyan-hover);
border-color: var(--accent-cyan-hover);
}
.btn-secondary {
background: var(--bg-secondary);
color: var(--text-primary);
border-color: var(--border-color);
}
.btn-secondary:hover:not(:disabled) {
background: var(--bg-tertiary);
border-color: var(--border-light);
}
.btn-ghost {
background: transparent;
color: var(--text-secondary);
border-color: var(--border-color);
}
.btn-ghost:hover:not(:disabled) {
background: var(--bg-elevated);
color: var(--text-primary);
}
.btn-danger {
background: var(--accent-red);
color: white;
border-color: var(--accent-red);
}
.btn-danger:hover:not(:disabled) {
background: #dc2626;
border-color: #dc2626;
}
.btn-success {
background: var(--accent-green);
color: white;
border-color: var(--accent-green);
}
.btn-success:hover:not(:disabled) {
background: #16a34a;
border-color: #16a34a;
}
/* Button sizes */
.btn-sm {
padding: var(--space-1) var(--space-2);
font-size: var(--text-xs);
}
.btn-lg {
padding: var(--space-3) var(--space-6);
font-size: var(--text-base);
}
/* Icon button */
.btn-icon {
padding: var(--space-2);
width: 36px;
height: 36px;
}
.btn-icon.btn-sm {
width: 28px;
height: 28px;
padding: var(--space-1);
}
/* ============================================
CARDS / PANELS
============================================ */
.card {
background: linear-gradient(180deg, var(--bg-card) 0%, var(--bg-secondary) 100%);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-sm);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
position: relative;
}
.card-header-title {
font-size: var(--text-xs);
font-weight: var(--font-semibold);
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
}
.card-body {
padding: var(--space-4);
}
.card-footer {
padding: var(--space-3) var(--space-4);
border-top: 1px solid var(--border-color);
background: var(--bg-secondary);
}
/* Panel variant (used in dashboards) */
.panel {
background: linear-gradient(180deg, var(--bg-card) 0%, var(--bg-secondary) 100%);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-sm);
}
@supports (clip-path: polygon(0 0)) {
.card,
.panel {
--notch-size: 6px;
border-radius: 0;
clip-path: polygon(
var(--notch-size) 0,
calc(100% - var(--notch-size)) 0,
100% var(--notch-size),
100% calc(100% - var(--notch-size)),
calc(100% - var(--notch-size)) 100%,
var(--notch-size) 100%,
0 calc(100% - var(--notch-size)),
0 var(--notch-size)
);
}
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--border-color);
background: linear-gradient(180deg, var(--bg-elevated) 0%, var(--bg-secondary) 100%);
font-size: var(--text-xs);
font-weight: var(--font-semibold);
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-secondary);
position: relative;
}
.card-header::before,
.panel-header::before {
content: '';
position: absolute;
top: 0;
left: var(--space-3);
width: 36px;
height: 2px;
background: var(--accent-cyan);
opacity: 0.7;
}
.panel-indicator {
width: 8px;
height: 8px;
border-radius: var(--radius-full);
background: var(--status-offline);
}
.panel-indicator.active {
background: var(--status-online);
box-shadow: 0 0 8px var(--status-online);
}
.panel-content {
padding: var(--space-3);
}
/* ============================================
BADGES
============================================ */
.badge {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: 2px var(--space-2);
font-size: var(--text-xs);
font-weight: var(--font-medium);
border-radius: var(--radius-full);
background: var(--bg-tertiary);
color: var(--text-secondary);
border: 1px solid var(--border-color);
}
.badge-primary {
background: var(--accent-cyan-dim);
color: var(--accent-cyan);
}
.badge-success {
background: var(--accent-green-dim);
color: var(--accent-green);
}
.badge-warning {
background: var(--accent-orange-dim);
color: var(--accent-orange);
}
.badge-danger {
background: var(--accent-red-dim);
color: var(--accent-red);
}
/* ============================================
DATA TAGS
============================================ */
.data-tag {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: 2px var(--space-2);
font-size: 10px;
font-family: var(--font-mono);
text-transform: uppercase;
letter-spacing: 0.12em;
border-radius: var(--radius-sm);
border: 1px solid var(--border-color);
background: var(--bg-tertiary);
color: var(--text-secondary);
box-shadow: inset 0 0 0 1px var(--border-glow);
}
.data-tag--accent {
border-color: var(--accent-cyan);
color: var(--accent-cyan);
background: var(--accent-cyan-dim);
}
.data-tag--warning {
border-color: var(--accent-amber);
color: var(--accent-amber);
background: var(--accent-amber-dim);
}
.data-tag--success {
border-color: var(--accent-green);
color: var(--accent-green);
background: var(--accent-green-dim);
}
.data-tag--danger {
border-color: var(--accent-red);
color: var(--accent-red);
background: var(--accent-red-dim);
}
/* ============================================
STATUS INDICATORS
============================================ */
.status-dot {
width: 8px;
height: 8px;
border-radius: var(--radius-full);
background: var(--status-offline);
flex-shrink: 0;
}
.status-dot.online,
.status-dot.active {
background: var(--status-online);
box-shadow: 0 0 4px var(--status-online);
}
.status-dot.warning {
background: var(--status-warning);
box-shadow: 0 0 4px var(--status-warning);
}
.status-dot.error,
.status-dot.offline {
background: var(--status-error);
}
.status-dot.inactive {
background: var(--status-offline);
}
/* Pulse animation for active status */
.status-dot.pulse {
animation: statusPulse 2s ease-in-out infinite;
}
@keyframes statusPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* ============================================
EMPTY STATE
============================================ */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-8) var(--space-4);
text-align: center;
color: var(--text-muted);
}
.empty-state-icon {
width: 48px;
height: 48px;
margin-bottom: var(--space-4);
opacity: 0.5;
}
.empty-state-title {
font-size: var(--text-base);
font-weight: var(--font-medium);
color: var(--text-secondary);
margin-bottom: var(--space-2);
}
.empty-state-description {
font-size: var(--text-sm);
color: var(--text-dim);
max-width: 300px;
}
.empty-state-action {
margin-top: var(--space-4);
}
/* ============================================
LOADING STATES
============================================ */
.spinner {
width: 20px;
height: 20px;
border: 2px solid var(--border-color);
border-top-color: var(--accent-cyan);
border-radius: var(--radius-full);
animation: spin 0.8s linear infinite;
}
.spinner-sm {
width: 14px;
height: 14px;
border-width: 2px;
}
.spinner-lg {
width: 32px;
height: 32px;
border-width: 3px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Loading overlay */
.loading-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-overlay);
z-index: var(--z-modal);
}
/* Skeleton loader */
.skeleton {
background: linear-gradient(
90deg,
var(--bg-tertiary) 25%,
var(--bg-elevated) 50%,
var(--bg-tertiary) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: var(--radius-sm);
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* ============================================
STATS STRIP
============================================ */
.stats-strip {
display: flex;
align-items: center;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
padding: 0 var(--space-4);
height: var(--stats-strip-height);
overflow-x: auto;
gap: var(--space-1);
}
.strip-stat {
display: flex;
flex-direction: column;
align-items: center;
padding: 0 var(--space-3);
min-width: fit-content;
}
.strip-value {
font-family: var(--font-mono);
font-size: var(--text-sm);
font-weight: var(--font-semibold);
color: var(--accent-cyan);
line-height: 1;
}
.strip-label {
font-size: 9px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.05em;
line-height: 1;
margin-top: 2px;
}
.strip-divider {
width: 1px;
height: 20px;
background: var(--border-color);
margin: 0 var(--space-2);
}
/* ============================================
FORM GROUPS
============================================ */
.form-group {
margin-bottom: var(--space-4);
}
.form-group label {
display: block;
margin-bottom: var(--space-1);
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--text-secondary);
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
}
.form-help {
margin-top: var(--space-1);
font-size: var(--text-xs);
color: var(--text-dim);
}
.form-error {
margin-top: var(--space-1);
font-size: var(--text-xs);
color: var(--accent-red);
}
/* Inline checkbox/radio */
.form-check {
display: flex;
align-items: center;
gap: var(--space-2);
cursor: pointer;
}
.form-check input {
width: auto;
}
.form-check label {
margin-bottom: 0;
cursor: pointer;
}
/* ============================================
ALERTS / TOASTS
============================================ */
.alert {
display: flex;
align-items: flex-start;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
border: 1px solid;
font-size: var(--text-sm);
}
.alert-info {
background: var(--accent-cyan-dim);
border-color: var(--accent-cyan);
color: var(--accent-cyan);
}
.alert-success {
background: var(--accent-green-dim);
border-color: var(--accent-green);
color: var(--accent-green);
}
.alert-warning {
background: var(--accent-orange-dim);
border-color: var(--accent-orange);
color: var(--accent-orange);
}
.alert-danger {
background: var(--accent-red-dim);
border-color: var(--accent-red);
color: var(--accent-red);
}
/* ============================================
TOOLTIPS
============================================ */
[data-tooltip] {
position: relative;
}
[data-tooltip]::after {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
padding: var(--space-1) var(--space-2);
background: var(--bg-elevated);
color: var(--text-primary);
font-size: var(--text-xs);
border-radius: var(--radius-sm);
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: opacity var(--transition-fast), visibility var(--transition-fast);
z-index: var(--z-tooltip);
pointer-events: none;
margin-bottom: var(--space-1);
box-shadow: var(--shadow-md);
}
[data-tooltip]:hover::after {
opacity: 1;
visibility: visible;
}
/* ============================================
ICONS
============================================ */
.icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
flex-shrink: 0;
}
.icon svg {
width: 100%;
height: 100%;
}
.icon--sm {
width: 16px;
height: 16px;
}
.icon--lg {
width: 24px;
height: 24px;
}
/* ============================================
SECTION HEADERS
============================================ */
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-4);
padding-bottom: var(--space-2);
border-bottom: 1px solid var(--border-color);
}
.section-title {
font-size: var(--text-sm);
font-weight: var(--font-semibold);
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
position: relative;
padding-left: var(--space-3);
}
.section-title::before {
content: '';
position: absolute;
left: 0;
top: 50%;
width: 2px;
height: 6px;
background: var(--accent-cyan);
transform: translateY(-50%);
opacity: 0.7;
box-shadow: 0 6px 0 var(--accent-cyan);
}
/* ============================================
DIVIDERS
============================================ */
.divider {
height: 1px;
background-image: repeating-linear-gradient(
90deg,
var(--border-light),
var(--border-light) 6px,
transparent 6px,
transparent 12px
);
margin: var(--space-4) 0;
}
.divider-vertical {
width: 1px;
height: 100%;
background-image: repeating-linear-gradient(
180deg,
var(--border-light),
var(--border-light) 6px,
transparent 6px,
transparent 12px
);
margin: 0 var(--space-3);
}
/* ============================================
UX POLISH - ENHANCED INTERACTIONS
============================================ */
/* Button hover lift */
.btn:hover:not(:disabled) {
box-shadow: 0 0 0 1px var(--border-light);
}
.btn:active:not(:disabled) {
box-shadow: inset 0 0 0 1px var(--border-light);
}
/* Card/Panel hover effects */
.card,
.panel {
transition:
box-shadow var(--transition-base),
border-color var(--transition-base),
transform var(--transition-base);
}
.card:hover,
.panel:hover {
border-color: var(--border-light);
}
/* Stats strip value highlight on hover */
.strip-stat {
transition: background-color var(--transition-fast);
border-radius: var(--radius-sm);
cursor: default;
}
.strip-stat:hover {
background: var(--bg-tertiary);
}
/* Status dot pulse animation */
.status-dot.online,
.status-dot.active {
animation: statusGlow 2s ease-in-out infinite;
}
@keyframes statusGlow {
0%, 100% {
box-shadow: 0 0 4px var(--status-online);
}
50% {
box-shadow: 0 0 8px var(--status-online), 0 0 12px var(--status-online);
}
}
/* Badge hover effect */
.badge {
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
}
.badge:hover {
transform: scale(1.02);
}
/* Alert entrance animation */
.alert {
animation: alertSlideIn 0.3s ease-out;
}
@keyframes alertSlideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Loading spinner smooth appearance */
.spinner {
animation: spin 0.8s linear infinite, fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* Input focus glow */
input:focus,
select:focus,
textarea:focus {
border-color: var(--accent-cyan);
box-shadow: 0 0 0 2px var(--accent-cyan-dim);
}
/* Nav item active indicator */
.nav-item,
.mode-nav-btn,
.mobile-nav-btn {
position: relative;
}
.nav-item.active::after,
.mode-nav-btn.active::after,
.mobile-nav-btn.active::after {
content: '';
position: absolute;
left: 12%;
right: 12%;
bottom: 2px;
height: 1px;
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
opacity: 0.75;
animation: railPulse 2.6s ease-in-out infinite;
}
@keyframes railPulse {
0%, 100% { opacity: 0.45; }
50% { opacity: 0.9; }
}
/* Smooth tooltip appearance */
[data-tooltip]::after {
transition:
opacity var(--transition-fast),
visibility var(--transition-fast),
transform var(--transition-fast);
transform: translateX(-50%) translateY(-4px);
}
[data-tooltip]:hover::after {
transform: translateX(-50%) translateY(0);
}
/* Disabled state with better visual feedback */
:disabled,
.disabled {
opacity: 0.5;
cursor: not-allowed;
filter: grayscale(30%);
}
File diff suppressed because it is too large Load Diff
+207
View File
@@ -0,0 +1,207 @@
/**
* INTERCEPT Design Tokens
* Single source of truth for colors, spacing, typography, and effects
* Import this file FIRST in any stylesheet that needs design tokens
*/
:root {
/* ============================================
COLOR PALETTE - Dark Theme (Default)
============================================ */
/* Backgrounds - layered depth system */
--bg-primary: #0b1118;
--bg-secondary: #101823;
--bg-tertiary: #151f2b;
--bg-card: #121a25;
--bg-elevated: #1b2734;
--bg-overlay: rgba(8, 13, 20, 0.75);
/* Background aliases for components */
--bg-dark: var(--bg-primary);
--bg-panel: var(--bg-secondary);
/* Accent colors */
--accent-cyan: #4aa3ff;
--accent-cyan-dim: rgba(74, 163, 255, 0.16);
--accent-cyan-hover: #6bb3ff;
--accent-green: #38c180;
--accent-green-dim: rgba(56, 193, 128, 0.18);
--accent-red: #e25d5d;
--accent-red-dim: rgba(226, 93, 93, 0.16);
--accent-orange: #d6a85e;
--accent-orange-dim: rgba(214, 168, 94, 0.16);
--accent-amber: #d6a85e;
--accent-amber-dim: rgba(214, 168, 94, 0.18);
--accent-yellow: #e1c26b;
--accent-purple: #8f7bd6;
/* Text hierarchy */
--text-primary: #d7e0ee;
--text-secondary: #9fb0c7;
--text-dim: #6f7f94;
--text-muted: #445266;
--text-inverse: #0b1118;
/* Borders */
--border-color: #263246;
--border-light: #354458;
--border-glow: rgba(74, 163, 255, 0.25);
--border-focus: var(--accent-cyan);
/* Status colors */
--status-online: #38c180;
--status-warning: #d6a85e;
--status-error: #e25d5d;
--status-offline: #6f7f94;
--status-info: #4aa3ff;
/* Subtle grid/pattern */
--grid-line: rgba(74, 163, 255, 0.1);
--grid-dot: rgba(255, 255, 255, 0.03);
--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='%23ffffff'%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");
/* ============================================
SPACING SCALE
============================================ */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-16: 64px;
/* ============================================
TYPOGRAPHY
============================================ */
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
/* Font sizes */
--text-xs: 10px;
--text-sm: 12px;
--text-base: 14px;
--text-lg: 16px;
--text-xl: 18px;
--text-2xl: 20px;
--text-3xl: 24px;
--text-4xl: 30px;
/* Font weights */
--font-normal: 400;
--font-medium: 500;
--font-semibold: 600;
--font-bold: 700;
/* Line heights */
--leading-tight: 1.25;
--leading-normal: 1.5;
--leading-relaxed: 1.75;
/* ============================================
BORDERS & RADIUS
============================================ */
--radius-sm: 3px;
--radius-md: 4px;
--radius-lg: 6px;
--radius-xl: 8px;
--radius-full: 9999px;
/* ============================================
SHADOWS
============================================ */
--shadow-sm: 0 1px 1px rgba(0, 0, 0, 0.35);
--shadow-md: 0 4px 8px rgba(0, 0, 0, 0.35);
--shadow-lg: 0 12px 18px rgba(0, 0, 0, 0.45);
--shadow-glow: 0 0 18px rgba(74, 163, 255, 0.16);
/* ============================================
TRANSITIONS
============================================ */
--transition-fast: 150ms ease;
--transition-base: 200ms ease;
--transition-slow: 300ms ease;
/* ============================================
Z-INDEX SCALE
============================================ */
--z-base: 0;
--z-dropdown: 100;
--z-sticky: 200;
--z-fixed: 300;
--z-modal-backdrop: 400;
--z-modal: 500;
--z-toast: 600;
--z-tooltip: 700;
/* ============================================
LAYOUT
============================================ */
--header-height: 60px;
--nav-height: 44px;
--sidebar-width: 280px;
--stats-strip-height: 36px;
--content-max-width: 1400px;
}
/* ============================================
LIGHT THEME OVERRIDES
============================================ */
[data-theme="light"] {
--bg-primary: #f4f7fb;
--bg-secondary: #e9eef5;
--bg-tertiary: #dde5f0;
--bg-card: #ffffff;
--bg-elevated: #f1f4f9;
--bg-overlay: rgba(244, 247, 251, 0.92);
/* Background aliases for components */
--bg-dark: var(--bg-primary);
--bg-panel: var(--bg-secondary);
--accent-cyan: #1f5fa8;
--accent-cyan-dim: rgba(31, 95, 168, 0.12);
--accent-cyan-hover: #2c73bf;
--accent-green: #1f8a57;
--accent-green-dim: rgba(31, 138, 87, 0.12);
--accent-red: #c74444;
--accent-red-dim: rgba(199, 68, 68, 0.12);
--accent-orange: #b5863a;
--accent-orange-dim: rgba(181, 134, 58, 0.12);
--accent-amber: #b5863a;
--accent-amber-dim: rgba(181, 134, 58, 0.12);
--text-primary: #122034;
--text-secondary: #3a4a5f;
--text-dim: #6b7c93;
--text-muted: #aab6c8;
--text-inverse: #f4f7fb;
--border-color: #d1d9e6;
--border-light: #c1ccdb;
--border-glow: rgba(31, 95, 168, 0.12);
--grid-line: rgba(31, 95, 168, 0.14);
--grid-dot: rgba(12, 18, 24, 0.06);
--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");
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.15);
--shadow-glow: 0 0 18px rgba(31, 95, 168, 0.1);
}
/* ============================================
REDUCED MOTION
============================================ */
@media (prefers-reduced-motion: reduce) {
:root {
--transition-fast: 0ms;
--transition-base: 0ms;
--transition-slow: 0ms;
}
}
+18 -67
View File
@@ -1,67 +1,18 @@
/* Local font declarations for offline mode */
/* Inter - Primary UI font */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/static/vendor/fonts/Inter-Regular.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/static/vendor/fonts/Inter-Medium.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/static/vendor/fonts/Inter-SemiBold.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/static/vendor/fonts/Inter-Bold.woff2') format('woff2');
}
/* JetBrains Mono - Monospace/code font */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/static/vendor/fonts/JetBrainsMono-Regular.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/static/vendor/fonts/JetBrainsMono-Medium.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/static/vendor/fonts/JetBrainsMono-SemiBold.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/static/vendor/fonts/JetBrainsMono-Bold.woff2') format('woff2');
}
/* Local font declarations for offline mode */
/* Space Mono - Console font */
@font-face {
font-family: 'Space Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/static/vendor/fonts/SpaceMono-Regular.woff2') format('woff2');
}
@font-face {
font-family: 'Space Mono';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/static/vendor/fonts/SpaceMono-Bold.woff2') format('woff2');
}
+439
View File
@@ -0,0 +1,439 @@
/* ============================================
Global Navigation Styles
Shared across all pages using nav.html
============================================ */
/* Icon base (kept lightweight for nav usage) */
.icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
flex-shrink: 0;
}
.icon svg {
width: 100%;
height: 100%;
}
.icon--sm {
width: 14px;
height: 14px;
}
/* Mode Navigation Bar */
.mode-nav {
display: none;
background: linear-gradient(180deg, rgba(17, 22, 32, 0.92), rgba(15, 20, 28, 0.88));
border-bottom: 1px solid var(--border-color, #202833);
padding: 0 20px;
position: relative;
z-index: 100;
backdrop-filter: blur(10px);
}
@media (min-width: 1024px) {
.mode-nav {
display: flex;
align-items: center;
gap: 8px;
height: 44px;
}
}
.mode-nav-label {
font-size: 9px;
color: var(--text-secondary, #b7c1cf);
text-transform: uppercase;
letter-spacing: 1px;
margin-right: 8px;
font-weight: 500;
font-family: var(--font-mono);
}
.mode-nav-divider {
width: 1px;
height: 24px;
background: var(--border-color, #202833);
margin: 0 12px;
}
.mode-nav-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
color: var(--text-secondary, #b7c1cf);
font-family: var(--font-sans);
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
text-decoration: none;
}
.mode-nav-btn .nav-label {
text-transform: uppercase;
letter-spacing: 0.08em;
font-family: var(--font-mono);
font-size: 10px;
}
.mode-nav-btn:hover {
background: rgba(27, 36, 51, 0.8);
color: var(--text-primary, #e7ebf2);
border-color: var(--border-color, #202833);
}
.mode-nav-btn.active {
background: rgba(27, 36, 51, 0.9);
color: var(--text-primary, #e7ebf2);
border-color: var(--accent-cyan, #4d7dbf);
box-shadow: inset 0 -2px 0 var(--accent-cyan, #4d7dbf);
}
.mode-nav-btn.active .nav-icon {
color: var(--accent-cyan, #4d7dbf);
}
.mode-nav-actions {
display: flex;
align-items: center;
gap: 16px;
}
.nav-action-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
background: rgba(24, 31, 44, 0.85);
border: 1px solid var(--border-light, #2b3645);
border-radius: 6px;
color: var(--text-primary, #e7ebf2);
font-family: var(--font-sans);
font-size: 11px;
font-weight: 500;
text-decoration: none;
cursor: pointer;
transition: all 0.15s ease;
}
.nav-action-btn .nav-label {
text-transform: uppercase;
letter-spacing: 0.08em;
font-family: var(--font-mono);
font-size: 10px;
}
.nav-action-btn:hover {
background: rgba(27, 36, 51, 0.95);
color: var(--text-primary, #e7ebf2);
box-shadow: 0 8px 16px rgba(5, 9, 15, 0.35);
border-color: var(--accent-cyan, #4d7dbf);
}
/* Dropdown Navigation */
.mode-nav-dropdown {
position: relative;
}
.mode-nav-dropdown-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
color: var(--text-secondary, #b7c1cf);
font-family: var(--font-sans);
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.mode-nav-dropdown-btn .nav-label {
text-transform: uppercase;
letter-spacing: 0.08em;
font-family: var(--font-mono);
font-size: 10px;
}
.mode-nav-dropdown-btn .dropdown-arrow {
width: 12px;
height: 12px;
margin-left: 4px;
transition: transform 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
}
.mode-nav-dropdown-btn .dropdown-arrow svg {
width: 100%;
height: 100%;
}
.mode-nav-dropdown-btn:hover {
background: rgba(27, 36, 51, 0.8);
color: var(--text-primary, #e7ebf2);
border-color: var(--border-color, #202833);
}
.mode-nav-dropdown.open .mode-nav-dropdown-btn {
background: rgba(27, 36, 51, 0.9);
color: var(--text-primary, #e7ebf2);
border-color: var(--border-color, #202833);
}
.mode-nav-dropdown.open .dropdown-arrow {
transform: rotate(180deg);
}
.mode-nav-dropdown.has-active .mode-nav-dropdown-btn {
background: rgba(27, 36, 51, 0.9);
color: var(--text-primary, #e7ebf2);
border-color: var(--accent-cyan, #4d7dbf);
box-shadow: inset 0 -2px 0 var(--accent-cyan, #4d7dbf);
}
.mode-nav-dropdown.has-active .mode-nav-dropdown-btn .nav-icon {
color: var(--accent-cyan, #4d7dbf);
}
.mode-nav-dropdown-menu {
position: absolute;
top: 100%;
left: 0;
margin-top: 4px;
min-width: 180px;
background: rgba(16, 22, 32, 0.98);
border: 1px solid var(--border-color, #202833);
border-radius: 8px;
box-shadow: 0 16px 36px rgba(5, 9, 15, 0.55);
opacity: 0;
visibility: hidden;
transform: translateY(-8px);
transition: all 0.15s ease;
z-index: 1000;
padding: 6px;
}
.mode-nav-dropdown.open .mode-nav-dropdown-menu {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.mode-nav-dropdown-menu .mode-nav-btn {
width: 100%;
justify-content: flex-start;
padding: 10px 12px;
border-radius: 6px;
}
.mode-nav-dropdown-menu .mode-nav-btn:hover {
background: rgba(27, 36, 51, 0.85);
}
.mode-nav-dropdown-menu .mode-nav-btn.active {
background: rgba(27, 36, 51, 0.95);
color: var(--text-primary, #e7ebf2);
box-shadow: inset 0 -2px 0 var(--accent-cyan, #4d7dbf);
}
/* Nav Bar Utilities */
.nav-utilities {
display: none;
align-items: center;
gap: 12px;
margin-left: auto;
flex-shrink: 0;
}
@media (min-width: 1024px) {
.nav-utilities {
display: flex;
}
}
.nav-clock {
display: flex;
align-items: center;
gap: 6px;
font-family: var(--font-mono);
font-size: 11px;
flex-shrink: 0;
white-space: nowrap;
}
.nav-clock .utc-label {
font-size: 9px;
color: var(--text-dim, #8a97a8);
text-transform: uppercase;
letter-spacing: 1px;
}
.nav-clock .utc-time {
color: var(--accent-cyan, #4d7dbf);
font-weight: 600;
}
.nav-divider {
width: 1px;
height: 20px;
background: var(--border-color, #202833);
}
.nav-tools {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.nav-tool-btn {
width: 28px;
height: 28px;
min-width: 28px;
border-radius: 6px;
background: rgba(20, 33, 53, 0.6);
border: 1px solid rgba(77, 125, 191, 0.12);
color: var(--text-secondary, #b7c1cf);
font-size: 14px;
font-weight: bold;
cursor: pointer;
transition: all 0.15s ease;
display: inline-flex;
align-items: center;
justify-content: center;
}
.nav-tool-btn:hover {
background: rgba(27, 36, 51, 0.9);
border-color: var(--accent-cyan, #4d7dbf);
color: var(--accent-cyan, #4d7dbf);
box-shadow: 0 6px 14px rgba(5, 9, 15, 0.35);
}
/* Position relative needed for absolute positioned icon children */
.nav-tool-btn {
position: relative;
}
.mode-nav-btn:focus-visible,
.mode-nav-dropdown-btn:focus-visible,
.nav-action-btn:focus-visible,
.nav-tool-btn:focus-visible {
outline: 2px solid var(--accent-cyan, #4d7dbf);
outline-offset: 2px;
}
/* Nav tool button SVG sizing and styling */
.nav-tool-btn svg {
width: 14px;
height: 14px;
stroke: currentColor;
}
.nav-tool-btn .icon {
display: inline-flex;
align-items: center;
justify-content: center;
}
.nav-tool-btn .icon svg {
width: 14px;
height: 14px;
stroke: currentColor;
}
/* Theme toggle icon states */
.nav-tool-btn .icon-sun,
.nav-tool-btn .icon-moon {
position: absolute;
transition: opacity 0.2s, transform 0.2s;
}
.nav-tool-btn .icon-sun {
opacity: 0;
transform: rotate(-90deg);
}
.nav-tool-btn .icon-moon {
opacity: 1;
transform: rotate(0deg);
}
[data-theme="light"] .nav-tool-btn .icon-sun {
opacity: 1;
transform: rotate(0deg);
}
[data-theme="light"] .nav-tool-btn .icon-moon {
opacity: 0;
transform: rotate(90deg);
}
/* Effects/animations toggle icon states */
.nav-tool-btn .icon-effects-off {
display: none;
}
[data-animations="off"] .nav-tool-btn .icon-effects-on {
display: none;
}
[data-animations="off"] .nav-tool-btn .icon-effects-off {
display: flex;
}
/* Main Dashboard Button in Nav */
a.nav-dashboard-btn,
a.nav-dashboard-btn:link,
a.nav-dashboard-btn:visited {
display: inline-flex !important;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 6px;
background: rgba(20, 33, 53, 0.6) !important;
border: 1px solid rgba(77, 125, 191, 0.12) !important;
color: #b7c1cf !important;
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
text-decoration: none !important;
}
a.nav-dashboard-btn:hover {
background: rgba(27, 36, 51, 0.9) !important;
border-color: #4d7dbf !important;
color: #4d7dbf !important;
box-shadow: 0 6px 14px rgba(5, 9, 15, 0.35);
}
.nav-dashboard-btn .icon {
width: 14px;
height: 14px;
}
.nav-dashboard-btn .icon svg {
width: 100%;
height: 100%;
stroke: currentColor;
}
.nav-dashboard-btn .nav-label {
font-family: var(--font-mono, 'JetBrains Mono', monospace);
letter-spacing: 0.5px;
}
+184
View File
@@ -0,0 +1,184 @@
/**
* Help Modal Styles
* Shared across all pages that include the help modal partial
*/
.help-modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.85);
z-index: 10000;
overflow-y: auto;
padding: 40px 20px;
}
.help-modal.active {
display: block;
}
.help-content {
max-width: 800px;
margin: 0 auto;
background: var(--bg-card, var(--bg-secondary, #0f1218));
border: 1px solid var(--border-color, #1f2937);
border-radius: 8px;
padding: 30px;
position: relative;
}
.help-content h2 {
color: var(--accent-cyan, #4a9eff);
margin-bottom: 20px;
font-size: 24px;
letter-spacing: 2px;
}
.help-content h3 {
color: var(--text-primary, #e8eaed);
margin: 25px 0 15px 0;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 1px;
border-bottom: 1px solid var(--border-color, #1f2937);
padding-bottom: 8px;
}
.help-close {
position: absolute;
top: 15px;
right: 15px;
background: none;
border: none;
color: var(--text-dim, #4b5563);
font-size: 24px;
cursor: pointer;
transition: color 0.2s;
}
.help-close:hover {
color: var(--accent-red, #ef4444);
}
.help-modal .icon-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
margin: 15px 0;
}
.help-modal .icon-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
background: var(--bg-primary, #0a0c10);
border: 1px solid var(--border-color, #1f2937);
border-radius: 4px;
font-size: 12px;
}
.help-modal .icon-item .icon {
font-size: 18px;
width: 30px;
text-align: center;
}
.help-modal .icon-item .desc {
color: var(--text-secondary, #9ca3af);
}
.help-modal .tip-list {
list-style: none;
padding: 0;
margin: 15px 0;
}
.help-modal .tip-list li {
padding: 8px 0;
padding-left: 20px;
position: relative;
color: var(--text-secondary, #9ca3af);
font-size: 13px;
border-bottom: 1px solid var(--border-color, #1f2937);
}
.help-modal .tip-list li:last-child {
border-bottom: none;
}
.help-modal .tip-list li::before {
content: '\203A';
position: absolute;
left: 0;
color: var(--accent-cyan, #4a9eff);
font-weight: bold;
}
.help-tabs {
display: flex;
gap: 0;
margin-bottom: 20px;
border: 1px solid var(--border-color, #1f2937);
border-radius: 4px;
overflow: hidden;
}
.help-tab {
flex: 1;
padding: 10px;
background: var(--bg-primary, #0a0c10);
border: none;
color: var(--text-secondary, #9ca3af);
cursor: pointer;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
transition: all 0.15s ease;
position: relative;
}
.help-tab:not(:last-child) {
border-right: 1px solid var(--border-color, #1f2937);
}
.help-tab:hover {
background: var(--bg-tertiary, #151a23);
color: var(--text-primary, #e8eaed);
}
.help-tab.active {
background: var(--bg-tertiary, #151a23);
color: var(--accent-cyan, #4a9eff);
}
.help-tab.active::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background: var(--accent-cyan, #4a9eff);
}
.help-section {
display: none;
}
.help-section.active {
display: block;
}
/* Ensure code tags are styled */
.help-modal code {
background: var(--bg-tertiary, #151a23);
padding: 2px 6px;
border-radius: 3px;
font-family: var(--font-mono, 'JetBrains Mono', monospace);
font-size: 11px;
color: var(--accent-cyan, #4a9eff);
}
+277 -116
View File
@@ -1,5 +1,3 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap');
* {
box-sizing: border-box;
margin: 0;
@@ -7,61 +5,72 @@
}
:root {
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
/* Tactical dark palette */
--bg-primary: #0a0c10;
--bg-secondary: #0f1218;
--bg-tertiary: #151a23;
--bg-card: #121620;
--bg-elevated: #1a202c;
--bg-primary: #0b1118;
--bg-secondary: #101823;
--bg-tertiary: #151f2b;
--bg-card: #121a25;
--bg-elevated: #1b2734;
/* Accent colors - sophisticated blue/amber */
--accent-cyan: #4a9eff;
--accent-cyan-dim: rgba(74, 158, 255, 0.15);
--accent-green: #22c55e;
--accent-green-dim: rgba(34, 197, 94, 0.15);
--accent-red: #ef4444;
--accent-red-dim: rgba(239, 68, 68, 0.15);
--accent-orange: #f59e0b;
--accent-amber: #d4a853;
--accent-amber-dim: rgba(212, 168, 83, 0.15);
/* Accent colors - slate/cyan */
--accent-cyan: #4aa3ff;
--accent-cyan-dim: rgba(74, 163, 255, 0.16);
--accent-green: #38c180;
--accent-green-dim: rgba(56, 193, 128, 0.18);
--accent-red: #e25d5d;
--accent-red-dim: rgba(226, 93, 93, 0.16);
--accent-orange: #d6a85e;
--accent-amber: #d6a85e;
--accent-amber-dim: rgba(214, 168, 94, 0.18);
/* Text hierarchy */
--text-primary: #e8eaed;
--text-secondary: #9ca3af;
--text-dim: #4b5563;
--text-muted: #374151;
--text-primary: #d7e0ee;
--text-secondary: #9fb0c7;
--text-dim: #6f7f94;
--text-muted: #445266;
/* Borders */
--border-color: #1f2937;
--border-light: #374151;
--border-glow: rgba(74, 158, 255, 0.2);
--border-color: #263246;
--border-light: #354458;
--border-glow: rgba(74, 163, 255, 0.25);
/* Status colors */
--status-online: #22c55e;
--status-warning: #f59e0b;
--status-error: #ef4444;
--status-offline: #6b7280;
--status-online: #38c180;
--status-warning: #d6a85e;
--status-error: #e25d5d;
--status-offline: #6f7f94;
/* Subtle grid/pattern */
--grid-line: rgba(74, 163, 255, 0.1);
--grid-dot: rgba(255, 255, 255, 0.03);
--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='%23ffffff'%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");
}
[data-theme="light"] {
--bg-primary: #f8fafc;
--bg-secondary: #f1f5f9;
--bg-tertiary: #e2e8f0;
--bg-primary: #f4f7fb;
--bg-secondary: #e9eef5;
--bg-tertiary: #dde5f0;
--bg-card: #ffffff;
--bg-elevated: #f8fafc;
--accent-cyan: #2563eb;
--accent-cyan-dim: rgba(37, 99, 235, 0.1);
--accent-green: #16a34a;
--accent-red: #dc2626;
--accent-orange: #d97706;
--accent-amber: #b45309;
--text-primary: #0f172a;
--text-secondary: #475569;
--text-dim: #94a3b8;
--text-muted: #cbd5e1;
--border-color: #e2e8f0;
--border-light: #cbd5e1;
--border-glow: rgba(37, 99, 235, 0.15);
--bg-elevated: #f1f4f9;
--accent-cyan: #1f5fa8;
--accent-cyan-dim: rgba(31, 95, 168, 0.12);
--accent-green: #1f8a57;
--accent-red: #c74444;
--accent-orange: #b5863a;
--accent-amber: #b5863a;
--text-primary: #122034;
--text-secondary: #3a4a5f;
--text-dim: #6b7c93;
--text-muted: #aab6c8;
--border-color: #d1d9e6;
--border-light: #c1ccdb;
--border-glow: rgba(31, 95, 168, 0.12);
--grid-line: rgba(31, 95, 168, 0.14);
--grid-dot: rgba(12, 18, 24, 0.06);
--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");
}
[data-theme="light"] body {
@@ -73,8 +82,16 @@
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg-primary);
font-family: var(--font-sans);
background-color: var(--bg-primary);
background-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(90deg, var(--grid-line) 1px, transparent 1px);
background-size: 40px 40px, auto, auto, 48px 48px, 48px 48px;
background-attachment: fixed;
color: var(--text-primary);
min-height: 100vh;
font-size: 14px;
@@ -108,8 +125,8 @@ body {
right: 0;
bottom: 0;
background:
radial-gradient(circle at 50% 50%, rgba(74, 158, 255, 0.03) 0%, transparent 50%),
linear-gradient(180deg, transparent 0%, rgba(0, 212, 255, 0.02) 100%);
radial-gradient(circle at 50% 50%, rgba(74, 163, 255, 0.04) 0%, transparent 50%),
linear-gradient(180deg, transparent 0%, rgba(74, 163, 255, 0.03) 100%);
pointer-events: none;
}
@@ -259,7 +276,7 @@ body {
}
.welcome-title {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 2.5rem;
font-weight: 700;
color: var(--text-primary);
@@ -269,7 +286,7 @@ body {
}
.welcome-tagline {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 0.9rem;
color: var(--accent-cyan);
letter-spacing: 0.15em;
@@ -278,7 +295,7 @@ body {
.welcome-version {
display: inline-block;
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 0.65rem;
color: var(--bg-primary);
background: var(--accent-cyan);
@@ -297,7 +314,7 @@ body {
}
.welcome-content h2 {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--text-secondary);
text-transform: uppercase;
@@ -333,14 +350,14 @@ body {
}
.changelog-version {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 0.8rem;
color: var(--accent-cyan);
font-weight: 600;
}
.changelog-date {
font-family: 'Inter', sans-serif;
font-family: var(--font-sans);
font-size: 0.7rem;
color: var(--text-dim);
}
@@ -352,7 +369,7 @@ body {
}
.changelog-list li {
font-family: 'Inter', sans-serif;
font-family: var(--font-sans);
font-size: 0.75rem;
color: var(--text-secondary);
margin-bottom: 6px;
@@ -364,7 +381,7 @@ body {
position: absolute;
left: -15px;
color: var(--accent-green);
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
}
/* Mode Selection Grid */
@@ -435,7 +452,7 @@ body {
}
.mode-card .mode-name {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 0.75rem;
font-weight: 600;
color: var(--text-primary);
@@ -444,7 +461,7 @@ body {
}
.mode-card .mode-desc {
font-family: 'Inter', sans-serif;
font-family: var(--font-sans);
font-size: 0.65rem;
color: var(--text-dim);
margin-top: 4px;
@@ -463,7 +480,7 @@ body {
display: flex;
align-items: center;
gap: 8px;
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 0.7rem;
font-weight: 600;
color: var(--text-secondary);
@@ -517,7 +534,7 @@ body {
}
.welcome-footer p {
font-family: 'Inter', sans-serif;
font-family: var(--font-sans);
font-size: 0.7rem;
color: var(--text-dim);
letter-spacing: 0.1em;
@@ -731,7 +748,7 @@ header h1 {
border: 1px solid transparent;
border-radius: 4px;
color: var(--text-secondary);
font-family: 'Inter', sans-serif;
font-family: var(--font-sans);
font-size: 11px;
font-weight: 500;
cursor: pointer;
@@ -778,7 +795,7 @@ header h1 {
border: 1px solid var(--accent-cyan);
border-radius: 4px;
color: var(--accent-cyan);
font-family: 'Inter', sans-serif;
font-family: var(--font-sans);
font-size: 11px;
font-weight: 500;
text-decoration: none;
@@ -814,7 +831,7 @@ header h1 {
border: 1px solid transparent;
border-radius: 4px;
color: var(--text-secondary);
font-family: 'Inter', sans-serif;
font-family: var(--font-sans);
font-size: 11px;
font-weight: 500;
cursor: pointer;
@@ -922,7 +939,7 @@ header h1 {
display: flex;
align-items: center;
gap: 6px;
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 11px;
flex-shrink: 0;
white-space: nowrap;
@@ -1030,7 +1047,7 @@ header h1 {
.version-badge {
font-size: 0.6rem;
font-weight: 500;
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
letter-spacing: 0.05em;
color: var(--text-secondary);
background: var(--bg-tertiary);
@@ -1089,7 +1106,7 @@ header h1 .tagline {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 6px;
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
transition: all 0.2s ease;
}
@@ -1578,7 +1595,7 @@ header h1 .tagline {
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-primary);
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 12px;
transition: all 0.15s ease;
}
@@ -1590,6 +1607,11 @@ header h1 .tagline {
box-shadow: 0 0 0 2px var(--accent-cyan-dim);
}
/* Ensure device select is wide enough for device name + serial */
#deviceSelect {
min-width: 280px;
}
.checkbox-group {
display: flex;
flex-wrap: wrap;
@@ -1632,7 +1654,7 @@ header h1 .tagline {
border: 1px solid var(--border-color);
color: var(--text-secondary);
cursor: pointer;
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
@@ -1652,7 +1674,7 @@ header h1 .tagline {
background: var(--accent-green);
border: none;
color: #fff;
font-family: 'Inter', sans-serif;
font-family: var(--font-sans);
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
@@ -1689,7 +1711,7 @@ header h1 .tagline {
background: var(--accent-red);
border: none;
color: #fff;
font-family: 'Inter', sans-serif;
font-family: var(--font-sans);
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
@@ -1752,7 +1774,7 @@ header h1 .tagline {
gap: 8px;
font-size: 10px;
color: var(--text-secondary);
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
}
.stats>div {
@@ -1778,7 +1800,7 @@ header h1 .tagline {
flex: 1;
padding: 10px;
overflow-y: auto;
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 11px;
background: var(--bg-primary);
min-height: 0; /* Allow shrinking in flex context */
@@ -1850,7 +1872,7 @@ header h1 .tagline {
.message .address {
color: var(--accent-green);
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 11px;
margin-bottom: 8px;
}
@@ -1863,7 +1885,7 @@ header h1 .tagline {
}
.message .content.numeric {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 15px;
letter-spacing: 2px;
color: var(--accent-cyan);
@@ -2084,7 +2106,7 @@ header h1 .tagline {
text-transform: uppercase;
letter-spacing: 1px;
transition: all 0.2s ease;
font-family: 'Inter', sans-serif;
font-family: var(--font-sans);
}
.control-btn:hover {
@@ -2352,7 +2374,7 @@ header h1 .tagline {
/* Dark theme for Leaflet */
.leaflet-container {
background: #0a0a0a;
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
}
/* Using actual dark tiles now - no filter needed */
@@ -2389,7 +2411,7 @@ header h1 .tagline {
display: flex;
justify-content: space-between;
z-index: 1000;
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 11px;
color: var(--accent-cyan);
text-shadow: 0 0 5px var(--accent-cyan);
@@ -2406,7 +2428,7 @@ header h1 .tagline {
display: flex;
justify-content: space-between;
z-index: 1000;
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 10px;
color: var(--accent-cyan);
text-shadow: 0 0 5px var(--accent-cyan);
@@ -2427,7 +2449,7 @@ header h1 .tagline {
}
.aircraft-popup {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 11px;
}
@@ -2471,7 +2493,7 @@ header h1 .tagline {
background: rgba(0, 0, 0, 0.8) !important;
border: 1px solid var(--accent-cyan) !important;
color: var(--accent-cyan) !important;
font-family: 'JetBrains Mono', monospace !important;
font-family: var(--font-mono) !important;
font-size: 10px !important;
padding: 2px 6px !important;
border-radius: 2px !important;
@@ -2499,7 +2521,7 @@ header h1 .tagline {
border-radius: 4px;
color: var(--text-secondary);
cursor: pointer;
font-family: 'Inter', sans-serif;
font-family: var(--font-sans);
font-size: 11px;
text-transform: uppercase;
transition: all 0.2s ease;
@@ -2715,7 +2737,7 @@ header h1 .tagline {
color: var(--accent-cyan);
font-size: 22px;
font-weight: 700;
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
text-shadow: 0 0 15px var(--accent-cyan-dim);
line-height: 1.2;
}
@@ -3109,7 +3131,7 @@ header h1 .tagline {
}
.sensor-card .data-value {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 14px;
color: var(--accent-cyan);
}
@@ -3159,7 +3181,7 @@ header h1 .tagline {
display: flex;
gap: 15px;
font-size: 10px;
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
}
.recon-stats span {
@@ -3209,14 +3231,14 @@ header h1 .tagline {
.device-id {
color: var(--text-dim);
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 10px;
}
.device-meta {
text-align: right;
color: var(--text-secondary);
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
}
.device-meta.encrypted {
@@ -3292,7 +3314,7 @@ header h1 .tagline {
}
.hex-dump {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-dim);
background: var(--bg-primary);
@@ -3383,7 +3405,7 @@ header h1 .tagline {
/* WiFi Main Content - 3 columns */
.wifi-main-content {
display: grid;
grid-template-columns: 1fr minmax(240px, 280px) minmax(240px, 280px);
grid-template-columns: minmax(300px, 1fr) minmax(240px, 280px) minmax(240px, 280px);
gap: 10px;
flex: 1;
min-height: 0;
@@ -3398,6 +3420,7 @@ header h1 .tagline {
border: 1px solid var(--border-color);
border-radius: 4px;
overflow: hidden;
min-width: 0; /* Prevent content from forcing panel wider */
}
.wifi-networks-header {
@@ -3565,6 +3588,8 @@ header h1 .tagline {
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 12px;
min-width: 0; /* Prevent content from forcing panel wider */
overflow: hidden;
}
.wifi-radar-panel h5 {
@@ -3800,10 +3825,90 @@ header h1 .tagline {
text-transform: uppercase;
}
.wifi-client-count-badge {
display: inline-block;
font-size: 10px;
padding: 1px 6px;
background: var(--accent-cyan);
color: var(--bg-primary);
border-radius: 10px;
font-weight: 600;
margin-left: 6px;
vertical-align: middle;
}
.wifi-client-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 200px;
overflow-y: auto;
}
.wifi-client-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 10px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 4px;
}
.wifi-client-identity {
display: flex;
flex-direction: column;
gap: 2px;
}
.wifi-client-mac {
font-family: monospace;
font-size: 12px;
color: var(--text-primary);
}
.wifi-client-vendor {
font-size: 10px;
color: var(--text-dim);
}
.wifi-client-probes {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 4px;
}
.wifi-client-probe-badge {
font-size: 9px;
padding: 2px 6px;
background: var(--bg-tertiary);
border-radius: 3px;
color: var(--text-secondary);
}
.wifi-client-signal {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
.wifi-client-rssi {
font-size: 12px;
font-weight: 500;
color: var(--text-primary);
}
.wifi-client-lastseen {
font-size: 9px;
color: var(--text-dim);
}
/* WiFi Responsive */
@media (max-width: 1400px) {
.wifi-main-content {
grid-template-columns: 1fr 240px 240px;
grid-template-columns: minmax(280px, 1fr) 240px 240px;
}
}
@@ -3961,7 +4066,7 @@ header h1 .tagline {
}
.bt-detail-address {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 10px;
color: #00d4ff;
}
@@ -3975,7 +4080,7 @@ header h1 .tagline {
}
.bt-detail-rssi-value {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 20px;
font-weight: 700;
}
@@ -4070,7 +4175,7 @@ header h1 .tagline {
}
.bt-detail-services-list {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 8px;
color: var(--text-dim);
white-space: nowrap;
@@ -4096,6 +4201,12 @@ header h1 .tagline {
color: #000;
}
.bt-detail-btn.active {
background: rgba(34, 197, 94, 0.2);
border-color: rgba(34, 197, 94, 0.6);
color: #9fffd1;
}
/* Selected device highlight */
.bt-device-row.selected {
background: rgba(0, 212, 255, 0.1);
@@ -4104,10 +4215,37 @@ header h1 .tagline {
.bt-device-list {
border-left-color: var(--accent-purple) !important;
display: flex;
flex-direction: column;
min-width: 280px;
max-width: 320px;
max-height: 100%;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 4px;
overflow: hidden;
}
.bt-device-list .wifi-device-list-content {
flex: 1;
overflow-y: auto;
min-height: 0;
}
.bt-device-list .wifi-device-list-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.bt-device-list .wifi-device-list-header h5 {
color: var(--accent-purple);
margin: 0;
font-size: 13px;
font-weight: 600;
}
/* Bluetooth Device Filters */
@@ -4117,6 +4255,7 @@ header h1 .tagline {
padding: 8px 12px;
border-bottom: 1px solid var(--border-color);
flex-wrap: wrap;
flex-shrink: 0;
}
.bt-filter-btn {
@@ -4259,6 +4398,17 @@ header h1 .tagline {
border: 1px solid rgba(139, 92, 246, 0.3);
}
.bt-history-badge {
display: inline-block;
padding: 1px 4px;
border-radius: 3px;
font-size: 8px;
font-weight: 600;
letter-spacing: 0.2px;
background: rgba(34, 197, 94, 0.15);
color: #22c55e;
}
.bt-device-name {
font-size: 13px;
font-weight: 600;
@@ -4289,7 +4439,7 @@ header h1 .tagline {
}
.bt-rssi-value {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 11px;
font-weight: 600;
min-width: 28px;
@@ -4648,7 +4798,7 @@ header h1 .tagline {
flex-direction: column;
gap: 4px;
font-size: 10px;
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
}
.security-legend-item {
@@ -4695,7 +4845,7 @@ header h1 .tagline {
}
.signal-value {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 28px;
color: var(--accent-cyan);
text-shadow: 0 0 10px var(--accent-cyan-dim);
@@ -4848,7 +4998,7 @@ body::before {
color: #000;
border: none;
padding: 12px 40px;
font-family: 'Inter', sans-serif;
font-family: var(--font-sans);
font-size: 14px;
font-weight: 600;
letter-spacing: 2px;
@@ -5211,7 +5361,7 @@ body::before {
.meter-value {
font-size: 10px;
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
color: var(--text-secondary);
width: 50px;
text-align: right;
@@ -5368,7 +5518,7 @@ body::before {
}
.freq-digits {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 56px;
font-weight: 700;
color: var(--accent-cyan);
@@ -5389,7 +5539,7 @@ body::before {
}
.freq-unit {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 20px;
color: var(--text-secondary);
margin-left: 8px;
@@ -5533,7 +5683,7 @@ body::before {
}
.knob-value {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 16px;
font-weight: 600;
color: var(--accent-cyan);
@@ -5658,7 +5808,7 @@ body::before {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
border-radius: 4px;
@@ -5720,13 +5870,13 @@ body::before {
}
.signal-arc-label {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 8px;
fill: var(--text-muted);
}
.signal-arc-value {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 14px;
font-weight: 600;
fill: var(--accent-cyan);
@@ -5758,7 +5908,7 @@ body::before {
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--accent-cyan);
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 14px;
font-weight: 600;
text-align: center;
@@ -5894,7 +6044,7 @@ body::before {
max-height: 200px;
overflow-y: auto;
padding: 10px 15px;
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 11px;
}
@@ -5983,7 +6133,7 @@ body::before {
}
.module-header {
font-family: 'Orbitron', 'JetBrains Mono', monospace;
font-family: 'Orbitron', 'Space Mono', monospace;
font-size: 10px;
font-weight: 600;
color: var(--accent-cyan);
@@ -6008,7 +6158,7 @@ body::before {
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-primary);
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 12px;
padding: 6px 8px;
text-align: center;
@@ -6047,16 +6197,27 @@ body::before {
cursor: not-allowed;
}
.radio-action-btn.scan {
.radio-action-btn.scan,
.radio-action-btn.listen {
background: var(--accent-green);
border-color: var(--accent-green);
color: #000;
}
.radio-action-btn.scan:hover:not(:disabled) {
.radio-action-btn.scan:hover:not(:disabled),
.radio-action-btn.listen:hover:not(:disabled) {
box-shadow: 0 0 15px rgba(0, 255, 136, 0.4);
}
.radio-action-btn.listen.active {
background: var(--accent-red);
border-color: var(--accent-red);
}
.radio-action-btn.listen.active:hover:not(:disabled) {
box-shadow: 0 0 20px var(--accent-red-dim);
}
/* Statistics Box */
.stat-box {
background: rgba(0, 0, 0, 0.3);
@@ -6066,7 +6227,7 @@ body::before {
}
.stat-value {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 22px;
font-weight: bold;
}
@@ -6114,7 +6275,7 @@ body::before {
.tune-btn {
padding: 4px 8px;
font-size: 10px;
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
background: var(--bg-elevated);
border: 1px solid var(--border-color);
color: var(--text-secondary);
@@ -6144,13 +6305,13 @@ body::before {
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
padding: 8px;
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
}
/* Listening Mode Selector Buttons */
.radio-mode-btn {
padding: 12px 24px;
font-family: 'Orbitron', 'JetBrains Mono', monospace;
font-family: 'Orbitron', 'Space Mono', monospace;
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
@@ -6191,7 +6352,7 @@ body::before {
/* Frequency Preset Buttons */
.preset-freq-btn {
padding: 8px 14px;
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 11px;
background: var(--bg-elevated);
border: 1px solid var(--border-color);
@@ -6255,4 +6416,4 @@ body::before {
[data-animations="off"] .logo-dot,
[data-animations="off"] .welcome-logo {
animation: none !important;
}
}
+6 -6
View File
@@ -37,7 +37,7 @@
/* Typography */
.landing-title {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 2.2rem;
font-weight: 700;
letter-spacing: 0.4em;
@@ -48,7 +48,7 @@
}
.landing-tagline {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
color: var(--accent-cyan);
font-size: 0.9rem;
letter-spacing: 0.15em;
@@ -71,7 +71,7 @@
/* Hacker Style Error */
.flash-error {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 10px;
color: var(--accent-red);
background: rgba(239, 68, 68, 0.1);
@@ -94,7 +94,7 @@
color: var(--accent-cyan);
padding: 12px;
margin-bottom: 15px;
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 11px;
outline: none;
box-sizing: border-box; /* Crucial for visibility */
@@ -106,7 +106,7 @@
border: 2px solid var(--accent-cyan);
color: var(--accent-cyan);
padding: 15px;
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-weight: 600;
letter-spacing: 3px;
cursor: pointer;
@@ -116,7 +116,7 @@
.landing-version {
margin-top: 25px;
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 10px;
color: rgba(255, 255, 255, 0.3);
letter-spacing: 2px;
+328 -328
View File
@@ -1,328 +1,328 @@
/* APRS Function Bar (Stats Strip) Styles */
.aprs-strip {
background: linear-gradient(180deg, var(--bg-panel) 0%, var(--bg-dark) 100%);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 6px 12px;
margin-bottom: 10px;
overflow-x: auto;
}
.aprs-strip-inner {
display: flex;
align-items: center;
gap: 8px;
min-width: max-content;
}
.aprs-strip .strip-stat {
display: flex;
flex-direction: column;
align-items: center;
padding: 4px 10px;
background: rgba(74, 158, 255, 0.05);
border: 1px solid rgba(74, 158, 255, 0.15);
border-radius: 4px;
min-width: 55px;
}
.aprs-strip .strip-stat:hover {
background: rgba(74, 158, 255, 0.1);
border-color: rgba(74, 158, 255, 0.3);
}
.aprs-strip .strip-value {
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
font-weight: 600;
color: var(--accent-cyan);
line-height: 1.2;
}
.aprs-strip .strip-label {
font-size: 8px;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 1px;
}
.aprs-strip .strip-divider {
width: 1px;
height: 28px;
background: var(--border-color);
margin: 0 4px;
}
/* Signal stat coloring */
.aprs-strip .signal-stat.good .strip-value { color: var(--accent-green); }
.aprs-strip .signal-stat.warning .strip-value { color: var(--accent-yellow); }
.aprs-strip .signal-stat.poor .strip-value { color: var(--accent-red); }
/* Controls */
.aprs-strip .strip-control {
display: flex;
align-items: center;
gap: 4px;
}
.aprs-strip .strip-select {
background: rgba(0,0,0,0.3);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 4px 8px;
border-radius: 4px;
font-size: 10px;
cursor: pointer;
}
.aprs-strip .strip-select:hover {
border-color: var(--accent-cyan);
}
.aprs-strip .strip-input-label {
font-size: 9px;
color: var(--text-muted);
font-weight: 600;
}
.aprs-strip .strip-input {
background: rgba(0,0,0,0.3);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 4px 6px;
border-radius: 4px;
font-size: 10px;
width: 50px;
text-align: center;
}
.aprs-strip .strip-input:hover,
.aprs-strip .strip-input:focus {
border-color: var(--accent-cyan);
outline: none;
}
/* Tool Status Indicators */
.aprs-strip .strip-tools {
display: flex;
gap: 4px;
}
.aprs-strip .strip-tool {
font-size: 9px;
font-weight: 600;
padding: 3px 6px;
border-radius: 3px;
background: rgba(255, 59, 48, 0.2);
color: var(--accent-red);
border: 1px solid rgba(255, 59, 48, 0.3);
}
.aprs-strip .strip-tool.ok {
background: rgba(0, 255, 136, 0.1);
color: var(--accent-green);
border-color: rgba(0, 255, 136, 0.3);
}
/* Buttons */
.aprs-strip .strip-btn {
background: rgba(74, 158, 255, 0.1);
border: 1px solid rgba(74, 158, 255, 0.2);
color: var(--text-primary);
padding: 6px 12px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.aprs-strip .strip-btn:hover:not(:disabled) {
background: rgba(74, 158, 255, 0.2);
border-color: rgba(74, 158, 255, 0.4);
}
.aprs-strip .strip-btn.primary {
background: linear-gradient(135deg, var(--accent-green) 0%, #10b981 100%);
border: none;
color: #000;
}
.aprs-strip .strip-btn.primary:hover:not(:disabled) {
filter: brightness(1.1);
}
.aprs-strip .strip-btn.stop {
background: linear-gradient(135deg, var(--accent-red) 0%, #dc2626 100%);
border: none;
color: #fff;
}
.aprs-strip .strip-btn.stop:hover:not(:disabled) {
filter: brightness(1.1);
}
.aprs-strip .strip-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Status indicator */
.aprs-strip .strip-status {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: rgba(0,0,0,0.2);
border-radius: 4px;
font-size: 10px;
font-weight: 600;
color: var(--text-secondary);
}
.aprs-strip .status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-muted);
}
.aprs-strip .status-dot.listening {
background: var(--accent-cyan);
animation: aprs-strip-pulse 1.5s ease-in-out infinite;
}
.aprs-strip .status-dot.tracking {
background: var(--accent-green);
animation: aprs-strip-pulse 1.5s ease-in-out infinite;
}
.aprs-strip .status-dot.error {
background: var(--accent-red);
}
@keyframes aprs-strip-pulse {
0%, 100% { opacity: 1; box-shadow: 0 0 4px 2px currentColor; }
50% { opacity: 0.6; box-shadow: none; }
}
/* Time display */
.aprs-strip .strip-time {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--text-muted);
padding: 4px 8px;
background: rgba(0,0,0,0.2);
border-radius: 4px;
white-space: nowrap;
}
/* APRS Status Bar Styles (Sidebar - legacy) */
.aprs-status-bar {
margin-top: 12px;
padding: 10px;
background: rgba(0,0,0,0.3);
border: 1px solid var(--border-color);
border-radius: 4px;
}
.aprs-status-indicator {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.aprs-status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--text-muted);
}
.aprs-status-dot.standby { background: var(--text-muted); }
.aprs-status-dot.listening {
background: var(--accent-cyan);
animation: aprs-pulse 1.5s ease-in-out infinite;
}
.aprs-status-dot.tracking { background: var(--accent-green); }
.aprs-status-dot.error { background: var(--accent-red); }
@keyframes aprs-pulse {
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(74, 158, 255, 0.7); }
50% { opacity: 0.6; box-shadow: 0 0 8px 4px rgba(74, 158, 255, 0.3); }
}
.aprs-status-text {
font-size: 10px;
font-weight: bold;
letter-spacing: 1px;
}
.aprs-status-stats {
display: flex;
flex-wrap: wrap;
gap: 8px;
font-size: 9px;
}
.aprs-stat {
color: var(--text-secondary);
}
.aprs-stat-label {
color: var(--text-muted);
}
/* Signal Meter Styles */
.aprs-signal-meter {
margin-top: 12px;
padding: 10px;
background: rgba(0,0,0,0.3);
border: 1px solid var(--border-color);
border-radius: 4px;
}
.aprs-meter-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.aprs-meter-label {
font-size: 10px;
font-weight: bold;
letter-spacing: 1px;
color: var(--text-secondary);
}
.aprs-meter-value {
font-size: 12px;
font-weight: bold;
font-family: monospace;
color: var(--accent-cyan);
min-width: 24px;
}
.aprs-meter-burst {
font-size: 9px;
font-weight: bold;
color: var(--accent-yellow);
background: rgba(255, 193, 7, 0.2);
padding: 2px 6px;
border-radius: 3px;
animation: burst-flash 0.3s ease-out;
}
@keyframes burst-flash {
0% { opacity: 1; transform: scale(1.1); }
100% { opacity: 1; transform: scale(1); }
}
.aprs-meter-bar-container {
position: relative;
height: 16px;
background: rgba(0,0,0,0.4);
border-radius: 3px;
overflow: hidden;
margin-bottom: 4px;
}
.aprs-meter-bar {
height: 100%;
width: 0%;
background: linear-gradient(90deg,
var(--accent-green) 0%,
var(--accent-cyan) 50%,
var(--accent-yellow) 75%,
var(--accent-red) 100%
);
border-radius: 3px;
transition: width 0.1s ease-out;
}
.aprs-meter-bar.no-signal {
opacity: 0.3;
}
.aprs-meter-ticks {
display: flex;
justify-content: space-between;
font-size: 8px;
color: var(--text-muted);
padding: 0 2px;
}
.aprs-meter-status {
font-size: 9px;
color: var(--text-muted);
text-align: center;
margin-top: 6px;
}
.aprs-meter-status.active {
color: var(--accent-green);
}
.aprs-meter-status.no-signal {
color: var(--accent-yellow);
}
/* APRS Function Bar (Stats Strip) Styles */
.aprs-strip {
background: linear-gradient(180deg, var(--bg-panel) 0%, var(--bg-dark) 100%);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 6px 12px;
margin-bottom: 10px;
overflow-x: auto;
}
.aprs-strip-inner {
display: flex;
align-items: center;
gap: 8px;
min-width: max-content;
}
.aprs-strip .strip-stat {
display: flex;
flex-direction: column;
align-items: center;
padding: 4px 10px;
background: rgba(74, 158, 255, 0.05);
border: 1px solid rgba(74, 158, 255, 0.15);
border-radius: 4px;
min-width: 55px;
}
.aprs-strip .strip-stat:hover {
background: rgba(74, 158, 255, 0.1);
border-color: rgba(74, 158, 255, 0.3);
}
.aprs-strip .strip-value {
font-family: var(--font-mono);
font-size: 14px;
font-weight: 600;
color: var(--accent-cyan);
line-height: 1.2;
}
.aprs-strip .strip-label {
font-size: 8px;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 1px;
}
.aprs-strip .strip-divider {
width: 1px;
height: 28px;
background: var(--border-color);
margin: 0 4px;
}
/* Signal stat coloring */
.aprs-strip .signal-stat.good .strip-value { color: var(--accent-green); }
.aprs-strip .signal-stat.warning .strip-value { color: var(--accent-yellow); }
.aprs-strip .signal-stat.poor .strip-value { color: var(--accent-red); }
/* Controls */
.aprs-strip .strip-control {
display: flex;
align-items: center;
gap: 4px;
}
.aprs-strip .strip-select {
background: rgba(0,0,0,0.3);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 4px 8px;
border-radius: 4px;
font-size: 10px;
cursor: pointer;
}
.aprs-strip .strip-select:hover {
border-color: var(--accent-cyan);
}
.aprs-strip .strip-input-label {
font-size: 9px;
color: var(--text-muted);
font-weight: 600;
}
.aprs-strip .strip-input {
background: rgba(0,0,0,0.3);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 4px 6px;
border-radius: 4px;
font-size: 10px;
width: 50px;
text-align: center;
}
.aprs-strip .strip-input:hover,
.aprs-strip .strip-input:focus {
border-color: var(--accent-cyan);
outline: none;
}
/* Tool Status Indicators */
.aprs-strip .strip-tools {
display: flex;
gap: 4px;
}
.aprs-strip .strip-tool {
font-size: 9px;
font-weight: 600;
padding: 3px 6px;
border-radius: 3px;
background: rgba(255, 59, 48, 0.2);
color: var(--accent-red);
border: 1px solid rgba(255, 59, 48, 0.3);
}
.aprs-strip .strip-tool.ok {
background: rgba(0, 255, 136, 0.1);
color: var(--accent-green);
border-color: rgba(0, 255, 136, 0.3);
}
/* Buttons */
.aprs-strip .strip-btn {
background: rgba(74, 158, 255, 0.1);
border: 1px solid rgba(74, 158, 255, 0.2);
color: var(--text-primary);
padding: 6px 12px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.aprs-strip .strip-btn:hover:not(:disabled) {
background: rgba(74, 158, 255, 0.2);
border-color: rgba(74, 158, 255, 0.4);
}
.aprs-strip .strip-btn.primary {
background: linear-gradient(135deg, var(--accent-green) 0%, #10b981 100%);
border: none;
color: #000;
}
.aprs-strip .strip-btn.primary:hover:not(:disabled) {
filter: brightness(1.1);
}
.aprs-strip .strip-btn.stop {
background: linear-gradient(135deg, var(--accent-red) 0%, #dc2626 100%);
border: none;
color: #fff;
}
.aprs-strip .strip-btn.stop:hover:not(:disabled) {
filter: brightness(1.1);
}
.aprs-strip .strip-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Status indicator */
.aprs-strip .strip-status {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: rgba(0,0,0,0.2);
border-radius: 4px;
font-size: 10px;
font-weight: 600;
color: var(--text-secondary);
}
.aprs-strip .status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-muted);
}
.aprs-strip .status-dot.listening {
background: var(--accent-cyan);
animation: aprs-strip-pulse 1.5s ease-in-out infinite;
}
.aprs-strip .status-dot.tracking {
background: var(--accent-green);
animation: aprs-strip-pulse 1.5s ease-in-out infinite;
}
.aprs-strip .status-dot.error {
background: var(--accent-red);
}
@keyframes aprs-strip-pulse {
0%, 100% { opacity: 1; box-shadow: 0 0 4px 2px currentColor; }
50% { opacity: 0.6; box-shadow: none; }
}
/* Time display */
.aprs-strip .strip-time {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-muted);
padding: 4px 8px;
background: rgba(0,0,0,0.2);
border-radius: 4px;
white-space: nowrap;
}
/* APRS Status Bar Styles (Sidebar - legacy) */
.aprs-status-bar {
margin-top: 12px;
padding: 10px;
background: rgba(0,0,0,0.3);
border: 1px solid var(--border-color);
border-radius: 4px;
}
.aprs-status-indicator {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.aprs-status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--text-muted);
}
.aprs-status-dot.standby { background: var(--text-muted); }
.aprs-status-dot.listening {
background: var(--accent-cyan);
animation: aprs-pulse 1.5s ease-in-out infinite;
}
.aprs-status-dot.tracking { background: var(--accent-green); }
.aprs-status-dot.error { background: var(--accent-red); }
@keyframes aprs-pulse {
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(74, 158, 255, 0.7); }
50% { opacity: 0.6; box-shadow: 0 0 8px 4px rgba(74, 158, 255, 0.3); }
}
.aprs-status-text {
font-size: 10px;
font-weight: bold;
letter-spacing: 1px;
}
.aprs-status-stats {
display: flex;
flex-wrap: wrap;
gap: 8px;
font-size: 9px;
}
.aprs-stat {
color: var(--text-secondary);
}
.aprs-stat-label {
color: var(--text-muted);
}
/* Signal Meter Styles */
.aprs-signal-meter {
margin-top: 12px;
padding: 10px;
background: rgba(0,0,0,0.3);
border: 1px solid var(--border-color);
border-radius: 4px;
}
.aprs-meter-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.aprs-meter-label {
font-size: 10px;
font-weight: bold;
letter-spacing: 1px;
color: var(--text-secondary);
}
.aprs-meter-value {
font-size: 12px;
font-weight: bold;
font-family: monospace;
color: var(--accent-cyan);
min-width: 24px;
}
.aprs-meter-burst {
font-size: 9px;
font-weight: bold;
color: var(--accent-yellow);
background: rgba(255, 193, 7, 0.2);
padding: 2px 6px;
border-radius: 3px;
animation: burst-flash 0.3s ease-out;
}
@keyframes burst-flash {
0% { opacity: 1; transform: scale(1.1); }
100% { opacity: 1; transform: scale(1); }
}
.aprs-meter-bar-container {
position: relative;
height: 16px;
background: rgba(0,0,0,0.4);
border-radius: 3px;
overflow: hidden;
margin-bottom: 4px;
}
.aprs-meter-bar {
height: 100%;
width: 0%;
background: linear-gradient(90deg,
var(--accent-green) 0%,
var(--accent-cyan) 50%,
var(--accent-yellow) 75%,
var(--accent-red) 100%
);
border-radius: 3px;
transition: width 0.1s ease-out;
}
.aprs-meter-bar.no-signal {
opacity: 0.3;
}
.aprs-meter-ticks {
display: flex;
justify-content: space-between;
font-size: 8px;
color: var(--text-muted);
padding: 0 2px;
}
.aprs-meter-status {
font-size: 9px;
color: var(--text-muted);
text-align: center;
margin-top: 6px;
}
.aprs-meter-status.active {
color: var(--accent-green);
}
.aprs-meter-status.no-signal {
color: var(--accent-yellow);
}
File diff suppressed because it is too large Load Diff
+8 -8
View File
@@ -27,7 +27,7 @@
}
.spy-stations-title {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
@@ -101,7 +101,7 @@
}
.spy-station-name {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
@@ -117,7 +117,7 @@
/* Type Badge */
.spy-station-badge {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
@@ -173,7 +173,7 @@
}
.spy-meta-mode {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 10px;
color: var(--accent-orange);
}
@@ -186,7 +186,7 @@
}
.spy-freq-list {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 11px;
color: var(--accent-cyan);
line-height: 1.6;
@@ -199,7 +199,7 @@
}
.spy-freq-item {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 11px;
color: var(--accent-cyan);
background: var(--bg-secondary);
@@ -236,7 +236,7 @@
}
.spy-freq-select {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 10px;
padding: 6px 8px;
background: var(--bg-secondary);
@@ -273,7 +273,7 @@
display: inline-flex;
align-items: center;
gap: 6px;
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
+668
View File
@@ -0,0 +1,668 @@
/**
* SSTV General Mode Styles
* Terrestrial Slow-Scan Television decoder interface
*/
/* ============================================
MODE VISIBILITY
============================================ */
#sstvGeneralMode.active {
display: block !important;
}
/* ============================================
VISUALS CONTAINER
============================================ */
.sstv-general-visuals-container {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
min-height: 0;
flex: 1;
height: 100%;
overflow: hidden;
}
/* ============================================
STATS STRIP
============================================ */
.sstv-general-stats-strip {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 14px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 8px;
flex-wrap: wrap;
flex-shrink: 0;
}
.sstv-general-strip-group {
display: flex;
align-items: center;
gap: 12px;
}
.sstv-general-strip-status {
display: flex;
align-items: center;
gap: 6px;
}
.sstv-general-strip-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.sstv-general-strip-dot.idle {
background: var(--text-dim);
}
.sstv-general-strip-dot.listening {
background: var(--accent-yellow);
animation: sstv-general-pulse 1s infinite;
}
.sstv-general-strip-dot.decoding {
background: var(--accent-cyan);
box-shadow: 0 0 6px var(--accent-cyan);
animation: sstv-general-pulse 0.5s infinite;
}
.sstv-general-strip-status-text {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-secondary);
text-transform: uppercase;
}
.sstv-general-strip-btn {
font-family: var(--font-mono);
font-size: 10px;
padding: 5px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
text-transform: uppercase;
font-weight: 600;
transition: all 0.15s ease;
}
.sstv-general-strip-btn.start {
background: var(--accent-cyan);
color: var(--bg-primary);
}
.sstv-general-strip-btn.start:hover {
background: var(--accent-cyan-bright, #00d4ff);
}
.sstv-general-strip-btn.stop {
background: var(--accent-red, #ff3366);
color: white;
}
.sstv-general-strip-btn.stop:hover {
background: #ff1a53;
}
.sstv-general-strip-divider {
width: 1px;
height: 24px;
background: var(--border-color);
}
.sstv-general-strip-stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
min-width: 50px;
}
.sstv-general-strip-value {
font-family: var(--font-mono);
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
}
.sstv-general-strip-value.accent-cyan {
color: var(--accent-cyan);
}
.sstv-general-strip-label {
font-family: var(--font-mono);
font-size: 8px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* ============================================
MAIN ROW (Live Decode + Gallery)
============================================ */
.sstv-general-main-row {
display: flex;
flex-direction: row;
gap: 12px;
flex: 1;
min-height: 0;
overflow: hidden;
}
/* ============================================
LIVE DECODE SECTION
============================================ */
.sstv-general-live-section {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
flex: 1;
min-width: 300px;
}
.sstv-general-live-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: rgba(0, 0, 0, 0.2);
border-bottom: 1px solid var(--border-color);
}
.sstv-general-live-title {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--font-mono);
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
}
.sstv-general-live-title svg {
color: var(--accent-cyan);
}
.sstv-general-live-content {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 16px;
min-height: 0;
}
.sstv-general-canvas-container {
position: relative;
background: #000;
border: 1px solid var(--border-color);
border-radius: 4px;
overflow: hidden;
}
.sstv-general-decode-info {
width: 100%;
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.sstv-general-mode-label {
font-family: var(--font-mono);
font-size: 11px;
color: var(--accent-cyan);
text-align: center;
}
.sstv-general-progress-bar {
width: 100%;
height: 4px;
background: var(--bg-secondary);
border-radius: 2px;
overflow: hidden;
}
.sstv-general-progress-bar .progress {
height: 100%;
background: linear-gradient(90deg, var(--accent-cyan), var(--accent-green));
border-radius: 2px;
transition: width 0.3s ease;
}
.sstv-general-status-message {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-dim);
text-align: center;
}
/* Idle state */
.sstv-general-idle-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 40px 20px;
color: var(--text-dim);
}
.sstv-general-idle-state svg {
width: 64px;
height: 64px;
opacity: 0.3;
margin-bottom: 16px;
}
.sstv-general-idle-state h4 {
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 8px;
}
.sstv-general-idle-state p {
font-size: 12px;
max-width: 250px;
}
/* ============================================
GALLERY SECTION
============================================ */
.sstv-general-gallery-section {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
flex: 1.5;
min-width: 300px;
}
.sstv-general-gallery-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: rgba(0, 0, 0, 0.2);
border-bottom: 1px solid var(--border-color);
}
.sstv-general-gallery-title {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--font-mono);
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
}
.sstv-general-gallery-count {
font-family: var(--font-mono);
font-size: 10px;
color: var(--accent-cyan);
background: var(--bg-secondary);
padding: 2px 8px;
border-radius: 10px;
}
.sstv-general-gallery-grid {
flex: 1;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
padding: 12px;
overflow-y: auto;
align-content: start;
}
.sstv-general-image-card {
position: relative;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
overflow: hidden;
transition: all 0.15s ease;
}
.sstv-general-image-card:hover {
border-color: var(--accent-cyan);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 212, 255, 0.2);
}
.sstv-general-image-card-inner {
cursor: pointer;
}
.sstv-general-image-preview {
width: 100%;
aspect-ratio: 4/3;
object-fit: cover;
background: #000;
display: block;
}
.sstv-general-image-actions {
position: absolute;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: flex-end;
gap: 4px;
padding: 6px;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
opacity: 0;
transition: opacity 0.15s;
}
.sstv-general-image-card:hover .sstv-general-image-actions {
opacity: 1;
}
.sstv-general-image-actions button {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
color: white;
cursor: pointer;
transition: all 0.15s;
}
.sstv-general-image-actions button:hover {
background: rgba(255, 255, 255, 0.25);
}
.sstv-general-image-actions button:last-child:hover {
background: var(--accent-red, #ff3366);
border-color: var(--accent-red, #ff3366);
}
.sstv-general-image-info {
padding: 8px 10px;
border-top: 1px solid var(--border-color);
}
.sstv-general-image-mode {
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
color: var(--accent-cyan);
margin-bottom: 4px;
}
.sstv-general-image-timestamp {
font-family: var(--font-mono);
font-size: 9px;
color: var(--text-dim);
}
/* Empty gallery state */
.sstv-general-gallery-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
color: var(--text-dim);
grid-column: 1 / -1;
}
.sstv-general-gallery-empty svg {
width: 48px;
height: 48px;
opacity: 0.3;
margin-bottom: 12px;
}
/* ============================================
SIGNAL MONITOR
============================================ */
.sstv-general-signal-monitor {
width: 100%;
max-width: 320px;
padding: 16px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
}
.sstv-general-signal-monitor-header {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 14px;
}
.sstv-general-signal-monitor-header svg {
color: var(--accent-cyan);
}
.sstv-general-signal-level-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.sstv-general-signal-level-label {
font-family: var(--font-mono);
font-size: 9px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
flex-shrink: 0;
}
.sstv-general-signal-bar-track {
flex: 1;
height: 6px;
background: var(--bg-primary);
border-radius: 3px;
overflow: hidden;
}
.sstv-general-signal-bar-fill {
height: 100%;
border-radius: 3px;
transition: width 0.3s ease, background 0.3s ease;
background: var(--text-dim);
}
.sstv-general-signal-level-value {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 600;
color: var(--text-primary);
min-width: 24px;
text-align: right;
}
.sstv-general-signal-status-text {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-dim);
text-align: center;
}
.sstv-general-signal-vis-state {
font-family: var(--font-mono);
font-size: 9px;
color: var(--text-dim);
text-align: center;
margin-top: 6px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.sstv-general-signal-vis-state.active {
color: var(--accent-cyan);
}
/* ============================================
IMAGE MODAL
============================================ */
.sstv-general-image-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.9);
display: none;
align-items: center;
justify-content: center;
z-index: 10000;
padding: 40px;
}
.sstv-general-image-modal.show {
display: flex;
}
.sstv-general-image-modal img {
max-width: 100%;
max-height: 100%;
border: 1px solid var(--border-color);
border-radius: 4px;
}
.sstv-general-modal-toolbar {
position: absolute;
top: 20px;
right: 60px;
display: flex;
gap: 8px;
z-index: 1;
}
.sstv-general-modal-btn {
display: flex;
align-items: center;
gap: 6px;
font-family: var(--font-mono);
font-size: 10px;
padding: 6px 12px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
color: white;
cursor: pointer;
transition: all 0.15s;
text-transform: uppercase;
}
.sstv-general-modal-btn:hover {
background: rgba(255, 255, 255, 0.2);
}
.sstv-general-modal-btn.delete:hover {
background: var(--accent-red, #ff3366);
border-color: var(--accent-red, #ff3366);
}
.sstv-general-modal-close {
position: absolute;
top: 20px;
right: 20px;
background: none;
border: none;
color: white;
font-size: 32px;
cursor: pointer;
opacity: 0.7;
transition: opacity 0.15s;
z-index: 1;
}
.sstv-general-modal-close:hover {
opacity: 1;
}
/* Clear All button */
.sstv-general-gallery-clear-btn {
font-family: var(--font-mono);
font-size: 9px;
text-transform: uppercase;
padding: 3px 8px;
border-radius: 4px;
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-dim);
cursor: pointer;
transition: all 0.15s;
margin-left: 8px;
}
.sstv-general-gallery-clear-btn:hover {
color: var(--accent-red, #ff3366);
border-color: var(--accent-red, #ff3366);
}
/* ============================================
RESPONSIVE
============================================ */
@media (max-width: 1024px) {
.sstv-general-main-row {
flex-direction: column;
overflow-y: auto;
}
.sstv-general-live-section {
max-width: none;
min-height: 350px;
}
.sstv-general-gallery-section {
min-height: 300px;
}
}
@media (max-width: 768px) {
.sstv-general-stats-strip {
padding: 8px 12px;
gap: 8px;
flex-wrap: wrap;
}
.sstv-general-strip-divider {
display: none;
}
.sstv-general-gallery-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 8px;
padding: 8px;
}
}
@keyframes sstv-general-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
+1067 -876
View File
File diff suppressed because it is too large Load Diff
+1693 -1463
View File
File diff suppressed because it is too large Load Diff
+660 -660
View File
File diff suppressed because it is too large Load Diff
+84 -30
View File
@@ -5,25 +5,28 @@
}
:root {
--bg-dark: #0a0c10;
--bg-panel: #0f1218;
--bg-card: #151a23;
--border-color: #1f2937;
--border-glow: #4a9eff;
--text-primary: #e8eaed;
--text-secondary: #9ca3af;
--text-dim: #4b5563;
--accent-cyan: #4a9eff;
--accent-green: #22c55e;
--accent-orange: #f59e0b;
--accent-red: #ef4444;
--accent-purple: #a855f7;
--accent-amber: #d4a853;
--grid-line: rgba(74, 158, 255, 0.08);
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
--bg-dark: #0b1118;
--bg-panel: #101823;
--bg-card: #151f2b;
--border-color: #263246;
--border-glow: #4aa3ff;
--text-primary: #d7e0ee;
--text-secondary: #9fb0c7;
--text-dim: #6f7f94;
--accent-cyan: #4aa3ff;
--accent-green: #38c180;
--accent-orange: #d6a85e;
--accent-red: #e25d5d;
--accent-purple: #8f7bd6;
--accent-amber: #d6a85e;
--grid-line: rgba(74, 163, 255, 0.1);
--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='%23ffffff'%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");
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
font-family: var(--font-sans);
background: var(--bg-dark);
color: var(--text-primary);
min-height: 100vh;
@@ -38,9 +41,10 @@ body {
right: 0;
bottom: 0;
background-image:
var(--noise-image),
linear-gradient(var(--grid-line) 1px, transparent 1px),
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
background-size: 50px 50px;
background-size: 40px 40px, 50px 50px, 50px 50px;
animation: gridMove 20s linear infinite;
pointer-events: none;
z-index: 0;
@@ -62,12 +66,14 @@ body {
top: 0;
left: 0;
right: 0;
height: 4px;
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
animation: scan 3s linear infinite;
color: var(--accent-cyan);
animation: scan 6s linear infinite;
pointer-events: none;
z-index: 1000;
opacity: 0.5;
opacity: 0.25;
box-shadow: 0 0 8px currentColor;
}
@keyframes scan {
@@ -92,8 +98,20 @@ body {
align-items: center;
}
.header::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
opacity: 0.6;
pointer-events: none;
}
.logo {
font-family: 'Inter', sans-serif;
font-family: var(--font-sans);
font-size: 20px;
font-weight: 700;
letter-spacing: 3px;
@@ -142,7 +160,7 @@ body {
border: 1px solid rgba(0, 212, 255, 0.3);
border-radius: 4px;
padding: 4px 10px;
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 11px;
}
@@ -162,10 +180,45 @@ body {
.status-bar {
display: flex;
gap: 20px;
gap: 12px;
align-items: center;
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 11px;
flex-wrap: nowrap;
}
.location-selector {
display: flex;
align-items: center;
gap: 8px;
}
.location-selector .location-label {
color: var(--text-secondary);
font-size: 10px;
}
.location-selector .location-select {
padding: 4px 8px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-primary);
font-size: 11px;
font-family: var(--font-mono);
}
.location-selector .location-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent-green);
box-shadow: 0 0 6px var(--accent-green);
}
.location-selector .location-status-dot.offline {
background: var(--accent-red);
box-shadow: 0 0 6px var(--accent-red);
}
.status-item {
@@ -211,6 +264,7 @@ body {
}
/* Main dashboard grid */
/* Header ~52px + Nav 44px = ~96px, using 100px for safety */
.dashboard {
position: relative;
z-index: 10;
@@ -218,7 +272,7 @@ body {
grid-template-columns: 1fr 1fr 340px;
grid-template-rows: 1fr auto;
gap: 0;
height: calc(100vh - 60px);
height: calc(100vh - 100px);
min-height: 500px;
}
@@ -457,7 +511,7 @@ body {
}
.telemetry-value {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 12px;
color: var(--accent-cyan);
}
@@ -543,7 +597,7 @@ body {
}
.pass-time {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
}
/* Bottom controls bar */
@@ -579,7 +633,7 @@ body {
border: 1px solid rgba(0, 212, 255, 0.3);
border-radius: 4px;
color: var(--accent-cyan);
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
font-size: 11px;
}
@@ -696,7 +750,7 @@ body {
display: flex;
flex-direction: column;
height: auto;
min-height: calc(100vh - 60px);
min-height: calc(100vh - 100px);
}
.polar-container,
@@ -748,4 +802,4 @@ body.embedded .panel {
body.embedded .controls-bar {
padding: 10px 15px;
}
}
+490 -427
View File
@@ -1,427 +1,490 @@
/* Settings Modal Styles */
.settings-modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.85);
z-index: 10000;
overflow-y: auto;
backdrop-filter: blur(4px);
}
.settings-modal.active {
display: flex;
justify-content: center;
align-items: flex-start;
padding: 40px 20px;
}
.settings-content {
background: var(--bg-dark, #0a0a0f);
border: 1px solid var(--border-color, #1a1a2e);
border-radius: 8px;
max-width: 600px;
width: 100%;
position: relative;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.settings-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border-color, #1a1a2e);
}
.settings-header h2 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-primary, #e0e0e0);
display: flex;
align-items: center;
gap: 8px;
}
.settings-header h2 .icon {
width: 20px;
height: 20px;
color: var(--accent-cyan, #00d4ff);
}
.settings-close {
background: none;
border: none;
color: var(--text-muted, #666);
font-size: 24px;
cursor: pointer;
padding: 4px;
line-height: 1;
transition: color 0.2s;
}
.settings-close:hover {
color: var(--accent-red, #ff4444);
}
/* Settings Tabs */
.settings-tabs {
display: flex;
border-bottom: 1px solid var(--border-color, #1a1a2e);
padding: 0 20px;
gap: 4px;
}
.settings-tab {
background: none;
border: none;
padding: 12px 16px;
color: var(--text-muted, #666);
font-size: 13px;
font-weight: 500;
cursor: pointer;
position: relative;
transition: color 0.2s;
}
.settings-tab:hover {
color: var(--text-primary, #e0e0e0);
}
.settings-tab.active {
color: var(--accent-cyan, #00d4ff);
}
.settings-tab.active::after {
content: '';
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 2px;
background: var(--accent-cyan, #00d4ff);
}
/* Settings Sections */
.settings-section {
display: none;
padding: 20px;
}
.settings-section.active {
display: block;
}
.settings-group {
margin-bottom: 24px;
}
.settings-group:last-child {
margin-bottom: 0;
}
.settings-group-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted, #666);
margin-bottom: 12px;
}
/* Settings Row */
.settings-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.settings-row:last-child {
border-bottom: none;
}
.settings-label {
display: flex;
flex-direction: column;
gap: 2px;
}
.settings-label-text {
font-size: 13px;
color: var(--text-primary, #e0e0e0);
}
.settings-label-desc {
font-size: 11px;
color: var(--text-muted, #666);
}
/* Toggle Switch */
.toggle-switch {
position: relative;
width: 44px;
height: 24px;
flex-shrink: 0;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--bg-tertiary, #1a1a2e);
border: 1px solid var(--border-color, #2a2a3e);
transition: 0.3s;
border-radius: 24px;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 2px;
bottom: 2px;
background-color: var(--text-muted, #666);
transition: 0.3s;
border-radius: 50%;
}
.toggle-switch input:checked + .toggle-slider {
background-color: var(--accent-cyan, #00d4ff);
border-color: var(--accent-cyan, #00d4ff);
}
.toggle-switch input:checked + .toggle-slider:before {
transform: translateX(20px);
background-color: white;
}
.toggle-switch input:focus + .toggle-slider {
box-shadow: 0 0 0 2px rgba(0, 212, 255, 0.3);
}
/* Select Dropdown */
.settings-select {
background: var(--bg-tertiary, #1a1a2e);
border: 1px solid var(--border-color, #2a2a3e);
border-radius: 4px;
padding: 8px 12px;
font-size: 13px;
color: var(--text-primary, #e0e0e0);
min-width: 160px;
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
padding-right: 32px;
}
.settings-select:focus {
outline: none;
border-color: var(--accent-cyan, #00d4ff);
}
/* Text Input */
.settings-input {
background: var(--bg-tertiary, #1a1a2e);
border: 1px solid var(--border-color, #2a2a3e);
border-radius: 4px;
padding: 8px 12px;
font-size: 13px;
color: var(--text-primary, #e0e0e0);
width: 200px;
}
.settings-input:focus {
outline: none;
border-color: var(--accent-cyan, #00d4ff);
}
.settings-input::placeholder {
color: var(--text-muted, #666);
}
/* Asset Status */
.asset-status {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
padding: 12px;
background: var(--bg-secondary, #0f0f1a);
border-radius: 6px;
}
.asset-status-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
}
.asset-name {
color: var(--text-muted, #888);
}
.asset-badge {
padding: 2px 8px;
border-radius: 10px;
font-size: 10px;
font-weight: 500;
text-transform: uppercase;
}
.asset-badge.available {
background: rgba(0, 255, 136, 0.15);
color: var(--accent-green, #00ff88);
}
.asset-badge.missing {
background: rgba(255, 68, 68, 0.15);
color: var(--accent-red, #ff4444);
}
.asset-badge.checking {
background: rgba(255, 170, 0, 0.15);
color: var(--accent-orange, #ffaa00);
}
/* Check Assets Button */
.check-assets-btn {
background: var(--bg-tertiary, #1a1a2e);
border: 1px solid var(--border-color, #2a2a3e);
color: var(--text-primary, #e0e0e0);
padding: 8px 16px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
margin-top: 12px;
transition: all 0.2s;
}
.check-assets-btn:hover {
border-color: var(--accent-cyan, #00d4ff);
color: var(--accent-cyan, #00d4ff);
}
.check-assets-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* About Section */
.about-info {
font-size: 13px;
color: var(--text-muted, #888);
line-height: 1.6;
}
.about-info p {
margin: 0 0 12px 0;
}
.about-info a {
color: var(--accent-cyan, #00d4ff);
text-decoration: none;
}
.about-info a:hover {
text-decoration: underline;
}
.about-version {
font-family: 'JetBrains Mono', monospace;
color: var(--accent-cyan, #00d4ff);
}
/* Donate Button */
.donate-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 10px 20px;
background: linear-gradient(135deg, var(--accent-amber, #d4a853) 0%, var(--accent-orange, #f59e0b) 100%);
border: none;
border-radius: 6px;
color: #000;
font-size: 13px;
font-weight: 600;
text-decoration: none;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 2px 8px rgba(212, 168, 83, 0.3);
}
.donate-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(212, 168, 83, 0.4);
filter: brightness(1.1);
}
.donate-btn:active {
transform: translateY(0);
}
/* Tile Provider Custom URL */
.custom-url-row {
margin-top: 8px;
padding-top: 8px;
}
.custom-url-row .settings-input {
width: 100%;
}
/* Info Callout */
.settings-info {
background: rgba(0, 212, 255, 0.1);
border: 1px solid rgba(0, 212, 255, 0.2);
border-radius: 6px;
padding: 12px;
margin-top: 16px;
font-size: 12px;
color: var(--text-muted, #888);
}
.settings-info strong {
color: var(--accent-cyan, #00d4ff);
}
/* Responsive */
@media (max-width: 640px) {
.settings-modal.active {
padding: 20px 10px;
}
.settings-content {
max-width: 100%;
}
.settings-row {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.settings-select,
.settings-input {
width: 100%;
}
}
/* Settings Modal Styles */
.settings-modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.85);
z-index: 10000;
overflow-y: auto;
backdrop-filter: blur(4px);
}
.settings-modal.active {
display: flex;
justify-content: center;
align-items: flex-start;
padding: 40px 20px;
}
.settings-content {
background: var(--bg-dark, #0a0a0f);
border: 1px solid var(--border-color, #1a1a2e);
border-radius: 8px;
max-width: 600px;
width: 100%;
position: relative;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.settings-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border-color, #1a1a2e);
}
.settings-header h2 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-primary, #e0e0e0);
display: flex;
align-items: center;
gap: 8px;
}
.settings-header h2 .icon {
width: 20px;
height: 20px;
color: var(--accent-cyan, #00d4ff);
}
.settings-close {
background: none;
border: none;
color: var(--text-muted, #666);
font-size: 24px;
cursor: pointer;
padding: 4px;
line-height: 1;
transition: color 0.2s;
}
.settings-close:hover {
color: var(--accent-red, #ff4444);
}
/* Settings Tabs */
.settings-tabs {
display: flex;
border-bottom: 1px solid var(--border-color, #1a1a2e);
padding: 0 20px;
gap: 4px;
}
.settings-tab {
background: none;
border: none;
padding: 12px 16px;
color: var(--text-muted, #666);
font-size: 13px;
font-weight: 500;
cursor: pointer;
position: relative;
transition: color 0.2s;
}
.settings-tab:hover {
color: var(--text-primary, #e0e0e0);
}
.settings-tab.active {
color: var(--accent-cyan, #00d4ff);
}
.settings-tab.active::after {
content: '';
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 2px;
background: var(--accent-cyan, #00d4ff);
}
/* Settings Sections */
.settings-section {
display: none;
padding: 20px;
}
.settings-section.active {
display: block;
}
.settings-group {
margin-bottom: 24px;
}
.settings-group:last-child {
margin-bottom: 0;
}
.settings-group-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted, #666);
margin-bottom: 12px;
}
/* Settings Row */
.settings-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.settings-row:last-child {
border-bottom: none;
}
.settings-label {
display: flex;
flex-direction: column;
gap: 2px;
}
.settings-label-text {
font-size: 13px;
color: var(--text-primary, #e0e0e0);
}
.settings-label-desc {
font-size: 11px;
color: var(--text-muted, #666);
}
/* Settings Feed Lists */
.settings-feed {
background: var(--bg-tertiary, #12121f);
border: 1px solid var(--border-color, #1a1a2e);
border-radius: 6px;
padding: 8px;
max-height: 240px;
overflow-y: auto;
}
.settings-feed-item {
padding: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
font-size: 11px;
}
.settings-feed-item:last-child {
border-bottom: none;
}
.settings-feed-title {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
color: var(--text-primary, #e0e0e0);
margin-bottom: 4px;
}
.settings-feed-meta {
color: var(--text-muted, #666);
font-size: 10px;
}
.settings-feed-empty {
color: var(--text-dim, #666);
text-align: center;
padding: 20px 10px;
font-size: 11px;
}
/* Toggle Switch */
.toggle-switch {
position: relative;
width: 44px;
height: 24px;
flex-shrink: 0;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--bg-tertiary, #1a1a2e);
border: 1px solid var(--border-color, #2a2a3e);
transition: 0.3s;
border-radius: 24px;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 2px;
bottom: 2px;
background-color: var(--text-muted, #666);
transition: 0.3s;
border-radius: 50%;
}
.toggle-switch input:checked + .toggle-slider {
background-color: var(--accent-cyan, #00d4ff);
border-color: var(--accent-cyan, #00d4ff);
}
.toggle-switch input:checked + .toggle-slider:before {
transform: translateX(20px);
background-color: white;
}
.toggle-switch input:focus + .toggle-slider {
box-shadow: 0 0 0 2px rgba(0, 212, 255, 0.3);
}
/* Select Dropdown */
.settings-select {
background: var(--bg-tertiary, #1a1a2e);
border: 1px solid var(--border-color, #2a2a3e);
border-radius: 4px;
padding: 8px 12px;
font-size: 13px;
color: var(--text-primary, #e0e0e0);
min-width: 160px;
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
padding-right: 32px;
}
.settings-select:focus {
outline: none;
border-color: var(--accent-cyan, #00d4ff);
}
/* Text Input */
.settings-input {
background: var(--bg-tertiary, #1a1a2e);
border: 1px solid var(--border-color, #2a2a3e);
border-radius: 4px;
padding: 8px 12px;
font-size: 13px;
color: var(--text-primary, #e0e0e0);
width: 200px;
}
.settings-input:focus {
outline: none;
border-color: var(--accent-cyan, #00d4ff);
}
.settings-input::placeholder {
color: var(--text-muted, #666);
}
/* Asset Status */
.asset-status {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
padding: 12px;
background: var(--bg-secondary, #0f0f1a);
border-radius: 6px;
}
.asset-status-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
}
.asset-name {
color: var(--text-muted, #888);
}
.asset-badge {
padding: 2px 8px;
border-radius: 10px;
font-size: 10px;
font-weight: 500;
text-transform: uppercase;
}
.asset-badge.available {
background: rgba(0, 255, 136, 0.15);
color: var(--accent-green, #00ff88);
}
.asset-badge.missing {
background: rgba(255, 68, 68, 0.15);
color: var(--accent-red, #ff4444);
}
.asset-badge.checking {
background: rgba(255, 170, 0, 0.15);
color: var(--accent-orange, #ffaa00);
}
/* Check Assets Button */
.check-assets-btn {
background: var(--bg-tertiary, #1a1a2e);
border: 1px solid var(--border-color, #2a2a3e);
color: var(--text-primary, #e0e0e0);
padding: 8px 16px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
margin-top: 12px;
transition: all 0.2s;
}
.check-assets-btn:hover {
border-color: var(--accent-cyan, #00d4ff);
color: var(--accent-cyan, #00d4ff);
}
.check-assets-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* GPS Detection Spinner */
.detecting-spinner {
display: inline-block;
width: 12px;
height: 12px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: detecting-spin 0.8s linear infinite;
vertical-align: middle;
margin-right: 6px;
}
@keyframes detecting-spin {
to { transform: rotate(360deg); }
}
/* About Section */
.about-info {
font-size: 13px;
color: var(--text-muted, #888);
line-height: 1.6;
}
.about-info p {
margin: 0 0 12px 0;
}
.about-info a {
color: var(--accent-cyan, #00d4ff);
text-decoration: none;
}
.about-info a:hover {
text-decoration: underline;
}
.about-version {
font-family: var(--font-mono);
color: var(--accent-cyan, #00d4ff);
}
/* Donate Button */
.donate-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 10px 20px;
background: linear-gradient(135deg, var(--accent-amber, #d4a853) 0%, var(--accent-orange, #f59e0b) 100%);
border: none;
border-radius: 6px;
color: #000;
font-size: 13px;
font-weight: 600;
text-decoration: none;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 2px 8px rgba(212, 168, 83, 0.3);
}
.donate-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(212, 168, 83, 0.4);
filter: brightness(1.1);
}
.donate-btn:active {
transform: translateY(0);
}
/* Tile Provider Custom URL */
.custom-url-row {
margin-top: 8px;
padding-top: 8px;
}
.custom-url-row .settings-input {
width: 100%;
}
/* Info Callout */
.settings-info {
background: rgba(0, 212, 255, 0.1);
border: 1px solid rgba(0, 212, 255, 0.2);
border-radius: 6px;
padding: 12px;
margin-top: 16px;
font-size: 12px;
color: var(--text-muted, #888);
}
.settings-info strong {
color: var(--accent-cyan, #00d4ff);
}
/* Map tile variants */
.tile-layer-cyan {
filter: sepia(0.35) hue-rotate(185deg) saturate(1.75) brightness(1.06) contrast(1.05);
}
/* Responsive */
@media (max-width: 640px) {
.settings-modal.active {
padding: 20px 10px;
}
.settings-content {
max-width: 100%;
}
.settings-row {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.settings-select,
.settings-input {
width: 100%;
}
}
+42 -14
View File
@@ -33,6 +33,9 @@ const ProximityRadar = (function() {
let activeFilter = null;
let onDeviceClick = null;
let selectedDeviceKey = null;
let isHovered = false;
let renderPending = false;
let renderTimer = null;
/**
* Initialize the radar component
@@ -162,8 +165,18 @@ const ProximityRadar = (function() {
devices.set(device.device_key, device);
});
// Apply filter and render
renderDevices();
// Defer render while user is hovering to prevent DOM rebuild flicker
if (isHovered) {
renderPending = true;
return;
}
// Debounce rapid updates (e.g. per-device SSE events)
if (renderTimer) clearTimeout(renderTimer);
renderTimer = setTimeout(() => {
renderTimer = null;
renderDevices();
}, 200);
}
/**
@@ -207,25 +220,32 @@ const ProximityRadar = (function() {
const pulseClass = isNew ? 'radar-dot-pulse' : '';
const isSelected = selectedDeviceKey && device.device_key === selectedDeviceKey;
// Hit area size (prevents hover flicker when scaling)
const hitAreaSize = Math.max(dotSize * 2, 15);
return `
<g class="radar-device ${pulseClass}${isSelected ? ' selected' : ''}" data-device-key="${escapeAttr(device.device_key)}"
transform="translate(${x}, ${y})" style="cursor: pointer;">
${isSelected ? `<circle r="${dotSize + 8}" fill="none" stroke="#00d4ff" stroke-width="2" stroke-opacity="0.8">
<animate attributeName="r" values="${dotSize + 6};${dotSize + 10};${dotSize + 6}" dur="1.5s" repeatCount="indefinite"/>
<animate attributeName="stroke-opacity" values="0.8;0.4;0.8" dur="1.5s" repeatCount="indefinite"/>
</circle>` : ''}
<circle r="${dotSize}" fill="${color}"
fill-opacity="${isSelected ? 1 : 0.4 + confidence * 0.5}"
stroke="${isSelected ? '#00d4ff' : color}" stroke-width="${isSelected ? 2 : 1}" />
${device.is_new && !isSelected ? `<circle r="${dotSize + 3}" fill="none" stroke="#3b82f6" stroke-width="1" stroke-dasharray="2,2" />` : ''}
<title>${escapeHtml(device.name || device.address)} (${device.rssi_current || '--'} dBm)</title>
<g transform="translate(${x}, ${y})">
<g class="radar-device ${pulseClass}${isSelected ? ' selected' : ''}" data-device-key="${escapeAttr(device.device_key)}"
style="cursor: pointer;">
<!-- Invisible hit area to prevent hover flicker -->
<circle class="radar-device-hitarea" r="${hitAreaSize}" fill="transparent" />
${isSelected ? `<circle r="${dotSize + 8}" fill="none" stroke="#00d4ff" stroke-width="2" stroke-opacity="0.8">
<animate attributeName="r" values="${dotSize + 6};${dotSize + 10};${dotSize + 6}" dur="1.5s" repeatCount="indefinite"/>
<animate attributeName="stroke-opacity" values="0.8;0.4;0.8" dur="1.5s" repeatCount="indefinite"/>
</circle>` : ''}
<circle r="${dotSize}" fill="${color}"
fill-opacity="${isSelected ? 1 : 0.4 + confidence * 0.5}"
stroke="${isSelected ? '#00d4ff' : color}" stroke-width="${isSelected ? 2 : 1}" />
${device.is_new && !isSelected ? `<circle r="${dotSize + 3}" fill="none" stroke="#3b82f6" stroke-width="1" stroke-dasharray="2,2" />` : ''}
<title>${escapeHtml(device.name || device.address)} (${device.rssi_current || '--'} dBm)</title>
</g>
</g>
`;
}).join('');
devicesGroup.innerHTML = dots;
// Attach click handlers
// Attach event handlers
devicesGroup.querySelectorAll('.radar-device').forEach(el => {
el.addEventListener('click', (e) => {
const deviceKey = el.getAttribute('data-device-key');
@@ -233,6 +253,14 @@ const ProximityRadar = (function() {
onDeviceClick(deviceKey);
}
});
el.addEventListener('mouseenter', () => { isHovered = true; });
el.addEventListener('mouseleave', () => {
isHovered = false;
if (renderPending) {
renderPending = false;
renderDevices();
}
});
});
}
+7 -18
View File
@@ -865,15 +865,12 @@ function connectAgentStream(mode, onMessage) {
}
let streamUrl;
if (currentAgent === 'local') {
streamUrl = `/${mode}/stream`;
} else {
// For remote agents, we could either:
// 1. Use the multi-agent stream: /controller/stream/all
// 2. Or proxy through controller (not implemented yet)
// For now, use multi-agent stream which includes agent_name tagging
streamUrl = '/controller/stream/all';
}
if (currentAgent === 'local') {
streamUrl = `/${mode}/stream`;
} else {
// For remote agents, proxy SSE through controller
streamUrl = `/controller/agents/${currentAgent}/${mode}/stream`;
}
agentEventSource = new EventSource(streamUrl);
@@ -881,15 +878,7 @@ function connectAgentStream(mode, onMessage) {
try {
const data = JSON.parse(event.data);
// If using multi-agent stream, filter by current agent if needed
if (streamUrl === '/controller/stream/all' && currentAgent !== 'local') {
const agent = agents.find(a => a.id == currentAgent);
if (agent && data.agent_name && data.agent_name !== agent.name) {
return; // Skip messages from other agents
}
}
onMessage(data);
onMessage(data);
} catch (e) {
console.error('Error parsing SSE message:', e);
}
+194
View File
@@ -0,0 +1,194 @@
const AlertCenter = (function() {
'use strict';
let alerts = [];
let rules = [];
let eventSource = null;
const TRACKER_RULE_NAME = 'Tracker Detected';
function init() {
loadRules();
loadFeed();
connect();
}
function connect() {
if (eventSource) {
eventSource.close();
}
eventSource = new EventSource('/alerts/stream');
eventSource.onmessage = function(e) {
try {
const data = JSON.parse(e.data);
if (data.type === 'keepalive') return;
handleAlert(data);
} catch (err) {
console.error('[Alerts] SSE parse error', err);
}
};
eventSource.onerror = function() {
console.warn('[Alerts] SSE connection error');
};
}
function handleAlert(alert) {
alerts.unshift(alert);
alerts = alerts.slice(0, 50);
updateFeedUI();
if (typeof showNotification === 'function') {
const severity = (alert.severity || '').toLowerCase();
if (['high', 'critical'].includes(severity)) {
showNotification(alert.title || 'Alert', alert.message || 'Alert triggered');
}
}
}
function updateFeedUI() {
const list = document.getElementById('alertsFeedList');
const countEl = document.getElementById('alertsFeedCount');
if (countEl) countEl.textContent = `(${alerts.length})`;
if (!list) return;
if (alerts.length === 0) {
list.innerHTML = '<div class="settings-feed-empty">No alerts yet</div>';
return;
}
list.innerHTML = alerts.map(alert => {
const title = escapeHtml(alert.title || 'Alert');
const message = escapeHtml(alert.message || '');
const severity = escapeHtml(alert.severity || 'medium');
const createdAt = alert.created_at ? new Date(alert.created_at).toLocaleString() : '';
return `
<div class="settings-feed-item">
<div class="settings-feed-title">
<span>${title}</span>
<span style="color: var(--text-dim);">${severity.toUpperCase()}</span>
</div>
<div class="settings-feed-meta">${message}</div>
<div class="settings-feed-meta" style="margin-top: 4px;">${createdAt}</div>
</div>
`;
}).join('');
}
function loadFeed() {
fetch('/alerts/events?limit=20')
.then(r => r.json())
.then(data => {
if (data.status === 'success') {
alerts = data.events || [];
updateFeedUI();
}
})
.catch(err => console.error('[Alerts] Load feed failed', err));
}
function loadRules() {
fetch('/alerts/rules?all=1')
.then(r => r.json())
.then(data => {
if (data.status === 'success') {
rules = data.rules || [];
}
})
.catch(err => console.error('[Alerts] Load rules failed', err));
}
function enableTrackerAlerts() {
ensureTrackerRule(true);
}
function disableTrackerAlerts() {
ensureTrackerRule(false);
}
function ensureTrackerRule(enabled) {
loadRules();
setTimeout(() => {
const existing = rules.find(r => r.name === TRACKER_RULE_NAME);
if (existing) {
fetch(`/alerts/rules/${existing.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled })
}).then(() => loadRules());
} else if (enabled) {
fetch('/alerts/rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: TRACKER_RULE_NAME,
mode: 'bluetooth',
event_type: 'device_update',
match: { is_tracker: true },
severity: 'high',
enabled: true,
notify: { webhook: true }
})
}).then(() => loadRules());
}
}, 150);
}
function addBluetoothWatchlist(address, name) {
if (!address) return;
const existing = rules.find(r => r.mode === 'bluetooth' && r.match && r.match.address === address);
if (existing) {
return;
}
fetch('/alerts/rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name ? `Watchlist ${name}` : `Watchlist ${address}`,
mode: 'bluetooth',
event_type: 'device_update',
match: { address: address },
severity: 'medium',
enabled: true,
notify: { webhook: true }
})
}).then(() => loadRules());
}
function removeBluetoothWatchlist(address) {
if (!address) return;
const existing = rules.find(r => r.mode === 'bluetooth' && r.match && r.match.address === address);
if (!existing) return;
fetch(`/alerts/rules/${existing.id}`, { method: 'DELETE' })
.then(() => loadRules());
}
function isWatchlisted(address) {
return rules.some(r => r.mode === 'bluetooth' && r.match && r.match.address === address && r.enabled);
}
function escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
return {
init,
loadFeed,
enableTrackerAlerts,
disableTrackerAlerts,
addBluetoothWatchlist,
removeBluetoothWatchlist,
isWatchlisted,
};
})();
document.addEventListener('DOMContentLoaded', () => {
if (typeof AlertCenter !== 'undefined') {
AlertCenter.init();
}
});
+494 -494
View File
@@ -1,36 +1,36 @@
/**
* Intercept - Core Application Logic
* Global state, mode switching, and shared functionality
*/
// ============== GLOBAL STATE ==============
// Mode state flags
let eventSource = null;
let isRunning = false;
let isSensorRunning = false;
let isAdsbRunning = false;
let isWifiRunning = false;
let isBtRunning = false;
let currentMode = 'pager';
// Message counters
let msgCount = 0;
let pocsagCount = 0;
let flexCount = 0;
let sensorCount = 0;
let filteredCount = 0;
// Device list (populated from server via Jinja2)
let deviceList = [];
// Auto-scroll setting
let autoScroll = localStorage.getItem('autoScroll') !== 'false';
// Mute setting
let muted = localStorage.getItem('audioMuted') === 'true';
// Observer location (load from localStorage or default to London)
/**
* Intercept - Core Application Logic
* Global state, mode switching, and shared functionality
*/
// ============== GLOBAL STATE ==============
// Mode state flags
let eventSource = null;
let isRunning = false;
let isSensorRunning = false;
let isAdsbRunning = false;
let isWifiRunning = false;
let isBtRunning = false;
let currentMode = 'pager';
// Message counters
let msgCount = 0;
let pocsagCount = 0;
let flexCount = 0;
let sensorCount = 0;
let filteredCount = 0;
// Device list (populated from server via Jinja2)
let deviceList = [];
// Auto-scroll setting
let autoScroll = localStorage.getItem('autoScroll') !== 'false';
// Mute setting
let muted = localStorage.getItem('audioMuted') === 'true';
// Observer location (load from localStorage or default to London)
let observerLocation = (function() {
if (window.ObserverLocation && ObserverLocation.getForModule) {
return ObserverLocation.getForModule('observerLocation');
@@ -44,464 +44,464 @@ let observerLocation = (function() {
}
return { lat: 51.5074, lon: -0.1278 };
})();
// Message storage for export
let allMessages = [];
// Track unique sensor devices
let uniqueDevices = new Set();
// SDR device usage tracking
let sdrDeviceUsage = {};
// ============== DISCLAIMER HANDLING ==============
function checkDisclaimer() {
const accepted = localStorage.getItem('disclaimerAccepted');
if (accepted === 'true') {
document.getElementById('disclaimerModal').classList.add('disclaimer-hidden');
}
}
function acceptDisclaimer() {
localStorage.setItem('disclaimerAccepted', 'true');
document.getElementById('disclaimerModal').classList.add('disclaimer-hidden');
}
function declineDisclaimer() {
document.getElementById('disclaimerModal').classList.add('disclaimer-hidden');
document.getElementById('rejectionPage').classList.remove('disclaimer-hidden');
}
// ============== HEADER CLOCK ==============
function updateHeaderClock() {
const now = new Date();
const utc = now.toISOString().substring(11, 19);
document.getElementById('headerUtcTime').textContent = utc;
}
// ============== MODE SWITCHING ==============
function switchMode(mode) {
// Stop any running scans when switching modes
if (isRunning && typeof stopDecoding === 'function') stopDecoding();
if (isSensorRunning && typeof stopSensorDecoding === 'function') stopSensorDecoding();
if (isWifiRunning && typeof stopWifiScan === 'function') stopWifiScan();
if (isBtRunning && typeof stopBtScan === 'function') stopBtScan();
if (isAdsbRunning && typeof stopAdsbScan === 'function') stopAdsbScan();
currentMode = mode;
// Remove active from all nav buttons, then add to the correct one
document.querySelectorAll('.mode-nav-btn').forEach(btn => btn.classList.remove('active'));
const modeMap = {
'pager': 'pager', 'sensor': '433', 'aircraft': 'aircraft',
'satellite': 'satellite', 'wifi': 'wifi', 'bluetooth': 'bluetooth',
'listening': 'listening', 'meshtastic': 'meshtastic'
};
document.querySelectorAll('.mode-nav-btn').forEach(btn => {
const label = btn.querySelector('.nav-label');
if (label && label.textContent.toLowerCase().includes(modeMap[mode])) {
btn.classList.add('active');
}
});
// Toggle mode content visibility
document.getElementById('pagerMode').classList.toggle('active', mode === 'pager');
document.getElementById('sensorMode').classList.toggle('active', mode === 'sensor');
document.getElementById('aircraftMode')?.classList.toggle('active', mode === 'aircraft');
document.getElementById('satelliteMode').classList.toggle('active', mode === 'satellite');
document.getElementById('wifiMode').classList.toggle('active', mode === 'wifi');
document.getElementById('bluetoothMode').classList.toggle('active', mode === 'bluetooth');
document.getElementById('listeningPostMode').classList.toggle('active', mode === 'listening');
document.getElementById('aprsMode')?.classList.toggle('active', mode === 'aprs');
document.getElementById('tscmMode')?.classList.toggle('active', mode === 'tscm');
document.getElementById('rtlamrMode')?.classList.toggle('active', mode === 'rtlamr');
document.getElementById('spystationsMode')?.classList.toggle('active', mode === 'spystations');
document.getElementById('meshtasticMode')?.classList.toggle('active', mode === 'meshtastic');
// Toggle stats visibility
document.getElementById('pagerStats').style.display = mode === 'pager' ? 'flex' : 'none';
document.getElementById('sensorStats').style.display = mode === 'sensor' ? 'flex' : 'none';
document.getElementById('aircraftStats').style.display = mode === 'aircraft' ? 'flex' : 'none';
document.getElementById('satelliteStats').style.display = mode === 'satellite' ? 'flex' : 'none';
document.getElementById('wifiStats').style.display = mode === 'wifi' ? 'flex' : 'none';
// Hide signal meter - individual panels show signal strength where needed
document.getElementById('signalMeter').style.display = 'none';
// Show/hide dashboard buttons in nav bar
document.getElementById('adsbDashboardBtn').style.display = mode === 'aircraft' ? 'inline-flex' : 'none';
document.getElementById('satelliteDashboardBtn').style.display = mode === 'satellite' ? 'inline-flex' : 'none';
// Update active mode indicator
const modeNames = {
'pager': 'PAGER',
'sensor': '433MHZ',
'aircraft': 'AIRCRAFT',
'satellite': 'SATELLITE',
'wifi': 'WIFI',
'bluetooth': 'BLUETOOTH',
'listening': 'LISTENING POST',
'tscm': 'TSCM',
'aprs': 'APRS',
'meshtastic': 'MESHTASTIC'
};
document.getElementById('activeModeIndicator').innerHTML = '<span class="pulse-dot"></span>' + modeNames[mode];
// Update mobile nav buttons
updateMobileNavButtons(mode);
// Close mobile drawer when mode is switched (on mobile)
if (window.innerWidth < 1024 && typeof window.closeMobileDrawer === 'function') {
window.closeMobileDrawer();
}
// Toggle layout containers
document.getElementById('wifiLayoutContainer').style.display = mode === 'wifi' ? 'flex' : 'none';
document.getElementById('btLayoutContainer').style.display = mode === 'bluetooth' ? 'flex' : 'none';
// Respect the "Show Radar Display" checkbox for aircraft mode
const showRadar = document.getElementById('adsbEnableMap')?.checked;
document.getElementById('aircraftVisuals').style.display = (mode === 'aircraft' && showRadar) ? 'grid' : 'none';
document.getElementById('satelliteVisuals').style.display = mode === 'satellite' ? 'block' : 'none';
document.getElementById('listeningPostVisuals').style.display = mode === 'listening' ? 'grid' : 'none';
// Update output panel title based on mode
const titles = {
'pager': 'Pager Decoder',
'sensor': '433MHz Sensor Monitor',
'aircraft': 'ADS-B Aircraft Tracker',
'satellite': 'Satellite Monitor',
'wifi': 'WiFi Scanner',
'bluetooth': 'Bluetooth Scanner',
'listening': 'Listening Post',
'meshtastic': 'Meshtastic Mesh Monitor'
};
document.getElementById('outputTitle').textContent = titles[mode] || 'Signal Monitor';
// Show/hide Device Intelligence for modes that use it
const reconBtn = document.getElementById('reconBtn');
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
if (mode === 'satellite' || mode === 'aircraft' || mode === 'listening') {
document.getElementById('reconPanel').style.display = 'none';
if (reconBtn) reconBtn.style.display = 'none';
if (intelBtn) intelBtn.style.display = 'none';
} else {
if (reconBtn) reconBtn.style.display = 'inline-block';
if (intelBtn) intelBtn.style.display = 'inline-block';
if (typeof reconEnabled !== 'undefined' && reconEnabled) {
document.getElementById('reconPanel').style.display = 'block';
}
}
// Show RTL-SDR device section for modes that use it
document.getElementById('rtlDeviceSection').style.display =
(mode === 'pager' || mode === 'sensor' || mode === 'aircraft' || mode === 'listening') ? 'block' : 'none';
// Toggle mode-specific tool status displays
document.getElementById('toolStatusPager').style.display = (mode === 'pager') ? 'grid' : 'none';
document.getElementById('toolStatusSensor').style.display = (mode === 'sensor') ? 'grid' : 'none';
document.getElementById('toolStatusAircraft').style.display = (mode === 'aircraft') ? 'grid' : 'none';
// Hide waterfall and output console for modes with their own visualizations
document.querySelector('.waterfall-container').style.display =
(mode === 'satellite' || mode === 'listening' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
document.getElementById('output').style.display =
(mode === 'satellite' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
document.querySelector('.status-bar').style.display = (mode === 'satellite' || mode === 'tscm' || mode === 'meshtastic' || mode === 'aprs' || mode === 'spystations') ? 'none' : 'flex';
// Load interfaces and initialize visualizations when switching modes
if (mode === 'wifi') {
if (typeof refreshWifiInterfaces === 'function') refreshWifiInterfaces();
if (typeof initRadar === 'function') initRadar();
if (typeof initWatchList === 'function') initWatchList();
} else if (mode === 'bluetooth') {
if (typeof refreshBtInterfaces === 'function') refreshBtInterfaces();
if (typeof initBtRadar === 'function') initBtRadar();
} else if (mode === 'aircraft') {
if (typeof checkAdsbTools === 'function') checkAdsbTools();
if (typeof initAircraftRadar === 'function') initAircraftRadar();
} else if (mode === 'satellite') {
if (typeof initPolarPlot === 'function') initPolarPlot();
if (typeof initSatelliteList === 'function') initSatelliteList();
} else if (mode === 'listening') {
if (typeof checkScannerTools === 'function') checkScannerTools();
if (typeof checkAudioTools === 'function') checkAudioTools();
if (typeof populateScannerDeviceSelect === 'function') populateScannerDeviceSelect();
if (typeof populateAudioDeviceSelect === 'function') populateAudioDeviceSelect();
} else if (mode === 'meshtastic') {
if (typeof Meshtastic !== 'undefined' && Meshtastic.init) Meshtastic.init();
}
}
// ============== SECTION COLLAPSE ==============
function toggleSection(el) {
el.closest('.section').classList.toggle('collapsed');
}
// ============== THEME MANAGEMENT ==============
function toggleTheme() {
const html = document.documentElement;
const currentTheme = html.getAttribute('data-theme');
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
html.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
// Update button text
const btn = document.getElementById('themeToggle');
if (btn) {
btn.textContent = newTheme === 'light' ? '🌙' : '☀️';
}
}
function loadTheme() {
const savedTheme = localStorage.getItem('theme') || 'dark';
document.documentElement.setAttribute('data-theme', savedTheme);
const btn = document.getElementById('themeToggle');
if (btn) {
btn.textContent = savedTheme === 'light' ? '🌙' : '☀️';
}
}
// ============== AUTO-SCROLL ==============
function toggleAutoScroll() {
autoScroll = !autoScroll;
localStorage.setItem('autoScroll', autoScroll);
updateAutoScrollButton();
}
function updateAutoScrollButton() {
const btn = document.getElementById('autoScrollBtn');
if (btn) {
btn.innerHTML = autoScroll ? '⬇ AUTO-SCROLL ON' : '⬇ AUTO-SCROLL OFF';
btn.classList.toggle('active', autoScroll);
}
}
// ============== SDR DEVICE MANAGEMENT ==============
function getSelectedDevice() {
return document.getElementById('deviceSelect').value;
}
function getSelectedSDRType() {
return document.getElementById('sdrTypeSelect').value;
}
function reserveDevice(deviceIndex, modeId) {
sdrDeviceUsage[modeId] = deviceIndex;
}
function releaseDevice(modeId) {
delete sdrDeviceUsage[modeId];
}
function checkDeviceAvailability(requestingMode) {
const selectedDevice = parseInt(getSelectedDevice());
for (const [mode, device] of Object.entries(sdrDeviceUsage)) {
if (mode !== requestingMode && device === selectedDevice) {
alert(`Device ${selectedDevice} is currently in use by ${mode} mode. Please select a different device or stop the other scan first.`);
return false;
}
}
return true;
}
// ============== BIAS-T SETTINGS ==============
function saveBiasTSetting() {
const enabled = document.getElementById('biasT')?.checked || false;
localStorage.setItem('biasTEnabled', enabled);
}
function getBiasTEnabled() {
return document.getElementById('biasT')?.checked || false;
}
function loadBiasTSetting() {
const saved = localStorage.getItem('biasTEnabled');
if (saved === 'true') {
const checkbox = document.getElementById('biasT');
if (checkbox) checkbox.checked = true;
}
}
// ============== REMOTE SDR ==============
function toggleRemoteSDR() {
const useRemote = document.getElementById('useRemoteSDR').checked;
const configDiv = document.getElementById('remoteSDRConfig');
const localControls = document.querySelectorAll('#sdrTypeSelect, #deviceSelect');
if (useRemote) {
configDiv.style.display = 'block';
localControls.forEach(el => el.disabled = true);
} else {
configDiv.style.display = 'none';
localControls.forEach(el => el.disabled = false);
}
}
function getRemoteSDRConfig() {
const useRemote = document.getElementById('useRemoteSDR')?.checked;
if (!useRemote) return null;
const host = document.getElementById('rtlTcpHost')?.value || 'localhost';
const port = parseInt(document.getElementById('rtlTcpPort')?.value || '1234');
if (!host || isNaN(port)) {
alert('Please enter valid rtl_tcp host and port');
return false;
}
return { host, port };
}
// ============== OUTPUT DISPLAY ==============
function showInfo(text) {
const output = document.getElementById('output');
if (!output) return;
const placeholder = output.querySelector('.placeholder');
if (placeholder) placeholder.remove();
const infoEl = document.createElement('div');
infoEl.className = 'info-msg';
infoEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #0a0a0a; border: 1px solid #1a1a1a; border-left: 2px solid #00d4ff; font-family: "JetBrains Mono", monospace; font-size: 11px; color: #888; word-break: break-all;';
infoEl.textContent = text;
output.insertBefore(infoEl, output.firstChild);
}
function showError(text) {
const output = document.getElementById('output');
if (!output) return;
const placeholder = output.querySelector('.placeholder');
if (placeholder) placeholder.remove();
const errorEl = document.createElement('div');
errorEl.className = 'error-msg';
errorEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #1a0a0a; border: 1px solid #2a1a1a; border-left: 2px solid #ff3366; font-family: "JetBrains Mono", monospace; font-size: 11px; color: #ff6688; word-break: break-all;';
errorEl.textContent = '⚠ ' + text;
output.insertBefore(errorEl, output.firstChild);
}
// ============== INITIALIZATION ==============
// ============== MOBILE NAVIGATION ==============
function initMobileNav() {
const hamburgerBtn = document.getElementById('hamburgerBtn');
const sidebar = document.getElementById('mainSidebar');
const overlay = document.getElementById('drawerOverlay');
if (!hamburgerBtn || !sidebar || !overlay) return;
function openDrawer() {
sidebar.classList.add('open');
overlay.classList.add('visible');
hamburgerBtn.classList.add('active');
document.body.style.overflow = 'hidden';
}
function closeDrawer() {
sidebar.classList.remove('open');
overlay.classList.remove('visible');
hamburgerBtn.classList.remove('active');
document.body.style.overflow = '';
}
function toggleDrawer() {
if (sidebar.classList.contains('open')) {
closeDrawer();
} else {
openDrawer();
}
}
hamburgerBtn.addEventListener('click', toggleDrawer);
overlay.addEventListener('click', closeDrawer);
// Close drawer when resizing to desktop
window.addEventListener('resize', () => {
if (window.innerWidth >= 1024) {
closeDrawer();
}
});
// Expose for external use
window.toggleMobileDrawer = toggleDrawer;
window.closeMobileDrawer = closeDrawer;
}
function setViewportHeight() {
// Fix for iOS Safari address bar height
const vh = window.innerHeight * 0.01;
document.documentElement.style.setProperty('--vh', `${vh}px`);
}
function updateMobileNavButtons(mode) {
// Update mobile nav bar buttons
document.querySelectorAll('.mobile-nav-btn').forEach(btn => {
const btnMode = btn.getAttribute('data-mode');
btn.classList.toggle('active', btnMode === mode);
});
}
function initApp() {
// Check disclaimer
checkDisclaimer();
// Load theme
loadTheme();
// Start clock
updateHeaderClock();
setInterval(updateHeaderClock, 1000);
// Load bias-T setting
loadBiasTSetting();
// Initialize observer location inputs
const adsbLatInput = document.getElementById('adsbObsLat');
const adsbLonInput = document.getElementById('adsbObsLon');
const obsLatInput = document.getElementById('obsLat');
const obsLonInput = document.getElementById('obsLon');
if (adsbLatInput) adsbLatInput.value = observerLocation.lat.toFixed(4);
if (adsbLonInput) adsbLonInput.value = observerLocation.lon.toFixed(4);
if (obsLatInput) obsLatInput.value = observerLocation.lat.toFixed(4);
if (obsLonInput) obsLonInput.value = observerLocation.lon.toFixed(4);
// Update UI state
updateAutoScrollButton();
// Make sections collapsible
document.querySelectorAll('.section h3').forEach(h3 => {
h3.addEventListener('click', function() {
this.parentElement.classList.toggle('collapsed');
});
});
// Collapse all sections by default (except SDR Device which is first)
document.querySelectorAll('.section').forEach((section, index) => {
if (index > 0) {
section.classList.add('collapsed');
}
});
// Initialize mobile navigation
initMobileNav();
// Set viewport height for mobile browsers
setViewportHeight();
window.addEventListener('resize', setViewportHeight);
}
// Run initialization when DOM is ready
document.addEventListener('DOMContentLoaded', initApp);
// Message storage for export
let allMessages = [];
// Track unique sensor devices
let uniqueDevices = new Set();
// SDR device usage tracking
let sdrDeviceUsage = {};
// ============== DISCLAIMER HANDLING ==============
function checkDisclaimer() {
const accepted = localStorage.getItem('disclaimerAccepted');
if (accepted === 'true') {
document.getElementById('disclaimerModal').classList.add('disclaimer-hidden');
}
}
function acceptDisclaimer() {
localStorage.setItem('disclaimerAccepted', 'true');
document.getElementById('disclaimerModal').classList.add('disclaimer-hidden');
}
function declineDisclaimer() {
document.getElementById('disclaimerModal').classList.add('disclaimer-hidden');
document.getElementById('rejectionPage').classList.remove('disclaimer-hidden');
}
// ============== HEADER CLOCK ==============
function updateHeaderClock() {
const now = new Date();
const utc = now.toISOString().substring(11, 19);
document.getElementById('headerUtcTime').textContent = utc;
}
// ============== MODE SWITCHING ==============
function switchMode(mode) {
// Stop any running scans when switching modes
if (isRunning && typeof stopDecoding === 'function') stopDecoding();
if (isSensorRunning && typeof stopSensorDecoding === 'function') stopSensorDecoding();
if (isWifiRunning && typeof stopWifiScan === 'function') stopWifiScan();
if (isBtRunning && typeof stopBtScan === 'function') stopBtScan();
if (isAdsbRunning && typeof stopAdsbScan === 'function') stopAdsbScan();
currentMode = mode;
// Remove active from all nav buttons, then add to the correct one
document.querySelectorAll('.mode-nav-btn').forEach(btn => btn.classList.remove('active'));
const modeMap = {
'pager': 'pager', 'sensor': '433', 'aircraft': 'aircraft',
'satellite': 'satellite', 'wifi': 'wifi', 'bluetooth': 'bluetooth',
'listening': 'listening', 'meshtastic': 'meshtastic'
};
document.querySelectorAll('.mode-nav-btn').forEach(btn => {
const label = btn.querySelector('.nav-label');
if (label && label.textContent.toLowerCase().includes(modeMap[mode])) {
btn.classList.add('active');
}
});
// Toggle mode content visibility
document.getElementById('pagerMode').classList.toggle('active', mode === 'pager');
document.getElementById('sensorMode').classList.toggle('active', mode === 'sensor');
document.getElementById('aircraftMode')?.classList.toggle('active', mode === 'aircraft');
document.getElementById('satelliteMode').classList.toggle('active', mode === 'satellite');
document.getElementById('wifiMode').classList.toggle('active', mode === 'wifi');
document.getElementById('bluetoothMode').classList.toggle('active', mode === 'bluetooth');
document.getElementById('listeningPostMode').classList.toggle('active', mode === 'listening');
document.getElementById('aprsMode')?.classList.toggle('active', mode === 'aprs');
document.getElementById('tscmMode')?.classList.toggle('active', mode === 'tscm');
document.getElementById('rtlamrMode')?.classList.toggle('active', mode === 'rtlamr');
document.getElementById('spystationsMode')?.classList.toggle('active', mode === 'spystations');
document.getElementById('meshtasticMode')?.classList.toggle('active', mode === 'meshtastic');
// Toggle stats visibility
document.getElementById('pagerStats').style.display = mode === 'pager' ? 'flex' : 'none';
document.getElementById('sensorStats').style.display = mode === 'sensor' ? 'flex' : 'none';
document.getElementById('aircraftStats').style.display = mode === 'aircraft' ? 'flex' : 'none';
document.getElementById('satelliteStats').style.display = mode === 'satellite' ? 'flex' : 'none';
document.getElementById('wifiStats').style.display = mode === 'wifi' ? 'flex' : 'none';
// Hide signal meter - individual panels show signal strength where needed
document.getElementById('signalMeter').style.display = 'none';
// Show/hide dashboard buttons in nav bar
document.getElementById('adsbDashboardBtn').style.display = mode === 'aircraft' ? 'inline-flex' : 'none';
document.getElementById('satelliteDashboardBtn').style.display = mode === 'satellite' ? 'inline-flex' : 'none';
// Update active mode indicator
const modeNames = {
'pager': 'PAGER',
'sensor': '433MHZ',
'aircraft': 'AIRCRAFT',
'satellite': 'SATELLITE',
'wifi': 'WIFI',
'bluetooth': 'BLUETOOTH',
'listening': 'LISTENING POST',
'tscm': 'TSCM',
'aprs': 'APRS',
'meshtastic': 'MESHTASTIC'
};
document.getElementById('activeModeIndicator').innerHTML = '<span class="pulse-dot"></span>' + modeNames[mode];
// Update mobile nav buttons
updateMobileNavButtons(mode);
// Close mobile drawer when mode is switched (on mobile)
if (window.innerWidth < 1024 && typeof window.closeMobileDrawer === 'function') {
window.closeMobileDrawer();
}
// Toggle layout containers
document.getElementById('wifiLayoutContainer').style.display = mode === 'wifi' ? 'flex' : 'none';
document.getElementById('btLayoutContainer').style.display = mode === 'bluetooth' ? 'flex' : 'none';
// Respect the "Show Radar Display" checkbox for aircraft mode
const showRadar = document.getElementById('adsbEnableMap')?.checked;
document.getElementById('aircraftVisuals').style.display = (mode === 'aircraft' && showRadar) ? 'grid' : 'none';
document.getElementById('satelliteVisuals').style.display = mode === 'satellite' ? 'block' : 'none';
document.getElementById('listeningPostVisuals').style.display = mode === 'listening' ? 'grid' : 'none';
// Update output panel title based on mode
const titles = {
'pager': 'Pager Decoder',
'sensor': '433MHz Sensor Monitor',
'aircraft': 'ADS-B Aircraft Tracker',
'satellite': 'Satellite Monitor',
'wifi': 'WiFi Scanner',
'bluetooth': 'Bluetooth Scanner',
'listening': 'Listening Post',
'meshtastic': 'Meshtastic Mesh Monitor'
};
document.getElementById('outputTitle').textContent = titles[mode] || 'Signal Monitor';
// Show/hide Device Intelligence for modes that use it
const reconBtn = document.getElementById('reconBtn');
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
if (mode === 'satellite' || mode === 'aircraft' || mode === 'listening') {
document.getElementById('reconPanel').style.display = 'none';
if (reconBtn) reconBtn.style.display = 'none';
if (intelBtn) intelBtn.style.display = 'none';
} else {
if (reconBtn) reconBtn.style.display = 'inline-block';
if (intelBtn) intelBtn.style.display = 'inline-block';
if (typeof reconEnabled !== 'undefined' && reconEnabled) {
document.getElementById('reconPanel').style.display = 'block';
}
}
// Show RTL-SDR device section for modes that use it
document.getElementById('rtlDeviceSection').style.display =
(mode === 'pager' || mode === 'sensor' || mode === 'aircraft' || mode === 'listening') ? 'block' : 'none';
// Toggle mode-specific tool status displays
document.getElementById('toolStatusPager').style.display = (mode === 'pager') ? 'grid' : 'none';
document.getElementById('toolStatusSensor').style.display = (mode === 'sensor') ? 'grid' : 'none';
document.getElementById('toolStatusAircraft').style.display = (mode === 'aircraft') ? 'grid' : 'none';
// Hide waterfall and output console for modes with their own visualizations
document.querySelector('.waterfall-container').style.display =
(mode === 'satellite' || mode === 'listening' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
document.getElementById('output').style.display =
(mode === 'satellite' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
document.querySelector('.status-bar').style.display = (mode === 'satellite' || mode === 'tscm' || mode === 'meshtastic' || mode === 'aprs' || mode === 'spystations') ? 'none' : 'flex';
// Load interfaces and initialize visualizations when switching modes
if (mode === 'wifi') {
if (typeof refreshWifiInterfaces === 'function') refreshWifiInterfaces();
if (typeof initRadar === 'function') initRadar();
if (typeof initWatchList === 'function') initWatchList();
} else if (mode === 'bluetooth') {
if (typeof refreshBtInterfaces === 'function') refreshBtInterfaces();
if (typeof initBtRadar === 'function') initBtRadar();
} else if (mode === 'aircraft') {
if (typeof checkAdsbTools === 'function') checkAdsbTools();
if (typeof initAircraftRadar === 'function') initAircraftRadar();
} else if (mode === 'satellite') {
if (typeof initPolarPlot === 'function') initPolarPlot();
if (typeof initSatelliteList === 'function') initSatelliteList();
} else if (mode === 'listening') {
if (typeof checkScannerTools === 'function') checkScannerTools();
if (typeof checkAudioTools === 'function') checkAudioTools();
if (typeof populateScannerDeviceSelect === 'function') populateScannerDeviceSelect();
if (typeof populateAudioDeviceSelect === 'function') populateAudioDeviceSelect();
} else if (mode === 'meshtastic') {
if (typeof Meshtastic !== 'undefined' && Meshtastic.init) Meshtastic.init();
}
}
// ============== SECTION COLLAPSE ==============
function toggleSection(el) {
el.closest('.section').classList.toggle('collapsed');
}
// ============== THEME MANAGEMENT ==============
function toggleTheme() {
const html = document.documentElement;
const currentTheme = html.getAttribute('data-theme') || 'dark';
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
html.setAttribute('data-theme', newTheme);
localStorage.setItem('intercept-theme', newTheme);
// Update button text
const btn = document.getElementById('themeToggle');
if (btn) {
btn.textContent = newTheme === 'light' ? '🌙' : '☀️';
}
}
function loadTheme() {
const savedTheme = localStorage.getItem('intercept-theme') || 'dark';
document.documentElement.setAttribute('data-theme', savedTheme);
const btn = document.getElementById('themeToggle');
if (btn) {
btn.textContent = savedTheme === 'light' ? '🌙' : '☀️';
}
}
// ============== AUTO-SCROLL ==============
function toggleAutoScroll() {
autoScroll = !autoScroll;
localStorage.setItem('autoScroll', autoScroll);
updateAutoScrollButton();
}
function updateAutoScrollButton() {
const btn = document.getElementById('autoScrollBtn');
if (btn) {
btn.innerHTML = autoScroll ? '⬇ AUTO-SCROLL ON' : '⬇ AUTO-SCROLL OFF';
btn.classList.toggle('active', autoScroll);
}
}
// ============== SDR DEVICE MANAGEMENT ==============
function getSelectedDevice() {
return document.getElementById('deviceSelect').value;
}
function getSelectedSDRType() {
return document.getElementById('sdrTypeSelect').value;
}
function reserveDevice(deviceIndex, modeId) {
sdrDeviceUsage[modeId] = deviceIndex;
}
function releaseDevice(modeId) {
delete sdrDeviceUsage[modeId];
}
function checkDeviceAvailability(requestingMode) {
const selectedDevice = parseInt(getSelectedDevice());
for (const [mode, device] of Object.entries(sdrDeviceUsage)) {
if (mode !== requestingMode && device === selectedDevice) {
alert(`Device ${selectedDevice} is currently in use by ${mode} mode. Please select a different device or stop the other scan first.`);
return false;
}
}
return true;
}
// ============== BIAS-T SETTINGS ==============
function saveBiasTSetting() {
const enabled = document.getElementById('biasT')?.checked || false;
localStorage.setItem('biasTEnabled', enabled);
}
function getBiasTEnabled() {
return document.getElementById('biasT')?.checked || false;
}
function loadBiasTSetting() {
const saved = localStorage.getItem('biasTEnabled');
if (saved === 'true') {
const checkbox = document.getElementById('biasT');
if (checkbox) checkbox.checked = true;
}
}
// ============== REMOTE SDR ==============
function toggleRemoteSDR() {
const useRemote = document.getElementById('useRemoteSDR').checked;
const configDiv = document.getElementById('remoteSDRConfig');
const localControls = document.querySelectorAll('#sdrTypeSelect, #deviceSelect');
if (useRemote) {
configDiv.style.display = 'block';
localControls.forEach(el => el.disabled = true);
} else {
configDiv.style.display = 'none';
localControls.forEach(el => el.disabled = false);
}
}
function getRemoteSDRConfig() {
const useRemote = document.getElementById('useRemoteSDR')?.checked;
if (!useRemote) return null;
const host = document.getElementById('rtlTcpHost')?.value || 'localhost';
const port = parseInt(document.getElementById('rtlTcpPort')?.value || '1234');
if (!host || isNaN(port)) {
alert('Please enter valid rtl_tcp host and port');
return false;
}
return { host, port };
}
// ============== OUTPUT DISPLAY ==============
function showInfo(text) {
const output = document.getElementById('output');
if (!output) return;
const placeholder = output.querySelector('.placeholder');
if (placeholder) placeholder.remove();
const infoEl = document.createElement('div');
infoEl.className = 'info-msg';
infoEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #0a0a0a; border: 1px solid #1a1a1a; border-left: 2px solid #00d4ff; font-family: "Space Mono", monospace; font-size: 11px; color: #888; word-break: break-all;';
infoEl.textContent = text;
output.insertBefore(infoEl, output.firstChild);
}
function showError(text) {
const output = document.getElementById('output');
if (!output) return;
const placeholder = output.querySelector('.placeholder');
if (placeholder) placeholder.remove();
const errorEl = document.createElement('div');
errorEl.className = 'error-msg';
errorEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #1a0a0a; border: 1px solid #2a1a1a; border-left: 2px solid #ff3366; font-family: "Space Mono", monospace; font-size: 11px; color: #ff6688; word-break: break-all;';
errorEl.textContent = '⚠ ' + text;
output.insertBefore(errorEl, output.firstChild);
}
// ============== INITIALIZATION ==============
// ============== MOBILE NAVIGATION ==============
function initMobileNav() {
const hamburgerBtn = document.getElementById('hamburgerBtn');
const sidebar = document.getElementById('mainSidebar');
const overlay = document.getElementById('drawerOverlay');
if (!hamburgerBtn || !sidebar || !overlay) return;
function openDrawer() {
sidebar.classList.add('open');
overlay.classList.add('visible');
hamburgerBtn.classList.add('active');
document.body.style.overflow = 'hidden';
}
function closeDrawer() {
sidebar.classList.remove('open');
overlay.classList.remove('visible');
hamburgerBtn.classList.remove('active');
document.body.style.overflow = '';
}
function toggleDrawer() {
if (sidebar.classList.contains('open')) {
closeDrawer();
} else {
openDrawer();
}
}
hamburgerBtn.addEventListener('click', toggleDrawer);
overlay.addEventListener('click', closeDrawer);
// Close drawer when resizing to desktop
window.addEventListener('resize', () => {
if (window.innerWidth >= 1024) {
closeDrawer();
}
});
// Expose for external use
window.toggleMobileDrawer = toggleDrawer;
window.closeMobileDrawer = closeDrawer;
}
function setViewportHeight() {
// Fix for iOS Safari address bar height
const vh = window.innerHeight * 0.01;
document.documentElement.style.setProperty('--vh', `${vh}px`);
}
function updateMobileNavButtons(mode) {
// Update mobile nav bar buttons
document.querySelectorAll('.mobile-nav-btn').forEach(btn => {
const btnMode = btn.getAttribute('data-mode');
btn.classList.toggle('active', btnMode === mode);
});
}
function initApp() {
// Check disclaimer
checkDisclaimer();
// Load theme
loadTheme();
// Start clock
updateHeaderClock();
setInterval(updateHeaderClock, 1000);
// Load bias-T setting
loadBiasTSetting();
// Initialize observer location inputs
const adsbLatInput = document.getElementById('adsbObsLat');
const adsbLonInput = document.getElementById('adsbObsLon');
const obsLatInput = document.getElementById('obsLat');
const obsLonInput = document.getElementById('obsLon');
if (adsbLatInput) adsbLatInput.value = observerLocation.lat.toFixed(4);
if (adsbLonInput) adsbLonInput.value = observerLocation.lon.toFixed(4);
if (obsLatInput) obsLatInput.value = observerLocation.lat.toFixed(4);
if (obsLonInput) obsLonInput.value = observerLocation.lon.toFixed(4);
// Update UI state
updateAutoScrollButton();
// Make sections collapsible
document.querySelectorAll('.section h3').forEach(h3 => {
h3.addEventListener('click', function() {
this.parentElement.classList.toggle('collapsed');
});
});
// Collapse all sections by default (except SDR Device which is first)
document.querySelectorAll('.section').forEach((section, index) => {
if (index > 0) {
section.classList.add('collapsed');
}
});
// Initialize mobile navigation
initMobileNav();
// Set viewport height for mobile browsers
setViewportHeight();
window.addEventListener('resize', setViewportHeight);
}
// Run initialization when DOM is ready
document.addEventListener('DOMContentLoaded', initApp);
+48
View File
@@ -0,0 +1,48 @@
(() => {
const dropdowns = Array.from(document.querySelectorAll('.mode-nav-dropdown'));
if (!dropdowns.length) return;
const closeAll = () => {
dropdowns.forEach((dropdown) => dropdown.classList.remove('open'));
};
const openDropdown = (dropdown) => {
if (!dropdown.classList.contains('open')) {
closeAll();
dropdown.classList.add('open');
}
};
document.addEventListener('click', (event) => {
const menuLink = event.target.closest('.mode-nav-dropdown-menu a');
if (menuLink) {
event.preventDefault();
event.stopPropagation();
window.location.href = menuLink.href;
return;
}
const button = event.target.closest('.mode-nav-dropdown-btn');
if (button) {
event.preventDefault();
const dropdown = button.closest('.mode-nav-dropdown');
if (!dropdown) return;
if (dropdown.classList.contains('open')) {
dropdown.classList.remove('open');
} else {
openDropdown(dropdown);
}
return;
}
if (!event.target.closest('.mode-nav-dropdown')) {
closeAll();
}
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
closeAll();
}
});
})();
+136
View File
@@ -0,0 +1,136 @@
const RecordingUI = (function() {
'use strict';
let recordings = [];
let active = [];
function init() {
refresh();
}
function refresh() {
fetch('/recordings')
.then(r => r.json())
.then(data => {
if (data.status !== 'success') return;
recordings = data.recordings || [];
active = data.active || [];
renderActive();
renderRecordings();
})
.catch(err => console.error('[Recording] Load failed', err));
}
function start() {
const modeSelect = document.getElementById('recordingModeSelect');
const labelInput = document.getElementById('recordingLabelInput');
const mode = modeSelect ? modeSelect.value : '';
const label = labelInput ? labelInput.value : '';
if (!mode) return;
fetch('/recordings/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode, label })
})
.then(r => r.json())
.then(() => {
refresh();
})
.catch(err => console.error('[Recording] Start failed', err));
}
function stop() {
const modeSelect = document.getElementById('recordingModeSelect');
const mode = modeSelect ? modeSelect.value : '';
if (!mode) return;
fetch('/recordings/stop', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode })
})
.then(r => r.json())
.then(() => refresh())
.catch(err => console.error('[Recording] Stop failed', err));
}
function stopById(sessionId) {
fetch('/recordings/stop', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: sessionId })
}).then(() => refresh());
}
function renderActive() {
const container = document.getElementById('recordingActiveList');
if (!container) return;
if (!active.length) {
container.innerHTML = '<div class="settings-feed-empty">No active recordings</div>';
return;
}
container.innerHTML = active.map(session => {
return `
<div class="settings-feed-item">
<div class="settings-feed-title">
<span>${escapeHtml(session.mode)}</span>
<button class="preset-btn" style="font-size: 9px; padding: 2px 6px;" onclick="RecordingUI.stopById('${session.id}')">Stop</button>
</div>
<div class="settings-feed-meta">Started: ${new Date(session.started_at).toLocaleString()}</div>
<div class="settings-feed-meta">Events: ${session.event_count || 0}</div>
</div>
`;
}).join('');
}
function renderRecordings() {
const container = document.getElementById('recordingList');
if (!container) return;
if (!recordings.length) {
container.innerHTML = '<div class="settings-feed-empty">No recordings yet</div>';
return;
}
container.innerHTML = recordings.map(rec => {
return `
<div class="settings-feed-item">
<div class="settings-feed-title">
<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>
<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>
`;
}).join('');
}
function download(sessionId) {
window.open(`/recordings/${sessionId}/download`, '_blank');
}
function escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
return {
init,
refresh,
start,
stop,
stopById,
download,
};
})();
document.addEventListener('DOMContentLoaded', () => {
if (typeof RecordingUI !== 'undefined') {
RecordingUI.init();
}
});
File diff suppressed because it is too large Load Diff
+75 -32
View File
@@ -366,7 +366,10 @@ const BluetoothMode = (function() {
// Badges
const badgesEl = document.getElementById('btDetailBadges');
let badgesHtml = `<span class="bt-detail-badge ${protocol}">${protocol.toUpperCase()}</span>`;
badgesHtml += `<span class="bt-detail-badge ${device.in_baseline ? 'baseline' : 'new'}">${device.in_baseline ? '✓ KNOWN' : '● NEW'}</span>`;
badgesHtml += `<span class="bt-detail-badge ${device.in_baseline ? 'baseline' : 'new'}">${device.in_baseline ? '✓ KNOWN' : '● NEW'}</span>`;
if (device.seen_before) {
badgesHtml += `<span class="bt-detail-badge flag">SEEN BEFORE</span>`;
}
// Tracker badge
if (device.is_tracker) {
@@ -448,12 +451,14 @@ const BluetoothMode = (function() {
? minMax[0] + '/' + minMax[1]
: '--';
document.getElementById('btDetailFirstSeen').textContent = device.first_seen
? new Date(device.first_seen).toLocaleTimeString()
: '--';
document.getElementById('btDetailLastSeen').textContent = device.last_seen
? new Date(device.last_seen).toLocaleTimeString()
: '--';
document.getElementById('btDetailFirstSeen').textContent = device.first_seen
? new Date(device.first_seen).toLocaleTimeString()
: '--';
document.getElementById('btDetailLastSeen').textContent = device.last_seen
? new Date(device.last_seen).toLocaleTimeString()
: '--';
updateWatchlistButton(device);
// Services
const servicesContainer = document.getElementById('btDetailServices');
@@ -465,13 +470,29 @@ const BluetoothMode = (function() {
servicesContainer.style.display = 'none';
}
// Show content, hide placeholder
placeholder.style.display = 'none';
content.style.display = 'block';
// Show content, hide placeholder
placeholder.style.display = 'none';
content.style.display = 'block';
// Highlight selected device in list
highlightSelectedDevice(deviceId);
}
}
/**
* Update watchlist button state
*/
function updateWatchlistButton(device) {
const btn = document.getElementById('btDetailWatchBtn');
if (!btn) return;
if (typeof AlertCenter === 'undefined') {
btn.style.display = 'none';
return;
}
btn.style.display = '';
const watchlisted = AlertCenter.isWatchlisted(device.address);
btn.textContent = watchlisted ? 'Watching' : 'Watchlist';
btn.classList.toggle('active', watchlisted);
}
/**
* Clear device selection
@@ -525,24 +546,43 @@ const BluetoothMode = (function() {
/**
* Copy selected device address to clipboard
*/
function copyAddress() {
if (!selectedDeviceId) return;
const device = devices.get(selectedDeviceId);
if (!device) return;
function copyAddress() {
if (!selectedDeviceId) return;
const device = devices.get(selectedDeviceId);
if (!device) return;
navigator.clipboard.writeText(device.address).then(() => {
const btn = document.querySelector('.bt-detail-btn');
if (btn) {
const originalText = btn.textContent;
btn.textContent = 'Copied!';
btn.style.background = '#22c55e';
navigator.clipboard.writeText(device.address).then(() => {
const btn = document.getElementById('btDetailCopyBtn');
if (btn) {
const originalText = btn.textContent;
btn.textContent = 'Copied!';
btn.style.background = '#22c55e';
setTimeout(() => {
btn.textContent = originalText;
btn.style.background = '';
}, 1500);
}
});
}
});
}
/**
* Toggle Bluetooth watchlist for selected device
*/
function toggleWatchlist() {
if (!selectedDeviceId) return;
const device = devices.get(selectedDeviceId);
if (!device || typeof AlertCenter === 'undefined') return;
if (AlertCenter.isWatchlisted(device.address)) {
AlertCenter.removeBluetoothWatchlist(device.address);
showInfo('Removed from watchlist');
} else {
AlertCenter.addBluetoothWatchlist(device.address, device.name || device.address);
showInfo('Added to watchlist');
}
setTimeout(() => updateWatchlistButton(device), 200);
}
/**
* Select a device - opens modal with details
@@ -1090,10 +1130,11 @@ const BluetoothMode = (function() {
const isNew = !inBaseline;
const hasName = !!device.name;
const isTracker = device.is_tracker === true;
const trackerType = device.tracker_type;
const trackerConfidence = device.tracker_confidence;
const riskScore = device.risk_score || 0;
const agentName = device._agent || 'Local';
const trackerType = device.tracker_type;
const trackerConfidence = device.tracker_confidence;
const riskScore = device.risk_score || 0;
const agentName = device._agent || 'Local';
const seenBefore = device.seen_before === true;
// Calculate RSSI bar width (0-100%)
// RSSI typically ranges from -100 (weak) to -30 (very strong)
@@ -1145,8 +1186,9 @@ const BluetoothMode = (function() {
// Build secondary info line
let secondaryParts = [addr];
if (mfr) secondaryParts.push(mfr);
secondaryParts.push('Seen ' + seenCount + '×');
if (mfr) secondaryParts.push(mfr);
secondaryParts.push('Seen ' + seenCount + '×');
if (seenBefore) secondaryParts.push('<span class="bt-history-badge">SEEN BEFORE</span>');
// Add agent name if not Local
if (agentName !== 'Local') {
secondaryParts.push('<span class="agent-badge agent-remote" style="font-size:8px;padding:1px 4px;">' + escapeHtml(agentName) + '</span>');
@@ -1358,9 +1400,10 @@ const BluetoothMode = (function() {
setBaseline,
clearBaseline,
exportData,
selectDevice,
clearSelection,
copyAddress,
selectDevice,
clearSelection,
copyAddress,
toggleWatchlist,
// Agent handling
handleAgentChange,
+504
View File
@@ -0,0 +1,504 @@
/**
* Intercept - DMR / Digital Voice Mode
* Decoding DMR, P25, NXDN, D-STAR digital voice protocols
*/
// ============== STATE ==============
let isDmrRunning = false;
let dmrEventSource = null;
let dmrCallCount = 0;
let dmrSyncCount = 0;
let dmrCallHistory = [];
let dmrCurrentProtocol = '--';
// ============== SYNTHESIZER STATE ==============
let dmrSynthCanvas = null;
let dmrSynthCtx = null;
let dmrSynthBars = [];
let dmrSynthAnimationId = null;
let dmrSynthInitialized = false;
let dmrActivityLevel = 0;
let dmrActivityTarget = 0;
let dmrEventType = 'idle';
let dmrLastEventTime = 0;
const DMR_BAR_COUNT = 48;
const DMR_DECAY_RATE = 0.015;
const DMR_BURST_SYNC = 0.6;
const DMR_BURST_CALL = 0.85;
const DMR_BURST_VOICE = 0.95;
// ============== TOOLS CHECK ==============
function checkDmrTools() {
fetch('/dmr/tools')
.then(r => r.json())
.then(data => {
const warning = document.getElementById('dmrToolsWarning');
const warningText = document.getElementById('dmrToolsWarningText');
if (!warning) return;
const missing = [];
if (!data.dsd) missing.push('dsd (Digital Speech Decoder)');
if (!data.rtl_fm) missing.push('rtl_fm (RTL-SDR)');
if (missing.length > 0) {
warning.style.display = 'block';
if (warningText) warningText.textContent = missing.join(', ');
} else {
warning.style.display = 'none';
}
})
.catch(() => {});
}
// ============== START / STOP ==============
function startDmr() {
const frequency = parseFloat(document.getElementById('dmrFrequency')?.value || 462.5625);
const protocol = document.getElementById('dmrProtocol')?.value || 'auto';
const gain = parseInt(document.getElementById('dmrGain')?.value || 40);
const device = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0;
// Check device availability before starting
if (typeof checkDeviceAvailability === 'function' && !checkDeviceAvailability('dmr')) {
return;
}
fetch('/dmr/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ frequency, protocol, gain, device })
})
.then(r => r.json())
.then(data => {
if (data.status === 'started') {
isDmrRunning = true;
dmrCallCount = 0;
dmrSyncCount = 0;
dmrCallHistory = [];
updateDmrUI();
connectDmrSSE();
dmrEventType = 'idle';
dmrActivityTarget = 0.1;
dmrLastEventTime = Date.now();
if (!dmrSynthInitialized) initDmrSynthesizer();
updateDmrSynthStatus();
const statusEl = document.getElementById('dmrStatus');
if (statusEl) statusEl.textContent = 'DECODING';
if (typeof reserveDevice === 'function') {
reserveDevice(parseInt(device), 'dmr');
}
if (typeof showNotification === 'function') {
showNotification('DMR', `Decoding ${frequency} MHz (${protocol.toUpperCase()})`);
}
} else {
if (typeof showNotification === 'function') {
showNotification('Error', data.message || 'Failed to start DMR');
}
}
})
.catch(err => console.error('[DMR] Start error:', err));
}
function stopDmr() {
fetch('/dmr/stop', { method: 'POST' })
.then(r => r.json())
.then(() => {
isDmrRunning = false;
if (dmrEventSource) { dmrEventSource.close(); dmrEventSource = null; }
updateDmrUI();
dmrEventType = 'stopped';
dmrActivityTarget = 0;
updateDmrSynthStatus();
const statusEl = document.getElementById('dmrStatus');
if (statusEl) statusEl.textContent = 'STOPPED';
if (typeof releaseDevice === 'function') {
releaseDevice('dmr');
}
})
.catch(err => console.error('[DMR] Stop error:', err));
}
// ============== SSE STREAMING ==============
function connectDmrSSE() {
if (dmrEventSource) dmrEventSource.close();
dmrEventSource = new EventSource('/dmr/stream');
dmrEventSource.onmessage = function(event) {
const msg = JSON.parse(event.data);
handleDmrMessage(msg);
};
dmrEventSource.onerror = function() {
if (isDmrRunning) {
setTimeout(connectDmrSSE, 2000);
}
};
}
function handleDmrMessage(msg) {
if (dmrSynthInitialized) dmrSynthPulse(msg.type);
if (msg.type === 'sync') {
dmrCurrentProtocol = msg.protocol || '--';
const protocolEl = document.getElementById('dmrActiveProtocol');
if (protocolEl) protocolEl.textContent = dmrCurrentProtocol;
const mainProtocolEl = document.getElementById('dmrMainProtocol');
if (mainProtocolEl) mainProtocolEl.textContent = dmrCurrentProtocol;
dmrSyncCount++;
const syncCountEl = document.getElementById('dmrSyncCount');
if (syncCountEl) syncCountEl.textContent = dmrSyncCount;
} else if (msg.type === 'call') {
dmrCallCount++;
const countEl = document.getElementById('dmrCallCount');
if (countEl) countEl.textContent = dmrCallCount;
const mainCountEl = document.getElementById('dmrMainCallCount');
if (mainCountEl) mainCountEl.textContent = dmrCallCount;
// Update current call display
const slotInfo = msg.slot != null ? `
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
<span style="color: var(--text-muted);">Slot</span>
<span style="color: var(--accent-orange); font-family: var(--font-mono);">${msg.slot}</span>
</div>` : '';
const callEl = document.getElementById('dmrCurrentCall');
if (callEl) {
callEl.innerHTML = `
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
<span style="color: var(--text-muted);">Talkgroup</span>
<span style="color: var(--accent-green); font-weight: bold; font-family: var(--font-mono);">${msg.talkgroup}</span>
</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
<span style="color: var(--text-muted);">Source ID</span>
<span style="color: var(--accent-cyan); font-family: var(--font-mono);">${msg.source_id}</span>
</div>${slotInfo}
<div style="display: flex; justify-content: space-between;">
<span style="color: var(--text-muted);">Time</span>
<span style="color: var(--text-primary);">${msg.timestamp}</span>
</div>
`;
}
// Add to history
dmrCallHistory.unshift({
talkgroup: msg.talkgroup,
source_id: msg.source_id,
protocol: dmrCurrentProtocol,
time: msg.timestamp,
});
if (dmrCallHistory.length > 50) dmrCallHistory.length = 50;
renderDmrHistory();
} else if (msg.type === 'slot') {
// Update slot info in current call
} else if (msg.type === 'raw') {
// Raw DSD output — triggers synthesizer activity via dmrSynthPulse
} else if (msg.type === 'heartbeat') {
// Decoder is alive and listening — keep synthesizer in listening state
if (isDmrRunning && dmrSynthInitialized) {
if (dmrEventType === 'idle' || dmrEventType === 'raw') {
dmrEventType = 'raw';
dmrActivityTarget = Math.max(dmrActivityTarget, 0.15);
dmrLastEventTime = Date.now();
updateDmrSynthStatus();
}
}
} else if (msg.type === 'status') {
const statusEl = document.getElementById('dmrStatus');
if (msg.text === 'started') {
if (statusEl) statusEl.textContent = 'DECODING';
} else if (msg.text === 'crashed') {
isDmrRunning = false;
updateDmrUI();
dmrEventType = 'stopped';
dmrActivityTarget = 0;
updateDmrSynthStatus();
if (statusEl) statusEl.textContent = 'CRASHED';
if (typeof releaseDevice === 'function') releaseDevice('dmr');
const detail = msg.detail || `Decoder exited (code ${msg.exit_code})`;
if (typeof showNotification === 'function') {
showNotification('DMR Error', detail);
}
} else if (msg.text === 'stopped') {
isDmrRunning = false;
updateDmrUI();
dmrEventType = 'stopped';
dmrActivityTarget = 0;
updateDmrSynthStatus();
if (statusEl) statusEl.textContent = 'STOPPED';
if (typeof releaseDevice === 'function') releaseDevice('dmr');
}
}
}
// ============== UI ==============
function updateDmrUI() {
const startBtn = document.getElementById('startDmrBtn');
const stopBtn = document.getElementById('stopDmrBtn');
if (startBtn) startBtn.style.display = isDmrRunning ? 'none' : 'block';
if (stopBtn) stopBtn.style.display = isDmrRunning ? 'block' : 'none';
}
function renderDmrHistory() {
const container = document.getElementById('dmrHistoryBody');
if (!container) return;
const historyCountEl = document.getElementById('dmrHistoryCount');
if (historyCountEl) historyCountEl.textContent = `${dmrCallHistory.length} calls`;
if (dmrCallHistory.length === 0) {
container.innerHTML = '<tr><td colspan="4" style="padding: 10px; text-align: center; color: var(--text-muted);">No calls recorded</td></tr>';
return;
}
container.innerHTML = dmrCallHistory.slice(0, 20).map(call => `
<tr>
<td style="padding: 3px 6px; font-family: var(--font-mono);">${call.time}</td>
<td style="padding: 3px 6px; color: var(--accent-green);">${call.talkgroup}</td>
<td style="padding: 3px 6px; color: var(--accent-cyan);">${call.source_id}</td>
<td style="padding: 3px 6px;">${call.protocol}</td>
</tr>
`).join('');
}
// ============== SYNTHESIZER ==============
function initDmrSynthesizer() {
dmrSynthCanvas = document.getElementById('dmrSynthCanvas');
if (!dmrSynthCanvas) return;
// Use the canvas element's own rendered size for the backing buffer
const rect = dmrSynthCanvas.getBoundingClientRect();
const w = Math.round(rect.width) || 600;
const h = Math.round(rect.height) || 70;
dmrSynthCanvas.width = w;
dmrSynthCanvas.height = h;
dmrSynthCtx = dmrSynthCanvas.getContext('2d');
dmrSynthBars = [];
for (let i = 0; i < DMR_BAR_COUNT; i++) {
dmrSynthBars[i] = { height: 2, targetHeight: 2, velocity: 0 };
}
dmrActivityLevel = 0;
dmrActivityTarget = 0;
dmrEventType = isDmrRunning ? 'idle' : 'stopped';
dmrSynthInitialized = true;
updateDmrSynthStatus();
if (dmrSynthAnimationId) cancelAnimationFrame(dmrSynthAnimationId);
drawDmrSynthesizer();
}
function drawDmrSynthesizer() {
if (!dmrSynthCtx || !dmrSynthCanvas) return;
const width = dmrSynthCanvas.width;
const height = dmrSynthCanvas.height;
const barWidth = (width / DMR_BAR_COUNT) - 2;
const now = Date.now();
// Clear canvas
dmrSynthCtx.fillStyle = 'rgba(0, 0, 0, 0.3)';
dmrSynthCtx.fillRect(0, 0, width, height);
// Decay activity toward target
const timeSinceEvent = now - dmrLastEventTime;
if (timeSinceEvent > 2000) {
// No events for 2s — decay target toward idle
dmrActivityTarget = Math.max(0, dmrActivityTarget - DMR_DECAY_RATE);
if (dmrActivityTarget < 0.1 && dmrEventType !== 'stopped') {
dmrEventType = 'idle';
updateDmrSynthStatus();
}
}
// Smooth approach to target
dmrActivityLevel += (dmrActivityTarget - dmrActivityLevel) * 0.08;
// Determine effective activity (idle breathing when stopped/idle)
let effectiveActivity = dmrActivityLevel;
if (dmrEventType === 'stopped') {
effectiveActivity = 0;
} else if (effectiveActivity < 0.1 && isDmrRunning) {
// Visible idle breathing — shows decoder is alive and listening
effectiveActivity = 0.12 + Math.sin(now / 1000) * 0.06;
}
// Ripple timing for sync events
const syncRippleAge = (dmrEventType === 'sync' && timeSinceEvent < 500) ? 1 - (timeSinceEvent / 500) : 0;
// Voice ripple overlay
const voiceRipple = (dmrEventType === 'voice') ? Math.sin(now / 60) * 0.15 : 0;
// Update bar targets and physics
for (let i = 0; i < DMR_BAR_COUNT; i++) {
const time = now / 200;
const wave1 = Math.sin(time + i * 0.3) * 0.2;
const wave2 = Math.sin(time * 1.7 + i * 0.5) * 0.15;
const randomAmount = 0.05 + effectiveActivity * 0.25;
const random = (Math.random() - 0.5) * randomAmount;
// Bell curve — center bars taller
const centerDist = Math.abs(i - DMR_BAR_COUNT / 2) / (DMR_BAR_COUNT / 2);
const centerBoost = 1 - centerDist * 0.5;
// Sync ripple: center-outward wave burst
let rippleBoost = 0;
if (syncRippleAge > 0) {
const ripplePos = (1 - syncRippleAge) * DMR_BAR_COUNT / 2;
const distFromRipple = Math.abs(i - DMR_BAR_COUNT / 2) - ripplePos;
rippleBoost = Math.max(0, 1 - Math.abs(distFromRipple) / 4) * syncRippleAge * 0.4;
}
const baseHeight = 0.1 + effectiveActivity * 0.55;
dmrSynthBars[i].targetHeight = Math.max(2,
(baseHeight + wave1 + wave2 + random + rippleBoost + voiceRipple) *
effectiveActivity * centerBoost * height
);
// Spring physics
const springStrength = effectiveActivity > 0.3 ? 0.15 : 0.1;
const diff = dmrSynthBars[i].targetHeight - dmrSynthBars[i].height;
dmrSynthBars[i].velocity += diff * springStrength;
dmrSynthBars[i].velocity *= 0.78;
dmrSynthBars[i].height += dmrSynthBars[i].velocity;
dmrSynthBars[i].height = Math.max(2, Math.min(height - 4, dmrSynthBars[i].height));
}
// Draw bars
for (let i = 0; i < DMR_BAR_COUNT; i++) {
const x = i * (barWidth + 2) + 1;
const barHeight = dmrSynthBars[i].height;
const y = (height - barHeight) / 2;
// HSL color by event type
let hue, saturation, lightness;
if (dmrEventType === 'voice' && timeSinceEvent < 3000) {
hue = 30; // Orange
saturation = 85;
lightness = 40 + (barHeight / height) * 25;
} else if (dmrEventType === 'call' && timeSinceEvent < 3000) {
hue = 120; // Green
saturation = 80;
lightness = 35 + (barHeight / height) * 30;
} else if (dmrEventType === 'sync' && timeSinceEvent < 2000) {
hue = 185; // Cyan
saturation = 85;
lightness = 38 + (barHeight / height) * 25;
} else if (dmrEventType === 'stopped') {
hue = 220;
saturation = 20;
lightness = 18 + (barHeight / height) * 8;
} else {
// Idle / decayed
hue = 210;
saturation = 40;
lightness = 25 + (barHeight / height) * 15;
}
// Vertical gradient per bar
const gradient = dmrSynthCtx.createLinearGradient(x, y, x, y + barHeight);
gradient.addColorStop(0, `hsla(${hue}, ${saturation}%, ${lightness + 18}%, 0.85)`);
gradient.addColorStop(0.5, `hsla(${hue}, ${saturation}%, ${lightness}%, 1)`);
gradient.addColorStop(1, `hsla(${hue}, ${saturation}%, ${lightness + 18}%, 0.85)`);
dmrSynthCtx.fillStyle = gradient;
dmrSynthCtx.fillRect(x, y, barWidth, barHeight);
// Glow on tall bars
if (barHeight > height * 0.5 && effectiveActivity > 0.4) {
dmrSynthCtx.shadowColor = `hsla(${hue}, ${saturation}%, 60%, 0.5)`;
dmrSynthCtx.shadowBlur = 8;
dmrSynthCtx.fillRect(x, y, barWidth, barHeight);
dmrSynthCtx.shadowBlur = 0;
}
}
// Center line
dmrSynthCtx.strokeStyle = 'rgba(0, 212, 255, 0.15)';
dmrSynthCtx.lineWidth = 1;
dmrSynthCtx.beginPath();
dmrSynthCtx.moveTo(0, height / 2);
dmrSynthCtx.lineTo(width, height / 2);
dmrSynthCtx.stroke();
dmrSynthAnimationId = requestAnimationFrame(drawDmrSynthesizer);
}
function dmrSynthPulse(type) {
dmrLastEventTime = Date.now();
if (type === 'sync') {
dmrActivityTarget = Math.max(dmrActivityTarget, DMR_BURST_SYNC);
dmrEventType = 'sync';
} else if (type === 'call') {
dmrActivityTarget = DMR_BURST_CALL;
dmrEventType = 'call';
} else if (type === 'voice') {
dmrActivityTarget = DMR_BURST_VOICE;
dmrEventType = 'voice';
} else if (type === 'slot' || type === 'nac') {
dmrActivityTarget = Math.max(dmrActivityTarget, 0.5);
} else if (type === 'raw') {
// Any DSD output means the decoder is alive and processing
dmrActivityTarget = Math.max(dmrActivityTarget, 0.25);
if (dmrEventType === 'idle') dmrEventType = 'raw';
}
// keepalive and status don't change visuals
updateDmrSynthStatus();
}
function updateDmrSynthStatus() {
const el = document.getElementById('dmrSynthStatus');
if (!el) return;
const labels = {
stopped: 'STOPPED',
idle: 'IDLE',
raw: 'LISTENING',
sync: 'SYNC',
call: 'CALL',
voice: 'VOICE'
};
const colors = {
stopped: 'var(--text-muted)',
idle: 'var(--text-muted)',
raw: '#607d8b',
sync: '#00e5ff',
call: '#4caf50',
voice: '#ff9800'
};
el.textContent = labels[dmrEventType] || 'IDLE';
el.style.color = colors[dmrEventType] || 'var(--text-muted)';
}
function resizeDmrSynthesizer() {
if (!dmrSynthCanvas) return;
const rect = dmrSynthCanvas.getBoundingClientRect();
if (rect.width > 0) {
dmrSynthCanvas.width = Math.round(rect.width);
dmrSynthCanvas.height = Math.round(rect.height) || 70;
}
}
function stopDmrSynthesizer() {
if (dmrSynthAnimationId) {
cancelAnimationFrame(dmrSynthAnimationId);
dmrSynthAnimationId = null;
}
}
window.addEventListener('resize', resizeDmrSynthesizer);
// ============== EXPORTS ==============
window.startDmr = startDmr;
window.stopDmr = stopDmr;
window.checkDmrTools = checkDmrTools;
window.initDmrSynthesizer = initDmrSynthesizer;
File diff suppressed because it is too large Load Diff
+11 -8
View File
@@ -97,7 +97,7 @@ const Meshtastic = (function() {
/**
* Initialize the Leaflet map
*/
function initMap() {
async function initMap() {
if (meshMap) return;
const mapContainer = document.getElementById('meshMap');
@@ -111,16 +111,19 @@ const Meshtastic = (function() {
window.meshMap = meshMap;
// Use settings manager for tile layer (allows runtime changes)
if (typeof Settings !== 'undefined' && Settings.createTileLayer) {
if (typeof Settings !== 'undefined') {
// Wait for settings to load from server before applying tiles
await Settings.init();
Settings.createTileLayer().addTo(meshMap);
Settings.registerMap(meshMap);
} else {
L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
maxZoom: 19,
subdomains: 'abcd'
}).addTo(meshMap);
}
L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
maxZoom: 19,
subdomains: 'abcd',
className: 'tile-layer-cyan'
}).addTo(meshMap);
}
// Handle resize
setTimeout(() => {
+1 -1
View File
@@ -84,7 +84,7 @@ const SpyStations = (function() {
modeContainer.innerHTML = modes.map(m => `
<label class="inline-checkbox">
<input type="checkbox" data-mode="${m}" checked onchange="SpyStations.applyFilters()">
<span style="font-family: 'JetBrains Mono', monospace; font-size: 10px;">${m}</span>
<span style="font-family: 'Space Mono', monospace; font-size: 10px;">${m}</span>
</label>
`).join('');
}
+756
View File
@@ -0,0 +1,756 @@
/**
* SSTV General Mode
* Terrestrial Slow-Scan Television decoder interface
*/
const SSTVGeneral = (function() {
// State
let isRunning = false;
let eventSource = null;
let images = [];
let currentMode = null;
let progress = 0;
// Signal scope state
let sstvGeneralScopeCtx = null;
let sstvGeneralScopeAnim = null;
let sstvGeneralScopeHistory = [];
const SSTV_GENERAL_SCOPE_LEN = 200;
let sstvGeneralScopeRms = 0;
let sstvGeneralScopePeak = 0;
let sstvGeneralScopeTargetRms = 0;
let sstvGeneralScopeTargetPeak = 0;
let sstvGeneralScopeMsgBurst = 0;
let sstvGeneralScopeTone = null;
/**
* Initialize the SSTV General mode
*/
function init() {
checkStatus();
loadImages();
}
/**
* Select a preset frequency from the dropdown
*/
function selectPreset(value) {
if (!value) return;
const parts = value.split('|');
const freq = parseFloat(parts[0]);
const mod = parts[1];
const freqInput = document.getElementById('sstvGeneralFrequency');
const modSelect = document.getElementById('sstvGeneralModulation');
if (freqInput) freqInput.value = freq;
if (modSelect) modSelect.value = mod;
// Update strip display
const stripFreq = document.getElementById('sstvGeneralStripFreq');
const stripMod = document.getElementById('sstvGeneralStripMod');
if (stripFreq) stripFreq.textContent = freq.toFixed(3);
if (stripMod) stripMod.textContent = mod.toUpperCase();
}
/**
* Check current decoder status
*/
async function checkStatus() {
try {
const response = await fetch('/sstv-general/status');
const data = await response.json();
if (!data.available) {
updateStatusUI('unavailable', 'Decoder not installed');
showStatusMessage('SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow', 'warning');
return;
}
if (data.running) {
isRunning = true;
updateStatusUI('listening', 'Listening...');
startStream();
} else {
updateStatusUI('idle', 'Idle');
}
updateImageCount(data.image_count || 0);
} catch (err) {
console.error('Failed to check SSTV General status:', err);
}
}
/**
* Start SSTV decoder
*/
async function start() {
const freqInput = document.getElementById('sstvGeneralFrequency');
const modSelect = document.getElementById('sstvGeneralModulation');
const deviceSelect = document.getElementById('deviceSelect');
const frequency = parseFloat(freqInput?.value || '14.230');
const modulation = modSelect?.value || 'usb';
const device = parseInt(deviceSelect?.value || '0', 10);
updateStatusUI('connecting', 'Starting...');
try {
const response = await fetch('/sstv-general/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ frequency, modulation, device })
});
const data = await response.json();
if (data.status === 'started' || data.status === 'already_running') {
isRunning = true;
updateStatusUI('listening', `${frequency} MHz ${modulation.toUpperCase()}`);
startStream();
showNotification('SSTV', `Listening on ${frequency} MHz ${modulation.toUpperCase()}`);
// Update strip
const stripFreq = document.getElementById('sstvGeneralStripFreq');
const stripMod = document.getElementById('sstvGeneralStripMod');
if (stripFreq) stripFreq.textContent = frequency.toFixed(3);
if (stripMod) stripMod.textContent = modulation.toUpperCase();
} else {
updateStatusUI('idle', 'Start failed');
showStatusMessage(data.message || 'Failed to start decoder', 'error');
}
} catch (err) {
console.error('Failed to start SSTV General:', err);
updateStatusUI('idle', 'Error');
showStatusMessage('Connection error: ' + err.message, 'error');
}
}
/**
* Stop SSTV decoder
*/
async function stop() {
try {
await fetch('/sstv-general/stop', { method: 'POST' });
isRunning = false;
stopStream();
updateStatusUI('idle', 'Stopped');
showNotification('SSTV', 'Decoder stopped');
} catch (err) {
console.error('Failed to stop SSTV General:', err);
}
}
/**
* Update status UI elements
*/
function updateStatusUI(status, text) {
const dot = document.getElementById('sstvGeneralStripDot');
const statusText = document.getElementById('sstvGeneralStripStatus');
const startBtn = document.getElementById('sstvGeneralStartBtn');
const stopBtn = document.getElementById('sstvGeneralStopBtn');
if (dot) {
dot.className = 'sstv-general-strip-dot';
if (status === 'listening' || status === 'detecting') {
dot.classList.add('listening');
} else if (status === 'decoding') {
dot.classList.add('decoding');
} else {
dot.classList.add('idle');
}
}
if (statusText) {
statusText.textContent = text || status;
}
if (startBtn && stopBtn) {
if (status === 'listening' || status === 'decoding') {
startBtn.style.display = 'none';
stopBtn.style.display = 'inline-block';
} else {
startBtn.style.display = 'inline-block';
stopBtn.style.display = 'none';
}
}
// Update live content area
const liveContent = document.getElementById('sstvGeneralLiveContent');
if (liveContent) {
if (status === 'idle' || status === 'unavailable') {
liveContent.innerHTML = renderIdleState();
}
}
}
/**
* Render idle state HTML
*/
function renderIdleState() {
return `
<div class="sstv-general-idle-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="12" cy="12" r="3"/>
<path d="M3 9h2M19 9h2M3 15h2M19 15h2"/>
</svg>
<h4>SSTV Decoder</h4>
<p>Select a frequency and click Start to listen for SSTV transmissions</p>
</div>
`;
}
/**
* Initialize signal scope canvas
*/
function initSstvGeneralScope() {
const canvas = document.getElementById('sstvGeneralScopeCanvas');
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * (window.devicePixelRatio || 1);
canvas.height = rect.height * (window.devicePixelRatio || 1);
sstvGeneralScopeCtx = canvas.getContext('2d');
sstvGeneralScopeHistory = new Array(SSTV_GENERAL_SCOPE_LEN).fill(0);
sstvGeneralScopeRms = 0;
sstvGeneralScopePeak = 0;
sstvGeneralScopeTargetRms = 0;
sstvGeneralScopeTargetPeak = 0;
sstvGeneralScopeMsgBurst = 0;
sstvGeneralScopeTone = null;
drawSstvGeneralScope();
}
/**
* Draw signal scope animation frame
*/
function drawSstvGeneralScope() {
const ctx = sstvGeneralScopeCtx;
if (!ctx) return;
const W = ctx.canvas.width;
const H = ctx.canvas.height;
const midY = H / 2;
// Phosphor persistence
ctx.fillStyle = 'rgba(5, 5, 16, 0.3)';
ctx.fillRect(0, 0, W, H);
// Smooth towards target
sstvGeneralScopeRms += (sstvGeneralScopeTargetRms - sstvGeneralScopeRms) * 0.25;
sstvGeneralScopePeak += (sstvGeneralScopeTargetPeak - sstvGeneralScopePeak) * 0.15;
// Push to history
sstvGeneralScopeHistory.push(Math.min(sstvGeneralScopeRms / 32768, 1.0));
if (sstvGeneralScopeHistory.length > SSTV_GENERAL_SCOPE_LEN) sstvGeneralScopeHistory.shift();
// Grid lines
ctx.strokeStyle = 'rgba(60, 40, 80, 0.4)';
ctx.lineWidth = 0.5;
for (let i = 1; i < 4; i++) {
const y = (H / 4) * i;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
}
for (let i = 1; i < 8; i++) {
const x = (W / 8) * i;
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
}
// Waveform
const stepX = W / (SSTV_GENERAL_SCOPE_LEN - 1);
ctx.strokeStyle = '#c080ff';
ctx.lineWidth = 1.5;
ctx.shadowColor = '#c080ff';
ctx.shadowBlur = 4;
// Upper half
ctx.beginPath();
for (let i = 0; i < sstvGeneralScopeHistory.length; i++) {
const x = i * stepX;
const amp = sstvGeneralScopeHistory[i] * midY * 0.9;
const y = midY - amp;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
// Lower half (mirror)
ctx.beginPath();
for (let i = 0; i < sstvGeneralScopeHistory.length; i++) {
const x = i * stepX;
const amp = sstvGeneralScopeHistory[i] * midY * 0.9;
const y = midY + amp;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
ctx.shadowBlur = 0;
// Peak indicator
const peakNorm = Math.min(sstvGeneralScopePeak / 32768, 1.0);
if (peakNorm > 0.01) {
const peakY = midY - peakNorm * midY * 0.9;
ctx.strokeStyle = 'rgba(255, 68, 68, 0.6)';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(0, peakY); ctx.lineTo(W, peakY); ctx.stroke();
ctx.setLineDash([]);
}
// Image decode flash
if (sstvGeneralScopeMsgBurst > 0.01) {
ctx.fillStyle = `rgba(0, 255, 100, ${sstvGeneralScopeMsgBurst * 0.15})`;
ctx.fillRect(0, 0, W, H);
sstvGeneralScopeMsgBurst *= 0.88;
}
// Update labels
const rmsLabel = document.getElementById('sstvGeneralScopeRmsLabel');
const peakLabel = document.getElementById('sstvGeneralScopePeakLabel');
const toneLabel = document.getElementById('sstvGeneralScopeToneLabel');
const statusLabel = document.getElementById('sstvGeneralScopeStatusLabel');
if (rmsLabel) rmsLabel.textContent = Math.round(sstvGeneralScopeRms);
if (peakLabel) peakLabel.textContent = Math.round(sstvGeneralScopePeak);
if (toneLabel) {
if (sstvGeneralScopeTone === 'leader') { toneLabel.textContent = 'LEADER'; toneLabel.style.color = '#0f0'; }
else if (sstvGeneralScopeTone === 'sync') { toneLabel.textContent = 'SYNC'; toneLabel.style.color = '#0ff'; }
else if (sstvGeneralScopeTone === 'decoding') { toneLabel.textContent = 'DECODING'; toneLabel.style.color = '#fa0'; }
else if (sstvGeneralScopeTone === 'noise') { toneLabel.textContent = 'NOISE'; toneLabel.style.color = '#555'; }
else { toneLabel.textContent = 'QUIET'; toneLabel.style.color = '#444'; }
}
if (statusLabel) {
if (sstvGeneralScopeRms > 500) { statusLabel.textContent = 'SIGNAL'; statusLabel.style.color = '#0f0'; }
else { statusLabel.textContent = 'MONITORING'; statusLabel.style.color = '#555'; }
}
sstvGeneralScopeAnim = requestAnimationFrame(drawSstvGeneralScope);
}
/**
* Stop signal scope
*/
function stopSstvGeneralScope() {
if (sstvGeneralScopeAnim) { cancelAnimationFrame(sstvGeneralScopeAnim); sstvGeneralScopeAnim = null; }
sstvGeneralScopeCtx = null;
}
/**
* Start SSE stream
*/
function startStream() {
if (eventSource) {
eventSource.close();
}
// Show and init scope
const scopePanel = document.getElementById('sstvGeneralScopePanel');
if (scopePanel) scopePanel.style.display = 'block';
initSstvGeneralScope();
eventSource = new EventSource('/sstv-general/stream');
eventSource.onmessage = (e) => {
try {
const data = JSON.parse(e.data);
if (data.type === 'sstv_progress') {
handleProgress(data);
} else if (data.type === 'sstv_scope') {
sstvGeneralScopeTargetRms = data.rms;
sstvGeneralScopeTargetPeak = data.peak;
if (data.tone !== undefined) sstvGeneralScopeTone = data.tone;
}
} catch (err) {
console.error('Failed to parse SSE message:', err);
}
};
eventSource.onerror = () => {
console.warn('SSTV General SSE error, will reconnect...');
setTimeout(() => {
if (isRunning) startStream();
}, 3000);
};
}
/**
* Stop SSE stream
*/
function stopStream() {
if (eventSource) {
eventSource.close();
eventSource = null;
}
stopSstvGeneralScope();
const scopePanel = document.getElementById('sstvGeneralScopePanel');
if (scopePanel) scopePanel.style.display = 'none';
}
/**
* Handle progress update
*/
function handleProgress(data) {
currentMode = data.mode || currentMode;
progress = data.progress || 0;
if (data.status === 'decoding') {
updateStatusUI('decoding', `Decoding ${currentMode || 'image'}...`);
renderDecodeProgress(data);
} else if (data.status === 'complete' && data.image) {
images.unshift(data.image);
updateImageCount(images.length);
renderGallery();
showNotification('SSTV', 'New image decoded!');
updateStatusUI('listening', 'Listening...');
sstvGeneralScopeMsgBurst = 1.0;
// Clear decode progress so signal monitor can take over
const liveContent = document.getElementById('sstvGeneralLiveContent');
if (liveContent) liveContent.innerHTML = '';
} else if (data.status === 'detecting') {
// Ignore detecting events if currently decoding (e.g. Doppler updates)
const dot = document.getElementById('sstvGeneralStripDot');
if (dot && dot.classList.contains('decoding')) return;
updateStatusUI('listening', data.message || 'Listening...');
if (data.signal_level !== undefined) {
renderSignalMonitor(data);
}
}
}
/**
* Render signal monitor in live area during detecting mode
*/
function renderSignalMonitor(data) {
const container = document.getElementById('sstvGeneralLiveContent');
if (!container) return;
const level = data.signal_level || 0;
const tone = data.sstv_tone;
let barColor, statusText;
if (tone === 'leader') {
barColor = 'var(--accent-green)';
statusText = 'SSTV leader tone detected';
} else if (tone === 'sync') {
barColor = 'var(--accent-cyan)';
statusText = 'SSTV sync pulse detected';
} else if (tone === 'noise') {
barColor = 'var(--text-dim)';
statusText = 'Audio signal present';
} else if (level > 10) {
barColor = 'var(--text-dim)';
statusText = 'Audio signal present';
} else {
barColor = 'var(--text-dim)';
statusText = 'No signal';
}
let monitor = container.querySelector('.sstv-general-signal-monitor');
if (!monitor) {
container.innerHTML = `
<div class="sstv-general-signal-monitor">
<div class="sstv-general-signal-monitor-header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M2 12L5 12M5 12C5 12 6 3 12 3C18 3 19 12 19 12M19 12L22 12"/>
<circle cx="12" cy="18" r="2"/>
<path d="M12 16V12"/>
</svg>
Signal Monitor
</div>
<div class="sstv-general-signal-level-row">
<span class="sstv-general-signal-level-label">LEVEL</span>
<div class="sstv-general-signal-bar-track">
<div class="sstv-general-signal-bar-fill" style="width: 0%"></div>
</div>
<span class="sstv-general-signal-level-value">0</span>
</div>
<div class="sstv-general-signal-status-text">No signal</div>
<div class="sstv-general-signal-vis-state">VIS: idle</div>
</div>`;
monitor = container.querySelector('.sstv-general-signal-monitor');
}
const fill = monitor.querySelector('.sstv-general-signal-bar-fill');
fill.style.width = level + '%';
fill.style.background = barColor;
monitor.querySelector('.sstv-general-signal-status-text').textContent = statusText;
monitor.querySelector('.sstv-general-signal-level-value').textContent = level;
const visStateEl = monitor.querySelector('.sstv-general-signal-vis-state');
if (visStateEl && data.vis_state) {
const stateLabels = {
'idle': 'Idle',
'leader_1': 'Leader',
'break': 'Break',
'leader_2': 'Leader 2',
'start_bit': 'Start bit',
'data_bits': 'Data bits',
'parity': 'Parity',
'stop_bit': 'Stop bit',
};
const label = stateLabels[data.vis_state] || data.vis_state;
visStateEl.textContent = 'VIS: ' + label;
visStateEl.className = 'sstv-general-signal-vis-state' +
(data.vis_state !== 'idle' ? ' active' : '');
}
}
/**
* Render decode progress in live area
*/
function renderDecodeProgress(data) {
const liveContent = document.getElementById('sstvGeneralLiveContent');
if (!liveContent) return;
let container = liveContent.querySelector('.sstv-general-decode-container');
if (!container) {
liveContent.innerHTML = `
<div class="sstv-general-decode-container">
<div class="sstv-general-canvas-container">
<img id="sstvGeneralDecodeImg" width="320" height="256" alt="Decoding..." style="display:block;background:#000;">
</div>
<div class="sstv-general-decode-info">
<div class="sstv-general-mode-label"></div>
<div class="sstv-general-progress-bar">
<div class="progress" style="width: 0%"></div>
</div>
<div class="sstv-general-status-message"></div>
</div>
</div>
`;
container = liveContent.querySelector('.sstv-general-decode-container');
}
container.querySelector('.sstv-general-mode-label').textContent = data.mode || 'Detecting mode...';
container.querySelector('.progress').style.width = (data.progress || 0) + '%';
container.querySelector('.sstv-general-status-message').textContent = data.message || 'Decoding...';
if (data.partial_image) {
const img = container.querySelector('#sstvGeneralDecodeImg');
if (img) img.src = data.partial_image;
}
}
/**
* Load decoded images
*/
async function loadImages() {
try {
const response = await fetch('/sstv-general/images');
const data = await response.json();
if (data.status === 'ok') {
images = data.images || [];
updateImageCount(images.length);
renderGallery();
}
} catch (err) {
console.error('Failed to load SSTV General images:', err);
}
}
/**
* Update image count display
*/
function updateImageCount(count) {
const countEl = document.getElementById('sstvGeneralImageCount');
const stripCount = document.getElementById('sstvGeneralStripImageCount');
if (countEl) countEl.textContent = count;
if (stripCount) stripCount.textContent = count;
}
/**
* Render image gallery
*/
function renderGallery() {
const gallery = document.getElementById('sstvGeneralGallery');
if (!gallery) return;
if (images.length === 0) {
gallery.innerHTML = `
<div class="sstv-general-gallery-empty">
<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="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
<p>No images decoded yet</p>
</div>
`;
return;
}
gallery.innerHTML = images.map(img => `
<div class="sstv-general-image-card">
<div class="sstv-general-image-card-inner" onclick="SSTVGeneral.showImage('${escapeHtml(img.url)}', '${escapeHtml(img.filename)}')">
<img src="${escapeHtml(img.url)}" alt="SSTV Image" class="sstv-general-image-preview" loading="lazy">
</div>
<div class="sstv-general-image-info">
<div class="sstv-general-image-mode">${escapeHtml(img.mode || 'Unknown')}</div>
<div class="sstv-general-image-timestamp">${formatTimestamp(img.timestamp)}</div>
</div>
<div class="sstv-general-image-actions">
<button onclick="event.stopPropagation(); SSTVGeneral.downloadImage('${escapeHtml(img.url)}', '${escapeHtml(img.filename)}')" title="Download">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</button>
<button onclick="event.stopPropagation(); SSTVGeneral.deleteImage('${escapeHtml(img.filename)}')" title="Delete">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
</button>
</div>
</div>
`).join('');
}
/**
* Show full-size image in modal
*/
let currentModalUrl = null;
let currentModalFilename = null;
function showImage(url, filename) {
currentModalUrl = url;
currentModalFilename = filename || null;
let modal = document.getElementById('sstvGeneralImageModal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'sstvGeneralImageModal';
modal.className = 'sstv-general-image-modal';
modal.innerHTML = `
<div class="sstv-general-modal-toolbar">
<button class="sstv-general-modal-btn" id="sstvGeneralModalDownload" title="Download">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Download
</button>
<button class="sstv-general-modal-btn delete" id="sstvGeneralModalDelete" title="Delete">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
Delete
</button>
</div>
<button class="sstv-general-modal-close" onclick="SSTVGeneral.closeImage()">&times;</button>
<img src="" alt="SSTV Image">
`;
modal.addEventListener('click', (e) => {
if (e.target === modal) closeImage();
});
modal.querySelector('#sstvGeneralModalDownload').addEventListener('click', () => {
if (currentModalUrl && currentModalFilename) {
downloadImage(currentModalUrl, currentModalFilename);
}
});
modal.querySelector('#sstvGeneralModalDelete').addEventListener('click', () => {
if (currentModalFilename) {
deleteImage(currentModalFilename);
}
});
document.body.appendChild(modal);
}
modal.querySelector('img').src = url;
modal.classList.add('show');
}
/**
* Close image modal
*/
function closeImage() {
const modal = document.getElementById('sstvGeneralImageModal');
if (modal) modal.classList.remove('show');
}
/**
* Format timestamp for display
*/
function formatTimestamp(isoString) {
if (!isoString) return '--';
try {
const date = new Date(isoString);
return date.toLocaleString();
} catch {
return isoString;
}
}
/**
* Escape HTML for safe display
*/
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Delete a single image
*/
async function deleteImage(filename) {
if (!confirm('Delete this image?')) return;
try {
const response = await fetch(`/sstv-general/images/${encodeURIComponent(filename)}`, { method: 'DELETE' });
const data = await response.json();
if (data.status === 'ok') {
images = images.filter(img => img.filename !== filename);
updateImageCount(images.length);
renderGallery();
closeImage();
showNotification('SSTV', 'Image deleted');
}
} catch (err) {
console.error('Failed to delete image:', err);
}
}
/**
* Delete all images
*/
async function deleteAllImages() {
if (!confirm('Delete all decoded images?')) return;
try {
const response = await fetch('/sstv-general/images', { method: 'DELETE' });
const data = await response.json();
if (data.status === 'ok') {
images = [];
updateImageCount(0);
renderGallery();
showNotification('SSTV', `${data.deleted} image${data.deleted !== 1 ? 's' : ''} deleted`);
}
} catch (err) {
console.error('Failed to delete images:', err);
}
}
/**
* Download an image
*/
function downloadImage(url, filename) {
const a = document.createElement('a');
a.href = url + '/download';
a.download = filename;
a.click();
}
/**
* Show status message
*/
function showStatusMessage(message, type) {
if (typeof showNotification === 'function') {
showNotification('SSTV', message);
} else {
console.log(`[SSTV General ${type}] ${message}`);
}
}
// Public API
return {
init,
start,
stop,
loadImages,
showImage,
closeImage,
deleteImage,
deleteAllImages,
downloadImage,
selectPreset
};
})();
+413 -51
View File
@@ -21,6 +21,18 @@ const SSTV = (function() {
// ISS frequency
const ISS_FREQ = 145.800;
// Signal scope state
let sstvScopeCtx = null;
let sstvScopeAnim = null;
let sstvScopeHistory = [];
const SSTV_SCOPE_LEN = 200;
let sstvScopeRms = 0;
let sstvScopePeak = 0;
let sstvScopeTargetRms = 0;
let sstvScopeTargetPeak = 0;
let sstvScopeMsgBurst = 0;
let sstvScopeTone = null;
/**
* Initialize the SSTV mode
*/
@@ -37,20 +49,20 @@ const SSTV = (function() {
/**
* Load location into input fields
*/
function loadLocationInputs() {
const latInput = document.getElementById('sstvObsLat');
const lonInput = document.getElementById('sstvObsLon');
let storedLat = localStorage.getItem('observerLat');
let storedLon = localStorage.getItem('observerLon');
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
const shared = ObserverLocation.getShared();
storedLat = shared.lat.toString();
storedLon = shared.lon.toString();
}
if (latInput && storedLat) latInput.value = storedLat;
if (lonInput && storedLon) lonInput.value = storedLon;
function loadLocationInputs() {
const latInput = document.getElementById('sstvObsLat');
const lonInput = document.getElementById('sstvObsLon');
let storedLat = localStorage.getItem('observerLat');
let storedLon = localStorage.getItem('observerLon');
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
const shared = ObserverLocation.getShared();
storedLat = shared.lat.toString();
storedLon = shared.lon.toString();
}
if (latInput && storedLat) latInput.value = storedLat;
if (lonInput && storedLon) lonInput.value = storedLon;
// Add change handlers to save and refresh
if (latInput) latInput.addEventListener('change', saveLocationFromInputs);
@@ -60,23 +72,23 @@ const SSTV = (function() {
/**
* Save location from input fields
*/
function saveLocationFromInputs() {
const latInput = document.getElementById('sstvObsLat');
const lonInput = document.getElementById('sstvObsLon');
function saveLocationFromInputs() {
const latInput = document.getElementById('sstvObsLat');
const lonInput = document.getElementById('sstvObsLon');
const lat = parseFloat(latInput?.value);
const lon = parseFloat(lonInput?.value);
if (!isNaN(lat) && lat >= -90 && lat <= 90 &&
!isNaN(lon) && lon >= -180 && lon <= 180) {
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
ObserverLocation.setShared({ lat, lon });
} else {
localStorage.setItem('observerLat', lat.toString());
localStorage.setItem('observerLon', lon.toString());
}
loadIssSchedule(); // Refresh pass predictions
}
if (!isNaN(lat) && lat >= -90 && lat <= 90 &&
!isNaN(lon) && lon >= -180 && lon <= 180) {
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
ObserverLocation.setShared({ lat, lon });
} else {
localStorage.setItem('observerLat', lat.toString());
localStorage.setItem('observerLon', lon.toString());
}
loadIssSchedule(); // Refresh pass predictions
}
}
/**
@@ -103,12 +115,12 @@ const SSTV = (function() {
if (latInput) latInput.value = lat;
if (lonInput) lonInput.value = lon;
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
ObserverLocation.setShared({ lat: parseFloat(lat), lon: parseFloat(lon) });
} else {
localStorage.setItem('observerLat', lat);
localStorage.setItem('observerLon', lon);
}
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
ObserverLocation.setShared({ lat: parseFloat(lat), lon: parseFloat(lon) });
} else {
localStorage.setItem('observerLat', lat);
localStorage.setItem('observerLon', lon);
}
btn.innerHTML = originalText;
btn.disabled = false;
@@ -159,7 +171,7 @@ const SSTV = (function() {
/**
* Initialize Leaflet map for ISS tracking
*/
function initMap() {
async function initMap() {
const mapContainer = document.getElementById('sstvIssMap');
if (!mapContainer || issMap) return;
@@ -173,14 +185,19 @@ const SSTV = (function() {
attributionControl: false,
worldCopyJump: true
});
window.issMap = issMap;
// Add tile layer using settings manager if available
if (typeof Settings !== 'undefined' && Settings.createTileLayer) {
if (typeof Settings !== 'undefined') {
// Wait for settings to load from server before applying tiles
await Settings.init();
Settings.createTileLayer().addTo(issMap);
Settings.registerMap(issMap);
} else {
// Fallback to dark theme tiles
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
maxZoom: 19
maxZoom: 19,
className: 'tile-layer-cyan'
}).addTo(issMap);
}
@@ -486,7 +503,7 @@ const SSTV = (function() {
if (!data.available) {
updateStatusUI('unavailable', 'Decoder not installed');
showStatusMessage('SSTV decoder not available. Install slowrx: apt install slowrx', 'warning');
showStatusMessage('SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow', 'warning');
return;
}
@@ -516,6 +533,11 @@ const SSTV = (function() {
const frequency = parseFloat(freqInput?.value || ISS_FREQ);
const device = parseInt(deviceSelect?.value || '0', 10);
// Check if device is available
if (typeof checkDeviceAvailability === 'function' && !checkDeviceAvailability('sstv')) {
return;
}
updateStatusUI('connecting', 'Starting...');
try {
@@ -529,6 +551,9 @@ const SSTV = (function() {
if (data.status === 'started' || data.status === 'already_running') {
isRunning = true;
if (typeof reserveDevice === 'function') {
reserveDevice(device, 'sstv');
}
updateStatusUI('listening', `${frequency} MHz`);
startStream();
showNotification('SSTV', `Listening on ${frequency} MHz`);
@@ -550,6 +575,9 @@ const SSTV = (function() {
try {
await fetch('/sstv/stop', { method: 'POST' });
isRunning = false;
if (typeof releaseDevice === 'function') {
releaseDevice('sstv');
}
stopStream();
updateStatusUI('idle', 'Stopped');
showNotification('SSTV', 'Decoder stopped');
@@ -618,6 +646,136 @@ const SSTV = (function() {
`;
}
/**
* Initialize signal scope canvas
*/
function initSstvScope() {
const canvas = document.getElementById('sstvScopeCanvas');
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * (window.devicePixelRatio || 1);
canvas.height = rect.height * (window.devicePixelRatio || 1);
sstvScopeCtx = canvas.getContext('2d');
sstvScopeHistory = new Array(SSTV_SCOPE_LEN).fill(0);
sstvScopeRms = 0;
sstvScopePeak = 0;
sstvScopeTargetRms = 0;
sstvScopeTargetPeak = 0;
sstvScopeMsgBurst = 0;
sstvScopeTone = null;
drawSstvScope();
}
/**
* Draw signal scope animation frame
*/
function drawSstvScope() {
const ctx = sstvScopeCtx;
if (!ctx) return;
const W = ctx.canvas.width;
const H = ctx.canvas.height;
const midY = H / 2;
// Phosphor persistence
ctx.fillStyle = 'rgba(5, 5, 16, 0.3)';
ctx.fillRect(0, 0, W, H);
// Smooth towards target
sstvScopeRms += (sstvScopeTargetRms - sstvScopeRms) * 0.25;
sstvScopePeak += (sstvScopeTargetPeak - sstvScopePeak) * 0.15;
// Push to history
sstvScopeHistory.push(Math.min(sstvScopeRms / 32768, 1.0));
if (sstvScopeHistory.length > SSTV_SCOPE_LEN) sstvScopeHistory.shift();
// Grid lines
ctx.strokeStyle = 'rgba(60, 40, 80, 0.4)';
ctx.lineWidth = 0.5;
for (let i = 1; i < 4; i++) {
const y = (H / 4) * i;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
}
for (let i = 1; i < 8; i++) {
const x = (W / 8) * i;
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
}
// Waveform
const stepX = W / (SSTV_SCOPE_LEN - 1);
ctx.strokeStyle = '#c080ff';
ctx.lineWidth = 1.5;
ctx.shadowColor = '#c080ff';
ctx.shadowBlur = 4;
// Upper half
ctx.beginPath();
for (let i = 0; i < sstvScopeHistory.length; i++) {
const x = i * stepX;
const amp = sstvScopeHistory[i] * midY * 0.9;
const y = midY - amp;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
// Lower half (mirror)
ctx.beginPath();
for (let i = 0; i < sstvScopeHistory.length; i++) {
const x = i * stepX;
const amp = sstvScopeHistory[i] * midY * 0.9;
const y = midY + amp;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
ctx.shadowBlur = 0;
// Peak indicator
const peakNorm = Math.min(sstvScopePeak / 32768, 1.0);
if (peakNorm > 0.01) {
const peakY = midY - peakNorm * midY * 0.9;
ctx.strokeStyle = 'rgba(255, 68, 68, 0.6)';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(0, peakY); ctx.lineTo(W, peakY); ctx.stroke();
ctx.setLineDash([]);
}
// Image decode flash
if (sstvScopeMsgBurst > 0.01) {
ctx.fillStyle = `rgba(0, 255, 100, ${sstvScopeMsgBurst * 0.15})`;
ctx.fillRect(0, 0, W, H);
sstvScopeMsgBurst *= 0.88;
}
// Update labels
const rmsLabel = document.getElementById('sstvScopeRmsLabel');
const peakLabel = document.getElementById('sstvScopePeakLabel');
const toneLabel = document.getElementById('sstvScopeToneLabel');
const statusLabel = document.getElementById('sstvScopeStatusLabel');
if (rmsLabel) rmsLabel.textContent = Math.round(sstvScopeRms);
if (peakLabel) peakLabel.textContent = Math.round(sstvScopePeak);
if (toneLabel) {
if (sstvScopeTone === 'leader') { toneLabel.textContent = 'LEADER'; toneLabel.style.color = '#0f0'; }
else if (sstvScopeTone === 'sync') { toneLabel.textContent = 'SYNC'; toneLabel.style.color = '#0ff'; }
else if (sstvScopeTone === 'decoding') { toneLabel.textContent = 'DECODING'; toneLabel.style.color = '#fa0'; }
else if (sstvScopeTone === 'noise') { toneLabel.textContent = 'NOISE'; toneLabel.style.color = '#555'; }
else { toneLabel.textContent = 'QUIET'; toneLabel.style.color = '#444'; }
}
if (statusLabel) {
if (sstvScopeRms > 500) { statusLabel.textContent = 'SIGNAL'; statusLabel.style.color = '#0f0'; }
else { statusLabel.textContent = 'MONITORING'; statusLabel.style.color = '#555'; }
}
sstvScopeAnim = requestAnimationFrame(drawSstvScope);
}
/**
* Stop signal scope
*/
function stopSstvScope() {
if (sstvScopeAnim) { cancelAnimationFrame(sstvScopeAnim); sstvScopeAnim = null; }
sstvScopeCtx = null;
}
/**
* Start SSE stream
*/
@@ -626,6 +784,11 @@ const SSTV = (function() {
eventSource.close();
}
// Show and init scope
const scopePanel = document.getElementById('sstvScopePanel');
if (scopePanel) scopePanel.style.display = 'block';
initSstvScope();
eventSource = new EventSource('/sstv/stream');
eventSource.onmessage = (e) => {
@@ -633,6 +796,10 @@ const SSTV = (function() {
const data = JSON.parse(e.data);
if (data.type === 'sstv_progress') {
handleProgress(data);
} else if (data.type === 'sstv_scope') {
sstvScopeTargetRms = data.rms;
sstvScopeTargetPeak = data.peak;
if (data.tone !== undefined) sstvScopeTone = data.tone;
}
} catch (err) {
console.error('Failed to parse SSE message:', err);
@@ -655,6 +822,9 @@ const SSTV = (function() {
eventSource.close();
eventSource = null;
}
stopSstvScope();
const scopePanel = document.getElementById('sstvScopePanel');
if (scopePanel) scopePanel.style.display = 'none';
}
/**
@@ -675,8 +845,97 @@ const SSTV = (function() {
renderGallery();
showNotification('SSTV', 'New image decoded!');
updateStatusUI('listening', 'Listening...');
sstvScopeMsgBurst = 1.0;
// Clear decode progress so signal monitor can take over
const liveContent = document.getElementById('sstvLiveContent');
if (liveContent) liveContent.innerHTML = '';
} else if (data.status === 'detecting') {
// Ignore detecting events if currently decoding (e.g. Doppler updates)
const dot = document.getElementById('sstvStripDot');
if (dot && dot.classList.contains('decoding')) return;
updateStatusUI('listening', data.message || 'Listening...');
if (data.signal_level !== undefined) {
renderSignalMonitor(data);
}
}
}
/**
* Render signal monitor in live area during detecting mode
*/
function renderSignalMonitor(data) {
const container = document.getElementById('sstvLiveContent');
if (!container) return;
const level = data.signal_level || 0;
const tone = data.sstv_tone;
let barColor, statusText;
if (tone === 'leader') {
barColor = 'var(--accent-green)';
statusText = 'SSTV leader tone detected';
} else if (tone === 'sync') {
barColor = 'var(--accent-cyan)';
statusText = 'SSTV sync pulse detected';
} else if (tone === 'noise') {
barColor = 'var(--text-dim)';
statusText = 'Audio signal present';
} else if (level > 10) {
barColor = 'var(--text-dim)';
statusText = 'Audio signal present';
} else {
barColor = 'var(--text-dim)';
statusText = 'No signal';
}
let monitor = container.querySelector('.sstv-signal-monitor');
if (!monitor) {
container.innerHTML = `
<div class="sstv-signal-monitor">
<div class="sstv-signal-monitor-header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M2 12L5 12M5 12C5 12 6 3 12 3C18 3 19 12 19 12M19 12L22 12"/>
<circle cx="12" cy="18" r="2"/>
<path d="M12 16V12"/>
</svg>
Signal Monitor
</div>
<div class="sstv-signal-level-row">
<span class="sstv-signal-level-label">LEVEL</span>
<div class="sstv-signal-bar-track">
<div class="sstv-signal-bar-fill" style="width: 0%"></div>
</div>
<span class="sstv-signal-level-value">0</span>
</div>
<div class="sstv-signal-status-text">No signal</div>
<div class="sstv-signal-vis-state">VIS: idle</div>
</div>`;
monitor = container.querySelector('.sstv-signal-monitor');
}
const fill = monitor.querySelector('.sstv-signal-bar-fill');
fill.style.width = level + '%';
fill.style.background = barColor;
monitor.querySelector('.sstv-signal-status-text').textContent = statusText;
monitor.querySelector('.sstv-signal-level-value').textContent = level;
const visStateEl = monitor.querySelector('.sstv-signal-vis-state');
if (visStateEl && data.vis_state) {
const stateLabels = {
'idle': 'Idle',
'leader_1': 'Leader',
'break': 'Break',
'leader_2': 'Leader 2',
'start_bit': 'Start bit',
'data_bits': 'Data bits',
'parity': 'Parity',
'stop_bit': 'Stop bit',
};
const label = stateLabels[data.vis_state] || data.vis_state;
visStateEl.textContent = 'VIS: ' + label;
visStateEl.className = 'sstv-signal-vis-state' +
(data.vis_state !== 'idle' ? ' active' : '');
}
}
@@ -687,18 +946,33 @@ const SSTV = (function() {
const liveContent = document.getElementById('sstvLiveContent');
if (!liveContent) return;
liveContent.innerHTML = `
<div class="sstv-canvas-container">
<canvas id="sstvCanvas" width="320" height="256"></canvas>
</div>
<div class="sstv-decode-info">
<div class="sstv-mode-label">${data.mode || 'Detecting mode...'}</div>
<div class="sstv-progress-bar">
<div class="progress" style="width: ${data.progress || 0}%"></div>
let container = liveContent.querySelector('.sstv-decode-container');
if (!container) {
liveContent.innerHTML = `
<div class="sstv-decode-container">
<div class="sstv-canvas-container">
<img id="sstvDecodeImg" width="320" height="256" alt="Decoding..." style="display:block;background:#000;">
</div>
<div class="sstv-decode-info">
<div class="sstv-mode-label"></div>
<div class="sstv-progress-bar">
<div class="progress" style="width: 0%"></div>
</div>
<div class="sstv-status-message"></div>
</div>
</div>
<div class="sstv-status-message">${data.message || 'Decoding...'}</div>
</div>
`;
`;
container = liveContent.querySelector('.sstv-decode-container');
}
container.querySelector('.sstv-mode-label').textContent = data.mode || 'Detecting mode...';
container.querySelector('.progress').style.width = (data.progress || 0) + '%';
container.querySelector('.sstv-status-message').textContent = data.message || 'Decoding...';
if (data.partial_image) {
const img = container.querySelector('#sstvDecodeImg');
if (img) img.src = data.partial_image;
}
}
/**
@@ -752,12 +1026,22 @@ const SSTV = (function() {
}
gallery.innerHTML = images.map(img => `
<div class="sstv-image-card" onclick="SSTV.showImage('${escapeHtml(img.url)}')">
<img src="${escapeHtml(img.url)}" alt="SSTV Image" class="sstv-image-preview" loading="lazy">
<div class="sstv-image-card">
<div class="sstv-image-card-inner" onclick="SSTV.showImage('${escapeHtml(img.url)}', '${escapeHtml(img.filename)}')">
<img src="${escapeHtml(img.url)}" alt="SSTV Image" class="sstv-image-preview" loading="lazy">
</div>
<div class="sstv-image-info">
<div class="sstv-image-mode">${escapeHtml(img.mode || 'Unknown')}</div>
<div class="sstv-image-timestamp">${formatTimestamp(img.timestamp)}</div>
</div>
<div class="sstv-image-actions">
<button onclick="event.stopPropagation(); SSTV.downloadImage('${escapeHtml(img.url)}', '${escapeHtml(img.filename)}')" title="Download">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</button>
<button onclick="event.stopPropagation(); SSTV.deleteImage('${escapeHtml(img.filename)}')" title="Delete">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
</button>
</div>
</div>
`).join('');
}
@@ -889,19 +1173,45 @@ const SSTV = (function() {
/**
* Show full-size image in modal
*/
function showImage(url) {
let currentModalUrl = null;
let currentModalFilename = null;
function showImage(url, filename) {
currentModalUrl = url;
currentModalFilename = filename || null;
let modal = document.getElementById('sstvImageModal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'sstvImageModal';
modal.className = 'sstv-image-modal';
modal.innerHTML = `
<div class="sstv-modal-toolbar">
<button class="sstv-modal-btn" id="sstvModalDownload" title="Download">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Download
</button>
<button class="sstv-modal-btn delete" id="sstvModalDelete" title="Delete">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
Delete
</button>
</div>
<button class="sstv-modal-close" onclick="SSTV.closeImage()">&times;</button>
<img src="" alt="SSTV Image">
`;
modal.addEventListener('click', (e) => {
if (e.target === modal) closeImage();
});
modal.querySelector('#sstvModalDownload').addEventListener('click', () => {
if (currentModalUrl && currentModalFilename) {
downloadImage(currentModalUrl, currentModalFilename);
}
});
modal.querySelector('#sstvModalDelete').addEventListener('click', () => {
if (currentModalFilename) {
deleteImage(currentModalFilename);
}
});
document.body.appendChild(modal);
}
@@ -940,6 +1250,55 @@ const SSTV = (function() {
return div.innerHTML;
}
/**
* Delete a single image
*/
async function deleteImage(filename) {
if (!confirm('Delete this image?')) return;
try {
const response = await fetch(`/sstv/images/${encodeURIComponent(filename)}`, { method: 'DELETE' });
const data = await response.json();
if (data.status === 'ok') {
images = images.filter(img => img.filename !== filename);
updateImageCount(images.length);
renderGallery();
closeImage();
showNotification('SSTV', 'Image deleted');
}
} catch (err) {
console.error('Failed to delete image:', err);
}
}
/**
* Delete all images
*/
async function deleteAllImages() {
if (!confirm('Delete all decoded images?')) return;
try {
const response = await fetch('/sstv/images', { method: 'DELETE' });
const data = await response.json();
if (data.status === 'ok') {
images = [];
updateImageCount(0);
renderGallery();
showNotification('SSTV', `${data.deleted} image${data.deleted !== 1 ? 's' : ''} deleted`);
}
} catch (err) {
console.error('Failed to delete images:', err);
}
}
/**
* Download an image
*/
function downloadImage(url, filename) {
const a = document.createElement('a');
a.href = url + '/download';
a.download = filename;
a.click();
}
/**
* Show status message
*/
@@ -960,6 +1319,9 @@ const SSTV = (function() {
loadIssSchedule,
showImage,
closeImage,
deleteImage,
deleteAllImages,
downloadImage,
useGPS,
updateTLE,
stopIssTracking,
+581
View File
@@ -0,0 +1,581 @@
/**
* Intercept - WebSDR Mode
* HF/Shortwave KiwiSDR Network Integration with In-App Audio
*/
// ============== STATE ==============
let websdrMap = null;
let websdrMarkers = [];
let websdrReceivers = [];
let websdrInitialized = false;
let websdrSpyStationsLoaded = false;
// KiwiSDR audio state
let kiwiWebSocket = null;
let kiwiAudioContext = null;
let kiwiScriptProcessor = null;
let kiwiGainNode = null;
let kiwiAudioBuffer = [];
let kiwiConnected = false;
let kiwiCurrentFreq = 0;
let kiwiCurrentMode = 'am';
let kiwiSmeter = 0;
let kiwiSmeterInterval = null;
let kiwiReceiverName = '';
const KIWI_SAMPLE_RATE = 12000;
// ============== INITIALIZATION ==============
function initWebSDR() {
if (websdrInitialized) {
if (websdrMap) {
setTimeout(() => websdrMap.invalidateSize(), 100);
}
return;
}
const mapEl = document.getElementById('websdrMap');
if (!mapEl || typeof L === 'undefined') return;
// Calculate minimum zoom so tiles fill the container vertically
const mapHeight = mapEl.clientHeight || 500;
const minZoom = Math.ceil(Math.log2(mapHeight / 256));
websdrMap = L.map('websdrMap', {
center: [20, 0],
zoom: Math.max(minZoom, 2),
minZoom: Math.max(minZoom, 2),
zoomControl: true,
maxBounds: [[-85, -360], [85, 360]],
maxBoundsViscosity: 1.0,
});
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; OpenStreetMap contributors &copy; CARTO',
subdomains: 'abcd',
maxZoom: 19,
}).addTo(websdrMap);
// Match background to tile ocean color so any remaining edge is seamless
mapEl.style.background = '#1a1d29';
websdrInitialized = true;
if (!websdrSpyStationsLoaded) {
loadSpyStationPresets();
}
[100, 300, 600, 1000].forEach(delay => {
setTimeout(() => {
if (websdrMap) websdrMap.invalidateSize();
}, delay);
});
}
// ============== RECEIVER SEARCH ==============
function searchReceivers(refresh) {
const freqKhz = parseFloat(document.getElementById('websdrFrequency')?.value || 0);
let url = '/websdr/receivers?available=true';
if (freqKhz > 0) url += `&freq_khz=${freqKhz}`;
if (refresh) url += '&refresh=true';
fetch(url)
.then(r => r.json())
.then(data => {
if (data.status === 'success') {
websdrReceivers = data.receivers || [];
renderReceiverList(websdrReceivers);
plotReceiversOnMap(websdrReceivers);
const countEl = document.getElementById('websdrReceiverCount');
if (countEl) countEl.textContent = `${websdrReceivers.length} found`;
}
})
.catch(err => console.error('[WEBSDR] Search error:', err));
}
// ============== MAP ==============
function plotReceiversOnMap(receivers) {
if (!websdrMap) return;
websdrMarkers.forEach(m => websdrMap.removeLayer(m));
websdrMarkers = [];
receivers.forEach((rx, idx) => {
if (rx.lat == null || rx.lon == null) return;
const marker = L.circleMarker([rx.lat, rx.lon], {
radius: 6,
fillColor: rx.available ? '#00d4ff' : '#666',
color: rx.available ? '#00d4ff' : '#666',
weight: 1,
opacity: 0.8,
fillOpacity: 0.6,
});
marker.bindPopup(`
<div style="font-size: 12px; min-width: 200px;">
<strong>${escapeHtmlWebsdr(rx.name)}</strong><br>
${rx.location ? `<span style="color: #aaa;">${escapeHtmlWebsdr(rx.location)}</span><br>` : ''}
<span style="color: #888;">Antenna: ${escapeHtmlWebsdr(rx.antenna || 'Unknown')}</span><br>
<span style="color: #888;">Users: ${rx.users}/${rx.users_max}</span><br>
<button onclick="selectReceiver(${idx})" style="margin-top: 6px; padding: 4px 12px; background: #00d4ff; color: #000; border: none; border-radius: 3px; cursor: pointer; font-weight: bold;">Listen</button>
</div>
`);
marker.addTo(websdrMap);
websdrMarkers.push(marker);
});
if (websdrMarkers.length > 0) {
const group = L.featureGroup(websdrMarkers);
websdrMap.fitBounds(group.getBounds(), { padding: [30, 30] });
}
}
// ============== RECEIVER LIST ==============
function renderReceiverList(receivers) {
const container = document.getElementById('websdrReceiverList');
if (!container) return;
if (receivers.length === 0) {
container.innerHTML = '<div style="color: var(--text-muted); text-align: center; padding: 20px;">No receivers found</div>';
return;
}
container.innerHTML = receivers.slice(0, 50).map((rx, idx) => `
<div style="padding: 8px; border-bottom: 1px solid rgba(255,255,255,0.05); cursor: pointer; transition: background 0.2s;"
onmouseover="this.style.background='rgba(0,212,255,0.05)'" onmouseout="this.style.background='transparent'"
onclick="selectReceiver(${idx})">
<div style="display: flex; justify-content: space-between; align-items: center;">
<strong style="font-size: 11px; color: var(--text-primary);">${escapeHtmlWebsdr(rx.name)}</strong>
<span style="font-size: 9px; padding: 1px 6px; background: ${rx.available ? 'rgba(0,230,118,0.15)' : 'rgba(158,158,158,0.15)'}; color: ${rx.available ? '#00e676' : '#9e9e9e'}; border-radius: 3px;">${rx.users}/${rx.users_max}</span>
</div>
<div style="font-size: 9px; color: var(--text-muted); margin-top: 2px;">
${rx.location ? escapeHtmlWebsdr(rx.location) + ' · ' : ''}${escapeHtmlWebsdr(rx.antenna || '')}
${rx.distance_km !== undefined ? ` · ${rx.distance_km} km` : ''}
</div>
</div>
`).join('');
}
// ============== SELECT RECEIVER ==============
function selectReceiver(index) {
const rx = websdrReceivers[index];
if (!rx) return;
const freqKhz = parseFloat(document.getElementById('websdrFrequency')?.value || 7000);
const mode = document.getElementById('websdrMode_select')?.value || 'am';
kiwiReceiverName = rx.name;
// Connect via backend proxy
connectToReceiver(rx.url, freqKhz, mode);
// Highlight on map
if (websdrMap && rx.lat != null && rx.lon != null) {
websdrMap.setView([rx.lat, rx.lon], 6);
}
}
// ============== KIWISDR AUDIO CONNECTION ==============
function connectToReceiver(receiverUrl, freqKhz, mode) {
// Disconnect if already connected
if (kiwiWebSocket) {
disconnectFromReceiver();
}
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${proto}//${location.host}/ws/kiwi-audio`;
kiwiWebSocket = new WebSocket(wsUrl);
kiwiWebSocket.binaryType = 'arraybuffer';
kiwiWebSocket.onopen = () => {
kiwiWebSocket.send(JSON.stringify({
cmd: 'connect',
url: receiverUrl,
freq_khz: freqKhz,
mode: mode,
}));
updateKiwiUI('connecting');
};
kiwiWebSocket.onmessage = (event) => {
if (typeof event.data === 'string') {
const msg = JSON.parse(event.data);
handleKiwiStatus(msg);
} else {
handleKiwiAudio(event.data);
}
};
kiwiWebSocket.onclose = () => {
kiwiConnected = false;
updateKiwiUI('disconnected');
};
kiwiWebSocket.onerror = () => {
updateKiwiUI('disconnected');
};
}
function handleKiwiStatus(msg) {
switch (msg.type) {
case 'connected':
kiwiConnected = true;
kiwiCurrentFreq = msg.freq_khz;
kiwiCurrentMode = msg.mode;
initKiwiAudioContext(msg.sample_rate || KIWI_SAMPLE_RATE);
updateKiwiUI('connected');
break;
case 'tuned':
kiwiCurrentFreq = msg.freq_khz;
kiwiCurrentMode = msg.mode;
updateKiwiUI('connected');
break;
case 'error':
console.error('[KIWI] Error:', msg.message);
if (typeof showNotification === 'function') {
showNotification('WebSDR', msg.message);
}
updateKiwiUI('error');
break;
case 'disconnected':
kiwiConnected = false;
cleanupKiwiAudio();
updateKiwiUI('disconnected');
break;
}
}
function handleKiwiAudio(arrayBuffer) {
if (arrayBuffer.byteLength < 4) return;
// First 2 bytes: S-meter (big-endian int16)
const view = new DataView(arrayBuffer);
kiwiSmeter = view.getInt16(0, false);
// Remaining bytes: PCM 16-bit signed LE
const pcmData = new Int16Array(arrayBuffer, 2);
// Convert to float32 [-1, 1] for Web Audio API
const float32 = new Float32Array(pcmData.length);
for (let i = 0; i < pcmData.length; i++) {
float32[i] = pcmData[i] / 32768.0;
}
// Add to playback buffer (limit buffer size to ~2s)
kiwiAudioBuffer.push(float32);
const maxChunks = Math.ceil((KIWI_SAMPLE_RATE * 2) / 512);
while (kiwiAudioBuffer.length > maxChunks) {
kiwiAudioBuffer.shift();
}
}
function initKiwiAudioContext(sampleRate) {
cleanupKiwiAudio();
kiwiAudioContext = new (window.AudioContext || window.webkitAudioContext)({
sampleRate: sampleRate,
});
// Resume if suspended (autoplay policy)
if (kiwiAudioContext.state === 'suspended') {
kiwiAudioContext.resume();
}
// ScriptProcessorNode: pulls audio from buffer
kiwiScriptProcessor = kiwiAudioContext.createScriptProcessor(2048, 0, 1);
kiwiScriptProcessor.onaudioprocess = (e) => {
const output = e.outputBuffer.getChannelData(0);
let offset = 0;
while (offset < output.length && kiwiAudioBuffer.length > 0) {
const chunk = kiwiAudioBuffer[0];
const needed = output.length - offset;
const available = chunk.length;
if (available <= needed) {
output.set(chunk, offset);
offset += available;
kiwiAudioBuffer.shift();
} else {
output.set(chunk.subarray(0, needed), offset);
kiwiAudioBuffer[0] = chunk.subarray(needed);
offset += needed;
}
}
// Fill remaining with silence
while (offset < output.length) {
output[offset++] = 0;
}
};
// Volume control
kiwiGainNode = kiwiAudioContext.createGain();
const savedVol = localStorage.getItem('kiwiVolume');
kiwiGainNode.gain.value = savedVol !== null ? parseFloat(savedVol) / 100 : 0.8;
const volValue = Math.round(kiwiGainNode.gain.value * 100);
['kiwiVolume', 'kiwiBarVolume'].forEach(id => {
const el = document.getElementById(id);
if (el) el.value = volValue;
});
kiwiScriptProcessor.connect(kiwiGainNode);
kiwiGainNode.connect(kiwiAudioContext.destination);
// S-meter display updates
if (kiwiSmeterInterval) clearInterval(kiwiSmeterInterval);
kiwiSmeterInterval = setInterval(updateSmeterDisplay, 200);
}
function disconnectFromReceiver() {
if (kiwiWebSocket && kiwiWebSocket.readyState === WebSocket.OPEN) {
kiwiWebSocket.send(JSON.stringify({ cmd: 'disconnect' }));
}
cleanupKiwiAudio();
if (kiwiWebSocket) {
kiwiWebSocket.close();
kiwiWebSocket = null;
}
kiwiConnected = false;
kiwiReceiverName = '';
updateKiwiUI('disconnected');
}
function cleanupKiwiAudio() {
if (kiwiSmeterInterval) {
clearInterval(kiwiSmeterInterval);
kiwiSmeterInterval = null;
}
if (kiwiScriptProcessor) {
kiwiScriptProcessor.disconnect();
kiwiScriptProcessor = null;
}
if (kiwiGainNode) {
kiwiGainNode.disconnect();
kiwiGainNode = null;
}
if (kiwiAudioContext) {
kiwiAudioContext.close().catch(() => {});
kiwiAudioContext = null;
}
kiwiAudioBuffer = [];
kiwiSmeter = 0;
}
function tuneKiwi(freqKhz, mode) {
if (!kiwiWebSocket || !kiwiConnected) return;
kiwiWebSocket.send(JSON.stringify({
cmd: 'tune',
freq_khz: freqKhz,
mode: mode || kiwiCurrentMode,
}));
}
function tuneFromBar() {
const freq = parseFloat(document.getElementById('kiwiBarFrequency')?.value || 0);
const mode = document.getElementById('kiwiBarMode')?.value || kiwiCurrentMode;
if (freq > 0) {
tuneKiwi(freq, mode);
// Also update sidebar frequency
const freqInput = document.getElementById('websdrFrequency');
if (freqInput) freqInput.value = freq;
}
}
function setKiwiVolume(value) {
if (kiwiGainNode) {
kiwiGainNode.gain.value = value / 100;
localStorage.setItem('kiwiVolume', value);
}
// Sync both volume sliders
['kiwiVolume', 'kiwiBarVolume'].forEach(id => {
const el = document.getElementById(id);
if (el && el.value !== String(value)) el.value = value;
});
}
// ============== S-METER ==============
function updateSmeterDisplay() {
// KiwiSDR S-meter: value in 0.1 dBm units (e.g., -730 = -73 dBm = S9)
const dbm = kiwiSmeter / 10;
let sUnit;
if (dbm >= -73) {
const over = Math.round((dbm + 73));
sUnit = over > 0 ? `S9+${over}` : 'S9';
} else {
sUnit = `S${Math.max(0, Math.round((dbm + 127) / 6))}`;
}
const pct = Math.min(100, Math.max(0, (dbm + 127) / 1.27));
// Update both sidebar and bar S-meter displays
['kiwiSmeterBar', 'kiwiBarSmeter'].forEach(id => {
const el = document.getElementById(id);
if (el) el.style.width = pct + '%';
});
['kiwiSmeterValue', 'kiwiBarSmeterValue'].forEach(id => {
const el = document.getElementById(id);
if (el) el.textContent = sUnit;
});
}
// ============== UI UPDATES ==============
function updateKiwiUI(state) {
const statusEl = document.getElementById('kiwiStatus');
const controlsBar = document.getElementById('kiwiAudioControls');
const disconnectBtn = document.getElementById('kiwiDisconnectBtn');
const receiverNameEl = document.getElementById('kiwiReceiverName');
const freqDisplay = document.getElementById('kiwiFreqDisplay');
const barReceiverName = document.getElementById('kiwiBarReceiverName');
const barFreq = document.getElementById('kiwiBarFrequency');
const barMode = document.getElementById('kiwiBarMode');
if (state === 'connected') {
if (statusEl) {
statusEl.textContent = 'CONNECTED';
statusEl.style.color = 'var(--accent-green)';
}
if (controlsBar) controlsBar.style.display = 'block';
if (disconnectBtn) disconnectBtn.style.display = 'block';
if (receiverNameEl) {
receiverNameEl.textContent = kiwiReceiverName;
receiverNameEl.style.display = 'block';
}
if (freqDisplay) freqDisplay.textContent = kiwiCurrentFreq + ' kHz';
if (barReceiverName) barReceiverName.textContent = kiwiReceiverName;
if (barFreq) barFreq.value = kiwiCurrentFreq;
if (barMode) barMode.value = kiwiCurrentMode;
} else if (state === 'connecting') {
if (statusEl) {
statusEl.textContent = 'CONNECTING...';
statusEl.style.color = 'var(--accent-orange)';
}
} else if (state === 'error') {
if (statusEl) {
statusEl.textContent = 'ERROR';
statusEl.style.color = 'var(--accent-red)';
}
} else {
// disconnected
if (statusEl) {
statusEl.textContent = 'DISCONNECTED';
statusEl.style.color = 'var(--text-muted)';
}
if (controlsBar) controlsBar.style.display = 'none';
if (disconnectBtn) disconnectBtn.style.display = 'none';
if (receiverNameEl) receiverNameEl.style.display = 'none';
if (freqDisplay) freqDisplay.textContent = '--- kHz';
// Reset both S-meter displays (sidebar + bar)
['kiwiSmeterBar', 'kiwiBarSmeter'].forEach(id => {
const el = document.getElementById(id);
if (el) el.style.width = '0%';
});
['kiwiSmeterValue', 'kiwiBarSmeterValue'].forEach(id => {
const el = document.getElementById(id);
if (el) el.textContent = 'S0';
});
}
}
// ============== SPY STATION PRESETS ==============
function loadSpyStationPresets() {
fetch('/spy-stations/stations')
.then(r => r.json())
.then(data => {
websdrSpyStationsLoaded = true;
const container = document.getElementById('websdrSpyPresets');
if (!container) return;
const stations = data.stations || data || [];
if (!Array.isArray(stations) || stations.length === 0) {
container.innerHTML = '<div style="color: var(--text-muted); text-align: center; padding: 10px;">No stations available</div>';
return;
}
container.innerHTML = stations.slice(0, 30).map(s => {
const primaryFreq = s.frequencies?.find(f => f.primary) || s.frequencies?.[0];
const freqKhz = primaryFreq?.freq_khz || 0;
return `
<div style="padding: 6px 4px; border-bottom: 1px solid rgba(255,255,255,0.05); cursor: pointer; display: flex; justify-content: space-between; align-items: center;"
onclick="tuneToSpyStation('${escapeHtmlWebsdr(s.id)}', ${freqKhz})"
onmouseover="this.style.background='rgba(0,212,255,0.05)'" onmouseout="this.style.background='transparent'">
<div>
<span style="color: var(--accent-cyan); font-weight: bold;">${escapeHtmlWebsdr(s.name)}</span>
<span style="color: var(--text-muted); font-size: 9px; margin-left: 4px;">${escapeHtmlWebsdr(s.nickname || '')}</span>
</div>
<span style="color: var(--accent-orange); font-family: var(--font-mono); font-size: 10px;">${freqKhz} kHz</span>
</div>
`;
}).join('');
})
.catch(err => {
console.error('[WEBSDR] Failed to load spy station presets:', err);
});
}
function tuneToSpyStation(stationId, freqKhz) {
const freqInput = document.getElementById('websdrFrequency');
if (freqInput) freqInput.value = freqKhz;
// If already connected, just retune
if (kiwiConnected) {
const mode = document.getElementById('websdrMode_select')?.value || kiwiCurrentMode;
tuneKiwi(freqKhz, mode);
return;
}
// Otherwise, search for receivers at this frequency
fetch(`/websdr/spy-station/${encodeURIComponent(stationId)}/receivers`)
.then(r => r.json())
.then(data => {
if (data.status === 'success') {
websdrReceivers = data.receivers || [];
renderReceiverList(websdrReceivers);
plotReceiversOnMap(websdrReceivers);
const countEl = document.getElementById('websdrReceiverCount');
if (countEl) countEl.textContent = `${websdrReceivers.length} for ${data.station?.name || stationId}`;
if (typeof showNotification === 'function' && data.station) {
showNotification('WebSDR', `Found ${websdrReceivers.length} receivers for ${data.station.name} at ${freqKhz} kHz`);
}
}
})
.catch(err => console.error('[WEBSDR] Spy station receivers error:', err));
}
// ============== UTILITIES ==============
function escapeHtmlWebsdr(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// ============== EXPORTS ==============
window.initWebSDR = initWebSDR;
window.searchReceivers = searchReceivers;
window.selectReceiver = selectReceiver;
window.tuneToSpyStation = tuneToSpyStation;
window.loadSpyStationPresets = loadSpyStationPresets;
window.connectToReceiver = connectToReceiver;
window.disconnectFromReceiver = disconnectFromReceiver;
window.tuneKiwi = tuneKiwi;
window.tuneFromBar = tuneFromBar;
window.setKiwiVolume = setKiwiVolume;
+207 -34
View File
@@ -28,9 +28,9 @@ const WiFiMode = (function() {
maxProbes: 1000,
};
// ==========================================================================
// Agent Support
// ==========================================================================
// ==========================================================================
// Agent Support
// ==========================================================================
/**
* Get the API base URL, routing through agent proxy if agent is selected.
@@ -59,15 +59,49 @@ const WiFiMode = (function() {
/**
* Check for agent mode conflicts before starting WiFi scan.
*/
function checkAgentConflicts() {
if (typeof currentAgent === 'undefined' || currentAgent === 'local') {
return true;
}
if (typeof checkAgentModeConflict === 'function') {
return checkAgentModeConflict('wifi');
}
return true;
}
function checkAgentConflicts() {
if (typeof currentAgent === 'undefined' || currentAgent === 'local') {
return true;
}
if (typeof checkAgentModeConflict === 'function') {
return checkAgentModeConflict('wifi');
}
return true;
}
function getChannelPresetList(preset) {
switch (preset) {
case '2.4-common':
return '1,6,11';
case '2.4-all':
return '1,2,3,4,5,6,7,8,9,10,11,12,13';
case '5-low':
return '36,40,44,48';
case '5-mid':
return '52,56,60,64';
case '5-high':
return '149,153,157,161,165';
default:
return '';
}
}
function buildChannelConfig() {
const preset = document.getElementById('wifiChannelPreset')?.value || '';
const listInput = document.getElementById('wifiChannelList')?.value || '';
const singleInput = document.getElementById('wifiChannel')?.value || '';
const listValue = listInput.trim();
const presetValue = getChannelPresetList(preset);
const channels = listValue || presetValue || '';
const channel = channels ? null : (singleInput.trim() ? parseInt(singleInput.trim()) : null);
return {
channels: channels || null,
channel: Number.isFinite(channel) ? channel : null,
};
}
// ==========================================================================
// State
@@ -461,10 +495,10 @@ const WiFiMode = (function() {
setScanning(true, 'deep');
try {
const iface = elements.interfaceSelect?.value || null;
const band = document.getElementById('wifiBand')?.value || 'all';
const channel = document.getElementById('wifiChannel')?.value || null;
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
const iface = elements.interfaceSelect?.value || null;
const band = document.getElementById('wifiBand')?.value || 'all';
const channelConfig = buildChannelConfig();
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
let response;
if (isAgentMode) {
@@ -473,23 +507,25 @@ const WiFiMode = (function() {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
interface: iface,
scan_type: 'deep',
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
channel: channel ? parseInt(channel) : null,
}),
});
} else {
response = await fetch(`${CONFIG.apiBase}/scan/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
interface: iface,
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
channel: channel ? parseInt(channel) : null,
}),
});
}
interface: iface,
scan_type: 'deep',
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
channel: channelConfig.channel,
channels: channelConfig.channels,
}),
});
} else {
response = await fetch(`${CONFIG.apiBase}/scan/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
interface: iface,
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
channel: channelConfig.channel,
channels: channelConfig.channels,
}),
});
}
if (!response.ok) {
const error = await response.json();
@@ -879,6 +915,7 @@ const WiFiMode = (function() {
updateNetworkRow(network);
updateStats();
updateProximityRadar();
updateChannelChart();
if (onNetworkUpdate) onNetworkUpdate(network);
}
@@ -887,6 +924,9 @@ const WiFiMode = (function() {
clients.set(client.mac, client);
updateStats();
// Update client display if this client belongs to the selected network
updateClientInList(client);
if (onClientUpdate) onClientUpdate(client);
}
@@ -1135,6 +1175,9 @@ const WiFiMode = (function() {
// Show the drawer
elements.detailDrawer.classList.add('open');
// Fetch and display clients for this network
fetchClientsForNetwork(network.bssid);
}
function closeDetail() {
@@ -1147,6 +1190,130 @@ const WiFiMode = (function() {
});
}
// ==========================================================================
// Client Display
// ==========================================================================
async function fetchClientsForNetwork(bssid) {
if (!elements.detailClientList) return;
try {
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
let response;
if (isAgentMode) {
// Route through agent proxy
response = await fetch(`/controller/agents/${currentAgent}/wifi/v2/clients?bssid=${encodeURIComponent(bssid)}&associated=true`);
} else {
response = await fetch(`${CONFIG.apiBase}/clients?bssid=${encodeURIComponent(bssid)}&associated=true`);
}
if (!response.ok) {
// Hide client list on error
elements.detailClientList.style.display = 'none';
return;
}
const data = await response.json();
// Handle agent response format (may be nested in 'result')
const result = isAgentMode && data.result ? data.result : data;
const clientList = result.clients || [];
if (clientList.length > 0) {
renderClientList(clientList, bssid);
elements.detailClientList.style.display = 'block';
} else {
elements.detailClientList.style.display = 'none';
}
} catch (error) {
console.debug('[WiFiMode] Error fetching clients:', error);
elements.detailClientList.style.display = 'none';
}
}
function renderClientList(clientList, bssid) {
const container = elements.detailClientList?.querySelector('.wifi-client-list');
const countBadge = document.getElementById('wifiClientCountBadge');
if (!container) return;
// Update count badge
if (countBadge) {
countBadge.textContent = clientList.length;
}
// Render client cards
container.innerHTML = clientList.map(client => {
const rssi = client.rssi_current;
const signalClass = rssi >= -50 ? 'signal-strong' :
rssi >= -70 ? 'signal-medium' :
rssi >= -85 ? 'signal-weak' : 'signal-very-weak';
// Format last seen time
const lastSeen = client.last_seen ? formatTime(client.last_seen) : '--';
// Build probed SSIDs badges
let probesHtml = '';
if (client.probed_ssids && client.probed_ssids.length > 0) {
const probes = client.probed_ssids.slice(0, 5); // Show max 5
probesHtml = `
<div class="wifi-client-probes">
${probes.map(ssid => `<span class="wifi-client-probe-badge">${escapeHtml(ssid)}</span>`).join('')}
${client.probed_ssids.length > 5 ? `<span class="wifi-client-probe-badge">+${client.probed_ssids.length - 5}</span>` : ''}
</div>
`;
}
return `
<div class="wifi-client-card" data-mac="${escapeHtml(client.mac)}">
<div class="wifi-client-identity">
<span class="wifi-client-mac">${escapeHtml(client.mac)}</span>
<span class="wifi-client-vendor">${escapeHtml(client.vendor || 'Unknown vendor')}</span>
${probesHtml}
</div>
<div class="wifi-client-signal">
<span class="wifi-client-rssi ${signalClass}">${rssi !== null && rssi !== undefined ? rssi + ' dBm' : '--'}</span>
<span class="wifi-client-lastseen">${lastSeen}</span>
</div>
</div>
`;
}).join('');
}
function updateClientInList(client) {
// Check if this client belongs to the currently selected network
if (!selectedNetwork || client.associated_bssid !== selectedNetwork) {
return;
}
const container = elements.detailClientList?.querySelector('.wifi-client-list');
if (!container) return;
const existingCard = container.querySelector(`[data-mac="${client.mac}"]`);
if (existingCard) {
// Update existing card's RSSI and last seen
const rssiEl = existingCard.querySelector('.wifi-client-rssi');
const lastSeenEl = existingCard.querySelector('.wifi-client-lastseen');
if (rssiEl && client.rssi_current !== null && client.rssi_current !== undefined) {
const rssi = client.rssi_current;
const signalClass = rssi >= -50 ? 'signal-strong' :
rssi >= -70 ? 'signal-medium' :
rssi >= -85 ? 'signal-weak' : 'signal-very-weak';
rssiEl.textContent = rssi + ' dBm';
rssiEl.className = 'wifi-client-rssi ' + signalClass;
}
if (lastSeenEl && client.last_seen) {
lastSeenEl.textContent = formatTime(client.last_seen);
}
} else {
// New client for this network - re-fetch the full list
fetchClientsForNetwork(selectedNetwork);
}
}
// ==========================================================================
// Statistics
// ==========================================================================
@@ -1290,9 +1457,15 @@ const WiFiMode = (function() {
return Object.values(stats).filter(s => s.ap_count > 0 || [1, 6, 11, 36, 40, 44, 48, 149, 153, 157, 161, 165].includes(s.channel));
}
function updateChannelChart(band = '2.4') {
function updateChannelChart(band) {
if (typeof ChannelChart === 'undefined') return;
// Use the currently active band tab if no band specified
if (!band) {
const activeTab = elements.channelBandTabs && elements.channelBandTabs.querySelector('.channel-band-tab.active');
band = activeTab ? activeTab.dataset.band : '2.4';
}
// Recalculate channel stats from networks if needed
if (channelStats.length === 0 && networks.size > 0) {
channelStats = calculateChannelStats();
@@ -0,0 +1,124 @@
/*!
* chartjs-adapter-date-fns v3.0.0 - Lightweight date adapter for Chart.js
* Uses native Date parsing (no external dependencies)
*/
(function() {
'use strict';
const FORMATS = {
datetime: 'MMM d, yyyy, h:mm:ss a',
millisecond: 'h:mm:ss.SSS a',
second: 'h:mm:ss a',
minute: 'h:mm a',
hour: 'ha',
day: 'MMM d',
week: 'PP',
month: 'MMM yyyy',
quarter: "'Q'Q - yyyy",
year: 'yyyy'
};
function formatDate(date, fmt) {
const d = new Date(date);
if (isNaN(d.getTime())) return '';
const h = d.getHours();
const m = d.getMinutes();
const s = d.getSeconds();
const ms = d.getMilliseconds();
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const ampm = h >= 12 ? 'PM' : 'AM';
const h12 = h % 12 || 12;
switch(fmt) {
case 'h:mm:ss.SSS a':
return `${h12}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}.${String(ms).padStart(3,'0')} ${ampm}`;
case 'h:mm:ss a':
return `${h12}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')} ${ampm}`;
case 'h:mm a':
return `${h12}:${String(m).padStart(2,'0')} ${ampm}`;
case 'ha':
return `${h12}${ampm}`;
case 'MMM d':
return `${months[d.getMonth()]} ${d.getDate()}`;
case 'MMM yyyy':
return `${months[d.getMonth()]} ${d.getFullYear()}`;
case 'yyyy':
return `${d.getFullYear()}`;
default:
return `${months[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}, ${h12}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')} ${ampm}`;
}
}
const UNITS = ['millisecond','second','minute','hour','day','week','month','quarter','year'];
const UNIT_MS = {
millisecond: 1,
second: 1000,
minute: 60000,
hour: 3600000,
day: 86400000,
week: 604800000,
month: 2592000000,
quarter: 7776000000,
year: 31536000000
};
if (typeof Chart !== 'undefined' && Chart._adapters && Chart._adapters._date) {
const adapter = Chart._adapters._date;
adapter.override({
_id: 'date-fns-lite',
formats: function() { return FORMATS; },
parse: function(value) {
if (value === null || value === undefined) return null;
if (typeof value === 'number') return value;
const d = new Date(value);
return isNaN(d.getTime()) ? null : d.getTime();
},
format: function(time, fmt) {
return formatDate(time, fmt);
},
add: function(time, amount, unit) {
const d = new Date(time);
switch(unit) {
case 'millisecond': d.setTime(d.getTime() + amount); break;
case 'second': d.setSeconds(d.getSeconds() + amount); break;
case 'minute': d.setMinutes(d.getMinutes() + amount); break;
case 'hour': d.setHours(d.getHours() + amount); break;
case 'day': d.setDate(d.getDate() + amount); break;
case 'week': d.setDate(d.getDate() + amount * 7); break;
case 'month': d.setMonth(d.getMonth() + amount); break;
case 'quarter': d.setMonth(d.getMonth() + amount * 3); break;
case 'year': d.setFullYear(d.getFullYear() + amount); break;
}
return d.getTime();
},
diff: function(max, min, unit) {
return (max - min) / (UNIT_MS[unit] || 1);
},
startOf: function(time, unit) {
const d = new Date(time);
switch(unit) {
case 'second': d.setMilliseconds(0); break;
case 'minute': d.setSeconds(0,0); break;
case 'hour': d.setMinutes(0,0,0); break;
case 'day': d.setHours(0,0,0,0); break;
case 'week': d.setHours(0,0,0,0); d.setDate(d.getDate() - d.getDay()); break;
case 'month': d.setHours(0,0,0,0); d.setDate(1); break;
case 'quarter': d.setHours(0,0,0,0); d.setMonth(d.getMonth() - d.getMonth() % 3, 1); break;
case 'year': d.setHours(0,0,0,0); d.setMonth(0,1); break;
}
return d.getTime();
},
endOf: function(time, unit) {
const d = new Date(time);
switch(unit) {
case 'second': d.setMilliseconds(999); break;
case 'minute': d.setSeconds(59,999); break;
case 'hour': d.setMinutes(59,59,999); break;
case 'day': d.setHours(23,59,59,999); break;
case 'month': d.setMonth(d.getMonth()+1,0); d.setHours(23,59,59,999); break;
case 'year': d.setMonth(11,31); d.setHours(23,59,59,999); break;
}
return d.getTime();
}
});
}
})();
+4917 -4899
View File
File diff suppressed because it is too large Load Diff
+23 -4
View File
@@ -4,8 +4,15 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ADS-B History // INTERCEPT</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
{% if offline_settings.fonts_source == 'local' %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
{% else %}
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
{% endif %}
<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/settings.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') }}">
</head>
<body>
@@ -22,6 +29,9 @@
</div>
</header>
{% set active_mode = 'adsb' %}
{% include 'partials/nav.html' with context %}
<main class="history-shell">
<section class="summary-strip">
<div class="summary-card">
@@ -462,7 +472,7 @@
if (!points.length) {
ctx.fillStyle = 'rgba(156, 163, 175, 0.6)';
ctx.font = '12px "JetBrains Mono", monospace';
ctx.font = '12px "Space Mono", monospace';
ctx.fillText(`No ${label.toLowerCase()} data`, 12, height / 2);
return;
}
@@ -470,7 +480,7 @@
const series = points.map(p => p[field]).filter(v => v !== null && v !== undefined);
if (!series.length) {
ctx.fillStyle = 'rgba(156, 163, 175, 0.6)';
ctx.font = '12px "JetBrains Mono", monospace';
ctx.font = '12px "Space Mono", monospace';
ctx.fillText(`No ${label.toLowerCase()} data`, 12, height / 2);
return;
}
@@ -511,7 +521,7 @@
}
ctx.fillStyle = 'rgba(226, 232, 240, 0.8)';
ctx.font = '11px "JetBrains Mono", monospace';
ctx.font = '11px "Space Mono", monospace';
ctx.fillText(`${maxVal} ${unit}`, 12, padding);
ctx.fillText(`${minVal} ${unit}`, 12, height - padding);
}
@@ -761,5 +771,14 @@
}
});
</script>
<!-- Settings Modal -->
{% include 'partials/settings-modal.html' %}
<!-- Help Modal -->
{% 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/global-nav.js') }}"></script>
</body>
</html>
+568 -588
View File
File diff suppressed because it is too large Load Diff
+25 -7
View File
@@ -8,7 +8,7 @@
{% if offline_settings.fonts_source == 'local' %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
{% else %}
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@400;500;600;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Orbitron:wght@400;500;600;700&display=swap" rel="stylesheet">
{% endif %}
<!-- Leaflet.js - Conditional CDN/Local loading -->
{% if offline_settings.assets_source == 'local' %}
@@ -18,8 +18,13 @@
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
{% endif %}
<!-- Core CSS variables -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/variables.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/global-nav.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/help-modal.css') }}">
<script>
window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }};
</script>
@@ -46,11 +51,12 @@
<input type="checkbox" id="showAllAgents" onchange="toggleShowAllAgents()"> All
</label>
</div>
<a href="#" onclick="history.back(); return false;" class="back-link">Back</a>
<a href="/" class="back-link">Main Dashboard</a>
</div>
</header>
{% set active_mode = 'ais' %}
{% include 'partials/nav.html' with context %}
<div class="stats-strip">
<div class="stats-strip-inner">
<div class="strip-stat">
@@ -384,7 +390,7 @@
};
// Initialize map
function initMap() {
async function initMap() {
if (observerLocation) {
document.getElementById('obsLat').value = observerLocation.lat;
document.getElementById('obsLon').value = observerLocation.lon;
@@ -398,14 +404,17 @@
// Use settings manager for tile layer (allows runtime changes)
window.vesselMap = vesselMap;
if (typeof Settings !== 'undefined' && Settings.createTileLayer) {
if (typeof Settings !== 'undefined') {
// Wait for settings to load from server before applying tiles
await Settings.init();
Settings.createTileLayer().addTo(vesselMap);
Settings.registerMap(vesselMap);
} else {
L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
maxZoom: 19,
subdomains: 'abcd'
subdomains: 'abcd',
className: 'tile-layer-cyan'
}).addTo(vesselMap);
}
@@ -1495,7 +1504,7 @@
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
cursor: pointer;
}
.agent-select-sm:focus {
@@ -1547,8 +1556,17 @@
}
</style>
<!-- Settings Modal -->
{% include 'partials/settings-modal.html' %}
<!-- Help Modal -->
{% include 'partials/help-modal.html' %}
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}"></script>
<!-- Agent Manager -->
<script src="{{ url_for('static', filename='js/core/agents.js') }}"></script>
<script src="{{ url_for('static', filename='js/core/global-nav.js') }}"></script>
<script>
// AIS-specific agent integration
let aisCurrentAgent = 'local';
+24
View File
@@ -0,0 +1,24 @@
{#
Card/Panel Component
Reusable container with optional header and footer
Variables:
- title: Optional card header title
- indicator: If true, shows status indicator dot in header
- indicator_active: If true, indicator is active/green
- no_padding: If true, removes body padding
#}
<div class="panel">
{% if title %}
<div class="panel-header">
<span>{{ title }}</span>
{% if indicator %}
<div class="panel-indicator {% if indicator_active %}active{% endif %}"></div>
{% endif %}
</div>
{% endif %}
<div class="panel-content{% if no_padding %}" style="padding: 0;{% else %}{% endif %}">
{{ caller() }}
</div>
</div>
+38
View File
@@ -0,0 +1,38 @@
{#
Empty State Component
Display when no data is available
Variables:
- icon: Optional SVG icon (default: generic empty icon)
- title: Main message (default: "No data")
- description: Optional helper text
- action_text: Optional button text
- action_onclick: Optional button onclick handler
- action_href: Optional button link
#}
<div class="empty-state">
<div class="empty-state-icon">
{% if icon %}
{{ icon|safe }}
{% else %}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="12" r="10"/>
<path d="M8 12h8"/>
</svg>
{% endif %}
</div>
<div class="empty-state-title">{{ title|default('No data') }}</div>
{% if description %}
<div class="empty-state-description">{{ description }}</div>
{% endif %}
{% if action_text %}
<div class="empty-state-action">
{% if action_href %}
<a href="{{ action_href }}" class="btn btn-primary btn-sm">{{ action_text }}</a>
{% elif action_onclick %}
<button class="btn btn-primary btn-sm" onclick="{{ action_onclick }}">{{ action_text }}</button>
{% endif %}
</div>
{% endif %}
</div>
+27
View File
@@ -0,0 +1,27 @@
{#
Loading State Component
Display while data is being fetched
Variables:
- text: Optional loading text (default: "Loading...")
- size: 'sm', 'md', or 'lg' (default: 'md')
- overlay: If true, renders as full overlay
#}
{% if overlay %}
<div class="loading-overlay">
<div class="loading-content">
<div class="spinner {% if size == 'sm' %}spinner-sm{% elif size == 'lg' %}spinner-lg{% endif %}"></div>
{% if text %}
<div class="loading-text mt-3 text-secondary text-sm">{{ text }}</div>
{% endif %}
</div>
</div>
{% else %}
<div class="loading-inline flex items-center gap-3">
<div class="spinner {% if size == 'sm' %}spinner-sm{% elif size == 'lg' %}spinner-lg{% endif %}"></div>
{% if text %}
<span class="text-secondary text-sm">{{ text }}</span>
{% endif %}
</div>
{% endif %}
+47
View File
@@ -0,0 +1,47 @@
{#
Stats Strip Component
Horizontal bar displaying key metrics
Variables:
- stats: List of stat objects with 'id', 'value', 'label', and optional 'title'
- show_divider: Show divider after stats (default: true)
- status_dot_id: Optional ID for status indicator dot
- status_text_id: Optional ID for status text
- time_id: Optional ID for time display
#}
<div class="stats-strip">
<div class="stats-strip-inner">
{% for stat in stats %}
<div class="strip-stat" {% if stat.title %}title="{{ stat.title }}"{% endif %}>
<span class="strip-value" id="{{ stat.id }}">{{ stat.value|default('0') }}</span>
<span class="strip-label">{{ stat.label }}</span>
</div>
{% endfor %}
{% if show_divider|default(true) %}
<div class="strip-divider"></div>
{% endif %}
{# Additional content from caller #}
{% if caller is defined %}
{{ caller() }}
{% endif %}
{% if status_dot_id or status_text_id %}
<div class="strip-divider"></div>
<div class="strip-status">
{% if status_dot_id %}
<div class="status-dot inactive" id="{{ status_dot_id }}"></div>
{% endif %}
{% if status_text_id %}
<span id="{{ status_text_id }}">STANDBY</span>
{% endif %}
</div>
{% endif %}
{% if time_id %}
<div class="strip-time" id="{{ time_id }}">--:--:-- UTC</div>
{% endif %}
</div>
</div>
+27
View File
@@ -0,0 +1,27 @@
{#
Status Badge Component
Compact status indicator with dot and text
Variables:
- status: 'online', 'offline', 'warning', 'error' (default: 'offline')
- text: Status text to display
- id: Optional ID for the text element (for JS updates)
- dot_id: Optional ID for the dot element (for JS updates)
- pulse: If true, adds pulse animation to dot
#}
{% set status_class = {
'online': 'online',
'active': 'online',
'offline': 'offline',
'warning': 'warning',
'error': 'error',
'inactive': 'inactive'
}.get(status|default('offline'), 'inactive') %}
<div class="status-badge flex items-center gap-2">
<div class="status-dot {{ status_class }}{% if pulse %} pulse{% endif %}"
{% if dot_id %}id="{{ dot_id }}"{% endif %}></div>
<span class="text-sm"
{% if id %}id="{{ id }}"{% endif %}>{{ text|default('Unknown') }}</span>
</div>
+3003 -287
View File
File diff suppressed because it is too large Load Diff

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