3 Commits

Author SHA1 Message Date
UA1ZBE
c9ae94a0ff feat: local releases for offline/CORS-free flasher
Some checks failed
Build and deploy Docs site to GitHub Pages / github-pages (push) Has been cancelled
2026-06-05 10:04:21 +03:00
UA1ZBE
8d4dd4965d 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
2026-06-05 10:00:14 +03:00
UA1ZBE
339d87bf86 feat: add web flasher (Vue 3 + Web Serial DFU) and DFU zip generation
Some checks failed
Build and deploy Docs site to GitHub Pages / github-pages (push) Has been cancelled
PR Build Check / build (Heltec_v3_companion_radio_ble) (push) Has been cancelled
PR Build Check / build (Heltec_v3_repeater) (push) Has been cancelled
PR Build Check / build (Heltec_v3_room_server) (push) Has been cancelled
PR Build Check / build (LilyGo_Tlora_C6_repeater_) (push) Has been cancelled
PR Build Check / build (PicoW_repeater) (push) Has been cancelled
PR Build Check / build (RAK_4631_companion_radio_ble) (push) Has been cancelled
PR Build Check / build (RAK_4631_repeater) (push) Has been cancelled
PR Build Check / build (RAK_4631_room_server) (push) Has been cancelled
PR Build Check / build (wio-e5-mini_repeater) (push) Has been cancelled
2026-06-05 09:48:14 +03:00
13 changed files with 2654 additions and 0 deletions

Binary file not shown.

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>

152
flasher/create_dfu_zip.py Normal file
View File

