Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 0 additions & 53 deletions src/lib/state/serial-ports-state.svelte.ts

This file was deleted.

76 changes: 76 additions & 0 deletions src/lib/utils/serial-context.svelte.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { isSerialSupported } from '$lib/utils/compatibility';
import { onMount } from 'svelte';

export class SerialContext {
#ports = $state<SerialPort[]>([]);
#onConnect: (e: Event) => void;
#onDisconnect: (e: Event) => void;

constructor() {
this.#onConnect = (e) => this.#addPort(e.target as SerialPort);
this.#onDisconnect = (e) => this.#removePort(e.target as SerialPort);

if (isSerialSupported) {
navigator.serial.addEventListener('connect', this.#onConnect);
navigator.serial.addEventListener('disconnect', this.#onDisconnect);

navigator.serial
.getPorts()
.then((existing) => {
for (const p of existing) {
this.#addPort(p);
}
})
.catch((error) => {
console.error('Failed to get serial ports', error);
});
}
}

get ports(): readonly SerialPort[] {
return this.#ports;
}

async requestPort(options: SerialPortRequestOptions): Promise<SerialPort | null> {
if (!isSerialSupported) return null;

const port = await navigator.serial.requestPort(options);
this.#addPort(port);
return port;
}

destroy() {
if (isSerialSupported) {
navigator.serial.removeEventListener('connect', this.#onConnect);
navigator.serial.removeEventListener('disconnect', this.#onDisconnect);
}
for (const port of this.#ports) {
port.close().catch(() => {});
}
this.#ports.length = 0;
}

#addPort(port: SerialPort) {
if (!this.#ports.includes(port)) {
this.#ports.push(port);
}
}

#removePort(port: SerialPort) {
const idx = this.#ports.indexOf(port);
if (idx !== -1) {
this.#ports.splice(idx, 1);
}
}
}

/**
* Creates a SerialContext scoped to the current component's lifetime.
* Must be called during component initialization (top-level script).
* Automatically cleans up event listeners on unmount.
*/
export function useSerial(): SerialContext {
const ctx = new SerialContext();
onMount(() => () => ctx.destroy());
return ctx;
}
62 changes: 29 additions & 33 deletions src/routes/flashtool/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,17 @@
import { Label } from '$lib/components/ui/label';
import { Progress } from '$lib/components/ui/progress';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '$lib/components/ui/sheet';
import { useSerial } from '$lib/utils/serial-context.svelte';
import { getBrowserName, isSerialSupported } from '$lib/utils/compatibility';
import FirmwareBoardSelector from './FirmwareBoardSelector.svelte';
import FirmwareFlasher from './FirmwareFlasher.svelte';
import FlashManager from './FlashManager';
import { useFlashManager } from './flash-context.svelte';
import HelpDialog from './HelpDialog.svelte';
Comment on lines +18 to 23
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Import paths likely won't resolve: serial-context.svelte.ts and flash-context.svelte.ts are imported as .../serial-context.svelte and ./flash-context.svelte. In this repo, .svelte.ts modules are imported with the full extension (e.g. src/lib/components/dialog-manager/dialog-manager.svelte:3). Update these imports to target the actual file names to avoid build failures.

Copilot uses AI. Check for mistakes.
import SerialPortSelector from './SerialPortSelector.svelte';

const serial = useSerial();

let port = $state<SerialPort | null>(null);
let manager = $state<FlashManager | null>(null);
let connectFailed = $state<boolean>(false);
let isFlashing = $state<boolean>(false);

let terminalOpen = $state<boolean>(false);
let terminalText = $state<string>('');
Expand All @@ -43,16 +43,13 @@
},
};

const flash = useFlashManager(terminal);

