5 Commits

Author SHA1 Message Date
c7f46e8711 feat: use progress api 2026-04-22 17:21:58 +02:00
40f8e3a430 feat: change t4g to ccb 2026-04-06 22:11:24 +02:00
07491b9741 feat: device flip 2026-03-31 19:40:01 +02:00
04540a55ef feat: T4G device rotation 2026-03-30 16:23:47 +02:00
8837c44300 feat: factory kit 2026-03-26 12:15:12 +01:00
18 changed files with 663 additions and 35 deletions

View File

@@ -1,5 +1,5 @@
{
"$schema": "https://unpkg.com/typesafe-i18n@5.26.2/schema/typesafe-i18n.json",
"baseLocale": "en",
"adapter": "svelte"
}
"$schema": "https://unpkg.com/typesafe-i18n@5.26.2/schema/typesafe-i18n.json",
"baseLocale": "en",
"adapter": "svelte"
}

View File

@@ -107,6 +107,7 @@ const config = {
"delete_sweep",
"print",
"restore_from_trash",
"factory",
"history",
"history_toggle_off",
"text_to_speech",

View File

@@ -73,6 +73,7 @@
"glob": "^11.0.3",
"js-yaml": "^4.1.1",
"jsdom": "^26.1.0",
"jszip": "^3.10.1",
"npm-run-all": "^4.1.5",
"prettier": "^3.7.4",
"prettier-plugin-css-order": "^2.1.2",

72
pnpm-lock.yaml generated
View File

@@ -125,6 +125,9 @@ importers:
jsdom:
specifier: ^26.1.0
version: 26.1.0
jszip:
specifier: ^3.10.1
version: 3.10.1
npm-run-all:
specifier: ^4.1.5
version: 4.1.5
@@ -2562,7 +2565,7 @@ packages:
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
deprecated: Glob versions prior to v9 are no longer supported
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
global-dirs@3.0.1:
resolution: {integrity: sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==}
@@ -2698,6 +2701,9 @@ packages:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'}
immediate@3.0.6:
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
immutable@5.1.1:
resolution: {integrity: sha512-3jatXi9ObIsPGr3N5hGw/vWWcTkq6hUYhpQz4k0wLC+owqWi/LiugIw9x0EdNZ2yGedKN/HzePiBvaJRXa0Ujg==}
@@ -2936,6 +2942,9 @@ packages:
resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==}
engines: {node: '>= 0.4'}
isarray@1.0.0:
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
isarray@2.0.5:
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
@@ -3016,6 +3025,9 @@ packages:
resolution: {integrity: sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ==}
engines: {'0': node >=0.6.0}
jszip@3.10.1:
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
keyv@5.5.5:
resolution: {integrity: sha512-FA5LmZVF1VziNc0bIdCSA1IoSVnDCqE8HJIZZv2/W8YmoAM50+tnUgJR/gQZwEeIMleuIOnRnHA/UaZRNeV4iQ==}
@@ -3044,6 +3056,9 @@ packages:
resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
engines: {node: '>=6'}
lie@3.3.0:
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
@@ -3269,6 +3284,9 @@ packages:
pako@0.2.9:
resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==}
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
pako@2.1.0:
resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==}
@@ -3429,6 +3447,9 @@ packages:
resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==}
engines: {node: ^14.13.1 || >=16.0.0}
process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
process@0.11.10:
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
engines: {node: '>= 0.6.0'}
@@ -3464,6 +3485,9 @@ packages:
resolution: {integrity: sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA==}
engines: {node: '>=4'}
readable-stream@2.3.8:
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
readdirp@4.0.2:
resolution: {integrity: sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==}
engines: {node: '>= 14.16.0'}
@@ -3573,6 +3597,9 @@ packages:
resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==}
engines: {node: '>=0.4'}
safe-buffer@5.1.2:
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
@@ -3631,6 +3658,9 @@ packages:
resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
engines: {node: '>= 0.4'}
setimmediate@1.0.5:
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
shebang-command@1.2.0:
resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==}
engines: {node: '>=0.10.0'}
@@ -3789,6 +3819,9 @@ packages:
resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
engines: {node: '>= 0.4'}
string_decoder@1.1.1:
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
stringify-object@3.3.0:
resolution: {integrity: sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==}
engines: {node: '>=4'}
@@ -7280,6 +7313,8 @@ snapshots:
ignore@7.0.5: {}
immediate@3.0.6: {}
immutable@5.1.1: {}
import-fresh@3.3.0:
@@ -7510,6 +7545,8 @@ snapshots:
call-bound: 1.0.4
get-intrinsic: 1.3.0
isarray@1.0.0: {}
isarray@2.0.5: {}
isexe@2.0.0: {}
@@ -7596,6 +7633,13 @@ snapshots:
json-schema: 0.4.0
verror: 1.10.0
jszip@3.10.1:
dependencies:
lie: 3.3.0
pako: 1.0.11
readable-stream: 2.3.8
setimmediate: 1.0.5
keyv@5.5.5:
dependencies:
'@keyv/serialize': 1.1.1
@@ -7614,6 +7658,10 @@ snapshots:
leven@3.1.0: {}
lie@3.3.0:
dependencies:
immediate: 3.0.6
lines-and-columns@1.2.4: {}
listr2@3.14.0(enquirer@2.4.1):
@@ -7823,6 +7871,8 @@ snapshots:
pako@0.2.9: {}
pako@1.0.11: {}
pako@2.1.0: {}
parent-module@1.0.1:
@@ -7948,6 +7998,8 @@ snapshots:
pretty-bytes@6.1.1: {}
process-nextick-args@2.0.1: {}
process@0.11.10: {}
proxy-from-env@1.0.0: {}
@@ -7981,6 +8033,16 @@ snapshots:
normalize-package-data: 2.5.0
path-type: 3.0.0
readable-stream@2.3.8:
dependencies:
core-util-is: 1.0.2
inherits: 2.0.4
isarray: 1.0.0
process-nextick-args: 2.0.1
safe-buffer: 5.1.2
string_decoder: 1.1.1
util-deprecate: 1.0.2
readdirp@4.0.2: {}
reflect.getprototypeof@1.0.10:
@@ -8127,6 +8189,8 @@ snapshots:
has-symbols: 1.1.0
isarray: 2.0.5
safe-buffer@5.1.2: {}
safe-buffer@5.2.1: {}
safe-push-apply@1.0.0:
@@ -8194,6 +8258,8 @@ snapshots:
es-errors: 1.3.0
es-object-atoms: 1.1.1
setimmediate@1.0.5: {}
shebang-command@1.2.0:
dependencies:
shebang-regex: 1.0.0
@@ -8410,6 +8476,10 @@ snapshots:
define-properties: 1.2.1
es-object-atoms: 1.0.0
string_decoder@1.1.1:
dependencies:
safe-buffer: 5.1.2
stringify-object@3.3.0:
dependencies:
get-own-enumerable-property-symbols: 3.0.2

