Skip to content

Commit d034783

Browse files
committed
add demo feature
1 parent 95e81d4 commit d034783

3 files changed

Lines changed: 197 additions & 30 deletions

File tree

Keypad.Flasher.Client/src/ch55xbl.css

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,17 @@
7979
border-color: var(--accent);
8080
}
8181

82+
.btn-demo {
83+
background: var(--button-bg);
84+
border-color: var(--button-border);
85+
color: var(--button-text);
86+
box-shadow: var(--button-shadow);
87+
}
88+
89+
.btn-demo:hover:not(:disabled) {
90+
box-shadow: var(--button-shadow-strong);
91+
}
92+
8293
.panel {
8394
padding: 16px;
8495
border-radius: 16px;
@@ -244,6 +255,12 @@
244255
color: var(--pill-warn-text);
245256
}
246257

258+
.pill-demo {
259+
background: #9a3412;
260+
color: #fff7ed;
261+
border: 1px solid #ea580c;
262+
}
263+
247264
.detected-name {
248265
font-size: 18px;
249266
font-weight: 600;

Keypad.Flasher.Client/src/ch55xbl.tsx

Lines changed: 108 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
import { useCallback, useRef, useState, useEffect, useLayoutEffect } from "react";
33
import {
44
CH55xBootloader,
5+
FakeBootloader,
56
parseIntelHexBrowser,
7+
type BootloaderClient,
68
type ConnectedInfo,
79
type Progress,
810
readFileAsText,
@@ -338,6 +340,7 @@ export default function CH55xBootloaderMinimal() {
338340
const [status, setStatus] = useState<Status>({ state: "idle" });
339341
const [connectedInfo, setConnectedInfo] = useState<ConnectedInfo | null>(null);
340342
const [progress, setProgress] = useState<Progress>({ phase: "", current: 0, total: 0 });
343+
const [demoMode, setDemoMode] = useState<boolean>(false);
341344
const [devMode, setDevMode] = useState<boolean>(false);
342345
const [debugFirmware, setDebugFirmware] = useState<boolean>(false);
343346
const [selectedProfile, setSelectedProfile] = useState<KnownDeviceProfile | null>(null);
@@ -361,7 +364,7 @@ export default function CH55xBootloaderMinimal() {
361364

362365
const fileInputRef = useRef<HTMLInputElement | null>(null);
363366
const hiddenKeyInputRef = useRef<HTMLInputElement | null>(null);
364-
const clientRef = useRef<CH55xBootloader | null>(null);
367+
const clientRef = useRef<BootloaderClient | null>(null);
365368
const lastBootloaderIdRef = useRef<number[] | null>(null);
366369
const highlightTimerRef = useRef<number | null>(null);
367370
const removeTimerRef = useRef<Map<string, number>>(new Map());
@@ -433,6 +436,57 @@ export default function CH55xBootloaderMinimal() {
433436
setCurrentBindings(nextBindings);
434437
}, []);
435438

439+
const applyConnectedDevice = useCallback((info: ConnectedInfo, options: { source: "real" | "demo"; persistLastId: boolean }) => {
440+
const previousId = lastBootloaderIdRef.current;
441+
const sameDevice = sameBootloaderId(previousId, info.id);
442+
443+
if (options.persistLastId) {
444+
lastBootloaderIdRef.current = info.id;
445+
}
446+
447+
setConnectedInfo(info);
448+
const profile = findProfileForBootloaderId(info.id);
449+
setSelectedProfile(profile);
450+
451+
if (options.persistLastId) {
452+
setRememberedBootloaderId(info.id);
453+
saveLastBootloaderId(info.id);
454+
}
455+
456+
const stored = loadStoredConfig(info.id);
457+
if (!sameDevice || !selectedLayout) {
458+
const nextLayout = stored?.layout ?? (profile?.layout ? cloneLayout(profile.layout) : null);
459+
setSelectedLayout(nextLayout);
460+
}
461+
462+
const nextBindings = stored?.bindings ?? profile?.defaultBindings ?? null;
463+
if (!sameDevice || !currentBindings) {
464+
setCurrentBindings(nextBindings);
465+
}
466+
467+
const detail = profile
468+
? `${options.source === "demo" ? "Demo: " : ""}${profile.name}`
469+
: (options.source === "demo" ? "Demo device" : undefined);
470+
setStatus(profile ? { state: "connectedKnown", detail } : { state: "connectedUnknown", detail });
471+
}, [currentBindings, selectedLayout]);
472+
473+
const restoreSavedConfig = useCallback(() => {
474+
const id = rememberedBootloaderId ?? lastBootloaderIdRef.current;
475+
if (!id) {
476+
setSelectedProfile(null);
477+
setSelectedLayout(null);
478+
setCurrentBindings(null);
479+
return;
480+
}
481+
const profile = findProfileForBootloaderId(id);
482+
setSelectedProfile(profile);
483+
const stored = loadStoredConfig(id);
484+
const nextLayout = stored?.layout ?? (profile?.layout ? cloneLayout(profile.layout) : null);
485+
const nextBindings = stored?.bindings ?? profile?.defaultBindings ?? null;
486+
setSelectedLayout(nextLayout);
487+
setCurrentBindings(nextBindings);
488+
}, [rememberedBootloaderId]);
489+
436490
const disconnectClient = useCallback(async (nextStatus?: Status, reboot?: boolean) => {
437491
const client = clientRef.current;
438492
if (client) {
@@ -442,10 +496,12 @@ export default function CH55xBootloaderMinimal() {
442496
await client.disconnect().catch(() => {});
443497
}
444498
clientRef.current = null;
499+
setDemoMode(false);
445500
setConnectedInfo(null);
446501
setProgress({ phase: "", current: 0, total: 0 });
502+
restoreSavedConfig();
447503
if (nextStatus) setStatus(nextStatus);
448-
}, []);
504+
}, [restoreSavedConfig]);
449505

450506
const handlePassiveDisconnect = useCallback(async (detail?: string) => {
451507
await disconnectClient({ state: "deviceLost", detail: detail ?? "Device disconnected. Reconnect to continue." });
@@ -480,43 +536,52 @@ export default function CH55xBootloaderMinimal() {
480536
};
481537
}, [capturingStepIndex]);
482538

539+
const handleDemoToggle = useCallback(async () => {
540+
if (demoMode) {
541+
await disconnectClient({ state: "idle" });
542+
return;
543+
}
544+
545+
try {
546+
setStatus({ state: "requesting", detail: "Starting demo…" });
547+
if (clientRef.current) {
548+
await clientRef.current.disconnect().catch(() => {});
549+
}
550+
const client = new FakeBootloader();
551+
clientRef.current = client;
552+
const info = await client.connect();
553+
setDemoMode(true);
554+
applyConnectedDevice(info, { source: "demo", persistLastId: false });
555+
} catch (err) {
556+
clientRef.current = null;
557+
setDemoMode(false);
558+
setStatus({ state: "error", detail: String((err as Error).message ?? "Failed to start demo mode.") });
559+
}
560+
}, [demoMode, disconnectClient, applyConnectedDevice]);
561+
483562
const handleConnect = useCallback(async () => {
484563
if (!webUsbAvailable) {
485564
setStatus({ state: "error", detail: "WebUSB not available in this browser." });
486565
return;
487566
}
488567
try {
489568
setStatus({ state: "requesting" });
490-
const client = clientRef.current ?? new CH55xBootloader();
569+
if (demoMode && clientRef.current) {
570+
await clientRef.current.disconnect().catch(() => {});
571+
}
572+
573+
const existing = clientRef.current;
574+
const client = existing instanceof CH55xBootloader ? existing : new CH55xBootloader();
491575
clientRef.current = client;
576+
577+
setDemoMode(false);
492578
const info = await client.connect();
493-
const previousId = lastBootloaderIdRef.current;
494-
const sameDevice = sameBootloaderId(previousId, info.id);
495-
lastBootloaderIdRef.current = info.id;
496-
setConnectedInfo(info);
497-
const profile = findProfileForBootloaderId(info.id);
498-
setRememberedBootloaderId(info.id);
499-
saveLastBootloaderId(info.id);
500-
setSelectedProfile(profile);
501-
const stored = loadStoredConfig(info.id);
502-
if (!sameDevice || !selectedLayout) {
503-
const nextLayout = stored?.layout ?? (profile?.layout ? cloneLayout(profile.layout) : null);
504-
setSelectedLayout(nextLayout);
505-
}
506-
const nextBindings = stored?.bindings ?? profile?.defaultBindings ?? null;
507-
if (!sameDevice || !currentBindings) {
508-
setCurrentBindings(nextBindings);
509-
}
510-
if (profile) {
511-
setStatus({ state: "connectedKnown", detail: profile.name });
512-
} else {
513-
setStatus({ state: "connectedUnknown" });
514-
}
579+
applyConnectedDevice(info, { source: "real", persistLastId: true });
515580
} catch (e) {
516581
const msg = normalizeUsbErrorMessage(String((e as Error).message ?? e));
517582
setStatus({ state: "error", detail: msg });
518583
}
519-
}, [webUsbAvailable, selectedLayout, currentBindings]);
584+
}, [webUsbAvailable, demoMode, applyConnectedDevice]);
520585

