Added settings dialog for configuring OctoPrint

Warning: Many settings will need a restart of OctoPrint to take effect, adding corresponding notes is still a TODO. There's also no proper validation and error handling yet, so use at your own risk.
master
Gina Häußge 2013-02-17 22:30:34 +01:00
parent cbae792dbe
commit 1c4203b708
6 changed files with 389 additions and 62 deletions

View File

@ -328,20 +328,64 @@ def setTimelapseConfig():
@app.route(BASEURL + "settings", methods=["GET"])
def getSettings():
s = settings()
[movementSpeedX, movementSpeedY, movementSpeedZ, movementSpeedE] = s.get(["printerParameters", "movementSpeed", ["x", "y", "z", "e"]])
return jsonify({
"serial_port": s.get("serial", "port"),
"serial_baudrate": s.get("serial", "baudrate")
"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"])
},
"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")
}
})
@app.route(BASEURL + "settings", methods=["POST"])
def setSettings():
s = settings()
if request.values.has_key("serial_port"):
s.set("serial", "port", request.values["serial_port"])
if request.values.has_key("serial_baudrate"):
s.set("serial", "baudrate", request.values["serial_baudrate"])
if "application/json" in request.headers["Content-Type"]:
data = request.json
s = settings()
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 "snapshot" in data["webcam"].keys(): s.set(["webcam", "snapshot"], data["webcam"]["snapshotUrl"])
if "ffmpeg" in data["webcam"].keys(): s.set(["webcam", "ffmpeg"], data["webcam"]["ffmpeg"])
if "bitrate" in data["webcam"].keys(): s.set(["webcam", "bitrate"], data["webcam"]["bitrate"])
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"])
s.save()
s.save()
return getSettings()
#~~ startup code

View File

@ -6,6 +6,7 @@ import ConfigParser
import sys
import os
import yaml
import logging
APPNAME="OctoPrint"
OLD_APPNAME="PrinterWebUI"
@ -63,6 +64,8 @@ valid_boolean_trues = ["true", "yes", "y", "1"]
class Settings(object):
def __init__(self):
self._logger = logging.getLogger(__name__)
self.settings_dir = None
self._config = None
@ -79,6 +82,12 @@ class Settings(object):
if os.path.exists(old_settings_dir) and os.path.isdir(old_settings_dir) and not os.path.exists(self.settings_dir):
os.rename(old_settings_dir, self.settings_dir)
def _getDefaultFolder(self, type):
folder = default_settings["folder"][type]
if folder is None:
folder = os.path.join(self.settings_dir, type.replace("_", os.path.sep))
return folder
#~~ load and save
def load(self):
@ -164,6 +173,7 @@ class Settings(object):
try:
return int(value)
except ValueError:
self._logger.warn("Could not convert %r to a valid integer when getting option %r" % (value, path))
return None
def getBoolean(self, path):
@ -175,12 +185,12 @@ class Settings(object):
return value.lower() in valid_boolean_trues
def getBaseFolder(self, type):
if type not in old_default_settings["folder"].keys():
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.replace("_", os.path.sep))
folder = self._getDefaultFolder(type)
if not os.path.isdir(folder):
os.makedirs(folder)
@ -189,7 +199,7 @@ class Settings(object):
#~~ setter
def set(self, path, value):
def set(self, path, value, force=False):
if len(path) == 0:
return
@ -198,38 +208,63 @@ class Settings(object):
while len(path) > 1:
key = path.pop(0)
if key in config.keys():
if key in config.keys() and key in defaults.keys():
config = config[key]
defaults = defaults[key]
elif key in defaults.keys():
config[key] = {}
config = config[key]
defaults = defaults[key]
else:
return
key = path.pop(0)
config[key] = value
self._dirty = True
if not force and key in defaults.keys() and key in config.keys() and defaults[key] == value:
del config[key]
self._dirty = True
elif force or (not key in config.keys() and defaults[key] != value) or (key in config.keys() and config[key] != value):
if value is None:
del config[key]
else:
config[key] = value
self._dirty = True
def setInt(self, path, value):
def setInt(self, path, value, force=False):
if value is None:
return
self.set(path, None, force)
try:
intValue = int(value)
except ValueError:
self._logger.warn("Could not convert %r to a valid integer when setting option %r" % (value, path))
return
self.set(path, intValue)
self.set(path, intValue, force)
def setBoolean(self, path, value):
if value is None:
return
elif isinstance(value, bool):
self.set(path, value)
def setBoolean(self, path, value, force=False):
if value is None or isinstance(value, bool):
self.set(path, value, force)
elif value.lower() in valid_boolean_trues:
self.set(path, True)
self.set(path, True, force)
else:
self.set(path, False)
self.set(path, False, force)
def setBaseFolder(self, type, path, force=False):
if type not in default_settings["folder"].keys():
return None
currentPath = self.getBaseFolder(type)
defaultPath = self._getDefaultFolder(type)
if (path is None or path == defaultPath) and "folder" in self._config.keys() and type in self._config["folder"].keys():
del self._config["folder"][type]
if not self._config["folder"]:
del self._config["folder"]
self._dirty = True
elif (path != currentPath and path != defaultPath) or force:
if not "folder" in self._config.keys():
self._config["folder"] = {}
self._config["folder"][type] = path
self._dirty = True
def _resolveSettingsDir(applicationName):
# taken from http://stackoverflow.com/questions/1084697/how-do-i-store-desktop-application-data-in-a-cross-platform-way-for-python

