diff --git a/app.py b/app.py
index eb069bc..8d778e7 100644
--- a/app.py
+++ b/app.py
@@ -103,6 +103,11 @@ satellite_process = None
satellite_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
satellite_lock = threading.Lock()
+# LoRa/ISM band
+lora_process = None
+lora_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
+lora_lock = threading.Lock()
+
# ============================================
# GLOBAL STATE DICTIONARIES
# ============================================
@@ -304,6 +309,7 @@ def health_check() -> Response:
'adsb': adsb_process is not None and (adsb_process.poll() is None if adsb_process else False),
'wifi': wifi_process is not None and (wifi_process.poll() is None if wifi_process else False),
'bluetooth': bt_process is not None and (bt_process.poll() is None if bt_process else False),
+ 'lora': lora_process is not None and (lora_process.poll() is None if lora_process else False),
},
'data': {
'aircraft_count': len(adsb_aircraft),
@@ -317,7 +323,7 @@ def health_check() -> Response:
@app.route('/killall', methods=['POST'])
def kill_all() -> Response:
"""Kill all decoder and WiFi processes."""
- global current_process, sensor_process, wifi_process, adsb_process
+ global current_process, sensor_process, wifi_process, adsb_process, lora_process
# Import adsb module to reset its state
from routes import adsb as adsb_module
@@ -351,6 +357,10 @@ def kill_all() -> Response:
adsb_process = None
adsb_module.adsb_using_service = False
+ # Reset LoRa state
+ with lora_lock:
+ lora_process = None
+
return jsonify({'status': 'killed', 'processes': killed})
diff --git a/routes/__init__.py b/routes/__init__.py
index 5b6a326..a311cf5 100644
--- a/routes/__init__.py
+++ b/routes/__init__.py
@@ -12,6 +12,7 @@ def register_blueprints(app):
from .settings import settings_bp
from .correlation import correlation_bp
from .listening_post import listening_post_bp
+ from .lora import lora_bp
app.register_blueprint(pager_bp)
app.register_blueprint(sensor_bp)
@@ -23,3 +24,4 @@ def register_blueprints(app):
app.register_blueprint(settings_bp)
app.register_blueprint(correlation_bp)
app.register_blueprint(listening_post_bp)
+ app.register_blueprint(lora_bp)
diff --git a/routes/lora.py b/routes/lora.py
new file mode 100644
index 0000000..54cb258
--- /dev/null
+++ b/routes/lora.py
@@ -0,0 +1,317 @@
+"""LoRa/ISM band monitoring routes."""
+
+from __future__ import annotations
+
+import json
+import queue
+import subprocess
+import threading
+import time
+from datetime import datetime
+from typing import Generator
+
+from flask import Blueprint, jsonify, request, Response
+
+import app as app_module
+from utils.logging import get_logger
+from utils.validation import (
+ validate_frequency, validate_device_index, validate_gain, validate_ppm,
+ validate_rtl_tcp_host, validate_rtl_tcp_port
+)
+from utils.sse import format_sse
+from utils.sdr import SDRFactory, SDRType
+
+logger = get_logger('intercept.lora')
+
+lora_bp = Blueprint('lora', __name__, url_prefix='/lora')
+
+# LoRa frequency bands by region
+LORA_BANDS = {
+ 'eu868': {
+ 'name': 'EU 868 MHz',
+ 'frequency': 868.0,
+ 'range': (863.0, 870.0),
+ 'channels': [868.1, 868.3, 868.5, 867.1, 867.3, 867.5, 867.7, 867.9]
+ },
+ 'us915': {
+ 'name': 'US 915 MHz',
+ 'frequency': 915.0,
+ 'range': (902.0, 928.0),
+ 'channels': [902.3, 902.5, 902.7, 902.9, 903.1, 903.3, 903.5, 903.7]
+ },
+ 'au915': {
+ 'name': 'AU 915 MHz',
+ 'frequency': 915.0,
+ 'range': (915.0, 928.0),
+ 'channels': [915.2, 915.4, 915.6, 915.8, 916.0, 916.2, 916.4, 916.6]
+ },
+ 'as923': {
+ 'name': 'AS 923 MHz',
+ 'frequency': 923.0,
+ 'range': (920.0, 925.0),
+ 'channels': [923.2, 923.4, 923.6, 923.8, 924.0, 924.2, 924.4, 924.6]
+ },
+ 'in865': {
+ 'name': 'IN 865 MHz',
+ 'frequency': 865.0,
+ 'range': (865.0, 867.0),
+ 'channels': [865.0625, 865.4025, 865.985]
+ },
+ 'ism433': {
+ 'name': 'ISM 433 MHz',
+ 'frequency': 433.92,
+ 'range': (433.05, 434.79),
+ 'channels': [433.05, 433.42, 433.92, 434.42]
+ }
+}
+
+# Device patterns that indicate LoRa/LPWAN devices
+LORA_DEVICE_PATTERNS = [
+ 'LoRa', 'Dragino', 'RAK', 'Heltec', 'TTGO', 'LoPy', 'Pycom',
+ 'Semtech', 'SX127', 'RFM95', 'RFM96', 'Murata', 'Microchip',
+ 'The Things', 'TTN', 'Helium', 'Chirpstack', 'LoRaWAN',
+ 'Smart meter', 'Sensus', 'Itron', 'Landis', 'Water meter',
+ 'Gas meter', 'Electric meter', 'Utility meter'
+]
+
+
+def is_lora_device(model: str, protocol: str = '') -> bool:
+ """Check if a device model/protocol indicates LoRa/LPWAN."""
+ combined = f"{model} {protocol}".lower()
+ return any(pattern.lower() in combined for pattern in LORA_DEVICE_PATTERNS)
+
+
+def stream_lora_output(process: subprocess.Popen[bytes]) -> None:
+ """Stream rtl_433 JSON output to LoRa queue."""
+ try:
+ app_module.lora_queue.put({'type': 'status', 'text': 'started'})
+
+ for line in iter(process.stdout.readline, b''):
+ line = line.decode('utf-8', errors='replace').strip()
+ if not line:
+ continue
+
+ try:
+ # rtl_433 outputs JSON objects
+ data = json.loads(line)
+
+ # Enhance with LoRa-specific info
+ model = data.get('model', 'Unknown')
+ protocol = data.get('protocol', '')
+ data['type'] = 'lora_device'
+ data['is_lora'] = is_lora_device(model, protocol)
+ data['timestamp'] = datetime.now().isoformat()
+
+ # Calculate signal quality if RSSI available
+ rssi = data.get('rssi')
+ if rssi is not None:
+ # Normalize RSSI to quality percentage
+ # Typical LoRa range: -120 dBm (weak) to -30 dBm (strong)
+ quality = max(0, min(100, (rssi + 120) * 100 / 90))
+ data['signal_quality'] = round(quality)
+
+ app_module.lora_queue.put(data)
+
+ except json.JSONDecodeError:
+ # Not JSON, could be info message
+ if line and not line.startswith('_'):
+ app_module.lora_queue.put({'type': 'info', 'text': line})
+
+ except Exception as e:
+ app_module.lora_queue.put({'type': 'error', 'text': str(e)})
+ finally:
+ process.wait()
+ app_module.lora_queue.put({'type': 'status', 'text': 'stopped'})
+ with app_module.lora_lock:
+ app_module.lora_process = None
+
+
+@lora_bp.route('/bands')
+def get_bands() -> Response:
+ """Get available LoRa frequency bands."""
+ return jsonify({
+ 'status': 'success',
+ 'bands': LORA_BANDS
+ })
+
+
+@lora_bp.route('/start', methods=['POST'])
+def start_lora() -> Response:
+ """Start LoRa band monitoring."""
+ with app_module.lora_lock:
+ if app_module.lora_process:
+ return jsonify({'status': 'error', 'message': 'LoRa monitor already running'}), 409
+
+ data = request.json or {}
+
+ # Get band or custom frequency
+ band_id = data.get('band', 'eu868')
+ band = LORA_BANDS.get(band_id, LORA_BANDS['eu868'])
+
+ # Allow custom frequency override
+ custom_freq = data.get('frequency')
+ if custom_freq:
+ try:
+ freq = validate_frequency(custom_freq)
+ except ValueError as e:
+ return jsonify({'status': 'error', 'message': str(e)}), 400
+ else:
+ freq = band['frequency']
+
+ # Validate other inputs
+ try:
+ gain = validate_gain(data.get('gain', '40')) # Higher default gain for weak signals
+ ppm = validate_ppm(data.get('ppm', '0'))
+ device = validate_device_index(data.get('device', '0'))
+ except ValueError as e:
+ return jsonify({'status': 'error', 'message': str(e)}), 400
+
+ # Clear queue
+ while not app_module.lora_queue.empty():
+ try:
+ app_module.lora_queue.get_nowait()
+ except queue.Empty:
+ break
+
+ # Get SDR type
+ sdr_type_str = data.get('sdr_type', 'rtlsdr')
+ try:
+ sdr_type = SDRType(sdr_type_str)
+ except ValueError:
+ sdr_type = SDRType.RTL_SDR
+
+ # Check for rtl_tcp
+ rtl_tcp_host = data.get('rtl_tcp_host')
+ rtl_tcp_port = data.get('rtl_tcp_port', 1234)
+
+ if rtl_tcp_host:
+ try:
+ rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
+ rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
+ except ValueError as e:
+ return jsonify({'status': 'error', 'message': str(e)}), 400
+
+ sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
+ else:
+ sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
+
+ builder = SDRFactory.get_builder(sdr_device.sdr_type)
+
+ # Build command for LoRa band monitoring
+ bias_t = data.get('bias_t', False)
+
+ # Use rtl_433 with settings optimized for LoRa bands
+ # -f frequency, -g gain, -F json, -M time:utc, -Y autolevel
+ cmd = builder.build_ism_command(
+ device=sdr_device,
+ frequency_mhz=freq,
+ gain=float(gain) if gain else 40.0,
+ ppm=int(ppm) if ppm else None,
+ bias_t=bias_t
+ )
+
+ # Add hop frequencies for the band if enabled
+ hop_enabled = data.get('hop_enabled', False)
+ if hop_enabled and 'channels' in band:
+ # Add additional frequencies to monitor
+ for ch_freq in band['channels'][:4]: # Limit to 4 hop frequencies
+ if ch_freq != freq:
+ cmd.extend(['-f', f'{ch_freq}M'])
+
+ full_cmd = ' '.join(cmd)
+ logger.info(f"Running: {full_cmd}")
+
+ try:
+ app_module.lora_process = subprocess.Popen(
+ cmd,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE
+ )
+
+ # Start output thread
+ thread = threading.Thread(target=stream_lora_output, args=(app_module.lora_process,))
+ thread.daemon = True
+ thread.start()
+
+ # Monitor stderr
+ def monitor_stderr():
+ for line in app_module.lora_process.stderr:
+ err = line.decode('utf-8', errors='replace').strip()
+ if err:
+ logger.debug(f"[rtl_433] {err}")
+
+ stderr_thread = threading.Thread(target=monitor_stderr)
+ stderr_thread.daemon = True
+ stderr_thread.start()
+
+ app_module.lora_queue.put({
+ 'type': 'info',
+ 'text': f'Monitoring {band["name"]} at {freq} MHz'
+ })
+
+ return jsonify({
+ 'status': 'started',
+ 'band': band_id,
+ 'frequency': freq,
+ 'command': full_cmd
+ })
+
+ except FileNotFoundError:
+ return jsonify({
+ 'status': 'error',
+ 'message': 'rtl_433 not found. Install with: sudo apt install rtl-433'
+ })
+ except Exception as e:
+ return jsonify({'status': 'error', 'message': str(e)})
+
+
+@lora_bp.route('/stop', methods=['POST'])
+def stop_lora() -> Response:
+ """Stop LoRa monitoring."""
+ with app_module.lora_lock:
+ if app_module.lora_process:
+ app_module.lora_process.terminate()
+ try:
+ app_module.lora_process.wait(timeout=2)
+ except subprocess.TimeoutExpired:
+ app_module.lora_process.kill()
+ app_module.lora_process = None
+ return jsonify({'status': 'stopped'})
+
+ return jsonify({'status': 'not_running'})
+
+
+@lora_bp.route('/stream')
+def stream_lora() -> Response:
+ """SSE stream for LoRa data."""
+ def generate() -> Generator[str, None, None]:
+ last_keepalive = time.time()
+ keepalive_interval = 30.0
+
+ while True:
+ try:
+ msg = app_module.lora_queue.get(timeout=1)
+ last_keepalive = time.time()
+ yield format_sse(msg)
+ except queue.Empty:
+ now = time.time()
+ if now - last_keepalive >= keepalive_interval:
+ yield format_sse({'type': 'keepalive'})
+ last_keepalive = now
+
+ response = Response(generate(), mimetype='text/event-stream')
+ response.headers['Cache-Control'] = 'no-cache'
+ response.headers['X-Accel-Buffering'] = 'no'
+ response.headers['Connection'] = 'keep-alive'
+ return response
+
+
+@lora_bp.route('/status')
+def lora_status() -> Response:
+ """Get LoRa monitoring status."""
+ with app_module.lora_lock:
+ running = app_module.lora_process is not None
+ return jsonify({
+ 'status': 'running' if running else 'stopped',
+ 'running': running
+ })
diff --git a/static/css/index.css b/static/css/index.css
index b1dbf26..590a3e0 100644
--- a/static/css/index.css
+++ b/static/css/index.css
@@ -2779,6 +2779,57 @@ header p {
background: rgba(0, 122, 255, 0.05);
}
+/* LoRa Layout Container */
+.lora-layout-container {
+ display: flex;
+ gap: 15px;
+ padding: 15px;
+ background: var(--bg-secondary);
+ margin: 0 15px 10px 15px;
+ border: 1px solid var(--border-color);
+ height: calc(100vh - 200px);
+ min-height: 400px;
+}
+
+.lora-layout-container .wifi-visuals {
+ flex: 1;
+}
+
+.lora-device-list {
+ border-left-color: var(--accent-green) !important;
+}
+
+.lora-device-list .wifi-device-list-header h5 {
+ color: var(--accent-green);
+}
+
+.lora-device-card {
+ border-left-color: var(--accent-green) !important;
+}
+
+.lora-device-card.selected {
+ border-color: var(--accent-green);
+ background: rgba(0, 255, 136, 0.1);
+}
+
+@media (max-width: 1200px) {
+ .lora-layout-container {
+ flex-direction: column;
+ height: auto;
+ max-height: calc(100vh - 200px);
+ }
+
+ .lora-layout-container .wifi-visuals {
+ max-height: 50vh;
+ }
+
+ .lora-device-list {
+ width: 100%;
+ min-width: auto;
+ max-height: 300px;
+ }
+}
+
@media (max-width: 1200px) {
.bt-layout-container {
flex-direction: column;
diff --git a/templates/index.html b/templates/index.html
index a8743cd..09dbe7f 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -243,6 +243,24 @@
+
+
+
@@ -252,6 +270,7 @@
SDR / RF
+
@@ -485,6 +504,70 @@
+
+
+
+
LoRa Band
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Settings
+
+
+
+
+
+
+
+
+
+
+
+
+
Device Patterns
+
+ rtl_433 detects LoRa/LPWAN devices including:
+ • Smart meters (water, gas, electric)
+ • LoRaWAN gateways and nodes
+ • IoT sensors and controllers
+ • Agricultural monitoring systems
+
+
+
+
+
+
+
@@ -1364,6 +1451,81 @@
+
+
+
+
+
+
+
📋 Selected Device
+
+
Click a device to view details
+
+
+
+
+
+
Device Categories
+
+
🌡️ Sensors: 0
+
⚡ Meters: 0
+
📡 LoRaWAN: 0
+
🔵 Other: 0
+
+
+
+
+
+
📊 Band Activity
+
+
+ Current Band:
+ --
+
+
+ Frequency:
+ -- MHz
+
+
+ Total Signals:
+ 0
+
+
+
+
+
+
📜 Activity Log
+
+
Waiting for signals...
+
+
+
+
+
+
+
+
+ Start monitoring to discover devices
+
+
+
+
+
@@ -1804,6 +1966,7 @@
let eventSource = null;
let isRunning = false;
let isSensorRunning = false;
+ let isLoraRunning = false;
let isAdsbRunning = false;
let isWifiRunning = false;
let isBtRunning = false;
@@ -2307,6 +2470,7 @@
// Stop any running scans when switching modes
if (isRunning) stopDecoding();
if (isSensorRunning) stopSensorDecoding();
+ if (isLoraRunning) stopLoraMonitoring();
if (isWifiRunning) stopWifiScan();
if (isBtRunning) stopBtScan();
if (isAdsbRunning) stopAdsbScan();
@@ -2315,9 +2479,9 @@
// Remove active from all nav buttons, then add to the correct one
document.querySelectorAll('.mode-nav-btn').forEach(btn => btn.classList.remove('active'));
const modeMap = {
- 'pager': 'pager', 'sensor': '433', 'aircraft': 'aircraft',
- 'satellite': 'satellite', 'wifi': 'wifi', 'bluetooth': 'bluetooth',
- 'listening': 'listening'
+ 'pager': 'pager', 'sensor': '433', 'lora': 'lora',
+ 'aircraft': 'aircraft', 'satellite': 'satellite', 'wifi': 'wifi',
+ 'bluetooth': 'bluetooth', 'listening': 'listening'
};
document.querySelectorAll('.mode-nav-btn').forEach(btn => {
const label = btn.querySelector('.nav-label');
@@ -2327,6 +2491,7 @@
});
document.getElementById('pagerMode').classList.toggle('active', mode === 'pager');
document.getElementById('sensorMode').classList.toggle('active', mode === 'sensor');
+ document.getElementById('loraMode').classList.toggle('active', mode === 'lora');
document.getElementById('aircraftMode').classList.toggle('active', mode === 'aircraft');
document.getElementById('satelliteMode').classList.toggle('active', mode === 'satellite');
document.getElementById('wifiMode').classList.toggle('active', mode === 'wifi');
@@ -2334,6 +2499,7 @@
document.getElementById('listeningPostMode').classList.toggle('active', mode === 'listening');
document.getElementById('pagerStats').style.display = mode === 'pager' ? 'flex' : 'none';
document.getElementById('sensorStats').style.display = mode === 'sensor' ? 'flex' : 'none';
+ document.getElementById('loraStats').style.display = mode === 'lora' ? 'flex' : 'none';
document.getElementById('aircraftStats').style.display = mode === 'aircraft' ? 'flex' : 'none';
document.getElementById('satelliteStats').style.display = mode === 'satellite' ? 'flex' : 'none';
document.getElementById('wifiStats').style.display = mode === 'wifi' ? 'flex' : 'none';
@@ -2345,6 +2511,7 @@
// Update header stats groups
document.getElementById('headerPagerStats').classList.toggle('active', mode === 'pager');
document.getElementById('headerSensorStats').classList.toggle('active', mode === 'sensor');
+ document.getElementById('headerLoraStats').classList.toggle('active', mode === 'lora');
document.getElementById('headerAircraftStats').classList.toggle('active', mode === 'aircraft');
document.getElementById('headerSatelliteStats').classList.toggle('active', mode === 'satellite');
document.getElementById('headerWifiStats').classList.toggle('active', mode === 'wifi');
@@ -2358,6 +2525,7 @@
const modeNames = {
'pager': 'PAGER',
'sensor': '433MHZ',
+ 'lora': 'LORA/ISM',
'aircraft': 'AIRCRAFT',
'satellite': 'SATELLITE',
'wifi': 'WIFI',
@@ -2367,6 +2535,7 @@
document.getElementById('activeModeIndicator').innerHTML = '
' + modeNames[mode];
document.getElementById('wifiLayoutContainer').style.display = mode === 'wifi' ? 'flex' : 'none';
document.getElementById('btLayoutContainer').style.display = mode === 'bluetooth' ? 'flex' : 'none';
+ document.getElementById('loraLayoutContainer').style.display = mode === 'lora' ? 'flex' : 'none';
// Respect the "Show Radar Display" checkbox for aircraft mode
const showRadar = document.getElementById('adsbEnableMap').checked;
document.getElementById('aircraftVisuals').style.display = (mode === 'aircraft' && showRadar) ? 'grid' : 'none';
@@ -2377,6 +2546,7 @@
const titles = {
'pager': 'Pager Decoder',
'sensor': '433MHz Sensor Monitor',
+ 'lora': 'LoRa/ISM Band Monitor',
'aircraft': 'ADS-B Aircraft Tracker',
'satellite': 'Satellite Monitor',
'wifi': 'WiFi Scanner',
@@ -2402,7 +2572,7 @@
}
// Show RTL-SDR device section for modes that use it
- document.getElementById('rtlDeviceSection').style.display = (mode === 'pager' || mode === 'sensor' || mode === 'aircraft') ? 'block' : 'none';
+ document.getElementById('rtlDeviceSection').style.display = (mode === 'pager' || mode === 'sensor' || mode === 'lora' || mode === 'aircraft' || mode === 'listening') ? 'block' : 'none';
// Toggle mode-specific tool status displays
document.getElementById('toolStatusPager').style.display = (mode === 'pager') ? 'grid' : 'none';
@@ -2609,6 +2779,345 @@
});
}
+ // ============================================
+ // LoRa/ISM BAND MONITORING
+ // ============================================
+
+ let loraEventSource = null;
+ let loraDevices = {};
+ let loraSignalCount = 0;
+ let loraDeviceCount = 0;
+
+ // LoRa band definitions
+ const LORA_BANDS = {
+ 'eu868': { name: 'EU 868 MHz', frequency: 868.0, channels: [868.1, 868.3, 868.5, 867.1, 867.3, 867.5, 867.7, 867.9] },
+ 'us915': { name: 'US 915 MHz', frequency: 915.0, channels: [902.3, 902.5, 902.7, 902.9, 903.1, 903.3, 903.5, 903.7] },
+ 'au915': { name: 'AU 915 MHz', frequency: 915.0, channels: [915.2, 915.4, 915.6, 915.8, 916.0, 916.2, 916.4, 916.6] },
+ 'as923': { name: 'AS 923 MHz', frequency: 923.0, channels: [923.2, 923.4, 923.6, 923.8, 924.0, 924.2, 924.4, 924.6] },
+ 'in865': { name: 'IN 865 MHz', frequency: 865.0, channels: [865.0625, 865.4025, 865.985] },
+ 'ism433': { name: 'ISM 433 MHz', frequency: 433.92, channels: [433.05, 433.42, 433.92, 434.42] }
+ };
+
+ function onLoraBandChanged() {
+ const bandId = document.getElementById('loraBandSelect').value;
+ const band = LORA_BANDS[bandId];
+ if (band) {
+ document.getElementById('loraFrequency').value = band.frequency;
+ updateLoraChannelButtons(bandId);
+ }
+ }
+
+ function updateLoraChannelButtons(bandId) {
+ const band = LORA_BANDS[bandId];
+ const container = document.getElementById('loraChannelButtons');
+ container.innerHTML = '';
+ if (band && band.channels) {
+ band.channels.slice(0, 4).forEach(freq => {
+ const btn = document.createElement('button');
+ btn.className = 'preset-btn';
+ btn.textContent = freq;
+ btn.onclick = () => document.getElementById('loraFrequency').value = freq;
+ container.appendChild(btn);
+ });
+ }
+ }
+
+ function setLoraRunning(running) {
+ isLoraRunning = running;
+ document.getElementById('statusDot').classList.toggle('running', running);
+ document.getElementById('startLoraBtn').style.display = running ? 'none' : 'block';
+ document.getElementById('stopLoraBtn').style.display = running ? 'block' : 'none';
+ }
+
+ function startLoraMonitoring() {
+ const band = document.getElementById('loraBandSelect').value;
+ const freq = document.getElementById('loraFrequency').value;
+ const gain = document.getElementById('loraGain').value;
+ const ppm = document.getElementById('loraPpm').value;
+ const hop = document.getElementById('loraHop').checked;
+ const device = getSelectedDevice();
+
+ // Check if device is available
+ if (!checkDeviceAvailability('lora')) {
+ return;
+ }
+
+ // Check for remote SDR
+ const remoteConfig = getRemoteSDRConfig();
+ if (remoteConfig === false) return;
+
+ const config = {
+ band: band,
+ frequency: freq,
+ gain: gain,
+ ppm: ppm,
+ device: device,
+ hop_enabled: hop,
+ sdr_type: getSelectedSDRType(),
+ bias_t: getBiasTEnabled()
+ };
+
+ if (remoteConfig) {
+ config.rtl_tcp_host = remoteConfig.host;
+ config.rtl_tcp_port = remoteConfig.port;
+ }
+
+ fetch('/lora/start', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify(config)
+ }).then(r => r.json())
+ .then(data => {
+ if (data.status === 'started') {
+ reserveDevice(parseInt(device), 'lora');
+ setLoraRunning(true);
+ startLoraStream();
+ // Update band info
+ document.getElementById('loraCurrentBand').textContent = LORA_BANDS[band]?.name || band;
+ document.getElementById('loraCurrentFreq').textContent = freq + ' MHz';
+ addLoraLogEntry('Started monitoring ' + LORA_BANDS[band]?.name + ' at ' + freq + ' MHz');
+ } else {
+ showError('Failed to start: ' + (data.message || 'Unknown error'));
+ }
+ });
+ }
+
+ function stopLoraMonitoring() {
+ fetch('/lora/stop', {method: 'POST'})
+ .then(r => r.json())
+ .then(data => {
+ releaseDevice('lora');
+ setLoraRunning(false);
+ if (loraEventSource) {
+ loraEventSource.close();
+ loraEventSource = null;
+ }
+ addLoraLogEntry('Monitoring stopped');
+ });
+ }
+
+ function startLoraStream() {
+ if (loraEventSource) {
+ loraEventSource.close();
+ }
+
+ loraEventSource = new EventSource('/lora/stream');
+
+ loraEventSource.onopen = function() {
+ addLoraLogEntry('Stream connected...');
+ };
+
+ loraEventSource.onmessage = function(e) {
+ const data = JSON.parse(e.data);
+ if (data.type === 'lora_device') {
+ handleLoraDevice(data);
+ } else if (data.type === 'status') {
+ if (data.text === 'stopped') {
+ setLoraRunning(false);
+ } else if (data.text === 'started') {
+ addLoraLogEntry('Receiver started');
+ }
+ } else if (data.type === 'info') {
+ addLoraLogEntry(data.text);
+ } else if (data.type === 'error') {
+ addLoraLogEntry('Error: ' + data.text, true);
+ }
+ };
+
+ loraEventSource.onerror = function(e) {
+ console.error('LoRa stream error');
+ addLoraLogEntry('Stream connection error', true);
+ };
+ }
+
+ function handleLoraDevice(data) {
+ loraSignalCount++;
+ document.getElementById('loraSignalCount').textContent = loraSignalCount;
+ document.getElementById('headerLoraSignalCount').textContent = loraSignalCount;
+ document.getElementById('loraTotalSignals').textContent = loraSignalCount;
+
+ // Create device key
+ const deviceKey = (data.model || 'Unknown') + '_' + (data.id || data.channel || Math.random().toString(36).substr(2, 9));
+
+ // Track device
+ if (!loraDevices[deviceKey]) {
+ loraDeviceCount++;
+ document.getElementById('loraDeviceCount').textContent = loraDeviceCount;
+ document.getElementById('headerLoraDeviceCount').textContent = loraDeviceCount;
+ document.getElementById('loraDeviceListCount').textContent = loraDeviceCount;
+ }
+
+ loraDevices[deviceKey] = {
+ ...data,
+ key: deviceKey,
+ lastSeen: new Date().toISOString()
+ };
+
+ // Update visualizations
+ updateLoraStats();
+ addLoraDeviceCard(data, deviceKey);
+ addLoraLogEntry('Signal: ' + (data.model || 'Unknown') + (data.id ? ' ID:' + data.id : ''));
+
+ // Update radar
+ updateLoraRadar();
+ }
+
+ function addLoraDeviceCard(data, deviceKey) {
+ const container = document.getElementById('loraDeviceListContent');
+
+ // Remove placeholder
+ const placeholder = container.querySelector('[style*="padding: 30px"]');
+ if (placeholder) placeholder.remove();
+
+ // Check if card exists
+ let card = container.querySelector(`[data-device-key="${deviceKey}"]`);
+ if (!card) {
+ card = document.createElement('div');
+ card.className = 'wifi-device-card lora-device-card';
+ card.setAttribute('data-device-key', deviceKey);
+ card.onclick = () => selectLoraDevice(deviceKey);
+ container.insertBefore(card, container.firstChild);
+ }
+
+ const rssi = data.rssi || data.signal_quality;
+ const signalClass = rssi && rssi > -50 ? 'strong' : rssi && rssi > -80 ? 'medium' : 'weak';
+
+ card.innerHTML = `
+
+
+ ${data.id ? `ID: ${data.id}` : ''}
+ ${data.channel ? `CH: ${data.channel}` : ''}
+ ${data.is_lora ? 'LoRa' : ''}
+
+ `;
+ }
+
+ function selectLoraDevice(deviceKey) {
+ const device = loraDevices[deviceKey];
+ if (!device) return;
+
+ document.querySelectorAll('.lora-device-card').forEach(c => c.classList.remove('selected'));
+ const card = document.querySelector(`[data-device-key="${deviceKey}"]`);
+ if (card) card.classList.add('selected');
+
+ const container = document.getElementById('loraSelectedDevice');
+ let details = '
';
+ for (const [key, value] of Object.entries(device)) {
+ if (value !== null && value !== undefined && !['type', 'key'].includes(key)) {
+ const label = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
+ details += `${label}:${value}`;
+ }
+ }
+ details += '
';
+ container.innerHTML = details;
+ }
+
+ function updateLoraStats() {
+ // Count device types
+ let sensors = 0, meters = 0, lorawan = 0, other = 0;
+ let strong = 0, medium = 0, weak = 0;
+
+ for (const device of Object.values(loraDevices)) {
+ const model = (device.model || '').toLowerCase();
+ if (model.includes('sensor') || model.includes('weather') || model.includes('temperature')) {
+ sensors++;
+ } else if (model.includes('meter') || model.includes('smart') || model.includes('utility')) {
+ meters++;
+ } else if (model.includes('lora') || model.includes('lpwan') || device.is_lora) {
+ lorawan++;
+ } else {
+ other++;
+ }
+
+ const rssi = device.rssi;
+ if (rssi && rssi > -50) strong++;
+ else if (rssi && rssi > -80) medium++;
+ else weak++;
+ }
+
+ document.getElementById('loraSensorCount').textContent = sensors;
+ document.getElementById('loraMeterCount').textContent = meters;
+ document.getElementById('loraLorawanCount').textContent = lorawan;
+ document.getElementById('loraOtherTypeCount').textContent = other;
+
+ // Update signal distribution
+ const total = Object.keys(loraDevices).length || 1;
+ document.getElementById('loraSignalStrong').style.width = (strong / total * 100) + '%';
+ document.getElementById('loraSignalMedium').style.width = (medium / total * 100) + '%';
+ document.getElementById('loraSignalWeak').style.width = (weak / total * 100) + '%';
+ document.getElementById('loraSignalStrongCount').textContent = strong;
+ document.getElementById('loraSignalMediumCount').textContent = medium;
+ document.getElementById('loraSignalWeakCount').textContent = weak;
+ }
+
+ function addLoraLogEntry(text, isError = false) {
+ const log = document.getElementById('loraActivityLog');
+ const placeholder = log.querySelector('[style*="padding: 10px"]');
+ if (placeholder) placeholder.remove();
+
+ const entry = document.createElement('div');
+ entry.style.cssText = `padding: 2px 0; border-bottom: 1px solid rgba(255,255,255,0.1); color: ${isError ? 'var(--accent-red)' : 'var(--text-secondary)'};`;
+ const time = new Date().toLocaleTimeString();
+ entry.innerHTML = `
[${time}] ${text}`;
+ log.insertBefore(entry, log.firstChild);
+
+ // Limit entries
+ while (log.children.length > 50) {
+ log.removeChild(log.lastChild);
+ }
+ }
+
+ function updateLoraRadar() {
+ const canvas = document.getElementById('loraRadarCanvas');
+ if (!canvas) return;
+ const ctx = canvas.getContext('2d');
+ const w = canvas.width, h = canvas.height;
+ const cx = w / 2, cy = h / 2;
+
+ ctx.clearRect(0, 0, w, h);
+
+ // Draw radar circles
+ ctx.strokeStyle = 'rgba(0, 212, 255, 0.3)';
+ ctx.lineWidth = 1;
+ for (let r = 20; r <= 60; r += 20) {
+ ctx.beginPath();
+ ctx.arc(cx, cy, r, 0, Math.PI * 2);
+ ctx.stroke();
+ }
+
+ // Draw crosshairs
+ ctx.beginPath();
+ ctx.moveTo(cx, 10);
+ ctx.lineTo(cx, h - 10);
+ ctx.moveTo(10, cy);
+ ctx.lineTo(w - 10, cy);
+ ctx.stroke();
+
+ // Plot devices
+ let i = 0;
+ for (const device of Object.values(loraDevices)) {
+ const rssi = device.rssi || -100;
+ const dist = Math.max(10, Math.min(60, (rssi + 120) * 0.6));
+ const angle = (i * 137.5) * Math.PI / 180;
+ const x = cx + dist * Math.cos(angle);
+ const y = cy + dist * Math.sin(angle);
+
+ ctx.fillStyle = device.is_lora ? 'var(--accent-green)' : 'var(--accent-cyan)';
+ ctx.beginPath();
+ ctx.arc(x, y, 4, 0, Math.PI * 2);
+ ctx.fill();
+ i++;
+ }
+ }
+
+ // Initialize LoRa on page load
+ document.addEventListener('DOMContentLoaded', function() {
+ updateLoraChannelButtons('eu868');
+ });
+
// Audio alert settings
let audioMuted = localStorage.getItem('audioMuted') === 'true';
let audioContext = null;