521586
const handleDisconnect = useCallback(async () => {
522587
await disconnectClient({ state: "idle" }, true);
@@ -542,10 +607,12 @@ export default function CH55xBootloaderMinimal() {
542607
}, [disconnectClient]);
543608

544609
useEffect(() => {
545-
const targetId = connectedInfo?.id ?? rememberedBootloaderId;
610+
if (demoMode) return;
611+
if (connectedInfo) return; // avoid overwriting saved presets while live-connected
612+
const targetId = rememberedBootloaderId;
546613
if (!targetId) return;
547614
saveStoredConfig(targetId, { bindings: currentBindings, layout: selectedLayout });
548-
}, [connectedInfo, rememberedBootloaderId, currentBindings, selectedLayout]);
615+
}, [connectedInfo, rememberedBootloaderId, currentBindings, selectedLayout, demoMode]);
549616

550617
useEffect(() => {
551618
if (!webUsbAvailable || typeof navigator === "undefined" || !navigator.usb) return;
@@ -563,6 +630,7 @@ export default function CH55xBootloaderMinimal() {
563630
}, [webUsbAvailable, handlePassiveDisconnect]);
564631

565632
useEffect(() => {
633+
if (demoMode) return;
566634
if (!connectedInfo || !clientRef.current) return;
567635
if (status.state === "flashing" || status.state === "compiling" || status.state === "requesting") return;
568636
let cancelled = false;
@@ -579,7 +647,7 @@ export default function CH55xBootloaderMinimal() {
579647
cancelled = true;
580648
window.clearInterval(intervalId);
581649
};
582-
}, [connectedInfo, status.state, handlePassiveDisconnect]);
650+
}, [connectedInfo, status.state, handlePassiveDisconnect, demoMode]);
583651

