diff --git a/src/octoprint/filemanager/__init__.py b/src/octoprint/filemanager/__init__.py index dae5ed51..fad1fc41 100644 --- a/src/octoprint/filemanager/__init__.py +++ b/src/octoprint/filemanager/__init__.py @@ -155,7 +155,11 @@ class FileManager(object): self._slicing_progress_callbacks.append(callback) def unregister_slicingprogress_callback(self, callback): - self._slicing_progress_callbacks.remove(callback) + try: + self._slicing_progress_callbacks.remove(callback) + except ValueError: + # callback was not registered + pass def _determine_analysis_backlog(self, storage_type, storage_manager): counter = 0 diff --git a/src/octoprint/server/api/settings.py b/src/octoprint/server/api/settings.py index 36efd324..0afb6913 100644 --- a/src/octoprint/server/api/settings.py +++ b/src/octoprint/server/api/settings.py @@ -285,7 +285,11 @@ def setSettings(): logger.exception("Could not save settings for plugin {name} ({version})".format(version=plugin._plugin_version, name=plugin._plugin_name)) if s.save(): - eventManager().fire(Events.SETTINGS_UPDATED) + payload = dict( + config_hash=s.config_hash, + effective_hash=s.effective_hash + ) + eventManager().fire(Events.SETTINGS_UPDATED, payload=payload) return getSettings() diff --git a/src/octoprint/server/util/sockjs.py b/src/octoprint/server/util/sockjs.py index 8e7df023..44b682f8 100644 --- a/src/octoprint/server/util/sockjs.py +++ b/src/octoprint/server/util/sockjs.py @@ -13,6 +13,7 @@ import time import octoprint.timelapse import octoprint.server from octoprint.events import Events +from octoprint.settings import settings import octoprint.printer @@ -58,8 +59,16 @@ class PrinterStateConnection(sockjs.tornado.SockJSConnection, octoprint.printer. plugin_hash = hashlib.md5() plugin_hash.update(",".join(ui_plugins)) + config_hash = settings().config_hash + # connected => update the API key, might be necessary if the client was left open while the server restarted - self._emit("connected", {"apikey": octoprint.server.UI_API_KEY, "version": octoprint.server.VERSION, "display_version": octoprint.server.DISPLAY_VERSION, "plugin_hash": plugin_hash.hexdigest()}) + self._emit("connected", dict( + apikey=octoprint.server.UI_API_KEY, + version=octoprint.server.VERSION, + display_version=octoprint.server.DISPLAY_VERSION, + plugin_hash=plugin_hash.hexdigest(), + config_hash=config_hash + )) self._printer.register_callback(self) self._fileManager.register_slicingprogress_callback(self) diff --git a/src/octoprint/settings.py b/src/octoprint/settings.py index 3bac8e65..f10f5994 100644 --- a/src/octoprint/settings.py +++ b/src/octoprint/settings.py @@ -545,6 +545,20 @@ class Settings(object): import yaml return yaml.safe_dump(self.effective) + @property + def effective_hash(self): + import hashlib + hash = hashlib.md5() + hash.update(repr(self.effective)) + return hash.hexdigest() + + @property + def config_hash(self): + import hashlib + hash = hashlib.md5() + hash.update(repr(self._config)) + return hash.hexdigest() + #~~ load and save def load(self, migrate=False): diff --git a/src/octoprint/static/js/app/dataupdater.js b/src/octoprint/static/js/app/dataupdater.js index 169786ea..3c2e6dbc 100644 --- a/src/octoprint/static/js/app/dataupdater.js +++ b/src/octoprint/static/js/app/dataupdater.js @@ -10,6 +10,7 @@ function DataUpdater(allViewModels) { self._autoReconnectDialogIndex = 1; self._pluginHash = undefined; + self._configHash = undefined; self.reloadOverlay = $("#reloadui_overlay"); $("#reloadui_overlay_reload").click(function() { location.reload(true); }); @@ -127,6 +128,9 @@ function DataUpdater(allViewModels) { var oldPluginHash = self._pluginHash; self._pluginHash = data["plugin_hash"]; + var oldConfigHash = self._configHash; + self._configHash = data["config_hash"]; + if ($("#offline_overlay").is(":visible")) { hideOfflineOverlay(); _.each(self.allViewModels, function(viewModel) { @@ -140,7 +144,10 @@ function DataUpdater(allViewModels) { } } - if (oldVersion != VERSION || (oldPluginHash != undefined && oldPluginHash != self._pluginHash)) { + var versionChanged = oldVersion != VERSION; + var pluginsChanged = oldPluginHash != undefined && oldPluginHash != self._pluginHash; + var configChanged = oldConfigHash != undefined && oldConfigHash != self._configHash; + if (versionChanged || pluginsChanged || configChanged) { self.reloadOverlay.show(); } @@ -180,7 +187,11 @@ function DataUpdater(allViewModels) { log.debug("Got event " + type + " with payload: " + JSON.stringify(payload)); - if (type == "MovieRendering") { + if (type == "SettingsUpdated") { + if (payload && payload.hasOwnProperty("config_hash")) { + self._configHash = payload.config_hash; + } + } else if (type == "MovieRendering") { new PNotify({title: gettext("Rendering timelapse"), text: _.sprintf(gettext("Now rendering timelapse %(movie_basename)s"), payload)}); } else if (type == "MovieDone") { new PNotify({title: gettext("Timelapse ready"), text: _.sprintf(gettext("New timelapse %(movie_basename)s is done rendering."), payload)}); diff --git a/src/octoprint/static/js/app/helpers.js b/src/octoprint/static/js/app/helpers.js index 0b3354a9..e0b75cc7 100644 --- a/src/octoprint/static/js/app/helpers.js +++ b/src/octoprint/static/js/app/helpers.js @@ -488,3 +488,110 @@ function splitTextToArray(text, sep, stripEmpty, filter) { function(item) { return (stripEmpty ? item : true) && (filter ? filter(item) : true); } ); } + +/** + * Returns true if comparing data and oldData yields changes, false otherwise. + * + * E.g. + * + * hasDataChanged( + * {foo: "bar", fnord: {one: "1", two: "2", three: "three", key: "value"}}, + * {foo: "bar", fnord: {one: "1", two: "2", three: "3", four: "4"}} + * ) + * + * will return + * + * true + * + * and + * + * hasDataChanged( + * {foo: "bar", fnord: {one: "1", two: "2", three: "3"}}, + * {foo: "bar", fnord: {one: "1", two: "2", three: "3"}} + * ) + * + * will return + * + * false + * + * Note that this will assume data and oldData to be structurally identical (same keys) + * and is optimized to check for value changes, not key updates. + */ +function hasDataChanged(data, oldData) { + if (data == undefined) { + return false; + } + + if (oldData == undefined) { + return true; + } + + if (_.isPlainObject(data)) { + return _.any(_.keys(data), function(key) {return hasDataChanged(data[key], oldData[key]);}); + } else { + return !_.isEqual(data, oldData); + } +} + +/** + * Compare provided data and oldData plain objects and only return those + * substructures of data that actually changed. + * + * E.g. + * + * getOnlyChangedData( + * {foo: "bar", fnord: {one: "1", two: "2", three: "three"}}, + * {foo: "bar", fnord: {one: "1", two: "2", three: "3"}} + * ) + * + * will return + * + * {fnord: {three: "three"}} + * + * and + * + * getOnlyChangedData( + * {foo: "bar", fnord: {one: "1", two: "2", three: "3"}}, + * {foo: "bar", fnord: {one: "1", two: "2", three: "3"}} + * ) + * + * will return + * + * {} + * + * Note that this will assume data and oldData to be structurally identical (same keys) + * and is optimized to check for value changes, not key updates. + */ +function getOnlyChangedData(data, oldData) { + if (data == undefined) { + return {}; + } + + if (oldData == undefined) { + return data; + } + + var f = function(root, oldRoot) { + if (!_.isPlainObject(root)) { + return root; + } + + var retval = {}; + _.forOwn(root, function(value, key) { + var oldValue = oldRoot[key]; + if (_.isPlainObject(value)) { + if (hasDataChanged(value, oldValue)) { + retval[key] = f(value, oldValue); + } + } else { + if (!_.isEqual(value, oldValue)) { + retval[key] = value; + } + } + }); + return retval; + }; + + return f(data, oldData); +} + diff --git a/src/octoprint/static/js/app/viewmodels/settings.js b/src/octoprint/static/js/app/viewmodels/settings.js index c878703d..3e14f625 100644 --- a/src/octoprint/static/js/app/viewmodels/settings.js +++ b/src/octoprint/static/js/app/viewmodels/settings.js @@ -22,6 +22,7 @@ $(function() { self.appearance_defaultLanguage = ko.observable(); self.settingsDialog = undefined; + self.settings_dialog_update_detected = undefined; self.translationManagerDialog = undefined; self.translationUploadElement = $("#settings_appearance_managelanguagesdialog_upload"); self.translationUploadButton = $("#settings_appearance_managelanguagesdialog_upload_start"); @@ -164,6 +165,7 @@ $(function() { self.server_commands_serverRestartCommand = ko.observable(undefined); self.settings = undefined; + self.lastReceivedSettings = undefined; self.addTemperatureProfile = function() { self.temperature_profiles.push({name: "New", extruder:0, bed:0}); @@ -182,11 +184,16 @@ $(function() { }; self.onSettingsShown = function() { - self.requestData(); + self.requestData(); + }; + + self.isDialogActive = function() { + return self.settingsDialog.is(":visible"); }; self.onStartup = function() { self.settingsDialog = $('#settings_dialog'); + self.settingsUpdatedDialog = $('#settings_dialog_update_detected'); self.translationManagerDialog = $('#settings_appearance_managelanguagesdialog'); self.translationUploadElement = $("#settings_appearance_managelanguagesdialog_upload"); self.translationUploadButton = $("#settings_appearance_managelanguagesdialog_upload_start"); @@ -247,6 +254,19 @@ $(function() { } }); }); + + $(".reload_all", self.settingsUpdatedDialog).click(function(e) { + e.preventDefault(); + self.settingsUpdatedDialog.modal("hide"); + self.requestData(); + return false; + }); + $(".reload_nonconflicts", self.settingsUpdatedDialog).click(function(e) { + e.preventDefault(); + self.settingsUpdatedDialog.modal("hide"); + self.requestData(undefined, true); + return false; + }); }; self.show = function() { @@ -270,7 +290,7 @@ $(function() { return false; }; - self.requestData = function(callback) { + self.requestData = function(callback, local) { if (self.receiving()) { if (callback) { self.callbacks.push(callback); @@ -289,7 +309,7 @@ $(function() { } try { - self.fromResponse(response); + self.fromResponse(response, local); var cb; while (self.callbacks.length) { @@ -380,92 +400,150 @@ $(function() { }) }; - self.fromResponse = function(response) { + /** + * Fetches the settings as currently stored in this client instance. + */ + self._getLocalData = function() { + var data = {}; + if (self.settings != undefined) { + data = ko.mapping.toJS(self.settings); + } + + // some special read functions for various observables + var specialMappings = { + feature: { + externalHeatupDetection: function() { return !self.feature_disableExternalHeatupDetection() } + }, + serial: { + additionalPorts : function() { return commentableLinesToArray(self.serial_additionalPorts()) }, + additionalBaudrates: function() { return _.map(splitTextToArray(self.serial_additionalBaudrates(), ",", true, function(item) { return !isNaN(parseInt(item)); }), function(item) { return parseInt(item); }) }, + longRunningCommands: function() { return splitTextToArray(self.serial_longRunningCommands(), ",", true) }, + checksumRequiringCommands: function() { return splitTextToArray(self.serial_checksumRequiringCommands(), ",", true) } + } + }; + + var mapFromObservables = function(data, mapping, keyPrefix) { + var flag = false; + var result = {}; + + // process all key-value-pairs here + _.forOwn(data, function(value, key) { + var observable = key; + if (keyPrefix != undefined) { + observable = keyPrefix + "_" + observable; + } + + if (_.isPlainObject(value)) { + // value is another object, we'll dive deeper + var subresult = mapFromObservables(value, (mapping && mapping[key]) ? mapping[key] : undefined, observable); + if (subresult != undefined) { + // we only set something on our result if we got something back + result[key] = subresult; + flag = true; + } + } else { + // if we have a custom read function for this, we'll use it + if (mapping && mapping[key] && _.isFunction(mapping[key])) { + result[key] = mapping[key](); + flag = true; + } else if (self.hasOwnProperty(observable)) { + result[key] = self[observable](); + flag = true; + } + } + }); + + // if we set something on our result (flag is true), we return result, else we return undefined + return flag ? result : undefined; + }; + + // map local observables based on our existing data + var dataFromObservables = mapFromObservables(data, specialMappings); + + data = _.extend(data, dataFromObservables); + return data; + }; + + self.fromResponse = function(response, local) { + // server side changes to set + var serverChangedData; + + // client side changes to keep + var clientChangedData; + + if (local) { + // local is true, so we'll keep all local changes and only update what's been updated server side + serverChangedData = getOnlyChangedData(response, self.lastReceivedSettings); + clientChangedData = getOnlyChangedData(self._getLocalData(), self.lastReceivedSettings); + } else { + // local is false or unset, so we'll forcefully update with the settings from the server + serverChangedData = response; + clientChangedData = undefined; + } + + // last received settings reset to response + self.lastReceivedSettings = response; + if (self.settings === undefined) { - self.settings = ko.mapping.fromJS(response); + self.settings = ko.mapping.fromJS(serverChangedData); } else { - ko.mapping.fromJS(response, self.settings); + ko.mapping.fromJS(serverChangedData, self.settings); } - self.api_enabled(response.api.enabled); - self.api_key(response.api.key); - self.api_allowCrossOrigin(response.api.allowCrossOrigin); + // some special apply functions for various observables + var specialMappings = { + appearance: { + defaultLanguage: function(value) { + self.appearance_defaultLanguage("_default"); + if (_.includes(self.locale_languages, value)) { + self.appearance_defaultLanguage(value); + } + } + }, + feature: { + externalHeatupDetection: function(value) { self.feature_disableExternalHeatupDetection(!value) } + }, + serial: { + additionalPorts : function(value) { self.serial_additionalPorts(value.join("\n"))}, + additionalBaudrates: function(value) { self.serial_additionalBaudrates(value.join(", "))}, + longRunningCommands: function(value) { self.serial_longRunningCommands(value.join(", "))}, + checksumRequiringCommands: function(value) { self.serial_checksumRequiringCommands(value.join(", "))} + } + }; - self.appearance_name(response.appearance.name); - self.appearance_color(response.appearance.color); - self.appearance_colorTransparent(response.appearance.colorTransparent); - self.appearance_defaultLanguage("_default"); - if (_.includes(self.locale_languages, response.appearance.defaultLanguage)) { - self.appearance_defaultLanguage(response.appearance.defaultLanguage); - } + var mapToObservables = function(data, mapping, local, keyPrefix) { + if (!_.isPlainObject(data)) { + return; + } - self.printer_defaultExtrusionLength(response.printer.defaultExtrusionLength); + // process all key-value-pairs here + _.forOwn(data, function(value, key) { + var observable = key; + if (keyPrefix != undefined) { + observable = keyPrefix + "_" + observable; + } - 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.webcam_ffmpegThreads(response.webcam.ffmpegThreads); - self.webcam_watermark(response.webcam.watermark); - self.webcam_flipH(response.webcam.flipH); - self.webcam_flipV(response.webcam.flipV); - self.webcam_rotate90(response.webcam.rotate90); + if (_.isPlainObject(value)) { + // value is another object, we'll dive deeper + mapToObservables(value, (mapping && mapping[key]) ? mapping[key] : undefined, (local && local[key]) ? local[key] : undefined, observable); + } else { + // if we have a local version of this field, we'll not override it + if (local && local.hasOwnProperty(key)) { + return; + } - self.feature_gcodeViewer(response.feature.gcodeViewer); - self.feature_temperatureGraph(response.feature.temperatureGraph); - self.feature_waitForStart(response.feature.waitForStart); - self.feature_alwaysSendChecksum(response.feature.alwaysSendChecksum); - self.feature_sdSupport(response.feature.sdSupport); - self.feature_sdAlwaysAvailable(response.feature.sdAlwaysAvailable); - self.feature_swallowOkAfterResend(response.feature.swallowOkAfterResend); - self.feature_repetierTargetTemp(response.feature.repetierTargetTemp); - self.feature_disableExternalHeatupDetection(!response.feature.externalHeatupDetection); - self.feature_keyboardControl(response.feature.keyboardControl); - self.feature_pollWatched(response.feature.pollWatched); + if (mapping && mapping[key] && _.isFunction(mapping[key])) { + // if we have a custom apply function for this, we'll use it + mapping[key](value); + } else if (self.hasOwnProperty(observable)) { + // else if we have a matching observable, we'll use that + self[observable](value); + } + } + }); + }; - self.serial_port(response.serial.port); - self.serial_baudrate(response.serial.baudrate); - self.serial_portOptions(response.serial.portOptions); - self.serial_baudrateOptions(response.serial.baudrateOptions); - self.serial_autoconnect(response.serial.autoconnect); - self.serial_timeoutConnection(response.serial.timeoutConnection); - self.serial_timeoutDetection(response.serial.timeoutDetection); - self.serial_timeoutCommunication(response.serial.timeoutCommunication); - self.serial_timeoutTemperature(response.serial.timeoutTemperature); - self.serial_timeoutSdStatus(response.serial.timeoutSdStatus); - self.serial_log(response.serial.log); - self.serial_additionalPorts(response.serial.additionalPorts.join("\n")); - self.serial_additionalBaudrates(response.serial.additionalBaudrates.join(", ")); - self.serial_longRunningCommands(response.serial.longRunningCommands.join(", ")); - self.serial_checksumRequiringCommands(response.serial.checksumRequiringCommands.join(", ")); - self.serial_helloCommand(response.serial.helloCommand); - - 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.folder_watched(response.folder.watched); - - self.temperature_profiles(response.temperature.profiles); - - self.scripts_gcode_beforePrintStarted(response.scripts.gcode.beforePrintStarted); - self.scripts_gcode_afterPrintDone(response.scripts.gcode.afterPrintDone); - self.scripts_gcode_afterPrintCancelled(response.scripts.gcode.afterPrintCancelled); - self.scripts_gcode_afterPrintPaused(response.scripts.gcode.afterPrintPaused); - self.scripts_gcode_beforePrintResumed(response.scripts.gcode.beforePrintResumed); - self.scripts_gcode_afterPrinterConnected(response.scripts.gcode.afterPrinterConnected); - self.scripts_gcode_beforePrinterDisconnected(response.scripts.gcode.beforePrinterDisconnected); - - self.temperature_profiles(response.temperature.profiles); - self.temperature_cutoff(response.temperature.cutoff); - - self.system_actions(response.system.actions); - - self.terminalFilters(response.terminalFilters); - - self.server_commands_systemShutdownCommand(response.server.commands.systemShutdownCommand); - self.server_commands_systemRestartCommand(response.server.commands.systemRestartCommand); - self.server_commands_serverRestartCommand(response.server.commands.serverRestartCommand); + mapToObservables(serverChangedData, specialMappings, clientChangedData); }; self.saveData = function (data, successCallback) { @@ -474,97 +552,9 @@ $(function() { if (data == undefined) { // we only set sending to true when we didn't include data self.sending(true); - data = ko.mapping.toJS(self.settings); - data = _.extend(data, { - "api" : { - "enabled": self.api_enabled(), - "key": self.api_key(), - "allowCrossOrigin": self.api_allowCrossOrigin() - }, - "appearance" : { - "name": self.appearance_name(), - "color": self.appearance_color(), - "colorTransparent": self.appearance_colorTransparent(), - "defaultLanguage": self.appearance_defaultLanguage() - }, - "printer": { - "defaultExtrusionLength": self.printer_defaultExtrusionLength() - }, - "webcam": { - "streamUrl": self.webcam_streamUrl(), - "snapshotUrl": self.webcam_snapshotUrl(), - "ffmpegPath": self.webcam_ffmpegPath(), - "bitrate": self.webcam_bitrate(), - "ffmpegThreads": self.webcam_ffmpegThreads(), - "watermark": self.webcam_watermark(), - "flipH": self.webcam_flipH(), - "flipV": self.webcam_flipV(), - "rotate90": self.webcam_rotate90() - }, - "feature": { - "gcodeViewer": self.feature_gcodeViewer(), - "temperatureGraph": self.feature_temperatureGraph(), - "waitForStart": self.feature_waitForStart(), - "alwaysSendChecksum": self.feature_alwaysSendChecksum(), - "sdSupport": self.feature_sdSupport(), - "sdAlwaysAvailable": self.feature_sdAlwaysAvailable(), - "swallowOkAfterResend": self.feature_swallowOkAfterResend(), - "repetierTargetTemp": self.feature_repetierTargetTemp(), - "externalHeatupDetection": !self.feature_disableExternalHeatupDetection(), - "keyboardControl": self.feature_keyboardControl(), - "pollWatched": self.feature_pollWatched() - }, - "serial": { - "port": self.serial_port(), - "baudrate": self.serial_baudrate(), - "autoconnect": self.serial_autoconnect(), - "timeoutConnection": self.serial_timeoutConnection(), - "timeoutDetection": self.serial_timeoutDetection(), - "timeoutCommunication": self.serial_timeoutCommunication(), - "timeoutTemperature": self.serial_timeoutTemperature(), - "timeoutSdStatus": self.serial_timeoutSdStatus(), - "log": self.serial_log(), - "additionalPorts": commentableLinesToArray(self.serial_additionalPorts()), - "additionalBaudrates": _.map(splitTextToArray(self.serial_additionalBaudrates(), ",", true, function(item) { return !isNaN(parseInt(item)); }), function(item) { return parseInt(item); }), - "longRunningCommands": splitTextToArray(self.serial_longRunningCommands(), ",", true), - "checksumRequiringCommands": splitTextToArray(self.serial_checksumRequiringCommands(), ",", true), - "helloCommand": self.serial_helloCommand() - }, - "folder": { - "uploads": self.folder_uploads(), - "timelapse": self.folder_timelapse(), - "timelapseTmp": self.folder_timelapseTmp(), - "logs": self.folder_logs(), - "watched": self.folder_watched() - }, - "temperature": { - "profiles": self.temperature_profiles(), - "cutoff": self.temperature_cutoff() - }, - "system": { - "actions": self.system_actions() - }, - "terminalFilters": self.terminalFilters(), - "scripts": { - "gcode": { - "beforePrintStarted": self.scripts_gcode_beforePrintStarted(), - "afterPrintDone": self.scripts_gcode_afterPrintDone(), - "afterPrintCancelled": self.scripts_gcode_afterPrintCancelled(), - "afterPrintPaused": self.scripts_gcode_afterPrintPaused(), - "beforePrintResumed": self.scripts_gcode_beforePrintResumed(), - "afterPrinterConnected": self.scripts_gcode_afterPrinterConnected(), - "beforePrinterDisconnected": self.scripts_gcode_beforePrinterDisconnected() - } - }, - "server": { - "commands": { - "systemShutdownCommand": self.server_commands_systemShutdownCommand(), - "systemRestartCommand": self.server_commands_systemRestartCommand(), - "serverRestartCommand": self.server_commands_serverRestartCommand() - } - } - }); + // we also only send data that actually changed when no data is specified + data = getOnlyChangedData(self._getLocalData(), self.lastReceivedSettings); } $.ajax({ @@ -590,7 +580,23 @@ $(function() { }; self.onEventSettingsUpdated = function() { - self.requestData(); + if (self.isDialogActive()) { + // dialog is open and not currently busy... + if (self.sending() || self.receiving()) { + return; + } + + if (!hasDataChanged(self._getLocalData(), self.lastReceivedSettings)) { + // we don't have local changes, so just fetch new data + self.requestData(); + } else { + // we have local changes, show update dialog + self.settingsUpdatedDialog.modal("show"); + } + } else { + // dialog is not open, just fetch new data + self.requestData(); + } }; self.enqueueForSaving = function(data) { diff --git a/src/octoprint/templates/dialogs/settings.jinja2 b/src/octoprint/templates/dialogs/settings.jinja2 index f3d3f708..b8e79a5c 100644 --- a/src/octoprint/templates/dialogs/settings.jinja2 +++ b/src/octoprint/templates/dialogs/settings.jinja2 @@ -52,3 +52,25 @@ + +