feat: new connection flow

This commit is contained in:
2023-07-23 17:44:26 +02:00
parent 998a400395
commit 4cc3343984
9 changed files with 221 additions and 96 deletions

View File

@@ -27,6 +27,7 @@ const config: IconsConfig = {
"sync", "sync",
"restart_alt", "restart_alt",
"usb", "usb",
"usb_off",
"rule_settings", "rule_settings",
"123", "123",
"abc", "abc",
@@ -34,6 +35,7 @@ const config: IconsConfig = {
"cloud_done", "cloud_done",
"backup", "backup",
"cloud_download", "cloud_download",
"cloud_off",
"share", "share",
"ios_share", "ios_share",
"close", "close",
@@ -41,6 +43,14 @@ const config: IconsConfig = {
"arrow_back_ios_new", "arrow_back_ios_new",
"save", "save",
"settings_backup_restore", "settings_backup_restore",
"sort",
"filter",
"settings_power",
"link",
"link_off",
"chevron_right",
"check_circle",
"error",
], ],
codePoints: { codePoints: {
speed: "e9e4", speed: "e9e4",

View File

@@ -8,6 +8,7 @@ export const popup: Action<HTMLButtonElement, ComponentType> = (node, Component)
const edit = tippy(node, { const edit = tippy(node, {
interactive: true, interactive: true,
trigger: "click", trigger: "click",
sticky: true,
onShow(instance) { onShow(instance) {
target = instance.popper.querySelector(".tippy-content") as HTMLElement target = instance.popper.querySelector(".tippy-content") as HTMLElement
target.classList.add("active") target.classList.add("active")

View File

@@ -3,12 +3,12 @@ import type {Action} from "svelte/action"
export interface UserPreferences { export interface UserPreferences {
backup: boolean backup: boolean
autoSync: boolean autoConnect: boolean
} }
export const userPreferences = writable<UserPreferences>({ export const userPreferences = writable<UserPreferences>({
backup: false, backup: false,
autoSync: true, autoConnect: true,
}) })
export const preference: Action<HTMLInputElement, keyof UserPreferences> = (node, key) => { export const preference: Action<HTMLInputElement, keyof UserPreferences> = (node, key) => {

View File

@@ -25,9 +25,9 @@ export const syncStatus: Writable<"done" | "error" | "downloading" | "uploading"
let device: CharaDevice // @hmr:keep let device: CharaDevice // @hmr:keep
export async function initSerial() { export async function initSerial(manual = false) {
const device = get(serialPort) ?? new CharaDevice() const device = get(serialPort) ?? new CharaDevice()
await device.ready() await device.init(manual)
serialPort.set(device) serialPort.set(device)
syncStatus.set("downloading") syncStatus.set("downloading")

View File

@@ -14,25 +14,29 @@ export async function canAutoConnect() {
} }
export class CharaDevice { export class CharaDevice {
private readonly port: Promise<SerialPort> private port!: SerialPort
private readonly reader: Promise<ReadableStreamDefaultReader<string>> private reader!: ReadableStreamDefaultReader<string>
private readonly abortController1 = new AbortController() private readonly abortController1 = new AbortController()
private readonly abortController2 = new AbortController() private readonly abortController2 = new AbortController()
private streamClosed!: Promise<void>
private lock?: Promise<true> private lock?: Promise<true>
version: Promise<string> version!: string
deviceId: Promise<string> deviceId!: string
constructor(baudRate = 115200) { constructor(private readonly baudRate = 115200) {}
this.port = getViablePorts().then(async ports => {
const port = async init(manual = false) {
ports.length === 1 const ports = await getViablePorts()
this.port =
!manual && ports.length === 1
? ports[0] ? ports[0]
: await navigator.serial.requestPort({filters: [{usbVendorId: VENDOR_ID}]}) : await navigator.serial.requestPort({filters: [{usbVendorId: VENDOR_ID}]})
await port.open({baudRate}) await this.port.open({baudRate: this.baudRate})
const info = port.getInfo() const info = this.port.getInfo()
serialLog.update(it => { serialLog.update(it => {
it.push({ it.push({
type: "system", type: "system",
@@ -42,46 +46,39 @@ export class CharaDevice {
}) })
return it return it
}) })
return port
})
this.reader = this.port.then(async port => {
const decoderStream = new TextDecoderStream()
void port.readable!.pipeTo(decoderStream.writable, {signal: this.abortController1.signal})
return decoderStream const decoderStream = new TextDecoderStream()
this.streamClosed = this.port.readable!.pipeTo(decoderStream.writable, {
signal: this.abortController1.signal,
})
this.reader = decoderStream
.readable!.pipeThrough(new TransformStream(new LineBreakTransformer()), { .readable!.pipeThrough(new TransformStream(new LineBreakTransformer()), {
signal: this.abortController2.signal, signal: this.abortController2.signal,
}) })
.getReader() .getReader()
})
this.lock = this.reader.then(() => { this.version = await this.send("VERSION")
delete this.lock this.deviceId = await this.send("ID")
return true
})
this.version = this.send("VERSION")
this.deviceId = this.send("ID")
} }
private async internalRead() { private async internalRead() {
return this.reader.then(async it => { const {value} = await this.reader.read()
const result: string = await it.read().then(({value}) => value!)
serialLog.update(it => { serialLog.update(it => {
it.push({ it.push({
type: "output", type: "output",
value: result, value: value!,
}) })
return it return it
}) })
return result return value!
})
} }
/** /**
* Send a command to the device * Send a command to the device
*/ */
private async internalSend(...command: string[]) { private async internalSend(...command: string[]) {
const port = await this.port const writer = this.port.writable!.getWriter()
const writer = port.writable!.getWriter()
try { try {
serialLog.update(it => { serialLog.update(it => {
it.push({ it.push({
@@ -96,19 +93,18 @@ export class CharaDevice {
} }
} }
async ready() {
await this.port
}
async forget() { async forget() {
await (await this.port).forget() await this.disconnect()
await this.port.forget()
} }
async disconnect() { async disconnect() {
this.abortController1.abort() await this.reader.cancel()
this.abortController2.abort() await this.streamClosed.catch(() => {
;(await this.reader).releaseLock() /** noop */
await (await this.port).close() })
this.reader.releaseLock()
await this.port.close()
} }
/** /**

View File

@@ -1,7 +1,10 @@
$padding: 16px; $padding: 16px;
.tippy-box[data-theme~="surface-variant"] { .tippy-box[data-theme~="surface-variant"] {
// overflow: hidden;
color: var(--md-sys-color-on-surface-variant); color: var(--md-sys-color-on-surface-variant);
background-color: var(--md-sys-color-surface-variant); background-color: var(--md-sys-color-surface-variant);
filter: drop-shadow(0 0 12px #000a); filter: drop-shadow(0 0 12px #000a);
border-radius: calc(24px + $padding); border-radius: calc(24px + $padding);
@@ -11,10 +14,10 @@ $padding: 16px;
} }
h2 { h2 {
margin-block-start: 8px;
margin-block-end: calc(8px + $padding);
display: flex; display: flex;
justify-content: center; justify-content: center;
margin-block-start: 8px;
margin-block-end: calc(8px + $padding);
} }
@each $placement in top, bottom, right, left { @each $placement in top, bottom, right, left {

View File

@@ -51,7 +51,7 @@
} satisfies RegisterSWOptions) } satisfies RegisterSWOptions)
} }
if ($userPreferences.autoSync && (await canAutoConnect())) await initSerial() if ($userPreferences.autoConnect && (await canAutoConnect())) await initSerial()
}) })
$: webManifestLink = pwaInfo ? pwaInfo.webManifest.linkTag : "" $: webManifestLink = pwaInfo ? pwaInfo.webManifest.linkTag : ""

View File

@@ -1,52 +1,159 @@
<script> <script lang="ts">
import {initSerial, serialPort} from "$lib/serial/connection" import {initSerial, serialPort} from "$lib/serial/connection"
import {browser} from "$app/environment" import {browser} from "$app/environment"
import {getViablePorts} from "$lib/serial/device" import {getViablePorts} from "$lib/serial/device"
import {slide, fade} from "svelte/transition"
import {preference} from "$lib/preferences"
let connectDialog = false
let powerDialog = false
</script> </script>
<h2>Devices</h2> <section>
<h2>Device</h2>
<div class="row"> {#if $serialPort}
<button disabled={$serialPort === undefined}><span class="icon">restart_alt</span>Reboot</button> <p transition:slide>
<button disabled={$serialPort === undefined}><span class="icon">rule_settings</span>Bootloader</button> {$serialPort.deviceId}
</div> <br />
{#if browser} Version {$serialPort.version}
{#await ($serialPort, getViablePorts()) then ports} </p>
<div class="row">
{#if ports.length === 0}
<button class="secondary" on:click={initSerial}>
<span class="icon">usb</span>Pair
</button>
{:else if $serialPort}
<button
class="secondary"
on:click={() => {
$serialPort.forget()
$serialPort = undefined
}}><span class="icon">usb</span>Unpair</button
>
{:else}
<button class="secondary" on:click={initSerial}><span class="icon">usb</span>Connect</button>
{/if} {/if}
{#if browser}
<div class="row">
<button
class:secondary={$serialPort}
class:error={!$serialPort}
on:click={() => (connectDialog = !connectDialog)}
>
{#if $serialPort}
<span class="icon">usb</span>
{:else}
<span class="icon">error</span>
{/if}
Connection
<span class="icon">chevron_right</span>
</button>
<button class="icon" disabled={$serialPort === undefined} on:click={() => (powerDialog = !powerDialog)}
>settings_power</button
>
</div>
{#await ($serialPort, getViablePorts()) then ports}
{#if connectDialog}
<div
class="backdrop"
transition:fade={{duration: 250}}
on:click={() => (connectDialog = !connectDialog)}
/>
<dialog open transition:slide={{duration: 250}}>
<label><input type="checkbox" use:preference={"autoConnect"} />Auto Connect</label>
{#if $serialPort} {#if $serialPort}
<button <button
on:click={() => { on:click={() => {
$serialPort.disconnect() $serialPort.disconnect()
$serialPort = undefined $serialPort = undefined
}}><span class="icon">usb</span>Disconnect</button }}><span class="icon">usb_off</span>Disconnect</button
>
{:else}
{#if ports.length > 0}
<button on:click={() => initSerial()}><span class="icon">usb</span>Connect</button>
{/if}
<button on:click={() => initSerial(true)}><span class="icon">link</span>Pair Device</button>
{/if}
{#if $serialPort}
<button
on:click={() => {
$serialPort.forget()
$serialPort = undefined
}}><span class="icon">link_off</span>Unpair Device</button
> >
{/if} {/if}
</div> </dialog>
{/await}
{/if} {/if}
{/await}
{#if powerDialog}
<div class="backdrop" transition:fade={{duration: 250}} on:click={() => (powerDialog = !powerDialog)} />
<dialog open transition:slide={{duration: 250}}>
<h3>Boot Menu</h3>
<button><span class="icon">restart_alt</span>Reboot</button>
<button><span class="icon">rule_settings</span>Bootloader</button>
</dialog>
{/if}
{/if}
</section>
<style lang="scss"> <style lang="scss">
h2 {
margin-block: 8px;
}
p {
margin-block: 8px;
}
section {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
}
.backdrop {
position: absolute;
z-index: 1;
inset: 0;
background: #0005;
border-radius: 40px;
}
dialog {
position: relative;
z-index: 2;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
width: 100%;
margin: 0;
margin-block-start: 16px;
padding: 0;
color: var(--md-sys-color-on-secondary-container);
background: var(--md-sys-color-secondary-container);
border: none;
border-radius: 32px;
}
.row { .row {
display: flex; display: flex;
gap: 8px; gap: 8px;
height: fit-content; height: fit-content;
} }
dialog > * {
margin-inline: 16px;
}
dialog > :first-child {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
margin: 0;
padding-block: 8px;
color: var(--md-sys-color-on-secondary);
background: var(--md-sys-color-secondary);
}
button { button {
cursor: pointer; cursor: pointer;
@@ -85,6 +192,11 @@
background: var(--md-sys-color-secondary); background: var(--md-sys-color-secondary);
} }
&.error {
color: var(--md-sys-color-on-error);
background: var(--md-sys-color-error);
}
&:active:not(:disabled) { &:active:not(:disabled) {
color: var(--md-sys-color-on-surface-variant); color: var(--md-sys-color-on-surface-variant);
background: var(--md-sys-color-surface-variant); background: var(--md-sys-color-surface-variant);

View File

@@ -8,6 +8,7 @@
import ConnectionPopup from "./ConnectionPopup.svelte" import ConnectionPopup from "./ConnectionPopup.svelte"
import {canAutoConnect} from "$lib/serial/device" import {canAutoConnect} from "$lib/serial/device"
import {browser} from "$app/environment" import {browser} from "$app/environment"
import {userPreferences} from "$lib/preferences"
const training = [ const training = [
{slug: "cpm", title: "CPM - Characters Per Minute", icon: "music_note"}, {slug: "cpm", title: "CPM - Characters Per Minute", icon: "music_note"},
@@ -53,8 +54,10 @@
backup backup
{:else if $syncStatus === "uploading"} {:else if $syncStatus === "uploading"}
cloud_download cloud_download
{:else} {:else if $userPreferences.backup}
cloud_done cloud_done
{:else}
cloud_off
{/if} {/if}
</button> </button>
{/if} {/if}