Files
meshcore-simple-sensor/flasher/index.html
UA1ZBE 339d87bf86
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
feat: add web flasher (Vue 3 + Web Serial DFU) and DFU zip generation
2026-06-05 09:48:14 +03:00

386 lines
18 KiB
HTML
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.
<!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="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 = '';
// If same origin, no CORS needed
if (OWN_ORIGIN === API_ORIGIN) {
return this._fetchDirect();
}
// Try direct, fallback to CORS proxy
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>