$effect(() => {
if (port && !manager) {
connectFailed = false;
FlashManager.Connect(port, terminal).then((m) => {
manager = m;
connectFailed = !manager;
});
} else if (!port && manager) {
manager.disconnect();
manager = null;
if (port && !flash.manager) {
flash.connect(port);
} else if (!port && flash.manager) {
flash.disconnect();
}
});

Expand All @@ -64,52 +61,52 @@
let eraseBeforeFlash = $state<boolean>(false);

async function AppModeDevice() {
if (isFlashing) return;
if (flash.isFlashing) return;
try {
isFlashing = true;
flash.isFlashing = true;

if (!manager) {
if (!flash.manager) {
terminal.writeLine(`Host-side error during reset: no device!`);
return;
}

await manager.ensureApplication(true);
await flash.manager.ensureApplication(true);
} catch (e) {
Comment on lines +68 to 74
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TypeScript nullability: flash.manager is a getter returning FlashManager | null, so if (!flash.manager) return; does not reliably narrow subsequent flash.manager.* calls under strict mode (typically "Object is possibly 'null'"). Assign const manager = flash.manager; after the guard and use manager for the awaited calls.

Copilot uses AI. Check for mistakes.
terminal.writeLine(`Host-side error during reset: ${e}`);
} finally {
isFlashing = false;
flash.isFlashing = false;
}
}

async function RunCommand() {
if (isFlashing) return;
if (flash.isFlashing) return;
try {
isFlashing = true;
flash.isFlashing = true;

if (!manager) {
if (!flash.manager) {
terminal.writeLine(`Couldn't send: no device!`);
return;
}

await manager.ensureApplication();
await manager.sendApplicationCommand(terminalCommand);
await flash.manager.ensureApplication();
await flash.manager.sendApplicationCommand(terminalCommand);
} catch (e) {
Comment on lines +86 to 93
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same strict-nullability issue as above in RunCommand(): the if (!flash.manager) return; guard won't narrow later flash.manager.* calls because manager is a getter that can change between accesses. Store it in a local variable and use that for ensureApplication() / sendApplicationCommand().

Copilot uses AI. Check for mistakes.
terminal.writeLine(`Couldn't send: ${e}`);
} finally {
isFlashing = false;
flash.isFlashing = false;
}
}
</script>

{#snippet mainContent()}
<SerialPortSelector bind:port disabled={isFlashing} />
<SerialPortSelector {serial} bind:port disabled={flash.isFlashing} />

{#if manager}
{#if flash.manager}
<h3 class="scroll-m-20 text-2xl font-semibold tracking-tight">Select Channel</h3>
<FirmwareChannelSelector bind:channel bind:version disabled={isFlashing} />
<FirmwareChannelSelector bind:channel bind:version disabled={flash.isFlashing} />

<h3 class="scroll-m-20 text-2xl font-semibold tracking-tight">Select Board</h3>
<FirmwareBoardSelector {version} bind:selectedBoard={board} disabled={isFlashing} />
<FirmwareBoardSelector {version} bind:selectedBoard={board} disabled={flash.isFlashing} />

<div class="items-top flex space-x-2">
<Checkbox id="erase-before-flash" bind:checked={eraseBeforeFlash} />
Expand All @@ -131,21 +128,20 @@
<FirmwareFlasher
{version}
{board}
{manager}
{flash}
{eraseBeforeFlash}
showNonStableWarning={channel !== 'stable'}
bind:isFlashing
/>
{/if}
{:else if port && !connectFailed}
{:else if port && !flash.connectFailed}
<div class="flex flex-col items-center gap-2">
<span class="text-center text-2xl"> Connecting... </span>
<Progress />
<!-- TODO: Make this a loading animation -->
</div>
{/if}

{#if port && connectFailed}
{#if port && flash.connectFailed}
<div class="flex flex-col items-start gap-2">
<span class="bold text-center text-2xl text-red-500"> Device connection failed </span>
<span class="text-center">
Expand Down Expand Up @@ -227,7 +223,7 @@
<div class="flex-1"></div>
<Button class="m-2" onclick={() => (terminalText = '')}>Clear</Button>
<!-- Reset & start application -->
<Button onclick={AppModeDevice} disabled={!manager || isFlashing}>Reset</Button>
<Button onclick={AppModeDevice} disabled={!flash.manager || flash.isFlashing}>Reset</Button>
</SheetTitle>
</SheetHeader>
<div
Expand Down
1 change: 1 addition & 0 deletions src/routes/flashtool/+page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const ssr = false;
30 changes: 11 additions & 19 deletions src/routes/flashtool/FirmwareFlasher.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,34 +3,26 @@
import { DownloadAndVerifyBoardBinary } from '$lib/api/firmwareCDN';
import { Button } from '$lib/components/ui/button';
import { Progress } from '$lib/components/ui/progress';
import FlashManager from './FlashManager';
import type { FlashContext } from './flash-context.svelte';
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Import path likely won't resolve: the file is flash-context.svelte.ts, but this imports ./flash-context.svelte. Elsewhere in the repo .svelte.ts modules are imported with the full extension (e.g. src/lib/components/dialog-manager/dialog-manager.svelte:3). Update the import to target the actual file name so the build doesn't fail.

Suggested change
import type { FlashContext } from './flash-context.svelte';
import type { FlashContext } from './flash-context.svelte.ts';

Copilot uses AI. Check for mistakes.
import RiskAcknowledgementModal from './RiskAcknowledgementModal.svelte';

interface Props {
version: string;
board: string;
manager: FlashManager;
flash: FlashContext;
eraseBeforeFlash: boolean;
showNonStableWarning: boolean;
isFlashing?: boolean;
}

let {
version,
board,
manager,
eraseBeforeFlash,
showNonStableWarning,
isFlashing = $bindable(false),
}: Props = $props();
let { version, board, flash, eraseBeforeFlash, showNonStableWarning }: Props = $props();

let riskAcknowledgeStatus = $state<'none' | 'shown' | 'accepted'>('none');
let progressName = $state<string | null>(null);
let progressPercent = $state<number | null>(null);
let error = $state<string | null>(null);

async function FlashDeviceImpl() {
if (!version || !board || !manager) {
if (!version || !board || !flash.manager) {
progressName = null;
error = 'No device selected.';
return;
Expand All @@ -45,7 +37,7 @@

progressName = 'Resetting...';
progressPercent = null;
await manager.ensureBootloader();
await flash.manager.ensureBootloader();

progressName = 'Downloading firmware...';
progressPercent = null;
Expand All @@ -58,28 +50,28 @@

progressName = 'Flashing firmware...';
progressPercent = null;
await manager.flash(firmware, eraseBeforeFlash, progressCallback);
await flash.manager.flash(firmware, eraseBeforeFlash, progressCallback);

progressName = 'Rebooting device... (Reconnect to power manually if stuck)';
progressPercent = null;
await manager.ensureApplication();
await flash.manager.ensureApplication();

progressName = 'Rebooted device! Flashing complete.';
progressPercent = 100;
}
async function FlashDevice() {
if (isFlashing) return;
if (flash.isFlashing) return;
if (showNonStableWarning && riskAcknowledgeStatus !== 'accepted') {
riskAcknowledgeStatus = 'shown';
return;
}
try {
isFlashing = true;
flash.isFlashing = true;
await FlashDeviceImpl();
} catch (e) {
error = (e as Error).message;
} finally {
isFlashing = false;
flash.isFlashing = false;
}
}
</script>
Expand All @@ -92,7 +84,7 @@

<div class="flex flex-col items-stretch justify-start gap-4">
<!-- Flash button -->
<Button onclick={FlashDevice} disabled={!manager || isFlashing}>
<Button onclick={FlashDevice} disabled={!flash.manager || flash.isFlashing}>
<Microchip />
Flash
</Button>
Expand Down
Loading
Loading