mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-09-01 22:48:17 +00:00
1321 lines
68 KiB
HTML
1321 lines
68 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover">
|
|
<title>MeshCore Config</title>
|
|
<style>
|
|
:root{
|
|
--bg:#f4f6f8; --card:#fff; --ink:#1a2330; --mut:#66738a; --line:#e2e7ee;
|
|
--acc:#2b7de9; --acc-ink:#fff; --ok:#1d9d5f; --err:#d64545; --warn:#c8871a;
|
|
--chip:#eef2f7; --in-bg:#fff; --in-line:#c8d2de; --shadow:0 1px 3px rgba(16,28,45,.08);
|
|
}
|
|
@media (prefers-color-scheme:dark){:root{
|
|
--bg:#10151c; --card:#1a212b; --ink:#e6ebf2; --mut:#8b98ab; --line:#2a3442;
|
|
--acc:#4a94f0; --chip:#232d3a; --in-bg:#141a22; --in-line:#3a4656; --shadow:0 1px 3px rgba(0,0,0,.4);
|
|
}}
|
|
*{box-sizing:border-box;margin:0}
|
|
html{-webkit-text-size-adjust:100%}
|
|
body{font:15px/1.45 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--ink);padding-bottom:90px;overflow-x:hidden}
|
|
h1{font-size:17px;font-weight:650}
|
|
h2{font-size:14px;font-weight:650;text-transform:uppercase;letter-spacing:.05em;color:var(--mut);margin:22px 0 10px}
|
|
h2:first-child{margin-top:0}
|
|
a{color:var(--acc)}
|
|
header{display:flex;align-items:center;gap:10px;padding:12px 16px;background:var(--card);border-bottom:1px solid var(--line);position:sticky;top:0;z-index:10}
|
|
header svg{flex:none}
|
|
.hmeta{min-width:0}
|
|
.hmeta div{font-size:12px;color:var(--mut);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
.badge{margin-left:auto;flex:none;font-size:11px;font-weight:650;padding:3px 9px;border-radius:99px;background:var(--chip);color:var(--mut)}
|
|
.badge.setup{background:#f3e8d3;color:#8a5c00}
|
|
@media (prefers-color-scheme:dark){.badge.setup{background:#3a2f14;color:#e0b45c}}
|
|
main{max-width:640px;margin:0 auto;padding:16px}
|
|
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:16px;box-shadow:var(--shadow);margin-bottom:14px}
|
|
.tabs{display:flex;gap:4px;background:var(--chip);padding:4px;border-radius:10px;margin-bottom:14px}
|
|
.tabs button{flex:1;border:0;background:none;color:var(--mut);font:inherit;font-size:13.5px;font-weight:600;padding:7px 4px;border-radius:7px;cursor:pointer}
|
|
.tabs button.on{background:var(--card);color:var(--ink);box-shadow:var(--shadow)}
|
|
.f{margin-bottom:13px}
|
|
.f label{display:block;font-size:12.5px;font-weight:600;margin-bottom:4px}
|
|
.f .hint{font-size:11.5px;color:var(--mut);margin-top:3px}
|
|
input[type=text],input[type=password],input[type=number],input[type=email],select{
|
|
width:100%;padding:8px 10px;font:inherit;font-size:16px;color:var(--ink);background:var(--in-bg);
|
|
border:1px solid var(--in-line);border-radius:8px;outline:none} /* >=16px: stops iOS zoom-on-focus (sideways-scroll in captive sheet) */
|
|
input:focus,select:focus{border-color:var(--acc);box-shadow:0 0 0 2px color-mix(in srgb,var(--acc) 25%,transparent)}
|
|
input.dirty,select.dirty{border-color:var(--warn)}
|
|
.row{display:flex;gap:10px}.row>.f{flex:1;min-width:0}
|
|
.sw{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:9px 0}
|
|
.sw span b{display:block;font-size:13.5px;font-weight:600}
|
|
.sw span i{font-style:normal;font-size:11.5px;color:var(--mut)}
|
|
.tgl{position:relative;width:42px;height:24px;flex:none}
|
|
.tgl input{position:absolute;opacity:0;width:100%;height:100%;margin:0;cursor:pointer}
|
|
.tgl u{position:absolute;inset:0;background:var(--in-line);border-radius:99px;transition:.15s;pointer-events:none}
|
|
.tgl u:before{content:"";position:absolute;top:3px;left:3px;width:18px;height:18px;background:#fff;border-radius:50%;transition:.15s}
|
|
.tgl input:checked+u{background:var(--acc)}
|
|
.tgl input:checked+u:before{left:21px}
|
|
button.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;border:0;font:inherit;font-size:14px;font-weight:650;
|
|
padding:9px 16px;border-radius:9px;cursor:pointer;background:var(--acc);color:var(--acc-ink)}
|
|
button.btn.sec{background:var(--chip);color:var(--ink)}
|
|
button.btn:disabled{opacity:.5;cursor:default}
|
|
button.btn.sm{font-size:12.5px;padding:6px 10px}
|
|
.chip{display:inline-block;font-size:11px;font-weight:650;padding:2px 8px;border-radius:99px;margin-left:6px;vertical-align:1px}
|
|
.chip.ok{background:color-mix(in srgb,var(--ok) 15%,transparent);color:var(--ok)}
|
|
.chip.err{background:color-mix(in srgb,var(--err) 15%,transparent);color:var(--err)}
|
|
.savebar{position:fixed;left:0;right:0;bottom:0;z-index:20;background:var(--card);border-top:1px solid var(--line);
|
|
padding:10px 16px calc(10px + env(safe-area-inset-bottom));display:none;align-items:center;gap:12px}
|
|
.savebar.show{display:flex}
|
|
.savebar b{font-size:13.5px}
|
|
.savebar .sp{flex:1}
|
|
.slot{border:1px solid var(--line);border-radius:10px;padding:12px;margin-bottom:10px}
|
|
.slot .st{display:flex;align-items:center;gap:8px;margin-bottom:10px}
|
|
.slot .st b{font-size:13.5px}
|
|
.pill{font-size:10.5px;font-weight:700;padding:2px 8px;border-radius:99px;background:var(--chip);color:var(--mut);text-transform:uppercase;letter-spacing:.04em}
|
|
.pill.ok{background:color-mix(in srgb,var(--ok) 15%,transparent);color:var(--ok)}
|
|
.pill.fail,.pill.disc{background:color-mix(in srgb,var(--err) 15%,transparent);color:var(--err)}
|
|
.pill.wait{background:color-mix(in srgb,var(--warn) 15%,transparent);color:var(--warn)}
|
|
.tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:10px}
|
|
.tile{background:var(--chip);border-radius:10px;padding:10px 12px}
|
|
.tile i{font-style:normal;display:block;font-size:11px;font-weight:650;color:var(--mut);text-transform:uppercase;letter-spacing:.04em}
|
|
.tile b{font-size:18px;font-weight:650}
|
|
.tile s{text-decoration:none;font-size:11.5px;color:var(--mut)}
|
|
canvas{width:100%;height:56px;display:block}
|
|
.net{display:flex;align-items:center;gap:10px;width:100%;border:0;background:none;color:var(--ink);font:inherit;
|
|
padding:10px 6px;border-bottom:1px solid var(--line);cursor:pointer;text-align:left}
|
|
.net:last-child{border-bottom:0}
|
|
.net .ss{flex:1;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
.net small{color:var(--mut)}
|
|
/* Inline scan panel (NOT an overlay): the iOS CNA sheet ignores programmatic
|
|
blur(), and when the keyboard is up iOS scrolls fixed overlays offscreen to
|
|
keep the focused field visible. A panel in normal flow directly under the
|
|
SSID field sits in that guaranteed-visible zone instead. */
|
|
.scanpanel{border:1px solid var(--line);border-radius:10px;background:var(--card);box-shadow:var(--shadow);margin:-2px 0 13px}
|
|
.scanpanel .sp-head{display:flex;align-items:center;gap:6px;font-size:12px;font-weight:650;text-transform:uppercase;letter-spacing:.05em;color:var(--mut);padding:10px 12px 4px}
|
|
.scanpanel #scan-list{max-height:45vh;overflow:auto;padding:0 8px 6px}
|
|
.steps{display:flex;gap:6px;margin-bottom:16px}
|
|
.steps i{flex:1;height:4px;border-radius:2px;background:var(--line)}
|
|
.steps i.on{background:var(--acc)}
|
|
.toast{position:fixed;top:64px;left:50%;transform:translateX(-50%);z-index:40;background:var(--ink);color:var(--bg);
|
|
font-size:13.5px;font-weight:600;padding:9px 16px;border-radius:9px;opacity:0;transition:.25s;pointer-events:none;max-width:90vw}
|
|
.toast.show{opacity:1}
|
|
.overlay{position:fixed;inset:0;z-index:50;background:var(--bg);display:none;align-items:center;justify-content:center;text-align:center;padding:24px}
|
|
.overlay.show{display:flex}
|
|
.spin{width:22px;height:22px;border:3px solid var(--line);border-top-color:var(--acc);border-radius:50%;animation:r 1s linear infinite;display:inline-block}
|
|
@keyframes r{to{transform:rotate(360deg)}}
|
|
.err-text{color:var(--err);font-size:13px;margin-top:8px;min-height:18px}
|
|
.kv{font-size:13.5px;display:flex;justify-content:space-between;gap:12px;padding:6px 0;border-bottom:1px solid var(--line)}
|
|
.kv:last-child{border-bottom:0}
|
|
.kv i{font-style:normal;color:var(--mut)}
|
|
.kv b{font-weight:600;text-align:right;overflow:hidden;text-overflow:ellipsis}
|
|
.hide{display:none!important}
|
|
.note{font-size:12.5px;color:var(--mut);background:var(--chip);border-radius:8px;padding:9px 11px;margin-bottom:13px}
|
|
.cli-out{min-height:260px;max-height:52vh;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;
|
|
background:#0b1016;color:#dce7f5;border:1px solid var(--line);border-radius:9px;padding:11px;
|
|
font:13px/1.45 ui-monospace,SFMono-Regular,Consolas,"Liberation Mono",monospace;margin-bottom:10px}
|
|
.cli-mode{display:flex;gap:16px;margin:0 0 9px;font-size:13px;color:var(--mut)}
|
|
.cli-mode label{display:flex;align-items:center;gap:6px;cursor:pointer}
|
|
.cli-mode input{margin:0}
|
|
.cli-row{display:flex;align-items:flex-end;gap:8px}
|
|
.cli-row input,.cli-row textarea{flex:1;min-width:0;padding:9px 10px;font:16px ui-monospace,SFMono-Regular,Consolas,"Liberation Mono",monospace;
|
|
color:var(--ink);background:var(--in-bg);border:1px solid var(--in-line);border-radius:8px;outline:none}
|
|
.cli-row textarea{width:100%;min-height:150px;resize:vertical}
|
|
.cli-row input:focus,.cli-row textarea:focus{border-color:var(--acc);box-shadow:0 0 0 2px color-mix(in srgb,var(--acc) 25%,transparent)}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="var(--acc)" stroke-width="2" stroke-linecap="round">
|
|
<circle cx="12" cy="12" r="2.2" fill="var(--acc)" stroke="none"/>
|
|
<path d="M7.7 7.7a6 6 0 0 0 0 8.6M16.3 7.7a6 6 0 0 1 0 8.6M4.9 4.9a10 10 0 0 0 0 14.2M19.1 4.9a10 10 0 0 1 0 14.2"/>
|
|
</svg>
|
|
<div class="hmeta">
|
|
<h1 id="h-name">MeshCore</h1>
|
|
<div id="h-sub">connecting…</div>
|
|
</div>
|
|
<span class="badge hide" id="h-badge"></span>
|
|
</header>
|
|
<main>
|
|
|
|
<!-- ============ LOGIN ============ -->
|
|
<section id="v-login" class="hide">
|
|
<div class="card">
|
|
<h2>Admin login</h2>
|
|
<p style="font-size:13.5px;color:var(--mut);margin-bottom:12px">Enter this node's admin password (the same one used for remote CLI admin).</p>
|
|
<form id="login-form">
|
|
<div class="f"><label for="login-pwd">Password</label>
|
|
<input type="password" id="login-pwd" autocomplete="current-password" autofocus></div>
|
|
<button class="btn" type="submit" id="login-btn">Sign in</button>
|
|
<div class="err-text" id="login-err"></div>
|
|
</form>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============ SETUP WIZARD ============ -->
|
|
<section id="v-wizard" class="hide">
|
|
<div class="note" id="cna-note">In the WiFi sign-in popup? It closes if your phone sleeps.
|
|
<a href="#" onclick="popOut();return false" style="white-space:nowrap">Open in your browser</a> to keep your progress.</div>
|
|
<div class="steps"><i id="stp1" class="on"></i><i id="stp2"></i><i id="stp3"></i><i id="stp4"></i></div>
|
|
|
|
<div id="wz1">
|
|
<div class="card">
|
|
<h2>Step 1 · WiFi</h2>
|
|
<p style="font-size:13.5px;color:var(--mut);margin-bottom:12px" id="wz-wifi-copy">Connect this node to your WiFi network so it can reach the MQTT servers.</p>
|
|
<div class="f"><label>Network name (SSID)</label>
|
|
<div class="row" style="gap:8px">
|
|
<input type="text" data-k="wifi.ssid" id="wz-ssid" autocapitalize="off" autocorrect="off" style="flex:1">
|
|
<button class="btn sec" type="button" onmousedown="event.preventDefault()" onclick="openScan('wz-ssid')">Scan</button>
|
|
</div></div>
|
|
<div class="f"><label>Password</label>
|
|
<input type="password" data-k="wifi.pwd" autocomplete="off">
|
|
<div class="hint">Leave blank for an open network. 2.4 GHz networks only.</div></div>
|
|
<div class="f"><label>Node name</label>
|
|
<input type="text" data-k="name" maxlength="31">
|
|
<div class="hint" id="wz-name-hint">Shown on the mesh and in MQTT status messages.</div></div>
|
|
</div>
|
|
<button class="btn" onclick="wzGo(2)">Next: Radio</button>
|
|
</div>
|
|
|
|
<div id="wz2" class="hide">
|
|
<div class="card">
|
|
<h2>Step 2 · Radio</h2>
|
|
<p style="font-size:13.5px;color:var(--mut);margin-bottom:12px">Nodes only hear each other on identical radio settings. Pick the preset your local mesh uses.</p>
|
|
<div class="f"><label>Region preset</label>
|
|
<select id="wz-rp" onchange="wzRadioSel(this)"></select>
|
|
<div class="hint" id="wz-rp-cur"></div></div>
|
|
<div class="f"><label>TX power (dBm)</label>
|
|
<input type="number" data-k="tx" min="-9" max="30" style="max-width:120px">
|
|
<div class="hint">Maximum legal power varies by region — check local rules.</div></div>
|
|
<div class="note">Need settings that aren't listed? Choose <b>Keep current settings</b>, finish setup, then fine-tune in the <a href="#" onclick="enterApp();return false">Advanced editor</a>.</div>
|
|
</div>
|
|
<div class="card password-card">
|
|
<h2>Admin password</h2>
|
|
<p style="font-size:13.5px;color:var(--mut);margin-bottom:12px">Replaces the factory password. It is used to sign in here later and to administer the node remotely over the mesh.</p>
|
|
<div class="f"><label>New password</label>
|
|
<input type="password" data-k="password" maxlength="15" autocomplete="new-password">
|
|
<div class="hint">Maximum 15 characters.</div></div>
|
|
<div class="f"><label>Confirm password</label>
|
|
<input type="password" data-cfm="password" maxlength="15" autocomplete="new-password"></div>
|
|
</div>
|
|
<div class="row">
|
|
<button class="btn sec" onclick="wzGo(1)">Back</button>
|
|
<button class="btn" style="flex:1" id="wz-radio-next" onclick="wzGo(3)">Next: MQTT</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="wz3" class="hide">
|
|
<div class="card">
|
|
<h2>Step 3 · MQTT</h2>
|
|
<div class="f"><label>Observer name (origin)</label>
|
|
<input type="text" data-k="mqtt.origin" autocapitalize="off">
|
|
<div class="hint">How this observer identifies itself in published status/packet messages. Informational only — not used for topics or authentication. Prefilled with the node name.</div></div>
|
|
<div class="f"><label>IATA region code</label>
|
|
<input type="text" data-k="mqtt.iata" maxlength="3" autocapitalize="characters" style="text-transform:uppercase;max-width:120px">
|
|
<div class="hint">Nearest airport code, e.g. <b>DEN</b>. Used in topic paths.</div></div>
|
|
<div class="f"><label>Owner public key</label>
|
|
<input type="text" data-k="mqtt.owner" maxlength="64" autocapitalize="off" autocorrect="off" autocomplete="off">
|
|
<div class="hint">Optional — 64-char hex public key of your companion node. Included in auth JWTs so services that support it can let you claim this node.</div></div>
|
|
<div class="f"><label>Owner email</label>
|
|
<input type="email" data-k="mqtt.email" autocapitalize="off">
|
|
<div class="hint">Optional — also included in auth JWTs for claiming this node on some services.</div></div>
|
|
<h2>Servers</h2>
|
|
<div id="wz-slots"></div>
|
|
</div>
|
|
<div class="row">
|
|
<button class="btn sec" onclick="wzGo(2)">Back</button>
|
|
<button class="btn" style="flex:1" onclick="wzGo(4)">Review</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="wz4" class="hide">
|
|
<div class="card">
|
|
<h2 id="wz-review-title">Step 4 · Review & save</h2>
|
|
<div id="wz-review"></div>
|
|
</div>
|
|
<div class="note">Saving reboots the node into normal operation. To keep the page available later, run <b>set webui on</b>; use <b>get webui</b> to see its address.</div>
|
|
<div class="row">
|
|
<button class="btn sec" id="wz-review-back" onclick="wzGo(3)">Back</button>
|
|
<button class="btn" style="flex:1" id="wz-save" onclick="wizardSave()">Save & reboot</button>
|
|
</div>
|
|
<div class="err-text" id="wz-err"></div>
|
|
</div>
|
|
|
|
<p style="text-align:center;margin-top:16px"><a href="#" onclick="enterApp();return false" style="font-size:13px">Advanced editor</a></p>
|
|
</section>
|
|
|
|
<!-- ============ MAIN APP ============ -->
|
|
<section id="v-app" class="hide">
|
|
<div class="tabs" id="tabs">
|
|
<button data-t="radio" class="on">Radio</button>
|
|
<button data-t="mqtt">MQTT</button>
|
|
<button data-t="wifi">WiFi</button>
|
|
<button data-t="stats">Stats</button>
|
|
<button data-t="cli">CLI</button>
|
|
</div>
|
|
|
|
<div id="t-radio">
|
|
<div class="card">
|
|
<h2>Node</h2>
|
|
<div class="f"><label>Name</label><input type="text" data-k="name" maxlength="31"></div>
|
|
<div class="row" data-cap="1">
|
|
<div class="f"><label>Latitude</label><input type="number" data-k="lat" step="any"></div>
|
|
<div class="f"><label>Longitude</label><input type="number" data-k="lon" step="any"></div>
|
|
</div>
|
|
</div>
|
|
<div class="card password-card">
|
|
<h2>Admin password</h2>
|
|
<div class="note">Signs in here and authenticates remote admin commands over the mesh. Leave blank to keep the current password. Maximum 15 characters.</div>
|
|
<div class="f"><label>New password</label>
|
|
<input type="password" data-k="password" maxlength="15" autocomplete="new-password"></div>
|
|
<div class="f"><label>Confirm password</label>
|
|
<input type="password" data-cfm="password" maxlength="15" autocomplete="new-password"></div>
|
|
</div>
|
|
<div class="card">
|
|
<h2>LoRa radio <span class="chip err hide" id="radio-warn">reboot to apply</span></h2>
|
|
<div class="note">Frequency, bandwidth, SF and CR must match your mesh exactly — a wrong value takes this node off the air until fixed over serial.</div>
|
|
<div class="row">
|
|
<div class="f"><label>Frequency (MHz)</label><input type="number" data-rg="freq" step="0.001" min="150" max="2500"></div>
|
|
<div class="f"><label>Bandwidth (kHz)</label><input type="number" data-rg="bw" step="0.01" min="7" max="500"></div>
|
|
</div>
|
|
<div class="row">
|
|
<div class="f"><label>Spreading factor</label>
|
|
<select data-rg="sf"><option>5</option><option>6</option><option>7</option><option>8</option><option>9</option><option>10</option><option>11</option><option>12</option></select></div>
|
|
<div class="f"><label>Coding rate</label>
|
|
<select data-rg="cr"><option>5</option><option>6</option><option>7</option><option>8</option></select></div>
|
|
</div>
|
|
<div class="f"><label>TX power (dBm)</label><input type="number" data-k="tx" min="-9" max="30" style="max-width:120px"></div>
|
|
</div>
|
|
<div class="card">
|
|
<h2>Advanced</h2>
|
|
<div class="row">
|
|
<div class="f" data-cap="2"><label>Airtime factor</label><input type="number" data-k="af" step="0.1" min="0">
|
|
<div class="hint" id="af-dc"></div></div>
|
|
<div class="f" data-cap="4"><label>RX delay base</label><input type="number" data-k="rxdelay" step="0.1" min="0" max="20"></div>
|
|
</div>
|
|
<div class="f" data-cap="512"><label>TX delay factor</label><input type="number" data-k="txdelay" step="0.1" min="0" max="2" style="max-width:120px"></div>
|
|
<div class="sw" data-cap="8"><span><b>CAD (listen before transmit)</b><i>Channel activity detection</i></span>
|
|
<span class="tgl"><input type="checkbox" data-k="cad"><u></u></span></div>
|
|
<div class="sw" data-cap="16"><span><b>RX boosted gain</b><i>SX126x receivers only</i></span>
|
|
<span class="tgl"><input type="checkbox" data-k="radio.rxgain"><u></u></span></div>
|
|
<div class="sw" data-cap="2048"><span><b>FEM RX boost</b><i>External front-end-module LNA</i></span>
|
|
<span class="tgl"><input type="checkbox" data-k="radio.fem.rxgain"><u></u></span></div>
|
|
<div class="sw" data-cap="32"><span><b>Repeat</b><i>Forward mesh traffic. Off = listen-only (still observes and publishes).</i></span>
|
|
<span class="tgl"><input type="checkbox" data-k="repeat"><u></u></span></div>
|
|
<div class="row" style="margin-top:6px" data-cap="64">
|
|
<div class="f"><label>Local advert (min)</label><input type="number" data-k="advert.interval" min="0" max="240">
|
|
<div class="hint">0 = off</div></div>
|
|
<div class="f"><label>Flood advert (hrs)</label><input type="number" data-k="flood.advert.interval" min="0" max="168">
|
|
<div class="hint">0 = off, else 3–168</div></div>
|
|
</div>
|
|
</div>
|
|
<div class="card" id="radio-flood-card">
|
|
<h2>Flooding & loops</h2>
|
|
<div class="note">Hop limits for flood traffic this node forwards. Lower values contain noisy neighbours; too low takes parts of the mesh out of reach.</div>
|
|
<div class="row" data-cap="128">
|
|
<div class="f"><label>Max flood hops</label><input type="number" data-k="flood.max" min="0" max="64">
|
|
<div class="hint">0–64. Default 64.</div></div>
|
|
<div class="f"><label>Max advert hops</label><input type="number" data-k="flood.max.advert" min="0" max="64">
|
|
<div class="hint">0–64. Default 8.</div></div>
|
|
</div>
|
|
<div class="f" data-cap="128"><label>Max unscoped flood hops</label><input type="number" data-k="flood.max.unscoped" min="0" max="64" style="max-width:120px">
|
|
<div class="hint">Applies to packets with no region scope. 0–64.</div></div>
|
|
<div class="f" style="margin-top:6px" data-cap="256"><label>Loop detection</label>
|
|
<select data-k="loop.detect"><option value="off">Off</option><option value="minimal">Minimal</option><option value="moderate">Moderate</option><option value="strict">Strict</option></select>
|
|
<div class="hint">Drops flood packets that already carry this node's hash. Stricter = fewer repeats tolerated before dropping.</div></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="t-mqtt" class="hide">
|
|
<div class="card">
|
|
<h2>Identity</h2>
|
|
<div class="f"><label>Observer name (origin)</label><input type="text" data-k="mqtt.origin" autocapitalize="off">
|
|
<div class="hint">How this observer identifies itself in published messages; informational only. Blank = node name.</div></div>
|
|
<div class="f"><label>IATA region code</label><input type="text" data-k="mqtt.iata" maxlength="3" autocapitalize="characters" style="text-transform:uppercase;max-width:120px"></div>
|
|
<div class="f"><label>Owner public key</label><input type="text" data-k="mqtt.owner" maxlength="64" autocapitalize="off" autocorrect="off">
|
|
<div class="hint">64-char hex public key of the owner's companion node (optional).</div></div>
|
|
<div class="f"><label>Owner email</label><input type="email" data-k="mqtt.email"></div>
|
|
</div>
|
|
<div class="card">
|
|
<h2>Publishing</h2>
|
|
<div class="note">This node only ever <b>uploads</b> to MQTT - nothing from a broker is injected into the mesh.</div>
|
|
<div class="sw"><span><b>Node status</b><i>Periodic health/stats to the status topic</i></span>
|
|
<span class="tgl"><input type="checkbox" data-k="mqtt.status"><u></u></span></div>
|
|
<div class="sw"><span><b>Publish packets</b><i>Master switch for all packet traffic below</i></span>
|
|
<span class="tgl"><input type="checkbox" data-k="mqtt.packets"><u></u></span></div>
|
|
<div class="sw"><span><b>Add raw frames</b><i>Also send the full hex frame to the raw topic</i></span>
|
|
<span class="tgl"><input type="checkbox" data-k="mqtt.raw"><u></u></span></div>
|
|
<div class="sw"><span><b>Received packets</b><i>Report packets heard over the air</i></span>
|
|
<span class="tgl"><input type="checkbox" data-k="mqtt.rx"><u></u></span></div>
|
|
<div class="sw"><span><b>Publish neighbors</b><i>Periodic neighbor table and scopes (PSRAM boards only)</i></span>
|
|
<span class="tgl"><input type="checkbox" data-k="mqtt.neighbors"><u></u></span></div>
|
|
<div class="row" style="margin-top:6px">
|
|
<div class="f"><label>Transmitted packets</label>
|
|
<select data-k="mqtt.tx"><option value="off">Off</option><option value="on">All TX</option><option value="advert">Own adverts only</option></select>
|
|
<div class="hint">Report packets this node sends.</div></div>
|
|
<div class="f"><label>Status interval (min)</label><input type="number" data-k="mqtt.interval" min="1" max="60"></div>
|
|
</div>
|
|
<div class="row" style="margin-top:6px">
|
|
<div class="f"><label>Neighbors interval (hours)</label><input type="number" data-k="mqtt.neighbors.interval" min="12" max="336">
|
|
<div class="hint">How often to publish the neighbor table (12-336, default 24).</div></div>
|
|
</div>
|
|
</div>
|
|
<div class="card">
|
|
<h2>Servers</h2>
|
|
<div id="app-slots"></div>
|
|
</div>
|
|
<div class="card">
|
|
<h2>Time & SNMP</h2>
|
|
<div class="f"><label>NTP server</label><input type="text" data-k="mqtt.ntp" autocapitalize="off" placeholder="pool.ntp.org">
|
|
<div class="hint">Enter <b>none</b> to clear.</div></div>
|
|
<div class="row">
|
|
<div class="f"><label>Timezone (POSIX)</label><input type="text" data-k="timezone" autocapitalize="off" placeholder="MST7MDT,M3.2.0,M11.1.0"></div>
|
|
<div class="f" style="max-width:110px"><label>UTC offset</label><input type="number" data-k="timezone.offset" min="-12" max="14"></div>
|
|
</div>
|
|
<div class="sw"><span><b>SNMP agent</b><i>Restart required</i></span>
|
|
<span class="tgl"><input type="checkbox" data-k="snmp"><u></u></span></div>
|
|
<div class="f"><label>SNMP community</label><input type="text" data-k="snmp.community" autocapitalize="off"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="t-wifi" class="hide">
|
|
<div class="card">
|
|
<h2>WiFi connection</h2>
|
|
<div class="note">Changing WiFi restarts the connection — this page will drop and the node reappears on the new network. Find its new IP from your router or the serial console.</div>
|
|
<div class="f"><label>Network name (SSID)</label>
|
|
<div class="row" style="gap:8px">
|
|
<input type="text" data-k="wifi.ssid" id="app-ssid" autocapitalize="off" autocorrect="off" style="flex:1">
|
|
<button class="btn sec" type="button" onmousedown="event.preventDefault()" onclick="openScan('app-ssid')">Scan</button>
|
|
</div></div>
|
|
<div class="f"><label>Password</label><input type="password" data-k="wifi.pwd" autocomplete="off"></div>
|
|
<div class="f" data-cap="1024"><label>Power save</label>
|
|
<select data-k="wifi.powersave"><option value="min">Minimum</option><option value="none">None (default; lowest latency)</option><option value="max">Maximum (battery)</option></select></div>
|
|
</div>
|
|
<div class="card">
|
|
<h2>Maintenance</h2>
|
|
<div class="row">
|
|
<button class="btn sec" style="flex:1" onclick="doReboot()">Reboot node</button>
|
|
<button class="btn sec" style="flex:1" id="logout-btn" onclick="doLogout()">Sign out</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="t-stats" class="hide">
|
|
<div class="card">
|
|
<h2>Device <span id="stats-age" style="float:right;font-weight:500;text-transform:none;letter-spacing:0"></span></h2>
|
|
<div class="tiles" id="tiles"></div>
|
|
</div>
|
|
<div class="card">
|
|
<h2>Free heap (KB)</h2><canvas id="spark-heap" height="56"></canvas>
|
|
<h2>Noise floor (dBm)</h2><canvas id="spark-noise" height="56"></canvas>
|
|
</div>
|
|
<div class="card" id="mqtt-stats-card">
|
|
<h2>MQTT servers</h2>
|
|
<div id="stat-slots" style="font-size:13.5px;color:var(--mut)">No data yet.</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="t-cli" class="hide">
|
|
<div class="card">
|
|
<h2>Node terminal</h2>
|
|
<div class="note">Available only while connected as a WiFi client; control it with <b>set wifi.cli on|off</b>. Runs one administrator CLI command at a time. Commands that reboot the node or stop WebConfig can close this page before their reply arrives.</div>
|
|
<pre class="cli-out" id="cli-output">WebConfig CLI ready.
|
|
</pre>
|
|
<div class="cli-mode" role="radiogroup" aria-label="Command input mode">
|
|
<label><input type="radio" name="cli-mode" value="single" checked> Single command</label>
|
|
<label><input type="radio" name="cli-mode" value="block"> Command block</label>
|
|
</div>
|
|
<form id="cli-form" class="cli-row">
|
|
<input type="text" id="cli-command" maxlength="159" autocomplete="off"
|
|
autocapitalize="off" autocorrect="off" spellcheck="false"
|
|
placeholder="Enter a command">
|
|
<textarea id="cli-block" class="hide" maxlength="16000" rows="8"
|
|
autocapitalize="off" autocorrect="off" spellcheck="false"
|
|
placeholder="Enter one command per line (up to 100 commands)"></textarea>
|
|
<button class="btn" id="cli-send" type="submit">Send</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
|
|
<!-- save bar -->
|
|
<div class="savebar" id="savebar">
|
|
<b id="save-n"></b><span class="sp"></span>
|
|
<button class="btn sec sm" onclick="revertAll()">Revert</button>
|
|
<button class="btn sm" id="save-btn" onclick="appSave()">Save changes</button>
|
|
</div>
|
|
|
|
<!-- inline scan panel: moved under whichever SSID field requested it -->
|
|
<div class="scanpanel hide" id="scan-panel">
|
|
<div class="sp-head">Nearby networks<span style="flex:1"></span>
|
|
<button class="btn sec sm" type="button" onmousedown="event.preventDefault()" onclick="startScan(true)">Rescan</button>
|
|
<button class="btn sec sm" type="button" onmousedown="event.preventDefault()" onclick="closeScan()">Close</button></div>
|
|
<div id="scan-list"><div style="text-align:center;padding:18px"><span class="spin"></span></div></div>
|
|
</div>
|
|
|
|
<div class="toast" id="toast"></div>
|
|
|
|
<!-- captive pop-out overlay -->
|
|
<div class="overlay" id="exit-ov">
|
|
<div style="max-width:420px;text-align:left">
|
|
<h1 style="margin-bottom:12px;text-align:center">Continue in your browser</h1>
|
|
<ol style="font-size:15px;line-height:1.9;padding-left:22px;color:var(--fg)">
|
|
<li>Tap <b>Done</b> (or <b>Cancel</b>) in the corner of this window.<br>
|
|
<span style="font-size:13px;color:var(--mut)">If asked, choose <b>Use Without Internet</b> / <b>Stay connected</b>.</span></li>
|
|
<li>Open Safari or Chrome on this device.</li>
|
|
<li>Go to <b style="font-size:17px" id="exit-url">http://192.168.4.1</b></li>
|
|
</ol>
|
|
<div class="note" style="margin-top:14px">Stay connected to the <b>MeshCore-Setup</b> WiFi network. Your progress here isn't carried over — the setup restarts in the browser, where it survives the screen locking.</div>
|
|
<p style="text-align:center;margin-top:10px"><a href="#" onclick="document.getElementById('exit-ov').classList.remove('show');return false" style="font-size:13px">Go back</a></p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- reboot overlay -->
|
|
<div class="overlay" id="reboot-ov">
|
|
<div>
|
|
<span class="spin" style="width:34px;height:34px"></span>
|
|
<h1 style="margin:16px 0 8px" id="reboot-title">Rebooting…</h1>
|
|
<p id="reboot-msg" style="color:var(--mut);max-width:420px"></p>
|
|
<p style="margin-top:14px;font-size:26px;font-weight:650" id="reboot-count"></p>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
"use strict";
|
|
var SENTINEL="********";
|
|
var st={mode:"",authed:false,hasMqtt:false,hasCli:false,passwordSupported:false,needsPassword:false,caps:0,cfg:null,orig:{},dirty:{},presets:[],nslots:0,statsTimer:0,hist:{heap:[],noise:[]},scanTarget:"",scanTimer:0};
|
|
|
|
function $(s){return document.querySelector(s)}
|
|
function $$(s){return Array.prototype.slice.call(document.querySelectorAll(s))}
|
|
function toast(m){var t=$("#toast");t.textContent=m;t.classList.add("show");clearTimeout(t._h);t._h=setTimeout(function(){t.classList.remove("show")},2600)}
|
|
function esc(s){return String(s).replace(/[&<>"]/g,function(c){return{"&":"&","<":"<",">":">",'"':"""}[c]})}
|
|
|
|
function api(path,opts){
|
|
opts=opts||{};
|
|
if(!opts.cache)opts.cache="no-store"; // iOS caches plain-HTTP API GETs across sessions
|
|
if(opts.timeout&&window.AbortController){
|
|
// The CNA sheet can leave a fetch hanging forever on a dead connection;
|
|
// abort so the caller's retry loop keeps moving.
|
|
var ctl=new AbortController();opts.signal=ctl.signal;
|
|
setTimeout(function(){ctl.abort()},opts.timeout);
|
|
}
|
|
return fetch(path,opts).then(function(r){
|
|
if(r.status===401){showLogin();throw new Error("auth")}
|
|
return r.json().then(function(j){
|
|
if(!r.ok&&r.status!==202){
|
|
var e=new Error(j.error||("HTTP "+r.status));
|
|
e.status=r.status;e.reqid=j.reqid;
|
|
throw e;
|
|
}
|
|
return j;
|
|
});
|
|
});
|
|
}
|
|
function post(path,body){return api(path,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)})}
|
|
function mkReqId(){
|
|
var a=new Uint8Array(8);
|
|
if(window.crypto&&crypto.getRandomValues)crypto.getRandomValues(a);
|
|
else for(var i=0;i<8;i++)a[i]=Math.floor(Math.random()*256);
|
|
return Array.prototype.map.call(a,function(b){
|
|
return(b<16?"0":"")+b.toString(16);
|
|
}).join("");
|
|
}
|
|
|
|
function applyFeatures(){
|
|
$$('[data-cap]').forEach(function(el){
|
|
el.classList.toggle("hide",!(st.caps&Number(el.dataset.cap)));
|
|
});
|
|
var floodCard=$("#radio-flood-card");
|
|
if(floodCard)floodCard.classList.toggle("hide",!(st.caps&(128|256)));
|
|
$$(".password-card").forEach(function(el){el.classList.toggle("hide",!st.passwordSupported)});
|
|
if(!st.hasCli){
|
|
var cliTab=$('#tabs button[data-t="cli"]');if(cliTab)cliTab.remove();
|
|
$("#t-cli").classList.add("hide");
|
|
}
|
|
if(st.hasMqtt)return;
|
|
|
|
// One HTML asset serves every ESP32 role. The firmware reports whether it
|
|
// actually has an MQTT bridge; on ordinary builds remove MQTT rather than
|
|
// showing controls that the backend cannot apply.
|
|
var mqttTab=$('#tabs button[data-t="mqtt"]');if(mqttTab)mqttTab.remove();
|
|
$("#t-mqtt").classList.add("hide");
|
|
$("#wz3").classList.add("hide");
|
|
$("#stp3").classList.add("hide");
|
|
$("#mqtt-stats-card").classList.add("hide");
|
|
$("#wz-wifi-copy").textContent="Connect this node to your WiFi network so its configuration page is available on your LAN.";
|
|
$("#wz-name-hint").textContent="Shown to other nodes on the mesh.";
|
|
$("#wz-radio-next").textContent="Review";
|
|
$("#wz-review-title").innerHTML="Step 3 · Review & save";
|
|
$("#wz-review-back").onclick=function(){wzGo(2)};
|
|
}
|
|
|
|
/* ---------- boot ---------- */
|
|
function boot(){
|
|
api("/api/status").then(function(s){
|
|
st.mode=s.mode;st.authed=s.auth;st.hasMqtt=!!s.mqtt;st.hasCli=!!s.cli;st.passwordSupported=!!s.password_supported;st.needsPassword=!!s.needs_password;st.caps=s.capabilities||0;st.nslots=s.active_slots||s.runtime_slots||0;
|
|
$("#h-name").textContent=s.name||"MeshCore";
|
|
$("#h-sub").textContent=s.role+" * "+s.fw+" * "+s.board;
|
|
var b=$("#h-badge");b.classList.remove("hide");
|
|
if(s.mode==="setup"){b.textContent="SETUP";b.classList.add("setup")}else{b.textContent="LAN"}
|
|
applyFeatures();
|
|
return st.hasMqtt?api("/api/presets").catch(function(){return{presets:[]}}):{presets:[]};
|
|
}).then(function(p){
|
|
st.presets=p.presets||[];
|
|
if(st.mode==="setup"){showWizard()}
|
|
else if(!st.authed){showLogin()}
|
|
else{enterApp()}
|
|
}).catch(function(e){
|
|
if(e.message!=="auth"){$("#h-sub").textContent="connection failed - retrying";setTimeout(boot,2000)}
|
|
});
|
|
}
|
|
|
|
function show(v){["#v-login","#v-wizard","#v-app"].forEach(function(id){$(id).classList.add("hide")});$(v).classList.remove("hide");updateSaveBar()}
|
|
function showLogin(){show("#v-login")}
|
|
|
|
/* ---------- login ---------- */
|
|
$("#login-form").addEventListener("submit",function(ev){
|
|
ev.preventDefault();
|
|
var btn=$("#login-btn");btn.disabled=true;$("#login-err").textContent="";
|
|
fetch("/api/login",{method:"POST",headers:{"Content-Type":"application/json"},
|
|
body:JSON.stringify({password:$("#login-pwd").value})})
|
|
.then(function(r){return r.json().then(function(j){return{s:r.status,j:j}})})
|
|
.then(function(x){
|
|
btn.disabled=false;
|
|
if(x.s===200){st.authed=true;$("#login-pwd").value="";enterApp()}
|
|
else $("#login-err").textContent=x.j.error||"login failed";
|
|
}).catch(function(){btn.disabled=false;$("#login-err").textContent="connection error"});
|
|
});
|
|
function doLogout(){post("/api/logout",{}).then(function(){st.authed=false;showLogin()}).catch(function(){})}
|
|
|
|
/* ---------- config load / bind ---------- */
|
|
function cfgVal(k){ // map a `set` key to its current value string, from st.cfg
|
|
var c=st.cfg,m,r=c.radio,q=c.mqtt||{slots:[]},w=c.wifi;
|
|
if((m=k.match(/^mqtt([1-9])\.(\w+)$/))){var s=(q.slots||[])[+m[1]-1]||{};var v=s[m[2]];return v==null?"":String(v)}
|
|
switch(k){
|
|
case"name":return r.name;case"lat":return String(r.lat);case"lon":return String(r.lon);
|
|
case"tx":return String(r.tx);case"af":return String(r.af);
|
|
case"rxdelay":return String(r.rxdelay);case"txdelay":return String(r.txdelay);
|
|
case"cad":return r.cad?"on":"off";case"radio.rxgain":return r.rxgain?"on":"off";
|
|
case"radio.fem.rxgain":return r.fem_rxgain?"on":"off";
|
|
case"repeat":return r.repeat?"on":"off";
|
|
case"flood.max":return String(r.flood_max);
|
|
case"flood.max.advert":return String(r.flood_max_advert);
|
|
case"flood.max.unscoped":return String(r.flood_max_unscoped);
|
|
case"loop.detect":return r.loop_detect;
|
|
case"advert.interval":return String(r.advert_interval);
|
|
case"flood.advert.interval":return String(r.flood_advert_interval);
|
|
case"password":return"";
|
|
case"wifi.ssid":return w.ssid;case"wifi.pwd":return w.pwd;case"wifi.powersave":return w.powersave;
|
|
case"mqtt.origin":return q.origin;case"mqtt.iata":return q.iata;
|
|
case"mqtt.status":return q.status?"on":"off";case"mqtt.packets":return q.packets?"on":"off";
|
|
case"mqtt.raw":return q.raw?"on":"off";case"mqtt.tx":return q.tx;case"mqtt.rx":return q.rx?"on":"off";
|
|
case"mqtt.interval":return String(q.interval);
|
|
case"mqtt.neighbors":return q.neighbors?"on":"off";case"mqtt.neighbors.interval":return String(q.neighbors_interval);
|
|
case"mqtt.ntp":return q.ntp;
|
|
case"mqtt.owner":return q.owner;case"mqtt.email":return q.email;
|
|
case"timezone":return q.timezone;case"timezone.offset":return String(q.timezone_offset);
|
|
case"snmp":return q.snmp?"on":"off";case"snmp.community":return q.snmp_community;
|
|
}
|
|
return"";
|
|
}
|
|
function elVal(el){
|
|
if(el.type==="checkbox")return el.checked?"on":"off";
|
|
return el.value;
|
|
}
|
|
function setEl(el,v){
|
|
if(el.type==="checkbox")el.checked=(v==="on");
|
|
else el.value=v;
|
|
}
|
|
function radioCombo(){
|
|
var p={};$$("[data-rg]").forEach(function(el){p[el.dataset.rg]=el.value});
|
|
return p.freq+","+p.bw+","+p.sf+","+p.cr;
|
|
}
|
|
function loadConfig(){
|
|
return api("/api/config").then(function(c){
|
|
st.cfg=c;st.orig={};st.dirty={};
|
|
if(st.hasMqtt){buildSlots("#app-slots");buildSlots("#wz-slots")}
|
|
$$("[data-k]").forEach(function(el){
|
|
var k=el.dataset.k,v=cfgVal(k);
|
|
st.orig[k]=v;setEl(el,v);el.classList.remove("dirty");
|
|
var lab=el.closest(".f");var ch=lab&&lab.querySelector(".chip");if(ch)ch.remove();
|
|
});
|
|
$$("[data-cfm]").forEach(function(el){el.value=""});
|
|
$$("[data-rg]").forEach(function(el){el.value=String(c.radio[el.dataset.rg]);el.classList.remove("dirty")});
|
|
st.orig.radio=radioCombo();afHint();
|
|
if(st.hasMqtt)for(var i=1;i<=st.nslots;i++){refreshSlotFields($("#app-slots"),i);refreshSlotFields($("#wz-slots"),i)}
|
|
updateSaveBar();
|
|
});
|
|
}
|
|
function markDirty(k,v,el){
|
|
if(v===st.orig[k]){delete st.dirty[k]}else{st.dirty[k]=v}
|
|
if(el)el.classList.toggle("dirty",k in st.dirty);
|
|
if(k==="af")afHint();
|
|
updateSaveBar();
|
|
}
|
|
// The CLI's `dutycycle` is not its own pref - it just writes airtime_factor as
|
|
// 100/(dc)-1. Showing the equivalent duty cycle beside `af` gives the number
|
|
// operators actually reason about without a second field fighting over one pref.
|
|
function afHint(){
|
|
var e=$("#af-dc");if(!e)return;
|
|
var af=parseFloat($('[data-k="af"]').value);
|
|
e.textContent=isFinite(af)&&af>=0?"~ "+(Math.round(1000/(af+1))/10)+"% duty cycle":"";
|
|
}
|
|
document.addEventListener("input",function(ev){
|
|
var el=ev.target;
|
|
if(el.dataset.k){
|
|
// keep duplicate fields (wizard vs app share keys) in sync
|
|
$$('[data-k="'+el.dataset.k+'"]').forEach(function(o){if(o!==el)setEl(o,elVal(el))});
|
|
markDirty(el.dataset.k,elVal(el),el);
|
|
}else if(el.dataset.cfm){
|
|
$$('[data-cfm="'+el.dataset.cfm+'"]').forEach(function(o){if(o!==el)o.value=el.value});
|
|
}else if(el.dataset.rg){
|
|
var v=radioCombo();
|
|
$$("[data-rg]").forEach(function(o){o.classList.toggle("dirty",v!==st.orig.radio)});
|
|
markDirty("radio",v);
|
|
var s=$("#wz-rp"); // keep the wizard preset dropdown honest
|
|
if(s&&s.options.length){
|
|
var m=-1;RADIO_PRESETS.forEach(function(p,i){if(comboEq(presetCombo(p),v))m=i});
|
|
s.value=m>=0?String(m):(("radio" in st.dirty)?"keep":s.value);
|
|
}
|
|
}
|
|
});
|
|
function updateSaveBar(){
|
|
var n=Object.keys(st.dirty).length;
|
|
var appVisible=!$("#v-app").classList.contains("hide");
|
|
$("#savebar").classList.toggle("show",n>0&&appVisible);
|
|
$("#save-n").textContent=n+(n===1?" change":" changes");
|
|
}
|
|
function revertAll(){loadConfig().then(function(){toast("Reverted")})}
|
|
|
|
/* ---------- slots ---------- */
|
|
function slotFieldHtml(i,f,type,label,hint){
|
|
return'<div class="f sf-'+f+'"><label>'+label+'</label><input type="'+type+'" data-k="mqtt'+i+'.'+f+'" autocapitalize="off" autocorrect="off" autocomplete="off">'+(hint?'<div class="hint">'+hint+'</div>':'')+'</div>';
|
|
}
|
|
function buildSlots(sel){
|
|
var host=$(sel);if(!host)return;
|
|
var html="";
|
|
for(var i=1;i<=st.nslots;i++){
|
|
var opts='<option value="none">- none -</option>';
|
|
st.presets.forEach(function(p){opts+='<option value="'+esc(p.name)+'">'+esc(p.name)+'</option>'});
|
|
opts+='<option value="custom">custom...</option>';
|
|
html+='<div class="slot" data-slot="'+i+'">'
|
|
+'<div class="st"><b>Slot '+i+'</b></div>'
|
|
+'<div class="f"><label>Preset</label><select data-k="mqtt'+i+'.preset">'+opts+'</select></div>'
|
|
+slotFieldHtml(i,"token","password","Access token","")
|
|
+slotFieldHtml(i,"username","text","Username","")
|
|
+slotFieldHtml(i,"password","password","Password","")
|
|
+slotFieldHtml(i,"server","text","Server host","")
|
|
+slotFieldHtml(i,"port","number","Port","")
|
|
+slotFieldHtml(i,"topic","text","Topic template","Placeholders: {iata} {device} {type} {token}")
|
|
+slotFieldHtml(i,"audience","text","JWT audience","Optional - blank for user/pass auth")
|
|
+'</div>';
|
|
}
|
|
host.innerHTML=html;
|
|
}
|
|
function refreshSlotFields(host,i){
|
|
if(!host)return;
|
|
var card=host.querySelector('[data-slot="'+i+'"]');if(!card)return;
|
|
var presetEl=card.querySelector('[data-k="mqtt'+i+'.preset"]');
|
|
var name=presetEl.value;
|
|
var def=null;st.presets.forEach(function(p){if(p.name===name)def=p});
|
|
var needs=name==="custom"?"custom":(def?def.needs:"none");
|
|
var showMap={token:needs==="token",username:needs==="userpass"||needs==="custom",
|
|
password:needs==="userpass"||needs==="password"||needs==="custom",server:needs==="custom",
|
|
port:needs==="custom",topic:needs==="custom",audience:needs==="custom"};
|
|
Object.keys(showMap).forEach(function(f){
|
|
var w=card.querySelector(".sf-"+f);if(w)w.classList.toggle("hide",!showMap[f]);
|
|
});
|
|
}
|
|
document.addEventListener("change",function(ev){
|
|
var el=ev.target,m;
|
|
if(el.dataset.k&&(m=el.dataset.k.match(/^mqtt([1-9])\.preset$/))){
|
|
["#app-slots","#wz-slots"].forEach(function(sel){refreshSlotFields($(sel),+m[1])});
|
|
}
|
|
});
|
|
|
|
/* ---------- save / results ---------- */
|
|
function chipFor(el,ok,msg){
|
|
var lab=el.closest(".f");if(!lab)return;
|
|
var old=lab.querySelector(".chip");if(old)old.remove();
|
|
var c=document.createElement("span");
|
|
c.className="chip "+(ok?"ok":"err");c.textContent=msg;
|
|
lab.querySelector("label").appendChild(c);
|
|
}
|
|
function saveChanges(extra,cb){
|
|
var setmap={};
|
|
// Clear old presets first, then assign new presets, then apply their fields.
|
|
// This lets an operator move a unique preset to an empty slot in one save.
|
|
Object.keys(st.dirty).sort(function(a,b){
|
|
var pa=/\.preset$/.test(a)?(st.dirty[a]==="none"?0:1):2;
|
|
var pb=/\.preset$/.test(b)?(st.dirty[b]==="none"?0:1):2;
|
|
return pa-pb||a.localeCompare(b);
|
|
}).forEach(function(k){setmap[k]=st.dirty[k]});
|
|
var body={set:setmap};
|
|
// Applying new station credentials necessarily drops this page. Reboot
|
|
// after a successful batch so the bridge joins with the complete SSID and
|
|
// password together instead of reconnecting between the two writes.
|
|
if((extra&&extra.reboot)||("wifi.ssid" in setmap)||("wifi.pwd" in setmap))body.reboot=true;
|
|
if(Object.keys(setmap).length===0&&!body.reboot){toast("Nothing to save");return}
|
|
var reqid=mkReqId();body.reqid=reqid;
|
|
$("#save-btn").disabled=true;
|
|
post("/api/config",body).then(function(){pollResult(cb,0,0,true,reqid,setmap)})
|
|
.catch(function(e){
|
|
if(e.message==="auth"){$("#save-btn").disabled=false;if(cb)cb(null,false);return}
|
|
if(e.status===400||e.status===413){
|
|
$("#save-btn").disabled=false;
|
|
if(cb)cb(null,false);else toast(e.message||"Save rejected");
|
|
return;
|
|
}
|
|
if(e.status===409){
|
|
if(e.reqid===reqid){pollResult(cb,0,0,true,reqid,setmap);return}
|
|
$("#save-btn").disabled=false;
|
|
if(cb)cb(null,false);else toast("Another change is being applied - retry shortly");
|
|
return;
|
|
}
|
|
// A network failure can lose the response after the POST landed. Poll the
|
|
// result by request ID so another or stale save cannot be mistaken for it.
|
|
pollResult(cb,0,0,false,reqid,setmap);
|
|
});
|
|
}
|
|
function appSave(){
|
|
var wifiChanged=("wifi.ssid" in st.dirty)||("wifi.pwd" in st.dirty);
|
|
var pwdErr=adminPwdError(wifiChanged);
|
|
if(pwdErr){
|
|
$('#tabs button[data-t="radio"]').click();
|
|
toast(pwdErr);
|
|
return;
|
|
}
|
|
saveChanges();
|
|
}
|
|
// acked: the node returned 202 for the POST, so the batch will be processed.
|
|
// A requested reboot happens only if every command succeeds. This still lets
|
|
// callers distinguish "confirmation lost" from "save never sent".
|
|
function pollResult(cb,errs,idles,acked,reqid,submitted){
|
|
errs=errs||0;idles=idles||0;
|
|
function fail(){$("#save-btn").disabled=false;
|
|
if(cb)cb(null,acked);else toast(acked?"Saved, but confirmation was lost - reload to verify":"Save failed - check connection and retry")}
|
|
api("/api/config/result?reqid="+encodeURIComponent(reqid),{timeout:4000}).then(function(r){
|
|
if(r.reqid!==reqid){fail();return}
|
|
if(r.state==="pending"){setTimeout(function(){pollResult(cb,0,idles,true,reqid,submitted)},400);return}
|
|
if(r.state!=="done"){
|
|
// idle: the POST likely never arrived - but give it a moment in case
|
|
// it's still in flight after a connection blip
|
|
if(idles<4){setTimeout(function(){pollResult(cb,errs,idles+1,acked,reqid,submitted)},700);return}
|
|
fail();return;
|
|
}
|
|
$("#save-btn").disabled=false;
|
|
var fails=0,radioReboot=false;
|
|
(r.results||[]).forEach(function(it){
|
|
var ok=/^OK/i.test(it.reply);
|
|
if(!ok)fails++;
|
|
if(!cb&&ok&&submitted&&(it.key in submitted)&&st.dirty[it.key]===submitted[it.key])delete st.dirty[it.key];
|
|
if(it.key==="radio"){
|
|
if(/reboot/i.test(it.reply))radioReboot=true;
|
|
}
|
|
var el=document.querySelector('#v-app [data-k="'+it.key+'"]');
|
|
if(el)chipFor(el,ok,ok?"OK":it.reply.replace(/^Err(or)?[:,]?\s*/i,""));
|
|
});
|
|
$("#radio-warn").classList.toggle("hide",!radioReboot);
|
|
if(cb){cb(r);return}
|
|
if(fails)toast(fails+" setting(s) rejected - see fields");
|
|
else if(r.reboot){
|
|
st.dirty={};
|
|
showReboot("The node is restarting with the saved WiFi settings. Reconnect to that network and open the node at its router-assigned address.",false,"Settings saved [OK] - rebooting");
|
|
return;
|
|
}else toast("Saved [OK]");
|
|
loadConfigSoft();
|
|
}).catch(function(e){
|
|
if(e.message==="auth"){fail();return}
|
|
if(e.status===400||e.status===404){fail();return}
|
|
// Transient network error: the save itself can knock the phone off the
|
|
// AP for a moment (flash writes stall WiFi). Keep polling - the device
|
|
// holds the result until we manage to read it.
|
|
if(errs<20){setTimeout(function(){pollResult(cb,errs+1,idles,acked,reqid,submitted)},700);return}
|
|
fail();
|
|
});
|
|
}
|
|
function loadConfigSoft(){ // re-sync origin values without clobbering result chips
|
|
api("/api/config").then(function(c){
|
|
st.cfg=c;
|
|
$$("[data-k]").forEach(function(el){
|
|
var k=el.dataset.k,stored=cfgVal(k);
|
|
st.orig[k]=stored;
|
|
if((k in st.dirty)&&st.dirty[k]!==stored){setEl(el,st.dirty[k]);el.classList.add("dirty")}
|
|
else{delete st.dirty[k];setEl(el,stored);el.classList.remove("dirty")}
|
|
});
|
|
if(!("password" in st.dirty))$$("[data-cfm]").forEach(function(el){el.value=""});
|
|
var storedRadio=[c.radio.freq,c.radio.bw,c.radio.sf,c.radio.cr].join(",");
|
|
st.orig.radio=storedRadio;
|
|
var keepRadio=("radio" in st.dirty)&&!comboEq(st.dirty.radio,storedRadio);
|
|
if(!keepRadio)delete st.dirty.radio;
|
|
var radioParts=(keepRadio?st.dirty.radio:storedRadio).split(",");
|
|
$$("[data-rg]").forEach(function(el){
|
|
var idx={freq:0,bw:1,sf:2,cr:3}[el.dataset.rg];
|
|
el.value=radioParts[idx];el.classList.toggle("dirty",keepRadio);
|
|
});
|
|
if(st.hasMqtt)for(var i=1;i<=st.nslots;i++){refreshSlotFields($("#app-slots"),i);refreshSlotFields($("#wz-slots"),i)}
|
|
afHint();
|
|
updateSaveBar();
|
|
}).catch(function(){});
|
|
}
|
|
|
|
/* ---------- tabs / app ---------- */
|
|
function enterApp(){
|
|
show("#v-app");
|
|
loadConfig().catch(function(e){if(e.message!=="auth")toast("Failed to load config")});
|
|
}
|
|
$("#tabs").addEventListener("click",function(ev){
|
|
var b=ev.target.closest("button");if(!b)return;
|
|
$$("#tabs button").forEach(function(x){x.classList.toggle("on",x===b)});
|
|
["radio","mqtt","wifi","stats","cli"].forEach(function(t){$("#t-"+t).classList.toggle("hide",t!==b.dataset.t)});
|
|
if(b.dataset.t==="stats")startStats();else stopStats();
|
|
if(b.dataset.t==="cli")setTimeout(focusCliInput,0);
|
|
});
|
|
|
|
/* ---------- CLI terminal ---------- */
|
|
var cliHistory=[],cliHistoryAt=0,cliRunning=false,cliQueue=[],cliQueueAt=0,cliBlockRun=false;
|
|
function appendCli(text){
|
|
var out=$("#cli-output");
|
|
out.textContent+=text;
|
|
if(out.textContent.length>12000)out.textContent=out.textContent.slice(-10000);
|
|
out.scrollTop=out.scrollHeight;
|
|
}
|
|
function isCliBlockMode(){
|
|
var selected=$('input[name="cli-mode"]:checked');
|
|
return selected&&selected.value==="block";
|
|
}
|
|
function focusCliInput(){
|
|
(isCliBlockMode()?$("#cli-block"):$("#cli-command")).focus();
|
|
}
|
|
function updateCliMode(){
|
|
var block=isCliBlockMode();
|
|
$("#cli-command").classList.toggle("hide",block);
|
|
$("#cli-block").classList.toggle("hide",!block);
|
|
$("#cli-send").textContent=block?"Run block":"Send";
|
|
focusCliInput();
|
|
}
|
|
function setCliRunning(running){
|
|
cliRunning=running;
|
|
$("#cli-command").disabled=running;
|
|
$("#cli-block").disabled=running;
|
|
$("#cli-send").disabled=running;
|
|
$$('input[name="cli-mode"]').forEach(function(input){input.disabled=running});
|
|
}
|
|
function cliByteLength(text){
|
|
if(typeof TextEncoder!=="undefined")return new TextEncoder().encode(text).length;
|
|
var bytes=encodeURIComponent(text).match(/%[0-9A-F]{2}|./g);
|
|
return bytes?bytes.length:0;
|
|
}
|
|
function parseCliBlock(text){
|
|
var lines=text.split(/\r\n|\r|\n/),commands=[];
|
|
for(var i=0;i<lines.length;i++){
|
|
if(!lines[i].trim())continue;
|
|
if(cliByteLength(lines[i])>159)throw new Error("line "+(i+1)+" exceeds 159 bytes");
|
|
commands.push(lines[i]);
|
|
if(commands.length>100)throw new Error("a block can contain at most 100 commands");
|
|
}
|
|
return commands;
|
|
}
|
|
function finishCliRun(completed){
|
|
var wasBlock=cliBlockRun,total=cliQueue.length;
|
|
cliQueue=[];
|
|
cliQueueAt=0;
|
|
cliBlockRun=false;
|
|
setCliRunning(false);
|
|
if(completed&&wasBlock){
|
|
appendCli("[block complete: "+total+" command"+(total===1?"":"s")+"]\n");
|
|
$("#cli-block").value="";
|
|
}
|
|
focusCliInput();
|
|
if(completed)loadConfigSoft();
|
|
}
|
|
function stopCliRun(message){
|
|
var remaining=cliQueue.length-cliQueueAt;
|
|
appendCli(message+"\n");
|
|
if(cliBlockRun&&remaining>0){
|
|
appendCli("[block stopped: "+remaining+" command"+(remaining===1?"":"s")+" not sent]\n");
|
|
}
|
|
finishCliRun(false);
|
|
}
|
|
function pollCli(reqid,tries,command){
|
|
api("/api/cli/result?reqid="+encodeURIComponent(reqid),{timeout:4000}).then(function(r){
|
|
if(r.reqid!==reqid)throw new Error("request mismatch");
|
|
if(r.state==="pending"){
|
|
setTimeout(function(){pollCli(reqid,tries,command)},150);
|
|
return;
|
|
}
|
|
if(r.state!=="done")throw new Error("result unavailable");
|
|
appendCli((r.reply||"(no reply)")+"\n");
|
|
if(/^set\s+wifi\.cli\s+off\s*$/i.test(command)){
|
|
appendCli("[terminal disabled]\n");
|
|
var remaining=cliQueue.length-cliQueueAt;
|
|
if(cliBlockRun&&remaining>0){
|
|
appendCli("[block stopped: "+remaining+" command"+(remaining===1?"":"s")+" not sent]\n");
|
|
}
|
|
finishCliRun(false);
|
|
setTimeout(function(){location.reload()},700);
|
|
}else{
|
|
runNextCliCommand();
|
|
}
|
|
}).catch(function(e){
|
|
if(e.message==="auth"){stopCliRun("[authentication required]");return}
|
|
if(tries<12){
|
|
setTimeout(function(){pollCli(reqid,tries+1,command)},400);
|
|
return;
|
|
}
|
|
stopCliRun("[reply unavailable - the command may have stopped WiFi, WebConfig, or rebooted the node]");
|
|
});
|
|
}
|
|
function runNextCliCommand(){
|
|
if(cliQueueAt>=cliQueue.length){
|
|
finishCliRun(true);
|
|
return;
|
|
}
|
|
var command=cliQueue[cliQueueAt++];
|
|
appendCli("> "+command+"\n");
|
|
var reqid=mkReqId();
|
|
post("/api/cli",{reqid:reqid,command:command}).then(function(){
|
|
pollCli(reqid,0,command);
|
|
}).catch(function(e){
|
|
stopCliRun("[error: "+e.message+"]");
|
|
});
|
|
}
|
|
$("#cli-form").addEventListener("submit",function(ev){
|
|
ev.preventDefault();
|
|
if(cliRunning)return;
|
|
var block=isCliBlockMode(),commands;
|
|
try{
|
|
commands=block?parseCliBlock($("#cli-block").value):[$("#cli-command").value];
|
|
if(!block&&cliByteLength(commands[0])>159)throw new Error("command exceeds 159 bytes");
|
|
}catch(e){
|
|
appendCli("[error: "+e.message+"]\n");
|
|
return;
|
|
}
|
|
commands=commands.filter(function(command){return command.trim()});
|
|
if(!commands.length)return;
|
|
commands.forEach(function(command){
|
|
cliHistory.push(command);
|
|
if(cliHistory.length>30)cliHistory.shift();
|
|
});
|
|
cliHistoryAt=cliHistory.length;
|
|
if(!block)$("#cli-command").value="";
|
|
cliQueue=commands;
|
|
cliQueueAt=0;
|
|
cliBlockRun=block;
|
|
if(block)appendCli("[running block: "+commands.length+" command"+(commands.length===1?"":"s")+"]\n");
|
|
setCliRunning(true);
|
|
runNextCliCommand();
|
|
});
|
|
$$('input[name="cli-mode"]').forEach(function(input){
|
|
input.addEventListener("change",updateCliMode);
|
|
});
|
|
$("#cli-command").addEventListener("keydown",function(ev){
|
|
if(ev.key!=="ArrowUp"&&ev.key!=="ArrowDown")return;
|
|
ev.preventDefault();
|
|
if(ev.key==="ArrowUp"&&cliHistoryAt>0)cliHistoryAt--;
|
|
if(ev.key==="ArrowDown"&&cliHistoryAt<cliHistory.length)cliHistoryAt++;
|
|
this.value=cliHistoryAt<cliHistory.length?cliHistory[cliHistoryAt]:"";
|
|
setTimeout(function(){
|
|
var input=$("#cli-command");
|
|
input.setSelectionRange(input.value.length,input.value.length);
|
|
},0);
|
|
});
|
|
$("#cli-block").addEventListener("keydown",function(ev){
|
|
if((ev.ctrlKey||ev.metaKey)&&ev.key==="Enter"){
|
|
ev.preventDefault();
|
|
$("#cli-form").requestSubmit();
|
|
}
|
|
});
|
|
|
|
/* ---------- stats ---------- */
|
|
function fmtUp(s){var d=Math.floor(s/86400),h=Math.floor(s%86400/3600),m=Math.floor(s%3600/60);
|
|
return d>0?d+"d "+h+"h":(h>0?h+"h "+m+"m":m+"m")}
|
|
function tile(i,b,s){return'<div class="tile"><i>'+i+'</i><b>'+b+'</b>'+(s?' <s>'+s+'</s>':'')+'</div>'}
|
|
function startStats(){stopStats();pollStats();st.statsTimer=setInterval(pollStats,3000)}
|
|
function stopStats(){if(st.statsTimer){clearInterval(st.statsTimer);st.statsTimer=0}}
|
|
function pollStats(){
|
|
api("/api/stats").then(function(s){
|
|
if(s.state==="pending"){$("#stats-age").textContent="collecting...";return}
|
|
$("#stats-age").textContent="";
|
|
var kb=Math.round(s.heap_free/1024);
|
|
$("#tiles").innerHTML=
|
|
tile("Uptime",fmtUp(s.uptime_s))+
|
|
tile("Battery",(s.batt_mv/1000).toFixed(2),"V")+
|
|
tile("Free heap",kb,"KB * min "+Math.round(s.heap_min/1024))+
|
|
tile("Max alloc",Math.round(s.heap_max_alloc/1024),"KB")+
|
|
tile("Noise floor",s.noise,"dBm")+
|
|
tile("Last RSSI",s.rssi,"dBm * SNR "+s.snr)+
|
|
tile("Airtime TX",fmtUp(s.airtime_s))+
|
|
tile("Airtime RX",fmtUp(s.rx_airtime_s))+
|
|
tile("Packets RX",s.recv,"err "+s.rx_err)+
|
|
tile("Packets TX",s.sent)+
|
|
tile("Flood RX/TX",s.recv_flood+" / "+s.sent_flood)+
|
|
tile("Direct RX/TX",s.recv_direct+" / "+s.sent_direct)+
|
|
tile("TX queue",s.tx_queue)+
|
|
(st.hasMqtt?tile("MQTT queue",s.mqtt_queue):"")+
|
|
tile("WiFi RSSI",s.wifi_rssi,"dBm")+
|
|
tile("IP",s.ip||"-");
|
|
push(st.hist.heap,kb);push(st.hist.noise,s.noise);
|
|
spark($("#spark-heap"),st.hist.heap);spark($("#spark-noise"),st.hist.noise);
|
|
if(st.hasMqtt){
|
|
var sl=s.slots||[];
|
|
$("#stat-slots").innerHTML=sl.length?sl.map(function(x){
|
|
var counts=(x.ok==null||x.err==null)?"":" "+x.ok+" ok / "+x.err+" err";
|
|
return'<div class="kv"><i>Slot '+x.n+" * "+esc(x.name)+'</i><b><span class="pill '+esc(x.state)+'">'+esc(x.state)+'</span>'+counts+'</b></div>';
|
|
}).join(""):"No servers configured.";
|
|
}
|
|
}).catch(function(){});
|
|
}
|
|
function push(a,v){a.push(v);if(a.length>60)a.shift()}
|
|
function spark(cv,data){
|
|
var dpr=window.devicePixelRatio||1,w=cv.clientWidth,h=56;
|
|
cv.width=w*dpr;cv.height=h*dpr;
|
|
var g=cv.getContext("2d");g.scale(dpr,dpr);g.clearRect(0,0,w,h);
|
|
if(data.length<2)return;
|
|
var mn=Math.min.apply(null,data),mx=Math.max.apply(null,data);
|
|
if(mx===mn){mx+=1;mn-=1}
|
|
g.strokeStyle=getComputedStyle(document.documentElement).getPropertyValue("--acc").trim()||"#2b7de9";
|
|
g.lineWidth=2;g.lineJoin="round";g.beginPath();
|
|
data.forEach(function(v,i){
|
|
var x=i/(data.length-1)*(w-4)+2,y=h-4-(v-mn)/(mx-mn)*(h-10);
|
|
i?g.lineTo(x,y):g.moveTo(x,y);
|
|
});
|
|
g.stroke();
|
|
g.fillStyle=g.strokeStyle;g.font="10px system-ui";g.textAlign="right";
|
|
g.fillText(data[data.length-1],w-2,10);
|
|
}
|
|
|
|
/* ---------- wifi scan ---------- */
|
|
function dropKeyboard(target){
|
|
var el=target&&document.getElementById(target);if(el)el.blur();
|
|
var ae=document.activeElement;
|
|
if(ae&&ae!==document.body&&ae.blur)ae.blur();
|
|
}
|
|
function openScan(target){
|
|
st.scanTarget=target;
|
|
// Move the panel into the flow right below the target SSID field: iOS
|
|
// keeps the focused field visible above the keyboard, so the panel under
|
|
// it stays visible even when the CNA sheet refuses to drop the keyboard.
|
|
var el=document.getElementById(target),panel=$("#scan-panel");
|
|
var f=el&&el.closest(".f");
|
|
if(f&&f.parentNode)f.parentNode.insertBefore(panel,f.nextSibling);
|
|
panel.classList.remove("hide");
|
|
// best-effort keyboard drop (works in real Safari; the CNA sheet ignores it)
|
|
dropKeyboard(target);
|
|
setTimeout(function(){dropKeyboard(target)},150);
|
|
startScan(false)}
|
|
function closeScan(){$("#scan-panel").classList.add("hide");clearTimeout(st.scanTimer)}
|
|
function startScan(force){
|
|
$("#scan-list").innerHTML='<div style="text-align:center;padding:18px"><span class="spin"></span></div>';
|
|
pollScan(force?"/api/scan?rescan=1":"/api/scan");
|
|
}
|
|
function bars(rssi){var n=rssi>-55?4:rssi>-67?3:rssi>-78?2:1;var o="";
|
|
for(var i=1;i<=4;i++)o+='<span style="display:inline-block;width:3px;margin-right:1px;border-radius:1px;height:'+(3+i*3)+'px;background:'+(i<=n?"var(--acc)":"var(--line)")+'"></span>';
|
|
return'<span style="display:inline-flex;align-items:flex-end;height:15px">'+o+"</span>"}
|
|
function pollScan(url){
|
|
api(url).then(function(r){
|
|
if(r.state==="scanning"){st.scanTimer=setTimeout(function(){pollScan("/api/scan")},1500);return}
|
|
var nets=(r.networks||[]).filter(function(n){return n.ssid});
|
|
nets.sort(function(a,b){return b.rssi-a.rssi});
|
|
var seen={};nets=nets.filter(function(n){if(seen[n.ssid])return false;seen[n.ssid]=1;return true});
|
|
$("#scan-list").innerHTML=nets.length?nets.map(function(n){
|
|
return'<button type="button" class="net" data-ssid="'+esc(n.ssid)+'">'+bars(n.rssi)
|
|
+'<span class="ss">'+esc(n.ssid)+"</span>"+(n.enc?"🔒":"")+"<small>"+n.rssi+"</small></button>";
|
|
}).join(""):'<p style="color:var(--mut);padding:12px">No networks found.</p>';
|
|
}).catch(function(){$("#scan-list").innerHTML='<p style="color:var(--mut);padding:12px">Scan failed.</p>'});
|
|
}
|
|
$("#scan-list").addEventListener("click",function(ev){
|
|
var b=ev.target.closest(".net");if(!b)return;
|
|
var el=document.getElementById(st.scanTarget);
|
|
el.value=b.dataset.ssid;
|
|
el.dispatchEvent(new Event("input",{bubbles:true}));
|
|
closeScan();
|
|
});
|
|
|
|
/* ---------- wizard ---------- */
|
|
// Regional radio presets, baked in because the setup AP has no internet.
|
|
// Source: https://api.meshcore.nz/api/v1/config (config.meshcore.io), Jul 2026.
|
|
// f=MHz, bw=kHz - submitted as `set radio f,bw,sf,cr`.
|
|
var RADIO_PRESETS=[
|
|
{t:"USA/Canada (Recommended)",f:"910.525",bw:"62.5",sf:"7",cr:"5"},
|
|
{t:"Australia",f:"915.800",bw:"250",sf:"10",cr:"5"},
|
|
{t:"Australia (Narrow)",f:"916.575",bw:"62.5",sf:"7",cr:"8"},
|
|
{t:"Australia (Mid)",f:"915.075",bw:"125",sf:"9",cr:"5"},
|
|
{t:"Australia: SA, WA",f:"923.125",bw:"62.5",sf:"8",cr:"8"},
|
|
{t:"Australia: QLD",f:"923.125",bw:"62.5",sf:"8",cr:"5"},
|
|
{t:"Brazil",f:"923.125",bw:"62.5",sf:"8",cr:"8"},
|
|
{t:"EU/UK (Narrow)",f:"869.618",bw:"62.5",sf:"8",cr:"8"},
|
|
{t:"EU/UK (Deprecated)",f:"869.525",bw:"250",sf:"11",cr:"5"},
|
|
{t:"Czech Republic (Narrow)",f:"869.432",bw:"62.5",sf:"7",cr:"5"},
|
|
{t:"EU 433MHz (Long Range)",f:"433.650",bw:"250",sf:"11",cr:"5"},
|
|
{t:"EU 433MHz (Narrow)",f:"433.650",bw:"62.5",sf:"8",cr:"8"},
|
|
{t:"Netherlands",f:"869.618",bw:"62.5",sf:"7",cr:"5"},
|
|
{t:"New Zealand",f:"917.375",bw:"250",sf:"11",cr:"5"},
|
|
{t:"New Zealand (Narrow)",f:"917.375",bw:"62.5",sf:"7",cr:"5"},
|
|
{t:"Portugal 433",f:"433.375",bw:"62.5",sf:"9",cr:"6"},
|
|
{t:"Portugal 868",f:"869.618",bw:"62.5",sf:"7",cr:"6"},
|
|
{t:"Switzerland",f:"869.618",bw:"62.5",sf:"8",cr:"8"},
|
|
{t:"Vietnam (Narrow)",f:"920.250",bw:"62.5",sf:"8",cr:"5"},
|
|
{t:"Vietnam (Deprecated)",f:"920.250",bw:"250",sf:"11",cr:"5"}
|
|
];
|
|
function presetCombo(p){return p.f+","+p.bw+","+p.sf+","+p.cr}
|
|
function comboEq(a,b){ // numeric compare: "62.5"=="62.50", "250"=="250.0"
|
|
if(!a||!b)return false;
|
|
var x=a.split(","),y=b.split(",");
|
|
for(var i=0;i<4;i++)if(Number(x[i])!==Number(y[i]))return false;
|
|
return true;
|
|
}
|
|
function presetDesc(p){return p.f+" MHz · BW"+p.bw+" · SF"+p.sf+" · CR"+p.cr}
|
|
function wzRadioFill(){
|
|
var sel=$("#wz-rp"),cur=st.orig.radio||"";
|
|
var html='<option value="">Select your region…</option>';
|
|
var match=-1;
|
|
RADIO_PRESETS.forEach(function(p,i){
|
|
if(comboEq(presetCombo(p),cur))match=i;
|
|
html+='<option value="'+i+'">'+esc(p.t)+' — '+presetDesc(p)+'</option>';
|
|
});
|
|
html+='<option value="keep">Keep current settings</option>';
|
|
sel.innerHTML=html;
|
|
// preselect when the node already matches a known preset; otherwise force a choice
|
|
if(match>=0)sel.value=String(match);
|
|
var c=(st.cfg&&st.cfg.radio)||{};
|
|
$("#wz-rp-cur").innerHTML="Currently: <b>"+esc(c.freq)+" MHz · BW"+esc(c.bw)+" · SF"+esc(c.sf)+" · CR"+esc(c.cr)+"</b>"
|
|
+(match<0?" (doesn't match a regional preset)":"");
|
|
sel.classList.remove("dirty");
|
|
}
|
|
function wzRadioSel(sel){
|
|
var v=sel.value,combo;
|
|
if(v===""||v==="keep"){combo=st.orig.radio}
|
|
else{var p=RADIO_PRESETS[+v];combo=presetCombo(p)}
|
|
// drive the shared `radio` key through the same path as the advanced editor
|
|
$$("[data-rg]").forEach(function(el){
|
|
var parts=combo.split(","),idx={freq:0,bw:1,sf:2,cr:3}[el.dataset.rg];
|
|
el.value=parts[idx];
|
|
el.classList.toggle("dirty",combo!==st.orig.radio);
|
|
});
|
|
sel.classList.toggle("dirty",combo!==st.orig.radio);
|
|
markDirty("radio",combo);
|
|
}
|
|
function showWizard(){
|
|
show("#v-wizard");
|
|
loadConfig().then(wzRadioFill).catch(function(){});
|
|
}
|
|
function wzGo(n){
|
|
if(!st.hasMqtt&&n===3)n=4;
|
|
[1,2,3,4].forEach(function(i){
|
|
$("#wz"+i).classList.toggle("hide",i!==n);
|
|
$("#stp"+i).classList.toggle("on",i<=n);
|
|
});
|
|
if(n===3){
|
|
// Prefill observer name with the node name - only while still blank, so
|
|
// an existing config or a manual edit is never clobbered.
|
|
var o=$('#wz3 [data-k="mqtt.origin"]');
|
|
var nm=st.dirty.name||st.orig.name||"";
|
|
if(o&&!o.value&&nm){o.value=nm;o.dispatchEvent(new Event("input",{bubbles:true}))}
|
|
}
|
|
if(n===4)buildReview();
|
|
window.scrollTo(0,0);
|
|
}
|
|
function popOut(){
|
|
// Tell the node to start answering OS captive probes with "success" so the
|
|
// sign-in sheet can be dismissed without dropping the WiFi, then walk the
|
|
// user through reopening the portal in their real browser.
|
|
api("/api/portal/exit",{method:"POST"}).then(function(r){
|
|
if(r&&r.url)$("#exit-url").textContent=r.url;
|
|
$("#exit-ov").classList.add("show");
|
|
}).catch(function(){toast("Couldn't reach the node - try again")});
|
|
}
|
|
function eff(k){return (k in st.dirty)?st.dirty[k]:(st.orig[k]||"")}
|
|
function adminPwdError(finishing){
|
|
if(!st.passwordSupported)return"";
|
|
var p=eff("password"),c=$('[data-cfm="password"]').value;
|
|
if(finishing&&st.needsPassword&&!p)return"Set an admin password";
|
|
if((p||c)&&p!==c)return"Admin passwords do not match";
|
|
return"";
|
|
}
|
|
function buildReview(){
|
|
var rows=[],d=st.dirty;
|
|
function row(l,v){rows.push('<div class="kv"><i>'+l+"</i><b>"+esc(v)+"</b></div>")}
|
|
row("WiFi network",d["wifi.ssid"]||st.orig["wifi.ssid"]||"(not set)");
|
|
row("WiFi password",d["wifi.pwd"]?"******":(st.orig["wifi.pwd"]?"(unchanged)":"(open network)"));
|
|
if(d.name||st.orig.name)row("Node name",d.name||st.orig.name);
|
|
var rc=d.radio||st.orig.radio||"",rp=null;
|
|
RADIO_PRESETS.forEach(function(p){if(comboEq(presetCombo(p),rc))rp=p});
|
|
row("Radio",(rp?rp.t+" - ":"")+rc.split(",").join(" / ")+(d.radio?"":" (unchanged)"));
|
|
if(d.tx)row("TX power",d.tx+" dBm");
|
|
if(st.passwordSupported&&(st.needsPassword||eff("password")))row("Admin password",eff("password")?"set":"(not set)");
|
|
if(st.hasMqtt){
|
|
row("Observer name",d["mqtt.origin"]||st.orig["mqtt.origin"]||"(not set)");
|
|
row("IATA",(d["mqtt.iata"]||st.orig["mqtt.iata"]||"(not set)").toUpperCase());
|
|
var own=d["mqtt.owner"]||st.orig["mqtt.owner"];
|
|
if(own)row("Owner key",own.length>12?own.slice(0,12)+"...":own);
|
|
var em=d["mqtt.email"]||st.orig["mqtt.email"];
|
|
if(em)row("Owner email",em);
|
|
for(var i=1;i<=st.nslots;i++){
|
|
var p=d["mqtt"+i+".preset"]||st.orig["mqtt"+i+".preset"];
|
|
if(p&&p!=="none")row("Slot "+i,p);
|
|
}
|
|
}
|
|
var extra=Object.keys(d).filter(function(k){return!/^(wifi\.|password$|name$|radio$|tx$|mqtt\.origin|mqtt\.iata|mqtt\.owner|mqtt\.email|mqtt[1-9]\.)/.test(k)}).length;
|
|
if(extra)row("Other changes",extra+" setting(s)");
|
|
$("#wz-review").innerHTML=rows.join("");
|
|
}
|
|
function wizardSave(){
|
|
if(!st.dirty["wifi.ssid"]&&!st.orig["wifi.ssid"]){$("#wz-err").textContent="Please set a WiFi network (step 1).";return}
|
|
if($("#wz-rp").value===""){$("#wz-err").textContent="Please choose a radio preset (step 2) - nodes ship with non-US defaults.";return}
|
|
var pwdErr=adminPwdError(true);
|
|
if(pwdErr){$("#wz-err").textContent=pwdErr+" (step 2).";return}
|
|
if(st.hasMqtt&&!/^[A-Za-z]{3}$/.test(st.dirty["mqtt.iata"]||st.orig["mqtt.iata"]||"")){
|
|
$("#wz-err").textContent="Please enter a 3-letter IATA region code (MQTT step).";return}
|
|
$("#wz-err").textContent="";
|
|
var b=$("#wz-save");b.disabled=true;
|
|
saveChanges({reboot:true},function(r,acked){
|
|
b.disabled=false;
|
|
if(!r){
|
|
if(acked){
|
|
// The node accepted the batch (202), but without its result we cannot
|
|
// know whether every field passed CLI validation.
|
|
showReboot("The node accepted the settings for processing, but the confirmation reply was lost. If every setting passed, its screen shows \"Config saved!\" and it reboots within 30 seconds. If it stays in setup mode, reconnect to MeshCore-Setup and correct the rejected field.",
|
|
false,"Settings sent - verify the node");
|
|
st.dirty={};
|
|
return;
|
|
}
|
|
$("#wz-err").textContent="Couldn't reach the node to save. Check this device is still on the MeshCore-Setup WiFi, then retry.";
|
|
return;
|
|
}
|
|
var fails=(r.results||[]).filter(function(x){return!/^OK/i.test(x.reply)});
|
|
if(fails.length){
|
|
$("#wz-err").textContent=fails.map(function(x){return x.key+": "+x.reply}).join(" * ");
|
|
return;
|
|
}
|
|
showReboot("The node is joining '"+eff("wifi.ssid")
|
|
+"'. Reconnect your phone or laptop to your normal WiFi. Find the node's IP on your router, or run 'get webui' over its CLI to see the address.",
|
|
false,"Settings saved [OK] - rebooting in a moment");
|
|
st.dirty={};
|
|
});
|
|
}
|
|
|
|
/* ---------- reboot ---------- */
|
|
function doReboot(){
|
|
if(!confirm("Reboot this node now?"))return;
|
|
post("/api/reboot",{}).then(function(){
|
|
showReboot("The node is restarting. This page will try to reconnect automatically.",true);
|
|
}).catch(function(e){if(e.message!=="auth")toast("Reboot failed: "+e.message)});
|
|
}
|
|
function showReboot(msg,reconnect,title){
|
|
$("#reboot-title").textContent=title||"Rebooting...";
|
|
$("#reboot-msg").textContent=msg;
|
|
$("#reboot-ov").classList.add("show");
|
|
var n=30;$("#reboot-count").textContent=n;
|
|
var t=setInterval(function(){
|
|
n--;$("#reboot-count").textContent=n>0?n:"";
|
|
if(n<=5&&reconnect){
|
|
fetch("/api/status",{cache:"no-store"}).then(function(r){if(r.ok){clearInterval(t);location.reload()}}).catch(function(){});
|
|
}
|
|
if(n<=0){clearInterval(t);
|
|
$("#reboot-count").innerHTML=reconnect?'<a href="/">Reload</a>':"You can close this page.";
|
|
}
|
|
},1000);
|
|
}
|
|
|
|
boot();
|
|
</script>
|
|
</body>
|
|
</html>
|