feat: expand repeater web panel with radio presets, info cards, and clock sync controls
此提交包含在:
@@ -32,7 +32,7 @@ For flashing guidance, including when to use `.bin` vs `-merged.bin`, see:
|
||||
|
||||
The custom firmware flasher site is:
|
||||
|
||||
- <https://flasher.meshcore.io/>
|
||||
- <https://flasher.eastmesh.au/>
|
||||
|
||||
## Install uv
|
||||
|
||||
|
||||
+56
-2
@@ -46,15 +46,69 @@ Practical rule:
|
||||
|
||||
## Recommended Flasher
|
||||
|
||||
For custom firmware files, use:
|
||||
The recommended flasher is:
|
||||
|
||||
- <https://flasher.meshcore.io/>
|
||||
- <https://flasher.eastmesh.au/>
|
||||
|
||||
It includes native support for:
|
||||
|
||||
- `companion_radio_wifi` firmware
|
||||
- `repeater_mqtt` firmware
|
||||
- custom firmware files
|
||||
|
||||
Recommended usage:
|
||||
|
||||
- use the normal `.bin` there when you are updating an existing device
|
||||
- use the `-merged.bin` there after an erase when you want a clean flash
|
||||
|
||||
## Beginner Setup
|
||||
|
||||
If this is your first time flashing EastMesh firmware, the easiest path is:
|
||||
|
||||
1. Open <https://flasher.eastmesh.au/>.
|
||||
2. Select the firmware type you want:
|
||||
- `Companion WiFi`
|
||||
- `Repeater MQTT`
|
||||
- or `Custom`
|
||||
3. Flash the correct firmware for your board.
|
||||
4. Use the built-in setup tools in the flasher site to finish first-time configuration.
|
||||
|
||||
The flasher site includes two especially useful actions after flashing:
|
||||
|
||||
- `Repeater Setup`
|
||||
- `Console`
|
||||
|
||||
### Repeater Setup
|
||||
|
||||
`Repeater Setup` is the guided first-time repeater flow.
|
||||
|
||||
It is the traditional way to configure a repeater after flashing, including:
|
||||
|
||||
- device name
|
||||
- latitude and longitude
|
||||
- admin and guest passwords
|
||||
- radio settings, including preset selection
|
||||
- advert interval
|
||||
- flood advert interval
|
||||
- flood max
|
||||
- some advanced repeater settings
|
||||
|
||||
As of `v1.2.1`, the local repeater web panel also includes the same common repeater settings, so many users can continue day-to-day configuration there after initial setup.
|
||||
|
||||
### Console
|
||||
|
||||
`Console` is the raw CLI interface.
|
||||
|
||||
It is especially useful, and often required, for the initial Wi-Fi setup on both firmware tracks:
|
||||
|
||||
- `set wifi.ssid <your-ssid>`
|
||||
- `set wifi.pwd <your-password>`
|
||||
|
||||
This applies to:
|
||||
|
||||
- `companion_radio_wifi`
|
||||
- `repeater_mqtt`
|
||||
|
||||
## Repeater MQTT Notes
|
||||
|
||||
`repeater_mqtt` builds include the EastMesh MQTT additions. Depending on the board, they may also include the local web panel.
|
||||
|
||||
@@ -1483,9 +1483,14 @@ void MyMesh::runWebCommand(const char* command, char* reply, size_t reply_size)
|
||||
matches_exact("get name") ||
|
||||
matches_exact("get lat") ||
|
||||
matches_exact("get lon") ||
|
||||
matches_exact("get radio") ||
|
||||
matches_exact("get prv.key") ||
|
||||
matches_exact("get role") ||
|
||||
matches_exact("get public.key") ||
|
||||
matches_exact("get advert.interval") ||
|
||||
matches_exact("get flood.advert.interval") ||
|
||||
matches_exact("get flood.max") ||
|
||||
matches_exact("get path.hash.mode") ||
|
||||
matches_exact("get owner.info") ||
|
||||
matches_exact("get guest.password") ||
|
||||
matches_prefix("set wifi.ssid ") ||
|
||||
@@ -1509,11 +1514,16 @@ void MyMesh::runWebCommand(const char* command, char* reply, size_t reply_size)
|
||||
matches_prefix("set name ") ||
|
||||
matches_prefix("set lat ") ||
|
||||
matches_prefix("set lon ") ||
|
||||
matches_prefix("set radio ") ||
|
||||
matches_prefix("password ") ||
|
||||
matches_prefix("set guest.password ") ||
|
||||
matches_prefix("set prv.key ") ||
|
||||
matches_prefix("set advert.interval ") ||
|
||||
matches_prefix("set flood.advert.interval ") ||
|
||||
matches_prefix("set flood.max ") ||
|
||||
matches_prefix("set path.hash.mode ") ||
|
||||
matches_prefix("time ") ||
|
||||
matches_prefix("time.force ") ||
|
||||
matches_prefix("set owner.info ");
|
||||
|
||||
if (!allowed) {
|
||||
|
||||
@@ -249,6 +249,12 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch
|
||||
uint32_t now = getRTCClock()->getCurrentTime();
|
||||
DateTime dt = DateTime(now);
|
||||
sprintf(reply, "%02d:%02d - %d/%d/%d UTC", dt.hour(), dt.minute(), dt.day(), dt.month(), dt.year());
|
||||
} else if (memcmp(command, "time.force ", 11) == 0) { // force set time (to epoch seconds)
|
||||
uint32_t secs = _atoi(&command[11]);
|
||||
getRTCClock()->setCurrentTime(secs);
|
||||
uint32_t now = getRTCClock()->getCurrentTime();
|
||||
DateTime dt = DateTime(now);
|
||||
sprintf(reply, "OK - clock force-set: %02d:%02d - %d/%d/%d UTC", dt.hour(), dt.minute(), dt.day(), dt.month(), dt.year());
|
||||
} else if (memcmp(command, "time ", 5) == 0) { // set time (to epoch seconds)
|
||||
uint32_t secs = _atoi(&command[5]);
|
||||
uint32_t curr = getRTCClock()->getCurrentTime();
|
||||
|
||||
+422
-88
@@ -194,7 +194,7 @@ const char kWebPanelLoginHtml[] PROGMEM = R"HTML(
|
||||
<main>
|
||||
<section class="card">
|
||||
<h1>Repeater Config</h1>
|
||||
<p>Use the repeater admin password to unlock the panel. Accept the self-signed certificate warning in your browser first.</p>
|
||||
<p>Use the repeater admin password to unlock the panel.</p>
|
||||
<input id="password" type="password" placeholder="Admin password" autocomplete="current-password">
|
||||
<button id="loginBtn">Unlock</button>
|
||||
<div id="status"></div>
|
||||
@@ -296,6 +296,8 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
||||
button.action-dreamy:hover { background:linear-gradient(135deg,#5c89d4,#9a8cff); }
|
||||
.stack { display:grid; gap:12px; }
|
||||
.field-card { display:grid; gap:10px; }
|
||||
.section-group { background:var(--surface2); border:1px solid var(--border); border-radius:12px; padding:14px; display:grid; gap:12px; }
|
||||
.section-group h3 { margin:0; font-size:13px; color:var(--text-muted); text-transform:uppercase; letter-spacing:.08em; }
|
||||
.inline-actions { display:grid; grid-template-columns:minmax(0,1fr) auto auto; gap:8px; align-items:center; }
|
||||
.label { font-size:12px; color:var(--text-muted); margin-bottom:6px; display:block; }
|
||||
.fieldline { display:grid; grid-template-columns:1fr auto; gap:8px; align-items:center; }
|
||||
@@ -332,6 +334,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
||||
.visually-hidden { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
|
||||
.panel-warning { min-height:1.4em; font-size:13px; color:var(--status-red); }
|
||||
.panel-note { font-size:13px; color:var(--text-muted); }
|
||||
.panel-status { min-height:1.4em; font-size:13px; color:var(--text-muted); }
|
||||
.themebtn { padding:10px 14px; }
|
||||
#status { white-space:pre-wrap; color:var(--text-muted); min-height:1.4em; }
|
||||
.terminal { background:var(--terminal-bg); border:1px solid var(--terminal-border); border-radius:12px; padding:14px; min-height:180px; max-height:320px; overflow:auto; font-family:inherit; font-size:14px; line-height:1.45; }
|
||||
@@ -399,7 +402,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
||||
<main>
|
||||
<section class="card" id="login" style="display:none">
|
||||
<h1>Repeater Config</h1>
|
||||
<p>Use the repeater admin password to unlock the command console. Accept the self-signed certificate warning in your browser first.</p>
|
||||
<p>Use the repeater admin password to unlock the command console.</p>
|
||||
<div class="row" style="margin-top:14px">
|
||||
<input id="password" type="password" placeholder="Admin password">
|
||||
<button id="loginBtn">Unlock</button>
|
||||
@@ -456,94 +459,183 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
||||
<div id="reply" class="terminal"></div>
|
||||
</section>
|
||||
|
||||
<section class="card" id="infoPanel" style="display:none">
|
||||
<h2>Info</h2>
|
||||
<div class="stack">
|
||||
<div class="row">
|
||||
<div class="field-card">
|
||||
<label class="label" for="roleValue">Role</label>
|
||||
<div class="fieldline">
|
||||
<input id="roleValue" readonly disabled>
|
||||
<span class="placeholder-slot" aria-hidden="true"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-card">
|
||||
<label class="label" for="clockUtc">Clock UTC</label>
|
||||
<div class="inline-actions">
|
||||
<input id="clockUtc" readonly disabled>
|
||||
<button class="iconbtn" data-load-cmd="clock" data-load-input="clockUtc" title="Refresh clock UTC">↻</button>
|
||||
<button id="syncClockBtn" class="savebtn">Sync</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-card">
|
||||
<label class="label" for="publicKey">Public Key</label>
|
||||
<div class="fieldline">
|
||||
<input id="publicKey" readonly disabled>
|
||||
<button id="copyPublicKeyBtn" class="iconbtn" title="Copy public key">⎘</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card" id="repeaterSettingsPanel" style="display:none">
|
||||
<h2>Repeater Settings</h2>
|
||||
<div class="stack">
|
||||
<div class="field-card">
|
||||
<label class="label" for="nodeName">Device Name</label>
|
||||
<div class="inline-actions">
|
||||
<input id="nodeName" placeholder="MeshCore-HOWL">
|
||||
<button class="iconbtn" data-load-cmd="get name" data-load-input="nodeName" title="Refresh device name">↻</button>
|
||||
<button class="savebtn" data-prefix="set name " data-input="nodeName">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="section-group">
|
||||
<h3>Repeater Settings</h3>
|
||||
<div class="field-card">
|
||||
<div>
|
||||
<label class="label" for="nodeLat">Latitude</label>
|
||||
<div class="fieldline">
|
||||
<input id="nodeLat" placeholder="0.0">
|
||||
<button class="iconbtn" data-load-cmd="get lat" data-load-input="nodeLat" title="Refresh latitude">↻</button>
|
||||
<label class="label" for="nodeName">Device Name</label>
|
||||
<div class="inline-actions">
|
||||
<input id="nodeName" placeholder="MeshCore-HOWL">
|
||||
<button class="iconbtn" data-load-cmd="get name" data-load-input="nodeName" title="Refresh device name">↻</button>
|
||||
<button class="savebtn" data-prefix="set name " data-input="nodeName">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="field-card">
|
||||
<div>
|
||||
<label class="label" for="nodeLat">Latitude</label>
|
||||
<div class="fieldline">
|
||||
<input id="nodeLat" placeholder="0.0">
|
||||
<button class="iconbtn" data-load-cmd="get lat" data-load-input="nodeLat" title="Refresh latitude">↻</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="savebtn" data-prefix="set lat " data-input="nodeLat">Save latitude</button>
|
||||
</div>
|
||||
<div class="field-card">
|
||||
<div>
|
||||
<label class="label" for="nodeLon">Longitude</label>
|
||||
<div class="fieldline">
|
||||
<input id="nodeLon" placeholder="0.0">
|
||||
<button class="iconbtn" data-load-cmd="get lon" data-load-input="nodeLon" title="Refresh longitude">↻</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="savebtn" data-prefix="set lon " data-input="nodeLon">Save longitude</button>
|
||||
</div>
|
||||
<button class="savebtn" data-prefix="set lat " data-input="nodeLat">Save latitude</button>
|
||||
</div>
|
||||
<div class="field-card">
|
||||
<div>
|
||||
<label class="label" for="nodeLon">Longitude</label>
|
||||
<div class="fieldline">
|
||||
<input id="nodeLon" placeholder="0.0">
|
||||
<button class="iconbtn" data-load-cmd="get lon" data-load-input="nodeLon" title="Refresh longitude">↻</button>
|
||||
<label class="label" for="privateKey">Private Key</label>
|
||||
<div class="inline-actions">
|
||||
<input id="privateKey" type="password" placeholder="64-hex-char private key">
|
||||
<button id="copyPrivateKeyBtn" class="iconbtn" title="Copy private key">⎘</button>
|
||||
<button class="savebtn" data-prefix="set prv.key " data-input="privateKey">Save</button>
|
||||
</div>
|
||||
<p>Changing the private key requires a reboot to apply.</p>
|
||||
</div>
|
||||
<div class="field-card">
|
||||
<label class="label" for="ownerInfo">Owner Info</label>
|
||||
<div class="inline-actions">
|
||||
<textarea id="ownerInfo" placeholder="Free text shown in owner info"></textarea>
|
||||
<button class="iconbtn" data-load-cmd="get owner.info" data-load-input="ownerInfo" data-load-format="multiline" title="Refresh owner info">↻</button>
|
||||
<button id="saveOwnerInfo" class="savebtn">Save</button>
|
||||
</div>
|
||||
<button class="savebtn" data-prefix="set lon " data-input="nodeLon">Save longitude</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-card">
|
||||
<label class="label" for="guestPassword">Guest Password</label>
|
||||
<div class="inline-actions">
|
||||
<input id="guestPassword" type="password" placeholder="new guest password">
|
||||
<div class="section-group">
|
||||
<h3>Access</h3>
|
||||
<div class="field-card">
|
||||
<label class="label" for="adminPassword">Admin Password</label>
|
||||
<div class="inline-actions">
|
||||
<input id="adminPassword" type="password" placeholder="new admin password">
|
||||
<span class="placeholder-slot" aria-hidden="true"></span>
|
||||
<button class="savebtn" data-prefix="set guest.password " data-input="guestPassword">Save</button>
|
||||
<button class="savebtn" data-prefix="password " data-input="adminPassword">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-card">
|
||||
<label class="label" for="privateKey">Private Key</label>
|
||||
<div class="inline-actions">
|
||||
<input id="privateKey" placeholder="64-hex-char private key">
|
||||
<div class="field-card">
|
||||
<label class="label" for="guestPassword">Guest Password</label>
|
||||
<div class="inline-actions">
|
||||
<input id="guestPassword" type="password" placeholder="new guest password">
|
||||
<span class="placeholder-slot" aria-hidden="true"></span>
|
||||
<button class="savebtn" data-prefix="set prv.key " data-input="privateKey">Save</button>
|
||||
</div>
|
||||
<p>Changing the private key requires a reboot to apply.</p>
|
||||
</div>
|
||||
<div class="row3">
|
||||
<div class="field-card">
|
||||
<div>
|
||||
<label class="label" for="advertInterval">Advert Interval (minutes)</label>
|
||||
<div class="fieldline">
|
||||
<input id="advertInterval" placeholder="2">
|
||||
<button class="iconbtn" data-load-cmd="get advert.interval" data-load-input="advertInterval" title="Refresh advert interval">↻</button>
|
||||
<button class="savebtn" data-prefix="set guest.password " data-input="guestPassword">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="savebtn" data-prefix="set advert.interval " data-input="advertInterval">Save advert interval</button>
|
||||
</div>
|
||||
<div class="field-card">
|
||||
<div>
|
||||
<label class="label" for="floodInterval">Flood Interval (hours)</label>
|
||||
<div class="fieldline">
|
||||
<input id="floodInterval" placeholder="12">
|
||||
<button class="iconbtn" data-load-cmd="get flood.advert.interval" data-load-input="floodInterval" title="Refresh flood interval">↻</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="savebtn" data-prefix="set flood.advert.interval " data-input="floodInterval">Save flood interval</button>
|
||||
</div>
|
||||
<div class="field-card">
|
||||
<div>
|
||||
<label class="label" for="floodMax">Flood Max</label>
|
||||
<div class="fieldline">
|
||||
<input id="floodMax" placeholder="64">
|
||||
<button class="iconbtn" data-load-cmd="get flood.max" data-load-input="floodMax" title="Refresh flood max">↻</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="savebtn" data-prefix="set flood.max " data-input="floodMax">Save flood max</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-card">
|
||||
<label class="label" for="ownerInfo">Owner Info</label>
|
||||
<div class="inline-actions">
|
||||
<textarea id="ownerInfo" placeholder="Free text shown in owner info"></textarea>
|
||||
<button class="iconbtn" data-load-cmd="get owner.info" data-load-input="ownerInfo" data-load-format="multiline" title="Refresh owner info">↻</button>
|
||||
<button id="saveOwnerInfo" class="savebtn">Save</button>
|
||||
<div class="section-group">
|
||||
<h3>Radio Settings</h3>
|
||||
<div class="field-card">
|
||||
<label class="label" for="radioPreset">Preset</label>
|
||||
<div class="row">
|
||||
<div class="field-card">
|
||||
<div>
|
||||
<label class="label" for="radioCurrent">Current Radio</label>
|
||||
<div class="fieldline">
|
||||
<input id="radioCurrent" placeholder="915.800 / BW250 / SF10 / CR5" readonly disabled>
|
||||
<button id="refreshRadioBtn" class="iconbtn" title="Refresh current radio">↻</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-card">
|
||||
<div>
|
||||
<label class="label" for="radioPreset">Preset</label>
|
||||
<div class="inline-actions">
|
||||
<select id="radioPreset">
|
||||
<option value="">Loading presets...</option>
|
||||
</select>
|
||||
<button id="reloadRadioPresetsBtn" class="iconbtn" title="Reload presets">↻</button>
|
||||
<button id="applyRadioPresetBtn" class="savebtn">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="radioPresetStatus" class="panel-status"></div>
|
||||
</div>
|
||||
<div class="field-card">
|
||||
<label class="label" for="pathHashMode">Path Hash Mode</label>
|
||||
<div class="inline-actions">
|
||||
<select id="pathHashMode">
|
||||
<option value="0">1-byte</option>
|
||||
<option value="1">2-byte</option>
|
||||
<option value="2">3-byte</option>
|
||||
</select>
|
||||
<button class="iconbtn" data-load-cmd="get path.hash.mode" data-load-input="pathHashMode" title="Refresh path hash mode">↻</button>
|
||||
<button class="savebtn" data-prefix="set path.hash.mode " data-input="pathHashMode">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-group">
|
||||
<h3>Advertising</h3>
|
||||
<div class="row3">
|
||||
<div class="field-card">
|
||||
<div>
|
||||
<label class="label" for="advertInterval">Advert Interval (minutes)</label>
|
||||
<div class="fieldline">
|
||||
<input id="advertInterval" placeholder="2">
|
||||
<button class="iconbtn" data-load-cmd="get advert.interval" data-load-input="advertInterval" title="Refresh advert interval">↻</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="savebtn" data-prefix="set advert.interval " data-input="advertInterval">Save advert interval</button>
|
||||
</div>
|
||||
<div class="field-card">
|
||||
<div>
|
||||
<label class="label" for="floodInterval">Flood Interval (hours)</label>
|
||||
<div class="fieldline">
|
||||
<input id="floodInterval" placeholder="12">
|
||||
<button class="iconbtn" data-load-cmd="get flood.advert.interval" data-load-input="floodInterval" title="Refresh flood interval">↻</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="savebtn" data-prefix="set flood.advert.interval " data-input="floodInterval">Save flood interval</button>
|
||||
</div>
|
||||
<div class="field-card">
|
||||
<div>
|
||||
<label class="label" for="floodMax">Flood Max</label>
|
||||
<div class="fieldline">
|
||||
<input id="floodMax" placeholder="64">
|
||||
<button class="iconbtn" data-load-cmd="get flood.max" data-load-input="floodMax" title="Refresh flood max">↻</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="savebtn" data-prefix="set flood.max " data-input="floodMax">Save flood max</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -710,8 +802,11 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
||||
|
||||
</main>
|
||||
<script>
|
||||
const RADIO_PRESETS_URL = "https://api.meshcore.nz/api/v1/config";
|
||||
let token = sessionStorage.getItem("repeater-token") || "";
|
||||
let commandQueue = Promise.resolve();
|
||||
let radioPresetEntries = [];
|
||||
let currentRadioConfig = null;
|
||||
const statusEl = document.getElementById("status");
|
||||
const replyEl = document.getElementById("reply");
|
||||
const themeToggleEl = document.getElementById("themeToggle");
|
||||
@@ -756,6 +851,10 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
||||
function parseReplyValue(text) {
|
||||
return (text || "").replace(/^>\s*/, "").trim();
|
||||
}
|
||||
function isCommandError(text) {
|
||||
const value = parseReplyValue(text).toLowerCase();
|
||||
return value.startsWith("err") || value.startsWith("(err") || value.startsWith("error");
|
||||
}
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
@@ -793,6 +892,86 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
||||
if (abs >= 1024) return (value / 1024).toFixed(1) + " KB";
|
||||
return Math.round(value) + " B";
|
||||
}
|
||||
function formatDecimal(value, digits) {
|
||||
const num = Number.parseFloat(value);
|
||||
if (!Number.isFinite(num)) return "";
|
||||
return num.toFixed(digits);
|
||||
}
|
||||
function normalizeRadioConfig(config) {
|
||||
if (!config) return null;
|
||||
const frequency = Number.parseFloat(config.frequency);
|
||||
const bandwidth = Number.parseFloat(config.bandwidth);
|
||||
const spreadingFactor = Number.parseInt(config.spreadingFactor, 10);
|
||||
const codingRate = Number.parseInt(config.codingRate, 10);
|
||||
if (!Number.isFinite(frequency) || !Number.isFinite(bandwidth) || !Number.isFinite(spreadingFactor) || !Number.isFinite(codingRate)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
frequency,
|
||||
bandwidth,
|
||||
spreadingFactor,
|
||||
codingRate
|
||||
};
|
||||
}
|
||||
function radioSignature(config) {
|
||||
const normalized = normalizeRadioConfig(config);
|
||||
if (!normalized) return "";
|
||||
return [
|
||||
normalized.frequency.toFixed(3),
|
||||
normalized.bandwidth.toFixed(3),
|
||||
normalized.spreadingFactor,
|
||||
normalized.codingRate
|
||||
].join("|");
|
||||
}
|
||||
function formatRadioConfig(config) {
|
||||
const normalized = normalizeRadioConfig(config);
|
||||
if (!normalized) return "";
|
||||
return `${normalized.frequency.toFixed(3)} / BW${normalized.bandwidth.toFixed(3)} / SF${normalized.spreadingFactor} / CR${normalized.codingRate}`;
|
||||
}
|
||||
function parseRadioValue(value) {
|
||||
const parts = String(value || "").split(",");
|
||||
if (parts.length !== 4) return null;
|
||||
return normalizeRadioConfig({
|
||||
frequency: parts[0],
|
||||
bandwidth: parts[1],
|
||||
spreadingFactor: parts[2],
|
||||
codingRate: parts[3]
|
||||
});
|
||||
}
|
||||
function setRadioPresetStatus(message, isError) {
|
||||
const el = document.getElementById("radioPresetStatus");
|
||||
if (!el) return;
|
||||
el.textContent = message || "";
|
||||
el.style.color = isError ? "var(--status-red)" : "var(--text-muted)";
|
||||
}
|
||||
function syncRadioPresetUi() {
|
||||
const currentEl = document.getElementById("radioCurrent");
|
||||
if (currentEl) {
|
||||
currentEl.value = currentRadioConfig ? formatRadioConfig(currentRadioConfig) : "";
|
||||
}
|
||||
const selectEl = document.getElementById("radioPreset");
|
||||
const applyBtn = document.getElementById("applyRadioPresetBtn");
|
||||
if (!selectEl || !applyBtn) return;
|
||||
const currentSig = radioSignature(currentRadioConfig);
|
||||
let matchedIndex = -1;
|
||||
for (let i = 0; i < radioPresetEntries.length; i++) {
|
||||
if (radioSignature(radioPresetEntries[i]) === currentSig) {
|
||||
matchedIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!radioPresetEntries.length) {
|
||||
applyBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
if (matchedIndex >= 0) {
|
||||
selectEl.value = String(matchedIndex);
|
||||
applyBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
selectEl.value = "";
|
||||
applyBtn.disabled = true;
|
||||
}
|
||||
function toneForPercent(percent, invert) {
|
||||
if (invert) {
|
||||
if (percent >= 70) return "bad";
|
||||
@@ -995,6 +1174,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
||||
document.getElementById("cliPanel").style.display = show ? "block" : "none";
|
||||
document.getElementById("quickCommandsPanel").style.display = show ? "block" : "none";
|
||||
document.getElementById("mqttSettingsPanel").style.display = show ? "block" : "none";
|
||||
document.getElementById("infoPanel").style.display = show ? "block" : "none";
|
||||
document.getElementById("statsPanel").style.display = show ? "block" : "none";
|
||||
document.getElementById("actionsPanel").style.display = show ? "block" : "none";
|
||||
document.getElementById("repeaterSettingsPanel").style.display = show ? "block" : "none";
|
||||
@@ -1028,10 +1208,11 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
||||
redirectToLogin();
|
||||
return { ok:false, text };
|
||||
}
|
||||
const ok = res.ok && !isCommandError(text);
|
||||
if (recordHistory) {
|
||||
appendHistory(cmd, text, res.ok);
|
||||
appendHistory(cmd, text, ok);
|
||||
}
|
||||
return { ok:res.ok, text };
|
||||
return { ok, text };
|
||||
});
|
||||
}
|
||||
function runPrefixed(prefix, inputId) {
|
||||
@@ -1044,9 +1225,49 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
||||
let value = parseReplyValue(result.text);
|
||||
if (format === "multiline") {
|
||||
value = value.replace(/\|/g, "\n");
|
||||
} else if (format === "uppercase") {
|
||||
value = value.toUpperCase();
|
||||
}
|
||||
document.getElementById(inputId).value = value;
|
||||
}
|
||||
async function copyToClipboard(value, successMessage) {
|
||||
try {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(value);
|
||||
} else {
|
||||
throw new Error("Clipboard unavailable");
|
||||
}
|
||||
statusEl.textContent = successMessage;
|
||||
} catch (_) {
|
||||
statusEl.textContent = "Copy failed";
|
||||
}
|
||||
}
|
||||
async function syncClock() {
|
||||
let epoch = Math.floor(Date.now() / 1000);
|
||||
let result = await runCommand("time " + String(epoch));
|
||||
if (result.ok) {
|
||||
await loadField("clock", "clockUtc");
|
||||
statusEl.textContent = "Clock synced";
|
||||
return;
|
||||
}
|
||||
const message = parseReplyValue(result.text).toLowerCase();
|
||||
if (!message.includes("cannot go backwards")) {
|
||||
statusEl.textContent = parseReplyValue(result.text) || "Clock sync failed";
|
||||
return;
|
||||
}
|
||||
if (!confirm("Device clock appears ahead of browser time. Force clock sync backwards?")) {
|
||||
statusEl.textContent = "Clock sync cancelled";
|
||||
return;
|
||||
}
|
||||
epoch = Math.floor(Date.now() / 1000);
|
||||
result = await runCommand("time.force " + String(epoch));
|
||||
if (result.ok) {
|
||||
await loadField("clock", "clockUtc");
|
||||
statusEl.textContent = "Clock force-synced";
|
||||
return;
|
||||
}
|
||||
statusEl.textContent = parseReplyValue(result.text) || "Clock force sync failed";
|
||||
}
|
||||
async function fetchJson(path) {
|
||||
if (!token) {
|
||||
redirectToLogin();
|
||||
@@ -1065,8 +1286,8 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
||||
return JSON.parse(text);
|
||||
});
|
||||
}
|
||||
function applyBootstrapData(data) {
|
||||
if (!data) return;
|
||||
function applyBootstrapData(data) {
|
||||
if (!data) return;
|
||||
if (typeof data.name === "string") document.getElementById("nodeName").value = data.name;
|
||||
if (typeof data.mqtt_iata === "string" && data.mqtt_iata.length) document.getElementById("mqttIata").value = data.mqtt_iata;
|
||||
if (typeof data.mqtt_owner === "string") document.getElementById("mqttOwner").value = data.mqtt_owner;
|
||||
@@ -1074,11 +1295,18 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
||||
if (typeof data.advert_interval === "string") document.getElementById("advertInterval").value = data.advert_interval;
|
||||
if (typeof data.flood_interval === "string") document.getElementById("floodInterval").value = data.flood_interval;
|
||||
if (typeof data.flood_max === "string") document.getElementById("floodMax").value = data.flood_max;
|
||||
if (typeof data.mqtt_eastmesh_au === "string") setBrokerToggle("mqttEastmeshAu", data.mqtt_eastmesh_au);
|
||||
if (typeof data.mqtt_letsmesh_eu === "string") setBrokerToggle("mqttLetsmeshEu", data.mqtt_letsmesh_eu);
|
||||
if (typeof data.mqtt_letsmesh_us === "string") setBrokerToggle("mqttLetsmeshUs", data.mqtt_letsmesh_us);
|
||||
updateBrokerWarning();
|
||||
}
|
||||
if (typeof data.path_hash_mode === "string") document.getElementById("pathHashMode").value = data.path_hash_mode;
|
||||
if (typeof data.private_key === "string") document.getElementById("privateKey").value = data.private_key.toUpperCase();
|
||||
if (typeof data.radio === "string") currentRadioConfig = parseRadioValue(data.radio);
|
||||
if (typeof data.role === "string") document.getElementById("roleValue").value = data.role;
|
||||
if (typeof data.clock === "string") document.getElementById("clockUtc").value = data.clock;
|
||||
if (typeof data.public_key === "string") document.getElementById("publicKey").value = data.public_key.toUpperCase();
|
||||
if (typeof data.mqtt_eastmesh_au === "string") setBrokerToggle("mqttEastmeshAu", data.mqtt_eastmesh_au);
|
||||
if (typeof data.mqtt_letsmesh_eu === "string") setBrokerToggle("mqttLetsmeshEu", data.mqtt_letsmesh_eu);
|
||||
if (typeof data.mqtt_letsmesh_us === "string") setBrokerToggle("mqttLetsmeshUs", data.mqtt_letsmesh_us);
|
||||
syncRadioPresetUi();
|
||||
updateBrokerWarning();
|
||||
}
|
||||
function getLetsmeshMode() {
|
||||
const eu = document.getElementById("mqttLetsmeshEu");
|
||||
const us = document.getElementById("mqttLetsmeshUs");
|
||||
@@ -1151,12 +1379,69 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
||||
function updateBrokerWarning() {
|
||||
document.getElementById("mqttBrokerWarning").textContent = "";
|
||||
}
|
||||
async function loadBrokerState(cmd, inputId) {
|
||||
const result = await runCommand(cmd);
|
||||
if (!result.ok) return;
|
||||
setBrokerToggle(inputId, parseReplyValue(result.text));
|
||||
updateBrokerWarning();
|
||||
}
|
||||
async function loadBrokerState(cmd, inputId) {
|
||||
const result = await runCommand(cmd);
|
||||
if (!result.ok) return;
|
||||
setBrokerToggle(inputId, parseReplyValue(result.text));
|
||||
updateBrokerWarning();
|
||||
}
|
||||
async function loadRadioConfig() {
|
||||
const result = await runCommand("get radio");
|
||||
if (!result.ok) {
|
||||
setRadioPresetStatus("Unable to load current radio config.", true);
|
||||
return;
|
||||
}
|
||||
const parsed = parseRadioValue(parseReplyValue(result.text));
|
||||
if (!parsed) {
|
||||
setRadioPresetStatus("Current radio config was not recognised.", true);
|
||||
return;
|
||||
}
|
||||
currentRadioConfig = parsed;
|
||||
setRadioPresetStatus("");
|
||||
syncRadioPresetUi();
|
||||
}
|
||||
async function loadRadioPresets() {
|
||||
const selectEl = document.getElementById("radioPreset");
|
||||
const applyBtn = document.getElementById("applyRadioPresetBtn");
|
||||
if (selectEl) {
|
||||
selectEl.innerHTML = '<option value="">Loading presets...</option>';
|
||||
}
|
||||
if (applyBtn) applyBtn.disabled = true;
|
||||
setRadioPresetStatus("");
|
||||
try {
|
||||
const res = await fetch(RADIO_PRESETS_URL, { cache:"no-store" });
|
||||
if (!res.ok) {
|
||||
throw new Error("Preset service returned " + res.status);
|
||||
}
|
||||
const payload = await res.json();
|
||||
const config = payload && payload.config ? payload.config : {};
|
||||
const suggested = config && config.suggested_radio_settings ? config.suggested_radio_settings : {};
|
||||
const entries = Array.isArray(suggested.entries) ? suggested.entries : [];
|
||||
radioPresetEntries = entries.map((entry) => ({
|
||||
title: String(entry.title || "Unnamed preset"),
|
||||
description: String(entry.description || ""),
|
||||
frequency: formatDecimal(entry.frequency, 3),
|
||||
bandwidth: formatDecimal(entry.bandwidth, 3),
|
||||
spreadingFactor: Number.parseInt(entry.spreading_factor, 10),
|
||||
codingRate: Number.parseInt(entry.coding_rate, 10)
|
||||
})).filter((entry) => radioSignature(entry));
|
||||
if (!selectEl) return;
|
||||
const options = ['<option value="">Custom / current</option>'];
|
||||
for (let i = 0; i < radioPresetEntries.length; i++) {
|
||||
const preset = radioPresetEntries[i];
|
||||
options.push(`<option value="${i}">${escapeHtml(preset.title)}</option>`);
|
||||
}
|
||||
selectEl.innerHTML = options.join("");
|
||||
syncRadioPresetUi();
|
||||
} catch (error) {
|
||||
radioPresetEntries = [];
|
||||
if (selectEl) {
|
||||
selectEl.innerHTML = '<option value="">Community presets unavailable</option>';
|
||||
}
|
||||
if (applyBtn) applyBtn.disabled = true;
|
||||
setRadioPresetStatus(error && error.message ? error.message : "Unable to load community presets.", true);
|
||||
}
|
||||
}
|
||||
async function refreshStats() {
|
||||
const getStatsBtn = document.getElementById("getStatsBtn");
|
||||
getStatsBtn.disabled = true;
|
||||
@@ -1270,6 +1555,42 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
||||
const value = document.getElementById("ownerInfo").value.replace(/\n/g, "|");
|
||||
runCommand("set owner.info " + value);
|
||||
};
|
||||
document.getElementById("refreshRadioBtn").onclick = () => loadRadioConfig();
|
||||
document.getElementById("reloadRadioPresetsBtn").onclick = () => loadRadioPresets();
|
||||
document.getElementById("copyPublicKeyBtn").onclick = () => copyToClipboard(document.getElementById("publicKey").value, "Public key copied");
|
||||
document.getElementById("copyPrivateKeyBtn").onclick = () => copyToClipboard(document.getElementById("privateKey").value.toUpperCase(), "Private key copied");
|
||||
document.getElementById("syncClockBtn").onclick = () => syncClock();
|
||||
document.getElementById("radioPreset").addEventListener("change", (event) => {
|
||||
const value = event.target.value;
|
||||
if (value === "") {
|
||||
syncRadioPresetUi();
|
||||
return;
|
||||
}
|
||||
const preset = radioPresetEntries[Number.parseInt(value, 10)];
|
||||
if (!preset) {
|
||||
syncRadioPresetUi();
|
||||
return;
|
||||
}
|
||||
document.getElementById("applyRadioPresetBtn").disabled = false;
|
||||
});
|
||||
document.getElementById("applyRadioPresetBtn").onclick = async () => {
|
||||
const selectEl = document.getElementById("radioPreset");
|
||||
const index = Number.parseInt(selectEl.value, 10);
|
||||
const preset = radioPresetEntries[index];
|
||||
if (!preset) {
|
||||
setRadioPresetStatus("Select a community preset first.", true);
|
||||
return;
|
||||
}
|
||||
const command = `set radio ${preset.frequency} ${preset.bandwidth} ${preset.spreadingFactor} ${preset.codingRate}`;
|
||||
const result = await runCommand(command);
|
||||
if (!result.ok) {
|
||||
setRadioPresetStatus(parseReplyValue(result.text) || "Unable to apply radio preset.", true);
|
||||
return;
|
||||
}
|
||||
currentRadioConfig = normalizeRadioConfig(preset);
|
||||
syncRadioPresetUi();
|
||||
setRadioPresetStatus("Preset saved. Reboot to apply the new radio settings.", false);
|
||||
};
|
||||
document.getElementById("rebootBtn").onclick = async () => {
|
||||
if (confirm("Reboot the repeater now?")) {
|
||||
await runCommand("reboot");
|
||||
@@ -1310,9 +1631,16 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
||||
loadBrokerState("get mqtt.letsmesh-us", "mqttLetsmeshUs"),
|
||||
loadField("get advert.interval", "advertInterval"),
|
||||
loadField("get flood.advert.interval", "floodInterval"),
|
||||
loadField("get flood.max", "floodMax")
|
||||
loadField("get flood.max", "floodMax"),
|
||||
loadField("get path.hash.mode", "pathHashMode"),
|
||||
loadField("get prv.key", "privateKey", "uppercase"),
|
||||
loadField("get role", "roleValue"),
|
||||
loadField("clock", "clockUtc"),
|
||||
loadField("get public.key", "publicKey", "uppercase"),
|
||||
loadRadioConfig()
|
||||
]);
|
||||
}
|
||||
loadRadioPresets();
|
||||
}
|
||||
refreshEastmeshModeUi();
|
||||
refreshLetsmeshModeUi();
|
||||
@@ -1528,6 +1856,12 @@ esp_err_t WebPanelServer::handleBootstrap(httpd_req_t* req) {
|
||||
{"advert_interval", "get advert.interval"},
|
||||
{"flood_interval", "get flood.advert.interval"},
|
||||
{"flood_max", "get flood.max"},
|
||||
{"path_hash_mode", "get path.hash.mode"},
|
||||
{"private_key", "get prv.key"},
|
||||
{"role", "get role"},
|
||||
{"clock", "clock"},
|
||||
{"public_key", "get public.key"},
|
||||
{"radio", "get radio"},
|
||||
};
|
||||
|
||||
httpd_resp_set_type(req, "application/json; charset=utf-8");
|
||||
|
||||
新增問題並參考
封鎖使用者