mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-08-02 00:39:28 +00:00
Adds device-level companion settings to /radio, headlined by the response path hash size (mode 0-2 = 1-3 bytes per hop) wired to the existing firmware config endpoints. New sections: Identity & Adverts (name, advert lat/lon, location policy, zero-hop/flood advert buttons), Mesh Behavior (extra ACKs, telemetry permissions), and write-only Advanced Tuning (RX delay base, airtime factor, sent x1000 per the wire format). Backend: GET /api/radio/params now returns the full SELF_INFO node fields; POST accepts the new fields with validation; new POST /api/radio/advert; scheduler ops handle the writes (other-params group as one read-modify-write frame so partial updates never clobber device state). Config-managed settings are not writable from the panel: new-contact mode is owned by [Bot] auto_manage_contacts and shown read-only; the node name is locked while bot_name + auto_update_device_name manage it. loop.detect is removed from the firmware endpoints entirely - it is repeater/room-server CLI config, and companion custom vars map to sensor settings. Also: dark-mode styling for disabled form fields (base.html), TASK-01 guard test updated for the deliberate reintroduction, 23 endpoint/template tests.
1436 lines
68 KiB
HTML
1436 lines
68 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>
|
||
|
||
<!-- Node Settings -->
|
||
<div class="card mb-4" id="node-settings-card">
|
||
<div class="card-header d-flex justify-content-between align-items-center">
|
||
<h5 class="mb-0"><i class="fas fa-microchip me-2"></i>Node Settings</h5>
|
||
<button class="btn btn-sm btn-outline-secondary" id="readNodeSettingsBtn">
|
||
<span class="spinner-border spinner-border-sm me-1" id="readNodeSpinner" style="display:none" role="status"></span>
|
||
<i class="fas fa-sync me-1" id="readNodeIcon"></i>Read from Device
|
||
</button>
|
||
</div>
|
||
<div class="card-body">
|
||
<div id="nodeSettingsAlert" style="display:none"></div>
|
||
|
||
<!-- Response Path Hashing -->
|
||
<h6 class="text-uppercase text-muted fw-semibold mb-3" style="font-size:.75rem;letter-spacing:.05em;">
|
||
Response Path Hashing
|
||
</h6>
|
||
<div class="row g-3 align-items-end">
|
||
<div class="col-md-3">
|
||
<label class="form-label" for="nodePathHashMode">Path Hash Size</label>
|
||
<select class="form-select" id="nodePathHashMode">
|
||
<option value="">—</option>
|
||
<option value="0">1 byte per hop (mode 0)</option>
|
||
<option value="1">2 bytes per hop (mode 1)</option>
|
||
<option value="2">3 bytes per hop (mode 2)</option>
|
||
</select>
|
||
</div>
|
||
<div class="col-md-auto">
|
||
<button type="button" class="btn btn-warning" id="savePathHashBtn">
|
||
<span class="spinner-border spinner-border-sm me-1" id="savePathHashSpinner" style="display:none" role="status"></span>
|
||
<i class="fas fa-upload me-1"></i>Write Path Settings
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<small class="form-text text-muted d-block mt-2">
|
||
Hash bytes per hop for outgoing (response) paths — mode 0 is the legacy
|
||
default; larger hashes avoid relay collisions but need firmware 1.14+
|
||
mesh-wide.
|
||
</small>
|
||
|
||
<hr class="my-4">
|
||
|
||
<!-- Identity & Adverts -->
|
||
<h6 class="text-uppercase text-muted fw-semibold mb-3" style="font-size:.75rem;letter-spacing:.05em;">
|
||
Identity & Adverts
|
||
</h6>
|
||
<div class="row g-3">
|
||
<div class="col-md-3">
|
||
<label class="form-label" for="nodeName">Node Name</label>
|
||
<input type="text" class="form-control" id="nodeName" maxlength="32"
|
||
placeholder="e.g. MyBot"{% if device_name_managed %} disabled{% endif %}>
|
||
{% if device_name_managed %}
|
||
<small class="form-text text-muted">
|
||
Managed by <code>[Bot] bot_name</code> in config.ini —
|
||
reapplied at startup.
|
||
</small>
|
||
{% else %}
|
||
<small class="form-text text-muted">Advertised name (max 32 bytes).</small>
|
||
{% endif %}
|
||
</div>
|
||
<div class="col-md-2">
|
||
<label class="form-label" for="nodeLat">Latitude</label>
|
||
<input type="number" class="form-control" id="nodeLat" step="0.000001" min="-90" max="90" placeholder="e.g. 47.6">
|
||
</div>
|
||
<div class="col-md-2">
|
||
<label class="form-label" for="nodeLon">Longitude</label>
|
||
<input type="number" class="form-control" id="nodeLon" step="0.000001" min="-180" max="180" placeholder="e.g. -122.3">
|
||
</div>
|
||
<div class="col-md-3">
|
||
<label class="form-label" for="nodeAdvLocPolicy">Advert Location Policy</label>
|
||
<select class="form-select" id="nodeAdvLocPolicy">
|
||
<option value="">—</option>
|
||
<option value="0">Don't include location</option>
|
||
<option value="1">Include location</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="mt-3 d-flex gap-2 flex-wrap">
|
||
<button type="button" class="btn btn-warning" id="saveIdentityBtn">
|
||
<span class="spinner-border spinner-border-sm me-1" id="saveIdentitySpinner" style="display:none" role="status"></span>
|
||
<i class="fas fa-upload me-1"></i>Write Identity
|
||
</button>
|
||
<button type="button" class="btn btn-outline-primary" id="sendAdvertZeroBtn">
|
||
<span class="spinner-border spinner-border-sm me-1" id="advertZeroSpinner" style="display:none" role="status"></span>
|
||
<i class="fas fa-bullhorn me-1"></i>Send Advert (Zero-Hop)
|
||
</button>
|
||
<button type="button" class="btn btn-outline-primary" id="sendAdvertFloodBtn">
|
||
<span class="spinner-border spinner-border-sm me-1" id="advertFloodSpinner" style="display:none" role="status"></span>
|
||
<i class="fas fa-water me-1"></i>Send Flood Advert
|
||
</button>
|
||
</div>
|
||
|
||
<hr class="my-4">
|
||
|
||
<!-- Mesh Behavior -->
|
||
<h6 class="text-uppercase text-muted fw-semibold mb-3" style="font-size:.75rem;letter-spacing:.05em;">
|
||
Mesh Behavior
|
||
</h6>
|
||
<div class="row g-3">
|
||
<div class="col-md-3">
|
||
<label class="form-label" for="nodeNewContactsMode">New Contacts</label>
|
||
<input type="text" class="form-control" id="nodeNewContactsMode" disabled
|
||
value="{% if auto_manage_contacts == 'device' %}Device auto-add (firmware slot policy){% elif auto_manage_contacts == 'bot' %}Bot-managed auto-add{% else %}Manual (!repeater commands){% endif %}">
|
||
</div>
|
||
<div class="col-md-2">
|
||
<label class="form-label" for="nodeMultiAcks">Extra ACKs</label>
|
||
<select class="form-select" id="nodeMultiAcks">
|
||
<option value="">—</option>
|
||
<option value="0">0 (default)</option>
|
||
<option value="1">1</option>
|
||
<option value="2">2</option>
|
||
<option value="3">3</option>
|
||
</select>
|
||
</div>
|
||
<div class="col-md-2">
|
||
<label class="form-label" for="nodeTelemBase">Base Telemetry</label>
|
||
<select class="form-select" id="nodeTelemBase">
|
||
<option value="">—</option>
|
||
<option value="0">Deny</option>
|
||
<option value="1">Per-contact flags</option>
|
||
<option value="2">Allow all</option>
|
||
</select>
|
||
</div>
|
||
<div class="col-md-2">
|
||
<label class="form-label" for="nodeTelemLoc">Location Telemetry</label>
|
||
<select class="form-select" id="nodeTelemLoc">
|
||
<option value="">—</option>
|
||
<option value="0">Deny</option>
|
||
<option value="1">Per-contact flags</option>
|
||
<option value="2">Allow all</option>
|
||
</select>
|
||
</div>
|
||
<div class="col-md-2">
|
||
<label class="form-label" for="nodeTelemEnv">Env. Telemetry</label>
|
||
<select class="form-select" id="nodeTelemEnv">
|
||
<option value="">—</option>
|
||
<option value="0">Deny</option>
|
||
<option value="1">Per-contact flags</option>
|
||
<option value="2">Allow all</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<small class="form-text text-muted d-block mt-2">
|
||
New-contact handling is managed by <code>[Bot] auto_manage_contacts</code>
|
||
in config.ini. Extra ACKs improve delivery at the cost of airtime.
|
||
Telemetry permissions control who may request each reading type.
|
||
</small>
|
||
<div class="mt-3">
|
||
<button type="button" class="btn btn-warning" id="saveMeshBtn">
|
||
<span class="spinner-border spinner-border-sm me-1" id="saveMeshSpinner" style="display:none" role="status"></span>
|
||
<i class="fas fa-upload me-1"></i>Write Mesh Behavior
|
||
</button>
|
||
</div>
|
||
|
||
<hr class="my-4">
|
||
|
||
<!-- Advanced Tuning -->
|
||
<details>
|
||
<summary class="text-uppercase text-muted fw-semibold mb-3" style="font-size:.75rem;letter-spacing:.05em;cursor:pointer;">
|
||
Advanced Tuning
|
||
</summary>
|
||
<div class="row g-3 mt-1 align-items-end">
|
||
<div class="col-md-2">
|
||
<label class="form-label" for="nodeRxDelay">RX Delay Base (s)</label>
|
||
<input type="number" class="form-control" id="nodeRxDelay" step="0.1" min="0" max="20" placeholder="e.g. 0">
|
||
</div>
|
||
<div class="col-md-2">
|
||
<label class="form-label" for="nodeAirtimeFactor">Airtime Factor</label>
|
||
<input type="number" class="form-control" id="nodeAirtimeFactor" step="0.1" min="0" max="9" placeholder="e.g. 1">
|
||
</div>
|
||
<div class="col-md-auto">
|
||
<button type="button" class="btn btn-outline-warning" id="applyTuningBtn">
|
||
<span class="spinner-border spinner-border-sm me-1" id="applyTuningSpinner" style="display:none" role="status"></span>
|
||
<i class="fas fa-wrench me-1"></i>Apply Tuning
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<small class="form-text text-muted d-block mt-2">
|
||
<i class="fas fa-exclamation-triangle text-warning me-1"></i>
|
||
Write-only: current values cannot be read back from the device. Both
|
||
fields are required and affect relay timing/duty-cycle behavior.
|
||
</small>
|
||
</details>
|
||
</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.setupNodeSettingsHandlers();
|
||
this.readRadioParams({ silent: true });
|
||
this.readFirmwareConfig({ 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);
|
||
this.fillNodeSettingsForm(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';
|
||
}
|
||
|
||
setupNodeSettingsHandlers() {
|
||
document.getElementById('readNodeSettingsBtn').addEventListener('click', () => {
|
||
this.readRadioParams();
|
||
this.readFirmwareConfig();
|
||
});
|
||
document.getElementById('savePathHashBtn').addEventListener('click', () => this.savePathHash());
|
||
document.getElementById('saveIdentityBtn').addEventListener('click', () => this.saveIdentity());
|
||
document.getElementById('saveMeshBtn').addEventListener('click', () => this.saveMesh());
|
||
document.getElementById('applyTuningBtn').addEventListener('click', () => this.applyTuning());
|
||
document.getElementById('sendAdvertZeroBtn').addEventListener('click', () => this.sendAdvert(false));
|
||
document.getElementById('sendAdvertFloodBtn').addEventListener('click', () => this.sendAdvert(true));
|
||
}
|
||
|
||
setNodeAlert(type, message) {
|
||
const el = document.getElementById('nodeSettingsAlert');
|
||
if (!type || !message) { el.style.display = 'none'; return; }
|
||
el.className = `alert alert-${type}`;
|
||
el.textContent = message;
|
||
el.style.display = 'block';
|
||
}
|
||
|
||
// Queue an operation via POST (or GET when payload is null), poll it, and
|
||
// return the completed/failed operation record.
|
||
async queueAndPoll(url, payload) {
|
||
const options = payload === null
|
||
? undefined
|
||
: {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
||
body: JSON.stringify(payload)
|
||
};
|
||
const resp = await fetch(url, options);
|
||
const data = await resp.json();
|
||
if (!resp.ok || !data.operation_id) {
|
||
return { status: 'failed', error_message: data.error || 'Failed to queue operation' };
|
||
}
|
||
return await this.pollRadioParamsOp(data.operation_id);
|
||
}
|
||
|
||
// Run an async write action with button/spinner state and shared alert handling.
|
||
async runNodeAction(btnId, spinnerId, action) {
|
||
const btn = document.getElementById(btnId);
|
||
const spinner = document.getElementById(spinnerId);
|
||
btn.disabled = true;
|
||
spinner.style.display = 'inline-block';
|
||
this.setNodeAlert('', '');
|
||
try {
|
||
await action();
|
||
} catch (e) {
|
||
this.setNodeAlert('danger', 'Error: ' + e.message);
|
||
} finally {
|
||
btn.disabled = false;
|
||
spinner.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
async readFirmwareConfig({ silent = false } = {}) {
|
||
try {
|
||
const result = await this.queueAndPoll('/api/radio/firmware/config/read', {});
|
||
if (result && result.status === 'completed' && result.result_data) {
|
||
this.fillFirmwareConfigForm(result.result_data);
|
||
if (!silent) this.setNodeAlert('success', 'Node settings read successfully.');
|
||
} else {
|
||
const msg = result?.error_message || 'Firmware config read failed';
|
||
if (silent && msg.toLowerCase().includes('not connected')) return;
|
||
this.setNodeAlert('danger', msg);
|
||
}
|
||
} catch (e) {
|
||
if (!silent) this.setNodeAlert('danger', 'Error: ' + e.message);
|
||
}
|
||
}
|
||
|
||
fillNodeSettingsForm(params) {
|
||
if (params.name != null) document.getElementById('nodeName').value = params.name;
|
||
if (params.adv_lat != null) document.getElementById('nodeLat').value = params.adv_lat;
|
||
if (params.adv_lon != null) document.getElementById('nodeLon').value = params.adv_lon;
|
||
if (params.adv_loc_policy != null) document.getElementById('nodeAdvLocPolicy').value = String(params.adv_loc_policy);
|
||
if (params.multi_acks != null) document.getElementById('nodeMultiAcks').value = String(params.multi_acks);
|
||
if (params.telemetry_mode_base != null) document.getElementById('nodeTelemBase').value = String(params.telemetry_mode_base);
|
||
if (params.telemetry_mode_loc != null) document.getElementById('nodeTelemLoc').value = String(params.telemetry_mode_loc);
|
||
if (params.telemetry_mode_env != null) document.getElementById('nodeTelemEnv').value = String(params.telemetry_mode_env);
|
||
}
|
||
|
||
fillFirmwareConfigForm(data) {
|
||
if (data.path_hash_mode != null && data.path_hash_mode >= 0) {
|
||
document.getElementById('nodePathHashMode').value = String(data.path_hash_mode);
|
||
}
|
||
}
|
||
|
||
async savePathHash() {
|
||
const mode = document.getElementById('nodePathHashMode').value;
|
||
if (mode === '') {
|
||
this.setNodeAlert('danger', 'No path settings to write.');
|
||
return;
|
||
}
|
||
const payload = { path_hash_mode: parseInt(mode) };
|
||
if (!confirm('Change the path hash size? Responses may fail to route until other nodes on the mesh run firmware that understands this hash size.')) {
|
||
return;
|
||
}
|
||
await this.runNodeAction('savePathHashBtn', 'savePathHashSpinner', async () => {
|
||
const result = await this.queueAndPoll('/api/radio/firmware/config/write', payload);
|
||
if (result && result.status === 'completed') {
|
||
this.setNodeAlert('success', 'Path settings written successfully.');
|
||
} else {
|
||
this.setNodeAlert('danger', result?.error_message || 'Write failed');
|
||
}
|
||
});
|
||
}
|
||
|
||
async saveIdentity() {
|
||
const nameInput = document.getElementById('nodeName');
|
||
// A disabled name field is config-managed ([Bot] bot_name); never write it.
|
||
const name = nameInput.disabled ? '' : nameInput.value.trim();
|
||
const lat = document.getElementById('nodeLat').value.trim();
|
||
const lon = document.getElementById('nodeLon').value.trim();
|
||
const policy = document.getElementById('nodeAdvLocPolicy').value;
|
||
|
||
if ((lat === '') !== (lon === '')) {
|
||
this.setNodeAlert('danger', 'Latitude and longitude must be provided together.');
|
||
return;
|
||
}
|
||
const payload = {};
|
||
if (name) payload.name = name;
|
||
if (lat !== '') {
|
||
payload.lat = parseFloat(lat);
|
||
payload.lon = parseFloat(lon);
|
||
}
|
||
if (policy !== '') payload.adv_loc_policy = parseInt(policy);
|
||
if (!Object.keys(payload).length) {
|
||
this.setNodeAlert('danger', 'No identity settings to write.');
|
||
return;
|
||
}
|
||
await this.runNodeAction('saveIdentityBtn', 'saveIdentitySpinner', async () => {
|
||
const result = await this.queueAndPoll('/api/radio/params', payload);
|
||
if (result && result.status === 'completed') {
|
||
this.setNodeAlert('success', 'Identity written successfully. Send an advert to announce the changes.');
|
||
} else {
|
||
this.setNodeAlert('danger', result?.error_message || 'Write failed');
|
||
}
|
||
});
|
||
}
|
||
|
||
async saveMesh() {
|
||
const multiAcks = document.getElementById('nodeMultiAcks').value;
|
||
const telemBase = document.getElementById('nodeTelemBase').value;
|
||
const telemLoc = document.getElementById('nodeTelemLoc').value;
|
||
const telemEnv = document.getElementById('nodeTelemEnv').value;
|
||
|
||
const payload = {};
|
||
if (multiAcks !== '') payload.multi_acks = parseInt(multiAcks);
|
||
if (telemBase !== '') payload.telemetry_mode_base = parseInt(telemBase);
|
||
if (telemLoc !== '') payload.telemetry_mode_loc = parseInt(telemLoc);
|
||
if (telemEnv !== '') payload.telemetry_mode_env = parseInt(telemEnv);
|
||
if (!Object.keys(payload).length) {
|
||
this.setNodeAlert('danger', 'No mesh behavior settings to write.');
|
||
return;
|
||
}
|
||
await this.runNodeAction('saveMeshBtn', 'saveMeshSpinner', async () => {
|
||
const result = await this.queueAndPoll('/api/radio/params', payload);
|
||
if (result && result.status === 'completed') {
|
||
this.setNodeAlert('success', 'Mesh behavior written successfully.');
|
||
} else {
|
||
this.setNodeAlert('danger', result?.error_message || 'Write failed');
|
||
}
|
||
});
|
||
}
|
||
|
||
async applyTuning() {
|
||
const rxDelay = document.getElementById('nodeRxDelay').value.trim();
|
||
const airtimeFactor = document.getElementById('nodeAirtimeFactor').value.trim();
|
||
if (rxDelay === '' || airtimeFactor === '') {
|
||
this.setNodeAlert('danger', 'RX delay and airtime factor must both be provided.');
|
||
return;
|
||
}
|
||
if (!confirm('Apply tuning parameters? These affect relay timing and airtime budgeting, and current values cannot be read back from the device.')) {
|
||
return;
|
||
}
|
||
await this.runNodeAction('applyTuningBtn', 'applyTuningSpinner', async () => {
|
||
const result = await this.queueAndPoll('/api/radio/params', {
|
||
rx_delay: parseFloat(rxDelay),
|
||
airtime_factor: parseFloat(airtimeFactor)
|
||
});
|
||
if (result && result.status === 'completed') {
|
||
this.setNodeAlert('success', 'Tuning parameters written successfully.');
|
||
} else {
|
||
this.setNodeAlert('danger', result?.error_message || 'Write failed');
|
||
}
|
||
});
|
||
}
|
||
|
||
async sendAdvert(flood) {
|
||
if (flood && !confirm('Send a flood advert? It will be repeated across the whole mesh — use sparingly.')) {
|
||
return;
|
||
}
|
||
const btnId = flood ? 'sendAdvertFloodBtn' : 'sendAdvertZeroBtn';
|
||
const spinnerId = flood ? 'advertFloodSpinner' : 'advertZeroSpinner';
|
||
await this.runNodeAction(btnId, spinnerId, async () => {
|
||
const result = await this.queueAndPoll('/api/radio/advert', { flood });
|
||
if (result && result.status === 'completed') {
|
||
this.setNodeAlert('success', flood ? 'Flood advert sent.' : 'Zero-hop advert sent.');
|
||
} else {
|
||
this.setNodeAlert('danger', result?.error_message || 'Advert failed');
|
||
}
|
||
});
|
||
}
|
||
|
||
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 %}
|
||
|