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
386 lines
18 KiB
HTML
386 lines
18 KiB
HTML
<!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>⚡ 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;">🔗 Репозиторий</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">← Все релизы</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">
|
||
ⓘ Релизы загружены через 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 · {{ 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>⬇ Скачать 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">⚙ Enter DFU mode</button>
|
||
<button v-else disabled class="secondary">✓ DFU mode</button>
|
||
<button @click="flashDfu" :disabled="!dfu.ready || flashing.active" class="contrast">⚡ 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;">
|
||
ⓘ Для 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);">✓ Готово!</p>
|
||
<pre class="term">{{ flashing.log }}</pre>
|
||
<div v-if="flashing.done" class="btn-group">
|
||
<button class="secondary" @click="resetFlash">← Назад к файлу</button>
|
||
</div>
|
||
</div>
|
||
<button class="secondary" style="margin-top:1rem;" @click="view='release'; selectedAsset=null">← Назад к релизу</button>
|
||
</div>
|
||
|
||
<!-- Serial Console -->
|
||
<div v-if="view === 'console'">
|
||
<hgroup>
|
||
<h5>💻 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">⚙ Подключиться</button>
|
||
<button v-else class="secondary" @click="closeConsole">✕ Отключиться</button>
|
||
<button v-if="console.connected" @click="consoleReset">↻ 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 <pass></code> — пароль</li>
|
||
</ul>
|
||
</details>
|
||
<button class="secondary" style="margin-top:1rem;" @click="view='releases'">← Назад</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);">
|
||
· {{ (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>⬇ Скачать</a>
|
||
<button class="small contrast" @click="startFlash(asset)">⚡ Прошить</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>📄 Релизов пока нет</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) }}
|
||
· {{ 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'">💻 Консоль</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>
|