UNPKG

webmonome

Version:

communicate with devices beyond a translator

482 lines (390 loc) 12.1 kB
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 };