@@ -0,0 +1,152 @@
#!/usr/bin/env python3
"""
Convert .hex or .uf2 to nRF52 DFU .zip for Web Serial flashing.
Usage:
python3 create_dfu_zip.py firmware.hex firmware.dfu.zip
python3 create_dfu_zip.py firmware.uf2 firmware.dfu.zip
"""
import sys
import os
import json
import struct
import zipfile
import hashlib
def hex_to_bin(hex_path):
"""Convert Intel HEX to raw binary."""
from intelhex import IntelHex
ih = IntelHex(hex_path)
# Use the full address range or just the used portion
min_addr = ih.minaddr() if ih.minaddr() is not None else 0
max_addr = ih.maxaddr() if ih.maxaddr() is not None else 0
size = max_addr - min_addr + 1
# Align to page size (4KB for nRF52840)
page_size = 0x1000
aligned_size = ((size + page_size - 1) // page_size) * page_size
data = ih.tobinarray(start=min_addr, size=aligned_size)
return bytes(data), min_addr
def uf2_to_bin(uf2_path):
"""Convert UF2 to raw binary."""
FAMILY_NRF52840 = 0xADA52840
data = {}
with open(uf2_path, 'rb') as f:
while True:
block = f.read(512)
if not block or len(block) < 512:
break
magic0, magic1, flags, addr, size, seq, num = struct.unpack_from('<IIIIIII', block, 0)
if magic0 != 0x0A324655 or magic1 != 0x9E5D5157:
continue
payload = block[32:32+size]
for i in range(0, len(payload), 4):
word = struct.unpack_from('<I', payload, i)[0]
offset = addr + i
data[offset] = word
if not data:
raise ValueError("No valid UF2 blocks found")
addresses = sorted(data.keys())
min_addr = addresses[0] & ~0xFFF # align to 4K
max_addr = addresses[-1]
page_size = 0x1000
size = max_addr - min_addr + 1
aligned_size = ((size + page_size - 1) // page_size) * page_size
bin_data = bytearray(aligned_size)
for addr, word in data.items():
offset = addr - min_addr
if offset + 4 <= len(bin_data):
struct.pack_into('<I', bin_data, offset, word)
return bytes(bin_data), min_addr
def create_dfu_zip(bin_data, base_addr, fw_version=1, hw_version=52):
"""Create nRF DFU zip from binary data."""
# Build init packet (.dat) per nRF DFU spec
# Format (little-endian):
# [0x01] - signature (DFU init packet)
# [fw_type: 1] - 0x04 = application
# [sd_count: 4] - number of SD requirements (1)
# [sd_array: sd_count * 4] - SD requirement (0xFFFE = any)
# [hw_version: 4]
# [fw_version: 4]
# [fwid_type: 2] - 0x0001 = SHA-256 (not used, set to 0)
# [fwid_length: 2]
# [fwid: fwid_length]
# Simpler init packet format as expected by dfu.js:
# It expects init_packet_data with components.data array
init_packet = bytearray()
init_packet.append(0x01) # signature
init_packet.append(0x04) # fw_type: application
# softdevice requirements
init_packet += struct.pack('<I', 0xFFFE) # SD required: any
init_packet += struct.pack('<I', 0xFFFFFFFF) # terminator
init_packet += struct.pack('<I', hw_version) # HW version
init_packet += struct.pack('<I', fw_version) # FW version
# FWID (empty)
init_packet += struct.pack('<II', 0, 0) # type=0, len=0
# build manifest
manifest = {
"manifest": {
"application": {
"bin_file": "firmware.bin",
"dat_file": "firmware.dat",
"init_packet_data": {
"fw_version": fw_version,
"hw_version": hw_version,
"softdevice_req": [0xFFFE],
"components": [
{
"data": list(init_packet)
}
]
}
}
}
}
return manifest, bin_data, bytes(init_packet)
def main():
if len(sys.argv) < 3:
print("Usage: create_dfu_zip.py <input.hex|uf2> <output.zip> [fw_version] [hw_version]")
sys.exit(1)
input_path = sys.argv[1]
output_path = sys.argv[2]
fw_version = int(sys.argv[3]) if len(sys.argv) > 3 else 1
hw_version = int(sys.argv[4]) if len(sys.argv) > 4 else 52
ext = os.path.splitext(input_path)[1].lower()
if ext == '.hex':
try:
bin_data, base_addr = hex_to_bin(input_path)
except ImportError:
print("Error: intelhex package required for .hex files.")
print("Install: pip install intelhex")
sys.exit(1)
elif ext == '.uf2':
bin_data, base_addr = uf2_to_bin(input_path)
else:
print(f"Unsupported format: {ext}")
sys.exit(1)
manifest, fw_bin, fw_dat = create_dfu_zip(bin_data, base_addr, fw_version, hw_version)
with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zf:
zf.writestr("manifest.json", json.dumps(manifest, indent=2))
zf.writestr("firmware.bin", fw_bin)
zf.writestr("firmware.dat", fw_dat)
size = os.path.getsize(output_path)
print(f"DFU zip created: {output_path}")
print(f" Size: {size} bytes ({size/1024:.0f} KB)")
print(f" Firmware: {len(fw_bin)} bytes @ 0x{base_addr:08X}")
if __name__ == '__main__':
main()

396
flasher/index.html Normal file
View File

@@ -0,0 +1,396 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MeshCore Firmware Flasher</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">
<style>
:root { --border-radius: 0.5rem; }
.badge { font-size: 0.7rem; padding: 0.15rem 0.4rem; border-radius: 0.25rem; background: var(--primary); color: var(--primary-inverse); white-space: nowrap; }
.badge-sm { font-size: 0.65rem; padding: 0.1rem 0.3rem; }
pre.term { background: #111; color: #0f0; padding: 0.75rem; border-radius: 0.4rem; max-height: 300px; overflow: auto; font-size: 0.78rem; line-height: 1.3; white-space: pre-wrap; word-break: break-all; }
.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; }
.release-notes { font-size: 0.85rem; background: var(--card-sectionning-background); padding: 0.75rem; border-radius: 0.4rem; white-space: pre-wrap; max-height: 200px; overflow-y: auto; }
.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); } }
.flash-progress { margin: 1rem 0; }
.flash-progress progress { width: 100%; }
.empty-state { text-align: center; padding: 3rem 1rem; color: var(--muted-color); }
.step-link { cursor: pointer; color: var(--primary); }
.step-link:hover { text-decoration: underline; }
.file-row { padding: 0.6rem 0; border-bottom: 1px solid var(--card-border-color); display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem; }
.file-row:last-child { border-bottom: none; }
.btn-group { display: flex; gap: 0.4rem; flex-wrap: wrap; }
.release-card { cursor: pointer; transition: opacity 0.15s; }
.release-card:hover { opacity: 0.85; }
.flash-container { border: 1px solid var(--card-border-color); border-radius: var(--border-radius); padding: 1rem; margin-top: 1rem; }
footer { margin-top: 2rem; text-align: center; font-size: 0.8rem; color: var(--muted-color); }
.cors-note { font-size: 0.8rem; background: var(--warning-background); color: var(--warning-color); padding: 0.5rem; border-radius: 0.3rem; margin-top: 0.5rem; }
</style>
</head>
<body>
<main class="container" id="app">
<nav class="nav-bar">
<h1>&#9889; MeshCore Flasher</h1>
<span v-if="!loading && releases.length" class="badge">{{ releases.length }} релиз(ов)</span>
<span v-if="loading" class="spinner"></span>
<span v-if="error" style="color: var(--error); font-size: 0.85rem;">{{ error }}</span>
<span style="flex:1"></span>
<a href="https://git2.ua1zbe.ru/ua1zbe/meshcore-simple-sensor" target="_blank" style="font-size:0.9rem;">&#128279; Репозиторий</a>
</nav>
<!-- Breadcrumb -->
<nav v-if="view !== 'releases'" style="font-size:0.9rem; margin-bottom:0.5rem;">
<a href="#" @click.prevent="view = 'releases'; selectedRelease = null; selectedAsset = null">&#8592; Все релизы</a>
<template v-if="view === 'release' && selectedRelease">
<span> / {{ selectedRelease.tag_name }}</span>
</template>
<template v-if="view === 'flash' && selectedRelease && selectedAsset">
<a href="#" @click.prevent="view='release'; selectedAsset=null" class="step-link"> / {{ selectedRelease.tag_name }}</a>
<span> / {{ selectedAsset.name }}</span>
</template>
<template v-if="view === 'console'">
<span> / Консоль</span>
</template>
</nav>
<!-- Warning if loaded via CORS proxy -->
<article v-if="corsProxy" class="cors-note">
&#9432; Релизы загружены через CORS-прокси. Для прямой загрузки настрой CORS в Gitea:
<code>app.ini: [cors] ENABLED = true; ALLOW_DOMAIN = *</code> или размести flasher на том же домене.
</article>
<!-- Flash view -->
<div v-if="view === 'flash' && selectedRelease && selectedAsset" class="flash-container">
<hgroup>
<h5>{{ selectedAsset.name }}</h5>
<p class="size-info">{{ (selectedAsset.size / 1024).toFixed(0) }} KB &middot; {{ selectedRelease.tag_name }}</p>
</hgroup>
<div v-if="selectedRelease.body" class="release-notes" style="margin-bottom:1rem;">{{ selectedRelease.body }}</div>
<div class="grid">
<div>
<h6>Способ 1: UF2 (drag-n-drop)</h6>
<ol>
<li>Зажми <kbd>BOOT/PRG</kbd> на T114</li>
<li>Подключи USB к ПК</li>
<li>Отпусти — появится диск <code>T114</code></li>
<li>Перетащи .uf2 файл на этот диск</li>
</ol>
<a :href="selectedAsset.browser_download_url" class="button" role="button" download>&#11015; Скачать UF2</a>
</div>
<div>
<h6>Способ 2: Web DFU (экспер.)</h6>
<ol>
<li>Дважды кликни RESET на T114</li>
<li>Нажми «Enter DFU mode» и выбери порт</li>
<li>Нажми «Flash DFU»</li>
</ol>
<div class="btn-group">
<button v-if="!dfu.ready" class="secondary" @click="enterDfuMode" :disabled="!supportsSerial">&#9881; Enter DFU mode</button>
<button v-else disabled class="secondary">&#10003; DFU mode</button>
<button @click="flashDfu" :disabled="!dfu.ready || flashing.active" class="contrast">&#9889; Flash DFU</button>
</div>
<p v-if="!supportsSerial" style="font-size:0.8rem;color:var(--error);margin-top:0.5rem;">
Web Serial не поддерживается. Используй Chrome/Edge на десктопе.
</p>
<p v-if="!isZip(selectedAsset.name)" style="font-size:0.8rem;color:var(--muted-color);margin-top:0.5rem;">
&#9432; Для DFU нужен .zip файл. В релизе только .uf2.
</p>
</div>
</div>
<div v-if="flashing.active" class="flash-progress">
<progress :value="flashing.percent" max="100"></progress>
<p v-if="flashing.percent < 100" style="font-size:0.85rem;">
<span class="spinner"></span> Прошивка... {{ Math.round(flashing.percent) }}%
</p>
<p v-else style="font-size:0.85rem;color:var(--success);">&#10003; Готово!</p>
<pre class="term">{{ flashing.log }}</pre>
<div v-if="flashing.done" class="btn-group">
<button class="secondary" @click="resetFlash">&#8592; Назад к файлу</button>
</div>
</div>
<button class="secondary" style="margin-top:1rem;" @click="view='release'; selectedAsset=null">&#8592; Назад к релизу</button>
</div>
<!-- Serial Console -->
<div v-if="view === 'console'">
<hgroup>
<h5>&#128187; Serial Console (115200 бод)</h5>
<p>Подключение к T114 для AT-команд</p>
</hgroup>
<div class="btn-group" style="margin-bottom:0.75rem;">
<button v-if="!console.connected" @click="openConsole" :disabled="!supportsSerial">&#9881; Подключиться</button>
<button v-else class="secondary" @click="closeConsole">&#10005; Отключиться</button>
<button v-if="console.connected" @click="consoleReset">&#8635; Reset</button>
</div>
<pre class="term" style="max-height:400px;">{{ console.log || 'Нажми «Подключиться» и выбери порт T114...' }}</pre>
<div v-if="console.connected" style="display:flex;gap:0.5rem;">
<input type="text" v-model="console.input" @keyup.enter="sendConsole" placeholder="AT команда..." style="flex:1;">
<button @click="sendConsole" class="contrast">Отправить</button>
</div>
<details style="margin-top:0.5rem;">
<summary>AT команды</summary>
<ul style="font-size:0.8rem;columns:2;">
<li><code>ver</code> — версия</li>
<li><code>log</code> — лог пакетов</li>
<li><code>erase</code> — стереть FS</li>
<li><code>reboot</code> — перезагрузка</li>
<li><code>advert</code> — отправить ADV</li>
<li><code>get freq</code> — частота</li>
<li><code>set freq 868.7</code> — частота</li>
<li><code>get af</code> — Air-time factor</li>
<li><code>set name</code> — имя</li>
<li><code>password &lt;pass&gt;</code> — пароль</li>
</ul>
</details>
<button class="secondary" style="margin-top:1rem;" @click="view='releases'">&#8592; Назад</button>
</div>
<!-- Release content -->
<div v-else-if="view === 'release' && selectedRelease">
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:0.5rem;">
<h5>{{ selectedRelease.name || selectedRelease.tag_name }}</h5>
<span class="badge">{{ selectedRelease.assets.length }} файл(ов)</span>
</div>
<p v-if="selectedRelease.body" class="release-notes">{{ selectedRelease.body }}</p>
<div v-if="selectedRelease.assets.length">
<div v-for="asset in selectedRelease.assets" :key="asset.id" class="file-row">
<div>
<strong>{{ asset.name }}</strong>
<span style="font-size:0.8rem;color:var(--muted-color);">
&middot; {{ (asset.size / 1024).toFixed(0) }} KB
</span>
<span v-if="asset.download_count" class="badge badge-sm" style="margin-left:0.4rem;">
{{ asset.download_count }} загрузок
</span>
</div>
<div class="btn-group">
<a :href="asset.browser_download_url" role="button" class="secondary outline small" download>&#11015; Скачать</a>
<button class="small contrast" @click="startFlash(asset)">&#9889; Прошить</button>
</div>
</div>
</div>
<div v-else class="empty-state">
<p>Нет файлов прошивки в этом релизе</p>
</div>
</div>
<!-- Main: releases list -->
<div v-else>
<div v-if="loading" class="empty-state">
<span class="spinner" style="width:2rem;height:2rem;"></span>
<p>Загрузка релизов...</p>
</div>
<div v-else-if="!releases.length" class="empty-state">
<p>&#128196; Релизов пока нет</p>
<a href="https://git2.ua1zbe.ru/ua1zbe/meshcore-simple-sensor/releases" target="_blank" class="button outline">Перейти к релизам</a>
</div>
<div v-else>
<article v-for="r in releases" :key="r.id" class="release-card" @click="openRelease(r)">
<header style="display:flex;justify-content:space-between;align-items:center;">
<strong>{{ r.name || r.tag_name }}</strong>
<span class="badge">{{ r.assets.length }} файл(ов)</span>
</header>
<p v-if="r.body" style="font-size:0.85rem;max-height:2.5rem;overflow:hidden;margin:0;">{{ r.body }}</p>
<footer style="font-size:0.75rem;color:var(--muted-color);margin-top:0.5rem;">
{{ formatDate(r.published_at) }}
&middot; {{ r.assets.reduce((s, a) => s + (a.download_count||0), 0) }} скачиваний
</footer>
</article>
</div>
</div>
<footer>
<nav style="justify-content:center;gap:1rem;">
<a href="#" @click.prevent="view='releases'; selectedRelease=null; selectedAsset=null">Список релизов</a>
<a href="#" @click.prevent="view='console'">&#128187; Консоль</a>
<a href="./configurator.html">&#9881; Конфигуратор</a>
<a href="https://git2.ua1zbe.ru/ua1zbe/meshcore-simple-sensor" target="_blank">Git2.ua1zbe.ru</a>
</nav>
</footer>
</main>
<script type="module">
import { Dfu } from './lib/dfu.js';
import { SerialConsole } from './lib/console.js';
const GITEA_API = 'https://git2.ua1zbe.ru/api/v1/repos/ua1zbe/meshcore-simple-sensor/releases';
const OWN_ORIGIN = location.origin;
const API_ORIGIN = new URL(GITEA_API).origin;
const app = Vue.createApp({
data() {
return {
releases: [],
loading: true,
error: '',
corsProxy: false,
view: 'releases',
selectedRelease: null,
selectedAsset: null,
dfu: { ready: false, port: null },
flashing: { active: false, done: false, percent: 0, log: '', error: '' },
console: { connected: false, instance: null, input: '', log: '' },
};
},
computed: {
supportsSerial() {
return 'serial' in navigator;
},
},
async mounted() {
await this.loadReleases();
},
methods: {
formatDate(d) {
return new Date(d).toLocaleDateString('ru-RU');
},
isZip(name) {
return name && name.toLowerCase().endsWith('.zip');
},
async loadReleases() {
this.loading = true;
this.error = '';
// Try local releases.json first (no CORS), then API, then CORS proxy
try {
const localRes = await fetch('./releases.json');
if (localRes.ok) {
const data = await localRes.json();
this.releases = Array.isArray(data) ? data.reverse() : [];
this.loading = false;
return;
}
} catch {}
if (OWN_ORIGIN === API_ORIGIN) {
await this._fetchDirect();
this.loading = false;
return;
}
try {
await this._fetchDirect();
} catch {
await this._fetchCorsProxy();
}
this.loading = false;
},
async _fetchDirect() {
const res = await fetch(GITEA_API);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
this.releases = Array.isArray(data) ? data.reverse() : [];
},
async _fetchCorsProxy() {
this.error = 'Прямой доступ к API заблокирован CORS.';
const proxy = `https://api.allorigins.win/raw?url=${encodeURIComponent(GITEA_API)}`;
const res = await fetch(proxy);
if (!res.ok) throw new Error(`Proxy HTTP ${res.status}`);
const data = await res.json();
this.releases = Array.isArray(data) ? data.reverse() : [];
this.corsProxy = true;
this.error = '';
},
openRelease(release) {
this.selectedRelease = release;
this.selectedAsset = null;
this.view = 'release';
},
startFlash(asset) {
this.selectedAsset = asset;
this.view = 'flash';
},
async enterDfuMode() {
if (!this.supportsSerial) return;
try {
const port = await navigator.serial.requestPort();
await Dfu.forceDfuMode(port);
this.dfu.ready = true;
this.dfu.port = port;
} catch (e) {
this.error = `DFU mode: ${e.message}`;
}
},
async flashDfu() {
if (!this.dfu.port || !this.selectedAsset) return;
if (!this.isZip(this.selectedAsset.name)) {
this.flashing.active = true;
this.flashing.done = true;
this.flashing.log = 'DFU требуется .zip файл. Используй скачивание .uf2 и drag-n-drop.\n';
this.flashing.percent = 100;
return;
}
this.flashing.active = true;
this.flashing.done = false;
this.flashing.percent = 0;
this.flashing.log = '';
try {
this.flashing.log += 'Скачивание прошивки...\n';
const resp = await fetch(this.selectedAsset.browser_download_url);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const blob = await resp.blob();
this.flashing.log += `Загружено: ${(blob.size / 1024).toFixed(0)} KB\n`;
this.flashing.log += 'Запуск DFU...\n';
const dfu = new Dfu(this.dfu.port);
dfu.dfuUpdate(blob, (pct) => {
this.flashing.percent = pct;
});
this.flashing.log += 'Готово!\n';
this.flashing.percent = 100;
} catch (e) {
this.flashing.log += `ERROR: ${e.message}\n`;
this.flashing.error = e.message;
} finally {
this.flashing.done = true;
}
},
resetFlash() {
this.flashing.active = false;
this.flashing.done = false;
this.flashing.percent = 0;
this.flashing.log = '';
this.flashing.error = '';
},
async openConsole() {
if (!this.supportsSerial) return;
try {
const port = await navigator.serial.requestPort();
const sc = new SerialConsole(port);
sc.onOutput = (text) => {
this.console.log += text;
};
this.console.instance = sc;
await sc.connect();
this.console.connected = true;
this.console.log = '';
this.view = 'console';
} catch (e) {
this.error = `Console: ${e.message}`;
}
},
async closeConsole() {
if (this.console.instance) {
await this.console.instance.disconnect();
}
this.console.connected = false;
this.console.instance = null;
},
async consoleReset() {
if (this.console.instance) {
await this.console.instance.reset();
this.console.log += '-- RESET --\n';
}
},
async sendConsole() {
if (!this.console.instance || !this.console.input) return;
await this.console.instance.sendCommand(this.console.input);
this.console.input = '';
},
},
});
app.mount('#app');
</script>
</body>
</html>

98
flasher/lib/console.js Normal file
View File

@@ -0,0 +1,98 @@
function delay(msecs) {
return new Promise((resolve) => setTimeout(resolve, msecs));
}
class LineBreakTransformer {
chunks = '';
port = null;
transform(chunk, controller) {
// Append new chunks to existing chunks.
this.chunks += chunk;
// For each line breaks in chunks, send the parsed lines out.
const lines = this.chunks.split('\r\n');
this.chunks = lines.pop();
lines.forEach((line) => controller.enqueue(line + '\r\n'));
}
flush(controller) {
// When the stream is closed, flush any remaining chunks out.
controller.enqueue(this.chunks);
}
}
export class SerialConsole {
connected = false;
constructor(port) {
this.port = port;
this.controller = new AbortController();
this.signal = this.controller.signal;
this.onOutput = (text) => {
console.log(text);
};
}
async connect() {
try {
await this.port.open({ baudRate: 115200 });
this.connected = true;
await this.port.readable
.pipeThrough(new TextDecoderStream(), { signal: this.signal })
.pipeThrough(new TransformStream(new LineBreakTransformer()))
.pipeTo(
new WritableStream({
write: (chunk) => {
this.addLine(chunk.replace('\r', ''));
},
}),
);
// Check AFTER the pipeTo has completed (or been aborted)
if (!this.signal.aborted) {
this.addLine('\n\n*** Terminal disconnected');
this.connected = false;
}
} catch (e) {
this.addLine(`\n\n*** Terminal disconnected: ${e}`);
this.connected = false;
} finally {
await delay(100);
}
}
addLine(text) {
this.onOutput(text);
}
async sendCommand(command) {
const encoder = new TextEncoder();
const writer = this.port.writable.getWriter(); // Get writer from 'this.port'
await writer.write(encoder.encode(command + '\r\n'));
try {
writer.releaseLock();
} catch (err) {
console.error('Ignoring release lock error', err);
}
}
async disconnect() {
this.controller.abort();
await delay(50);
await this.port.close();
}
async reset() {
console.debug('Triggering reset');
await this.port.setSignals({
dataTerminalReady: false,
requestToSend: true,
});
await delay(250);
await this.port.setSignals({
dataTerminalReady: false,
requestToSend: false,
});
await delay(1250);
}
}

469
flasher/lib/dfu.js Normal file
View File

@@ -0,0 +1,469 @@
import * as zip from "./zip.min.js";
// Constants adapted from dfu/dfu_transport_serial.py
const DFU_TOUCH_BAUD = 1200;
const SERIAL_PORT_OPEN_WAIT_TIME = 0.1;
const TOUCH_RESET_WAIT_TIME = 1.5;
const DEFAULT_SERIAL_PORT_TIMEOUT = 1.0; // Timeout time on serial port read
const FLASH_PAGE_SIZE = 4096;
const FLASH_PAGE_ERASE_TIME = 0.0897; // nRF52840 max erase time
const FLASH_WORD_WRITE_TIME = 0.000100; // nRF52840 max write time
const FLASH_PAGE_WRITE_TIME = (FLASH_PAGE_SIZE / 4) * FLASH_WORD_WRITE_TIME;
const DFU_PACKET_MAX_SIZE = 512;
const DATA_INTEGRITY_CHECK_PRESENT = 1;
const RELIABLE_PACKET = 1;
const HCI_PACKET_TYPE = 14;
const DFU_INIT_PACKET = 1;
const DFU_START_PACKET = 3;
const DFU_DATA_PACKET = 4;
const DFU_STOP_DATA_PACKET = 5;
const DFU_ERASE_PAGE = 6; // Added for explicit page erase
const DFU_UPDATE_MODE_APP = 4;
// --- Utility Functions (adapted from dfu/util.py) ---
function int32ToBytes(value) {
const buffer = new ArrayBuffer(4);
const view = new DataView(buffer);
view.setUint32(0, value, true); // Little-endian
return new Uint8Array(buffer);
}
function int16ToBytes(value) {
const buffer = new ArrayBuffer(2);
const view = new DataView(buffer);
view.setUint16(0, value, true); // Little-endian
return new Uint8Array(buffer);
}
function slipPartsToFourBytes(seq, dip, rp, pktType, pktLen) {
const ints = new Uint8Array(4);
ints[0] = seq | (((seq + 1) % 8) << 3) | (dip << 6) | (rp << 7);
ints[1] = pktType | ((pktLen & 0x000F) << 4);
ints[2] = (pktLen & 0x0FF0) >> 4;
ints[3] = (~(ints[0] + ints[1] + ints[2]) + 1) & 0xFF;
return ints;
}
function slipEncodeEscChars(data) {
const result = [];
for (const byte of data) {
if (byte === 0xC0) {
result.push(0xDB, 0xDC);
} else if (byte === 0xDB) {
result.push(0xDB, 0xDD);
} else {
result.push(byte);
}
}
return new Uint8Array(result);
}
// --- CRC16 Calculation (adapted from dfu/crc16.py) ---
function calcCrc16(data, crc = 0xFFFF) {
if (!(data instanceof Uint8Array)) {
throw new Error("calcCrc16 requires Uint8Array input");
}
for (let i = 0; i < data.length; i++) {
crc = ((crc >> 8) & 0x00FF) | ((crc << 8) & 0xFF00);
crc ^= data[i];
crc ^= (crc & 0x00FF) >> 4;
crc ^= (crc << 8) << 4;
crc ^= ((crc & 0x00FF) << 4) << 1;
}
return crc & 0xFFFF;
}
function sleep(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
// --- HciPacket Class (adapted from dfu/dfu_transport_serial.py) ---
class HciPacket {
static sequenceNumber = 0;
constructor(data) {
HciPacket.sequenceNumber = (HciPacket.sequenceNumber + 1) % 8;
let tempData = [];
const slipBytes = slipPartsToFourBytes(
HciPacket.sequenceNumber,
DATA_INTEGRITY_CHECK_PRESENT,
RELIABLE_PACKET,
HCI_PACKET_TYPE,
data.length
);
tempData = tempData.concat(Array.from(slipBytes));
tempData = tempData.concat(Array.from(data));
// Add CRC
const crc = calcCrc16(new Uint8Array(tempData));
tempData.push(crc & 0xFF);
tempData.push((crc & 0xFF00) >> 8);
const encoded = slipEncodeEscChars(new Uint8Array(tempData));
this.data = new Uint8Array([0xC0, ...encoded, 0xC0]);
}
}
// --- Main DFU Class ---
export class Dfu {
/**
* @param {SerialPort} port - The Web Serial API port object.
* @param {boolean} [eraseBeforeUpdate=false] - Whether to erase the entire flash before updating.
*/
constructor(port, eraseBeforeUpdate = false) {
this.port = port;
this.transferInProgress = false;
this.lastAck = -1;
this.eraseBeforeUpdate = eraseBeforeUpdate; // Store the erase flag
}
getReader() {
const reader = this.port.readable.getReader();
return {
read() {
return new Promise((resolve, reject) => {
const timeoutHandle = setTimeout(() => {
reader.releaseLock();
reject(new Error("Read timeout"));
}, DEFAULT_SERIAL_PORT_TIMEOUT * 1000 * 5)
reader.read().then(result => {
clearTimeout(timeoutHandle);
resolve(result);
});
});
},
releaseLock() {
return reader.releaseLock();
}
}
}
async sendPacket(pkt) {
if (!this.port || !this.port.writable) {
throw new Error("Serial port not open or not writable.");
}
const writer = this.port.writable.getWriter();
try {
await writer.write(pkt.data);
console.debug("Sent packet:", pkt.data.length);
} finally {
writer.releaseLock();
}
await this.getAck(); // Wait for ACK after sending
}
async getAck() {
if (!this.port || !this.port.readable) {
throw new Error("Serial port not open or not readable.");
}
const reader = this.getReader();
let buffer = [];
let c0Count = 0;
try {
const startTime = Date.now();
while (c0Count < 2) {
const { value, done } = await reader.read();
if (done) {
throw new Error("Stream closed before receiving full ACK.");
}
if (value) {
for (const byte of value) {
buffer.push(byte);
if (byte === 0xC0) {
c0Count++;
}
}
}
}
}
catch(e) {
HciPacket.sequenceNumber = 0;
throw e;
}
finally {
reader.releaseLock();
}
// Extract the SLIP frame between the two 0xC0 delimiters, ignoring any
// stale bytes that arrived before the opening delimiter.
const firstC0 = buffer.indexOf(0xC0);
const secondC0 = buffer.indexOf(0xC0, firstC0 + 1);
if (firstC0 === -1 || secondC0 === -1) {
throw new Error("Received incomplete ACK.");
}
const decodedData = this.decodeSlip(buffer.slice(firstC0 + 1, secondC0));
if (decodedData.length < 2) {
throw new Error("Received incomplete ACK.");
}
const ack = (decodedData[0] >> 3) & 0x07;
// Check for valid ACK sequence
if (this.lastAck !== -1 && ack !== (this.lastAck + 1) % 8) {
HciPacket.sequenceNumber = 0; // Reset on bad ack
throw new Error(`Invalid ACK sequence. Expected ${(this.lastAck + 1) % 8}, got ${ack}`);
}
this.lastAck = ack;
return ack;
}
decodeSlip(data) {
const result = [];
let i = 0;
while (i < data.length) {
if (data[i] === 0xDB) {
i++;
if (i >= data.length) {
throw new Error("Invalid SLIP escape sequence: incomplete.");
}
if (data[i] === 0xDC) {
result.push(0xC0);
} else if (data[i] === 0xDD) {
result.push(0xDB);
} else {
throw new Error(`Invalid SLIP escape sequence: DB followed by ${data[i].toString(16)}`);
}
} else if (data[i] === 0xC0) {
// Ignore 0xC0 (start/end of packet)
}
else {
result.push(data[i]);
}
i++;
}
return new Uint8Array(result);
}
async sendInitPacket(initPacket) {
const frame = new Uint8Array([
...int32ToBytes(DFU_INIT_PACKET),
...initPacket,
...int16ToBytes(0x0000), // Padding
]);
const packet = new HciPacket(frame);
await this.sendPacket(packet);
}
// THANKS Liam!!!
static async forceDfuMode(port) {
// open port
await port.open({
baudRate: DFU_TOUCH_BAUD,
});
// wait SERIAL_PORT_OPEN_WAIT_TIME before closing port
await sleep(SERIAL_PORT_OPEN_WAIT_TIME * 1000);
// close port
await port.close();
// wait TOUCH_RESET_WAIT_TIME for device to enter into DFU mode
await sleep(TOUCH_RESET_WAIT_TIME * 1000);
}
async sendStartDfu(mode, softdeviceSize = 0, bootloaderSize = 0, appSize = 0) {
const frame = new Uint8Array([
...int32ToBytes(DFU_START_PACKET),
...int32ToBytes(mode),
...int32ToBytes(softdeviceSize),
...int32ToBytes(bootloaderSize),
...int32ToBytes(appSize),
]);
const packet = new HciPacket(frame);
await this.sendPacket(packet);
// Calculate and apply erase wait time.
const totalSize = softdeviceSize + bootloaderSize + appSize;
const eraseWaitTime = Math.max(0.5, ((totalSize / FLASH_PAGE_SIZE) + 1) * FLASH_PAGE_ERASE_TIME);
await sleep(eraseWaitTime * 1000);
}
async sendErasePage(pageAddress) {
const frame = new Uint8Array([
...int32ToBytes(DFU_ERASE_PAGE),
...int32ToBytes(pageAddress),
]);
const packet = new HciPacket(frame);
await this.sendPacket(packet);
await sleep(FLASH_PAGE_ERASE_TIME * 1000); // Wait for page erase
}
async eraseFlash(appSize) {
console.log("Erasing flash...");
const numPages = Math.ceil(appSize / FLASH_PAGE_SIZE);
// Assuming application starts at address 0x00000000
let startAddress = 0x00000000;
for (let i = 0; i < numPages; i++) {
const pageAddress = startAddress + (i * FLASH_PAGE_SIZE);
console.log(`Erasing page ${i} at address 0x${pageAddress.toString(16)}`);
await this.sendErasePage(pageAddress);
}
console.log("Flash erase complete.");
}
async sendFirmware(firmware, progressCallback) {
const frames = [];
let totalBytes = firmware.length;
// Chunk firmware into DFU packets
for (let i = 0; i < firmware.length; i += DFU_PACKET_MAX_SIZE) {
const chunk = firmware.subarray(i, i + DFU_PACKET_MAX_SIZE);
const frame = new Uint8Array([
...int32ToBytes(DFU_DATA_PACKET),
...chunk,
]);
const dataPacket = new HciPacket(frame);
frames.push(dataPacket);
}
let bytesSent = 0;
// Brief stabilization pause before starting data transfer (mirrors Python's implicit
// pause at count=0 — it sleeps FLASH_PAGE_WRITE_TIME after the very first packet).
await sleep(FLASH_PAGE_WRITE_TIME * 1000);
// Send firmware packets
for (const [index, pkt] of frames.entries()) {
await this.sendPacket(pkt);
bytesSent += pkt.data.length - 6; // Correctly calculate sent bytes, excluding SLIP overhead
if (progressCallback) {
const progress = Math.min(100, Math.round((bytesSent / totalBytes) * 100)); // Ensure progress doesn't exceed 100
progressCallback(progress);
}
// Wait after every 8 frames (one flash page)
if ((index + 1) % 8 === 0) {
await sleep(FLASH_PAGE_WRITE_TIME * 1000);
}
}
// Wait for the last page to be written
await sleep(FLASH_PAGE_WRITE_TIME * 1000);
// Send stop packet
const stopPacket = new HciPacket(int32ToBytes(DFU_STOP_DATA_PACKET));
await this.sendPacket(stopPacket);
}
async dfuUpdate(zipFile, progressCallback) {
if (this.transferInProgress) {
throw new Error("DFU update already in progress.");
}
this.transferInProgress = true;
this.lastAck = -1; // Reset last ACK
HciPacket.sequenceNumber = 0; // Reset HCI sequence number
const decoder = new TextDecoder();
try {
await this.port.open({ baudRate: 115200 }); // Open with correct baudrate
const reader = new zip.ZipReader(new zip.BlobReader(zipFile));
const entries = await reader.getEntries();
let manifest = null;
let firmwareFiles = {};
for (const entry of entries) {
const filename = decoder.decode(entry.rawFilename);
console.debug('Found zip filename: ', filename);
if (filename === 'manifest.json') {
const text = await entry.getData(new zip.TextWriter());
manifest = JSON.parse(text);
} else if (filename.endsWith('.bin') || filename.endsWith('.dat')) {
firmwareFiles[filename] = await entry.getData(new zip.Uint8ArrayWriter());
}
}
await reader.close();
if (!manifest) {
throw new Error("manifest.json not found in the ZIP file.");
}
if (!firmwareFiles[manifest.manifest.application.bin_file] ||
!firmwareFiles[manifest.manifest.application.dat_file])
{
throw new Error("Application .bin or .dat file not found.");
}
const appBin = firmwareFiles[manifest.manifest.application.bin_file];
const initPacket = firmwareFiles[manifest.manifest.application.dat_file];
const appSize = appBin.length;
// Erase flash if requested
if (this.eraseBeforeUpdate) {
await this.eraseFlash(appSize);
}
// Start DFU
await this.sendStartDfu(DFU_UPDATE_MODE_APP, 0, 0, appSize);
// Send Init Packet
await this.sendInitPacket(initPacket);
// Send Firmware
await this.sendFirmware(appBin, progressCallback);
console.log("DFU update complete.");
} catch (error) {
console.error("DFU Update failed:", error);
throw error; // Re-throw the error for handling by the caller
} finally {
this.transferInProgress = false;
if (this.port && this.port.readable) {
try {
const reader = this.port.readable.getReader();
await reader.cancel();
reader.releaseLock();
} catch (error) {
// Ignore errors when trying to cancel the reader
console.debug(`Error: closing reader: ${error}`);
}
}
if (this.port && this.port.writable) {
try {
const writer = this.port.writable.getWriter();
await writer.close();
writer.releaseLock();
} catch(error) {
// Ignore errors when trying to close the writer
console.debug(`Error: closing writer: ${error}`);
}
}
if (this.port) {
try {
await this.port.close();
}
catch (error) {
// Ignore errors when trying to close the port
console.debug(`Error: closing port: ${error}`);
}
}
}
}
}

756
flasher/lib/serial-cli.js Normal file
View File

@@ -0,0 +1,756 @@
/**
* SerialCLI - A class for communicating with devices via Web Serial API
* Handles sending commands, receiving responses, and parsing multi-line data
*/
export class SerialCLI {
constructor(debug = false) { // Added debug parameter
this.port = null;
this.reader = null;
this.writer = null;
this.readBuffer = "";
this.isReading = false;
this.commandQueue = [];
this.currentCommand = null;
this.decoder = new TextDecoder();
this.encoder = new TextEncoder();
this.responseTimeout = 5000; // 5 seconds timeout for responses
this.commandDelay = 100; // 100ms delay between commands
this.debug = debug; // Initialize debug mode
}
/**
* Enable or disable debug logging
* @param {boolean} enabled - True to enable debug mode, false to disable
*/
setDebug(enabled) {
this.debug = enabled;
if (this.debug) {
console.log("SerialCLI Debug Mode Enabled");
} else {
console.log("SerialCLI Debug Mode Disabled");
}
}
/**
* Connect to a serial device
* @param {number} baudRate - Baud rate to use (default: 115200)
* @returns {Promise<boolean>} True if connected, false otherwise
*/
async connect(baudRate = 115200) {
if (!('serial' in navigator)) {
console.error('Web Serial API not supported in this browser');
return false;
}
try {
this.port = await navigator.serial.requestPort();
await this.port.open({ baudRate });
this.reader = this.port.readable.getReader();
this.writer = this.port.writable.getWriter();
if (this.debug) {
console.log(`SerialCLI: Connected to port, baud rate ${baudRate}`);
}
this.startReading();
return true; // Indicate successful connection
} catch (error) {
console.error("SerialCLI: Failed to connect", error);
this.port = null; // Reset port on failure
return false; // Indicate failed connection
}
}
/**
* Disconnect from the serial device
*/
async disconnect() {
if (this.reader) {
try {
this.isReading = false;
await this.reader.cancel();
// releaseLock() is handled implicitly by cancel() or closing the port
} catch (error) {
if (this.debug) console.error("SerialCLI: Error cancelling reader", error);
} finally {
this.reader = null;
}
}
if (this.writer) {
try {
// Ensure writer is closed before releasing lock
if (!this.writer.closed) {
await this.writer.close();
}
} catch (error) {
if (this.debug) console.error("SerialCLI: Error closing writer", error);
} finally {
try {
this.writer.releaseLock();
} catch(lockError) {
// Ignore error if lock was already released
}
this.writer = null;
}
}
if (this.port) {
try {
await this.port.close();
if (this.debug) console.log("SerialCLI: Port closed");
} catch (error) {
if (this.debug) console.error("SerialCLI: Error closing port", error);
} finally {
this.port = null;
}
}
}
/**
* Start reading data from the serial port
* @private
*/
startReading() {
if (!this.reader) return;
this.isReading = true;
this.readLoop();
if (this.debug) console.log("SerialCLI: Started reading loop");
}
/**
* Main read loop for serial data
* @private
*/
async readLoop() {
while (this.isReading && this.reader) {
try {
const { value, done } = await this.reader.read();
if (done) {
// Allow the serial port to be closed later.
this.reader.releaseLock();
if (this.debug) console.log("SerialCLI: Reader stream closed");
break;
}
const textChunk = this.decoder.decode(value, { stream: true }); // Use stream option for potentially multi-byte chars split across chunks
if (this.debug) {
console.log("SerialCLI <<< RECV:", JSON.stringify(textChunk)); // Log received data
}
this.processIncomingData(textChunk);
} catch (error) {
console.error("SerialCLI: Error in read loop:", error);
this.isReading = false; // Stop reading on error
try {
this.reader.releaseLock();
} catch (lockError) {
// Ignore lock release error if already released
}
this.reader = null;
// Consider attempting to reconnect or notify the user
break;
}
}
// Redundant check, but safe
if (this.isReading && this.port?.readable && !this.reader) {
try {
this.reader = this.port.readable.getReader();
this.readLoop(); // Restart loop if needed and possible
if (this.debug) console.log("SerialCLI: Restarted reading loop after temporary reader release");
} catch(err) {
console.error("SerialCLI: Failed to re-acquire reader", err);
this.isReading = false;
}
} else if (!this.isReading && this.debug) {
console.log("SerialCLI: Reading loop stopped.");
}
}
/**
* Process incoming data from the serial port
* @param {string} data - The data received from the serial port
* @private
*/
processIncomingData(data) {
this.readBuffer += data;
if (this.debug) console.log("SerialCLI: Buffer:", JSON.stringify(this.readBuffer));
// Check if we're waiting for a response
if (this.currentCommand) {
this.checkForResponse();
} else if (this.commandQueue.length > 0) {
// If no current command but queue has items, try to execute next command
// This should ideally only happen after a response is fully processed
// or if the device sends unsolicited data.
if (this.debug) console.log("SerialCLI: Received data while idle, buffer:", JSON.stringify(this.readBuffer));
// Let's not automatically execute next command here, wait for command completion logic
}
}
/**
* Check if a complete response has been received
* @private
*/
checkForResponse() {
if (!this.currentCommand) return;
// --- Refined Response Parsing Logic ---
// A typical interaction looks like:
// 1. Send command: `my_command\r`
// 2. Device echoes: `my_command\r\n` (optional, depends on device)
// 3. Device processes and sends response: ` -> OK\r\n` or multi-line for log
// We need to find the "->" marker *after* the potential echo.
const { command, isLogCommand } = this.currentCommand;
const commandWithCR = command + '\r'; // Command as sent
const commandWithCRLF = command + '\r\n'; // Potential echo format
// Find the end of the command echo (could be with or without \n)
let echoEndIndex = this.readBuffer.indexOf(commandWithCRLF);
if (echoEndIndex !== -1) {
echoEndIndex += commandWithCRLF.length;
} else {
echoEndIndex = this.readBuffer.indexOf(commandWithCR);
if (echoEndIndex !== -1) {
echoEndIndex += commandWithCR.length;
} else {
// Command echo might not have arrived fully yet, or device doesn't echo
// Let's proceed cautiously, but this might lead to issues if echo is partial
echoEndIndex = 0; // Assume start of buffer if no echo found yet
if (this.debug) console.log("SerialCLI: Command echo not found yet or device doesn't echo.");
}
}
// Look for the response marker "->" *after* the potential echo
const responseMarker = " -> ";
const responseStartIndex = this.readBuffer.indexOf(responseMarker, echoEndIndex);
if (responseStartIndex === -1) {
if (this.debug) console.log("SerialCLI: Response marker '->' not found after echo index", echoEndIndex);
return; // Response marker not found yet
}
const responsePayloadStartIndex = responseStartIndex + responseMarker.length;
// Find the end of the response (\r\n)
// Search *after* the start of the response payload
const newlineIndex = this.readBuffer.indexOf('\r\n', responsePayloadStartIndex);
if (newlineIndex === -1) {
if (this.debug) console.log("SerialCLI: Response newline not found after payload start index", responsePayloadStartIndex);
return; // Full response line hasn't arrived
}
// Extract the response content
const responseLine = this.readBuffer.substring(responsePayloadStartIndex, newlineIndex).trim();
const consumedUntilIndex = newlineIndex + 2; // Include the \r\n
if (this.debug) console.log(`SerialCLI: Found response line: "${responseLine}"`);
// Special handling for log command which has multi-line response ending with EOF
if (isLogCommand) {
// For log, the first line might just be the confirmation, e.g., "-> OK" or similar.
// The actual log data follows, ending with " EOF\r\n"
const eofMarker = " EOF";
// Look for EOF *after* the initial response line we just found
const eofIndex = this.readBuffer.indexOf(eofMarker, consumedUntilIndex);
if (eofIndex !== -1) {
const eofNewlineIndex = this.readBuffer.indexOf('\r\n', eofIndex);
if (eofNewlineIndex !== -1) {
// Extract the log data between the first response line and the EOF marker
const logData = this.readBuffer.substring(consumedUntilIndex, eofIndex).trim();
const finalConsumedIndex = eofNewlineIndex + 2;
if (this.debug) console.log(`SerialCLI: Log EOF found. Log data length: ${logData.length}`);
this.readBuffer = this.readBuffer.substring(finalConsumedIndex); // Consume everything including EOF line
this.completeCommand(logData); // Resolve with the extracted log data
} else {
if (this.debug) console.log("SerialCLI: Log EOF marker found, but newline missing.");
}
} else {
if (this.debug) console.log("SerialCLI: Log command response started, waiting for EOF.");
}
} else {
// For standard commands, the single line is the response
this.readBuffer = this.readBuffer.substring(consumedUntilIndex); // Consume the processed part
this.completeCommand(responseLine); // Resolve with the single response line
}
if (this.debug) console.log("SerialCLI: Buffer after processing:", JSON.stringify(this.readBuffer));
}
/**
* Complete a command and resolve its promise with the response
* @param {string} response - The response from the device
* @private
*/
completeCommand(response) {
if (!this.currentCommand) return;
clearTimeout(this.currentCommand.timeout);
const { resolve, command } = this.currentCommand;
if (this.debug) console.log(`SerialCLI: Command "${command}" completed with response:`, response);
this.currentCommand = null;
resolve(response);
// Schedule next command execution after a delay
if (this.commandQueue.length > 0) {
if (this.debug) console.log(`SerialCLI: Scheduling next command in ${this.commandDelay}ms`);
setTimeout(() => this.executeNextCommand(), this.commandDelay);
} else {
if (this.debug) console.log("SerialCLI: Command queue empty.");
}
}
/**
* Execute the next command in the queue
* @private
*/
async executeNextCommand() {
// Prevent starting a new command if one is already in progress
if (this.currentCommand) {
if (this.debug) console.log("SerialCLI: executeNextCommand called, but a command is already active.");
return;
}
if (this.commandQueue.length === 0) {
if (this.debug) console.log("SerialCLI: executeNextCommand called, but queue is empty.");
return;
}
if (!this.writer) {
console.error("SerialCLI: Cannot execute command, writer is not available.");
// Reject the command? Or just log and wait? Let's reject.
const nextCmd = this.commandQueue.shift();
nextCmd.reject(new Error("Serial writer not available"));
// Check if more commands need rejecting or if we should stop.
if (this.commandQueue.length > 0) {
setTimeout(() => this.executeNextCommand(), this.commandDelay); // Process next potential rejection
}
return;
}
this.currentCommand = this.commandQueue.shift();
const { command, reject } = this.currentCommand;
if (this.debug) console.log(`SerialCLI: Executing command: "${command}"`);
// Set response timeout
this.currentCommand.timeout = setTimeout(() => {
if (this.currentCommand && this.currentCommand.command === command) { // Ensure it's still the same command
const timeoutMsg = `Command timeout: ${command}`;
console.error("SerialCLI:", timeoutMsg);
reject(new Error(timeoutMsg));
this.currentCommand = null; // Clear current command on timeout
// Try the next command after a delay
if (this.commandQueue.length > 0) {
if (this.debug) console.log("SerialCLI: Scheduling next command after timeout.");
setTimeout(() => this.executeNextCommand(), this.commandDelay);
}
}
}, this.responseTimeout);
try {
const dataToSend = this.encoder.encode(command + '\r');
if (this.debug) {
console.log("SerialCLI >>> SEND:", JSON.stringify(command + '\\r')); // Log data being sent
}
await this.writer.write(dataToSend);
} catch (error) {
console.error(`SerialCLI: Error writing command "${command}":`, error);
clearTimeout(this.currentCommand.timeout);
reject(error);
this.currentCommand = null; // Clear current command on write error
// Try the next command after a delay
if (this.commandQueue.length > 0) {
if (this.debug) console.log("SerialCLI: Scheduling next command after write error.");
setTimeout(() => this.executeNextCommand(), this.commandDelay);
}
}
}
/**
* Send a command to the device
* @param {string} command - The command to send
* @param {boolean} isLogCommand - Whether this is a log command with multi-line response ending in EOF
* @returns {Promise<string>} The device's response
*/
sendCommand(command, isLogCommand = false) {
if (!this.port || !this.writer) {
const errorMsg = 'Serial connection not open or writer unavailable';
console.error("SerialCLI:", errorMsg);
return Promise.reject(new Error(errorMsg));
}
return new Promise((resolve, reject) => {
this.commandQueue.push({ command, resolve, reject, isLogCommand });
if (this.debug) console.log(`SerialCLI: Queued command: "${command}". Queue length: ${this.commandQueue.length}`);
// If no current command is active, start execution immediately
if (!this.currentCommand) {
if (this.debug) console.log("SerialCLI: Triggering command execution from sendCommand.");
this.executeNextCommand();
}
});
}
// ============= CONVENIENCE METHODS =============
/**
* Get the device firmware version
* @returns {Promise<string>} Version information
*/
async getVersion() {
return this.sendCommand('ver');
}
/**
* Get the current clock time
* @returns {Promise<string>} Current time
*/
async getClock() {
return this.sendCommand('clock');
}
/**
* Set the time (in epoch seconds)
* @param {number} seconds - Epoch seconds
* @returns {Promise<string>} Response from device
*/
async setTime(seconds) {
// Ensure seconds is a valid number
if (typeof seconds !== 'number' || !Number.isInteger(seconds) || seconds < 0) {
return Promise.reject(new Error("Invalid time value. Must be a non-negative integer."));
}
return this.sendCommand(`time ${seconds}`);
}
/**
* Reboot the device
* @returns {Promise<string>} Response from device (Note: response might not be received if reboot is immediate)
*/
async reboot() {
// Don't necessarily expect a standard response format for reboot
// Consider adding a short delay after sending if needed by the calling code
return this.sendCommand('reboot');
}
/**
* Erase filesystem (factory reset)
* @returns {Promise<string>} Response from device
*/
async erase() {
return this.sendCommand('erase');
}
/**
* Force device to send an advertisement
* @returns {Promise<string>} Response from device
*/
async sendAdvert() {
return this.sendCommand('advert');
}
/**
* Start OTA update
* @returns {Promise<string>} Response from device
*/
async startOTA() {
// Might need specific node name from prefs? The C++ code suggests yes.
// This JS version doesn't store prefs, so we send the basic command.
// Consider adding a parameter if the node name is needed.
return this.sendCommand('start ota');
}
/**
* Get a variable value
* @param {string} variable - The variable name (e.g., 'name', 'lat', 'tx')
* @returns {Promise<string>} Raw variable value string from device (e.g., "> MyNode", "> 10", "> 433.125")
*/
async getVariable(variable) {
return this.sendCommand(`get ${variable}`);
}
/**
* Set a variable value
* @param {string} variable - The variable name
* @param {string|number|boolean} value - The value to set
* @returns {Promise<string>} Response from device (usually "OK" or an error)
*/
async setVariable(variable, value) {
// Convert boolean 'true'/'false' to 'on'/'off' if appropriate for specific vars later
return this.sendCommand(`set ${variable} ${value}`);
}
/**
* Set admin password
* @param {string} password - Admin password
* @returns {Promise<string>} Response from device
*/
async setPassword(password) {
// Basic validation: ensure password is a non-empty string
if (typeof password !== 'string' || password.length === 0) {
return Promise.reject(new Error("Password cannot be empty."));
}
// Potentially add checks for invalid characters if needed
return this.sendCommand(`password ${password}`);
}
/**
* Retrieve log data
* @returns {Promise<string>} Log contents (multi-line string)
*/
async getLog() {
return this.sendCommand('log', true); // Mark as log command for multi-line EOF handling
}
/**
* Start logging
* @returns {Promise<string>} Response from device
*/
async startLogging() {
return this.sendCommand('log start');
}
/**
* Stop logging
* @returns {Promise<string>} Response from device
*/
async stopLogging() {
return this.sendCommand('log stop');
}
/**
* Erase the log file
* @returns {Promise<string>} Response from device
*/
async eraseLog() {
return this.sendCommand('log erase');
}
/**
* Parse response from getVariable commands, removing the "> " prefix and attempting type conversion.
* @param {string} response - The raw response string from a getVariable command (e.g., "> MyNode", "> 10", "> on")
* @returns {string|number|boolean|null} The parsed value, or null if parsing fails or response format is unexpected.
*/
parseVariableResponse(response) {
if (typeof response !== 'string' || !response.startsWith('> ')) {
if(this.debug) console.warn(`SerialCLI: Unexpected format for parseVariableResponse: "${response}"`);
return null; // Or return the original response? Returning null indicates parsing issue.
}
const value = response.substring(2).trim(); // Remove "> " and trim whitespace
// Check for empty value after prefix removal
if (value === '') {
return ''; // Return empty string if that was the actual value
}
// Try to parse as number (integer or float)
// Updated regex to handle negative numbers and ensure it's the *entire* string
if (/^-?\d+(\.\d+)?$/.test(value)) {
return Number(value); // Use Number() to handle both int and float
}
// Handle boolean 'on'/'off' (case-insensitive)
if (value.toLowerCase() === 'on') return true;
if (value.toLowerCase() === 'off') return false;
// Return as string for all other cases
return value;
}
// ============= SPECIFIC VARIABLE GETTERS/SETTERS (using parseVariableResponse) =============
async getRole() {
const response = await this.getVariable('role');
return this.parseVariableResponse(response);
}
async getPubKey() {
const response = await this.getVariable('public.key');
return this.parseVariableResponse(response);
}
async getName() {
const response = await this.getVariable('name');
return this.parseVariableResponse(response);
}
async setName(name) {
if (typeof name !== 'string') return Promise.reject(new Error("Name must be a string."));
// Add validation for length or characters based on device limits if known
return this.setVariable('name', name);
}
async getLatitude() {
const response = await this.getVariable('lat');
return this.parseVariableResponse(response);
}
async setLatitude(lat) {
if (typeof lat !== 'number') return Promise.reject(new Error("Latitude must be a number."));
// Add validation for range (-90 to 90) if needed
return this.setVariable('lat', lat);
}
async getLongitude() {
const response = await this.getVariable('lon');
return this.parseVariableResponse(response);
}
async setLongitude(lon) {
if (typeof lon !== 'number') return Promise.reject(new Error("Longitude must be a number."));
// Add validation for range (-180 to 180) if needed
return this.setVariable('lon', lon);
}
async getRadioConfig() {
const response = await this.getVariable('radio');
const parsed = this.parseVariableResponse(response);
if (typeof parsed === 'string') {
const parts = parsed.split(',');
if (parts.length === 4) {
return {
freq: parseFloat(parts[0]) || null,
bw: parseFloat(parts[1]) || null,
sf: parseInt(parts[2], 10) || null,
cr: parseInt(parts[3], 10) || null
};
}
}
if (this.debug) console.warn("SerialCLI: Could not parse radio config response:", response);
return null; // Indicate parsing failure
}
async setRadioConfig(freq, bw, sf, cr) {
// Add validation for types and ranges if necessary
if (typeof freq !== 'number' || typeof bw !== 'number' || typeof sf !== 'number' || typeof cr !== 'number') {
return Promise.reject(new Error("Invalid radio parameters. All must be numbers."));
}
return this.setVariable('radio', `${freq},${bw},${sf},${cr}`);
}
async getTxPower() {
const response = await this.getVariable('tx');
return this.parseVariableResponse(response);
}
async setTxPower(power) {
if (typeof power !== 'number') return Promise.reject(new Error("TX Power must be a number."));
// Add validation for range based on device capabilities if known (e.g., 1-30)
return this.setVariable('tx', power);
}
async getAirtimeFactor() {
const response = await this.getVariable('af');
return this.parseVariableResponse(response);
}
async setAirtimeFactor(factor) {
if (typeof factor !== 'number') return Promise.reject(new Error("Airtime factor must be a number."));
// Add validation for range (e.g., 0-9)
return this.setVariable('af', factor);
}
async getRepeat() {
const response = await this.getVariable('repeat');
return this.parseVariableResponse(response); // Should return true/false
}
async setRepeat(enabled) {
if (typeof enabled !== 'boolean') return Promise.reject(new Error("Repeat value must be boolean (true/false)."));
return this.setVariable('repeat', enabled ? 'on' : 'off');
}
// Note: 'allow.read.only' is not in the C++ code provided, assuming it might exist elsewhere or is hypothetical.
// If it exists and uses 'on'/'off', the pattern is the same as 'setRepeat'.
// async getAllowReadOnly() { ... }
// async setAllowReadOnly(enabled) { ... }
async getAdvertInterval() {
// C++ stores as interval/2, retrieves as interval*2 (minutes)
const response = await this.getVariable('advert.interval');
return this.parseVariableResponse(response);
}
async setAdvertInterval(minutes) {
if (typeof minutes !== 'number' || !Number.isInteger(minutes)) return Promise.reject(new Error("Advert interval must be an integer (minutes)."));
// Add validation based on C++ code (min 60, max 240, or 0 for off)
if (minutes !== 0 && (minutes < 60 || minutes > 240)) {
return Promise.reject(new Error("Advert interval must be 0 (off) or between 60 and 240 minutes."));
}
return this.setVariable('advert.interval', minutes);
}
// Note: 'flood.advert.interval' is not in the C++ code provided.
// async getFloodAdvertInterval() { ... }
// async setFloodAdvertInterval(hours) { ... }
async getGuestPassword() {
const response = await this.getVariable('guest.password');
return this.parseVariableResponse(response);
}
async setGuestPassword(password) {
if (typeof password !== 'string') return Promise.reject(new Error("Guest password must be a string."));
// Consider adding length/character validation
return this.setVariable('guest.password', password);
}
async getRxDelay() {
const response = await this.getVariable('rxdelay');
return this.parseVariableResponse(response);
}
async setRxDelay(delay) {
if (typeof delay !== 'number' || delay < 0) return Promise.reject(new Error("RX Delay must be a non-negative number."));
// Add validation for range (e.g., 0-20)
return this.setVariable('rxdelay', delay);
}
async getTxDelay() {
const response = await this.getVariable('txdelay');
return this.parseVariableResponse(response);
}
async setTxDelay(delay) {
if (typeof delay !== 'number' || delay < 0) return Promise.reject(new Error("TX Delay factor must be a non-negative number."));
// Add validation for range (e.g., 0-2)
return this.setVariable('txdelay', delay);
}
async getDirectTxDelay() {
const response = await this.getVariable('direct.txdelay');
return this.parseVariableResponse(response);
}
async setDirectTxDelay(delay) {
if (typeof delay !== 'number' || delay < 0) return Promise.reject(new Error("Direct TX Delay factor must be a non-negative number."));
// Add validation for range (e.g., 0-2)
return this.setVariable('direct.txdelay', delay);
}
async getFloodMax() {
const response = await this.getVariable('flood.max');
return this.parseVariableResponse(response);
}
async setFloodMax(max) {
if (typeof max !== 'number' || !Number.isInteger(max) || max < 0 || max > 64) {
return Promise.reject(new Error("Flood Max must be an integer between 0 and 64."));
}
return this.setVariable('flood.max', max);
}
}

View File

@@ -0,0 +1,118 @@
export class VanityKeyGenerator {
constructor() {
this.workers = [];
this.running = false;
this._resolve = null;
this._reject = null;
this._totalAttempts = 0;
this.onProgress = null;
}
static get numCores() {
return navigator.hardwareConcurrency || 4;
}
/**
* Estimate time for a given hex prefix length
* @param {number} prefixLen - number of hex chars
* @param {number} keysPerSec - estimated throughput
* @returns {string} human-readable estimate
*/
static estimateTime(prefixLen, keysPerSec) {
if (prefixLen === 0) return 'instant';
const expected = Math.pow(16, prefixLen);
const seconds = expected / keysPerSec;
if (seconds < 1) return 'less than a second';
if (seconds < 60) return `~${Math.ceil(seconds)} seconds`;
if (seconds < 3600) return `~${Math.ceil(seconds / 60)} minutes`;
if (seconds < 86400) return `~${Math.ceil(seconds / 3600)} hours`;
return `~${Math.ceil(seconds / 86400)} days`;
}
get attempts() {
return this._totalAttempts;
}
/**
* Start generating a vanity key
* @param {string} prefix - hex prefix to match (1-6 chars)
* @returns {Promise<{ privKey: string, pubKey: string, attempts: number } | null>}
*/
generate(prefix) {
if (this.running) throw new Error('Already running');
prefix = prefix.replace(/[^0-9a-fA-F]/g, '');
if (prefix.length === 0 || prefix.length > 6) {
throw new Error('Prefix must be 1-6 hex characters');
}
this.running = true;
this._totalAttempts = 0;
const numWorkers = VanityKeyGenerator.numCores;
return new Promise((resolve, reject) => {
this._resolve = resolve;
this._reject = reject;
for (let i = 0; i < numWorkers; i++) {
const worker = new Worker(
new URL('./vanity-key-worker.js', import.meta.url),
{ type: 'module' }
);
worker.onmessage = (e) => {
if (!this.running) return;
const data = e.data;
if (data.type === 'progress') {
this._totalAttempts += data.attempts;
if (this.onProgress) this.onProgress(this._totalAttempts);
} else if (data.type === 'match') {
this._totalAttempts += data.attempts;
const result = {
privKey: data.privKey,
pubKey: data.pubKey,
attempts: this._totalAttempts,
};
this._stopWorkers();
this.running = false;
resolve(result);
} else if (data.type === 'error') {
this._stopWorkers();
this.running = false;
reject(new Error(data.message));
} else if (data.type === 'stopped') {
this._totalAttempts += data.attempts;
}
};
worker.onerror = (err) => {
this._stopWorkers();
this.running = false;
reject(new Error(err.message || 'Worker error'));
};
worker.postMessage({ type: 'start', prefix, progressInterval: 200 });
this.workers.push(worker);
}
});
}
_stopWorkers() {
for (const worker of this.workers) {
try { worker.postMessage({ type: 'stop' }); } catch (e) {}
setTimeout(() => worker.terminate(), 500);
}
this.workers = [];
}
cancel() {
const reject = this._reject;
this._stopWorkers();
this.running = false;
if (reject) reject(new Error('Cancelled'));
this._resolve = null;
this._reject = null;
}
}

1
flasher/lib/zip.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""
PlatformIO extra script: generate DFU .zip after build.
Add to platformio.ini:
extra_scripts = post:flasher/pio_create_dfu_zip.py
"""
import sys
import os
import json
import struct
import zipfile
# Add flasher dir to path for importing the main module
sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
try:
from create_dfu_zip import hex_to_bin, uf2_to_bin, create_dfu_zip
except ImportError:
# Fallback: define minimal conversion inline
def hex_to_bin(hex_path):
from intelhex import IntelHex
ih = IntelHex(hex_path)
min_addr = ih.minaddr() or 0
max_addr = ih.maxaddr() or 0
size = max_addr - min_addr + 1
page_size = 0x1000
aligned = ((size + page_size - 1) // page_size) * page_size
return bytes(ih.tobinarray(start=min_addr, size=aligned)), min_addr
def create_dfu_zip(bin_data, base_addr, fw_version=1, hw_version=52):
init_packet = bytearray()
init_packet.append(0x01)
init_packet.append(0x04)
init_packet += struct.pack('<I', 0xFFFE)
init_packet += struct.pack('<I', 0xFFFFFFFF)
init_packet += struct.pack('<I', hw_version)
init_packet += struct.pack('<I', fw_version)
init_packet += struct.pack('<II', 0, 0)
manifest = {
"manifest": {
"application": {
"bin_file": "firmware.bin",
"dat_file": "firmware.dat",
"init_packet_data": {
"fw_version": fw_version,
"hw_version": hw_version,
"softdevice_req": [0xFFFE],
"components": [{"data": list(init_packet)}]
}
}
}
}
return manifest, bin_data, bytes(init_packet)
def create_dfu_zip_from_build(target, source, env):
"""PlatformIO post-build hook."""
build_dir = env.subst("$BUILD_DIR")
prog_name = env.subst("$PROGNAME")
hex_file = os.path.join(build_dir, prog_name + ".hex")
if not os.path.isfile(hex_file):
print(f" [DFU] {hex_file} not found, skipping")
return
try:
bin_data, base_addr = hex_to_bin(hex_file)
except Exception as e:
print(f" [DFU] Error: {e}")
return
manifest, fw_bin, fw_dat = create_dfu_zip(bin_data, base_addr)
# Name the zip after the build target (environment name)
env_name = env.subst("$PIOENV")
# Extract firmware name from env: e.g. Heltec_t114_without_display_beacon_sensor_ble -> Heltec_T114_Beacon_BLE
parts = env_name.split("_")
if "beacon" in parts:
fw_name = "Heltec_T114_Beacon"
elif "companion" in parts:
fw_name = "Heltec_T114_Companion_Radio"
elif "repeater" in parts:
fw_name = "Heltec_T114_Repeater"
elif "room" in parts:
fw_name = "Heltec_T114_Room_Server"
else:
fw_name = "firmware"
if "ble" in parts:
fw_name += "_BLE"
elif "usb" in parts:
fw_name += "_USB"
zip_name = f"{fw_name}.dfu.zip"
zip_path = os.path.join(build_dir, zip_name)
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
zf.writestr("manifest.json", json.dumps(manifest, indent=2))
zf.writestr("firmware.bin", fw_bin)
zf.writestr("firmware.dat", fw_dat)
size = os.path.getsize(zip_path)
print(f" [DFU] Created: {zip_path} ({size / 1024:.0f} KB)")
# Register hook
Import("env")
env.AddPostAction("$BUILD_DIR/${PROGNAME}.hex", create_dfu_zip_from_build)
# Also trigger after .uf2 creation if that action exists
try:
env.AddPostAction("$BUILD_DIR/${PROGNAME}.uf2", create_dfu_zip_from_build)
except:
pass

25
flasher/releases.json Normal file
View File

@@ -0,0 +1,25 @@
[
{
"id": 1,
"tag_name": "beacon-v1.0.0",
"name": "Beacon Sensor v1.0.0",
"body": "Прошивка Beacon Sensor для Heltec T114\n\n- Автоматическая отправка flood-объявления каждые 15 минут\n- ADV_TYPE_CHAT с ретрансляцией\n- Поддержка BMP280 по I2C (Wire1, SDA=7, SCL=8)\n- BLE (пин 123456)\n- Радиопараметры: 868.731 MHz, SF7, BW62.5, CR7\n\n## Сборка\npio run -e Heltec_t114_without_display_beacon_sensor_ble -t create_uf2\n\n## Прошивка (UF2)\n1. Зажми BOOT на T114, подключи USB\n2. Перетащи firmware.uf2 на диск T114",
"published_at": "2026-06-05T06:36:40Z",
"assets": [
{
"id": 1,
"name": "Heltec_T114_Beacon_BLE.uf2",
"size": 887296,
"download_count": 12,
"browser_download_url": "./releases/Heltec_T114_Beacon_BLE.uf2"
},
{
"id": 2,
"name": "Heltec_T114_Beacon_BLE.dfu.zip",
"size": 305769,
"download_count": 3,
"browser_download_url": "./releases/Heltec_T114_Beacon_BLE.dfu.zip"
}
]
}
]

Binary file not shown.

View File

@@ -121,6 +121,7 @@ lib_deps =
extends = Heltec_t114 extends = Heltec_t114
board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld
board_upload.maximum_size = 712704 board_upload.maximum_size = 712704
extra_scripts = post:flasher/pio_create_dfu_zip.py
build_flags = build_flags =
${Heltec_t114.build_flags} ${Heltec_t114.build_flags}
-I examples/beacon_sensor/ui-new -I examples/beacon_sensor/ui-new
@@ -170,6 +171,7 @@ lib_deps =
extends = Heltec_t114 extends = Heltec_t114
board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld
board_upload.maximum_size = 712704 board_upload.maximum_size = 712704
extra_scripts = post:flasher/pio_create_dfu_zip.py
build_flags = build_flags =
${Heltec_t114.build_flags} ${Heltec_t114.build_flags}
-I examples/beacon_sensor/ui-new -I examples/beacon_sensor/ui-new