Skip to content

Add Skylanders Portal of Power support#20

Draft
pathawks wants to merge 1 commit into
mainfrom
skylanders
Draft

Add Skylanders Portal of Power support#20
pathawks wants to merge 1 commit into
mainfrom
skylanders

Conversation

@pathawks

@pathawks pathawks commented May 31, 2026

Copy link
Copy Markdown
Owner

Adds support for the Skylanders "Portal of Power" — the USB NFC base — so nabu can read a figure placed on the portal and back it up as a raw tag dump. It follows the same WebHID, scanner-style flow as the existing Disney Infinity base: connect, place a figure, it's read automatically, and you save a .bin.

How it works

The portal is a fixed 32-byte HID device. The driver activates the base, watches its ~10 Hz status stream to learn which slots are occupied, and reads each present figure block-by-block (64 × 16 B = 1024 B) through the portal's QUERY_BLOCK command.

The host needs no MIFARE keys and performs no decryption. The portal firmware does the MIFARE Classic authentication itself, so the driver simply requests blocks and writes back exactly what the licensed reader returns. A figure's encrypted application blocks are saved verbatim; only the plaintext identity (NUID, figure id, variant id) is parsed — for the on-screen row and the dump filename. No character/name database is bundled.

Platform support

Works in Chrome on macOS and Windows. Linux is not supported: the portal only accepts host writes via the SET_REPORT Output control request, which Chrome on Linux can't issue — it routes WebHID writes through hidraw to the firmware's broken interrupt-OUT endpoint, and no udev rule changes that. The device description states the limitation so Linux users aren't surprised by a connect failure.

Note

The macOS/Windows path hasn't yet been exercised end-to-end in a browser. That's the remaining validation step before this leaves draft.

Files

  • src/lib/drivers/portal-of-power/portal-commands.ts (wire format, opcode tables, status bitmap decode/diff), portal-driver.ts (PortalOfPowerDriver), portal-figure-file.ts (identity parse + filename), plus unit tests.
  • src/components/wizard/portal-scanner.tsx — the scanner UI (per-slot read state, LED feedback, save).
  • Wiring only in devices.ts, connection-registry.ts, and App.tsx; use-connection.ts is untouched since it's already data-driven over the device registry.

Licensing

No third-party code or data is vendored. The protocol is referenced from Dolphin's Skylander.cpp (GPL-2.0-or-later, compatible with this project's GPL-3.0) in a header comment only — the same way the Disney Infinity driver references Dolphin — so THIRD-PARTY-LICENSES is unchanged.

Testing

  • npm run lint, tsc -b, and vitest pass (176 tests, including new coverage for the status bitmap decode/diff and the figure identity parse).
  • Hardware: pending the macOS/Windows Chrome run noted above.

Read NFC figures placed on the Skylanders portal over WebHID and back each one up as its raw 1024-byte tag dump, following the same scanner-style pattern as the Disney Infinity base.

The portal firmware performs the MIFARE Classic authentication itself, so the driver only issues per-block QUERY reads — no keys, no decryption. Encrypted application blocks are saved verbatim; only the plaintext identity (NUID, figure id, variant id) is parsed, for display and the dump filename. No character database ships.

Works in Chrome on macOS and Windows. Linux is unsupported: the portal only accepts host writes via SET_REPORT Output, which Chrome on Linux can't issue; the device description notes this.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds first-class support for the Skylanders “Portal of Power” WebHID device, enabling nabu to auto-detect figure placement, read full 1K MIFARE dumps via the portal’s block query command, and save raw .bin tag dumps through a dedicated scanner-style UI (similar to the existing Disney Infinity flow).

Changes:

  • Introduces a new Portal of Power HID protocol layer + driver with status-delta slot events and full figure block dumping.
  • Adds a new PortalScanner wizard UI for per-slot read state, LED feedback, and .bin saving.
  • Wires the new device into the device registry, connection registry, and App scanner routing.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 15 comments.

Show a summary per file
File Description
src/lib/drivers/portal-of-power/portal-figure-file.ts Parses plaintext identity fields and builds dump filenames.
src/lib/drivers/portal-of-power/portal-figure-file.test.ts Unit tests for identity parsing, dump-size detection, and filename format.
src/lib/drivers/portal-of-power/portal-driver.ts Implements the Portal of Power WebHID driver, command serialization, status handling, and block/figure reads.
src/lib/drivers/portal-of-power/portal-commands.ts Defines opcodes/constants and status bitmap decode + diff logic.
src/lib/drivers/portal-of-power/portal-commands.test.ts Unit tests for status decoding/diffing and slot index encoding.
src/lib/core/devices.ts Adds the Portal of Power to the canonical supported device list (with Linux limitation note).
src/lib/core/connection-registry.ts Registers the new transport filters and driver constructor for connection flow.
src/components/wizard/portal-scanner.tsx Adds the scanner UI for reading figures from slots and saving .bin dumps with LED state feedback.
src/App.tsx Routes connected “skylanders” scanner devices to PortalScanner and excludes them from generic auto-detect flow.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

// null prior yields an `added` for every currently-occupied slot.
const status = driver.currentStatus;
if (status) for (const ev of diffStatus(null, status)) handleEvent(ev);
void setLed(LED_OFF);
Comment on lines +67 to +68
const readGen = new Map<number, number>();
let lastLed: Rgb | null = null;

const readSlot = async (slot: number, gen: number) => {
try {
const data = await driver.readFigure(slot);
Comment on lines +130 to +132
return () => {
driver.onTagEvent(null);
};
Comment on lines +217 to +222
if (resp.length < 3 + BLOCK_SIZE) {
throw new Error(
`Portal returned a short block (${resp.length} bytes) for slot ${slot}, block ${block}`,
);
}
return resp.slice(3, 3 + BLOCK_SIZE);
// null prior yields an `added` for every currently-occupied slot.
const status = driver.currentStatus;
if (status) for (const ev of diffStatus(null, status)) handleEvent(ev);
void setLed(LED_OFF);
Comment on lines +67 to +68
const readGen = new Map<number, number>();
let lastLed: Rgb | null = null;

const readSlot = async (slot: number, gen: number) => {
try {
const data = await driver.readFigure(slot);
Comment on lines +130 to +132
return () => {
driver.onTagEvent(null);
};
Comment on lines +217 to +222
if (resp.length < 3 + BLOCK_SIZE) {
throw new Error(
`Portal returned a short block (${resp.length} bytes) for slot ${slot}, block ${block}`,
);
}
return resp.slice(3, 3 + BLOCK_SIZE);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants