conv. commit

This commit is contained in:
swaits committed 2026-04-08 10:59:57 -06:00
commit fd677e33a0
24 files changed
+5159

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
.astro/
.pnpm-approve-builds.json
+47
View File
@@ -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()],
},
});
+6
View File
@@ -0,0 +1,6 @@
[build]
command = "pnpm build"
publish = "dist"
[build.environment]
NODE_VERSION = "22"
+36
View File
@@ -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/*",
};
+21
View File
@@ -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"
}
}
+3579
View File
File diff suppressed because it is too large. Load diff
+4
View File
@@ -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

+74
View File
@@ -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} &middot; {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} &middot; {board.radio}</div>
<div class="text-xs {badgeColor(board)} mt-2 font-mono">{badgeText(board)}</div>
</button>
{/each}
</div>
</div>
+246
View File
@@ -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 &amp; 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 &amp; 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 &amp; 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>
+29
View File
@@ -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>
+82
View File
@@ -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>
+23
View File
@@ -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>
+105
View File
@@ -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>
+109
View File
@@ -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>
+67
View File
@@ -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 &middot;
<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>
+76
View File
@@ -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 };
}
+122
View File
@@ -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`;
}
+123
View File
@@ -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`;
}
+48
View File
@@ -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;
}
+12
View File
@@ -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>
+284
View File
@@ -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 &middot; 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 &middot; 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 &middot; 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 &middot; 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 &mdash; <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 &mdash; <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 &mdash; 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 &mdash; 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 &mdash; 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 &mdash; receive, transmit, ping-pong, bridge (Python)</p>
</a>
</div>
</section>
</Base>
+51
View File
@@ -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;
}
}
+5
View File
@@ -0,0 +1,5 @@
import { vitePreprocess } from "@astrojs/svelte";
export default {
preprocess: [vitePreprocess()],
};
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"verbatimModuleSyntax": true
}
}