From 352c1ca5ecfdf9383e534dab6121eb0a102060f1 Mon Sep 17 00:00:00 2001 From: Peli de Halleux Date: Fri, 27 Sep 2019 06:53:26 -0700 Subject: [PATCH] Experiment BT support using Chrome web serial (#920) * plumbing * plumbing * logging * more notes * fixing typing * more plumbing * more plumbing * different baud rate * talking to the brick * first over the air drop * fix buffer * tweak paraetmers * formatting fixing double upload * reduce console.log * cleanup * add BLE button to download dialog * changed label * recover from broken COM port * fix function call * reduce log level * adding ticks * some help * updated support matrix * more docs * updated browser help * more docs * add link * add device * added image --- docs/SUMMARY.md | 1 + docs/about.md | 2 +- docs/bluetooth.md | 51 +++ docs/fll.md | 5 + docs/static/bluetooth/experimental.png | Bin 0 -> 27150 bytes editor/deploy.ts | 219 ++++++++-- editor/extension.ts | 43 +- editor/wrap.ts | 530 ++++++++++++------------- theme/style.less | 5 + 9 files changed, 538 insertions(+), 318 deletions(-) create mode 100644 docs/bluetooth.md create mode 100644 docs/static/bluetooth/experimental.png 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 0000000000000000000000000000000000000000..83df41f7f14223b9d55c9a6581e4ed40be45f67d GIT binary patch literal 27150 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 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