Some checks failed
Build and deploy Docs site to GitHub Pages / github-pages (push) Has been cancelled
522 lines
22 KiB
HTML
522 lines
22 KiB
HTML
<!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>⚙ 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;">← 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>💻 Подключение к устройству</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>⚙ Подключиться</span>
|
|
</button>
|
|
</article>
|
|
|
|
<!-- Configuration form -->
|
|
<div v-else>
|
|
<div class="grid" style="margin-bottom: 1rem;">
|
|
<button @click="disconnect" class="secondary">✕ Отключиться</button>
|
|
<button @click="refreshData" class="outline">↻ Обновить</button>
|
|
<button @click="reboot" class="outline">↻ Reboot</button>
|
|
<button @click="eraseConfirm" class="outline" style="color:var(--error)">🗑 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 & 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%;">🗺 Карта</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">ⓘ Есть несохранённые изменения</div>
|
|
<button @click="saveData" :disabled="app.locked" class="contrast" style="width:100%;">💾 Save settings</button>
|
|
</div>
|
|
|
|
<footer style="margin-top:2rem;text-align:center;font-size:0.8rem;color:var(--muted-color);">
|
|
<a href="./index.html">← Назад к Flasher</a>
|
|
</footer>
|
|
|
|
<!-- Map dialog -->
|
|
<dialog id="mapDialog">
|
|
<article>
|
|
<header><strong>Choose location from map</strong> <a href="#" @click.prevent="closeMap">✕</a></header>
|
|
<button @click="requestLocation" class="secondary">📍 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>
|