diff --git a/package-lock.json b/package-lock.json
index c887d5869..824406d2c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -3799,9 +3799,9 @@
       }
     },
     "eslint": {
-      "version": "5.0.1",
-      "resolved": "https://registry.npmjs.org/eslint/-/eslint-5.0.1.tgz",
-      "integrity": "sha512-D5nG2rErquLUstgUaxJlWB5+gu+U/3VDY0fk/Iuq8y9CUFy/7Y6oF4N2cR1tV8knzQvciIbfqfohd359xTLIKQ==",
+      "version": "5.1.0",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-5.1.0.tgz",
+      "integrity": "sha512-DyH6JsoA1KzA5+OSWFjg56DFJT+sDLO0yokaPZ9qY0UEmYrPA1gEX/G1MnVkmRDsksG4H1foIVz2ZXXM3hHYvw==",
       "dev": true,
       "requires": {
         "ajv": "^6.5.0",
@@ -3811,6 +3811,7 @@
         "debug": "^3.1.0",
         "doctrine": "^2.1.0",
         "eslint-scope": "^4.0.0",
+        "eslint-utils": "^1.3.1",
         "eslint-visitor-keys": "^1.0.0",
         "espree": "^4.0.0",
         "esquery": "^1.0.1",
@@ -3818,7 +3819,7 @@
         "file-entry-cache": "^2.0.0",
         "functional-red-black-tree": "^1.0.1",
         "glob": "^7.1.2",
-        "globals": "^11.5.0",
+        "globals": "^11.7.0",
         "ignore": "^3.3.3",
         "imurmurhash": "^0.1.4",
         "inquirer": "^5.2.0",
@@ -4138,6 +4139,12 @@
         "estraverse": "^4.1.1"
       }
     },
+    "eslint-utils": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-1.3.1.tgz",
+      "integrity": "sha512-Z7YjnIldX+2XMcjr7ZkgEsOj/bREONV60qYeB/bjMAqqqZ4zxKyWX+BOUkdmRmA9riiIPVvo5x86m5elviOk0Q==",
+      "dev": true
+    },
     "eslint-visitor-keys": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz",
