mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-11-04 11:57:16 +00:00
Region scopes had to be hand-edited in config.ini, and the radio's own default
region could not be set from the bot at all. Both now live on the Radio page.
Node Settings gains a Default Region Scope field beside the path hash size,
which is the firmware's own setting — NodePrefs.default_scope_name and
default_scope_key, the region the radio falls back to for any send the bot does
not scope itself. Firmware that has no such command is reported as not having
answered rather than shown as an empty field, and a stored key that is not the
stored name's hash is flagged, because the radio routes by the key and the name
beside it is only a label. A build-flag default stores the bare name while
hashing the '#' form, so the comparison normalizes first.
Clearing it does not go through the meshcore library, because none of its reset
paths can: set_default_flood_scope(None) raises on len(None), "" earns
ILLEGAL_ARG from the firmware, and "*" only works because the frame is padded by
character count rather than by the name it wrote, landing one byte short of the
length the firmware reads as "a scope follows". The firmware's own contract for
clearing is a bare CMD_SET_DEFAULT_FLOOD_SCOPE, so that is what goes out. The
same padding bug is why a device scope name must be ASCII: a multi-byte
character displaces the transport key out of its field.
A separate Region Scopes card covers the bot's side — [Channels] flood_scopes as
an explicit choice between "reply whatever the scope" and an allowlist, and
outgoing_flood_scope_override. It writes config.ini, queues a hot reload, then
polls that reload and reports what the bot did with it, including saying plainly
when nothing picked it up. Per-channel flood_scope.<channel> entries are listed
read-only, since they are the reason a channel can ignore the default.
The two settings interact in a way worth stating on the page: once the bot sends
a scoped message it restores with set_flood_scope("*"), which leaves the radio in
forced-unscoped mode, so the device default stops applying until the bot scopes
another send.
Also from building it:
- flood_scopes left in [Bot] is surfaced rather than hidden. CommandManager
still honours it when [Channels] is empty, so a page that ignored it would
show "replies to every scope" while the bot enforced an allowlist — and
saving "off" hands the allowlist straight back to [Bot], which the banner now
says instead of claiming the old key stops mattering.
- outgoing_flood_scope_override = none is read as global flood on the send path,
as it already was everywhere else. send_channel_message tested the raw value
against a fixed tuple, so the lowercase spelling became the region "#none".
- Scope normalization moved to modules/flood_scope.py so the viewer, a separate
process, can validate a typed name without importing the bot's command
machinery. CommandManager delegates to it.
- Dark mode named only .border, so a .border-start divider drew Bootstrap's
light #dee2e6 onto a dark card. All four directional utilities now match.
1981 lines
94 KiB
HTML
1981 lines
94 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">
|
||
|
||
<!-- Default Region Scope (device) -->
|
||
<h6 class="text-uppercase text-muted fw-semibold mb-3" style="font-size:.75rem;letter-spacing:.05em;">
|
||
Default Region Scope
|
||
</h6>
|
||
<div class="row g-3 align-items-end">
|
||
<div class="col-md-4">
|
||
<label class="form-label" for="nodeDefaultScope">Region</label>
|
||
<input type="text" class="form-control" id="nodeDefaultScope"
|
||
maxlength="30" placeholder="blank = unscoped" autocomplete="off">
|
||
<small class="form-text text-muted" id="nodeDefaultScopeKey"></small>
|
||
</div>
|
||
<div class="col-md-auto">
|
||
<button type="button" class="btn btn-warning" id="saveDefaultScopeBtn">
|
||
<span class="spinner-border spinner-border-sm me-1" id="saveDefaultScopeSpinner"
|
||
style="display:none" role="status"></span>
|
||
<i class="fas fa-upload me-1"></i>Write Default Scope
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<small class="form-text text-muted d-block mt-2">
|
||
The radio's own default region, stored in firmware and used for any
|
||
send the bot does not scope itself. Max 30 ASCII characters including
|
||
the <code>#</code>, which is added if you omit it. Blank clears it, and
|
||
the radio then floods unscoped.
|
||
</small>
|
||
<small class="form-text text-muted d-block mt-2">
|
||
<i class="fas fa-info-circle me-1"></i>
|
||
Once the bot sends a scoped message it leaves the radio in forced-unscoped
|
||
mode, so this default stops applying until the bot scopes another send.
|
||
If the bot should post under a region, set it in
|
||
<a href="#region-scopes-card">Region Scopes</a> below rather than relying
|
||
on this.
|
||
</small>
|
||
<div class="alert alert-warning py-2 px-3 small mt-2" id="nodeDefaultScopeNote"
|
||
style="display:none"></div>
|
||
|
||
<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>
|
||
|
||
<!-- Region Scopes -->
|
||
<div class="card mb-4" id="region-scopes-card">
|
||
<div class="card-header d-flex justify-content-between align-items-center">
|
||
<h5 class="mb-0"><i class="fas fa-map-marked-alt me-2"></i>Region Scopes</h5>
|
||
<small class="text-muted" id="regionScopeTarget"></small>
|
||
</div>
|
||
<div class="card-body">
|
||
<div id="regionScopeAlert" style="display:none"></div>
|
||
|
||
<p class="text-muted small mb-4">
|
||
A region code confines a channel message to repeaters holding that
|
||
region's key, instead of flooding the whole mesh. These are bot
|
||
settings in <code>config.ini</code>, not device settings, so they
|
||
are not read from or written to the radio — the radio's own
|
||
<a href="#node-settings-card">Default Region Scope</a> is above, and
|
||
only covers sends the bot does not scope itself. See the
|
||
<a href="/region-warnings">Region Warnings</a> page for how much
|
||
unscoped traffic this mesh actually carries.
|
||
</p>
|
||
|
||
<div class="alert alert-warning py-2 px-3 small" id="regionScopeLegacy"
|
||
style="display:none"></div>
|
||
|
||
<!-- Incoming allowlist -->
|
||
<h6 class="text-uppercase text-muted fw-semibold mb-3" style="font-size:.75rem;letter-spacing:.05em;">
|
||
Which scopes the bot replies to
|
||
</h6>
|
||
<div class="form-check mb-2">
|
||
<input class="form-check-input" type="radio" name="regionScopeMode"
|
||
id="regionScopeModeAll" value="all">
|
||
<label class="form-check-label" for="regionScopeModeAll">
|
||
Reply whatever the scope
|
||
<span class="text-muted">— every message the bot hears on a monitored channel.</span>
|
||
</label>
|
||
</div>
|
||
<div class="form-check mb-3">
|
||
<input class="form-check-input" type="radio" name="regionScopeMode"
|
||
id="regionScopeModeList" value="list">
|
||
<label class="form-check-label" for="regionScopeModeList">
|
||
Only these scopes
|
||
<span class="text-muted">— reply mirrors the scope it came in on.</span>
|
||
</label>
|
||
</div>
|
||
<div class="ms-4 ps-2 border-start" id="regionScopeListFields">
|
||
<div class="row g-3">
|
||
<div class="col-md-6">
|
||
<label class="form-label" for="regionScopeList">Region scopes</label>
|
||
<input type="text" class="form-control" id="regionScopeList"
|
||
placeholder="e.g. #west, #east" autocomplete="off">
|
||
<small class="form-text text-muted">
|
||
Comma-separated. The leading <code>#</code> is added if you omit it.
|
||
</small>
|
||
</div>
|
||
<div class="col-md-6 d-flex align-items-center">
|
||
<div class="form-check mt-md-4">
|
||
<input class="form-check-input" type="checkbox" id="regionScopeAllowGlobal">
|
||
<label class="form-check-label" for="regionScopeAllowGlobal">
|
||
Also reply to unscoped (global) messages
|
||
<small class="d-block text-muted">
|
||
Adds <code>*</code>. Without it, a sender with no region set
|
||
gets no reply.
|
||
</small>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<hr class="my-4">
|
||
|
||
<!-- Outgoing default -->
|
||
<h6 class="text-uppercase text-muted fw-semibold mb-3" style="font-size:.75rem;letter-spacing:.05em;">
|
||
Default outgoing scope
|
||
</h6>
|
||
<div class="row g-3">
|
||
<div class="col-md-4">
|
||
<label class="form-label" for="regionScopeOutgoing">Scope</label>
|
||
<input type="text" class="form-control" id="regionScopeOutgoing"
|
||
placeholder="blank = global flood" autocomplete="off">
|
||
<small class="form-text text-muted">
|
||
Used for sends that have no scope of their own — scheduled
|
||
messages, feeds, webhooks — and for a reply whose incoming
|
||
scope could not be matched. It never overrides a mirrored
|
||
reply scope.
|
||
</small>
|
||
</div>
|
||
<div class="col-md-8">
|
||
<div id="regionScopeChannelOverrides" class="small text-muted"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mt-3 d-flex gap-2 align-items-center flex-wrap">
|
||
<button type="button" class="btn btn-warning" id="saveRegionScopesBtn">
|
||
<span class="spinner-border spinner-border-sm me-1" id="saveRegionScopesSpinner"
|
||
style="display:none" role="status"></span>
|
||
<i class="fas fa-save me-1"></i>Save Region Scopes
|
||
</button>
|
||
<small id="regionScopeStatus" class="text-muted"></small>
|
||
</div>
|
||
</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 nonce="{{ g.csp_nonce }}">
|
||
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.setupRegionScopeHandlers();
|
||
this.readRadioParams({ silent: true });
|
||
this.readFirmwareConfig({ silent: true });
|
||
this.loadRegionScopes();
|
||
|
||
// 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');
|
||
tbody.replaceChildren();
|
||
|
||
if (this.channels.length === 0) {
|
||
const row = document.createElement('tr');
|
||
const cell = document.createElement('td');
|
||
cell.colSpan = 6;
|
||
cell.className = 'text-center';
|
||
cell.textContent = 'No channels configured';
|
||
row.appendChild(cell);
|
||
tbody.appendChild(row);
|
||
return;
|
||
}
|
||
|
||
// Get feed counts per channel
|
||
fetch('/api/channels/stats').then(r => r.json()).then(stats => {
|
||
const feedCounts = stats.channel_feed_counts || {};
|
||
|
||
tbody.replaceChildren();
|
||
this.channels.forEach(channel => {
|
||
const channelIdx = channel.channel_idx ?? channel.index;
|
||
const channelName = channel.name || channel.channel_name || `Channel ${channelIdx}`;
|
||
const feedCount = feedCounts[channelName] || 0;
|
||
|
||
const row = document.createElement('tr');
|
||
row.appendChild(this.makeTextCell(channelIdx));
|
||
row.appendChild(this.makeTextCell(channelName));
|
||
|
||
const typeCell = document.createElement('td');
|
||
typeCell.appendChild(this.makeBadge(
|
||
channel.type === 'hashtag' ? 'Hashtag' : 'Custom',
|
||
channel.type === 'hashtag' ? 'bg-primary' : 'bg-secondary'
|
||
));
|
||
row.appendChild(typeCell);
|
||
|
||
const feedCell = document.createElement('td');
|
||
if (feedCount > 0) {
|
||
const link = document.createElement('a');
|
||
link.href = `/feeds?channel=${encodeURIComponent(String(channelName))}`;
|
||
link.textContent = String(feedCount);
|
||
feedCell.appendChild(link);
|
||
} else {
|
||
feedCell.textContent = '0';
|
||
}
|
||
row.appendChild(feedCell);
|
||
|
||
const statusCell = document.createElement('td');
|
||
statusCell.appendChild(this.makeBadge('Active', 'bg-success'));
|
||
row.appendChild(statusCell);
|
||
|
||
const actionCell = document.createElement('td');
|
||
const viewButton = this.makeIconButton('btn-info', 'fa-eye', 'View Details');
|
||
viewButton.addEventListener('click', () => this.viewChannel(channelIdx));
|
||
actionCell.appendChild(viewButton);
|
||
|
||
const deleteButton = this.makeIconButton('btn-danger', 'fa-trash', 'Delete');
|
||
deleteButton.dataset.channelIndex = String(channelIdx);
|
||
deleteButton.disabled = feedCount > 0;
|
||
deleteButton.addEventListener('click', () => this.deleteChannel(channelIdx));
|
||
actionCell.appendChild(deleteButton);
|
||
row.appendChild(actionCell);
|
||
tbody.appendChild(row);
|
||
});
|
||
});
|
||
}
|
||
|
||
makeTextCell(value) {
|
||
const cell = document.createElement('td');
|
||
cell.textContent = String(value ?? '');
|
||
return cell;
|
||
}
|
||
|
||
makeBadge(text, colorClass) {
|
||
const badge = document.createElement('span');
|
||
badge.className = `badge ${colorClass}`;
|
||
badge.textContent = String(text ?? '');
|
||
return badge;
|
||
}
|
||
|
||
makeIconButton(colorClass, iconClass, title) {
|
||
const button = document.createElement('button');
|
||
button.type = 'button';
|
||
button.className = `btn btn-sm ${colorClass}`;
|
||
button.title = title;
|
||
const icon = document.createElement('i');
|
||
icon.className = `fas ${iconClass}`;
|
||
button.appendChild(icon);
|
||
return button;
|
||
}
|
||
|
||
setLoadingButton(button, label = '') {
|
||
const spinner = document.createElement('span');
|
||
spinner.className = 'spinner-border spinner-border-sm';
|
||
spinner.setAttribute('role', 'status');
|
||
if (label) spinner.classList.add('me-2');
|
||
button.replaceChildren(spinner);
|
||
if (label) button.appendChild(document.createTextNode(label));
|
||
}
|
||
|
||
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) {
|
||
// Not a radio-only disconnect: the bot's main loop runs while it is
|
||
// connected, so disconnecting ends the process. Label it honestly.
|
||
btn.className = 'btn btn-danger';
|
||
txt.textContent = 'Stop Bot';
|
||
btn.title = 'Stops the bot process. It restarts only if managed by '
|
||
+ 'systemd or Docker.';
|
||
} else {
|
||
btn.className = 'btn btn-success';
|
||
txt.textContent = 'Connect';
|
||
btn.title = 'Reconnect the radio.';
|
||
}
|
||
}
|
||
|
||
async handleConnectToggle() {
|
||
const action = this.radioConnected ? 'disconnect' : 'connect';
|
||
|
||
if (action === 'disconnect' && !confirm(
|
||
'Stop the bot?\n\n'
|
||
+ 'This shuts the bot down completely, not just the radio link. '
|
||
+ 'It will stop responding on the mesh and this web viewer will '
|
||
+ 'become unavailable.\n\n'
|
||
+ 'It comes back only if something restarts it (systemd, Docker). '
|
||
+ 'If you started it by hand, you will need to start it again the '
|
||
+ 'same way.'
|
||
)) return;
|
||
|
||
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...' : 'Stopping...';
|
||
|
||
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' : 'Stopping'}... (${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(action === 'connect' ? 'Radio connected successfully' : 'Bot stopping…');
|
||
return;
|
||
} else if (result.status === 'failed' || result.status === 'interrupted') {
|
||
spinner.style.display = 'none';
|
||
btn.disabled = false;
|
||
this.updateConnectButton();
|
||
this.showError(result.error_message || (action === 'connect' ? 'Failed to connect radio' : 'Failed to stop bot'));
|
||
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.textContent;
|
||
|
||
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;
|
||
this.setLoadingButton(saveBtn, 'Creating...');
|
||
|
||
try {
|
||
const pr = await MeshCoreChannelOps.postChannel(data);
|
||
const result = pr.result;
|
||
|
||
if (pr.ok) {
|
||
if (result.pending && result.operation_id) {
|
||
this.setLoadingButton(saveBtn, '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.textContent = 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.textContent = originalBtnText;
|
||
}
|
||
} else {
|
||
this.showError(result.error || 'Failed to create channel');
|
||
saveBtn.disabled = false;
|
||
saveBtn.textContent = originalBtnText;
|
||
}
|
||
} catch (error) {
|
||
this.showError('Error creating channel: ' + error.message);
|
||
saveBtn.disabled = false;
|
||
saveBtn.textContent = 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 content = document.getElementById('channelDetailsContent');
|
||
const row = document.createElement('div');
|
||
row.className = 'row';
|
||
const infoColumn = document.createElement('div');
|
||
infoColumn.className = 'col-md-6';
|
||
const infoHeading = document.createElement('h6');
|
||
infoHeading.textContent = 'Channel Information';
|
||
infoColumn.appendChild(infoHeading);
|
||
infoColumn.appendChild(this.makeDetailLine('Name:', channel.name || channel.channel_name || ''));
|
||
infoColumn.appendChild(this.makeDetailLine('Index:', channel.channel_idx ?? channel.index));
|
||
infoColumn.appendChild(this.makeDetailLine('Type:', channel.type || 'Unknown'));
|
||
|
||
const keyLine = this.makeDetailLine('Key:', '');
|
||
if (channelKey) {
|
||
const code = document.createElement('code');
|
||
code.style.fontSize = '0.9em';
|
||
code.style.wordBreak = 'break-all';
|
||
code.textContent = String(channelKey);
|
||
keyLine.appendChild(code);
|
||
const copyButton = this.makeIconButton('btn-outline-secondary ms-2', 'fa-copy', 'Copy key');
|
||
copyButton.appendChild(document.createTextNode(' Copy'));
|
||
copyButton.addEventListener('click', () => {
|
||
navigator.clipboard.writeText(String(channelKey));
|
||
this.showSuccess('Key copied to clipboard');
|
||
});
|
||
keyLine.appendChild(copyButton);
|
||
} else {
|
||
const emptyKey = document.createElement('em');
|
||
emptyKey.textContent = 'No key available';
|
||
keyLine.appendChild(emptyKey);
|
||
}
|
||
infoColumn.appendChild(keyLine);
|
||
|
||
const feedsColumn = document.createElement('div');
|
||
feedsColumn.className = 'col-md-6';
|
||
const feedsHeading = document.createElement('h6');
|
||
feedsHeading.textContent = 'Feed Subscriptions';
|
||
feedsColumn.appendChild(feedsHeading);
|
||
if (Array.isArray(feedsData.feeds) && feedsData.feeds.length > 0) {
|
||
const list = document.createElement('ul');
|
||
feedsData.feeds.forEach(feed => {
|
||
const item = document.createElement('li');
|
||
const link = document.createElement('a');
|
||
link.href = `/feeds?id=${encodeURIComponent(String(feed.id ?? ''))}`;
|
||
link.textContent = String(feed.feed_name || feed.feed_url || 'Unnamed feed');
|
||
item.appendChild(link);
|
||
list.appendChild(item);
|
||
});
|
||
feedsColumn.appendChild(list);
|
||
} else {
|
||
const emptyFeeds = document.createElement('p');
|
||
emptyFeeds.textContent = 'No feed subscriptions';
|
||
feedsColumn.appendChild(emptyFeeds);
|
||
}
|
||
row.append(infoColumn, feedsColumn);
|
||
content.replaceChildren(row);
|
||
const modal = new bootstrap.Modal(document.getElementById('channelDetailsModal'));
|
||
modal.show();
|
||
} catch (error) {
|
||
this.showError('Error loading channel details: ' + error.message);
|
||
}
|
||
}
|
||
|
||
makeDetailLine(label, value) {
|
||
const line = document.createElement('p');
|
||
const strong = document.createElement('strong');
|
||
strong.textContent = `${label} `;
|
||
line.append(strong, document.createTextNode(String(value ?? '')));
|
||
return line;
|
||
}
|
||
|
||
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 deleteBtn = Array.from(document.querySelectorAll('button[data-channel-index]'))
|
||
.find(button => button.dataset.channelIndex === String(channelIdx)) || null;
|
||
if (deleteBtn) {
|
||
deleteBtn.disabled = true;
|
||
this.setLoadingButton(deleteBtn);
|
||
|
||
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);
|
||
} else {
|
||
// Immediate success
|
||
this.showSuccess('Channel deleted successfully');
|
||
await this.loadChannels();
|
||
await this.loadStatistics();
|
||
deleteBtn.disabled = false;
|
||
this.restoreDeleteButton(deleteBtn);
|
||
}
|
||
} else {
|
||
this.showError(result.error || 'Failed to delete channel');
|
||
deleteBtn.disabled = false;
|
||
this.restoreDeleteButton(deleteBtn);
|
||
}
|
||
} catch (error) {
|
||
this.showError('Error deleting channel: ' + error.message);
|
||
if (deleteBtn) {
|
||
deleteBtn.disabled = false;
|
||
this.restoreDeleteButton(deleteBtn);
|
||
}
|
||
}
|
||
} 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);
|
||
}
|
||
}
|
||
}
|
||
|
||
restoreDeleteButton(button) {
|
||
const icon = document.createElement('i');
|
||
icon.className = 'fas fa-trash';
|
||
button.replaceChildren(icon);
|
||
}
|
||
|
||
async pollDeleteOperationStatus(operationId, button = 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;
|
||
this.restoreDeleteButton(button);
|
||
}
|
||
return;
|
||
} else if (result.status === 'failed' || result.status === 'interrupted') {
|
||
this.showError(result.error_message || 'Channel deletion failed');
|
||
if (button) {
|
||
button.disabled = false;
|
||
this.restoreDeleteButton(button);
|
||
}
|
||
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;
|
||
this.restoreDeleteButton(button);
|
||
}
|
||
// 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' || data.status === 'interrupted') 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('saveDefaultScopeBtn')
|
||
.addEventListener('click', () => this.saveDefaultScope());
|
||
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);
|
||
}
|
||
this.fillDefaultScope(data);
|
||
}
|
||
|
||
fillDefaultScope(data) {
|
||
const field = document.getElementById('nodeDefaultScope');
|
||
const keyNote = document.getElementById('nodeDefaultScopeKey');
|
||
const note = document.getElementById('nodeDefaultScopeNote');
|
||
keyNote.textContent = '';
|
||
note.style.display = 'none';
|
||
|
||
// Absent keys mean the radio never answered — older firmware has no
|
||
// CMD_GET_DEFAULT_FLOOD_SCOPE. Say so rather than showing an empty box
|
||
// that reads as "no scope set".
|
||
if (!data.default_scope_supported) {
|
||
field.value = '';
|
||
field.disabled = true;
|
||
document.getElementById('saveDefaultScopeBtn').disabled = true;
|
||
note.textContent = 'This radio did not report a default scope. The firmware '
|
||
+ 'is likely older than the setting; nothing was changed.';
|
||
note.style.display = 'block';
|
||
return;
|
||
}
|
||
|
||
field.disabled = false;
|
||
document.getElementById('saveDefaultScopeBtn').disabled = false;
|
||
const name = data.default_scope_name || '';
|
||
field.value = name;
|
||
if (!name) return;
|
||
|
||
keyNote.textContent = `Key ${(data.default_scope_key || '').slice(0, 8)}…`;
|
||
// The name is a label the app wrote; the key is what the radio routes
|
||
// by. They only disagree if something wrote them separately, and then
|
||
// the name on screen is not the region this radio is actually in.
|
||
if (data.default_scope_key && data.default_scope_key_matches === false) {
|
||
note.textContent = `The stored key does not match ${name}. The radio routes by `
|
||
+ 'the key, so the name shown is only a label. Writing the scope again '
|
||
+ 'puts the two back in step.';
|
||
note.style.display = 'block';
|
||
}
|
||
}
|
||
|
||
async saveDefaultScope() {
|
||
const raw = document.getElementById('nodeDefaultScope').value.trim();
|
||
if (!raw && !confirm(
|
||
'Clear the radio\'s default region scope?\n\n'
|
||
+ 'Sends the bot does not scope itself will then flood the whole mesh '
|
||
+ 'instead of staying inside a region.'
|
||
)) return;
|
||
|
||
await this.runNodeAction('saveDefaultScopeBtn', 'saveDefaultScopeSpinner', async () => {
|
||
const result = await this.queueAndPoll(
|
||
'/api/radio/firmware/config/write', { default_flood_scope: raw });
|
||
if (result && result.status === 'completed') {
|
||
this.setNodeAlert('success', raw
|
||
? `Default scope set to ${raw}.`
|
||
: 'Default scope cleared — the radio floods unscoped.');
|
||
// Read it back: the radio is the authority on what it stored.
|
||
await this.readFirmwareConfig({ silent: true });
|
||
} else {
|
||
this.setNodeAlert('danger', result?.error_message || 'Write failed');
|
||
}
|
||
});
|
||
}
|
||
|
||
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');
|
||
}
|
||
});
|
||
}
|
||
|
||
|
||
// ── Region scopes ([Channels] flood_scopes) ─────────────────────────────
|
||
|
||
setupRegionScopeHandlers() {
|
||
const sync = () => this.syncRegionScopeMode();
|
||
document.getElementById('regionScopeModeAll').addEventListener('change', sync);
|
||
document.getElementById('regionScopeModeList').addEventListener('change', sync);
|
||
document.getElementById('saveRegionScopesBtn')
|
||
.addEventListener('click', () => this.saveRegionScopes());
|
||
}
|
||
|
||
// The allowlist inputs are meaningless in "reply whatever the scope" mode,
|
||
// so disable rather than hide them: a hidden list the operator forgot they
|
||
// filled in is the version of this that loses their scopes on the next save.
|
||
syncRegionScopeMode() {
|
||
const listMode = document.getElementById('regionScopeModeList').checked;
|
||
document.getElementById('regionScopeList').disabled = !listMode;
|
||
document.getElementById('regionScopeAllowGlobal').disabled = !listMode;
|
||
document.getElementById('regionScopeListFields').style.opacity = listMode ? '1' : '.55';
|
||
}
|
||
|
||
async loadRegionScopes() {
|
||
try {
|
||
const resp = await fetch('/api/region-scopes', {
|
||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||
});
|
||
const data = await resp.json();
|
||
if (!resp.ok) {
|
||
this.setRegionScopeAlert('danger', data.error || 'Could not read region scopes');
|
||
return;
|
||
}
|
||
this.fillRegionScopeForm(data);
|
||
this.setRegionScopeAlert('', '');
|
||
} catch (e) {
|
||
this.setRegionScopeAlert('danger', 'Error: ' + e.message);
|
||
}
|
||
}
|
||
|
||
fillRegionScopeForm(data) {
|
||
const scopes = Array.isArray(data.scopes) ? data.scopes : [];
|
||
document.getElementById('regionScopeList').value = scopes.join(', ');
|
||
document.getElementById('regionScopeAllowGlobal').checked = !!data.allow_global;
|
||
document.getElementById('regionScopeOutgoing').value = data.outgoing_override || '';
|
||
const listMode = !!data.allowlist_active;
|
||
document.getElementById('regionScopeModeList').checked = listMode;
|
||
document.getElementById('regionScopeModeAll').checked = !listMode;
|
||
this.syncRegionScopeMode();
|
||
|
||
const target = document.getElementById('regionScopeTarget');
|
||
target.textContent = data.target ? `[Channels] in ${data.target}` : '';
|
||
|
||
const legacy = document.getElementById('regionScopeLegacy');
|
||
if (data.legacy_section) {
|
||
// [Channels] is empty, so the bot fell back to the old location.
|
||
// Saving an allowlist here takes over; saving "reply whatever the
|
||
// scope" leaves [Channels] empty and hands it straight back.
|
||
legacy.textContent =
|
||
`[Channels] has no flood_scopes, so the bot is using the one under `
|
||
+ `[${data.legacy_section}]. Saving a list here writes [Channels], which `
|
||
+ `the bot reads first. Saving "reply whatever the scope" leaves [Channels] `
|
||
+ `empty, and the [${data.legacy_section}] entry takes over again — remove `
|
||
+ 'it from config.ini by hand to turn the allowlist off for good.';
|
||
legacy.style.display = 'block';
|
||
} else {
|
||
legacy.style.display = 'none';
|
||
}
|
||
|
||
this.renderChannelScopeOverrides(data.channel_overrides || []);
|
||
}
|
||
|
||
renderChannelScopeOverrides(overrides) {
|
||
const host = document.getElementById('regionScopeChannelOverrides');
|
||
host.replaceChildren();
|
||
if (!overrides.length) return;
|
||
|
||
const heading = document.createElement('div');
|
||
heading.className = 'fw-semibold mb-1';
|
||
heading.textContent = 'Per-channel overrides (edit in config.ini)';
|
||
host.appendChild(heading);
|
||
|
||
const list = document.createElement('ul');
|
||
list.className = 'list-unstyled mb-0';
|
||
overrides.forEach(entry => {
|
||
const item = document.createElement('li');
|
||
const name = document.createElement('code');
|
||
name.textContent = entry.channel;
|
||
item.appendChild(name);
|
||
item.appendChild(document.createTextNode(' → '));
|
||
const scope = document.createElement('code');
|
||
scope.textContent = entry.scope || 'global flood';
|
||
item.appendChild(scope);
|
||
list.appendChild(item);
|
||
});
|
||
host.appendChild(list);
|
||
|
||
const note = document.createElement('div');
|
||
note.className = 'mt-1';
|
||
note.textContent = 'These win over the default outgoing scope on their channel.';
|
||
host.appendChild(note);
|
||
}
|
||
|
||
async saveRegionScopes() {
|
||
const listMode = document.getElementById('regionScopeModeList').checked;
|
||
const payload = {
|
||
allowlist_enabled: listMode,
|
||
scopes: listMode ? document.getElementById('regionScopeList').value : '',
|
||
allow_global: listMode && document.getElementById('regionScopeAllowGlobal').checked,
|
||
outgoing_override: document.getElementById('regionScopeOutgoing').value,
|
||
};
|
||
|
||
if (listMode && !payload.allow_global) {
|
||
const ok = confirm(
|
||
'Only reply to the listed scopes?\n\n'
|
||
+ 'Anyone whose client has no region set floods unscoped, and the bot '
|
||
+ 'will not answer them at all. Tick "also reply to unscoped messages" '
|
||
+ 'to keep answering those.'
|
||
);
|
||
if (!ok) return;
|
||
}
|
||
|
||
const btn = document.getElementById('saveRegionScopesBtn');
|
||
const spinner = document.getElementById('saveRegionScopesSpinner');
|
||
btn.disabled = true;
|
||
spinner.style.display = 'inline-block';
|
||
this.setRegionScopeAlert('', '');
|
||
this.setRegionScopeStatus('Saving…', 'text-muted');
|
||
try {
|
||
const resp = await fetch('/api/region-scopes', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-Requested-With': 'XMLHttpRequest'
|
||
},
|
||
body: JSON.stringify(payload)
|
||
});
|
||
const data = await resp.json();
|
||
if (!resp.ok || !data.success) {
|
||
this.setRegionScopeStatus('', '');
|
||
this.setRegionScopeAlert('danger', data.error || 'Save failed');
|
||
return;
|
||
}
|
||
// Show what was actually written, including the canonical form of
|
||
// anything typed loosely (west -> #west, * folded into the checkbox).
|
||
this.fillRegionScopeForm(data.settings || {});
|
||
this.setRegionScopeStatus('Saved. Waiting for the bot to reload…', 'text-muted');
|
||
const applied = await this.awaitConfigReload(data.reload_operation_id);
|
||
this.setRegionScopeStatus(applied.text, applied.cls);
|
||
} catch (e) {
|
||
this.setRegionScopeStatus('', '');
|
||
this.setRegionScopeAlert('danger', 'Error: ' + e.message);
|
||
} finally {
|
||
btn.disabled = false;
|
||
spinner.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// A queued reload row only means the request was written down. Poll it so
|
||
// the page reports what the bot did, and says plainly when nothing picked
|
||
// it up (a stopped bot, or a reload the bot refused).
|
||
async awaitConfigReload(operationId) {
|
||
if (!operationId) {
|
||
return {
|
||
text: 'Saved to config.ini. It applies the next time the bot reloads.',
|
||
cls: 'text-warning'
|
||
};
|
||
}
|
||
for (let attempt = 0; attempt < 15; attempt++) {
|
||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||
try {
|
||
const resp = await fetch(`/api/channel-operations/${operationId}`);
|
||
const data = await resp.json();
|
||
if (data.status === 'completed') {
|
||
return { text: 'Saved and applied — the bot is using it now.', cls: 'text-success' };
|
||
}
|
||
if (data.status === 'failed' || data.status === 'interrupted') {
|
||
// reload_config's messages already end in a full stop.
|
||
const reason = String(data.error_message || 'reason not reported')
|
||
.trim().replace(/\.+$/, '');
|
||
return {
|
||
text: `Saved to config.ini, but the reload failed: ${reason}. `
|
||
+ 'Restart the bot to apply it.',
|
||
cls: 'text-danger'
|
||
};
|
||
}
|
||
} catch { /* keep polling; the answer is the same either way */ }
|
||
}
|
||
return {
|
||
text: 'Saved to config.ini. The bot has not picked it up — if it is not '
|
||
+ 'running, this applies at startup.',
|
||
cls: 'text-warning'
|
||
};
|
||
}
|
||
|
||
setRegionScopeAlert(type, message) {
|
||
const el = document.getElementById('regionScopeAlert');
|
||
if (!type || !message) { el.style.display = 'none'; return; }
|
||
el.className = `alert alert-${type}`;
|
||
el.textContent = message;
|
||
el.style.display = 'block';
|
||
}
|
||
|
||
setRegionScopeStatus(text, cls) {
|
||
const el = document.getElementById('regionScopeStatus');
|
||
el.className = cls || 'text-muted';
|
||
el.textContent = text || '';
|
||
}
|
||
|
||
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;';
|
||
const messageNode = document.createElement('span');
|
||
messageNode.textContent = String(message ?? '');
|
||
const closeButton = document.createElement('button');
|
||
closeButton.type = 'button';
|
||
closeButton.className = 'btn-close';
|
||
closeButton.dataset.bsDismiss = 'alert';
|
||
alert.append(messageNode, closeButton);
|
||
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;';
|
||
const messageNode = document.createElement('span');
|
||
messageNode.textContent = String(message ?? '');
|
||
const closeButton = document.createElement('button');
|
||
closeButton.type = 'button';
|
||
closeButton.className = 'btn-close';
|
||
closeButton.dataset.bsDismiss = 'alert';
|
||
alert.append(messageNode, closeButton);
|
||
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 %}
|