mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -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:
50
CHANGELOG.md
50
CHANGELOG.md
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
19
app.py
19
app.py
@@ -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
|
||||
# ============================================
|
||||
|
||||
36
config.py
36
config.py
@@ -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'))
|
||||
})
|
||||
|
||||
163
routes/offline.py
Normal file
163
routes/offline.py
Normal file
@@ -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
|
||||
})
|
||||
58
setup.sh
58
setup.sh
@@ -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.
|
||||
|
||||
67
static/css/fonts-local.css
Normal file
67
static/css/fonts-local.css
Normal file
@@ -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');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
1182
static/css/modes/meshtastic.css
Normal file
1182
static/css/modes/meshtastic.css
Normal file
File diff suppressed because it is too large
Load Diff
399
static/css/settings.css
Normal file
399
static/css/settings.css
Normal file
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
399
static/js/core/settings-manager.js
Normal file
399
static/js/core/settings-manager.js
Normal file
@@ -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();
|
||||
});
|
||||
1196
static/js/modes/meshtastic.js
Normal file
1196
static/js/modes/meshtastic.js
Normal file
File diff suppressed because it is too large
Load Diff
20
static/vendor/chartjs/chart.umd.min.js
vendored
Normal file
20
static/vendor/chartjs/chart.umd.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
static/vendor/fonts/Inter-Bold.woff2
vendored
Normal file
BIN
static/vendor/fonts/Inter-Bold.woff2
vendored
Normal file
Binary file not shown.
BIN
static/vendor/fonts/Inter-Medium.woff2
vendored
Normal file
BIN
static/vendor/fonts/Inter-Medium.woff2
vendored
Normal file
Binary file not shown.
BIN
static/vendor/fonts/Inter-Regular.woff2
vendored
Normal file
BIN
static/vendor/fonts/Inter-Regular.woff2
vendored
Normal file
Binary file not shown.
BIN
static/vendor/fonts/Inter-SemiBold.woff2
vendored
Normal file
BIN
static/vendor/fonts/Inter-SemiBold.woff2
vendored
Normal file
Binary file not shown.
BIN
static/vendor/fonts/JetBrainsMono-Bold.woff2
vendored
Normal file
BIN
static/vendor/fonts/JetBrainsMono-Bold.woff2
vendored
Normal file
Binary file not shown.
BIN
static/vendor/fonts/JetBrainsMono-Medium.woff2
vendored
Normal file
BIN
static/vendor/fonts/JetBrainsMono-Medium.woff2
vendored
Normal file
Binary file not shown.
BIN
static/vendor/fonts/JetBrainsMono-Regular.woff2
vendored
Normal file
BIN
static/vendor/fonts/JetBrainsMono-Regular.woff2
vendored
Normal file
Binary file not shown.
BIN
static/vendor/fonts/JetBrainsMono-SemiBold.woff2
vendored
Normal file
BIN
static/vendor/fonts/JetBrainsMono-SemiBold.woff2
vendored
Normal file
Binary file not shown.
BIN
static/vendor/leaflet/images/layers-2x.png
vendored
Normal file
BIN
static/vendor/leaflet/images/layers-2x.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
static/vendor/leaflet/images/layers.png
vendored
Normal file
BIN
static/vendor/leaflet/images/layers.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 696 B |
BIN
static/vendor/leaflet/images/marker-icon-2x.png
vendored
Normal file
BIN
static/vendor/leaflet/images/marker-icon-2x.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
BIN
static/vendor/leaflet/images/marker-icon.png
vendored
Normal file
BIN
static/vendor/leaflet/images/marker-icon.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
static/vendor/leaflet/images/marker-shadow.png
vendored
Normal file
BIN
static/vendor/leaflet/images/marker-shadow.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 618 B |
661
static/vendor/leaflet/leaflet.css
vendored
Normal file
661
static/vendor/leaflet/leaflet.css
vendored
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
6
static/vendor/leaflet/leaflet.js
vendored
Normal file
6
static/vendor/leaflet/leaflet.js
vendored
Normal file
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
102
templates/partials/modes/meshtastic.html
Normal file
102
templates/partials/modes/meshtastic.html
Normal file
@@ -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>
|
||||
167
templates/partials/settings-modal.html
Normal file
167
templates/partials/settings-modal.html
Normal file
@@ -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,
|
||||
|
||||
338
utils/bluetooth/ubertooth_scanner.py
Normal file
338
utils/bluetooth/ubertooth_scanner.py
Normal file
@@ -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,
|
||||
)
|
||||
@@ -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