Compare commits

..

34 Commits

Author SHA1 Message Date
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
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
68 changed files with 14273 additions and 5021 deletions
+100
View File
@@ -2,6 +2,106 @@
All notable changes to iNTERCEPT will be documented in this file. 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 ## [2.12.0] - 2026-01-29
### Added ### Added
+29
View File
@@ -63,6 +63,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libcurl4-openssl-dev \ libcurl4-openssl-dev \
zlib1g-dev \ zlib1g-dev \
libzmq3-dev \ libzmq3-dev \
libpulse-dev \
libfftw3-dev \
liblapack-dev \
libcodec2-dev \
# Build dump1090 # Build dump1090
&& cd /tmp \ && cd /tmp \
&& git clone --depth 1 https://github.com/flightaware/dump1090.git \ && git clone --depth 1 https://github.com/flightaware/dump1090.git \
@@ -109,6 +113,27 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& make \ && make \
&& cp acarsdec /usr/bin/acarsdec \ && cp acarsdec /usr/bin/acarsdec \
&& rm -rf /tmp/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 # Cleanup build tools to reduce image size
&& apt-get remove -y \ && apt-get remove -y \
build-essential \ build-essential \
@@ -124,6 +149,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libcurl4-openssl-dev \ libcurl4-openssl-dev \
zlib1g-dev \ zlib1g-dev \
libzmq3-dev \ libzmq3-dev \
libpulse-dev \
libfftw3-dev \
liblapack-dev \
libcodec2-dev \
&& apt-get autoremove -y \ && apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
+5
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 - **Aircraft Tracking** - ADS-B via dump1090 with real-time map and radar
- **Vessel Tracking** - AIS ship tracking with VHF DSC distress monitoring - **Vessel Tracking** - AIS ship tracking with VHF DSC distress monitoring
- **ACARS Messaging** - Aircraft datalink messages via acarsdec - **ACARS Messaging** - Aircraft datalink messages via acarsdec
- **DMR Digital Voice** - DMR/P25/NXDN/D-STAR decoding via dsd-fme with visual synthesizer
- **Listening Post** - Frequency scanner with audio monitoring - **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 - **Satellite Tracking** - Pass prediction using TLE data
- **ADS-B History** - Persistent aircraft history with reporting dashboard (Postgres optional) - **ADS-B History** - Persistent aircraft history with reporting dashboard (Postgres optional)
- **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng - **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng
- **Bluetooth Scanning** - Device discovery and tracker detection (with Ubertooth support) - **Bluetooth Scanning** - Device discovery and tracker detection (with Ubertooth support)
- **TSCM** - Counter-surveillance with RF baseline comparison and threat detection
- **Meshtastic** - LoRa mesh network integration - **Meshtastic** - LoRa mesh network integration
- **Spy Stations** - Number stations and diplomatic HF network database - **Spy Stations** - Number stations and diplomatic HF network database
- **Remote Agents** - Distributed SIGINT with remote sensor nodes - **Remote Agents** - Distributed SIGINT with remote sensor nodes
+23 -2
View File
@@ -105,7 +105,7 @@ def inject_offline_settings():
'enabled': get_setting('offline.enabled', False), 'enabled': get_setting('offline.enabled', False),
'assets_source': get_setting('offline.assets_source', 'cdn'), 'assets_source': get_setting('offline.assets_source', 'cdn'),
'fonts_source': get_setting('offline.fonts_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', '') '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_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
dsc_lock = threading.Lock() 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 (Technical Surveillance Countermeasures)
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
tscm_lock = threading.Lock() tscm_lock = threading.Lock()
@@ -635,6 +641,7 @@ def health_check() -> Response:
'wifi': wifi_process is not None and (wifi_process.poll() is None if wifi_process else False), '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), '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), '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': { 'data': {
'aircraft_count': len(adsb_aircraft), 'aircraft_count': len(adsb_aircraft),
@@ -652,6 +659,7 @@ def kill_all() -> Response:
"""Kill all decoder, WiFi, and Bluetooth processes.""" """Kill all decoder, WiFi, and Bluetooth processes."""
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_process global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_process
global dmr_process, dmr_rtl_process
# Import adsb and ais modules to reset their state # Import adsb and ais modules to reset their state
from routes import adsb as adsb_module from routes import adsb as adsb_module
@@ -663,7 +671,7 @@ def kill_all() -> Response:
'rtl_fm', 'multimon-ng', 'rtl_433', 'rtl_fm', 'multimon-ng', 'rtl_433',
'airodump-ng', 'aireplay-ng', 'airmon-ng', 'airodump-ng', 'aireplay-ng', 'airmon-ng',
'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher', 'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher',
'hcitool', 'bluetoothctl' 'hcitool', 'bluetoothctl', 'dsd'
] ]
for proc in processes_to_kill: for proc in processes_to_kill:
@@ -707,6 +715,11 @@ def kill_all() -> Response:
dsc_process = None dsc_process = None
dsc_rtl_process = None dsc_rtl_process = None
# Reset DMR state
with dmr_lock:
dmr_process = None
dmr_rtl_process = None
# Reset Bluetooth state (legacy) # Reset Bluetooth state (legacy)
with bt_lock: with bt_lock:
if bt_process: if bt_process:
@@ -847,6 +860,14 @@ def main() -> None:
except ImportError as e: except ImportError as e:
print(f"WebSocket audio disabled (install flask-sock): {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}")
print(f"Open http://localhost:{args.port} in your browser") print(f"Open http://localhost:{args.port} in your browser")
print() print()
print("Press Ctrl+C to stop") print("Press Ctrl+C to stop")
+28 -8
View File
@@ -7,26 +7,42 @@ import os
import sys import sys
# Application version # Application version
VERSION = "2.13.1" VERSION = "2.14.0"
# Changelog - latest release notes (shown on welcome screen) # Changelog - latest release notes (shown on welcome screen)
CHANGELOG = [ 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", "version": "2.13.1",
"date": "February 2026", "date": "February 2026",
"highlights": [ "highlights": [
"Help modal system with keyboard shortcuts reference", "UI overhaul with slate/cyan theme and JetBrains Mono font",
"Main Dashboard button in navigation bar", "Signal scanner rewritten with rtl_power sweep and SNR filtering",
"Settings modal accessible from all dashboards", "Listening Post audio streaming via WAV with retry/fallback",
"Dashboard CSS improvements and consistency fixes", "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", "version": "2.13.0",
"date": "February 2026", "date": "February 2026",
"highlights": [ "highlights": [
"WiFi client display in AP detail drawer", "WiFi client display in AP detail drawer with real-time SSE updates",
"Real-time client updates via SSE streaming", "Help modal system with keyboard shortcuts reference",
"Global navbar and settings modal accessible from all dashboards",
"Probed SSID badges for connected clients", "Probed SSID badges for connected clients",
] ]
}, },
@@ -34,7 +50,11 @@ CHANGELOG = [
"version": "2.12.1", "version": "2.12.1",
"date": "February 2026", "date": "February 2026",
"highlights": [ "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",
] ]
}, },
{ {
+1
View File
@@ -0,0 +1 @@
www.intercept-sigint.com
+26 -3
View File
@@ -3122,6 +3122,7 @@ class ModeManager:
wifi_interface = params.get('wifi_interface') or params.get('interface') wifi_interface = params.get('wifi_interface') or params.get('interface')
bt_adapter = params.get('bt_interface') or params.get('adapter', 'hci0') bt_adapter = params.get('bt_interface') or params.get('adapter', 'hci0')
sdr_device = params.get('sdr_device', params.get('device', 0)) sdr_device = params.get('sdr_device', params.get('device', 0))
sweep_type = params.get('sweep_type')
# Get baseline_id for comparison (same as local mode) # Get baseline_id for comparison (same as local mode)
baseline_id = params.get('baseline_id') baseline_id = params.get('baseline_id')
@@ -3131,7 +3132,7 @@ class ModeManager:
# Start the combined TSCM scanner thread using existing Intercept functions # Start the combined TSCM scanner thread using existing Intercept functions
thread = threading.Thread( thread = threading.Thread(
target=self._tscm_scanner_thread, target=self._tscm_scanner_thread,
args=(scan_wifi, scan_bt, scan_rf, wifi_interface, bt_adapter, sdr_device, baseline_id), args=(scan_wifi, scan_bt, scan_rf, wifi_interface, bt_adapter, sdr_device, baseline_id, sweep_type),
daemon=True daemon=True
) )
thread.start() thread.start()
@@ -3154,7 +3155,7 @@ class ModeManager:
def _tscm_scanner_thread(self, scan_wifi: bool, scan_bt: bool, scan_rf: bool, def _tscm_scanner_thread(self, scan_wifi: bool, scan_bt: bool, scan_rf: bool,
wifi_interface: str | None, bt_adapter: str, sdr_device: int, wifi_interface: str | None, bt_adapter: str, sdr_device: int,
baseline_id: int | None = None): baseline_id: int | None = None, sweep_type: str | None = None):
"""Combined TSCM scanner using existing Intercept functions. """Combined TSCM scanner using existing Intercept functions.
NOTE: This matches local mode behavior exactly: NOTE: This matches local mode behavior exactly:
@@ -3170,6 +3171,15 @@ class ModeManager:
from routes.tscm import _scan_wifi_networks, _scan_bluetooth_devices, _scan_rf_signals from routes.tscm import _scan_wifi_networks, _scan_bluetooth_devices, _scan_rf_signals
logger.info("TSCM imports successful") 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) # Load baseline if specified (same as local mode)
baseline = None baseline = None
if baseline_id and HAS_BASELINE_DB and get_tscm_baseline: if baseline_id and HAS_BASELINE_DB and get_tscm_baseline:
@@ -3243,6 +3253,9 @@ class ModeManager:
profile = self._tscm_correlation.analyze_wifi_device(enriched) profile = self._tscm_correlation.analyze_wifi_device(enriched)
enriched['classification'] = profile.risk_level.value enriched['classification'] = profile.risk_level.value
enriched['score'] = profile.total_score enriched['score'] = profile.total_score
enriched['score_modifier'] = profile.score_modifier
enriched['known_device'] = profile.known_device
enriched['known_device_name'] = profile.known_device_name
enriched['indicators'] = [ enriched['indicators'] = [
{'type': i.type.value, 'desc': i.description} {'type': i.type.value, 'desc': i.description}
for i in profile.indicators for i in profile.indicators
@@ -3289,6 +3302,9 @@ class ModeManager:
profile = self._tscm_correlation.analyze_bluetooth_device(enriched) profile = self._tscm_correlation.analyze_bluetooth_device(enriched)
enriched['classification'] = profile.risk_level.value enriched['classification'] = profile.risk_level.value
enriched['score'] = profile.total_score enriched['score'] = profile.total_score
enriched['score_modifier'] = profile.score_modifier
enriched['known_device'] = profile.known_device
enriched['known_device_name'] = profile.known_device_name
enriched['indicators'] = [ enriched['indicators'] = [
{'type': i.type.value, 'desc': i.description} {'type': i.type.value, 'desc': i.description}
for i in profile.indicators for i in profile.indicators
@@ -3304,7 +3320,11 @@ class ModeManager:
try: try:
# Pass a stop check that uses our stop_event (not the module's _sweep_running) # Pass a stop check that uses our stop_event (not the module's _sweep_running)
agent_stop_check = lambda: stop_event and stop_event.is_set() agent_stop_check = lambda: stop_event and stop_event.is_set()
rf_signals = _scan_rf_signals(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 # Analyze each RF signal like local mode does
analyzed_signals = [] analyzed_signals = []
@@ -3328,6 +3348,9 @@ class ModeManager:
profile = self._tscm_correlation.analyze_rf_signal(signal) profile = self._tscm_correlation.analyze_rf_signal(signal)
analyzed['classification'] = profile.risk_level.value analyzed['classification'] = profile.risk_level.value
analyzed['score'] = profile.total_score analyzed['score'] = profile.total_score
analyzed['score_modifier'] = profile.score_modifier
analyzed['known_device'] = profile.known_device
analyzed['known_device_name'] = profile.known_device_name
analyzed['indicators'] = [ analyzed['indicators'] = [
{'type': i.type.value, 'desc': i.description} {'type': i.type.value, 'desc': i.description}
for i in profile.indicators for i in profile.indicators
+11 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "intercept" name = "intercept"
version = "2.13.1" version = "2.14.0"
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth" description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
readme = "README.md" readme = "README.md"
requires-python = ">=3.9" requires-python = ">=3.9"
@@ -33,6 +33,7 @@ dependencies = [
"flask-limiter>=2.5.4", "flask-limiter>=2.5.4",
"bleak>=0.21.0", "bleak>=0.21.0",
"flask-sock", "flask-sock",
"websocket-client>=1.6.0",
"requests>=2.28.0", "requests>=2.28.0",
] ]
@@ -52,6 +53,15 @@ dev = [
"types-flask>=1.1.0", "types-flask>=1.1.0",
] ]
optionals = [
"scipy>=1.10.0",
"qrcode[pil]>=7.4",
"numpy>=1.24.0",
"meshtastic>=2.0.0",
"psycopg2-binary>=2.9.9",
"scapy>=2.4.5",
]
[project.scripts] [project.scripts]
intercept = "intercept:main" intercept = "intercept:main"
+2
View File
@@ -35,4 +35,6 @@ qrcode[pil]>=7.4
# ruff>=0.1.0 # ruff>=0.1.0
# black>=23.0.0 # black>=23.0.0
# mypy>=1.0.0 # mypy>=1.0.0
# WebSocket support for in-app audio streaming (KiwiSDR, Listening Post)
flask-sock flask-sock
websocket-client>=1.6.0
+6
View File
@@ -26,6 +26,9 @@ def register_blueprints(app):
from .offline import offline_bp from .offline import offline_bp
from .updater import updater_bp from .updater import updater_bp
from .sstv import sstv_bp from .sstv import sstv_bp
from .sstv_general import sstv_general_bp
from .dmr import dmr_bp
from .websdr import websdr_bp
app.register_blueprint(pager_bp) app.register_blueprint(pager_bp)
app.register_blueprint(sensor_bp) app.register_blueprint(sensor_bp)
@@ -51,6 +54,9 @@ def register_blueprints(app):
app.register_blueprint(offline_bp) # Offline mode settings app.register_blueprint(offline_bp) # Offline mode settings
app.register_blueprint(updater_bp) # GitHub update checking app.register_blueprint(updater_bp) # GitHub update checking
app.register_blueprint(sstv_bp) # ISS SSTV decoder 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
# Initialize TSCM state with queue and lock from app # Initialize TSCM state with queue and lock from app
import app as app_module import app as app_module
+59 -5
View File
@@ -13,7 +13,7 @@ import tempfile
import threading import threading
import time import time
from datetime import datetime from datetime import datetime
from subprocess import DEVNULL, PIPE, STDOUT from subprocess import PIPE, STDOUT
from typing import Generator, Optional from typing import Generator, Optional
from flask import Blueprint, jsonify, request, Response from flask import Blueprint, jsonify, request, Response
@@ -31,6 +31,9 @@ from utils.constants import (
aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs') 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 by region (MHz)
APRS_FREQUENCIES = { APRS_FREQUENCIES = {
'north_america': '144.390', 'north_america': '144.390',
@@ -1301,7 +1304,7 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
This function reads from the decoder's stdout (text mode, line-buffered). This function reads from the decoder's stdout (text mode, line-buffered).
The decoder's stderr is merged into stdout (STDOUT) to avoid deadlocks. 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: Outputs two types of messages to the queue:
- type='aprs': Decoded APRS packets - type='aprs': Decoded APRS packets
@@ -1383,6 +1386,7 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
logger.error(f"APRS stream error: {e}") logger.error(f"APRS stream error: {e}")
app_module.aprs_queue.put({'type': 'error', 'message': str(e)}) app_module.aprs_queue.put({'type': 'error', 'message': str(e)})
finally: finally:
global aprs_active_device
app_module.aprs_queue.put({'type': 'status', 'status': 'stopped'}) app_module.aprs_queue.put({'type': 'status', 'status': 'stopped'})
# Cleanup processes # Cleanup processes
for proc in [rtl_process, decoder_process]: for proc in [rtl_process, decoder_process]:
@@ -1394,6 +1398,10 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
proc.kill() proc.kill()
except Exception: except Exception:
pass 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') @aprs_bp.route('/tools')
@@ -1441,6 +1449,7 @@ def get_stations() -> Response:
def start_aprs() -> Response: def start_aprs() -> Response:
"""Start APRS decoder.""" """Start APRS decoder."""
global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations
global aprs_active_device
with app_module.aprs_lock: with app_module.aprs_lock:
if app_module.aprs_process and app_module.aprs_process.poll() is None: if app_module.aprs_process and app_module.aprs_process.poll() is None:
@@ -1477,6 +1486,16 @@ def start_aprs() -> Response:
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return jsonify({'status': 'error', 'message': str(e)}), 400
# Reserve SDR device to prevent conflicts with other modes
error = app_module.reserve_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 # Get frequency for region
region = data.get('region', 'north_america') region = data.get('region', 'north_america')
frequency = APRS_FREQUENCIES.get(region, '144.390') frequency = APRS_FREQUENCIES.get(region, '144.390')
@@ -1552,15 +1571,25 @@ def start_aprs() -> Response:
try: try:
# Start rtl_fm with stdout piped to decoder. # 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. # NOTE: RTL-SDR Blog V4 may show offset-tuned frequency in logs - this is normal.
rtl_process = subprocess.Popen( rtl_process = subprocess.Popen(
rtl_cmd, rtl_cmd,
stdout=PIPE, stdout=PIPE,
stderr=DEVNULL, stderr=PIPE,
start_new_session=True 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. # Start decoder with stdin wired to rtl_fm's stdout.
# Use text mode with line buffering for reliable line-by-line reading. # Use text mode with line buffering for reliable line-by-line reading.
# Merge stderr into stdout to avoid blocking on unbuffered stderr. # Merge stderr into stdout to avoid blocking on unbuffered stderr.
@@ -1582,13 +1611,25 @@ def start_aprs() -> Response:
time.sleep(PROCESS_START_WAIT) time.sleep(PROCESS_START_WAIT)
if rtl_process.poll() is not None: 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})' 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) logger.error(error_msg)
try: try:
decoder_process.kill() decoder_process.kill()
except Exception: except Exception:
pass 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 return jsonify({'status': 'error', 'message': error_msg}), 500
if decoder_process.poll() is not None: if decoder_process.poll() is not None:
@@ -1602,6 +1643,9 @@ def start_aprs() -> Response:
rtl_process.kill() rtl_process.kill()
except Exception: except Exception:
pass 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 return jsonify({'status': 'error', 'message': error_msg}), 500
# Store references for status checks and cleanup # Store references for status checks and cleanup
@@ -1626,12 +1670,17 @@ def start_aprs() -> Response:
except Exception as e: except Exception as e:
logger.error(f"Failed to start APRS decoder: {e}") logger.error(f"Failed to start APRS decoder: {e}")
if aprs_active_device is not None:
app_module.release_sdr_device(aprs_active_device)
aprs_active_device = None
return jsonify({'status': 'error', 'message': str(e)}), 500 return jsonify({'status': 'error', 'message': str(e)}), 500
@aprs_bp.route('/stop', methods=['POST']) @aprs_bp.route('/stop', methods=['POST'])
def stop_aprs() -> Response: def stop_aprs() -> Response:
"""Stop APRS decoder.""" """Stop APRS decoder."""
global aprs_active_device
with app_module.aprs_lock: with app_module.aprs_lock:
processes_to_stop = [] processes_to_stop = []
@@ -1660,6 +1709,11 @@ def stop_aprs() -> Response:
if hasattr(app_module, 'aprs_rtl_process'): if hasattr(app_module, 'aprs_rtl_process'):
app_module.aprs_rtl_process = None app_module.aprs_rtl_process = None
# Release SDR device
if aprs_active_device is not None:
app_module.release_sdr_device(aprs_active_device)
aprs_active_device = None
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
+43
View File
@@ -17,6 +17,8 @@ import time
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Generator from typing import Generator
import requests
from flask import Blueprint, jsonify, request, Response from flask import Blueprint, jsonify, request, Response
from utils.database import ( from utils.database import (
@@ -480,6 +482,47 @@ def proxy_mode_data(agent_id: int, mode: str):
}), 502 }), 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']) @controller_bp.route('/agents/<int:agent_id>/wifi/monitor', methods=['POST'])
def proxy_wifi_monitor(agent_id: int): def proxy_wifi_monitor(agent_id: int):
"""Toggle monitor mode on a remote agent's WiFi interface.""" """Toggle monitor mode on a remote agent's WiFi interface."""
+403
View File
@@ -0,0 +1,403 @@
"""DMR / P25 / Digital Voice decoding routes."""
from __future__ import annotations
import os
import queue
import re
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.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."""
line = line.strip()
if not line:
return None
# 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': datetime.now().strftime('%H:%M:%S'),
}
# Talkgroup and Source: "TG: 12345 Src: 67890"
tg_match = re.match(r'.*TG:\s*(\d+)\s+Src:\s*(\d+)', line)
if tg_match:
return {
'type': 'call',
'talkgroup': int(tg_match.group(1)),
'source_id': int(tg_match.group(2)),
'timestamp': datetime.now().strftime('%H:%M:%S'),
}
# Slot info: "Slot 1" or "Slot 2"
slot_match = re.match(r'.*Slot\s*(\d+)', line)
if slot_match:
return {
'type': 'slot',
'slot': int(slot_match.group(1)),
'timestamp': datetime.now().strftime('%H:%M:%S'),
}
# DMR voice frame
if 'Voice' in line or 'voice' in line:
return {
'type': 'voice',
'detail': line,
'timestamp': datetime.now().strftime('%H:%M:%S'),
}
# P25 NAC (Network Access Code)
nac_match = re.match(r'.*NAC:\s*(\w+)', line)
if nac_match:
return {
'type': 'nac',
'nac': nac_match.group(1),
'timestamp': datetime.now().strftime('%H:%M:%S'),
}
return None
def stream_dsd_output(rtl_process: subprocess.Popen, dsd_process: subprocess.Popen):
"""Read DSD stderr output and push parsed events to the queue."""
global dmr_running
try:
dmr_queue.put_nowait({'type': 'status', 'text': 'started'})
while dmr_running:
if dsd_process.poll() is not None:
break
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:
try:
dmr_queue.put_nowait(parsed)
except queue.Full:
try:
dmr_queue.get_nowait()
except queue.Empty:
pass
try:
dmr_queue.put_nowait(parsed)
except queue.Full:
pass
except Exception as e:
logger.error(f"DSD stream error: {e}")
finally:
dmr_running = False
try:
dmr_queue.put_nowait({'type': 'status', 'text': 'stopped'})
except queue.Full:
pass
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,
)
dmr_dsd_process = subprocess.Popen(
dsd_cmd,
stdin=dmr_rtl_process.stdout,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
)
# 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 the most relevant error to the user
detail = rtl_err.strip() or dsd_err.strip()
msg = 'Failed to start DSD pipeline'
if detail:
msg += f': {detail}'
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
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
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()
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
+410 -53
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import math
import os import os
import queue import queue
import select import select
@@ -100,6 +101,17 @@ def find_ffmpeg() -> str | None:
return shutil.which('ffmpeg') return shutil.which('ffmpeg')
VALID_MODULATIONS = ['fm', 'wfm', 'am', 'usb', 'lsb']
def normalize_modulation(value: str) -> str:
"""Normalize and validate modulation string."""
mod = str(value or '').lower().strip()
if mod not in VALID_MODULATIONS:
raise ValueError(f'Invalid modulation. Use: {", ".join(VALID_MODULATIONS)}')
return mod
def add_activity_log(event_type: str, frequency: float, details: str = ''): def add_activity_log(event_type: str, frequency: float, details: str = ''):
@@ -287,6 +299,7 @@ def scanner_loop():
_start_audio_stream(current_freq, mod) _start_audio_stream(current_freq, mod)
try: try:
snr_db = round(10 * math.log10(rms / effective_threshold), 1) if rms > 0 and effective_threshold > 0 else 0.0
scanner_queue.put_nowait({ scanner_queue.put_nowait({
'type': 'signal_found', 'type': 'signal_found',
'frequency': current_freq, 'frequency': current_freq,
@@ -294,6 +307,7 @@ def scanner_loop():
'audio_streaming': True, 'audio_streaming': True,
'level': int(rms), 'level': int(rms),
'threshold': int(effective_threshold), 'threshold': int(effective_threshold),
'snr': snr_db,
'range_start': scanner_config['start_freq'], 'range_start': scanner_config['start_freq'],
'range_end': scanner_config['end_freq'] 'range_end': scanner_config['end_freq']
}) })
@@ -606,6 +620,7 @@ def scanner_loop_power():
'audio_streaming': False, 'audio_streaming': False,
'level': level, 'level': level,
'threshold': threshold, 'threshold': threshold,
'snr': round(snr, 1),
'range_start': scanner_config['start_freq'], 'range_start': scanner_config['start_freq'],
'range_end': scanner_config['end_freq'] 'range_end': scanner_config['end_freq']
}) })
@@ -720,42 +735,105 @@ def _start_audio_stream(frequency: float, modulation: str):
] ]
try: try:
# Use shell pipe for reliable streaming # Use subprocess piping for reliable streaming.
# Log stderr to temp files for error diagnosis # Log stderr to temp files for error diagnosis.
rtl_stderr_log = '/tmp/rtl_fm_stderr.log' rtl_stderr_log = '/tmp/rtl_fm_stderr.log'
ffmpeg_stderr_log = '/tmp/ffmpeg_stderr.log' ffmpeg_stderr_log = '/tmp/ffmpeg_stderr.log'
shell_cmd = f"{' '.join(sdr_cmd)} 2>{rtl_stderr_log} | {' '.join(encoder_cmd)} 2>{ffmpeg_stderr_log}"
logger.info(f"Starting audio: {frequency} MHz, mod={modulation}, device={scanner_config['device']}") logger.info(f"Starting audio: {frequency} MHz, mod={modulation}, device={scanner_config['device']}")
audio_rtl_process = None # Not used in shell mode # Retry loop for USB device contention (device may not be
audio_process = subprocess.Popen( # released immediately after a previous process exits)
shell_cmd, max_attempts = 3
shell=True, for attempt in range(max_attempts):
stdout=subprocess.PIPE, audio_rtl_process = None
stderr=subprocess.PIPE, audio_process = None
bufsize=0, rtl_err_handle = None
start_new_session=True # Create new process group for clean shutdown ffmpeg_err_handle = None
)
# Brief delay to check if process started successfully
time.sleep(0.3)
if audio_process.poll() is not None:
# Read stderr from temp files
rtl_stderr = ''
ffmpeg_stderr = ''
try: try:
with open(rtl_stderr_log, 'r') as f: rtl_err_handle = open(rtl_stderr_log, 'w')
rtl_stderr = f.read().strip() ffmpeg_err_handle = open(ffmpeg_stderr_log, 'w')
except: audio_rtl_process = subprocess.Popen(
pass sdr_cmd,
try: stdout=subprocess.PIPE,
with open(ffmpeg_stderr_log, 'r') as f: stderr=rtl_err_handle,
ffmpeg_stderr = f.read().strip() bufsize=0,
except: start_new_session=True # Create new process group for clean shutdown
pass )
logger.error(f"Audio pipeline exited immediately. rtl_fm stderr: {rtl_stderr}, ffmpeg stderr: {ffmpeg_stderr}") audio_process = subprocess.Popen(
return encoder_cmd,
stdin=audio_rtl_process.stdout,
stdout=subprocess.PIPE,
stderr=ffmpeg_err_handle,
bufsize=0,
start_new_session=True # Create new process group for clean shutdown
)
if audio_rtl_process.stdout:
audio_rtl_process.stdout.close()
finally:
if rtl_err_handle:
rtl_err_handle.close()
if ffmpeg_err_handle:
ffmpeg_err_handle.close()
# Brief delay to check if process started successfully
time.sleep(0.3)
if (audio_rtl_process and audio_rtl_process.poll() is not None) or (
audio_process and audio_process.poll() is not None
):
# Read stderr from temp files
rtl_stderr = ''
ffmpeg_stderr = ''
try:
with open(rtl_stderr_log, 'r') as f:
rtl_stderr = f.read().strip()
except Exception:
pass
try:
with open(ffmpeg_stderr_log, 'r') as f:
ffmpeg_stderr = f.read().strip()
except Exception:
pass
if 'usb_claim_interface' in rtl_stderr and attempt < max_attempts - 1:
logger.warning(f"USB device busy (attempt {attempt + 1}/{max_attempts}), waiting for release...")
if audio_process:
try:
audio_process.terminate()
audio_process.wait(timeout=0.5)
except Exception:
pass
if audio_rtl_process:
try:
audio_rtl_process.terminate()
audio_rtl_process.wait(timeout=0.5)
except Exception:
pass
time.sleep(1.0)
continue
if audio_process and audio_process.poll() is None:
try:
audio_process.terminate()
audio_process.wait(timeout=0.5)
except Exception:
pass
if audio_rtl_process and audio_rtl_process.poll() is None:
try:
audio_rtl_process.terminate()
audio_rtl_process.wait(timeout=0.5)
except Exception:
pass
audio_process = None
audio_rtl_process = None
logger.error(
f"Audio pipeline exited immediately. rtl_fm stderr: {rtl_stderr}, ffmpeg stderr: {ffmpeg_stderr}"
)
return
# Pipeline started successfully
break
# Validate that audio is producing data quickly # Validate that audio is producing data quickly
try: try:
@@ -788,33 +866,44 @@ def _stop_audio_stream_internal():
audio_running = False audio_running = False
audio_frequency = 0.0 audio_frequency = 0.0
# Kill the shell process and its children # Kill the pipeline processes and their groups
if audio_process: if audio_process:
try: try:
# Kill entire process group (rtl_fm, ffmpeg, shell) # Kill entire process group (SDR demod + ffmpeg)
try: try:
os.killpg(os.getpgid(audio_process.pid), signal.SIGKILL) os.killpg(os.getpgid(audio_process.pid), signal.SIGKILL)
except (ProcessLookupError, PermissionError): except (ProcessLookupError, PermissionError):
audio_process.kill() audio_process.kill()
audio_process.wait(timeout=0.5) audio_process.wait(timeout=0.5)
except: except Exception:
pass
if audio_rtl_process:
try:
try:
os.killpg(os.getpgid(audio_rtl_process.pid), signal.SIGKILL)
except (ProcessLookupError, PermissionError):
audio_rtl_process.kill()
audio_rtl_process.wait(timeout=0.5)
except Exception:
pass pass
audio_process = None audio_process = None
audio_rtl_process = None audio_rtl_process = None
# Kill any orphaned rtl_fm and ffmpeg processes # Kill any orphaned rtl_fm, rtl_power, and ffmpeg processes
try: for proc_pattern in ['rtl_fm', 'rtl_power']:
subprocess.run(['pkill', '-9', 'rtl_fm'], capture_output=True, timeout=0.5) try:
except: subprocess.run(['pkill', '-9', proc_pattern], capture_output=True, timeout=0.5)
pass except Exception:
pass
try: try:
subprocess.run(['pkill', '-9', '-f', 'ffmpeg.*pipe:0'], capture_output=True, timeout=0.5) subprocess.run(['pkill', '-9', '-f', 'ffmpeg.*pipe:0'], capture_output=True, timeout=0.5)
except: except Exception:
pass pass
# Pause for SDR device to be released (important for frequency/modulation changes) # Pause for SDR device USB interface to be released by kernel
time.sleep(0.7) time.sleep(1.0)
# ============================================ # ============================================
@@ -873,7 +962,7 @@ def start_scanner() -> Response:
scanner_config['start_freq'] = float(data.get('start_freq', 88.0)) scanner_config['start_freq'] = float(data.get('start_freq', 88.0))
scanner_config['end_freq'] = float(data.get('end_freq', 108.0)) scanner_config['end_freq'] = float(data.get('end_freq', 108.0))
scanner_config['step'] = float(data.get('step', 0.1)) scanner_config['step'] = float(data.get('step', 0.1))
scanner_config['modulation'] = str(data.get('modulation', 'wfm')).lower() scanner_config['modulation'] = normalize_modulation(data.get('modulation', 'wfm'))
scanner_config['squelch'] = int(data.get('squelch', 0)) scanner_config['squelch'] = int(data.get('squelch', 0))
scanner_config['dwell_time'] = float(data.get('dwell_time', 3.0)) scanner_config['dwell_time'] = float(data.get('dwell_time', 3.0))
scanner_config['scan_delay'] = float(data.get('scan_delay', 0.5)) scanner_config['scan_delay'] = float(data.get('scan_delay', 0.5))
@@ -1056,8 +1145,14 @@ def update_scanner_config() -> Response:
updated.append(f"dwell={data['dwell_time']}s") updated.append(f"dwell={data['dwell_time']}s")
if 'modulation' in data: if 'modulation' in data:
scanner_config['modulation'] = str(data['modulation']).lower() try:
updated.append(f"mod={data['modulation']}") scanner_config['modulation'] = normalize_modulation(data['modulation'])
updated.append(f"mod={data['modulation']}")
except (ValueError, TypeError) as e:
return jsonify({
'status': 'error',
'message': str(e)
}), 400
if updated: if updated:
logger.info(f"Scanner config updated: {', '.join(updated)}") logger.info(f"Scanner config updated: {', '.join(updated)}")
@@ -1179,7 +1274,7 @@ def start_audio() -> Response:
try: try:
frequency = float(data.get('frequency', 0)) frequency = float(data.get('frequency', 0))
modulation = str(data.get('modulation', 'wfm')).lower() modulation = normalize_modulation(data.get('modulation', 'wfm'))
squelch = int(data.get('squelch', 0)) squelch = int(data.get('squelch', 0))
gain = int(data.get('gain', 40)) gain = int(data.get('gain', 40))
device = int(data.get('device', 0)) device = int(data.get('device', 0))
@@ -1196,13 +1291,6 @@ def start_audio() -> Response:
'message': 'frequency is required' 'message': 'frequency is required'
}), 400 }), 400
valid_mods = ['fm', 'wfm', 'am', 'usb', 'lsb']
if modulation not in valid_mods:
return jsonify({
'status': 'error',
'message': f'Invalid modulation. Use: {", ".join(valid_mods)}'
}), 400
valid_sdr_types = ['rtlsdr', 'hackrf', 'airspy', 'limesdr', 'sdrplay'] valid_sdr_types = ['rtlsdr', 'hackrf', 'airspy', 'limesdr', 'sdrplay']
if sdr_type not in valid_sdr_types: if sdr_type not in valid_sdr_types:
return jsonify({ return jsonify({
@@ -1379,3 +1467,272 @@ def stream_audio() -> Response:
'Transfer-Encoding': 'chunked', 'Transfer-Encoding': 'chunked',
} }
) )
# ============================================
# SIGNAL IDENTIFICATION ENDPOINT
# ============================================
@listening_post_bp.route('/signal/guess', methods=['POST'])
def guess_signal() -> Response:
"""Identify a signal based on frequency, modulation, and other parameters."""
data = request.json or {}
freq_mhz = data.get('frequency_mhz')
if freq_mhz is None:
return jsonify({'status': 'error', 'message': 'frequency_mhz is required'}), 400
try:
freq_mhz = float(freq_mhz)
except (ValueError, TypeError):
return jsonify({'status': 'error', 'message': 'Invalid frequency_mhz'}), 400
if freq_mhz <= 0:
return jsonify({'status': 'error', 'message': 'frequency_mhz must be positive'}), 400
frequency_hz = int(freq_mhz * 1e6)
modulation = data.get('modulation')
bandwidth_hz = data.get('bandwidth_hz')
if bandwidth_hz is not None:
try:
bandwidth_hz = int(bandwidth_hz)
except (ValueError, TypeError):
bandwidth_hz = None
region = data.get('region', 'UK/EU')
try:
from utils.signal_guess import guess_signal_type_dict
result = guess_signal_type_dict(
frequency_hz=frequency_hz,
modulation=modulation,
bandwidth_hz=bandwidth_hz,
region=region,
)
return jsonify({'status': 'ok', **result})
except Exception as e:
logger.error(f"Signal guess error: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500
# ============================================
# WATERFALL / SPECTROGRAM ENDPOINTS
# ============================================
waterfall_process: Optional[subprocess.Popen] = None
waterfall_thread: Optional[threading.Thread] = None
waterfall_running = False
waterfall_lock = threading.Lock()
waterfall_queue: queue.Queue = queue.Queue(maxsize=200)
waterfall_active_device: Optional[int] = None
waterfall_config = {
'start_freq': 88.0,
'end_freq': 108.0,
'bin_size': 10000,
'gain': 40,
'device': 0,
}
def _waterfall_loop():
"""Continuous rtl_power sweep loop emitting waterfall data."""
global waterfall_running, waterfall_process
rtl_power_path = find_rtl_power()
if not rtl_power_path:
logger.error("rtl_power not found for waterfall")
waterfall_running = False
return
try:
while waterfall_running:
start_hz = int(waterfall_config['start_freq'] * 1e6)
end_hz = int(waterfall_config['end_freq'] * 1e6)
bin_hz = int(waterfall_config['bin_size'])
gain = waterfall_config['gain']
device = waterfall_config['device']
cmd = [
rtl_power_path,
'-f', f'{start_hz}:{end_hz}:{bin_hz}',
'-i', '0.5',
'-1',
'-g', str(gain),
'-d', str(device),
]
try:
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
waterfall_process = proc
stdout, _ = proc.communicate(timeout=15)
except subprocess.TimeoutExpired:
proc.kill()
stdout = b''
finally:
waterfall_process = None
if not waterfall_running:
break
if not stdout:
time.sleep(0.2)
continue
# Parse rtl_power CSV output
all_bins = []
sweep_start_hz = start_hz
sweep_end_hz = end_hz
for line in stdout.decode(errors='ignore').splitlines():
if not line or line.startswith('#'):
continue
parts = [p.strip() for p in line.split(',')]
start_idx = None
for i, tok in enumerate(parts):
try:
val = float(tok)
except ValueError:
continue
if val > 1e5:
start_idx = i
break
if start_idx is None or len(parts) < start_idx + 4:
continue
try:
seg_start = float(parts[start_idx])
seg_end = float(parts[start_idx + 1])
seg_bin = float(parts[start_idx + 2])
raw_values = []
for v in parts[start_idx + 3:]:
try:
raw_values.append(float(v))
except ValueError:
continue
if raw_values and raw_values[0] >= 0 and any(val < 0 for val in raw_values[1:]):
raw_values = raw_values[1:]
all_bins.extend(raw_values)
sweep_start_hz = min(sweep_start_hz, seg_start)
sweep_end_hz = max(sweep_end_hz, seg_end)
except ValueError:
continue
if all_bins:
msg = {
'type': 'waterfall_sweep',
'start_freq': sweep_start_hz / 1e6,
'end_freq': sweep_end_hz / 1e6,
'bins': all_bins,
'timestamp': datetime.now().isoformat(),
}
try:
waterfall_queue.put_nowait(msg)
except queue.Full:
try:
waterfall_queue.get_nowait()
except queue.Empty:
pass
try:
waterfall_queue.put_nowait(msg)
except queue.Full:
pass
time.sleep(0.1)
except Exception as e:
logger.error(f"Waterfall loop error: {e}")
finally:
waterfall_running = False
logger.info("Waterfall loop stopped")
@listening_post_bp.route('/waterfall/start', methods=['POST'])
def start_waterfall() -> Response:
"""Start the waterfall/spectrogram display."""
global waterfall_thread, waterfall_running, waterfall_config, waterfall_active_device
with waterfall_lock:
if waterfall_running:
return jsonify({'status': 'error', 'message': 'Waterfall already running'}), 409
if not find_rtl_power():
return jsonify({'status': 'error', 'message': 'rtl_power not found'}), 503
data = request.json or {}
try:
waterfall_config['start_freq'] = float(data.get('start_freq', 88.0))
waterfall_config['end_freq'] = float(data.get('end_freq', 108.0))
waterfall_config['bin_size'] = int(data.get('bin_size', 10000))
waterfall_config['gain'] = int(data.get('gain', 40))
waterfall_config['device'] = int(data.get('device', 0))
except (ValueError, TypeError) as e:
return jsonify({'status': 'error', 'message': f'Invalid parameter: {e}'}), 400
if waterfall_config['start_freq'] >= waterfall_config['end_freq']:
return jsonify({'status': 'error', 'message': 'start_freq must be less than end_freq'}), 400
# Clear stale queue
try:
while True:
waterfall_queue.get_nowait()
except queue.Empty:
pass
# Claim SDR device
error = app_module.claim_sdr_device(waterfall_config['device'], 'waterfall')
if error:
return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409
waterfall_active_device = waterfall_config['device']
waterfall_running = True
waterfall_thread = threading.Thread(target=_waterfall_loop, daemon=True)
waterfall_thread.start()
return jsonify({'status': 'started', 'config': waterfall_config})
@listening_post_bp.route('/waterfall/stop', methods=['POST'])
def stop_waterfall() -> Response:
"""Stop the waterfall display."""
global waterfall_running, waterfall_process, waterfall_active_device
waterfall_running = False
if waterfall_process and waterfall_process.poll() is None:
try:
waterfall_process.terminate()
waterfall_process.wait(timeout=1)
except Exception:
try:
waterfall_process.kill()
except Exception:
pass
waterfall_process = None
if waterfall_active_device is not None:
app_module.release_sdr_device(waterfall_active_device)
waterfall_active_device = None
return jsonify({'status': 'stopped'})
@listening_post_bp.route('/waterfall/stream')
def stream_waterfall() -> Response:
"""SSE stream for waterfall data."""
def generate() -> Generator[str, None, None]:
last_keepalive = time.time()
while True:
try:
msg = waterfall_queue.get(timeout=SSE_QUEUE_TIMEOUT)
last_keepalive = time.time()
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
+1 -1
View File
@@ -13,7 +13,7 @@ OFFLINE_DEFAULTS = {
'offline.enabled': False, 'offline.enabled': False,
'offline.assets_source': 'cdn', 'offline.assets_source': 'cdn',
'offline.fonts_source': 'cdn', 'offline.fonts_source': 'cdn',
'offline.tile_provider': 'openstreetmap', 'offline.tile_provider': 'cartodb_dark_cyan',
'offline.tile_server_url': '' 'offline.tile_server_url': ''
} }
+14 -14
View File
@@ -42,30 +42,30 @@ def _fetch_iss_realtime(observer_lat: Optional[float] = None, observer_lon: Opti
iss_alt = 420 # Default altitude in km iss_alt = 420 # Default altitude in km
source = None source = None
# Try primary API: Open Notify # Try primary API: Where The ISS At
try: 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: if response.status_code == 200:
data = response.json() data = response.json()
if data.get('message') == 'success': iss_lat = float(data['latitude'])
iss_lat = float(data['iss_position']['latitude']) iss_lon = float(data['longitude'])
iss_lon = float(data['iss_position']['longitude']) iss_alt = float(data.get('altitude', 420))
source = 'open-notify' source = 'wheretheiss'
except Exception as e: 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: if iss_lat is None:
try: 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: if response.status_code == 200:
data = response.json() data = response.json()
iss_lat = float(data['latitude']) if data.get('message') == 'success':
iss_lon = float(data['longitude']) iss_lat = float(data['iss_position']['latitude'])
iss_alt = float(data.get('altitude', 420)) iss_lon = float(data['iss_position']['longitude'])
source = 'wheretheiss' source = 'open-notify'
except Exception as e: 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: if iss_lat is None:
return None return None
+26 -26
View File
@@ -467,7 +467,32 @@ def iss_position():
observer_lat = request.args.get('latitude', type=float) observer_lat = request.args.get('latitude', type=float)
observer_lon = request.args.get('longitude', type=float) observer_lon = request.args.get('longitude', type=float)
# Try primary API: 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: try:
response = requests.get('http://api.open-notify.org/iss-now.json', timeout=5) response = requests.get('http://api.open-notify.org/iss-now.json', timeout=5)
if response.status_code == 200: if response.status_code == 200:
@@ -493,31 +518,6 @@ def iss_position():
except Exception as e: except Exception as e:
logger.warning(f"Open Notify API failed: {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 # Both APIs failed
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
+288
View File
@@ -0,0 +1,288 @@
"""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.sstv import (
DecodeProgress,
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(progress: DecodeProgress) -> None:
"""Callback to queue progress updates for SSE stream."""
try:
_sstv_general_queue.put_nowait(progress.to_dict())
except queue.Full:
try:
_sstv_general_queue.get_nowait()
_sstv_general_queue.put_nowait(progress.to_dict())
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 slowrx: apt install slowrx',
}), 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('/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()
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
+744 -219
View File
File diff suppressed because it is too large Load Diff
+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")
+23 -1
View File
@@ -1240,10 +1240,32 @@ def v2_get_networks():
@wifi_bp.route('/v2/clients') @wifi_bp.route('/v2/clients')
def v2_get_clients(): def v2_get_clients():
"""Get all discovered clients.""" """Get discovered clients with optional filtering."""
try: try:
scanner = get_wifi_scanner() scanner = get_wifi_scanner()
clients = scanner.clients 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({ return jsonify({
'clients': [c.to_dict() for c in clients], 'clients': [c.to_dict() for c in clients],
'total': len(clients), 'total': len(clients),
+230 -18
View File
@@ -210,6 +210,10 @@ check_tools() {
info "GPS:" info "GPS:"
check_required "gpsd" "GPS daemon" gpsd check_required "gpsd" "GPS daemon" gpsd
echo
info "Digital Voice:"
check_optional "dsd" "Digital Speech Decoder (DMR/P25)" dsd dsd-fme
echo echo
info "Audio:" info "Audio:"
check_required "ffmpeg" "Audio encoder/decoder" ffmpeg check_required "ffmpeg" "Audio encoder/decoder" ffmpeg
@@ -390,7 +394,6 @@ install_slowrx_from_source_macos() {
info "slowrx not available via Homebrew. Building from source..." info "slowrx not available via Homebrew. Building from source..."
# Ensure build dependencies are installed # Ensure build dependencies are installed
brew_install cmake
brew_install fftw brew_install fftw
brew_install libsndfile brew_install libsndfile
brew_install gtk+3 brew_install gtk+3
@@ -406,13 +409,8 @@ install_slowrx_from_source_macos() {
cd "$tmp_dir/slowrx" cd "$tmp_dir/slowrx"
info "Compiling slowrx..." info "Compiling slowrx..."
mkdir -p build && cd build # slowrx uses a plain Makefile, not CMake
local cmake_log make_log local make_log
cmake_log=$(cmake .. 2>&1) || {
warn "cmake failed for slowrx:"
echo "$cmake_log" | tail -20
exit 1
}
make_log=$(make 2>&1) || { make_log=$(make 2>&1) || {
warn "make failed for slowrx:" warn "make failed for slowrx:"
echo "$make_log" | tail -20 echo "$make_log" | tail -20
@@ -460,8 +458,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() { install_macos_packages() {
TOTAL_STEPS=16 TOTAL_STEPS=17
CURRENT_STEP=0 CURRENT_STEP=0
progress "Checking Homebrew" progress "Checking Homebrew"
@@ -481,11 +663,20 @@ install_macos_packages() {
progress "Installing direwolf (APRS decoder)" progress "Installing direwolf (APRS decoder)"
(brew_install direwolf) || warn "direwolf not available via Homebrew" (brew_install direwolf) || warn "direwolf not available via Homebrew"
progress "Installing slowrx (SSTV decoder)" progress "Skipping slowrx (SSTV decoder)"
if ! cmd_exists slowrx; then warn "slowrx requires ALSA (Linux-only) and cannot build on macOS. Skipping."
install_slowrx_from_source_macos || warn "slowrx build failed - ISS SSTV decoding will not be available"
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 else
ok "slowrx already installed" ok "DSD already installed"
fi fi
progress "Installing ffmpeg" progress "Installing ffmpeg"
@@ -509,14 +700,22 @@ install_macos_packages() {
fi fi
progress "Installing dump1090" 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" 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" progress "Installing AIS-catcher"
if ! cmd_exists AIS-catcher && ! cmd_exists aiscatcher; then if ! cmd_exists AIS-catcher && ! cmd_exists aiscatcher; then
(brew_install aiscatcher) || warn "AIS-catcher not available via Homebrew" (brew_install aiscatcher) || install_aiscatcher_from_source_macos || warn "AIS-catcher not available"
else else
ok "AIS-catcher already installed" ok "AIS-catcher already installed"
fi fi
@@ -849,7 +1048,7 @@ install_debian_packages() {
export NEEDRESTART_MODE=a export NEEDRESTART_MODE=a
fi fi
TOTAL_STEPS=21 TOTAL_STEPS=22
CURRENT_STEP=0 CURRENT_STEP=0
progress "Updating APT package lists" progress "Updating APT package lists"
@@ -906,7 +1105,20 @@ install_debian_packages() {
apt_install direwolf || true apt_install direwolf || true
progress "Installing slowrx (SSTV decoder)" progress "Installing slowrx (SSTV decoder)"
apt_install slowrx || cmd_exists slowrx || install_slowrx_from_source_debian apt_install slowrx || cmd_exists slowrx || install_slowrx_from_source_debian || warn "slowrx not available. ISS SSTV decoding will not be available."
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" progress "Installing ffmpeg"
apt_install ffmpeg apt_install ffmpeg
+58 -25
View File
@@ -5,25 +5,26 @@
} }
:root { :root {
--font-sans: 'Space Mono', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; --font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
--font-mono: 'Space Mono', 'Fira Code', 'Consolas', monospace; --font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
--bg-dark: #0a0c10; --bg-dark: #0b1118;
--bg-panel: #0f1218; --bg-panel: #101823;
--bg-card: #151a23; --bg-card: #151f2b;
--border-color: #1f2937; --border-color: #263246;
--border-glow: #4a9eff; --border-glow: #4aa3ff;
--text-primary: #e8eaed; --text-primary: #d7e0ee;
--text-secondary: #9ca3af; --text-secondary: #9fb0c7;
--text-dim: #4b5563; --text-dim: #6f7f94;
--accent-green: #22c55e; --accent-green: #38c180;
--accent-cyan: #4a9eff; --accent-cyan: #4aa3ff;
--accent-orange: #f59e0b; --accent-orange: #d6a85e;
--accent-red: #ef4444; --accent-red: #e25d5d;
--accent-yellow: #eab308; --accent-yellow: #e1c26b;
--accent-amber: #d4a853; --accent-amber: #d6a85e;
--grid-line: rgba(74, 158, 255, 0.08); --grid-line: rgba(74, 163, 255, 0.1);
--radar-cyan: #4a9eff; --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-bg: #0f1218; --radar-cyan: #4aa3ff;
--radar-bg: #101823;
} }
body { body {
@@ -42,9 +43,10 @@ body {
right: 0; right: 0;
bottom: 0; bottom: 0;
background-image: background-image:
var(--noise-image),
linear-gradient(var(--grid-line) 1px, transparent 1px), linear-gradient(var(--grid-line) 1px, transparent 1px),
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px); linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
background-size: 50px 50px; background-size: 40px 40px, 50px 50px, 50px 50px;
pointer-events: none; pointer-events: none;
z-index: 0; z-index: 0;
} }
@@ -57,10 +59,12 @@ body {
right: 0; right: 0;
height: 2px; height: 2px;
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent); background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
color: var(--accent-cyan);
animation: scan 6s linear infinite; animation: scan 6s linear infinite;
pointer-events: none; pointer-events: none;
z-index: 1000; z-index: 1000;
opacity: 0.3; opacity: 0.25;
box-shadow: 0 0 8px currentColor;
} }
@keyframes scan { @keyframes scan {
@@ -88,6 +92,18 @@ body {
min-height: 52px; 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) { @media (min-width: 768px) {
.header { .header {
padding: 12px 20px; padding: 12px 20px;
@@ -755,14 +771,31 @@ body {
grid-column: 1 / -1; grid-column: 1 / -1;
grid-row: 2; grid-row: 2;
display: flex; display: flex;
align-items: center; align-items: stretch !important;
flex-wrap: nowrap; flex-wrap: nowrap;
gap: 8px; gap: 8px;
padding: 8px 15px; padding: 8px 15px;
background: var(--bg-panel); background: var(--bg-panel);
border-top: 1px solid rgba(74, 158, 255, 0.3); border-top: 1px solid rgba(74, 158, 255, 0.3);
font-size: 11px; 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 { .controls-bar label {
@@ -1466,7 +1499,7 @@ body {
} }
.strip-report-btn { .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; border: none;
color: white; color: white;
padding: 8px 12px; padding: 8px 12px;
@@ -1769,7 +1802,7 @@ body {
} }
.strip-btn.primary { .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; border: none;
color: white; color: white;
} }
+32 -16
View File
@@ -5,20 +5,21 @@
} }
:root { :root {
--font-sans: 'Space Mono', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; --font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
--font-mono: 'Space Mono', 'Fira Code', 'Consolas', monospace; --font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
--bg-dark: #0a0c10; --bg-dark: #0b1118;
--bg-panel: #0f1218; --bg-panel: #101823;
--bg-card: #141a24; --bg-card: #151f2b;
--border-color: #1f2937; --border-color: #263246;
--border-glow: rgba(74, 158, 255, 0.6); --border-glow: rgba(74, 163, 255, 0.4);
--text-primary: #e8eaed; --text-primary: #d7e0ee;
--text-secondary: #9ca3af; --text-secondary: #9fb0c7;
--text-dim: #4b5563; --text-dim: #6f7f94;
--accent-cyan: #4a9eff; --accent-cyan: #4aa3ff;
--accent-green: #22c55e; --accent-green: #38c180;
--accent-amber: #d4a853; --accent-amber: #d6a85e;
--grid-line: rgba(74, 158, 255, 0.08); --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 { body {
@@ -36,9 +37,10 @@ body {
position: fixed; position: fixed;
inset: 0; inset: 0;
background-image: background-image:
var(--noise-image),
linear-gradient(var(--grid-line) 1px, transparent 1px), linear-gradient(var(--grid-line) 1px, transparent 1px),
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px); linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
background-size: 50px 50px; background-size: 40px 40px, 50px 50px, 50px 50px;
pointer-events: none; pointer-events: none;
z-index: 0; z-index: 0;
} }
@@ -50,10 +52,12 @@ body {
right: 0; right: 0;
height: 2px; height: 2px;
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent); background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
color: var(--accent-cyan);
animation: scan 6s linear infinite; animation: scan 6s linear infinite;
pointer-events: none; pointer-events: none;
z-index: 1; z-index: 1;
opacity: 0.3; opacity: 0.25;
box-shadow: 0 0 8px currentColor;
} }
@keyframes scan { @keyframes scan {
@@ -74,6 +78,18 @@ body {
gap: 12px; 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 { .logo {
font-size: 18px; font-size: 18px;
font-weight: 700; font-weight: 700;
+57 -24
View File
@@ -8,25 +8,26 @@
} }
:root { :root {
--font-sans: 'Space Mono', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; --font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
--font-mono: 'Space Mono', 'Fira Code', 'Consolas', monospace; --font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
--bg-dark: #0a0c10; --bg-dark: #0b1118;
--bg-panel: #0f1218; --bg-panel: #101823;
--bg-card: #151a23; --bg-card: #151f2b;
--border-color: #1f2937; --border-color: #263246;
--border-glow: #4a9eff; --border-glow: #4aa3ff;
--text-primary: #e8eaed; --text-primary: #d7e0ee;
--text-secondary: #9ca3af; --text-secondary: #9fb0c7;
--text-dim: #4b5563; --text-dim: #6f7f94;
--accent-green: #22c55e; --accent-green: #38c180;
--accent-cyan: #4a9eff; --accent-cyan: #4aa3ff;
--accent-orange: #f59e0b; --accent-orange: #d6a85e;
--accent-red: #ef4444; --accent-red: #e25d5d;
--accent-yellow: #eab308; --accent-yellow: #e1c26b;
--accent-amber: #d4a853; --accent-amber: #d6a85e;
--grid-line: rgba(74, 158, 255, 0.08); --grid-line: rgba(74, 163, 255, 0.1);
--radar-cyan: #4a9eff; --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-bg: #0f1218; --radar-cyan: #4aa3ff;
--radar-bg: #101823;
} }
body { body {
@@ -45,9 +46,10 @@ body {
right: 0; right: 0;
bottom: 0; bottom: 0;
background-image: background-image:
var(--noise-image),
linear-gradient(var(--grid-line) 1px, transparent 1px), linear-gradient(var(--grid-line) 1px, transparent 1px),
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px); linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
background-size: 50px 50px; background-size: 40px 40px, 50px 50px, 50px 50px;
pointer-events: none; pointer-events: none;
z-index: 0; z-index: 0;
} }
@@ -60,10 +62,12 @@ body {
right: 0; right: 0;
height: 2px; height: 2px;
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent); background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
color: var(--accent-cyan);
animation: scan 6s linear infinite; animation: scan 6s linear infinite;
pointer-events: none; pointer-events: none;
z-index: 1000; z-index: 1000;
opacity: 0.3; opacity: 0.25;
box-shadow: 0 0 8px currentColor;
} }
@keyframes scan { @keyframes scan {
@@ -91,6 +95,18 @@ body {
min-height: 52px; 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) { @media (min-width: 768px) {
.header { .header {
padding: 12px 20px; padding: 12px 20px;
@@ -355,7 +371,7 @@ body {
} }
.strip-btn.primary { .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; border: none;
color: white; color: white;
} }
@@ -670,14 +686,31 @@ body {
grid-column: 1 / -1; grid-column: 1 / -1;
grid-row: 2; grid-row: 2;
display: flex; display: flex;
align-items: center; align-items: stretch !important;
flex-wrap: nowrap; flex-wrap: nowrap;
gap: 8px; gap: 8px;
padding: 8px 15px; padding: 8px 15px;
background: var(--bg-panel); background: var(--bg-panel);
border-top: 1px solid rgba(74, 158, 255, 0.3); border-top: 1px solid rgba(74, 158, 255, 0.3);
font-size: 11px; 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 { .control-group {
+19 -8
View File
@@ -26,7 +26,15 @@ body {
font-size: var(--text-base); font-size: var(--text-base);
line-height: var(--leading-normal); line-height: var(--leading-normal);
color: var(--text-primary); color: var(--text-primary);
background: var(--bg-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; min-height: 100vh;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
@@ -39,6 +47,7 @@ h1, h2, h3, h4, h5, h6 {
font-weight: var(--font-semibold); font-weight: var(--font-semibold);
line-height: var(--leading-tight); line-height: var(--leading-tight);
color: var(--text-primary); color: var(--text-primary);
letter-spacing: 0.01em;
} }
h1 { font-size: var(--text-4xl); } h1 { font-size: var(--text-4xl); }
@@ -81,13 +90,15 @@ code, kbd, pre, samp {
} }
code { code {
background: var(--bg-tertiary); background: var(--bg-elevated);
border: 1px solid var(--border-color);
padding: 2px 6px; padding: 2px 6px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
} }
pre { pre {
background: var(--bg-tertiary); background: var(--bg-elevated);
border: 1px solid var(--border-color);
padding: var(--space-4); padding: var(--space-4);
border-radius: var(--radius-md); border-radius: var(--radius-md);
overflow-x: auto; overflow-x: auto;
@@ -125,7 +136,7 @@ button:disabled {
input, input,
select, select,
textarea { textarea {
background: var(--bg-tertiary); background: var(--bg-secondary);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: var(--radius-md); border-radius: var(--radius-md);
padding: var(--space-2) var(--space-3); padding: var(--space-2) var(--space-3);
@@ -138,7 +149,7 @@ select:focus,
textarea:focus { textarea:focus {
outline: none; outline: none;
border-color: var(--accent-cyan); border-color: var(--accent-cyan);
box-shadow: 0 0 0 3px var(--accent-cyan-dim); box-shadow: 0 0 0 2px var(--accent-cyan-dim);
} }
input::placeholder, input::placeholder,
@@ -149,7 +160,7 @@ textarea::placeholder {
select { select {
cursor: pointer; cursor: pointer;
appearance: none; 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='%239ca3af' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E"); 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-repeat: no-repeat;
background-position: right 8px center; background-position: right 8px center;
padding-right: 28px; padding-right: 28px;
@@ -191,14 +202,14 @@ td {
th { th {
font-weight: var(--font-semibold); font-weight: var(--font-semibold);
color: var(--text-secondary); color: var(--text-secondary);
background: var(--bg-secondary); background: var(--bg-tertiary);
text-transform: uppercase; text-transform: uppercase;
font-size: var(--text-xs); font-size: var(--text-xs);
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
tr:hover td { tr:hover td {
background: var(--bg-tertiary); background: var(--bg-elevated);
} }
/* ============================================ /* ============================================
+144 -25
View File
@@ -23,6 +23,9 @@
transition: all var(--transition-fast); transition: all var(--transition-fast);
white-space: nowrap; white-space: nowrap;
text-decoration: none; text-decoration: none;
font-family: var(--font-mono);
text-transform: uppercase;
letter-spacing: 0.06em;
} }
.btn:focus-visible { .btn:focus-visible {
@@ -40,6 +43,7 @@
background: var(--accent-cyan); background: var(--accent-cyan);
color: var(--text-inverse); color: var(--text-inverse);
border-color: var(--accent-cyan); border-color: var(--accent-cyan);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
} }
.btn-primary:hover:not(:disabled) { .btn-primary:hover:not(:disabled) {
@@ -48,24 +52,24 @@
} }
.btn-secondary { .btn-secondary {
background: var(--bg-tertiary); background: var(--bg-secondary);
color: var(--text-primary); color: var(--text-primary);
border-color: var(--border-color); border-color: var(--border-color);
} }
.btn-secondary:hover:not(:disabled) { .btn-secondary:hover:not(:disabled) {
background: var(--bg-elevated); background: var(--bg-tertiary);
border-color: var(--border-light); border-color: var(--border-light);
} }
.btn-ghost { .btn-ghost {
background: transparent; background: transparent;
color: var(--text-secondary); color: var(--text-secondary);
border-color: transparent; border-color: var(--border-color);
} }
.btn-ghost:hover:not(:disabled) { .btn-ghost:hover:not(:disabled) {
background: var(--bg-tertiary); background: var(--bg-elevated);
color: var(--text-primary); color: var(--text-primary);
} }
@@ -119,10 +123,11 @@
CARDS / PANELS CARDS / PANELS
============================================ */ ============================================ */
.card { .card {
background: var(--bg-card); background: linear-gradient(180deg, var(--bg-card) 0%, var(--bg-secondary) 100%);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
overflow: hidden; overflow: hidden;
box-shadow: var(--shadow-sm);
} }
.card-header { .card-header {
@@ -132,6 +137,7 @@
padding: var(--space-3) var(--space-4); padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary); background: var(--bg-secondary);
position: relative;
} }
.card-header-title { .card-header-title {
@@ -154,10 +160,29 @@
/* Panel variant (used in dashboards) */ /* Panel variant (used in dashboards) */
.panel { .panel {
background: var(--bg-card); background: linear-gradient(180deg, var(--bg-card) 0%, var(--bg-secondary) 100%);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
overflow: hidden; 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 { .panel-header {
@@ -172,6 +197,19 @@
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.1em; letter-spacing: 0.1em;
color: var(--text-secondary); 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 { .panel-indicator {
@@ -203,6 +241,7 @@
border-radius: var(--radius-full); border-radius: var(--radius-full);
background: var(--bg-tertiary); background: var(--bg-tertiary);
color: var(--text-secondary); color: var(--text-secondary);
border: 1px solid var(--border-color);
} }
.badge-primary { .badge-primary {
@@ -225,6 +264,49 @@
color: var(--accent-red); 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 INDICATORS
============================================ */ ============================================ */
@@ -239,12 +321,12 @@
.status-dot.online, .status-dot.online,
.status-dot.active { .status-dot.active {
background: var(--status-online); background: var(--status-online);
box-shadow: 0 0 6px var(--status-online); box-shadow: 0 0 4px var(--status-online);
} }
.status-dot.warning { .status-dot.warning {
background: var(--status-warning); background: var(--status-warning);
box-shadow: 0 0 6px var(--status-warning); box-shadow: 0 0 4px var(--status-warning);
} }
.status-dot.error, .status-dot.error,
@@ -571,6 +653,21 @@
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
color: var(--text-secondary); 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);
} }
/* ============================================ /* ============================================
@@ -578,14 +675,26 @@
============================================ */ ============================================ */
.divider { .divider {
height: 1px; height: 1px;
background: var(--border-color); background-image: repeating-linear-gradient(
90deg,
var(--border-light),
var(--border-light) 6px,
transparent 6px,
transparent 12px
);
margin: var(--space-4) 0; margin: var(--space-4) 0;
} }
.divider-vertical { .divider-vertical {
width: 1px; width: 1px;
height: 100%; height: 100%;
background: var(--border-color); background-image: repeating-linear-gradient(
180deg,
var(--border-light),
var(--border-light) 6px,
transparent 6px,
transparent 12px
);
margin: 0 var(--space-3); margin: 0 var(--space-3);
} }
@@ -595,13 +704,11 @@
/* Button hover lift */ /* Button hover lift */
.btn:hover:not(:disabled) { .btn:hover:not(:disabled) {
transform: translateY(-1px); box-shadow: 0 0 0 1px var(--border-light);
box-shadow: var(--shadow-md);
} }
.btn:active:not(:disabled) { .btn:active:not(:disabled) {
transform: translateY(0); box-shadow: inset 0 0 0 1px var(--border-light);
box-shadow: var(--shadow-sm);
} }
/* Card/Panel hover effects */ /* Card/Panel hover effects */
@@ -637,10 +744,10 @@
@keyframes statusGlow { @keyframes statusGlow {
0%, 100% { 0%, 100% {
box-shadow: 0 0 6px var(--status-online); box-shadow: 0 0 4px var(--status-online);
} }
50% { 50% {
box-shadow: 0 0 12px var(--status-online), 0 0 20px var(--status-online); box-shadow: 0 0 8px var(--status-online), 0 0 12px var(--status-online);
} }
} }
@@ -650,7 +757,7 @@
} }
.badge:hover { .badge:hover {
transform: scale(1.05); transform: scale(1.02);
} }
/* Alert entrance animation */ /* Alert entrance animation */
@@ -684,21 +791,33 @@ input:focus,
select:focus, select:focus,
textarea:focus { textarea:focus {
border-color: var(--accent-cyan); border-color: var(--accent-cyan);
box-shadow: 0 0 0 3px var(--accent-cyan-dim), 0 0 20px rgba(74, 158, 255, 0.1); box-shadow: 0 0 0 2px var(--accent-cyan-dim);
} }
/* Nav item active indicator */ /* Nav item active indicator */
.nav-item,
.mode-nav-btn,
.mobile-nav-btn {
position: relative;
}
.nav-item.active::after,
.mode-nav-btn.active::after, .mode-nav-btn.active::after,
.mobile-nav-btn.active::after { .mobile-nav-btn.active::after {
content: ''; content: '';
position: absolute; position: absolute;
bottom: -2px; left: 12%;
left: 50%; right: 12%;
transform: translateX(-50%); bottom: 2px;
width: 60%; height: 1px;
height: 2px; background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
background: currentColor; opacity: 0.75;
border-radius: var(--radius-full); animation: railPulse 2.6s ease-in-out infinite;
}
@keyframes railPulse {
0%, 100% { opacity: 0.45; }
50% { opacity: 0.9; }
} }
/* Smooth tooltip appearance */ /* Smooth tooltip appearance */
+72 -8
View File
@@ -28,11 +28,24 @@
justify-content: space-between; justify-content: space-between;
height: var(--header-height); height: var(--header-height);
padding: 0 var(--space-4); padding: 0 var(--space-4);
background: var(--bg-secondary); background: linear-gradient(180deg, var(--bg-elevated) 0%, var(--bg-secondary) 100%);
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
position: sticky; position: sticky;
top: 0; top: 0;
z-index: var(--z-sticky); z-index: var(--z-sticky);
box-shadow: var(--shadow-sm);
}
.app-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;
} }
.app-header-left { .app-header-left {
@@ -119,12 +132,25 @@
.app-nav { .app-nav {
display: flex; display: flex;
align-items: center; align-items: center;
background: var(--bg-tertiary); background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
padding: 0 var(--space-4); padding: 0 var(--space-4);
height: var(--nav-height); height: var(--nav-height);
gap: var(--space-1); gap: var(--space-1);
overflow-x: auto; overflow-x: auto;
position: relative;
}
.app-nav::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 1px;
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
opacity: 0.5;
pointer-events: none;
} }
.app-nav::-webkit-scrollbar { .app-nav::-webkit-scrollbar {
@@ -181,7 +207,7 @@
top: 100%; top: 100%;
left: 0; left: 0;
min-width: 180px; min-width: 180px;
background: var(--bg-card); background: var(--bg-elevated);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: var(--radius-md); border-radius: var(--radius-md);
box-shadow: var(--shadow-lg); box-shadow: var(--shadow-lg);
@@ -275,11 +301,24 @@
============================================ */ ============================================ */
.mobile-nav { .mobile-nav {
display: none; display: none;
background: var(--bg-tertiary); background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
padding: var(--space-2) var(--space-3); padding: var(--space-2) var(--space-3);
overflow-x: auto; overflow-x: auto;
gap: var(--space-2); gap: var(--space-2);
position: relative;
}
.mobile-nav::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 1px;
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
opacity: 0.45;
pointer-events: none;
} }
.mobile-nav::-webkit-scrollbar { .mobile-nav::-webkit-scrollbar {
@@ -359,7 +398,7 @@
/* Sidebar */ /* Sidebar */
.app-sidebar { .app-sidebar {
width: var(--sidebar-width); width: var(--sidebar-width);
background: var(--bg-secondary); background: linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-tertiary) 100%);
border-right: 1px solid var(--border-color); border-right: 1px solid var(--border-color);
overflow-y: auto; overflow-y: auto;
flex-shrink: 0; flex-shrink: 0;
@@ -413,9 +452,22 @@
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: var(--space-2) var(--space-4); padding: var(--space-2) var(--space-4);
background: var(--bg-secondary); background: linear-gradient(180deg, var(--bg-elevated) 0%, var(--bg-secondary) 100%);
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
flex-shrink: 0; flex-shrink: 0;
position: relative;
}
.dashboard-header::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
opacity: 0.55;
pointer-events: none;
} }
.dashboard-header-logo { .dashboard-header-logo {
@@ -445,7 +497,7 @@
.dashboard-sidebar { .dashboard-sidebar {
width: 320px; width: 320px;
background: var(--bg-secondary); background: linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-tertiary) 100%);
border-left: 1px solid var(--border-color); border-left: 1px solid var(--border-color);
overflow-y: auto; overflow-y: auto;
display: flex; display: flex;
@@ -589,13 +641,25 @@
/* Mode Navigation Bar */ /* Mode Navigation Bar */
.mode-nav { .mode-nav {
display: none; display: none;
background: #151a23 !important; /* Explicit color - forced to ensure consistency */ background: var(--bg-secondary) !important; /* Explicit color - forced to ensure consistency */
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
padding: 0 20px; padding: 0 20px;
position: relative; position: relative;
z-index: 100; z-index: 100;
} }
.mode-nav::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 1px;
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
opacity: 0.5;
pointer-events: none;
}
@media (min-width: 1024px) { @media (min-width: 1024px) {
.mode-nav { .mode-nav {
display: flex; display: flex;
+75 -66
View File
@@ -10,51 +10,56 @@
============================================ */ ============================================ */
/* Backgrounds - layered depth system */ /* Backgrounds - layered depth system */
--bg-primary: #0a0c10; --bg-primary: #0b1118;
--bg-secondary: #0f1218; --bg-secondary: #101823;
--bg-tertiary: #151a23; --bg-tertiary: #151f2b;
--bg-card: #121620; --bg-card: #121a25;
--bg-elevated: #1a202c; --bg-elevated: #1b2734;
--bg-overlay: rgba(0, 0, 0, 0.7); --bg-overlay: rgba(8, 13, 20, 0.75);
/* Background aliases for components */ /* Background aliases for components */
--bg-dark: var(--bg-primary); --bg-dark: var(--bg-primary);
--bg-panel: var(--bg-secondary); --bg-panel: var(--bg-secondary);
/* Accent colors */ /* Accent colors */
--accent-cyan: #4a9eff; --accent-cyan: #4aa3ff;
--accent-cyan-dim: rgba(74, 158, 255, 0.15); --accent-cyan-dim: rgba(74, 163, 255, 0.16);
--accent-cyan-hover: #6bb3ff; --accent-cyan-hover: #6bb3ff;
--accent-green: #22c55e; --accent-green: #38c180;
--accent-green-dim: rgba(34, 197, 94, 0.15); --accent-green-dim: rgba(56, 193, 128, 0.18);
--accent-red: #ef4444; --accent-red: #e25d5d;
--accent-red-dim: rgba(239, 68, 68, 0.15); --accent-red-dim: rgba(226, 93, 93, 0.16);
--accent-orange: #f59e0b; --accent-orange: #d6a85e;
--accent-orange-dim: rgba(245, 158, 11, 0.15); --accent-orange-dim: rgba(214, 168, 94, 0.16);
--accent-amber: #d4a853; --accent-amber: #d6a85e;
--accent-amber-dim: rgba(212, 168, 83, 0.15); --accent-amber-dim: rgba(214, 168, 94, 0.18);
--accent-yellow: #eab308; --accent-yellow: #e1c26b;
--accent-purple: #a855f7; --accent-purple: #8f7bd6;
/* Text hierarchy */ /* Text hierarchy */
--text-primary: #e8eaed; --text-primary: #d7e0ee;
--text-secondary: #9ca3af; --text-secondary: #9fb0c7;
--text-dim: #4b5563; --text-dim: #6f7f94;
--text-muted: #374151; --text-muted: #445266;
--text-inverse: #0a0c10; --text-inverse: #0b1118;
/* Borders */ /* Borders */
--border-color: #1f2937; --border-color: #263246;
--border-light: #374151; --border-light: #354458;
--border-glow: rgba(74, 158, 255, 0.2); --border-glow: rgba(74, 163, 255, 0.25);
--border-focus: var(--accent-cyan); --border-focus: var(--accent-cyan);
/* Status colors */ /* Status colors */
--status-online: #22c55e; --status-online: #38c180;
--status-warning: #f59e0b; --status-warning: #d6a85e;
--status-error: #ef4444; --status-error: #e25d5d;
--status-offline: #6b7280; --status-offline: #6f7f94;
--status-info: #3b82f6; --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 SPACING SCALE
@@ -73,8 +78,8 @@
/* ============================================ /* ============================================
TYPOGRAPHY TYPOGRAPHY
============================================ */ ============================================ */
--font-sans: 'Space Mono', ui-monospace, 'SF Mono', monospace; --font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
--font-mono: 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', monospace; --font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
/* Font sizes */ /* Font sizes */
--text-xs: 10px; --text-xs: 10px;
@@ -100,19 +105,19 @@
/* ============================================ /* ============================================
BORDERS & RADIUS BORDERS & RADIUS
============================================ */ ============================================ */
--radius-sm: 4px; --radius-sm: 3px;
--radius-md: 6px; --radius-md: 4px;
--radius-lg: 8px; --radius-lg: 6px;
--radius-xl: 12px; --radius-xl: 8px;
--radius-full: 9999px; --radius-full: 9999px;
/* ============================================ /* ============================================
SHADOWS SHADOWS
============================================ */ ============================================ */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); --shadow-sm: 0 1px 1px rgba(0, 0, 0, 0.35);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4); --shadow-md: 0 4px 8px rgba(0, 0, 0, 0.35);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.5); --shadow-lg: 0 12px 18px rgba(0, 0, 0, 0.45);
--shadow-glow: 0 0 20px rgba(74, 158, 255, 0.15); --shadow-glow: 0 0 18px rgba(74, 163, 255, 0.16);
/* ============================================ /* ============================================
TRANSITIONS TRANSITIONS
@@ -147,43 +152,47 @@
LIGHT THEME OVERRIDES LIGHT THEME OVERRIDES
============================================ */ ============================================ */
[data-theme="light"] { [data-theme="light"] {
--bg-primary: #f8fafc; --bg-primary: #f4f7fb;
--bg-secondary: #f1f5f9; --bg-secondary: #e9eef5;
--bg-tertiary: #e2e8f0; --bg-tertiary: #dde5f0;
--bg-card: #ffffff; --bg-card: #ffffff;
--bg-elevated: #f8fafc; --bg-elevated: #f1f4f9;
--bg-overlay: rgba(255, 255, 255, 0.9); --bg-overlay: rgba(244, 247, 251, 0.92);
/* Background aliases for components */ /* Background aliases for components */
--bg-dark: var(--bg-primary); --bg-dark: var(--bg-primary);
--bg-panel: var(--bg-secondary); --bg-panel: var(--bg-secondary);
--accent-cyan: #2563eb; --accent-cyan: #1f5fa8;
--accent-cyan-dim: rgba(37, 99, 235, 0.1); --accent-cyan-dim: rgba(31, 95, 168, 0.12);
--accent-cyan-hover: #1d4ed8; --accent-cyan-hover: #2c73bf;
--accent-green: #16a34a; --accent-green: #1f8a57;
--accent-green-dim: rgba(22, 163, 74, 0.1); --accent-green-dim: rgba(31, 138, 87, 0.12);
--accent-red: #dc2626; --accent-red: #c74444;
--accent-red-dim: rgba(220, 38, 38, 0.1); --accent-red-dim: rgba(199, 68, 68, 0.12);
--accent-orange: #d97706; --accent-orange: #b5863a;
--accent-orange-dim: rgba(217, 119, 6, 0.1); --accent-orange-dim: rgba(181, 134, 58, 0.12);
--accent-amber: #b45309; --accent-amber: #b5863a;
--accent-amber-dim: rgba(180, 83, 9, 0.1); --accent-amber-dim: rgba(181, 134, 58, 0.12);
--text-primary: #0f172a; --text-primary: #122034;
--text-secondary: #475569; --text-secondary: #3a4a5f;
--text-dim: #94a3b8; --text-dim: #6b7c93;
--text-muted: #cbd5e1; --text-muted: #aab6c8;
--text-inverse: #f8fafc; --text-inverse: #f4f7fb;
--border-color: #e2e8f0; --border-color: #d1d9e6;
--border-light: #cbd5e1; --border-light: #c1ccdb;
--border-glow: rgba(37, 99, 235, 0.15); --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-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1); --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.15); --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.15);
--shadow-glow: 0 0 20px rgba(37, 99, 235, 0.1); --shadow-glow: 0 0 18px rgba(31, 95, 168, 0.1);
} }
/* ============================================ /* ============================================
+65 -48
View File
@@ -5,63 +5,72 @@
} }
:root { :root {
--font-sans: 'Space Mono', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; --font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
--font-mono: 'Space Mono', 'Fira Code', 'Consolas', monospace; --font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
/* Tactical dark palette */ /* Tactical dark palette */
--bg-primary: #0a0c10; --bg-primary: #0b1118;
--bg-secondary: #0f1218; --bg-secondary: #101823;
--bg-tertiary: #151a23; --bg-tertiary: #151f2b;
--bg-card: #121620; --bg-card: #121a25;
--bg-elevated: #1a202c; --bg-elevated: #1b2734;
/* Accent colors - sophisticated blue/amber */ /* Accent colors - slate/cyan */
--accent-cyan: #4a9eff; --accent-cyan: #4aa3ff;
--accent-cyan-dim: rgba(74, 158, 255, 0.15); --accent-cyan-dim: rgba(74, 163, 255, 0.16);
--accent-green: #22c55e; --accent-green: #38c180;
--accent-green-dim: rgba(34, 197, 94, 0.15); --accent-green-dim: rgba(56, 193, 128, 0.18);
--accent-red: #ef4444; --accent-red: #e25d5d;
--accent-red-dim: rgba(239, 68, 68, 0.15); --accent-red-dim: rgba(226, 93, 93, 0.16);
--accent-orange: #f59e0b; --accent-orange: #d6a85e;
--accent-amber: #d4a853; --accent-amber: #d6a85e;
--accent-amber-dim: rgba(212, 168, 83, 0.15); --accent-amber-dim: rgba(214, 168, 94, 0.18);
/* Text hierarchy */ /* Text hierarchy */
--text-primary: #e8eaed; --text-primary: #d7e0ee;
--text-secondary: #9ca3af; --text-secondary: #9fb0c7;
--text-dim: #4b5563; --text-dim: #6f7f94;
--text-muted: #374151; --text-muted: #445266;
/* Borders */ /* Borders */
--border-color: #1f2937; --border-color: #263246;
--border-light: #374151; --border-light: #354458;
--border-glow: rgba(74, 158, 255, 0.2); --border-glow: rgba(74, 163, 255, 0.25);
/* Status colors */ /* Status colors */
--status-online: #22c55e; --status-online: #38c180;
--status-warning: #f59e0b; --status-warning: #d6a85e;
--status-error: #ef4444; --status-error: #e25d5d;
--status-offline: #6b7280; --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"] { [data-theme="light"] {
--bg-primary: #f8fafc; --bg-primary: #f4f7fb;
--bg-secondary: #f1f5f9; --bg-secondary: #e9eef5;
--bg-tertiary: #e2e8f0; --bg-tertiary: #dde5f0;
--bg-card: #ffffff; --bg-card: #ffffff;
--bg-elevated: #f8fafc; --bg-elevated: #f1f4f9;
--accent-cyan: #2563eb; --accent-cyan: #1f5fa8;
--accent-cyan-dim: rgba(37, 99, 235, 0.1); --accent-cyan-dim: rgba(31, 95, 168, 0.12);
--accent-green: #16a34a; --accent-green: #1f8a57;
--accent-red: #dc2626; --accent-red: #c74444;
--accent-orange: #d97706; --accent-orange: #b5863a;
--accent-amber: #b45309; --accent-amber: #b5863a;
--text-primary: #0f172a; --text-primary: #122034;
--text-secondary: #475569; --text-secondary: #3a4a5f;
--text-dim: #94a3b8; --text-dim: #6b7c93;
--text-muted: #cbd5e1; --text-muted: #aab6c8;
--border-color: #e2e8f0; --border-color: #d1d9e6;
--border-light: #cbd5e1; --border-light: #c1ccdb;
--border-glow: rgba(37, 99, 235, 0.15); --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 { [data-theme="light"] body {
@@ -74,7 +83,15 @@
body { body {
font-family: var(--font-sans); font-family: var(--font-sans);
background: var(--bg-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;
color: var(--text-primary); color: var(--text-primary);
min-height: 100vh; min-height: 100vh;
font-size: 14px; font-size: 14px;
@@ -108,8 +125,8 @@ body {
right: 0; right: 0;
bottom: 0; bottom: 0;
background: background:
radial-gradient(circle at 50% 50%, rgba(74, 158, 255, 0.03) 0%, transparent 50%), radial-gradient(circle at 50% 50%, rgba(74, 163, 255, 0.04) 0%, transparent 50%),
linear-gradient(180deg, transparent 0%, rgba(0, 212, 255, 0.02) 100%); linear-gradient(180deg, transparent 0%, rgba(74, 163, 255, 0.03) 100%);
pointer-events: none; pointer-events: none;
} }
+477
View File
@@ -0,0 +1,477 @@
/**
* 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 {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
overflow: hidden;
transition: all 0.15s ease;
cursor: pointer;
}
.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-preview {
width: 100%;
aspect-ratio: 4/3;
object-fit: cover;
background: #000;
display: block;
}
.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;
}
/* ============================================
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-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;
}
.sstv-general-modal-close:hover {
opacity: 1;
}
/* ============================================
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; }
}
+213 -5
View File
@@ -45,6 +45,7 @@
padding: 12px; padding: 12px;
background: rgba(0,0,0,0.3); background: rgba(0,0,0,0.3);
border-radius: 8px; border-radius: 8px;
flex-wrap: wrap;
} }
.tscm-threat-banner .threat-card { .tscm-threat-banner .threat-card {
flex: 1; flex: 1;
@@ -68,11 +69,6 @@
min-height: 200px; min-height: 200px;
height: 200px; height: 200px;
} }
/* Full-width panels (like Detected Threats) get more height */
.tscm-panel[style*="grid-column: span 2"] {
min-height: 150px;
height: 150px;
}
.tscm-panel-header { .tscm-panel-header {
padding: 10px 12px; padding: 10px 12px;
background: rgba(0,0,0,0.3); background: rgba(0,0,0,0.3);
@@ -200,6 +196,17 @@
margin-left: 6px; margin-left: 6px;
font-size: 10px; font-size: 10px;
} }
.known-badge {
margin-left: 6px;
font-size: 9px;
padding: 1px 4px;
border-radius: 3px;
background: rgba(0, 255, 136, 0.2);
color: #00ff88;
border: 1px solid rgba(0, 255, 136, 0.4);
text-transform: uppercase;
letter-spacing: 0.4px;
}
.tscm-device-header { .tscm-device-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -465,6 +472,18 @@
color: var(--text-dim); color: var(--text-dim);
width: 40%; width: 40%;
} }
.device-detail-id {
display: inline-block;
margin-left: 6px;
font-size: 10px;
color: var(--text-muted);
font-family: var(--font-mono);
}
.tscm-more-hint {
margin-top: 6px;
font-size: 10px;
color: var(--text-muted);
}
.indicator-list { .indicator-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -882,6 +901,42 @@
margin-left: auto; margin-left: auto;
} }
/* Filters */
.tscm-filter-bar {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 8px 12px;
margin-bottom: 12px;
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
align-items: flex-end;
}
.tscm-filter-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.tscm-filter-group label {
font-size: 9px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.6px;
}
.tscm-filter-group select {
background: rgba(0, 0, 0, 0.4);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 4px 6px;
font-size: 10px;
}
.tscm-filter-status {
margin-left: auto;
font-size: 10px;
color: var(--text-muted);
}
/* Advanced Modal Styles */ /* Advanced Modal Styles */
.tscm-advanced-modal { .tscm-advanced-modal {
max-width: 600px; max-width: 600px;
@@ -1461,3 +1516,156 @@
width: 10px; width: 10px;
height: 10px; height: 10px;
} }
/* Meeting banner actions */
.tscm-meeting-banner {
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.tscm-meeting-banner .meeting-actions {
margin-left: auto;
}
/* Case linking */
.tscm-case-link-banner {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
padding: 8px 10px;
margin-bottom: 10px;
background: rgba(74, 158, 255, 0.12);
border: 1px solid rgba(74, 158, 255, 0.3);
border-radius: 6px;
font-size: 11px;
}
.case-actions {
margin-top: 8px;
}
.tscm-case-link-btn {
margin-left: auto;
font-size: 9px;
padding: 2px 6px;
background: rgba(74, 158, 255, 0.2);
color: #9ed0ff;
border: 1px solid rgba(74, 158, 255, 0.4);
border-radius: 3px;
cursor: pointer;
}
/* Schedules */
.tscm-schedule-form {
display: grid;
gap: 10px;
}
.tscm-schedule-list {
display: grid;
gap: 10px;
}
.tscm-schedule-item {
padding: 10px;
border-radius: 6px;
background: rgba(0, 0, 0, 0.2);
border: 1px solid var(--border-color);
}
.tscm-schedule-item.enabled {
border-color: rgba(0, 255, 136, 0.35);
}
.tscm-schedule-item.disabled {
opacity: 0.7;
}
.tscm-schedule-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.tscm-schedule-status {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
}
.tscm-schedule-meta {
font-size: 10px;
color: var(--text-muted);
margin-bottom: 4px;
}
.tscm-schedule-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-top: 6px;
}
/* Meeting summary */
.tscm-summary-list {
display: grid;
gap: 8px;
}
.tscm-summary-item {
padding: 8px 10px;
background: rgba(0, 0, 0, 0.2);
border: 1px solid var(--border-color);
border-radius: 6px;
}
.tscm-summary-meta {
font-size: 10px;
color: var(--text-muted);
margin-top: 4px;
}
.tscm-summary-risk {
font-size: 10px;
color: #ff9933;
margin-top: 4px;
}
/* Case notes */
.tscm-case-notes {
display: grid;
gap: 8px;
margin-bottom: 10px;
}
.tscm-case-note {
padding: 8px 10px;
background: rgba(0, 0, 0, 0.2);
border: 1px solid var(--border-color);
border-radius: 6px;
}
.tscm-case-note-meta {
display: flex;
justify-content: space-between;
font-size: 9px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 4px;
}
.tscm-case-note-type {
color: var(--accent-cyan);
}
.tscm-case-note-content {
font-size: 11px;
line-height: 1.4;
white-space: pre-wrap;
}
.tscm-case-note-author {
font-size: 9px;
color: var(--text-muted);
margin-top: 4px;
}
.tscm-case-note-form {
display: grid;
gap: 6px;
margin-top: 8px;
}
.tscm-case-note-form textarea {
min-height: 80px;
}
.tscm-case-note-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-top: 6px;
}
+37 -21
View File
@@ -5,23 +5,24 @@
} }
:root { :root {
--font-sans: 'Space Mono', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; --font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
--font-mono: 'Space Mono', 'Fira Code', 'Consolas', monospace; --font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
--bg-dark: #0a0c10; --bg-dark: #0b1118;
--bg-panel: #0f1218; --bg-panel: #101823;
--bg-card: #151a23; --bg-card: #151f2b;
--border-color: #1f2937; --border-color: #263246;
--border-glow: #4a9eff; --border-glow: #4aa3ff;
--text-primary: #e8eaed; --text-primary: #d7e0ee;
--text-secondary: #9ca3af; --text-secondary: #9fb0c7;
--text-dim: #4b5563; --text-dim: #6f7f94;
--accent-cyan: #4a9eff; --accent-cyan: #4aa3ff;
--accent-green: #22c55e; --accent-green: #38c180;
--accent-orange: #f59e0b; --accent-orange: #d6a85e;
--accent-red: #ef4444; --accent-red: #e25d5d;
--accent-purple: #a855f7; --accent-purple: #8f7bd6;
--accent-amber: #d4a853; --accent-amber: #d6a85e;
--grid-line: rgba(74, 158, 255, 0.08); --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 { body {
@@ -40,9 +41,10 @@ body {
right: 0; right: 0;
bottom: 0; bottom: 0;
background-image: background-image:
var(--noise-image),
linear-gradient(var(--grid-line) 1px, transparent 1px), linear-gradient(var(--grid-line) 1px, transparent 1px),
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px); linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
background-size: 50px 50px; background-size: 40px 40px, 50px 50px, 50px 50px;
animation: gridMove 20s linear infinite; animation: gridMove 20s linear infinite;
pointer-events: none; pointer-events: none;
z-index: 0; z-index: 0;
@@ -64,12 +66,14 @@ body {
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
height: 4px; height: 2px;
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent); 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; pointer-events: none;
z-index: 1000; z-index: 1000;
opacity: 0.5; opacity: 0.25;
box-shadow: 0 0 8px currentColor;
} }
@keyframes scan { @keyframes scan {
@@ -94,6 +98,18 @@ body {
align-items: center; 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 { .logo {
font-family: var(--font-sans); font-family: var(--font-sans);
font-size: 20px; font-size: 20px;
+5
View File
@@ -421,6 +421,11 @@
color: var(--accent-cyan, #00d4ff); 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 */ /* Responsive */
@media (max-width: 640px) { @media (max-width: 640px) {
.settings-modal.active { .settings-modal.active {
+39 -16
View File
@@ -33,6 +33,9 @@ const ProximityRadar = (function() {
let activeFilter = null; let activeFilter = null;
let onDeviceClick = null; let onDeviceClick = null;
let selectedDeviceKey = null; let selectedDeviceKey = null;
let isHovered = false;
let renderPending = false;
let renderTimer = null;
/** /**
* Initialize the radar component * Initialize the radar component
@@ -162,8 +165,18 @@ const ProximityRadar = (function() {
devices.set(device.device_key, device); devices.set(device.device_key, device);
}); });
// Apply filter and render // Defer render while user is hovering to prevent DOM rebuild flicker
renderDevices(); if (isHovered) {
renderPending = true;
return;
}
// Debounce rapid updates (e.g. per-device SSE events)
if (renderTimer) clearTimeout(renderTimer);
renderTimer = setTimeout(() => {
renderTimer = null;
renderDevices();
}, 200);
} }
/** /**
@@ -211,26 +224,28 @@ const ProximityRadar = (function() {
const hitAreaSize = Math.max(dotSize * 2, 15); const hitAreaSize = Math.max(dotSize * 2, 15);
return ` return `
<g class="radar-device ${pulseClass}${isSelected ? ' selected' : ''}" data-device-key="${escapeAttr(device.device_key)}" <g transform="translate(${x}, ${y})">
transform="translate(${x}, ${y})" style="cursor: pointer;"> <g class="radar-device ${pulseClass}${isSelected ? ' selected' : ''}" data-device-key="${escapeAttr(device.device_key)}"
<!-- Invisible hit area to prevent hover flicker --> style="cursor: pointer;">
<circle class="radar-device-hitarea" r="${hitAreaSize}" fill="transparent" /> <!-- Invisible hit area to prevent hover flicker -->
${isSelected ? `<circle r="${dotSize + 8}" fill="none" stroke="#00d4ff" stroke-width="2" stroke-opacity="0.8"> <circle class="radar-device-hitarea" r="${hitAreaSize}" fill="transparent" />
<animate attributeName="r" values="${dotSize + 6};${dotSize + 10};${dotSize + 6}" dur="1.5s" repeatCount="indefinite"/> ${isSelected ? `<circle r="${dotSize + 8}" fill="none" stroke="#00d4ff" stroke-width="2" stroke-opacity="0.8">
<animate attributeName="stroke-opacity" values="0.8;0.4;0.8" dur="1.5s" repeatCount="indefinite"/> <animate attributeName="r" values="${dotSize + 6};${dotSize + 10};${dotSize + 6}" dur="1.5s" repeatCount="indefinite"/>
</circle>` : ''} <animate attributeName="stroke-opacity" values="0.8;0.4;0.8" dur="1.5s" repeatCount="indefinite"/>
<circle r="${dotSize}" fill="${color}" </circle>` : ''}
fill-opacity="${isSelected ? 1 : 0.4 + confidence * 0.5}" <circle r="${dotSize}" fill="${color}"
stroke="${isSelected ? '#00d4ff' : color}" stroke-width="${isSelected ? 2 : 1}" /> fill-opacity="${isSelected ? 1 : 0.4 + confidence * 0.5}"
${device.is_new && !isSelected ? `<circle r="${dotSize + 3}" fill="none" stroke="#3b82f6" stroke-width="1" stroke-dasharray="2,2" />` : ''} stroke="${isSelected ? '#00d4ff' : color}" stroke-width="${isSelected ? 2 : 1}" />
<title>${escapeHtml(device.name || device.address)} (${device.rssi_current || '--'} dBm)</title> ${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> </g>
`; `;
}).join(''); }).join('');
devicesGroup.innerHTML = dots; devicesGroup.innerHTML = dots;
// Attach click handlers // Attach event handlers
devicesGroup.querySelectorAll('.radar-device').forEach(el => { devicesGroup.querySelectorAll('.radar-device').forEach(el => {
el.addEventListener('click', (e) => { el.addEventListener('click', (e) => {
const deviceKey = el.getAttribute('data-device-key'); const deviceKey = el.getAttribute('data-device-key');
@@ -238,6 +253,14 @@ const ProximityRadar = (function() {
onDeviceClick(deviceKey); onDeviceClick(deviceKey);
} }
}); });
el.addEventListener('mouseenter', () => { isHovered = true; });
el.addEventListener('mouseleave', () => {
isHovered = false;
if (renderPending) {
renderPending = false;
renderDevices();
}
});
}); });
} }
+2 -13
View File
@@ -868,11 +868,8 @@ function connectAgentStream(mode, onMessage) {
if (currentAgent === 'local') { if (currentAgent === 'local') {
streamUrl = `/${mode}/stream`; streamUrl = `/${mode}/stream`;
} else { } else {
// For remote agents, we could either: // For remote agents, proxy SSE through controller
// 1. Use the multi-agent stream: /controller/stream/all streamUrl = `/controller/agents/${currentAgent}/${mode}/stream`;
// 2. Or proxy through controller (not implemented yet)
// For now, use multi-agent stream which includes agent_name tagging
streamUrl = '/controller/stream/all';
} }
agentEventSource = new EventSource(streamUrl); agentEventSource = new EventSource(streamUrl);
@@ -881,14 +878,6 @@ function connectAgentStream(mode, onMessage) {
try { try {
const data = JSON.parse(event.data); 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) { } catch (e) {
console.error('Error parsing SSE message:', e); console.error('Error parsing SSE message:', e);
+23 -3
View File
@@ -8,7 +8,7 @@ const Settings = {
'offline.enabled': false, 'offline.enabled': false,
'offline.assets_source': 'cdn', 'offline.assets_source': 'cdn',
'offline.fonts_source': 'cdn', 'offline.fonts_source': 'cdn',
'offline.tile_provider': 'cartodb_dark', 'offline.tile_provider': 'cartodb_dark_cyan',
'offline.tile_server_url': '' 'offline.tile_server_url': ''
}, },
@@ -24,6 +24,14 @@ const Settings = {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>', attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
subdomains: 'abcd' subdomains: 'abcd'
}, },
cartodb_dark_cyan: {
url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png',
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
subdomains: 'abcd',
options: {
className: 'tile-layer-cyan'
}
},
cartodb_light: { cartodb_light: {
url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/light_all/{z}/{x}/{y}.png', url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/light_all/{z}/{x}/{y}.png',
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>', attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
@@ -213,7 +221,8 @@ const Settings = {
const config = this.getTileConfig(); const config = this.getTileConfig();
const options = { const options = {
attribution: config.attribution, attribution: config.attribution,
maxZoom: 19 maxZoom: 19,
...(config.options || {})
}; };
if (config.subdomains) { if (config.subdomains) {
options.subdomains = config.subdomains; options.subdomains = config.subdomains;
@@ -351,7 +360,8 @@ const Settings = {
// Add new tile layer // Add new tile layer
const options = { const options = {
attribution: config.attribution, attribution: config.attribution,
maxZoom: 19 maxZoom: 19,
...(config.options || {})
}; };
if (config.subdomains) { if (config.subdomains) {
options.subdomains = config.subdomains; options.subdomains = config.subdomains;
@@ -742,6 +752,11 @@ async function checkForUpdatesManual() {
const content = document.getElementById('updateStatusContent'); const content = document.getElementById('updateStatusContent');
if (!content) return; if (!content) return;
if (typeof Updater === 'undefined') {
content.innerHTML = `<div style="color: var(--text-dim); padding: 10px;">Update checking is unavailable. If you use a content blocker, try allowing <code>updater.js</code> to load.</div>`;
return;
}
content.innerHTML = '<div style="text-align: center; padding: 20px; color: var(--text-dim);">Checking for updates...</div>'; content.innerHTML = '<div style="text-align: center; padding: 20px; color: var(--text-dim);">Checking for updates...</div>';
try { try {
@@ -759,6 +774,11 @@ async function loadUpdateStatus() {
const content = document.getElementById('updateStatusContent'); const content = document.getElementById('updateStatusContent');
if (!content) return; if (!content) return;
if (typeof Updater === 'undefined') {
content.innerHTML = `<div style="color: var(--text-dim); padding: 10px;">Update checking is unavailable. If you use a content blocker, try allowing <code>updater.js</code> to load.</div>`;
return;
}
try { try {
const data = await Updater.getStatus(); const data = await Updater.getStatus();
renderUpdateStatus(data); renderUpdateStatus(data);
+454
View File
@@ -0,0 +1,454 @@
/**
* 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;
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 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';
})
.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 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>
<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 === 'status') {
const statusEl = document.getElementById('dmrStatus');
if (statusEl) {
statusEl.textContent = msg.text === 'started' ? 'DECODING' : 'IDLE';
}
if (msg.text === 'stopped') {
isDmrRunning = false;
updateDmrUI();
}
}
}
// ============== 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.05 && 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.05 && isDmrRunning) {
// Gentle idle breathing
effectiveActivity = 0.05 + Math.sin(now / 800) * 0.035;
}
// 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);
}
// keepalive and status don't change visuals
updateDmrSynthStatus();
}
function updateDmrSynthStatus() {
const el = document.getElementById('dmrSynthStatus');
if (!el) return;
const labels = {
stopped: 'STOPPED',
idle: 'IDLE',
sync: 'SYNC',
call: 'CALL',
voice: 'VOICE'
};
const colors = {
stopped: 'var(--text-muted)',
idle: 'var(--text-muted)',
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;
+288
View File
@@ -830,6 +830,11 @@ function handleSignalFound(data) {
if (typeof showNotification === 'function') { if (typeof showNotification === 'function') {
showNotification('Signal Found!', `${freqStr} MHz - Audio streaming`); showNotification('Signal Found!', `${freqStr} MHz - Audio streaming`);
} }
// Auto-trigger signal identification
if (typeof guessSignal === 'function') {
guessSignal(data.frequency, data.modulation);
}
} }
function handleSignalLost(data) { function handleSignalLost(data) {
@@ -985,11 +990,15 @@ function addSignalHit(data) {
} }
const mod = data.modulation || 'fm'; const mod = data.modulation || 'fm';
const snr = data.snr != null ? data.snr : null;
const snrText = snr != null ? `${snr > 0 ? '+' : ''}${snr.toFixed(1)} dB` : '---';
const snrColor = snr != null ? (snr >= 10 ? 'var(--accent-green)' : snr >= 3 ? 'var(--accent-cyan)' : 'var(--accent-orange, #f0a030)') : 'var(--text-muted)';
const row = document.createElement('tr'); const row = document.createElement('tr');
row.style.borderBottom = '1px solid var(--border-color)'; row.style.borderBottom = '1px solid var(--border-color)';
row.innerHTML = ` row.innerHTML = `
<td style="padding: 4px; color: var(--text-secondary); font-size: 9px;">${timestamp}</td> <td style="padding: 4px; color: var(--text-secondary); font-size: 9px;">${timestamp}</td>
<td style="padding: 4px; color: var(--accent-green); font-weight: bold;">${data.frequency.toFixed(3)}</td> <td style="padding: 4px; color: var(--accent-green); font-weight: bold;">${data.frequency.toFixed(3)}</td>
<td style="padding: 4px; color: ${snrColor}; font-weight: bold; font-size: 9px;">${snrText}</td>
<td style="padding: 4px; color: var(--text-secondary);">${mod.toUpperCase()}</td> <td style="padding: 4px; color: var(--text-secondary);">${mod.toUpperCase()}</td>
<td style="padding: 4px; text-align: center;"> <td style="padding: 4px; text-align: center;">
<button class="preset-btn" onclick="tuneToFrequency(${data.frequency}, '${mod}')" style="padding: 2px 6px; font-size: 9px; background: var(--accent-green); border: none; color: #000; cursor: pointer; border-radius: 3px;">Listen</button> <button class="preset-btn" onclick="tuneToFrequency(${data.frequency}, '${mod}')" style="padding: 2px 6px; font-size: 9px; background: var(--accent-green); border: none; color: #000; cursor: pointer; border-radius: 3px;">Listen</button>
@@ -2933,6 +2942,281 @@ window.updateListenButtonState = updateListenButtonState;
// Export functions for HTML onclick handlers // Export functions for HTML onclick handlers
window.toggleDirectListen = toggleDirectListen; window.toggleDirectListen = toggleDirectListen;
window.startDirectListen = startDirectListen; window.startDirectListen = startDirectListen;
// ============== SIGNAL IDENTIFICATION ==============
function guessSignal(frequencyMhz, modulation) {
const body = { frequency_mhz: frequencyMhz };
if (modulation) body.modulation = modulation;
return fetch('/listening/signal/guess', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
.then(r => r.json())
.then(data => {
if (data.status === 'ok') {
renderSignalGuess(data);
}
return data;
})
.catch(err => console.error('[SIGNAL-ID] Error:', err));
}
function renderSignalGuess(result) {
const panel = document.getElementById('signalGuessPanel');
if (!panel) return;
panel.style.display = 'block';
const label = document.getElementById('signalGuessLabel');
const badge = document.getElementById('signalGuessBadge');
const explanation = document.getElementById('signalGuessExplanation');
const tagsEl = document.getElementById('signalGuessTags');
const altsEl = document.getElementById('signalGuessAlternatives');
if (label) label.textContent = result.primary_label || 'Unknown';
if (badge) {
badge.textContent = result.confidence || '';
const colors = { 'HIGH': '#00e676', 'MEDIUM': '#ff9800', 'LOW': '#9e9e9e' };
badge.style.background = colors[result.confidence] || '#9e9e9e';
badge.style.color = '#000';
}
if (explanation) explanation.textContent = result.explanation || '';
if (tagsEl) {
tagsEl.innerHTML = (result.tags || []).map(tag =>
`<span style="background: rgba(0,200,255,0.15); color: var(--accent-cyan); padding: 1px 6px; border-radius: 3px; font-size: 9px;">${tag}</span>`
).join('');
}
if (altsEl) {
if (result.alternatives && result.alternatives.length > 0) {
altsEl.innerHTML = '<strong>Also:</strong> ' + result.alternatives.map(a =>
`${a.label} <span style="color: ${a.confidence === 'HIGH' ? '#00e676' : a.confidence === 'MEDIUM' ? '#ff9800' : '#9e9e9e'}">(${a.confidence})</span>`
).join(', ');
} else {
altsEl.innerHTML = '';
}
}
}
function manualSignalGuess() {
const input = document.getElementById('signalGuessFreqInput');
if (!input || !input.value) return;
const freq = parseFloat(input.value);
if (isNaN(freq) || freq <= 0) return;
guessSignal(freq, currentModulation);
}
// ============== WATERFALL / SPECTROGRAM ==============
let isWaterfallRunning = false;
let waterfallEventSource = null;
let waterfallCanvas = null;
let waterfallCtx = null;
let spectrumCanvas = null;
let spectrumCtx = null;
let waterfallStartFreq = 88;
let waterfallEndFreq = 108;
function initWaterfallCanvas() {
waterfallCanvas = document.getElementById('waterfallCanvas');
spectrumCanvas = document.getElementById('spectrumCanvas');
if (waterfallCanvas) waterfallCtx = waterfallCanvas.getContext('2d');
if (spectrumCanvas) spectrumCtx = spectrumCanvas.getContext('2d');
}
function dBmToColor(normalized) {
// Viridis-inspired: dark blue -> cyan -> green -> yellow
const n = Math.max(0, Math.min(1, normalized));
let r, g, b;
if (n < 0.25) {
const t = n / 0.25;
r = Math.round(20 + t * 20);
g = Math.round(10 + t * 60);
b = Math.round(80 + t * 100);
} else if (n < 0.5) {
const t = (n - 0.25) / 0.25;
r = Math.round(40 - t * 20);
g = Math.round(70 + t * 130);
b = Math.round(180 - t * 30);
} else if (n < 0.75) {
const t = (n - 0.5) / 0.25;
r = Math.round(20 + t * 180);
g = Math.round(200 + t * 55);
b = Math.round(150 - t * 130);
} else {
const t = (n - 0.75) / 0.25;
r = Math.round(200 + t * 55);
g = Math.round(255 - t * 55);
b = Math.round(20 - t * 20);
}
return `rgb(${r},${g},${b})`;
}
function drawWaterfallRow(bins) {
if (!waterfallCtx || !waterfallCanvas) return;
const w = waterfallCanvas.width;
const h = waterfallCanvas.height;
// Scroll existing content down by 1 pixel
const imageData = waterfallCtx.getImageData(0, 0, w, h - 1);
waterfallCtx.putImageData(imageData, 0, 1);
// Find min/max for normalization
let minVal = Infinity, maxVal = -Infinity;
for (let i = 0; i < bins.length; i++) {
if (bins[i] < minVal) minVal = bins[i];
if (bins[i] > maxVal) maxVal = bins[i];
}
const range = maxVal - minVal || 1;
// Draw new row at top
const binWidth = w / bins.length;
for (let i = 0; i < bins.length; i++) {
const normalized = (bins[i] - minVal) / range;
waterfallCtx.fillStyle = dBmToColor(normalized);
waterfallCtx.fillRect(Math.floor(i * binWidth), 0, Math.ceil(binWidth) + 1, 1);
}
}
function drawSpectrumLine(bins, startFreq, endFreq) {
if (!spectrumCtx || !spectrumCanvas) return;
const w = spectrumCanvas.width;
const h = spectrumCanvas.height;
spectrumCtx.clearRect(0, 0, w, h);
// Background
spectrumCtx.fillStyle = 'rgba(0, 0, 0, 0.8)';
spectrumCtx.fillRect(0, 0, w, h);
// Grid lines
spectrumCtx.strokeStyle = 'rgba(0, 200, 255, 0.1)';
spectrumCtx.lineWidth = 0.5;
for (let i = 0; i < 5; i++) {
const y = (h / 5) * i;
spectrumCtx.beginPath();
spectrumCtx.moveTo(0, y);
spectrumCtx.lineTo(w, y);
spectrumCtx.stroke();
}
// Frequency labels
spectrumCtx.fillStyle = 'rgba(0, 200, 255, 0.5)';
spectrumCtx.font = '9px monospace';
const freqRange = endFreq - startFreq;
for (let i = 0; i <= 4; i++) {
const freq = startFreq + (freqRange / 4) * i;
const x = (w / 4) * i;
spectrumCtx.fillText(freq.toFixed(1), x + 2, h - 2);
}
if (bins.length === 0) return;
// Find min/max for scaling
let minVal = Infinity, maxVal = -Infinity;
for (let i = 0; i < bins.length; i++) {
if (bins[i] < minVal) minVal = bins[i];
if (bins[i] > maxVal) maxVal = bins[i];
}
const range = maxVal - minVal || 1;
// Draw spectrum line
spectrumCtx.strokeStyle = 'rgba(0, 255, 255, 0.9)';
spectrumCtx.lineWidth = 1.5;
spectrumCtx.beginPath();
for (let i = 0; i < bins.length; i++) {
const x = (i / (bins.length - 1)) * w;
const normalized = (bins[i] - minVal) / range;
const y = h - 12 - normalized * (h - 16);
if (i === 0) spectrumCtx.moveTo(x, y);
else spectrumCtx.lineTo(x, y);
}
spectrumCtx.stroke();
// Fill under line
const lastX = w;
const lastY = h - 12 - ((bins[bins.length - 1] - minVal) / range) * (h - 16);
spectrumCtx.lineTo(lastX, h);
spectrumCtx.lineTo(0, h);
spectrumCtx.closePath();
spectrumCtx.fillStyle = 'rgba(0, 255, 255, 0.08)';
spectrumCtx.fill();
}
function startWaterfall() {
const startFreq = parseFloat(document.getElementById('waterfallStartFreq')?.value || 88);
const endFreq = parseFloat(document.getElementById('waterfallEndFreq')?.value || 108);
const binSize = parseInt(document.getElementById('waterfallBinSize')?.value || 10000);
const gain = parseInt(document.getElementById('waterfallGain')?.value || 40);
const device = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0;
if (startFreq >= endFreq) {
if (typeof showNotification === 'function') showNotification('Error', 'End frequency must be greater than start');
return;
}
waterfallStartFreq = startFreq;
waterfallEndFreq = endFreq;
fetch('/listening/waterfall/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ start_freq: startFreq, end_freq: endFreq, bin_size: binSize, gain: gain, device: device })
})
.then(r => r.json())
.then(data => {
if (data.status === 'started') {
isWaterfallRunning = true;
document.getElementById('startWaterfallBtn').style.display = 'none';
document.getElementById('stopWaterfallBtn').style.display = 'block';
const waterfallPanel = document.getElementById('waterfallPanel');
if (waterfallPanel) waterfallPanel.style.display = 'block';
initWaterfallCanvas();
connectWaterfallSSE();
} else {
if (typeof showNotification === 'function') showNotification('Error', data.message || 'Failed to start waterfall');
}
})
.catch(err => console.error('[WATERFALL] Start error:', err));
}
function stopWaterfall() {
fetch('/listening/waterfall/stop', { method: 'POST' })
.then(r => r.json())
.then(() => {
isWaterfallRunning = false;
if (waterfallEventSource) { waterfallEventSource.close(); waterfallEventSource = null; }
document.getElementById('startWaterfallBtn').style.display = 'block';
document.getElementById('stopWaterfallBtn').style.display = 'none';
})
.catch(err => console.error('[WATERFALL] Stop error:', err));
}
function connectWaterfallSSE() {
if (waterfallEventSource) waterfallEventSource.close();
waterfallEventSource = new EventSource('/listening/waterfall/stream');
waterfallEventSource.onmessage = function(event) {
const msg = JSON.parse(event.data);
if (msg.type === 'waterfall_sweep') {
drawWaterfallRow(msg.bins);
drawSpectrumLine(msg.bins, msg.start_freq, msg.end_freq);
}
};
waterfallEventSource.onerror = function() {
if (isWaterfallRunning) {
setTimeout(connectWaterfallSSE, 2000);
}
};
}
window.stopDirectListen = stopDirectListen; window.stopDirectListen = stopDirectListen;
window.toggleScanner = toggleScanner; window.toggleScanner = toggleScanner;
window.startScanner = startScanner; window.startScanner = startScanner;
@@ -2949,3 +3233,7 @@ window.removeBookmark = removeBookmark;
window.tuneToFrequency = tuneToFrequency; window.tuneToFrequency = tuneToFrequency;
window.clearScannerLog = clearScannerLog; window.clearScannerLog = clearScannerLog;
window.exportScannerLog = exportScannerLog; window.exportScannerLog = exportScannerLog;
window.manualSignalGuess = manualSignalGuess;
window.guessSignal = guessSignal;
window.startWaterfall = startWaterfall;
window.stopWaterfall = stopWaterfall;
+2 -1
View File
@@ -120,7 +120,8 @@ const Meshtastic = (function() {
L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', { 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>', attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
maxZoom: 19, maxZoom: 19,
subdomains: 'abcd' subdomains: 'abcd',
className: 'tile-layer-cyan'
}).addTo(meshMap); }).addTo(meshMap);
} }
+410
View File
@@ -0,0 +1,410 @@
/**
* 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;
/**
* 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 slowrx: apt install slowrx', '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>
`;
}
/**
* Start SSE stream
*/
function startStream() {
if (eventSource) {
eventSource.close();
}
eventSource = new EventSource('/sstv-general/stream');
eventSource.onmessage = (e) => {
try {
const data = JSON.parse(e.data);
if (data.type === 'sstv_progress') {
handleProgress(data);
}
} 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;
}
}
/**
* 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...');
} else if (data.status === 'detecting') {
updateStatusUI('listening', data.message || 'Listening...');
}
}
/**
* Render decode progress in live area
*/
function renderDecodeProgress(data) {
const liveContent = document.getElementById('sstvGeneralLiveContent');
if (!liveContent) return;
liveContent.innerHTML = `
<div class="sstv-general-canvas-container">
<canvas id="sstvGeneralCanvas" width="320" height="256"></canvas>
</div>
<div class="sstv-general-decode-info">
<div class="sstv-general-mode-label">${data.mode || 'Detecting mode...'}</div>
<div class="sstv-general-progress-bar">
<div class="progress" style="width: ${data.progress || 0}%"></div>
</div>
<div class="sstv-general-status-message">${data.message || 'Decoding...'}</div>
</div>
`;
}
/**
* 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" onclick="SSTVGeneral.showImage('${escapeHtml(img.url)}')">
<img src="${escapeHtml(img.url)}" alt="SSTV Image" class="sstv-general-image-preview" loading="lazy">
<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>
`).join('');
}
/**
* Show full-size image in modal
*/
function showImage(url) {
let modal = document.getElementById('sstvGeneralImageModal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'sstvGeneralImageModal';
modal.className = 'sstv-general-image-modal';
modal.innerHTML = `
<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();
});
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;
}
/**
* 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,
selectPreset
};
})();
+2 -1
View File
@@ -184,7 +184,8 @@ const SSTV = (function() {
} else { } else {
// Fallback to dark theme tiles // Fallback to dark theme tiles
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
maxZoom: 19 maxZoom: 19,
className: 'tile-layer-cyan'
}).addTo(issMap); }).addTo(issMap);
} }
+573
View File
@@ -0,0 +1,573 @@
/**
* 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;
websdrMap = L.map('websdrMap', {
center: [30, 0],
zoom: 2,
zoomControl: true,
});
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);
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`;
const sidebarCount = document.getElementById('websdrSidebarCount');
if (sidebarCount) sidebarCount.textContent = websdrReceivers.length;
}
})
.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;
+8 -1
View File
@@ -879,6 +879,7 @@ const WiFiMode = (function() {
updateNetworkRow(network); updateNetworkRow(network);
updateStats(); updateStats();
updateProximityRadar(); updateProximityRadar();
updateChannelChart();
if (onNetworkUpdate) onNetworkUpdate(network); if (onNetworkUpdate) onNetworkUpdate(network);
} }
@@ -1420,9 +1421,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)); 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; 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 // Recalculate channel stats from networks if needed
if (channelStats.length === 0 && networks.size > 0) { if (channelStats.length === 0 && networks.size > 0) {
channelStats = calculateChannelStats(); 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();
}
});
}
})();
+2 -1
View File
@@ -2391,7 +2391,8 @@ sudo make install</code>
L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', { 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>', attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
maxZoom: 19, maxZoom: 19,
subdomains: 'abcd' subdomains: 'abcd',
className: 'tile-layer-cyan'
}).addTo(radarMap); }).addTo(radarMap);
} }
+2 -1
View File
@@ -413,7 +413,8 @@
L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', { 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>', attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
maxZoom: 19, maxZoom: 19,
subdomains: 'abcd' subdomains: 'abcd',
className: 'tile-layer-cyan'
}).addTo(vesselMap); }).addTo(vesselMap);
} }
+2205 -111
View File
File diff suppressed because it is too large Load Diff
+71
View File
@@ -0,0 +1,71 @@
<!-- DMR / DIGITAL VOICE MODE -->
<div id="dmrMode" class="mode-content">
<div class="section">
<h3>Digital Voice</h3>
<!-- Dependency Warning -->
<div id="dmrToolsWarning" style="display: none; background: rgba(255, 100, 100, 0.1); border: 1px solid var(--accent-red); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
<p style="color: var(--accent-red); margin: 0; font-size: 0.85em;">
<strong>Missing:</strong><br>
<span id="dmrToolsWarningText"></span>
</p>
</div>
<div class="form-group">
<label>Frequency (MHz)</label>
<input type="number" id="dmrFrequency" value="462.5625" step="0.0001" style="width: 100%;">
</div>
<div class="form-group">
<label>Protocol</label>
<select id="dmrProtocol">
<option value="auto" selected>Auto Detect</option>
<option value="dmr">DMR</option>
<option value="p25">P25</option>
<option value="nxdn">NXDN</option>
<option value="dstar">D-STAR</option>
<option value="provoice">ProVoice</option>
</select>
</div>
<div class="form-group">
<label>Gain</label>
<input type="number" id="dmrGain" value="40" min="0" max="50" style="width: 100%;">
</div>
</div>
<!-- Actions -->
<button class="run-btn" id="startDmrBtn" onclick="startDmr()" style="margin-top: 12px;">
Start Decoder
</button>
<button class="stop-btn" id="stopDmrBtn" onclick="stopDmr()" style="display: none; margin-top: 12px;">
Stop Decoder
</button>
<!-- Current Call -->
<div class="section" style="margin-top: 12px;">
<h3>Current Call</h3>
<div id="dmrCurrentCall" style="background: rgba(0,0,0,0.3); border-radius: 6px; padding: 10px; font-size: 11px;">
<div style="color: var(--text-muted); text-align: center;">No active call</div>
</div>
</div>
<!-- Status -->
<div class="section" style="margin-top: 12px;">
<h3>Status</h3>
<div style="background: rgba(0,0,0,0.3); border-radius: 6px; padding: 10px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Status</span>
<span id="dmrStatus" style="font-size: 11px; color: var(--accent-cyan);">IDLE</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Protocol</span>
<span id="dmrActiveProtocol" style="font-size: 11px; color: var(--text-primary);">--</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Calls</span>
<span id="dmrCallCount" style="font-size: 14px; font-weight: bold; color: var(--accent-green);">0</span>
</div>
</div>
</div>
</div>
@@ -46,4 +46,50 @@
</div> </div>
</div> </div>
<!-- Signal Identification -->
<div class="section">
<h3>Signal Identification</h3>
<div style="display: flex; gap: 4px; margin-bottom: 8px;">
<input type="text" id="signalGuessFreqInput" placeholder="Freq (MHz)" style="flex: 1; padding: 6px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
<button class="preset-btn" onclick="manualSignalGuess()" style="background: var(--accent-cyan); color: #000; padding: 6px 10px; font-weight: 600;">ID</button>
</div>
<div id="signalGuessPanel" style="display: none; background: rgba(0,0,0,0.3); border-radius: 6px; padding: 10px; font-size: 11px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<span id="signalGuessLabel" style="font-weight: bold; color: var(--text-primary);"></span>
<span id="signalGuessBadge" style="padding: 2px 8px; border-radius: 3px; font-size: 9px; font-weight: bold;"></span>
</div>
<div id="signalGuessExplanation" style="color: var(--text-muted); font-size: 10px; margin-bottom: 6px;"></div>
<div id="signalGuessTags" style="display: flex; flex-wrap: wrap; gap: 3px;"></div>
<div id="signalGuessAlternatives" style="margin-top: 6px; font-size: 10px; color: var(--text-muted);"></div>
</div>
</div>
<!-- Waterfall Controls -->
<div class="section">
<h3>Waterfall</h3>
<div class="form-group" style="margin-bottom: 6px;">
<label style="font-size: 10px;">Start (MHz)</label>
<input type="number" id="waterfallStartFreq" value="88" step="0.1" style="width: 100%; padding: 5px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
</div>
<div class="form-group" style="margin-bottom: 6px;">
<label style="font-size: 10px;">End (MHz)</label>
<input type="number" id="waterfallEndFreq" value="108" step="0.1" style="width: 100%; padding: 5px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
</div>
<div class="form-group" style="margin-bottom: 6px;">
<label style="font-size: 10px;">Bin Size</label>
<select id="waterfallBinSize" style="width: 100%; padding: 5px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
<option value="5000">5 kHz</option>
<option value="10000" selected>10 kHz</option>
<option value="25000">25 kHz</option>
<option value="100000">100 kHz</option>
</select>
</div>
<div class="form-group" style="margin-bottom: 8px;">
<label style="font-size: 10px;">Gain</label>
<input type="number" id="waterfallGain" value="40" min="0" max="50" style="width: 100%; padding: 5px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
</div>
<button class="run-btn" id="startWaterfallBtn" onclick="startWaterfall()" style="width: 100%; padding: 8px;">Start Waterfall</button>
<button class="stop-btn" id="stopWaterfallBtn" onclick="stopWaterfall()" style="display: none; width: 100%; padding: 8px; margin-top: 4px;">Stop Waterfall</button>
</div>
</div> </div>
@@ -0,0 +1,86 @@
<!-- SSTV GENERAL MODE -->
<div id="sstvGeneralMode" class="mode-content">
<div class="section">
<h3>SSTV Decoder</h3>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
Decode Slow-Scan Television images on common amateur radio HF/VHF/UHF frequencies.
Select a predefined frequency or enter a custom one.
</p>
<p class="info-text" style="font-size: 10px; color: var(--accent-yellow); margin-bottom: 8px;">
Note: HF frequencies (below 30 MHz) require an upconverter with RTL-SDR.
</p>
</div>
<div class="section">
<h3>Frequency</h3>
<div class="form-group">
<label>Preset Frequency</label>
<select id="sstvGeneralPresetFreq" onchange="SSTVGeneral.selectPreset(this.value)" style="width: 100%; padding: 6px 8px; font-family: var(--font-mono); font-size: 11px; background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 4px; color: var(--text-primary);">
<option value="">-- Select frequency --</option>
<optgroup label="80 m (HF)">
<option value="3.845|lsb">3.845 MHz LSB - US calling</option>
<option value="3.730|lsb">3.730 MHz LSB - Europe primary</option>
</optgroup>
<optgroup label="40 m (HF)">
<option value="7.171|lsb">7.171 MHz LSB - International</option>
<option value="7.040|lsb">7.040 MHz LSB - Alt US/EU</option>
</optgroup>
<optgroup label="30 m (HF)">
<option value="10.132|usb">10.132 MHz USB - Narrowband</option>
</optgroup>
<optgroup label="20 m (HF)">
<option value="14.230|usb">14.230 MHz USB - Most popular</option>
<option value="14.233|usb">14.233 MHz USB - Digital SSTV</option>
<option value="14.240|usb">14.240 MHz USB - Europe alt</option>
</optgroup>
<optgroup label="15 m (HF)">
<option value="21.340|usb">21.340 MHz USB - International</option>
</optgroup>
<optgroup label="10 m (HF)">
<option value="28.680|usb">28.680 MHz USB - International</option>
</optgroup>
<optgroup label="6 m (VHF)">
<option value="50.950|usb">50.950 MHz USB - SSTV calling</option>
</optgroup>
<optgroup label="2 m (VHF)">
<option value="145.625|fm">145.625 MHz FM - Simplex</option>
</optgroup>
<optgroup label="70 cm (UHF)">
<option value="433.775|fm">433.775 MHz FM - Simplex</option>
</optgroup>
</select>
</div>
<div class="form-group">
<label>Frequency (MHz)</label>
<input type="number" id="sstvGeneralFrequency" value="14.230" step="0.001" min="1" max="500">
</div>
<div class="form-group">
<label>Modulation</label>
<select id="sstvGeneralModulation" style="width: 100%; padding: 6px 8px; font-family: var(--font-mono); font-size: 11px; background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 4px; color: var(--text-primary);">
<option value="usb">USB (Upper Sideband)</option>
<option value="lsb">LSB (Lower Sideband)</option>
<option value="fm">FM (Frequency Modulation)</option>
</select>
</div>
</div>
<div class="section">
<h3>Resources</h3>
<div style="display: flex; flex-direction: column; gap: 6px;">
<a href="https://www.sigidwiki.com/wiki/Slow-Scan_Television_(SSTV)" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
SigID Wiki - SSTV
</a>
</div>
</div>
<div class="section">
<h3>About Terrestrial SSTV</h3>
<p class="info-text" style="font-size: 11px; color: var(--text-dim);">
Amateur radio operators transmit SSTV images on HF bands worldwide.
The most popular frequency is 14.230 MHz USB on the 20m band.
</p>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-top: 8px;">
Common modes: PD120, PD180, Martin1, Scottie1, Robot36
</p>
</div>
</div>
+9
View File
@@ -146,6 +146,9 @@
<button class="stop-btn" id="tscmEndMeetingBtn" onclick="tscmEndMeeting()" style="width: 100%; padding: 8px; display: none;"> <button class="stop-btn" id="tscmEndMeetingBtn" onclick="tscmEndMeeting()" style="width: 100%; padding: 8px; display: none;">
End Meeting Window End Meeting Window
</button> </button>
<button class="preset-btn" id="tscmMeetingSummaryBtn" onclick="tscmShowMeetingSummary()" style="width: 100%; padding: 8px; margin-top: 6px; display: none;">
View Meeting Summary
</button>
<div style="font-size: 9px; color: var(--text-muted); margin-top: 4px;"> <div style="font-size: 9px; color: var(--text-muted); margin-top: 4px;">
Devices detected during meetings get flagged Devices detected during meetings get flagged
</div> </div>
@@ -159,12 +162,18 @@
<button class="preset-btn" onclick="tscmShowCapabilities()" style="font-size: 10px; padding: 8px;"> <button class="preset-btn" onclick="tscmShowCapabilities()" style="font-size: 10px; padding: 8px;">
Capabilities Capabilities
</button> </button>
<button class="preset-btn" onclick="tscmShowWifiIndicators()" style="font-size: 10px; padding: 8px;">
WiFi Indicators
</button>
<button class="preset-btn" onclick="tscmShowKnownDevices()" style="font-size: 10px; padding: 8px;"> <button class="preset-btn" onclick="tscmShowKnownDevices()" style="font-size: 10px; padding: 8px;">
Known Devices Known Devices
</button> </button>
<button class="preset-btn" onclick="tscmShowCases()" style="font-size: 10px; padding: 8px;"> <button class="preset-btn" onclick="tscmShowCases()" style="font-size: 10px; padding: 8px;">
Cases Cases
</button> </button>
<button class="preset-btn" onclick="tscmShowSchedules()" style="font-size: 10px; padding: 8px;">
Schedules
</button>
<button class="preset-btn" onclick="tscmShowPlaybooks()" style="font-size: 10px; padding: 8px;"> <button class="preset-btn" onclick="tscmShowPlaybooks()" style="font-size: 10px; padding: 8px;">
Playbooks Playbooks
</button> </button>
+78
View File
@@ -0,0 +1,78 @@
<!-- WEBSDR MODE -->
<div id="websdrMode" class="mode-content">
<div class="section">
<h3>WebSDR</h3>
<div class="form-group">
<label>Frequency (kHz)</label>
<input type="number" id="websdrFrequency" value="6500" step="1" style="width: 100%;">
</div>
<div class="form-group">
<label>Mode</label>
<select id="websdrMode_select">
<option value="usb">USB</option>
<option value="lsb">LSB</option>
<option value="am" selected>AM</option>
<option value="cw">CW</option>
</select>
</div>
<button class="run-btn" onclick="searchReceivers()" style="width: 100%; margin-top: 8px;">
Find Receivers
</button>
<button class="preset-btn" onclick="searchReceivers(true)" style="width: 100%; margin-top: 4px; font-size: 10px;">
Refresh List
</button>
</div>
<!-- Audio Player -->
<div class="section" style="margin-top: 12px;">
<h3>Audio Player</h3>
<div style="background: rgba(0,0,0,0.3); border-radius: 6px; padding: 10px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Status</span>
<span id="kiwiStatus" style="font-size: 11px; color: var(--text-muted);">DISCONNECTED</span>
</div>
<div id="kiwiReceiverName" style="font-size: 11px; color: var(--accent-cyan); margin-bottom: 6px; display: none; word-break: break-word;"></div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Frequency</span>
<span id="kiwiFreqDisplay" style="font-size: 14px; font-family: var(--font-mono); color: var(--text-primary);">--- kHz</span>
</div>
<!-- S-meter -->
<div style="margin-bottom: 8px;">
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">S-Meter</span>
<div style="height: 8px; background: rgba(0,0,0,0.5); border-radius: 4px; margin-top: 3px; overflow: hidden;">
<div id="kiwiSmeterBar" style="height: 100%; width: 0%; background: linear-gradient(to right, var(--accent-green), var(--accent-orange), var(--accent-red)); transition: width 0.2s; border-radius: 4px;"></div>
</div>
<div style="text-align: right; font-size: 9px; color: var(--text-muted); margin-top: 2px;">
<span id="kiwiSmeterValue">S0</span>
</div>
</div>
<!-- Volume -->
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
<span style="font-size: 10px; color: var(--text-muted);">VOL</span>
<input type="range" id="kiwiVolume" min="0" max="100" value="80" style="flex: 1;" oninput="setKiwiVolume(this.value)">
</div>
<button id="kiwiDisconnectBtn" class="stop-btn" onclick="disconnectFromReceiver()" style="width: 100%; display: none;">
Disconnect
</button>
</div>
</div>
<!-- Spy Station Presets -->
<div class="section" style="margin-top: 12px;">
<h3>Spy Station Presets</h3>
<div id="websdrSpyPresets" style="max-height: 250px; overflow-y: auto; font-size: 11px;">
<div style="color: var(--text-muted); text-align: center; padding: 10px;">Loading...</div>
</div>
</div>
<!-- Receiver Count -->
<div class="section" style="margin-top: 12px;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Receivers</span>
<span id="websdrSidebarCount" style="font-size: 11px; color: var(--accent-cyan);">0</span>
</div>
</div>
</div>
+6
View File
@@ -71,6 +71,8 @@
{{ mode_item('listening', 'Listening Post', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>') }} {{ mode_item('listening', 'Listening Post', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>') }}
{{ mode_item('spystations', 'Spy Stations', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }} {{ mode_item('spystations', 'Spy Stations', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
{{ mode_item('meshtastic', 'Meshtastic', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>') }} {{ mode_item('meshtastic', 'Meshtastic', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>') }}
{{ mode_item('dmr', 'Digital Voice', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="22"/></svg>') }}
{{ mode_item('websdr', 'WebSDR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
</div> </div>
</div> </div>
@@ -116,6 +118,7 @@
{{ mode_item('satellite', 'Satellite', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/><path d="m16 8 3-3"/><path d="M9 21a6 6 0 0 0-6-6"/></svg>', '/satellite/dashboard') }} {{ mode_item('satellite', 'Satellite', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/><path d="m16 8 3-3"/><path d="M9 21a6 6 0 0 0-6-6"/></svg>', '/satellite/dashboard') }}
{% endif %} {% endif %}
{{ mode_item('sstv', 'ISS SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M3 9h2"/><path d="M19 9h2"/><path d="M3 15h2"/><path d="M19 15h2"/></svg>') }} {{ mode_item('sstv', 'ISS SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M3 9h2"/><path d="M19 9h2"/><path d="M3 15h2"/><path d="M19 15h2"/></svg>') }}
{{ mode_item('sstv_general', 'HF SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg>') }}
</div> </div>
</div> </div>
@@ -182,9 +185,12 @@
{{ mobile_item('satellite', 'Sat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/></svg>', '/satellite/dashboard') }} {{ mobile_item('satellite', 'Sat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/></svg>', '/satellite/dashboard') }}
{% endif %} {% endif %}
{{ mobile_item('sstv', 'SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/></svg>') }} {{ mobile_item('sstv', 'SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/></svg>') }}
{{ mobile_item('sstv_general', 'HF SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/></svg>') }}
{{ mobile_item('listening', 'Scanner', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>') }} {{ mobile_item('listening', 'Scanner', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>') }}
{{ mobile_item('spystations', 'Spy', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }} {{ mobile_item('spystations', 'Spy', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
{{ mobile_item('meshtastic', 'Mesh', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>') }} {{ mobile_item('meshtastic', 'Mesh', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>') }}
{{ mobile_item('dmr', 'DMR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="22"/></svg>') }}
{{ mobile_item('websdr', 'WebSDR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
</nav> </nav>
{# JavaScript stub for pages that don't have switchMode defined #} {# JavaScript stub for pages that don't have switchMode defined #}
+1
View File
@@ -72,6 +72,7 @@
<select id="tileProvider" class="settings-select" onchange="Settings.setTileProvider(this.value)"> <select id="tileProvider" class="settings-select" onchange="Settings.setTileProvider(this.value)">
<option value="openstreetmap">OpenStreetMap</option> <option value="openstreetmap">OpenStreetMap</option>
<option value="cartodb_dark">CartoDB Dark</option> <option value="cartodb_dark">CartoDB Dark</option>
<option value="cartodb_dark_cyan">CartoDB Dark (Cyan Tint)</option>
<option value="cartodb_light">CartoDB Positron</option> <option value="cartodb_light">CartoDB Positron</option>
<option value="esri_world">ESRI World Imagery</option> <option value="esri_world">ESRI World Imagery</option>
<option value="custom">Custom URL</option> <option value="custom">Custom URL</option>
+2 -1
View File
@@ -458,7 +458,8 @@
L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', { 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>', attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
maxZoom: 19, maxZoom: 19,
subdomains: 'abcd' subdomains: 'abcd',
className: 'tile-layer-cyan'
}).addTo(groundMap); }).addTo(groundMap);
} }
+4 -2
View File
@@ -5,11 +5,13 @@ from app import app as flask_app
from routes import register_blueprints from routes import register_blueprints
@pytest.fixture @pytest.fixture(scope='session')
def app(): def app():
"""Create application for testing.""" """Create application for testing."""
register_blueprints(flask_app)
flask_app.config['TESTING'] = True flask_app.config['TESTING'] = True
# Register blueprints only if not already registered
if 'pager' not in flask_app.blueprints:
register_blueprints(flask_app)
return flask_app return flask_app
+145
View File
@@ -0,0 +1,145 @@
"""Tests for the DMR / Digital Voice decoding module."""
from unittest.mock import patch, MagicMock
import pytest
from routes.dmr import parse_dsd_output
# ============================================
# parse_dsd_output() tests
# ============================================
def test_parse_sync_dmr():
"""Should parse DMR sync line."""
result = parse_dsd_output('Sync: +DMR (data)')
assert result is not None
assert result['type'] == 'sync'
assert 'DMR' in result['protocol']
def test_parse_sync_p25():
"""Should parse P25 sync line."""
result = parse_dsd_output('Sync: +P25 Phase 1')
assert result is not None
assert result['type'] == 'sync'
assert 'P25' in result['protocol']
def test_parse_talkgroup_and_source():
"""Should parse talkgroup and source ID."""
result = parse_dsd_output('TG: 12345 Src: 67890')
assert result is not None
assert result['type'] == 'call'
assert result['talkgroup'] == 12345
assert result['source_id'] == 67890
def test_parse_slot():
"""Should parse slot info."""
result = parse_dsd_output('Slot 1')
assert result is not None
assert result['type'] == 'slot'
assert result['slot'] == 1
def test_parse_voice():
"""Should parse voice frame info."""
result = parse_dsd_output('Voice Frame 1')
assert result is not None
assert result['type'] == 'voice'
def test_parse_nac():
"""Should parse P25 NAC."""
result = parse_dsd_output('NAC: 293')
assert result is not None
assert result['type'] == 'nac'
assert result['nac'] == '293'
def test_parse_empty_line():
"""Empty lines should return None."""
assert parse_dsd_output('') is None
assert parse_dsd_output(' ') is None
def test_parse_unrecognized():
"""Unrecognized lines should return None."""
assert parse_dsd_output('some random text') is None
# ============================================
# Endpoint tests
# ============================================
@pytest.fixture
def auth_client(client):
"""Client with logged-in session."""
with client.session_transaction() as sess:
sess['logged_in'] = True
return client
def test_dmr_tools(auth_client):
"""Tools endpoint should return availability info."""
resp = auth_client.get('/dmr/tools')
assert resp.status_code == 200
data = resp.get_json()
assert 'dsd' in data
assert 'rtl_fm' in data
assert 'protocols' in data
def test_dmr_status(auth_client):
"""Status endpoint should work."""
resp = auth_client.get('/dmr/status')
assert resp.status_code == 200
data = resp.get_json()
assert 'running' in data
def test_dmr_start_no_dsd(auth_client):
"""Start should fail gracefully when dsd is not installed."""
with patch('routes.dmr.find_dsd', return_value=None):
resp = auth_client.post('/dmr/start', json={
'frequency': 462.5625,
'protocol': 'auto',
})
assert resp.status_code == 503
data = resp.get_json()
assert 'dsd' in data['message']
def test_dmr_start_no_rtl_fm(auth_client):
"""Start should fail when rtl_fm is missing."""
with patch('routes.dmr.find_dsd', return_value='/usr/bin/dsd'), \
patch('routes.dmr.find_rtl_fm', return_value=None):
resp = auth_client.post('/dmr/start', json={
'frequency': 462.5625,
})
assert resp.status_code == 503
def test_dmr_start_invalid_protocol(auth_client):
"""Start should reject invalid protocol."""
with patch('routes.dmr.find_dsd', return_value='/usr/bin/dsd'), \
patch('routes.dmr.find_rtl_fm', return_value='/usr/bin/rtl_fm'):
resp = auth_client.post('/dmr/start', json={
'frequency': 462.5625,
'protocol': 'invalid',
})
assert resp.status_code == 400
def test_dmr_stop(auth_client):
"""Stop should succeed."""
resp = auth_client.post('/dmr/stop')
assert resp.status_code == 200
data = resp.get_json()
assert data['status'] == 'stopped'
def test_dmr_stream_mimetype(auth_client):
"""Stream should return event-stream content type."""
resp = auth_client.get('/dmr/stream')
assert resp.content_type.startswith('text/event-stream')
+321
View File
@@ -0,0 +1,321 @@
"""Tests for the KiwiSDR WebSocket audio client."""
import struct
from unittest.mock import patch, MagicMock
import pytest
from utils.kiwisdr import (
KiwiSDRClient,
KIWI_SAMPLE_RATE,
KIWI_SND_HEADER_SIZE,
KIWI_DEFAULT_PORT,
MODE_FILTERS,
VALID_MODES,
parse_host_port,
)
# ============================================
# parse_host_port tests
# ============================================
def test_parse_host_port_basic():
"""Should parse host:port from a simple URL."""
assert parse_host_port('http://kiwi.example.com:8073') == ('kiwi.example.com', 8073)
def test_parse_host_port_no_port():
"""Should default to 8073 when port is missing."""
assert parse_host_port('http://kiwi.example.com') == ('kiwi.example.com', KIWI_DEFAULT_PORT)
def test_parse_host_port_https():
"""Should strip https:// prefix."""
assert parse_host_port('https://secure.kiwi.com:9090') == ('secure.kiwi.com', 9090)
def test_parse_host_port_ws():
"""Should strip ws:// prefix."""
assert parse_host_port('ws://kiwi.local:8074') == ('kiwi.local', 8074)
def test_parse_host_port_with_path():
"""Should strip trailing path from URL."""
assert parse_host_port('http://kiwi.com:8073/some/path') == ('kiwi.com', 8073)
def test_parse_host_port_bare_host():
"""Should handle bare hostname without protocol."""
assert parse_host_port('kiwi.local') == ('kiwi.local', KIWI_DEFAULT_PORT)
def test_parse_host_port_bare_host_with_port():
"""Should handle bare hostname with port."""
assert parse_host_port('kiwi.local:8074') == ('kiwi.local', 8074)
def test_parse_host_port_empty():
"""Should handle empty/None input."""
assert parse_host_port('') == ('', KIWI_DEFAULT_PORT)
def test_parse_host_port_invalid_port():
"""Should default port for non-numeric port."""
assert parse_host_port('http://kiwi.com:abc') == ('kiwi.com', KIWI_DEFAULT_PORT)
# ============================================
# SND frame parsing tests
# ============================================
def _make_snd_frame(smeter_raw: int, pcm_samples: list[int]) -> bytes:
"""Build a mock KiwiSDR SND binary frame."""
header = b'SND' # 3 bytes: magic
header += b'\x00' # 1 byte: flags
header += struct.pack('>I', 42) # 4 bytes: sequence number
header += struct.pack('>h', smeter_raw) # 2 bytes: S-meter
# PCM data: 16-bit signed LE
pcm = b''.join(struct.pack('<h', s) for s in pcm_samples)
return header + pcm
def test_parse_snd_frame_smeter():
"""Should extract S-meter value from SND frame."""
client = KiwiSDRClient(host='test', port=8073)
audio_data = []
def on_audio(pcm, smeter):
audio_data.append((pcm, smeter))
client._on_audio = on_audio
frame = _make_snd_frame(-730, [100, -100, 200]) # -73.0 dBm = S9
client._parse_snd_frame(frame)
assert client.last_smeter == -730
assert len(audio_data) == 1
assert audio_data[0][1] == -730
def test_parse_snd_frame_pcm_data():
"""Should forward PCM data from SND frame."""
client = KiwiSDRClient(host='test', port=8073)
received_pcm = []
def on_audio(pcm, smeter):
received_pcm.append(pcm)
client._on_audio = on_audio
samples = [1000, -2000, 3000, -4000]
frame = _make_snd_frame(0, samples)
client._parse_snd_frame(frame)
assert len(received_pcm) == 1
# PCM data is 8 bytes (4 samples * 2 bytes each)
assert len(received_pcm[0]) == len(samples) * 2
def test_parse_snd_frame_short():
"""Should ignore frames shorter than header size."""
client = KiwiSDRClient(host='test', port=8073)
client._on_audio = MagicMock()
client._parse_snd_frame(b'SND\x00') # Too short
client._on_audio.assert_not_called()
def test_parse_snd_frame_wrong_magic():
"""Should ignore frames with wrong header magic."""
client = KiwiSDRClient(host='test', port=8073)
client._on_audio = MagicMock()
frame = b'XXX' + b'\x00' * 7 + b'\x00' * 10 # Wrong magic
client._parse_snd_frame(frame)
client._on_audio.assert_not_called()
# ============================================
# Client state tests
# ============================================
def test_client_initial_state():
"""New client should start disconnected."""
client = KiwiSDRClient(host='kiwi.local', port=8073)
assert client.connected is False
assert client.host == 'kiwi.local'
assert client.port == 8073
assert client.frequency_khz == 0
assert client.mode == 'am'
def test_client_tune_when_disconnected():
"""Tune should fail when not connected."""
client = KiwiSDRClient(host='test', port=8073)
assert client.tune(7000, 'usb') is False
def test_client_disconnect_when_not_connected():
"""Disconnect should not raise when already disconnected."""
client = KiwiSDRClient(host='test', port=8073)
client.disconnect() # Should not raise
assert client.connected is False
@patch('utils.kiwisdr.WEBSOCKET_CLIENT_AVAILABLE', False)
def test_client_connect_no_websocket():
"""Connect should fail if websocket-client not available."""
client = KiwiSDRClient(host='test', port=8073)
assert client.connect(7000, 'am') is False
# ============================================
# Constants tests
# ============================================
def test_sample_rate():
"""Sample rate should be 12 kHz."""
assert KIWI_SAMPLE_RATE == 12000
def test_snd_header_size():
"""SND header should be 10 bytes."""
assert KIWI_SND_HEADER_SIZE == 10
def test_valid_modes():
"""All expected modes should be in VALID_MODES."""
assert 'am' in VALID_MODES
assert 'usb' in VALID_MODES
assert 'lsb' in VALID_MODES
assert 'cw' in VALID_MODES
def test_mode_filters_defined():
"""All valid modes should have filter definitions."""
for mode in VALID_MODES:
assert mode in MODE_FILTERS
low, high = MODE_FILTERS[mode]
assert low < high
def test_mode_filter_am_symmetric():
"""AM filter should be symmetric."""
low, high = MODE_FILTERS['am']
assert low == -high
def test_mode_filter_usb_positive():
"""USB filter should be in positive passband."""
low, high = MODE_FILTERS['usb']
assert low > 0
assert high > low
def test_mode_filter_lsb_negative():
"""LSB filter should be in negative passband."""
low, high = MODE_FILTERS['lsb']
assert low < 0
assert high < 0
# ============================================
# Connection tests with mocked WebSocket
# ============================================
@patch('utils.kiwisdr.WEBSOCKET_CLIENT_AVAILABLE', True)
@patch('utils.kiwisdr.websocket')
def test_client_connect_success(mock_ws_module):
"""Connect should establish a WebSocket connection."""
mock_ws = MagicMock()
mock_ws_module.WebSocket.return_value = mock_ws
client = KiwiSDRClient(host='kiwi.local', port=8073)
result = client.connect(7000, 'am')
assert result is True
assert client.connected is True
assert client.frequency_khz == 7000
assert client.mode == 'am'
# Verify WebSocket was created and connected
mock_ws_module.WebSocket.assert_called_once()
mock_ws.connect.assert_called_once()
# Verify protocol messages were sent
calls = [str(c) for c in mock_ws.send.call_args_list]
auth_sent = any('SET auth' in c for c in calls)
compression_sent = any('SET compression=0' in c for c in calls)
mod_sent = any('SET mod=am' in c and 'freq=7000' in c for c in calls)
assert auth_sent, "Auth message not sent"
assert compression_sent, "Compression message not sent"
assert mod_sent, "Tune message not sent"
# Cleanup
client.disconnect()
@patch('utils.kiwisdr.WEBSOCKET_CLIENT_AVAILABLE', True)
@patch('utils.kiwisdr.websocket')
def test_client_connect_failure(mock_ws_module):
"""Connect should handle connection failures."""
mock_ws = MagicMock()
mock_ws.connect.side_effect = ConnectionRefusedError("Connection refused")
mock_ws_module.WebSocket.return_value = mock_ws
client = KiwiSDRClient(host='unreachable.local', port=8073)
result = client.connect(7000, 'am')
assert result is False
assert client.connected is False
@patch('utils.kiwisdr.WEBSOCKET_CLIENT_AVAILABLE', True)
@patch('utils.kiwisdr.websocket')
def test_client_tune_success(mock_ws_module):
"""Tune should send the correct SET mod command."""
mock_ws = MagicMock()
mock_ws_module.WebSocket.return_value = mock_ws
client = KiwiSDRClient(host='kiwi.local', port=8073)
client.connect(7000, 'am')
mock_ws.send.reset_mock()
result = client.tune(14000, 'usb')
assert result is True
assert client.frequency_khz == 14000
assert client.mode == 'usb'
tune_calls = [str(c) for c in mock_ws.send.call_args_list]
assert any('SET mod=usb' in c and 'freq=14000' in c for c in tune_calls)
client.disconnect()
@patch('utils.kiwisdr.WEBSOCKET_CLIENT_AVAILABLE', True)
@patch('utils.kiwisdr.websocket')
def test_client_invalid_mode_fallback(mock_ws_module):
"""Connect with invalid mode should fall back to AM."""
mock_ws = MagicMock()
mock_ws_module.WebSocket.return_value = mock_ws
client = KiwiSDRClient(host='kiwi.local', port=8073)
client.connect(7000, 'invalid_mode')
assert client.mode == 'am'
client.disconnect()
@patch('utils.kiwisdr.WEBSOCKET_CLIENT_AVAILABLE', True)
@patch('utils.kiwisdr.websocket')
def test_client_ws_url_format(mock_ws_module):
"""WebSocket URL should follow KiwiSDR format."""
mock_ws = MagicMock()
mock_ws_module.WebSocket.return_value = mock_ws
client = KiwiSDRClient(host='test.kiwi.com', port=8074)
client.connect(7000, 'am')
ws_url = mock_ws.connect.call_args[0][0]
assert ws_url.startswith('ws://test.kiwi.com:8074/')
assert ws_url.endswith('/SND')
client.disconnect()
+26 -18
View File
@@ -1,7 +1,8 @@
import pytest import pytest
from pathlib import Path from pathlib import Path
import importlib.metadata import importlib.metadata
import tomllib # Standard in Python 3.11+ import tomllib
import re
def get_root_path(): def get_root_path():
return Path(__file__).parent.parent return Path(__file__).parent.parent
@@ -18,20 +19,22 @@ def parse_txt_requirements(file_path):
with open(file_path, "r") as f: with open(file_path, "r") as f:
for line in f: for line in f:
line = line.strip() line = line.strip()
# Ignore empty lines, comments, and recursive/local flags
if not line or line.startswith(("#", "-e", "git+", "-r")): if not line or line.startswith(("#", "-e", "git+", "-r")):
continue continue
packages.add(_clean_string(line)) packages.add(_clean_string(line))
return packages return packages
def parse_toml_section(data, section_type="main"): def parse_toml_section(data, section_type="main"):
"""Extracts full requirement strings from pyproject.toml.""" """Extracts full requirement strings from pyproject.toml including optional sections."""
packages = set() packages = set()
project = data.get("project", {})
if section_type == "main": if section_type == "main":
deps = data.get("project", {}).get("dependencies", []) deps = project.get("dependencies", [])
else: elif section_type == "optional":
# Check optional-dependencies or dependency-groups deps = project.get("optional-dependencies", {}).get("optionals", [])
deps = data.get("project", {}).get("optional-dependencies", {}).get("dev", []) elif section_type == "dev":
deps = project.get("optional-dependencies", {}).get("dev", [])
if not deps: if not deps:
deps = data.get("dependency-groups", {}).get("dev", []) deps = data.get("dependency-groups", {}).get("dev", [])
@@ -48,9 +51,10 @@ def test_dependency_files_integrity():
with open(toml_path, "rb") as f: with open(toml_path, "rb") as f:
toml_data = tomllib.load(f) toml_data = tomllib.load(f)
# Validate Production Sync # Validate Production Sync (Main + Optionals)
txt_main = parse_txt_requirements(root / "requirements.txt") txt_main = parse_txt_requirements(root / "requirements.txt")
toml_main = parse_toml_section(toml_data, "main") toml_main = parse_toml_section(toml_data, "main") | parse_toml_section(toml_data, "optional")
assert txt_main == toml_main, ( assert txt_main == toml_main, (
f"Production version mismatch!\n" f"Production version mismatch!\n"
f"Only in TXT: {txt_main - toml_main}\n" f"Only in TXT: {txt_main - toml_main}\n"
@@ -72,7 +76,11 @@ def test_environment_vs_toml():
with open(root / "pyproject.toml", "rb") as f: with open(root / "pyproject.toml", "rb") as f:
data = tomllib.load(f) data = tomllib.load(f)
all_declared = parse_toml_section(data, "main") | parse_toml_section(data, "dev") all_declared = (
parse_toml_section(data, "main") |
parse_toml_section(data, "optional") |
parse_toml_section(data, "dev")
)
_verify_installation(all_declared, "TOML") _verify_installation(all_declared, "TOML")
def test_environment_vs_requirements(): def test_environment_vs_requirements():
@@ -89,21 +97,21 @@ def _verify_installation(package_set, source_name):
missing_or_wrong = [] missing_or_wrong = []
for req in package_set: for req in package_set:
# Split name from version to check installation status # Split name from version
# handles ==, >=, ~=, <=, > , <
import re
parts = re.split(r'==|>=|~=|<=|>|<', req) parts = re.split(r'==|>=|~=|<=|>|<', req)
name = parts[0].strip() raw_name = parts[0].strip()
# CLEAN EXTRAS: "qrcode[pil]" -> "qrcode"
clean_name = re.sub(r'\[.*\]', '', raw_name)
try: try:
installed_ver = importlib.metadata.version(name) installed_ver = importlib.metadata.version(clean_name)
# If the config uses exact versioning '==', we can do a strict check
if "==" in req: if "==" in req:
expected_ver = req.split("==")[1].strip() expected_ver = req.split("==")[1].strip()
if installed_ver != expected_ver: if installed_ver != expected_ver:
missing_or_wrong.append(f"{name} (Installed: {installed_ver}, Expected: {expected_ver})") missing_or_wrong.append(f"{clean_name} (Installed: {installed_ver}, Expected: {expected_ver})")
except importlib.metadata.PackageNotFoundError: except importlib.metadata.PackageNotFoundError:
missing_or_wrong.append(f"{name} (Not installed)") missing_or_wrong.append(f"{clean_name} (Not installed)")
if missing_or_wrong: if missing_or_wrong:
pytest.fail(f"Environment out of sync with {source_name}:\n" + "\n".join(missing_or_wrong)) pytest.fail(f"Environment out of sync with {source_name}:\n" + "\n".join(missing_or_wrong))
+100
View File
@@ -0,0 +1,100 @@
"""Tests for the Signal Identification (guess) API endpoint."""
import pytest
@pytest.fixture
def auth_client(client):
"""Client with logged-in session."""
with client.session_transaction() as sess:
sess['logged_in'] = True
return client
def test_signal_guess_fm_broadcast(auth_client):
"""FM broadcast frequency should return a known signal type."""
resp = auth_client.post('/listening/signal/guess', json={
'frequency_mhz': 98.1,
'modulation': 'wfm',
})
assert resp.status_code == 200
data = resp.get_json()
assert data['status'] == 'ok'
assert data['primary_label']
assert data['confidence'] in ('HIGH', 'MEDIUM', 'LOW')
def test_signal_guess_airband(auth_client):
"""Airband frequency should be identified."""
resp = auth_client.post('/listening/signal/guess', json={
'frequency_mhz': 121.5,
'modulation': 'am',
})
assert resp.status_code == 200
data = resp.get_json()
assert data['status'] == 'ok'
assert data['primary_label']
def test_signal_guess_ism_band(auth_client):
"""ISM band frequency (433.92 MHz) should be identified."""
resp = auth_client.post('/listening/signal/guess', json={
'frequency_mhz': 433.92,
})
assert resp.status_code == 200
data = resp.get_json()
assert data['status'] == 'ok'
assert data['primary_label']
assert data['confidence'] in ('HIGH', 'MEDIUM', 'LOW')
def test_signal_guess_missing_frequency(auth_client):
"""Missing frequency should return 400."""
resp = auth_client.post('/listening/signal/guess', json={})
assert resp.status_code == 400
data = resp.get_json()
assert data['status'] == 'error'
def test_signal_guess_invalid_frequency(auth_client):
"""Invalid frequency value should return 400."""
resp = auth_client.post('/listening/signal/guess', json={
'frequency_mhz': 'abc',
})
assert resp.status_code == 400
def test_signal_guess_negative_frequency(auth_client):
"""Negative frequency should return 400."""
resp = auth_client.post('/listening/signal/guess', json={
'frequency_mhz': -5.0,
})
assert resp.status_code == 400
def test_signal_guess_with_region(auth_client):
"""Specifying region should work."""
resp = auth_client.post('/listening/signal/guess', json={
'frequency_mhz': 462.5625,
'region': 'US',
})
assert resp.status_code == 200
data = resp.get_json()
assert data['status'] == 'ok'
def test_signal_guess_response_structure(auth_client):
"""Response should have all expected fields."""
resp = auth_client.post('/listening/signal/guess', json={
'frequency_mhz': 146.52,
'modulation': 'fm',
})
assert resp.status_code == 200
data = resp.get_json()
assert 'primary_label' in data
assert 'confidence' in data
assert 'alternatives' in data
assert 'explanation' in data
assert 'tags' in data
assert isinstance(data['alternatives'], list)
assert isinstance(data['tags'], list)
+80
View File
@@ -0,0 +1,80 @@
"""Tests for the Waterfall / Spectrogram endpoints."""
from unittest.mock import patch, MagicMock
import pytest
@pytest.fixture
def auth_client(client):
"""Client with logged-in session."""
with client.session_transaction() as sess:
sess['logged_in'] = True
return client
def test_waterfall_start_no_rtl_power(auth_client):
"""Start should fail gracefully when rtl_power is not available."""
with patch('routes.listening_post.find_rtl_power', return_value=None):
resp = auth_client.post('/listening/waterfall/start', json={
'start_freq': 88.0,
'end_freq': 108.0,
})
assert resp.status_code == 503
data = resp.get_json()
assert 'rtl_power' in data['message']
def test_waterfall_start_invalid_range(auth_client):
"""Start should reject end <= start."""
with patch('routes.listening_post.find_rtl_power', return_value='/usr/bin/rtl_power'):
resp = auth_client.post('/listening/waterfall/start', json={
'start_freq': 108.0,
'end_freq': 88.0,
})
assert resp.status_code == 400
def test_waterfall_start_success(auth_client):
"""Start should succeed with mocked rtl_power and device."""
with patch('routes.listening_post.find_rtl_power', return_value='/usr/bin/rtl_power'), \
patch('routes.listening_post.app_module') as mock_app:
mock_app.claim_sdr_device.return_value = None # No error, claim succeeds
resp = auth_client.post('/listening/waterfall/start', json={
'start_freq': 88.0,
'end_freq': 108.0,
'gain': 40,
'device': 0,
})
assert resp.status_code == 200
data = resp.get_json()
assert data['status'] == 'started'
# Clean up: stop waterfall
import routes.listening_post as lp
lp.waterfall_running = False
def test_waterfall_stop(auth_client):
"""Stop should succeed."""
resp = auth_client.post('/listening/waterfall/stop')
assert resp.status_code == 200
data = resp.get_json()
assert data['status'] == 'stopped'
def test_waterfall_stream_mimetype(auth_client):
"""Stream should return event-stream content type."""
resp = auth_client.get('/listening/waterfall/stream')
assert resp.content_type.startswith('text/event-stream')
def test_waterfall_start_device_busy(auth_client):
"""Start should fail when device is in use."""
with patch('routes.listening_post.find_rtl_power', return_value='/usr/bin/rtl_power'), \
patch('routes.listening_post.app_module') as mock_app:
mock_app.claim_sdr_device.return_value = 'SDR device 0 is in use by scanner'
resp = auth_client.post('/listening/waterfall/start', json={
'start_freq': 88.0,
'end_freq': 108.0,
})
assert resp.status_code == 409
+170
View File
@@ -0,0 +1,170 @@
"""Tests for the HF/Shortwave WebSDR integration."""
from unittest.mock import patch, MagicMock
import pytest
from routes.websdr import _parse_gps_coord, _haversine
from utils.kiwisdr import parse_host_port
# ============================================
# Helper function tests
# ============================================
def test_parse_gps_coord_float():
"""Should parse a simple float string."""
assert _parse_gps_coord('51.5074') == pytest.approx(51.5074)
def test_parse_gps_coord_negative():
"""Should parse a negative coordinate."""
assert _parse_gps_coord('-33.87') == pytest.approx(-33.87)
def test_parse_gps_coord_parentheses():
"""Should handle parentheses in coordinate string."""
assert _parse_gps_coord('(-33.87)') == pytest.approx(-33.87)
def test_parse_gps_coord_empty():
"""Should return None for empty string."""
assert _parse_gps_coord('') is None
assert _parse_gps_coord(None) is None
def test_parse_gps_coord_invalid():
"""Should return None for invalid string."""
assert _parse_gps_coord('abc') is None
def test_haversine_same_point():
"""Distance between same point should be 0."""
assert _haversine(51.5, -0.1, 51.5, -0.1) == pytest.approx(0.0, abs=0.01)
def test_haversine_known_distance():
"""Test with known city pair (London to Paris ~343 km)."""
dist = _haversine(51.5074, -0.1278, 48.8566, 2.3522)
assert 340 < dist < 350
# ============================================
# Endpoint tests
# ============================================
@pytest.fixture
def auth_client(client):
"""Client with logged-in session."""
with client.session_transaction() as sess:
sess['logged_in'] = True
return client
def test_websdr_status(auth_client):
"""Status endpoint should return cache info."""
resp = auth_client.get('/websdr/status')
assert resp.status_code == 200
data = resp.get_json()
assert data['status'] == 'ok'
assert 'cached_receivers' in data
def test_websdr_receivers_empty_cache(auth_client):
"""Receivers endpoint should work even with empty cache."""
with patch('routes.websdr.get_receivers', return_value=[]):
resp = auth_client.get('/websdr/receivers')
assert resp.status_code == 200
data = resp.get_json()
assert data['status'] == 'success'
assert data['receivers'] == []
def test_websdr_receivers_with_data(auth_client):
"""Receivers endpoint should return filtered data."""
mock_receivers = [
{'name': 'Test RX', 'url': 'http://test.com', 'lat': 51.5, 'lon': -0.1,
'users': 1, 'users_max': 4, 'available': True, 'freq_lo': 0, 'freq_hi': 30000,
'antenna': 'Dipole', 'bands': 'HF'},
{'name': 'Full RX', 'url': 'http://full.com', 'lat': 48.8, 'lon': 2.3,
'users': 4, 'users_max': 4, 'available': False, 'freq_lo': 0, 'freq_hi': 30000,
'antenna': 'Loop', 'bands': 'HF'},
]
with patch('routes.websdr.get_receivers', return_value=mock_receivers):
# Filter available only
resp = auth_client.get('/websdr/receivers?available=true')
assert resp.status_code == 200
data = resp.get_json()
assert len(data['receivers']) == 1
assert data['receivers'][0]['name'] == 'Test RX'
def test_websdr_nearest_missing_params(auth_client):
"""Nearest endpoint should require lat/lon."""
resp = auth_client.get('/websdr/receivers/nearest')
assert resp.status_code == 400
def test_websdr_nearest_with_coords(auth_client):
"""Nearest endpoint should sort by distance."""
mock_receivers = [
{'name': 'Far RX', 'url': 'http://far.com', 'lat': -33.87, 'lon': 151.21,
'users': 0, 'users_max': 4, 'available': True, 'freq_lo': 0, 'freq_hi': 30000,
'antenna': 'Dipole', 'bands': 'HF'},
{'name': 'Near RX', 'url': 'http://near.com', 'lat': 51.0, 'lon': -0.5,
'users': 0, 'users_max': 4, 'available': True, 'freq_lo': 0, 'freq_hi': 30000,
'antenna': 'Loop', 'bands': 'HF'},
]
with patch('routes.websdr.get_receivers', return_value=mock_receivers):
resp = auth_client.get('/websdr/receivers/nearest?lat=51.5&lon=-0.1')
assert resp.status_code == 200
data = resp.get_json()
assert data['status'] == 'success'
assert len(data['receivers']) == 2
# Near should be first
assert data['receivers'][0]['name'] == 'Near RX'
def test_websdr_spy_station_receivers(auth_client):
"""Spy station cross-reference should find matching receivers."""
mock_receivers = [
{'name': 'HF RX', 'url': 'http://hf.com', 'lat': 51.5, 'lon': -0.1,
'users': 0, 'users_max': 4, 'available': True, 'freq_lo': 0, 'freq_hi': 30000,
'antenna': 'Dipole', 'bands': 'HF'},
]
with patch('routes.websdr.get_receivers', return_value=mock_receivers):
# e06 is one of the spy stations
resp = auth_client.get('/websdr/spy-station/e06/receivers')
assert resp.status_code == 200
data = resp.get_json()
assert data['status'] == 'success'
assert 'station' in data
def test_websdr_spy_station_not_found(auth_client):
"""Non-existent station should return 404."""
resp = auth_client.get('/websdr/spy-station/nonexistent/receivers')
assert resp.status_code == 404
# ============================================
# parse_host_port tests (integration)
# ============================================
def test_parse_host_port_http_url():
"""Should parse standard KiwiSDR URL."""
host, port = parse_host_port('http://kiwi.example.com:8073')
assert host == 'kiwi.example.com'
assert port == 8073
def test_parse_host_port_no_protocol():
"""Should handle bare hostname."""
host, port = parse_host_port('my-kiwi.local:8074')
assert host == 'my-kiwi.local'
assert port == 8074
def test_parse_host_port_with_trailing_slash():
"""Should handle URL with trailing path."""
host, port = parse_host_port('http://kiwi.com:8073/')
assert host == 'kiwi.com'
assert port == 8073
+106
View File
@@ -1215,6 +1215,112 @@ def delete_known_device(identifier: str) -> bool:
return cursor.rowcount > 0 return cursor.rowcount > 0
# =============================================================================
# TSCM Schedule Functions
# =============================================================================
def create_tscm_schedule(
name: str,
cron_expression: str,
sweep_type: str = 'standard',
baseline_id: int | None = None,
zone_name: str | None = None,
enabled: bool = True,
notify_on_threat: bool = True,
notify_email: str | None = None,
last_run: str | None = None,
next_run: str | None = None,
) -> int:
"""Create a new TSCM sweep schedule."""
with get_db() as conn:
cursor = conn.execute('''
INSERT INTO tscm_schedules
(name, baseline_id, zone_name, cron_expression, sweep_type,
enabled, last_run, next_run, notify_on_threat, notify_email)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
name,
baseline_id,
zone_name,
cron_expression,
sweep_type,
1 if enabled else 0,
last_run,
next_run,
1 if notify_on_threat else 0,
notify_email,
))
return cursor.lastrowid
def get_tscm_schedule(schedule_id: int) -> dict | None:
"""Get a TSCM schedule by ID."""
with get_db() as conn:
cursor = conn.execute(
'SELECT * FROM tscm_schedules WHERE id = ?',
(schedule_id,)
)
row = cursor.fetchone()
return dict(row) if row else None
def get_all_tscm_schedules(
enabled: bool | None = None,
limit: int = 200
) -> list[dict]:
"""Get all TSCM schedules."""
conditions = []
params = []
if enabled is not None:
conditions.append('enabled = ?')
params.append(1 if enabled else 0)
where_clause = f'WHERE {" AND ".join(conditions)}' if conditions else ''
params.append(limit)
with get_db() as conn:
cursor = conn.execute(f'''
SELECT * FROM tscm_schedules
{where_clause}
ORDER BY id DESC
LIMIT ?
''', params)
return [dict(row) for row in cursor]
def update_tscm_schedule(schedule_id: int, **fields) -> bool:
"""Update a TSCM schedule."""
if not fields:
return False
updates = []
params = []
for key, value in fields.items():
updates.append(f'{key} = ?')
params.append(value)
params.append(schedule_id)
with get_db() as conn:
cursor = conn.execute(
f'UPDATE tscm_schedules SET {", ".join(updates)} WHERE id = ?',
params
)
return cursor.rowcount > 0
def delete_tscm_schedule(schedule_id: int) -> bool:
"""Delete a TSCM schedule."""
with get_db() as conn:
cursor = conn.execute(
'DELETE FROM tscm_schedules WHERE id = ?',
(schedule_id,)
)
return cursor.rowcount > 0
def is_known_good_device(identifier: str, location: str | None = None) -> dict | None: def is_known_good_device(identifier: str, location: str | None = None) -> dict | None:
"""Check if a device is in the known-good registry for a location.""" """Check if a device is in the known-good registry for a location."""
with get_db() as conn: with get_db() as conn:
+288
View File
@@ -0,0 +1,288 @@
"""KiwiSDR WebSocket audio client.
Connects to a KiwiSDR receiver via its WebSocket API and streams
decoded PCM audio back through a callback.
"""
from __future__ import annotations
import struct
import threading
import time
from typing import Optional, Callable
try:
import websocket # websocket-client library
WEBSOCKET_CLIENT_AVAILABLE = True
except ImportError:
WEBSOCKET_CLIENT_AVAILABLE = False
from utils.logging import get_logger
logger = get_logger('intercept.kiwisdr')
# Protocol constants
KIWI_KEEPALIVE_INTERVAL = 5.0
KIWI_SAMPLE_RATE = 12000 # 12 kHz mono
KIWI_SND_HEADER_SIZE = 10 # "SND"(3) + flags(1) + seq(4) + smeter(2)
KIWI_DEFAULT_PORT = 8073
VALID_MODES = ('am', 'usb', 'lsb', 'cw')
# Default bandpass filters per mode (Hz)
MODE_FILTERS = {
'am': (-4500, 4500),
'usb': (300, 3000),
'lsb': (-3000, -300),
'cw': (300, 800),
}
def parse_host_port(url: str) -> tuple[str, int]:
"""Extract host and port from a KiwiSDR URL like 'http://host:port'.
Returns (host, port) tuple. Defaults to port 8073 if not specified.
"""
if not url:
return ('', KIWI_DEFAULT_PORT)
# Strip protocol
cleaned = url
for prefix in ('http://', 'https://', 'ws://', 'wss://'):
if cleaned.lower().startswith(prefix):
cleaned = cleaned[len(prefix):]
break
# Strip path
cleaned = cleaned.split('/')[0]
# Split host:port
if ':' in cleaned:
parts = cleaned.rsplit(':', 1)
host = parts[0]
try:
port = int(parts[1])
except ValueError:
port = KIWI_DEFAULT_PORT
else:
host = cleaned
port = KIWI_DEFAULT_PORT
return (host, port)
class KiwiSDRClient:
"""Manages a WebSocket connection to a single KiwiSDR receiver."""
def __init__(
self,
host: str,
port: int = KIWI_DEFAULT_PORT,
on_audio: Optional[Callable[[bytes, int], None]] = None,
on_error: Optional[Callable[[str], None]] = None,
on_disconnect: Optional[Callable[[], None]] = None,
password: str = '',
):
self.host = host
self.port = port
self.password = password
self._on_audio = on_audio
self._on_error = on_error
self._on_disconnect = on_disconnect
self._ws = None
self._connected = False
self._stopping = False
self._receive_thread: Optional[threading.Thread] = None
self._keepalive_thread: Optional[threading.Thread] = None
self._send_lock = threading.Lock()
self.frequency_khz: float = 0
self.mode: str = 'am'
self.last_smeter: int = 0
@property
def connected(self) -> bool:
return self._connected
def connect(self, frequency_khz: float, mode: str = 'am') -> bool:
"""Connect to KiwiSDR and start receiving audio."""
if not WEBSOCKET_CLIENT_AVAILABLE:
logger.error("websocket-client not installed")
return False
if self._connected:
self.disconnect()
self.frequency_khz = frequency_khz
self.mode = mode if mode in VALID_MODES else 'am'
self._stopping = False
ws_url = self._build_ws_url()
logger.info(f"Connecting to KiwiSDR: {ws_url}")
try:
self._ws = websocket.WebSocket()
self._ws.settimeout(10)
self._ws.connect(ws_url)
# Auth
self._send('SET auth t=kiwi p=' + self.password)
time.sleep(0.2)
# Request uncompressed PCM
self._send('SET compression=0')
# Set AGC
self._send('SET agc=1 hang=0 thresh=-100 slope=6 decay=1000 manGain=50')
# Tune to frequency
self._send_tune(frequency_khz, self.mode)
# Request audio start
self._send('SET AR OK in=12000 out=44100')
self._connected = True
# Start receive thread
self._receive_thread = threading.Thread(
target=self._receive_loop, daemon=True, name='kiwi-rx'
)
self._receive_thread.start()
# Start keepalive thread
self._keepalive_thread = threading.Thread(
target=self._keepalive_loop, daemon=True, name='kiwi-ka'
)
self._keepalive_thread.start()
logger.info(f"Connected to KiwiSDR {self.host}:{self.port} @ {frequency_khz} kHz {self.mode}")
return True
except Exception as e:
logger.error(f"KiwiSDR connection failed: {e}")
self._cleanup()
return False
def tune(self, frequency_khz: float, mode: str = 'am') -> bool:
"""Retune without disconnecting."""
if not self._connected or not self._ws:
return False
self.frequency_khz = frequency_khz
if mode in VALID_MODES:
self.mode = mode
try:
self._send_tune(frequency_khz, self.mode)
logger.info(f"Retuned to {frequency_khz} kHz {self.mode}")
return True
except Exception as e:
logger.error(f"Retune failed: {e}")
return False
def disconnect(self) -> None:
"""Cleanly disconnect from KiwiSDR."""
self._stopping = True
self._connected = False
self._cleanup()
logger.info("Disconnected from KiwiSDR")
def _build_ws_url(self) -> str:
ts = int(time.time() * 1000)
return f'ws://{self.host}:{self.port}/{ts}/SND'
def _send(self, msg: str) -> None:
with self._send_lock:
if self._ws:
self._ws.send(msg)
def _send_tune(self, freq_khz: float, mode: str) -> None:
low_cut, high_cut = MODE_FILTERS.get(mode, MODE_FILTERS['am'])
self._send(f'SET mod={mode} low_cut={low_cut} high_cut={high_cut} freq={freq_khz}')
def _receive_loop(self) -> None:
"""Background thread: read frames from KiwiSDR WebSocket."""
try:
while self._connected and not self._stopping:
try:
if not self._ws:
break
self._ws.settimeout(2.0)
data = self._ws.recv()
except websocket.WebSocketTimeoutException:
continue
except Exception as e:
if not self._stopping:
logger.error(f"KiwiSDR receive error: {e}")
break
if not data or not isinstance(data, bytes):
# Text message (status/config) — ignore
continue
self._parse_snd_frame(data)
except Exception as e:
if not self._stopping:
logger.error(f"KiwiSDR receive loop error: {e}")
finally:
if not self._stopping:
self._connected = False
if self._on_disconnect:
try:
self._on_disconnect()
except Exception:
pass
def _parse_snd_frame(self, data: bytes) -> None:
"""Parse a KiwiSDR SND binary frame."""
if len(data) < KIWI_SND_HEADER_SIZE:
return
# Check header magic
if data[:3] != b'SND':
return
# flags = data[3]
# seq = struct.unpack('>I', data[4:8])[0]
# S-meter: big-endian int16 at offset 8
smeter_raw = struct.unpack('>h', data[8:10])[0]
self.last_smeter = smeter_raw
# PCM audio data starts at offset 10
pcm_data = data[KIWI_SND_HEADER_SIZE:]
if pcm_data and self._on_audio:
try:
self._on_audio(pcm_data, smeter_raw)
except Exception:
pass
def _keepalive_loop(self) -> None:
"""Background thread: send keepalive every 5 seconds."""
while self._connected and not self._stopping:
time.sleep(KIWI_KEEPALIVE_INTERVAL)
if self._connected and not self._stopping:
try:
self._send('SET keepalive')
except Exception:
break
def _cleanup(self) -> None:
"""Close WebSocket and join threads."""
if self._ws:
try:
self._ws.close()
except Exception:
pass
self._ws = None
if self._receive_thread and self._receive_thread.is_alive():
self._receive_thread.join(timeout=3.0)
if self._keepalive_thread and self._keepalive_thread.is_alive():
self._keepalive_thread.join(timeout=3.0)
self._receive_thread = None
self._keepalive_thread = None
+39 -11
View File
@@ -181,6 +181,7 @@ class SSTVImage:
timestamp: datetime timestamp: datetime
frequency: float frequency: float
size_bytes: int = 0 size_bytes: int = 0
url_prefix: str = '/sstv'
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {
@@ -190,7 +191,7 @@ class SSTVImage:
'timestamp': self.timestamp.isoformat(), 'timestamp': self.timestamp.isoformat(),
'frequency': self.frequency, 'frequency': self.frequency,
'size_bytes': self.size_bytes, 'size_bytes': self.size_bytes,
'url': f'/sstv/images/{self.filename}' 'url': f'{self.url_prefix}/images/{self.filename}'
} }
@@ -227,18 +228,20 @@ class SSTVDecoder:
# How often to check/update Doppler (seconds) # How often to check/update Doppler (seconds)
DOPPLER_UPDATE_INTERVAL = 5 DOPPLER_UPDATE_INTERVAL = 5
def __init__(self, output_dir: str | Path | None = None): def __init__(self, output_dir: str | Path | None = None, url_prefix: str = '/sstv'):
self._process = None self._process = None
self._rtl_process = None self._rtl_process = None
self._running = False self._running = False
self._lock = threading.Lock() self._lock = threading.Lock()
self._callback: Callable[[DecodeProgress], None] | None = None self._callback: Callable[[DecodeProgress], None] | None = None
self._output_dir = Path(output_dir) if output_dir else Path('instance/sstv_images') self._output_dir = Path(output_dir) if output_dir else Path('instance/sstv_images')
self._url_prefix = url_prefix
self._images: list[SSTVImage] = [] self._images: list[SSTVImage] = []
self._reader_thread = None self._reader_thread = None
self._watcher_thread = None self._watcher_thread = None
self._doppler_thread = None self._doppler_thread = None
self._frequency = ISS_SSTV_FREQ self._frequency = ISS_SSTV_FREQ
self._modulation = 'fm'
self._current_tuned_freq_hz: int = 0 self._current_tuned_freq_hz: int = 0
self._device_index = 0 self._device_index = 0
@@ -246,6 +249,7 @@ class SSTVDecoder:
self._doppler_tracker = DopplerTracker('ISS') self._doppler_tracker = DopplerTracker('ISS')
self._doppler_enabled = False self._doppler_enabled = False
self._last_doppler_info: DopplerInfo | None = None self._last_doppler_info: DopplerInfo | None = None
self._file_decoder: str | None = None
# Ensure output directory exists # Ensure output directory exists
self._output_dir.mkdir(parents=True, exist_ok=True) self._output_dir.mkdir(parents=True, exist_ok=True)
@@ -268,6 +272,7 @@ class SSTVDecoder:
try: try:
result = subprocess.run(['which', 'slowrx'], capture_output=True, timeout=5) result = subprocess.run(['which', 'slowrx'], capture_output=True, timeout=5)
if result.returncode == 0: if result.returncode == 0:
self._file_decoder = 'slowrx'
return 'slowrx' return 'slowrx'
except Exception: except Exception:
pass pass
@@ -277,7 +282,8 @@ class SSTVDecoder:
# Check for Python sstv package # Check for Python sstv package
try: try:
import sstv import sstv
return 'python-sstv' self._file_decoder = 'python-sstv'
return None
except ImportError: except ImportError:
pass pass
@@ -294,6 +300,7 @@ class SSTVDecoder:
device_index: int = 0, device_index: int = 0,
latitude: float | None = None, latitude: float | None = None,
longitude: float | None = None, longitude: float | None = None,
modulation: str = 'fm',
) -> bool: ) -> bool:
""" """
Start SSTV decoder listening on specified frequency. Start SSTV decoder listening on specified frequency.
@@ -303,6 +310,7 @@ class SSTVDecoder:
device_index: RTL-SDR device index device_index: RTL-SDR device index
latitude: Observer latitude for Doppler correction (optional) latitude: Observer latitude for Doppler correction (optional)
longitude: Observer longitude for Doppler correction (optional) longitude: Observer longitude for Doppler correction (optional)
modulation: Demodulation mode for rtl_fm (fm, usb, lsb). Default: fm
Returns: Returns:
True if started successfully True if started successfully
@@ -321,6 +329,7 @@ class SSTVDecoder:
self._frequency = frequency self._frequency = frequency
self._device_index = device_index self._device_index = device_index
self._modulation = modulation
# Configure Doppler tracking if location provided # Configure Doppler tracking if location provided
self._doppler_enabled = False self._doppler_enabled = False
@@ -396,12 +405,12 @@ class SSTVDecoder:
def _start_rtl_fm_pipeline(self, freq_hz: int) -> None: def _start_rtl_fm_pipeline(self, freq_hz: int) -> None:
"""Start the rtl_fm -> slowrx pipeline at the specified frequency.""" """Start the rtl_fm -> slowrx pipeline at the specified frequency."""
# Build rtl_fm command for FM demodulation # Build rtl_fm command for demodulation
rtl_cmd = [ rtl_cmd = [
'rtl_fm', 'rtl_fm',
'-d', str(self._device_index), '-d', str(self._device_index),
'-f', str(freq_hz), '-f', str(freq_hz),
'-M', 'fm', '-M', self._modulation,
'-s', '48000', '-s', '48000',
'-r', '48000', '-r', '48000',
'-l', '0', # No squelch '-l', '0', # No squelch
@@ -514,7 +523,7 @@ class SSTVDecoder:
'rtl_fm', 'rtl_fm',
'-d', str(self._device_index), '-d', str(self._device_index),
'-f', str(new_freq_hz), '-f', str(new_freq_hz),
'-M', 'fm', '-M', self._modulation,
'-s', '48000', '-s', '48000',
'-r', '48000', '-r', '48000',
'-l', '0', '-l', '0',
@@ -604,7 +613,8 @@ class SSTVDecoder:
mode='Unknown', # Would need to parse from slowrx output mode='Unknown', # Would need to parse from slowrx output
timestamp=datetime.now(timezone.utc), timestamp=datetime.now(timezone.utc),
frequency=self._frequency, frequency=self._frequency,
size_bytes=filepath.stat().st_size size_bytes=filepath.stat().st_size,
url_prefix=self._url_prefix,
) )
self._images.append(image) self._images.append(image)
@@ -662,8 +672,9 @@ class SSTVDecoder:
path=filepath, path=filepath,
mode='Unknown', mode='Unknown',
timestamp=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc), timestamp=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
frequency=ISS_SSTV_FREQ, frequency=self._frequency,
size_bytes=stat.st_size size_bytes=stat.st_size,
url_prefix=self._url_prefix,
) )
self._images.append(image) self._images.append(image)
except Exception as e: except Exception as e:
@@ -693,7 +704,9 @@ class SSTVDecoder:
images = [] images = []
if self._decoder == 'slowrx': decoder = self._decoder or self._file_decoder
if decoder == 'slowrx':
# Use slowrx with file input # Use slowrx with file input
output_file = self._output_dir / f"sstv_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png" output_file = self._output_dir / f"sstv_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png"
@@ -715,7 +728,7 @@ class SSTVDecoder:
) )
images.append(image) images.append(image)
elif self._decoder == 'python-sstv': elif decoder == 'python-sstv':
# Use Python sstv library # Use Python sstv library
try: try:
from sstv.decode import SSTVDecoder as PythonSSTVDecoder from sstv.decode import SSTVDecoder as PythonSSTVDecoder
@@ -762,3 +775,18 @@ def is_sstv_available() -> bool:
"""Check if SSTV decoding is available.""" """Check if SSTV decoding is available."""
decoder = get_sstv_decoder() decoder = get_sstv_decoder()
return decoder.decoder_available is not None return decoder.decoder_available is not None
# Global general SSTV decoder instance (separate from ISS)
_general_decoder: SSTVDecoder | None = None
def get_general_sstv_decoder() -> SSTVDecoder:
"""Get or create the global general SSTV decoder instance."""
global _general_decoder
if _general_decoder is None:
_general_decoder = SSTVDecoder(
output_dir='instance/sstv_general_images',
url_prefix='/sstv-general',
)
return _general_decoder
+4 -2
View File
@@ -372,8 +372,10 @@ def _detect_rf_capabilities(caps: SweepCapabilities, sdr_device: Any) -> None:
if devices: if devices:
device = devices[0] # Use first device device = devices[0] # Use first device
rf_cap.available = True rf_cap.available = True
rf_cap.device_type = device.get('type', 'unknown') rf_cap.device_type = getattr(device, 'sdr_type', 'unknown')
rf_cap.driver = device.get('driver', '') if hasattr(rf_cap.device_type, 'value'):
rf_cap.device_type = rf_cap.device_type.value
rf_cap.driver = getattr(device, 'driver', '')
# Set frequency ranges based on device type # Set frequency ranges based on device type
if 'rtl' in rf_cap.device_type.lower(): if 'rtl' in rf_cap.device_type.lower():
+85
View File
@@ -157,6 +157,9 @@ class DeviceProfile:
# Output # Output
confidence: float = 0.0 confidence: float = 0.0
recommended_action: str = 'monitor' recommended_action: str = 'monitor'
known_device: bool = False
known_device_name: Optional[str] = None
score_modifier: int = 0
def add_rssi_sample(self, rssi: int) -> None: def add_rssi_sample(self, rssi: int) -> None:
"""Add an RSSI sample with timestamp.""" """Add an RSSI sample with timestamp."""
@@ -208,6 +211,26 @@ class DeviceProfile:
indicator_count = len(self.indicators) indicator_count = len(self.indicators)
self.confidence = min(1.0, (indicator_count * 0.15) + (self.total_score * 0.05)) self.confidence = min(1.0, (indicator_count * 0.15) + (self.total_score * 0.05))
def apply_score_modifier(self, modifier: int | None) -> None:
"""Apply a score modifier (e.g., known-good device adjustment)."""
base_score = sum(i.score for i in self.indicators)
modifier_val = int(modifier) if modifier is not None else 0
self.score_modifier = modifier_val
self.total_score = max(0, base_score + modifier_val)
if self.total_score >= 6:
self.risk_level = RiskLevel.HIGH_INTEREST
self.recommended_action = 'investigate'
elif self.total_score >= 3:
self.risk_level = RiskLevel.NEEDS_REVIEW
self.recommended_action = 'review'
else:
self.risk_level = RiskLevel.INFORMATIONAL
self.recommended_action = 'monitor'
indicator_count = len(self.indicators)
self.confidence = min(1.0, (indicator_count * 0.15) + (self.total_score * 0.05))
def to_dict(self) -> dict: def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization.""" """Convert to dictionary for JSON serialization."""
return { return {
@@ -232,10 +255,13 @@ class DeviceProfile:
for i in self.indicators for i in self.indicators
], ],
'total_score': self.total_score, 'total_score': self.total_score,
'score_modifier': self.score_modifier,
'risk_level': self.risk_level.value, 'risk_level': self.risk_level.value,
'confidence': round(self.confidence, 2), 'confidence': round(self.confidence, 2),
'recommended_action': self.recommended_action, 'recommended_action': self.recommended_action,
'correlated_devices': self.correlated_devices, 'correlated_devices': self.correlated_devices,
'known_device': self.known_device,
'known_device_name': self.known_device_name,
} }
@@ -286,6 +312,7 @@ class CorrelationEngine:
self.device_profiles: dict[str, DeviceProfile] = {} self.device_profiles: dict[str, DeviceProfile] = {}
self.meeting_windows: list[tuple[datetime, datetime]] = [] self.meeting_windows: list[tuple[datetime, datetime]] = []
self.correlation_window = timedelta(minutes=5) self.correlation_window = timedelta(minutes=5)
self._known_device_cache: dict[str, dict | None] = {}
def start_meeting_window(self) -> None: def start_meeting_window(self) -> None:
"""Mark the start of a sensitive period (meeting).""" """Mark the start of a sensitive period (meeting)."""
@@ -310,6 +337,54 @@ class CorrelationEngine:
return True return True
return False return False
def _lookup_known_device(self, identifier: str, protocol: str) -> dict | None:
"""Lookup known-good device details with light normalization."""
cache_key = f"{protocol}:{identifier}"
if cache_key in self._known_device_cache:
return self._known_device_cache[cache_key]
try:
from utils.database import is_known_good_device
candidates = []
if identifier:
candidates.append(str(identifier))
if protocol == 'rf':
try:
freq_val = float(identifier)
candidates.append(f"{freq_val:.3f}")
candidates.append(f"{freq_val:.1f}")
except (ValueError, TypeError):
pass
known = None
for cand in candidates:
if not cand:
continue
known = is_known_good_device(str(cand).upper())
if known:
break
except Exception:
known = None
self._known_device_cache[cache_key] = known
return known
def _apply_known_device_modifier(self, profile: DeviceProfile, identifier: str, protocol: str) -> None:
"""Apply known-good score modifier and update profile metadata."""
known = self._lookup_known_device(identifier, protocol)
if known:
profile.known_device = True
profile.known_device_name = known.get('name') if isinstance(known, dict) else None
modifier = known.get('score_modifier', 0) if isinstance(known, dict) else 0
else:
profile.known_device = False
profile.known_device_name = None
modifier = 0
profile.apply_score_modifier(modifier)
def get_or_create_profile(self, identifier: str, protocol: str) -> DeviceProfile: def get_or_create_profile(self, identifier: str, protocol: str) -> DeviceProfile:
"""Get existing profile or create new one.""" """Get existing profile or create new one."""
key = f"{protocol}:{identifier}" key = f"{protocol}:{identifier}"
@@ -583,6 +658,8 @@ class CorrelationEngine:
) )
profile.device_type = 'Samsung SmartTag' profile.device_type = 'Samsung SmartTag'
self._apply_known_device_modifier(profile, mac, 'bluetooth')
return profile return profile
def analyze_wifi_device(self, device: dict) -> DeviceProfile: def analyze_wifi_device(self, device: dict) -> DeviceProfile:
@@ -695,6 +772,8 @@ class CorrelationEngine:
{'rssi': latest_rssi} {'rssi': latest_rssi}
) )
self._apply_known_device_modifier(profile, bssid, 'wifi')
return profile return profile
def analyze_rf_signal(self, signal: dict) -> DeviceProfile: def analyze_rf_signal(self, signal: dict) -> DeviceProfile:
@@ -785,6 +864,8 @@ class CorrelationEngine:
{'during_meeting': True} {'during_meeting': True}
) )
self._apply_known_device_modifier(profile, freq_key, 'rf')
return profile return profile
def correlate_devices(self) -> list[dict]: def correlate_devices(self) -> list[dict]:
@@ -887,6 +968,10 @@ class CorrelationEngine:
} }
correlations.append(correlation) correlations.append(correlation)
# Re-apply known-good modifiers after correlation boosts
for profile in self.device_profiles.values():
self._apply_known_device_modifier(profile, profile.identifier, profile.protocol)
return correlations return correlations
def get_high_interest_devices(self) -> list[DeviceProfile]: def get_high_interest_devices(self) -> list[DeviceProfile]: