Files
MeshCore-Posadmesh/posadmesh-tools/flasher/app.js
T

534 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ESPLoader, Transport } from './vendor/esptool-js/bundle.js';
const $ = (id) => document.getElementById(id);
const logEl = $('log');
let index = null;
let manifestStamp = ''; // generated_at манифеста, который сейчас в памяти
let selected = null; // выбранный файл из index.json
let ownFile = null; // файл, загруженный пользователем с компьютера
let transport = null;
let loader = null;
let connected = false;
let busy = false;
let chipName = '—';
// ---------------------------------------------------------------- журнал
function log(msg, cls = '') {
const t = new Date().toLocaleTimeString('ru-RU');
const line = document.createElement('div');
if (cls) line.className = cls;
line.textContent = `[${t}] ${msg}`;
logEl.appendChild(line);
logEl.scrollTop = logEl.scrollHeight;
}
// ---------------------------------------------------------------- устаревшая страница
// Страница, открытая давно, держит манифест в памяти: после выкладки новой
// сборки его ссылки ведут на удалённые файлы и скачивание молча не работает.
// Показываем баннер и предлагаем обновить страницу.
function showStale(msg) {
const box = $('stale');
if (!box) return;
if (msg) $('stale-text').textContent = msg;
if (box.hidden) {
box.hidden = false;
log('Доступна новая версия прошивальщика — обновите страницу.', 'warn');
}
}
// Сверяет generated_at манифеста в памяти со свежим ответом сервера.
async function checkFreshness() {
if (!manifestStamp) return false;
try {
const res = await fetch('index.json', { cache: 'no-store' });
if (!res.ok) return false;
const fresh = await res.json();
if (fresh.generated_at && fresh.generated_at !== manifestStamp) {
showStale();
return true;
}
} catch (e) { /* сеть недоступна — молчим, это не ошибка страницы */ }
return false;
}
function setProgress(ratio, label = '') {
const pct = Math.max(0, Math.min(1, ratio || 0)) * 100;
$('bar').style.width = pct.toFixed(1) + '%';
$('progress-label').textContent = label || (pct > 0 ? pct.toFixed(0) + ' %' : '');
}
function human(bytes) {
if (bytes >= 1048576) return (bytes / 1048576).toFixed(2) + ' МБ';
if (bytes >= 1024) return (bytes / 1024).toFixed(0) + ' КБ';
return bytes + ' Б';
}
// ---------------------------------------------------------------- проверки
(function checkEnvironment() {
const secure = window.isSecureContext;
const serial = 'serial' in navigator;
const bs = $('b-secure');
bs.textContent = secure ? 'защищённый контекст: ок' : 'нужен HTTPS';
bs.className = 'badge ' + (secure ? 'ok' : 'err');
const bl = $('b-serial');
if (serial) {
bl.textContent = 'Web Serial поддерживается';
bl.className = 'badge ok';
} else {
bl.textContent = 'Web Serial недоступен — нужен Chrome или Edge 89+';
bl.className = 'badge err';
}
})();
// ---------------------------------------------------------------- отрисовка
function fileRow(section, board, file) {
const row = document.createElement('div');
row.className = 'file';
row.dataset.key = file.repo + '/' + file.tag + '/' + file.name;
const fmtLabel = { merged: 'merged, 0x0', app: '.bin, 0x10000', uf2: 'uf2', zip: 'zip' }[file.format] || file.format;
row.innerHTML = `
<div class="name">
<span class="role">${file.role_label}${file.chip === 'nrf52' ? ' · nRF52' : ' · ESP32'}</span>
<span class="fname">${file.name}</span>
</div>
<span class="tag ${file.format}">${fmtLabel}</span>
<span class="size">${human(file.size)}</span>
`;
const btn = document.createElement('button');
btn.className = 'small' + (file.flashable ? '' : '');
btn.textContent = file.flashable ? 'Прошить' : 'Скачать';
btn.addEventListener('click', async (e) => {
e.preventDefault();
if (file.flashable) {
select(file, row);
flash();
} else {
// Перед скачиванием проверяем, что файл ещё на месте: у давно открытой
// страницы в памяти может быть старый манифест со ссылками на удалённые
// файлы. Тогда честно показываем баннер «обновите страницу».
btn.disabled = true;
try {
const head = await fetch(file.url, { method: 'HEAD', cache: 'no-store' });
if (!head.ok) {
showStale('Файл ' + file.name + ' не найден — на сервере уже новая версия прошивальщика.');
await checkFreshness();
return;
}
} catch (err) { /* сеть недоступна — пробуем скачать как раньше */ }
finally { btn.disabled = false; }
const a = document.createElement('a');
a.href = file.url;
a.download = file.name;
a.click();
log('Скачивается ' + file.name + ' — ' + (file.format === 'uf2'
? 'скопируйте файл на UF2-диск платы'
: 'прошейте через nRF Connect DFU'), 'warn');
}
});
row.appendChild(btn);
return row;
}
// Порядок версий: свежая сверху. Сначала по дате релиза из манифеста,
// если даты нет — по номеру версии (v0.2.0 новее v0.1.1).
function releaseRank(section) {
const rank = new Map();
(section.releases || []).forEach((r, i) => rank.set(r.tag, { date: r.date || '', idx: i }));
return rank;
}
function compareReleases(a, b, rank) {
const ra = rank.get(a), rb = rank.get(b);
const da = ra ? ra.date : '', db = rb ? rb.date : '';
if (da !== db) return db.localeCompare(da); // позже выпущено — выше
const ia = ra ? ra.idx : 999, ib = rb ? rb.idx : 999;
if (ia !== ib) return ia - ib; // порядок из манифеста
return b.localeCompare(a, undefined, { numeric: true });
}
function plural(n, one, few, many) {
const m10 = n % 10, m100 = n % 100;
if (m10 === 1 && m100 !== 11) return one;
if (m10 >= 2 && m10 <= 4 && (m100 < 10 || m100 >= 20)) return few;
return many;
}
function boardCard(section, board) {
const el = document.createElement('details');
el.className = 'board';
const roles = {};
board.files.forEach((f) => { roles[f.role_label] = 1; });
el.innerHTML = `<summary>${board.board}
<span class="roles">${Object.keys(roles).join(' · ')}</span></summary>`;
const box = document.createElement('div');
box.className = 'files';
// файлы одной платы раскладываем по версиям прошивки, чтобы не были вразнобой
const rank = releaseRank(section);
const fmtOrder = { merged: 0, app: 1, uf2: 2, zip: 3 };
const tags = [...new Set(board.files.map((f) => f.tag))].sort((a, b) => compareReleases(a, b, rank));
const newest = tags[0];
const relByTag = {};
(section.releases || []).forEach((r) => { relByTag[r.tag] = r; });
tags.forEach((tag) => {
const files = board.files.filter((f) => f.tag === tag)
.sort((a, b) => a.role_label.localeCompare(b.role_label, 'ru')
|| (fmtOrder[a.format] ?? 9) - (fmtOrder[b.format] ?? 9));
const rel = relByTag[tag] || {};
const date = String(rel.date || rel.created_at || '').slice(0, 10);
// каждая версия — отдельный блок: свежая раскрыта, остальные свёрнуты
const block = document.createElement('details');
block.className = 'verblock' + (tag === newest ? ' newest' : '');
block.open = tag === newest;
const sum = document.createElement('summary');
sum.innerHTML = `<span class="vtag">${files[0].version || tag}</span>`
+ (tag === newest ? '<span class="vnew">свежая</span>' : '')
+ (date ? `<span class="vdate">${date}</span>` : '')
+ `<span class="vcount">${files.length} ${plural(files.length, 'файл', 'файла', 'файлов')}</span>`;
block.appendChild(sum);
const inner = document.createElement('div');
inner.className = 'verfiles';
files.forEach((f) => inner.appendChild(fileRow(section, board, f)));
block.appendChild(inner);
box.appendChild(block);
});
el.appendChild(box);
return el;
}
function sectionBlock(section) {
const el = document.createElement('section');
el.className = 'group';
el.dataset.section = section.id;
el.innerHTML = `<h2>${section.title}<span class="count">${section.board_count} плат · ${section.file_count} файлов</span></h2>
<p class="lead">${section.lead}</p>
<p class="details">${section.details}</p>`;
const list = document.createElement('div');
list.className = 'boards';
section.boards.forEach((b) => list.appendChild(boardCard(section, b)));
el.appendChild(list);
return el;
}
function applyFilter(query) {
const q = query.trim().toLowerCase();
let found = 0;
document.querySelectorAll('section.group').forEach((sec) => {
let visibleBoards = 0;
sec.querySelectorAll('details.board').forEach((b) => {
// ищем и по названию платы, и по версиям, и по именам файлов
const hay = [
b.querySelector('summary') ? b.querySelector('summary').textContent : '',
...[...b.querySelectorAll('.vtag, .fname')].map((n) => n.textContent),
].join(' ').toLowerCase();
const hit = !q || hay.includes(q);
b.style.display = hit ? '' : 'none';
b.classList.toggle('hit', Boolean(q) && hit);
if (hit) { visibleBoards++; found++; }
// при поиске раскрываем блоки версий, чтобы совпадения были видны сразу
if (q) b.querySelectorAll('details.verblock').forEach((v) => { v.open = true; });
});
sec.style.display = visibleBoards ? '' : 'none';
});
const hint = $('search-hint');
if (hint) {
hint.textContent = q
? (found ? `Найдено плат: ${found}` : 'Ничего не найдено — попробуйте другое название')
: 'Начните вводить название платы — список отфильтруется';
hint.className = 'search-hint' + (q ? (found ? ' ok' : ' err') : '');
}
}
async function loadIndex() {
const badge = $('b-index');
try {
const res = await fetch('index.json', { cache: 'no-store' });
if (!res.ok) throw new Error('HTTP ' + res.status);
index = await res.json();
manifestStamp = index.generated_at || '';
const host = $('sections');
host.textContent = '';
index.sections.forEach((s) => host.appendChild(sectionBlock(s)));
badge.textContent = `прошивок: ${index.totals.files}, плат: ${index.totals.boards} (${index.generated_at})`;
badge.className = 'badge ok';
$('search').addEventListener('input', (e) => applyFilter(e.target.value));
} catch (err) {
badge.textContent = 'не удалось прочитать index.json: ' + err.message;
badge.className = 'badge err';
log('Ошибка загрузки index.json: ' + err.message, 'err');
}
}
// ---------------------------------------------------------------- выбор файла
function select(file, row) {
selected = file;
document.querySelectorAll('.file.selected').forEach((el) => el.classList.remove('selected'));
if (row) row.classList.add('selected');
$('v-file').textContent = file.name;
$('v-addr').textContent = file.format === 'merged' ? '0x0 (полный образ)'
: file.format === 'app' ? '0x10000 (только прошивка)' : '—';
$('v-size').textContent = human(file.size);
updateButtons();
}
function updateButtons() {
$('btn-flash').disabled = busy || !selected || !selected.flashable;
$('btn-erase').disabled = busy || !connected;
$('btn-reset').disabled = busy || !connected;
$('btn-connect').disabled = busy || connected;
$('btn-own-flash').disabled = busy || !ownFile;
}
// ---------------------------------------------------------------- работа с портом
async function connect() {
if (connected) return true;
if (!('serial' in navigator)) {
log('Браузер не поддерживает Web Serial. Откройте страницу в Chrome или Edge 89+.', 'err');
return false;
}
try {
busy = true; updateButtons();
const device = await navigator.serial.requestPort();
transport = new Transport(device, true);
loader = new ESPLoader({
transport,
baudrate: parseInt($('baud').value, 10),
terminal: { clean() {}, writeLine: (d) => log(d), write: (d) => log(d) },
});
log('Подключение к устройству…');
const chip = await loader.main();
chipName = (loader.chip && loader.chip.CHIP_NAME) || chip || 'неизвестен';
connected = true;
$('v-chip').textContent = chipName;
$('v-port').textContent = 'подключено';
log('Подключено. Чип: ' + chipName, 'ok');
try {
const mac = await loader.readMac();
log('MAC: ' + mac);
} catch (e) { /* не критично */ }
busy = false; updateButtons();
return true;
} catch (err) {
busy = false; connected = false;
log('Не удалось подключиться: ' + (err && err.message ? err.message : err), 'err');
$('v-port').textContent = 'не подключено';
updateButtons();
return false;
}
}
// esptool-js (0.5.x) ожидает содержимое прошивки как «двоичную строку» (binary string):
// внутри writeFlash вызывается bstrToUi8(data), то есть data.charCodeAt(i). Если передать
// Uint8Array, падает с "data.charCodeAt is not a function" — поэтому конвертируем сами.
function bytesToBinaryString(bytes) {
let out = '';
const CHUNK = 0x8000; // чтобы String.fromCharCode.apply не упёрся в лимит аргументов
for (let i = 0; i < bytes.length; i += CHUNK) {
out += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK));
}
return out;
}
async function fetchFirmware(file) {
log('Загрузка ' + file.name + ' (' + human(file.size) + ')…');
const res = await fetch(file.url, { cache: 'no-store' });
if (!res.ok) throw new Error('не удалось скачать файл: HTTP ' + res.status);
const buf = await res.arrayBuffer();
const bytes = new Uint8Array(buf);
log('Получено ' + human(bytes.length) + ', подготовка данных…');
const data = bytesToBinaryString(bytes);
if (data.length !== bytes.length) {
throw new Error(`данные повреждены при подготовке: ${data.length} вместо ${bytes.length} байт`);
}
return data;
}
async function flash() {
if (!selected) return;
if (!selected.flashable) {
log('Этот файл прошивается не из браузера: ' + selected.name, 'warn');
return;
}
if (!connected && !(await connect())) return;
const eraseAll = $('erase-first').checked;
const addr = selected.format === 'merged' ? 0x0 : 0x10000;
try {
busy = true; updateButtons();
setProgress(0, 'подготовка');
const data = await fetchFirmware(selected);
if (eraseAll) {
log('Полное стирание флеша…', 'warn');
await loader.eraseFlash();
log('Флеш очищен', 'ok');
}
log(`Запись по адресу 0x${addr.toString(16)}…`);
await loader.writeFlash({
fileArray: [{ data, address: addr }],
flashSize: 'keep',
eraseAll: false,
compress: true,
reportProgress: (fileIndex, written, total) => setProgress(written / total),
});
log('Запись завершена', 'ok');
setProgress(1, '100 %');
await loader.after('hard_reset');
log('Устройство перезагружено — прошивка применена', 'ok');
} catch (err) {
log('Ошибка прошивки: ' + (err && err.message ? err.message : err), 'err');
setProgress(0, 'ошибка');
} finally {
busy = false; updateButtons();
}
}
async function eraseOnly() {
if (!connected && !(await connect())) return;
if (!confirm('Стереть флеш полностью? Настройки, ключи и координаты узла будут удалены.')) return;
try {
busy = true; updateButtons();
log('Полное стирание флеша…', 'warn');
await loader.eraseFlash();
log('Флеш очищен', 'ok');
} catch (err) {
log('Ошибка стирания: ' + (err && err.message ? err.message : err), 'err');
} finally {
busy = false; updateButtons();
}
}
async function resetDevice() {
try {
await loader.after('hard_reset');
log('Команда перезагрузки отправлена', 'ok');
} catch (err) {
log('Ошибка перезагрузки: ' + (err && err.message ? err.message : err), 'err');
}
}
// ---------------------------------------------------------------- своя прошивка
function setOwnFile(file) {
ownFile = file || null;
$('own-name').textContent = ownFile
? `${ownFile.name} · ${human(ownFile.size)}`
: 'Перетащите файл сюда или нажмите, чтобы выбрать';
$('drop').classList.toggle('filled', Boolean(ownFile));
if (ownFile) log('Выбран свой файл: ' + ownFile.name + ' (' + human(ownFile.size) + ')');
updateButtons();
}
async function flashOwn() {
if (!ownFile) return;
if (!connected && !(await connect())) return;
const want = $('own-chip').value;
if (want !== 'auto' && chipName) {
const known = { esp32: 'ESP32', esp32s3: 'ESP32-S3', esp32c3: 'ESP32-C3', esp32c6: 'ESP32-C6', esp32s2: 'ESP32-S2' };
const short = want.replace('esp32', '').toUpperCase();
if (short && !chipName.toUpperCase().includes(short)) {
if (!confirm(`Выбран чип ${known[want]}, а подключён ${chipName}. Продолжить прошивку?`)) return;
}
}
const addr = parseInt($('own-addr').value, 16) || 0;
const eraseAll = $('erase-first').checked;
try {
busy = true; updateButtons();
setProgress(0, 'подготовка');
log(`Чтение своего файла ${ownFile.name} (${human(ownFile.size)})…`);
const bytes = new Uint8Array(await ownFile.arrayBuffer());
const data = bytesToBinaryString(bytes);
if (data.length !== bytes.length) throw new Error('данные повреждены при подготовке: ' + data.length + ' вместо ' + bytes.length + ' байт');
if (eraseAll) {
log('Полное стирание флеша…', 'warn');
await loader.eraseFlash();
log('Флеш очищен', 'ok');
}
log(`Запись по адресу 0x${addr.toString(16)}…`);
await loader.writeFlash({
fileArray: [{ data, address: addr }],
flashSize: 'keep',
eraseAll: false,
compress: true,
reportProgress: (fileIndex, written, total) => setProgress(written / total),
});
log('Запись завершена', 'ok');
setProgress(1, '100 %');
await loader.after('hard_reset');
log('Устройство перезагружено — прошивка применена', 'ok');
} catch (err) {
log('Ошибка прошивки: ' + (err && err.message ? err.message : err), 'err');
setProgress(0, 'ошибка');
} finally {
busy = false; updateButtons();
}
}
$('btn-connect').addEventListener('click', connect);
$('btn-flash').addEventListener('click', flash);
$('btn-erase').addEventListener('click', eraseOnly);
$('btn-reset').addEventListener('click', resetDevice);
$('btn-clear-log').addEventListener('click', () => { logEl.textContent = ''; });
$('erase-first').addEventListener('change', (e) => {
if (e.target.checked) log('Включено полное стирание перед прошивкой — настройки узла будут удалены', 'warn');
});
// своя прошивка: выбор файла, drag&drop, очистка поиска
$('own-file').addEventListener('change', (e) => setOwnFile(e.target.files && e.target.files[0]));
$('btn-own-flash').addEventListener('click', flashOwn);
['dragenter', 'dragover'].forEach((ev) => $('drop').addEventListener(ev, (e) => {
e.preventDefault(); $('drop').classList.add('over');
}));
['dragleave', 'dragend'].forEach((ev) => $('drop').addEventListener(ev, () => $('drop').classList.remove('over')));
$('drop').addEventListener('drop', (e) => {
e.preventDefault();
$('drop').classList.remove('over');
const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
if (f) setOwnFile(f);
});
$('search-clear').addEventListener('click', () => {
$('search').value = '';
applyFilter('');
$('search').focus();
});
if ('serial' in navigator) {
navigator.serial.addEventListener('disconnect', () => {
connected = false;
$('v-port').textContent = 'отключено';
$('v-chip').textContent = '—';
log('Устройство отключено', 'warn');
updateButtons();
});
}
loadIndex();
updateButtons();
// Раз в минуту и при возврате на вкладку сверяем манифест: если на сервере уже
// новая сборка, показываем баннер вместо тихих 404 при скачивании файлов.
setInterval(checkFreshness, 60000);
document.addEventListener('visibilitychange', () => {
if (!document.hidden) checkFreshness();
});