// 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); }; 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 'wedo2': if (!ScratchDeviceManager) { 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 if (spec.type == 'serial') { awaitingSpecs['serial'] = ext_name; } else if (spec.type == 'wedo2') { awaitingSpecs['wedo2'] = ext_name; } } } if (plugin) { if (awaitingSpecs['hid']) { plugin.hid_list(function (deviceList) { var hidList = awaitingSpecs['hid']; for (var i = 0; i < deviceList.length; i++) { var ext_name = hidList[deviceList[i]["vendor_id"] + '_' + deviceList[i]["product_id"]]; if (ext_name) { handlers[ext_name]._deviceConnected(new HidDevice(deviceList[i], ext_name)); } } }); } if (awaitingSpecs['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)); } }); } } if (ScratchDeviceManager && awaitingSpecs['wedo2']) { ext_name = awaitingSpecs['wedo2']; ScratchDeviceManager.wedo2_list(function(deviceList) { for (var i = 0; i < deviceList.length; ++i) { handlers[ext_name]._deviceConnected(new WeDo2Device(deviceList[i].id || deviceList[i], ext_name)); } }); } if (!shouldLookForDevices()) { stopPolling(); } } 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; // 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 = ' '; plugin = pluginContainer.firstChild; } // Talk to the actual plugin, but make it pretend to be asynchronous. plugin = new window.ScratchPlugin.PluginWrapper(plugin); } // 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); }; } // TODO: create a base class for these device classes so that we can share common code function WeDo2Device(id, ext_name) { var dev = null; var self = this; this.id = id; function disconnect() { setTimeout(function () { self.close(); handlers[ext_name]._deviceRemoved(self); }, 0); } this.open = function(readyCallback) { ScratchDeviceManager.wedo2_open(self.id, function(d) { dev = d; if (dev) { devices[ext_name] = self; dev.setDeviceWasClosedHandler(disconnect); } if (readyCallback) readyCallback(d ? self : null); }); }; this.close = function() { if (!dev) return; dev.close(); delete devices[ext_name]; dev = null; checkPolling(); }; this.is_open = function() { return !!dev; }; // 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(); }; } })();