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:
Smittix
2026-01-28 20:14:51 +00:00
parent eae1820fda
commit db304631f8
47 changed files with 5948 additions and 128 deletions

View File

@@ -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

View File

@@ -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
View File

@@ -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
# ============================================

View File

@@ -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)

View File

@@ -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

View File

@@ -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>

View File

@@ -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

View File

@@ -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

View File

@@ -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
View 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
})

View File

@@ -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.

View 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');
}

View File

@@ -372,7 +372,18 @@ body {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 20px;
padding: 16px;
max-height: calc(100vh - 300px);
overflow-y: auto;
}
.welcome-modes > h2 {
position: sticky;
top: -16px;
background: var(--bg-secondary);
padding: 8px 0;
margin: -8px 0 12px 0;
z-index: 1;
}
.mode-grid {
@@ -439,6 +450,65 @@ body {
margin-top: 4px;
}
/* Mode Categories */
.mode-category {
margin-bottom: 16px;
}
.mode-category:last-child {
margin-bottom: 0;
}
.mode-category-title {
display: flex;
align-items: center;
gap: 8px;
font-family: 'JetBrains Mono', monospace;
font-size: 0.7rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.1em;
margin: 0 0 10px 0;
padding-bottom: 6px;
border-bottom: 1px solid var(--border-color);
}
.mode-category-icon {
display: flex;
align-items: center;
color: var(--accent-cyan);
}
.mode-category-icon svg {
width: 14px;
height: 14px;
}
/* Compact Mode Grid */
.mode-grid-compact {
grid-template-columns: repeat(5, 1fr);
gap: 6px;
}
.mode-card-sm {
padding: 10px 6px;
}
.mode-card-sm .mode-icon {
margin-bottom: 6px;
}
.mode-card-sm .mode-icon svg {
width: 20px;
height: 20px;
}
.mode-card-sm .mode-name {
font-size: 0.6rem;
letter-spacing: 0.02em;
}
/* Welcome Footer */
.welcome-footer {
text-align: center;
@@ -501,11 +571,18 @@ body {
grid-template-columns: repeat(2, 1fr);
}
/* Larger phones: 3 columns for mode grid */
.mode-grid-compact {
grid-template-columns: repeat(3, 1fr);
}
/* Larger phones: more columns for mode grid */
@media (min-width: 480px) {
.mode-grid {
grid-template-columns: repeat(3, 1fr);
}
.mode-grid-compact {
grid-template-columns: repeat(4, 1fr);
}
}
/* Tablet and up: Side-by-side layout */
@@ -522,6 +599,10 @@ body {
.welcome-title-block {
text-align: left;
}
.mode-grid-compact {
grid-template-columns: repeat(5, 1fr);
}
}
/* ============================================
@@ -6121,4 +6202,50 @@ body::before {
.preset-freq-btn:active {
transform: scale(0.98);
}
/* Animation toggle icon states in nav bar */
.nav-tool-btn .icon-effects-on,
.nav-tool-btn .icon-effects-off {
position: absolute;
transition: opacity 0.2s, transform 0.2s;
font-size: 14px;
}
.nav-tool-btn .icon-effects-on {
opacity: 1;
transform: rotate(0deg);
}
.nav-tool-btn .icon-effects-off {
opacity: 0;
transform: rotate(-90deg);
}
[data-animations="off"] .nav-tool-btn .icon-effects-on {
opacity: 0;
transform: rotate(90deg);
}
[data-animations="off"] .nav-tool-btn .icon-effects-off {
opacity: 1;
transform: rotate(0deg);
}
/* Disable cosmetic animations when toggled off */
[data-animations="off"] .globe-svg,
[data-animations="off"] .rotating-meridians,
[data-animations="off"] .meridian-1,
[data-animations="off"] .meridian-2,
[data-animations="off"] .meridian-3,
[data-animations="off"] .welcome-scanline,
[data-animations="off"] .landing-scanline,
[data-animations="off"] .scanline,
[data-animations="off"] .signal-wave,
[data-animations="off"] .signal-wave-1,
[data-animations="off"] .signal-wave-2,
[data-animations="off"] .signal-wave-3,
[data-animations="off"] .logo-dot,
[data-animations="off"] .welcome-logo {
animation: none !important;
}

File diff suppressed because it is too large Load Diff

399
static/css/settings.css Normal file
View 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%;
}
}

View File

@@ -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>

View File

@@ -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();
}
}

View 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: '&copy; <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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <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 &copy; Esri &mdash; 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;
">&times;</button>
`;
document.body.appendChild(prompt);
}
}
};
// Settings modal functions
function showSettings() {
const modal = document.getElementById('settingsModal');
if (modal) {
modal.classList.add('active');
Settings.init().then(() => {
Settings.checkAssets();
});
}
}
function hideSettings() {
const modal = document.getElementById('settingsModal');
if (modal) {
modal.classList.remove('active');
}
}
function switchSettingsTab(tabName) {
// Update tab buttons
document.querySelectorAll('.settings-tab').forEach(tab => {
tab.classList.toggle('active', tab.dataset.tab === tabName);
});
// Update sections
document.querySelectorAll('.settings-section').forEach(section => {
section.classList.toggle('active', section.id === `settings-${tabName}`);
});
}
// Initialize settings on page load
document.addEventListener('DOMContentLoaded', () => {
Settings.init();
});

File diff suppressed because it is too large Load Diff

20
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

Binary file not shown.

BIN
static/vendor/fonts/Inter-Medium.woff2 vendored Normal file

Binary file not shown.

BIN
static/vendor/fonts/Inter-Regular.woff2 vendored Normal file

Binary file not shown.

BIN
static/vendor/fonts/Inter-SemiBold.woff2 vendored Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
static/vendor/leaflet/images/layers.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

661
static/vendor/leaflet/leaflet.css vendored Normal file
View 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

File diff suppressed because one or more lines are too long

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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()">&times;</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>

View 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()">&times;</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>

View File

@@ -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>

View File

@@ -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)

View File

@@ -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

View File

@@ -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,

View 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,
)

View File

@@ -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]:
"""