Skip to content

JavaScript library for interacting with a Panic Playdate console over USB, wherever WebSerial is supported.

License

Notifications You must be signed in to change notification settings

cranksters/pd-usb

Repository files navigation

pd-usb

JavaScript library for interacting with a Panic Playdate console over USB, wherever WebSerial is supported.

⚠️ This library is unofficial and is not affiliated with Panic. Details on the USB protocol were gleaned from reverse-engineering and packet sniffing. Things may be incorrect!

Features

  • Get Playdate device stats such as its version info, serial, cpu stats, etc
  • Grab a screenshot from the Playdate and draw it to a HTML5 canvas, or send an image to be previewed on the device
  • Read the button and crank input state
  • Execute secret commands!
  • Send compiled Lua payloads over USB!
  • Extensive error handling with helpful error messages
  • Exports full Typescript types, has zero dependencies, and weighs less than 5kb minified and gzipped

Examples

Installation

With NPM

npm install pd-usb --save

Then assuming you're using a module-compatible system (like Webpack, Rollup, etc):

import { requestConnectPlaydate } from 'pd-usb';

async function connectToPlaydate() {
  const playdate = await requestConnectPlaydate();
}

Directly in a browser

Using the module directly via Unpkg:

<script type="module">
  import { requestConnectPlaydate } from 'https://unpkg.com/pd-usb?module';

  async function connectToPlaydate() {
    const playdate = await requestConnectPlaydate();
  }
</script>

Using an external script reference

<script src="https://unpkg.com/pd-usb/dist/pd-usb.min.js"></script>
<script>
  async function connectToPlaydate() {
    const playdate = await pdusb.requestConnectPlaydate();
  }
</script>

When using the library this way, a global called pdusb will be created containing all the exports from the module version.

Usage

Preamble

WebSerial is asynchronous by nature, so this library uses async/await a lot. If you're not already familiar with that, now would be a good time to catch up!

Detecting WebSerial support

WebSerial is also only supported in Secure Contexts, and only in certain browsers (currently Google Chrome, Microsoft Edge, and Opera).

You can use the isUsbSupported() method to check if the current environment supports WebSerial:

import { isUsbSupported } from 'pd-usb';

if (!isUsbSupported) {
  alert('Sorry, your browser does not support USB, and cannot connect to a Playdate :(')
}

Connecting to a Playdate

Next we want to actually connect to a Playdate. Calling requestConnectPlaydate() will prompt the user to select a Playdate device from a menu, and returns a Promise that will resolve a PlaydateDevice object if the connection was successful, or reject if a connection could not be made.

For security reasons, requestConnectPlaydate() can only be called with a user interaction, such as a click. It's recommended to create a "Connect to Playdate" button somewhere on your page:

<button id="connectButton">Connect to Playdate</button>

Then call requestConnectPlaydate() in the button's click event callback function:

import { requestConnectPlaydate } from 'pd-usb';

const button = document.getElementById('connectButton');

button.addEventListener('click', async() => {
  try {
    const device = await requestConnectPlaydate();
    // do something with device here...
  }
  catch (e) {
    alert('Could not connect to Playdate, lock and unlock the device and try again.');
  }
});

Open and close a PlaydateDevice

Before interacting with the device, you need to make sure that it is open for communication. This can be done by calling PlaydateDevice's asynchronous open method, which returns a promise that resolves when the device is ready, or throws an error if a connection could not be opened.

After you are done, it's a good idea to end by calling the close method to stop the connection. This function is also asynchronous and returns a Promise that resolves when the device has been closed successfully. Note that browsers seen to handle closing the device when you leave or refresh a page, so it's not the end of the world if you forget this.

await device.open();

// interact with the device here...

await device.close();

PlaydateDevice events

A PlaydateDevice instance will fire events when certain things happen, allowing you to write code to handle things such as the device being disconnected.

You can add event listeners with the PlaydateDevice's on method, and remove then with the off method.

function handleDisconnect() {
  alert('Oh no, the Playdate has been disconnected! Please plug it back in!')
}

// add an event handler
// the handleDisconnect function will be called whenever the disconnect event fires
device.on('disconnect', handleDisconnect);

// remove an event handler
device.off('disconnect', handleDisconnect);

The following events are available:

Event Details
open The device has been opened
close The device has been closed
disconnect The device has been physically disconnected
controls:start Control-polling mode has been started
controls:update A new control state has been received while control-polling mode is active
controls:stop Control-polling mode has been stopped

PlaydateDevice general API

These methods are asynchronous and will resolve when a response has been received from the Playdate, so you need to remember to use async/await.

getVersion

Returns an object containing version information about the Playdate, such as its OS build info, SDK version, serial number, etc.

const version = await device.getVersion();

getSerial

Returns the Playdate's serial number as a string, useful for if you need the user to be able to identify the connected device.

const serial = await device.getSerial();

getScreen

Capture a screenshot from the Playdate, and get the raw framebuffer. This will return the 1-bit framebuffer data as Uint8Array of bytes, where each bit in the byte will represent 1 pixel; 0 for black, 1 for white. The framebuffer is 400 x 240 pixels

const screenBuffer = await device.getScreen();

