From 1f4d364dc4804d3cba5602723bf07cdb3e31de16 Mon Sep 17 00:00:00 2001 From: Scott Martin Date: Sun, 10 Apr 2022 20:58:07 -0400 Subject: [PATCH 01/20] Optimistic commit --- continuousprint/__init__.py | 59 +++++++++---------- continuousprint/static/js/continuousprint.js | 7 +++ .../static/js/continuousprint_settings.js | 53 +++++++++++++++++ .../templates/continuousprint_settings.jinja2 | 44 +++++++++++--- 4 files changed, 123 insertions(+), 40 deletions(-) create mode 100644 continuousprint/static/js/continuousprint_settings.js diff --git a/continuousprint/__init__.py b/continuousprint/__init__.py index d27cd33..cdc080f 100644 --- a/continuousprint/__init__.py +++ b/continuousprint/__init__.py @@ -4,6 +4,8 @@ import octoprint.plugin import flask import json +import yaml +import os from io import BytesIO from octoprint.server.util.flask import restricted_access from octoprint.events import Events @@ -17,6 +19,7 @@ QUEUE_KEY = "cp_queue" +PRINTER_PROFILE_KEY = "cp_printer_profile" CLEARING_SCRIPT_KEY = "cp_bed_clearing_script" FINISHED_SCRIPT_KEY = "cp_queue_finished_script" TEMP_FILES = dict( @@ -49,27 +52,28 @@ def _update_driver_settings(self): # part of SettingsPlugin def get_settings_defaults(self): + base = os.path.dirname(__file__) + with open(os.path.join(base, "data/printer_profiles.yaml"), "r") as f: + self._printer_profiles = yaml.safe_load(f.read())['PrinterProfile'] + with open(os.path.join(base, "data/gcode_scripts.yaml"), "r") as f: + self._gcode_scripts = yaml.safe_load(f.read())['GScript'] + d = {} d[QUEUE_KEY] = "[]" - d[CLEARING_SCRIPT_KEY] = ( - "M17 ;enable steppers\n" - "G91 ; Set relative for lift\n" - "G0 Z10 ; lift z by 10\n" - "G90 ;back to absolute positioning\n" - "M190 R25 ; set bed to 25 and wait for cooldown\n" - "G0 X200 Y235 ;move to back corner\n" - "G0 X110 Y235 ;move to mid bed aft\n" - "G0 Z1 ;come down to 1MM from bed\n" - "G0 Y0 ;wipe forward\n" - "G0 Y235 ;wipe aft\n" - "G28 ; home" - ) - d[FINISHED_SCRIPT_KEY] = ( - "M18 ; disable steppers\n" - "M104 T0 S0 ; extruder heater off\n" - "M140 S0 ; heated bed heater off\n" - "M300 S880 P300 ; beep to show its finished" - ) + d[PRINTER_PROFILE_KEY] = "Generic" + + profile = self._printer_profile_manager.get_current() + if profile is not None: + print("Printer:", profile['model'], "-", profile['name']) + print("TODO match profile on default init") + + for s in self._gcode_scripts: + name = s['name'] + gcode = s['gcode'] + if name == "Pause": + d[CLEARING_SCRIPT_KEY] = gcode + elif name == "Generic Off": + d[FINISHED_SCRIPT_KEY] = gcode d[RESTART_MAX_RETRIES_KEY] = 3 d[RESTART_ON_PAUSE_KEY] = False d[RESTART_MAX_TIME_KEY] = 60 * 60 @@ -410,25 +414,15 @@ def reset(self): # part of TemplatePlugin def get_template_vars(self): return dict( - cp_enabled=(self.d.active if hasattr(self, "d") else False), - cp_bed_clearing_script=self._settings.get([CLEARING_SCRIPT_KEY]), - cp_queue_finished=self._settings.get([FINISHED_SCRIPT_KEY]), - cp_restart_on_pause_enabled=self._settings.get_boolean( - [RESTART_ON_PAUSE_KEY] - ), - cp_restart_on_pause_max_seconds=self._settings.get_int( - [RESTART_MAX_TIME_KEY] - ), - cp_restart_on_pause_max_restarts=self._settings.get_int( - [RESTART_MAX_RETRIES_KEY] - ), + printer_profiles=self._printer_profiles, + gcode_scripts=self._gcode_scripts ) def get_template_configs(self): return [ dict( type="settings", - custom_bindings=False, + custom_bindings=True, template="continuousprint_settings.jinja2", ), dict( @@ -449,6 +443,7 @@ def get_assets(self): "js/continuousprint_queueset.js", "js/continuousprint_job.js", "js/continuousprint_viewmodel.js", + "js/continuousprint_settings.js", "js/continuousprint.js", ], css=["css/continuousprint.css"], diff --git a/continuousprint/static/js/continuousprint.js b/continuousprint/static/js/continuousprint.js index 705963b..a2c16ab 100644 --- a/continuousprint/static/js/continuousprint.js +++ b/continuousprint/static/js/continuousprint.js @@ -32,4 +32,11 @@ $(function() { ], elements: ["#tab_plugin_continuousprint"] }); + OCTOPRINT_VIEWMODELS.push({ + construct: CPSettingsViewModel, + dependencies: [ + "settingsViewModel", + ], + elements: ["#settings_plugin_continuousprint"] + }); }); diff --git a/continuousprint/static/js/continuousprint_settings.js b/continuousprint/static/js/continuousprint_settings.js new file mode 100644 index 0000000..178bc96 --- /dev/null +++ b/continuousprint/static/js/continuousprint_settings.js @@ -0,0 +1,53 @@ +function CPSettingsViewModel(parameters) { + var self = this; + self.PLUGIN_ID = "octoprint.plugins.continuousprint"; + self.log = log.getLogger(self.PLUGIN_ID); + self.settings = parameters[0]; + + // Constants defined in continuousprint_settings.jinja2, passed from the plugin (see `get_template_vars()` in __init__.py) + try { + self.profiles = {}; + for (let prof of CP_PRINTER_PROFILES) { + if (self.profiles[prof.make] === undefined) { + self.profiles[prof.make] = {}; + } + self.profiles[prof.make][prof.model] = prof; + } + self.scripts = {}; + for (let s of CP_GCODE_SCRIPTS) { + self.scripts[s.name] = s.gcode; + } + + self.selected_make = ko.observable(); + let makes = Object.keys(self.profiles); + makes.unshift("Select one"); + self.printer_makes = ko.observable(makes); + self.selected_model = ko.observable(); + self.printer_models = ko.computed(function() { + let models = self.profiles[self.selected_make()]; + if (models === undefined) { + return ["-"]; + } + let result = Object.keys(models); + result.unshift("-"); + return result; + }); + + self.modelChanged = function(e) { + let profile = self.profiles[self.selected_make()][self.selected_model()]; + if (profile === undefined) { + return; + } + let cpset = self.settings.settings.plugins.continuousprint; + cpset.cp_bed_clearing_script(self.scripts[profile.defaults.clearBed]); + cpset.cp_queue_finished_script(self.scripts[profile.defaults.finished]); + } + } catch(e) { + console.error(e); + } +} + + +try { +module.exports = CPSettingsViewModel; +} catch {} diff --git a/continuousprint/templates/continuousprint_settings.jinja2 b/continuousprint/templates/continuousprint_settings.jinja2 index 152e00e..60b6d54 100644 --- a/continuousprint/templates/continuousprint_settings.jinja2 +++ b/continuousprint/templates/continuousprint_settings.jinja2 @@ -1,32 +1,60 @@ -

Continuous Print Settings

+

Continuous Print

+ +
-

Basic Scripts

+

Printer Profiles

+

Load community-contributed bed clearing scripts for a your printer:

+
+
+ +
+ +
+
+
+ +
+ +
+
+
+

Click "Save" to keep the changes, or "Close" to discard them.

+ +

Customize

For examples and best practices, see the GCODE scripting guide.

+
- +
- +

Failure Recovery

-
+

Failure recovery requires The Spaghetti Detective version ≥ 1.8.11, but the plugin does not appear to be installed.

Read more about how to set this up in the Failure Recovery guide.

-
+

Failure recovery is enabled because The Spaghetti Detective is installed.

@@ -37,7 +65,7 @@
- + seconds ago
@@ -46,7 +74,7 @@
- + retries
From 63452a798e912bf2af38a093cbbaef29dbad3a55 Mon Sep 17 00:00:00 2001 From: Scott Martin Date: Mon, 11 Apr 2022 12:34:38 -0400 Subject: [PATCH 02/20] Add tests for printer settings, add descriptions to settings and add contributing section to scripting guide --- continuousprint/data/gcode_scripts.yaml | 64 +++++++++++++++ continuousprint/data/printer_profiles.yaml | 34 ++++++++ .../static/js/continuousprint_settings.js | 30 +++++--- .../js/continuousprint_settings.test.js | 77 +++++++++++++++++++ .../templates/continuousprint_settings.jinja2 | 2 +- docs/gcode-scripting.md | 76 +++--------------- 6 files changed, 205 insertions(+), 78 deletions(-) create mode 100644 continuousprint/data/gcode_scripts.yaml create mode 100644 continuousprint/data/printer_profiles.yaml create mode 100644 continuousprint/static/js/continuousprint_settings.test.js diff --git a/continuousprint/data/gcode_scripts.yaml b/continuousprint/data/gcode_scripts.yaml new file mode 100644 index 0000000..82029dc --- /dev/null +++ b/continuousprint/data/gcode_scripts.yaml @@ -0,0 +1,64 @@ +GScript: + - name: "Sweep Gantry" + description: > + This script example assumes a box style printer with a vertical Z axis and a 200mm x 235mm XY build area. + It uses the printer's extruder to push the part off the build plate." + version: 0.0.1 + gcode: | + M17 ;enable steppers + G91 ; Set relative for lift + G0 Z10 ; lift z by 10 + G90 ;back to absolute positioning + M190 R25 ; set bed to 25 and wait for cooldown + G0 X200 Y235 ;move to back corner + G0 X110 Y235 ;move to mid bed aft + G0 Z1 ;come down to 1MM from bed + G0 Y0 ;wipe forward + G0 Y235 ;wipe aft + G28 ; home + - name: "Advance Belt Short" + description: > + This script works with a belt printer (specifically, a Creality CR-30). The belt is advanced to move + the print out of the way before starting another print. + version: 0.0.1 + gcode: | + M17 ; enable steppers + G91 ; Set relative for lift + G21 ; Set units to mm + G0 Z10 ; advance bed (Z) by 10mm + G90 ; back to absolute positioning + M104 S0; Set Hot-end to 0C (off) + M140 S0; Set bed to 0C (off) + - name: "Pause" + description: > + Use this script if you want to remove the print yourself but use the queue to keep track of your + prints. It uses an @ Command to tell OctoPrint to pause the print. The printer will stay paused + until you press "Resume" on the OctoPrint UI. + version: 0.0.1 + gcode: | + M18 ; disable steppers + M104 T0 S0 ; extruder heater off + M140 S0 ; heated bed heater off + @pause ; wait for user input + - name: "Generic Off" + description: > + This is a generic "heaters and motors off" script which should be compatible with most printers. + version: 0.0.1 + gcode: | + M18 ; disable steppers + M104 T0 S0 ; extruder heater off + M140 S0 ; heated bed heater off + M300 S880 P300 ; beep to show its finished + - name: "Advance Belt Long" + description: > + The same idea as "Advance Belt Short", but with a longer advancement to roll off all completed prints. + version: 0.0.1 + gcode: | + M17 ; enable steppers + G91 ; Set relative for lift + G21 ; Set units to mm + G0 Z300 ; advance bed (Z) to roll off all parts + M18 ; Disable steppers + G90 ; back to absolute positioning + M104 S0; Set Hot-end to 0C (off) + M140 S0; Set bed to 0C (off) diff --git a/continuousprint/data/printer_profiles.yaml b/continuousprint/data/printer_profiles.yaml new file mode 100644 index 0000000..d0e0326 --- /dev/null +++ b/continuousprint/data/printer_profiles.yaml @@ -0,0 +1,34 @@ +PrinterProfile: + - name: "Generic" + make: "Generic" + model: "Generic" + width: 150 + depth: 150 + height: 150 + formFactor: "rectangular" + selfClearing: false + defaults: + clearBed: "Pause" + finished: "Generic Off" + - name: "Creality CR30" + make: "Creality" + model: "CR30" + width: 200 + depth: 1000 + height: 200 + formFactor: "rectangular" + selfClearing: true + defaults: + clearBed: "Advance Belt Short" + finished: "Advance Belt Long" + - name: "Monoprice Mini Delta V2" + make: "Monoprice" + model: "Mini Delta V2" + width: 110 + depth: 110 + height: 120 + formFactor: "circular" + selfClearing: false + defaults: + clearBed: "Pause" + finished: "Generic Off" diff --git a/continuousprint/static/js/continuousprint_settings.js b/continuousprint/static/js/continuousprint_settings.js index 178bc96..40ec438 100644 --- a/continuousprint/static/js/continuousprint_settings.js +++ b/continuousprint/static/js/continuousprint_settings.js @@ -1,20 +1,29 @@ -function CPSettingsViewModel(parameters) { +if (typeof log === "undefined" || log === null) { + // In the testing environment, dependencies must be manually imported + ko = require('knockout'); + log = { + "getLogger": () => {return console;} + }; + CP_PRINTER_PROFILES = []; + CP_GCODE_SCRIPTS = []; +} + +function CPSettingsViewModel(parameters, profiles=CP_PRINTER_PROFILES, scripts=CP_GCODE_SCRIPTS) { var self = this; self.PLUGIN_ID = "octoprint.plugins.continuousprint"; self.log = log.getLogger(self.PLUGIN_ID); self.settings = parameters[0]; // Constants defined in continuousprint_settings.jinja2, passed from the plugin (see `get_template_vars()` in __init__.py) - try { self.profiles = {}; - for (let prof of CP_PRINTER_PROFILES) { + for (let prof of profiles) { if (self.profiles[prof.make] === undefined) { self.profiles[prof.make] = {}; } self.profiles[prof.make][prof.model] = prof; } self.scripts = {}; - for (let s of CP_GCODE_SCRIPTS) { + for (let s of scripts) { self.scripts[s.name] = s.gcode; } @@ -33,8 +42,8 @@ function CPSettingsViewModel(parameters) { return result; }); - self.modelChanged = function(e) { - let profile = self.profiles[self.selected_make()][self.selected_model()]; + self.modelChanged = function() { + let profile = (self.profiles[self.selected_make()] || {})[self.selected_model()]; if (profile === undefined) { return; } @@ -42,12 +51,13 @@ function CPSettingsViewModel(parameters) { cpset.cp_bed_clearing_script(self.scripts[profile.defaults.clearBed]); cpset.cp_queue_finished_script(self.scripts[profile.defaults.finished]); } - } catch(e) { - console.error(e); - } } try { -module.exports = CPSettingsViewModel; +module.exports = { + CPSettingsViewModel, + CP_PRINTER_PROFILES, + CP_GCODE_SCRIPTS, +}; } catch {} diff --git a/continuousprint/static/js/continuousprint_settings.test.js b/continuousprint/static/js/continuousprint_settings.test.js new file mode 100644 index 0000000..f3d5020 --- /dev/null +++ b/continuousprint/static/js/continuousprint_settings.test.js @@ -0,0 +1,77 @@ +// Import must happen after declaring constants +const VM = require('./continuousprint_settings'); + +const PROFILES = [ + { + name: 'Generic', + make: 'Generic', + model: 'Generic', + defaults: { + clearBed: 'script1', + finished: 'script2', + }, + }, + { + name: 'TestPrinter', + make: 'Test', + model: 'Printer', + defaults: { + clearBed: 'script1', + finished: 'script2', + }, + }, +] + +const SCRIPTS = [ + { + name: 'script1', + gcode: 'test1', + }, + { + name: 'script2', + gcode: 'test2', + }, +]; + + +function mocks() { + return [ + { + settings: { + plugins: { + continuousprint: { + cp_bed_clearing_script: jest.fn(), + cp_queue_finished_script: jest.fn(), + }, + }, + }, + }, + ]; +} + +test('makes are populated', () => { + let v = new VM.CPSettingsViewModel(mocks(), PROFILES, SCRIPTS); + expect(v.printer_makes().length).toBeGreaterThan(1); // Not just "Select one" +}); + +test('models are populated based on selected_make', () => { + let v = new VM.CPSettingsViewModel(mocks(), PROFILES, SCRIPTS); + v.selected_make("Test"); + expect(v.printer_models()).toEqual(["-", "Printer"]); +}); + +test('valid model change updates settings scripts', () => { + let v = new VM.CPSettingsViewModel(mocks(), PROFILES, SCRIPTS); + v.selected_make("Test"); + v.selected_model("Printer"); + v.modelChanged(); + expect(v.settings.settings.plugins.continuousprint.cp_bed_clearing_script).toHaveBeenCalledWith("test1"); + expect(v.settings.settings.plugins.continuousprint.cp_queue_finished_script).toHaveBeenCalledWith("test2"); +}); + +test('invalid model change is ignored', () => { + let v = new VM.CPSettingsViewModel(mocks(), PROFILES, SCRIPTS); + v.modelChanged(); + expect(v.settings.settings.plugins.continuousprint.cp_bed_clearing_script).not.toHaveBeenCalled(); + expect(v.settings.settings.plugins.continuousprint.cp_queue_finished_script).not.toHaveBeenCalled(); +}); diff --git a/continuousprint/templates/continuousprint_settings.jinja2 b/continuousprint/templates/continuousprint_settings.jinja2 index 60b6d54..60f622c 100644 --- a/continuousprint/templates/continuousprint_settings.jinja2 +++ b/continuousprint/templates/continuousprint_settings.jinja2 @@ -7,7 +7,7 @@

Printer Profiles

-

Load community-contributed bed clearing scripts for a your printer:

+

Load community-contributed bed clearing scripts for your printer:

diff --git a/docs/gcode-scripting.md b/docs/gcode-scripting.md index 7f52f8e..2d44906 100644 --- a/docs/gcode-scripting.md +++ b/docs/gcode-scripting.md @@ -2,82 +2,24 @@ GCODE scripts can be quite complex - if you want to learn the basics, try reading through [this primer](https://www.simplify3d.com/support/articles/3d-printing-gcode-tutorial/). -## Bed cleaing scripts +## Bed clearing scripts When Continuous Print is managing the queue, this script is run after every print completes - **including prints started before queue managing begins**. Your cleaning script should remove all 3D print material from the build area to make way for the next print. -### Gantry Sweep - -This script example assumes a box style printer with a vertical Z axis and a `200mm x 235mm` XY build area. It uses the printer's extruder to -push the part off the build plate. - -``` -M17 ;enable steppers -G91 ; Set relative for lift -G0 Z10 ; lift z by 10 -G90 ;back to absolute positioning -M190 R25 ; set bed to 25 and wait for cooldown -G0 X200 Y235 ;move to back corner -G0 X110 Y235 ;move to mid bed aft -G0 Z1 ;come down to 1MM from bed -G0 Y0 ;wipe forward -G0 Y235 ;wipe aft -G28 ; home -``` - -### Advance Belt - -This script works with a belt printer (specifically, a [Creality CR-30](https://www.creality.com/goods-detail/creality-3dprintmill-3d-printer)). The belt is advanced to move the print out of the way before starting another print. - -``` -M17 ; enable steppers -G91 ; Set relative for lift -G21 ; Set units to mm -G0 Z10 ; advance bed (Z) by 10mm -G90 ; back to absolute positioning -M104 S0; Set Hot-end to 0C (off) -M140 S0; Set bed to 0C (off) -``` - -### Wait for Input - -Use this script if you want to remove the print yourself but use the queue to keep track of your prints. It uses an [`@ Command`](https://docs.octoprint.org/en/master/features/atcommands.html) to tell OctoPrint to pause the print. The printer will stay paused until you press "Resume" on the OctoPrint UI. - -``` -M18 ; disable steppers -M104 T0 S0 ; extruder heater off -M140 S0 ; heated bed heater off -@pause ; wait for user input -``` - ## Queue finished scripts This script is run after all prints in the queue have been printed. Use this script to put the machine in a safe resting state. Note that the last print will have already been cleared by the bed cleaning script (above). -### Generic - -This is a generic "heaters and motors off" script which should be compatible with most printers. - -``` -M18 ; disable steppers -M104 T0 S0 ; extruder heater off -M140 S0 ; heated bed heater off -M300 S880 P300 ; beep to show its finished -``` +## Contributing -### Advance Belt +When you come up with a useful script for e.g. clearing the print bed, consider contributing it back to the community! -This matches the "Advance Belt" script above. +1. Visit [the repository](https://github.com/smartin015/continuousprint) and click the "Fork" button on the top right to create a fork. +2. Go to [printer_profiles.yaml](https://github.com/smartin015/continuousprint/tree/rc/continuousprint/data/printer_profiles.yaml) and check to see if your printer make and model are present. If they aren't, click the pencil icon on the top right of the file to begin editing. +3. When you're done adding details, save it to a new branch of your fork. +4. Now go to [gcode_scripts.yaml](https://github.com/smartin015/continuousprint/tree/rc/continuousprint/data/gcode_scripts.yaml) and edit it in the same way, adding your gcode and any additional fields. +5. Save this print to the same branch (or create a new one if a matching printer profile already exists). +6. Check one last time that the script names match those provided in your printer profiles `defaults` section, then submit a pull request. **Make sure to do a PR against the `rc` branch, NOT the `master` branch.** -``` -M17 ; enable steppers -G91 ; Set relative for lift -G21 ; Set units to mm -G0 Z300 ; advance bed (Z) to roll off all parts -M18 ; Disable steppers -G90 ; back to absolute positioning -M104 S0; Set Hot-end to 0C (off) -M140 S0; Set bed to 0C (off) -``` From a4e717e2be9b7306242ccf7924e564231413c501 Mon Sep 17 00:00:00 2001 From: Scott Martin Date: Sat, 16 Apr 2022 14:01:27 -0400 Subject: [PATCH 03/20] Switched to state machine for managing CPQ state --- continuousprint/__init__.py | 108 +++++----- continuousprint/driver.py | 349 +++++++++++++++++++-------------- continuousprint/driver_test.py | 321 +++++++++++++++++------------- 3 files changed, 435 insertions(+), 343 deletions(-) diff --git a/continuousprint/__init__.py b/continuousprint/__init__.py index a382a37..d883521 100644 --- a/continuousprint/__init__.py +++ b/continuousprint/__init__.py @@ -13,9 +13,11 @@ import octoprint.filemanager from octoprint.filemanager.util import StreamWrapper from octoprint.filemanager.destinations import FileDestinations +from octoprint.util import RepeatedTimer + from .print_queue import PrintQueue, QueueItem -from .driver import ContinuousPrintDriver +from .driver import ContinuousPrintDriver, Action as DA, Printer as DP QUEUE_KEY = "cp_queue" CLEARING_SCRIPT_KEY = "cp_bed_clearing_script" @@ -83,6 +85,10 @@ def get_settings_defaults(self): d[BED_COOLDOWN_TIMEOUT_KEY] = 60 return d + def _active(self): + return self.d.state != self.d._state_inactive if hasattr(self, "d") else False + + def _rm_temp_files(self): # Clean up any file references from prior runs for path in TEMP_FILES.values(): @@ -109,25 +115,40 @@ def on_after_startup(self): self.q = PrintQueue(self._settings, QUEUE_KEY) self.d = ContinuousPrintDriver( queue=self.q, - finish_script_fn=self.run_finish_script, - clear_bed_fn=self.clear_bed, - start_print_fn=self.start_print, - cancel_print_fn=self.cancel_print, + script_runner=self, logger=self._logger, ) + self.update(DA.DEACTIVATE) # Initializes and passes printer state self._update_driver_settings() self._rm_temp_files() self.next_pause_is_spaghetti = False + + # It's possible to miss events or for some weirdness to occur in conditionals. Adding a watchdog + # timer with a periodic tick ensures that the driver knows what the state of the printer is. + self.watchdog = RepeatedTimer(5.0, lambda: self.update(DA.TICK)) + self.watchdog.start() self._logger.info("Continuous Print Plugin started") + def update(self, a: DA): + # Access current file via `get_current_job` instead of `is_current_file` because the latter may go away soon + # See https://docs.octoprint.org/en/master/modules/printer.html#octoprint.printer.PrinterInterface.is_current_file + # Avoid using payload.get('path') as some events may not express path info. + path = self._printer.get_current_job().get("file", {}).get("name") + pstate = self._printer.get_state_id() + p = DP.BUSY + if pstate == "OPERATIONAL": + p = DP.IDLE + elif pstate == "PAUSED": + p = DP.PAUSED + + if self.d.action(a, p, path): + self._msg(type="reload") # Reload UI when new state is added + # part of EventHandlerPlugin def on_event(self, event, payload): if not hasattr(self, "d"): # Ignore any messages arriving before init return - # Access current file via `get_current_job` instead of `is_current_file` because the latter may go away soon - # See https://docs.octoprint.org/en/master/modules/printer.html#octoprint.printer.PrinterInterface.is_current_file - # Avoid using payload.get('path') as some events may not express path info. current_file = self._printer.get_current_job().get("file", {}).get("name") is_current_path = current_file == self.d.current_path() is_finish_script = current_file == TEMP_FILES[FINISHED_SCRIPT_KEY] @@ -147,23 +168,17 @@ def on_event(self, event, payload): if self._printer.is_current_file(path, sd=False): return self._rm_temp_files() - elif (is_current_path or is_finish_script) and event == Events.PRINT_DONE: - self.d.on_print_success(is_finish_script) - self.paused = False - self._msg(type="reload") # reload UI - elif ( - is_current_path - and event == Events.PRINT_FAILED - and payload["reason"] != "cancelled" - ): + elif event == Events.PRINT_DONE: + self.update(DA.SUCCESS) + elif event == Events.PRINT_FAILED: # Note that cancelled events are already handled directly with Events.PRINT_CANCELLED - self.d.on_print_failed() - self.paused = False - self._msg(type="reload") # reload UI - elif is_current_path and event == Events.PRINT_CANCELLED: - self.d.on_print_cancelled(initiator=payload.get('user', None)) - self.paused = False - self._msg(type="reload") # reload UI + self.update(DA.FAILURE) + elif event == Events.PRINT_CANCELLED: + print(payload.get('user')) + if payload.get('user') is not None: + self.update(DA.DEACTIVATE) + else: + self.update(DA.TICK) elif ( is_current_path and tsd_command is not None @@ -171,40 +186,20 @@ def on_event(self, event, payload): and payload.get("cmd") == "pause" and payload.get("initiator") == "system" ): - self._logger.info( - "Got spaghetti detection event; flagging next pause event for restart" - ) - self.next_pause_is_spaghetti = True + self.update(DA.SPAGHETTI) elif is_current_path and event == Events.PRINT_PAUSED: - self.d.on_print_paused( - is_temp_file=(payload["path"] in TEMP_FILES.values()), - is_spaghetti=self.next_pause_is_spaghetti, - ) - self.next_pause_is_spaghetti = False - self.paused = True - self._msg(type="reload") # reload UI + self.update(DA.TICK) elif is_current_path and event == Events.PRINT_RESUMED: - self.d.on_print_resumed() - self.paused = False - self._msg(type="reload") + self.update(DA.TICK) elif ( event == Events.PRINTER_STATE_CHANGED and self._printer.get_state_id() == "OPERATIONAL" ): - self._msg(type="reload") # reload UI + self.update(DA.TICK) elif event == Events.UPDATED_FILES: self._msg(type="updatefiles") elif event == Events.SETTINGS_UPDATED: self._update_driver_settings() - # Play out actions until printer no longer in a state where we can run commands - # Note that PAUSED state is respected so that gcode can include `@pause` commands. - # See https://docs.octoprint.org/en/master/features/atcommands.html - while ( - self._printer.get_state_id() == "OPERATIONAL" - and self.d.pending_actions() > 0 - ): - self._logger.warning("on_printer_ready") - self.d.on_printer_ready() def _write_temp_gcode(self, key): gcode = self._settings.get([key]) @@ -222,6 +217,7 @@ def run_finish_script(self): self._msg("Print Queue Complete", type="complete") path = self._write_temp_gcode(FINISHED_SCRIPT_KEY) self._printer.select_file(path, sd=False, printAfterSelect=True) + return path def cancel_print(self): self._msg("Print cancelled", type="error") @@ -253,6 +249,7 @@ def clear_bed(self): self.wait_for_bed_cooldown() path = self._write_temp_gcode(CLEARING_SCRIPT_KEY) self._printer.select_file(path, sd=False, printAfterSelect=True) + return path def start_print(self, item, clear_bed=True): self._msg("Starting print: " + item.name) @@ -265,6 +262,7 @@ def start_print(self, item, clear_bed=True): self._msg("File not found: " + item.path, type="error") except InvalidFileType: self._msg("File not gcode: " + item.path, type="error") + return item.path def state_json(self, extra_message=None): # Values are stored json-serialized, so we need to create a json string and inject them into it @@ -279,19 +277,18 @@ def state_json(self, extra_message=None): # IMPORTANT: Non-additive changes to this response string must be released in a MAJOR version bump # (e.g. 1.4.1 -> 2.0.0). resp = '{"active": %s, "status": "%s", "queue": %s%s}' % ( - "true" if hasattr(self, "d") and self.d.active else "false", + "true" if self._active() else "false", "Initializing" if not hasattr(self, "d") else self.d.status, q, extra_message, ) return resp - # Listen for resume from printer ("M118 //action:queuego"), only act if actually paused. #from @grtrenchman + # Listen for resume from printer ("M118 //action:queuego") #from @grtrenchman def resume_action_handler(self, comm, line, action, *args, **kwargs): if not action == "queuego": return - if self.paused: - self.d.set_active() + self.update(DA.ACTIVATE) # Public API method returning the full state of the plugin in JSON format. # See `state_json()` for return values. @@ -309,10 +306,7 @@ def set_active(self): if not Permissions.PLUGIN_CONTINUOUSPRINT_STARTQUEUE.can(): return flask.make_response("Insufficient Rights", 403) self._logger.info("attempt failed due to insufficient permissions.") - self.d.set_active( - flask.request.form["active"] == "true", - printer_ready=(self._printer.get_state_id() == "OPERATIONAL"), - ) + self.update(DA.ACTIVATE if flask.request.form["active"] == "true" else DA.DEACTIVATE) return self.state_json() # PRIVATE API method - may change without warning. @@ -441,7 +435,7 @@ def reset(self): # part of TemplatePlugin def get_template_vars(self): return dict( - cp_enabled=(self.d.active if hasattr(self, "d") else False), + cp_enabled=self._active(), cp_bed_clearing_script=self._settings.get([CLEARING_SCRIPT_KEY]), cp_queue_finished=self._settings.get([FINISHED_SCRIPT_KEY]), cp_restart_on_pause_enabled=self._settings.get_boolean( diff --git a/continuousprint/driver.py b/continuousprint/driver.py index 013fa56..4983370 100644 --- a/continuousprint/driver.py +++ b/continuousprint/driver.py @@ -1,5 +1,18 @@ import time +from enum import Enum, auto +class Action(Enum): + ACTIVATE = auto() + DEACTIVATE = auto() + SUCCESS = auto() + FAILURE = auto() + SPAGHETTI = auto() + TICK = auto() + +class Printer(Enum): + IDLE = auto() + PAUSED = auto() + BUSY = auto() # Inspired by answers at # https://stackoverflow.com/questions/6108819/javascript-timestamp-to-relative-time @@ -16,31 +29,201 @@ class ContinuousPrintDriver: def __init__( self, queue, - finish_script_fn, - clear_bed_fn, - start_print_fn, - cancel_print_fn, + script_runner, logger, ): + self._logger = logger + self.status = None + self._set_status("Initializing") self.q = queue - self.active = False + self.state = self._state_unknown self.retries = 0 - self._logger = logger self.retry_on_pause = False self.max_retries = 0 self.retry_threshold_seconds = 0 self.first_print = True - self.actions = [] + self._runner = script_runner + self._intent = None # Intended file path + self._update_ui = False + + def action(self, a: Action, p: Printer, path: str=None): + self._logger.debug(f"{a.name}, {p.name}, path={path}") + if path is not None: + self._cur_path = path + nxt = self.state(a, p) + if nxt is not None: + self._logger.info(f"{self.state.__name__} -> {nxt.__name__}") + self.state = nxt + self._update_ui = True + + + if self._update_ui: + self._update_ui = False + return True + return False + + def _state_unknown(self, a: Action, p: Printer): + if a == Action.DEACTIVATE: + return self._state_inactive + + def _state_inactive(self, a: Action, p: Printer): + self.retries = 0 + + if a == Action.ACTIVATE: + if p != Printer.IDLE: + return self._state_printing + else: + # TODO "clear bed on startup" setting + return self._state_start_print + + if p == Printer.IDLE: + self._set_status("Inactive (click Start Managing)") + else: + self._set_status("Inactive (active print continues unmanaged)") + + + def _state_start_print(self, a: Action, p: Printer): + if a == Action.DEACTIVATE: + return self._state_inactive + + if p != Printer.IDLE: + self._set_status("Waiting for printer to be ready") + return + + # The next print may not be the *immediately* next print + # e.g. if we skip over a print or start mid-print + idx = self._next_available_idx() + if idx is not None: + p = self.q[idx] + p.start_ts = int(time.time()) + p.end_ts = None + p.retries = self.retries + self.q[idx] = p + self._intent = self._runner.start_print(p) + return self._state_printing + else: + return self._state_inactive + + + def _elapsed(self): + return (time.time() - self.q[self._cur_idx()].start_ts) + + def _state_printing(self, a: Action, p: Printer, elapsed=None): + if a == Action.DEACTIVATE: + return self._state_inactive + elif a == Action.FAILURE: + return self._state_failure + elif a == Action.SPAGHETTI: + elapsed = self._elapsed() + if self.retry_on_pause and elapsed < self.retry_threshold_seconds: + return self._state_spaghetti_recovery + else: + self._set_status( + f"Print paused {timeAgo(elapsed)} into print (over auto-restart threshold of {timeAgo(self.retry_threshold_seconds)}); awaiting user input" + ) + return self._state_paused + elif a == Action.SUCCESS: + return self._state_success + + if p == Printer.BUSY: + idx = self._cur_idx() + if idx is not None: + self._set_status(f"Printing {self.q[idx].name}") + elif p == Printer.PAUSED: + return self._state_paused + elif p == Printer.IDLE: # Idle state without event; assume success + return self._state_success + + def _state_paused(self, a: Action, p: Printer): + self._set_status("Queue paused") + if a == Action.DEACTIVATE or p == Printer.IDLE: + return self._state_inactive + elif p == Printer.BUSY: + return self._state_printing + + def _state_spaghetti_recovery(self, a: Action, p: Printer): + self._set_status(f"Cancelling print (spaghetti seen early in print)") + if p == Printer.PAUSED: + self._runner.cancel_print() + self._intent = None + return self._state_failure + + def _state_failure(self, a: Action, p: Printer): + if p != Printer.IDLE: + return + + if self.retries + 1 < self.max_retries: + self.retries += 1 + return self._state_start_clearing + else: + idx = self._cur_idx() + if idx is not None: + self._complete_item(idx, "failure") + return self._state_inactive + + def _state_success(self, a: Action, p: Printer): + idx = self._cur_idx() + + # Complete prior queue item if that's what we just finished + if idx is not None: + path = self.q[idx].path + if self._intent == path and self._cur_path == path: + self._complete_item(idx, "success") + else: + self._logger.info(f"Current queue item {path} not matching intent {self._intent}, current path {self._cur_path} - no completion") + self.retries = 0 + + # Clear bed if we have a next queue item, otherwise run finishing script + idx = self._next_available_idx() + if idx is not None: + return self._state_start_clearing + else: + return self._state_start_finishing + + def _state_start_clearing(self, a: Action, p: Printer): + if a == Action.DEACTIVATE: + return self._state_inactive + if p != Printer.IDLE: + self._set_status("Waiting for printer to be ready") + return + + self._intent = self._runner.clear_bed() + return self._state_clearing + + def _state_clearing(self, a: Action, p: Printer): + if a == Action.DEACTIVATE: + return self._state_inactive + if p != Printer.IDLE: + return + + self._set_status("Clearing bed") + return self._state_start_print + + def _state_start_finishing(self, a: Action, p: Printer): + if a == Action.DEACTIVATE: + return self._state_inactive + if p != Printer.IDLE: + self._set_status("Waiting for printer to be ready") + return + + self._intent = self._runner.run_finish_script() + return self._state_finishing - self.finish_script_fn = finish_script_fn - self.clear_bed_fn = clear_bed_fn - self.start_print_fn = start_print_fn - self.cancel_print_fn = cancel_print_fn - self._set_status("Initialized (click Start Managing to run the queue)") + def _state_finishing(self, a: Action, p: Printer): + if a == Action.DEACTIVATE: + return self._state_inactive + if p != Printer.IDLE: + return + + self._set_status("Finising up") + + return self._state_inactive def _set_status(self, status): - self.status = status - self._logger.info(status) + if status != self.status: + self._update_ui = True + self.status = status + self._logger.info(status) def set_retry_on_pause( self, enabled, max_retries=3, retry_threshold_seconds=60 * 60 @@ -48,27 +231,10 @@ def set_retry_on_pause( self.retry_on_pause = enabled self.max_retries = max_retries self.retry_threshold_seconds = retry_threshold_seconds - self._logger.info( + self._logger.debug( f"Retry on pause: {enabled} (max_retries {max_retries}, threshold {retry_threshold_seconds}s)" ) - def set_active(self, active=True, printer_ready=True): - if active and not self.active: - self.active = True - self.first_print = True - self.retries = 0 - if not printer_ready: - self._set_status("Waiting for printer to be ready") - else: - self._begin_next_available_print() - self.on_printer_ready() - elif self.active and not active: - self.active = False - if not printer_ready: - self._set_status("Inactive (active prints continue unmanaged)") - else: - self._set_status("Inactive (ready - click Start Managing)") - def _cur_idx(self): for (i, item) in enumerate(self.q): if item.start_ts is not None and item.end_ts is None: @@ -85,126 +251,9 @@ def _next_available_idx(self): return i return None - def _begin_next_available_print(self): - # The next print may not be the *immediately* next print - # e.g. if we skip over a print or start mid-print - idx = self._next_available_idx() - if idx is not None: - p = self.q[idx] - p.start_ts = int(time.time()) - p.end_ts = None - p.retries = self.retries - self.q[idx] = p - if not self.first_print: - self.actions.append(self._clear_bed) - self.actions.append(lambda: self._start_print(p)) - self.first_print = False - else: - self.actions.append(self._finish) - - def _finish(self): - self._set_status("Running finish script") - self.finish_script_fn() - - def _clear_bed(self): - self._set_status("Running bed clearing script") - self.clear_bed_fn() - - def _start_print(self, p): - if self.retries > 0: - self._set_status( - f"Printing {p.name} (attempt {self.retries+1}/{self.max_retries})" - ) - else: - self._set_status(f"Printing {p.name}") - self.start_print_fn(p) - def _complete_item(self, idx, result): + self._logger.debug(f"Completing q[{idx}] - {result}") item = self.q[idx] item.end_ts = int(time.time()) item.result = result self.q[idx] = item # TODO necessary? - - def pending_actions(self): - return len(self.actions) - - def on_printer_ready(self): - if len(self.actions) > 0: - a = self.actions.pop(0) - self._logger.info("Printer ready; performing next action %s" % a.__repr__()) - a() - return True - else: - return False - - def on_print_success(self, is_finish_script=False): - if not self.active: - return - - idx = self._cur_idx() - if idx is not None: - self._complete_item(idx, "success") - - self.retries = 0 - - if is_finish_script: - self.set_active(False) - else: - self.actions.append(self._begin_next_available_print) - - def on_print_failed(self): - if not self.active: - return - self._complete_item(self._cur_idx(), "failure") - self.active = False - self._set_status("Inactive (print failed)") - self.first_print = True - - def on_print_cancelled(self, initiator): - self.first_print = True - if not self.active: - return - - idx = self._cur_idx() - - if initiator is not None: - self._complete_item(idx, f"failure (user {initiator} cancelled)") - self.active = False - self._set_status(f"Inactive (print cancelled by user {initiator})") - elif self.retries + 1 < self.max_retries: - self.retries += 1 - self.actions.append( - self._begin_next_available_print - ) # same print, not finished - else: - self._complete_item(idx, "failure (max retries)") - self.active = False - self._set_status("Inactive (print cancelled with too many retries)") - - def on_print_paused(self, elapsed=None, is_temp_file=False, is_spaghetti=False): - if ( - not self.active - or not self.retry_on_pause - or is_temp_file - or not is_spaghetti - ): - self._set_status("Print paused") - return - - elapsed = elapsed or (time.time() - self.q[self._cur_idx()].start_ts) - if elapsed < self.retry_threshold_seconds: - self._set_status( - "Cancelling print (spaghetti detected {timeAgo(elapsed)} into print)" - ) - self.cancel_print_fn() - # self.actions.append(self.cancel_print_fn) - else: - self._set_status( - f"Print paused {timeAgo(elapsed)} into print (over auto-restart threshold of {timeAgo(self.retry_threshold_seconds)}); awaiting user input" - ) - - def on_print_resumed(self): - # This happens after pause & manual resume - idx = self._cur_idx() - if idx is not None: - self._set_status(f"Printing {self.q[idx].name}") diff --git a/continuousprint/driver_test.py b/continuousprint/driver_test.py index 1d6ca9f..64c8f21 100644 --- a/continuousprint/driver_test.py +++ b/continuousprint/driver_test.py @@ -1,13 +1,22 @@ import unittest from unittest.mock import MagicMock from print_queue import PrintQueue, QueueItem -from driver import ContinuousPrintDriver +from driver import ContinuousPrintDriver, Action as DA, Printer as DP from mock_settings import MockSettings import logging logging.basicConfig(level=logging.DEBUG) +class Runner(): + def __init__(self): + self.run_finish_script = MagicMock() + self.start_print = MagicMock() + self.cancel_print = MagicMock() + self.clear_bed = MagicMock() + + + def setupTestQueueAndDriver(self, num_complete): self.s = MockSettings("q") self.q = PrintQueue(self.s, "q") @@ -25,18 +34,11 @@ def setupTestQueueAndDriver(self, num_complete): ) self.d = ContinuousPrintDriver( queue=self.q, - finish_script_fn=MagicMock(), - start_print_fn=MagicMock(), - cancel_print_fn=MagicMock(), - clear_bed_fn=MagicMock(), + script_runner=Runner(), logger=logging.getLogger(), ) self.d.set_retry_on_pause(True) - - -def flush(d): - while d.pending_actions() > 0: - d.on_printer_ready() + self.d.action(DA.DEACTIVATE, DP.IDLE) class TestQueueManagerFromInitialState(unittest.TestCase): @@ -44,155 +46,194 @@ def setUp(self): setupTestQueueAndDriver(self, 0) def test_activate_not_printing(self): - self.d.set_active() - flush(self.d) - self.d.start_print_fn.assert_called_once() - self.assertEqual(self.d.start_print_fn.call_args[0][0], self.q[0]) + self.d.action(DA.ACTIVATE, DP.IDLE) + self.d.action(DA.TICK, DP.IDLE) + self.d._runner.start_print.assert_called_once() + self.assertEqual(self.d._runner.start_print.call_args[0][0], self.q[0]) + self.assertEqual(self.d.state, self.d._state_printing) def test_activate_already_printing(self): - self.d.set_active(printer_ready=False) - self.d.start_print_fn.assert_not_called() + self.d.action(DA.ACTIVATE, DP.BUSY) + self.d.action(DA.TICK, DP.BUSY) + self.d._runner.start_print.assert_not_called() + self.assertEqual(self.d.state, self.d._state_printing) def test_events_cause_no_action_when_inactive(self): def assert_nocalls(): - self.d.finish_script_fn.assert_not_called() - self.d.start_print_fn.assert_not_called() - - self.d.on_print_success() - assert_nocalls() - self.d.on_print_failed() - assert_nocalls() - self.d.on_print_cancelled(initiator=None) - assert_nocalls() - self.d.on_print_paused(0) - assert_nocalls() - self.d.on_print_resumed() - assert_nocalls() + self.d._runner.run_finish_script.assert_not_called() + self.d._runner.start_print.assert_not_called() + + for p in [DP.IDLE, DP.BUSY, DP.PAUSED]: + for a in [DA.SUCCESS, DA.FAILURE, DA.TICK, DA.DEACTIVATE, DA.SPAGHETTI]: + self.d.action(a, p) + assert_nocalls() + self.assertEqual(self.d.state, self.d._state_inactive) def test_completed_print_not_in_queue(self): - self.d.set_active(printer_ready=False) - self.d.on_print_success() - flush(self.d) + self.d.action(DA.ACTIVATE, DP.BUSY) + self.d.action(DA.SUCCESS, DP.IDLE, "otherprint.gcode") # -> success + self.d.action(DA.TICK, DP.IDLE) # -> start_clearing + + self.assertEqual(self.q[0].end_ts, None) # Queue item not completed since print not in queue + self.assertEqual(self.q[0].result, None) + + self.d.action(DA.TICK, DP.IDLE) # -> clearing + self.d.action(DA.SUCCESS, DP.IDLE) # -> start_print + self.d.action(DA.TICK, DP.IDLE) # -> printing # Non-queue print completion while the driver is active # should kick off a new print from the head of the queue - self.d.start_print_fn.assert_called_once() - self.assertEqual(self.d.start_print_fn.call_args[0][0], self.q[0]) - + self.d._runner.start_print.assert_called_once() + self.assertEqual(self.d._runner.start_print.call_args[0][0], self.q[0]) + def test_completed_first_print(self): - self.d.set_active() - self.d.start_print_fn.reset_mock() - - self.d.on_print_success() - flush(self.d) - self.assertEqual(self.d.first_print, False) - self.d.clear_bed_fn.assert_called_once() - self.d.start_print_fn.assert_called_once() - self.assertEqual(self.d.start_print_fn.call_args[0][0], self.q[1]) - + self.d.action(DA.ACTIVATE, DP.IDLE) # -> start_print + self.d.action(DA.TICK, DP.IDLE) # -> printing + self.d._runner.start_print.reset_mock() + + self.d._intent = self.q[0].path + self.d._cur_path = self.d._intent + + self.d.action(DA.SUCCESS, DP.IDLE, self.d._intent) # -> success + self.d.action(DA.TICK, DP.IDLE) # -> start_clearing + self.d.action(DA.TICK, DP.IDLE) # -> clearing + self.d._runner.clear_bed.assert_called_once() + + self.d.action(DA.SUCCESS, DP.IDLE) # -> start_print + self.d.action(DA.TICK, DP.IDLE) # -> printing + self.d._runner.start_print.assert_called_once() + self.assertEqual(self.d._runner.start_print.call_args[0][0], self.q[1]) + + def test_start_clearing_waits_for_idle(self): + self.d.state = self.d._state_start_clearing + self.d.action(DA.TICK, DP.BUSY) + self.assertEqual(self.d.state, self.d._state_start_clearing) + self.d._runner.clear_bed.assert_not_called() + self.d.action(DA.TICK, DP.PAUSED) + self.assertEqual(self.d.state, self.d._state_start_clearing) + self.d._runner.clear_bed.assert_not_called() class TestQueueManagerPartiallyComplete(unittest.TestCase): def setUp(self): setupTestQueueAndDriver(self, 1) def test_success(self): - self.d.set_active() - self.d.start_print_fn.reset_mock() + self.d.action(DA.ACTIVATE, DP.IDLE) # -> start_print + self.d.action(DA.TICK, DP.IDLE) # -> printing + self.d._runner.start_print.reset_mock() + + self.d._intent = self.q[1].path + self.d._cur_path = self.d._intent - self.d.on_print_success() - flush(self.d) - self.d.start_print_fn.assert_called_once() - self.assertEqual(self.d.start_print_fn.call_args[0][0], self.q[2]) + self.d.action(DA.SUCCESS, DP.IDLE) # -> success + self.d.action(DA.TICK, DP.IDLE) # -> start_clearing + self.d.action(DA.TICK, DP.IDLE) # -> clearing + self.d.action(DA.TICK, DP.IDLE) # -> start_print + self.d.action(DA.TICK, DP.IDLE) # -> printing + self.d._runner.start_print.assert_called_once() + self.assertEqual(self.d._runner.start_print.call_args[0][0], self.q[2]) def test_success_after_queue_prepend_starts_prepended(self): - self.d.set_active() - self.d.start_print_fn.reset_mock() + self.d.action(DA.ACTIVATE, DP.IDLE) # -> start_print + self.d.action(DA.TICK, DP.IDLE) # -> printing + self.d._runner.start_print.reset_mock() n = QueueItem("new", "/new.gco", True) self.q.add([n], idx=0) - self.d.on_print_success() - flush(self.d) - self.d.start_print_fn.assert_called_once - self.assertEqual(self.d.start_print_fn.call_args[0][0], n) + self.d._intent = self.q[1].path + self.d._cur_path = self.d._intent - def test_paused_with_spaghetti_early_triggers_cancel(self): - self.d.set_active() - - self.d.on_print_paused(self.d.retry_threshold_seconds - 1, is_spaghetti=True) - flush(self.d) - self.d.cancel_print_fn.assert_called_once_with() - - def test_paused_manually_early_falls_through(self): - self.d.set_active() + self.d.action(DA.SUCCESS, DP.IDLE) # -> success + self.d.action(DA.TICK, DP.IDLE) # -> start_clearing + self.d.action(DA.TICK, DP.IDLE) # -> clearing + self.d.action(DA.TICK, DP.IDLE) # -> start_print + self.d.action(DA.TICK, DP.IDLE) # -> printing + self.d._runner.start_print.assert_called_once + self.assertEqual(self.d._runner.start_print.call_args[0][0], n) - self.d.on_print_paused(self.d.retry_threshold_seconds - 1, is_spaghetti=False) - flush(self.d) - self.d.cancel_print_fn.assert_not_called() + def test_paused_with_spaghetti_early_triggers_cancel(self): + self.d.action(DA.ACTIVATE, DP.IDLE) # -> start_print + self.d.action(DA.TICK, DP.IDLE) # -> printing + + self.d._elapsed = lambda: 10 + self.d.action(DA.SPAGHETTI, DP.BUSY) # -> spaghetti_recovery + self.d.action(DA.TICK, DP.PAUSED) # -> cancel + failure + self.d._runner.cancel_print.assert_called() + self.assertEqual(self.d.state, self.d._state_failure) + + def test_paused_with_spaghetti_late_waits_for_user(self): + self.d.action(DA.ACTIVATE, DP.IDLE) # -> start_print + self.d.action(DA.TICK, DP.IDLE) # -> printing + + self.d._elapsed = lambda: self.d.retry_threshold_seconds + 1 + self.d.action(DA.SPAGHETTI, DP.BUSY) # -> printing (ignore spaghetti) + self.d.action(DA.TICK, DP.PAUSED) # -> paused + self.d._runner.cancel_print.assert_not_called() + self.assertEqual(self.d.state, self.d._state_paused) + + def test_paused_manually_early_waits_for_user(self): + self.d.action(DA.ACTIVATE, DP.IDLE) # -> start_print + self.d.action(DA.TICK, DP.IDLE) # -> printing + + self.d._elapsed = lambda: 10 + self.d.action(DA.TICK, DP.PAUSED) # -> paused + self.d.action(DA.TICK, DP.PAUSED) # stay in paused state + self.d._runner.cancel_print.assert_not_called() + self.assertEqual(self.d.state, self.d._state_paused) + + def test_paused_manually_late_waits_for_user(self): + self.d.action(DA.ACTIVATE, DP.IDLE) # -> start_print + self.d.action(DA.TICK, DP.IDLE) # -> printing + + self.d._elapsed = lambda: 1000 + self.d.action(DA.TICK, DP.PAUSED) # -> paused + self.d.action(DA.TICK, DP.PAUSED) # stay in paused state + self.d._runner.cancel_print.assert_not_called() + self.assertEqual(self.d.state, self.d._state_paused) def test_paused_on_temp_file_falls_through(self): - self.d.set_active() - self.d.start_print_fn.reset_mock() - self.d.on_print_paused(is_temp_file=True, is_spaghetti=True) - self.d.cancel_print_fn.assert_not_called() - self.assertEqual(self.d.pending_actions(), 0) - - def test_user_cancelled_sets_inactive_and_fails_print(self): - self.d.set_active() - self.d.start_print_fn.reset_mock() - - self.d.on_print_cancelled(initiator="admin") - flush(self.d) - self.d.start_print_fn.assert_not_called() - self.assertEqual(self.d.active, False) - self.assertRegex(self.q[1].result, "^failure.*") - - def test_cancelled_triggers_retry(self): - self.d.set_active() - self.d.start_print_fn.reset_mock() - - self.d.on_print_cancelled(initiator=None) - flush(self.d) - self.d.start_print_fn.assert_called_once() - self.assertEqual(self.d.start_print_fn.call_args[0][0], self.q[1]) - self.assertEqual(self.d.retries, 1) - - def test_set_active_clears_retries(self): + self.d.state = self.d._state_clearing # -> clearing + self.d.action(DA.TICK, DP.PAUSED) + self.d._runner.cancel_print.assert_not_called() + self.assertEqual(self.d.state, self.d._state_clearing) + + self.d.state = self.d._state_finishing # -> finishing + self.d.action(DA.TICK, DP.PAUSED) + self.d._runner.cancel_print.assert_not_called() + self.assertEqual(self.d.state, self.d._state_finishing) + + def test_user_deactivate_sets_inactive(self): + self.d.action(DA.ACTIVATE, DP.IDLE) # -> start_print + self.d.action(DA.TICK, DP.IDLE) # -> printing + self.d._runner.start_print.reset_mock() + + self.d.action(DA.DEACTIVATE, DP.IDLE) # -> inactive + self.assertEqual(self.d.state, self.d._state_inactive) + self.d._runner.start_print.assert_not_called() + self.assertEqual(self.q[1].result, None) + + def test_retry_after_failure(self): + self.d.state = self.d._state_failure + self.d.retries = self.d.max_retries-2 + self.d.action(DA.TICK, DP.IDLE) # Start clearing + self.assertEqual(self.d.retries, self.d.max_retries-1) + self.assertEqual(self.d.state, self.d._state_start_clearing) + + def test_activate_clears_retries(self): self.d.retries = self.d.max_retries - 1 - self.d.set_active() + self.d.action(DA.ACTIVATE, DP.IDLE) # -> start_print self.assertEqual(self.d.retries, 0) - def test_cancelled_with_max_retries_sets_inactive(self): - self.d.set_active() - self.d.start_print_fn.reset_mock() - self.d.retries = self.d.max_retries - - self.d.on_print_cancelled(initiator=None) - flush(self.d) - self.d.start_print_fn.assert_not_called() - self.assertEqual(self.d.active, False) - - def test_paused_late_waits_for_user(self): - self.d.set_active() - self.d.start_print_fn.reset_mock() - - self.d.on_print_paused(self.d.retry_threshold_seconds + 1, is_spaghetti=True) - self.d.start_print_fn.assert_not_called() - - def test_failure_sets_inactive(self): - self.d.set_active() - self.d.start_print_fn.reset_mock() - - self.d.on_print_failed() - flush(self.d) - self.d.start_print_fn.assert_not_called() - self.assertEqual(self.d.active, False) - - def test_resume_sets_status(self): - self.d.set_active() - self.d.start_print_fn.reset_mock() + def test_failure_with_max_retries_sets_inactive(self): + self.d.state = self.d._state_failure + self.d.retries = self.d.max_retries - 1 + self.d.action(DA.TICK, DP.IDLE) # -> inactive + self.assertEqual(self.d.state, self.d._state_inactive) - self.d.on_print_resumed() - self.assertTrue("paused" not in self.d.status.lower()) + def test_resume_from_pause(self): + self.d.state = self.d._state_paused + self.d.action(DA.TICK, DP.BUSY) + self.assertEqual(self.d.state, self.d._state_printing) class TestQueueManagerOnLastPrint(unittest.TestCase): @@ -200,14 +241,22 @@ def setUp(self): setupTestQueueAndDriver(self, 2) def test_completed_last_print(self): - self.d.set_active() - self.d.start_print_fn.reset_mock() - - self.d.on_print_success() - flush(self.d) - self.d.on_print_success(is_finish_script=True) - self.d.finish_script_fn.assert_called_once_with() - self.assertEqual(self.d.active, False) + self.d.action(DA.ACTIVATE, DP.IDLE) # -> start_print + self.d.action(DA.TICK, DP.IDLE) # -> printing + self.d._runner.start_print.reset_mock() + + self.d._intent = self.q[-1].path + self.d._cur_path = self.d._intent + + self.d.action(DA.SUCCESS, DP.IDLE, self.d._intent) # -> success + self.d.action(DA.TICK, DP.IDLE) # -> start_finishing + self.d.action(DA.TICK, DP.IDLE) # -> finishing + self.d._runner.run_finish_script.assert_called() + self.assertEqual(self.d.state, self.d._state_finishing) + + self.d.action(DA.TICK, DP.IDLE) # -> inactive + self.assertEqual(self.d.state, self.d._state_inactive) + if __name__ == "__main__": From 95777fa45c833a808540ef56f2268c1f2b3976c9 Mon Sep 17 00:00:00 2001 From: Scott Martin Date: Sun, 17 Apr 2022 13:27:40 -0400 Subject: [PATCH 04/20] Added basic multi-material selection to UI (not yet hooked up to backend) --- continuousprint/__init__.py | 12 +++++ continuousprint/print_queue.py | 2 + .../static/css/continuousprint.css | 20 +++++++- continuousprint/static/js/continuousprint.js | 2 +- .../static/js/continuousprint_job.test.js | 25 +++++----- .../static/js/continuousprint_queueitem.js | 2 + .../js/continuousprint_queueitem.test.js | 1 + .../static/js/continuousprint_queueset.js | 48 +++++++++++++++++++ .../static/js/continuousprint_viewmodel.js | 5 +- .../js/continuousprint_viewmodel.test.js | 2 +- .../templates/continuousprint_tab.jinja2 | 18 ++++++- 11 files changed, 119 insertions(+), 18 deletions(-) diff --git a/continuousprint/__init__.py b/continuousprint/__init__.py index a382a37..1e4778d 100644 --- a/continuousprint/__init__.py +++ b/continuousprint/__init__.py @@ -30,6 +30,7 @@ BED_COOLDOWN_SCRIPT_KEY = "cp_bed_cooldown_script" BED_COOLDOWN_THRESHOLD_KEY = "bed_cooldown_threshold" BED_COOLDOWN_TIMEOUT_KEY = "bed_cooldown_timeout" +MATERIAL_SELECTION_KEY = "cp_material_selection_enabled" class ContinuousprintPlugin( octoprint.plugin.SettingsPlugin, @@ -105,6 +106,16 @@ def on_after_startup(self): else: self._settings.set([RESTART_ON_PAUSE_KEY], False) + + # SpoolManager plugin isn't required, but does enable material-based printing if it exists + self._spool_manager = self._plugin_manager.get_plugin("SpoolManager") + if self._spool_manager is not None: + self._logger.info(f"SpoolManager found - enabling material selection") + self._settings.set([MATERIAL_SELECTION_KEY], True) + else: + self._settings.set([MATERIAL_SELECTION_KEY], False) + + self._settings.save() self.q = PrintQueue(self._settings, QUEUE_KEY) self.d = ContinuousPrintDriver( @@ -115,6 +126,7 @@ def on_after_startup(self): cancel_print_fn=self.cancel_print, logger=self._logger, ) + self._update_driver_settings() self._rm_temp_files() self.next_pause_is_spaghetti = False diff --git a/continuousprint/print_queue.py b/continuousprint/print_queue.py index 1405d2d..6a11d72 100644 --- a/continuousprint/print_queue.py +++ b/continuousprint/print_queue.py @@ -13,6 +13,7 @@ def __init__( end_ts=None, result=None, job="", + materials=[], run=0, retries=0, ): @@ -24,6 +25,7 @@ def __init__( raise Exception("SD must be bool, got %s" % (type(sd))) self.sd = sd self.job = job + self.materials = materials self.run = run self.start_ts = start_ts self.end_ts = end_ts diff --git a/continuousprint/static/css/continuousprint.css b/continuousprint/static/css/continuousprint.css index 4fe870c..e384088 100644 --- a/continuousprint/static/css/continuousprint.css +++ b/continuousprint/static/css/continuousprint.css @@ -14,7 +14,15 @@ background-color: #222 !important; } -#tab_plugin_continuousprint .accordion-heading, #tab_plugin_continuousprint .queue-row-container { +#tab_plugin_continuousprint .accordion-heading, { + width:100%; + display: flex; + flex-wrap: nowrap; + flex-direction: column; + justify-content: right; + align-items: center; +} +#tab_plugin_continuousprint .queue-row-container { width:100%; display: flex; flex-wrap: nowrap; @@ -107,6 +115,16 @@ border: none; box-shadow: none; } +#tab_plugin_continuousprint .label { + border: 1px gray solid; +} +#tab_plugin_continuousprint div.material_select { + display: flex; + flex-direction: row; + justify-content: space-between; + gap: 1vh; +} + #tab_plugin_continuousprint ::placeholder { font-weight: italic; opacity: 0.7; diff --git a/continuousprint/static/js/continuousprint.js b/continuousprint/static/js/continuousprint.js index 705963b..f7d5916 100644 --- a/continuousprint/static/js/continuousprint.js +++ b/continuousprint/static/js/continuousprint.js @@ -28,7 +28,7 @@ $(function() { "printerStateViewModel", "loginStateViewModel", "filesViewModel", - "settingsViewModel", + "printerProfilesViewModel", ], elements: ["#tab_plugin_continuousprint"] }); diff --git a/continuousprint/static/js/continuousprint_job.test.js b/continuousprint/static/js/continuousprint_job.test.js index 1b07968..1bb5f3d 100644 --- a/continuousprint/static/js/continuousprint_job.test.js +++ b/continuousprint/static/js/continuousprint_job.test.js @@ -9,6 +9,7 @@ const DATA = { idx: 0, job: "testjob", run: 0, + materials: [], start_ts: null, end_ts: null, result: null, @@ -85,18 +86,18 @@ test('as_queue contains all fields and all items in the right order', () => { // Note that item 0 and item 1 sets are interleaved, 3 then 3 etc. // with completed items listed first and runs increasing per full repetition of all items expect(j.as_queue()).toStrictEqual([ - {"end_ts": 101, "job": "test", "name": "item 0", "path": "item.gcode", "result": "success", "retries": 0, "run": 0, "sd": false, "start_ts": 100}, - {"end_ts": null, "job": "test", "name": "item 0", "path": "item.gcode", "result": null, "retries": 0, "run": 0, "sd": false, "start_ts": null}, - {"end_ts": null, "job": "test", "name": "item 0", "path": "item.gcode", "result": null, "retries": 0, "run": 0, "sd": false, "start_ts": null}, - {"end_ts": 101, "job": "test", "name": "item 1", "path": "item.gcode", "result": "success", "retries": 0, "run": 0, "sd": false, "start_ts": 100}, - {"end_ts": null, "job": "test", "name": "item 1", "path": "item.gcode", "result": null, "retries": 0, "run": 0, "sd": false, "start_ts": null}, - {"end_ts": null, "job": "test", "name": "item 1", "path": "item.gcode", "result": null, "retries": 0, "run": 0, "sd": false, "start_ts": null}, - {"end_ts": null, "job": "test", "name": "item 0", "path": "item.gcode", "result": null, "retries": 0, "run": 1, "sd": false, "start_ts": null}, - {"end_ts": null, "job": "test", "name": "item 0", "path": "item.gcode", "result": null, "retries": 0, "run": 1, "sd": false, "start_ts": null}, - {"end_ts": null, "job": "test", "name": "item 0", "path": "item.gcode", "result": null, "retries": 0, "run": 1, "sd": false, "start_ts": null}, - {"end_ts": null, "job": "test", "name": "item 1", "path": "item.gcode", "result": null, "retries": 0, "run": 1, "sd": false, "start_ts": null}, - {"end_ts": null, "job": "test", "name": "item 1", "path": "item.gcode", "result": null, "retries": 0, "run": 1, "sd": false, "start_ts": null}, - {"end_ts": null, "job": "test", "name": "item 1", "path": "item.gcode", "result": null, "retries": 0, "run": 1, "sd": false, "start_ts": null} + {"end_ts": 101, "job": "test", "materials": [], "name": "item 0", "path": "item.gcode", "result": "success", "retries": 0, "run": 0, "sd": false, "start_ts": 100}, + {"end_ts": null, "job": "test", "materials": [], "name": "item 0", "path": "item.gcode", "result": null, "retries": 0, "run": 0, "sd": false, "start_ts": null}, + {"end_ts": null, "job": "test", "materials": [], "name": "item 0", "path": "item.gcode", "result": null, "retries": 0, "run": 0, "sd": false, "start_ts": null}, + {"end_ts": 101, "job": "test", "materials": [], "name": "item 1", "path": "item.gcode", "result": "success", "retries": 0, "run": 0, "sd": false, "start_ts": 100}, + {"end_ts": null, "job": "test", "materials": [], "name": "item 1", "path": "item.gcode", "result": null, "retries": 0, "run": 0, "sd": false, "start_ts": null}, + {"end_ts": null, "job": "test", "materials": [], "name": "item 1", "path": "item.gcode", "result": null, "retries": 0, "run": 0, "sd": false, "start_ts": null}, + {"end_ts": null, "job": "test", "materials": [], "name": "item 0", "path": "item.gcode", "result": null, "retries": 0, "run": 1, "sd": false, "start_ts": null}, + {"end_ts": null, "job": "test", "materials": [], "name": "item 0", "path": "item.gcode", "result": null, "retries": 0, "run": 1, "sd": false, "start_ts": null}, + {"end_ts": null, "job": "test", "materials": [], "name": "item 0", "path": "item.gcode", "result": null, "retries": 0, "run": 1, "sd": false, "start_ts": null}, + {"end_ts": null, "job": "test", "materials": [], "name": "item 1", "path": "item.gcode", "result": null, "retries": 0, "run": 1, "sd": false, "start_ts": null}, + {"end_ts": null, "job": "test", "materials": [], "name": "item 1", "path": "item.gcode", "result": null, "retries": 0, "run": 1, "sd": false, "start_ts": null}, + {"end_ts": null, "job": "test", "materials": [], "name": "item 1", "path": "item.gcode", "result": null, "retries": 0, "run": 1, "sd": false, "start_ts": null} ]); }); diff --git a/continuousprint/static/js/continuousprint_queueitem.js b/continuousprint/static/js/continuousprint_queueitem.js index 0fcf890..48bb78a 100644 --- a/continuousprint/static/js/continuousprint_queueitem.js +++ b/continuousprint/static/js/continuousprint_queueitem.js @@ -17,6 +17,7 @@ function CPQueueItem(data) { self.sd = data.sd; self.job = ko.observable(data.job); self.run = ko.observable(data.run); + self.materials = ko.observable(data.materials || []); self.changed = ko.observable(data.changed || false); self.start_ts = ko.observable(data.start_ts || null); self.end_ts = ko.observable(data.end_ts || null); @@ -80,6 +81,7 @@ function CPQueueItem(data) { sd: self.sd, job: self.job(), run: self.run(), + materials: self.materials(), start_ts: self.start_ts(), end_ts: self.end_ts(), result: self._result(), // Don't propagate default strings diff --git a/continuousprint/static/js/continuousprint_queueitem.test.js b/continuousprint/static/js/continuousprint_queueitem.test.js index c881623..31844f4 100644 --- a/continuousprint/static/js/continuousprint_queueitem.test.js +++ b/continuousprint/static/js/continuousprint_queueitem.test.js @@ -5,6 +5,7 @@ const DATA = { path: "item.gcode", sd: false, job: "testjob", + materials: [], run: 0, start_ts: null, end_ts: null, diff --git a/continuousprint/static/js/continuousprint_queueset.js b/continuousprint/static/js/continuousprint_queueset.js index 1864037..279720e 100644 --- a/continuousprint/static/js/continuousprint_queueset.js +++ b/continuousprint/static/js/continuousprint_queueset.js @@ -31,6 +31,42 @@ function CPQueueSet(items) { } return false; }); + + self._textColorFromBackground = function(rrggbb) { + // https://stackoverflow.com/a/12043228 + var rgb = parseInt(rrggbb, 16); // convert rrggbb to decimal + var r = (rgb >> 16) & 0xff; // extract red + var g = (rgb >> 8) & 0xff; // extract green + var b = (rgb >> 0) & 0xff; // extract blue + var luma = 0.2126 * r + 0.7152 * g + 0.0722 * b; // per ITU-R BT.709 + return (luma >= 128) ? "#000000" : "#FFFFFF"; + } + self._materialShortName = function(m) { + m = m.trim().toUpperCase(); + if (m === "PETG") { + return "G"; + } + return m[0]; + } + + self.materials = ko.computed(function() { + let result = []; + let mats = self.items()[0]; + if (mats !== undefined) { + mats = mats.materials() + } + for (let i of mats) { + let split = i.split("_"); + let bg = split[1]; + result.push({ + shortName: self._materialShortName(split[0]), + color: self._textColorFromBackground(bg), + bgColor: "#" + bg, + key: i, + }); + } + return result; + }); self.length = ko.computed(function() {return self.items().length;}); self.name = ko.computed(function() {return self.items()[0].name;}); self.count = ko.computed(function() { @@ -125,6 +161,18 @@ function CPQueueSet(items) { self.items(items); } } + self.set_material = function(t, v) { + const items = self.items(); + for (let i of items) { + let mats = i.materials(); + while (t >= mats.length) { + mats.push(null); + } + mats[t] = v; + i.materials(mats); + } + self.items(items); + } } try { diff --git a/continuousprint/static/js/continuousprint_viewmodel.js b/continuousprint/static/js/continuousprint_viewmodel.js index 1e765c5..fcdc13d 100644 --- a/continuousprint/static/js/continuousprint_viewmodel.js +++ b/continuousprint/static/js/continuousprint_viewmodel.js @@ -49,7 +49,8 @@ function CPViewModel(parameters) { self.printerState = parameters[0]; self.loginState = parameters[1]; self.files = parameters[2]; - self.settings = parameters[3]; // (smartin015@) IDK why this is a dependency + self.printerProfiles = parameters[3]; + self.extruders = ko.computed(function() { return self.printerProfiles.currentProfileData().extruder.count(); }); self.api = parameters[4] || new CPAPI(); // These are used in the jinja template @@ -190,7 +191,7 @@ function CPViewModel(parameters) { }); self._setState = function(state) { - self.log.info(`[${self.PLUGIN_ID}] updating jobs:`, state); + self.log.info(`[${self.PLUGIN_ID}] updating jobs (len ${state.queue.length})`); self._updateJobs(state.queue); self.active(state.active); self.status(state.status); diff --git a/continuousprint/static/js/continuousprint_viewmodel.test.js b/continuousprint/static/js/continuousprint_viewmodel.test.js index 19704a5..44dc923 100644 --- a/continuousprint/static/js/continuousprint_viewmodel.test.js +++ b/continuousprint/static/js/continuousprint_viewmodel.test.js @@ -9,7 +9,7 @@ function mocks(filename="test.gcode") { }, {}, // loginState only used in continuousprint.js {onServerDisconnect: jest.fn(), onServerConnect: jest.fn()}, - {}, // settings apparently unused + {currentProfileData: () => {return {extruder: {count: () => 1}}}}, {assign: jest.fn(), getState: jest.fn(), setActive: jest.fn()}, ]; } diff --git a/continuousprint/templates/continuousprint_tab.jinja2 b/continuousprint/templates/continuousprint_tab.jinja2 index a1febbc..9b6406b 100644 --- a/continuousprint/templates/continuousprint_tab.jinja2 +++ b/continuousprint/templates/continuousprint_tab.jinja2 @@ -55,11 +55,13 @@
-

+ + +

@@ -72,11 +74,25 @@
+
+
Materials:
+
+
+ + +
+
+ From 25f0550034ec0bdb5c8da89260f3741b59e23ab9 Mon Sep 17 00:00:00 2001 From: Scott Martin Date: Sun, 17 Apr 2022 13:34:49 -0400 Subject: [PATCH 05/20] Cleanup and bump version to 1.6.0rc1 --- continuousprint/__init__.py | 9 ++------- setup.py | 2 +- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/continuousprint/__init__.py b/continuousprint/__init__.py index ffc1da4..85eece7 100644 --- a/continuousprint/__init__.py +++ b/continuousprint/__init__.py @@ -20,7 +20,6 @@ from .driver import ContinuousPrintDriver QUEUE_KEY = "cp_queue" -PRINTER_PROFILE_KEY = "cp_printer_profile" CLEARING_SCRIPT_KEY = "cp_bed_clearing_script" FINISHED_SCRIPT_KEY = "cp_queue_finished_script" TEMP_FILES = dict( @@ -64,13 +63,9 @@ def get_settings_defaults(self): d = {} d[QUEUE_KEY] = "[]" - d[PRINTER_PROFILE_KEY] = "Generic" + d[CLEARING_SCRIPT_KEY] = "" + d[FINISHED_SCRIPT_KEY] = "" - profile = self._printer_profile_manager.get_current() - if profile is not None: - print("Printer:", profile['model'], "-", profile['name']) - print("TODO match profile on default init") - for s in self._gcode_scripts: name = s['name'] gcode = s['gcode'] diff --git a/setup.py b/setup.py index cb9dd91..dc28d7a 100644 --- a/setup.py +++ b/setup.py @@ -14,7 +14,7 @@ plugin_name = "continuousprint" # The plugin's version. Can be overwritten within OctoPrint's internal data via __plugin_version__ in the plugin module -plugin_version = "1.5.0rc3" +plugin_version = "1.6.0rc1" # The plugin's description. Can be overwritten within OctoPrint's internal data via __plugin_description__ in the plugin # module From 3f6a822aa1649464c927fe4f77a16e573a14b045 Mon Sep 17 00:00:00 2001 From: Scott Martin Date: Sun, 17 Apr 2022 15:58:33 -0400 Subject: [PATCH 06/20] Add data/ folder to setup.py, manifest install configs --- MANIFEST.in | 1 + setup.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/MANIFEST.in b/MANIFEST.in index 53c7cad..ef6ed2a 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -2,3 +2,4 @@ include README.md recursive-include continuousprint/templates * recursive-include continuousprint/translations * recursive-include continuousprint/static * +recursive-include continuousprint/data * diff --git a/setup.py b/setup.py index dc28d7a..87b4ac1 100644 --- a/setup.py +++ b/setup.py @@ -43,7 +43,7 @@ # already be installed automatically if they exist. Note that if you add something here you'll also need to update # MANIFEST.in to match to ensure that python setup.py sdist produces a source distribution that contains all your # files. This is sadly due to how python's setup.py works, see also http://stackoverflow.com/a/14159430/2028598 -plugin_additional_data = [] +plugin_additional_data = ["continuousprint/data"] # Any additional python packages you need to install with your plugin that are not contained in .* plugin_additional_packages = [] From d2ea6c7091f0168a7679a238cb1fc2f5bdbc585c Mon Sep 17 00:00:00 2001 From: Scott Martin Date: Sun, 17 Apr 2022 16:14:45 -0400 Subject: [PATCH 07/20] Update setup.py --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 87b4ac1..58999df 100644 --- a/setup.py +++ b/setup.py @@ -14,7 +14,7 @@ plugin_name = "continuousprint" # The plugin's version. Can be overwritten within OctoPrint's internal data via __plugin_version__ in the plugin module -plugin_version = "1.6.0rc1" +plugin_version = "1.6.0rc3" # The plugin's description. Can be overwritten within OctoPrint's internal data via __plugin_description__ in the plugin # module From e27b791fbf41e3408b9c5220b6bc5d33e2ab5734 Mon Sep 17 00:00:00 2001 From: Scott Martin Date: Sun, 17 Apr 2022 16:14:54 -0400 Subject: [PATCH 08/20] Update setup.py --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 58999df..e78cb24 100644 --- a/setup.py +++ b/setup.py @@ -14,7 +14,7 @@ plugin_name = "continuousprint" # The plugin's version. Can be overwritten within OctoPrint's internal data via __plugin_version__ in the plugin module -plugin_version = "1.6.0rc3" +plugin_version = "1.6.0rc2" # The plugin's description. Can be overwritten within OctoPrint's internal data via __plugin_description__ in the plugin # module From 1032210e3ed723a8989a5a682e66add30734276e Mon Sep 17 00:00:00 2001 From: Scott Martin Date: Sun, 17 Apr 2022 16:16:09 -0400 Subject: [PATCH 09/20] Update setup.py --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index e78cb24..58999df 100644 --- a/setup.py +++ b/setup.py @@ -14,7 +14,7 @@ plugin_name = "continuousprint" # The plugin's version. Can be overwritten within OctoPrint's internal data via __plugin_version__ in the plugin module -plugin_version = "1.6.0rc2" +plugin_version = "1.6.0rc3" # The plugin's description. Can be overwritten within OctoPrint's internal data via __plugin_description__ in the plugin # module From 254e0a1e74dcc806cfead815c409f75e60dfa55b Mon Sep 17 00:00:00 2001 From: Scott Martin Date: Mon, 18 Apr 2022 20:51:17 -0400 Subject: [PATCH 10/20] Optimistic commit - persisted selections although dropdowns not updated --- continuousprint/__init__.py | 1 + continuousprint/print_queue.py | 1 + .../static/js/continuousprint_api.js | 4 ++++ .../static/js/continuousprint_queueset.js | 17 +++++++++++--- .../static/js/continuousprint_viewmodel.js | 22 +++++++++++++++++++ .../templates/continuousprint_tab.jinja2 | 12 +++++----- 6 files changed, 48 insertions(+), 9 deletions(-) diff --git a/continuousprint/__init__.py b/continuousprint/__init__.py index 875326d..a0b7063 100644 --- a/continuousprint/__init__.py +++ b/continuousprint/__init__.py @@ -357,6 +357,7 @@ def assign(self): path=i["path"], sd=i["sd"], job=i["job"], + materials=i["materials"], run=i["run"], start_ts=i.get("start_ts"), end_ts=i.get("end_ts"), diff --git a/continuousprint/print_queue.py b/continuousprint/print_queue.py index 6a11d72..515e0b1 100644 --- a/continuousprint/print_queue.py +++ b/continuousprint/print_queue.py @@ -74,6 +74,7 @@ def _load(self): end_ts=v.get("end_ts"), result=v.get("result"), job=v.get("job"), + materials=v.get("materials", []), run=v.get("run"), retries=v.get("retries", 0), ) diff --git a/continuousprint/static/js/continuousprint_api.js b/continuousprint/static/js/continuousprint_api.js index fc82aa5..9343487 100644 --- a/continuousprint/static/js/continuousprint_api.js +++ b/continuousprint/static/js/continuousprint_api.js @@ -25,6 +25,10 @@ class CPAPI { setActive(active, cb) { this._call(this.BASE + "set_active", cb, "POST", {active}); } + + getSpoolManagerState(cb) { + this._call("plugin/SpoolManager/loadSpoolsByQuery?selectedPageSize=25&from=0&to=25&sortColumn=displayName&sortOrder=desc&filterName=&materialFilter=all&vendorFilter=all&colorFilter=all", cb, "GET"); + } } try { diff --git a/continuousprint/static/js/continuousprint_queueset.js b/continuousprint/static/js/continuousprint_queueset.js index 279720e..fb9870a 100644 --- a/continuousprint/static/js/continuousprint_queueset.js +++ b/continuousprint/static/js/continuousprint_queueset.js @@ -34,7 +34,7 @@ function CPQueueSet(items) { self._textColorFromBackground = function(rrggbb) { // https://stackoverflow.com/a/12043228 - var rgb = parseInt(rrggbb, 16); // convert rrggbb to decimal + var rgb = parseInt(rrggbb.substr(1), 16); // convert rrggbb to decimal var r = (rgb >> 16) & 0xff; // extract red var g = (rgb >> 8) & 0xff; // extract green var b = (rgb >> 0) & 0xff; // extract blue @@ -56,12 +56,23 @@ function CPQueueSet(items) { mats = mats.materials() } for (let i of mats) { + if (i === null || i === "Any") { + result.push({ + title: "any", + shortName: " ", + color: "transparent", + bgColor: "transparent", + key: i, + }); + continue; + } let split = i.split("_"); - let bg = split[1]; + let bg = split[2] || ""; result.push({ + title: i.replaceAll("_", " "), shortName: self._materialShortName(split[0]), color: self._textColorFromBackground(bg), - bgColor: "#" + bg, + bgColor: bg, key: i, }); } diff --git a/continuousprint/static/js/continuousprint_viewmodel.js b/continuousprint/static/js/continuousprint_viewmodel.js index fcdc13d..2aabfba 100644 --- a/continuousprint/static/js/continuousprint_viewmodel.js +++ b/continuousprint/static/js/continuousprint_viewmodel.js @@ -60,6 +60,22 @@ function CPViewModel(parameters) { self.jobs = ko.observableArray([]); self.selected = ko.observable(null); + try { + + self.materials = ko.observable([]); + self.api.getSpoolManagerState(function(resp) { + console.log(resp); + let result = {}; + for (let spool of resp.allSpools) { + let k = `${spool.material}_${spool.colorName}_#${spool.color.substring(1)}`; + result[k] = {value: k, text: `${spool.material} (${spool.colorName})`}; + } + self.materials(Object.values(result)); + }); + + } catch (e) { console.error(e);} + + self.isSelected = function(j=null, q=null) { j = self._resolve(j); q = self._resolve(q); @@ -135,6 +151,7 @@ function CPViewModel(parameters) { for (let j of self.jobs()) { q = q.concat(j.as_queue()); } + console.log(q); self.api.assign(q, self._setState); }); @@ -283,6 +300,11 @@ function CPViewModel(parameters) { self._updateQueue(); }); + self.setMaterial = _ecatch("setMaterial", function(vm, idx, mat) { + vm.set_material(idx, mat); + self._updateQueue(); + }); + self.sortStart = _ecatch("sortStart", function(evt) { // Faking server disconnect allows us to disable the default whole-page // file drag and drop behavior. diff --git a/continuousprint/templates/continuousprint_tab.jinja2 b/continuousprint/templates/continuousprint_tab.jinja2 index 9b6406b..b5f0c51 100644 --- a/continuousprint/templates/continuousprint_tab.jinja2 +++ b/continuousprint/templates/continuousprint_tab.jinja2 @@ -60,7 +60,7 @@

- +

@@ -84,11 +84,11 @@
- + + + +
From 382db6c6fff149260af6ccac658484df53b71839 Mon Sep 17 00:00:00 2001 From: Scott Martin Date: Tue, 19 Apr 2022 10:58:06 -0400 Subject: [PATCH 11/20] Added spool manager blocking logic and unit tests, also fixed drop down selection --- continuousprint/__init__.py | 32 +++++++-- continuousprint/driver.py | 20 +++++- continuousprint/driver_test.py | 71 +++++++++++++++++-- .../static/js/continuousprint_queueset.js | 2 +- .../templates/continuousprint_tab.jinja2 | 4 +- 5 files changed, 114 insertions(+), 15 deletions(-) diff --git a/continuousprint/__init__.py b/continuousprint/__init__.py index a0b7063..8c4a935 100644 --- a/continuousprint/__init__.py +++ b/continuousprint/__init__.py @@ -113,14 +113,16 @@ def on_after_startup(self): # SpoolManager plugin isn't required, but does enable material-based printing if it exists - self._spool_manager = self._plugin_manager.get_plugin("SpoolManager") - if self._spool_manager is not None: + # Code based loosely on https://github.com/OllisGit/OctoPrint-PrintJobHistory/ (see _getPluginInformation) + smplugin = self._plugin_manager.plugins.get("SpoolManager") + if smplugin is not None and smplugin.enabled: + self._spool_manager = smplugin.implementation self._logger.info(f"SpoolManager found - enabling material selection") self._settings.set([MATERIAL_SELECTION_KEY], True) else: + self._spool_manager = None self._settings.set([MATERIAL_SELECTION_KEY], False) - self._settings.save() self.q = PrintQueue(self._settings, QUEUE_KEY) self.d = ContinuousPrintDriver( @@ -151,9 +153,17 @@ def update(self, a: DA): elif pstate == "PAUSED": p = DP.PAUSED - if self.d.action(a, p, path): + materials = [] + if self._spool_manager is not None: + # We need *all* selected spools for all tools, so we must look it up from the plugin itself + # (event payload also excludes color hex string which is needed for our identifiers) + materials = self._spool_manager.api_getSelectedSpoolInformations() + materials = [f"{m['material']}_{m['colorName']}_{m['color']}" if m is not None else None for m in materials] + + if self.d.action(a, p, path, materials): self._msg(type="reload") # Reload UI when new state is added + # part of EventHandlerPlugin def on_event(self, event, payload): if not hasattr(self, "d"): # Ignore any messages arriving before init @@ -163,11 +173,19 @@ def on_event(self, event, payload): is_current_path = current_file == self.d.current_path() is_finish_script = current_file == TEMP_FILES[FINISHED_SCRIPT_KEY] + # Try to fetch plugin-specific events, defaulting to None otherwise + # This custom event is only defined when OctoPrint-TheSpaghettiDetective plugin is installed. - # try to fetch the attribute but default to None tsd_command = getattr( octoprint.events.Events, "PLUGIN_THESPAGHETTIDETECTIVE_COMMAND", None ) + # This event is only defined when OctoPrint-SpoolManager plugin is installed. + spool_selected = getattr( + octoprint.events.Events, "PLUGIN__SPOOLMANAGER_SPOOL_SELECTED", None + ) + spool_deselected = getattr( + octoprint.events.Events, "PLUGIN__SPOOLMANAGER_SPOOL_DESELECTED", None + ) if event == Events.METADATA_ANALYSIS_FINISHED: # OctoPrint analysis writes to the printing file - we must remove @@ -197,6 +215,10 @@ def on_event(self, event, payload): and payload.get("initiator") == "system" ): self.update(DA.SPAGHETTI) + elif (spool_selected is not None and event == spool_selected): + self.update(DA.TICK) + elif (spool_deselected is not None and event == spool_deselected): + self.update(DA.TICK) elif is_current_path and event == Events.PRINT_PAUSED: self.update(DA.TICK) elif is_current_path and event == Events.PRINT_RESUMED: diff --git a/continuousprint/driver.py b/continuousprint/driver.py index 4983370..0af05cd 100644 --- a/continuousprint/driver.py +++ b/continuousprint/driver.py @@ -45,11 +45,15 @@ def __init__( self._runner = script_runner self._intent = None # Intended file path self._update_ui = False + self._cur_path = None + self._cur_materials = [] - def action(self, a: Action, p: Printer, path: str=None): - self._logger.debug(f"{a.name}, {p.name}, path={path}") + def action(self, a: Action, p: Printer, path: str=None, materials: list=[]): + self._logger.debug(f"{a.name}, {p.name}, path={path}, materials={materials}") if path is not None: self._cur_path = path + if len(materials) > 0: + self._cur_materials = materials nxt = self.state(a, p) if nxt is not None: self._logger.info(f"{self.state.__name__} -> {nxt.__name__}") @@ -90,6 +94,16 @@ def _state_start_print(self, a: Action, p: Printer): self._set_status("Waiting for printer to be ready") return + # Block until we have the right materials loaded (if required) + item = self.q[self._next_available_idx()] + for i, im in enumerate(item.materials): + if im is None: # No constraint + continue + cur = self._cur_materials[i] if i < len(self._cur_materials) else None + if im != cur: + self._set_status(f"Waiting for spool {im} in tool {i} (currently: {cur})") + return + # The next print may not be the *immediately* next print # e.g. if we skip over a print or start mid-print idx = self._next_available_idx() @@ -103,7 +117,7 @@ def _state_start_print(self, a: Action, p: Printer): return self._state_printing else: return self._state_inactive - + def _elapsed(self): return (time.time() - self.q[self._cur_idx()].start_ts) diff --git a/continuousprint/driver_test.py b/continuousprint/driver_test.py index 64c8f21..999b306 100644 --- a/continuousprint/driver_test.py +++ b/continuousprint/driver_test.py @@ -17,7 +17,7 @@ def __init__(self): -def setupTestQueueAndDriver(self, num_complete): +def setupTestQueueAndDriver(self, num_complete=0): self.s = MockSettings("q") self.q = PrintQueue(self.s, "q") self.q.assign( @@ -41,7 +41,7 @@ def setupTestQueueAndDriver(self, num_complete): self.d.action(DA.DEACTIVATE, DP.IDLE) -class TestQueueManagerFromInitialState(unittest.TestCase): +class TestFromInitialState(unittest.TestCase): def setUp(self): setupTestQueueAndDriver(self, 0) @@ -113,7 +113,7 @@ def test_start_clearing_waits_for_idle(self): self.assertEqual(self.d.state, self.d._state_start_clearing) self.d._runner.clear_bed.assert_not_called() -class TestQueueManagerPartiallyComplete(unittest.TestCase): +class TestPartiallyComplete(unittest.TestCase): def setUp(self): setupTestQueueAndDriver(self, 1) @@ -236,7 +236,7 @@ def test_resume_from_pause(self): self.assertEqual(self.d.state, self.d._state_printing) -class TestQueueManagerOnLastPrint(unittest.TestCase): +class TestOnLastPrint(unittest.TestCase): def setUp(self): setupTestQueueAndDriver(self, 2) @@ -257,7 +257,70 @@ def test_completed_last_print(self): self.d.action(DA.TICK, DP.IDLE) # -> inactive self.assertEqual(self.d.state, self.d._state_inactive) +class TestMaterialConstraints(unittest.TestCase): + def setUp(self): + setupTestQueueAndDriver(self) + + def _setItemMaterials(self, m): + self.q.assign([ + QueueItem("foo", "/foo.gcode", True, materials=m) + ]) + + def test_empty(self): + self._setItemMaterials([]) + self.d.action(DA.ACTIVATE, DP.IDLE) + self.d.action(DA.TICK, DP.IDLE) + self.d._runner.start_print.assert_called() + self.assertEqual(self.d.state, self.d._state_printing) + + def test_none(self): + self._setItemMaterials([None]) + self.d.action(DA.ACTIVATE, DP.IDLE) + self.d.action(DA.TICK, DP.IDLE) + self.d._runner.start_print.assert_called() + self.assertEqual(self.d.state, self.d._state_printing) + + def test_tool1mat_none(self): + self._setItemMaterials(["tool1mat"]) + self.d.action(DA.ACTIVATE, DP.IDLE) + self.d.action(DA.TICK, DP.IDLE) + self.d._runner.start_print.assert_not_called() + self.assertEqual(self.d.state, self.d._state_start_print) + + def test_tool1mat_wrong(self): + self._setItemMaterials(["tool1mat"]) + self.d.action(DA.ACTIVATE, DP.IDLE) + self.d.action(DA.TICK, DP.IDLE, materials=["tool0bad"]) + self.d._runner.start_print.assert_not_called() + self.assertEqual(self.d.state, self.d._state_start_print) + def test_tool1mat_ok(self): + self._setItemMaterials(["tool1mat"]) + self.d.action(DA.ACTIVATE, DP.IDLE) + self.d.action(DA.TICK, DP.IDLE, materials=["tool1mat"]) + self.d._runner.start_print.assert_called() + self.assertEqual(self.d.state, self.d._state_printing) + + def test_tool1mat_ok(self): + self._setItemMaterials([None, "tool2mat"]) + self.d.action(DA.ACTIVATE, DP.IDLE) + self.d.action(DA.TICK, DP.IDLE, materials=[None, "tool2mat"]) + self.d._runner.start_print.assert_called() + self.assertEqual(self.d.state, self.d._state_printing) + + def test_tool1mat_tool2mat_ok(self): + self._setItemMaterials(["tool1mat", "tool2mat"]) + self.d.action(DA.ACTIVATE, DP.IDLE) + self.d.action(DA.TICK, DP.IDLE, materials=["tool1mat", "tool2mat"]) + self.d._runner.start_print.assert_called() + self.assertEqual(self.d.state, self.d._state_printing) + + def test_tool1mat_tool2mat_reversed(self): + self._setItemMaterials(["tool1mat", "tool2mat"]) + self.d.action(DA.ACTIVATE, DP.IDLE) + self.d.action(DA.TICK, DP.IDLE, materials=["tool2mat", "tool1mat"]) + self.d._runner.start_print.assert_not_called() + self.assertEqual(self.d.state, self.d._state_start_print) if __name__ == "__main__": unittest.main() diff --git a/continuousprint/static/js/continuousprint_queueset.js b/continuousprint/static/js/continuousprint_queueset.js index fb9870a..1f690da 100644 --- a/continuousprint/static/js/continuousprint_queueset.js +++ b/continuousprint/static/js/continuousprint_queueset.js @@ -56,7 +56,7 @@ function CPQueueSet(items) { mats = mats.materials() } for (let i of mats) { - if (i === null || i === "Any") { + if (i === null || i === "") { result.push({ title: "any", shortName: " ", diff --git a/continuousprint/templates/continuousprint_tab.jinja2 b/continuousprint/templates/continuousprint_tab.jinja2 index b5f0c51..5ffc644 100644 --- a/continuousprint/templates/continuousprint_tab.jinja2 +++ b/continuousprint/templates/continuousprint_tab.jinja2 @@ -52,7 +52,7 @@
-
+
@@ -84,7 +84,7 @@
- From 34065c429ebf0697e142d83859f47cae5b1f6881 Mon Sep 17 00:00:00 2001 From: Scott Martin Date: Tue, 19 Apr 2022 11:35:32 -0400 Subject: [PATCH 12/20] Added material selection documentation and visibility handling when SpoolManager isn't installed --- continuousprint/__init__.py | 1 + .../static/css/continuousprint.css | 5 +++ .../static/js/continuousprint_viewmodel.js | 6 ---- .../templates/continuousprint_settings.jinja2 | 16 +++++++++ .../templates/continuousprint_tab.jinja2 | 26 ++++++++------ docs/cpq_material_selection.png | Bin 0 -> 49564 bytes docs/material-selection.md | 34 ++++++++++++++++++ mkdocs.yml | 1 + 8 files changed, 72 insertions(+), 17 deletions(-) create mode 100644 docs/cpq_material_selection.png create mode 100644 docs/material-selection.md diff --git a/continuousprint/__init__.py b/continuousprint/__init__.py index 8c4a935..8db4edc 100644 --- a/continuousprint/__init__.py +++ b/continuousprint/__init__.py @@ -83,6 +83,7 @@ def get_settings_defaults(self): d[BED_COOLDOWN_SCRIPT_KEY] = "; Put script to run before bed cools here\n" d[BED_COOLDOWN_THRESHOLD_KEY] = 30 d[BED_COOLDOWN_TIMEOUT_KEY] = 60 + d[MATERIAL_SELECTION_KEY] = False return d def _active(self): diff --git a/continuousprint/static/css/continuousprint.css b/continuousprint/static/css/continuousprint.css index e384088..e3e36fd 100644 --- a/continuousprint/static/css/continuousprint.css +++ b/continuousprint/static/css/continuousprint.css @@ -129,6 +129,11 @@ font-weight: italic; opacity: 0.7; } +#tab_plugin_continuousprint .hint { + font-weight: italic; + font-size: 85%; + opacity: 0.7; +} #tab_plugin_continuousprint #queue_sets.empty::before { content: "Hint: Click the + button on a file, or drag a queue item here"; font-weight: italic; diff --git a/continuousprint/static/js/continuousprint_viewmodel.js b/continuousprint/static/js/continuousprint_viewmodel.js index 2aabfba..27e36de 100644 --- a/continuousprint/static/js/continuousprint_viewmodel.js +++ b/continuousprint/static/js/continuousprint_viewmodel.js @@ -60,11 +60,8 @@ function CPViewModel(parameters) { self.jobs = ko.observableArray([]); self.selected = ko.observable(null); - try { - self.materials = ko.observable([]); self.api.getSpoolManagerState(function(resp) { - console.log(resp); let result = {}; for (let spool of resp.allSpools) { let k = `${spool.material}_${spool.colorName}_#${spool.color.substring(1)}`; @@ -73,9 +70,6 @@ function CPViewModel(parameters) { self.materials(Object.values(result)); }); - } catch (e) { console.error(e);} - - self.isSelected = function(j=null, q=null) { j = self._resolve(j); q = self._resolve(q); diff --git a/continuousprint/templates/continuousprint_settings.jinja2 b/continuousprint/templates/continuousprint_settings.jinja2 index 03472f6..300c607 100644 --- a/continuousprint/templates/continuousprint_settings.jinja2 +++ b/continuousprint/templates/continuousprint_settings.jinja2 @@ -120,4 +120,20 @@
+ +
+

Material Selection

+
+

Material selection requires SpoolManager, but the plugin does not appear to be installed or enabled.

+

Read more about this feature in the Material Selection guide.

+
+
+

+ Material selection is enabled because SpoolManager is installed and enabled. +

+

+ Read more about material selection in the Material Selection guide. +

+
+
diff --git a/continuousprint/templates/continuousprint_tab.jinja2 b/continuousprint/templates/continuousprint_tab.jinja2 index 5ffc644..9659f45 100644 --- a/continuousprint/templates/continuousprint_tab.jinja2 +++ b/continuousprint/templates/continuousprint_tab.jinja2 @@ -80,19 +80,23 @@
-
Materials:
-
-
- - +
+
Materials:
+
+
+ + +
- +
+ Hint: Install SpoolManager, add spools, then reload the page to select material types. +
Name
diff --git a/docs/cpq_material_selection.png b/docs/cpq_material_selection.png new file mode 100644 index 0000000000000000000000000000000000000000..332142901cecf73ad3d27e6c78a7a72c4670202e GIT binary patch literal 49564 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 Continuous Print` and scroll to "Material Selection". If you see "Material selection is enabled", then you're good to go! + +!!! Important + + Continuous Print only knows about the materials that you've defined in SpoolManager. Be sure to add a few spools before trying to select materials, or else you won't see any options available. + +## Selecting materials + +When you add a new print file to the queue, it assumes nothing about material by default - any material type and color will be used to print it. + +Materials are implemented at the level of Sets (see [here](https://smartin015.github.io/continuousprint/getting-started/#use-jobs-to-group-your-print-files) for definition). If you want a set to print in a certain color or material, click the triangle next to its name. If material selection is enabled (as above), you should see drop-down boxes for each extruder your printer has. + + +Select the desired materials for your desired hotends (called "tools" in OctoPrint parlance). If you leave a tool empty, it's considered unconstrained - any material will do. + +As you select materials, you'll see labels appear next to the name of the set. These help indicate at a glance what sets are constrained, and how. You can hover over individual labels to see more details on what material is specified. + +![Sample image of material selection](cpq_material_selection.png) + +## Behavior + +When the print queue reaches a set with a specific material selected, it will wait to start the set until you select a matching spool via SpoolManager for every tool with a specified material. + +Note that the material (e.g. "PLA") and the color are all that's matched. If for instance you have more than one spool of black PLA, selecting any of these spools in SpoolManager is sufficient if the print requires black PLA. diff --git a/mkdocs.yml b/mkdocs.yml index 65c045a..f3a8fa2 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -24,5 +24,6 @@ nav: - GCODE Scripting: gcode-scripting.md - Advanced Queuing: advanced-queuing.md - Failure Recovery: failure-recovery.md + - Material Selection: material-selection.md - Contributing: contributing.md - API: api.md From 1446eba7a79d89027bfbaf6bd5c8d1f6461f181c Mon Sep 17 00:00:00 2001 From: Scott Martin Date: Tue, 19 Apr 2022 11:39:14 -0400 Subject: [PATCH 13/20] Linter pass --- continuousprint/__init__.py | 79 +++-- continuousprint/data/gcode_scripts.yaml | 8 +- continuousprint/driver.py | 328 +++++++++--------- continuousprint/driver_test.py | 162 ++++----- .../static/js/continuousprint_queueset.js | 6 +- .../templates/continuousprint_settings.jinja2 | 6 +- docs/contributing.md | 12 +- docs/failure-recovery.md | 5 +- docs/gcode-scripting.md | 3 +- docs/material-selection.md | 4 +- 10 files changed, 311 insertions(+), 302 deletions(-) diff --git a/continuousprint/__init__.py b/continuousprint/__init__.py index 8db4edc..1839ac4 100644 --- a/continuousprint/__init__.py +++ b/continuousprint/__init__.py @@ -36,6 +36,7 @@ BED_COOLDOWN_TIMEOUT_KEY = "bed_cooldown_timeout" MATERIAL_SELECTION_KEY = "cp_material_selection_enabled" + class ContinuousprintPlugin( octoprint.plugin.SettingsPlugin, octoprint.plugin.TemplatePlugin, @@ -60,9 +61,9 @@ def _update_driver_settings(self): def get_settings_defaults(self): base = os.path.dirname(__file__) with open(os.path.join(base, "data/printer_profiles.yaml"), "r") as f: - self._printer_profiles = yaml.safe_load(f.read())['PrinterProfile'] + self._printer_profiles = yaml.safe_load(f.read())["PrinterProfile"] with open(os.path.join(base, "data/gcode_scripts.yaml"), "r") as f: - self._gcode_scripts = yaml.safe_load(f.read())['GScript'] + self._gcode_scripts = yaml.safe_load(f.read())["GScript"] d = {} d[QUEUE_KEY] = "[]" @@ -70,12 +71,12 @@ def get_settings_defaults(self): d[FINISHED_SCRIPT_KEY] = "" for s in self._gcode_scripts: - name = s['name'] - gcode = s['gcode'] - if name == "Pause": - d[CLEARING_SCRIPT_KEY] = gcode - elif name == "Generic Off": - d[FINISHED_SCRIPT_KEY] = gcode + name = s["name"] + gcode = s["gcode"] + if name == "Pause": + d[CLEARING_SCRIPT_KEY] = gcode + elif name == "Generic Off": + d[FINISHED_SCRIPT_KEY] = gcode d[RESTART_MAX_RETRIES_KEY] = 3 d[RESTART_ON_PAUSE_KEY] = False d[RESTART_MAX_TIME_KEY] = 60 * 60 @@ -89,7 +90,6 @@ def get_settings_defaults(self): def _active(self): return self.d.state != self.d._state_inactive if hasattr(self, "d") else False - def _rm_temp_files(self): # Clean up any file references from prior runs for path in TEMP_FILES.values(): @@ -112,17 +112,16 @@ def on_after_startup(self): else: self._settings.set([RESTART_ON_PAUSE_KEY], False) - # SpoolManager plugin isn't required, but does enable material-based printing if it exists # Code based loosely on https://github.com/OllisGit/OctoPrint-PrintJobHistory/ (see _getPluginInformation) smplugin = self._plugin_manager.plugins.get("SpoolManager") if smplugin is not None and smplugin.enabled: - self._spool_manager = smplugin.implementation - self._logger.info(f"SpoolManager found - enabling material selection") - self._settings.set([MATERIAL_SELECTION_KEY], True) + self._spool_manager = smplugin.implementation + self._logger.info("SpoolManager found - enabling material selection") + self._settings.set([MATERIAL_SELECTION_KEY], True) else: - self._spool_manager = None - self._settings.set([MATERIAL_SELECTION_KEY], False) + self._spool_manager = None + self._settings.set([MATERIAL_SELECTION_KEY], False) self._settings.save() self.q = PrintQueue(self._settings, QUEUE_KEY) @@ -131,11 +130,11 @@ def on_after_startup(self): script_runner=self, logger=self._logger, ) - self.update(DA.DEACTIVATE) # Initializes and passes printer state + self.update(DA.DEACTIVATE) # Initializes and passes printer state self._update_driver_settings() self._rm_temp_files() self.next_pause_is_spaghetti = False - + # It's possible to miss events or for some weirdness to occur in conditionals. Adding a watchdog # timer with a periodic tick ensures that the driver knows what the state of the printer is. self.watchdog = RepeatedTimer(5.0, lambda: self.update(DA.TICK)) @@ -147,23 +146,27 @@ def update(self, a: DA): # See https://docs.octoprint.org/en/master/modules/printer.html#octoprint.printer.PrinterInterface.is_current_file # Avoid using payload.get('path') as some events may not express path info. path = self._printer.get_current_job().get("file", {}).get("name") - pstate = self._printer.get_state_id() + pstate = self._printer.get_state_id() p = DP.BUSY if pstate == "OPERATIONAL": - p = DP.IDLE + p = DP.IDLE elif pstate == "PAUSED": - p = DP.PAUSED + p = DP.PAUSED materials = [] if self._spool_manager is not None: - # We need *all* selected spools for all tools, so we must look it up from the plugin itself - # (event payload also excludes color hex string which is needed for our identifiers) - materials = self._spool_manager.api_getSelectedSpoolInformations() - materials = [f"{m['material']}_{m['colorName']}_{m['color']}" if m is not None else None for m in materials] + # We need *all* selected spools for all tools, so we must look it up from the plugin itself + # (event payload also excludes color hex string which is needed for our identifiers) + materials = self._spool_manager.api_getSelectedSpoolInformations() + materials = [ + f"{m['material']}_{m['colorName']}_{m['color']}" + if m is not None + else None + for m in materials + ] if self.d.action(a, p, path, materials): - self._msg(type="reload") # Reload UI when new state is added - + self._msg(type="reload") # Reload UI when new state is added # part of EventHandlerPlugin def on_event(self, event, payload): @@ -172,7 +175,6 @@ def on_event(self, event, payload): current_file = self._printer.get_current_job().get("file", {}).get("name") is_current_path = current_file == self.d.current_path() - is_finish_script = current_file == TEMP_FILES[FINISHED_SCRIPT_KEY] # Try to fetch plugin-specific events, defaulting to None otherwise @@ -203,11 +205,11 @@ def on_event(self, event, payload): # Note that cancelled events are already handled directly with Events.PRINT_CANCELLED self.update(DA.FAILURE) elif event == Events.PRINT_CANCELLED: - print(payload.get('user')) - if payload.get('user') is not None: - self.update(DA.DEACTIVATE) + print(payload.get("user")) + if payload.get("user") is not None: + self.update(DA.DEACTIVATE) else: - self.update(DA.TICK) + self.update(DA.TICK) elif ( is_current_path and tsd_command is not None @@ -216,9 +218,9 @@ def on_event(self, event, payload): and payload.get("initiator") == "system" ): self.update(DA.SPAGHETTI) - elif (spool_selected is not None and event == spool_selected): + elif spool_selected is not None and event == spool_selected: self.update(DA.TICK) - elif (spool_deselected is not None and event == spool_deselected): + elif spool_deselected is not None and event == spool_deselected: self.update(DA.TICK) elif is_current_path and event == Events.PRINT_PAUSED: self.update(DA.TICK) @@ -264,7 +266,9 @@ def wait_for_bed_cooldown(self): self._printer.set_temperature("bed", 0) # turn bed off start_time = time.time() - while (time.time() - start_time) <= (60 * float(self._settings.get(["bed_cooldown_timeout"]))): # timeout converted to seconds + while (time.time() - start_time) <= ( + 60 * float(self._settings.get(["bed_cooldown_timeout"])) + ): # timeout converted to seconds bed_temp = self._printer.get_current_temperatures()["bed"]["actual"] if bed_temp <= float(self._settings.get(["bed_cooldown_threshold"])): self._logger.info( @@ -339,7 +343,9 @@ def set_active(self): if not Permissions.PLUGIN_CONTINUOUSPRINT_STARTQUEUE.can(): return flask.make_response("Insufficient Rights", 403) self._logger.info("attempt failed due to insufficient permissions.") - self.update(DA.ACTIVATE if flask.request.form["active"] == "true" else DA.DEACTIVATE) + self.update( + DA.ACTIVATE if flask.request.form["active"] == "true" else DA.DEACTIVATE + ) return self.state_json() # PRIVATE API method - may change without warning. @@ -469,8 +475,7 @@ def reset(self): # part of TemplatePlugin def get_template_vars(self): return dict( - printer_profiles=self._printer_profiles, - gcode_scripts=self._gcode_scripts + printer_profiles=self._printer_profiles, gcode_scripts=self._gcode_scripts ) def get_template_configs(self): diff --git a/continuousprint/data/gcode_scripts.yaml b/continuousprint/data/gcode_scripts.yaml index 82029dc..f7ef237 100644 --- a/continuousprint/data/gcode_scripts.yaml +++ b/continuousprint/data/gcode_scripts.yaml @@ -1,6 +1,6 @@ GScript: - name: "Sweep Gantry" - description: > + description: > This script example assumes a box style printer with a vertical Z axis and a 200mm x 235mm XY build area. It uses the printer's extruder to push the part off the build plate." version: 0.0.1 @@ -18,7 +18,7 @@ GScript: G28 ; home - name: "Advance Belt Short" description: > - This script works with a belt printer (specifically, a Creality CR-30). The belt is advanced to move + This script works with a belt printer (specifically, a Creality CR-30). The belt is advanced to move the print out of the way before starting another print. version: 0.0.1 gcode: | @@ -31,8 +31,8 @@ GScript: M140 S0; Set bed to 0C (off) - name: "Pause" description: > - Use this script if you want to remove the print yourself but use the queue to keep track of your - prints. It uses an @ Command to tell OctoPrint to pause the print. The printer will stay paused + Use this script if you want to remove the print yourself but use the queue to keep track of your + prints. It uses an @ Command to tell OctoPrint to pause the print. The printer will stay paused until you press "Resume" on the OctoPrint UI. version: 0.0.1 gcode: | diff --git a/continuousprint/driver.py b/continuousprint/driver.py index 0af05cd..4be1af6 100644 --- a/continuousprint/driver.py +++ b/continuousprint/driver.py @@ -1,18 +1,21 @@ import time from enum import Enum, auto + class Action(Enum): - ACTIVATE = auto() - DEACTIVATE = auto() - SUCCESS = auto() - FAILURE = auto() - SPAGHETTI = auto() - TICK = auto() + ACTIVATE = auto() + DEACTIVATE = auto() + SUCCESS = auto() + FAILURE = auto() + SPAGHETTI = auto() + TICK = auto() + class Printer(Enum): - IDLE = auto() - PAUSED = auto() - BUSY = auto() + IDLE = auto() + PAUSED = auto() + BUSY = auto() + # Inspired by answers at # https://stackoverflow.com/questions/6108819/javascript-timestamp-to-relative-time @@ -43,201 +46,202 @@ def __init__( self.retry_threshold_seconds = 0 self.first_print = True self._runner = script_runner - self._intent = None # Intended file path + self._intent = None # Intended file path self._update_ui = False self._cur_path = None self._cur_materials = [] - def action(self, a: Action, p: Printer, path: str=None, materials: list=[]): - self._logger.debug(f"{a.name}, {p.name}, path={path}, materials={materials}") - if path is not None: - self._cur_path = path - if len(materials) > 0: - self._cur_materials = materials - nxt = self.state(a, p) - if nxt is not None: - self._logger.info(f"{self.state.__name__} -> {nxt.__name__}") - self.state = nxt - self._update_ui = True - - - if self._update_ui: - self._update_ui = False - return True - return False + def action(self, a: Action, p: Printer, path: str = None, materials: list = []): + self._logger.debug(f"{a.name}, {p.name}, path={path}, materials={materials}") + if path is not None: + self._cur_path = path + if len(materials) > 0: + self._cur_materials = materials + nxt = self.state(a, p) + if nxt is not None: + self._logger.info(f"{self.state.__name__} -> {nxt.__name__}") + self.state = nxt + self._update_ui = True + + if self._update_ui: + self._update_ui = False + return True + return False def _state_unknown(self, a: Action, p: Printer): - if a == Action.DEACTIVATE: - return self._state_inactive + if a == Action.DEACTIVATE: + return self._state_inactive def _state_inactive(self, a: Action, p: Printer): - self.retries = 0 - - if a == Action.ACTIVATE: - if p != Printer.IDLE: - return self._state_printing - else: - # TODO "clear bed on startup" setting - return self._state_start_print + self.retries = 0 - if p == Printer.IDLE: - self._set_status("Inactive (click Start Managing)") - else: - self._set_status("Inactive (active print continues unmanaged)") + if a == Action.ACTIVATE: + if p != Printer.IDLE: + return self._state_printing + else: + # TODO "clear bed on startup" setting + return self._state_start_print + if p == Printer.IDLE: + self._set_status("Inactive (click Start Managing)") + else: + self._set_status("Inactive (active print continues unmanaged)") def _state_start_print(self, a: Action, p: Printer): - if a == Action.DEACTIVATE: - return self._state_inactive - - if p != Printer.IDLE: - self._set_status("Waiting for printer to be ready") - return - - # Block until we have the right materials loaded (if required) - item = self.q[self._next_available_idx()] - for i, im in enumerate(item.materials): - if im is None: # No constraint - continue - cur = self._cur_materials[i] if i < len(self._cur_materials) else None - if im != cur: - self._set_status(f"Waiting for spool {im} in tool {i} (currently: {cur})") - return - - # The next print may not be the *immediately* next print - # e.g. if we skip over a print or start mid-print - idx = self._next_available_idx() - if idx is not None: - p = self.q[idx] - p.start_ts = int(time.time()) - p.end_ts = None - p.retries = self.retries - self.q[idx] = p - self._intent = self._runner.start_print(p) - return self._state_printing - else: - return self._state_inactive + if a == Action.DEACTIVATE: + return self._state_inactive + if p != Printer.IDLE: + self._set_status("Waiting for printer to be ready") + return + + # Block until we have the right materials loaded (if required) + item = self.q[self._next_available_idx()] + for i, im in enumerate(item.materials): + if im is None: # No constraint + continue + cur = self._cur_materials[i] if i < len(self._cur_materials) else None + if im != cur: + self._set_status( + f"Waiting for spool {im} in tool {i} (currently: {cur})" + ) + return + + # The next print may not be the *immediately* next print + # e.g. if we skip over a print or start mid-print + idx = self._next_available_idx() + if idx is not None: + p = self.q[idx] + p.start_ts = int(time.time()) + p.end_ts = None + p.retries = self.retries + self.q[idx] = p + self._intent = self._runner.start_print(p) + return self._state_printing + else: + return self._state_inactive def _elapsed(self): - return (time.time() - self.q[self._cur_idx()].start_ts) + return time.time() - self.q[self._cur_idx()].start_ts def _state_printing(self, a: Action, p: Printer, elapsed=None): - if a == Action.DEACTIVATE: - return self._state_inactive - elif a == Action.FAILURE: - return self._state_failure - elif a == Action.SPAGHETTI: - elapsed = self._elapsed() - if self.retry_on_pause and elapsed < self.retry_threshold_seconds: - return self._state_spaghetti_recovery - else: - self._set_status( - f"Print paused {timeAgo(elapsed)} into print (over auto-restart threshold of {timeAgo(self.retry_threshold_seconds)}); awaiting user input" - ) - return self._state_paused - elif a == Action.SUCCESS: - return self._state_success - - if p == Printer.BUSY: - idx = self._cur_idx() - if idx is not None: - self._set_status(f"Printing {self.q[idx].name}") - elif p == Printer.PAUSED: - return self._state_paused - elif p == Printer.IDLE: # Idle state without event; assume success - return self._state_success + if a == Action.DEACTIVATE: + return self._state_inactive + elif a == Action.FAILURE: + return self._state_failure + elif a == Action.SPAGHETTI: + elapsed = self._elapsed() + if self.retry_on_pause and elapsed < self.retry_threshold_seconds: + return self._state_spaghetti_recovery + else: + self._set_status( + f"Print paused {timeAgo(elapsed)} into print (over auto-restart threshold of {timeAgo(self.retry_threshold_seconds)}); awaiting user input" + ) + return self._state_paused + elif a == Action.SUCCESS: + return self._state_success + + if p == Printer.BUSY: + idx = self._cur_idx() + if idx is not None: + self._set_status(f"Printing {self.q[idx].name}") + elif p == Printer.PAUSED: + return self._state_paused + elif p == Printer.IDLE: # Idle state without event; assume success + return self._state_success def _state_paused(self, a: Action, p: Printer): - self._set_status("Queue paused") - if a == Action.DEACTIVATE or p == Printer.IDLE: - return self._state_inactive - elif p == Printer.BUSY: - return self._state_printing + self._set_status("Queue paused") + if a == Action.DEACTIVATE or p == Printer.IDLE: + return self._state_inactive + elif p == Printer.BUSY: + return self._state_printing def _state_spaghetti_recovery(self, a: Action, p: Printer): - self._set_status(f"Cancelling print (spaghetti seen early in print)") - if p == Printer.PAUSED: - self._runner.cancel_print() - self._intent = None - return self._state_failure + self._set_status("Cancelling print (spaghetti seen early in print)") + if p == Printer.PAUSED: + self._runner.cancel_print() + self._intent = None + return self._state_failure def _state_failure(self, a: Action, p: Printer): - if p != Printer.IDLE: - return + if p != Printer.IDLE: + return + + if self.retries + 1 < self.max_retries: + self.retries += 1 + return self._state_start_clearing + else: + idx = self._cur_idx() + if idx is not None: + self._complete_item(idx, "failure") + return self._state_inactive - if self.retries + 1 < self.max_retries: - self.retries += 1 - return self._state_start_clearing - else: + def _state_success(self, a: Action, p: Printer): idx = self._cur_idx() + + # Complete prior queue item if that's what we just finished if idx is not None: - self._complete_item(idx, "failure") - return self._state_inactive - - def _state_success(self, a: Action, p: Printer): - idx = self._cur_idx() - - # Complete prior queue item if that's what we just finished - if idx is not None: - path = self.q[idx].path - if self._intent == path and self._cur_path == path: - self._complete_item(idx, "success") - else: - self._logger.info(f"Current queue item {path} not matching intent {self._intent}, current path {self._cur_path} - no completion") - self.retries = 0 - - # Clear bed if we have a next queue item, otherwise run finishing script - idx = self._next_available_idx() - if idx is not None: - return self._state_start_clearing - else: - return self._state_start_finishing + path = self.q[idx].path + if self._intent == path and self._cur_path == path: + self._complete_item(idx, "success") + else: + self._logger.info( + f"Current queue item {path} not matching intent {self._intent}, current path {self._cur_path} - no completion" + ) + self.retries = 0 + + # Clear bed if we have a next queue item, otherwise run finishing script + idx = self._next_available_idx() + if idx is not None: + return self._state_start_clearing + else: + return self._state_start_finishing def _state_start_clearing(self, a: Action, p: Printer): - if a == Action.DEACTIVATE: - return self._state_inactive - if p != Printer.IDLE: - self._set_status("Waiting for printer to be ready") - return + if a == Action.DEACTIVATE: + return self._state_inactive + if p != Printer.IDLE: + self._set_status("Waiting for printer to be ready") + return - self._intent = self._runner.clear_bed() - return self._state_clearing + self._intent = self._runner.clear_bed() + return self._state_clearing def _state_clearing(self, a: Action, p: Printer): - if a == Action.DEACTIVATE: - return self._state_inactive - if p != Printer.IDLE: - return + if a == Action.DEACTIVATE: + return self._state_inactive + if p != Printer.IDLE: + return - self._set_status("Clearing bed") - return self._state_start_print + self._set_status("Clearing bed") + return self._state_start_print def _state_start_finishing(self, a: Action, p: Printer): - if a == Action.DEACTIVATE: - return self._state_inactive - if p != Printer.IDLE: - self._set_status("Waiting for printer to be ready") - return + if a == Action.DEACTIVATE: + return self._state_inactive + if p != Printer.IDLE: + self._set_status("Waiting for printer to be ready") + return - self._intent = self._runner.run_finish_script() - return self._state_finishing + self._intent = self._runner.run_finish_script() + return self._state_finishing def _state_finishing(self, a: Action, p: Printer): - if a == Action.DEACTIVATE: - return self._state_inactive - if p != Printer.IDLE: - return + if a == Action.DEACTIVATE: + return self._state_inactive + if p != Printer.IDLE: + return - self._set_status("Finising up") + self._set_status("Finising up") - return self._state_inactive + return self._state_inactive def _set_status(self, status): if status != self.status: - self._update_ui = True - self.status = status - self._logger.info(status) + self._update_ui = True + self.status = status + self._logger.info(status) def set_retry_on_pause( self, enabled, max_retries=3, retry_threshold_seconds=60 * 60 diff --git a/continuousprint/driver_test.py b/continuousprint/driver_test.py index 999b306..2efabd3 100644 --- a/continuousprint/driver_test.py +++ b/continuousprint/driver_test.py @@ -8,13 +8,12 @@ logging.basicConfig(level=logging.DEBUG) -class Runner(): - def __init__(self): - self.run_finish_script = MagicMock() - self.start_print = MagicMock() - self.cancel_print = MagicMock() - self.clear_bed = MagicMock() - +class Runner: + def __init__(self): + self.run_finish_script = MagicMock() + self.start_print = MagicMock() + self.cancel_print = MagicMock() + self.clear_bed = MagicMock() def setupTestQueueAndDriver(self, num_complete=0): @@ -64,43 +63,45 @@ def assert_nocalls(): self.d._runner.start_print.assert_not_called() for p in [DP.IDLE, DP.BUSY, DP.PAUSED]: - for a in [DA.SUCCESS, DA.FAILURE, DA.TICK, DA.DEACTIVATE, DA.SPAGHETTI]: - self.d.action(a, p) - assert_nocalls() - self.assertEqual(self.d.state, self.d._state_inactive) + for a in [DA.SUCCESS, DA.FAILURE, DA.TICK, DA.DEACTIVATE, DA.SPAGHETTI]: + self.d.action(a, p) + assert_nocalls() + self.assertEqual(self.d.state, self.d._state_inactive) def test_completed_print_not_in_queue(self): self.d.action(DA.ACTIVATE, DP.BUSY) - self.d.action(DA.SUCCESS, DP.IDLE, "otherprint.gcode") # -> success - self.d.action(DA.TICK, DP.IDLE) # -> start_clearing + self.d.action(DA.SUCCESS, DP.IDLE, "otherprint.gcode") # -> success + self.d.action(DA.TICK, DP.IDLE) # -> start_clearing - self.assertEqual(self.q[0].end_ts, None) # Queue item not completed since print not in queue + self.assertEqual( + self.q[0].end_ts, None + ) # Queue item not completed since print not in queue self.assertEqual(self.q[0].result, None) - self.d.action(DA.TICK, DP.IDLE) # -> clearing - self.d.action(DA.SUCCESS, DP.IDLE) # -> start_print - self.d.action(DA.TICK, DP.IDLE) # -> printing + self.d.action(DA.TICK, DP.IDLE) # -> clearing + self.d.action(DA.SUCCESS, DP.IDLE) # -> start_print + self.d.action(DA.TICK, DP.IDLE) # -> printing # Non-queue print completion while the driver is active # should kick off a new print from the head of the queue self.d._runner.start_print.assert_called_once() self.assertEqual(self.d._runner.start_print.call_args[0][0], self.q[0]) - + def test_completed_first_print(self): - self.d.action(DA.ACTIVATE, DP.IDLE) # -> start_print - self.d.action(DA.TICK, DP.IDLE) # -> printing + self.d.action(DA.ACTIVATE, DP.IDLE) # -> start_print + self.d.action(DA.TICK, DP.IDLE) # -> printing self.d._runner.start_print.reset_mock() self.d._intent = self.q[0].path self.d._cur_path = self.d._intent - self.d.action(DA.SUCCESS, DP.IDLE, self.d._intent) # -> success - self.d.action(DA.TICK, DP.IDLE) # -> start_clearing - self.d.action(DA.TICK, DP.IDLE) # -> clearing + self.d.action(DA.SUCCESS, DP.IDLE, self.d._intent) # -> success + self.d.action(DA.TICK, DP.IDLE) # -> start_clearing + self.d.action(DA.TICK, DP.IDLE) # -> clearing self.d._runner.clear_bed.assert_called_once() - - self.d.action(DA.SUCCESS, DP.IDLE) # -> start_print - self.d.action(DA.TICK, DP.IDLE) # -> printing + + self.d.action(DA.SUCCESS, DP.IDLE) # -> start_print + self.d.action(DA.TICK, DP.IDLE) # -> printing self.d._runner.start_print.assert_called_once() self.assertEqual(self.d._runner.start_print.call_args[0][0], self.q[1]) @@ -113,29 +114,30 @@ def test_start_clearing_waits_for_idle(self): self.assertEqual(self.d.state, self.d._state_start_clearing) self.d._runner.clear_bed.assert_not_called() + class TestPartiallyComplete(unittest.TestCase): def setUp(self): setupTestQueueAndDriver(self, 1) def test_success(self): - self.d.action(DA.ACTIVATE, DP.IDLE) # -> start_print - self.d.action(DA.TICK, DP.IDLE) # -> printing + self.d.action(DA.ACTIVATE, DP.IDLE) # -> start_print + self.d.action(DA.TICK, DP.IDLE) # -> printing self.d._runner.start_print.reset_mock() self.d._intent = self.q[1].path self.d._cur_path = self.d._intent - self.d.action(DA.SUCCESS, DP.IDLE) # -> success - self.d.action(DA.TICK, DP.IDLE) # -> start_clearing - self.d.action(DA.TICK, DP.IDLE) # -> clearing - self.d.action(DA.TICK, DP.IDLE) # -> start_print - self.d.action(DA.TICK, DP.IDLE) # -> printing + self.d.action(DA.SUCCESS, DP.IDLE) # -> success + self.d.action(DA.TICK, DP.IDLE) # -> start_clearing + self.d.action(DA.TICK, DP.IDLE) # -> clearing + self.d.action(DA.TICK, DP.IDLE) # -> start_print + self.d.action(DA.TICK, DP.IDLE) # -> printing self.d._runner.start_print.assert_called_once() self.assertEqual(self.d._runner.start_print.call_args[0][0], self.q[2]) def test_success_after_queue_prepend_starts_prepended(self): - self.d.action(DA.ACTIVATE, DP.IDLE) # -> start_print - self.d.action(DA.TICK, DP.IDLE) # -> printing + self.d.action(DA.ACTIVATE, DP.IDLE) # -> start_print + self.d.action(DA.TICK, DP.IDLE) # -> printing self.d._runner.start_print.reset_mock() n = QueueItem("new", "/new.gco", True) self.q.add([n], idx=0) @@ -143,91 +145,91 @@ def test_success_after_queue_prepend_starts_prepended(self): self.d._intent = self.q[1].path self.d._cur_path = self.d._intent - self.d.action(DA.SUCCESS, DP.IDLE) # -> success - self.d.action(DA.TICK, DP.IDLE) # -> start_clearing - self.d.action(DA.TICK, DP.IDLE) # -> clearing - self.d.action(DA.TICK, DP.IDLE) # -> start_print - self.d.action(DA.TICK, DP.IDLE) # -> printing + self.d.action(DA.SUCCESS, DP.IDLE) # -> success + self.d.action(DA.TICK, DP.IDLE) # -> start_clearing + self.d.action(DA.TICK, DP.IDLE) # -> clearing + self.d.action(DA.TICK, DP.IDLE) # -> start_print + self.d.action(DA.TICK, DP.IDLE) # -> printing self.d._runner.start_print.assert_called_once self.assertEqual(self.d._runner.start_print.call_args[0][0], n) def test_paused_with_spaghetti_early_triggers_cancel(self): - self.d.action(DA.ACTIVATE, DP.IDLE) # -> start_print - self.d.action(DA.TICK, DP.IDLE) # -> printing + self.d.action(DA.ACTIVATE, DP.IDLE) # -> start_print + self.d.action(DA.TICK, DP.IDLE) # -> printing self.d._elapsed = lambda: 10 - self.d.action(DA.SPAGHETTI, DP.BUSY) # -> spaghetti_recovery - self.d.action(DA.TICK, DP.PAUSED) # -> cancel + failure + self.d.action(DA.SPAGHETTI, DP.BUSY) # -> spaghetti_recovery + self.d.action(DA.TICK, DP.PAUSED) # -> cancel + failure self.d._runner.cancel_print.assert_called() self.assertEqual(self.d.state, self.d._state_failure) def test_paused_with_spaghetti_late_waits_for_user(self): - self.d.action(DA.ACTIVATE, DP.IDLE) # -> start_print - self.d.action(DA.TICK, DP.IDLE) # -> printing + self.d.action(DA.ACTIVATE, DP.IDLE) # -> start_print + self.d.action(DA.TICK, DP.IDLE) # -> printing self.d._elapsed = lambda: self.d.retry_threshold_seconds + 1 - self.d.action(DA.SPAGHETTI, DP.BUSY) # -> printing (ignore spaghetti) - self.d.action(DA.TICK, DP.PAUSED) # -> paused + self.d.action(DA.SPAGHETTI, DP.BUSY) # -> printing (ignore spaghetti) + self.d.action(DA.TICK, DP.PAUSED) # -> paused self.d._runner.cancel_print.assert_not_called() self.assertEqual(self.d.state, self.d._state_paused) def test_paused_manually_early_waits_for_user(self): - self.d.action(DA.ACTIVATE, DP.IDLE) # -> start_print - self.d.action(DA.TICK, DP.IDLE) # -> printing + self.d.action(DA.ACTIVATE, DP.IDLE) # -> start_print + self.d.action(DA.TICK, DP.IDLE) # -> printing self.d._elapsed = lambda: 10 - self.d.action(DA.TICK, DP.PAUSED) # -> paused - self.d.action(DA.TICK, DP.PAUSED) # stay in paused state + self.d.action(DA.TICK, DP.PAUSED) # -> paused + self.d.action(DA.TICK, DP.PAUSED) # stay in paused state self.d._runner.cancel_print.assert_not_called() self.assertEqual(self.d.state, self.d._state_paused) def test_paused_manually_late_waits_for_user(self): - self.d.action(DA.ACTIVATE, DP.IDLE) # -> start_print - self.d.action(DA.TICK, DP.IDLE) # -> printing + self.d.action(DA.ACTIVATE, DP.IDLE) # -> start_print + self.d.action(DA.TICK, DP.IDLE) # -> printing self.d._elapsed = lambda: 1000 - self.d.action(DA.TICK, DP.PAUSED) # -> paused - self.d.action(DA.TICK, DP.PAUSED) # stay in paused state + self.d.action(DA.TICK, DP.PAUSED) # -> paused + self.d.action(DA.TICK, DP.PAUSED) # stay in paused state self.d._runner.cancel_print.assert_not_called() self.assertEqual(self.d.state, self.d._state_paused) def test_paused_on_temp_file_falls_through(self): - self.d.state = self.d._state_clearing # -> clearing + self.d.state = self.d._state_clearing # -> clearing self.d.action(DA.TICK, DP.PAUSED) self.d._runner.cancel_print.assert_not_called() self.assertEqual(self.d.state, self.d._state_clearing) - self.d.state = self.d._state_finishing # -> finishing - self.d.action(DA.TICK, DP.PAUSED) + self.d.state = self.d._state_finishing # -> finishing + self.d.action(DA.TICK, DP.PAUSED) self.d._runner.cancel_print.assert_not_called() self.assertEqual(self.d.state, self.d._state_finishing) def test_user_deactivate_sets_inactive(self): - self.d.action(DA.ACTIVATE, DP.IDLE) # -> start_print - self.d.action(DA.TICK, DP.IDLE) # -> printing + self.d.action(DA.ACTIVATE, DP.IDLE) # -> start_print + self.d.action(DA.TICK, DP.IDLE) # -> printing self.d._runner.start_print.reset_mock() - self.d.action(DA.DEACTIVATE, DP.IDLE) # -> inactive + self.d.action(DA.DEACTIVATE, DP.IDLE) # -> inactive self.assertEqual(self.d.state, self.d._state_inactive) self.d._runner.start_print.assert_not_called() self.assertEqual(self.q[1].result, None) def test_retry_after_failure(self): self.d.state = self.d._state_failure - self.d.retries = self.d.max_retries-2 - self.d.action(DA.TICK, DP.IDLE) # Start clearing - self.assertEqual(self.d.retries, self.d.max_retries-1) + self.d.retries = self.d.max_retries - 2 + self.d.action(DA.TICK, DP.IDLE) # Start clearing + self.assertEqual(self.d.retries, self.d.max_retries - 1) self.assertEqual(self.d.state, self.d._state_start_clearing) def test_activate_clears_retries(self): self.d.retries = self.d.max_retries - 1 - self.d.action(DA.ACTIVATE, DP.IDLE) # -> start_print + self.d.action(DA.ACTIVATE, DP.IDLE) # -> start_print self.assertEqual(self.d.retries, 0) def test_failure_with_max_retries_sets_inactive(self): self.d.state = self.d._state_failure self.d.retries = self.d.max_retries - 1 - self.d.action(DA.TICK, DP.IDLE) # -> inactive + self.d.action(DA.TICK, DP.IDLE) # -> inactive self.assertEqual(self.d.state, self.d._state_inactive) def test_resume_from_pause(self): @@ -241,31 +243,30 @@ def setUp(self): setupTestQueueAndDriver(self, 2) def test_completed_last_print(self): - self.d.action(DA.ACTIVATE, DP.IDLE) # -> start_print - self.d.action(DA.TICK, DP.IDLE) # -> printing + self.d.action(DA.ACTIVATE, DP.IDLE) # -> start_print + self.d.action(DA.TICK, DP.IDLE) # -> printing self.d._runner.start_print.reset_mock() self.d._intent = self.q[-1].path self.d._cur_path = self.d._intent - self.d.action(DA.SUCCESS, DP.IDLE, self.d._intent) # -> success - self.d.action(DA.TICK, DP.IDLE) # -> start_finishing - self.d.action(DA.TICK, DP.IDLE) # -> finishing + self.d.action(DA.SUCCESS, DP.IDLE, self.d._intent) # -> success + self.d.action(DA.TICK, DP.IDLE) # -> start_finishing + self.d.action(DA.TICK, DP.IDLE) # -> finishing self.d._runner.run_finish_script.assert_called() self.assertEqual(self.d.state, self.d._state_finishing) - - self.d.action(DA.TICK, DP.IDLE) # -> inactive + + self.d.action(DA.TICK, DP.IDLE) # -> inactive self.assertEqual(self.d.state, self.d._state_inactive) - + + class TestMaterialConstraints(unittest.TestCase): def setUp(self): setupTestQueueAndDriver(self) def _setItemMaterials(self, m): - self.q.assign([ - QueueItem("foo", "/foo.gcode", True, materials=m) - ]) - + self.q.assign([QueueItem("foo", "/foo.gcode", True, materials=m)]) + def test_empty(self): self._setItemMaterials([]) self.d.action(DA.ACTIVATE, DP.IDLE) @@ -301,7 +302,7 @@ def test_tool1mat_ok(self): self.d._runner.start_print.assert_called() self.assertEqual(self.d.state, self.d._state_printing) - def test_tool1mat_ok(self): + def test_tool2mat_ok(self): self._setItemMaterials([None, "tool2mat"]) self.d.action(DA.ACTIVATE, DP.IDLE) self.d.action(DA.TICK, DP.IDLE, materials=[None, "tool2mat"]) @@ -322,5 +323,6 @@ def test_tool1mat_tool2mat_reversed(self): self.d._runner.start_print.assert_not_called() self.assertEqual(self.d.state, self.d._state_start_print) + if __name__ == "__main__": unittest.main() diff --git a/continuousprint/static/js/continuousprint_queueset.js b/continuousprint/static/js/continuousprint_queueset.js index 1f690da..2c4b0ad 100644 --- a/continuousprint/static/js/continuousprint_queueset.js +++ b/continuousprint/static/js/continuousprint_queueset.js @@ -31,7 +31,7 @@ function CPQueueSet(items) { } return false; }); - + self._textColorFromBackground = function(rrggbb) { // https://stackoverflow.com/a/12043228 var rgb = parseInt(rrggbb.substr(1), 16); // convert rrggbb to decimal @@ -39,7 +39,7 @@ function CPQueueSet(items) { var g = (rgb >> 8) & 0xff; // extract green var b = (rgb >> 0) & 0xff; // extract blue var luma = 0.2126 * r + 0.7152 * g + 0.0722 * b; // per ITU-R BT.709 - return (luma >= 128) ? "#000000" : "#FFFFFF"; + return (luma >= 128) ? "#000000" : "#FFFFFF"; } self._materialShortName = function(m) { m = m.trim().toUpperCase(); @@ -71,7 +71,7 @@ function CPQueueSet(items) { result.push({ title: i.replaceAll("_", " "), shortName: self._materialShortName(split[0]), - color: self._textColorFromBackground(bg), + color: self._textColorFromBackground(bg), bgColor: bg, key: i, }); diff --git a/continuousprint/templates/continuousprint_settings.jinja2 b/continuousprint/templates/continuousprint_settings.jinja2 index 300c607..ccb18ba 100644 --- a/continuousprint/templates/continuousprint_settings.jinja2 +++ b/continuousprint/templates/continuousprint_settings.jinja2 @@ -11,7 +11,7 @@
-
+
@@ -19,7 +19,7 @@
-
+
@@ -120,7 +120,7 @@
- +

Material Selection

diff --git a/docs/contributing.md b/docs/contributing.md index c1777fe..7838b08 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -56,7 +56,7 @@ if you installed the dev tools (step 2) you can run `mkdocs serve` from the root ## 3. Run unit tests to verify changes -When you've made your changes, it's important to test for regressions. +When you've made your changes, it's important to test for regressions. Run python tests with this command: @@ -89,16 +89,16 @@ Users of [OctoPi](https://octoprint.org/download/) can install a development ver Note that we're using the bundled version of python3 that comes with octoprint, **NOT** the system installed python3. If you try the latter, it'll give an error that sounds like octoprint isn't installed. -## 5. Submit a pull request +## 5. Submit a pull request -When you've made and tested your changes, follow the remaining instructions for [contributing to projects](https://docs.github.com/en/get-started/quickstart/contributing-to-projects) to create a pull request. +When you've made and tested your changes, follow the remaining instructions for [contributing to projects](https://docs.github.com/en/get-started/quickstart/contributing-to-projects) to create a pull request. !!! important - New pull requests must be submitted to the `rc` branch, **not to the `master` branch**. - + New pull requests must be submitted to the `rc` branch, **not to the `master` branch**. + Additonally, the [plugin version line](https://github.com/smartin015/continuousprint/blob/rc/setup.py#L17) in `setup.py` **must have an incremented `rc` number** (e.g. `1.5.0rc2` -> `1.5.0rc3`, `1.6.1` -> `1.6.2rc1`). - + This allows users to test the "release candidate" and shake out any bugs before everyone receives the change. You should receive a review within a day or so - if you haven't heard back in a week or more, [email the plugin author](https://github.com/smartin015/continuousprint/blob/master/setup.py#L27). diff --git a/docs/failure-recovery.md b/docs/failure-recovery.md index 6372768..ed194f8 100644 --- a/docs/failure-recovery.md +++ b/docs/failure-recovery.md @@ -6,13 +6,13 @@ Follow the steps in this guide to help Continuous Print recover from unexpected ## Spaghetti Detection and Recovery -By default, the print queue doesn't know whether your print is proceeding fine or spraying filament everywhere ("spaghettification"). +By default, the print queue doesn't know whether your print is proceeding fine or spraying filament everywhere ("spaghettification"). Follow [The Spaghetti Detective installation instructions](https://www.thespaghettidetective.com/docs/octoprint-plugin-setup/) on your octoprint installation, then restart OctoPrint. Continuous Print will automatically detect that TSD is installed and will enable queue recovery when spaghetti is detected (TSD plugin must be `v1.8.11` or higher). When TSD thinks the print is failing: -1. Continuous Print checks how long the current print has been running. If the failure was detected late into the print, the queue will pause and wait for user input. +1. Continuous Print checks how long the current print has been running. If the failure was detected late into the print, the queue will pause and wait for user input. 2. Otherwise, it looks to see how many time this specific print has been attempted. If it's been tried too many times, the queue pauses and waits for user input. 3. Otherwise, run the failure clearing script and try the print again. @@ -26,4 +26,3 @@ By going to Settings -> Continuous Print and scrolling down to "Failure Recovery * The amount of time a print can run before Continuous Print pauses the qeueue on failure * The number of allowed retries before stopping the queue - diff --git a/docs/gcode-scripting.md b/docs/gcode-scripting.md index 2d44906..714c1bc 100644 --- a/docs/gcode-scripting.md +++ b/docs/gcode-scripting.md @@ -4,7 +4,7 @@ GCODE scripts can be quite complex - if you want to learn the basics, try readin ## Bed clearing scripts -When Continuous Print is managing the queue, this script is run after every print completes - **including prints started before queue managing begins**. +When Continuous Print is managing the queue, this script is run after every print completes - **including prints started before queue managing begins**. Your cleaning script should remove all 3D print material from the build area to make way for the next print. @@ -22,4 +22,3 @@ When you come up with a useful script for e.g. clearing the print bed, consider 4. Now go to [gcode_scripts.yaml](https://github.com/smartin015/continuousprint/tree/rc/continuousprint/data/gcode_scripts.yaml) and edit it in the same way, adding your gcode and any additional fields. 5. Save this print to the same branch (or create a new one if a matching printer profile already exists). 6. Check one last time that the script names match those provided in your printer profiles `defaults` section, then submit a pull request. **Make sure to do a PR against the `rc` branch, NOT the `master` branch.** - diff --git a/docs/material-selection.md b/docs/material-selection.md index 28dbe3d..984b222 100644 --- a/docs/material-selection.md +++ b/docs/material-selection.md @@ -6,7 +6,7 @@ Follow the steps in this guide to support tagging queued items with specific mat Octoprint doesn't know by default what spools of filament you have on hand, nor what's loaded into which hotend of your printer. For this reason, we'll need to install [SpoolManager](https://plugins.octoprint.org/plugins/SpoolManager/), which manages all spool informations and stores it in a database. -Follow the [setup instructions](https://github.com/OllisGit/OctoPrint-SpoolManager#setup) for SpoolManager, then restart OctoPrint. +Follow the [setup instructions](https://github.com/OllisGit/OctoPrint-SpoolManager#setup) for SpoolManager, then restart OctoPrint. To confirm everything's operational, go to `Settings > Continuous Print` and scroll to "Material Selection". If you see "Material selection is enabled", then you're good to go! @@ -18,7 +18,7 @@ To confirm everything's operational, go to `Settings > Continuous Print` and scr When you add a new print file to the queue, it assumes nothing about material by default - any material type and color will be used to print it. -Materials are implemented at the level of Sets (see [here](https://smartin015.github.io/continuousprint/getting-started/#use-jobs-to-group-your-print-files) for definition). If you want a set to print in a certain color or material, click the triangle next to its name. If material selection is enabled (as above), you should see drop-down boxes for each extruder your printer has. +Materials are implemented at the level of Sets (see [here](https://smartin015.github.io/continuousprint/getting-started/#use-jobs-to-group-your-print-files) for definition). If you want a set to print in a certain color or material, click the triangle next to its name. If material selection is enabled (as above), you should see drop-down boxes for each extruder your printer has. Select the desired materials for your desired hotends (called "tools" in OctoPrint parlance). If you leave a tool empty, it's considered unconstrained - any material will do. From ae795c10a3496bd8ba7f597338d7bd8519e2f891 Mon Sep 17 00:00:00 2001 From: Scott Martin Date: Tue, 19 Apr 2022 11:50:37 -0400 Subject: [PATCH 14/20] Fix JS tests, small tweak to UX, fix bug in spool lookup --- continuousprint/static/css/continuousprint.css | 1 + continuousprint/static/js/continuousprint_api.js | 2 +- continuousprint/static/js/continuousprint_viewmodel.test.js | 2 +- continuousprint/templates/continuousprint_tab.jinja2 | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/continuousprint/static/css/continuousprint.css b/continuousprint/static/css/continuousprint.css index e3e36fd..772cc77 100644 --- a/continuousprint/static/css/continuousprint.css +++ b/continuousprint/static/css/continuousprint.css @@ -121,6 +121,7 @@ #tab_plugin_continuousprint div.material_select { display: flex; flex-direction: row; + flex-wrap: wrap; justify-content: space-between; gap: 1vh; } diff --git a/continuousprint/static/js/continuousprint_api.js b/continuousprint/static/js/continuousprint_api.js index 9343487..ee8d83b 100644 --- a/continuousprint/static/js/continuousprint_api.js +++ b/continuousprint/static/js/continuousprint_api.js @@ -27,7 +27,7 @@ class CPAPI { } getSpoolManagerState(cb) { - this._call("plugin/SpoolManager/loadSpoolsByQuery?selectedPageSize=25&from=0&to=25&sortColumn=displayName&sortOrder=desc&filterName=&materialFilter=all&vendorFilter=all&colorFilter=all", cb, "GET"); + this._call("plugin/SpoolManager/loadSpoolsByQuery?from=0&to=1000000&sortColumn=displayName&sortOrder=desc&filterName=&materialFilter=all&vendorFilter=all&colorFilter=all", cb, "GET"); } } diff --git a/continuousprint/static/js/continuousprint_viewmodel.test.js b/continuousprint/static/js/continuousprint_viewmodel.test.js index 44dc923..1ab8359 100644 --- a/continuousprint/static/js/continuousprint_viewmodel.test.js +++ b/continuousprint/static/js/continuousprint_viewmodel.test.js @@ -10,7 +10,7 @@ function mocks(filename="test.gcode") { {}, // loginState only used in continuousprint.js {onServerDisconnect: jest.fn(), onServerConnect: jest.fn()}, {currentProfileData: () => {return {extruder: {count: () => 1}}}}, - {assign: jest.fn(), getState: jest.fn(), setActive: jest.fn()}, + {assign: jest.fn(), getState: jest.fn(), setActive: jest.fn(), getSpoolManagerState: jest.fn()}, ]; } diff --git a/continuousprint/templates/continuousprint_tab.jinja2 b/continuousprint/templates/continuousprint_tab.jinja2 index 9659f45..528bb9f 100644 --- a/continuousprint/templates/continuousprint_tab.jinja2 +++ b/continuousprint/templates/continuousprint_tab.jinja2 @@ -84,7 +84,7 @@
Materials:
- +
Name