# coding=utf-8 __author__ = "Gina Häußge " __license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' from werkzeug.utils import secure_filename import tornadio2 from flask import Flask, request, render_template, jsonify, send_from_directory, url_for, current_app, session, abort from flask.ext.login import LoginManager, login_user, logout_user, login_required, current_user from flask.ext.principal import Principal, Permission, RoleNeed, Identity, identity_changed, AnonymousIdentity, identity_loaded, UserNeed import os import threading import logging, logging.config import subprocess from octoprint.printer import Printer, getConnectionOptions from octoprint.settings import settings, valid_boolean_trues import octoprint.timelapse as timelapse import octoprint.gcodefiles as gcodefiles import octoprint.util as util import octoprint.users as users import octoprint.events as events SUCCESS = {} BASEURL = "/ajax/" app = Flask("octoprint") # Only instantiated by the Server().run() method # In order that threads don't start too early when running as a Daemon printer = None gcodeManager = None userManager = None eventManager =None principals = Principal(app) admin_permission = Permission(RoleNeed("admin")) user_permission = Permission(RoleNeed("user")) #~~ Printer state class PrinterStateConnection(tornadio2.SocketConnection): def __init__(self, printer, gcodeManager, userManager, session, endpoint=None): tornadio2.SocketConnection.__init__(self, session, endpoint) self._logger = logging.getLogger(__name__) self._temperatureBacklog = [] self._temperatureBacklogMutex = threading.Lock() self._logBacklog = [] self._logBacklogMutex = threading.Lock() self._messageBacklog = [] self._messageBacklogMutex = threading.Lock() self._printer = printer self._gcodeManager = gcodeManager self._userManager = userManager def on_open(self, info): global eventManager eventManager.fire("ClientOpen") self._logger.info("New connection from client") # Use of global here is smelly printer.registerCallback(self) gcodeManager.registerCallback(self) def on_close(self): global eventManager eventManager.fire("ClientClosed") self._logger.info("Closed client connection") # Use of global here is smelly printer.unregisterCallback(self) gcodeManager.unregisterCallback(self) def on_message(self, message): pass def sendCurrentData(self, data): # add current temperature, log and message backlogs to sent data with self._temperatureBacklogMutex: temperatures = self._temperatureBacklog self._temperatureBacklog = [] with self._logBacklogMutex: logs = self._logBacklog self._logBacklog = [] with self._messageBacklogMutex: messages = self._messageBacklog self._messageBacklog = [] data.update({ "temperatures": temperatures, "logs": logs, "messages": messages }) self.emit("current", data) def sendHistoryData(self, data): self.emit("history", data) def sendUpdateTrigger(self, type): self.emit("updateTrigger", type) def addLog(self, data): with self._logBacklogMutex: self._logBacklog.append(data) def addMessage(self, data): with self._messageBacklogMutex: self._messageBacklog.append(data) def addTemperature(self, data): with self._temperatureBacklogMutex: self._temperatureBacklog.append(data) # Did attempt to make webserver an encapsulated class but ended up with __call__ failures @app.route("/") def index(): return render_template( "index.jinja2", ajaxBaseUrl=BASEURL, webcamStream=settings().get(["webcam", "stream"]), enableTimelapse=(settings().get(["webcam", "snapshot"]) is not None and settings().get(["webcam", "ffmpeg"]) is not None), enableGCodeVisualizer=settings().get(["feature", "gCodeVisualizer"]), enableSystemMenu=settings().get(["system"]) is not None and settings().get(["system", "actions"]) is not None and len(settings().get(["system", "actions"])) > 0, enableAccessControl=userManager is not None ) #~~ Printer control @app.route(BASEURL + "control/connection/options", methods=["GET"]) def connectionOptions(): return jsonify(getConnectionOptions()) @app.route(BASEURL + "control/connection", methods=["POST"]) @login_required def connect(): if "command" in request.values.keys() and request.values["command"] == "connect": port = None baudrate = None if "port" in request.values.keys(): port = request.values["port"] if "baudrate" in request.values.keys(): baudrate = request.values["baudrate"] if "save" in request.values.keys(): settings().set(["serial", "port"], port) settings().setInt(["serial", "baudrate"], baudrate) settings().save() printer.connect(port=port, baudrate=baudrate) elif "command" in request.values.keys() and request.values["command"] == "disconnect": printer.disconnect() eventManager.fire("Disconnected") return jsonify(SUCCESS) @app.route(BASEURL + "control/command", methods=["POST"]) @login_required def printerCommand(): if "application/json" in request.headers["Content-Type"]: data = request.json parameters = {} if "parameters" in data.keys(): parameters = data["parameters"] commands = [] if "command" in data.keys(): commands = [data["command"]] elif "commands" in data.keys(): commands = data["commands"] commandsToSend = [] for command in commands: commandToSend = command if len(parameters) > 0: commandToSend = command % parameters commandsToSend.append(commandToSend) printer.commands(commandsToSend) return jsonify(SUCCESS) @app.route(BASEURL + "control/job", methods=["POST"]) @login_required def printJobControl(): if "command" in request.values.keys(): if request.values["command"] == "start": printer.startPrint() elif request.values["command"] == "pause": printer.togglePausePrint() eventManager.fire("Paused") elif request.values["command"] == "cancel": printer.cancelPrint() eventManager.fire("Cancelled") return jsonify(SUCCESS) @app.route(BASEURL + "control/temperature", methods=["POST"]) @login_required def setTargetTemperature(): if "temp" in request.values.keys(): # set target temperature temp = request.values["temp"] printer.command("M104 S" + temp) if "bedTemp" in request.values.keys(): # set target bed temperature bedTemp = request.values["bedTemp"] printer.command("M140 S" + bedTemp) return jsonify(SUCCESS) @app.route(BASEURL + "control/jog", methods=["POST"]) @login_required def jog(): if not printer.isOperational() or printer.isPrinting(): # do not jog when a print job is running or we don't have a connection return jsonify(SUCCESS) (movementSpeedX, movementSpeedY, movementSpeedZ, movementSpeedE) = settings().get(["printerParameters", "movementSpeed", ["x", "y", "z", "e"]]) if "x" in request.values.keys(): # jog x x = request.values["x"] printer.commands(["G91", "G1 X%s F%d" % (x, movementSpeedX), "G90"]) if "y" in request.values.keys(): # jog y y = request.values["y"] printer.commands(["G91", "G1 Y%s F%d" % (y, movementSpeedY), "G90"]) if "z" in request.values.keys(): # jog z z = request.values["z"] printer.commands(["G91", "G1 Z%s F%d" % (z, movementSpeedZ), "G90"]) if "homeXY" in request.values.keys(): # home x/y printer.command("G28 X0 Y0") if "homeZ" in request.values.keys(): # home z printer.command("G28 Z0") if "extrude" in request.values.keys(): # extrude/retract length = request.values["extrude"] printer.commands(["G91", "G1 E%s F%d" % (length, movementSpeedE), "G90"]) return jsonify(SUCCESS) @app.route(BASEURL + "control/speed", methods=["GET"]) def getSpeedValues(): return jsonify(feedrate=printer.feedrateState()) @app.route(BASEURL + "control/speed", methods=["POST"]) @login_required def speed(): if not printer.isOperational(): return jsonify(SUCCESS) for key in ["outerWall", "innerWall", "fill", "support"]: if key in request.values.keys(): value = int(request.values[key]) printer.setFeedrateModifier(key, value) return getSpeedValues() @app.route(BASEURL + "control/custom", methods=["GET"]) def getCustomControls(): customControls = settings().get(["controls"]) return jsonify(controls=customControls) #~~ GCODE file handling @app.route(BASEURL + "gcodefiles", methods=["GET"]) def readGcodeFiles(): return jsonify(files=gcodeManager.getAllFileData()) @app.route(BASEURL + "gcodefiles/", methods=["GET"]) def readGcodeFile(filename): return send_from_directory(settings().getBaseFolder("uploads"), filename, as_attachment=True) @app.route(BASEURL + "gcodefiles/upload", methods=["POST"]) @login_required def uploadGcodeFile(): filename = None if "gcode_file" in request.files.keys(): file = request.files["gcode_file"] filename = gcodeManager.addFile(file) global eventManager eventManager.fire("Upload",filename) return jsonify(files=gcodeManager.getAllFileData(), filename=filename) @app.route(BASEURL + "gcodefiles/load", methods=["POST"]) @login_required def loadGcodeFile(): if "filename" in request.values.keys(): printAfterLoading = False if "print" in request.values.keys() and request.values["print"] in valid_boolean_trues: printAfterLoading = True filename = gcodeManager.getAbsolutePath(request.values["filename"]) if filename is not None: printer.loadGcode(filename, printAfterLoading) global eventManager eventManager.fire("LoadStart",filename) return jsonify(SUCCESS) @app.route(BASEURL + "gcodefiles/delete", methods=["POST"]) @login_required def deleteGcodeFile(): if "filename" in request.values.keys(): filename = request.values["filename"] gcodeManager.removeFile(filename) return readGcodeFiles() #~~ timelapse handling @app.route(BASEURL + "timelapse", methods=["GET"]) def getTimelapseData(): lapse = printer.getTimelapse() type = "off" additionalConfig = {} if lapse is not None and isinstance(lapse, timelapse.ZTimelapse): type = "zchange" elif lapse is not None and isinstance(lapse, timelapse.TimedTimelapse): type = "timed" additionalConfig = { "interval": lapse.interval() } files = timelapse.getFinishedTimelapses() for file in files: file["url"] = url_for("downloadTimelapse", filename=file["name"]) return jsonify({ "type": type, "config": additionalConfig, "files": files }) @app.route(BASEURL + "timelapse/", methods=["GET"]) def downloadTimelapse(filename): if util.isAllowedFile(filename, set(["mpg"])): return send_from_directory(settings().getBaseFolder("timelapse"), filename, as_attachment=True) @app.route(BASEURL + "timelapse/", methods=["DELETE"]) @login_required def deleteTimelapse(filename): if util.isAllowedFile(filename, set(["mpg"])): secure = os.path.join(settings().getBaseFolder("timelapse"), secure_filename(filename)) if os.path.exists(secure): os.remove(secure) return getTimelapseData() @app.route(BASEURL + "timelapse", methods=["POST"]) @login_required def setTimelapseConfig(): global eventManager if request.values.has_key("type"): type = request.values["type"] lapse = None if "zchange" == type: lapse = timelapse.ZTimelapse(eventManager) elif "timed" == type: interval = 10 if request.values.has_key("interval"): try: interval = int(request.values["interval"]) except ValueError: pass lapse = timelapse.TimedTimelapse( eventManager,interval) printer.setTimelapse(lapse) return getTimelapseData() #~~ settings @app.route(BASEURL + "settings", methods=["GET"]) def getSettings(): s = settings() [movementSpeedX, movementSpeedY, movementSpeedZ, movementSpeedE] = s.get(["printerParameters", "movementSpeed", ["x", "y", "z", "e"]]) return jsonify({ "appearance": { "name": s.get(["appearance", "name"]), "color": s.get(["appearance", "color"]) }, "printer": { "movementSpeedX": movementSpeedX, "movementSpeedY": movementSpeedY, "movementSpeedZ": movementSpeedZ, "movementSpeedE": movementSpeedE, }, "webcam": { "streamUrl": s.get(["webcam", "stream"]), "snapshotUrl": s.get(["webcam", "snapshot"]), "ffmpegPath": s.get(["webcam", "ffmpeg"]), "bitrate": s.get(["webcam", "bitrate"]), "watermark": s.getBoolean(["webcam", "watermark"]) }, "feature": { "gcodeViewer": s.getBoolean(["feature", "gCodeVisualizer"]), "waitForStart": s.getBoolean(["feature", "waitForStartOnConnect"]) }, "folder": { "uploads": s.getBaseFolder("uploads"), "timelapse": s.getBaseFolder("timelapse"), "timelapseTmp": s.getBaseFolder("timelapse_tmp"), "logs": s.getBaseFolder("logs") }, "temperature": { "profiles": s.get(["temperature", "profiles"]) }, "system": { "actions": s.get(["system", "actions"]), "events": s.get(["system", "events"]) } }) @app.route(BASEURL + "settings", methods=["POST"]) @login_required @admin_permission.require(403) def setSettings(): if "application/json" in request.headers["Content-Type"]: data = request.json s = settings() if "appearance" in data.keys(): if "name" in data["appearance"].keys(): s.set(["appearance", "name"], data["appearance"]["name"]) if "color" in data["appearance"].keys(): s.set(["appearance", "color"], data["appearance"]["color"]) if "printer" in data.keys(): if "movementSpeedX" in data["printer"].keys(): s.setInt(["printerParameters", "movementSpeed", "x"], data["printer"]["movementSpeedX"]) if "movementSpeedY" in data["printer"].keys(): s.setInt(["printerParameters", "movementSpeed", "y"], data["printer"]["movementSpeedY"]) if "movementSpeedZ" in data["printer"].keys(): s.setInt(["printerParameters", "movementSpeed", "z"], data["printer"]["movementSpeedZ"]) if "movementSpeedE" in data["printer"].keys(): s.setInt(["printerParameters", "movementSpeed", "e"], data["printer"]["movementSpeedE"]) if "webcam" in data.keys(): if "streamUrl" in data["webcam"].keys(): s.set(["webcam", "stream"], data["webcam"]["streamUrl"]) if "snapshotUrl" in data["webcam"].keys(): s.set(["webcam", "snapshot"], data["webcam"]["snapshotUrl"]) if "ffmpegPath" in data["webcam"].keys(): s.set(["webcam", "ffmpeg"], data["webcam"]["ffmpegPath"]) if "bitrate" in data["webcam"].keys(): s.set(["webcam", "bitrate"], data["webcam"]["bitrate"]) if "watermark" in data["webcam"].keys(): s.setBoolean(["webcam", "watermark"], data["webcam"]["watermark"]) if "feature" in data.keys(): if "gcodeViewer" in data["feature"].keys(): s.setBoolean(["feature", "gCodeVisualizer"], data["feature"]["gcodeViewer"]) if "waitForStart" in data["feature"].keys(): s.setBoolean(["feature", "waitForStartOnConnect"], data["feature"]["waitForStart"]) if "folder" in data.keys(): if "uploads" in data["folder"].keys(): s.setBaseFolder("uploads", data["folder"]["uploads"]) if "timelapse" in data["folder"].keys(): s.setBaseFolder("timelapse", data["folder"]["timelapse"]) if "timelapseTmp" in data["folder"].keys(): s.setBaseFolder("timelapse_tmp", data["folder"]["timelapseTmp"]) if "logs" in data["folder"].keys(): s.setBaseFolder("logs", data["folder"]["logs"]) if "temperature" in data.keys(): if "profiles" in data["temperature"].keys(): s.set(["temperature", "profiles"], data["temperature"]["profiles"]) if "system" in data.keys(): if "actions" in data["system"].keys(): s.set(["system", "actions"], data["system"]["actions"]) if "events" in data["system"].keys(): s.set(["system", "events"], data["system"]["events"]) s.save() return getSettings() #~~ user settings @app.route(BASEURL + "users", methods=["GET"]) @login_required @admin_permission.require(403) def getUsers(): if userManager is None: return jsonify(SUCCESS) return jsonify({"users": userManager.getAllUsers()}) @app.route(BASEURL + "users", methods=["POST"]) @login_required @admin_permission.require(403) def addUser(): if userManager is None: return jsonify(SUCCESS) if "application/json" in request.headers["Content-Type"]: data = request.json name = data["name"] password = data["password"] active = data["active"] roles = ["user"] if "admin" in data.keys() and data["admin"]: roles.append("admin") try: userManager.addUser(name, password, active, roles) except users.UserAlreadyExists: abort(409) return getUsers() @app.route(BASEURL + "users/", methods=["GET"]) @login_required def getUser(username): if userManager is None: return jsonify(SUCCESS) if current_user is not None and not current_user.is_anonymous() and (current_user.get_name() == username or current_user.is_admin()): user = userManager.findUser(username) if user is not None: return jsonify(user.asDict()) else: abort(404) else: abort(403) @app.route(BASEURL + "users/", methods=["PUT"]) @login_required @admin_permission.require(403) def updateUser(username): if userManager is None: return jsonify(SUCCESS) user = userManager.findUser(username) if user is not None: if "application/json" in request.headers["Content-Type"]: data = request.json # change roles roles = ["user"] if "admin" in data.keys() and data["admin"]: roles.append("admin") userManager.changeUserRoles(username, roles) # change activation if "active" in data.keys(): userManager.changeUserActivation(username, data["active"]) return getUsers() else: abort(404) @app.route(BASEURL + "users/", methods=["DELETE"]) @login_required @admin_permission.require(http_exception=403) def removeUser(username): if userManager is None: return jsonify(SUCCESS) try: userManager.removeUser(username) return getUsers() except users.UnknownUser: abort(404) @app.route(BASEURL + "users//password", methods=["PUT"]) @login_required def changePasswordForUser(username): if userManager is None: return jsonify(SUCCESS) if current_user is not None and not current_user.is_anonymous() and (current_user.get_name() == username or current_user.is_admin()): if "application/json" in request.headers["Content-Type"]: data = request.json if "password" in data.keys() and data["password"]: try: userManager.changeUserPassword(username, data["password"]) except users.UnknownUser: return app.make_response(("Unknown user: %s" % username, 404, [])) return jsonify(SUCCESS) else: return app.make_response(("Forbidden", 403, [])) #~~ system control @app.route(BASEURL + "system", methods=["POST"]) @login_required @admin_permission.require(403) def performSystemAction(): logger = logging.getLogger(__name__) if request.values.has_key("action"): action = request.values["action"] availableActions = settings().get(["system", "actions"]) for availableAction in availableActions: if availableAction["action"] == action: logger.info("Performing command: %s" % availableAction["command"]) try: subprocess.check_output(availableAction["command"], shell=True) except subprocess.CalledProcessError, e: logger.warn("Command failed with return code %i: %s" % (e.returncode, e.message)) return app.make_response(("Command failed with return code %i: %s" % (e.returncode, e.message), 500, [])) except Exception, ex: logger.exception("Command failed") return app.make_response(("Command failed: %r" % ex, 500, [])) return jsonify(SUCCESS) #~~ Login/user handling @app.route(BASEURL + "login", methods=["POST"]) def login(): if userManager is not None and "user" in request.values.keys() and "pass" in request.values.keys(): username = request.values["user"] password = request.values["pass"] if "remember" in request.values.keys() and request.values["remember"]: remember = True else: remember = False user = userManager.findUser(username) if user is not None: if user.check_password(users.UserManager.createPasswordHash(password)): login_user(user, remember=remember) identity_changed.send(current_app._get_current_object(), identity=Identity(user.get_id())) return jsonify(user.asDict()) return app.make_response(("User unknown or password incorrect", 401, [])) elif "passive" in request.values.keys(): user = current_user if user is not None and not user.is_anonymous(): return jsonify(user.asDict()) return jsonify(SUCCESS) @app.route(BASEURL + "logout", methods=["POST"]) @login_required def logout(): # Remove session keys set by Flask-Principal for key in ('identity.id', 'identity.auth_type'): del session[key] identity_changed.send(current_app._get_current_object(), identity=AnonymousIdentity()) logout_user() return jsonify(SUCCESS) @identity_loaded.connect_via(app) def on_identity_loaded(sender, identity): user = load_user(identity.id) if user is None: return identity.provides.add(UserNeed(user.get_name())) if user.is_user(): identity.provides.add(RoleNeed("user")) if user.is_admin(): identity.provides.add(RoleNeed("admin")) def load_user(id): if userManager is not None: return userManager.findUser(id) return users.DummyUser() #~~ startup code class Server(): def __init__(self, configfile=None, basedir=None, host="0.0.0.0", port=5000, debug=False): self._configfile = configfile self._basedir = basedir self._host = host self._port = port self._debug = debug def run(self): # Global as I can't work out a way to get it into PrinterStateConnection global printer global gcodeManager global userManager global eventManager from tornado.wsgi import WSGIContainer from tornado.httpserver import HTTPServer from tornado.ioloop import IOLoop from tornado.web import Application, FallbackHandler # first initialize the settings singleton and make sure it uses given configfile and basedir if available self._initSettings(self._configfile, self._basedir) # then initialize logging self._initLogging(self._debug) logger = logging.getLogger(__name__) eventManager = events.EventManager() gcodeManager = gcodefiles.GcodeManager() printer = Printer(gcodeManager, eventManager) self.event_dispatcher = events.EventResponse (eventManager,printer) self.event_dispatcher.setupEvents(settings()) # a few test commands to test the event manager is working... # eventManager.Register("Startup",self,self.event_rec) # eventManager.unRegister("Startup",self,self.event_rec) # eventManager.FireEvent("Startup") if settings().getBoolean(["accessControl", "enabled"]): userManagerName = settings().get(["accessControl", "userManager"]) try: clazz = util.getClass(userManagerName) userManager = clazz() except AttributeError, e: logger.exception("Could not instantiate user manager %s, will run with accessControl disabled!" % userManagerName) app.secret_key = "k3PuVYgtxNm8DXKKTw2nWmFQQun9qceV" login_manager = LoginManager() login_manager.session_protection = "strong" login_manager.user_callback = load_user if userManager is None: login_manager.anonymous_user = users.DummyUser principals.identity_loaders.appendleft(users.dummy_identity_loader) login_manager.init_app(app) if self._host is None: self._host = settings().get(["server", "host"]) if self._port is None: self._port = settings().getInt(["server", "port"]) logger.info("Listening on http://%s:%d" % (self._host, self._port)) app.debug = self._debug self._router = tornadio2.TornadioRouter(self._createSocketConnection) self._tornado_app = Application(self._router.urls + [ (".*", FallbackHandler, {"fallback": WSGIContainer(app)}) ]) self._server = HTTPServer(self._tornado_app) self._server.listen(self._port, address=self._host) eventManager.fire("Startup") IOLoop.instance().start() def _createSocketConnection(self, session, endpoint=None): global printer, gcodeManager, userManager return PrinterStateConnection(printer, gcodeManager, userManager, session, endpoint) def _initSettings(self, configfile, basedir): s = settings(init=True, basedir=basedir, configfile=configfile) def _initLogging(self, debug): config = { "version": 1, "formatters": { "simple": { "format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s" } }, "handlers": { "console": { "class": "logging.StreamHandler", "level": "DEBUG", "formatter": "simple", "stream": "ext://sys.stdout" }, "file": { "class": "logging.handlers.TimedRotatingFileHandler", "level": "DEBUG", "formatter": "simple", "when": "D", "backupCount": "1", "filename": os.path.join(settings().getBaseFolder("logs"), "octoprint.log") }, "serialFile": { "class": "logging.handlers.RotatingFileHandler", "level": "DEBUG", "formatter": "simple", "maxBytes": 2 * 1024 * 1024, # let's limit the serial log to 2MB in size "filename": os.path.join(settings().getBaseFolder("logs"), "serial.log") } }, "loggers": { }, "root": { "level": "INFO", "handlers": ["console", "file"] } } if debug: config["loggers"]["SERIAL"] = { "level": "DEBUG", "handlers": ["serialFile"], "propagate": False } logging.config.dictConfig(config) if __name__ == "__main__": octoprint = Server() octoprint.run()