Add LoRa/ISM band monitoring mode

- Add new LoRa backend route (routes/lora.py) with:
  - Frequency band definitions (EU868, US915, AU915, AS923, IN865, ISM433)
  - Start/stop/stream/status endpoints using rtl_433
  - Device pattern matching for LoRa/LPWAN devices
  - Signal quality calculation from RSSI

- Add LoRa frontend UI with:
  - Navigation button in SDR/RF group
  - Band selector with channel presets
  - Visualization layout (radar, device types, signal quality, activity log)
  - Device card list with selection details
  - Header stats for devices and signals

- Fix Bias-T toggle visibility for Listening Post and LoRa modes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-01-08 16:56:21 +00:00
parent 6c99651ac9
commit f3b1865a79
5 changed files with 894 additions and 5 deletions

View File

@@ -243,6 +243,24 @@
</div>
</div>
</div>
<!-- LoRa Stats -->
<div class="header-stats-group" id="headerLoraStats">
<div class="stat-badge">
<span class="badge-icon">📶</span>
<div>
<span class="badge-value" id="headerLoraDeviceCount">0</span>
<span class="badge-label">devices</span>
</div>
</div>
<div class="stat-badge">
<span class="badge-icon">📡</span>
<div>
<span class="badge-value" id="headerLoraSignalCount">0</span>
<span class="badge-label">signals</span>
</div>
</div>
</div>
</div>
</header>
@@ -252,6 +270,7 @@
<span class="mode-nav-label">SDR / RF</span>
<button class="mode-nav-btn active" onclick="switchMode('pager')"><span class="nav-icon">📟</span><span class="nav-label">Pager</span></button>
<button class="mode-nav-btn" onclick="switchMode('sensor')"><span class="nav-icon">📡</span><span class="nav-label">433MHz</span></button>
<button class="mode-nav-btn" onclick="switchMode('lora')"><span class="nav-icon">📶</span><span class="nav-label">LoRa/ISM</span></button>
<button class="mode-nav-btn" onclick="switchMode('aircraft')"><span class="nav-icon">✈️</span><span class="nav-label">Aircraft</span></button>
<button class="mode-nav-btn" onclick="switchMode('satellite')"><span class="nav-icon">🛰️</span><span class="nav-label">Satellite</span></button>
<button class="mode-nav-btn" onclick="switchMode('listening')"><span class="nav-icon">📻</span><span class="nav-label">Listening Post</span></button>
@@ -485,6 +504,70 @@
</button>
</div>
<!-- LoRa MODE -->
<div id="loraMode" class="mode-content">
<div class="section">
<h3>LoRa Band</h3>
<div class="form-group">
<label>Region/Band</label>
<select id="loraBandSelect" onchange="onLoraBandChanged()">
<option value="eu868">EU 868 MHz</option>
<option value="us915">US 915 MHz</option>
<option value="au915">AU 915 MHz</option>
<option value="as923">AS 923 MHz</option>
<option value="in865">IN 865 MHz</option>
<option value="ism433">ISM 433 MHz</option>
</select>
</div>
<div class="form-group">
<label>Frequency (MHz)</label>
<input type="text" id="loraFrequency" value="868.0" placeholder="e.g., 868.0">
</div>
<div class="preset-buttons" id="loraChannelButtons">
<!-- Populated by JavaScript based on selected band -->
</div>
</div>
<div class="section">
<h3>Settings</h3>
<div class="form-group">
<label>Gain (dB, higher for weak signals)</label>
<input type="text" id="loraGain" value="40" placeholder="0-50, 40 recommended">
</div>
<div class="form-group">
<label>PPM Correction</label>
<input type="text" id="loraPpm" value="0" placeholder="Frequency correction">
</div>
<div class="form-group">
<label class="inline-checkbox">
<input type="checkbox" id="loraHop">
Enable Channel Hopping
</label>
<div class="info-text" style="font-size: 10px; color: #666; margin-top: 4px;">
Monitor multiple channels in the selected band
</div>
</div>
</div>
<div class="section">
<h3>Device Patterns</h3>
<div class="info-text" style="font-size: 11px;">
rtl_433 detects LoRa/LPWAN devices including:<br>
<span style="color: var(--accent-cyan);">• Smart meters</span> (water, gas, electric)<br>
<span style="color: var(--accent-green);">• LoRaWAN</span> gateways and nodes<br>
<span style="color: var(--accent-orange);">• IoT sensors</span> and controllers<br>
<span style="color: var(--accent-purple);">• Agricultural</span> monitoring systems
</div>
</div>
<button class="run-btn" id="startLoraBtn" onclick="startLoraMonitoring()">
Start Monitoring
</button>
<button class="stop-btn" id="stopLoraBtn" onclick="stopLoraMonitoring()" style="display: none;">
Stop Monitoring
</button>
</div>
<!-- WiFi MODE -->
<div id="wifiMode" class="mode-content">
<div class="section">
@@ -1173,6 +1256,10 @@
<div class="stats" id="satelliteStats" style="display: none;">
<div title="Upcoming Passes">🛰️ <span id="passCount">0</span></div>
</div>
<div class="stats" id="loraStats" style="display: none;">
<div title="LoRa Devices">📶 <span id="loraDeviceCount">0</span></div>
<div title="Signals Detected">📡 <span id="loraSignalCount">0</span></div>
</div>
</div>
</div>
@@ -1364,6 +1451,81 @@
</div>
</div>
<!-- LoRa Layout Container -->
<div class="lora-layout-container" id="loraLayoutContainer" style="display: none;">
<!-- Left: LoRa Visualizations -->
<div class="wifi-visuals" id="loraVisuals">
<!-- Selected Device Info -->
<div class="wifi-visual-panel" style="grid-column: span 2;">
<h5>📋 Selected Device</h5>
<div id="loraSelectedDevice" style="font-size: 11px; min-height: 100px;">
<div style="color: var(--text-dim); padding: 20px; text-align: center;">Click a device to view details</div>
</div>
</div>
<!-- Row 1: Signal Radar + Device Types -->
<div class="wifi-visual-panel">
<h5>Signal Radar</h5>
<div class="radar-container">
<canvas id="loraRadarCanvas" width="150" height="150"></canvas>
</div>
</div>
<div class="wifi-visual-panel">
<h5>Device Categories</h5>
<div class="bt-type-overview" id="loraTypeOverview">
<div class="bt-type-item"><span class="bt-type-icon">🌡️</span> Sensors: <strong id="loraSensorCount">0</strong></div>
<div class="bt-type-item"><span class="bt-type-icon"></span> Meters: <strong id="loraMeterCount">0</strong></div>
<div class="bt-type-item"><span class="bt-type-icon">📡</span> LoRaWAN: <strong id="loraLorawanCount">0</strong></div>
<div class="bt-type-item"><span class="bt-type-icon">🔵</span> Other: <strong id="loraOtherTypeCount">0</strong></div>
</div>
</div>
<!-- Row 2: Signal Quality + Band Activity -->
<div class="wifi-visual-panel">
<h5>📶 Signal Quality</h5>
<div class="bt-signal-dist" id="loraSignalDist">
<div class="signal-range"><span>Strong (-50+)</span><div class="signal-bar-bg"><div class="signal-bar strong" id="loraSignalStrong" style="width: 0%;"></div></div><span id="loraSignalStrongCount">0</span></div>
<div class="signal-range"><span>Medium (-80)</span><div class="signal-bar-bg"><div class="signal-bar medium" id="loraSignalMedium" style="width: 0%;"></div></div><span id="loraSignalMediumCount">0</span></div>
<div class="signal-range"><span>Weak (-100)</span><div class="signal-bar-bg"><div class="signal-bar weak" id="loraSignalWeak" style="width: 0%;"></div></div><span id="loraSignalWeakCount">0</span></div>
</div>
</div>
<div class="wifi-visual-panel">
<h5>📊 Band Activity</h5>
<div id="loraBandActivity" style="font-size: 11px;">
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
<span>Current Band:</span>
<span id="loraCurrentBand" style="color: var(--accent-cyan);">--</span>
</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
<span>Frequency:</span>
<span id="loraCurrentFreq" style="color: var(--accent-green);">-- MHz</span>
</div>
<div style="display: flex; justify-content: space-between;">
<span>Total Signals:</span>
<span id="loraTotalSignals" style="color: var(--accent-orange);">0</span>
</div>
</div>
</div>
<!-- Row 3: Activity Log -->
<div class="wifi-visual-panel" style="grid-column: span 2;">
<h5>📜 Activity Log</h5>
<div id="loraActivityLog" style="max-height: 120px; overflow-y: auto; font-size: 11px; font-family: 'JetBrains Mono', monospace;">
<div style="color: var(--text-dim); padding: 10px; text-align: center;">Waiting for signals...</div>
</div>
</div>
</div>
<!-- Right: LoRa Device Cards -->
<div class="wifi-device-list lora-device-list" id="loraDeviceListPanel">
<div class="wifi-device-list-header">
<h5>📶 LoRa/ISM Devices</h5>
<span class="device-count">(<span id="loraDeviceListCount">0</span>)</span>
</div>
<div class="wifi-device-list-content" id="loraDeviceListContent">
<div style="color: var(--text-dim); text-align: center; padding: 30px;">
Start monitoring to discover devices
</div>
</div>
</div>
</div>
<!-- Aircraft Visualizations - Leaflet Map -->
<div class="wifi-visuals" id="aircraftVisuals" style="display: none;">
<!-- Map Panel -->
@@ -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 = '<span class="pulse-dot"></span>' + 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 = `
<div class="device-card-header">
<span class="device-name">${data.model || 'Unknown Device'}</span>
<span class="device-signal ${signalClass}">${rssi ? rssi + ' dBm' : 'N/A'}</span>
</div>
<div class="device-card-details">
${data.id ? `<span>ID: ${data.id}</span>` : ''}
${data.channel ? `<span>CH: ${data.channel}</span>` : ''}
${data.is_lora ? '<span style="color: var(--accent-green);">LoRa</span>' : ''}
</div>
`;
}
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 = '<div style="display: grid; grid-template-columns: auto 1fr; gap: 4px 12px;">';
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 += `<span style="color: var(--text-dim);">${label}:</span><span>${value}</span>`;
}
}
details += '</div>';
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 = `<span style="color: var(--text-dim);">[${time}]</span> ${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;