Skip to content

Commit 85fbf38

Browse files
committed
Read real Toy Pad tag content; document known issues
The Toy Pad's READ command args were documented as [mode, tagIndex, page] in the reference projects we copied from; the actual order is [mode, page, tagIndex]. With the wrong order, the portal silently returns pages 0-3 regardless of the requested page — so the driver had literally never been reading real NTAG213 content past page 3, just pages 0-3 repeated 11x and labeled a "character." Correcting the order yields genuine 180-byte NTAG213 dumps with unique CRCs per tag. While here, make the scanner actually useful for non-Lego tags: - Classify tags using the marker byte from the portal's 0x56 event: NTAG (Lego), MIFARE Classic Disney, foreign MIFARE, 4-byte MIFARE. Non-Lego tags short-circuit with device-agnostic messages instead of being labeled as Lego characters. - Validate the returned page-0 UID against the anti-collision UID, because the portal doesn't reliably honor tagIndex when multiple tags are on the pad — it returns data for whichever tag its NFC controller happens to be locked onto. Mismatches now show "another tag on the portal is blocking this read" instead of silently presenting another tag's bytes. - Clear the failed-UID cache when a non-NTAG tag is removed, so a Disney tag being lifted off unblocks NTAG tags that had been failing via NFC coexistence. - Display the UID in the error state. - Tighten the portal-command msg-id range to 0x20..0x7F so late auto-read responses (which the portal sends with IDs starting at 0x86) can't masquerade as our outstanding READ. Plus a top-of-file TODO block in the driver cataloguing the known limitations that weren't fixed in this pass: no multi-tag addressing, Disney coexistence poisoning NTAG reads, unverified PWD_AUTH arg order, leaked HID listener on reconnect, no auto- retry after coexistence clears, Amiibo content not read.
1 parent 3a0392a commit 85fbf38

4 files changed

Lines changed: 222 additions & 46 deletions

File tree

src/components/wizard/toypad-scanner.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,12 @@ function PadCard({ padId, state }: { padId: PadId; state: PadState }) {
8888
)}
8989

9090
{state.phase === "error" && (
91-
<p className="py-4 text-sm text-destructive">{state.error}</p>
91+
<div className="flex flex-col gap-2 py-4">
92+
<p className="text-sm text-destructive">{state.error}</p>
93+
{state.uid && (
94+
<InfoLine label="UID" value={formatUid(state.uid)} mono />
95+
)}
96+
</div>
9297
)}
9398

