diff --git a/README.md b/README.md index 09d3b6b..96a49b7 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,8 @@ allows * reading the communication log and send arbitrary codes to be executed by the printer * moving the X, Y and Z axis (jog controls, although very ugly ones right now) * changing the speed modifiers for inner & outer wall, fill and support -* optional: visual monitoring of the printer via webcam stream integrated into the UI (using MJPG-Streamer) +* optional: visual monitoring of the printer via webcam stream integrated into the UI (using e.g. MJPG-Streamer) +* optional: creation of timelapse recordings of the printjob via webcam stream (using e.g. MJPG-Streamer) -- currently two timelaspe methods are implemented, triggering a shot on z-layer change or every "n" seconds The intended usecase is to run the Printer WebUI on a single-board computer like the Raspberry Pi and a WiFi module, connect the printer to the server and therefore create a WiFi-enabled 3D printer. @@ -65,6 +66,8 @@ The following example config should explain the available options: [webcam] # use this option to enable display of a webcam stream in the UI, e.g. via MJPG-Streamer stream = http://10.0.0.2:8080/?action=stream + # use this option to enable timelapse support via snapshot, e.g. via MJPG-Streamer + snapshot = http://10.0.0.1:8080/?action=snapshot Setup on a Raspberry Pi running Raspbian ---------------------------------------- @@ -132,6 +135,6 @@ It also uses the following libraries and frameworks for backend and frontend: * Flot: http://www.flotcharts.org/ * jQuery File Upload: http://blueimp.github.com/jQuery-File-Upload/ -And this for Webcam support: +The following software is recommended for Webcam support on the Raspberry Pi: * MJPG-Streamer: http://sourceforge.net/apps/mediawiki/mjpg-streamer/index.php?title=Main_Page diff --git a/printer_webui/printer.py b/printer_webui/printer.py index f3ed0b2..4e9fe42 100644 --- a/printer_webui/printer.py +++ b/printer_webui/printer.py @@ -3,7 +3,6 @@ __author__ = "Gina Häußge " __license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' import time -import os from threading import Thread import datetime @@ -58,6 +57,8 @@ class Printer(): self.feedrateModifierMapping = {"outerWall": "WALL-OUTER", "innerWall": "WALL_INNER", "fill": "FILL", "support": "SUPPORT"} + self.timelapse = None + # comm self.comm = None @@ -97,6 +98,15 @@ class Printer(): self.comm.setFeedrateModifier(self.feedrateModifierMapping[structure], percentage / 100.0) + def setTimelapse(self, timelapse): + if self.timelapse is not None and self.isPrinting(): + self.timelapse.onPrintjobStopped() + del self.timelapse + self.timelapse = timelapse + + def getTimelapse(self): + return self.timelapse + def mcLog(self, message): """ Callback method for the comm object, called upon log output. @@ -135,8 +145,15 @@ class Printer(): Callback method for the comm object, called if the connection state changes. New state is stored for retrieval by the frontend. """ + oldState = self.state self.state = state + if self.timelapse is not None: + if oldState == self.comm.STATE_PRINTING: + self.timelapse.onPrintjobStopped() + elif state == self.comm.STATE_PRINTING: + self.timelapse.onPrintjobStarted() + def mcMessage(self, message): """ Callback method for the comm object, called upon message exchanges via serial. @@ -152,13 +169,19 @@ class Printer(): """ self.printTime = self.comm.getPrintTime() self.printTimeLeft = self.comm.getPrintTimeRemainingEstimate() + oldProgress = self.progress; self.progress = self.comm.getPrintPos() + if self.timelapse is not None: + self.timelapse.onPrintjobProgress(oldProgress, self.progress, int(round(self.progress * 100 / len(self.gcodeList)))) def mcZChange(self, newZ): """ Callback method for the comm object, called upon change of the z-layer. """ + oldZ = self.currentZ self.currentZ = newZ + if self.timelapse is not None: + self.timelapse.onZChange(oldZ, self.currentZ) def onGcodeLoaded(self, gcodeLoader): """ diff --git a/printer_webui/server.py b/printer_webui/server.py index fada932..eefbada 100644 --- a/printer_webui/server.py +++ b/printer_webui/server.py @@ -2,29 +2,22 @@ __author__ = "Gina Häußge " __license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' -from flask import Flask, request, render_template, jsonify, send_file, abort +from flask import Flask, request, render_template, jsonify from werkzeug import secure_filename from printer_webui.printer import Printer, getConnectionOptions from printer_webui.settings import settings +from printer_webui.timelapse import ZTimelapse, TimedTimelapse -import sys import os import fnmatch -import StringIO BASEURL="/ajax/" SUCCESS={} -UPLOAD_FOLDER = os.path.join(settings().settings_dir, "uploads") -if not os.path.isdir(UPLOAD_FOLDER): - os.makedirs(UPLOAD_FOLDER) +UPLOAD_FOLDER = settings().getBaseFolder("uploads") ALLOWED_EXTENSIONS = set(["gcode"]) -WEBCAM_FOLDER = os.path.join(settings().settings_dir, "webcam") -if not os.path.isdir(WEBCAM_FOLDER): - os.makedirs(WEBCAM_FOLDER) - app = Flask("printer_webui") printer = Printer() @@ -235,6 +228,48 @@ def deleteGcodeFile(): os.remove(secure) return readGcodeFiles() +#~~ timelapse configuration + +@app.route(BASEURL + "timelapse", methods=["GET"]) +def getTimelapseConfig(): + timelapse = printer.getTimelapse() + + type = "off" + additionalConfig = {} + if timelapse is not None and isinstance(timelapse, ZTimelapse): + type = "zchange" + elif timelapse is not None and isinstance(timelapse, TimedTimelapse): + type = "timed" + additionalConfig = { + "interval": timelapse.interval + } + + return jsonify({ + "type": type, + "config": additionalConfig + }) + +@app.route(BASEURL + "timelapse", methods=["POST"]) +def setTimelapseConfig(): + if not request.values.has_key("type"): + return getTimelapseConfig() + + type = request.values["type"] + timelapse = None + if "zchange" == type: + timelapse = ZTimelapse() + elif "timed" == type: + interval = 10 + if request.values.has_key("interval"): + try: + interval = int(request.values["interval"]) + except ValueError: + pass + timelapse = TimedTimelapse(interval) + + printer.setTimelapse(timelapse) + return getTimelapseConfig() + #~~ settings @app.route(BASEURL + "settings", methods=["GET"]) diff --git a/printer_webui/settings.py b/printer_webui/settings.py index 7f3a278..abe551c 100644 --- a/printer_webui/settings.py +++ b/printer_webui/settings.py @@ -26,7 +26,12 @@ default_settings = { "port": 5000 }, "webcam": { - "stream": None + "stream": None, + "snapshot": None + }, + "folder": { + "uploads": None, + "timelapse": None } } @@ -74,6 +79,7 @@ class Settings(object): with open(os.path.join(self.settings_dir, "config.ini"), "wb") as configFile: self._config.write(configFile) self._changes = None + self.load() def get(self, section, key): if section not in default_settings.keys(): @@ -100,6 +106,19 @@ class Settings(object): except ValueError: return None + def getBaseFolder(self, type): + if type not in default_settings["folder"].keys(): + return None + + folder = self.get("folder", type) + if folder is None: + folder = os.path.join(self.settings_dir, type) + + if not os.path.isdir(folder): + os.makedirs(folder) + + return folder + def set(self, section, key, value): if section not in default_settings.keys(): return None diff --git a/printer_webui/static/css/ui.css b/printer_webui/static/css/ui.css index 148869c..e52db34 100644 --- a/printer_webui/static/css/ui.css +++ b/printer_webui/static/css/ui.css @@ -97,4 +97,5 @@ table th.gcode_files_action, table td.gcode_files_action { #webcam_container { height: 440px; background-color: #000000; + margin-bottom: 20px; } \ No newline at end of file diff --git a/printer_webui/static/js/ui.js b/printer_webui/static/js/ui.js index bfef9eb..601e19e 100644 --- a/printer_webui/static/js/ui.js +++ b/printer_webui/static/js/ui.js @@ -384,7 +384,78 @@ function GcodeFilesViewModel() { } var gcodeFilesViewModel = new GcodeFilesViewModel(); -function DataUpdater(connectionViewModel, printerStateViewModel, temperatureViewModel, speedViewModel, terminalViewModel) { +function WebcamViewModel() { + var self = this; + + self.timelapseType = ko.observable(undefined); + self.timelapseTimedInterval = ko.observable(undefined); + + self.isErrorOrClosed = ko.observable(undefined); + self.isOperational = ko.observable(undefined); + self.isPrinting = ko.observable(undefined); + self.isPaused = ko.observable(undefined); + self.isError = ko.observable(undefined); + self.isReady = ko.observable(undefined); + self.isLoading = ko.observable(undefined); + + self.intervalInputEnabled = ko.computed(function() { + return ("timed" == self.timelapseType()); + }) + + self.isOperational.subscribe(function(newValue) { + self.requestData(); + }) + + self.requestData = function() { + $.ajax({ + url: AJAX_BASEURL + "timelapse", + type: "GET", + dataType: "json", + success: self.fromResponse + }) + } + + self.fromResponse = function(response) { + self.timelapseType(response.type) + + if (response.type == "timed" && response.config && response.config.interval) { + self.timelapseTimedInterval(response.config.interval) + } else { + self.timelapseTimedInterval(undefined) + } + } + + self.fromStateResponse = function(response) { + self.isErrorOrClosed(response.closedOrError); + self.isOperational(response.operational); + self.isPaused(response.paused); + self.isPrinting(response.printing); + self.isError(response.error); + self.isReady(response.ready); + self.isLoading(response.loading); + } + + self.save = function() { + var data = { + "type": self.timelapseType() + } + + if (self.timelapseType() == "timed") { + data["interval"] = self.timelapseTimedInterval(); + } + + $.ajax({ + url: AJAX_BASEURL + "timelapse", + type: "POST", + dataType: "json", + data: data, + success: self.fromResponse + }) + } +} +var webcamViewModel = new WebcamViewModel(); + +function DataUpdater(connectionViewModel, printerStateViewModel, temperatureViewModel, speedViewModel, terminalViewModel, webcamViewModel) { var self = this; self.updateInterval = 500; @@ -397,6 +468,7 @@ function DataUpdater(connectionViewModel, printerStateViewModel, temperatureView self.temperatureViewModel = temperatureViewModel; self.terminalViewModel = terminalViewModel; self.speedViewModel = speedViewModel; + self.webcamViewModel = webcamViewModel; self.requestData = function() { var parameters = {}; @@ -418,6 +490,7 @@ function DataUpdater(connectionViewModel, printerStateViewModel, temperatureView self.printerStateViewModel.fromResponse(response); self.connectionViewModel.fromStateResponse(response); self.speedViewModel.fromResponse(response); + self.webcamViewModel.fromStateResponse(response); if (response.temperatures) self.temperatureViewModel.fromResponse(response); @@ -436,7 +509,7 @@ function DataUpdater(connectionViewModel, printerStateViewModel, temperatureView }); } } -var dataUpdater = new DataUpdater(connectionViewModel, printerStateViewModel, temperatureViewModel, speedViewModel, terminalViewModel); +var dataUpdater = new DataUpdater(connectionViewModel, printerStateViewModel, temperatureViewModel, speedViewModel, terminalViewModel, webcamViewModel); $(function() { @@ -578,11 +651,17 @@ $(function() { ko.applyBindings(terminalViewModel, document.getElementById("term")); ko.applyBindings(speedViewModel, document.getElementById("speed")); + var webcamElement = document.getElementById("webcam"); + if (webcamElement) { + ko.applyBindings(webcamViewModel, document.getElementById("webcam")); + } + //~~ startup commands dataUpdater.requestData(); connectionViewModel.requestData(); gcodeFilesViewModel.requestData(); + webcamViewModel.requestData(); } ); diff --git a/printer_webui/templates/index.html b/printer_webui/templates/index.html index 877e3b6..8493b24 100644 --- a/printer_webui/templates/index.html +++ b/printer_webui/templates/index.html @@ -221,6 +221,26 @@
+ +
+ Timelapse + + + + +
+ + +
+ +
+ +
+
{% endif %}