Added Fullscreen Map and Radio settings.

This commit is contained in:
Robowarrior834
2026-04-20 16:24:40 -04:00
parent f061df391e
commit 217a30f8d7
7 changed files with 832 additions and 4 deletions
+79 -1
View File
@@ -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
+186
View File
@@ -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/<id>."""
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/<id> 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 1001700 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 512'}), 400
payload['sf'] = sf
if 'cr' in payload:
cr = int(payload['cr'])
if not (5 <= cr <= 8):
return jsonify({'error': 'cr must be 58'}), 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 130 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"""
@@ -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"),
+112 -1
View File
@@ -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;
@@ -68,5 +68,80 @@
<span id="radio-debug-status" class="small" style="display:none;"></span>
</div>
</div>
<div class="row g-4 mt-2">
<div class="col-lg-6">
<h6 class="text-uppercase text-muted fw-semibold mb-3" style="font-size:.75rem;letter-spacing:.05em;">
Radio Health Probe
</h6>
<div class="mb-3">
<label class="form-label" for="radio-probe-interval">Probe Interval (seconds)</label>
<input type="number" class="form-control" id="radio-probe-interval" min="300" max="900" step="60">
<div class="form-text">
How often to check radio health (300-900 seconds). Default: 300
</div>
</div>
<div class="mb-3">
<label class="form-label" for="radio-probe-fail-threshold">Probe Fail Threshold</label>
<input type="number" class="form-control" id="radio-probe-fail-threshold" min="1" max="10">
<div class="form-text">
Consecutive failed probes before zombie state. Default: 3
</div>
</div>
<div class="small text-muted mb-3">
config.ini baseline:
<span id="radio-probe-ini-summary">loading...</span>
</div>
<div class="d-flex gap-2 align-items-center flex-wrap">
<button type="button" class="btn btn-primary" id="save-radio-probe-btn">
<i class="fas fa-save me-1"></i>Save
</button>
<button type="button" class="btn btn-outline-secondary" id="save-radio-probe-config-btn">
<i class="fas fa-file-export me-1"></i>Save to config.ini
</button>
<span id="radio-probe-status" class="small" style="display:none;"></span>
</div>
</div>
<div class="col-lg-6">
<h6 class="text-uppercase text-muted fw-semibold mb-3" style="font-size:.75rem;letter-spacing:.05em;">
Radio Offline Alert
</h6>
<div class="mb-3">
<label class="form-label" for="radio-offline-threshold">Offline Threshold</label>
<input type="number" class="form-control" id="radio-offline-threshold" min="1" max="10">
<div class="form-text">
Consecutive send timeouts before offline state. Default: 3
</div>
</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="radio-offline-alert-enabled-toggle" role="switch">
<label class="form-check-label" for="radio-offline-alert-enabled-toggle">
Send email alert when radio goes offline
</label>
</div>
<div class="mb-3">
<label class="form-label" for="radio-offline-alert-email">Alert recipient(s)</label>
<input type="text" class="form-control" id="radio-offline-alert-email"
placeholder="ops@example.com, admin@example.com">
<div class="form-text">
Comma-separated addresses. Leave blank to fall back to nightly recipients.
</div>
</div>
<div class="small text-muted mb-3">
config.ini baseline:
<span id="radio-offline-ini-summary">loading...</span>
</div>
<div class="d-flex gap-2 align-items-center flex-wrap">
<button type="button" class="btn btn-primary" id="save-radio-offline-btn">
<i class="fas fa-save me-1"></i>Save
</button>
<button type="button" class="btn btn-outline-secondary" id="save-radio-offline-config-btn">
<i class="fas fa-file-export me-1"></i>Save to config.ini
</button>
<span id="radio-offline-status" class="small" style="display:none;"></span>
</div>
</div>
</div>
</div>
</section>
+162 -2
View File
@@ -133,6 +133,9 @@
<button class="btn btn-outline-secondary btn-sm" onclick="exportView()">
<i class="fas fa-download"></i>
</button>
<button class="btn btn-outline-secondary btn-sm" id="btn-fullscreen" onclick="toggleFullscreen()">
<i class="fas fa-expand"></i>
</button>
</div>
</div>
</div>
@@ -358,6 +361,70 @@
}
</style>
<!-- Full screen styles -->
<style>
.fullscreen-mode {
position: fixed !important;
top: 0 !important;
left: 0 !important;
width: 100vw !important;
height: 100vh !important;
z-index: 9999 !important;
background: white;
}
.fullscreen-mode .leaflet-control-container {
z-index: 10000;
}
body.fullscreen-active {
overflow: hidden;
}
.fullscreen-mode #map-view {
height: 100vh !important;
width: 100vw !important;
}
/* Hide other elements in fullscreen mode */
.fullscreen-mode ~ .row,
.fullscreen-mode ~ .visualization-row {
display: none !important;
}
/* Show exit fullscreen button in fullscreen mode */
.fullscreen-mode .fullscreen-exit-btn {
position: absolute;
top: 10px;
right: 10px;
z-index: 10001;
background: rgba(255, 255, 255, 0.9);
border: 1px solid #ccc;
border-radius: 4px;
padding: 8px 12px;
cursor: pointer;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
}
.fullscreen-mode .fullscreen-exit-btn:hover {
background: rgba(255, 255, 255, 1);
}
[data-theme="dark"] .fullscreen-mode {
background: var(--bg-color);
}
[data-theme="dark"] .fullscreen-mode .fullscreen-exit-btn {
background: rgba(45, 45, 45, 0.9);
border-color: #555;
color: var(--text-color);
}
[data-theme="dark"] .fullscreen-mode .fullscreen-exit-btn:hover {
background: rgba(45, 45, 45, 1);
}
</style>
<!-- Leaflet JS -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<!-- vis-network JS -->
@@ -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 = '<i class="fas fa-times"></i> 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
+212
View File
@@ -58,6 +58,80 @@
</div>
</div>
<!-- Radio Parameters -->
<div class="card mb-4" id="radio-params-card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="fas fa-sliders-h me-2"></i>Radio Parameters</h5>
<button class="btn btn-sm btn-outline-secondary" id="readRadioParamsBtn">
<span class="spinner-border spinner-border-sm me-1" id="readParamsSpinner" style="display:none" role="status"></span>
<i class="fas fa-sync me-1" id="readParamsIcon"></i>Read from Device
</button>
</div>
<div class="card-body">
<div id="radioParamsAlert" style="display:none"></div>
<form id="radioParamsForm">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label" for="radioFreq">Frequency (MHz)</label>
<input type="number" class="form-control" id="radioFreq" name="freq"
step="0.001" min="100" max="1700" placeholder="e.g. 915.0">
<small class="form-text text-muted">1001700 MHz</small>
</div>
<div class="col-md-2">
<label class="form-label" for="radioBw">Bandwidth (kHz)</label>
<select class="form-select" id="radioBw" name="bw">
<option value=""></option>
<option value="62.5">62.5</option>
<option value="125">125</option>
<option value="250">250</option>
<option value="500">500</option>
</select>
</div>
<div class="col-md-2">
<label class="form-label" for="radioSf">Spreading Factor</label>
<select class="form-select" id="radioSf" name="sf">
<option value=""></option>
<option value="5">SF5</option>
<option value="6">SF6</option>
<option value="7">SF7</option>
<option value="8">SF8</option>
<option value="9">SF9</option>
<option value="10">SF10</option>
<option value="11">SF11</option>
<option value="12">SF12</option>
</select>
</div>
<div class="col-md-2">
<label class="form-label" for="radioCr">Coding Rate</label>
<select class="form-select" id="radioCr" name="cr">
<option value=""></option>
<option value="5">4/5</option>
<option value="6">4/6</option>
<option value="7">4/7</option>
<option value="8">4/8</option>
</select>
</div>
<div class="col-md-2">
<label class="form-label" for="radioTxPower">TX Power (dBm)</label>
<input type="number" class="form-control" id="radioTxPower" name="tx_power"
step="1" min="1" max="30" placeholder="e.g. 22">
<small class="form-text text-muted" id="maxTxPowerHint"></small>
</div>
</div>
<div class="mt-3 d-flex gap-2">
<button type="button" class="btn btn-warning" id="writeRadioParamsBtn">
<span class="spinner-border spinner-border-sm me-1" id="writeParamsSpinner" style="display:none" role="status"></span>
<i class="fas fa-upload me-1"></i>Write to Device
</button>
<small class="text-muted align-self-center">
<i class="fas fa-exclamation-triangle text-warning me-1"></i>
Changing radio parameters affects all nodes on the same frequency.
</small>
</div>
</form>
</div>
</div>
<!-- Channel Management -->
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
@@ -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';