@@ -13129,9 +13136,9 @@
       }
     },
     "scratch-blocks": {
-      "version": "0.1.0-prerelease.1530135682",
-      "resolved": "https://registry.npmjs.org/scratch-blocks/-/scratch-blocks-0.1.0-prerelease.1530135682.tgz",
-      "integrity": "sha512-JadXtaqDLmebmitbk5s5RVtuApvWWaj+ECSIfXY01dY9X3JDZAopMDWZuP/8uNWg7S1xMBrIaD8Rv0r83lGdmA==",
+      "version": "0.1.0-prerelease.1531144787",
+      "resolved": "https://registry.npmjs.org/scratch-blocks/-/scratch-blocks-0.1.0-prerelease.1531144787.tgz",
+      "integrity": "sha512-fSS/C6pBh5kNkimFfHDO+XG8Ny6lzHePb5eeqxDkel2u5agJgkAbIgBgJnsqwJ+iJmKhmJgW29K/iM442MDYXA==",
       "dev": true,
       "requires": {
         "exports-loader": "0.6.3",
@@ -16054,6 +16061,11 @@
         "untildify": "^3.0.2"
       },
       "dependencies": {
+        "ansi-regex": {
+          "version": "3.0.0",
+          "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz",
+          "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg="
+        },
         "ansi-styles": {
           "version": "3.2.1",
           "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
@@ -16108,6 +16120,14 @@
           "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
           "dev": true
         },
+        "strip-ansi": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz",
+          "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=",
+          "requires": {
+            "ansi-regex": "^3.0.0"
+          }
+        },
         "supports-color": {
           "version": "5.4.0",
           "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.4.0.tgz",
diff --git a/src/engine/runtime.js b/src/engine/runtime.js
index 1ba19570b..530db3b40 100644
--- a/src/engine/runtime.js
+++ b/src/engine/runtime.js
@@ -254,6 +254,8 @@ class Runtime extends EventEmitter {
             video: new Video(this)
         };
 
+        this.extensionDevices = {};
+
         /**
          * A runtime profiler that records timed events for later playback to
          * diagnose Scratch performance.
@@ -394,6 +396,30 @@ class Runtime extends EventEmitter {
         return 'EXTENSION_ADDED';
     }
 
+    /**
+     * Event name for updating the available set of peripheral devices.
+     * @const {string}
+     */
+    static get PERIPHERAL_LIST_UPDATE () {
+        return 'PERIPHERAL_LIST_UPDATE';
+    }
+
+    /**
+     * Event name for reporting that a peripheral has connected.
+     * @const {string}
+     */
+    static get PERIPHERAL_CONNECTED () {
+        return 'PERIPHERAL_CONNECTED';
+    }
+
+    /**
+     * Event name for reporting that a peripheral has encountered an error.
+     * @const {string}
+     */
+    static get PERIPHERAL_ERROR () {
+        return 'PERIPHERAL_ERROR';
+    }
+
     /**
      * Event name for reporting that blocksInfo was updated.
      * @const {string}
@@ -867,6 +893,36 @@ class Runtime extends EventEmitter {
             (result, categoryInfo) => result.concat(categoryInfo.blocks.map(blockInfo => blockInfo.json)), []);
     }
 
+    registerExtensionDevice (extensionId, device) {
+        this.extensionDevices[extensionId] = device;
+    }
+
+    startDeviceScan (extensionId) {
+        if (this.extensionDevices[extensionId]) {
+            this.extensionDevices[extensionId].startDeviceScan();
+        }
+    }
+
+    connectToPeripheral (extensionId, peripheralId) {
+        if (this.extensionDevices[extensionId]) {
+            this.extensionDevices[extensionId].connectDevice(peripheralId);
+        }
+    }
+
+    disconnectExtensionSession (extensionId) {
+        if (this.extensionDevices[extensionId]) {
+            this.extensionDevices[extensionId].disconnectSession();
+        }
+    }
+
+    getPeripheralIsConnected (extensionId) {
+        let isConnected = false;
+        if (this.extensionDevices[extensionId]) {
+            isConnected = this.extensionDevices[extensionId].getPeripheralIsConnected();
+        }
+        return isConnected;
+    }
+
     /**
      * Retrieve the function associated with the given opcode.
      * @param {!string} opcode The opcode to look up.
diff --git a/src/extension-support/extension-manager.js b/src/extension-support/extension-manager.js
index cff9f4f0e..7a004104f 100644
--- a/src/extension-support/extension-manager.js
+++ b/src/extension-support/extension-manager.js
@@ -15,6 +15,7 @@ const Scratch3SpeakBlocks = require('../extensions/scratch3_speak');
 const Scratch3TranslateBlocks = require('../extensions/scratch3_translate');
 const Scratch3VideoSensingBlocks = require('../extensions/scratch3_video_sensing');
 const Scratch3SpeechBlocks = require('../extensions/scratch3_speech');
+const Scratch3Ev3Blocks = require('../extensions/scratch3_ev3');
 
 const builtinExtensions = {
     pen: Scratch3PenBlocks,
@@ -24,7 +25,8 @@ const builtinExtensions = {
     speak: Scratch3SpeakBlocks,
     translate: Scratch3TranslateBlocks,
     videoSensing: Scratch3VideoSensingBlocks,
-    speech: Scratch3SpeechBlocks
+    speech: Scratch3SpeechBlocks,
+    ev3: Scratch3Ev3Blocks
 };
 
 /**
diff --git a/src/extensions/scratch3_ev3/index.js b/src/extensions/scratch3_ev3/index.js
new file mode 100644
index 000000000..e5ea01660
--- /dev/null
+++ b/src/extensions/scratch3_ev3/index.js
@@ -0,0 +1,919 @@
+const ArgumentType = require('../../extension-support/argument-type');
+const BlockType = require('../../extension-support/block-type');
+const Cast = require('../../util/cast');
+const log = require('../../util/log');
+const Base64Util = require('../../util/base64-util');
+const BTSession = require('../../io/btSession');
+
+// TODO: Refactor/rename all these high level primitives to be clearer/match
+
+/**
+ * Icon svg to be displayed at the left edge of each extension block, encoded as a data URI.
+ * @type {string}
+ */
+// eslint-disable-next-line max-len
+const blockIconURI = 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iNDBweCIgaGVpZ2h0PSI0MHB4IiB2aWV3Qm94PSIwIDAgNDAgNDAiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+CiAgICA8IS0tIEdlbmVyYXRvcjogU2tldGNoIDUwLjIgKDU1MDQ3KSAtIGh0dHA6Ly93d3cuYm9oZW1pYW5jb2RpbmcuY29tL3NrZXRjaCAtLT4KICAgIDx0aXRsZT5ldjMtYmxvY2staWNvbjwvdGl0bGU+CiAgICA8ZGVzYz5DcmVhdGVkIHdpdGggU2tldGNoLjwvZGVzYz4KICAgIDxkZWZzPjwvZGVmcz4KICAgIDxnIGlkPSJldjMtYmxvY2staWNvbiIgc3Ryb2tlPSJub25lIiBzdHJva2Utd2lkdGg9IjEiIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+CiAgICAgICAgPGcgaWQ9ImV2MyIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoNS41MDAwMDAsIDMuNTAwMDAwKSIgZmlsbC1ydWxlPSJub256ZXJvIj4KICAgICAgICAgICAgPHJlY3QgaWQ9IlJlY3RhbmdsZS1wYXRoIiBzdHJva2U9IiM3Qzg3QTUiIGZpbGw9IiNGRkZGRkYiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgeD0iMC41IiB5PSIzLjU5IiB3aWR0aD0iMjgiIGhlaWdodD0iMjUuODEiIHJ4PSIxIj48L3JlY3Q+CiAgICAgICAgICAgIDxyZWN0IGlkPSJSZWN0YW5nbGUtcGF0aCIgc3Ryb2tlPSIjN0M4N0E1IiBmaWxsPSIjRTZFN0U4IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHg9IjIuNSIgeT0iMC41IiB3aWR0aD0iMjQiIGhlaWdodD0iMzIiIHJ4PSIxIj48L3JlY3Q+CiAgICAgICAgICAgIDxyZWN0IGlkPSJSZWN0YW5nbGUtcGF0aCIgc3Ryb2tlPSIjN0M4N0E1IiBmaWxsPSIjRkZGRkZGIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHg9IjIuNSIgeT0iMTQuNSIgd2lkdGg9IjI0IiBoZWlnaHQ9IjEzIj48L3JlY3Q+CiAgICAgICAgICAgIDxwYXRoIGQ9Ik0xNC41LDEwLjUgTDE0LjUsMTQuNSIgaWQ9IlNoYXBlIiBzdHJva2U9IiM3Qzg3QTUiIGZpbGw9IiNFNkU3RTgiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PC9wYXRoPgogICAgICAgICAgICA8cmVjdCBpZD0iUmVjdGFuZ2xlLXBhdGgiIGZpbGw9IiM0MTQ3NTciIHg9IjQuNSIgeT0iMi41IiB3aWR0aD0iMjAiIGhlaWdodD0iMTAiIHJ4PSIxIj48L3JlY3Q+CiAgICAgICAgICAgIDxyZWN0IGlkPSJSZWN0YW5nbGUtcGF0aCIgZmlsbD0iIzdDODdBNSIgb3BhY2l0eT0iMC41IiB4PSIxMy41IiB5PSIyMC4xMyIgd2lkdGg9IjIiIGhlaWdodD0iMiIgcng9IjAuNSI+PC9yZWN0PgogICAgICAgICAgICA8cGF0aCBkPSJNOS4wNiwyMC4xMyBMMTAuNTYsMjAuMTMgQzEwLjgzNjE0MjQsMjAuMTMgMTEuMDYsMjAuMzUzODU3NiAxMS4wNiwyMC42MyBMMTEuMDYsMjEuNjMgQzExLjA2LDIxLjkwNjE0MjQgMTAuODM2MTQyNCwyMi4xMyAxMC41NiwyMi4xMyBMOS4wNiwyMi4xMyBDOC41MDc3MTUyNSwyMi4xMyA4LjA2LDIxLjY4MjI4NDcgOC4wNiwyMS4xMyBDOC4wNiwyMC41Nzc3MTUzIDguNTA3NzE1MjUsMjAuMTMgOS4wNiwyMC4xMyBaIiBpZD0iU2hhcGUiIGZpbGw9IiM3Qzg3QTUiIG9wYWNpdHk9IjAuNSI+PC9wYXRoPgogICAgICAgICAgICA8cGF0aCBkPSJNMTguOTEsMjAuMTMgTDIwLjQyLDIwLjEzIEMyMC42OTYxNDI0LDIwLjEzIDIwLjkyLDIwLjM1Mzg1NzYgMjAuOTIsMjAuNjMgTDIwLjkyLDIxLjYzIEMyMC45MiwyMS45MDYxNDI0IDIwLjY5NjE0MjQsMjIuMTMgMjAuNDIsMjIuMTMgTDE4LjkyLDIyLjEzIEMxOC4zNjc3MTUzLDIyLjEzIDE3LjkyLDIxLjY4MjI4NDcgMTcuOTIsMjEuMTMgQzE3LjkxOTk3MjYsMjAuNTgxNTk3IDE4LjM2MTYyNDUsMjAuMTM1NDg0IDE4LjkxLDIwLjEzIFoiIGlkPSJTaGFwZSIgZmlsbD0iIzdDODdBNSIgb3BhY2l0eT0iMC41IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgxOS40MjAwMDAsIDIxLjEzMDAwMCkgcm90YXRlKC0xODAuMDAwMDAwKSB0cmFuc2xhdGUoLTE5LjQyMDAwMCwgLTIxLjEzMDAwMCkgIj48L3BhdGg+CiAgICAgICAgICAgIDxwYXRoIGQ9Ik04LjIzLDE3LjUgTDUsMTcuNSBDNC43MjM4NTc2MywxNy41IDQuNSwxNy4yNzYxNDI0IDQuNSwxNyBMNC41LDE0LjUgTDEwLjUsMTQuNSBMOC42NSwxNy4yOCBDOC41NTQ2Njk2MSwxNy40MTc5MDgyIDguMzk3NjUwMDYsMTcuNTAwMTU2NiA4LjIzLDE3LjUgWiIgaWQ9IlNoYXBlIiBmaWxsPSIjN0M4N0E1IiBvcGFjaXR5PSIwLjUiPjwvcGF0aD4KICAgICAgICAgICAgPHBhdGggZD0iTTE4LjE1LDE4Ljg1IEwxNy42NSwxOS4zNSBDMTcuNTUyMzQxNiwxOS40NDQwNzU2IDE3LjQ5ODAzMzksMTkuNTc0NDE0MiAxNy41LDE5LjcxIEwxNy41LDIwIEMxNy41LDIwLjI3NjE0MjQgMTcuMjc2MTQyNCwyMC41IDE3LDIwLjUgTDE2LjUsMjAuNSBDMTYuMjIzODU3NiwyMC41IDE2LDIwLjI3NjE0MjQgMTYsMjAgQzE2LDE5LjcyMzg1NzYgMTUuNzc2MTQyNCwxOS41IDE1LjUsMTkuNSBMMTMuNSwxOS41IEMxMy4yMjM4NTc2LDE5LjUgMTMsMTkuNzIzODU3NiAxMywyMCBDMTMsMjAuMjc2MTQyNCAxMi43NzYxNDI0LDIwLjUgMTIuNSwyMC41IEwxMiwyMC41IEMxMS43MjM4NTc2LDIwLjUgMTEuNSwyMC4yNzYxNDI0IDExLjUsMjAgTDExLjUsMTkuNzEgQzExLjUwMTk2NjEsMTkuNTc0NDE0MiAxMS40NDc2NTg0LDE5LjQ0NDA3NTYgMTEuMzUsMTkuMzUgTDEwLjg1LDE4Ljg1IEMxMC42NTgyMTY3LDE4LjY1MjE4NjMgMTAuNjU4MjE2NywxOC4zMzc4MTM3IDEwLjg1LDE4LjE0IEwxMi4zNiwxNi42NSBDMTIuNDUwMjgwMywxNi41NTI4NjE3IDEyLjU3NzM5NjEsMTYuNDk4MzgzNSAxMi43MSwxNi41IEwxNi4yOSwxNi41IEMxNi40MjI2MDM5LDE2LjQ5ODM4MzUgMTYuNTQ5NzE5NywxNi41NTI4NjE3IDE2LjY0LDE2LjY1IEwxOC4xNSwxOC4xNCBDMTguMzQxNzgzMywxOC4zMzc4MTM3IDE4LjM0MTc4MzMsMTguNjUyMTg2MyAxOC4xNSwxOC44NSBaIiBpZD0iU2hhcGUiIGZpbGw9IiM3Qzg3QTUiIG9wYWNpdHk9IjAuNSI+PC9wYXRoPgogICAgICAgICAgICA8cGF0aCBkPSJNMTAuODUsMjMuNDUgTDExLjM1LDIyLjk1IEMxMS40NDc2NTg0LDIyLjg1NTkyNDQgMTEuNTAxOTY2MSwyMi43MjU1ODU4IDExLjUsMjIuNTkgTDExLjUsMjIuMyBDMTEuNSwyMi4wMjM4NTc2IDExLjcyMzg1NzYsMjEuOCAxMiwyMS44IEwxMi41LDIxLjggQzEyLjc3NjE0MjQsMjEuOCAxMywyMi4wMjM4NTc2IDEzLDIyLjMgQzEzLDIyLjU3NjE0MjQgMTMuMjIzODU3NiwyMi44IDEzLjUsMjIuOCBMMTUuNSwyMi44IEMxNS43NzYxNDI0LDIyLjggMTYsMjIuNTc2MTQyNCAxNiwyMi4zIEMxNiwyMi4wMjM4NTc2IDE2LjIyMzg1NzYsMjEuOCAxNi41LDIxLjggTDE3LDIxLjggQzE3LjI3NjE0MjQsMjEuOCAxNy41LDIyLjAyMzg1NzYgMTcuNSwyMi4zIEwxNy41LDIyLjU5IEMxNy40OTgwMzM5LDIyLjcyNTU4NTggMTcuNTUyMzQxNiwyMi44NTU5MjQ0IDE3LjY1LDIyLjk1IEwxOC4xNSwyMy40NSBDMTguMzQwNTcxNCwyMy42NDQ0MjE4IDE4LjM0MDU3MTQsMjMuOTU1NTc4MiAxOC4xNSwyNC4xNSBMMTYuNjQsMjUuNjUgQzE2LjU0OTcxOTcsMjUuNzQ3MTM4MyAxNi40MjI2MDM5LDI1LjgwMTYxNjUgMTYuMjksMjUuOCBMMTIuNzEsMjUuOCBDMTIuNTc3Mzk2MSwyNS44MDE2MTY1IDEyLjQ1MDI4MDMsMjUuNzQ3MTM4MyAxMi4zNiwyNS42NSBMMTAuODUsMjQuMTUgQzEwLjY1OTQyODYsMjMuOTU1NTc4MiAxMC42NTk0Mjg2LDIzLjY0NDQyMTggMTAuODUsMjMuNDUgWiIgaWQ9IlNoYXBlIiBmaWxsPSIjN0M4N0E1IiBvcGFjaXR5PSIwLjUiPjwvcGF0aD4KICAgICAgICAgICAgPHBhdGggZD0iTTIxLjUsMjcuNSBMMjYuNSwyNy41IEwyNi41LDMxLjUgQzI2LjUsMzIuMDUyMjg0NyAyNi4wNTIyODQ3LDMyLjUgMjUuNSwzMi41IEwyMS41LDMyLjUgTDIxLjUsMjcuNSBaIiBpZD0iU2hhcGUiIHN0cm9rZT0iI0NDNEMyMyIgZmlsbD0iI0YxNUEyOSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48L3BhdGg+CiAgICAgICAgPC9nPgogICAgPC9nPgo8L3N2Zz4=';
+
+/**
+ * High-level primitives / constants used by the extension.
+ * @type {object}
+ */
+const BTCommand = {
+    LAYER: 0x00,
+    NUM8: 0x81,
+    NUM16: 0x82,
+    NUM32: 0x83,
+    COAST: 0x0,
+    BRAKE: 0x1,
+    LONGRAMP: 50,
+    STEPSPEED: 0xAE,
+    TIMESPEED: 0xAF,
+    OUTPUTSTOP: 0xA3,
+    OUTPUTRESET: 0xA2,
+    STEPSPEEDSYNC: 0xB0,
+    TIMESPEEDSYNC: 0xB1
+};
+
+/**
+ * Array of accepted motor ports.
+ * @note These should not be translated as they correspond to labels on
+ *       the EV3 hub.
+ * @type {array}
+ */
+const MOTOR_PORTS = [
+    {
+        name: 'A',
+        value: 1
+    },
+    {
+        name: 'B',
+        value: 2
+    },
+    {
+        name: 'C',
+        value: 4
+    },
+    {
+        name: 'D',
+        value: 8
+    }
+];
+
+/**
+ * Array of accepted sensor ports.
+ * @note These should not be translated as they correspond to labels on
+ *       the EV3 hub.
+ * @type {array}
+ */
+const SENSOR_PORTS = [
+    {
+        name: '1',
+        value: 1
+    },
+    {
+        name: '2',
+        value: 2
+    },
+    {
+        name: '3',
+        value: 3
+    },
+    {
+        name: '4',
+        value: 4
+    }
+];
+
+// firmware pdf page 100
+const EV_DEVICE_TYPES = {
+    29: 'color',
+    30: 'ultrasonic',
+    32: 'gyro',
+    16: 'touch',
+    8: 'mediumMotor',
+    7: 'largeMotor',
+    126: 'none'
+};
+
+// firmware pdf page 100?
+const EV_DEVICE_MODES = {
+    touch: 0,
+    color: 1,
+    ultrasonic: 1
+};
+
+const EV_DEVICE_LABELS = {
+    touch: 'button',
+    color: 'brightness',
+    ultrasonic: 'distance'
+};
+
+
+class EV3 {
+
+    constructor (runtime, extensionId) {
+
+        /**
+         * The Scratch 3.0 runtime used to trigger the green flag button.
+         * @type {Runtime}
+         * @private
+         */
+        this._runtime = runtime;
+
+        /**
+         * EV3 State
+         */
+        this.connected = false;
+        this.speed = 50;
+        this._sensors = {
+            distance: 0,
+            brightness: 0
+        };
+        this._motorPositions = {
+            1: 0,
+            2: 0,
+            4: 0,
+            8: 0
+        };
+        this._sensorPorts = [];
+        this._motorPorts = [];
+        this._sensorPortsWaiting = [false, false, false, false];
+        this._motorPortsWaiting = [false, false, false, false];
+        this._pollingIntervalID = null;
+
+        /**
+         * The Bluetooth connection session for reading/writing device data.
+         * @type {BTSession}
+         * @private
+         */
+        this._bt = null;
+        this._runtime.registerExtensionDevice(extensionId, this);
+    }
+
+    // TODO: keep here?
+    /**
+     * Called by the runtime when user wants to scan for a device.
+     */
+    startDeviceScan () {
+        this._bt = new BTSession(this._runtime, {
+            majorDeviceClass: 8,
+            minorDeviceClass: 1
+        }, this._onSessionConnect.bind(this), this._onSessionMessage.bind(this));
+    }
+
+    // TODO: keep here?
+    /**
+     * Called by the runtime when user wants to connect to a certain device.
+     * @param {number} id - the id of the device to connect to.
+     */
+    connectDevice (id) {
+        this._bt.connectDevice(id);
+    }
+
+    // TODO: keep here?
+    /**
+     * Called by the runtime when user wants to disconnect from the device.
+     */
+    disconnectSession () {
+        this._bt.disconnectSession();
+        window.clearInterval(this._pollingIntervalID); // TODO: window?
+        this._sensorPorts = [];
+        this._motorPorts = [];
+    }
+
+    /**
+     * Called by the runtime to detect whether the device is connected.
+     * @return {boolean} - the connected state.
+     */
+    getPeripheralIsConnected () {
+        let connected = false;
+        if (this._bt) {
+            connected = this._bt.getPeripheralIsConnected();
+        }
+        return connected;
+    }
+
+    get distance () {
+        if (!this.connected) return 0;
+
+        // https://shop.lego.com/en-US/EV3-Ultrasonic-Sensor-45504
+        // Measures distances between one and 250 cm (one to 100 in.)
+        // Accurate to +/- 1 cm (+/- .394 in.)
+        let value = this._sensors.distance > 100 ? 100 : this._sensors.distance;
+        value = value < 0 ? 0 : value;
+
+        return Math.round(value);
+    }
+
+    get brightness () {
+        if (!this.connected) return 0;
+
+        return this._sensors.brightness;
+    }
+
+    getMotorPosition (port) {
+        if (!this.connected) return;
+
+        return this._motorPositions[port];
+    }
+
+    isButtonPressed (/* args */) {
+        if (!this.connected) return;
+
+        return this._sensors.button;
+    }
+
+    beep () {
+        if (!this.connected) return;
+
+        this._bt.sendMessage({
+            message: 'DwAAAIAAAJQBgQKC6AOC6AM=',
+            encoding: 'base64'
+        });
+    }
+
+    motorTurnClockwise (port, time) {
+        if (!this.connected) return;
+
+        // Build up motor command
+        const cmd = this._applyPrefix(0, this._motorCommand(
+            BTCommand.TIMESPEED,
+            port,
+            time,
+            this.speed,
+            BTCommand.LONGRAMP
+        ));
+
+        // Send message
+        this._bt.sendMessage({
+            message: Base64Util.arrayBufferToBase64(cmd),
+            encoding: 'base64'
+        });
+
+        // Yield for time
+        return new Promise(resolve => {
+            setTimeout(() => {
+                resolve();
+            }, time);
+        });
+    }
+
+    motorTurnCounterClockwise (port, time) {
+        if (!this.connected) return;
+
+        // Build up motor command
+        const cmd = this._applyPrefix(0, this._motorCommand(
+            BTCommand.TIMESPEED,
+            port,
+            time,
+            this.speed * -1,
+            BTCommand.LONGRAMP
+        ));
+
+        // Send message
+        this._bt.sendMessage({
+            message: Base64Util.arrayBufferToBase64(cmd),
+            encoding: 'base64'
+        });
+
+        // Yield for time
+        return new Promise(resolve => {
+            setTimeout(() => {
+                resolve();
+            }, time);
+        });
+    }
+
+    motorRotate (port, degrees) {
+        if (!this.connected) return;
+
+        // TODO: Build up motor command
+        log.info(`motor rotate port: ${port} and degrees: ${degrees}`);
+    }
+
+    motorSetPosition (port, degrees) {
+        if (!this.connected) return;
+
+        // TODO: Build up motor command
+        log.info(`motor set position port: ${port} and degrees: ${degrees}`);
+    }
+
+    motorSetPower (port, power) {
+        if (!this.connected) return;
+
+        // TODO: Build up motor command
+        log.info(`motor set power port: ${port} and degrees: ${power}`);
+    }
+
+    _applyPrefix (n, cmd) {
+        const len = cmd.length + 5;
+        return [].concat(
+            len & 0xFF,
+            (len >> 8) & 0xFF,
+            0x1,
+            0x0,
+            0x0,
+            n,
+            0x0,
+            cmd
+        );
+    }
+
+    /**
+     * Generate a motor command in EV3 byte array format (CMD, LAYER, PORT,
+     * SPEED, RAMP UP, RUN, RAMP DOWN, BREAKING TYPE)
+     * @param  {string} command Motor command primitive (i.e. "prefix")
+     * @param  {string} port    Port to address
+     * @param  {number} n       Value to be passed to motor command
+     * @param  {number} speed   Speed value
+     * @param  {number} ramp    Ramp value
+     * @return {array}          Byte array
+     */
+    _motorCommand (command, port, n, speed, ramp) {
+        /**
+         * Generate run values for a given input.
+         * @param  {number} run Run input
+         * @return {array}      Run values (byte array)
+         */
+        const getRunValues = function (run) {
+            // If run duration is less than max 16-bit integer
+            if (run < 0x7fff) {
+                return [
+                    BTCommand.NUM16,
+                    run & 0xff,
+                    (run >> 8) & 0xff
+                ];
+            }
+
+            // Run forever
+            return [
+                BTCommand.NUM32,
+                run & 0xff,
+                (run >> 8) & 0xff,
+                (run >> 16) & 0xff,
+                (run >> 24) & 0xff
+            ];
+        };
+
+        // If speed is less than zero, make it positive and multiply the input
+        // value by -1
+        if (speed < 0) {
+            speed = -1 * speed;
+            n = -1 * n;
+        }
+
+        // If the input value is less than 0
+        const dir = (n < 0) ? 0x100 - speed : speed; // step negative or possitive
+        n = Math.abs(n);
+
+        // Setup motor run duration and ramping behavior
+        let rampup = ramp;
+        let rampdown = ramp;
+        let run = n - (ramp * 2);
+        if (run < 0) {
+            rampup = Math.floor(n / 2);
+            run = 0;
+            rampdown = n - rampup;
+        }
+
+        // Generate motor command
+        const runcmd = getRunValues(run);
+        return [
+            command,
+            BTCommand.LAYER,
+            port,
+            BTCommand.NUM8,
+            dir & 0xff,
+            BTCommand.NUM8,
+            rampup
+        ].concat(runcmd.concat([
+            BTCommand.NUM8,
+            rampdown,
+            BTCommand.BRAKE
+        ]));
+    }
+
+    _onSessionConnect () {
+        this.connected = true;
+
+        // GET EV3 SENSOR LIST
+        /*
+        0B [ 11]
+        00 [  0]
+        01 [  1]
+        00 [  0]
+        00 [  0]
+        21 [ 33]
+        00 [  0]
+        98 [152] opInput_Device_List
+        81 [129] LENGTH
+        21 [ 33] ARRAY
+        60 [ 96] CHANGED
+        E1 [225] size of global var?
+        20 [ 32] global var index
+        */
+        this._bt.sendMessage({
+            message: 'CwABAAAhAJiBIWDhIA==', // [11, 0, 1, 0, 0, 33, 0, 152, 129, 33, 96, 225, 32]
+            encoding: 'base64'
+        }).then(
+            x => {
+                log.info(`get device list resolved: ${x}`);
+            },
+            e => {
+                log.info(`get device list rejected: ${e}`);
+            }
+        );
+    }
+
+    _getSessionData () {
+        if (!this.connected) {
+            window.clearInterval(this._pollingIntervalID);
+            return;
+        }
+
+        // GET EV3 DISTANCE PORT 0
+        /*
+        99 [153] input device
+        1D [ 29] ready si
+        00 [  0] layer (this brick)
+        00 [  0] sensor port 0
+        00 [  0] do not change type
+        01 [  1] mode 1 = EV3-Ultrasonic-Inch
+        01 [  1] one data set
+        60 [ 96] global var index
+        */
+        /*
+        this._bt.sendMessage({
+            message: 'DQAAAAAEAJkdAAAAAQFg', // [13, 0, 0, 0, 0, 4, 0, 153, 29, 0, 0, 0, 1, 1, 96]
+            encoding: 'base64'
+        });
+        */
+
+        // GET EV3 BRIGHTNESS PORT 1
+        /*
+        0x99 [153] input device
+        0x1D [ 29] ready si
+        0x00 [  0] layer (this brick)
+        0x01 [  1] sensor port 1
+        0x00 [  0] do not change type
+        0x01 [  1] mode 1 = EV3-Color-Ambient
+        0x01 [  1] one data set
+        0x60 [ 96] global var index
+        */
+        /*
+        this._bt.sendMessage({
+            message: 'DQAAAAAEAJkdAAEAAQFg', // [13, 0, 0, 0, 0, 4, 0, 153, 29, 0, 1, 0, 1, 1, 96]
+            encoding: 'base64'
+        });
+        */
+
+
+        // COMPOUND COMMAND FOR READING sensors0x27   command size
+        // 0x??  [    ]   command size
+        // 0x00  [   0]   command size
+        // 0x01  [   1]   message counter
+        // 0x00  [   0]   message counter
+        // 0x00  [   0]   command type
+        // 0x??  [    ]   result payload size of global/local vars
+        // 0x00  [   0]   result payload size of global/local vars
+        const compoundCommand = [];
+        compoundCommand[0] = 0; // calculate length later
+        compoundCommand[1] = 0; // command size
+        compoundCommand[2] = 1; // message counter // TODO: ?????
+        compoundCommand[3] = 0; // message counter
+        compoundCommand[4] = 0; // command type: direct command
+        compoundCommand[5] = 0; // global/local vars
+        compoundCommand[6] = 0; // global/local vars
+        let compoundCommandIndex = 7;
+        let sensorCount = -1;
+
+        // Read from available sensors
+        for (let i = 0; i < this._sensorPorts.length; i++) {
+            if (this._sensorPorts[i] !== 'none') {
+                sensorCount++;
+                // make up sensor command array
+                // 0x9D  [ 157]   op: get sensor value
+                // 0x00  [   0]   layer
+                // 0x02  [    ]   port
+                // 0x00  [   0]   do not change type
+                // 0x00  [    ]   mode
+                // 0xE1  [ 225]
+                // 0x0C  [    ]   global index
+                compoundCommand[compoundCommandIndex + 0] = 157;
+                compoundCommand[compoundCommandIndex + 1] = 0;
+                compoundCommand[compoundCommandIndex + 2] = i;
+                compoundCommand[compoundCommandIndex + 3] = 0;
+                compoundCommand[compoundCommandIndex + 4] = EV_DEVICE_MODES[this._sensorPorts[i]];
+                compoundCommand[compoundCommandIndex + 5] = 225;
+                compoundCommand[compoundCommandIndex + 6] = sensorCount * 4;
+                compoundCommandIndex += 7;
+            }
+        }
+        // Read from available motors
+        // let motorCount = 0;
+        for (let i = 0; i < this._motorPorts.length; i++) {
+            if (this._motorPorts[i] !== 'none') {
+                sensorCount++;
+                // make up sensor command array
+                // 0xB3  [ 179]   op: get motor position value
+                // 0x00  [   0]   layer
+                // 0x02  [    ]   output bit fields ??
+                // 0xE1  [ 225]
+                // 0x??  [   0]   global index
+                compoundCommand[compoundCommandIndex + 0] = 179;
+                compoundCommand[compoundCommandIndex + 1] = 0;
+                compoundCommand[compoundCommandIndex + 2] = i;
+                compoundCommand[compoundCommandIndex + 3] = 225;
+                compoundCommand[compoundCommandIndex + 4] = sensorCount * 4;
+                compoundCommandIndex += 5;
+                // motorCount++;
+            }
+        }
+
+
+        // Calculate compound command length
+        compoundCommand[0] = compoundCommand.length - 2;
+        // Calculate global var payload length needed
+        compoundCommand[5] = (sensorCount + 1) * 4;
+        // console.log('compound command to send: ' + compoundCommand);
+        this._bt.sendMessage({
+            message: Base64Util.uint8ArrayToBase64(compoundCommand),
+            encoding: 'base64'
+        });
+
+        // TODO: Read from available motor ports
+    }
+
+    _onSessionMessage (params) {
+        const message = params.message;
+        const array = Base64Util.base64ToUint8Array(message);
+
+        if (this._sensorPorts.length === 0) {
+            // SENSOR LIST
+            // JAABAAIefn5+fn5+fn5+fn5+fn5+Bwd+fn5+fn5+fn5+fn5+fgA=
+            log.info(`device array: ${array}`);
+            this._sensorPorts[0] = EV_DEVICE_TYPES[array[5]];
+            this._sensorPorts[1] = EV_DEVICE_TYPES[array[6]];
+            this._sensorPorts[2] = EV_DEVICE_TYPES[array[7]];
+            this._sensorPorts[3] = EV_DEVICE_TYPES[array[8]];
+            this._motorPorts[0] = EV_DEVICE_TYPES[array[21]];
+            this._motorPorts[1] = EV_DEVICE_TYPES[array[22]];
+            this._motorPorts[2] = EV_DEVICE_TYPES[array[23]];
+            this._motorPorts[3] = EV_DEVICE_TYPES[array[24]];
+            log.info(`sensor ports: ${this._sensorPorts}`);
+            log.info(`motor ports: ${this._motorPorts}`);
+
+            // Now ready to read from assigned _sensors
+            // Start reading sensor data
+            // TODO: window?
+            this._pollingIntervalID = window.setInterval(this._getSessionData.bind(this), 100);
+        } else {
+            // log.info(`received compound command result: ${array}`);
+            let offset = 5;
+            for (let i = 0; i < this._sensorPorts.length; i++) {
+                if (this._sensorPorts[i] !== 'none') {
+                    const value = this._array2float([
+                        array[offset],
+                        array[offset + 1],
+                        array[offset + 2],
+                        array[offset + 3]
+                    ]);
+                    log.info(`sensor at port ${i} ${this._sensorPorts[i]} value: ${value}`);
+                    this._sensors[EV_DEVICE_LABELS[this._sensorPorts[i]]] = value;
+                    offset += 4;
+                }
+            }
+            for (let i = 0; i < this._motorPorts.length; i++) {
+                if (this._motorPorts[i] !== 'none') {
+                    let value = this._tachoValue([
+                        array[offset],
+                        array[offset + 1],
+                        array[offset + 2],
+                        array[offset + 3]
+                    ]);
+                    if (value > 0x7fffffff) {
+                        value = value - 0x100000000;
+                    }
+                    log.info(`motor at port ${i} ${this._motorPorts[i]} value: ${value}`);
+                    this._motorPositions[MOTOR_PORTS[i].value] = value;
+                    offset += 4;
+                }
+            }
+            // const sensorValue = this._array2float([array[5], array[6], array[7], array[8]]);
+            // log.info('receiving port array?: ' + array);
+            // log.info('receiving port sensorValue?: ' + sensorValue);
+            // this._sensors.distance = distance;
+        }
+
+    }
+
+    _tachoValue (list) {
+        const value = list[0] + (list[1] * 256) + (list[2] * 256 * 256) + (list[3] * 256 * 256 * 256);
+        return value;
+    }
+
+    // TODO: put elsewhere
+    _array2float (list) {
+        const buffer = new Uint8Array(list).buffer;
+        const view = new DataView(buffer);
+        return view.getFloat32(0, true);
+    }
+
+}
+
+class Scratch3Ev3Blocks {
+
+    /**
+     * The ID of the extension.
+     * @return {string} the id
+     */
+    static get EXTENSION_ID () {
+        return 'ev3';
+    }
+
+    /**
+     * Creates a new instance of the EV3 extension.
+     * @param  {object} runtime VM runtime
+     * @constructor
+     */
+    constructor (runtime) {
+        /**
+         * The Scratch 3.0 runtime.
+         * @type {Runtime}
+         */
+        this.runtime = runtime;
+
+        // Create a new EV3 device instance
+        this._device = new EV3(this.runtime, Scratch3Ev3Blocks.EXTENSION_ID);
+    }
+
+    /**
+     * Define the EV3 extension.
+     * @return {object} Extension description.
+     */
+    getInfo () {
+        return {
+            id: Scratch3Ev3Blocks.EXTENSION_ID,
+            name: 'LEGO MINDSTORMS EV3',
+            blockIconURI: blockIconURI,
+            showStatusButton: true,
+            blocks: [
+                {
+                    opcode: 'motorTurnClockwise',
+                    text: 'motor [PORT] turn clockwise for [TIME] seconds',
+                    blockType: BlockType.COMMAND,
+                    arguments: {
+                        PORT: {
+                            type: ArgumentType.STRING,
+                            menu: 'motorPorts',
+                            defaultValue: MOTOR_PORTS[0].value
+                        },
+                        TIME: {
+                            type: ArgumentType.NUMBER,
+                            defaultValue: 1
+                        }
+                    }
+                },
+                {
+                    opcode: 'motorTurnCounterClockwise',
+                    text: 'motor [PORT] turn counter for [TIME] seconds',
+                    blockType: BlockType.COMMAND,
+                    arguments: {
+                        PORT: {
+                            type: ArgumentType.STRING,
+                            menu: 'motorPorts',
+                            defaultValue: MOTOR_PORTS[0].value
+                        },
+                        TIME: {
+                            type: ArgumentType.NUMBER,
+                            defaultValue: 1
+                        }
+                    }
+                },
+                {
+                    opcode: 'motorRotate',
+                    text: 'motor [PORT] rotate [DEGREES] degrees',
+                    blockType: BlockType.COMMAND,
+                    arguments: {
+                        PORT: {
+                            type: ArgumentType.STRING,
+                            menu: 'motorPorts',
+                            defaultValue: MOTOR_PORTS[0].value
+                        },
+                        DEGREES: {
+                            type: ArgumentType.NUMBER,
+                            defaultValue: 90
+                        }
+                    }
+                },
+                {
+                    opcode: 'motorSetPosition',
+                    text: 'motor [PORT] set position [DEGREES] degrees',
+                    blockType: BlockType.COMMAND,
+                    arguments: {
+                        PORT: {
+                            type: ArgumentType.STRING,
+                            menu: 'motorPorts',
+                            defaultValue: MOTOR_PORTS[0].value
+                        },
+                        DEGREES: {
+                            type: ArgumentType.NUMBER,
+                            defaultValue: 90
+                        }
+                    }
+                },
+                {
+                    opcode: 'motorSetPower',
+                    text: 'motor [PORT] set power [POWER] %',
+                    blockType: BlockType.COMMAND,
+                    arguments: {
+                        PORT: {
+                            type: ArgumentType.STRING,
+                            menu: 'motorPorts',
+                            defaultValue: MOTOR_PORTS[0].value
+                        },
+                        POWER: {
+                            type: ArgumentType.NUMBER,
+                            defaultValue: 50
+                        }
+                    }
+                },
+                {
+                    opcode: 'getMotorPosition',
+                    text: 'motor [PORT] position',
+                    blockType: BlockType.REPORTER,
+                    arguments: {
+                        PORT: {
+                            type: ArgumentType.STRING,
+                            menu: 'motorPorts',
+                            defaultValue: MOTOR_PORTS[0].value
+                        }
+                    }
+                },
+                {
+                    opcode: 'whenButtonPressed',
+                    text: 'when button [PORT] pressed',
+                    blockType: BlockType.HAT,
+                    arguments: {
+                        PORT: {
+                            type: ArgumentType.STRING,
+                            menu: 'sensorPorts',
+                            defaultValue: SENSOR_PORTS[0].value
+                        }
+                    }
+                },
+                {
+                    opcode: 'whenDistanceLessThan',
+                    text: 'when distance < [DISTANCE]',
+                    blockType: BlockType.HAT,
+                    arguments: {
+                        DISTANCE: {
+                            type: ArgumentType.NUMBER,
+                            defaultValue: 5
+                        }
+                    }
+                },
+                {
+                    opcode: 'whenBrightnessLessThan',
+                    text: 'when brightness < [DISTANCE]',
+                    blockType: BlockType.HAT,
+                    arguments: {
+                        DISTANCE: {
+                            type: ArgumentType.NUMBER,
+                            defaultValue: 50
+                        }
+                    }
+                },
+                {
+                    opcode: 'buttonPressed',
+                    text: 'button [PORT] pressed?',
+                    blockType: BlockType.BOOLEAN,
+                    arguments: {
+                        PORT: {
+                            type: ArgumentType.STRING,
+                            menu: 'sensorPorts',
+                            defaultValue: SENSOR_PORTS[0].value
+                        }
+                    }
+                },
+                {
+                    opcode: 'getDistance',
+                    text: 'distance',
+                    blockType: BlockType.REPORTER
+                },
+                {
+                    opcode: 'getBrightness',
+                    text: 'brightness',
+                    blockType: BlockType.REPORTER
+                },
+                {
+                    opcode: 'beep',
+                    text: 'beep',
+                    blockType: BlockType.COMMAND
+                }
+            ],
+            menus: {
+                motorPorts: this._buildMenu(MOTOR_PORTS),
+                sensorPorts: this._buildMenu(SENSOR_PORTS)
+            }
+        };
+    }
+
+    /**
+     * Create data for a menu in scratch-blocks format, consisting of an array of objects with text and
+     * value properties. The text is a translated string, and the value is one-indexed.
+     * @param  {object[]} info - An array of info objects each having a name property.
+     * @return {array} - An array of objects with text and value properties.
+     * @private
+     */
+    _buildMenu (info) {
+        return info.map((entry, index) => {
+            const obj = {};
+            obj.text = entry.name;
+            obj.value = String(index + 1);
+            return obj;
+        });
+    }
+
+    motorTurnClockwise (args) {
+        const port = Cast.toNumber(args.PORT);
+        const time = Cast.toNumber(args.TIME) * 1000;
+
+        return this._device.motorTurnClockwise(port, time);
+    }
+
+    motorTurnCounterClockwise (args) {
+        const port = Cast.toNumber(args.PORT);
+        const time = Cast.toNumber(args.TIME) * 1000;
+
+        return this._device.motorTurnCounterClockwise(port, time);
+    }
+
+    motorRotate (args) {
+        const port = Cast.toNumber(args.PORT);
+        const degrees = Cast.toNumber(args.DEGREES);
+
+        this._device.motorRotate(port, degrees);
+        return;
+    }
+
+    motorSetPosition (args) {
+        const port = Cast.toNumber(args.PORT);
+        const degrees = Cast.toNumber(args.DEGREES);
+
+        this._device.motorSetPosition(port, degrees);
+        return;
+    }
+
+    motorSetPower (args) {
+        const port = Cast.toNumber(args.PORT);
+        const power = Cast.toNumber(args.POWER);
+
+        this._device.motorSetPower(port, power);
+        return;
+    }
+
+    getMotorPosition (args) {
+        const port = Cast.toNumber(args.PORT);
+
+        return this._device.getMotorPosition(port);
+    }
+
+    whenButtonPressed (args) {
+        const port = Cast.toNumber(args.PORT);
+
+        return this._device.isButtonPressed(port);
+    }
+
+    whenDistanceLessThan (args) {
+        const distance = Cast.toNumber(args.DISTANCE);
+
+        return this._device.distance < distance;
+    }
+
+    whenBrightnessLessThan (args) {
+        const brightness = Cast.toNumber(args.DISTANCE);
+
+        return this._device.brightness < brightness;
+    }
+
+    buttonPressed (args) {
+        const port = Cast.toNumber(args.PORT);
+
+        return this._device.isButtonPressed(port);
+    }
+
+    getDistance () {
+        return this._device.distance;
+    }
+
+    getBrightness () {
+        return this._device.brightness;
+    }
+
+    beep () {
+        return this._device.beep();
+    }
+}
+
+module.exports = Scratch3Ev3Blocks;
diff --git a/src/extensions/scratch3_microbit/index.js b/src/extensions/scratch3_microbit/index.js
index a121b43dc..e9ad6bab7 100644
--- a/src/extensions/scratch3_microbit/index.js
+++ b/src/extensions/scratch3_microbit/index.js
@@ -1,7 +1,7 @@
 const ArgumentType = require('../../extension-support/argument-type');
 const BlockType = require('../../extension-support/block-type');
 const log = require('../../util/log');
-const ScratchBLE = require('../../io/scratchBLE');
+const BLESession = require('../../io/bleSession');
 const Base64Util = require('../../util/base64-util');
 
 /**
@@ -50,8 +50,9 @@ class MicroBit {
     /**
      * Construct a MicroBit communication object.
      * @param {Runtime} runtime - the Scratch 3.0 runtime
+     * @param {string} extensionId - the id of the extension
      */
-    constructor (runtime) {
+    constructor (runtime, extensionId) {
 
         /**
          * The Scratch 3.0 runtime used to trigger the green flag button.
@@ -61,11 +62,12 @@ class MicroBit {
         this._runtime = runtime;
 
         /**
-         * The ScratchBLE connection session for reading/writing device data.
-         * @type {ScratchBLE}
+         * The BluetoothLowEnergy connection session for reading/writing device data.
+         * @type {BLESession}
          * @private
          */
-        this._ble = new ScratchBLE();
+        this._ble = null;
+        this._runtime.registerExtensionDevice(extensionId, this);
 
         /**
          * The most recently received value for each sensor.
@@ -102,33 +104,60 @@ class MicroBit {
                 timeout: false
             }
         };
+    }
 
-        // TODO: Temporary until the gui requests a device connection
-        this._ble.waitForSocket()
-            // TODO: remove pinging once no longer needed
-            .then(() => this._ble.sendRemoteRequest('pingMe'))
-            .then(() => this._onBLEReady());
+    // TODO: keep here?
+    /**
+     * Called by the runtime when user wants to scan for a device.
+     */
+    startDeviceScan () {
+        log.info('making a new BLE session');
+        this._ble = new BLESession(this._runtime, {
+            filters: [
+                {services: [BLEUUID.service]}
+            ]
+        }, this._onSessionConnect.bind(this));
+    }
 
-        // TODO: Add ScratchBLE 'disconnect' handling
+    // TODO: keep here?
+    /**
+     * Called by the runtime when user wants to connect to a certain device.
+     * @param {number} id - the id of the device to connect to.
+     */
+    connectDevice (id) {
+        this._ble.connectDevice(id);
+    }
 
+    disconnectSession () {
+        this._ble.disconnectSession();
+    }
+
+    getPeripheralIsConnected () {
+        let connected = false;
+        if (this._ble) {
+            connected = this._ble.getPeripheralIsConnected();
+        }
+        return connected;
     }
 
     /**
      * @param {string} text - the text to display.
+     * @return {Promise} - a Promise that resolves when writing to device.
      */
     displayText (text) {
         const output = new Uint8Array(text.length);
         for (let i = 0; i < text.length; i++) {
             output[i] = text.charCodeAt(i);
         }
-        this._writeBLE(BLECommand.CMD_DISPLAY_TEXT, output);
+        return this._writeSessionData(BLECommand.CMD_DISPLAY_TEXT, output);
     }
 
     /**
      * @param {Uint8Array} matrix - the matrix to display.
+     * @return {Promise} - a Promise that resolves when writing to device.
      */
     displayMatrix (matrix) {
-        this._writeBLE(BLECommand.CMD_DISPLAY_LED, matrix);
+        return this._writeSessionData(BLECommand.CMD_DISPLAY_LED, matrix);
     }
 
     /**
@@ -181,38 +210,20 @@ class MicroBit {
         return this._sensors.touchPins[pin];
     }
 
-    /**
-     * Requests connection to a device when BLE session is ready.
-     */
-    _onBLEReady () {
-        this._ble.requestDevice({
-            filters: [
-                {services: [BLEUUID.service]}
-            ]
-        }, this._onBLEConnect.bind(this), this._onBLEError);
-    }
-
     /**
      * Starts reading data from device after BLE has connected to it.
      */
-    _onBLEConnect () {
-        const callback = this._processBLEData.bind(this);
+    _onSessionConnect () {
+        const callback = this._processSessionData.bind(this);
         this._ble.read(BLEUUID.service, BLEUUID.rxChar, true, callback);
     }
 
-    /**
-     * @param {string} e - Error from BLE session.
-     */
-    _onBLEError (e) {
-        log.error(`BLE error: ${e}`);
-    }
-
     /**
      * Process the sensor data from the incoming BLE characteristic.
      * @param {object} base64 - the incoming BLE data.
      * @private
      */
-    _processBLEData (base64) {
+    _processSessionData (base64) {
         const data = Base64Util.base64ToUint8Array(base64);
 
         this._sensors.tiltX = data[1] | (data[0] << 8);
@@ -234,16 +245,17 @@ class MicroBit {
      * Write a message to the device BLE session.
      * @param {number} command - the BLE command hex.
      * @param {Uint8Array} message - the message to write.
+     * @return {Promise} - a Promise that resolves when writing to device.
      * @private
      */
-    _writeBLE (command, message) {
+    _writeSessionData (command, message) {
         const output = new Uint8Array(message.length + 1);
         output[0] = command; // attach command to beginning of message
         for (let i = 0; i < message.length; i++) {
             output[i + 1] = message[i];
         }
-        const b64enc = Base64Util.uint8ArrayToBase64(output);
-        this._ble.write(BLEUUID.service, BLEUUID.txChar, b64enc, 'base64');
+        const data = Base64Util.uint8ArrayToBase64(output);
+        return this._ble.write(BLEUUID.service, BLEUUID.txChar, data, 'base64');
     }
 }
 
@@ -318,7 +330,7 @@ class Scratch3MicroBitBlocks {
         this.runtime = runtime;
 
         // Create a new MicroBit device instance
-        this._device = new MicroBit(this.runtime);
+        this._device = new MicroBit(this.runtime, Scratch3MicroBitBlocks.EXTENSION_ID);
     }
 
     /**
@@ -516,17 +528,18 @@ class Scratch3MicroBitBlocks {
     /**
      * Display text on the 5x5 LED matrix.
      * @param {object} args - the block's arguments.
+     * @return {Promise} - a Promise that resolves when writing to device.
      * Note the limit is 19 characters
      */
     displayText (args) {
         const text = String(args.TEXT).substring(0, 19);
-        this._device.displayText(text);
-        return;
+        return this._device.displayText(text);
     }
 
     /**
      * Display a predefined symbol on the 5x5 LED matrix.
      * @param {object} args - the block's arguments.
+     * @return {Promise} - a Promise that resolves when writing to device.
      */
     displaySymbol (args) {
         const hex = symbols2hex[args.SYMBOL];
@@ -536,8 +549,7 @@ class Scratch3MicroBitBlocks {
         this._device.ledMatrixState[2] = (hex >> 10) & 0x1F;
         this._device.ledMatrixState[3] = (hex >> 5) & 0x1F;
         this._device.ledMatrixState[4] = hex & 0x1F;
-        this._device.displayMatrix(this._device.ledMatrixState);
-        return;
+        return this._device.displayMatrix(this._device.ledMatrixState);
     }
 
     /**
diff --git a/src/io/bleSession.js b/src/io/bleSession.js
new file mode 100644
index 000000000..1596c721a
--- /dev/null
+++ b/src/io/bleSession.js
@@ -0,0 +1,146 @@
+const JSONRPCWebSocket = require('../util/jsonrpc-web-socket');
+const log = require('../util/log');
+const ScratchLinkWebSocket = 'ws://localhost:20110/scratch/ble';
+
+class BLESession extends JSONRPCWebSocket {
+
+    /**
+     * A BLE device session object.  It handles connecting, over web sockets, to
+     * BLE devices, and reading and writing data to them.
+     * @param {Runtime} runtime - the Runtime for sending/receiving GUI update events.
+     * @param {object} deviceOptions - the list of options for device discovery.
+     * @param {object} connectCallback - a callback for connection.
+     */
+    constructor (runtime, deviceOptions, connectCallback) {
+        const ws = new WebSocket(ScratchLinkWebSocket);
+        super(ws);
+
+        this._ws = ws;
+        this._ws.onopen = this.requestDevice.bind(this); // only call request device after socket opens
+        this._ws.onerror = this._sendError.bind(this, 'ws onerror');
+        this._ws.onclose = this._sendError.bind(this, 'ws onclose');
+
+        this._availablePeripherals = {};
+        this._connectCallback = connectCallback;
+        this._characteristicDidChangeCallback = null;
+        this._deviceOptions = deviceOptions;
+        this._runtime = runtime;
+
+        this._connected = false;
+    }
+
+    /**
+     * Request connection to the device.
+     * If the web socket is not yet open, request when the socket promise resolves.
+     */
+    requestDevice () {
+        if (this._ws.readyState === 1) { // is this needed since it's only called on ws.onopen?
+            // TODO: start a 'discover' timeout
+            this.sendRemoteRequest('discover', this._deviceOptions)
+                .catch(e => this._sendError(e)); // never reached?
+        }
+        // TODO: else?
+    }
+
+    /**
+     * Try connecting to the input peripheral id, and then call the connect
+     * callback if connection is successful.
+     * @param {number} id - the id of the peripheral to connect to
+     */
+    connectDevice (id) {
+        this.sendRemoteRequest('connect', {peripheralId: id})
+            .then(() => {
+                log.info('should have connected');
+                this._runtime.emit(this._runtime.constructor.PERIPHERAL_CONNECTED);
+                this._connected = true;
+                this._connectCallback();
+            })
+            .catch(e => {
+                this._sendError(e);
+            });
+    }
+
+    /**
+     * Close the websocket.
+     */
+    disconnectSession () {
+        this._ws.close();
+        this._connected = false;
+    }
+
+    /**
+     * @return {bool} whether the peripheral is connected.
+     */
+    getPeripheralIsConnected () {
+        return this._connected;
+    }
+
+    /**
+     * Handle a received call from the socket.
+     * @param {string} method - a received method label.
+     * @param {object} params - a received list of parameters.
+     * @return {object} - optional return value.
+     */
+    didReceiveCall (method, params) {
+        switch (method) {
+        case 'didDiscoverPeripheral':
+            this._availablePeripherals[params.peripheralId] = params;
+            this._runtime.emit(
+                this._runtime.constructor.PERIPHERAL_LIST_UPDATE,
+                this._availablePeripherals
+            );
+            // TODO: cancel a discover timeout if one is active
+            break;
+        case 'characteristicDidChange':
+            this._characteristicDidChangeCallback(params.message);
+            break;
+        case 'ping':
+            return 42;
+        }
+    }
+
+    /**
+     * Start reading from the specified ble service.
+     * @param {number} serviceId - the ble service to read.
+     * @param {number} characteristicId - the ble characteristic to read.
+     * @param {boolean} optStartNotifications - whether to start receiving characteristic change notifications.
+     * @param {object} onCharacteristicChanged - callback for characteristic change notifications.
+     * @return {Promise} - a promise from the remote read request.
+     */
+    read (serviceId, characteristicId, optStartNotifications = false, onCharacteristicChanged) {
+        const params = {
+            serviceId,
+            characteristicId
+        };
+        if (optStartNotifications) {
+            params.startNotifications = true;
+        }
+        this._characteristicDidChangeCallback = onCharacteristicChanged;
+        return this.sendRemoteRequest('read', params);
+        // TODO: handle error here
+    }
+
+    /**
+     * Write data to the specified ble service.
+     * @param {number} serviceId - the ble service to write.
+     * @param {number} characteristicId - the ble characteristic to write.
+     * @param {string} message - the message to send.
+     * @param {string} encoding - the message encoding type.
+     * @return {Promise} - a promise from the remote send request.
+     */
+    write (serviceId, characteristicId, message, encoding = null) {
+        const params = {serviceId, characteristicId, message};
+        if (encoding) {
+            params.encoding = encoding;
+        }
+        return this.sendRemoteRequest('write', params);
+    }
+
+    _sendError (e) {
+        this._connected = false;
+        log.error(`BLESession error: ${JSON.stringify(e)}`);
+        this._runtime.emit(this._runtime.constructor.PERIPHERAL_ERROR);
+    }
+}
+
+module.exports = BLESession;
diff --git a/src/io/btSession.js b/src/io/btSession.js
new file mode 100644
index 000000000..bf847296e
--- /dev/null
+++ b/src/io/btSession.js
@@ -0,0 +1,116 @@
+const JSONRPCWebSocket = require('../util/jsonrpc-web-socket');
+const log = require('../util/log');
+const ScratchLinkWebSocket = 'ws://localhost:20110/scratch/bt';
+
+class BTSession extends JSONRPCWebSocket {
+
+    /**
+     * A BT device session object.  It handles connecting, over web sockets, to
+     * BT devices, and reading and writing data to them.
+     * @param {Runtime} runtime - the Runtime for sending/receiving GUI update events.
+     * @param {object} deviceOptions - the list of options for device discovery.
+     * @param {object} connectCallback - a callback for connection.
+     * @param {object} messageCallback - a callback for message sending.
+     */
+    constructor (runtime, deviceOptions, connectCallback, messageCallback) {
+        const ws = new WebSocket(ScratchLinkWebSocket);
+        super(ws);
+
+        this._ws = ws;
+        this._ws.onopen = this.requestDevice.bind(this); // only call request device after socket opens
+        this._ws.onerror = this._sendError.bind(this, 'ws onerror');
+        this._ws.onclose = this._sendError.bind(this, 'ws onclose');
+
+        this._availablePeripherals = {};
+        this._connectCallback = connectCallback;
+        this._characteristicDidChangeCallback = null;
+        this._deviceOptions = deviceOptions;
+        this._messageCallback = messageCallback;
+        this._runtime = runtime;
+
+        this._connected = false;
+    }
+
+    /**
+     * Request connection to the device.
+     * If the web socket is not yet open, request when the socket promise resolves.
+     */
+    requestDevice () {
+        if (this._ws.readyState === 1) { // is this needed since it's only called on ws.onopen?
+            // TODO: start a 'discover' timeout
+            this.sendRemoteRequest('discover', this._deviceOptions)
+                .catch(e => this._sendError(e)); // never reached?
+        }
+        // TODO: else?
+    }
+
+    /**
+     * Try connecting to the input peripheral id, and then call the connect
+     * callback if connection is successful.
+     * @param {number} id - the id of the peripheral to connect to
+     */
+    connectDevice (id) {
+        this.sendRemoteRequest('connect', {peripheralId: id})
+            .then(() => {
+                log.info('should have connected');
+                this._runtime.emit(this._runtime.constructor.PERIPHERAL_CONNECTED);
+                this._connected = true;
+                this._connectCallback();
+            })
+            .catch(e => {
+                this._sendError(e);
+            });
+    }
+
+    /**
+     * Close the websocket.
+     */
+    disconnectSession () {
+        this._ws.close();
+        this._connected = false;
+    }
+
+    /**
+     * @return {bool} whether the peripheral is connected.
+     */
+    getPeripheralIsConnected () {
+        return this._connected;
+    }
+
+
+    sendMessage (options) {
+        return this.sendRemoteRequest('send', options);
+    }
+
+    /**
+     * Handle a received call from the socket.
+     * @param {string} method - a received method label.
+     * @param {object} params - a received list of parameters.
+     * @return {object} - optional return value.
+     */
+    didReceiveCall (method, params) {
+        // TODO: Add peripheral 'undiscover' handling
+        switch (method) {
+        case 'didDiscoverPeripheral':
+            this._availablePeripherals[params.peripheralId] = params;
+            this._runtime.emit(
+                this._runtime.constructor.PERIPHERAL_LIST_UPDATE,
+                this._availablePeripherals
+            );
+            // TODO: cancel a discover timeout if one is active
+            break;
+        case 'didReceiveMessage':
+            this._messageCallback(params); // TODO: refine?
+            break;
+        default:
+            return 'nah';
+        }
+    }
+
+    _sendError (e) {
+        log.error(`BTSession error: ${JSON.stringify(e)}`);
+        this._runtime.emit(this._runtime.constructor.PERIPHERAL_ERROR);
+    }
+}
+
+module.exports = BTSession;
diff --git a/src/io/peripheralChooser.js b/src/io/peripheralChooser.js
deleted file mode 100644
index 53d0a50bc..000000000
--- a/src/io/peripheralChooser.js
+++ /dev/null
@@ -1,38 +0,0 @@
-class PeripheralChooser {
-
-    get chosenPeripheralId () {
-        return this._chosenPeripheralId;
-    }
-
-    constructor () {
-        this._availablePeripherals = []; // TODO for use in gui?
-        this._chosenPeripheralId = null;
-    }
-
-    /**
-     * Launches a GUI menu to choose a peripheral.
-     * @return {Promise} - chosen peripheral promise.
-     */
-    choosePeripheral () {
-        return new Promise((resolve, reject) => {
-            // TODO: Temporary: should launch gui instead.
-            this._tempPeripheralChosenCallback = resolve;
-            this._tempPeripheralChosenReject = reject;
-        });
-    }
-
-    /**
-     * Adds the peripheral ID to list of available peripherals.
-     * @param {number} peripheralId - the id to add.
-     */
-    addPeripheral (peripheralId) {
-        this._availablePeripherals.push(peripheralId);
-
-        // TODO: Temporary: calls chosen callback on whatever peripherals are added.
-        this._chosenPeripheralId = this._availablePeripherals[0];
-        this._tempPeripheralChosenCallback(this._chosenPeripheralId);
-    }
-
-}
-
-module.exports = PeripheralChooser;
diff --git a/src/io/scratchBLE.js b/src/io/scratchBLE.js
deleted file mode 100644
index 36bd9a7da..000000000
--- a/src/io/scratchBLE.js
+++ /dev/null
@@ -1,104 +0,0 @@
-const JSONRPCWebSocket = require('../util/jsonrpc-web-socket');
-const PeripheralChooser = require('./peripheralChooser');
-
-const ScratchLinkWebSocket = 'ws://localhost:20110/scratch/ble';
-
-class ScratchBLE extends JSONRPCWebSocket {
-    constructor () {
-        const ws = new WebSocket(ScratchLinkWebSocket);
-
-        super(ws);
-
-        this._ws = ws;
-        this.peripheralChooser = new PeripheralChooser(); // TODO: finalize gui connection
-        this._characteristicDidChange = null;
-    }
-
-    /**
-     * Returns a promise for when the web socket opens.
-     * @return {Promise} - a promise when BLE socket is open.
-     */
-    waitForSocket () {
-        return new Promise((resolve, reject) => {
-            this._ws.onopen = resolve;
-            this._ws.onerror = reject;
-        });
-    }
-
-    /**
-     * Request a device with the device options and optional gui options.
-     * @param {object} deviceOptions - list of device guiOptions.
-     * @param {object} onConnect - on connect callback.
-     * @param {object} onError - on error callbackk.
-     */
-    requestDevice (deviceOptions, onConnect, onError) {
-        this.sendRemoteRequest('discover', deviceOptions)
-            .then(() => this.peripheralChooser.choosePeripheral()) // TODO: use gui options?
-            .then(id => this.sendRemoteRequest(
-                'connect',
-                {peripheralId: id}
-            ))
-            .then(
-                onConnect,
-                onError
-            );
-    }
-
-    /**
-     * Handle a received call from the socket.
-     * @param {string} method - a received method label.
-     * @param {object} params - a received list of parameters.
-     * @return {object} - optional return value.
-     */
-    didReceiveCall (method, params) {
-        // TODO: Add peripheral 'undiscover' handling
-        switch (method) {
-        case 'didDiscoverPeripheral':
-            this.peripheralChooser.addPeripheral(params.peripheralId);
-            break;
-        case 'characteristicDidChange':
-            this._characteristicDidChange(params.message);
-            break;
-        case 'ping':
-            return 42;
-        }
-    }
-
-    /**
-     * Start reading from the specified ble service.
-     * @param {number} serviceId - the ble service to read.
-     * @param {number} characteristicId - the ble characteristic to read.
-     * @param {boolean} optStartNotifications - whether to start receiving characteristic change notifications.
-     * @param {object} onCharacteristicChanged - callback for characteristic change notifications.
-     * @return {Promise} - a promise from the remote read request.
-     */
-    read (serviceId, characteristicId, optStartNotifications = false, onCharacteristicChanged) {
-        const params = {
-            serviceId,
-            characteristicId
-        };
-        if (optStartNotifications) {
-            params.startNotifications = true;
-        }
-        this._characteristicDidChange = onCharacteristicChanged;
-        return this.sendRemoteRequest('read', params);
-    }
-
-    /**
-     * Write data to the specified ble service.
-     * @param {number} serviceId - the ble service to write.
-     * @param {number} characteristicId - the ble characteristic to write.
-     * @param {string} message - the message to send.
-     * @param {string} encoding - the message encoding type.
-     * @return {Promise} - a promise from the remote send request.
-     */
-    write (serviceId, characteristicId, message, encoding = null) {
-        const params = {serviceId, characteristicId, message};
-        if (encoding) {
-            params.encoding = encoding;
-        }
-        return this.sendRemoteRequest('write', params);
-    }
-}
-
-module.exports = ScratchBLE;
diff --git a/src/io/scratchBT.js b/src/io/scratchBT.js
deleted file mode 100644
index 2890b525d..000000000
--- a/src/io/scratchBT.js
+++ /dev/null
@@ -1,37 +0,0 @@
-const JSONRPCWebSocket = require('../util/jsonrpc');
-
-const ScratchLinkWebSocket = 'ws://localhost:20110/scratch/bt';
-
-class ScratchBT extends JSONRPCWebSocket {
-    constructor () {
-        super(new WebSocket(ScratchLinkWebSocket));
-    }
-
-    requestDevice (options) {
-        return this.sendRemoteRequest('discover', options);
-    }
-
-    connectDevice (options) {
-        return this.sendRemoteRequest('connect', options);
-    }
-
-    sendMessage (options) {
-        return this.sendRemoteRequest('send', options);
-    }
-
-    didReceiveCall (method /* , params */) {
-        // TODO: Add peripheral 'undiscover' handling
-        switch (method) {
-        case 'didDiscoverPeripheral':
-            // TODO: do something on peripheral discovered
-            break;
-        case 'didReceiveMessage':
-            // TODO: do something on received message
-            break;
-        default:
-            return 'nah';
-        }
-    }
-}
-
-module.exports = ScratchBT;
diff --git a/src/util/base64-util.js b/src/util/base64-util.js
index 60680e851..c2bd7f743 100644
--- a/src/util/base64-util.js
+++ b/src/util/base64-util.js
@@ -28,6 +28,21 @@ class Base64Util {
         return base64;
     }
 
+    /**
+    * Convert an array buffer to a base64 encoded string.
+    * @param {array} buffer - an array buffer to convert.
+    * @return {string} - the base64 encoded string.
+    */
+    static arrayBufferToBase64 (buffer) {
+        let binary = '';
+        const bytes = new Uint8Array(buffer);
+        const len = bytes.byteLength;
+        for (let i = 0; i < len; i++) {
+            binary += String.fromCharCode(bytes[ i ]);
+        }
+        return btoa(binary);
+    }
+
 }
 
 module.exports = Base64Util;
diff --git a/src/util/jsonrpc-web-socket.js b/src/util/jsonrpc-web-socket.js
index 22310d337..af5af27e1 100644
--- a/src/util/jsonrpc-web-socket.js
+++ b/src/util/jsonrpc-web-socket.js
@@ -1,4 +1,5 @@
 const JSONRPC = require('./jsonrpc');
+// const log = require('../util/log');
 
 class JSONRPCWebSocket extends JSONRPC {
     constructor (webSocket) {
diff --git a/src/virtual-machine.js b/src/virtual-machine.js
index 922c2fc20..e95258fb4 100644
--- a/src/virtual-machine.js
+++ b/src/virtual-machine.js
@@ -106,6 +106,16 @@ class VirtualMachine extends EventEmitter {
             this.emit(Runtime.BLOCKSINFO_UPDATE, blocksInfo);
         });
 
+        this.runtime.on(Runtime.PERIPHERAL_LIST_UPDATE, info => {
+            this.emit(Runtime.PERIPHERAL_LIST_UPDATE, info);
+        });
+        this.runtime.on(Runtime.PERIPHERAL_CONNECTED, () =>
+            this.emit(Runtime.PERIPHERAL_CONNECTED)
+        );
+        this.runtime.on(Runtime.PERIPHERAL_ERROR, () =>
+            this.emit(Runtime.PERIPHERAL_ERROR)
+        );
+
         this.extensionManager = new ExtensionManager(this.runtime);
 
         this.blockListener = this.blockListener.bind(this);
@@ -195,6 +205,22 @@ class VirtualMachine extends EventEmitter {
         this.runtime.ioDevices.video.setProvider(videoProvider);
     }
 
+    startDeviceScan (extensionId) {
+        this.runtime.startDeviceScan(extensionId);
+    }
+
+    connectToPeripheral (extensionId, peripheralId) {
+        this.runtime.connectToPeripheral(extensionId, peripheralId);
+    }
+
+    disconnectExtensionSession (extensionId) {
+        this.runtime.disconnectExtensionSession(extensionId);
+    }
+
+    getPeripheralIsConnected (extensionId) {
+        return this.runtime.getPeripheralIsConnected(extensionId);
+    }
+
     /**
      * Load a Scratch project from a .sb, .sb2, .sb3 or json string.
      * @param {string | object} input A json string, object, or ArrayBuffer representing the project to load.