tethr
Version:
Controlls USB-connected cameras, webcam, and smartphone camera from browser
588 lines • 21.2 kB
JavaScript
import { times } from 'lodash';
import { ConfigForDevicePropTable, DriveModeTable, ExposureModeTable, WhiteBalanceTable, } from '../configs';
import { DatatypeCode, DevicePropCode, EventCode, ObjectFormatCode, OpCode, PTPAccessCapabilityCode, PTPFilesystemTypeCode, PTPStorageTypeCode, ResCode, } from '../PTPDatacode';
import { PTPDataView } from '../PTPDataView';
import { Tethr, } from '../Tethr';
import { isntNil, readonlyConfigDesc, toHexString, UnsupportedOperationResult, } from '../util';
export class TethrPTPUSB extends Tethr {
device;
_opened = false;
constructor(device) {
super();
this.device = device;
}
async open() {
if (!this.device.opened) {
await this.device.open();
}
await this.device.sendCommand({
label: 'Open Session',
opcode: OpCode.OpenSession,
parameters: [0x1],
expectedResCodes: [ResCode.OK, ResCode.SessionAlreadyOpen],
});
this.device.onEventCode(EventCode.DevicePropChanged, this.onDevicePropChanged);
this.device.on('disconnect', () => {
this._opened = false;
this.emit('disconnect');
});
window.addEventListener('beforeunload', async () => {
await this.close();
});
this._opened = true;
}
async close() {
this._opened = false;
await this.device.sendCommand({
label: 'Close Session',
opcode: OpCode.CloseSession,
});
await this.device.close();
}
get opened() {
return this._opened;
}
get type() {
return 'ptpusb';
}
get name() {
return this.device.usb.productName ?? 'Generic PTP Camera';
}
setLog(log) {
this.device.setLog(log);
}
// Configs
setAperture(value) {
return this.setDevicePropValue({
devicePropCode: DevicePropCode.FNumber,
datatypeCode: DatatypeCode.Uint16,
encode(value) {
if (value === 'auto')
return null;
return Math.round(value * 100);
},
value,
});
}
getApertureDesc() {
return this.getDevicePropDesc({
devicePropCode: DevicePropCode.FNumber,
datatypeCode: DatatypeCode.Uint16,
decode(data) {
return (data / 100);
},
});
}
getBatteryLevelDesc() {
return this.getDevicePropDesc({
devicePropCode: DevicePropCode.BatteryLevel,
datatypeCode: DatatypeCode.Uint8,
decode: data => data,
});
}
async getCanTakePhotoDesc() {
const { operationsSupported } = await this.getDeviceInfo();
const value = operationsSupported.includes(OpCode.InitiateCapture);
return readonlyConfigDesc(value);
}
setCaptureDelay(value) {
return this.setDevicePropValue({
devicePropCode: DevicePropCode.CaptureDelay,
datatypeCode: DatatypeCode.Uint32,
encode: data => data,
value,
});
}
getCaptureDelayDesc() {
return this.getDevicePropDesc({
devicePropCode: DevicePropCode.CaptureDelay,
datatypeCode: DatatypeCode.Uint32,
decode: data => data,
});
}
setDriveMode(value) {
return this.setDevicePropValue({
devicePropCode: DevicePropCode.StillCaptureMode,
datatypeCode: DatatypeCode.Uint16,
encode(value) {
return DriveModeTable.getKey(value) ?? 0x0;
},
value,
});
}
getDriveModeDesc() {
return this.getDevicePropDesc({
devicePropCode: DevicePropCode.StillCaptureMode,
datatypeCode: DatatypeCode.Uint16,
decode: data => {
return DriveModeTable.get(data) ?? 'normal';
},
});
}
setExposureComp(value) {
return this.setDevicePropValue({
devicePropCode: DevicePropCode.ExposureBiasCompensation,
datatypeCode: DatatypeCode.Int16,
encode(str) {
if (str === '0')
return 0;
const match = str.match(/^([+-]?)([0-9]+)?\s?(1\/2|1\/3|2\/3)?$/);
if (!match)
return null;
const [, signStr, integerStr, fractionStr] = match;
const sign = signStr === '-' ? -1 : +1;
const integer = parseInt(integerStr);
let fracMills = 0;
switch (fractionStr) {
case '1/3':
fracMills = 300;
break;
case '1/2':
fracMills = 500;
break;
case '2/3':
fracMills = 700;
break;
}
return sign * (integer * 1000 + fracMills);
},
value,
});
}
getExposureCompDesc() {
return this.getDevicePropDesc({
devicePropCode: DevicePropCode.ExposureBiasCompensation,
datatypeCode: DatatypeCode.Int16,
decode(mills) {
if (mills === 0)
return '0';
const millsAbs = Math.abs(mills);
const sign = mills > 0 ? '+' : '-';
const integer = Math.floor(millsAbs / 1000);
const fracMills = millsAbs % 1000;
let fraction = '';
switch (fracMills) {
case 300:
fraction = '1/3';
break;
case 500:
fraction = '1/2';
break;
case 700:
fraction = '2/3';
break;
}
if (integer === 0)
return `${sign}${fraction}`;
if (fraction === '')
return `${sign}${integer}`;
return `${sign}${integer} ${fraction}`;
},
});
}
async setExposureMode(value) {
return this.setDevicePropValue({
devicePropCode: DevicePropCode.ExposureProgramMode,
datatypeCode: DatatypeCode.Uint16,
encode: value => {
return (ExposureModeTable.getKey(value) ??
parseInt(value.replace('vendor ', ''), 16));
},
value,
});
}
async getExposureModeDesc() {
return this.getDevicePropDesc({
devicePropCode: DevicePropCode.ExposureProgramMode,
datatypeCode: DatatypeCode.Uint16,
decode(data) {
return (ExposureModeTable.get(data) ??
`vendor ${toHexString(data, 4)}`);
},
});
}
setImageSize(value) {
return this.setDevicePropValue({
devicePropCode: DevicePropCode.ImageSize,
datatypeCode: DatatypeCode.String,
encode: data => data,
value,
});
}
getImageSizeDesc() {
return this.getDevicePropDesc({
devicePropCode: DevicePropCode.ImageSize,
datatypeCode: DatatypeCode.String,
decode: data => data,
});
}
setIso(value) {
return this.setDevicePropValue({
devicePropCode: DevicePropCode.ExposureIndex,
datatypeCode: DatatypeCode.Uint16,
encode(iso) {
if (iso === 'auto')
return 0xffff;
return iso;
},
value,
});
}
getIsoDesc() {
return this.getDevicePropDesc({
devicePropCode: DevicePropCode.ExposureIndex,
datatypeCode: DatatypeCode.Uint16,
decode: data => {
if (data === 0xffff)
return 'auto';
return data;
},
});
}
async getManufacturerDesc() {
const value = (await this.getDeviceInfo()).manufacturer;
return readonlyConfigDesc(value);
}
async getModelDesc() {
const value = (await this.getDeviceInfo()).model;
return readonlyConfigDesc(value);
}
setWhiteBalance(value) {
return this.setDevicePropValue({
devicePropCode: DevicePropCode.WhiteBalance,
datatypeCode: DatatypeCode.Uint16,
encode(value) {
return (WhiteBalanceTable.getKey(value) ??
parseInt(value.replace(/^vendor /, ''), 16));
},
value,
});
}
getWhiteBalanceDesc() {
return this.getDevicePropDesc({
devicePropCode: DevicePropCode.WhiteBalance,
datatypeCode: DatatypeCode.Uint16,
decode: data => {
return (WhiteBalanceTable.get(data) ??
`vendor ${toHexString(data, 4)}`);
},
});
}
setTimelapseNumber(value) {
return this.setDevicePropValue({
devicePropCode: DevicePropCode.TimelapseNumber,
datatypeCode: DatatypeCode.Uint32,
encode: data => data,
value,
});
}
getTimelapseNumberDesc() {
return this.getDevicePropDesc({
devicePropCode: DevicePropCode.TimelapseNumber,
datatypeCode: DatatypeCode.Uint32,
decode: data => data,
});
}
setTimelapseInterval(value) {
return this.setDevicePropValue({
devicePropCode: DevicePropCode.TimelapseInterval,
datatypeCode: DatatypeCode.Uint32,
encode: data => data,
value,
});
}
getTimelapseIntervalDesc() {
return this.getDevicePropDesc({
devicePropCode: DevicePropCode.TimelapseInterval,
datatypeCode: DatatypeCode.Uint32,
decode: data => data,
});
}
// Actions
async takePhoto({ doDownload = true } = {}) {
if (!(await this.getCanTakePhoto())) {
return UnsupportedOperationResult;
}
await this.device.sendCommand({
label: 'InitiateCapture',
opcode: OpCode.InitiateCapture,
parameters: [0x0],
});
const objectAddedEvent = await this.device.waitEvent(EventCode.ObjectAdded);
if (!doDownload)
return { status: 'ok', value: [] };
const objectID = objectAddedEvent.parameters[0];
const objectInfo = await this.getObjectInfo(objectID);
const objectBuffer = await this.getObject(objectID);
const tethrObject = {
...objectInfo,
blob: new Blob([objectBuffer], { type: 'image/jpeg' }),
};
return { status: 'ok', value: [tethrObject] };
}
//----------------------------------------------------------------------------
// Utility functions for generic PTP devices
async setDevicePropValue({ devicePropCode, datatypeCode, encode, value, }) {
const devicePropData = encode(value);
if (devicePropData === null) {
return {
status: 'invalid parameter',
};
}
const dataView = new PTPDataView();
switch (datatypeCode) {
case DatatypeCode.Uint8:
dataView.writeUint8(devicePropData);
break;
case DatatypeCode.Int8:
dataView.writeInt8(devicePropData);
break;
case DatatypeCode.Uint16:
dataView.writeUint16(devicePropData);
break;
case DatatypeCode.Int16:
dataView.writeInt16(devicePropData);
break;
case DatatypeCode.Uint32:
dataView.writeUint32(devicePropData);
break;
case DatatypeCode.Int32:
dataView.writeInt32(devicePropData);
break;
case DatatypeCode.Uint64:
dataView.writeBigUint64(devicePropData);
break;
case DatatypeCode.String:
dataView.writeBigUint64(devicePropData);
break;
default: {
const label = DatatypeCode[datatypeCode] ?? toHexString(16);
throw new Error(`DevicePropDesc of datatype ${label} is not yet supported`);
}
}
const { resCode } = await this.device.sendData({
label: 'SetDevicePropValue',
opcode: OpCode.SetDevicePropValue,
parameters: [devicePropCode],
data: dataView.toBuffer(),
expectedResCodes: [ResCode.OK, ResCode.DeviceBusy],
});
return {
status: resCode === ResCode.OK ? 'ok' : 'busy',
};
}
async getDevicePropDesc({ devicePropCode, datatypeCode, decode, }) {
// Check if the deviceProps is supported
if (!(await this.isDevicePropSupported(devicePropCode))) {
return {
writable: false,
value: null,
};
}
const { data } = await this.device.receiveData({
label: 'GetDevicePropDesc',
opcode: OpCode.GetDevicePropDesc,
parameters: [devicePropCode],
});
const dataView = new PTPDataView(data.slice(2));
/*const dataType =*/ dataView.readUint16();
const writable = dataView.readUint8() === 0x01; // Get/Set
let readValue;
switch (datatypeCode) {
case DatatypeCode.Uint8:
readValue = dataView.readUint8;
break;
case DatatypeCode.Uint16:
readValue = dataView.readUint16;
break;
case DatatypeCode.Int16:
readValue = dataView.readInt16;
break;
case DatatypeCode.Uint32:
readValue = dataView.readUint32;
break;
case DatatypeCode.Uint64:
readValue = dataView.readUint64;
break;
case DatatypeCode.String:
readValue = dataView.readUTF16StringNT;
break;
default: {
const label = DatatypeCode[datatypeCode] ?? toHexString(16);
throw new Error(`PropDesc of datatype ${label} is not yet supported`);
}
}
readValue(); // Skip factoryDefault
const value = decode(readValue());
// Read options
const formFlag = dataView.readUint8();
let option;
switch (formFlag) {
case 0x00:
// None
break;
case 0x01: {
// Range
const min = decode(readValue());
const max = decode(readValue());
const step = decode(readValue());
if (typeof min !== 'number' ||
typeof max !== 'number' ||
typeof step !== 'number') {
throw new Error(`Cannot enumerate supported values of device config`);
}
option = {
type: 'range',
min,
max,
step,
};
break;
}
case 0x02: {
// Enumeration
const length = dataView.readUint16();
option = {
type: 'enum',
values: times(length, readValue).map(decode).filter(isntNil),
};
break;
}
default:
throw new Error(`Invalid form flag ${formFlag}`);
}
if (writable && option) {
return { writable, value, option };
}
else {
return { writable: false, value, option };
}
}
async isDevicePropSupported(code) {
const { devicePropsSupported } = await this.getDeviceInfo();
return devicePropsSupported.includes(code);
}
async getDeviceInfo() {
return await TethrPTPUSB.getDeviceInfo(this.device);
}
async getObjectHandles(storageId = 0xffffffff) {
const { data } = await this.device.receiveData({
label: 'GetObjectHandles',
opcode: OpCode.GetObjectHandles,
parameters: [storageId, 0xffffffff, 0x0],
});
return new PTPDataView(data).readUint32Array();
}
async getObjectInfo(id) {
const { data } = await this.device.receiveData({
label: 'GetObjectInfo',
opcode: OpCode.GetObjectInfo,
parameters: [id],
});
const dataView = new PTPDataView(data);
return {
id,
storageID: dataView.readUint32(),
format: this.getObjectFormatNameByCode(dataView.readUint16()),
// protectionStatus: dataView.readUint16(),
byteLength: dataView.skip(2).readUint32(),
thumb: {
format: this.getObjectFormatNameByCode(dataView.readUint16()),
compressedSize: dataView.readUint32(),
width: dataView.readUint32(),
height: dataView.readUint32(),
},
image: {
width: dataView.readUint32(),
height: dataView.readUint32(),
bitDepth: dataView.readUint32(),
},
// parent: dataView.readUint32(),
// associationType: dataView.readUint16(),
// associationDesc: dataView.readUint32(),
sequenceNumber: dataView.skip(4 + 2 + 4).readUint32(),
filename: dataView.readFixedUTF16String(),
captureDate: dataView.readDate(),
modificationDate: dataView.readDate(),
// keywords: dataView.readFixedUTF16String(),
};
}
async getObject(objectID) {
const { byteLength } = await this.getObjectInfo(objectID);
const { data } = await this.device.receiveData({
label: 'GetObject',
opcode: OpCode.GetObject,
parameters: [objectID],
maxByteLength: byteLength + 1000,
});
return data;
}
async getStorages() {
const { data } = await this.device.receiveData({
label: 'GetStorageIDs',
opcode: OpCode.GetStorageIDs,
});
const dataView = new PTPDataView(data);
const ids = dataView.readUint32Array();
const storages = [];
for (const id of ids) {
const { data } = await this.device.receiveData({
label: 'GetStorageInfo',
parameters: [id],
opcode: OpCode.GetStorageInfo,
});
const storageInfo = new PTPDataView(data);
const storage = {
id,
type: PTPStorageTypeCode[storageInfo.readUint16()],
filesystemType: PTPFilesystemTypeCode[storageInfo.readUint16()],
accessCapability: PTPAccessCapabilityCode[storageInfo.readUint16()],
maxCapability: storageInfo.readUint64(),
freeSpaceInBytes: storageInfo.readUint64(),
freeSpaceInImages: storageInfo.readUint32(),
description: storageInfo.readAsciiString(),
label: storageInfo.readAsciiString(),
};
storages.push(storage);
}
return storages;
}
onDevicePropChanged = async (event) => {
const devicePropCode = event.parameters[0];
const name = this.getConfigNameByCode(devicePropCode);
if (!name)
return;
const desc = await this.getDesc(name);
this.emit(`${name}Change`, desc);
};
getConfigNameByCode(code) {
return ConfigForDevicePropTable.get(code) ?? null;
}
getObjectFormatNameByCode(code) {
return ObjectFormatCode[code].toLowerCase();
}
static async getDeviceInfo(device) {
const { data } = await device.receiveData({
label: 'GetDeviceInfo',
opcode: OpCode.GetDeviceInfo,
});
const dataView = new PTPDataView(data);
return {
standardVersion: dataView.readUint16(),
vendorExtensionID: dataView.readUint32(),
vendorExtensionVersion: dataView.readUint16(),
vendorExtensionDesc: dataView.readFixedUTF16String(),
functionalMode: dataView.readUint16(),
operationsSupported: dataView.readUint16Array(),
eventsSupported: dataView.readUint16Array(),
devicePropsSupported: dataView.readUint16Array(),
captureFormats: dataView.readUint16Array(),
imageFormats: dataView.readUint16Array(),
manufacturer: dataView.readFixedUTF16String(),
model: dataView.readFixedUTF16String(),
deviceVersion: dataView.readFixedUTF16String(),
serialNumber: dataView.readFixedUTF16String(),
};
}
}
//# sourceMappingURL=TethrPTPUSB.js.map