584652
const handlePickFile = useCallback(() => {
585653
if (!clientRef.current) {
@@ -1307,6 +1375,16 @@ export default function CH55xBootloaderMinimal() {
13071375

13081376
<div className="actions">
13091377
<button onClick={handleConnect} className="btn">Connect</button>
1378+
{!demoMode && (
1379+
<button
1380+
onClick={handleDemoToggle}
1381+
className="btn btn-demo"
1382+
disabled={Boolean(connectedInfo)}
1383+
title="Start demo mode to explore the UI without connecting a real device."
1384+
>
1385+
Start Demo
1386+
</button>
1387+
)}
13101388
{connectedInfo && (
13111389
<button onClick={handleDisconnect} className="btn">Disconnect</button>
13121390
)}
@@ -1355,6 +1433,7 @@ export default function CH55xBootloaderMinimal() {
13551433
<div className="detected-card">
13561434
<div className="detected-header">
13571435
<span className="pill">Detected device</span>
1436+
{demoMode && <span className="pill pill-demo">Demo</span>}
13581437
{!selectedProfile && <span className="pill pill-warn">Unknown</span>}
13591438
</div>
13601439
<div className="detected-name">{selectedProfile?.name ?? "Unknown device"}</div>

Keypad.Flasher.Client/src/lib/ch55x-bootloader.ts

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,15 @@ export type ConnectedInfo = { version: string; id: number[]; deviceIdHex: string
1616
export type Progress = { phase: "" | "Writing" | "Verifying"; current: number; total: number };
1717
export type ProgressCb = (p: Progress) => void;
1818

19+
export interface BootloaderClient {
20+
connect(): Promise<ConnectedInfo>;
21+
disconnect(): Promise<void>;
22+
flashBinary(hexBytes: Uint8Array, onProgress?: ProgressCb): Promise<void>;
23+
getConnectedDevice(): USBDevice | null;
24+
ping(): Promise<void>;
25+
runApplication(): Promise<void>;
26+
}
27+
1928
// ===== Constants =====
2029
const VENDOR_ID = 0x4348;
2130
const PRODUCT_ID = 0x55e0;
@@ -155,7 +164,7 @@ export function parseIntelHexBrowser(data: string, bufferSize: number): {
155164
}
156165

157166
// ===== Core class =====
158-
export class CH55xBootloader {
167+
export class CH55xBootloader implements BootloaderClient {
159168
public uploadReady = false;
160169
public bootloaderDeviceID: number | null = null;
161170
private device: USBDevice | null = null;
@@ -392,6 +401,68 @@ export class CH55xBootloader {
392401
}
393402
}
394403

404+
// ===== Demo/fake bootloader for UI previews =====
405+
const DEMO_BOOTLOADER_ID = [126, 80, 44, 189];
406+
const DEMO_DEVICE_ID_HEX = "0xd3f0";
407+
const DEMO_VERSION = "9.9.9";
408+
const DEMO_STEPS = 120;
409+
const DEMO_STEP_DELAY_MS = 3;
410+
411+
export class FakeBootloader implements BootloaderClient {
412+
private connected = false;
413+
private readonly bootloaderId: number[];
414+
private readonly stepDelayMs: number;
415+
416+
constructor(opts: { bootloaderId?: number[]; stepDelayMs?: number } = {}) {
417+
this.bootloaderId = opts.bootloaderId ?? DEMO_BOOTLOADER_ID;
418+
this.stepDelayMs = opts.stepDelayMs ?? DEMO_STEP_DELAY_MS;
419+
}
420+
421+
private ensureConnected() {
422+
if (!this.connected) throw new Error("Demo bootloader not connected.");
423+
}
424+
425+
async connect(): Promise<ConnectedInfo> {
426+
this.connected = true;
427+
return { version: DEMO_VERSION, id: [...this.bootloaderId], deviceIdHex: DEMO_DEVICE_ID_HEX };
428+
}
429+
430+
async disconnect(): Promise<void> {
431+
this.connected = false;
432+
}
433+
434+
getConnectedDevice(): USBDevice | null {
435+
return null;
436+
}
437+
438+
async ping(): Promise<void> {
439+
this.ensureConnected();
440+
}
441+
442+
async runApplication(): Promise<void> {
443+
this.connected = false;
444+
}
445+
446+
async flashBinary(_hexBytes: Uint8Array, onProgress?: ProgressCb): Promise<void> {
447+
this.ensureConnected();
448+
449+
const writeTotal = DEMO_STEPS;
450+
onProgress?.({ phase: "Writing", current: 0, total: writeTotal });
451+
for (let i = 0; i < writeTotal; i += 1) {
452+
await new Promise((resolve) => window.setTimeout(resolve, this.stepDelayMs));
453+
onProgress?.({ phase: "Writing", current: i + 1, total: writeTotal });
454+
}
455+
456+
onProgress?.({ phase: "Verifying", current: 0, total: writeTotal });
457+
for (let i = 0; i < writeTotal; i += 1) {
458+
await new Promise((resolve) => window.setTimeout(resolve, this.stepDelayMs));
459+
onProgress?.({ phase: "Verifying", current: i + 1, total: writeTotal });
460+
}
461+
462+
onProgress?.({ phase: "", current: 0, total: 0 });
463+
}
464+
}
465+
395466
// ===== Small utils for consumers =====
396467
export async function readFileAsText(file: File): Promise<string> {
397468
return new Promise((resolve, reject) => {

0 commit comments

Comments
 (0)