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/README.md b/README.md index 57c3c7d..7fc54e4 100644 --- a/README.md +++ b/README.md @@ -15,4 +15,4 @@ WARNING: Your printer must have a method of clearing the bed automatically, with See https://smartin015.github.io/continuousprint/ for all documentation on installation, setup, queueing strategies, and development. -See also [here](https://octo-plugin-stats2-a6l7lv6h7-smartin015.vercel.app/) for adoption stats. +See also [here](https://octo-plugin-stats2.vercel.app/) for adoption stats. diff --git a/continuousprint/__init__.py b/continuousprint/__init__.py index a382a37..1839ac4 100644 --- a/continuousprint/__init__.py +++ b/continuousprint/__init__.py @@ -5,6 +5,8 @@ import octoprint.util import flask import json +import yaml +import os import time from io import BytesIO from octoprint.server.util.flask import restricted_access @@ -13,9 +15,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" @@ -30,6 +34,8 @@ 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, @@ -53,27 +59,24 @@ 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[CLEARING_SCRIPT_KEY] = "" + 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 d[RESTART_MAX_RETRIES_KEY] = 3 d[RESTART_ON_PAUSE_KEY] = False d[RESTART_MAX_TIME_KEY] = 60 * 60 @@ -81,8 +84,12 @@ 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): + 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(): @@ -105,38 +112,83 @@ 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("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( 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 + + 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 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] + + # 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 @@ -147,23 +199,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 +217,24 @@ 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 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.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 +252,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") @@ -235,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( @@ -253,6 +286,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 +299,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 +314,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,9 +343,8 @@ 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() @@ -353,6 +386,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"), @@ -441,25 +475,14 @@ 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( @@ -480,6 +503,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/data/gcode_scripts.yaml b/continuousprint/data/gcode_scripts.yaml new file mode 100644 index 0000000..f7ef237 --- /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/driver.py b/continuousprint/driver.py index 013fa56..4be1af6 100644 --- a/continuousprint/driver.py +++ b/continuousprint/driver.py @@ -1,4 +1,20 @@ 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 @@ -16,76 +32,81 @@ 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 + self._cur_path = None + self._cur_materials = [] - 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 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 - def _set_status(self, status): - self.status = status - self._logger.info(status) + if self._update_ui: + self._update_ui = False + return True + return False - def set_retry_on_pause( - self, enabled, max_retries=3, retry_threshold_seconds=60 * 60 - ): - self.retry_on_pause = enabled - self.max_retries = max_retries - self.retry_threshold_seconds = retry_threshold_seconds - self._logger.info( - f"Retry on pause: {enabled} (max_retries {max_retries}, threshold {retry_threshold_seconds}s)" - ) + def _state_unknown(self, a: Action, p: Printer): + if a == Action.DEACTIVATE: + return self._state_inactive - 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)") + def _state_inactive(self, a: Action, p: Printer): + self.retries = 0 + + if a == Action.ACTIVATE: + if p != Printer.IDLE: + return self._state_printing else: - self._set_status("Inactive (ready - click Start Managing)") + # TODO "clear bed on startup" setting + return self._state_start_print - def _cur_idx(self): - for (i, item) in enumerate(self.q): - if item.start_ts is not None and item.end_ts is None: - return i - return None + if p == Printer.IDLE: + self._set_status("Inactive (click Start Managing)") + else: + self._set_status("Inactive (active print continues unmanaged)") - def current_path(self): - idx = self._cur_idx() - return None if idx is None else self.q[idx].name + def _state_start_print(self, a: Action, p: Printer): + if a == Action.DEACTIVATE: + return self._state_inactive - def _next_available_idx(self): - for (i, item) in enumerate(self.q): - if item.end_ts is None: - return i - return None + 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 - 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() @@ -95,116 +116,162 @@ def _begin_next_available_print(self): 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 + self._intent = self._runner.start_print(p) + return self._state_printing else: - self.actions.append(self._finish) - - def _finish(self): - self._set_status("Running finish script") - self.finish_script_fn() + return self._state_inactive - def _clear_bed(self): - self._set_status("Running bed clearing script") - self.clear_bed_fn() + def _elapsed(self): + return time.time() - self.q[self._cur_idx()].start_ts - 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 _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 - def _complete_item(self, idx, result): - item = self.q[idx] - item.end_ts = int(time.time()) - item.result = result - self.q[idx] = item # TODO necessary? + 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 pending_actions(self): - return len(self.actions) + 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 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 _state_spaghetti_recovery(self, a: Action, p: Printer): + 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 on_print_success(self, is_finish_script=False): - if not self.active: + 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() - if idx is not None: - self._complete_item(idx, "success") + # 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 - if is_finish_script: - self.set_active(False) + # 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: - self.actions.append(self._begin_next_available_print) + return self._state_start_finishing - def on_print_failed(self): - if not self.active: + 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._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: + 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 - idx = self._cur_idx() + self._set_status("Clearing bed") + return self._state_start_print - 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") + 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 - 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" - ) + 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 + + self._set_status("Finising up") + + return self._state_inactive + + def _set_status(self, 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 + ): + self.retry_on_pause = enabled + self.max_retries = max_retries + self.retry_threshold_seconds = retry_threshold_seconds + self._logger.debug( + f"Retry on pause: {enabled} (max_retries {max_retries}, threshold {retry_threshold_seconds}s)" + ) + + def _cur_idx(self): + for (i, item) in enumerate(self.q): + if item.start_ts is not None and item.end_ts is None: + return i + return None - def on_print_resumed(self): - # This happens after pause & manual resume + def current_path(self): idx = self._cur_idx() - if idx is not None: - self._set_status(f"Printing {self.q[idx].name}") + return None if idx is None else self.q[idx].name + + def _next_available_idx(self): + for (i, item) in enumerate(self.q): + if item.end_ts is None: + return i + return None + + 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? diff --git a/continuousprint/driver_test.py b/continuousprint/driver_test.py index 1d6ca9f..2efabd3 100644 --- a/continuousprint/driver_test.py +++ b/continuousprint/driver_test.py @@ -1,14 +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) -def setupTestQueueAndDriver(self, num_complete): +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): self.s = MockSettings("q") self.q = PrintQueue(self.s, "q") self.q.assign( @@ -25,189 +33,295 @@ 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) + self.d.action(DA.DEACTIVATE, DP.IDLE) -def flush(d): - while d.pending_actions() > 0: - d.on_printer_ready() - - -class TestQueueManagerFromInitialState(unittest.TestCase): +class TestFromInitialState(unittest.TestCase): 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]) - - -class TestQueueManagerPartiallyComplete(unittest.TestCase): + 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 TestPartiallyComplete(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 + 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_cancelled(initiator=None) - flush(self.d) - self.d.start_print_fn.assert_not_called() - self.assertEqual(self.d.active, False) + 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) - 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() +class TestOnLastPrint(unittest.TestCase): + def setUp(self): + setupTestQueueAndDriver(self, 2) - def test_failure_sets_inactive(self): - self.d.set_active() - self.d.start_print_fn.reset_mock() + 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._runner.start_print.reset_mock() - self.d.on_print_failed() - flush(self.d) - self.d.start_print_fn.assert_not_called() - self.assertEqual(self.d.active, False) + self.d._intent = self.q[-1].path + self.d._cur_path = self.d._intent - def test_resume_sets_status(self): - self.d.set_active() - self.d.start_print_fn.reset_mock() + 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.on_print_resumed() - self.assertTrue("paused" not in self.d.status.lower()) + self.d.action(DA.TICK, DP.IDLE) # -> inactive + self.assertEqual(self.d.state, self.d._state_inactive) -class TestQueueManagerOnLastPrint(unittest.TestCase): +class TestMaterialConstraints(unittest.TestCase): 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) + 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_tool2mat_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__": diff --git a/continuousprint/print_queue.py b/continuousprint/print_queue.py index 1405d2d..515e0b1 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 @@ -72,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/css/continuousprint.css b/continuousprint/static/css/continuousprint.css index 4fe870c..772cc77 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,10 +115,26 @@ border: none; box-shadow: none; } +#tab_plugin_continuousprint .label { + border: 1px gray solid; +} +#tab_plugin_continuousprint div.material_select { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: space-between; + gap: 1vh; +} + #tab_plugin_continuousprint ::placeholder { 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.js b/continuousprint/static/js/continuousprint.js index 705963b..704f25f 100644 --- a/continuousprint/static/js/continuousprint.js +++ b/continuousprint/static/js/continuousprint.js @@ -28,8 +28,15 @@ $(function() { "printerStateViewModel", "loginStateViewModel", "filesViewModel", - "settingsViewModel", + "printerProfilesViewModel", ], elements: ["#tab_plugin_continuousprint"] }); + OCTOPRINT_VIEWMODELS.push({ + construct: CPSettingsViewModel, + dependencies: [ + "settingsViewModel", + ], + elements: ["#settings_plugin_continuousprint"] + }); }); diff --git a/continuousprint/static/js/continuousprint_api.js b/continuousprint/static/js/continuousprint_api.js index fc82aa5..ee8d83b 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?from=0&to=1000000&sortColumn=displayName&sortOrder=desc&filterName=&materialFilter=all&vendorFilter=all&colorFilter=all", cb, "GET"); + } } try { 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..2c4b0ad 100644 --- a/continuousprint/static/js/continuousprint_queueset.js +++ b/continuousprint/static/js/continuousprint_queueset.js @@ -31,6 +31,53 @@ 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 + 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) { + if (i === null || i === "") { + result.push({ + title: "any", + shortName: " ", + color: "transparent", + bgColor: "transparent", + key: i, + }); + continue; + } + let split = i.split("_"); + let bg = split[2] || ""; + result.push({ + title: i.replaceAll("_", " "), + 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 +172,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_settings.js b/continuousprint/static/js/continuousprint_settings.js new file mode 100644 index 0000000..40ec438 --- /dev/null +++ b/continuousprint/static/js/continuousprint_settings.js @@ -0,0 +1,63 @@ +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) + self.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 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() { + 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]); + } +} + + +try { +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/static/js/continuousprint_viewmodel.js b/continuousprint/static/js/continuousprint_viewmodel.js index 1e765c5..27e36de 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 @@ -59,6 +60,16 @@ function CPViewModel(parameters) { self.jobs = ko.observableArray([]); self.selected = ko.observable(null); + self.materials = ko.observable([]); + self.api.getSpoolManagerState(function(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)); + }); + self.isSelected = function(j=null, q=null) { j = self._resolve(j); q = self._resolve(q); @@ -134,6 +145,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); }); @@ -190,7 +202,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); @@ -282,6 +294,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/static/js/continuousprint_viewmodel.test.js b/continuousprint/static/js/continuousprint_viewmodel.test.js index 19704a5..1ab8359 100644 --- a/continuousprint/static/js/continuousprint_viewmodel.test.js +++ b/continuousprint/static/js/continuousprint_viewmodel.test.js @@ -9,8 +9,8 @@ function mocks(filename="test.gcode") { }, {}, // loginState only used in continuousprint.js {onServerDisconnect: jest.fn(), onServerConnect: jest.fn()}, - {}, // settings apparently unused - {assign: jest.fn(), getState: jest.fn(), setActive: jest.fn()}, + {currentProfileData: () => {return {extruder: {count: () => 1}}}}, + {assign: jest.fn(), getState: jest.fn(), setActive: jest.fn(), getSpoolManagerState: jest.fn()}, ]; } diff --git a/continuousprint/templates/continuousprint_settings.jinja2 b/continuousprint/templates/continuousprint_settings.jinja2 index 09c6623..ccb18ba 100644 --- a/continuousprint/templates/continuousprint_settings.jinja2 +++ b/continuousprint/templates/continuousprint_settings.jinja2 @@ -1,18 +1,65 @@ -

