diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index a85c2c79..c4d0b90c 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -4,6 +4,7 @@ * [Troubleshoot](/troubleshoot) * [EV3 Manager](https://ev3manager.education.lego.com/) +* [Bluetooth](/bluetooth) * [Forum](https://forum.makecode.com) * [LEGO Support](https://www.lego.com/service/) * [FIRST LEGO League](/fll) diff --git a/docs/about.md b/docs/about.md index 48cba36c..1ec3bd5d 100644 --- a/docs/about.md +++ b/docs/about.md @@ -28,7 +28,7 @@ program to a **.uf2** file, which you then copy to the **@drivename@** drive. Th ### ~ hint -Not seeing the **@drivename@** drive? Make sure to upgrade your firmware at https://ev3manager.education.lego.com/. Try these [troubleshooting](/troubleshoot) tips if you still have trouble getting the drive to appear. +**Experimental support** for Bluetooth download is now available. Please read the [Bluetooth](/bluetooth) page for more information. ### ~ diff --git a/docs/bluetooth.md b/docs/bluetooth.md new file mode 100644 index 00000000..b8880602 --- /dev/null +++ b/docs/bluetooth.md @@ -0,0 +1,51 @@ +# Bluetooth + +This page describes the procedure to download MakeCode program to the EV3 brick +over Bluetooth. + +## ~ hint + +### WARNING: EXPERIMENTAL FEATURES AHEAD! + +Support for Bluetooth download relies on [Web Serial](https://wicg.github.io/serial/), +an experimental browser feature. Web Serial is a work [in progress](https://www.chromestatus.com/feature/6577673212002304); +it may change or be removed in future versions without notice. + +By enabling these experimental browser features, you could lose browser data or compromise your device security +or privacy. + +## ~ + +## Supported browsers + +* Chrome desktop, version 77 and higher, Windows 10 +* [Edge Insider desktop](https://www.microsoftedgeinsider.com), version 77 and higher, Windows 10 + +To make sure your browser is up to date, go to the '...' menu, click "Help" then "About". + +## Machine Setup + +* pair your EV3 brick with your computer over Bluetooth. This is the usual pairing procedure. +* go to [chrome://flags/#enable-experimental-web-platform-features](chrome://flags/#enable-experimental-web-platform-features) and **enable** +**Experimental Web Platform features** + +![A screenshot of the flags page in chrome](/static/bluetooth/experimental.png) + +## Download over Bluetooth + +* go to the **beta** editor https://makecode.mindstorms.com/beta +* click on **Download** to start a file download as usual +* on the download dialog, you should see a **Bluetooth** button. Click on the +**Bluetooth** button to enable the mode. +* **make sure the EV3 brick is not running a program** +* click on **Download** again to download over bluetooth. + +## Choosing the correct serial port + +Unforunately, the browser dialog does not make it easy to select which serial port is the brick. +On Windows, it typically reads "Standard Serial over Bluetooth" and you may +have multiple of those if you've paired different bricks. + +## Feedback + +Please send us your feedback through https://forum.makecode.com. \ No newline at end of file diff --git a/docs/fll.md b/docs/fll.md index af02b673..095a5d0e 100644 --- a/docs/fll.md +++ b/docs/fll.md @@ -92,6 +92,11 @@ You can share your projects by clicking on the **share** button in the top left Sharing programs is also shown in the [Tips and Tricks](https://legoeducation.videomarketingplatform.co/v.ihtml/player.html?token=5c594c2373367f7870196f519f3bfc7a&source=embed&photo%5fid=35719472) video. +### Can I use Bluetooth to transfer my program? + +The official answer is currently no. That being said, we have **Experimental support** for Bluetooth download. Please read the [Bluetooth](/bluetooth) page for more information. + + ### Why can't I delete my program (*.uf2) files from the Brick? There's a bug in the firmware which prevents you from deleting the programs (``*.uf2`` files) from your EV3 Brick. There isn't a firmware update to fix this yet. diff --git a/docs/static/bluetooth/experimental.png b/docs/static/bluetooth/experimental.png new file mode 100644 index 00000000..83df41f7 Binary files /dev/null and b/docs/static/bluetooth/experimental.png differ diff --git a/editor/deploy.ts b/editor/deploy.ts index ced52bfb..efcad389 100644 --- a/editor/deploy.ts +++ b/editor/deploy.ts @@ -2,57 +2,198 @@ /// import UF2 = pxtc.UF2; +import { Ev3Wrapper } from "./wrap"; -export let ev3: pxt.editor.Ev3Wrapper +export let ev3: Ev3Wrapper export function debug() { - return initAsync() + return initHidAsync() .then(w => w.downloadFileAsync("/tmp/dmesg.txt", v => console.log(pxt.Util.uint8ArrayToString(v)))) } -function hf2Async() { - return pxt.HF2.mkPacketIOAsync() - .then(h => { - let w = new pxt.editor.Ev3Wrapper(h) - ev3 = w - return w.reconnectAsync(true) - .then(() => w) - }) + +// Web Serial API https://wicg.github.io/serial/ +// chromium bug https://bugs.chromium.org/p/chromium/issues/detail?id=884928 +// Under experimental features in Chrome Desktop 77+ +enum ParityType { + "none", + "even", + "odd", + "mark", + "space" +} +declare interface SerialOptions { + baudrate?: number; + databits?: number; + stopbits?: number; + parity?: ParityType; + buffersize?: number; + rtscts?: boolean; + xon?: boolean; + xoff?: boolean; + xany?: boolean; +} +type SerialPortInfo = pxt.Map; +type SerialPortRequestOptions = any; +declare class SerialPort { + open(options?: SerialOptions): Promise; + close(): void; + readonly readable: any; + readonly writable: any; + //getInfo(): SerialPortInfo; +} +declare interface Serial extends EventTarget { + onconnect: any; + ondisconnect: any; + getPorts(): Promise + requestPort(options: SerialPortRequestOptions): Promise; } -let noHID = false +class WebSerialPackageIO implements pxt.HF2.PacketIO { + onData: (v: Uint8Array) => void; + onError: (e: Error) => void; + onEvent: (v: Uint8Array) => void; + onSerial: (v: Uint8Array, isErr: boolean) => void; + sendSerialAsync: (buf: Uint8Array, useStdErr: boolean) => Promise; + private _reader: any; + private _writer: any; -let initPromise: Promise -export function initAsync() { - if (initPromise) - return initPromise + constructor(private port: SerialPort, private options: SerialOptions) { - let canHID = false + // start reading + this.readSerialAsync(); + } + + async readSerialAsync() { + this._reader = this.port.readable.getReader(); + let buffer: Uint8Array; + while (!!this._reader) { + const { done, value } = await this._reader.read() + if (!buffer) buffer = value; + else { // concat + let tmp = new Uint8Array(buffer.length + value.byteLength) + tmp.set(buffer, 0) + tmp.set(value, buffer.length) + buffer = tmp; + } + if (buffer && buffer.length >= 6) { + this.onData(new Uint8Array(buffer)); + buffer = undefined; + } + } + } + + static isSupported(): boolean { + return !!(navigator).serial; + } + + static async mkPacketIOAsync(): Promise { + const serial = (navigator).serial; + if (serial) { + try { + const requestOptions: SerialPortRequestOptions = {}; + const port = await serial.requestPort(requestOptions); + const options: SerialOptions = { + baudrate: 460800, + buffersize: 4096 + }; + await port.open(options); + if (port) + return new WebSerialPackageIO(port, options); + } catch (e) { + console.log(`connection error`, e) + } + } + throw new Error("could not open serial port"); + } + + error(msg: string): any { + console.error(msg); + throw new Error(lf("error on brick ({0})", msg)) + } + + async reconnectAsync(): Promise { + if (!this._reader) { + await this.port.open(this.options); + this.readSerialAsync(); + } + return Promise.resolve(); + } + + async disconnectAsync(): Promise { + this.port.close(); + this._reader = undefined; + this._writer = undefined; + return Promise.resolve(); + } + + sendPacketAsync(pkt: Uint8Array): Promise { + if (!this._writer) + this._writer = this.port.writable.getWriter(); + return this._writer.write(pkt); + } +} + +function hf2Async() { + const pktIOAsync: Promise = useWebSerial + ? WebSerialPackageIO.mkPacketIOAsync() : pxt.HF2.mkPacketIOAsync() + return pktIOAsync.then(h => { + let w = new Ev3Wrapper(h) + ev3 = w + return w.reconnectAsync(true) + .then(() => w) + }) +} + +let useHID = false; +let useWebSerial = false; +export function initAsync(): Promise { if (pxt.U.isNodeJS) { // doesn't seem to work ATM - canHID = false + useHID = false } else { - const forceHexDownload = /forceHexDownload/i.test(window.location.href); - if (pxt.Cloud.isLocalHost() && pxt.Cloud.localToken && !forceHexDownload) - canHID = true + const nodehid = /nodehid/i.test(window.location.href); + if (pxt.Cloud.isLocalHost() && pxt.Cloud.localToken && nodehid) + useHID = true; } - if (noHID) - canHID = false + if(WebSerialPackageIO.isSupported()) + pxt.tickEvent("bluetooth.supported"); - if (canHID) { + return Promise.resolve(); +} + +export function canUseWebSerial() { + return WebSerialPackageIO.isSupported(); +} + +export function enableWebSerial() { + initPromise = undefined; + useWebSerial = WebSerialPackageIO.isSupported(); + useHID = useWebSerial; +} + +let initPromise: Promise +function initHidAsync() { // needs to run within a click handler + if (initPromise) + return initPromise + if (useHID) { initPromise = hf2Async() .catch(err => { + console.error(err); initPromise = null - noHID = true - return Promise.reject(err) + useHID = false; + useWebSerial = false; + // cleanup + let p = ev3 ? ev3.disconnectAsync().catch(e => {}) : Promise.resolve(); + return p.then(() => Promise.reject(err)) }) } else { - noHID = true + useHID = false + useWebSerial = false; initPromise = Promise.reject(new Error("no HID")) } - - return initPromise + return initPromise; } // this comes from aux/pxt.lms @@ -61,8 +202,6 @@ const rbfTemplate = ` 74617274696e672e2e2e0084006080XX00448581644886488405018130813e80427965210084000a ` export function deployCoreAsync(resp: pxtc.CompileResult) { - let w: pxt.editor.Ev3Wrapper - let filename = resp.downloadFileBaseName || "pxt" filename = filename.replace(/^lego-/, "") @@ -107,27 +246,31 @@ export function deployCoreAsync(resp: pxtc.CompileResult) { return Promise.resolve(); } - if (noHID) return saveUF2Async() + if (!useHID) return saveUF2Async() - return initAsync() + pxt.tickEvent("bluetooth.flash"); + let w: Ev3Wrapper; + return initHidAsync() .then(w_ => { w = w_ if (w.isStreaming) pxt.U.userError("please stop the program first") - return w.stopAsync() + return w.reconnectAsync(false) }) + .then(() => w.stopAsync()) .then(() => w.rmAsync(elfPath)) .then(() => w.flashAsync(elfPath, UF2.readBytes(origElfUF2, 0, origElfUF2.length * 256))) .then(() => w.flashAsync(rbfPath, rbfBIN)) .then(() => w.runAsync(rbfPath)) .then(() => { + pxt.tickEvent("bluetooth.success"); return w.disconnectAsync() //return Promise.delay(1000).then(() => w.dmesgAsync()) }).catch(e => { - // if we failed to initalize, retry - if (noHID) - return saveUF2Async() - else - return Promise.reject(e) + pxt.tickEvent("bluetooth.fail"); + useHID = false; + useWebSerial = false; + // if we failed to initalize, tell the user to retry + return Promise.reject(e) }) } diff --git a/editor/extension.ts b/editor/extension.ts index 4b43bdbe..db05a0e9 100644 --- a/editor/extension.ts +++ b/editor/extension.ts @@ -1,28 +1,18 @@ /// /// -import { deployCoreAsync, initAsync } from "./deploy"; +import { deployCoreAsync, initAsync, canUseWebSerial, enableWebSerial } from "./deploy"; pxt.editor.initExtensionsAsync = function (opts: pxt.editor.ExtensionOptions): Promise { pxt.debug('loading pxt-ev3 target extensions...') const res: pxt.editor.ExtensionResult = { deployCoreAsync, showUploadInstructionsAsync: (fn: string, url: string, confirmAsync: (options: any) => Promise) => { - let resolve: (thenableOrResult?: void | PromiseLike) => void; - let reject: (error: any) => void; - const deferred = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - const boardName = pxt.appTarget.appTheme.boardName || "???"; - const boardDriveName = pxt.appTarget.appTheme.driveDisplayName || pxt.appTarget.compile.driveName || "???"; - // https://msdn.microsoft.com/en-us/library/cc848897.aspx // "For security reasons, data URIs are restricted to downloaded resources. // Data URIs cannot be used for navigation, for scripting, or to populate frame or iframe elements" const downloadAgain = !pxt.BrowserUtils.isIE() && !pxt.BrowserUtils.isEdge(); const docUrl = pxt.appTarget.appTheme.usbDocs; - const saveAs = pxt.BrowserUtils.hasSaveAs(); const htmlBody = `
@@ -84,7 +74,36 @@ pxt.editor.initExtensionsAsync = function (opts: pxt.editor.ExtensionOptions): P hideAgree: false, agreeLbl: lf("I got it"), className: 'downloaddialog', - buttons: [downloadAgain ? { + buttons: [canUseWebSerial() ? { + label: lf("Bluetooth"), + icon: "bluetooth", + className: "bluetooth focused", + onclick: () => { + pxt.tickEvent("bluetooth.enable"); + enableWebSerial(); + confirmAsync({ + header: lf("Bluetooth enabled"), + hasCloseIcon: true, + hideCancel: true, + buttons: [{ + label: lf("Help"), + icon: "question circle", + className: "lightgrey", + url: "/bluetooth" + }], + htmlBody: ` +

+${lf("Please download again to send your code to the EV3 over Bluetooth.")} +

+

+${lf("You will be prompted to select a serial port.")} +${lf("On Windows, look for 'Standard Serial over Bluetooth link'.")} +${lf("If you have paired multiple EV3, you might have to try out multiple ports until you find the correct one.")} +

+` + }) + } + } : undefined, downloadAgain ? { label: fn, icon: "download", className: "lightgrey focused", diff --git a/editor/wrap.ts b/editor/wrap.ts index d27f6bf1..76fafb8c 100644 --- a/editor/wrap.ts +++ b/editor/wrap.ts @@ -1,285 +1,281 @@ -namespace pxt.editor { - import HF2 = pxt.HF2 - import U = pxt.U +import HF2 = pxt.HF2 +import U = pxt.U - function log(msg: string) { - pxt.log("EWRAP: " + msg) +function log(msg: string) { + pxt.debug("EWRAP: " + msg) +} + +export interface DirEntry { + name: string; + md5?: string; + size?: number; +} + +const runTemplate = "C00882010084XX0060640301606400" +const usbMagic = 0x3d3f + +export class Ev3Wrapper { + msgs = new U.PromiseBuffer() + private cmdSeq = U.randomUint32() & 0xffff; + private lock = new U.PromiseQueue(); + isStreaming = false; + dataDump = false; + + constructor(public io: pxt.HF2.PacketIO) { + io.onData = buf => { + buf = buf.slice(0, HF2.read16(buf, 0) + 2) + if (HF2.read16(buf, 4) == usbMagic) { + let code = HF2.read16(buf, 6) + let payload = buf.slice(8) + if (code == 1) { + let str = U.uint8ArrayToString(payload) + if (U.isNodeJS) + pxt.debug("SERIAL: " + str.replace(/\n+$/, "")) + else + window.postMessage({ + type: 'serial', + id: 'n/a', // TODO? + data: str + }, "*") + } else + pxt.debug("Magic: " + code + ": " + U.toHex(payload)) + return + } + if (this.dataDump) + log("RECV: " + U.toHex(buf)) + this.msgs.push(buf) + } } - export interface DirEntry { - name: string; - md5?: string; - size?: number; + private allocCore(addSize: number, replyType: number) { + let len = 5 + addSize + let buf = new Uint8Array(len) + HF2.write16(buf, 0, len - 2) // pktLen + HF2.write16(buf, 2, this.cmdSeq++) // msgCount + buf[4] = replyType + return buf } - const runTemplate = "C00882010084XX0060640301606400" - const usbMagic = 0x3d3f + private allocSystem(addSize: number, cmd: number, replyType = 1) { + let buf = this.allocCore(addSize + 1, replyType) + buf[5] = cmd + return buf + } - export class Ev3Wrapper { - msgs = new U.PromiseBuffer() - private cmdSeq = U.randomUint32() & 0xffff; - private lock = new U.PromiseQueue(); - isStreaming = false; - dataDump = false; + private allocCustom(code: number, addSize = 0) { + let buf = this.allocCore(1 + 2 + addSize, 0) + HF2.write16(buf, 4, usbMagic) + HF2.write16(buf, 6, code) + return buf + } - constructor(public io: pxt.HF2.PacketIO) { - io.onData = buf => { - buf = buf.slice(0, HF2.read16(buf, 0) + 2) - if (HF2.read16(buf, 4) == usbMagic) { - let code = HF2.read16(buf, 6) - let payload = buf.slice(8) - if (code == 1) { - let str = U.uint8ArrayToString(payload) - if (Util.isNodeJS) - console.log("SERIAL: " + str.replace(/\n+$/, "")) - else - window.postMessage({ - type: 'serial', - id: 'n/a', // TODO? - data: str - }, "*") - } else - console.log("Magic: " + code + ": " + U.toHex(payload)) - return - } - if (this.dataDump) - log("RECV: " + U.toHex(buf)) - this.msgs.push(buf) - } - } - - private allocCore(addSize: number, replyType: number) { - let len = 5 + addSize - let buf = new Uint8Array(len) - HF2.write16(buf, 0, len - 2) // pktLen - HF2.write16(buf, 2, this.cmdSeq++) // msgCount - buf[4] = replyType - return buf - } - - private allocSystem(addSize: number, cmd: number, replyType = 1) { - let buf = this.allocCore(addSize + 1, replyType) - buf[5] = cmd - return buf - } - - private allocCustom(code: number, addSize = 0) { - let buf = this.allocCore(1 + 2 + addSize, 0) - HF2.write16(buf, 4, usbMagic) - HF2.write16(buf, 6, code) - return buf - } - - stopAsync() { - return this.isVmAsync() - .then(vm => { - if (vm) return Promise.resolve(); - log(`stopping PXT app`) - let buf = this.allocCustom(2) - return this.justSendAsync(buf) - .then(() => Promise.delay(500)) - }) - } - - dmesgAsync() { - log(`asking for DMESG buffer over serial`) - let buf = this.allocCustom(3) - return this.justSendAsync(buf) - } - - runAsync(path: string) { - let codeHex = runTemplate.replace("XX", U.toHex(U.stringToUint8Array(path))) - let code = U.fromHex(codeHex) - let pkt = this.allocCore(2 + code.length, 0) - HF2.write16(pkt, 5, 0x0800) - U.memcpy(pkt, 7, code) - log(`run ${path}`) - return this.justSendAsync(pkt) - } - - justSendAsync(buf: Uint8Array) { - return this.lock.enqueue("talk", () => { - this.msgs.drain() - if (this.dataDump) - log("SEND: " + U.toHex(buf)) - return this.io.sendPacketAsync(buf) - }) - } - - talkAsync(buf: Uint8Array, altResponse = 0) { - return this.lock.enqueue("talk", () => { - this.msgs.drain() - if (this.dataDump) - log("TALK: " + U.toHex(buf)) - return this.io.sendPacketAsync(buf) - .then(() => this.msgs.shiftAsync(1000)) - .then(resp => { - if (resp[2] != buf[2] || resp[3] != buf[3]) - U.userError("msg count de-sync") - if (buf[4] == 1) { - if (altResponse != -1 && resp[5] != buf[5]) - U.userError("cmd de-sync") - if (altResponse != -1 && resp[6] != 0 && resp[6] != altResponse) - U.userError("cmd error: " + resp[6]) - } - return resp - }) - }) - } - - flashAsync(path: string, file: Uint8Array) { - log(`write ${file.length} bytes to ${path}`) - - let handle = -1 - - let loopAsync = (pos: number): Promise => { - if (pos >= file.length) return Promise.resolve() - let size = file.length - pos - if (size > 1000) size = 1000 - let upl = this.allocSystem(1 + size, 0x93, 0x1) - upl[6] = handle - U.memcpy(upl, 6 + 1, file, pos, size) - return this.talkAsync(upl, 8) // 8=EOF - .then(() => loopAsync(pos + size)) - } - - let begin = this.allocSystem(4 + path.length + 1, 0x92) - HF2.write32(begin, 6, file.length) // fileSize - U.memcpy(begin, 10, U.stringToUint8Array(path)) - return this.lock.enqueue("file", () => - this.talkAsync(begin) - .then(resp => { - handle = resp[7] - return loopAsync(0) - })) - } - - lsAsync(path: string): Promise { - let lsReq = this.allocSystem(2 + path.length + 1, 0x99) - HF2.write16(lsReq, 6, 1024) // maxRead - U.memcpy(lsReq, 8, U.stringToUint8Array(path)) - - return this.talkAsync(lsReq, 8) - .then(resp => - U.uint8ArrayToString(resp.slice(12)).split(/\n/).map(s => { - if (!s) return null as DirEntry - let m = /^([A-F0-9]+) ([A-F0-9]+) ([^\/]*)$/.exec(s) - if (m) - return { - md5: m[1], - size: parseInt(m[2], 16), - name: m[3] - } - else - return { - name: s.replace(/\/$/, "") - } - }).filter(v => !!v)) - } - - rmAsync(path: string): Promise { - log(`rm ${path}`) - let rmReq = this.allocSystem(path.length + 1, 0x9c) - U.memcpy(rmReq, 6, U.stringToUint8Array(path)) - - return this.talkAsync(rmReq, 5) - .then(resp => { }) - } - - isVmAsync(): Promise { - let path = "/no/such/dir" - let mkdirReq = this.allocSystem(path.length + 1, 0x9b) - U.memcpy(mkdirReq, 6, U.stringToUint8Array(path)) - return this.talkAsync(mkdirReq, -1) - .then(resp => { - let isVM = resp[6] == 0x05 - log(`${isVM ? "PXT app" : "VM"} running`) - return isVM - }) - } - - private streamFileOnceAsync(path: string, cb: (d: Uint8Array) => void) { - let fileSize = 0 - let filePtr = 0 - let handle = -1 - let resp = (buf: Uint8Array): Promise => { - if (buf[6] == 2) { - // handle not ready - file is missing - this.isStreaming = false - return Promise.resolve() - } - - if (buf[6] != 0 && buf[6] != 8) - U.userError("bad response when streaming file: " + buf[6] + " " + U.toHex(buf)) - - this.isStreaming = true - fileSize = HF2.read32(buf, 7) - if (handle == -1) { - handle = buf[11] - log(`stream on, handle=${handle}`) - } - let data = buf.slice(12) - filePtr += data.length - if (data.length > 0) - cb(data) - - if (buf[6] == 8) { - // end of file - this.isStreaming = false - return this.rmAsync(path) - } - - let contFileReq = this.allocSystem(1 + 2, 0x97) - HF2.write16(contFileReq, 7, 1000) // maxRead - contFileReq[6] = handle - return Promise.delay(data.length > 0 ? 0 : 500) - .then(() => this.talkAsync(contFileReq, -1)) - .then(resp) - } - - let getFileReq = this.allocSystem(2 + path.length + 1, 0x96) - HF2.write16(getFileReq, 6, 1000) // maxRead - U.memcpy(getFileReq, 8, U.stringToUint8Array(path)) - return this.talkAsync(getFileReq, -1).then(resp) - } - - streamFileAsync(path: string, cb: (d: Uint8Array) => void) { - let loop = (): Promise => - this.lock.enqueue("file", () => - this.streamFileOnceAsync(path, cb)) + stopAsync() { + return this.isVmAsync() + .then(vm => { + if (vm) return Promise.resolve(); + log(`stopping PXT app`) + let buf = this.allocCustom(2) + return this.justSendAsync(buf) .then(() => Promise.delay(500)) - .then(loop) - return loop() + }) + } + + dmesgAsync() { + log(`asking for DMESG buffer over serial`) + let buf = this.allocCustom(3) + return this.justSendAsync(buf) + } + + runAsync(path: string) { + let codeHex = runTemplate.replace("XX", U.toHex(U.stringToUint8Array(path))) + let code = U.fromHex(codeHex) + let pkt = this.allocCore(2 + code.length, 0) + HF2.write16(pkt, 5, 0x0800) + U.memcpy(pkt, 7, code) + log(`run ${path}`) + return this.justSendAsync(pkt) + } + + justSendAsync(buf: Uint8Array) { + return this.lock.enqueue("talk", () => { + this.msgs.drain() + if (this.dataDump) + log("SEND: " + U.toHex(buf)) + return this.io.sendPacketAsync(buf) + }) + } + + talkAsync(buf: Uint8Array, altResponse = 0) { + return this.lock.enqueue("talk", () => { + this.msgs.drain() + if (this.dataDump) + log("TALK: " + U.toHex(buf)) + return this.io.sendPacketAsync(buf) + .then(() => this.msgs.shiftAsync(1000)) + .then(resp => { + if (resp[2] != buf[2] || resp[3] != buf[3]) + U.userError("msg count de-sync") + if (buf[4] == 1) { + if (altResponse != -1 && resp[5] != buf[5]) + U.userError("cmd de-sync") + if (altResponse != -1 && resp[6] != 0 && resp[6] != altResponse) + U.userError("cmd error: " + resp[6]) + } + return resp + }) + }) + } + + flashAsync(path: string, file: Uint8Array) { + log(`write ${file.length} bytes to ${path}`) + + let handle = -1 + + let loopAsync = (pos: number): Promise => { + if (pos >= file.length) return Promise.resolve() + let size = file.length - pos + if (size > 1000) size = 1000 + let upl = this.allocSystem(1 + size, 0x93, 0x1) + upl[6] = handle + U.memcpy(upl, 6 + 1, file, pos, size) + return this.talkAsync(upl, 8) // 8=EOF + .then(() => loopAsync(pos + size)) } + let begin = this.allocSystem(4 + path.length + 1, 0x92) + HF2.write32(begin, 6, file.length) // fileSize + U.memcpy(begin, 10, U.stringToUint8Array(path)) + return this.lock.enqueue("file", () => + this.talkAsync(begin) + .then(resp => { + handle = resp[7] + return loopAsync(0) + })) + } - downloadFileAsync(path: string, cb: (d: Uint8Array) => void) { - return this.lock.enqueue("file", () => - this.streamFileOnceAsync(path, cb)) - } - + lsAsync(path: string): Promise { + let lsReq = this.allocSystem(2 + path.length + 1, 0x99) + HF2.write16(lsReq, 6, 1024) // maxRead + U.memcpy(lsReq, 8, U.stringToUint8Array(path)) - private initAsync() { - return Promise.resolve() + return this.talkAsync(lsReq, 8) + .then(resp => + U.uint8ArrayToString(resp.slice(12)).split(/\n/).map(s => { + if (!s) return null as DirEntry + let m = /^([A-F0-9]+) ([A-F0-9]+) ([^\/]*)$/.exec(s) + if (m) + return { + md5: m[1], + size: parseInt(m[2], 16), + name: m[3] + } + else + return { + name: s.replace(/\/$/, "") + } + }).filter(v => !!v)) + } + + rmAsync(path: string): Promise { + log(`rm ${path}`) + let rmReq = this.allocSystem(path.length + 1, 0x9c) + U.memcpy(rmReq, 6, U.stringToUint8Array(path)) + + return this.talkAsync(rmReq, 5) + .then(resp => { }) + } + + isVmAsync(): Promise { + let path = "/no/such/dir" + let mkdirReq = this.allocSystem(path.length + 1, 0x9b) + U.memcpy(mkdirReq, 6, U.stringToUint8Array(path)) + return this.talkAsync(mkdirReq, -1) + .then(resp => { + let isVM = resp[6] == 0x05 + log(`${isVM ? "PXT app" : "VM"} running`) + return isVM + }) + } + + private streamFileOnceAsync(path: string, cb: (d: Uint8Array) => void) { + let fileSize = 0 + let filePtr = 0 + let handle = -1 + let resp = (buf: Uint8Array): Promise => { + if (buf[6] == 2) { + // handle not ready - file is missing + this.isStreaming = false + return Promise.resolve() + } + + if (buf[6] != 0 && buf[6] != 8) + U.userError("bad response when streaming file: " + buf[6] + " " + U.toHex(buf)) + + this.isStreaming = true + fileSize = HF2.read32(buf, 7) + if (handle == -1) { + handle = buf[11] + log(`stream on, handle=${handle}`) + } + let data = buf.slice(12) + filePtr += data.length + if (data.length > 0) + cb(data) + + if (buf[6] == 8) { + // end of file + this.isStreaming = false + return this.rmAsync(path) + } + + let contFileReq = this.allocSystem(1 + 2, 0x97) + HF2.write16(contFileReq, 7, 1000) // maxRead + contFileReq[6] = handle + return Promise.delay(data.length > 0 ? 0 : 500) + .then(() => this.talkAsync(contFileReq, -1)) + .then(resp) } - private resetState() { + let getFileReq = this.allocSystem(2 + path.length + 1, 0x96) + HF2.write16(getFileReq, 6, 1000) // maxRead + U.memcpy(getFileReq, 8, U.stringToUint8Array(path)) + return this.talkAsync(getFileReq, -1).then(resp) + } - } - - reconnectAsync(first = false): Promise { - this.resetState() - if (first) return this.initAsync() - log(`reconnect`); - return this.io.reconnectAsync() - .then(() => this.initAsync()) - } - - disconnectAsync() { - log(`disconnect`); - return this.io.disconnectAsync() - } + streamFileAsync(path: string, cb: (d: Uint8Array) => void) { + let loop = (): Promise => + this.lock.enqueue("file", () => + this.streamFileOnceAsync(path, cb)) + .then(() => Promise.delay(500)) + .then(loop) + return loop() } -} \ No newline at end of file + downloadFileAsync(path: string, cb: (d: Uint8Array) => void) { + return this.lock.enqueue("file", () => + this.streamFileOnceAsync(path, cb)) + } + + + private initAsync() { + return Promise.resolve() + } + + private resetState() { + + } + + reconnectAsync(first = false): Promise { + this.resetState() + if (first) return this.initAsync() + log(`reconnect`); + return this.io.reconnectAsync() + .then(() => this.initAsync()) + } + + disconnectAsync() { + log(`disconnect`); + return this.io.disconnectAsync() + } +} diff --git a/theme/style.less b/theme/style.less index 69a407f7..6598c49e 100644 --- a/theme/style.less +++ b/theme/style.less @@ -185,3 +185,8 @@ font-family: 'legoIcons' !important; content: "\f119" !important; } + +.bluetooth { + background-color: #007EF4 !important; + color: white !important; +} \ No newline at end of file