Compare commits

..

17 Commits

Author SHA1 Message Date
Smittix d0e8eaf397 fix: Add LISTEN button to listening post function strip and clarify controls
- Add dedicated LISTEN button (🎧) to function strip for direct audio playback
- Rename START to SCAN (📡) to clarify it scans for signals, not direct audio
- Update function strip status to show LISTENING (green) vs SCANNING (cyan)
- Update updateListeningStripRunning() to accept mode parameter
- Add listenFromStrip() function for direct audio from function strip
- Update stopListening() to handle both scanner and audio states
- Move .listening status dot to green color group (matches audio state)

This clarifies the confusion where users expected the START button to play audio,
but it actually started the frequency scanner which only plays audio when signals
are detected.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 21:20:17 +00:00
Smittix 4bf5bd2d37 fix: Initialize default mode and override nav.html fallback functions
- Call switchMode(currentMode) on page load to properly initialize the
  default pager mode and show the function strip
- Explicitly set window.switchMode and window.toggleNavDropdown after
  function definitions to override fallbacks defined in nav.html partial
- This fixes an issue where the nav.html fallback functions (which
  redirect to /?mode=X) would persist due to script execution order

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 20:41:13 +00:00
Smittix 206e63944e fix: Reduce Open Notify API failure log level to debug
The Open Notify API is unreliable and has a fallback. Changed from
warning to debug to match satellite.py and reduce log noise.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 15:50:05 +00:00
Smittix 978e6cdaea fix: Resolve TSCM function strip visibility and clipping issues
- Fix function strip content being clipped by changing overflow to visible
- Add min-height and increased padding to function strip
- Add explicit colors for TSCM strip stat values and labels
- Fix output-panel overflow for TSCM mode using :has() selector
- Add CSS variables --bg-dark and --bg-panel aliases
- Clean up sidebar section margins for consistent spacing
- Add unique IDs to WiFi/Bluetooth export sections for restore function

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 15:40:10 +00:00
Smittix 68d831dbe3 feat: Add collapsible sidebar with mode-specific section ordering
- Sidebar sections now rearrange based on active mode
- Mode info section (e.g., WiFi Scanning, Spectrum Analyzer) appears at top expanded
- Signal Source and SDR Device sections collapse and move below mode sections
- Other mode sections default to collapsed state
- Sections restore to original containers when switching modes
- Fix SCAN/LISTEN button styling consistency in Listening Post
- Reorder WiFi sections: WiFi Scanning, Signal Source, Monitor Mode, Scan Settings, Attack Options, Proximity Alerts, Export

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 13:25:54 +00:00
Smittix 623a0da056 fix: Add z-index to navbar to sit above radar grid overlay
Dashboard pages have a .radar-bg element with a grid pattern that
was showing through the navbar. Adding position: relative and z-index: 10
ensures the nav appears above the background effects.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 10:42:04 +00:00
Smittix d6f5127cd6 fix: Add direct link to layout.css in dashboard templates
CSS @import from within stylesheets may not load reliably.
Adding direct <link> tags ensures layout.css (with nav styles)
is loaded on all dashboard pages.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 10:31:23 +00:00
Smittix 166963f2a1 fix: Force navbar background color with !important
Using !important to ensure the navbar background color (#151a23)
cannot be overridden by any cascade or specificity issues.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 10:29:16 +00:00
Smittix eeca15c83e fix: Ensure consistent navbar background across all pages
Added explicit fallback color (#151a23) for .mode-nav background to
ensure consistency between main index and dashboard pages. CSS
variables may resolve differently based on cascade order.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 10:24:34 +00:00
Smittix ed1dfbb9b5 feat: Add UX polish - transitions, hover effects, accessibility
- Add smooth transitions for all interactive elements
- Button hover lift and active press feedback
- Card/panel hover effects with shadow
- Status dot glow animation
- Alert slide-in animation
- Enhanced input focus glow
- Better focus-visible states for keyboard navigation
- Reduced motion support (prefers-reduced-motion)
- High contrast mode support (prefers-contrast)
- Skip link for accessibility

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 10:07:02 +00:00
Smittix 29af551828 refactor: Import core components CSS in dashboards
- Add components.css import to ADSB, AIS, Satellite dashboards
- Provides base styles for panels, stats strips, status dots, etc.
- Dashboard-specific styles cascade over base styles

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 10:06:16 +00:00
Smittix e1e05523d2 refactor: Migrate index.html to use unified nav partial
- Replace 100+ lines of duplicated nav markup with partial include
- Add is_index_page variable to nav partial for conditional behavior
- Satellite/SSTV use switchMode on index, link to dashboard elsewhere
- Reduces code duplication, single source of truth for navigation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 10:04:48 +00:00
Smittix 94fcea0e99 fix: Hide welcome overlay when mode URL param is present
- Skip welcome screen when navigating with ?mode=X parameter
- Fixes nav from dashboards to panel modes (e.g., APRS, WiFi)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 09:52:49 +00:00
Smittix b0c323bb89 fix: Handle mode URL parameter on index page load
- Read ?mode=X parameter from URL when index.html loads
- Automatically switch to the specified mode (e.g., /?mode=aprs)
- Fixes navigation from dashboard pages to panel modes like APRS

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 09:50:10 +00:00
Smittix 425572ac87 fix: Make Satellite nav link go to dashboard like ADSB/AIS
- Changed Satellite from switchMode button to dashboard link
- Consistent behavior: all dashboard modes (ADSB, AIS, Satellite) now
  link to their respective dashboards in the nav partial
- Only affects dashboard pages; index.html keeps its own navigation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 09:45:46 +00:00
Smittix db931c3806 fix: Add navigation styles to dashboards
- Add mode navigation CSS to core/layout.css so it's available on all pages
- Import layout.css in dashboard stylesheets (ADSB, AIS, Satellite)
- Fixes nav partial appearing unstyled on dashboard pages

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 09:31:45 +00:00
Smittix a58e2f0d21 feat: UI refactor - unified navigation, design tokens, dashboard integration
- Add centralized design tokens in static/css/core/variables.css
- Create reusable base templates (base.html, base_dashboard.html)
- Add unified navigation partial (partials/nav.html) with all modules
- Create reusable UI components (card, loading, empty_state, status_badge, stats_strip)
- Integrate navigation into ADSB, AIS, and Satellite dashboards
- Consolidate CSS by importing shared variables in dashboard stylesheets
- Add comprehensive UI_GUIDE.md documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 09:27:39 +00:00
157 changed files with 30943 additions and 53136 deletions
-1
View File
@@ -35,7 +35,6 @@ htmlcov/
# Local Postgres data
pgdata/
pgdata.bak/
# Captured files (don't include in image)
*.cap
-2
View File
@@ -1,2 +0,0 @@
# Uncomment and set to use external storage for ADS-B history
# PGDATA_PATH=/mnt/external/intercept/pgdata
-5
View File
@@ -54,8 +54,3 @@ intercept_agent_*.cfg
# Temporary files
/tmp/
*.tmp
# Env files
.env
.env.*
!.env.example
-100
View File
@@ -2,106 +2,6 @@
All notable changes to iNTERCEPT will be documented in this file.
## [2.14.0] - 2026-02-06
### Added
- **DMR Digital Voice Decoder** - Decode DMR, P25, NXDN, and D-STAR protocols
- Integration with dsd-fme (Digital Speech Decoder - Florida Man Edition)
- Real-time SSE streaming of sync, call, voice, and slot events
- Call history table with talkgroup, source ID, and protocol tracking
- Protocol auto-detection or manual selection
- Pipeline error diagnostics with rtl_fm stderr capture
- **DMR Visual Synthesizer** - Canvas-based signal activity visualization
- Spring-physics animated bars reacting to SSE decoder events
- Color-coded by event type: cyan (sync), green (call), orange (voice)
- Center-outward ripple bursts on sync events
- Smooth decay and idle breathing animation
- Responsive canvas with window resize handling
- **HF SSTV General Mode** - Terrestrial slow-scan TV on shortwave frequencies
- Predefined HF SSTV frequencies (14.230, 21.340, 28.680 MHz, etc.)
- Modulation support for USB/LSB reception
- **WebSDR Integration** - Remote HF/shortwave listening via WebSDR servers
- **Listening Post Enhancements** - Improved signal scanner and audio handling
### Fixed
- APRS rtl_fm startup failure and SDR device conflicts
- DSD voice decoder detection for dsd-fme and PulseAudio errors
- dsd-fme protocol flags and ncurses disable for headless operation
- dsd-fme audio output flag for pipeline compatibility
- TSCM sweep scan resilience with per-device error isolation
- TSCM WiFi detection using scanner singleton for device availability
- TSCM correlation and cluster emission fixes
- Detected Threats panel items now clickable to show device details
- Proximity radar tooltip flicker on hover
- Radar blip flicker by deferring renders during hover
- ISS position API priority swap to avoid timeout delays
- Updater settings panel error when updater.js is blocked
- Missing scapy in optionals dependency group
---
## [2.13.1] - 2026-02-04
### Added
- **UI Overhaul** - Revamped styling with slate/cyan theme
- Switched app font to JetBrains Mono
- Global navigation bar across all dashboards
- Cyan-tinted map tiles as default
- **Signal Scanner Rewrite** - Switched to rtl_power sweep for better coverage
- SNR column added to signal hits table
- SNR threshold control for power scan
- Improved sweep progress tracking and stability
- Frequency-based sweep display with range syncing
- **Listening Post Audio** - WAV streaming with retry and fallback
- WebSocket audio fallback for listening
- User-initiated audio play prompt
- Audio pipeline restart for fresh stream headers
### Fixed
- WiFi connected clients panel now filters to selected AP instead of showing all clients
- USB device contention when starting audio pipeline
- Dual scrollbar issue on main dashboard
- Controls bar alignment in dashboard pages
- Mode query routing from dashboard nav
---
## [2.13.0] - 2026-02-04
### Added
- **WiFi Client Display** - Connected clients shown in AP detail drawer
- Real-time client updates via SSE streaming
- Probed SSID badges for connected clients
- Signal strength indicators and vendor identification
- **Help Modal** - Keyboard shortcuts reference system
- **Main Dashboard Button** - Quick navigation from any page
- **Settings Modal** - Accessible from all dashboards
### Changed
- Dashboard CSS improvements and consistency fixes
---
## [2.12.1] - 2026-02-02
### Added
- **SDR Device Registry** - Prevents decoder conflicts between concurrent modes
- **SDR Device Status Panel** - Shows connected SDR devices with ADS-B Bias-T toggle
- **Real-time Doppler Tracking** - ISS SSTV reception with Doppler correction
- **TCP Connection Support** - Meshtastic devices connectable over TCP
- **Shared Observer Location** - Configurable shared location with auto-start options
- **slowrx Source Build** - Fallback build for Debian/Ubuntu
### Fixed
- SDR device type not synced on page refresh
- Meshtastic connection type not restored on page refresh
- WiFi deep scan polling on agent with normalized scan_type value
- Auto-detect RTL-SDR drivers and blacklist instead of prompting
- TPMS pressure field mappings for 433MHz sensor display
- Agent capabilities cache invalidation after monitor mode toggle
---
## [2.12.0] - 2026-01-29
### Added
-31
View File
@@ -41,8 +41,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
soapysdr-module-rtlsdr \
soapysdr-module-hackrf \
soapysdr-module-lms7 \
soapysdr-module-airspy \
airspy \
limesuite \
hackrf \
# Utilities
@@ -65,10 +63,6 @@ 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 \
@@ -115,27 +109,6 @@ 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 \
@@ -151,10 +124,6 @@ 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/*
-46
View File
@@ -31,16 +31,11 @@ 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
@@ -79,47 +74,6 @@ The ADS-B history feature persists aircraft messages to Postgres for long-term a
docker compose --profile history up -d
```
Set the following environment variables (for example in a `.env` file):
```bash
INTERCEPT_ADSB_HISTORY_ENABLED=true
INTERCEPT_ADSB_DB_HOST=adsb_db
INTERCEPT_ADSB_DB_PORT=5432
INTERCEPT_ADSB_DB_NAME=intercept_adsb
INTERCEPT_ADSB_DB_USER=intercept
INTERCEPT_ADSB_DB_PASSWORD=intercept
```
### Other ADS-B Settings
Set these as environment variables for either local installs or Docker:
| Variable | Default | Description |
|----------|---------|-------------|
| `INTERCEPT_ADSB_AUTO_START` | `false` | Auto-start ADS-B tracking when the dashboard loads |
| `INTERCEPT_SHARED_OBSERVER_LOCATION` | `true` | Share observer location across ADS-B/AIS/SSTV/Satellite modules |
**Local install example**
```bash
INTERCEPT_ADSB_AUTO_START=true \
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
python app.py
```
**Docker example (.env)**
```bash
INTERCEPT_ADSB_AUTO_START=true
INTERCEPT_SHARED_OBSERVER_LOCATION=false
```
To store Postgres data on external storage, set `PGDATA_PATH` (defaults to `./pgdata`):
```bash
PGDATA_PATH=/mnt/usbpi1/intercept/pgdata
```
Then open **/adsb/history** for the reporting dashboard.
### Open the Interface
+1 -1
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,4 +1,4 @@
{
"version": "2026-02-01_ba81b697",
"downloaded": "2026-02-04T17:06:54.806043Z"
"version": "2026-01-11_fae1348c",
"downloaded": "2026-01-12T15:55:42.769654Z"
}
+6 -145
View File
@@ -27,7 +27,7 @@ from typing import Any
from flask import Flask, render_template, jsonify, send_file, Response, request,redirect, url_for, flash, session
from werkzeug.security import check_password_hash
from config import VERSION, CHANGELOG, SHARED_OBSERVER_LOCATION_ENABLED
from config import VERSION, CHANGELOG
from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
from utils.process import cleanup_stale_processes
from utils.sdr import SDRFactory
@@ -38,7 +38,6 @@ from utils.constants import (
MAX_BT_DEVICE_AGE_SECONDS,
MAX_VESSEL_AGE_SECONDS,
MAX_DSC_MESSAGE_AGE_SECONDS,
MAX_DEAUTH_ALERTS_AGE_SECONDS,
QUEUE_MAX_SIZE,
)
import logging
@@ -105,7 +104,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', 'cartodb_dark_cyan'),
'tile_provider': get_setting('offline.tile_provider', 'openstreetmap'),
'tile_server_url': get_setting('offline.tile_server_url', '')
}
}
@@ -172,21 +171,10 @@ 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()
# Deauth Attack Detection
deauth_detector = None
deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
deauth_detector_lock = threading.Lock()
# ============================================
# GLOBAL STATE DICTIONARIES
# ============================================
@@ -216,9 +204,6 @@ ais_vessels = DataStore(max_age_seconds=MAX_VESSEL_AGE_SECONDS, name='ais_vessel
# DSC (Digital Selective Calling) state - using DataStore for automatic cleanup
dsc_messages = DataStore(max_age_seconds=MAX_DSC_MESSAGE_AGE_SECONDS, name='dsc_messages')
# Deauth alerts - using DataStore for automatic cleanup
deauth_alerts = DataStore(max_age_seconds=MAX_DEAUTH_ALERTS_AGE_SECONDS, name='deauth_alerts')
# Satellite state
satellite_passes = [] # Predicted satellite passes (not auto-cleaned, calculated)
@@ -230,53 +215,6 @@ cleanup_manager.register(bt_beacons)
cleanup_manager.register(adsb_aircraft)
cleanup_manager.register(ais_vessels)
cleanup_manager.register(dsc_messages)
cleanup_manager.register(deauth_alerts)
# ============================================
# SDR DEVICE REGISTRY
# ============================================
# Tracks which mode is using which SDR device to prevent conflicts
# Key: device_index (int), Value: mode_name (str)
sdr_device_registry: dict[int, str] = {}
sdr_device_registry_lock = threading.Lock()
def claim_sdr_device(device_index: int, mode_name: str) -> str | None:
"""Claim an SDR device for a mode.
Args:
device_index: The SDR device index to claim
mode_name: Name of the mode claiming the device (e.g., 'sensor', 'rtlamr')
Returns:
Error message if device is in use, None if successfully claimed
"""
with sdr_device_registry_lock:
if device_index in sdr_device_registry:
in_use_by = sdr_device_registry[device_index]
return f'SDR device {device_index} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.'
sdr_device_registry[device_index] = mode_name
return None
def release_sdr_device(device_index: int) -> None:
"""Release an SDR device from the registry.
Args:
device_index: The SDR device index to release
"""
with sdr_device_registry_lock:
sdr_device_registry.pop(device_index, None)
def get_sdr_device_status() -> dict[int, str]:
"""Get current SDR device allocations.
Returns:
Dictionary mapping device indices to mode names
"""
with sdr_device_registry_lock:
return dict(sdr_device_registry)
# ============================================
@@ -288,10 +226,6 @@ def require_login():
# Routes that don't require login (to avoid infinite redirect loop)
allowed_routes = ['login', 'static', 'favicon', 'health', 'health_check']
# Allow audio streaming endpoints without session auth
if request.path.startswith('/listening/audio/'):
return None
# Controller API endpoints use API key auth, not session auth
# Allow agent push/pull endpoints without session login
if request.path.startswith('/controller/'):
@@ -345,14 +279,7 @@ def index() -> str:
'rtlamr': check_tool('rtlamr')
}
devices = [d.to_dict() for d in SDRFactory.detect_devices()]
return render_template(
'index.html',
tools=tools,
devices=devices,
version=VERSION,
changelog=CHANGELOG,
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
)
return render_template('index.html', tools=tools, devices=devices, version=VERSION, changelog=CHANGELOG)
@app.route('/favicon.svg')
@@ -367,22 +294,6 @@ def get_devices() -> Response:
return jsonify([d.to_dict() for d in devices])
@app.route('/devices/status')
def get_devices_status() -> Response:
"""Get all SDR devices with usage status."""
devices = SDRFactory.detect_devices()
registry = get_sdr_device_status()
result = []
for device in devices:
d = device.to_dict()
d['in_use'] = device.index in registry
d['used_by'] = registry.get(device.index)
result.append(d)
return jsonify(result)
@app.route('/devices/debug')
def get_devices_debug() -> Response:
"""Get detailed SDR device detection diagnostics."""
@@ -641,7 +552,6 @@ 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),
@@ -656,23 +566,19 @@ def health_check() -> Response:
@app.route('/killall', methods=['POST'])
def kill_all() -> Response:
"""Kill all decoder, WiFi, and Bluetooth processes."""
"""Kill all decoder and WiFi 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
global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process
# Import adsb and ais modules to reset their state
from routes import adsb as adsb_module
from routes import ais as ais_module
from utils.bluetooth import reset_bluetooth_scanner
killed = []
processes_to_kill = [
'rtl_fm', 'multimon-ng', 'rtl_433',
'airodump-ng', 'aireplay-ng', 'airmon-ng',
'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher',
'hcitool', 'bluetoothctl', 'dsd',
'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg',
'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher'
]
for proc in processes_to_kill:
@@ -716,35 +622,6 @@ def kill_all() -> Response:
dsc_process = None
dsc_rtl_process = None
# Reset DMR state
with dmr_lock:
dmr_process = None
dmr_rtl_process = None
# Reset Bluetooth state (legacy)
with bt_lock:
if bt_process:
try:
bt_process.terminate()
bt_process.wait(timeout=2)
except Exception:
try:
bt_process.kill()
except Exception:
pass
bt_process = None
# Reset Bluetooth v2 scanner
try:
reset_bluetooth_scanner()
killed.append('bluetooth')
except Exception:
pass
# Clear SDR device registry
with sdr_device_registry_lock:
sdr_device_registry.clear()
return jsonify({'status': 'killed', 'processes': killed})
@@ -861,22 +738,6 @@ def main() -> None:
except ImportError as e:
print(f"WebSocket audio disabled (install flask-sock): {e}")
# Initialize KiwiSDR WebSocket audio proxy
try:
from routes.websdr import init_websdr_audio
init_websdr_audio(app)
print("KiwiSDR audio proxy enabled")
except ImportError as e:
print(f"KiwiSDR audio proxy disabled: {e}")
# Initialize WebSocket for waterfall streaming
try:
from routes.waterfall_websocket import init_waterfall_websocket
init_waterfall_websocket(app)
print("WebSocket waterfall streaming enabled")
except ImportError as e:
print(f"WebSocket waterfall disabled: {e}")
print(f"Open http://localhost:{args.port} in your browser")
print()
print("Press Ctrl+C to stop")
+9 -64
View File
@@ -7,56 +7,10 @@ import os
import sys
# Application version
VERSION = "2.14.0"
VERSION = "2.12.0"
# Changelog - latest release notes (shown on welcome screen)
CHANGELOG = [
{
"version": "2.14.0",
"date": "February 2026",
"highlights": [
"DMR/P25/NXDN/D-STAR digital voice decoder with dsd-fme",
"DMR visual synthesizer with event-driven spring-physics bars",
"HF SSTV general mode with predefined shortwave frequencies",
"WebSDR integration for remote HF/shortwave listening",
"Listening Post signal scanner and audio pipeline improvements",
"TSCM sweep resilience, WiFi detection, and correlation fixes",
"APRS rtl_fm startup and SDR device conflict fixes",
]
},
{
"version": "2.13.1",
"date": "February 2026",
"highlights": [
"UI overhaul with slate/cyan theme and JetBrains Mono font",
"Signal scanner rewritten with rtl_power sweep and SNR filtering",
"Listening Post audio streaming via WAV with retry/fallback",
"WiFi connected clients panel now filters to selected AP",
"Global navigation bar across all dashboards",
"Fixed USB device contention when starting audio pipeline",
]
},
{
"version": "2.13.0",
"date": "February 2026",
"highlights": [
"WiFi client display in AP detail drawer with real-time SSE updates",
"Help modal system with keyboard shortcuts reference",
"Global navbar and settings modal accessible from all dashboards",
"Probed SSID badges for connected clients",
]
},
{
"version": "2.12.1",
"date": "February 2026",
"highlights": [
"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",
]
},
{
"version": "2.12.0",
"date": "January 2026",
@@ -185,7 +139,6 @@ BT_UPDATE_INTERVAL = _get_env_float('BT_UPDATE_INTERVAL', 2.0)
# ADS-B settings
ADSB_SBS_PORT = _get_env_int('ADSB_SBS_PORT', 30003)
ADSB_UPDATE_INTERVAL = _get_env_float('ADSB_UPDATE_INTERVAL', 1.0)
ADSB_AUTO_START = _get_env_bool('ADSB_AUTO_START', False)
ADSB_HISTORY_ENABLED = _get_env_bool('ADSB_HISTORY_ENABLED', False)
ADSB_DB_HOST = _get_env('ADSB_DB_HOST', 'localhost')
ADSB_DB_PORT = _get_env_int('ADSB_DB_PORT', 5432)
@@ -196,27 +149,19 @@ ADSB_HISTORY_BATCH_SIZE = _get_env_int('ADSB_HISTORY_BATCH_SIZE', 500)
ADSB_HISTORY_FLUSH_INTERVAL = _get_env_float('ADSB_HISTORY_FLUSH_INTERVAL', 1.0)
ADSB_HISTORY_QUEUE_SIZE = _get_env_int('ADSB_HISTORY_QUEUE_SIZE', 50000)
# Observer location settings
SHARED_OBSERVER_LOCATION_ENABLED = _get_env_bool('SHARED_OBSERVER_LOCATION', True)
# Satellite settings
SATELLITE_UPDATE_INTERVAL = _get_env_int('SATELLITE_UPDATE_INTERVAL', 30)
SATELLITE_TRAJECTORY_POINTS = _get_env_int('SATELLITE_TRAJECTORY_POINTS', 30)
SATELLITE_ORBIT_MINUTES = _get_env_int('SATELLITE_ORBIT_MINUTES', 45)
# Update checking
GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept')
UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True)
UPDATE_CHECK_INTERVAL_HOURS = _get_env_int('UPDATE_CHECK_INTERVAL_HOURS', 6)
# Alerting
ALERT_WEBHOOK_URL = _get_env('ALERT_WEBHOOK_URL', '')
ALERT_WEBHOOK_SECRET = _get_env('ALERT_WEBHOOK_SECRET', '')
ALERT_WEBHOOK_TIMEOUT = _get_env_int('ALERT_WEBHOOK_TIMEOUT', 5)
# Admin credentials
ADMIN_USERNAME = _get_env('ADMIN_USERNAME', 'admin')
ADMIN_PASSWORD = _get_env('ADMIN_PASSWORD', 'admin')
# Update checking
GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept')
UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True)
UPDATE_CHECK_INTERVAL_HOURS = _get_env_int('UPDATE_CHECK_INTERVAL_HOURS', 6)
# Admin credentials
ADMIN_USERNAME = _get_env('ADMIN_USERNAME', 'admin')
ADMIN_PASSWORD = _get_env('ADMIN_PASSWORD', 'admin')
def configure_logging() -> None:
"""Configure application logging."""
+1 -10
View File
@@ -36,10 +36,6 @@ services:
# - INTERCEPT_ADSB_DB_NAME=intercept_adsb
# - INTERCEPT_ADSB_DB_USER=intercept
# - INTERCEPT_ADSB_DB_PASSWORD=intercept
# ADS-B auto-start on dashboard load (default false)
- INTERCEPT_ADSB_AUTO_START=${INTERCEPT_ADSB_AUTO_START:-false}
# Shared observer location across modules
- INTERCEPT_SHARED_OBSERVER_LOCATION=${INTERCEPT_SHARED_OBSERVER_LOCATION:-true}
# Network mode for WiFi scanning (requires host network)
# network_mode: host
restart: unless-stopped
@@ -72,10 +68,6 @@ services:
- INTERCEPT_ADSB_DB_NAME=intercept_adsb
- INTERCEPT_ADSB_DB_USER=intercept
- INTERCEPT_ADSB_DB_PASSWORD=intercept
# ADS-B auto-start on dashboard load (default false)
- INTERCEPT_ADSB_AUTO_START=${INTERCEPT_ADSB_AUTO_START:-false}
# Shared observer location across modules
- INTERCEPT_SHARED_OBSERVER_LOCATION=${INTERCEPT_SHARED_OBSERVER_LOCATION:-true}
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:5050/health"]
@@ -94,8 +86,7 @@ services:
- POSTGRES_USER=intercept
- POSTGRES_PASSWORD=intercept
volumes:
# Default local path (override with PGDATA_PATH for external storage)
- ${PGDATA_PATH:-./pgdata}:/var/lib/postgresql/data
- ./pgdata:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U intercept -d intercept_adsb"]
-1
View File
@@ -1 +0,0 @@
www.intercept-sigint.com
+43 -76
View File
@@ -61,88 +61,55 @@ INTERCEPT automatically detects known trackers:
1. **Select Hardware** - Choose your SDR type (RTL-SDR uses dump1090, others use readsb)
2. **Check Tools** - Ensure dump1090 or readsb is installed
3. **Set Location** - Choose location source:
- **Manual Entry** - Type coordinates directly
- **Browser GPS** - Use browser's built-in geolocation (requires HTTPS)
- **USB GPS Dongle** - Connect a USB GPS receiver for continuous updates
- **Shared Location** - By default, the observer location is shared across modules
(disable with `INTERCEPT_SHARED_OBSERVER_LOCATION=false`)
4. **Start Tracking** - Click "Start Tracking" to begin ADS-B reception
5. **View Map** - Aircraft appear on the interactive Leaflet map
3. **Set Location** - Choose location source:
- **Manual Entry** - Type coordinates directly
- **Browser GPS** - Use browser's built-in geolocation (requires HTTPS)
- **USB GPS Dongle** - Connect a USB GPS receiver for continuous updates
4. **Start Tracking** - Click "Start Tracking" to begin ADS-B reception
5. **View Map** - Aircraft appear on the interactive Leaflet map
6. **Click Aircraft** - Click markers for detailed information
7. **Display Options** - Toggle callsigns, altitude, trails, range rings, clustering
8. **Filter Aircraft** - Use dropdown to show all, military, civil, or emergency only
9. **Full Dashboard** - Click "Full Screen Dashboard" for dedicated radar view
> Note: ADS-B auto-start is disabled by default. To enable auto-start on dashboard load,
> set `INTERCEPT_ADSB_AUTO_START=true`.
9. **Full Dashboard** - Click "Full Screen Dashboard" for dedicated radar view
### Emergency Squawks
The system highlights aircraft transmitting emergency squawks:
- **7500** - Hijack
- **7600** - Radio failure
- **7700** - General emergency
## ADS-B History (Optional)
The history dashboard persists aircraft messages and per-aircraft snapshots to Postgres for long-running tracking and reporting.
### Enable History
Set the following environment variables (Docker recommended):
| Variable | Default | Description |
|----------|---------|-------------|
| `INTERCEPT_ADSB_HISTORY_ENABLED` | `false` | Enables history storage and reporting |
| `INTERCEPT_ADSB_DB_HOST` | `localhost` | Postgres host (use `adsb_db` in Docker) |
| `INTERCEPT_ADSB_DB_PORT` | `5432` | Postgres port |
| `INTERCEPT_ADSB_DB_NAME` | `intercept_adsb` | Database name |
| `INTERCEPT_ADSB_DB_USER` | `intercept` | Database user |
| `INTERCEPT_ADSB_DB_PASSWORD` | `intercept` | Database password |
### Other ADS-B Settings
| 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
```
### Docker Setup
`docker-compose.yml` includes an `adsb_db` service and a persistent volume for history storage:
```bash
docker compose --profile history up -d
```
To store Postgres data on external storage, set `PGDATA_PATH` (defaults to `./pgdata`):
```bash
PGDATA_PATH=/mnt/usbpi1/intercept/pgdata
```
### Using the History Dashboard
1. Open **/adsb/history**
2. Use **Start Tracking** to run ADS-B in headless mode
3. View aircraft history and timelines
4. Stop tracking when desired (session history is recorded)
The system highlights aircraft transmitting emergency squawks:
- **7500** - Hijack
- **7600** - Radio failure
- **7700** - General emergency
## ADS-B History (Optional)
The history dashboard persists aircraft messages and per-aircraft snapshots to Postgres for long-running tracking and reporting.
### Enable History
Set the following environment variables (Docker recommended):
| Variable | Default | Description |
|----------|---------|-------------|
| `INTERCEPT_ADSB_HISTORY_ENABLED` | `false` | Enables history storage and reporting |
| `INTERCEPT_ADSB_DB_HOST` | `localhost` | Postgres host (use `adsb_db` in Docker) |
| `INTERCEPT_ADSB_DB_PORT` | `5432` | Postgres port |
| `INTERCEPT_ADSB_DB_NAME` | `intercept_adsb` | Database name |
| `INTERCEPT_ADSB_DB_USER` | `intercept` | Database user |
| `INTERCEPT_ADSB_DB_PASSWORD` | `intercept` | Database password |
### Docker Setup
`docker-compose.yml` includes an `adsb_db` service and a persistent volume for history storage:
```bash
docker compose up -d
```
### Using the History Dashboard
1. Open **/adsb/history**
2. Use **Start Tracking** to run ADS-B in headless mode
3. View aircraft history and timelines
4. Stop tracking when desired (session history is recorded)
## Satellite Mode
+134 -477
View File
@@ -838,15 +838,14 @@ class ModeManager:
data['data'] = list(getattr(self, 'ais_vessels', {}).values())
elif mode == 'aprs':
data['data'] = list(getattr(self, 'aprs_stations', {}).values())
elif mode == 'tscm':
data['data'] = {
'anomalies': getattr(self, 'tscm_anomalies', []),
'baseline': getattr(self, 'tscm_baseline', {}),
'wifi_devices': list(self.wifi_networks.values()),
'wifi_clients': list(getattr(self, 'tscm_wifi_clients', {}).values()),
'bt_devices': list(self.bluetooth_devices.values()),
'rf_signals': getattr(self, 'tscm_rf_signals', []),
}
elif mode == 'tscm':
data['data'] = {
'anomalies': getattr(self, 'tscm_anomalies', []),
'baseline': getattr(self, 'tscm_baseline', {}),
'wifi_devices': list(self.wifi_networks.values()),
'bt_devices': list(self.bluetooth_devices.values()),
'rf_signals': getattr(self, 'tscm_rf_signals', []),
}
elif mode == 'listening_post':
data['data'] = {
'activity': getattr(self, 'listening_post_activity', []),
@@ -873,150 +872,6 @@ class ModeManager:
return data
# =========================================================================
# WiFi Monitor Mode
# =========================================================================
def toggle_monitor_mode(self, params: dict) -> dict:
"""Enable or disable monitor mode on a WiFi interface."""
import re
action = params.get('action', 'start')
interface = params.get('interface', '')
kill_processes = params.get('kill_processes', False)
# Validate interface name (alphanumeric, underscore, dash only)
if not interface or not re.match(r'^[a-zA-Z][a-zA-Z0-9_-]*$', interface):
return {'status': 'error', 'message': 'Invalid interface name'}
airmon_path = self._get_tool_path('airmon-ng')
iw_path = self._get_tool_path('iw')
if action == 'start':
if airmon_path:
try:
# Get interfaces before
def get_wireless_interfaces():
interfaces = set()
try:
for iface in os.listdir('/sys/class/net'):
if os.path.exists(f'/sys/class/net/{iface}/wireless') or 'mon' in iface:
interfaces.add(iface)
except OSError:
pass
return interfaces
interfaces_before = get_wireless_interfaces()
# Kill interfering processes if requested
if kill_processes:
subprocess.run([airmon_path, 'check', 'kill'],
capture_output=True, timeout=10)
# Start monitor mode
result = subprocess.run([airmon_path, 'start', interface],
capture_output=True, text=True, timeout=15)
output = result.stdout + result.stderr
time.sleep(1)
interfaces_after = get_wireless_interfaces()
# Find the new monitor interface
new_interfaces = interfaces_after - interfaces_before
monitor_iface = None
if new_interfaces:
for iface in new_interfaces:
if 'mon' in iface:
monitor_iface = iface
break
if not monitor_iface:
monitor_iface = list(new_interfaces)[0]
# Try to parse from airmon-ng output
if not monitor_iface:
patterns = [
r'\b([a-zA-Z][a-zA-Z0-9_-]*mon)\b',
r'\[phy\d+\]([a-zA-Z][a-zA-Z0-9_-]*mon)',
r'enabled.*?\[phy\d+\]([a-zA-Z][a-zA-Z0-9_-]*)',
]
for pattern in patterns:
match = re.search(pattern, output, re.IGNORECASE)
if match:
candidate = match.group(1)
if candidate and not candidate[0].isdigit():
monitor_iface = candidate
break
# Fallback: check if original interface is in monitor mode
if not monitor_iface:
try:
result = subprocess.run(['iwconfig', interface],
capture_output=True, text=True, timeout=5)
if 'Mode:Monitor' in result.stdout:
monitor_iface = interface
except (subprocess.SubprocessError, OSError):
pass
# Last resort: try common naming
if not monitor_iface:
potential = interface + 'mon'
if os.path.exists(f'/sys/class/net/{potential}'):
monitor_iface = potential
if not monitor_iface or not os.path.exists(f'/sys/class/net/{monitor_iface}'):
all_wireless = list(get_wireless_interfaces())
return {
'status': 'error',
'message': f'Monitor interface not created. airmon-ng output: {output[:500]}. Available interfaces: {all_wireless}'
}
self.wifi_monitor_interface = monitor_iface
self._capabilities = None # Invalidate cache so interfaces refresh
logger.info(f"Monitor mode enabled on {monitor_iface}")
return {'status': 'success', 'monitor_interface': monitor_iface}
except Exception as e:
logger.error(f"Error enabling monitor mode: {e}")
return {'status': 'error', 'message': str(e)}
elif iw_path:
try:
subprocess.run(['ip', 'link', 'set', interface, 'down'], capture_output=True)
subprocess.run([iw_path, interface, 'set', 'monitor', 'control'], capture_output=True)
subprocess.run(['ip', 'link', 'set', interface, 'up'], capture_output=True)
self.wifi_monitor_interface = interface
self._capabilities = None # Invalidate cache
return {'status': 'success', 'monitor_interface': interface}
except Exception as e:
return {'status': 'error', 'message': str(e)}
else:
return {'status': 'error', 'message': 'No monitor mode tools available (airmon-ng or iw)'}
else: # stop
current_iface = getattr(self, 'wifi_monitor_interface', None) or interface
if airmon_path:
try:
subprocess.run([airmon_path, 'stop', current_iface],
capture_output=True, text=True, timeout=15)
self.wifi_monitor_interface = None
self._capabilities = None # Invalidate cache
return {'status': 'success', 'message': 'Monitor mode disabled'}
except Exception as e:
return {'status': 'error', 'message': str(e)}
elif iw_path:
try:
subprocess.run(['ip', 'link', 'set', current_iface, 'down'], capture_output=True)
subprocess.run([iw_path, current_iface, 'set', 'type', 'managed'], capture_output=True)
subprocess.run(['ip', 'link', 'set', current_iface, 'up'], capture_output=True)
self.wifi_monitor_interface = None
self._capabilities = None # Invalidate cache
return {'status': 'success', 'message': 'Monitor mode disabled'}
except Exception as e:
return {'status': 'error', 'message': str(e)}
return {'status': 'error', 'message': 'Unknown action'}
# =========================================================================
# Mode-specific implementations
# =========================================================================
@@ -1059,34 +914,26 @@ class ModeManager:
"""Internal mode stop - terminates processes and cleans up."""
logger.info(f"Stopping mode {mode}")
# Signal stop first - this unblocks any waiting threads
# Signal stop
if mode in self.stop_events:
self.stop_events[mode].set()
# Terminate process if running
if mode in self.processes:
proc = self.processes[mode]
try:
if proc and proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=2)
except subprocess.TimeoutExpired:
proc.kill()
try:
proc.wait(timeout=1)
except Exception:
pass
except (OSError, ProcessLookupError) as e:
# Process already dead or inaccessible
logger.debug(f"Process cleanup for {mode}: {e}")
if proc and proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=3)
except subprocess.TimeoutExpired:
proc.kill()
del self.processes[mode]
# Wait for output thread (short timeout since stop event is set)
# Wait for output thread
if mode in self.output_threads:
thread = self.output_threads[mode]
if thread and thread.is_alive():
thread.join(timeout=1)
thread.join(timeout=2)
del self.output_threads[mode]
# Clean up
@@ -1105,24 +952,23 @@ class ModeManager:
self.wifi_clients.clear()
elif mode == 'bluetooth':
self.bluetooth_devices.clear()
elif mode == 'tscm':
# Clean up TSCM sub-threads
for sub_thread_name in ['tscm_wifi', 'tscm_bt', 'tscm_rf']:
if sub_thread_name in self.output_threads:
thread = self.output_threads[sub_thread_name]
if thread and thread.is_alive():
thread.join(timeout=2)
del self.output_threads[sub_thread_name]
# Clear TSCM data
self.tscm_anomalies = []
self.tscm_baseline = {}
self.tscm_rf_signals = []
self.tscm_wifi_clients = {}
# Clear reported threat tracking sets
if hasattr(self, '_tscm_reported_wifi'):
self._tscm_reported_wifi.clear()
if hasattr(self, '_tscm_reported_bt'):
self._tscm_reported_bt.clear()
elif mode == 'tscm':
# Clean up TSCM sub-threads
for sub_thread_name in ['tscm_wifi', 'tscm_bt', 'tscm_rf']:
if sub_thread_name in self.output_threads:
thread = self.output_threads[sub_thread_name]
if thread and thread.is_alive():
thread.join(timeout=2)
del self.output_threads[sub_thread_name]
# Clear TSCM data
self.tscm_anomalies = []
self.tscm_baseline = {}
self.tscm_rf_signals = []
# Clear reported threat tracking sets
if hasattr(self, '_tscm_reported_wifi'):
self._tscm_reported_wifi.clear()
if hasattr(self, '_tscm_reported_bt'):
self._tscm_reported_bt.clear()
elif mode == 'dsc':
# Clear DSC data
if hasattr(self, 'dsc_messages'):
@@ -1291,16 +1137,10 @@ class ModeManager:
except json.JSONDecodeError:
pass # Not JSON, ignore
except (OSError, ValueError) as e:
# Bad file descriptor or closed file - process was terminated
logger.debug(f"Sensor output reader stopped: {e}")
except Exception as e:
logger.error(f"Sensor output reader error: {e}")
finally:
try:
proc.wait(timeout=1)
except Exception:
pass
proc.wait()
logger.info("Sensor output reader stopped")
# -------------------------------------------------------------------------
@@ -1542,10 +1382,9 @@ class ModeManager:
def _start_wifi(self, params: dict) -> dict:
"""Start WiFi scanning using Intercept's UnifiedWiFiScanner."""
interface = params.get('interface')
channel = params.get('channel')
channels = params.get('channels')
band = params.get('band', 'abg')
scan_type = params.get('scan_type', 'deep')
channel = params.get('channel')
band = params.get('band', 'abg')
scan_type = params.get('scan_type', 'deep')
# Handle quick scan - returns results synchronously
if scan_type == 'quick':
@@ -1574,21 +1413,8 @@ class ModeManager:
else:
scan_band = 'all'
channel_list = None
if channels:
if isinstance(channels, str):
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
elif isinstance(channels, (list, tuple, set)):
channel_list = list(channels)
else:
channel_list = [channels]
try:
channel_list = [int(c) for c in channel_list]
except (TypeError, ValueError):
return {'status': 'error', 'message': 'Invalid channels'}
# Start deep scan
if scanner.start_deep_scan(interface=interface, band=scan_band, channel=channel, channels=channel_list):
# Start deep scan
if scanner.start_deep_scan(interface=interface, band=scan_band, channel=channel):
# Start thread to sync data to agent's dictionaries
thread = threading.Thread(
target=self._wifi_data_sync,
@@ -1607,12 +1433,12 @@ class ModeManager:
else:
return {'status': 'error', 'message': scanner.get_status().error or 'Failed to start deep scan'}
except ImportError:
# Fallback to direct airodump-ng
return self._start_wifi_fallback(interface, channel, band, channels)
except Exception as e:
logger.error(f"WiFi scanner error: {e}")
return {'status': 'error', 'message': str(e)}
except ImportError:
# Fallback to direct airodump-ng
return self._start_wifi_fallback(interface, channel, band)
except Exception as e:
logger.error(f"WiFi scanner error: {e}")
return {'status': 'error', 'message': str(e)}
def _wifi_data_sync(self, scanner):
"""Sync WiFi scanner data to agent's data structures."""
@@ -1646,14 +1472,8 @@ class ModeManager:
if hasattr(self, '_wifi_scanner_instance') and self._wifi_scanner_instance:
self._wifi_scanner_instance.stop_deep_scan()
def _start_wifi_fallback(
self,
interface: str | None,
channel: int | None,
band: str,
channels: list[int] | str | None = None,
) -> dict:
"""Fallback WiFi deep scan using airodump-ng directly."""
def _start_wifi_fallback(self, interface: str | None, channel: int | None, band: str) -> dict:
"""Fallback WiFi deep scan using airodump-ng directly."""
if not interface:
return {'status': 'error', 'message': 'WiFi interface required'}
@@ -1680,23 +1500,8 @@ class ModeManager:
cmd = [airodump_path, '-w', csv_path, '--output-format', output_formats, '--band', band]
if gps_manager.is_running:
cmd.append('--gpsd')
channel_list = None
if channels:
if isinstance(channels, str):
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
elif isinstance(channels, (list, tuple, set)):
channel_list = list(channels)
else:
channel_list = [channels]
try:
channel_list = [int(c) for c in channel_list]
except (TypeError, ValueError):
return {'status': 'error', 'message': 'Invalid channels'}
if channel_list:
cmd.extend(['-c', ','.join(str(c) for c in channel_list)])
elif channel:
cmd.extend(['-c', str(channel)])
if channel:
cmd.extend(['-c', str(channel)])
cmd.append(interface)
try:
@@ -2297,24 +2102,15 @@ class ModeManager:
logger.debug(f"Pager: {parsed.get('protocol')} addr={parsed.get('address')}")
except (OSError, ValueError) as e:
# Bad file descriptor or closed file - process was terminated
logger.debug(f"Pager reader stopped: {e}")
except Exception as e:
logger.error(f"Pager reader error: {e}")
finally:
try:
proc.wait(timeout=1)
except Exception:
pass
proc.wait()
if 'pager_rtl' in self.processes:
try:
rtl_proc = self.processes['pager_rtl']
if rtl_proc.poll() is None:
rtl_proc.terminate()
del self.processes['pager_rtl']
except Exception:
pass
rtl_proc = self.processes['pager_rtl']
if rtl_proc.poll() is None:
rtl_proc.terminate()
del self.processes['pager_rtl']
logger.info("Pager reader stopped")
def _parse_pager_message(self, line: str) -> dict | None:
@@ -2696,15 +2492,10 @@ class ModeManager:
except json.JSONDecodeError:
pass
except (OSError, ValueError) as e:
logger.debug(f"ACARS reader stopped: {e}")
except Exception as e:
logger.error(f"ACARS reader error: {e}")
finally:
try:
proc.wait(timeout=1)
except Exception:
pass
proc.wait()
logger.info("ACARS reader stopped")
# -------------------------------------------------------------------------
@@ -2841,23 +2632,15 @@ class ModeManager:
logger.debug(f"APRS: {callsign}")
except (OSError, ValueError) as e:
logger.debug(f"APRS reader stopped: {e}")
except Exception as e:
logger.error(f"APRS reader error: {e}")
finally:
try:
proc.wait(timeout=1)
except Exception:
pass
proc.wait()
if 'aprs_rtl' in self.processes:
try:
rtl_proc = self.processes['aprs_rtl']
if rtl_proc.poll() is None:
rtl_proc.terminate()
del self.processes['aprs_rtl']
except Exception:
pass
rtl_proc = self.processes['aprs_rtl']
if rtl_proc.poll() is None:
rtl_proc.terminate()
del self.processes['aprs_rtl']
logger.info("APRS reader stopped")
def _parse_aprs_packet(self, line: str) -> dict | None:
@@ -3005,23 +2788,15 @@ class ModeManager:
except json.JSONDecodeError:
pass
except (OSError, ValueError) as e:
logger.debug(f"RTLAMR reader stopped: {e}")
except Exception as e:
logger.error(f"RTLAMR reader error: {e}")
finally:
try:
proc.wait(timeout=1)
except Exception:
pass
proc.wait()
if 'rtlamr_tcp' in self.processes:
try:
tcp_proc = self.processes['rtlamr_tcp']
if tcp_proc.poll() is None:
tcp_proc.terminate()
del self.processes['rtlamr_tcp']
except Exception:
pass
tcp_proc = self.processes['rtlamr_tcp']
if tcp_proc.poll() is None:
tcp_proc.terminate()
del self.processes['rtlamr_tcp']
logger.info("RTLAMR reader stopped")
# -------------------------------------------------------------------------
@@ -3126,15 +2901,10 @@ class ModeManager:
except ImportError:
logger.warning("DSCDecoder not available (missing scipy/numpy)")
except (OSError, ValueError) as e:
logger.debug(f"DSC reader stopped: {e}")
except Exception as e:
logger.error(f"DSC reader error: {e}")
finally:
try:
proc.wait(timeout=1)
except Exception:
pass
proc.wait()
logger.info("DSC reader stopped")
# -------------------------------------------------------------------------
@@ -3148,21 +2918,17 @@ class ModeManager:
self.tscm_baseline = {}
if not hasattr(self, 'tscm_anomalies'):
self.tscm_anomalies = []
if not hasattr(self, 'tscm_rf_signals'):
self.tscm_rf_signals = []
if not hasattr(self, 'tscm_wifi_clients'):
self.tscm_wifi_clients = {}
self.tscm_anomalies.clear()
self.tscm_wifi_clients.clear()
if not hasattr(self, 'tscm_rf_signals'):
self.tscm_rf_signals = []
self.tscm_anomalies.clear()
# Get params for what to scan
scan_wifi = params.get('wifi', True)
scan_bt = params.get('bluetooth', True)
scan_rf = params.get('rf', True)
wifi_interface = params.get('wifi_interface') or params.get('interface')
bt_adapter = params.get('bt_interface') or params.get('adapter', 'hci0')
sdr_device = params.get('sdr_device', params.get('device', 0))
sweep_type = params.get('sweep_type')
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))
# Get baseline_id for comparison (same as local mode)
baseline_id = params.get('baseline_id')
@@ -3170,11 +2936,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, sweep_type),
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),
daemon=True
)
thread.start()
self.output_threads['tscm'] = thread
@@ -3193,9 +2959,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, sweep_type: str | 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):
"""Combined TSCM scanner using existing Intercept functions.
NOTE: This matches local mode behavior exactly:
@@ -3208,20 +2974,11 @@ class ModeManager:
stop_event = self.stop_events.get(mode)
# Import existing Intercept TSCM functions
from routes.tscm import _scan_wifi_networks, _scan_wifi_clients, _scan_bluetooth_devices, _scan_rf_signals
logger.info("TSCM imports successful")
sweep_ranges = None
if sweep_type:
try:
from data.tscm_frequencies import get_sweep_preset, SWEEP_PRESETS
preset = get_sweep_preset(sweep_type) or SWEEP_PRESETS.get('standard')
sweep_ranges = preset.get('ranges') if preset else None
except Exception:
sweep_ranges = None
# Load baseline if specified (same as local mode)
baseline = None
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
if baseline_id and HAS_BASELINE_DB and get_tscm_baseline:
baseline = get_tscm_baseline(baseline_id)
if baseline:
@@ -3242,9 +2999,8 @@ class ModeManager:
self._tscm_correlation = None
# Track devices seen during this sweep (like local mode's all_wifi/all_bt dicts)
seen_wifi = {}
seen_wifi_clients = {}
seen_bt = {}
seen_wifi = {}
seen_bt = {}
last_rf_scan = 0
rf_scan_interval = 30
@@ -3290,63 +3046,19 @@ 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['score_modifier'] = profile.score_modifier
enriched['known_device'] = profile.known_device
enriched['known_device_name'] = profile.known_device_name
enriched['indicators'] = [
{'type': i.type.value, 'desc': i.description}
for i in profile.indicators
]
enriched['recommended_action'] = profile.recommended_action
self.wifi_networks[bssid] = enriched
# WiFi clients (monitor mode only)
try:
wifi_clients = _scan_wifi_clients(wifi_interface or '')
for client in wifi_clients:
mac = (client.get('mac') or '').upper()
if not mac or mac in seen_wifi_clients:
continue
seen_wifi_clients[mac] = client
rssi_val = client.get('rssi_current')
if rssi_val is None:
rssi_val = client.get('rssi_median') or client.get('rssi_ema')
client_device = {
'mac': mac,
'vendor': client.get('vendor'),
'name': client.get('vendor') or 'WiFi Client',
'rssi': rssi_val,
'associated_bssid': client.get('associated_bssid'),
'probed_ssids': client.get('probed_ssids', []),
'probe_count': client.get('probe_count', len(client.get('probed_ssids', []))),
'is_client': True,
}
if self._tscm_correlation:
profile = self._tscm_correlation.analyze_wifi_device(client_device)
client_device['classification'] = profile.risk_level.value
client_device['score'] = profile.total_score
client_device['score_modifier'] = profile.score_modifier
client_device['known_device'] = profile.known_device
client_device['known_device_name'] = profile.known_device_name
client_device['indicators'] = [
{'type': i.type.value, 'desc': i.description}
for i in profile.indicators
]
client_device['recommended_action'] = profile.recommended_action
self.tscm_wifi_clients[mac] = client_device
except Exception as e:
logger.debug(f"WiFi client scan error: {e}")
except Exception as e:
logger.debug(f"WiFi scan error: {e}")
if self._tscm_correlation:
profile = self._tscm_correlation.analyze_wifi_device(enriched)
enriched['classification'] = profile.risk_level.value
enriched['score'] = profile.total_score
enriched['indicators'] = [
{'type': i.type.value, 'desc': i.description}
for i in profile.indicators
]
enriched['recommended_action'] = profile.recommended_action
self.wifi_networks[bssid] = enriched
except Exception as e:
logger.debug(f"WiFi scan error: {e}")
# Bluetooth scan using Intercept's function (same as local mode)
if scan_bt:
@@ -3380,18 +3092,15 @@ 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['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
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
self.bluetooth_devices[mac] = enriched
except Exception as e:
@@ -3402,11 +3111,7 @@ 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,
sweep_ranges=sweep_ranges
)
rf_signals = _scan_rf_signals(sdr_device, stop_check=agent_stop_check)
# Analyze each RF signal like local mode does
analyzed_signals = []
@@ -3426,17 +3131,14 @@ 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['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
]
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
]
analyzed['is_threat'] = is_threat
analyzed_signals.append(analyzed)
@@ -3927,12 +3629,6 @@ class InterceptAgentHandler(BaseHTTPRequestHandler):
config.push_interval = int(body['push_interval'])
self._send_json({'status': 'updated', 'config': config.to_dict()})
elif path == '/wifi/monitor':
# Enable/disable monitor mode on WiFi interface
result = mode_manager.toggle_monitor_mode(body)
status = 200 if result.get('status') == 'success' else 400
self._send_json(result, status)
elif path.startswith('/') and path.count('/') == 2:
# /{mode}/start or /{mode}/stop
parts = path.split('/')
@@ -4098,53 +3794,19 @@ def main():
print(" Press Ctrl+C to stop")
print()
# Shutdown flag
shutdown_requested = threading.Event()
# Handle shutdown - run cleanup in separate thread to avoid blocking
# Handle shutdown
def signal_handler(sig, frame):
if shutdown_requested.is_set():
# Already shutting down, force exit
print("\nForce exit...")
os._exit(1)
shutdown_requested.set()
print("\nShutting down...")
def cleanup():
# Stop all running modes first (they have subprocesses)
for mode in list(mode_manager.running_modes.keys()):
try:
mode_manager.stop_mode(mode)
except Exception as e:
logger.debug(f"Error stopping {mode}: {e}")
# Stop push services
if data_push_loop:
try:
data_push_loop.stop()
except Exception:
pass
if push_client:
try:
push_client.stop()
except Exception:
pass
# Stop GPS
try:
gps_manager.stop()
except Exception:
pass
# Shutdown HTTP server
try:
httpd.shutdown()
except Exception:
pass
# Run cleanup in background thread so signal handler returns quickly
cleanup_thread = threading.Thread(target=cleanup, daemon=True)
cleanup_thread.start()
# Stop all running modes
for mode in list(mode_manager.running_modes.keys()):
mode_manager.stop_mode(mode)
if data_push_loop:
data_push_loop.stop()
if push_client:
push_client.stop()
gps_manager.stop()
httpd.shutdown()
sys.exit(0)
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
@@ -4153,14 +3815,9 @@ def main():
httpd.serve_forever()
except KeyboardInterrupt:
pass
except Exception:
pass
# Give cleanup thread time to finish
if shutdown_requested.is_set():
time.sleep(0.5)
print("Agent stopped.")
finally:
if push_client:
push_client.stop()
if __name__ == '__main__':
+1 -12
View File
@@ -1,6 +1,6 @@
[project]
name = "intercept"
version = "2.14.0"
version = "2.12.0"
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
readme = "README.md"
requires-python = ">=3.9"
@@ -33,7 +33,6 @@ dependencies = [
"flask-limiter>=2.5.4",
"bleak>=0.21.0",
"flask-sock",
"websocket-client>=1.6.0",
"requests>=2.28.0",
]
@@ -53,16 +52,6 @@ dev = [
"types-flask>=1.1.0",
]
optionals = [
"scipy>=1.10.0",
"qrcode[pil]>=7.4",
"numpy>=1.24.0",
"Pillow>=9.0.0",
"meshtastic>=2.0.0",
"psycopg2-binary>=2.9.9",
"scapy>=2.4.5",
]
[project.scripts]
intercept = "intercept:main"
+1 -9
View File
@@ -13,22 +13,16 @@ bleak>=0.21.0
# Satellite tracking (optional - only needed for satellite features)
skyfield>=1.45
# DSC decoding and SSTV decoding (DSP pipeline)
# DSC decoding (optional - only needed for VHF DSC maritime distress)
scipy>=1.10.0
numpy>=1.24.0
# SSTV image output (optional - needed for SSTV image decoding)
Pillow>=9.0.0
# GPS dongle support (optional - only needed for USB GPS receivers)
pyserial>=3.5
# Meshtastic mesh network support (optional - only needed for Meshtastic features)
meshtastic>=2.0.0
# Deauthentication attack detection (optional - for WiFi TSCM)
scapy>=2.4.5
# QR code generation for Meshtastic channels (optional)
qrcode[pil]>=7.4
@@ -38,6 +32,4 @@ qrcode[pil]>=7.4
# ruff>=0.1.0
# black>=23.0.0
# mypy>=1.0.0
# WebSocket support for in-app audio streaming (KiwiSDR, Listening Post)
flask-sock
websocket-client>=1.6.0
-10
View File
@@ -26,11 +26,6 @@ def register_blueprints(app):
from .offline import offline_bp
from .updater import updater_bp
from .sstv import sstv_bp
from .sstv_general import sstv_general_bp
from .dmr import dmr_bp
from .websdr import websdr_bp
from .alerts import alerts_bp
from .recordings import recordings_bp
app.register_blueprint(pager_bp)
app.register_blueprint(sensor_bp)
@@ -56,11 +51,6 @@ def register_blueprints(app):
app.register_blueprint(offline_bp) # Offline mode settings
app.register_blueprint(updater_bp) # GitHub update checking
app.register_blueprint(sstv_bp) # ISS SSTV decoder
app.register_blueprint(sstv_general_bp) # General terrestrial SSTV
app.register_blueprint(dmr_bp) # DMR / P25 / Digital Voice
app.register_blueprint(websdr_bp) # HF/Shortwave WebSDR
app.register_blueprint(alerts_bp) # Cross-mode alerts
app.register_blueprint(recordings_bp) # Session recordings
# Initialize TSCM state with queue and lock from app
import app as app_module
+6 -57
View File
@@ -20,15 +20,13 @@ from flask import Blueprint, jsonify, request, Response
import app as app_module
from utils.logging import sensor_logger as logger
from utils.validation import validate_device_index, validate_gain, validate_ppm
from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.sse import format_sse
from utils.constants import (
PROCESS_TERMINATE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT,
PROCESS_START_WAIT,
)
from utils.process import register_process, unregister_process
acars_bp = Blueprint('acars', __name__, url_prefix='/acars')
@@ -45,9 +43,6 @@ DEFAULT_ACARS_FREQUENCIES = [
acars_message_count = 0
acars_last_message_time = None
# Track which device is being used
acars_active_device: int | None = None
def find_acarsdec():
"""Find acarsdec binary."""
@@ -146,24 +141,9 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
logger.error(f"ACARS stream error: {e}")
app_module.acars_queue.put({'type': 'error', 'message': str(e)})
finally:
global acars_active_device
# Ensure process is terminated
try:
process.terminate()
process.wait(timeout=2)
except Exception:
try:
process.kill()
except Exception:
pass
unregister_process(process)
app_module.acars_queue.put({'type': 'status', 'status': 'stopped'})
with app_module.acars_lock:
app_module.acars_process = None
# Release SDR device
if acars_active_device is not None:
app_module.release_sdr_device(acars_active_device)
acars_active_device = None
@acars_bp.route('/tools')
@@ -195,7 +175,7 @@ def acars_status() -> Response:
@acars_bp.route('/start', methods=['POST'])
def start_acars() -> Response:
"""Start ACARS decoder."""
global acars_message_count, acars_last_message_time, acars_active_device
global acars_message_count, acars_last_message_time
with app_module.acars_lock:
if app_module.acars_process and app_module.acars_process.poll() is None:
@@ -222,18 +202,6 @@ def start_acars() -> Response:
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
# Check if device is available
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'acars')
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
acars_active_device = device_int
# Get frequencies - use provided or defaults
frequencies = data.get('frequencies', DEFAULT_ACARS_FREQUENCIES)
if isinstance(frequencies, str):
@@ -314,10 +282,7 @@ def start_acars() -> Response:
time.sleep(PROCESS_START_WAIT)
if process.poll() is not None:
# Process died - release device
if acars_active_device is not None:
app_module.release_sdr_device(acars_active_device)
acars_active_device = None
# Process died
stderr = ''
if process.stderr:
stderr = process.stderr.read().decode('utf-8', errors='replace')
@@ -328,7 +293,6 @@ def start_acars() -> Response:
return jsonify({'status': 'error', 'message': error_msg}), 500
app_module.acars_process = process
register_process(process)
# Start output streaming thread
thread = threading.Thread(
@@ -346,10 +310,6 @@ def start_acars() -> Response:
})
except Exception as e:
# Release device on failure
if acars_active_device is not None:
app_module.release_sdr_device(acars_active_device)
acars_active_device = None
logger.error(f"Failed to start ACARS decoder: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500
@@ -357,8 +317,6 @@ def start_acars() -> Response:
@acars_bp.route('/stop', methods=['POST'])
def stop_acars() -> Response:
"""Stop ACARS decoder."""
global acars_active_device
with app_module.acars_lock:
if not app_module.acars_process:
return jsonify({
@@ -376,11 +334,6 @@ def stop_acars() -> Response:
app_module.acars_process = None
# Release device from registry
if acars_active_device is not None:
app_module.release_sdr_device(acars_active_device)
acars_active_device = None
return jsonify({'status': 'stopped'})
@@ -392,13 +345,9 @@ def stream_acars() -> Response:
while True:
try:
msg = app_module.acars_queue.get(timeout=SSE_QUEUE_TIMEOUT)
last_keepalive = time.time()
try:
process_event('acars', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg)
msg = app_module.acars_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:
+11 -67
View File
@@ -33,9 +33,7 @@ from config import (
ADSB_DB_PASSWORD,
ADSB_DB_PORT,
ADSB_DB_USER,
ADSB_AUTO_START,
ADSB_HISTORY_ENABLED,
SHARED_OBSERVER_LOCATION_ENABLED,
)
from utils.logging import adsb_logger as logger
from utils.validation import (
@@ -43,7 +41,6 @@ from utils.validation import (
validate_rtl_tcp_host, validate_rtl_tcp_port
)
from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.sdr import SDRFactory, SDRType
from utils.constants import (
ADSB_SBS_PORT,
@@ -687,16 +684,6 @@ def start_adsb():
app_module.adsb_process = None
logger.info("Killed stale ADS-B process")
# Check if device is available before starting local dump1090
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'adsb')
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
# Create device object and build command via abstraction layer
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
builder = SDRFactory.get_builder(sdr_type)
@@ -725,51 +712,23 @@ def start_adsb():
time.sleep(DUMP1090_START_WAIT)
if app_module.adsb_process.poll() is not None:
# Process exited - release device and get error message
app_module.release_sdr_device(device_int)
# Process exited - try to get error message
stderr_output = ''
if app_module.adsb_process.stderr:
try:
stderr_output = app_module.adsb_process.stderr.read().decode('utf-8', errors='ignore').strip()
except Exception:
pass
# Parse stderr to provide specific guidance
error_type = 'START_FAILED'
stderr_lower = stderr_output.lower()
if 'usb_claim_interface' in stderr_lower or 'libusb_error_busy' in stderr_lower or 'device or resource busy' in stderr_lower:
error_msg = 'SDR device is busy. Another process may be using it.'
suggestion = 'Try: 1) Stop other SDR applications, 2) Run "pkill -f rtl_" to kill stale processes, or 3) Remove and reinsert the SDR device.'
error_type = 'DEVICE_BUSY'
elif 'no supported devices' in stderr_lower or 'no rtl-sdr' in stderr_lower or 'failed to open' in stderr_lower:
error_msg = 'RTL-SDR device not found.'
suggestion = 'Ensure the device is connected. Try removing and reinserting the SDR.'
error_type = 'DEVICE_NOT_FOUND'
elif 'kernel driver is active' in stderr_lower or 'dvb' in stderr_lower:
error_msg = 'Kernel DVB-T driver is blocking the device.'
suggestion = 'Blacklist the DVB drivers: Go to Settings > Hardware > "Blacklist DVB Drivers" or run "sudo rmmod dvb_usb_rtl28xxu".'
error_type = 'KERNEL_DRIVER'
elif 'permission' in stderr_lower or 'access' in stderr_lower:
error_msg = 'Permission denied accessing RTL-SDR device.'
suggestion = 'Run Intercept with sudo, or add udev rules for RTL-SDR devices.'
error_type = 'PERMISSION_DENIED'
elif sdr_type == SDRType.RTL_SDR:
error_msg = 'dump1090 failed to start.'
suggestion = 'Try removing and reinserting the SDR device, or check if another application is using it.'
if sdr_type == SDRType.RTL_SDR:
error_msg = 'dump1090 failed to start. Check RTL-SDR device permissions or if another process is using it.'
if stderr_output:
error_msg += f' Error: {stderr_output[:200]}'
return jsonify({'status': 'error', 'message': error_msg})
else:
error_msg = f'ADS-B decoder failed to start for {sdr_type.value}.'
suggestion = 'Ensure readsb is installed with SoapySDR support and the device is connected.'
full_msg = f'{error_msg} {suggestion}'
if stderr_output and len(stderr_output) < 300:
full_msg += f' (Details: {stderr_output})'
return jsonify({
'status': 'error',
'error_type': error_type,
'message': full_msg
})
error_msg = f'ADS-B decoder failed to start for {sdr_type.value}. Ensure readsb is installed with SoapySDR support and the device is connected.'
if stderr_output:
error_msg += f' Error: {stderr_output[:200]}'
return jsonify({'status': 'error', 'message': error_msg})
adsb_using_service = True
adsb_active_device = device # Track which device is being used
@@ -791,8 +750,6 @@ def start_adsb():
'session': session
})
except Exception as e:
# Release device on failure
app_module.release_sdr_device(device_int)
return jsonify({'status': 'error', 'message': str(e)})
@@ -820,11 +777,6 @@ def stop_adsb():
pass
app_module.adsb_process = None
logger.info("ADS-B process stopped")
# Release device from registry
if adsb_active_device is not None:
app_module.release_sdr_device(adsb_active_device)
adsb_using_service = False
adsb_active_device = None
@@ -844,10 +796,6 @@ def stream_adsb():
try:
msg = app_module.adsb_queue.get(timeout=SSE_QUEUE_TIMEOUT)
last_keepalive = time.time()
try:
process_event('adsb', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg)
except queue.Empty:
now = time.time()
@@ -864,11 +812,7 @@ def stream_adsb():
@adsb_bp.route('/dashboard')
def adsb_dashboard():
"""Popout ADS-B dashboard."""
return render_template(
'adsb_dashboard.html',
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
adsb_auto_start=ADSB_AUTO_START,
)
return render_template('adsb_dashboard.html')
@adsb_bp.route('/history')
+1 -29
View File
@@ -15,11 +15,9 @@ from typing import Generator
from flask import Blueprint, jsonify, request, Response, render_template
import app as app_module
from config import SHARED_OBSERVER_LOCATION_ENABLED
from utils.logging import get_logger
from utils.validation import validate_device_index, validate_gain
from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.sdr import SDRFactory, SDRType
from utils.constants import (
AIS_TCP_PORT,
@@ -371,16 +369,6 @@ def start_ais():
app_module.ais_process = None
logger.info("Killed existing AIS process")
# Check if device is available
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'ais')
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
# Build command using SDR abstraction
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
builder = SDRFactory.get_builder(sdr_type)
@@ -411,8 +399,6 @@ def start_ais():
time.sleep(2.0)
if app_module.ais_process.poll() is not None:
# Release device on failure
app_module.release_sdr_device(device_int)
stderr_output = ''
if app_module.ais_process.stderr:
try:
@@ -438,8 +424,6 @@ def start_ais():
'port': tcp_port
})
except Exception as e:
# Release device on failure
app_module.release_sdr_device(device_int)
logger.error(f"Failed to start AIS-catcher: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500
@@ -463,11 +447,6 @@ def stop_ais():
pass
app_module.ais_process = None
logger.info("AIS process stopped")
# Release device from registry
if ais_active_device is not None:
app_module.release_sdr_device(ais_active_device)
ais_running = False
ais_active_device = None
@@ -485,10 +464,6 @@ def stream_ais():
try:
msg = app_module.ais_queue.get(timeout=SSE_QUEUE_TIMEOUT)
last_keepalive = time.time()
try:
process_event('ais', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg)
except queue.Empty:
now = time.time()
@@ -505,7 +480,4 @@ def stream_ais():
@ais_bp.route('/dashboard')
def ais_dashboard():
"""Popout AIS dashboard."""
return render_template(
'ais_dashboard.html',
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
)
return render_template('ais_dashboard.html')
-76
View File
@@ -1,76 +0,0 @@
"""Alerting API endpoints."""
from __future__ import annotations
import queue
import time
from typing import Generator
from flask import Blueprint, Response, jsonify, request
from utils.alerts import get_alert_manager
from utils.sse import format_sse
alerts_bp = Blueprint('alerts', __name__, url_prefix='/alerts')
@alerts_bp.route('/rules', methods=['GET'])
def list_rules():
manager = get_alert_manager()
include_disabled = request.args.get('all') in ('1', 'true', 'yes')
return jsonify({'status': 'success', 'rules': manager.list_rules(include_disabled=include_disabled)})
@alerts_bp.route('/rules', methods=['POST'])
def create_rule():
data = request.get_json() or {}
if not isinstance(data.get('match', {}), dict):
return jsonify({'status': 'error', 'message': 'match must be a JSON object'}), 400
manager = get_alert_manager()
rule_id = manager.add_rule(data)
return jsonify({'status': 'success', 'rule_id': rule_id})
@alerts_bp.route('/rules/<int:rule_id>', methods=['PUT', 'PATCH'])
def update_rule(rule_id: int):
data = request.get_json() or {}
manager = get_alert_manager()
ok = manager.update_rule(rule_id, data)
if not ok:
return jsonify({'status': 'error', 'message': 'Rule not found or no changes'}), 404
return jsonify({'status': 'success'})
@alerts_bp.route('/rules/<int:rule_id>', methods=['DELETE'])
def delete_rule(rule_id: int):
manager = get_alert_manager()
ok = manager.delete_rule(rule_id)
if not ok:
return jsonify({'status': 'error', 'message': 'Rule not found'}), 404
return jsonify({'status': 'success'})
@alerts_bp.route('/events', methods=['GET'])
def list_events():
manager = get_alert_manager()
limit = request.args.get('limit', default=100, type=int)
mode = request.args.get('mode')
severity = request.args.get('severity')
events = manager.list_events(limit=limit, mode=mode, severity=severity)
return jsonify({'status': 'success', 'events': events})
@alerts_bp.route('/stream', methods=['GET'])
def stream_alerts() -> Response:
manager = get_alert_manager()
def generate() -> Generator[str, None, None]:
for event in manager.stream_events(timeout=1.0):
yield format_sse(event)
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response
+9 -68
View File
@@ -13,7 +13,7 @@ import tempfile
import threading
import time
from datetime import datetime
from subprocess import PIPE, STDOUT
from subprocess import DEVNULL, PIPE, STDOUT
from typing import Generator, Optional
from flask import Blueprint, jsonify, request, Response
@@ -21,8 +21,7 @@ from flask import Blueprint, jsonify, request, Response
import app as app_module
from utils.logging import sensor_logger as logger
from utils.validation import validate_device_index, validate_gain, validate_ppm
from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.sse import format_sse
from utils.constants import (
PROCESS_TERMINATE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL,
@@ -32,9 +31,6 @@ 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',
@@ -1305,7 +1301,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 captured via PIPE with a monitor thread.
rtl_fm's stderr is sent to DEVNULL for the same reason.
Outputs two types of messages to the queue:
- type='aprs': Decoded APRS packets
@@ -1387,7 +1383,6 @@ 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]:
@@ -1399,10 +1394,6 @@ 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')
@@ -1450,7 +1441,6 @@ 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:
@@ -1487,16 +1477,6 @@ def start_aprs() -> Response:
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
# Reserve SDR device to prevent conflicts with other modes
error = app_module.claim_sdr_device(device, 'aprs')
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
aprs_active_device = device
# Get frequency for region
region = data.get('region', 'north_america')
frequency = APRS_FREQUENCIES.get(region, '144.390')
@@ -1572,25 +1552,15 @@ def start_aprs() -> Response:
try:
# Start rtl_fm with stdout piped to decoder.
# stderr is captured via PIPE so errors are reported to the user.
# stderr goes to DEVNULL to prevent blocking (rtl_fm logs to stderr).
# NOTE: RTL-SDR Blog V4 may show offset-tuned frequency in logs - this is normal.
rtl_process = subprocess.Popen(
rtl_cmd,
stdout=PIPE,
stderr=PIPE,
stderr=DEVNULL,
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.
@@ -1612,25 +1582,13 @@ def start_aprs() -> Response:
time.sleep(PROCESS_START_WAIT)
if rtl_process.poll() is not None:
# 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
# rtl_fm exited early - something went wrong
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:
@@ -1644,9 +1602,6 @@ 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
@@ -1671,17 +1626,12 @@ 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 = []
@@ -1710,11 +1660,6 @@ 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'})
@@ -1726,13 +1671,9 @@ def stream_aprs() -> Response:
while True:
try:
msg = app_module.aprs_queue.get(timeout=SSE_QUEUE_TIMEOUT)
last_keepalive = time.time()
try:
process_event('aprs', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg)
msg = app_module.aprs_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:
+7 -5
View File
@@ -66,6 +66,12 @@ def kill_audio_processes():
pass
rtl_process = None
# Kill any orphaned processes
try:
subprocess.run(['pkill', '-9', '-f', 'rtl_fm'], capture_output=True, timeout=1)
except:
pass
time.sleep(0.3)
@@ -223,11 +229,7 @@ def init_audio_websocket(app: Flask):
except TimeoutError:
pass
except Exception as e:
msg = str(e).lower()
if "connection closed" in msg:
logger.info("WebSocket closed by client")
break
if "timed out" not in msg:
if "timed out" not in str(e).lower():
logger.error(f"WebSocket receive error: {e}")
# Stream audio data if active
+7 -12
View File
@@ -18,11 +18,10 @@ from typing import Any, Generator
from flask import Blueprint, jsonify, request, Response
import app as app_module
from utils.dependencies import check_tool
from utils.logging import bluetooth_logger as logger
from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.validation import validate_bluetooth_interface
from utils.dependencies import check_tool
from utils.logging import bluetooth_logger as logger
from utils.sse import format_sse
from utils.validation import validate_bluetooth_interface
from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer
from data.patterns import AIRTAG_PREFIXES, TILE_PREFIXES, SAMSUNG_TRACKER
from utils.constants import (
@@ -562,13 +561,9 @@ def stream_bt():
while True:
try:
msg = app_module.bt_queue.get(timeout=1)
last_keepalive = time.time()
try:
process_event('bluetooth', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg)
msg = app_module.bt_queue.get(timeout=1)
last_keepalive = time.time()
yield format_sse(msg)
except queue.Empty:
now = time.time()
if now - last_keepalive >= keepalive_interval:
+75 -177
View File
@@ -7,40 +7,32 @@ aggregation, and heuristics.
from __future__ import annotations
import csv
import io
import json
import logging
import threading
import time
import csv
import io
import json
import logging
from datetime import datetime
from typing import Generator
from flask import Blueprint, Response, jsonify, request, session
from utils.bluetooth import (
BluetoothScanner,
BTDeviceAggregate,
get_bluetooth_scanner,
check_capabilities,
RANGE_UNKNOWN,
from utils.bluetooth import (
BluetoothScanner,
BTDeviceAggregate,
get_bluetooth_scanner,
check_capabilities,
RANGE_UNKNOWN,
TrackerType,
TrackerConfidence,
get_tracker_engine,
)
from utils.database import get_db
from utils.sse import format_sse
from utils.event_pipeline import process_event
)
from utils.database import get_db
from utils.sse import format_sse
logger = logging.getLogger('intercept.bluetooth_v2')
# Blueprint
bluetooth_v2_bp = Blueprint('bluetooth_v2', __name__, url_prefix='/api/bluetooth')
# Seen-before tracking
_bt_seen_cache: set[str] = set()
_bt_session_seen: set[str] = set()
_bt_seen_lock = threading.Lock()
bluetooth_v2_bp = Blueprint('bluetooth_v2', __name__, url_prefix='/api/bluetooth')
# =============================================================================
# DATABASE FUNCTIONS
@@ -172,20 +164,13 @@ def get_all_baselines() -> list[dict]:
return [dict(row) for row in cursor]
def save_observation_history(device: BTDeviceAggregate) -> None:
"""Save device observation to history."""
with get_db() as conn:
conn.execute('''
INSERT INTO bt_observation_history (device_id, rssi, seen_count)
VALUES (?, ?, ?)
''', (device.device_id, device.rssi_current, device.seen_count))
def load_seen_device_ids() -> set[str]:
"""Load distinct device IDs from history for seen-before tracking."""
with get_db() as conn:
cursor = conn.execute('SELECT DISTINCT device_id FROM bt_observation_history')
return {row['device_id'] for row in cursor}
def save_observation_history(device: BTDeviceAggregate) -> None:
"""Save device observation to history."""
with get_db() as conn:
conn.execute('''
INSERT INTO bt_observation_history (device_id, rssi, seen_count)
VALUES (?, ?, ?)
''', (device.device_id, device.rssi_current, device.seen_count))
# =============================================================================
@@ -206,7 +191,7 @@ def get_capabilities():
@bluetooth_v2_bp.route('/scan/start', methods=['POST'])
def start_scan():
def start_scan():
"""
Start Bluetooth scanning.
@@ -236,42 +221,17 @@ def start_scan():
# Get scanner instance
scanner = get_bluetooth_scanner(adapter_id)
# Initialize database tables if needed
init_bt_tables()
def _handle_seen_before(device: BTDeviceAggregate) -> None:
try:
with _bt_seen_lock:
device.seen_before = device.device_id in _bt_seen_cache
if device.device_id not in _bt_session_seen:
save_observation_history(device)
_bt_session_seen.add(device.device_id)
except Exception as e:
logger.debug(f"BT seen-before update failed: {e}")
# Setup seen-before callback
if scanner._on_device_updated is None:
scanner._on_device_updated = _handle_seen_before
# Ensure cache is initialized
with _bt_seen_lock:
if not _bt_seen_cache:
_bt_seen_cache.update(load_seen_device_ids())
# Check if already scanning
if scanner.is_scanning:
return jsonify({
'status': 'already_running',
'scan_status': scanner.get_status().to_dict()
})
# Refresh seen-before cache and reset session set for a new scan
with _bt_seen_lock:
_bt_seen_cache.clear()
_bt_seen_cache.update(load_seen_device_ids())
_bt_session_seen.clear()
# Load active baseline if exists
# Check if already scanning
if scanner.is_scanning:
return jsonify({
'status': 'already_running',
'scan_status': scanner.get_status().to_dict()
})
# Initialize database tables if needed
init_bt_tables()
# Load active baseline if exists
baseline_id = get_active_baseline_id()
if baseline_id:
device_ids = get_baseline_device_ids(baseline_id)
@@ -896,15 +856,11 @@ def stream_events():
else:
return event_type, event
def event_generator() -> Generator[str, None, None]:
"""Generate SSE events from scanner."""
for event in scanner.stream_events(timeout=1.0):
event_name, event_data = map_event_type(event)
try:
process_event('bluetooth', event_data, event_name)
except Exception:
pass
yield format_sse(event_data, event=event_name)
def event_generator() -> Generator[str, None, None]:
"""Generate SSE events from scanner."""
for event in scanner.stream_events(timeout=1.0):
event_name, event_data = map_event_type(event)
yield format_sse(event_data, event=event_name)
return Response(
event_generator(),
@@ -988,34 +944,23 @@ def get_tscm_bluetooth_snapshot(duration: int = 8) -> list[dict]:
devices = scanner.get_devices()
logger.info(f"TSCM snapshot: get_devices() returned {len(devices)} devices")
# Convert to TSCM format with tracker detection data
tscm_devices = []
for device in devices:
manufacturer_name = device.manufacturer_name
if (not manufacturer_name) or str(manufacturer_name).lower().startswith('unknown'):
if device.address and not device.is_randomized_mac:
try:
from data.oui import get_manufacturer
oui_vendor = get_manufacturer(device.address)
if oui_vendor and oui_vendor != 'Unknown':
manufacturer_name = oui_vendor
except Exception:
pass
device_data = {
'mac': device.address,
'address_type': device.address_type,
'device_key': device.device_key,
'name': device.name or 'Unknown',
'rssi': device.rssi_current or -100,
'rssi_median': device.rssi_median,
'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None,
'type': _classify_device_type(device),
'manufacturer': manufacturer_name,
'manufacturer_id': device.manufacturer_id,
'manufacturer_data': device.manufacturer_bytes.hex() if device.manufacturer_bytes else None,
'protocol': device.protocol,
'first_seen': device.first_seen.isoformat(),
# Convert to TSCM format with tracker detection data
tscm_devices = []
for device in devices:
device_data = {
'mac': device.address,
'address_type': device.address_type,
'device_key': device.device_key,
'name': device.name or 'Unknown',
'rssi': device.rssi_current or -100,
'rssi_median': device.rssi_median,
'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None,
'type': _classify_device_type(device),
'manufacturer': device.manufacturer_name,
'manufacturer_id': device.manufacturer_id,
'manufacturer_data': device.manufacturer_bytes.hex() if device.manufacturer_bytes else None,
'protocol': device.protocol,
'first_seen': device.first_seen.isoformat(),
'last_seen': device.last_seen.isoformat(),
'seen_count': device.seen_count,
'range_band': device.range_band,
@@ -1229,38 +1174,14 @@ def get_device_timeseries(device_key: str):
return jsonify(result)
def _classify_device_type(device: BTDeviceAggregate) -> str:
"""Classify device type from available data."""
name_lower = (device.name or '').lower()
manufacturer_lower = (device.manufacturer_name or '').lower()
service_uuids = device.service_uuids or []
if (not manufacturer_lower) or manufacturer_lower.startswith('unknown'):
if device.address and not device.is_randomized_mac:
try:
from data.oui import get_manufacturer
oui_vendor = get_manufacturer(device.address)
if oui_vendor and oui_vendor != 'Unknown':
manufacturer_lower = oui_vendor.lower()
except Exception:
pass
def normalize_uuid(uuid: str) -> str:
if not uuid:
return ''
value = str(uuid).lower().strip()
if value.startswith('0x'):
value = value[2:]
# Bluetooth Base UUID normalization (16-bit UUIDs)
if value.endswith('-0000-1000-8000-00805f9b34fb') and len(value) >= 8:
return value[4:8]
if len(value) == 4:
return value
return value
# Check by name patterns
if any(x in name_lower for x in ['airpods', 'headphone', 'earbuds', 'buds', 'beats']):
return 'audio'
def _classify_device_type(device: BTDeviceAggregate) -> str:
"""Classify device type from available data."""
name_lower = (device.name or '').lower()
manufacturer_lower = (device.manufacturer_name or '').lower()
# Check by name patterns
if any(x in name_lower for x in ['airpods', 'headphone', 'earbuds', 'buds', 'beats']):
return 'audio'
if any(x in name_lower for x in ['watch', 'band', 'fitbit', 'garmin']):
return 'wearable'
if any(x in name_lower for x in ['iphone', 'pixel', 'galaxy', 'phone']):
@@ -1269,41 +1190,18 @@ def _classify_device_type(device: BTDeviceAggregate) -> str:
return 'computer'
if any(x in name_lower for x in ['mouse', 'keyboard', 'trackpad']):
return 'peripheral'
if any(x in name_lower for x in ['tile', 'airtag', 'smarttag', 'chipolo']):
return 'tracker'
if any(x in name_lower for x in ['speaker', 'sonos', 'echo', 'home']):
return 'speaker'
if any(x in name_lower for x in ['tv', 'chromecast', 'roku', 'firestick']):
return 'media'
# Tracker signals (metadata or Find My service)
if getattr(device, 'is_tracker', False) or getattr(device, 'tracker_type', None):
return 'tracker'
normalized_uuids = {normalize_uuid(u) for u in service_uuids if u}
if 'fd6f' in normalized_uuids:
return 'tracker'
# Service UUIDs (GATT / classic)
audio_uuids = {'110b', '110a', '111e', '111f', '1108', '1203'}
wearable_uuids = {'180d', '1814', '1816'}
hid_uuids = {'1812'}
beacon_uuids = {'feaa', 'feab', 'feb1', 'febe'}
if normalized_uuids & audio_uuids:
return 'audio'
if normalized_uuids & hid_uuids:
return 'peripheral'
if normalized_uuids & wearable_uuids:
return 'wearable'
if normalized_uuids & beacon_uuids:
return 'beacon'
# Check by manufacturer
if 'apple' in manufacturer_lower:
return 'apple_device'
if 'samsung' in manufacturer_lower:
return 'samsung_device'
if any(x in name_lower for x in ['tile', 'airtag', 'smarttag', 'chipolo']):
return 'tracker'
if any(x in name_lower for x in ['speaker', 'sonos', 'echo', 'home']):
return 'speaker'
if any(x in name_lower for x in ['tv', 'chromecast', 'roku', 'firestick']):
return 'media'
# Check by manufacturer
if 'apple' in manufacturer_lower:
return 'apple_device'
if 'samsung' in manufacturer_lower:
return 'samsung_device'
# Check by class of device
if device.major_class:
+16 -124
View File
@@ -10,16 +10,14 @@ This blueprint provides:
from __future__ import annotations
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
import json
import logging
import queue
import time
from datetime import datetime, timezone
from typing import Generator
from flask import Blueprint, jsonify, request, Response
from utils.database import (
create_agent, get_agent, get_agent_by_name, list_agents,
@@ -93,17 +91,6 @@ def register_agent():
if not base_url:
return jsonify({'status': 'error', 'message': 'Base URL is required'}), 400
# Validate URL format
from urllib.parse import urlparse
try:
parsed = urlparse(base_url)
if parsed.scheme not in ('http', 'https'):
return jsonify({'status': 'error', 'message': 'URL must start with http:// or https://'}), 400
if not parsed.netloc:
return jsonify({'status': 'error', 'message': 'Invalid URL format'}), 400
except Exception:
return jsonify({'status': 'error', 'message': 'Invalid URL format'}), 400
# Check if agent already exists
existing = get_agent_by_name(name)
if existing:
@@ -141,12 +128,9 @@ def register_agent():
update_agent(agent_id, update_last_seen=True)
agent = get_agent(agent_id)
message = 'Agent registered successfully'
if capabilities is None:
message += ' (could not connect - agent may be offline)'
return jsonify({
'status': 'success',
'message': message,
'message': 'Agent registered successfully',
'agent': agent
}), 201
@@ -452,12 +436,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)
@@ -475,99 +459,7 @@ 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>/<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 {}
try:
client = create_client_from_agent(agent)
result = client.post('/wifi/monitor', data)
# Refresh agent capabilities after monitor mode toggle so UI stays in sync
if result.get('status') == 'success':
try:
metadata = client.refresh_metadata()
if metadata.get('healthy'):
caps = metadata.get('capabilities') or {}
agent_interfaces = caps.get('interfaces', {})
if not agent_interfaces.get('sdr_devices') and caps.get('devices'):
agent_interfaces['sdr_devices'] = caps.get('devices', [])
update_agent(
agent_id,
capabilities=caps.get('modes'),
interfaces=agent_interfaces,
update_last_seen=True
)
except Exception:
pass # Non-fatal if refresh fails
return jsonify({
'status': result.get('status', 'error'),
'agent_id': agent_id,
'agent_name': agent['name'],
'monitor_interface': result.get('monitor_interface'),
'message': result.get('message')
})
except AgentConnectionError as e:
return jsonify({
'status': 'error',
'message': f'Cannot connect to agent: {e}'
}), 503
except AgentHTTPError as e:
except (AgentHTTPError, AgentConnectionError) as e:
return jsonify({
'status': 'error',
'message': f'Agent error: {e}'
-513
View File
@@ -1,513 +0,0 @@
"""DMR / P25 / Digital Voice decoding routes."""
from __future__ import annotations
import os
import queue
import re
import select
import shutil
import subprocess
import threading
import time
from datetime import datetime
from typing import Generator, Optional
from flask import Blueprint, jsonify, request, Response
import app as app_module
from utils.logging import get_logger
from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.process import register_process, unregister_process
from utils.constants import (
SSE_QUEUE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL,
QUEUE_MAX_SIZE,
)
logger = get_logger('intercept.dmr')
dmr_bp = Blueprint('dmr', __name__, url_prefix='/dmr')
# ============================================
# GLOBAL STATE
# ============================================
dmr_rtl_process: Optional[subprocess.Popen] = None
dmr_dsd_process: Optional[subprocess.Popen] = None
dmr_thread: Optional[threading.Thread] = None
dmr_running = False
dmr_lock = threading.Lock()
dmr_queue: queue.Queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
dmr_active_device: Optional[int] = None
VALID_PROTOCOLS = ['auto', 'dmr', 'p25', 'nxdn', 'dstar', 'provoice']
# Classic dsd flags
_DSD_PROTOCOL_FLAGS = {
'auto': [],
'dmr': ['-fd'],
'p25': ['-fp'],
'nxdn': ['-fn'],
'dstar': ['-fi'],
'provoice': ['-fv'],
}
# dsd-fme uses different flag names
_DSD_FME_PROTOCOL_FLAGS = {
'auto': ['-ft'],
'dmr': ['-fs'],
'p25': ['-f1'],
'nxdn': ['-fi'],
'dstar': [],
'provoice': ['-fp'],
}
# ============================================
# HELPERS
# ============================================
def find_dsd() -> tuple[str | None, bool]:
"""Find DSD (Digital Speech Decoder) binary.
Checks for dsd-fme first (common fork), then falls back to dsd.
Returns (path, is_fme) tuple.
"""
path = shutil.which('dsd-fme')
if path:
return path, True
path = shutil.which('dsd')
if path:
return path, False
return None, False
def find_rtl_fm() -> str | None:
"""Find rtl_fm binary."""
return shutil.which('rtl_fm')
def parse_dsd_output(line: str) -> dict | None:
"""Parse a line of DSD stderr output into a structured event.
Handles output from both classic ``dsd`` and ``dsd-fme`` which use
different formatting for talkgroup / source / voice frame lines.
"""
line = line.strip()
if not line:
return None
# Skip DSD/dsd-fme startup banner lines (ASCII art, version info, etc.)
# These contain box-drawing characters or are pure decoration.
if re.search(r'[╔╗╚╝║═██▀▄╗╝╩╦╠╣╬│┤├┘└┐┌─┼█▓▒░]', line):
return None
if re.match(r'^\s*(Build Version|MBElib|CODEC2|Audio (Out|In)|Decoding )', line):
return None
ts = datetime.now().strftime('%H:%M:%S')
# Sync detection: "Sync: +DMR (data)" or "Sync: +P25 Phase 1"
sync_match = re.match(r'Sync:\s*\+?(\S+.*)', line)
if sync_match:
return {
'type': 'sync',
'protocol': sync_match.group(1).strip(),
'timestamp': ts,
}
# Talkgroup and Source — check BEFORE slot so "Slot 1 Voice LC, TG: …"
# is captured as a call event rather than a bare slot event.
# Classic dsd: "TG: 12345 Src: 67890"
# dsd-fme: "TG: 12345, Src: 67890" or "Talkgroup: 12345, Source: 67890"
tg_match = re.search(
r'(?:TG|Talkgroup)[:\s]+(\d+)[,\s]+(?:Src|Source)[:\s]+(\d+)', line, re.IGNORECASE
)
if tg_match:
result = {
'type': 'call',
'talkgroup': int(tg_match.group(1)),
'source_id': int(tg_match.group(2)),
'timestamp': ts,
}
# Extract slot if present on the same line
slot_inline = re.search(r'Slot\s*(\d+)', line)
if slot_inline:
result['slot'] = int(slot_inline.group(1))
return result
# P25 NAC (Network Access Code) — check before voice/slot
nac_match = re.search(r'NAC[:\s]+([0-9A-Fa-f]+)', line)
if nac_match:
return {
'type': 'nac',
'nac': nac_match.group(1),
'timestamp': ts,
}
# Voice frame detection — check BEFORE bare slot match
# Classic dsd: "Voice" keyword in frame lines
# dsd-fme: "voice" or "Voice LC" or "VOICE" in output
if re.search(r'\bvoice\b', line, re.IGNORECASE):
result = {
'type': 'voice',
'detail': line,
'timestamp': ts,
}
slot_inline = re.search(r'Slot\s*(\d+)', line)
if slot_inline:
result['slot'] = int(slot_inline.group(1))
return result
# Bare slot info (only when line is *just* slot info, not voice/call)
slot_match = re.match(r'\s*Slot\s*(\d+)\s*$', line)
if slot_match:
return {
'type': 'slot',
'slot': int(slot_match.group(1)),
'timestamp': ts,
}
# dsd-fme status lines we can surface: "TDMA", "CACH", "PI", "BS", etc.
# Also catches "Closing", "Input", and other lifecycle lines.
# Forward as raw so the frontend can show decoder is alive.
return {
'type': 'raw',
'text': line[:200],
'timestamp': ts,
}
_HEARTBEAT_INTERVAL = 3.0 # seconds between heartbeats when decoder is idle
def _queue_put(event: dict):
"""Put an event on the DMR queue, dropping oldest if full."""
try:
dmr_queue.put_nowait(event)
except queue.Full:
try:
dmr_queue.get_nowait()
except queue.Empty:
pass
try:
dmr_queue.put_nowait(event)
except queue.Full:
pass
def stream_dsd_output(rtl_process: subprocess.Popen, dsd_process: subprocess.Popen):
"""Read DSD stderr output and push parsed events to the queue.
Uses select() with a timeout so we can send periodic heartbeat
events while readline() would otherwise block indefinitely during
silence (no signal being decoded).
"""
global dmr_running
try:
_queue_put({'type': 'status', 'text': 'started'})
last_heartbeat = time.time()
while dmr_running:
if dsd_process.poll() is not None:
break
# Wait up to 1s for data on stderr instead of blocking forever
ready, _, _ = select.select([dsd_process.stderr], [], [], 1.0)
if ready:
line = dsd_process.stderr.readline()
if not line:
if dsd_process.poll() is not None:
break
continue
text = line.decode('utf-8', errors='replace').strip()
if not text:
continue
parsed = parse_dsd_output(text)
if parsed:
_queue_put(parsed)
last_heartbeat = time.time()
else:
# No stderr output — send heartbeat so frontend knows
# decoder is still alive and listening
now = time.time()
if now - last_heartbeat >= _HEARTBEAT_INTERVAL:
_queue_put({
'type': 'heartbeat',
'timestamp': datetime.now().strftime('%H:%M:%S'),
})
last_heartbeat = now
except Exception as e:
logger.error(f"DSD stream error: {e}")
finally:
global dmr_active_device, dmr_rtl_process, dmr_dsd_process
dmr_running = False
# Capture exit info for diagnostics
rc = dsd_process.poll()
reason = 'stopped'
detail = ''
if rc is not None and rc != 0:
reason = 'crashed'
try:
remaining = dsd_process.stderr.read(1024)
if remaining:
detail = remaining.decode('utf-8', errors='replace').strip()[:200]
except Exception:
pass
logger.warning(f"DSD process exited with code {rc}: {detail}")
# Cleanup both processes
for proc in [dsd_process, rtl_process]:
if proc and proc.poll() is None:
try:
proc.terminate()
proc.wait(timeout=2)
except Exception:
try:
proc.kill()
except Exception:
pass
if proc:
unregister_process(proc)
dmr_rtl_process = None
dmr_dsd_process = None
_queue_put({'type': 'status', 'text': reason, 'exit_code': rc, 'detail': detail})
# Release SDR device
if dmr_active_device is not None:
app_module.release_sdr_device(dmr_active_device)
dmr_active_device = None
logger.info("DSD stream thread stopped")
# ============================================
# API ENDPOINTS
# ============================================
@dmr_bp.route('/tools')
def check_tools() -> Response:
"""Check for required tools."""
dsd_path, _ = find_dsd()
rtl_fm = find_rtl_fm()
return jsonify({
'dsd': dsd_path is not None,
'rtl_fm': rtl_fm is not None,
'available': dsd_path is not None and rtl_fm is not None,
'protocols': VALID_PROTOCOLS,
})
@dmr_bp.route('/start', methods=['POST'])
def start_dmr() -> Response:
"""Start digital voice decoding."""
global dmr_rtl_process, dmr_dsd_process, dmr_thread, dmr_running, dmr_active_device
with dmr_lock:
if dmr_running:
return jsonify({'status': 'error', 'message': 'Already running'}), 409
dsd_path, is_fme = find_dsd()
if not dsd_path:
return jsonify({'status': 'error', 'message': 'dsd not found. Install dsd-fme or dsd.'}), 503
rtl_fm_path = find_rtl_fm()
if not rtl_fm_path:
return jsonify({'status': 'error', 'message': 'rtl_fm not found. Install rtl-sdr tools.'}), 503
data = request.json or {}
try:
frequency = float(data.get('frequency', 462.5625))
gain = int(data.get('gain', 40))
device = int(data.get('device', 0))
protocol = str(data.get('protocol', 'auto')).lower()
except (ValueError, TypeError) as e:
return jsonify({'status': 'error', 'message': f'Invalid parameter: {e}'}), 400
if frequency <= 0:
return jsonify({'status': 'error', 'message': 'Frequency must be positive'}), 400
if protocol not in VALID_PROTOCOLS:
return jsonify({'status': 'error', 'message': f'Invalid protocol. Use: {", ".join(VALID_PROTOCOLS)}'}), 400
# Clear stale queue
try:
while True:
dmr_queue.get_nowait()
except queue.Empty:
pass
# Claim SDR device
error = app_module.claim_sdr_device(device, 'dmr')
if error:
return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409
dmr_active_device = device
freq_hz = int(frequency * 1e6)
# Build rtl_fm command (48kHz sample rate for DSD)
rtl_cmd = [
rtl_fm_path,
'-M', 'fm',
'-f', str(freq_hz),
'-s', '48000',
'-g', str(gain),
'-d', str(device),
'-l', '1', # squelch level
]
# Build DSD command
# Use -o - to send decoded audio to stdout (piped to DEVNULL)
# instead of PulseAudio which may not be available under sudo
dsd_cmd = [dsd_path, '-i', '-', '-o', '-']
if is_fme:
dsd_cmd.extend(_DSD_FME_PROTOCOL_FLAGS.get(protocol, []))
else:
dsd_cmd.extend(_DSD_PROTOCOL_FLAGS.get(protocol, []))
try:
dmr_rtl_process = subprocess.Popen(
rtl_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
register_process(dmr_rtl_process)
dmr_dsd_process = subprocess.Popen(
dsd_cmd,
stdin=dmr_rtl_process.stdout,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
)
register_process(dmr_dsd_process)
# Allow rtl_fm to send directly to dsd
dmr_rtl_process.stdout.close()
time.sleep(0.3)
rtl_rc = dmr_rtl_process.poll()
dsd_rc = dmr_dsd_process.poll()
if rtl_rc is not None or dsd_rc is not None:
# Process died — capture stderr for diagnostics
rtl_err = ''
if dmr_rtl_process.stderr:
rtl_err = dmr_rtl_process.stderr.read().decode('utf-8', errors='replace')[:500]
dsd_err = ''
if dmr_dsd_process.stderr:
dsd_err = dmr_dsd_process.stderr.read().decode('utf-8', errors='replace')[:500]
logger.error(f"DSD pipeline died: rtl_fm rc={rtl_rc} err={rtl_err!r}, dsd rc={dsd_rc} err={dsd_err!r}")
if dmr_active_device is not None:
app_module.release_sdr_device(dmr_active_device)
dmr_active_device = None
# Surface a clear error to the user
detail = rtl_err.strip() or dsd_err.strip()
if 'usb_claim_interface' in rtl_err or 'Failed to open' in rtl_err:
msg = f'SDR device {device} is busy — it may be in use by another mode or process. Try a different device.'
elif detail:
msg = f'Failed to start DSD pipeline: {detail}'
else:
msg = 'Failed to start DSD pipeline'
return jsonify({'status': 'error', 'message': msg}), 500
# Drain rtl_fm stderr in background to prevent pipe blocking
def _drain_rtl_stderr(proc):
try:
for line in proc.stderr:
pass
except Exception:
pass
threading.Thread(target=_drain_rtl_stderr, args=(dmr_rtl_process,), daemon=True).start()
dmr_running = True
dmr_thread = threading.Thread(
target=stream_dsd_output,
args=(dmr_rtl_process, dmr_dsd_process),
daemon=True,
)
dmr_thread.start()
return jsonify({
'status': 'started',
'frequency': frequency,
'protocol': protocol,
})
except Exception as e:
logger.error(f"Failed to start DMR: {e}")
if dmr_active_device is not None:
app_module.release_sdr_device(dmr_active_device)
dmr_active_device = None
return jsonify({'status': 'error', 'message': str(e)}), 500
@dmr_bp.route('/stop', methods=['POST'])
def stop_dmr() -> Response:
"""Stop digital voice decoding."""
global dmr_rtl_process, dmr_dsd_process, dmr_running, dmr_active_device
with dmr_lock:
dmr_running = False
for proc in [dmr_dsd_process, dmr_rtl_process]:
if proc and proc.poll() is None:
try:
proc.terminate()
proc.wait(timeout=2)
except Exception:
try:
proc.kill()
except Exception:
pass
if proc:
unregister_process(proc)
dmr_rtl_process = None
dmr_dsd_process = None
if dmr_active_device is not None:
app_module.release_sdr_device(dmr_active_device)
dmr_active_device = None
return jsonify({'status': 'stopped'})
@dmr_bp.route('/status')
def dmr_status() -> Response:
"""Get DMR decoder status."""
return jsonify({
'running': dmr_running,
'device': dmr_active_device,
})
@dmr_bp.route('/stream')
def stream_dmr() -> Response:
"""SSE stream for DMR decoder events."""
def generate() -> Generator[str, None, None]:
last_keepalive = time.time()
while True:
try:
msg = dmr_queue.get(timeout=SSE_QUEUE_TIMEOUT)
last_keepalive = time.time()
try:
process_event('dmr', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg)
except queue.Empty:
now = time.time()
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
return response
+18 -74
View File
@@ -36,11 +36,9 @@ from utils.database import (
)
from utils.dsc.parser import parse_dsc_message
from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.validation import validate_device_index, validate_gain
from utils.sdr import SDRFactory, SDRType
from utils.dependencies import get_tool_path
from utils.process import register_process, unregister_process
logger = logging.getLogger('intercept.dsc')
@@ -49,9 +47,6 @@ dsc_bp = Blueprint('dsc', __name__, url_prefix='/dsc')
# Module state (track if running independent of process state)
dsc_running = False
# Track which device is being used
dsc_active_device: int | None = None
def _get_dsc_decoder_path() -> str | None:
"""Get path to DSC decoder."""
@@ -171,34 +166,17 @@ def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> Non
'error': str(e)
})
finally:
global dsc_active_device
try:
os.close(master_fd)
except OSError:
pass
decoder_process.wait()
dsc_running = False
# Cleanup both processes
with app_module.dsc_lock:
rtl_proc = app_module.dsc_rtl_process
for proc in [rtl_proc, decoder_process]:
if proc:
try:
proc.terminate()
proc.wait(timeout=2)
except Exception:
try:
proc.kill()
except Exception:
pass
unregister_process(proc)
app_module.dsc_queue.put({'type': 'status', 'status': 'stopped'})
with app_module.dsc_lock:
app_module.dsc_process = None
app_module.dsc_rtl_process = None
# Release SDR device
if dsc_active_device is not None:
app_module.release_sdr_device(dsc_active_device)
dsc_active_device = None
def _store_critical_alert(msg: dict) -> None:
@@ -331,18 +309,21 @@ def start_decoding() -> Response:
'message': str(e)
}), 400
# Check if device is available using centralized registry
global dsc_active_device
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'dsc')
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
dsc_active_device = device_int
# Check if device is in use by AIS
try:
from routes import ais as ais_module
if hasattr(ais_module, 'ais_running') and ais_module.ais_running:
# AIS is running - check if same device
if hasattr(ais_module, 'ais_device') and str(ais_module.ais_device) == str(device):
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': f'SDR device {device} is in use by AIS tracking',
'suggestion': 'Use a different SDR device or stop AIS tracking first',
'in_use_by': 'ais'
}), 409
except ImportError:
pass
# Clear queue
while not app_module.dsc_queue.empty():
@@ -381,7 +362,6 @@ def start_decoding() -> Response:
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
register_process(rtl_process)
# Start stderr monitor thread
stderr_thread = threading.Thread(
@@ -402,7 +382,6 @@ def start_decoding() -> Response:
stderr=slave_fd,
close_fds=True
)
register_process(decoder_process)
os.close(slave_fd)
rtl_process.stdout.close()
@@ -429,37 +408,11 @@ def start_decoding() -> Response:
})
except FileNotFoundError as e:
# Kill orphaned rtl_fm process
try:
rtl_process.terminate()
rtl_process.wait(timeout=2)
except Exception:
try:
rtl_process.kill()
except Exception:
pass
# Release device on failure
if dsc_active_device is not None:
app_module.release_sdr_device(dsc_active_device)
dsc_active_device = None
return jsonify({
'status': 'error',
'message': f'Tool not found: {e.filename}'
}), 400
except Exception as e:
# Kill orphaned rtl_fm process if it was started
try:
rtl_process.terminate()
rtl_process.wait(timeout=2)
except Exception:
try:
rtl_process.kill()
except Exception:
pass
# Release device on failure
if dsc_active_device is not None:
app_module.release_sdr_device(dsc_active_device)
dsc_active_device = None
logger.error(f"Failed to start DSC decoder: {e}")
return jsonify({
'status': 'error',
@@ -470,7 +423,7 @@ def start_decoding() -> Response:
@dsc_bp.route('/stop', methods=['POST'])
def stop_decoding() -> Response:
"""Stop DSC decoder."""
global dsc_running, dsc_active_device
global dsc_running
with app_module.dsc_lock:
if not app_module.dsc_process:
@@ -507,11 +460,6 @@ def stop_decoding() -> Response:
app_module.dsc_process = None
app_module.dsc_rtl_process = None
# Release device from registry
if dsc_active_device is not None:
app_module.release_sdr_device(dsc_active_device)
dsc_active_device = None
return jsonify({'status': 'stopped'})
@@ -526,10 +474,6 @@ def stream() -> Response:
try:
msg = app_module.dsc_queue.get(timeout=1)
last_keepalive = time.time()
try:
process_event('dsc', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg)
except queue.Empty:
now = time.time()
+91 -1000
View File
File diff suppressed because it is too large Load Diff
+9 -49
View File
@@ -3,9 +3,8 @@
Provides endpoints for connecting to Meshtastic devices, configuring
channels with encryption keys, and streaming received messages.
Supports multiple connection types:
- USB/Serial: Physical device connected via USB
- TCP: WiFi-enabled devices accessible via IP address
Requires a physical Meshtastic device (Heltec, T-Beam, RAK, etc.)
connected via USB/Serial.
"""
from __future__ import annotations
@@ -96,7 +95,7 @@ def get_status():
Get Meshtastic connection status.
Returns:
JSON with connection status, device info, connection type, and node information.
JSON with connection status, device info, and node information.
"""
if not is_meshtastic_available():
return jsonify({
@@ -112,7 +111,6 @@ def get_status():
'available': True,
'running': False,
'device': None,
'connection_type': None,
'node_info': None,
})
@@ -122,7 +120,6 @@ def get_status():
'available': True,
'running': client.is_running,
'device': client.device_path,
'connection_type': client.connection_type,
'error': client.error,
'node_info': node_info.to_dict() if node_info else None,
})
@@ -134,20 +131,13 @@ def start_mesh():
Start Meshtastic listener.
Connects to a Meshtastic device and begins receiving messages.
Supports both USB/Serial and TCP connections.
The device must be connected via USB/Serial.
JSON body (optional):
{
"connection_type": "serial", // 'serial' (default) or 'tcp'
"device": "/dev/ttyUSB0", // Serial port path. Auto-discovers if not provided.
"hostname": "192.168.1.100" // IP address or hostname for TCP connections
"device": "/dev/ttyUSB0" // Serial port path. Auto-discovers if not provided.
}
Examples:
Serial (auto-discover): {}
Serial (specific port): {"device": "/dev/ttyUSB0"}
TCP: {"connection_type": "tcp", "hostname": "192.168.1.100"}
Returns:
JSON with connection status.
"""
@@ -161,8 +151,7 @@ def start_mesh():
if client and client.is_running:
return jsonify({
'status': 'already_running',
'device': client.device_path,
'connection_type': client.connection_type
'device': client.device_path
})
# Clear queue and history
@@ -173,46 +162,18 @@ def start_mesh():
break
_recent_messages.clear()
# Parse connection parameters
# Get optional device path
data = request.get_json(silent=True) or {}
connection_type = data.get('connection_type', 'serial').lower().strip()
device = data.get('device')
hostname = data.get('hostname')
# Validate connection type
if connection_type not in ('serial', 'tcp'):
return jsonify({
'status': 'error',
'message': f"Invalid connection_type: {connection_type}. Must be 'serial' or 'tcp'"
}), 400
# Validate TCP parameters
if connection_type == 'tcp':
if not hostname:
return jsonify({
'status': 'error',
'message': 'hostname is required for TCP connections'
}), 400
hostname = str(hostname).strip()
if not hostname:
return jsonify({
'status': 'error',
'message': 'hostname cannot be empty'
}), 400
# Validate serial device path if provided
# Validate device path if provided
if device:
device = str(device).strip()
if not device:
device = None
# Start client
success = start_meshtastic(
device=device,
callback=_message_callback,
connection_type=connection_type,
hostname=hostname
)
success = start_meshtastic(device=device, callback=_message_callback)
if success:
client = get_meshtastic_client()
@@ -220,7 +181,6 @@ def start_mesh():
return jsonify({
'status': 'started',
'device': client.device_path if client else None,
'connection_type': client.connection_type if client else None,
'node_info': node_info.to_dict() if node_info else None,
})
else:
+1 -1
View File
@@ -13,7 +13,7 @@ OFFLINE_DEFAULTS = {
'offline.enabled': False,
'offline.assets_source': 'cdn',
'offline.fonts_source': 'cdn',
'offline.tile_provider': 'cartodb_dark_cyan',
'offline.tile_provider': 'openstreetmap',
'offline.tile_server_url': ''
}
+8 -165
View File
@@ -2,14 +2,12 @@
from __future__ import annotations
import math
import os
import pathlib
import re
import pty
import queue
import select
import struct
import subprocess
import threading
import time
@@ -25,16 +23,12 @@ from utils.validation import (
validate_rtl_tcp_host, validate_rtl_tcp_port
)
from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.process import safe_terminate, register_process, unregister_process
from utils.process import safe_terminate, register_process
from utils.sdr import SDRFactory, SDRType, SDRValidationError
from utils.dependencies import get_tool_path
pager_bp = Blueprint('pager', __name__)
# Track which device is being used
pager_active_device: int | None = None
def parse_multimon_output(line: str) -> dict[str, str] | None:
"""Parse multimon-ng output line."""
@@ -108,62 +102,6 @@ def log_message(msg: dict[str, Any]) -> None:
logger.error(f"Failed to log message: {e}")
def audio_relay_thread(
rtl_stdout,
multimon_stdin,
output_queue: queue.Queue,
stop_event: threading.Event,
) -> None:
"""Relay audio from rtl_fm to multimon-ng while computing signal levels.
Reads raw 16-bit LE PCM from *rtl_stdout*, writes every chunk straight
through to *multimon_stdin*, and every ~100 ms pushes an RMS / peak scope
event onto *output_queue*.
"""
CHUNK = 4096 # bytes 2048 samples at 16-bit mono
INTERVAL = 0.1 # seconds between scope updates
last_scope = time.monotonic()
try:
while not stop_event.is_set():
data = rtl_stdout.read(CHUNK)
if not data:
break
# Forward audio untouched
try:
multimon_stdin.write(data)
multimon_stdin.flush()
except (BrokenPipeError, OSError):
break
# Compute scope levels every ~100 ms
now = time.monotonic()
if now - last_scope >= INTERVAL:
last_scope = now
try:
n_samples = len(data) // 2
if n_samples == 0:
continue
samples = struct.unpack(f'<{n_samples}h', data[:n_samples * 2])
peak = max(abs(s) for s in samples)
rms = int(math.sqrt(sum(s * s for s in samples) / n_samples))
output_queue.put_nowait({
'type': 'scope',
'rms': rms,
'peak': peak,
})
except (struct.error, ValueError, queue.Full):
pass
except Exception as e:
logger.debug(f"Audio relay error: {e}")
finally:
try:
multimon_stdin.close()
except OSError:
pass
def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
"""Stream decoder output to queue using PTY for unbuffered output."""
try:
@@ -205,43 +143,18 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
except Exception as e:
app_module.output_queue.put({'type': 'error', 'text': str(e)})
finally:
global pager_active_device
try:
os.close(master_fd)
except OSError:
pass
# Signal relay thread to stop
with app_module.process_lock:
stop_relay = getattr(app_module.current_process, '_stop_relay', None)
if stop_relay:
stop_relay.set()
# Cleanup companion rtl_fm process and decoder
with app_module.process_lock:
rtl_proc = getattr(app_module.current_process, '_rtl_process', None)
for proc in [rtl_proc, process]:
if proc:
try:
proc.terminate()
proc.wait(timeout=2)
except Exception:
try:
proc.kill()
except Exception:
pass
unregister_process(proc)
process.wait()
app_module.output_queue.put({'type': 'status', 'text': 'stopped'})
with app_module.process_lock:
app_module.current_process = None
# Release SDR device
if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device)
pager_active_device = None
@pager_bp.route('/start', methods=['POST'])
def start_decoding() -> Response:
global pager_active_device
with app_module.process_lock:
if app_module.current_process:
return jsonify({'status': 'error', 'message': 'Already running'}), 409
@@ -265,29 +178,10 @@ def start_decoding() -> Response:
except (ValueError, TypeError):
return jsonify({'status': 'error', 'message': 'Invalid squelch value'}), 400
# Check for rtl_tcp (remote SDR) connection
rtl_tcp_host = data.get('rtl_tcp_host')
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
# Claim local device if not using remote rtl_tcp
if not rtl_tcp_host:
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'pager')
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
pager_active_device = device_int
# Validate protocols
valid_protocols = ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX']
protocols = data.get('protocols', valid_protocols)
if not isinstance(protocols, list):
if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device)
pager_active_device = None
return jsonify({'status': 'error', 'message': 'Protocols must be a list'}), 400
protocols = [p for p in protocols if p in valid_protocols]
if not protocols:
@@ -319,6 +213,10 @@ def start_decoding() -> Response:
except ValueError:
sdr_type = SDRType.RTL_SDR
# Check for rtl_tcp (remote SDR) connection
rtl_tcp_host = data.get('rtl_tcp_host')
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
if rtl_tcp_host:
# Validate and create network device
try:
@@ -363,7 +261,6 @@ def start_decoding() -> Response:
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
register_process(rtl_process)
# Start a thread to monitor rtl_fm stderr for errors
def monitor_rtl_stderr():
@@ -382,30 +279,18 @@ def start_decoding() -> Response:
multimon_process = subprocess.Popen(
multimon_cmd,
stdin=subprocess.PIPE,
stdin=rtl_process.stdout,
stdout=slave_fd,
stderr=slave_fd,
close_fds=True
)
register_process(multimon_process)
os.close(slave_fd)
# Spawn audio relay thread between rtl_fm and multimon-ng
stop_relay = threading.Event()
relay = threading.Thread(
target=audio_relay_thread,
args=(rtl_process.stdout, multimon_process.stdin,
app_module.output_queue, stop_relay),
)
relay.daemon = True
relay.start()
rtl_process.stdout.close()
app_module.current_process = multimon_process
app_module.current_process._rtl_process = rtl_process
app_module.current_process._master_fd = master_fd
app_module.current_process._stop_relay = stop_relay
app_module.current_process._relay_thread = relay
# Start output thread with PTY master fd
thread = threading.Thread(target=stream_decoder, args=(master_fd, multimon_process))
@@ -417,47 +302,15 @@ def start_decoding() -> Response:
return jsonify({'status': 'started', 'command': full_cmd})
except FileNotFoundError as e:
# Kill orphaned rtl_fm process
try:
rtl_process.terminate()
rtl_process.wait(timeout=2)
except Exception:
try:
rtl_process.kill()
except Exception:
pass
# Release device on failure
if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device)
pager_active_device = None
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'})
except Exception as e:
# Kill orphaned rtl_fm process if it was started
try:
rtl_process.terminate()
rtl_process.wait(timeout=2)
except Exception:
try:
rtl_process.kill()
except Exception:
pass
# Release device on failure
if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device)
pager_active_device = None
return jsonify({'status': 'error', 'message': str(e)})
@pager_bp.route('/stop', methods=['POST'])
def stop_decoding() -> Response:
global pager_active_device
with app_module.process_lock:
if app_module.current_process:
# Signal audio relay thread to stop
if hasattr(app_module.current_process, '_stop_relay'):
app_module.current_process._stop_relay.set()
# Kill rtl_fm process first
if hasattr(app_module.current_process, '_rtl_process'):
try:
@@ -484,12 +337,6 @@ def stop_decoding() -> Response:
app_module.current_process.kill()
app_module.current_process = None
# Release device from registry
if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device)
pager_active_device = None
return jsonify({'status': 'stopped'})
return jsonify({'status': 'not_running'})
@@ -550,10 +397,6 @@ def stream() -> Response:
try:
msg = app_module.output_queue.get(timeout=1)
last_keepalive = time.time()
try:
process_event('pager', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg)
except queue.Empty:
now = time.time()
-109
View File
@@ -1,109 +0,0 @@
"""Session recording API endpoints."""
from __future__ import annotations
from pathlib import Path
from flask import Blueprint, jsonify, request, send_file
from utils.recording import get_recording_manager, RECORDING_ROOT
recordings_bp = Blueprint('recordings', __name__, url_prefix='/recordings')
@recordings_bp.route('/start', methods=['POST'])
def start_recording():
data = request.get_json() or {}
mode = (data.get('mode') or '').strip()
if not mode:
return jsonify({'status': 'error', 'message': 'mode is required'}), 400
label = data.get('label')
metadata = data.get('metadata') if isinstance(data.get('metadata'), dict) else {}
manager = get_recording_manager()
session = manager.start_recording(mode=mode, label=label, metadata=metadata)
return jsonify({
'status': 'success',
'session': {
'id': session.id,
'mode': session.mode,
'label': session.label,
'started_at': session.started_at.isoformat(),
'file_path': str(session.file_path),
}
})
@recordings_bp.route('/stop', methods=['POST'])
def stop_recording():
data = request.get_json() or {}
mode = data.get('mode')
session_id = data.get('id')
manager = get_recording_manager()
session = manager.stop_recording(mode=mode, session_id=session_id)
if not session:
return jsonify({'status': 'error', 'message': 'No active recording found'}), 404
return jsonify({
'status': 'success',
'session': {
'id': session.id,
'mode': session.mode,
'label': session.label,
'started_at': session.started_at.isoformat(),
'stopped_at': session.stopped_at.isoformat() if session.stopped_at else None,
'event_count': session.event_count,
'size_bytes': session.size_bytes,
'file_path': str(session.file_path),
}
})
@recordings_bp.route('', methods=['GET'])
def list_recordings():
manager = get_recording_manager()
limit = request.args.get('limit', default=50, type=int)
return jsonify({
'status': 'success',
'recordings': manager.list_recordings(limit=limit),
'active': manager.get_active(),
})
@recordings_bp.route('/<session_id>', methods=['GET'])
def get_recording(session_id: str):
manager = get_recording_manager()
rec = manager.get_recording(session_id)
if not rec:
return jsonify({'status': 'error', 'message': 'Recording not found'}), 404
return jsonify({'status': 'success', 'recording': rec})
@recordings_bp.route('/<session_id>/download', methods=['GET'])
def download_recording(session_id: str):
manager = get_recording_manager()
rec = manager.get_recording(session_id)
if not rec:
return jsonify({'status': 'error', 'message': 'Recording not found'}), 404
file_path = Path(rec['file_path'])
try:
resolved_root = RECORDING_ROOT.resolve()
resolved_file = file_path.resolve()
if resolved_root not in resolved_file.parents:
return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400
except Exception:
return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400
if not file_path.exists():
return jsonify({'status': 'error', 'message': 'Recording file missing'}), 404
return send_file(
file_path,
mimetype='application/x-ndjson',
as_attachment=True,
download_name=file_path.name,
)
+10 -74
View File
@@ -18,8 +18,7 @@ from utils.validation import (
validate_frequency, validate_device_index, validate_gain, validate_ppm
)
from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.process import safe_terminate, register_process, unregister_process
from utils.process import safe_terminate, register_process
rtlamr_bp = Blueprint('rtlamr', __name__)
@@ -27,9 +26,6 @@ rtlamr_bp = Blueprint('rtlamr', __name__)
rtl_tcp_process = None
rtl_tcp_lock = threading.Lock()
# Track which device is being used
rtlamr_active_device: int | None = None
def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
"""Stream rtlamr JSON output to queue."""
@@ -62,42 +58,15 @@ def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
except Exception as e:
app_module.rtlamr_queue.put({'type': 'error', 'text': str(e)})
finally:
global rtl_tcp_process, rtlamr_active_device
# Ensure rtlamr process is terminated
try:
process.terminate()
process.wait(timeout=2)
except Exception:
try:
process.kill()
except Exception:
pass
unregister_process(process)
# Kill companion rtl_tcp process
with rtl_tcp_lock:
if rtl_tcp_process:
try:
rtl_tcp_process.terminate()
rtl_tcp_process.wait(timeout=2)
except Exception:
try:
rtl_tcp_process.kill()
except Exception:
pass
unregister_process(rtl_tcp_process)
rtl_tcp_process = None
process.wait()
app_module.rtlamr_queue.put({'type': 'status', 'text': 'stopped'})
with app_module.rtlamr_lock:
app_module.rtlamr_process = None
# Release SDR device
if rtlamr_active_device is not None:
app_module.release_sdr_device(rtlamr_active_device)
rtlamr_active_device = None
@rtlamr_bp.route('/start_rtlamr', methods=['POST'])
def start_rtlamr() -> Response:
global rtl_tcp_process, rtlamr_active_device
global rtl_tcp_process
with app_module.rtlamr_lock:
if app_module.rtlamr_process:
@@ -114,18 +83,6 @@ def start_rtlamr() -> Response:
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
# Check if device is available
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'rtlamr')
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
rtlamr_active_device = device_int
# Clear queue
while not app_module.rtlamr_queue.empty():
try:
@@ -161,8 +118,7 @@ def start_rtlamr() -> Response:
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
register_process(rtl_tcp_process)
# Wait a moment for rtl_tcp to start
time.sleep(3)
@@ -170,10 +126,6 @@ def start_rtlamr() -> Response:
app_module.rtlamr_queue.put({'type': 'info', 'text': f'rtl_tcp: {" ".join(rtl_tcp_cmd)}'})
except Exception as e:
logger.error(f"Failed to start rtl_tcp: {e}")
# Release SDR device on rtl_tcp failure
if rtlamr_active_device is not None:
app_module.release_sdr_device(rtlamr_active_device)
rtlamr_active_device = None
return jsonify({'status': 'error', 'message': f'Failed to start rtl_tcp: {e}'}), 500
# Build rtlamr command
@@ -207,7 +159,6 @@ def start_rtlamr() -> Response:
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
register_process(app_module.rtlamr_process)
# Start output thread
thread = threading.Thread(target=stream_rtlamr_output, args=(app_module.rtlamr_process,))
@@ -231,33 +182,27 @@ def start_rtlamr() -> Response:
return jsonify({'status': 'started', 'command': full_cmd})
except FileNotFoundError:
# If rtlamr fails, clean up rtl_tcp and release device
# If rtlamr fails, clean up rtl_tcp
with rtl_tcp_lock:
if rtl_tcp_process:
rtl_tcp_process.terminate()
rtl_tcp_process.wait(timeout=2)
rtl_tcp_process = None
if rtlamr_active_device is not None:
app_module.release_sdr_device(rtlamr_active_device)
rtlamr_active_device = None
return jsonify({'status': 'error', 'message': 'rtlamr not found. Install from https://github.com/bemasher/rtlamr'})
except Exception as e:
# If rtlamr fails, clean up rtl_tcp and release device
# If rtlamr fails, clean up rtl_tcp
with rtl_tcp_lock:
if rtl_tcp_process:
rtl_tcp_process.terminate()
rtl_tcp_process.wait(timeout=2)
rtl_tcp_process = None
if rtlamr_active_device is not None:
app_module.release_sdr_device(rtlamr_active_device)
rtlamr_active_device = None
return jsonify({'status': 'error', 'message': str(e)})
@rtlamr_bp.route('/stop_rtlamr', methods=['POST'])
def stop_rtlamr() -> Response:
global rtl_tcp_process, rtlamr_active_device
global rtl_tcp_process
with app_module.rtlamr_lock:
if app_module.rtlamr_process:
app_module.rtlamr_process.terminate()
@@ -266,7 +211,7 @@ def stop_rtlamr() -> Response:
except subprocess.TimeoutExpired:
app_module.rtlamr_process.kill()
app_module.rtlamr_process = None
# Also stop rtl_tcp
with rtl_tcp_lock:
if rtl_tcp_process:
@@ -277,12 +222,7 @@ def stop_rtlamr() -> Response:
rtl_tcp_process.kill()
rtl_tcp_process = None
logger.info("rtl_tcp stopped")
# Release device from registry
if rtlamr_active_device is not None:
app_module.release_sdr_device(rtlamr_active_device)
rtlamr_active_device = None
return jsonify({'status': 'stopped'})
@@ -296,10 +236,6 @@ def stream_rtlamr() -> Response:
try:
msg = app_module.rtlamr_queue.get(timeout=1)
last_keepalive = time.time()
try:
process_event('rtlamr', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg)
except queue.Empty:
now = time.time()
+15 -20
View File
@@ -13,8 +13,6 @@ import requests
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
from utils.validation import validate_latitude, validate_longitude, validate_hours, validate_elevation
@@ -42,30 +40,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: Where The ISS At
# Try primary API: Open Notify
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}")
# Try fallback API: Open Notify
# Try fallback API: Where The ISS At
if iss_lat is None:
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}")
if iss_lat is None:
return None
@@ -122,10 +120,7 @@ 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,
)
return render_template('satellite_dashboard.html')
@satellite_bp.route('/predict', methods=['POST'])
+6 -77
View File
@@ -19,15 +19,11 @@ from utils.validation import (
validate_rtl_tcp_host, validate_rtl_tcp_port
)
from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.process import safe_terminate, register_process, unregister_process
from utils.process import safe_terminate, register_process
from utils.sdr import SDRFactory, SDRType
sensor_bp = Blueprint('sensor', __name__)
# Track which device is being used
sensor_active_device: int | None = None
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
"""Stream rtl_433 JSON output to queue."""
@@ -45,21 +41,6 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
data['type'] = 'sensor'
app_module.sensor_queue.put(data)
# Push scope event when signal level data is present
rssi = data.get('rssi')
snr = data.get('snr')
noise = data.get('noise')
if rssi is not None or snr is not None:
try:
app_module.sensor_queue.put_nowait({
'type': 'scope',
'rssi': rssi if rssi is not None else 0,
'snr': snr if snr is not None else 0,
'noise': noise if noise is not None else 0,
})
except queue.Full:
pass
# Log if enabled
if app_module.logging_enabled:
try:
@@ -75,30 +56,14 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
except Exception as e:
app_module.sensor_queue.put({'type': 'error', 'text': str(e)})
finally:
global sensor_active_device
# Ensure process is terminated
try:
process.terminate()
process.wait(timeout=2)
except Exception:
try:
process.kill()
except Exception:
pass
unregister_process(process)
process.wait()
app_module.sensor_queue.put({'type': 'status', 'text': 'stopped'})
with app_module.sensor_lock:
app_module.sensor_process = None
# Release SDR device
if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device)
sensor_active_device = None
@sensor_bp.route('/start_sensor', methods=['POST'])
def start_sensor() -> Response:
global sensor_active_device
with app_module.sensor_lock:
if app_module.sensor_process:
return jsonify({'status': 'error', 'message': 'Sensor already running'}), 409
@@ -114,22 +79,6 @@ def start_sensor() -> Response:
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
# Check for rtl_tcp (remote SDR) connection
rtl_tcp_host = data.get('rtl_tcp_host')
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
# Claim local device if not using remote rtl_tcp
if not rtl_tcp_host:
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'sensor')
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
sensor_active_device = device_int
# Clear queue
while not app_module.sensor_queue.empty():
try:
@@ -144,6 +93,10 @@ def start_sensor() -> Response:
except ValueError:
sdr_type = SDRType.RTL_SDR
# Check for rtl_tcp (remote SDR) connection
rtl_tcp_host = data.get('rtl_tcp_host')
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
if rtl_tcp_host:
# Validate and create network device
try:
@@ -173,16 +126,12 @@ def start_sensor() -> Response:
full_cmd = ' '.join(cmd)
logger.info(f"Running: {full_cmd}")
# Add signal level metadata so the frontend scope can display RSSI/SNR
cmd.extend(['-M', 'level'])
try:
app_module.sensor_process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
register_process(app_module.sensor_process)
# Start output thread
thread = threading.Thread(target=stream_sensor_output, args=(app_module.sensor_process,))
@@ -206,23 +155,13 @@ def start_sensor() -> Response:
return jsonify({'status': 'started', 'command': full_cmd})
except FileNotFoundError:
# Release device on failure
if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device)
sensor_active_device = None
return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'})
except Exception as e:
# Release device on failure
if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device)
sensor_active_device = None
return jsonify({'status': 'error', 'message': str(e)})
@sensor_bp.route('/stop_sensor', methods=['POST'])
def stop_sensor() -> Response:
global sensor_active_device
with app_module.sensor_lock:
if app_module.sensor_process:
app_module.sensor_process.terminate()
@@ -231,12 +170,6 @@ def stop_sensor() -> Response:
except subprocess.TimeoutExpired:
app_module.sensor_process.kill()
app_module.sensor_process = None
# Release device from registry
if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device)
sensor_active_device = None
return jsonify({'status': 'stopped'})
return jsonify({'status': 'not_running'})
@@ -252,10 +185,6 @@ def stream_sensor() -> Response:
try:
msg = app_module.sensor_queue.get(timeout=1)
last_keepalive = time.time()
try:
process_event('sensor', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg)
except queue.Empty:
now = time.time()
+41 -224
View File
@@ -13,14 +13,13 @@ from typing import Generator
from flask import Blueprint, jsonify, request, Response, send_file
import app as app_module
from utils.logging import get_logger
from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.sstv import (
get_sstv_decoder,
is_sstv_available,
ISS_SSTV_FREQ,
DecodeProgress,
)
logger = get_logger('intercept.sstv')
@@ -30,18 +29,15 @@ sstv_bp = Blueprint('sstv', __name__, url_prefix='/sstv')
# Queue for SSE progress streaming
_sstv_queue: queue.Queue = queue.Queue(maxsize=100)
# Track which device is being used
sstv_active_device: int | None = None
def _progress_callback(data: dict) -> None:
"""Callback to queue progress/scope updates for SSE stream."""
def _progress_callback(progress: DecodeProgress) -> None:
"""Callback to queue progress updates for SSE stream."""
try:
_sstv_queue.put_nowait(data)
_sstv_queue.put_nowait(progress.to_dict())
except queue.Full:
try:
_sstv_queue.get_nowait()
_sstv_queue.put_nowait(data)
_sstv_queue.put_nowait(progress.to_dict())
except queue.Empty:
pass
@@ -57,21 +53,13 @@ def get_status():
available = is_sstv_available()
decoder = get_sstv_decoder()
result = {
return jsonify({
'available': available,
'decoder': decoder.decoder_available,
'running': decoder.is_running,
'iss_frequency': ISS_SSTV_FREQ,
'image_count': len(decoder.get_images()),
'doppler_enabled': decoder.doppler_enabled,
}
# Include Doppler info if available
doppler_info = decoder.last_doppler_info
if doppler_info:
result['doppler'] = doppler_info.to_dict()
return jsonify(result)
})
@sstv_bp.route('/start', methods=['POST'])
@@ -82,22 +70,16 @@ def start_decoder():
JSON body (optional):
{
"frequency": 145.800, // Frequency in MHz (default: ISS 145.800)
"device": 0, // RTL-SDR device index
"latitude": 40.7128, // Observer latitude for Doppler correction
"longitude": -74.0060 // Observer longitude for Doppler correction
"device": 0 // RTL-SDR device index
}
If latitude and longitude are provided, real-time Doppler shift compensation
will be enabled, which improves reception by tracking the ISS frequency shift
as it passes overhead (up to ±3.5 kHz at 145.800 MHz).
Returns:
JSON with start status.
"""
if not is_sstv_available():
return jsonify({
'status': 'error',
'message': 'SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow'
'message': 'SSTV decoder not available. Install slowrx: apt install slowrx'
}), 400
decoder = get_sstv_decoder()
@@ -105,8 +87,7 @@ def start_decoder():
if decoder.is_running:
return jsonify({
'status': 'already_running',
'frequency': ISS_SSTV_FREQ,
'doppler_enabled': decoder.doppler_enabled
'frequency': ISS_SSTV_FREQ
})
# Clear queue
@@ -120,8 +101,6 @@ def start_decoder():
data = request.get_json(silent=True) or {}
frequency = data.get('frequency', ISS_SSTV_FREQ)
device_index = data.get('device', 0)
latitude = data.get('latitude')
longitude = data.get('longitude')
# Validate frequency
try:
@@ -137,68 +116,17 @@ def start_decoder():
'message': 'Invalid frequency'
}), 400
# Validate location if provided
if latitude is not None and longitude is not None:
try:
latitude = float(latitude)
longitude = float(longitude)
if not (-90 <= latitude <= 90):
return jsonify({
'status': 'error',
'message': 'Latitude must be between -90 and 90'
}), 400
if not (-180 <= longitude <= 180):
return jsonify({
'status': 'error',
'message': 'Longitude must be between -180 and 180'
}), 400
except (TypeError, ValueError):
return jsonify({
'status': 'error',
'message': 'Invalid latitude or longitude'
}), 400
else:
latitude = None
longitude = None
# Claim SDR device
global sstv_active_device
device_int = int(device_index)
error = app_module.claim_sdr_device(device_int, 'sstv')
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
# Set callback and start
decoder.set_callback(_progress_callback)
success = decoder.start(
frequency=frequency,
device_index=device_index,
latitude=latitude,
longitude=longitude
)
success = decoder.start(frequency=frequency, device_index=device_index)
if success:
sstv_active_device = device_int
result = {
return jsonify({
'status': 'started',
'frequency': frequency,
'device': device_index,
'doppler_enabled': decoder.doppler_enabled
}
# Include initial Doppler info if available
if decoder.doppler_enabled and decoder.last_doppler_info:
result['doppler'] = decoder.last_doppler_info.to_dict()
return jsonify(result)
'device': device_index
})
else:
# Release device on failure
app_module.release_sdr_device(device_int)
return jsonify({
'status': 'error',
'message': 'Failed to start decoder'
@@ -213,51 +141,11 @@ def stop_decoder():
Returns:
JSON confirmation.
"""
global sstv_active_device
decoder = get_sstv_decoder()
decoder.stop()
# Release device from registry
if sstv_active_device is not None:
app_module.release_sdr_device(sstv_active_device)
sstv_active_device = None
return jsonify({'status': 'stopped'})
@sstv_bp.route('/doppler')
def get_doppler():
"""
Get current Doppler shift information.
Returns real-time Doppler shift data if tracking is enabled.
Returns:
JSON with Doppler shift information.
"""
decoder = get_sstv_decoder()
if not decoder.doppler_enabled:
return jsonify({
'status': 'disabled',
'message': 'Doppler tracking not enabled. Provide latitude/longitude when starting decoder.'
})
doppler_info = decoder.last_doppler_info
if not doppler_info:
return jsonify({
'status': 'unavailable',
'message': 'Doppler data not yet available'
})
return jsonify({
'status': 'ok',
'doppler': doppler_info.to_dict(),
'nominal_frequency_mhz': ISS_SSTV_FREQ,
'corrected_frequency_mhz': doppler_info.frequency_hz / 1_000_000
})
@sstv_bp.route('/images')
def list_images():
"""
@@ -312,73 +200,6 @@ def get_image(filename: str):
return send_file(image_path, mimetype='image/png')
@sstv_bp.route('/images/<filename>/download')
def download_image(filename: str):
"""
Download a decoded SSTV image file.
Args:
filename: Image filename
Returns:
Image file as attachment or 404.
"""
decoder = get_sstv_decoder()
# Security: only allow alphanumeric filenames with .png extension
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
if not filename.endswith('.png'):
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
image_path = decoder._output_dir / filename
if not image_path.exists():
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
return send_file(image_path, mimetype='image/png', as_attachment=True, download_name=filename)
@sstv_bp.route('/images/<filename>', methods=['DELETE'])
def delete_image(filename: str):
"""
Delete a decoded SSTV image.
Args:
filename: Image filename
Returns:
JSON confirmation.
"""
decoder = get_sstv_decoder()
# Security: only allow alphanumeric filenames with .png extension
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
if not filename.endswith('.png'):
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
if decoder.delete_image(filename):
return jsonify({'status': 'ok'})
else:
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
@sstv_bp.route('/images', methods=['DELETE'])
def delete_all_images():
"""
Delete all decoded SSTV images.
Returns:
JSON with count of deleted images.
"""
decoder = get_sstv_decoder()
count = decoder.delete_all_images()
return jsonify({'status': 'ok', 'deleted': count})
@sstv_bp.route('/stream')
def stream_progress():
"""
@@ -400,10 +221,6 @@ def stream_progress():
try:
progress = _sstv_queue.get(timeout=1)
last_keepalive = time.time()
try:
process_event('sstv', progress, progress.get('type'))
except Exception:
pass
yield format_sse(progress)
except queue.Empty:
now = time.time()
@@ -563,32 +380,7 @@ def iss_position():
observer_lat = request.args.get('latitude', type=float)
observer_lon = request.args.get('longitude', type=float)
# 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 primary API: Open Notify
try:
response = requests.get('http://api.open-notify.org/iss-now.json', timeout=5)
if response.status_code == 200:
@@ -612,7 +404,32 @@ def iss_position():
return jsonify(result)
except Exception as e:
logger.warning(f"Open Notify API failed: {e}")
logger.debug(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({
-338
View File
@@ -1,338 +0,0 @@
"""General SSTV (Slow-Scan Television) decoder routes.
Provides endpoints for decoding terrestrial SSTV images on common HF/VHF/UHF
frequencies used by amateur radio operators worldwide.
"""
from __future__ import annotations
import queue
import time
from collections.abc import Generator
from pathlib import Path
from flask import Blueprint, Response, jsonify, request, send_file
from utils.logging import get_logger
from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.sstv import (
get_general_sstv_decoder,
)
logger = get_logger('intercept.sstv_general')
sstv_general_bp = Blueprint('sstv_general', __name__, url_prefix='/sstv-general')
# Queue for SSE progress streaming
_sstv_general_queue: queue.Queue = queue.Queue(maxsize=100)
# Predefined SSTV frequencies
SSTV_FREQUENCIES = [
{'band': '80 m', 'frequency': 3.845, 'modulation': 'lsb', 'notes': 'Common US SSTV calling frequency', 'type': 'Terrestrial HF'},
{'band': '80 m', 'frequency': 3.730, 'modulation': 'lsb', 'notes': 'Europe primary (analog/digital variants)', 'type': 'Terrestrial HF'},
{'band': '40 m', 'frequency': 7.171, 'modulation': 'lsb', 'notes': 'Common international/US/EU SSTV activity', 'type': 'Terrestrial HF'},
{'band': '40 m', 'frequency': 7.040, 'modulation': 'lsb', 'notes': 'Alternative US/Europe calling', 'type': 'Terrestrial HF'},
{'band': '30 m', 'frequency': 10.132, 'modulation': 'usb', 'notes': 'Narrowband SSTV (e.g., MP73-N digital)', 'type': 'Terrestrial HF'},
{'band': '20 m', 'frequency': 14.230, 'modulation': 'usb', 'notes': 'Most popular international SSTV frequency', 'type': 'Terrestrial HF'},
{'band': '20 m', 'frequency': 14.233, 'modulation': 'usb', 'notes': 'Digital SSTV calling / alternative activity', 'type': 'Terrestrial HF'},
{'band': '20 m', 'frequency': 14.240, 'modulation': 'usb', 'notes': 'Europe alternative', 'type': 'Terrestrial HF'},
{'band': '15 m', 'frequency': 21.340, 'modulation': 'usb', 'notes': 'International calling frequency', 'type': 'Terrestrial HF'},
{'band': '10 m', 'frequency': 28.680, 'modulation': 'usb', 'notes': 'International calling frequency', 'type': 'Terrestrial HF'},
{'band': '6 m', 'frequency': 50.950, 'modulation': 'usb', 'notes': 'SSTV calling (less common)', 'type': 'Terrestrial VHF'},
{'band': '2 m', 'frequency': 145.625, 'modulation': 'fm', 'notes': 'Australia/common simplex (FM sometimes used)', 'type': 'Terrestrial VHF'},
{'band': '70 cm', 'frequency': 433.775, 'modulation': 'fm', 'notes': 'Australia/common simplex', 'type': 'Terrestrial UHF'},
]
# Build a lookup for auto-detecting modulation from frequency
_FREQ_MODULATION_MAP = {entry['frequency']: entry['modulation'] for entry in SSTV_FREQUENCIES}
def _progress_callback(data: dict) -> None:
"""Callback to queue progress/scope updates for SSE stream."""
try:
_sstv_general_queue.put_nowait(data)
except queue.Full:
try:
_sstv_general_queue.get_nowait()
_sstv_general_queue.put_nowait(data)
except queue.Empty:
pass
@sstv_general_bp.route('/frequencies')
def get_frequencies():
"""Return the predefined SSTV frequency table."""
return jsonify({
'status': 'ok',
'frequencies': SSTV_FREQUENCIES,
})
@sstv_general_bp.route('/status')
def get_status():
"""Get general SSTV decoder status."""
decoder = get_general_sstv_decoder()
return jsonify({
'available': decoder.decoder_available is not None,
'decoder': decoder.decoder_available,
'running': decoder.is_running,
'image_count': len(decoder.get_images()),
})
@sstv_general_bp.route('/start', methods=['POST'])
def start_decoder():
"""
Start general SSTV decoder.
JSON body:
{
"frequency": 14.230, // Frequency in MHz (required)
"modulation": "usb", // fm, usb, or lsb (auto-detected from frequency table if omitted)
"device": 0 // RTL-SDR device index
}
"""
decoder = get_general_sstv_decoder()
if decoder.decoder_available is None:
return jsonify({
'status': 'error',
'message': 'SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow',
}), 400
if decoder.is_running:
return jsonify({
'status': 'already_running',
})
# Clear queue
while not _sstv_general_queue.empty():
try:
_sstv_general_queue.get_nowait()
except queue.Empty:
break
data = request.get_json(silent=True) or {}
frequency = data.get('frequency')
modulation = data.get('modulation')
device_index = data.get('device', 0)
# Validate frequency
if frequency is None:
return jsonify({
'status': 'error',
'message': 'Frequency is required',
}), 400
try:
frequency = float(frequency)
if not (1 <= frequency <= 500):
return jsonify({
'status': 'error',
'message': 'Frequency must be between 1-500 MHz (HF requires upconverter for RTL-SDR)',
}), 400
except (TypeError, ValueError):
return jsonify({
'status': 'error',
'message': 'Invalid frequency',
}), 400
# Auto-detect modulation from frequency table if not specified
if not modulation:
modulation = _FREQ_MODULATION_MAP.get(frequency, 'usb')
# Validate modulation
if modulation not in ('fm', 'usb', 'lsb'):
return jsonify({
'status': 'error',
'message': 'Modulation must be fm, usb, or lsb',
}), 400
# Set callback and start
decoder.set_callback(_progress_callback)
success = decoder.start(
frequency=frequency,
device_index=device_index,
modulation=modulation,
)
if success:
return jsonify({
'status': 'started',
'frequency': frequency,
'modulation': modulation,
'device': device_index,
})
else:
return jsonify({
'status': 'error',
'message': 'Failed to start decoder',
}), 500
@sstv_general_bp.route('/stop', methods=['POST'])
def stop_decoder():
"""Stop general SSTV decoder."""
decoder = get_general_sstv_decoder()
decoder.stop()
return jsonify({'status': 'stopped'})
@sstv_general_bp.route('/images')
def list_images():
"""Get list of decoded SSTV images."""
decoder = get_general_sstv_decoder()
images = decoder.get_images()
limit = request.args.get('limit', type=int)
if limit and limit > 0:
images = images[-limit:]
return jsonify({
'status': 'ok',
'images': [img.to_dict() for img in images],
'count': len(images),
})
@sstv_general_bp.route('/images/<filename>')
def get_image(filename: str):
"""Get a decoded SSTV image file."""
decoder = get_general_sstv_decoder()
# Security: only allow alphanumeric filenames with .png extension
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
if not filename.endswith('.png'):
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
image_path = decoder._output_dir / filename
if not image_path.exists():
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
return send_file(image_path, mimetype='image/png')
@sstv_general_bp.route('/images/<filename>/download')
def download_image(filename: str):
"""Download a decoded SSTV image file."""
decoder = get_general_sstv_decoder()
# Security: only allow alphanumeric filenames with .png extension
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
if not filename.endswith('.png'):
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
image_path = decoder._output_dir / filename
if not image_path.exists():
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
return send_file(image_path, mimetype='image/png', as_attachment=True, download_name=filename)
@sstv_general_bp.route('/images/<filename>', methods=['DELETE'])
def delete_image(filename: str):
"""Delete a decoded SSTV image."""
decoder = get_general_sstv_decoder()
# Security: only allow alphanumeric filenames with .png extension
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
if not filename.endswith('.png'):
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
if decoder.delete_image(filename):
return jsonify({'status': 'ok'})
else:
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
@sstv_general_bp.route('/images', methods=['DELETE'])
def delete_all_images():
"""Delete all decoded SSTV images."""
decoder = get_general_sstv_decoder()
count = decoder.delete_all_images()
return jsonify({'status': 'ok', 'deleted': count})
@sstv_general_bp.route('/stream')
def stream_progress():
"""SSE stream of SSTV decode progress."""
def generate() -> Generator[str, None, None]:
last_keepalive = time.time()
keepalive_interval = 30.0
while True:
try:
progress = _sstv_general_queue.get(timeout=1)
last_keepalive = time.time()
try:
process_event('sstv_general', progress, progress.get('type'))
except Exception:
pass
yield format_sse(progress)
except queue.Empty:
now = time.time()
if now - last_keepalive >= keepalive_interval:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response
@sstv_general_bp.route('/decode-file', methods=['POST'])
def decode_file():
"""Decode SSTV from an uploaded audio file."""
if 'audio' not in request.files:
return jsonify({
'status': 'error',
'message': 'No audio file provided',
}), 400
audio_file = request.files['audio']
if not audio_file.filename:
return jsonify({
'status': 'error',
'message': 'No file selected',
}), 400
import tempfile
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp:
audio_file.save(tmp.name)
tmp_path = tmp.name
try:
decoder = get_general_sstv_decoder()
images = decoder.decode_file(tmp_path)
return jsonify({
'status': 'ok',
'images': [img.to_dict() for img in images],
'count': len(images),
})
except Exception as e:
logger.error(f"Error decoding file: {e}")
return jsonify({
'status': 'error',
'message': str(e),
}), 500
finally:
try:
Path(tmp_path).unlink()
except Exception:
pass
+3290 -3958
View File
File diff suppressed because it is too large Load Diff
+2 -42
View File
@@ -2,15 +2,14 @@
from __future__ import annotations
from flask import Blueprint, Response, jsonify, request
from flask import Blueprint, jsonify, request, Response
from utils.logging import get_logger
from utils.updater import (
check_for_updates,
dismiss_update,
get_update_status,
dismiss_update,
perform_update,
restart_application,
)
logger = get_logger('intercept.routes.updater')
@@ -138,42 +137,3 @@ def dismiss_notification() -> Response:
'success': False,
'error': str(e)
}), 500
@updater_bp.route('/restart', methods=['POST'])
def restart_app() -> Response:
"""
Restart the application.
This endpoint triggers a graceful restart of the application:
1. Stops all running decoder processes
2. Cleans up global state
3. Replaces the current process with a fresh instance
The response may not be received by the client since the process
is replaced immediately. Clients should poll /health until the
server responds again.
Returns:
JSON with restart status (may not be delivered)
"""
import threading
logger.info("Restart requested via API")
# Send response before restarting
# Use a short delay to allow the response to be sent
def delayed_restart():
import time
time.sleep(0.5) # Allow response to be sent
restart_application()
# Start restart in a background thread so we can return a response
restart_thread = threading.Thread(target=delayed_restart, daemon=False)
restart_thread.start()
return jsonify({
'success': True,
'message': 'Application is restarting. Please wait...',
'action': 'restart'
})
-326
View File
@@ -1,326 +0,0 @@
"""WebSocket-based waterfall streaming with I/Q capture and server-side FFT."""
import json
import subprocess
import threading
import time
from flask import Flask
try:
from flask_sock import Sock
WEBSOCKET_AVAILABLE = True
except ImportError:
WEBSOCKET_AVAILABLE = False
Sock = None
from utils.logging import get_logger
from utils.process import safe_terminate, register_process, unregister_process
from utils.waterfall_fft import (
build_binary_frame,
compute_power_spectrum,
cu8_to_complex,
quantize_to_uint8,
)
from utils.sdr import SDRFactory, SDRType
from utils.sdr.base import SDRCapabilities, SDRDevice
logger = get_logger('intercept.waterfall_ws')
# Maximum bandwidth per SDR type (Hz)
MAX_BANDWIDTH = {
SDRType.RTL_SDR: 2400000,
SDRType.HACKRF: 20000000,
SDRType.LIME_SDR: 20000000,
SDRType.AIRSPY: 10000000,
SDRType.SDRPLAY: 2000000,
}
def _resolve_sdr_type(sdr_type_str: str) -> SDRType:
"""Convert client sdr_type string to SDRType enum."""
mapping = {
'rtlsdr': SDRType.RTL_SDR,
'rtl_sdr': SDRType.RTL_SDR,
'hackrf': SDRType.HACKRF,
'limesdr': SDRType.LIME_SDR,
'lime_sdr': SDRType.LIME_SDR,
'airspy': SDRType.AIRSPY,
'sdrplay': SDRType.SDRPLAY,
}
return mapping.get(sdr_type_str.lower(), SDRType.RTL_SDR)
def _build_dummy_device(device_index: int, sdr_type: SDRType) -> SDRDevice:
"""Build a minimal SDRDevice for command building."""
builder = SDRFactory.get_builder(sdr_type)
caps = builder.get_capabilities()
return SDRDevice(
sdr_type=sdr_type,
index=device_index,
name=f'{sdr_type.value}-{device_index}',
serial='N/A',
driver=sdr_type.value,
capabilities=caps,
)
def init_waterfall_websocket(app: Flask):
"""Initialize WebSocket waterfall streaming."""
if not WEBSOCKET_AVAILABLE:
logger.warning("flask-sock not installed, WebSocket waterfall disabled")
return
sock = Sock(app)
@sock.route('/ws/waterfall')
def waterfall_stream(ws):
"""WebSocket endpoint for real-time waterfall streaming."""
logger.info("WebSocket waterfall client connected")
# Import app module for device claiming
import app as app_module
iq_process = None
reader_thread = None
stop_event = threading.Event()
claimed_device = None
try:
while True:
try:
msg = ws.receive(timeout=0.1)
except TimeoutError:
if stop_event.is_set():
break
continue
except Exception as e:
if "closed" in str(e).lower():
break
if "timed out" not in str(e).lower():
logger.error(f"WebSocket receive error: {e}")
continue
if msg is None:
break
try:
data = json.loads(msg)
except (json.JSONDecodeError, TypeError):
continue
cmd = data.get('cmd')
if cmd == 'start':
# Stop any existing capture
stop_event.set()
if reader_thread and reader_thread.is_alive():
reader_thread.join(timeout=2)
if iq_process:
safe_terminate(iq_process)
unregister_process(iq_process)
iq_process = None
if claimed_device is not None:
app_module.release_sdr_device(claimed_device)
claimed_device = None
stop_event.clear()
# Parse config
center_freq = float(data.get('center_freq', 100.0))
span_mhz = float(data.get('span_mhz', 2.0))
gain = data.get('gain')
if gain is not None:
gain = float(gain)
device_index = int(data.get('device', 0))
sdr_type_str = data.get('sdr_type', 'rtlsdr')
fft_size = int(data.get('fft_size', 1024))
fps = int(data.get('fps', 25))
avg_count = int(data.get('avg_count', 4))
ppm = data.get('ppm')
if ppm is not None:
ppm = int(ppm)
bias_t = bool(data.get('bias_t', False))
# Clamp FFT size to valid powers of 2
fft_size = max(256, min(8192, fft_size))
# Resolve SDR type and bandwidth
sdr_type = _resolve_sdr_type(sdr_type_str)
max_bw = MAX_BANDWIDTH.get(sdr_type, 2400000)
span_hz = int(span_mhz * 1e6)
sample_rate = min(span_hz, max_bw)
# Compute effective frequency range
effective_span_mhz = sample_rate / 1e6
start_freq = center_freq - effective_span_mhz / 2
end_freq = center_freq + effective_span_mhz / 2
# Claim the device
claim_err = app_module.claim_sdr_device(device_index, 'waterfall')
if claim_err:
ws.send(json.dumps({
'status': 'error',
'message': claim_err,
'error_type': 'DEVICE_BUSY',
}))
continue
claimed_device = device_index
# Build I/Q capture command
try:
builder = SDRFactory.get_builder(sdr_type)
device = _build_dummy_device(device_index, sdr_type)
iq_cmd = builder.build_iq_capture_command(
device=device,
frequency_mhz=center_freq,
sample_rate=sample_rate,
gain=gain,
ppm=ppm,
bias_t=bias_t,
)
except NotImplementedError as e:
app_module.release_sdr_device(device_index)
claimed_device = None
ws.send(json.dumps({
'status': 'error',
'message': str(e),
}))
continue
# Spawn I/Q capture process
try:
logger.info(
f"Starting I/Q capture: {center_freq} MHz, "
f"span={effective_span_mhz:.1f} MHz, "
f"sr={sample_rate}, fft={fft_size}"
)
iq_process = subprocess.Popen(
iq_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
bufsize=0,
)
register_process(iq_process)
# Brief check that process started
time.sleep(0.2)
if iq_process.poll() is not None:
raise RuntimeError("I/Q capture process exited immediately")
except Exception as e:
logger.error(f"Failed to start I/Q capture: {e}")
if iq_process:
safe_terminate(iq_process)
unregister_process(iq_process)
iq_process = None
app_module.release_sdr_device(device_index)
claimed_device = None
ws.send(json.dumps({
'status': 'error',
'message': f'Failed to start I/Q capture: {e}',
}))
continue
# Send started confirmation
ws.send(json.dumps({
'status': 'started',
'start_freq': start_freq,
'end_freq': end_freq,
'fft_size': fft_size,
'sample_rate': sample_rate,
}))
# Start reader thread
def fft_reader(
proc, ws_ref, stop_evt,
_fft_size, _avg_count, _fps,
_start_freq, _end_freq,
):
"""Read I/Q from subprocess, compute FFT, send binary frames."""
bytes_per_frame = _fft_size * _avg_count * 2
frame_interval = 1.0 / _fps
try:
while not stop_evt.is_set():
if proc.poll() is not None:
break
frame_start = time.monotonic()
# Read raw I/Q bytes
raw = b''
remaining = bytes_per_frame
while remaining > 0 and not stop_evt.is_set():
chunk = proc.stdout.read(min(remaining, 65536))
if not chunk:
break
raw += chunk
remaining -= len(chunk)
if len(raw) < _fft_size * 2:
break
# Process FFT pipeline
samples = cu8_to_complex(raw)
power_db = compute_power_spectrum(
samples,
fft_size=_fft_size,
avg_count=_avg_count,
)
quantized = quantize_to_uint8(power_db)
frame = build_binary_frame(
_start_freq, _end_freq, quantized,
)
try:
ws_ref.send(frame)
except Exception:
break
# Pace to target FPS
elapsed = time.monotonic() - frame_start
sleep_time = frame_interval - elapsed
if sleep_time > 0:
stop_evt.wait(sleep_time)
except Exception as e:
logger.debug(f"FFT reader stopped: {e}")
reader_thread = threading.Thread(
target=fft_reader,
args=(
iq_process, ws, stop_event,
fft_size, avg_count, fps,
start_freq, end_freq,
),
daemon=True,
)
reader_thread.start()
elif cmd == 'stop':
stop_event.set()
if reader_thread and reader_thread.is_alive():
reader_thread.join(timeout=2)
reader_thread = None
if iq_process:
safe_terminate(iq_process)
unregister_process(iq_process)
iq_process = None
if claimed_device is not None:
app_module.release_sdr_device(claimed_device)
claimed_device = None
stop_event.clear()
ws.send(json.dumps({'status': 'stopped'}))
except Exception as e:
logger.info(f"WebSocket waterfall closed: {e}")
finally:
# Cleanup
stop_event.set()
if reader_thread and reader_thread.is_alive():
reader_thread.join(timeout=2)
if iq_process:
safe_terminate(iq_process)
unregister_process(iq_process)
if claimed_device is not None:
app_module.release_sdr_device(claimed_device)
logger.info("WebSocket waterfall client disconnected")
-504
View File
@@ -1,504 +0,0 @@
"""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")
+37 -260
View File
@@ -17,12 +17,11 @@ from flask import Blueprint, jsonify, request, Response
import app as app_module
from utils.dependencies import check_tool, get_tool_path
from utils.logging import wifi_logger as logger
from utils.process import is_valid_mac, is_valid_channel
from utils.validation import validate_wifi_channel, validate_mac_address, validate_network_interface
from utils.sse import format_sse
from utils.event_pipeline import process_event
from data.oui import get_manufacturer
from utils.logging import wifi_logger as logger
from utils.process import is_valid_mac, is_valid_channel
from utils.validation import validate_wifi_channel, validate_mac_address, validate_network_interface
from utils.sse import format_sse
from data.oui import get_manufacturer
from utils.constants import (
WIFI_TERMINATE_TIMEOUT,
PMKID_TERMINATE_TIMEOUT,
@@ -47,33 +46,8 @@ from utils.constants import (
wifi_bp = Blueprint('wifi', __name__, url_prefix='/wifi')
# PMKID process state
pmkid_process = None
pmkid_lock = threading.Lock()
def _parse_channel_list(raw_channels: Any) -> list[int] | None:
"""Parse a channel list from string/list input."""
if raw_channels in (None, '', []):
return None
if isinstance(raw_channels, str):
parts = [p.strip() for p in re.split(r'[\s,]+', raw_channels) if p.strip()]
elif isinstance(raw_channels, (list, tuple, set)):
parts = list(raw_channels)
else:
parts = [raw_channels]
channels: list[int] = []
seen = set()
for part in parts:
if part in (None, ''):
continue
ch = validate_wifi_channel(part)
if ch not in seen:
channels.append(ch)
seen.add(ch)
return channels or None
pmkid_process = None
pmkid_lock = threading.Lock()
def detect_wifi_interfaces():
@@ -633,9 +607,8 @@ def start_wifi_scan():
return jsonify({'status': 'error', 'message': 'Scan already running'})
data = request.json
channel = data.get('channel')
channels = data.get('channels')
band = data.get('band', 'abg')
channel = data.get('channel')
band = data.get('band', 'abg')
# Use provided interface or fall back to stored monitor interface
interface = data.get('interface')
@@ -685,17 +658,8 @@ def start_wifi_scan():
interface
]
channel_list = None
if channels:
try:
channel_list = _parse_channel_list(channels)
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
if channel_list:
cmd.extend(['-c', ','.join(str(c) for c in channel_list)])
elif channel:
cmd.extend(['-c', str(channel)])
if channel:
cmd.extend(['-c', str(channel)])
logger.info(f"Running: {' '.join(cmd)}")
@@ -887,53 +851,32 @@ def check_handshake_status():
return jsonify({'status': 'stopped', 'file_exists': False, 'handshake_found': False})
file_size = os.path.getsize(capture_file)
handshake_found = False
handshake_valid: bool | None = None
handshake_checked = False
handshake_reason: str | None = None
handshake_found = False
try:
if target_bssid and is_valid_mac(target_bssid):
aircrack_path = get_tool_path('aircrack-ng')
if aircrack_path:
result = subprocess.run(
[aircrack_path, '-a', '2', '-b', target_bssid, capture_file],
capture_output=True, text=True, timeout=10
)
output = result.stdout + result.stderr
output_lower = output.lower()
handshake_checked = True
if 'no valid wpa handshakes found' in output_lower:
handshake_valid = False
handshake_reason = 'No valid WPA handshake found'
elif '0 handshake' in output_lower:
handshake_valid = False
elif '1 handshake' in output_lower or ('handshake' in output_lower and 'wpa' in output_lower):
handshake_valid = True
else:
handshake_valid = False
if target_bssid and is_valid_mac(target_bssid):
aircrack_path = get_tool_path('aircrack-ng')
if aircrack_path:
result = subprocess.run(
[aircrack_path, '-a', '2', '-b', target_bssid, capture_file],
capture_output=True, text=True, timeout=10
)
output = result.stdout + result.stderr
if '1 handshake' in output or ('handshake' in output.lower() and 'wpa' in output.lower()):
if '0 handshake' not in output:
handshake_found = True
except subprocess.TimeoutExpired:
pass
except Exception as e:
logger.error(f"Error checking handshake: {e}")
if handshake_valid:
handshake_found = True
normalized_bssid = target_bssid.upper() if target_bssid else None
if normalized_bssid and normalized_bssid not in app_module.wifi_handshakes:
app_module.wifi_handshakes.append(normalized_bssid)
return jsonify({
'status': 'running' if app_module.wifi_process and app_module.wifi_process.poll() is None else 'stopped',
'file_exists': True,
'file_size': file_size,
'file': capture_file,
'handshake_found': handshake_found,
'handshake_valid': handshake_valid,
'handshake_checked': handshake_checked,
'handshake_reason': handshake_reason
})
except Exception as e:
logger.error(f"Error checking handshake: {e}")
return jsonify({
'status': 'running' if app_module.wifi_process and app_module.wifi_process.poll() is None else 'stopped',
'file_exists': True,
'file_size': file_size,
'file': capture_file,
'handshake_found': handshake_found
})
@wifi_bp.route('/pmkid/capture', methods=['POST'])
@@ -1141,13 +1084,9 @@ def stream_wifi():
while True:
try:
msg = app_module.wifi_queue.get(timeout=1)
last_keepalive = time.time()
try:
process_event('wifi', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg)
msg = app_module.wifi_queue.get(timeout=1)
last_keepalive = time.time()
yield format_sse(msg)
except queue.Empty:
now = time.time()
if now - last_keepalive >= keepalive_interval:
@@ -1301,32 +1240,10 @@ def v2_get_networks():
@wifi_bp.route('/v2/clients')
def v2_get_clients():
"""Get discovered clients with optional filtering."""
"""Get all discovered clients."""
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),
@@ -1496,143 +1413,3 @@ def v2_clear_data():
except Exception as e:
logger.exception("Error clearing data")
return jsonify({'error': str(e)}), 500
# =============================================================================
# V2 Deauth Detection Endpoints
# =============================================================================
@wifi_bp.route('/v2/deauth/status')
def v2_deauth_status():
"""
Get deauth detection status and recent alerts.
Returns:
- is_running: Whether deauth detector is active
- interface: Monitor interface being used
- stats: Detection statistics
- recent_alerts: Recent deauth alerts
"""
try:
scanner = get_wifi_scanner()
detector = scanner.deauth_detector
if detector:
stats = detector.stats
alerts = detector.get_alerts(limit=50)
else:
stats = {
'is_running': False,
'interface': None,
'packets_captured': 0,
'alerts_generated': 0,
}
alerts = []
return jsonify({
'is_running': stats.get('is_running', False),
'interface': stats.get('interface'),
'started_at': stats.get('started_at'),
'stats': {
'packets_captured': stats.get('packets_captured', 0),
'alerts_generated': stats.get('alerts_generated', 0),
'active_trackers': stats.get('active_trackers', 0),
},
'recent_alerts': alerts,
})
except Exception as e:
logger.exception("Error getting deauth status")
return jsonify({'error': str(e)}), 500
@wifi_bp.route('/v2/deauth/stream')
def v2_deauth_stream():
"""
SSE stream for real-time deauth alerts.
Events:
- deauth_alert: A deauth attack was detected
- deauth_detector_started: Detector started
- deauth_detector_stopped: Detector stopped
- deauth_error: An error occurred
- keepalive: Periodic keepalive
"""
def generate():
last_keepalive = time.time()
keepalive_interval = SSE_KEEPALIVE_INTERVAL
while True:
try:
# Try to get from the dedicated deauth queue
msg = app_module.deauth_detector_queue.get(timeout=SSE_QUEUE_TIMEOUT)
last_keepalive = time.time()
yield format_sse(msg)
except queue.Empty:
now = time.time()
if now - last_keepalive >= keepalive_interval:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response
@wifi_bp.route('/v2/deauth/alerts')
def v2_deauth_alerts():
"""
Get historical deauth alerts.
Query params:
- limit: Maximum number of alerts to return (default 100)
"""
try:
limit = request.args.get('limit', 100, type=int)
limit = max(1, min(limit, 1000)) # Clamp between 1 and 1000
scanner = get_wifi_scanner()
alerts = scanner.get_deauth_alerts(limit=limit)
# Also include alerts from DataStore that might have been persisted
try:
stored_alerts = list(app_module.deauth_alerts.values())
# Merge and deduplicate by ID
alert_ids = {a.get('id') for a in alerts}
for alert in stored_alerts:
if alert.get('id') not in alert_ids:
alerts.append(alert)
# Sort by timestamp descending
alerts.sort(key=lambda a: a.get('timestamp', 0), reverse=True)
alerts = alerts[:limit]
except Exception:
pass
return jsonify({
'alerts': alerts,
'count': len(alerts),
})
except Exception as e:
logger.exception("Error getting deauth alerts")
return jsonify({'error': str(e)}), 500
@wifi_bp.route('/v2/deauth/clear', methods=['POST'])
def v2_deauth_clear():
"""Clear deauth alert history."""
try:
scanner = get_wifi_scanner()
scanner.clear_deauth_alerts()
# Clear the queue
while not app_module.deauth_detector_queue.empty():
try:
app_module.deauth_detector_queue.get_nowait()
except queue.Empty:
break
return jsonify({'status': 'cleared'})
except Exception as e:
logger.exception("Error clearing deauth alerts")
return jsonify({'error': str(e)}), 500
+28 -50
View File
@@ -16,16 +16,14 @@ from typing import Generator
from flask import Blueprint, jsonify, request, Response
from utils.wifi import (
get_wifi_scanner,
analyze_channels,
get_hidden_correlator,
SCAN_MODE_QUICK,
SCAN_MODE_DEEP,
)
from utils.sse import format_sse
from utils.validation import validate_wifi_channel
from utils.event_pipeline import process_event
from utils.wifi import (
get_wifi_scanner,
analyze_channels,
get_hidden_correlator,
SCAN_MODE_QUICK,
SCAN_MODE_DEEP,
)
from utils.sse import format_sse
logger = logging.getLogger(__name__)
@@ -87,44 +85,28 @@ def start_deep_scan():
Requires monitor mode interface and root privileges.
Request body:
interface: Monitor mode interface (e.g., 'wlan0mon')
band: Band to scan ('2.4', '5', 'all')
channel: Optional specific channel to monitor
channels: Optional list or comma-separated channels to monitor
Request body:
interface: Monitor mode interface (e.g., 'wlan0mon')
band: Band to scan ('2.4', '5', 'all')
channel: Optional specific channel to monitor
"""
data = request.get_json() or {}
interface = data.get('interface')
band = data.get('band', 'all')
channel = data.get('channel')
channels = data.get('channels')
channel_list = None
if channels:
if isinstance(channels, str):
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
elif isinstance(channels, (list, tuple, set)):
channel_list = list(channels)
else:
channel_list = [channels]
try:
channel_list = [validate_wifi_channel(c) for c in channel_list]
except (TypeError, ValueError):
return jsonify({'error': 'Invalid channels'}), 400
if channel:
try:
channel = validate_wifi_channel(channel)
except ValueError:
return jsonify({'error': 'Invalid channel'}), 400
channel = data.get('channel')
if channel:
try:
channel = int(channel)
except ValueError:
return jsonify({'error': 'Invalid channel'}), 400
scanner = get_wifi_scanner()
success = scanner.start_deep_scan(
interface=interface,
band=band,
channel=channel,
channels=channel_list,
)
success = scanner.start_deep_scan(
interface=interface,
band=band,
channel=channel,
)
if success:
return jsonify({
@@ -406,14 +388,10 @@ def event_stream():
- keepalive: Periodic keepalive
"""
def generate() -> Generator[str, None, None]:
scanner = get_wifi_scanner()
for event in scanner.get_event_stream():
try:
process_event('wifi', event, event.get('type'))
except Exception:
pass
yield format_sse(event)
scanner = get_wifi_scanner()
for event in scanner.get_event_stream():
yield format_sse(event)
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
+20 -242
View File
@@ -204,14 +204,11 @@ check_tools() {
check_required "dump1090" "ADS-B decoder" dump1090
check_required "acarsdec" "ACARS decoder" acarsdec
check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher
echo
info "GPS:"
check_required "gpsd" "GPS daemon" gpsd
echo
info "Digital Voice:"
check_optional "dsd" "Digital Speech Decoder (DMR/P25)" dsd dsd-fme
echo
info "Audio:"
check_required "ffmpeg" "Audio encoder/decoder" ffmpeg
@@ -388,7 +385,6 @@ install_rtlamr_from_source() {
fi
}
install_multimon_ng_from_source_macos() {
info "multimon-ng not available via Homebrew. Building from source..."
@@ -420,192 +416,8 @@ 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=17
TOTAL_STEPS=15
CURRENT_STEP=0
progress "Checking Homebrew"
@@ -625,22 +437,6 @@ install_macos_packages() {
progress "Installing direwolf (APRS decoder)"
(brew_install direwolf) || warn "direwolf not available via Homebrew"
progress "SSTV decoder"
ok "SSTV uses built-in pure Python decoder (no external tools needed)"
progress "Installing DSD (Digital Speech Decoder, optional)"
if ! cmd_exists dsd && ! cmd_exists dsd-fme; then
echo
info "DSD is used for DMR, P25, NXDN, and D-STAR digital voice decoding."
if ask_yes_no "Do you want to install DSD?"; then
install_dsd_from_source || warn "DSD build failed. DMR/P25 decoding will not be available."
else
warn "Skipping DSD installation. DMR/P25 decoding will not be available."
fi
else
ok "DSD already installed"
fi
progress "Installing ffmpeg"
brew_install ffmpeg
@@ -662,22 +458,14 @@ install_macos_packages() {
fi
progress "Installing dump1090"
if ! cmd_exists dump1090; then
(brew_install dump1090-mutability) || install_dump1090_from_source_macos || warn "dump1090 not available"
else
ok "dump1090 already installed"
fi
(brew_install dump1090-mutability) || warn "dump1090 not available via Homebrew"
progress "Installing acarsdec"
if ! cmd_exists acarsdec; then
(brew_install acarsdec) || install_acarsdec_from_source_macos || warn "acarsdec not available"
else
ok "acarsdec already installed"
fi
(brew_install acarsdec) || warn "acarsdec not available via Homebrew"
progress "Installing AIS-catcher"
if ! cmd_exists AIS-catcher && ! cmd_exists aiscatcher; then
(brew_install aiscatcher) || install_aiscatcher_from_source_macos || warn "AIS-catcher not available"
(brew_install aiscatcher) || warn "AIS-catcher not available via Homebrew"
else
ok "AIS-catcher already installed"
fi
@@ -979,7 +767,7 @@ install_debian_packages() {
export NEEDRESTART_MODE=a
fi
TOTAL_STEPS=22
TOTAL_STEPS=20
CURRENT_STEP=0
progress "Updating APT package lists"
@@ -1023,9 +811,19 @@ install_debian_packages() {
progress "RTL-SDR Blog drivers"
if cmd_exists rtl_test; then
ok "RTL-SDR drivers already installed"
info "RTL-SDR tools already installed."
if $IS_DRAGONOS; then
info "Skipping RTL-SDR Blog driver installation (DragonOS has working drivers)."
else
echo "RTL-SDR Blog drivers provide improved support for V4 dongles."
echo "Installing these will REPLACE your current RTL-SDR drivers."
if ask_yes_no "Install RTL-SDR Blog drivers?"; then
install_rtlsdr_blog_drivers_debian
else
ok "Keeping existing RTL-SDR drivers."
fi
fi
else
info "RTL-SDR drivers not found, installing RTL-SDR Blog drivers..."
install_rtlsdr_blog_drivers_debian
fi
@@ -1035,22 +833,6 @@ install_debian_packages() {
progress "Installing direwolf (APRS decoder)"
apt_install direwolf || true
progress "SSTV decoder"
ok "SSTV uses built-in pure Python decoder (no external tools needed)"
progress "Installing DSD (Digital Speech Decoder, optional)"
if ! cmd_exists dsd && ! cmd_exists dsd-fme; then
echo
info "DSD is used for DMR, P25, NXDN, and D-STAR digital voice decoding."
if ask_yes_no "Do you want to install DSD?"; then
install_dsd_from_source || warn "DSD build failed. DMR/P25 decoding will not be available."
else
warn "Skipping DSD installation. DMR/P25 decoding will not be available."
fi
else
ok "DSD already installed"
fi
progress "Installing ffmpeg"
apt_install ffmpeg
@@ -1140,12 +922,11 @@ install_debian_packages() {
setup_udev_rules_debian
progress "Kernel driver configuration"
echo
if $IS_DRAGONOS; then
info "DragonOS already has RTL-SDR drivers configured correctly."
elif [[ -f /etc/modprobe.d/blacklist-rtlsdr.conf ]]; then
ok "DVB kernel drivers already blacklisted"
info "Skipping kernel driver blacklist (not needed)."
else
echo
echo "The DVB-T kernel drivers conflict with RTL-SDR userspace access."
echo "Blacklisting them allows rtl_sdr tools to access the device."
if ask_yes_no "Blacklist conflicting kernel drivers?"; then
@@ -1235,7 +1016,4 @@ main() {
}
main "$@"
# Clear traps before exiting to prevent spurious errors during cleanup
trap - ERR EXIT
exit 0
+55 -140
View File
@@ -1,3 +1,11 @@
/**
* ADSB Dashboard Styles
* Imports design tokens for consistency, with dashboard-specific overrides
*/
@import url('./core/variables.css');
@import url('./core/layout.css');
@import url('./core/components.css');
* {
margin: 0;
padding: 0;
@@ -5,30 +13,18 @@
}
:root {
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
--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;
/* Dashboard-specific aliases (for backward compatibility) */
--bg-dark: var(--bg-primary);
--bg-panel: var(--bg-secondary);
--bg-card: var(--bg-tertiary);
/* Use tokens for shared values, keep custom values for dashboard-specific */
--grid-line: rgba(74, 158, 255, 0.08);
--radar-cyan: var(--accent-cyan);
--radar-bg: var(--bg-secondary);
}
body {
font-family: var(--font-sans);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-dark);
color: var(--text-primary);
min-height: 100vh;
@@ -43,10 +39,9 @@ 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: 40px 40px, 50px 50px, 50px 50px;
background-size: 50px 50px;
pointer-events: none;
z-index: 0;
}
@@ -59,12 +54,10 @@ 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.25;
box-shadow: 0 0 8px currentColor;
opacity: 0.3;
}
@keyframes scan {
@@ -77,7 +70,7 @@ body {
}
}
/* Header */
/* Header - Mobile first */
.header {
position: relative;
z-index: 10;
@@ -87,31 +80,20 @@ body {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: nowrap;
gap: 12px;
flex-wrap: wrap;
gap: 8px;
min-height: 52px;
}
.header::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
opacity: 0.6;
pointer-events: none;
}
@media (min-width: 768px) {
.header {
padding: 12px 20px;
flex-wrap: nowrap;
}
}
.logo {
font-family: var(--font-sans);
font-family: 'Inter', sans-serif;
font-size: 16px;
font-weight: 700;
letter-spacing: 2px;
@@ -143,52 +125,14 @@ body {
letter-spacing: 2px;
}
}
}
.status-bar {
display: flex;
gap: 12px;
gap: 20px;
align-items: center;
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
flex-wrap: nowrap;
}
.agent-selector-compact {
display: flex;
align-items: center;
gap: 8px;
}
.agent-selector-compact .agent-select-sm {
padding: 4px 8px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-primary);
font-size: 11px;
font-family: var(--font-mono);
}
.agent-selector-compact .agent-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent-green);
box-shadow: 0 0 6px var(--accent-green);
}
.agent-selector-compact .agent-status-dot.offline {
background: var(--accent-red);
box-shadow: 0 0 6px var(--accent-red);
}
.agent-selector-compact .show-all-label {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--text-secondary);
cursor: pointer;
}
.status-dot {
@@ -227,15 +171,15 @@ body {
}
/* Main dashboard grid - Mobile first */
/* Header ~52px + Nav 44px + Stats strip ~55px = ~151px, using 160px for safety */
/* Header ~55px + Stats strip ~55px = ~110px, using 115px for safety */
.dashboard {
position: relative;
z-index: 10;
display: flex;
flex-direction: column;
gap: 0;
height: calc(100dvh - 160px);
height: calc(100vh - 160px); /* Fallback */
height: calc(100dvh - 115px);
height: calc(100vh - 115px); /* Fallback */
min-height: 400px;
}
@@ -271,7 +215,7 @@ body {
@media (min-width: 1024px) {
.acars-sidebar {
display: flex;
max-height: calc(100dvh - 160px);
max-height: calc(100dvh - 115px);
}
}
@@ -679,7 +623,7 @@ body {
}
.telemetry-value {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
color: var(--accent-cyan);
}
@@ -735,7 +679,7 @@ body {
}
.aircraft-icao {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
color: var(--text-secondary);
background: rgba(74, 158, 255, 0.1);
@@ -755,7 +699,7 @@ body {
}
.aircraft-detail-value {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
color: var(--accent-cyan);
font-size: 11px;
}
@@ -771,31 +715,14 @@ body {
grid-column: 1 / -1;
grid-row: 2;
display: flex;
align-items: stretch !important;
align-items: center;
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: 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;
overflow-x: auto;
}
.controls-bar label {
@@ -862,7 +789,7 @@ body {
border: 1px solid rgba(74, 158, 255, 0.3);
border-radius: 4px;
color: var(--accent-cyan);
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
}
@@ -873,7 +800,7 @@ body {
border: 1px solid rgba(74, 158, 255, 0.3);
border-radius: 4px;
color: var(--accent-cyan);
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
}
@@ -886,24 +813,6 @@ body {
color: var(--accent-green);
}
/* Bias-T toggle styling */
.bias-t-label {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
background: linear-gradient(90deg, rgba(255, 100, 0, 0.15), rgba(255, 100, 0, 0.05));
border: 1px solid var(--accent-orange, #ff6400);
border-radius: 4px;
color: var(--accent-orange, #ff6400);
font-weight: 500;
font-size: 10px;
}
.bias-t-label input[type="checkbox"] {
accent-color: var(--accent-orange, #ff6400);
}
.control-group.airband-group {
background: rgba(245, 158, 11, 0.05);
border-color: rgba(245, 158, 11, 0.2);
@@ -951,7 +860,7 @@ body {
border: none;
background: var(--accent-green);
color: #fff;
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
@@ -983,7 +892,7 @@ body {
border: 1px solid rgba(74, 158, 255, 0.3);
border-radius: 4px;
color: var(--accent-cyan);
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
cursor: pointer;
}
@@ -1095,7 +1004,7 @@ body {
cursor: pointer;
font-size: 11px;
font-weight: 600;
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
display: flex;
align-items: center;
gap: 5px;
@@ -1129,7 +1038,7 @@ body {
}
.airband-status {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
padding: 0 8px;
color: var(--text-muted);
@@ -1289,7 +1198,7 @@ body {
display: flex !important;
flex-direction: column !important;
height: auto !important;
min-height: calc(100dvh - 160px);
min-height: calc(100dvh - 115px);
overflow-y: auto !important;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
@@ -1358,6 +1267,12 @@ body {
padding: 6px 8px;
}
/* Status bar - compact on mobile */
.status-bar {
flex-wrap: wrap;
gap: 6px;
}
/* Strip time smaller on mobile */
.strip-time {
font-size: 10px;
@@ -1473,7 +1388,7 @@ body {
}
.strip-value {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
font-weight: 600;
color: var(--accent-cyan);
@@ -1499,7 +1414,7 @@ body {
}
.strip-report-btn {
background: linear-gradient(135deg, var(--accent-cyan) 0%, #2b6fb8 100%);
background: linear-gradient(135deg, var(--accent-cyan) 0%, #2563eb 100%);
border: none;
color: white;
padding: 8px 12px;
@@ -1611,7 +1526,7 @@ body {
.report-grid span:nth-child(even) {
color: var(--text-primary);
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
}
.report-highlights {
@@ -1802,7 +1717,7 @@ body {
}
.strip-btn.primary {
background: linear-gradient(135deg, var(--accent-cyan) 0%, #2b6fb8 100%);
background: linear-gradient(135deg, var(--accent-cyan) 0%, #2563eb 100%);
border: none;
color: white;
}
@@ -1850,7 +1765,7 @@ body {
font-size: 11px;
font-weight: 500;
color: var(--accent-cyan);
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
padding-left: 8px;
border-left: 1px solid rgba(74, 158, 255, 0.2);
white-space: nowrap;
@@ -2004,7 +1919,7 @@ body {
}
.squawk-code {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-weight: 700;
color: var(--accent-cyan);
font-size: 12px;
+20 -38
View File
@@ -5,42 +5,38 @@
}
:root {
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
--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");
--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);
}
body {
font-family: var(--font-sans);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-dark);
color: var(--text-primary);
min-height: 100vh;
}
.mono {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
}
.radar-bg {
position: fixed;
inset: 0;
background-image:
var(--noise-image),
linear-gradient(var(--grid-line) 1px, transparent 1px),
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
background-size: 40px 40px, 50px 50px, 50px 50px;
background-size: 50px 50px;
pointer-events: none;
z-index: 0;
}
@@ -52,12 +48,10 @@ 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.25;
box-shadow: 0 0 8px currentColor;
opacity: 0.3;
}
@keyframes scan {
@@ -78,18 +72,6 @@ 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;
@@ -109,7 +91,7 @@ body {
display: flex;
align-items: center;
gap: 12px;
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
}
@@ -286,7 +268,7 @@ body {
}
.status-pill {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
padding: 8px 12px;
border-radius: 999px;
@@ -324,7 +306,7 @@ body {
}
.panel-meta {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--accent-cyan);
}
@@ -365,7 +347,7 @@ body {
}
.mono {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
}
.empty-row td,
+343 -331
View File
@@ -1,331 +1,343 @@
/*
* Agents Management CSS
* Styles for the remote agent management interface
* Inherits CSS variables from core/variables.css
*/
/* Agent indicator in navigation */
.agent-indicator {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: rgba(0, 212, 255, 0.1);
border: 1px solid rgba(0, 212, 255, 0.3);
border-radius: 20px;
cursor: pointer;
transition: all 0.2s;
}
.agent-indicator:hover {
background: rgba(0, 212, 255, 0.2);
border-color: var(--accent-cyan);
}
.agent-indicator-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent-green);
box-shadow: 0 0 6px var(--accent-green);
}
.agent-indicator-dot.remote {
background: var(--accent-cyan);
box-shadow: 0 0 6px var(--accent-cyan);
}
.agent-indicator-dot.multiple {
background: var(--accent-orange);
box-shadow: 0 0 6px var(--accent-orange);
}
.agent-indicator-label {
font-size: 11px;
color: var(--text-primary);
font-family: var(--font-mono);
}
.agent-indicator-count {
font-size: 10px;
padding: 2px 6px;
background: rgba(0, 212, 255, 0.2);
border-radius: 10px;
color: var(--accent-cyan);
}
/* Agent selector dropdown */
.agent-selector {
position: relative;
}
.agent-selector-dropdown {
position: absolute;
top: 100%;
right: 0;
margin-top: 8px;
min-width: 280px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
z-index: 1000;
display: none;
}
.agent-selector-dropdown.show {
display: block;
}
.agent-selector-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 15px;
border-bottom: 1px solid var(--border-color);
}
.agent-selector-header h4 {
margin: 0;
font-size: 12px;
color: var(--accent-cyan);
text-transform: uppercase;
letter-spacing: 1px;
}
.agent-selector-manage {
font-size: 11px;
color: var(--accent-cyan);
text-decoration: none;
}
.agent-selector-manage:hover {
text-decoration: underline;
}
.agent-selector-list {
max-height: 300px;
overflow-y: auto;
}
.agent-selector-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 15px;
cursor: pointer;
transition: background 0.2s;
border-bottom: 1px solid var(--border-color);
}
.agent-selector-item:last-child {
border-bottom: none;
}
.agent-selector-item:hover {
background: rgba(0, 212, 255, 0.1);
}
.agent-selector-item.selected {
background: rgba(0, 212, 255, 0.15);
border-left: 3px solid var(--accent-cyan);
}
.agent-selector-item.local {
border-left: 3px solid var(--accent-green);
}
.agent-selector-item-status {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.agent-selector-item-status.online {
background: var(--accent-green);
}
.agent-selector-item-status.offline {
background: var(--accent-red);
}
.agent-selector-item-info {
flex: 1;
min-width: 0;
}
.agent-selector-item-name {
font-size: 13px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.agent-selector-item-url {
font-size: 10px;
color: var(--text-secondary);
font-family: var(--font-mono);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.agent-selector-item-check {
color: var(--accent-green);
opacity: 0;
}
.agent-selector-item.selected .agent-selector-item-check {
opacity: 1;
}
/* Agent badge in data displays */
.agent-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
font-size: 10px;
background: rgba(0, 212, 255, 0.1);
color: var(--accent-cyan);
border-radius: 10px;
font-family: var(--font-mono);
}
.agent-badge.local,
.agent-badge.agent-local {
background: rgba(0, 255, 136, 0.1);
color: var(--accent-green);
}
.agent-badge.agent-remote {
background: rgba(0, 212, 255, 0.1);
color: var(--accent-cyan);
}
/* WiFi table agent column */
.wifi-networks-table .col-agent {
width: 100px;
text-align: center;
}
.wifi-networks-table th.col-agent {
font-size: 10px;
}
/* Bluetooth table agent column */
.bt-devices-table .col-agent {
width: 100px;
text-align: center;
}
.agent-badge-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
/* Agent column in data tables */
.data-table .agent-col {
width: 120px;
max-width: 120px;
}
/* Multi-agent stream indicator */
.multi-agent-indicator {
position: fixed;
bottom: 20px;
left: 20px;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 20px;
font-size: 11px;
color: var(--text-secondary);
z-index: 100;
}
.multi-agent-indicator.active {
border-color: var(--accent-cyan);
color: var(--accent-cyan);
}
.multi-agent-indicator-pulse {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent-cyan);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(0.8); }
}
/* Agent connection status toast */
.agent-toast {
position: fixed;
top: 80px;
right: 20px;
padding: 10px 15px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
font-size: 12px;
z-index: 1001;
animation: slideInRight 0.3s ease;
}
.agent-toast.connected {
border-color: var(--accent-green);
color: var(--accent-green);
}
.agent-toast.disconnected {
border-color: var(--accent-red);
color: var(--accent-red);
}
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Responsive adjustments */
@media (max-width: 768px) {
.agent-indicator {
padding: 4px 8px;
}
.agent-indicator-label {
display: none;
}
.agent-selector-dropdown {
position: fixed;
top: auto;
bottom: 0;
left: 0;
right: 0;
margin: 0;
border-radius: 16px 16px 0 0;
max-height: 60vh;
}
.agents-grid {
grid-template-columns: 1fr;
}
}
/*
* Agents Management CSS
* Styles for the remote agent management interface
*/
/* CSS Variables (inherited from main theme) */
:root {
--bg-primary: #0a0a0f;
--bg-secondary: #12121a;
--text-primary: #e0e0e0;
--text-secondary: #888;
--border-color: #1a1a2e;
--accent-cyan: #00d4ff;
--accent-green: #00ff88;
--accent-red: #ff3366;
--accent-orange: #ff9f1c;
}
/* Agent indicator in navigation */
.agent-indicator {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: rgba(0, 212, 255, 0.1);
border: 1px solid rgba(0, 212, 255, 0.3);
border-radius: 20px;
cursor: pointer;
transition: all 0.2s;
}
.agent-indicator:hover {
background: rgba(0, 212, 255, 0.2);
border-color: var(--accent-cyan);
}
.agent-indicator-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent-green);
box-shadow: 0 0 6px var(--accent-green);
}
.agent-indicator-dot.remote {
background: var(--accent-cyan);
box-shadow: 0 0 6px var(--accent-cyan);
}
.agent-indicator-dot.multiple {
background: var(--accent-orange);
box-shadow: 0 0 6px var(--accent-orange);
}
.agent-indicator-label {
font-size: 11px;
color: var(--text-primary);
font-family: 'JetBrains Mono', monospace;
}
.agent-indicator-count {
font-size: 10px;
padding: 2px 6px;
background: rgba(0, 212, 255, 0.2);
border-radius: 10px;
color: var(--accent-cyan);
}
/* Agent selector dropdown */
.agent-selector {
position: relative;
}
.agent-selector-dropdown {
position: absolute;
top: 100%;
right: 0;
margin-top: 8px;
min-width: 280px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
z-index: 1000;
display: none;
}
.agent-selector-dropdown.show {
display: block;
}
.agent-selector-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 15px;
border-bottom: 1px solid var(--border-color);
}
.agent-selector-header h4 {
margin: 0;
font-size: 12px;
color: var(--accent-cyan);
text-transform: uppercase;
letter-spacing: 1px;
}
.agent-selector-manage {
font-size: 11px;
color: var(--accent-cyan);
text-decoration: none;
}
.agent-selector-manage:hover {
text-decoration: underline;
}
.agent-selector-list {
max-height: 300px;
overflow-y: auto;
}
.agent-selector-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 15px;
cursor: pointer;
transition: background 0.2s;
border-bottom: 1px solid var(--border-color);
}
.agent-selector-item:last-child {
border-bottom: none;
}
.agent-selector-item:hover {
background: rgba(0, 212, 255, 0.1);
}
.agent-selector-item.selected {
background: rgba(0, 212, 255, 0.15);
border-left: 3px solid var(--accent-cyan);
}
.agent-selector-item.local {
border-left: 3px solid var(--accent-green);
}
.agent-selector-item-status {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.agent-selector-item-status.online {
background: var(--accent-green);
}
.agent-selector-item-status.offline {
background: var(--accent-red);
}
.agent-selector-item-info {
flex: 1;
min-width: 0;
}
.agent-selector-item-name {
font-size: 13px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.agent-selector-item-url {
font-size: 10px;
color: var(--text-secondary);
font-family: 'JetBrains Mono', monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.agent-selector-item-check {
color: var(--accent-green);
opacity: 0;
}
.agent-selector-item.selected .agent-selector-item-check {
opacity: 1;
}
/* Agent badge in data displays */
.agent-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
font-size: 10px;
background: rgba(0, 212, 255, 0.1);
color: var(--accent-cyan);
border-radius: 10px;
font-family: 'JetBrains Mono', monospace;
}
.agent-badge.local,
.agent-badge.agent-local {
background: rgba(0, 255, 136, 0.1);
color: var(--accent-green);
}
.agent-badge.agent-remote {
background: rgba(0, 212, 255, 0.1);
color: var(--accent-cyan);
}
/* WiFi table agent column */
.wifi-networks-table .col-agent {
width: 100px;
text-align: center;
}
.wifi-networks-table th.col-agent {
font-size: 10px;
}
/* Bluetooth table agent column */
.bt-devices-table .col-agent {
width: 100px;
text-align: center;
}
.agent-badge-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
/* Agent column in data tables */
.data-table .agent-col {
width: 120px;
max-width: 120px;
}
/* Multi-agent stream indicator */
.multi-agent-indicator {
position: fixed;
bottom: 20px;
left: 20px;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 20px;
font-size: 11px;
color: var(--text-secondary);
z-index: 100;
}
.multi-agent-indicator.active {
border-color: var(--accent-cyan);
color: var(--accent-cyan);
}
.multi-agent-indicator-pulse {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent-cyan);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(0.8); }
}
/* Agent connection status toast */
.agent-toast {
position: fixed;
top: 80px;
right: 20px;
padding: 10px 15px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
font-size: 12px;
z-index: 1001;
animation: slideInRight 0.3s ease;
}
.agent-toast.connected {
border-color: var(--accent-green);
color: var(--accent-green);
}
.agent-toast.disconnected {
border-color: var(--accent-red);
color: var(--accent-red);
}
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Responsive adjustments */
@media (max-width: 768px) {
.agent-indicator {
padding: 4px 8px;
}
.agent-indicator-label {
display: none;
}
.agent-selector-dropdown {
position: fixed;
top: auto;
bottom: 0;
left: 0;
right: 0;
margin: 0;
border-radius: 16px 16px 0 0;
max-height: 60vh;
}
.agents-grid {
grid-template-columns: 1fr;
}
}
+49 -128
View File
@@ -1,5 +1,10 @@
/* AIS Dashboard - Vessel Tracking Interface */
/* Styled to match ADSB Dashboard */
/**
* AIS Dashboard Styles - Vessel Tracking Interface
* Imports design tokens for consistency, with dashboard-specific overrides
*/
@import url('./core/variables.css');
@import url('./core/layout.css');
@import url('./core/components.css');
* {
margin: 0;
@@ -8,30 +13,18 @@
}
:root {
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
--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;
/* Dashboard-specific aliases (for backward compatibility) */
--bg-dark: var(--bg-primary);
--bg-panel: var(--bg-secondary);
--bg-card: var(--bg-tertiary);
/* Use tokens for shared values, keep custom values for dashboard-specific */
--grid-line: rgba(74, 158, 255, 0.08);
--radar-cyan: var(--accent-cyan);
--radar-bg: var(--bg-secondary);
}
body {
font-family: var(--font-sans);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-dark);
color: var(--text-primary);
min-height: 100vh;
@@ -46,10 +39,9 @@ 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: 40px 40px, 50px 50px, 50px 50px;
background-size: 50px 50px;
pointer-events: none;
z-index: 0;
}
@@ -62,12 +54,10 @@ 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.25;
box-shadow: 0 0 8px currentColor;
opacity: 0.3;
}
@keyframes scan {
@@ -95,18 +85,6 @@ 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;
@@ -115,7 +93,7 @@ body {
}
.logo {
font-family: var(--font-sans);
font-family: 'Inter', sans-serif;
font-size: 16px;
font-weight: 700;
letter-spacing: 2px;
@@ -150,49 +128,10 @@ body {
.status-bar {
display: flex;
gap: 12px;
gap: 20px;
align-items: center;
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
flex-wrap: nowrap;
}
.agent-selector-compact {
display: flex;
align-items: center;
gap: 8px;
}
.agent-selector-compact .agent-select-sm {
padding: 4px 8px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-primary);
font-size: 11px;
font-family: var(--font-mono);
}
.agent-selector-compact .agent-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent-green);
box-shadow: 0 0 6px var(--accent-green);
}
.agent-selector-compact .agent-status-dot.offline {
background: var(--accent-red);
box-shadow: 0 0 6px var(--accent-red);
}
.agent-selector-compact .show-all-label {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--text-secondary);
cursor: pointer;
}
.back-link {
@@ -240,7 +179,7 @@ body {
}
.strip-value {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
font-weight: 600;
color: var(--accent-cyan);
@@ -344,7 +283,7 @@ body {
font-size: 11px;
font-weight: 500;
color: var(--accent-cyan);
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
padding-left: 8px;
border-left: 1px solid rgba(74, 158, 255, 0.2);
white-space: nowrap;
@@ -371,21 +310,20 @@ body {
}
.strip-btn.primary {
background: linear-gradient(135deg, var(--accent-cyan) 0%, #2b6fb8 100%);
background: linear-gradient(135deg, var(--accent-cyan) 0%, #2563eb 100%);
border: none;
color: white;
}
/* Main dashboard grid - Mobile first */
/* Header ~52px + Nav 44px + Stats strip ~55px = ~151px, using 160px for safety */
.dashboard {
position: relative;
z-index: 10;
display: flex;
flex-direction: column;
gap: 0;
height: calc(100dvh - 160px);
height: calc(100vh - 160px);
height: calc(100dvh - 95px);
height: calc(100vh - 95px);
min-height: 400px;
}
@@ -425,7 +363,7 @@ body {
/* Leaflet overrides - Dark map styling */
.leaflet-container {
background: var(--bg-dark) !important;
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
}
/* Using actual dark tiles now - no filter needed */
@@ -496,7 +434,7 @@ body {
padding: 10px 15px;
background: rgba(74, 158, 255, 0.05);
border-bottom: 1px solid rgba(74, 158, 255, 0.1);
font-family: 'Orbitron', 'Space Mono', monospace;
font-family: 'Orbitron', 'JetBrains Mono', monospace;
font-size: 11px;
font-weight: 500;
letter-spacing: 2px;
@@ -568,7 +506,7 @@ body {
}
.vessel-name {
font-family: 'Orbitron', 'Space Mono', monospace;
font-family: 'Orbitron', 'JetBrains Mono', monospace;
font-size: 16px;
font-weight: 700;
color: var(--accent-cyan);
@@ -576,7 +514,7 @@ body {
}
.vessel-mmsi {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--text-secondary);
background: rgba(74, 158, 255, 0.1);
@@ -606,7 +544,7 @@ body {
}
.detail-value {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
color: var(--accent-cyan);
}
@@ -662,20 +600,20 @@ body {
}
.vessel-item-name {
font-family: 'Orbitron', 'Space Mono', monospace;
font-family: 'Orbitron', 'JetBrains Mono', monospace;
font-size: 12px;
font-weight: 600;
color: var(--accent-cyan);
}
.vessel-item-type {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
color: var(--text-secondary);
}
.vessel-item-speed {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--accent-cyan);
text-align: right;
@@ -686,31 +624,14 @@ body {
grid-column: 1 / -1;
grid-row: 2;
display: flex;
align-items: stretch !important;
align-items: center;
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: 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;
overflow-x: auto;
}
.control-group {
@@ -762,7 +683,7 @@ body {
border: 1px solid rgba(74, 158, 255, 0.3);
border-radius: 4px;
color: var(--accent-cyan);
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
}
@@ -773,7 +694,7 @@ body {
border: 1px solid rgba(74, 158, 255, 0.3);
border-radius: 4px;
color: var(--accent-cyan);
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
}
@@ -792,7 +713,7 @@ body {
border: none;
background: var(--accent-green);
color: #fff;
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
@@ -909,7 +830,7 @@ body {
display: flex !important;
flex-direction: column !important;
height: auto !important;
min-height: calc(100dvh - 160px);
min-height: calc(100dvh - 95px);
overflow-y: auto !important;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
@@ -1079,7 +1000,7 @@ body {
padding: 6px 12px;
background: rgba(0, 0, 0, 0.2);
border-bottom: 1px solid rgba(245, 158, 11, 0.1);
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
}
@@ -1154,7 +1075,7 @@ body {
}
.dsc-message-category {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
@@ -1171,13 +1092,13 @@ body {
}
.dsc-message-time {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
color: var(--text-dim);
}
.dsc-message-mmsi {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--accent-orange);
}
@@ -1195,7 +1116,7 @@ body {
}
.dsc-message-pos {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
color: var(--text-secondary);
}
@@ -1223,7 +1144,7 @@ body {
}
.dsc-distress-alert .dsc-alert-header {
font-family: 'Orbitron', 'Space Mono', monospace;
font-family: 'Orbitron', 'JetBrains Mono', monospace;
font-size: 24px;
font-weight: 700;
color: var(--accent-red);
@@ -1232,7 +1153,7 @@ body {
}
.dsc-distress-alert .dsc-alert-mmsi {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 16px;
color: var(--accent-cyan);
margin-bottom: 8px;
@@ -1252,7 +1173,7 @@ body {
}
.dsc-distress-alert .dsc-alert-position {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
color: var(--accent-cyan);
margin-bottom: 16px;
@@ -1263,7 +1184,7 @@ body {
border: none;
color: white;
padding: 10px 24px;
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+371 -371
View File
@@ -1,371 +1,371 @@
/* Function Strip (Action Bar) - Shared across modes
* Based on APRS strip pattern, reusable for Pager, Sensor, Bluetooth, WiFi, TSCM, etc.
*/
.function-strip {
background: linear-gradient(180deg, var(--bg-panel) 0%, var(--bg-dark) 100%);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 8px 12px;
margin-bottom: 10px;
overflow: visible;
min-height: 44px;
}
.function-strip-inner {
display: flex;
align-items: center;
gap: 8px;
min-width: max-content;
}
/* Stats */
.function-strip .strip-stat {
display: flex;
flex-direction: column;
align-items: center;
padding: 6px 10px;
background: rgba(74, 158, 255, 0.05);
border: 1px solid rgba(74, 158, 255, 0.15);
border-radius: 4px;
min-width: 55px;
}
.function-strip .strip-stat:hover {
background: rgba(74, 158, 255, 0.1);
border-color: rgba(74, 158, 255, 0.3);
}
.function-strip .strip-value {
font-family: var(--font-mono);
font-size: 14px;
font-weight: 600;
color: var(--accent-cyan);
line-height: 1.2;
}
.function-strip .strip-label {
font-size: 8px;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 1px;
}
.function-strip .strip-divider {
width: 1px;
height: 28px;
background: var(--border-color);
margin: 0 4px;
}
/* Signal stat coloring */
.function-strip .signal-stat.good .strip-value { color: var(--accent-green); }
.function-strip .signal-stat.warning .strip-value { color: var(--accent-yellow); }
.function-strip .signal-stat.poor .strip-value { color: var(--accent-red); }
/* Controls */
.function-strip .strip-control {
display: flex;
align-items: center;
gap: 4px;
}
.function-strip .strip-select {
background: rgba(0,0,0,0.3);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 4px 8px;
border-radius: 4px;
font-size: 10px;
cursor: pointer;
}
.function-strip .strip-select:hover {
border-color: var(--accent-cyan);
}
.function-strip .strip-select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.function-strip .strip-input-label {
font-size: 9px;
color: var(--text-muted);
font-weight: 600;
}
.function-strip .strip-input {
background: rgba(0,0,0,0.3);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 4px 6px;
border-radius: 4px;
font-size: 10px;
width: 50px;
text-align: center;
}
.function-strip .strip-input:hover,
.function-strip .strip-input:focus {
border-color: var(--accent-cyan);
outline: none;
}
.function-strip .strip-input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Wider input for frequency values */
.function-strip .strip-input.wide {
width: 70px;
}
/* Tool Status Indicators */
.function-strip .strip-tools {
display: flex;
gap: 4px;
}
.function-strip .strip-tool {
font-size: 9px;
font-weight: 600;
padding: 3px 6px;
border-radius: 3px;
background: rgba(255, 59, 48, 0.2);
color: var(--accent-red);
border: 1px solid rgba(255, 59, 48, 0.3);
}
.function-strip .strip-tool.ok {
background: rgba(0, 255, 136, 0.1);
color: var(--accent-green);
border-color: rgba(0, 255, 136, 0.3);
}
.function-strip .strip-tool.warn {
background: rgba(255, 193, 7, 0.2);
color: var(--accent-yellow);
border-color: rgba(255, 193, 7, 0.3);
}
/* Buttons */
.function-strip .strip-btn {
background: rgba(74, 158, 255, 0.1);
border: 1px solid rgba(74, 158, 255, 0.2);
color: var(--text-primary);
padding: 6px 12px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.function-strip .strip-btn:hover:not(:disabled) {
background: rgba(74, 158, 255, 0.2);
border-color: rgba(74, 158, 255, 0.4);
}
.function-strip .strip-btn.primary {
background: linear-gradient(135deg, var(--accent-green) 0%, #10b981 100%);
border: none;
color: #000;
}
.function-strip .strip-btn.primary:hover:not(:disabled) {
filter: brightness(1.1);
}
.function-strip .strip-btn.stop {
background: linear-gradient(135deg, var(--accent-red) 0%, #dc2626 100%);
border: none;
color: #fff;
}
.function-strip .strip-btn.stop:hover:not(:disabled) {
filter: brightness(1.1);
}
.function-strip .strip-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Status indicator */
.function-strip .strip-status {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: rgba(0,0,0,0.2);
border-radius: 4px;
font-size: 10px;
font-weight: 600;
color: var(--text-secondary);
}
.function-strip .status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-muted);
}
.function-strip .status-dot.inactive {
background: var(--text-muted);
}
.function-strip .status-dot.active,
.function-strip .status-dot.scanning,
.function-strip .status-dot.decoding {
background: var(--accent-cyan);
animation: strip-pulse 1.5s ease-in-out infinite;
}
.function-strip .status-dot.listening,
.function-strip .status-dot.tracking,
.function-strip .status-dot.receiving {
background: var(--accent-green);
animation: strip-pulse 1.5s ease-in-out infinite;
}
.function-strip .status-dot.sweeping {
background: var(--accent-orange);
animation: strip-pulse 1s ease-in-out infinite;
}
.function-strip .status-dot.error {
background: var(--accent-red);
}
@keyframes strip-pulse {
0%, 100% { opacity: 1; box-shadow: 0 0 4px 2px currentColor; }
50% { opacity: 0.6; box-shadow: none; }
}
/* Time display */
.function-strip .strip-time {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-muted);
padding: 4px 8px;
background: rgba(0,0,0,0.2);
border-radius: 4px;
white-space: nowrap;
}
/* Mode-specific accent colors */
.function-strip.pager-strip .strip-stat {
background: rgba(255, 193, 7, 0.05);
border-color: rgba(255, 193, 7, 0.15);
}
.function-strip.pager-strip .strip-stat:hover {
background: rgba(255, 193, 7, 0.1);
border-color: rgba(255, 193, 7, 0.3);
}
.function-strip.pager-strip .strip-value {
color: var(--accent-yellow);
}
.function-strip.sensor-strip .strip-stat {
background: rgba(0, 255, 136, 0.05);
border-color: rgba(0, 255, 136, 0.15);
}
.function-strip.sensor-strip .strip-stat:hover {
background: rgba(0, 255, 136, 0.1);
border-color: rgba(0, 255, 136, 0.3);
}
.function-strip.sensor-strip .strip-value {
color: var(--accent-green);
}
.function-strip.bt-strip .strip-stat {
background: rgba(0, 122, 255, 0.05);
border-color: rgba(0, 122, 255, 0.15);
}
.function-strip.bt-strip .strip-stat:hover {
background: rgba(0, 122, 255, 0.1);
border-color: rgba(0, 122, 255, 0.3);
}
.function-strip.bt-strip .strip-value {
color: #0a84ff;
}
.function-strip.wifi-strip .strip-stat {
background: rgba(255, 149, 0, 0.05);
border-color: rgba(255, 149, 0, 0.15);
}
.function-strip.wifi-strip .strip-stat:hover {
background: rgba(255, 149, 0, 0.1);
border-color: rgba(255, 149, 0, 0.3);
}
.function-strip.wifi-strip .strip-value {
color: var(--accent-orange);
}
.function-strip.tscm-strip {
margin-top: 4px; /* Extra clearance to prevent top clipping */
}
.function-strip.tscm-strip .strip-stat {
background: rgba(255, 59, 48, 0.15);
border: 1px solid rgba(255, 59, 48, 0.4);
}
.function-strip.tscm-strip .strip-stat:hover {
background: rgba(255, 59, 48, 0.25);
border-color: rgba(255, 59, 48, 0.6);
}
.function-strip.tscm-strip .strip-value {
color: #ef4444; /* Explicit red color */
}
.function-strip.tscm-strip .strip-label {
color: #9ca3af; /* Explicit light gray */
}
.function-strip.tscm-strip .strip-select {
color: #e8eaed; /* Explicit white for selects */
background: rgba(0, 0, 0, 0.4);
}
.function-strip.tscm-strip .strip-btn {
color: #e8eaed; /* Explicit white for buttons */
}
.function-strip.tscm-strip .strip-tool {
color: #e8eaed; /* Explicit white for tool indicators */
}
.function-strip.tscm-strip .strip-time,
.function-strip.tscm-strip .strip-status span {
color: #9ca3af; /* Explicit gray for status/time */
}
.function-strip.rtlamr-strip .strip-stat {
background: rgba(175, 82, 222, 0.05);
border-color: rgba(175, 82, 222, 0.15);
}
.function-strip.rtlamr-strip .strip-stat:hover {
background: rgba(175, 82, 222, 0.1);
border-color: rgba(175, 82, 222, 0.3);
}
.function-strip.rtlamr-strip .strip-value {
color: #af52de;
}
.function-strip.listening-strip .strip-stat {
background: rgba(74, 158, 255, 0.05);
border-color: rgba(74, 158, 255, 0.15);
}
.function-strip.listening-strip .strip-stat:hover {
background: rgba(74, 158, 255, 0.1);
border-color: rgba(74, 158, 255, 0.3);
}
.function-strip.listening-strip .strip-value {
color: var(--accent-cyan);
}
/* Threat-colored stats for TSCM */
.function-strip .strip-stat.threat-high .strip-value { color: var(--accent-red); }
.function-strip .strip-stat.threat-review .strip-value { color: var(--accent-orange); }
.function-strip .strip-stat.threat-info .strip-value { color: var(--accent-cyan); }
/* Function Strip (Action Bar) - Shared across modes
* Based on APRS strip pattern, reusable for Pager, Sensor, Bluetooth, WiFi, TSCM, etc.
*/
.function-strip {
background: linear-gradient(180deg, var(--bg-panel) 0%, var(--bg-dark) 100%);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 8px 12px;
margin-bottom: 10px;
overflow: visible;
min-height: 44px;
}
.function-strip-inner {
display: flex;
align-items: center;
gap: 8px;
min-width: max-content;
}
/* Stats */
.function-strip .strip-stat {
display: flex;
flex-direction: column;
align-items: center;
padding: 6px 10px;
background: rgba(74, 158, 255, 0.05);
border: 1px solid rgba(74, 158, 255, 0.15);
border-radius: 4px;
min-width: 55px;
}
.function-strip .strip-stat:hover {
background: rgba(74, 158, 255, 0.1);
border-color: rgba(74, 158, 255, 0.3);
}
.function-strip .strip-value {
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
font-weight: 600;
color: var(--accent-cyan);
line-height: 1.2;
}
.function-strip .strip-label {
font-size: 8px;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 1px;
}
.function-strip .strip-divider {
width: 1px;
height: 28px;
background: var(--border-color);
margin: 0 4px;
}
/* Signal stat coloring */
.function-strip .signal-stat.good .strip-value { color: var(--accent-green); }
.function-strip .signal-stat.warning .strip-value { color: var(--accent-yellow); }
.function-strip .signal-stat.poor .strip-value { color: var(--accent-red); }
/* Controls */
.function-strip .strip-control {
display: flex;
align-items: center;
gap: 4px;
}
.function-strip .strip-select {
background: rgba(0,0,0,0.3);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 4px 8px;
border-radius: 4px;
font-size: 10px;
cursor: pointer;
}
.function-strip .strip-select:hover {
border-color: var(--accent-cyan);
}
.function-strip .strip-select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.function-strip .strip-input-label {
font-size: 9px;
color: var(--text-muted);
font-weight: 600;
}
.function-strip .strip-input {
background: rgba(0,0,0,0.3);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 4px 6px;
border-radius: 4px;
font-size: 10px;
width: 50px;
text-align: center;
}
.function-strip .strip-input:hover,
.function-strip .strip-input:focus {
border-color: var(--accent-cyan);
outline: none;
}
.function-strip .strip-input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Wider input for frequency values */
.function-strip .strip-input.wide {
width: 70px;
}
/* Tool Status Indicators */
.function-strip .strip-tools {
display: flex;
gap: 4px;
}
.function-strip .strip-tool {
font-size: 9px;
font-weight: 600;
padding: 3px 6px;
border-radius: 3px;
background: rgba(255, 59, 48, 0.2);
color: var(--accent-red);
border: 1px solid rgba(255, 59, 48, 0.3);
}
.function-strip .strip-tool.ok {
background: rgba(0, 255, 136, 0.1);
color: var(--accent-green);
border-color: rgba(0, 255, 136, 0.3);
}
.function-strip .strip-tool.warn {
background: rgba(255, 193, 7, 0.2);
color: var(--accent-yellow);
border-color: rgba(255, 193, 7, 0.3);
}
/* Buttons */
.function-strip .strip-btn {
background: rgba(74, 158, 255, 0.1);
border: 1px solid rgba(74, 158, 255, 0.2);
color: var(--text-primary);
padding: 6px 12px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.function-strip .strip-btn:hover:not(:disabled) {
background: rgba(74, 158, 255, 0.2);
border-color: rgba(74, 158, 255, 0.4);
}
.function-strip .strip-btn.primary {
background: linear-gradient(135deg, var(--accent-green) 0%, #10b981 100%);
border: none;
color: #000;
}
.function-strip .strip-btn.primary:hover:not(:disabled) {
filter: brightness(1.1);
}
.function-strip .strip-btn.stop {
background: linear-gradient(135deg, var(--accent-red) 0%, #dc2626 100%);
border: none;
color: #fff;
}
.function-strip .strip-btn.stop:hover:not(:disabled) {
filter: brightness(1.1);
}
.function-strip .strip-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Status indicator */
.function-strip .strip-status {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: rgba(0,0,0,0.2);
border-radius: 4px;
font-size: 10px;
font-weight: 600;
color: var(--text-secondary);
}
.function-strip .status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-muted);
}
.function-strip .status-dot.inactive {
background: var(--text-muted);
}
.function-strip .status-dot.active,
.function-strip .status-dot.scanning,
.function-strip .status-dot.decoding {
background: var(--accent-cyan);
animation: strip-pulse 1.5s ease-in-out infinite;
}
.function-strip .status-dot.listening,
.function-strip .status-dot.tracking,
.function-strip .status-dot.receiving {
background: var(--accent-green);
animation: strip-pulse 1.5s ease-in-out infinite;
}
.function-strip .status-dot.sweeping {
background: var(--accent-orange);
animation: strip-pulse 1s ease-in-out infinite;
}
.function-strip .status-dot.error {
background: var(--accent-red);
}
@keyframes strip-pulse {
0%, 100% { opacity: 1; box-shadow: 0 0 4px 2px currentColor; }
50% { opacity: 0.6; box-shadow: none; }
}
/* Time display */
.function-strip .strip-time {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--text-muted);
padding: 4px 8px;
background: rgba(0,0,0,0.2);
border-radius: 4px;
white-space: nowrap;
}
/* Mode-specific accent colors */
.function-strip.pager-strip .strip-stat {
background: rgba(255, 193, 7, 0.05);
border-color: rgba(255, 193, 7, 0.15);
}
.function-strip.pager-strip .strip-stat:hover {
background: rgba(255, 193, 7, 0.1);
border-color: rgba(255, 193, 7, 0.3);
}
.function-strip.pager-strip .strip-value {
color: var(--accent-yellow);
}
.function-strip.sensor-strip .strip-stat {
background: rgba(0, 255, 136, 0.05);
border-color: rgba(0, 255, 136, 0.15);
}
.function-strip.sensor-strip .strip-stat:hover {
background: rgba(0, 255, 136, 0.1);
border-color: rgba(0, 255, 136, 0.3);
}
.function-strip.sensor-strip .strip-value {
color: var(--accent-green);
}
.function-strip.bt-strip .strip-stat {
background: rgba(0, 122, 255, 0.05);
border-color: rgba(0, 122, 255, 0.15);
}
.function-strip.bt-strip .strip-stat:hover {
background: rgba(0, 122, 255, 0.1);
border-color: rgba(0, 122, 255, 0.3);
}
.function-strip.bt-strip .strip-value {
color: #0a84ff;
}
.function-strip.wifi-strip .strip-stat {
background: rgba(255, 149, 0, 0.05);
border-color: rgba(255, 149, 0, 0.15);
}
.function-strip.wifi-strip .strip-stat:hover {
background: rgba(255, 149, 0, 0.1);
border-color: rgba(255, 149, 0, 0.3);
}
.function-strip.wifi-strip .strip-value {
color: var(--accent-orange);
}
.function-strip.tscm-strip {
margin-top: 4px; /* Extra clearance to prevent top clipping */
}
.function-strip.tscm-strip .strip-stat {
background: rgba(255, 59, 48, 0.15);
border: 1px solid rgba(255, 59, 48, 0.4);
}
.function-strip.tscm-strip .strip-stat:hover {
background: rgba(255, 59, 48, 0.25);
border-color: rgba(255, 59, 48, 0.6);
}
.function-strip.tscm-strip .strip-value {
color: #ef4444; /* Explicit red color */
}
.function-strip.tscm-strip .strip-label {
color: #9ca3af; /* Explicit light gray */
}
.function-strip.tscm-strip .strip-select {
color: #e8eaed; /* Explicit white for selects */
background: rgba(0, 0, 0, 0.4);
}
.function-strip.tscm-strip .strip-btn {
color: #e8eaed; /* Explicit white for buttons */
}
.function-strip.tscm-strip .strip-tool {
color: #e8eaed; /* Explicit white for tool indicators */
}
.function-strip.tscm-strip .strip-time,
.function-strip.tscm-strip .strip-status span {
color: #9ca3af; /* Explicit gray for status/time */
}
.function-strip.rtlamr-strip .strip-stat {
background: rgba(175, 82, 222, 0.05);
border-color: rgba(175, 82, 222, 0.15);
}
.function-strip.rtlamr-strip .strip-stat:hover {
background: rgba(175, 82, 222, 0.1);
border-color: rgba(175, 82, 222, 0.3);
}
.function-strip.rtlamr-strip .strip-value {
color: #af52de;
}
.function-strip.listening-strip .strip-stat {
background: rgba(74, 158, 255, 0.05);
border-color: rgba(74, 158, 255, 0.15);
}
.function-strip.listening-strip .strip-stat:hover {
background: rgba(74, 158, 255, 0.1);
border-color: rgba(74, 158, 255, 0.3);
}
.function-strip.listening-strip .strip-value {
color: var(--accent-cyan);
}
/* Threat-colored stats for TSCM */
.function-strip .strip-stat.threat-high .strip-value { color: var(--accent-red); }
.function-strip .strip-stat.threat-review .strip-value { color: var(--accent-orange); }
.function-strip .strip-stat.threat-info .strip-value { color: var(--accent-cyan); }
+1 -9
View File
@@ -14,18 +14,10 @@
.radar-device {
transition: transform 0.2s ease;
transform-origin: center center;
cursor: pointer;
}
.radar-device:hover {
transform: scale(1.2);
}
/* Invisible larger hit area to prevent hover flicker */
.radar-device-hitarea {
fill: transparent;
pointer-events: all;
transform: scale(1.3);
}
.radar-dot-pulse circle:first-child {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+63 -74
View File
@@ -21,34 +21,25 @@ 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-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;
}
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;
}
/* ============================================
TYPOGRAPHY
============================================ */
h1, h2, h3, h4, h5, h6 {
font-weight: var(--font-semibold);
line-height: var(--leading-tight);
color: var(--text-primary);
letter-spacing: 0.01em;
}
h1, h2, h3, h4, h5, h6 {
font-weight: var(--font-semibold);
line-height: var(--leading-tight);
color: var(--text-primary);
}
h1 { font-size: var(--text-4xl); }
h2 { font-size: var(--text-3xl); }
@@ -89,20 +80,18 @@ code, kbd, pre, samp {
font-size: 0.9em;
}
code {
background: var(--bg-elevated);
border: 1px solid var(--border-color);
padding: 2px 6px;
border-radius: var(--radius-sm);
}
code {
background: var(--bg-tertiary);
padding: 2px 6px;
border-radius: var(--radius-sm);
}
pre {
background: var(--bg-elevated);
border: 1px solid var(--border-color);
padding: var(--space-4);
border-radius: var(--radius-md);
overflow-x: auto;
}
pre {
background: var(--bg-tertiary);
padding: var(--space-4);
border-radius: var(--radius-md);
overflow-x: auto;
}
pre code {
background: none;
@@ -133,38 +122,38 @@ button:disabled {
opacity: 0.5;
}
input,
select,
textarea {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-3);
color: var(--text-primary);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
input,
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:focus,
select:focus,
textarea:focus {
outline: none;
border-color: var(--accent-cyan);
box-shadow: 0 0 0 2px var(--accent-cyan-dim);
}
input:focus,
select:focus,
textarea:focus {
outline: none;
border-color: var(--accent-cyan);
box-shadow: 0 0 0 3px var(--accent-cyan-dim);
}
input::placeholder,
textarea::placeholder {
color: var(--text-dim);
}
select {
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239fb0c7' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
padding-right: 28px;
}
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;
}
input[type="checkbox"],
input[type="radio"] {
@@ -199,18 +188,18 @@ td {
border-bottom: 1px solid var(--border-color);
}
th {
font-weight: var(--font-semibold);
color: var(--text-secondary);
background: var(--bg-tertiary);
text-transform: uppercase;
font-size: var(--text-xs);
letter-spacing: 0.05em;
}
tr:hover td {
background: var(--bg-elevated);
}
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);
}
/* ============================================
LISTS
+148 -267
View File
@@ -9,24 +9,21 @@
============================================ */
/* 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;
font-family: var(--font-mono);
text-transform: uppercase;
letter-spacing: 0.06em;
}
cursor: pointer;
transition: all var(--transition-fast);
white-space: nowrap;
text-decoration: none;
}
.btn:focus-visible {
outline: 2px solid var(--border-focus);
@@ -39,39 +36,38 @@
}
/* Button variants */
.btn-primary {
background: var(--accent-cyan);
color: var(--text-inverse);
border-color: var(--accent-cyan);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
}
.btn-primary {
background: var(--accent-cyan);
color: var(--text-inverse);
border-color: var(--accent-cyan);
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-cyan-hover);
border-color: var(--accent-cyan-hover);
}
.btn-secondary {
background: var(--bg-secondary);
color: var(--text-primary);
border-color: var(--border-color);
}
.btn-secondary:hover:not(:disabled) {
background: var(--bg-tertiary);
border-color: var(--border-light);
}
.btn-secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
border-color: var(--border-color);
}
.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-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-danger {
background: var(--accent-red);
@@ -122,23 +118,21 @@
/* ============================================
CARDS / PANELS
============================================ */
.card {
background: linear-gradient(180deg, var(--bg-card) 0%, var(--bg-secondary) 100%);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-sm);
}
.card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
overflow: hidden;
}
.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 {
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-title {
font-size: var(--text-xs);
@@ -159,58 +153,26 @@
}
/* Panel variant (used in dashboards) */
.panel {
background: linear-gradient(180deg, var(--bg-card) 0%, var(--bg-secondary) 100%);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-sm);
}
@supports (clip-path: polygon(0 0)) {
.card,
.panel {
--notch-size: 6px;
border-radius: 0;
clip-path: polygon(
var(--notch-size) 0,
calc(100% - var(--notch-size)) 0,
100% var(--notch-size),
100% calc(100% - var(--notch-size)),
calc(100% - var(--notch-size)) 100%,
var(--notch-size) 100%,
0 calc(100% - var(--notch-size)),
0 var(--notch-size)
);
}
}
.panel {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
overflow: hidden;
}
.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-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-indicator {
width: 8px;
@@ -231,18 +193,17 @@
/* ============================================
BADGES
============================================ */
.badge {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: 2px var(--space-2);
font-size: var(--text-xs);
font-weight: var(--font-medium);
border-radius: var(--radius-full);
background: var(--bg-tertiary);
color: var(--text-secondary);
border: 1px solid var(--border-color);
}
.badge {
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-primary {
background: var(--accent-cyan-dim);
@@ -259,53 +220,10 @@
color: var(--accent-orange);
}
.badge-danger {
background: var(--accent-red-dim);
color: var(--accent-red);
}
/* ============================================
DATA TAGS
============================================ */
.data-tag {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: 2px var(--space-2);
font-size: 10px;
font-family: var(--font-mono);
text-transform: uppercase;
letter-spacing: 0.12em;
border-radius: var(--radius-sm);
border: 1px solid var(--border-color);
background: var(--bg-tertiary);
color: var(--text-secondary);
box-shadow: inset 0 0 0 1px var(--border-glow);
}
.data-tag--accent {
border-color: var(--accent-cyan);
color: var(--accent-cyan);
background: var(--accent-cyan-dim);
}
.data-tag--warning {
border-color: var(--accent-amber);
color: var(--accent-amber);
background: var(--accent-amber-dim);
}
.data-tag--success {
border-color: var(--accent-green);
color: var(--accent-green);
background: var(--accent-green-dim);
}
.data-tag--danger {
border-color: var(--accent-red);
color: var(--accent-red);
background: var(--accent-red-dim);
}
.badge-danger {
background: var(--accent-red-dim);
color: var(--accent-red);
}
/* ============================================
STATUS INDICATORS
@@ -318,16 +236,16 @@
flex-shrink: 0;
}
.status-dot.online,
.status-dot.active {
background: var(--status-online);
box-shadow: 0 0 4px var(--status-online);
}
.status-dot.online,
.status-dot.active {
background: var(--status-online);
box-shadow: 0 0 6px var(--status-online);
}
.status-dot.warning {
background: var(--status-warning);
box-shadow: 0 0 4px var(--status-warning);
}
.status-dot.warning {
background: var(--status-warning);
box-shadow: 0 0 6px var(--status-warning);
}
.status-dot.error,
.status-dot.offline {
@@ -647,69 +565,44 @@
border-bottom: 1px solid var(--border-color);
}
.section-title {
font-size: var(--text-sm);
font-weight: var(--font-semibold);
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
position: relative;
padding-left: var(--space-3);
}
.section-title::before {
content: '';
position: absolute;
left: 0;
top: 50%;
width: 2px;
height: 6px;
background: var(--accent-cyan);
transform: translateY(-50%);
opacity: 0.7;
box-shadow: 0 6px 0 var(--accent-cyan);
}
.section-title {
font-size: var(--text-sm);
font-weight: var(--font-semibold);
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
}
/* ============================================
DIVIDERS
============================================ */
.divider {
height: 1px;
background-image: repeating-linear-gradient(
90deg,
var(--border-light),
var(--border-light) 6px,
transparent 6px,
transparent 12px
);
margin: var(--space-4) 0;
}
.divider-vertical {
width: 1px;
height: 100%;
background-image: repeating-linear-gradient(
180deg,
var(--border-light),
var(--border-light) 6px,
transparent 6px,
transparent 12px
);
margin: 0 var(--space-3);
}
.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);
}
/* ============================================
UX POLISH - ENHANCED INTERACTIONS
============================================ */
/* Button hover lift */
.btn:hover:not(:disabled) {
box-shadow: 0 0 0 1px var(--border-light);
}
.btn:active:not(:disabled) {
box-shadow: inset 0 0 0 1px var(--border-light);
}
.btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.btn:active:not(:disabled) {
transform: translateY(0);
box-shadow: var(--shadow-sm);
}
/* Card/Panel hover effects */
.card,
@@ -742,23 +635,23 @@
animation: statusGlow 2s ease-in-out infinite;
}
@keyframes statusGlow {
0%, 100% {
box-shadow: 0 0 4px var(--status-online);
}
50% {
box-shadow: 0 0 8px var(--status-online), 0 0 12px var(--status-online);
}
}
@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);
}
}
/* Badge hover effect */
.badge {
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
}
.badge:hover {
transform: scale(1.02);
}
.badge:hover {
transform: scale(1.05);
}
/* Alert entrance animation */
.alert {
@@ -787,38 +680,26 @@
}
/* Input focus glow */
input:focus,
select:focus,
textarea:focus {
border-color: var(--accent-cyan);
box-shadow: 0 0 0 2px var(--accent-cyan-dim);
}
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);
}
/* Nav item active indicator */
.nav-item,
.mode-nav-btn,
.mobile-nav-btn {
position: relative;
}
.nav-item.active::after,
.mode-nav-btn.active::after,
.mobile-nav-btn.active::after {
content: '';
position: absolute;
left: 12%;
right: 12%;
bottom: 2px;
height: 1px;
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
opacity: 0.75;
animation: railPulse 2.6s ease-in-out infinite;
}
@keyframes railPulse {
0%, 100% { opacity: 0.45; }
50% { opacity: 0.9; }
}
.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);
}
/* Smooth tooltip appearance */
[data-tooltip]::after {
+66 -130
View File
@@ -22,31 +22,18 @@
/* ============================================
GLOBAL HEADER
============================================ */
.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 {
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-left {
display: flex;
@@ -129,29 +116,16 @@
/* ============================================
GLOBAL NAVIGATION
============================================ */
.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 {
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::-webkit-scrollbar {
height: 0;
@@ -202,14 +176,14 @@
}
/* Dropdown menu */
.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);
.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);
box-shadow: var(--shadow-lg);
padding: var(--space-1);
opacity: 0;
@@ -299,27 +273,14 @@
/* ============================================
MOBILE NAVIGATION
============================================ */
.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 {
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::-webkit-scrollbar {
height: 0;
@@ -396,13 +357,13 @@
}
/* Sidebar */
.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;
}
.app-sidebar {
width: var(--sidebar-width);
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
overflow-y: auto;
flex-shrink: 0;
}
.sidebar-section {
padding: var(--space-4);
@@ -447,28 +408,15 @@
overflow: hidden;
}
.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 {
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-logo {
font-size: var(--text-lg);
@@ -495,10 +443,10 @@
position: relative;
}
.dashboard-sidebar {
width: 320px;
background: linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-tertiary) 100%);
border-left: 1px solid var(--border-color);
.dashboard-sidebar {
width: 320px;
background: var(--bg-secondary);
border-left: 1px solid var(--border-color);
overflow-y: auto;
display: flex;
flex-direction: column;
@@ -639,26 +587,14 @@
============================================ */
/* Mode Navigation Bar */
.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;
}
.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;
}
@media (min-width: 1024px) {
.mode-nav {
+198 -207
View File
@@ -1,207 +1,198 @@
/**
* INTERCEPT Design Tokens
* Single source of truth for colors, spacing, typography, and effects
* Import this file FIRST in any stylesheet that needs design tokens
*/
:root {
/* ============================================
COLOR PALETTE - Dark Theme (Default)
============================================ */
/* Backgrounds - layered depth system */
--bg-primary: #0b1118;
--bg-secondary: #101823;
--bg-tertiary: #151f2b;
--bg-card: #121a25;
--bg-elevated: #1b2734;
--bg-overlay: rgba(8, 13, 20, 0.75);
/* Background aliases for components */
--bg-dark: var(--bg-primary);
--bg-panel: var(--bg-secondary);
/* Accent colors */
--accent-cyan: #4aa3ff;
--accent-cyan-dim: rgba(74, 163, 255, 0.16);
--accent-cyan-hover: #6bb3ff;
--accent-green: #38c180;
--accent-green-dim: rgba(56, 193, 128, 0.18);
--accent-red: #e25d5d;
--accent-red-dim: rgba(226, 93, 93, 0.16);
--accent-orange: #d6a85e;
--accent-orange-dim: rgba(214, 168, 94, 0.16);
--accent-amber: #d6a85e;
--accent-amber-dim: rgba(214, 168, 94, 0.18);
--accent-yellow: #e1c26b;
--accent-purple: #8f7bd6;
/* Text hierarchy */
--text-primary: #d7e0ee;
--text-secondary: #9fb0c7;
--text-dim: #6f7f94;
--text-muted: #445266;
--text-inverse: #0b1118;
/* Borders */
--border-color: #263246;
--border-light: #354458;
--border-glow: rgba(74, 163, 255, 0.25);
--border-focus: var(--accent-cyan);
/* Status colors */
--status-online: #38c180;
--status-warning: #d6a85e;
--status-error: #e25d5d;
--status-offline: #6f7f94;
--status-info: #4aa3ff;
/* Subtle grid/pattern */
--grid-line: rgba(74, 163, 255, 0.1);
--grid-dot: rgba(255, 255, 255, 0.03);
--noise-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='40'%20height='40'%20viewBox='0%200%2040%2040'%3E%3Cg%20fill='%23ffffff'%20fill-opacity='0.05'%3E%3Ccircle%20cx='3'%20cy='5'%20r='1'/%3E%3Ccircle%20cx='11'%20cy='9'%20r='1'/%3E%3Ccircle%20cx='18'%20cy='3'%20r='1'/%3E%3Ccircle%20cx='26'%20cy='12'%20r='1'/%3E%3Ccircle%20cx='34'%20cy='6'%20r='1'/%3E%3Ccircle%20cx='7'%20cy='19'%20r='1'/%3E%3Ccircle%20cx='15'%20cy='24'%20r='1'/%3E%3Ccircle%20cx='28'%20cy='22'%20r='1'/%3E%3Ccircle%20cx='36'%20cy='18'%20r='1'/%3E%3Ccircle%20cx='5'%20cy='33'%20r='1'/%3E%3Ccircle%20cx='19'%20cy='32'%20r='1'/%3E%3Ccircle%20cx='31'%20cy='34'%20r='1'/%3E%3C/g%3E%3C/svg%3E");
/* ============================================
SPACING SCALE
============================================ */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-16: 64px;
/* ============================================
TYPOGRAPHY
============================================ */
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
/* Font sizes */
--text-xs: 10px;
--text-sm: 12px;
--text-base: 14px;
--text-lg: 16px;
--text-xl: 18px;
--text-2xl: 20px;
--text-3xl: 24px;
--text-4xl: 30px;
/* Font weights */
--font-normal: 400;
--font-medium: 500;
--font-semibold: 600;
--font-bold: 700;
/* Line heights */
--leading-tight: 1.25;
--leading-normal: 1.5;
--leading-relaxed: 1.75;
/* ============================================
BORDERS & RADIUS
============================================ */
--radius-sm: 3px;
--radius-md: 4px;
--radius-lg: 6px;
--radius-xl: 8px;
--radius-full: 9999px;
/* ============================================
SHADOWS
============================================ */
--shadow-sm: 0 1px 1px rgba(0, 0, 0, 0.35);
--shadow-md: 0 4px 8px rgba(0, 0, 0, 0.35);
--shadow-lg: 0 12px 18px rgba(0, 0, 0, 0.45);
--shadow-glow: 0 0 18px rgba(74, 163, 255, 0.16);
/* ============================================
TRANSITIONS
============================================ */
--transition-fast: 150ms ease;
--transition-base: 200ms ease;
--transition-slow: 300ms ease;
/* ============================================
Z-INDEX SCALE
============================================ */
--z-base: 0;
--z-dropdown: 100;
--z-sticky: 200;
--z-fixed: 300;
--z-modal-backdrop: 400;
--z-modal: 500;
--z-toast: 600;
--z-tooltip: 700;
/* ============================================
LAYOUT
============================================ */
--header-height: 60px;
--nav-height: 44px;
--sidebar-width: 280px;
--stats-strip-height: 36px;
--content-max-width: 1400px;
}
/* ============================================
LIGHT THEME OVERRIDES
============================================ */
[data-theme="light"] {
--bg-primary: #f4f7fb;
--bg-secondary: #e9eef5;
--bg-tertiary: #dde5f0;
--bg-card: #ffffff;
--bg-elevated: #f1f4f9;
--bg-overlay: rgba(244, 247, 251, 0.92);
/* Background aliases for components */
--bg-dark: var(--bg-primary);
--bg-panel: var(--bg-secondary);
--accent-cyan: #1f5fa8;
--accent-cyan-dim: rgba(31, 95, 168, 0.12);
--accent-cyan-hover: #2c73bf;
--accent-green: #1f8a57;
--accent-green-dim: rgba(31, 138, 87, 0.12);
--accent-red: #c74444;
--accent-red-dim: rgba(199, 68, 68, 0.12);
--accent-orange: #b5863a;
--accent-orange-dim: rgba(181, 134, 58, 0.12);
--accent-amber: #b5863a;
--accent-amber-dim: rgba(181, 134, 58, 0.12);
--text-primary: #122034;
--text-secondary: #3a4a5f;
--text-dim: #6b7c93;
--text-muted: #aab6c8;
--text-inverse: #f4f7fb;
--border-color: #d1d9e6;
--border-light: #c1ccdb;
--border-glow: rgba(31, 95, 168, 0.12);
--grid-line: rgba(31, 95, 168, 0.14);
--grid-dot: rgba(12, 18, 24, 0.06);
--noise-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='40'%20height='40'%20viewBox='0%200%2040%2040'%3E%3Cg%20fill='%23000000'%20fill-opacity='0.05'%3E%3Ccircle%20cx='3'%20cy='5'%20r='1'/%3E%3Ccircle%20cx='11'%20cy='9'%20r='1'/%3E%3Ccircle%20cx='18'%20cy='3'%20r='1'/%3E%3Ccircle%20cx='26'%20cy='12'%20r='1'/%3E%3Ccircle%20cx='34'%20cy='6'%20r='1'/%3E%3Ccircle%20cx='7'%20cy='19'%20r='1'/%3E%3Ccircle%20cx='15'%20cy='24'%20r='1'/%3E%3Ccircle%20cx='28'%20cy='22'%20r='1'/%3E%3Ccircle%20cx='36'%20cy='18'%20r='1'/%3E%3Ccircle%20cx='5'%20cy='33'%20r='1'/%3E%3Ccircle%20cx='19'%20cy='32'%20r='1'/%3E%3Ccircle%20cx='31'%20cy='34'%20r='1'/%3E%3C/g%3E%3C/svg%3E");
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.15);
--shadow-glow: 0 0 18px rgba(31, 95, 168, 0.1);
}
/* ============================================
REDUCED MOTION
============================================ */
@media (prefers-reduced-motion: reduce) {
:root {
--transition-fast: 0ms;
--transition-base: 0ms;
--transition-slow: 0ms;
}
}
/**
* INTERCEPT Design Tokens
* Single source of truth for colors, spacing, typography, and effects
* Import this file FIRST in any stylesheet that needs design tokens
*/
:root {
/* ============================================
COLOR PALETTE - Dark Theme (Default)
============================================ */
/* Backgrounds - layered depth system */
--bg-primary: #0a0c10;
--bg-secondary: #0f1218;
--bg-tertiary: #151a23;
--bg-card: #121620;
--bg-elevated: #1a202c;
--bg-overlay: rgba(0, 0, 0, 0.7);
/* 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-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;
/* Text hierarchy */
--text-primary: #e8eaed;
--text-secondary: #9ca3af;
--text-dim: #4b5563;
--text-muted: #374151;
--text-inverse: #0a0c10;
/* Borders */
--border-color: #1f2937;
--border-light: #374151;
--border-glow: rgba(74, 158, 255, 0.2);
--border-focus: var(--accent-cyan);
/* Status colors */
--status-online: #22c55e;
--status-warning: #f59e0b;
--status-error: #ef4444;
--status-offline: #6b7280;
--status-info: #3b82f6;
/* ============================================
SPACING SCALE
============================================ */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-16: 64px;
/* ============================================
TYPOGRAPHY
============================================ */
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
/* Font sizes */
--text-xs: 10px;
--text-sm: 12px;
--text-base: 14px;
--text-lg: 16px;
--text-xl: 18px;
--text-2xl: 20px;
--text-3xl: 24px;
--text-4xl: 30px;
/* Font weights */
--font-normal: 400;
--font-medium: 500;
--font-semibold: 600;
--font-bold: 700;
/* Line heights */
--leading-tight: 1.25;
--leading-normal: 1.5;
--leading-relaxed: 1.75;
/* ============================================
BORDERS & RADIUS
============================================ */
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
--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);
/* ============================================
TRANSITIONS
============================================ */
--transition-fast: 150ms ease;
--transition-base: 200ms ease;
--transition-slow: 300ms ease;
/* ============================================
Z-INDEX SCALE
============================================ */
--z-base: 0;
--z-dropdown: 100;
--z-sticky: 200;
--z-fixed: 300;
--z-modal-backdrop: 400;
--z-modal: 500;
--z-toast: 600;
--z-tooltip: 700;
/* ============================================
LAYOUT
============================================ */
--header-height: 60px;
--nav-height: 44px;
--sidebar-width: 280px;
--stats-strip-height: 36px;
--content-max-width: 1400px;
}
/* ============================================
LIGHT THEME OVERRIDES
============================================ */
[data-theme="light"] {
--bg-primary: #f8fafc;
--bg-secondary: #f1f5f9;
--bg-tertiary: #e2e8f0;
--bg-card: #ffffff;
--bg-elevated: #f8fafc;
--bg-overlay: rgba(255, 255, 255, 0.9);
/* 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);
--text-primary: #0f172a;
--text-secondary: #475569;
--text-dim: #94a3b8;
--text-muted: #cbd5e1;
--text-inverse: #f8fafc;
--border-color: #e2e8f0;
--border-light: #cbd5e1;
--border-glow: rgba(37, 99, 235, 0.15);
--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);
}
/* ============================================
REDUCED MOTION
============================================ */
@media (prefers-reduced-motion: reduce) {
:root {
--transition-fast: 0ms;
--transition-base: 0ms;
--transition-slow: 0ms;
}
}
+67 -18
View File
@@ -1,18 +1,67 @@
/* Local font declarations for offline mode */
/* Space Mono - Console font */
@font-face {
font-family: 'Space Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/static/vendor/fonts/SpaceMono-Regular.woff2') format('woff2');
}
@font-face {
font-family: 'Space Mono';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/static/vendor/fonts/SpaceMono-Bold.woff2') format('woff2');
}
/* Local font declarations for offline mode */
/* Inter - Primary UI font */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/static/vendor/fonts/Inter-Regular.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/static/vendor/fonts/Inter-Medium.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/static/vendor/fonts/Inter-SemiBold.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/static/vendor/fonts/Inter-Bold.woff2') format('woff2');
}
/* JetBrains Mono - Monospace/code font */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/static/vendor/fonts/JetBrainsMono-Regular.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/static/vendor/fonts/JetBrainsMono-Medium.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/static/vendor/fonts/JetBrainsMono-SemiBold.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/static/vendor/fonts/JetBrainsMono-Bold.woff2') format('woff2');
}
-439
View File
@@ -1,439 +0,0 @@
/* ============================================
Global Navigation Styles
Shared across all pages using nav.html
============================================ */
/* Icon base (kept lightweight for nav usage) */
.icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
flex-shrink: 0;
}
.icon svg {
width: 100%;
height: 100%;
}
.icon--sm {
width: 14px;
height: 14px;
}
/* Mode Navigation Bar */
.mode-nav {
display: none;
background: linear-gradient(180deg, rgba(17, 22, 32, 0.92), rgba(15, 20, 28, 0.88));
border-bottom: 1px solid var(--border-color, #202833);
padding: 0 20px;
position: relative;
z-index: 100;
backdrop-filter: blur(10px);
}
@media (min-width: 1024px) {
.mode-nav {
display: flex;
align-items: center;
gap: 8px;
height: 44px;
}
}
.mode-nav-label {
font-size: 9px;
color: var(--text-secondary, #b7c1cf);
text-transform: uppercase;
letter-spacing: 1px;
margin-right: 8px;
font-weight: 500;
font-family: var(--font-mono);
}
.mode-nav-divider {
width: 1px;
height: 24px;
background: var(--border-color, #202833);
margin: 0 12px;
}
.mode-nav-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
color: var(--text-secondary, #b7c1cf);
font-family: var(--font-sans);
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
text-decoration: none;
}
.mode-nav-btn .nav-label {
text-transform: uppercase;
letter-spacing: 0.08em;
font-family: var(--font-mono);
font-size: 10px;
}
.mode-nav-btn:hover {
background: rgba(27, 36, 51, 0.8);
color: var(--text-primary, #e7ebf2);
border-color: var(--border-color, #202833);
}
.mode-nav-btn.active {
background: rgba(27, 36, 51, 0.9);
color: var(--text-primary, #e7ebf2);
border-color: var(--accent-cyan, #4d7dbf);
box-shadow: inset 0 -2px 0 var(--accent-cyan, #4d7dbf);
}
.mode-nav-btn.active .nav-icon {
color: var(--accent-cyan, #4d7dbf);
}
.mode-nav-actions {
display: flex;
align-items: center;
gap: 16px;
}
.nav-action-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
background: rgba(24, 31, 44, 0.85);
border: 1px solid var(--border-light, #2b3645);
border-radius: 6px;
color: var(--text-primary, #e7ebf2);
font-family: var(--font-sans);
font-size: 11px;
font-weight: 500;
text-decoration: none;
cursor: pointer;
transition: all 0.15s ease;
}
.nav-action-btn .nav-label {
text-transform: uppercase;
letter-spacing: 0.08em;
font-family: var(--font-mono);
font-size: 10px;
}
.nav-action-btn:hover {
background: rgba(27, 36, 51, 0.95);
color: var(--text-primary, #e7ebf2);
box-shadow: 0 8px 16px rgba(5, 9, 15, 0.35);
border-color: var(--accent-cyan, #4d7dbf);
}
/* Dropdown Navigation */
.mode-nav-dropdown {
position: relative;
}
.mode-nav-dropdown-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
color: var(--text-secondary, #b7c1cf);
font-family: var(--font-sans);
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.mode-nav-dropdown-btn .nav-label {
text-transform: uppercase;
letter-spacing: 0.08em;
font-family: var(--font-mono);
font-size: 10px;
}
.mode-nav-dropdown-btn .dropdown-arrow {
width: 12px;
height: 12px;
margin-left: 4px;
transition: transform 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
}
.mode-nav-dropdown-btn .dropdown-arrow svg {
width: 100%;
height: 100%;
}
.mode-nav-dropdown-btn:hover {
background: rgba(27, 36, 51, 0.8);
color: var(--text-primary, #e7ebf2);
border-color: var(--border-color, #202833);
}
.mode-nav-dropdown.open .mode-nav-dropdown-btn {
background: rgba(27, 36, 51, 0.9);
color: var(--text-primary, #e7ebf2);
border-color: var(--border-color, #202833);
}
.mode-nav-dropdown.open .dropdown-arrow {
transform: rotate(180deg);
}
.mode-nav-dropdown.has-active .mode-nav-dropdown-btn {
background: rgba(27, 36, 51, 0.9);
color: var(--text-primary, #e7ebf2);
border-color: var(--accent-cyan, #4d7dbf);
box-shadow: inset 0 -2px 0 var(--accent-cyan, #4d7dbf);
}
.mode-nav-dropdown.has-active .mode-nav-dropdown-btn .nav-icon {
color: var(--accent-cyan, #4d7dbf);
}
.mode-nav-dropdown-menu {
position: absolute;
top: 100%;
left: 0;
margin-top: 4px;
min-width: 180px;
background: rgba(16, 22, 32, 0.98);
border: 1px solid var(--border-color, #202833);
border-radius: 8px;
box-shadow: 0 16px 36px rgba(5, 9, 15, 0.55);
opacity: 0;
visibility: hidden;
transform: translateY(-8px);
transition: all 0.15s ease;
z-index: 1000;
padding: 6px;
}
.mode-nav-dropdown.open .mode-nav-dropdown-menu {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.mode-nav-dropdown-menu .mode-nav-btn {
width: 100%;
justify-content: flex-start;
padding: 10px 12px;
border-radius: 6px;
}
.mode-nav-dropdown-menu .mode-nav-btn:hover {
background: rgba(27, 36, 51, 0.85);
}
.mode-nav-dropdown-menu .mode-nav-btn.active {
background: rgba(27, 36, 51, 0.95);
color: var(--text-primary, #e7ebf2);
box-shadow: inset 0 -2px 0 var(--accent-cyan, #4d7dbf);
}
/* Nav Bar Utilities */
.nav-utilities {
display: none;
align-items: center;
gap: 12px;
margin-left: auto;
flex-shrink: 0;
}
@media (min-width: 1024px) {
.nav-utilities {
display: flex;
}
}
.nav-clock {
display: flex;
align-items: center;
gap: 6px;
font-family: var(--font-mono);
font-size: 11px;
flex-shrink: 0;
white-space: nowrap;
}
.nav-clock .utc-label {
font-size: 9px;
color: var(--text-dim, #8a97a8);
text-transform: uppercase;
letter-spacing: 1px;
}
.nav-clock .utc-time {
color: var(--accent-cyan, #4d7dbf);
font-weight: 600;
}
.nav-divider {
width: 1px;
height: 20px;
background: var(--border-color, #202833);
}
.nav-tools {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.nav-tool-btn {
width: 28px;
height: 28px;
min-width: 28px;
border-radius: 6px;
background: rgba(20, 33, 53, 0.6);
border: 1px solid rgba(77, 125, 191, 0.12);
color: var(--text-secondary, #b7c1cf);
font-size: 14px;
font-weight: bold;
cursor: pointer;
transition: all 0.15s ease;
display: inline-flex;
align-items: center;
justify-content: center;
}
.nav-tool-btn:hover {
background: rgba(27, 36, 51, 0.9);
border-color: var(--accent-cyan, #4d7dbf);
color: var(--accent-cyan, #4d7dbf);
box-shadow: 0 6px 14px rgba(5, 9, 15, 0.35);
}
/* Position relative needed for absolute positioned icon children */
.nav-tool-btn {
position: relative;
}
.mode-nav-btn:focus-visible,
.mode-nav-dropdown-btn:focus-visible,
.nav-action-btn:focus-visible,
.nav-tool-btn:focus-visible {
outline: 2px solid var(--accent-cyan, #4d7dbf);
outline-offset: 2px;
}
/* Nav tool button SVG sizing and styling */
.nav-tool-btn svg {
width: 14px;
height: 14px;
stroke: currentColor;
}
.nav-tool-btn .icon {
display: inline-flex;
align-items: center;
justify-content: center;
}
.nav-tool-btn .icon svg {
width: 14px;
height: 14px;
stroke: currentColor;
}
/* Theme toggle icon states */
.nav-tool-btn .icon-sun,
.nav-tool-btn .icon-moon {
position: absolute;
transition: opacity 0.2s, transform 0.2s;
}
.nav-tool-btn .icon-sun {
opacity: 0;
transform: rotate(-90deg);
}
.nav-tool-btn .icon-moon {
opacity: 1;
transform: rotate(0deg);
}
[data-theme="light"] .nav-tool-btn .icon-sun {
opacity: 1;
transform: rotate(0deg);
}
[data-theme="light"] .nav-tool-btn .icon-moon {
opacity: 0;
transform: rotate(90deg);
}
/* Effects/animations toggle icon states */
.nav-tool-btn .icon-effects-off {
display: none;
}
[data-animations="off"] .nav-tool-btn .icon-effects-on {
display: none;
}
[data-animations="off"] .nav-tool-btn .icon-effects-off {
display: flex;
}
/* Main Dashboard Button in Nav */
a.nav-dashboard-btn,
a.nav-dashboard-btn:link,
a.nav-dashboard-btn:visited {
display: inline-flex !important;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 6px;
background: rgba(20, 33, 53, 0.6) !important;
border: 1px solid rgba(77, 125, 191, 0.12) !important;
color: #b7c1cf !important;
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
text-decoration: none !important;
}
a.nav-dashboard-btn:hover {
background: rgba(27, 36, 51, 0.9) !important;
border-color: #4d7dbf !important;
color: #4d7dbf !important;
box-shadow: 0 6px 14px rgba(5, 9, 15, 0.35);
}
.nav-dashboard-btn .icon {
width: 14px;
height: 14px;
}
.nav-dashboard-btn .icon svg {
width: 100%;
height: 100%;
stroke: currentColor;
}
.nav-dashboard-btn .nav-label {
font-family: var(--font-mono, 'JetBrains Mono', monospace);
letter-spacing: 0.5px;
}
-184
View File
@@ -1,184 +0,0 @@
/**
* Help Modal Styles
* Shared across all pages that include the help modal partial
*/
.help-modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.85);
z-index: 10000;
overflow-y: auto;
padding: 40px 20px;
}
.help-modal.active {
display: block;
}
.help-content {
max-width: 800px;
margin: 0 auto;
background: var(--bg-card, var(--bg-secondary, #0f1218));
border: 1px solid var(--border-color, #1f2937);
border-radius: 8px;
padding: 30px;
position: relative;
}
.help-content h2 {
color: var(--accent-cyan, #4a9eff);
margin-bottom: 20px;
font-size: 24px;
letter-spacing: 2px;
}
.help-content h3 {
color: var(--text-primary, #e8eaed);
margin: 25px 0 15px 0;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 1px;
border-bottom: 1px solid var(--border-color, #1f2937);
padding-bottom: 8px;
}
.help-close {
position: absolute;
top: 15px;
right: 15px;
background: none;
border: none;
color: var(--text-dim, #4b5563);
font-size: 24px;
cursor: pointer;
transition: color 0.2s;
}
.help-close:hover {
color: var(--accent-red, #ef4444);
}
.help-modal .icon-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
margin: 15px 0;
}
.help-modal .icon-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
background: var(--bg-primary, #0a0c10);
border: 1px solid var(--border-color, #1f2937);
border-radius: 4px;
font-size: 12px;
}
.help-modal .icon-item .icon {
font-size: 18px;
width: 30px;
text-align: center;
}
.help-modal .icon-item .desc {
color: var(--text-secondary, #9ca3af);
}
.help-modal .tip-list {
list-style: none;
padding: 0;
margin: 15px 0;
}
.help-modal .tip-list li {
padding: 8px 0;
padding-left: 20px;
position: relative;
color: var(--text-secondary, #9ca3af);
font-size: 13px;
border-bottom: 1px solid var(--border-color, #1f2937);
}
.help-modal .tip-list li:last-child {
border-bottom: none;
}
.help-modal .tip-list li::before {
content: '\203A';
position: absolute;
left: 0;
color: var(--accent-cyan, #4a9eff);
font-weight: bold;
}
.help-tabs {
display: flex;
gap: 0;
margin-bottom: 20px;
border: 1px solid var(--border-color, #1f2937);
border-radius: 4px;
overflow: hidden;
}
.help-tab {
flex: 1;
padding: 10px;
background: var(--bg-primary, #0a0c10);
border: none;
color: var(--text-secondary, #9ca3af);
cursor: pointer;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
transition: all 0.15s ease;
position: relative;
}
.help-tab:not(:last-child) {
border-right: 1px solid var(--border-color, #1f2937);
}
.help-tab:hover {
background: var(--bg-tertiary, #151a23);
color: var(--text-primary, #e8eaed);
}
.help-tab.active {
background: var(--bg-tertiary, #151a23);
color: var(--accent-cyan, #4a9eff);
}
.help-tab.active::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background: var(--accent-cyan, #4a9eff);
}
.help-section {
display: none;
}
.help-section.active {
display: block;
}
/* Ensure code tags are styled */
.help-modal code {
background: var(--bg-tertiary, #151a23);
padding: 2px 6px;
border-radius: 3px;
font-family: var(--font-mono, 'JetBrains Mono', monospace);
font-size: 11px;
color: var(--accent-cyan, #4a9eff);
}
+183 -302
View File
@@ -1,78 +1,23 @@
/**
* INTERCEPT Main Styles
* Legacy styles for index.html - will be incrementally refactored
*
* Note: Design tokens are now imported from core/variables.css
* New pages should import core CSS directly in templates
*/
/* Import design tokens (provides CSS variables) */
@import url('./core/variables.css');
/* Font import - kept for backward compatibility with pages not using base template */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap');
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
/* Tactical dark palette */
--bg-primary: #0b1118;
--bg-secondary: #101823;
--bg-tertiary: #151f2b;
--bg-card: #121a25;
--bg-elevated: #1b2734;
/* 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: #d7e0ee;
--text-secondary: #9fb0c7;
--text-dim: #6f7f94;
--text-muted: #445266;
/* Borders */
--border-color: #263246;
--border-light: #354458;
--border-glow: rgba(74, 163, 255, 0.25);
/* Status colors */
--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: #f4f7fb;
--bg-secondary: #e9eef5;
--bg-tertiary: #dde5f0;
--bg-card: #ffffff;
--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 {
background: var(--bg-primary);
}
@@ -82,16 +27,8 @@
}
body {
font-family: var(--font-sans);
background-color: var(--bg-primary);
background-image:
var(--noise-image),
radial-gradient(circle at 15% 0%, var(--grid-dot), transparent 45%),
linear-gradient(180deg, var(--grid-dot), transparent 35%),
linear-gradient(var(--grid-line) 1px, transparent 1px),
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
background-size: 40px 40px, auto, auto, 48px 48px, 48px 48px;
background-attachment: fixed;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
font-size: 14px;
@@ -125,8 +62,8 @@ body {
right: 0;
bottom: 0;
background:
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%);
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%);
pointer-events: none;
}
@@ -276,7 +213,7 @@ body {
}
.welcome-title {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 2.5rem;
font-weight: 700;
color: var(--text-primary);
@@ -286,7 +223,7 @@ body {
}
.welcome-tagline {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 0.9rem;
color: var(--accent-cyan);
letter-spacing: 0.15em;
@@ -295,7 +232,7 @@ body {
.welcome-version {
display: inline-block;
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 0.65rem;
color: var(--bg-primary);
background: var(--accent-cyan);
@@ -314,7 +251,7 @@ body {
}
.welcome-content h2 {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem;
color: var(--text-secondary);
text-transform: uppercase;
@@ -350,14 +287,14 @@ body {
}
.changelog-version {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 0.8rem;
color: var(--accent-cyan);
font-weight: 600;
}
.changelog-date {
font-family: var(--font-sans);
font-family: 'Inter', sans-serif;
font-size: 0.7rem;
color: var(--text-dim);
}
@@ -369,7 +306,7 @@ body {
}
.changelog-list li {
font-family: var(--font-sans);
font-family: 'Inter', sans-serif;
font-size: 0.75rem;
color: var(--text-secondary);
margin-bottom: 6px;
@@ -381,7 +318,7 @@ body {
position: absolute;
left: -15px;
color: var(--accent-green);
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
}
/* Mode Selection Grid */
@@ -452,7 +389,7 @@ body {
}
.mode-card .mode-name {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-primary);
@@ -461,7 +398,7 @@ body {
}
.mode-card .mode-desc {
font-family: var(--font-sans);
font-family: 'Inter', sans-serif;
font-size: 0.65rem;
color: var(--text-dim);
margin-top: 4px;
@@ -480,7 +417,7 @@ body {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 0.7rem;
font-weight: 600;
color: var(--text-secondary);
@@ -534,7 +471,7 @@ body {
}
.welcome-footer p {
font-family: var(--font-sans);
font-family: 'Inter', sans-serif;
font-size: 0.7rem;
color: var(--text-dim);
letter-spacing: 0.1em;
@@ -703,9 +640,11 @@ header h1 {
/* Mode Navigation Bar */
.mode-nav {
display: none;
background: var(--bg-tertiary);
background: #151a23 !important; /* Explicit color - forced to ensure consistency */
border-bottom: 1px solid var(--border-color);
padding: 0 20px;
position: relative;
z-index: 10;
}
@media (min-width: 1024px) {
@@ -748,7 +687,7 @@ header h1 {
border: 1px solid transparent;
border-radius: 4px;
color: var(--text-secondary);
font-family: var(--font-sans);
font-family: 'Inter', sans-serif;
font-size: 11px;
font-weight: 500;
cursor: pointer;
@@ -795,7 +734,7 @@ header h1 {
border: 1px solid var(--accent-cyan);
border-radius: 4px;
color: var(--accent-cyan);
font-family: var(--font-sans);
font-family: 'Inter', sans-serif;
font-size: 11px;
font-weight: 500;
text-decoration: none;
@@ -831,7 +770,7 @@ header h1 {
border: 1px solid transparent;
border-radius: 4px;
color: var(--text-secondary);
font-family: var(--font-sans);
font-family: 'Inter', sans-serif;
font-size: 11px;
font-weight: 500;
cursor: pointer;
@@ -939,7 +878,7 @@ header h1 {
display: flex;
align-items: center;
gap: 6px;
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
flex-shrink: 0;
white-space: nowrap;
@@ -995,18 +934,6 @@ header h1 {
color: var(--accent-cyan);
}
/* Donate button - warm amber accent */
.nav-tool-btn--donate {
text-decoration: none;
color: var(--accent-amber);
}
.nav-tool-btn--donate:hover {
color: var(--accent-orange);
border-color: var(--accent-amber);
background: rgba(212, 168, 83, 0.1);
}
/* Theme toggle icon states in nav bar */
.nav-tool-btn .icon-sun,
.nav-tool-btn .icon-moon {
@@ -1047,7 +974,7 @@ header h1 {
.version-badge {
font-size: 0.6rem;
font-weight: 500;
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
letter-spacing: 0.05em;
color: var(--text-secondary);
background: var(--bg-tertiary);
@@ -1106,7 +1033,7 @@ header h1 .tagline {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 6px;
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
transition: all 0.2s ease;
}
@@ -1487,6 +1414,7 @@ header h1 .tagline {
overflow: visible;
padding: 12px;
position: relative;
margin: 0; /* Reset any inherited margins - spacing handled by parent gap */
}
.section h3 {
@@ -1595,7 +1523,7 @@ header h1 .tagline {
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-primary);
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
transition: all 0.15s ease;
}
@@ -1607,11 +1535,6 @@ header h1 .tagline {
box-shadow: 0 0 0 2px var(--accent-cyan-dim);
}
/* Ensure device select is wide enough for device name + serial */
#deviceSelect {
min-width: 280px;
}
.checkbox-group {
display: flex;
flex-wrap: wrap;
@@ -1654,7 +1577,7 @@ header h1 .tagline {
border: 1px solid var(--border-color);
color: var(--text-secondary);
cursor: pointer;
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
@@ -1668,13 +1591,108 @@ header h1 .tagline {
border-color: var(--accent-cyan);
}
/* WiFi Mode Tab Buttons */
.wifi-mode-tab {
flex: 1;
padding: 8px;
font-size: 11px;
font-family: 'JetBrains Mono', monospace;
text-transform: uppercase;
letter-spacing: 0.05em;
background: var(--bg-tertiary);
color: var(--text-secondary);
border: 1px solid var(--border-color);
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
}
.wifi-mode-tab:hover {
background: var(--bg-secondary);
color: var(--text-primary);
border-color: var(--accent-cyan);
}
.wifi-mode-tab.active {
background: var(--accent-green);
color: #000;
border-color: var(--accent-green);
}
.wifi-mode-tab.active:hover {
background: #1db954;
border-color: #1db954;
}
/* WiFi Start/Stop Buttons */
.wifi-start-btn {
background: var(--accent-green) !important;
color: #000 !important;
border-color: var(--accent-green) !important;
}
.wifi-start-btn:hover {
background: #1db954 !important;
border-color: #1db954 !important;
box-shadow: 0 2px 8px rgba(34, 197, 94, 0.3);
}
.wifi-stop-btn {
background: var(--accent-red) !important;
color: #fff !important;
border-color: var(--accent-red) !important;
}
.wifi-stop-btn:hover {
background: #e62e50 !important;
border-color: #e62e50 !important;
box-shadow: 0 2px 8px rgba(255, 51, 102, 0.3);
}
/* WiFi Monitor Mode Buttons */
.wifi-monitor-btn {
background: var(--accent-green) !important;
color: #000 !important;
border-color: var(--accent-green) !important;
}
.wifi-monitor-btn:hover {
background: #1db954 !important;
border-color: #1db954 !important;
box-shadow: 0 2px 8px rgba(34, 197, 94, 0.3);
}
.wifi-monitor-stop-btn {
background: var(--accent-orange) !important;
color: #000 !important;
border-color: var(--accent-orange) !important;
}
.wifi-monitor-stop-btn:hover {
background: #e68a00 !important;
border-color: #e68a00 !important;
box-shadow: 0 2px 8px rgba(255, 159, 28, 0.3);
}
/* WiFi Danger/Attack Buttons */
.wifi-danger-btn {
border-color: var(--accent-red) !important;
color: var(--accent-red) !important;
}
.wifi-danger-btn:hover {
background: var(--accent-red) !important;
color: #fff !important;
box-shadow: 0 2px 8px rgba(255, 51, 102, 0.3);
}
.run-btn {
width: 100%;
padding: 12px;
background: var(--accent-green);
border: none;
color: #fff;
font-family: var(--font-sans);
font-family: 'Inter', sans-serif;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
@@ -1711,7 +1729,7 @@ header h1 .tagline {
background: var(--accent-red);
border: none;
color: #fff;
font-family: var(--font-sans);
font-family: 'Inter', sans-serif;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
@@ -1774,7 +1792,7 @@ header h1 .tagline {
gap: 8px;
font-size: 10px;
color: var(--text-secondary);
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
}
.stats>div {
@@ -1800,7 +1818,7 @@ header h1 .tagline {
flex: 1;
padding: 10px;
overflow-y: auto;
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
background: var(--bg-primary);
min-height: 0; /* Allow shrinking in flex context */
@@ -1872,7 +1890,7 @@ header h1 .tagline {
.message .address {
color: var(--accent-green);
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
margin-bottom: 8px;
}
@@ -1885,7 +1903,7 @@ header h1 .tagline {
}
.message .content.numeric {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 15px;
letter-spacing: 2px;
color: var(--accent-cyan);
@@ -2106,7 +2124,7 @@ header h1 .tagline {
text-transform: uppercase;
letter-spacing: 1px;
transition: all 0.2s ease;
font-family: var(--font-sans);
font-family: 'Inter', sans-serif;
}
.control-btn:hover {
@@ -2374,7 +2392,7 @@ header h1 .tagline {
/* Dark theme for Leaflet */
.leaflet-container {
background: #0a0a0a;
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
}
/* Using actual dark tiles now - no filter needed */
@@ -2411,7 +2429,7 @@ header h1 .tagline {
display: flex;
justify-content: space-between;
z-index: 1000;
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--accent-cyan);
text-shadow: 0 0 5px var(--accent-cyan);
@@ -2428,7 +2446,7 @@ header h1 .tagline {
display: flex;
justify-content: space-between;
z-index: 1000;
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--accent-cyan);
text-shadow: 0 0 5px var(--accent-cyan);
@@ -2449,7 +2467,7 @@ header h1 .tagline {
}
.aircraft-popup {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
}
@@ -2493,7 +2511,7 @@ header h1 .tagline {
background: rgba(0, 0, 0, 0.8) !important;
border: 1px solid var(--accent-cyan) !important;
color: var(--accent-cyan) !important;
font-family: var(--font-mono) !important;
font-family: 'JetBrains Mono', monospace !important;
font-size: 10px !important;
padding: 2px 6px !important;
border-radius: 2px !important;
@@ -2521,7 +2539,7 @@ header h1 .tagline {
border-radius: 4px;
color: var(--text-secondary);
cursor: pointer;
font-family: var(--font-sans);
font-family: 'Inter', sans-serif;
font-size: 11px;
text-transform: uppercase;
transition: all 0.2s ease;
@@ -2737,7 +2755,7 @@ header h1 .tagline {
color: var(--accent-cyan);
font-size: 22px;
font-weight: 700;
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
text-shadow: 0 0 15px var(--accent-cyan-dim);
line-height: 1.2;
}
@@ -3131,7 +3149,7 @@ header h1 .tagline {
}
.sensor-card .data-value {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
color: var(--accent-cyan);
}
@@ -3181,7 +3199,7 @@ header h1 .tagline {
display: flex;
gap: 15px;
font-size: 10px;
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
}
.recon-stats span {
@@ -3231,14 +3249,14 @@ header h1 .tagline {
.device-id {
color: var(--text-dim);
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
}
.device-meta {
text-align: right;
color: var(--text-secondary);
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
}
.device-meta.encrypted {
@@ -3314,7 +3332,7 @@ header h1 .tagline {
}
.hex-dump {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--text-dim);
background: var(--bg-primary);
@@ -3405,7 +3423,7 @@ header h1 .tagline {
/* WiFi Main Content - 3 columns */
.wifi-main-content {
display: grid;
grid-template-columns: minmax(300px, 1fr) minmax(240px, 280px) minmax(240px, 280px);
grid-template-columns: 1fr minmax(240px, 280px) minmax(240px, 280px);
gap: 10px;
flex: 1;
min-height: 0;
@@ -3420,7 +3438,6 @@ header h1 .tagline {
border: 1px solid var(--border-color);
border-radius: 4px;
overflow: hidden;
min-width: 0; /* Prevent content from forcing panel wider */
}
.wifi-networks-header {
@@ -3588,8 +3605,6 @@ header h1 .tagline {
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 12px;
min-width: 0; /* Prevent content from forcing panel wider */
overflow: hidden;
}
.wifi-radar-panel h5 {
@@ -3825,90 +3840,10 @@ header h1 .tagline {
text-transform: uppercase;
}
.wifi-client-count-badge {
display: inline-block;
font-size: 10px;
padding: 1px 6px;
background: var(--accent-cyan);
color: var(--bg-primary);
border-radius: 10px;
font-weight: 600;
margin-left: 6px;
vertical-align: middle;
}
.wifi-client-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 200px;
overflow-y: auto;
}
.wifi-client-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 10px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 4px;
}
.wifi-client-identity {
display: flex;
flex-direction: column;
gap: 2px;
}
.wifi-client-mac {
font-family: monospace;
font-size: 12px;
color: var(--text-primary);
}
.wifi-client-vendor {
font-size: 10px;
color: var(--text-dim);
}
.wifi-client-probes {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 4px;
}
.wifi-client-probe-badge {
font-size: 9px;
padding: 2px 6px;
background: var(--bg-tertiary);
border-radius: 3px;
color: var(--text-secondary);
}
.wifi-client-signal {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
.wifi-client-rssi {
font-size: 12px;
font-weight: 500;
color: var(--text-primary);
}
.wifi-client-lastseen {
font-size: 9px;
color: var(--text-dim);
}
/* WiFi Responsive */
@media (max-width: 1400px) {
.wifi-main-content {
grid-template-columns: minmax(280px, 1fr) 240px 240px;
grid-template-columns: 1fr 240px 240px;
}
}
@@ -4066,7 +4001,7 @@ header h1 .tagline {
}
.bt-detail-address {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: #00d4ff;
}
@@ -4080,7 +4015,7 @@ header h1 .tagline {
}
.bt-detail-rssi-value {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 20px;
font-weight: 700;
}
@@ -4175,7 +4110,7 @@ header h1 .tagline {
}
.bt-detail-services-list {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 8px;
color: var(--text-dim);
white-space: nowrap;
@@ -4201,12 +4136,6 @@ header h1 .tagline {
color: #000;
}
.bt-detail-btn.active {
background: rgba(34, 197, 94, 0.2);
border-color: rgba(34, 197, 94, 0.6);
color: #9fffd1;
}
/* Selected device highlight */
.bt-device-row.selected {
background: rgba(0, 212, 255, 0.1);
@@ -4215,37 +4144,10 @@ header h1 .tagline {
.bt-device-list {
border-left-color: var(--accent-purple) !important;
display: flex;
flex-direction: column;
min-width: 280px;
max-width: 320px;
max-height: 100%;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 4px;
overflow: hidden;
}
.bt-device-list .wifi-device-list-content {
flex: 1;
overflow-y: auto;
min-height: 0;
}
.bt-device-list .wifi-device-list-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.bt-device-list .wifi-device-list-header h5 {
color: var(--accent-purple);
margin: 0;
font-size: 13px;
font-weight: 600;
}
/* Bluetooth Device Filters */
@@ -4255,7 +4157,6 @@ header h1 .tagline {
padding: 8px 12px;
border-bottom: 1px solid var(--border-color);
flex-wrap: wrap;
flex-shrink: 0;
}
.bt-filter-btn {
@@ -4398,17 +4299,6 @@ header h1 .tagline {
border: 1px solid rgba(139, 92, 246, 0.3);
}
.bt-history-badge {
display: inline-block;
padding: 1px 4px;
border-radius: 3px;
font-size: 8px;
font-weight: 600;
letter-spacing: 0.2px;
background: rgba(34, 197, 94, 0.15);
color: #22c55e;
}
.bt-device-name {
font-size: 13px;
font-weight: 600;
@@ -4439,7 +4329,7 @@ header h1 .tagline {
}
.bt-rssi-value {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
font-weight: 600;
min-width: 28px;
@@ -4798,7 +4688,7 @@ header h1 .tagline {
flex-direction: column;
gap: 4px;
font-size: 10px;
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
}
.security-legend-item {
@@ -4845,7 +4735,7 @@ header h1 .tagline {
}
.signal-value {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 28px;
color: var(--accent-cyan);
text-shadow: 0 0 10px var(--accent-cyan-dim);
@@ -4998,7 +4888,7 @@ body::before {
color: #000;
border: none;
padding: 12px 40px;
font-family: var(--font-sans);
font-family: 'Inter', sans-serif;
font-size: 14px;
font-weight: 600;
letter-spacing: 2px;
@@ -5361,7 +5251,7 @@ body::before {
.meter-value {
font-size: 10px;
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
color: var(--text-secondary);
width: 50px;
text-align: right;
@@ -5518,7 +5408,7 @@ body::before {
}
.freq-digits {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 56px;
font-weight: 700;
color: var(--accent-cyan);
@@ -5539,7 +5429,7 @@ body::before {
}
.freq-unit {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 20px;
color: var(--text-secondary);
margin-left: 8px;
@@ -5683,7 +5573,7 @@ body::before {
}
.knob-value {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 16px;
font-weight: 600;
color: var(--accent-cyan);
@@ -5808,7 +5698,7 @@ body::before {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
font-weight: 600;
border-radius: 4px;
@@ -5870,13 +5760,13 @@ body::before {
}
.signal-arc-label {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 8px;
fill: var(--text-muted);
}
.signal-arc-value {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
font-weight: 600;
fill: var(--accent-cyan);
@@ -5908,7 +5798,7 @@ body::before {
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--accent-cyan);
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
font-weight: 600;
text-align: center;
@@ -6044,7 +5934,7 @@ body::before {
max-height: 200px;
overflow-y: auto;
padding: 10px 15px;
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
}
@@ -6133,7 +6023,7 @@ body::before {
}
.module-header {
font-family: 'Orbitron', 'Space Mono', monospace;
font-family: 'Orbitron', 'JetBrains Mono', monospace;
font-size: 10px;
font-weight: 600;
color: var(--accent-cyan);
@@ -6158,7 +6048,7 @@ body::before {
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-primary);
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
padding: 6px 8px;
text-align: center;
@@ -6201,7 +6091,7 @@ body::before {
.radio-action-btn.listen {
background: var(--accent-green);
border-color: var(--accent-green);
color: #000;
color: #fff;
}
.radio-action-btn.scan:hover:not(:disabled),
@@ -6209,15 +6099,6 @@ body::before {
box-shadow: 0 0 15px rgba(0, 255, 136, 0.4);
}
.radio-action-btn.listen.active {
background: var(--accent-red);
border-color: var(--accent-red);
}
.radio-action-btn.listen.active:hover:not(:disabled) {
box-shadow: 0 0 20px var(--accent-red-dim);
}
/* Statistics Box */
.stat-box {
background: rgba(0, 0, 0, 0.3);
@@ -6227,7 +6108,7 @@ body::before {
}
.stat-value {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 22px;
font-weight: bold;
}
@@ -6275,7 +6156,7 @@ body::before {
.tune-btn {
padding: 4px 8px;
font-size: 10px;
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
background: var(--bg-elevated);
border: 1px solid var(--border-color);
color: var(--text-secondary);
@@ -6305,13 +6186,13 @@ body::before {
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
padding: 8px;
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
}
/* Listening Mode Selector Buttons */
.radio-mode-btn {
padding: 12px 24px;
font-family: 'Orbitron', 'Space Mono', monospace;
font-family: 'Orbitron', 'JetBrains Mono', monospace;
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
@@ -6352,7 +6233,7 @@ body::before {
/* Frequency Preset Buttons */
.preset-freq-btn {
padding: 8px 14px;
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
background: var(--bg-elevated);
border: 1px solid var(--border-color);
@@ -6416,4 +6297,4 @@ body::before {
[data-animations="off"] .logo-dot,
[data-animations="off"] .welcome-logo {
animation: none !important;
}
}
+6 -6
View File
@@ -37,7 +37,7 @@
/* Typography */
.landing-title {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 2.2rem;
font-weight: 700;
letter-spacing: 0.4em;
@@ -48,7 +48,7 @@
}
.landing-tagline {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
color: var(--accent-cyan);
font-size: 0.9rem;
letter-spacing: 0.15em;
@@ -71,7 +71,7 @@
/* Hacker Style Error */
.flash-error {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--accent-red);
background: rgba(239, 68, 68, 0.1);
@@ -94,7 +94,7 @@
color: var(--accent-cyan);
padding: 12px;
margin-bottom: 15px;
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
outline: none;
box-sizing: border-box; /* Crucial for visibility */
@@ -106,7 +106,7 @@
border: 2px solid var(--accent-cyan);
color: var(--accent-cyan);
padding: 15px;
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-weight: 600;
letter-spacing: 3px;
cursor: pointer;
@@ -116,7 +116,7 @@
.landing-version {
margin-top: 25px;
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: rgba(255, 255, 255, 0.3);
letter-spacing: 2px;
+328 -328
View File
@@ -1,328 +1,328 @@
/* APRS Function Bar (Stats Strip) Styles */
.aprs-strip {
background: linear-gradient(180deg, var(--bg-panel) 0%, var(--bg-dark) 100%);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 6px 12px;
margin-bottom: 10px;
overflow-x: auto;
}
.aprs-strip-inner {
display: flex;
align-items: center;
gap: 8px;
min-width: max-content;
}
.aprs-strip .strip-stat {
display: flex;
flex-direction: column;
align-items: center;
padding: 4px 10px;
background: rgba(74, 158, 255, 0.05);
border: 1px solid rgba(74, 158, 255, 0.15);
border-radius: 4px;
min-width: 55px;
}
.aprs-strip .strip-stat:hover {
background: rgba(74, 158, 255, 0.1);
border-color: rgba(74, 158, 255, 0.3);
}
.aprs-strip .strip-value {
font-family: var(--font-mono);
font-size: 14px;
font-weight: 600;
color: var(--accent-cyan);
line-height: 1.2;
}
.aprs-strip .strip-label {
font-size: 8px;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 1px;
}
.aprs-strip .strip-divider {
width: 1px;
height: 28px;
background: var(--border-color);
margin: 0 4px;
}
/* Signal stat coloring */
.aprs-strip .signal-stat.good .strip-value { color: var(--accent-green); }
.aprs-strip .signal-stat.warning .strip-value { color: var(--accent-yellow); }
.aprs-strip .signal-stat.poor .strip-value { color: var(--accent-red); }
/* Controls */
.aprs-strip .strip-control {
display: flex;
align-items: center;
gap: 4px;
}
.aprs-strip .strip-select {
background: rgba(0,0,0,0.3);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 4px 8px;
border-radius: 4px;
font-size: 10px;
cursor: pointer;
}
.aprs-strip .strip-select:hover {
border-color: var(--accent-cyan);
}
.aprs-strip .strip-input-label {
font-size: 9px;
color: var(--text-muted);
font-weight: 600;
}
.aprs-strip .strip-input {
background: rgba(0,0,0,0.3);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 4px 6px;
border-radius: 4px;
font-size: 10px;
width: 50px;
text-align: center;
}
.aprs-strip .strip-input:hover,
.aprs-strip .strip-input:focus {
border-color: var(--accent-cyan);
outline: none;
}
/* Tool Status Indicators */
.aprs-strip .strip-tools {
display: flex;
gap: 4px;
}
.aprs-strip .strip-tool {
font-size: 9px;
font-weight: 600;
padding: 3px 6px;
border-radius: 3px;
background: rgba(255, 59, 48, 0.2);
color: var(--accent-red);
border: 1px solid rgba(255, 59, 48, 0.3);
}
.aprs-strip .strip-tool.ok {
background: rgba(0, 255, 136, 0.1);
color: var(--accent-green);
border-color: rgba(0, 255, 136, 0.3);
}
/* Buttons */
.aprs-strip .strip-btn {
background: rgba(74, 158, 255, 0.1);
border: 1px solid rgba(74, 158, 255, 0.2);
color: var(--text-primary);
padding: 6px 12px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.aprs-strip .strip-btn:hover:not(:disabled) {
background: rgba(74, 158, 255, 0.2);
border-color: rgba(74, 158, 255, 0.4);
}
.aprs-strip .strip-btn.primary {
background: linear-gradient(135deg, var(--accent-green) 0%, #10b981 100%);
border: none;
color: #000;
}
.aprs-strip .strip-btn.primary:hover:not(:disabled) {
filter: brightness(1.1);
}
.aprs-strip .strip-btn.stop {
background: linear-gradient(135deg, var(--accent-red) 0%, #dc2626 100%);
border: none;
color: #fff;
}
.aprs-strip .strip-btn.stop:hover:not(:disabled) {
filter: brightness(1.1);
}
.aprs-strip .strip-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Status indicator */
.aprs-strip .strip-status {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: rgba(0,0,0,0.2);
border-radius: 4px;
font-size: 10px;
font-weight: 600;
color: var(--text-secondary);
}
.aprs-strip .status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-muted);
}
.aprs-strip .status-dot.listening {
background: var(--accent-cyan);
animation: aprs-strip-pulse 1.5s ease-in-out infinite;
}
.aprs-strip .status-dot.tracking {
background: var(--accent-green);
animation: aprs-strip-pulse 1.5s ease-in-out infinite;
}
.aprs-strip .status-dot.error {
background: var(--accent-red);
}
@keyframes aprs-strip-pulse {
0%, 100% { opacity: 1; box-shadow: 0 0 4px 2px currentColor; }
50% { opacity: 0.6; box-shadow: none; }
}
/* Time display */
.aprs-strip .strip-time {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-muted);
padding: 4px 8px;
background: rgba(0,0,0,0.2);
border-radius: 4px;
white-space: nowrap;
}
/* APRS Status Bar Styles (Sidebar - legacy) */
.aprs-status-bar {
margin-top: 12px;
padding: 10px;
background: rgba(0,0,0,0.3);
border: 1px solid var(--border-color);
border-radius: 4px;
}
.aprs-status-indicator {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.aprs-status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--text-muted);
}
.aprs-status-dot.standby { background: var(--text-muted); }
.aprs-status-dot.listening {
background: var(--accent-cyan);
animation: aprs-pulse 1.5s ease-in-out infinite;
}
.aprs-status-dot.tracking { background: var(--accent-green); }
.aprs-status-dot.error { background: var(--accent-red); }
@keyframes aprs-pulse {
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(74, 158, 255, 0.7); }
50% { opacity: 0.6; box-shadow: 0 0 8px 4px rgba(74, 158, 255, 0.3); }
}
.aprs-status-text {
font-size: 10px;
font-weight: bold;
letter-spacing: 1px;
}
.aprs-status-stats {
display: flex;
flex-wrap: wrap;
gap: 8px;
font-size: 9px;
}
.aprs-stat {
color: var(--text-secondary);
}
.aprs-stat-label {
color: var(--text-muted);
}
/* Signal Meter Styles */
.aprs-signal-meter {
margin-top: 12px;
padding: 10px;
background: rgba(0,0,0,0.3);
border: 1px solid var(--border-color);
border-radius: 4px;
}
.aprs-meter-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.aprs-meter-label {
font-size: 10px;
font-weight: bold;
letter-spacing: 1px;
color: var(--text-secondary);
}
.aprs-meter-value {
font-size: 12px;
font-weight: bold;
font-family: monospace;
color: var(--accent-cyan);
min-width: 24px;
}
.aprs-meter-burst {
font-size: 9px;
font-weight: bold;
color: var(--accent-yellow);
background: rgba(255, 193, 7, 0.2);
padding: 2px 6px;
border-radius: 3px;
animation: burst-flash 0.3s ease-out;
}
@keyframes burst-flash {
0% { opacity: 1; transform: scale(1.1); }
100% { opacity: 1; transform: scale(1); }
}
.aprs-meter-bar-container {
position: relative;
height: 16px;
background: rgba(0,0,0,0.4);
border-radius: 3px;
overflow: hidden;
margin-bottom: 4px;
}
.aprs-meter-bar {
height: 100%;
width: 0%;
background: linear-gradient(90deg,
var(--accent-green) 0%,
var(--accent-cyan) 50%,
var(--accent-yellow) 75%,
var(--accent-red) 100%
);
border-radius: 3px;
transition: width 0.1s ease-out;
}
.aprs-meter-bar.no-signal {
opacity: 0.3;
}
.aprs-meter-ticks {
display: flex;
justify-content: space-between;
font-size: 8px;
color: var(--text-muted);
padding: 0 2px;
}
.aprs-meter-status {
font-size: 9px;
color: var(--text-muted);
text-align: center;
margin-top: 6px;
}
.aprs-meter-status.active {
color: var(--accent-green);
}
.aprs-meter-status.no-signal {
color: var(--accent-yellow);
}
/* APRS Function Bar (Stats Strip) Styles */
.aprs-strip {
background: linear-gradient(180deg, var(--bg-panel) 0%, var(--bg-dark) 100%);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 6px 12px;
margin-bottom: 10px;
overflow-x: auto;
}
.aprs-strip-inner {
display: flex;
align-items: center;
gap: 8px;
min-width: max-content;
}
.aprs-strip .strip-stat {
display: flex;
flex-direction: column;
align-items: center;
padding: 4px 10px;
background: rgba(74, 158, 255, 0.05);
border: 1px solid rgba(74, 158, 255, 0.15);
border-radius: 4px;
min-width: 55px;
}
.aprs-strip .strip-stat:hover {
background: rgba(74, 158, 255, 0.1);
border-color: rgba(74, 158, 255, 0.3);
}
.aprs-strip .strip-value {
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
font-weight: 600;
color: var(--accent-cyan);
line-height: 1.2;
}
.aprs-strip .strip-label {
font-size: 8px;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 1px;
}
.aprs-strip .strip-divider {
width: 1px;
height: 28px;
background: var(--border-color);
margin: 0 4px;
}
/* Signal stat coloring */
.aprs-strip .signal-stat.good .strip-value { color: var(--accent-green); }
.aprs-strip .signal-stat.warning .strip-value { color: var(--accent-yellow); }
.aprs-strip .signal-stat.poor .strip-value { color: var(--accent-red); }
/* Controls */
.aprs-strip .strip-control {
display: flex;
align-items: center;
gap: 4px;
}
.aprs-strip .strip-select {
background: rgba(0,0,0,0.3);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 4px 8px;
border-radius: 4px;
font-size: 10px;
cursor: pointer;
}
.aprs-strip .strip-select:hover {
border-color: var(--accent-cyan);
}
.aprs-strip .strip-input-label {
font-size: 9px;
color: var(--text-muted);
font-weight: 600;
}
.aprs-strip .strip-input {
background: rgba(0,0,0,0.3);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 4px 6px;
border-radius: 4px;
font-size: 10px;
width: 50px;
text-align: center;
}
.aprs-strip .strip-input:hover,
.aprs-strip .strip-input:focus {
border-color: var(--accent-cyan);
outline: none;
}
/* Tool Status Indicators */
.aprs-strip .strip-tools {
display: flex;
gap: 4px;
}
.aprs-strip .strip-tool {
font-size: 9px;
font-weight: 600;
padding: 3px 6px;
border-radius: 3px;
background: rgba(255, 59, 48, 0.2);
color: var(--accent-red);
border: 1px solid rgba(255, 59, 48, 0.3);
}
.aprs-strip .strip-tool.ok {
background: rgba(0, 255, 136, 0.1);
color: var(--accent-green);
border-color: rgba(0, 255, 136, 0.3);
}
/* Buttons */
.aprs-strip .strip-btn {
background: rgba(74, 158, 255, 0.1);
border: 1px solid rgba(74, 158, 255, 0.2);
color: var(--text-primary);
padding: 6px 12px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.aprs-strip .strip-btn:hover:not(:disabled) {
background: rgba(74, 158, 255, 0.2);
border-color: rgba(74, 158, 255, 0.4);
}
.aprs-strip .strip-btn.primary {
background: linear-gradient(135deg, var(--accent-green) 0%, #10b981 100%);
border: none;
color: #000;
}
.aprs-strip .strip-btn.primary:hover:not(:disabled) {
filter: brightness(1.1);
}
.aprs-strip .strip-btn.stop {
background: linear-gradient(135deg, var(--accent-red) 0%, #dc2626 100%);
border: none;
color: #fff;
}
.aprs-strip .strip-btn.stop:hover:not(:disabled) {
filter: brightness(1.1);
}
.aprs-strip .strip-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Status indicator */
.aprs-strip .strip-status {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: rgba(0,0,0,0.2);
border-radius: 4px;
font-size: 10px;
font-weight: 600;
color: var(--text-secondary);
}
.aprs-strip .status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-muted);
}
.aprs-strip .status-dot.listening {
background: var(--accent-cyan);
animation: aprs-strip-pulse 1.5s ease-in-out infinite;
}
.aprs-strip .status-dot.tracking {
background: var(--accent-green);
animation: aprs-strip-pulse 1.5s ease-in-out infinite;
}
.aprs-strip .status-dot.error {
background: var(--accent-red);
}
@keyframes aprs-strip-pulse {
0%, 100% { opacity: 1; box-shadow: 0 0 4px 2px currentColor; }
50% { opacity: 0.6; box-shadow: none; }
}
/* Time display */
.aprs-strip .strip-time {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--text-muted);
padding: 4px 8px;
background: rgba(0,0,0,0.2);
border-radius: 4px;
white-space: nowrap;
}
/* APRS Status Bar Styles (Sidebar - legacy) */
.aprs-status-bar {
margin-top: 12px;
padding: 10px;
background: rgba(0,0,0,0.3);
border: 1px solid var(--border-color);
border-radius: 4px;
}
.aprs-status-indicator {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.aprs-status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--text-muted);
}
.aprs-status-dot.standby { background: var(--text-muted); }
.aprs-status-dot.listening {
background: var(--accent-cyan);
animation: aprs-pulse 1.5s ease-in-out infinite;
}
.aprs-status-dot.tracking { background: var(--accent-green); }
.aprs-status-dot.error { background: var(--accent-red); }
@keyframes aprs-pulse {
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(74, 158, 255, 0.7); }
50% { opacity: 0.6; box-shadow: 0 0 8px 4px rgba(74, 158, 255, 0.3); }
}
.aprs-status-text {
font-size: 10px;
font-weight: bold;
letter-spacing: 1px;
}
.aprs-status-stats {
display: flex;
flex-wrap: wrap;
gap: 8px;
font-size: 9px;
}
.aprs-stat {
color: var(--text-secondary);
}
.aprs-stat-label {
color: var(--text-muted);
}
/* Signal Meter Styles */
.aprs-signal-meter {
margin-top: 12px;
padding: 10px;
background: rgba(0,0,0,0.3);
border: 1px solid var(--border-color);
border-radius: 4px;
}
.aprs-meter-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.aprs-meter-label {
font-size: 10px;
font-weight: bold;
letter-spacing: 1px;
color: var(--text-secondary);
}
.aprs-meter-value {
font-size: 12px;
font-weight: bold;
font-family: monospace;
color: var(--accent-cyan);
min-width: 24px;
}
.aprs-meter-burst {
font-size: 9px;
font-weight: bold;
color: var(--accent-yellow);
background: rgba(255, 193, 7, 0.2);
padding: 2px 6px;
border-radius: 3px;
animation: burst-flash 0.3s ease-out;
}
@keyframes burst-flash {
0% { opacity: 1; transform: scale(1.1); }
100% { opacity: 1; transform: scale(1); }
}
.aprs-meter-bar-container {
position: relative;
height: 16px;
background: rgba(0,0,0,0.4);
border-radius: 3px;
overflow: hidden;
margin-bottom: 4px;
}
.aprs-meter-bar {
height: 100%;
width: 0%;
background: linear-gradient(90deg,
var(--accent-green) 0%,
var(--accent-cyan) 50%,
var(--accent-yellow) 75%,
var(--accent-red) 100%
);
border-radius: 3px;
transition: width 0.1s ease-out;
}
.aprs-meter-bar.no-signal {
opacity: 0.3;
}
.aprs-meter-ticks {
display: flex;
justify-content: space-between;
font-size: 8px;
color: var(--text-muted);
padding: 0 2px;
}
.aprs-meter-status {
font-size: 9px;
color: var(--text-muted);
text-align: center;
margin-top: 6px;
}
.aprs-meter-status.active {
color: var(--accent-green);
}
.aprs-meter-status.no-signal {
color: var(--accent-yellow);
}
File diff suppressed because it is too large Load Diff
+8 -8
View File
@@ -27,7 +27,7 @@
}
.spy-stations-title {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
@@ -101,7 +101,7 @@
}
.spy-station-name {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
@@ -117,7 +117,7 @@
/* Type Badge */
.spy-station-badge {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
@@ -173,7 +173,7 @@
}
.spy-meta-mode {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--accent-orange);
}
@@ -186,7 +186,7 @@
}
.spy-freq-list {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--accent-cyan);
line-height: 1.6;
@@ -199,7 +199,7 @@
}
.spy-freq-item {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--accent-cyan);
background: var(--bg-secondary);
@@ -236,7 +236,7 @@
}
.spy-freq-select {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
padding: 6px 8px;
background: var(--bg-secondary);
@@ -273,7 +273,7 @@
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
-668
View File
@@ -1,668 +0,0 @@
/**
* SSTV General Mode Styles
* Terrestrial Slow-Scan Television decoder interface
*/
/* ============================================
MODE VISIBILITY
============================================ */
#sstvGeneralMode.active {
display: block !important;
}
/* ============================================
VISUALS CONTAINER
============================================ */
.sstv-general-visuals-container {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
min-height: 0;
flex: 1;
height: 100%;
overflow: hidden;
}
/* ============================================
STATS STRIP
============================================ */
.sstv-general-stats-strip {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 14px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 8px;
flex-wrap: wrap;
flex-shrink: 0;
}
.sstv-general-strip-group {
display: flex;
align-items: center;
gap: 12px;
}
.sstv-general-strip-status {
display: flex;
align-items: center;
gap: 6px;
}
.sstv-general-strip-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.sstv-general-strip-dot.idle {
background: var(--text-dim);
}
.sstv-general-strip-dot.listening {
background: var(--accent-yellow);
animation: sstv-general-pulse 1s infinite;
}
.sstv-general-strip-dot.decoding {
background: var(--accent-cyan);
box-shadow: 0 0 6px var(--accent-cyan);
animation: sstv-general-pulse 0.5s infinite;
}
.sstv-general-strip-status-text {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-secondary);
text-transform: uppercase;
}
.sstv-general-strip-btn {
font-family: var(--font-mono);
font-size: 10px;
padding: 5px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
text-transform: uppercase;
font-weight: 600;
transition: all 0.15s ease;
}
.sstv-general-strip-btn.start {
background: var(--accent-cyan);
color: var(--bg-primary);
}
.sstv-general-strip-btn.start:hover {
background: var(--accent-cyan-bright, #00d4ff);
}
.sstv-general-strip-btn.stop {
background: var(--accent-red, #ff3366);
color: white;
}
.sstv-general-strip-btn.stop:hover {
background: #ff1a53;
}
.sstv-general-strip-divider {
width: 1px;
height: 24px;
background: var(--border-color);
}
.sstv-general-strip-stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
min-width: 50px;
}
.sstv-general-strip-value {
font-family: var(--font-mono);
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
}
.sstv-general-strip-value.accent-cyan {
color: var(--accent-cyan);
}
.sstv-general-strip-label {
font-family: var(--font-mono);
font-size: 8px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* ============================================
MAIN ROW (Live Decode + Gallery)
============================================ */
.sstv-general-main-row {
display: flex;
flex-direction: row;
gap: 12px;
flex: 1;
min-height: 0;
overflow: hidden;
}
/* ============================================
LIVE DECODE SECTION
============================================ */
.sstv-general-live-section {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
flex: 1;
min-width: 300px;
}
.sstv-general-live-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: rgba(0, 0, 0, 0.2);
border-bottom: 1px solid var(--border-color);
}
.sstv-general-live-title {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--font-mono);
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
}
.sstv-general-live-title svg {
color: var(--accent-cyan);
}
.sstv-general-live-content {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 16px;
min-height: 0;
}
.sstv-general-canvas-container {
position: relative;
background: #000;
border: 1px solid var(--border-color);
border-radius: 4px;
overflow: hidden;
}
.sstv-general-decode-info {
width: 100%;
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.sstv-general-mode-label {
font-family: var(--font-mono);
font-size: 11px;
color: var(--accent-cyan);
text-align: center;
}
.sstv-general-progress-bar {
width: 100%;
height: 4px;
background: var(--bg-secondary);
border-radius: 2px;
overflow: hidden;
}
.sstv-general-progress-bar .progress {
height: 100%;
background: linear-gradient(90deg, var(--accent-cyan), var(--accent-green));
border-radius: 2px;
transition: width 0.3s ease;
}
.sstv-general-status-message {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-dim);
text-align: center;
}
/* Idle state */
.sstv-general-idle-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 40px 20px;
color: var(--text-dim);
}
.sstv-general-idle-state svg {
width: 64px;
height: 64px;
opacity: 0.3;
margin-bottom: 16px;
}
.sstv-general-idle-state h4 {
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 8px;
}
.sstv-general-idle-state p {
font-size: 12px;
max-width: 250px;
}
/* ============================================
GALLERY SECTION
============================================ */
.sstv-general-gallery-section {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
flex: 1.5;
min-width: 300px;
}
.sstv-general-gallery-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: rgba(0, 0, 0, 0.2);
border-bottom: 1px solid var(--border-color);
}
.sstv-general-gallery-title {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--font-mono);
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
}
.sstv-general-gallery-count {
font-family: var(--font-mono);
font-size: 10px;
color: var(--accent-cyan);
background: var(--bg-secondary);
padding: 2px 8px;
border-radius: 10px;
}
.sstv-general-gallery-grid {
flex: 1;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
padding: 12px;
overflow-y: auto;
align-content: start;
}
.sstv-general-image-card {
position: relative;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
overflow: hidden;
transition: all 0.15s ease;
}
.sstv-general-image-card:hover {
border-color: var(--accent-cyan);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 212, 255, 0.2);
}
.sstv-general-image-card-inner {
cursor: pointer;
}
.sstv-general-image-preview {
width: 100%;
aspect-ratio: 4/3;
object-fit: cover;
background: #000;
display: block;
}
.sstv-general-image-actions {
position: absolute;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: flex-end;
gap: 4px;
padding: 6px;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
opacity: 0;
transition: opacity 0.15s;
}
.sstv-general-image-card:hover .sstv-general-image-actions {
opacity: 1;
}
.sstv-general-image-actions button {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
color: white;
cursor: pointer;
transition: all 0.15s;
}
.sstv-general-image-actions button:hover {
background: rgba(255, 255, 255, 0.25);
}
.sstv-general-image-actions button:last-child:hover {
background: var(--accent-red, #ff3366);
border-color: var(--accent-red, #ff3366);
}
.sstv-general-image-info {
padding: 8px 10px;
border-top: 1px solid var(--border-color);
}
.sstv-general-image-mode {
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
color: var(--accent-cyan);
margin-bottom: 4px;
}
.sstv-general-image-timestamp {
font-family: var(--font-mono);
font-size: 9px;
color: var(--text-dim);
}
/* Empty gallery state */
.sstv-general-gallery-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
color: var(--text-dim);
grid-column: 1 / -1;
}
.sstv-general-gallery-empty svg {
width: 48px;
height: 48px;
opacity: 0.3;
margin-bottom: 12px;
}
/* ============================================
SIGNAL MONITOR
============================================ */
.sstv-general-signal-monitor {
width: 100%;
max-width: 320px;
padding: 16px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
}
.sstv-general-signal-monitor-header {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 14px;
}
.sstv-general-signal-monitor-header svg {
color: var(--accent-cyan);
}
.sstv-general-signal-level-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.sstv-general-signal-level-label {
font-family: var(--font-mono);
font-size: 9px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
flex-shrink: 0;
}
.sstv-general-signal-bar-track {
flex: 1;
height: 6px;
background: var(--bg-primary);
border-radius: 3px;
overflow: hidden;
}
.sstv-general-signal-bar-fill {
height: 100%;
border-radius: 3px;
transition: width 0.3s ease, background 0.3s ease;
background: var(--text-dim);
}
.sstv-general-signal-level-value {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 600;
color: var(--text-primary);
min-width: 24px;
text-align: right;
}
.sstv-general-signal-status-text {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-dim);
text-align: center;
}
.sstv-general-signal-vis-state {
font-family: var(--font-mono);
font-size: 9px;
color: var(--text-dim);
text-align: center;
margin-top: 6px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.sstv-general-signal-vis-state.active {
color: var(--accent-cyan);
}
/* ============================================
IMAGE MODAL
============================================ */
.sstv-general-image-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.9);
display: none;
align-items: center;
justify-content: center;
z-index: 10000;
padding: 40px;
}
.sstv-general-image-modal.show {
display: flex;
}
.sstv-general-image-modal img {
max-width: 100%;
max-height: 100%;
border: 1px solid var(--border-color);
border-radius: 4px;
}
.sstv-general-modal-toolbar {
position: absolute;
top: 20px;
right: 60px;
display: flex;
gap: 8px;
z-index: 1;
}
.sstv-general-modal-btn {
display: flex;
align-items: center;
gap: 6px;
font-family: var(--font-mono);
font-size: 10px;
padding: 6px 12px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
color: white;
cursor: pointer;
transition: all 0.15s;
text-transform: uppercase;
}
.sstv-general-modal-btn:hover {
background: rgba(255, 255, 255, 0.2);
}
.sstv-general-modal-btn.delete:hover {
background: var(--accent-red, #ff3366);
border-color: var(--accent-red, #ff3366);
}
.sstv-general-modal-close {
position: absolute;
top: 20px;
right: 20px;
background: none;
border: none;
color: white;
font-size: 32px;
cursor: pointer;
opacity: 0.7;
transition: opacity 0.15s;
z-index: 1;
}
.sstv-general-modal-close:hover {
opacity: 1;
}
/* Clear All button */
.sstv-general-gallery-clear-btn {
font-family: var(--font-mono);
font-size: 9px;
text-transform: uppercase;
padding: 3px 8px;
border-radius: 4px;
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-dim);
cursor: pointer;
transition: all 0.15s;
margin-left: 8px;
}
.sstv-general-gallery-clear-btn:hover {
color: var(--accent-red, #ff3366);
border-color: var(--accent-red, #ff3366);
}
/* ============================================
RESPONSIVE
============================================ */
@media (max-width: 1024px) {
.sstv-general-main-row {
flex-direction: column;
overflow-y: auto;
}
.sstv-general-live-section {
max-width: none;
min-height: 350px;
}
.sstv-general-gallery-section {
min-height: 300px;
}
}
@media (max-width: 768px) {
.sstv-general-stats-strip {
padding: 8px 12px;
gap: 8px;
flex-wrap: wrap;
}
.sstv-general-strip-divider {
display: none;
}
.sstv-general-gallery-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 8px;
padding: 8px;
}
}
@keyframes sstv-general-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
+876 -1067
View File
File diff suppressed because it is too large Load Diff
+1468 -1693
View File
File diff suppressed because it is too large Load Diff
+660 -660
View File
File diff suppressed because it is too large Load Diff
+31 -84
View File
@@ -1,3 +1,11 @@
/**
* Satellite Dashboard Styles
* Imports design tokens for consistency, with dashboard-specific overrides
*/
@import url('./core/variables.css');
@import url('./core/layout.css');
@import url('./core/components.css');
* {
margin: 0;
padding: 0;
@@ -5,28 +13,16 @@
}
:root {
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
--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");
/* Dashboard-specific aliases (for backward compatibility) */
--bg-dark: var(--bg-primary);
--bg-panel: var(--bg-secondary);
--bg-card: var(--bg-tertiary);
/* Use tokens for shared values, keep custom values for dashboard-specific */
--grid-line: rgba(74, 158, 255, 0.08);
}
body {
font-family: var(--font-sans);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-dark);
color: var(--text-primary);
min-height: 100vh;
@@ -41,10 +37,9 @@ 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: 40px 40px, 50px 50px, 50px 50px;
background-size: 50px 50px;
animation: gridMove 20s linear infinite;
pointer-events: none;
z-index: 0;
@@ -66,14 +61,12 @@ body {
top: 0;
left: 0;
right: 0;
height: 2px;
height: 4px;
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
color: var(--accent-cyan);
animation: scan 6s linear infinite;
animation: scan 3s linear infinite;
pointer-events: none;
z-index: 1000;
opacity: 0.25;
box-shadow: 0 0 8px currentColor;
opacity: 0.5;
}
@keyframes scan {
@@ -98,20 +91,8 @@ 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-family: 'Inter', sans-serif;
font-size: 20px;
font-weight: 700;
letter-spacing: 3px;
@@ -160,7 +141,7 @@ body {
border: 1px solid rgba(0, 212, 255, 0.3);
border-radius: 4px;
padding: 4px 10px;
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
}
@@ -180,45 +161,10 @@ body {
.status-bar {
display: flex;
gap: 12px;
gap: 20px;
align-items: center;
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
flex-wrap: nowrap;
}
.location-selector {
display: flex;
align-items: center;
gap: 8px;
}
.location-selector .location-label {
color: var(--text-secondary);
font-size: 10px;
}
.location-selector .location-select {
padding: 4px 8px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-primary);
font-size: 11px;
font-family: var(--font-mono);
}
.location-selector .location-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent-green);
box-shadow: 0 0 6px var(--accent-green);
}
.location-selector .location-status-dot.offline {
background: var(--accent-red);
box-shadow: 0 0 6px var(--accent-red);
}
.status-item {
@@ -264,7 +210,7 @@ body {
}
/* Main dashboard grid */
/* Header ~52px + Nav 44px = ~96px, using 100px for safety */
/* Header ~60px + Controls bar ~55px = ~115px */
.dashboard {
position: relative;
z-index: 10;
@@ -272,7 +218,8 @@ body {
grid-template-columns: 1fr 1fr 340px;
grid-template-rows: 1fr auto;
gap: 0;
height: calc(100vh - 100px);
height: calc(100dvh - 115px);
height: calc(100vh - 115px); /* Fallback */
min-height: 500px;
}
@@ -511,7 +458,7 @@ body {
}
.telemetry-value {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
color: var(--accent-cyan);
}
@@ -597,7 +544,7 @@ body {
}
.pass-time {
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
}
/* Bottom controls bar */
@@ -633,7 +580,7 @@ body {
border: 1px solid rgba(0, 212, 255, 0.3);
border-radius: 4px;
color: var(--accent-cyan);
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
}
@@ -750,7 +697,7 @@ body {
display: flex;
flex-direction: column;
height: auto;
min-height: calc(100vh - 100px);
min-height: calc(100dvh - 115px);
}
.polar-container,
@@ -802,4 +749,4 @@ body.embedded .panel {
body.embedded .controls-bar {
padding: 10px 15px;
}
}
+399 -490
View File
@@ -1,490 +1,399 @@
/* Settings Modal Styles */
.settings-modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.85);
z-index: 10000;
overflow-y: auto;
backdrop-filter: blur(4px);
}
.settings-modal.active {
display: flex;
justify-content: center;
align-items: flex-start;
padding: 40px 20px;
}
.settings-content {
background: var(--bg-dark, #0a0a0f);
border: 1px solid var(--border-color, #1a1a2e);
border-radius: 8px;
max-width: 600px;
width: 100%;
position: relative;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.settings-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border-color, #1a1a2e);
}
.settings-header h2 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-primary, #e0e0e0);
display: flex;
align-items: center;
gap: 8px;
}
.settings-header h2 .icon {
width: 20px;
height: 20px;
color: var(--accent-cyan, #00d4ff);
}
.settings-close {
background: none;
border: none;
color: var(--text-muted, #666);
font-size: 24px;
cursor: pointer;
padding: 4px;
line-height: 1;
transition: color 0.2s;
}
.settings-close:hover {
color: var(--accent-red, #ff4444);
}
/* Settings Tabs */
.settings-tabs {
display: flex;
border-bottom: 1px solid var(--border-color, #1a1a2e);
padding: 0 20px;
gap: 4px;
}
.settings-tab {
background: none;
border: none;
padding: 12px 16px;
color: var(--text-muted, #666);
font-size: 13px;
font-weight: 500;
cursor: pointer;
position: relative;
transition: color 0.2s;
}
.settings-tab:hover {
color: var(--text-primary, #e0e0e0);
}
.settings-tab.active {
color: var(--accent-cyan, #00d4ff);
}
.settings-tab.active::after {
content: '';
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 2px;
background: var(--accent-cyan, #00d4ff);
}
/* Settings Sections */
.settings-section {
display: none;
padding: 20px;
}
.settings-section.active {
display: block;
}
.settings-group {
margin-bottom: 24px;
}
.settings-group:last-child {
margin-bottom: 0;
}
.settings-group-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted, #666);
margin-bottom: 12px;
}
/* Settings Row */
.settings-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.settings-row:last-child {
border-bottom: none;
}
.settings-label {
display: flex;
flex-direction: column;
gap: 2px;
}
.settings-label-text {
font-size: 13px;
color: var(--text-primary, #e0e0e0);
}
.settings-label-desc {
font-size: 11px;
color: var(--text-muted, #666);
}
/* Settings Feed Lists */
.settings-feed {
background: var(--bg-tertiary, #12121f);
border: 1px solid var(--border-color, #1a1a2e);
border-radius: 6px;
padding: 8px;
max-height: 240px;
overflow-y: auto;
}
.settings-feed-item {
padding: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
font-size: 11px;
}
.settings-feed-item:last-child {
border-bottom: none;
}
.settings-feed-title {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
color: var(--text-primary, #e0e0e0);
margin-bottom: 4px;
}
.settings-feed-meta {
color: var(--text-muted, #666);
font-size: 10px;
}
.settings-feed-empty {
color: var(--text-dim, #666);
text-align: center;
padding: 20px 10px;
font-size: 11px;
}
/* Toggle Switch */
.toggle-switch {
position: relative;
width: 44px;
height: 24px;
flex-shrink: 0;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--bg-tertiary, #1a1a2e);
border: 1px solid var(--border-color, #2a2a3e);
transition: 0.3s;
border-radius: 24px;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 2px;
bottom: 2px;
background-color: var(--text-muted, #666);
transition: 0.3s;
border-radius: 50%;
}
.toggle-switch input:checked + .toggle-slider {
background-color: var(--accent-cyan, #00d4ff);
border-color: var(--accent-cyan, #00d4ff);
}
.toggle-switch input:checked + .toggle-slider:before {
transform: translateX(20px);
background-color: white;
}
.toggle-switch input:focus + .toggle-slider {
box-shadow: 0 0 0 2px rgba(0, 212, 255, 0.3);
}
/* Select Dropdown */
.settings-select {
background: var(--bg-tertiary, #1a1a2e);
border: 1px solid var(--border-color, #2a2a3e);
border-radius: 4px;
padding: 8px 12px;
font-size: 13px;
color: var(--text-primary, #e0e0e0);
min-width: 160px;
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
padding-right: 32px;
}
.settings-select:focus {
outline: none;
border-color: var(--accent-cyan, #00d4ff);
}
/* Text Input */
.settings-input {
background: var(--bg-tertiary, #1a1a2e);
border: 1px solid var(--border-color, #2a2a3e);
border-radius: 4px;
padding: 8px 12px;
font-size: 13px;
color: var(--text-primary, #e0e0e0);
width: 200px;
}
.settings-input:focus {
outline: none;
border-color: var(--accent-cyan, #00d4ff);
}
.settings-input::placeholder {
color: var(--text-muted, #666);
}
/* Asset Status */
.asset-status {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
padding: 12px;
background: var(--bg-secondary, #0f0f1a);
border-radius: 6px;
}
.asset-status-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
}
.asset-name {
color: var(--text-muted, #888);
}
.asset-badge {
padding: 2px 8px;
border-radius: 10px;
font-size: 10px;
font-weight: 500;
text-transform: uppercase;
}
.asset-badge.available {
background: rgba(0, 255, 136, 0.15);
color: var(--accent-green, #00ff88);
}
.asset-badge.missing {
background: rgba(255, 68, 68, 0.15);
color: var(--accent-red, #ff4444);
}
.asset-badge.checking {
background: rgba(255, 170, 0, 0.15);
color: var(--accent-orange, #ffaa00);
}
/* Check Assets Button */
.check-assets-btn {
background: var(--bg-tertiary, #1a1a2e);
border: 1px solid var(--border-color, #2a2a3e);
color: var(--text-primary, #e0e0e0);
padding: 8px 16px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
margin-top: 12px;
transition: all 0.2s;
}
.check-assets-btn:hover {
border-color: var(--accent-cyan, #00d4ff);
color: var(--accent-cyan, #00d4ff);
}
.check-assets-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* GPS Detection Spinner */
.detecting-spinner {
display: inline-block;
width: 12px;
height: 12px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: detecting-spin 0.8s linear infinite;
vertical-align: middle;
margin-right: 6px;
}
@keyframes detecting-spin {
to { transform: rotate(360deg); }
}
/* About Section */
.about-info {
font-size: 13px;
color: var(--text-muted, #888);
line-height: 1.6;
}
.about-info p {
margin: 0 0 12px 0;
}
.about-info a {
color: var(--accent-cyan, #00d4ff);
text-decoration: none;
}
.about-info a:hover {
text-decoration: underline;
}
.about-version {
font-family: var(--font-mono);
color: var(--accent-cyan, #00d4ff);
}
/* Donate Button */
.donate-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 10px 20px;
background: linear-gradient(135deg, var(--accent-amber, #d4a853) 0%, var(--accent-orange, #f59e0b) 100%);
border: none;
border-radius: 6px;
color: #000;
font-size: 13px;
font-weight: 600;
text-decoration: none;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 2px 8px rgba(212, 168, 83, 0.3);
}
.donate-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(212, 168, 83, 0.4);
filter: brightness(1.1);
}
.donate-btn:active {
transform: translateY(0);
}
/* Tile Provider Custom URL */
.custom-url-row {
margin-top: 8px;
padding-top: 8px;
}
.custom-url-row .settings-input {
width: 100%;
}
/* Info Callout */
.settings-info {
background: rgba(0, 212, 255, 0.1);
border: 1px solid rgba(0, 212, 255, 0.2);
border-radius: 6px;
padding: 12px;
margin-top: 16px;
font-size: 12px;
color: var(--text-muted, #888);
}
.settings-info strong {
color: var(--accent-cyan, #00d4ff);
}
/* Map tile variants */
.tile-layer-cyan {
filter: sepia(0.35) hue-rotate(185deg) saturate(1.75) brightness(1.06) contrast(1.05);
}
/* Responsive */
@media (max-width: 640px) {
.settings-modal.active {
padding: 20px 10px;
}
.settings-content {
max-width: 100%;
}
.settings-row {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.settings-select,
.settings-input {
width: 100%;
}
}
/* Settings Modal Styles */
.settings-modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.85);
z-index: 10000;
overflow-y: auto;
backdrop-filter: blur(4px);
}
.settings-modal.active {
display: flex;
justify-content: center;
align-items: flex-start;
padding: 40px 20px;
}
.settings-content {
background: var(--bg-dark, #0a0a0f);
border: 1px solid var(--border-color, #1a1a2e);
border-radius: 8px;
max-width: 600px;
width: 100%;
position: relative;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.settings-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border-color, #1a1a2e);
}
.settings-header h2 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-primary, #e0e0e0);
display: flex;
align-items: center;
gap: 8px;
}
.settings-header h2 .icon {
width: 20px;
height: 20px;
color: var(--accent-cyan, #00d4ff);
}
.settings-close {
background: none;
border: none;
color: var(--text-muted, #666);
font-size: 24px;
cursor: pointer;
padding: 4px;
line-height: 1;
transition: color 0.2s;
}
.settings-close:hover {
color: var(--accent-red, #ff4444);
}
/* Settings Tabs */
.settings-tabs {
display: flex;
border-bottom: 1px solid var(--border-color, #1a1a2e);
padding: 0 20px;
gap: 4px;
}
.settings-tab {
background: none;
border: none;
padding: 12px 16px;
color: var(--text-muted, #666);
font-size: 13px;
font-weight: 500;
cursor: pointer;
position: relative;
transition: color 0.2s;
}
.settings-tab:hover {
color: var(--text-primary, #e0e0e0);
}
.settings-tab.active {
color: var(--accent-cyan, #00d4ff);
}
.settings-tab.active::after {
content: '';
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 2px;
background: var(--accent-cyan, #00d4ff);
}
/* Settings Sections */
.settings-section {
display: none;
padding: 20px;
}
.settings-section.active {
display: block;
}
.settings-group {
margin-bottom: 24px;
}
.settings-group:last-child {
margin-bottom: 0;
}
.settings-group-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted, #666);
margin-bottom: 12px;
}
/* Settings Row */
.settings-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.settings-row:last-child {
border-bottom: none;
}
.settings-label {
display: flex;
flex-direction: column;
gap: 2px;
}
.settings-label-text {
font-size: 13px;
color: var(--text-primary, #e0e0e0);
}
.settings-label-desc {
font-size: 11px;
color: var(--text-muted, #666);
}
/* Toggle Switch */
.toggle-switch {
position: relative;
width: 44px;
height: 24px;
flex-shrink: 0;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--bg-tertiary, #1a1a2e);
border: 1px solid var(--border-color, #2a2a3e);
transition: 0.3s;
border-radius: 24px;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 2px;
bottom: 2px;
background-color: var(--text-muted, #666);
transition: 0.3s;
border-radius: 50%;
}
.toggle-switch input:checked + .toggle-slider {
background-color: var(--accent-cyan, #00d4ff);
border-color: var(--accent-cyan, #00d4ff);
}
.toggle-switch input:checked + .toggle-slider:before {
transform: translateX(20px);
background-color: white;
}
.toggle-switch input:focus + .toggle-slider {
box-shadow: 0 0 0 2px rgba(0, 212, 255, 0.3);
}
/* Select Dropdown */
.settings-select {
background: var(--bg-tertiary, #1a1a2e);
border: 1px solid var(--border-color, #2a2a3e);
border-radius: 4px;
padding: 8px 12px;
font-size: 13px;
color: var(--text-primary, #e0e0e0);
min-width: 160px;
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
padding-right: 32px;
}
.settings-select:focus {
outline: none;
border-color: var(--accent-cyan, #00d4ff);
}
/* Text Input */
.settings-input {
background: var(--bg-tertiary, #1a1a2e);
border: 1px solid var(--border-color, #2a2a3e);
border-radius: 4px;
padding: 8px 12px;
font-size: 13px;
color: var(--text-primary, #e0e0e0);
width: 200px;
}
.settings-input:focus {
outline: none;
border-color: var(--accent-cyan, #00d4ff);
}
.settings-input::placeholder {
color: var(--text-muted, #666);
}
/* Asset Status */
.asset-status {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
padding: 12px;
background: var(--bg-secondary, #0f0f1a);
border-radius: 6px;
}
.asset-status-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
}
.asset-name {
color: var(--text-muted, #888);
}
.asset-badge {
padding: 2px 8px;
border-radius: 10px;
font-size: 10px;
font-weight: 500;
text-transform: uppercase;
}
.asset-badge.available {
background: rgba(0, 255, 136, 0.15);
color: var(--accent-green, #00ff88);
}
.asset-badge.missing {
background: rgba(255, 68, 68, 0.15);
color: var(--accent-red, #ff4444);
}
.asset-badge.checking {
background: rgba(255, 170, 0, 0.15);
color: var(--accent-orange, #ffaa00);
}
/* Check Assets Button */
.check-assets-btn {
background: var(--bg-tertiary, #1a1a2e);
border: 1px solid var(--border-color, #2a2a3e);
color: var(--text-primary, #e0e0e0);
padding: 8px 16px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
margin-top: 12px;
transition: all 0.2s;
}
.check-assets-btn:hover {
border-color: var(--accent-cyan, #00d4ff);
color: var(--accent-cyan, #00d4ff);
}
.check-assets-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* About Section */
.about-info {
font-size: 13px;
color: var(--text-muted, #888);
line-height: 1.6;
}
.about-info p {
margin: 0 0 12px 0;
}
.about-info a {
color: var(--accent-cyan, #00d4ff);
text-decoration: none;
}
.about-info a:hover {
text-decoration: underline;
}
.about-version {
font-family: 'JetBrains Mono', monospace;
color: var(--accent-cyan, #00d4ff);
}
/* Tile Provider Custom URL */
.custom-url-row {
margin-top: 8px;
padding-top: 8px;
}
.custom-url-row .settings-input {
width: 100%;
}
/* Info Callout */
.settings-info {
background: rgba(0, 212, 255, 0.1);
border: 1px solid rgba(0, 212, 255, 0.2);
border-radius: 6px;
padding: 12px;
margin-top: 16px;
font-size: 12px;
color: var(--text-muted, #888);
}
.settings-info strong {
color: var(--accent-cyan, #00d4ff);
}
/* Responsive */
@media (max-width: 640px) {
.settings-modal.active {
padding: 20px 10px;
}
.settings-content {
max-width: 100%;
}
.settings-row {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.settings-select,
.settings-input {
width: 100%;
}
}
+14 -42
View File
@@ -33,9 +33,6 @@ 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
@@ -165,18 +162,8 @@ const ProximityRadar = (function() {
devices.set(device.device_key, device);
});
// 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);
// Apply filter and render
renderDevices();
}
/**
@@ -220,32 +207,25 @@ const ProximityRadar = (function() {
const pulseClass = isNew ? 'radar-dot-pulse' : '';
const isSelected = selectedDeviceKey && device.device_key === selectedDeviceKey;
// Hit area size (prevents hover flicker when scaling)
const hitAreaSize = Math.max(dotSize * 2, 15);
return `
<g 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 class="radar-device ${pulseClass}${isSelected ? ' selected' : ''}" data-device-key="${escapeAttr(device.device_key)}"
transform="translate(${x}, ${y})" style="cursor: pointer;">
${isSelected ? `<circle r="${dotSize + 8}" fill="none" stroke="#00d4ff" stroke-width="2" stroke-opacity="0.8">
<animate attributeName="r" values="${dotSize + 6};${dotSize + 10};${dotSize + 6}" dur="1.5s" repeatCount="indefinite"/>
<animate attributeName="stroke-opacity" values="0.8;0.4;0.8" dur="1.5s" repeatCount="indefinite"/>
</circle>` : ''}
<circle r="${dotSize}" fill="${color}"
fill-opacity="${isSelected ? 1 : 0.4 + confidence * 0.5}"
stroke="${isSelected ? '#00d4ff' : color}" stroke-width="${isSelected ? 2 : 1}" />
${device.is_new && !isSelected ? `<circle r="${dotSize + 3}" fill="none" stroke="#3b82f6" stroke-width="1" stroke-dasharray="2,2" />` : ''}
<title>${escapeHtml(device.name || device.address)} (${device.rssi_current || '--'} dBm)</title>
</g>
`;
}).join('');
devicesGroup.innerHTML = dots;
// Attach event handlers
// Attach click handlers
devicesGroup.querySelectorAll('.radar-device').forEach(el => {
el.addEventListener('click', (e) => {
const deviceKey = el.getAttribute('data-device-key');
@@ -253,14 +233,6 @@ const ProximityRadar = (function() {
onDeviceClick(deviceKey);
}
});
el.addEventListener('mouseenter', () => { isHovered = true; });
el.addEventListener('mouseleave', () => {
isHovered = false;
if (renderPending) {
renderPending = false;
renderDevices();
}
});
});
}
+18 -7
View File
@@ -865,12 +865,15 @@ function connectAgentStream(mode, onMessage) {
}
let streamUrl;
if (currentAgent === 'local') {
streamUrl = `/${mode}/stream`;
} else {
// For remote agents, proxy SSE through controller
streamUrl = `/controller/agents/${currentAgent}/${mode}/stream`;
}
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';
}
agentEventSource = new EventSource(streamUrl);
@@ -878,7 +881,15 @@ function connectAgentStream(mode, onMessage) {
try {
const data = JSON.parse(event.data);
onMessage(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);
} catch (e) {
console.error('Error parsing SSE message:', e);
}
-194
View File
@@ -1,194 +0,0 @@
const AlertCenter = (function() {
'use strict';
let alerts = [];
let rules = [];
let eventSource = null;
const TRACKER_RULE_NAME = 'Tracker Detected';
function init() {
loadRules();
loadFeed();
connect();
}
function connect() {
if (eventSource) {
eventSource.close();
}
eventSource = new EventSource('/alerts/stream');
eventSource.onmessage = function(e) {
try {
const data = JSON.parse(e.data);
if (data.type === 'keepalive') return;
handleAlert(data);
} catch (err) {
console.error('[Alerts] SSE parse error', err);
}
};
eventSource.onerror = function() {
console.warn('[Alerts] SSE connection error');
};
}
function handleAlert(alert) {
alerts.unshift(alert);
alerts = alerts.slice(0, 50);
updateFeedUI();
if (typeof showNotification === 'function') {
const severity = (alert.severity || '').toLowerCase();
if (['high', 'critical'].includes(severity)) {
showNotification(alert.title || 'Alert', alert.message || 'Alert triggered');
}
}
}
function updateFeedUI() {
const list = document.getElementById('alertsFeedList');
const countEl = document.getElementById('alertsFeedCount');
if (countEl) countEl.textContent = `(${alerts.length})`;
if (!list) return;
if (alerts.length === 0) {
list.innerHTML = '<div class="settings-feed-empty">No alerts yet</div>';
return;
}
list.innerHTML = alerts.map(alert => {
const title = escapeHtml(alert.title || 'Alert');
const message = escapeHtml(alert.message || '');
const severity = escapeHtml(alert.severity || 'medium');
const createdAt = alert.created_at ? new Date(alert.created_at).toLocaleString() : '';
return `
<div class="settings-feed-item">
<div class="settings-feed-title">
<span>${title}</span>
<span style="color: var(--text-dim);">${severity.toUpperCase()}</span>
</div>
<div class="settings-feed-meta">${message}</div>
<div class="settings-feed-meta" style="margin-top: 4px;">${createdAt}</div>
</div>
`;
}).join('');
}
function loadFeed() {
fetch('/alerts/events?limit=20')
.then(r => r.json())
.then(data => {
if (data.status === 'success') {
alerts = data.events || [];
updateFeedUI();
}
})
.catch(err => console.error('[Alerts] Load feed failed', err));
}
function loadRules() {
fetch('/alerts/rules?all=1')
.then(r => r.json())
.then(data => {
if (data.status === 'success') {
rules = data.rules || [];
}
})
.catch(err => console.error('[Alerts] Load rules failed', err));
}
function enableTrackerAlerts() {
ensureTrackerRule(true);
}
function disableTrackerAlerts() {
ensureTrackerRule(false);
}
function ensureTrackerRule(enabled) {
loadRules();
setTimeout(() => {
const existing = rules.find(r => r.name === TRACKER_RULE_NAME);
if (existing) {
fetch(`/alerts/rules/${existing.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled })
}).then(() => loadRules());
} else if (enabled) {
fetch('/alerts/rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: TRACKER_RULE_NAME,
mode: 'bluetooth',
event_type: 'device_update',
match: { is_tracker: true },
severity: 'high',
enabled: true,
notify: { webhook: true }
})
}).then(() => loadRules());
}
}, 150);
}
function addBluetoothWatchlist(address, name) {
if (!address) return;
const existing = rules.find(r => r.mode === 'bluetooth' && r.match && r.match.address === address);
if (existing) {
return;
}
fetch('/alerts/rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name ? `Watchlist ${name}` : `Watchlist ${address}`,
mode: 'bluetooth',
event_type: 'device_update',
match: { address: address },
severity: 'medium',
enabled: true,
notify: { webhook: true }
})
}).then(() => loadRules());
}
function removeBluetoothWatchlist(address) {
if (!address) return;
const existing = rules.find(r => r.mode === 'bluetooth' && r.match && r.match.address === address);
if (!existing) return;
fetch(`/alerts/rules/${existing.id}`, { method: 'DELETE' })
.then(() => loadRules());
}
function isWatchlisted(address) {
return rules.some(r => r.mode === 'bluetooth' && r.match && r.match.address === address && r.enabled);
}
function escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
return {
init,
loadFeed,
enableTrackerAlerts,
disableTrackerAlerts,
addBluetoothWatchlist,
removeBluetoothWatchlist,
isWatchlisted,
};
})();
document.addEventListener('DOMContentLoaded', () => {
if (typeof AlertCenter !== 'undefined') {
AlertCenter.init();
}
});
+504 -507
View File
File diff suppressed because it is too large Load Diff
-48
View File
@@ -1,48 +0,0 @@
(() => {
const dropdowns = Array.from(document.querySelectorAll('.mode-nav-dropdown'));
if (!dropdowns.length) return;
const closeAll = () => {
dropdowns.forEach((dropdown) => dropdown.classList.remove('open'));
};
const openDropdown = (dropdown) => {
if (!dropdown.classList.contains('open')) {
closeAll();
dropdown.classList.add('open');
}
};
document.addEventListener('click', (event) => {
const menuLink = event.target.closest('.mode-nav-dropdown-menu a');
if (menuLink) {
event.preventDefault();
event.stopPropagation();
window.location.href = menuLink.href;
return;
}
const button = event.target.closest('.mode-nav-dropdown-btn');
if (button) {
event.preventDefault();
const dropdown = button.closest('.mode-nav-dropdown');
if (!dropdown) return;
if (dropdown.classList.contains('open')) {
dropdown.classList.remove('open');
} else {
openDropdown(dropdown);
}
return;
}
if (!event.target.closest('.mode-nav-dropdown')) {
closeAll();
}
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
closeAll();
}
});
})();
-103
View File
@@ -1,103 +0,0 @@
// Shared observer location helper for map-based modules.
// Default: shared location enabled unless explicitly disabled via config.
window.ObserverLocation = (function() {
const DEFAULT_LOCATION = { lat: 51.5074, lon: -0.1278 };
const SHARED_KEY = 'observerLocation';
const AIS_KEY = 'ais_observerLocation';
const LEGACY_LAT_KEY = 'observerLat';
const LEGACY_LON_KEY = 'observerLon';
function isSharedEnabled() {
return window.INTERCEPT_SHARED_OBSERVER_LOCATION !== false;
}
function normalize(lat, lon) {
const latNum = parseFloat(lat);
const lonNum = parseFloat(lon);
if (Number.isNaN(latNum) || Number.isNaN(lonNum)) return null;
if (latNum < -90 || latNum > 90 || lonNum < -180 || lonNum > 180) return null;
return { lat: latNum, lon: lonNum };
}
function parseLocation(raw) {
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
if (parsed && parsed.lat !== undefined && parsed.lon !== undefined) {
return normalize(parsed.lat, parsed.lon);
}
} catch (e) {}
return null;
}
function readKey(key) {
return parseLocation(localStorage.getItem(key));
}
function readLegacyLatLon() {
const lat = localStorage.getItem(LEGACY_LAT_KEY);
const lon = localStorage.getItem(LEGACY_LON_KEY);
if (!lat || !lon) return null;
return normalize(lat, lon);
}
function getShared() {
const current = readKey(SHARED_KEY);
if (current) return current;
const legacy = readKey(AIS_KEY) || readLegacyLatLon();
if (legacy) {
setShared(legacy);
return legacy;
}
return { ...DEFAULT_LOCATION };
}
function setShared(location, options = {}) {
if (!location) return;
localStorage.setItem(SHARED_KEY, JSON.stringify(location));
if (options.updateLegacy !== false) {
localStorage.setItem(LEGACY_LAT_KEY, location.lat.toString());
localStorage.setItem(LEGACY_LON_KEY, location.lon.toString());
}
}
function getForModule(moduleKey, options = {}) {
if (isSharedEnabled()) {
return getShared();
}
if (moduleKey) {
const moduleLocation = readKey(moduleKey);
if (moduleLocation) return moduleLocation;
}
if (options.fallbackToLatLon) {
const legacy = readLegacyLatLon();
if (legacy) return legacy;
}
return { ...DEFAULT_LOCATION };
}
function setForModule(moduleKey, location, options = {}) {
if (!location) return;
if (isSharedEnabled()) {
setShared(location, options);
return;
}
if (moduleKey) {
localStorage.setItem(moduleKey, JSON.stringify(location));
} else if (options.fallbackToLatLon) {
localStorage.setItem(LEGACY_LAT_KEY, location.lat.toString());
localStorage.setItem(LEGACY_LON_KEY, location.lon.toString());
}
}
return {
isSharedEnabled,
getShared,
setShared,
getForModule,
setForModule,
normalize,
DEFAULT_LOCATION: { ...DEFAULT_LOCATION }
};
})();
-136
View File
@@ -1,136 +0,0 @@
const RecordingUI = (function() {
'use strict';
let recordings = [];
let active = [];
function init() {
refresh();
}
function refresh() {
fetch('/recordings')
.then(r => r.json())
.then(data => {
if (data.status !== 'success') return;
recordings = data.recordings || [];
active = data.active || [];
renderActive();
renderRecordings();
})
.catch(err => console.error('[Recording] Load failed', err));
}
function start() {
const modeSelect = document.getElementById('recordingModeSelect');
const labelInput = document.getElementById('recordingLabelInput');
const mode = modeSelect ? modeSelect.value : '';
const label = labelInput ? labelInput.value : '';
if (!mode) return;
fetch('/recordings/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode, label })
})
.then(r => r.json())
.then(() => {
refresh();
})
.catch(err => console.error('[Recording] Start failed', err));
}
function stop() {
const modeSelect = document.getElementById('recordingModeSelect');
const mode = modeSelect ? modeSelect.value : '';
if (!mode) return;
fetch('/recordings/stop', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode })
})
.then(r => r.json())
.then(() => refresh())
.catch(err => console.error('[Recording] Stop failed', err));
}
function stopById(sessionId) {
fetch('/recordings/stop', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: sessionId })
}).then(() => refresh());
}
function renderActive() {
const container = document.getElementById('recordingActiveList');
if (!container) return;
if (!active.length) {
container.innerHTML = '<div class="settings-feed-empty">No active recordings</div>';
return;
}
container.innerHTML = active.map(session => {
return `
<div class="settings-feed-item">
<div class="settings-feed-title">
<span>${escapeHtml(session.mode)}</span>
<button class="preset-btn" style="font-size: 9px; padding: 2px 6px;" onclick="RecordingUI.stopById('${session.id}')">Stop</button>
</div>
<div class="settings-feed-meta">Started: ${new Date(session.started_at).toLocaleString()}</div>
<div class="settings-feed-meta">Events: ${session.event_count || 0}</div>
</div>
`;
}).join('');
}
function renderRecordings() {
const container = document.getElementById('recordingList');
if (!container) return;
if (!recordings.length) {
container.innerHTML = '<div class="settings-feed-empty">No recordings yet</div>';
return;
}
container.innerHTML = recordings.map(rec => {
return `
<div class="settings-feed-item">
<div class="settings-feed-title">
<span>${escapeHtml(rec.mode)}${rec.label ? `${escapeHtml(rec.label)}` : ''}</span>
<button class="preset-btn" style="font-size: 9px; padding: 2px 6px;" onclick="RecordingUI.download('${rec.id}')">Download</button>
</div>
<div class="settings-feed-meta">${new Date(rec.started_at).toLocaleString()}${rec.stopped_at ? `${new Date(rec.stopped_at).toLocaleString()}` : ''}</div>
<div class="settings-feed-meta">Events: ${rec.event_count || 0} ${(rec.size_bytes || 0) / 1024.0 > 0 ? (rec.size_bytes / 1024).toFixed(1) + ' KB' : '0 KB'}</div>
</div>
`;
}).join('');
}
function download(sessionId) {
window.open(`/recordings/${sessionId}/download`, '_blank');
}
function escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
return {
init,
refresh,
start,
stop,
stopById,
download,
};
})();
document.addEventListener('DOMContentLoaded', () => {
if (typeof RecordingUI !== 'undefined') {
RecordingUI.init();
}
});
File diff suppressed because it is too large Load Diff
+32 -75
View File
@@ -366,10 +366,7 @@ const BluetoothMode = (function() {
// Badges
const badgesEl = document.getElementById('btDetailBadges');
let badgesHtml = `<span class="bt-detail-badge ${protocol}">${protocol.toUpperCase()}</span>`;
badgesHtml += `<span class="bt-detail-badge ${device.in_baseline ? 'baseline' : 'new'}">${device.in_baseline ? '✓ KNOWN' : '● NEW'}</span>`;
if (device.seen_before) {
badgesHtml += `<span class="bt-detail-badge flag">SEEN BEFORE</span>`;
}
badgesHtml += `<span class="bt-detail-badge ${device.in_baseline ? 'baseline' : 'new'}">${device.in_baseline ? '✓ KNOWN' : '● NEW'}</span>`;
// Tracker badge
if (device.is_tracker) {
@@ -451,14 +448,12 @@ const BluetoothMode = (function() {
? minMax[0] + '/' + minMax[1]
: '--';
document.getElementById('btDetailFirstSeen').textContent = device.first_seen
? new Date(device.first_seen).toLocaleTimeString()
: '--';
document.getElementById('btDetailLastSeen').textContent = device.last_seen
? new Date(device.last_seen).toLocaleTimeString()
: '--';
updateWatchlistButton(device);
document.getElementById('btDetailFirstSeen').textContent = device.first_seen
? new Date(device.first_seen).toLocaleTimeString()
: '--';
document.getElementById('btDetailLastSeen').textContent = device.last_seen
? new Date(device.last_seen).toLocaleTimeString()
: '--';
// Services
const servicesContainer = document.getElementById('btDetailServices');
@@ -470,29 +465,13 @@ const BluetoothMode = (function() {
servicesContainer.style.display = 'none';
}
// Show content, hide placeholder
placeholder.style.display = 'none';
content.style.display = 'block';
// Show content, hide placeholder
placeholder.style.display = 'none';
content.style.display = 'block';
// Highlight selected device in list
highlightSelectedDevice(deviceId);
}
/**
* Update watchlist button state
*/
function updateWatchlistButton(device) {
const btn = document.getElementById('btDetailWatchBtn');
if (!btn) return;
if (typeof AlertCenter === 'undefined') {
btn.style.display = 'none';
return;
}
btn.style.display = '';
const watchlisted = AlertCenter.isWatchlisted(device.address);
btn.textContent = watchlisted ? 'Watching' : 'Watchlist';
btn.classList.toggle('active', watchlisted);
}
}
/**
* Clear device selection
@@ -546,43 +525,24 @@ const BluetoothMode = (function() {
/**
* Copy selected device address to clipboard
*/
function copyAddress() {
if (!selectedDeviceId) return;
const device = devices.get(selectedDeviceId);
if (!device) return;
function copyAddress() {
if (!selectedDeviceId) return;
const device = devices.get(selectedDeviceId);
if (!device) return;
navigator.clipboard.writeText(device.address).then(() => {
const btn = document.getElementById('btDetailCopyBtn');
if (btn) {
const originalText = btn.textContent;
btn.textContent = 'Copied!';
btn.style.background = '#22c55e';
navigator.clipboard.writeText(device.address).then(() => {
const btn = document.querySelector('.bt-detail-btn');
if (btn) {
const originalText = btn.textContent;
btn.textContent = 'Copied!';
btn.style.background = '#22c55e';
setTimeout(() => {
btn.textContent = originalText;
btn.style.background = '';
}, 1500);
}
});
}
/**
* Toggle Bluetooth watchlist for selected device
*/
function toggleWatchlist() {
if (!selectedDeviceId) return;
const device = devices.get(selectedDeviceId);
if (!device || typeof AlertCenter === 'undefined') return;
if (AlertCenter.isWatchlisted(device.address)) {
AlertCenter.removeBluetoothWatchlist(device.address);
showInfo('Removed from watchlist');
} else {
AlertCenter.addBluetoothWatchlist(device.address, device.name || device.address);
showInfo('Added to watchlist');
}
setTimeout(() => updateWatchlistButton(device), 200);
}
});
}
/**
* Select a device - opens modal with details
@@ -1130,11 +1090,10 @@ const BluetoothMode = (function() {
const isNew = !inBaseline;
const hasName = !!device.name;
const isTracker = device.is_tracker === true;
const trackerType = device.tracker_type;
const trackerConfidence = device.tracker_confidence;
const riskScore = device.risk_score || 0;
const agentName = device._agent || 'Local';
const seenBefore = device.seen_before === true;
const trackerType = device.tracker_type;
const trackerConfidence = device.tracker_confidence;
const riskScore = device.risk_score || 0;
const agentName = device._agent || 'Local';
// Calculate RSSI bar width (0-100%)
// RSSI typically ranges from -100 (weak) to -30 (very strong)
@@ -1186,9 +1145,8 @@ const BluetoothMode = (function() {
// Build secondary info line
let secondaryParts = [addr];
if (mfr) secondaryParts.push(mfr);
secondaryParts.push('Seen ' + seenCount + '×');
if (seenBefore) secondaryParts.push('<span class="bt-history-badge">SEEN BEFORE</span>');
if (mfr) secondaryParts.push(mfr);
secondaryParts.push('Seen ' + seenCount + '×');
// Add agent name if not Local
if (agentName !== 'Local') {
secondaryParts.push('<span class="agent-badge agent-remote" style="font-size:8px;padding:1px 4px;">' + escapeHtml(agentName) + '</span>');
@@ -1400,10 +1358,9 @@ const BluetoothMode = (function() {
setBaseline,
clearBaseline,
exportData,
selectDevice,
clearSelection,
copyAddress,
toggleWatchlist,
selectDevice,
clearSelection,
copyAddress,
// Agent handling
handleAgentChange,
-504
View File
@@ -1,504 +0,0 @@
/**
* Intercept - DMR / Digital Voice Mode
* Decoding DMR, P25, NXDN, D-STAR digital voice protocols
*/
// ============== STATE ==============
let isDmrRunning = false;
let dmrEventSource = null;
let dmrCallCount = 0;
let dmrSyncCount = 0;
let dmrCallHistory = [];
let dmrCurrentProtocol = '--';
// ============== SYNTHESIZER STATE ==============
let dmrSynthCanvas = null;
let dmrSynthCtx = null;
let dmrSynthBars = [];
let dmrSynthAnimationId = null;
let dmrSynthInitialized = false;
let dmrActivityLevel = 0;
let dmrActivityTarget = 0;
let dmrEventType = 'idle';
let dmrLastEventTime = 0;
const DMR_BAR_COUNT = 48;
const DMR_DECAY_RATE = 0.015;
const DMR_BURST_SYNC = 0.6;
const DMR_BURST_CALL = 0.85;
const DMR_BURST_VOICE = 0.95;
// ============== TOOLS CHECK ==============
function checkDmrTools() {
fetch('/dmr/tools')
.then(r => r.json())
.then(data => {
const warning = document.getElementById('dmrToolsWarning');
const warningText = document.getElementById('dmrToolsWarningText');
if (!warning) return;
const missing = [];
if (!data.dsd) missing.push('dsd (Digital Speech Decoder)');
if (!data.rtl_fm) missing.push('rtl_fm (RTL-SDR)');
if (missing.length > 0) {
warning.style.display = 'block';
if (warningText) warningText.textContent = missing.join(', ');
} else {
warning.style.display = 'none';
}
})
.catch(() => {});
}
// ============== START / STOP ==============
function startDmr() {
const frequency = parseFloat(document.getElementById('dmrFrequency')?.value || 462.5625);
const protocol = document.getElementById('dmrProtocol')?.value || 'auto';
const gain = parseInt(document.getElementById('dmrGain')?.value || 40);
const device = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0;
// Check device availability before starting
if (typeof checkDeviceAvailability === 'function' && !checkDeviceAvailability('dmr')) {
return;
}
fetch('/dmr/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ frequency, protocol, gain, device })
})
.then(r => r.json())
.then(data => {
if (data.status === 'started') {
isDmrRunning = true;
dmrCallCount = 0;
dmrSyncCount = 0;
dmrCallHistory = [];
updateDmrUI();
connectDmrSSE();
dmrEventType = 'idle';
dmrActivityTarget = 0.1;
dmrLastEventTime = Date.now();
if (!dmrSynthInitialized) initDmrSynthesizer();
updateDmrSynthStatus();
const statusEl = document.getElementById('dmrStatus');
if (statusEl) statusEl.textContent = 'DECODING';
if (typeof reserveDevice === 'function') {
reserveDevice(parseInt(device), 'dmr');
}
if (typeof showNotification === 'function') {
showNotification('DMR', `Decoding ${frequency} MHz (${protocol.toUpperCase()})`);
}
} else {
if (typeof showNotification === 'function') {
showNotification('Error', data.message || 'Failed to start DMR');
}
}
})
.catch(err => console.error('[DMR] Start error:', err));
}
function stopDmr() {
fetch('/dmr/stop', { method: 'POST' })
.then(r => r.json())
.then(() => {
isDmrRunning = false;
if (dmrEventSource) { dmrEventSource.close(); dmrEventSource = null; }
updateDmrUI();
dmrEventType = 'stopped';
dmrActivityTarget = 0;
updateDmrSynthStatus();
const statusEl = document.getElementById('dmrStatus');
if (statusEl) statusEl.textContent = 'STOPPED';
if (typeof releaseDevice === 'function') {
releaseDevice('dmr');
}
})
.catch(err => console.error('[DMR] Stop error:', err));
}
// ============== SSE STREAMING ==============
function connectDmrSSE() {
if (dmrEventSource) dmrEventSource.close();
dmrEventSource = new EventSource('/dmr/stream');
dmrEventSource.onmessage = function(event) {
const msg = JSON.parse(event.data);
handleDmrMessage(msg);
};
dmrEventSource.onerror = function() {
if (isDmrRunning) {
setTimeout(connectDmrSSE, 2000);
}
};
}
function handleDmrMessage(msg) {
if (dmrSynthInitialized) dmrSynthPulse(msg.type);
if (msg.type === 'sync') {
dmrCurrentProtocol = msg.protocol || '--';
const protocolEl = document.getElementById('dmrActiveProtocol');
if (protocolEl) protocolEl.textContent = dmrCurrentProtocol;
const mainProtocolEl = document.getElementById('dmrMainProtocol');
if (mainProtocolEl) mainProtocolEl.textContent = dmrCurrentProtocol;
dmrSyncCount++;
const syncCountEl = document.getElementById('dmrSyncCount');
if (syncCountEl) syncCountEl.textContent = dmrSyncCount;
} else if (msg.type === 'call') {
dmrCallCount++;
const countEl = document.getElementById('dmrCallCount');
if (countEl) countEl.textContent = dmrCallCount;
const mainCountEl = document.getElementById('dmrMainCallCount');
if (mainCountEl) mainCountEl.textContent = dmrCallCount;
// Update current call display
const slotInfo = msg.slot != null ? `
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
<span style="color: var(--text-muted);">Slot</span>
<span style="color: var(--accent-orange); font-family: var(--font-mono);">${msg.slot}</span>
</div>` : '';
const callEl = document.getElementById('dmrCurrentCall');
if (callEl) {
callEl.innerHTML = `
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
<span style="color: var(--text-muted);">Talkgroup</span>
<span style="color: var(--accent-green); font-weight: bold; font-family: var(--font-mono);">${msg.talkgroup}</span>
</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
<span style="color: var(--text-muted);">Source ID</span>
<span style="color: var(--accent-cyan); font-family: var(--font-mono);">${msg.source_id}</span>
</div>${slotInfo}
<div style="display: flex; justify-content: space-between;">
<span style="color: var(--text-muted);">Time</span>
<span style="color: var(--text-primary);">${msg.timestamp}</span>
</div>
`;
}
// Add to history
dmrCallHistory.unshift({
talkgroup: msg.talkgroup,
source_id: msg.source_id,
protocol: dmrCurrentProtocol,
time: msg.timestamp,
});
if (dmrCallHistory.length > 50) dmrCallHistory.length = 50;
renderDmrHistory();
} else if (msg.type === 'slot') {
// Update slot info in current call
} else if (msg.type === 'raw') {
// Raw DSD output — triggers synthesizer activity via dmrSynthPulse
} else if (msg.type === 'heartbeat') {
// Decoder is alive and listening — keep synthesizer in listening state
if (isDmrRunning && dmrSynthInitialized) {
if (dmrEventType === 'idle' || dmrEventType === 'raw') {
dmrEventType = 'raw';
dmrActivityTarget = Math.max(dmrActivityTarget, 0.15);
dmrLastEventTime = Date.now();
updateDmrSynthStatus();
}
}
} else if (msg.type === 'status') {
const statusEl = document.getElementById('dmrStatus');
if (msg.text === 'started') {
if (statusEl) statusEl.textContent = 'DECODING';
} else if (msg.text === 'crashed') {
isDmrRunning = false;
updateDmrUI();
dmrEventType = 'stopped';
dmrActivityTarget = 0;
updateDmrSynthStatus();
if (statusEl) statusEl.textContent = 'CRASHED';
if (typeof releaseDevice === 'function') releaseDevice('dmr');
const detail = msg.detail || `Decoder exited (code ${msg.exit_code})`;
if (typeof showNotification === 'function') {
showNotification('DMR Error', detail);
}
} else if (msg.text === 'stopped') {
isDmrRunning = false;
updateDmrUI();
dmrEventType = 'stopped';
dmrActivityTarget = 0;
updateDmrSynthStatus();
if (statusEl) statusEl.textContent = 'STOPPED';
if (typeof releaseDevice === 'function') releaseDevice('dmr');
}
}
}
// ============== UI ==============
function updateDmrUI() {
const startBtn = document.getElementById('startDmrBtn');
const stopBtn = document.getElementById('stopDmrBtn');
if (startBtn) startBtn.style.display = isDmrRunning ? 'none' : 'block';
if (stopBtn) stopBtn.style.display = isDmrRunning ? 'block' : 'none';
}
function renderDmrHistory() {
const container = document.getElementById('dmrHistoryBody');
if (!container) return;
const historyCountEl = document.getElementById('dmrHistoryCount');
if (historyCountEl) historyCountEl.textContent = `${dmrCallHistory.length} calls`;
if (dmrCallHistory.length === 0) {
container.innerHTML = '<tr><td colspan="4" style="padding: 10px; text-align: center; color: var(--text-muted);">No calls recorded</td></tr>';
return;
}
container.innerHTML = dmrCallHistory.slice(0, 20).map(call => `
<tr>
<td style="padding: 3px 6px; font-family: var(--font-mono);">${call.time}</td>
<td style="padding: 3px 6px; color: var(--accent-green);">${call.talkgroup}</td>
<td style="padding: 3px 6px; color: var(--accent-cyan);">${call.source_id}</td>
<td style="padding: 3px 6px;">${call.protocol}</td>
</tr>
`).join('');
}
// ============== SYNTHESIZER ==============
function initDmrSynthesizer() {
dmrSynthCanvas = document.getElementById('dmrSynthCanvas');
if (!dmrSynthCanvas) return;
// Use the canvas element's own rendered size for the backing buffer
const rect = dmrSynthCanvas.getBoundingClientRect();
const w = Math.round(rect.width) || 600;
const h = Math.round(rect.height) || 70;
dmrSynthCanvas.width = w;
dmrSynthCanvas.height = h;
dmrSynthCtx = dmrSynthCanvas.getContext('2d');
dmrSynthBars = [];
for (let i = 0; i < DMR_BAR_COUNT; i++) {
dmrSynthBars[i] = { height: 2, targetHeight: 2, velocity: 0 };
}
dmrActivityLevel = 0;
dmrActivityTarget = 0;
dmrEventType = isDmrRunning ? 'idle' : 'stopped';
dmrSynthInitialized = true;
updateDmrSynthStatus();
if (dmrSynthAnimationId) cancelAnimationFrame(dmrSynthAnimationId);
drawDmrSynthesizer();
}
function drawDmrSynthesizer() {
if (!dmrSynthCtx || !dmrSynthCanvas) return;
const width = dmrSynthCanvas.width;
const height = dmrSynthCanvas.height;
const barWidth = (width / DMR_BAR_COUNT) - 2;
const now = Date.now();
// Clear canvas
dmrSynthCtx.fillStyle = 'rgba(0, 0, 0, 0.3)';
dmrSynthCtx.fillRect(0, 0, width, height);
// Decay activity toward target
const timeSinceEvent = now - dmrLastEventTime;
if (timeSinceEvent > 2000) {
// No events for 2s — decay target toward idle
dmrActivityTarget = Math.max(0, dmrActivityTarget - DMR_DECAY_RATE);
if (dmrActivityTarget < 0.1 && dmrEventType !== 'stopped') {
dmrEventType = 'idle';
updateDmrSynthStatus();
}
}
// Smooth approach to target
dmrActivityLevel += (dmrActivityTarget - dmrActivityLevel) * 0.08;
// Determine effective activity (idle breathing when stopped/idle)
let effectiveActivity = dmrActivityLevel;
if (dmrEventType === 'stopped') {
effectiveActivity = 0;
} else if (effectiveActivity < 0.1 && isDmrRunning) {
// Visible idle breathing — shows decoder is alive and listening
effectiveActivity = 0.12 + Math.sin(now / 1000) * 0.06;
}
// Ripple timing for sync events
const syncRippleAge = (dmrEventType === 'sync' && timeSinceEvent < 500) ? 1 - (timeSinceEvent / 500) : 0;
// Voice ripple overlay
const voiceRipple = (dmrEventType === 'voice') ? Math.sin(now / 60) * 0.15 : 0;
// Update bar targets and physics
for (let i = 0; i < DMR_BAR_COUNT; i++) {
const time = now / 200;
const wave1 = Math.sin(time + i * 0.3) * 0.2;
const wave2 = Math.sin(time * 1.7 + i * 0.5) * 0.15;
const randomAmount = 0.05 + effectiveActivity * 0.25;
const random = (Math.random() - 0.5) * randomAmount;
// Bell curve — center bars taller
const centerDist = Math.abs(i - DMR_BAR_COUNT / 2) / (DMR_BAR_COUNT / 2);
const centerBoost = 1 - centerDist * 0.5;
// Sync ripple: center-outward wave burst
let rippleBoost = 0;
if (syncRippleAge > 0) {
const ripplePos = (1 - syncRippleAge) * DMR_BAR_COUNT / 2;
const distFromRipple = Math.abs(i - DMR_BAR_COUNT / 2) - ripplePos;
rippleBoost = Math.max(0, 1 - Math.abs(distFromRipple) / 4) * syncRippleAge * 0.4;
}
const baseHeight = 0.1 + effectiveActivity * 0.55;
dmrSynthBars[i].targetHeight = Math.max(2,
(baseHeight + wave1 + wave2 + random + rippleBoost + voiceRipple) *
effectiveActivity * centerBoost * height
);
// Spring physics
const springStrength = effectiveActivity > 0.3 ? 0.15 : 0.1;
const diff = dmrSynthBars[i].targetHeight - dmrSynthBars[i].height;
dmrSynthBars[i].velocity += diff * springStrength;
dmrSynthBars[i].velocity *= 0.78;
dmrSynthBars[i].height += dmrSynthBars[i].velocity;
dmrSynthBars[i].height = Math.max(2, Math.min(height - 4, dmrSynthBars[i].height));
}
// Draw bars
for (let i = 0; i < DMR_BAR_COUNT; i++) {
const x = i * (barWidth + 2) + 1;
const barHeight = dmrSynthBars[i].height;
const y = (height - barHeight) / 2;
// HSL color by event type
let hue, saturation, lightness;
if (dmrEventType === 'voice' && timeSinceEvent < 3000) {
hue = 30; // Orange
saturation = 85;
lightness = 40 + (barHeight / height) * 25;
} else if (dmrEventType === 'call' && timeSinceEvent < 3000) {
hue = 120; // Green
saturation = 80;
lightness = 35 + (barHeight / height) * 30;
} else if (dmrEventType === 'sync' && timeSinceEvent < 2000) {
hue = 185; // Cyan
saturation = 85;
lightness = 38 + (barHeight / height) * 25;
} else if (dmrEventType === 'stopped') {
hue = 220;
saturation = 20;
lightness = 18 + (barHeight / height) * 8;
} else {
// Idle / decayed
hue = 210;
saturation = 40;
lightness = 25 + (barHeight / height) * 15;
}
// Vertical gradient per bar
const gradient = dmrSynthCtx.createLinearGradient(x, y, x, y + barHeight);
gradient.addColorStop(0, `hsla(${hue}, ${saturation}%, ${lightness + 18}%, 0.85)`);
gradient.addColorStop(0.5, `hsla(${hue}, ${saturation}%, ${lightness}%, 1)`);
gradient.addColorStop(1, `hsla(${hue}, ${saturation}%, ${lightness + 18}%, 0.85)`);
dmrSynthCtx.fillStyle = gradient;
dmrSynthCtx.fillRect(x, y, barWidth, barHeight);
// Glow on tall bars
if (barHeight > height * 0.5 && effectiveActivity > 0.4) {
dmrSynthCtx.shadowColor = `hsla(${hue}, ${saturation}%, 60%, 0.5)`;
dmrSynthCtx.shadowBlur = 8;
dmrSynthCtx.fillRect(x, y, barWidth, barHeight);
dmrSynthCtx.shadowBlur = 0;
}
}
// Center line
dmrSynthCtx.strokeStyle = 'rgba(0, 212, 255, 0.15)';
dmrSynthCtx.lineWidth = 1;
dmrSynthCtx.beginPath();
dmrSynthCtx.moveTo(0, height / 2);
dmrSynthCtx.lineTo(width, height / 2);
dmrSynthCtx.stroke();
dmrSynthAnimationId = requestAnimationFrame(drawDmrSynthesizer);
}
function dmrSynthPulse(type) {
dmrLastEventTime = Date.now();
if (type === 'sync') {
dmrActivityTarget = Math.max(dmrActivityTarget, DMR_BURST_SYNC);
dmrEventType = 'sync';
} else if (type === 'call') {
dmrActivityTarget = DMR_BURST_CALL;
dmrEventType = 'call';
} else if (type === 'voice') {
dmrActivityTarget = DMR_BURST_VOICE;
dmrEventType = 'voice';
} else if (type === 'slot' || type === 'nac') {
dmrActivityTarget = Math.max(dmrActivityTarget, 0.5);
} else if (type === 'raw') {
// Any DSD output means the decoder is alive and processing
dmrActivityTarget = Math.max(dmrActivityTarget, 0.25);
if (dmrEventType === 'idle') dmrEventType = 'raw';
}
// keepalive and status don't change visuals
updateDmrSynthStatus();
}
function updateDmrSynthStatus() {
const el = document.getElementById('dmrSynthStatus');
if (!el) return;
const labels = {
stopped: 'STOPPED',
idle: 'IDLE',
raw: 'LISTENING',
sync: 'SYNC',
call: 'CALL',
voice: 'VOICE'
};
const colors = {
stopped: 'var(--text-muted)',
idle: 'var(--text-muted)',
raw: '#607d8b',
sync: '#00e5ff',
call: '#4caf50',
voice: '#ff9800'
};
el.textContent = labels[dmrEventType] || 'IDLE';
el.style.color = colors[dmrEventType] || 'var(--text-muted)';
}
function resizeDmrSynthesizer() {
if (!dmrSynthCanvas) return;
const rect = dmrSynthCanvas.getBoundingClientRect();
if (rect.width > 0) {
dmrSynthCanvas.width = Math.round(rect.width);
dmrSynthCanvas.height = Math.round(rect.height) || 70;
}
}
function stopDmrSynthesizer() {
if (dmrSynthAnimationId) {
cancelAnimationFrame(dmrSynthAnimationId);
dmrSynthAnimationId = null;
}
}
window.addEventListener('resize', resizeDmrSynthesizer);
// ============== EXPORTS ==============
window.startDmr = startDmr;
window.stopDmr = stopDmr;
window.checkDmrTools = checkDmrTools;
window.initDmrSynthesizer = initDmrSynthesizer;
File diff suppressed because it is too large Load Diff
+25 -85
View File
@@ -97,7 +97,7 @@ const Meshtastic = (function() {
/**
* Initialize the Leaflet map
*/
async function initMap() {
function initMap() {
if (meshMap) return;
const mapContainer = document.getElementById('meshMap');
@@ -111,19 +111,16 @@ const Meshtastic = (function() {
window.meshMap = meshMap;
// Use settings manager for tile layer (allows runtime changes)
if (typeof Settings !== 'undefined') {
// Wait for settings to load from server before applying tiles
await Settings.init();
if (typeof Settings !== 'undefined' && Settings.createTileLayer) {
Settings.createTileLayer().addTo(meshMap);
Settings.registerMap(meshMap);
} else {
L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
maxZoom: 19,
subdomains: 'abcd',
className: 'tile-layer-cyan'
}).addTo(meshMap);
}
L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
maxZoom: 19,
subdomains: 'abcd'
}).addTo(meshMap);
}
// Handle resize
setTimeout(() => {
@@ -146,7 +143,7 @@ const Meshtastic = (function() {
if (data.running) {
isConnected = true;
updateConnectionUI(true, data.device, data.connection_type);
updateConnectionUI(true, data.device);
if (data.node_info) {
updateNodeInfo(data.node_info);
localNodeId = data.node_info.num;
@@ -161,64 +158,21 @@ const Meshtastic = (function() {
}
}
/**
* Handle connection type change (serial vs TCP)
*/
function onConnectionTypeChange() {
const connTypeSelect = document.getElementById('meshStripConnType');
const deviceSelect = document.getElementById('meshStripDevice');
const hostnameInput = document.getElementById('meshStripHostname');
if (!connTypeSelect) return;
const connType = connTypeSelect.value;
if (connType === 'tcp') {
// Show hostname input, hide device select
if (deviceSelect) deviceSelect.style.display = 'none';
if (hostnameInput) hostnameInput.style.display = 'block';
} else {
// Show device select, hide hostname input
if (deviceSelect) deviceSelect.style.display = 'block';
if (hostnameInput) hostnameInput.style.display = 'none';
}
}
/**
* Start Meshtastic connection
*/
async function start() {
// Get connection type
const connTypeSelect = document.getElementById('meshStripConnType');
const connectionType = connTypeSelect?.value || 'serial';
// Try strip device select first, then sidebar
const stripDeviceSelect = document.getElementById('meshStripDevice');
const sidebarDeviceSelect = document.getElementById('meshDeviceSelect');
let device = stripDeviceSelect?.value || sidebarDeviceSelect?.value || null;
// Get connection parameters based on type
let device = null;
let hostname = null;
if (connectionType === 'tcp') {
// TCP connection - get hostname
const hostnameInput = document.getElementById('meshStripHostname');
hostname = hostnameInput?.value?.trim() || null;
if (!hostname) {
showStatusMessage('Please enter a hostname or IP address for TCP connection', 'error');
updateStatusIndicator('disconnected', 'Enter hostname');
return;
}
} else {
// Serial connection - get device
const stripDeviceSelect = document.getElementById('meshStripDevice');
const sidebarDeviceSelect = document.getElementById('meshDeviceSelect');
device = stripDeviceSelect?.value || sidebarDeviceSelect?.value || null;
// Check if auto-detect is selected but multiple ports exist
if (!device && stripDeviceSelect && stripDeviceSelect.options.length > 2) {
// Multiple ports available - prompt user to select one
showStatusMessage('Multiple ports detected. Please select a specific device from the dropdown.', 'warning');
updateStatusIndicator('disconnected', 'Select a device');
return;
}
// Check if auto-detect is selected but multiple ports exist
if (!device && stripDeviceSelect && stripDeviceSelect.options.length > 2) {
// Multiple ports available - prompt user to select one
showStatusMessage('Multiple ports detected. Please select a specific device from the dropdown.', 'warning');
updateStatusIndicator('disconnected', 'Select a device');
return;
}
updateStatusIndicator('connecting', 'Connecting...');
@@ -230,27 +184,17 @@ const Meshtastic = (function() {
if (stripStatus) stripStatus.textContent = 'Connecting...';
try {
const requestBody = {
connection_type: connectionType
};
if (connectionType === 'tcp') {
requestBody.hostname = hostname;
} else if (device) {
requestBody.device = device;
}
const response = await fetch('/meshtastic/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
body: JSON.stringify({ device: device || undefined })
});
const data = await response.json();
if (data.status === 'started' || data.status === 'already_running') {
isConnected = true;
updateConnectionUI(true, data.device, data.connection_type);
updateConnectionUI(true, data.device);
if (data.node_info) {
updateNodeInfo(data.node_info);
localNodeId = data.node_info.num;
@@ -258,8 +202,7 @@ const Meshtastic = (function() {
loadChannels();
loadNodes();
startStream();
const connLabel = data.connection_type === 'tcp' ? 'TCP' : 'Serial';
showNotification('Meshtastic', `Connected via ${connLabel}`);
showNotification('Meshtastic', 'Connected to device');
} else {
updateStatusIndicator('disconnected', data.message || 'Connection failed');
showStatusMessage(data.message || 'Failed to connect', 'error');
@@ -289,7 +232,7 @@ const Meshtastic = (function() {
/**
* Update connection UI state
*/
function updateConnectionUI(connected, device, connectionType) {
function updateConnectionUI(connected, device) {
const connectBtn = document.getElementById('meshConnectBtn');
const disconnectBtn = document.getElementById('meshDisconnectBtn');
const nodeSection = document.getElementById('meshNodeSection');
@@ -305,9 +248,7 @@ const Meshtastic = (function() {
const stripStatus = document.getElementById('meshStripStatus');
if (connected) {
const connLabel = connectionType === 'tcp' ? 'TCP' : 'Serial';
const statusText = device ? `${device} (${connLabel})` : `Connected (${connLabel})`;
updateStatusIndicator('connected', statusText);
updateStatusIndicator('connected', device ? `Connected to ${device}` : 'Connected');
if (connectBtn) connectBtn.style.display = 'none';
if (disconnectBtn) disconnectBtn.style.display = 'block';
if (nodeSection) nodeSection.style.display = 'block';
@@ -322,7 +263,7 @@ const Meshtastic = (function() {
if (stripDot) {
stripDot.className = 'mesh-strip-dot connected';
}
if (stripStatus) stripStatus.textContent = statusText;
if (stripStatus) stripStatus.textContent = device || 'Connected';
} else {
updateStatusIndicator('disconnected', 'Disconnected');
if (connectBtn) connectBtn.style.display = 'block';
@@ -2259,7 +2200,6 @@ const Meshtastic = (function() {
init,
start,
stop,
onConnectionTypeChange,
loadPorts,
refreshChannels,
openChannelModal,
+1 -1
View File
@@ -84,7 +84,7 @@ const SpyStations = (function() {
modeContainer.innerHTML = modes.map(m => `
<label class="inline-checkbox">
<input type="checkbox" data-mode="${m}" checked onchange="SpyStations.applyFilters()">
<span style="font-family: 'Space Mono', monospace; font-size: 10px;">${m}</span>
<span style="font-family: 'JetBrains Mono', monospace; font-size: 10px;">${m}</span>
</label>
`).join('');
}
-756
View File
@@ -1,756 +0,0 @@
/**
* SSTV General Mode
* Terrestrial Slow-Scan Television decoder interface
*/
const SSTVGeneral = (function() {
// State
let isRunning = false;
let eventSource = null;
let images = [];
let currentMode = null;
let progress = 0;
// Signal scope state
let sstvGeneralScopeCtx = null;
let sstvGeneralScopeAnim = null;
let sstvGeneralScopeHistory = [];
const SSTV_GENERAL_SCOPE_LEN = 200;
let sstvGeneralScopeRms = 0;
let sstvGeneralScopePeak = 0;
let sstvGeneralScopeTargetRms = 0;
let sstvGeneralScopeTargetPeak = 0;
let sstvGeneralScopeMsgBurst = 0;
let sstvGeneralScopeTone = null;
/**
* Initialize the SSTV General mode
*/
function init() {
checkStatus();
loadImages();
}
/**
* Select a preset frequency from the dropdown
*/
function selectPreset(value) {
if (!value) return;
const parts = value.split('|');
const freq = parseFloat(parts[0]);
const mod = parts[1];
const freqInput = document.getElementById('sstvGeneralFrequency');
const modSelect = document.getElementById('sstvGeneralModulation');
if (freqInput) freqInput.value = freq;
if (modSelect) modSelect.value = mod;
// Update strip display
const stripFreq = document.getElementById('sstvGeneralStripFreq');
const stripMod = document.getElementById('sstvGeneralStripMod');
if (stripFreq) stripFreq.textContent = freq.toFixed(3);
if (stripMod) stripMod.textContent = mod.toUpperCase();
}
/**
* Check current decoder status
*/
async function checkStatus() {
try {
const response = await fetch('/sstv-general/status');
const data = await response.json();
if (!data.available) {
updateStatusUI('unavailable', 'Decoder not installed');
showStatusMessage('SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow', 'warning');
return;
}
if (data.running) {
isRunning = true;
updateStatusUI('listening', 'Listening...');
startStream();
} else {
updateStatusUI('idle', 'Idle');
}
updateImageCount(data.image_count || 0);
} catch (err) {
console.error('Failed to check SSTV General status:', err);
}
}
/**
* Start SSTV decoder
*/
async function start() {
const freqInput = document.getElementById('sstvGeneralFrequency');
const modSelect = document.getElementById('sstvGeneralModulation');
const deviceSelect = document.getElementById('deviceSelect');
const frequency = parseFloat(freqInput?.value || '14.230');
const modulation = modSelect?.value || 'usb';
const device = parseInt(deviceSelect?.value || '0', 10);
updateStatusUI('connecting', 'Starting...');
try {
const response = await fetch('/sstv-general/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ frequency, modulation, device })
});
const data = await response.json();
if (data.status === 'started' || data.status === 'already_running') {
isRunning = true;
updateStatusUI('listening', `${frequency} MHz ${modulation.toUpperCase()}`);
startStream();
showNotification('SSTV', `Listening on ${frequency} MHz ${modulation.toUpperCase()}`);
// Update strip
const stripFreq = document.getElementById('sstvGeneralStripFreq');
const stripMod = document.getElementById('sstvGeneralStripMod');
if (stripFreq) stripFreq.textContent = frequency.toFixed(3);
if (stripMod) stripMod.textContent = modulation.toUpperCase();
} else {
updateStatusUI('idle', 'Start failed');
showStatusMessage(data.message || 'Failed to start decoder', 'error');
}
} catch (err) {
console.error('Failed to start SSTV General:', err);
updateStatusUI('idle', 'Error');
showStatusMessage('Connection error: ' + err.message, 'error');
}
}
/**
* Stop SSTV decoder
*/
async function stop() {
try {
await fetch('/sstv-general/stop', { method: 'POST' });
isRunning = false;
stopStream();
updateStatusUI('idle', 'Stopped');
showNotification('SSTV', 'Decoder stopped');
} catch (err) {
console.error('Failed to stop SSTV General:', err);
}
}
/**
* Update status UI elements
*/
function updateStatusUI(status, text) {
const dot = document.getElementById('sstvGeneralStripDot');
const statusText = document.getElementById('sstvGeneralStripStatus');
const startBtn = document.getElementById('sstvGeneralStartBtn');
const stopBtn = document.getElementById('sstvGeneralStopBtn');
if (dot) {
dot.className = 'sstv-general-strip-dot';
if (status === 'listening' || status === 'detecting') {
dot.classList.add('listening');
} else if (status === 'decoding') {
dot.classList.add('decoding');
} else {
dot.classList.add('idle');
}
}
if (statusText) {
statusText.textContent = text || status;
}
if (startBtn && stopBtn) {
if (status === 'listening' || status === 'decoding') {
startBtn.style.display = 'none';
stopBtn.style.display = 'inline-block';
} else {
startBtn.style.display = 'inline-block';
stopBtn.style.display = 'none';
}
}
// Update live content area
const liveContent = document.getElementById('sstvGeneralLiveContent');
if (liveContent) {
if (status === 'idle' || status === 'unavailable') {
liveContent.innerHTML = renderIdleState();
}
}
}
/**
* Render idle state HTML
*/
function renderIdleState() {
return `
<div class="sstv-general-idle-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="12" cy="12" r="3"/>
<path d="M3 9h2M19 9h2M3 15h2M19 15h2"/>
</svg>
<h4>SSTV Decoder</h4>
<p>Select a frequency and click Start to listen for SSTV transmissions</p>
</div>
`;
}
/**
* Initialize signal scope canvas
*/
function initSstvGeneralScope() {
const canvas = document.getElementById('sstvGeneralScopeCanvas');
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * (window.devicePixelRatio || 1);
canvas.height = rect.height * (window.devicePixelRatio || 1);
sstvGeneralScopeCtx = canvas.getContext('2d');
sstvGeneralScopeHistory = new Array(SSTV_GENERAL_SCOPE_LEN).fill(0);
sstvGeneralScopeRms = 0;
sstvGeneralScopePeak = 0;
sstvGeneralScopeTargetRms = 0;
sstvGeneralScopeTargetPeak = 0;
sstvGeneralScopeMsgBurst = 0;
sstvGeneralScopeTone = null;
drawSstvGeneralScope();
}
/**
* Draw signal scope animation frame
*/
function drawSstvGeneralScope() {
const ctx = sstvGeneralScopeCtx;
if (!ctx) return;
const W = ctx.canvas.width;
const H = ctx.canvas.height;
const midY = H / 2;
// Phosphor persistence
ctx.fillStyle = 'rgba(5, 5, 16, 0.3)';
ctx.fillRect(0, 0, W, H);
// Smooth towards target
sstvGeneralScopeRms += (sstvGeneralScopeTargetRms - sstvGeneralScopeRms) * 0.25;
sstvGeneralScopePeak += (sstvGeneralScopeTargetPeak - sstvGeneralScopePeak) * 0.15;
// Push to history
sstvGeneralScopeHistory.push(Math.min(sstvGeneralScopeRms / 32768, 1.0));
if (sstvGeneralScopeHistory.length > SSTV_GENERAL_SCOPE_LEN) sstvGeneralScopeHistory.shift();
// Grid lines
ctx.strokeStyle = 'rgba(60, 40, 80, 0.4)';
ctx.lineWidth = 0.5;
for (let i = 1; i < 4; i++) {
const y = (H / 4) * i;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
}
for (let i = 1; i < 8; i++) {
const x = (W / 8) * i;
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
}
// Waveform
const stepX = W / (SSTV_GENERAL_SCOPE_LEN - 1);
ctx.strokeStyle = '#c080ff';
ctx.lineWidth = 1.5;
ctx.shadowColor = '#c080ff';
ctx.shadowBlur = 4;
// Upper half
ctx.beginPath();
for (let i = 0; i < sstvGeneralScopeHistory.length; i++) {
const x = i * stepX;
const amp = sstvGeneralScopeHistory[i] * midY * 0.9;
const y = midY - amp;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
// Lower half (mirror)
ctx.beginPath();
for (let i = 0; i < sstvGeneralScopeHistory.length; i++) {
const x = i * stepX;
const amp = sstvGeneralScopeHistory[i] * midY * 0.9;
const y = midY + amp;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
ctx.shadowBlur = 0;
// Peak indicator
const peakNorm = Math.min(sstvGeneralScopePeak / 32768, 1.0);
if (peakNorm > 0.01) {
const peakY = midY - peakNorm * midY * 0.9;
ctx.strokeStyle = 'rgba(255, 68, 68, 0.6)';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(0, peakY); ctx.lineTo(W, peakY); ctx.stroke();
ctx.setLineDash([]);
}
// Image decode flash
if (sstvGeneralScopeMsgBurst > 0.01) {
ctx.fillStyle = `rgba(0, 255, 100, ${sstvGeneralScopeMsgBurst * 0.15})`;
ctx.fillRect(0, 0, W, H);
sstvGeneralScopeMsgBurst *= 0.88;
}
// Update labels
const rmsLabel = document.getElementById('sstvGeneralScopeRmsLabel');
const peakLabel = document.getElementById('sstvGeneralScopePeakLabel');
const toneLabel = document.getElementById('sstvGeneralScopeToneLabel');
const statusLabel = document.getElementById('sstvGeneralScopeStatusLabel');
if (rmsLabel) rmsLabel.textContent = Math.round(sstvGeneralScopeRms);
if (peakLabel) peakLabel.textContent = Math.round(sstvGeneralScopePeak);
if (toneLabel) {
if (sstvGeneralScopeTone === 'leader') { toneLabel.textContent = 'LEADER'; toneLabel.style.color = '#0f0'; }
else if (sstvGeneralScopeTone === 'sync') { toneLabel.textContent = 'SYNC'; toneLabel.style.color = '#0ff'; }
else if (sstvGeneralScopeTone === 'decoding') { toneLabel.textContent = 'DECODING'; toneLabel.style.color = '#fa0'; }
else if (sstvGeneralScopeTone === 'noise') { toneLabel.textContent = 'NOISE'; toneLabel.style.color = '#555'; }
else { toneLabel.textContent = 'QUIET'; toneLabel.style.color = '#444'; }
}
if (statusLabel) {
if (sstvGeneralScopeRms > 500) { statusLabel.textContent = 'SIGNAL'; statusLabel.style.color = '#0f0'; }
else { statusLabel.textContent = 'MONITORING'; statusLabel.style.color = '#555'; }
}
sstvGeneralScopeAnim = requestAnimationFrame(drawSstvGeneralScope);
}
/**
* Stop signal scope
*/
function stopSstvGeneralScope() {
if (sstvGeneralScopeAnim) { cancelAnimationFrame(sstvGeneralScopeAnim); sstvGeneralScopeAnim = null; }
sstvGeneralScopeCtx = null;
}
/**
* Start SSE stream
*/
function startStream() {
if (eventSource) {
eventSource.close();
}
// Show and init scope
const scopePanel = document.getElementById('sstvGeneralScopePanel');
if (scopePanel) scopePanel.style.display = 'block';
initSstvGeneralScope();
eventSource = new EventSource('/sstv-general/stream');
eventSource.onmessage = (e) => {
try {
const data = JSON.parse(e.data);
if (data.type === 'sstv_progress') {
handleProgress(data);
} else if (data.type === 'sstv_scope') {
sstvGeneralScopeTargetRms = data.rms;
sstvGeneralScopeTargetPeak = data.peak;
if (data.tone !== undefined) sstvGeneralScopeTone = data.tone;
}
} catch (err) {
console.error('Failed to parse SSE message:', err);
}
};
eventSource.onerror = () => {
console.warn('SSTV General SSE error, will reconnect...');
setTimeout(() => {
if (isRunning) startStream();
}, 3000);
};
}
/**
* Stop SSE stream
*/
function stopStream() {
if (eventSource) {
eventSource.close();
eventSource = null;
}
stopSstvGeneralScope();
const scopePanel = document.getElementById('sstvGeneralScopePanel');
if (scopePanel) scopePanel.style.display = 'none';
}
/**
* Handle progress update
*/
function handleProgress(data) {
currentMode = data.mode || currentMode;
progress = data.progress || 0;
if (data.status === 'decoding') {
updateStatusUI('decoding', `Decoding ${currentMode || 'image'}...`);
renderDecodeProgress(data);
} else if (data.status === 'complete' && data.image) {
images.unshift(data.image);
updateImageCount(images.length);
renderGallery();
showNotification('SSTV', 'New image decoded!');
updateStatusUI('listening', 'Listening...');
sstvGeneralScopeMsgBurst = 1.0;
// Clear decode progress so signal monitor can take over
const liveContent = document.getElementById('sstvGeneralLiveContent');
if (liveContent) liveContent.innerHTML = '';
} else if (data.status === 'detecting') {
// Ignore detecting events if currently decoding (e.g. Doppler updates)
const dot = document.getElementById('sstvGeneralStripDot');
if (dot && dot.classList.contains('decoding')) return;
updateStatusUI('listening', data.message || 'Listening...');
if (data.signal_level !== undefined) {
renderSignalMonitor(data);
}
}
}
/**
* Render signal monitor in live area during detecting mode
*/
function renderSignalMonitor(data) {
const container = document.getElementById('sstvGeneralLiveContent');
if (!container) return;
const level = data.signal_level || 0;
const tone = data.sstv_tone;
let barColor, statusText;
if (tone === 'leader') {
barColor = 'var(--accent-green)';
statusText = 'SSTV leader tone detected';
} else if (tone === 'sync') {
barColor = 'var(--accent-cyan)';
statusText = 'SSTV sync pulse detected';
} else if (tone === 'noise') {
barColor = 'var(--text-dim)';
statusText = 'Audio signal present';
} else if (level > 10) {
barColor = 'var(--text-dim)';
statusText = 'Audio signal present';
} else {
barColor = 'var(--text-dim)';
statusText = 'No signal';
}
let monitor = container.querySelector('.sstv-general-signal-monitor');
if (!monitor) {
container.innerHTML = `
<div class="sstv-general-signal-monitor">
<div class="sstv-general-signal-monitor-header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M2 12L5 12M5 12C5 12 6 3 12 3C18 3 19 12 19 12M19 12L22 12"/>
<circle cx="12" cy="18" r="2"/>
<path d="M12 16V12"/>
</svg>
Signal Monitor
</div>
<div class="sstv-general-signal-level-row">
<span class="sstv-general-signal-level-label">LEVEL</span>
<div class="sstv-general-signal-bar-track">
<div class="sstv-general-signal-bar-fill" style="width: 0%"></div>
</div>
<span class="sstv-general-signal-level-value">0</span>
</div>
<div class="sstv-general-signal-status-text">No signal</div>
<div class="sstv-general-signal-vis-state">VIS: idle</div>
</div>`;
monitor = container.querySelector('.sstv-general-signal-monitor');
}
const fill = monitor.querySelector('.sstv-general-signal-bar-fill');
fill.style.width = level + '%';
fill.style.background = barColor;
monitor.querySelector('.sstv-general-signal-status-text').textContent = statusText;
monitor.querySelector('.sstv-general-signal-level-value').textContent = level;
const visStateEl = monitor.querySelector('.sstv-general-signal-vis-state');
if (visStateEl && data.vis_state) {
const stateLabels = {
'idle': 'Idle',
'leader_1': 'Leader',
'break': 'Break',
'leader_2': 'Leader 2',
'start_bit': 'Start bit',
'data_bits': 'Data bits',
'parity': 'Parity',
'stop_bit': 'Stop bit',
};
const label = stateLabels[data.vis_state] || data.vis_state;
visStateEl.textContent = 'VIS: ' + label;
visStateEl.className = 'sstv-general-signal-vis-state' +
(data.vis_state !== 'idle' ? ' active' : '');
}
}
/**
* Render decode progress in live area
*/
function renderDecodeProgress(data) {
const liveContent = document.getElementById('sstvGeneralLiveContent');
if (!liveContent) return;
let container = liveContent.querySelector('.sstv-general-decode-container');
if (!container) {
liveContent.innerHTML = `
<div class="sstv-general-decode-container">
<div class="sstv-general-canvas-container">
<img id="sstvGeneralDecodeImg" width="320" height="256" alt="Decoding..." style="display:block;background:#000;">
</div>
<div class="sstv-general-decode-info">
<div class="sstv-general-mode-label"></div>
<div class="sstv-general-progress-bar">
<div class="progress" style="width: 0%"></div>
</div>
<div class="sstv-general-status-message"></div>
</div>
</div>
`;
container = liveContent.querySelector('.sstv-general-decode-container');
}
container.querySelector('.sstv-general-mode-label').textContent = data.mode || 'Detecting mode...';
container.querySelector('.progress').style.width = (data.progress || 0) + '%';
container.querySelector('.sstv-general-status-message').textContent = data.message || 'Decoding...';
if (data.partial_image) {
const img = container.querySelector('#sstvGeneralDecodeImg');
if (img) img.src = data.partial_image;
}
}
/**
* Load decoded images
*/
async function loadImages() {
try {
const response = await fetch('/sstv-general/images');
const data = await response.json();
if (data.status === 'ok') {
images = data.images || [];
updateImageCount(images.length);
renderGallery();
}
} catch (err) {
console.error('Failed to load SSTV General images:', err);
}
}
/**
* Update image count display
*/
function updateImageCount(count) {
const countEl = document.getElementById('sstvGeneralImageCount');
const stripCount = document.getElementById('sstvGeneralStripImageCount');
if (countEl) countEl.textContent = count;
if (stripCount) stripCount.textContent = count;
}
/**
* Render image gallery
*/
function renderGallery() {
const gallery = document.getElementById('sstvGeneralGallery');
if (!gallery) return;
if (images.length === 0) {
gallery.innerHTML = `
<div class="sstv-general-gallery-empty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
<p>No images decoded yet</p>
</div>
`;
return;
}
gallery.innerHTML = images.map(img => `
<div class="sstv-general-image-card">
<div class="sstv-general-image-card-inner" onclick="SSTVGeneral.showImage('${escapeHtml(img.url)}', '${escapeHtml(img.filename)}')">
<img src="${escapeHtml(img.url)}" alt="SSTV Image" class="sstv-general-image-preview" loading="lazy">
</div>
<div class="sstv-general-image-info">
<div class="sstv-general-image-mode">${escapeHtml(img.mode || 'Unknown')}</div>
<div class="sstv-general-image-timestamp">${formatTimestamp(img.timestamp)}</div>
</div>
<div class="sstv-general-image-actions">
<button onclick="event.stopPropagation(); SSTVGeneral.downloadImage('${escapeHtml(img.url)}', '${escapeHtml(img.filename)}')" title="Download">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</button>
<button onclick="event.stopPropagation(); SSTVGeneral.deleteImage('${escapeHtml(img.filename)}')" title="Delete">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
</button>
</div>
</div>
`).join('');
}
/**
* Show full-size image in modal
*/
let currentModalUrl = null;
let currentModalFilename = null;
function showImage(url, filename) {
currentModalUrl = url;
currentModalFilename = filename || null;
let modal = document.getElementById('sstvGeneralImageModal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'sstvGeneralImageModal';
modal.className = 'sstv-general-image-modal';
modal.innerHTML = `
<div class="sstv-general-modal-toolbar">
<button class="sstv-general-modal-btn" id="sstvGeneralModalDownload" title="Download">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Download
</button>
<button class="sstv-general-modal-btn delete" id="sstvGeneralModalDelete" title="Delete">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
Delete
</button>
</div>
<button class="sstv-general-modal-close" onclick="SSTVGeneral.closeImage()">&times;</button>
<img src="" alt="SSTV Image">
`;
modal.addEventListener('click', (e) => {
if (e.target === modal) closeImage();
});
modal.querySelector('#sstvGeneralModalDownload').addEventListener('click', () => {
if (currentModalUrl && currentModalFilename) {
downloadImage(currentModalUrl, currentModalFilename);
}
});
modal.querySelector('#sstvGeneralModalDelete').addEventListener('click', () => {
if (currentModalFilename) {
deleteImage(currentModalFilename);
}
});
document.body.appendChild(modal);
}
modal.querySelector('img').src = url;
modal.classList.add('show');
}
/**
* Close image modal
*/
function closeImage() {
const modal = document.getElementById('sstvGeneralImageModal');
if (modal) modal.classList.remove('show');
}
/**
* Format timestamp for display
*/
function formatTimestamp(isoString) {
if (!isoString) return '--';
try {
const date = new Date(isoString);
return date.toLocaleString();
} catch {
return isoString;
}
}
/**
* Escape HTML for safe display
*/
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Delete a single image
*/
async function deleteImage(filename) {
if (!confirm('Delete this image?')) return;
try {
const response = await fetch(`/sstv-general/images/${encodeURIComponent(filename)}`, { method: 'DELETE' });
const data = await response.json();
if (data.status === 'ok') {
images = images.filter(img => img.filename !== filename);
updateImageCount(images.length);
renderGallery();
closeImage();
showNotification('SSTV', 'Image deleted');
}
} catch (err) {
console.error('Failed to delete image:', err);
}
}
/**
* Delete all images
*/
async function deleteAllImages() {
if (!confirm('Delete all decoded images?')) return;
try {
const response = await fetch('/sstv-general/images', { method: 'DELETE' });
const data = await response.json();
if (data.status === 'ok') {
images = [];
updateImageCount(0);
renderGallery();
showNotification('SSTV', `${data.deleted} image${data.deleted !== 1 ? 's' : ''} deleted`);
}
} catch (err) {
console.error('Failed to delete images:', err);
}
}
/**
* Download an image
*/
function downloadImage(url, filename) {
const a = document.createElement('a');
a.href = url + '/download';
a.download = filename;
a.click();
}
/**
* Show status message
*/
function showStatusMessage(message, type) {
if (typeof showNotification === 'function') {
showNotification('SSTV', message);
} else {
console.log(`[SSTV General ${type}] ${message}`);
}
}
// Public API
return {
init,
start,
stop,
loadImages,
showImage,
closeImage,
deleteImage,
deleteAllImages,
downloadImage,
selectPreset
};
})();
+24 -399
View File
@@ -21,18 +21,6 @@ const SSTV = (function() {
// ISS frequency
const ISS_FREQ = 145.800;
// Signal scope state
let sstvScopeCtx = null;
let sstvScopeAnim = null;
let sstvScopeHistory = [];
const SSTV_SCOPE_LEN = 200;
let sstvScopeRms = 0;
let sstvScopePeak = 0;
let sstvScopeTargetRms = 0;
let sstvScopeTargetPeak = 0;
let sstvScopeMsgBurst = 0;
let sstvScopeTone = null;
/**
* Initialize the SSTV mode
*/
@@ -53,13 +41,8 @@ const SSTV = (function() {
const latInput = document.getElementById('sstvObsLat');
const lonInput = document.getElementById('sstvObsLon');
let storedLat = localStorage.getItem('observerLat');
let storedLon = localStorage.getItem('observerLon');
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
const shared = ObserverLocation.getShared();
storedLat = shared.lat.toString();
storedLon = shared.lon.toString();
}
const storedLat = localStorage.getItem('observerLat');
const storedLon = localStorage.getItem('observerLon');
if (latInput && storedLat) latInput.value = storedLat;
if (lonInput && storedLon) lonInput.value = storedLon;
@@ -81,12 +64,8 @@ const SSTV = (function() {
if (!isNaN(lat) && lat >= -90 && lat <= 90 &&
!isNaN(lon) && lon >= -180 && lon <= 180) {
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
ObserverLocation.setShared({ lat, lon });
} else {
localStorage.setItem('observerLat', lat.toString());
localStorage.setItem('observerLon', lon.toString());
}
localStorage.setItem('observerLat', lat.toString());
localStorage.setItem('observerLon', lon.toString());
loadIssSchedule(); // Refresh pass predictions
}
}
@@ -115,12 +94,8 @@ const SSTV = (function() {
if (latInput) latInput.value = lat;
if (lonInput) lonInput.value = lon;
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
ObserverLocation.setShared({ lat: parseFloat(lat), lon: parseFloat(lon) });
} else {
localStorage.setItem('observerLat', lat);
localStorage.setItem('observerLon', lon);
}
localStorage.setItem('observerLat', lat);
localStorage.setItem('observerLon', lon);
btn.innerHTML = originalText;
btn.disabled = false;
@@ -171,7 +146,7 @@ const SSTV = (function() {
/**
* Initialize Leaflet map for ISS tracking
*/
async function initMap() {
function initMap() {
const mapContainer = document.getElementById('sstvIssMap');
if (!mapContainer || issMap) return;
@@ -185,19 +160,14 @@ const SSTV = (function() {
attributionControl: false,
worldCopyJump: true
});
window.issMap = issMap;
// Add tile layer using settings manager if available
if (typeof Settings !== 'undefined') {
// Wait for settings to load from server before applying tiles
await Settings.init();
if (typeof Settings !== 'undefined' && Settings.createTileLayer) {
Settings.createTileLayer().addTo(issMap);
Settings.registerMap(issMap);
} else {
// Fallback to dark theme tiles
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
maxZoom: 19,
className: 'tile-layer-cyan'
maxZoom: 19
}).addTo(issMap);
}
@@ -503,7 +473,7 @@ const SSTV = (function() {
if (!data.available) {
updateStatusUI('unavailable', 'Decoder not installed');
showStatusMessage('SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow', 'warning');
showStatusMessage('SSTV decoder not available. Install slowrx: apt install slowrx', 'warning');
return;
}
@@ -533,11 +503,6 @@ const SSTV = (function() {
const frequency = parseFloat(freqInput?.value || ISS_FREQ);
const device = parseInt(deviceSelect?.value || '0', 10);
// Check if device is available
if (typeof checkDeviceAvailability === 'function' && !checkDeviceAvailability('sstv')) {
return;
}
updateStatusUI('connecting', 'Starting...');
try {
@@ -551,9 +516,6 @@ const SSTV = (function() {
if (data.status === 'started' || data.status === 'already_running') {
isRunning = true;
if (typeof reserveDevice === 'function') {
reserveDevice(device, 'sstv');
}
updateStatusUI('listening', `${frequency} MHz`);
startStream();
showNotification('SSTV', `Listening on ${frequency} MHz`);
@@ -575,9 +537,6 @@ const SSTV = (function() {
try {
await fetch('/sstv/stop', { method: 'POST' });
isRunning = false;
if (typeof releaseDevice === 'function') {
releaseDevice('sstv');
}
stopStream();
updateStatusUI('idle', 'Stopped');
showNotification('SSTV', 'Decoder stopped');
@@ -646,136 +605,6 @@ const SSTV = (function() {
`;
}
/**
* Initialize signal scope canvas
*/
function initSstvScope() {
const canvas = document.getElementById('sstvScopeCanvas');
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * (window.devicePixelRatio || 1);
canvas.height = rect.height * (window.devicePixelRatio || 1);
sstvScopeCtx = canvas.getContext('2d');
sstvScopeHistory = new Array(SSTV_SCOPE_LEN).fill(0);
sstvScopeRms = 0;
sstvScopePeak = 0;
sstvScopeTargetRms = 0;
sstvScopeTargetPeak = 0;
sstvScopeMsgBurst = 0;
sstvScopeTone = null;
drawSstvScope();
}
/**
* Draw signal scope animation frame
*/
function drawSstvScope() {
const ctx = sstvScopeCtx;
if (!ctx) return;
const W = ctx.canvas.width;
const H = ctx.canvas.height;
const midY = H / 2;
// Phosphor persistence
ctx.fillStyle = 'rgba(5, 5, 16, 0.3)';
ctx.fillRect(0, 0, W, H);
// Smooth towards target
sstvScopeRms += (sstvScopeTargetRms - sstvScopeRms) * 0.25;
sstvScopePeak += (sstvScopeTargetPeak - sstvScopePeak) * 0.15;
// Push to history
sstvScopeHistory.push(Math.min(sstvScopeRms / 32768, 1.0));
if (sstvScopeHistory.length > SSTV_SCOPE_LEN) sstvScopeHistory.shift();
// Grid lines
ctx.strokeStyle = 'rgba(60, 40, 80, 0.4)';
ctx.lineWidth = 0.5;
for (let i = 1; i < 4; i++) {
const y = (H / 4) * i;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
}
for (let i = 1; i < 8; i++) {
const x = (W / 8) * i;
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
}
// Waveform
const stepX = W / (SSTV_SCOPE_LEN - 1);
ctx.strokeStyle = '#c080ff';
ctx.lineWidth = 1.5;
ctx.shadowColor = '#c080ff';
ctx.shadowBlur = 4;
// Upper half
ctx.beginPath();
for (let i = 0; i < sstvScopeHistory.length; i++) {
const x = i * stepX;
const amp = sstvScopeHistory[i] * midY * 0.9;
const y = midY - amp;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
// Lower half (mirror)
ctx.beginPath();
for (let i = 0; i < sstvScopeHistory.length; i++) {
const x = i * stepX;
const amp = sstvScopeHistory[i] * midY * 0.9;
const y = midY + amp;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
ctx.shadowBlur = 0;
// Peak indicator
const peakNorm = Math.min(sstvScopePeak / 32768, 1.0);
if (peakNorm > 0.01) {
const peakY = midY - peakNorm * midY * 0.9;
ctx.strokeStyle = 'rgba(255, 68, 68, 0.6)';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(0, peakY); ctx.lineTo(W, peakY); ctx.stroke();
ctx.setLineDash([]);
}
// Image decode flash
if (sstvScopeMsgBurst > 0.01) {
ctx.fillStyle = `rgba(0, 255, 100, ${sstvScopeMsgBurst * 0.15})`;
ctx.fillRect(0, 0, W, H);
sstvScopeMsgBurst *= 0.88;
}
// Update labels
const rmsLabel = document.getElementById('sstvScopeRmsLabel');
const peakLabel = document.getElementById('sstvScopePeakLabel');
const toneLabel = document.getElementById('sstvScopeToneLabel');
const statusLabel = document.getElementById('sstvScopeStatusLabel');
if (rmsLabel) rmsLabel.textContent = Math.round(sstvScopeRms);
if (peakLabel) peakLabel.textContent = Math.round(sstvScopePeak);
if (toneLabel) {
if (sstvScopeTone === 'leader') { toneLabel.textContent = 'LEADER'; toneLabel.style.color = '#0f0'; }
else if (sstvScopeTone === 'sync') { toneLabel.textContent = 'SYNC'; toneLabel.style.color = '#0ff'; }
else if (sstvScopeTone === 'decoding') { toneLabel.textContent = 'DECODING'; toneLabel.style.color = '#fa0'; }
else if (sstvScopeTone === 'noise') { toneLabel.textContent = 'NOISE'; toneLabel.style.color = '#555'; }
else { toneLabel.textContent = 'QUIET'; toneLabel.style.color = '#444'; }
}
if (statusLabel) {
if (sstvScopeRms > 500) { statusLabel.textContent = 'SIGNAL'; statusLabel.style.color = '#0f0'; }
else { statusLabel.textContent = 'MONITORING'; statusLabel.style.color = '#555'; }
}
sstvScopeAnim = requestAnimationFrame(drawSstvScope);
}
/**
* Stop signal scope
*/
function stopSstvScope() {
if (sstvScopeAnim) { cancelAnimationFrame(sstvScopeAnim); sstvScopeAnim = null; }
sstvScopeCtx = null;
}
/**
* Start SSE stream
*/
@@ -784,11 +613,6 @@ const SSTV = (function() {
eventSource.close();
}
// Show and init scope
const scopePanel = document.getElementById('sstvScopePanel');
if (scopePanel) scopePanel.style.display = 'block';
initSstvScope();
eventSource = new EventSource('/sstv/stream');
eventSource.onmessage = (e) => {
@@ -796,10 +620,6 @@ const SSTV = (function() {
const data = JSON.parse(e.data);
if (data.type === 'sstv_progress') {
handleProgress(data);
} else if (data.type === 'sstv_scope') {
sstvScopeTargetRms = data.rms;
sstvScopeTargetPeak = data.peak;
if (data.tone !== undefined) sstvScopeTone = data.tone;
}
} catch (err) {
console.error('Failed to parse SSE message:', err);
@@ -822,9 +642,6 @@ const SSTV = (function() {
eventSource.close();
eventSource = null;
}
stopSstvScope();
const scopePanel = document.getElementById('sstvScopePanel');
if (scopePanel) scopePanel.style.display = 'none';
}
/**
@@ -845,97 +662,8 @@ const SSTV = (function() {
renderGallery();
showNotification('SSTV', 'New image decoded!');
updateStatusUI('listening', 'Listening...');
sstvScopeMsgBurst = 1.0;
// Clear decode progress so signal monitor can take over
const liveContent = document.getElementById('sstvLiveContent');
if (liveContent) liveContent.innerHTML = '';
} else if (data.status === 'detecting') {
// Ignore detecting events if currently decoding (e.g. Doppler updates)
const dot = document.getElementById('sstvStripDot');
if (dot && dot.classList.contains('decoding')) return;
updateStatusUI('listening', data.message || 'Listening...');
if (data.signal_level !== undefined) {
renderSignalMonitor(data);
}
}
}
/**
* Render signal monitor in live area during detecting mode
*/
function renderSignalMonitor(data) {
const container = document.getElementById('sstvLiveContent');
if (!container) return;
const level = data.signal_level || 0;
const tone = data.sstv_tone;
let barColor, statusText;
if (tone === 'leader') {
barColor = 'var(--accent-green)';
statusText = 'SSTV leader tone detected';
} else if (tone === 'sync') {
barColor = 'var(--accent-cyan)';
statusText = 'SSTV sync pulse detected';
} else if (tone === 'noise') {
barColor = 'var(--text-dim)';
statusText = 'Audio signal present';
} else if (level > 10) {
barColor = 'var(--text-dim)';
statusText = 'Audio signal present';
} else {
barColor = 'var(--text-dim)';
statusText = 'No signal';
}
let monitor = container.querySelector('.sstv-signal-monitor');
if (!monitor) {
container.innerHTML = `
<div class="sstv-signal-monitor">
<div class="sstv-signal-monitor-header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M2 12L5 12M5 12C5 12 6 3 12 3C18 3 19 12 19 12M19 12L22 12"/>
<circle cx="12" cy="18" r="2"/>
<path d="M12 16V12"/>
</svg>
Signal Monitor
</div>
<div class="sstv-signal-level-row">
<span class="sstv-signal-level-label">LEVEL</span>
<div class="sstv-signal-bar-track">
<div class="sstv-signal-bar-fill" style="width: 0%"></div>
</div>
<span class="sstv-signal-level-value">0</span>
</div>
<div class="sstv-signal-status-text">No signal</div>
<div class="sstv-signal-vis-state">VIS: idle</div>
</div>`;
monitor = container.querySelector('.sstv-signal-monitor');
}
const fill = monitor.querySelector('.sstv-signal-bar-fill');
fill.style.width = level + '%';
fill.style.background = barColor;
monitor.querySelector('.sstv-signal-status-text').textContent = statusText;
monitor.querySelector('.sstv-signal-level-value').textContent = level;
const visStateEl = monitor.querySelector('.sstv-signal-vis-state');
if (visStateEl && data.vis_state) {
const stateLabels = {
'idle': 'Idle',
'leader_1': 'Leader',
'break': 'Break',
'leader_2': 'Leader 2',
'start_bit': 'Start bit',
'data_bits': 'Data bits',
'parity': 'Parity',
'stop_bit': 'Stop bit',
};
const label = stateLabels[data.vis_state] || data.vis_state;
visStateEl.textContent = 'VIS: ' + label;
visStateEl.className = 'sstv-signal-vis-state' +
(data.vis_state !== 'idle' ? ' active' : '');
}
}
@@ -946,33 +674,18 @@ const SSTV = (function() {
const liveContent = document.getElementById('sstvLiveContent');
if (!liveContent) return;
let container = liveContent.querySelector('.sstv-decode-container');
if (!container) {
liveContent.innerHTML = `
<div class="sstv-decode-container">
<div class="sstv-canvas-container">
<img id="sstvDecodeImg" width="320" height="256" alt="Decoding..." style="display:block;background:#000;">
</div>
<div class="sstv-decode-info">
<div class="sstv-mode-label"></div>
<div class="sstv-progress-bar">
<div class="progress" style="width: 0%"></div>
</div>
<div class="sstv-status-message"></div>
</div>
liveContent.innerHTML = `
<div class="sstv-canvas-container">
<canvas id="sstvCanvas" width="320" height="256"></canvas>
</div>
<div class="sstv-decode-info">
<div class="sstv-mode-label">${data.mode || 'Detecting mode...'}</div>
<div class="sstv-progress-bar">
<div class="progress" style="width: ${data.progress || 0}%"></div>
</div>
`;
container = liveContent.querySelector('.sstv-decode-container');
}
container.querySelector('.sstv-mode-label').textContent = data.mode || 'Detecting mode...';
container.querySelector('.progress').style.width = (data.progress || 0) + '%';
container.querySelector('.sstv-status-message').textContent = data.message || 'Decoding...';
if (data.partial_image) {
const img = container.querySelector('#sstvDecodeImg');
if (img) img.src = data.partial_image;
}
<div class="sstv-status-message">${data.message || 'Decoding...'}</div>
</div>
`;
}
/**
@@ -1026,22 +739,12 @@ const SSTV = (function() {
}
gallery.innerHTML = images.map(img => `
<div class="sstv-image-card">
<div class="sstv-image-card-inner" onclick="SSTV.showImage('${escapeHtml(img.url)}', '${escapeHtml(img.filename)}')">
<img src="${escapeHtml(img.url)}" alt="SSTV Image" class="sstv-image-preview" loading="lazy">
</div>
<div class="sstv-image-card" onclick="SSTV.showImage('${escapeHtml(img.url)}')">
<img src="${escapeHtml(img.url)}" alt="SSTV Image" class="sstv-image-preview" loading="lazy">
<div class="sstv-image-info">
<div class="sstv-image-mode">${escapeHtml(img.mode || 'Unknown')}</div>
<div class="sstv-image-timestamp">${formatTimestamp(img.timestamp)}</div>
</div>
<div class="sstv-image-actions">
<button onclick="event.stopPropagation(); SSTV.downloadImage('${escapeHtml(img.url)}', '${escapeHtml(img.filename)}')" title="Download">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</button>
<button onclick="event.stopPropagation(); SSTV.deleteImage('${escapeHtml(img.filename)}')" title="Delete">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
</button>
</div>
</div>
`).join('');
}
@@ -1173,45 +876,19 @@ const SSTV = (function() {
/**
* Show full-size image in modal
*/
let currentModalUrl = null;
let currentModalFilename = null;
function showImage(url, filename) {
currentModalUrl = url;
currentModalFilename = filename || null;
function showImage(url) {
let modal = document.getElementById('sstvImageModal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'sstvImageModal';
modal.className = 'sstv-image-modal';
modal.innerHTML = `
<div class="sstv-modal-toolbar">
<button class="sstv-modal-btn" id="sstvModalDownload" title="Download">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Download
</button>
<button class="sstv-modal-btn delete" id="sstvModalDelete" title="Delete">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
Delete
</button>
</div>
<button class="sstv-modal-close" onclick="SSTV.closeImage()">&times;</button>
<img src="" alt="SSTV Image">
`;
modal.addEventListener('click', (e) => {
if (e.target === modal) closeImage();
});
modal.querySelector('#sstvModalDownload').addEventListener('click', () => {
if (currentModalUrl && currentModalFilename) {
downloadImage(currentModalUrl, currentModalFilename);
}
});
modal.querySelector('#sstvModalDelete').addEventListener('click', () => {
if (currentModalFilename) {
deleteImage(currentModalFilename);
}
});
document.body.appendChild(modal);
}
@@ -1250,55 +927,6 @@ const SSTV = (function() {
return div.innerHTML;
}
/**
* Delete a single image
*/
async function deleteImage(filename) {
if (!confirm('Delete this image?')) return;
try {
const response = await fetch(`/sstv/images/${encodeURIComponent(filename)}`, { method: 'DELETE' });
const data = await response.json();
if (data.status === 'ok') {
images = images.filter(img => img.filename !== filename);
updateImageCount(images.length);
renderGallery();
closeImage();
showNotification('SSTV', 'Image deleted');
}
} catch (err) {
console.error('Failed to delete image:', err);
}
}
/**
* Delete all images
*/
async function deleteAllImages() {
if (!confirm('Delete all decoded images?')) return;
try {
const response = await fetch('/sstv/images', { method: 'DELETE' });
const data = await response.json();
if (data.status === 'ok') {
images = [];
updateImageCount(0);
renderGallery();
showNotification('SSTV', `${data.deleted} image${data.deleted !== 1 ? 's' : ''} deleted`);
}
} catch (err) {
console.error('Failed to delete images:', err);
}
}
/**
* Download an image
*/
function downloadImage(url, filename) {
const a = document.createElement('a');
a.href = url + '/download';
a.download = filename;
a.click();
}
/**
* Show status message
*/
@@ -1319,9 +947,6 @@ const SSTV = (function() {
loadIssSchedule,
showImage,
closeImage,
deleteImage,
deleteAllImages,
downloadImage,
useGPS,
updateTLE,
stopIssTracking,
-581
View File
@@ -1,581 +0,0 @@
/**
* Intercept - WebSDR Mode
* HF/Shortwave KiwiSDR Network Integration with In-App Audio
*/
// ============== STATE ==============
let websdrMap = null;
let websdrMarkers = [];
let websdrReceivers = [];
let websdrInitialized = false;
let websdrSpyStationsLoaded = false;
// KiwiSDR audio state
let kiwiWebSocket = null;
let kiwiAudioContext = null;
let kiwiScriptProcessor = null;
let kiwiGainNode = null;
let kiwiAudioBuffer = [];
let kiwiConnected = false;
let kiwiCurrentFreq = 0;
let kiwiCurrentMode = 'am';
let kiwiSmeter = 0;
let kiwiSmeterInterval = null;
let kiwiReceiverName = '';
const KIWI_SAMPLE_RATE = 12000;
// ============== INITIALIZATION ==============
function initWebSDR() {
if (websdrInitialized) {
if (websdrMap) {
setTimeout(() => websdrMap.invalidateSize(), 100);
}
return;
}
const mapEl = document.getElementById('websdrMap');
if (!mapEl || typeof L === 'undefined') return;
// Calculate minimum zoom so tiles fill the container vertically
const mapHeight = mapEl.clientHeight || 500;
const minZoom = Math.ceil(Math.log2(mapHeight / 256));
websdrMap = L.map('websdrMap', {
center: [20, 0],
zoom: Math.max(minZoom, 2),
minZoom: Math.max(minZoom, 2),
zoomControl: true,
maxBounds: [[-85, -360], [85, 360]],
maxBoundsViscosity: 1.0,
});
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; OpenStreetMap contributors &copy; CARTO',
subdomains: 'abcd',
maxZoom: 19,
}).addTo(websdrMap);
// Match background to tile ocean color so any remaining edge is seamless
mapEl.style.background = '#1a1d29';
websdrInitialized = true;
if (!websdrSpyStationsLoaded) {
loadSpyStationPresets();
}
[100, 300, 600, 1000].forEach(delay => {
setTimeout(() => {
if (websdrMap) websdrMap.invalidateSize();
}, delay);
});
}
// ============== RECEIVER SEARCH ==============
function searchReceivers(refresh) {
const freqKhz = parseFloat(document.getElementById('websdrFrequency')?.value || 0);
let url = '/websdr/receivers?available=true';
if (freqKhz > 0) url += `&freq_khz=${freqKhz}`;
if (refresh) url += '&refresh=true';
fetch(url)
.then(r => r.json())
.then(data => {
if (data.status === 'success') {
websdrReceivers = data.receivers || [];
renderReceiverList(websdrReceivers);
plotReceiversOnMap(websdrReceivers);
const countEl = document.getElementById('websdrReceiverCount');
if (countEl) countEl.textContent = `${websdrReceivers.length} found`;
}
})
.catch(err => console.error('[WEBSDR] Search error:', err));
}
// ============== MAP ==============
function plotReceiversOnMap(receivers) {
if (!websdrMap) return;
websdrMarkers.forEach(m => websdrMap.removeLayer(m));
websdrMarkers = [];
receivers.forEach((rx, idx) => {
if (rx.lat == null || rx.lon == null) return;
const marker = L.circleMarker([rx.lat, rx.lon], {
radius: 6,
fillColor: rx.available ? '#00d4ff' : '#666',
color: rx.available ? '#00d4ff' : '#666',
weight: 1,
opacity: 0.8,
fillOpacity: 0.6,
});
marker.bindPopup(`
<div style="font-size: 12px; min-width: 200px;">
<strong>${escapeHtmlWebsdr(rx.name)}</strong><br>
${rx.location ? `<span style="color: #aaa;">${escapeHtmlWebsdr(rx.location)}</span><br>` : ''}
<span style="color: #888;">Antenna: ${escapeHtmlWebsdr(rx.antenna || 'Unknown')}</span><br>
<span style="color: #888;">Users: ${rx.users}/${rx.users_max}</span><br>
<button onclick="selectReceiver(${idx})" style="margin-top: 6px; padding: 4px 12px; background: #00d4ff; color: #000; border: none; border-radius: 3px; cursor: pointer; font-weight: bold;">Listen</button>
</div>
`);
marker.addTo(websdrMap);
websdrMarkers.push(marker);
});
if (websdrMarkers.length > 0) {
const group = L.featureGroup(websdrMarkers);
websdrMap.fitBounds(group.getBounds(), { padding: [30, 30] });
}
}
// ============== RECEIVER LIST ==============
function renderReceiverList(receivers) {
const container = document.getElementById('websdrReceiverList');
if (!container) return;
if (receivers.length === 0) {
container.innerHTML = '<div style="color: var(--text-muted); text-align: center; padding: 20px;">No receivers found</div>';
return;
}
container.innerHTML = receivers.slice(0, 50).map((rx, idx) => `
<div style="padding: 8px; border-bottom: 1px solid rgba(255,255,255,0.05); cursor: pointer; transition: background 0.2s;"
onmouseover="this.style.background='rgba(0,212,255,0.05)'" onmouseout="this.style.background='transparent'"
onclick="selectReceiver(${idx})">
<div style="display: flex; justify-content: space-between; align-items: center;">
<strong style="font-size: 11px; color: var(--text-primary);">${escapeHtmlWebsdr(rx.name)}</strong>
<span style="font-size: 9px; padding: 1px 6px; background: ${rx.available ? 'rgba(0,230,118,0.15)' : 'rgba(158,158,158,0.15)'}; color: ${rx.available ? '#00e676' : '#9e9e9e'}; border-radius: 3px;">${rx.users}/${rx.users_max}</span>
</div>
<div style="font-size: 9px; color: var(--text-muted); margin-top: 2px;">
${rx.location ? escapeHtmlWebsdr(rx.location) + ' · ' : ''}${escapeHtmlWebsdr(rx.antenna || '')}
${rx.distance_km !== undefined ? ` · ${rx.distance_km} km` : ''}
</div>
</div>
`).join('');
}
// ============== SELECT RECEIVER ==============
function selectReceiver(index) {
const rx = websdrReceivers[index];
if (!rx) return;
const freqKhz = parseFloat(document.getElementById('websdrFrequency')?.value || 7000);
const mode = document.getElementById('websdrMode_select')?.value || 'am';
kiwiReceiverName = rx.name;
// Connect via backend proxy
connectToReceiver(rx.url, freqKhz, mode);
// Highlight on map
if (websdrMap && rx.lat != null && rx.lon != null) {
websdrMap.setView([rx.lat, rx.lon], 6);
}
}
// ============== KIWISDR AUDIO CONNECTION ==============
function connectToReceiver(receiverUrl, freqKhz, mode) {
// Disconnect if already connected
if (kiwiWebSocket) {
disconnectFromReceiver();
}
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${proto}//${location.host}/ws/kiwi-audio`;
kiwiWebSocket = new WebSocket(wsUrl);
kiwiWebSocket.binaryType = 'arraybuffer';
kiwiWebSocket.onopen = () => {
kiwiWebSocket.send(JSON.stringify({
cmd: 'connect',
url: receiverUrl,
freq_khz: freqKhz,
mode: mode,
}));
updateKiwiUI('connecting');
};
kiwiWebSocket.onmessage = (event) => {
if (typeof event.data === 'string') {
const msg = JSON.parse(event.data);
handleKiwiStatus(msg);
} else {
handleKiwiAudio(event.data);
}
};
kiwiWebSocket.onclose = () => {
kiwiConnected = false;
updateKiwiUI('disconnected');
};
kiwiWebSocket.onerror = () => {
updateKiwiUI('disconnected');
};
}
function handleKiwiStatus(msg) {
switch (msg.type) {
case 'connected':
kiwiConnected = true;
kiwiCurrentFreq = msg.freq_khz;
kiwiCurrentMode = msg.mode;
initKiwiAudioContext(msg.sample_rate || KIWI_SAMPLE_RATE);
updateKiwiUI('connected');
break;
case 'tuned':
kiwiCurrentFreq = msg.freq_khz;
kiwiCurrentMode = msg.mode;
updateKiwiUI('connected');
break;
case 'error':
console.error('[KIWI] Error:', msg.message);
if (typeof showNotification === 'function') {
showNotification('WebSDR', msg.message);
}
updateKiwiUI('error');
break;
case 'disconnected':
kiwiConnected = false;
cleanupKiwiAudio();
updateKiwiUI('disconnected');
break;
}
}
function handleKiwiAudio(arrayBuffer) {
if (arrayBuffer.byteLength < 4) return;
// First 2 bytes: S-meter (big-endian int16)
const view = new DataView(arrayBuffer);
kiwiSmeter = view.getInt16(0, false);
// Remaining bytes: PCM 16-bit signed LE
const pcmData = new Int16Array(arrayBuffer, 2);
// Convert to float32 [-1, 1] for Web Audio API
const float32 = new Float32Array(pcmData.length);
for (let i = 0; i < pcmData.length; i++) {
float32[i] = pcmData[i] / 32768.0;
}
// Add to playback buffer (limit buffer size to ~2s)
kiwiAudioBuffer.push(float32);
const maxChunks = Math.ceil((KIWI_SAMPLE_RATE * 2) / 512);
while (kiwiAudioBuffer.length > maxChunks) {
kiwiAudioBuffer.shift();
}
}
function initKiwiAudioContext(sampleRate) {
cleanupKiwiAudio();
kiwiAudioContext = new (window.AudioContext || window.webkitAudioContext)({
sampleRate: sampleRate,
});
// Resume if suspended (autoplay policy)
if (kiwiAudioContext.state === 'suspended') {
kiwiAudioContext.resume();
}
// ScriptProcessorNode: pulls audio from buffer
kiwiScriptProcessor = kiwiAudioContext.createScriptProcessor(2048, 0, 1);
kiwiScriptProcessor.onaudioprocess = (e) => {
const output = e.outputBuffer.getChannelData(0);
let offset = 0;
while (offset < output.length && kiwiAudioBuffer.length > 0) {
const chunk = kiwiAudioBuffer[0];
const needed = output.length - offset;
const available = chunk.length;
if (available <= needed) {
output.set(chunk, offset);
offset += available;
kiwiAudioBuffer.shift();
} else {
output.set(chunk.subarray(0, needed), offset);
kiwiAudioBuffer[0] = chunk.subarray(needed);
offset += needed;
}
}
// Fill remaining with silence
while (offset < output.length) {
output[offset++] = 0;
}
};
// Volume control
kiwiGainNode = kiwiAudioContext.createGain();
const savedVol = localStorage.getItem('kiwiVolume');
kiwiGainNode.gain.value = savedVol !== null ? parseFloat(savedVol) / 100 : 0.8;
const volValue = Math.round(kiwiGainNode.gain.value * 100);
['kiwiVolume', 'kiwiBarVolume'].forEach(id => {
const el = document.getElementById(id);
if (el) el.value = volValue;
});
kiwiScriptProcessor.connect(kiwiGainNode);
kiwiGainNode.connect(kiwiAudioContext.destination);
// S-meter display updates
if (kiwiSmeterInterval) clearInterval(kiwiSmeterInterval);
kiwiSmeterInterval = setInterval(updateSmeterDisplay, 200);
}
function disconnectFromReceiver() {
if (kiwiWebSocket && kiwiWebSocket.readyState === WebSocket.OPEN) {
kiwiWebSocket.send(JSON.stringify({ cmd: 'disconnect' }));
}
cleanupKiwiAudio();
if (kiwiWebSocket) {
kiwiWebSocket.close();
kiwiWebSocket = null;
}
kiwiConnected = false;
kiwiReceiverName = '';
updateKiwiUI('disconnected');
}
function cleanupKiwiAudio() {
if (kiwiSmeterInterval) {
clearInterval(kiwiSmeterInterval);
kiwiSmeterInterval = null;
}
if (kiwiScriptProcessor) {
kiwiScriptProcessor.disconnect();
kiwiScriptProcessor = null;
}
if (kiwiGainNode) {
kiwiGainNode.disconnect();
kiwiGainNode = null;
}
if (kiwiAudioContext) {
kiwiAudioContext.close().catch(() => {});
kiwiAudioContext = null;
}
kiwiAudioBuffer = [];
kiwiSmeter = 0;
}
function tuneKiwi(freqKhz, mode) {
if (!kiwiWebSocket || !kiwiConnected) return;
kiwiWebSocket.send(JSON.stringify({
cmd: 'tune',
freq_khz: freqKhz,
mode: mode || kiwiCurrentMode,
}));
}
function tuneFromBar() {
const freq = parseFloat(document.getElementById('kiwiBarFrequency')?.value || 0);
const mode = document.getElementById('kiwiBarMode')?.value || kiwiCurrentMode;
if (freq > 0) {
tuneKiwi(freq, mode);
// Also update sidebar frequency
const freqInput = document.getElementById('websdrFrequency');
if (freqInput) freqInput.value = freq;
}
}
function setKiwiVolume(value) {
if (kiwiGainNode) {
kiwiGainNode.gain.value = value / 100;
localStorage.setItem('kiwiVolume', value);
}
// Sync both volume sliders
['kiwiVolume', 'kiwiBarVolume'].forEach(id => {
const el = document.getElementById(id);
if (el && el.value !== String(value)) el.value = value;
});
}
// ============== S-METER ==============
function updateSmeterDisplay() {
// KiwiSDR S-meter: value in 0.1 dBm units (e.g., -730 = -73 dBm = S9)
const dbm = kiwiSmeter / 10;
let sUnit;
if (dbm >= -73) {
const over = Math.round((dbm + 73));
sUnit = over > 0 ? `S9+${over}` : 'S9';
} else {
sUnit = `S${Math.max(0, Math.round((dbm + 127) / 6))}`;
}
const pct = Math.min(100, Math.max(0, (dbm + 127) / 1.27));
// Update both sidebar and bar S-meter displays
['kiwiSmeterBar', 'kiwiBarSmeter'].forEach(id => {
const el = document.getElementById(id);
if (el) el.style.width = pct + '%';
});
['kiwiSmeterValue', 'kiwiBarSmeterValue'].forEach(id => {
const el = document.getElementById(id);
if (el) el.textContent = sUnit;
});
}
// ============== UI UPDATES ==============
function updateKiwiUI(state) {
const statusEl = document.getElementById('kiwiStatus');
const controlsBar = document.getElementById('kiwiAudioControls');
const disconnectBtn = document.getElementById('kiwiDisconnectBtn');
const receiverNameEl = document.getElementById('kiwiReceiverName');
const freqDisplay = document.getElementById('kiwiFreqDisplay');
const barReceiverName = document.getElementById('kiwiBarReceiverName');
const barFreq = document.getElementById('kiwiBarFrequency');
const barMode = document.getElementById('kiwiBarMode');
if (state === 'connected') {
if (statusEl) {
statusEl.textContent = 'CONNECTED';
statusEl.style.color = 'var(--accent-green)';
}
if (controlsBar) controlsBar.style.display = 'block';
if (disconnectBtn) disconnectBtn.style.display = 'block';
if (receiverNameEl) {
receiverNameEl.textContent = kiwiReceiverName;
receiverNameEl.style.display = 'block';
}
if (freqDisplay) freqDisplay.textContent = kiwiCurrentFreq + ' kHz';
if (barReceiverName) barReceiverName.textContent = kiwiReceiverName;
if (barFreq) barFreq.value = kiwiCurrentFreq;
if (barMode) barMode.value = kiwiCurrentMode;
} else if (state === 'connecting') {
if (statusEl) {
statusEl.textContent = 'CONNECTING...';
statusEl.style.color = 'var(--accent-orange)';
}
} else if (state === 'error') {
if (statusEl) {
statusEl.textContent = 'ERROR';
statusEl.style.color = 'var(--accent-red)';
}
} else {
// disconnected
if (statusEl) {
statusEl.textContent = 'DISCONNECTED';
statusEl.style.color = 'var(--text-muted)';
}
if (controlsBar) controlsBar.style.display = 'none';
if (disconnectBtn) disconnectBtn.style.display = 'none';
if (receiverNameEl) receiverNameEl.style.display = 'none';
if (freqDisplay) freqDisplay.textContent = '--- kHz';
// Reset both S-meter displays (sidebar + bar)
['kiwiSmeterBar', 'kiwiBarSmeter'].forEach(id => {
const el = document.getElementById(id);
if (el) el.style.width = '0%';
});
['kiwiSmeterValue', 'kiwiBarSmeterValue'].forEach(id => {
const el = document.getElementById(id);
if (el) el.textContent = 'S0';
});
}
}
// ============== SPY STATION PRESETS ==============
function loadSpyStationPresets() {
fetch('/spy-stations/stations')
.then(r => r.json())
.then(data => {
websdrSpyStationsLoaded = true;
const container = document.getElementById('websdrSpyPresets');
if (!container) return;
const stations = data.stations || data || [];
if (!Array.isArray(stations) || stations.length === 0) {
container.innerHTML = '<div style="color: var(--text-muted); text-align: center; padding: 10px;">No stations available</div>';
return;
}
container.innerHTML = stations.slice(0, 30).map(s => {
const primaryFreq = s.frequencies?.find(f => f.primary) || s.frequencies?.[0];
const freqKhz = primaryFreq?.freq_khz || 0;
return `
<div style="padding: 6px 4px; border-bottom: 1px solid rgba(255,255,255,0.05); cursor: pointer; display: flex; justify-content: space-between; align-items: center;"
onclick="tuneToSpyStation('${escapeHtmlWebsdr(s.id)}', ${freqKhz})"
onmouseover="this.style.background='rgba(0,212,255,0.05)'" onmouseout="this.style.background='transparent'">
<div>
<span style="color: var(--accent-cyan); font-weight: bold;">${escapeHtmlWebsdr(s.name)}</span>
<span style="color: var(--text-muted); font-size: 9px; margin-left: 4px;">${escapeHtmlWebsdr(s.nickname || '')}</span>
</div>
<span style="color: var(--accent-orange); font-family: var(--font-mono); font-size: 10px;">${freqKhz} kHz</span>
</div>
`;
}).join('');
})
.catch(err => {
console.error('[WEBSDR] Failed to load spy station presets:', err);
});
}
function tuneToSpyStation(stationId, freqKhz) {
const freqInput = document.getElementById('websdrFrequency');
if (freqInput) freqInput.value = freqKhz;
// If already connected, just retune
if (kiwiConnected) {
const mode = document.getElementById('websdrMode_select')?.value || kiwiCurrentMode;
tuneKiwi(freqKhz, mode);
return;
}
// Otherwise, search for receivers at this frequency
fetch(`/websdr/spy-station/${encodeURIComponent(stationId)}/receivers`)
.then(r => r.json())
.then(data => {
if (data.status === 'success') {
websdrReceivers = data.receivers || [];
renderReceiverList(websdrReceivers);
plotReceiversOnMap(websdrReceivers);
const countEl = document.getElementById('websdrReceiverCount');
if (countEl) countEl.textContent = `${websdrReceivers.length} for ${data.station?.name || stationId}`;
if (typeof showNotification === 'function' && data.station) {
showNotification('WebSDR', `Found ${websdrReceivers.length} receivers for ${data.station.name} at ${freqKhz} kHz`);
}
}
})
.catch(err => console.error('[WEBSDR] Spy station receivers error:', err));
}
// ============== UTILITIES ==============
function escapeHtmlWebsdr(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// ============== EXPORTS ==============
window.initWebSDR = initWebSDR;
window.searchReceivers = searchReceivers;
window.selectReceiver = selectReceiver;
window.tuneToSpyStation = tuneToSpyStation;
window.loadSpyStationPresets = loadSpyStationPresets;
window.connectToReceiver = connectToReceiver;
window.disconnectFromReceiver = disconnectFromReceiver;
window.tuneKiwi = tuneKiwi;
window.tuneFromBar = tuneFromBar;
window.setKiwiVolume = setKiwiVolume;
+474 -324
View File
@@ -28,9 +28,9 @@ const WiFiMode = (function() {
maxProbes: 1000,
};
// ==========================================================================
// Agent Support
// ==========================================================================
// ==========================================================================
// Agent Support
// ==========================================================================
/**
* Get the API base URL, routing through agent proxy if agent is selected.
@@ -59,49 +59,15 @@ const WiFiMode = (function() {
/**
* Check for agent mode conflicts before starting WiFi scan.
*/
function checkAgentConflicts() {
if (typeof currentAgent === 'undefined' || currentAgent === 'local') {
return true;
}
if (typeof checkAgentModeConflict === 'function') {
return checkAgentModeConflict('wifi');
}
return true;
}
function getChannelPresetList(preset) {
switch (preset) {
case '2.4-common':
return '1,6,11';
case '2.4-all':
return '1,2,3,4,5,6,7,8,9,10,11,12,13';
case '5-low':
return '36,40,44,48';
case '5-mid':
return '52,56,60,64';
case '5-high':
return '149,153,157,161,165';
default:
return '';
}
}
function buildChannelConfig() {
const preset = document.getElementById('wifiChannelPreset')?.value || '';
const listInput = document.getElementById('wifiChannelList')?.value || '';
const singleInput = document.getElementById('wifiChannel')?.value || '';
const listValue = listInput.trim();
const presetValue = getChannelPresetList(preset);
const channels = listValue || presetValue || '';
const channel = channels ? null : (singleInput.trim() ? parseInt(singleInput.trim()) : null);
return {
channels: channels || null,
channel: Number.isFinite(channel) ? channel : null,
};
}
function checkAgentConflicts() {
if (typeof currentAgent === 'undefined' || currentAgent === 'local') {
return true;
}
if (typeof checkAgentModeConflict === 'function') {
return checkAgentModeConflict('wifi');
}
return true;
}
// ==========================================================================
// State
@@ -111,7 +77,6 @@ const WiFiMode = (function() {
let scanMode = 'quick'; // 'quick' or 'deep'
let eventSource = null;
let pollTimer = null;
let agentPollTimer = null;
// Data stores
let networks = new Map(); // bssid -> network
@@ -371,6 +336,9 @@ const WiFiMode = (function() {
if (elements.scanModeDeep) {
elements.scanModeDeep.addEventListener('click', () => setScanMode('deep'));
}
// Initialize button visibility (default to quick mode)
setScanMode('quick');
}
function setScanMode(mode) {
@@ -384,6 +352,21 @@ const WiFiMode = (function() {
elements.scanModeDeep.classList.toggle('active', mode === 'deep');
}
// Update button visibility based on mode
if (!isScanning) {
if (elements.quickScanBtn) {
elements.quickScanBtn.style.display = mode === 'quick' ? 'inline-block' : 'none';
elements.quickScanBtn.textContent = 'Start Quick Scan';
}
if (elements.deepScanBtn) {
elements.deepScanBtn.style.display = mode === 'deep' ? 'inline-block' : 'none';
elements.deepScanBtn.textContent = 'Start Deep Scan';
}
if (elements.stopScanBtn) {
elements.stopScanBtn.style.display = 'none';
}
}
console.log('[WiFiMode] Scan mode set to:', mode);
}
@@ -392,7 +375,10 @@ const WiFiMode = (function() {
// ==========================================================================
async function startQuickScan() {
if (isScanning) return;
if (isScanning) {
showInfo('Scan already in progress');
return;
}
// Check for agent mode conflicts
if (!checkAgentConflicts()) {
@@ -400,6 +386,7 @@ const WiFiMode = (function() {
}
console.log('[WiFiMode] Starting quick scan...');
showInfo('Starting quick scan...');
setScanning(true, 'quick');
try {
@@ -471,6 +458,9 @@ const WiFiMode = (function() {
// Process results
processQuickScanResult({ ...scanResult, access_points: accessPoints });
// Show success notification
showInfo(`Found ${accessPoints.length} network${accessPoints.length !== 1 ? 's' : ''}`);
// For quick scan, we're done after one scan
// But keep polling if user wants continuous updates
if (scanMode === 'quick') {
@@ -484,7 +474,10 @@ const WiFiMode = (function() {
}
async function startDeepScan() {
if (isScanning) return;
if (isScanning) {
showInfo('Scan already in progress');
return;
}
// Check for agent mode conflicts
if (!checkAgentConflicts()) {
@@ -492,13 +485,14 @@ const WiFiMode = (function() {
}
console.log('[WiFiMode] Starting deep scan...');
showInfo('Starting deep scan (requires monitor mode)...');
setScanning(true, 'deep');
try {
const iface = elements.interfaceSelect?.value || null;
const band = document.getElementById('wifiBand')?.value || 'all';
const channelConfig = buildChannelConfig();
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
const iface = elements.interfaceSelect?.value || null;
const band = document.getElementById('wifiBand')?.value || 'all';
const channel = document.getElementById('wifiChannel')?.value || null;
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
let response;
if (isAgentMode) {
@@ -507,25 +501,23 @@ const WiFiMode = (function() {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
interface: iface,
scan_type: 'deep',
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
channel: channelConfig.channel,
channels: channelConfig.channels,
}),
});
} else {
response = await fetch(`${CONFIG.apiBase}/scan/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
interface: iface,
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
channel: channelConfig.channel,
channels: channelConfig.channels,
}),
});
}
interface: iface,
scan_type: 'deep',
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
channel: channel ? parseInt(channel) : null,
}),
});
} else {
response = await fetch(`${CONFIG.apiBase}/scan/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
interface: iface,
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
channel: channel ? parseInt(channel) : null,
}),
});
}
if (!response.ok) {
const error = await response.json();
@@ -542,13 +534,8 @@ const WiFiMode = (function() {
console.log('[WiFiMode] Agent deep scan started:', scanResult);
}
// Start SSE stream for real-time updates (works with push-enabled agents)
// Start SSE stream for real-time updates
startEventStream();
// Also start polling for agent data (works without push enabled)
if (isAgentMode) {
startAgentDeepScanPolling();
}
} catch (error) {
console.error('[WiFiMode] Deep scan error:', error);
showError(error.message);
@@ -558,6 +545,7 @@ const WiFiMode = (function() {
async function stopScan() {
console.log('[WiFiMode] Stopping scan...');
showInfo('Stopping scan...');
// Stop polling
if (pollTimer) {
@@ -565,9 +553,6 @@ const WiFiMode = (function() {
pollTimer = null;
}
// Stop agent polling
stopAgentDeepScanPolling();
// Close event stream
if (eventSource) {
eventSource.close();
@@ -583,6 +568,7 @@ const WiFiMode = (function() {
} else if (scanMode === 'deep') {
await fetch(`${CONFIG.apiBase}/scan/stop`, { method: 'POST' });
}
showInfo('Scan stopped');
} catch (error) {
console.warn('[WiFiMode] Error stopping scan:', error);
}
@@ -594,15 +580,21 @@ const WiFiMode = (function() {
isScanning = scanning;
if (mode) scanMode = mode;
// Update buttons
if (elements.quickScanBtn) {
elements.quickScanBtn.style.display = scanning ? 'none' : 'inline-block';
}
if (elements.deepScanBtn) {
elements.deepScanBtn.style.display = scanning ? 'none' : 'inline-block';
}
if (elements.stopScanBtn) {
elements.stopScanBtn.style.display = scanning ? 'inline-block' : 'none';
// Update buttons based on scanning state and current mode
if (scanning) {
// Scanning: hide start buttons, show stop button
if (elements.quickScanBtn) elements.quickScanBtn.style.display = 'none';
if (elements.deepScanBtn) elements.deepScanBtn.style.display = 'none';
if (elements.stopScanBtn) elements.stopScanBtn.style.display = 'inline-block';
} else {
// Not scanning: show appropriate start button based on mode, hide stop
if (elements.quickScanBtn) {
elements.quickScanBtn.style.display = scanMode === 'quick' ? 'inline-block' : 'none';
}
if (elements.deepScanBtn) {
elements.deepScanBtn.style.display = scanMode === 'deep' ? 'inline-block' : 'none';
}
if (elements.stopScanBtn) elements.stopScanBtn.style.display = 'none';
}
// Update status
@@ -629,18 +621,9 @@ const WiFiMode = (function() {
const status = isAgentMode && data.result ? data.result : data;
if (status.is_scanning || status.running) {
// Agent returns scan_type in params, local returns scan_mode
// Normalize: agent may return 'deepscan' or 'deep', UI expects 'deep' or 'quick'
let detectedMode = status.scan_mode || (status.params && status.params.scan_type) || 'deep';
if (detectedMode === 'deepscan') detectedMode = 'deep';
setScanning(true, detectedMode);
if (detectedMode === 'deep') {
setScanning(true, status.scan_mode);
if (status.scan_mode === 'deep') {
startEventStream();
// Also start polling for agent mode (works without push enabled)
if (isAgentMode) {
startAgentDeepScanPolling();
}
} else {
startQuickScanPolling();
}
@@ -709,76 +692,6 @@ const WiFiMode = (function() {
});
}
// ==========================================================================
// Agent Deep Scan Polling (fallback when push is not enabled)
// ==========================================================================
function startAgentDeepScanPolling() {
if (agentPollTimer) return;
console.log('[WiFiMode] Starting agent deep scan polling...');
agentPollTimer = setInterval(async () => {
if (!isScanning || scanMode !== 'deep') {
clearInterval(agentPollTimer);
agentPollTimer = null;
return;
}
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
if (!isAgentMode) {
clearInterval(agentPollTimer);
agentPollTimer = null;
return;
}
try {
const response = await fetch(`/controller/agents/${currentAgent}/wifi/data`);
if (!response.ok) return;
const result = await response.json();
if (result.status !== 'success' || !result.data) return;
const data = result.data.data || result.data;
const agentName = result.agent_name || 'Remote';
// Process networks
if (data.networks && Array.isArray(data.networks)) {
data.networks.forEach(net => {
net._agent = agentName;
handleStreamEvent({
type: 'network_update',
network: net
});
});
}
// Process clients
if (data.clients && Array.isArray(data.clients)) {
data.clients.forEach(client => {
client._agent = agentName;
handleStreamEvent({
type: 'client_update',
client: client
});
});
}
console.debug(`[WiFiMode] Agent poll: ${data.networks?.length || 0} networks, ${data.clients?.length || 0} clients`);
} catch (error) {
console.debug('[WiFiMode] Agent poll error:', error);
}
}, 2000); // Poll every 2 seconds
}
function stopAgentDeepScanPolling() {
if (agentPollTimer) {
clearInterval(agentPollTimer);
agentPollTimer = null;
}
}
// ==========================================================================
// SSE Event Stream
// ==========================================================================
@@ -915,7 +828,6 @@ const WiFiMode = (function() {
updateNetworkRow(network);
updateStats();
updateProximityRadar();
updateChannelChart();
if (onNetworkUpdate) onNetworkUpdate(network);
}
@@ -924,9 +836,6 @@ const WiFiMode = (function() {
clients.set(client.mac, client);
updateStats();
// Update client display if this client belongs to the selected network
updateClientInList(client);
if (onClientUpdate) onClientUpdate(client);
}
@@ -1175,9 +1084,6 @@ const WiFiMode = (function() {
// Show the drawer
elements.detailDrawer.classList.add('open');
// Fetch and display clients for this network
fetchClientsForNetwork(network.bssid);
}
function closeDetail() {
@@ -1190,130 +1096,6 @@ const WiFiMode = (function() {
});
}
// ==========================================================================
// Client Display
// ==========================================================================
async function fetchClientsForNetwork(bssid) {
if (!elements.detailClientList) return;
try {
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
let response;
if (isAgentMode) {
// Route through agent proxy
response = await fetch(`/controller/agents/${currentAgent}/wifi/v2/clients?bssid=${encodeURIComponent(bssid)}&associated=true`);
} else {
response = await fetch(`${CONFIG.apiBase}/clients?bssid=${encodeURIComponent(bssid)}&associated=true`);
}
if (!response.ok) {
// Hide client list on error
elements.detailClientList.style.display = 'none';
return;
}
const data = await response.json();
// Handle agent response format (may be nested in 'result')
const result = isAgentMode && data.result ? data.result : data;
const clientList = result.clients || [];
if (clientList.length > 0) {
renderClientList(clientList, bssid);
elements.detailClientList.style.display = 'block';
} else {
elements.detailClientList.style.display = 'none';
}
} catch (error) {
console.debug('[WiFiMode] Error fetching clients:', error);
elements.detailClientList.style.display = 'none';
}
}
function renderClientList(clientList, bssid) {
const container = elements.detailClientList?.querySelector('.wifi-client-list');
const countBadge = document.getElementById('wifiClientCountBadge');
if (!container) return;
// Update count badge
if (countBadge) {
countBadge.textContent = clientList.length;
}
// Render client cards
container.innerHTML = clientList.map(client => {
const rssi = client.rssi_current;
const signalClass = rssi >= -50 ? 'signal-strong' :
rssi >= -70 ? 'signal-medium' :
rssi >= -85 ? 'signal-weak' : 'signal-very-weak';
// Format last seen time
const lastSeen = client.last_seen ? formatTime(client.last_seen) : '--';
// Build probed SSIDs badges
let probesHtml = '';
if (client.probed_ssids && client.probed_ssids.length > 0) {
const probes = client.probed_ssids.slice(0, 5); // Show max 5
probesHtml = `
<div class="wifi-client-probes">
${probes.map(ssid => `<span class="wifi-client-probe-badge">${escapeHtml(ssid)}</span>`).join('')}
${client.probed_ssids.length > 5 ? `<span class="wifi-client-probe-badge">+${client.probed_ssids.length - 5}</span>` : ''}
</div>
`;
}
return `
<div class="wifi-client-card" data-mac="${escapeHtml(client.mac)}">
<div class="wifi-client-identity">
<span class="wifi-client-mac">${escapeHtml(client.mac)}</span>
<span class="wifi-client-vendor">${escapeHtml(client.vendor || 'Unknown vendor')}</span>
${probesHtml}
</div>
<div class="wifi-client-signal">
<span class="wifi-client-rssi ${signalClass}">${rssi !== null && rssi !== undefined ? rssi + ' dBm' : '--'}</span>
<span class="wifi-client-lastseen">${lastSeen}</span>
</div>
</div>
`;
}).join('');
}
function updateClientInList(client) {
// Check if this client belongs to the currently selected network
if (!selectedNetwork || client.associated_bssid !== selectedNetwork) {
return;
}
const container = elements.detailClientList?.querySelector('.wifi-client-list');
if (!container) return;
const existingCard = container.querySelector(`[data-mac="${client.mac}"]`);
if (existingCard) {
// Update existing card's RSSI and last seen
const rssiEl = existingCard.querySelector('.wifi-client-rssi');
const lastSeenEl = existingCard.querySelector('.wifi-client-lastseen');
if (rssiEl && client.rssi_current !== null && client.rssi_current !== undefined) {
const rssi = client.rssi_current;
const signalClass = rssi >= -50 ? 'signal-strong' :
rssi >= -70 ? 'signal-medium' :
rssi >= -85 ? 'signal-weak' : 'signal-very-weak';
rssiEl.textContent = rssi + ' dBm';
rssiEl.className = 'wifi-client-rssi ' + signalClass;
}
if (lastSeenEl && client.last_seen) {
lastSeenEl.textContent = formatTime(client.last_seen);
}
} else {
// New client for this network - re-fetch the full list
fetchClientsForNetwork(selectedNetwork);
}
}
// ==========================================================================
// Statistics
// ==========================================================================
@@ -1457,15 +1239,9 @@ 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) {
function updateChannelChart(band = '2.4') {
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();
@@ -1553,19 +1329,9 @@ const WiFiMode = (function() {
console.log('[WiFiMode] Agent changed from', lastAgentId, 'to', currentAgentId);
// Stop UI polling only - don't stop the actual scan on the agent
// The agent should continue running independently
// Stop any running scan
if (isScanning) {
stopAgentDeepScanPolling();
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
if (eventSource) {
eventSource.close();
eventSource = null;
}
setScanning(false);
stopScan();
}
// Clear existing data when switching agents (unless "Show All" is enabled)
@@ -1577,9 +1343,6 @@ const WiFiMode = (function() {
// Refresh capabilities for new agent
checkCapabilities();
// Check if new agent already has a scan running
checkScanStatus();
lastAgentId = currentAgentId;
}
@@ -1700,3 +1463,390 @@ document.addEventListener('DOMContentLoaded', () => {
WiFiMode.init();
}
});
// =============================================================================
// WiFi Helper Functions
// Implements UI helper functions for Monitor Mode, Deauth, Watch List, etc.
// =============================================================================
const WiFiHelpers = (function() {
'use strict';
// Note: Monitor mode and attack endpoints are in /wifi/ (v1), not /wifi/v2/
const CONFIG = {
apiBase: '/wifi/v2',
apiV1Base: '/wifi',
};
// Watch list state
let watchList = [];
// ==========================================================================
// Monitor Mode
// ==========================================================================
async function enableMonitorMode() {
const iface = document.getElementById('wifiInterfaceSelect')?.value;
const killProcesses = document.getElementById('killProcesses')?.checked || false;
const startBtn = document.getElementById('monitorStartBtn');
const stopBtn = document.getElementById('monitorStopBtn');
const statusEl = document.getElementById('monitorStatus');
// Provide immediate feedback
console.log('[WiFiHelpers] Enable monitor mode clicked');
showNotification('Monitor Mode', 'Enabling monitor mode...', 'info');
if (!iface) {
showNotification('Monitor Mode', 'No interface selected. Please wait for interface detection.', 'error');
return;
}
// Update UI to show processing
if (startBtn) {
startBtn.disabled = true;
startBtn.textContent = 'Enabling...';
}
try {
// Use v1 API which has monitor mode support
const response = await fetch(`${CONFIG.apiV1Base}/monitor`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
interface: iface,
action: 'enable',
kill_processes: killProcesses
}),
});
const result = await response.json();
if (!response.ok || result.error) {
throw new Error(result.error || result.message || 'Failed to enable monitor mode');
}
// Success - update UI
if (startBtn) {
startBtn.style.display = 'none';
startBtn.disabled = false;
startBtn.textContent = 'Enable Monitor';
}
if (stopBtn) stopBtn.style.display = 'inline-block';
if (statusEl) {
statusEl.innerHTML = `Monitor mode: <span style="color: var(--accent-green);">Active</span> (${result.monitor_interface || iface})`;
}
showNotification('Monitor Mode', `Enabled on ${result.monitor_interface || iface}`, 'success');
} catch (error) {
console.error('[WiFiHelpers] Enable monitor mode error:', error);
showNotification('Monitor Mode', error.message, 'error');
// Reset button
if (startBtn) {
startBtn.disabled = false;
startBtn.textContent = 'Enable Monitor';
}
}
}
async function disableMonitorMode() {
const iface = document.getElementById('wifiInterfaceSelect')?.value;
const startBtn = document.getElementById('monitorStartBtn');
const stopBtn = document.getElementById('monitorStopBtn');
const statusEl = document.getElementById('monitorStatus');
console.log('[WiFiHelpers] Disable monitor mode clicked');
showNotification('Monitor Mode', 'Disabling monitor mode...', 'info');
if (stopBtn) {
stopBtn.disabled = true;
stopBtn.textContent = 'Disabling...';
}
try {
// Use v1 API which has monitor mode support
const response = await fetch(`${CONFIG.apiV1Base}/monitor`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
interface: iface,
action: 'disable'
}),
});
const result = await response.json();
if (!response.ok || result.error) {
throw new Error(result.error || result.message || 'Failed to disable monitor mode');
}
// Success - update UI
if (stopBtn) {
stopBtn.style.display = 'none';
stopBtn.disabled = false;
stopBtn.textContent = 'Disable Monitor';
}
if (startBtn) startBtn.style.display = 'inline-block';
if (statusEl) {
statusEl.innerHTML = `Monitor mode: <span style="color: var(--accent-red);">Inactive</span>`;
}
showNotification('Monitor Mode', 'Disabled successfully', 'info');
} catch (error) {
console.error('[WiFiHelpers] Disable monitor mode error:', error);
showNotification('Monitor Mode', error.message, 'error');
// Reset button
if (stopBtn) {
stopBtn.disabled = false;
stopBtn.textContent = 'Disable Monitor';
}
}
}
// ==========================================================================
// Deauth Attack
// ==========================================================================
async function sendDeauth() {
const bssid = document.getElementById('targetBssid')?.value?.trim();
const client = document.getElementById('targetClient')?.value?.trim() || 'FF:FF:FF:FF:FF:FF';
const count = parseInt(document.getElementById('deauthCount')?.value) || 5;
const iface = document.getElementById('wifiInterfaceSelect')?.value;
console.log('[WiFiHelpers] Send deauth clicked');
if (!bssid || !/^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/.test(bssid)) {
showNotification('Deauth Attack', 'Please enter a valid target BSSID (e.g., AA:BB:CC:DD:EE:FF)', 'error');
return;
}
// Confirm action
if (!confirm(`Send ${count} deauth frames to ${bssid}?\n\nWARNING: Only use on networks you are authorized to test.`)) {
return;
}
showNotification('Deauth Attack', `Sending ${count} deauth frames...`, 'info');
try {
// Use v1 API which has deauth support
const response = await fetch(`${CONFIG.apiV1Base}/deauth`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
bssid: bssid,
client: client,
count: count,
interface: iface
}),
});
const result = await response.json();
if (!response.ok || result.error) {
throw new Error(result.error || result.message || 'Deauth attack failed');
}
showNotification('Deauth Attack', `Sent ${count} deauth frames to ${bssid}`, 'success');
} catch (error) {
console.error('[WiFiHelpers] Deauth error:', error);
showNotification('Deauth Attack', error.message, 'error');
}
}
// ==========================================================================
// Watch List (Proximity Alerts)
// ==========================================================================
function addWatchMac() {
const input = document.getElementById('watchMacInput');
const mac = input?.value?.trim().toUpperCase();
console.log('[WiFiHelpers] Add watch MAC clicked, value:', mac);
if (!mac) {
showNotification('Watch List', 'Please enter a MAC address', 'error');
return;
}
if (!/^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/.test(mac)) {
showNotification('Watch List', 'Invalid format. Use XX:XX:XX:XX:XX:XX', 'error');
return;
}
if (watchList.includes(mac)) {
showNotification('Watch List', 'MAC address already in watch list', 'info');
return;
}
watchList.push(mac);
input.value = '';
updateWatchListDisplay();
showNotification('Watch List', `Added ${mac} to watch list`, 'success');
// Save to localStorage
try {
localStorage.setItem('wifiWatchList', JSON.stringify(watchList));
} catch (e) {
console.warn('[WiFiHelpers] Could not save watch list to localStorage');
}
}
function removeWatchMac(mac) {
watchList = watchList.filter(m => m !== mac);
updateWatchListDisplay();
// Save to localStorage
try {
localStorage.setItem('wifiWatchList', JSON.stringify(watchList));
} catch (e) {
console.warn('[WiFiHelpers] Could not save watch list to localStorage');
}
}
function updateWatchListDisplay() {
const container = document.getElementById('watchList');
if (!container) return;
if (watchList.length === 0) {
container.innerHTML = '<span style="color: var(--text-dim);">No MAC addresses in watch list</span>';
return;
}
container.innerHTML = watchList.map(mac => `
<div style="display: flex; justify-content: space-between; align-items: center; padding: 4px 0; border-bottom: 1px solid var(--border-color);">
<code style="font-size: 10px;">${mac}</code>
<button onclick="WiFiHelpers.removeWatchMac('${mac}')" style="background: none; border: none; color: var(--accent-red); cursor: pointer; font-size: 12px; padding: 2px 6px;">&times;</button>
</div>
`).join('');
}
function loadWatchList() {
try {
const saved = localStorage.getItem('wifiWatchList');
if (saved) {
watchList = JSON.parse(saved);
updateWatchListDisplay();
}
} catch (e) {
console.warn('[WiFiHelpers] Could not load watch list from localStorage');
}
}
// ==========================================================================
// Handshake Capture
// ==========================================================================
async function checkCaptureStatus() {
const statusEl = document.getElementById('captureStatus');
const bssid = document.getElementById('captureTargetBssid')?.textContent;
console.log('[WiFiHelpers] Check capture status clicked');
showNotification('Handshake Capture', 'Checking capture status...', 'info');
try {
// Use v1 API which has handshake capture support
const response = await fetch(`${CONFIG.apiV1Base}/handshake/status`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bssid: bssid }),
});
const result = await response.json();
if (statusEl) {
if (result.capturing || result.running) {
statusEl.textContent = result.handshake_found ? 'Handshake captured!' : 'Capturing...';
statusEl.style.color = result.handshake_found ? 'var(--accent-green)' : 'var(--accent-orange)';
} else {
statusEl.textContent = 'Not capturing';
statusEl.style.color = 'var(--text-secondary)';
}
}
if (result.handshake_found) {
showNotification('Handshake Capture', `Handshake captured! File: ${result.capture_file || 'check captures folder'}`, 'success');
} else if (result.capturing || result.running) {
showNotification('Handshake Capture', 'Still capturing, no handshake yet...', 'info');
} else {
showNotification('Handshake Capture', 'No active capture', 'info');
}
} catch (error) {
console.error('[WiFiHelpers] Check capture status error:', error);
showNotification('Handshake Capture', error.message, 'error');
if (statusEl) {
statusEl.textContent = 'Error checking status';
statusEl.style.color = 'var(--accent-red)';
}
}
}
async function stopHandshakeCapture() {
const panel = document.getElementById('captureStatusPanel');
const statusEl = document.getElementById('captureStatus');
console.log('[WiFiHelpers] Stop handshake capture clicked');
showNotification('Handshake Capture', 'Stopping capture...', 'info');
try {
// Use v1 API - handshake stop endpoint
const response = await fetch(`${CONFIG.apiV1Base}/scan/stop`, {
method: 'POST',
});
const result = await response.json();
if (!response.ok || result.error) {
throw new Error(result.error || result.message || 'Failed to stop capture');
}
if (statusEl) {
statusEl.textContent = 'Stopped';
statusEl.style.color = 'var(--text-secondary)';
}
// Hide panel after delay
setTimeout(() => {
if (panel) panel.style.display = 'none';
}, 2000);
showNotification('Handshake Capture', 'Capture stopped', 'success');
} catch (error) {
console.error('[WiFiHelpers] Stop capture error:', error);
showNotification('Handshake Capture', error.message, 'error');
}
}
// ==========================================================================
// Utility
// ==========================================================================
function showNotification(title, message, type) {
if (typeof window.showNotification === 'function') {
window.showNotification(title, message, type);
} else {
console.log(`[${type.toUpperCase()}] ${title}: ${message}`);
}
}
// ==========================================================================
// Initialize
// ==========================================================================
// Load watch list on page load
document.addEventListener('DOMContentLoaded', loadWatchList);
// ==========================================================================
// Public API
// ==========================================================================
return {
enableMonitorMode,
disableMonitorMode,
sendDeauth,
addWatchMac,
removeWatchMac,
checkCaptureStatus,
stopHandshakeCapture,
getWatchList: () => [...watchList],
};
})();
@@ -1,124 +0,0 @@
/*!
* 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();
}
});
}
})();
+4870 -4917
View File
File diff suppressed because it is too large Load Diff
+7 -24
View File
@@ -4,15 +4,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ADS-B History // INTERCEPT</title>
{% if offline_settings.fonts_source == 'local' %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
{% else %}
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
{% endif %}
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/global-nav.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/help-modal.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/adsb_history.css') }}">
</head>
<body>
@@ -29,9 +22,6 @@
</div>
</header>
{% set active_mode = 'adsb' %}
{% include 'partials/nav.html' with context %}
<main class="history-shell">
<section class="summary-strip">
<div class="summary-card">
@@ -472,7 +462,7 @@
if (!points.length) {
ctx.fillStyle = 'rgba(156, 163, 175, 0.6)';
ctx.font = '12px "Space Mono", monospace';
ctx.font = '12px "JetBrains Mono", monospace';
ctx.fillText(`No ${label.toLowerCase()} data`, 12, height / 2);
return;
}
@@ -480,7 +470,7 @@
const series = points.map(p => p[field]).filter(v => v !== null && v !== undefined);
if (!series.length) {
ctx.fillStyle = 'rgba(156, 163, 175, 0.6)';
ctx.font = '12px "Space Mono", monospace';
ctx.font = '12px "JetBrains Mono", monospace';
ctx.fillText(`No ${label.toLowerCase()} data`, 12, height / 2);
return;
}
@@ -521,7 +511,7 @@
}
ctx.fillStyle = 'rgba(226, 232, 240, 0.8)';
ctx.font = '11px "Space Mono", monospace';
ctx.font = '11px "JetBrains Mono", monospace';
ctx.fillText(`${maxVal} ${unit}`, 12, padding);
ctx.fillText(`${minVal} ${unit}`, 12, height - padding);
}
@@ -568,9 +558,11 @@
}
devices.forEach((dev, idx) => {
const index = dev.index !== undefined ? dev.index : idx;
const type = (dev.sdr_type || dev.driver || 'RTL-SDR').toUpperCase();
const serial = dev.serial ? ` (${dev.serial.slice(-4)})` : '';
const opt = document.createElement('option');
opt.value = index;
opt.textContent = `SDR ${index}: ${dev.name}`;
opt.textContent = `${type} #${index}${serial}`;
sessionDeviceSelect.appendChild(opt);
});
sessionDeviceSelect.disabled = false;
@@ -771,14 +763,5 @@
}
});
</script>
<!-- Settings Modal -->
{% include 'partials/settings-modal.html' %}
<!-- Help Modal -->
{% include 'partials/help-modal.html' %}
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}"></script>
<script src="{{ url_for('static', filename='js/core/global-nav.js') }}"></script>
</body>
</html>
+536 -568
View File
File diff suppressed because it is too large Load Diff
+31 -50
View File
@@ -1,14 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VESSEL RADAR // INTERCEPT - See the Invisible</title>
<title>VESSEL RADAR // iNTERCEPT</title>
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
<!-- Design tokens and shared styles -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/variables.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/layout.css') }}">
<!-- Fonts - Conditional CDN/Local loading -->
{% if offline_settings.fonts_source == 'local' %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
{% else %}
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Orbitron:wght@400;500;600;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
{% endif %}
<!-- Leaflet.js - Conditional CDN/Local loading -->
{% if offline_settings.assets_source == 'local' %}
@@ -18,17 +22,8 @@
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
{% endif %}
<!-- Core CSS variables -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/variables.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/ais_dashboard.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/global-nav.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/help-modal.css') }}">
<script>
window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }};
</script>
<script src="{{ url_for('static', filename='js/core/observer-location.js') }}"></script>
</head>
<body>
<!-- Radar background effects -->
@@ -37,8 +32,10 @@
<header class="header">
<div class="logo">
VESSEL RADAR
<span>// INTERCEPT - AIS Tracking</span>
<a href="/" style="color: inherit; text-decoration: none;">
VESSEL RADAR
<span>// iNTERCEPT</span>
</a>
</div>
<div class="status-bar">
<!-- Agent Selector -->
@@ -54,8 +51,9 @@
</div>
</header>
<!-- Unified Navigation -->
{% set active_mode = 'ais' %}
{% include 'partials/nav.html' with context %}
{% include 'partials/nav.html' %}
<div class="stats-strip">
<div class="stats-strip-inner">
@@ -229,12 +227,7 @@
const MAX_TRAIL_POINTS = 50;
// Observer location
let observerLocation = (function() {
if (window.ObserverLocation && ObserverLocation.getForModule) {
return ObserverLocation.getForModule('ais_observerLocation');
}
return { lat: 51.5074, lon: -0.1278 };
})();
let observerLocation = { lat: 51.5074, lon: -0.1278 };
let rangeRingsLayer = null;
let observerMarker = null;
@@ -390,10 +383,18 @@
};
// Initialize map
async function initMap() {
if (observerLocation) {
document.getElementById('obsLat').value = observerLocation.lat;
document.getElementById('obsLon').value = observerLocation.lon;
function initMap() {
// Load saved observer location
const saved = localStorage.getItem('ais_observerLocation');
if (saved) {
try {
const parsed = JSON.parse(saved);
if (parsed.lat && parsed.lon) {
observerLocation = parsed;
document.getElementById('obsLat').value = parsed.lat;
document.getElementById('obsLon').value = parsed.lon;
}
} catch (e) {}
}
vesselMap = L.map('vesselMap', {
@@ -404,17 +405,14 @@
// Use settings manager for tile layer (allows runtime changes)
window.vesselMap = vesselMap;
if (typeof Settings !== 'undefined') {
// Wait for settings to load from server before applying tiles
await Settings.init();
if (typeof Settings !== 'undefined' && Settings.createTileLayer) {
Settings.createTileLayer().addTo(vesselMap);
Settings.registerMap(vesselMap);
} else {
L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
maxZoom: 19,
subdomains: 'abcd',
className: 'tile-layer-cyan'
subdomains: 'abcd'
}).addTo(vesselMap);
}
@@ -480,11 +478,7 @@
const lon = parseFloat(document.getElementById('obsLon').value);
if (!isNaN(lat) && !isNaN(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) {
observerLocation = { lat, lon };
if (window.ObserverLocation) {
ObserverLocation.setForModule('ais_observerLocation', observerLocation);
} else {
localStorage.setItem('ais_observerLocation', JSON.stringify(observerLocation));
}
localStorage.setItem('ais_observerLocation', JSON.stringify(observerLocation));
if (observerMarker) {
observerMarker.setLatLng([lat, lon]);
}
@@ -1072,11 +1066,7 @@
drawRangeRings();
// Save to localStorage
if (window.ObserverLocation) {
ObserverLocation.setForModule('ais_observerLocation', observerLocation);
} else {
localStorage.setItem('ais_observerLocation', JSON.stringify(observerLocation));
}
localStorage.setItem('ais_observerLocation', JSON.stringify(observerLocation));
}
function showGpsIndicator(show) {
@@ -1504,7 +1494,7 @@
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
font-family: var(--font-mono);
font-family: 'JetBrains Mono', monospace;
cursor: pointer;
}
.agent-select-sm:focus {
@@ -1556,17 +1546,8 @@
}
</style>
<!-- Settings Modal -->
{% include 'partials/settings-modal.html' %}
<!-- Help Modal -->
{% include 'partials/help-modal.html' %}
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}"></script>
<!-- Agent Manager -->
<script src="{{ url_for('static', filename='js/core/agents.js') }}"></script>
<script src="{{ url_for('static', filename='js/core/global-nav.js') }}"></script>
<script>
// AIS-specific agent integration
let aisCurrentAgent = 'local';
+1344 -3230
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -10,7 +10,7 @@
{% if offline_settings and offline_settings.fonts_source == 'local' %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
{% else %}
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
{% endif %}
{# Core CSS (Design System) #}
@@ -137,7 +137,7 @@
}
// Apply saved theme
const savedTheme = localStorage.getItem('intercept-theme');
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
document.documentElement.setAttribute('data-theme', savedTheme);
}
File diff suppressed because it is too large Load Diff
-318
View File
@@ -1,318 +0,0 @@
{#
Help Modal Partial
Provides consistent help modal across all pages
#}
<!-- Help Modal -->
<div id="helpModal" class="help-modal" onclick="if(event.target === this) hideHelp()">
<div class="help-content">
<button class="help-close" onclick="hideHelp()">&times;</button>
<h2>iNTERCEPT Help</h2>
<div class="help-tabs">
<button class="help-tab active" data-tab="icons" onclick="switchHelpTab('icons')">Icons</button>
<button class="help-tab" data-tab="modes" onclick="switchHelpTab('modes')">Modes</button>
<button class="help-tab" data-tab="wifi" onclick="switchHelpTab('wifi')">WiFi</button>
<button class="help-tab" data-tab="tips" onclick="switchHelpTab('tips')">Tips</button>
</div>
<!-- Icons Section -->
<div id="help-icons" class="help-section active">
<h3>Stats Bar Icons</h3>
<div class="icon-grid">
<div class="icon-item"><span class="icon">&#128223;</span><span class="desc">POCSAG messages decoded</span></div>
<div class="icon-item"><span class="icon">&#128224;</span><span class="desc">FLEX messages decoded</span></div>
<div class="icon-item"><span class="icon">&#128232;</span><span class="desc">Total messages received</span></div>
<div class="icon-item"><span class="icon">&#127777;&#65039;</span><span class="desc">Unique sensors detected</span></div>
<div class="icon-item"><span class="icon">&#128202;</span><span class="desc">Device types found</span></div>
<div class="icon-item"><span class="icon">&#128752;&#65039;</span><span class="desc">Satellites monitored</span></div>
<div class="icon-item"><span class="icon">&#128225;</span><span class="desc">WiFi Access Points</span></div>
<div class="icon-item"><span class="icon">&#128100;</span><span class="desc">Connected WiFi clients</span></div>
<div class="icon-item"><span class="icon">&#129309;</span><span class="desc">Captured handshakes</span></div>
<div class="icon-item"><span class="icon">&#128641;</span><span class="desc">Detected drones (click for details)</span></div>
<div class="icon-item"><span class="icon">&#9888;&#65039;</span><span class="desc">Rogue APs (click for details)</span></div>
<div class="icon-item"><span class="icon">&#128309;</span><span class="desc">Bluetooth devices</span></div>
<div class="icon-item"><span class="icon">&#128205;</span><span class="desc">BLE beacons / APRS stations</span></div>
</div>
<h3>Mode Tab Icons</h3>
<div class="icon-grid">
<div class="icon-item"><span class="icon">&#128223;</span><span class="desc">Pager - POCSAG/FLEX decoder</span></div>
<div class="icon-item"><span class="icon">&#128225;</span><span class="desc">433MHz - Sensor decoder</span></div>
<div class="icon-item"><span class="icon">&#9889;</span><span class="desc">Meters - Utility meter decoder</span></div>
<div class="icon-item"><span class="icon">&#9992;&#65039;</span><span class="desc">Aircraft - ADS-B tracking &amp; history</span></div>
<div class="icon-item"><span class="icon">&#128674;</span><span class="desc">Vessels - AIS &amp; VHF DSC distress</span></div>
<div class="icon-item"><span class="icon">&#128251;</span><span class="desc">Spy Stations - Number stations database</span></div>
<div class="icon-item"><span class="icon">&#128205;</span><span class="desc">APRS - Amateur radio tracking</span></div>
<div class="icon-item"><span class="icon">&#128752;&#65039;</span><span class="desc">Satellite - Pass prediction</span></div>
<div class="icon-item"><span class="icon">&#128246;</span><span class="desc">WiFi - Network scanner</span></div>
<div class="icon-item"><span class="icon">&#128309;</span><span class="desc">Bluetooth - BT/BLE scanner</span></div>
<div class="icon-item"><span class="icon">&#128251;</span><span class="desc">Listening Post - SDR scanner</span></div>
<div class="icon-item"><span class="icon">&#128269;</span><span class="desc">TSCM - Counter-surveillance</span></div>
</div>
</div>
<!-- Modes Section -->
<div id="help-modes" class="help-section">
<h3>Pager Mode</h3>
<ul class="tip-list">
<li>Decodes POCSAG and FLEX pager signals using RTL-SDR</li>
<li>Set frequency to local pager frequencies (common: 152-158 MHz)</li>
<li>Messages are displayed in real-time as they're decoded</li>
<li>Use presets for common pager frequencies</li>
</ul>
<h3>433MHz Sensor Mode</h3>
<ul class="tip-list">
<li>Decodes wireless sensors on 433.92 MHz ISM band</li>
<li>Detects temperature, humidity, weather stations, tire pressure monitors</li>
<li>Supports many common protocols (Acurite, LaCrosse, Oregon Scientific, etc.)</li>
<li>Device intelligence builds profiles of recurring devices</li>
</ul>
<h3>Utility Meter Mode</h3>
<ul class="tip-list">
<li>Decodes utility meter transmissions (water, gas, electric) using rtlamr</li>
<li>Supports ERT protocol on 912 MHz (North America) or 868 MHz (Europe)</li>
<li>Displays meter IDs and consumption data in real-time</li>
<li>Supports SCM, SCM+, IDM, NetIDM, and R900 message types</li>
</ul>
<h3>Aircraft (Dashboard)</h3>
<ul class="tip-list">
<li>Opens the dedicated ADS-B Dashboard for aircraft tracking</li>
<li>Features radar scope, map view, airband audio, and ACARS decoding</li>
<li>Optional history mode persists data to Postgres for long-term analysis</li>
<li>Access history dashboard at <code>/adsb/history</code></li>
</ul>
<h3>Vessels (Dashboard)</h3>
<ul class="tip-list">
<li>Opens the AIS Dashboard for maritime vessel tracking</li>
<li>Displays vessel name, MMSI, callsign, destination, and navigation data</li>
<li><strong>VHF DSC Channel 70:</strong> Monitors maritime distress frequency (156.525 MHz)</li>
<li>Decodes DSC messages: Distress, Urgency, Safety, and Routine calls</li>
<li>MMSI country identification via Maritime Identification Digits (MID)</li>
<li>Visual alerts for DISTRESS and URGENCY messages with map markers</li>
</ul>
<h3>Spy Stations</h3>
<ul class="tip-list">
<li>Database of number stations and diplomatic HF networks</li>
<li>Browse stations from priyom.org with frequencies and schedules</li>
<li>Filter by type (number/diplomatic), country, and mode</li>
<li>Famous stations: UVB-76 "The Buzzer", Cuban HM01, Israeli E17z</li>
<li>Click "Tune" to listen via Listening Post mode</li>
</ul>
<h3>APRS Mode</h3>
<ul class="tip-list">
<li>Decodes APRS (Automatic Packet Reporting System) on VHF</li>
<li>Tracks amateur radio operators transmitting position data</li>
<li>Regional frequencies: 144.390 MHz (N. America), 144.800 MHz (Europe)</li>
<li>Uses Direwolf or multimon-ng for packet decoding</li>
<li>Interactive map shows station positions in real-time</li>
</ul>
<h3>Satellite Mode</h3>
<ul class="tip-list">
<li>Track satellites using TLE (Two-Line Element) data</li>
<li>Add satellites manually or fetch from Celestrak by category</li>
<li>Categories: Amateur, Weather, ISS, Starlink, GPS, and more</li>
<li>View next pass predictions with elevation and duration</li>
</ul>
<h3>WiFi Mode</h3>
<ul class="tip-list">
<li>Requires a WiFi adapter capable of monitor mode</li>
<li>Click "Enable Monitor" to put adapter in monitor mode</li>
<li>Scans all channels or lock to a specific channel</li>
<li>Detects drones by SSID patterns and manufacturer OUI</li>
<li>Rogue AP detection flags same SSID on multiple BSSIDs</li>
<li>Click network rows to target for deauth or handshake capture</li>
</ul>
<h3>Bluetooth Mode</h3>
<ul class="tip-list">
<li>Scans for classic Bluetooth and BLE devices</li>
<li>Shows device names, addresses, and signal strength</li>
<li>Manufacturer lookup from MAC address OUI</li>
<li>Radar visualization shows device proximity</li>
</ul>
<h3>Listening Post Mode</h3>
<ul class="tip-list">
<li>Wideband SDR scanner with spectrum visualization</li>
<li>Tune to any frequency supported by your SDR hardware</li>
<li>AM/FM/USB/LSB demodulation modes</li>
<li>Bookmark frequencies for quick recall</li>
<li>Quick tune presets for emergency and marine channels</li>
</ul>
<h3>TSCM Mode</h3>
<ul class="tip-list">
<li>Technical Surveillance Countermeasures sweep</li>
<li>Scans for unknown RF transmitters, WiFi devices, Bluetooth</li>
<li>Baseline comparison to detect new/anomalous devices</li>
<li>Threat classification: Critical, High, Medium, Low</li>
<li>Useful for security audits and bug sweeps</li>
<li><em style="color: var(--text-muted);">Note: This feature is in early development</em></li>
</ul>
<h3>Meshtastic Mode</h3>
<ul class="tip-list">
<li>Integrates with Meshtastic LoRa mesh network devices</li>
<li>Connect Heltec, T-Beam, RAK, or other compatible devices via USB</li>
<li>Real-time message streaming with RSSI and SNR metrics</li>
<li>Configure channels with encryption keys</li>
<li>View connected nodes and message history</li>
<li>Requires: Meshtastic device + <code>pip install meshtastic</code></li>
</ul>
<h3>Network Monitor</h3>
<ul class="tip-list">
<li>Aggregates data from multiple remote INTERCEPT agents</li>
<li>View all WiFi, Bluetooth, ADS-B, AIS data in one unified view</li>
<li>Real-time streaming via Server-Sent Events (SSE)</li>
<li>Location estimation using multi-agent trilateration</li>
<li>Manage agents at <code>/controller/manage</code></li>
</ul>
</div>
<!-- WiFi Section -->
<div id="help-wifi" class="help-section">
<h3>Monitor Mode</h3>
<ul class="tip-list">
<li><strong>Enable Monitor:</strong> Puts WiFi adapter in monitor mode for passive scanning</li>
<li><strong>Kill Processes:</strong> Optional - stops NetworkManager/wpa_supplicant (may drop other connections)</li>
<li>Some adapters rename when entering monitor mode (e.g., wlan0 &rarr; wlan0mon)</li>
</ul>
<h3>Handshake Capture</h3>
<ul class="tip-list">
<li>Click "Capture" on a network to start targeted handshake capture</li>
<li>Status panel shows capture progress and file location</li>
<li>Use deauth to force clients to reconnect (only on authorized networks!)</li>
<li>Handshake files saved to /tmp/intercept_handshake_*.cap</li>
</ul>
<h3>Drone Detection</h3>
<ul class="tip-list">
<li>Drones detected by SSID patterns (DJI, Parrot, Autel, etc.)</li>
<li>Also detected by manufacturer OUI in MAC address</li>
<li>Distance estimated from signal strength (approximate)</li>
<li>Click drone count in stats bar to see all detected drones</li>
</ul>
<h3>Rogue AP Detection</h3>
<ul class="tip-list">
<li>Flags networks where same SSID appears on multiple BSSIDs</li>
<li>Could indicate evil twin attack or legitimate multi-AP setup</li>
<li>Click rogue count to see which SSIDs are flagged</li>
</ul>
<h3>Proximity Alerts</h3>
<ul class="tip-list">
<li>Add MAC addresses to watch list for alerts when detected</li>
<li>Watch list persists in browser localStorage</li>
<li>Useful for tracking specific devices</li>
</ul>
<h3>Client Probe Analysis</h3>
<ul class="tip-list">
<li>Shows what networks client devices are looking for</li>
<li>Orange highlights indicate sensitive/private network names</li>
<li>Reveals user location history (home, work, hotels, airports)</li>
<li>Useful for security awareness and pen test reports</li>
</ul>
</div>
<!-- Tips Section -->
<div id="help-tips" class="help-section">
<h3>General Tips</h3>
<ul class="tip-list">
<li><strong>Collapsible sections:</strong> Click any section header (&nabla;) to collapse/expand</li>
<li><strong>Sound alerts:</strong> Toggle sound on/off in settings for each mode</li>
<li><strong>Export data:</strong> Use export buttons to save captured data as JSON</li>
<li><strong>Device Intelligence:</strong> Tracks device patterns over time</li>
<li><strong>Theme toggle:</strong> Click the theme button in header to switch dark/light mode</li>
<li><strong>Settings:</strong> Click the gear icon in the header to access settings</li>
<li><strong>Offline mode:</strong> Enable in Settings to use local assets without internet</li>
</ul>
<h3>Keyboard Shortcuts</h3>
<ul class="tip-list">
<li><strong>F1</strong> - Open this help page</li>
<li><strong>?</strong> - Open help (when not typing in a field)</li>
<li><strong>Escape</strong> - Close help and modal dialogs</li>
</ul>
<h3>Requirements</h3>
<ul class="tip-list">
<li><strong>Pager:</strong> RTL-SDR, rtl_fm, multimon-ng</li>
<li><strong>433MHz Sensors:</strong> RTL-SDR, rtl_433</li>
<li><strong>Utility Meters:</strong> RTL-SDR, rtl_tcp, rtlamr</li>
<li><strong>Aircraft (ADS-B):</strong> RTL-SDR, dump1090 or rtl_adsb</li>
<li><strong>Aircraft (ACARS):</strong> Second RTL-SDR, acarsdec</li>
<li><strong>Vessels (AIS):</strong> RTL-SDR, AIS-catcher</li>
<li><strong>APRS:</strong> RTL-SDR, direwolf or multimon-ng</li>
<li><strong>Satellite:</strong> Internet for Celestrak (optional), skyfield</li>
<li><strong>WiFi:</strong> Monitor-mode adapter, aircrack-ng suite</li>
<li><strong>Bluetooth:</strong> Bluetooth adapter, bluez (hcitool/bluetoothctl)</li>
<li><strong>Listening Post:</strong> RTL-SDR or SoapySDR-compatible hardware</li>
<li><strong>TSCM:</strong> WiFi adapter, Bluetooth adapter, RTL-SDR (all optional)</li>
<li>Run as root/sudo for full hardware access</li>
</ul>
<h3>Legal Notice</h3>
<ul class="tip-list">
<li>Only use on networks and devices you own or have authorization to test</li>
<li>Passive monitoring may be legal; active attacks require authorization</li>
<li>Check local laws regarding radio frequency monitoring</li>
</ul>
</div>
</div>
</div>
<script>
// Help modal functions - defined here so all pages have them
(function() {
// Only define if not already defined (index.html defines its own)
if (typeof window.showHelp === 'undefined') {
window.showHelp = function() {
document.getElementById('helpModal').classList.add('active');
document.body.style.overflow = 'hidden';
};
}
if (typeof window.hideHelp === 'undefined') {
window.hideHelp = function() {
document.getElementById('helpModal').classList.remove('active');
document.body.style.overflow = '';
};
}
if (typeof window.switchHelpTab === 'undefined') {
window.switchHelpTab = function(tab) {
document.querySelectorAll('.help-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.help-section').forEach(s => s.classList.remove('active'));
document.querySelector('.help-tab[data-tab="' + tab + '"]').classList.add('active');
document.getElementById('help-' + tab).classList.add('active');
};
}
// Keyboard shortcuts for help (only add once)
if (!window._helpKeyboardSetup) {
window._helpKeyboardSetup = true;
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') hideHelp();
// Open help with F1 or ? key (when not typing in an input)
var helpModal = document.getElementById('helpModal');
if (helpModal && (e.key === 'F1' || (e.key === '?' && !e.target.matches('input, textarea, select'))) && !helpModal.classList.contains('active')) {
e.preventDefault();
showHelp();
}
});
}
})();
</script>

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