534 lines
22 KiB
JavaScript
534 lines
22 KiB
JavaScript
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();
|
||
});
|