9499
{state.phase === "done" && state.result && (
@@ -145,6 +150,10 @@ function PadResultCard({
145150
);
146151
}
147152

153+
function formatUid(hex: string): string {
154+
return hex.match(/.{2}/g)?.join(":") ?? hex;
155+
}
156+
148157
function InfoLine({
149158
label,
150159
value,

src/hooks/use-toypad-scanner.ts

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { useState, useEffect, useCallback, useMemo } from "react";
22
import type { DeviceDriver, OutputFile, VerificationHashes } from "@/lib/types";
33
import type { ToyPadDriver } from "@/lib/drivers/toypad/toypad-driver";
4-
import type { PadId, TagEvent } from "@/lib/drivers/toypad/toypad-commands";
4+
import type {
5+
PadId,
6+
TagEvent,
7+
TagKind,
8+
} from "@/lib/drivers/toypad/toypad-commands";
59
import {
610
PAD_CENTER,
711
PAD_LEFT,
@@ -75,19 +79,42 @@ export function useToyPadScanner(
7579
const readingPads = new Set<PadId>();
7680

7781
const handleTag = async (event: TagEvent) => {
78-
const { pad, action, uid, index } = event;
82+
const { pad, action, uid, index, kind } = event;
7983
const uidHex = toHex(uid);
8084

8185
if (action === "removed") {
8286
failedUids.delete(uidHex);
8387
readingPads.delete(pad);
8488
updatePad(pad, EMPTY_PAD);
8589
tpDriver.setLed(pad, ...LED_IDLE).catch(() => {});
90+
// Removing a non-NTAG tag often unblocks NTAG reads that were failing
91+
// because the portal's NFC controller can't juggle both families at
92+
// once — clear failedUids so the user doesn't have to re-lift every
93+
// Lego figure that happened to collide with a Disney / Skylanders tag.
94+
//
95+
// TODO: auto-retry pads currently in error state here. Right now
96+
// clearing `failedUids` is necessary-but-not-sufficient — the UI
97+
// still shows the old error until the user physically lifts and
98+
// re-places each affected tag to trigger a fresh "placed" event.
99+
if (kind !== "ntag") failedUids.clear();
86100
return;
87101
}
88102

89103
if (failedUids.has(uidHex) || readingPads.has(pad)) return;
90104

105+
// Only NTAG tags are readable through the Toy Pad. For anything else
106+
// the portal detected (Disney Infinity MIFARE, Skylanders, hotel keys)
107+
// the READ command either errors or returns stale data from a prior
108+
// tag — so short-circuit and show what the portal already told us.
109+
if (kind !== "ntag") {
110+
const msg = unreadableMessage(kind);
111+
log(msg, "warn");
112+
failedUids.add(uidHex);
113+
updatePad(pad, { phase: "error", uid: uidHex, error: msg });
114+
tpDriver.setLed(pad, ...LED_ERROR).catch(() => {});
115+
return;
116+
}
117+
91118
readingPads.add(pad);
92119
updatePad(pad, {
93120
phase: "reading",
@@ -105,6 +132,30 @@ export function useToyPadScanner(
105132
throw new Error("Could not read any data from this tag.");
106133
}
107134

135+
// The Toy Pad's READ command doesn't reliably honor the tagIndex arg
136+
// when multiple tags are on the portal — it silently returns whichever
137+
// tag the NFC controller is currently locked onto. Detect that by
138+
// confirming the page-0 UID matches the anti-collision UID we got in
139+
// the tag event, and fail loudly if they diverge.
140+
//
141+
// TODO: find a portal-level "select tag N" command so multi-tag reads
142+
// actually work. Until then, users can only reliably read one tag
143+
// at a time even though the portal tracks multiple indices.
144+
const page0Uid = new Uint8Array([
145+
rawData[0],
146+
rawData[1],
147+
rawData[2],
148+
rawData[4],
149+
rawData[5],
150+
rawData[6],
151+
rawData[7],
152+
]);
153+
if (!uidsEqual(page0Uid, uid)) {
154+
throw new Error(
155+
"Another tag on the portal is blocking this read — try removing other tags.",
156+
);
157+
}
158+
108159
const isPartial = rawData.length < NTAG213_SIZE;
109160
const parsed = parseLegoDimensionsData(rawData);
110161
const config = ldSystem.buildReadConfig({ uid, padIndex: index });
@@ -154,3 +205,20 @@ function toHex(bytes: Uint8Array): string {
154205
.join("")
155206
.toUpperCase();
156207
}
208+
209+
function uidsEqual(a: Uint8Array, b: Uint8Array): boolean {
210+
return a.length === b.length && a.every((x, i) => x === b[i]);
211+
}
212+
213+
function unreadableMessage(kind: TagKind): string {
214+
switch (kind) {
215+
case "mifare-disney":
216+
return "Disney Infinity figure — use the Disney Infinity Base to read.";
217+
case "mifare-foreign":
218+
return "Unsupported MIFARE Classic tag (Skylanders or similar). The Toy Pad can't read MIFARE block data.";
219+
case "mifare-4byte":
220+
return "Unsupported 4-byte MIFARE Classic tag (e.g. hotel key). The Toy Pad can't read MIFARE block data.";
221+
default:
222+
return "Unsupported tag type.";
223+
}
224+
}

src/lib/drivers/toypad/toypad-commands.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,46 @@ export const PAD_NAMES: Record<PadId, string> = {
3737
3: "Right",
3838
};
3939

40+
/**
41+
* Portal's own classification of a detected tag (byte 3 of the 0x56 event).
42+
* Same scheme as the Disney Infinity Base portal — both use the PDP NFC
43+
* front-end. Only `ntag` tags are readable through the Toy Pad's command
44+
* surface; the firmware won't authenticate MIFARE Classic tags, so all
45+
* other kinds are informational only (UID via the event, no block data).
46+
*/
47+
export type TagKind =
48+
| "ntag" // 0x00: NTAG21x / NDEF tag (Lego Dimensions figures)
49+
| "mifare-foreign" // 0x01: MIFARE Classic with 7-byte UID (Skylanders etc.)
50+
| "mifare-4byte" // 0x08: MIFARE Classic with 4-byte UID (hotel keys etc.)
51+
| "mifare-disney" // 0x09: MIFARE Classic authenticated as Disney Infinity
52+
| "unknown";
53+
54+
export const MARKER_NTAG = 0x00;
55+
export const MARKER_MIFARE_FOREIGN = 0x01;
56+
export const MARKER_MIFARE_4BYTE = 0x08;
57+
export const MARKER_MIFARE_DISNEY = 0x09;
58+
59+
export function kindFromMarker(marker: number): TagKind {
60+
switch (marker) {
61+
case MARKER_NTAG:
62+
return "ntag";
63+
case MARKER_MIFARE_FOREIGN:
64+
return "mifare-foreign";
65+
case MARKER_MIFARE_4BYTE:
66+
return "mifare-4byte";
67+
case MARKER_MIFARE_DISNEY:
68+
return "mifare-disney";
69+
default:
70+
return "unknown";
71+
}
72+
}
73+
4074
export interface TagEvent {
4175
pad: PadId;
4276
uid: Uint8Array;
4377
action: "placed" | "removed";
4478
index: number;
79+
kind: TagKind;
4580
}
4681

4782
// "(c) LEGO 2014" init payload — the portal requires this exact sequence to activate

0 commit comments

Comments
 (0)