Crudely adapt webapp to new firmware

This commit is contained in:
daylily
2026-01-09 16:35:10 +08:00
parent c0c65f1fae
commit c72aa66405
16 changed files with 3040 additions and 4529 deletions
+5 -1
View File
@@ -4,5 +4,9 @@
"semi": false,
"arrowParens": "avoid",
"singleQuote": true,
"printWidth": 120
"printWidth": 100,
"plugins": [
"prettier-plugin-svelte"
],
"svelteIndentScriptAndStyle": false
}
-4341
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -16,10 +16,13 @@
"@tsconfig/svelte": "^5.0.4",
"@types/node": "^22.14.0",
"@types/w3c-web-hid": "^1.0.6",
"@variegated-coffee/serde-postcard-ts": "^0.1.4",
"autoprefixer": "^10.4.20",
"bits-ui": "^1.3.16",
"clsx": "^2.1.1",
"mode-watcher": "^0.5.1",
"prettier": "^3.7.4",
"prettier-plugin-svelte": "^3.4.1",
"svelte": "^5.25.7",
"svelte-sonner": "^0.3.28",
"tailwind-merge": "^3.1.0",
@@ -30,5 +33,6 @@
"unplugin-icons": "^22.1.0",
"vite": "^6.2.5",
"wrangler": "^4.34.0"
}
},
"packageManager": "pnpm@10.27.0+sha512.72d699da16b1179c14ba9e64dc71c9a40988cbdc65c264cb0e489db7de917f20dcf4d64d8723625f2969ba52d4b7e2a1170682d9ac2a5dcaeaab732b7e16f04a"
}
+2667
View File
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -1,12 +1,12 @@
<script lang="ts">
import { ModeWatcher } from 'mode-watcher'
import { Toaster } from 'svelte-sonner'
import { ModeWatcher } from 'mode-watcher'
import { Toaster } from 'svelte-sonner'
import Footer from '$lib/layouts/Footer.svelte'
import Main from '$lib/layouts/Main.svelte'
import Unsupported from '$lib/layouts/Unsupported.svelte'
import Footer from '$lib/layouts/Footer.svelte'
import Main from '$lib/layouts/Main.svelte'
import Unsupported from '$lib/layouts/Unsupported.svelte'
const unsupported = navigator.hid === undefined
const unsupported = navigator.requestMIDIAccess === undefined
</script>
<ModeWatcher />
+49 -47
View File
@@ -1,13 +1,55 @@
import { DEVICE_PID, DEVICE_VID } from '$lib/constants'
import { getContext, onDestroy, setContext } from 'svelte'
import { Device } from '$lib/image/device'
import { getContext, setContext, untrack } from 'svelte'
import { toast } from 'svelte-sonner'
function isInkclip(dev: HIDDevice) {
return dev.vendorId == DEVICE_VID && dev.productId == DEVICE_PID
function isInkclip(port: MIDIPort): boolean {
return !!(
port.manufacturer?.toLowerCase().includes('daylily') ||
port.name?.toLowerCase().includes('inkclip')
)
}
export interface DeviceContext {
device: HIDDevice | null
export class DeviceContext {
private input: MIDIInput | null = $state(null)
private output: MIDIOutput | null = $state(null)
device: Device | null = $state(null)
constructor() {
$effect(() => {
if (untrack(() => this.device == null)) {
if (this.input == null || this.output == null) return
this.device = new Device(this.input, this.output)
toast.info('Device connected')
} else {
if (this.input != null && this.output != null) return
this.device = null
}
})
}
async initialize() {
try {
const midi = await navigator.requestMIDIAccess({ sysex: true })
console.log(Array.from(midi.inputs.values()), Array.from(midi.outputs.values()))
this.input = midi.inputs.values().find(isInkclip) ?? null
this.output = midi.outputs.values().find(isInkclip) ?? null
midi.addEventListener('statechange', e => {
console.log(e.port)
if (!e.port || !isInkclip(e.port)) return
if (e.port?.state === 'connected') {
if (e.port.type === 'input' && !this.input) this.input = e.port as MIDIInput
else if (e.port.type === 'output' && !this.output) this.output = e.port as MIDIOutput
} else {
if (e.port.type === 'input' && e.port === this.input) this.input = null
else if (e.port.type === 'output' && e.port === this.output) this.output = null
}
})
} catch (e) {
toast.error('Failed to acquire MIDI access. Please grant access manually in your browser.')
}
}
}
const DeviceContextToken = Symbol('device')
@@ -17,46 +59,6 @@ export function getDeviceContext(): DeviceContext {
}
export function createDeviceContext(): DeviceContext {
const ctx: DeviceContext = $state({ device: null })
navigator.hid.getDevices().then(devices => {
const dev = devices.find(isInkclip)
if (dev !== undefined) ctx.device = dev
})
function connectIfIdle(e: HIDConnectionEvent) {
if (!isInkclip(e.device) || ctx.device !== null) return
toast.info('Device connected')
ctx.device = e.device
}
function disconnectIfSame(e: HIDConnectionEvent) {
if (ctx.device !== e.device) return
toast.info('Device disconnected')
ctx.device = null
}
navigator.hid.addEventListener('connect', connectIfIdle)
navigator.hid.addEventListener('disconnect', disconnectIfSame)
onDestroy(() => {
navigator.hid.removeEventListener('connect', connectIfIdle)
navigator.hid.removeEventListener('disconnect', disconnectIfSame)
})
let ctx = new DeviceContext()
return setContext(DeviceContextToken, ctx)
}
export async function tryOpenDevice(device: HIDDevice): Promise<boolean> {
if (device.opened) return true
try {
await device.open()
return true
} catch (e) {
toast.error(`Unable to open device: ${e}`)
return false
}
}
+6 -8
View File
@@ -6,8 +6,8 @@ import { Scaler } from '$lib/image/scaler'
import { Quantizer } from '$lib/image/quantizer'
import { DEVICE_HEIGHT, DEVICE_WIDTH } from '$lib/constants'
export interface RenderedContext {
rendered: number[] | null
export class RenderedContext {
rendered: Uint8Array | null = $state(null)
}
export const RenderedContextToken = Symbol('rendered')
@@ -20,18 +20,16 @@ const scaler = new Scaler(DEVICE_WIDTH, DEVICE_HEIGHT)
export function createRenderedContext(
imageCtx: Readonly<ImageContext>,
config: ConversionConfig
config: ConversionConfig,
): Readonly<RenderedContext> {
const ctx: RenderedContext = $state({
rendered: null,
})
const ctx: RenderedContext = new RenderedContext()
const quantizer = $derived(
new Quantizer({
ditheringKernel: config.ditheringKernel,
contrast: config.contrast,
brightness: config.brightness,
})
}),
)
const canvas = new OffscreenCanvas(DEVICE_WIDTH, DEVICE_HEIGHT)
@@ -56,7 +54,7 @@ export function createRenderedContext(
})
const quantizedData = quantizer.reduce(canvasCtx)
ctx.rendered = quantizedData
ctx.rendered = Uint8Array.from(quantizedData)
})
return setContext(RenderedContextToken, ctx)
+107
View File
@@ -0,0 +1,107 @@
import * as p from '@variegated-coffee/serde-postcard-ts'
import { decode7in8InPlace, encode7in8 } from './pack'
export type DeviceType = p.InferType<typeof deviceTypeSchema>
const deviceTypeSchema = p.enumType('DeviceType', {
BWRev1: p.unitVariant('BWRev1'),
})
export type Chroma = p.InferType<typeof chromaSchema>
const chromaSchema = p.enumType('Chroma', {
Black: p.unitVariant('Black'),
})
export type Request = p.InferType<typeof requestSchema>
const requestSchema = p.enumType('Request', {
GetIdentification: p.unitVariant('GetIdentification'),
UpdateDisplay: p.unitVariant('UpdateDisplay'),
SetPattern: p.structVariant('SetPattern', {
from: p.u32(),
to: p.u32(),
chroma: chromaSchema,
pattern: p.bytes(),
}),
})
export type Response = p.InferType<typeof responseSchema>
const responseSchema = p.enumType('Response', {
GetIdentification: p.structVariant('GetIdentification', {
serial: p.string(),
model: deviceTypeSchema,
}),
UpdateDisplay: p.unitVariant('UpdateDisplay'),
SetPattern: p.unitVariant('SetPattern'),
})
const MAGIC_NUMBER = [0x7d]
interface RecvOptions {
timeout?: number
filter?: (resp: Response) => boolean
}
export class Device {
constructor(
private input: MIDIInput,
private output: MIDIOutput,
) {}
private send(req: Request) {
const encResult = encode7in8(p.serialize(requestSchema, req))
const len = 1 + MAGIC_NUMBER.length + encResult.byteLength + 1
const buf = new Uint8Array(len)
// SysEx start
buf[0] = 0xf0
// Magic number
for (let ix = 0; ix < MAGIC_NUMBER.length; ix++) buf[1 + ix] = MAGIC_NUMBER[ix]
// Request body
for (let ix = 0; ix < encResult.byteLength; ix++)
buf[1 + MAGIC_NUMBER.length + ix] = encResult[ix]
// SysEx end
buf[1 + MAGIC_NUMBER.length + encResult.byteLength] = 0xf7
this.output.send(buf)
}
private recv({ timeout, filter }: RecvOptions): Promise<Response> {
return new Promise((resolve, reject) => {
const callback = (e: MIDIMessageEvent) => {
if (!e.data) return
if (e.data.length < 2 || e.data[0] !== 0xf0 || e.data[e.data.length - 1] !== 0xf7) return
const sysexPayload = e.data.slice(1, -1)
if (sysexPayload.length < MAGIC_NUMBER.length) return
for (let ix = 0; ix < MAGIC_NUMBER.length; ix++)
if (sysexPayload[ix] !== MAGIC_NUMBER[ix]) return
const payload = decode7in8InPlace(sysexPayload.slice(MAGIC_NUMBER.length))
try {
const decResult = p.deserialize(responseSchema, payload)
if (!filter || filter(decResult.value)) resolve(decResult.value)
} catch (e) {
reject(e)
}
this.input.removeEventListener('midimessage', callback)
}
if (timeout != null) setTimeout(() => reject(new Error('recv timed out')), timeout)
this.input.addEventListener('midimessage', callback)
})
}
request(
req: Request,
opts: RecvOptions = { timeout: 100, filter: resp => resp.type === req.type },
): Promise<Response> {
const recv = this.recv(opts)
this.send(req)
return recv
}
async open() {
if (this.input.connection !== 'open') await this.input.open()
if (this.output.connection !== 'open') await this.output.open()
}
}
+60
View File
@@ -0,0 +1,60 @@
// 7-in-8 encoding (SysEx-style).
/**
* Decode 7-in-8 data in-place.
* @param payload Buffer containing 7-in-8 encoded bytes.
* @returns A subarray view of `payload` containing the decoded bytes.
*/
export function decode7in8InPlace(payload: Uint8Array): Uint8Array {
let decodedLen = 0
const groups = Math.ceil(payload.length / 8)
for (let stride = 0; stride < groups; stride++) {
const base = 8 * stride
if (base >= payload.length) break
// Most significant bits of a 7-in-8 group
const msbs = payload[base]
for (let stroll = 1; stroll < 8; stroll++) {
const inIx = base + stroll
if (inIx >= payload.length) break
const low7 = payload[inIx] & 0x7f
const msb = (msbs << (8 - stroll)) & 0x80
payload[decodedLen] = (low7 | msb) & 0xff
decodedLen++
}
}
return payload.subarray(0, decodedLen)
}
/**
* Encode raw bytes into 7-in-8 form (SysEx-style), allocating the output buffer.
* Output length is: n + ceil(n/7)
*/
export function encode7in8(payload: Uint8Array): Uint8Array {
const outLen = payload.length + Math.ceil(payload.length / 7)
const output = new Uint8Array(outLen)
let encodedLen = 0
const groups = Math.ceil(payload.length / 7)
for (let stride = 0; stride < groups; stride++) {
const msbsIx = encodedLen
output[msbsIx] = 0
encodedLen++
for (let stroll = 1; stroll < 8; stroll++) {
const inIx = 7 * stride + (stroll - 1)
if (inIx >= payload.length) break
output[msbsIx] |= (payload[inIx] & 0x80) >> (8 - stroll)
output[encodedLen] = payload[inIx] & 0x7f
encodedLen++
}
}
return output
}
+19 -14
View File
@@ -1,20 +1,25 @@
<script lang="ts">
import { Separator } from '$lib/components/ui/separator'
import ConnectSection from '$lib/layouts/connect/ConnectSection.svelte'
import EditSection from '$lib/layouts/edit/EditSection.svelte'
import WriteSection from '$lib/layouts/write/WriteSection.svelte'
import { Separator } from '$lib/components/ui/separator'
import ConnectSection from '$lib/layouts/connect/ConnectSection.svelte'
import EditSection from '$lib/layouts/edit/EditSection.svelte'
import WriteSection from '$lib/layouts/write/WriteSection.svelte'
import { createDeviceContext } from '$lib/contexts/device.svelte'
import { createConversionConfig } from '$lib/contexts/config.svelte'
import { createFilesContext } from '$lib/contexts/files.svelte'
import { createImageContext } from '$lib/contexts/image.svelte'
import { createRenderedContext } from '$lib/contexts/rendered.svelte'
import { createDeviceContext } from '$lib/contexts/device.svelte'
import { createConversionConfig } from '$lib/contexts/config.svelte'
import { createFilesContext } from '$lib/contexts/files.svelte'
import { createImageContext } from '$lib/contexts/image.svelte'
import { createRenderedContext } from '$lib/contexts/rendered.svelte'
import { onMount } from 'svelte'
createDeviceContext()
const config = createConversionConfig()
const filesCtx = createFilesContext()
const imageCtx = createImageContext(filesCtx)
createRenderedContext(imageCtx, config)
const deviceCtx = createDeviceContext()
const config = createConversionConfig()
const filesCtx = createFilesContext()
const imageCtx = createImageContext(filesCtx)
createRenderedContext(imageCtx, config)
onMount(() => {
deviceCtx.initialize()
})
</script>
<main class="grow w-full stack gap-4">
+2 -2
View File
@@ -16,12 +16,12 @@
<AlertDialog.Title>Browser not supported</AlertDialog.Title>
<AlertDialog.Description class="stack gap-2">
<p>Write to Inkclip uses the WebHID API, which is not supported by your browser.</p>
<p>Write to Inkclip uses the Web MIDI API, which is not supported by your browser.</p>
<p>
We recommend using a Chromium-based browser, such as a recent version of
<a href="https://www.google.com/chrome/">Google Chrome</a>,
<a href="https://www.microsoft.com/edge">Microsoft Edge</a>, or
<a href="https://www.opera.com/">Opera</a>.
<a href="https://www.opera.com/">Opera</a>. Firefox may also work, although we are aware of more issues there.
</p>
</AlertDialog.Description>
</AlertDialog.Content>
@@ -1,27 +1,16 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button'
import { Button } from '$lib/components/ui/button'
import { getDeviceContext } from '$lib/contexts/device.svelte'
import { DEVICE_PID, DEVICE_VID } from '$lib/constants'
import { getDeviceContext } from '$lib/contexts/device.svelte'
const deviceCtx = getDeviceContext()
async function requestDevice() {
const devs = await navigator.hid.requestDevice({
filters: [
{
vendorId: DEVICE_VID,
productId: DEVICE_PID,
},
],
})
if (devs == undefined || devs[0] == undefined) return
deviceCtx.device = devs[0]
}
const deviceCtx = getDeviceContext()
</script>
<Button variant={deviceCtx.device === null ? 'default' : 'secondary'} onclick={requestDevice}>
<Button
variant={deviceCtx.device === null ? 'default' : 'secondary'}
onclick={() => {
/* TODO: */
}}
>
{#if deviceCtx.device === null}
Select device
{:else}
@@ -1,51 +1,50 @@
<script lang="ts">
import IconPending from '~icons/material-symbols/pending'
import IconCheckCircle from '~icons/material-symbols/check-circle'
import ConnectButton from './ConnectButton.svelte'
import IconPending from '~icons/material-symbols/pending'
import IconCheckCircle from '~icons/material-symbols/check-circle'
import ConnectButton from './ConnectButton.svelte'
import { getDeviceContext, tryOpenDevice } from '$lib/contexts/device.svelte'
import MoreInfo from '$lib/components/MoreInfo.svelte'
import { SERIAL_NUMBER_REPORT_ID } from '$lib/constants'
import { getDeviceContext } from '$lib/contexts/device.svelte'
import MoreInfo from '$lib/components/MoreInfo.svelte'
import { assert } from '$lib/utils'
const deviceCtx = getDeviceContext()
const deviceCtx = getDeviceContext()
let serial = $state('[Retrieving...]')
let serial = $state('[Retrieving...]')
async function updateSerial() {
if (deviceCtx.device === null) {
serial = '[Retrieving...]'
return
}
if (!(await tryOpenDevice(deviceCtx.device))) {
serial = '[Error]'
return
}
let updated = false
function setSerial(e: HIDInputReportEvent) {
if (updated || e.reportId !== SERIAL_NUMBER_REPORT_ID) return
serial = String.fromCharCode(...Array.from(new Uint8Array(e.data.buffer)))
updated = true
}
deviceCtx.device.addEventListener('inputreport', setSerial)
await deviceCtx.device.sendReport(SERIAL_NUMBER_REPORT_ID, new Uint8Array(1))
async function updateSerial() {
if (deviceCtx.device === null) {
serial = '[Retrieving...]'
return
}
$effect(() => {
updateSerial()
})
await deviceCtx.device.open()
try {
const response = await deviceCtx.device.request({ type: 'GetIdentification' })
assert(response.type === 'GetIdentification')
console.log(response)
serial = response.value.serial
} catch (e) {
console.error(e)
serial = '[Error]'
}
}
$effect(() => {
updateSerial()
})
</script>
<section class="row gap-2 max-lg:stack max-lg:items-stretch" aria-labelledby="connect-section-label">
<section
class="row gap-2 max-lg:stack max-lg:items-stretch"
aria-labelledby="connect-section-label"
>
<div class="grow">
<h2 class="font-semibold text-xl/8" id="connect-section-label">Connect to a device</h2>
<div class="row gap-1 text-sm" aria-live="polite">
{#if deviceCtx.device === null}
<IconPending aria-hidden /> Not connected to any device yet. Plug in your device, and click on the button to select
it.
<IconPending aria-hidden /> Not connected to any device yet. Plug in your device to connect.
{:else}
<MoreInfo>
{#snippet icon()}
@@ -53,10 +52,10 @@
{/snippet}
The serial ID of this device is <code>{serial}</code>.
</MoreInfo>
Successfully conected to device. If you want to, you can connect to another device instead.
Successfully conected to device.
{/if}
</div>
</div>
<ConnectButton />
<!-- <ConnectButton /> -->
</section>
@@ -9,7 +9,11 @@ export function freshContext(el: HTMLCanvasElement) {
return ctx
}
export function makeAltText(filesCtx: FilesContext, imageCtx: ImageContext, config: ConversionConfig): string {
export function makeAltText(
filesCtx: FilesContext,
imageCtx: ImageContext,
config: ConversionConfig,
): string {
if (filesCtx.files.length < 1) return 'No image selected for e-paper preview'
const file = filesCtx.files[0]
@@ -50,8 +54,10 @@ export function makeAltText(filesCtx: FilesContext, imageCtx: ImageContext, conf
return output.join(', ')
}
export function drawQuantizedData(ctx: CanvasRenderingContext2D, data: number[]) {
export function drawQuantizedData(ctx: CanvasRenderingContext2D, data: Uint8Array) {
console.log('attempting drawQuantizedData')
const imageData = ctx.createImageData(DEVICE_WIDTH, DEVICE_HEIGHT)
console.log('finished creating')
for (let y = 0; y < DEVICE_HEIGHT; y++) {
for (let x = 0; x < DEVICE_WIDTH; x++) {
@@ -66,5 +72,8 @@ export function drawQuantizedData(ctx: CanvasRenderingContext2D, data: number[])
imageData.data[bitmapIx + 3] = 0xff
}
}
console.log('finished writing')
ctx.putImageData(imageData, 0, 0)
console.log('finished putting')
}
+55 -51
View File
@@ -1,67 +1,71 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button'
import { Button } from '$lib/components/ui/button'
import { toast } from 'svelte-sonner'
import { getDeviceContext, tryOpenDevice } from '$lib/contexts/device.svelte'
import { getRenderedContext } from '$lib/contexts/rendered.svelte'
import {
BYTES_IN_A_ROW,
DEVICE_HEIGHT,
DEVICE_WIDTH,
SERIAL_NUMBER_REPORT_ID,
WRITE_PATTERN_REPORT_ID,
WRITE_TIME,
} from '$lib/constants'
import { toast } from 'svelte-sonner'
import { getDeviceContext } from '$lib/contexts/device.svelte'
import { getRenderedContext } from '$lib/contexts/rendered.svelte'
import { BYTES_IN_A_ROW, DEVICE_HEIGHT, DEVICE_WIDTH, WRITE_TIME } from '$lib/constants'
interface Props {
onprogress: (inPropgress: boolean) => void
}
interface Props {
onprogress: (inPropgress: boolean) => void
}
const { onprogress }: Props = $props()
const { onprogress }: Props = $props()
let inProgress = $state(false)
let inProgress = $state(false)
const deviceCtx = getDeviceContext()
const renderedCtx = getRenderedContext()
const deviceCtx = getDeviceContext()
const renderedCtx = getRenderedContext()
let disabled = $derived(deviceCtx.device === null || renderedCtx.rendered === null || inProgress)
let secondary = $derived(deviceCtx.device === null)
let disabled = $derived(deviceCtx.device === null || renderedCtx.rendered === null || inProgress)
let secondary = $derived(deviceCtx.device === null)
async function connectAndWrite() {
if (deviceCtx.device === null || renderedCtx.rendered === null) return
if (!(await tryOpenDevice(deviceCtx.device))) return
async function connectAndWrite() {
if (deviceCtx.device === null || renderedCtx.rendered === null) return
await deviceCtx.device.open()
const buffer = new Uint8Array(DEVICE_HEIGHT * BYTES_IN_A_ROW)
for (let y = 0; y < DEVICE_HEIGHT; y++) {
for (let xStride = 0; xStride < BYTES_IN_A_ROW; xStride++) {
let cell = 0x0
for (let xStroll = 0; xStroll < 8; xStroll++) {
const index = y * DEVICE_WIDTH + xStride * 8 + xStroll
cell |= renderedCtx.rendered[index] << xStroll
}
buffer[y * BYTES_IN_A_ROW + xStride] = cell
const buffer = new Uint8Array(DEVICE_HEIGHT * BYTES_IN_A_ROW)
for (let y = 0; y < DEVICE_HEIGHT; y++) {
for (let xStride = 0; xStride < BYTES_IN_A_ROW; xStride++) {
let cell = 0x0
for (let xStroll = 0; xStroll < 8; xStroll++) {
const index = y * DEVICE_WIDTH + xStride * 8 + xStroll
cell |= renderedCtx.rendered[index] << xStroll
}
buffer[y * BYTES_IN_A_ROW + xStride] = cell
}
inProgress = true
try {
await deviceCtx.device.sendReport(WRITE_PATTERN_REPORT_ID, buffer)
} catch (e) {
toast.error(`Error writing to device: ${e}`)
return
} finally {
setTimeout(() => {
inProgress = false
}, WRITE_TIME)
}
toast.success('Wrote pattern to device')
}
$effect(() => {
onprogress(inProgress)
})
inProgress = true
try {
for (let i = 0; i < buffer.byteLength; i += 500) {
await deviceCtx.device.request({
type: 'SetPattern',
value: {
from: i,
to: i + 500,
chroma: { type: 'Black' },
pattern: buffer.slice(i, i + 500),
},
})
}
await deviceCtx.device.request({ type: 'UpdateDisplay' })
} catch (e) {
toast.error(`Error writing to device: ${e}`)
return
} finally {
setTimeout(() => {
inProgress = false
}, WRITE_TIME)
}
toast.success('Wrote pattern to device')
}
$effect(() => {
onprogress(inProgress)
})
</script>
<Button onclick={connectAndWrite} variant={secondary ? 'secondary' : 'default'} {disabled}>
+4
View File
@@ -8,3 +8,7 @@ export function cn(...inputs: ClassValue[]) {
export function showNumber(n: number): string {
return String(n).replace('-', '').replace('Infinity', '∞').replace('NaN', '⁇')
}
export function assert(expr: unknown): asserts expr {
if (!expr) throw Error('assertion failed')
}