// 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}; })();