ble-file-transfer-js/adafruit-ble-file-transfer.js
2022-09-27 11:08:12 -07:00

552 lines
19 KiB
JavaScript

const bleFileCharVersionUUID = 'adaf0100-4669-6c65-5472-616e73666572';
const bleFileCharTransferUUID = 'adaf0200-4669-6c65-5472-616e73666572';
const ANY_COMMAND = 0;
const THIS_COMMAND = 1;
const READ_COMMAND = 0x10;
const READ_DATA = 0x11;
const READ_PACING = 0x12;
const WRITE_COMMAND = 0x20;
const WRITE_PACING = 0x21;
const WRITE_DATA = 0x22;
const DELETE_COMMAND = 0x30;
const DELETE_STATUS = 0x31;
const MKDIR_COMMAND = 0x40;
const MKDIR_STATUS = 0x41;
const LISTDIR_COMMAND = 0x50;
const LISTDIR_ENTRY = 0x51;
const MOVE_COMMAND = 0x60;
const MOVE_STATUS = 0x61;
const STATUS_OK = 0x01;
const STATUS_ERROR = 0x02;
const STATUS_ERROR_USB_MOUNTED = 0x05;
// Flags
const FLAG_DIRECTORY = 0x01;
// 500 works on mac
const BYTES_PER_WRITE = 20;
class FileTransferClient {
constructor(bleDevice, bufferSize = 4096) {
this._resolve = null;
this._reject = null;
this._command = ANY_COMMAND;
this._offset = 0;
// We have a ton of memory so just buffer everything :-)
this._buffer = new Uint8Array(bufferSize);
this._transfer = null;
this._device = bleDevice;
this._raw = false;
bleDevice.addEventListener("gattserverdisconnected", this.onDisconnected.bind(this));
this._onTransferNotifty = this.onTransferNotifty.bind(this);
}
async onDisconnected() {
//ftc disconnected;
this._transfer = null;
if (this._reject != null) {
this._reject("disconnected");
this._reject = null;
this._resolve = null;
}
this._command = ANY_COMMAND;
this._offset = 0;
}
async checkConnection() {
if (this._reject != null) {
throw "Command in progress";
}
if (this._transfer != null) {
//connection ok
return;
}
try {
//check connection
let service = await this._device.gatt.getPrimaryService(0xfebb);
const versionChar = await service.getCharacteristic(bleFileCharVersionUUID);
let version = (await versionChar.readValue()).getUint32(0, true);
if (version != 4) {
return Promise.reject("Unsupported version: " + version);
}
//version ok
this._transfer = await service.getCharacteristic(bleFileCharTransferUUID);
this._transfer.removeEventListener('characteristicvaluechanged', this._onTransferNotifty);
this._transfer.addEventListener('characteristicvaluechanged', this._onTransferNotifty);
await this._transfer.startNotifications();
} catch (e) {
console.log("caught connection error", e, e.stack);
this.onDisconnected();
}
}
async _write(value) {
try {
if (value.byteLength < BYTES_PER_WRITE) {
await this._transfer.writeValueWithoutResponse(value);
return;
}
var offset = 0;
while (offset < value.byteLength) {
let len = Math.min(value.byteLength - offset, BYTES_PER_WRITE);
let chunk_contents = value.slice(offset, offset + len);
// Delay to ensure the last value was written to the device.
await this.sleep(100);
await this._transfer.writeValueWithoutResponse(chunk_contents);
offset += len;
}
} catch (e) {
console.log("caught write error", e, e.stack);
this.onDisconnected();
}
}
async bond() {
await this.checkConnection();
//bonded internally
}
async onTransferNotifty(event) {
this._buffer.set(new Uint8Array(event.target.value.buffer), this._offset);
this._command = this._buffer[0];
this._offset += event.target.value.byteLength;
if (this._command == READ_DATA) {
this._command = await this.processReadData(new DataView(this._buffer.buffer, 0, this._offset));
} else if (this._command == WRITE_PACING) {
this._command = await this.processWritePacing(new DataView(this._buffer.buffer, 0, this._offset));
} else if (this._command == LISTDIR_ENTRY) {
this._command = await this.processListDirEntry(new DataView(this._buffer.buffer, 0, this._offset));
} else if (this._command == MKDIR_STATUS) {
this._command = await this.processMkDirStatus(new DataView(this._buffer.buffer, 0, this._offset));
} else if (this._command == DELETE_STATUS) {
this._command = await this.processDeleteStatus(new DataView(this._buffer.buffer, 0, this._offset));
} else if (this._command == MOVE_STATUS) {
this._command = await this.processMoveStatus(new DataView(this._buffer.buffer, 0, this._offset));
} else {
console.log("Unknown Command: " + this._command);
}
if (this._command != THIS_COMMAND) {
//reset buffer
this._offset = 0;
}
}
async readFile(filename, raw = false) {
await this.checkConnection();
this._incomingFile = null;
this._incomingOffset = 0;
this._raw = raw;
var header = new ArrayBuffer(12);
var view = new DataView(header);
let encoded = new TextEncoder().encode(filename);
view.setUint8(0, READ_COMMAND);
// Offset 1 is reserved
view.setUint16(2, encoded.byteLength, true);
view.setUint32(4, 0, true);
view.setUint32(8, this._buffer.byteLength - 16, true);
await this._write(header);
await this._write(encoded);
//wrote read
let p = new Promise((resolve, reject) => {
//start read
this._resolve = resolve;
this._reject = reject;
});
//read return
return p;
}
async writeFile(path, offset, contents, modificationTime, raw = false) {
let encoder = new TextEncoder();
if (!raw) {
let same = contents.slice(0, offset);
let different = contents.slice(offset);
offset = encoder.encode(same).byteLength;
contents = encoder.encode(different);
} else if (!(contents instanceof Uint8Array)) {
contents = new Uint8Array(contents);
}
await this.checkConnection();
if (modificationTime === undefined) {
modificationTime = Date.now();
}
var header = new ArrayBuffer(20);
var view = new DataView(header);
let encoded = new TextEncoder().encode(path);
view.setUint8(0, WRITE_COMMAND);
// Offset 1 is reserved
view.setUint16(2, encoded.byteLength, true);
view.setUint32(4, offset, true);
view.setBigUint64(8, BigInt(modificationTime * 1000000), true);
view.setUint32(16, offset + contents.byteLength, true);
await this._write(header);
await this._write(encoded);
this._outgoingContents = contents;
this._outgoingOffset = offset;
//wrote write
let p = new Promise((resolve, reject) => {
//start write
this._resolve = resolve;
this._reject = reject;
});
//write return
return p;
}
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async processWritePacing(payload) {
let status = payload.getUint8(1);
// Two bytes of padding.
let chunkOffset = payload.getUint32(4, true);
let freeSpace = payload.getUint32(16, true);
if (status != STATUS_OK) {
if (status == STATUS_ERROR_USB_MOUNTED) {
this._reject("Unable to write while USB connected");
} else if (status == STATUS_ERROR) {
this._reject("Invalid Path");
} else {
this._reject("Unknown Status: " + status);
}
this._reject = null;
this._resolve = null;
return ANY_COMMAND;
}
if (freeSpace == 0) {
this._resolve();
this._reject = null;
this._resolve = null;
return ANY_COMMAND;
}
var header = new ArrayBuffer(12);
var view = new DataView(header);
view.setUint8(0, WRITE_DATA);
view.setUint8(1, STATUS_OK);
// Offsets 2 and 3 are reserved
view.setUint32(4, chunkOffset, true);
let remaining = Math.min(this._outgoingOffset + this._outgoingContents.byteLength - chunkOffset, freeSpace);
view.setUint32(8, remaining, true);
await this._write(header);
let baseOffset = chunkOffset - this._outgoingOffset;
await this._write(this._outgoingContents.subarray(baseOffset, baseOffset + remaining));
return WRITE_PACING;
}
async processReadData(payload) {
const headerSize = 16;
let status = payload.getUint8(1);
let chunkOffset = payload.getUint32(4, true);
let totalLength = payload.getUint32(8, true);
let chunkLength = payload.getUint32(12, true);
if (status != STATUS_OK) {
if (status == STATUS_ERROR_USB_MOUNTED) {
this._reject("Unable to read while USB connected");
} else if (status == STATUS_ERROR) {
this._reject("Invalid Path");
} else {
this._reject("Unknown Status: " + status);
}
this._resolve = null;
this._reject = null;
this._incomingFile = null;
this._incomingOffset = 0;
return ANY_COMMAND;
}
if (payload.byteLength < headerSize + chunkLength) {
// need more
return THIS_COMMAND;
}
// full payload
if (this._incomingFile == null) {
this._incomingFile = new Uint8Array(totalLength);
}
this._incomingFile.set(new Uint8Array(payload.buffer.slice(headerSize, payload.byteLength)), chunkOffset);
this._incomingOffset += chunkLength;
let remaining = this._incomingFile.byteLength - this._incomingOffset;
if (remaining == 0) {
if (this._raw) {
this._resolve(new Blob([this._incomingFile]));
} else {
this._resolve(new TextDecoder().decode(this._incomingFile));
}
this._resolve = null;
this._reject = null;
this._incomingFile = null;
this._incomingOffset = 0;
return ANY_COMMAND;
}
var header = new ArrayBuffer(12);
var view = new DataView(header);
view.setUint8(0, READ_PACING);
view.setUint8(1, STATUS_OK);
// Offsets 2 and 3 are reserved
view.setUint32(4, this._incomingOffset, true);
view.setUint32(8, Math.min(this._buffer.byteLength - 12, remaining), true);
await this._write(header);
return READ_DATA;
}
async processListDirEntry(payload, offset = 0) {
let paths = [];
let b = this._buffer.buffer;
const headerSize = 28;
let cmd, path;
let flags, modificationTime, fileSize;
let status = payload.getUint8(1);
let pathLength = payload.getUint16(2, true);
let i = payload.getUint32(4, true);
let totalItems = payload.getUint32(8, true);
if (status != STATUS_OK) {
if (status == STATUS_ERROR_USB_MOUNTED) {
this._reject("Unable to read while USB connected");
} else if (status == STATUS_ERROR) {
this._reject("Invalid Path");
} else {
this._reject("Unknown Status: " + status);
}
this._resolve = null;
this._reject = null;
return ANY_COMMAND;
}
// Figure out if complete
offset = 0;
while (offset < payload.byteLength) {
if (offset + headerSize + pathLength > payload.byteLength) {
break;
}
pathLength = payload.getUint16(offset + 2, true);
i = payload.getUint32(offset + 4, true);
totalItems = payload.getUint32(offset + 8, true);
offset += headerSize + pathLength;
}
// Check if we have all items and all expected data for last item
if (i < totalItems - 1 || payload.byteLength < offset + headerSize) {
// need more
return THIS_COMMAND;
}
// full payload, now process it
offset = 0;
while (offset < payload.byteLength) {
cmd = payload.getUint8(offset + 0);
status = payload.getUint8(offset + 1);
pathLength = payload.getUint16(offset + 2, true);
i = payload.getUint32(offset + 4, true);
totalItems = payload.getUint32(offset + 8, true);
flags = payload.getUint32(offset + 12, true);
modificationTime = payload.getBigUint64(offset + 16, true);
fileSize = payload.getUint32(offset + 24, true);
if (cmd != LISTDIR_ENTRY) {
throw new ProtocolError();
}
if (i >= totalItems) {
break;
}
if (offset + headerSize + pathLength > payload.byteLength) {
break;
}
path = String.fromCharCode.apply(null, new Uint8Array(b.slice(offset + headerSize, offset + headerSize + pathLength)));
paths.push({
path: path,
isDir: !!(flags & FLAG_DIRECTORY),
fileSize: fileSize,
fileDate: Number(modificationTime / BigInt(1000000)),
});
offset += headerSize + pathLength;
if (status != STATUS_OK) {
break;
}
}
this._resolve(paths);
this._resolve = null;
this._reject = null;
return ANY_COMMAND;
}
async processMkDirStatus(payload) {
const headerSize = 16;
let status = payload.getUint8(1);
if (payload.byteLength < headerSize) {
return THIS_COMMAND;
}
if (status != STATUS_OK) {
if (status == STATUS_ERROR_USB_MOUNTED) {
this._reject("Unable to write while USB connected");
} else if (status == STATUS_ERROR) {
this._reject("Invalid Path");
} else {
this._reject("Unknown Status: " + status);
}
} else {
this._resolve(true);
}
this._resolve = null;
this._reject = null;
return ANY_COMMAND;
}
async processDeleteStatus(payload) {
const headerSize = 2;
if (payload.byteLength < headerSize) {
return THIS_COMMAND;
}
let status = payload.getUint8(1);
if (status != STATUS_OK) {
if (status == STATUS_ERROR_USB_MOUNTED) {
this._reject("Unable to write while USB connected");
} else if (status == STATUS_ERROR) {
this._reject("File or Folder not found");
} else {
this._reject("Unknown Status: " + status);
}
} else {
this._resolve(true);
}
this._resolve = null;
this._reject = null;
return ANY_COMMAND;
}
async processMoveStatus(payload) {
const headerSize = 2;
if (payload.byteLength < headerSize) {
return THIS_COMMAND;
}
let status = payload.getUint8(1);
if (status != STATUS_OK) {
if (status == STATUS_ERROR_USB_MOUNTED) {
this._reject("Unable to write while USB connected");
} else if (status == STATUS_ERROR) {
this._reject("Unable to move file");
} else {
this._reject("Unknown Status: " + status);
}
} else {
this._resolve(true);
}
this._resolve = null;
this._reject = null;
return ANY_COMMAND;
}
// Makes the directory and any missing parents
async makeDir(path, modificationTime) {
await this.checkConnection();
if (modificationTime === undefined) {
modificationTime = Date.now();
}
let encoded = new TextEncoder().encode(path);
var header = new ArrayBuffer(16);
var view = new DataView(header);
view.setUint8(0, MKDIR_COMMAND);
// Offset 1 is reserved
view.setUint16(2, encoded.byteLength, true);
// Offsets 4-7 Reserved
view.setBigUint64(8, BigInt(modificationTime * 1000000), true);
await this._write(header);
await this._write(encoded);
let p = new Promise((resolve, reject) => {
this._resolve = resolve;
this._reject = reject;
});
return p;
}
// Returns a list of tuples, one tuple for each file or directory in the given path
async listDir(path) {
await this.checkConnection();
let encoded = new TextEncoder().encode(path);
var header = new ArrayBuffer(4);
var view = new DataView(header);
view.setUint8(0, LISTDIR_COMMAND);
// Offset 1 is reserved
view.setUint16(2, encoded.byteLength, true);
await this._write(header);
await this._write(encoded);
let p = new Promise((resolve, reject) => {
this._resolve = resolve;
this._reject = reject;
});
return p;
}
// Deletes the file or directory at the given path. Directories must be empty.
async delete(path) {
await this.checkConnection();
let encoded = new TextEncoder().encode(path);
var header = new ArrayBuffer(4);
var view = new DataView(header);
view.setUint8(0, DELETE_COMMAND);
// Offset 1 is reserved
view.setUint16(2, encoded.byteLength, true);
await this._write(header);
await this._write(encoded);
let p = new Promise((resolve, reject) => {
this._resolve = resolve;
this._reject = reject;
});
return p;
}
// Moves the file or directory from oldPath to newPath.
async move(oldPath, newPath) {
await this.checkConnection();
let encodedOldPath = new TextEncoder().encode(oldPath);
let encodedNewPath = new TextEncoder().encode(newPath);
var header = new ArrayBuffer(6);
var view = new DataView(header);
view.setUint8(0, MOVE_COMMAND);
// Offset 1 is reserved
view.setUint16(2, encodedOldPath.byteLength, true);
view.setUint16(4, encodedNewPath.byteLength, true);
await this._write(header);
await this._write(encodedOldPath);
await this._write(new TextEncoder().encode(" "));
await this._write(encodedNewPath);
let p = new Promise((resolve, reject) => {
this._resolve = resolve;
this._reject = reject;
});
return p;
}
}
class ProtocolError extends Error {
constructor(message) {
super(message);
this.name = "ProtocolError";
}
}
class ValueError extends Error {
constructor(message) {
super(message);
this.name = "ValueError";
}
}
export {FileTransferClient, ProtocolError, ValueError};