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