diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..007f765c --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,34 @@ +name: Lint + +on: + # Trigger the workflow on push or pull request, + # but only for the main branch + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + run-linters: + name: Run linters + runs-on: ubuntu-latest + + steps: + - name: Check out Git repository + uses: actions/checkout@v2 + + - name: Set up Python + uses: actions/setup-python@v1 + with: + python-version: 3.8 + + - name: Install Python dependencies + run: pip install black flake8 + + - name: Run linters + uses: wearerequired/lint-action@v1 + with: + black: true + flake8: true diff --git a/continuousprint/__init__.py b/continuousprint/__init__.py index c36ae9b3..dba6335f 100644 --- a/continuousprint/__init__.py +++ b/continuousprint/__init__.py @@ -6,387 +6,298 @@ from octoprint.server.util.flask import restricted_access from octoprint.events import eventManager, Events -class ContinuousprintPlugin(octoprint.plugin.SettingsPlugin, - octoprint.plugin.TemplatePlugin, - octoprint.plugin.AssetPlugin, - octoprint.plugin.StartupPlugin, - octoprint.plugin.BlueprintPlugin, - octoprint.plugin.EventHandlerPlugin): - print_history = [] - enabled = False - paused = False - looped = False - item = None; - - ##~~ SettingsPlugin mixin - def get_settings_defaults(self): - return dict( - cp_queue="[]", - cp_bed_clearing_script="M17 ;enable steppers\nG91 ; Set relative for lift\nG0 Z10 ; lift z by 10\nG90 ;back to absolute positioning\nM190 R25 ; set bed to 25 for cooldown\nG4 S90 ; wait for temp stabalisation\nM190 R30 ;verify temp below threshold\nG0 X200 Y235 ;move to back corner\nG0 X110 Y235 ;move to mid bed aft\nG0 Z1v ;come down to 1MM from bed\nG0 Y0 ;wipe forward\nG0 Y235 ;wipe aft\nG28 ; home", - cp_queue_finished="M18 ; disable steppers\nM104 T0 S0 ; extruder heater off\nM140 S0 ; heated bed heater off\nM300 S880 P300 ; beep to show its finished", - cp_looped="false", - cp_print_history="[]" - - ) - - - - - ##~~ StartupPlugin mixin - def on_after_startup(self): - self._logger.info("Continuous Print Plugin started") - self._settings.save() - - - - - ##~~ Event hook - def on_event(self, event, payload): - try: - ## Print complete check it was the print in the bottom of the queue and not just any print - if event == Events.PRINT_DONE: - if self.enabled == True: - self.complete_print(payload) - - # On fail stop all prints - if event == Events.PRINT_FAILED or event == Events.PRINT_CANCELLED: - self.enabled = False # Set enabled to false - self._plugin_manager.send_plugin_message(self._identifier, dict(type="error", msg="Print queue cancelled")) - - if event == Events.PRINTER_STATE_CHANGED: - # If the printer is operational and the last print succeeded then we start next print - state = self._printer.get_state_id() - if state == "OPERATIONAL": - if self.enabled == True and self.paused == False: - self.start_next_print() - - if event == Events.FILE_SELECTED: - # Add some code to clear the print at the bottom - self._logger.info("File selected") - bed_clearing_script=self._settings.get(["cp_bed_clearing_script"]) - - if event == Events.UPDATED_FILES: - self._plugin_manager.send_plugin_message(self._identifier, dict(type="updatefiles", msg="")) - except Exception as error: - raise error - self._logger.exception("Exception when handling event.") - - def complete_print(self, payload): - queue = json.loads(self._settings.get(["cp_queue"])) - LOOPED=self._settings.get(["cp_looped"]) - self.item = queue[0] - if payload["path"] == self.item["path"] and self.item["count"] > 0: - - # check to see if loop count is set. If it is increment times run. - - if "times_run" not in self.item: - self.item["times_run"] = 0 - - self.item["times_run"] += 1 - - - - # On complete_print, remove the item from the queue - # if the item has run for loop count or no loop count is specified and - # if looped is True requeue the item. - if self.item["times_run"] >= self.item["count"]: - self.item["times_run"] = 0 - queue.pop(0) - if LOOPED=="false": - self.looped=False - if LOOPED=="true": - self.looped=True - if self.looped==True and self.item!=None: - queue.append(self.item) - - self._settings.set(["cp_queue"], json.dumps(queue)) - self._settings.save() - - #Add to the print History - - print_history = json.loads(self._settings.get(["cp_print_history"])) - # #calculate time - # time=payload["time"]/60; - # suffix="mins" - # if time>60: - # time = time/60 - # suffix = "hours" - # if time>24: - # time= time/24 - # suffix= "days" - # #Add to the print History - # inPrintHistory=False - # if len(print_history)==1 and item["path"]==print_history[0]["path"]: - # print_history[0]=dict( - # path = payload["path"], - # name = payload["name"], - # time = (print_history[0]["time"]+payload["time"])/(print_history[0]["times_run"]+1), - # times_run = print_history[0]["times_run"]+1, - # title = print_history[0]["title"]+" "+print_history[i]["times_run"]+". " + str(int(time))+suffix - # ) - # inPrintHistory=True - # if len(print_history)>1: - # for i in range(0,len(print_history)-1): - # if item["path"]==print_history[i]["path"] and InPrintHistory != True: - # print_history[i]=dict( - # path = payload["path"], - # name = payload["name"], - # time = (print_history[i]["time"]+payload["time"])/(print_history[i]["times_run"]+1), - # times_run = print_history[i]["times_run"]+1, - # title = print_history[i]["title"]+" "+print_history[i]["times_run"]+". " + str(int(time))+suffix - # ) - # inPrintHistory=True - # if inPrintHistory == False: - # print_history.append(dict( - # path = payload["path"], - # name = payload["name"], - # time = payload["time"], - # times_run = item["times_run"], - # title="Print Times: 1. "+str(int(time))+suffix - # )) - # - print_history.append(dict( - name = payload["name"], - time = payload["time"] - )) - - #save print history - self._settings.set(["cp_print_history"], json.dumps(print_history)) - self._settings.save() - - # Clear down the bed - if len(queue)>0: - self.clear_bed() - - # Tell the UI to reload - self._plugin_manager.send_plugin_message(self._identifier, dict(type="reload", msg="")) - else: - enabled = False - - - def parse_gcode(self, input_script): - script = [] - for x in input_script: - if x.find("[PAUSE]", 0) > -1: - self.paused = True - self._plugin_manager.send_plugin_message(self._identifier, dict(type="paused", msg="Queue paused")) - else: - script.append(x) - return script - - - - - def clear_bed(self): - self._logger.info("Clearing bed") - bed_clearing_script=self._settings.get(["cp_bed_clearing_script"]).split("\n") - self._printer.commands(self.parse_gcode(bed_clearing_script),force=True) - - def complete_queue(self): - self.enabled = False # Set enabled to false - self._plugin_manager.send_plugin_message(self._identifier, dict(type="complete", msg="Print Queue Complete")) - queue_finished_script = self._settings.get(["cp_queue_finished"]).split("\n") - self._printer.commands(self.parse_gcode(queue_finished_script,force=True))#send queue finished script to the printer - - - - def start_next_print(self): - if self.enabled == True and self.paused == False: - queue = json.loads(self._settings.get(["cp_queue"])) - - - if len(queue) > 0: - self._plugin_manager.send_plugin_message(self._identifier, dict(type="popup", msg="Starting print: " + queue[0]["name"])) - self._plugin_manager.send_plugin_message(self._identifier, dict(type="reload", msg="")) - - sd = False - if queue[0]["sd"] == "true": - sd = True - try: - self._printer.select_file(queue[0]["path"], sd) - self._logger.info(queue[0]["path"]) - self._printer.start_print() - except InvalidFileLocation: - self._plugin_manager.send_plugin_message(self._identifier, dict(type="popup", msg="ERROR file not found")) - except InvalidFileType: - self._plugin_manager.send_plugin_message(self._identifier, dict(type="popup", msg="ERROR file not gcode")) - else: - self.complete_queue() - - - - ##~~ APIs - @octoprint.plugin.BlueprintPlugin.route("/looped", methods=["GET"]) - @restricted_access - def looped(self): - loop2=self._settings.get(["cp_looped"]) - return loop2 - - @octoprint.plugin.BlueprintPlugin.route("/loop", methods=["GET"]) - @restricted_access - def loop(self): - self.looped=True - self._settings.set(["cp_looped"], "true") - - - - @octoprint.plugin.BlueprintPlugin.route("/unloop", methods=["GET"]) - @restricted_access - def unloop(self): - self.looped=False - self._settings.set(["cp_looped"], "false") - - - @octoprint.plugin.BlueprintPlugin.route("/queue", methods=["GET"]) - @restricted_access - def get_queue(self): - #this is getting to be quite redundant. Turning an array of jsons into a dictionary just so flask can turn it into a json of an array of jsons. - #return flask.jsonify(queue=json.loads(self._settings.get(["cp_queue"]))) - return '{"queue":' + self._settings.get(["cp_queue"]) + "}" - - @octoprint.plugin.BlueprintPlugin.route("/print_history", methods=["GET"]) - @restricted_access - def get_print_history(self): - #return flask.jsonify(queue=json.loads(self._settings.get(["cp_print_history"]))) - return'{"queue":' + self._settings.get(["cp_print_history"]) + "}" - - @octoprint.plugin.BlueprintPlugin.route("/queueup", methods=["GET"]) - @restricted_access - def queue_up(self): - index = int(flask.request.args.get("index", 0)) - queue = json.loads(self._settings.get(["cp_queue"])) - orig = queue[index] - queue[index] = queue[index-1] - queue[index-1] = orig - self._settings.set(["cp_queue"], json.dumps(queue)) - self._settings.save() - return flask.jsonify(queue=queue) - - @octoprint.plugin.BlueprintPlugin.route("/change", methods=["GET"]) - @restricted_access - def change(self): - index = int(flask.request.args.get("index")) - count = int(flask.request.args.get("count")) - queue = json.loads(self._settings.get(["cp_queue"])) - queue[index]["count"]=count - self._settings.set(["cp_queue"], json.dumps(queue)) - self._settings.save() - return flask.jsonify(queue=queue) - - @octoprint.plugin.BlueprintPlugin.route("/queuedown", methods=["GET"]) - @restricted_access - def queue_down(self): - index = int(flask.request.args.get("index", 0)) - queue = json.loads(self._settings.get(["cp_queue"])) - orig = queue[index] - queue[index] = queue[index+1] - queue[index+1] = orig - self._settings.set(["cp_queue"], json.dumps(queue)) - self._settings.save() - return flask.jsonify(queue=queue) - - @octoprint.plugin.BlueprintPlugin.route("/addqueue", methods=["POST"]) - @restricted_access - def add_queue(self): - queue = json.loads(self._settings.get(["cp_queue"])) - queue.append(dict( - name=flask.request.form["name"], - path=flask.request.form["path"], - sd=flask.request.form["sd"], - count=int(flask.request.form["count"]) - )) - self._settings.set(["cp_queue"], json.dumps(queue)) - self._settings.save() - return flask.make_response("success", 200) - - @octoprint.plugin.BlueprintPlugin.route("/removequeue", methods=["DELETE"]) - @restricted_access - def remove_queue(self): - queue = json.loads(self._settings.get(["cp_queue"])) - self._logger.info(flask.request.args.get("index", 0)) - queue.pop(int(flask.request.args.get("index", 0))) - self._settings.set(["cp_queue"], json.dumps(queue)) - self._settings.save() - return flask.make_response("success", 200) - - @octoprint.plugin.BlueprintPlugin.route("/startqueue", methods=["GET"]) - @restricted_access - def start_queue(self): - self._settings.set(["cp_print_history"], "[]")#Clear Print History - self._settings.save() - self.paused = False - self.enabled = True # Set enabled to true - self.start_next_print() - return flask.make_response("success", 200) - - @octoprint.plugin.BlueprintPlugin.route("/resumequeue", methods=["GET"]) - @restricted_access - def resume_queue(self): - self.paused = False - self.start_next_print() - return flask.make_response("success", 200) - - ##~~ TemplatePlugin - def get_template_vars(self): - return dict( - cp_enabled=self.enabled, - cp_bed_clearing_script=self._settings.get(["cp_bed_clearing_script"]), - cp_queue_finished=self._settings.get(["cp_queue_finished"]), - cp_paused=self.paused - ) - def get_template_configs(self): - return [ - dict(type="settings", custom_bindings=False, template="continuousprint_settings.jinja2"), - dict(type="tab", custom_bindings=False, template="continuousprint_tab.jinja2") - ] - - ##~~ AssetPlugin - def get_assets(self): - return dict( - js=["js/continuousprint.js"], - css=["css/continuousprint.css"] - ) - - - def get_update_information(self): - # Define the configuration for your plugin to use with the Software Update - # Plugin here. See https://docs.octoprint.org/en/master/bundledplugins/softwareupdate.html - # for details. - return dict( - continuousprint=dict( - displayName="Continuous Print Plugin", - displayVersion=self._plugin_version, - - # version check: github repository - type="github_release", - user="Zinc-OS", - repo="continuousprint", - current=self._plugin_version, - stable_branch=dict( - name="Stable", branch="master", comittish=["master"] - ), - prerelease_branches=[ - dict( - name="Release Candidate", - branch="rc", - comittish=["rc", "master"], - ) - ], - # update method: pip - pip="https://github.com/Zinc-OS/continuousprint/archive/{target_version}.zip" - ) - ) +from .print_queue import PrintQueue, QueueItem +from .driver import ContinuousPrintDriver + + +QUEUE_KEY = "cp_queue" +CLEARING_SCRIPT_KEY = "cp_bed_clearing_script" +FINISHED_SCRIPT_KEY = "cp_queue_finished" +RESTART_MAX_RETRIES_KEY = "cp_restart_on_pause_max_restarts" +RESTART_ON_PAUSE_KEY = "cp_restart_on_pause_enabled" +RESTART_MAX_TIME_KEY = "cp_restart_on_pause_max_seconds" + +class ContinuousprintPlugin( + octoprint.plugin.SettingsPlugin, + octoprint.plugin.TemplatePlugin, + octoprint.plugin.AssetPlugin, + octoprint.plugin.StartupPlugin, + octoprint.plugin.BlueprintPlugin, + octoprint.plugin.EventHandlerPlugin, +): + + def _msg(self, msg="", type="popup"): + self._plugin_manager.send_plugin_message( + self._identifier, dict(type=type, msg=msg) + ) + + + def _update_driver_settings(self): + self.d.set_retry_on_pause( + self._settings.get([RESTART_ON_PAUSE_KEY]), + self._settings.get([RESTART_MAX_RETRIES_KEY]), + self._settings.get([RESTART_MAX_TIME_KEY]), + ) + + ##~~ SettingsPlugin + def get_settings_defaults(self): + 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[RESTART_MAX_RETRIES_KEY] = 3 + d[RESTART_ON_PAUSE_KEY] = False + d[RESTART_MAX_TIME_KEY] = 60*60 + return d + + ##~~ StartupPlugin + def on_after_startup(self): + self._settings.save() + self.q = PrintQueue(self._settings, QUEUE_KEY) + self.d = ContinuousPrintDriver( + queue = self.q, + finish_script_fn = self.run_finish_script, + start_print_fn = self.start_print, + cancel_print_fn = self.cancel_print, + logger = self._logger, + ) + self._update_driver_settings() + self._logger.info("Continuous Print Plugin started") + + ##~~ EventHandlerPlugin + def on_event(self, event, payload): + if not hasattr(self, "d"): # Sometimes message arrive pre-init + return + + if event == Events.PRINT_DONE: + self.d.on_print_success() + self._msg(type="reload") # reload UI + elif event == Events.PRINT_FAILED and payload["reason"] != "cancelled": + self.d.on_print_failed() + self._msg(type="reload") # reload UI + elif event == Events.PRINT_CANCELLED: + self.d.on_print_cancelled() + self._msg(type="reload") # reload UI + elif event == Events.PRINT_PAUSED: + self.d.on_print_paused() + self._msg(type="reload") # reload UI + elif event == Events.PRINTER_STATE_CHANGED and self._printer.get_state_id() == "OPERATIONAL": + self._msg(type="reload") # reload UI + 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 + while self._printer.get_state_id() in ["OPERATIONAL", "PAUSED"] and self.d.pending_actions() > 0: + self.d.on_printer_ready() + + def run_finish_script(self, run_finish_script=True): + self._msg("Print Queue Complete", type="complete") + if run_finish_script: + queue_finished_script = self._settings.get([FINISHED_SCRIPT_KEY]).split("\n") + self._printer.commands(queue_finished_script, force=True) + + def cancel_print(self): + self._msg("Print cancelled", type="error") + self._printer.cancel_print() + + def start_print(self, item, clear_bed=True): + if clear_bed: + self._logger.info("Clearing bed") + bed_clearing_script = self._settings.get([CLEARING_SCRIPT_KEY]).split("\n") + self._printer.commands(bed_clearing_script, force=True) + + self._msg("Starting print: " + item.name) + self._msg(type="reload") + try: + self._printer.select_file(item.path, item.sd) + self._logger.info(item.path) + self._printer.start_print() + except InvalidFileLocation: + self._msg("File not found: " + item.path, type="error") + except InvalidFileType: + self._msg("File not gcode: " + item.path, type="error") + + def state_json(self, changed=None): + # Values are stored serialized, so we need to create a json string and inject them + q = self._settings.get([QUEUE_KEY]) + if changed is not None: + q = json.loads(q) + for i in changed: + q[i]["changed"] = True + q = json.dumps(q) + + resp = ('{"active": %s, "status": "%s", "queue": %s}' % ( + "true" if self.d.active else "false", + self.d.status, + q + )) + return resp + + + ##~~ APIs + @octoprint.plugin.BlueprintPlugin.route("/state", methods=["GET"]) + @restricted_access + def state(self): + return self.state_json() + + @octoprint.plugin.BlueprintPlugin.route("/move", methods=["POST"]) + @restricted_access + def move(self): + idx = int(flask.request.form["idx"]) + count = int(flask.request.form["count"]) + offs = int(flask.request.form["offs"]) + self.q.move(idx, count, offs) + return self.state_json(changed=range(idx+offs, idx+offs+count)) + + @octoprint.plugin.BlueprintPlugin.route("/add", methods=["POST"]) + @restricted_access + def add(self): + idx = flask.request.form.get("idx") + if idx is None: + idx = len(self.q) + else: + idx = int(idx) + items = json.loads(flask.request.form["items"]) + self.q.add([QueueItem( + name=i["name"], + path=i["path"], + sd=i["sd"], + ) for i in items], idx) + return self.state_json(changed=range(idx, idx+len(items))) + + @octoprint.plugin.BlueprintPlugin.route("/remove", methods=["POST"]) + @restricted_access + def remove(self): + idx = int(flask.request.form["idx"]) + count = int(flask.request.form["count"]) + self.q.remove(idx, count) + return self.state_json(changed=[idx]) + + + @octoprint.plugin.BlueprintPlugin.route("/set_active", methods=["POST"]) + @restricted_access + def set_active(self): + self.d.set_active(flask.request.form["active"] == "true", printer_ready=(self._printer.get_state_id() == "OPERATIONAL")) + return self.state_json() + + @octoprint.plugin.BlueprintPlugin.route("/clear", methods=["POST"]) + @restricted_access + def clear(self): + i = 0 + keep_failures = (flask.request.form["keep_failures"] == "true") + keep_non_ended = (flask.request.form["keep_non_ended"] == "true") + self._logger.info(f"Clearing queue (keep_failures={keep_failures}, keep_non_ended={keep_non_ended})") + changed = [] + while i < len(self.q): + v = self.q[i] + self._logger.info(f"{v.name} -- end_ts {v.end_ts} result {v.result}") + if v.end_ts is None and keep_non_ended: + i = i + 1 + elif v.result == "failure" and keep_failures: + i = i + 1 + else: + del self.q[i] + changed.append(i) + return self.state_json(changed=changed) + + @octoprint.plugin.BlueprintPlugin.route("/reset", methods=["POST"]) + @restricted_access + def reset(self): + idxs = json.loads(flask.request.form["idxs"]) + for idx in idxs: + i = self.q[idx] + i.start_ts = None + i.end_ts = None + self.q.remove(idx, count) + return self.state_json(changed=[idx]) + + ##~~ 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]), + ) + + def get_template_configs(self): + return [ + dict( + type="settings", + custom_bindings=False, + template="continuousprint_settings.jinja2", + ), + dict( + type="tab", custom_bindings=False, template="continuousprint_tab.jinja2" + ), + ] + + ##~~ AssetPlugin + def get_assets(self): + return dict(js=[ + "js/continuousprint_api.js", + "js/continuousprint.js", + ], css=["css/continuousprint.css"]) + + def get_update_information(self): + # Define the configuration for your plugin to use with the Software Update + # Plugin here. See https://docs.octoprint.org/en/master/bundledplugins/softwareupdate.html + # for details. + return dict( + continuousprint=dict( + displayName="Continuous Print Plugin", + displayVersion=self._plugin_version, + # version check: github repository + type="github_release", + user="Zinc-OS", + repo="continuousprint", + current=self._plugin_version, + stable_branch=dict( + name="Stable", branch="master", comittish=["master"] + ), + prerelease_branches=[ + dict( + name="Release Candidate", + branch="rc", + comittish=["rc", "master"], + ) + ], + # update method: pip + pip="https://github.com/Zinc-OS/continuousprint/archive/{target_version}.zip", + ) + ) __plugin_name__ = "Continuous Print" -__plugin_pythoncompat__ = ">=2.7,<4" # python 2 and 3 +__plugin_pythoncompat__ = ">=3.6,<4" -def __plugin_load__(): - global __plugin_implementation__ - __plugin_implementation__ = ContinuousprintPlugin() - global __plugin_hooks__ - __plugin_hooks__ = { - "octoprint.plugin.softwareupdate.check_config": __plugin_implementation__.get_update_information - } +def __plugin_load__(): + global __plugin_implementation__ + __plugin_implementation__ = ContinuousprintPlugin() + global __plugin_hooks__ + __plugin_hooks__ = { + "octoprint.plugin.softwareupdate.check_config": __plugin_implementation__.get_update_information + } diff --git a/continuousprint/driver.py b/continuousprint/driver.py new file mode 100644 index 00000000..00805fbc --- /dev/null +++ b/continuousprint/driver.py @@ -0,0 +1,162 @@ +import time + + +# Inspired by answers at +# https://stackoverflow.com/questions/6108819/javascript-timestamp-to-relative-time +def timeAgo(elapsed): + if elapsed < 60*60: + return str(round(elapsed/(60))) + ' minutes' + elif elapsed < 60*60*24: + return str(round(elapsed/(60*60))) + ' hours' + else: + return str(round(elapsed/(60*60*24))) + ' days' + + +class ContinuousPrintDriver: + + def __init__(self, queue, finish_script_fn, start_print_fn, cancel_print_fn, logger): + self.q = queue + self.active = False + self.retries = 0 + self._logger = logger + self.retry_on_pause = False + self.max_retries = 0 + self.retry_threshold_seconds = 0 + + self.actions = [] + + self.finish_script_fn = finish_script_fn + self.start_print_fn = start_print_fn + self.cancel_print_fn = cancel_print_fn + self._set_status("Initialized") + + def _set_status(self, status): + 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.info(f"Retry on pause: {enabled} (max_retries {max_retries}, threshold {retry_threshold_seconds}s)") + + def set_active(self, active=True, printer_ready=True): + if active and not self.active: + self.active = True + self.retries = 0 + if not printer_ready: + self._set_status("Waiting for printer to be ready") + else: + self._begin_next_available_print() + self.on_printer_ready() + elif self.active and not active: + self.active = False + if not printer_ready: + self._set_status("Inactive (active prints continue unmanaged)") + else: + self._set_status("Inactive (ready)") + + 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 _next_available_idx(self): + for (i, item) in enumerate(self.q): + if item.end_ts is None: + return i + return None + + def _begin_next_available_print(self): + # The next print may not be the *immediately* next print + # e.g. if we skip over a print or start mid-print + idx = self._next_available_idx() + if idx is not None: + p = self.q[idx] + p.start_ts = int(time.time()) + p.end_ts = None + p.retries = self.retries + self.q[idx] = p + + if 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.actions.append(lambda: self.start_print_fn(p, clear_bed=True)) + else: + self.active = False + self._set_status("Inactive (no new work available)") + self.actions.append(self.finish_script_fn) + + + 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? + + def pending_actions(self): + return len(self.actions) + + def on_printer_ready(self): + if len(self.actions) > 0: + a = self.actions.pop(0) + self._logger.info("Printer ready; performing next action %s" % a.__repr__()) + a() + return True + else: + return False + + def on_print_success(self): + if not self.active: + return + + idx = self._cur_idx() + if idx is not None: + self._complete_item(idx, "success") + + self.retries = 0 + self.actions.append(self._begin_next_available_print) + + + def on_print_failed(self): + if not self.active: + return + self._complete_item(self._cur_idx(), "failure") + self.active = False + self._set_status("Inactive (print failed)") + + def on_print_cancelled(self): + if not self.active: + return + idx = self._cur_idx() + item = self.q[idx] + if 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): + if not self.active or not self.retry_on_pause: + self._set_status("Print paused") + return + + elapsed = elapsed or (time.time() - self.q[self._cur_idx()].start_ts) + if elapsed < self.retry_threshold_seconds: + self._set_status("Cancelling print (paused early, likely adhesion failure)") + self.cancel_print_fn() + # self.actions.append(self.cancel_print_fn) + else: + self._set_status(f"Print paused {timeAgo(elapsed)} into print (over auto-restart threshold of {timeAgo(self.retry_threshold_seconds)}); awaiting user input") + + def on_print_resumed(self): + # This happens after pause & manual resume + idx = self._cur_idx() + if idx is not None: + self._set_status(f"Printing {self.q[idx].name}") + diff --git a/continuousprint/driver_test.py b/continuousprint/driver_test.py new file mode 100644 index 00000000..db8f29f5 --- /dev/null +++ b/continuousprint/driver_test.py @@ -0,0 +1,167 @@ +import unittest +from unittest.mock import MagicMock +from print_queue import PrintQueue, QueueItem +from driver import ContinuousPrintDriver +from mock_settings import MockSettings +import logging +logging.basicConfig(level=logging.DEBUG) + +def setupTestQueueAndDriver(self, num_complete): + self.s = MockSettings("q") + self.q = PrintQueue(self.s, "q") + self.q.assign([ + QueueItem("foo", "/foo.gcode", True, end_ts = 1 if num_complete > 0 else None), + QueueItem("bar", "/bar.gco", True, end_ts = 2 if num_complete > 1 else None), + QueueItem("baz", "/baz.gco", True, end_ts = 3 if num_complete > 2 else None), + ]) + self.d = ContinuousPrintDriver( + queue = self.q, + finish_script_fn = MagicMock(), + start_print_fn = MagicMock(), + cancel_print_fn = MagicMock(), + logger = logging.getLogger()) + self.d.set_retry_on_pause(True) + +def flush(d): + while d.pending_actions() > 0: + d.on_printer_ready() + + +class TestQueueManagerFromInitialState(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]) + + def test_activate_already_printing(self): + self.d.set_active(printer_ready=False) + self.d.start_print_fn.assert_not_called() + + 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() + assert_nocalls() + self.d.on_print_paused(0) + assert_nocalls() + + def test_completed_print_not_in_queue(self): + self.d.set_active(printer_ready=False) + self.d.on_print_success() + flush(self.d) + + # 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]) + + 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.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): + def setUp(self): + setupTestQueueAndDriver(self, 1) + + def test_success(self): + self.d.set_active() + self.d.start_print_fn.reset_mock() + + 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]) + + def test_success_after_queue_prepend_starts_prepended(self): + self.d.set_active() + self.d.start_print_fn.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) + + + def test_paused_early_triggers_cancel(self): + self.d.set_active() + + self.d.on_print_paused(self.d.retry_threshold_seconds - 1) + flush(self.d) + self.d.cancel_print_fn.assert_called_once_with() + + def test_cancelled_triggers_retry(self): + self.d.set_active() + self.d.start_print_fn.reset_mock() + + self.d.on_print_cancelled() + 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.retries = self.d.max_retries-1 + self.d.set_active() + self.assertEqual(self.d.retries, 0) + + def test_cancelled_with_max_retries_sets_inactive(self): + self.d.set_active() + self.d.start_print_fn.reset_mock() + self.d.retries = self.d.max_retries + + self.d.on_print_cancelled() + flush(self.d) + self.d.start_print_fn.assert_not_called() + self.assertEqual(self.d.active, False) + + def test_paused_late_waits_for_user(self): + self.d.set_active() + self.d.start_print_fn.reset_mock() + + self.d.on_print_paused(self.d.retry_threshold_seconds + 1) + self.d.start_print_fn.assert_not_called() + + def test_failure_sets_inactive(self): + self.d.set_active() + self.d.start_print_fn.reset_mock() + + self.d.on_print_failed() + flush(self.d) + self.d.start_print_fn.assert_not_called() + self.assertEqual(self.d.active, False) + + +class TestQueueManagerOnLastPrint(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.finish_script_fn.assert_called_once_with() + self.assertEqual(self.d.active, False) + + +if __name__ == "__main__": + unittest.main() diff --git a/continuousprint/mock_settings.py b/continuousprint/mock_settings.py new file mode 100644 index 00000000..4033b671 --- /dev/null +++ b/continuousprint/mock_settings.py @@ -0,0 +1,17 @@ +class MockSettings: + def __init__(self, k, s = "[]"): + self.k = k + self.s = s + + def save(self): + pass + + def get(self, a): + if a[0] != self.k: + raise Exception(f"Unexpected settings key {a[0]}") + return self.s + + def set(self, ak, v): + if ak[0] != self.k: + raise Exception(f"Unexpected settings key {ak[0]}") + self.s = v diff --git a/continuousprint/print_queue.py b/continuousprint/print_queue.py new file mode 100644 index 00000000..05a47a8e --- /dev/null +++ b/continuousprint/print_queue.py @@ -0,0 +1,133 @@ +import json +import time + +# See QueueItem in continuousprint.js for matching JS object +class QueueItem: + def __init__(self, name, path, sd, start_ts=None, end_ts=None, result=None, retries=0): + self.name = name + self.path = path + if type(sd) != bool: + raise Exception("SD must be bool, got %s" % (type(sd))) + self.sd = sd + self.start_ts = start_ts + self.end_ts = end_ts + self.result = result + self.retries = retries + + def __eq__(self, other): + return ( + self.name == other.name + and self.path == other.path + and self.sd == other.sd + ) + + +# This is a simple print queue that tracks the order of items +# and persists state to Octoprint settings +class PrintQueue: + def __init__(self, settings, key, logger=None): + self.key = key + self._logger = logger + self._settings = settings + self.q = [] + self._load() + + def _save(self): + self._settings.set([self.key], json.dumps([i.__dict__ for i in self.q])) + self._settings.save() + + def _load(self): + items = [] + for v in json.loads(self._settings.get([self.key])): + if v.get("path") is None: + if self._logger is not None: + self._logger.error(f"Invalid queue item {str(v)}, ignoring") + continue + items.append( + QueueItem( + name = v.get("name", v["path"]), # Use path if name not given (old plugin version data may do this) + path = v["path"], + sd = v.get("sd", False), + start_ts = v.get("start_ts"), + end_ts = v.get("end_ts"), + result = v.get("result"), + retries = v.get("retries", 0), + )) + self.assign(items) + + def _validate(self, item): + if not isinstance(item, QueueItem): + raise Exception("Invalid queue item: %s" % item) + + def assign(self, items): + for v in items: + self._validate(v) + self.q = list(items) + self._save() + + def __len__(self): + self._load() + return len(self.q) + + def __delitem__(self, i): + self._load() + del self.q[i] + self._save() + + def __getitem__(self, i): + self._load() + return self.q[i] + + def __setitem__(self, i, v): + self._validate(v) + self._load() + self.q[i] = v + self._save() + + def json(self): + return self._settings.get([self.key]) + + def add(self, items, idx=None): + for v in items: + self._validate(v) + if idx is None: + idx = len(self.q) + self._load() + self.q[idx:idx] = items + self._save() + + def remove(self, idx, num=0): + self._load() + del self.q[idx:idx+num] + self._save() + + def move(self, fromidx, num, offs): + self._load() + slc = self.q[fromidx:fromidx+num] + self.q = self.q[0:fromidx] + self.q[fromidx+num:] + self.q = self.q[0:fromidx+offs] + slc + self.q[fromidx+offs:] + self._save() + + def pop(self): + self._load() + v = self.q.pop(0) + self._save() + return v + + def peek(self): + self._load() + return self.q[0] if len(self.q) > 0 else None + + def available(self): + self._load() + return list(filter(lambda i: i.end_ts is None, self.q)) + + def complete(self, path, result): + self._load() + for item in self.q: + if item.end_ts is None and item.path == path: + item.end_ts = int(time.time()) + item.result = result + self._save() + return + raise Exception("Completed item with path %s not found in queue" % path) diff --git a/continuousprint/print_queue_test.py b/continuousprint/print_queue_test.py new file mode 100644 index 00000000..c02d9097 --- /dev/null +++ b/continuousprint/print_queue_test.py @@ -0,0 +1,127 @@ +import unittest +import json +from print_queue import PrintQueue, QueueItem +from mock_settings import MockSettings + +test_items = [ + QueueItem("foo", "/foo.gcode", False, end_ts=123), + QueueItem("bar", "/bar.gco", True, end_ts=456), + QueueItem("baz", "/baz.gco", True), + QueueItem("boz", "/boz.gco", True), + QueueItem("foz", "/foz.gco", True), +] + +class TestPrintQueue(unittest.TestCase): + def setUp(self): + self.s = MockSettings("q") + self.q = PrintQueue(self.s, "q") + + def test_add_and_len(self): + self.q.add(test_items) + self.assertEqual(len(self.q), len(test_items)) + + def test_add_idx(self): + self.q.add(test_items) + self.q.add([test_items[0]], 2) + self.assertEqual(self.q[2], test_items[0]) + # No index implies append + self.q.add([test_items[1]]) + self.assertEqual(self.q[-1], test_items[1]) + + def test_array_access(self): + self.q.add([test_items[0] for i in range(3)]) + self.q[1] = test_items[1] + self.assertEqual(self.q[1], test_items[1]) + + def test_peek(self): + self.q.add([test_items[0] for i in range(3)]) + self.assertEqual(self.q.peek(), test_items[0]) + self.assertEqual(len(self.q), 3) + + def test_pop(self): + self.q.add([test_items[0] for i in range(3)]) + self.assertEqual(self.q.pop(), test_items[0]) + self.assertEqual(len(self.q), 2) + + def test_pop_empty(self): + with self.assertRaises(IndexError): + self.q.pop() + + def test_peek_empty(self): + self.assertEqual(self.q.peek(), None) + + def test_remove(self): + self.q.add([test_items[0]]) + del self.q[0] + self.assertEqual(len(self.q), 0) + + def test_move(self): + self.q.add(test_items) + expected = [test_items[i] for i in [0,3,4,1,2]] + self.q.move(1, 2, 2) # [0,1,2,3,4] --> [0,3,4,1,2] + for i in range(5): + self.assertEqual(self.q[i], expected[i], "mismatch at idx %d; want %s got %s" % (i, [v.name for v in expected], [v.name for v in self.q])) + + def test_move_head(self): + self.q.add(test_items) + expected = [test_items[i] for i in [1,0,2,3,4]] + self.q.move(0,1,1) + for i in range(5): + self.assertEqual(self.q[i], expected[i], "mismatch at idx %d; want %s got %s" % (i, [v.name for v in expected], [v.name for v in self.q])) + + def test_move_back(self): + self.q.add(test_items) + expected = [test_items[i] for i in [0,2,1,3,4]] + self.q.move(2,1,-1) + for i in range(5): + self.assertEqual(self.q[i], expected[i], "mismatch at idx %d; want %s got %s" % (i, [v.name for v in expected], [v.name for v in self.q])) + + def test_available(self): + self.q.add(test_items) + self.assertEqual(len(self.q.available()), len(test_items)-2) + + def test_complete(self): + self.q.add(test_items) + self.q.complete("/baz.gco", "done") + self.assertTrue(self.q[2].end_ts is not None) + +# Ensure we're at least somewhat compatible with old queue style +class TestPrintQueueCompat(unittest.TestCase): + def test_default_queue_valid(self): + self.s = MockSettings("q", "[]") + self.q = PrintQueue(self.s, "q") + + self.q.add([test_items[0]]) + self.assertEqual(len(self.q), 1) + self.assertEqual(self.q[0], test_items[0]) + + def test_queue_with_legacy_item_valid(self): + self.s = MockSettings("q", json.dumps([{ + "path": "/foo/bar", + "count": 3, + "times_run": 0, + }])) + self.q = PrintQueue(self.s, "q") + + self.assertEqual(self.q[0].path, "/foo/bar") + self.assertEqual(self.q[0].name, "/foo/bar") + self.assertEqual(self.q[0].sd, False) + + self.q.add([test_items[0]]) + self.assertEqual(len(self.q), 2) + + def test_queue_with_no_path_skipped(self): + # On the off chance, just ignore entries without any path information + self.s = MockSettings("q", json.dumps([{ + # path not set + "name": "/foo/bar", + "count": 3, + "times_run": 0, + }])) + self.q = PrintQueue(self.s, "q") + self.assertEqual(len(self.q), 0) + + + +if __name__ == "__main__": + unittest.main() diff --git a/continuousprint/static/css/continuousprint.css b/continuousprint/static/css/continuousprint.css index 840f945c..ecd6587f 100644 --- a/continuousprint/static/css/continuousprint.css +++ b/continuousprint/static/css/continuousprint.css @@ -3,20 +3,38 @@ display: flex; flex-wrap: nowrap; flex-direction: row; - justify-content: space-between; - align-items: stretch; + justify-content: right; } -#tab_plugin_continuousprint .queue-inner-row-container { - display: flex; - flex-wrap: nowrap; - flex-direction: row; - justify-content: flex-start; - align-items: stretch; - width:50%; - flex-grow:2; - - +#tab_plugin_continuousprint .queue-row-container > * { + margin-left: 10px; + margin-bottom: 0; + line-height: 1.6; +} +#tab_plugin_continuousprint .queue-row-details { + margin-top: 10px; +} +#tab_plugin_continuousprint .queuesets > div { + border-bottom: 1px black solid; + padding: 10px; +} +#tab_plugin_continuousprint .queuesets > :first-child { + border-top: 1px black solid; +} +#tab_plugin_continuousprint .queue-set-list { + table-layout:auto; + width: 100%; + border-collapse: collapse; +} +#tab_plugin_continuousprint .queue-set-list th { + text-align: left; + border-bottom: 1px gray solid; +} +#tab_plugin_continuousprint .queue-set-list tr :first-child { + border-right: 1px gray solid; +} +#tab_plugin_continuousprint .well { + background-color: transparent; } #tab_plugin_continuousprint .count-box{ min-width:30px; @@ -25,6 +43,7 @@ } #tab_plugin_continuousprint .file-name { + flex: 1; flex-shrink: 10; overflow-x: scroll; white-space: nowrap; @@ -45,7 +64,42 @@ } +/* https://kamranahmed.info/blog/2016/01/30/yellow-fade-technique-in-css/ */ +@keyframes yellowfade { + from { background: yellow; } + to { background: transparent; } +} +.updated-item { + animation-name: yellowfade; + animation-duration: 1.5s; +} - +/* ==== This section is for compatibility with the Themeify plugin - overrides progress bars so that success/failure colors can still be seen ==== */ +#tab_plugin_continuousprint .progress .bar.bar-success { + background-color: #62c462; +} +#tab_plugin_continuousprint .progress .bar.bar-danger { + background-color: #ee5f5b; +} +#tab_plugin_continuousprint .progress-striped .bar { + background-color: #149bdf; + background-image: -webkit-gradient(linear,0 100%,100% 0,color-stop(0.25,rgba(255,255,255,0.15)),color-stop(0.25,transparent),color-stop(0.5,transparent),color-stop(0.5,rgba(255,255,255,0.15)),color-stop(0.75,rgba(255,255,255,0.15)),color-stop(0.75,transparent),to(transparent)); + background-image: -webkit-linear-gradient(45deg,rgba(255,255,255,0.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,0.15) 50%,rgba(255,255,255,0.15) 75%,transparent 75%,transparent); + background-image: -moz-linear-gradient(45deg,rgba(255,255,255,0.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,0.15) 50%,rgba(255,255,255,0.15) 75%,transparent 75%,transparent); + background-image: -o-linear-gradient(45deg,rgba(255,255,255,0.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,0.15) 50%,rgba(255,255,255,0.15) 75%,transparent 75%,transparent); + background-image: linear-gradient(45deg,rgba(255,255,255,0.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,0.15) 50%,rgba(255,255,255,0.15) 75%,transparent 75%,transparent); + -webkit-background-size: 40px 40px; + -moz-background-size: 40px 40px; + -o-background-size: 40px 40px; + background-size: 40px 40px; +} +#tab_plugin_continuousprint .progress.active .bar { + -webkit-animation: progress-bar-stripes 2s linear infinite; + -moz-animation: progress-bar-stripes 2s linear infinite; + -ms-animation: progress-bar-stripes 2s linear infinite; + -o-animation: progress-bar-stripes 2s linear infinite; + animation: progress-bar-stripes 2s linear infinite; +} +/* ======== */ diff --git a/continuousprint/static/js/continuousprint.js b/continuousprint/static/js/continuousprint.js index 9d4ad9bb..78e008e9 100644 --- a/continuousprint/static/js/continuousprint.js +++ b/continuousprint/static/js/continuousprint.js @@ -1,523 +1,420 @@ /* * View model for OctoPrint-Print-Queue * - * Author: Michael New + * Contributors: Michael New, Scott Martin * License: AGPLv3 */ $(function() { - function ContinuousPrintViewModel(parameters) { - var self = this; - self.params = parameters; - self.printerState = parameters[0]; - self.loginState = parameters[1]; - self.files = parameters[2]; - self.settings = parameters[3]; - self.is_paused = ko.observable(); - self.is_looped = ko.observable(); - self.ncount=1; - self.itemsInQueue=0; - - self.onBeforeBinding = function() { - self.loadQueue(); - self.is_paused(false); - self.checkLooped(); - + + const QueueState = { + UNKNOWN: null, + QUEUED: "queued", + PRINTING: "printing", + + } + + // Inspired by answers at + // https://stackoverflow.com/questions/6108819/javascript-timestamp-to-relative-time + function timeAgo(previous, current=null) { + var sPerMinute = 60; + var sPerHour = sPerMinute * 60; + var sPerDay = sPerHour * 24; + var sPerMonth = sPerDay * 30; + if (current === null) { + current = (new Date()).getTime()/1000; + } + var elapsed = current - previous; + if (elapsed < sPerHour) { + return Math.round(elapsed/sPerMinute) + ' minutes'; + } + else if (elapsed < sPerDay ) { + return Math.round(elapsed/sPerHour) + ' hours'; + } + else if (elapsed < sPerMonth) { + return Math.round(elapsed/sPerDay) + ' days'; + } + else { + return Math.round(elapsed/sPerMonth) + ' months'; + } } - self.files.addtoqueue = function(data) { - var sd="true"; - if(data.origin=="local"){ - sd="false"; - } - data.sd=sd; - self.addToQueue({ - name:data.name, - path:data.path, - sd:sd, - count:1 - + + // see QueueItem in print_queue.py for matching python object + function QueueItem(data, idx) { + var self = this; + self.idx = idx; + self.name = data.name; + self.path = data.path; + self.sd = data.sd; + self.changed = ko.observable(data.changed || false); + self.retries= ko.observable((data.start_ts !== null) ? data.retries : null); + self.start_ts = ko.observable(data.start_ts); + self.end_ts = ko.observable(data.end_ts); + self.result = ko.computed(function() { + if (data.result !== null) { + return data.result; + } + if (self.start_ts() === null) { + return "pending"; + } + if (self.start_ts() !== null && self.end_ts() === null) { + return "started"; + } + }); + self.duration = ko.computed(function() { + let start = self.start_ts(); + let end = self.end_ts(); + if (start === null || end === null) { + return null; + } + return timeAgo(start, end); + }); + } + + function QueueSetItem(items, idx) { + var self = this; + self.idx = idx; + self._n = items[0].name; // Used for easier inspection in console + self._len = items.length; + self.items = ko.observableArray(items); + self.changed = ko.computed(function() { + for (let item of self.items()) { + if (item.changed()) { + return true; + } + } + return false; + }); + self.length = ko.computed(function() {return self.items().length;}); + self.name = ko.computed(function() {return self.items()[0].name;}); + self.path = ko.computed(function() {return self.items()[0].path;}); + self.sd = ko.computed(function() {return self.items()[0].sd;}); + self.start_ts = ko.computed(function() {return self.items()[0].start_ts();}); + self.end_ts = ko.computed(function() { + for (let item of self.items()) { + if (item.end_ts !== null) { + return item.end_ts(); + } + } + return null; + }); + self.result = ko.computed(function() { + return self.items()[self.items().length-1].result(); + }) + self.num_completed = ko.computed(function() { + let i = 0; + for (let item of self.items()) { + if (item.end_ts() !== null) { + i++; + } + } + return i; + }); + self.progress = ko.computed(function() { + let progress = []; + let curNum = 0; + let curResult = self.items()[0].result(); + for (let item of self.items()) { + let res = item.result(); + if (res !== curResult) { + progress.push({ + pct: (100 * curNum / self._len).toFixed(0) + "%", + result: curResult, }); - - - } - self.loadQueue = function() { - $('#queue_list').html(""); - $.ajax({ - url: "plugin/continuousprint/queue", - type: "GET", - dataType: "json", - headers: { - "X-Api-Key":UI_API_KEY, - }, - success:function(r){ - self.itemsInQueue=r.queue.length; - if (r.queue.length > 0) { - $('#queue_list').html(""); - for(var i = 0; i < r.queue.length; i++) { - var file = r.queue[i]; - var row; + curNum = 0; + curResult = res; + } + curNum++; + } + progress.push({ + pct: (100 * curNum / self._len).toFixed(0) + "%", + result: curResult, + }); + return progress; + }); + self.active = ko.computed(function() { + for (let item of self.items()) { + if (item.start_ts === null && item.end_ts !== null) { + return true; + } + } + }); + self.description = ko.computed(function() { + if (self.start_ts() === null) { + return "Pending"; + } else if (self.active()) { + return `First item started ${timeAgo(self.start_ts)} ago`; + } else if (self.end_ts() !== null) { + return `${self.result()} (${timeAgo(self.end_ts())} ago; took ${timeAgo(self.start_ts(), self.end_ts())})`; + } else { + return self.result(); + } + }); + } - var other = " "; - if (i == 0) {other = "";} - if (i == 1) {other = " ";} - row = $("
" + file.name + "
" + file.name + "