mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-06-05 23:31:27 +00:00
1042 lines
46 KiB
HTML
1042 lines
46 KiB
HTML
{% extends "base.html" %}
|
||
|
||
{% block title %}Radio Settings - MeshCore Bot{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="container-fluid">
|
||
<div class="row">
|
||
<div class="col-12">
|
||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||
<h1 class="mb-0">
|
||
<i class="fas fa-broadcast-tower"></i> Radio Settings
|
||
</h1>
|
||
<div class="d-flex gap-2">
|
||
<button class="btn btn-secondary" id="connectToggleBtn" disabled>
|
||
<span class="spinner-border spinner-border-sm me-1"
|
||
id="connectBtnSpinner" style="display:none" role="status"></span>
|
||
<span id="connectBtnText">Loading...</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Statistics Cards -->
|
||
<div class="row mb-4">
|
||
<div class="col-md-3">
|
||
<div class="card">
|
||
<div class="card-body">
|
||
<h5 class="card-title">Total Channels</h5>
|
||
<h2 id="total-channels">-</h2>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<div class="card">
|
||
<div class="card-body">
|
||
<h5 class="card-title">
|
||
Available Slots
|
||
<i class="fas fa-info-circle text-muted ms-1" id="available-slots-info" style="font-size: 0.8em; cursor: help; display: none;" data-bs-toggle="tooltip"></i>
|
||
</h5>
|
||
<h2 id="available-slots">-</h2>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<div class="card">
|
||
<div class="card-body">
|
||
<h5 class="card-title">Channels with Feeds</h5>
|
||
<h2 id="channels-with-feeds">-</h2>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<div class="card">
|
||
<div class="card-body">
|
||
<h5 class="card-title">Empty Channels</h5>
|
||
<h2 id="empty-channels">-</h2>
|
||
</div>
|
||
</div>
|
||
</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">
|
||
<h5 class="mb-0">Channel Management</h5>
|
||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createChannelModal">
|
||
<i class="fas fa-plus"></i> Create Channel
|
||
</button>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="table-responsive">
|
||
<table class="table table-striped table-hover" id="channelsTable">
|
||
<thead class="table-dark">
|
||
<tr>
|
||
<th>Index</th>
|
||
<th>Name</th>
|
||
<th>Type</th>
|
||
<th>Feed Count</th>
|
||
<th>Status</th>
|
||
<th>Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="channelsTableBody">
|
||
<tr>
|
||
<td colspan="6" class="text-center">Loading...</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Create Channel Modal -->
|
||
<div class="modal fade" id="createChannelModal" tabindex="-1">
|
||
<div class="modal-dialog">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title">Create Channel</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<form id="createChannelForm">
|
||
<div class="mb-3">
|
||
<label class="form-label">Channel Name</label>
|
||
<input type="text" class="form-control" id="channelName" name="channel_name" required placeholder="#channelname or channelname">
|
||
<small class="form-text text-muted">
|
||
<strong>Hashtag channel:</strong> Start with # (e.g., #emergency) - key auto-generated<br>
|
||
<strong>Custom channel:</strong> No # prefix - you must provide a channel key
|
||
</small>
|
||
</div>
|
||
<div class="mb-3" id="channelKeyGroup" style="display: none;">
|
||
<label class="form-label">Channel Key (32 hex characters) <span class="text-danger">*</span></label>
|
||
<input type="text" class="form-control" id="channelKey" name="channel_key" placeholder="00000000000000000000000000000000" pattern="[0-9a-fA-F]{32}" maxlength="32">
|
||
<small class="form-text text-muted">Required for custom channels. Must be exactly 32 hexadecimal characters (16 bytes).</small>
|
||
</div>
|
||
<div class="mb-3">
|
||
<div class="alert alert-info">
|
||
<strong>Channel Index:</strong> <span id="autoChannelIndex">-</span>
|
||
<br><small>The lowest available channel index will be automatically selected</small>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||
<button type="button" class="btn btn-primary" id="saveChannelBtn">Create Channel</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Channel Details Modal -->
|
||
<div class="modal fade" id="channelDetailsModal" tabindex="-1">
|
||
<div class="modal-dialog modal-lg">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title">Channel Details</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body" id="channelDetailsContent">
|
||
Loading...
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block extra_js %}
|
||
<script>
|
||
class RadioManager {
|
||
constructor() {
|
||
this.channels = [];
|
||
this.maxChannels = 40; // Default, will be updated from API
|
||
this.radioConnected = null;
|
||
this.initialize();
|
||
}
|
||
|
||
async initialize() {
|
||
await this.loadChannels();
|
||
await this.loadStatistics();
|
||
await this.loadRadioStatus();
|
||
this.setupEventHandlers();
|
||
this.setupRadioParamsHandlers();
|
||
this.readRadioParams({ silent: true });
|
||
|
||
// Auto-refresh every 30 seconds
|
||
setInterval(() => this.loadChannels(), 30000);
|
||
setInterval(() => this.loadStatistics(), 60000);
|
||
setInterval(() => this.loadRadioStatus(), 15000);
|
||
}
|
||
|
||
async loadChannels() {
|
||
try {
|
||
const response = await fetch('/api/channels');
|
||
const data = await response.json();
|
||
// Filter out decode-only channels (they're for packet decoding, not radio management)
|
||
this.channels = (data.channels || []).filter(c => !c.decode_only);
|
||
this.renderChannels();
|
||
this.updateChannelIndexDisplay();
|
||
} catch (error) {
|
||
console.error('Error loading channels:', error);
|
||
this.showError('Failed to load channels: ' + error.message);
|
||
}
|
||
}
|
||
|
||
async loadStatistics() {
|
||
try {
|
||
const response = await fetch('/api/channels/stats');
|
||
const data = await response.json();
|
||
|
||
// Get max_channels from API (defaults to 40 if not provided)
|
||
this.maxChannels = data.max_channels || 40;
|
||
|
||
// Calculate statistics
|
||
const totalChannels = this.channels.length;
|
||
const availableSlots = this.maxChannels - totalChannels;
|
||
const channelsWithFeeds = Object.keys(data.channel_feed_counts || {}).length;
|
||
|
||
// Count channels that are configured but have no feeds
|
||
const feedCounts = data.channel_feed_counts || {};
|
||
let emptyChannels = 0;
|
||
for (const channel of this.channels) {
|
||
const channelName = channel.name || channel.channel_name || `Channel ${channel.channel_idx || channel.index}`;
|
||
const feedCount = feedCounts[channelName] || 0;
|
||
if (feedCount === 0) {
|
||
emptyChannels++;
|
||
}
|
||
}
|
||
|
||
document.getElementById('total-channels').textContent = totalChannels;
|
||
|
||
// Update available slots display with info if limit is artificially set
|
||
const availableSlotsElement = document.getElementById('available-slots');
|
||
const availableSlotsInfo = document.getElementById('available-slots-info');
|
||
availableSlotsElement.textContent = availableSlots;
|
||
|
||
// Show info icon and tooltip if max_channels is less than 40
|
||
if (this.maxChannels < 40) {
|
||
availableSlotsInfo.style.display = 'inline';
|
||
const tooltipText = `Limited by max_channels setting (${this.maxChannels} channels). To increase, set [Bot] max_channels in config.ini (max 40).`;
|
||
availableSlotsInfo.setAttribute('title', tooltipText);
|
||
availableSlotsInfo.setAttribute('data-bs-toggle', 'tooltip');
|
||
availableSlotsInfo.setAttribute('data-bs-placement', 'top');
|
||
// Initialize or update Bootstrap tooltip
|
||
const existingTooltip = bootstrap.Tooltip.getInstance(availableSlotsInfo);
|
||
if (existingTooltip) {
|
||
existingTooltip.dispose();
|
||
}
|
||
new bootstrap.Tooltip(availableSlotsInfo, {
|
||
title: tooltipText,
|
||
placement: 'top'
|
||
});
|
||
} else {
|
||
availableSlotsInfo.style.display = 'none';
|
||
// Dispose tooltip if it exists
|
||
const existingTooltip = bootstrap.Tooltip.getInstance(availableSlotsInfo);
|
||
if (existingTooltip) {
|
||
existingTooltip.dispose();
|
||
}
|
||
}
|
||
|
||
document.getElementById('channels-with-feeds').textContent = channelsWithFeeds;
|
||
document.getElementById('empty-channels').textContent = emptyChannels;
|
||
} catch (error) {
|
||
console.error('Error loading statistics:', error);
|
||
}
|
||
}
|
||
|
||
getLowestAvailableChannelIndex() {
|
||
return MeshCoreChannelOps.getLowestAvailableChannelIndex(this.channels, this.maxChannels);
|
||
}
|
||
|
||
updateChannelIndexDisplay() {
|
||
const display = document.getElementById('autoChannelIndex');
|
||
const lowestIndex = this.getLowestAvailableChannelIndex();
|
||
|
||
if (lowestIndex !== null) {
|
||
display.textContent = `Channel ${lowestIndex}`;
|
||
display.className = 'text-success fw-bold';
|
||
display.title = '';
|
||
} else {
|
||
const limitText = this.maxChannels < 40
|
||
? `all ${this.maxChannels} channels in use (limited by bot.max_channels setting)`
|
||
: `all ${this.maxChannels} channels in use`;
|
||
display.textContent = `No available slots (${limitText})`;
|
||
display.className = 'text-danger fw-bold';
|
||
if (this.maxChannels < 40) {
|
||
display.title = `To increase the limit, set bot.max_channels in config.ini (max 40). Current limit: ${this.maxChannels}`;
|
||
display.style.cursor = 'help';
|
||
} else {
|
||
display.title = '';
|
||
display.style.cursor = 'default';
|
||
}
|
||
}
|
||
}
|
||
|
||
renderChannels() {
|
||
const tbody = document.getElementById('channelsTableBody');
|
||
|
||
if (this.channels.length === 0) {
|
||
tbody.innerHTML = '<tr><td colspan="6" class="text-center">No channels configured</td></tr>';
|
||
return;
|
||
}
|
||
|
||
// Get feed counts per channel
|
||
fetch('/api/channels/stats').then(r => r.json()).then(stats => {
|
||
const feedCounts = stats.channel_feed_counts || {};
|
||
|
||
tbody.innerHTML = this.channels.map(channel => {
|
||
const channelName = channel.name || channel.channel_name || `Channel ${channel.channel_idx || channel.index}`;
|
||
const feedCount = feedCounts[channelName] || 0;
|
||
const typeBadge = channel.type === 'hashtag'
|
||
? '<span class="badge bg-primary">Hashtag</span>'
|
||
: '<span class="badge bg-secondary">Custom</span>';
|
||
|
||
return `
|
||
<tr>
|
||
<td>${channel.channel_idx || channel.index}</td>
|
||
<td>${channelName}</td>
|
||
<td>${typeBadge}</td>
|
||
<td>${feedCount > 0 ? `<a href="/feeds?channel=${encodeURIComponent(channelName)}">${feedCount}</a>` : '0'}</td>
|
||
<td><span class="badge bg-success">Active</span></td>
|
||
<td>
|
||
<button class="btn btn-sm btn-info" onclick="radioManager.viewChannel(${channel.channel_idx || channel.index})" title="View Details">
|
||
<i class="fas fa-eye"></i>
|
||
</button>
|
||
<button class="btn btn-sm btn-danger" onclick="radioManager.deleteChannel(${channel.channel_idx || channel.index})" title="Delete" ${feedCount > 0 ? 'disabled' : ''}>
|
||
<i class="fas fa-trash"></i>
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}).join('');
|
||
});
|
||
}
|
||
|
||
setupEventHandlers() {
|
||
// Channel name input - update key preview and toggle key input
|
||
const channelNameInput = document.getElementById('channelName');
|
||
if (channelNameInput) {
|
||
channelNameInput.addEventListener('input', (e) => {
|
||
this.handleChannelNameChange(e.target.value).catch(err => {
|
||
console.error('Error in handleChannelNameChange:', err);
|
||
});
|
||
});
|
||
}
|
||
|
||
// Save channel
|
||
const saveBtn = document.getElementById('saveChannelBtn');
|
||
if (saveBtn) {
|
||
saveBtn.addEventListener('click', () => this.saveChannel());
|
||
}
|
||
|
||
// Radio control buttons
|
||
const connectToggleBtn = document.getElementById('connectToggleBtn');
|
||
if (connectToggleBtn) {
|
||
connectToggleBtn.addEventListener('click', () => this.handleConnectToggle());
|
||
}
|
||
}
|
||
|
||
async loadRadioStatus() {
|
||
try {
|
||
const response = await fetch('/api/radio/status');
|
||
const data = await response.json();
|
||
this.radioConnected = data.connected;
|
||
this.updateConnectButton();
|
||
} catch (error) {
|
||
console.error('Error loading radio status:', error);
|
||
}
|
||
}
|
||
|
||
updateConnectButton() {
|
||
const btn = document.getElementById('connectToggleBtn');
|
||
const txt = document.getElementById('connectBtnText');
|
||
if (!btn || !txt) return;
|
||
|
||
btn.disabled = false;
|
||
if (this.radioConnected === null) {
|
||
btn.className = 'btn btn-secondary';
|
||
txt.textContent = 'Status Unknown';
|
||
} else if (this.radioConnected) {
|
||
btn.className = 'btn btn-danger';
|
||
txt.textContent = 'Disconnect';
|
||
} else {
|
||
btn.className = 'btn btn-success';
|
||
txt.textContent = 'Connect';
|
||
}
|
||
}
|
||
|
||
async handleConnectToggle() {
|
||
const action = this.radioConnected ? 'disconnect' : 'connect';
|
||
const spinner = document.getElementById('connectBtnSpinner');
|
||
const btn = document.getElementById('connectToggleBtn');
|
||
const txt = document.getElementById('connectBtnText');
|
||
|
||
btn.disabled = true;
|
||
spinner.style.display = 'inline-block';
|
||
txt.textContent = action === 'connect' ? 'Connecting...' : 'Disconnecting...';
|
||
|
||
try {
|
||
const response = await fetch('/api/radio/connect', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-Requested-With': 'XMLHttpRequest'
|
||
},
|
||
body: JSON.stringify({ action })
|
||
});
|
||
const data = await response.json();
|
||
|
||
if (response.ok && data.operation_id) {
|
||
await this.pollConnectOperation(data.operation_id, action);
|
||
} else {
|
||
this.showError(data.error || 'Failed to queue operation');
|
||
spinner.style.display = 'none';
|
||
btn.disabled = false;
|
||
this.updateConnectButton();
|
||
}
|
||
} catch (error) {
|
||
this.showError('Error: ' + error.message);
|
||
spinner.style.display = 'none';
|
||
btn.disabled = false;
|
||
this.updateConnectButton();
|
||
}
|
||
}
|
||
|
||
async pollConnectOperation(operationId, action, maxWait = 60) {
|
||
const startTime = Date.now();
|
||
const spinner = document.getElementById('connectBtnSpinner');
|
||
const txt = document.getElementById('connectBtnText');
|
||
const btn = document.getElementById('connectToggleBtn');
|
||
|
||
for (let attempts = 0; attempts < maxWait; attempts++) {
|
||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||
txt.textContent = `${action === 'connect' ? 'Connecting' : 'Disconnecting'}... (${elapsed}s)`;
|
||
|
||
try {
|
||
const response = await fetch(`/api/channel-operations/${operationId}`);
|
||
const result = await response.json();
|
||
|
||
if (result.status === 'completed') {
|
||
spinner.style.display = 'none';
|
||
await this.loadRadioStatus();
|
||
this.showSuccess(`Radio ${action}ed successfully`);
|
||
return;
|
||
} else if (result.status === 'failed') {
|
||
spinner.style.display = 'none';
|
||
btn.disabled = false;
|
||
this.updateConnectButton();
|
||
this.showError(result.error_message || `Failed to ${action} radio`);
|
||
return;
|
||
}
|
||
} catch (error) {
|
||
console.error('Error polling operation:', error);
|
||
}
|
||
}
|
||
|
||
// Timeout
|
||
spinner.style.display = 'none';
|
||
btn.disabled = false;
|
||
await this.loadRadioStatus();
|
||
this.showError('Operation timed out — check radio status.');
|
||
}
|
||
|
||
handleChannelNameChange(channelName) {
|
||
const isHashtag = channelName.trim().startsWith('#');
|
||
const keyGroup = document.getElementById('channelKeyGroup');
|
||
const keyInput = document.getElementById('channelKey');
|
||
|
||
if (isHashtag) {
|
||
// Hashtag channel - hide key input
|
||
keyGroup.style.display = 'none';
|
||
keyInput.removeAttribute('required');
|
||
} else {
|
||
// Custom channel - show key input
|
||
keyGroup.style.display = 'block';
|
||
keyInput.setAttribute('required', 'required');
|
||
}
|
||
}
|
||
|
||
async saveChannel() {
|
||
const form = document.getElementById('createChannelForm');
|
||
const formData = new FormData(form);
|
||
const saveBtn = document.getElementById('saveChannelBtn');
|
||
const originalBtnText = saveBtn.innerHTML;
|
||
|
||
const channelName = formData.get('channel_name')?.trim();
|
||
const channelKey = formData.get('channel_key')?.trim();
|
||
|
||
if (!channelName) {
|
||
this.showError('Channel name is required');
|
||
return;
|
||
}
|
||
|
||
// Get the lowest available channel index
|
||
const channelIdx = this.getLowestAvailableChannelIndex();
|
||
if (channelIdx === null) {
|
||
const limitMsg = this.maxChannels < 40
|
||
? `No available channel slots. All ${this.maxChannels} channels are in use (limited by bot.max_channels setting). To increase, set bot.max_channels in config.ini (max 40).`
|
||
: `No available channel slots. All ${this.maxChannels} channels are in use.`;
|
||
this.showError(limitMsg);
|
||
return;
|
||
}
|
||
|
||
const isHashtag = channelName.startsWith('#');
|
||
|
||
// Validate custom channel has key
|
||
if (!isHashtag && !channelKey) {
|
||
this.showError('Channel key is required for custom channels (channels without # prefix)');
|
||
return;
|
||
}
|
||
|
||
// Validate key format if provided
|
||
if (channelKey) {
|
||
if (channelKey.length !== 32) {
|
||
this.showError('Channel key must be exactly 32 hexadecimal characters');
|
||
return;
|
||
}
|
||
if (!/^[0-9a-fA-F]{32}$/.test(channelKey)) {
|
||
this.showError('Channel key must contain only hexadecimal characters (0-9, a-f, A-F)');
|
||
return;
|
||
}
|
||
}
|
||
|
||
const data = {
|
||
name: channelName,
|
||
channel_idx: channelIdx
|
||
};
|
||
|
||
// Add key only for custom channels
|
||
if (!isHashtag && channelKey) {
|
||
data.channel_key = channelKey;
|
||
}
|
||
|
||
// Disable button and show loading state
|
||
saveBtn.disabled = true;
|
||
saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status"></span>Creating...';
|
||
|
||
try {
|
||
const pr = await MeshCoreChannelOps.postChannel(data);
|
||
const result = pr.result;
|
||
|
||
if (pr.ok) {
|
||
if (result.pending && result.operation_id) {
|
||
saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status"></span>Processing...';
|
||
const pollStatus = await MeshCoreChannelOps.pollChannelOperation(result.operation_id, {
|
||
button: saveBtn,
|
||
originalButtonText: originalBtnText,
|
||
onFailed: (msg) => { this.showError(msg); },
|
||
onSlowOperationWarning: () => {
|
||
this.showError('Channel operation is taking longer than expected. The dialog will close automatically when complete.');
|
||
},
|
||
onFinalTimeout: async () => {
|
||
this.showError('Channel operation timed out. Please check the channel list to see if it was created.');
|
||
await this.loadChannels();
|
||
await this.loadStatistics();
|
||
}
|
||
});
|
||
if (pollStatus === 'completed') {
|
||
this.showSuccess('Channel created successfully');
|
||
bootstrap.Modal.getInstance(document.getElementById('createChannelModal')).hide();
|
||
form.reset();
|
||
this.handleChannelNameChange('');
|
||
await this.loadChannels();
|
||
await this.loadStatistics();
|
||
}
|
||
saveBtn.disabled = false;
|
||
saveBtn.innerHTML = originalBtnText;
|
||
} else {
|
||
this.showSuccess('Channel created successfully');
|
||
bootstrap.Modal.getInstance(document.getElementById('createChannelModal')).hide();
|
||
form.reset();
|
||
this.handleChannelNameChange('');
|
||
await this.loadChannels();
|
||
await this.loadStatistics();
|
||
saveBtn.disabled = false;
|
||
saveBtn.innerHTML = originalBtnText;
|
||
}
|
||
} else {
|
||
this.showError(result.error || 'Failed to create channel');
|
||
saveBtn.disabled = false;
|
||
saveBtn.innerHTML = originalBtnText;
|
||
}
|
||
} catch (error) {
|
||
this.showError('Error creating channel: ' + error.message);
|
||
saveBtn.disabled = false;
|
||
saveBtn.innerHTML = originalBtnText;
|
||
}
|
||
}
|
||
|
||
async viewChannel(channelIdx) {
|
||
try {
|
||
// Get channel feeds
|
||
const feedsResponse = await fetch(`/api/channels/${channelIdx}/feeds`);
|
||
const feedsData = await feedsResponse.json();
|
||
|
||
const channel = this.channels.find(c => (c.channel_idx || c.index) === channelIdx);
|
||
if (!channel) {
|
||
this.showError('Channel not found');
|
||
return;
|
||
}
|
||
|
||
const channelKey = channel.key_hex || '';
|
||
const keyDisplay = channelKey
|
||
? `<code style="font-size: 0.9em; word-break: break-all;">${channelKey}</code> <button class="btn btn-sm btn-outline-secondary ms-2" onclick="navigator.clipboard.writeText('${channelKey}'); radioManager.showSuccess('Key copied to clipboard');"><i class="fas fa-copy"></i> Copy</button>`
|
||
: '<em>No key available</em>';
|
||
|
||
const content = `
|
||
<div class="row">
|
||
<div class="col-md-6">
|
||
<h6>Channel Information</h6>
|
||
<p><strong>Name:</strong> ${channel.name || channel.channel_name}</p>
|
||
<p><strong>Index:</strong> ${channel.channel_idx || channel.index}</p>
|
||
<p><strong>Type:</strong> ${channel.type || 'Unknown'}</p>
|
||
<p><strong>Key:</strong> ${keyDisplay}</p>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<h6>Feed Subscriptions</h6>
|
||
${feedsData.feeds && feedsData.feeds.length > 0
|
||
? `<ul>${feedsData.feeds.map(f => `<li><a href="/feeds?id=${f.id}">${f.feed_name || f.feed_url}</a></li>`).join('')}</ul>`
|
||
: '<p>No feed subscriptions</p>'}
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.getElementById('channelDetailsContent').innerHTML = content;
|
||
const modal = new bootstrap.Modal(document.getElementById('channelDetailsModal'));
|
||
modal.show();
|
||
} catch (error) {
|
||
this.showError('Error loading channel details: ' + error.message);
|
||
}
|
||
}
|
||
|
||
async deleteChannel(channelIdx) {
|
||
const channel = this.channels.find(c => (c.channel_idx || c.index) === channelIdx);
|
||
if (!channel) {
|
||
this.showError('Channel not found');
|
||
return;
|
||
}
|
||
|
||
// Check if channel has feeds
|
||
try {
|
||
const feedsResponse = await fetch(`/api/channels/${channelIdx}/feeds`);
|
||
const feedsData = await feedsResponse.json();
|
||
|
||
if (feedsData.feeds && feedsData.feeds.length > 0) {
|
||
if (!confirm(`This channel has ${feedsData.feeds.length} active feed subscription(s). Are you sure you want to delete it?`)) {
|
||
return;
|
||
}
|
||
} else {
|
||
if (!confirm('Are you sure you want to delete this channel?')) {
|
||
return;
|
||
}
|
||
}
|
||
} catch (error) {
|
||
// Continue with deletion even if feed check fails
|
||
}
|
||
|
||
// Find the delete button and show loading state
|
||
const deleteButtons = document.querySelectorAll(`button[onclick*="deleteChannel(${channelIdx})"]`);
|
||
let deleteBtn = null;
|
||
if (deleteButtons.length > 0) {
|
||
deleteBtn = deleteButtons[0];
|
||
const originalHtml = deleteBtn.innerHTML;
|
||
deleteBtn.disabled = true;
|
||
deleteBtn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status"></span>';
|
||
|
||
try {
|
||
const response = await fetch(`/api/channels/${channelIdx}`, {
|
||
method: 'DELETE',
|
||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (response.ok) {
|
||
if (result.pending && result.operation_id) {
|
||
// Operation is queued, poll for status
|
||
await this.pollDeleteOperationStatus(result.operation_id, deleteBtn, originalHtml);
|
||
} else {
|
||
// Immediate success
|
||
this.showSuccess('Channel deleted successfully');
|
||
await this.loadChannels();
|
||
await this.loadStatistics();
|
||
deleteBtn.disabled = false;
|
||
deleteBtn.innerHTML = originalHtml;
|
||
}
|
||
} else {
|
||
this.showError(result.error || 'Failed to delete channel');
|
||
deleteBtn.disabled = false;
|
||
deleteBtn.innerHTML = originalHtml;
|
||
}
|
||
} catch (error) {
|
||
this.showError('Error deleting channel: ' + error.message);
|
||
if (deleteBtn) {
|
||
deleteBtn.disabled = false;
|
||
deleteBtn.innerHTML = originalHtml;
|
||
}
|
||
}
|
||
} else {
|
||
// Fallback if button not found
|
||
try {
|
||
const response = await fetch(`/api/channels/${channelIdx}`, {
|
||
method: 'DELETE',
|
||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (response.ok) {
|
||
if (result.pending && result.operation_id) {
|
||
await this.pollDeleteOperationStatus(result.operation_id);
|
||
} else {
|
||
this.showSuccess('Channel deleted successfully');
|
||
await this.loadChannels();
|
||
await this.loadStatistics();
|
||
}
|
||
} else {
|
||
this.showError(result.error || 'Failed to delete channel');
|
||
}
|
||
} catch (error) {
|
||
this.showError('Error deleting channel: ' + error.message);
|
||
}
|
||
}
|
||
}
|
||
|
||
async pollDeleteOperationStatus(operationId, button = null, originalButtonHtml = null, maxWait = 60) {
|
||
const startTime = Date.now();
|
||
const checkInterval = 1000; // Check every second
|
||
let attempts = 0;
|
||
const maxAttempts = Math.floor(maxWait * 1000 / checkInterval);
|
||
|
||
while (attempts < maxAttempts) {
|
||
await new Promise(resolve => setTimeout(resolve, checkInterval));
|
||
attempts++;
|
||
|
||
try {
|
||
const response = await fetch(`/api/channel-operations/${operationId}`);
|
||
const result = await response.json();
|
||
|
||
if (result.status === 'completed') {
|
||
this.showSuccess('Channel deleted successfully');
|
||
await this.loadChannels();
|
||
await this.loadStatistics();
|
||
if (button) {
|
||
button.disabled = false;
|
||
button.innerHTML = originalButtonHtml;
|
||
}
|
||
return;
|
||
} else if (result.status === 'failed') {
|
||
this.showError(result.error_message || 'Channel deletion failed');
|
||
if (button) {
|
||
button.disabled = false;
|
||
button.innerHTML = originalButtonHtml;
|
||
}
|
||
return;
|
||
}
|
||
// If still pending, continue polling
|
||
|
||
} catch (error) {
|
||
console.error('Error polling operation status:', error);
|
||
// Continue polling on error
|
||
}
|
||
}
|
||
|
||
// Timeout
|
||
this.showError('Channel deletion is taking longer than expected. Please check the channel list to see if it was deleted.');
|
||
if (button) {
|
||
button.disabled = false;
|
||
button.innerHTML = originalButtonHtml;
|
||
}
|
||
// Still refresh the channel list in case it was deleted
|
||
await this.loadChannels();
|
||
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';
|
||
alert.style.cssText = 'top: 20px; right: 20px; z-index: 9999; max-width: 300px;';
|
||
alert.innerHTML = `${message}<button type="button" class="btn-close" data-bs-dismiss="alert"></button>`;
|
||
document.body.appendChild(alert);
|
||
setTimeout(() => alert.remove(), 5000);
|
||
}
|
||
|
||
showSuccess(message) {
|
||
const alert = document.createElement('div');
|
||
alert.className = 'alert alert-success alert-dismissible fade show position-fixed';
|
||
alert.style.cssText = 'top: 20px; right: 20px; z-index: 9999; max-width: 300px;';
|
||
alert.innerHTML = `${message}<button type="button" class="btn-close" data-bs-dismiss="alert"></button>`;
|
||
document.body.appendChild(alert);
|
||
setTimeout(() => alert.remove(), 3000);
|
||
}
|
||
}
|
||
|
||
// Initialize radio manager when page loads
|
||
let radioManager;
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
radioManager = new RadioManager();
|
||
});
|
||
</script>
|
||
|
||
<style>
|
||
/* Dark mode table styling with better contrast - matching contacts page */
|
||
[data-theme="dark"] .table-striped > tbody > tr:nth-of-type(odd) > td {
|
||
background-color: #3a3a3a; /* Slightly lighter than even rows, but not too light */
|
||
}
|
||
|
||
[data-theme="dark"] .table-striped > tbody > tr:nth-of-type(even) > td {
|
||
background-color: #2d2d2d; /* Darker gray (card-bg) */
|
||
}
|
||
|
||
[data-theme="dark"] .table-hover > tbody > tr:hover > td {
|
||
background-color: #404040 !important; /* Slightly lighter for hover, but maintains good contrast */
|
||
}
|
||
|
||
[data-theme="dark"] .table-dark {
|
||
background-color: var(--bg-tertiary);
|
||
color: var(--text-color);
|
||
}
|
||
|
||
[data-theme="dark"] .table-dark th {
|
||
background-color: var(--bg-tertiary);
|
||
border-color: var(--border-color);
|
||
color: var(--text-color);
|
||
}
|
||
|
||
[data-theme="dark"] .table td {
|
||
border-color: var(--border-color);
|
||
color: var(--text-color);
|
||
}
|
||
|
||
/* Ensure all text in table cells is light in dark mode */
|
||
[data-theme="dark"] .table td,
|
||
[data-theme="dark"] .table td * {
|
||
color: var(--text-color) !important;
|
||
}
|
||
|
||
/* Ensure small text is also light */
|
||
[data-theme="dark"] .table td small {
|
||
color: var(--text-muted) !important;
|
||
}
|
||
</style>
|
||
{% endblock %}
|
||
|