webmonome
Version:
communicate with devices beyond a translator
482 lines (390 loc) • 12.1 kB
JavaScript
const MSG_PREFIX = 'WebMonome: ';
const ERR_NOT_SUPPORTED = 'device type not yet supported';
const ERR_WRITE = 'write error';
const ERR_WRITE_MISSING_BYTES = 'write is missing bytes';
const WARN_NO_USB = 'this browser does not support WebUsb.';
const USB_XFER_STATUS_OK = 'ok';
function clamp(val, min, max) {
return Math.max(Math.min(val, max), min);
}
function packLineData(state) {
let data = 0;
for (let i = 0; i < Math.min(8, state.length); i++) {
data = data | clamp(state[i], 0, 1) << i;
}
return data;
}
function err(msg) {
throw new Error(MSG_PREFIX + msg);
}
function log(msg, level = 0) {
console[['log', 'warn', 'error'][level]](MSG_PREFIX + msg);
}
class Monome extends EventTarget {
constructor(device) {
super();
this.device = device;
this.callbacks = {}; // set i/o based on chosen interface endpoints
this.endpointIn = getEndpoint(device, 'in');
this.endpointOut = getEndpoint(device, 'out');
}
async listen() {
if (!this.isConnected) return;
const result = await this.read(64); // TODO: did i have a reason for skipping the first two bytes here?
// seems older mext send noisey bytes that i was skipping. 2021 devices
// run "silent" and skipping bytes breaks everything
// not skipping seems to still work fine for older mext
if (result.status === 'ok' && result.data.byteLength > 0) {
this.processData(result.data, 0);
}
await this.listen();
}
async read(byteLength) {
if (!this.isConnected) return;
return this.device.transferIn(this.endpointIn, byteLength);
}
async write(data) {
const buffer = new Uint8Array(data);
const result = await this.device.transferOut(this.endpointOut, buffer);
if (result.status !== USB_XFER_STATUS_OK) err(ERR_WRITE);
if (result.bytesWritten !== buffer.byteLength) err(ERR_WRITE_MISSING_BYTES);
return result;
}
get isConnected() {
return Boolean(this.device && this.device.opened);
}
emit(eventName, payload) {
const event = new CustomEvent(eventName, {
detail: payload
});
this.dispatchEvent(event);
return event;
}
}
const getEndpointsForDevice = device => {
return device.configuration.interfaces[getInterfaceForVendor(device.vendorId)].alternates[0].endpoints;
};
const getEndpoint = (device, dir) => {
const defaults = {
in: 1,
out: 2
};
let endpoint;
try {
const endpoints = getEndpointsForDevice(device);
endpoint = endpoints.find(({
direction
}) => direction === dir);
} catch (e) {
log(e, 2);
}
return endpoint ? endpoint.endpointNumber : defaults[dir];
};
/* sections */
const ADDR_SYSTEM = 0x0;
const ADDR_LED_GRID = 0x1;
const ADDR_KEY_GRID = 0x2;
/* sys out */
const SYS_QUERY = 0x0;
const SYS_GET_ID = 0x1;
const SYS_GET_GRID_SIZES = 0x5;
/* sys in */
const SYS_QUERY_RESPONSE = 0x0;
const SYS_ID = 0x1;
const SYS_GRID_SIZE = 0x3;
/* grid out */
const CMD_LED_OFF = 0x0;
const CMD_LED_ON = 0x1;
const CMD_LED_ALL_OFF = 0x2;
const CMD_LED_ALL_ON = 0x3;
const CMD_LED_MAP = 0x4;
const CMD_LED_ROW = 0x5;
const CMD_LED_COLUMN = 0x6;
const CMD_LED_INTENSITY = 0x7;
const CMD_LED_LEVEL_SET = 0x8;
const CMD_LED_LEVEL_ALL = 0x9;
const CMD_LED_LEVEL_MAP = 0xa;
const CMD_LED_LEVEL_ROW = 0xb;
const CMD_LED_LEVEL_COLUMN = 0xc;
/* grid in */
const CMD_KEY_UP = 0x0;
const CMD_KEY_DOWN = 0x1;
const packHeader = (addr, cmd) => (addr & 0xf) << 4 | cmd & 0xf;
const packBuffer = ([addr, cmd, ...data]) => [packHeader(addr, cmd), ...(Array.isArray(data) ? data : [])];
const packIntensityData = (state, length) => {
const data = [];
for (let i = 0; i < Math.ceil(length / 2); i++) {
data[i] = 0;
}
for (let i = 0; i < Math.min(length, state.length); i++) {
const byteIndex = Math.floor(i / 2);
const nybbleIndex = i % 2;
const nybbleOffset = nybbleIndex === 0 ? 4 : 0;
if (typeof data[byteIndex] !== 'number') {
data[byteIndex] = 0;
}
data[byteIndex] = data[byteIndex] | clamp(state[i], 0, 15) << nybbleOffset;
}
return data;
};
class Mext extends Monome {
constructor(device) {
super(device);
}
processData(data, start) {
const header = data.getUint8(start++);
switch (header) {
case packHeader(ADDR_SYSTEM, SYS_QUERY_RESPONSE):
this.emit('query', {
type: data.getUint8(start++),
count: data.getUint8(start++)
});
break;
case packHeader(ADDR_SYSTEM, SYS_ID):
let str = '';
for (let i = 0; i < 32; i++) {
str += String.fromCharCode(data.getUint8(start++));
}
this.emit('getId', str);
break;
case packHeader(ADDR_SYSTEM, SYS_GRID_SIZE):
this.emit('getGridSize', {
x: data.getUint8(start++),
y: data.getUint8(start++)
});
break;
case packHeader(ADDR_KEY_GRID, CMD_KEY_DOWN):
this.emit('gridKeyDown', {
x: data.getUint8(start++),
y: data.getUint8(start++)
});
break;
case packHeader(ADDR_KEY_GRID, CMD_KEY_UP):
this.emit('gridKeyUp', {
x: data.getUint8(start++),
y: data.getUint8(start++)
});
break;
}
if (data.byteLength > start + 1) {
this.processData(data, start);
}
}
query() {
return this.writeBuffer(ADDR_SYSTEM, SYS_QUERY);
}
getId() {
return this.writeBuffer(ADDR_SYSTEM, SYS_GET_ID);
}
getGridSize() {
return this.writeBuffer(ADDR_SYSTEM, SYS_GET_GRID_SIZES);
}
gridLed(x, y, on) {
return this.writeBuffer(ADDR_LED_GRID, on ? CMD_LED_ON : CMD_LED_OFF, x, y);
}
gridLedAll(on) {
return this.writeBuffer(ADDR_LED_GRID, on ? CMD_LED_ALL_ON : CMD_LED_ALL_OFF);
}
gridLedCol(x, y, state) {
if (!Array.isArray(state)) return;
return this.writeBuffer(ADDR_LED_GRID, CMD_LED_COLUMN, x, y, packLineData(state));
}
gridLedRow(x, y, state) {
if (!Array.isArray(state)) return;
return this.writeBuffer(ADDR_LED_GRID, CMD_LED_ROW, x, y, packLineData(state));
}
gridLedMap(x, y, state) {
if (!Array.isArray(state)) return;
const data = [0, 0, 0, 0, 0, 0, 0, 0];
for (let i = 0; i < Math.min(64, state.length); i++) {
const byteIndex = Math.floor(i / 8);
const bitIndex = i % 8;
data[byteIndex] = data[byteIndex] | clamp(state[i], 0, 1) << bitIndex;
}
return this.writeBuffer(ADDR_LED_GRID, CMD_LED_MAP, x, y, ...data);
}
gridLedIntensity(intensity) {
return this.writeBuffer(ADDR_LED_GRID, CMD_LED_INTENSITY, clamp(intensity, 0, 15));
}
gridLedLevel(x, y, level) {
return this.writeBuffer(ADDR_LED_GRID, CMD_LED_LEVEL_SET, x, y, clamp(level, 0, 15));
}
gridLedLevelAll(level) {
return this.writeBuffer(ADDR_LED_GRID, CMD_LED_LEVEL_ALL, clamp(level, 0, 15));
}
gridLedLevelCol(x, y, state) {
if (!Array.isArray(state)) return;
return this.writeBuffer(ADDR_LED_GRID, CMD_LED_LEVEL_COLUMN, x, y, ...packIntensityData(state, 8));
}
gridLedLevelRow(x, y, state) {
if (!Array.isArray(state)) return;
return this.writeBuffer(ADDR_LED_GRID, CMD_LED_LEVEL_ROW, x, y, ...packIntensityData(state, 8));
}
gridLedLevelMap(x, y, state) {
if (!Array.isArray(state)) return;
return this.writeBuffer(ADDR_LED_GRID, CMD_LED_LEVEL_MAP, x, y, ...packIntensityData(state, 64));
}
writeBuffer() {
return this.write(packBuffer(Array.from(arguments)));
}
}
/* input from series device */
const PROTO_SERIES_BUTTON_DOWN = 0x00;
const PROTO_SERIES_BUTTON_UP = 0x10;
/* output from series device */
const PROTO_SERIES_LED_ON = 0x20;
const PROTO_SERIES_LED_OFF = 0x30;
const PROTO_SERIES_LED_ROW_8 = 0x40;
const PROTO_SERIES_LED_COL_8 = 0x50;
const PROTO_SERIES_LED_ROW_16 = 0x60;
const PROTO_SERIES_LED_COL_16 = 0x70;
const PROTO_SERIES_CLEAR = 0x90;
const PROTO_SERIES_INTENSITY = 0xa0;
class Series extends Monome {
constructor(device) {
super(device);
}
processData(data, start) {
const header = data.getUint8(start++);
let x;
let y;
let datum;
switch (header) {
case PROTO_SERIES_BUTTON_DOWN:
datum = data.getUint8(start++);
[x, y] = [datum >> 4, datum & 0xf];
this.emit('gridKeyDown', {
x,
y
});
break;
case PROTO_SERIES_BUTTON_UP:
datum = data.getUint8(start++);
[x, y] = [datum >> 4, datum & 0xf];
this.emit('gridKeyUp', {
x,
y
});
break;
}
if (data.byteLength > start + 1) {
this.processData(data, start);
}
}
query() {}
getId() {}
getGridSize() {
const sizes = {
64: {
x: 8,
y: 8
},
128: {
x: 16,
y: 8
},
256: {
x: 16,
y: 16
}
};
const [_, size] = this.device.serialNumber.match(/^m(64|128|256)/);
this.emit('getGridSize', sizes[size]);
}
gridLed(x, y, on) {
return this.write([on ? PROTO_SERIES_LED_ON : PROTO_SERIES_LED_OFF, x << 4 | y]);
}
gridLedAll(on) {
return this.write([PROTO_SERIES_CLEAR | on & 0x01]);
}
gridLedCol(x, y, state) {
if (!Array.isArray(state)) return;
/*
y offset is seemingly ignored in the serial protocol?
see: https://github.com/monome/libmonome/blob/cd11b2fde61b7ecd1c171cf9f8568918b0199df9/src/proto/series.c#L184
*/
const mode = state.length === 8 ? PROTO_SERIES_LED_COL_8 : PROTO_SERIES_LED_COL_16;
return this.write([mode | x & 0x0f, packLineData(state)]);
}
gridLedRow(x, y, state) {
if (!Array.isArray(state)) return;
/*
x offset is seemingly ignored in the serial protocol?
see: https://github.com/monome/libmonome/blob/cd11b2fde61b7ecd1c171cf9f8568918b0199df9/src/proto/series.c#L209
*/
const mode = state.length === 8 ? PROTO_SERIES_LED_ROW_8 : PROTO_SERIES_LED_ROW_16;
return this.write([mode | y & 0x0f, packLineData(state)]);
}
gridLedMap(x, y, state) {}
gridLedIntensity(intensity) {
return this.write([PROTO_SERIES_INTENSITY | clamp(intensity, 0, 15) & 0x0f]);
}
gridLedLevel(x, y, level) {
return this.gridLed(x, y, level > 7);
}
gridLedLevelAll(level) {
return this.gridLedIntensity(level);
}
gridLedLevelCol(x, y, state) {
return this.gridLedCol(x, y, state.map(level => level > 7));
}
gridLedLevelRow(x, y, state) {
return this.gridLedRow(x, y, state.map(level => level > 7));
}
gridLedLevelMap(x, y, state) {}
}
/* monome usb vendor id */
const VENDOR_ID_GENESIS = 0x0403;
const VENDOR_ID_2021 = 0x0483; // based on personal observation, unsure if there is a
// a dynamic way to determine these (right now, i just
// know from exp that 2021 devices have to claim interface
// 1 instead of 0)
const interfaceMap = {
[VENDOR_ID_GENESIS]: 0,
[VENDOR_ID_2021]: 1
};
const getInterfaceForVendor = vendorId => interfaceMap[vendorId] || 0;
/* check for support */
const hasUsb = 'navigator' in window && 'usb' in navigator;
var webmonome = {
async connect() {
if (!hasUsb) return log(WARN_NO_USB, 1);
let device;
try {
device = await navigator.usb.requestDevice({
filters: [{
vendorId: VENDOR_ID_GENESIS
}, {
vendorId: VENDOR_ID_2021
}]
});
await device.open();
if (device.configuration === null) await device.selectConfiguration(1);
await device.claimInterface(getInterfaceForVendor(device.vendorId));
const monome = factory(device);
monome.listen();
return monome;
} catch (e) {
device = null;
throw e;
}
}
};
function factory(device) {
const Klass = {
mext: Mext,
series: Series
}[deviceType(device)];
return new Klass(device);
}
function deviceType(device) {
if (/^m(64|128|256)/.test(device.serialNumber) || /^mk/.test(device.serialNumber)) {
return 'series';
} else if (/^[Mm]\d+/.test(device.serialNumber)) {
return 'mext';
} else {
err(ERR_NOT_SUPPORTED);
}
}
export default webmonome;
export { getInterfaceForVendor };