feat: improve UF2 flow

This commit is contained in:
2024-11-03 14:39:35 +01:00
parent 06d122b5d6
commit b4605fe84d
11 changed files with 323 additions and 104 deletions

View File

@@ -4,6 +4,7 @@ const config = {
"node_modules/@fontsource-variable/material-symbols-rounded/files/material-symbols-rounded-latin-full-normal.woff2", "node_modules/@fontsource-variable/material-symbols-rounded/files/material-symbols-rounded-latin-full-normal.woff2",
outputPath: "src/lib/assets/icons.min.woff2", outputPath: "src/lib/assets/icons.min.woff2",
icons: [ icons: [
"deployed_code_update",
"adjust", "adjust",
"add", "add",
"piano", "piano",

View File

@@ -56,6 +56,7 @@
"@types/flexsearch": "^0.7.6", "@types/flexsearch": "^0.7.6",
"@types/w3c-web-serial": "^1.0.6", "@types/w3c-web-serial": "^1.0.6",
"@types/w3c-web-usb": "^1.0.10", "@types/w3c-web-usb": "^1.0.10",
"@types/wicg-file-system-access": "^2023.10.5",
"@vite-pwa/sveltekit": "^0.6.0", "@vite-pwa/sveltekit": "^0.6.0",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"codemirror": "^6.0.1", "codemirror": "^6.0.1",

8
pnpm-lock.yaml generated
View File

@@ -74,6 +74,9 @@ importers:
'@types/w3c-web-usb': '@types/w3c-web-usb':
specifier: ^1.0.10 specifier: ^1.0.10
version: 1.0.10 version: 1.0.10
'@types/wicg-file-system-access':
specifier: ^2023.10.5
version: 2023.10.5
'@vite-pwa/sveltekit': '@vite-pwa/sveltekit':
specifier: ^0.6.0 specifier: ^0.6.0
version: 0.6.0(@sveltejs/kit@2.5.18(@sveltejs/vite-plugin-svelte@3.1.1(svelte@5.0.0-next.221)(vite@5.3.5(@types/node@20.14.10)(sass@1.77.8)(terser@5.31.1)))(svelte@5.0.0-next.221)(vite@5.3.5(@types/node@20.14.10)(sass@1.77.8)(terser@5.31.1)))(vite-plugin-pwa@0.20.1(vite@5.3.5(@types/node@20.14.10)(sass@1.77.8)(terser@5.31.1))(workbox-build@7.1.1)(workbox-window@7.1.0)) version: 0.6.0(@sveltejs/kit@2.5.18(@sveltejs/vite-plugin-svelte@3.1.1(svelte@5.0.0-next.221)(vite@5.3.5(@types/node@20.14.10)(sass@1.77.8)(terser@5.31.1)))(svelte@5.0.0-next.221)(vite@5.3.5(@types/node@20.14.10)(sass@1.77.8)(terser@5.31.1)))(vite-plugin-pwa@0.20.1(vite@5.3.5(@types/node@20.14.10)(sass@1.77.8)(terser@5.31.1))(workbox-build@7.1.1)(workbox-window@7.1.0))
@@ -1408,6 +1411,9 @@ packages:
'@types/w3c-web-usb@1.0.10': '@types/w3c-web-usb@1.0.10':
resolution: {integrity: sha512-CHgUI5kTc/QLMP8hODUHhge0D4vx+9UiAwIGiT0sTy/B2XpdX1U5rJt6JSISgr6ikRT7vxV9EVAFeYZqUnl1gQ==} resolution: {integrity: sha512-CHgUI5kTc/QLMP8hODUHhge0D4vx+9UiAwIGiT0sTy/B2XpdX1U5rJt6JSISgr6ikRT7vxV9EVAFeYZqUnl1gQ==}
'@types/wicg-file-system-access@2023.10.5':
resolution: {integrity: sha512-e9kZO9kCdLqT2h9Tw38oGv9UNzBBWaR1MzuAavxPcsV/7FJ3tWbU6RI3uB+yKIDPGLkGVbplS52ub0AcRLvrhA==}
'@types/yauzl@2.10.3': '@types/yauzl@2.10.3':
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
@@ -5537,6 +5543,8 @@ snapshots:
'@types/w3c-web-usb@1.0.10': {} '@types/w3c-web-usb@1.0.10': {}
'@types/wicg-file-system-access@2023.10.5': {}
'@types/yauzl@2.10.3': '@types/yauzl@2.10.3':
dependencies: dependencies:
'@types/node': 20.14.10 '@types/node': 20.14.10

View File

@@ -0,0 +1,55 @@
<script lang="ts">
import { fly } from "svelte/transition";
import { afterNavigate, beforeNavigate } from "$app/navigation";
import { expoIn, expoOut } from "svelte/easing";
import type { Snippet } from "svelte";
let { children, routeOrder }: { children: Snippet; routeOrder: string[]; direction: } =
$props();
let inDirection = $state(0);
let outDirection = $state(0);
let outroEnd: undefined | (() => void) = $state(undefined);
let animationDone: Promise<void>;
let isNavigating = $state(false);
function routeIndex(route: string | undefined): number {
return routeOrder.findIndex((it) => route?.startsWith(it));
}
beforeNavigate((navigation) => {
const from = routeIndex(navigation.from?.url.pathname);
const to = routeIndex(navigation.to?.url.pathname);
if (from === -1 || to === -1 || from === to) return;
isNavigating = true;
inDirection = from > to ? -1 : 1;
outDirection = from > to ? 1 : -1;
animationDone = new Promise((resolve) => {
outroEnd = resolve;
});
});
afterNavigate(async () => {
await animationDone;
isNavigating = false;
});
</script>
{#if !isNavigating}
<main
in:fly={{ y: inDirection * 24, duration: 150, easing: expoOut }}
out:fly={{ y: outDirection * 24, duration: 150, easing: expoIn }}
onoutroend={outroEnd}
>
{@render children()}
</main>
{/if}
<style lang="scss">
main {
padding: 0;
}
</style>

View File

@@ -53,11 +53,13 @@ export interface ProgressInfo {
} }
export const syncProgress = writable<ProgressInfo | undefined>(undefined); export const syncProgress = writable<ProgressInfo | undefined>(undefined);
export async function initSerial(manual = false) { export async function initSerial(manual = false, withSync = true) {
const device = get(serialPort) ?? new CharaDevice(); const device = get(serialPort) ?? new CharaDevice();
await device.init(manual); await device.init(manual);
serialPort.set(device); serialPort.set(device);
await sync(); if (withSync) {
await sync();
}
} }
export async function sync() { export async function sync() {

View File

@@ -14,27 +14,26 @@
let isNavigating = $state(false); let isNavigating = $state(false);
const routeOrder = [ const routeOrder = [
"/config/chords/", "/config",
"/config/layout/", "/learn",
"/config/settings/", "/docs",
"/editor",
"/chat",
"/plugin",
]; ];
function routeIndex(route: string | undefined): number {
return routeOrder.findIndex((it) => route?.startsWith(it));
}
beforeNavigate((navigation) => { beforeNavigate((navigation) => {
const from = navigation.from?.url.pathname; const from = routeIndex(navigation.from?.url.pathname);
const to = navigation.to?.url.pathname; const to = routeIndex(navigation.to?.url.pathname);
if (from === to) return; if (from === -1 || to === -1 || from === to) return;
isNavigating = true; isNavigating = true;
if (!(from && to && routeOrder.includes(from) && routeOrder.includes(to))) { inDirection = from > to ? -1 : 1;
inDirection = 0; outDirection = from > to ? 1 : -1;
outDirection = 0;
} else {
const fromIndex = routeOrder.indexOf(from);
const toIndex = routeOrder.indexOf(to);
inDirection = fromIndex > toIndex ? -1 : 1;
outDirection = fromIndex > toIndex ? 1 : -1;
}
animationDone = new Promise((resolve) => { animationDone = new Promise((resolve) => {
outroEnd = resolve; outroEnd = resolve;
@@ -49,8 +48,8 @@
{#if !isNavigating} {#if !isNavigating}
<main <main
in:fly={{ x: inDirection * 24, duration: 150, easing: expoOut }} in:fly={{ y: inDirection * 24, duration: 150, easing: expoOut }}
out:fly={{ x: outDirection * 24, duration: 150, easing: expoIn }} out:fly={{ y: outDirection * 24, duration: 150, easing: expoIn }}
onoutroend={outroEnd} onoutroend={outroEnd}
> >
{@render children()} {@render children()}

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
import PageTransition from "./PageTransition.svelte";
import Navigation from "./Navigation.svelte"; import Navigation from "./Navigation.svelte";
let { children }: { children?: Snippet } = $props(); let { children }: { children?: Snippet } = $props();
@@ -8,5 +9,9 @@
<Navigation /> <Navigation />
{#if children} {#if children}
{@render children()} <PageTransition>
{#if children}
{@render children()}
{/if}
</PageTransition>
{/if} {/if}

View File

@@ -0,0 +1,65 @@
<script lang="ts">
import { fly } from "svelte/transition";
import { afterNavigate, beforeNavigate } from "$app/navigation";
import { expoIn, expoOut } from "svelte/easing";
import type { Snippet } from "svelte";
let { children }: { children: Snippet } = $props();
let inDirection = $state(0);
let outDirection = $state(0);
let outroEnd: undefined | (() => void) = $state(undefined);
let animationDone: Promise<void>;
let isNavigating = $state(false);
const routeOrder = [
"/config/chords/",
"/config/layout/",
"/config/settings/",
];
beforeNavigate((navigation) => {
const from = navigation.from?.url.pathname;
const to = navigation.to?.url.pathname;
if (from === to) return;
isNavigating = true;
if (!(from && to && routeOrder.includes(from) && routeOrder.includes(to))) {
inDirection = 0;
outDirection = 0;
} else {
const fromIndex = routeOrder.indexOf(from);
const toIndex = routeOrder.indexOf(to);
inDirection = fromIndex > toIndex ? -1 : 1;
outDirection = fromIndex > toIndex ? 1 : -1;
}
animationDone = new Promise((resolve) => {
outroEnd = resolve;
});
});
afterNavigate(async () => {
await animationDone;
isNavigating = false;
});
</script>
{#if !isNavigating}
<main
in:fly={{ x: inDirection * 24, duration: 150, easing: expoOut }}
out:fly={{ x: outDirection * 24, duration: 150, easing: expoIn }}
onoutroend={outroEnd}
>
{@render children()}
</main>
{/if}
<style lang="scss">
main {
padding: 0;
width: 100%;
}
</style>

View File

@@ -315,7 +315,7 @@
} }
input[type="checkbox"] { input[type="checkbox"] {
font-size: 12px; font-size: 12px !important;
} }
fieldset { fieldset {

View File

@@ -4,15 +4,6 @@
let { data } = $props(); let { data } = $props();
let files: FileList | null = $state(null);
$effect(() => {
const file = files?.[0];
if (file && $serialPort) {
$serialPort.updateFirmware(file);
}
});
let currentDevice = $derived( let currentDevice = $derived(
$serialPort $serialPort
? `${$serialPort.device.toLowerCase()}_${$serialPort.chipset.toLowerCase()}` ? `${$serialPort.device.toLowerCase()}_${$serialPort.chipset.toLowerCase()}`
@@ -34,8 +25,6 @@
<aside transition:slide>Connect your device to see which one you need</aside> <aside transition:slide>Connect your device to see which one you need</aside>
{/if} {/if}
<input type="file" accept=".bin" bind:files />
<style lang="scss"> <style lang="scss">
ul { ul {
display: flex; display: flex;

View File

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { serialPort } from "$lib/serial/connection"; import { downloadBackup } from "$lib/backup/backup";
import { slide } from "svelte/transition"; import { initSerial, serialPort } from "$lib/serial/connection";
import { fade, slide } from "svelte/transition";
let { data } = $props(); let { data } = $props();
@@ -8,6 +9,8 @@
let success = $state(false); let success = $state(false);
let error = $state<Error | undefined>(undefined); let error = $state<Error | undefined>(undefined);
let step = $state(0);
async function update() { async function update() {
working = true; working = true;
error = undefined; error = undefined;
@@ -59,6 +62,46 @@
return `${(value / 1024 / 1024).toFixed(2)}MB`; return `${(value / 1024 / 1024).toFixed(2)}MB`;
} }
} }
async function connect() {
try {
await initSerial(true, false);
step = 1;
} catch (e) {
error = e as Error;
}
}
function backup() {
downloadBackup();
step = 2;
}
function bootloader() {
$serialPort?.bootloader();
$serialPort = undefined;
step = 3;
}
async function getFileSystem() {
if (!uf2Url) return;
const uf2Promise = fetch(uf2Url).then((it) => it.blob());
const handle = await window.showSaveFilePicker({
id: `${data.device}-update`,
suggestedName: "CURRENT.UF2",
excludeAcceptAllOption: true,
types: [
{
description: "UF2 Firmware",
accept: { "application/octet-stream": [".UF2"] },
},
],
});
const writable = await handle.createWritable();
const uf2 = await uf2Promise;
await uf2.stream().pipeTo(writable);
step = 4;
}
</script> </script>
<div> <div>
@@ -71,6 +114,44 @@
to <em class="version">{data.version}</em> to <em class="version">{data.version}</em>
</h2> </h2>
{#if data.ota && !data.device.endsWith("m0")}
{@const buttonError = error || (!success && isCorrectDevice === false)}
<section>
<button
class="update-button"
class:working
class:primary={!buttonError}
class:error={buttonError}
disabled={working || $serialPort === undefined || !isCorrectDevice}
onclick={update}>Apply Update</button
>
{#if $serialPort && isCorrectDevice}
<div transition:slide>
Your device is ready and compatible. Click the button to perform the
update.
</div>
{:else if $serialPort && isCorrectDevice === false}
<div class="error" transition:slide>
Your device is incompatible with the selected update.
</div>
{:else if success}
<div class="primary" transition:slide>Update successful</div>
{:else if error}
<div class="error" transition:slide>{error.message}</div>
{:else if working}
<div class="primary" transition:slide>Updating your device...</div>
{:else}
<div class="primary" transition:slide>
Connect your device to continue
</div>
{/if}
</section>
<hr />
<h3>Manual Update</h3>
{/if}
<ul class="files"> <ul class="files">
{#if data.uf2} {#if data.uf2}
<li> <li>
@@ -99,59 +180,47 @@
{/if} {/if}
<section> <section>
<h3>OTA Upate</h3> <h4>UF2 Instructions</h4>
{#if data.ota}
{@const buttonError = error || (!success && isCorrectDevice === false)}
<button
class:working
class:primary={!buttonError}
class:error={buttonError}
disabled={working || $serialPort === undefined || !isCorrectDevice}
onclick={update}>Apply Update</button
>
{#if $serialPort && isCorrectDevice}
<div transition:slide>
Your device is ready and compatible. Click the button to perform the
update.
</div>
{:else if $serialPort && isCorrectDevice === false}
<div class="error" transition:slide>
Your device is incompatible with the selected update.
</div>
{:else if success}
<div class="primary" transition:slide>Update successful</div>
{:else if error}
<div class="error" transition:slide>{error.message}</div>
{:else if working}
<div class="primary" transition:slide>Updating your device...</div>
{:else}
<div class="primary" transition:slide>
Connect your device to continue
</div>
{/if}
{:else}
<em>There are no OTA files for this device.</em>
{/if}
</section>
<hr />
<h3>Other options</h3>
<section>
<h4>Via UF2</h4>
<ol> <ol>
<li>Backup your device</li> <li>
<li>Reboot to bootloader</li> <button class="inline-button" onclick={connect}
<li>Save CURRENT.UF2 to the new drive</li> ><span class="icon">usb</span>Connect</button
<li>Restore</li> >
your device
{#if step >= 1}
<span class="icon ok" transition:fade>check_circle</span>
{/if}
</li>
<li class:faded={step < 1}>
Make a <button class="inline-button" onclick={backup}
><span class="icon">download</span>Backup</button
>
{#if step >= 2}
<span class="icon ok" transition:fade>check_circle</span>
{/if}
</li>
<li class:faded={step < 2}>
Reboot to <button class="inline-button" onclick={bootloader}
><span class="icon">restart_alt</span>Bootloader</button
>
{#if step >= 3}
<span class="icon ok" transition:fade>check_circle</span>
{/if}
</li>
<li class:faded={step < 3}>
Replace <button class="inline-button" onclick={getFileSystem}
><span class="icon">deployed_code_update</span>CURRENT.UF2</button
>
on the new drive
{#if step >= 4}
<span class="icon ok" transition:fade>check_circle</span>
{/if}
</li>
</ol> </ol>
</section> </section>
<section>
<h4>Via Serial</h4>
<p>WIP</p>
</section>
ading 0 Chordmaps.
</div> </div>
<style lang="scss"> <style lang="scss">
@@ -189,7 +258,32 @@
} }
} }
button { button.inline-button {
display: inline;
padding: 0;
margin: 0;
height: unset;
font-size: inherit;
color: var(--md-sys-color-primary);
.icon {
font-size: 1.2em;
translate: 0 0.1em;
padding-inline-end: 0.2em;
}
}
.icon.ok {
font-size: 1.2em;
translate: 0 0.1em;
--icon-fill: 1;
}
.faded {
opacity: 0.8;
}
button.update-button {
overflow: hidden; overflow: hidden;
position: relative; position: relative;
height: 42px; height: 42px;
@@ -250,26 +344,26 @@
display: flex; display: flex;
padding: 0; padding: 0;
gap: 8px; gap: 8px;
}
a { a[download] {
display: grid; display: grid;
grid-template-columns: 1fr auto; grid-template-columns: 1fr auto;
grid-template-rows: 1fr; grid-template-rows: 1fr;
border: 1px solid var(--md-sys-color-outline); border: 1px solid var(--md-sys-color-outline);
border-radius: 8px; border-radius: 8px;
font-size: 0.9em; font-size: 0.9em;
height: auto; height: auto;
.size { .size {
font-size: 0.8em; font-size: 0.8em;
opacity: 0.8; opacity: 0.8;
} }
.icon { .icon {
padding-inline-start: 0.4em; padding-inline-start: 0.4em;
grid-column: 2; grid-column: 2;
grid-row: 1 / span 2; grid-row: 1 / span 2;
}
} }
} }