UNPKG

tethr

Version:

Controlls USB-connected cameras, webcam, and smartphone camera from browser

350 lines 14.6 kB
import EventEmitter from 'eventemitter3'; import PQueue from 'p-queue'; import sleep from 'sleep-promise'; import { ResCode } from './PTPDatacode'; import { PTPDataView } from './PTPDataView'; import { toHexString } from './util'; var PTPBlockType; (function (PTPBlockType) { PTPBlockType[PTPBlockType["Command"] = 1] = "Command"; PTPBlockType[PTPBlockType["Data"] = 2] = "Data"; PTPBlockType[PTPBlockType["Response"] = 3] = "Response"; PTPBlockType[PTPBlockType["Event"] = 4] = "Event"; })(PTPBlockType || (PTPBlockType = {})); const PTPCommandMaxByteLength = 12 + 4 * 3; const PTPDefaultTimeoutMs = 10000; const PTPTryCount = 30; const PTPTryAgainIntervalMs = 100; const PTPQueueOptions = { timeout: PTPDefaultTimeoutMs, throwOnTimeout: true, }; export class PTPDevice extends EventEmitter { usb; #transactionId = 0x00000000; #endpointNumberBulkOut = 0x0; #endpointNumberBulkIn = 0x0; #endpointerNumberInterruptIn = 0x0; #opened = false; #queue = new PQueue({ concurrency: 1 }); #console = console; constructor(usb, { log = true } = {}) { super(); this.usb = usb; this.setLog(log); this.#queue.on('idle', () => this.emit('idle')); } open = async () => { await this.usb.open(); // Configurate let { configuration } = this.usb; if (!configuration) { const num = this.usb.configurations[0].configurationValue; await this.usb.selectConfiguration(num); configuration = this.usb.configuration; } if (!configuration) throw new Error('Cannot configure PTPDevice'); const usbInterface = configuration.interfaces[0]; const interfaceNum = usbInterface.interfaceNumber; await this.usb.reset(); await this.usb.releaseInterface(interfaceNum); await this.usb.claimInterface(interfaceNum); // Determine endpoints number const endpoints = usbInterface.alternates[0].endpoints; const endpointOut = endpoints.find(e => e.type === 'bulk' && e.direction === 'out'); const endpointIn = endpoints.find(e => e.type === 'bulk' && e.direction === 'in'); const endpointEvent = endpoints.find(e => e.type === 'interrupt' && e.direction === 'in'); if (!endpointOut || !endpointIn || !endpointEvent) throw new Error('Invalid endpoints'); this.#endpointNumberBulkOut = endpointOut.endpointNumber; this.#endpointNumberBulkIn = endpointIn.endpointNumber; this.#endpointerNumberInterruptIn = endpointEvent.endpointNumber; this.#listenInterruptIn(); this.#listenDisconnect(); this.#opened = true; }; close = async () => { this.#queue.clear(); if (this.usb && this.usb.opened) { await this.usb.close(); } this.#opened = false; }; get opened() { return this.#opened; } setLog(log) { this.#console = log ? console : { groupCollapsed: () => null, groupEnd: () => null, info: () => null, }; } onEventCode(eventCode, callback) { const eventName = toHexString(eventCode, 2); this.on(`ptpevent:${eventName}`, callback); } offEventCode(eventCode, callback) { const eventName = toHexString(eventCode, 2); this.off(`ptpevent:${eventName}`, callback); } sendCommand = (option) => { return this.#queue.add(async () => { this.#console.groupCollapsed(`Send Command [${option.label}]`); return this.#sendCommandNow(option).finally(this.#console.groupEnd); }, PTPQueueOptions); }; sendData = (option) => { return this.#queue.add(async () => { this.#console.groupCollapsed(`Send Data [${option.label}]`); return await this.#sendDataNow(option).finally(this.#console.groupEnd); }, PTPQueueOptions); }; receiveData = (option) => { return this.#queue.add(async () => { this.#console.groupCollapsed(`Receive Data [${option.label}]`); return await this.#receiveDataNow(option).finally(this.#console.groupEnd); }, PTPQueueOptions); }; async #sendCommandNow(option) { const { opcode, parameters, expectedResCodes } = { parameters: [], expectedResCodes: [ResCode.OK], ...option, }; for (let i = 0; i < PTPTryCount; i++) { const id = this.#generateTransactionId(); await this.#transferOutCommand(opcode, id, parameters); const res = await this.#waitBulkIn(id, PTPCommandMaxByteLength); // Error checking if (res.type !== PTPBlockType.Response) { throw new Error(`Expected response type: ${PTPBlockType.Response}, got: ${res.type}`); } // When the device is busy, try again const tryAgain = !expectedResCodes.includes(ResCode.DeviceBusy) && res.code === ResCode.DeviceBusy; if (tryAgain) { await sleep(PTPTryAgainIntervalMs); continue; } // Check rescode if (!expectedResCodes.includes(res.code)) { const expected = expectedResCodes.map(toHexString); const got = toHexString(res.code); throw new Error(`Expected rescode=[${expected}], got= ${got}`); } return { resCode: res.code, parameters: [...new Uint32Array(res.payload)], }; } throw new Error('Cannot send command'); } async #sendDataNow(option) { const { opcode, data, parameters, expectedResCodes } = { parameters: [], expectedResCodes: [ResCode.OK], ...option, }; for (let i = 0; i < PTPTryCount; i++) { const id = this.#generateTransactionId(); await this.#transferOutCommand(opcode, id, parameters); await this.#transferOutData(opcode, id, data); const res = await this.#waitBulkIn(id, PTPCommandMaxByteLength); // Error checking if (res.type !== PTPBlockType.Response) { throw new Error(`Expected response type: ${PTPBlockType.Response}, got: ${res.type}`); } // When the device is busy, try again const tryAgain = !expectedResCodes.includes(ResCode.DeviceBusy) && res.code === ResCode.DeviceBusy; if (tryAgain) { await sleep(PTPTryAgainIntervalMs); continue; } // Check rescode if (!expectedResCodes.includes(res.code)) { const expected = expectedResCodes.map(toHexString); const got = toHexString(res.code); throw new Error(`Expected rescode=[${expected}], got=${got}`); } return { resCode: res.code, parameters: [...new Uint32Array(res.payload)], }; } throw new Error('Cannot send data'); } async #receiveDataNow(option) { const { opcode, parameters, expectedResCodes, maxByteLength } = { parameters: [], expectedResCodes: [ResCode.OK], maxByteLength: 10_000_000, // = 10MB. Increased for media data transfer ...option, }; for (let i = 0; i < PTPTryCount; i++) { const id = this.#generateTransactionId(); await this.#transferOutCommand(opcode, id, parameters); const res1 = await this.#waitBulkIn(id, maxByteLength); if (res1.type === PTPBlockType.Response) { if (expectedResCodes.includes(res1.code)) { return { resCode: res1.code, parameters: [], data: new ArrayBuffer(0), }; } } if (res1.type !== PTPBlockType.Data) { throw new Error(`Cannot receive data code=${toHexString(res1.code)}`); } const res2 = await this.#waitBulkIn(id, PTPCommandMaxByteLength); if (res2.type !== PTPBlockType.Response) { throw new Error(`Expected response type: ${PTPBlockType.Response}, but got: ${res2.type}`); } // When the device is busy, try again const tryAgain = !expectedResCodes.includes(ResCode.DeviceBusy) && res2.code === ResCode.DeviceBusy; if (tryAgain) { await sleep(PTPTryAgainIntervalMs); continue; } return { resCode: res2.code, parameters: [...new Uint32Array(res2.payload)], data: res1.payload, }; } throw new Error('Cannot receive data'); } waitEvent = async (code) => { const eventName = toHexString(code, 2); return new Promise(resolve => { this.once(`ptpevent:${eventName}`, resolve); }); }; async #transferOutCommand(opcode, transactionId, parameters) { if (!this.usb) throw new Error('Device is not opened'); const length = 12 + parameters.length * 4; const dataView = new PTPDataView(); dataView.writeUint32(length); dataView.writeUint16(PTPBlockType.Command); dataView.writeUint16(opcode); dataView.writeUint32(transactionId); parameters.forEach(param => dataView.writeUint32(param)); const sent = await this.usb.transferOut(this.#endpointNumberBulkOut, dataView.toBuffer()); this.#console.info('transferOutBulk', 'type=Command', 'opcode=' + toHexString(opcode, 2), 'id=' + transactionId, 'params=' + parameters.map(v => toHexString(v, 4))); if (sent.status !== 'ok') { if (sent.status === 'stall') { await this.usb.clearHalt('out', this.#endpointNumberBulkOut); } throw new Error(`transferOutBulk failed: ${sent.status}`); } } async #transferOutData(opcode, transactionId, data) { if (!this.usb) return false; const dataView = new PTPDataView(); const length = 12 + data.byteLength; dataView.writeUint32(length); dataView.writeUint16(PTPBlockType.Data); dataView.writeUint16(opcode); dataView.writeUint32(transactionId); const dataBytes = new Uint8Array(data); dataBytes.forEach(byte => dataView.writeUint8(byte)); const sent = await this.usb.transferOut(this.#endpointNumberBulkOut, dataView.toBuffer()); this.#console.info('transferOutBulk', 'type=Data', 'opcode=' + toHexString(opcode, 2), 'id=' + transactionId, 'payload=' + toHexString(data)); if (sent.status === 'stall') { await this.usb.clearHalt('out', this.#endpointNumberBulkOut); } return sent.status === 'ok'; } async #waitBulkIn(expectedTransactionId, maxByteLength) { if (!this.usb || !this.usb.opened) { throw new Error('Device is not opened'); } const { data, status } = await this.usb.transferIn(this.#endpointNumberBulkIn, maxByteLength); // Error checking if (status !== 'ok') { if (status === 'stall') { await this.usb.clearHalt('in', this.#endpointNumberBulkIn); } throw new Error(`BulkIn returned status: ${status}`); } if (!data || data.byteLength < 12) throw new Error('Invalid bulkIn data'); // Unpack packet const type = data.getUint16(4, true); const code = data.getUint16(6, true); const transactionId = data.getUint32(8, true); const payload = data.buffer.slice(12); this.#console.info('transferInBulk', 'type=' + PTPBlockType[type], 'code=' + toHexString(code, 2), 'id=' + transactionId, 'expectedId=' + expectedTransactionId, 'payload=', payload); if (transactionId !== expectedTransactionId) throw new Error(`Transaction ID mismatch. Expected=${expectedTransactionId},` + ` got=${transactionId}`); return { type, code, transactionId, payload, }; } #listenInterruptIn = async () => { if (!this.usb || !this.usb.opened) { throw new Error('Device is not opened'); } try { const { data, status } = await this.usb.transferIn(this.#endpointerNumberInterruptIn, 0x800000); // Error checking if (status !== 'ok') { if (status === 'stall') { await this.usb.clearHalt('in', this.#endpointerNumberInterruptIn); } throw new Error(`InterruptIn returned status: ${status}`); } if (!data || data.byteLength < 12) { return; } // Unpack packet const type = data.getUint16(4, true); const code = data.getUint16(6, true); const transactionId = data.getUint32(8, true); const parameters = new Uint32Array(data.buffer.slice(12)); const eventName = toHexString(code, 2); this.#console.info('transferInInterrupt', 'type=' + PTPBlockType[type], 'code=' + eventName, 'id=' + transactionId, 'parameters=' + [...parameters].map(v => toHexString(v, 4))); this.emit(`ptpevent:${eventName}`, { code, parameters }); } catch (err) { // If the error has risen because of disconnection of the device, // just ignore the error. if (err instanceof DOMException && err.message.match(/The transfer was cancelled./) && !this.#opened) { return; } throw err; } finally { setTimeout(this.#listenInterruptIn, 0); } }; #listenDisconnect() { navigator.usb.addEventListener('disconnect', ev => { if (ev.device === this.usb) { this.emit('disconnect'); } }); } #generateTransactionId = () => { this.#transactionId += 1; if (this.#transactionId > 0xfffffffe) { this.#transactionId = 1; } return this.#transactionId; }; } //# sourceMappingURL=PTPDevice.js.map