feat: add configurator (Web Serial config via AT commands)
Some checks failed
Build and deploy Docs site to GitHub Pages / github-pages (push) Has been cancelled

This commit is contained in:
UA1ZBE
2026-06-05 10:00:14 +03:00
parent 339d87bf86
commit 8d4dd4965d
3 changed files with 1395 additions and 0 deletions

521
flasher/configurator.html Normal file
View File

@@ -0,0 +1,521 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MeshCore Configurator</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
:root { --border-radius: 0.5rem; }
.nav-bar { display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap; padding: 0.5rem 0; }
.nav-bar h1 { margin: 0; font-size: 1.1rem; }
pre.term { background: #111; color: #0f0; padding: 0.75rem; border-radius: 0.4rem; max-height: 300px; overflow: auto; font-size: 0.78rem; }
.spinner { display: inline-block; width: 1rem; height: 1rem; border: 2px solid var(--primary); border-top-color: transparent; border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.config-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
@media (max-width: 768px) { .config-grid { grid-template-columns: 1fr; } }
.full-width { grid-column: 1 / -1; }
.byte-counter { font-size: 0.75rem; float: right; }
.busy-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 999; }
.busy-overlay article { text-align: center; }
.snackbar { position: fixed; bottom: 1rem; left: 50%; transform: translateX(-50%); background: var(--card-background); padding: 0.75rem 1.5rem; border-radius: 0.5rem; box-shadow: 0 4px 12px rgba(0,0,0,0.3); z-index: 1000; display: none; }
.snackbar.active { display: block; }
.repeat-fieldset { margin-bottom: 1rem; }
fieldset { margin-bottom: 1rem; }
.unsaved { color: var(--warning); font-size: 0.85rem; margin-bottom: 0.5rem; }
.console-output { background: #111; color: #0f0; padding: 0.5rem; border-radius: 0.4rem; max-height: 300px; overflow: auto; font-family: monospace; font-size: 0.78rem; }
.console-input-line { display: flex; align-items: center; gap: 0.25rem; }
.console-prompt { color: #0f0; }
.console-output input { background: transparent; border: none; color: #0f0; font-family: monospace; font-size: 0.78rem; outline: none; flex: 1; }
.leaflet-container { height: 300px; border-radius: 0.5rem; }
</style>
</head>
<body>
<main class="container" id="app">
<nav class="nav-bar">
<h1>&#9881; MeshCore Configurator</h1>
<span v-if="app.busy" class="spinner"></span>
<span style="flex:1"></span>
<a href="./index.html" style="font-size:0.9rem;">&#8592; Flasher</a>
</nav>
<div v-if="app.busy" class="busy-overlay">
<article>
<progress indeterminate></progress>
<p>{{ app.busy }}</p>
</article>
</div>
<div class="snackbar" :class="{ active: snackbar.show }">{{ snackbar.text }}</div>
<!-- Connect / Disconnect -->
<article v-if="!app.connected">
<header><strong>&#128187; Подключение к устройству</strong></header>
<p>Нажми «Подключиться» и выбери порт T114 в окне браузера.</p>
<button @click="connect" :disabled="app.connecting" class="contrast">
<span v-if="app.connecting"><span class="spinner"></span> Подключение...</span>
<span v-else>&#9881; Подключиться</span>
</button>
</article>
<!-- Configuration form -->
<div v-else>
<div class="grid" style="margin-bottom: 1rem;">
<button @click="disconnect" class="secondary">&#10005; Отключиться</button>
<button @click="refreshData" class="outline">&#8635; Обновить</button>
<button @click="reboot" class="outline">&#8635; Reboot</button>
<button @click="eraseConfirm" class="outline" style="color:var(--error)">&#128465; Factory reset</button>
</div>
<div class="grid" style="margin-bottom: 1rem;">
<article>
<strong>Версия:</strong> {{ app.device.version || '—' }}
</article>
<article>
<strong>Роль:</strong> <code>{{ app.device.role || '—' }}</code>
</article>
<article>
<strong>Clock:</strong> {{ app.device.clock || '—' }}
</article>
</div>
<article>
<header><strong>Public Key</strong></header>
<code style="word-break:break-all;">{{ app.device.pubKey || '—' }}</code>
</article>
<div class="config-grid">
<!-- Name & Location -->
<fieldset class="full-width repeat-fieldset">
<legend>Name &amp; Location</legend>
<div class="field border label">
<input placeholder=" " :value="app.device.vars.name" @input="onNameInput">
<label>Name</label>
</div>
<div><span class="byte-counter" :style="{ color: nameBytes > nameMaxBytes ? 'var(--error)' : 'var(--muted-color)' }">{{ nameBytes }} / {{ nameMaxBytes }} bytes</span></div>
<div class="grid">
<div>
<label>Latitude</label>
<input type="text" v-model="app.device.vars.lat" pattern="-?[0-9]{1,2}([.][0-9]{1,6})?">
</div>
<div>
<label>Longitude</label>
<input type="text" v-model="app.device.vars.lon" pattern="-?[0-9]{1,3}([.][0-9]{1,6})?">
</div>
<div style="display:flex;align-items:end;">
<button class="secondary" @click="showMap" style="width:100%;">&#128506; Карта</button>
</div>
</div>
</fieldset>
<!-- Access -->
<fieldset class="full-width repeat-fieldset">
<legend>Access</legend>
<div>
<label>New Admin password</label>
<input type="password" v-model="app.newPassword" placeholder="Оставь пустым, чтобы не менять">
</div>
<div>
<label>Guest password</label>
<input v-model="app.device.vars['guest.password']">
</div>
</fieldset>
<!-- Radio settings -->
<fieldset class="full-width repeat-fieldset">
<legend>Radio settings</legend>
<div>
<label>Preset</label>
<select @change="setRadioPreset($event.target.value)">
<option v-for="(p, i) in presets" :selected="p === activePreset" :value="i">{{ p.title }}</option>
</select>
</div>
<div class="grid">
<div>
<label>Frequency (MHz)</label>
<input type="number" v-model="app.device.vars.radio.freq" step="0.001">
</div>
<div>
<label>Bandwidth (kHz)</label>
<select v-model="app.device.vars.radio.bw">
<option>7.8</option><option>10.4</option><option>15.6</option><option>20.8</option>
<option>31.25</option><option>41.7</option><option>62.5</option>
<option>125</option><option>250</option><option>500</option>
</select>
</div>
</div>
<div class="grid">
<div>
<label>Spreading factor</label>
<select v-model="app.device.vars.radio.sf">
<option>7</option><option>8</option><option>9</option><option>10</option><option>11</option><option>12</option>
</select>
</div>
<div>
<label>Coding rate</label>
<select v-model="app.device.vars.radio.cr">
<option>5</option><option>6</option><option>7</option><option>8</option>
</select>
</div>
</div>
<div class="grid">
<div>
<label>TX Power (dBm)</label>
<input type="number" v-model="app.device.vars.tx" min="1" max="22">
</div>
<div>
<label>Duty cycle (%)</label>
<input type="number" v-model="dutyCycle" min="1" max="50">
<small>Airtime factor: {{ app.device.vars.af }}</small>
</div>
</div>
</fieldset>
<!-- Advertising -->
<fieldset class="full-width repeat-fieldset">
<legend>Advertising</legend>
<div class="grid">
<div>
<label>Advert interval (min, 0=off)</label>
<input type="number" v-model="app.device.vars['advert.interval']" min="0" max="240">
</div>
<div>
<label>Flood advert interval (hrs)</label>
<input type="number" v-model="app.device.vars['flood.advert.interval']" min="0" max="168">
</div>
<div>
<label>Flood max hops</label>
<input type="number" v-model="app.device.vars['flood.max']" min="0" max="64">
</div>
</div>
<div class="grid">
<label><input type="checkbox" v-model="app.device.vars.repeat"> Repeater mode</label>
<label v-if="app.device.role === 'room-server'"><input type="checkbox" v-model="app.device.vars['allow.read.only']"> Read only</label>
</div>
</fieldset>
<!-- Advanced -->
<fieldset class="full-width repeat-fieldset">
<legend><label><input type="checkbox" v-model="app.showAdvanced"> Advanced settings</label></legend>
<div v-if="app.showAdvanced">
<div class="grid">
<div>
<label>Loop detection</label>
<select v-model="app.device.vars['loop.detect']">
<option value="off">Off</option>
<option value="minimal">Minimal</option>
<option value="moderate">Moderate</option>
<option value="strict">Strict</option>
</select>
</div>
<div>
<label>Path hash mode</label>
<select v-model="app.device.vars['path.hash.mode']">
<option value="0">1-byte (0)</option>
<option value="1">2-byte (1)</option>
<option value="2">3-byte (2)</option>
</select>
</div>
</div>
<div class="grid">
<div>
<label>RX delay base</label>
<input type="number" v-model="app.device.vars.rxdelay" min="0" max="20" step="0.1">
</div>
<div>
<label>TX delay factor</label>
<input type="number" v-model="app.device.vars.txdelay" min="0" max="2" step="0.1">
</div>
<div>
<label>Direct TX delay</label>
<input type="number" v-model="app.device.vars['direct.txdelay']" min="0" max="2" step="0.1">
</div>
</div>
<div class="grid">
<div>
<label>Interference threshold</label>
<input type="number" v-model="app.device.vars['int.thresh']" min="0" max="255">
</div>
<div>
<label>AGC reset interval</label>
<input type="number" v-model="app.device.vars['agc.reset.interval']" min="0" step="4">
</div>
</div>
<label><input type="checkbox" v-model="multiAcks"> Multi ACKs</label>
</div>
</fieldset>
</div>
<div v-if="hasChanges" class="unsaved">&#9432; Есть несохранённые изменения</div>
<button @click="saveData" :disabled="app.locked" class="contrast" style="width:100%;">&#128190; Save settings</button>
</div>
<footer style="margin-top:2rem;text-align:center;font-size:0.8rem;color:var(--muted-color);">
<a href="./index.html">&#8592; Назад к Flasher</a>
</footer>
<!-- Map dialog -->
<dialog id="mapDialog">
<article>
<header><strong>Choose location from map</strong> <a href="#" @click.prevent="closeMap">&#10005;</a></header>
<button @click="requestLocation" class="secondary">&#128205; Request location</button>
<div id="map" class="leaflet-container"></div>
<footer>
<a href="#" @click.prevent="setMapLatLon" class="contrast">Set location</a>
<a href="#" @click.prevent="closeMap">Cancel</a>
</footer>
</article>
</dialog>
</main>
<script type="module">
import { SerialCLI } from './lib/serial-cli.js';
const UTF8 = new TextEncoder();
const app = Vue.createApp({
data() {
return {
app: {
connecting: false,
connected: false,
locked: false,
busy: '',
showAdvanced: false,
newPassword: '',
device: {
version: '', clock: '', role: '', pubKey: '', prvKey: '',
vars: {
name: '', repeat: true, 'allow.read.only': false,
radio: { freq: 868.731, sf: 7, cr: 7, bw: '62.5' },
tx: 22, af: 1,
rxdelay: 0, txdelay: 0.5, 'direct.txdelay': 0.2,
'flood.max': 64, 'flood.advert.interval': 0, 'advert.interval': 0,
'guest.password': '',
lat: 0, lon: 0,
'int.thresh': 0, 'agc.reset.interval': 0,
'multi.acks': 0, 'owner.info': '',
'path.hash.mode': 0, 'loop.detect': 'off',
},
varsDevice: {},
},
},
cli: null,
snackbar: { show: false, text: '' },
presets: [{ title: 'Custom' }],
map: null, marker: null,
};
},
computed: {
nameBytes() { return UTF8.encode(String(this.app.device.vars.name || '')).length; },
nameMaxBytes() {
const lat = Number(this.app.device.vars.lat);
const lon = Number(this.app.device.vars.lon);
return (lat !== 0 || lon !== 0) ? 24 : 32;
},
dutyCycle: {
get() { const af = Number(this.app.device.vars.af) || 0; return Math.round(100 / (af + 1)); },
set(v) { const dc = Number(v); if (dc >= 1 && dc <= 50) this.app.device.vars.af = ((100 / dc) - 1).toFixed(1); },
},
multiAcks: {
get() { return this.app.device.vars['multi.acks'] == 1; },
set(v) { this.app.device.vars['multi.acks'] = v ? 1 : 0; },
},
activePreset() {
const r = this.app.device.vars.radio;
return this.presets.find(p =>
Number(p.frequency) == r.freq && Number(p.spreading_factor) == r.sf &&
Number(p.bandwidth) == r.bw && Number(p.coding_rate) == r.cr
) || this.presets[0];
},
hasChanges() {
const v = this.app.device.vars;
const vd = this.app.device.varsDevice;
for (const k of Object.keys(v)) {
if (!(k in vd)) continue;
if (JSON.stringify(v[k]) !== JSON.stringify(vd[k])) return true;
}
return !!this.app.newPassword;
},
},
methods: {
showMsg(text, ms) {
this.snackbar = { show: true, text };
setTimeout(() => { this.snackbar.show = false; }, ms || 2000);
},
async connect() {
this.app.connecting = true;
try {
this.cli = new SerialCLI();
await this.cli.connect(115200);
await this.cli.setTime(Math.floor(Date.now() / 1000));
this.app.connected = true;
await this.loadData();
await this.loadPresets();
} catch (e) {
alert(`Connect error: ${e.message}`);
} finally {
this.app.connecting = false;
}
},
async disconnect() {
if (this.cli) await this.cli.disconnect();
this.cli = null;
this.app.connected = false;
},
async loadData() {
this.app.busy = 'Reading configuration...';
const c = this.cli;
const v = this.app.device.vars;
const vd = this.app.device.varsDevice;
try {
this.app.device.version = await c.getVersion();
this.app.device.clock = await c.getClock();
this.app.device.role = await c.sendCommand('get role');
this.app.device.role = c.parseVariableResponse(this.app.device.role);
this.app.device.pubKey = await c.sendCommand('get public.key');
this.app.device.pubKey = c.parseVariableResponse(this.app.device.pubKey);
try {
const pk = await c.getVariable('prv.key');
this.app.device.prvKey = c.parseVariableResponse(pk);
} catch {}
for (const key of Object.keys(v)) {
try {
const resp = await c.getVariable(key);
let val = c.parseVariableResponse(resp);
if (val === null) continue;
if (key === 'radio') {
const parts = String(val).split(',');
val = { freq: Number(parts[0]).toFixed(3), bw: parts[1].replace('.0',''), sf: parts[2], cr: parts[3] };
}
if (['rxdelay','txdelay','direct.txdelay'].includes(key)) val = Math.round(Number(val) * 10) / 10;
if (['lat','lon'].includes(key)) val = Math.round(Number(val) * 100000) / 100000;
if (key === 'loop.detect') val = val === false ? 'off' : String(val);
if (key === 'multi.acks') val = String(Number(val));
v[key] = val;
vd[key] = typeof val === 'object' ? { ...val } : val;
} catch {}
}
} finally {
this.app.busy = '';
}
},
async refreshData() {
await this.loadData();
this.showMsg('Configuration reloaded');
},
async saveData() {
this.app.locked = true;
this.app.busy = 'Saving...';
const c = this.cli;
const v = this.app.device.vars;
const vd = this.app.device.varsDevice;
try {
let needsReboot = false;
const rebootKeys = new Set(['radio', 'prv.key']);
for (const key of Object.keys(v)) {
if (!(key in vd)) continue;
if (JSON.stringify(v[key]) === JSON.stringify(vd[key])) continue;
let val = v[key];
if (rebootKeys.has(key)) needsReboot = true;
if (key === 'repeat' || key === 'allow.read.only') val = val ? 'on' : 'off';
if (key === 'radio') val = `${v.radio.freq},${v.radio.bw}.0,${v.radio.sf},${v.radio.cr}`;
await c.setVariable(key, val);
}
if (this.app.newPassword) {
await c.sendCommand(`password ${this.app.newPassword}`);
this.app.newPassword = '';
}
await this.loadData();
this.showMsg('Settings saved!', 3000);
if (needsReboot && confirm('Some changes require a reboot. Reboot now?')) {
await c.reboot();
this.disconnect();
}
} catch (e) {
alert(`Save error: ${e.message}`);
} finally {
this.app.busy = '';
this.app.locked = false;
}
},
async loadPresets() {
try {
const res = await fetch('https://api.meshcore.nz/api/v1/config');
const data = await res.json();
this.presets = [{ title: 'Custom' }, ...data.config.suggested_radio_settings.entries];
} catch {}
},
setRadioPreset(idx) {
const p = this.presets[idx];
if (!p.frequency) return;
const r = this.app.device.vars.radio;
r.freq = p.frequency;
r.sf = p.spreading_factor;
r.bw = p.bandwidth;
r.cr = p.coding_rate;
},
onNameInput(e) {
const text = e.target.value;
if (UTF8.encode(text).length <= this.nameMaxBytes) {
this.app.device.vars.name = text;
} else {
e.target.value = this.app.device.vars.name;
}
},
async reboot() {
if (!confirm('Reboot device?')) return;
await this.cli.reboot();
this.disconnect();
},
async eraseConfirm() {
if (!confirm('Factory reset? All data will be lost!')) return;
await this.cli.erase();
await this.cli.reboot();
this.disconnect();
},
showMap() {
const d = document.getElementById('mapDialog');
d.showModal();
this.$nextTick(() => {
if (!this.map) this.initMap();
const v = this.app.device.vars;
this.map.setView([v.lat || 0, v.lon || 0], 2);
this.marker.setLatLng([v.lat || 0, v.lon || 0]);
setTimeout(() => this.map.invalidateSize(), 100);
});
},
initMap() {
this.map = L.map('map', { maxBounds: [[-90, -180], [90, 200]] });
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(this.map);
this.marker = L.marker([0, 0]).addTo(this.map);
this.map.on('click', (e) => this.marker.setLatLng(e.latlng));
},
requestLocation() {
navigator.geolocation.getCurrentPosition(
(pos) => {
this.marker.setLatLng([pos.coords.latitude, pos.coords.longitude]);
this.map.setView([pos.coords.latitude, pos.coords.longitude], 7);
},
() => alert('Location access denied')
);
},
setMapLatLon() {
const pos = this.marker.getLatLng();
this.app.device.vars.lat = pos.lat.toFixed(5);
this.app.device.vars.lon = pos.lng.toFixed(5);
this.closeMap();
},
closeMap() {
document.getElementById('mapDialog').close();
},
},
});
app.mount('#app');
</script>
</body>
</html>