feat: expand repeater web panel with radio presets, info cards, and clock sync controls

Этот коммит содержится в:
Jared Dohrman
2026-04-12 10:33:13 +10:00
родитель 3bb4f69566
Коммит 71ddba8bdc
5 изменённых файлов: 495 добавлений и 91 удалений
+1 -1
Просмотреть файл
@@ -32,7 +32,7 @@ For flashing guidance, including when to use `.bin` vs `-merged.bin`, see:
The custom firmware flasher site is: The custom firmware flasher site is:
- <https://flasher.meshcore.io/> - <https://flasher.eastmesh.au/>
## Install uv ## Install uv
+56 -2
Просмотреть файл
@@ -46,15 +46,69 @@ Practical rule:
## Recommended Flasher ## 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: Recommended usage:
- use the normal `.bin` there when you are updating an existing device - 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 - 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 Notes
`repeater_mqtt` builds include the EastMesh MQTT additions. Depending on the board, they may also include the local web panel. `repeater_mqtt` builds include the EastMesh MQTT additions. Depending on the board, they may also include the local web panel.
+10
Просмотреть файл
@@ -1483,9 +1483,14 @@ void MyMesh::runWebCommand(const char* command, char* reply, size_t reply_size)
matches_exact("get name") || matches_exact("get name") ||
matches_exact("get lat") || matches_exact("get lat") ||
matches_exact("get lon") || 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 advert.interval") ||
matches_exact("get flood.advert.interval") || matches_exact("get flood.advert.interval") ||
matches_exact("get flood.max") || matches_exact("get flood.max") ||
matches_exact("get path.hash.mode") ||
matches_exact("get owner.info") || matches_exact("get owner.info") ||
matches_exact("get guest.password") || matches_exact("get guest.password") ||
matches_prefix("set wifi.ssid ") || 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 name ") ||
matches_prefix("set lat ") || matches_prefix("set lat ") ||
matches_prefix("set lon ") || matches_prefix("set lon ") ||
matches_prefix("set radio ") ||
matches_prefix("password ") ||
matches_prefix("set guest.password ") || matches_prefix("set guest.password ") ||
matches_prefix("set prv.key ") || matches_prefix("set prv.key ") ||
matches_prefix("set advert.interval ") || matches_prefix("set advert.interval ") ||
matches_prefix("set flood.advert.interval ") || matches_prefix("set flood.advert.interval ") ||
matches_prefix("set flood.max ") || matches_prefix("set flood.max ") ||
matches_prefix("set path.hash.mode ") ||
matches_prefix("time ") ||
matches_prefix("time.force ") ||
matches_prefix("set owner.info "); matches_prefix("set owner.info ");
if (!allowed) { if (!allowed) {
+6
Просмотреть файл
@@ -249,6 +249,12 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch
uint32_t now = getRTCClock()->getCurrentTime(); uint32_t now = getRTCClock()->getCurrentTime();
DateTime dt = DateTime(now); DateTime dt = DateTime(now);
sprintf(reply, "%02d:%02d - %d/%d/%d UTC", dt.hour(), dt.minute(), dt.day(), dt.month(), dt.year()); 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) } else if (memcmp(command, "time ", 5) == 0) { // set time (to epoch seconds)
uint32_t secs = _atoi(&command[5]); uint32_t secs = _atoi(&command[5]);
uint32_t curr = getRTCClock()->getCurrentTime(); uint32_t curr = getRTCClock()->getCurrentTime();
+422 -88
Просмотреть файл
@@ -194,7 +194,7 @@ const char kWebPanelLoginHtml[] PROGMEM = R"HTML(
<main> <main>
<section class="card"> <section class="card">
<h1>Repeater Config</h1> <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"> <input id="password" type="password" placeholder="Admin password" autocomplete="current-password">
<button id="loginBtn">Unlock</button> <button id="loginBtn">Unlock</button>
<div id="status"></div> <div id="status"></div>
@@ -296,6 +296,8 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
button.action-dreamy:hover { background:linear-gradient(135deg,#5c89d4,#9a8cff); } button.action-dreamy:hover { background:linear-gradient(135deg,#5c89d4,#9a8cff); }
.stack { display:grid; gap:12px; } .stack { display:grid; gap:12px; }
.field-card { display:grid; gap:10px; } .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; } .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; } .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; } .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; } .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-warning { min-height:1.4em; font-size:13px; color:var(--status-red); }
.panel-note { font-size:13px; color:var(--text-muted); } .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; } .themebtn { padding:10px 14px; }
#status { white-space:pre-wrap; color:var(--text-muted); min-height:1.4em; } #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; } .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> <main>
<section class="card" id="login" style="display:none"> <section class="card" id="login" style="display:none">
<h1>Repeater Config</h1> <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"> <div class="row" style="margin-top:14px">
<input id="password" type="password" placeholder="Admin password"> <input id="password" type="password" placeholder="Admin password">
<button id="loginBtn">Unlock</button> <button id="loginBtn">Unlock</button>
@@ -456,94 +459,183 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
<div id="reply" class="terminal"></div> <div id="reply" class="terminal"></div>
</section> </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">&#8635;</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">&#x2398;</button>
</div>
</div>
</div>
</section>
<section class="card" id="repeaterSettingsPanel" style="display:none"> <section class="card" id="repeaterSettingsPanel" style="display:none">
<h2>Repeater Settings</h2> <h2>Repeater Settings</h2>
<div class="stack"> <div class="stack">
<div class="field-card"> <div class="section-group">
<label class="label" for="nodeName">Device Name</label> <h3>Repeater Settings</h3>
<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">&#8635;</button>
<button class="savebtn" data-prefix="set name " data-input="nodeName">Save</button>
</div>
</div>
<div class="row">
<div class="field-card"> <div class="field-card">
<div> <label class="label" for="nodeName">Device Name</label>
<label class="label" for="nodeLat">Latitude</label> <div class="inline-actions">
<div class="fieldline"> <input id="nodeName" placeholder="MeshCore-HOWL">
<input id="nodeLat" placeholder="0.0"> <button class="iconbtn" data-load-cmd="get name" data-load-input="nodeName" title="Refresh device name">&#8635;</button>
<button class="iconbtn" data-load-cmd="get lat" data-load-input="nodeLat" title="Refresh latitude">&#8635;</button> <button class="savebtn" data-prefix="set name " data-input="nodeName">Save</button>
</div> </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">&#8635;</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">&#8635;</button>
</div>
</div>
<button class="savebtn" data-prefix="set lon " data-input="nodeLon">Save longitude</button>
</div> </div>
<button class="savebtn" data-prefix="set lat " data-input="nodeLat">Save latitude</button>
</div> </div>
<div class="field-card"> <div class="field-card">
<div> <label class="label" for="privateKey">Private Key</label>
<label class="label" for="nodeLon">Longitude</label> <div class="inline-actions">
<div class="fieldline"> <input id="privateKey" type="password" placeholder="64-hex-char private key">
<input id="nodeLon" placeholder="0.0"> <button id="copyPrivateKeyBtn" class="iconbtn" title="Copy private key">&#x2398;</button>
<button class="iconbtn" data-load-cmd="get lon" data-load-input="nodeLon" title="Refresh longitude">&#8635;</button> <button class="savebtn" data-prefix="set prv.key " data-input="privateKey">Save</button>
</div> </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">&#8635;</button>
<button id="saveOwnerInfo" class="savebtn">Save</button>
</div> </div>
<button class="savebtn" data-prefix="set lon " data-input="nodeLon">Save longitude</button>
</div> </div>
</div> </div>
<div class="field-card"> <div class="section-group">
<label class="label" for="guestPassword">Guest Password</label> <h3>Access</h3>
<div class="inline-actions"> <div class="field-card">
<input id="guestPassword" type="password" placeholder="new guest password"> <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> <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> <div class="field-card">
<div class="field-card"> <label class="label" for="guestPassword">Guest Password</label>
<label class="label" for="privateKey">Private Key</label> <div class="inline-actions">
<div class="inline-actions"> <input id="guestPassword" type="password" placeholder="new guest password">
<input id="privateKey" placeholder="64-hex-char private key">
<span class="placeholder-slot" aria-hidden="true"></span> <span class="placeholder-slot" aria-hidden="true"></span>
<button class="savebtn" data-prefix="set prv.key " data-input="privateKey">Save</button> <button class="savebtn" data-prefix="set guest.password " data-input="guestPassword">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">&#8635;</button>
</div> </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">&#8635;</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">&#8635;</button>
</div>
</div>
<button class="savebtn" data-prefix="set flood.max " data-input="floodMax">Save flood max</button>
</div> </div>
</div> </div>
<div class="field-card"> <div class="section-group">
<label class="label" for="ownerInfo">Owner Info</label> <h3>Radio Settings</h3>
<div class="inline-actions"> <div class="field-card">
<textarea id="ownerInfo" placeholder="Free text shown in owner info"></textarea> <label class="label" for="radioPreset">Preset</label>
<button class="iconbtn" data-load-cmd="get owner.info" data-load-input="ownerInfo" data-load-format="multiline" title="Refresh owner info">&#8635;</button> <div class="row">
<button id="saveOwnerInfo" class="savebtn">Save</button> <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">&#8635;</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">&#8635;</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">&#8635;</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">&#8635;</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">&#8635;</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">&#8635;</button>
</div>
</div>
<button class="savebtn" data-prefix="set flood.max " data-input="floodMax">Save flood max</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -710,8 +802,11 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
</main> </main>
<script> <script>
const RADIO_PRESETS_URL = "https://api.meshcore.nz/api/v1/config";
let token = sessionStorage.getItem("repeater-token") || ""; let token = sessionStorage.getItem("repeater-token") || "";
let commandQueue = Promise.resolve(); let commandQueue = Promise.resolve();
let radioPresetEntries = [];
let currentRadioConfig = null;
const statusEl = document.getElementById("status"); const statusEl = document.getElementById("status");
const replyEl = document.getElementById("reply"); const replyEl = document.getElementById("reply");
const themeToggleEl = document.getElementById("themeToggle"); const themeToggleEl = document.getElementById("themeToggle");
@@ -756,6 +851,10 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
function parseReplyValue(text) { function parseReplyValue(text) {
return (text || "").replace(/^>\s*/, "").trim(); 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) { function clamp(value, min, max) {
return Math.min(max, Math.max(min, value)); 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"; if (abs >= 1024) return (value / 1024).toFixed(1) + " KB";
return Math.round(value) + " B"; 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) { function toneForPercent(percent, invert) {
if (invert) { if (invert) {
if (percent >= 70) return "bad"; 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("cliPanel").style.display = show ? "block" : "none";
document.getElementById("quickCommandsPanel").style.display = show ? "block" : "none"; document.getElementById("quickCommandsPanel").style.display = show ? "block" : "none";
document.getElementById("mqttSettingsPanel").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("statsPanel").style.display = show ? "block" : "none";
document.getElementById("actionsPanel").style.display = show ? "block" : "none"; document.getElementById("actionsPanel").style.display = show ? "block" : "none";
document.getElementById("repeaterSettingsPanel").style.display = show ? "block" : "none"; document.getElementById("repeaterSettingsPanel").style.display = show ? "block" : "none";
@@ -1028,10 +1208,11 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
redirectToLogin(); redirectToLogin();
return { ok:false, text }; return { ok:false, text };
} }
const ok = res.ok && !isCommandError(text);
if (recordHistory) { if (recordHistory) {
appendHistory(cmd, text, res.ok); appendHistory(cmd, text, ok);
} }
return { ok:res.ok, text }; return { ok, text };
}); });
} }
function runPrefixed(prefix, inputId) { function runPrefixed(prefix, inputId) {
@@ -1044,9 +1225,49 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
let value = parseReplyValue(result.text); let value = parseReplyValue(result.text);
if (format === "multiline") { if (format === "multiline") {
value = value.replace(/\|/g, "\n"); value = value.replace(/\|/g, "\n");
} else if (format === "uppercase") {
value = value.toUpperCase();
} }
document.getElementById(inputId).value = value; 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) { async function fetchJson(path) {
if (!token) { if (!token) {
redirectToLogin(); redirectToLogin();
@@ -1065,8 +1286,8 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
return JSON.parse(text); return JSON.parse(text);
}); });
} }
function applyBootstrapData(data) { function applyBootstrapData(data) {
if (!data) return; if (!data) return;
if (typeof data.name === "string") document.getElementById("nodeName").value = data.name; 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_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; 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.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_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.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.path_hash_mode === "string") document.getElementById("pathHashMode").value = data.path_hash_mode;
if (typeof data.mqtt_letsmesh_eu === "string") setBrokerToggle("mqttLetsmeshEu", data.mqtt_letsmesh_eu); if (typeof data.private_key === "string") document.getElementById("privateKey").value = data.private_key.toUpperCase();
if (typeof data.mqtt_letsmesh_us === "string") setBrokerToggle("mqttLetsmeshUs", data.mqtt_letsmesh_us); if (typeof data.radio === "string") currentRadioConfig = parseRadioValue(data.radio);
updateBrokerWarning(); 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() { function getLetsmeshMode() {
const eu = document.getElementById("mqttLetsmeshEu"); const eu = document.getElementById("mqttLetsmeshEu");
const us = document.getElementById("mqttLetsmeshUs"); const us = document.getElementById("mqttLetsmeshUs");
@@ -1151,12 +1379,69 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
function updateBrokerWarning() { function updateBrokerWarning() {
document.getElementById("mqttBrokerWarning").textContent = ""; document.getElementById("mqttBrokerWarning").textContent = "";
} }
async function loadBrokerState(cmd, inputId) { async function loadBrokerState(cmd, inputId) {
const result = await runCommand(cmd); const result = await runCommand(cmd);
if (!result.ok) return; if (!result.ok) return;
setBrokerToggle(inputId, parseReplyValue(result.text)); setBrokerToggle(inputId, parseReplyValue(result.text));
updateBrokerWarning(); 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() { async function refreshStats() {
const getStatsBtn = document.getElementById("getStatsBtn"); const getStatsBtn = document.getElementById("getStatsBtn");
getStatsBtn.disabled = true; getStatsBtn.disabled = true;
@@ -1270,6 +1555,42 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
const value = document.getElementById("ownerInfo").value.replace(/\n/g, "|"); const value = document.getElementById("ownerInfo").value.replace(/\n/g, "|");
runCommand("set owner.info " + value); 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 () => { document.getElementById("rebootBtn").onclick = async () => {
if (confirm("Reboot the repeater now?")) { if (confirm("Reboot the repeater now?")) {
await runCommand("reboot"); await runCommand("reboot");
@@ -1310,9 +1631,16 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
loadBrokerState("get mqtt.letsmesh-us", "mqttLetsmeshUs"), loadBrokerState("get mqtt.letsmesh-us", "mqttLetsmeshUs"),
loadField("get advert.interval", "advertInterval"), loadField("get advert.interval", "advertInterval"),
loadField("get flood.advert.interval", "floodInterval"), 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(); refreshEastmeshModeUi();
refreshLetsmeshModeUi(); refreshLetsmeshModeUi();
@@ -1528,6 +1856,12 @@ esp_err_t WebPanelServer::handleBootstrap(httpd_req_t* req) {
{"advert_interval", "get advert.interval"}, {"advert_interval", "get advert.interval"},
{"flood_interval", "get flood.advert.interval"}, {"flood_interval", "get flood.advert.interval"},
{"flood_max", "get flood.max"}, {"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"); httpd_resp_set_type(req, "application/json; charset=utf-8");