Continuous Print Settings

+

Continuous Print

+ +
+

Printer Profiles

+

Load community-contributed bed clearing scripts for your printer:

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

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

+ +

Customize

+

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

+ +
+
Bed clearing & Finishing
+
+ +
+ +
+
+
+ +
+ +
+
+
Bed Cooldown Settings

If enabled, when print in queue is finished OctoPrint will run Bed Cooldown Script, turn the heated bed off and monitor the bed temperature. After either the Bed Cooldown Threshold or Bed Cooldown Timeout is - met the Bed clearing routine will continue. + met, the bed clearing routine will continue.

- +
@@ -20,50 +67,33 @@
- +
- +
- +
-

Basic Scripts

-

- 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.

@@ -74,7 +104,7 @@
- + seconds ago
@@ -83,11 +113,27 @@
- + retries
+ +
+

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 a1febbc..528bb9f 100644 --- a/continuousprint/templates/continuousprint_tab.jinja2 +++ b/continuousprint/templates/continuousprint_tab.jinja2 @@ -52,14 +52,16 @@
-
+
-

+ + +

@@ -72,11 +74,29 @@
+
+
+
Materials:
+
+
+ + +
+
+
+
+ Hint: Install SpoolManager, add spools, then reload the page to select material types. +
diff --git a/docs/contributing.md b/docs/contributing.md index c1777fe..3046fb0 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -52,11 +52,15 @@ You should see "Successfully installed continuousprint" when running the install Continuous Print uses [mkdocs](https://www.mkdocs.org/) to generate web documentation. All documentation lives in `docs/`. +```shell +pip install mkdocs mkdocs-material +``` + if you installed the dev tools (step 2) you can run `mkdocs serve` from the root of the repository to see doc edits live at [http://localhost:8000](http://localhost:8000). ## 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 +93,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/cpq_material_selection.png b/docs/cpq_material_selection.png new file mode 100644 index 0000000..3321429 Binary files /dev/null and b/docs/cpq_material_selection.png differ 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 7f52f8e..714c1bc 100644 --- a/docs/gcode-scripting.md +++ b/docs/gcode-scripting.md @@ -2,82 +2,23 @@ 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**. +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 -``` - -### Advance Belt +## Contributing -This matches the "Advance Belt" script above. +When you come up with a useful script for e.g. clearing the print bed, consider contributing it back to the community! -``` -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) -``` +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.** diff --git a/docs/managed-bed-cooldown.md b/docs/managed-bed-cooldown.md new file mode 100644 index 0000000..a4e5a66 --- /dev/null +++ b/docs/managed-bed-cooldown.md @@ -0,0 +1,24 @@ +# Managed Bed Cooldown +## Use Case +Depending on your printer model the g-code instruction M190 (Wait for Bed Temperature) is not always respected +when the targed temperature is cooling down. +For printers that don't respect the M190 cooldown instruction but depend on the bed cooling to a specified temperature +this feature should be enabled. + +## Configure feature +This feature can be configured in the Continuous Print settings panel under `Bed Cooldown Settings`. + +**Enable Managed Bed Cooldown Box** enables and disables the feature. + + +**Bed Cooldown Script** is the G-Code script that will run once print in queue is finished, but before bed cooldown is run. Useful for triggering events via g-code like activating part cooling fan, or moving print head from above part while it cools. + +**Bed Cooldown Threshold** is the temperature in Celsius that once met triggers the bed to clear. +The goal is to pick a temperature at which the part becomes free from the bed. Example temperature range is around 25 to 35 but depends greatly on your bed material. Experiment to find the best threshold for your printer. + +**Bed Cooldown Timeout** a timeout in minutes starting from after the bed clear script has run when once exceeded bed will be cleared regardless of bed temperature. Useful for cases where the target bed temperature is not being met, but the part is ready to be cleared anyway. Useful for cases where the part cools faster than the bed, or external environment is too hot so bed is not meeting temperature, but part has already cooled enough. + + +Once configured the final event flow will look like this + +`PRINT FINISHES -> Bed Cooldown Script Runs -> Bed is turned off -> Wait until measured temp meets threshold OR timeout is exceeded -> Bed Clearing Script Runs -> NEXT PRINT BEGINS` diff --git a/docs/material-selection.md b/docs/material-selection.md new file mode 100644 index 0000000..984b222 --- /dev/null +++ b/docs/material-selection.md @@ -0,0 +1,34 @@ +# Material Selection (BETA) + +Follow the steps in this guide to support tagging queued items with specific material requirements. + +## Enabling material selection + +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. + +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! + +!!! 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..1f35e71 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -23,6 +23,8 @@ nav: - Getting Started: getting-started.md - GCODE Scripting: gcode-scripting.md - Advanced Queuing: advanced-queuing.md + - Managed Bed Cooldown: managed-bed-cooldown.md - Failure Recovery: failure-recovery.md + - Material Selection: material-selection.md - Contributing: contributing.md - API: api.md diff --git a/setup.py b/setup.py index 33bfd30..e62797d 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.0" +plugin_version = "1.6.0" # The plugin's description. Can be overwritten within OctoPrint's internal data via __plugin_description__ in the plugin # module @@ -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 = []
Name