conv. commit
This commit is contained in:
24 files changed
+5159
No files matched your search
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.astro/
|
||||
.pnpm-approve-builds.json
|
||||
@@ -0,0 +1,47 @@
|
||||
import { defineConfig } from "astro/config";
|
||||
import svelte from "@astrojs/svelte";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
// Dev-only middleware that proxies /api/firmware/* to GitHub releases,
|
||||
// following redirects server-side to avoid CORS issues.
|
||||
function firmwareProxy() {
|
||||
return {
|
||||
name: "firmware-proxy",
|
||||
configureServer(server) {
|
||||
server.middlewares.use(async (req, res, next) => {
|
||||
const match = req.url?.match(
|
||||
/^\/api\/firmware\/([^/]+)\/([^/]+)\/([^/]+)\/(.+)$/,
|
||||
);
|
||||
if (!match) return next();
|
||||
|
||||
const [, owner, repo, tag, filename] = match;
|
||||
const ghUrl = `https://github.com/${owner}/${repo}/releases/download/${tag}/${filename}`;
|
||||
|
||||
try {
|
||||
const resp = await fetch(ghUrl, { redirect: "follow" });
|
||||
if (!resp.ok) {
|
||||
res.writeHead(resp.status);
|
||||
res.end(`GitHub returned ${resp.status}`);
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
});
|
||||
const buffer = Buffer.from(await resp.arrayBuffer());
|
||||
res.end(buffer);
|
||||
} catch (err) {
|
||||
res.writeHead(502);
|
||||
res.end(`Proxy error: ${err}`);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [svelte()],
|
||||
vite: {
|
||||
plugins: [tailwindcss(), firmwareProxy()],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
[build]
|
||||
command = "pnpm build"
|
||||
publish = "dist"
|
||||
|
||||
[build.environment]
|
||||
NODE_VERSION = "22"
|
||||
@@ -0,0 +1,36 @@
|
||||
// Proxies firmware binary downloads from GitHub Releases to avoid CORS restrictions.
|
||||
// URL: /api/firmware/:owner/:repo/:tag/:filename
|
||||
// Example: /api/firmware/donglora/firmware/v0.2.1/donglora-heltec_v4-v0.2.1.bin
|
||||
|
||||
export default async (request: Request) => {
|
||||
const url = new URL(request.url);
|
||||
const match = url.pathname.match(
|
||||
/^\/api\/firmware\/([^/]+)\/([^/]+)\/([^/]+)\/([^/]+)$/,
|
||||
);
|
||||
|
||||
if (!match) {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
const [, owner, repo, tag, filename] = match;
|
||||
const ghUrl = `https://github.com/${owner}/${repo}/releases/download/${tag}/${filename}`;
|
||||
|
||||
const resp = await fetch(ghUrl);
|
||||
if (!resp.ok) {
|
||||
return new Response(`GitHub returned ${resp.status}`, { status: resp.status });
|
||||
}
|
||||
|
||||
return new Response(resp.body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
"Cache-Control": "public, max-age=86400",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const config = {
|
||||
path: "/api/firmware/*",
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "donglora-website",
|
||||
"type": "module",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview"
|
||||
},
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"dependencies": {
|
||||
"@astrojs/svelte": "^8.0.4",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"astro": "^6.1.4",
|
||||
"esptool-js": "^0.6.0",
|
||||
"marked": "^18.0.0",
|
||||
"svelte": "^5.55.2",
|
||||
"tailwindcss": "^4.2.2"
|
||||
}
|
||||
}
|
||||
Generated
+3579
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="4" fill="#0a0a0f"/>
|
||||
<text x="16" y="22" font-family="monospace" font-size="16" font-weight="bold" fill="#6ee7b7" text-anchor="middle">D</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 248 B |
@@ -0,0 +1,74 @@
|
||||
<script lang="ts">
|
||||
import type { BoardDef } from "../lib/types";
|
||||
import { boards } from "../lib/boards";
|
||||
|
||||
interface Props {
|
||||
selected: BoardDef | null;
|
||||
onselect: (board: BoardDef) => void;
|
||||
}
|
||||
|
||||
let { selected, onselect }: Props = $props();
|
||||
|
||||
const espBoards = boards.filter((b) => b.family === "esp32s3");
|
||||
const nrfBoards = boards.filter((b) => b.family === "nrf52840");
|
||||
|
||||
function badgeText(board: BoardDef): string {
|
||||
switch (board.flashMethod) {
|
||||
case "web-serial":
|
||||
return "Web Flash";
|
||||
case "uf2":
|
||||
return "UF2 Download";
|
||||
case "download-only":
|
||||
return "CLI Only";
|
||||
}
|
||||
}
|
||||
|
||||
function badgeColor(board: BoardDef): string {
|
||||
switch (board.flashMethod) {
|
||||
case "web-serial":
|
||||
return "text-accent";
|
||||
case "uf2":
|
||||
return "text-info";
|
||||
case "download-only":
|
||||
return "text-warn";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<h3 class="font-mono text-sm text-text-muted mb-3 uppercase tracking-wider">Select Board</h3>
|
||||
|
||||
<p class="text-xs text-text-dim mb-2 font-mono">ESP32-S3</p>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-4">
|
||||
{#each espBoards as board}
|
||||
<button
|
||||
class="text-left bg-bg-card border p-4 rounded transition-all cursor-pointer
|
||||
{selected?.id === board.id
|
||||
? 'border-accent shadow-[0_0_12px_oklch(from_var(--color-accent)_l_c_h_/_0.15)]'
|
||||
: 'border-border hover:border-border-bright'}"
|
||||
onclick={() => onselect(board)}
|
||||
>
|
||||
<div class="font-mono font-medium text-text text-sm">{board.name}</div>
|
||||
<div class="text-xs text-text-muted mt-1">{board.mcu} · {board.radio}</div>
|
||||
<div class="text-xs {badgeColor(board)} mt-2 font-mono">{badgeText(board)}</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-text-dim mb-2 font-mono">nRF52840</p>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{#each nrfBoards as board}
|
||||
<button
|
||||
class="text-left bg-bg-card border p-4 rounded transition-all cursor-pointer
|
||||
{selected?.id === board.id
|
||||
? 'border-accent shadow-[0_0_12px_oklch(from_var(--color-accent)_l_c_h_/_0.15)]'
|
||||
: 'border-border hover:border-border-bright'}"
|
||||
onclick={() => onselect(board)}
|
||||
>
|
||||
<div class="font-mono font-medium text-text text-sm">{board.name}</div>
|
||||
<div class="text-xs text-text-muted mt-1">{board.mcu} · {board.radio}</div>
|
||||
<div class="text-xs {badgeColor(board)} mt-2 font-mono">{badgeText(board)}</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,246 @@
|
||||
<script lang="ts">
|
||||
import type { BoardDef, Release, FlashProgress } from "../lib/types";
|
||||
import { findAsset, getAssetName } from "../lib/boards";
|
||||
import { isWebSerialSupported, connect, flash, disconnect } from "../lib/esptool";
|
||||
import ProgressBar from "./ProgressBar.svelte";
|
||||
import FlashLog from "./FlashLog.svelte";
|
||||
|
||||
interface Props {
|
||||
board: BoardDef;
|
||||
release: Release;
|
||||
}
|
||||
|
||||
let { board, release }: Props = $props();
|
||||
|
||||
let state: "prep" | "ready" | "connecting" | "downloading" | "flashing" | "done" | "error" = $state("prep");
|
||||
let progress: FlashProgress = $state({ phase: "", percent: 0, message: "" });
|
||||
let logLines: string[] = $state([]);
|
||||
let errorMessage: string = $state("");
|
||||
|
||||
const webSerialSupported = isWebSerialSupported();
|
||||
const asset = $derived(findAsset(board, release.version, release.assets));
|
||||
const assetName = $derived(getAssetName(board, release.version));
|
||||
|
||||
function addLog(msg: string) {
|
||||
logLines = [...logLines, msg];
|
||||
}
|
||||
|
||||
async function startFlash() {
|
||||
state = "connecting";
|
||||
logLines = [];
|
||||
errorMessage = "";
|
||||
progress = { phase: "Connecting", percent: 0, message: "Waiting for port selection..." };
|
||||
|
||||
const callbacks = {
|
||||
onProgress: (p: FlashProgress) => { progress = p; },
|
||||
onLog: addLog,
|
||||
};
|
||||
|
||||
let session;
|
||||
try {
|
||||
session = await connect(callbacks);
|
||||
} catch (err) {
|
||||
const msg = err instanceof DOMException && err.name === "NotFoundError"
|
||||
? "Port selection cancelled."
|
||||
: `Connection failed: ${err instanceof Error ? err.message : err}`;
|
||||
errorMessage = msg;
|
||||
addLog(`Error: ${msg}`);
|
||||
state = "error";
|
||||
return;
|
||||
}
|
||||
|
||||
// Download firmware
|
||||
state = "downloading";
|
||||
progress = { phase: "Downloading", percent: 0, message: "Fetching firmware..." };
|
||||
addLog(`Downloading ${board.name} firmware v${release.version}...`);
|
||||
|
||||
if (!asset) {
|
||||
errorMessage = `Firmware binary not found for ${board.name} v${release.version}. The .bin file may not be available in this release yet.`;
|
||||
addLog(`Error: ${errorMessage}`);
|
||||
await disconnect(session);
|
||||
state = "error";
|
||||
return;
|
||||
}
|
||||
|
||||
let firmware: Uint8Array;
|
||||
try {
|
||||
const resp = await fetch(asset.fetchUrl);
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
firmware = new Uint8Array(await resp.arrayBuffer());
|
||||
addLog(`Downloaded ${(firmware.length / 1024).toFixed(1)} KB`);
|
||||
} catch (err) {
|
||||
errorMessage = `Failed to download firmware: ${err instanceof Error ? err.message : err}`;
|
||||
addLog(`Error: ${errorMessage}`);
|
||||
await disconnect(session);
|
||||
state = "error";
|
||||
return;
|
||||
}
|
||||
|
||||
// Flash
|
||||
state = "flashing";
|
||||
try {
|
||||
await flash(session, firmware, callbacks);
|
||||
state = "done";
|
||||
addLog("Flash complete! Device has been reset.");
|
||||
} catch (err) {
|
||||
errorMessage = `Flash failed: ${err instanceof Error ? err.message : err}`;
|
||||
addLog(`Error: ${errorMessage}`);
|
||||
state = "error";
|
||||
} finally {
|
||||
await disconnect(session);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Web Flash section -->
|
||||
{#if board.flashMethod === "web-serial"}
|
||||
{#if !webSerialSupported}
|
||||
<div class="bg-bg-card border border-warn/30 rounded p-4">
|
||||
<p class="text-warn text-sm font-medium mb-2">Web Serial not supported</p>
|
||||
<p class="text-text-muted text-sm">
|
||||
Your browser doesn't support Web Serial. Use
|
||||
<strong class="text-text">Chrome</strong>,
|
||||
<strong class="text-text">Edge</strong>, or
|
||||
<strong class="text-text">Opera</strong> (version 89+) for web flashing.
|
||||
You can still download the firmware below and flash with the CLI.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#if state === "prep"}
|
||||
<div class="bg-bg-card border border-border rounded p-5">
|
||||
<h4 class="font-mono text-sm font-medium text-text mb-4">Enter Download Mode</h4>
|
||||
<p class="text-sm text-text-muted mb-4">
|
||||
Before flashing, your {board.name} must be in download mode:
|
||||
</p>
|
||||
<ol class="space-y-3 text-sm mb-5">
|
||||
<li class="flex gap-3">
|
||||
<span class="font-mono text-accent font-bold shrink-0">1.</span>
|
||||
<p class="text-text">
|
||||
Plug in your {board.name} via USB
|
||||
</p>
|
||||
</li>
|
||||
<li class="flex gap-3">
|
||||
<span class="font-mono text-accent font-bold shrink-0">2.</span>
|
||||
<p class="text-text">
|
||||
Hold the <code class="font-mono text-accent bg-bg-elevated px-1.5 py-0.5 rounded">PRG</code> button
|
||||
</p>
|
||||
</li>
|
||||
<li class="flex gap-3">
|
||||
<span class="font-mono text-accent font-bold shrink-0">3.</span>
|
||||
<p class="text-text">
|
||||
While holding <code class="font-mono text-accent bg-bg-elevated px-1.5 py-0.5 rounded">PRG</code>, tap <code class="font-mono text-accent bg-bg-elevated px-1.5 py-0.5 rounded">RST</code> and release both
|
||||
</p>
|
||||
</li>
|
||||
<li class="flex gap-3">
|
||||
<span class="font-mono text-accent font-bold shrink-0">4.</span>
|
||||
<p class="text-text">
|
||||
The display should go blank — the board is now in download mode
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
<button
|
||||
onclick={() => { state = "ready"; }}
|
||||
class="bg-accent text-bg font-mono font-medium px-6 py-3 rounded hover:bg-accent-bright transition-colors cursor-pointer"
|
||||
>
|
||||
Ready — Connect & Flash
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if state === "ready"}
|
||||
<button
|
||||
onclick={startFlash}
|
||||
class="bg-accent text-bg font-mono font-medium px-6 py-3 rounded hover:bg-accent-bright transition-colors cursor-pointer"
|
||||
>
|
||||
Connect & Flash
|
||||
</button>
|
||||
<p class="text-xs text-text-dim">
|
||||
Your browser will ask you to select a serial port.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if state === "connecting" || state === "downloading" || state === "flashing"}
|
||||
<ProgressBar percent={progress.percent} label={progress.phase} />
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm text-text-muted font-mono">{progress.message}</p>
|
||||
{#if progress.percent === 100}
|
||||
<svg class="animate-spin h-4 w-4 text-accent" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
{#if progress.percent === 100}
|
||||
<p class="text-xs text-text-dim">Finishing up — please don't disconnect the device...</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if state === "done"}
|
||||
<div class="bg-accent/10 border border-accent/30 rounded p-4">
|
||||
<p class="text-accent font-mono font-medium">Flash successful!</p>
|
||||
<p class="text-text-muted text-sm mt-1">
|
||||
Your {board.name} is now running DongLoRa v{release.version}.
|
||||
</p>
|
||||
<p class="text-text-dim text-xs mt-2">
|
||||
If the board doesn't restart automatically, press the <code class="font-mono text-accent bg-bg-elevated px-1 rounded">RST</code> button to reboot it.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if state === "error"}
|
||||
<div class="bg-error/10 border border-error/30 rounded p-4">
|
||||
<p class="text-error font-mono font-medium text-sm">{errorMessage}</p>
|
||||
<button
|
||||
onclick={() => { state = "prep"; }}
|
||||
class="mt-3 text-sm text-accent hover:text-accent-bright font-mono cursor-pointer"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if state !== "ready"}
|
||||
<FlashLog lines={logLines} />
|
||||
{/if}
|
||||
{/if}
|
||||
{:else if board.flashMethod === "download-only"}
|
||||
<div class="bg-bg-card border border-warn/30 rounded p-4 mb-2">
|
||||
<p class="text-warn text-sm font-medium mb-1">CP2102 UART — web flashing not supported</p>
|
||||
<p class="text-text-muted text-sm">{board.notes}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Download + CLI instructions (always shown) -->
|
||||
<div class="bg-bg-card border border-border rounded p-5 mt-2">
|
||||
<h4 class="font-mono text-sm font-medium text-text mb-3">Download & Flash via CLI</h4>
|
||||
|
||||
{#if asset}
|
||||
<a
|
||||
href={asset.downloadUrl}
|
||||
class="inline-block bg-bg-elevated border border-border text-accent font-mono text-sm px-4 py-2 rounded hover:border-accent transition-colors"
|
||||
download
|
||||
>
|
||||
Download {assetName}
|
||||
</a>
|
||||
{:else}
|
||||
<p class="text-warn text-sm mb-2">
|
||||
.bin file not available for this release.
|
||||
<a
|
||||
href="https://github.com/donglora/firmware/releases"
|
||||
class="text-accent hover:text-accent-bright"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Check GitHub releases
|
||||
</a>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="mt-3 text-sm text-text-muted">
|
||||
<p class="mb-2">Flash with <a href="https://github.com/esp-rs/espflash" class="text-accent hover:text-accent-bright" target="_blank" rel="noopener noreferrer">espflash</a>:</p>
|
||||
<pre class="font-mono text-xs text-accent bg-bg-elevated p-3 rounded overflow-x-auto">cargo install espflash
|
||||
espflash flash {assetName}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
let { lines }: Props = $props();
|
||||
let container: HTMLDivElement | undefined = $state();
|
||||
|
||||
$effect(() => {
|
||||
if (lines.length && container) {
|
||||
// Scroll after DOM update
|
||||
queueMicrotask(() => {
|
||||
container!.scrollTop = container!.scrollHeight;
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={container}
|
||||
class="terminal-scroll bg-bg-card border border-border rounded font-mono text-xs p-4 h-48 overflow-y-auto"
|
||||
>
|
||||
{#each lines as line}
|
||||
<div class="text-accent/80 leading-relaxed">{line}</div>
|
||||
{/each}
|
||||
{#if lines.length === 0}
|
||||
<div class="text-text-dim">Waiting for connection...</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,82 @@
|
||||
<script lang="ts">
|
||||
import type { BoardDef, Release } from "../lib/types";
|
||||
import { fetchReleases } from "../lib/github";
|
||||
import BoardSelector from "./BoardSelector.svelte";
|
||||
import VersionSelector from "./VersionSelector.svelte";
|
||||
import EspFlashFlow from "./EspFlashFlow.svelte";
|
||||
import Uf2DownloadFlow from "./Uf2DownloadFlow.svelte";
|
||||
|
||||
let selectedBoard: BoardDef | null = $state(null);
|
||||
let selectedRelease: Release | null = $state(null);
|
||||
|
||||
let releases: Release[] = $state([]);
|
||||
let releasesLoading = $state(true);
|
||||
let releasesError: string | null = $state(null);
|
||||
|
||||
// Fetch releases on mount
|
||||
$effect(() => {
|
||||
fetchReleases()
|
||||
.then((data) => {
|
||||
releases = data;
|
||||
releasesLoading = false;
|
||||
// Auto-select latest stable release
|
||||
const stable = data.find((r) => !r.prerelease);
|
||||
if (stable) selectedRelease = stable;
|
||||
})
|
||||
.catch((err) => {
|
||||
releasesError = err instanceof Error ? err.message : "Failed to load releases";
|
||||
releasesLoading = false;
|
||||
});
|
||||
});
|
||||
|
||||
function handleBoardSelect(board: BoardDef) {
|
||||
selectedBoard = board;
|
||||
}
|
||||
|
||||
function handleVersionSelect(release: Release) {
|
||||
selectedRelease = release;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-8">
|
||||
<!-- Step 1: Board Selection -->
|
||||
<BoardSelector selected={selectedBoard} onselect={handleBoardSelect} />
|
||||
|
||||
<!-- Step 2: Version Selection (shown after board selected) -->
|
||||
{#if selectedBoard}
|
||||
<div class="border-t border-border pt-8">
|
||||
<VersionSelector
|
||||
{releases}
|
||||
selected={selectedRelease}
|
||||
onselect={handleVersionSelect}
|
||||
loading={releasesLoading}
|
||||
error={releasesError}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Step 3: Flash/Download (shown after both selected) -->
|
||||
{#if selectedBoard && selectedRelease}
|
||||
<div class="border-t border-border pt-8">
|
||||
<h3 class="font-mono text-sm text-text-muted mb-3 uppercase tracking-wider">
|
||||
{#if selectedBoard.family === "esp32s3"}
|
||||
Flash Firmware
|
||||
{:else}
|
||||
Download Firmware
|
||||
{/if}
|
||||
</h3>
|
||||
|
||||
{#if selectedBoard.notes}
|
||||
<div class="bg-bg-elevated border border-border rounded p-3 mb-4">
|
||||
<p class="text-xs text-text-muted">{selectedBoard.notes}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if selectedBoard.family === "esp32s3"}
|
||||
<EspFlashFlow board={selectedBoard} release={selectedRelease} />
|
||||
{:else}
|
||||
<Uf2DownloadFlow board={selectedBoard} release={selectedRelease} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
percent: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
let { percent, label }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="w-full">
|
||||
{#if label}
|
||||
<div class="flex justify-between text-xs font-mono text-text-muted mb-1">
|
||||
<span>{label}</span>
|
||||
<span>{percent}%</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="w-full h-2 bg-bg-elevated rounded overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-accent rounded transition-all duration-200"
|
||||
style="width: {percent}%; box-shadow: 0 0 8px oklch(from var(--color-accent) l c h / 0.4);"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,105 @@
|
||||
<script lang="ts">
|
||||
import type { BoardDef, Release } from "../lib/types";
|
||||
import { findAsset, getAssetName } from "../lib/boards";
|
||||
|
||||
interface Props {
|
||||
board: BoardDef;
|
||||
release: Release;
|
||||
}
|
||||
|
||||
let { board, release }: Props = $props();
|
||||
|
||||
let downloaded = $state(false);
|
||||
|
||||
const asset = $derived(findAsset(board, release.version, release.assets));
|
||||
const assetName = $derived(getAssetName(board, release.version));
|
||||
|
||||
const driveName = $derived(
|
||||
board.id === "rak_wisblock_4631"
|
||||
? "RAK4631"
|
||||
: board.id === "wio_tracker_l1"
|
||||
? "WIO-L1"
|
||||
: "UF2BOOT",
|
||||
);
|
||||
|
||||
function handleDownload() {
|
||||
downloaded = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
{#if asset}
|
||||
<a
|
||||
href={asset.downloadUrl}
|
||||
class="inline-block bg-accent text-bg font-mono font-medium px-6 py-3 rounded hover:bg-accent-bright transition-colors"
|
||||
download
|
||||
onclick={handleDownload}
|
||||
>
|
||||
Download Firmware (.uf2)
|
||||
</a>
|
||||
{:else}
|
||||
<div class="bg-warn/10 border border-warn/30 rounded p-4">
|
||||
<p class="text-warn text-sm">
|
||||
UF2 file not found for {board.name} v{release.version}.
|
||||
<a
|
||||
href="https://github.com/donglora/firmware/releases"
|
||||
class="text-accent hover:text-accent-bright"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Check GitHub releases
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- UF2 flashing instructions (always shown) -->
|
||||
<div class="bg-bg-card border border-border rounded p-5">
|
||||
<h4 class="font-mono text-sm font-medium text-text mb-4">UF2 Flashing Instructions</h4>
|
||||
<ol class="space-y-4 text-sm">
|
||||
<li class="flex gap-3">
|
||||
<span class="font-mono text-accent font-bold shrink-0">1.</span>
|
||||
<div>
|
||||
<p class="text-text">Double-tap the RESET button on your {board.name}</p>
|
||||
<p class="text-text-dim text-xs mt-1">Tap twice quickly (within 500ms). The board enters UF2 bootloader mode.</p>
|
||||
</div>
|
||||
</li>
|
||||
<li class="flex gap-3">
|
||||
<span class="font-mono text-accent font-bold shrink-0">2.</span>
|
||||
<div>
|
||||
<p class="text-text">
|
||||
A USB drive named <code class="font-mono text-accent bg-bg-elevated px-1 rounded">{driveName}</code> should appear on your computer
|
||||
</p>
|
||||
<p class="text-text-dim text-xs mt-1">If it doesn't appear, try a different USB cable or port.</p>
|
||||
</div>
|
||||
</li>
|
||||
<li class="flex gap-3">
|
||||
<span class="font-mono text-accent font-bold shrink-0">3.</span>
|
||||
<div>
|
||||
<p class="text-text">
|
||||
Drag the downloaded <code class="font-mono text-accent bg-bg-elevated px-1 rounded">{assetName}</code> file onto that drive
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li class="flex gap-3">
|
||||
<span class="font-mono text-accent font-bold shrink-0">4.</span>
|
||||
<div>
|
||||
<p class="text-text">Done! The board reboots automatically when flashing completes</p>
|
||||
<p class="text-text-dim text-xs mt-1">The USB drive will disappear and the board will restart with the new firmware.</p>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<!-- CLI alternative -->
|
||||
<div class="bg-bg-card border border-border rounded p-5">
|
||||
<h4 class="font-mono text-sm font-medium text-text mb-3">Alternative: Flash via CLI</h4>
|
||||
<div class="text-sm text-text-muted">
|
||||
<p class="mb-2">
|
||||
If you have a debug probe (J-Link, DAPLink), you can flash the .elf directly:
|
||||
</p>
|
||||
<pre class="font-mono text-xs text-accent bg-bg-elevated p-3 rounded overflow-x-auto">cargo install probe-rs-tools
|
||||
probe-rs run --chip nRF52840_xxAA donglora-{board.id}-v{release.version}.elf</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,109 @@
|
||||
<script lang="ts">
|
||||
import type { Release } from "../lib/types";
|
||||
import { marked } from "marked";
|
||||
|
||||
interface Props {
|
||||
releases: Release[];
|
||||
selected: Release | null;
|
||||
onselect: (release: Release) => void;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
let { releases, selected, onselect, loading, error }: Props = $props();
|
||||
|
||||
let changelogHtml = $derived(selected?.body ? marked.parse(selected.body) : "");
|
||||
|
||||
function handleChange(e: Event) {
|
||||
const tag = (e.target as HTMLSelectElement).value;
|
||||
const release = releases.find((r) => r.tag === tag);
|
||||
if (release) onselect(release);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<h3 class="font-mono text-sm text-text-muted mb-3 uppercase tracking-wider">Select Version</h3>
|
||||
|
||||
{#if loading}
|
||||
<div class="text-text-dim text-sm font-mono">Loading releases...</div>
|
||||
{:else if error}
|
||||
<div class="text-error text-sm">
|
||||
{error}
|
||||
<a
|
||||
href="https://github.com/donglora/firmware/releases"
|
||||
class="text-accent hover:text-accent-bright ml-2"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
View on GitHub
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="relative">
|
||||
<select
|
||||
class="w-full bg-bg-card border border-border rounded px-4 py-2 pr-10 font-mono text-sm text-text
|
||||
focus:outline-none focus:border-accent appearance-none cursor-pointer"
|
||||
value={selected?.tag ?? ""}
|
||||
onchange={handleChange}
|
||||
>
|
||||
<option value="" disabled>Choose a version...</option>
|
||||
{#each releases as release}
|
||||
<option value={release.tag}>
|
||||
{release.version}
|
||||
{release.prerelease ? "(pre-release)" : ""}
|
||||
— {release.date}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
<svg class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{#if selected && changelogHtml}
|
||||
<div class="mt-4 bg-bg-card border border-border rounded p-4">
|
||||
<h4 class="font-mono text-xs text-text-dim uppercase tracking-wider mb-3">Changelog</h4>
|
||||
<div class="prose-dark text-sm text-text-muted leading-relaxed">
|
||||
{@html changelogHtml}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.prose-dark :global(h1),
|
||||
.prose-dark :global(h2),
|
||||
.prose-dark :global(h3) {
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.prose-dark :global(ul) {
|
||||
list-style-type: disc;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
.prose-dark :global(li) {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.prose-dark :global(code) {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8em;
|
||||
color: var(--color-accent);
|
||||
background: var(--color-bg-elevated);
|
||||
padding: 0.1em 0.3em;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.prose-dark :global(a) {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
.prose-dark :global(a:hover) {
|
||||
color: var(--color-accent-bright);
|
||||
}
|
||||
.prose-dark :global(p) {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
import "../styles/global.css";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const { title, description = "Protocol-agnostic LoRa over USB" } = Astro.props;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="description" content={description} />
|
||||
<title>{title}</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body class="min-h-screen bg-bg text-text antialiased">
|
||||
<header
|
||||
class="border-b border-border px-6 py-4 flex items-center justify-between max-w-6xl mx-auto"
|
||||
>
|
||||
<a href="/" class="font-mono font-bold text-xl text-accent hover:text-accent-bright">
|
||||
DongLoRa
|
||||
</a>
|
||||
<nav class="flex gap-6 text-sm">
|
||||
<a href="/" class="text-text-muted hover:text-text transition-colors">Home</a>
|
||||
<a href="/flash" class="text-text-muted hover:text-text transition-colors">Flasher</a>
|
||||
<a
|
||||
href="https://github.com/donglora"
|
||||
class="text-text-muted hover:text-text transition-colors"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="max-w-6xl mx-auto px-6 py-12">
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<footer class="border-t border-border px-6 py-8 mt-16">
|
||||
<div class="max-w-6xl mx-auto text-center text-text-dim text-sm">
|
||||
<p>
|
||||
MIT License ·
|
||||
<a
|
||||
href="https://github.com/donglora"
|
||||
class="text-text-muted hover:text-accent transition-colors"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
github.com/donglora
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
import Base from "../layouts/Base.astro";
|
||||
import Flasher from "../components/Flasher.svelte";
|
||||
---
|
||||
|
||||
<Base title="DongLoRa Flasher" description="Flash DongLoRa firmware onto your LoRa board from the browser">
|
||||
<h1 class="font-mono text-3xl font-bold mb-2">Firmware Flasher</h1>
|
||||
<p class="text-text-muted mb-8">
|
||||
Flash DongLoRa firmware directly from your browser using Web Serial, or download UF2 files for nRF boards.
|
||||
</p>
|
||||
<Flasher client:load />
|
||||
</Base>
|
||||
@@ -0,0 +1,284 @@
|
||||
---
|
||||
import Base from "../layouts/Base.astro";
|
||||
---
|
||||
|
||||
<Base title="DongLoRa — Protocol-agnostic LoRa over USB">
|
||||
<!-- Early development banner -->
|
||||
<div class="bg-warn/10 border border-warn/30 rounded p-4 mb-8">
|
||||
<p class="text-warn text-sm font-medium mb-1">Early development</p>
|
||||
<p class="text-text-muted text-sm">
|
||||
DongLoRa is a tool for building tools. It assumes familiarity with LoRa, serial protocols,
|
||||
and programming. APIs may change. If you're looking for a ready-made mesh device, check out
|
||||
<a href="https://meshtastic.org" class="text-accent hover:text-accent-bright" target="_blank" rel="noopener noreferrer">Meshtastic</a> or
|
||||
<a href="https://meshcore.co.uk" class="text-accent hover:text-accent-bright" target="_blank" rel="noopener noreferrer">MeshCore</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Hero -->
|
||||
<section class="py-16 text-center">
|
||||
<h1 class="font-mono text-5xl sm:text-6xl font-bold text-accent mb-4">DongLoRa</h1>
|
||||
<p class="text-text-dim text-sm mb-3 italic">/ˈdɒŋ.ɡəl.ɔːr.ə/ — dongle + LoRa</p>
|
||||
<p class="text-xl text-text-muted max-w-2xl mx-auto mb-2">
|
||||
Protocol-agnostic LoRa over USB.
|
||||
</p>
|
||||
<p class="text-lg text-text-dim max-w-xl mx-auto mb-8">
|
||||
Plug in. Talk radio.
|
||||
</p>
|
||||
<a
|
||||
href="/flash"
|
||||
class="inline-block bg-accent text-bg font-mono font-medium px-6 py-3 rounded hover:bg-accent-bright transition-colors"
|
||||
>
|
||||
Flash Your Board
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<!-- What is it -->
|
||||
<section class="py-12 max-w-3xl mx-auto">
|
||||
<h2 class="font-mono text-2xl font-bold mb-6 text-text">What is it?</h2>
|
||||
<p class="text-text-muted leading-relaxed mb-4">
|
||||
LoRa radios are cheap, long-range, low-power, and everywhere. But using one from a
|
||||
computer is surprisingly hard. You either run someone else's firmware with someone else's
|
||||
protocol baked in, or you write your own embedded code from scratch.
|
||||
</p>
|
||||
<p class="text-text-muted leading-relaxed mb-4">
|
||||
DongLoRa takes a different approach. Flash firmware onto a LoRa board once, and from that
|
||||
point on it's a dumb USB radio. It doesn't know or care what protocol you're running. It
|
||||
receives bytes from the air and hands them to your computer. It takes bytes from your
|
||||
computer and puts them on the air. That's all it does.
|
||||
</p>
|
||||
<p class="text-text-muted leading-relaxed">
|
||||
Everything else — what those bytes mean, how to route them, what to display, how to respond
|
||||
— happens in your code, on your computer, in whatever language you want.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Architecture -->
|
||||
<section class="py-8 max-w-3xl mx-auto">
|
||||
<pre
|
||||
class="font-mono text-sm text-accent bg-bg-card border border-border p-6 rounded overflow-x-auto"
|
||||
>your code ──► client library ──► USB ──► firmware ──► LoRa radio
|
||||
|
||||
your code ──► client library ──┐
|
||||
your code ──► client library ──┼──► mux ──► USB ──► firmware ──► LoRa radio
|
||||
your code ──► client library ──┘</pre>
|
||||
</section>
|
||||
|
||||
<!-- Why / What can you do -->
|
||||
<section class="py-12 max-w-3xl mx-auto">
|
||||
<h2 class="font-mono text-2xl font-bold mb-6 text-text">What can you do with it?</h2>
|
||||
<p class="text-text-muted leading-relaxed mb-6">
|
||||
Because the firmware is protocol-agnostic, a DongLoRa dongle works with any LoRa network
|
||||
— Meshtastic, MeshCore, custom protocols, or raw packets. You get the feedback loop of a
|
||||
scripting environment instead of a flash-and-pray embedded workflow.
|
||||
</p>
|
||||
<div class="space-y-4">
|
||||
<div class="bg-bg-card border border-border p-5 rounded">
|
||||
<h3 class="font-mono font-medium text-text mb-2">Interact with mesh networks</h3>
|
||||
<p class="text-sm text-text-muted">
|
||||
Send and receive packets on Meshtastic, MeshCore, or any other LoRa protocol from a
|
||||
laptop, desktop, or Raspberry Pi. Write scripts that interact with mesh traffic
|
||||
programmatically.
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-bg-card border border-border p-5 rounded">
|
||||
<h3 class="font-mono font-medium text-text mb-2">Build bots and automations</h3>
|
||||
<p class="text-sm text-text-muted">
|
||||
Write a chatbot that receives messages over LoRa and responds via an LLM. Build an
|
||||
alert system. Log every packet on a frequency. All from Python or Rust on a server.
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-bg-card border border-border p-5 rounded">
|
||||
<h3 class="font-mono font-medium text-text mb-2">Bridge distant networks</h3>
|
||||
<p class="text-sm text-text-muted">
|
||||
The bridge application connects to local LoRa traffic, encrypts it, and relays it to
|
||||
other bridges over the internet. Two bridges in different cities extend a mesh network's
|
||||
range by thousands of miles.
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-bg-card border border-border p-5 rounded">
|
||||
<h3 class="font-mono font-medium text-text mb-2">Prototype without embedded development</h3>
|
||||
<p class="text-sm text-text-muted">
|
||||
Test a LoRa idea in Python in an afternoon. Change frequency, bandwidth, spreading factor
|
||||
— all from your code. Iterate fast. No cross-compilation, no JTAG, no boot loops.
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-bg-card border border-border p-5 rounded">
|
||||
<h3 class="font-mono font-medium text-text mb-2">Analyze RF traffic</h3>
|
||||
<p class="text-sm text-text-muted">
|
||||
Sniff, decode, and log every packet on a frequency. Useful for debugging mesh deployments,
|
||||
understanding channel utilization, or reverse-engineering protocols.
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-bg-card border border-border p-5 rounded">
|
||||
<h3 class="font-mono font-medium text-text mb-2">Deploy headless gateways</h3>
|
||||
<p class="text-sm text-text-muted">
|
||||
A Raspberry Pi with a dongle plugged in is a LoRa gateway. Add the mux and you can run
|
||||
multiple services on a single radio — a mesh node, a packet logger, and a bridge, all at
|
||||
once.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Supported Boards -->
|
||||
<section class="py-12">
|
||||
<h2 class="font-mono text-2xl font-bold mb-6 text-text">Supported Boards</h2>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div class="bg-bg-card border border-border p-5 rounded">
|
||||
<h3 class="font-mono font-medium text-text mb-2">Heltec V3</h3>
|
||||
<p class="text-sm text-text-muted">ESP32-S3 · SX1262</p>
|
||||
<p class="text-xs text-accent mt-2 font-mono">Web Flash</p>
|
||||
</div>
|
||||
<div class="bg-bg-card border border-border p-5 rounded">
|
||||
<h3 class="font-mono font-medium text-text mb-2">Heltec V4</h3>
|
||||
<p class="text-sm text-text-muted">ESP32-S3 · SX1262</p>
|
||||
<p class="text-xs text-accent mt-2 font-mono">Web Flash</p>
|
||||
</div>
|
||||
<div class="bg-bg-card border border-border p-5 rounded">
|
||||
<h3 class="font-mono font-medium text-text mb-2">RAK WisBlock 4631</h3>
|
||||
<p class="text-sm text-text-muted">nRF52840 · SX1262</p>
|
||||
<p class="text-xs text-info mt-2 font-mono">UF2 Download</p>
|
||||
</div>
|
||||
<div class="bg-bg-card border border-border p-5 rounded">
|
||||
<h3 class="font-mono font-medium text-text mb-2">Wio Tracker L1</h3>
|
||||
<p class="text-sm text-text-muted">nRF52840 · SX1262</p>
|
||||
<p class="text-xs text-info mt-2 font-mono">UF2 Download</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Quick Start -->
|
||||
<section class="py-12 max-w-3xl mx-auto">
|
||||
<h2 class="font-mono text-2xl font-bold mb-6 text-text">Quick Start</h2>
|
||||
<ol class="space-y-6">
|
||||
<li class="flex gap-4">
|
||||
<span class="font-mono text-accent font-bold text-lg shrink-0">1.</span>
|
||||
<div>
|
||||
<p class="font-medium text-text">Flash the firmware</p>
|
||||
<p class="text-sm text-text-muted">
|
||||
Use the <a href="/flash" class="text-accent hover:text-accent-bright">web flasher</a> or
|
||||
flash from the command line.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li class="flex gap-4">
|
||||
<span class="font-mono text-accent font-bold text-lg shrink-0">2.</span>
|
||||
<div>
|
||||
<p class="font-medium text-text">Install a client library</p>
|
||||
<pre
|
||||
class="font-mono text-sm text-accent bg-bg-card border border-border px-4 py-2 rounded mt-2"
|
||||
>pip install donglora-python</pre>
|
||||
</div>
|
||||
</li>
|
||||
<li class="flex gap-4">
|
||||
<span class="font-mono text-accent font-bold text-lg shrink-0">3.</span>
|
||||
<div>
|
||||
<p class="font-medium text-text">Send your first packet</p>
|
||||
<pre
|
||||
class="font-mono text-sm text-accent bg-bg-card border border-border px-4 py-3 rounded mt-2 overflow-x-auto"
|
||||
>from donglora import DongLoRa
|
||||
|
||||
radio = DongLoRa()
|
||||
radio.send(b"hello lora")</pre>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<!-- Repositories -->
|
||||
<section class="py-12">
|
||||
<h2 class="font-mono text-2xl font-bold mb-6 text-text">Repositories</h2>
|
||||
|
||||
<!-- Firmware -->
|
||||
<h3 class="font-mono text-sm text-text-dim uppercase tracking-wider mb-3">Firmware</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-8">
|
||||
<a
|
||||
href="https://github.com/donglora/firmware"
|
||||
class="bg-bg-card border border-border p-4 rounded hover:border-border-bright transition-colors block"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span class="font-mono text-accent text-sm">firmware</span>
|
||||
<p class="text-sm text-text-muted mt-1">Embedded firmware for LoRa boards (Rust, no_std, Embassy)</p>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Client Libraries -->
|
||||
<h3 class="font-mono text-sm text-text-dim uppercase tracking-wider mb-3">Client Libraries</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-8">
|
||||
<a
|
||||
href="https://github.com/donglora/client-rs"
|
||||
class="bg-bg-card border border-border p-4 rounded hover:border-border-bright transition-colors block"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span class="font-mono text-accent text-sm">client-rs</span>
|
||||
<p class="text-sm text-text-muted mt-1">Rust client library — <code class="font-mono text-xs text-accent">donglora-client</code> on crates.io</p>
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/donglora/client-py"
|
||||
class="bg-bg-card border border-border p-4 rounded hover:border-border-bright transition-colors block"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span class="font-mono text-accent text-sm">client-py</span>
|
||||
<p class="text-sm text-text-muted mt-1">Python client library — <code class="font-mono text-xs text-accent">donglora-python</code> on PyPI</p>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Multiplexer -->
|
||||
<h3 class="font-mono text-sm text-text-dim uppercase tracking-wider mb-3">Multiplexer</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-8">
|
||||
<a
|
||||
href="https://github.com/donglora/mux-rs"
|
||||
class="bg-bg-card border border-border p-4 rounded hover:border-border-bright transition-colors block"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span class="font-mono text-accent text-sm">mux-rs</span>
|
||||
<p class="text-sm text-text-muted mt-1">USB multiplexer daemon — share one radio across multiple programs (Rust)</p>
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/donglora/mux-py"
|
||||
class="bg-bg-card border border-border p-4 rounded hover:border-border-bright transition-colors block"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span class="font-mono text-accent text-sm">mux-py</span>
|
||||
<p class="text-sm text-text-muted mt-1">USB multiplexer daemon (Python)</p>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Applications -->
|
||||
<h3 class="font-mono text-sm text-text-dim uppercase tracking-wider mb-3">Applications</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<a
|
||||
href="https://github.com/donglora/bridge"
|
||||
class="bg-bg-card border border-border p-4 rounded hover:border-border-bright transition-colors block"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span class="font-mono text-accent text-sm">bridge</span>
|
||||
<p class="text-sm text-text-muted mt-1">P2P LoRa bridge — relays encrypted packets across the internet (Rust)</p>
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/donglora/ai-bot"
|
||||
class="bg-bg-card border border-border p-4 rounded hover:border-border-bright transition-colors block"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span class="font-mono text-accent text-sm">ai-bot</span>
|
||||
<p class="text-sm text-text-muted mt-1">MeshCore AI chatbot — mesh name "Orac" (Python)</p>
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/donglora/examples"
|
||||
class="bg-bg-card border border-border p-4 rounded hover:border-border-bright transition-colors block"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span class="font-mono text-accent text-sm">examples</span>
|
||||
<p class="text-sm text-text-muted mt-1">Ready-to-run scripts — receive, transmit, ping-pong, bridge (Python)</p>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</Base>
|
||||
@@ -0,0 +1,51 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
/* Terminal-inspired dark palette */
|
||||
--color-bg: #0a0a0f;
|
||||
--color-bg-card: #12121a;
|
||||
--color-bg-elevated: #1a1a25;
|
||||
|
||||
--color-border: #2a2a3a;
|
||||
--color-border-bright: #3a3a4f;
|
||||
|
||||
--color-text: #e0e0e8;
|
||||
--color-text-muted: #8888a0;
|
||||
--color-text-dim: #555570;
|
||||
|
||||
--color-accent: #6ee7b7;
|
||||
--color-accent-dim: #34d399;
|
||||
--color-accent-bright: #a7f3d0;
|
||||
|
||||
--color-warn: #fbbf24;
|
||||
--color-error: #f87171;
|
||||
--color-info: #60a5fa;
|
||||
|
||||
--font-mono: "JetBrains Mono", "Fira Code", ui-monospace, monospace;
|
||||
--font-sans: "Inter", system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
color-scheme: dark;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.terminal-scroll::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.terminal-scroll::-webkit-scrollbar-track {
|
||||
background: var(--color-bg-card);
|
||||
}
|
||||
.terminal-scroll::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border-bright);
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { vitePreprocess } from "@astrojs/svelte";
|
||||
|
||||
export default {
|
||||
preprocess: [vitePreprocess()],
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"verbatimModuleSyntax": true
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user