mirror of
https://github.com/smittix/intercept.git
synced 2026-06-12 16:03:29 -07:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ae9fe5d063 | |||
| 6783a1cbc4 | |||
| 7fd7861b4b | |||
| 3e453a7b6d | |||
| fbbf20d820 | |||
| 765404fdc2 | |||
| 67fa196a28 | |||
| 4e3f0ad800 | |||
| 4c67307951 | |||
| 8fca54e523 | |||
| b4742f205a | |||
| 16f730db76 | |||
| 958d8d5f20 | |||
| 88f71c9b5e | |||
| 079ed216a8 | |||
| 337c25f66b | |||
| eabb6b2951 | |||
| 5d4b19aef2 | |||
| 11941bedad | |||
| 8ba47f3935 | |||
| 9dd8849b21 | |||
| 725d95c079 | |||
| c5bd13ea52 | |||
| 9ecad43f76 | |||
| 953e94da44 | |||
| 805fc69281 | |||
| d620618bb8 | |||
| 6c358fbfad | |||
| a5599eb0d0 | |||
| a8d25f9c01 | |||
| a09793b6ec | |||
| 675a3cdbfb | |||
| abc51a0dad | |||
| cc5ccf75a2 |
+100
@@ -2,6 +2,106 @@
|
||||
|
||||
All notable changes to iNTERCEPT will be documented in this file.
|
||||
|
||||
## [2.14.0] - 2026-02-06
|
||||
|
||||
### Added
|
||||
- **DMR Digital Voice Decoder** - Decode DMR, P25, NXDN, and D-STAR protocols
|
||||
- Integration with dsd-fme (Digital Speech Decoder - Florida Man Edition)
|
||||
- Real-time SSE streaming of sync, call, voice, and slot events
|
||||
- Call history table with talkgroup, source ID, and protocol tracking
|
||||
- Protocol auto-detection or manual selection
|
||||
- Pipeline error diagnostics with rtl_fm stderr capture
|
||||
- **DMR Visual Synthesizer** - Canvas-based signal activity visualization
|
||||
- Spring-physics animated bars reacting to SSE decoder events
|
||||
- Color-coded by event type: cyan (sync), green (call), orange (voice)
|
||||
- Center-outward ripple bursts on sync events
|
||||
- Smooth decay and idle breathing animation
|
||||
- Responsive canvas with window resize handling
|
||||
- **HF SSTV General Mode** - Terrestrial slow-scan TV on shortwave frequencies
|
||||
- Predefined HF SSTV frequencies (14.230, 21.340, 28.680 MHz, etc.)
|
||||
- Modulation support for USB/LSB reception
|
||||
- **WebSDR Integration** - Remote HF/shortwave listening via WebSDR servers
|
||||
- **Listening Post Enhancements** - Improved signal scanner and audio handling
|
||||
|
||||
### Fixed
|
||||
- APRS rtl_fm startup failure and SDR device conflicts
|
||||
- DSD voice decoder detection for dsd-fme and PulseAudio errors
|
||||
- dsd-fme protocol flags and ncurses disable for headless operation
|
||||
- dsd-fme audio output flag for pipeline compatibility
|
||||
- TSCM sweep scan resilience with per-device error isolation
|
||||
- TSCM WiFi detection using scanner singleton for device availability
|
||||
- TSCM correlation and cluster emission fixes
|
||||
- Detected Threats panel items now clickable to show device details
|
||||
- Proximity radar tooltip flicker on hover
|
||||
- Radar blip flicker by deferring renders during hover
|
||||
- ISS position API priority swap to avoid timeout delays
|
||||
- Updater settings panel error when updater.js is blocked
|
||||
- Missing scapy in optionals dependency group
|
||||
|
||||
---
|
||||
|
||||
## [2.13.1] - 2026-02-04
|
||||
|
||||
### Added
|
||||
- **UI Overhaul** - Revamped styling with slate/cyan theme
|
||||
- Switched app font to JetBrains Mono
|
||||
- Global navigation bar across all dashboards
|
||||
- Cyan-tinted map tiles as default
|
||||
- **Signal Scanner Rewrite** - Switched to rtl_power sweep for better coverage
|
||||
- SNR column added to signal hits table
|
||||
- SNR threshold control for power scan
|
||||
- Improved sweep progress tracking and stability
|
||||
- Frequency-based sweep display with range syncing
|
||||
- **Listening Post Audio** - WAV streaming with retry and fallback
|
||||
- WebSocket audio fallback for listening
|
||||
- User-initiated audio play prompt
|
||||
- Audio pipeline restart for fresh stream headers
|
||||
|
||||
### Fixed
|
||||
- WiFi connected clients panel now filters to selected AP instead of showing all clients
|
||||
- USB device contention when starting audio pipeline
|
||||
- Dual scrollbar issue on main dashboard
|
||||
- Controls bar alignment in dashboard pages
|
||||
- Mode query routing from dashboard nav
|
||||
|
||||
---
|
||||
|
||||
## [2.13.0] - 2026-02-04
|
||||
|
||||
### Added
|
||||
- **WiFi Client Display** - Connected clients shown in AP detail drawer
|
||||
- Real-time client updates via SSE streaming
|
||||
- Probed SSID badges for connected clients
|
||||
- Signal strength indicators and vendor identification
|
||||
- **Help Modal** - Keyboard shortcuts reference system
|
||||
- **Main Dashboard Button** - Quick navigation from any page
|
||||
- **Settings Modal** - Accessible from all dashboards
|
||||
|
||||
### Changed
|
||||
- Dashboard CSS improvements and consistency fixes
|
||||
|
||||
---
|
||||
|
||||
## [2.12.1] - 2026-02-02
|
||||
|
||||
### Added
|
||||
- **SDR Device Registry** - Prevents decoder conflicts between concurrent modes
|
||||
- **SDR Device Status Panel** - Shows connected SDR devices with ADS-B Bias-T toggle
|
||||
- **Real-time Doppler Tracking** - ISS SSTV reception with Doppler correction
|
||||
- **TCP Connection Support** - Meshtastic devices connectable over TCP
|
||||
- **Shared Observer Location** - Configurable shared location with auto-start options
|
||||
- **slowrx Source Build** - Fallback build for Debian/Ubuntu
|
||||
|
||||
### Fixed
|
||||
- SDR device type not synced on page refresh
|
||||
- Meshtastic connection type not restored on page refresh
|
||||
- WiFi deep scan polling on agent with normalized scan_type value
|
||||
- Auto-detect RTL-SDR drivers and blacklist instead of prompting
|
||||
- TPMS pressure field mappings for 433MHz sensor display
|
||||
- Agent capabilities cache invalidation after monitor mode toggle
|
||||
|
||||
---
|
||||
|
||||
## [2.12.0] - 2026-01-29
|
||||
|
||||
### Added
|
||||
|
||||
+29
@@ -63,6 +63,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libcurl4-openssl-dev \
|
||||
zlib1g-dev \
|
||||
libzmq3-dev \
|
||||
libpulse-dev \
|
||||
libfftw3-dev \
|
||||
liblapack-dev \
|
||||
libcodec2-dev \
|
||||
# Build dump1090
|
||||
&& cd /tmp \
|
||||
&& git clone --depth 1 https://github.com/flightaware/dump1090.git \
|
||||
@@ -109,6 +113,27 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& make \
|
||||
&& cp acarsdec /usr/bin/acarsdec \
|
||||
&& rm -rf /tmp/acarsdec \
|
||||
# Build mbelib (required by DSD)
|
||||
&& cd /tmp \
|
||||
&& git clone https://github.com/lwvmobile/mbelib.git \
|
||||
&& cd mbelib \
|
||||
&& (git checkout ambe_tones || true) \
|
||||
&& mkdir build && cd build \
|
||||
&& cmake .. \
|
||||
&& make -j$(nproc) \
|
||||
&& make install \
|
||||
&& ldconfig \
|
||||
&& rm -rf /tmp/mbelib \
|
||||
# Build DSD-FME (Digital Speech Decoder for DMR/P25)
|
||||
&& cd /tmp \
|
||||
&& git clone --depth 1 https://github.com/lwvmobile/dsd-fme.git \
|
||||
&& cd dsd-fme \
|
||||
&& mkdir build && cd build \
|
||||
&& cmake .. \
|
||||
&& make -j$(nproc) \
|
||||
&& make install \
|
||||
&& ldconfig \
|
||||
&& rm -rf /tmp/dsd-fme \
|
||||
# Cleanup build tools to reduce image size
|
||||
&& apt-get remove -y \
|
||||
build-essential \
|
||||
@@ -124,6 +149,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libcurl4-openssl-dev \
|
||||
zlib1g-dev \
|
||||
libzmq3-dev \
|
||||
libpulse-dev \
|
||||
libfftw3-dev \
|
||||
liblapack-dev \
|
||||
libcodec2-dev \
|
||||
&& apt-get autoremove -y \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
@@ -31,11 +31,16 @@ Support the developer of this open-source project
|
||||
- **Aircraft Tracking** - ADS-B via dump1090 with real-time map and radar
|
||||
- **Vessel Tracking** - AIS ship tracking with VHF DSC distress monitoring
|
||||
- **ACARS Messaging** - Aircraft datalink messages via acarsdec
|
||||
- **DMR Digital Voice** - DMR/P25/NXDN/D-STAR decoding via dsd-fme with visual synthesizer
|
||||
- **Listening Post** - Frequency scanner with audio monitoring
|
||||
- **WebSDR** - Remote HF/shortwave listening via WebSDR servers
|
||||
- **ISS SSTV** - Receive slow-scan TV from the International Space Station
|
||||
- **HF SSTV** - Terrestrial SSTV on shortwave frequencies
|
||||
- **Satellite Tracking** - Pass prediction using TLE data
|
||||
- **ADS-B History** - Persistent aircraft history with reporting dashboard (Postgres optional)
|
||||
- **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng
|
||||
- **Bluetooth Scanning** - Device discovery and tracker detection (with Ubertooth support)
|
||||
- **TSCM** - Counter-surveillance with RF baseline comparison and threat detection
|
||||
- **Meshtastic** - LoRa mesh network integration
|
||||
- **Spy Stations** - Number stations and diplomatic HF network database
|
||||
- **Remote Agents** - Distributed SIGINT with remote sensor nodes
|
||||
@@ -63,59 +68,59 @@ cd intercept
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
> **Note:** Docker requires privileged mode for USB SDR access. See `docker-compose.yml` for configuration options.
|
||||
|
||||
### ADS-B History (Optional)
|
||||
|
||||
The ADS-B history feature persists aircraft messages to Postgres for long-term analysis.
|
||||
|
||||
```bash
|
||||
# Start with ADS-B history and Postgres
|
||||
docker compose --profile history up -d
|
||||
```
|
||||
|
||||
Set the following environment variables (for example in a `.env` file):
|
||||
|
||||
```bash
|
||||
INTERCEPT_ADSB_HISTORY_ENABLED=true
|
||||
INTERCEPT_ADSB_DB_HOST=adsb_db
|
||||
INTERCEPT_ADSB_DB_PORT=5432
|
||||
INTERCEPT_ADSB_DB_NAME=intercept_adsb
|
||||
INTERCEPT_ADSB_DB_USER=intercept
|
||||
INTERCEPT_ADSB_DB_PASSWORD=intercept
|
||||
```
|
||||
|
||||
### Other ADS-B Settings
|
||||
|
||||
Set these as environment variables for either local installs or Docker:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `INTERCEPT_ADSB_AUTO_START` | `false` | Auto-start ADS-B tracking when the dashboard loads |
|
||||
| `INTERCEPT_SHARED_OBSERVER_LOCATION` | `true` | Share observer location across ADS-B/AIS/SSTV/Satellite modules |
|
||||
|
||||
**Local install example**
|
||||
|
||||
```bash
|
||||
INTERCEPT_ADSB_AUTO_START=true \
|
||||
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
|
||||
python app.py
|
||||
```
|
||||
|
||||
**Docker example (.env)**
|
||||
|
||||
```bash
|
||||
INTERCEPT_ADSB_AUTO_START=true
|
||||
INTERCEPT_SHARED_OBSERVER_LOCATION=false
|
||||
```
|
||||
|
||||
To store Postgres data on external storage, set `PGDATA_PATH` (defaults to `./pgdata`):
|
||||
|
||||
```bash
|
||||
PGDATA_PATH=/mnt/usbpi1/intercept/pgdata
|
||||
```
|
||||
|
||||
Then open **/adsb/history** for the reporting dashboard.
|
||||
> **Note:** Docker requires privileged mode for USB SDR access. See `docker-compose.yml` for configuration options.
|
||||
|
||||
### ADS-B History (Optional)
|
||||
|
||||
The ADS-B history feature persists aircraft messages to Postgres for long-term analysis.
|
||||
|
||||
```bash
|
||||
# Start with ADS-B history and Postgres
|
||||
docker compose --profile history up -d
|
||||
```
|
||||
|
||||
Set the following environment variables (for example in a `.env` file):
|
||||
|
||||
```bash
|
||||
INTERCEPT_ADSB_HISTORY_ENABLED=true
|
||||
INTERCEPT_ADSB_DB_HOST=adsb_db
|
||||
INTERCEPT_ADSB_DB_PORT=5432
|
||||
INTERCEPT_ADSB_DB_NAME=intercept_adsb
|
||||
INTERCEPT_ADSB_DB_USER=intercept
|
||||
INTERCEPT_ADSB_DB_PASSWORD=intercept
|
||||
```
|
||||
|
||||
### Other ADS-B Settings
|
||||
|
||||
Set these as environment variables for either local installs or Docker:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `INTERCEPT_ADSB_AUTO_START` | `false` | Auto-start ADS-B tracking when the dashboard loads |
|
||||
| `INTERCEPT_SHARED_OBSERVER_LOCATION` | `true` | Share observer location across ADS-B/AIS/SSTV/Satellite modules |
|
||||
|
||||
**Local install example**
|
||||
|
||||
```bash
|
||||
INTERCEPT_ADSB_AUTO_START=true \
|
||||
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
|
||||
python app.py
|
||||
```
|
||||
|
||||
**Docker example (.env)**
|
||||
|
||||
```bash
|
||||
INTERCEPT_ADSB_AUTO_START=true
|
||||
INTERCEPT_SHARED_OBSERVER_LOCATION=false
|
||||
```
|
||||
|
||||
To store Postgres data on external storage, set `PGDATA_PATH` (defaults to `./pgdata`):
|
||||
|
||||
```bash
|
||||
PGDATA_PATH=/mnt/usbpi1/intercept/pgdata
|
||||
```
|
||||
|
||||
Then open **/adsb/history** for the reporting dashboard.
|
||||
|
||||
### Open the Interface
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ def inject_offline_settings():
|
||||
'enabled': get_setting('offline.enabled', False),
|
||||
'assets_source': get_setting('offline.assets_source', 'cdn'),
|
||||
'fonts_source': get_setting('offline.fonts_source', 'cdn'),
|
||||
'tile_provider': get_setting('offline.tile_provider', 'openstreetmap'),
|
||||
'tile_provider': get_setting('offline.tile_provider', 'cartodb_dark_cyan'),
|
||||
'tile_server_url': get_setting('offline.tile_server_url', '')
|
||||
}
|
||||
}
|
||||
@@ -172,6 +172,12 @@ dsc_rtl_process = None
|
||||
dsc_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
dsc_lock = threading.Lock()
|
||||
|
||||
# DMR / Digital Voice
|
||||
dmr_process = None
|
||||
dmr_rtl_process = None
|
||||
dmr_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
dmr_lock = threading.Lock()
|
||||
|
||||
# TSCM (Technical Surveillance Countermeasures)
|
||||
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
tscm_lock = threading.Lock()
|
||||
@@ -278,13 +284,13 @@ def get_sdr_device_status() -> dict[int, str]:
|
||||
# ============================================
|
||||
|
||||
@app.before_request
|
||||
def require_login():
|
||||
# Routes that don't require login (to avoid infinite redirect loop)
|
||||
allowed_routes = ['login', 'static', 'favicon', 'health', 'health_check']
|
||||
|
||||
# Allow audio streaming endpoints without session auth
|
||||
if request.path.startswith('/listening/audio/'):
|
||||
return None
|
||||
def require_login():
|
||||
# Routes that don't require login (to avoid infinite redirect loop)
|
||||
allowed_routes = ['login', 'static', 'favicon', 'health', 'health_check']
|
||||
|
||||
# Allow audio streaming endpoints without session auth
|
||||
if request.path.startswith('/listening/audio/'):
|
||||
return None
|
||||
|
||||
# Controller API endpoints use API key auth, not session auth
|
||||
# Allow agent push/pull endpoints without session login
|
||||
@@ -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),
|
||||
'bluetooth': bt_process is not None and (bt_process.poll() is None if bt_process else False),
|
||||
'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False),
|
||||
'dmr': dmr_process is not None and (dmr_process.poll() is None if dmr_process else False),
|
||||
},
|
||||
'data': {
|
||||
'aircraft_count': len(adsb_aircraft),
|
||||
@@ -652,6 +659,7 @@ def kill_all() -> Response:
|
||||
"""Kill all decoder, WiFi, and Bluetooth processes."""
|
||||
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
|
||||
global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_process
|
||||
global dmr_process, dmr_rtl_process
|
||||
|
||||
# Import adsb and ais modules to reset their state
|
||||
from routes import adsb as adsb_module
|
||||
@@ -663,7 +671,7 @@ def kill_all() -> Response:
|
||||
'rtl_fm', 'multimon-ng', 'rtl_433',
|
||||
'airodump-ng', 'aireplay-ng', 'airmon-ng',
|
||||
'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher',
|
||||
'hcitool', 'bluetoothctl'
|
||||
'hcitool', 'bluetoothctl', 'dsd'
|
||||
]
|
||||
|
||||
for proc in processes_to_kill:
|
||||
@@ -707,6 +715,11 @@ def kill_all() -> Response:
|
||||
dsc_process = None
|
||||
dsc_rtl_process = None
|
||||
|
||||
# Reset DMR state
|
||||
with dmr_lock:
|
||||
dmr_process = None
|
||||
dmr_rtl_process = None
|
||||
|
||||
# Reset Bluetooth state (legacy)
|
||||
with bt_lock:
|
||||
if bt_process:
|
||||
@@ -847,6 +860,14 @@ def main() -> None:
|
||||
except ImportError as e:
|
||||
print(f"WebSocket audio disabled (install flask-sock): {e}")
|
||||
|
||||
# Initialize KiwiSDR WebSocket audio proxy
|
||||
try:
|
||||
from routes.websdr import init_websdr_audio
|
||||
init_websdr_audio(app)
|
||||
print("KiwiSDR audio proxy enabled")
|
||||
except ImportError as e:
|
||||
print(f"KiwiSDR audio proxy disabled: {e}")
|
||||
|
||||
print(f"Open http://localhost:{args.port} in your browser")
|
||||
print()
|
||||
print("Press Ctrl+C to stop")
|
||||
|
||||
@@ -7,26 +7,42 @@ import os
|
||||
import sys
|
||||
|
||||
# Application version
|
||||
VERSION = "2.13.1"
|
||||
VERSION = "2.14.0"
|
||||
|
||||
# Changelog - latest release notes (shown on welcome screen)
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "2.14.0",
|
||||
"date": "February 2026",
|
||||
"highlights": [
|
||||
"DMR/P25/NXDN/D-STAR digital voice decoder with dsd-fme",
|
||||
"DMR visual synthesizer with event-driven spring-physics bars",
|
||||
"HF SSTV general mode with predefined shortwave frequencies",
|
||||
"WebSDR integration for remote HF/shortwave listening",
|
||||
"Listening Post signal scanner and audio pipeline improvements",
|
||||
"TSCM sweep resilience, WiFi detection, and correlation fixes",
|
||||
"APRS rtl_fm startup and SDR device conflict fixes",
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2.13.1",
|
||||
"date": "February 2026",
|
||||
"highlights": [
|
||||
"Help modal system with keyboard shortcuts reference",
|
||||
"Main Dashboard button in navigation bar",
|
||||
"Settings modal accessible from all dashboards",
|
||||
"Dashboard CSS improvements and consistency fixes",
|
||||
"UI overhaul with slate/cyan theme and JetBrains Mono font",
|
||||
"Signal scanner rewritten with rtl_power sweep and SNR filtering",
|
||||
"Listening Post audio streaming via WAV with retry/fallback",
|
||||
"WiFi connected clients panel now filters to selected AP",
|
||||
"Global navigation bar across all dashboards",
|
||||
"Fixed USB device contention when starting audio pipeline",
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2.13.0",
|
||||
"date": "February 2026",
|
||||
"highlights": [
|
||||
"WiFi client display in AP detail drawer",
|
||||
"Real-time client updates via SSE streaming",
|
||||
"WiFi client display in AP detail drawer with real-time SSE updates",
|
||||
"Help modal system with keyboard shortcuts reference",
|
||||
"Global navbar and settings modal accessible from all dashboards",
|
||||
"Probed SSID badges for connected clients",
|
||||
]
|
||||
},
|
||||
@@ -34,7 +50,11 @@ CHANGELOG = [
|
||||
"version": "2.12.1",
|
||||
"date": "February 2026",
|
||||
"highlights": [
|
||||
"Bug fixes and improvements",
|
||||
"SDR device registry to prevent decoder conflicts",
|
||||
"SDR device status panel and ADS-B Bias-T toggle",
|
||||
"Real-time Doppler tracking for ISS SSTV reception",
|
||||
"TCP connection support for Meshtastic",
|
||||
"Shared observer location with auto-start options",
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
www.intercept-sigint.com
|
||||
+67
-44
@@ -3118,10 +3118,11 @@ class ModeManager:
|
||||
# Get params for what to scan
|
||||
scan_wifi = params.get('wifi', True)
|
||||
scan_bt = params.get('bluetooth', True)
|
||||
scan_rf = params.get('rf', True)
|
||||
wifi_interface = params.get('wifi_interface') or params.get('interface')
|
||||
bt_adapter = params.get('bt_interface') or params.get('adapter', 'hci0')
|
||||
sdr_device = params.get('sdr_device', params.get('device', 0))
|
||||
scan_rf = params.get('rf', True)
|
||||
wifi_interface = params.get('wifi_interface') or params.get('interface')
|
||||
bt_adapter = params.get('bt_interface') or params.get('adapter', 'hci0')
|
||||
sdr_device = params.get('sdr_device', params.get('device', 0))
|
||||
sweep_type = params.get('sweep_type')
|
||||
|
||||
# Get baseline_id for comparison (same as local mode)
|
||||
baseline_id = params.get('baseline_id')
|
||||
@@ -3129,11 +3130,11 @@ class ModeManager:
|
||||
started_scans = []
|
||||
|
||||
# Start the combined TSCM scanner thread using existing Intercept functions
|
||||
thread = threading.Thread(
|
||||
target=self._tscm_scanner_thread,
|
||||
args=(scan_wifi, scan_bt, scan_rf, wifi_interface, bt_adapter, sdr_device, baseline_id),
|
||||
daemon=True
|
||||
)
|
||||
thread = threading.Thread(
|
||||
target=self._tscm_scanner_thread,
|
||||
args=(scan_wifi, scan_bt, scan_rf, wifi_interface, bt_adapter, sdr_device, baseline_id, sweep_type),
|
||||
daemon=True
|
||||
)
|
||||
thread.start()
|
||||
self.output_threads['tscm'] = thread
|
||||
|
||||
@@ -3152,9 +3153,9 @@ class ModeManager:
|
||||
'scanning': started_scans
|
||||
}
|
||||
|
||||
def _tscm_scanner_thread(self, scan_wifi: bool, scan_bt: bool, scan_rf: bool,
|
||||
wifi_interface: str | None, bt_adapter: str, sdr_device: int,
|
||||
baseline_id: int | None = None):
|
||||
def _tscm_scanner_thread(self, scan_wifi: bool, scan_bt: bool, scan_rf: bool,
|
||||
wifi_interface: str | None, bt_adapter: str, sdr_device: int,
|
||||
baseline_id: int | None = None, sweep_type: str | None = None):
|
||||
"""Combined TSCM scanner using existing Intercept functions.
|
||||
|
||||
NOTE: This matches local mode behavior exactly:
|
||||
@@ -3167,11 +3168,20 @@ class ModeManager:
|
||||
stop_event = self.stop_events.get(mode)
|
||||
|
||||
# Import existing Intercept TSCM functions
|
||||
from routes.tscm import _scan_wifi_networks, _scan_bluetooth_devices, _scan_rf_signals
|
||||
logger.info("TSCM imports successful")
|
||||
|
||||
# Load baseline if specified (same as local mode)
|
||||
baseline = None
|
||||
from routes.tscm import _scan_wifi_networks, _scan_bluetooth_devices, _scan_rf_signals
|
||||
logger.info("TSCM imports successful")
|
||||
|
||||
sweep_ranges = None
|
||||
if sweep_type:
|
||||
try:
|
||||
from data.tscm_frequencies import get_sweep_preset, SWEEP_PRESETS
|
||||
preset = get_sweep_preset(sweep_type) or SWEEP_PRESETS.get('standard')
|
||||
sweep_ranges = preset.get('ranges') if preset else None
|
||||
except Exception:
|
||||
sweep_ranges = None
|
||||
|
||||
# Load baseline if specified (same as local mode)
|
||||
baseline = None
|
||||
if baseline_id and HAS_BASELINE_DB and get_tscm_baseline:
|
||||
baseline = get_tscm_baseline(baseline_id)
|
||||
if baseline:
|
||||
@@ -3239,15 +3249,18 @@ class ModeManager:
|
||||
enriched['is_new'] = not classification.get('in_baseline', False)
|
||||
enriched['reasons'] = classification.get('reasons', [])
|
||||
|
||||
if self._tscm_correlation:
|
||||
profile = self._tscm_correlation.analyze_wifi_device(enriched)
|
||||
enriched['classification'] = profile.risk_level.value
|
||||
enriched['score'] = profile.total_score
|
||||
enriched['indicators'] = [
|
||||
{'type': i.type.value, 'desc': i.description}
|
||||
for i in profile.indicators
|
||||
]
|
||||
enriched['recommended_action'] = profile.recommended_action
|
||||
if self._tscm_correlation:
|
||||
profile = self._tscm_correlation.analyze_wifi_device(enriched)
|
||||
enriched['classification'] = profile.risk_level.value
|
||||
enriched['score'] = profile.total_score
|
||||
enriched['score_modifier'] = profile.score_modifier
|
||||
enriched['known_device'] = profile.known_device
|
||||
enriched['known_device_name'] = profile.known_device_name
|
||||
enriched['indicators'] = [
|
||||
{'type': i.type.value, 'desc': i.description}
|
||||
for i in profile.indicators
|
||||
]
|
||||
enriched['recommended_action'] = profile.recommended_action
|
||||
|
||||
self.wifi_networks[bssid] = enriched
|
||||
except Exception as e:
|
||||
@@ -3285,15 +3298,18 @@ class ModeManager:
|
||||
enriched['is_new'] = not classification.get('in_baseline', False)
|
||||
enriched['reasons'] = classification.get('reasons', [])
|
||||
|
||||
if self._tscm_correlation:
|
||||
profile = self._tscm_correlation.analyze_bluetooth_device(enriched)
|
||||
enriched['classification'] = profile.risk_level.value
|
||||
enriched['score'] = profile.total_score
|
||||
enriched['indicators'] = [
|
||||
{'type': i.type.value, 'desc': i.description}
|
||||
for i in profile.indicators
|
||||
]
|
||||
enriched['recommended_action'] = profile.recommended_action
|
||||
if self._tscm_correlation:
|
||||
profile = self._tscm_correlation.analyze_bluetooth_device(enriched)
|
||||
enriched['classification'] = profile.risk_level.value
|
||||
enriched['score'] = profile.total_score
|
||||
enriched['score_modifier'] = profile.score_modifier
|
||||
enriched['known_device'] = profile.known_device
|
||||
enriched['known_device_name'] = profile.known_device_name
|
||||
enriched['indicators'] = [
|
||||
{'type': i.type.value, 'desc': i.description}
|
||||
for i in profile.indicators
|
||||
]
|
||||
enriched['recommended_action'] = profile.recommended_action
|
||||
|
||||
self.bluetooth_devices[mac] = enriched
|
||||
except Exception as e:
|
||||
@@ -3304,7 +3320,11 @@ class ModeManager:
|
||||
try:
|
||||
# Pass a stop check that uses our stop_event (not the module's _sweep_running)
|
||||
agent_stop_check = lambda: stop_event and stop_event.is_set()
|
||||
rf_signals = _scan_rf_signals(sdr_device, stop_check=agent_stop_check)
|
||||
rf_signals = _scan_rf_signals(
|
||||
sdr_device,
|
||||
stop_check=agent_stop_check,
|
||||
sweep_ranges=sweep_ranges
|
||||
)
|
||||
|
||||
# Analyze each RF signal like local mode does
|
||||
analyzed_signals = []
|
||||
@@ -3324,14 +3344,17 @@ class ModeManager:
|
||||
analyzed['reasons'] = classification.get('reasons', [])
|
||||
|
||||
# Use correlation engine for scoring (same as local mode)
|
||||
if hasattr(self, '_tscm_correlation') and self._tscm_correlation:
|
||||
profile = self._tscm_correlation.analyze_rf_signal(signal)
|
||||
analyzed['classification'] = profile.risk_level.value
|
||||
analyzed['score'] = profile.total_score
|
||||
analyzed['indicators'] = [
|
||||
{'type': i.type.value, 'desc': i.description}
|
||||
for i in profile.indicators
|
||||
]
|
||||
if hasattr(self, '_tscm_correlation') and self._tscm_correlation:
|
||||
profile = self._tscm_correlation.analyze_rf_signal(signal)
|
||||
analyzed['classification'] = profile.risk_level.value
|
||||
analyzed['score'] = profile.total_score
|
||||
analyzed['score_modifier'] = profile.score_modifier
|
||||
analyzed['known_device'] = profile.known_device
|
||||
analyzed['known_device_name'] = profile.known_device_name
|
||||
analyzed['indicators'] = [
|
||||
{'type': i.type.value, 'desc': i.description}
|
||||
for i in profile.indicators
|
||||
]
|
||||
|
||||
analyzed['is_threat'] = is_threat
|
||||
analyzed_signals.append(analyzed)
|
||||
|
||||
+11
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "intercept"
|
||||
version = "2.13.1"
|
||||
version = "2.14.0"
|
||||
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
@@ -33,6 +33,7 @@ dependencies = [
|
||||
"flask-limiter>=2.5.4",
|
||||
"bleak>=0.21.0",
|
||||
"flask-sock",
|
||||
"websocket-client>=1.6.0",
|
||||
"requests>=2.28.0",
|
||||
]
|
||||
|
||||
@@ -52,6 +53,15 @@ dev = [
|
||||
"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]
|
||||
intercept = "intercept:main"
|
||||
|
||||
|
||||
@@ -35,4 +35,6 @@ qrcode[pil]>=7.4
|
||||
# ruff>=0.1.0
|
||||
# black>=23.0.0
|
||||
# mypy>=1.0.0
|
||||
# WebSocket support for in-app audio streaming (KiwiSDR, Listening Post)
|
||||
flask-sock
|
||||
websocket-client>=1.6.0
|
||||
|
||||
@@ -26,6 +26,9 @@ def register_blueprints(app):
|
||||
from .offline import offline_bp
|
||||
from .updater import updater_bp
|
||||
from .sstv import sstv_bp
|
||||
from .sstv_general import sstv_general_bp
|
||||
from .dmr import dmr_bp
|
||||
from .websdr import websdr_bp
|
||||
|
||||
app.register_blueprint(pager_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(updater_bp) # GitHub update checking
|
||||
app.register_blueprint(sstv_bp) # ISS SSTV decoder
|
||||
app.register_blueprint(sstv_general_bp) # General terrestrial SSTV
|
||||
app.register_blueprint(dmr_bp) # DMR / P25 / Digital Voice
|
||||
app.register_blueprint(websdr_bp) # HF/Shortwave WebSDR
|
||||
|
||||
# Initialize TSCM state with queue and lock from app
|
||||
import app as app_module
|
||||
|
||||
+59
-5
@@ -13,7 +13,7 @@ import tempfile
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from subprocess import DEVNULL, PIPE, STDOUT
|
||||
from subprocess import PIPE, STDOUT
|
||||
from typing import Generator, Optional
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
@@ -31,6 +31,9 @@ from utils.constants import (
|
||||
|
||||
aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs')
|
||||
|
||||
# Track which SDR device is being used
|
||||
aprs_active_device: int | None = None
|
||||
|
||||
# APRS frequencies by region (MHz)
|
||||
APRS_FREQUENCIES = {
|
||||
'north_america': '144.390',
|
||||
@@ -1301,7 +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).
|
||||
The decoder's stderr is merged into stdout (STDOUT) to avoid deadlocks.
|
||||
rtl_fm's stderr is sent to DEVNULL for the same reason.
|
||||
rtl_fm's stderr is captured via PIPE with a monitor thread.
|
||||
|
||||
Outputs two types of messages to the queue:
|
||||
- type='aprs': Decoded APRS packets
|
||||
@@ -1383,6 +1386,7 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
|
||||
logger.error(f"APRS stream error: {e}")
|
||||
app_module.aprs_queue.put({'type': 'error', 'message': str(e)})
|
||||
finally:
|
||||
global aprs_active_device
|
||||
app_module.aprs_queue.put({'type': 'status', 'status': 'stopped'})
|
||||
# Cleanup processes
|
||||
for proc in [rtl_process, decoder_process]:
|
||||
@@ -1394,6 +1398,10 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
# Release SDR device
|
||||
if aprs_active_device is not None:
|
||||
app_module.release_sdr_device(aprs_active_device)
|
||||
aprs_active_device = None
|
||||
|
||||
|
||||
@aprs_bp.route('/tools')
|
||||
@@ -1441,6 +1449,7 @@ def get_stations() -> Response:
|
||||
def start_aprs() -> Response:
|
||||
"""Start APRS decoder."""
|
||||
global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations
|
||||
global aprs_active_device
|
||||
|
||||
with app_module.aprs_lock:
|
||||
if app_module.aprs_process and app_module.aprs_process.poll() is None:
|
||||
@@ -1477,6 +1486,16 @@ def start_aprs() -> Response:
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
# Reserve SDR device to prevent conflicts with other modes
|
||||
error = app_module.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
|
||||
region = data.get('region', 'north_america')
|
||||
frequency = APRS_FREQUENCIES.get(region, '144.390')
|
||||
@@ -1552,15 +1571,25 @@ def start_aprs() -> Response:
|
||||
|
||||
try:
|
||||
# Start rtl_fm with stdout piped to decoder.
|
||||
# stderr goes to DEVNULL to prevent blocking (rtl_fm logs to stderr).
|
||||
# stderr is captured via PIPE so errors are reported to the user.
|
||||
# NOTE: RTL-SDR Blog V4 may show offset-tuned frequency in logs - this is normal.
|
||||
rtl_process = subprocess.Popen(
|
||||
rtl_cmd,
|
||||
stdout=PIPE,
|
||||
stderr=DEVNULL,
|
||||
stderr=PIPE,
|
||||
start_new_session=True
|
||||
)
|
||||
|
||||
# Start a thread to monitor rtl_fm stderr for errors
|
||||
def monitor_rtl_stderr():
|
||||
for line in rtl_process.stderr:
|
||||
err_text = line.decode('utf-8', errors='replace').strip()
|
||||
if err_text:
|
||||
logger.debug(f"[RTL_FM] {err_text}")
|
||||
|
||||
rtl_stderr_thread = threading.Thread(target=monitor_rtl_stderr, daemon=True)
|
||||
rtl_stderr_thread.start()
|
||||
|
||||
# Start decoder with stdin wired to rtl_fm's stdout.
|
||||
# Use text mode with line buffering for reliable line-by-line reading.
|
||||
# Merge stderr into stdout to avoid blocking on unbuffered stderr.
|
||||
@@ -1582,13 +1611,25 @@ def start_aprs() -> Response:
|
||||
time.sleep(PROCESS_START_WAIT)
|
||||
|
||||
if rtl_process.poll() is not None:
|
||||
# rtl_fm exited early - something went wrong
|
||||
# rtl_fm exited early - capture stderr for diagnostics
|
||||
stderr_output = ''
|
||||
try:
|
||||
remaining = rtl_process.stderr.read()
|
||||
if remaining:
|
||||
stderr_output = remaining.decode('utf-8', errors='replace').strip()
|
||||
except Exception:
|
||||
pass
|
||||
error_msg = f'rtl_fm failed to start (exit code {rtl_process.returncode})'
|
||||
if stderr_output:
|
||||
error_msg += f': {stderr_output[:200]}'
|
||||
logger.error(error_msg)
|
||||
try:
|
||||
decoder_process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
if aprs_active_device is not None:
|
||||
app_module.release_sdr_device(aprs_active_device)
|
||||
aprs_active_device = None
|
||||
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||
|
||||
if decoder_process.poll() is not None:
|
||||
@@ -1602,6 +1643,9 @@ def start_aprs() -> Response:
|
||||
rtl_process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
if aprs_active_device is not None:
|
||||
app_module.release_sdr_device(aprs_active_device)
|
||||
aprs_active_device = None
|
||||
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||
|
||||
# Store references for status checks and cleanup
|
||||
@@ -1626,12 +1670,17 @@ def start_aprs() -> Response:
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start APRS decoder: {e}")
|
||||
if aprs_active_device is not None:
|
||||
app_module.release_sdr_device(aprs_active_device)
|
||||
aprs_active_device = None
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
|
||||
@aprs_bp.route('/stop', methods=['POST'])
|
||||
def stop_aprs() -> Response:
|
||||
"""Stop APRS decoder."""
|
||||
global aprs_active_device
|
||||
|
||||
with app_module.aprs_lock:
|
||||
processes_to_stop = []
|
||||
|
||||
@@ -1660,6 +1709,11 @@ def stop_aprs() -> Response:
|
||||
if hasattr(app_module, 'aprs_rtl_process'):
|
||||
app_module.aprs_rtl_process = None
|
||||
|
||||
# Release SDR device
|
||||
if aprs_active_device is not None:
|
||||
app_module.release_sdr_device(aprs_active_device)
|
||||
aprs_active_device = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
|
||||
+69
-26
@@ -10,14 +10,16 @@ This blueprint provides:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Generator
|
||||
|
||||
import requests
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
from utils.database import (
|
||||
create_agent, get_agent, get_agent_by_name, list_agents,
|
||||
@@ -450,12 +452,12 @@ def proxy_mode_status(agent_id: int, mode: str):
|
||||
}), 502
|
||||
|
||||
|
||||
@controller_bp.route('/agents/<int:agent_id>/<mode>/data', methods=['GET'])
|
||||
def proxy_mode_data(agent_id: int, mode: str):
|
||||
"""Get current data from a remote agent."""
|
||||
agent = get_agent(agent_id)
|
||||
if not agent:
|
||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||
@controller_bp.route('/agents/<int:agent_id>/<mode>/data', methods=['GET'])
|
||||
def proxy_mode_data(agent_id: int, mode: str):
|
||||
"""Get current data from a remote agent."""
|
||||
agent = get_agent(agent_id)
|
||||
if not agent:
|
||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||
|
||||
try:
|
||||
client = create_client_from_agent(agent)
|
||||
@@ -473,18 +475,59 @@ def proxy_mode_data(agent_id: int, mode: str):
|
||||
'data': result
|
||||
})
|
||||
|
||||
except (AgentHTTPError, AgentConnectionError) as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Agent error: {e}'
|
||||
}), 502
|
||||
|
||||
|
||||
@controller_bp.route('/agents/<int:agent_id>/wifi/monitor', methods=['POST'])
|
||||
def proxy_wifi_monitor(agent_id: int):
|
||||
"""Toggle monitor mode on a remote agent's WiFi interface."""
|
||||
agent = get_agent(agent_id)
|
||||
if not agent:
|
||||
except (AgentHTTPError, AgentConnectionError) as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Agent error: {e}'
|
||||
}), 502
|
||||
|
||||
|
||||
@controller_bp.route('/agents/<int:agent_id>/<mode>/stream')
|
||||
def proxy_mode_stream(agent_id: int, mode: str):
|
||||
"""Proxy SSE stream from a remote agent."""
|
||||
agent = get_agent(agent_id)
|
||||
if not agent:
|
||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||
|
||||
client = create_client_from_agent(agent)
|
||||
query = request.query_string.decode('utf-8')
|
||||
url = f"{client.base_url}/{mode}/stream"
|
||||
if query:
|
||||
url = f"{url}?{query}"
|
||||
|
||||
headers = {'Accept': 'text/event-stream'}
|
||||
if agent.get('api_key'):
|
||||
headers['X-API-Key'] = agent['api_key']
|
||||
|
||||
def generate() -> Generator[str, None, None]:
|
||||
try:
|
||||
with requests.get(url, headers=headers, stream=True, timeout=(5, 3600)) as resp:
|
||||
resp.raise_for_status()
|
||||
for chunk in resp.iter_content(chunk_size=1024):
|
||||
if not chunk:
|
||||
continue
|
||||
yield chunk.decode('utf-8', errors='ignore')
|
||||
except Exception as e:
|
||||
logger.error(f"SSE proxy error for agent {agent_id}/{mode}: {e}")
|
||||
yield format_sse({
|
||||
'type': 'error',
|
||||
'message': str(e),
|
||||
'agent_id': agent_id,
|
||||
'mode': mode,
|
||||
})
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
|
||||
@controller_bp.route('/agents/<int:agent_id>/wifi/monitor', methods=['POST'])
|
||||
def proxy_wifi_monitor(agent_id: int):
|
||||
"""Toggle monitor mode on a remote agent's WiFi interface."""
|
||||
agent = get_agent(agent_id)
|
||||
if not agent:
|
||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
+403
@@ -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
|
||||
+1098
-741
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -13,7 +13,7 @@ OFFLINE_DEFAULTS = {
|
||||
'offline.enabled': False,
|
||||
'offline.assets_source': 'cdn',
|
||||
'offline.fonts_source': 'cdn',
|
||||
'offline.tile_provider': 'openstreetmap',
|
||||
'offline.tile_provider': 'cartodb_dark_cyan',
|
||||
'offline.tile_server_url': ''
|
||||
}
|
||||
|
||||
|
||||
+23
-23
@@ -11,9 +11,9 @@ from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
|
||||
from flask import Blueprint, jsonify, request, render_template, Response
|
||||
|
||||
from config import SHARED_OBSERVER_LOCATION_ENABLED
|
||||
from flask import Blueprint, jsonify, request, render_template, Response
|
||||
|
||||
from config import SHARED_OBSERVER_LOCATION_ENABLED
|
||||
|
||||
from data.satellites import TLE_SATELLITES
|
||||
from utils.logging import satellite_logger as logger
|
||||
@@ -42,30 +42,30 @@ def _fetch_iss_realtime(observer_lat: Optional[float] = None, observer_lon: Opti
|
||||
iss_alt = 420 # Default altitude in km
|
||||
source = None
|
||||
|
||||
# Try primary API: Open Notify
|
||||
# Try primary API: Where The ISS At
|
||||
try:
|
||||
response = requests.get('http://api.open-notify.org/iss-now.json', timeout=5)
|
||||
response = requests.get('https://api.wheretheiss.at/v1/satellites/25544', timeout=5)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data.get('message') == 'success':
|
||||
iss_lat = float(data['iss_position']['latitude'])
|
||||
iss_lon = float(data['iss_position']['longitude'])
|
||||
source = 'open-notify'
|
||||
iss_lat = float(data['latitude'])
|
||||
iss_lon = float(data['longitude'])
|
||||
iss_alt = float(data.get('altitude', 420))
|
||||
source = 'wheretheiss'
|
||||
except Exception as e:
|
||||
logger.debug(f"Open Notify API failed: {e}")
|
||||
logger.debug(f"Where The ISS At API failed: {e}")
|
||||
|
||||
# Try fallback API: Where The ISS At
|
||||
# Try fallback API: Open Notify
|
||||
if iss_lat is None:
|
||||
try:
|
||||
response = requests.get('https://api.wheretheiss.at/v1/satellites/25544', timeout=5)
|
||||
response = requests.get('http://api.open-notify.org/iss-now.json', timeout=5)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
iss_lat = float(data['latitude'])
|
||||
iss_lon = float(data['longitude'])
|
||||
iss_alt = float(data.get('altitude', 420))
|
||||
source = 'wheretheiss'
|
||||
if data.get('message') == 'success':
|
||||
iss_lat = float(data['iss_position']['latitude'])
|
||||
iss_lon = float(data['iss_position']['longitude'])
|
||||
source = 'open-notify'
|
||||
except Exception as e:
|
||||
logger.debug(f"Where The ISS At API failed: {e}")
|
||||
logger.debug(f"Open Notify API failed: {e}")
|
||||
|
||||
if iss_lat is None:
|
||||
return None
|
||||
@@ -120,12 +120,12 @@ def _fetch_iss_realtime(observer_lat: Optional[float] = None, observer_lon: Opti
|
||||
|
||||
|
||||
@satellite_bp.route('/dashboard')
|
||||
def satellite_dashboard():
|
||||
"""Popout satellite tracking dashboard."""
|
||||
return render_template(
|
||||
'satellite_dashboard.html',
|
||||
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
||||
)
|
||||
def satellite_dashboard():
|
||||
"""Popout satellite tracking dashboard."""
|
||||
return render_template(
|
||||
'satellite_dashboard.html',
|
||||
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
||||
)
|
||||
|
||||
|
||||
@satellite_bp.route('/predict', methods=['POST'])
|
||||
|
||||
+26
-26
@@ -467,7 +467,32 @@ def iss_position():
|
||||
observer_lat = request.args.get('latitude', type=float)
|
||||
observer_lon = request.args.get('longitude', type=float)
|
||||
|
||||
# Try primary API: Open Notify
|
||||
# Try primary API: Where The ISS At
|
||||
try:
|
||||
response = requests.get('https://api.wheretheiss.at/v1/satellites/25544', timeout=5)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
iss_lat = float(data['latitude'])
|
||||
iss_lon = float(data['longitude'])
|
||||
|
||||
result = {
|
||||
'status': 'ok',
|
||||
'lat': iss_lat,
|
||||
'lon': iss_lon,
|
||||
'altitude': float(data.get('altitude', 420)),
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
'source': 'wheretheiss'
|
||||
}
|
||||
|
||||
# Calculate observer-relative data if location provided
|
||||
if observer_lat is not None and observer_lon is not None:
|
||||
result.update(_calculate_observer_data(iss_lat, iss_lon, observer_lat, observer_lon))
|
||||
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.warning(f"Where The ISS At API failed: {e}")
|
||||
|
||||
# Try fallback API: Open Notify
|
||||
try:
|
||||
response = requests.get('http://api.open-notify.org/iss-now.json', timeout=5)
|
||||
if response.status_code == 200:
|
||||
@@ -493,31 +518,6 @@ def iss_position():
|
||||
except Exception as e:
|
||||
logger.warning(f"Open Notify API failed: {e}")
|
||||
|
||||
# Try fallback API: Where The ISS At
|
||||
try:
|
||||
response = requests.get('https://api.wheretheiss.at/v1/satellites/25544', timeout=5)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
iss_lat = float(data['latitude'])
|
||||
iss_lon = float(data['longitude'])
|
||||
|
||||
result = {
|
||||
'status': 'ok',
|
||||
'lat': iss_lat,
|
||||
'lon': iss_lon,
|
||||
'altitude': float(data.get('altitude', 420)),
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
'source': 'wheretheiss'
|
||||
}
|
||||
|
||||
# Calculate observer-relative data if location provided
|
||||
if observer_lat is not None and observer_lon is not None:
|
||||
result.update(_calculate_observer_data(iss_lat, iss_lon, observer_lat, observer_lon))
|
||||
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.warning(f"Where The ISS At API failed: {e}")
|
||||
|
||||
# Both APIs failed
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
|
||||
@@ -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
|
||||
+3815
-3290
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -1240,10 +1240,32 @@ def v2_get_networks():
|
||||
|
||||
@wifi_bp.route('/v2/clients')
|
||||
def v2_get_clients():
|
||||
"""Get all discovered clients."""
|
||||
"""Get discovered clients with optional filtering."""
|
||||
try:
|
||||
scanner = get_wifi_scanner()
|
||||
clients = scanner.clients
|
||||
|
||||
# Filter by association status
|
||||
associated = request.args.get('associated')
|
||||
if associated == 'true':
|
||||
clients = [c for c in clients if c.is_associated]
|
||||
elif associated == 'false':
|
||||
clients = [c for c in clients if not c.is_associated]
|
||||
|
||||
# Filter by associated BSSID
|
||||
bssid = request.args.get('bssid')
|
||||
if bssid:
|
||||
clients = [c for c in clients if c.associated_bssid == bssid.upper()]
|
||||
|
||||
# Filter by minimum RSSI
|
||||
min_rssi = request.args.get('min_rssi')
|
||||
if min_rssi:
|
||||
try:
|
||||
min_rssi = int(min_rssi)
|
||||
clients = [c for c in clients if c.rssi_current and c.rssi_current >= min_rssi]
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return jsonify({
|
||||
'clients': [c.to_dict() for c in clients],
|
||||
'total': len(clients),
|
||||
|
||||
@@ -210,6 +210,10 @@ check_tools() {
|
||||
info "GPS:"
|
||||
check_required "gpsd" "GPS daemon" gpsd
|
||||
|
||||
echo
|
||||
info "Digital Voice:"
|
||||
check_optional "dsd" "Digital Speech Decoder (DMR/P25)" dsd dsd-fme
|
||||
|
||||
echo
|
||||
info "Audio:"
|
||||
check_required "ffmpeg" "Audio encoder/decoder" ffmpeg
|
||||
@@ -390,7 +394,6 @@ install_slowrx_from_source_macos() {
|
||||
info "slowrx not available via Homebrew. Building from source..."
|
||||
|
||||
# Ensure build dependencies are installed
|
||||
brew_install cmake
|
||||
brew_install fftw
|
||||
brew_install libsndfile
|
||||
brew_install gtk+3
|
||||
@@ -406,13 +409,8 @@ install_slowrx_from_source_macos() {
|
||||
|
||||
cd "$tmp_dir/slowrx"
|
||||
info "Compiling slowrx..."
|
||||
mkdir -p build && cd build
|
||||
local cmake_log make_log
|
||||
cmake_log=$(cmake .. 2>&1) || {
|
||||
warn "cmake failed for slowrx:"
|
||||
echo "$cmake_log" | tail -20
|
||||
exit 1
|
||||
}
|
||||
# slowrx uses a plain Makefile, not CMake
|
||||
local make_log
|
||||
make_log=$(make 2>&1) || {
|
||||
warn "make failed for slowrx:"
|
||||
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() {
|
||||
TOTAL_STEPS=16
|
||||
TOTAL_STEPS=17
|
||||
CURRENT_STEP=0
|
||||
|
||||
progress "Checking Homebrew"
|
||||
@@ -481,11 +663,20 @@ install_macos_packages() {
|
||||
progress "Installing direwolf (APRS decoder)"
|
||||
(brew_install direwolf) || warn "direwolf not available via Homebrew"
|
||||
|
||||
progress "Installing slowrx (SSTV decoder)"
|
||||
if ! cmd_exists slowrx; then
|
||||
install_slowrx_from_source_macos || warn "slowrx build failed - ISS SSTV decoding will not be available"
|
||||
progress "Skipping slowrx (SSTV decoder)"
|
||||
warn "slowrx requires ALSA (Linux-only) and cannot build on macOS. Skipping."
|
||||
|
||||
progress "Installing DSD (Digital Speech Decoder, optional)"
|
||||
if ! cmd_exists dsd && ! cmd_exists dsd-fme; then
|
||||
echo
|
||||
info "DSD is used for DMR, P25, NXDN, and D-STAR digital voice decoding."
|
||||
if ask_yes_no "Do you want to install DSD?"; then
|
||||
install_dsd_from_source || warn "DSD build failed. DMR/P25 decoding will not be available."
|
||||
else
|
||||
warn "Skipping DSD installation. DMR/P25 decoding will not be available."
|
||||
fi
|
||||
else
|
||||
ok "slowrx already installed"
|
||||
ok "DSD already installed"
|
||||
fi
|
||||
|
||||
progress "Installing ffmpeg"
|
||||
@@ -509,14 +700,22 @@ install_macos_packages() {
|
||||
fi
|
||||
|
||||
progress "Installing dump1090"
|
||||
(brew_install dump1090-mutability) || warn "dump1090 not available via Homebrew"
|
||||
if ! cmd_exists dump1090; then
|
||||
(brew_install dump1090-mutability) || install_dump1090_from_source_macos || warn "dump1090 not available"
|
||||
else
|
||||
ok "dump1090 already installed"
|
||||
fi
|
||||
|
||||
progress "Installing acarsdec"
|
||||
(brew_install acarsdec) || warn "acarsdec not available via Homebrew"
|
||||
if ! cmd_exists acarsdec; then
|
||||
(brew_install acarsdec) || install_acarsdec_from_source_macos || warn "acarsdec not available"
|
||||
else
|
||||
ok "acarsdec already installed"
|
||||
fi
|
||||
|
||||
progress "Installing AIS-catcher"
|
||||
if ! cmd_exists AIS-catcher && ! cmd_exists aiscatcher; then
|
||||
(brew_install aiscatcher) || warn "AIS-catcher not available via Homebrew"
|
||||
(brew_install aiscatcher) || install_aiscatcher_from_source_macos || warn "AIS-catcher not available"
|
||||
else
|
||||
ok "AIS-catcher already installed"
|
||||
fi
|
||||
@@ -849,7 +1048,7 @@ install_debian_packages() {
|
||||
export NEEDRESTART_MODE=a
|
||||
fi
|
||||
|
||||
TOTAL_STEPS=21
|
||||
TOTAL_STEPS=22
|
||||
CURRENT_STEP=0
|
||||
|
||||
progress "Updating APT package lists"
|
||||
@@ -906,7 +1105,20 @@ install_debian_packages() {
|
||||
apt_install direwolf || true
|
||||
|
||||
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"
|
||||
apt_install ffmpeg
|
||||
|
||||
@@ -5,25 +5,26 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--font-sans: 'Space Mono', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
--font-mono: 'Space Mono', 'Fira Code', 'Consolas', monospace;
|
||||
--bg-dark: #0a0c10;
|
||||
--bg-panel: #0f1218;
|
||||
--bg-card: #151a23;
|
||||
--border-color: #1f2937;
|
||||
--border-glow: #4a9eff;
|
||||
--text-primary: #e8eaed;
|
||||
--text-secondary: #9ca3af;
|
||||
--text-dim: #4b5563;
|
||||
--accent-green: #22c55e;
|
||||
--accent-cyan: #4a9eff;
|
||||
--accent-orange: #f59e0b;
|
||||
--accent-red: #ef4444;
|
||||
--accent-yellow: #eab308;
|
||||
--accent-amber: #d4a853;
|
||||
--grid-line: rgba(74, 158, 255, 0.08);
|
||||
--radar-cyan: #4a9eff;
|
||||
--radar-bg: #0f1218;
|
||||
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||
--bg-dark: #0b1118;
|
||||
--bg-panel: #101823;
|
||||
--bg-card: #151f2b;
|
||||
--border-color: #263246;
|
||||
--border-glow: #4aa3ff;
|
||||
--text-primary: #d7e0ee;
|
||||
--text-secondary: #9fb0c7;
|
||||
--text-dim: #6f7f94;
|
||||
--accent-green: #38c180;
|
||||
--accent-cyan: #4aa3ff;
|
||||
--accent-orange: #d6a85e;
|
||||
--accent-red: #e25d5d;
|
||||
--accent-yellow: #e1c26b;
|
||||
--accent-amber: #d6a85e;
|
||||
--grid-line: rgba(74, 163, 255, 0.1);
|
||||
--noise-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='40'%20height='40'%20viewBox='0%200%2040%2040'%3E%3Cg%20fill='%23ffffff'%20fill-opacity='0.05'%3E%3Ccircle%20cx='3'%20cy='5'%20r='1'/%3E%3Ccircle%20cx='11'%20cy='9'%20r='1'/%3E%3Ccircle%20cx='18'%20cy='3'%20r='1'/%3E%3Ccircle%20cx='26'%20cy='12'%20r='1'/%3E%3Ccircle%20cx='34'%20cy='6'%20r='1'/%3E%3Ccircle%20cx='7'%20cy='19'%20r='1'/%3E%3Ccircle%20cx='15'%20cy='24'%20r='1'/%3E%3Ccircle%20cx='28'%20cy='22'%20r='1'/%3E%3Ccircle%20cx='36'%20cy='18'%20r='1'/%3E%3Ccircle%20cx='5'%20cy='33'%20r='1'/%3E%3Ccircle%20cx='19'%20cy='32'%20r='1'/%3E%3Ccircle%20cx='31'%20cy='34'%20r='1'/%3E%3C/g%3E%3C/svg%3E");
|
||||
--radar-cyan: #4aa3ff;
|
||||
--radar-bg: #101823;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -42,9 +43,10 @@ body {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-image:
|
||||
var(--noise-image),
|
||||
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
||||
background-size: 50px 50px;
|
||||
background-size: 40px 40px, 50px 50px, 50px 50px;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
@@ -57,10 +59,12 @@ body {
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||
color: var(--accent-cyan);
|
||||
animation: scan 6s linear infinite;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
opacity: 0.3;
|
||||
opacity: 0.25;
|
||||
box-shadow: 0 0 8px currentColor;
|
||||
}
|
||||
|
||||
@keyframes scan {
|
||||
@@ -88,6 +92,18 @@ body {
|
||||
min-height: 52px;
|
||||
}
|
||||
|
||||
.header::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.header {
|
||||
padding: 12px 20px;
|
||||
@@ -755,14 +771,31 @@ body {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: stretch !important;
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px;
|
||||
padding: 8px 15px;
|
||||
background: var(--bg-panel);
|
||||
border-top: 1px solid rgba(74, 158, 255, 0.3);
|
||||
font-size: 11px;
|
||||
overflow-x: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.controls-bar > .control-group {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
padding: 6px 10px;
|
||||
background: rgba(74, 158, 255, 0.03);
|
||||
border: 1px solid rgba(74, 158, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.controls-bar > .control-group > .control-group-items {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.controls-bar label {
|
||||
@@ -1466,7 +1499,7 @@ body {
|
||||
}
|
||||
|
||||
.strip-report-btn {
|
||||
background: linear-gradient(135deg, var(--accent-cyan) 0%, #2563eb 100%);
|
||||
background: linear-gradient(135deg, var(--accent-cyan) 0%, #2b6fb8 100%);
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
@@ -1769,7 +1802,7 @@ body {
|
||||
}
|
||||
|
||||
.strip-btn.primary {
|
||||
background: linear-gradient(135deg, var(--accent-cyan) 0%, #2563eb 100%);
|
||||
background: linear-gradient(135deg, var(--accent-cyan) 0%, #2b6fb8 100%);
|
||||
border: none;
|
||||
color: white;
|
||||
}
|
||||
|
||||
+32
-16
@@ -5,20 +5,21 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--font-sans: 'Space Mono', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
--font-mono: 'Space Mono', 'Fira Code', 'Consolas', monospace;
|
||||
--bg-dark: #0a0c10;
|
||||
--bg-panel: #0f1218;
|
||||
--bg-card: #141a24;
|
||||
--border-color: #1f2937;
|
||||
--border-glow: rgba(74, 158, 255, 0.6);
|
||||
--text-primary: #e8eaed;
|
||||
--text-secondary: #9ca3af;
|
||||
--text-dim: #4b5563;
|
||||
--accent-cyan: #4a9eff;
|
||||
--accent-green: #22c55e;
|
||||
--accent-amber: #d4a853;
|
||||
--grid-line: rgba(74, 158, 255, 0.08);
|
||||
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||
--bg-dark: #0b1118;
|
||||
--bg-panel: #101823;
|
||||
--bg-card: #151f2b;
|
||||
--border-color: #263246;
|
||||
--border-glow: rgba(74, 163, 255, 0.4);
|
||||
--text-primary: #d7e0ee;
|
||||
--text-secondary: #9fb0c7;
|
||||
--text-dim: #6f7f94;
|
||||
--accent-cyan: #4aa3ff;
|
||||
--accent-green: #38c180;
|
||||
--accent-amber: #d6a85e;
|
||||
--grid-line: rgba(74, 163, 255, 0.1);
|
||||
--noise-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='40'%20height='40'%20viewBox='0%200%2040%2040'%3E%3Cg%20fill='%23ffffff'%20fill-opacity='0.05'%3E%3Ccircle%20cx='3'%20cy='5'%20r='1'/%3E%3Ccircle%20cx='11'%20cy='9'%20r='1'/%3E%3Ccircle%20cx='18'%20cy='3'%20r='1'/%3E%3Ccircle%20cx='26'%20cy='12'%20r='1'/%3E%3Ccircle%20cx='34'%20cy='6'%20r='1'/%3E%3Ccircle%20cx='7'%20cy='19'%20r='1'/%3E%3Ccircle%20cx='15'%20cy='24'%20r='1'/%3E%3Ccircle%20cx='28'%20cy='22'%20r='1'/%3E%3Ccircle%20cx='36'%20cy='18'%20r='1'/%3E%3Ccircle%20cx='5'%20cy='33'%20r='1'/%3E%3Ccircle%20cx='19'%20cy='32'%20r='1'/%3E%3Ccircle%20cx='31'%20cy='34'%20r='1'/%3E%3C/g%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -36,9 +37,10 @@ body {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image:
|
||||
var(--noise-image),
|
||||
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
||||
background-size: 50px 50px;
|
||||
background-size: 40px 40px, 50px 50px, 50px 50px;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
@@ -50,10 +52,12 @@ body {
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||
color: var(--accent-cyan);
|
||||
animation: scan 6s linear infinite;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
opacity: 0.3;
|
||||
opacity: 0.25;
|
||||
box-shadow: 0 0 8px currentColor;
|
||||
}
|
||||
|
||||
@keyframes scan {
|
||||
@@ -74,6 +78,18 @@ body {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
|
||||
@@ -8,25 +8,26 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--font-sans: 'Space Mono', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
--font-mono: 'Space Mono', 'Fira Code', 'Consolas', monospace;
|
||||
--bg-dark: #0a0c10;
|
||||
--bg-panel: #0f1218;
|
||||
--bg-card: #151a23;
|
||||
--border-color: #1f2937;
|
||||
--border-glow: #4a9eff;
|
||||
--text-primary: #e8eaed;
|
||||
--text-secondary: #9ca3af;
|
||||
--text-dim: #4b5563;
|
||||
--accent-green: #22c55e;
|
||||
--accent-cyan: #4a9eff;
|
||||
--accent-orange: #f59e0b;
|
||||
--accent-red: #ef4444;
|
||||
--accent-yellow: #eab308;
|
||||
--accent-amber: #d4a853;
|
||||
--grid-line: rgba(74, 158, 255, 0.08);
|
||||
--radar-cyan: #4a9eff;
|
||||
--radar-bg: #0f1218;
|
||||
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||
--bg-dark: #0b1118;
|
||||
--bg-panel: #101823;
|
||||
--bg-card: #151f2b;
|
||||
--border-color: #263246;
|
||||
--border-glow: #4aa3ff;
|
||||
--text-primary: #d7e0ee;
|
||||
--text-secondary: #9fb0c7;
|
||||
--text-dim: #6f7f94;
|
||||
--accent-green: #38c180;
|
||||
--accent-cyan: #4aa3ff;
|
||||
--accent-orange: #d6a85e;
|
||||
--accent-red: #e25d5d;
|
||||
--accent-yellow: #e1c26b;
|
||||
--accent-amber: #d6a85e;
|
||||
--grid-line: rgba(74, 163, 255, 0.1);
|
||||
--noise-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='40'%20height='40'%20viewBox='0%200%2040%2040'%3E%3Cg%20fill='%23ffffff'%20fill-opacity='0.05'%3E%3Ccircle%20cx='3'%20cy='5'%20r='1'/%3E%3Ccircle%20cx='11'%20cy='9'%20r='1'/%3E%3Ccircle%20cx='18'%20cy='3'%20r='1'/%3E%3Ccircle%20cx='26'%20cy='12'%20r='1'/%3E%3Ccircle%20cx='34'%20cy='6'%20r='1'/%3E%3Ccircle%20cx='7'%20cy='19'%20r='1'/%3E%3Ccircle%20cx='15'%20cy='24'%20r='1'/%3E%3Ccircle%20cx='28'%20cy='22'%20r='1'/%3E%3Ccircle%20cx='36'%20cy='18'%20r='1'/%3E%3Ccircle%20cx='5'%20cy='33'%20r='1'/%3E%3Ccircle%20cx='19'%20cy='32'%20r='1'/%3E%3Ccircle%20cx='31'%20cy='34'%20r='1'/%3E%3C/g%3E%3C/svg%3E");
|
||||
--radar-cyan: #4aa3ff;
|
||||
--radar-bg: #101823;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -45,9 +46,10 @@ body {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-image:
|
||||
var(--noise-image),
|
||||
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
||||
background-size: 50px 50px;
|
||||
background-size: 40px 40px, 50px 50px, 50px 50px;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
@@ -60,10 +62,12 @@ body {
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||
color: var(--accent-cyan);
|
||||
animation: scan 6s linear infinite;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
opacity: 0.3;
|
||||
opacity: 0.25;
|
||||
box-shadow: 0 0 8px currentColor;
|
||||
}
|
||||
|
||||
@keyframes scan {
|
||||
@@ -91,6 +95,18 @@ body {
|
||||
min-height: 52px;
|
||||
}
|
||||
|
||||
.header::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.header {
|
||||
padding: 12px 20px;
|
||||
@@ -355,7 +371,7 @@ body {
|
||||
}
|
||||
|
||||
.strip-btn.primary {
|
||||
background: linear-gradient(135deg, var(--accent-cyan) 0%, #2563eb 100%);
|
||||
background: linear-gradient(135deg, var(--accent-cyan) 0%, #2b6fb8 100%);
|
||||
border: none;
|
||||
color: white;
|
||||
}
|
||||
@@ -670,14 +686,31 @@ body {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: stretch !important;
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px;
|
||||
padding: 8px 15px;
|
||||
background: var(--bg-panel);
|
||||
border-top: 1px solid rgba(74, 158, 255, 0.3);
|
||||
font-size: 11px;
|
||||
overflow-x: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.controls-bar > .control-group {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
padding: 6px 10px;
|
||||
background: rgba(74, 158, 255, 0.03);
|
||||
border: 1px solid rgba(74, 158, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.controls-bar > .control-group > .control-group-items {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
|
||||
+74
-63
@@ -21,25 +21,34 @@ html {
|
||||
tab-size: 4;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-base);
|
||||
line-height: var(--leading-normal);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-primary);
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-base);
|
||||
line-height: var(--leading-normal);
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-primary);
|
||||
background-image:
|
||||
var(--noise-image),
|
||||
radial-gradient(circle at 15% 0%, var(--grid-dot), transparent 45%),
|
||||
linear-gradient(180deg, var(--grid-dot), transparent 35%),
|
||||
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
||||
background-size: 40px 40px, auto, auto, 48px 48px, 48px 48px;
|
||||
background-attachment: fixed;
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
TYPOGRAPHY
|
||||
============================================ */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-weight: var(--font-semibold);
|
||||
line-height: var(--leading-tight);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-weight: var(--font-semibold);
|
||||
line-height: var(--leading-tight);
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
h1 { font-size: var(--text-4xl); }
|
||||
h2 { font-size: var(--text-3xl); }
|
||||
@@ -80,18 +89,20 @@ code, kbd, pre, samp {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
code {
|
||||
background: var(--bg-tertiary);
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
code {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
pre {
|
||||
background: var(--bg-tertiary);
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
overflow-x: auto;
|
||||
}
|
||||
pre {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
pre code {
|
||||
background: none;
|
||||
@@ -122,38 +133,38 @@ button:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
color: var(--text-primary);
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
color: var(--text-primary);
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-cyan);
|
||||
box-shadow: 0 0 0 3px var(--accent-cyan-dim);
|
||||
}
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-cyan);
|
||||
box-shadow: 0 0 0 2px var(--accent-cyan-dim);
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
select {
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 8px center;
|
||||
padding-right: 28px;
|
||||
}
|
||||
select {
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239fb0c7' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 8px center;
|
||||
padding-right: 28px;
|
||||
}
|
||||
|
||||
input[type="checkbox"],
|
||||
input[type="radio"] {
|
||||
@@ -188,18 +199,18 @@ td {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-secondary);
|
||||
text-transform: uppercase;
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
tr:hover td {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
th {
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-tertiary);
|
||||
text-transform: uppercase;
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
tr:hover td {
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
LISTS
|
||||
|
||||
+267
-148
@@ -9,21 +9,24 @@
|
||||
============================================ */
|
||||
|
||||
/* Base button */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
}
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
font-family: var(--font-mono);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.btn:focus-visible {
|
||||
outline: 2px solid var(--border-focus);
|
||||
@@ -36,38 +39,39 @@
|
||||
}
|
||||
|
||||
/* Button variants */
|
||||
.btn-primary {
|
||||
background: var(--accent-cyan);
|
||||
color: var(--text-inverse);
|
||||
border-color: var(--accent-cyan);
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--accent-cyan);
|
||||
color: var(--text-inverse);
|
||||
border-color: var(--accent-cyan);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--accent-cyan-hover);
|
||||
border-color: var(--accent-cyan-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
.btn-secondary {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--bg-tertiary);
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--bg-elevated);
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--accent-red);
|
||||
@@ -118,21 +122,23 @@
|
||||
/* ============================================
|
||||
CARDS / PANELS
|
||||
============================================ */
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.card {
|
||||
background: linear-gradient(180deg, var(--bg-card) 0%, var(--bg-secondary) 100%);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card-header-title {
|
||||
font-size: var(--text-xs);
|
||||
@@ -153,26 +159,58 @@
|
||||
}
|
||||
|
||||
/* Panel variant (used in dashboards) */
|
||||
.panel {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.panel {
|
||||
background: linear-gradient(180deg, var(--bg-card) 0%, var(--bg-secondary) 100%);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
@supports (clip-path: polygon(0 0)) {
|
||||
.card,
|
||||
.panel {
|
||||
--notch-size: 6px;
|
||||
border-radius: 0;
|
||||
clip-path: polygon(
|
||||
var(--notch-size) 0,
|
||||
calc(100% - var(--notch-size)) 0,
|
||||
100% var(--notch-size),
|
||||
100% calc(100% - var(--notch-size)),
|
||||
calc(100% - var(--notch-size)) 100%,
|
||||
var(--notch-size) 100%,
|
||||
0 calc(100% - var(--notch-size)),
|
||||
0 var(--notch-size)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: linear-gradient(180deg, var(--bg-elevated) 0%, var(--bg-secondary) 100%);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: linear-gradient(180deg, var(--bg-elevated) 0%, var(--bg-secondary) 100%);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-secondary);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card-header::before,
|
||||
.panel-header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: var(--space-3);
|
||||
width: 36px;
|
||||
height: 2px;
|
||||
background: var(--accent-cyan);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.panel-indicator {
|
||||
width: 8px;
|
||||
@@ -193,17 +231,18 @@
|
||||
/* ============================================
|
||||
BADGES
|
||||
============================================ */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: 2px var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-medium);
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: 2px var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-medium);
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.badge-primary {
|
||||
background: var(--accent-cyan-dim);
|
||||
@@ -220,10 +259,53 @@
|
||||
color: var(--accent-orange);
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
background: var(--accent-red-dim);
|
||||
color: var(--accent-red);
|
||||
}
|
||||
.badge-danger {
|
||||
background: var(--accent-red-dim);
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
DATA TAGS
|
||||
============================================ */
|
||||
.data-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: 2px var(--space-2);
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
box-shadow: inset 0 0 0 1px var(--border-glow);
|
||||
}
|
||||
|
||||
.data-tag--accent {
|
||||
border-color: var(--accent-cyan);
|
||||
color: var(--accent-cyan);
|
||||
background: var(--accent-cyan-dim);
|
||||
}
|
||||
|
||||
.data-tag--warning {
|
||||
border-color: var(--accent-amber);
|
||||
color: var(--accent-amber);
|
||||
background: var(--accent-amber-dim);
|
||||
}
|
||||
|
||||
.data-tag--success {
|
||||
border-color: var(--accent-green);
|
||||
color: var(--accent-green);
|
||||
background: var(--accent-green-dim);
|
||||
}
|
||||
|
||||
.data-tag--danger {
|
||||
border-color: var(--accent-red);
|
||||
color: var(--accent-red);
|
||||
background: var(--accent-red-dim);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
STATUS INDICATORS
|
||||
@@ -236,16 +318,16 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-dot.online,
|
||||
.status-dot.active {
|
||||
background: var(--status-online);
|
||||
box-shadow: 0 0 6px var(--status-online);
|
||||
}
|
||||
.status-dot.online,
|
||||
.status-dot.active {
|
||||
background: var(--status-online);
|
||||
box-shadow: 0 0 4px var(--status-online);
|
||||
}
|
||||
|
||||
.status-dot.warning {
|
||||
background: var(--status-warning);
|
||||
box-shadow: 0 0 6px var(--status-warning);
|
||||
}
|
||||
.status-dot.warning {
|
||||
background: var(--status-warning);
|
||||
box-shadow: 0 0 4px var(--status-warning);
|
||||
}
|
||||
|
||||
.status-dot.error,
|
||||
.status-dot.offline {
|
||||
@@ -565,44 +647,69 @@
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.section-title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-secondary);
|
||||
position: relative;
|
||||
padding-left: var(--space-3);
|
||||
}
|
||||
|
||||
.section-title::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
width: 2px;
|
||||
height: 6px;
|
||||
background: var(--accent-cyan);
|
||||
transform: translateY(-50%);
|
||||
opacity: 0.7;
|
||||
box-shadow: 0 6px 0 var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
DIVIDERS
|
||||
============================================ */
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--border-color);
|
||||
margin: var(--space-4) 0;
|
||||
}
|
||||
|
||||
.divider-vertical {
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
background: var(--border-color);
|
||||
margin: 0 var(--space-3);
|
||||
}
|
||||
.divider {
|
||||
height: 1px;
|
||||
background-image: repeating-linear-gradient(
|
||||
90deg,
|
||||
var(--border-light),
|
||||
var(--border-light) 6px,
|
||||
transparent 6px,
|
||||
transparent 12px
|
||||
);
|
||||
margin: var(--space-4) 0;
|
||||
}
|
||||
|
||||
.divider-vertical {
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
background-image: repeating-linear-gradient(
|
||||
180deg,
|
||||
var(--border-light),
|
||||
var(--border-light) 6px,
|
||||
transparent 6px,
|
||||
transparent 12px
|
||||
);
|
||||
margin: 0 var(--space-3);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
UX POLISH - ENHANCED INTERACTIONS
|
||||
============================================ */
|
||||
|
||||
/* Button hover lift */
|
||||
.btn:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.btn:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
box-shadow: 0 0 0 1px var(--border-light);
|
||||
}
|
||||
|
||||
.btn:active:not(:disabled) {
|
||||
box-shadow: inset 0 0 0 1px var(--border-light);
|
||||
}
|
||||
|
||||
/* Card/Panel hover effects */
|
||||
.card,
|
||||
@@ -635,23 +742,23 @@
|
||||
animation: statusGlow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes statusGlow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 6px var(--status-online);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 12px var(--status-online), 0 0 20px var(--status-online);
|
||||
}
|
||||
}
|
||||
@keyframes statusGlow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 4px var(--status-online);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 8px var(--status-online), 0 0 12px var(--status-online);
|
||||
}
|
||||
}
|
||||
|
||||
/* Badge hover effect */
|
||||
.badge {
|
||||
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.badge:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
.badge:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
/* Alert entrance animation */
|
||||
.alert {
|
||||
@@ -680,26 +787,38 @@
|
||||
}
|
||||
|
||||
/* Input focus glow */
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
border-color: var(--accent-cyan);
|
||||
box-shadow: 0 0 0 3px var(--accent-cyan-dim), 0 0 20px rgba(74, 158, 255, 0.1);
|
||||
}
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
border-color: var(--accent-cyan);
|
||||
box-shadow: 0 0 0 2px var(--accent-cyan-dim);
|
||||
}
|
||||
|
||||
/* Nav item active indicator */
|
||||
.mode-nav-btn.active::after,
|
||||
.mobile-nav-btn.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 60%;
|
||||
height: 2px;
|
||||
background: currentColor;
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
.nav-item,
|
||||
.mode-nav-btn,
|
||||
.mobile-nav-btn {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-item.active::after,
|
||||
.mode-nav-btn.active::after,
|
||||
.mobile-nav-btn.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 12%;
|
||||
right: 12%;
|
||||
bottom: 2px;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||
opacity: 0.75;
|
||||
animation: railPulse 2.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes railPulse {
|
||||
0%, 100% { opacity: 0.45; }
|
||||
50% { opacity: 0.9; }
|
||||
}
|
||||
|
||||
/* Smooth tooltip appearance */
|
||||
[data-tooltip]::after {
|
||||
|
||||
+130
-66
@@ -22,18 +22,31 @@
|
||||
/* ============================================
|
||||
GLOBAL HEADER
|
||||
============================================ */
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: var(--header-height);
|
||||
padding: 0 var(--space-4);
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: var(--z-sticky);
|
||||
}
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: var(--header-height);
|
||||
padding: 0 var(--space-4);
|
||||
background: linear-gradient(180deg, var(--bg-elevated) 0%, var(--bg-secondary) 100%);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
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 {
|
||||
display: flex;
|
||||
@@ -116,16 +129,29 @@
|
||||
/* ============================================
|
||||
GLOBAL NAVIGATION
|
||||
============================================ */
|
||||
.app-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--bg-tertiary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 0 var(--space-4);
|
||||
height: var(--nav-height);
|
||||
gap: var(--space-1);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.app-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 0 var(--space-4);
|
||||
height: var(--nav-height);
|
||||
gap: var(--space-1);
|
||||
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 {
|
||||
height: 0;
|
||||
@@ -176,14 +202,14 @@
|
||||
}
|
||||
|
||||
/* Dropdown menu */
|
||||
.nav-dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
min-width: 180px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
.nav-dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
min-width: 180px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: var(--space-1);
|
||||
opacity: 0;
|
||||
@@ -273,14 +299,27 @@
|
||||
/* ============================================
|
||||
MOBILE NAVIGATION
|
||||
============================================ */
|
||||
.mobile-nav {
|
||||
display: none;
|
||||
background: var(--bg-tertiary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
overflow-x: auto;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.mobile-nav {
|
||||
display: none;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
overflow-x: auto;
|
||||
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 {
|
||||
height: 0;
|
||||
@@ -357,13 +396,13 @@
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.app-sidebar {
|
||||
width: var(--sidebar-width);
|
||||
background: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border-color);
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.app-sidebar {
|
||||
width: var(--sidebar-width);
|
||||
background: linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-tertiary) 100%);
|
||||
border-right: 1px solid var(--border-color);
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
padding: var(--space-4);
|
||||
@@ -408,15 +447,28 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: linear-gradient(180deg, var(--bg-elevated) 0%, var(--bg-secondary) 100%);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
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 {
|
||||
font-size: var(--text-lg);
|
||||
@@ -443,10 +495,10 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dashboard-sidebar {
|
||||
width: 320px;
|
||||
background: var(--bg-secondary);
|
||||
border-left: 1px solid var(--border-color);
|
||||
.dashboard-sidebar {
|
||||
width: 320px;
|
||||
background: linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-tertiary) 100%);
|
||||
border-left: 1px solid var(--border-color);
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -587,14 +639,26 @@
|
||||
============================================ */
|
||||
|
||||
/* Mode Navigation Bar */
|
||||
.mode-nav {
|
||||
display: none;
|
||||
background: #151a23 !important; /* Explicit color - forced to ensure consistency */
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 0 20px;
|
||||
position: relative;
|
||||
z-index: 100;
|
||||
}
|
||||
.mode-nav {
|
||||
display: none;
|
||||
background: var(--bg-secondary) !important; /* Explicit color - forced to ensure consistency */
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 0 20px;
|
||||
position: relative;
|
||||
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) {
|
||||
.mode-nav {
|
||||
|
||||
@@ -10,51 +10,56 @@
|
||||
============================================ */
|
||||
|
||||
/* Backgrounds - layered depth system */
|
||||
--bg-primary: #0a0c10;
|
||||
--bg-secondary: #0f1218;
|
||||
--bg-tertiary: #151a23;
|
||||
--bg-card: #121620;
|
||||
--bg-elevated: #1a202c;
|
||||
--bg-overlay: rgba(0, 0, 0, 0.7);
|
||||
--bg-primary: #0b1118;
|
||||
--bg-secondary: #101823;
|
||||
--bg-tertiary: #151f2b;
|
||||
--bg-card: #121a25;
|
||||
--bg-elevated: #1b2734;
|
||||
--bg-overlay: rgba(8, 13, 20, 0.75);
|
||||
|
||||
/* Background aliases for components */
|
||||
--bg-dark: var(--bg-primary);
|
||||
--bg-panel: var(--bg-secondary);
|
||||
|
||||
/* Accent colors */
|
||||
--accent-cyan: #4a9eff;
|
||||
--accent-cyan-dim: rgba(74, 158, 255, 0.15);
|
||||
--accent-cyan: #4aa3ff;
|
||||
--accent-cyan-dim: rgba(74, 163, 255, 0.16);
|
||||
--accent-cyan-hover: #6bb3ff;
|
||||
--accent-green: #22c55e;
|
||||
--accent-green-dim: rgba(34, 197, 94, 0.15);
|
||||
--accent-red: #ef4444;
|
||||
--accent-red-dim: rgba(239, 68, 68, 0.15);
|
||||
--accent-orange: #f59e0b;
|
||||
--accent-orange-dim: rgba(245, 158, 11, 0.15);
|
||||
--accent-amber: #d4a853;
|
||||
--accent-amber-dim: rgba(212, 168, 83, 0.15);
|
||||
--accent-yellow: #eab308;
|
||||
--accent-purple: #a855f7;
|
||||
--accent-green: #38c180;
|
||||
--accent-green-dim: rgba(56, 193, 128, 0.18);
|
||||
--accent-red: #e25d5d;
|
||||
--accent-red-dim: rgba(226, 93, 93, 0.16);
|
||||
--accent-orange: #d6a85e;
|
||||
--accent-orange-dim: rgba(214, 168, 94, 0.16);
|
||||
--accent-amber: #d6a85e;
|
||||
--accent-amber-dim: rgba(214, 168, 94, 0.18);
|
||||
--accent-yellow: #e1c26b;
|
||||
--accent-purple: #8f7bd6;
|
||||
|
||||
/* Text hierarchy */
|
||||
--text-primary: #e8eaed;
|
||||
--text-secondary: #9ca3af;
|
||||
--text-dim: #4b5563;
|
||||
--text-muted: #374151;
|
||||
--text-inverse: #0a0c10;
|
||||
--text-primary: #d7e0ee;
|
||||
--text-secondary: #9fb0c7;
|
||||
--text-dim: #6f7f94;
|
||||
--text-muted: #445266;
|
||||
--text-inverse: #0b1118;
|
||||
|
||||
/* Borders */
|
||||
--border-color: #1f2937;
|
||||
--border-light: #374151;
|
||||
--border-glow: rgba(74, 158, 255, 0.2);
|
||||
--border-color: #263246;
|
||||
--border-light: #354458;
|
||||
--border-glow: rgba(74, 163, 255, 0.25);
|
||||
--border-focus: var(--accent-cyan);
|
||||
|
||||
/* Status colors */
|
||||
--status-online: #22c55e;
|
||||
--status-warning: #f59e0b;
|
||||
--status-error: #ef4444;
|
||||
--status-offline: #6b7280;
|
||||
--status-info: #3b82f6;
|
||||
--status-online: #38c180;
|
||||
--status-warning: #d6a85e;
|
||||
--status-error: #e25d5d;
|
||||
--status-offline: #6f7f94;
|
||||
--status-info: #4aa3ff;
|
||||
|
||||
/* Subtle grid/pattern */
|
||||
--grid-line: rgba(74, 163, 255, 0.1);
|
||||
--grid-dot: rgba(255, 255, 255, 0.03);
|
||||
--noise-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='40'%20height='40'%20viewBox='0%200%2040%2040'%3E%3Cg%20fill='%23ffffff'%20fill-opacity='0.05'%3E%3Ccircle%20cx='3'%20cy='5'%20r='1'/%3E%3Ccircle%20cx='11'%20cy='9'%20r='1'/%3E%3Ccircle%20cx='18'%20cy='3'%20r='1'/%3E%3Ccircle%20cx='26'%20cy='12'%20r='1'/%3E%3Ccircle%20cx='34'%20cy='6'%20r='1'/%3E%3Ccircle%20cx='7'%20cy='19'%20r='1'/%3E%3Ccircle%20cx='15'%20cy='24'%20r='1'/%3E%3Ccircle%20cx='28'%20cy='22'%20r='1'/%3E%3Ccircle%20cx='36'%20cy='18'%20r='1'/%3E%3Ccircle%20cx='5'%20cy='33'%20r='1'/%3E%3Ccircle%20cx='19'%20cy='32'%20r='1'/%3E%3Ccircle%20cx='31'%20cy='34'%20r='1'/%3E%3C/g%3E%3C/svg%3E");
|
||||
|
||||
/* ============================================
|
||||
SPACING SCALE
|
||||
@@ -73,8 +78,8 @@
|
||||
/* ============================================
|
||||
TYPOGRAPHY
|
||||
============================================ */
|
||||
--font-sans: 'Space Mono', ui-monospace, 'SF Mono', monospace;
|
||||
--font-mono: 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', monospace;
|
||||
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||
|
||||
/* Font sizes */
|
||||
--text-xs: 10px;
|
||||
@@ -100,19 +105,19 @@
|
||||
/* ============================================
|
||||
BORDERS & RADIUS
|
||||
============================================ */
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 8px;
|
||||
--radius-xl: 12px;
|
||||
--radius-sm: 3px;
|
||||
--radius-md: 4px;
|
||||
--radius-lg: 6px;
|
||||
--radius-xl: 8px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* ============================================
|
||||
SHADOWS
|
||||
============================================ */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4);
|
||||
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.5);
|
||||
--shadow-glow: 0 0 20px rgba(74, 158, 255, 0.15);
|
||||
--shadow-sm: 0 1px 1px rgba(0, 0, 0, 0.35);
|
||||
--shadow-md: 0 4px 8px rgba(0, 0, 0, 0.35);
|
||||
--shadow-lg: 0 12px 18px rgba(0, 0, 0, 0.45);
|
||||
--shadow-glow: 0 0 18px rgba(74, 163, 255, 0.16);
|
||||
|
||||
/* ============================================
|
||||
TRANSITIONS
|
||||
@@ -147,43 +152,47 @@
|
||||
LIGHT THEME OVERRIDES
|
||||
============================================ */
|
||||
[data-theme="light"] {
|
||||
--bg-primary: #f8fafc;
|
||||
--bg-secondary: #f1f5f9;
|
||||
--bg-tertiary: #e2e8f0;
|
||||
--bg-primary: #f4f7fb;
|
||||
--bg-secondary: #e9eef5;
|
||||
--bg-tertiary: #dde5f0;
|
||||
--bg-card: #ffffff;
|
||||
--bg-elevated: #f8fafc;
|
||||
--bg-overlay: rgba(255, 255, 255, 0.9);
|
||||
--bg-elevated: #f1f4f9;
|
||||
--bg-overlay: rgba(244, 247, 251, 0.92);
|
||||
|
||||
/* Background aliases for components */
|
||||
--bg-dark: var(--bg-primary);
|
||||
--bg-panel: var(--bg-secondary);
|
||||
|
||||
--accent-cyan: #2563eb;
|
||||
--accent-cyan-dim: rgba(37, 99, 235, 0.1);
|
||||
--accent-cyan-hover: #1d4ed8;
|
||||
--accent-green: #16a34a;
|
||||
--accent-green-dim: rgba(22, 163, 74, 0.1);
|
||||
--accent-red: #dc2626;
|
||||
--accent-red-dim: rgba(220, 38, 38, 0.1);
|
||||
--accent-orange: #d97706;
|
||||
--accent-orange-dim: rgba(217, 119, 6, 0.1);
|
||||
--accent-amber: #b45309;
|
||||
--accent-amber-dim: rgba(180, 83, 9, 0.1);
|
||||
--accent-cyan: #1f5fa8;
|
||||
--accent-cyan-dim: rgba(31, 95, 168, 0.12);
|
||||
--accent-cyan-hover: #2c73bf;
|
||||
--accent-green: #1f8a57;
|
||||
--accent-green-dim: rgba(31, 138, 87, 0.12);
|
||||
--accent-red: #c74444;
|
||||
--accent-red-dim: rgba(199, 68, 68, 0.12);
|
||||
--accent-orange: #b5863a;
|
||||
--accent-orange-dim: rgba(181, 134, 58, 0.12);
|
||||
--accent-amber: #b5863a;
|
||||
--accent-amber-dim: rgba(181, 134, 58, 0.12);
|
||||
|
||||
--text-primary: #0f172a;
|
||||
--text-secondary: #475569;
|
||||
--text-dim: #94a3b8;
|
||||
--text-muted: #cbd5e1;
|
||||
--text-inverse: #f8fafc;
|
||||
--text-primary: #122034;
|
||||
--text-secondary: #3a4a5f;
|
||||
--text-dim: #6b7c93;
|
||||
--text-muted: #aab6c8;
|
||||
--text-inverse: #f4f7fb;
|
||||
|
||||
--border-color: #e2e8f0;
|
||||
--border-light: #cbd5e1;
|
||||
--border-glow: rgba(37, 99, 235, 0.15);
|
||||
--border-color: #d1d9e6;
|
||||
--border-light: #c1ccdb;
|
||||
--border-glow: rgba(31, 95, 168, 0.12);
|
||||
|
||||
--grid-line: rgba(31, 95, 168, 0.14);
|
||||
--grid-dot: rgba(12, 18, 24, 0.06);
|
||||
--noise-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='40'%20height='40'%20viewBox='0%200%2040%2040'%3E%3Cg%20fill='%23000000'%20fill-opacity='0.05'%3E%3Ccircle%20cx='3'%20cy='5'%20r='1'/%3E%3Ccircle%20cx='11'%20cy='9'%20r='1'/%3E%3Ccircle%20cx='18'%20cy='3'%20r='1'/%3E%3Ccircle%20cx='26'%20cy='12'%20r='1'/%3E%3Ccircle%20cx='34'%20cy='6'%20r='1'/%3E%3Ccircle%20cx='7'%20cy='19'%20r='1'/%3E%3Ccircle%20cx='15'%20cy='24'%20r='1'/%3E%3Ccircle%20cx='28'%20cy='22'%20r='1'/%3E%3Ccircle%20cx='36'%20cy='18'%20r='1'/%3E%3Ccircle%20cx='5'%20cy='33'%20r='1'/%3E%3Ccircle%20cx='19'%20cy='32'%20r='1'/%3E%3Ccircle%20cx='31'%20cy='34'%20r='1'/%3E%3C/g%3E%3C/svg%3E");
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.15);
|
||||
--shadow-glow: 0 0 20px rgba(37, 99, 235, 0.1);
|
||||
--shadow-glow: 0 0 18px rgba(31, 95, 168, 0.1);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
|
||||
+65
-48
@@ -5,63 +5,72 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--font-sans: 'Space Mono', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
--font-mono: 'Space Mono', 'Fira Code', 'Consolas', monospace;
|
||||
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||
/* Tactical dark palette */
|
||||
--bg-primary: #0a0c10;
|
||||
--bg-secondary: #0f1218;
|
||||
--bg-tertiary: #151a23;
|
||||
--bg-card: #121620;
|
||||
--bg-elevated: #1a202c;
|
||||
--bg-primary: #0b1118;
|
||||
--bg-secondary: #101823;
|
||||
--bg-tertiary: #151f2b;
|
||||
--bg-card: #121a25;
|
||||
--bg-elevated: #1b2734;
|
||||
|
||||
/* Accent colors - sophisticated blue/amber */
|
||||
--accent-cyan: #4a9eff;
|
||||
--accent-cyan-dim: rgba(74, 158, 255, 0.15);
|
||||
--accent-green: #22c55e;
|
||||
--accent-green-dim: rgba(34, 197, 94, 0.15);
|
||||
--accent-red: #ef4444;
|
||||
--accent-red-dim: rgba(239, 68, 68, 0.15);
|
||||
--accent-orange: #f59e0b;
|
||||
--accent-amber: #d4a853;
|
||||
--accent-amber-dim: rgba(212, 168, 83, 0.15);
|
||||
/* Accent colors - slate/cyan */
|
||||
--accent-cyan: #4aa3ff;
|
||||
--accent-cyan-dim: rgba(74, 163, 255, 0.16);
|
||||
--accent-green: #38c180;
|
||||
--accent-green-dim: rgba(56, 193, 128, 0.18);
|
||||
--accent-red: #e25d5d;
|
||||
--accent-red-dim: rgba(226, 93, 93, 0.16);
|
||||
--accent-orange: #d6a85e;
|
||||
--accent-amber: #d6a85e;
|
||||
--accent-amber-dim: rgba(214, 168, 94, 0.18);
|
||||
|
||||
/* Text hierarchy */
|
||||
--text-primary: #e8eaed;
|
||||
--text-secondary: #9ca3af;
|
||||
--text-dim: #4b5563;
|
||||
--text-muted: #374151;
|
||||
--text-primary: #d7e0ee;
|
||||
--text-secondary: #9fb0c7;
|
||||
--text-dim: #6f7f94;
|
||||
--text-muted: #445266;
|
||||
|
||||
/* Borders */
|
||||
--border-color: #1f2937;
|
||||
--border-light: #374151;
|
||||
--border-glow: rgba(74, 158, 255, 0.2);
|
||||
--border-color: #263246;
|
||||
--border-light: #354458;
|
||||
--border-glow: rgba(74, 163, 255, 0.25);
|
||||
|
||||
/* Status colors */
|
||||
--status-online: #22c55e;
|
||||
--status-warning: #f59e0b;
|
||||
--status-error: #ef4444;
|
||||
--status-offline: #6b7280;
|
||||
--status-online: #38c180;
|
||||
--status-warning: #d6a85e;
|
||||
--status-error: #e25d5d;
|
||||
--status-offline: #6f7f94;
|
||||
|
||||
/* Subtle grid/pattern */
|
||||
--grid-line: rgba(74, 163, 255, 0.1);
|
||||
--grid-dot: rgba(255, 255, 255, 0.03);
|
||||
--noise-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='40'%20height='40'%20viewBox='0%200%2040%2040'%3E%3Cg%20fill='%23ffffff'%20fill-opacity='0.05'%3E%3Ccircle%20cx='3'%20cy='5'%20r='1'/%3E%3Ccircle%20cx='11'%20cy='9'%20r='1'/%3E%3Ccircle%20cx='18'%20cy='3'%20r='1'/%3E%3Ccircle%20cx='26'%20cy='12'%20r='1'/%3E%3Ccircle%20cx='34'%20cy='6'%20r='1'/%3E%3Ccircle%20cx='7'%20cy='19'%20r='1'/%3E%3Ccircle%20cx='15'%20cy='24'%20r='1'/%3E%3Ccircle%20cx='28'%20cy='22'%20r='1'/%3E%3Ccircle%20cx='36'%20cy='18'%20r='1'/%3E%3Ccircle%20cx='5'%20cy='33'%20r='1'/%3E%3Ccircle%20cx='19'%20cy='32'%20r='1'/%3E%3Ccircle%20cx='31'%20cy='34'%20r='1'/%3E%3C/g%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg-primary: #f8fafc;
|
||||
--bg-secondary: #f1f5f9;
|
||||
--bg-tertiary: #e2e8f0;
|
||||
--bg-primary: #f4f7fb;
|
||||
--bg-secondary: #e9eef5;
|
||||
--bg-tertiary: #dde5f0;
|
||||
--bg-card: #ffffff;
|
||||
--bg-elevated: #f8fafc;
|
||||
--accent-cyan: #2563eb;
|
||||
--accent-cyan-dim: rgba(37, 99, 235, 0.1);
|
||||
--accent-green: #16a34a;
|
||||
--accent-red: #dc2626;
|
||||
--accent-orange: #d97706;
|
||||
--accent-amber: #b45309;
|
||||
--text-primary: #0f172a;
|
||||
--text-secondary: #475569;
|
||||
--text-dim: #94a3b8;
|
||||
--text-muted: #cbd5e1;
|
||||
--border-color: #e2e8f0;
|
||||
--border-light: #cbd5e1;
|
||||
--border-glow: rgba(37, 99, 235, 0.15);
|
||||
--bg-elevated: #f1f4f9;
|
||||
--accent-cyan: #1f5fa8;
|
||||
--accent-cyan-dim: rgba(31, 95, 168, 0.12);
|
||||
--accent-green: #1f8a57;
|
||||
--accent-red: #c74444;
|
||||
--accent-orange: #b5863a;
|
||||
--accent-amber: #b5863a;
|
||||
--text-primary: #122034;
|
||||
--text-secondary: #3a4a5f;
|
||||
--text-dim: #6b7c93;
|
||||
--text-muted: #aab6c8;
|
||||
--border-color: #d1d9e6;
|
||||
--border-light: #c1ccdb;
|
||||
--border-glow: rgba(31, 95, 168, 0.12);
|
||||
|
||||
--grid-line: rgba(31, 95, 168, 0.14);
|
||||
--grid-dot: rgba(12, 18, 24, 0.06);
|
||||
--noise-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='40'%20height='40'%20viewBox='0%200%2040%2040'%3E%3Cg%20fill='%23000000'%20fill-opacity='0.05'%3E%3Ccircle%20cx='3'%20cy='5'%20r='1'/%3E%3Ccircle%20cx='11'%20cy='9'%20r='1'/%3E%3Ccircle%20cx='18'%20cy='3'%20r='1'/%3E%3Ccircle%20cx='26'%20cy='12'%20r='1'/%3E%3Ccircle%20cx='34'%20cy='6'%20r='1'/%3E%3Ccircle%20cx='7'%20cy='19'%20r='1'/%3E%3Ccircle%20cx='15'%20cy='24'%20r='1'/%3E%3Ccircle%20cx='28'%20cy='22'%20r='1'/%3E%3Ccircle%20cx='36'%20cy='18'%20r='1'/%3E%3Ccircle%20cx='5'%20cy='33'%20r='1'/%3E%3Ccircle%20cx='19'%20cy='32'%20r='1'/%3E%3Ccircle%20cx='31'%20cy='34'%20r='1'/%3E%3C/g%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
[data-theme="light"] body {
|
||||
@@ -74,7 +83,15 @@
|
||||
|
||||
body {
|
||||
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);
|
||||
min-height: 100vh;
|
||||
font-size: 14px;
|
||||
@@ -108,8 +125,8 @@ body {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background:
|
||||
radial-gradient(circle at 50% 50%, rgba(74, 158, 255, 0.03) 0%, transparent 50%),
|
||||
linear-gradient(180deg, transparent 0%, rgba(0, 212, 255, 0.02) 100%);
|
||||
radial-gradient(circle at 50% 50%, rgba(74, 163, 255, 0.04) 0%, transparent 50%),
|
||||
linear-gradient(180deg, transparent 0%, rgba(74, 163, 255, 0.03) 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -45,6 +45,7 @@
|
||||
padding: 12px;
|
||||
background: rgba(0,0,0,0.3);
|
||||
border-radius: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tscm-threat-banner .threat-card {
|
||||
flex: 1;
|
||||
@@ -68,11 +69,6 @@
|
||||
min-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 {
|
||||
padding: 10px 12px;
|
||||
background: rgba(0,0,0,0.3);
|
||||
@@ -200,6 +196,17 @@
|
||||
margin-left: 6px;
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -465,6 +472,18 @@
|
||||
color: var(--text-dim);
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -882,6 +901,42 @@
|
||||
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 */
|
||||
.tscm-advanced-modal {
|
||||
max-width: 600px;
|
||||
@@ -1461,3 +1516,156 @@
|
||||
width: 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;
|
||||
}
|
||||
|
||||
@@ -5,23 +5,24 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--font-sans: 'Space Mono', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
--font-mono: 'Space Mono', 'Fira Code', 'Consolas', monospace;
|
||||
--bg-dark: #0a0c10;
|
||||
--bg-panel: #0f1218;
|
||||
--bg-card: #151a23;
|
||||
--border-color: #1f2937;
|
||||
--border-glow: #4a9eff;
|
||||
--text-primary: #e8eaed;
|
||||
--text-secondary: #9ca3af;
|
||||
--text-dim: #4b5563;
|
||||
--accent-cyan: #4a9eff;
|
||||
--accent-green: #22c55e;
|
||||
--accent-orange: #f59e0b;
|
||||
--accent-red: #ef4444;
|
||||
--accent-purple: #a855f7;
|
||||
--accent-amber: #d4a853;
|
||||
--grid-line: rgba(74, 158, 255, 0.08);
|
||||
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||
--bg-dark: #0b1118;
|
||||
--bg-panel: #101823;
|
||||
--bg-card: #151f2b;
|
||||
--border-color: #263246;
|
||||
--border-glow: #4aa3ff;
|
||||
--text-primary: #d7e0ee;
|
||||
--text-secondary: #9fb0c7;
|
||||
--text-dim: #6f7f94;
|
||||
--accent-cyan: #4aa3ff;
|
||||
--accent-green: #38c180;
|
||||
--accent-orange: #d6a85e;
|
||||
--accent-red: #e25d5d;
|
||||
--accent-purple: #8f7bd6;
|
||||
--accent-amber: #d6a85e;
|
||||
--grid-line: rgba(74, 163, 255, 0.1);
|
||||
--noise-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='40'%20height='40'%20viewBox='0%200%2040%2040'%3E%3Cg%20fill='%23ffffff'%20fill-opacity='0.05'%3E%3Ccircle%20cx='3'%20cy='5'%20r='1'/%3E%3Ccircle%20cx='11'%20cy='9'%20r='1'/%3E%3Ccircle%20cx='18'%20cy='3'%20r='1'/%3E%3Ccircle%20cx='26'%20cy='12'%20r='1'/%3E%3Ccircle%20cx='34'%20cy='6'%20r='1'/%3E%3Ccircle%20cx='7'%20cy='19'%20r='1'/%3E%3Ccircle%20cx='15'%20cy='24'%20r='1'/%3E%3Ccircle%20cx='28'%20cy='22'%20r='1'/%3E%3Ccircle%20cx='36'%20cy='18'%20r='1'/%3E%3Ccircle%20cx='5'%20cy='33'%20r='1'/%3E%3Ccircle%20cx='19'%20cy='32'%20r='1'/%3E%3Ccircle%20cx='31'%20cy='34'%20r='1'/%3E%3C/g%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -40,9 +41,10 @@ body {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-image:
|
||||
var(--noise-image),
|
||||
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
||||
background-size: 50px 50px;
|
||||
background-size: 40px 40px, 50px 50px, 50px 50px;
|
||||
animation: gridMove 20s linear infinite;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
@@ -64,12 +66,14 @@ body {
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4px;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||
animation: scan 3s linear infinite;
|
||||
color: var(--accent-cyan);
|
||||
animation: scan 6s linear infinite;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
opacity: 0.5;
|
||||
opacity: 0.25;
|
||||
box-shadow: 0 0 8px currentColor;
|
||||
}
|
||||
|
||||
@keyframes scan {
|
||||
@@ -94,6 +98,18 @@ body {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 20px;
|
||||
|
||||
@@ -421,6 +421,11 @@
|
||||
color: var(--accent-cyan, #00d4ff);
|
||||
}
|
||||
|
||||
/* Map tile variants */
|
||||
.tile-layer-cyan {
|
||||
filter: sepia(0.35) hue-rotate(185deg) saturate(1.75) brightness(1.06) contrast(1.05);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 640px) {
|
||||
.settings-modal.active {
|
||||
|
||||
@@ -33,6 +33,9 @@ const ProximityRadar = (function() {
|
||||
let activeFilter = null;
|
||||
let onDeviceClick = null;
|
||||
let selectedDeviceKey = null;
|
||||
let isHovered = false;
|
||||
let renderPending = false;
|
||||
let renderTimer = null;
|
||||
|
||||
/**
|
||||
* Initialize the radar component
|
||||
@@ -162,8 +165,18 @@ const ProximityRadar = (function() {
|
||||
devices.set(device.device_key, device);
|
||||
});
|
||||
|
||||
// Apply filter and render
|
||||
renderDevices();
|
||||
// Defer render while user is hovering to prevent DOM rebuild flicker
|
||||
if (isHovered) {
|
||||
renderPending = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce rapid updates (e.g. per-device SSE events)
|
||||
if (renderTimer) clearTimeout(renderTimer);
|
||||
renderTimer = setTimeout(() => {
|
||||
renderTimer = null;
|
||||
renderDevices();
|
||||
}, 200);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -211,26 +224,28 @@ const ProximityRadar = (function() {
|
||||
const hitAreaSize = Math.max(dotSize * 2, 15);
|
||||
|
||||
return `
|
||||
<g class="radar-device ${pulseClass}${isSelected ? ' selected' : ''}" data-device-key="${escapeAttr(device.device_key)}"
|
||||
transform="translate(${x}, ${y})" style="cursor: pointer;">
|
||||
<!-- Invisible hit area to prevent hover flicker -->
|
||||
<circle class="radar-device-hitarea" r="${hitAreaSize}" fill="transparent" />
|
||||
${isSelected ? `<circle r="${dotSize + 8}" fill="none" stroke="#00d4ff" stroke-width="2" stroke-opacity="0.8">
|
||||
<animate attributeName="r" values="${dotSize + 6};${dotSize + 10};${dotSize + 6}" dur="1.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="stroke-opacity" values="0.8;0.4;0.8" dur="1.5s" repeatCount="indefinite"/>
|
||||
</circle>` : ''}
|
||||
<circle r="${dotSize}" fill="${color}"
|
||||
fill-opacity="${isSelected ? 1 : 0.4 + confidence * 0.5}"
|
||||
stroke="${isSelected ? '#00d4ff' : color}" stroke-width="${isSelected ? 2 : 1}" />
|
||||
${device.is_new && !isSelected ? `<circle r="${dotSize + 3}" fill="none" stroke="#3b82f6" stroke-width="1" stroke-dasharray="2,2" />` : ''}
|
||||
<title>${escapeHtml(device.name || device.address)} (${device.rssi_current || '--'} dBm)</title>
|
||||
<g transform="translate(${x}, ${y})">
|
||||
<g class="radar-device ${pulseClass}${isSelected ? ' selected' : ''}" data-device-key="${escapeAttr(device.device_key)}"
|
||||
style="cursor: pointer;">
|
||||
<!-- Invisible hit area to prevent hover flicker -->
|
||||
<circle class="radar-device-hitarea" r="${hitAreaSize}" fill="transparent" />
|
||||
${isSelected ? `<circle r="${dotSize + 8}" fill="none" stroke="#00d4ff" stroke-width="2" stroke-opacity="0.8">
|
||||
<animate attributeName="r" values="${dotSize + 6};${dotSize + 10};${dotSize + 6}" dur="1.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="stroke-opacity" values="0.8;0.4;0.8" dur="1.5s" repeatCount="indefinite"/>
|
||||
</circle>` : ''}
|
||||
<circle r="${dotSize}" fill="${color}"
|
||||
fill-opacity="${isSelected ? 1 : 0.4 + confidence * 0.5}"
|
||||
stroke="${isSelected ? '#00d4ff' : color}" stroke-width="${isSelected ? 2 : 1}" />
|
||||
${device.is_new && !isSelected ? `<circle r="${dotSize + 3}" fill="none" stroke="#3b82f6" stroke-width="1" stroke-dasharray="2,2" />` : ''}
|
||||
<title>${escapeHtml(device.name || device.address)} (${device.rssi_current || '--'} dBm)</title>
|
||||
</g>
|
||||
</g>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
devicesGroup.innerHTML = dots;
|
||||
|
||||
// Attach click handlers
|
||||
// Attach event handlers
|
||||
devicesGroup.querySelectorAll('.radar-device').forEach(el => {
|
||||
el.addEventListener('click', (e) => {
|
||||
const deviceKey = el.getAttribute('data-device-key');
|
||||
@@ -238,6 +253,14 @@ const ProximityRadar = (function() {
|
||||
onDeviceClick(deviceKey);
|
||||
}
|
||||
});
|
||||
el.addEventListener('mouseenter', () => { isHovered = true; });
|
||||
el.addEventListener('mouseleave', () => {
|
||||
isHovered = false;
|
||||
if (renderPending) {
|
||||
renderPending = false;
|
||||
renderDevices();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -865,15 +865,12 @@ function connectAgentStream(mode, onMessage) {
|
||||
}
|
||||
|
||||
let streamUrl;
|
||||
if (currentAgent === 'local') {
|
||||
streamUrl = `/${mode}/stream`;
|
||||
} else {
|
||||
// For remote agents, we could either:
|
||||
// 1. Use the multi-agent stream: /controller/stream/all
|
||||
// 2. Or proxy through controller (not implemented yet)
|
||||
// For now, use multi-agent stream which includes agent_name tagging
|
||||
streamUrl = '/controller/stream/all';
|
||||
}
|
||||
if (currentAgent === 'local') {
|
||||
streamUrl = `/${mode}/stream`;
|
||||
} else {
|
||||
// For remote agents, proxy SSE through controller
|
||||
streamUrl = `/controller/agents/${currentAgent}/${mode}/stream`;
|
||||
}
|
||||
|
||||
agentEventSource = new EventSource(streamUrl);
|
||||
|
||||
@@ -881,15 +878,7 @@ function connectAgentStream(mode, onMessage) {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
// If using multi-agent stream, filter by current agent if needed
|
||||
if (streamUrl === '/controller/stream/all' && currentAgent !== 'local') {
|
||||
const agent = agents.find(a => a.id == currentAgent);
|
||||
if (agent && data.agent_name && data.agent_name !== agent.name) {
|
||||
return; // Skip messages from other agents
|
||||
}
|
||||
}
|
||||
|
||||
onMessage(data);
|
||||
onMessage(data);
|
||||
} catch (e) {
|
||||
console.error('Error parsing SSE message:', e);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ const Settings = {
|
||||
'offline.enabled': false,
|
||||
'offline.assets_source': 'cdn',
|
||||
'offline.fonts_source': 'cdn',
|
||||
'offline.tile_provider': 'cartodb_dark',
|
||||
'offline.tile_provider': 'cartodb_dark_cyan',
|
||||
'offline.tile_server_url': ''
|
||||
},
|
||||
|
||||
@@ -24,6 +24,14 @@ const Settings = {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||||
subdomains: 'abcd'
|
||||
},
|
||||
cartodb_dark_cyan: {
|
||||
url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png',
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||||
subdomains: 'abcd',
|
||||
options: {
|
||||
className: 'tile-layer-cyan'
|
||||
}
|
||||
},
|
||||
cartodb_light: {
|
||||
url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/light_all/{z}/{x}/{y}.png',
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||||
@@ -213,7 +221,8 @@ const Settings = {
|
||||
const config = this.getTileConfig();
|
||||
const options = {
|
||||
attribution: config.attribution,
|
||||
maxZoom: 19
|
||||
maxZoom: 19,
|
||||
...(config.options || {})
|
||||
};
|
||||
if (config.subdomains) {
|
||||
options.subdomains = config.subdomains;
|
||||
@@ -351,7 +360,8 @@ const Settings = {
|
||||
// Add new tile layer
|
||||
const options = {
|
||||
attribution: config.attribution,
|
||||
maxZoom: 19
|
||||
maxZoom: 19,
|
||||
...(config.options || {})
|
||||
};
|
||||
if (config.subdomains) {
|
||||
options.subdomains = config.subdomains;
|
||||
@@ -742,6 +752,11 @@ async function checkForUpdatesManual() {
|
||||
const content = document.getElementById('updateStatusContent');
|
||||
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>';
|
||||
|
||||
try {
|
||||
@@ -759,6 +774,11 @@ async function loadUpdateStatus() {
|
||||
const content = document.getElementById('updateStatusContent');
|
||||
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 {
|
||||
const data = await Updater.getStatus();
|
||||
renderUpdateStatus(data);
|
||||
|
||||
@@ -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;
|
||||
@@ -830,6 +830,11 @@ function handleSignalFound(data) {
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('Signal Found!', `${freqStr} MHz - Audio streaming`);
|
||||
}
|
||||
|
||||
// Auto-trigger signal identification
|
||||
if (typeof guessSignal === 'function') {
|
||||
guessSignal(data.frequency, data.modulation);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSignalLost(data) {
|
||||
@@ -985,11 +990,15 @@ function addSignalHit(data) {
|
||||
}
|
||||
|
||||
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');
|
||||
row.style.borderBottom = '1px solid var(--border-color)';
|
||||
row.innerHTML = `
|
||||
<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: ${snrColor}; font-weight: bold; font-size: 9px;">${snrText}</td>
|
||||
<td style="padding: 4px; color: var(--text-secondary);">${mod.toUpperCase()}</td>
|
||||
<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>
|
||||
@@ -2933,6 +2942,281 @@ window.updateListenButtonState = updateListenButtonState;
|
||||
// Export functions for HTML onclick handlers
|
||||
window.toggleDirectListen = toggleDirectListen;
|
||||
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.toggleScanner = toggleScanner;
|
||||
window.startScanner = startScanner;
|
||||
@@ -2949,3 +3233,7 @@ window.removeBookmark = removeBookmark;
|
||||
window.tuneToFrequency = tuneToFrequency;
|
||||
window.clearScannerLog = clearScannerLog;
|
||||
window.exportScannerLog = exportScannerLog;
|
||||
window.manualSignalGuess = manualSignalGuess;
|
||||
window.guessSignal = guessSignal;
|
||||
window.startWaterfall = startWaterfall;
|
||||
window.stopWaterfall = stopWaterfall;
|
||||
|
||||
@@ -117,12 +117,13 @@ const Meshtastic = (function() {
|
||||
Settings.createTileLayer().addTo(meshMap);
|
||||
Settings.registerMap(meshMap);
|
||||
} else {
|
||||
L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||||
maxZoom: 19,
|
||||
subdomains: 'abcd'
|
||||
}).addTo(meshMap);
|
||||
}
|
||||
L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||||
maxZoom: 19,
|
||||
subdomains: 'abcd',
|
||||
className: 'tile-layer-cyan'
|
||||
}).addTo(meshMap);
|
||||
}
|
||||
|
||||
// Handle resize
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -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()">×</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
|
||||
};
|
||||
})();
|
||||
@@ -183,10 +183,11 @@ const SSTV = (function() {
|
||||
Settings.registerMap(issMap);
|
||||
} else {
|
||||
// Fallback to dark theme tiles
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
maxZoom: 19
|
||||
}).addTo(issMap);
|
||||
}
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
maxZoom: 19,
|
||||
className: 'tile-layer-cyan'
|
||||
}).addTo(issMap);
|
||||
}
|
||||
|
||||
// Create ISS icon
|
||||
const issIcon = L.divIcon({
|
||||
|
||||
@@ -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: '© OpenStreetMap contributors © 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;
|
||||
@@ -879,6 +879,7 @@ const WiFiMode = (function() {
|
||||
updateNetworkRow(network);
|
||||
updateStats();
|
||||
updateProximityRadar();
|
||||
updateChannelChart();
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
function updateChannelChart(band = '2.4') {
|
||||
function updateChannelChart(band) {
|
||||
if (typeof ChannelChart === 'undefined') return;
|
||||
|
||||
// Use the currently active band tab if no band specified
|
||||
if (!band) {
|
||||
const activeTab = elements.channelBandTabs && elements.channelBandTabs.querySelector('.channel-band-tab.active');
|
||||
band = activeTab ? activeTab.dataset.band : '2.4';
|
||||
}
|
||||
|
||||
// Recalculate channel stats from networks if needed
|
||||
if (channelStats.length === 0 && networks.size > 0) {
|
||||
channelStats = calculateChannelStats();
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/*!
|
||||
* chartjs-adapter-date-fns v3.0.0 - Lightweight date adapter for Chart.js
|
||||
* Uses native Date parsing (no external dependencies)
|
||||
*/
|
||||
(function() {
|
||||
'use strict';
|
||||
const FORMATS = {
|
||||
datetime: 'MMM d, yyyy, h:mm:ss a',
|
||||
millisecond: 'h:mm:ss.SSS a',
|
||||
second: 'h:mm:ss a',
|
||||
minute: 'h:mm a',
|
||||
hour: 'ha',
|
||||
day: 'MMM d',
|
||||
week: 'PP',
|
||||
month: 'MMM yyyy',
|
||||
quarter: "'Q'Q - yyyy",
|
||||
year: 'yyyy'
|
||||
};
|
||||
|
||||
function formatDate(date, fmt) {
|
||||
const d = new Date(date);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
const h = d.getHours();
|
||||
const m = d.getMinutes();
|
||||
const s = d.getSeconds();
|
||||
const ms = d.getMilliseconds();
|
||||
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||
const ampm = h >= 12 ? 'PM' : 'AM';
|
||||
const h12 = h % 12 || 12;
|
||||
|
||||
switch(fmt) {
|
||||
case 'h:mm:ss.SSS a':
|
||||
return `${h12}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}.${String(ms).padStart(3,'0')} ${ampm}`;
|
||||
case 'h:mm:ss a':
|
||||
return `${h12}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')} ${ampm}`;
|
||||
case 'h:mm a':
|
||||
return `${h12}:${String(m).padStart(2,'0')} ${ampm}`;
|
||||
case 'ha':
|
||||
return `${h12}${ampm}`;
|
||||
case 'MMM d':
|
||||
return `${months[d.getMonth()]} ${d.getDate()}`;
|
||||
case 'MMM yyyy':
|
||||
return `${months[d.getMonth()]} ${d.getFullYear()}`;
|
||||
case 'yyyy':
|
||||
return `${d.getFullYear()}`;
|
||||
default:
|
||||
return `${months[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}, ${h12}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')} ${ampm}`;
|
||||
}
|
||||
}
|
||||
|
||||
const UNITS = ['millisecond','second','minute','hour','day','week','month','quarter','year'];
|
||||
const UNIT_MS = {
|
||||
millisecond: 1,
|
||||
second: 1000,
|
||||
minute: 60000,
|
||||
hour: 3600000,
|
||||
day: 86400000,
|
||||
week: 604800000,
|
||||
month: 2592000000,
|
||||
quarter: 7776000000,
|
||||
year: 31536000000
|
||||
};
|
||||
|
||||
if (typeof Chart !== 'undefined' && Chart._adapters && Chart._adapters._date) {
|
||||
const adapter = Chart._adapters._date;
|
||||
adapter.override({
|
||||
_id: 'date-fns-lite',
|
||||
formats: function() { return FORMATS; },
|
||||
parse: function(value) {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (typeof value === 'number') return value;
|
||||
const d = new Date(value);
|
||||
return isNaN(d.getTime()) ? null : d.getTime();
|
||||
},
|
||||
format: function(time, fmt) {
|
||||
return formatDate(time, fmt);
|
||||
},
|
||||
add: function(time, amount, unit) {
|
||||
const d = new Date(time);
|
||||
switch(unit) {
|
||||
case 'millisecond': d.setTime(d.getTime() + amount); break;
|
||||
case 'second': d.setSeconds(d.getSeconds() + amount); break;
|
||||
case 'minute': d.setMinutes(d.getMinutes() + amount); break;
|
||||
case 'hour': d.setHours(d.getHours() + amount); break;
|
||||
case 'day': d.setDate(d.getDate() + amount); break;
|
||||
case 'week': d.setDate(d.getDate() + amount * 7); break;
|
||||
case 'month': d.setMonth(d.getMonth() + amount); break;
|
||||
case 'quarter': d.setMonth(d.getMonth() + amount * 3); break;
|
||||
case 'year': d.setFullYear(d.getFullYear() + amount); break;
|
||||
}
|
||||
return d.getTime();
|
||||
},
|
||||
diff: function(max, min, unit) {
|
||||
return (max - min) / (UNIT_MS[unit] || 1);
|
||||
},
|
||||
startOf: function(time, unit) {
|
||||
const d = new Date(time);
|
||||
switch(unit) {
|
||||
case 'second': d.setMilliseconds(0); break;
|
||||
case 'minute': d.setSeconds(0,0); break;
|
||||
case 'hour': d.setMinutes(0,0,0); break;
|
||||
case 'day': d.setHours(0,0,0,0); break;
|
||||
case 'week': d.setHours(0,0,0,0); d.setDate(d.getDate() - d.getDay()); break;
|
||||
case 'month': d.setHours(0,0,0,0); d.setDate(1); break;
|
||||
case 'quarter': d.setHours(0,0,0,0); d.setMonth(d.getMonth() - d.getMonth() % 3, 1); break;
|
||||
case 'year': d.setHours(0,0,0,0); d.setMonth(0,1); break;
|
||||
}
|
||||
return d.getTime();
|
||||
},
|
||||
endOf: function(time, unit) {
|
||||
const d = new Date(time);
|
||||
switch(unit) {
|
||||
case 'second': d.setMilliseconds(999); break;
|
||||
case 'minute': d.setSeconds(59,999); break;
|
||||
case 'hour': d.setMinutes(59,59,999); break;
|
||||
case 'day': d.setHours(23,59,59,999); break;
|
||||
case 'month': d.setMonth(d.getMonth()+1,0); d.setHours(23,59,59,999); break;
|
||||
case 'year': d.setMonth(11,31); d.setHours(23,59,59,999); break;
|
||||
}
|
||||
return d.getTime();
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -2391,7 +2391,8 @@ sudo make install</code>
|
||||
L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||||
maxZoom: 19,
|
||||
subdomains: 'abcd'
|
||||
subdomains: 'abcd',
|
||||
className: 'tile-layer-cyan'
|
||||
}).addTo(radarMap);
|
||||
}
|
||||
|
||||
|
||||
@@ -413,7 +413,8 @@
|
||||
L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||||
maxZoom: 19,
|
||||
subdomains: 'abcd'
|
||||
subdomains: 'abcd',
|
||||
className: 'tile-layer-cyan'
|
||||
}).addTo(vesselMap);
|
||||
}
|
||||
|
||||
|
||||
+2205
-111
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
@@ -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>
|
||||
@@ -146,6 +146,9 @@
|
||||
<button class="stop-btn" id="tscmEndMeetingBtn" onclick="tscmEndMeeting()" style="width: 100%; padding: 8px; display: none;">
|
||||
End Meeting Window
|
||||
</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;">
|
||||
Devices detected during meetings get flagged
|
||||
</div>
|
||||
@@ -159,12 +162,18 @@
|
||||
<button class="preset-btn" onclick="tscmShowCapabilities()" style="font-size: 10px; padding: 8px;">
|
||||
Capabilities
|
||||
</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;">
|
||||
Known Devices
|
||||
</button>
|
||||
<button class="preset-btn" onclick="tscmShowCases()" style="font-size: 10px; padding: 8px;">
|
||||
Cases
|
||||
</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;">
|
||||
Playbooks
|
||||
</button>
|
||||
|
||||
@@ -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>
|
||||
@@ -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('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('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>
|
||||
|
||||
@@ -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') }}
|
||||
{% 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_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>
|
||||
|
||||
@@ -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') }}
|
||||
{% 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_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('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('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>
|
||||
|
||||
{# JavaScript stub for pages that don't have switchMode defined #}
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
<select id="tileProvider" class="settings-select" onchange="Settings.setTileProvider(this.value)">
|
||||
<option value="openstreetmap">OpenStreetMap</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="esri_world">ESRI World Imagery</option>
|
||||
<option value="custom">Custom URL</option>
|
||||
|
||||
@@ -458,7 +458,8 @@
|
||||
L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||||
maxZoom: 19,
|
||||
subdomains: 'abcd'
|
||||
subdomains: 'abcd',
|
||||
className: 'tile-layer-cyan'
|
||||
}).addTo(groundMap);
|
||||
}
|
||||
|
||||
|
||||
+4
-2
@@ -5,11 +5,13 @@ from app import app as flask_app
|
||||
from routes import register_blueprints
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@pytest.fixture(scope='session')
|
||||
def app():
|
||||
"""Create application for testing."""
|
||||
register_blueprints(flask_app)
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -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')
|
||||
@@ -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()
|
||||
+27
-19
@@ -1,7 +1,8 @@
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
import importlib.metadata
|
||||
import tomllib # Standard in Python 3.11+
|
||||
import tomllib
|
||||
import re
|
||||
|
||||
def get_root_path():
|
||||
return Path(__file__).parent.parent
|
||||
@@ -18,20 +19,22 @@ def parse_txt_requirements(file_path):
|
||||
with open(file_path, "r") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
# Ignore empty lines, comments, and recursive/local flags
|
||||
if not line or line.startswith(("#", "-e", "git+", "-r")):
|
||||
continue
|
||||
packages.add(_clean_string(line))
|
||||
return packages
|
||||
|
||||
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()
|
||||
project = data.get("project", {})
|
||||
|
||||
if section_type == "main":
|
||||
deps = data.get("project", {}).get("dependencies", [])
|
||||
else:
|
||||
# Check optional-dependencies or dependency-groups
|
||||
deps = data.get("project", {}).get("optional-dependencies", {}).get("dev", [])
|
||||
deps = project.get("dependencies", [])
|
||||
elif section_type == "optional":
|
||||
deps = project.get("optional-dependencies", {}).get("optionals", [])
|
||||
elif section_type == "dev":
|
||||
deps = project.get("optional-dependencies", {}).get("dev", [])
|
||||
if not deps:
|
||||
deps = data.get("dependency-groups", {}).get("dev", [])
|
||||
|
||||
@@ -48,9 +51,10 @@ def test_dependency_files_integrity():
|
||||
with open(toml_path, "rb") as f:
|
||||
toml_data = tomllib.load(f)
|
||||
|
||||
# Validate Production Sync
|
||||
# Validate Production Sync (Main + Optionals)
|
||||
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, (
|
||||
f"Production version mismatch!\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:
|
||||
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")
|
||||
|
||||
def test_environment_vs_requirements():
|
||||
@@ -89,21 +97,21 @@ def _verify_installation(package_set, source_name):
|
||||
missing_or_wrong = []
|
||||
|
||||
for req in package_set:
|
||||
# Split name from version to check installation status
|
||||
# handles ==, >=, ~=, <=, > , <
|
||||
import re
|
||||
# Split name from version
|
||||
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:
|
||||
installed_ver = importlib.metadata.version(name)
|
||||
# If the config uses exact versioning '==', we can do a strict check
|
||||
installed_ver = importlib.metadata.version(clean_name)
|
||||
if "==" in req:
|
||||
expected_ver = req.split("==")[1].strip()
|
||||
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:
|
||||
missing_or_wrong.append(f"{name} (Not installed)")
|
||||
missing_or_wrong.append(f"{clean_name} (Not installed)")
|
||||
|
||||
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))
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
+121
-15
@@ -1205,21 +1205,127 @@ def get_all_known_devices(
|
||||
]
|
||||
|
||||
|
||||
def delete_known_device(identifier: str) -> bool:
|
||||
"""Remove a device from the known-good registry."""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute(
|
||||
'DELETE FROM tscm_known_devices WHERE identifier = ?',
|
||||
(identifier.upper(),)
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
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."""
|
||||
with get_db() as conn:
|
||||
if location:
|
||||
cursor = conn.execute('''
|
||||
def delete_known_device(identifier: str) -> bool:
|
||||
"""Remove a device from the known-good registry."""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute(
|
||||
'DELETE FROM tscm_known_devices WHERE identifier = ?',
|
||||
(identifier.upper(),)
|
||||
)
|
||||
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:
|
||||
"""Check if a device is in the known-good registry for a location."""
|
||||
with get_db() as conn:
|
||||
if location:
|
||||
cursor = conn.execute('''
|
||||
SELECT * FROM tscm_known_devices
|
||||
WHERE identifier = ? AND (location = ? OR scope = 'global')
|
||||
''', (identifier.upper(), location))
|
||||
|
||||
@@ -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
@@ -181,6 +181,7 @@ class SSTVImage:
|
||||
timestamp: datetime
|
||||
frequency: float
|
||||
size_bytes: int = 0
|
||||
url_prefix: str = '/sstv'
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
@@ -190,7 +191,7 @@ class SSTVImage:
|
||||
'timestamp': self.timestamp.isoformat(),
|
||||
'frequency': self.frequency,
|
||||
'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)
|
||||
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._rtl_process = None
|
||||
self._running = False
|
||||
self._lock = threading.Lock()
|
||||
self._callback: Callable[[DecodeProgress], None] | None = None
|
||||
self._output_dir = Path(output_dir) if output_dir else Path('instance/sstv_images')
|
||||
self._url_prefix = url_prefix
|
||||
self._images: list[SSTVImage] = []
|
||||
self._reader_thread = None
|
||||
self._watcher_thread = None
|
||||
self._doppler_thread = None
|
||||
self._frequency = ISS_SSTV_FREQ
|
||||
self._modulation = 'fm'
|
||||
self._current_tuned_freq_hz: int = 0
|
||||
self._device_index = 0
|
||||
|
||||
@@ -246,6 +249,7 @@ class SSTVDecoder:
|
||||
self._doppler_tracker = DopplerTracker('ISS')
|
||||
self._doppler_enabled = False
|
||||
self._last_doppler_info: DopplerInfo | None = None
|
||||
self._file_decoder: str | None = None
|
||||
|
||||
# Ensure output directory exists
|
||||
self._output_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -268,6 +272,7 @@ class SSTVDecoder:
|
||||
try:
|
||||
result = subprocess.run(['which', 'slowrx'], capture_output=True, timeout=5)
|
||||
if result.returncode == 0:
|
||||
self._file_decoder = 'slowrx'
|
||||
return 'slowrx'
|
||||
except Exception:
|
||||
pass
|
||||
@@ -277,7 +282,8 @@ class SSTVDecoder:
|
||||
# Check for Python sstv package
|
||||
try:
|
||||
import sstv
|
||||
return 'python-sstv'
|
||||
self._file_decoder = 'python-sstv'
|
||||
return None
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
@@ -294,6 +300,7 @@ class SSTVDecoder:
|
||||
device_index: int = 0,
|
||||
latitude: float | None = None,
|
||||
longitude: float | None = None,
|
||||
modulation: str = 'fm',
|
||||
) -> bool:
|
||||
"""
|
||||
Start SSTV decoder listening on specified frequency.
|
||||
@@ -303,6 +310,7 @@ class SSTVDecoder:
|
||||
device_index: RTL-SDR device index
|
||||
latitude: Observer latitude for Doppler correction (optional)
|
||||
longitude: Observer longitude for Doppler correction (optional)
|
||||
modulation: Demodulation mode for rtl_fm (fm, usb, lsb). Default: fm
|
||||
|
||||
Returns:
|
||||
True if started successfully
|
||||
@@ -321,6 +329,7 @@ class SSTVDecoder:
|
||||
|
||||
self._frequency = frequency
|
||||
self._device_index = device_index
|
||||
self._modulation = modulation
|
||||
|
||||
# Configure Doppler tracking if location provided
|
||||
self._doppler_enabled = False
|
||||
@@ -396,12 +405,12 @@ class SSTVDecoder:
|
||||
|
||||
def _start_rtl_fm_pipeline(self, freq_hz: int) -> None:
|
||||
"""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_fm',
|
||||
'-d', str(self._device_index),
|
||||
'-f', str(freq_hz),
|
||||
'-M', 'fm',
|
||||
'-M', self._modulation,
|
||||
'-s', '48000',
|
||||
'-r', '48000',
|
||||
'-l', '0', # No squelch
|
||||
@@ -514,7 +523,7 @@ class SSTVDecoder:
|
||||
'rtl_fm',
|
||||
'-d', str(self._device_index),
|
||||
'-f', str(new_freq_hz),
|
||||
'-M', 'fm',
|
||||
'-M', self._modulation,
|
||||
'-s', '48000',
|
||||
'-r', '48000',
|
||||
'-l', '0',
|
||||
@@ -604,7 +613,8 @@ class SSTVDecoder:
|
||||
mode='Unknown', # Would need to parse from slowrx output
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
frequency=self._frequency,
|
||||
size_bytes=filepath.stat().st_size
|
||||
size_bytes=filepath.stat().st_size,
|
||||
url_prefix=self._url_prefix,
|
||||
)
|
||||
self._images.append(image)
|
||||
|
||||
@@ -662,8 +672,9 @@ class SSTVDecoder:
|
||||
path=filepath,
|
||||
mode='Unknown',
|
||||
timestamp=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
|
||||
frequency=ISS_SSTV_FREQ,
|
||||
size_bytes=stat.st_size
|
||||
frequency=self._frequency,
|
||||
size_bytes=stat.st_size,
|
||||
url_prefix=self._url_prefix,
|
||||
)
|
||||
self._images.append(image)
|
||||
except Exception as e:
|
||||
@@ -693,7 +704,9 @@ class SSTVDecoder:
|
||||
|
||||
images = []
|
||||
|
||||
if self._decoder == 'slowrx':
|
||||
decoder = self._decoder or self._file_decoder
|
||||
|
||||
if decoder == 'slowrx':
|
||||
# Use slowrx with file input
|
||||
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)
|
||||
|
||||
elif self._decoder == 'python-sstv':
|
||||
elif decoder == 'python-sstv':
|
||||
# Use Python sstv library
|
||||
try:
|
||||
from sstv.decode import SSTVDecoder as PythonSSTVDecoder
|
||||
@@ -762,3 +775,18 @@ def is_sstv_available() -> bool:
|
||||
"""Check if SSTV decoding is available."""
|
||||
decoder = get_sstv_decoder()
|
||||
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
|
||||
|
||||
@@ -372,8 +372,10 @@ def _detect_rf_capabilities(caps: SweepCapabilities, sdr_device: Any) -> None:
|
||||
if devices:
|
||||
device = devices[0] # Use first device
|
||||
rf_cap.available = True
|
||||
rf_cap.device_type = device.get('type', 'unknown')
|
||||
rf_cap.driver = device.get('driver', '')
|
||||
rf_cap.device_type = getattr(device, 'sdr_type', 'unknown')
|
||||
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
|
||||
if 'rtl' in rf_cap.device_type.lower():
|
||||
|
||||
+167
-82
@@ -154,9 +154,12 @@ class DeviceProfile:
|
||||
# Correlation
|
||||
correlated_devices: list[str] = field(default_factory=list)
|
||||
|
||||
# Output
|
||||
confidence: float = 0.0
|
||||
recommended_action: str = 'monitor'
|
||||
# Output
|
||||
confidence: float = 0.0
|
||||
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:
|
||||
"""Add an RSSI sample with timestamp."""
|
||||
@@ -190,9 +193,9 @@ class DeviceProfile:
|
||||
))
|
||||
self._recalculate_score()
|
||||
|
||||
def _recalculate_score(self) -> None:
|
||||
"""Recalculate total score and risk level."""
|
||||
self.total_score = sum(i.score for i in self.indicators)
|
||||
def _recalculate_score(self) -> None:
|
||||
"""Recalculate total score and risk level."""
|
||||
self.total_score = sum(i.score for i in self.indicators)
|
||||
|
||||
if self.total_score >= 6:
|
||||
self.risk_level = RiskLevel.HIGH_INTEREST
|
||||
@@ -204,9 +207,29 @@ class DeviceProfile:
|
||||
self.risk_level = RiskLevel.INFORMATIONAL
|
||||
self.recommended_action = 'monitor'
|
||||
|
||||
# Calculate confidence based on number and quality of indicators
|
||||
indicator_count = len(self.indicators)
|
||||
self.confidence = min(1.0, (indicator_count * 0.15) + (self.total_score * 0.05))
|
||||
# Calculate confidence based on number and quality of indicators
|
||||
indicator_count = len(self.indicators)
|
||||
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:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
@@ -231,12 +254,15 @@ class DeviceProfile:
|
||||
}
|
||||
for i in self.indicators
|
||||
],
|
||||
'total_score': self.total_score,
|
||||
'risk_level': self.risk_level.value,
|
||||
'confidence': round(self.confidence, 2),
|
||||
'recommended_action': self.recommended_action,
|
||||
'correlated_devices': self.correlated_devices,
|
||||
}
|
||||
'total_score': self.total_score,
|
||||
'score_modifier': self.score_modifier,
|
||||
'risk_level': self.risk_level.value,
|
||||
'confidence': round(self.confidence, 2),
|
||||
'recommended_action': self.recommended_action,
|
||||
'correlated_devices': self.correlated_devices,
|
||||
'known_device': self.known_device,
|
||||
'known_device_name': self.known_device_name,
|
||||
}
|
||||
|
||||
|
||||
# Known audio-capable BLE service UUIDs
|
||||
@@ -282,10 +308,11 @@ class CorrelationEngine:
|
||||
potential surveillance activity patterns.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.device_profiles: dict[str, DeviceProfile] = {}
|
||||
self.meeting_windows: list[tuple[datetime, datetime]] = []
|
||||
self.correlation_window = timedelta(minutes=5)
|
||||
def __init__(self):
|
||||
self.device_profiles: dict[str, DeviceProfile] = {}
|
||||
self.meeting_windows: list[tuple[datetime, datetime]] = []
|
||||
self.correlation_window = timedelta(minutes=5)
|
||||
self._known_device_cache: dict[str, dict | None] = {}
|
||||
|
||||
def start_meeting_window(self) -> None:
|
||||
"""Mark the start of a sensitive period (meeting)."""
|
||||
@@ -299,16 +326,64 @@ class CorrelationEngine:
|
||||
self.meeting_windows[-1] = (start, datetime.now())
|
||||
logger.info("Meeting window ended")
|
||||
|
||||
def is_during_meeting(self, timestamp: datetime = None) -> bool:
|
||||
"""Check if timestamp falls within a meeting window."""
|
||||
ts = timestamp or datetime.now()
|
||||
for start, end in self.meeting_windows:
|
||||
if end is None:
|
||||
if ts >= start:
|
||||
return True
|
||||
elif start <= ts <= end:
|
||||
return True
|
||||
return False
|
||||
def is_during_meeting(self, timestamp: datetime = None) -> bool:
|
||||
"""Check if timestamp falls within a meeting window."""
|
||||
ts = timestamp or datetime.now()
|
||||
for start, end in self.meeting_windows:
|
||||
if end is None:
|
||||
if ts >= start:
|
||||
return True
|
||||
elif start <= ts <= end:
|
||||
return True
|
||||
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:
|
||||
"""Get existing profile or create new one."""
|
||||
@@ -559,31 +634,33 @@ class CorrelationEngine:
|
||||
)
|
||||
|
||||
# Also check name for tracker keywords
|
||||
if profile.name:
|
||||
name_lower = profile.name.lower()
|
||||
if 'airtag' in name_lower or 'findmy' in name_lower:
|
||||
profile.add_indicator(
|
||||
IndicatorType.AIRTAG_DETECTED,
|
||||
f'AirTag identified by name: {profile.name}',
|
||||
{'name': profile.name}
|
||||
)
|
||||
profile.device_type = 'AirTag'
|
||||
elif 'tile' in name_lower:
|
||||
profile.add_indicator(
|
||||
IndicatorType.TILE_DETECTED,
|
||||
f'Tile tracker identified by name: {profile.name}',
|
||||
{'name': profile.name}
|
||||
)
|
||||
profile.device_type = 'Tile Tracker'
|
||||
elif 'smarttag' in name_lower:
|
||||
profile.add_indicator(
|
||||
IndicatorType.SMARTTAG_DETECTED,
|
||||
f'SmartTag identified by name: {profile.name}',
|
||||
{'name': profile.name}
|
||||
)
|
||||
profile.device_type = 'Samsung SmartTag'
|
||||
|
||||
return profile
|
||||
if profile.name:
|
||||
name_lower = profile.name.lower()
|
||||
if 'airtag' in name_lower or 'findmy' in name_lower:
|
||||
profile.add_indicator(
|
||||
IndicatorType.AIRTAG_DETECTED,
|
||||
f'AirTag identified by name: {profile.name}',
|
||||
{'name': profile.name}
|
||||
)
|
||||
profile.device_type = 'AirTag'
|
||||
elif 'tile' in name_lower:
|
||||
profile.add_indicator(
|
||||
IndicatorType.TILE_DETECTED,
|
||||
f'Tile tracker identified by name: {profile.name}',
|
||||
{'name': profile.name}
|
||||
)
|
||||
profile.device_type = 'Tile Tracker'
|
||||
elif 'smarttag' in name_lower:
|
||||
profile.add_indicator(
|
||||
IndicatorType.SMARTTAG_DETECTED,
|
||||
f'SmartTag identified by name: {profile.name}',
|
||||
{'name': profile.name}
|
||||
)
|
||||
profile.device_type = 'Samsung SmartTag'
|
||||
|
||||
self._apply_known_device_modifier(profile, mac, 'bluetooth')
|
||||
|
||||
return profile
|
||||
|
||||
def analyze_wifi_device(self, device: dict) -> DeviceProfile:
|
||||
"""
|
||||
@@ -686,16 +763,18 @@ class CorrelationEngine:
|
||||
)
|
||||
|
||||
# 8. Strong hidden AP (very suspicious)
|
||||
if profile.is_hidden and profile.rssi_samples:
|
||||
latest_rssi = profile.rssi_samples[-1][1]
|
||||
if latest_rssi > -50:
|
||||
profile.add_indicator(
|
||||
IndicatorType.ROGUE_AP,
|
||||
f'Strong hidden AP (RSSI: {latest_rssi} dBm)',
|
||||
{'rssi': latest_rssi}
|
||||
)
|
||||
|
||||
return profile
|
||||
if profile.is_hidden and profile.rssi_samples:
|
||||
latest_rssi = profile.rssi_samples[-1][1]
|
||||
if latest_rssi > -50:
|
||||
profile.add_indicator(
|
||||
IndicatorType.ROGUE_AP,
|
||||
f'Strong hidden AP (RSSI: {latest_rssi} dBm)',
|
||||
{'rssi': latest_rssi}
|
||||
)
|
||||
|
||||
self._apply_known_device_modifier(profile, bssid, 'wifi')
|
||||
|
||||
return profile
|
||||
|
||||
def analyze_rf_signal(self, signal: dict) -> DeviceProfile:
|
||||
"""
|
||||
@@ -778,14 +857,16 @@ class CorrelationEngine:
|
||||
)
|
||||
|
||||
# 5. Meeting correlation
|
||||
if self.is_during_meeting():
|
||||
profile.add_indicator(
|
||||
IndicatorType.MEETING_CORRELATED,
|
||||
'Signal detected during sensitive period',
|
||||
{'during_meeting': True}
|
||||
)
|
||||
|
||||
return profile
|
||||
if self.is_during_meeting():
|
||||
profile.add_indicator(
|
||||
IndicatorType.MEETING_CORRELATED,
|
||||
'Signal detected during sensitive period',
|
||||
{'during_meeting': True}
|
||||
)
|
||||
|
||||
self._apply_known_device_modifier(profile, freq_key, 'rf')
|
||||
|
||||
return profile
|
||||
|
||||
def correlate_devices(self) -> list[dict]:
|
||||
"""
|
||||
@@ -872,22 +953,26 @@ class CorrelationEngine:
|
||||
{'correlated_device': ap.identifier}
|
||||
)
|
||||
|
||||
# Correlation 3: Same vendor BLE + WiFi
|
||||
for bt in bt_devices:
|
||||
if bt.manufacturer:
|
||||
for wifi in wifi_devices:
|
||||
if wifi.manufacturer and bt.manufacturer.lower() in wifi.manufacturer.lower():
|
||||
correlation = {
|
||||
# Correlation 3: Same vendor BLE + WiFi
|
||||
for bt in bt_devices:
|
||||
if bt.manufacturer:
|
||||
for wifi in wifi_devices:
|
||||
if wifi.manufacturer and bt.manufacturer.lower() in wifi.manufacturer.lower():
|
||||
correlation = {
|
||||
'type': 'same_vendor_bt_wifi',
|
||||
'description': f'Same vendor ({bt.manufacturer}) on BLE and WiFi',
|
||||
'devices': [bt.identifier, wifi.identifier],
|
||||
'protocols': ['bluetooth', 'wifi'],
|
||||
'score_boost': 2,
|
||||
'significance': 'medium',
|
||||
}
|
||||
correlations.append(correlation)
|
||||
|
||||
return correlations
|
||||
}
|
||||
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
|
||||
|
||||
def get_high_interest_devices(self) -> list[DeviceProfile]:
|
||||
"""Get all devices classified as high interest."""
|
||||
|
||||
Reference in New Issue
Block a user