View File

@ -1,8 +1,12 @@
/** Top bar */
body {
padding-top: 60px;
}
.tab-content {
/** OctoPrint application tabs */
.octoprint-container .tab-content {
padding: 9px 15px;
border-left: 1px solid #DDD;
border-right: 1px solid #DDD;
@ -16,13 +20,11 @@ body {
border-bottom-left-radius: 4px;
}
.accordion-heading a.accordion-toggle { display: inline-block; }
.nav {
.octoprint-container .nav {
margin-bottom: 0px;
}
.tab-content h1 {
.octoprint-container .tab-content h1 {
display: block;
width: 100%;
padding: 0;
@ -35,6 +37,17 @@ body {
font-weight: normal;
}
/** Accordions */
.octoprint-container .accordion-heading a.accordion-toggle { display: inline-block; }
.octoprint-container .accordion-heading .settings-trigger {
float: right;
padding: 0px 15px;
}
/** Tables */
table {
table-layout: fixed;
}
@ -59,6 +72,24 @@ table th.gcode_files_action, table td.gcode_files_action {
width: 20%;
}
table th.timelapse_files_name, table td.timelapse_files_name {
text-overflow: ellipsis;
text-align: left;
width: 55%;
}
table th.timelapse_files_size, table td.timelapse_files_size {
text-align: right;
width: 25%;
}
table th.timelapse_files_action, table td.timelapse_files_action {
text-align: center;
width: 20%;
}
/** Temperature tab */
#temperature-graph {
height: 350px;
width: 100%;
@ -75,11 +106,14 @@ table th.gcode_files_action, table td.gcode_files_action {
text-align: right;
}
/** Connection settings */
#connection_ports, #connection_baudrates {
width: 100%;
}
/** Offline overlay */
#offline_overlay {
position: fixed;
top: 0;
@ -116,26 +150,14 @@ table th.gcode_files_action, table td.gcode_files_action {
margin: auto;
}
table th.timelapse_files_name, table td.timelapse_files_name {
text-overflow: ellipsis;
text-align: left;
width: 55%;
}
table th.timelapse_files_size, table td.timelapse_files_size {
text-align: right;
width: 25%;
}
table th.timelapse_files_action, table td.timelapse_files_action {
text-align: center;
width: 20%;
}
/** Webcam */
#webcam_container {
width: 100%;
}
/** GCODE file manager */
#files .popover {
font-size: 85%;
}
@ -144,9 +166,7 @@ table th.timelapse_files_action, table td.timelapse_files_action {
margin-bottom: 0;
}
.overflow_visible {
overflow: visible !important;
}
/** Controls */
#controls {
overflow: hidden;
@ -193,11 +213,13 @@ table th.timelapse_files_action, table td.timelapse_files_action {
height: 30px;
}
.accordion-heading .settings-trigger {
float: right;
padding: 0px 15px;
}
/** General helper classes */
.text-right {
text-align: right;
}
.overflow_visible {
overflow: visible !important;
}

View File

@ -85,7 +85,7 @@
margin-right: 5px;
}
.mbut{
#gcode .mbut{
height:60px;
width: 250px;
font-size: 1em;
@ -96,20 +96,20 @@
padding: 0px;
}
.mtab{
#gcode .mtab{
background: transparent;
border: none;
font-weight: normal;
font-size: 0.5em;
}
.mtab-defstate{
#gcode .mtab-defstate{
background: transparent;
border: none;
}
.mtab-content{
#gcode .mtab-content{
background: #ffffff;
background-image: none;
border: 0px none #dddddd;
@ -117,22 +117,22 @@
margin: 0px;
}
.bar {
#gcode .bar {
-webkit-transition: width 0s linear !important;
-moz-transition: width 0s linear !important;
-o-transition: width 0s linear !important;
transition: width 0s linear !important;
}
.nav {
#gcode .nav {
margin-bottom: 0px !important;
}
.tab-content {
#gcode .tab-content {
overflow: visible;
}
.aboutpage {
#gcode .aboutpage {
margin: 10px;
}
@ -150,12 +150,12 @@
margin-bottom: 10px;
}
.colorBox {
#gcode .colorBox {
width: 50px;
height: 15px;
border: 1px solid #000000;
float:left;
}
.activeline {background: #fff0b6 !important;}
#gcode .activeline {background: #fff0b6 !important;}

View File

@ -998,6 +998,97 @@ function GcodeViewModel() {
}
function SettingsViewModel() {
var self = this;
self.printer_movementSpeedX = ko.observable(undefined);
self.printer_movementSpeedY = ko.observable(undefined);
self.printer_movementSpeedZ = ko.observable(undefined);
self.printer_movementSpeedE = ko.observable(undefined);
self.webcam_streamUrl = ko.observable(undefined);
self.webcam_snapshotUrl = ko.observable(undefined);
self.webcam_ffmpegPath = ko.observable(undefined);
self.webcam_bitrate = ko.observable(undefined);
self.feature_gcodeViewer = ko.observable(undefined);
self.feature_waitForStart = ko.observable(undefined);
self.folder_uploads = ko.observable(undefined);
self.folder_timelapse = ko.observable(undefined);
self.folder_timelapseTmp = ko.observable(undefined);
self.folder_logs = ko.observable(undefined);
self.requestData = function() {
$.ajax({
url: AJAX_BASEURL + "settings",
type: "GET",
dataType: "json",
success: self.fromResponse
})
}
self.fromResponse = function(response) {
self.printer_movementSpeedX(response.printer.movementSpeedX);
self.printer_movementSpeedY(response.printer.movementSpeedY);
self.printer_movementSpeedZ(response.printer.movementSpeedZ);
self.printer_movementSpeedE(response.printer.movementSpeedE);
self.webcam_streamUrl(response.webcam.streamUrl);
self.webcam_snapshotUrl(response.webcam.snapshotUrl);
self.webcam_ffmpegPath(response.webcam.ffmpegPath);
self.webcam_bitrate(response.webcam.bitrate);
self.feature_gcodeViewer(response.feature.gcodeViewer);
self.feature_waitForStart(response.feature.waitForStart);
self.folder_uploads(response.folder.uploads);
self.folder_timelapse(response.folder.timelapse);
self.folder_timelapseTmp(response.folder.timelapseTmp);
self.folder_logs(response.folder.logs);
}
self.saveData = function() {
var data = {
"printer": {
"movementSpeedX": self.printer_movementSpeedX(),
"movementSpeedY": self.printer_movementSpeedY(),
"movementSpeedZ": self.printer_movementSpeedZ(),
"movementSpeedE": self.printer_movementSpeedE()
},
"webcam": {
"streamUrl": self.webcam_streamUrl(),
"snapshotUrl": self.webcam_snapshotUrl(),
"ffmpegPath": self.webcam_ffmpegPath(),
"bitrate": self.webcam_bitrate()
},
"feature": {
"gcodeViewer": self.feature_gcodeViewer(),
"waitForStart": self.feature_waitForStart()
},
"folder": {
"uploads": self.folder_uploads(),
"timelapse": self.folder_timelapse(),
"timelapseTmp": self.folder_timelapseTmp(),
"logs": self.folder_logs()
}
}
$.ajax({
url: AJAX_BASEURL + "settings",
type: "POST",
dataType: "json",
contentType: "application/json; charset=UTF-8",
data: JSON.stringify(data),
success: function(response) {
self.fromResponse(response);
$("#settings_dialog").modal("hide");
}
})
}
}
function DataUpdater(connectionViewModel, printerStateViewModel, temperatureViewModel, controlsViewModel, speedViewModel, terminalViewModel, gcodeFilesViewModel, webcamViewModel, gcodeViewModel) {
var self = this;
@ -1074,6 +1165,7 @@ $(function() {
var gcodeFilesViewModel = new GcodeFilesViewModel();
var webcamViewModel = new WebcamViewModel();
var gcodeViewModel = new GcodeViewModel();
var settingsViewModel = new SettingsViewModel();
var dataUpdater = new DataUpdater(
connectionViewModel,
@ -1237,6 +1329,7 @@ $(function() {
ko.applyBindings(terminalViewModel, document.getElementById("term"));
ko.applyBindings(speedViewModel, document.getElementById("speed"));
ko.applyBindings(gcodeViewModel, document.getElementById("gcode"));
ko.applyBindings(settingsViewModel, document.getElementById("settings_dialog"));
var webcamElement = document.getElementById("webcam");
if (webcamElement) {
@ -1252,6 +1345,7 @@ $(function() {
controlsViewModel.requestData();
gcodeFilesViewModel.requestData();
webcamViewModel.requestData();
settingsViewModel.requestData();
//~~ UI stuff

View File

@ -25,10 +25,15 @@
<div class="navbar-inner">
<div class="container">
<a class="brand" href="#"><img src="{{ url_for('static', filename='img/tentacle-20x20.png') }}"> OctoPrint</a>
<div class="nav-collapse">
<ul class="nav pull-right">
<li><a class="pull-right" href="#settings_dialog" data-toggle="modal"><i class="icon-wrench"></i> Settings</a></li>
</ul>
</div>
</div>
</div>
</div>
<div class="container">
<div class="container octoprint-container">
<div class="row">
<div class="accordion span4">
<div class="accordion-group">
@ -450,6 +455,7 @@
</div>
</div>
</div>
<div id="offline_overlay">
<div id="offline_overlay_background"></div>
<div id="offline_overlay_wrapper">
@ -469,6 +475,132 @@
</div>
</div>
<div id="settings_dialog" class="modal hide fade" tabindex="-1" role="dialog" aria-labelledby="settings_dialog_label" aria-hidden="true">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal">×</button>
<h3 id="settings_dialog_label">OctoPrint Settings</h3>
</div>
<div class="modal-body">
<div class="tabbable">
<ul class="nav nav-pills" id="settingsTabs">
<li class="active"><a href="#settings_printerParameters" data-toggle="tab">Printer Parameters</a></li>
<li><a href="#settings_webcam" data-toggle="tab">Webcam</a></li>
<li><a href="#settings_features" data-toggle="tab">Features</a></li>
<li><a href="#settings_folder" data-toggle="tab">Folder</a></li>
</ul>
<div class="tab-content">
<div class="tab-pane active" id="settings_printerParameters">
<form class="form-horizontal">
<div class="control-group">
<label class="control-label" for="settings-movementSpeedX">Movement Speed X Axis</label>
<div class="controls">
<input type="text" class="input-mini text-right" data-bind="value: printer_movementSpeedX" id="settings-movementSpeedX">
</div>
</div>
<div class="control-group">
<label class="control-label" for="settings-movementSpeedY">Movement Speed Y Axis</label>
<div class="controls">
<input type="text" class="input-mini text-right" data-bind="value: printer_movementSpeedY" id="settings-movementSpeedY">
</div>
</div>
<div class="control-group">
<label class="control-label" for="settings-movementSpeedZ">Movement Speed Z Axis</label>
<div class="controls">
<input type="text" class="input-mini text-right" data-bind="value: printer_movementSpeedZ" id="settings-movementSpeedZ">
</div>
</div>
<div class="control-group">
<label class="control-label" for="settings-movementSpeedE">Movement Speed Extruder</label>
<div class="controls">
<input type="text" class="input-mini text-right" data-bind="value: printer_movementSpeedE" id="settings-movementSpeedE">
</div>
</div>
</form>
</div>
<div class="tab-pane" id="settings_webcam">
<form class="form-horizontal">
<div class="control-group">
<label class="control-label" for="settings-webcamStreamUrl">Stream URL</label>
<div class="controls">
<input type="text" class="input-block-level" data-bind="value: webcam_streamUrl" id="settings-webcamStreamUrl">
</div>
</div>
<div class="control-group">
<label class="control-label" for="settings-webcamStreamUrl">Snapshot URL</label>
<div class="controls">
<input type="text" class="input-block-level" data-bind="value: webcam_snapshotUrl" id="settings-webcamSnapshotUrl">
</div>
</div>
<div class="control-group">
<label class="control-label" for="settings-webcamStreamUrl">Path to FFMPEG</label>
<div class="controls">
<input type="text" class="input-block-level" data-bind="value: webcam_ffmpegPath" id="settings-webcamFfmpegPath">
</div>
</div>
<div class="control-group">
<label class="control-label" for="settings-webcamBitrate">Timelapse bitrate</label>
<div class="controls">
<input type="text" class="input-block-level" data-bind="value: webcam_bitrate" id="settings-webcamBitrate">
</div>
</div>
</form>
</div>
<div class="tab-pane" id="settings_features">
<form class="form-horizontal">
<div class="control-group">
<div class="controls">
<label class="checkbox">
<input type="checkbox" data-bind="checked: feature_gcodeViewer" id="settings-featureGcodeViewer"> Enable GCode Visualizer
</label>
</div>
</div>
<div class="control-group">
<div class="controls">
<label class="checkbox">
<input type="checkbox" data-bind="checked: feature_waitForStart" id="settings-featureWaitForStart"> Wait for start on connect
</label>
</div>
</div>
</form>
</div>
<div class="tab-pane" id="settings_folder">
<form class="form-horizontal">
<div class="control-group">
<label class="control-label" for="settings-folderUploads">Upload Folder</label>
<div class="controls">
<input type="text" class="input-block-level" data-bind="value: folder_uploads" id="settings-folderUploads">
</div>
</div>
<div class="control-group">
<label class="control-label" for="settings-folderTimelapse">Timelapse Folder</label>
<div class="controls">
<input type="text" class="input-block-level" data-bind="value: folder_timelapse" id="settings-folderTimelapse">
</div>
</div>
<div class="control-group">
<label class="control-label" for="settings-folderTimelapseTemp">Timelapse Temp Folder</label>
<div class="controls">
<input type="text" class="input-block-level" data-bind="value: folder_timelapseTmp" id="settings-folderTimelapseTemp">
</div>
</div>
<div class="control-group">
<label class="control-label" for="settings-folderLogs">Logs Folder</label>
<div class="controls">
<input type="text" class="input-block-level" data-bind="value: folder_logs" id="settings-folderLogs">
</div>
</div>
</form>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn" data-dismiss="modal" aria-hidden="true">Cancel</button>
<button class="btn btn-primary" data-bind="click: saveData">Save</button>
</div>
</div>
<script type="text/javascript" src="http://code.jquery.com/jquery-latest.js"></script>
<script type="text/javascript" src="{{ url_for('static', filename='js/underscore-min.js') }}"></script>
<script type="text/javascript" src="{{ url_for('static', filename='js/knockout-2.2.1.js') }}"></script>