feat: add web flasher (Vue 3 + Web Serial DFU) and DFU zip generation
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
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
This commit is contained in:
BIN
flasher/Heltec_T114_Beacon_BLE.dfu.zip
Normal file
BIN
flasher/Heltec_T114_Beacon_BLE.dfu.zip
Normal file
Binary file not shown.
152
flasher/create_dfu_zip.py
Normal file
152
flasher/create_dfu_zip.py
Normal file
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Convert .hex or .uf2 to nRF52 DFU .zip for Web Serial flashing.
|
||||
|
||||
Usage:
|
||||
python3 create_dfu_zip.py firmware.hex firmware.dfu.zip
|
||||
python3 create_dfu_zip.py firmware.uf2 firmware.dfu.zip
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import struct
|
||||
import zipfile
|
||||
import hashlib
|
||||
|
||||
def hex_to_bin(hex_path):
|
||||
"""Convert Intel HEX to raw binary."""
|
||||
from intelhex import IntelHex
|
||||
ih = IntelHex(hex_path)
|
||||
# Use the full address range or just the used portion
|
||||
min_addr = ih.minaddr() if ih.minaddr() is not None else 0
|
||||
max_addr = ih.maxaddr() if ih.maxaddr() is not None else 0
|
||||
size = max_addr - min_addr + 1
|
||||
# Align to page size (4KB for nRF52840)
|
||||
page_size = 0x1000
|
||||
aligned_size = ((size + page_size - 1) // page_size) * page_size
|
||||
data = ih.tobinarray(start=min_addr, size=aligned_size)
|
||||
return bytes(data), min_addr
|
||||
|
||||
def uf2_to_bin(uf2_path):
|
||||
"""Convert UF2 to raw binary."""
|
||||
FAMILY_NRF52840 = 0xADA52840
|
||||
data = {}
|
||||
with open(uf2_path, 'rb') as f:
|
||||
while True:
|
||||
block = f.read(512)
|
||||
if not block or len(block) < 512:
|
||||
break
|
||||
magic0, magic1, flags, addr, size, seq, num = struct.unpack_from('<IIIIIII', block, 0)
|
||||
if magic0 != 0x0A324655 or magic1 != 0x9E5D5157:
|
||||
continue
|
||||
payload = block[32:32+size]
|
||||
for i in range(0, len(payload), 4):
|
||||
word = struct.unpack_from('<I', payload, i)[0]
|
||||
offset = addr + i
|
||||
data[offset] = word
|
||||
if not data:
|
||||
raise ValueError("No valid UF2 blocks found")
|
||||
addresses = sorted(data.keys())
|
||||
min_addr = addresses[0] & ~0xFFF # align to 4K
|
||||
max_addr = addresses[-1]
|
||||
page_size = 0x1000
|
||||
size = max_addr - min_addr + 1
|
||||
aligned_size = ((size + page_size - 1) // page_size) * page_size
|
||||
bin_data = bytearray(aligned_size)
|
||||
for addr, word in data.items():
|
||||
offset = addr - min_addr
|
||||
if offset + 4 <= len(bin_data):
|
||||
struct.pack_into('<I', bin_data, offset, word)
|
||||
return bytes(bin_data), min_addr
|
||||
|
||||
def create_dfu_zip(bin_data, base_addr, fw_version=1, hw_version=52):
|
||||
"""Create nRF DFU zip from binary data."""
|
||||
|
||||
# Build init packet (.dat) per nRF DFU spec
|
||||
# Format (little-endian):
|
||||
# [0x01] - signature (DFU init packet)
|
||||
# [fw_type: 1] - 0x04 = application
|
||||
# [sd_count: 4] - number of SD requirements (1)
|
||||
# [sd_array: sd_count * 4] - SD requirement (0xFFFE = any)
|
||||
# [hw_version: 4]
|
||||
# [fw_version: 4]
|
||||
# [fwid_type: 2] - 0x0001 = SHA-256 (not used, set to 0)
|
||||
# [fwid_length: 2]
|
||||
# [fwid: fwid_length]
|
||||
|
||||
# Simpler init packet format as expected by dfu.js:
|
||||
# It expects init_packet_data with components.data array
|
||||
|
||||
init_packet = bytearray()
|
||||
init_packet.append(0x01) # signature
|
||||
init_packet.append(0x04) # fw_type: application
|
||||
# softdevice requirements
|
||||
init_packet += struct.pack('<I', 0xFFFE) # SD required: any
|
||||
init_packet += struct.pack('<I', 0xFFFFFFFF) # terminator
|
||||
init_packet += struct.pack('<I', hw_version) # HW version
|
||||
init_packet += struct.pack('<I', fw_version) # FW version
|
||||
# FWID (empty)
|
||||
init_packet += struct.pack('<II', 0, 0) # type=0, len=0
|
||||
|
||||
# build manifest
|
||||
manifest = {
|
||||
"manifest": {
|
||||
"application": {
|
||||
"bin_file": "firmware.bin",
|
||||
"dat_file": "firmware.dat",
|
||||
"init_packet_data": {
|
||||
"fw_version": fw_version,
|
||||
"hw_version": hw_version,
|
||||
"softdevice_req": [0xFFFE],
|
||||
"components": [
|
||||
{
|
||||
"data": list(init_packet)
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return manifest, bin_data, bytes(init_packet)
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3:
|
||||
print("Usage: create_dfu_zip.py <input.hex|uf2> <output.zip> [fw_version] [hw_version]")
|
||||
sys.exit(1)
|
||||
|
||||
input_path = sys.argv[1]
|
||||
output_path = sys.argv[2]
|
||||
fw_version = int(sys.argv[3]) if len(sys.argv) > 3 else 1
|
||||
hw_version = int(sys.argv[4]) if len(sys.argv) > 4 else 52
|
||||
|
||||
ext = os.path.splitext(input_path)[1].lower()
|
||||
|
||||
if ext == '.hex':
|
||||
try:
|
||||
bin_data, base_addr = hex_to_bin(input_path)
|
||||
except ImportError:
|
||||
print("Error: intelhex package required for .hex files.")
|
||||
print("Install: pip install intelhex")
|
||||
sys.exit(1)
|
||||
elif ext == '.uf2':
|
||||
bin_data, base_addr = uf2_to_bin(input_path)
|
||||
else:
|
||||
print(f"Unsupported format: {ext}")
|
||||
sys.exit(1)
|
||||
|
||||
manifest, fw_bin, fw_dat = create_dfu_zip(bin_data, base_addr, fw_version, hw_version)
|
||||
|
||||
with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr("manifest.json", json.dumps(manifest, indent=2))
|
||||
zf.writestr("firmware.bin", fw_bin)
|
||||
zf.writestr("firmware.dat", fw_dat)
|
||||
|
||||
size = os.path.getsize(output_path)
|
||||
print(f"DFU zip created: {output_path}")
|
||||
print(f" Size: {size} bytes ({size/1024:.0f} KB)")
|
||||
print(f" Firmware: {len(fw_bin)} bytes @ 0x{base_addr:08X}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
385
flasher/index.html
Normal file
385
flasher/index.html
Normal file
@@ -0,0 +1,385 @@
|
||||
<!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>
|
||||
98
flasher/lib/console.js
Normal file
98
flasher/lib/console.js
Normal file
@@ -0,0 +1,98 @@
|
||||
function delay(msecs) {
|
||||
return new Promise((resolve) => setTimeout(resolve, msecs));
|
||||
}
|
||||
|
||||
class LineBreakTransformer {
|
||||
chunks = '';
|
||||
port = null;
|
||||
|
||||
transform(chunk, controller) {
|
||||
// Append new chunks to existing chunks.
|
||||
this.chunks += chunk;
|
||||
// For each line breaks in chunks, send the parsed lines out.
|
||||
const lines = this.chunks.split('\r\n');
|
||||
this.chunks = lines.pop();
|
||||
lines.forEach((line) => controller.enqueue(line + '\r\n'));
|
||||
}
|
||||
|
||||
flush(controller) {
|
||||
// When the stream is closed, flush any remaining chunks out.
|
||||
controller.enqueue(this.chunks);
|
||||
}
|
||||
}
|
||||
|
||||
export class SerialConsole {
|
||||
connected = false;
|
||||
constructor(port) {
|
||||
this.port = port;
|
||||
this.controller = new AbortController();
|
||||
this.signal = this.controller.signal;
|
||||
this.onOutput = (text) => {
|
||||
console.log(text);
|
||||
};
|
||||
}
|
||||
|
||||
async connect() {
|
||||
try {
|
||||
await this.port.open({ baudRate: 115200 });
|
||||
this.connected = true;
|
||||
await this.port.readable
|
||||
.pipeThrough(new TextDecoderStream(), { signal: this.signal })
|
||||
.pipeThrough(new TransformStream(new LineBreakTransformer()))
|
||||
.pipeTo(
|
||||
new WritableStream({
|
||||
write: (chunk) => {
|
||||
this.addLine(chunk.replace('\r', ''));
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Check AFTER the pipeTo has completed (or been aborted)
|
||||
if (!this.signal.aborted) {
|
||||
this.addLine('\n\n*** Terminal disconnected');
|
||||
this.connected = false;
|
||||
}
|
||||
} catch (e) {
|
||||
this.addLine(`\n\n*** Terminal disconnected: ${e}`);
|
||||
this.connected = false;
|
||||
} finally {
|
||||
await delay(100);
|
||||
}
|
||||
}
|
||||
|
||||
addLine(text) {
|
||||
this.onOutput(text);
|
||||
}
|
||||
|
||||
async sendCommand(command) {
|
||||
const encoder = new TextEncoder();
|
||||
const writer = this.port.writable.getWriter(); // Get writer from 'this.port'
|
||||
await writer.write(encoder.encode(command + '\r\n'));
|
||||
try {
|
||||
writer.releaseLock();
|
||||
} catch (err) {
|
||||
console.error('Ignoring release lock error', err);
|
||||
}
|
||||
}
|
||||
|
||||
async disconnect() {
|
||||
this.controller.abort();
|
||||
await delay(50);
|
||||
await this.port.close();
|
||||
}
|
||||
|
||||
async reset() {
|
||||
console.debug('Triggering reset');
|
||||
await this.port.setSignals({
|
||||
dataTerminalReady: false,
|
||||
requestToSend: true,
|
||||
});
|
||||
await delay(250);
|
||||
await this.port.setSignals({
|
||||
dataTerminalReady: false,
|
||||
requestToSend: false,
|
||||
});
|
||||
|
||||
await delay(1250);
|
||||
}
|
||||
}
|
||||
469
flasher/lib/dfu.js
Normal file
469
flasher/lib/dfu.js
Normal file
@@ -0,0 +1,469 @@
|
||||
import * as zip from "./zip.min.js";
|
||||
|
||||
// Constants adapted from dfu/dfu_transport_serial.py
|
||||
const DFU_TOUCH_BAUD = 1200;
|
||||
const SERIAL_PORT_OPEN_WAIT_TIME = 0.1;
|
||||
const TOUCH_RESET_WAIT_TIME = 1.5;
|
||||
|
||||
const DEFAULT_SERIAL_PORT_TIMEOUT = 1.0; // Timeout time on serial port read
|
||||
const FLASH_PAGE_SIZE = 4096;
|
||||
const FLASH_PAGE_ERASE_TIME = 0.0897; // nRF52840 max erase time
|
||||
const FLASH_WORD_WRITE_TIME = 0.000100; // nRF52840 max write time
|
||||
const FLASH_PAGE_WRITE_TIME = (FLASH_PAGE_SIZE / 4) * FLASH_WORD_WRITE_TIME;
|
||||
const DFU_PACKET_MAX_SIZE = 512;
|
||||
|
||||
const DATA_INTEGRITY_CHECK_PRESENT = 1;
|
||||
const RELIABLE_PACKET = 1;
|
||||
const HCI_PACKET_TYPE = 14;
|
||||
|
||||
const DFU_INIT_PACKET = 1;
|
||||
const DFU_START_PACKET = 3;
|
||||
const DFU_DATA_PACKET = 4;
|
||||
const DFU_STOP_DATA_PACKET = 5;
|
||||
const DFU_ERASE_PAGE = 6; // Added for explicit page erase
|
||||
|
||||
const DFU_UPDATE_MODE_APP = 4;
|
||||
|
||||
// --- Utility Functions (adapted from dfu/util.py) ---
|
||||
|
||||
function int32ToBytes(value) {
|
||||
const buffer = new ArrayBuffer(4);
|
||||
const view = new DataView(buffer);
|
||||
view.setUint32(0, value, true); // Little-endian
|
||||
return new Uint8Array(buffer);
|
||||
}
|
||||
|
||||
function int16ToBytes(value) {
|
||||
const buffer = new ArrayBuffer(2);
|
||||
const view = new DataView(buffer);
|
||||
view.setUint16(0, value, true); // Little-endian
|
||||
return new Uint8Array(buffer);
|
||||
}
|
||||
|
||||
function slipPartsToFourBytes(seq, dip, rp, pktType, pktLen) {
|
||||
const ints = new Uint8Array(4);
|
||||
ints[0] = seq | (((seq + 1) % 8) << 3) | (dip << 6) | (rp << 7);
|
||||
ints[1] = pktType | ((pktLen & 0x000F) << 4);
|
||||
ints[2] = (pktLen & 0x0FF0) >> 4;
|
||||
ints[3] = (~(ints[0] + ints[1] + ints[2]) + 1) & 0xFF;
|
||||
return ints;
|
||||
}
|
||||
|
||||
function slipEncodeEscChars(data) {
|
||||
const result = [];
|
||||
for (const byte of data) {
|
||||
if (byte === 0xC0) {
|
||||
result.push(0xDB, 0xDC);
|
||||
} else if (byte === 0xDB) {
|
||||
result.push(0xDB, 0xDD);
|
||||
} else {
|
||||
result.push(byte);
|
||||
}
|
||||
}
|
||||
return new Uint8Array(result);
|
||||
}
|
||||
|
||||
// --- CRC16 Calculation (adapted from dfu/crc16.py) ---
|
||||
|
||||
function calcCrc16(data, crc = 0xFFFF) {
|
||||
if (!(data instanceof Uint8Array)) {
|
||||
throw new Error("calcCrc16 requires Uint8Array input");
|
||||
}
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
crc = ((crc >> 8) & 0x00FF) | ((crc << 8) & 0xFF00);
|
||||
crc ^= data[i];
|
||||
crc ^= (crc & 0x00FF) >> 4;
|
||||
crc ^= (crc << 8) << 4;
|
||||
crc ^= ((crc & 0x00FF) << 4) << 1;
|
||||
}
|
||||
return crc & 0xFFFF;
|
||||
}
|
||||
|
||||
function sleep(milliseconds) {
|
||||
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
// --- HciPacket Class (adapted from dfu/dfu_transport_serial.py) ---
|
||||
|
||||
class HciPacket {
|
||||
static sequenceNumber = 0;
|
||||
|
||||
constructor(data) {
|
||||
HciPacket.sequenceNumber = (HciPacket.sequenceNumber + 1) % 8;
|
||||
let tempData = [];
|
||||
|
||||
const slipBytes = slipPartsToFourBytes(
|
||||
HciPacket.sequenceNumber,
|
||||
DATA_INTEGRITY_CHECK_PRESENT,
|
||||
RELIABLE_PACKET,
|
||||
HCI_PACKET_TYPE,
|
||||
data.length
|
||||
);
|
||||
tempData = tempData.concat(Array.from(slipBytes));
|
||||
|
||||
tempData = tempData.concat(Array.from(data));
|
||||
|
||||
// Add CRC
|
||||
const crc = calcCrc16(new Uint8Array(tempData));
|
||||
tempData.push(crc & 0xFF);
|
||||
tempData.push((crc & 0xFF00) >> 8);
|
||||
|
||||
const encoded = slipEncodeEscChars(new Uint8Array(tempData));
|
||||
this.data = new Uint8Array([0xC0, ...encoded, 0xC0]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// --- Main DFU Class ---
|
||||
|
||||
export class Dfu {
|
||||
/**
|
||||
* @param {SerialPort} port - The Web Serial API port object.
|
||||
* @param {boolean} [eraseBeforeUpdate=false] - Whether to erase the entire flash before updating.
|
||||
*/
|
||||
constructor(port, eraseBeforeUpdate = false) {
|
||||
this.port = port;
|
||||
this.transferInProgress = false;
|
||||
this.lastAck = -1;
|
||||
this.eraseBeforeUpdate = eraseBeforeUpdate; // Store the erase flag
|
||||
}
|
||||
|
||||
getReader() {
|
||||
const reader = this.port.readable.getReader();
|
||||
|
||||
return {
|
||||
read() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
reader.releaseLock();
|
||||
reject(new Error("Read timeout"));
|
||||
}, DEFAULT_SERIAL_PORT_TIMEOUT * 1000 * 5)
|
||||
|
||||
reader.read().then(result => {
|
||||
clearTimeout(timeoutHandle);
|
||||
resolve(result);
|
||||
});
|
||||
});
|
||||
},
|
||||
releaseLock() {
|
||||
return reader.releaseLock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async sendPacket(pkt) {
|
||||
if (!this.port || !this.port.writable) {
|
||||
throw new Error("Serial port not open or not writable.");
|
||||
}
|
||||
|
||||
const writer = this.port.writable.getWriter();
|
||||
try {
|
||||
await writer.write(pkt.data);
|
||||
console.debug("Sent packet:", pkt.data.length);
|
||||
} finally {
|
||||
writer.releaseLock();
|
||||
}
|
||||
|
||||
await this.getAck(); // Wait for ACK after sending
|
||||
}
|
||||
|
||||
async getAck() {
|
||||
if (!this.port || !this.port.readable) {
|
||||
throw new Error("Serial port not open or not readable.");
|
||||
}
|
||||
|
||||
const reader = this.getReader();
|
||||
let buffer = [];
|
||||
let c0Count = 0;
|
||||
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
while (c0Count < 2) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
throw new Error("Stream closed before receiving full ACK.");
|
||||
}
|
||||
|
||||
if (value) {
|
||||
for (const byte of value) {
|
||||
buffer.push(byte);
|
||||
if (byte === 0xC0) {
|
||||
c0Count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(e) {
|
||||
HciPacket.sequenceNumber = 0;
|
||||
throw e;
|
||||
}
|
||||
finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
// Extract the SLIP frame between the two 0xC0 delimiters, ignoring any
|
||||
// stale bytes that arrived before the opening delimiter.
|
||||
const firstC0 = buffer.indexOf(0xC0);
|
||||
const secondC0 = buffer.indexOf(0xC0, firstC0 + 1);
|
||||
if (firstC0 === -1 || secondC0 === -1) {
|
||||
throw new Error("Received incomplete ACK.");
|
||||
}
|
||||
const decodedData = this.decodeSlip(buffer.slice(firstC0 + 1, secondC0));
|
||||
|
||||
if (decodedData.length < 2) {
|
||||
throw new Error("Received incomplete ACK.");
|
||||
}
|
||||
const ack = (decodedData[0] >> 3) & 0x07;
|
||||
|
||||
// Check for valid ACK sequence
|
||||
if (this.lastAck !== -1 && ack !== (this.lastAck + 1) % 8) {
|
||||
HciPacket.sequenceNumber = 0; // Reset on bad ack
|
||||
throw new Error(`Invalid ACK sequence. Expected ${(this.lastAck + 1) % 8}, got ${ack}`);
|
||||
}
|
||||
this.lastAck = ack;
|
||||
|
||||
return ack;
|
||||
}
|
||||
|
||||
decodeSlip(data) {
|
||||
const result = [];
|
||||
let i = 0;
|
||||
while (i < data.length) {
|
||||
if (data[i] === 0xDB) {
|
||||
i++;
|
||||
if (i >= data.length) {
|
||||
throw new Error("Invalid SLIP escape sequence: incomplete.");
|
||||
}
|
||||
if (data[i] === 0xDC) {
|
||||
result.push(0xC0);
|
||||
} else if (data[i] === 0xDD) {
|
||||
result.push(0xDB);
|
||||
} else {
|
||||
throw new Error(`Invalid SLIP escape sequence: DB followed by ${data[i].toString(16)}`);
|
||||
}
|
||||
} else if (data[i] === 0xC0) {
|
||||
// Ignore 0xC0 (start/end of packet)
|
||||
}
|
||||
else {
|
||||
result.push(data[i]);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return new Uint8Array(result);
|
||||
}
|
||||
|
||||
async sendInitPacket(initPacket) {
|
||||
const frame = new Uint8Array([
|
||||
...int32ToBytes(DFU_INIT_PACKET),
|
||||
...initPacket,
|
||||
...int16ToBytes(0x0000), // Padding
|
||||
]);
|
||||
const packet = new HciPacket(frame);
|
||||
await this.sendPacket(packet);
|
||||
}
|
||||
|
||||
// THANKS Liam!!!
|
||||
static async forceDfuMode(port) {
|
||||
// open port
|
||||
await port.open({
|
||||
baudRate: DFU_TOUCH_BAUD,
|
||||
});
|
||||
|
||||
// wait SERIAL_PORT_OPEN_WAIT_TIME before closing port
|
||||
await sleep(SERIAL_PORT_OPEN_WAIT_TIME * 1000);
|
||||
|
||||
// close port
|
||||
await port.close();
|
||||
|
||||
// wait TOUCH_RESET_WAIT_TIME for device to enter into DFU mode
|
||||
await sleep(TOUCH_RESET_WAIT_TIME * 1000);
|
||||
}
|
||||
|
||||
async sendStartDfu(mode, softdeviceSize = 0, bootloaderSize = 0, appSize = 0) {
|
||||
const frame = new Uint8Array([
|
||||
...int32ToBytes(DFU_START_PACKET),
|
||||
...int32ToBytes(mode),
|
||||
...int32ToBytes(softdeviceSize),
|
||||
...int32ToBytes(bootloaderSize),
|
||||
...int32ToBytes(appSize),
|
||||
]);
|
||||
|
||||
const packet = new HciPacket(frame);
|
||||
await this.sendPacket(packet);
|
||||
|
||||
// Calculate and apply erase wait time.
|
||||
const totalSize = softdeviceSize + bootloaderSize + appSize;
|
||||
const eraseWaitTime = Math.max(0.5, ((totalSize / FLASH_PAGE_SIZE) + 1) * FLASH_PAGE_ERASE_TIME);
|
||||
await sleep(eraseWaitTime * 1000);
|
||||
}
|
||||
|
||||
|
||||
async sendErasePage(pageAddress) {
|
||||
const frame = new Uint8Array([
|
||||
...int32ToBytes(DFU_ERASE_PAGE),
|
||||
...int32ToBytes(pageAddress),
|
||||
]);
|
||||
const packet = new HciPacket(frame);
|
||||
await this.sendPacket(packet);
|
||||
await sleep(FLASH_PAGE_ERASE_TIME * 1000); // Wait for page erase
|
||||
}
|
||||
|
||||
|
||||
async eraseFlash(appSize) {
|
||||
console.log("Erasing flash...");
|
||||
const numPages = Math.ceil(appSize / FLASH_PAGE_SIZE);
|
||||
|
||||
// Assuming application starts at address 0x00000000
|
||||
let startAddress = 0x00000000;
|
||||
|
||||
for (let i = 0; i < numPages; i++) {
|
||||
const pageAddress = startAddress + (i * FLASH_PAGE_SIZE);
|
||||
console.log(`Erasing page ${i} at address 0x${pageAddress.toString(16)}`);
|
||||
await this.sendErasePage(pageAddress);
|
||||
}
|
||||
console.log("Flash erase complete.");
|
||||
}
|
||||
|
||||
|
||||
async sendFirmware(firmware, progressCallback) {
|
||||
const frames = [];
|
||||
let totalBytes = firmware.length;
|
||||
|
||||
// Chunk firmware into DFU packets
|
||||
for (let i = 0; i < firmware.length; i += DFU_PACKET_MAX_SIZE) {
|
||||
const chunk = firmware.subarray(i, i + DFU_PACKET_MAX_SIZE);
|
||||
const frame = new Uint8Array([
|
||||
...int32ToBytes(DFU_DATA_PACKET),
|
||||
...chunk,
|
||||
]);
|
||||
const dataPacket = new HciPacket(frame);
|
||||
frames.push(dataPacket);
|
||||
}
|
||||
|
||||
let bytesSent = 0;
|
||||
// Brief stabilization pause before starting data transfer (mirrors Python's implicit
|
||||
// pause at count=0 — it sleeps FLASH_PAGE_WRITE_TIME after the very first packet).
|
||||
await sleep(FLASH_PAGE_WRITE_TIME * 1000);
|
||||
|
||||
// Send firmware packets
|
||||
for (const [index, pkt] of frames.entries()) {
|
||||
await this.sendPacket(pkt);
|
||||
bytesSent += pkt.data.length - 6; // Correctly calculate sent bytes, excluding SLIP overhead
|
||||
|
||||
if (progressCallback) {
|
||||
const progress = Math.min(100, Math.round((bytesSent / totalBytes) * 100)); // Ensure progress doesn't exceed 100
|
||||
progressCallback(progress);
|
||||
}
|
||||
|
||||
// Wait after every 8 frames (one flash page)
|
||||
if ((index + 1) % 8 === 0) {
|
||||
await sleep(FLASH_PAGE_WRITE_TIME * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for the last page to be written
|
||||
await sleep(FLASH_PAGE_WRITE_TIME * 1000);
|
||||
|
||||
// Send stop packet
|
||||
const stopPacket = new HciPacket(int32ToBytes(DFU_STOP_DATA_PACKET));
|
||||
await this.sendPacket(stopPacket);
|
||||
}
|
||||
|
||||
async dfuUpdate(zipFile, progressCallback) {
|
||||
if (this.transferInProgress) {
|
||||
throw new Error("DFU update already in progress.");
|
||||
}
|
||||
this.transferInProgress = true;
|
||||
this.lastAck = -1; // Reset last ACK
|
||||
HciPacket.sequenceNumber = 0; // Reset HCI sequence number
|
||||
const decoder = new TextDecoder();
|
||||
try {
|
||||
await this.port.open({ baudRate: 115200 }); // Open with correct baudrate
|
||||
|
||||
const reader = new zip.ZipReader(new zip.BlobReader(zipFile));
|
||||
const entries = await reader.getEntries();
|
||||
|
||||
let manifest = null;
|
||||
let firmwareFiles = {};
|
||||
|
||||
for (const entry of entries) {
|
||||
const filename = decoder.decode(entry.rawFilename);
|
||||
console.debug('Found zip filename: ', filename);
|
||||
if (filename === 'manifest.json') {
|
||||
const text = await entry.getData(new zip.TextWriter());
|
||||
manifest = JSON.parse(text);
|
||||
} else if (filename.endsWith('.bin') || filename.endsWith('.dat')) {
|
||||
firmwareFiles[filename] = await entry.getData(new zip.Uint8ArrayWriter());
|
||||
}
|
||||
}
|
||||
|
||||
await reader.close();
|
||||
|
||||
if (!manifest) {
|
||||
throw new Error("manifest.json not found in the ZIP file.");
|
||||
}
|
||||
if (!firmwareFiles[manifest.manifest.application.bin_file] ||
|
||||
!firmwareFiles[manifest.manifest.application.dat_file])
|
||||
{
|
||||
throw new Error("Application .bin or .dat file not found.");
|
||||
}
|
||||
|
||||
const appBin = firmwareFiles[manifest.manifest.application.bin_file];
|
||||
const initPacket = firmwareFiles[manifest.manifest.application.dat_file];
|
||||
const appSize = appBin.length;
|
||||
|
||||
// Erase flash if requested
|
||||
if (this.eraseBeforeUpdate) {
|
||||
await this.eraseFlash(appSize);
|
||||
}
|
||||
|
||||
// Start DFU
|
||||
await this.sendStartDfu(DFU_UPDATE_MODE_APP, 0, 0, appSize);
|
||||
|
||||
// Send Init Packet
|
||||
await this.sendInitPacket(initPacket);
|
||||
|
||||
// Send Firmware
|
||||
await this.sendFirmware(appBin, progressCallback);
|
||||
|
||||
console.log("DFU update complete.");
|
||||
|
||||
} catch (error) {
|
||||
console.error("DFU Update failed:", error);
|
||||
throw error; // Re-throw the error for handling by the caller
|
||||
} finally {
|
||||
this.transferInProgress = false;
|
||||
if (this.port && this.port.readable) {
|
||||
try {
|
||||
const reader = this.port.readable.getReader();
|
||||
await reader.cancel();
|
||||
reader.releaseLock();
|
||||
|
||||
} catch (error) {
|
||||
// Ignore errors when trying to cancel the reader
|
||||
console.debug(`Error: closing reader: ${error}`);
|
||||
}
|
||||
}
|
||||
if (this.port && this.port.writable) {
|
||||
try {
|
||||
const writer = this.port.writable.getWriter();
|
||||
await writer.close();
|
||||
writer.releaseLock();
|
||||
} catch(error) {
|
||||
// Ignore errors when trying to close the writer
|
||||
console.debug(`Error: closing writer: ${error}`);
|
||||
}
|
||||
}
|
||||
if (this.port) {
|
||||
try {
|
||||
await this.port.close();
|
||||
}
|
||||
catch (error) {
|
||||
// Ignore errors when trying to close the port
|
||||
console.debug(`Error: closing port: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1
flasher/lib/zip.min.js
vendored
Normal file
1
flasher/lib/zip.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
116
flasher/pio_create_dfu_zip.py
Normal file
116
flasher/pio_create_dfu_zip.py
Normal file
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PlatformIO extra script: generate DFU .zip after build.
|
||||
|
||||
Add to platformio.ini:
|
||||
extra_scripts = post:flasher/pio_create_dfu_zip.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import struct
|
||||
import zipfile
|
||||
|
||||
# Add flasher dir to path for importing the main module
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
|
||||
|
||||
try:
|
||||
from create_dfu_zip import hex_to_bin, uf2_to_bin, create_dfu_zip
|
||||
except ImportError:
|
||||
# Fallback: define minimal conversion inline
|
||||
def hex_to_bin(hex_path):
|
||||
from intelhex import IntelHex
|
||||
ih = IntelHex(hex_path)
|
||||
min_addr = ih.minaddr() or 0
|
||||
max_addr = ih.maxaddr() or 0
|
||||
size = max_addr - min_addr + 1
|
||||
page_size = 0x1000
|
||||
aligned = ((size + page_size - 1) // page_size) * page_size
|
||||
return bytes(ih.tobinarray(start=min_addr, size=aligned)), min_addr
|
||||
|
||||
def create_dfu_zip(bin_data, base_addr, fw_version=1, hw_version=52):
|
||||
init_packet = bytearray()
|
||||
init_packet.append(0x01)
|
||||
init_packet.append(0x04)
|
||||
init_packet += struct.pack('<I', 0xFFFE)
|
||||
init_packet += struct.pack('<I', 0xFFFFFFFF)
|
||||
init_packet += struct.pack('<I', hw_version)
|
||||
init_packet += struct.pack('<I', fw_version)
|
||||
init_packet += struct.pack('<II', 0, 0)
|
||||
manifest = {
|
||||
"manifest": {
|
||||
"application": {
|
||||
"bin_file": "firmware.bin",
|
||||
"dat_file": "firmware.dat",
|
||||
"init_packet_data": {
|
||||
"fw_version": fw_version,
|
||||
"hw_version": hw_version,
|
||||
"softdevice_req": [0xFFFE],
|
||||
"components": [{"data": list(init_packet)}]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return manifest, bin_data, bytes(init_packet)
|
||||
|
||||
|
||||
def create_dfu_zip_from_build(target, source, env):
|
||||
"""PlatformIO post-build hook."""
|
||||
build_dir = env.subst("$BUILD_DIR")
|
||||
prog_name = env.subst("$PROGNAME")
|
||||
hex_file = os.path.join(build_dir, prog_name + ".hex")
|
||||
|
||||
if not os.path.isfile(hex_file):
|
||||
print(f" [DFU] {hex_file} not found, skipping")
|
||||
return
|
||||
|
||||
try:
|
||||
bin_data, base_addr = hex_to_bin(hex_file)
|
||||
except Exception as e:
|
||||
print(f" [DFU] Error: {e}")
|
||||
return
|
||||
|
||||
manifest, fw_bin, fw_dat = create_dfu_zip(bin_data, base_addr)
|
||||
|
||||
# Name the zip after the build target (environment name)
|
||||
env_name = env.subst("$PIOENV")
|
||||
# Extract firmware name from env: e.g. Heltec_t114_without_display_beacon_sensor_ble -> Heltec_T114_Beacon_BLE
|
||||
parts = env_name.split("_")
|
||||
if "beacon" in parts:
|
||||
fw_name = "Heltec_T114_Beacon"
|
||||
elif "companion" in parts:
|
||||
fw_name = "Heltec_T114_Companion_Radio"
|
||||
elif "repeater" in parts:
|
||||
fw_name = "Heltec_T114_Repeater"
|
||||
elif "room" in parts:
|
||||
fw_name = "Heltec_T114_Room_Server"
|
||||
else:
|
||||
fw_name = "firmware"
|
||||
|
||||
if "ble" in parts:
|
||||
fw_name += "_BLE"
|
||||
elif "usb" in parts:
|
||||
fw_name += "_USB"
|
||||
|
||||
zip_name = f"{fw_name}.dfu.zip"
|
||||
zip_path = os.path.join(build_dir, zip_name)
|
||||
|
||||
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr("manifest.json", json.dumps(manifest, indent=2))
|
||||
zf.writestr("firmware.bin", fw_bin)
|
||||
zf.writestr("firmware.dat", fw_dat)
|
||||
|
||||
size = os.path.getsize(zip_path)
|
||||
print(f" [DFU] Created: {zip_path} ({size / 1024:.0f} KB)")
|
||||
|
||||
|
||||
# Register hook
|
||||
Import("env")
|
||||
env.AddPostAction("$BUILD_DIR/${PROGNAME}.hex", create_dfu_zip_from_build)
|
||||
|
||||
# Also trigger after .uf2 creation if that action exists
|
||||
try:
|
||||
env.AddPostAction("$BUILD_DIR/${PROGNAME}.uf2", create_dfu_zip_from_build)
|
||||
except:
|
||||
pass
|
||||
Reference in New Issue
Block a user