From 58e94b606dcc4921dd2162890e184ca4d269df03 Mon Sep 17 00:00:00 2001 From: Jared Dohrman Date: Mon, 27 Apr 2026 12:28:15 +1000 Subject: [PATCH] feat: add web panel region selector --- docs/custom-cli.md | 1 + docs/web-panel.md | 4 + src/helpers/web/WebPanelServer.cpp | 129 ++++++++++++++++++++++++++--- 3 files changed, 121 insertions(+), 13 deletions(-) diff --git a/docs/custom-cli.md b/docs/custom-cli.md index 8150e0de..4469651c 100644 --- a/docs/custom-cli.md +++ b/docs/custom-cli.md @@ -124,6 +124,7 @@ Notes: - commands run with the same care as if you typed them into the repeater CLI directly - this is intended for local admin use on a trusted network - `start ota` releases the local HTTP redirect listener on port `80` so the OTA HTTP listener can take over without stopping the rest of the repeater services, regardless of whether the command is run from the web panel, serial CLI, or a remote companion/app CLI session +- the `/app` Regions shortcut runs the existing MeshCore region commands in sequence: `region put au`, `region put au-STATE`, `region allowf au`, `region allowf au-STATE`, then `region save` ## Companion WiFi Rescue Commands diff --git a/docs/web-panel.md b/docs/web-panel.md index 18107ebd..3eb2d7ba 100644 --- a/docs/web-panel.md +++ b/docs/web-panel.md @@ -166,6 +166,10 @@ Notes: - the refresh buttons load the current value from the repeater - the save buttons send the matching CLI command immediately +## Radio Settings And Regions + +The `Radio Settings` section includes the community radio preset selector, path hash mode, and a `Region (Australia)` dropdown derived from the state groups used by the MQTT IATA selector. The dropdown is preselected only when the repeater already has a matching allowed `au-STATE` region. Pressing the region `Save` button creates and allows the base `au` region plus the selected state region, then persists the region map with `region save`. + ## Info This section shows: diff --git a/src/helpers/web/WebPanelServer.cpp b/src/helpers/web/WebPanelServer.cpp index 5b397bd9..39e74c5e 100644 --- a/src/helpers/web/WebPanelServer.cpp +++ b/src/helpers/web/WebPanelServer.cpp @@ -392,11 +392,13 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( button.action-dreamy { background:linear-gradient(135deg,#4e7ac7,#8b7cf6); color:#f7f7ff; border:none; } button.action-dreamy:hover { background:linear-gradient(135deg,#5c89d4,#9a8cff); } .stack { display:grid; gap:12px; } - .field-card { display:grid; gap:10px; } + .field-card { display:grid; gap:6px; align-content:start; } .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; } + .inline-actions.two-actions { grid-template-columns:minmax(0,1fr) auto; } + .label { font-size:12px; color:var(--text-muted); margin:0; display:block; } + .field-card > div > .label + .fieldline, .field-card > div > .label + .inline-actions, .stack > div > .label + .quick { margin-top:6px; } .fieldline { display:grid; grid-template-columns:1fr auto; gap:8px; align-items:center; } .iconbtn { width:44px; padding:12px 0; } .placeholder-slot { display:block; width:44px; height:44px; } @@ -733,16 +735,28 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
-
- -
- - - +
+
+ +
+ + + +
+
+
+ +
+ + +
+
@@ -1009,6 +1023,14 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( const RADIO_PRESETS_URL = "https://api.meshcore.nz/api/v1/config"; const isStatsPage = window.location.pathname === "/stats"; const PANEL_TITLE_KEY = "repeater-panel-title"; + const REGION_STATE_CODES = { + "ACT": "act", + "New South Wales": "nsw", + "Queensland": "qld", + "South Australia": "sa", + "Tasmania": "tas", + "Victoria": "vic" + }; let token = sessionStorage.getItem("repeater-token") || ""; let commandQueue = Promise.resolve(); let radioPresetEntries = []; @@ -1207,6 +1229,84 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( el.textContent = message || ""; el.style.color = isError ? "var(--status-red)" : "var(--text-muted)"; } + function setRegionStatus(message, isError) { + const el = document.getElementById("regionStatus"); + if (!el) return; + el.textContent = message || ""; + el.style.color = isError ? "var(--status-red)" : "var(--text-muted)"; + } + function selectHasOption(selectEl, value) { + if (!selectEl) return false; + for (const option of selectEl.options) { + if (option.value === value) return true; + } + return false; + } + function buildRegionStateOptions() { + const regionSelect = document.getElementById("regionState"); + const mqttSelect = document.getElementById("mqttIata"); + if (!regionSelect || !mqttSelect) return; + const previous = regionSelect.value; + const seen = new Set(); + const options = ['']; + mqttSelect.querySelectorAll("optgroup").forEach((group) => { + const label = group.label || ""; + const code = REGION_STATE_CODES[label]; + if (!code || seen.has(code)) return; + seen.add(code); + const value = "au-" + code; + options.push(``); + }); + regionSelect.innerHTML = options.join(""); + if (previous && selectHasOption(regionSelect, previous)) { + regionSelect.value = previous; + } else { + regionSelect.value = ""; + } + } + async function loadRegionSelection(options = {}) { + const regionSelect = document.getElementById("regionState"); + if (!regionSelect) return; + const result = await runCommand("region list allowed", options); + if (!result.ok) { + regionSelect.value = ""; + return; + } + const allowed = parseReplyValue(result.text).toLowerCase().split(",").map((item) => item.trim()); + for (const value of allowed) { + if (value.startsWith("au-") && selectHasOption(regionSelect, value)) { + regionSelect.value = value; + setRegionStatus("", false); + return; + } + } + regionSelect.value = ""; + setRegionStatus("", false); + } + async function saveRegionSelection() { + const regionSelect = document.getElementById("regionState"); + if (!regionSelect || !regionSelect.value) { + setRegionStatus("Select an AU state region first.", true); + return; + } + const region = regionSelect.value; + const commands = [ + "region put au", + "region put " + region, + "region allowf au", + "region allowf " + region, + "region save" + ]; + setRegionStatus("Saving regions...", false); + for (const command of commands) { + const result = await runCommand(command); + if (!result.ok) { + setRegionStatus(parseReplyValue(result.text) || ("Unable to apply: " + command), true); + return; + } + } + setRegionStatus("Saved au and " + region + ".", false); + } function setGhostNodeModeStatus(message, isError) { const el = document.getElementById("ghostNodeModeStatus"); if (!el) return; @@ -2379,6 +2479,8 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( if (mqttIataSelect) { mqttIataSelect.addEventListener("change", refreshMqttIataWarning); } + buildRegionStateOptions(); + document.getElementById("saveRegionBtn").onclick = () => saveRegionSelection(); async function setEastmeshMode(enabled) { if (enabled && getLetsmeshMode() === "both") { await setLetsmeshMode("eu"); @@ -2554,7 +2656,8 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( ]); await loadSection("Loading radio settings...", [ () => loadRadioConfig(quiet), - () => loadField("get path.hash.mode", "pathHashMode", null, quiet) + () => loadField("get path.hash.mode", "pathHashMode", null, quiet), + () => loadRegionSelection(quiet) ]); await loadSection("Loading advertising...", [ () => loadField("get advert.interval", "advertInterval", null, quiet),