pd-usb
JavaScript library for interacting with a Panic Playdate console over USB, wherever WebUSB 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 4kb minified and gzipped
Examples
⚠️ TODO: list 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 playdateUsb.requestConnectPlaydate();
}
</script>
When using the library this way, a global called playdateUsb
will be created containing all the exports from the module version.
Usage
Preamble
WebUSB 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 WebUSB support
WebUSB 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 WebUSB:
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();
}
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();
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!')
}
device.on('disconnect', handleDisconnect);
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 |
controls:stop | Control-polling mode has been stopped |
PlaydateDevice 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();
getConsoleOutput
Get any data that has been printed to the Playdate's console output (e.g. via print() in Lua, playdate->system->logToConsole() in C, etc) as a string.
const consoleOutput = await device.getConsoleOutput();
Alternatively, you can use getRawConsoleOutput()
to get the console output as an Uint8Array of bytes, if for example you have printed binary data to the console.
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);
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);
await device.sendBitmapIndexed(pixels);
If you want to creating a bitmap using a HTML5 canvas, check out the bitmap example.
run
Launch a .pdx rom 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 rom does not exist.
await device.run('/System/Crayons.pdx');
startPollingControls
Begin polling the device for control updates. Can be stopped with stopPollingControls()
. While control polling is active, you won't be able to communicate with the device. The controls:update
event will be fired whenever the control state changes.
device.startPollingControls();
device.on('controls:update', function(state) {
if (state.buttonDown.b) {
console.log('B button is pressed!');
}
});
await device.stopPollingControls();
This method will resolve only when control polling is stopped, so you probably don't want to call it with await
unless you're aware of that.
Please also 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!
getControls
Returns the current control state, while control polling is active. Note that this method is not asynchronous.
device.startPollingControls();
const state = device.getControls();
console.log('B button:', state.buttonDown.b);
console.log('Crank angle:', state.crank);
await device.stopPollingControls();
stopPollingControls
Stop polling for control updates, after startPollingControls() has been called. After this has completed, you'll be able to communicate with the device again.
await device.stopPollingControls();
sendCommand
Sends a plaintext command directly to the Playdate, and returns the response as a string. 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 luaU_dump()
from luac. 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);
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
- Playdate streaming support
- Stack traces, memory stats, CPU stats, etc
- The
eval
command seems really interesting, and it could be neat to look into dynamically building a compiled lua function that can send/receive data with the currently running game. - 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