Crudely adapt webapp to new firmware
This commit is contained in:
+5
-1
@@ -4,5 +4,9 @@
|
|||||||
"semi": false,
|
"semi": false,
|
||||||
"arrowParens": "avoid",
|
"arrowParens": "avoid",
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"printWidth": 120
|
"printWidth": 100,
|
||||||
|
"plugins": [
|
||||||
|
"prettier-plugin-svelte"
|
||||||
|
],
|
||||||
|
"svelteIndentScriptAndStyle": false
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
-4341
File diff suppressed because it is too large
Load Diff
+5
-1
@@ -16,10 +16,13 @@
|
|||||||
"@tsconfig/svelte": "^5.0.4",
|
"@tsconfig/svelte": "^5.0.4",
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
"@types/w3c-web-hid": "^1.0.6",
|
"@types/w3c-web-hid": "^1.0.6",
|
||||||
|
"@variegated-coffee/serde-postcard-ts": "^0.1.4",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"bits-ui": "^1.3.16",
|
"bits-ui": "^1.3.16",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"mode-watcher": "^0.5.1",
|
"mode-watcher": "^0.5.1",
|
||||||
|
"prettier": "^3.7.4",
|
||||||
|
"prettier-plugin-svelte": "^3.4.1",
|
||||||
"svelte": "^5.25.7",
|
"svelte": "^5.25.7",
|
||||||
"svelte-sonner": "^0.3.28",
|
"svelte-sonner": "^0.3.28",
|
||||||
"tailwind-merge": "^3.1.0",
|
"tailwind-merge": "^3.1.0",
|
||||||
@@ -30,5 +33,6 @@
|
|||||||
"unplugin-icons": "^22.1.0",
|
"unplugin-icons": "^22.1.0",
|
||||||
"vite": "^6.2.5",
|
"vite": "^6.2.5",
|
||||||
"wrangler": "^4.34.0"
|
"wrangler": "^4.34.0"
|
||||||
}
|
},
|
||||||
|
"packageManager": "pnpm@10.27.0+sha512.72d699da16b1179c14ba9e64dc71c9a40988cbdc65c264cb0e489db7de917f20dcf4d64d8723625f2969ba52d4b7e2a1170682d9ac2a5dcaeaab732b7e16f04a"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2667
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ModeWatcher } from 'mode-watcher'
|
import { ModeWatcher } from 'mode-watcher'
|
||||||
import { Toaster } from 'svelte-sonner'
|
import { Toaster } from 'svelte-sonner'
|
||||||
|
|
||||||
import Footer from '$lib/layouts/Footer.svelte'
|
import Footer from '$lib/layouts/Footer.svelte'
|
||||||
import Main from '$lib/layouts/Main.svelte'
|
import Main from '$lib/layouts/Main.svelte'
|
||||||
import Unsupported from '$lib/layouts/Unsupported.svelte'
|
import Unsupported from '$lib/layouts/Unsupported.svelte'
|
||||||
|
|
||||||
const unsupported = navigator.hid === undefined
|
const unsupported = navigator.requestMIDIAccess === undefined
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ModeWatcher />
|
<ModeWatcher />
|
||||||
|
|||||||
@@ -1,13 +1,55 @@
|
|||||||
import { DEVICE_PID, DEVICE_VID } from '$lib/constants'
|
import { Device } from '$lib/image/device'
|
||||||
import { getContext, onDestroy, setContext } from 'svelte'
|
import { getContext, setContext, untrack } from 'svelte'
|
||||||
import { toast } from 'svelte-sonner'
|
import { toast } from 'svelte-sonner'
|
||||||
|
|
||||||
function isInkclip(dev: HIDDevice) {
|
function isInkclip(port: MIDIPort): boolean {
|
||||||
return dev.vendorId == DEVICE_VID && dev.productId == DEVICE_PID
|
return !!(
|
||||||
|
port.manufacturer?.toLowerCase().includes('daylily') ||
|
||||||
|
port.name?.toLowerCase().includes('inkclip')
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeviceContext {
|
export class DeviceContext {
|
||||||
device: HIDDevice | null
|
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')
|
const DeviceContextToken = Symbol('device')
|
||||||
@@ -17,46 +59,6 @@ export function getDeviceContext(): DeviceContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createDeviceContext(): DeviceContext {
|
export function createDeviceContext(): DeviceContext {
|
||||||
const ctx: DeviceContext = $state({ device: null })
|
let ctx = new DeviceContext()
|
||||||
|
|
||||||
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)
|
|
||||||
})
|
|
||||||
|
|
||||||
return setContext(DeviceContextToken, ctx)
|
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 +6,8 @@ import { Scaler } from '$lib/image/scaler'
|
|||||||
import { Quantizer } from '$lib/image/quantizer'
|
import { Quantizer } from '$lib/image/quantizer'
|
||||||
import { DEVICE_HEIGHT, DEVICE_WIDTH } from '$lib/constants'
|
import { DEVICE_HEIGHT, DEVICE_WIDTH } from '$lib/constants'
|
||||||
|
|
||||||
export interface RenderedContext {
|
export class RenderedContext {
|
||||||
rendered: number[] | null
|
rendered: Uint8Array | null = $state(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RenderedContextToken = Symbol('rendered')
|
export const RenderedContextToken = Symbol('rendered')
|
||||||
@@ -20,18 +20,16 @@ const scaler = new Scaler(DEVICE_WIDTH, DEVICE_HEIGHT)
|
|||||||
|
|
||||||
export function createRenderedContext(
|
export function createRenderedContext(
|
||||||
imageCtx: Readonly<ImageContext>,
|
imageCtx: Readonly<ImageContext>,
|
||||||
config: ConversionConfig
|
config: ConversionConfig,
|
||||||
): Readonly<RenderedContext> {
|
): Readonly<RenderedContext> {
|
||||||
const ctx: RenderedContext = $state({
|
const ctx: RenderedContext = new RenderedContext()
|
||||||
rendered: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const quantizer = $derived(
|
const quantizer = $derived(
|
||||||
new Quantizer({
|
new Quantizer({
|
||||||
ditheringKernel: config.ditheringKernel,
|
ditheringKernel: config.ditheringKernel,
|
||||||
contrast: config.contrast,
|
contrast: config.contrast,
|
||||||
brightness: config.brightness,
|
brightness: config.brightness,
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const canvas = new OffscreenCanvas(DEVICE_WIDTH, DEVICE_HEIGHT)
|
const canvas = new OffscreenCanvas(DEVICE_WIDTH, DEVICE_HEIGHT)
|
||||||
@@ -56,7 +54,7 @@ export function createRenderedContext(
|
|||||||
})
|
})
|
||||||
|
|
||||||
const quantizedData = quantizer.reduce(canvasCtx)
|
const quantizedData = quantizer.reduce(canvasCtx)
|
||||||
ctx.rendered = quantizedData
|
ctx.rendered = Uint8Array.from(quantizedData)
|
||||||
})
|
})
|
||||||
|
|
||||||
return setContext(RenderedContextToken, ctx)
|
return setContext(RenderedContextToken, ctx)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,20 +1,25 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Separator } from '$lib/components/ui/separator'
|
import { Separator } from '$lib/components/ui/separator'
|
||||||
import ConnectSection from '$lib/layouts/connect/ConnectSection.svelte'
|
import ConnectSection from '$lib/layouts/connect/ConnectSection.svelte'
|
||||||
import EditSection from '$lib/layouts/edit/EditSection.svelte'
|
import EditSection from '$lib/layouts/edit/EditSection.svelte'
|
||||||
import WriteSection from '$lib/layouts/write/WriteSection.svelte'
|
import WriteSection from '$lib/layouts/write/WriteSection.svelte'
|
||||||
|
|
||||||
import { createDeviceContext } from '$lib/contexts/device.svelte'
|
import { createDeviceContext } from '$lib/contexts/device.svelte'
|
||||||
import { createConversionConfig } from '$lib/contexts/config.svelte'
|
import { createConversionConfig } from '$lib/contexts/config.svelte'
|
||||||
import { createFilesContext } from '$lib/contexts/files.svelte'
|
import { createFilesContext } from '$lib/contexts/files.svelte'
|
||||||
import { createImageContext } from '$lib/contexts/image.svelte'
|
import { createImageContext } from '$lib/contexts/image.svelte'
|
||||||
import { createRenderedContext } from '$lib/contexts/rendered.svelte'
|
import { createRenderedContext } from '$lib/contexts/rendered.svelte'
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
|
||||||
createDeviceContext()
|
const deviceCtx = createDeviceContext()
|
||||||
const config = createConversionConfig()
|
const config = createConversionConfig()
|
||||||
const filesCtx = createFilesContext()
|
const filesCtx = createFilesContext()
|
||||||
const imageCtx = createImageContext(filesCtx)
|
const imageCtx = createImageContext(filesCtx)
|
||||||
createRenderedContext(imageCtx, config)
|
createRenderedContext(imageCtx, config)
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
deviceCtx.initialize()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main class="grow w-full stack gap-4">
|
<main class="grow w-full stack gap-4">
|
||||||
|
|||||||
@@ -16,12 +16,12 @@
|
|||||||
<AlertDialog.Title>Browser not supported</AlertDialog.Title>
|
<AlertDialog.Title>Browser not supported</AlertDialog.Title>
|
||||||
|
|
||||||
<AlertDialog.Description class="stack gap-2">
|
<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>
|
<p>
|
||||||
We recommend using a Chromium-based browser, such as a recent version of
|
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.google.com/chrome/">Google Chrome</a>,
|
||||||
<a href="https://www.microsoft.com/edge">Microsoft Edge</a>, or
|
<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>
|
</p>
|
||||||
</AlertDialog.Description>
|
</AlertDialog.Description>
|
||||||
</AlertDialog.Content>
|
</AlertDialog.Content>
|
||||||
|
|||||||
@@ -1,27 +1,16 @@
|
|||||||
<script lang="ts">
|
<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'
|
const deviceCtx = getDeviceContext()
|
||||||
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]
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Button variant={deviceCtx.device === null ? 'default' : 'secondary'} onclick={requestDevice}>
|
<Button
|
||||||
|
variant={deviceCtx.device === null ? 'default' : 'secondary'}
|
||||||
|
onclick={() => {
|
||||||
|
/* TODO: */
|
||||||
|
}}
|
||||||
|
>
|
||||||
{#if deviceCtx.device === null}
|
{#if deviceCtx.device === null}
|
||||||
Select device
|
Select device
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -1,51 +1,50 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import IconPending from '~icons/material-symbols/pending'
|
import IconPending from '~icons/material-symbols/pending'
|
||||||
import IconCheckCircle from '~icons/material-symbols/check-circle'
|
import IconCheckCircle from '~icons/material-symbols/check-circle'
|
||||||
import ConnectButton from './ConnectButton.svelte'
|
import ConnectButton from './ConnectButton.svelte'
|
||||||
|
|
||||||
import { getDeviceContext, tryOpenDevice } from '$lib/contexts/device.svelte'
|
import { getDeviceContext } from '$lib/contexts/device.svelte'
|
||||||
import MoreInfo from '$lib/components/MoreInfo.svelte'
|
import MoreInfo from '$lib/components/MoreInfo.svelte'
|
||||||
import { SERIAL_NUMBER_REPORT_ID } from '$lib/constants'
|
import { assert } from '$lib/utils'
|
||||||
|
|
||||||
const deviceCtx = getDeviceContext()
|
const deviceCtx = getDeviceContext()
|
||||||
|
|
||||||
let serial = $state('[Retrieving...]')
|
let serial = $state('[Retrieving...]')
|
||||||
|
|
||||||
async function updateSerial() {
|
async function updateSerial() {
|
||||||
if (deviceCtx.device === null) {
|
if (deviceCtx.device === null) {
|
||||||
serial = '[Retrieving...]'
|
serial = '[Retrieving...]'
|
||||||
return
|
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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
await deviceCtx.device.open()
|
||||||
updateSerial()
|
|
||||||
})
|
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>
|
</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">
|
<div class="grow">
|
||||||
<h2 class="font-semibold text-xl/8" id="connect-section-label">Connect to a device</h2>
|
<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">
|
<div class="row gap-1 text-sm" aria-live="polite">
|
||||||
{#if deviceCtx.device === null}
|
{#if deviceCtx.device === null}
|
||||||
<IconPending aria-hidden /> Not connected to any device yet. Plug in your device, and click on the button to select
|
<IconPending aria-hidden /> Not connected to any device yet. Plug in your device to connect.
|
||||||
it.
|
|
||||||
{:else}
|
{:else}
|
||||||
<MoreInfo>
|
<MoreInfo>
|
||||||
{#snippet icon()}
|
{#snippet icon()}
|
||||||
@@ -53,10 +52,10 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
The serial ID of this device is <code>{serial}</code>.
|
The serial ID of this device is <code>{serial}</code>.
|
||||||
</MoreInfo>
|
</MoreInfo>
|
||||||
Successfully conected to device. If you want to, you can connect to another device instead.
|
Successfully conected to device.
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConnectButton />
|
<!-- <ConnectButton /> -->
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ export function freshContext(el: HTMLCanvasElement) {
|
|||||||
return ctx
|
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'
|
if (filesCtx.files.length < 1) return 'No image selected for e-paper preview'
|
||||||
|
|
||||||
const file = filesCtx.files[0]
|
const file = filesCtx.files[0]
|
||||||
@@ -50,8 +54,10 @@ export function makeAltText(filesCtx: FilesContext, imageCtx: ImageContext, conf
|
|||||||
return output.join(', ')
|
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)
|
const imageData = ctx.createImageData(DEVICE_WIDTH, DEVICE_HEIGHT)
|
||||||
|
console.log('finished creating')
|
||||||
|
|
||||||
for (let y = 0; y < DEVICE_HEIGHT; y++) {
|
for (let y = 0; y < DEVICE_HEIGHT; y++) {
|
||||||
for (let x = 0; x < DEVICE_WIDTH; x++) {
|
for (let x = 0; x < DEVICE_WIDTH; x++) {
|
||||||
@@ -66,5 +72,8 @@ export function drawQuantizedData(ctx: CanvasRenderingContext2D, data: number[])
|
|||||||
imageData.data[bitmapIx + 3] = 0xff
|
imageData.data[bitmapIx + 3] = 0xff
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
console.log('finished writing')
|
||||||
|
|
||||||
ctx.putImageData(imageData, 0, 0)
|
ctx.putImageData(imageData, 0, 0)
|
||||||
|
console.log('finished putting')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,67 +1,71 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$lib/components/ui/button'
|
import { Button } from '$lib/components/ui/button'
|
||||||
|
|
||||||
import { toast } from 'svelte-sonner'
|
import { toast } from 'svelte-sonner'
|
||||||
import { getDeviceContext, tryOpenDevice } from '$lib/contexts/device.svelte'
|
import { getDeviceContext } from '$lib/contexts/device.svelte'
|
||||||
import { getRenderedContext } from '$lib/contexts/rendered.svelte'
|
import { getRenderedContext } from '$lib/contexts/rendered.svelte'
|
||||||
import {
|
import { BYTES_IN_A_ROW, DEVICE_HEIGHT, DEVICE_WIDTH, WRITE_TIME } from '$lib/constants'
|
||||||
BYTES_IN_A_ROW,
|
|
||||||
DEVICE_HEIGHT,
|
|
||||||
DEVICE_WIDTH,
|
|
||||||
SERIAL_NUMBER_REPORT_ID,
|
|
||||||
WRITE_PATTERN_REPORT_ID,
|
|
||||||
WRITE_TIME,
|
|
||||||
} from '$lib/constants'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onprogress: (inPropgress: boolean) => void
|
onprogress: (inPropgress: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const { onprogress }: Props = $props()
|
const { onprogress }: Props = $props()
|
||||||
|
|
||||||
let inProgress = $state(false)
|
let inProgress = $state(false)
|
||||||
|
|
||||||
const deviceCtx = getDeviceContext()
|
const deviceCtx = getDeviceContext()
|
||||||
const renderedCtx = getRenderedContext()
|
const renderedCtx = getRenderedContext()
|
||||||
|
|
||||||
let disabled = $derived(deviceCtx.device === null || renderedCtx.rendered === null || inProgress)
|
let disabled = $derived(deviceCtx.device === null || renderedCtx.rendered === null || inProgress)
|
||||||
let secondary = $derived(deviceCtx.device === null)
|
let secondary = $derived(deviceCtx.device === null)
|
||||||
|
|
||||||
async function connectAndWrite() {
|
async function connectAndWrite() {
|
||||||
if (deviceCtx.device === null || renderedCtx.rendered === null) return
|
if (deviceCtx.device === null || renderedCtx.rendered === null) return
|
||||||
if (!(await tryOpenDevice(deviceCtx.device))) return
|
await deviceCtx.device.open()
|
||||||
|
|
||||||
const buffer = new Uint8Array(DEVICE_HEIGHT * BYTES_IN_A_ROW)
|
const buffer = new Uint8Array(DEVICE_HEIGHT * BYTES_IN_A_ROW)
|
||||||
for (let y = 0; y < DEVICE_HEIGHT; y++) {
|
for (let y = 0; y < DEVICE_HEIGHT; y++) {
|
||||||
for (let xStride = 0; xStride < BYTES_IN_A_ROW; xStride++) {
|
for (let xStride = 0; xStride < BYTES_IN_A_ROW; xStride++) {
|
||||||
let cell = 0x0
|
let cell = 0x0
|
||||||
for (let xStroll = 0; xStroll < 8; xStroll++) {
|
for (let xStroll = 0; xStroll < 8; xStroll++) {
|
||||||
const index = y * DEVICE_WIDTH + xStride * 8 + xStroll
|
const index = y * DEVICE_WIDTH + xStride * 8 + xStroll
|
||||||
cell |= renderedCtx.rendered[index] << xStroll
|
cell |= renderedCtx.rendered[index] << xStroll
|
||||||
}
|
|
||||||
buffer[y * BYTES_IN_A_ROW + xStride] = cell
|
|
||||||
}
|
}
|
||||||
|
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(() => {
|
inProgress = true
|
||||||
onprogress(inProgress)
|
|
||||||
})
|
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>
|
</script>
|
||||||
|
|
||||||
<Button onclick={connectAndWrite} variant={secondary ? 'secondary' : 'default'} {disabled}>
|
<Button onclick={connectAndWrite} variant={secondary ? 'secondary' : 'default'} {disabled}>
|
||||||
|
|||||||
@@ -8,3 +8,7 @@ export function cn(...inputs: ClassValue[]) {
|
|||||||
export function showNumber(n: number): string {
|
export function showNumber(n: number): string {
|
||||||
return String(n).replace('-', '−').replace('Infinity', '∞').replace('NaN', '⁇')
|
return String(n).replace('-', '−').replace('Infinity', '∞').replace('NaN', '⁇')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function assert(expr: unknown): asserts expr {
|
||||||
|
if (!expr) throw Error('assertion failed')
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user