mirror of
https://github.com/smittix/intercept.git
synced 2026-06-13 00:03:33 -07:00
feat: Add Meshtastic, Ubertooth, and Offline Mode support
New Features: - Meshtastic LoRa mesh network integration - Real-time message streaming via SSE - Channel configuration with encryption - Node information with RSSI/SNR metrics - Ubertooth One BLE scanner backend - Passive capture across all 40 BLE channels - Raw advertising payload access - Offline mode with bundled assets - Local Leaflet, Chart.js, and fonts - Multiple map tile providers - Settings modal for configuration Technical Changes: - New routes: meshtastic.py, offline.py - New utils: ubertooth_scanner.py, meshtastic.py - New CSS/JS for meshtastic and settings - Updated dashboard templates with conditional asset loading - Added context processor for offline settings Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,56 @@
|
||||
|
||||
All notable changes to iNTERCEPT will be documented in this file.
|
||||
|
||||
## [2.11.0] - 2026-01-28
|
||||
|
||||
### Added
|
||||
- **Meshtastic Mesh Network Integration** - LoRa mesh communication support
|
||||
- Connect to Meshtastic devices (Heltec, T-Beam, RAK) via USB/Serial
|
||||
- Real-time message streaming via SSE
|
||||
- Channel configuration with encryption key support
|
||||
- Node information display with signal metrics (RSSI, SNR)
|
||||
- Message history with up to 500 messages
|
||||
- **Ubertooth One BLE Scanner** - Advanced Bluetooth scanning
|
||||
- Passive BLE packet capture across all 40 BLE channels
|
||||
- Raw advertising payload access
|
||||
- Integration with existing Bluetooth scanning modes
|
||||
- Automatic detection of Ubertooth hardware
|
||||
- **Offline Mode** - Run iNTERCEPT without internet connectivity
|
||||
- Bundled Leaflet 1.9.4 (JS, CSS, marker images)
|
||||
- Bundled Chart.js 4.4.1
|
||||
- Bundled Inter and JetBrains Mono fonts (woff2)
|
||||
- Local asset status checking and validation
|
||||
- **Settings Modal** - New configuration interface accessible from navigation
|
||||
- Offline tab: Toggle offline mode, configure asset sources
|
||||
- Display tab: Theme and animation preferences
|
||||
- About tab: Version info and links
|
||||
- **Multiple Map Tile Providers** - Choose from:
|
||||
- OpenStreetMap (default)
|
||||
- CartoDB Dark
|
||||
- CartoDB Positron (light)
|
||||
- ESRI World Imagery
|
||||
- Custom tile server URL
|
||||
|
||||
### Changed
|
||||
- **Dashboard Templates** - Conditional asset loading based on offline settings
|
||||
- **Bluetooth Scanner** - Added Ubertooth backend alongside BlueZ/DBus
|
||||
- **Dependencies** - Added meshtastic SDK to requirements.txt
|
||||
|
||||
### Technical
|
||||
- Added `routes/meshtastic.py` for Meshtastic API endpoints
|
||||
- Added `utils/meshtastic.py` for device management
|
||||
- Added `utils/bluetooth/ubertooth_scanner.py` for Ubertooth support
|
||||
- Added `routes/offline.py` for offline mode API
|
||||
- Added `static/js/core/settings-manager.js` for client-side settings
|
||||
- Added `static/css/settings.css` for settings modal styles
|
||||
- Added `static/css/modes/meshtastic.css` for Meshtastic UI
|
||||
- Added `static/js/modes/meshtastic.js` for Meshtastic frontend
|
||||
- Added `templates/partials/modes/meshtastic.html` for Meshtastic mode
|
||||
- Added `templates/partials/settings-modal.html` for settings UI
|
||||
- Added `static/vendor/` directory structure for bundled assets
|
||||
|
||||
---
|
||||
|
||||
## [2.10.0] - 2026-01-25
|
||||
|
||||
### Added
|
||||
|
||||
@@ -35,9 +35,11 @@ Support the developer of this open-source project
|
||||
- **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
|
||||
- **Bluetooth Scanning** - Device discovery and tracker detection (with Ubertooth support)
|
||||
- **Meshtastic** - LoRa mesh network integration
|
||||
- **Spy Stations** - Number stations and diplomatic HF network database
|
||||
- **Remote Agents** - Distributed SIGINT with remote sensor nodes
|
||||
- **Offline Mode** - Bundled assets for air-gapped/field deployments
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -91,6 +91,25 @@ def add_security_headers(response):
|
||||
return response
|
||||
|
||||
|
||||
# ============================================
|
||||
# CONTEXT PROCESSORS
|
||||
# ============================================
|
||||
|
||||
@app.context_processor
|
||||
def inject_offline_settings():
|
||||
"""Inject offline settings into all templates."""
|
||||
from utils.database import get_setting
|
||||
return {
|
||||
'offline_settings': {
|
||||
'enabled': get_setting('offline.enabled', False),
|
||||
'assets_source': get_setting('offline.assets_source', 'cdn'),
|
||||
'fonts_source': get_setting('offline.fonts_source', 'cdn'),
|
||||
'tile_provider': get_setting('offline.tile_provider', 'openstreetmap'),
|
||||
'tile_server_url': get_setting('offline.tile_server_url', '')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ============================================
|
||||
# GLOBAL PROCESS MANAGEMENT
|
||||
# ============================================
|
||||
|
||||
@@ -7,10 +7,20 @@ import os
|
||||
import sys
|
||||
|
||||
# Application version
|
||||
VERSION = "2.10.0"
|
||||
VERSION = "2.11.0"
|
||||
|
||||
# Changelog - latest release notes (shown on welcome screen)
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "2.11.0",
|
||||
"date": "January 2026",
|
||||
"highlights": [
|
||||
"Meshtastic LoRa mesh network integration",
|
||||
"Ubertooth One BLE scanning support",
|
||||
"Offline mode with bundled assets",
|
||||
"Settings modal with tile provider configuration",
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2.10.0",
|
||||
"date": "January 2026",
|
||||
@@ -126,18 +136,18 @@ AIRODUMP_HEADER_LINES = _get_env_int('AIRODUMP_HEADER_LINES', 2)
|
||||
BT_SCAN_TIMEOUT = _get_env_int('BT_SCAN_TIMEOUT', 10)
|
||||
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_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)
|
||||
ADSB_DB_NAME = _get_env('ADSB_DB_NAME', 'intercept_adsb')
|
||||
ADSB_DB_USER = _get_env('ADSB_DB_USER', 'intercept')
|
||||
ADSB_DB_PASSWORD = _get_env('ADSB_DB_PASSWORD', 'intercept')
|
||||
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)
|
||||
# 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_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)
|
||||
ADSB_DB_NAME = _get_env('ADSB_DB_NAME', 'intercept_adsb')
|
||||
ADSB_DB_USER = _get_env('ADSB_DB_USER', 'intercept')
|
||||
ADSB_DB_PASSWORD = _get_env('ADSB_DB_PASSWORD', 'intercept')
|
||||
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)
|
||||
|
||||
# Satellite settings
|
||||
SATELLITE_UPDATE_INTERVAL = _get_env_int('SATELLITE_UPDATE_INTERVAL', 30)
|
||||
|
||||
@@ -165,6 +165,49 @@ Technical Surveillance Countermeasures (TSCM) screening for detecting wireless s
|
||||
- No cryptographic de-randomization
|
||||
- Passive screening only (no active probing by default)
|
||||
|
||||
## Meshtastic Mesh Networks
|
||||
|
||||
Integration with Meshtastic LoRa mesh networking devices for decentralized communication.
|
||||
|
||||
### Device Support
|
||||
- **Heltec** - LoRa32 series
|
||||
- **T-Beam** - TTGO T-Beam with GPS
|
||||
- **RAK** - WisBlock series
|
||||
- Any Meshtastic-compatible device via USB/Serial
|
||||
|
||||
### Features
|
||||
- **Real-time messaging** - Stream messages as they arrive
|
||||
- **Channel configuration** - Set encryption keys and channel names
|
||||
- **Node information** - View connected nodes with signal metrics
|
||||
- **Message history** - Up to 500 messages retained
|
||||
- **Signal quality** - RSSI and SNR for each message
|
||||
- **Hop tracking** - See message hop count
|
||||
|
||||
### Requirements
|
||||
- Physical Meshtastic device connected via USB
|
||||
- Meshtastic Python SDK (`pip install meshtastic`)
|
||||
|
||||
## Ubertooth One BLE Scanning
|
||||
|
||||
Advanced Bluetooth Low Energy scanning using Ubertooth One hardware.
|
||||
|
||||
### Capabilities
|
||||
- **40-channel scanning** - Capture BLE advertisements across all channels
|
||||
- **Raw payload access** - Full advertising data for analysis
|
||||
- **Passive sniffing** - No active scanning required
|
||||
- **MAC address extraction** - Public and random address types
|
||||
- **RSSI measurement** - Signal strength for proximity estimation
|
||||
|
||||
### Integration
|
||||
- Works alongside standard BlueZ/DBus Bluetooth scanning
|
||||
- Automatically detected when ubertooth-btle is available
|
||||
- Falls back to standard adapter if Ubertooth not present
|
||||
|
||||
### Requirements
|
||||
- Ubertooth One hardware
|
||||
- ubertooth-btle command-line tool installed
|
||||
- libubertooth library
|
||||
|
||||
## Remote Agents (Distributed SIGINT)
|
||||
|
||||
Deploy lightweight sensor nodes across multiple locations and aggregate data to a central controller.
|
||||
@@ -215,6 +258,42 @@ Deploy lightweight sensor nodes across multiple locations and aggregate data to
|
||||
| ? | Open help (when not typing) |
|
||||
| Escape | Close help/modals |
|
||||
|
||||
## Offline Mode
|
||||
|
||||
Run iNTERCEPT without internet connectivity by using bundled local assets.
|
||||
|
||||
### Bundled Assets
|
||||
- **Leaflet 1.9.4** - Map library with marker images
|
||||
- **Chart.js 4.4.1** - Signal strength graphs
|
||||
- **Inter font** - Primary UI font (400, 500, 600, 700 weights)
|
||||
- **JetBrains Mono font** - Monospace/code font (400, 500, 600, 700 weights)
|
||||
|
||||
### Settings Modal
|
||||
Access via the gear icon in the navigation bar:
|
||||
- **Offline Tab** - Toggle offline mode, configure asset sources (CDN vs local)
|
||||
- **Display Tab** - Theme and animation preferences
|
||||
- **About Tab** - Version info and links
|
||||
|
||||
### Map Tile Providers
|
||||
Choose from multiple tile sources for maps:
|
||||
- **OpenStreetMap** - Default, general purpose
|
||||
- **CartoDB Dark** - Dark themed, matches UI
|
||||
- **CartoDB Positron** - Light themed
|
||||
- **ESRI World Imagery** - Satellite imagery
|
||||
- **Custom URL** - Connect to your own tile server (e.g., local OpenStreetMap tile cache)
|
||||
|
||||
### Local Asset Status
|
||||
The settings modal shows availability status for each bundled asset:
|
||||
- Green "Available" badge when asset is present
|
||||
- Red "Missing" badge when asset is not found
|
||||
- Click "Check Assets" to refresh status
|
||||
|
||||
### Use Cases
|
||||
- **Air-gapped environments** - Run on isolated networks
|
||||
- **Field deployments** - Operate without reliable internet
|
||||
- **Local tile servers** - Use pre-cached map tiles for specific regions
|
||||
- **Reduced latency** - Faster loading with local assets
|
||||
|
||||
## General
|
||||
|
||||
- **Web-based interface** - no desktop app needed
|
||||
|
||||
@@ -130,6 +130,18 @@
|
||||
<h3>Remote Agents</h3>
|
||||
<p>Distributed signal intelligence with remote sensor nodes. Deploy agents across multiple locations and aggregate data to a central controller.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📴</div>
|
||||
<h3>Offline Mode</h3>
|
||||
<p>Run without internet using bundled assets. Choose from multiple map tile providers or use your own local tile server.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📡</div>
|
||||
<h3>Meshtastic</h3>
|
||||
<p>LoRa mesh network integration. Connect to Meshtastic devices for decentralized, long-range communication monitoring.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -20,6 +20,9 @@ numpy>=1.24.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
|
||||
|
||||
# Development dependencies (install with: pip install -r requirements-dev.txt)
|
||||
# pytest>=7.0.0
|
||||
# pytest-cov>=4.0.0
|
||||
|
||||
@@ -19,9 +19,11 @@ def register_blueprints(app):
|
||||
from .settings import settings_bp
|
||||
from .correlation import correlation_bp
|
||||
from .listening_post import listening_post_bp
|
||||
from .meshtastic import meshtastic_bp
|
||||
from .tscm import tscm_bp, init_tscm_state
|
||||
from .spy_stations import spy_stations_bp
|
||||
from .controller import controller_bp
|
||||
from .offline import offline_bp
|
||||
|
||||
app.register_blueprint(pager_bp)
|
||||
app.register_blueprint(sensor_bp)
|
||||
@@ -40,9 +42,11 @@ def register_blueprints(app):
|
||||
app.register_blueprint(settings_bp)
|
||||
app.register_blueprint(correlation_bp)
|
||||
app.register_blueprint(listening_post_bp)
|
||||
app.register_blueprint(meshtastic_bp)
|
||||
app.register_blueprint(tscm_bp)
|
||||
app.register_blueprint(spy_stations_bp)
|
||||
app.register_blueprint(controller_bp) # Remote agent controller
|
||||
app.register_blueprint(offline_bp) # Offline mode settings
|
||||
|
||||
# Initialize TSCM state with queue and lock from app
|
||||
import app as app_module
|
||||
|
||||
@@ -280,6 +280,72 @@ def configure_channel(index: int):
|
||||
}), 500
|
||||
|
||||
|
||||
@meshtastic_bp.route('/send', methods=['POST'])
|
||||
def send_message():
|
||||
"""
|
||||
Send a text message to the mesh network.
|
||||
|
||||
JSON body:
|
||||
{
|
||||
"text": "Hello mesh!", // Required: message text (max 237 chars)
|
||||
"channel": 0, // Optional: channel index (default 0)
|
||||
"to": "!a1b2c3d4" // Optional: destination node (default broadcast)
|
||||
}
|
||||
|
||||
Returns:
|
||||
JSON with send status.
|
||||
"""
|
||||
if not is_meshtastic_available():
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Meshtastic SDK not installed'
|
||||
}), 400
|
||||
|
||||
client = get_meshtastic_client()
|
||||
|
||||
if not client or not client.is_running:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Not connected to Meshtastic device'
|
||||
}), 400
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
text = data.get('text', '').strip()
|
||||
|
||||
if not text:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Message text is required'
|
||||
}), 400
|
||||
|
||||
if len(text) > 237:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Message too long (max 237 characters)'
|
||||
}), 400
|
||||
|
||||
channel = data.get('channel', 0)
|
||||
if not isinstance(channel, int) or not 0 <= channel <= 7:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Channel must be 0-7'
|
||||
}), 400
|
||||
|
||||
destination = data.get('to')
|
||||
|
||||
logger.info(f"Sending message: text='{text[:50]}...', channel={channel}, to={destination}")
|
||||
success, error = client.send_text(text, channel=channel, destination=destination)
|
||||
logger.info(f"Send result: success={success}, error={error}")
|
||||
|
||||
if success:
|
||||
return jsonify({'status': 'sent'})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': error or 'Failed to send message'
|
||||
}), 500
|
||||
|
||||
|
||||
@meshtastic_bp.route('/messages')
|
||||
def get_messages():
|
||||
"""
|
||||
@@ -384,3 +450,42 @@ def get_node():
|
||||
'status': 'error',
|
||||
'message': 'Failed to get node information'
|
||||
}), 500
|
||||
|
||||
|
||||
@meshtastic_bp.route('/nodes')
|
||||
def get_nodes():
|
||||
"""
|
||||
Get all tracked mesh nodes with their positions.
|
||||
|
||||
Returns all nodes that have been seen on the mesh network,
|
||||
including their positions (if reported), battery levels, and signal info.
|
||||
|
||||
Query parameters:
|
||||
with_position: If 'true', only return nodes with valid positions
|
||||
|
||||
Returns:
|
||||
JSON with list of nodes.
|
||||
"""
|
||||
client = get_meshtastic_client()
|
||||
|
||||
if not client or not client.is_running:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Not connected to Meshtastic device',
|
||||
'nodes': []
|
||||
}), 400
|
||||
|
||||
nodes = client.get_nodes()
|
||||
nodes_list = [n.to_dict() for n in nodes]
|
||||
|
||||
# Filter to only nodes with positions if requested
|
||||
with_position = request.args.get('with_position', '').lower() == 'true'
|
||||
if with_position:
|
||||
nodes_list = [n for n in nodes_list if n.get('has_position')]
|
||||
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'nodes': nodes_list,
|
||||
'count': len(nodes_list),
|
||||
'with_position_count': sum(1 for n in nodes_list if n.get('has_position'))
|
||||
})
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"""
|
||||
Offline mode routes - Asset management and settings for offline operation.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
from utils.database import get_setting, set_setting
|
||||
import os
|
||||
|
||||
offline_bp = Blueprint('offline', __name__, url_prefix='/offline')
|
||||
|
||||
# Default offline settings
|
||||
OFFLINE_DEFAULTS = {
|
||||
'offline.enabled': False,
|
||||
'offline.assets_source': 'cdn',
|
||||
'offline.fonts_source': 'cdn',
|
||||
'offline.tile_provider': 'openstreetmap',
|
||||
'offline.tile_server_url': ''
|
||||
}
|
||||
|
||||
# Asset paths to check
|
||||
ASSET_PATHS = {
|
||||
'leaflet': [
|
||||
'static/vendor/leaflet/leaflet.js',
|
||||
'static/vendor/leaflet/leaflet.css'
|
||||
],
|
||||
'chartjs': [
|
||||
'static/vendor/chartjs/chart.umd.min.js'
|
||||
],
|
||||
'inter': [
|
||||
'static/vendor/fonts/Inter-Regular.woff2',
|
||||
'static/vendor/fonts/Inter-Medium.woff2',
|
||||
'static/vendor/fonts/Inter-SemiBold.woff2',
|
||||
'static/vendor/fonts/Inter-Bold.woff2'
|
||||
],
|
||||
'jetbrains': [
|
||||
'static/vendor/fonts/JetBrainsMono-Regular.woff2',
|
||||
'static/vendor/fonts/JetBrainsMono-Medium.woff2',
|
||||
'static/vendor/fonts/JetBrainsMono-SemiBold.woff2',
|
||||
'static/vendor/fonts/JetBrainsMono-Bold.woff2'
|
||||
],
|
||||
'leaflet_images': [
|
||||
'static/vendor/leaflet/images/marker-icon.png',
|
||||
'static/vendor/leaflet/images/marker-icon-2x.png',
|
||||
'static/vendor/leaflet/images/marker-shadow.png',
|
||||
'static/vendor/leaflet/images/layers.png',
|
||||
'static/vendor/leaflet/images/layers-2x.png'
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def get_offline_settings():
|
||||
"""Get all offline settings with defaults."""
|
||||
settings = {}
|
||||
for key, default in OFFLINE_DEFAULTS.items():
|
||||
settings[key] = get_setting(key, default)
|
||||
return settings
|
||||
|
||||
|
||||
@offline_bp.route('/settings', methods=['GET'])
|
||||
def get_settings():
|
||||
"""Get current offline settings."""
|
||||
settings = get_offline_settings()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'settings': settings
|
||||
})
|
||||
|
||||
|
||||
@offline_bp.route('/settings', methods=['POST'])
|
||||
def save_setting():
|
||||
"""Save an offline setting."""
|
||||
data = request.get_json()
|
||||
if not data or 'key' not in data or 'value' not in data:
|
||||
return jsonify({'status': 'error', 'message': 'Missing key or value'}), 400
|
||||
|
||||
key = data['key']
|
||||
value = data['value']
|
||||
|
||||
# Validate key is an allowed setting
|
||||
if key not in OFFLINE_DEFAULTS:
|
||||
return jsonify({'status': 'error', 'message': f'Unknown setting: {key}'}), 400
|
||||
|
||||
# Validate value type matches default
|
||||
default_type = type(OFFLINE_DEFAULTS[key])
|
||||
if not isinstance(value, default_type):
|
||||
# Try to convert
|
||||
try:
|
||||
if default_type == bool:
|
||||
value = str(value).lower() in ('true', '1', 'yes')
|
||||
else:
|
||||
value = default_type(value)
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Invalid value type for {key}'
|
||||
}), 400
|
||||
|
||||
set_setting(key, value)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'key': key,
|
||||
'value': value
|
||||
})
|
||||
|
||||
|
||||
@offline_bp.route('/status', methods=['GET'])
|
||||
def get_status():
|
||||
"""Check status of local assets."""
|
||||
# Get the app root directory
|
||||
app_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
results = {}
|
||||
all_available = True
|
||||
|
||||
for asset_name, paths in ASSET_PATHS.items():
|
||||
available = True
|
||||
missing = []
|
||||
for path in paths:
|
||||
full_path = os.path.join(app_root, path)
|
||||
if not os.path.exists(full_path):
|
||||
available = False
|
||||
missing.append(path)
|
||||
|
||||
results[asset_name] = {
|
||||
'available': available,
|
||||
'missing': missing if not available else []
|
||||
}
|
||||
|
||||
if not available:
|
||||
all_available = False
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'all_available': all_available,
|
||||
'assets': results,
|
||||
'offline_enabled': get_setting('offline.enabled', False)
|
||||
})
|
||||
|
||||
|
||||
@offline_bp.route('/check-asset', methods=['GET'])
|
||||
def check_asset():
|
||||
"""Check if a specific asset file exists."""
|
||||
path = request.args.get('path', '')
|
||||
if not path:
|
||||
return jsonify({'status': 'error', 'message': 'Missing path parameter'}), 400
|
||||
|
||||
# Security: only allow checking within static/vendor
|
||||
if not path.startswith('/static/vendor/'):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid path'}), 400
|
||||
|
||||
# Remove leading slash and construct full path
|
||||
relative_path = path.lstrip('/')
|
||||
app_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
full_path = os.path.join(app_root, relative_path)
|
||||
|
||||
exists = os.path.exists(full_path)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'path': path,
|
||||
'exists': exists
|
||||
})
|
||||
@@ -413,7 +413,7 @@ install_multimon_ng_from_source_macos() {
|
||||
}
|
||||
|
||||
install_macos_packages() {
|
||||
TOTAL_STEPS=14
|
||||
TOTAL_STEPS=15
|
||||
CURRENT_STEP=0
|
||||
|
||||
progress "Checking Homebrew"
|
||||
@@ -478,6 +478,19 @@ install_macos_packages() {
|
||||
progress "Installing gpsd"
|
||||
brew_install gpsd
|
||||
|
||||
progress "Installing Ubertooth tools (optional)"
|
||||
if ! cmd_exists ubertooth-btle; then
|
||||
echo
|
||||
info "Ubertooth is used for advanced Bluetooth packet sniffing with Ubertooth One hardware."
|
||||
if ask_yes_no "Do you want to install Ubertooth tools?"; then
|
||||
brew_install ubertooth || warn "Ubertooth not available via Homebrew"
|
||||
else
|
||||
warn "Skipping Ubertooth installation. You can install it later if needed."
|
||||
fi
|
||||
else
|
||||
ok "Ubertooth already installed"
|
||||
fi
|
||||
|
||||
warn "macOS note: hcitool/hciconfig are Linux (BlueZ) utilities and often unavailable on macOS."
|
||||
info "TSCM BLE scanning uses bleak library (installed via pip) for manufacturer data detection."
|
||||
echo
|
||||
@@ -613,6 +626,34 @@ install_aiscatcher_from_source_debian() {
|
||||
)
|
||||
}
|
||||
|
||||
install_ubertooth_from_source_debian() {
|
||||
info "Building Ubertooth from source..."
|
||||
|
||||
apt_install build-essential git cmake libusb-1.0-0-dev pkg-config libbluetooth-dev
|
||||
|
||||
# Run in subshell to isolate EXIT trap
|
||||
(
|
||||
tmp_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
|
||||
info "Cloning Ubertooth..."
|
||||
git clone --depth 1 https://github.com/greatscottgadgets/ubertooth.git "$tmp_dir/ubertooth" >/dev/null 2>&1 \
|
||||
|| { warn "Failed to clone Ubertooth"; exit 1; }
|
||||
|
||||
cd "$tmp_dir/ubertooth/host"
|
||||
mkdir -p build && cd build
|
||||
|
||||
info "Compiling Ubertooth..."
|
||||
if cmake .. >/dev/null 2>&1 && make >/dev/null 2>&1; then
|
||||
$SUDO make install >/dev/null 2>&1
|
||||
$SUDO ldconfig
|
||||
ok "Ubertooth installed successfully from source."
|
||||
else
|
||||
warn "Failed to build Ubertooth from source."
|
||||
fi
|
||||
)
|
||||
}
|
||||
|
||||
install_rtlsdr_blog_drivers_debian() {
|
||||
# The RTL-SDR Blog drivers provide better support for:
|
||||
# - RTL-SDR Blog V4 (R828D tuner)
|
||||
@@ -720,7 +761,7 @@ install_debian_packages() {
|
||||
export NEEDRESTART_MODE=a
|
||||
fi
|
||||
|
||||
TOTAL_STEPS=19
|
||||
TOTAL_STEPS=20
|
||||
CURRENT_STEP=0
|
||||
|
||||
progress "Updating APT package lists"
|
||||
@@ -818,6 +859,19 @@ install_debian_packages() {
|
||||
progress "Installing Bluetooth tools"
|
||||
apt_install bluez bluetooth || true
|
||||
|
||||
progress "Installing Ubertooth tools (optional)"
|
||||
if ! cmd_exists ubertooth-btle; then
|
||||
echo
|
||||
info "Ubertooth is used for advanced Bluetooth packet sniffing with Ubertooth One hardware."
|
||||
if ask_yes_no "Do you want to install Ubertooth tools?"; then
|
||||
apt_install libubertooth-dev ubertooth || install_ubertooth_from_source_debian
|
||||
else
|
||||
warn "Skipping Ubertooth installation. You can install it later if needed."
|
||||
fi
|
||||
else
|
||||
ok "Ubertooth already installed"
|
||||
fi
|
||||
|
||||
progress "Installing SoapySDR"
|
||||
# Exclude xtrx-dkms - its kernel module fails to build on newer kernels (6.14+)
|
||||
# and causes apt to hang. Most users don't have XTRX hardware anyway.
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/* 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');
|
||||
}
|
||||
+129
-2
@@ -372,7 +372,18 @@ body {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
padding: 16px;
|
||||
max-height: calc(100vh - 300px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.welcome-modes > h2 {
|
||||
position: sticky;
|
||||
top: -16px;
|
||||
background: var(--bg-secondary);
|
||||
padding: 8px 0;
|
||||
margin: -8px 0 12px 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.mode-grid {
|
||||
@@ -439,6 +450,65 @@ body {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Mode Categories */
|
||||
.mode-category {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.mode-category:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.mode-category-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
margin: 0 0 10px 0;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.mode-category-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.mode-category-icon svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
/* Compact Mode Grid */
|
||||
.mode-grid-compact {
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mode-card-sm {
|
||||
padding: 10px 6px;
|
||||
}
|
||||
|
||||
.mode-card-sm .mode-icon {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.mode-card-sm .mode-icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.mode-card-sm .mode-name {
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* Welcome Footer */
|
||||
.welcome-footer {
|
||||
text-align: center;
|
||||
@@ -501,11 +571,18 @@ body {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
/* Larger phones: 3 columns for mode grid */
|
||||
.mode-grid-compact {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
/* Larger phones: more columns for mode grid */
|
||||
@media (min-width: 480px) {
|
||||
.mode-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
.mode-grid-compact {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* Tablet and up: Side-by-side layout */
|
||||
@@ -522,6 +599,10 @@ body {
|
||||
.welcome-title-block {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.mode-grid-compact {
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
@@ -6121,4 +6202,50 @@ body::before {
|
||||
|
||||
.preset-freq-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* Animation toggle icon states in nav bar */
|
||||
.nav-tool-btn .icon-effects-on,
|
||||
.nav-tool-btn .icon-effects-off {
|
||||
position: absolute;
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.nav-tool-btn .icon-effects-on {
|
||||
opacity: 1;
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
.nav-tool-btn .icon-effects-off {
|
||||
opacity: 0;
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
[data-animations="off"] .nav-tool-btn .icon-effects-on {
|
||||
opacity: 0;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
[data-animations="off"] .nav-tool-btn .icon-effects-off {
|
||||
opacity: 1;
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
/* Disable cosmetic animations when toggled off */
|
||||
[data-animations="off"] .globe-svg,
|
||||
[data-animations="off"] .rotating-meridians,
|
||||
[data-animations="off"] .meridian-1,
|
||||
[data-animations="off"] .meridian-2,
|
||||
[data-animations="off"] .meridian-3,
|
||||
[data-animations="off"] .welcome-scanline,
|
||||
[data-animations="off"] .landing-scanline,
|
||||
[data-animations="off"] .scanline,
|
||||
[data-animations="off"] .signal-wave,
|
||||
[data-animations="off"] .signal-wave-1,
|
||||
[data-animations="off"] .signal-wave-2,
|
||||
[data-animations="off"] .signal-wave-3,
|
||||
[data-animations="off"] .logo-dot,
|
||||
[data-animations="off"] .welcome-logo {
|
||||
animation: none !important;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +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);
|
||||
}
|
||||
|
||||
/* 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%;
|
||||
}
|
||||
}
|
||||
@@ -988,6 +988,66 @@ const SignalCards = (function() {
|
||||
return card;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build HTML for all meter detail fields from raw message data
|
||||
*/
|
||||
function buildMeterDetailsHtml(msg, seenCount) {
|
||||
let html = '';
|
||||
const rawMessage = msg.rawMessage || {};
|
||||
|
||||
// Display all fields from the raw rtlamr message
|
||||
for (const [key, value] of Object.entries(rawMessage)) {
|
||||
if (value === null || value === undefined) continue;
|
||||
|
||||
// Format the label (convert camelCase/PascalCase to spaces)
|
||||
const label = key.replace(/([A-Z])/g, ' $1').replace(/^./, s => s.toUpperCase()).trim();
|
||||
|
||||
// Format the value based on type
|
||||
let displayValue;
|
||||
if (Array.isArray(value)) {
|
||||
// For arrays like DifferentialConsumptionIntervals, show count and values
|
||||
if (value.length > 10) {
|
||||
displayValue = `[${value.length} values] ${value.slice(0, 5).join(', ')}...`;
|
||||
} else {
|
||||
displayValue = value.join(', ');
|
||||
}
|
||||
} else if (typeof value === 'object') {
|
||||
displayValue = JSON.stringify(value);
|
||||
} else if (key === 'Consumption') {
|
||||
displayValue = `${value.toLocaleString()} units`;
|
||||
} else {
|
||||
displayValue = String(value);
|
||||
}
|
||||
|
||||
html += `
|
||||
<div class="signal-advanced-item">
|
||||
<span class="signal-advanced-label">${escapeHtml(label)}</span>
|
||||
<span class="signal-advanced-value">${escapeHtml(displayValue)}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Add message type if not in raw message
|
||||
if (!rawMessage.Type && msg.type) {
|
||||
html += `
|
||||
<div class="signal-advanced-item">
|
||||
<span class="signal-advanced-label">Message Type</span>
|
||||
<span class="signal-advanced-value">${escapeHtml(msg.type)}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Add seen count
|
||||
html += `
|
||||
<div class="signal-advanced-item">
|
||||
<span class="signal-advanced-label">Seen</span>
|
||||
<span class="signal-advanced-value">${seenCount} time${seenCount > 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a utility meter (rtlamr) card
|
||||
*/
|
||||
@@ -1060,30 +1120,7 @@ const SignalCards = (function() {
|
||||
<div class="signal-advanced-section">
|
||||
<div class="signal-advanced-title">Meter Details</div>
|
||||
<div class="signal-advanced-grid">
|
||||
<div class="signal-advanced-item">
|
||||
<span class="signal-advanced-label">Meter ID</span>
|
||||
<span class="signal-advanced-value">${escapeHtml(msg.id || 'N/A')}</span>
|
||||
</div>
|
||||
<div class="signal-advanced-item">
|
||||
<span class="signal-advanced-label">Type</span>
|
||||
<span class="signal-advanced-value">${escapeHtml(msg.type || 'Unknown')}</span>
|
||||
</div>
|
||||
${msg.endpoint_type ? `
|
||||
<div class="signal-advanced-item">
|
||||
<span class="signal-advanced-label">Endpoint</span>
|
||||
<span class="signal-advanced-value">${escapeHtml(msg.endpoint_type)}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
${msg.endpoint_id ? `
|
||||
<div class="signal-advanced-item">
|
||||
<span class="signal-advanced-label">Endpoint ID</span>
|
||||
<span class="signal-advanced-value">${escapeHtml(msg.endpoint_id)}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="signal-advanced-item">
|
||||
<span class="signal-advanced-label">Seen</span>
|
||||
<span class="signal-advanced-value">${seenCount} time${seenCount > 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
${buildMeterDetailsHtml(msg, seenCount)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+16
-7
@@ -95,7 +95,7 @@ function switchMode(mode) {
|
||||
const modeMap = {
|
||||
'pager': 'pager', 'sensor': '433', 'aircraft': 'aircraft',
|
||||
'satellite': 'satellite', 'wifi': 'wifi', 'bluetooth': 'bluetooth',
|
||||
'listening': 'listening'
|
||||
'listening': 'listening', 'meshtastic': 'meshtastic'
|
||||
};
|
||||
document.querySelectorAll('.mode-nav-btn').forEach(btn => {
|
||||
const label = btn.querySelector('.nav-label');
|
||||
@@ -107,11 +107,16 @@ function switchMode(mode) {
|
||||
// Toggle mode content visibility
|
||||
document.getElementById('pagerMode').classList.toggle('active', mode === 'pager');
|
||||
document.getElementById('sensorMode').classList.toggle('active', mode === 'sensor');
|
||||
document.getElementById('aircraftMode').classList.toggle('active', mode === 'aircraft');
|
||||
document.getElementById('aircraftMode')?.classList.toggle('active', mode === 'aircraft');
|
||||
document.getElementById('satelliteMode').classList.toggle('active', mode === 'satellite');
|
||||
document.getElementById('wifiMode').classList.toggle('active', mode === 'wifi');
|
||||
document.getElementById('bluetoothMode').classList.toggle('active', mode === 'bluetooth');
|
||||
document.getElementById('listeningPostMode').classList.toggle('active', mode === 'listening');
|
||||
document.getElementById('aprsMode')?.classList.toggle('active', mode === 'aprs');
|
||||
document.getElementById('tscmMode')?.classList.toggle('active', mode === 'tscm');
|
||||
document.getElementById('rtlamrMode')?.classList.toggle('active', mode === 'rtlamr');
|
||||
document.getElementById('spystationsMode')?.classList.toggle('active', mode === 'spystations');
|
||||
document.getElementById('meshtasticMode')?.classList.toggle('active', mode === 'meshtastic');
|
||||
|
||||
// Toggle stats visibility
|
||||
document.getElementById('pagerStats').style.display = mode === 'pager' ? 'flex' : 'none';
|
||||
@@ -137,7 +142,8 @@ function switchMode(mode) {
|
||||
'bluetooth': 'BLUETOOTH',
|
||||
'listening': 'LISTENING POST',
|
||||
'tscm': 'TSCM',
|
||||
'aprs': 'APRS'
|
||||
'aprs': 'APRS',
|
||||
'meshtastic': 'MESHTASTIC'
|
||||
};
|
||||
document.getElementById('activeModeIndicator').innerHTML = '<span class="pulse-dot"></span>' + modeNames[mode];
|
||||
|
||||
@@ -167,7 +173,8 @@ function switchMode(mode) {
|
||||
'satellite': 'Satellite Monitor',
|
||||
'wifi': 'WiFi Scanner',
|
||||
'bluetooth': 'Bluetooth Scanner',
|
||||
'listening': 'Listening Post'
|
||||
'listening': 'Listening Post',
|
||||
'meshtastic': 'Meshtastic Mesh Monitor'
|
||||
};
|
||||
document.getElementById('outputTitle').textContent = titles[mode] || 'Signal Monitor';
|
||||
|
||||
@@ -197,10 +204,10 @@ function switchMode(mode) {
|
||||
|
||||
// Hide waterfall and output console for modes with their own visualizations
|
||||
document.querySelector('.waterfall-container').style.display =
|
||||
(mode === 'satellite' || mode === 'listening' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth') ? 'none' : 'block';
|
||||
(mode === 'satellite' || mode === 'listening' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
|
||||
document.getElementById('output').style.display =
|
||||
(mode === 'satellite' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth') ? 'none' : 'block';
|
||||
document.querySelector('.status-bar').style.display = (mode === 'satellite' || mode === 'tscm') ? 'none' : 'flex';
|
||||
(mode === 'satellite' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
|
||||
document.querySelector('.status-bar').style.display = (mode === 'satellite' || mode === 'tscm' || mode === 'meshtastic' || mode === 'aprs' || mode === 'spystations') ? 'none' : 'flex';
|
||||
|
||||
// Load interfaces and initialize visualizations when switching modes
|
||||
if (mode === 'wifi') {
|
||||
@@ -221,6 +228,8 @@ function switchMode(mode) {
|
||||
if (typeof checkAudioTools === 'function') checkAudioTools();
|
||||
if (typeof populateScannerDeviceSelect === 'function') populateScannerDeviceSelect();
|
||||
if (typeof populateAudioDeviceSelect === 'function') populateAudioDeviceSelect();
|
||||
} else if (mode === 'meshtastic') {
|
||||
if (typeof Meshtastic !== 'undefined' && Meshtastic.init) Meshtastic.init();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
/**
|
||||
* Settings Manager - Handles offline mode and application settings
|
||||
*/
|
||||
|
||||
const Settings = {
|
||||
// Default settings
|
||||
defaults: {
|
||||
'offline.enabled': false,
|
||||
'offline.assets_source': 'cdn',
|
||||
'offline.fonts_source': 'cdn',
|
||||
'offline.tile_provider': 'openstreetmap',
|
||||
'offline.tile_server_url': ''
|
||||
},
|
||||
|
||||
// Tile provider configurations
|
||||
tileProviders: {
|
||||
openstreetmap: {
|
||||
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
subdomains: 'abc'
|
||||
},
|
||||
cartodb_dark: {
|
||||
url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
|
||||
subdomains: 'abcd'
|
||||
},
|
||||
cartodb_light: {
|
||||
url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
|
||||
subdomains: 'abcd'
|
||||
},
|
||||
esri_world: {
|
||||
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||
attribution: 'Tiles © Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community',
|
||||
subdomains: null
|
||||
}
|
||||
},
|
||||
|
||||
// Current settings cache
|
||||
_cache: {},
|
||||
|
||||
/**
|
||||
* Initialize settings - load from server/localStorage
|
||||
*/
|
||||
async init() {
|
||||
try {
|
||||
const response = await fetch('/offline/settings');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
this._cache = { ...this.defaults, ...data.settings };
|
||||
} else {
|
||||
// Fall back to localStorage
|
||||
this._loadFromLocalStorage();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to load settings from server, using localStorage:', e);
|
||||
this._loadFromLocalStorage();
|
||||
}
|
||||
|
||||
this._updateUI();
|
||||
return this._cache;
|
||||
},
|
||||
|
||||
/**
|
||||
* Load settings from localStorage
|
||||
*/
|
||||
_loadFromLocalStorage() {
|
||||
const stored = localStorage.getItem('intercept_settings');
|
||||
if (stored) {
|
||||
try {
|
||||
this._cache = { ...this.defaults, ...JSON.parse(stored) };
|
||||
} catch (e) {
|
||||
this._cache = { ...this.defaults };
|
||||
}
|
||||
} else {
|
||||
this._cache = { ...this.defaults };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Save a setting to server and localStorage
|
||||
*/
|
||||
async _save(key, value) {
|
||||
this._cache[key] = value;
|
||||
|
||||
// Save to localStorage as backup
|
||||
localStorage.setItem('intercept_settings', JSON.stringify(this._cache));
|
||||
|
||||
// Save to server
|
||||
try {
|
||||
await fetch('/offline/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key, value })
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('Failed to save setting to server:', e);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a setting value
|
||||
*/
|
||||
get(key) {
|
||||
return this._cache[key] ?? this.defaults[key];
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle offline mode master switch
|
||||
*/
|
||||
async toggleOfflineMode(enabled) {
|
||||
await this._save('offline.enabled', enabled);
|
||||
|
||||
if (enabled) {
|
||||
// When enabling offline mode, also switch assets and fonts to local
|
||||
await this._save('offline.assets_source', 'local');
|
||||
await this._save('offline.fonts_source', 'local');
|
||||
}
|
||||
|
||||
this._updateUI();
|
||||
this._showReloadPrompt();
|
||||
},
|
||||
|
||||
/**
|
||||
* Set asset source (cdn or local)
|
||||
*/
|
||||
async setAssetSource(source) {
|
||||
await this._save('offline.assets_source', source);
|
||||
this._showReloadPrompt();
|
||||
},
|
||||
|
||||
/**
|
||||
* Set fonts source (cdn or local)
|
||||
*/
|
||||
async setFontsSource(source) {
|
||||
await this._save('offline.fonts_source', source);
|
||||
this._showReloadPrompt();
|
||||
},
|
||||
|
||||
/**
|
||||
* Set tile provider
|
||||
*/
|
||||
async setTileProvider(provider) {
|
||||
await this._save('offline.tile_provider', provider);
|
||||
|
||||
// Show/hide custom URL input
|
||||
const customRow = document.getElementById('customTileUrlRow');
|
||||
if (customRow) {
|
||||
customRow.style.display = provider === 'custom' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// If not custom and we have a map, update tiles immediately
|
||||
if (provider !== 'custom') {
|
||||
this._updateMapTiles();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Set custom tile server URL
|
||||
*/
|
||||
async setCustomTileUrl(url) {
|
||||
await this._save('offline.tile_server_url', url);
|
||||
this._updateMapTiles();
|
||||
},
|
||||
|
||||
/**
|
||||
* Get current tile configuration
|
||||
*/
|
||||
getTileConfig() {
|
||||
const provider = this.get('offline.tile_provider');
|
||||
|
||||
if (provider === 'custom') {
|
||||
const customUrl = this.get('offline.tile_server_url');
|
||||
return {
|
||||
url: customUrl || 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
attribution: 'Custom Tile Server',
|
||||
subdomains: 'abc'
|
||||
};
|
||||
}
|
||||
|
||||
return this.tileProviders[provider] || this.tileProviders.openstreetmap;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if local assets are available
|
||||
*/
|
||||
async checkAssets() {
|
||||
const assets = {
|
||||
leaflet: [
|
||||
'/static/vendor/leaflet/leaflet.js',
|
||||
'/static/vendor/leaflet/leaflet.css'
|
||||
],
|
||||
chartjs: [
|
||||
'/static/vendor/chartjs/chart.umd.min.js'
|
||||
],
|
||||
inter: [
|
||||
'/static/vendor/fonts/Inter-Regular.woff2'
|
||||
],
|
||||
jetbrains: [
|
||||
'/static/vendor/fonts/JetBrainsMono-Regular.woff2'
|
||||
]
|
||||
};
|
||||
|
||||
const results = {};
|
||||
|
||||
for (const [name, urls] of Object.entries(assets)) {
|
||||
const statusEl = document.getElementById(`status${name.charAt(0).toUpperCase() + name.slice(1)}`);
|
||||
if (statusEl) {
|
||||
statusEl.textContent = 'Checking...';
|
||||
statusEl.className = 'asset-badge checking';
|
||||
}
|
||||
|
||||
let available = true;
|
||||
for (const url of urls) {
|
||||
try {
|
||||
const response = await fetch(url, { method: 'HEAD' });
|
||||
if (!response.ok) {
|
||||
available = false;
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
available = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
results[name] = available;
|
||||
|
||||
if (statusEl) {
|
||||
statusEl.textContent = available ? 'Available' : 'Missing';
|
||||
statusEl.className = `asset-badge ${available ? 'available' : 'missing'}`;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update UI elements to reflect current settings
|
||||
*/
|
||||
_updateUI() {
|
||||
// Offline mode toggle
|
||||
const offlineEnabled = document.getElementById('offlineEnabled');
|
||||
if (offlineEnabled) {
|
||||
offlineEnabled.checked = this.get('offline.enabled');
|
||||
}
|
||||
|
||||
// Assets source
|
||||
const assetsSource = document.getElementById('assetsSource');
|
||||
if (assetsSource) {
|
||||
assetsSource.value = this.get('offline.assets_source');
|
||||
}
|
||||
|
||||
// Fonts source
|
||||
const fontsSource = document.getElementById('fontsSource');
|
||||
if (fontsSource) {
|
||||
fontsSource.value = this.get('offline.fonts_source');
|
||||
}
|
||||
|
||||
// Tile provider
|
||||
const tileProvider = document.getElementById('tileProvider');
|
||||
if (tileProvider) {
|
||||
tileProvider.value = this.get('offline.tile_provider');
|
||||
}
|
||||
|
||||
// Custom tile URL
|
||||
const customTileUrl = document.getElementById('customTileUrl');
|
||||
if (customTileUrl) {
|
||||
customTileUrl.value = this.get('offline.tile_server_url') || '';
|
||||
}
|
||||
|
||||
// Show/hide custom URL row
|
||||
const customRow = document.getElementById('customTileUrlRow');
|
||||
if (customRow) {
|
||||
customRow.style.display = this.get('offline.tile_provider') === 'custom' ? 'block' : 'none';
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update map tiles if a map exists
|
||||
*/
|
||||
_updateMapTiles() {
|
||||
// Look for common map variable names
|
||||
const maps = [
|
||||
window.map,
|
||||
window.leafletMap,
|
||||
window.aprsMap,
|
||||
window.adsbMap
|
||||
].filter(m => m && typeof m.eachLayer === 'function');
|
||||
|
||||
if (maps.length === 0) return;
|
||||
|
||||
const config = this.getTileConfig();
|
||||
|
||||
maps.forEach(map => {
|
||||
// Remove existing tile layers
|
||||
map.eachLayer(layer => {
|
||||
if (layer instanceof L.TileLayer) {
|
||||
map.removeLayer(layer);
|
||||
}
|
||||
});
|
||||
|
||||
// Add new tile layer
|
||||
const options = {
|
||||
attribution: config.attribution
|
||||
};
|
||||
if (config.subdomains) {
|
||||
options.subdomains = config.subdomains;
|
||||
}
|
||||
|
||||
L.tileLayer(config.url, options).addTo(map);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Show reload prompt
|
||||
*/
|
||||
_showReloadPrompt() {
|
||||
// Create or update reload prompt
|
||||
let prompt = document.getElementById('settingsReloadPrompt');
|
||||
if (!prompt) {
|
||||
prompt = document.createElement('div');
|
||||
prompt.id = 'settingsReloadPrompt';
|
||||
prompt.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
background: var(--bg-dark, #0a0a0f);
|
||||
border: 1px solid var(--accent-cyan, #00d4ff);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
z-index: 10001;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||
`;
|
||||
prompt.innerHTML = `
|
||||
<span style="color: var(--text-primary, #e0e0e0); font-size: 13px;">
|
||||
Reload to apply changes
|
||||
</span>
|
||||
<button onclick="location.reload()" style="
|
||||
background: var(--accent-cyan, #00d4ff);
|
||||
border: none;
|
||||
color: #000;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
">Reload</button>
|
||||
<button onclick="this.parentElement.remove()" style="
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted, #666);
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
">×</button>
|
||||
`;
|
||||
document.body.appendChild(prompt);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Settings modal functions
|
||||
function showSettings() {
|
||||
const modal = document.getElementById('settingsModal');
|
||||
if (modal) {
|
||||
modal.classList.add('active');
|
||||
Settings.init().then(() => {
|
||||
Settings.checkAssets();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function hideSettings() {
|
||||
const modal = document.getElementById('settingsModal');
|
||||
if (modal) {
|
||||
modal.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
function switchSettingsTab(tabName) {
|
||||
// Update tab buttons
|
||||
document.querySelectorAll('.settings-tab').forEach(tab => {
|
||||
tab.classList.toggle('active', tab.dataset.tab === tabName);
|
||||
});
|
||||
|
||||
// Update sections
|
||||
document.querySelectorAll('.settings-section').forEach(section => {
|
||||
section.classList.toggle('active', section.id === `settings-${tabName}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize settings on page load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
Settings.init();
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
+20
File diff suppressed because one or more lines are too long
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 696 B |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 618 B |
Vendored
+661
@@ -0,0 +1,661 @@
|
||||
/* required styles */
|
||||
|
||||
.leaflet-pane,
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-tile-container,
|
||||
.leaflet-pane > svg,
|
||||
.leaflet-pane > canvas,
|
||||
.leaflet-zoom-box,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-layer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
/* Prevents IE11 from highlighting tiles in blue */
|
||||
.leaflet-tile::selection {
|
||||
background: transparent;
|
||||
}
|
||||
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
|
||||
.leaflet-safari .leaflet-tile {
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
}
|
||||
/* hack that prevents hw layers "stretching" when loading new tiles */
|
||||
.leaflet-safari .leaflet-tile-container {
|
||||
width: 1600px;
|
||||
height: 1600px;
|
||||
-webkit-transform-origin: 0 0;
|
||||
}
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
display: block;
|
||||
}
|
||||
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
|
||||
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
|
||||
.leaflet-container .leaflet-overlay-pane svg {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
.leaflet-container .leaflet-marker-pane img,
|
||||
.leaflet-container .leaflet-shadow-pane img,
|
||||
.leaflet-container .leaflet-tile-pane img,
|
||||
.leaflet-container img.leaflet-image-layer,
|
||||
.leaflet-container .leaflet-tile {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
width: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.leaflet-container img.leaflet-tile {
|
||||
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
|
||||
mix-blend-mode: plus-lighter;
|
||||
}
|
||||
|
||||
.leaflet-container.leaflet-touch-zoom {
|
||||
-ms-touch-action: pan-x pan-y;
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag {
|
||||
-ms-touch-action: pinch-zoom;
|
||||
/* Fallback for FF which doesn't support pinch-zoom */
|
||||
touch-action: none;
|
||||
touch-action: pinch-zoom;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
|
||||
-ms-touch-action: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.leaflet-container {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.leaflet-container a {
|
||||
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
|
||||
}
|
||||
.leaflet-tile {
|
||||
filter: inherit;
|
||||
visibility: hidden;
|
||||
}
|
||||
.leaflet-tile-loaded {
|
||||
visibility: inherit;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
width: 0;
|
||||
height: 0;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
z-index: 800;
|
||||
}
|
||||
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
|
||||
.leaflet-overlay-pane svg {
|
||||
-moz-user-select: none;
|
||||
}
|
||||
|
||||
.leaflet-pane { z-index: 400; }
|
||||
|
||||
.leaflet-tile-pane { z-index: 200; }
|
||||
.leaflet-overlay-pane { z-index: 400; }
|
||||
.leaflet-shadow-pane { z-index: 500; }
|
||||
.leaflet-marker-pane { z-index: 600; }
|
||||
.leaflet-tooltip-pane { z-index: 650; }
|
||||
.leaflet-popup-pane { z-index: 700; }
|
||||
|
||||
.leaflet-map-pane canvas { z-index: 100; }
|
||||
.leaflet-map-pane svg { z-index: 200; }
|
||||
|
||||
.leaflet-vml-shape {
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
.lvml {
|
||||
behavior: url(#default#VML);
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
|
||||
/* control positioning */
|
||||
|
||||
.leaflet-control {
|
||||
position: relative;
|
||||
z-index: 800;
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-top,
|
||||
.leaflet-bottom {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-top {
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-right {
|
||||
right: 0;
|
||||
}
|
||||
.leaflet-bottom {
|
||||
bottom: 0;
|
||||
}
|
||||
.leaflet-left {
|
||||
left: 0;
|
||||
}
|
||||
.leaflet-control {
|
||||
float: left;
|
||||
clear: both;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
float: right;
|
||||
}
|
||||
.leaflet-top .leaflet-control {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.leaflet-left .leaflet-control {
|
||||
margin-left: 10px;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
|
||||
/* zoom and fade animations */
|
||||
|
||||
.leaflet-fade-anim .leaflet-popup {
|
||||
opacity: 0;
|
||||
-webkit-transition: opacity 0.2s linear;
|
||||
-moz-transition: opacity 0.2s linear;
|
||||
transition: opacity 0.2s linear;
|
||||
}
|
||||
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
|
||||
opacity: 1;
|
||||
}
|
||||
.leaflet-zoom-animated {
|
||||
-webkit-transform-origin: 0 0;
|
||||
-ms-transform-origin: 0 0;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
svg.leaflet-zoom-animated {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-animated {
|
||||
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
}
|
||||
.leaflet-zoom-anim .leaflet-tile,
|
||||
.leaflet-pan-anim .leaflet-tile {
|
||||
-webkit-transition: none;
|
||||
-moz-transition: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-hide {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
|
||||
/* cursors */
|
||||
|
||||
.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
}
|
||||
.leaflet-grab {
|
||||
cursor: -webkit-grab;
|
||||
cursor: -moz-grab;
|
||||
cursor: grab;
|
||||
}
|
||||
.leaflet-crosshair,
|
||||
.leaflet-crosshair .leaflet-interactive {
|
||||
cursor: crosshair;
|
||||
}
|
||||
.leaflet-popup-pane,
|
||||
.leaflet-control {
|
||||
cursor: auto;
|
||||
}
|
||||
.leaflet-dragging .leaflet-grab,
|
||||
.leaflet-dragging .leaflet-grab .leaflet-interactive,
|
||||
.leaflet-dragging .leaflet-marker-draggable {
|
||||
cursor: move;
|
||||
cursor: -webkit-grabbing;
|
||||
cursor: -moz-grabbing;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* marker & overlays interactivity */
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-pane > svg path,
|
||||
.leaflet-tile-container {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.leaflet-marker-icon.leaflet-interactive,
|
||||
.leaflet-image-layer.leaflet-interactive,
|
||||
.leaflet-pane > svg path.leaflet-interactive,
|
||||
svg.leaflet-image-layer.leaflet-interactive path {
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* visual tweaks */
|
||||
|
||||
.leaflet-container {
|
||||
background: #ddd;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.leaflet-container a {
|
||||
color: #0078A8;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
border: 2px dotted #38f;
|
||||
background: rgba(255,255,255,0.5);
|
||||
}
|
||||
|
||||
|
||||
/* general typography */
|
||||
.leaflet-container {
|
||||
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||
font-size: 12px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
|
||||
/* general toolbar styles */
|
||||
|
||||
.leaflet-bar {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a {
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #ccc;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
line-height: 26px;
|
||||
display: block;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
}
|
||||
.leaflet-bar a,
|
||||
.leaflet-control-layers-toggle {
|
||||
background-position: 50% 50%;
|
||||
background-repeat: no-repeat;
|
||||
display: block;
|
||||
}
|
||||
.leaflet-bar a:hover,
|
||||
.leaflet-bar a:focus {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
.leaflet-bar a:first-child {
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
border-bottom: none;
|
||||
}
|
||||
.leaflet-bar a.leaflet-disabled {
|
||||
cursor: default;
|
||||
background-color: #f4f4f4;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-bar a {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:first-child {
|
||||
border-top-left-radius: 2px;
|
||||
border-top-right-radius: 2px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 2px;
|
||||
border-bottom-right-radius: 2px;
|
||||
}
|
||||
|
||||
/* zoom control */
|
||||
|
||||
.leaflet-control-zoom-in,
|
||||
.leaflet-control-zoom-out {
|
||||
font: bold 18px 'Lucida Console', Monaco, monospace;
|
||||
text-indent: 1px;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
|
||||
/* layers control */
|
||||
|
||||
.leaflet-control-layers {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
|
||||
background: #fff;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers.png);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.leaflet-retina .leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers-2x.png);
|
||||
background-size: 26px 26px;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers-toggle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.leaflet-control-layers .leaflet-control-layers-list,
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
|
||||
display: none;
|
||||
}
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-list {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
.leaflet-control-layers-expanded {
|
||||
padding: 6px 10px 6px 6px;
|
||||
color: #333;
|
||||
background: #fff;
|
||||
}
|
||||
.leaflet-control-layers-scrollbar {
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
padding-right: 5px;
|
||||
}
|
||||
.leaflet-control-layers-selector {
|
||||
margin-top: 2px;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
.leaflet-control-layers label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
}
|
||||
.leaflet-control-layers-separator {
|
||||
height: 0;
|
||||
border-top: 1px solid #ddd;
|
||||
margin: 5px -10px 5px -6px;
|
||||
}
|
||||
|
||||
/* Default icon URLs */
|
||||
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
|
||||
background-image: url(images/marker-icon.png);
|
||||
}
|
||||
|
||||
|
||||
/* attribution and scale controls */
|
||||
|
||||
.leaflet-container .leaflet-control-attribution {
|
||||
background: #fff;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
margin: 0;
|
||||
}
|
||||
.leaflet-control-attribution,
|
||||
.leaflet-control-scale-line {
|
||||
padding: 0 5px;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.leaflet-control-attribution a {
|
||||
text-decoration: none;
|
||||
}
|
||||
.leaflet-control-attribution a:hover,
|
||||
.leaflet-control-attribution a:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.leaflet-attribution-flag {
|
||||
display: inline !important;
|
||||
vertical-align: baseline !important;
|
||||
width: 1em;
|
||||
height: 0.6669em;
|
||||
}
|
||||
.leaflet-left .leaflet-control-scale {
|
||||
margin-left: 5px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control-scale {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.leaflet-control-scale-line {
|
||||
border: 2px solid #777;
|
||||
border-top: none;
|
||||
line-height: 1.1;
|
||||
padding: 2px 5px 1px;
|
||||
white-space: nowrap;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
text-shadow: 1px 1px #fff;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child) {
|
||||
border-top: 2px solid #777;
|
||||
border-bottom: none;
|
||||
margin-top: -2px;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
|
||||
border-bottom: 2px solid #777;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-attribution,
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
box-shadow: none;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
border: 2px solid rgba(0,0,0,0.2);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
|
||||
/* popup */
|
||||
|
||||
.leaflet-popup {
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.leaflet-popup-content-wrapper {
|
||||
padding: 1px;
|
||||
text-align: left;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.leaflet-popup-content {
|
||||
margin: 13px 24px 13px 20px;
|
||||
line-height: 1.3;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
min-height: 1px;
|
||||
}
|
||||
.leaflet-popup-content p {
|
||||
margin: 17px 0;
|
||||
margin: 1.3em 0;
|
||||
}
|
||||
.leaflet-popup-tip-container {
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
margin-top: -1px;
|
||||
margin-left: -20px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-popup-tip {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
padding: 1px;
|
||||
|
||||
margin: -10px auto 0;
|
||||
pointer-events: auto;
|
||||
|
||||
-webkit-transform: rotate(45deg);
|
||||
-moz-transform: rotate(45deg);
|
||||
-ms-transform: rotate(45deg);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.leaflet-popup-content-wrapper,
|
||||
.leaflet-popup-tip {
|
||||
background: white;
|
||||
color: #333;
|
||||
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
border: none;
|
||||
text-align: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font: 16px/24px Tahoma, Verdana, sans-serif;
|
||||
color: #757575;
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button:hover,
|
||||
.leaflet-container a.leaflet-popup-close-button:focus {
|
||||
color: #585858;
|
||||
}
|
||||
.leaflet-popup-scrolled {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper {
|
||||
-ms-zoom: 1;
|
||||
}
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
width: 24px;
|
||||
margin: 0 auto;
|
||||
|
||||
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
|
||||
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-control-zoom,
|
||||
.leaflet-oldie .leaflet-control-layers,
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper,
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
border: 1px solid #999;
|
||||
}
|
||||
|
||||
|
||||
/* div icon */
|
||||
|
||||
.leaflet-div-icon {
|
||||
background: #fff;
|
||||
border: 1px solid #666;
|
||||
}
|
||||
|
||||
|
||||
/* Tooltip */
|
||||
/* Base styles for the element that has a tooltip */
|
||||
.leaflet-tooltip {
|
||||
position: absolute;
|
||||
padding: 6px;
|
||||
background-color: #fff;
|
||||
border: 1px solid #fff;
|
||||
border-radius: 3px;
|
||||
color: #222;
|
||||
white-space: nowrap;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-tooltip.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-tooltip-top:before,
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
border: 6px solid transparent;
|
||||
background: transparent;
|
||||
content: "";
|
||||
}
|
||||
|
||||
/* Directions */
|
||||
|
||||
.leaflet-tooltip-bottom {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.leaflet-tooltip-top {
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-top:before {
|
||||
left: 50%;
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-top:before {
|
||||
bottom: 0;
|
||||
margin-bottom: -12px;
|
||||
border-top-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before {
|
||||
top: 0;
|
||||
margin-top: -12px;
|
||||
margin-left: -6px;
|
||||
border-bottom-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-left {
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-right {
|
||||
margin-left: 6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
top: 50%;
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before {
|
||||
right: 0;
|
||||
margin-right: -12px;
|
||||
border-left-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-right:before {
|
||||
left: 0;
|
||||
margin-left: -12px;
|
||||
border-right-color: #fff;
|
||||
}
|
||||
|
||||
/* Printing */
|
||||
|
||||
@media print {
|
||||
/* Prevent printers from removing background-images of controls. */
|
||||
.leaflet-control {
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
}
|
||||
Vendored
+6
File diff suppressed because one or more lines are too long
@@ -4,9 +4,20 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AIRCRAFT RADAR // INTERCEPT - See the Invisible</title>
|
||||
<!-- 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=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' %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.css') }}">
|
||||
<script src="{{ url_for('static', filename='vendor/leaflet/leaflet.js') }}"></script>
|
||||
{% else %}
|
||||
<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 %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/adsb_dashboard.css') }}">
|
||||
</head>
|
||||
|
||||
@@ -4,9 +4,20 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>VESSEL RADAR // INTERCEPT - See the Invisible</title>
|
||||
<!-- 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=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
{% endif %}
|
||||
<!-- Leaflet.js - Conditional CDN/Local loading -->
|
||||
{% if offline_settings.assets_source == 'local' %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.css') }}">
|
||||
<script src="{{ url_for('static', filename='vendor/leaflet/leaflet.js') }}"></script>
|
||||
{% else %}
|
||||
<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 %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/ais_dashboard.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
||||
</head>
|
||||
|
||||
+299
-71
@@ -6,11 +6,26 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>iNTERCEPT // See the Invisible</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<!-- Leaflet.js for APRS map -->
|
||||
<!-- 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=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
{% endif %}
|
||||
<!-- Leaflet.js for APRS map - Conditional CDN/Local loading -->
|
||||
{% if offline_settings.assets_source == 'local' %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.css') }}">
|
||||
<script src="{{ url_for('static', filename='vendor/leaflet/leaflet.js') }}"></script>
|
||||
{% else %}
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="" />
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
|
||||
<!-- Chart.js for signal strength graphs -->
|
||||
{% endif %}
|
||||
<!-- Chart.js for signal strength graphs - Conditional CDN/Local loading -->
|
||||
{% if offline_settings.assets_source == 'local' %}
|
||||
<script src="{{ url_for('static', filename='vendor/chartjs/chart.umd.min.js') }}"></script>
|
||||
{% else %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||
{% endif %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/index.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/acars.css') }}">
|
||||
@@ -22,6 +37,8 @@
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/device-cards.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/proximity-viz.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/spy-stations.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/meshtastic.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -105,67 +122,78 @@
|
||||
<!-- Right: Mode Selection -->
|
||||
<div class="welcome-modes">
|
||||
<h2>Select Mode</h2>
|
||||
<div class="mode-grid">
|
||||
<button class="mode-card" onclick="selectMode('pager')">
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg></span>
|
||||
<span class="mode-name">Pager</span>
|
||||
<span class="mode-desc">POCSAG/FLEX decoding</span>
|
||||
</button>
|
||||
<button class="mode-card" onclick="selectMode('sensor')">
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49m11.31-2.82a10 10 0 0 1 0 14.14m-14.14 0a10 10 0 0 1 0-14.14"/></svg></span>
|
||||
<span class="mode-name">433MHz</span>
|
||||
<span class="mode-desc">IoT sensor monitoring</span>
|
||||
</button>
|
||||
<a href="/adsb/dashboard" class="mode-card" style="text-decoration: none;">
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg></span>
|
||||
<span class="mode-name">Aircraft</span>
|
||||
<span class="mode-desc">ADS-B tracking</span>
|
||||
</a>
|
||||
<a href="/ais/dashboard" class="mode-card" style="text-decoration: none;">
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></svg></span>
|
||||
<span class="mode-name">Vessels</span>
|
||||
<span class="mode-desc">AIS ship tracking</span>
|
||||
</a>
|
||||
<button class="mode-card" onclick="selectMode('wifi')">
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor" stroke="none"/></svg></span>
|
||||
<span class="mode-name">WiFi</span>
|
||||
<span class="mode-desc">Network reconnaissance</span>
|
||||
</button>
|
||||
<button class="mode-card" onclick="selectMode('bluetooth')">
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg></span>
|
||||
<span class="mode-name">Bluetooth</span>
|
||||
<span class="mode-desc">Device discovery</span>
|
||||
</button>
|
||||
<button class="mode-card" onclick="selectMode('tscm')">
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span>
|
||||
<span class="mode-name">TSCM</span>
|
||||
<span class="mode-desc">Counter-surveillance</span>
|
||||
</button>
|
||||
<button class="mode-card" onclick="selectMode('satellite')">
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/><path d="m16 8 3-3"/><path d="M9 21a6 6 0 0 0-6-6"/></svg></span>
|
||||
<span class="mode-name">Satellite</span>
|
||||
<span class="mode-desc">Pass prediction</span>
|
||||
</button>
|
||||
<button class="mode-card" onclick="selectMode('listening')">
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg></span>
|
||||
<span class="mode-name">Listening Post</span>
|
||||
<span class="mode-desc">Frequency scanning</span>
|
||||
</button>
|
||||
<button class="mode-card" onclick="selectMode('aprs')">
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg></span>
|
||||
<span class="mode-name">APRS</span>
|
||||
<span class="mode-desc">Amateur radio</span>
|
||||
</button>
|
||||
<button class="mode-card" onclick="selectMode('rtlamr')">
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v4"/><path d="m16.2 7.8 2.9-2.9"/><path d="M18 12h4"/><path d="m16.2 16.2 2.9 2.9"/><path d="M12 18v4"/><path d="m4.9 19.1 2.9-2.9"/><path d="M2 12h4"/><path d="m4.9 4.9 2.9 2.9"/><circle cx="12" cy="12" r="4"/></svg></span>
|
||||
<span class="mode-name">Meters</span>
|
||||
<span class="mode-desc">Utility meters</span>
|
||||
</button>
|
||||
<button class="mode-card" onclick="selectMode('spystations')">
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg></span>
|
||||
<span class="mode-name">Spy Stations</span>
|
||||
<span class="mode-desc">Number stations & diplomatic</span>
|
||||
</button>
|
||||
|
||||
<!-- SDR / Radio Frequency -->
|
||||
<div class="mode-category">
|
||||
<h3 class="mode-category-title"><span class="mode-category-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49m11.31-2.82a10 10 0 0 1 0 14.14m-14.14 0a10 10 0 0 1 0-14.14"/></svg></span> SDR / Radio</h3>
|
||||
<div class="mode-grid mode-grid-compact">
|
||||
<button class="mode-card mode-card-sm" onclick="selectMode('pager')">
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg></span>
|
||||
<span class="mode-name">Pager</span>
|
||||
</button>
|
||||
<button class="mode-card mode-card-sm" onclick="selectMode('sensor')">
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg></span>
|
||||
<span class="mode-name">433MHz</span>
|
||||
</button>
|
||||
<a href="/adsb/dashboard" class="mode-card mode-card-sm" style="text-decoration: none;">
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg></span>
|
||||
<span class="mode-name">Aircraft</span>
|
||||
</a>
|
||||
<a href="/ais/dashboard" class="mode-card mode-card-sm" style="text-decoration: none;">
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/></svg></span>
|
||||
<span class="mode-name">Vessels</span>
|
||||
</a>
|
||||
<button class="mode-card mode-card-sm" onclick="selectMode('aprs')">
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg></span>
|
||||
<span class="mode-name">APRS</span>
|
||||
</button>
|
||||
<button class="mode-card mode-card-sm" onclick="selectMode('rtlamr')">
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg></span>
|
||||
<span class="mode-name">Meters</span>
|
||||
</button>
|
||||
<button class="mode-card mode-card-sm" onclick="selectMode('satellite')">
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/></svg></span>
|
||||
<span class="mode-name">Satellite</span>
|
||||
</button>
|
||||
<button class="mode-card mode-card-sm" onclick="selectMode('listening')">
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg></span>
|
||||
<span class="mode-name">Scanner</span>
|
||||
</button>
|
||||
<button class="mode-card mode-card-sm" onclick="selectMode('spystations')">
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg></span>
|
||||
<span class="mode-name">Spy Stations</span>
|
||||
</button>
|
||||
<button class="mode-card mode-card-sm" onclick="selectMode('meshtastic')">
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg></span>
|
||||
<span class="mode-name">Meshtastic</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Wireless -->
|
||||
<div class="mode-category">
|
||||
<h3 class="mode-category-title"><span class="mode-category-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><circle cx="12" cy="20" r="1" fill="currentColor"/></svg></span> Wireless</h3>
|
||||
<div class="mode-grid mode-grid-compact">
|
||||
<button class="mode-card mode-card-sm" onclick="selectMode('wifi')">
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor" stroke="none"/></svg></span>
|
||||
<span class="mode-name">WiFi</span>
|
||||
</button>
|
||||
<button class="mode-card mode-card-sm" onclick="selectMode('bluetooth')">
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg></span>
|
||||
<span class="mode-name">Bluetooth</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Security -->
|
||||
<div class="mode-category">
|
||||
<h3 class="mode-category-title"><span class="mode-category-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg></span> Security</h3>
|
||||
<div class="mode-grid mode-grid-compact">
|
||||
<button class="mode-card mode-card-sm" onclick="selectMode('tscm')">
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span>
|
||||
<span class="mode-name">TSCM</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -312,6 +340,7 @@
|
||||
<button class="mode-nav-btn" onclick="switchMode('satellite')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/><path d="m16 8 3-3"/><path d="M9 21a6 6 0 0 0-6-6"/></svg></span><span class="nav-label">Satellite</span></button>
|
||||
<button class="mode-nav-btn" onclick="switchMode('listening')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg></span><span class="nav-label">Listening Post</span></button>
|
||||
<button class="mode-nav-btn" onclick="switchMode('spystations')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg></span><span class="nav-label">Spy Stations</span></button>
|
||||
<button class="mode-nav-btn" onclick="switchMode('meshtastic')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg></span><span class="nav-label">Meshtastic</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mode-nav-dropdown" data-group="wireless">
|
||||
@@ -348,6 +377,10 @@
|
||||
</div>
|
||||
<div class="nav-divider"></div>
|
||||
<div class="nav-tools">
|
||||
<button class="nav-tool-btn" onclick="toggleAnimations()" title="Toggle Animations">
|
||||
<span class="icon-effects-on icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg></span>
|
||||
<span class="icon-effects-off icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/><line x1="2" y1="2" x2="22" y2="22"/></svg></span>
|
||||
</button>
|
||||
<button class="nav-tool-btn" onclick="toggleTheme()" title="Toggle Light/Dark Theme">
|
||||
<span class="icon-moon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg></span>
|
||||
<span class="icon-sun icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg></span>
|
||||
@@ -356,6 +389,7 @@
|
||||
id="depsBtn"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span></button>
|
||||
<a href="/controller/monitor" class="nav-tool-btn" title="Network Monitor - Multi-Agent View" style="text-decoration: none;"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></span></a>
|
||||
<a href="/controller/manage" class="nav-tool-btn" title="Manage Remote Agents" style="text-decoration: none;"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg></span></a>
|
||||
<button class="nav-tool-btn" onclick="showSettings()" title="Settings"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span></button>
|
||||
<button class="nav-tool-btn" onclick="showHelp()" title="Help & Documentation">?</button>
|
||||
<button class="nav-tool-btn" onclick="logout(event)" title="Logout">
|
||||
<span class="power-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg></span>
|
||||
@@ -378,6 +412,7 @@
|
||||
<button class="mobile-nav-btn" data-mode="satellite" onclick="switchMode('satellite')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/></svg></span> Sat</button>
|
||||
<button class="mobile-nav-btn" data-mode="listening" onclick="switchMode('listening')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg></span> Scanner</button>
|
||||
<button class="mobile-nav-btn" data-mode="spystations" onclick="switchMode('spystations')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg></span> Spy</button>
|
||||
<button class="mobile-nav-btn" data-mode="meshtastic" onclick="switchMode('meshtastic')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg></span> Mesh</button>
|
||||
</nav>
|
||||
|
||||
<!-- Mobile Drawer Overlay -->
|
||||
@@ -527,6 +562,8 @@
|
||||
|
||||
{% include 'partials/modes/spy-stations.html' %}
|
||||
|
||||
{% include 'partials/modes/meshtastic.html' %}
|
||||
|
||||
<button class="preset-btn" onclick="killAll()"
|
||||
style="width: 100%; margin-top: 10px; border-color: #ff3366; color: #ff3366;">
|
||||
Kill All Processes
|
||||
@@ -1542,6 +1579,124 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Meshtastic Messages Dashboard -->
|
||||
<div id="meshtasticVisuals" class="mesh-visuals-container" style="display: none;">
|
||||
<!-- Compact Status Strip -->
|
||||
<div class="mesh-stats-strip">
|
||||
<div class="mesh-strip-group">
|
||||
<button class="mesh-strip-sidebar-toggle" onclick="Meshtastic.toggleSidebar()" title="Toggle sidebar">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="mesh-strip-status">
|
||||
<span class="mesh-strip-dot disconnected" id="meshStripDot"></span>
|
||||
<span class="mesh-strip-status-text" id="meshStripStatus">Disconnected</span>
|
||||
</div>
|
||||
<select id="meshStripDevice" class="mesh-strip-select" title="Device">
|
||||
<option value="">Auto-detect</option>
|
||||
</select>
|
||||
<button class="mesh-strip-btn connect" id="meshStripConnectBtn" onclick="Meshtastic.start()">Connect</button>
|
||||
<button class="mesh-strip-btn disconnect" id="meshStripDisconnectBtn" onclick="Meshtastic.stop()" style="display: none;">Disconnect</button>
|
||||
</div>
|
||||
<div class="mesh-strip-divider"></div>
|
||||
<div class="mesh-strip-group">
|
||||
<div class="mesh-strip-stat">
|
||||
<span class="mesh-strip-value" id="meshStripNodeName">--</span>
|
||||
<span class="mesh-strip-label">NODE</span>
|
||||
</div>
|
||||
<div class="mesh-strip-stat">
|
||||
<span class="mesh-strip-value mesh-strip-id" id="meshStripNodeId">--</span>
|
||||
<span class="mesh-strip-label">ID</span>
|
||||
</div>
|
||||
<div class="mesh-strip-stat">
|
||||
<span class="mesh-strip-value" id="meshStripModel">--</span>
|
||||
<span class="mesh-strip-label">MODEL</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mesh-strip-divider"></div>
|
||||
<div class="mesh-strip-group">
|
||||
<div class="mesh-strip-stat">
|
||||
<span class="mesh-strip-value accent-cyan" id="meshStripMsgCount">0</span>
|
||||
<span class="mesh-strip-label">MSGS</span>
|
||||
</div>
|
||||
<div class="mesh-strip-stat">
|
||||
<span class="mesh-strip-value accent-green" id="meshStripNodeCount">0</span>
|
||||
<span class="mesh-strip-label">NODES</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Row (Messages + Map side by side) -->
|
||||
<div class="mesh-main-row">
|
||||
<!-- Messages Section -->
|
||||
<div class="mesh-messages-section">
|
||||
<div class="mesh-messages-header">
|
||||
<div class="mesh-messages-title">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 18px; height: 18px;">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
Messages
|
||||
</div>
|
||||
<div class="mesh-messages-filter">
|
||||
<select id="meshVisualsFilter" onchange="Meshtastic.applyFilter()">
|
||||
<option value="">All Channels</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mesh-messages-list" id="meshMessagesGrid">
|
||||
<div class="mesh-messages-empty">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M12 2v4m0 12v4M2 12h4m12 0h4"/>
|
||||
</svg>
|
||||
<p>Connect to a Meshtastic device to see messages</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Message Compose Box -->
|
||||
<div class="mesh-compose" id="meshCompose" style="display: none;">
|
||||
<div class="mesh-compose-header">
|
||||
<select id="meshComposeChannel" class="mesh-compose-channel" title="Channel to send on">
|
||||
<option value="0">CH 0</option>
|
||||
</select>
|
||||
<input type="text" id="meshComposeTo" placeholder="^all (broadcast)" class="mesh-compose-to" title="Destination node ID or ^all for broadcast">
|
||||
</div>
|
||||
<div class="mesh-compose-body">
|
||||
<input type="text" id="meshComposeText" placeholder="Type a message..." maxlength="237" class="mesh-compose-input" oninput="Meshtastic.updateCharCount()" onkeydown="Meshtastic.handleComposeKeydown(event)">
|
||||
<button onclick="Meshtastic.sendMessage()" class="mesh-compose-send" title="Send message">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 18px; height: 18px;">
|
||||
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mesh-compose-hint">
|
||||
<span id="meshComposeCount">0</span>/237
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Node Map -->
|
||||
<div class="mesh-map-section">
|
||||
<div class="mesh-map-header">
|
||||
<div class="mesh-map-title">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 18px; height: 18px;">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M12 2v4m0 12v4M2 12h4m12 0h4"/>
|
||||
</svg>
|
||||
Node Map
|
||||
</div>
|
||||
<div class="mesh-map-stats">
|
||||
<span>NODES: <span id="meshMapNodeCount">0</span></span>
|
||||
<span>WITH GPS: <span id="meshMapGpsCount">0</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="meshMap" class="mesh-map"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Device Intelligence Dashboard (above waterfall for prominence) -->
|
||||
<div class="recon-panel collapsed" id="reconPanel">
|
||||
<div class="recon-header" onclick="toggleReconCollapse()" style="cursor: pointer;">
|
||||
@@ -1625,6 +1780,7 @@
|
||||
<script src="{{ url_for('static', filename='js/modes/wifi.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/listening-post.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/spy-stations.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/meshtastic.js') }}"></script>
|
||||
|
||||
<script>
|
||||
// ============================================
|
||||
@@ -2148,7 +2304,7 @@
|
||||
const modeMap = {
|
||||
'pager': 'pager', 'sensor': '433',
|
||||
'satellite': 'satellite', 'wifi': 'wifi', 'bluetooth': 'bluetooth',
|
||||
'listening': 'listening', 'aprs': 'aprs', 'tscm': 'tscm'
|
||||
'listening': 'listening', 'aprs': 'aprs', 'tscm': 'tscm', 'meshtastic': 'meshtastic'
|
||||
};
|
||||
document.querySelectorAll('.mode-nav-btn').forEach(btn => {
|
||||
const label = btn.querySelector('.nav-label');
|
||||
@@ -2167,6 +2323,7 @@
|
||||
document.getElementById('tscmMode')?.classList.toggle('active', mode === 'tscm');
|
||||
document.getElementById('aisMode')?.classList.toggle('active', mode === 'ais');
|
||||
document.getElementById('spystationsMode')?.classList.toggle('active', mode === 'spystations');
|
||||
document.getElementById('meshtasticMode')?.classList.toggle('active', mode === 'meshtastic');
|
||||
const pagerStats = document.getElementById('pagerStats');
|
||||
const sensorStats = document.getElementById('sensorStats');
|
||||
const satelliteStats = document.getElementById('satelliteStats');
|
||||
@@ -2198,7 +2355,8 @@
|
||||
'aprs': 'APRS',
|
||||
'tscm': 'TSCM',
|
||||
'ais': 'AIS VESSELS',
|
||||
'spystations': 'SPY STATIONS'
|
||||
'spystations': 'SPY STATIONS',
|
||||
'meshtastic': 'MESHTASTIC'
|
||||
};
|
||||
const activeModeIndicator = document.getElementById('activeModeIndicator');
|
||||
if (activeModeIndicator) activeModeIndicator.innerHTML = '<span class="pulse-dot"></span>' + (modeNames[mode] || mode.toUpperCase());
|
||||
@@ -2209,6 +2367,7 @@
|
||||
const aprsVisuals = document.getElementById('aprsVisuals');
|
||||
const tscmVisuals = document.getElementById('tscmVisuals');
|
||||
const spyStationsVisuals = document.getElementById('spyStationsVisuals');
|
||||
const meshtasticVisuals = document.getElementById('meshtasticVisuals');
|
||||
if (wifiLayoutContainer) wifiLayoutContainer.style.display = mode === 'wifi' ? 'flex' : 'none';
|
||||
if (btLayoutContainer) btLayoutContainer.style.display = mode === 'bluetooth' ? 'flex' : 'none';
|
||||
if (satelliteVisuals) satelliteVisuals.style.display = mode === 'satellite' ? 'block' : 'none';
|
||||
@@ -2216,6 +2375,17 @@
|
||||
if (aprsVisuals) aprsVisuals.style.display = mode === 'aprs' ? 'flex' : 'none';
|
||||
if (tscmVisuals) tscmVisuals.style.display = mode === 'tscm' ? 'flex' : 'none';
|
||||
if (spyStationsVisuals) spyStationsVisuals.style.display = mode === 'spystations' ? 'flex' : 'none';
|
||||
if (meshtasticVisuals) meshtasticVisuals.style.display = mode === 'meshtastic' ? 'flex' : 'none';
|
||||
|
||||
// Hide sidebar by default for Meshtastic mode, show for others
|
||||
const mainContent = document.querySelector('.main-content');
|
||||
if (mainContent) {
|
||||
if (mode === 'meshtastic') {
|
||||
mainContent.classList.add('mesh-sidebar-hidden');
|
||||
} else {
|
||||
mainContent.classList.remove('mesh-sidebar-hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Show/hide mode-specific timeline containers
|
||||
const pagerTimelineContainer = document.getElementById('pagerTimelineContainer');
|
||||
@@ -2235,7 +2405,8 @@
|
||||
'aprs': 'APRS Tracker',
|
||||
'tscm': 'TSCM Counter-Surveillance',
|
||||
'ais': 'AIS Vessel Tracker',
|
||||
'spystations': 'Spy Stations'
|
||||
'spystations': 'Spy Stations',
|
||||
'meshtastic': 'Meshtastic Mesh Monitor'
|
||||
};
|
||||
const outputTitle = document.getElementById('outputTitle');
|
||||
if (outputTitle) outputTitle.textContent = titles[mode] || 'Signal Monitor';
|
||||
@@ -2253,7 +2424,7 @@
|
||||
const reconBtn = document.getElementById('reconBtn');
|
||||
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
|
||||
const reconPanel = document.getElementById('reconPanel');
|
||||
if (mode === 'satellite' || mode === 'listening' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') {
|
||||
if (mode === 'satellite' || mode === 'listening' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic') {
|
||||
if (reconPanel) reconPanel.style.display = 'none';
|
||||
if (reconBtn) reconBtn.style.display = 'none';
|
||||
if (intelBtn) intelBtn.style.display = 'none';
|
||||
@@ -2284,9 +2455,17 @@
|
||||
// Hide output console for modes with their own visualizations
|
||||
const outputEl = document.getElementById('output');
|
||||
const statusBar = document.querySelector('.status-bar');
|
||||
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'listening' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
|
||||
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'listening' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic') ? 'none' : 'block';
|
||||
if (statusBar) statusBar.style.display = (mode === 'satellite') ? 'none' : 'flex';
|
||||
|
||||
// Restore sidebar when leaving Meshtastic mode (user may have collapsed it)
|
||||
if (mode !== 'meshtastic') {
|
||||
const mainContent = document.querySelector('.main-content');
|
||||
if (mainContent) {
|
||||
mainContent.classList.remove('mesh-sidebar-hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Load interfaces and initialize visualizations when switching modes
|
||||
if (mode === 'wifi') {
|
||||
refreshWifiInterfaces();
|
||||
@@ -2316,18 +2495,26 @@
|
||||
}
|
||||
} else if (mode === 'spystations') {
|
||||
SpyStations.init();
|
||||
} else if (mode === 'meshtastic') {
|
||||
Meshtastic.init();
|
||||
// Fix map sizing after container becomes visible
|
||||
setTimeout(() => {
|
||||
Meshtastic.invalidateMap();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle window resize for maps (especially important on mobile orientation change)
|
||||
window.addEventListener('resize', function () {
|
||||
if (aprsMap) aprsMap.invalidateSize();
|
||||
if (typeof Meshtastic !== 'undefined') Meshtastic.invalidateMap();
|
||||
});
|
||||
|
||||
// Also handle orientation changes explicitly for mobile
|
||||
window.addEventListener('orientationchange', function () {
|
||||
setTimeout(() => {
|
||||
if (aprsMap) aprsMap.invalidateSize();
|
||||
if (typeof Meshtastic !== 'undefined') Meshtastic.invalidateMap();
|
||||
}, 200);
|
||||
});
|
||||
|
||||
@@ -2906,7 +3093,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Convert rtlamr data to our card format
|
||||
// Convert rtlamr data to our card format, preserving all raw fields
|
||||
const msg = {
|
||||
id: String(meterId),
|
||||
type: data.Type || 'Unknown',
|
||||
@@ -2914,7 +3101,8 @@
|
||||
unit: 'units',
|
||||
endpoint_type: msgData.EndpointType,
|
||||
endpoint_id: msgData.EndpointID,
|
||||
timestamp: new Date().toISOString()
|
||||
timestamp: new Date().toISOString(),
|
||||
rawMessage: msgData // Include all original fields for detailed display
|
||||
};
|
||||
|
||||
// Create card using SignalCards component
|
||||
@@ -8679,7 +8867,23 @@
|
||||
}).catch(err => console.warn('Failed to save theme to server:', err));
|
||||
}
|
||||
|
||||
// Load saved theme on page load
|
||||
// Animation toggle functions
|
||||
function toggleAnimations() {
|
||||
const html = document.documentElement;
|
||||
const currentState = html.getAttribute('data-animations');
|
||||
const newState = currentState === 'off' ? 'on' : 'off';
|
||||
|
||||
if (newState === 'on') {
|
||||
html.removeAttribute('data-animations');
|
||||
} else {
|
||||
html.setAttribute('data-animations', newState);
|
||||
}
|
||||
|
||||
// Save to localStorage for persistence
|
||||
localStorage.setItem('intercept-animations', newState);
|
||||
}
|
||||
|
||||
// Load saved theme and animations on page load
|
||||
(function () {
|
||||
// First apply localStorage theme for instant load (no flash)
|
||||
const localTheme = localStorage.getItem('intercept-theme');
|
||||
@@ -8687,6 +8891,12 @@
|
||||
document.documentElement.setAttribute('data-theme', 'light');
|
||||
}
|
||||
|
||||
// Apply animations preference
|
||||
const localAnimations = localStorage.getItem('intercept-animations');
|
||||
if (localAnimations === 'off') {
|
||||
document.documentElement.setAttribute('data-animations', 'off');
|
||||
}
|
||||
|
||||
// Then fetch from server to sync (in case changed on another device)
|
||||
fetch('/settings/theme')
|
||||
.then(r => r.json())
|
||||
@@ -11368,6 +11578,16 @@
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- WiFi Section -->
|
||||
@@ -11428,6 +11648,8 @@
|
||||
<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>
|
||||
@@ -11772,6 +11994,12 @@
|
||||
}, 600); // 600ms is enough for the user to perceive the color change
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Settings Modal -->
|
||||
{% include 'partials/settings-modal.html' %}
|
||||
|
||||
<!-- Settings Manager -->
|
||||
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<script>
|
||||
// Apply animations preference immediately to prevent flash
|
||||
(function() {
|
||||
var animations = localStorage.getItem('intercept-animations');
|
||||
if (animations === 'off') {
|
||||
document.documentElement.setAttribute('data-animations', 'off');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>iNTERCEPT // Restricted Access</title>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<!-- MESHTASTIC MODE -->
|
||||
<div id="meshtasticMode" class="mode-content mesh-sidebar-collapsed">
|
||||
<!-- Hide Sidebar Button -->
|
||||
<button class="mesh-hide-sidebar-btn" onclick="Meshtastic.toggleSidebar()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="11 17 6 12 11 7"/>
|
||||
<polyline points="18 17 13 12 18 7"/>
|
||||
</svg>
|
||||
Hide Sidebar
|
||||
</button>
|
||||
|
||||
<!-- Collapse Toggle for Options Panel -->
|
||||
<div class="mesh-sidebar-toggle" onclick="Meshtastic.toggleOptionsPanel()">
|
||||
<span class="mesh-sidebar-toggle-icon" id="meshSidebarIcon">▶</span>
|
||||
<span class="mesh-sidebar-toggle-text">Meshtastic Options</span>
|
||||
</div>
|
||||
|
||||
<!-- Collapsible Content -->
|
||||
<div class="mesh-sidebar-content" id="meshSidebarContent">
|
||||
<!-- Channels Panel - shown when connected -->
|
||||
<div class="section" id="meshChannelsSection" style="display: none;">
|
||||
<h3>Channels</h3>
|
||||
<div id="meshChannelsList">
|
||||
<!-- Populated by JavaScript -->
|
||||
</div>
|
||||
<button class="preset-btn" onclick="Meshtastic.refreshChannels()" style="width: 100%; margin-top: 8px;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 14px; height: 14px; margin-right: 6px; vertical-align: middle;">
|
||||
<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/>
|
||||
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
|
||||
</svg>
|
||||
Refresh Channels
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Help</h3>
|
||||
<button class="preset-btn" onclick="Meshtastic.showHelp()" style="width: 100%;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 14px; height: 14px; margin-right: 6px; vertical-align: middle;">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
|
||||
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||
</svg>
|
||||
About Meshtastic
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Resources</h3>
|
||||
<div style="display: flex; flex-direction: column; gap: 6px;">
|
||||
<a href="https://meshtastic.org" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
|
||||
Meshtastic.org
|
||||
</a>
|
||||
<a href="https://meshtastic.org/docs/" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Channel Configuration Modal -->
|
||||
<div id="meshChannelModal" class="signal-details-modal">
|
||||
<div class="signal-details-modal-backdrop" onclick="Meshtastic.closeChannelModal()"></div>
|
||||
<div class="signal-details-modal-content">
|
||||
<div class="signal-details-modal-header">
|
||||
<h3>Configure Channel <span id="meshModalChannelIndex">0</span></h3>
|
||||
<button class="signal-details-modal-close" onclick="Meshtastic.closeChannelModal()">×</button>
|
||||
</div>
|
||||
<div class="signal-details-modal-body">
|
||||
<div class="signal-details-section">
|
||||
<div class="signal-details-title">Channel Settings</div>
|
||||
<div class="form-group" style="margin-bottom: 12px;">
|
||||
<label style="font-size: 11px; color: var(--text-secondary); margin-bottom: 4px;">Channel Name (max 12 chars)</label>
|
||||
<input type="text" id="meshModalChannelName" maxlength="12" placeholder="MyChannel" style="width: 100%;">
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom: 12px;">
|
||||
<label style="font-size: 11px; color: var(--text-secondary); margin-bottom: 4px;">Encryption (PSK)</label>
|
||||
<select id="meshModalPskFormat" onchange="Meshtastic.onPskFormatChange()" style="width: 100%; margin-bottom: 8px;">
|
||||
<option value="keep">Keep Current</option>
|
||||
<option value="none">None (No Encryption)</option>
|
||||
<option value="default">Default (Public Key - NOT SECURE)</option>
|
||||
<option value="random">Random (Generate AES-256)</option>
|
||||
<option value="simple">Passphrase (simple:...)</option>
|
||||
<option value="base64">Base64 Key</option>
|
||||
<option value="hex">Hex Key (0x...)</option>
|
||||
</select>
|
||||
<div id="meshModalPskInputContainer" style="display: none;">
|
||||
<input type="text" id="meshModalPskValue" placeholder="Enter key..." style="width: 100%;">
|
||||
</div>
|
||||
<div id="meshModalPskWarning" style="display: none; background: rgba(255,193,7,0.1); border: 1px solid var(--accent-yellow); border-radius: 4px; padding: 8px; margin-top: 8px; font-size: 10px;">
|
||||
<strong style="color: var(--accent-yellow);">Warning:</strong>
|
||||
<span style="color: var(--text-secondary);">The default key is publicly known and provides no security.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="signal-details-modal-footer" style="display: flex; gap: 8px;">
|
||||
<button class="preset-btn" onclick="Meshtastic.closeChannelModal()" style="flex: 1;">Cancel</button>
|
||||
<button class="run-btn" onclick="Meshtastic.saveChannelConfig()" style="flex: 1;">Save Changes</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,167 @@
|
||||
<!-- Settings Modal -->
|
||||
<div id="settingsModal" class="settings-modal" onclick="if(event.target === this) hideSettings()">
|
||||
<div class="settings-content">
|
||||
<div class="settings-header">
|
||||
<h2>
|
||||
<span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
|
||||
Settings
|
||||
</h2>
|
||||
<button class="settings-close" onclick="hideSettings()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-tabs">
|
||||
<button class="settings-tab active" data-tab="offline" onclick="switchSettingsTab('offline')">Offline</button>
|
||||
<button class="settings-tab" data-tab="display" onclick="switchSettingsTab('display')">Display</button>
|
||||
<button class="settings-tab" data-tab="about" onclick="switchSettingsTab('about')">About</button>
|
||||
</div>
|
||||
|
||||
<!-- Offline Section -->
|
||||
<div id="settings-offline" class="settings-section active">
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title">Offline Mode</div>
|
||||
|
||||
<div class="settings-row">
|
||||
<div class="settings-label">
|
||||
<span class="settings-label-text">Enable Offline Mode</span>
|
||||
<span class="settings-label-desc">Use local assets instead of CDN</span>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="offlineEnabled" onchange="Settings.toggleOfflineMode(this.checked)">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title">Asset Sources</div>
|
||||
|
||||
<div class="settings-row">
|
||||
<div class="settings-label">
|
||||
<span class="settings-label-text">JavaScript/CSS Libraries</span>
|
||||
<span class="settings-label-desc">Leaflet, Chart.js</span>
|
||||
</div>
|
||||
<select id="assetsSource" class="settings-select" onchange="Settings.setAssetSource(this.value)">
|
||||
<option value="cdn">CDN (Online)</option>
|
||||
<option value="local">Local</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="settings-row">
|
||||
<div class="settings-label">
|
||||
<span class="settings-label-text">Web Fonts</span>
|
||||
<span class="settings-label-desc">Inter, JetBrains Mono</span>
|
||||
</div>
|
||||
<select id="fontsSource" class="settings-select" onchange="Settings.setFontsSource(this.value)">
|
||||
<option value="cdn">Google Fonts (Online)</option>
|
||||
<option value="local">Local</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title">Map Tiles</div>
|
||||
|
||||
<div class="settings-row">
|
||||
<div class="settings-label">
|
||||
<span class="settings-label-text">Tile Provider</span>
|
||||
<span class="settings-label-desc">Map background imagery</span>
|
||||
</div>
|
||||
<select id="tileProvider" class="settings-select" onchange="Settings.setTileProvider(this.value)">
|
||||
<option value="openstreetmap">OpenStreetMap</option>
|
||||
<option value="cartodb_dark">CartoDB Dark</option>
|
||||
<option value="cartodb_light">CartoDB Positron</option>
|
||||
<option value="esri_world">ESRI World Imagery</option>
|
||||
<option value="custom">Custom URL</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="settings-row custom-url-row" id="customTileUrlRow" style="display: none;">
|
||||
<div class="settings-label" style="width: 100%;">
|
||||
<span class="settings-label-text">Custom Tile URL</span>
|
||||
<span class="settings-label-desc">e.g., http://localhost:8080/{z}/{x}/{y}.png</span>
|
||||
<input type="text" id="customTileUrl" class="settings-input"
|
||||
placeholder="http://tile-server/{z}/{x}/{y}.png"
|
||||
onchange="Settings.setCustomTileUrl(this.value)">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title">Local Asset Status</div>
|
||||
<div class="asset-status" id="assetStatus">
|
||||
<div class="asset-status-row">
|
||||
<span class="asset-name">Leaflet JS/CSS</span>
|
||||
<span class="asset-badge checking" id="statusLeaflet">Checking...</span>
|
||||
</div>
|
||||
<div class="asset-status-row">
|
||||
<span class="asset-name">Chart.js</span>
|
||||
<span class="asset-badge checking" id="statusChartjs">Checking...</span>
|
||||
</div>
|
||||
<div class="asset-status-row">
|
||||
<span class="asset-name">Inter Font</span>
|
||||
<span class="asset-badge checking" id="statusInter">Checking...</span>
|
||||
</div>
|
||||
<div class="asset-status-row">
|
||||
<span class="asset-name">JetBrains Mono</span>
|
||||
<span class="asset-badge checking" id="statusJetBrains">Checking...</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="check-assets-btn" onclick="Settings.checkAssets()">
|
||||
Check Assets
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-info">
|
||||
<strong>Note:</strong> Changes to asset sources require a page reload to take effect.
|
||||
Local assets must be available in <code>/static/vendor/</code>.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Display Section -->
|
||||
<div id="settings-display" class="settings-section">
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title">Visual Preferences</div>
|
||||
|
||||
<div class="settings-row">
|
||||
<div class="settings-label">
|
||||
<span class="settings-label-text">Theme</span>
|
||||
<span class="settings-label-desc">Color scheme preference</span>
|
||||
</div>
|
||||
<select id="themeSelect" class="settings-select" onchange="setThemePreference(this.value)">
|
||||
<option value="dark">Dark</option>
|
||||
<option value="light">Light</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="settings-row">
|
||||
<div class="settings-label">
|
||||
<span class="settings-label-text">Animations</span>
|
||||
<span class="settings-label-desc">Enable visual effects and animations</span>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="animationsEnabled" checked onchange="setAnimationsEnabled(this.checked)">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- About Section -->
|
||||
<div id="settings-about" class="settings-section">
|
||||
<div class="settings-group">
|
||||
<div class="about-info">
|
||||
<p><strong>iNTERCEPT</strong> - Signal Intelligence Platform</p>
|
||||
<p>Version: <span class="about-version">{{ version }}</span></p>
|
||||
<p>
|
||||
A unified web interface for software-defined radio (SDR) tools,
|
||||
supporting pager decoding, sensor monitoring, aircraft tracking,
|
||||
WiFi/Bluetooth scanning, and more.
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://github.com/intercept" target="_blank">GitHub Repository</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -4,9 +4,20 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SATELLITE COMMAND // iNTERCEPT - See the Invisible</title>
|
||||
<!-- 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=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' %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.css') }}">
|
||||
<script src="{{ url_for('static', filename='vendor/leaflet/leaflet.js') }}"></script>
|
||||
{% else %}
|
||||
<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 %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/satellite_dashboard.css') }}">
|
||||
</head>
|
||||
|
||||
@@ -238,6 +238,9 @@ def _check_fallback_tools(caps: SystemCapabilities) -> None:
|
||||
# Check btmgmt
|
||||
caps.has_btmgmt = shutil.which('btmgmt') is not None
|
||||
|
||||
# Check ubertooth tools (Ubertooth One hardware)
|
||||
caps.has_ubertooth = shutil.which('ubertooth-btle') is not None
|
||||
|
||||
# Check CAP_NET_ADMIN for non-root users
|
||||
if not caps.is_root:
|
||||
_check_capabilities_permission(caps)
|
||||
|
||||
@@ -531,6 +531,16 @@ class FallbackScanner:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Try ubertooth (raw packet capture with Ubertooth One hardware)
|
||||
try:
|
||||
from .ubertooth_scanner import UbertoothScanner
|
||||
self._active_scanner = UbertoothScanner(on_observation=self._on_observation)
|
||||
if self._active_scanner.start():
|
||||
self._backend = 'ubertooth'
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.error("No fallback scanner available")
|
||||
return False
|
||||
|
||||
|
||||
@@ -407,6 +407,7 @@ class SystemCapabilities:
|
||||
has_hcitool: bool = False
|
||||
has_bluetoothctl: bool = False
|
||||
has_btmgmt: bool = False
|
||||
has_ubertooth: bool = False
|
||||
|
||||
# Recommended backend
|
||||
recommended_backend: str = 'none'
|
||||
@@ -421,7 +422,8 @@ class SystemCapabilities:
|
||||
(self.has_dbus and self.has_bluez and len(self.adapters) > 0) or
|
||||
self.has_bleak or
|
||||
self.has_hcitool or
|
||||
self.has_bluetoothctl
|
||||
self.has_bluetoothctl or
|
||||
self.has_ubertooth
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
@@ -442,6 +444,7 @@ class SystemCapabilities:
|
||||
'has_hcitool': self.has_hcitool,
|
||||
'has_bluetoothctl': self.has_bluetoothctl,
|
||||
'has_btmgmt': self.has_btmgmt,
|
||||
'has_ubertooth': self.has_ubertooth,
|
||||
'preferred_backend': self.recommended_backend, # Alias for frontend
|
||||
'recommended_backend': self.recommended_backend,
|
||||
'issues': self.issues,
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
"""
|
||||
Ubertooth One BLE scanner backend.
|
||||
|
||||
Uses ubertooth-btle for passive BLE packet capture across all 40 channels.
|
||||
Provides enhanced sniffing capabilities compared to standard Bluetooth adapters.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from typing import Callable, Optional
|
||||
|
||||
from .constants import (
|
||||
ADDRESS_TYPE_PUBLIC,
|
||||
ADDRESS_TYPE_RANDOM,
|
||||
)
|
||||
from .models import BTObservation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Ubertooth-specific timeout for subprocess operations
|
||||
UBERTOOTH_STARTUP_TIMEOUT = 5.0
|
||||
|
||||
|
||||
class UbertoothScanner:
|
||||
"""
|
||||
BLE scanner using Ubertooth One hardware via ubertooth-btle.
|
||||
|
||||
Captures raw BLE advertisements passively across all 40 BLE channels.
|
||||
Provides richer data than standard adapters including raw advertising payloads.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device_index: int = 0,
|
||||
on_observation: Optional[Callable[[BTObservation], None]] = None,
|
||||
):
|
||||
"""
|
||||
Initialize Ubertooth scanner.
|
||||
|
||||
Args:
|
||||
device_index: Ubertooth device index (for systems with multiple Ubertooths)
|
||||
on_observation: Callback for each BLE observation
|
||||
"""
|
||||
self._device_index = device_index
|
||||
self._on_observation = on_observation
|
||||
self._process: Optional[subprocess.Popen] = None
|
||||
self._is_scanning = False
|
||||
self._reader_thread: Optional[threading.Thread] = None
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
@staticmethod
|
||||
def is_available() -> bool:
|
||||
"""Check if ubertooth-btle is available on the system."""
|
||||
return shutil.which('ubertooth-btle') is not None
|
||||
|
||||
def start(self) -> bool:
|
||||
"""
|
||||
Start Ubertooth BLE scanning.
|
||||
|
||||
Spawns ubertooth-btle in advertisement-only mode (-n flag).
|
||||
|
||||
Returns:
|
||||
True if scanning started successfully, False otherwise.
|
||||
"""
|
||||
if not self.is_available():
|
||||
logger.error("ubertooth-btle not found in PATH")
|
||||
return False
|
||||
|
||||
if self._is_scanning:
|
||||
logger.warning("Ubertooth scanner already running")
|
||||
return True
|
||||
|
||||
try:
|
||||
# Build command: ubertooth-btle -n -U <device_index>
|
||||
# -n = advertisements only (no follow mode)
|
||||
# -U = device index for multiple Ubertooths
|
||||
cmd = ['ubertooth-btle', '-n']
|
||||
if self._device_index > 0:
|
||||
cmd.extend(['-U', str(self._device_index)])
|
||||
|
||||
self._process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1, # Line buffered
|
||||
)
|
||||
|
||||
self._stop_event.clear()
|
||||
self._reader_thread = threading.Thread(
|
||||
target=self._read_output,
|
||||
daemon=True,
|
||||
name='ubertooth-reader'
|
||||
)
|
||||
self._reader_thread.start()
|
||||
self._is_scanning = True
|
||||
logger.info(f"Ubertooth scanner started (device index: {self._device_index})")
|
||||
return True
|
||||
|
||||
except FileNotFoundError:
|
||||
logger.error("ubertooth-btle not found")
|
||||
return False
|
||||
except PermissionError:
|
||||
logger.error("ubertooth-btle requires appropriate permissions (try running as root)")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start Ubertooth scanner: {e}")
|
||||
return False
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop Ubertooth scanning and clean up resources."""
|
||||
self._stop_event.set()
|
||||
|
||||
if self._process:
|
||||
try:
|
||||
self._process.terminate()
|
||||
self._process.wait(timeout=2.0)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("Ubertooth process did not terminate, killing")
|
||||
self._process.kill()
|
||||
self._process.wait(timeout=1.0)
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping Ubertooth process: {e}")
|
||||
finally:
|
||||
self._process = None
|
||||
|
||||
if self._reader_thread:
|
||||
self._reader_thread.join(timeout=2.0)
|
||||
self._reader_thread = None
|
||||
|
||||
self._is_scanning = False
|
||||
logger.info("Ubertooth scanner stopped")
|
||||
|
||||
@property
|
||||
def is_scanning(self) -> bool:
|
||||
"""Return whether the scanner is currently active."""
|
||||
return self._is_scanning
|
||||
|
||||
def _read_output(self) -> None:
|
||||
"""
|
||||
Background thread to read and parse ubertooth-btle output.
|
||||
|
||||
Output format example:
|
||||
systime=1349412883 freq=2402 addr=8e89bed6 delta_t=38.441 ms 00 17 ab cd ef 01 22 ...
|
||||
"""
|
||||
try:
|
||||
while not self._stop_event.is_set() and self._process:
|
||||
line = self._process.stdout.readline()
|
||||
if not line:
|
||||
# Process ended
|
||||
break
|
||||
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# Skip non-packet lines (errors, status messages)
|
||||
if not line.startswith('systime='):
|
||||
# Log errors from stderr would go here if needed
|
||||
continue
|
||||
|
||||
try:
|
||||
observation = self._parse_advertisement(line)
|
||||
if observation and self._on_observation:
|
||||
self._on_observation(observation)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error parsing Ubertooth output: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ubertooth reader thread error: {e}")
|
||||
finally:
|
||||
self._is_scanning = False
|
||||
|
||||
def _parse_advertisement(self, line: str) -> Optional[BTObservation]:
|
||||
"""
|
||||
Parse a single ubertooth-btle output line into a BTObservation.
|
||||
|
||||
Format: systime=<epoch> freq=<mhz> addr=<access_addr> delta_t=<ms> ms <hex bytes...>
|
||||
|
||||
The hex bytes contain the BLE PDU:
|
||||
- Byte 0: PDU type and header flags
|
||||
- Byte 1: Length
|
||||
- Bytes 2-7: Advertiser MAC address (reversed byte order)
|
||||
- Remaining: Advertising data payload
|
||||
|
||||
Args:
|
||||
line: Raw output line from ubertooth-btle
|
||||
|
||||
Returns:
|
||||
BTObservation if successfully parsed, None otherwise.
|
||||
"""
|
||||
# Parse the structured prefix
|
||||
# Example: systime=1349412883 freq=2402 addr=8e89bed6 delta_t=38.441 ms 00 17 ab cd ef ...
|
||||
match = re.match(
|
||||
r'systime=(\d+)\s+freq=(\d+)\s+addr=([0-9a-fA-F]+)\s+delta_t=[\d.]+\s+ms\s+(.+)',
|
||||
line
|
||||
)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
# Parse hex bytes
|
||||
hex_data = match.group(4).strip()
|
||||
try:
|
||||
raw_bytes = bytes.fromhex(hex_data.replace(' ', ''))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
if len(raw_bytes) < 8:
|
||||
# Need at least PDU header + MAC address
|
||||
return None
|
||||
|
||||
# Parse PDU header
|
||||
pdu_type = raw_bytes[0] & 0x0F
|
||||
# tx_add = (raw_bytes[0] >> 6) & 0x01 # TxAdd: 1 = random address
|
||||
length = raw_bytes[1]
|
||||
|
||||
# Validate length
|
||||
if len(raw_bytes) < 2 + length:
|
||||
return None
|
||||
|
||||
# Extract advertiser address (bytes 2-7, reversed)
|
||||
# BLE addresses are transmitted LSB first
|
||||
addr_bytes = raw_bytes[2:8]
|
||||
address = ':'.join(f'{b:02X}' for b in reversed(addr_bytes))
|
||||
|
||||
# Determine address type from PDU type and TxAdd flag
|
||||
tx_add = (raw_bytes[0] >> 6) & 0x01
|
||||
address_type = ADDRESS_TYPE_RANDOM if tx_add else ADDRESS_TYPE_PUBLIC
|
||||
|
||||
# Parse advertising data payload (after MAC address)
|
||||
adv_data = raw_bytes[8:2 + length] if length > 6 else b''
|
||||
|
||||
# Parse advertising data structures
|
||||
name = None
|
||||
manufacturer_id = None
|
||||
manufacturer_data = None
|
||||
service_uuids = []
|
||||
service_data = {}
|
||||
tx_power = None
|
||||
|
||||
# Parse AD structures: each is [length][type][data...]
|
||||
i = 0
|
||||
while i < len(adv_data):
|
||||
if i >= len(adv_data):
|
||||
break
|
||||
ad_len = adv_data[i]
|
||||
if ad_len == 0 or i + 1 + ad_len > len(adv_data):
|
||||
break
|
||||
|
||||
ad_type = adv_data[i + 1]
|
||||
ad_payload = adv_data[i + 2:i + 1 + ad_len]
|
||||
|
||||
# 0x01 = Flags
|
||||
# 0x02/0x03 = Incomplete/Complete list of 16-bit UUIDs
|
||||
if ad_type in (0x02, 0x03) and len(ad_payload) >= 2:
|
||||
for j in range(0, len(ad_payload), 2):
|
||||
if j + 2 <= len(ad_payload):
|
||||
uuid16 = int.from_bytes(ad_payload[j:j + 2], 'little')
|
||||
service_uuids.append(f'{uuid16:04X}')
|
||||
|
||||
# 0x06/0x07 = Incomplete/Complete list of 128-bit UUIDs
|
||||
elif ad_type in (0x06, 0x07) and len(ad_payload) >= 16:
|
||||
for j in range(0, len(ad_payload), 16):
|
||||
if j + 16 <= len(ad_payload):
|
||||
uuid_bytes = ad_payload[j:j + 16]
|
||||
uuid128 = '-'.join([
|
||||
uuid_bytes[15:11:-1].hex(),
|
||||
uuid_bytes[11:9:-1].hex(),
|
||||
uuid_bytes[9:7:-1].hex(),
|
||||
uuid_bytes[7:5:-1].hex(),
|
||||
uuid_bytes[5::-1].hex(),
|
||||
])
|
||||
service_uuids.append(uuid128.upper())
|
||||
|
||||
# 0x08/0x09 = Shortened/Complete Local Name
|
||||
elif ad_type in (0x08, 0x09):
|
||||
try:
|
||||
name = ad_payload.decode('utf-8', errors='replace')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 0x0A = TX Power Level
|
||||
elif ad_type == 0x0A and len(ad_payload) >= 1:
|
||||
# Signed 8-bit value
|
||||
tx_power = ad_payload[0] if ad_payload[0] < 128 else ad_payload[0] - 256
|
||||
|
||||
# 0xFF = Manufacturer Specific Data
|
||||
elif ad_type == 0xFF and len(ad_payload) >= 2:
|
||||
manufacturer_id = int.from_bytes(ad_payload[0:2], 'little')
|
||||
manufacturer_data = bytes(ad_payload[2:])
|
||||
|
||||
# 0x16 = Service Data (16-bit UUID)
|
||||
elif ad_type == 0x16 and len(ad_payload) >= 2:
|
||||
svc_uuid = f'{int.from_bytes(ad_payload[0:2], "little"):04X}'
|
||||
service_data[svc_uuid] = bytes(ad_payload[2:])
|
||||
|
||||
# 0x20 = Service Data (32-bit UUID)
|
||||
elif ad_type == 0x20 and len(ad_payload) >= 4:
|
||||
svc_uuid = f'{int.from_bytes(ad_payload[0:4], "little"):08X}'
|
||||
service_data[svc_uuid] = bytes(ad_payload[4:])
|
||||
|
||||
# 0x21 = Service Data (128-bit UUID)
|
||||
elif ad_type == 0x21 and len(ad_payload) >= 16:
|
||||
uuid_bytes = ad_payload[0:16]
|
||||
svc_uuid = '-'.join([
|
||||
uuid_bytes[15:11:-1].hex(),
|
||||
uuid_bytes[11:9:-1].hex(),
|
||||
uuid_bytes[9:7:-1].hex(),
|
||||
uuid_bytes[7:5:-1].hex(),
|
||||
uuid_bytes[5::-1].hex(),
|
||||
]).upper()
|
||||
service_data[svc_uuid] = bytes(ad_payload[16:])
|
||||
|
||||
i += 1 + ad_len
|
||||
|
||||
# Determine if connectable from PDU type
|
||||
# ADV_IND (0x00) and ADV_DIRECT_IND (0x01) are connectable
|
||||
is_connectable = pdu_type in (0x00, 0x01)
|
||||
|
||||
return BTObservation(
|
||||
timestamp=datetime.now(),
|
||||
address=address,
|
||||
address_type=address_type,
|
||||
rssi=None, # Ubertooth doesn't provide RSSI in standard mode
|
||||
tx_power=tx_power,
|
||||
name=name,
|
||||
manufacturer_id=manufacturer_id,
|
||||
manufacturer_data=manufacturer_data,
|
||||
service_uuids=service_uuids,
|
||||
service_data=service_data,
|
||||
is_connectable=is_connectable,
|
||||
)
|
||||
+330
-7
@@ -25,10 +25,12 @@ logger = get_logger('intercept.meshtastic')
|
||||
try:
|
||||
import meshtastic
|
||||
import meshtastic.serial_interface
|
||||
from meshtastic import BROADCAST_ADDR
|
||||
from pubsub import pub
|
||||
HAS_MESHTASTIC = True
|
||||
except ImportError:
|
||||
HAS_MESHTASTIC = False
|
||||
BROADCAST_ADDR = 0xFFFFFFFF # Fallback if SDK not installed
|
||||
logger.warning("Meshtastic SDK not installed. Install with: pip install meshtastic")
|
||||
|
||||
|
||||
@@ -44,20 +46,25 @@ class MeshtasticMessage:
|
||||
snr: float | None
|
||||
hop_limit: int | None
|
||||
timestamp: datetime
|
||||
from_name: str | None = None
|
||||
to_name: str | None = None
|
||||
raw_packet: dict = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
'type': 'meshtastic',
|
||||
'from': self.from_id,
|
||||
'from_name': self.from_name,
|
||||
'to': self.to_id,
|
||||
'to_name': self.to_name,
|
||||
'message': self.message,
|
||||
'text': self.message, # Alias for frontend compatibility
|
||||
'portnum': self.portnum,
|
||||
'channel': self.channel,
|
||||
'rssi': self.rssi,
|
||||
'snr': self.snr,
|
||||
'hop_limit': self.hop_limit,
|
||||
'timestamp': self.timestamp.isoformat(),
|
||||
'timestamp': self.timestamp.timestamp(), # Unix seconds for frontend
|
||||
}
|
||||
|
||||
|
||||
@@ -97,6 +104,38 @@ class ChannelConfig:
|
||||
return 'unknown'
|
||||
|
||||
|
||||
@dataclass
|
||||
class MeshNode:
|
||||
"""Tracked Meshtastic node with position and metadata."""
|
||||
num: int
|
||||
user_id: str
|
||||
long_name: str
|
||||
short_name: str
|
||||
hw_model: str
|
||||
latitude: float | None = None
|
||||
longitude: float | None = None
|
||||
altitude: int | None = None
|
||||
battery_level: int | None = None
|
||||
snr: float | None = None
|
||||
last_heard: datetime | None = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
'num': self.num,
|
||||
'id': self.user_id or f"!{self.num:08x}",
|
||||
'long_name': self.long_name,
|
||||
'short_name': self.short_name,
|
||||
'hw_model': self.hw_model,
|
||||
'latitude': self.latitude,
|
||||
'longitude': self.longitude,
|
||||
'altitude': self.altitude,
|
||||
'battery_level': self.battery_level,
|
||||
'snr': self.snr,
|
||||
'last_heard': self.last_heard.isoformat() if self.last_heard else None,
|
||||
'has_position': self.latitude is not None and self.longitude is not None,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class NodeInfo:
|
||||
"""Meshtastic node information."""
|
||||
@@ -132,6 +171,7 @@ class MeshtasticClient:
|
||||
self._running = False
|
||||
self._callback: Callable[[MeshtasticMessage], None] | None = None
|
||||
self._lock = threading.Lock()
|
||||
self._nodes: dict[int, MeshNode] = {} # num -> MeshNode
|
||||
self._device_path: str | None = None
|
||||
self._error: str | None = None
|
||||
|
||||
@@ -230,32 +270,89 @@ class MeshtasticClient:
|
||||
def _on_connection(self, interface, topic=None) -> None:
|
||||
"""Handle connection established event."""
|
||||
logger.info("Meshtastic connection established")
|
||||
# Sync nodes from device's nodeDB so names are available for messages
|
||||
self._sync_nodes_from_interface()
|
||||
# Try to set device time from host computer
|
||||
self._sync_device_time()
|
||||
|
||||
def _on_disconnect(self, interface, topic=None) -> None:
|
||||
"""Handle connection lost event."""
|
||||
logger.warning("Meshtastic connection lost")
|
||||
self._running = False
|
||||
|
||||
def _sync_device_time(self) -> None:
|
||||
"""Sync device time from host computer."""
|
||||
if not self._interface:
|
||||
return
|
||||
try:
|
||||
# Try to set the device's time using the SDK
|
||||
import time
|
||||
current_time = int(time.time())
|
||||
if hasattr(self._interface, 'localNode') and self._interface.localNode:
|
||||
local_node = self._interface.localNode
|
||||
if hasattr(local_node, 'setTime'):
|
||||
local_node.setTime(current_time)
|
||||
logger.info(f"Set device time to {current_time}")
|
||||
elif hasattr(self._interface, 'sendAdmin'):
|
||||
# Alternative: send admin message with time
|
||||
logger.debug("setTime not available, device time not synced")
|
||||
else:
|
||||
logger.debug("localNode not available, device time not synced")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to sync device time: {e}")
|
||||
|
||||
def _on_receive(self, packet: dict, interface) -> None:
|
||||
"""Handle received packet from Meshtastic device."""
|
||||
if not self._callback:
|
||||
return
|
||||
|
||||
try:
|
||||
decoded = packet.get('decoded', {})
|
||||
from_num = packet.get('from', 0)
|
||||
to_num = packet.get('to', 0)
|
||||
portnum = decoded.get('portnum', 'UNKNOWN')
|
||||
|
||||
# Track node from packet (always, even for filtered messages)
|
||||
self._track_node_from_packet(packet, decoded, portnum)
|
||||
|
||||
# Skip callback if none set
|
||||
if not self._callback:
|
||||
return
|
||||
|
||||
# Filter out internal protocol messages that aren't useful to users
|
||||
ignored_portnums = {
|
||||
'ROUTING_APP', # Mesh routing/acknowledgments
|
||||
'ADMIN_APP', # Admin commands
|
||||
'REPLY_APP', # Internal replies
|
||||
'STORE_FORWARD_APP', # Store and forward protocol
|
||||
'RANGE_TEST_APP', # Range testing
|
||||
'PAXCOUNTER_APP', # People counter
|
||||
'REMOTE_HARDWARE_APP', # Remote hardware control
|
||||
'SIMULATOR_APP', # Simulator
|
||||
'MAP_REPORT_APP', # Map reporting
|
||||
'TELEMETRY_APP', # Device telemetry (battery, etc.) - too noisy
|
||||
'POSITION_APP', # Position updates - used for map, not messages
|
||||
'NODEINFO_APP', # Node info - used for tracking, not messages
|
||||
}
|
||||
if portnum in ignored_portnums:
|
||||
logger.debug(f"Ignoring {portnum} message from {from_num}")
|
||||
return
|
||||
|
||||
# Extract text message if present
|
||||
message = None
|
||||
portnum = decoded.get('portnum', 'UNKNOWN')
|
||||
if portnum == 'TEXT_MESSAGE_APP':
|
||||
message = decoded.get('text')
|
||||
elif portnum in ('WAYPOINT_APP', 'TRACEROUTE_APP'):
|
||||
# Show these as informational messages
|
||||
message = f"[{portnum}]"
|
||||
elif 'payload' in decoded:
|
||||
# For other message types, include payload info
|
||||
message = f"[{portnum}]"
|
||||
|
||||
# Look up node names - try cache first, then SDK's nodeDB
|
||||
from_name = self._lookup_node_name(from_num)
|
||||
to_name = self._lookup_node_name(to_num) if to_num != BROADCAST_ADDR else None
|
||||
|
||||
msg = MeshtasticMessage(
|
||||
from_id=self._format_node_id(packet.get('from', 0)),
|
||||
to_id=self._format_node_id(packet.get('to', 0)),
|
||||
from_id=self._format_node_id(from_num),
|
||||
to_id=self._format_node_id(to_num),
|
||||
message=message,
|
||||
portnum=portnum,
|
||||
channel=packet.get('channel', 0),
|
||||
@@ -263,6 +360,8 @@ class MeshtasticClient:
|
||||
snr=packet.get('rxSnr'),
|
||||
hop_limit=packet.get('hopLimit'),
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
from_name=from_name,
|
||||
to_name=to_name,
|
||||
raw_packet=packet,
|
||||
)
|
||||
|
||||
@@ -272,6 +371,101 @@ class MeshtasticClient:
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing Meshtastic packet: {e}")
|
||||
|
||||
def _track_node_from_packet(self, packet: dict, decoded: dict, portnum: str) -> None:
|
||||
"""Update node tracking from received packet."""
|
||||
from_num = packet.get('from', 0)
|
||||
if from_num == 0 or from_num == 0xFFFFFFFF:
|
||||
return
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Get or create node entry
|
||||
if from_num not in self._nodes:
|
||||
self._nodes[from_num] = MeshNode(
|
||||
num=from_num,
|
||||
user_id=f"!{from_num:08x}",
|
||||
long_name='',
|
||||
short_name='',
|
||||
hw_model='UNKNOWN',
|
||||
)
|
||||
|
||||
node = self._nodes[from_num]
|
||||
node.last_heard = now
|
||||
node.snr = packet.get('rxSnr', node.snr)
|
||||
|
||||
# Parse NODEINFO_APP for user details
|
||||
if portnum == 'NODEINFO_APP':
|
||||
user = decoded.get('user', {})
|
||||
if user:
|
||||
node.long_name = user.get('longName', node.long_name)
|
||||
node.short_name = user.get('shortName', node.short_name)
|
||||
node.hw_model = user.get('hwModel', node.hw_model)
|
||||
if user.get('id'):
|
||||
node.user_id = user.get('id')
|
||||
|
||||
# Parse POSITION_APP for location
|
||||
elif portnum == 'POSITION_APP':
|
||||
position = decoded.get('position', {})
|
||||
if position:
|
||||
lat = position.get('latitude') or position.get('latitudeI')
|
||||
lon = position.get('longitude') or position.get('longitudeI')
|
||||
|
||||
# Handle integer format (latitudeI/longitudeI are in 1e-7 degrees)
|
||||
if isinstance(lat, int) and abs(lat) > 1000:
|
||||
lat = lat / 1e7
|
||||
if isinstance(lon, int) and abs(lon) > 1000:
|
||||
lon = lon / 1e7
|
||||
|
||||
if lat is not None and lon is not None:
|
||||
node.latitude = lat
|
||||
node.longitude = lon
|
||||
node.altitude = position.get('altitude', node.altitude)
|
||||
|
||||
# Parse TELEMETRY_APP for battery
|
||||
elif portnum == 'TELEMETRY_APP':
|
||||
telemetry = decoded.get('telemetry', {})
|
||||
device_metrics = telemetry.get('deviceMetrics', {})
|
||||
if device_metrics:
|
||||
battery = device_metrics.get('batteryLevel')
|
||||
if battery is not None:
|
||||
node.battery_level = battery
|
||||
|
||||
def _lookup_node_name(self, node_num: int) -> str | None:
|
||||
"""Look up a node's name by its number."""
|
||||
if node_num == 0 or node_num == BROADCAST_ADDR:
|
||||
return None
|
||||
|
||||
# Try our cache first
|
||||
if node_num in self._nodes:
|
||||
node = self._nodes[node_num]
|
||||
name = node.short_name or node.long_name
|
||||
if name:
|
||||
return name
|
||||
|
||||
# Try SDK's nodeDB with various key formats
|
||||
if self._interface and hasattr(self._interface, 'nodes') and self._interface.nodes:
|
||||
nodes = self._interface.nodes
|
||||
|
||||
# Try direct lookup with different key formats
|
||||
for key in [node_num, f"!{node_num:08x}", f"!{node_num:x}", str(node_num)]:
|
||||
if key in nodes:
|
||||
user = nodes[key].get('user', {})
|
||||
name = user.get('shortName') or user.get('longName')
|
||||
if name:
|
||||
logger.debug(f"Found name '{name}' for node {node_num} with key {key}")
|
||||
return name
|
||||
|
||||
# Search through all nodes by num field
|
||||
for key, node_data in nodes.items():
|
||||
if node_data.get('num') == node_num:
|
||||
user = node_data.get('user', {})
|
||||
name = user.get('shortName') or user.get('longName')
|
||||
if name:
|
||||
logger.debug(f"Found name '{name}' for node {node_num} by search")
|
||||
return name
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _format_node_id(node_num: int) -> str:
|
||||
"""Format node number as hex string."""
|
||||
@@ -302,6 +496,74 @@ class MeshtasticClient:
|
||||
logger.error(f"Error getting node info: {e}")
|
||||
return None
|
||||
|
||||
def get_nodes(self) -> list[MeshNode]:
|
||||
"""Get all tracked nodes."""
|
||||
# Also pull nodes from the SDK's nodeDB if available
|
||||
self._sync_nodes_from_interface()
|
||||
return list(self._nodes.values())
|
||||
|
||||
def _sync_nodes_from_interface(self) -> None:
|
||||
"""Sync nodes from the Meshtastic SDK's nodeDB."""
|
||||
if not self._interface:
|
||||
return
|
||||
|
||||
try:
|
||||
nodes = self._interface.nodes
|
||||
if not nodes:
|
||||
return
|
||||
|
||||
for node_id, node_data in nodes.items():
|
||||
# Skip if it's a string key like '!abcd1234'
|
||||
if isinstance(node_id, str):
|
||||
try:
|
||||
num = int(node_id[1:], 16) if node_id.startswith('!') else int(node_id)
|
||||
except ValueError:
|
||||
continue
|
||||
else:
|
||||
num = node_id
|
||||
|
||||
user = node_data.get('user', {})
|
||||
position = node_data.get('position', {})
|
||||
|
||||
# Get or create node
|
||||
if num not in self._nodes:
|
||||
self._nodes[num] = MeshNode(
|
||||
num=num,
|
||||
user_id=user.get('id', f"!{num:08x}"),
|
||||
long_name=user.get('longName', ''),
|
||||
short_name=user.get('shortName', ''),
|
||||
hw_model=user.get('hwModel', 'UNKNOWN'),
|
||||
)
|
||||
|
||||
node = self._nodes[num]
|
||||
|
||||
# Update from SDK data
|
||||
if user:
|
||||
node.long_name = user.get('longName', node.long_name) or node.long_name
|
||||
node.short_name = user.get('shortName', node.short_name) or node.short_name
|
||||
node.hw_model = user.get('hwModel', node.hw_model) or node.hw_model
|
||||
if user.get('id'):
|
||||
node.user_id = user.get('id')
|
||||
|
||||
if position:
|
||||
lat = position.get('latitude')
|
||||
lon = position.get('longitude')
|
||||
if lat is not None and lon is not None:
|
||||
node.latitude = lat
|
||||
node.longitude = lon
|
||||
node.altitude = position.get('altitude', node.altitude)
|
||||
|
||||
# Update last heard from SDK
|
||||
last_heard = node_data.get('lastHeard')
|
||||
if last_heard:
|
||||
node.last_heard = datetime.fromtimestamp(last_heard, tz=timezone.utc)
|
||||
|
||||
# Update SNR
|
||||
node.snr = node_data.get('snr', node.snr)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error syncing nodes from interface: {e}")
|
||||
|
||||
def get_channels(self) -> list[ChannelConfig]:
|
||||
"""Get all configured channels."""
|
||||
if not self._interface:
|
||||
@@ -321,6 +583,67 @@ class MeshtasticClient:
|
||||
logger.error(f"Error getting channels: {e}")
|
||||
return channels
|
||||
|
||||
def send_text(self, text: str, channel: int = 0,
|
||||
destination: str | int | None = None) -> tuple[bool, str]:
|
||||
"""
|
||||
Send a text message to the mesh network.
|
||||
|
||||
Args:
|
||||
text: Message text (max 237 characters)
|
||||
channel: Channel index to send on (0-7)
|
||||
destination: Target node ID (string like "!a1b2c3d4" or int).
|
||||
None or "^all" for broadcast.
|
||||
|
||||
Returns:
|
||||
Tuple of (success, error_message)
|
||||
"""
|
||||
if not self._interface:
|
||||
return False, "Not connected to device"
|
||||
|
||||
if not text or len(text) > 237:
|
||||
return False, "Message must be 1-237 characters"
|
||||
|
||||
try:
|
||||
# Parse destination - use broadcast address for None/^all
|
||||
dest_id = BROADCAST_ADDR # Default to broadcast
|
||||
|
||||
if destination:
|
||||
if isinstance(destination, int):
|
||||
dest_id = destination
|
||||
elif destination == "^all":
|
||||
dest_id = BROADCAST_ADDR
|
||||
elif destination.startswith('!'):
|
||||
dest_id = int(destination[1:], 16)
|
||||
else:
|
||||
# Try parsing as integer
|
||||
try:
|
||||
dest_id = int(destination)
|
||||
except ValueError:
|
||||
return False, f"Invalid destination: {destination}"
|
||||
|
||||
# Send the message using sendData for more control
|
||||
logger.debug(f"Calling sendData: text='{text[:30]}', dest={dest_id}, channel={channel}")
|
||||
|
||||
# Use sendData with TEXT_MESSAGE_APP portnum
|
||||
# This gives us more control over the packet
|
||||
from meshtastic import portnums_pb2
|
||||
|
||||
self._interface.sendData(
|
||||
text.encode('utf-8'),
|
||||
destinationId=dest_id,
|
||||
portNum=portnums_pb2.PortNum.TEXT_MESSAGE_APP,
|
||||
channelIndex=channel,
|
||||
)
|
||||
logger.debug("sendData completed")
|
||||
|
||||
dest_str = "^all" if dest_id == BROADCAST_ADDR else f"!{dest_id:08x}"
|
||||
logger.info(f"Sent message to {dest_str} on channel {channel}: {text[:50]}...")
|
||||
return True, None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending message: {e}")
|
||||
return False, str(e)
|
||||
|
||||
def set_channel(self, index: int, name: str | None = None,
|
||||
psk: str | None = None) -> tuple[bool, str]:
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user