feat: factory kit

This commit is contained in:
2026-03-26 12:15:12 +01:00
parent dee754c015
commit 8837c44300
9 changed files with 409 additions and 5 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

@@ -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();
});
}

Binary file not shown.