getScreenIndexed

Capture a screenshot from the Playdate, and get the unpacked framebuffer. This will return an 8-bit indexed framebuffer as an Uint8Array. Each element of the array will represent a single pixel; 0x0 for black, 0x1 for white. The framebuffer is 400 x 240 pixels.

const screenPixels = await device.getScreenIndexed();

If you want to draw the screen to a HTML5 canvas, check out the screen example.

sendBitmap

Send a 1-bit bitmap buffer to display on the Playdate's screen. The input bitmap must be an Uint8Array of bytes, where each bit in the byte will represent 1 pixel; 0 for black, 1 for white. The input bitmap must also contain 400 x 240 pixels.

const screenBuffer = new Uint8Array(12000);

// put some pixels into screenBuffer here

await device.sendBitmap(screenBuffer);

sendBitmapIndexed

Send a indexed bitmap to display on the Playdate's screen. The input bitmap must be an Uint8Array of bytes, each byte in the array will represent 1 pixel; 0x0 for black, 0x1 for white. The input bitmap must also contain 400 x 240 pixels.

const pixels = new Uint8Array(400 * 240);

// put some pixels into the pixels array here

await device.sendBitmapIndexed(pixels);

If you want to creating a bitmap using a HTML5 canvas, check out the bitmap example.

run

Launch a .pdx file at a given path on the Playdate's data disk. The path must begin with a forward slash, and the device may crash if the selected file does not exist.

await device.run('/System/Crayons.pdx');

sendCommand

Sends a plaintext command directly to the Playdate, and returns the response as an array of strings for each line. You can use await sendCommand('help') to get a list of all available commands.

⚠️ The commands that this library wraps with functions (such as getVersion() or getScreen()) are known to be safe, are used by the Playdate Simulator, and have all been tested on actual Playdate hardware. However, some of the commands that you can potentially run with sendCommand() could be dangerous, and might even harm your favorite yellow handheld if you don't know what you're doing. Please don't execute any commands that you're unsure about!

evalLuaPayload

Sends a compiled Lua function to the device to be evaluated. The payload must be a Playdate-compatible Lua function compiled with pdc from the Playdate SDK. It will return anything printed to the device's console.

⚠️ This is pretty hardcore, you're probably not going to find this useable unless you really know what you're doing.

const payloadData = new Uint8Array(... put your payload data here);
await device.evalLuaPayload(payloadData);

Reading Playdate controls

The Playdate can be put into a control polling mode, where it will send the current state of the buttons and crank at regular intervals. While control polling is active, you won't be able to communicate with the device.

Control polling can be started with startPollingControls() and stopped with stopPollingControls(). While polling is active, the controls:update event will be fired whenever a new control state is received, which seems to be every time the Playdate's update loop is run.

// add a controls:update event handler
device.on('controls:update', function(state) {
  // handle control updates here...
  if (state.buttonDown.b) {
    console.log('B button is pressed!');
  }
});

// start polling controls
await device.startPollingControls();

// sometime later...
await device.stopPollingControls();

Please note that disconnecting your Playdate while control polling is active can sometimes cause future USB connections to goof up for a while. This library has code that tries to fix this by clearing the input buffer, but it doesn't seem to be perfect. If this happens, try locking and unlocking your device a few times!

Getting the control state without an event

You can also query the Playdate's control state at your own pace, without using events. Note that these methods will only work while control polling is active.

getControls()

Returns the current control state as an object containing button states and crank angle/dock state.

const state = device.getControls();
console.log('B button:', state.pressed.b);
console.log('Crank angle:', state.crank);

buttonIsPressed(button), buttonJustPressed(button), buttonJustReleased(button)

Equivalents to the Playdate SDK's button query methods.

button should be one of the constants:

  • pbusb.kButtonA
  • pbusb.kButtonB
  • pbusb.kButtonUp
  • pbusb.kButtonDown
  • pbusb.kButtonLeft
  • pbusb.kButtonRight
  • pbusb.kButtonMenu
  • pbusb.kButtonLock

Or one of the strings "a", "b", "up", "down", "left", "right", "menu", "lock".

isCrankDocked(), getCrankPosition()

Equivalents to the Playdate SDK's crank query methods.

Contributing

Contributions and ports to other languages are welcome! Here's a list of things I'd like to do, but haven't found the time yet:

  • Node support
  • Figure out how Playdate streaming works
  • Stack traces, memory stats, CPU stats, etc
  • Port to another language to build a general Playdate USB CLI tool?

USB Docs

If you're looking for reference, I've documented the Playdate's USB protocol and some of the more interesting commands over on my playdate-reverse-engineering repo.

Setup

To build the project, you'll need to have Node and NPM installed. Clone the repo to your local machine, then run npm install in the project's root directory to grab dependencies. After that you can run npm start to begin a dev server on your machine's localhost and point it to the examples directory. You can run npm run build to build the production-ready files for distribution.

Special Thanks


2021 James Daniel

If you have any questions or just want to say hi, you can reach me on Twitter (@rakujira), on Discord (@jaames#9860), or via email (mail at jamesdaniel dot dev).

Playdate is © Panic Inc. This project isn't affiliated with or endorsed by them in any way