flasher.meshcore.dev/flasher.js
Rastislav Vysoky e023661bbc
Merge pull request #3 from Xinayder/firefox-webserial
Check if window.navigator.serial exists for setting flashing.supported
2026-01-24 00:47:08 +01:00

534 lines
15 KiB
JavaScript

import "./lib/beer.min.js";
import { createApp, reactive, ref, nextTick, watch, computed } from "./lib/vue.min.js";
import { Dfu } from "./lib/dfu.js";
import { ESPLoader, Transport, HardReset } from "./lib/esp32.js";
import { SerialConsole } from './lib/console.js';
const configRes = await fetch('./config.json');
const config = await configRes.json();
const githubRes = await fetch('/releases');
const github = await githubRes.json();
const commandReference = {
'time ': 'Set time {epoch-secs}',
'erase': 'Erase filesystem',
'advert': 'Send Advertisment packet',
'reboot': 'Reboot device',
'clock': 'Display current time',
'password ': 'Set new password',
'log': 'Ouput log',
'log start': 'Start packet logging to file system',
'log stop': 'Stop packet logging to file system',
'log erase': 'Erase the packet logs from file system',
'ver': 'Show device version',
'set freq ': 'Set frequency {Mhz}',
'set af ': 'Set Air-time factor',
'set tx ': 'Set Tx power {dBm}',
'set repeat ': 'Set repeater mode {on|off}',
'set advert.interval ': 'Set advert rebroadcast interval {minutes}',
'set guest.password ': 'Set guest password',
'set name ': 'Set advertisement name',
'set lat': 'Set the advertisement map latitude',
'set lon': 'Set the advertisement map longitude',
'get freq ': 'Get frequency (Mhz)',
'get af': 'Get Air-time factor',
'get tx': 'Get Tx power (dBm)',
'get repeat': 'Get repeater mode',
'get advert.interval': 'Get advert rebroadcast interval (minutes)',
'get name': 'Get advertisement name',
'get lat': 'Get the advertisement map latitude',
'get lon': 'Get the advertisement map longitude',
};
async function delay(milis) {
return await new Promise((resolve) => setTimeout(resolve, milis));
}
function getGithubReleases(roleType, files) {
const versions = {};
for(const [fileType, matchRE] of Object.entries(files)) {
for(const versionType of github) {
if(versionType.type !== roleType) { continue }
const version = versions[versionType.version] ??= {
notes: versionType.notes,
files: []
};
for(const file of versionType.files) {
if(!new RegExp(matchRE).test(file.name)) { continue }
version.files.push({
type: fileType,
name: file.url,
title: file.name,
})
}
}
}
return versions;
}
function addGithubFiles() {
for(const device of config.device) {
for(const firmware of device.firmware) {
const gDef = firmware.github;
if(!gDef?.files) { continue }
firmware.version = getGithubReleases(gDef.type, gDef.files);
// clean versions without files
for(const [verName, verValue] of Object.entries(firmware.version)) {
if(verValue.files.length === 0) delete firmware.version[verName]
}
}
}
config.device = config.device.filter(device => device.firmware.some(firmware => Object.keys(firmware.version).length > 0 ));
return config;
}
async function digestMessage(message) {
const msgUint8 = new TextEncoder().encode(message); // encode as (utf-8) Uint8Array
const hashBuffer = await window.crypto.subtle.digest("SHA-256", msgUint8); // hash the message
const hashArray = Array.from(new Uint8Array(hashBuffer)); // convert buffer to byte array
const hashHex = hashArray
.map((b) => b.toString(16).padStart(2, "0"))
.join(""); // convert bytes to hex string
return hashHex;
}
async function blobToBinaryString(blob) {
const bytes = new Uint8Array(await blob.arrayBuffer())
let binString = '';
for (let i = 0; i < bytes.length; i++) {
binString += String.fromCharCode(bytes[i]);
}
return binString;
}
console.log(addGithubFiles());
function setup() {
const consoleEditBox = ref();
const consoleWindow = ref();
const deviceFilterText = ref('');
const snackbar = reactive({
text: '',
class: '',
icon: '',
});
const selected = reactive({
device: null,
firmware: null,
version: null,
wipe: false,
nrfEraserFlashingPercent: 0,
nrfEraserFlashing: false,
port: null,
});
const getRoleFwValue = (firmware, key) => {
const role = config.role[firmware.role] ?? {};
return firmware[key] ?? role[key] ?? '';
}
const getSelFwValue = (key) => {
const fwVersion = selected.firmware.version[selected.version];
return fwVersion ? fwVersion[key] || '' : '';
}
const flashing = reactive({
supported: 'Serial' in window || 'serial' in window.navigator,
instance: null,
locked: false,
percent: 0,
log: '',
error: '',
dfuComplete: false,
});
const serialCon = reactive({
instance: null,
opened: false,
content: '',
edit: '',
});
window.app = { selected, flashing, serialCon };
const log = {
clean() { flashing.log = '' },
write(data) { flashing.log += data },
writeLine(data) { flashing.log += data + '\n' }
};
const retry = async() => {
flashing.active = false;
flashing.log = '';
flashing.error = '';
flashing.dfuComplete = false;
flashing.percent = 0;
if(flashing.instance instanceof ESPLoader) {
await flashing.instance?.hr.reset();
await flashing.instance?.transport?.disconnect();
}
}
const close = () => {
location.reload()
}
const getFirmwarePath = (file) => {
return file.name.startsWith('/') ? file.name : `${config.staticPath}/${file.name}`;
}
const firmwareHasData = (firmware) => {
const firstVersion = Object.keys(firmware.version)[0];
if(!firstVersion) return false;
return firmware.version[firstVersion].files.length > 0;
}
const stepBack = () => {
if(selected.device && selected.firmware) {
if(selected.firmware.version[selected.version].customFile) {
selected.firmware = null;
selected.device = null;
return
}
selected.firmware = null;
return;
}
if(selected.device) {
selected.device = null;
}
}
watch(() => selected.firmware, (firmware) => {
if(firmware == null) return;
selected.version = Object.keys(firmware.version)[0];
});
const flasherCleanup = async () => {
flashing.active = false;
flashing.log = '';
flashing.error = '';
flashing.dfuComplete = false;
flashing.percent = 0;
selected.firmware = null;
selected.version = null;
selected.wipe = false;
selected.device = null;
selected.nrfEraserFlashingPercent = 0;
selected.nrfEraserFlashing = false;
if(flashing.instance instanceof ESPLoader) {
await flashing.instance?.hr.reset();
await flashing.instance?.transport?.disconnect();
}
else if(flashing.instance instanceof Dfu) {
try {
flashing.instance.port.close()
}
catch(e) {
console.error(e);
}
}
flashing.instance = null;
}
const openSerialGUI = () => {
window.open('https://config.meshcore.dev','meshcore_config','directories=no,titlebar=no,toolbar=no,location=no,status=no,menubar=no,scrollbars=no,resizable=no,width=1000,height=800');
}
const openSerialCon = async() => {
const port = selected.port = await navigator.serial.requestPort();
const serialConsole = serialCon.instance = new SerialConsole(port);
serialCon.content = '-------------------------------------------------------------------------\n';
serialCon.content += 'Welcome to MeshCore serial console.\n'
serialCon.content += 'Click on the cursor to get all supported commands.\n';
serialCon.content += '-------------------------------------------------------------------------\n\n';
serialConsole.onOutput = (text) => {
serialCon.content += text;
};
serialConsole.connect();
serialCon.opened = true;
await nextTick();
consoleEditBox.value.focus();
}
const closeSerialCon = async() => {
serialCon.opened = false;
await serialCon.instance.disconnect();
}
const sendCommand = async(text) => {
const consoleEl = consoleWindow.value;
serialCon.edit = '';
await serialCon.instance.sendCommand(text);
setTimeout(() => consoleEl.scrollTop = consoleEl.scrollHeight, 100);
}
const dfuMode = async() => {
await Dfu.forceDfuMode(await navigator.serial.requestPort({}))
flashing.dfuComplete = true;
}
const customFirmwareLoad = async(ev) => {
const firmwareFile = ev.target.files[0];
const type = firmwareFile.name.endsWith('.bin') ? 'esp32' : 'nrf52';
selected.device = {
name: 'Custom device',
type,
};
if(firmwareFile.name.endsWith('-merged.bin')) {
alert(
'You selected custom file that ends with "merged.bin".'+
'This will erase your flash! Proceed with caution.'+
'If you want just to update your firmware, please use non-merged bin.'
);
selected.wipe = true;
}
selected.firmware = {
icon: 'unknown_document',
title: firmwareFile.name,
version: {},
}
selected.version = firmwareFile.name;
selected.firmware.version[selected.version] = {
customFile: true,
files: [{ type: 'flash', file: firmwareFile }]
}
}
const espReset = async(t) => {
await t.setRTS(true);
await delay(100)
await t.setRTS(false);
}
const nrfErase = async() => {
if(!(selected.device.type === 'nrf52' && selected.device.erase)) {
console.error('nRF erase called for non-nrf device or device.erase is not defined')
return;
}
const url = `${config.staticPath}/${selected.device.erase}`;
console.log('downloading: ' + url);
const resp = await fetch(url);
if(resp.status !== 200) {
alert(`Could not download the firmware file from the server, reported: HTTP ${resp.status}.\nPlease try again.`)
return;
}
const flashData = await resp.blob();
const port = selected.port = await navigator.serial.requestPort({});
const dfu = new Dfu(port);
try {
selected.nrfEraserFlashing = true;
await dfu.dfuUpdate(flashData, async (progress) => {
selected.nrfEraserFlashingPercent = progress;
if(progress === 100 && selected.nrfEraserFlashing) {
selected.nrfEraserFlashing = false;
selected.dfuComplete = false;
setTimeout(() => {
alert('Device erase firmware has been flashed and flash has been erased.\nYou can flash MeshCore now.');
}, 200);
}
}, 60000);
}
catch(e) {
alert(`nRF flashing erase firmware failed: ${e}.\nDid you put the device into DFU mode before attempting erasing?`);
selected.nrfEraserFlashing = false;
selected.nrfEraserFlashingPercent = 0;
return;
}
}
const canFlash = (device) => {
return device.type !== 'noflash'
}
const flashDevice = async() => {
const device = selected.device;
const firmware = selected.firmware.version[selected.version];
const flashFiles = firmware.files.filter(f => f.type.startsWith('flash'));
if(!flashFiles[0]) {
alert('Cannot find configuration for flash file! please report this to Discord.')
flasherCleanup();
return;
}
console.log({flashFiles});
let flashData;
if(flashFiles[0].file) {
flashData = flashFiles[0].file;
} else {
let flashFile;
if(device.type === 'esp32') {
flashFile = flashFiles.find(f => f.type === (selected.wipe ? 'flash-wipe' : 'flash-update'));
}
else {
flashFile = flashFiles[0];
}
console.log({flashFiles, flashFile});
const url = getFirmwarePath(flashFile);
console.log('downloading: ' + url);
const resp = await fetch(url);
if(resp.status !== 200) {
alert(`Could not download the firmware file from the server, reported: HTTP ${resp.status}.\nPlease try again.`)
return;
}
flashData = await resp.blob();
}
const port = selected.port = await navigator.serial.requestPort({});
if(device.type === 'esp32') {
let esploader;
let transport;
const flashOptions = {
terminal: log,
compress: true,
eraseAll: selected.wipe,
flashSize: 'keep',
flashMode: 'keep',
flashFreq: 'keep',
baudrate: 115200,
romBaudrate: 115200,
enableTracing: false,
fileArray: [{
data: await blobToBinaryString(flashData),
address: selected.wipe ? 0x00000 : 0x10000
}],
reportProgress: async (_, written, total) => {
flashing.percent = (written / total) * 100;
},
};
try {
flashing.active = true;
transport = new Transport(port, true);
flashOptions.transport = transport;
flashing.instance = esploader = new ESPLoader(flashOptions);
esploader.hr = new HardReset(transport);
await esploader.main();
await esploader.flashId();
}
catch(e) {
console.error(e);
flashing.error = `Failed to initialize. Did you place the device into firmware download mode? Detail: ${e}`;
esploader = null;
return;
}
try {
await esploader.writeFlash(flashOptions);
await delay(100);
await esploader.after('hard_reset');
await delay(100);
await espReset(transport);
await transport.disconnect();
}
catch(e) {
console.error(e);
flashing.error = `ESP32 flashing failed: ${e}`;
await espReset(transport);
await transport.disconnect();
return;
}
}
else if(device.type === 'nrf52') {
const dfu = flashing.instance = new Dfu(port);
flashing.active = true;
try {
await dfu.dfuUpdate(flashData, async (progress) => {
flashing.percent = progress;
}, 60000);
}
catch(e) {
console.error(e);
flashing.error = `nRF flashing failed: ${e}. Please reset the device and try again.`;
return;
}
}
};
const devices = computed(() => {
const classSortPrefix = (d) => d.class === 'ripple' ? '1' : '2';
const deviceGroups = {};
for(const cls of ['ripple', 'community']) {
const devices = config.device.toSorted(
(a, b) => (classSortPrefix(a) + a.maker + a.name).localeCompare(classSortPrefix(b) + b.maker + b.name)
).filter(
d => d.class === cls && (deviceFilterText.value == '' || d.name.toLowerCase().includes(deviceFilterText.value?.toLowerCase()))
)
if(devices.length > 0) deviceGroups[cls] = devices;
}
return deviceGroups;
});
const showMessage = (text, icon, displayMs) => {
snackbar.class = 'active';
snackbar.text = text;
snackbar.icon = icon || '';
setTimeout(() => {
snackbar.icon = '';
snackbar.text = '';
snackbar.class = '';
}, displayMs || 2000);
}
const consoleMouseUp = (ev) => {
if(window.getSelection().toString().length) {
navigator.clipboard.writeText(window.getSelection().toString())
showMessage('text copied to clipboard');
}
consoleEditBox.value.focus();
}
return {
snackbar,
consoleEditBox, consoleWindow, consoleMouseUp,
config, devices, selected, flashing, deviceFilterText,
flashDevice, flasherCleanup, dfuMode,
serialCon, closeSerialCon, openSerialCon,
sendCommand, openSerialGUI,
retry, close, commandReference,
stepBack,
customFirmwareLoad, getFirmwarePath, getSelFwValue, getRoleFwValue,
firmwareHasData,
canFlash, nrfErase
}
}
createApp({ setup }).mount('#app');