diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..aff8422 --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +gulpfile.js diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..bd22f78 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,12 @@ +--- +"extends": + - "airbnb" +"rules": + "indent": ["error", 4] + "max-len": ["error", 120, { + ignoreUrls: true, + ignoreComments: false, + ignoreRegExpLiterals: true, + ignoreStrings: true, + ignoreTemplateLiterals: true, + }] diff --git a/.gitignore b/.gitignore index 1f6c2f6..74af358 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ dist .DS_Store *.zip .atom -.docker +node_modules +package-lock.json diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..a020802 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,18 @@ +# Development environment +All files in the `octoprint_filamentmanager/static/{css,js}` directory will be build with [Gulp](https://gulpjs.com/), from the source in `static/{css,js}`, and not modified directly. The build process includes: +- Static code analysis with [ESLint](https://eslint.org/) +- Transcompiling to ES5 with [Babel](https://babeljs.io/) +- Concatinating all JS files into one file `filamentmanager.bundled.js` +- Concatinating and minifying all CSS file into one file `filamentmanager.min.css` + + +## Prerequisites +1. Install [NodeJS](http://www.nodejs.org/) and [NPM](https://www.npmjs.com/) with your package manager + +1. Install development dependencies with `npm install` + + +## Build +1. Check the source code with `npx gulp lint` + +1. Start the build process with `npx gulp build` diff --git a/MANIFEST.in b/MANIFEST.in index 1e1d756..3716754 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,4 @@ +include LICENSE include README.md recursive-include octoprint_filamentmanager/templates * recursive-include octoprint_filamentmanager/translations * diff --git a/README.md b/README.md index 0c11eda..62ac433 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,21 @@ This OctoPrint plugin helps to manage your filament spools. The project is still If you have questions or encounter issues please take a look at the [Frequently Asked Questions](https://github.com/malnvenshorn/OctoPrint-FilamentManager/wiki#faq) first. There might be already an answer. In case you haven't found what you are looking for, feel free to open a [ticket](https://github.com/malnvenshorn/OctoPrint-FilamentManager/issues/new) and I'll try to help. -## Additional features +## Features * Replacing filament volume with weight in sidebar -* Filament odometer to keep track of remaining filament on spool +* Software odometer to measure used filament * Warn if print exceeds remaining filament on spool * Assign temperature offset to spools * Automatically pause print if filament runs out * Import & export of spool inventory +* Support for PostgreSQL as database for multiple instances + +## Setup +Install via the bundled [Plugin Manager](https://github.com/foosel/OctoPrint/wiki/Plugin:-Plugin-Manager) +or manually using this URL: + + https://github.com/malnvenshorn/OctoPrint-FilamentManager/archive/master.zip ## Screenshots diff --git a/babel.cfg b/babel.cfg index b6f5945..ea3d218 100644 --- a/babel.cfg +++ b/babel.cfg @@ -1,6 +1,6 @@ -[python: */**.py] -[jinja2: */**.jinja2] +[python: octoprint_filamentmanager/**.py] +[jinja2: octoprint_filamentmanager/**.jinja2] extensions=jinja2.ext.autoescape, jinja2.ext.with_ -[javascript: */**.js] +[javascript: octoprint_filamentmanager/**.js] extract_messages = gettext, ngettext diff --git a/gulpfile.js b/gulpfile.js new file mode 100644 index 0000000..fbaba6e --- /dev/null +++ b/gulpfile.js @@ -0,0 +1,36 @@ +var gulp = require('gulp'); +var concat = require('gulp-concat'); +var eslint = require('gulp-eslint'); +var babel = require('gulp-babel'); +let cleanCSS = require('gulp-clean-css'); + +gulp.task('lint', () => { + return gulp.src(['static/js/**/*.js']) + .pipe(eslint()) + .pipe(eslint.format()); +}); + +gulp.task('build', ['js', 'css']) + +gulp.task('js', () => { + return gulp.src([ + 'static/js/constructor.js', + 'static/js/**/!(bootstrap)*.js', + 'static/js/bootstrap.js', + ]) + .pipe(babel({ + presets: ['env'], + plugins: ['transform-remove-strict-mode'] + })) + .pipe(concat('filamentmanager.bundled.js')) + .pipe(gulp.dest('octoprint_filamentmanager/static/js/')); +}); + +gulp.task('css', () => { + return gulp.src([ + 'static/css/*.css', + ]) + .pipe(concat('filamentmanager.min.css')) + .pipe(cleanCSS()) + .pipe(gulp.dest('octoprint_filamentmanager/static/css/')); +}); diff --git a/octoprint_filamentmanager/__init__.py b/octoprint_filamentmanager/__init__.py index d41aff5..05d1605 100644 --- a/octoprint_filamentmanager/__init__.py +++ b/octoprint_filamentmanager/__init__.py @@ -5,117 +5,196 @@ __license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" __copyright__ = "Copyright (C) 2017 Sven Lohrmann - Released under terms of the AGPLv3 License" -import math -import os -import tempfile -import hashlib -import shutil -from datetime import datetime -from flask import jsonify, request, make_response, Response -from werkzeug.exceptions import BadRequest -from werkzeug.http import http_date +from math import pi as PI + import octoprint.plugin +from octoprint.settings import valid_boolean_trues from octoprint.events import Events -from octoprint.server.util.flask import restricted_access, check_lastmodified, check_etag -from octoprint.server import admin_permission from octoprint.util import dict_merge -from .manager import FilamentManager +from octoprint.util.version import is_octoprint_compatible + +from .api import FilamentManagerApi +from .data import FilamentManager from .odometer import FilamentOdometer -class FilamentManagerPlugin(octoprint.plugin.StartupPlugin, +class FilamentManagerPlugin(FilamentManagerApi, + octoprint.plugin.StartupPlugin, + octoprint.plugin.ShutdownPlugin, octoprint.plugin.SettingsPlugin, octoprint.plugin.AssetPlugin, octoprint.plugin.TemplatePlugin, - octoprint.plugin.BlueprintPlugin, octoprint.plugin.EventHandlerPlugin): - DB_VERSION = 2 + DB_VERSION = 3 def __init__(self): + self.client_id = None self.filamentManager = None self.filamentOdometer = None - self.odometerEnabled = False self.lastPrintState = None + + self.odometerEnabled = False self.pauseEnabled = False - self.pauseThreshold = [] + self.pauseThresholds = dict() - # StartupPlugin + def initialize(self): + def get_client_id(): + client_id = self._settings.get(["database", "clientID"]) + if client_id is None: + from uuid import uuid1 + client_id = str(uuid1()) + self._settings.set(["database", "clientID"], client_id) + return client_id + + self.client_id = get_client_id() - def on_startup(self, host, port): self.filamentOdometer = FilamentOdometer() self.filamentOdometer.set_g90_extruder(self._settings.getBoolean(["feature", "g90InfluencesExtruder"])) - db_path = os.path.join(self.get_plugin_data_folder(), "filament.db") + db_config = self._settings.get(["database"], merged=True) + migrate_schema_version = False - if os.path.isfile(db_path) and self._settings.get(["_db_version"]) is None: - # correct missing _db_version - self._settings.set(["_db_version"], 1) + if db_config["useExternal"] not in valid_boolean_trues: + import os + # set uri for internal sqlite database + db_path = os.path.join(self.get_plugin_data_folder(), "filament.db") + db_config["uri"] = "sqlite:///" + db_path + migrate_schema_version = os.path.isfile(db_path) try: - self.filamentManager = FilamentManager(db_path) - self.filamentManager.init_database() - - if self._settings.get(["_db_version"]) is None: - # we assume the database is initialized the first time - # therefore we got the latest db scheme - self._settings.set(["_db_version"], self.DB_VERSION) - else: - # migrate existing database if neccessary - self.migrate_db_scheme() + # initialize database + self.filamentManager = FilamentManager(db_config) + self.filamentManager.initialize() + + schema_version = self.filamentManager.get_schema_version() + + # migrate schema version to database if needed + # since plugin version 0.5.0 the schema version will be expected in the database + if schema_version is None and migrate_schema_version: + # three conditions must be met: + # - internal database is selected + # - database was not newly created + # - there is no schema version in the database already + if self._settings.get(["_db_version"]) is None: + # no version was set before 0.3.0 => expecting the first schema + schema_version = 1 + else: + # migrate schema version from config.yaml + schema_version = self._settings.getInt(["_db_version"]) + self._logger.warn("No schema_id found in database, setting id to %s" % schema_version) + self.filamentManager.set_schema_version(schema_version) + + # migrate database schema if needed + if schema_version is None: + # we assume the database is initialized the first time => we got the latest db scheme + self.filamentManager.set_schema_version(self.DB_VERSION) + elif schema_version < self.DB_VERSION: + # migrate existing database + self.migrate_database_schema(self.DB_VERSION, schema_version) + self.filamentManager.set_schema_version(self.DB_VERSION) + self._logger.info("Updated database schema from version {old} to {new}" + .format(old=schema_version, new=self.DB_VERSION)) except Exception as e: - self._logger.error("Failed to create database: {message}".format(message=str(e))) - else: - self._update_pause_threshold() + self._logger.error("Failed to initialize database: {message}".format(message=str(e))) - def migrate_db_scheme(self): - if 1 == self._settings.get(["_db_version"]): + def migrate_database_schema(self, target, current): + if current <= 1: # add temperature column sql = "ALTER TABLE spools ADD COLUMN temp_offset INTEGER NOT NULL DEFAULT 0;" - try: - self.filamentManager.execute_script(sql) - self._settings.set(["_db_version"], 2) - except Exception as e: - self._logger.error("Database migration failed from version {old} to {new}: {message}" - .format(old=self._settings.get(["_db_version"]), new=2, message=str(e))) - return - - # migrate selected spools from config.yaml to database - selections = self._settings.get(["selectedSpools"]) - if selections is not None: - for key in selections: - data = dict( - tool=key.replace("tool", ""), - spool=dict( - id=selections[key] - ) - ) - self.filamentManager.update_selection(key.replace("tool", ""), data) - self._settings.set(["selectedSpools"], None) + self.filamentManager.execute_script(sql) + + if current <= 2: + # recreate tables except profiles and spools + sql = """ DROP TABLE modifications; + DROP TABLE selections; + DROP TRIGGER profiles_onINSERT; + DROP TRIGGER profiles_onUPDATE; + DROP TRIGGER profiles_onDELETE; + DROP TRIGGER spools_onINSERT; + DROP TRIGGER spools_onUPDATE; + DROP TRIGGER spools_onDELETE; """ + self.filamentManager.execute_script(sql) + self.filamentManager.initialize() + + def on_after_startup(self): + # subscribe to the notify channel so that we get notified if another client has altered the data + # notify is not available if we are connected to the internal sqlite database + if self.filamentManager.notify is not None: + def notify(pid, channel, payload): + # ignore notifications triggered by our own connection + if pid != self.filamentManager.conn.connection.get_backend_pid(): + self.send_client_message("data_changed", data=dict(table=channel, action=payload)) + self.on_data_modified(channel, payload) + self.filamentManager.notify.subscribe(notify) + + # initialize the pause thresholds + self.update_pause_thresholds() + + # set temperature offsets for saved selections + try: + all_selections = self.filamentManager.get_all_selections(self.client_id) + self.set_temp_offsets(all_selections) + except Exception as e: + self._logger.error("Failed to set temperature offsets: {message}".format(message=str(e))) + + def on_shutdown(self): + if self.filamentManager is not None: + self.filamentManager.close() + + def on_data_modified(self, data, action): + if action.lower() == "update": + # if either profiles, spools or selections are updated + # we have to recalculate the pause thresholds + self.update_pause_thresholds() + + def send_client_message(self, message_type, data=None): + self._plugin_manager.send_plugin_message(self._identifier, dict(type=message_type, data=data)) + + def set_temp_offsets(self, selections): + offset_dict = dict() + for tool in selections: + offset_dict["tool%s" % tool["tool"]] = tool["spool"]["temp_offset"] if tool["spool"] is not None else 0 + self._printer.set_temperature_offset(offset_dict) # SettingsPlugin + def get_settings_version(self): + return 1 + def get_settings_defaults(self): return dict( - _db_version=None, enableOdometer=True, enableWarning=True, autoPause=False, pauseThreshold=100, - currencySymbol="€" + database=dict( + useExternal=False, + uri="postgresql://", + name="", + user="", + password="", + clientID=None, + ), + currencySymbol="€", + confirmSpoolSelection=False, ) + def on_settings_migrate(self, target, current=None): + if current is None or current < 1: + self._settings.set(["selectedSpools"], None) + self._settings.set(["_db_version"], None) + def on_settings_save(self, data): # before saving - old_threshold = self._settings.getInt(["pauseThreshold"]) - + old_threshold = self._settings.getFloat(["pauseThreshold"]) octoprint.plugin.SettingsPlugin.on_settings_save(self, data) # after saving - new_threshold = self._settings.getInt(["pauseThreshold"]) - - if old_threshold != new_threshold: - self._update_pause_threshold() + if old_threshold != self._settings.getFloat(["pauseThreshold"]): + # if the threshold settings has been modified + # we have to recalculate the pause thresholds + self.update_pause_thresholds() self.filamentOdometer.set_g90_extruder(self._settings.getBoolean(["feature", "g90InfluencesExtruder"])) @@ -123,8 +202,8 @@ def on_settings_save(self, data): def get_assets(self): return dict( - css=["css/style.css", "css/font.css"], - js=["js/filamentmanager.js", "js/warning.js", "js/client.js"] + css=["css/filamentmanager.min.css"], + js=["js/filamentmanager.bundled.js"], ) # TemplatePlugin @@ -135,453 +214,127 @@ def get_template_configs(self): dict(type="generic", template="settings_profiledialog.jinja2"), dict(type="generic", template="settings_spooldialog.jinja2"), dict(type="generic", template="settings_configdialog.jinja2"), - dict(type="sidebar", icon="reel", template="sidebar.jinja2", template_header="sidebar_header.jinja2") + dict(type="sidebar", icon="reel", template="sidebar.jinja2", template_header="sidebar_header.jinja2"), + dict(type="generic", template="spool_confirmation.jinja2"), ] - # BlueprintPlugin - - @octoprint.plugin.BlueprintPlugin.route("/profiles", methods=["GET"]) - def get_profiles_list(self): - force = request.values.get("force", False) - - mod = self.filamentManager.get_profiles_modifications() - lm = mod["changed_at"] if mod else 0 - etag = (hashlib.sha1(str(lm))).hexdigest() - - if not force and check_lastmodified(int(lm)) and check_etag(etag): - return make_response("Not Modified", 304) - - try: - all_profiles = self.filamentManager.get_all_profiles() - response = jsonify(dict(profiles=all_profiles)) - response.set_etag(etag) - response.headers["Last-Modified"] = http_date(lm) - response.headers["Cache-Control"] = "max-age=0" - return response - except Exception as e: - self._logger.error("Failed to fetch profiles: {message}".format(message=str(e))) - return make_response("Failed to fetch profiles, see the log for more details", 500) - - @octoprint.plugin.BlueprintPlugin.route("/profiles/", methods=["GET"]) - def get_profile(self, identifier): - try: - profile = self.filamentManager.get_profile(identifier) - if profile: - return jsonify(dict(profile=profile)) - else: - self._logger.warn("Profile with id {id} does not exist".format(id=identifier)) - return make_response("Unknown profile", 404) - except Exception as e: - self._logger.error("Failed to fetch profile with id {id}: {message}" - .format(id=str(identifier), message=str(e))) - return make_response("Failed to fetch profile, see the log for more details", 500) - - @octoprint.plugin.BlueprintPlugin.route("/profiles", methods=["POST"]) - @restricted_access - def create_profile(self): - if "application/json" not in request.headers["Content-Type"]: - return make_response("Expected content-type JSON", 400) - - try: - json_data = request.json - except BadRequest: - return make_response("Malformed JSON body in request", 400) - - if "profile" not in json_data: - return make_response("No profile included in request", 400) - - new_profile = json_data["profile"] - - for key in ["vendor", "material", "density", "diameter"]: - if key not in new_profile: - return make_response("Profile does not contain mandatory '{}' field".format(key), 400) - - try: - saved_profile = self.filamentManager.create_profile(new_profile) - return jsonify(dict(profile=saved_profile)) - except Exception as e: - self._logger.error("Failed to create profile: {message}".format(message=str(e))) - return make_response("Failed to create profile, see the log for more details", 500) - - @octoprint.plugin.BlueprintPlugin.route("/profiles/", methods=["PATCH"]) - @restricted_access - def update_profile(self, identifier): - if "application/json" not in request.headers["Content-Type"]: - return make_response("Expected content-type JSON", 400) - - try: - json_data = request.json - except BadRequest: - return make_response("Malformed JSON body in request", 400) - - if "profile" not in json_data: - return make_response("No profile included in request", 400) - - try: - profile = self.filamentManager.get_profile(identifier) - except Exception as e: - self._logger.error("Failed to fetch profile with id {id}: {message}" - .format(id=str(identifier), message=str(e))) - return make_response("Failed to fetch profile, see the log for more details", 500) - - if not profile: - self._logger.warn("Profile with id {id} does not exist".format(id=identifier)) - return make_response("Unknown profile", 404) - - updated_profile = json_data["profile"] - merged_profile = dict_merge(profile, updated_profile) - - try: - saved_profile = self.filamentManager.update_profile(identifier, merged_profile) - self._update_pause_threshold() - return jsonify(dict(profile=saved_profile)) - except Exception as e: - self._logger.error("Failed to update profile with id {id}: {message}" - .format(id=str(identifier), message=str(e))) - return make_response("Failed to update profile, see the log for more details", 500) - - @octoprint.plugin.BlueprintPlugin.route("/profiles/", methods=["DELETE"]) - @restricted_access - def delete_profile(self, identifier): - try: - self.filamentManager.delete_profile(identifier) - return make_response("", 204) - except Exception as e: - self._logger.error("Failed to delete profile with id {id}: {message}" - .format(id=str(identifier), message=str(e))) - return make_response("Failed to delete profile, see the log for more details", 500) - - @octoprint.plugin.BlueprintPlugin.route("/spools", methods=["GET"]) - def get_spools_list(self): - force = request.values.get("force", False) - - mod_spool = self.filamentManager.get_spools_modifications() - mod_profile = self.filamentManager.get_profiles_modifications() - lm = max(mod_spool["changed_at"] if mod_spool else 0, - mod_profile["changed_at"] if mod_profile else 0) - etag = (hashlib.sha1(str(lm))).hexdigest() - - if not force and check_lastmodified(int(lm)) and check_etag(etag): - return make_response("Not Modified", 304) - - try: - all_spools = self.filamentManager.get_all_spools() - response = jsonify(dict(spools=all_spools)) - response.set_etag(etag) - response.headers["Last-Modified"] = http_date(lm) - response.headers["Cache-Control"] = "max-age=0" - return response - except Exception as e: - self._logger.error("Failed to fetch spools: {message}".format(message=str(e))) - return make_response("Failed to fetch spools, see the log for more details", 500) - - @octoprint.plugin.BlueprintPlugin.route("/spools/", methods=["GET"]) - def get_spool(self, identifier): - try: - spool = self.filamentManager.get_spool(identifier) - if spool: - return jsonify(dict(spool=spool)) - else: - self._logger.warn("Spool with id {id} does not exist".format(id=identifier)) - return make_response("Unknown spool", 404) - except Exception as e: - self._logger.error("Failed to fetch spool with id {id}: {message}" - .format(id=str(identifier), message=str(e))) - return make_response("Failed to fetch spool, see the log for more details", 500) - - @octoprint.plugin.BlueprintPlugin.route("/spools", methods=["POST"]) - @restricted_access - def create_spool(self): - if "application/json" not in request.headers["Content-Type"]: - return make_response("Expected content-type JSON", 400) - - try: - json_data = request.json - except BadRequest: - return make_response("Malformed JSON body in request", 400) - - if "spool" not in json_data: - return make_response("No spool included in request", 400) - - new_spool = json_data["spool"] - - for key in ["name", "profile", "cost", "weight", "used", "temp_offset"]: - if key not in new_spool: - return make_response("Spool does not contain mandatory '{}' field".format(key), 400) - - if "id" not in new_spool.get("profile", {}): - return make_response("Spool does not contain mandatory 'id (profile)' field", 400) - - try: - saved_spool = self.filamentManager.create_spool(new_spool) - return jsonify(dict(spool=saved_spool)) - except Exception as e: - self._logger.error("Failed to create spool: {message}".format(message=str(e))) - return make_response("Failed to create spool, see the log for more details", 500) - - @octoprint.plugin.BlueprintPlugin.route("/spools/", methods=["PATCH"]) - @restricted_access - def update_spool(self, identifier): - if "application/json" not in request.headers["Content-Type"]: - return make_response("Expected content-type JSON", 400) - - try: - json_data = request.json - except BadRequest: - return make_response("Malformed JSON body in request", 400) - - if "spool" not in json_data: - return make_response("No spool included in request", 400) - - try: - spool = self.filamentManager.get_spool(identifier) - except Exception as e: - self._logger.error("Failed to fetch spool with id {id}: {message}" - .format(id=str(identifier), message=str(e))) - return make_response("Failed to fetch spool, see the log for more details", 500) - - if not spool: - self._logger.warn("Spool with id {id} does not exist".format(id=identifier)) - return make_response("Unknown spool", 404) - - updated_spool = json_data["spool"] - merged_spool = dict_merge(spool, updated_spool) - - try: - saved_spool = self.filamentManager.update_spool(identifier, merged_spool) - self._update_pause_threshold() - return jsonify(dict(spool=saved_spool)) - except Exception as e: - self._logger.error("Failed to update spool with id {id}: {message}" - .format(id=str(identifier), message=str(e))) - return make_response("Failed to update spool, see the log for more details", 500) - - @octoprint.plugin.BlueprintPlugin.route("/spools/", methods=["DELETE"]) - @restricted_access - def delete_spool(self, identifier): - try: - self.filamentManager.delete_spool(identifier) - return make_response("", 204) - except Exception as e: - self._logger.error("Failed to delete spool with id {id}: {message}" - .format(id=str(identifier), message=str(e))) - return make_response("Failed to delete spool, see the log for more details", 500) - - @octoprint.plugin.BlueprintPlugin.route("/selections", methods=["GET"]) - def get_selections_list(self): - try: - all_selections = self.filamentManager.get_all_selections() - response = jsonify(dict(selections=all_selections)) - return response - except Exception as e: - self._logger.error("Failed to fetch selected spools: {message}".format(message=str(e))) - return make_response("Failed to fetch selected spools, see the log for more details", 500) - - @octoprint.plugin.BlueprintPlugin.route("/selections/", methods=["PATCH"]) - @restricted_access - def update_selection(self, identifier): - if "application/json" not in request.headers["Content-Type"]: - return make_response("Expected content-type JSON", 400) - - try: - json_data = request.json - except BadRequest: - return make_response("Malformed JSON body in request", 400) - - if "selection" not in json_data: - return make_response("No selection included in request", 400) - - selection = json_data["selection"] - - if "tool" not in selection: - return make_response("Selection does not contain mandatory 'tool' field", 400) - if "id" not in selection.get("spool", {}): - return make_response("Selection does not contain mandatory 'id (spool)' field", 400) - - try: - saved_selection = self.filamentManager.update_selection(identifier, selection) - self._update_pause_threshold() - return jsonify(dict(selection=saved_selection)) - except Exception as e: - self._logger.error("Failed to update selected spool for tool {id}: {message}" - .format(id=str(identifier), message=str(e))) - return make_response("Failed to update selected spool, see the log for more details", 500) - - @octoprint.plugin.BlueprintPlugin.route("/export", methods=["GET"]) - @restricted_access - @admin_permission.require(403) - def export_data(self): - try: - tempdir = tempfile.mkdtemp() - self.filamentManager.export_data(tempdir) - archive_path = shutil.make_archive(tempfile.mktemp(), "zip", tempdir) - except Exception as e: - self._logger.error("Data export failed: {message}".format(message=str(e))) - return make_response("Data export failed, see the log for more details", 500) - finally: - try: - shutil.rmtree(tempdir) - except Exception as e: - self._logger.warn("Could not remove temporary directory {path}: {message}" - .format(path=tempdir, message=str(e))) - - archive_name = "filament_export_{timestamp}.zip".format(timestamp=datetime.now().strftime("%Y-%m-%d_%H-%M-%S")) - - def file_generator(): - with open(archive_path) as f: - for c in f: - yield c - try: - os.remove(archive_path) - except Exception as e: - self._logger.warn("Could not remove temporary file {path}: {message}" - .format(path=archive_path, message=str(e))) - - response = Response(file_generator(), mimetype="application/zip") - response.headers.set('Content-Disposition', 'attachment', filename=archive_name) - return response - - @octoprint.plugin.BlueprintPlugin.route("/import", methods=["POST"]) - @restricted_access - @admin_permission.require(403) - def import_data(self): - input_name = "file" - input_upload_path = input_name + "." + self._settings.global_get(["server", "uploads", "pathSuffix"]) - input_upload_name = input_name + "." + self._settings.global_get(["server", "uploads", "nameSuffix"]) - - if input_upload_path not in request.values or input_upload_name not in request.values: - return make_response("No file included", 400) - - upload_path = request.values[input_upload_path] - upload_name = request.values[input_upload_name] - - if not upload_name.lower().endswith(".zip"): - return make_response("File doesn't have a valid extension for an import archive", 400) - - try: - tempdir = tempfile.mkdtemp() - # python 2.7 lacks of shutil.unpack_archive ¯\_(ツ)_/¯ - from zipfile import ZipFile - with ZipFile(upload_path, "r") as zip_file: - zip_file.extractall(tempdir) - self.filamentManager.import_data(tempdir) - except Exception as e: - self._logger.error("Data import failed: {message}".format(message=str(e))) - return make_response("Data import failed, see the log for more details", 500) - finally: - try: - shutil.rmtree(tempdir) - except Exception as e: - self._logger.warn("Could not remove temporary directory {path}: {message}" - .format(path=tempdir, message=str(e))) - - return make_response("", 204) - # EventHandlerPlugin def on_event(self, event, payload): if event == Events.PRINTER_STATE_CHANGED: - if payload['state_id'] == "PRINTING": - if self.lastPrintState == "PAUSED": - # resuming print - self.filamentOdometer.reset_extruded_length() - else: - # starting new print - self.filamentOdometer.reset() - self.odometerEnabled = self._settings.getBoolean(["enableOdometer"]) - self.pauseEnabled = self._settings.getBoolean(["autoPause"]) - self._logger.debug("Printer State: {}".format(payload["state_string"])) - self._logger.debug("Odometer: {}".format("Enabled" if self.odometerEnabled else "Disabled")) - self._logger.debug("AutoPause: {}".format("Enabled" if self.pauseEnabled else "Disabled")) - elif self.lastPrintState == "PRINTING": - self._logger.debug("Printer State: {}".format(payload["state_string"])) - # print state changed from printing, update filament usage - if self.odometerEnabled: - self.odometerEnabled = False - self._update_filament_usage() - - # update last print state - self.lastPrintState = payload['state_id'] - - def _update_filament_usage(self): + self.on_printer_state_changed(payload) + + def on_printer_state_changed(self, payload): + if payload['state_id'] == "PRINTING": + if self.lastPrintState == "PAUSED": + # resuming print + self.filamentOdometer.reset_extruded_length() + else: + # starting new print + self.filamentOdometer.reset() + self.odometerEnabled = self._settings.getBoolean(["enableOdometer"]) + self.pauseEnabled = self._settings.getBoolean(["autoPause"]) + self._logger.debug("Printer State: %s" % payload["state_string"]) + self._logger.debug("Odometer: %s" % ("On" if self.odometerEnabled else "Off")) + self._logger.debug("AutoPause: %s" % ("On" if self.pauseEnabled and self.odometerEnabled else "Off")) + elif self.lastPrintState == "PRINTING": + # print state changed from printing => update filament usage + self._logger.debug("Printer State: %s" % payload["state_string"]) + if self.odometerEnabled: + self.odometerEnabled = False # disabled because we don't want to track manual extrusion + self.update_filament_usage() + + # update last print state + self.lastPrintState = payload['state_id'] + + def update_filament_usage(self): printer_profile = self._printer_profile_manager.get_current_or_default() - extrusion = self.filamentOdometer.get_values() + extrusion = self.filamentOdometer.get_extrusion() numTools = min(printer_profile['extruder']['count'], len(extrusion)) + def calculate_weight(length, profile): + radius = profile["diameter"] / 2 # mm + volume = (length * PI * radius * radius) / 1000 # cm³ + return volume * profile["density"] # g + for tool in xrange(0, numTools): - self._logger.info("Filament used: {length} mm (tool{id})".format(length=str(extrusion[tool]), id=str(tool))) + self._logger.info("Filament used: {length} mm (tool{id})" + .format(length=str(extrusion[tool]), id=str(tool))) try: - selection = self.filamentManager.get_selection(tool) + selection = self.filamentManager.get_selection(tool, self.client_id) spool = selection["spool"] - if not spool: + if spool is None: + # spool not found => skip self._logger.warn("No selected spool for tool{id}".format(id=tool)) continue # update spool - spool_string = "{name} - {material} ({vendor})" - spool_string = spool_string.format(name=spool["name"], material=spool["profile"]["material"], - vendor=spool["profile"]["vendor"]) - volume = self._length_to_volume(spool["profile"]['diameter'], extrusion[tool]) / 1000 - weight = volume * spool["profile"]['density'] + weight = calculate_weight(extrusion[tool], spool["profile"]) old_value = spool["weight"] - spool["used"] spool["used"] += weight new_value = spool["weight"] - spool["used"] - self._logger.debug("Updating remaining filament on spool '{spool}' from {old}g to {new}g ({diff}g)" + + self.filamentManager.update_spool(spool["id"], spool) + + # logging + spool_string = "{name} - {material} ({vendor})" + spool_string = spool_string.format(name=spool["name"], material=spool["profile"]["material"], + vendor=spool["profile"]["vendor"]) + self._logger.debug("Updated remaining filament on spool '{spool}' from {old}g to {new}g ({diff}g)" .format(spool=spool_string, old=str(old_value), new=str(new_value), diff=str(new_value - old_value))) - self.filamentManager.update_spool(spool["id"], spool) except Exception as e: self._logger.error("Failed to update filament on tool{id}: {message}" .format(id=str(tool), message=str(e))) - self._send_client_message("updated_filaments") - self._update_pause_threshold() - - def _send_client_message(self, message_type, data=None): - self._plugin_manager.send_plugin_message(self._identifier, dict(type=message_type, data=data)) - - def _length_to_volume(self, diameter, length): - radius = diameter / 2 - return length * math.pi * radius * radius - - def _volume_to_length(self, diameter, volume): - radius = diameter / 2 - return volume / (math.pi * radius * radius) + self.send_client_message("data_changed", data=dict(table="spools", action="update")) + self.on_data_modified("spools", "update") # Protocol hook def filament_odometer(self, comm_instance, phase, cmd, cmd_type, gcode, *args, **kwargs): if self.odometerEnabled: self.filamentOdometer.parse(gcode, cmd) - if self.pauseEnabled: - extrusion = self.filamentOdometer.get_values() - tool = self.filamentOdometer.get_current_tool() - try: - if self.pauseThreshold[tool] is not None and extrusion[tool] >= self.pauseThreshold[tool]: - self._logger.info("Filament is running out, pausing print") - self._printer.pause_print() - except IndexError: - # Ignoring index out of range errors - # This usually means that the tool has no spool assigned - pass - - def _update_pause_threshold(self): + if self.pauseEnabled and self.check_threshold(): + self._logger.info("Filament is running out, pausing print") + self._printer.pause_print() + + def check_threshold(self): + extrusion = self.filamentOdometer.get_extrusion() + tool = self.filamentOdometer.get_current_tool() + threshold = self.pauseThresholds.get("tool%s" % tool) + return (threshold is not None and extrusion[tool] >= threshold) + + def update_pause_thresholds(self): + def set_threshold(selection): + def threshold(spool): + radius = spool["profile"]["diameter"] / 2 + volume = (spool["weight"] - spool["used"]) / spool["profile"]["density"] + length = (volume * 1000) / (PI * radius * radius) + return length - self._settings.getFloat(["pauseThreshold"]) + + try: + spool = selection["spool"] + if spool is not None: + self.pauseThresholds["tool%s" % selection["tool"]] = threshold(spool) + except ZeroDivisionError: + self._logger.warn("ZeroDivisionError while calculating pause threshold for tool{tool}, " + "pause feature not available for selected spool".format(tool=tool)) + + self.pauseThresholds = dict() + try: - selections = self.filamentManager.get_all_selections() - tmp = [] - for sel in selections: - if sel["tool"] >= len(tmp): - tmp.extend([None for i in xrange(sel["tool"] - len(tmp) + 1)]) - diameter = sel["spool"]["profile"]["diameter"] - volume = (sel["spool"]["weight"] - sel["spool"]["used"]) / sel["spool"]["profile"]["density"] - threshold = self._volume_to_length(diameter, volume * 1000) - self._settings.getInt(["pauseThreshold"]) - tmp.insert(sel["tool"], threshold) - self.pauseThreshold = tmp + selections = self.filamentManager.get_all_selections(self.client_id) except Exception as e: - self.pauseThreshold = [] - self._logger.error("Failed to set pause tresholds: {message}".format(message=str(e))) + self._logger.error("Failed to fetch selected spools, pause feature will not be available: {message}" + .format(message=str(e))) + else: + for s in selections: + set_threshold(s) + + self._logger.debug("Updated thresholds: {thresholds}".format(thresholds=str(self.pauseThresholds))) # Softwareupdate hook @@ -605,8 +358,17 @@ def get_update_information(self): __plugin_name__ = "Filament Manager" +__required_octoprint_version__ = ">=1.3.6" + def __plugin_load__(): + if not is_octoprint_compatible(__required_octoprint_version__): + import logging + logger = logging.getLogger(__name__) + logger.error("OctoPrint version is not compatible ({version} required)" + .format(version=__required_octorpint_version__)) + return + global __plugin_implementation__ __plugin_implementation__ = FilamentManagerPlugin() diff --git a/octoprint_filamentmanager/api/__init__.py b/octoprint_filamentmanager/api/__init__.py new file mode 100644 index 0000000..89319db --- /dev/null +++ b/octoprint_filamentmanager/api/__init__.py @@ -0,0 +1,375 @@ +# coding=utf-8 +from __future__ import absolute_import + +__author__ = "Sven Lohrmann " +__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" +__copyright__ = "Copyright (C) 2017 Sven Lohrmann - Released under terms of the AGPLv3 License" + +import os +import tempfile +import shutil +from datetime import datetime + +from flask import jsonify, request, make_response, Response +from werkzeug.exceptions import BadRequest + +import octoprint.plugin +from octoprint.settings import valid_boolean_trues +from octoprint.server import admin_permission +from octoprint.server.util.flask import restricted_access, check_lastmodified, check_etag +from octoprint.util import dict_merge + +from .util import * + + +class FilamentManagerApi(octoprint.plugin.BlueprintPlugin): + + @octoprint.plugin.BlueprintPlugin.route("/profiles", methods=["GET"]) + def get_profiles_list(self): + force = request.values.get("force", "false") in valid_boolean_trues + + try: + lm = self.filamentManager.get_profiles_lastmodified() + except Exception as e: + lm = None + self._logger.error("Failed to fetch profiles lastmodified timestamp: {message}".format(message=str(e))) + + etag = entity_tag(lm) + + if not force and check_lastmodified(lm) and check_etag(etag): + return make_response("Not Modified", 304) + + try: + all_profiles = self.filamentManager.get_all_profiles() + response = jsonify(dict(profiles=all_profiles)) + return add_revalidation_header_with_no_max_age(response, lm, etag) + except Exception as e: + self._logger.error("Failed to fetch profiles: {message}".format(message=str(e))) + return make_response("Failed to fetch profiles, see the log for more details", 500) + + @octoprint.plugin.BlueprintPlugin.route("/profiles/", methods=["GET"]) + def get_profile(self, identifier): + try: + profile = self.filamentManager.get_profile(identifier) + if profile is not None: + return jsonify(dict(profile=profile)) + else: + self._logger.warn("Profile with id {id} does not exist".format(id=identifier)) + return make_response("Unknown profile", 404) + except Exception as e: + self._logger.error("Failed to fetch profile with id {id}: {message}" + .format(id=str(identifier), message=str(e))) + return make_response("Failed to fetch profile, see the log for more details", 500) + + @octoprint.plugin.BlueprintPlugin.route("/profiles", methods=["POST"]) + @restricted_access + def create_profile(self): + if "application/json" not in request.headers["Content-Type"]: + return make_response("Expected content-type JSON", 400) + + try: + json_data = request.json + except BadRequest: + return make_response("Malformed JSON body in request", 400) + + if "profile" not in json_data: + return make_response("No profile included in request", 400) + + new_profile = json_data["profile"] + + for key in ["vendor", "material", "density", "diameter"]: + if key not in new_profile: + return make_response("Profile does not contain mandatory '{}' field".format(key), 400) + + try: + saved_profile = self.filamentManager.create_profile(new_profile) + return jsonify(dict(profile=saved_profile)) + except Exception as e: + self._logger.error("Failed to create profile: {message}".format(message=str(e))) + return make_response("Failed to create profile, see the log for more details", 500) + + @octoprint.plugin.BlueprintPlugin.route("/profiles/", methods=["PATCH"]) + @restricted_access + def update_profile(self, identifier): + if "application/json" not in request.headers["Content-Type"]: + return make_response("Expected content-type JSON", 400) + + try: + json_data = request.json + except BadRequest: + return make_response("Malformed JSON body in request", 400) + + if "profile" not in json_data: + return make_response("No profile included in request", 400) + + try: + profile = self.filamentManager.get_profile(identifier) + except Exception as e: + self._logger.error("Failed to fetch profile with id {id}: {message}" + .format(id=str(identifier), message=str(e))) + return make_response("Failed to fetch profile, see the log for more details", 500) + + if not profile: + self._logger.warn("Profile with id {id} does not exist".format(id=identifier)) + return make_response("Unknown profile", 404) + + updated_profile = json_data["profile"] + merged_profile = dict_merge(profile, updated_profile) + + try: + saved_profile = self.filamentManager.update_profile(identifier, merged_profile) + except Exception as e: + self._logger.error("Failed to update profile with id {id}: {message}" + .format(id=str(identifier), message=str(e))) + return make_response("Failed to update profile, see the log for more details", 500) + else: + self.on_data_modified("profiles", "update") + return jsonify(dict(profile=saved_profile)) + + @octoprint.plugin.BlueprintPlugin.route("/profiles/", methods=["DELETE"]) + @restricted_access + def delete_profile(self, identifier): + try: + self.filamentManager.delete_profile(identifier) + return make_response("", 204) + except Exception as e: + self._logger.error("Failed to delete profile with id {id}: {message}" + .format(id=str(identifier), message=str(e))) + return make_response("Failed to delete profile, see the log for more details", 500) + + @octoprint.plugin.BlueprintPlugin.route("/spools", methods=["GET"]) + def get_spools_list(self): + force = request.values.get("force", "false") in valid_boolean_trues + + try: + lm = self.filamentManager.get_spools_lastmodified() + except Exception as e: + lm = None + self._logger.error("Failed to fetch spools lastmodified timestamp: {message}".format(message=str(e))) + + etag = entity_tag(lm) + + if not force and check_lastmodified(lm) and check_etag(etag): + return make_response("Not Modified", 304) + + try: + all_spools = self.filamentManager.get_all_spools() + response = jsonify(dict(spools=all_spools)) + return add_revalidation_header_with_no_max_age(response, lm, etag) + except Exception as e: + self._logger.error("Failed to fetch spools: {message}".format(message=str(e))) + return make_response("Failed to fetch spools, see the log for more details", 500) + + @octoprint.plugin.BlueprintPlugin.route("/spools/", methods=["GET"]) + def get_spool(self, identifier): + try: + spool = self.filamentManager.get_spool(identifier) + if spool is not None: + return jsonify(dict(spool=spool)) + else: + self._logger.warn("Spool with id {id} does not exist".format(id=identifier)) + return make_response("Unknown spool", 404) + except Exception as e: + self._logger.error("Failed to fetch spool with id {id}: {message}" + .format(id=str(identifier), message=str(e))) + return make_response("Failed to fetch spool, see the log for more details", 500) + + @octoprint.plugin.BlueprintPlugin.route("/spools", methods=["POST"]) + @restricted_access + def create_spool(self): + if "application/json" not in request.headers["Content-Type"]: + return make_response("Expected content-type JSON", 400) + + try: + json_data = request.json + except BadRequest: + return make_response("Malformed JSON body in request", 400) + + if "spool" not in json_data: + return make_response("No spool included in request", 400) + + new_spool = json_data["spool"] + + for key in ["name", "profile", "cost", "weight", "used", "temp_offset"]: + if key not in new_spool: + return make_response("Spool does not contain mandatory '{}' field".format(key), 400) + + if "id" not in new_spool.get("profile", {}): + return make_response("Spool does not contain mandatory 'id (profile)' field", 400) + + try: + saved_spool = self.filamentManager.create_spool(new_spool) + return jsonify(dict(spool=saved_spool)) + except Exception as e: + self._logger.error("Failed to create spool: {message}".format(message=str(e))) + return make_response("Failed to create spool, see the log for more details", 500) + + @octoprint.plugin.BlueprintPlugin.route("/spools/", methods=["PATCH"]) + @restricted_access + def update_spool(self, identifier): + if "application/json" not in request.headers["Content-Type"]: + return make_response("Expected content-type JSON", 400) + + try: + json_data = request.json + except BadRequest: + return make_response("Malformed JSON body in request", 400) + + if "spool" not in json_data: + return make_response("No spool included in request", 400) + + try: + spool = self.filamentManager.get_spool(identifier) + except Exception as e: + self._logger.error("Failed to fetch spool with id {id}: {message}" + .format(id=str(identifier), message=str(e))) + return make_response("Failed to fetch spool, see the log for more details", 500) + + if not spool: + self._logger.warn("Spool with id {id} does not exist".format(id=identifier)) + return make_response("Unknown spool", 404) + + updated_spool = json_data["spool"] + merged_spool = dict_merge(spool, updated_spool) + + try: + saved_spool = self.filamentManager.update_spool(identifier, merged_spool) + except Exception as e: + self._logger.error("Failed to update spool with id {id}: {message}" + .format(id=str(identifier), message=str(e))) + return make_response("Failed to update spool, see the log for more details", 500) + else: + self.on_data_modified("spools", "update") + return jsonify(dict(spool=saved_spool)) + + @octoprint.plugin.BlueprintPlugin.route("/spools/", methods=["DELETE"]) + @restricted_access + def delete_spool(self, identifier): + try: + self.filamentManager.delete_spool(identifier) + return make_response("", 204) + except Exception as e: + self._logger.error("Failed to delete spool with id {id}: {message}" + .format(id=str(identifier), message=str(e))) + return make_response("Failed to delete spool, see the log for more details", 500) + + @octoprint.plugin.BlueprintPlugin.route("/selections", methods=["GET"]) + def get_selections_list(self): + try: + all_selections = self.filamentManager.get_all_selections(self.client_id) + return jsonify(dict(selections=all_selections)) + except Exception as e: + self._logger.error("Failed to fetch selected spools: {message}".format(message=str(e))) + return make_response("Failed to fetch selected spools, see the log for more details", 500) + + @octoprint.plugin.BlueprintPlugin.route("/selections/", methods=["PATCH"]) + @restricted_access + def update_selection(self, identifier): + if "application/json" not in request.headers["Content-Type"]: + return make_response("Expected content-type JSON", 400) + + try: + json_data = request.json + except BadRequest: + return make_response("Malformed JSON body in request", 400) + + if "selection" not in json_data: + return make_response("No selection included in request", 400) + + selection = json_data["selection"] + + if "tool" not in selection: + return make_response("Selection does not contain mandatory 'tool' field", 400) + if "id" not in selection.get("spool", {}): + return make_response("Selection does not contain mandatory 'id (spool)' field", 400) + + if self._printer.is_printing(): + return make_response("Trying to change filament while printing", 409) + + try: + saved_selection = self.filamentManager.update_selection(identifier, self.client_id, selection) + except Exception as e: + self._logger.error("Failed to update selected spool for tool{id}: {message}" + .format(id=str(identifier), message=str(e))) + return make_response("Failed to update selected spool, see the log for more details", 500) + else: + try: + self.set_temp_offsets([saved_selection]) + except Exception as e: + self._logger.error("Failed to set temperature offsets: {message}".format(message=str(e))) + self.on_data_modified("selections", "update") + return jsonify(dict(selection=saved_selection)) + + @octoprint.plugin.BlueprintPlugin.route("/export", methods=["GET"]) + @restricted_access + @admin_permission.require(403) + def export_data(self): + try: + tempdir = tempfile.mkdtemp() + self.filamentManager.export_data(tempdir) + archive_path = shutil.make_archive(tempfile.mktemp(), "zip", tempdir) + except Exception as e: + self._logger.error("Data export failed: {message}".format(message=str(e))) + return make_response("Data export failed, see the log for more details", 500) + finally: + try: + shutil.rmtree(tempdir) + except Exception as e: + self._logger.warn("Could not remove temporary directory {path}: {message}" + .format(path=tempdir, message=str(e))) + + timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + archive_name = "filament_export_{timestamp}.zip".format(timestamp=timestamp) + + def file_generator(): + with open(archive_path) as f: + for c in f: + yield c + try: + os.remove(archive_path) + except Exception as e: + self._logger.warn("Could not remove temporary file {path}: {message}" + .format(path=archive_path, message=str(e))) + + response = Response(file_generator(), mimetype="application/zip") + response.headers.set('Content-Disposition', 'attachment', filename=archive_name) + return response + + @octoprint.plugin.BlueprintPlugin.route("/import", methods=["POST"]) + @restricted_access + @admin_permission.require(403) + def import_data(self): + def unzip(filename, extract_dir): + # python 2.7 lacks of shutil.unpack_archive ¯\_(ツ)_/¯ + from zipfile import ZipFile + with ZipFile(filename, "r") as zip_file: + zip_file.extractall(extract_dir) + + input_name = "file" + input_upload_path = input_name + "." + self._settings.global_get(["server", "uploads", "pathSuffix"]) + input_upload_name = input_name + "." + self._settings.global_get(["server", "uploads", "nameSuffix"]) + + if input_upload_path not in request.values or input_upload_name not in request.values: + return make_response("No file included", 400) + + upload_path = request.values[input_upload_path] + upload_name = request.values[input_upload_name] + + if not upload_name.lower().endswith(".zip"): + return make_response("File doesn't have a valid extension for an import archive", 400) + + try: + tempdir = tempfile.mkdtemp() + unzip(upload_path, tempdir) + self.filamentManager.import_data(tempdir) + except Exception as e: + self._logger.error("Data import failed: {message}".format(message=str(e))) + return make_response("Data import failed, see the log for more details", 500) + finally: + try: + shutil.rmtree(tempdir) + except Exception as e: + self._logger.warn("Could not remove temporary directory {path}: {message}" + .format(path=tempdir, message=str(e))) + + return make_response("", 204) diff --git a/octoprint_filamentmanager/api/util.py b/octoprint_filamentmanager/api/util.py new file mode 100644 index 0000000..76d03ff --- /dev/null +++ b/octoprint_filamentmanager/api/util.py @@ -0,0 +1,20 @@ +# coding=utf-8 +from __future__ import absolute_import + +__author__ = "Sven Lohrmann " +__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" +__copyright__ = "Copyright (C) 2017 Sven Lohrmann - Released under terms of the AGPLv3 License" + +import hashlib +from werkzeug.http import http_date + + +def add_revalidation_header_with_no_max_age(response, lm, etag): + response.set_etag(etag) + response.headers["Last-Modified"] = http_date(lm) + response.headers["Cache-Control"] = "max-age=0" + return response + + +def entity_tag(lm): + return (hashlib.sha1(str(lm))).hexdigest() diff --git a/octoprint_filamentmanager/data/__init__.py b/octoprint_filamentmanager/data/__init__.py new file mode 100644 index 0000000..57659f0 --- /dev/null +++ b/octoprint_filamentmanager/data/__init__.py @@ -0,0 +1,359 @@ +# coding=utf-8 + +__author__ = "Sven Lohrmann " +__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" +__copyright__ = "Copyright (C) 2017 Sven Lohrmann - Released under terms of the AGPLv3 License" + +import io +import os +from multiprocessing import Lock + +from backports import csv +from uritools import uricompose, urisplit +from sqlalchemy import create_engine, event, text +from sqlalchemy.schema import MetaData, Table, Column, ForeignKeyConstraint, DDL, PrimaryKeyConstraint +from sqlalchemy.sql import insert, update, delete, select, label +from sqlalchemy.types import INTEGER, VARCHAR, REAL, TIMESTAMP +from sqlalchemy.dialects.postgresql import insert as pg_insert +import sqlalchemy.sql.functions as func + +from .listen import PGNotify + + +class FilamentManager(object): + + DIALECT_SQLITE = "sqlite" + DIALECT_POSTGRESQL = "postgresql" + + def __init__(self, config): + if not set(("uri", "name", "user", "password")).issubset(config): + raise ValueError("Incomplete config dictionary") + + # QUESTION thread local connection (pool) vs sharing a serialized connection, pro/cons? + # from sqlalchemy.orm import sessionmaker, scoped_session + # Session = scoped_session(sessionmaker(bind=engine)) + # when using a connection pool how do we prevent notifiying ourself on database changes? + self.lock = Lock() + self.notify = None + + uri_parts = urisplit(config["uri"]) + + if self.DIALECT_SQLITE == uri_parts.scheme: + self.engine = create_engine(config["uri"], connect_args={"check_same_thread": False}) + self.conn = self.engine.connect() + self.conn.execute(text("PRAGMA foreign_keys = ON").execution_options(autocommit=True)) + elif self.DIALECT_POSTGRESQL == uri_parts.scheme: + uri = uricompose(scheme=uri_parts.scheme, host=uri_parts.host, port=uri_parts.getport(default=5432), + path="/{}".format(config["name"]), + userinfo="{}:{}".format(config["user"], config["password"])) + self.engine = create_engine(uri) + self.conn = self.engine.connect() + self.notify = PGNotify(uri) + else: + raise ValueError("Engine '{engine}' not supported".format(engine=uri_parts.scheme)) + + def close(self): + self.conn.close() + + def initialize(self): + metadata = MetaData() + + self.profiles = Table("profiles", metadata, + Column("id", INTEGER, primary_key=True, autoincrement=True), + Column("vendor", VARCHAR(255), nullable=False, server_default=""), + Column("material", VARCHAR(255), nullable=False, server_default=""), + Column("density", REAL, nullable=False, server_default="0"), + Column("diameter", REAL, nullable=False, server_default="0")) + + self.spools = Table("spools", metadata, + Column("id", INTEGER, primary_key=True, autoincrement=True), + Column("profile_id", INTEGER, nullable=False), + Column("name", VARCHAR(255), nullable=False, server_default=""), + Column("cost", REAL, nullable=False, server_default="0"), + Column("weight", REAL, nullable=False, server_default="0"), + Column("used", REAL, nullable=False, server_default="0"), + Column("temp_offset", INTEGER, nullable=False, server_default="0"), + ForeignKeyConstraint(["profile_id"], ["profiles.id"], ondelete="RESTRICT")) + + self.selections = Table("selections", metadata, + Column("tool", INTEGER,), + Column("client_id", VARCHAR(36)), + Column("spool_id", INTEGER), + PrimaryKeyConstraint("tool", "client_id", name="selections_pkey"), + ForeignKeyConstraint(["spool_id"], ["spools.id"], ondelete="CASCADE")) + + self.versioning = Table("versioning", metadata, + Column("schema_id", INTEGER, primary_key=True, autoincrement=False)) + + self.modifications = Table("modifications", metadata, + Column("table_name", VARCHAR(255), nullable=False, primary_key=True), + Column("action", VARCHAR(255), nullable=False), + Column("changed_at", TIMESTAMP, nullable=False, + server_default=text("CURRENT_TIMESTAMP"))) + + if self.DIALECT_POSTGRESQL == self.engine.dialect.name: + def should_create_function(name): + row = self.conn.execute("select proname from pg_proc where proname = '%s'" % name).scalar() + return not bool(row) + + def should_create_trigger(name): + row = self.conn.execute("select tgname from pg_trigger where tgname = '%s'" % name).scalar() + return not bool(row) + + trigger_function = DDL(""" + CREATE FUNCTION update_lastmodified() + RETURNS TRIGGER AS $func$ + BEGIN + INSERT INTO modifications (table_name, action, changed_at) + VALUES(TG_TABLE_NAME, TG_OP, CURRENT_TIMESTAMP) + ON CONFLICT (table_name) DO UPDATE + SET action=TG_OP, changed_at=CURRENT_TIMESTAMP + WHERE modifications.table_name=TG_TABLE_NAME; + PERFORM pg_notify(TG_TABLE_NAME, TG_OP); + RETURN NULL; + END; + $func$ LANGUAGE plpgsql; + """) + + if should_create_function("update_lastmodified"): + event.listen(metadata, "after_create", trigger_function) + + for table in [self.profiles.name, self.spools.name]: + for action in ["INSERT", "UPDATE", "DELETE"]: + name = "{table}_on_{action}".format(table=table, action=action.lower()) + trigger = DDL(""" + CREATE TRIGGER {name} AFTER {action} on {table} + FOR EACH ROW EXECUTE PROCEDURE update_lastmodified() + """.format(name=name, table=table, action=action)) + if should_create_trigger(name): + event.listen(metadata, "after_create", trigger) + + elif self.DIALECT_SQLITE == self.engine.dialect.name: + for table in [self.profiles.name, self.spools.name]: + for action in ["INSERT", "UPDATE", "DELETE"]: + name = "{table}_on_{action}".format(table=table, action=action.lower()) + trigger = DDL(""" + CREATE TRIGGER IF NOT EXISTS {name} AFTER {action} on {table} + FOR EACH ROW BEGIN + REPLACE INTO modifications (table_name, action) VALUES ('{table}','{action}'); + END + """.format(name=name, table=table, action=action)) + event.listen(metadata, "after_create", trigger) + + metadata.create_all(self.conn, checkfirst=True) + + def execute_script(self, script): + with self.lock, self.conn.begin(): + for stmt in script.split(";"): + self.conn.execute(text(stmt)) + + # versioning + + def get_schema_version(self): + with self.lock, self.conn.begin(): + return self.conn.execute(select([func.max(self.versioning.c.schema_id)])).scalar() + + def set_schema_version(self, version): + with self.lock, self.conn.begin(): + self.conn.execute(insert(self.versioning).values((version,))) + self.conn.execute(delete(self.versioning).where(self.versioning.c.schema_id < version)) + + # profiles + + def get_all_profiles(self): + with self.lock, self.conn.begin(): + stmt = select([self.profiles]).order_by(self.profiles.c.material, self.profiles.c.vendor) + result = self.conn.execute(stmt) + return self._result_to_dict(result) + + def get_profiles_lastmodified(self): + with self.lock, self.conn.begin(): + stmt = select([self.modifications.c.changed_at]).where(self.modifications.c.table_name == "profiles") + return self.conn.execute(stmt).scalar() + + def get_profile(self, identifier): + with self.lock, self.conn.begin(): + stmt = select([self.profiles]).where(self.profiles.c.id == identifier)\ + .order_by(self.profiles.c.material, self.profiles.c.vendor) + result = self.conn.execute(stmt) + return self._result_to_dict(result, one=True) + + def create_profile(self, data): + with self.lock, self.conn.begin(): + stmt = insert(self.profiles)\ + .values(vendor=data["vendor"], material=data["material"], density=data["density"], + diameter=data["diameter"]) + result = self.conn.execute(stmt) + data["id"] = result.lastrowid + return data + + def update_profile(self, identifier, data): + with self.lock, self.conn.begin(): + stmt = update(self.profiles).where(self.profiles.c.id == identifier)\ + .values(vendor=data["vendor"], material=data["material"], density=data["density"], + diameter=data["diameter"]) + self.conn.execute(stmt) + return data + + def delete_profile(self, identifier): + with self.lock, self.conn.begin(): + stmt = delete(self.profiles).where(self.profiles.c.id == identifier) + self.conn.execute(stmt) + + # spools + + def _build_spool_dict(self, row, column_names): + spool = dict(profile=dict()) + for i, value in enumerate(row): + if i < len(self.spools.columns): + spool[column_names[i]] = value + else: + spool["profile"][column_names[i]] = value + del spool["profile_id"] + return spool + + def get_all_spools(self): + with self.lock, self.conn.begin(): + j = self.spools.join(self.profiles, self.spools.c.profile_id == self.profiles.c.id) + stmt = select([self.spools, self.profiles]).select_from(j).order_by(self.spools.c.name) + result = self.conn.execute(stmt) + return [self._build_spool_dict(row, row.keys()) for row in result.fetchall()] + + def get_spools_lastmodified(self): + with self.lock, self.conn.begin(): + stmt = select([func.max(self.modifications.c.changed_at)])\ + .where(self.modifications.c.table_name.in_(["spools", "profiles"])) + return self.conn.execute(stmt).scalar() + + def get_spool(self, identifier): + with self.lock, self.conn.begin(): + j = self.spools.join(self.profiles, self.spools.c.profile_id == self.profiles.c.id) + stmt = select([self.spools, self.profiles]).select_from(j)\ + .where(self.spools.c.id == identifier).order_by(self.spools.c.name) + result = self.conn.execute(stmt) + row = result.fetchone() + return self._build_spool_dict(row, row.keys()) if row is not None else None + + def create_spool(self, data): + with self.lock, self.conn.begin(): + stmt = insert(self.spools)\ + .values(name=data["name"], cost=data["cost"], weight=data["weight"], used=data["used"], + temp_offset=data["temp_offset"], profile_id=data["profile"]["id"]) + result = self.conn.execute(stmt) + data["id"] = result.lastrowid + return data + + def update_spool(self, identifier, data): + with self.lock, self.conn.begin(): + stmt = update(self.spools).where(self.spools.c.id == identifier)\ + .values(name=data["name"], cost=data["cost"], weight=data["weight"], used=data["used"], + temp_offset=data["temp_offset"], profile_id=data["profile"]["id"]) + self.conn.execute(stmt) + return data + + def delete_spool(self, identifier): + with self.lock, self.conn.begin(): + stmt = delete(self.spools).where(self.spools.c.id == identifier) + self.conn.execute(stmt) + + # selections + + def _build_selection_dict(self, row, column_names): + sel = dict(spool=dict(profile=dict())) + for i, value in enumerate(row): + if i < len(self.selections.columns): + sel[column_names[i]] = value + elif i < len(self.selections.columns)+len(self.spools.columns): + sel["spool"][column_names[i]] = value + else: + sel["spool"]["profile"][column_names[i]] = value + del sel["spool_id"] + del sel["spool"]["profile_id"] + return sel + + def get_all_selections(self, client_id): + with self.lock, self.conn.begin(): + j1 = self.selections.join(self.spools, self.selections.c.spool_id == self.spools.c.id) + j2 = j1.join(self.profiles, self.spools.c.profile_id == self.profiles.c.id) + stmt = select([self.selections, self.spools, self.profiles]).select_from(j2)\ + .where(self.selections.c.client_id == client_id).order_by(self.selections.c.tool) + result = self.conn.execute(stmt) + return [self._build_selection_dict(row, row.keys()) for row in result.fetchall()] + + def get_selection(self, identifier, client_id): + with self.lock, self.conn.begin(): + j1 = self.selections.join(self.spools, self.selections.c.spool_id == self.spools.c.id) + j2 = j1.join(self.profiles, self.spools.c.profile_id == self.profiles.c.id) + stmt = select([self.selections, self.spools, self.profiles]).select_from(j2)\ + .where((self.selections.c.tool == identifier) & (self.selections.c.client_id == client_id)) + result = self.conn.execute(stmt) + row = result.fetchone() + return self._build_selection_dict(row, row.keys()) if row is not None else dict(tool=identifier, spool=None) + + def update_selection(self, identifier, client_id, data): + with self.lock, self.conn.begin(): + values = dict() + if self.engine.dialect.name == self.DIALECT_SQLITE: + stmt = insert(self.selections).prefix_with("OR REPLACE")\ + .values(tool=identifier, client_id=client_id, spool_id=data["spool"]["id"]) + elif self.engine.dialect.name == self.DIALECT_POSTGRESQL: + stmt = pg_insert(self.selections)\ + .values(tool=identifier, client_id=client_id, spool_id=data["spool"]["id"])\ + .on_conflict_do_update(constraint="selections_pkey", set_=dict(spool_id=data["spool"]["id"])) + self.conn.execute(stmt) + return self.get_selection(identifier, client_id) + + def export_data(self, dirpath): + def to_csv(table): + with self.lock, self.conn.begin(): + result = self.conn.execute(select([table])) + filepath = os.path.join(dirpath, table.name + ".csv") + with io.open(filepath, mode="w", encoding="utf-8") as csv_file: + csv_writer = csv.writer(csv_file) + csv_writer.writerow(table.columns.keys()) + csv_writer.writerows(result) + + tables = [self.profiles, self.spools] + for t in tables: + to_csv(t) + + def import_data(self, dirpath): + def from_csv(table): + filepath = os.path.join(dirpath, table.name + ".csv") + with io.open(filepath, mode="r", encoding="utf-8") as csv_file: + csv_reader = csv.reader(csv_file) + header = next(csv_reader) + with self.lock, self.conn.begin(): + for row in csv_reader: + values = dict(zip(header, row)) + + if self.engine.dialect.name == self.DIALECT_SQLITE: + identifier = values[table.c.id] + # try to update entry + stmt = update(table).values(values).where(table.c.id == identifier) + if self.conn.execute(stmt).rowcount == 0: + # identifier doesn't match any => insert new entry + stmt = insert(table).values(values) + self.conn.execute(stmt) + elif self.engine.dialect.name == self.DIALECT_POSTGRESQL: + stmt = pg_insert(table).values(values)\ + .on_conflict_do_update(index_elements=[table.c.id], set_=values) + self.conn.execute(stmt) + + if self.DIALECT_POSTGRESQL == self.engine.dialect.name: + # update sequences + self.conn.execute(text("SELECT setval('profiles_id_seq', max(id)) FROM profiles")) + self.conn.execute(text("SELECT setval('spools_id_seq', max(id)) FROM spools")) + + tables = [self.profiles, self.spools] + for t in tables: + from_csv(t) + + # helper + + def _result_to_dict(self, result, one=False): + if one: + row = result.fetchone() + return dict(row) if row is not None else None + else: + return [dict(row) for row in result.fetchall()] diff --git a/octoprint_filamentmanager/data/listen.py b/octoprint_filamentmanager/data/listen.py new file mode 100644 index 0000000..f17573c --- /dev/null +++ b/octoprint_filamentmanager/data/listen.py @@ -0,0 +1,38 @@ +# coding=utf-8 + +__author__ = "Sven Lohrmann " +__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" +__copyright__ = "Copyright (C) 2017 Sven Lohrmann - Released under terms of the AGPLv3 License" + +from threading import Thread +from select import select as wait_ready +from sqlalchemy import create_engine, text + + +class PGNotify(object): + + def __init__(self, uri): + self.subscriber = list() + + engine = create_engine(uri) + conn = engine.connect() + conn.execute(text("LISTEN profiles; LISTEN spools;").execution_options(autocommit=True)) + + notify_thread = Thread(target=self.notify, args=(conn,)) + notify_thread.daemon = True + notify_thread.start() + + def notify(self, conn): + while True: + if wait_ready([conn.connection], [], [], 5) != ([], [], []): + conn.connection.poll() + while conn.connection.notifies: + notify = conn.connection.notifies.pop() + for func in self.subscriber: + func(pid=notify.pid, channel=notify.channel, payload=notify.payload) + + def subscribe(self, func): + self.subscriber.append(func) + + def unsubscribe(self, func): + self.subscriber.remove(func) diff --git a/octoprint_filamentmanager/manager.py b/octoprint_filamentmanager/manager.py deleted file mode 100644 index d171009..0000000 --- a/octoprint_filamentmanager/manager.py +++ /dev/null @@ -1,238 +0,0 @@ -# coding=utf-8 - -__author__ = "Sven Lohrmann " -__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" -__copyright__ = "Copyright (C) 2017 Sven Lohrmann - Released under terms of the AGPLv3 License" - -import sqlite3 -import io -import os -from backports import csv -from multiprocessing import Lock - - -class FilamentManager(object): - - def __init__(self, database): - self._db_lock = Lock() - self._db = sqlite3.connect(database, check_same_thread=False) - self._db.execute("PRAGMA foreign_keys = ON") - - def init_database(self): - scheme = [] - scheme.append( - """ CREATE TABLE IF NOT EXISTS profiles ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - vendor TEXT NOT NULL DEFAULT "", - material TEXT NOT NULL DEFAULT "", - density REAL NOT NULL DEFAULT 0, - diameter REAL NOT NULL DEFAULT 0); - - CREATE TABLE IF NOT EXISTS spools ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - profile_id INTEGER NOT NULL, - name TEXT NOT NULL DEFAULT "", - cost REAL NOT NULL DEFAULT 0, - weight REAL NOT NULL DEFAULT 0, - used REAL NOT NULL DEFAULT 0, - temp_offset INTEGER NOT NULL DEFAULT 0, - FOREIGN KEY (profile_id) REFERENCES profiles(id) ON DELETE RESTRICT); - - CREATE TABLE IF NOT EXISTS selections ( - tool INTEGER PRIMARY KEY ON CONFLICT REPLACE, - spool_id INTEGER, - FOREIGN KEY (spool_id) REFERENCES spools(id) ON DELETE CASCADE); - - CREATE TABLE IF NOT EXISTS modifications ( - table_name TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE, - action TEXT NOT NULL, - changed_at TIMESTAMP DEFAULT (strftime('%s', 'now'))); """) - - for table in ["profiles", "spools"]: - for action in ["INSERT", "UPDATE", "DELETE"]: - scheme.append( - """ CREATE TRIGGER IF NOT EXISTS {table}_on{action} AFTER {action} ON {table} - BEGIN - INSERT INTO modifications (table_name, action) VALUES ('{table}','{action}'); - END; """.format(table=table, action=action)) - - self.execute_script("".join(scheme)) - - def execute_script(self, script): - with self._db_lock, self._db as db: - db.executescript(script) - - # profiles - - def get_all_profiles(self): - with self._db_lock, self._db as db: - cursor = db.execute(""" SELECT id, vendor, material, density, diameter - FROM profiles ORDER BY material COLLATE NOCASE, vendor COLLATE NOCASE """) - return self._cursor_to_dict(cursor) - - def get_profiles_modifications(self): - with self._db_lock, self._db as db: - cursor = db.execute("SELECT changed_at FROM modifications WHERE table_name = 'profiles'") - return self._cursor_to_dict(cursor, one=True) - - def get_profile(self, identifier): - with self._db_lock, self._db as db: - cursor = db.execute(""" SELECT id, vendor, material, density, diameter FROM profiles WHERE id = ? - ORDER BY material COLLATE NOCASE, vendor COLLATE NOCASE """, (identifier,)) - return self._cursor_to_dict(cursor, one=True) - - def create_profile(self, data): - with self._db_lock, self._db as db: - cursor = db.execute("INSERT INTO profiles (material, vendor, density, diameter) VALUES (?, ?, ?, ?)", - (data.get("material", ""), data.get("vendor", ""), data.get("density", 0), - data.get("diameter", 0))) - data["id"] = cursor.lastrowid - return data - - def update_profile(self, identifier, data): - with self._db_lock, self._db as db: - db.execute("UPDATE profiles SET material = ?, vendor = ?, density = ?, diameter = ? WHERE id = ?", - (data.get("material"), data.get("vendor"), data.get("density"), data.get("diameter"), - identifier)) - return data - - def delete_profile(self, identifier): - with self._db_lock, self._db as db: - db.execute("DELETE FROM profiles WHERE id = ?", (identifier,)) - - # spools - - def _build_spool_dict(self, row, column_names): - spool = dict(profile=dict()) - for i, value in enumerate(row): - if i < 6: - spool[column_names[i][0]] = value - else: - spool["profile"][column_names[i][0]] = value - return spool - - def get_all_spools(self): - with self._db_lock, self._db as db: - cursor = db.execute(""" SELECT s.id, s.name, s.cost, s.weight, s.used, s.temp_offset, - p.id, p.vendor, p.material, p.density, p.diameter - FROM spools AS s, profiles AS p WHERE s.profile_id = p.id - ORDER BY s.name COLLATE NOCASE """) - return [self._build_spool_dict(row, cursor.description) for row in cursor.fetchall()] - - def get_spools_modifications(self): - with self._db_lock, self._db as db: - cursor = db.execute("SELECT changed_at FROM modifications WHERE table_name = 'spools'") - return self._cursor_to_dict(cursor, one=True) - - def get_spool(self, identifier): - with self._db_lock, self._db as db: - cursor = db.execute(""" SELECT s.id, s.name, s.cost, s.weight, s.used, s.temp_offset, - p.id, p.vendor, p.material, p.density, p.diameter - FROM spools AS s, profiles AS p WHERE s.profile_id = p.id - AND s.id = ? """, (identifier,)) - result = cursor.fetchone() - return self._build_spool_dict(result, cursor.description) if result is not None else dict() - - def create_spool(self, data): - with self._db_lock, self._db as db: - sql = "INSERT INTO spools (name, profile_id, cost, weight, used, temp_offset) VALUES (?, ?, ?, ?, ?, ?)" - cursor = db.execute(sql, (data.get("name", ""), data["profile"].get("id", 0), data.get("cost", 0), - data.get("weight", 0), data.get("used", 0), data.get("temp_offset", 0))) - data["id"] = cursor.lastrowid - return data - - def update_spool(self, identifier, data): - with self._db_lock, self._db as db: - db.execute(""" UPDATE spools SET name = ?, profile_id = ?, cost = ?, weight = ?, used = ?, - temp_offset = ? WHERE id = ? """, (data.get("name"), data["profile"].get("id"), - data.get("cost"), data.get("weight"), data.get("used"), data.get("temp_offset"), identifier)) - return data - - def delete_spool(self, identifier): - with self._db_lock, self._db as db: - db.execute("DELETE FROM spools WHERE id = ?", (identifier,)) - - # selections - - def _build_selection_dict(self, row, column_names): - selection = dict(spool=dict(profile=dict())) - for i, value in enumerate(row): - if i < 1: - selection[column_names[i][0]] = value - if i < 7: - selection["spool"][column_names[i][0]] = value - else: - selection["spool"]["profile"][column_names[i][0]] = value - return selection - - def get_all_selections(self): - with self._db_lock, self._db as db: - cursor = db.execute(""" SELECT t.tool, s.id, s.name, s.cost, s.weight, s.used, s.temp_offset, - p.id, p.vendor, p.material, p.density, p.diameter - FROM selections AS t, spools AS s, profiles AS p - WHERE t.spool_id = s.id AND s.profile_id = p.id ORDER BY tool """) - return [self._build_selection_dict(row, cursor.description) for row in cursor.fetchall()] - - def get_selection(self, identifier): - with self._db_lock, self._db as db: - cursor = db.execute(""" SELECT t.tool, s.id, s.name, s.cost, s.weight, s.used, s.temp_offset, - p.id, p.vendor, p.material, p.density, p.diameter - FROM selections AS t, spools AS s, profiles AS p - WHERE t.spool_id = s.id AND s.profile_id = p.id - AND t.tool = ? """, (identifier,)) - result = cursor.fetchone() - if result is not None: - return self._build_selection_dict(result, cursor.description) - else: - return dict(tool=identifier, spool=None) - - def update_selection(self, identifier, data): - with self._db_lock, self._db as db: - db.execute("INSERT INTO selections (tool, spool_id) VALUES (?, ?)", - (identifier, data["spool"]["id"])) - return self.get_selection(identifier) - - def export_data(self, dirpath): - tablenames = ["profiles", "spools"] - for table in tablenames: - self._export_to_csv(dirpath, table) - - def import_data(self, dirpath): - tablenames = ["profiles", "spools"] - for table in tablenames: - self._import_from_csv(dirpath, table) - - # helper - - def _import_from_csv(self, dirpath, tablename): - filepath = os.path.join(dirpath, tablename + ".csv") - with io.open(filepath, mode="r", encoding="utf-8") as csv_file: - csv_reader = csv.reader(csv_file) - header = next(csv_reader) - columns = ",".join(header) - placeholder = ",".join(["?"] * len(header)) - with self._db_lock, self._db as db: - # INSERT OR IGNORE doesn't call the insert TRIGGER ¯\_(ツ)_/¯ - # forcing a data update on client side is neccessary after import - db.executemany("INSERT OR IGNORE INTO {table} ({columns}) VALUES ({values});" - .format(table=tablename, columns=columns, values=placeholder), csv_reader) - - def _export_to_csv(self, dirpath, tablename): - with self._db_lock, self._db as db: - cursor = db.execute("SELECT * FROM " + tablename) - filepath = os.path.join(dirpath, tablename + ".csv") - with io.open(filepath, mode="w", encoding="utf-8") as csv_file: - csv_writer = csv.writer(csv_file) - csv_writer.writerow([i[0] for i in cursor.description]) - csv_writer.writerows(cursor) - - def _cursor_to_dict(self, cursor, one=False): - if one: - result = cursor.fetchone() - if result is not None: - return dict((cursor.description[i][0], value) for i, value in enumerate(result)) - else: - return dict() - else: - return [dict((cursor.description[i][0], value) for i, value in enumerate(row)) - for row in cursor.fetchall()] diff --git a/octoprint_filamentmanager/odometer.py b/octoprint_filamentmanager/odometer.py index 06d8bd8..3c60f23 100644 --- a/octoprint_filamentmanager/odometer.py +++ b/octoprint_filamentmanager/odometer.py @@ -1,10 +1,12 @@ # coding=utf-8 -import re +from __future__ import absolute_import __author__ = "Sven Lohrmann based on work by Gina Häußge " __license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" __copyright__ = "Copyright (C) 2017 Sven Lohrmann - Released under terms of the AGPLv3 License" +import re + class FilamentOdometer(object): @@ -29,6 +31,9 @@ def reset_extruded_length(self): self.totalExtrusion = [0.0] * tools def parse(self, gcode, cmd): + if gcode is None: + return + if gcode == "G1" or gcode == "G0": # move e = self._get_float(cmd, self.regexE) if e is not None: @@ -67,10 +72,10 @@ def parse(self, gcode, cmd): self.totalExtrusion.append(0.0) self.maxExtrusion.append(0.0) - def set_g90_extruder(self, flag): + def set_g90_extruder(self, flag=True): self.g90_extruder = flag - def get_values(self): + def get_extrusion(self): return self.maxExtrusion def get_current_tool(self): diff --git a/octoprint_filamentmanager/static/README.md b/octoprint_filamentmanager/static/README.md new file mode 100644 index 0000000..cde50d3 --- /dev/null +++ b/octoprint_filamentmanager/static/README.md @@ -0,0 +1,3 @@ +# All JS and CSS files are generated! + +Read the [DEVELOPMENT.md](../../DEVELOPMENT.md) for more details about building these files. diff --git a/octoprint_filamentmanager/static/css/filamentmanager.min.css b/octoprint_filamentmanager/static/css/filamentmanager.min.css new file mode 100644 index 0000000..70f1542 --- /dev/null +++ b/octoprint_filamentmanager/static/css/filamentmanager.min.css @@ -0,0 +1 @@ +@font-face{font-family:icomoon;src:url(../fonts/icomoon.eot?2tngg);src:url(../fonts/icomoon.eot?2tngg#iefix) format('embedded-opentype'),url(../fonts/icomoon.ttf?2tngg) format('truetype'),url(../fonts/icomoon.woff?2tngg) format('woff'),url(../fonts/icomoon.svg?2tngg#icomoon) format('svg');font-weight:400;font-style:normal}.fa-reel{font-family:icomoon!important}.fa-reel:before{content:"\e900"}.settings_plugin_filamentmanager_spools_header{margin-bottom:10px}.settings_plugin_filamentmanager_spools_header input[type=number]{width:14px;height:11px;font-size:12px;text-align:center}table td.settings_plugin_filamentmanager_spools_name,table th.settings_plugin_filamentmanager_spools_name{width:20%;text-align:left}table td.settings_plugin_filamentmanager_spools_material,table th.settings_plugin_filamentmanager_spools_material{width:15%;text-align:left}table td.settings_plugin_filamentmanager_spools_vendor,table th.settings_plugin_filamentmanager_spools_vendor{width:20%;text-align:left}table td.settings_plugin_filamentmanager_spools_weight,table th.settings_plugin_filamentmanager_spools_weight{width:15%;text-align:right}table td.settings_plugin_filamentmanager_spools_remaining,table th.settings_plugin_filamentmanager_spools_remaining{width:15%;text-align:right}table td.settings_plugin_filamentmanager_spools_used,table th.settings_plugin_filamentmanager_spools_used{width:15%;text-align:right}table td.settings_plugin_filamentmanager_spools_action,table th.settings_plugin_filamentmanager_spools_action{width:70px;text-align:center}table td.settings_plugin_filamentmanager_spools_action a{color:#000;text-decoration:none}.settings_plugin_filamentmanager_profiledialog_select{margin-bottom:6px}.settings_plugin_filamentmanager_profiledialog_select button.btn-small{margin-bottom:10px}#sidebar_plugin_filamentmanager_wrapper .accordion-heading i.fa-spinner{float:right;margin:10px 15px}#sidebar_plugin_filamentmanager .accordion-inner{padding-bottom:0} \ No newline at end of file diff --git a/octoprint_filamentmanager/static/js/client.js b/octoprint_filamentmanager/static/js/client.js deleted file mode 100644 index 84febfd..0000000 --- a/octoprint_filamentmanager/static/js/client.js +++ /dev/null @@ -1,101 +0,0 @@ -(function (global, factory) { - if (typeof define === "function" && define.amd) { - define(["OctoPrintClient", "jquery"], factory); - } else { - factory(global.OctoPrintClient, global.$); - } -})(this, function(OctoPrintClient, $) { - "use strict"; - - var pluginUrl = "plugin/filamentmanager"; - - var profileUrl = function(profile) { - var url = pluginUrl + "/profiles"; - return (profile === undefined) ? url : url + "/" + profile; - }; - - var spoolUrl = function(spool) { - var url = pluginUrl + "/spools"; - return (spool === undefined) ? url : url + "/" + spool; - }; - - var selectionUrl = function(selection) { - var url = pluginUrl + "/selections"; - return (selection === undefined) ? url : url + "/" + selection; - } - - var OctoPrintFilamentManagerClient = function(base) { - this.base = base; - }; - - OctoPrintFilamentManagerClient.prototype.listProfiles = function (force, opts) { - force = force || false; - var query = {}; - if (force) { - query.force = force || false; - } - return this.base.getWithQuery(profileUrl(), query, opts); - }; - - OctoPrintFilamentManagerClient.prototype.getProfile = function (id, opts) { - return this.base.get(profileUrl(id), opts); - }; - - OctoPrintFilamentManagerClient.prototype.addProfile = function (profile, opts) { - profile = profile || {}; - var data = {profile: profile}; - return this.base.postJson(profileUrl(), data, opts); - }; - - OctoPrintFilamentManagerClient.prototype.updateProfile = function (id, profile, opts) { - profile = profile || {}; - var data = {profile: profile}; - return this.base.patchJson(profileUrl(id), data, opts); - }; - - OctoPrintFilamentManagerClient.prototype.deleteProfile = function (id, opts) { - return this.base.delete(profileUrl(id), opts); - }; - - OctoPrintFilamentManagerClient.prototype.listSpools = function (force, opts) { - force = force || false; - var query = {}; - if (force) { - query.force = force || false; - } - return this.base.getWithQuery(spoolUrl(), query, opts); - }; - - OctoPrintFilamentManagerClient.prototype.getSpool = function (id, opts) { - return this.base.get(spoolUrl(id), opts); - }; - - OctoPrintFilamentManagerClient.prototype.addSpool = function (spool, opts) { - spool = spool || {}; - var data = {spool: spool}; - return this.base.postJson(spoolUrl(), data, opts); - }; - - OctoPrintFilamentManagerClient.prototype.updateSpool = function (id, spool, opts) { - spool = spool || {}; - var data = {spool: spool}; - return this.base.patchJson(spoolUrl(id), data, opts); - }; - - OctoPrintFilamentManagerClient.prototype.deleteSpool = function (id, opts) { - return this.base.delete(spoolUrl(id), opts); - }; - - OctoPrintFilamentManagerClient.prototype.listSelections = function (opts) { - return this.base.get(selectionUrl(), opts); - }; - - OctoPrintFilamentManagerClient.prototype.updateSelection = function (id, selection, opts) { - selection = selection || {}; - var data = {selection: selection}; - return this.base.patchJson(selectionUrl(id), data, opts); - }; - - OctoPrintClient.registerPluginComponent("filamentmanager", OctoPrintFilamentManagerClient); - return OctoPrintFilamentManagerClient; -}); diff --git a/octoprint_filamentmanager/static/js/filamentmanager.bundled.js b/octoprint_filamentmanager/static/js/filamentmanager.bundled.js new file mode 100644 index 0000000..fbb5107 --- /dev/null +++ b/octoprint_filamentmanager/static/js/filamentmanager.bundled.js @@ -0,0 +1,1018 @@ +/* + * View model for OctoPrint-FilamentManager + * + * Author: Sven Lohrmann + * License: AGPLv3 + */ + +var FilamentManager = function FilamentManager() { + this.core.client.call(this); + return this.core.bridge.call(this); +}; + +FilamentManager.prototype = { + constructor: FilamentManager, + core: {}, + viewModels: {}, + selectedSpools: undefined +}; +var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); + +function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } + +var Utils = function () { + function Utils() { + _classCallCheck(this, Utils); + } + + _createClass(Utils, null, [{ + key: "validInt", + // eslint-disable-line no-unused-vars + value: function validInt(value, def) { + var v = Number.parseInt(value, 10); + return Number.isNaN(v) ? def : v; + } + }, { + key: "validFloat", + value: function validFloat(value, def) { + var v = Number.parseFloat(value); + return Number.isNaN(v) ? def : v; + } + }, { + key: "runRequestChain", + value: function runRequestChain(requests) { + var index = 0; + + var next = function callNextRequest() { + if (index < requests.length) { + // Do the next, increment the call index + requests[index]().done(function () { + index += 1; + next(); + }); + } + }; + + next(); // Start chain + } + }, { + key: "extractToolIDFromName", + value: function extractToolIDFromName(name) { + var result = /(\d+)/.exec(name); + return result === null ? 0 : result[1]; + } + }]); + + return Utils; +}(); +/* global FilamentManager _ */ + +FilamentManager.prototype.core.bridge = function pluginBridge() { + var self = this; + + self.core.bridge = { + allViewModels: {}, + + REQUIRED_VIEWMODELS: ['settingsViewModel', 'printerStateViewModel', 'loginStateViewModel', 'temperatureViewModel'], + + BINDINGS: ['#settings_plugin_filamentmanager', '#settings_plugin_filamentmanager_profiledialog', '#settings_plugin_filamentmanager_spooldialog', '#settings_plugin_filamentmanager_configurationdialog', '#sidebar_plugin_filamentmanager_wrapper', '#plugin_filamentmanager_confirmationdialog'], + + viewModel: function FilamentManagerViewModel(viewModels) { + self.core.bridge.allViewModels = _.object(self.core.bridge.REQUIRED_VIEWMODELS, viewModels); + self.core.callbacks.call(self); + + self.viewModels.profiles.call(self); + self.viewModels.spools.call(self); + self.viewModels.selections.call(self); + self.viewModels.config.call(self); + self.viewModels.import.call(self); + self.viewModels.confirmation.call(self); + + self.viewModels.profiles.updateCallbacks.push(self.viewModels.spools.requestSpools); + self.viewModels.profiles.updateCallbacks.push(self.viewModels.selections.requestSelectedSpools); + self.viewModels.spools.updateCallbacks.push(self.viewModels.selections.requestSelectedSpools); + self.viewModels.import.afterImportCallbacks.push(self.viewModels.profiles.requestProfiles); + self.viewModels.import.afterImportCallbacks.push(self.viewModels.spools.requestSpools); + self.viewModels.import.afterImportCallbacks.push(self.viewModels.selections.requestSelectedSpools); + + self.viewModels.warning.call(self); + self.selectedSpools = self.viewModels.selections.selectedSpools; // for backwards compatibility + return self; + } + }; + + return self.core.bridge; +}; +/* global FilamentManager Utils */ + +FilamentManager.prototype.core.callbacks = function octoprintCallbacks() { + var self = this; + + self.onStartup = function onStartupCallback() { + self.viewModels.warning.replaceFilamentView(); + self.viewModels.confirmation.replacePrintStart(); + self.viewModels.confirmation.replacePrintResume(); + }; + + self.onBeforeBinding = function onBeforeBindingCallback() { + self.viewModels.config.loadData(); + self.viewModels.selections.setArraySize(); + self.viewModels.selections.setSubscriptions(); + self.viewModels.warning.setSubscriptions(); + }; + + self.onStartupComplete = function onStartupCompleteCallback() { + var requests = [self.viewModels.profiles.requestProfiles, self.viewModels.spools.requestSpools, self.viewModels.selections.requestSelectedSpools]; + + // We chain them because, e.g. selections depends on spools + Utils.runRequestChain(requests); + }; + + self.onDataUpdaterPluginMessage = function onDataUpdaterPluginMessageCallback(plugin, data) { + if (plugin !== 'filamentmanager') return; + + var messageType = data.type; + // const messageData = data.data; + // TODO needs improvement + if (messageType === 'data_changed') { + self.viewModels.profiles.requestProfiles(); + self.viewModels.spools.requestSpools(); + self.viewModels.selections.requestSelectedSpools(); + } + }; +}; +/* global FilamentManager OctoPrint */ + +FilamentManager.prototype.core.client = function apiClient() { + var self = this.core.client; + + var pluginUrl = 'plugin/filamentmanager'; + + var profileUrl = function apiProfileNamespace(profile) { + var url = pluginUrl + '/profiles'; + return profile === undefined ? url : url + '/' + profile; + }; + + var spoolUrl = function apiSpoolNamespace(spool) { + var url = pluginUrl + '/spools'; + return spool === undefined ? url : url + '/' + spool; + }; + + var selectionUrl = function apiSelectionNamespace(selection) { + var url = pluginUrl + '/selections'; + return selection === undefined ? url : url + '/' + selection; + }; + + self.profile = { + list: function list() { + var force = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : false; + var opts = arguments[1]; + + var query = force ? { force: force } : {}; + return OctoPrint.getWithQuery(profileUrl(), query, opts); + }, + get: function get(id, opts) { + return OctoPrint.get(profileUrl(id), opts); + }, + add: function add(profile, opts) { + var data = { profile: profile }; + return OctoPrint.postJson(profileUrl(), data, opts); + }, + update: function update(id, profile, opts) { + var data = { profile: profile }; + return OctoPrint.patchJson(profileUrl(id), data, opts); + }, + delete: function _delete(id, opts) { + return OctoPrint.delete(profileUrl(id), opts); + } + }; + + self.spool = { + list: function list() { + var force = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : false; + var opts = arguments[1]; + + var query = force ? { force: force } : {}; + return OctoPrint.getWithQuery(spoolUrl(), query, opts); + }, + get: function get(id, opts) { + return OctoPrint.get(spoolUrl(id), opts); + }, + add: function add(spool, opts) { + var data = { spool: spool }; + return OctoPrint.postJson(spoolUrl(), data, opts); + }, + update: function update(id, spool, opts) { + var data = { spool: spool }; + return OctoPrint.patchJson(spoolUrl(id), data, opts); + }, + delete: function _delete(id, opts) { + return OctoPrint.delete(spoolUrl(id), opts); + } + }; + + self.selection = { + list: function list(opts) { + return OctoPrint.get(selectionUrl(), opts); + }, + update: function update(id, selection, opts) { + var data = { selection: selection }; + return OctoPrint.patchJson(selectionUrl(id), data, opts); + } + }; +}; +/* global FilamentManager ko $ */ + +FilamentManager.prototype.viewModels.config = function configurationViewModel() { + var self = this.viewModels.config; + var settingsViewModel = this.core.bridge.allViewModels.settingsViewModel; + + + var dialog = $('#settings_plugin_filamentmanager_configurationdialog'); + + self.showDialog = function showConfigurationDialog() { + self.loadData(); + dialog.modal('show'); + }; + + self.hideDialog = function hideConfigurationDialog() { + dialog.modal('hide'); + }; + + self.config = ko.mapping.fromJS({}); + + self.saveData = function savePluginConfiguration(viewModel, event) { + var target = $(event.target); + target.prepend(' '); + + var data = { + plugins: { + filamentmanager: ko.mapping.toJS(self.config) + } + }; + + settingsViewModel.saveData(data, { + success: function success() { + self.hideDialog(); + }, + complete: function complete() { + $('i.fa-spinner', target).remove(); + }, + + sending: true + }); + }; + + self.loadData = function mapPluginConfigurationToObservables() { + var pluginSettings = settingsViewModel.settings.plugins.filamentmanager; + ko.mapping.fromJS(ko.toJS(pluginSettings), self.config); + }; +}; +/* global FilamentManager gettext $ ko Utils */ + +FilamentManager.prototype.viewModels.confirmation = function spoolSelectionConfirmationViewModel() { + var self = this.viewModels.confirmation; + var _core$bridge$allViewM = this.core.bridge.allViewModels, + printerStateViewModel = _core$bridge$allViewM.printerStateViewModel, + settingsViewModel = _core$bridge$allViewM.settingsViewModel; + var selections = this.viewModels.selections; + + + var dialog = $('#plugin_filamentmanager_confirmationdialog'); + var button = $('#plugin_filamentmanager_confirmationdialog_print'); + + self.selections = ko.observableArray([]); + + self.print = function startResumePrintDummy() {}; + + self.checkSelection = function checkIfSpoolSelectionsMatchesSelectedSpoolsInSidebar() { + var match = true; + self.selections().forEach(function (value) { + if (selections.tools()[value.tool]() !== value.spool) match = false; + }); + button.attr('disabled', !match); + }; + + var showDialog = function showSpoolConfirmationDialog() { + var s = []; + printerStateViewModel.filament().forEach(function (value) { + var toolID = Utils.extractToolIDFromName(value.name()); + s.push({ spool: undefined, tool: toolID }); + }); + self.selections(s); + button.attr('disabled', true); + dialog.modal('show'); + }; + + printerStateViewModel.fmPrint = function confirmSpoolSelectionBeforeStartPrint() { + if (settingsViewModel.settings.plugins.filamentmanager.confirmSpoolSelection()) { + showDialog(); + button.html(gettext('Start Print')); + self.print = function startPrint() { + dialog.modal('hide'); + printerStateViewModel.print(); + }; + } else { + printerStateViewModel.print(); + } + }; + + printerStateViewModel.fmResume = function confirmSpoolSelectionBeforeResumePrint() { + if (settingsViewModel.settings.plugins.filamentmanager.confirmSpoolSelection()) { + showDialog(); + button.html(gettext('Resume Print')); + self.print = function resumePrint() { + dialog.modal('hide'); + printerStateViewModel.onlyResume(); + }; + } else { + printerStateViewModel.onlyResume(); + } + }; + + self.replacePrintStart = function replacePrintStartButtonBehavior() { + // Modifying print button action to invoke 'fmPrint' + var element = $('#job_print'); + var dataBind = element.attr('data-bind'); + dataBind = dataBind.replace(/click:(.*?)(?=,|$)/, 'click: fmPrint'); + element.attr('data-bind', dataBind); + }; + + self.replacePrintResume = function replacePrintResumeButtonBehavior() { + // Modifying resume button action to invoke 'fmResume' + var element = $('#job_pause'); + var dataBind = element.attr('data-bind'); + dataBind = dataBind.replace(/click:(.*?)(?=,|$)/, 'click: function() { isPaused() ? fmResume() : onlyPause(); }'); + element.attr('data-bind', dataBind); + }; +}; +/* global FilamentManager ko $ PNotify gettext */ + +FilamentManager.prototype.viewModels.import = function importDataViewModel() { + var self = this.viewModels.import; + + var importButton = $('#settings_plugin_filamentmanager_import_button'); + var importElement = $('#settings_plugin_filamentmanager_import'); + + self.importFilename = ko.observable(); + self.importInProgress = ko.observable(false); + + self.afterImportCallbacks = []; + + self.invalidArchive = ko.pureComputed(function () { + var name = self.importFilename(); + return name !== undefined && !name.toLocaleLowerCase().endsWith('.zip'); + }); + + self.enableImport = ko.pureComputed(function () { + var name = self.importFilename(); + return name !== undefined && name.trim() !== '' && !self.invalidArchive(); + }); + + importElement.fileupload({ + dataType: 'json', + maxNumberOfFiles: 1, + autoUpload: false, + add: function add(e, data) { + if (data.files.length === 0) return; + + self.importFilename(data.files[0].name); + + importButton.unbind('click'); + importButton.bind('click', function (event) { + self.importInProgress(true); + event.preventDefault(); + data.submit(); + }); + }, + done: function done() { + self.afterImportCallbacks.forEach(function (callback) { + callback(); + }); + }, + fail: function fail() { + new PNotify({ // eslint-disable-line no-new + title: gettext('Data import failed'), + text: gettext('Something went wrong, please consult the logs.'), + type: 'error', + hide: false + }); + }, + always: function always() { + importButton.unbind('click'); + self.importFilename(undefined); + self.importInProgress(false); + } + }); +}; +/* global FilamentManager ko gettext showConfirmationDialog PNotify $ Utils */ + +FilamentManager.prototype.viewModels.profiles = function profilesViewModel() { + var self = this.viewModels.profiles; + var api = this.core.client; + + self.allProfiles = ko.observableArray([]); + + self.cleanProfile = function getDefaultValuesForNewProfile() { + return { + id: undefined, + material: '', + vendor: '', + density: 1.25, + diameter: 1.75 + }; + }; + + self.loadedProfile = { + id: ko.observable(), + vendor: ko.observable(), + material: ko.observable(), + density: ko.observable(), + diameter: ko.observable(), + isNew: ko.observable(true) + }; + + self.vendorInvalid = ko.pureComputed(function () { + return !self.loadedProfile.vendor(); + }); + self.materialInvalid = ko.pureComputed(function () { + return !self.loadedProfile.material(); + }); + + var loadProfile = function loadSelectedProfile() { + if (self.loadedProfile.id() === undefined) { + if (!self.loadedProfile.isNew()) { + // selected 'new profile' in options menu, but no profile created yet + self.fromProfileData(); + } + } else { + // find profile data + var data = ko.utils.arrayFirst(self.allProfiles(), function (item) { + return item.id === self.loadedProfile.id(); + }); + + if (!data) data = self.cleanProfile(); + + // populate data + self.fromProfileData(data); + } + }; + + self.loadedProfile.id.subscribe(loadProfile); + + self.fromProfileData = function setLoadedProfileFromJSObject() { + var data = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : self.cleanProfile(); + + self.loadedProfile.isNew(data.id === undefined); + self.loadedProfile.id(data.id); + self.loadedProfile.vendor(data.vendor); + self.loadedProfile.material(data.material); + self.loadedProfile.density(data.density); + self.loadedProfile.diameter(data.diameter); + }; + + self.toProfileData = function getLoadedProfileAsJSObject() { + var defaultProfile = self.cleanProfile(); + + return { + id: self.loadedProfile.id(), + vendor: self.loadedProfile.vendor(), + material: self.loadedProfile.material(), + density: Utils.validFloat(self.loadedProfile.density(), defaultProfile.density), + diameter: Utils.validFloat(self.loadedProfile.diameter(), defaultProfile.diameter) + }; + }; + + var dialog = $('#settings_plugin_filamentmanager_profiledialog'); + + self.showProfileDialog = function showProfileDialog() { + self.fromProfileData(); + dialog.modal('show'); + }; + + self.requestInProgress = ko.observable(false); + + self.processProfiles = function processRequestedProfiles(data) { + self.allProfiles(data.profiles); + }; + + self.requestProfiles = function requestAllProfilesFromBackend() { + var force = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : false; + + self.requestInProgress(true); + return api.profile.list(force).done(function (response) { + self.processProfiles(response); + }).always(function () { + self.requestInProgress(false); + }); + }; + + self.saveProfile = function saveProfileToBackend() { + var data = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : self.toProfileData(); + + return self.loadedProfile.isNew() ? self.addProfile(data) : self.updateProfile(data); + }; + + self.addProfile = function addProfileToBackend() { + var data = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : self.toProfileData(); + + self.requestInProgress(true); + api.profile.add(data).done(function (response) { + var id = response.profile.id; + + self.requestProfiles().done(function () { + self.loadedProfile.id(id); + }); + }).fail(function () { + new PNotify({ // eslint-disable-line no-new + title: gettext('Could not add profile'), + text: gettext('There was an unexpected error while saving the filament profile, please consult the logs.'), + type: 'error', + hide: false + }); + self.requestInProgress(false); + }); + }; + + self.updateCallbacks = []; + + self.updateProfile = function updateProfileInBackend() { + var data = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : self.toProfileData(); + + self.requestInProgress(true); + api.profile.update(data.id, data).done(function () { + self.requestProfiles(); + self.updateCallbacks.forEach(function (callback) { + callback(); + }); + }).fail(function () { + new PNotify({ // eslint-disable-line no-new + title: gettext('Could not update profile'), + text: gettext('There was an unexpected error while updating the filament profile, please consult the logs.'), + type: 'error', + hide: false + }); + self.requestInProgress(false); + }); + }; + + self.removeProfile = function removeProfileFromBackend(data) { + var perform = function performProfileRemoval() { + api.profile.delete(data.id).done(function () { + self.requestProfiles(); + }).fail(function () { + new PNotify({ // eslint-disable-line no-new + title: gettext('Could not delete profile'), + text: gettext('There was an unexpected error while removing the filament profile, please consult the logs.'), + type: 'error', + hide: false + }); + self.requestInProgress(false); + }); + }; + + showConfirmationDialog({ + title: gettext('Delete profile?'), + message: gettext('You are about to delete the filament profile ' + data.material + ' (' + data.vendor + '). Please note that it is not possible to delete profiles with associated spools.'), + proceed: gettext('Delete'), + onproceed: perform + }); + }; +}; +/* global FilamentManager ko gettext PNotify */ + +FilamentManager.prototype.viewModels.selections = function selectedSpoolsViewModel() { + var self = this.viewModels.selections; + var api = this.core.client; + var settingsViewModel = this.core.bridge.allViewModels.settingsViewModel; + + + self.selectedSpools = ko.observableArray([]); + + // selected spool id for each tool + self.tools = ko.observableArray([]); + // set to false if querying selections to prevent triggering the change event again when setting selected spools + self.enableSpoolUpdate = false; + + self.setArraySize = function setArraySizeToNumberOfTools() { + var currentProfileData = settingsViewModel.printerProfiles.currentProfileData(); + var numExtruders = currentProfileData ? currentProfileData.extruder.count() : 0; + + if (self.tools().length === numExtruders) return; + + if (self.tools().length < numExtruders) { + // number of extruders has increased + for (var i = self.tools().length; i < numExtruders; i += 1) { + self.selectedSpools().push(undefined); + self.tools().push(ko.observable(undefined)); + } + } else { + // number of extruders has decreased + for (var _i = numExtruders; _i < self.tools().length; _i += 1) { + self.tools().pop(); + self.selectedSpools().pop(); + } + } + + // notify observers + self.tools.valueHasMutated(); + self.selectedSpools.valueHasMutated(); + }; + + self.setSubscriptions = function subscribeToProfileDataObservable() { + settingsViewModel.printerProfiles.currentProfileData.subscribe(self.setArraySize); + }; + + self.requestInProgress = ko.observable(false); + + self.setSelectedSpools = function setSelectedSpoolsReceivedFromBackend(data) { + self.enableSpoolUpdate = false; + data.selections.forEach(function (selection) { + self.updateSelectedSpoolData(selection); + }); + self.enableSpoolUpdate = true; + }; + + self.requestSelectedSpools = function requestSelectedSpoolsFromBackend() { + self.requestInProgress(true); + return api.selection.list().done(function (data) { + self.setSelectedSpools(data); + }).always(function () { + self.requestInProgress(false); + }); + }; + + self.updateSelectedSpool = function updateSelectedSpoolInBackend(tool) { + var id = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : null; + + if (!self.enableSpoolUpdate) return; + + var data = { tool: tool, spool: { id: id } }; + + self.requestInProgress(true); + api.selection.update(tool, data).done(function (response) { + self.updateSelectedSpoolData(response.selection); + }).fail(function () { + new PNotify({ // eslint-disable-line no-new + title: gettext('Could not select spool'), + text: gettext('There was an unexpected error while selecting the spool, please consult the logs.'), + type: 'error', + hide: false + }); + }).always(function () { + self.requestInProgress(false); + }); + }; + + self.updateSelectedSpoolData = function updateSelectedSpoolData(data) { + if (data.tool < self.tools().length) { + self.tools()[data.tool](data.spool !== null ? data.spool.id : undefined); + self.selectedSpools()[data.tool] = data.spool !== null ? data.spool : undefined; + self.selectedSpools.valueHasMutated(); // notifies observers + } + }; +}; +/* global FilamentManager ItemListHelper ko Utils $ PNotify gettext showConfirmationDialog */ + +FilamentManager.prototype.viewModels.spools = function spoolsViewModel() { + var self = this.viewModels.spools; + var api = this.core.client; + + var profilesViewModel = this.viewModels.profiles; + + self.allSpools = new ItemListHelper('filamentSpools', { + name: function name(a, b) { + // sorts ascending + if (a.name.toLocaleLowerCase() < b.name.toLocaleLowerCase()) return -1; + if (a.name.toLocaleLowerCase() > b.name.toLocaleLowerCase()) return 1; + return 0; + }, + material: function material(a, b) { + // sorts ascending + if (a.profile.material.toLocaleLowerCase() < b.profile.material.toLocaleLowerCase()) return -1; + if (a.profile.material.toLocaleLowerCase() > b.profile.material.toLocaleLowerCase()) return 1; + return 0; + }, + vendor: function vendor(a, b) { + // sorts ascending + if (a.profile.vendor.toLocaleLowerCase() < b.rofile.vendor.toLocaleLowerCase()) return -1; + if (a.profile.vendor.toLocaleLowerCase() > b.profile.vendor.toLocaleLowerCase()) return 1; + return 0; + }, + remaining: function remaining(a, b) { + // sorts descending + var ra = parseFloat(a.weight) - parseFloat(a.used); + var rb = parseFloat(b.weight) - parseFloat(b.used); + if (ra > rb) return -1; + if (ra < rb) return 1; + return 0; + } + }, {}, 'name', [], [], 10); + + self.pageSize = ko.pureComputed({ + read: function read() { + return self.allSpools.pageSize(); + }, + write: function write(value) { + self.allSpools.pageSize(Utils.validInt(value, self.allSpools.pageSize())); + } + }); + + self.cleanSpool = function getDefaultValuesForNewSpool() { + return { + id: undefined, + name: '', + cost: 20, + weight: 1000, + used: 0, + temp_offset: 0, + profile: { + id: profilesViewModel.allProfiles().length > 0 ? profilesViewModel.allProfiles()[0].id : undefined + } + }; + }; + + self.loadedSpool = { + id: ko.observable(), + name: ko.observable(), + profile: ko.observable(), + cost: ko.observable(), + totalWeight: ko.observable(), + remaining: ko.observable(), + temp_offset: ko.observable(), + isNew: ko.observable(true) + }; + + self.nameInvalid = ko.pureComputed(function () { + return !self.loadedSpool.name(); + }); + + self.fromSpoolData = function setLoadedSpoolsFromJSObject() { + var data = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : self.cleanSpool(); + + self.loadedSpool.isNew(data.id === undefined); + self.loadedSpool.id(data.id); + self.loadedSpool.name(data.name); + self.loadedSpool.profile(data.profile.id); + self.loadedSpool.totalWeight(data.weight); + self.loadedSpool.cost(data.cost); + self.loadedSpool.remaining(data.weight - data.used); + self.loadedSpool.temp_offset(data.temp_offset); + }; + + self.toSpoolData = function getLoadedProfileAsJSObject() { + var defaultSpool = self.cleanSpool(); + var totalWeight = Utils.validFloat(self.loadedSpool.totalWeight(), defaultSpool.weight); + var remaining = Math.min(Utils.validFloat(self.loadedSpool.remaining(), defaultSpool.weight), totalWeight); + + return { + id: self.loadedSpool.id(), + name: self.loadedSpool.name(), + cost: Utils.validFloat(self.loadedSpool.cost(), defaultSpool.cost), + weight: totalWeight, + used: totalWeight - remaining, + temp_offset: self.loadedSpool.temp_offset(), + profile: { + id: self.loadedSpool.profile() + } + }; + }; + + var dialog = $('#settings_plugin_filamentmanager_spooldialog'); + + self.showSpoolDialog = function showSpoolDialog(data) { + self.fromSpoolData(data); + dialog.modal('show'); + }; + + self.hideSpoolDialog = function hideSpoolDialog() { + dialog.modal('hide'); + }; + + self.requestInProgress = ko.observable(false); + + self.processSpools = function processRequestedSpools(data) { + self.allSpools.updateItems(data.spools); + }; + + self.requestSpools = function requestAllSpoolsFromBackend(force) { + self.requestInProgress(true); + return api.spool.list(force).done(function (response) { + self.processSpools(response); + }).always(function () { + self.requestInProgress(false); + }); + }; + + self.saveSpool = function saveSpoolToBackend() { + var data = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : self.toSpoolData(); + + return self.loadedSpool.isNew() ? self.addSpool(data) : self.updateSpool(data); + }; + + self.addSpool = function addSpoolToBackend() { + var data = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : self.toSpoolData(); + + self.requestInProgress(true); + api.spool.add(data).done(function () { + self.hideSpoolDialog(); + self.requestSpools(); + }).fail(function () { + new PNotify({ // eslint-disable-line no-new + title: gettext('Could not add spool'), + text: gettext('There was an unexpected error while saving the filament spool, please consult the logs.'), + type: 'error', + hide: false + }); + self.requestInProgress(false); + }); + }; + + self.updateCallbacks = []; + + self.updateSpool = function updateSpoolInBackend() { + var data = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : self.toSpoolData(); + + self.requestInProgress(true); + api.spool.update(data.id, data).done(function () { + self.hideSpoolDialog(); + self.requestSpools(); + self.updateCallbacks.forEach(function (callback) { + callback(); + }); + }).fail(function () { + new PNotify({ // eslint-disable-line no-new + title: gettext('Could not update spool'), + text: gettext('There was an unexpected error while updating the filament spool, please consult the logs.'), + type: 'error', + hide: false + }); + self.requestInProgress(false); + }); + }; + + self.removeSpool = function removeSpoolFromBackend(data) { + var perform = function performSpoolRemoval() { + self.requestInProgress(true); + api.spool.delete(data.id).done(function () { + self.requestSpools(); + }).fail(function () { + new PNotify({ // eslint-disable-line no-new + title: gettext('Could not delete spool'), + text: gettext('There was an unexpected error while removing the filament spool, please consult the logs.'), + type: 'error', + hide: false + }); + self.requestInProgress(false); + }); + }; + + showConfirmationDialog({ + title: gettext('Delete spool?'), + message: gettext('You are about to delete the filament spool ' + data.name + ' - ' + data.profile.material + ' (' + data.profile.vendor + ').'), + proceed: gettext('Delete'), + onproceed: perform + }); + }; + + self.duplicateSpool = function duplicateAndAddSpoolToBackend(data) { + var newData = data; + newData.used = 0; + self.addSpool(newData); + }; +}; +/* global FilamentManager ko Node $ gettext PNotify Utils */ + +FilamentManager.prototype.viewModels.warning = function insufficientFilamentWarningViewModel() { + var self = this.viewModels.warning; + var _core$bridge$allViewM = this.core.bridge.allViewModels, + printerStateViewModel = _core$bridge$allViewM.printerStateViewModel, + settingsViewModel = _core$bridge$allViewM.settingsViewModel; + var selections = this.viewModels.selections; + + + printerStateViewModel.filamentWithWeight = ko.observableArray([]); + + printerStateViewModel.formatFilamentWithWeight = function formatFilamentWithWeightInSidebar(filament) { + if (!filament || !filament.length) return '-'; + + var result = (filament.length / 1000).toFixed(2) + 'm'; + + if (Object.prototype.hasOwnProperty.call(filament, 'weight') && filament.weight) { + result += ' / ' + filament.weight.toFixed(2) + 'g'; + } + + return result; + }; + + self.replaceFilamentView = function replaceFilamentViewInSidebar() { + $('#state').find('.accordion-inner').contents().each(function (index, item) { + if (item.nodeType === Node.COMMENT_NODE) { + if (item.nodeValue === ' ko foreach: filament ') { + item.nodeValue = ' ko foreach: [] '; // eslint-disable-line no-param-reassign + var element = '
'; + $(element).insertBefore(item); + return false; // exit loop + } + } + return true; + }); + }; + + var filename = void 0; + var waitForFilamentData = false; + + var warning = null; + + var updateFilament = function updateFilamentWeightAndCheckRemainingFilament() { + var calculateWeight = function calculateFilamentWeight(length, diameter, density) { + var radius = diameter / 2; + var volume = length * Math.PI * radius * radius / 1000; + return volume * density; + }; + + var showWarning = function showWarningIfRequiredFilamentExceedsRemaining(required, remaining) { + if (required < remaining) return false; + + if (warning) { + // fade out notification if one is still shown + warning.options.delay = 1000; + warning.queueRemove(); + } + + warning = new PNotify({ + title: gettext('Insufficient filament'), + text: gettext("The current print job needs more material than what's left on the selected spool."), + type: 'warning', + hide: false + }); + + return true; + }; + + var filament = printerStateViewModel.filament(); + var spoolData = selections.selectedSpools(); + + var warningIsShown = false; // used to prevent a separate warning message for each tool + + for (var i = 0; i < filament.length; i += 1) { + var toolID = Utils.extractToolIDFromName(filament[i].name()); + + if (!spoolData[toolID]) { + filament[i].data().weight = 0; + } else { + var _filament$i$data = filament[i].data(), + length = _filament$i$data.length; + + var _spoolData$toolID$pro = spoolData[toolID].profile, + diameter = _spoolData$toolID$pro.diameter, + density = _spoolData$toolID$pro.density; + + + var requiredFilament = calculateWeight(length, diameter, density); + var remainingFilament = spoolData[toolID].weight - spoolData[toolID].used; + + filament[i].data().weight = requiredFilament; + + if (!warningIsShown && settingsViewModel.settings.plugins.filamentmanager.enableWarning()) { + warningIsShown = showWarning(requiredFilament, remainingFilament); + } + } + } + + filename = printerStateViewModel.filename(); + printerStateViewModel.filamentWithWeight(filament); + }; + + self.setSubscriptions = function subscribeToObservablesWhichTriggerAnUpdate() { + selections.selectedSpools.subscribe(updateFilament); + + printerStateViewModel.filament.subscribe(function () { + // OctoPrint constantly updates the filament observable, to prevent invocing the warning message + // on every update we only call the updateFilament() method if the selected file has changed + if (filename !== printerStateViewModel.filename()) { + if (printerStateViewModel.filename() !== undefined && printerStateViewModel.filament().length < 1) { + // file selected, but no filament data found, probably because it's still in analysis queue + waitForFilamentData = true; + } else { + waitForFilamentData = false; + updateFilament(); + } + } else if (waitForFilamentData && printerStateViewModel.filament().length > 0) { + waitForFilamentData = false; + updateFilament(); + } + }); + }; +}; +/* global FilamentManager OCTOPRINT_VIEWMODELS */ + +(function registerViewModel() { + var Plugin = new FilamentManager(); + + OCTOPRINT_VIEWMODELS.push({ + construct: Plugin.viewModel, + dependencies: Plugin.REQUIRED_VIEWMODELS, + elements: Plugin.BINDINGS + }); +})(); \ No newline at end of file diff --git a/octoprint_filamentmanager/static/js/filamentmanager.js b/octoprint_filamentmanager/static/js/filamentmanager.js deleted file mode 100644 index 82de94a..0000000 --- a/octoprint_filamentmanager/static/js/filamentmanager.js +++ /dev/null @@ -1,736 +0,0 @@ -/* - * View model for OctoPrint-FilamentManager - * - * Author: Sven Lohrmann - * License: AGPLv3 - */ -$(function() { - "use strict"; - - var cleanProfile = function() { - return { - id: 0, - material: "", - vendor: "", - density: 1.25, - diameter: 1.75 - }; - }; - - var cleanSpool = function() { - return { - id: 0, - name: "", - cost: 20, - weight: 1000, - used: 0, - temp_offset: 0, - profile: { - id: 0 - } - }; - }; - - var validFloat = function(value, def) { - var f = parseFloat(value); - return isNaN(f) ? def : f; - }; - - function ProfileEditorViewModel(profiles) { - var self = this; - - self.profiles = profiles; - self.isNew = ko.observable(true); - self.selectedProfile = ko.observable(); - - self.id = ko.observable(); - self.vendor = ko.observable(); - self.material = ko.observable(); - self.density = ko.observable(); - self.diameter = ko.observable(); - - self.vendorInvalid = ko.pureComputed(function() { - return !self.vendor(); - }); - - self.materialInvalid = ko.pureComputed(function() { - return !self.material(); - }); - - self.selectedProfile.subscribe(function() { - if (self.selectedProfile() === undefined) { - if (!self.isNew()) { - // selected 'new profile' in options menu, but no profile created yet - self.fromProfileData(); - } - return; - } - - // find profile data - var data = ko.utils.arrayFirst(self.profiles(), function(item) { - return item.id == self.selectedProfile(); - }); - - if (data !== null) { - // populate data - self.fromProfileData(data); - } - }); - - self.fromProfileData = function(data) { - self.isNew(data === undefined); - - if (data === undefined) { - data = cleanProfile(); - self.selectedProfile(undefined); - } - - self.id(data.id); - self.vendor(data.vendor); - self.material(data.material); - self.density(data.density); - self.diameter(data.diameter); - }; - - self.toProfileData = function() { - var defaultProfile = cleanProfile(); - - return { - id: self.id(), - vendor: self.vendor(), - material: self.material(), - density: validFloat(self.density(), defaultProfile.density), - diameter: validFloat(self.diameter(), defaultProfile.diameter) - }; - }; - - self.fromProfileData(); - } - - function SpoolEditorViewModel(profiles) { - var self = this; - - self.profiles = profiles; - self.isNew = ko.observable(false); - - self.id = ko.observable(); - self.name = ko.observable(); - self.selectedProfile = ko.observable(); - self.cost = ko.observable(); - self.totalWeight = ko.observable(); - self.temp_offset = ko.observable(); - - self.remaining = ko.observable(); - - self.nameInvalid = ko.pureComputed(function() { - return !self.name(); - }); - - self.fromSpoolData = function(data) { - self.isNew(data === undefined); - - if (data === undefined) { - data = cleanSpool(); - if (self.profiles().length > 0) { - // automatically select first profile in list - data.profile.id = self.profiles()[0].id; - } - } - - // populate data - self.id(data.id); - self.name(data.name); - self.selectedProfile(data.profile.id); - self.totalWeight(data.weight); - self.cost(data.cost); - self.remaining(data.weight - data.used); - self.temp_offset(data.temp_offset); - }; - - self.toSpoolData = function() { - var defaultSpool = cleanSpool(); - var weight = validFloat(self.totalWeight(), defaultSpool.weight); - var remaining = Math.min(validFloat(self.remaining(), defaultSpool.weight), weight); - - return { - id: self.id(), - name: self.name(), - cost: validFloat(self.cost(), defaultSpool.cost), - weight: weight, - used: weight - remaining, - temp_offset: self.temp_offset(), - profile: { - id: self.selectedProfile() - } - }; - }; - } - - function FilamentManagerViewModel(parameters) { - var self = this; - - self.settings = parameters[0]; - self.printerState = parameters[1]; - self.loginState = parameters[2]; - self.temperature = parameters[3]; - - self.config_enableOdometer = ko.observable(); - self.config_enableWarning = ko.observable(); - self.config_autoPause = ko.observable(); - self.config_pauseThreshold = ko.observable(); - self.config_currencySymbol = ko.observable(); - - self.requestInProgress = ko.observable(false); - - self.profiles = ko.observableArray([]); - self.spools = new ItemListHelper( - "filamentSpools", - { - "name": function(a, b) { - // sorts ascending - if (a["name"].toLocaleLowerCase() < b["name"].toLocaleLowerCase()) return -1; - if (a["name"].toLocaleLowerCase() > b["name"].toLocaleLowerCase()) return 1; - return 0; - }, - "material": function(a, b) { - // sorts ascending - if (a["profile"]["material"].toLocaleLowerCase() - < b["profile"]["material"].toLocaleLowerCase()) return -1; - if (a["profile"]["material"].toLocaleLowerCase() - > b["profile"]["material"].toLocaleLowerCase()) return 1; - return 0; - }, - "vendor": function(a, b) { - // sorts ascending - if (a["profile"]["vendor"].toLocaleLowerCase() - < b["profile"]["vendor"].toLocaleLowerCase()) return -1; - if (a["profile"]["vendor"].toLocaleLowerCase() - > b["profile"]["vendor"].toLocaleLowerCase()) return 1; - return 0; - }, - "remaining": function(a, b) { - // sorts descending - ra = parseFloat(a.weight) - parseFloat(a.used); - rb = parseFloat(b.weight) - parseFloat(b.used); - if (ra > rb) return -1; - if (ra < rb) return 1; - return 0; - } - }, - {}, "name", [], [], 10 - ); - self.selectedSpools = ko.observableArray([]); - - self.pageSize = ko.pureComputed({ - read : function(){ - return self.spools.pageSize(); - }, - write: function(value){ - self.spools.pageSize(parseInt(value)); - } - }); - - self.profileEditor = new ProfileEditorViewModel(self.profiles); - self.spoolEditor = new SpoolEditorViewModel(self.profiles); - - self.onBeforeBinding = function() { - self._copyConfig(); - self.onExtruderCountChange(); // set initial number of tools - self.settings.printerProfiles.currentProfileData.subscribe(function() { - self.onExtruderCountChange(); // update number of tools on changes - }); - }; - - self.onStartupComplete = function() { - self.requestInProgress(true); - $.when(self.requestProfiles(), self.requestSpools(), self.requestSelectedSpools()) - .done(function(profiles, spools, selections) { - self.processProfiles(profiles[0]); - self.processSpools(spools[0]); - self.processSelectedSpools(selections[0]); - }) - .always(function() { - self.requestInProgress(false); - }); - }; - - self.onDataUpdaterPluginMessage = function(plugin, data) { - if (plugin !== "filamentmanager") { - return; - } - - var messageType = data.type; - var messageData = data.data; - - if (messageType === "updated_filaments") { - self.requestInProgress(true); - $.when(self.requestSpools(), self.requestSelectedSpools()) - .done(function(spools, selections) { - self.processSpools(spools[0]); - self.processSelectedSpools(selections[0]); - }) - .always(function() { - self.requestInProgress(false); - }); - } - }; - - // spool selection - - self.selectedSpoolsHelper = ko.observableArray([]); // selected spool id for each tool - self.tools = ko.observableArray([]); // number of tools to generate select elements in template - self.onSelectedSpoolChangeEnabled = false; // false if querying selections to prevent triggering the - // change event again when setting selected spools - - self.onExtruderCountChange = function() { - var currentProfileData = self.settings.printerProfiles.currentProfileData(); - var numExtruders = (currentProfileData ? currentProfileData.extruder.count() : 0); - - if (self.selectedSpoolsHelper().length < numExtruders) { - // number of extruders has increased - for (var i = self.selectedSpoolsHelper().length; i < numExtruders; ++i) { - // add observables - self.selectedSpools.push(undefined); // notifies observers - self.selectedSpoolsHelper().push(ko.observable(undefined)); - } - } else { - // number of extruders has decreased - for (var i = numExtruders; i < self.selectedSpoolsHelper().length; ++i) { - // remove observables - self.selectedSpoolsHelper().pop(); - self.selectedSpools.pop(); // notifies observers - } - } - - self.tools(new Array(numExtruders)); - }; - - self.onSelectedSpoolChange = function(tool) { - if (!self.onSelectedSpoolChangeEnabled) return; - - var spool = self.selectedSpoolsHelper()[tool](); - var data = { - tool: tool, - spool: { - id: spool !== undefined ? spool : null - } - }; - self.updateSelectedSpool(data); - }; - - self.updateSelectedSpool = function(data) { - self.requestInProgress(true); - OctoPrint.plugins.filamentmanager.updateSelection(data.tool, data) - .done(function(data) { - var spool = data["selection"]; - self._updateSelectedSpoolData(spool); - self._applyTemperatureOffset(spool); - }) - .fail(function() { - var text = gettext("There was an unexpected error while selecting the spool, please consult the logs."); - new PNotify({title: gettext("Could not select spool"), text: text, type: "error", hide: false}); - }) - .always(function() { - self.requestInProgress(false); - }); - }; - - self.requestSelectedSpools = function() { - return OctoPrint.plugins.filamentmanager.listSelections(); - }; - - self.processSelectedSpools = function(data) { - self.onSelectedSpoolChangeEnabled = false; - _.each(data["selections"], function(selection, index) { - self._updateSelectedSpoolData(selection); - self._applyTemperatureOffset(selection); - }); - self.onSelectedSpoolChangeEnabled = true; - } - - self._updateSelectedSpoolData = function(data) { - if (data.tool < self.tools().length) { - self.selectedSpoolsHelper()[data.tool](data.spool != null ? data.spool.id : undefined); - self.selectedSpools()[data.tool] = (data.spool != null ? data.spool : undefined); - self.selectedSpools.valueHasMutated(); // notifies observers - } - }; - - self._reapplySubscription = undefined; - - self._applyTemperatureOffset = function(data) { - if (self.loginState.isUser()) { - // if logged in apply temperature offset - if (data.tool < self.tools().length) { - var tool = self.temperature.tools()[data.tool]; - var spool = data.spool; - self.temperature.changingOffset.item = tool; - self.temperature.changingOffset.name(tool.name()); - self.temperature.changingOffset.offset(tool.offset()); - self.temperature.changingOffset.newOffset(spool != null ? spool.temp_offset : 0); - self.temperature.confirmChangeOffset(); - } - } else { - // if not logged in set a subscription to automatically apply the temperature offset after login - if (self._reapplySubscription === undefined) { - self._reapplySubscription = self.loginState.isUser.subscribe(self._reapplyTemperatureOffset); - } - } - }; - - self._reapplyTemperatureOffset = function() { - if (!self.loginState.isUser()) return; - - // apply temperature offset - _.each(self.selectedSpools(), function(spool, index) { - var selection = {spool: spool, tool: index}; - self._applyTemperatureOffset(selection); - }); - - // remove subscription - self._reapplySubscription.dispose(); - self._reapplySubscription = undefined; - }; - - // plugin settings - - self.showSettingsDialog = function() { - self._copyConfig(); - $("#settings_plugin_filamentmanager_configurationdialog").modal("show"); - }; - - self.hideSettingsDialog = function() { - $("#settings_plugin_filamentmanager_configurationdialog").modal("hide"); - }; - - self.savePluginSettings = function(viewModel, event) { - var target = $(event.target); - target.prepend(' '); - - var data = { - plugins: { - filamentmanager: { - enableOdometer: self.config_enableOdometer(), - enableWarning: self.config_enableWarning(), - autoPause: self.config_autoPause(), - pauseThreshold: self.config_pauseThreshold(), - currencySymbol: self.config_currencySymbol() - } - } - }; - - self.settings.saveData(data, { - success: function() { - self.hideSettingsDialog(); - self._copyConfig(); - }, - complete: function() { - $("i.fa-spinner", target).remove(); - }, - sending: true - }); - }; - - self._copyConfig = function() { - var pluginSettings = self.settings.settings.plugins.filamentmanager; - self.config_enableOdometer(pluginSettings.enableOdometer()); - self.config_enableWarning(pluginSettings.enableWarning()); - self.config_autoPause(pluginSettings.autoPause()); - self.config_pauseThreshold(pluginSettings.pauseThreshold()); - self.config_currencySymbol(pluginSettings.currencySymbol()); - }; - - // profiles - - self.showProfilesDialog = function() { - $("#settings_plugin_filamentmanager_profiledialog").modal("show"); - }; - - self.requestProfiles = function(force=false) { - return OctoPrint.plugins.filamentmanager.listProfiles(force); - }; - - self.processProfiles = function(data) { - self.profiles(data.profiles); - }; - - self.saveProfile = function(data) { - if (data === undefined) { - data = self.profileEditor.toProfileData(); - } - - self.profileEditor.isNew() ? self.addProfile(data) : self.updateProfile(data); - }; - - self.addProfile = function(data) { - if (data === undefined) { - data = self.profileEditor.toProfileData(); - } - - self.requestInProgress(true); - OctoPrint.plugins.filamentmanager.addProfile(data) - .done(function() { - self.requestProfiles() - .done(self.processProfiles) - .always(function() { - self.requestInProgress(false); - }); - }) - .fail(function() { - var text = gettext("There was an unexpected error while saving the filament profile, " + - "please consult the logs."); - new PNotify({title: gettext("Could not add profile"), text: text, type: "error", hide: false}); - self.requestInProgress(false); - }); - }; - - self.updateProfile = function(data) { - if (data === undefined) { - data = self.profileEditor.toProfileData(); - } - - self.requestInProgress(true); - OctoPrint.plugins.filamentmanager.updateProfile(data.id, data) - .done(function() { - $.when(self.requestProfiles(), self.requestSpools(), self.requestSelectedSpools()) - .done(function(profiles, spools, selections) { - self.processProfiles(profiles[0]); - self.processSpools(spools[0]); - self.processSelectedSpools(selections[0]); - }) - .always(function() { - self.requestInProgress(false); - }); - }) - .fail(function() { - var text = gettext("There was an unexpected error while updating the filament profile, " + - "please consult the logs."); - new PNotify({title: gettext("Could not update profile"), text: text, type: "error", hide: false}); - self.requestInProgress(false); - }); - }; - - self.removeProfile = function(data) { - var perform = function() { - OctoPrint.plugins.filamentmanager.deleteProfile(data.id) - .done(function() { - self.requestProfiles() - .done(self.processProfiles) - .always(function() { - self.requestInProgress(false); - }); - }) - .fail(function(xhr) { - var text; - if (xhr.status == 409) { - text = gettext("Cannot delete profiles with associated spools."); - } else { - text = gettext("There was an unexpected error while removing the filament profile, " + - "please consult the logs."); - } - var title = gettext("Could not delete profile");; - new PNotify({title: title, text: text, type: "error", hide: false}); - self.requestInProgress(false); - }); - }; - - showConfirmationDialog({ - title: gettext("Delete profile?"), - message: _.sprintf(gettext("You are about to delete the filament profile %s (%s). " + - "Please note that it is not possible to delete profiles with associated spools."), - data.material, data.vendor), - proceed: gettext("Delete"), - onproceed: perform - }); - }; - - // spools - - self.showSpoolDialog = function(data) { - self.spoolEditor.fromSpoolData(data); - $("#settings_plugin_filamentmanager_spooldialog").modal("show"); - }; - - self.hideSpoolDialog = function() { - $("#settings_plugin_filamentmanager_spooldialog").modal("hide"); - }; - - self.requestSpools = function(force=false) { - return OctoPrint.plugins.filamentmanager.listSpools(force); - } - - self.processSpools = function(data) { - self.spools.updateItems(data.spools); - }; - - self.saveSpool = function(data) { - if (data === undefined) { - data = self.spoolEditor.toSpoolData(); - } - - self.spoolEditor.isNew() ? self.addSpool(data) : self.updateSpool(data); - }; - - self.addSpool = function(data) { - if (data === undefined) { - data = self.spoolEditor.toSpoolData(); - } - - self.requestInProgress(true); - OctoPrint.plugins.filamentmanager.addSpool(data) - .done(function() { - self.hideSpoolDialog(); - self.requestSpools() - .done(self.processSpools) - .always(function() { - self.requestInProgress(false); - }); - }) - .fail(function() { - var text = gettext("There was an unexpected error while saving the filament spool, " + - "please consult the logs."); - new PNotify({title: gettext("Could not add spool"), text: text, type: "error", hide: false}); - }); - }; - - self.updateSpool = function(data) { - if (data === undefined) { - data = self.spoolEditor.toSpoolData(); - } - - self.requestInProgress(true); - OctoPrint.plugins.filamentmanager.updateSpool(data.id, data) - .done(function() { - self.hideSpoolDialog(); - $.when(self.requestSpools(), self.requestSelectedSpools()) - .done(function(spools, selections) { - self.processSpools(spools[0]); - self.processSelectedSpools(selections[0]); - }) - .always(function() { - self.requestInProgress(false); - }); - }) - .fail(function() { - var text = gettext("There was an unexpected error while updating the filament spool, " + - "please consult the logs."); - new PNotify({title: gettext("Could not update spool"), text: text, type: "error", hide: false}); - self.requestInProgress(false); - }); - }; - - self.removeSpool = function(data) { - var perform = function() { - self.requestInProgress(true); - OctoPrint.plugins.filamentmanager.deleteSpool(data.id) - .done(function() { - self.requestSpools() - .done(self.processSpools) - .always(function() { - self.requestInProgress(false); - }); - }) - .fail(function() { - var text = gettext("There was an unexpected error while removing the filament spool, " + - "please consult the logs."); - new PNotify({title: gettext("Could not delete spool"), text: text, type: "error", hide: false}); - self.requestInProgress(false); - }); - }; - - showConfirmationDialog({ - title: gettext("Delete spool?"), - message: _.sprintf(gettext("You are about to delete the filament spool %s - %s (%s)."), - data.name, data.profile.material, data.profile.vendor), - proceed: gettext("Delete"), - onproceed: perform - }); - }; - - self.duplicateSpool = function(data) { - data.used = 0; - self.addSpool(data); - } - - // import & export - - self.importFilename = ko.observable(); - - self.invalidArchive = ko.pureComputed(function() { - var name = self.importFilename(); - return name !== undefined && !(_.endsWith(name.toLocaleLowerCase(), ".zip")); - }); - - self.enableImport = ko.pureComputed(function() { - var name = self.importFilename(); - return name !== undefined && name.trim() != "" && !self.invalidArchive(); - }); - - self.importButton = $("#settings_plugin_filamentmanager_import_button"); - self.importElement = $("#settings_plugin_filamentmanager_import"); - - self.importElement.fileupload({ - dataType: "json", - maxNumberOfFiles: 1, - autoUpload: false, - add: function(e, data) { - if (data.files.length == 0) { - return false; - } - - self.importFilename(data.files[0].name); - - self.importButton.unbind("click"); - self.importButton.bind("click", function(event) { - event.preventDefault(); - data.submit(); - }); - }, - done: function(e, data) { - new PNotify({ - title: gettext("Data import successfull"), - type: "success", - hide: true - }); - - self.importButton.unbind("click"); - self.importFilename(undefined); - - self.requestInProgress(true); - $.when(self.requestProfiles(true), self.requestSpools(true)) - .done(function(profiles, spools) { - self.processProfiles(profiles[0]); - self.processSpools(spools[0]); - }) - .always(function() { - self.requestInProgress(false); - }); - }, - fail: function(e, data) { - new PNotify({ - title: gettext("Data import failed"), - text: gettext("Something went wrong, please consult the logs."), - type: "error", - hide: false - }); - - self.importButton.unbind("click"); - self.importFilename(undefined); - } - }); - - self.exportUrl = function() { - return "plugin/filamentmanager/export?apikey=" + UI_API_KEY; - } - } - - OCTOPRINT_VIEWMODELS.push({ - construct: FilamentManagerViewModel, - dependencies: ["settingsViewModel", "printerStateViewModel", "loginStateViewModel", "temperatureViewModel"], - elements: ["#settings_plugin_filamentmanager", - "#settings_plugin_filamentmanager_profiledialog", - "#settings_plugin_filamentmanager_spooldialog", - "#settings_plugin_filamentmanager_configurationdialog", - "#sidebar_plugin_filamentmanager_wrapper"] - }); -}); diff --git a/octoprint_filamentmanager/static/js/warning.js b/octoprint_filamentmanager/static/js/warning.js deleted file mode 100644 index eef1d4c..0000000 --- a/octoprint_filamentmanager/static/js/warning.js +++ /dev/null @@ -1,119 +0,0 @@ -/* - * View model for OctoPrint-FilamentManager - * - * Author: Sven Lohrmann - * License: AGPLv3 - */ -$(function() { - "use strict"; - - function FilamentWarningViewModel(parameters) { - var self = this; - - self.filamentManager = parameters[0]; - self.printerState = parameters[1]; - self.settings = parameters[2]; - - self.filename = undefined; - - self.printerState.filamentWithWeight = ko.observableArray([]); - - self.printerState.formatFilamentWithWeight = function(filament) { - if (!filament || !filament["length"]) return "-"; - var result = "%(length).02fm"; - if (filament.hasOwnProperty("weight") && filament.weight) { - result += " / %(weight).02fg"; - } - return _.sprintf(result, {length: filament["length"] / 1000, weight: filament["weight"]}); - } - - self.onStartup = function() { - $("#state").find(".accordion-inner").contents().each(function(index, item) { - if (item.nodeType === Node.COMMENT_NODE) { - if (item.nodeValue === " ko foreach: filament ") { - item.nodeValue = " ko foreach: [] "; - $("" + - "" + - "
" + - "").insertBefore(item); - return false; // exit loop - } - } - }); - }; - - self.waitForFilamentData = false - - self.onBeforeBinding = function() { - self.printerState.filament.subscribe(function() { - if (self.filename !== self.printerState.filename()) { - if (self.printerState.filename() != undefined && self.printerState.filament().length < 1) { - // file selected, but no filament data found, probably because it's still in analysis queue - self.waitForFilamentData = true; - } else { - self._processData(); - } - } - else if (self.waitForFilamentData && self.printerState.filament().length > 0) { - self._processData(); - } - }); - self.filamentManager.selectedSpools.subscribe(self._processData); - } - - self._processData = function() { - self.waitForFilamentData = false; - - var filament = self.printerState.filament(); - var spoolData = self.filamentManager.selectedSpools(); - - var warningIsShown = false; // used to prevent a separate warning message for each tool - - for (var i = 0; i < filament.length && i < spoolData.length; ++i) { - if (spoolData[i] == undefined) { - // skip tools with no selected spool - filament[i].data().weight = 0; - continue; - } - - var length = filament[i].data().length; - var diameter = spoolData[i].profile.diameter; - var density = spoolData[i].profile.density; - - var requiredFilament = self._calculateFilamentWeight(length, diameter, density); - - filament[i].data().weight = requiredFilament; - - if (self.settings.settings.plugins.filamentmanager.enableWarning()) { - var remainingFilament = spoolData[i].weight - spoolData[i].used; - - if (requiredFilament > remainingFilament && !warningIsShown) { - self._showWarning(); - warningIsShown = true; - } - } - } - - self.filename = self.printerState.filename(); - self.printerState.filamentWithWeight(filament);; - }; - - self._showWarning = function() { - var text = gettext("The current print job needs more material than what's remaining on the selected spool."); - new PNotify({title: gettext("Insufficient filament"), text: text, type: "warning", hide: false}); - }; - - self._calculateFilamentWeight = function(length, diameter, density) { - var radius = diameter / 2; - var volume = length * Math.PI * radius * radius / 1000; - return volume * density; - }; - } - - OCTOPRINT_VIEWMODELS.push({ - construct: FilamentWarningViewModel, - dependencies: ["filamentManagerViewModel", "printerStateViewModel", "settingsViewModel"], - elements: [] - }); -}); diff --git a/octoprint_filamentmanager/templates/settings.jinja2 b/octoprint_filamentmanager/templates/settings.jinja2 index 2ec4f2a..d176cd4 100644 --- a/octoprint_filamentmanager/templates/settings.jinja2 +++ b/octoprint_filamentmanager/templates/settings.jinja2 @@ -1,10 +1,10 @@
- +
-

{{ _("Filament Spools") }}

+

{{ _("Filament Spools") }}

@@ -42,7 +42,7 @@ {{ _('Action') }} - + @@ -51,9 +51,9 @@ g % - | - | - + | + | + @@ -63,21 +63,21 @@
- - + +
diff --git a/octoprint_filamentmanager/templates/settings_configdialog.jinja2 b/octoprint_filamentmanager/templates/settings_configdialog.jinja2 index 0f298c6..63a0123 100644 --- a/octoprint_filamentmanager/templates/settings_configdialog.jinja2 +++ b/octoprint_filamentmanager/templates/settings_configdialog.jinja2 @@ -7,82 +7,143 @@