View File

@@ -0,0 +1,121 @@
Add-Type -AssemblyName System.Windows.Forms
# Function to create a form for COM port selection
function Show-ComPortSelectionForm {
# Create the form
$form = New-Object System.Windows.Forms.Form
$form.Text = "Select a COM Port"
$form.Size = New-Object System.Drawing.Size(400, 400) # Set the size of the form
$form.StartPosition = "CenterScreen"
# Create a label to display a message
$infoLabel = New-Object System.Windows.Forms.Label
$infoLabel.Location = New-Object System.Drawing.Point(20, 20)
$infoLabel.Size = New-Object System.Drawing.Size(340, 30)
$infoLabel.Text = "Select a COM port."
# Create a ListBox to hold the COM port items
$comPortListBox = New-Object System.Windows.Forms.ListBox
$comPortListBox.Location = New-Object System.Drawing.Point(20, 60)
$comPortListBox.Size = New-Object System.Drawing.Size(340, 200)
# Create a label for status output
$statusLabel = New-Object System.Windows.Forms.Label
$statusLabel.Location = New-Object System.Drawing.Point(20, 280)
$statusLabel.Size = New-Object System.Drawing.Size(340, 60)
$statusLabel.Text = "Status: Ready"
$statusLabel.ForeColor = [System.Drawing.Color]::Black
# Function to populate COM ports
function PopulateComPorts {
# Clear existing items
$comPortListBox.Items.Clear()
# Get all COM ports with "OK" status
$comPorts = Get-PnpDevice | Where-Object { $_.Class -eq 'Ports' -and $_.Status -eq 'OK' }
# Add full friendly name to the ListBox
foreach ($comPort in $comPorts) {
$comPortListBox.Items.Add($comPort.FriendlyName)
}
}
# Populate COM ports initially
PopulateComPorts
# Event handler for mouse click
$comPortListBox.Add_MouseClick({
ProcessSelection
})
# Event handler for "Enter" key press
$comPortListBox.Add_KeyDown({
param($sender, $e)
if ($e.KeyCode -eq 'Enter') {
ProcessSelection
}
})
# Function to process selection
function ProcessSelection {
if ($comPortListBox.SelectedItem) {
# Get the selected full friendly name
$selectedFriendlyName = $comPortListBox.SelectedItem
# Extract the COMx part from the friendly name
$selectedFriendlyName -match '(COM\d+)' | Out-Null
$selectedComPort = $Matches[1]
if ($selectedComPort) {
# Clear the status label
$statusLabel.Text = "Status: Executing..."
$statusLabel.ForeColor = [System.Drawing.Color]::Black
# Run the batch file with the extracted COM port (e.g., COM3)
$process = Start-Process cmd.exe -ArgumentList "/c .\flash.bat $selectedComPort" -NoNewWindow -PassThru -Wait
$exitCode = $process.ExitCode
if ($exitCode -eq 0) {
# Successful execution
$statusLabel.Text = "Status: Execution completed successfully."
$statusLabel.ForeColor = [System.Drawing.Color]::Green
} else {
# Error occurred
$statusLabel.Text = "Status: Error occurred during execution."
$statusLabel.ForeColor = [System.Drawing.Color]::Red
}
}
}
}
# Create a timer for automatic updates
$timer = New-Object System.Windows.Forms.Timer
$timer.Interval = 1000 # Set interval to 1 second
# Timer Tick event to refresh the COM port items
$timer.Add_Tick({
PopulateComPorts # Refresh the COM port items
})
# Start the timer when the form is shown
$form.Add_Shown({
$timer.Start()
})
# Stop the timer when the form is closed
$form.Add_FormClosing({
$timer.Stop()
})
# Add controls to the form
$form.Controls.Add($infoLabel)
$form.Controls.Add($comPortListBox) # Add the ListBox to the form
$form.Controls.Add($statusLabel) # Add the status label
# Show the form
$form.ShowDialog()
}
# Show the COM port selection dialog
Show-ComPortSelectionForm

