diff --git a/THIRD-PARTY-LICENSES b/THIRD-PARTY-LICENSES index 9f82875..dee8fe4 100644 --- a/THIRD-PARTY-LICENSES +++ b/THIRD-PARTY-LICENSES @@ -46,6 +46,76 @@ SOFTWARE. ================================================================================ +joycon-webhid +https://github.com/aka256/joycon-webhid + +Switch Pro Controller NFC protocol implementation in +src/lib/drivers/procon/ was informed by the joycon-webhid WebHID +implementation. + +License: MIT + +Copyright (c) 2021 aka + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +================================================================================ + +jc_toolkit +https://github.com/CTCaer/jc_toolkit + +MCU and NFC command sequences in src/lib/drivers/procon/ were +cross-referenced with jc_toolkit's Joy-Con protocol implementation. + +License: MIT + +Copyright (c) 2017 CTCaer + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +================================================================================ + +Nintendo_Switch_Reverse_Engineering +https://github.com/dekuNukem/Nintendo_Switch_Reverse_Engineering + +HID report structures and MCU/NFC state machine documentation in +src/lib/drivers/procon/ were referenced from dekuNukem's reverse +engineering notes. No license specified in that repository. + +================================================================================ + AmiiboAPI https://github.com/N3evin/AmiiboAPI diff --git a/src/hooks/use-connection.ts b/src/hooks/use-connection.ts index 1ca4d46..6ebb463 100644 --- a/src/hooks/use-connection.ts +++ b/src/hooks/use-connection.ts @@ -6,6 +6,8 @@ import { HidTransport } from "@/lib/transport/hid-transport"; import { GBxCartDriver } from "@/lib/drivers/gbxcart/gbxcart-driver"; import { PowerSaveDriver } from "@/lib/drivers/powersave/powersave-driver"; import { DEVICE_FILTERS } from "@/lib/drivers/powersave/powersave-commands"; +import { ProConDriver } from "@/lib/drivers/procon/procon-driver"; +import { DEVICE_FILTERS as PROCON_FILTERS } from "@/lib/drivers/procon/procon-commands"; import type { DeviceDriver, DeviceInfo, Transport } from "@/lib/types"; // ─── Device probing ────────────────────────────────────────────────────── @@ -273,6 +275,38 @@ export function useConnection({ log, onReady }: UseConnectionOptions) { const info = await psDriver.initialize(); log(`Connected: ${info.deviceName}`); finishConnect(psDriver, info, "POWERSAVE"); + return; + } catch (e) { + log(`Auto-reconnect failed: ${(e as Error).message}`, "warn"); + } + } + + const pcDevice = devices.find((d) => + PROCON_FILTERS.some( + (f) => f.vendorId === d.vendorId && f.productId === d.productId, + ), + ); + if (pcDevice && !driverRef.current) { + log("Reconnecting to Pro Controller..."); + try { + const transport = new HidTransport(PROCON_FILTERS); + const identity = await transport.connectWithDevice(pcDevice); + log(`HID device opened: ${identity.name}`); + + transport.on("onDisconnect", () => { + log("Device disconnected", "warn"); + handleDisconnect(); + }); + + const pcDriver = new ProConDriver( + transport, + identity.raw as HIDDevice, + ); + pcDriver.on("onLog", (msg, level) => log(msg, level)); + + const info = await pcDriver.initialize(); + log(`Connected: ${info.deviceName}`); + finishConnect(pcDriver, info, "PROCON"); } catch (e) { log(`Auto-reconnect failed: ${(e as Error).message}`, "warn"); } @@ -357,6 +391,36 @@ export function useConnection({ log, onReady }: UseConnectionOptions) { finishConnect(psDriver, info, deviceId); break; } + + case "PROCON": { + const transport = new HidTransport(PROCON_FILTERS); + let identity; + if (authorized) { + log("Connecting..."); + identity = await transport.connectWithDevice( + authorized as HIDDevice, + ); + } else { + log("Requesting HID device..."); + identity = await transport.connect(); + } + + transport.on("onDisconnect", () => { + log("Device disconnected", "warn"); + handleDisconnect(); + }); + + const pcDriver = new ProConDriver( + transport, + identity.raw as HIDDevice, + ); + pcDriver.on("onLog", (msg, level) => log(msg, level)); + + const pcInfo = await pcDriver.initialize(); + log(`Connected: ${pcInfo.deviceName}`); + finishConnect(pcDriver, pcInfo, deviceId); + break; + } } } catch (e) { const msg = (e as Error).message; diff --git a/src/lib/core/devices.ts b/src/lib/core/devices.ts index 303db44..28c5778 100644 --- a/src/lib/core/devices.ts +++ b/src/lib/core/devices.ts @@ -37,4 +37,15 @@ export const DEVICES: Record = { "Datel NFC portal. Also supports MaxLander/NaMiio clones. " + "Protocol: github.com/malc0mn/amiigo", }, + PROCON: { + id: "PROCON", + name: "Switch Pro Controller", + vendorId: 0x057e, + productId: 0x2009, + transport: "webhid", + systems: [{ id: "amiibo", name: "Amiibo (NTAG215)" }], + notes: + "Reads Amiibo via the Pro Controller's built-in NFC reader. " + + "Also supports Joy-Con (R). Linux blocked: HID descriptor omits report 0x31.", + }, }; diff --git a/src/lib/drivers/procon/procon-commands.ts b/src/lib/drivers/procon/procon-commands.ts new file mode 100644 index 0000000..96ddc5f --- /dev/null +++ b/src/lib/drivers/procon/procon-commands.ts @@ -0,0 +1,222 @@ +/** + * Nintendo Switch Pro Controller / Joy-Con (R) — NFC protocol constants. + * + * References: + * github.com/dekuNukem/Nintendo_Switch_Reverse_Engineering + * github.com/aka256/joycon-webhid + * github.com/CTCaer/jc_toolkit + */ + +// ─── Device identifiers ──────────────────────────────────────────────── + +export const DEVICE_FILTERS: HIDDeviceFilter[] = [ + { vendorId: 0x057e, productId: 0x2009 }, // Pro Controller + { vendorId: 0x057e, productId: 0x2007 }, // Joy-Con (R) +]; + +// ─── Report IDs ──────────────────────────────────────────────────────── + +/** Output report IDs (host -> controller). */ +export const REPORT = { + USB_CMD: 0x80, + SUBCOMMAND: 0x01, + MCU: 0x11, +} as const; + +/** Input report IDs (controller -> host). */ +export const INPUT = { + USB_REPLY: 0x81, + SUBCOMMAND_REPLY: 0x21, + MCU_DATA: 0x31, +} as const; + +// ─── Subcommands (report 0x01, byte 9) ───────────────────────────────── + +export const SUBCMD = { + SET_INPUT_MODE: 0x03, + SET_MCU_STATE: 0x22, + SET_MCU_CONFIG: 0x21, +} as const; + +// ─── MCU commands (report 0x11, byte 9) ───────────────────────────────── + +export const MCU_CMD = { + STATUS: 0x01, + NFC: 0x02, +} as const; + +// ─── NFC sub-commands (report 0x11, byte 10) ──────────────────────────── + +export const NFC_CMD = { + START_POLLING: 0x01, + STOP_POLLING: 0x02, + START_WAITING: 0x04, + READ_NTAG: 0x06, +} as const; + +// ─── MCU report types (input 0x31, DataView offset 48) ───────────────── + +export const MCU_REPORT = { + EMPTY: 0x00, + STATE: 0x01, + NFC_STATE: 0x2a, + NFC_DATA: 0x3a, + EMPTY_FF: 0xff, +} as const; + +// ─── MCU state values (input 0x31, offset 55 when type=0x01) ─────────── + +export const MCU_STATE = { + STANDBY: 0x01, + NFC: 0x04, + INITIALIZING: 0x06, +} as const; + +// ─── NFC IC state values (input 0x31, offset 55 when type=0x2a) ──────── + +export const NFC_IC = { + WAITING: 0x00, + POLLING: 0x01, + READING: 0x02, + READ_FINISHED: 0x04, + ERROR: 0x07, + DEACTIVATED: 0x08, + DETECTED: 0x09, + INITIALIZING: 0x0b, +} as const; + +// ─── NFC error codes (input 0x31, offset 49 when type=0x2a) ──────────── + +export const NFC_ERROR: Record = { + 0x00: "OK", + 0x3c: "Function error", + 0x3d: "Reset required", + 0x3e: "Read error", + 0x3f: "Write error", + 0x40: "Argument error", + 0x41: "Timeout error", + 0x42: "Invalid UID error", + 0x43: "Unknown error", + 0x44: "Invalid tag error (password)", + 0x45: "Verify error", + 0x46: "Activation error", + 0x47: "Invalid tag error", + 0x48: "Invalid format error", + 0x49: "Authentication error", + 0x4a: "Sequence error", + 0x4b: "Command timeout error", + 0x4c: "Mifare error", +}; + +// ─── NTAG215 constants ────────────────────────────────────────────────── + +export const NTAG215_SIZE = 540; +export const NTAG215_PAGES = 135; + +/** + * Page ranges for NTAG215 (max 60 pages per range). + * Range 1: pages 0x00-0x3B (60 pages, 240 bytes) + * Range 2: pages 0x3C-0x77 (60 pages, 240 bytes) + * Range 3: pages 0x78-0x86 (15 pages, 60 bytes) + */ +export const NTAG215_RANGES = [ + 0x00, 0x3b, 0x3c, 0x77, 0x78, 0x86, 0x00, 0x00, +]; +export const NTAG215_RANGE_COUNT = 3; + +// ─── Protocol constants ───────────────────────────────────────────────── + +export const NEUTRAL_RUMBLE = new Uint8Array([ + 0x00, 0x01, 0x40, 0x40, 0x00, 0x01, 0x40, 0x40, +]); + +/** Arguments for NFC start-polling command. */ +export const POLL_ARGS = new Uint8Array([ + 0x00, 0x00, 0x08, 0x05, 0x00, 0xff, 0xff, 0x00, 0x01, +]); + +/** Arguments for NFC stop-polling / start-waiting commands. */ +export const WAIT_ARGS = new Uint8Array([0x00, 0x00, 0x08, 0x00]); + +/** + * Build the NFC read-NTAG argument payload. + * Format: [seq, 0x00, flags, payloadLen, 0xD0, uidLen, uid[7], filter, rangeCount, ranges[8]] + */ +export function buildReadNtagArgs(): number[] { + return [ + 0x00, // packet sequence + 0x00, // padding + 0x08, // flags: last packet + 0x13, // payload length (19) + 0xd0, // fixed + 0x07, // UID length + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, // UID (zeros = any tag) + 0x00, // tag filter (0 = all NTAG types) + NTAG215_RANGE_COUNT, + ...NTAG215_RANGES, + ]; +} + +/** Offsets within DataView of a 0x31 input report (reportId excluded). */ +export const OFF = { + MCU_REPORT_TYPE: 48, + NFC_RESULT: 49, + NFC_INPUT_TYPE: 50, + NFC_FRAGMENT_NUM: 51, + MCU_STATE: 55, + NFC_IC_STATE: 55, + TAG_PRESENT: 59, + TAG_IC: 61, + TAG_UID_LEN: 63, + TAG_UID: 64, + /** NTAG data start in fragment 1 (67-byte header after MCU report start). */ + FRAG1_DATA: 115, + /** NTAG data start in fragment 2 (7-byte header after MCU report start). */ + FRAG2_DATA: 55, +} as const; + +/** Bytes of NTAG data in fragment 1. */ +export const FRAG1_LEN = 245; +/** Bytes of NTAG data in fragment 2. */ +export const FRAG2_LEN = NTAG215_SIZE - FRAG1_LEN; // 295 + +// ─── CRC-8-CCITT (polynomial 0x07, init 0x00) ────────────────────────── + +/* prettier-ignore */ +const CRC8_TABLE = new Uint8Array([ + 0x00, 0x07, 0x0e, 0x09, 0x1c, 0x1b, 0x12, 0x15, 0x38, 0x3f, 0x36, 0x31, 0x24, + 0x23, 0x2a, 0x2d, 0x70, 0x77, 0x7e, 0x79, 0x6c, 0x6b, 0x62, 0x65, 0x48, 0x4f, + 0x46, 0x41, 0x54, 0x53, 0x5a, 0x5d, 0xe0, 0xe7, 0xee, 0xe9, 0xfc, 0xfb, 0xf2, + 0xf5, 0xd8, 0xdf, 0xd6, 0xd1, 0xc4, 0xc3, 0xca, 0xcd, 0x90, 0x97, 0x9e, 0x99, + 0x8c, 0x8b, 0x82, 0x85, 0xa8, 0xaf, 0xa6, 0xa1, 0xb4, 0xb3, 0xba, 0xbd, 0xc7, + 0xc0, 0xc9, 0xce, 0xdb, 0xdc, 0xd5, 0xd2, 0xff, 0xf8, 0xf1, 0xf6, 0xe3, 0xe4, + 0xed, 0xea, 0xb7, 0xb0, 0xb9, 0xbe, 0xab, 0xac, 0xa5, 0xa2, 0x8f, 0x88, 0x81, + 0x86, 0x93, 0x94, 0x9d, 0x9a, 0x27, 0x20, 0x29, 0x2e, 0x3b, 0x3c, 0x35, 0x32, + 0x1f, 0x18, 0x11, 0x16, 0x03, 0x04, 0x0d, 0x0a, 0x57, 0x50, 0x59, 0x5e, 0x4b, + 0x4c, 0x45, 0x42, 0x6f, 0x68, 0x61, 0x66, 0x73, 0x74, 0x7d, 0x7a, 0x89, 0x8e, + 0x87, 0x80, 0x95, 0x92, 0x9b, 0x9c, 0xb1, 0xb6, 0xbf, 0xb8, 0xad, 0xaa, 0xa3, + 0xa4, 0xf9, 0xfe, 0xf7, 0xf0, 0xe5, 0xe2, 0xeb, 0xec, 0xc1, 0xc6, 0xcf, 0xc8, + 0xdd, 0xda, 0xd3, 0xd4, 0x69, 0x6e, 0x67, 0x60, 0x75, 0x72, 0x7b, 0x7c, 0x51, + 0x56, 0x5f, 0x58, 0x4d, 0x4a, 0x43, 0x44, 0x19, 0x1e, 0x17, 0x10, 0x05, 0x02, + 0x0b, 0x0c, 0x21, 0x26, 0x2f, 0x28, 0x3d, 0x3a, 0x33, 0x34, 0x4e, 0x49, 0x40, + 0x47, 0x52, 0x55, 0x5c, 0x5b, 0x76, 0x71, 0x78, 0x7f, 0x6a, 0x6d, 0x64, 0x63, + 0x3e, 0x39, 0x30, 0x37, 0x22, 0x25, 0x2c, 0x2b, 0x06, 0x01, 0x08, 0x0f, 0x1a, + 0x1d, 0x14, 0x13, 0xae, 0xa9, 0xa0, 0xa7, 0xb2, 0xb5, 0xbc, 0xbb, 0x96, 0x91, + 0x98, 0x9f, 0x8a, 0x8d, 0x84, 0x83, 0xde, 0xd9, 0xd0, 0xd7, 0xc2, 0xc5, 0xcc, + 0xcb, 0xe6, 0xe1, 0xe8, 0xef, 0xfa, 0xfd, 0xf4, 0xf3, +]); + +/** Compute CRC-8-CCITT over a byte array. */ +export function crc8(data: Uint8Array | number[]): number { + let val = 0; + for (let i = 0; i < data.length; i++) { + val = CRC8_TABLE[val ^ (data[i] & 0xff)]; + } + return val; +} diff --git a/src/lib/drivers/procon/procon-driver.ts b/src/lib/drivers/procon/procon-driver.ts new file mode 100644 index 0000000..6cf5fee --- /dev/null +++ b/src/lib/drivers/procon/procon-driver.ts @@ -0,0 +1,497 @@ +/** + * Nintendo Switch Pro Controller / Joy-Con (R) — NFC Amiibo reader. + * + * Reads NTAG215 tags (540 bytes) via the controller's built-in NFC reader. + * Uses HID input report 0x31 (MCU data mode) with the NFC sub-protocol. + * + * References: + * github.com/dekuNukem/Nintendo_Switch_Reverse_Engineering + * github.com/aka256/joycon-webhid + * github.com/CTCaer/jc_toolkit + * + * NOTE: On Linux, the Pro Controller's HID descriptor omits report 0x31, + * which blocks MCU/NFC access. This works on macOS and Windows. + */ + +import type { + DeviceDriver, + DeviceDriverEvents, + DeviceCapability, + DeviceInfo, + CartridgeInfo, + ReadConfig, + DumpProgress, + SystemId, + DetectSystemResult, +} from "@/lib/types"; +import type { HidTransport } from "@/lib/transport/hid-transport"; +import { + REPORT, + INPUT, + SUBCMD, + MCU_CMD, + NFC_CMD, + MCU_REPORT, + MCU_STATE, + NFC_IC, + NFC_ERROR, + NTAG215_SIZE, + NEUTRAL_RUMBLE, + POLL_ARGS, + WAIT_ARGS, + OFF, + FRAG1_LEN, + FRAG2_LEN, + crc8, + buildReadNtagArgs, +} from "./procon-commands"; + +const MCU_TIMEOUT_MS = 5000; +const READ_TIMEOUT_MS = 8000; + +interface McuWaiter { + predicate: (data: DataView) => boolean; + resolve: (data: DataView) => void; + reject: (error: Error) => void; + timer: ReturnType; +} + +function toHex(bytes: Uint8Array): string { + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")) + .join("") + .toUpperCase(); +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +export class ProConDriver implements DeviceDriver { + readonly id = "PROCON"; + readonly name = "Switch Pro Controller"; + readonly capabilities: DeviceCapability[] = [ + { systemId: "amiibo", operations: ["dump_rom"], autoDetect: false }, + ]; + + transport: HidTransport; + private device: HIDDevice; + private events: Partial = {}; + + // Packet counter (low nibble, 0x0-0xF) + private packetNum = 0; + + // MCU waiter queue — resolved by input report handler + private mcuWaiters: McuWaiter[] = []; + + // NFC polling state + private pollTimer: ReturnType | null = null; + private detectedUid: Uint8Array | null = null; + + constructor(transport: HidTransport, device: HIDDevice) { + this.transport = transport; + this.device = device; + device.addEventListener("inputreport", this.onInputReport); + } + + // ─── DeviceDriver interface ────────────────────────────────────────── + + async initialize(): Promise { + // USB handshake — required for USB-connected controllers. + // Steps: get status -> UART handshake -> switch to 3 Mbps baud -> + // re-handshake at new baud -> force USB-only mode. + // Bluetooth controllers don't support report 0x80, so we catch errors. + try { + await this.device.sendReport(REPORT.USB_CMD, new Uint8Array([0x01])); + await sleep(100); + await this.device.sendReport(REPORT.USB_CMD, new Uint8Array([0x02])); + await sleep(100); + await this.device.sendReport(REPORT.USB_CMD, new Uint8Array([0x03])); + await sleep(100); + await this.device.sendReport(REPORT.USB_CMD, new Uint8Array([0x02])); + await sleep(100); + await this.device.sendReport(REPORT.USB_CMD, new Uint8Array([0x04])); + await sleep(100); + this.debug("USB handshake complete"); + } catch { + this.debug("USB handshake skipped (Bluetooth?)"); + } + + // Set input report mode to NFC/IR (0x31) + await this.sendSubcommand(SUBCMD.SET_INPUT_MODE, [0x31]); + await sleep(100); + + // Suspend -> Resume MCU (clean state) + await this.sendSubcommand(SUBCMD.SET_MCU_STATE, [0x00]); + await sleep(50); + await this.sendSubcommand(SUBCMD.SET_MCU_STATE, [0x01]); + + // Wait for MCU standby + this.log("Initializing NFC reader..."); + await this.pollUntilMcuState(MCU_STATE.STANDBY, MCU_TIMEOUT_MS); + + // Configure MCU for NFC mode + await this.sendMcuConfig(0x21, 0x00, [0x04]); + await this.pollUntilMcuState(MCU_STATE.NFC, MCU_TIMEOUT_MS); + + this.log("NFC reader ready"); + this.startNfcPolling(); + + return { + firmwareVersion: "", + deviceName: this.name, + capabilities: this.capabilities, + }; + } + + async detectSystem(): Promise { + return null; + } + + async detectCartridge(_systemId: SystemId): Promise { + if (!this.detectedUid) return null; + return { + title: toHex(this.detectedUid), + meta: { uid: this.detectedUid, uidHex: toHex(this.detectedUid) }, + }; + } + + async readROM( + _config: ReadConfig, + signal?: AbortSignal, + ): Promise { + this.stopNfcPolling(); + // Let in-flight poll commands settle + await sleep(200); + + try { + return await this.readNtag215(signal); + } finally { + // Clear detected UID so the scanner can detect re-placement or removal + this.detectedUid = null; + this.startNfcPolling(); + } + } + + async readSave( + _config: ReadConfig, + _signal?: AbortSignal, + ): Promise { + throw new Error("NFC tags do not have separate save data"); + } + + async writeSave( + _data: Uint8Array, + _config: ReadConfig, + _signal?: AbortSignal, + ): Promise { + throw new Error("NFC tag writing not implemented"); + } + + on( + event: K, + handler: DeviceDriverEvents[K], + ): void { + this.events[event] = handler; + } + + // ─── NTAG215 read ──────────────────────────────────────────────────── + + private async readNtag215(signal?: AbortSignal): Promise { + this.log("Reading NTAG215 (540 bytes)..."); + + // Send start-waiting + read command + await this.sendMcuNfc(NFC_CMD.START_WAITING, [...WAIT_ARGS]); + await sleep(50); + await this.sendMcuNfc(NFC_CMD.READ_NTAG, buildReadNtagArgs()); + + // Keep sending status requests so the MCU delivers fragments + const keepalive = setInterval(() => { + this.sendMcuNfc(NFC_CMD.START_WAITING, [...WAIT_ARGS]).catch(() => {}); + }, 100); + + try { + const data = new Uint8Array(NTAG215_SIZE); + + // Fragment 1: 245 bytes of NTAG data at offset 115 + const frag1 = await this.waitForMcu( + (d) => + d.getUint8(OFF.MCU_REPORT_TYPE) === MCU_REPORT.NFC_DATA && + d.getUint8(OFF.NFC_FRAGMENT_NUM) === 0x01, + READ_TIMEOUT_MS, + signal, + ); + for (let i = 0; i < FRAG1_LEN; i++) { + data[i] = frag1.getUint8(OFF.FRAG1_DATA + i); + } + this.emitProgress("rom", FRAG1_LEN, NTAG215_SIZE); + + // Fragment 2: 295 bytes of NTAG data at offset 55 + const frag2 = await this.waitForMcu( + (d) => + d.getUint8(OFF.MCU_REPORT_TYPE) === MCU_REPORT.NFC_DATA && + d.getUint8(OFF.NFC_FRAGMENT_NUM) === 0x02, + READ_TIMEOUT_MS, + signal, + ); + for (let i = 0; i < FRAG2_LEN; i++) { + data[FRAG1_LEN + i] = frag2.getUint8(OFF.FRAG2_DATA + i); + } + this.emitProgress("rom", NTAG215_SIZE, NTAG215_SIZE); + + // Wait for read-finished confirmation + await this.waitForMcu( + (d) => + d.getUint8(OFF.MCU_REPORT_TYPE) === MCU_REPORT.NFC_STATE && + d.getUint8(OFF.NFC_IC_STATE) === NFC_IC.READ_FINISHED, + READ_TIMEOUT_MS, + signal, + ); + + this.log("Read complete"); + return data; + } finally { + clearInterval(keepalive); + } + } + + // ─── NFC polling ───────────────────────────────────────────────────── + + private startNfcPolling(): void { + if (this.pollTimer) return; + + // Initial start-waiting command + this.sendMcuNfc(NFC_CMD.START_WAITING, [...WAIT_ARGS]).catch(() => {}); + + this.pollTimer = setInterval(() => { + this.sendMcuNfc(NFC_CMD.START_POLLING, [...POLL_ARGS]).catch(() => {}); + }, 300); + } + + private stopNfcPolling(): void { + if (this.pollTimer) { + clearInterval(this.pollTimer); + this.pollTimer = null; + } + this.sendMcuNfc(NFC_CMD.STOP_POLLING, [...WAIT_ARGS]).catch(() => {}); + } + + // ─── Input report handling ─────────────────────────────────────────── + + private onInputReport = (event: Event): void => { + const e = event as unknown as HIDInputReportEvent; + const { data, reportId } = e; + + if (reportId === INPUT.MCU_DATA) { + this.handleMcuReport(data); + } + // 0x81 and 0x21 replies don't carry MCU data we need to process + }; + + private handleMcuReport(data: DataView): void { + const type = data.getUint8(OFF.MCU_REPORT_TYPE); + if (type === MCU_REPORT.EMPTY || type === MCU_REPORT.EMPTY_FF) return; + + // Update internal NFC detection state + if (type === MCU_REPORT.NFC_STATE) { + const icState = data.getUint8(OFF.NFC_IC_STATE); + const result = data.getUint8(OFF.NFC_RESULT); + + if ( + icState === NFC_IC.DETECTED && + data.getUint8(OFF.TAG_PRESENT) === 1 + ) { + const uidLen = data.getUint8(OFF.TAG_UID_LEN); + const uid = new Uint8Array(uidLen); + for (let i = 0; i < uidLen; i++) { + uid[i] = data.getUint8(OFF.TAG_UID + i); + } + if (!this.detectedUid || toHex(uid) !== toHex(this.detectedUid)) { + this.detectedUid = uid; + this.debug(`Tag detected: ${toHex(uid)}`); + } + } else if (icState === NFC_IC.ERROR) { + const errMsg = NFC_ERROR[result] ?? `0x${result.toString(16)}`; + this.debug(`NFC error: ${errMsg}`); + } else if (icState === NFC_IC.POLLING || icState === NFC_IC.WAITING) { + if (this.detectedUid) { + this.debug("Tag removed"); + this.detectedUid = null; + } + } + } + + // Resolve waiters + for (let i = this.mcuWaiters.length - 1; i >= 0; i--) { + const waiter = this.mcuWaiters[i]; + if (waiter.predicate(data)) { + clearTimeout(waiter.timer); + this.mcuWaiters.splice(i, 1); + waiter.resolve(data); + return; + } + } + } + + // ─── MCU waiter ────────────────────────────────────────────────────── + + private waitForMcu( + predicate: (data: DataView) => boolean, + timeoutMs: number, + signal?: AbortSignal, + ): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new Error("Aborted")); + return; + } + + const timer = setTimeout(() => { + const idx = this.mcuWaiters.findIndex((w) => w.timer === timer); + if (idx >= 0) this.mcuWaiters.splice(idx, 1); + reject(new Error("MCU response timeout")); + }, timeoutMs); + + const waiter: McuWaiter = { predicate, resolve, reject, timer }; + this.mcuWaiters.push(waiter); + + signal?.addEventListener( + "abort", + () => { + clearTimeout(timer); + const idx = this.mcuWaiters.indexOf(waiter); + if (idx >= 0) this.mcuWaiters.splice(idx, 1); + reject(new Error("Aborted")); + }, + { once: true }, + ); + }); + } + + /** + * Send MCU status requests until the MCU reaches the target state. + * Needed after sending MCU config commands — the MCU transitions + * asynchronously and reports its state in 0x31 input reports. + */ + private async pollUntilMcuState( + targetState: number, + timeoutMs: number, + ): Promise { + const statePromise = this.waitForMcu( + (d) => + d.getUint8(OFF.MCU_REPORT_TYPE) === MCU_REPORT.STATE && + d.getUint8(OFF.MCU_STATE) === targetState, + timeoutMs, + ); + + // Keep sending status requests to trigger MCU data in 0x31 reports + const poll = setInterval(() => { + this.sendMcuStatus().catch(() => {}); + }, 100); + + try { + await statePromise; + } finally { + clearInterval(poll); + } + } + + // ─── Protocol layer ────────────────────────────────────────────────── + + /** + * Send a subcommand via report 0x01. + * Format: [packetNum, rumble[8], subcmd, ...args] + */ + private async sendSubcommand( + subcmd: number, + args: number[] = [], + ): Promise { + const packet = new Uint8Array(48); + packet[0] = this.nextPacketNum(); + packet.set(NEUTRAL_RUMBLE, 1); + packet[9] = subcmd; + for (let i = 0; i < args.length; i++) packet[10 + i] = args[i]; + await this.device.sendReport(REPORT.SUBCOMMAND, packet); + } + + /** + * Send an MCU config subcommand (0x21) with CRC-8. + * Format: [packetNum, rumble[8], 0x21, mcuCmd, mcuSubCmdArg[36], crc8] + */ + private async sendMcuConfig( + mcuCmd: number, + mcuSub: number, + args: number[], + ): Promise { + const mcuSubCmdArg = new Uint8Array(36); + mcuSubCmdArg[0] = mcuSub; + for (let i = 0; i < args.length; i++) mcuSubCmdArg[1 + i] = args[i]; + + const packet = new Uint8Array(48); + packet[0] = this.nextPacketNum(); + packet.set(NEUTRAL_RUMBLE, 1); + packet[9] = SUBCMD.SET_MCU_CONFIG; + packet[10] = mcuCmd; + packet.set(mcuSubCmdArg, 11); + packet[47] = crc8(mcuSubCmdArg); + await this.device.sendReport(REPORT.SUBCOMMAND, packet); + } + + /** + * Send an MCU NFC command via report 0x11 with CRC-8. + * Format: [packetNum, rumble[8], mcuCmd(0x02), mcuSubCmdArg[36], crc8] + */ + private async sendMcuNfc(subcmd: number, args: number[]): Promise { + const mcuSubCmdArg = new Uint8Array(36); + mcuSubCmdArg[0] = subcmd; + for (let i = 0; i < args.length; i++) mcuSubCmdArg[1 + i] = args[i]; + + const packet = new Uint8Array(48); + packet[0] = this.nextPacketNum(); + packet.set(NEUTRAL_RUMBLE, 1); + packet[9] = MCU_CMD.NFC; + packet.set(mcuSubCmdArg, 10); + packet[46] = crc8(mcuSubCmdArg); + await this.device.sendReport(REPORT.MCU, packet); + } + + /** Send MCU status request (report 0x11, cmd 0x01). No CRC needed. */ + private async sendMcuStatus(): Promise { + const packet = new Uint8Array(48); + packet[0] = this.nextPacketNum(); + packet.set(NEUTRAL_RUMBLE, 1); + packet[9] = MCU_CMD.STATUS; + await this.device.sendReport(REPORT.MCU, packet); + } + + // ─── Helpers ───────────────────────────────────────────────────────── + + private nextPacketNum(): number { + const n = this.packetNum; + this.packetNum = (this.packetNum + 1) & 0x0f; + return n; + } + + private emitProgress( + phase: DumpProgress["phase"], + bytesRead: number, + totalBytes: number, + ): void { + this.events.onProgress?.({ + phase, + bytesRead, + totalBytes, + fraction: bytesRead / totalBytes, + }); + } + + private log( + message: string, + level: "info" | "warn" | "error" = "info", + ): void { + this.events.onLog?.(message, level); + } + + private debug(message: string): void { + console.log(`[procon] ${message}`); + } +}