liveprinter.js

/**
 * @file Main liveprinter system file for a livecoding system for live CNC manufacturing.
 * @author Evan Raskob <evanraskob+nosp4m@gmail.com>
 * @version 0.8
 * @license
 * Copyright (c) 2018 Evan Raskob and others
 * Licensed under the GNU Affero 3.0 License (the "License"); you may
* not use this file except in compliance with the License. You may obtain
* a copy of the License at
*
*     {@link https://www.gnu.org/licenses/gpl-3.0.en.html}
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/

/**
 * @namespace LivePrinter
 */

// import vector functions - doesn't work in chrome ?
//import { Vector } from './lib/Vector.prototype.js';

$.when($.ready).then(
    function () {
        "use strict";

        if (!window.console) window.console = {};
        if (!window.console.log) window.console.log = function () { };

        // dangerous?
        // need to pass to scripts somehow
        if (!window.scope) {
            window.scope = Object.create(null);
        }
        /**
         * Global namespace for all printer functions.  See {@link globalEval}
         * @memberOf LivePrinter
         * @inner
         */
        let scope = window.scope; // shorthand
        scope.serialPorts = []; // available ports

        let outgoingQueue = []; // messages for the server

        let pythonMode = false;

        /**
         * Handy object for scheduling events at intervals, etc.
         * @class
         * @constructor
         * @memberOf LivePrinter
         * @inner
         */
        window.scope.Scheduler = {
            ScheduledEvents: [],
            schedulerInterval: 40,
            timerID: null,
            startTime: Date.now(),

            /**
             * Clear all scheduled events.
             * */
            clearEvents: function () {
                window.scope.Scheduler.ScheduledEvents = [];
            },

            /**
            * Schedule a function to run (and optionally repeat).
            * @param {Object} args Object with timeOffset: ms offset to schedule this for, func: function, repeat: true/false whether to reschedule
            */
            scheduleEvent: function (args) {
                args.time = Date.now() - window.scope.Scheduler.startTime;

                window.scope.Scheduler.ScheduledEvents.push(args);
            },

            /**
             * Remove an event using a filtering function (like matching a name)
             * @param {Function} func - filtering function  
             */
            removeEvent: function (func) {
                // run events 
                window.scope.Scheduler.ScheduledEvents = window.scope.Scheduler.ScheduledEvents.filter(func);
            },

            /**
             * Remove an event using the name property of that event
             * @param {string} name of the event to remove
             */
            removeEventByName: function (name) {
                // run events 
                window.scope.Scheduler.ScheduledEvents = window.scope.Scheduler.ScheduledEvents.filter(e => e.name !== name);
            },

            /**
             * Start the Scheduler running events.
             */
            startScheduler: function () {

                console.log("scheduler starting at time: " + this.startTime);

                function scheduler(nextTime) {
                    const time = Date.now() - window.scope.Scheduler.startTime; // in ms

                    // run events 
                    window.scope.Scheduler.ScheduledEvents.filter(
                        function (event) {
                            let keep = true;

                            if (event.time < time) {
                                //console.log("running event at time:" + time);
                                event.func(time);

                                if (event.repeat) {
                                    event.time = time + event.timeOffset;
                                    keep = true;
                                }
                                else {
                                    keep = false;
                                }
                            }
                            return keep;
                        });
                }

                window.scope.Scheduler.timerID = window.setInterval(scheduler, window.scope.Scheduler.schedulerInterval);
            }
        };


        window.scope.Scheduler.startScheduler();

        // Scheduler.scheduleEvent({
        //     timeOffset: 2000,
        //     func: function() { console.log("EVENT"); } ,
        //     repeat: true,
        // });


        //////////////////////////////////////////////////////////////////////////////////////////
        // Codemirror:
        // https://codemirror.net/doc/manual.html
        //////////////////////////////////////////////////////////////////////////////////////////

        /**
         * CodeMirror code editor instance (local code). See {@link https://codemirror.net/doc/manual.html}
         * @memberOf LivePrinter
         */
        const CodeEditor = CodeMirror.fromTextArea(document.getElementById("code-editor"), {
            lineNumbers: true,
            scrollbarStyle: "simple",
            styleActiveLine: true,
            lineWrapping: true,
            undoDepth: 100,
            //autocomplete: true,
            extraKeys: {
                "Ctrl-Enter": runCode,
                "Cmd-Enter": runCode,
                "Ctrl-Space": "autocomplete",
                "Ctrl-Q": function (cm) { cm.foldCode(cm.getCursor()); }
            },
            foldGutter: true,
            autoCloseBrackets: true
        });

        /**
         * Global code CodeMirror editor instance. See {@link https://codemirror.net/doc/manual.html}
         * @namespace CodeMirror
         * @memberOf LivePrinter
         */
        const GlobalCodeEditor = CodeMirror.fromTextArea(document.getElementById("global-code-editor"), {
            lineNumbers: true,
            scrollbarStyle: "simple",
            styleActiveLine: true,
            lineWrapping: true,
            undoDepth: 20,
            //autocomplete: true,
            extraKeys: {
                "Ctrl-Enter": runGlobalCode,
                "Cmd-Enter": runGlobalCode,
                "Ctrl-Space": "autocomplete",
                "Ctrl-Q": function (cm) { cm.foldCode(cm.getCursor()); }
            },
            foldGutter: true,
            autoCloseBrackets: true
        });

        //hide tab-panel after codeMirror rendering (by removing the extra 'active' class)
        $('.hideAfterLoad').each(function () {
            $(this).removeClass('active');
        });

        //GlobalCodeEditor.hide(); // hidden to start

        // CodeMirror stuff

        const WORD = /[\w$]+/g, RANGE = 500;

        CodeMirror.registerHelper("hint", "anyword", function (editor, options) {
            const word = options && options.word || WORD;
            const range = options && options.range || RANGE;
            const cur = editor.getCursor(), curLine = editor.getLine(cur.line);
            let start = cur.ch, end = start;
            while (end < curLine.length && word.test(curLine.charAt(end)))++end;
            while (start && word.test(curLine.charAt(start - 1)))--start;
            let curWord = start !== end && curLine.slice(start, end);

            let list = [], seen = {};
            function scan(dir) {
                let line = cur.line, end = Math.min(Math.max(line + dir * range, editor.firstLine()), editor.lastLine()) + dir;
                for (; line !== end; line += dir) {
                    let text = editor.getLine(line), m;
                    word.lastIndex = 0;
                    while (m = word.exec(text)) {
                        if ((!curWord || m[0].indexOf(curWord) === 0) && !seen.hasOwnProperty(m[0])) {
                            seen[m[0]] = true;
                            list.push(m[0]);
                        }
                    }
                }
            }
            scan(-1);
            scan(1);
            return { list: list, from: CodeMirror.Pos(cur.line, start), to: CodeMirror.Pos(cur.line, end) };
        });

        /**
         * Clear HTML of all displayed code errors
         */
        function clearError() {
            $(".code-errors").html("<p>[no errors]</p>");
        }

        /**
         * Show an error in the HTML GUI  
         * @param {Error} e Standard JavaScript error object to show
         * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SyntaxError
         * @memberOf LivePrinter
         */
        function doError(e) {
            // report to user
            $(".code-errors").html("<p>" + e.name + ": " + e.message + "(line:" + e.lineNumber + ")</p>");
            console.log(e);
            /*
            console.log("SyntaxError? " + (e instanceof SyntaxError)); // true
            console.log(e); // true
            console.log("SyntaxError? " + (e instanceof SyntaxError)); // true
            console.log("ReferenceError? " + (e instanceof ReferenceError)); // true
            console.log(e.message);                // "missing ; before statement"
            console.log(e.name);                   // "SyntaxError"
            console.log(e.fileName);               // "Scratchpad/1"
            console.log(e.lineNumber);             // 1
            console.log(e.columnNumber);           // 4
            console.log(e.stack);                  // "@Scratchpad/1:2:3\n"
            */

            // this sucked because of coding... jst highlight instead!
            /*
            if (e.lineNumber) {
                // remember that syntax errors start at line 1 which is line 0 in CodeMirror!
                CodeEditor.setSelection({ line: (e.lineNumber-1), ch: e.columnNumber }, { line: (e.lineNumber-1), ch: (e.columnNumber + 1) });
            }
            */
        }
        window.doError = doError;

        /**
         * blink an element using css animation class
         * @param {JQuery} $elem element to blink
         * @param {String} speed "fast" or "slow" 
         * @param {Function} callback function to run at end
         * @memberOf LivePrinter
         */

        function blinkElem($elem, speed, callback) {
            $elem.removeClass("blinkit fast slow"); // remove to make sure it's not there
            $elem.on("animationend", function () {
                if (callback !== undefined && typeof callback === "function") callback();
                $(this).removeClass("blinkit fast slow");
            });
            if (speed === "fast") {
                $elem.addClass("blinkit fast");
            }
            else if (speed === "slow") {
                $elem.addClass("blinkit slow");
            } else {
                $elem.addClass("blinkit");
            }
        }

        /**
         * Get the list of serial ports from the server (or refresh it) and display in the GUI (the listener will take care of that)
         * @memberOf LivePrinter
         */
        function getSerialPorts() {
            const message = {
                'jsonrpc': '2.0',
                'id': 6,
                'method': 'get-serial-ports',
                'params': []
            };
            socketHandler.sendMessage(message);
        }
        // expose as global
        window.scope.getSerialPorts = getSerialPorts;

        /**
         * Toggle the language mode for livecoding scripts between Javascript and Python.
         * @memberOf LivePrinter
         */
        function setLanguageMode() {
            if (pythonMode) {
                CodeEditor.setOption("gutters", ["CodeMirror-linenumbers", "CodeMirror-foldgutter"]);
                CodeEditor.setOption("mode", "text/x-python");
                CodeEditor.setOption("lint", true);

                GlobalCodeEditor.setOption("gutters", ["CodeMirror-linenumbers", "CodeMirror-foldgutter"]);
                GlobalCodeEditor.setOption("mode", "text/x-python");
                GlobalCodeEditor.setOption("lint", true);

            } else {
                CodeEditor.setOption("gutters", ["CodeMirror-lint-markers", "CodeMirror-linenumbers", "CodeMirror-foldgutter"]);
                CodeEditor.setOption("mode", "javascript");
                CodeEditor.setOption("lint", {
                    globalstrict: true,
                    strict: false,
                    esversion: 6
                });

                GlobalCodeEditor.setOption("gutters", ["CodeMirror-lint-markers", "CodeMirror-linenumbers", "CodeMirror-foldgutter"]);
                GlobalCodeEditor.setOption("mode", "javascript");
                GlobalCodeEditor.setOption("lint", {
                    globalstrict: true,
                    strict: false,
                    esversion: 6
                });
            }
        }

        /**
         * build examples loader links for dynamically loading example files
         * @memberOf LivePrinter
         */
        let exList = $("#examples-list > .dropdown-item").not("[id*='session']");
        exList.on("click", function () {
            let me = $(this);
            let filename = me.data("link");
            clearError(); // clear loading errors
            var jqxhr = $.ajax({ url: filename, dataType: "text" })
                .done(function (content) {
                    let newDoc = CodeMirror.Doc(content, "javascript");
                    blinkElem($(".CodeMirror"), "slow", () => CodeEditor.swapDoc(newDoc));
                })
                .fail(function () {
                    doError({ name: "error", message: "file load error:" + filename });
                });
        });

        /**
         * Strip GCode comments from text. Comments can be embedded in a line using parentheses () or for the remainder of a lineusing a semi-colon.
         * The semi-colon is not treated as the start of a comment when enclosed in parentheses.
         * Borrowed from {@link https://github.com/cncjs/gcode-parser/blob/master/src/index.js} (MIT License)
         * See {@link http://linuxcnc.org/docs/html/gcode/overview.html#gcode:comments}
         * @memberOf LivePrinter
         */
        const stripComments = (() => {
            const re1 = new RegExp(/\s*\([^\)]*\)/g); // Remove anything inside the parentheses
            const re2 = new RegExp(/\s*;.*/g); // Remove anything after a semi-colon to the end of the line, including preceding spaces
            //const re3 = new RegExp(/\s+/g);
            return (line => line.replace(re1, '').replace(re2, '')); //.replace(re3, ''));
        })();

        /**
         * Convert code to JSON RPC for sending to the server.
         * @param {string} gcode to convert
         * @returns {string} json message
         * @memberOf LivePrinter
         * 
         */
        function codeToJSON(gcode) {
            if (typeof gcode === 'string') gcode = [stripComments(gcode)];

            if (typeof gcode === 'object' && Array.isArray(gcode)) {
                let message = {
                    'jsonrpc': '2.0',
                    'id': 1,
                    'method': 'gcode',
                    'params': gcode
                };

                let message_json = JSON.stringify(message);
                // debugging
                //console.log(message_json);

                return message_json;

                //socketHandler.socket.send(message_json);
            }
            else throw new Error("invalid gcode in sendGCode[" + typeof text + "]:" + text);
        }

        /**
         * Send GCode to the server via websockets.
         * @param {string} gcode gcode to send
         * @memberOf LivePrinter
         */
        function sendGCode(gcode) {
            let message = codeToJSON(gcode);
            socketHandler.socket.send(message);
        }

        /**
         * queue to be run after OK -- for movements, etc.
         * only if necessary... send if nothing is already in the queue
         * @param {string} gcode to send
         * @memberOf LivePrinter
         */
        function queueGCode(gcode) {
            let message = codeToJSON(gcode);
            if (outgoingQueue.length > 0)
                outgoingQueue.push(message);
            else
                socketHandler.socket.send(message);
        }

        /**
         * This function takes the highlighted "local" code from the editor and runs the compiling and error-checking functions.
         * @memberOf LivePrinter
         */
        function runCode() {
            let code = CodeEditor.getSelection();
            const cursor = CodeEditor.getCursor();

            // parse first??
            let validCode = true;

            if (!code) {
                // info level
                //console.log("no selections");
                code = CodeEditor.getLine(cursor.line);
                CodeEditor.setSelection({ line: cursor.line, ch: 0 }, { line: cursor.line, ch: code.length });
            }
            // blink the form
            blinkElem($("form"));

            // run code
            //if (validCode) {
            try {
                globalEval(code, cursor.line + 1);
            } catch (e) {
                doError(e);
            }
        }


        /**
         * This function takes the highlighted "global" code from the editor and runs the compiling and error-checking functions.
         */
        function runGlobalCode() {
            let code = GlobalCodeEditor.getSelection();
            const cursor = GlobalCodeEditor.getCursor();

            // parse first??
            let validCode = true;

            if (!code) {
                // info level
                //console.log("no selections");
                code = GlobalCodeEditor.getLine(cursor.line);
                GlobalCodeEditor.setSelection({ line: cursor.line, ch: 0 }, { line: cursor.line, ch: code.length });
            }
            // blink the form
            blinkElem($("form"));

            // run code
            //if (validCode) {
            try {
                globalEval(code, cursor.line + 1, true);
            } catch (e) {
                doError(e);
            }
        }

        /**
         * Function to start or stop polling for temperature updates
         * @param {Boolean} state true if starting, false if stopping
         * @param {Integer} interval time interval between updates
         * @memberOf LivePrinter
         */
        let updateTemperature = function (state, interval = 5000) {

            if (state) {
                // schedule temperature updates every little while
                window.scope.Scheduler.scheduleEvent({
                    name: "tempUpdates",
                    timeOffset: interval,
                    func: (time) => {
                        if (socketHandler.socket.readyState === socketHandler.socket.OPEN) {
                            sendGCode("M105");
                        }
                    },
                    repeat: true
                });
            } else {
                // stop updates
                window.scope.Scheduler.removeEventByName("tempUpdates");
            }
        };

        /**
        * Handle websockets communications
        * and event listeners
        * @memberOf LivePrinter
        */
        var socketHandler = {
            socket: null, //websocket
            listeners: [], // listeners for json rpc calls

            start: function () {
                $("#info > ul").empty();
                $("#errors > ul").empty();
                $("#commands > ul").empty();
                var url = "ws://" + location.host + "/json";
                this.socket = new WebSocket(url);
                console.log('opening socket');

                this.socket.onmessage = function (event) {
                    //console.log(event.data);
                    let jsonRPC = JSON.parse(event.data);
                    //console.log(jsonRPC);
                    socketHandler.handleJSONRPC(jsonRPC);
                    socketHandler.showMessage(event.data);
                };

                // runs when printer connection is established via websockets
                this.socket.onopen = function () {
                    // TEST
                    // printer.extrude({
                    //     'x': 20,
                    //     'y': 30,
                    //     'z': 10,
                    // });

                    //sendGCode("G92");
                    //sendGCode("G28");

                    var node = $("<li>PRINTER CONNECTED</li>");
                    node.hide();
                    $("#info").prepend(node);
                    node.slideDown();

                    let message = {
                        'jsonrpc': '2.0',
                        'id': 6,
                        'method': 'get-serial-ports',
                        'params': []
                    };
                    let message_json = JSON.stringify(message);
                    this.send(message_json);
                };
            },

            /**
             * Sends a message as a JSON string. Will stringify any object sent. 
             * @param {any} message message to send (should be some sort of JSON format)
             */
            sendMessage(message) {
                let message_json = JSON.stringify(message);
                this.socket.send(message_json);
            },

            /**
             * Show a message in the GUI 
             * @param {Object} message message to show (should have id and html properties)
             */ 
            showMessage: function (message) {
                var existing = $("#m" + message.id);
                if (existing.length > 0) return;
                var node = $(message.html);
                node.hide();
                $("#inbox").prepend(node);
                node.slideDown();
            },

            handleError: function (errorJSON) {
                // TODO:
                console.log("JSON RPC ERROR: " + errorJSON);
                errorHandler.error({ message: errorJSON });
            },

            handleJSONRPC: function (jsonRPC) {
                // call all listeners
                //console.log("socket:");
                //console.log(jsonRPC);
                this.listeners.map(listener => { if (listener[jsonRPC.method]) { listener[jsonRPC.method](jsonRPC.params); } });
            },

            //
            // add a listener to the queue of jsonrpc event listeners
            // must have a function for jsonrpc event method name which takes appropriate params json object
            registerListener: function (listener) {
                this.listeners.push(listener);
            },

            removeListener: function (listener) {
                this.listeners = this.listeners.filter(l => l !== listener);
            }
        };

        // TEST

        //var testListener = {
        //    "info": function (params) {
        //        console.log("INFO:");
        //        console.log(params);
        //    }
        //};

        //socketHandler.registerListener(testListener);


        /*
        * START SETTING UP SESSION VARIABLES ETC>
        * **************************************
        * 
        */
        if (scope.printer) delete scope.printer;

        scope.printer = new Printer(sendGCode);

        // handler for JSON-RPC calls from server
        const jsonrpcPositionListener = {
            "position": function (params) {
                console.log("position:");
                console.log(params);
                scope.printer.x = parseFloat(params.x);
                scope.printer.y = parseFloat(params.y);
                scope.printer.z = parseFloat(params.z);
                scope.printer.e = parseFloat(params.e);
            }
        };

        socketHandler.registerListener(jsonrpcPositionListener);

        $("#gcode").select();  // focus on code input
        socketHandler.start(); // start websockets

        const responseJSON = JSON.stringify({
            "jsonrpc": "2.0",
            "id": 4,
            "method": "response",
            "params": []
        });

        let waitingForResponse = false; // only ask for responses if we expect them?

        window.scope.Scheduler.scheduleEvent({
            name: "queryResponses",
            timeOffset: 80,
            func: function (event) {
                //console.log(message_json);
                if (socketHandler.socket.readyState === socketHandler.socket.OPEN) {
                    socketHandler.socket.send(responseJSON);
                }
            },
            repeat: true
        });

        /**
         * json-rpc temperature event handler
         * @memberOf LivePrinter
         */
        const tempHandler = {
            'temperature': function (tempEvent) {
                //console.log("temp event:");
                //console.log(tempEvent);
                let tmp = parseFloat(tempEvent.hotend).toFixed(2);
                let target = parseFloat(tempEvent.hotend_target).toFixed(2);

                $("#temperature > p").html(
                    '<strong>hotend temp/target:'
                    + tmp + " / " + target
                    + '</strong>');

                // look for 10% diff, it's not very accurate...
                /*
                if ((Math.abs(tmp - target) / target) < 0.10) {
                    let gcodeString = "";
                    for (var i=1; i<5; i++)
                    {
                            gcodeString +='M300 P200 S' + i*220+'\n';
                    }
                    sendGCode(gcodeString);
                }
                */
            }
        };
        socketHandler.registerListener(tempHandler);


        /**
         * json-rpc error event handler
         * @memberOf LivePrinter
         */
        const errorHandler = {
            'error': function (event) {
                appendLoggingNode($("#errors > ul"), event.time, event.message);
                blinkElem($("#errors-tab"));
                blinkElem($("#inbox"));
            }
        };
        socketHandler.registerListener(errorHandler);


        /**
         * json-rpc error event handler
         * @memberOf LivePrinter
         */
        const infoHandler = {
            'info': function (event) {
                appendLoggingNode($("#info > ul"), event.time, event.message);
                blinkElem($("#info-tab"));
            },
            'resend': function (event) {
                appendLoggingNode($("#info > ul"), event.time, event.message);
                blinkElem($("#info-tab"));
                blinkElem($("#inbox"));
            }
        };


        socketHandler.registerListener(infoHandler);

        /**
         * json-rpc gcode event handler
         * @memberOf LivePrinter
         */
        const commandHandler = {
            'gcode': function (event) {
                //(new Date(parseInt(event.time))).toLocaleDateString('en-US')
                appendLoggingNode($("#commands > ul"), event.time, event.message);
                blinkElem($("#commands-tab"));
                blinkElem($("#inbox"));
            }
        };

        socketHandler.registerListener(commandHandler);

        /**
         * json-rpc ok event handler
         * @memberOf LivePrinter
         */
        const okHandler = {
            'ok': function (event) {
                //console.log("ok event:");
                //console.log(event);
                if (outgoingQueue.length > 0) {
                    let msg = outgoingQueue.pop();
                    socketHandler.socket.send(msg);
                }
                blinkElem($("#commands-tab"));
            }
        };

        socketHandler.registerListener(okHandler);


        /**
         * json-rpc serial ports list event handler
         * @memberOf LivePrinter
         */
        const portsListHandler = {
            'serial-ports-list': function (event) {
                window.scope.serialPorts = []; // reset serial ports list
                let portsDropdown = $("#serial-ports-list");
                //console.log("list of serial ports:");
                //console.log(event);
                portsDropdown.empty();
                if (event.message.length === 0) {
                    appendLoggingNode($("#info > ul"), (new Date()).getUTCMilliseconds(), "<li>no serial ports found</li > ");
                    window.scope.serialPorts.push("dummy");
                }
                else {
                    let msg = "<ul>Serial ports found:";
                    for (let p of event.message) {
                        msg += "<li>" + p + "</li>";
                        window.scope.serialPorts.push(p);
                    }
                    msg += "</ul>";
                    appendLoggingNode($("#info > ul"), (new Date()).getUTCMilliseconds(), msg);
                }

                window.scope.serialPorts.forEach(function (port) {
                    //console.log("PORT:" + port);
                    let newButton = $('<button class="dropdown-item" type="button" data-port-name="' + port + '">' + port + '</button>');
                    //newButton.data("portName", port);
                    newButton.click(function (e) {
                        e.preventDefault();
                        let me = $(this);
                        let message = {
                            'jsonrpc': '2.0',
                            'id': 6,
                            'method': 'set-serial-port',
                            'params': [me.data("portName")]
                        };
                        socketHandler.socket.send(JSON.stringify(message));
                        $("#serial-ports-list > drop-down-menu > button").removeClass("active");
                        me.addClass("active");
                    });
                    portsDropdown.append(newButton);
                });

                blinkElem($("#serial-ports-list"));
                blinkElem($("#info-tab"));
            }
        };

        socketHandler.registerListener(portsListHandler);

        /**
         * Append a dismissible, styled text node to one of the side menus, formatted appropriately.
         * @param {jQuery} elem JQuery element to append this to
         * @param {Number} time Time of the event
         * @param {String} message message text for new element
         * @memberOf LivePrinter
         */
        function appendLoggingNode(elem, time, message) {
            const dateStr = (new Date(time)).toLocaleString('en-US', { year: '2-digit', month: '2-digit', day: '2-digit', hours: '2-digit', minutes: '2-digit', seconds: '2-digit' });
            elem.prepend("<li class='alert alert-primary alert-dismissible fade show' role='alert'>"
                + dateStr
                + '<strong>'
                + ": " + message
                + '</strong>'
                + '<button type="button" class="close" data-dismiss="alert" aria-label="Close">'
                + '<span aria-hidden="true">&times;</span></button>'
                + "</li>");
        }

        ////////////////////////////////////////////////////////
        ///////////////// GUI SETUP ////////////////////////////
        /////////////////////////////////////////////////////////////////////////////////////////////////////////

        
        $('a[data-toggle="pill"]').on('shown.bs.tab', function (e) {
            const target = $(e.target).attr("href") // activated tab
            if (target === "#global-code-editor-area") {
                GlobalCodeEditor.refresh();
                setLanguageMode(); // have to update gutter, etc.
                clearError();
            }
            else if (target === "#code-editor-area") {
                CodeEditor.refresh();
                setLanguageMode(); // have to update gutter, etc.
                clearError();
            }  
            //console.log(target);
        });

        //
        // redirect error to browser GUI
        //
        $(window).on("error", function (evt) {
            console.log("jQuery error event:", evt);
            var e = evt.originalEvent; // get the javascript event
            console.log("original event:", e);
            doError(e);
        });

        $("#sendCode").on("click", runCode);

        $("#temp-display-btn").on("click", function () {
            let me = $(this);
            let doUpdates = !me.hasClass('active'); // because it becomes active *after* a push
            updateTemperature(doUpdates);
            if (doUpdates) {
                me.text("stop polling temperature");
            }
            else {
                me.text("start polling Temperature");
            }
            me.button('toggle');
        });

        $("#refresh-serial-ports-btn").on("click", function () {
            let me = $(this);
            getSerialPorts();
        });

        $("#python-mode-btn").on("click", function () {
            let me = $(this);
            pythonMode = !me.hasClass('active'); // because it becomes active *after* a push

            if (pythonMode) {
                me.text("python mode");
            }
            else {
                me.text("javascript mode");
            }
            setLanguageMode(); // update codemirror editor
            me.button('toggle');
        });

        // make sure language mode is set
        setLanguageMode();

        scope.clearPrinterCommandQueue = function () {
            let message = {
                'jsonrpc': '2.0',
                'id': 7,
                'method': 'clear-command-queue',
                'params': []
            };
            socketHandler.socket.send(JSON.stringify(message));
        };

        /*
         * Clear printer queue on server 
         */
        $("#clear-btn").on("click", scope.clearPrinterCommandQueue);


        // TODO: temp probe that gets scheduled every 300ms and then removes self when
        // tempHandler called


        ////////////////////////////////////////////////////////////////////////////////////////
        ////////////////////////////////////////////////////////////////////////////////////////
        // update printing API to share with running script
        scope.socket = socketHandler;
        scope.sendGCode = sendGCode;

        // mouse handling functions
        scope.mx = 0;
        scope.my = 0;
        scope.pmx = 0;
        scope.pmy = 0;
        scope.md = false; // mouse down
        scope.pmd = false; // previous mouse down

        // add click handler - wrapper for jquery
        scope.click = function (func, elem = "undefined") {
            if (elem !== "undefined" || elem) {
                return $(elem).click(func);
            }
            else {
                return $(window).click(func);
            }
        };

        /**
         * 
         * @param {Function} func function to run when mouse moved
         * @param {any} minDelta minimum mouse distance, under which the function won't be run
         * @example 
         * Example in use:
         * s.mousemove( function(e) {
         *     console.log(e);
	     *     console.log((e.x-e.px) + "," + (e.y-e.py));
         *   }, 20);
         * @memberOf LivePrinter
         */
        scope.mousemove = function (func, minDelta = 20) {
            // global mouse functions
            // remove all revious handlers -- might be dangerous?
            $(document).off("mousemove");
            $(document).off("mousedown");

            $(document).mousedown(function (e) {
                scope.pmd = scope.md;
                scope.md = true;
                scope.pmx = e.pageX;
                scope.pmy = e.pageY;

                $(document).mousemove(function (evt) {
                    let self = $(this);
                    scope.mx = evt.pageX;
                    scope.my = evt.pageY;
                    let distX = scope.mx - scope.pmx;
                    let distY = scope.my - scope.pmy;
                    let dist = distX * distX + distY * distY;
                    if (minDelta * minDelta < dist) {
                        console.log("mouse move:" + evt.pageX + "," + evt.pageY);
                        func.call(this, {
                            x: scope.mx, y: scope.my,
                            px: scope.pmx, py: scope.pmy,
                            dx: (scope.mx - scope.pmx) / self.width(),
                            dy: (scope.my - scope.pmy) / self.height(),
                            md: scope.md, pmd: scope.pmd
                        });
                        scope.pmx = evt.pageX;
                        scope.pmy = evt.pageY;
                    }
                });
            });
            $(document).mouseup(function (e) {
                scope.pmd = scope.md;
                scope.md = false;
                $(document).off("mousemove");
            });
        };

        ///////////////////////////////////////////////////////////////////////////////////////////////
        ///////////////////// Browser storage /////////////////////////////////////////////////////////
        ///////////////////////////////////////////////////////////////////////////////////////////////

        /**
        * Local Storage for saving/loading documents.
        * Default behaviour is loading the last edited session.
        * @param {String} type type (global key in window object) for storage object 
        * @returns {Boolean} true or false, if storage is available
        * @see https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API/Using_the_Web_Storage_API
        * @memberOf LivePrinter
        */
        function storageAvailable(type) {
            try {
                var storage = window[type],
                    x = '__storage_test__';
                storage.setItem(x, x);
                storage.removeItem(x);
                return true;
            }
            catch (e) {
                return e instanceof DOMException && (
                    // everything except Firefox
                    e.code === 22 ||
                    // Firefox
                    e.code === 1014 ||
                    // test name field too, because code might not be present
                    // everything except Firefox
                    e.name === 'QuotaExceededError' ||
                    // Firefox
                    e.name === 'NS_ERROR_DOM_QUOTA_REACHED') &&
                    // acknowledge QuotaExceededError only if there's something already stored
                    storage.length !== 0;
            }
        }

        const editedLocalKey = "editedLoc";
        const savedLocalKey = "savedLoc";

        const editedGlobalKey = "editedGlob";
        const savedGlobalKey = "savedGlob";

        const localChangeFunc = cm => {
            let txt = cm.getDoc().getValue();
            localStorage.setItem(editedLocalKey, txt);
        };

        const globalChangeFunc = cm => {
            let txt = cm.getDoc().getValue();
            localStorage.setItem(editedGlobalKey, txt);
        };

        CodeEditor.on("change", localChangeFunc);

        GlobalCodeEditor.on("change", globalChangeFunc);

        let reloadLocalSession = () => {
            CodeEditor.off("change");
            let newFile = localStorage.getItem(editedLocalKey);
            if (newFile !== undefined && newFile) {
                blinkElem($(".CodeMirror"), "slow", () => {
                    CodeEditor.swapDoc(
                        CodeMirror.Doc(
                            newFile, "javascript"
                        )
                    );
                    CodeEditor.on("change", localChangeFunc);
                });
            }
            setLanguageMode();
        };

        let reloadGlobalSession = () => {
            GlobalCodeEditor.off("change");
            let newFile = localStorage.getItem(editedGlobalKey);
            if (newFile !== undefined && newFile) {
                blinkElem($(".CodeMirror"), "slow", () => {
                    GlobalCodeEditor.swapDoc(
                        CodeMirror.Doc(
                            newFile, "javascript"
                        )
                    );
                    GlobalCodeEditor.on("change", globalChangeFunc);
                });
            }
            setLanguageMode();
        };

        $("#reload-edited-session").on("click", reloadLocalSession);

        $("#save-session").on("click", () => {
            CodeEditor.off("change");
            let txt = CodeEditor.getDoc().getValue();
            localStorage.setItem(savedKey, txt);
            blinkElem($(".CodeMirror"), "fast", () => {
                CodeEditor.on("change", localChangeFunc);
            });
            // mark as reload-able
            $("#reload-saved-session").removeClass("graylink");
        });

        // start as non-reloadable
        $("#reload-saved-session").addClass("graylink");

        $("#reload-saved-session").on("click", () => {
            CodeEditor.off("change");
            let newFile = localStorage.getItem(savedKey);
            if (newFile !== undefined && newFile) {
                blinkElem($(".CodeMirror"), "slow", () => {
                    CodeEditor.swapDoc(
                        CodeMirror.Doc(
                            newFile, "javascript"
                        )
                    );
                    CodeEditor.on("change", localChangeFunc);
                });
            }
        });

        if (storageAvailable('localStorage')) {
            // finally, load the last stored session:
            reloadGlobalSession();
            reloadLocalSession();
        }
        else {
            errorHandler({ name: "save error", message: "no local storage available for saving files!" });
        }
        // disable form reloading on code compile
        $('form').submit(false);


        /**
          * Evaluate the code in local (within closure) or global space according to the current editor mode (javascript/python).
          * @param {string} code to evaluate
          * @param {integer} line line number for error displaying
          * @param {Boolean} globally true if executing in global space, false (normal) if executing within closure to minimise side-effects
          * @memberOf LivePrinter
          */
        function globalEval(code, line, globally = false) {
            clearError();
            code = jQuery.trim(code);
            console.log(code);
            if (code) {
                if (pythonMode) {


                    code = "from browser import document as doc\nfrom browser import window as win\nlp = win.scope.printer\ngcode = win.scope.sendGCode\n"
                        + code;

                    let script = document.createElement("script");
                    script.type = "text/python";
                    script.text = code;

                    // run and remove
                    let scriptsContainer = $("#python-scripts");
                    scriptsContainer.empty(); // remove old ones
                    scriptsContainer.append(script); // append new one

                    brython(); // re-run brython

                    //code = __BRYTHON__.py2js(code + "", "newcode", "newcode").to_js();
                    console.log(code);
                    // eval(code);
                }
                else {
                    if (!globally) {
                        // give quick access to liveprinter API
                        code = "let cancel = s.clearPrinterCommandQueue;" + code; //alias
                        code = "let lp = window.scope.printer;" + code;
                        code = "let sched = window.scope.Scheduler;" + code;
                        code = "let socket = window.scope.socket;" + code;
                        code = "let gcode = window.scope.sendGCode;" + code;
                        code = "let s = window.scope;" + code;
                        code = "let None = function() {};" + code;


                        // wrap code in anonymous function to avoid redeclaring scope variables and
                        // scope bleed.  For global functions that persist, use lp scope or s

                        // error handling
                        code = 'try {' + code;
                        code = code + '} catch (e) { e.lineNumber=line;window.doError(e); }';

                        code = "let line =" + line + ";" + code;


                        // function wrapping
                        code = '(function(){"use strict";' + code;
                        code = code + "})();";
                    }

                    console.log("adding code:" + code);
                    let script = document.createElement("script");
                    script.text = code;
                    /*
                        * NONE OF THIS WORKS IN CHROME... should be aesy, but no.
                        *
                    let node = null;
                    script.onreadystatechange = script.onload = function () {
                        console.log("loaded");
                        node.printer = printer;
                        node.scheduler = Scheduler;
                        node.socket = socketHandler;
    
                        node.parentNode.removeChild(script);
                        node = null;
                    };
                    script.onerror = function (e) { console.log("script error:" + e) };
    
                    node = document.head.appendChild(script);
                    */
                    // run and remove
                    document.head.appendChild(script).parentNode.removeChild(script);
                }
            }
        } // end globalEval

        //brython(10);
    });