diff --git a/rosboard/handlers.py b/rosboard/handlers.py index 24178ee6..385aa0ae 100644 --- a/rosboard/handlers.py +++ b/rosboard/handlers.py @@ -9,6 +9,7 @@ import uuid from . import __version__ +from plugins import get_plugin_js_files, received_plugin_message class NoCacheStaticFileHandler(tornado.web.StaticFileHandler): def set_extra_headers(self, path): @@ -49,6 +50,7 @@ def open(self): self.write_message(json.dumps([ROSBoardSocketHandler.MSG_SYSTEM, { "hostname": socket.gethostname(), "version": __version__, + "plugin_js_files": get_plugin_js_files() }], separators=(',', ':'))) def on_close(self): @@ -142,7 +144,7 @@ def on_message(self, message): self.latency = (received_pong_time - self.last_ping_times[argv[1].get(ROSBoardSocketHandler.PONG_SEQ, 0) % 1024]) / 2 if self.latency > 1000.0: self.node.logwarn("socket %s has high latency of %.2f ms" % (str(self.id), self.latency)) - + if self.latency > 10000.0: self.node.logerr("socket %s has excessive latency of %.2f ms; closing connection" % (str(self.id), self.latency)) self.close() @@ -187,6 +189,15 @@ def on_message(self, message): except KeyError: print("KeyError trying to remove sub") + # client wants to send something to a plugin + elif argv[0] == ROSBoardSocketHandler.MSG_PLUGIN_MESSAGE: + if len(argv) != 2 or type(argv[1]) is not dict: + print("error: plugin_message: bad: %s" % message) + return + name = argv[1].get("name") + msg = argv[1].get("message") + received_plugin_message(name, msg) + ROSBoardSocketHandler.MSG_PING = "p"; ROSBoardSocketHandler.MSG_PONG = "q"; ROSBoardSocketHandler.MSG_MSG = "m"; @@ -194,6 +205,7 @@ def on_message(self, message): ROSBoardSocketHandler.MSG_SUB = "s"; ROSBoardSocketHandler.MSG_SYSTEM = "y"; ROSBoardSocketHandler.MSG_UNSUB = "u"; +ROSBoardSocketHandler.MSG_PLUGIN_MESSAGE = "pm"; ROSBoardSocketHandler.PING_SEQ = "s"; ROSBoardSocketHandler.PONG_SEQ = "s"; diff --git a/rosboard/html/js/index.js b/rosboard/html/js/index.js index 366a6274..e359a8eb 100644 --- a/rosboard/html/js/index.js +++ b/rosboard/html/js/index.js @@ -24,21 +24,6 @@ var snackbarContainer = document.querySelector('#demo-toast-example'); let subscriptions = {}; -if(window.localStorage && window.localStorage.subscriptions) { - if(window.location.search && window.location.search.indexOf("reset") !== -1) { - subscriptions = {}; - updateStoredSubscriptions(); - window.location.href = "?"; - } else { - try { - subscriptions = JSON.parse(window.localStorage.subscriptions); - } catch(e) { - console.log(e); - subscriptions = {}; - } - } -} - let $grid = null; $(() => { $grid = $('.grid').masonry({ @@ -75,13 +60,6 @@ function newCard() { return card; } -let onOpen = function() { - for(let topic_name in subscriptions) { - console.log("Re-subscribing to " + topic_name); - initSubscribe({topicName: topic_name, topicType: subscriptions[topic_name].topicType}); - } -} - let onSystem = function(system) { if(system.hostname) { console.log("hostname: " + system.hostname); @@ -92,6 +70,30 @@ let onSystem = function(system) { console.log("server version: " + system.version); versionCheck(system.version); } + + for (let idx in system.plugin_js_files) { + importJsOnce(system.plugin_js_files[idx]); + } + + if(window.localStorage && window.localStorage.subscriptions) { + if(window.location.search && window.location.search.indexOf("reset") !== -1) { + subscriptions = {}; + updateStoredSubscriptions(); + window.location.href = "?"; + } else { + try { + subscriptions = JSON.parse(window.localStorage.subscriptions); + } catch(e) { + console.log(e); + subscriptions = {}; + } + } + } + + for(let topic_name in subscriptions) { + console.log("Re-subscribing to " + topic_name); + initSubscribe({topicName: topic_name, topicType: subscriptions[topic_name].topicType}); + } } let onMsg = function(msg) { @@ -108,7 +110,7 @@ let currentTopics = {}; let currentTopicsStr = ""; let onTopics = function(topics) { - + // check if topics has actually changed, if not, don't do anything // lazy shortcut to deep compares, might possibly even be faster than // implementing a deep compare due to @@ -117,12 +119,12 @@ let onTopics = function(topics) { if(newTopicsStr === currentTopicsStr) return; currentTopics = topics; currentTopicsStr = newTopicsStr; - + let topicTree = treeifyPaths(Object.keys(topics)); - + $("#topics-nav-ros").empty(); $("#topics-nav-system").empty(); - + addTopicTreeToNav(topicTree[0], $('#topics-nav-ros')); $('') @@ -195,7 +197,7 @@ function initSubscribe({topicName, topicType}) { subscriptions[topicName] = { topicType: topicType, } - } + } currentTransport.subscribe({topicName: topicName}); if(!subscriptions[topicName].viewer) { let card = newCard(); @@ -217,7 +219,6 @@ let currentTransport = null; function initDefaultTransport() { currentTransport = new WebSocketV1Transport({ path: "/rosboard/v1", - onOpen: onOpen, onMsg: onMsg, onTopics: onTopics, onSystem: onSystem, @@ -236,7 +237,7 @@ function treeifyPaths(paths) { r[name] = {result: []}; r.result.push({name, children: r[name].result}) } - + return r[name]; }, level) }); diff --git a/rosboard/html/js/transports/WebSocketV1Transport.js b/rosboard/html/js/transports/WebSocketV1Transport.js index d65a6532..6104a92f 100644 --- a/rosboard/html/js/transports/WebSocketV1Transport.js +++ b/rosboard/html/js/transports/WebSocketV1Transport.js @@ -8,25 +8,25 @@ class WebSocketV1Transport { this.onSystem = onSystem ? onSystem.bind(this) : null; this.ws = null; } - + connect() { var protocolPrefix = (window.location.protocol === 'https:') ? 'wss:' : 'ws:'; let abspath = protocolPrefix + '//' + location.host + this.path; - + let that = this; - + this.ws = new WebSocket(abspath); - + this.ws.onopen = function(){ console.log("connected"); if(that.onOpen) that.onOpen(that); } - + this.ws.onclose = function(){ console.log("disconnected"); if(that.onClose) that.onClose(that); } - + this.ws.onmessage = function(wsmsg) { let data = []; try { @@ -39,7 +39,7 @@ class WebSocketV1Transport { } let wsMsgType = data[0]; - + if(wsMsgType === WebSocketV1Transport.MSG_PING) { this.send(JSON.stringify([WebSocketV1Transport.MSG_PONG, { [WebSocketV1Transport.PONG_SEQ]: data[1][WebSocketV1Transport.PING_SEQ], @@ -52,11 +52,11 @@ class WebSocketV1Transport { else console.log("received unknown message: " + wsmsg.data); } } - + isConnected() { return (this.ws && this.ws.readyState === this.ws.OPEN); } - + subscribe({topicName, maxUpdateRate = 24.0}) { this.ws.send(JSON.stringify([WebSocketV1Transport.MSG_SUB, {topicName: topicName, maxUpdateRate: maxUpdateRate}])); } @@ -64,8 +64,12 @@ class WebSocketV1Transport { unsubscribe({topicName}) { this.ws.send(JSON.stringify([WebSocketV1Transport.MSG_UNSUB, {topicName: topicName}])); } + + send_plugin_message(name, message) { + this.ws.send(JSON.stringify([WebSocketV1Transport.MSG_PLUGIN_MESSAGE, { name: name, message: message }])); + } } - + WebSocketV1Transport.MSG_PING = "p"; WebSocketV1Transport.MSG_PONG = "q"; WebSocketV1Transport.MSG_MSG = "m"; @@ -73,6 +77,7 @@ class WebSocketV1Transport { WebSocketV1Transport.MSG_SUB = "s"; WebSocketV1Transport.MSG_SYSTEM = "y"; WebSocketV1Transport.MSG_UNSUB = "u"; + WebSocketV1Transport.MSG_PLUGIN_MESSAGE = "pm"; WebSocketV1Transport.PING_SEQ= "s"; WebSocketV1Transport.PONG_SEQ = "s"; diff --git a/rosboard/html/js/viewers/meta/Viewer.js b/rosboard/html/js/viewers/meta/Viewer.js index b0c4db51..4d700036 100644 --- a/rosboard/html/js/viewers/meta/Viewer.js +++ b/rosboard/html/js/viewers/meta/Viewer.js @@ -222,9 +222,14 @@ Viewer.onClose = (viewerInstance) => { console.log("not implemented; override ne Viewer.onSwitchViewer = (viewerInstance, newViewerType) => { console.log("not implemented; override necessary"); } // not to be overwritten by child class! -Viewer.registerViewer = (viewer) => { +Viewer.registerViewer = (viewer, toTop=false) => { // registers a viewer. the viewer child class calls this at the end of the file to register itself - Viewer._viewers.push(viewer); + if (toTop) { + // call with true if it's a plugin + Viewer._viewers.unshift(viewer); + } else { + Viewer._viewers.push(viewer); + } }; // not to be overwritten by child class! diff --git a/rosboard/plugins/__init__.py b/rosboard/plugins/__init__.py new file mode 100644 index 00000000..6dc87199 --- /dev/null +++ b/rosboard/plugins/__init__.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 + +import pathlib +import pkgutil +import sys +import traceback + + +_plugins = {} + +def _load_plugins(dirname: str): + global _plugins + for importer, package_name, _ in pkgutil.iter_modules([dirname]): + full_package_name = '%s.%s' % (dirname, package_name) + if full_package_name in sys.modules: + print(f'Plugin "{full_package_name}" not loaded, already present. Check name clashes.') + continue + + try: + module = importer.find_module(package_name).load_module(package_name) + _plugins[package_name] = module + except Exception as e: + traceback.print_exception(e) + +def get_plugin_js_files(): + global _plugins + js_files = [] + + for plugin in _plugins.values(): + js_files.extend(plugin.js_files) + + return js_files + +def received_plugin_message(name: str, message): + global _plugins + if name not in _plugins: + print(f'Plugin unknown "{name}"') + return + + try: + _plugins[name].receive(message) + except Exception as e: + traceback.print_exception(e) + +_load_plugins(str(pathlib.Path(__file__).parent.resolve())) diff --git a/rosboard/plugins/plugin.md b/rosboard/plugins/plugin.md new file mode 100644 index 00000000..c7da733c --- /dev/null +++ b/rosboard/plugins/plugin.md @@ -0,0 +1,50 @@ +Example of a possible plugin with ros support: + +```python +#!/usr/bin/env python3 + +import os +import pathlib +import sys + +sys.path.append(f"{pathlib.Path(__file__).parent.resolve()}/.") +if os.environ.get("ROS_VERSION") == "1": + import rospy # ROS1 +elif os.environ.get("ROS_VERSION") == "2": + import rospy2 as rospy # ROS2 +else: + print("ROS not detected. Please source your ROS environment\n(e.g. 'source /opt/ros/DISTRO/setup.bash')") + exit(1) + +from std_msgs.msg import String + + +class DummyPlugin: + + def __init__(self): + self.publisher = rospy.Publisher('dummy_topic', String) + + def receive(self, message): + msg = String() + msg.data = str(message) + self.publisher.publish(msg) + + +# This is the most important function for all plugins, +# it's called when a message from the client is sent towards the backend +def receive(message): + global _instance + if _instance == None: + _instance = DummyPlugin() + + _instance.receive(message) + +_instance = None + +# Add all javascript files which are needed for this plugin on the client side: +# e.g. "js/plugins/dummy.js", "js/viewer/dummy.js" +# From client side use the javascript function, like this: +# currentTransport.send_plugin_message("dummy", "myImportantMessage"}) +# currentTransport.send_plugin_message("dummy", {myKey: "myImportantMessage"}) +js_files = [] +``` \ No newline at end of file