conv. commit
This commit is contained in:
24 files changed
+5159
No files matched your search
@@ -0,0 +1,76 @@
|
||||
import type { BoardDef } from "./types";
|
||||
|
||||
export const boards: BoardDef[] = [
|
||||
{
|
||||
id: "heltec_v3",
|
||||
name: "Heltec V3",
|
||||
mcu: "ESP32-S3",
|
||||
radio: "SX1262",
|
||||
family: "esp32s3",
|
||||
flashMethod: "web-serial",
|
||||
assetPattern: "donglora-heltec_v3-v{version}.bin",
|
||||
notes: "Native USB CDC-ACM (hardware mod may be required on some units)",
|
||||
},
|
||||
{
|
||||
id: "heltec_v4",
|
||||
name: "Heltec V4",
|
||||
mcu: "ESP32-S3",
|
||||
radio: "SX1262",
|
||||
family: "esp32s3",
|
||||
flashMethod: "web-serial",
|
||||
assetPattern: "donglora-heltec_v4-v{version}.bin",
|
||||
},
|
||||
{
|
||||
id: "heltec_v3_uart",
|
||||
name: "Heltec V3 (UART)",
|
||||
mcu: "ESP32-S3",
|
||||
radio: "SX1262",
|
||||
family: "esp32s3",
|
||||
flashMethod: "download-only",
|
||||
assetPattern: "donglora-heltec_v3_uart-v{version}.bin",
|
||||
notes:
|
||||
"Uses CP2102 USB-to-UART bridge. Web flashing is unreliable — download the .bin and flash with espflash CLI.",
|
||||
},
|
||||
{
|
||||
id: "rak_wisblock_4631",
|
||||
name: "RAK WisBlock 4631",
|
||||
mcu: "nRF52840",
|
||||
radio: "SX1262",
|
||||
family: "nrf52840",
|
||||
flashMethod: "uf2",
|
||||
assetPattern: "donglora-rak_wisblock_4631-v{version}.uf2",
|
||||
},
|
||||
{
|
||||
id: "wio_tracker_l1",
|
||||
name: "Wio Tracker L1",
|
||||
mcu: "nRF52840",
|
||||
radio: "SX1262",
|
||||
family: "nrf52840",
|
||||
flashMethod: "uf2",
|
||||
assetPattern: "donglora-wio_tracker_l1-v{version}.uf2",
|
||||
},
|
||||
];
|
||||
|
||||
export function getAssetName(board: BoardDef, version: string): string {
|
||||
return board.assetPattern.replace("{version}", version);
|
||||
}
|
||||
|
||||
import type { ReleaseAsset } from "./types";
|
||||
|
||||
export interface AssetUrls {
|
||||
/** For <a download> links */
|
||||
downloadUrl: string;
|
||||
/** For fetch() in browser — proxied to avoid CORS */
|
||||
fetchUrl: string;
|
||||
}
|
||||
|
||||
export function findAsset(
|
||||
board: BoardDef,
|
||||
version: string,
|
||||
assets: ReleaseAsset[],
|
||||
): AssetUrls | undefined {
|
||||
const expected = getAssetName(board, version);
|
||||
const asset = assets.find((a) => a.name === expected);
|
||||
if (!asset) return undefined;
|
||||
return { downloadUrl: asset.downloadUrl, fetchUrl: asset.fetchUrl };
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { FlashProgress } from "./types";
|
||||
|
||||
export function isWebSerialSupported(): boolean {
|
||||
return typeof navigator !== "undefined" && "serial" in navigator;
|
||||
}
|
||||
|
||||
export interface FlashCallbacks {
|
||||
onProgress: (progress: FlashProgress) => void;
|
||||
onLog: (message: string) => void;
|
||||
}
|
||||
|
||||
interface FlashSession {
|
||||
transport: InstanceType<Awaited<ReturnType<typeof loadEsptool>>["Transport"]>;
|
||||
loader: InstanceType<Awaited<ReturnType<typeof loadEsptool>>["ESPLoader"]>;
|
||||
}
|
||||
|
||||
async function loadEsptool() {
|
||||
const mod = await import("esptool-js");
|
||||
return { ESPLoader: mod.ESPLoader, Transport: mod.Transport };
|
||||
}
|
||||
|
||||
export async function connect(callbacks: FlashCallbacks): Promise<FlashSession> {
|
||||
if (!isWebSerialSupported()) {
|
||||
throw new Error("Web Serial API is not supported in this browser. Use Chrome, Edge, or Opera.");
|
||||
}
|
||||
|
||||
const { ESPLoader, Transport } = await loadEsptool();
|
||||
|
||||
callbacks.onLog("Requesting serial port...");
|
||||
const port = await navigator.serial.requestPort();
|
||||
|
||||
callbacks.onLog("Opening connection...");
|
||||
const transport = new Transport(port, true);
|
||||
|
||||
const loader = new ESPLoader({
|
||||
transport,
|
||||
baudrate: 460800,
|
||||
romBaudrate: 115200,
|
||||
terminal: {
|
||||
clean: () => {},
|
||||
writeLine: (data: string) => callbacks.onLog(data),
|
||||
write: (data: string) => {
|
||||
if (data.trim()) callbacks.onLog(data.trim());
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
callbacks.onLog("Connecting to ESP32...");
|
||||
callbacks.onProgress({ phase: "Connecting", percent: 0, message: "Detecting chip..." });
|
||||
await loader.main();
|
||||
|
||||
const chip = loader.chip.CHIP_NAME;
|
||||
callbacks.onLog(`Detected: ${chip}`);
|
||||
|
||||
if (!chip.includes("ESP32-S3")) {
|
||||
await transport.disconnect();
|
||||
throw new Error(`Expected ESP32-S3 but detected ${chip}. Make sure you selected the correct board.`);
|
||||
}
|
||||
|
||||
return { transport, loader };
|
||||
}
|
||||
|
||||
export async function flash(
|
||||
session: FlashSession,
|
||||
firmware: Uint8Array,
|
||||
callbacks: FlashCallbacks,
|
||||
): Promise<void> {
|
||||
callbacks.onLog("Reading flash ID...");
|
||||
await session.loader.flashId();
|
||||
|
||||
callbacks.onLog("Preparing to flash...");
|
||||
callbacks.onProgress({ phase: "Flashing", percent: 0, message: "Erasing flash..." });
|
||||
|
||||
// Pass firmware as Uint8Array directly — NOT as a binary string.
|
||||
// pako's deflate() treats string input as UTF-8 text, which corrupts
|
||||
// every byte > 0x7F (e.g. 0xE9 becomes 0xC3 0xA9). Uint8Array is
|
||||
// handled as raw bytes.
|
||||
const fileArray = [
|
||||
{
|
||||
data: firmware,
|
||||
address: 0x0,
|
||||
},
|
||||
];
|
||||
|
||||
await session.loader.writeFlash({
|
||||
fileArray,
|
||||
flashSize: "keep",
|
||||
flashMode: "keep",
|
||||
flashFreq: "keep",
|
||||
eraseAll: false,
|
||||
compress: true,
|
||||
reportProgress: (_fileIndex: number, written: number, total: number) => {
|
||||
const percent = Math.round((written / total) * 100);
|
||||
callbacks.onProgress({
|
||||
phase: "Flashing",
|
||||
percent,
|
||||
message: `${formatBytes(written)} / ${formatBytes(total)}`,
|
||||
});
|
||||
},
|
||||
calculateMD5Hash: (_image: string) => undefined,
|
||||
} as never);
|
||||
|
||||
callbacks.onLog("Verifying and finalizing...");
|
||||
callbacks.onProgress({ phase: "Finishing", percent: 100, message: "Verifying flash..." });
|
||||
await session.loader.after("hard_reset" as never);
|
||||
callbacks.onLog("Hard reset complete.");
|
||||
}
|
||||
|
||||
export async function disconnect(session: FlashSession): Promise<void> {
|
||||
try {
|
||||
await session.transport.disconnect();
|
||||
} catch {
|
||||
// Best effort
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const kb = bytes / 1024;
|
||||
if (kb < 1024) return `${kb.toFixed(1)} KB`;
|
||||
return `${(kb / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { Release, ReleaseAsset } from "./types";
|
||||
|
||||
const REPO = "donglora/firmware";
|
||||
const API_BASE = `https://api.github.com/repos/${REPO}/releases`;
|
||||
// Bump CACHE_VERSION when the cached data shape changes to invalidate stale entries
|
||||
const CACHE_VERSION = 2;
|
||||
const CACHE_KEY = `donglora-releases-v${CACHE_VERSION}`;
|
||||
const ETAG_KEY = `donglora-releases-etag-v${CACHE_VERSION}`;
|
||||
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
const TIMESTAMP_KEY = `donglora-releases-ts-v${CACHE_VERSION}`;
|
||||
|
||||
interface CachedData {
|
||||
releases: Release[];
|
||||
timestamp: number;
|
||||
etag: string | null;
|
||||
}
|
||||
|
||||
function getCached(): CachedData | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(CACHE_KEY);
|
||||
const ts = sessionStorage.getItem(TIMESTAMP_KEY);
|
||||
const etag = sessionStorage.getItem(ETAG_KEY);
|
||||
if (!raw || !ts) return null;
|
||||
return { releases: JSON.parse(raw), timestamp: Number(ts), etag };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function setCache(releases: Release[], etag: string | null): void {
|
||||
try {
|
||||
sessionStorage.setItem(CACHE_KEY, JSON.stringify(releases));
|
||||
sessionStorage.setItem(TIMESTAMP_KEY, String(Date.now()));
|
||||
if (etag) sessionStorage.setItem(ETAG_KEY, etag);
|
||||
} catch {
|
||||
// sessionStorage full or unavailable — ignore
|
||||
}
|
||||
}
|
||||
|
||||
function parseRelease(raw: GitHubRelease): Release {
|
||||
const tag = raw.tag_name;
|
||||
const version = tag.startsWith("v") ? tag.slice(1) : tag;
|
||||
return {
|
||||
tag,
|
||||
version,
|
||||
name: raw.name || tag,
|
||||
date: raw.published_at?.split("T")[0] ?? "",
|
||||
body: raw.body ?? "",
|
||||
prerelease: raw.prerelease,
|
||||
assets: raw.assets.map(
|
||||
(a): ReleaseAsset => ({
|
||||
name: a.name,
|
||||
downloadUrl: a.browser_download_url,
|
||||
fetchUrl: `/api/firmware/${REPO}/${tag}/${a.name}`,
|
||||
size: a.size,
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
interface GitHubRelease {
|
||||
tag_name: string;
|
||||
name: string | null;
|
||||
published_at: string | null;
|
||||
body: string | null;
|
||||
prerelease: boolean;
|
||||
assets: { name: string; browser_download_url: string; url: string; size: number }[];
|
||||
}
|
||||
|
||||
export async function fetchReleases(): Promise<Release[]> {
|
||||
const cached = getCached();
|
||||
|
||||
// Return fresh cache without hitting the network
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
||||
return cached.releases;
|
||||
}
|
||||
|
||||
// Try conditional request with ETag
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
};
|
||||
if (cached?.etag) {
|
||||
headers["If-None-Match"] = cached.etag;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch(API_BASE, { headers });
|
||||
|
||||
if (resp.status === 304 && cached) {
|
||||
// Not modified — refresh timestamp, return cached
|
||||
setCache(cached.releases, cached.etag);
|
||||
return cached.releases;
|
||||
}
|
||||
|
||||
if (resp.status === 403 || resp.status === 429) {
|
||||
// Rate limited — return cached if available
|
||||
if (cached) return cached.releases;
|
||||
throw new Error("GitHub API rate limit exceeded. Try again later.");
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new Error(`GitHub API error: ${resp.status}`);
|
||||
}
|
||||
|
||||
const data: GitHubRelease[] = await resp.json();
|
||||
const releases = data.map(parseRelease);
|
||||
const etag = resp.headers.get("etag");
|
||||
setCache(releases, etag);
|
||||
return releases;
|
||||
} catch (err) {
|
||||
// Network error — return cached if available
|
||||
if (cached) return cached.releases;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export function getDownloadUrl(release: Release, assetName: string): string | undefined {
|
||||
return release.assets.find((a) => a.name === assetName)?.url;
|
||||
}
|
||||
|
||||
export function releasesPageUrl(): string {
|
||||
return `https://github.com/${REPO}/releases`;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
export type BoardFamily = "esp32s3" | "nrf52840";
|
||||
export type FlashMethod = "web-serial" | "uf2" | "download-only";
|
||||
|
||||
export interface BoardDef {
|
||||
id: string;
|
||||
name: string;
|
||||
mcu: string;
|
||||
radio: string;
|
||||
family: BoardFamily;
|
||||
flashMethod: FlashMethod;
|
||||
assetPattern: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface Release {
|
||||
tag: string;
|
||||
version: string;
|
||||
name: string;
|
||||
date: string;
|
||||
body: string;
|
||||
assets: ReleaseAsset[];
|
||||
prerelease: boolean;
|
||||
}
|
||||
|
||||
export interface ReleaseAsset {
|
||||
name: string;
|
||||
/** browser_download_url — for <a download> links (human-facing, redirects) */
|
||||
downloadUrl: string;
|
||||
/** Proxy URL — for fetch() in the browser (avoids CORS issues with GitHub) */
|
||||
fetchUrl: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export type FlashState =
|
||||
| "idle"
|
||||
| "board-selected"
|
||||
| "version-selected"
|
||||
| "connecting"
|
||||
| "downloading"
|
||||
| "flashing"
|
||||
| "done"
|
||||
| "error";
|
||||
|
||||
export interface FlashProgress {
|
||||
phase: string;
|
||||
percent: number;
|
||||
message: string;
|
||||
}
|
||||
Reference in new issue
Block a user