diff options
| author | Adam Soltys <[email protected]> | 2026-09-21 22:49:19 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-21 22:49:19 -0700 |
| commit | 6735d9ec73949046932716dba90ebd9cd63b513d (patch) | |
| tree | 1dc7d59bb7586ca7a2e883bc6657990716962da6 /src/routes/(app)/pos | |
| parent | 2e98fba55d572186f39d00235db1024791ba1561 (diff) | |
| download | coinos-ui-6735d9ec73949046932716dba90ebd9cd63b513d.tar.gz coinos-ui-6735d9ec73949046932716dba90ebd9cd63b513d.zip | |
Web flasher moves from /pos to /flash; /pos redirects there.
coinos v3 takes /pos for the point-of-sale till, so the terminal flasher
needs its own address. Old links keep working through the redirect.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Tmfg8k5f3TbNJS34qiqHAp
Diffstat (limited to 'src/routes/(app)/pos')
| -rw-r--r-- | src/routes/(app)/pos/+page.server.ts | 58 | ||||
| -rw-r--r-- | src/routes/(app)/pos/+page.svelte | 360 | ||||
| -rw-r--r-- | src/routes/(app)/pos/+page.ts | 6 |
3 files changed, 6 insertions, 418 deletions
diff --git a/src/routes/(app)/pos/+page.server.ts b/src/routes/(app)/pos/+page.server.ts deleted file mode 100644 index 1e3e1e99..00000000 --- a/src/routes/(app)/pos/+page.server.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { PUBLIC_COINOS_URL } from "$env/static/public"; -import { auth, fd, get } from "$lib/utils"; -import { hex } from "@scure/base"; -import { fail } from "@sveltejs/kit"; - -const LITTLEFS_SIZE = 0x20000; -const LITTLEFS_MAGIC = "littlefs"; - -// A valid image is exactly one partition long and carries the LittleFS -// superblock magic at byte 8. Anything else (e.g. a JSON error body from -// the server) must never reach the flasher. -const isLittleFS = (buf: Uint8Array) => - buf.length === LITTLEFS_SIZE && - new TextDecoder().decode(buf.subarray(8, 8 + LITTLEFS_MAGIC.length)) === - LITTLEFS_MAGIC; - -export async function load({ cookies }) { - let token; - - try { - token = await get("/ro", auth(cookies)); - } catch (e) {} - - return { token }; -} - -export const actions = { - default: async ({ fetch, request }) => { - const body = await fd(request); - const res = await fetch(`${PUBLIC_COINOS_URL}/flash`, { - method: "POST", - body: JSON.stringify(body), - headers: { - "content-type": "application/json", - accept: "application/octet-stream", - }, - }); - - const buf = new Uint8Array(await res.arrayBuffer()); - - if (!res.ok) { - let message = `Config server returned ${res.status}`; - try { - message = JSON.parse(new TextDecoder().decode(buf)).message || message; - } catch (e) {} - return fail(res.status >= 400 && res.status < 600 ? res.status : 500, { - error: message, - }); - } - - if (!isLittleFS(buf)) - return fail(500, { - error: `Config server returned an invalid image (${buf.length} bytes)`, - }); - - return { bytes: hex.encode(buf) }; - }, -}; diff --git a/src/routes/(app)/pos/+page.svelte b/src/routes/(app)/pos/+page.svelte deleted file mode 100644 index 5a11d148..00000000 --- a/src/routes/(app)/pos/+page.svelte +++ /dev/null @@ -1,360 +0,0 @@ -<script> - import { enhance } from "$app/forms"; - import { onDestroy } from "svelte"; - import { ESPLoader, Transport } from "esptool-js"; - import { hex } from "@scure/base"; - - // Convert Uint8Array → JS "binary string" in safe chunks - function bs(uint8Array) { - let binaryString = ""; - const chunkSize = 0x8000; - for (let i = 0; i < uint8Array.length; i += chunkSize) { - const chunk = uint8Array.subarray(i, i + chunkSize); - binaryString += String.fromCharCode(...chunk); - } - return binaryString; - } - - let { data, form } = $props(); - let { token } = $state(data); - - // --- Defaults --- - const LITTLEFS_ADDRESS_DEFAULT = 0x3d0000; // your existing LittleFS offset - const FW_ADDRESS_DEFAULT = 0x10000; // typical app offset on ESP32-C3 - const CONNECT_BAUD = 115200; // initial - const WORK_BAUD = 921600; // fast flashing - - // --- Shared state --- - let esploader = $state(); - let chip, transport; - let connected = $state(false); - let portInfo = $state(""); - - // --- Existing config path (generated server-side to hex in form.bytes) --- - let bytes = $derived(form?.bytes ? hex.decode(form.bytes) : undefined); - let configError = $state(""); - $effect(() => { - configError = form?.error || ""; - }); - let littlefsAddress = $state(LITTLEFS_ADDRESS_DEFAULT); - let configProgress = $state(0); - let configDone = $state(false); - - // --- New firmware flashing path --- - let fwFile = $state(null); - let fwBytes = $state(); // Uint8Array - let fwAddress = $state(FW_ADDRESS_DEFAULT); - let fwEraseAll = $state(false); - let fwProgress = $state(0); - let fwDone = $state(false); - let fwError = $state(""); - - // --- Latest published release (see coinos-pos/publish.sh) --- - /** @type {any} */ - let release = $state(null); // manifest.json contents - let releaseError = $state(""); - $effect(() => { - fetch("/firmware/manifest.json", { cache: "no-store" }) - .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)))) - .then((m) => (release = m)) - .catch((e) => (releaseError = `No published release: ${e.message}`)); - }); - - // Simple tab toggle - let tab = $state("config"); // "config" | "firmware" - - let connect = async () => { - try { - const device = await navigator.serial.requestPort({}); - transport = new Transport(device, true); - esploader = new ESPLoader({ - transport, - baudrate: CONNECT_BAUD, - }); - - chip = await esploader.main(); - - connected = true; - portInfo = (await device.getInfo?.()) ? JSON.stringify(await device.getInfo()) : "Connected"; - } catch (e) { - console.log(e); - // A failed connect must not leave the port claimed: the browser keeps - // it open until someone calls close(), and the chip may already be - // sitting in the ROM download mode. - await resetDevice(); - } - }; - - // Close the serial port and forget it. The browser holds a port open until - // it is explicitly closed — navigating away does not release it. - let releasePort = async () => { - try { - await transport?.disconnect(); - } catch (e) {} - transport = undefined; - esploader = undefined; - connected = false; - }; - - // Pulse RTS to hard-reset the chip out of the ROM bootloader so it boots - // the app with the new flash contents. Same sequence esptool.py uses - // ("Hard resetting via RTS pin"); on the C3's USB-JTAG-serial port RTS - // drives EN, so the device re-enumerates and must be reconnected. - let resetDevice = async () => { - if (!transport) return; - try { - await transport.setDTR(false); - await transport.setRTS(true); - await new Promise((r) => setTimeout(r, 100)); - await transport.setRTS(false); - } catch (e) { - console.warn("reset failed", e); - } - await releasePort(); - portInfo = "Device reset. Reconnect to flash again."; - }; - - // Connecting parks the chip in the ROM download mode (esptool stub loaded, - // app not running). Until it is hard-reset the OLED keeps whatever the app - // last drew and the keypad is dead, so it looks frozen rather than - // "in flashing mode". Leaving the page — SPA navigation, reload, or tab - // close — must therefore reset the device and release the port; neither - // happens on its own. - onDestroy(() => { - resetDevice(); - }); - $effect(() => { - const onHide = () => resetDevice(); - window.addEventListener("pagehide", onHide); - return () => window.removeEventListener("pagehide", onHide); - }); - - let flashConfig = async () => { - if (!esploader || !bytes) return; - if (bytes.length !== 0x20000) { - configError = `Refusing to flash: image is ${bytes.length} bytes, expected ${0x20000}`; - return; - } - configDone = false; - configProgress = 0; - - const fileArray = [{ data: bs(bytes), address: Number(littlefsAddress) }]; - - await esploader.writeFlash({ - fileArray, - flashSize: "keep", - eraseAll: false, - compress: true, - reportProgress: (_fileIndex, written, total) => { - configProgress = Math.floor((written / total) * 100); - }, - }); - - configDone = true; - await resetDevice(); - }; - - function onPickFw(e) { - fwError = ""; - fwDone = false; - const f = e.target.files?.[0]; - fwFile = f || null; - if (!fwFile) { - fwBytes = undefined; - return; - } - const reader = new FileReader(); - reader.onload = () => { - const buf = new Uint8Array(reader.result); - fwBytes = buf; - }; - reader.onerror = () => { - fwError = "Failed to read file."; - fwBytes = undefined; - }; - reader.readAsArrayBuffer(fwFile); - } - - let flashFirmware = async () => { - if (!esploader) { - fwError = "Not connected."; - return; - } - if (!fwBytes?.length) { - fwError = "No firmware selected."; - return; - } - fwError = ""; - fwDone = false; - fwProgress = 0; - - try { - const fileArray = [{ data: bs(fwBytes), address: Number(fwAddress) }]; - - await esploader.writeFlash({ - fileArray, - flashSize: "keep", - eraseAll: Boolean(fwEraseAll), - compress: true, - reportProgress: (_fileIndex, written, total) => { - fwProgress = Math.floor((written / total) * 100); - }, - }); - - fwDone = true; - await resetDevice(); - } catch (e) { - console.error(e); - fwError = e?.message || "Flash failed."; - } - }; - - // Flash every part of the published release at its offset (bootloader, - // partition table, OTA data, app). The LittleFS config partition is not - // part of the manifest, so device credentials survive. - let flashRelease = async () => { - if (!esploader) { - fwError = "Not connected."; - return; - } - if (!release?.parts?.length) { - fwError = "No release available."; - return; - } - fwError = ""; - fwDone = false; - fwProgress = 0; - - try { - const fileArray = []; - for (const part of release.parts) { - const r = await fetch(`${part.path}?v=${encodeURIComponent(release.version)}`, { cache: "no-store" }); - if (!r.ok) throw new Error(`Download failed: ${part.path} (HTTP ${r.status})`); - const buf = new Uint8Array(await r.arrayBuffer()); - if (part.size && buf.length !== part.size) - throw new Error(`Size mismatch for ${part.path}: got ${buf.length}, expected ${part.size}`); - fileArray.push({ data: bs(buf), address: Number(part.address) }); - } - - const totals = fileArray.map((f) => f.data.length); - const grand = totals.reduce((a, b) => a + b, 0); - await esploader.writeFlash({ - fileArray, - flashSize: "keep", - eraseAll: false, - compress: true, - reportProgress: (fileIndex, written, total) => { - const before = totals.slice(0, fileIndex).reduce((a, b) => a + b, 0); - fwProgress = Math.floor(((before + (written / total) * totals[fileIndex]) / grand) * 100); - }, - }); - - fwDone = true; - await resetDevice(); - } catch (e) { - console.error(e); - fwError = e?.message || "Flash failed."; - } - }; -</script> - -<div class="container px-4 max-w-lg mx-auto space-y-5 mt-20"> - <h1 class="px-3 md:px-0 text-center text-3xl md:text-4xl font-semibold"> - ESP32-C3 Flasher - </h1> - - {#if !connected} - <div class="text-center"> - {#if configDone} - <div class="text-2xl mb-3">Config written ✔ Device restarted</div> - {:else if fwDone} - <div class="text-2xl mb-3">Firmware written ✔ Device restarted</div> - {/if} - <button class="btn" onclick={connect}>Connect</button> - <div class="text-sm mt-2 opacity-70">{portInfo}</div> - </div> -{/if} - - {#if connected} - <!-- Tabs --> - <div class="flex gap-2 justify-center"> - <button class="btn" class:btn-neutral={tab === "config"} onclick={() => (tab = "config")}>Flash config</button> - <button class="btn" class:btn-neutral={tab === "firmware"} onclick={() => (tab = "firmware")}>Flash firmware</button> - <button class="btn" onclick={resetDevice} title="Reset the device into its app and release the serial port">Disconnect</button> - </div> - - {#if tab === "config"} - <!-- Existing CONFIG FLOW --> - {#if bytes} - {#if configDone} - <div class="text-center text-2xl">Config written ✔</div> - {:else} - <div>Generated config file (<b>{bytes.length} bytes</b>)</div> - <label class="label mt-2">LittleFS address (hex)</label> - <input class="input" bind:value={littlefsAddress} onchange={(e) => (littlefsAddress = Number(e.target.value))} /> - <div class="mt-3"> - <button class="btn" onclick={flashConfig}>Flash config</button> - </div> - {#if configProgress > 0}<div class="mt-2">Progress: {configProgress}%</div>{/if} - {/if} - {:else} - {#if configError} - <div class="text-red-600 mb-2">{configError}</div> - {/if} - <form method="POST" use:enhance class="space-y-2"> - <input name="ssid" placeholder="Wifi SSID" class="input" /> - <input name="key" placeholder="Wifi password" class="input" /> - <input name="token" bind:value={token} placeholder="Coinos API token" class="input" /> - <button type="submit" class="btn">Generate Config</button> - </form> - {/if} - {:else} - <!-- NEW FIRMWARE FLOW --> - <div class="space-y-3"> - {#if release} - <div class="p-3 rounded-lg border border-current/20 space-y-2"> - <div class="font-semibold">Latest release: {release.version}</div> - <div class="text-sm opacity-70">Built {release.built} · flashes bootloader, partitions and app; keeps your wifi/token config</div> - <button class="btn" onclick={flashRelease}>Flash latest release</button> - </div> - {:else if releaseError} - <div class="text-sm opacity-70">{releaseError}</div> - {/if} - - <label class="label">Or select a .ino.bin (firmware)</label> - <input type="file" accept=".bin,application/octet-stream" class="input" onchange={onPickFw} /> - - <label class="label">Firmware address (hex, default 0x10000)</label> - <input class="input" bind:value={fwAddress} onchange={(e) => (fwAddress = Number(e.target.value))} /> - - <label class="flex items-center gap-2 mt-2"> - <input type="checkbox" bind:checked={fwEraseAll} /> - <span>Erase all flash (usually NOT needed)</span> - </label> - - <div class="mt-3"> - <button class="btn" disabled={!fwBytes} onclick={flashFirmware}>Flash firmware</button> - </div> - - {#if fwProgress > 0 && !fwDone} - <div>Progress: {fwProgress}%</div> - {/if} - {#if fwDone} - <div class="text-center text-2xl">Firmware written ✔</div> - {/if} - {#if fwError} - <div class="text-red-600">{fwError}</div> - {/if} - </div> - {/if} - {/if} -</div> - -<style> - @reference "../../../app.css"; - - .btn { @apply px-4 py-2 rounded-2xl shadow; } - .btn-neutral { @apply bg-gray-200; } - .input { @apply w-full px-3 py-2 border rounded-xl; } - .label { @apply text-sm opacity-70; } -</style> diff --git a/src/routes/(app)/pos/+page.ts b/src/routes/(app)/pos/+page.ts new file mode 100644 index 00000000..393950d8 --- /dev/null +++ b/src/routes/(app)/pos/+page.ts @@ -0,0 +1,6 @@ +import { redirect } from "@sveltejs/kit"; + +// The web flasher moved to /flash: /pos is the coinos v3 till. +export const load = () => { + redirect(301, "/flash"); +}; |