View File

@@ -1,7 +1,8 @@
name: T4G
name: CCB
col:
- row:
- switch: { e: 3, n: 5, w: 4, s: 6 }
rotationAnchor: true
- offset: [0.5, 0]
row:
- key: 2

View File

@@ -2,6 +2,8 @@ export interface CompiledLayout {
name: string;
size: [number, number];
keys: CompiledLayoutKey[];
fixedKeys: CompiledLayoutKey[];
rotationAnchor?: [number, number];
}
export interface CompiledLayoutKey {

View File

@@ -1,12 +1,12 @@
<script lang="ts">
import { deviceLayout } from "$lib/serial/connection";
import { deviceLayout, deviceMeta } from "$lib/serial/connection";
import { dev } from "$app/environment";
import ActionSelector from "$lib/components/layout/ActionSelector.svelte";
import { get } from "svelte/store";
import KeyboardKey from "$lib/components/layout/KeyboardKey.svelte";
import { getContext, mount, unmount } from "svelte";
import type { VisualLayoutConfig } from "./visual-layout.js";
import { changes, ChangeType, layout } from "$lib/undo-redo";
import { changes, ChangeType, layout, settings } from "$lib/undo-redo";
import { fly } from "svelte/transition";
import { expoOut } from "svelte/easing";
import { activeLayer, activeProfile } from "$lib/serial/connection";
@@ -119,9 +119,13 @@
}
function edit(index: number) {
const keyInfo = layoutInfo.keys[index];
const keyInfo =
layoutInfo.keys.find(({ id }) => id === index) ??
layoutInfo.fixedKeys.find(({ id }) => id === index);
if (!keyInfo) return;
const clickedGroup = groupParent.children.item(index) as SVGGElement;
const clickedGroup = groupParent.querySelector(
`g[data-id="${index}"]`,
) as SVGGElement;
const nextAction =
get(layout)[get(activeProfile)]![get(activeLayer)]?.[keyInfo.id];
const currentAction =
@@ -189,30 +193,185 @@
}
let focusKey: CompiledLayoutKey;
let groupParent: SVGElement;
let groupParent: SVGGElement;
let rotationTarget: SVGCircleElement;
let rotationSetting = $derived(
$deviceMeta?.settings
.find((it) => it.name === "misc")
?.items.find((it) => it.name === "device rotation"),
);
let settingRotation = $derived(
rotationSetting
? ($settings[$activeProfile]?.[rotationSetting.id]?.value ?? 90)
: 90,
);
let flippedSetting = $derived(
$deviceMeta?.settings
.find((it) => it.name === "misc")
?.items.find((it) => it.name === "device orientation"),
);
let flipped = $derived(
flippedSetting
? $settings[$activeProfile]?.[flippedSetting.id]?.value !== 0
: false,
);
let draggingRotation = $state(90);
let isDragging = $state(false);
let rotation = $derived(isDragging ? draggingRotation : settingRotation);
let dragOffset = 0;
function calcDragOffset(event: MouseEvent) {
const offset = rotationTarget.getBoundingClientRect();
const cx = offset.x + offset.width / 2;
const cy = offset.y + offset.height / 2;
const a = Math.atan2(event.x - cx, event.y - cy) * (180 / Math.PI) + 90;
return flipped ? (a + 180) % 360 : a;
}
function toggleFlip() {
changes.update((changes) => {
changes.push([
{
type: ChangeType.Setting,
id: flippedSetting!.id,
setting: flipped ? 0 : 1,
profile: get(activeProfile),
},
]);
return changes;
});
}
function dragRotation(event: MouseEvent) {
if (!isDragging) return;
const value = Math.min(
180,
Math.max(0, Math.round(calcDragOffset(event) - dragOffset)),
);
if (draggingRotation !== value) {
draggingRotation = value;
}
}
function dragEnable(event: MouseEvent) {
dragOffset = calcDragOffset(event) - rotation;
draggingRotation = rotation;
isDragging = true;
}
function dragDisable() {
isDragging = false;
if (settingRotation !== draggingRotation) {
changes.update((changes) => {
changes.push([
{
type: ChangeType.Setting,
id: rotationSetting!.id,
setting: draggingRotation,
profile: get(activeProfile),
},
]);
return changes;
});
}
}
</script>
<svelte:window on:keydown={navigate} />
<svg
class="print"
viewBox="0 0 {layoutInfo.size[0] * scale} {layoutInfo.size[1] * scale}"
bind:this={groupParent}
viewBox="0 {flipped ? margin * -3 : 0} {layoutInfo.size[0] *
scale} {layoutInfo.size[1] * scale}"
transition:fly={{ y: 48, easing: expoOut }}
onmousemove={dragRotation}
onmouseup={dragDisable}
>
{#each layoutInfo.keys as key, i}
<KeyboardKey
{i}
{key}
onfocusin={() => (focusKey = key)}
onclick={() => edit(i)}
onkeypress={({ key }) => {
if (key === "Enter") {
edit(i);
}
}}
<g
bind:this={groupParent}
transform="rotate({flipped ? 180 : 0})"
transform-origin="{(layoutInfo.rotationAnchor?.[0] ?? 0) *
scale} {(layoutInfo.size[1] * scale) / 2}"
>
<g
transform-origin="{(layoutInfo.rotationAnchor?.[0] ?? 0) *
scale} {(layoutInfo.rotationAnchor?.[1] ?? 0) * scale}"
transform="rotate({-(rotation - 90)})"
class="group"
>
{#each layoutInfo.keys as key}
<KeyboardKey
{key}
{flipped}
onfocusin={() => (focusKey = key)}
onclick={() => edit(key.id)}
onkeypress={(event) => {
if (event.key === "Enter") {
edit(key.id);
}
}}
/>
{/each}
{#if rotationSetting}
<rect
role="button"
tabindex="-1"
onmousedown={dragEnable}
ondblclick={toggleFlip}
class="handle"
x={(layoutInfo.size[0] * scale) / 2 - (0.5 * scale) / 2}
y={layoutInfo.size[1] * scale + margin - 0.05 * scale}
width={0.5 * scale}
height={0.05 * scale}
ry={0.025 * scale}
fill="currentColor"
stroke="currentColor"
stroke-width={strokeWidth}
/>
{#if isDragging}
{@const x = (layoutInfo.size[0] * scale) / 2}
{@const y = layoutInfo.size[1] * scale + margin * (flipped ? 2 : 3)}
<text
transition:fly={{ y: 2, easing: expoOut }}
transform={flipped ? "rotate(180)" : undefined}
transform-origin="{x} {y}"
class="handle-label"
text-anchor="middle"
font-size={fontSize}
fill="currentColor"
{x}
{y}>{rotation - 90}°</text
>
{/if}
{/if}
</g>
<circle
bind:this={rotationTarget}
cx={(layoutInfo.rotationAnchor?.[0] ?? 0) * scale}
cy={(layoutInfo.rotationAnchor?.[1] ?? 0) * scale}
r="0"
/>
{/each}
<g
transform="rotate({flipped ? 180 : 0})"
transform-origin="{(layoutInfo.rotationAnchor?.[0] ?? 0) *
scale} {(layoutInfo.rotationAnchor?.[1] ?? 0) * scale}"
>
{#each layoutInfo.fixedKeys as key}
<KeyboardKey
{key}
onfocusin={() => (focusKey = key)}
onclick={() => edit(key.id)}
onkeypress={(event) => {
if (event.key === "Enter") {
edit(key.id);
}
}}
/>
{/each}
</g>
</g>
</svg>
<style lang="scss">
@@ -222,4 +381,27 @@
max-height: calc(100% - 170px);
overflow: visible;
}
.handle {
opacity: 0.3;
transition: opacity 0.1s;
cursor: grab;
}
.handle:hover {
opacity: 0.5;
}
.handle:active {
opacity: 1;
cursor: grabbing;
}
.handle:focus {
outline: none;
}
.handle-label {
user-select: none;
}
</style>

View File

@@ -17,14 +17,14 @@
const highlight = getContext<Writable<Set<number>> | undefined>("highlight");
let {
i,
key,
flipped = false,
onclick,
onkeypress,
onfocusin,
}: {
i: number;
key: CompiledLayoutKey;
flipped?: boolean;
onclick: MouseEventHandler<SVGGElement>;
onkeypress: KeyboardEventHandler<SVGGElement>;
onfocusin: FocusEventHandler<SVGGElement>;
@@ -44,7 +44,10 @@
{onkeypress}
{onfocusin}
role="button"
tabindex={i + 1}
tabindex={key.id + 1}
transform={flipped ? "rotate(180)" : undefined}
transform-origin="center"
data-id={key.id}
>
{#if key.shape === "square"}
<rect
@@ -122,6 +125,9 @@
path,
g {
transform-box: fill-box;
}
path {
transform-origin: top left;
}

View File

@@ -36,8 +36,8 @@
import("$lib/assets/layouts/m4gr.layout.yml").then(
(it) => it.default as CompiledLayout,
),
T4G: () =>
import("$lib/assets/layouts/t4g.layout.yml").then(
CCB: () =>
import("$lib/assets/layouts/ccb.layout.yml").then(
(it) => it.default as CompiledLayout,
),
};

View File

@@ -8,6 +8,7 @@ import { userPreferences } from "$lib/preferences";
import { getMeta } from "$lib/meta/meta-storage";
import type { VersionMeta } from "$lib/meta/types/meta";
import { serial as serialPolyfill } from "web-serial-polyfill";
import semverGte from "semver/functions/gte";
export const serialPort = writable<CharaDevice | undefined>();
@@ -91,6 +92,20 @@ export async function initSerial(port: SerialPortLike, withSync: boolean) {
}
}
export async function waitForDevice(device: CharaDevice) {
if (semverGte(device.version, "3.1.0")) {
const startProgress = await device.getProgress();
let total = startProgress;
while (total < 1.0) {
total = await device.getProgress();
syncProgress.set({
max: 1.0 - startProgress,
current: total - startProgress,
});
}
}
}
export async function sync() {
const device = get(serialPort);
if (!device) return;
@@ -100,6 +115,9 @@ export async function sync() {
device.version.toString(),
);
deviceMeta.set(meta);
await waitForDevice(device);
const chordCount = await device.getChordCount();
const maxSettings = meta.settings

View File

@@ -19,7 +19,7 @@ export const PORT_FILTERS: Map<string, SerialPortFilter> = new Map([
["X", { usbProductId: 0x818b, usbVendorId: 0x303a }],
["M4G S3 (pre-production)", { usbProductId: 0x1001, usbVendorId: 0x303a }],
["M4G S3", { usbProductId: 0x829a, usbVendorId: 0x303a }],
["T4G S2", { usbProductId: 0x82f2, usbVendorId: 0x303a }],
["CCB S2", { usbProductId: 0x82f2, usbVendorId: 0x303a }],
]);
const DEVICE_ALIASES = new Map<string, Set<string>>([
@@ -30,7 +30,7 @@ const DEVICE_ALIASES = new Map<string, Set<string>>([
["CCX", new Set(["X", "ccx"])],
["M4G", new Set(["M4G S3", "m4g_s3", "M4G S3 (pre-production)"])],
["M4G (right)", new Set(["M4GR S3", "m4gr_s3"])],
["T4G", new Set(["T4G S2", "t4g_s2"])],
["CCB", new Set(["T4G S2", "t4g_s2", "CCB S2", "ccb s2"])],
]);
export function getName(alias: string): string {
@@ -67,6 +67,7 @@ const KEY_COUNTS = {
M4G: 90,
M4GR: 90,
T4G: 7,
CCB: 7,
ZERO: 256,
} as const;
@@ -142,7 +143,7 @@ export class CharaDevice {
constructor(
readonly port: SerialPortLike,
public baudRate = 115200,
public baudRate = 921600,
) {}
async init() {
@@ -389,6 +390,11 @@ export class CharaDevice {
if (status !== "0") throw new Error(`Failed with status ${status}`);
}
async getProgress() {
const [status] = await this.send(1, ["CML", "C5"]);
return Number.parseFloat(status);
}
async deleteChord(chord: Pick<Chord, "actions">) {
const status = await this.send(1, [
"CML",

View File

@@ -5,6 +5,7 @@
import { lt as semverLt } from "semver";
import type { LoaderOptions, ESPLoader } from "esptool-js";
import ProgressButton from "$lib/ProgressButton.svelte";
import FactoryKit from "./FactoryKit.svelte";
let { data } = $props();
@@ -354,6 +355,10 @@
</section>
{/if}
{#if data.meta.update.esptool}
<FactoryKit meta={data.meta as any} />
{/if}
{#if false && data.meta.update.esptool}
<section>
<h3>Factory Flash (WIP)</h3>

View File

@@ -0,0 +1,64 @@
<script lang="ts">
import ProgressButton from "$lib/ProgressButton.svelte";
import {
assembleFactoryKit,
type VersionMetaWithEsptool,
} from "./factory-kit";
let { meta }: { meta: VersionMetaWithEsptool } = $props();
const osChoices = ["Windows", "Linux", "MacOS"] as const;
let os: (typeof osChoices)[number] = $state(osChoices[0]);
let working = $state(false);
let progress = $state(0);
let error: string | undefined = $state(undefined);
async function download() {
progress = 0;
error = undefined;
working = true;
try {
const result = await assembleFactoryKit(meta, os, (value) => {
progress = value;
});
const url = URL.createObjectURL(result);
const a = document.createElement("a");
a.href = url;
a.download = `factory-kit-${meta.device}-${meta.version}-${os}.zip`;
a.click();
} catch (err) {
error = (err as Error).message;
} finally {
working = false;
}
}
</script>
<section>
<h3><span class="icon">factory</span> Factory Kit</h3>
<div class="os-selection">
{#each osChoices as value}
<label
><input type="radio" name="os" {value} bind:group={os} />{value}</label
>
{/each}
</div>
<ProgressButton onclick={download} {progress} {working} {error}
><span class="icon">download</span> Download</ProgressButton
>
</section>
<style lang="scss">
.os-selection {
display: flex;
gap: 2px;
margin: 16px 0;
}
h3 {
display: flex;
align-items: center;
gap: 8px;
}
</style>

View File

@@ -0,0 +1,142 @@
import type { VersionMeta } from "$lib/meta/types/meta";
async function progressFetch(
url: string,
onProgress: (progress: number) => void,
) {
const request = new XMLHttpRequest();
request.open("GET", url, true);
request.responseType = "arraybuffer";
request.onprogress = (event) => {
if (event.total > 0) {
onProgress(event.loaded / event.total);
}
};
const result = new Promise<ArrayBuffer>((resolve, reject) => {
request.onload = () => {
onProgress(1);
if (request.status >= 200 && request.status < 300) {
resolve(request.response);
} else {
reject(new Error(`Failed to fetch ${url}: ${request.statusText}`));
}
};
});
request.send();
return result;
}
export type VersionMetaWithEsptool = VersionMeta & {
update: { esptool: Exclude<VersionMeta["update"]["esptool"], undefined> };
};
export async function assembleFactoryKit(
meta: VersionMetaWithEsptool,
os: "Windows" | "Linux" | "MacOS",
onProgress: (progress: number) => void,
): Promise<Blob> {
let otherProgress: number[] = new Array(
Object.keys(meta.update.esptool.files).length,
).fill(0);
let esptoolProgress = 0;
let compressProgress = 0;
function reportProgress() {
const total =
0.1 * (otherProgress.reduce((a, b) => a + b) / otherProgress.length) +
0.5 * esptoolProgress +
0.4 * compressProgress;
onProgress(total);
}
const esptool =
os === "Windows"
? progressFetch(`/esptool-v5.2.0-windows-amd64.zip`, (progress) => {
esptoolProgress = progress;
reportProgress();
})
: undefined;
const files = Object.values(meta.update.esptool.files).map(
(file, i) =>
[
file,
progressFetch(`${meta.path}/${file}`, (progress) => {
otherProgress[i] = progress;
reportProgress();
}),
] as const,
);
console.log(files);
const JSZip = await import("jszip").then((m) => m.default);
const zip = new JSZip();
const esptoolZipPromise = esptool
? esptool.then((it) => JSZip.loadAsync(it))
: undefined;
for (const [file, dataPromise] of files) {
zip.file(file, dataPromise);
}
const esptoolScript = ".\\esptool\\esptool.exe";
const port = "%1";
const ext = os === "Windows" ? "bat" : "sh";
zip.file(
`flash.${ext}`,
[
esptoolScript,
"--chip",
meta.update.esptool.chip,
"--port",
port,
"--baud",
meta.update.esptool.baud,
"--before",
meta.update.esptool.before,
"--after",
meta.update.esptool.after,
"write_flash",
"-z",
"--flash_mode",
meta.update.esptool.flash_mode,
"--flash_freq",
meta.update.esptool.flash_freq,
"--flash_size",
meta.update.esptool.flash_size,
...Object.entries(meta.update.esptool.files).flatMap(
([address, file]) => [address, file],
),
].join(" "),
{ unixPermissions: "755" },
);
zip.file(`${meta.device}-${meta.version}`, "");
if (os === "Windows") {
zip.file(
"factory-flash.ps1",
import("$lib/assets/factory-flash.ps1?raw").then((m) => m.default),
);
}
zip.file(
"README.txt",
[
`Factory Kit for ${meta.device} ${meta.version}`,
"",
os !== "Windows"
? "Requires esptool, please download from https://github.com/espressif/esptool/releases"
: "",
os === "Windows"
? 'Right click factory-flash.ps1 and select "Run with PowerShell'
: `Run flash.sh with \`./flash.sh <port>\``,
].join("\n"),
);
if (esptoolZipPromise) {
const esptoolZip = await esptoolZipPromise;
for (const [path, file] of Object.entries(esptoolZip.files)) {
if (!file.dir) {
zip.file(
path.replace(/^esptool[^\/]*\//, "esptool/"),
file.async("arraybuffer"),
);
}
}
}
return zip.generateAsync({ type: "blob" }, ({ percent }) => {
compressProgress = percent / 100;
reportProgress();
});
}

View File

@@ -20,6 +20,7 @@
sync,
syncProgress,
syncStatus,
waitForDevice,
} from "$lib/serial/connection";
import ProgressButton from "$lib/ProgressButton.svelte";
import { tick } from "svelte";
@@ -136,6 +137,7 @@
empty.add(id);
}
}
changes.update((changes) => {
changes.push([
...empty.keys().map(
@@ -242,6 +244,8 @@
let progressCurrent = 0;
await waitForDevice(port);
function updateProgress() {
syncProgress.set({
max: progressMax,

Binary file not shown.

View File

@@ -29,6 +29,7 @@ export interface VisualLayoutSwitch extends Positionable {
s: number;
d: number;
};
rotationAnchor?: true;
}
const fileRegex = /\.(layout\.yml)$/;
@@ -53,6 +54,7 @@ export function compileLayout(layout: VisualLayout): CompiledLayout {
name: layout.name,
size: [0, 0],
keys: [],
fixedKeys: [],
};
let y = 0;
@@ -80,13 +82,16 @@ export function compileLayout(layout: VisualLayout): CompiledLayout {
} else if ("switch" in info) {
const cx = x + ox + 1;
const cy = y + oy + 1;
if (info.rotationAnchor) {
compiled.rotationAnchor = [cx, cy];
}
for (const [i, id] of [
info.switch.s,
info.switch.w,
info.switch.n,
info.switch.e,
].entries()) {
compiled.keys.push({
(info.rotationAnchor ? compiled.fixedKeys : compiled.keys).push({
id,
shape: "quarter-circle",
cornerRadius: 0,
@@ -96,7 +101,7 @@ export function compileLayout(layout: VisualLayout): CompiledLayout {
});
}
if (info.switch.d !== undefined) {
compiled.keys.push({
(info.rotationAnchor ? compiled.fixedKeys : compiled.keys).push({
id: info.switch.d,
shape: "square",
cornerRadius: 0.5,