text: // scratch_ext.js
  // Shane M. Clements, November 2013
  // ScratchExtensions
  //
  // Scratch 2.0 extension manager which Scratch communicates with to initialize extensions and communicate with them.
  // The extension manager also handles creating the browser plugin to enable access to HID and serial devices.
  window.ScratchExtensions = new (function () {
      var plugin = null;
      var handlers = {};
      var blockDefs = {};
      var menuDefs = {};
      var deviceSpecs = {};
      var devices = {};
      var poller = null;
      var lib = this;
  
      var isOffline = Scratch && Scratch.FlashApp && Scratch.FlashApp.ASobj &&
          Scratch.FlashApp.ASobj.isOffline && Scratch.FlashApp.ASobj.isOffline();
      var pluginAvailable = function () {
          return !!window.ArrayBuffer && !!(
                  isOffline ||
                  (window.ScratchPlugin && window.ScratchPlugin.isAvailable()) ||
                  (window.ScratchDeviceHost && window.ScratchDeviceHost.isAvailable())
              );
      };
  
      lib.register = function (name, descriptor, handler, deviceSpec) {
          if (name in handlers) {
              console.log('Scratch extension "' + name + '" already exists!');
              return false;
          }
  
          handlers[name] = handler;
          blockDefs[name] = descriptor.blocks;
          if (descriptor.menus) menuDefs[name] = descriptor.menus;
          if (deviceSpec) deviceSpecs[name] = deviceSpec;
  
          // Show the blocks in Scratch!
          var extObj = {
              extensionName: name,
              blockSpecs: descriptor.blocks,
              url: descriptor.url,
              menus: descriptor.menus,
              javascriptURL: loadingURL
          };
          Scratch.FlashApp.ASobj.ASloadExtension(extObj);
  
          if (deviceSpec) {
              if (!plugin) {
                  if (pluginAvailable()) {
                      // createDevicePlugin() will eventually call checkPolling() if it succeeds
                      setTimeout(createDevicePlugin, 10);
                  } else if (ScratchDeviceManager) {
                      // No plugin is NBD if we're using the SDM
                      checkPolling();
                  } else if (window.ScratchPlugin.useActiveX) {
                      JSsetProjectBanner('Sorry, your version of Internet Explorer is not supported.  Please upgrade to version 10 or 11.');
                  }
              }
              else {
                  // Second hardware-using project in the same tab
                  checkPolling();
              }
          }
  
          return true;
      };
  
      var loadingURL;
      lib.loadExternalJS = function (url) {
          var scr = document.createElement("script");
          scr.src = url;// + "?ts=" + new Date().getTime();
          loadingURL = url;
          document.getElementsByTagName("head")[0].appendChild(scr);
      };
  
      lib.loadLocalJS = function (code) {
          // Run the extension code in the global scope
          try {
              (new Function(code))();
          } catch (e) {
              console.log(e.stack.toString());
          }
      };
  
      lib.unregister = function (name) {
          try {
              handlers[name]._shutdown();
          } catch (e) {
          }
          delete handlers[name];
          delete blockDefs[name];
          delete menuDefs[name];
          delete deviceSpecs[name];
      };
  
      lib.canAccessDevices = function () {
          return pluginAvailable();
      };
  
      lib.getReporter = function (ext_name, reporter, args) {
          return handlers[ext_name][reporter].apply(handlers[ext_name], args);
      };
  
      lib.getReporterAsync = function (ext_name, reporter, args, job_id) {
          var callback = function (retval) {
              Scratch.FlashApp.ASobj.ASextensionReporterDone(ext_name, job_id, retval);
          };
          if(handlers[ext_name]._getStatus().status != 2){
              callback(false);
          }
          else{
              args.push(callback);
              handlers[ext_name][reporter].apply(handlers[ext_name], args);
          }
      };
  
      lib.getReporterForceAsync = function (ext_name, reporter, args, job_id) {
          var retval = handlers[ext_name][reporter].apply(handlers[ext_name], args);
          Scratch.FlashApp.ASobj.ASextensionReporterDone(ext_name, job_id, retval);
      };
  
      lib.runCommand = function (ext_name, command, args) {
          handlers[ext_name][command].apply(handlers[ext_name], args);
      };
  
      lib.runAsync = function (ext_name, command, args, job_id) {
          var callback = function () {
              Scratch.FlashApp.ASobj.ASextensionCallDone(ext_name, job_id);
          };
          args.push(callback);
          handlers[ext_name][command].apply(handlers[ext_name], args);
      };
  
      lib.getStatus = function (ext_name) {
          if (!(ext_name in handlers)) {
              return {status: 0, msg: 'Not loaded'};
          }
  
          if (ext_name in deviceSpecs) {
              switch (deviceSpecs[ext_name].type) {
                  case 'ble':
                  case 'wedo2':
                      if (!(ScratchDeviceManager && ScratchDeviceManager.isConnected())) {
                          return {status: 0, msg: 'Missing Scratch Device Manager'};
                      }
                      break;
                  default:
                      if (!pluginAvailable()) {
                          return {status: 0, msg: 'Missing browser plugin'};
                      }
                      break;
              }
          }
  
          return handlers[ext_name]._getStatus();
      };
  
      lib.stop = function (ext_name) {
          var ext = handlers[ext_name];
          if (ext._stop) {
              ext._stop();
          }
          else if (ext.resetAll) { // old, undocumented call
              ext.resetAll();
          }
      };
  
      lib.notify = function (text) {
          if (window.JSsetProjectBanner) {
              JSsetProjectBanner(text);
          } else {
              alert(text);
          }
      };
  
      lib.resetPlugin = function () {
          if (plugin && plugin.reset) plugin.reset();
          shutdown();
      };
  
      $(window).unload(function (e) {
          shutdown();
      });
  
      function shutdown() {
          for (var extName in handlers) {
              handlers[extName]._shutdown();
          }
          handlers = {};
          stopPolling();
      }
  
      function checkDevices() {
          var awaitingSpecs = {};
          var ext_name;
          for (ext_name in deviceSpecs) {
              if (!devices[ext_name]) {
                  var spec = deviceSpecs[ext_name];
                  if (spec.type == 'hid') {
                      if (!awaitingSpecs['hid']) awaitingSpecs['hid'] = {};
                      awaitingSpecs['hid'][spec.vendor + '_' + spec.product] = ext_name;
                  }
                  else {
                      awaitingSpecs[spec.type] = ext_name;
                  }
              }
          }
  
          for (var specType in awaitingSpecs) {
              if (!awaitingSpecs.hasOwnProperty(specType)) continue;
  
              if (plugin && specType == 'hid') {
                  var awaitingHid = awaitingSpecs['hid'];
                  plugin.hid_list(function (deviceList) {
                      for (var i = 0; i < deviceList.length; i++) {
                          var deviceID = deviceList[i]["vendor_id"] + '_' + deviceList[i]["product_id"];
                          var hid_ext_name = awaitingHid[deviceID];
                          if (hid_ext_name) {
                              handlers[hid_ext_name]._deviceConnected(new HidDevice(deviceList[i], hid_ext_name));
                          }
                      }
                  });
              }
              else if (plugin && specType == 'serial') {
                  ext_name = awaitingSpecs['serial'];
                  plugin.serial_list(function (deviceList) {
                      for (var i = 0; i < deviceList.length; i++) {
                          handlers[ext_name]._deviceConnected(new SerialDevice(deviceList[i], ext_name));
                      }
                  });
              }
              else if (ScratchDeviceManager) {
                  ext_name = awaitingSpecs[specType];
                  ScratchDeviceManager.device_list(specType, ext_name, deviceSpecs[ext_name], deviceListCallback);
              }
          }
  
          if (!shouldLookForDevices()) {
              stopPolling();
          }
      }
  
      function deviceListCallback(deviceList, ext_type, ext_name) {
          for (var i = 0; i < deviceList.length; ++i) {
              var deviceConstructor = Devices[ext_type];
              var deviceId = deviceList[i].id || deviceList[i];
              var device = new deviceConstructor(deviceId, ext_type, ext_name);
              handlers[ext_name]._deviceConnected(device);
          }
      }
  
      function checkPolling() {
          if (poller || !shouldLookForDevices()) return;
  
          poller = setInterval(checkDevices, 500);
      }
  
      function stopPolling() {
          if (poller) clearInterval(poller);
          poller = null;
      }
  
      function shouldLookForDevices() {
          for (var ext_name in deviceSpecs) {
              if (!devices[ext_name]) {
                  return true;
              }
          }
  
          return false;
      }
  
      function createDevicePlugin() {
          if (plugin) return;
  
          try {
              // TODO: delegate more of this to the other files
              if (isOffline) {
                  // Talk to the AIR Native Extension through the offline editor's plugin emulation.
                  plugin = Scratch.FlashApp.ASobj.getPlugin();
              } else if (window.ScratchDeviceHost && window.ScratchDeviceHost.isAvailable()) {
                  // Talk to the Native Messaging Host through a Chrome extension.
                  plugin = window.ScratchDeviceHost;
              } else {
                  if (window.ScratchPlugin.useActiveX) {
                      // we must be on IE or similar
                      plugin = new ActiveXObject(window.ScratchPlugin.axObjectName);
                  } else {
                      // Not IE: try NPAPI
                      var pluginContainer = document.createElement('div');
                      document.getElementById('scratch').parentNode.appendChild(pluginContainer);
                      pluginContainer.innerHTML =
                          '<object type="application/x-scratchdeviceplugin" width="1" height="1"> </object>';
                      plugin = pluginContainer.firstChild;
                  }
                  // Talk to the actual plugin, but make it pretend to be asynchronous.
                  plugin = new window.ScratchPlugin.PluginWrapper(plugin);
              }
          }
          catch (e) {
              console.error('Error creating plugin or wrapper:', e);
              plugin = null;
          }
  
          // Wait a moment to access the plugin and claim any devices that plugins are
          // interested in.
          setTimeout(checkPolling, 100);
      }
  
      function HidDevice(info, ext_name) {
          var dev = null;
          var self = this;
  
          // TODO: add support for multiple devices per extension
          //if(!(ext_name in devices)) devices[ext_name] = {};
  
          this.id = info["path"];
          this.info = info;
  
          function disconnect() {
              setTimeout(function () {
                  self.close();
                  handlers[ext_name]._deviceRemoved(self);
              }, 0);
          }
  
          this.open = function (readyCallback) {
              plugin.hid_open(self.id, function (d) {
                  dev = d;
                  if (dev) {
                      devices[ext_name] = self;
                      dev.set_nonblocking(true);
                  }
                  if (readyCallback) readyCallback(d ? self : null);
              });
          };
          this.close = function () {
              if (!dev) return;
              dev.close();
              delete devices[ext_name];
              dev = null;
  
              checkPolling();
          };
          this.write = function (data, callback) {
              if (!dev) return;
              dev.write(data, function (len) {
                  if (len < 0) disconnect();
                  if (callback) callback(len);
              });
          };
          this.read = function (callback, len) {
              if (!dev) return null;
              if (!len) len = 65;
              dev.read(len, function (data) {
                  if (data.byteLength == 0) disconnect();
                  callback(data);
              });
          };
      }
  
      function SerialDevice(id, ext_name) {
          var dev = null;
          var self = this;
  
          // TODO: add support for multiple devices per extension
          //if(!(ext_name in devices)) devices[ext_name] = {};
  
          this.id = id;
          this.open = function (opts, readyCallback) {
              plugin.serial_open(self.id, opts, function (d) {
                  dev = d;
                  if (dev) {
                      devices[ext_name] = self;
                      dev.set_error_handler(function (message) {
                          console.log('Serial device error\nDevice: ' + id + '\nError: ' + message);
                      });
                  }
                  if (readyCallback) readyCallback(d ? self : null);
              });
          };
          this.close = function () {
              if (!dev) return;
              dev.close();
              delete devices[ext_name];
              dev = null;
  
              checkPolling();
          };
          this.send = function (data) {
              if (!dev) return;
              dev.send(data);
          };
          this.set_receive_handler = function (handler) {
              if (!dev) return;
              dev.set_receive_handler(handler);
          };
      }
  
      function WeDo2Device(id, ext_type, ext_name) {
          var dev = null;
          this.ext_type = ext_type;
          this.ext_name = ext_name;
          var self = this;
          this.id = id;
  
          function RawWeDo2(deviceId, socket) {
              var WeDo = this;
              var eventHandlers = {};
  
              WeDo.close = function() {
                  socket.close();
              };
  
              WeDo.setMotorOn = function(motorIndex, power) {
                  socket.emit('motorOn', {motorIndex:motorIndex, power:power});
              };
  
              WeDo.setMotorOff = function(motorIndex) {
                  socket.emit('motorOff', {motorIndex:motorIndex});
              };
  
              WeDo.setMotorBrake = function(motorIndex) {
                  socket.emit('motorBrake', {motorIndex:motorIndex});
              };
  
              WeDo.setLED = function(rgb) {
                  socket.emit('setLED', {rgb:rgb});
              };
  
              WeDo.playTone = function(tone, durationMs) {
                  socket.emit('playTone', {tone:tone, ms:durationMs});
              };
  
              WeDo.stopTone = function() {
                  socket.emit('stopTone');
              };
  
              function setHandler(eventName, handler) {
                  if (eventHandlers.hasOwnProperty(eventName)) {
                      var oldHandler = eventHandlers[eventName];
                      if (oldHandler) {
                          socket.removeListener(eventName, oldHandler);
                      }
                  }
                  if (handler) {
                      socket.on(eventName, handler);
                  }
                  eventHandlers[eventName] = handler;
              }
  
              // function handler(event) { access event.sensorName and event.sensorValue }
              WeDo.setSensorHandler = function (handler) {
                  setHandler('sensorChanged', handler);
              };
  
              WeDo.setDeviceWasClosedHandler = function (handler) {
                  // TODO: resolve this ambiguity
                  setHandler('disconnect', handler);
                  setHandler('deviceWasClosed', handler);
              };
          }
  
          function disconnect() {
              setTimeout(function () {
                  self.close();
                  handlers[ext_name]._deviceRemoved(self);
              }, 0);
          }
  
          this.is_open = function() {
              return !!dev;
          };
  
          this.open = function(readyCallback) {
              ScratchDeviceManager.socket_open(self.ext_name, self.ext_type, self.id, function(socket) {
                  if (socket) {
                      dev = new RawWeDo2(self.id, socket);
                      devices[ext_name] = self;
                      dev.setDeviceWasClosedHandler(disconnect);
                  }
                  else {
                      dev = null;
                      disconnect();
                  }
                  if (readyCallback) {
                      readyCallback(dev ? self : null);
                  }
              });
          };
  
          this.close = function() {
              if (!dev) return;
              dev.close();
              delete devices[ext_name];
              dev = null;
  
              checkPolling();
          };
  
          // The `handler` should be a function like: function handler(event) {...}
          // The `event` will contain properties called `sensorName` and `sensorValue`.
          // Sensor names include `tilt` and `distance`.
          this.set_sensor_handler = function(handler) {
              if (!dev) return;
              dev.setSensorHandler(handler);
          };
  
          // Starts motor at given power, 0-100. Use negative power for reverse.
          this.set_motor_on = function(motorIndex, power) {
              dev.setMotorOn(motorIndex, power);
          };
          // Applies active braking.
          this.set_motor_brake = function(motorIndex) {
              dev.setMotorBrake(motorIndex);
          };
          // Turns motor off. Depending on power and load, the motor will drift to a stop.
          this.set_motor_off = function(motorIndex) {
              dev.setMotorOff(motorIndex);
          };
  
          // Sets the RGB LED color. The RGB color should be specified in 0xRRGGBB format.
          this.set_led = function(rgb) {
              dev.setLED(rgb);
          };
  
          this.play_tone = function(tone, durationMs) {
              dev.playTone(tone, durationMs);
          };
          this.stop_tone = function() {
              dev.stopTone();
          };
      }
  
  
      function BleDevice(id, ext_type, ext_name) {
          var self = this;
          this.ext_name = ext_name;
          this.ext_type = ext_type;
          this.socket = null;
  
          this.id = id;
  
          var onActions = [];
          var onceActions = [];
  
          function disconnect() {
              setTimeout(function () {
                  self.close();
                  handlers[self.ext_name]._deviceRemoved(self);
              }, 0);
          }
  
          this.emit = function(action, data){
              if(self.socket){
                  self.socket.emit(action, data);
              }
              return !!self.socket;
          };
  
          this.on = function(action, callback){
              if(self.is_open()){
                  self.socket.on(action, callback);
              }
              else{
                  onActions.push([action, callback]);
              }
          };
  
          this.once = function(action, callback){
              if(self.is_open()){
                  self.socket.once(action, callback);
              }
              else{
                  onceActions.push([action, callback]);
              }
          };
  
          this.open = function(readyCallback) {
              ScratchDeviceManager.socket_open(self.ext_name, ext_type, self.id, function(s) {
                  self.socket = s;
                  if (self.socket) {
                      devices[self.ext_name] = self;
                      onActions.forEach(function(element){
                          self.socket.on(element[0], element[1]);
                      });
                      onceActions.forEach(function(element){
                          self.socket.once(element[0], element[1]);
                      });
  
                      self.socket.on('disconnect', disconnect);
                      self.socket.on('deviceWasClosed', disconnect);
  
                  }
                  if (readyCallback) readyCallback(self.socket ? self : null);
              });
          };
  
          this.close = function() {
              if (!self.socket) return;
              self.socket.close();
              delete devices[self.ext_name];
              self.socket = null;
  
              checkPolling();
          };
  
          this.is_open = function() {
              return !!self.socket;
          };
  
      }
      Devices = {ble: BleDevice, wedo2: WeDo2Device};
  })();
  