diff --git a/modules/scheduler.py b/modules/scheduler.py index ad79bad..b610c75 100644 --- a/modules/scheduler.py +++ b/modules/scheduler.py @@ -709,6 +709,7 @@ class MessageScheduler: SELECT id, operation_type, channel_idx, channel_name, channel_key_hex FROM channel_operations WHERE status = 'pending' + AND operation_type IN ('add', 'remove') ORDER BY created_at ASC LIMIT 10 ''') @@ -823,7 +824,8 @@ class MessageScheduler: WHERE status = 'pending' AND operation_type IN ( 'radio_reboot', 'radio_connect', 'radio_disconnect', - 'firmware_read', 'firmware_write' + 'firmware_read', 'firmware_write', + 'radio_params_read', 'radio_params_write' ) ORDER BY created_at ASC LIMIT 1 @@ -850,6 +852,11 @@ class MessageScheduler: elif op_type == 'firmware_write': payload = json.loads(op['payload_data'] or '{}') success, result_payload = await self._firmware_write_op(payload) + elif op_type == 'radio_params_read': + success, result_payload = await self._radio_params_read_op() + elif op_type == 'radio_params_write': + payload = json.loads(op['payload_data'] or '{}') + success, result_payload = await self._radio_params_write_op(payload) else: success = False @@ -965,6 +972,77 @@ class MessageScheduler: self.logger.error(f"Firmware write failed: {e}") return False, {'error': str(e)} + async def _radio_params_read_op(self): + """Read current radio parameters (freq, bw, sf, cr, tx_power) via SELF_INFO.""" + import asyncio + from meshcore.events import EventType + try: + meshcore = getattr(self.bot, 'meshcore', None) + if not meshcore or not getattr(meshcore, 'is_connected', False): + return False, {'error': 'Radio not connected'} + + event = await asyncio.wait_for( + meshcore.commands.send_appstart(), timeout=10 + ) + if event is None or event.type == EventType.ERROR: + return False, {'error': 'Failed to read radio parameters'} + + p = event.payload or {} + return True, { + 'freq': p.get('radio_freq'), + 'bw': p.get('radio_bw'), + 'sf': p.get('radio_sf'), + 'cr': p.get('radio_cr'), + 'tx_power': p.get('tx_power'), + 'max_tx_power': p.get('max_tx_power'), + } + except Exception as e: + self.logger.error(f"Radio params read failed: {e}") + return False, {'error': str(e)} + + async def _radio_params_write_op(self, payload: dict): + """Write radio parameters (freq, bw, sf, cr, tx_power) to device.""" + import asyncio + from meshcore.events import EventType + try: + meshcore = getattr(self.bot, 'meshcore', None) + if not meshcore or not getattr(meshcore, 'is_connected', False): + return False, {'error': 'Radio not connected'} + + results = {} + errors = [] + + if any(k in payload for k in ('freq', 'bw', 'sf', 'cr')): + freq = float(payload['freq']) + bw = float(payload['bw']) + sf = int(payload['sf']) + cr = int(payload['cr']) + result = await asyncio.wait_for( + meshcore.commands.set_radio(freq, bw, sf, cr), timeout=10 + ) + ok = getattr(result, 'type', None) == EventType.OK + results['radio'] = ok + if not ok: + errors.append(f"set_radio failed: {result}") + + if 'tx_power' in payload: + result = await asyncio.wait_for( + meshcore.commands.set_tx_power(int(payload['tx_power'])), timeout=10 + ) + ok = getattr(result, 'type', None) == EventType.OK + results['tx_power'] = ok + if not ok: + errors.append(f"set_tx_power failed: {result}") + + success = len(errors) == 0 + response: dict = {'results': results} + if errors: + response['errors'] = errors + return success, response + except Exception as e: + self.logger.error(f"Radio params write failed: {e}") + return False, {'error': str(e)} + # ── Maintenance (delegates to MaintenanceRunner) ───────────────────────── @property diff --git a/modules/web_viewer/app.py b/modules/web_viewer/app.py index d2fcb79..8a5a671 100644 --- a/modules/web_viewer/app.py +++ b/modules/web_viewer/app.py @@ -1746,6 +1746,122 @@ class BotDataViewer: self.logger.exception("Error saving radio debug config") return jsonify({'success': False, 'error': str(exc)}), 500 + # ── Radio probe config ─────────────────────────────────────────────── + + @self.app.route('/api/config/radio-probe') + def api_config_radio_probe_get() -> "Response": + """Return radio probe settings.""" + try: + return jsonify({ + 'probe_interval_seconds': self.db_manager.get_metadata('radio.probe_interval_seconds') or + self.config.getint('Connection', 'radio_probe_interval_seconds', fallback=300), + 'probe_fail_threshold': self.db_manager.get_metadata('radio.probe_fail_threshold') or + self.config.getint('Connection', 'radio_probe_fail_threshold', fallback=3), + }) + except Exception as exc: + self.logger.exception("Error getting radio probe config") + return jsonify({'success': False, 'error': str(exc)}), 500 + + @self.app.route('/api/config/radio-probe', methods=['POST']) + def api_config_radio_probe_post() -> "Response": + """Save radio probe settings to bot_metadata.""" + try: + data = request.get_json(silent=True) or {} + probe_interval = int(data.get('probe_interval_seconds', 300)) + probe_fail_threshold = int(data.get('probe_fail_threshold', 3)) + + # Validate ranges + if not (300 <= probe_interval <= 900): + return jsonify({'success': False, 'error': 'probe_interval_seconds must be 300-900'}), 400 + if not (1 <= probe_fail_threshold <= 10): + return jsonify({'success': False, 'error': 'probe_fail_threshold must be 1-10'}), 400 + + saved = [] + self.db_manager.set_metadata('radio.probe_interval_seconds', str(probe_interval)) + saved.append('probe_interval_seconds') + self.db_manager.set_metadata('radio.probe_fail_threshold', str(probe_fail_threshold)) + saved.append('probe_fail_threshold') + + self.logger.info("Radio probe config updated (metadata): %s", ', '.join(saved)) + + # Optionally save to config.ini + config_saved = False + if data.get('save_to_config', False): + try: + self.config.set('Connection', 'radio_probe_interval_seconds', str(probe_interval)) + self.config.set('Connection', 'radio_probe_fail_threshold', str(probe_fail_threshold)) + with open(self.config_path, 'w') as f: + self.config.write(f) + config_saved = True + self.logger.info("Radio probe settings written to config.ini") + except Exception as exc: + self.logger.error("Failed to write radio probe settings to config.ini: %s", exc) + + return jsonify({'success': True, 'saved': saved, 'config_saved': config_saved}) + except Exception as exc: + self.logger.exception("Error saving radio probe config") + return jsonify({'success': False, 'error': str(exc)}), 500 + + # ── Radio offline alert config ─────────────────────────────────────── + + @self.app.route('/api/config/radio-offline-alert') + def api_config_radio_offline_alert_get() -> "Response": + """Return radio offline alert settings.""" + try: + return jsonify({ + 'offline_threshold': self.db_manager.get_metadata('radio.offline_threshold') or + self.config.getint('Connection', 'radio_offline_threshold', fallback=3), + 'alert_enabled': self.db_manager.get_metadata('radio.offline_alert_enabled') == 'true' or + self.config.getboolean('Connection', 'radio_offline_alert_enabled', fallback=False), + 'alert_email': self.db_manager.get_metadata('radio.offline_alert_email') or + self.config.get('Connection', 'radio_offline_alert_email', fallback=''), + }) + except Exception as exc: + self.logger.exception("Error getting radio offline alert config") + return jsonify({'success': False, 'error': str(exc)}), 500 + + @self.app.route('/api/config/radio-offline-alert', methods=['POST']) + def api_config_radio_offline_alert_post() -> "Response": + """Save radio offline alert settings to bot_metadata.""" + try: + data = request.get_json(silent=True) or {} + offline_threshold = int(data.get('offline_threshold', 3)) + alert_enabled = bool(data.get('alert_enabled', False)) + alert_email = str(data.get('alert_email', '')).strip() + + # Validate ranges + if not (1 <= offline_threshold <= 10): + return jsonify({'success': False, 'error': 'offline_threshold must be 1-10'}), 400 + + saved = [] + self.db_manager.set_metadata('radio.offline_threshold', str(offline_threshold)) + saved.append('offline_threshold') + self.db_manager.set_metadata('radio.offline_alert_enabled', 'true' if alert_enabled else 'false') + saved.append('alert_enabled') + self.db_manager.set_metadata('radio.offline_alert_email', alert_email) + saved.append('alert_email') + + self.logger.info("Radio offline alert config updated (metadata): %s", ', '.join(saved)) + + # Optionally save to config.ini + config_saved = False + if data.get('save_to_config', False): + try: + self.config.set('Connection', 'radio_offline_threshold', str(offline_threshold)) + self.config.set('Connection', 'radio_offline_alert_enabled', 'true' if alert_enabled else 'false') + self.config.set('Connection', 'radio_offline_alert_email', alert_email) + with open(self.config_path, 'w') as f: + self.config.write(f) + config_saved = True + self.logger.info("Radio offline alert settings written to config.ini") + except Exception as exc: + self.logger.error("Failed to write radio offline alert settings to config.ini: %s", exc) + + return jsonify({'success': True, 'saved': saved, 'config_saved': config_saved}) + except Exception as exc: + self.logger.exception("Error saving radio offline alert config") + return jsonify({'success': False, 'error': str(exc)}), 500 + # ── Radio offline clear ────────────────────────────────────────────── @self.app.route('/api/admin/radio-offline-clear', methods=['POST']) @@ -3785,6 +3901,76 @@ class BotDataViewer: self.logger.error(f"Error queuing firmware write: {e}") return jsonify({'error': str(e)}), 500 + @self.app.route('/api/radio/params', methods=['GET']) + def api_radio_params_read(): + """Queue a radio parameter read (freq, bw, sf, cr, tx_power). Poll /api/channel-operations/.""" + try: + with self.db_manager.connection() as conn: + cursor = conn.cursor() + cursor.execute( + "INSERT INTO channel_operations (operation_type, status) VALUES ('radio_params_read', 'pending')" + ) + conn.commit() + op_id = cursor.lastrowid + return jsonify({'success': True, 'operation_id': op_id}) + except Exception as e: + self.logger.error(f"Error queuing radio params read: {e}") + return jsonify({'error': str(e)}), 500 + + @self.app.route('/api/radio/params', methods=['POST']) + def api_radio_params_write(): + """Queue a radio parameter write. Body: {freq, bw, sf, cr, tx_power}. + Poll /api/channel-operations/ for result.""" + try: + data = request.get_json(silent=True) or {} + allowed = {'freq', 'bw', 'sf', 'cr', 'tx_power'} + payload = {k: v for k, v in data.items() if k in allowed} + if not payload: + return jsonify({'error': 'No valid fields (freq, bw, sf, cr, tx_power)'}), 400 + + if 'freq' in payload: + freq = float(payload['freq']) + if not (100.0 <= freq <= 1700.0): + return jsonify({'error': 'freq must be 100–1700 MHz'}), 400 + payload['freq'] = freq + if 'bw' in payload: + bw = float(payload['bw']) + if bw not in (62.5, 125.0, 250.0, 500.0): + return jsonify({'error': 'bw must be 62.5, 125, 250, or 500 kHz'}), 400 + payload['bw'] = bw + if 'sf' in payload: + sf = int(payload['sf']) + if not (5 <= sf <= 12): + return jsonify({'error': 'sf must be 5–12'}), 400 + payload['sf'] = sf + if 'cr' in payload: + cr = int(payload['cr']) + if not (5 <= cr <= 8): + return jsonify({'error': 'cr must be 5–8'}), 400 + payload['cr'] = cr + if 'tx_power' in payload: + tx = int(payload['tx_power']) + if not (1 <= tx <= 30): + return jsonify({'error': 'tx_power must be 1–30 dBm'}), 400 + payload['tx_power'] = tx + + radio_fields = {'freq', 'bw', 'sf', 'cr'} + if radio_fields & set(payload) and not radio_fields <= set(payload): + return jsonify({'error': 'freq, bw, sf, and cr must all be provided together'}), 400 + + with self.db_manager.connection() as conn: + cursor = conn.cursor() + cursor.execute( + "INSERT INTO channel_operations (operation_type, payload_data, status) VALUES ('radio_params_write', ?, 'pending')", + (json.dumps(payload),) + ) + conn.commit() + op_id = cursor.lastrowid + return jsonify({'success': True, 'operation_id': op_id}) + except Exception as e: + self.logger.error(f"Error queuing radio params write: {e}") + return jsonify({'error': str(e)}), 500 + def _setup_socketio_handlers(self): """Setup SocketIO event handlers using modern patterns""" diff --git a/modules/web_viewer/templates/api_explorer.html b/modules/web_viewer/templates/api_explorer.html index de98198..f4f17dd 100644 --- a/modules/web_viewer/templates/api_explorer.html +++ b/modules/web_viewer/templates/api_explorer.html @@ -138,6 +138,12 @@ ("POST", "/api/config/logging", "Update log level configuration"), ("GET", "/api/config/zombie-alert", "Get zombie-alert thresholds"), ("POST", "/api/config/zombie-alert", "Update zombie-alert thresholds"), + ("GET", "/api/config/radio-debug", "Get radio debug logging setting"), + ("POST", "/api/config/radio-debug", "Update radio debug logging setting"), + ("GET", "/api/config/radio-probe", "Get radio probe settings"), + ("POST", "/api/config/radio-probe", "Update radio probe settings"), + ("GET", "/api/config/radio-offline-alert","Get radio offline alert settings"), + ("POST", "/api/config/radio-offline-alert","Update radio offline alert settings"), ]), ("Greeter", "fa-hand-sparkles", [ ("GET", "/api/greeter", "Greeter configuration and rollout state"), diff --git a/modules/web_viewer/templates/config.html b/modules/web_viewer/templates/config.html index 00757c9..b672755 100644 --- a/modules/web_viewer/templates/config.html +++ b/modules/web_viewer/templates/config.html @@ -562,13 +562,30 @@ class RadioReliabilityManager { this.debugApplyBtn = document.getElementById('apply-radio-debug-btn'); this.debugStatusEl = document.getElementById('radio-debug-status'); - if (!this.zombieEnabledEl || !this.debugEnabledEl) return; + this.probeIntervalEl = document.getElementById('radio-probe-interval'); + this.probeFailThresholdEl = document.getElementById('radio-probe-fail-threshold'); + this.probeIniEl = document.getElementById('radio-probe-ini-summary'); + this.probeSaveBtn = document.getElementById('save-radio-probe-btn'); + this.probeSaveConfigBtn = document.getElementById('save-radio-probe-config-btn'); + this.probeStatusEl = document.getElementById('radio-probe-status'); + + this.offlineThresholdEl = document.getElementById('radio-offline-threshold'); + this.offlineAlertEnabledEl = document.getElementById('radio-offline-alert-enabled-toggle'); + this.offlineAlertEmailEl = document.getElementById('radio-offline-alert-email'); + this.offlineIniEl = document.getElementById('radio-offline-ini-summary'); + this.offlineSaveBtn = document.getElementById('save-radio-offline-btn'); + this.offlineSaveConfigBtn = document.getElementById('save-radio-offline-config-btn'); + this.offlineStatusEl = document.getElementById('radio-offline-status'); + + if (!this.zombieEnabledEl || !this.debugEnabledEl || !this.probeIntervalEl || !this.offlineThresholdEl) return; this.initialize(); } async initialize() { await this.loadZombieAlertConfig(); await this.loadRadioDebugConfig(); + await this.loadRadioProbeConfig(); + await this.loadRadioOfflineAlertConfig(); this.setupEventHandlers(); } @@ -578,6 +595,10 @@ class RadioReliabilityManager { this.debugSaveBtn.addEventListener('click', () => this.saveRadioDebug(false, false)); this.debugSaveConfigBtn.addEventListener('click', () => this.saveRadioDebug(true, false)); this.debugApplyBtn.addEventListener('click', () => this.saveRadioDebug(true, true)); + this.probeSaveBtn.addEventListener('click', () => this.saveRadioProbe(false)); + this.probeSaveConfigBtn.addEventListener('click', () => this.saveRadioProbe(true)); + this.offlineSaveBtn.addEventListener('click', () => this.saveRadioOfflineAlert(false)); + this.offlineSaveConfigBtn.addEventListener('click', () => this.saveRadioOfflineAlert(true)); } async loadZombieAlertConfig() { @@ -675,6 +696,96 @@ class RadioReliabilityManager { } } + async loadRadioProbeConfig() { + try { + const resp = await fetch('/api/config/radio-probe'); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const data = await resp.json(); + this.probeIntervalEl.value = data.probe_interval_seconds || 300; + this.probeFailThresholdEl.value = data.probe_fail_threshold || 3; + this.probeIniEl.textContent = `interval=${data.probe_interval_seconds || 300}s, threshold=${data.probe_fail_threshold || 3}`; + } catch (err) { + this.showStatus(this.probeStatusEl, `Failed to load radio probe config: ${err.message}`, 'danger'); + } + } + + async saveRadioProbe(writeToConfig) { + this.setBusy([this.probeSaveBtn, this.probeSaveConfigBtn], true); + this.hideStatus(this.probeStatusEl); + try { + const resp = await fetch('/api/config/radio-probe', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify({ + probe_interval_seconds: parseInt(this.probeIntervalEl.value), + probe_fail_threshold: parseInt(this.probeFailThresholdEl.value), + save_to_config: writeToConfig, + }), + }); + const data = await resp.json(); + if (!resp.ok || !data.success) throw new Error(data.error || 'Save failed'); + this.showStatus( + this.probeStatusEl, + writeToConfig ? 'Saved to metadata and config.ini.' : 'Saved to metadata.', + 'success', + ); + await this.loadRadioProbeConfig(); + } catch (err) { + this.showStatus(this.probeStatusEl, `Save failed: ${err.message}`, 'danger'); + } finally { + this.setBusy([this.probeSaveBtn, this.probeSaveConfigBtn], false); + } + } + + async loadRadioOfflineAlertConfig() { + try { + const resp = await fetch('/api/config/radio-offline-alert'); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const data = await resp.json(); + this.offlineThresholdEl.value = data.offline_threshold || 3; + this.offlineAlertEnabledEl.checked = data.alert_enabled || false; + this.offlineAlertEmailEl.value = data.alert_email || ''; + this.offlineIniEl.textContent = `threshold=${data.offline_threshold || 3}, enabled=${data.alert_enabled || false}, email=${data.alert_email || '(blank)'}`; + } catch (err) { + this.showStatus(this.offlineStatusEl, `Failed to load radio offline alert config: ${err.message}`, 'danger'); + } + } + + async saveRadioOfflineAlert(writeToConfig) { + this.setBusy([this.offlineSaveBtn, this.offlineSaveConfigBtn], true); + this.hideStatus(this.offlineStatusEl); + try { + const resp = await fetch('/api/config/radio-offline-alert', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify({ + offline_threshold: parseInt(this.offlineThresholdEl.value), + alert_enabled: this.offlineAlertEnabledEl.checked, + alert_email: this.offlineAlertEmailEl.value.trim(), + save_to_config: writeToConfig, + }), + }); + const data = await resp.json(); + if (!resp.ok || !data.success) throw new Error(data.error || 'Save failed'); + this.showStatus( + this.offlineStatusEl, + writeToConfig ? 'Saved to metadata and config.ini.' : 'Saved to metadata.', + 'success', + ); + await this.loadRadioOfflineAlertConfig(); + } catch (err) { + this.showStatus(this.offlineStatusEl, `Save failed: ${err.message}`, 'danger'); + } finally { + this.setBusy([this.offlineSaveBtn, this.offlineSaveConfigBtn], false); + } + } + setBusy(buttons, busy) { buttons.forEach(btn => { if (btn) btn.disabled = busy; diff --git a/modules/web_viewer/templates/config/panels/radio_reliability.html b/modules/web_viewer/templates/config/panels/radio_reliability.html index 11f4657..8a137b2 100644 --- a/modules/web_viewer/templates/config/panels/radio_reliability.html +++ b/modules/web_viewer/templates/config/panels/radio_reliability.html @@ -68,5 +68,80 @@ + +
+
+
+ Radio Health Probe +
+
+ + +
+ How often to check radio health (300-900 seconds). Default: 300 +
+
+
+ + +
+ Consecutive failed probes before zombie state. Default: 3 +
+
+
+ config.ini baseline: + loading... +
+
+ + + +
+
+ +
+
+ Radio Offline Alert +
+
+ + +
+ Consecutive send timeouts before offline state. Default: 3 +
+
+
+ + +
+
+ + +
+ Comma-separated addresses. Leave blank to fall back to nightly recipients. +
+
+
+ config.ini baseline: + loading... +
+
+ + + +
+
+
diff --git a/modules/web_viewer/templates/mesh.html b/modules/web_viewer/templates/mesh.html index aeadde0..132fcd5 100644 --- a/modules/web_viewer/templates/mesh.html +++ b/modules/web_viewer/templates/mesh.html @@ -133,6 +133,9 @@ + @@ -358,6 +361,70 @@ } + + + @@ -1063,6 +1130,7 @@ document.getElementById('btn-view-graph').classList.add('btn-outline-primary'); document.getElementById('map-view').style.display = 'block'; document.getElementById('graph-view').style.display = 'none'; + document.getElementById('btn-fullscreen').style.display = 'inline-block'; renderMap(); } else { document.getElementById('btn-view-graph').classList.add('active'); @@ -1073,6 +1141,7 @@ document.getElementById('btn-view-map').classList.add('btn-outline-primary'); document.getElementById('map-view').style.display = 'none'; document.getElementById('graph-view').style.display = 'block'; + document.getElementById('btn-fullscreen').style.display = 'none'; renderGraph(); } } @@ -2398,13 +2467,104 @@ }; const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; a.download = `mesh-graph-${new Date().toISOString().split('T')[0]}.json`; a.click(); URL.revokeObjectURL(url); } + function toggleFullscreen() { + const mapContainer = document.getElementById('map-view'); + const btn = document.getElementById('btn-fullscreen'); + const icon = btn.querySelector('i'); + + if (!document.fullscreenElement) { + // Enter fullscreen + if (mapContainer.requestFullscreen) { + mapContainer.requestFullscreen(); + } else if (mapContainer.webkitRequestFullscreen) { // Safari + mapContainer.webkitRequestFullscreen(); + } else if (mapContainer.msRequestFullscreen) { // IE11 + mapContainer.msRequestFullscreen(); + } + + // Add fullscreen class for custom styling + mapContainer.classList.add('fullscreen-mode'); + document.body.classList.add('fullscreen-active'); + + // Change icon to exit fullscreen + icon.className = 'fas fa-compress'; + + // Add exit button + const exitBtn = document.createElement('button'); + exitBtn.className = 'fullscreen-exit-btn'; + exitBtn.innerHTML = ' Exit Fullscreen'; + exitBtn.onclick = toggleFullscreen; + mapContainer.appendChild(exitBtn); + + // Invalidate map size after fullscreen transition + setTimeout(() => { + if (map) { + map.invalidateSize(); + } + }, 100); + + } else { + // Exit fullscreen + if (document.exitFullscreen) { + document.exitFullscreen(); + } else if (document.webkitExitFullscreen) { // Safari + document.webkitExitFullscreen(); + } else if (document.msExitFullscreen) { // IE11 + document.msExitFullscreen(); + } + + // Remove fullscreen class + mapContainer.classList.remove('fullscreen-mode'); + document.body.classList.remove('fullscreen-active'); + + // Change icon back to expand + icon.className = 'fas fa-expand'; + + // Remove exit button + const exitBtn = mapContainer.querySelector('.fullscreen-exit-btn'); + if (exitBtn) { + exitBtn.remove(); + } + + // Invalidate map size after exiting fullscreen + setTimeout(() => { + if (map) { + map.invalidateSize(); + } + }, 100); + } + } + + // Handle fullscreen change events (ESC key, etc.) + document.addEventListener('fullscreenchange', function() { + if (!document.fullscreenElement) { + const mapContainer = document.getElementById('map-view'); + const btn = document.getElementById('btn-fullscreen'); + const icon = btn.querySelector('i'); + + mapContainer.classList.remove('fullscreen-mode'); + document.body.classList.remove('fullscreen-active'); + icon.className = 'fas fa-expand'; + + const exitBtn = mapContainer.querySelector('.fullscreen-exit-btn'); + if (exitBtn) { + exitBtn.remove(); + } + + // Invalidate map size after exiting fullscreen + setTimeout(() => { + if (map) { + map.invalidateSize(); + } + }, 100); + } + }); + // Socket.IO setup for real-time updates // Live updates are applied only when the map view is active. When the graph view is active, // we refresh data in the background but do not re-render the graph, to avoid the chaotic diff --git a/modules/web_viewer/templates/radio.html b/modules/web_viewer/templates/radio.html index 56ac185..1f47366 100644 --- a/modules/web_viewer/templates/radio.html +++ b/modules/web_viewer/templates/radio.html @@ -58,6 +58,80 @@ + +
+
+
Radio Parameters
+ +
+
+ +
+
+
+ + + 100–1700 MHz +
+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+
+
+ + + + Changing radio parameters affects all nodes on the same frequency. + +
+
+
+
+
@@ -165,6 +239,8 @@ class RadioManager { await this.loadStatistics(); await this.loadRadioStatus(); this.setupEventHandlers(); + this.setupRadioParamsHandlers(); + this.readRadioParams({ silent: true }); // Auto-refresh every 30 seconds setInterval(() => this.loadChannels(), 30000); @@ -758,6 +834,142 @@ class RadioManager { await this.loadStatistics(); } + setupRadioParamsHandlers() { + document.getElementById('readRadioParamsBtn').addEventListener('click', () => this.readRadioParams()); + document.getElementById('writeRadioParamsBtn').addEventListener('click', () => this.writeRadioParams()); + } + + async readRadioParams({ silent = false } = {}) { + const btn = document.getElementById('readRadioParamsBtn'); + const spinner = document.getElementById('readParamsSpinner'); + const icon = document.getElementById('readParamsIcon'); + btn.disabled = true; + spinner.style.display = 'inline-block'; + icon.style.display = 'none'; + if (!silent) this.setParamsAlert('', ''); + try { + const resp = await fetch('/api/radio/params'); + const data = await resp.json(); + if (!resp.ok || !data.operation_id) { + if (!silent) this.setParamsAlert('danger', data.error || 'Failed to queue read'); + return; + } + const result = await this.pollRadioParamsOp(data.operation_id); + if (result && result.status === 'completed' && result.result_data) { + this.fillRadioParamsForm(result.result_data); + if (!silent) this.setParamsAlert('success', 'Radio parameters read successfully.'); + } else { + const msg = result?.error_message || 'Read failed'; + if (silent && msg.toLowerCase().includes('not connected')) return; + this.setParamsAlert('danger', msg); + } + } catch (e) { + if (!silent) this.setParamsAlert('danger', 'Error: ' + e.message); + } finally { + btn.disabled = false; + spinner.style.display = 'none'; + icon.style.display = 'inline'; + } + } + + async writeRadioParams() { + const freq = document.getElementById('radioFreq').value.trim(); + const bw = document.getElementById('radioBw').value; + const sf = document.getElementById('radioSf').value; + const cr = document.getElementById('radioCr').value; + const txPower = document.getElementById('radioTxPower').value.trim(); + + const payload = {}; + if (freq || bw || sf || cr) { + if (!freq || !bw || !sf || !cr) { + this.setParamsAlert('danger', 'Frequency, bandwidth, spreading factor, and coding rate must all be provided together.'); + return; + } + payload.freq = parseFloat(freq); + payload.bw = parseFloat(bw); + payload.sf = parseInt(sf); + payload.cr = parseInt(cr); + } + if (txPower) { + payload.tx_power = parseInt(txPower); + } + if (!Object.keys(payload).length) { + this.setParamsAlert('danger', 'No parameters to write.'); + return; + } + + if (!confirm('Write these radio parameters to the device? This will affect communication with all other nodes on the same frequency.')) { + return; + } + + const btn = document.getElementById('writeRadioParamsBtn'); + const spinner = document.getElementById('writeParamsSpinner'); + btn.disabled = true; + spinner.style.display = 'inline-block'; + this.setParamsAlert('', ''); + try { + const resp = await fetch('/api/radio/params', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, + body: JSON.stringify(payload) + }); + const data = await resp.json(); + if (!resp.ok || !data.operation_id) { + this.setParamsAlert('danger', data.error || 'Failed to queue write'); + return; + } + const result = await this.pollRadioParamsOp(data.operation_id); + if (result && result.status === 'completed') { + this.setParamsAlert('success', 'Radio parameters written successfully.'); + } else { + this.setParamsAlert('danger', result?.error_message || 'Write failed'); + } + } catch (e) { + this.setParamsAlert('danger', 'Error: ' + e.message); + } finally { + btn.disabled = false; + spinner.style.display = 'none'; + } + } + + async pollRadioParamsOp(opId, maxAttempts = 30) { + for (let i = 0; i < maxAttempts; i++) { + await new Promise(r => setTimeout(r, 1000)); + try { + const resp = await fetch(`/api/channel-operations/${opId}`); + const data = await resp.json(); + if (data.status === 'completed' || data.status === 'failed') return data; + } catch (_) {} + } + return { status: 'failed', error_message: 'Timed out waiting for device response' }; + } + + fillRadioParamsForm(params) { + if (params.freq != null) document.getElementById('radioFreq').value = params.freq; + if (params.bw != null) { + const sel = document.getElementById('radioBw'); + sel.value = String(params.bw); + if (!sel.value) sel.value = ''; + } + if (params.sf != null) document.getElementById('radioSf').value = String(params.sf); + if (params.cr != null) document.getElementById('radioCr').value = String(params.cr); + if (params.tx_power != null) { + document.getElementById('radioTxPower').value = params.tx_power; + if (params.max_tx_power != null) { + document.getElementById('radioTxPower').max = params.max_tx_power; + document.getElementById('maxTxPowerHint').textContent = `Max: ${params.max_tx_power} dBm`; + } + } + } + + setParamsAlert(type, message) { + const el = document.getElementById('radioParamsAlert'); + if (!type || !message) { el.style.display = 'none'; return; } + el.className = `alert alert-${type}`; + el.textContent = message; + el.style.display = 'block'; + } + showError(message) { const alert = document.createElement('div'); alert.className = 'alert alert-danger alert-dismissible fade show position-fixed';