mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-08-28 13:24:09 +00:00
Added Fullscreen Map and Radio settings.
This commit is contained in:
+79
-1
@@ -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
|
||||
|
||||
@@ -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 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"""
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">100–1700 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';
|
||||
|
||||
Reference in New Issue
Block a user