diff --git a/.travis.yml b/.travis.yml index 144f2f62..506906af 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,6 +8,7 @@ jobs: python: 3.7 install: - pip install setuptools==60.9.0 # https://github.com/pypa/setuptools/issues/3293 + - pip install peerprint==0.3.0 # Needed for testing/mocking implementation - pip install OctoPrint # Need OctoPrint to satisfy req's of `__init__.py` - pip install coverage coveralls - pip install -r requirements.txt diff --git a/Dockerfile b/Dockerfile index bf6e11d3..a3f5585d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,9 @@ FROM python:3.7 # Installing ffmpeg is needed for working with timelapses - can be ommitted otherwise +# ZMQ libraries are peerprint dependencies - TODO need to find a way to auto install # Also install vim for later edit based debugging -RUN apt-get update && apt-get -y install --no-install-recommends ffmpeg vim && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get -y install --no-install-recommends ffmpeg libczmq-dev libzmq5 vim && rm -rf /var/lib/apt/lists/* # IPFS installation for LAN filesharing RUN wget https://dist.ipfs.tech/kubo/v0.15.0/kubo_v0.15.0_linux-amd64.tar.gz \ diff --git a/README.md b/README.md index dcd6a1ec..01492c27 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ This plugin automates your 3D printing! * **Group multiple files together into "jobs" and run them multiple times.** Don't make 10 boxes by printing 10 bases, then 10 lids - just define a "box" job and print box/lid combos in sequence. * **Reduce manual intervention with failure automation.** This plugin optionally integrates with [The Spaghetti Detective](https://www.thespaghettidetective.com/) and can retry prints that fail to adhere to the bed, with configurable limits on how hard to try before giving up. * **Print with multiple 3D printers over the local network**. LAN queues can parallelize your printing efforts, while still providing a single queue to print from. +* **Automatically slice STL files before printing**. Integrates with [PrePrintService](https://plugins.octoprint.org/plugins/preprintservice/) and other OctoPrint slicer implementations so you can add STLs to the queue and slice them on-the-fly. # Documentation diff --git a/continuousprint/__init__.py b/continuousprint/__init__.py index 64ed2846..762d9fe6 100644 --- a/continuousprint/__init__.py +++ b/continuousprint/__init__.py @@ -30,6 +30,10 @@ class ContinuousprintPlugin( # -------------------- Begin BlueprintPlugin -------------------- + def is_blueprint_csrf_protected(self): + # See https://docs.octoprint.org/en/maintenance/plugins/mixins.html#blueprintplugin + return True + def get_blueprint(self): # called before on_startup, but we need the plugin to provide the blueprint self.on_startup() diff --git a/continuousprint/api.py b/continuousprint/api.py index 62f98e6a..b96ed895 100644 --- a/continuousprint/api.py +++ b/continuousprint/api.py @@ -2,13 +2,13 @@ from enum import Enum from octoprint.access.permissions import Permissions, ADMIN_GROUP from octoprint.server.util.flask import restricted_access -from .queues.lan import ValidationError +from .queues.base import ValidationError from .automation import getInterpreter, genEventScript import flask import json from .storage import queries -from .storage.database import DEFAULT_QUEUE -from .data import CustomEvents +from .storage.database import DEFAULT_QUEUE, ARCHIVE_QUEUE +from .data import CustomEvents, Keys from .driver import Action as DA from abc import ABC, abstractmethod @@ -141,7 +141,7 @@ def popup(self, msg, type="popup"): return self._msg(dict(type=type, msg=msg)) def _sync(self, attr, data): - self._logger.debug(f"Refreshing UI {attr}") + # self._logger.debug(f"Refreshing UI {attr}") msg = dict(type=f"set{attr}") msg[attr] = data self._msg(msg) @@ -210,8 +210,11 @@ def add_job(self): def mv_job(self): src_id = flask.request.form["id"] after_id = flask.request.form["after_id"] + before_id = flask.request.form.get("before_id") if after_id == "": # Treat empty string as 'none' i.e. front of queue after_id = None + if before_id == "": # Treat empty as 'none' i.e. end of queue + before_id = None sq = self._get_queue(flask.request.form["src_queue"]) dq = self._get_queue(flask.request.form.get("dest_queue")) @@ -225,7 +228,7 @@ def mv_job(self): src_id = new_id # Finally, move the job - dq.mv_job(src_id, after_id) + dq.mv_job(src_id, after_id, before_id) return json.dumps("OK") # PRIVATE API METHOD - may change without warning. @@ -311,7 +314,24 @@ def reset_history(self): @restricted_access @cpq_permission(Permission.GETQUEUES) def get_queues(self): - return json.dumps([q.as_dict() for q in queries.getQueues()]) + qs = dict() + if self._get_key(Keys.NETWORK_QUEUES, False): + for n in self._peerprint.get_plugin().client.get_connections(): + qs[n.network] = dict( + name=n.network, addr=n.addr, strategy="LINEAR", enabled=False + ) + + for q in queries.getQueues(): + if q.name == DEFAULT_QUEUE: + qs[q.name] = q.as_dict() + qs[q.name]["enabled"] = True + qs[q.name]["rank"] = q.rank + elif q.name in qs: + qs[q.name]["enabled"] = True + + qs = list(qs.values()) + qs.sort(key=lambda q: q.get("rank", 99999999)) + return json.dumps(qs) # PRIVATE API METHOD - may change without warning. @octoprint.plugin.BlueprintPlugin.route("/queues/edit", methods=["POST"]) @@ -319,6 +339,7 @@ def get_queues(self): @cpq_permission(Permission.EDITQUEUES) def edit_queues(self): queues = json.loads(flask.request.form.get("json")) + queues = [q for q in queues if q["enabled"]] # strip disabled queues (absent_names, added) = queries.assignQueues(queues) self._commit_queues(added, absent_names) return json.dumps("OK") diff --git a/continuousprint/api_test.py b/continuousprint/api_test.py index 613fd828..bd7446e6 100644 --- a/continuousprint/api_test.py +++ b/continuousprint/api_test.py @@ -2,6 +2,7 @@ import json import logging from .driver import Action as DA +from .storage.database import DEFAULT_QUEUE from unittest.mock import patch, MagicMock, call, PropertyMock import imp from flask import Flask @@ -63,6 +64,7 @@ def kill_patches(): self.api._basefolder = "notexisty" self.api._identifier = "continuousprint" self.api._get_queue = MagicMock() + self.api._get_key = lambda k, d: d self.api._logger = logging.getLogger() self.app.register_blueprint(self.api.get_blueprint()) self.app.config.update({"TESTING": True}) @@ -165,14 +167,29 @@ def test_add_job(self): self.assertEqual(rep.get_data(as_text=True), '"ret"') self.api._get_queue().add_job.assert_called_with("jobname") - def test_mv_job(self): + def test_mv_job_no_before_id(self): self.perm.PLUGIN_CONTINUOUSPRINT_EDITJOB.can.return_value = True data = dict(id="foo", after_id="bar", src_queue="q1", dest_queue="q2") rep = self.client.post("/job/mv", data=data) self.assertEqual(rep.status_code, 200) - self.api._get_queue().mv_job.assert_called_with(data["id"], data["after_id"]) + self.api._get_queue().mv_job.assert_called_with( + data["id"], data["after_id"], None + ) + + def test_mv_job(self): + self.perm.PLUGIN_CONTINUOUSPRINT_EDITJOB.can.return_value = True + data = dict( + id="foo", after_id="bar", before_id="baz", src_queue="q1", dest_queue="q2" + ) + + rep = self.client.post("/job/mv", data=data) + + self.assertEqual(rep.status_code, 200) + self.api._get_queue().mv_job.assert_called_with( + data["id"], data["after_id"], data["before_id"] + ) def test_edit_job(self): self.perm.PLUGIN_CONTINUOUSPRINT_EDITJOB.can.return_value = True @@ -251,19 +268,47 @@ def test_reset_history(self, q): @patch("continuousprint.api.queries") def test_get_queues(self, q): self.perm.PLUGIN_CONTINUOUSPRINT_GETQUEUES.can.return_value = True - mq = MagicMock() - mq.as_dict.return_value = dict(foo="bar") - q.getQueues.return_value = [mq] + self.api._get_key = lambda k, d: True + + self.api._peerprint = MagicMock() + self.api._peerprint.get_plugin().client.get_connections.return_value = [ + MagicMock(network="foo", addr="1234"), + MagicMock(network="bar", addr="1234"), + ] + + mq = MagicMock(rank=0) + mq.name = "foo" + mq.as_dict.return_value = dict(name="foo") + lq = MagicMock(rank=1) + lq.name = DEFAULT_QUEUE + lq.as_dict.return_value = dict(name=DEFAULT_QUEUE) + q.getQueues.return_value = [mq, lq] + rep = self.client.get("/queues/get") self.assertEqual(rep.status_code, 200) - self.assertEqual(json.loads(rep.get_data(as_text=True)), [dict(foo="bar")]) + self.assertEqual( + json.loads(rep.get_data(as_text=True)), + [ + dict(name=DEFAULT_QUEUE, enabled=True, rank=1), + dict(name="foo", enabled=True, addr="1234", strategy="LINEAR"), + dict(name="bar", enabled=False, addr="1234", strategy="LINEAR"), + ], + ) @patch("continuousprint.api.queries") def test_edit_queues(self, q): self.perm.PLUGIN_CONTINUOUSPRINT_EDITQUEUES.can.return_value = True q.assignQueues.return_value = ("absent", "added") self.api._commit_queues = MagicMock() - rep = self.client.post("/queues/edit", data=dict(json='"foo"')) + rep = self.client.post( + "/queues/edit", + data=dict( + json=json.dumps( + [dict(name="foo", enabled=True), dict(name="bar", enabled=False)] + ) + ), + ) + q.assignQueues.assert_called_with([dict(name="foo", enabled=True)]) self.assertEqual(rep.status_code, 200) self.assertEqual(rep.data, b'"OK"') self.api._commit_queues.assert_called_with("added", "absent") diff --git a/continuousprint/data/__init__.py b/continuousprint/data/__init__.py index 6b9844a5..713bff79 100644 --- a/continuousprint/data/__init__.py +++ b/continuousprint/data/__init__.py @@ -147,7 +147,7 @@ class Keys(Enum): BED_COOLDOWN_THRESHOLD = ("bed_cooldown_threshold", 30) BED_COOLDOWN_TIMEOUT = ("bed_cooldown_timeout", 60) MATERIAL_SELECTION = ("cp_material_selection_enabled", False) - NETWORK_NAME = ("cp_network_name", "Generic") + NETWORK_QUEUES = ("cp_network_queues_enabled", False) AUTOMATION_TIMELAPSE_ACTION = ( "cp_automation_timelapse_action", "do_nothing", @@ -184,10 +184,25 @@ def __init__(self, setting, default): "js/continuousprint_queue.js", "js/continuousprint_viewmodel.js", "js/continuousprint_settings_event.js", + "js/continuousprint_settings_automation.js", + "js/continuousprint_settings_queues.js", "js/continuousprint_settings.js", "js/continuousprint.js", ], - css=["css/continuousprint.css"], + css=[ + "css/continuousprint_history.less.css", + "css/continuousprint_settings.less.css", + "css/continuousprint_shared.less.css", + "css/continuousprint_tab.less.css", + "css/continuousprint_themeify_compat.less.css", + ], + less=[ + "less/continuousprint_history.less", + "less/continuousprint_settings.less", + "less/continuousprint_shared.less", + "less/continuousprint_tab.less", + "less/continuousprint_themeify_compat.less", + ], ) TEMPLATES = [ diff --git a/continuousprint/integration_test.py b/continuousprint/integration_test.py index 89f6c420..4d9c90e2 100644 --- a/continuousprint/integration_test.py +++ b/continuousprint/integration_test.py @@ -1,4 +1,5 @@ import unittest +import json import datetime import time import tempfile @@ -10,32 +11,27 @@ from .storage.database_test import DBTest from .storage.database import DEFAULT_QUEUE, MODELS, populate_queues from .storage import queries -from .storage.lan import LANJobView +from .storage.peer import PeerJobView, PeerQueueView from .queues.multi import MultiQueue from .queues.local import LocalQueue -from .queues.lan import LANQueue -from .queues.abstract import Strategy +from .queues.net import NetworkQueue +from .queues.base import Strategy from .data import CustomEvents from .script_runner import ScriptRunner from peewee import SqliteDatabase from collections import defaultdict -from peerprint.lan_queue import LANPrintQueueBase -from peerprint.sync_objects_test import TestReplDict +from peerprint.server_test import MockServer # logging.basicConfig(level=logging.DEBUG) class IntegrationTest(DBTest): - def newQueue(self): - raise NotImplementedError - def setUp(self): super().setUp() def onupdate(): pass - self.lq = self.newQueue() self.mq = MultiQueue(queries, Strategy.IN_ORDER, onupdate) self.mq.add(self.lq.ns, self.lq) self.d = Driver( @@ -112,6 +108,10 @@ def newQueue(self): lq._set_path_exists = lambda p: True return lq + def setUp(self): + self.lq = self.newQueue() + super().setUp() + def test_retries_failure(self): queries.appendSet( DEFAULT_QUEUE, "", dict(path="j1.gcode", sd=False, material="", count=1) @@ -167,12 +167,28 @@ def test_completes_job_in_order(self): queries.appendSet( DEFAULT_QUEUE, "", - dict(path="a.gcode", sd=False, material="", profile="", count=2), + dict( + path="a.gcode", + sd=False, + material="", + profile="", + count=2, + id="set_a", + manifest="foo", + ), ) queries.appendSet( DEFAULT_QUEUE, "1", - dict(path="b.gcode", sd=False, material="", profile="", count=1), + dict( + path="b.gcode", + sd=False, + material="", + profile="", + count=1, + id="set_b", + manifest="foo", + ), ) queries.updateJob(1, dict(draft=False)) @@ -254,72 +270,65 @@ def test_external_symbols(self): self.assertEqual(self.d.state.__name__, self.d._state_activating.__name__) -class LocalLockManager: - def __init__(self, locks, ns): - self.locks = locks - self.selfID = ns - - def getPeerLocks(self): - result = defaultdict(list) - for lk, p in self.locks.items(): - result[p].append(lk) - return result - - def tryAcquire(self, k, sync=None, timeout=None): - if self.locks.get(k) is not None and self.locks[k] != self.selfID: - return False - self.locks[k] = self.selfID - return True - - def release(self, k): - self.locks.pop(k, None) - - -class TestLANQueue(IntegrationTest): +class TestNetworkQueue(IntegrationTest): """A simple in-memory integration test between DB storage layer, queuing layer, and driver.""" - def newQueue(self): + def setUp(self): + # Manually construct and mock out the base implementation def onupdate(): pass - lq = LANQueue( + fs = MagicMock() + fs.fetch.return_value = "foo.gcode" + + self.srv = MockServer([]) + pp = MagicMock() + type(pp.get_plugin()).client = self.srv + type(pp.get_plugin()).fileshare = fs + type(pp.get_plugin()).printer_name = "test_printer" + self.lq = NetworkQueue( "LAN", - "asdf:12345", + pp, logging.getLogger("lantest"), Strategy.IN_ORDER, onupdate, - MagicMock(), dict(name="profile"), lambda path: path, ) - return lq - - def setUp(self): + self.lq._server_id = self.srv.get_id(self.lq.ns) super().setUp() - # Manually construct and mock out the base implementation - self.lq.lan.q = LANPrintQueueBase( - self.lq.ns, self.lq.addr, MagicMock(), logging.getLogger("lantestbase") - ) - self.lq.lan.q.locks = LocalLockManager(dict(), "lq") - self.lq.lan.q.jobs = TestReplDict(lambda a, b: None) - self.lq.lan.q.peers = {} - self.lq.lan.q.peers[self.lq.addr] = (time.time(), dict(fs_addr="mock")) - self.lq._fileshare.fetch.return_value = "from_fileshare.gcode" def test_completes_job_in_order(self): - self.lq.lan.q.setJob( - "uuid1", - dict( - id="uuid1", - name="j1", - created=0, - sets=[ - dict(path="a.gcode", count=1, remaining=1), - dict(path="b.gcode", count=1, remaining=1), - ], - count=1, - remaining=1, + self.srv.set_record( + self.lq.ns, + uuid="uuid1", + manifest=json.dumps( + dict( + id="uuid1", + name="j1", + created=0, + sets=[ + dict( + path="a.gcode", + count=1, + remaining=1, + id="set0", + metadata="1", + ), + dict( + path="b.gcode", + count=1, + remaining=1, + id="set1", + metadata="2", + ), + ], + count=1, + remaining=1, + peer_="foo", # TODO - should probably rely on creator + ) ), + rank=dict(), ) self.d.action(DA.ACTIVATE, DP.IDLE) # -> start_print -> printing self.assert_from_printing_state("a.gcode") @@ -327,23 +336,36 @@ def test_completes_job_in_order(self): def test_multi_job(self): for name in ("j1", "j2"): - self.lq.lan.q.setJob( - f"{name}_id", - dict( - id=f"{name}_id", - name=name, - created=0, - sets=[dict(path=f"{name}.gcode", count=1, remaining=1)], - count=1, - remaining=1, + self.srv.set_record( + self.lq.ns, + uuid=f"{name}_id", + manifest=json.dumps( + dict( + id=f"{name}_id", + name=name, + created=0, + sets=[ + dict( + path=f"{name}.gcode", + count=1, + remaining=1, + id=f"{name}_set0", + metadata="1", + ) + ], + count=1, + remaining=1, + peer_="foo", # TODO - should probably rely on creator + ) ), + rank=dict(), ) self.d.action(DA.ACTIVATE, DP.IDLE) # -> start_print -> printing self.assert_from_printing_state("j1.gcode") self.assert_from_printing_state("j2.gcode", finishing=True) -class TestMultiDriverLANQueue(unittest.TestCase): +class TestMultiDriverNetworkQueue(unittest.TestCase): def setUp(self): NPEERS = 2 self.dbs = [SqliteDatabase(":memory:") for i in range(NPEERS)] @@ -351,24 +373,29 @@ def setUp(self): def onupdate(): pass - self.locks = {} + fs = MagicMock() + fs.fetch.return_value = "foo.gcode" + self.srv = MockServer([]) self.peers = [] for i, db in enumerate(self.dbs): + pp = MagicMock() + type(pp.get_plugin()).client = self.srv + type(pp.get_plugin()).fileshare = fs + type(pp.get_plugin()).printer_name = f"peer{i}" + with db.bind_ctx(MODELS): populate_queues() - fsm = MagicMock(host="fsaddr", port=0) - fsm.fetch.return_value = "from_fileshare.gcode" - profile = dict(name="profile") - lq = LANQueue( + lq = NetworkQueue( "LAN", - f"peer{i}:{12345+i}", + pp, logging.getLogger(f"peer{i}:LAN"), Strategy.IN_ORDER, onupdate, - fsm, - profile, + dict(name="profile"), lambda path, sd: path, ) + lq._server_id = self.srv.get_id(lq.ns) + mq = MultiQueue(queries, Strategy.IN_ORDER, onupdate) mq.add(lq.ns, lq) d = Driver( @@ -381,22 +408,15 @@ def onupdate(): d._runner.set_active.return_value = True d.set_retry_on_pause(True) d.action(DA.DEACTIVATE, DP.IDLE) - lq.lan.q = LANPrintQueueBase( - lq.ns, lq.addr, MagicMock(), logging.getLogger("lantestbase") - ) - lq.lan.q.locks = LocalLockManager(self.locks, f"peer{i}") - if i == 0: - lq.lan.q.jobs = TestReplDict(lambda a, b: None) - lq.lan.q.peers = dict() - else: - lq.lan.q.peers = self.peers[0][2].lan.q.peers - lq.lan.q.jobs = self.peers[0][2].lan.q.jobs self.peers.append((d, mq, lq, db)) - for p in self.peers: - self.peers[0][2].lan.q.peers[p[2].addr] = ( - time.time(), - dict(fs_addr="fakeaddr", profile=dict(name="profile")), + for i, p in enumerate(self.peers): + self.srv.set_status( + "LAN", + name=f"peer{i}", + printers=[ + dict(name=f"printer{i}", profile=dict(name="profile")), + ], ) def test_ordered_acquisition(self): @@ -405,46 +425,61 @@ def test_ordered_acquisition(self): (d1, _, lq1, db1) = self.peers[0] (d2, _, lq2, db2) = self.peers[1] for name in ("j1", "j2", "j3"): - lq1.lan.q.setJob( + lq1.set_job( f"{name}_hash", dict( id=f"{name}_hash", name=name, created=0, sets=[ - dict(path=f"{name}.gcode", count=1, remaining=1), + dict( + path=f"{name}.gcode", + count=1, + remaining=1, + id=f"{name}_s0", + metadata="foo", + ), ], count=1, remaining=1, + peer_="test", ), ) - # Activating peer0 causes it to acquire j1 and begin printing its file + logging.info( + "Activating peer0 causes it to acquire j1 and begin printing its file" + ) with db1.bind_ctx(MODELS): d1.action(DA.ACTIVATE, DP.IDLE) # -> start_printing -> printing d1._runner.start_print.assert_called() self.assertEqual(d1._runner.start_print.call_args[0][0].path, "j1.gcode") - self.assertEqual(self.locks.get("j1_hash"), "peer0") + self.assertEqual(lq2._get_job("j1_hash")["acquired_by"], "peer0") d1._runner.start_print.reset_mock() - # Activating peer1 causes it to skip j1, acquire j2 and begin printing its file + logging.info( + "Activating peer1 causes it to skip j1, acquire j2 and begin printing its file" + ) with db2.bind_ctx(MODELS): d2.action(DA.ACTIVATE, DP.IDLE) # -> start_printing -> printing d2._runner.start_print.assert_called() self.assertEqual(d2._runner.start_print.call_args[0][0].path, "j2.gcode") d2._runner.start_print.reset_mock() - # When peer0 finishes it decrements the job and releases it, then acquires j3 and begins work + logging.info( + "When peer0 finishes it decrements the job and releases it, then acquires j3 and begins work" + ) with db1.bind_ctx(MODELS): d1.action(DA.SUCCESS, DP.IDLE, path="j1.gcode") # -> success d1.action(DA.TICK, DP.IDLE) # -> start_clearing d1.action(DA.TICK, DP.IDLE) # -> clearing d1.action(DA.SUCCESS, DP.IDLE) # -> start_print self.assertEqual(d1._runner.start_print.call_args[0][0].path, "j3.gcode") - self.assertEqual(self.locks["j3_hash"], "peer0") + self.assertEqual(lq2._get_job("j3_hash")["acquired_by"], "peer0") d1._runner.start_print.reset_mock() - # When peer1 finishes it decrements j2 and releases it, then goes idle as j3 is already acquired + logging.info( + "When peer1 finishes it decrements j2 and releases it, then goes idle as j3 is already acquired" + ) with db2.bind_ctx(MODELS): d2.action(DA.SUCCESS, DP.IDLE, path="j2.gcode") # -> success d2.action(DA.TICK, DP.IDLE) # -> start_finishing @@ -453,34 +488,41 @@ def test_ordered_acquisition(self): self.assertEqual(d2.state.__name__, d2._state_idle.__name__) def test_non_local_edit(self): + # Editing a file on a remote peer should fetch and unpack the manifest file, + # then apply the new changes and re-pack it before posting it again (d1, _, lq1, db1) = self.peers[0] (d2, _, lq2, db2) = self.peers[1] with tempfile.TemporaryDirectory() as tdir: (Path(tdir) / "test.gcode").touch() - j = LANJobView( + j = PeerJobView() + j.load_dict( dict( id="jobhash", name="job", created=0, sets=[ dict( + id="sethash", path="test.gcode", count=1, remaining=1, profiles=["profile"], + metadata="foo", ), ], count=1, remaining=1, peer_=None, ), - lq1, + PeerQueueView(lq1), ) + lq1._fileshare.post.return_value = "post_cid" lq1._path_on_disk = lambda p, sd: str(Path(tdir) / p) + logging.info("Initial job insertion into lq2") lq1.import_job_from_view(j, j.id) - lq2._fileshare.post.assert_not_called() + lq1._fileshare.post.reset_mock() - # LQ2 edits the job + logging.info("lq2 edits the job with mocked fileshare unpacking") lq2._fileshare.fetch.return_value = str(Path(tdir) / "unpack/") (Path(tdir) / "unpack").mkdir() lq2dest = Path(tdir) / "unpack/test.gcode" @@ -488,8 +530,7 @@ def test_non_local_edit(self): lq2.edit_job("jobhash", dict(draft=True)) lq2._fileshare.post.assert_called_once() - # Job posts with lan 2 address, from pov of lq1 - self.assertEqual(list(lq1.lan.q.jobs.values())[0][0], lq2.addr) + # Uses resolved file path c = lq2._fileshare.post.call_args[0] self.assertEqual(c[1], {str(lq2dest): str(lq2dest)}) diff --git a/continuousprint/plugin.py b/continuousprint/plugin.py index dbf91caa..b2ca0c2b 100644 --- a/continuousprint/plugin.py +++ b/continuousprint/plugin.py @@ -4,7 +4,6 @@ import socket import json import time -import shutil import traceback import random from pathlib import Path @@ -14,20 +13,20 @@ from octoprint.filemanager.destinations import FileDestinations import octoprint.timelapse -from peerprint.filesharing import Fileshare from .analysis import CPQProfileAnalysisQueue from .thirdparty.spoolmanager import SpoolManagerIntegration from .driver import Driver, Action as DA, Printer as DP, shouldBlockCoreEvents -from .queues.lan import LANQueue +from .queues.net import NetworkQueue from .queues.multi import MultiQueue from .queues.local import LocalQueue -from .queues.abstract import Strategy +from .queues.base import Strategy from .storage.database import ( migrateFromSettings, migrateScriptsFromSettings, init_db, DEFAULT_QUEUE, ARCHIVE_QUEUE, + Queue, ) from .data import ( PRINTER_PROFILES, @@ -51,7 +50,6 @@ class CPQPlugin(ContinuousPrintAPI): ) RECONNECT_WINDOW_SIZE = 5.0 MAX_WINDOW_EXP = 6 - GET_ADDR_TIMEOUT = 3 CPQ_ANALYSIS_FINISHED = "CPQ_ANALYSIS_FINISHED" def __init__( @@ -88,7 +86,6 @@ def __init__( def start(self): self._setup_thirdparty_plugin_integration() self._init_db() - self._init_fileshare() self._init_queues() self._init_driver() self._init_analysis_queue() @@ -124,62 +121,6 @@ def resume_action(self): self._update(DA.ACTIVATE) self._sync_state() - def _can_bind_addr(self, addr): - try: - s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - s.bind(addr) - except OSError: - return False - finally: - s.close() - return True - - def get_local_addr(self): - # https://stackoverflow.com/a/2838309 - # Note that this is vulnerable to race conditions in that - # the port is open when it's assigned, but could be reassigned - # before the caller can use it. - s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - s.settimeout(CPQPlugin.GET_ADDR_TIMEOUT) - checkaddr = tuple( - [ - self._settings.global_get(["server", "onlineCheck", v]) - for v in ("host", "port") - ] - ) - try: - s.connect(checkaddr) - result = s.getsockname() - finally: - # Note: close has no effect on already closed socket - s.close() - - if self._can_bind_addr(result): - return f"{result[0]}:{result[1]}" - - # For whatever reason, client-based local IP resolution can sometimes still fail to bind. - # In this case, fall back to MDNS based resolution - # https://stackoverflow.com/a/57355707 - self._logger.warning( - "Online check based IP resolution failed to bind; attempting MDNS local IP resolution" - ) - hostname = socket.gethostname() - try: - local_ip = socket.gethostbyname(f"{hostname}.local") - except socket.gaierror: - local_ip = socket.gethostbyname(hostname) - - # Find open port: https://stackoverflow.com/a/2838309 - # This will raise OSError if it cannot bind to that address either - try: - s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - s.bind((local_ip, 0)) - s.listen(1) - port = s.getsockname()[1] - finally: - s.close() - return f"{local_ip}:{port}" - def _add_set(self, path, sd, draft=True, profiles=[]): # We may need to delay adding a file if it hasn't yet finished analysis meta = self._file_manager.get_additional_metadata( @@ -282,6 +223,20 @@ def _setup_thirdparty_plugin_integration(self): else: self._spool_manager = None self._set_key(Keys.MATERIAL_SELECTION, False) + + # PeerPrint is only needed for network printing + ppplugin = self._plugin_manager.plugins.get("octoprint_peerprint") + if ppplugin is not None and ppplugin.enabled: + self._peerprint = ppplugin.implementation + self._logger.info("PeerPrint found - enabling network queues") + self._set_key(Keys.NETWORK_QUEUES, True) + else: + self._peerprint = None + self._logger.info( + "No peerprint instance found; network queues will be disabled" + ) + self._set_key(Keys.NETWORK_QUEUES, False) + self._settings.save() # Try to fetch plugin-specific events, defaulting to None otherwise @@ -349,32 +304,6 @@ def gatedCommJobReader(self, *args, **kwargs): finally: return result - def _init_fileshare(self, fs_cls=Fileshare): - # Note: fileshare_dir referenced when cleaning up old files - self.fileshare_dir = self._path_on_disk( - f"{PRINT_FILE_DIR}/fileshare/", sd=False - ) - try: - fileshare_addr = self.get_local_addr() - except OSError: - self._exception_msg( - "Failed to find a local addr for LAN fileshare; LAN queues will be disabled." - ) - self._fileshare = None - return - - self._logger.info(f"Starting fileshare with address {fileshare_addr}") - self._fileshare = fs_cls(fileshare_addr, self.fileshare_dir, self._logger) - try: - self._fileshare.connect() - self._logger.info( - f"Fileshare listening on {self._fileshare.host}:{self._fileshare.port}" - ) - except OSError: - self._exception_msg( - "Failed to bind Fileshare HTTP server; hosting LAN jobs will fail, but fetching jobs may still work." - ) - def _init_db(self): init_db( queues_db=Path(self._data_folder) / "queue.sqlite3", @@ -415,45 +344,47 @@ def _init_db(self): self._queries.clearOldState() - def _init_queues(self, lancls=LANQueue, localcls=LocalQueue): + def _init_queue(self, q, netcls=NetworkQueue, localcls=LocalQueue): + if q.name == ARCHIVE_QUEUE: + return + elif q.name == DEFAULT_QUEUE: + self.q.add( + q.name, + localcls( + self._queries, + q.name, + Strategy.IN_ORDER, # TODO set strategy from q + self._printer_profile, + self._path_on_disk, + self._add_folder, + ), + ) + elif self._get_key(Keys.NETWORK_QUEUES, False): # Peerprint queue + nq = netcls( + q.name, + self._peerprint, + self._logger.getChild(q.name), + Strategy.IN_ORDER, # TODO set strategy from q + self._on_queue_update, + self._printer_profile, + self._path_on_disk, + ) + nq.connect() + self.q.add(q.name, nq) + + def _init_queues(self, netcls=NetworkQueue, localcls=LocalQueue): self._printer_profile = PRINTER_PROFILES.get( self._get_key(Keys.PRINTER_PROFILE) ) + self.q = MultiQueue( self._queries, Strategy.IN_ORDER, self._sync_history ) # TODO set strategy for this and all other queue creations for q in self._queries.getQueues(): - if q.addr is not None: - try: - lq = lancls( - q.name, - q.addr if q.addr.lower() != "auto" else self.get_local_addr(), - self._logger, - Strategy.IN_ORDER, - self._on_queue_update, - self._fileshare, - self._printer_profile, - self._path_on_disk, - ) - lq.connect() - self.q.add(q.name, lq) - except Exception: - self._exception_msg( - f'Unable to join network queue "{q.name}" with address {q.addr}' - ) - - elif q.name != ARCHIVE_QUEUE: - self.q.add( - q.name, - localcls( - self._queries, - q.name, - Strategy.IN_ORDER, - self._printer_profile, - self._path_on_disk, - self._add_folder, - ), - ) + try: + self._init_queue(q, netcls, localcls) + except Exception as e: + self._logger.error(str(e)) def _init_driver(self, srcls=ScriptRunner, dcls=Driver): self._runner = srcls( @@ -580,40 +511,6 @@ def _on_analysis_finished(self, entry, result): ) self.on_event(self.CPQ_ANALYSIS_FINISHED, dict(path=entry.path, result=result)) - def _cleanup_fileshare(self): - if not os.path.exists(self.fileshare_dir): - return n - - # This cleans up all non-useful fileshare files across all network queues, so they aren't just taking up space. - # First we collect all non-local queue items hosted by us - these are excluded from cleanup as someone may need to fetch them. - keep_hashes = set() - for name, q in self.q.queues.items(): - if name == ARCHIVE_QUEUE or name == DEFAULT_QUEUE: - continue - for j in q.as_dict()["jobs"]: - if j["peer_"] == q.addr or j["acquired_by_"] == q.addr: - keep_hashes.add(j["hash"]) - - # Loop through all .gjob and .gcode files in base directory and delete them if they aren't referenced or acquired by us - n = 0 - - for d in os.listdir(self.fileshare_dir): - name, suffix = os.path.splitext(d) - if suffix not in ("", ".gjob", ".gcode", ".gco"): - self._logger.debug( - f"Fileshare cleanup ignoring non-printable file: {os.path.join(self.fileshare_dir, name)}" - ) - continue - if name not in keep_hashes: - p = Path(self.fileshare_dir) / d - if p.is_dir(): - # Have to use shutil instead of p.rmdir() as the directory will likely not be empty - shutil.rmtree(p, ignore_errors=True) - else: - p.unlink() - n += 1 - return n - def tick(self): # Catch/pass all exceptions to prevent errors from stopping the repeated timer. try: @@ -723,9 +620,10 @@ def on_event(self, event, payload): self._timelapse_start_ts = time.time() self._update(DA.SUCCESS) - n = self._cleanup_fileshare() - if n > 0: - self._logger.info(f"Deleted {n} unreferenced fileshare files/dirs") + if self._get_key(Keys.NETWORK_QUEUES, False): + n = self.cleanup_fileshare() + if n > 0: + self._logger.info(f"Deleted {n} unreferenced fileshare files/dirs") elif event == Events.PRINT_FAILED: # Note that cancelled events are already handled directly with Events.PRINT_CANCELLED self._update(DA.FAILURE) @@ -757,6 +655,17 @@ def on_event(self, event, payload): elif event == Events.SETTINGS_UPDATED: self._on_settings_updated() + def cleanup_fileshare(self): + # This cleans up all non-useful fileshare files across all network queues, so they aren't just taking up space. + keep_hashes = set() + for name, q in self.q.queues.items(): + if name == ARCHIVE_QUEUE or name == DEFAULT_QUEUE: + continue + for j in q.as_dict()["jobs"]: + keep_hashes.add(j["hash"]) + + return self._peerprint.get_plugin().cleanup_fileshare(keep_hashes) + # ----------------------- End EventHandlerPlugin -------------------- # ---------------------- Begin ContinuousPrintAPI ------------------- @@ -795,11 +704,7 @@ def _update(self, a: DA): if self.d.action(a, p, path, materials, bed_temp, timelapse_start_ts): self._sync_state() - run = self.q.get_run() - if run is not None: - run = run.as_dict() - netname = self._get_key(Keys.NETWORK_NAME) - self.q.update_peer_state(netname, p.name, run, self._printer_profile) + self.q.update_peer_state(p.name, self._printer_profile) def _state_json(self): # IMPORTANT: Non-additive changes to this response string must be released in a MAJOR version bump @@ -835,27 +740,15 @@ def _history_json(self): def _get_queue(self, name): return self.q.get(name) - def _commit_queues(self, added, removed): + def _commit_queues(self, added, removed, netcls=NetworkQueue, localcls=LocalQueue): for name in removed: self.q.remove(name) for a in added: - try: - lq = LANQueue( - a["name"], - a["addr"] if a["addr"].lower() != "auto" else self.get_local_addr(), - self._logger, - Strategy.IN_ORDER, - self._on_queue_update, - self._fileshare, - self._printer_profile, - self._path_on_disk, - ) # TODO specify strategy - lq.connect() - self.q.add(a["name"], lq) - except ValueError: - self._logger.error( - f"Unable to join network queue (name {qdata['name']}, addr {qdata['addr']}) due to ValueError" - ) + q = Queue( + name=a["name"], + strategy=Strategy.IN_ORDER, + ) + self._init_queue(q, netcls, localcls) # We trigger state update rather than returning it here, because this is called by the settings viewmodel # (not the main viewmodel that displays the queues) diff --git a/continuousprint/plugin_test.py b/continuousprint/plugin_test.py index d9ec006d..99ad2693 100644 --- a/continuousprint/plugin_test.py +++ b/continuousprint/plugin_test.py @@ -72,19 +72,32 @@ def testObicoFound(self): ): p._setup_thirdparty_plugin_integration() + self.assertEqual(p._get_key(Keys.NETWORK_QUEUES), False) # PeerPrint self.assertEqual(p._get_key(Keys.MATERIAL_SELECTION), False) # Spoolmanager self.assertEqual(p._get_key(Keys.RESTART_ON_PAUSE), True) # Obico def testSpoolManagerFound(self): p = setupPlugin() - p._plugin_manager.plugins.get.return_value = MagicMock() + p._plugin_manager.plugins.get.side_effect = (MagicMock(), None) p._setup_thirdparty_plugin_integration() - p._plugin_manager.plugins.get.assert_called_with("SpoolManager") + p._plugin_manager.plugins.get.assert_any_call("SpoolManager") + self.assertEqual(p._get_key(Keys.NETWORK_QUEUES), False) # PeerPrint self.assertEqual(p._get_key(Keys.MATERIAL_SELECTION), True) # Spoolmanager self.assertEqual(p._get_key(Keys.RESTART_ON_PAUSE), False) # Obico + def testPeerPrintFound(self): + p = setupPlugin() + p._plugin_manager.plugins.get.side_effect = (None, MagicMock()) + + p._setup_thirdparty_plugin_integration() + + p._plugin_manager.plugins.get.assert_any_call("octoprint_peerprint") + self.assertEqual(p._get_key(Keys.NETWORK_QUEUES), True) # PeerPrint + self.assertEqual(p._get_key(Keys.RESTART_ON_PAUSE), False) # Obico + self.assertEqual(p._get_key(Keys.MATERIAL_SELECTION), False) # SpoolManager + def testPatchCommJobReader(self): p = setupPlugin() gnfj = p._printer._comm._get_next_from_job @@ -194,41 +207,17 @@ def testDBWithLegacySettings(self): p._init_db() self.assertEqual(len(getJobsAndSets(DEFAULT_QUEUE)), 1) - def testFileshare(self): - p = setupPlugin() - fs = MagicMock() - p.get_local_addr = lambda: ("111.111.111.111:0") - p._file_manager.path_on_disk.return_value = "/testpath" - - p._init_fileshare(fs_cls=fs) - - fs.assert_called_with("111.111.111.111:0", "/testpath", logging.getLogger()) - - def testFileshareAddrFailure(self): - p = setupPlugin() - fs = MagicMock() - p.get_local_addr = MagicMock(side_effect=[OSError("testing")]) - p._init_fileshare(fs_cls=fs) # Does not raise exception - self.assertEqual(p._fileshare, None) - - def testFileshareConnectFailure(self): - p = setupPlugin() - fs = MagicMock() - p.get_local_addr = lambda: "111.111.111.111:0" - fs.connect.side_effect = OSError("testing") - p._init_fileshare(fs_cls=fs) # Does not raise exception - self.assertEqual(p._fileshare, fs()) - def testQueues(self): p = setupPlugin() QT = namedtuple("MockQueue", ["name", "addr"]) + p._set_key(Keys.NETWORK_QUEUES, True) + p._peerprint = MagicMock() p._queries.getQueues.return_value = [ QT(name="LAN", addr="0.0.0.0:0"), QT(name=DEFAULT_QUEUE, addr=None), QT(name=ARCHIVE_QUEUE, addr=None), ] - p._fileshare = None - p._init_queues(lancls=MagicMock(), localcls=MagicMock()) + p._init_queues(netcls=MagicMock(), localcls=MagicMock()) self.assertEqual(len(p.q.queues), 2) # 2 queues created, archive skipped def testDriver(self): @@ -621,61 +610,3 @@ def testAnalysisCompleted(self): self.p._file_manager.set_additional_metadata.assert_called_with( ANY, "a.gcode", ANY, ANY, overwrite=True ) - - -class TestCleanupFileshare(unittest.TestCase): - def setUp(self): - self.td = tempfile.TemporaryDirectory() - q = MagicMock() - q.as_dict.return_value = dict( - jobs=[ - {"hash": "a", "peer_": q.addr, "acquired_by_": None}, - {"hash": "b", "peer_": "peer2", "acquired_by_": q.addr}, - {"hash": "c", "peer_": "peer2", "acquired_by_": None}, - {"hash": "d", "peer_": "peer2", "acquired_by_": None}, - ] - ) - self.p = setupPlugin() - self.p.fileshare_dir = self.td.name - self.p.q = MagicMock() - self.p.q.queues.items.return_value = [("q", q)] - - def tearDown(self): - self.td.cleanup() - - def testCleanupNoFiles(self): - self.assertEqual(self.p._cleanup_fileshare(), 0) - - def testCleanupWithFiles(self): - p = Path(self.p.fileshare_dir) - (p / "d").mkdir() - for n in ("a", "b", "c"): - (p / f"{n}.gcode").touch() - self.assertEqual(self.p._cleanup_fileshare(), 2) - - for n in ("a", "b"): - self.assertTrue((p / f"{n}.gcode").exists()) - self.assertFalse((p / "c.gcode").exists()) - self.assertFalse((p / "d").exists()) - - -class TestLocalAddressResolution(unittest.TestCase): - def setUp(self): - self.p = setupPlugin() - - @patch("continuousprint.plugin.socket") - def testResolutionViaCheckAddrOK(self, msock): - self.p._settings.global_set(["server", "onlineCheck", "host"], "checkhost") - self.p._settings.global_set(["server", "onlineCheck", "port"], 5678) - s = msock.socket() - s.getsockname.return_value = ("1.2.3.4", "1234") - self.assertEqual(self.p.get_local_addr(), "1.2.3.4:1234") - s.connect.assert_called_with(("checkhost", 5678)) - - @patch("continuousprint.plugin.socket") - def testResolutionFailoverToMDNS(self, msock): - self.p._can_bind_addr = lambda a: False - msock.gethostbyname.return_value = "1.2.3.4" - s = msock.socket() - s.getsockname.return_value = ("ignored", "1234") - self.assertEqual(self.p.get_local_addr(), "1.2.3.4:1234") diff --git a/continuousprint/queues/abstract.py b/continuousprint/queues/base.py similarity index 94% rename from continuousprint/queues/abstract.py rename to continuousprint/queues/base.py index dae1e6fc..c66bead9 100644 --- a/continuousprint/queues/abstract.py +++ b/continuousprint/queues/base.py @@ -5,6 +5,10 @@ from abc import ABC, abstractmethod +class ValidationError(Exception): + pass + + class Strategy(Enum): IN_ORDER = auto() # Jobs and sets printed in lexRank order LEAST_MANUAL = auto() # Choose the job which produces the least manual changes @@ -40,6 +44,10 @@ def get_job(self) -> Optional[JobView]: def get_set(self) -> Optional[SetView]: return self.set + @abstractmethod + def resolve(self, path, peer, hash_) -> Optional[str]: + pass + @abstractmethod def acquire(self) -> bool: pass diff --git a/continuousprint/queues/abstract_test.py b/continuousprint/queues/base_test.py similarity index 61% rename from continuousprint/queues/abstract_test.py rename to continuousprint/queues/base_test.py index 979cdb78..5d9dc060 100644 --- a/continuousprint/queues/abstract_test.py +++ b/continuousprint/queues/base_test.py @@ -1,49 +1,54 @@ -from ..storage.database import JobView, SetView - - class DummyQueue: name = "foo" -def testJob(inst): - s = SetView() - s.id = inst - s.path = f"set{inst}.gcode" - s.count = 2 - s.remaining = 2 - s.rank = 0 - s.sd = False - s.material_keys = "" - s.metadata = None - s.profile_keys = "profile" - s.completed = 0 - s.save = lambda: True - j = JobView() - s.job = j - j.id = inst - j.acquired = False - j.name = f"job{inst}" - j.count = 2 - j.remaining = 2 - j.sets = [s] - j.draft = False - j.rank = 0 - j.queue = DummyQueue() - j.created = 5 - j.save = lambda: True +def testJob(inst, cls, qcls=DummyQueue): + if qcls is None: + qcls = DummyQueue + j = cls() + j.load_dict( + dict( + job=j, + id=inst, + acquired=False, + name=f"job{inst}", + count=2, + remaining=2, + draft=False, + rank=0, + peer_="foo", + created=5, + sets=[ + dict( + id=f"set{inst}", + path=f"set{inst}.gcode", + count=2, + remaining=2, + rank=0, + sd=False, + material_keys="", + metadata=None, + profiles=["profile"], + completed=0, + ) + ], + ), + qcls(), + ) return j class JobEqualityTests: def _strip(self, d, ks): for k in ks: - del d[k] + if k in d: + del d[k] def assertJobsEqual(self, v1, v2, ignore=[]): d1 = v1.as_dict() d2 = v2.as_dict() for d in (d1, d2): - self._strip(d, [*ignore, "id", "queue"]) + self._strip(d, [*ignore, "id", "queue", "hash"]) for s in d1["sets"]: self._strip(s, ("id", "rank")) for s in d2["sets"]: @@ -51,23 +56,34 @@ def assertJobsEqual(self, v1, v2, ignore=[]): self.assertEqual(d1, d2) def assertSetsEqual(self, s1, s2): - d1 = s1.as_dict() - d2 = s2.as_dict() - for d in (d1, d2): - self._strip(d, ("id", "rank")) - self.assertEqual(d1, d2) + self.assertTrue(s1 is not None) + self.assertTrue(s2 is not None) + for k in ( + "path", + "count", + "metadata", + "material_keys", + "profile_keys", + "sd", + "remaining", + "completed", + ): + self.assertEqual(getattr(s1, k), getattr(s2, k)) class AbstractQueueTests(JobEqualityTests): + maxDiff = None + def setUp(self): - raise NotImplementedError("Must create queue as self.q with testJob() inserted") + raise NotImplementedError( + "Must create queue as self.q with testJob() inserted, also assign self.j" + ) def test_acquire_get_release(self): - j = testJob(0) self.assertEqual(self.q.acquire(), True) self.assertEqual(self.q.get_job().acquired, True) - self.assertJobsEqual(self.q.get_job(), j, ignore=["acquired"]) - self.assertSetsEqual(self.q.get_set(), j.sets[0]) + self.assertJobsEqual(self.q.get_job(), self.j, ignore=["acquired", "sd"]) + self.assertSetsEqual(self.q.get_set(), self.j.sets[0]) self.q.release() self.assertEqual(self.q.get_job(), None) self.assertEqual(self.q.get_set(), None) @@ -99,21 +115,21 @@ class EditableQueueTests(JobEqualityTests): def setUp(self): raise NotImplementedError( - "Must create queue as self.q with testJob() inserted (inst=0..3)" + "Must create queue as self.q with testJob() inserted (inst=0..3) and self.cls/qcls for classes in mkjob()" ) def test_mv_job_exchange(self): - self.q.mv_job(self.jids[1], self.jids[2]) + self.q.mv_job(self.jids[1], self.jids[2], self.jids[3]) jids = [j["id"] for j in self.q.as_dict()["jobs"]] self.assertEqual(jids, [self.jids[i] for i in (0, 2, 1, 3)]) def test_mv_to_front(self): - self.q.mv_job(self.jids[2], None) + self.q.mv_job(self.jids[2], None, self.jids[0]) jids = [j["id"] for j in self.q.as_dict()["jobs"]] self.assertEqual(jids, [self.jids[i] for i in (2, 0, 1, 3)]) def test_mv_to_back(self): - self.q.mv_job(self.jids[2], self.jids[3]) + self.q.mv_job(self.jids[2], self.jids[3], None) jids = [j["id"] for j in self.q.as_dict()["jobs"]] self.assertEqual(jids, [self.jids[i] for i in (0, 1, 3, 2)]) @@ -128,24 +144,27 @@ def test_edit_job_then_decrement_persists_changes(self): self.assertEqual(len(self.q.as_dict()["jobs"][0]["sets"]), 1) # Edit the acquired job, adding a new set - newsets = [testJob(0).sets[0].as_dict()] # Same as existing - newsets.append(testJob(100).sets[0].as_dict()) # New set + newsets = [self.q.as_dict()["jobs"][0]["sets"][0]] # Same as existing + newsets.append(testJob(100, self.cls, self.qcls).sets[0].as_dict()) # New set self.q.edit_job(self.jids[0], dict(sets=newsets)) - # Value after decrement should be consistent, i.e. not regress to prior acquired-job value self.q.decrement() self.assertEqual(len(self.q.as_dict()["jobs"][0]["sets"]), 2) def test_get_job_view(self): - self.assertJobsEqual(self.q.get_job_view(self.jids[0]), testJob(0)) + self.assertJobsEqual( + self.q.get_job_view(self.jids[0]), + testJob(0, self.cls, self.qcls), + ignore=("rd", "rn", "rank"), + ) def test_import_job_from_view(self): - j = testJob(10) + j = testJob(10, self.cls, self.qcls) jid = self.q.import_job_from_view(j) - self.assertJobsEqual(self.q.get_job_view(jid), j) + self.assertJobsEqual(self.q.get_job_view(jid), j, ignore=("rd", "rn", "rank")) def test_import_job_from_view_persists_completion_and_remaining(self): - j = testJob(10) + j = testJob(10, self.cls, self.qcls) j.sets[0].completed = 3 j.sets[0].remaining = 5 jid = self.q.import_job_from_view(j) diff --git a/continuousprint/queues/lan.py b/continuousprint/queues/lan.py deleted file mode 100644 index 47ff4502..00000000 --- a/continuousprint/queues/lan.py +++ /dev/null @@ -1,333 +0,0 @@ -import uuid -from typing import Optional -from bisect import bisect_left -from peerprint.lan_queue import LANPrintQueue, ChangeType -from ..storage.lan import LANJobView, LANSetView -from ..storage.database import JobView, SetView -from pathlib import Path -from .abstract import AbstractEditableQueue, QueueData, Strategy -import dataclasses - - -class ValidationError(Exception): - pass - - -class LANQueue(AbstractEditableQueue): - def __init__( - self, - ns, - addr, - logger, - strategy: Strategy, - update_cb, - fileshare, - profile, - path_on_disk_fn, - ): - super().__init__() - self._logger = logger - self._profile = profile - self.strategy = strategy - self.ns = ns - self.addr = addr - self.lan = None - self.job_id = None - self.set_id = None - self.update_cb = update_cb - self._fileshare = fileshare - self._path_on_disk = path_on_disk_fn - self.lan = LANPrintQueue(self.ns, self.addr, self._on_update, self._logger) - - # ---------- LAN queue methods --------- - - def is_ready(self) -> bool: - return self.lan.q.is_ready() - - def connect(self): - self.lan.connect() - - def _compare_peer(self, prev, nxt): - if prev is None and nxt is not None: - return True - if prev is not None and nxt is None: - return True - if prev is None and nxt is None: - return False - for k in ("status", "run"): - if prev.get(k) != nxt.get(k): - return True - return False - - def _compare_job(self, prev, nxt): - return True # Always trigger callback - TODO make this more sophisticated - - def _on_update(self, changetype, prev, nxt): - if changetype == ChangeType.PEER and not self._compare_peer(prev, nxt): - return - elif changetype == ChangeType.JOB and not self._compare_job(prev, nxt): - return - self.update_cb(self) - - def destroy(self): - self.lan.destroy() - - def update_peer_state(self, name, status, run, profile): - if self.lan is not None and self.lan.q is not None: - self.lan.q.syncPeer( - dict( - active_set=self._active_set(), - name=name, - status=status, - run=run, - profile=profile, - fs_addr=f"{self._fileshare.host}:{self._fileshare.port}", - ) - ) - - def set_job(self, jid: str, manifest: dict): - # Preserve peer address of job if present in the manifest - return self.lan.q.setJob(jid, manifest, addr=manifest.get("peer_", None)) - - def get_gjob_dirpath(self, peer, hash_): - # Get fileshare address from the peer - peerstate = self._get_peers().get(peer) - if peerstate is None: - raise ValidationError( - "Cannot resolve set {path} within job hash {hash_}; peer state is None" - ) - - # fetch unpacked job from fileshare (may be cached) and return the real path - return self._fileshare.fetch(peerstate["fs_addr"], hash_, unpack=True) - - # -------- Wrappers around LANQueue to add/remove metadata ------ - - def _annotate_job(self, peer_and_manifest, acquired_by): - (peer, manifest) = peer_and_manifest - m = dict(**manifest) - m["peer_"] = peer - m["acquired"] = True if acquired_by is not None else False - m["acquired_by_"] = acquired_by - return m - - def _normalize_job(self, data): - del m["peer_"] - del m["acquired_by_"] - - def _get_jobs(self) -> list: - joblocks = self.lan.q.getLocks() - jobs = [] - for (jid, v) in self.lan.q.getJobs(): - jobs.append(self._annotate_job(v, joblocks.get(jid))) - return jobs - - def _get_job(self, jid) -> dict: - j = self.lan.q.getJob(jid) - if j is not None: - joblocks = self.lan.q.getLocks() - return self._annotate_job(j, joblocks.get(jid)) - - def _get_peers(self) -> list: - result = {} - # Locks are given by job:peer, so reverse this - peerlocks = dict([(v, k) for k, v in self.lan.q.getLocks().items()]) - for k, v in self.lan.q.getPeers().items(): - result[k] = dict(**v, acquired=peerlocks.get(k, [])) - return result - - # --------- begin AbstractQueue -------- - - def get_job(self) -> Optional[JobView]: - # Override to ensure the latest data is received - return self.get_job_view(self.job_id) - - def get_set(self) -> Optional[SetView]: - if self.job_id is not None and self.set_id is not None: - # Linear search through sets isn't efficient, but it works. - j = self.get_job_view(self.job_id) - for s in j.sets: - if s.id == self.set_id: - return s - - def _peek(self): - if self.lan is None or self.lan.q is None: - return (None, None) - for data in self._get_jobs(): - acq = data.get("acquired_by_") - if acq is not None and acq != self.addr: - continue # Acquired by somebody else, so don't consider for scheduling - job = LANJobView(data, self) - s = job.next_set(self._profile) - if s is not None: - return (job, s) - return (None, None) - - def acquire(self) -> bool: - if self.lan is None or self.lan.q is None: - return False - (job, s) = self._peek() - if job is not None and s is not None: - if self.lan.q.acquireJob(job.id): - self._logger.debug(f"acquire() candidate:\n{job}\n{s}") - self.job_id = job.id - self.set_id = s.id - self._logger.debug("acquire() success") - return True - else: - self._logger.debug("acquire() failed") - return False - else: - return False - - def release(self) -> None: - if self.job_id is not None: - self.lan.q.releaseJob(self.job_id) - self.job_id = None - self.set_id = None - - def decrement(self) -> None: - if self.job_id is not None: - next_set = self.get_set().decrement(self._profile) - if next_set: - self._logger.debug("Still has work, going for next set") - self.set_id = next_set.id - return True - else: - self._logger.debug("No more work; releasing") - self.release() - return False - else: - raise Exception("Cannot decrement; no job acquired") - - def _active_set(self): - assigned = self.get_set() - if assigned is not None: - return assigned.id - return None - - def as_dict(self) -> dict: - jobs = [] - peers = {} - if self.lan.q is not None: - jobs = self._get_jobs() - peers = self._get_peers() - for j in jobs: - j["queue"] = self.ns - - return dataclasses.asdict( - QueueData( - name=self.lan.ns, - addr=self.addr, - strategy=self.strategy.name, - jobs=jobs, - peers=peers, - active_set=self._active_set(), - ) - ) - - def reset_jobs(self, job_ids) -> dict: - for jid in job_ids: - j = self._get_job(jid) - if j is None: - continue - - j["remaining"] = j["count"] - for s in j.get("sets", []): - s["remaining"] = s["count"] - s["completed"] = 0 - self.lan.q.setJob(jid, j, addr=j["peer_"]) - - def remove_jobs(self, job_ids) -> dict: - n = 0 - for jid in job_ids: - if self.lan.q.removeJob(jid) is not None: - n += 1 - return dict(jobs_deleted=n) - - # --------- end AbstractQueue ------ - - # --------- AbstractEditableQueue implementation ------ - - def get_job_view(self, job_id): - j = self._get_job(job_id) - if j is not None: - return LANJobView(j, self) - - def import_job_from_view(self, j, jid=None): - err = self._validate_job(j) - if err is not None: - raise ValidationError(err) - filepaths = dict([(s.path, self._path_on_disk(s.path, s.sd)) for s in j.sets]) - manifest = j.as_dict() - if manifest.get("created") is None: - manifest["created"] = int(time.time()) - # Note: post mutates manifest by stripping fields - manifest["hash"] = self._fileshare.post(manifest, filepaths) - manifest["id"] = jid if jid is not None else self._gen_uuid() - - # Propagate peer if importing from a LANJobView - # But don't fail with AttributeError if it's just a JobView - self.lan.q.setJob(manifest["id"], manifest, addr=getattr(j, "peer", None)) - return manifest["id"] - - def mv_job(self, job_id, after_id): - self.lan.q.jobs.mv(job_id, after_id) - - def _path_exists(self, fullpath): - return Path(fullpath).exists() - - def _validate_job(self, j: JobView) -> str: - peer_profiles = set( - [ - p.get("profile", dict()).get("name", "UNKNOWN") - for p in self._get_peers().values() - ] - ) - - for s in j.sets: - sprof = set(s.profiles()) - # All sets in the job *must* have an assigned profile - if len(sprof) == 0: - return f"validation for job {j.name} failed - set {s.path} has no assigned profile" - - # At least one printer in the queue must have a compatible proile - if len(peer_profiles.intersection(sprof)) == 0: - return f"validation for job {j.name} failed - no match for set {s.path} with profiles {sprof} (connected printer profiles: {peer_profiles})" - - # All set paths must resolve to actual files - fullpath = self._path_on_disk(s.path, s.sd) - if fullpath is None or not self._path_exists(fullpath): - return f"validation for job {j.name} failed - file not found at {s.path} (is it stored on disk and not SD?)" - - def _gen_uuid(self) -> str: - for i in range(100): - result = uuid.uuid4() - if not self.lan.q.hasJob(result): - return str(result) - raise Exception("UUID generation failed - too many ID collisions") - - def edit_job(self, job_id, data) -> bool: - # For lan queues, "editing" a job is basically resubmission of the whole thing. - # This is because the backing .gjob format is a single file containing the full manifest. - - j = self.get_job_view(job_id) - for (k, v) in data.items(): - if k in ("id", "peer_", "queue"): - continue - if k == "sets": - j.updateSets( - v - ) # Set data must be translated into views, done by updateSets() - else: - setattr(j, k, v) - - # We must resolve the set paths so we have them locally, as editing can - # also occur on servers other than the one that submitted the job. - j.remap_set_paths() - - # We are also now the source of this job - j.peer = self.addr - - # Exchange the old job for the new job (reuse job ID) - jid = self.import_job_from_view(j, j.id) - return self._get_job(jid) diff --git a/continuousprint/queues/lan_test.py b/continuousprint/queues/lan_test.py deleted file mode 100644 index 4bea19c0..00000000 --- a/continuousprint/queues/lan_test.py +++ /dev/null @@ -1,158 +0,0 @@ -import unittest -import logging -import tempfile -from datetime import datetime -from unittest.mock import MagicMock -from .abstract import Strategy -from .abstract_test import ( - AbstractQueueTests, - EditableQueueTests, - testJob as makeAbstractTestJob, -) -from .lan import LANQueue, ValidationError -from ..storage.database import JobView, SetView -from peerprint.lan_queue_test import LANQueueLocalTest as PeerPrintLANTest - -# logging.basicConfig(level=logging.DEBUG) - - -class LANQueueTest(unittest.TestCase, PeerPrintLANTest): - def setUp(self): - PeerPrintLANTest.setUp(self) # Generate peerprint LANQueue as self.q - self.q.q.syncPeer( - dict( - profile=dict(name="profile"), - fs_addr="mock_fs_addr", - ), - addr=self.q.q.addr, - ) # Helps pass validation - ppq = self.q # Rename to make way for CPQ LANQueue - - self.ucb = MagicMock() - self.fs = MagicMock() - self.fs.fetch.return_value = "asdf.gcode" - self.q = LANQueue( - "ns", - "localhost:1234", - logging.getLogger(), - Strategy.IN_ORDER, - self.ucb, - self.fs, - dict(name="profile"), - lambda path, sd: path, - ) - self.q.lan = ppq - self.q._path_exists = lambda p: True # Override path check for validation - - -class TestAbstractImpl(AbstractQueueTests, LANQueueTest): - def setUp(self): - LANQueueTest.setUp(self) - self.jid = self.q.import_job_from_view(makeAbstractTestJob(0)) - - -class TestEditableImpl(EditableQueueTests, LANQueueTest): - def setUp(self): - LANQueueTest.setUp(self) - self.jids = [ - self.q.import_job_from_view(makeAbstractTestJob(i)) - for i in range(EditableQueueTests.NUM_TEST_JOBS) - ] - - -class TestLANQueueNoConnection(LANQueueTest): - def test_update_peer_state(self): - self.q.update_peer_state("HI", {}, {}, {}) # No explosions? Good - - -class DummyQueue: - name = "lantest" - - -class TestLANQueueConnected(LANQueueTest): - def setUp(self): - super().setUp() - self.q.lan = MagicMock() - self.q.lan.q = MagicMock() - self.q.lan.q.hasJob.return_value = False # For UUID generation - self.q.lan.q.getPeers.return_value = { - "a": dict(fs_addr="123", profile=dict(name="abc")), - } - - def test_get_gjob_dirpath_failed_bad_peer(self): - with self.assertRaises(Exception): - self.q.get_gjob_dirpath("b", "hash") - - def test_get_gjob_dirpath(self): - self.fs.fetch.return_value = "/dir/" - self.assertEqual(self.q.get_gjob_dirpath("a", "hash"), "/dir/") - self.fs.fetch.assert_called_with("123", "hash", unpack=True) - - def _jbase(self, path="a.gcode"): - j = JobView() - j.id = 1 - j.name = "j1" - j.queue = DummyQueue() - s = SetView() - s.path = path - s.id = 2 - s.sd = False - s.count = 1 - s.remaining = 1 - s.completed = 0 - s.profile_keys = "" - s.rank = 1 - s.material_keys = "" - j.sets = [s] - j.count = 1 - j.draft = False - j.created = 100 - j.remaining = 1 - j.acquired = False - return j - - def test_validation_file_missing(self): - j = self._jbase() - j.sets[0].profile_keys = "def,abc" - self.q._path_exists = lambda p: False # Override path check for validation - with self.assertRaisesRegex(ValidationError, "file not found"): - self.q.import_job_from_view(j) - self.fs.post.assert_not_called() - - def test_validation_no_profile(self): - with self.assertRaisesRegex(ValidationError, "no assigned profile"): - self.q.import_job_from_view(self._jbase()) - self.fs.post.assert_not_called() - - def test_validation_no_match(self): - j = self._jbase() - j.sets[0].profile_keys = "def" - with self.assertRaisesRegex(ValidationError, "no match for set"): - self.q.import_job_from_view(j) - self.fs.post.assert_not_called() - - -class TestLANQueueWithJob(LANQueueTest): - def setUp(self): - pass - - def test_acquire_success(self): - pass - - def test_acquire_failed(self): - pass - - def test_acquire_failed_no_jobs(self): - pass - - def test_release(self): - pass - - def test_decrement_more_work(self): - pass - - def test_decrement_no_more_work(self): - pass - - def test_as_dict(self): - pass diff --git a/continuousprint/queues/local.py b/continuousprint/queues/local.py index 4d7f38b3..af907624 100644 --- a/continuousprint/queues/local.py +++ b/continuousprint/queues/local.py @@ -1,8 +1,9 @@ -from .abstract import Strategy, QueueData, AbstractFactoryQueue +from .base import Strategy, QueueData, AbstractFactoryQueue import tempfile import shutil import os from ..storage.database import JobView, SetView +from ..storage.peer import PeerJobView from peerprint.filesharing import pack_job, unpack_job, packed_name from pathlib import Path import dataclasses @@ -44,6 +45,9 @@ def _set_path_exists(self, s): # --------------------- Begin AbstractQueue ------------------ + def resolve(self, path, peer, hash_): + raise NotImplementedError() + def acquire(self) -> bool: if self.job is not None: return True @@ -115,9 +119,10 @@ def import_job_from_view(self, v, copy_fn=shutil.copytree): manifest = v.as_dict() # If importing from a non-local queue, we must also fetch/import the files so they're available locally. - if hasattr(v, "get_base_dir"): + if isinstance(v, PeerJobView): dest_dir = f'ContinuousPrint/imports/{manifest["name"]}_{manifest["id"]}' - gjob_dir = v.get_base_dir() + + gjob_dir = v.queue.q.get_gjob_dirpath(v.hash) copy_fn(gjob_dir, self._path_on_disk(dest_dir, False)) for s in manifest["sets"]: s["path"] = os.path.join(dest_dir, s["path"]) @@ -134,7 +139,7 @@ def import_job_from_view(self, v, copy_fn=shutil.copytree): self.add_set(j.id, s) return j.id - def mv_job(self, job_id, after_id): + def mv_job(self, job_id, after_id, before_id): return self.queries.moveJob(job_id, after_id) def edit_job(self, job_id, data): diff --git a/continuousprint/queues/local_test.py b/continuousprint/queues/local_test.py index 1153b40f..18645600 100644 --- a/continuousprint/queues/local_test.py +++ b/continuousprint/queues/local_test.py @@ -2,11 +2,11 @@ import logging from ..storage.database_test import QueuesDBTest from ..storage import queries -from ..storage.lan import LANJobView -from ..storage.database import JobView +from ..storage.peer import PeerJobView, PeerQueueView +from ..storage.database import JobView, Job, Queue from unittest.mock import MagicMock -from .abstract import Strategy, QueueData -from .abstract_test import ( +from .base import Strategy, QueueData +from .base_test import ( AbstractQueueTests, EditableQueueTests, testJob as makeAbstractTestJob, @@ -29,7 +29,10 @@ def setUp(self): MagicMock(), MagicMock(), ) - self.jid = self.q.import_job_from_view(makeAbstractTestJob(0)) + self.jid = self.q.import_job_from_view( + makeAbstractTestJob(0, cls=Job, qcls=Queue) + ) + self.j = Job.get(id=self.jid) self.q._set_path_exists = lambda p: True @@ -37,6 +40,8 @@ class TestEditableImpl(EditableQueueTests, QueuesDBTest): # See abstract_test.py for actual test cases def setUp(self): QueuesDBTest.setUp(self) + self.cls = Job + self.qcls = Queue self.q = LocalQueue( queries, "local", @@ -46,13 +51,13 @@ def setUp(self): MagicMock(), ) self.jids = [ - self.q.import_job_from_view(makeAbstractTestJob(i)) + self.q.import_job_from_view(makeAbstractTestJob(i, cls=Job, qcls=Queue)) for i in range(EditableQueueTests.NUM_TEST_JOBS) ] self.q._set_path_exists = lambda p: True -class TestLocalQueueImportFromLAN(unittest.TestCase): +class TestLocalQueueImportFromNetwork(unittest.TestCase): def setUp(self): queries = MagicMock() queries.getAcquiredJob.return_value = None @@ -65,11 +70,10 @@ def setUp(self): MagicMock(), ) - def testImportJobFromLANView(self): - # Jobs imported from LAN into local queue must have their + def testImportJobFromPeerView(self): + # Jobs imported into local queue must have their # gcode files copied from the remote peer, in a way which # doesn't get auto-cleaned (as in the fileshare/ directory) - lq = MagicMock() j = JobView() j.id = "567" j.save = MagicMock() @@ -77,13 +81,16 @@ def testImportJobFromLANView(self): manifest = dict( name="test_job", id="123", - sets=[dict(path="a.gcode", count=1, remaining=1)], + sets=[dict(path="a.gcode", count=1, remaining=1, metadata="")], hash="foo", peer_="addr", ) cp = MagicMock() - lq.get_gjob_dirpath.return_value = "gjob_dirpath" - self.q.import_job_from_view(LANJobView(manifest, lq), cp) + j = PeerJobView() + netq = PeerQueueView(MagicMock()) + netq.q.get_gjob_dirpath.return_value = "gjob_dirpath" + j.load_dict(manifest, netq) + self.q.import_job_from_view(j, cp) wantdir = "ContinuousPrint/imports/test_job_123" cp.assert_called_with("gjob_dirpath", wantdir) diff --git a/continuousprint/queues/multi.py b/continuousprint/queues/multi.py index 362d6abb..c38069b4 100644 --- a/continuousprint/queues/multi.py +++ b/continuousprint/queues/multi.py @@ -1,6 +1,6 @@ from typing import Optional from ..storage.database import Run, SetView -from .abstract import AbstractQueue, Strategy +from .base import AbstractQueue, Strategy import dataclasses @@ -17,6 +17,9 @@ def __init__(self, queries, strategy, update_cb): self.active_queue = None self.update_cb = update_cb + def resolve(self, path, peer, hash_) -> Optional[str]: + raise NotImplementedError() + def update_peer_state(self, *args): for q in self.queues.values(): if hasattr(q, "update_peer_state"): @@ -81,6 +84,7 @@ def acquire(self) -> bool: for k, q in self.queues.items(): if q.acquire(): self.active_queue = q + print("Acquired thing", self.get_job(), self.get_set()) self.run = self.queries.getActiveRun( self.active_queue.ns, self.get_job().name, self.get_set().path ) diff --git a/continuousprint/queues/multi_test.py b/continuousprint/queues/multi_test.py index df910b4e..e80c176b 100644 --- a/continuousprint/queues/multi_test.py +++ b/continuousprint/queues/multi_test.py @@ -1,7 +1,7 @@ import unittest import logging from unittest.mock import MagicMock -from .abstract import Strategy +from .base import Strategy from .multi import MultiQueue # logging.basicConfig(level=logging.DEBUG) diff --git a/continuousprint/queues/net.py b/continuousprint/queues/net.py new file mode 100644 index 00000000..0f01a362 --- /dev/null +++ b/continuousprint/queues/net.py @@ -0,0 +1,483 @@ +import uuid +import time +import json +from threading import Thread +from typing import Optional +from bisect import bisect_left +from ..storage.peer import PeerJobView, PeerQueueView, PeerSetView +from ..storage.database import JobView, SetView +from ..storage.rank import Rational, rational_intermediate +from pathlib import Path +from .base import AbstractEditableQueue, QueueData, Strategy, ValidationError +import dataclasses + + +class NetworkQueue(AbstractEditableQueue): + def __init__( + self, + ns, + peerprint, + logger, + strategy: Strategy, + update_cb, + profile, + path_on_disk_fn, + ): + super().__init__() + self._logger = logger + self._profile = profile + self.strategy = strategy + self.ns = ns + self._peerprint = peerprint + self.job_id = None + self.set_id = None + self.server_id = None + self.update_cb = update_cb + self._fileshare = peerprint.get_plugin().fileshare + self._path_on_disk = path_on_disk_fn + + @property + def _printer_name(self): + return self._peerprint.get_plugin().printer_name + + @property + def _client(self): + # Put this in a function so client can be lazy initialized and we still get it + return self._peerprint.get_plugin().client + + # ---------- Network queue methods --------- + + def connect(self): + self._logger.debug("Waiting for p2p server...") + while True: + if self._client.is_ready() and self._client.ping(): + break + time.sleep(1.0) + + conns = [c.network for c in self._client.get_connections()] + if self.ns not in conns: + raise Exception( + f'Network queue "{self.ns}" init failure - no such connection on peerprint server, candidates {conns}' + ) + + self.server_id = self._client.get_id(self.ns) + self._logger.debug(f"p2p server ready, ID is {self.server_id}") + + self._loop = True + Thread(target=self._loop_stream, daemon=True).start() + + def __del__(self): + self._loop = False + + def resolve(self, path, hash_): + dirpath = self.get_gjob_dirpath(hash_) + return str(Path(dirpath) / path) + + def is_ready(self) -> bool: + return self._client.is_ready() + + def _loop_stream(self): + while self._loop: + self._logger.debug("starting event stream") + evts = self._client.stream_events(self.ns) + for e in evts: + self._on_update(e) + # Delay to prevent overwhelming server + # TODO switch to random exponential backoff + time.sleep(5.0) + + def _on_update(self, e): + # self._logger.debug(f"network event: {e}") + self.update_cb(self) + + def _set_completion(self, uuid, timestamp, typ): + return self._client.set_completion( + self.ns, + uuid=uuid, + completer=self.server_id, + client=self._printer_name, + timestamp=timestamp, + type=int(typ), + completer_state=json.dumps( + dict(profile=self._profile, printer=self._printer_name) + ).encode("utf8"), + ) + + def update_peer_state(self, status, profile): + # Store info in memory to decorate completions later on + self._profile = profile + + if self._client is not None: + self._client.set_status( + self.ns, + active_unit=json.dumps(self._active_set()), + name=name, + status=str(status), + profile=json.dumps(profile), + # TODO lat/lon + ) + + def _last_rank(self): + greatest = (0, 1, 0.0) + for r in self._client.get_records(self.ns): + if greatest is None or r.record.rank.gen > greatest[2]: + greatest = (r.record.rank.num, r.record.rank.den, r.record.rank.gen) + return greatest + + def set_job(self, jid: str, manifest: dict): + rank = dict( + [ + (k, manifest.get(l)) + for k, l in (("num", "rn"), ("den", "rd"), ("gen", "rank")) + ] + ) + if None in rank.values(): + n = self._last_rank() + rank = dict(num=n[0] + 1, den=n[1], gen=float((n[0] + 1) / n[1])) + + approver = manifest.get("peer_") + if approver is None or approver == "": + approver = self.server_id + # self._logger.debug(f"set_record with approver {approver} (manifest approver {manifest.get("peer_")}, server-id {self.server_id} {type(self.server_id)})") + + return self._client.set_record( + self.ns, + manifest=json.dumps(manifest), + # Preserve peer address of job if present in the manifest + approver=approver, + uuid=jid, + created=getattr(manifest, "created", int(time.time())), + tags=getattr(manifest, "tags", []), + rank=rank, + ) + + def get_gjob_dirpath(self, hash_): + # fetch unpacked job from fileshare (may be cached) and return the real path + return self._fileshare.fetch(hash_, unpack=True) + + # -------- Wrappers around NetworkQueue to add/remove metadata ------ + + def _get_jobs(self) -> list: + if not self._client.is_ready(): + return [] + + acquiredBy = dict() + tombstones = set() + for c in self._client.get_completions(self.ns): + if c.completion.timestamp > 0 and c.completion.type == int( + self._client.CompletionType.TOMBSTONE + ): + tombstones.add(c.completion.uuid) + elif c.completion.timestamp == 0 and c.completion.type == int( + self._client.CompletionType.ACQUIRE + ): + acquiredBy[c.completion.uuid] = c.completion.client + + # Filter to newest records not tombstoned + records = {} + for r in self._client.get_records(self.ns): + if r.record.uuid in tombstones: + continue + cur = records.get(r.record.uuid) + if cur is not None and cur.record.created > r.record.created: + continue + records[r.record.uuid] = r + + # Convert to dict object + rs = [] + for r in records.values(): + rdata = json.loads(r.record.manifest) + acquirer = acquiredBy.get(r.record.uuid) + rdata["acquired"] = acquirer is not None + if rdata["acquired"] and acquirer != self._printer_name: + rdata["acquired_by"] = acquirer + rdata["rn"] = r.record.rank.num + rdata["rd"] = r.record.rank.den + rdata["rank"] = r.record.rank.gen + rs.append(rdata) + rs.sort(key=lambda r: r["rank"]) + return rs + + def _get_job(self, jid) -> dict: + if not self._client.is_ready() or jid is None: + return None + + acquiredBy = None + for c in self._client.get_completions(self.ns, uuid=jid): + if c.completion.timestamp > 0 and c.completion.type == int( + self._client.CompletionType.TOMBSTONE + ): + # Even if a job record exists, a tombstone completion may + # hide it from view. + # We persist the original record so its deletion can propagate. + return None + elif c.completion.timestamp == 0 and c.completion.type == int( + self._client.CompletionType.ACQUIRE + ): + acquiredBy = c.completion.client + + best = None + for r in self._client.get_records(self.ns, uuid=jid): + # print("_get_job record check", r.record.uuid, r.record.created) + if best is None or r.record.created > best.record.created: + best = r + + if best is not None: + data = json.loads(best.record.manifest) + data["acquired"] = acquiredBy is not None + if acquiredBy is not None and acquiredBy != self._printer_name: + data["acquired_by"] = acquiredBy + data["rn"] = best.record.rank.num + data["rd"] = best.record.rank.den + data["rank"] = best.record.rank.gen + return data + + def _get_peers(self) -> list: + if not self._client.is_ready(): + return [] + return list(self._client.get_peers(self.ns)) + + # --------- begin AbstractQueue -------- + + def get_job(self) -> Optional[JobView]: + if self.job_id is not None: + # Override to ensure the latest data is received + return self.get_job_view(self.job_id) + + def get_set(self) -> Optional[SetView]: + print("get_set with job_id", self.job_id, "set_id", self.set_id) + if self.job_id is not None and self.set_id is not None: + # Linear search through sets isn't efficient, but it works. + j = self.get_job_view(self.job_id) + if j is not None: + for s in j.sets: + if s.id == self.set_id: + return s + + def _peek(self): + if not self.is_ready(): + return (None, None) + for data in self._get_jobs(): + # skip if another peer is working on the job + peer_completions = set( + [ + c.completion.completer + for c in self._client.get_completions(self.ns, data["id"]) + if c.completion.completer != self.server_id + and c.completion.client != self._printer_name + and c.completion.type != int(self._client.CompletionType.RELEASE) + ] + ) + if len(peer_completions) > 0: + continue + + job = PeerJobView() + job.load_dict(data, PeerQueueView(self)) + s = job.next_set(self._profile) + if s is not None: + return (job, s) + return (None, None) + + def acquire(self) -> bool: + if not self.is_ready(): + return False + (job, s) = self._peek() + if job is None or s is None: + return False + + self._set_completion(job.id, 0, self._client.CompletionType.ACQUIRE) + self.job_id = job.id + self.set_id = s.id + return True + + def release(self) -> None: + if self.job_id is not None: + self._set_completion(self.job_id, 0, self._client.CompletionType.RELEASE) + self.job_id = None + self.set_id = None + + def decrement(self) -> None: + if self.job_id is not None: + next_set = self.get_set().decrement(self._profile) + if next_set: + self._logger.debug("Still has work, going for next set") + self.set_id = next_set.id + return True + else: + self._logger.debug("No more work; releasing") + self.release() + return False + else: + raise Exception("Cannot decrement; no job acquired") + + def _active_set(self): + assigned = self.get_set() + if assigned is not None: + return assigned.id + return None + + def as_dict(self) -> dict: + jobs = [] + peers = {} + if self.is_ready(): + jobs = self._get_jobs() + peers = self._get_peers() + for j in jobs: + j["queue"] = self.ns + + return dataclasses.asdict( + QueueData( + name=self.ns, + addr="p2p", + strategy=self.strategy.name, + jobs=jobs, + peers=peers, + active_set=self._active_set(), + ) + ) + + def reset_jobs(self, job_ids) -> dict: + for jid in job_ids: + j = self._get_job(jid) + if j is None: + continue + + j["remaining"] = j["count"] + for s in j.get("sets", []): + s["remaining"] = s["count"] + s["completed"] = 0 + self.set_job(jid, j) + + def remove_jobs(self, job_ids) -> dict: + n = 0 + for jid in job_ids: + # No need to check for job existence; peers will just ignore + # it if they don't have a matching record. + self._set_completion( + jid, int(time.time()), self._client.CompletionType.TOMBSTONE + ) + n += 1 + return dict(jobs_deleted=n) + + # --------- end AbstractQueue ------ + + # --------- AbstractEditableQueue implementation ------ + + def get_job_view(self, job_id): + j = self._get_job(job_id) + if j is not None: + v = PeerJobView() + v.load_dict(j, PeerQueueView(self)) + return v + + def import_job_from_view(self, j, jid=None): + err = self._validate_job(j) + if err is not None: + raise ValidationError(err) + filepaths = dict([(s.path, self._path_on_disk(s.path, s.sd)) for s in j.sets]) + manifest = j.as_dict() + if manifest.get("created") is None: + manifest["created"] = int(time.time()) + # Note: post mutates manifest by stripping fields + manifest["hash"] = self._fileshare.post(manifest, filepaths) + + if jid is None: + jid = self._gen_uuid() + manifest["id"] = jid + + # Propagate peer if importing from a PeerJobView + # But don't fail with AttributeError if it's just a JobView + manifest["peer_"] = getattr(j, "peer", None) + print("Dict manifest", manifest) + self.set_job(jid, manifest) + return jid + + def mv_job(self, job_id, after_id, before_id): + # See `.storage.rank` for details on rational-based + # ordering via Stern-Brocot tree + # TODO memoize fetch somehow + manifest = self._get_job(job_id) + bj = self._get_job(before_id) + aj = self._get_job(after_id) + + br = Rational(bj["rn"], bj["rd"]) if bj is not None else Rational(1, 0) + ar = Rational(aj["rn"], aj["rd"]) if aj is not None else Rational(0, 1) + # Ordering is , , + # So "after" comes before "before". Yes, it's weird. + newRank = rational_intermediate(ar, br) + # self._logger.debug(f"rational_intermediate({ar}, {br}) => {newRank}") + + manifest["rn"] = newRank.n + manifest["rd"] = newRank.d + manifest["rank"] = float(newRank.n) / float(newRank.d) + self.set_job(job_id, manifest) + + def _path_exists(self, fullpath): + return Path(fullpath).exists() + + def _validate_job(self, j: JobView) -> str: + peer_profiles = set() + for peer in self._get_peers(): + for printer in peer.get("clients", []): + try: + p = json.loads(printer.get("profile")) + peer_profiles.add(p.get("name", "UNKNOWN")) + except json.decoder.JSONDecodeError: + self._logger.warning( + "Failed to decode profile for peer", peer.get("name") + ) + peer_profiles.add( + self._profile.get("name", "UNKNOWN") + ) # We're always in the queue + for s in j.sets: + sprof = set(s.profiles()) + # All sets in the job *must* have an assigned profile + if len(sprof) == 0: + return f"validation for job {j.name} failed - set {s.path} has no assigned profile" + + # At least one printer in the queue must have a compatible proile + if len(peer_profiles.intersection(sprof)) == 0: + return f"validation for job {j.name} failed - no match for set {s.path} with profiles {sprof} (connected printer profiles: {peer_profiles})" + + # All set paths must resolve to actual files + fullpath = self._path_on_disk(s.path, s.sd) + if fullpath is None or not self._path_exists(fullpath): + return f"validation for job {j.name} failed - file not found at {s.path} (is it stored on disk and not SD?)" + + def _gen_uuid(self) -> str: + for i in range(100): + result = str(uuid.uuid4()) + if self._get_job(result) is None: + return result + raise Exception("UUID generation failed - too many ID collisions") + + def edit_job(self, job_id, data) -> str: + # For lan queues, "editing" a job is basically resubmission of the whole thing. + # This is because the backing .gjob format is a single file containing the full manifest. + + j = self.get_job_view(job_id) + for (k, v) in data.items(): + if k in ("id", "peer_", "queue"): + continue + if k == "sets": + # Set data must be translated into views + j.sets = [] + for i, s in enumerate(v): + sv = PeerSetView() + sv.load_dict(s, j, i) + j.sets.append(sv) + + else: + setattr(j, k, v) + + # We must resolve the set paths so we have them locally, as editing can + # also occur on servers other than the one that submitted the job. + for s in j.sets: + s.path = s.resolve() + + # We are also now the source of this job + j.peer = self.server_id + + # Exchange the old job for the new job (reuse job ID) + jid = self.import_job_from_view(j, j.id) + return self._get_job(jid) diff --git a/continuousprint/queues/net_test.py b/continuousprint/queues/net_test.py new file mode 100644 index 00000000..5592e8ce --- /dev/null +++ b/continuousprint/queues/net_test.py @@ -0,0 +1,136 @@ +import unittest +import json +import logging +import tempfile +from datetime import datetime +from unittest.mock import MagicMock +from .base import Strategy +from .base_test import ( + AbstractQueueTests, + EditableQueueTests, + testJob as makeAbstractTestJob, + DummyQueue, +) +from .net import NetworkQueue, ValidationError +from ..storage.peer import PeerJobView, PeerSetView, PeerQueueView +from peerprint.server_test import MockServer + +# logging.basicConfig(level=logging.DEBUG) + + +class NetworkQueueTest(unittest.TestCase): + def setUp(self): + self.ucb = MagicMock() + self.fs = MagicMock() + self.fs.fetch.return_value = "asdf.gcode" + self.fs.post.return_value = "CIDHASH" + self.cli = MockServer( + [ + # To pass validation, need a peer that has a printer with the right profile + dict( + name="testpeer", + printers=[ + dict( + name="testprinter", profile=json.dumps(dict(name="profile")) + ), + ], + ) + ] + ) + pp = MagicMock() + pp.get_plugin.return_value = MagicMock( + client=self.cli, + fileshare=self.fs, + printer_name="test_printer", + ) + self.q = NetworkQueue( + "ns", + pp, + logging.getLogger(), + Strategy.IN_ORDER, + self.ucb, + dict(name="profile"), + lambda path, sd: path, + ) + self.q._server_id = "testid" + + self.q._path_exists = lambda p: True # Override path check for validation + + +class TestAbstractImpl(AbstractQueueTests, NetworkQueueTest): + def setUp(self): + NetworkQueueTest.setUp(self) + self.jid = self.q.import_job_from_view(makeAbstractTestJob(0, cls=PeerJobView)) + self.j = self.q.get_job_view(self.jid) + + +class TestEditableImpl(EditableQueueTests, NetworkQueueTest): + def setUp(self): + NetworkQueueTest.setUp(self) + self.jids = [ + self.q.import_job_from_view(makeAbstractTestJob(i, cls=PeerJobView)) + for i in range(EditableQueueTests.NUM_TEST_JOBS) + ] + self.cls = PeerJobView + self.qcls = DummyQueue + + def test_mv_job_no_before_id(self): + self.skipTest("TODO") + + +class TestNetworkQueue(NetworkQueueTest): + def test_get_gjob_dirpath(self): + self.fs.fetch.return_value = "/dir/" + self.assertEqual(self.q.get_gjob_dirpath("hash"), "/dir/") + self.fs.fetch.assert_called_with("hash", unpack=True) + + def _jbase(self, path="a.gcode"): + j = PeerJobView() + j.id = 1 + j.name = "j1" + j.queue = PeerQueueView(MagicMock(ns="lantest")) + s = PeerSetView() + s.path = path + s.id = 2 + s.sd = False + s.count = 1 + s.remaining = 1 + s.completed = 0 + s.profile_keys = "" + s.rank = 1 + s.material_keys = "" + j.sets = [s] + j.count = 1 + j.draft = False + j.created = 100 + j.remaining = 1 + j.acquired = False + return j + + def test_validation_file_missing(self): + j = self._jbase() + j.sets[0].profile_keys = "brofile,profile" + self.q._path_exists = lambda p: False # Override path check for validation + with self.assertRaisesRegex(ValidationError, "file not found"): + self.q.import_job_from_view(j) + self.fs.post.assert_not_called() + + def test_validation_no_profile(self): + with self.assertRaisesRegex(ValidationError, "no assigned profile"): + self.q.import_job_from_view(self._jbase()) + self.fs.post.assert_not_called() + + def test_validation_no_match(self): + j = self._jbase() + j.sets[0].profile_keys = "brofile" + with self.assertRaisesRegex(ValidationError, "no match for set"): + self.q.import_job_from_view(j) + self.fs.post.assert_not_called() + + def test_resolved_paths_before_edit(self): + self.fs.fetch.return_value = "/resolvedir/" + jid = self.q.import_job_from_view(makeAbstractTestJob(0, cls=PeerJobView)) + self.q.edit_job(jid, dict(draft=True)) + j = self.q.get_job_view(jid) + # Paths include resolved gjob directory + self.assertEqual(j.sets[0].path, "/resolvedir/set0.gcode") diff --git a/continuousprint/script_runner.py b/continuousprint/script_runner.py index 15e9a032..cf354965 100644 --- a/continuousprint/script_runner.py +++ b/continuousprint/script_runner.py @@ -6,7 +6,7 @@ from octoprint.printer import InvalidFileLocation, InvalidFileType from octoprint.server import current_user from octoprint.slicing.exceptions import SlicingException -from .storage.lan import LANResolveError +from .storage.peer import ResolveError from .storage.database import STLResolveError from .data import TEMP_FILE_DIR, CustomEvents, Keys from .storage.queries import getAutomationForEvent @@ -107,9 +107,9 @@ def set_active(self, item, cb): # or other format where unpacking or transformation is needed to get to .gcode. try: path = item.resolve() - except LANResolveError as e: + except ResolveError as e: self._logger.error(e) - self._msg(f"Could not resolve LAN print path for {path}", type="error") + self._msg(f"Could not resolve print path for {path}", type="error") return False except STLResolveError as e: self._logger.warning(e) diff --git a/continuousprint/script_runner_test.py b/continuousprint/script_runner_test.py index 48841f0b..f51aa3ca 100644 --- a/continuousprint/script_runner_test.py +++ b/continuousprint/script_runner_test.py @@ -11,7 +11,7 @@ from .storage.database_test import AutomationDBTest from .storage import queries from .storage.database import SetView -from .storage.lan import LANResolveError +from .storage.peer import ResolveError import logging # logging.basicConfig(level=logging.DEBUG) @@ -141,7 +141,7 @@ def test_set_active_sd(self): def test_set_active_lan_resolve_error(self): li = MagicMock(LI()) - li.resolve.side_effect = LANResolveError("testing error") + li.resolve.side_effect = ResolveError("testing error") self.assertEqual(self.s.set_active(li, MagicMock()), False) self.s._printer.select_file.assert_not_called() diff --git a/continuousprint/static/css/continuousprint.css b/continuousprint/static/css/continuousprint.css deleted file mode 100644 index 80e60b14..00000000 --- a/continuousprint/static/css/continuousprint.css +++ /dev/null @@ -1,511 +0,0 @@ -:root { - /* Small padding roughly matches the spacing between Print/Pause/Cancel buttons in the State panel */ - --cpq-pad: 6px; - --cpq-pad2: 20px; -} - -@font-face { - font-family: Cabin Regular; - src: url(../ttf/Cabin/Cabin-Regular.ttf); -} - -#files .cpq-gjob div[title="Load"] { - display: none !important; -} -#files .cpq-gjob div[title="Load and Print"] { - display: none !important; -} -#files .cpq-gjob.fa-archive { - margin-right: 4px; -} - -#state span.ERROR { - background-color: rgba(255, 0, 0, 0.6); - border-radius: var(--cpq-pad); - padding: var(--cpq-pad); -} -#state span.NEEDS_ACTION { - animation: pulse 2.5s infinite; - border-radius: var(--cpq-pad); - padding: var(--cpq-pad); -} - -@keyframes pulse { - 0% { - background-color: rgba(255, 150, 0, 0.3); - } - - 50% { - background-color: rgba(255, 150, 0, 0.8); - } - - 100% { - background-color: rgba(255, 150, 0, 0.3); - } -} - -#tab_plugin_continuousprint .cpq-leftpad { - margin-left: var(--cpq-pad); -} -#continuousprint_tab_queues > div { - margin-bottom: 50px; -} -#tab_plugin_continuousprint .highlight { - border: 3px #77a4ff solid !important; - min-height: 100px; -} -.themeify.discorded #tab_plugin_continuousprint .highlight, -.themeify.cyborg #tab_plugin_continuousprint .highlight, -.themeify.discoranged #tab_plugin_continuousprint .highlight, -.themeify.nighttime #tab_plugin_continuousprint .highlight { - border: 3px #656b76 solid !important; -} - -#tab_plugin_continuousprint .multiselect { - flex-grow: 0; - display: flex; - flex-direction: row; - justify-content: right; - margin-bottom: -31px; - margin-left: 4px; -} -#tab_plugin_continuousprint .multiselect .btn-group { - /* Lay on top of table headers */ - z-index: 1; -} -#tab_plugin_continuousprint .cp-header { - display: flex; - flex-direction: row; - align-items: center; - font-weight: bold; -} -#tab_plugin_continuousprint .job-header { - background-color: #eee; -} -#tab_plugin_continuousprint .profile-select { - /* Match the style of Bootstrap labels */ - height: 20px; - width: 60px; - margin-bottom: 0px; -} -#tab_plugin_continuousprint .job.acquired .job-name { - font-weight: bold; -} -#tab_plugin_continuousprint .fa-grip-vertical, #settings_plugin_continuousprint .fa-grip-vertical { - opacity: 0.0; - margin-left: 1px !important; -} -#tab_plugin_continuousprint *:hover > .fa-grip-vertical, #settings_plugin_continuousprint *:hover > .fa-grip-vertical { - opacity: 0.5; - cursor: grab; -} -.themeify.discorded #tab_plugin_continuousprint .job-header, -.themeify.cyborg #tab_plugin_continuousprint .job-header, -.themeify.discoranged #tab_plugin_continuousprint .job-header, -.themeify.nighttime #tab_plugin_continuousprint .job-header { - background-color: #222 !important; -} -#tab_plugin_continuousprint .queue-list { - border-radius: var(--cpq-pad); - min-height: 70px; - border-top: 1px #e5e5e5 solid; - border-bottom: 1px #e5e5e5 solid; - padding: var(--cpq-pad) 0px; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; -} -#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, #settings_continuousprint_queues .queue-row-container { - width:100%; - display: flex; - flex-wrap: nowrap; - flex-direction: row; - justify-content: right; - align-items: center; -} -#tab_plugin_continuousprint .job { - position: relative; -} -#tab_plugin_continuousprint .job.shiftsel { - border-left: 3px solid #CCC !important; -} -#tab_plugin_continuousprint .job.draft .job-gutter { - display: flex; - flex-direction: row; - justify-content: flex-end; - padding-right: var(--cpq-pad); - padding-top: var(--cpq-pad); -} -#tab_plugin_continuousprint .job.draft .job-gutter .text-error { - width: 100%; - line-height: 1; - text-align: right; - padding-right: var(--cpq-pad2); -} -#tab_plugin_continuousprint .job .floatedit { - position: absolute; - top: var(--cpq-pad2); - right: var(--cpq-pad2); - opacity: 0.0; - z-index: 1000; -} -#tab_plugin_continuousprint .job:hover .floatedit { - opacity: 0.5; -} -#tab_plugin_continuousprint .job:hover .floatedit:hover { - opacity: 1.0; -} -#tab_plugin_continuousprint .job .accordion-group { - border-right: 0px; -} -#tab_plugin_continuousprint .job > div.accordion-body > div.accordion-inner { - padding-right: 0px; - border-right: 0px; -} -#tab_plugin_continuousprint .queue-row-container > *, #settings_continuousprint_queues .queue-row-container > * { - margin-left: var(--cpq-pad); - margin-bottom: 0; - line-height: 1.6; -} -#tab_plugin_continuousprint .total, -#tab_plugin_continuousprint .completed, -#tab_plugin_continuousprint .remaining, -#tab_plugin_continuousprint .count { - width: 67px; - text-align: center; - margin-left: var(--cpq-pad); - padding: 2px; - border: 1px transparent solid; -} -#tab_plugin_continuousprint .cp-queue .loading { - opacity: 0.3; - cursor: default !important; -} -#tab_plugin_continuousprint .edit-header { - display: flex; - justify-content: space-between; - font-weight: bold; - opacity: 0.5; -} -#tab_plugin_continuousprint .sets.draft { - border-bottom: 2px #ccc solid; -} -#tab_plugin_continuousprint .job-stats { - display: flex; - justify-content: space-between; -} -#tab_plugin_continuousprint .has_title { - cursor: help; -} -#tab_plugin_continuousprint .has_title > sup { - opacity: 0.5; -} -#tab_plugin_continuousprint .progresstext { - width: 100%; - position: absolute; - text-align:center; -} -#tab_plugin_continuousprint .set_warning { - flex: 1; - text-align: center; - margin-right: 17px; -} -#tab_plugin_continuousprint .draft .set_warning { - flex: 0; -} -#tab_plugin_continuousprint .sets .progress { - margin-left: 17px; -} -#tab_plugin_continuousprint .progress { - position: relative; - flex: 1; - display: flex; /* allow for stacking progresses in jobs */ - margin-right: var(--cpq-pad2); -} -#tab_plugin_continuousprint .accordion-heading-button { - padding: var(--cpq-pad) var(--cpq-pad); -} -#tab_plugin_continuousprint .accordion-heading-button input { - margin-top: -2px; -} -#tab_plugin_continuousprint .accordion-body.collapse { - /* Transitions copied from div#files.accordion-body.collapse */ - max-height: 0; - overflow-y: auto; - -webkit-transition: max-height .35s ease; - -moz-transition: max-height .35s ease; - -o-transition: max-height .35s ease; - transition: max-height .35s ease; -} -#tab_plugin_continuousprint .accordion-body.collapse.in { - max-height: 500px; -} -.cpq_logo { - height: 40px; - float: left; -} -#settings_plugin_continuousprint .cpq_title { - font-family: "Cabin Regular"; - font-size: 2em; - display: flex; - align-items: center; -} -#settings_plugin_continuousprint .cpq_title > img { - width: 71px; -} -#settings_plugin_continuousprint .header-row { - width:100%; - display: flex; - flex-wrap: nowrap; - flex-direction: row; - align-items: center; - min-height: 30px; -} -#settings_plugin_continuousprint .subheader { - opacity: 0.7; - font-style: italic; -} -#settings_plugin_continuousprint .header-row > * { - margin-left: var(--cpq-pad); -} -#settings_plugin_continuousprint .events > h4 { - margin-top: var(--cpq-pad2); - padding-top: var(--cpq-pad2); -} -#settings_plugin_continuousprint .simulation-output { - display:flex; - flex-direction: row; - justify-content: center; -} -#settings_plugin_continuousprint .simulation-output > div { - flex: 1; - padding: var(--cpq-pad); -} -#settings_plugin_continuousprint .simulation-output h5 { - margin: 0; - text-align: center; - opacity: 0.5; -} -#settings_plugin_continuousprint .output-block { - border: 1px #e5e5e5 solid; /* copy from .accordion-group */ - border-radius: var(--cpq-pad); - width: 100%; - min-height: 150px; - max-height: 400px; - overflow-y: scroll; - padding: 0; - margin: 0; -} -#tab_plugin_continuousprint .queue-header, #settings_continuousprint_queues .queue-header { - display: flex; - justify-content: space-between; - font-weight: bold; - opacity: 0.5; - margin-left: 55px; - margin-right: var(--cpq-pad2); - align-items: flex-end; -} -#tab_plugin_continuousprint .queue-header > div, #settings_continuousprint_queues .queue-header > div { - text-align: center; - margin-left: var(--cpq-pad); -} -#tab_plugin_continuousprint #queue_sets.empty { - border: 1px #e5e5e5 solid; /* copy from .accordion-group */ - padding: var(--cpq-pad); - border-radius: var(--cpq-pad); -} -#tab_plugin_continuousprint .accordion-body.collapse { - /* Transitions copied from div#files.accordion-body.collapse */ - max-height: 0; - overflow-y: auto; - -webkit-transition: max-height .35s ease; - -moz-transition: max-height .35s ease; - -o-transition: max-height .35s ease; - transition: max-height .35s ease; -} -#tab_plugin_continuousprint .accordion-body.collapse.in { - max-height: 500px; -} -#tab_plugin_continuousprint .label { - border: 1px gray solid; -} -#tab_plugin_continuousprint ::placeholder { - font-weight: italic; - opacity: 0.7; -} -#tab_plugin_continuousprint .hint, -#settings_plugin_continuousprint .hint { - font-weight: italic; - font-size: 85%; - opacity: 0.7; -} -#tab_plugin_continuousprint #queue_sets.empty.draft::before { - content: "Hint: Click the + button on a file, or drag in a set from another job in edit mode."; - font-weight: italic; - font-size: 85%; - opacity: 0.7; -} -#tab_plugin_continuousprint .queue-set-list { - overflow-y: auto; - display: flex; - flex-direction: row; - flex-wrap: wrap; - justify-content: space-between; -} -#tab_plugin_continuousprint .queue-set-list h4 { - border-bottom: 1px #e5e5e5 solid; -} -#tab_plugin_continuousprint .queue-set-list .full { - width: 100%; -} -#tab_plugin_continuousprint .queue-set-list .gutter { - border-top: 1px #e5e5e5 solid; - padding-top: var(--cpq-pad); - display: flex; - justify-content: space-between; -} - -#tab_plugin_continuousprint .count-box{ - min-width:30px; - max-height:15px; - -moz-appearance: textfield; - text-align: center; - border: 1px #ccc solid !important; -} -#tab_plugin_continuousprint .count-box::-webkit-outer-spin-button, -#tab_plugin_continuousprint .count-box::-webkit-inner-spin-button { - /* chrome, safari, edge, opera: hide up/down buttons on count box input */ - -webkit-appearance: none; - margin: 0; -} - - -.themeify.discorded #tab_plugin_continuousprint input, -.themeify.cyborg #tab_plugin_continuousprint input, -.themeify.discoranged #tab_plugin_continuousprint input, -.themeify.nighttime #tab_plugin_continuousprint input { - background-color: #656b76 !important; - color: white !important; -} - -#tab_plugin_continuousprint .file-name, #tab_plugin_continuousprint .job-name { - flex: 1; - flex-shrink: 10; - overflow-x: hidden; - white-space: nowrap; - max-width: 96%; - text-overflow: ellipsis; - border: 0; -} -.bold{ - font-weight: bold; -} - -/* 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 { - animation: none; - background-image: none; -} -#tab_plugin_continuousprint .progress.active .bar.active { - -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; - 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); -} - -/* Below stylesheets are for the "history" page */ - -#tab_plugin_continuousprint .header, #tab_plugin_continuousprint .entries > div { - display: flex; - justify-content: space-between; - padding: 0px 44px 0px 32px; - flex-direction: row; -} - -#tab_plugin_continuousprint .header { - font-weight: bold; - opacity: 0.5; - border-bottom: 1px gray solid; -} - -#tab_plugin_continuousprint .header > *, #tab_plugin_continuousprint .entries > .entry > * { - flex: 1; - text-align: center; -} - -#tab_plugin_continuousprint .timelapse_thumbnail { - position: relative; - display: inline-block; -} - -#tab_plugin_continuousprint .timelapse_thumbnail > img { - display: none; - cursor: pointer; - position: absolute; - z-index: 1000; - top: 0; - max-width: 100px; - left: 18px; - border: 1px black solid; - padding: 5px; - background-color: white; -} -#tab_plugin_continuousprint .timelapse_thumbnail > i { - padding: 2px; /* So hover is maintained when mousing over to image */ -} -#tab_plugin_continuousprint .timelapse_thumbnail:hover > img { - display: block; -} - -#tab_plugin_continuousprint .separator { - display: flex; - justify-content: space-between; - align-items: center; -} -#tab_plugin_continuousprint .separator .line { - content: ''; - flex: 1; - border-bottom: 1px solid #000; - margin-left: 5px; - margin-right: 5px; -} diff --git a/continuousprint/static/css/continuousprint_history.less.css b/continuousprint/static/css/continuousprint_history.less.css new file mode 100644 index 00000000..a15eaec8 --- /dev/null +++ b/continuousprint/static/css/continuousprint_history.less.css @@ -0,0 +1,52 @@ +#tab_plugin_continuousprint .header, +#tab_plugin_continuousprint .entries > div { + display: flex; + justify-content: space-between; + padding: 0px 44px 0px 32px; + flex-direction: row; +} +#tab_plugin_continuousprint .header { + font-weight: bold; + opacity: 0.5; + border-bottom: 1px gray solid; +} +#tab_plugin_continuousprint .header > *, +#tab_plugin_continuousprint .entries > .entry > * { + flex: 1; + text-align: center; +} +#tab_plugin_continuousprint .timelapse_thumbnail { + position: relative; + display: inline-block; +} +#tab_plugin_continuousprint .timelapse_thumbnail > img { + display: none; + cursor: pointer; + position: absolute; + z-index: 1000; + top: 0; + max-width: 100px; + left: 18px; + border: 1px black solid; + padding: 5px; + background-color: white; +} +#tab_plugin_continuousprint .timelapse_thumbnail > i { + padding: 2px; + /* So hover is maintained when mousing over to image */ +} +#tab_plugin_continuousprint .timelapse_thumbnail:hover > img { + display: block; +} +#tab_plugin_continuousprint .separator { + display: flex; + justify-content: space-between; + align-items: center; +} +#tab_plugin_continuousprint .separator .line { + content: ''; + flex: 1; + border-bottom: 1px solid #000; + margin-left: 5px; + margin-right: 5px; +} diff --git a/continuousprint/static/css/continuousprint_settings.less.css b/continuousprint/static/css/continuousprint_settings.less.css new file mode 100644 index 00000000..4bb14ebd --- /dev/null +++ b/continuousprint/static/css/continuousprint_settings.less.css @@ -0,0 +1,96 @@ +#settings_plugin_continuousprint .cpq_title { + font-family: "Cabin Regular"; + font-size: 2em; + display: flex; + align-items: center; +} +#settings_plugin_continuousprint .cpq_title > img { + width: 71px; +} +#settings_plugin_continuousprint .queue-header :first-child { + text-align: left; + width: 300px; +} +#settings_plugin_continuousprint .queue-row-container { + width: 100%; + display: flex; + flex-wrap: nowrap; + flex-direction: row; + justify-content: right; + align-items: center; +} +#settings_plugin_continuousprint .queue-row-container > * { + margin-left: var(--cpq-pad2); + margin-bottom: 0; + line-height: 1.6; +} +#settings_plugin_continuousprint .queue-row-container > .row-name { + width: 300px; +} +#settings_plugin_continuousprint .queue-row-container select { + margin-bottom: 0px; +} +#settings_plugin_continuousprint .queue-row-container > div { + flex: 1; + text-align: center; +} +#settings_plugin_continuousprint .lobby { + max-height: 300px; + overflow-y: scroll; +} +#settings_plugin_continuousprint .lobby-network-details { + display: flex; + flex-direction: row; +} +#settings_plugin_continuousprint .lobby-network-details > div { + flex: 1; + margin: var(--cpq-pad); +} +#settings_plugin_continuousprint .lobby-network-details > div:first-child { + border-right: 1px #e5e5e5 solid; + /* copy from .accordion-group */ +} +#settings_plugin_continuousprint .header-row { + width: 100%; + display: flex; + flex-wrap: nowrap; + flex-direction: row; + align-items: center; + min-height: 30px; +} +#settings_plugin_continuousprint .header-row > * { + margin-left: var(--cpq-pad); +} +#settings_plugin_continuousprint .subheader { + opacity: 0.7; + font-style: italic; +} +#settings_plugin_continuousprint .events > h4 { + margin-top: var(--cpq-pad2); + padding-top: var(--cpq-pad2); +} +#settings_plugin_continuousprint .simulation-output { + display: flex; + flex-direction: row; + justify-content: center; +} +#settings_plugin_continuousprint .simulation-output > div { + flex: 1; + padding: var(--cpq-pad); +} +#settings_plugin_continuousprint .simulation-output h5 { + margin: 0; + text-align: center; + opacity: 0.5; +} +#settings_plugin_continuousprint .output-block { + border: 1px #e5e5e5 solid; + /* copy from .accordion-group */ + border-radius: var(--cpq-pad); + width: 100%; + min-height: 150px; + max-height: 400px; + overflow-y: scroll; + padding: 0; + margin: 0; +} diff --git a/continuousprint/static/css/continuousprint_shared.less.css b/continuousprint/static/css/continuousprint_shared.less.css new file mode 100644 index 00000000..dbb4e949 --- /dev/null +++ b/continuousprint/static/css/continuousprint_shared.less.css @@ -0,0 +1,91 @@ +:root { + /* Small padding roughly matches the spacing between Print/Pause/Cancel buttons in the State panel */ + --cpq-pad: 6px; + --cpq-pad2: 20px; +} +@font-face { + font-family: Cabin Regular; + src: url(../ttf/Cabin/Cabin-Regular.ttf); +} +#tab_plugin_continuousprint .hint, +#settings_plugin_continuousprint .hint { + font-weight: italic; + font-size: 85%; + opacity: 0.7; +} +#tab_plugin_continuousprint .bold, +#settings_plugin_continuousprint .bold { + font-weight: bold; +} +#tab_plugin_continuousprint .label, +#settings_plugin_continuousprint .label { + border: 1px gray solid; +} +#tab_plugin_continuousprint .fa-grip-vertical, +#settings_plugin_continuousprint .fa-grip-vertical { + opacity: 0; + margin-left: 1px !important; +} +#tab_plugin_continuousprint *:hover > .fa-grip-vertical, +#settings_plugin_continuousprint *:hover > .fa-grip-vertical { + opacity: 0.5; + cursor: grab; +} +#tab_plugin_continuousprint .accordion-heading-button, +#settings_plugin_continuousprint .accordion-heading-button { + padding: var(--cpq-pad) var(--cpq-pad); +} +#tab_plugin_continuousprint .accordion-heading-button input, +#settings_plugin_continuousprint .accordion-heading-button input { + margin-top: -2px; +} +#tab_plugin_continuousprint .accordion-body.collapse, +#settings_plugin_continuousprint .accordion-body.collapse { + /* Transitions copied from div#files.accordion-body.collapse */ + max-height: 0; + overflow-y: auto; + -webkit-transition: max-height 0.35s ease; + -moz-transition: max-height 0.35s ease; + -o-transition: max-height 0.35s ease; + transition: max-height 0.35s ease; +} +#tab_plugin_continuousprint .accordion-body.collapse.in, +#settings_plugin_continuousprint .accordion-body.collapse.in { + max-height: 500px; +} +#tab_plugin_continuousprint .action-gutter, +#settings_plugin_continuousprint .action-gutter { + display: flex; + flex-direction: row; + justify-content: flex-end; + padding-right: var(--cpq-pad); + padding-top: var(--cpq-pad); +} +#tab_plugin_continuousprint .action-gutter .text-error, +#settings_plugin_continuousprint .action-gutter .text-error { + width: 100%; + line-height: 1; + text-align: right; + padding-right: var(--cpq-pad2); +} +#tab_plugin_continuousprint .queue-header, +#settings_plugin_continuousprint .queue-header { + display: flex; + justify-content: space-between; + font-weight: bold; + opacity: 0.5; + align-items: flex-end; +} +#tab_plugin_continuousprint .queue-header > div, +#settings_plugin_continuousprint .queue-header > div { + text-align: center; + margin-left: var(--cpq-pad); +} +#tab_plugin_continuousprint .has_title, +#settings_plugin_continuousprint .has_title { + cursor: help; +} +#tab_plugin_continuousprint .has_title > sup, +#settings_plugin_continuousprint .has_title > sup { + opacity: 0.5; +} diff --git a/continuousprint/static/css/continuousprint_tab.less.css b/continuousprint/static/css/continuousprint_tab.less.css new file mode 100644 index 00000000..ef298feb --- /dev/null +++ b/continuousprint/static/css/continuousprint_tab.less.css @@ -0,0 +1,244 @@ +#files .cpq-gjob div[title="Load"] { + display: none !important; +} +#files .cpq-gjob div[title="Load and Print"] { + display: none !important; +} +#files .cpq-gjob .fa-archive { + margin-right: 4px; +} +#state span .ERROR { + background-color: rgba(255, 0, 0, 0.6); + border-radius: var(--cpq-pad); + padding: var(--cpq-pad); +} +#state span .NEEDS_ACTION { + animation: pulse 2.5s infinite; + border-radius: var(--cpq-pad); + padding: var(--cpq-pad); +} +#tab_plugin_continuousprint .cpq-leftpad { + margin-left: var(--cpq-pad); +} +#tab_plugin_continuousprint #continuousprint_tab_queues > div { + margin-bottom: 50px; +} +#tab_plugin_continuousprint .highlight { + border: 3px #77a4ff solid !important; + min-height: 100px; +} +#tab_plugin_continuousprint .multiselect { + flex-grow: 0; + display: flex; + flex-direction: row; + justify-content: right; + margin-bottom: -31px; + margin-left: 4px; +} +#tab_plugin_continuousprint .multiselect .btn-group { + /* Lay on top of table headers */ + z-index: 1; +} +#tab_plugin_continuousprint .cp-header { + display: flex; + flex-direction: row; + align-items: center; + font-weight: bold; +} +#tab_plugin_continuousprint .job-header { + background-color: #eee; +} +#tab_plugin_continuousprint .profile-select { + /* Match the style of Bootstrap labels */ + height: 20px; + width: 60px; + margin-bottom: 0px; +} +#tab_plugin_continuousprint .fa-grip-vertical { + opacity: 0; + margin-left: 1px !important; +} +#tab_plugin_continuousprint *:hover > .fa-grip-vertical { + opacity: 0.5; + cursor: grab; +} +#tab_plugin_continuousprint .queue-list { + border-radius: var(--cpq-pad); + min-height: 70px; + border-top: 1px #e5e5e5 solid; + border-bottom: 1px #e5e5e5 solid; + padding: var(--cpq-pad) 0px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} +#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; + flex-direction: row; + justify-content: right; + align-items: center; +} +#tab_plugin_continuousprint .job { + position: relative; +} +#tab_plugin_continuousprint .job.acquired .job-name { + font-weight: bold; +} +#tab_plugin_continuousprint .job.acquired.local div.job-header { + background-color: #DDF; +} +#tab_plugin_continuousprint .job .shiftsel { + border-left: 3px solid #CCC !important; +} +#tab_plugin_continuousprint .job .floatedit { + position: absolute; + top: var(--cpq-pad2); + right: var(--cpq-pad2); + opacity: 0; + z-index: 1000; +} +#tab_plugin_continuousprint .job .accordion-group { + border-right: 0px; +} +#tab_plugin_continuousprint .job > div.accordion-body > div.accordion-inner { + padding-right: 0px; + border-right: 0px; +} +#tab_plugin_continuousprint .job:hover .floatedit { + opacity: 0.5; +} +#tab_plugin_continuousprint .job:hover .floatedit:hover .floatedit:hover { + opacity: 1; +} +#tab_plugin_continuousprint .queue-row-container > * { + margin-left: var(--cpq-pad); + margin-bottom: 0; + line-height: 1.6; +} +#tab_plugin_continuousprint .total, +#tab_plugin_continuousprint .completed, +#tab_plugin_continuousprint .remaining, +#tab_plugin_continuousprint .count { + width: 67px; + text-align: center; + margin-left: var(--cpq-pad); + padding: 2px; + border: 1px transparent solid; +} +#tab_plugin_continuousprint .cp-queue .loading { + opacity: 0.3; + cursor: default !important; +} +#tab_plugin_continuousprint .edit-header { + display: flex; + justify-content: space-between; + font-weight: bold; + opacity: 0.5; +} +#tab_plugin_continuousprint .sets.draft { + border-bottom: 2px #ccc solid; +} +#tab_plugin_continuousprint .sets .progress { + margin-left: 17px; +} +#tab_plugin_continuousprint .job-stats { + display: flex; + justify-content: space-between; +} +#tab_plugin_continuousprint .progresstext { + width: 100%; + position: absolute; + text-align: center; +} +#tab_plugin_continuousprint .set_warning { + flex: 1; + text-align: center; + margin-right: 17px; +} +#tab_plugin_continuousprint .draft .set_warning { + flex: 0; +} +#tab_plugin_continuousprint .progress { + position: relative; + flex: 1; + display: flex; + /* allow for stacking progresses in jobs */ + margin-right: var(--cpq-pad2); +} +#tab_plugin_continuousprint .cpq_logo { + height: 40px; + float: left; +} +#tab_plugin_continuousprint .queue-header { + margin-left: 55px; + margin-right: var(--cpq-pad2); +} +#tab_plugin_continuousprint #queue_sets.empty { + border: 1px #e5e5e5 solid; + /* copy from .accordion-group */ + padding: var(--cpq-pad); + border-radius: var(--cpq-pad); +} +#tab_plugin_continuousprint #queue_sets.empty.draft::before { + content: "Hint: Click the + button on a file, or drag in a set from another job in edit mode."; + font-weight: italic; + font-size: 85%; + opacity: 0.7; +} +#tab_plugin_continuousprint ::placeholder { + font-weight: italic; + opacity: 0.7; +} +#tab_plugin_continuousprint .queue-set-list { + overflow-y: auto; + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: space-between; +} +#tab_plugin_continuousprint .queue-set-list h4 { + border-bottom: 1px #e5e5e5 solid; +} +#tab_plugin_continuousprint .queue-set-list .full { + width: 100%; +} +#tab_plugin_continuousprint .queue-set-list .gutter { + border-top: 1px #e5e5e5 solid; + padding-top: var(--cpq-pad); + display: flex; + justify-content: space-between; +} +#tab_plugin_continuousprint .count-box { + min-width: 30px; + max-height: 15px; + -moz-appearance: textfield; + text-align: center; + border: 1px #ccc solid !important; +} +#tab_plugin_continuousprint .count-box::-webkit-outer-spin-button, +#tab_plugin_continuousprint .count-box::-webkit-inner-spin-button { + /* chrome, safari, edge, opera: hide up/down buttons on count box input */ + -webkit-appearance: none; + margin: 0; +} +#tab_plugin_continuousprint .file-name, +#tab_plugin_continuousprint .job-name { + flex: 1; + flex-shrink: 10; + overflow-x: hidden; + white-space: nowrap; + max-width: 96%; + text-overflow: ellipsis; + border: 0; +} diff --git a/continuousprint/static/css/continuousprint_themeify_compat.less.css b/continuousprint/static/css/continuousprint_themeify_compat.less.css new file mode 100644 index 00000000..6df2fb9e --- /dev/null +++ b/continuousprint/static/css/continuousprint_themeify_compat.less.css @@ -0,0 +1,50 @@ +.themeify.discorded #tab_plugin_continuousprint .highlight, +.themeify.cyborg #tab_plugin_continuousprint .highlight, +.themeify.discoranged #tab_plugin_continuousprint .highlight, +.themeify.nighttime #tab_plugin_continuousprint .highlight { + border: 3px #656b76 solid !important; +} +.themeify.discorded #tab_plugin_continuousprint .job-header, +.themeify.cyborg #tab_plugin_continuousprint .job-header, +.themeify.discoranged #tab_plugin_continuousprint .job-header, +.themeify.nighttime #tab_plugin_continuousprint .job-header { + background-color: #222 !important; +} +.themeify.discorded #tab_plugin_continuousprint input, +.themeify.cyborg #tab_plugin_continuousprint input, +.themeify.discoranged #tab_plugin_continuousprint input, +.themeify.nighttime #tab_plugin_continuousprint input { + background-color: #656b76 !important; + color: white !important; +} +/* ==== 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 { + animation: none; + background-image: none; +} +#tab_plugin_continuousprint .progress.active .bar.active { + -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; + 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); +} diff --git a/continuousprint/static/js/continuousprint_job.js b/continuousprint/static/js/continuousprint_job.js index f713a00f..0d18a913 100644 --- a/continuousprint/static/js/continuousprint_job.js +++ b/continuousprint/static/js/continuousprint_job.js @@ -34,13 +34,8 @@ function CPJob(obj, peers, api, profile, materials, stats_dimensions=CP_STATS_DI self.id = ko.observable(obj.id); self._name = ko.observable(obj.name || ""); - if (obj.acquired_by_) { - let peer = peers[obj.acquired_by_]; - if (peer !== undefined) { - self.acquiredBy = ko.observable(`${peer.name} (${peer.profile.name})`); - } else { - self.acquiredBy = ko.observable(obj.acquired_by_) - } + if (obj.acquired_by) { + self.acquiredBy = ko.observable(obj.acquired_by); } else if (obj.acquired) { self.acquiredBy = ko.observable('local'); } else { diff --git a/continuousprint/static/js/continuousprint_queue.js b/continuousprint/static/js/continuousprint_queue.js index 7c09ac9f..2858af58 100644 --- a/continuousprint/static/js/continuousprint_queue.js +++ b/continuousprint/static/js/continuousprint_queue.js @@ -39,29 +39,35 @@ function CPQueue(data, api, files, profile, materials, stats_dimensions=CP_STATS self.details = ko.observable(""); self.fullDetails = ko.observable(""); self.showStats = ko.observable(true); - self.ready = ko.observable(data.name === 'local' || Object.keys(data.peers).length > 0); if (self.addr !== null && data.peers !== undefined) { - let pkeys = Object.keys(data.peers); - if (pkeys.length === 0) { - self.details(`(connecting...)`); - self.fullDetails('Searching for other printers with this queue\non the local network - this could take up to a minute'); - } else { - self.details(`(${pkeys.length} printer${(pkeys.length != 1) ? 's' : ''})`); - let fd = 'Connected printers:'; - for (let p of pkeys) { - let pd = data.peers[p]; - fd += `\n${pd.name} (${pd.profile.name}, ${p}): ${pd.status}`; + try { + let pstr = []; + let actives = []; + for (let peer of data.peers) { + for (let printer of peer.clients) { + let prof = JSON.parse(printer.profile); + let loc = "location unknown"; + if (printer.location.Latitude && printer.location.Longitude) { + let loc = `${printer.location.Latitude.toFixed(2)}lat, ${printer.location.Longitude.toFixed(2)}lon`; + } + pstr.push(`${printer.name} (${prof.name}, ${loc}): ${printer.status}`); + if (printer.activeUnit) { + actives.push(printer.activeUnit); + } + } } - self.fullDetails(fd); - } - - let actives = []; - for (let pd of Object.values(data.peers)) { - if (pd.active_set !== null) { - actives.push(pd.active_set); + if (pstr.length === 0) { + self.details(`(1 printer)`); + self.fullDetails('This printer is the only one on the network - go to Settings > PeerPrint for troubleshooting tips'); + } else { + self.details(`(${pstr.length} printer${(pstr.length != 1) ? 's' : ''})`); + self.fullDetails(`Connected Printers:\n${pstr.join('\n')}\nGo to Settings > PeerPrint for more details`); } + self.active_sets = ko.observableArray(actives); + } catch (err) { + console.error(err); + self.active_sets = ko.observableArray([]); } - self.active_sets = ko.observableArray(actives); } else { self.active_sets = ko.observableArray([data.active_set]); } @@ -75,7 +81,7 @@ function CPQueue(data, api, files, profile, materials, stats_dimensions=CP_STATS continue; } for (let s of j.sets()) { - if (actives.has(s.id)) { + if (actives.has(s.id.toString())) { result.push(j.id()); break; } diff --git a/continuousprint/static/js/continuousprint_queue.test.js b/continuousprint/static/js/continuousprint_queue.test.js index 262312ba..182e2f4c 100644 --- a/continuousprint/static/js/continuousprint_queue.test.js +++ b/continuousprint/static/js/continuousprint_queue.test.js @@ -48,9 +48,14 @@ function items(njobs = 1, nsets = 2) { } function init(njobs = 1) { - return new VM({name:"test", jobs:items(njobs), peers:[ - {name: "localhost", profile: {name: "profile"}, status: "IDLE"} - ]}, mockapi(), mockfiles(), mockprofile(), mockmaterials()); + return new VM({ + name:"test", + jobs:items(njobs), + peers:[{ + name: "server", + clients: [{name: "localhost", profile: JSON.stringify({name: "profile"}), status: "IDLE", location: {}}], + }], + }, mockapi(), mockfiles(), mockprofile(), mockmaterials()); } test('newEmptyJob', () => { diff --git a/continuousprint/static/js/continuousprint_settings.js b/continuousprint/static/js/continuousprint_settings.js index 87267c04..2f097727 100644 --- a/continuousprint/static/js/continuousprint_settings.js +++ b/continuousprint/static/js/continuousprint_settings.js @@ -11,430 +11,150 @@ if (typeof log === "undefined" || log === null) { CPAPI = require('./continuousprint_api'); CP_SIMULATOR_DEFAULT_SYMTABLE = function() {return {};}; CPSettingsEvent = require('./continuousprint_settings_event'); + CPSettingsAutomationViewModel = require('./continuousprint_settings_automation').CPSettingsAutomationViewModel; + CPSettingsQueuesViewModel = require('./continuousprint_settings_queues').CPSettingsQueuesViewModel; OctoPrint = undefined; } function CPSettingsViewModel(parameters, profiles=CP_PRINTER_PROFILES, default_scripts=CP_GCODE_SCRIPTS, custom_events=CP_CUSTOM_EVENTS, default_symtable=CP_SIMULATOR_DEFAULT_SYMTABLE, octoprint=OctoPrint) { - var self = this; - self.PLUGIN_ID = "octoprint.plugins.continuousprint"; - self.log = log.getLogger(self.PLUGIN_ID); - self.settings = parameters[0]; - self.files = parameters[1]; - self.api = parameters[2] || new CPAPI(); - self.loading = ko.observable(false); - self.api.init(self.loading, function(code, reason) { - console.log("API Error", code, reason); - new PNotify({ - title: `Continuous Print Settings (Error ${code})`, - text: reason, - type: 'error', - hide: true, - buttons: {closer: true, sticker: false}, - }); + var self = this; + self.PLUGIN_ID = "octoprint.plugins.continuousprint"; + self.log = log.getLogger(self.PLUGIN_ID); + self.settings = parameters[0]; + self.files = parameters[1]; + self.api = parameters[2] || new CPAPI(); + self.loading = ko.observable(false); + self.api.init(self.loading, function(code, reason) { + console.log("API Error", code, reason); + new PNotify({ + title: `Continuous Print Settings (Error ${code})`, + text: reason, + type: 'error', + hide: true, + buttons: {closer: true, sticker: false}, }); - self.local_ip = ko.observable(CP_LOCAL_IP || ''); - - // We have to use the global slicer data retriever instead of - // slicingViewModel because the latter does not make its profiles - // available without modifying the slicing modal. - self.slicers = ko.observable({}); - self.slicer = ko.observable(); - self.slicer_profile = ko.observable(); - if (octoprint !== undefined) { - octoprint.slicing.listAllSlicersAndProfiles().done(function (data) { - let result = {}; - for (let d of Object.values(data)) { - let profiles = []; - let default_profile = null; - for (let p of Object.keys(d.profiles)) { - if (d.profiles[p].default) { - default_profile = p; - continue; - } - profiles.push(p); - } - if (default_profile) { - profiles.unshift(default_profile); + }); + + self.automation = new CPSettingsAutomationViewModel(self.api, default_scripts, custom_events, default_symtable); + self.queues = new CPSettingsQueuesViewModel(self.api); + + self.local_ip = ko.observable(CP_LOCAL_IP || ''); + + // We have to use the global slicer data retriever instead of + // slicingViewModel because the latter does not make its profiles + // available without modifying the slicing modal. + self.slicers = ko.observable({}); + self.slicer = ko.observable(); + self.slicer_profile = ko.observable(); + if (octoprint !== undefined) { + octoprint.slicing.listAllSlicersAndProfiles().done(function (data) { + let result = {}; + for (let d of Object.values(data)) { + let profiles = []; + let default_profile = null; + for (let p of Object.keys(d.profiles)) { + if (d.profiles[p].default) { + default_profile = p; + continue; } - result[d.key] = { - name: d.displayName, - key: d.key, - profiles, - }; + profiles.push(p); } - self.slicers(result); - }); - } - self.slicerProfiles = ko.computed(function() { - return (self.slicers()[self.slicer()] || {}).profiles; - }); - // 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.default_scripts = {}; - for (let s of default_scripts) { - self.default_scripts[s.name] = s.gcode; - } - - // Patch the settings viewmodel to allow for us to block saving when validation has failed. - // As of 2022-05-31, 'exchanging()' is only used for display and not for logic. - self.settings.exchanging_orig = self.settings.exchanging; - self.settings.exchanging = ko.pureComputed(function () { - return self.settings.exchanging_orig() || - !self.allValidQueueNames() || !self.allValidQueueAddr() || - !self.allUniqueScriptNames() || !self.allUniquePreprocessorNames(); - }); - - self.queues = ko.observableArray(); - self.queue_fingerprint = null; - self.scripts = ko.observableArray([]); - self.preprocessors = ko.observableArray([]); - self.events = ko.observableArray([]); - self.scripts_fingerprint = null; - - self.selected_make = ko.observable(); - self.selected_model = ko.observable(); - let makes = Object.keys(self.profiles); - self.printer_makes = ko.observable(makes); - 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_printer_profile(profile.name); - }; - - self.preprocessorSelectOptions = ko.computed(function() { - let result = [{name: '', value: null}, {name: 'Add new...', value: 'ADDNEW'}]; - for (let p of self.preprocessors()) { - result.push({name: p.name(), value: p}); - } - return result; - }); - - function mkScript(name, body, expanded) { - let b = ko.observable(body || ""); - let n = ko.observable(name || ""); - return { - name: n, - body: b, - expanded: ko.observable((expanded === undefined) ? true : expanded), - preview: ko.computed(function() { - let flat = b().replace('\n', ' '); - return (flat.length > 32) ? flat.slice(0, 29) + "..." : flat; - }), - registrations: ko.computed(function() { - let nn = n(); - let result = []; - for (let e of self.events()) { - for (let a of e.actions()) { - let ppname = a.preprocessor(); - if (ppname !== null && ppname.name) { - ppname = ppname.name(); - } - if (a.script.name() === nn || ppname === nn) { - result.push(e.display); - } - } - } - return result; - }), - }; - } - - self.loadScriptsFromProfile = function() { - let profile = (self.profiles[self.selected_make()] || {})[self.selected_model()]; - if (profile === undefined) { - return; - } - self.addScript(`Clear Bed (${profile.name})`, - self.default_scripts[profile.defaults.clearBed], true); - self.addScript(`Finish (${profile.name})`, - self.default_scripts[profile.defaults.finished], true); - } - - self.loadFromFile = function(file, cb) { - // Inspired by https://stackoverflow.com/a/14155586 - if(!window.FileReader) return; - var reader = new FileReader(); - reader.onload = function(evt) { - if(evt.target.readyState != 2) return; - if(evt.target.error) { - alert('Error while reading file'); - return; - } - cb(file.name, evt.target.result, false); - }; - reader.readAsText(file); - }; - self.loadScriptFromFile = (f) => self.loadFromFile(f, self.addScript); - self.loadPreprocessorFromFile = (f) => self.loadFromFile(f, self.addPreprocessor); - - self.downloadFile = function(filename, body) { - // https://stackoverflow.com/a/45831357 - var blob = new Blob([body], {type: 'text/plain'}); - if (window.navigator && window.navigator.msSaveOrOpenBlob) { - window.navigator.msSaveOrOpenBlob(blob, filename); - } else { - var e = document.createEvent('MouseEvents'), - a = document.createElement('a'); - a.download = filename; - a.href = window.URL.createObjectURL(blob); - a.dataset.downloadurl = ['text/plain', a.download, a.href].join(':'); - e.initEvent('click', true, false, window, 0, 0, 0, 0, 0, false, false, false, false, 0, null); - a.dispatchEvent(e); - } - } - self.downloadScript = function(s) { - let n = s.name() - if (!n.endsWith(".gcode")) { - n += ".gcode"; - } - self.downloadFile(n, s.body()); - }; - - self.downloadPreprocessor = function(p) { - let n = p.name() - if (!n.endsWith(".py")) { - n += ".py"; - } - self.downloadFile(n, p.body()); - }; - - self.actionPreprocessorChanged = function(vm) { - if (vm.preprocessor() === "ADDNEW") { - p = self.addPreprocessor("", "", true); - vm.preprocessor(p); - self.gotoTab("scripts"); - } - }; - - self.addScript = function(name, body, expanded) { - let s = mkScript(name, body, expanded); - self.scripts.push(s); - return s; - }; - - self.addPreprocessor = function(name, body, expanded) { - let p = mkScript(name, body, expanded); - self.preprocessors.push(p); - return p; - }; - - self.rmScript = function(s) { - for (let e of self.events()) { - for (let a of e.actions()) { - if (a.script == s) { - e.actions.remove(a); - } - } - } - self.scripts.remove(s); - } - self.rmPreprocessor = function(p) { - for (let e of self.events()) { - for (let a of e.actions()) { - if (a.preprocessor() == p) { - a.preprocessor(null); - } + if (default_profile) { + profiles.unshift(default_profile); } + result[d.key] = { + name: d.displayName, + key: d.key, + profiles, + }; } - self.preprocessors.remove(p); + self.slicers(result); + }); + } + self.slicerProfiles = ko.computed(function() { + return (self.slicers()[self.slicer()] || {}).profiles; + }); + // 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.gotoScript = function(s) { - s.expanded(true); - self.gotoTab("scripts"); + self.profiles[prof.make][prof.model] = prof; + } + + // Patch the settings viewmodel to allow for us to block saving when validation has failed. + // As of 2022-05-31, 'exchanging()' is only used for display and not for logic. + self.settings.exchanging_orig = self.settings.exchanging; + self.settings.exchanging = ko.pureComputed(function () { + return self.settings.exchanging_orig() || + !self.automation.allUniqueScriptNames() || + !self.automation.allUniquePreprocessorNames(); + }); + + self.selected_make = ko.observable(); + self.selected_model = ko.observable(); + let makes = Object.keys(self.profiles); + self.printer_makes = ko.observable(makes); + self.printer_models = ko.computed(function() { + let models = self.profiles[self.selected_make()]; + if (models === undefined) { + return ["-"]; } - self.gotoTab = function(suffix) { - $(`#settings_continuousprint_tabs a[href="#settings_continuousprint_${suffix}"]`).tab('show'); + 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_printer_profile(profile.name); + }; - self.addAction = function(e, s) { - if (s === null) { - s = self.addScript(); - self.gotoScript(s); + // Called automatically by SettingsViewModel + self.onSettingsShown = function() { + for (let prof of profiles) { + if (self.settings.settings.plugins.continuousprint.cp_printer_profile() === prof.name) { + self.selected_make(prof.make); + self.selected_model(prof.model); + break; } - e.actions.push({ - script: s, - preprocessor: ko.observable(null), - }); - }; - self.rmAction = function(e, a) { - e.actions.remove(a); + self.slicer(self.settings.settings.plugins.continuousprint.cp_slicer()); + self.slicer_profile(self.settings.settings.plugins.continuousprint.cp_slicer_profile()); } - self.allUniqueScriptNames = ko.computed(function() { - let names = new Set(); - for (let s of self.scripts()) { - let n = s.name(); - if (names.has(n)) { - return false; - } - names.add(n); - } - return true; - }); - self.allUniquePreprocessorNames = ko.computed(function() { - let names = new Set(); - for (let p of self.preprocessors()) { - let n = p.name(); - if (names.has(n)) { - return false; - } - names.add(n); - } - return true; - }); - - self.newBlankQueue = function() { - self.queues.push({name: "", addr: "", strategy: ""}); - }; - self.rmQueue = function(q) { - self.queues.remove(q); - } - self.queueChanged = function() { - self.queues.valueHasMutated(); - } - self.allValidQueueAddr = ko.computed(function() { - for (let q of self.queues()) { - if (q.name === 'local' || q.addr.toLowerCase() === "auto") { - continue; - } - let sp = q.addr.split(':'); - if (sp.length !== 2) { - return false; - } - let port = parseInt(sp[1]); - if (isNaN(port) || port < 5000) { - return false; - } - } - return true; - }); - self.allValidQueueNames = ko.computed(function() { - for (let q of self.queues()) { - if (q.name.trim() === '') { - return false; - } - } - return true; - }); - // Called automatically by SettingsViewModel - self.onSettingsShown = function() { - for (let prof of profiles) { - if (self.settings.settings.plugins.continuousprint.cp_printer_profile() === prof.name) { - self.selected_make(prof.make); - self.selected_model(prof.model); - break; - } - self.slicer(self.settings.settings.plugins.continuousprint.cp_slicer()); - self.slicer_profile(self.settings.settings.plugins.continuousprint.cp_slicer_profile()); - } - // Queues and scripts are stored in the DB; we must fetch them whenever - // the settings page is loaded - self.api.get(self.api.QUEUES, (result) => { - let queues = [] - for (let r of result) { - if (r.name === "archive") { - continue; // Archive is hidden - } - queues.push(r); - } - self.queues(queues); - self.queue_fingerprint = JSON.stringify(queues); - }); - - self.api.get(self.api.AUTOMATION, (result) => { - let scripts = {}; - for (let k of Object.keys(result.scripts)) { - scripts[k] = mkScript(k, result.scripts[k], false); - } - self.scripts(Object.values(scripts)); - - let preprocessors = {}; - for (let k of Object.keys(result.preprocessors)) { - preprocessors[k] = mkScript(k, result.preprocessors[k], false); - } - self.preprocessors(Object.values(preprocessors)); + self.queues.onSettingsShown(); + self.automation.onSettingsShown(); + }; - let events = [] - for (let k of custom_events) { - let actions = []; - for (let a of result.events[k.event] || []) { - actions.push({ - script: scripts[a.script], - preprocessor: ko.observable(preprocessors[a.preprocessor]), - }); - } - events.push(new CPSettingsEvent(k, actions, self.api, default_symtable())); - } - events.sort((a, b) => a.display < b.display); - self.events(events); - self.scripts_fingerprint = JSON.stringify(result); - }); - }; + // Called automatically by SettingsViewModel + self.onSettingsBeforeSave = function() { + let cpset = self.settings.settings.plugins.continuousprint; + cpset.cp_slicer(self.slicer()); + cpset.cp_slicer_profile(self.slicer_profile()); - // Called automatically by SettingsViewModel - self.onSettingsBeforeSave = function() { - let cpset = self.settings.settings.plugins.continuousprint; - cpset.cp_slicer(self.slicer()); - cpset.cp_slicer_profile(self.slicer_profile()); + self.queues.onSettingsBeforeSave(); + self.automation.onSettingsBeforeSave(); + } - let queues = self.queues(); - if (JSON.stringify(queues) !== self.queue_fingerprint) { - // Sadly it appears flask doesn't have good parsing of nested POST structures, - // So we pass it a JSON string instead. - self.api.edit(self.api.QUEUES, queues, () => { - // Editing queues causes a UI refresh to the main viewmodel; no work is needed here - }); - } + self.sortStart = function() { + // Faking server disconnect allows us to disable the default whole-page + // file drag and drop behavior. + self.files.onServerDisconnect(); + }; + self.sortEnd = function() { + // Re-enable default drag and drop behavior + self.files.onServerConnect(); + }; - let scripts = {} - for (let s of self.scripts()) { - scripts[s.name()] = s.body(); - } - let preprocessors = {} - for (let p of self.preprocessors()) { - preprocessors[p.name()] = p.body(); - } - let events = {}; - for (let e of self.events()) { - let e2 = e.pack(); - if (e2) { - events[e.event] = e2; - } - } - let data = {scripts, preprocessors, events}; - if (JSON.stringify(data) !== self.scripts_fingerprint) { - self.api.edit(self.api.AUTOMATION, data, () => {}); - } - } - self.sortStart = function() { - // Faking server disconnect allows us to disable the default whole-page - // file drag and drop behavior. - self.files.onServerDisconnect(); - }; - self.sortEnd = function() { - // Re-enable default drag and drop behavior - self.files.onServerConnect(); - }; + self.gotoTab = function(suffix) { + $(`#settings_continuousprint_tabs a[href="#settings_continuousprint_${suffix}"]`).tab('show'); + } } diff --git a/continuousprint/static/js/continuousprint_settings.test.js b/continuousprint/static/js/continuousprint_settings.test.js index 9bd8c4c7..6b0d662d 100644 --- a/continuousprint/static/js/continuousprint_settings.test.js +++ b/continuousprint/static/js/continuousprint_settings.test.js @@ -95,36 +95,6 @@ test('valid model change updates profile in settings', () => { expect(v.settings.settings.plugins.continuousprint.cp_printer_profile).toHaveBeenCalledWith("TestPrinter"); }); -test('loadScriptsFromProfile', () => { - let v = new VM.CPSettingsViewModel(mocks(), PROFILES, SCRIPTS, EVENTS); - v.selected_make("Test"); - v.selected_model("Printer"); - v.loadScriptsFromProfile(); - expect(v.scripts()[0].name()).toMatch(/^Clear Bed.*/); - expect(v.scripts()[1].name()).toMatch(/^Finish.*/); -}); - -test('"auto" address allows submit', () =>{ - let v = new VM.CPSettingsViewModel(mocks(), PROFILES, SCRIPTS, EVENTS); - v.queues.push({name: 'asdf', addr: 'auto'}); - v.onSettingsBeforeSave(); - expect(v.settings.exchanging()).toEqual(false); -}); - -test('invalid address blocks submit', () =>{ - let v = new VM.CPSettingsViewModel(mocks(), PROFILES, SCRIPTS, EVENTS); - v.queues.push({name: 'asdf', addr: 'something_invalid'}); - v.onSettingsBeforeSave(); - expect(v.settings.exchanging()).toEqual(true); -}); - -test('valid address allows submit', () =>{ - let v = new VM.CPSettingsViewModel(mocks(), PROFILES, SCRIPTS, EVENTS); - v.queues.push({name: 'asdf', addr: '192.168.1.69:13337'}); - v.onSettingsBeforeSave(); - expect(v.settings.exchanging()).toEqual(false); -}); - test('invalid model change is ignored', () => { let v = new VM.CPSettingsViewModel(mocks(), PROFILES, SCRIPTS, EVENTS); v.modelChanged(); @@ -133,172 +103,12 @@ test('invalid model change is ignored', () => { }); test('load queues and scripts on settings view shown', () => { - m = mocks(); - m[2].get = function (typ, cb) { - if (typ === m[2].QUEUES) { - cb([ - {name: "archive"}, - {name: "local", addr: "", strategy:"IN_ORDER"}, - {name: "LAN", addr: "a:1", strategy:"IN_ORDER"}, - ]); - } else if (typ === m[2].AUTOMATION) { - cb({ - scripts: {a: 'g1', b: 'g2'}, - preprocessors: {c: 'p1'}, - events: {e1: [{script: 'a', preprocessor: 'c'}]}, - }); - } - }; - let v = new VM.CPSettingsViewModel(m, PROFILES, SCRIPTS, EVENTS); - v.onSettingsShown(); - expect(v.queues().length).toBe(2); // Archive excluded -}); -test('dirty exit commits queues', () => { - let m = mocks(); - m[2].get = function (typ, cb) { - if (typ === m[2].QUEUES) { - cb([]); - } else if (typ === m[2].AUTOMATION) { - cb({ - scripts: {}, - preprocessors: {}, - events: {}, - }); - } - }; - let v = new VM.CPSettingsViewModel(m, PROFILES, SCRIPTS, EVENTS); - v.onSettingsShown(); - v.queues.push({name: 'asdf', addr: ''}); - v.onSettingsBeforeSave(); - expect(v.api.edit).toHaveBeenCalledWith(m[2].QUEUES, expect.anything(), expect.anything()); -}); -test('non-dirty exit does not call commitQueues', () => { - let m = mocks(); - m[2].get = function (typ, cb) { - if (typ === m[2].QUEUES) { - cb([]); - } else if (typ === m[2].AUTOMATION) { - cb({ - scripts: {}, - preprocessors: {}, - events: {}, - }); - } - }; - let v = new VM.CPSettingsViewModel(m, PROFILES, SCRIPTS, EVENTS); - v.onSettingsShown(); - v.onSettingsBeforeSave(); - expect(v.api.edit).not.toHaveBeenCalled(); -}); - -test('addPreprocessor, rmPreprocessor', () => { - let v = new VM.CPSettingsViewModel(mocks(), PROFILES, SCRIPTS, EVENTS); - let p = v.addPreprocessor(); - expect(v.preprocessors().length).toEqual(1); - - // rmPreprocessor also removes from any events, without deleting the action - v.events([{ - actions: ko.observableArray([ - {script: {name: ko.observable('testscript')}, preprocessor: ko.observable(p)}, - ]) - }]); - v.rmPreprocessor(p); - expect(v.preprocessors().length).toEqual(0); - expect(v.events()[0].actions()[0].preprocessor()).toEqual(null); - -}); - -test('addScript, rmScript', () => { - let v = new VM.CPSettingsViewModel(mocks(), PROFILES, SCRIPTS, EVENTS); - v.addScript(); - expect(v.scripts().length).toEqual(1); - - // rmScript also removes the script from any events - v.events([{ - actions: ko.observableArray([ - {script: v.scripts()[0], preprocessor: ko.observable(null)}, - ]) - }]); - v.rmScript(v.scripts()[0]); - expect(v.scripts().length).toEqual(0); - expect(v.events()[0].actions().length).toEqual(0); -}); - -test('addAction, rmAction', () => { let v = new VM.CPSettingsViewModel(mocks(), PROFILES, SCRIPTS, EVENTS); - let e = {"actions": ko.observableArray([])}; - let a = {script:"foo"}; - v.addAction(e, a); - expect(e.actions()[0].script).toEqual(a); - v.rmAction(e, e.actions()[0]); - expect(e.actions().length).toEqual(0); -}); - -test('script or preprocessor naming collision blocks submit', () =>{ - let v = new VM.CPSettingsViewModel(mocks(), PROFILES, SCRIPTS, EVENTS); - v.addScript(); - v.addScript(); - expect(v.settings.exchanging()).toEqual(true); -}); - -test('registrations of script / preprocessor are tracked', () => { - let v = new VM.CPSettingsViewModel(mocks(), PROFILES, SCRIPTS, EVENTS); - let s = v.addScript(); - expect(s.registrations()).toEqual([]); - v.events([{ - display: "testevent", - actions: ko.observableArray([ - {script: s, preprocessor: ko.observable(null)}, - ]) - }]); - expect(s.registrations()).toEqual(["testevent"]); -}); -test('loadScriptFromFile, loadPreprocessorFromFile', () => { - let v = new VM.CPSettingsViewModel(mocks(), PROFILES, SCRIPTS, EVENTS); - v.loadFromFile = (file, cb) => cb("name", "result", false); - // No file argument needed since using fake loadFromFile - v.loadScriptFromFile(); - let s = v.scripts()[0]; - expect(s.body()).toEqual("result"); - expect(s.name()).toEqual("name"); - v.loadPreprocessorFromFile(); - let p = v.preprocessors()[0]; - expect(p.body()).toEqual("result"); - expect(p.name()).toEqual("name"); -}); -test('downloadScript, downloadPreprocessor', () => { - let v = new VM.CPSettingsViewModel(mocks(), PROFILES, SCRIPTS, EVENTS); - v.downloadFile = jest.fn() - - v.downloadScript({name: ko.observable('foo'), body: ko.observable('bar')}); - expect(v.downloadFile).toHaveBeenCalledWith("foo.gcode", "bar"); - - v.downloadPreprocessor({name: ko.observable('foo'), body: ko.observable('bar')}); - expect(v.downloadFile).toHaveBeenCalledWith("foo.py", "bar"); -}); -test('add new preprocessor from Events tab', () =>{ - let v = new VM.CPSettingsViewModel(mocks(), PROFILES, SCRIPTS, EVENTS); - v.gotoTab = jest.fn() - let s = v.addScript(); - v.events([{ - display: "testevent", - actions: ko.observableArray([ - {script: s, preprocessor: ko.observable(null)}, - ]) - }]); - let a = v.events()[0].actions()[0] - a.preprocessor(null); - v.actionPreprocessorChanged(a); - expect(v.preprocessors().length).toBe(0); - expect(a.preprocessor()).toBe(null); - expect(v.gotoTab).not.toHaveBeenCalled(); - - a.preprocessor('ADDNEW'); - v.actionPreprocessorChanged(a); - expect(v.preprocessors().length).toBe(1); - expect(a.preprocessor()).not.toBe(null); - expect(v.gotoTab).toHaveBeenCalled(); - + v.queues.onSettingsShown = jest.fn(); + v.automation.onSettingsShown = jest.fn(); + v.onSettingsShown(); + expect(v.queues.onSettingsShown).toHaveBeenCalled(); + expect(v.automation.onSettingsShown).toHaveBeenCalled(); }); test('Get slicers and profiles for dropdowns', () => { diff --git a/continuousprint/static/js/continuousprint_settings_automation.js b/continuousprint/static/js/continuousprint_settings_automation.js new file mode 100644 index 00000000..3c1ca098 --- /dev/null +++ b/continuousprint/static/js/continuousprint_settings_automation.js @@ -0,0 +1,259 @@ +if (typeof log === "undefined" || log === null) { + // In the testing environment, dependencies must be manually imported + ko = require('knockout'); + CP_GCODE_SCRIPTS = []; + CP_CUSTOM_EVENTS = []; + CP_SIMULATOR_DEFAULT_SYMTABLE = function() {return {};}; + CPSettingsEvent = require('./continuousprint_settings_event'); +} + +function CPSettingsAutomationViewModel(api, default_scripts=CP_GCODE_SCRIPTS, custom_events=CP_CUSTOM_EVENTS, default_symtable=CP_SIMULATOR_DEFAULT_SYMTABLE) { + var self = this; + self.api = api; + self.default_scripts = {}; + for (let s of default_scripts) { + self.default_scripts[s.name] = s.gcode; + } + self.scripts = ko.observableArray([]); + self.preprocessors = ko.observableArray([]); + self.events = ko.observableArray([]); + self.scripts_fingerprint = null; + + self.preprocessorSelectOptions = ko.computed(function() { + let result = [{name: '', value: null}, {name: 'Add new...', value: 'ADDNEW'}]; + for (let p of self.preprocessors()) { + result.push({name: p.name(), value: p}); + } + return result; + }); + + function mkScript(name, body, expanded) { + let b = ko.observable(body || ""); + let n = ko.observable(name || ""); + return { + name: n, + body: b, + expanded: ko.observable((expanded === undefined) ? true : expanded), + preview: ko.computed(function() { + let flat = b().replace('\n', ' '); + return (flat.length > 32) ? flat.slice(0, 29) + "..." : flat; + }), + registrations: ko.computed(function() { + let nn = n(); + let result = []; + for (let e of self.events()) { + for (let a of e.actions()) { + let ppname = a.preprocessor(); + if (ppname && ppname.name) { + ppname = ppname.name(); + } + if (a.script.name() === nn || ppname === nn) { + result.push(e.display); + } + } + } + return result; + }), + }; + } + + self.loadScriptsFromProfile = function(profile) { + if (profile === undefined) { + return; + } + self.addScript(`Clear Bed (${profile.name})`, + self.default_scripts[profile.defaults.clearBed], true); + self.addScript(`Finish (${profile.name})`, + self.default_scripts[profile.defaults.finished], true); + } + + self.loadFromFile = function(file, cb) { + // Inspired by https://stackoverflow.com/a/14155586 + if(!window.FileReader) return; + var reader = new FileReader(); + reader.onload = function(evt) { + if(evt.target.readyState != 2) return; + if(evt.target.error) { + alert('Error while reading file'); + return; + } + cb(file.name, evt.target.result, false); + }; + reader.readAsText(file); + }; + self.loadScriptFromFile = (f) => self.loadFromFile(f, self.addScript); + self.loadPreprocessorFromFile = (f) => self.loadFromFile(f, self.addPreprocessor); + + self.downloadFile = function(filename, body) { + // https://stackoverflow.com/a/45831357 + var blob = new Blob([body], {type: 'text/plain'}); + if (window.navigator && window.navigator.msSaveOrOpenBlob) { + window.navigator.msSaveOrOpenBlob(blob, filename); + } else { + var e = document.createEvent('MouseEvents'), + a = document.createElement('a'); + a.download = filename; + a.href = window.URL.createObjectURL(blob); + a.dataset.downloadurl = ['text/plain', a.download, a.href].join(':'); + e.initEvent('click', true, false, window, 0, 0, 0, 0, 0, false, false, false, false, 0, null); + a.dispatchEvent(e); + } + } + self.downloadScript = function(s) { + let n = s.name() + if (!n.endsWith(".gcode")) { + n += ".gcode"; + } + self.downloadFile(n, s.body()); + }; + + self.downloadPreprocessor = function(p) { + let n = p.name() + if (!n.endsWith(".py")) { + n += ".py"; + } + self.downloadFile(n, p.body()); + }; + + self.actionPreprocessorChanged = function(vm) { + if (vm.preprocessor() === "ADDNEW") { + p = self.addPreprocessor("", "", true); + vm.preprocessor(p); + self.gotoTab("scripts"); + } + }; + + self.addScript = function(name, body, expanded) { + let s = mkScript(name, body, expanded); + self.scripts.push(s); + return s; + }; + + self.addPreprocessor = function(name, body, expanded) { + let p = mkScript(name, body, expanded); + self.preprocessors.push(p); + return p; + }; + + self.rmScript = function(s) { + for (let e of self.events()) { + for (let a of e.actions()) { + if (a.script == s) { + e.actions.remove(a); + } + } + } + self.scripts.remove(s); + } + self.rmPreprocessor = function(p) { + for (let e of self.events()) { + for (let a of e.actions()) { + if (a.preprocessor() == p) { + a.preprocessor(null); + } + } + } + self.preprocessors.remove(p); + } + self.gotoScript = function(s) { + s.expanded(true); + self.gotoTab("scripts"); + } + + self.addAction = function(e, s) { + if (s === null) { + s = self.addScript(); + self.gotoScript(s); + } + e.actions.push({ + script: s, + preprocessor: ko.observable(null), + }); + }; + self.rmAction = function(e, a) { + e.actions.remove(a); + } + self.allUniqueScriptNames = ko.computed(function() { + let names = new Set(); + for (let s of self.scripts()) { + let n = s.name(); + if (names.has(n)) { + return false; + } + names.add(n); + } + return true; + }); + self.allUniquePreprocessorNames = ko.computed(function() { + let names = new Set(); + for (let p of self.preprocessors()) { + let n = p.name(); + if (names.has(n)) { + return false; + } + names.add(n); + } + return true; + }); + + self.onSettingsShown = function() { + self.api.get(self.api.AUTOMATION, (result) => { + let scripts = {}; + for (let k of Object.keys(result.scripts)) { + scripts[k] = mkScript(k, result.scripts[k], false); + } + self.scripts(Object.values(scripts)); + + let preprocessors = {}; + for (let k of Object.keys(result.preprocessors)) { + preprocessors[k] = mkScript(k, result.preprocessors[k], false); + } + self.preprocessors(Object.values(preprocessors)); + + let events = [] + for (let k of custom_events) { + let actions = []; + for (let a of result.events[k.event] || []) { + actions.push({ + script: scripts[a.script], + preprocessor: ko.observable(preprocessors[a.preprocessor]), + }); + } + events.push(new CPSettingsEvent(k, actions, self.api, default_symtable())); + } + events.sort((a, b) => a.display < b.display); + self.events(events); + self.scripts_fingerprint = JSON.stringify(result); + }); + }; + + self.onSettingsBeforeSave = function() { + let scripts = {} + for (let s of self.scripts()) { + scripts[s.name()] = s.body(); + } + let preprocessors = {} + for (let p of self.preprocessors()) { + preprocessors[p.name()] = p.body(); + } + let events = {}; + for (let e of self.events()) { + let e2 = e.pack(); + if (e2) { + events[e.event] = e2; + } + } + let data = {scripts, preprocessors, events}; + if (JSON.stringify(data) !== self.scripts_fingerprint) { + self.api.edit(self.api.AUTOMATION, data, () => {}); + } + } +} + + +try { +module.exports = { + CPSettingsAutomationViewModel, + CP_GCODE_SCRIPTS, +}; +} catch {} diff --git a/continuousprint/static/js/continuousprint_settings_automation.test.js b/continuousprint/static/js/continuousprint_settings_automation.test.js new file mode 100644 index 00000000..758cde88 --- /dev/null +++ b/continuousprint/static/js/continuousprint_settings_automation.test.js @@ -0,0 +1,184 @@ +// Import must happen after declaring constants +const VM = require('./continuousprint_settings_automation'); + +const SYMTABLE = () => {return {}}; + +const SCRIPTS = [ + { + name: 'script1', + gcode: 'test1', + }, + { + name: 'script2', + gcode: 'test2', + }, +]; + +const EVENTS = [ + {event: 'e1'}, +]; + +function mocks() { + return { + AUTOMATION: 'automation', + QUEUES: 'queues', + init: jest.fn(), + get: jest.fn((_, cb) => cb([])), + edit: jest.fn(), + simulate: jest.fn(), + }; +} + +test('loadScriptsFromProfile', () => { + let v = new VM.CPSettingsAutomationViewModel(mocks(), SCRIPTS, EVENTS, SYMTABLE); + v.loadScriptsFromProfile({ + name: 'TestPrinter', + make: 'Test', + model: 'Printer', + defaults: { + clearBed: 'script1', + finished: 'script2', + }, + }); + expect(v.scripts()[0].name()).toMatch(/^Clear Bed.*/); + expect(v.scripts()[1].name()).toMatch(/^Finish.*/); +}); + +test('load scripts, preprocessors, events on settings view shown', () => { + m = mocks(); + m.get = function (typ, cb) { + if (typ === m.AUTOMATION) { + cb({ + scripts: {a: 'g1', b: 'g2'}, + preprocessors: {c: 'p1'}, + events: {e1: [{script: 'a', preprocessor: 'c'}]}, + }); + } + }; + let v = new VM.CPSettingsAutomationViewModel(m, SCRIPTS, EVENTS, SYMTABLE); + v.onSettingsShown(); + expect(v.events().length).toBe(1); + expect(v.scripts().length).toBe(2); + expect(v.preprocessors().length).toBe(1); +}); + +test('addPreprocessor, rmPreprocessor', () => { + let v = new VM.CPSettingsAutomationViewModel(mocks(), SCRIPTS, EVENTS, SYMTABLE); + let p = v.addPreprocessor(); + expect(v.preprocessors().length).toEqual(1); + + // rmPreprocessor also removes from any events, without deleting the action + v.events([{ + actions: ko.observableArray([ + {script: {name: ko.observable('testscript')}, preprocessor: ko.observable(p)}, + ]) + }]); + v.rmPreprocessor(p); + expect(v.preprocessors().length).toEqual(0); + expect(v.events()[0].actions()[0].preprocessor()).toEqual(null); + +}); + +test('addScript, rmScript', () => { + let v = new VM.CPSettingsAutomationViewModel(mocks(), SCRIPTS, EVENTS, SYMTABLE); + v.addScript(); + expect(v.scripts().length).toEqual(1); + + // rmScript also removes the script from any events + v.events([{ + actions: ko.observableArray([ + {script: v.scripts()[0], preprocessor: ko.observable(null)}, + ]) + }]); + v.rmScript(v.scripts()[0]); + expect(v.scripts().length).toEqual(0); + expect(v.events()[0].actions().length).toEqual(0); +}); + +test('addAction, rmAction', () => { + let v = new VM.CPSettingsAutomationViewModel(mocks(), SCRIPTS, EVENTS, SYMTABLE); + let e = {"actions": ko.observableArray([])}; + let a = {script:"foo"}; + v.addAction(e, a); + expect(e.actions()[0].script).toEqual(a); + v.rmAction(e, e.actions()[0]); + expect(e.actions().length).toEqual(0); +}); + +test('allUniqueScriptNames', () =>{ + let v = new VM.CPSettingsAutomationViewModel(mocks(), SCRIPTS, EVENTS, SYMTABLE); + expect(v.allUniqueScriptNames()).toEqual(true); + v.addScript(); + expect(v.allUniqueScriptNames()).toEqual(true); + v.addScript(); + expect(v.allUniqueScriptNames()).toEqual(false); +}); + +test('allUniquePreprocessorNames', () =>{ + let v = new VM.CPSettingsAutomationViewModel(mocks(), SCRIPTS, EVENTS, SYMTABLE); + expect(v.allUniquePreprocessorNames()).toEqual(true); + v.addPreprocessor(); + expect(v.allUniquePreprocessorNames()).toEqual(true); + v.addPreprocessor(); + expect(v.allUniquePreprocessorNames()).toEqual(false); +}); + +test('registrations of script / preprocessor are tracked', () => { + let v = new VM.CPSettingsAutomationViewModel(mocks(), SCRIPTS, EVENTS, SYMTABLE); + let s = v.addScript(); + expect(s.registrations()).toEqual([]); + v.events([{ + display: "testevent", + actions: ko.observableArray([ + {script: s, preprocessor: ko.observable(null)}, + ]) + }]); + expect(s.registrations()).toEqual(["testevent"]); +}); +test('loadScriptFromFile, loadPreprocessorFromFile', () => { + let v = new VM.CPSettingsAutomationViewModel(mocks(), SCRIPTS, EVENTS, SYMTABLE); + v.loadFromFile = (file, cb) => cb("name", "result", false); + // No file argument needed since using fake loadFromFile + v.loadScriptFromFile(); + let s = v.scripts()[0]; + expect(s.body()).toEqual("result"); + expect(s.name()).toEqual("name"); + v.loadPreprocessorFromFile(); + let p = v.preprocessors()[0]; + expect(p.body()).toEqual("result"); + expect(p.name()).toEqual("name"); +}); +test('downloadScript, downloadPreprocessor', () => { + let v = new VM.CPSettingsAutomationViewModel(mocks(), SCRIPTS, EVENTS, SYMTABLE); + v.downloadFile = jest.fn() + + v.downloadScript({name: ko.observable('foo'), body: ko.observable('bar')}); + expect(v.downloadFile).toHaveBeenCalledWith("foo.gcode", "bar"); + + v.downloadPreprocessor({name: ko.observable('foo'), body: ko.observable('bar')}); + expect(v.downloadFile).toHaveBeenCalledWith("foo.py", "bar"); +}); + +test('add new preprocessor from Events tab', () =>{ + let v = new VM.CPSettingsAutomationViewModel(mocks(), SCRIPTS, EVENTS, SYMTABLE); + v.gotoTab = jest.fn() + let s = v.addScript(); + v.events([{ + display: "testevent", + actions: ko.observableArray([ + {script: s, preprocessor: ko.observable(null)}, + ]) + }]); + let a = v.events()[0].actions()[0] + a.preprocessor(null); + v.actionPreprocessorChanged(a); + expect(v.preprocessors().length).toBe(0); + expect(a.preprocessor()).toBe(null); + expect(v.gotoTab).not.toHaveBeenCalled(); + + a.preprocessor('ADDNEW'); + v.actionPreprocessorChanged(a); + expect(v.preprocessors().length).toBe(1); + expect(a.preprocessor()).not.toBe(null); + expect(v.gotoTab).toHaveBeenCalled(); +}); diff --git a/continuousprint/static/js/continuousprint_settings_event.js b/continuousprint/static/js/continuousprint_settings_event.js index 41ea5c27..8cee94c5 100644 --- a/continuousprint/static/js/continuousprint_settings_event.js +++ b/continuousprint/static/js/continuousprint_settings_event.js @@ -20,6 +20,7 @@ function CPSettingsEvent(evt, actions, api, default_symtable) { self.desc = evt.desc; // Construct symtable used for simulating output + console.log("eventSymtable", default_symtable); let sym = default_symtable; if (sym.current) { sym.current.state = evt.sym_state; @@ -157,7 +158,7 @@ function CPSettingsEvent(evt, actions, api, default_symtable) { let ks = []; for (let a of self.actions()) { let pp = a.preprocessor(); - if (pp !== null) { + if (pp !== null && pp !== undefined) { pp = pp.name(); } ks.push({ diff --git a/continuousprint/static/js/continuousprint_settings_queues.js b/continuousprint/static/js/continuousprint_settings_queues.js new file mode 100644 index 00000000..92bc2e3d --- /dev/null +++ b/continuousprint/static/js/continuousprint_settings_queues.js @@ -0,0 +1,53 @@ +if (typeof log === "undefined" || log === null) { + // In the testing environment, dependencies must be manually imported + ko = require('knockout'); +} + +function CPSettingsQueuesViewModel(api) { + var self = this; + self.api = api; + self.queues = ko.observableArray([]); + self.queue_fingerprint = null; + self.rmQueue = function(q) { + self.queues.remove(q); + } + self.queueChanged = function() { + self.queues.valueHasMutated(); + } + self.onSettingsShown = function() { + self.loadQueues(); + }; + + // Called automatically by SettingsViewModel + self.onSettingsBeforeSave = function() { + let queues = self.queues(); + if (JSON.stringify(queues) !== self.queue_fingerprint) { + // Sadly it appears flask doesn't have good parsing of nested POST structures, + // So we pass it a JSON string instead. + self.api.edit(self.api.QUEUES, queues, () => { + // Editing queues causes a UI refresh to the main viewmodel + }); + } + }; + + self.loadQueues = function() { + self.api.get(self.api.QUEUES, (result) => { + let queues = [] + for (let r of result) { + if (r.name === "archive") { + continue; // Archive is hidden + } + queues.push(r); + } + self.queues(queues); + self.queue_fingerprint = JSON.stringify(queues); + }); + }; +} + + +try { +module.exports = { + CPSettingsQueuesViewModel, +}; +} catch {} diff --git a/continuousprint/static/js/continuousprint_settings_queues.test.js b/continuousprint/static/js/continuousprint_settings_queues.test.js new file mode 100644 index 00000000..098d4351 --- /dev/null +++ b/continuousprint/static/js/continuousprint_settings_queues.test.js @@ -0,0 +1,52 @@ +// Import must happen after declaring constants +const VM = require('./continuousprint_settings_queues'); + +function mocks() { + return { + AUTOMATION: 'automation', + QUEUES: 'queues', + get: jest.fn((_, cb) => cb([])), + edit: jest.fn(), + }; +} + +test('load queues on settings view shown', () => { + m = mocks(); + m.get = function (typ, cb) { + if (typ === m.QUEUES) { + cb([ + {name: "archive"}, + {name: "local", addr: "", strategy:"IN_ORDER"}, + {name: "LAN", addr: "a:1", strategy:"IN_ORDER"}, + ]); + } + }; + let v = new VM.CPSettingsQueuesViewModel(m); + v.onSettingsShown(); + expect(v.queues().length).toBe(2); // Archive excluded +}); +test('dirty exit commits queues', () => { + let m = mocks(); + m.get = function (typ, cb) { + if (typ === m.QUEUES) { + cb([]); + } + }; + let v = new VM.CPSettingsQueuesViewModel(m); + v.onSettingsShown(); + v.queues.push({name: 'asdf', addr: ''}); + v.onSettingsBeforeSave(); + expect(v.api.edit).toHaveBeenCalledWith(m.QUEUES, expect.anything(), expect.anything()); +}); +test('non-dirty exit does not commit queues', () => { + let m = mocks(); + m.get = function (typ, cb) { + if (typ === m.QUEUES) { + cb([]); + } + }; + let v = new VM.CPSettingsQueuesViewModel(m); + v.onSettingsShown(); + v.onSettingsBeforeSave(); + expect(v.api.edit).not.toHaveBeenCalled(); +}); diff --git a/continuousprint/static/js/continuousprint_viewmodel.js b/continuousprint/static/js/continuousprint_viewmodel.js index ab32a9e8..3bdfc3d6 100644 --- a/continuousprint/static/js/continuousprint_viewmodel.js +++ b/continuousprint/static/js/continuousprint_viewmodel.js @@ -259,12 +259,14 @@ function CPViewModel(parameters) { // infer the index of the job based on the rendered HTML given by evt.to if (vm.constructor.name === "CPJob") { let destq = self.queues()[self._getElemIdx(evt.to, "cp-queue")]; + let destj = destq.jobs(); let dest_idx = evt.newIndex; self.api.mv(self.api.JOB, { src_queue: src.name, dest_queue: destq.name, id: vm.id(), - after_id: (dest_idx > 0) ? destq.jobs()[dest_idx-1].id() : null + after_id: (dest_idx > 0) ? destj[dest_idx-1].id() : null, + before_id: (dest_idx < destj.length-1) ? destj[dest_idx+1].id() : null, }, (result) => { if (result.error) { self.onDataUpdaterPluginMessage("continuousprint", {type: "error", msg: result.error}); diff --git a/continuousprint/static/less/build.sh b/continuousprint/static/less/build.sh new file mode 100755 index 00000000..366075f1 --- /dev/null +++ b/continuousprint/static/less/build.sh @@ -0,0 +1,2 @@ +#!/bin/bash +for file in *.less; do lessc --strict-imports $file ../css/`basename $file`.css ; done diff --git a/continuousprint/static/less/continuousprint_history.less b/continuousprint/static/less/continuousprint_history.less new file mode 100644 index 00000000..8194f1d6 --- /dev/null +++ b/continuousprint/static/less/continuousprint_history.less @@ -0,0 +1,55 @@ + +#tab_plugin_continuousprint .header, #tab_plugin_continuousprint .entries > div { + display: flex; + justify-content: space-between; + padding: 0px 44px 0px 32px; + flex-direction: row; +} + +#tab_plugin_continuousprint .header { + font-weight: bold; + opacity: 0.5; + border-bottom: 1px gray solid; +} + +#tab_plugin_continuousprint .header > *, #tab_plugin_continuousprint .entries > .entry > * { + flex: 1; + text-align: center; +} + +#tab_plugin_continuousprint .timelapse_thumbnail { + position: relative; + display: inline-block; +} + +#tab_plugin_continuousprint .timelapse_thumbnail > img { + display: none; + cursor: pointer; + position: absolute; + z-index: 1000; + top: 0; + max-width: 100px; + left: 18px; + border: 1px black solid; + padding: 5px; + background-color: white; +} +#tab_plugin_continuousprint .timelapse_thumbnail > i { + padding: 2px; /* So hover is maintained when mousing over to image */ +} +#tab_plugin_continuousprint .timelapse_thumbnail:hover > img { + display: block; +} + +#tab_plugin_continuousprint .separator { + display: flex; + justify-content: space-between; + align-items: center; +} +#tab_plugin_continuousprint .separator .line { + content: ''; + flex: 1; + border-bottom: 1px solid #000; + margin-left: 5px; + margin-right: 5px; +} diff --git a/continuousprint/static/less/continuousprint_settings.less b/continuousprint/static/less/continuousprint_settings.less new file mode 100644 index 00000000..99961940 --- /dev/null +++ b/continuousprint/static/less/continuousprint_settings.less @@ -0,0 +1,96 @@ +#settings_plugin_continuousprint { + .cpq_title { + font-family: "Cabin Regular"; + font-size: 2em; + display: flex; + align-items: center; + > img { + width: 71px; + } + } + .queue-header :first-child { + text-align: left; + width: 300px; + } + .queue-row-container { + width:100%; + display: flex; + flex-wrap: nowrap; + flex-direction: row; + justify-content: right; + align-items: center; + > * { + margin-left: var(--cpq-pad2); + margin-bottom: 0; + line-height: 1.6; + } + > .row-name { + width: 300px; + } + select { + margin-bottom: 0px; + } + > div { + flex: 1; + text-align: center; + } + } + .lobby { + max-height: 300px; + overflow-y: scroll; + } + .lobby-network-details { + display: flex; + flex-direction: row; + > div { + flex: 1; + margin: var(--cpq-pad); + &:first-child { + border-right: 1px #e5e5e5 solid; /* copy from .accordion-group */ + } + } + } + .header-row { + width:100%; + display: flex; + flex-wrap: nowrap; + flex-direction: row; + align-items: center; + min-height: 30px; + > * { + margin-left: var(--cpq-pad); + } + } + .subheader { + opacity: 0.7; + font-style: italic; + } + .events > h4 { + margin-top: var(--cpq-pad2); + padding-top: var(--cpq-pad2); + } + .simulation-output { + display:flex; + flex-direction: row; + justify-content: center; + > div { + flex: 1; + padding: var(--cpq-pad); + } + h5 { + margin: 0; + text-align: center; + opacity: 0.5; + } + } + .output-block { + border: 1px #e5e5e5 solid; /* copy from .accordion-group */ + border-radius: var(--cpq-pad); + width: 100%; + min-height: 150px; + max-height: 400px; + overflow-y: scroll; + padding: 0; + margin: 0; + } +} diff --git a/continuousprint/static/less/continuousprint_shared.less b/continuousprint/static/less/continuousprint_shared.less new file mode 100644 index 00000000..42153781 --- /dev/null +++ b/continuousprint/static/less/continuousprint_shared.less @@ -0,0 +1,82 @@ +:root { + /* Small padding roughly matches the spacing between Print/Pause/Cancel buttons in the State panel */ + --cpq-pad: 6px; + --cpq-pad2: 20px; +} + +@font-face { + font-family: Cabin Regular; + src: url(../ttf/Cabin/Cabin-Regular.ttf); +} + +#tab_plugin_continuousprint, #settings_plugin_continuousprint { + .hint { + font-weight: italic; + font-size: 85%; + opacity: 0.7; + } + .bold{ + font-weight: bold; + } + .label { + border: 1px gray solid; + } + .fa-grip-vertical { + opacity: 0.0; + margin-left: 1px !important; + } + *:hover > .fa-grip-vertical { + opacity: 0.5; + cursor: grab; + } + + .accordion-heading-button { + padding: var(--cpq-pad) var(--cpq-pad); + input { + margin-top: -2px; + } + } + .accordion-body.collapse { + /* Transitions copied from div#files.accordion-body.collapse */ + max-height: 0; + overflow-y: auto; + -webkit-transition: max-height .35s ease; + -moz-transition: max-height .35s ease; + -o-transition: max-height .35s ease; + transition: max-height .35s ease; + &.in { + max-height: 500px; + } + } + + .action-gutter { + display: flex; + flex-direction: row; + justify-content: flex-end; + padding-right: var(--cpq-pad); + padding-top: var(--cpq-pad); + .text-error { + width: 100%; + line-height: 1; + text-align: right; + padding-right: var(--cpq-pad2); + } + } + .queue-header { + display: flex; + justify-content: space-between; + font-weight: bold; + opacity: 0.5; + align-items: flex-end; + > div { + text-align: center; + margin-left: var(--cpq-pad); + } + } + .has_title { + cursor: help; + > sup { + opacity: 0.5; + } + } +} diff --git a/continuousprint/static/less/continuousprint_tab.less b/continuousprint/static/less/continuousprint_tab.less new file mode 100644 index 00000000..a77b8088 --- /dev/null +++ b/continuousprint/static/less/continuousprint_tab.less @@ -0,0 +1,249 @@ +#files .cpq-gjob { + div[title="Load"] { + display: none !important; + } + div[title="Load and Print"] { + display: none !important; + } + .fa-archive { + margin-right: 4px; + } +} + +#state span { + .ERROR { + background-color: rgba(255, 0, 0, 0.6); + border-radius: var(--cpq-pad); + padding: var(--cpq-pad); + } + .NEEDS_ACTION { + animation: pulse 2.5s infinite; + border-radius: var(--cpq-pad); + padding: var(--cpq-pad); + } +} + +#tab_plugin_continuousprint { + .cpq-leftpad { + margin-left: var(--cpq-pad); + } + #continuousprint_tab_queues > div { + margin-bottom: 50px; + } + .highlight { + border: 3px #77a4ff solid !important; + min-height: 100px; + } + .multiselect { + flex-grow: 0; + display: flex; + flex-direction: row; + justify-content: right; + margin-bottom: -31px; + margin-left: 4px; + .btn-group { + /* Lay on top of table headers */ + z-index: 1; + } + } + + .cp-header { + display: flex; + flex-direction: row; + align-items: center; + font-weight: bold; + } + .job-header { + background-color: #eee; + } + .profile-select { + /* Match the style of Bootstrap labels */ + height: 20px; + width: 60px; + margin-bottom: 0px; + } + .fa-grip-vertical { + opacity: 0.0; + margin-left: 1px !important; + } + *:hover > .fa-grip-vertical { + opacity: 0.5; + cursor: grab; + } + .queue-list { + border-radius: var(--cpq-pad); + min-height: 70px; + border-top: 1px #e5e5e5 solid; + border-bottom: 1px #e5e5e5 solid; + padding: var(--cpq-pad) 0px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + } + .accordion-heading { + width:100%; + display: flex; + flex-wrap: nowrap; + flex-direction: column; + justify-content: right; + align-items: center; + } + .queue-row-container{ + width:100%; + display: flex; + flex-wrap: nowrap; + flex-direction: row; + justify-content: right; + align-items: center; + } + .job { + position: relative; + + &.acquired .job-name { + font-weight: bold; + } + &.acquired.local div.job-header { + background-color: #DDF; + } + .shiftsel { + border-left: 3px solid #CCC !important; + } + .floatedit { + position: absolute; + top: var(--cpq-pad2); + right: var(--cpq-pad2); + opacity: 0.0; + z-index: 1000; + } + .accordion-group { + border-right: 0px; + } + > div.accordion-body > div.accordion-inner { + padding-right: 0px; + border-right: 0px; + } + &:hover .floatedit { + opacity: 0.5; + &:hover .floatedit:hover { + opacity: 1.0; + } + } + } + .queue-row-container > * { + margin-left: var(--cpq-pad); + margin-bottom: 0; + line-height: 1.6; + } + .total, .completed, .remaining, .count { + width: 67px; + text-align: center; + margin-left: var(--cpq-pad); + padding: 2px; + border: 1px transparent solid; + } + .cp-queue .loading { + opacity: 0.3; + cursor: default !important; + } + .edit-header { + display: flex; + justify-content: space-between; + font-weight: bold; + opacity: 0.5; + } + .sets { + &.draft { + border-bottom: 2px #ccc solid; + } + .progress { + margin-left: 17px; + } + } + .job-stats { + display: flex; + justify-content: space-between; + } + .progresstext { + width: 100%; + position: absolute; + text-align:center; + } + .set_warning { + flex: 1; + text-align: center; + margin-right: 17px; + } + .draft .set_warning { + flex: 0; + } + .progress { + position: relative; + flex: 1; + display: flex; /* allow for stacking progresses in jobs */ + margin-right: var(--cpq-pad2); + } + .cpq_logo { + height: 40px; + float: left; + } + .queue-header { + margin-left: 55px; + margin-right: var(--cpq-pad2); + } + #queue_sets.empty { + border: 1px #e5e5e5 solid; /* copy from .accordion-group */ + padding: var(--cpq-pad); + border-radius: var(--cpq-pad); + &.draft::before { + content: "Hint: Click the + button on a file, or drag in a set from another job in edit mode."; + font-weight: italic; + font-size: 85%; + opacity: 0.7; + } + } + ::placeholder { + font-weight: italic; + opacity: 0.7; + } + .queue-set-list { + overflow-y: auto; + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: space-between; + h4 { + border-bottom: 1px #e5e5e5 solid; + } + .full { + width: 100%; + } + .gutter { + border-top: 1px #e5e5e5 solid; + padding-top: var(--cpq-pad); + display: flex; + justify-content: space-between; + } + } + .count-box { + min-width:30px; + max-height:15px; + -moz-appearance: textfield; + text-align: center; + border: 1px #ccc solid !important; + &::-webkit-outer-spin-button, &::-webkit-inner-spin-button { + /* chrome, safari, edge, opera: hide up/down buttons on count box input */ + -webkit-appearance: none; + margin: 0; + } + } + .file-name, .job-name { + flex: 1; + flex-shrink: 10; + overflow-x: hidden; + white-space: nowrap; + max-width: 96%; + text-overflow: ellipsis; + border: 0; + } +} diff --git a/continuousprint/static/less/continuousprint_themeify_compat.less b/continuousprint/static/less/continuousprint_themeify_compat.less new file mode 100644 index 00000000..b4c3c49e --- /dev/null +++ b/continuousprint/static/less/continuousprint_themeify_compat.less @@ -0,0 +1,53 @@ +.themeify.discorded #tab_plugin_continuousprint .highlight, +.themeify.cyborg #tab_plugin_continuousprint .highlight, +.themeify.discoranged #tab_plugin_continuousprint .highlight, +.themeify.nighttime #tab_plugin_continuousprint .highlight { + border: 3px #656b76 solid !important; +} +.themeify.discorded #tab_plugin_continuousprint .job-header, +.themeify.cyborg #tab_plugin_continuousprint .job-header, +.themeify.discoranged #tab_plugin_continuousprint .job-header, +.themeify.nighttime #tab_plugin_continuousprint .job-header { + background-color: #222 !important; +} +.themeify.discorded #tab_plugin_continuousprint input, +.themeify.cyborg #tab_plugin_continuousprint input, +.themeify.discoranged #tab_plugin_continuousprint input, +.themeify.nighttime #tab_plugin_continuousprint input { + background-color: #656b76 !important; + color: white !important; +} + +/* ==== 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 { + animation: none; + background-image: none; +} +#tab_plugin_continuousprint .progress.active .bar.active { + -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; + 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); +} diff --git a/continuousprint/storage/database.py b/continuousprint/storage/database.py index 6359efb8..e97cf63e 100644 --- a/continuousprint/storage/database.py +++ b/continuousprint/storage/database.py @@ -28,6 +28,13 @@ import time +def getint(d, k, default=0): + v = d.get(k, default) + if type(v) == str: + v = int(v) + return v + + logging.getLogger("peewee").setLevel(logging.INFO) @@ -92,7 +99,7 @@ class Queue(Model): name = CharField(unique=True) created = DateTimeField(default=datetime.datetime.now) rank = FloatField() - addr = CharField(null=True) # null == local queue + addr = CharField(null=True) # null == local (offline) queue strategy = CharField() class Meta: @@ -115,6 +122,12 @@ class JobView: def refresh_sets(self): raise NotImplementedError() + def save(self): + raise NotImplementedError() + + def _load_set(self, data, idx): + raise NotImplementedError() + def decrement(self): self.remaining = max(self.remaining - 1, 0) self.save() @@ -149,9 +162,16 @@ def _next_set(self, profile, custom_filter): return (s, True) return (None, any_printable) - @classmethod - def from_dict(self, data: dict): - raise NotImplementedError + def load_dict(self, data: dict, queue): + self.queue = queue + self.name = data.get("name", "") + self.created = getint(data, "created") + self.count = getint(data, "count") + self.remaining = getint(data, "remaining", default=self.count) + self.id = data.get("id", None) + self.draft = data.get("draft", False) + self.acquired = data.get("acquired", False) + self.sets = [self._load_set(s, i) for i, s in enumerate(data["sets"])] def as_dict(self): sets = list(self.sets) @@ -190,11 +210,10 @@ class Job(Model, JobView): class Meta: database = DB.queues - @classmethod - def from_dict(self, data: dict): - j = Job(**data) - j.sets = [Set.from_dict(s) for s in data["sets"]] - return j + def _load_set(self, data, idx): + s = Set() + s.load_dict(data, self, idx) + return s def refresh_sets(self): Set.update(remaining=Set.count, completed=0).where(Set.job == self).execute() @@ -203,6 +222,9 @@ def refresh_sets(self): class SetView: """See JobView for rationale for this class.""" + def save(self): + raise NotImplementedError() + def _csv2list(self, v): if v == "": return [] @@ -226,6 +248,33 @@ def decrement(self, profile): self.save() # Save must occur before job is observed return self.job.next_set(profile) + def load_dict(self, data, job, rank=None): + self.job = job + for listform, csvform in [ + ("materials", "material_keys"), + ("profiles", "profile_keys"), + ]: + if data.get(listform) is not None: + data[csvform] = ",".join(data[listform]) + del data[listform] + else: + data[csvform] = data.get(csvform, "") + + for numeric in ("count", "remaining", "completed"): + data[numeric] = getint(data, numeric, 0) + + if rank is not None: + data["rank"] = rank + elif data.get("rank") is not None: + data["rank"] = float(data["rank"]) + + for filler in ("id", "sd"): + if filler not in data: + data[filler] = None + + for (k, v) in data.items(): + setattr(self, k, v) + def resolve(self, override=None): if override is not None: self._resolved = override diff --git a/continuousprint/storage/database_test.py b/continuousprint/storage/database_test.py index 441d7b64..483691e5 100644 --- a/continuousprint/storage/database_test.py +++ b/continuousprint/storage/database_test.py @@ -254,7 +254,7 @@ def testDecrementZeroCount(self): self.j.decrement() self.assertEqual(self.j.remaining, 0) - def testFromDict(self): + def testLoadDict(self): Set.create( path="a", sd=False, @@ -275,7 +275,9 @@ def testFromDict(self): ) j = Job.get(id=self.j.id) d = j.as_dict() - j2 = Job.from_dict(d) + + j2 = Job() + j2.load_dict(d, None) self.assertEqual(j2.name, j.name) self.assertEqual(j2.count, j.count) self.assertEqual([s.path for s in j2.sets], [s.path for s in j.sets]) @@ -374,9 +376,10 @@ def testResolveAlreadySet(self): self.s._resolved = "testval" self.assertEqual(self.s.resolve(), "testval") - def testFromDict(self): + def testLoadDict(self): d = self.s.as_dict() - s = Set.from_dict(d) + s = Set() + s.load_dict(d, None) self.assertEqual(s.path, self.s.path) self.assertEqual(s.count, self.s.count) self.assertEqual(s.materials(), self.s.materials()) diff --git a/continuousprint/storage/lan.py b/continuousprint/storage/lan.py deleted file mode 100644 index 638dc909..00000000 --- a/continuousprint/storage/lan.py +++ /dev/null @@ -1,83 +0,0 @@ -from .database import JobView, SetView -from pathlib import Path -from .queries import getint -from requests.exceptions import HTTPError - - -class LANQueueView: - def __init__(self, lq): - self.lq = lq - self.name = lq.ns - - -class LANJobView(JobView): - def __init__(self, manifest, lq): - # === Fields present in JobView === - self.name = manifest.get("name", "") - self.created = getint(manifest, "created") - self.count = getint(manifest, "count") - self.remaining = getint(manifest, "remaining", default=self.count) - self.queue = LANQueueView(lq) - self.id = manifest["id"] - self.updateSets(manifest["sets"]) - self.draft = manifest.get("draft", False) - self.acquired = manifest.get("acquired", False) - - # === LANJobView specific fields === - self.peer = manifest["peer_"] - self.hash = manifest.get("hash") - - def get_base_dir(self): - return self.queue.lq.get_gjob_dirpath(self.peer, self.hash) - - def remap_set_paths(self): - # Replace all relative/local set paths with fully resolved paths - for s in self.sets: - s.path = s.resolve() - - def updateSets(self, sets_list): - self.sets = [LANSetView(s, self, i) for i, s in enumerate(sets_list)] - - def save(self): - # as_dict implemented in JobView doesn't handle LANJobView specific fields, so we must inject them here. - d = self.as_dict() - d["peer_"] = self.peer - d["hash"] = self.hash - self.queue.lq.set_job(self.id, d) - - def refresh_sets(self): - for s in self.sets: - s.remaining = s.count - s.completed = 0 - self.save() - - -class LANResolveError(Exception): - pass - - -class LANSetView(SetView): - def __init__(self, data, job, rank): - self.job = job - self.sd = False - self.rank = int(rank) - self.id = f"{job.id}_{rank}" - for attr in ("path", "count"): - setattr(self, attr, data[attr]) - self.remaining = getint(data, "remaining", default=self.count) - self.completed = getint(data, "completed") - self.metadata = data.get("metadata") - self.material_keys = ",".join(data.get("materials", [])) - self.profile_keys = ",".join(data.get("profiles", [])) - self._resolved = None - - def resolve(self, override=None) -> str: - if self._resolved is None: - try: - self._resolved = str(Path(self.job.get_base_dir()) / self.path) - except HTTPError as e: - raise LANResolveError(f"Failed to resolve {self.path}") from e - return super().resolve(override) - - def save(self): - self.job.save() diff --git a/continuousprint/storage/peer.py b/continuousprint/storage/peer.py new file mode 100644 index 00000000..6403ff13 --- /dev/null +++ b/continuousprint/storage/peer.py @@ -0,0 +1,67 @@ +from .database import JobView, SetView +from pathlib import Path +from .queries import getint +from requests.exceptions import HTTPError + + +class PeerQueueView: + def __init__(self, q): + self.q = q + self.name = q.ns + + +class PeerJobView(JobView): + def _load_set(self, data, idx): + s = PeerSetView() + s.load_dict(data, self, idx) + return s + + def load_dict(self, data: dict, queue): + super().load_dict(data, queue) + self.peer = data["peer_"] + self.hash = data.get("hash") + self.rn = data.get("rn") + self.rd = data.get("rd") + + def as_dict(self): + d = super().as_dict() + d["peer_"] = self.peer + d["hash"] = self.hash + if self.rn is not None: + d["rn"] = self.rn + d["rd"] = self.rd + try: + d["rank"] = self.rn / self.rd + except ZeroDivisionError: + d["rank"] = 0 + return d + + def save(self): + self.queue.q.set_job(self.id, self.as_dict()) + + def refresh_sets(self): + for s in self.sets: + s.remaining = s.count + s.completed = 0 + self.save() + + +class ResolveError(Exception): + pass + + +class PeerSetView(SetView): + def load_dict(self, data, job, rank): + super().load_dict(data, job, rank) + self._resolved = None + + def save(self): + self.job.save() + + def resolve(self, override=None) -> str: + if self._resolved is None: + try: + self._resolved = self.job.queue.q.resolve(self.path, self.job.hash) + except HTTPError as e: + raise ResolveError(f"Failed to resolve {self.path}") from e + return super().resolve(override) diff --git a/continuousprint/storage/lan_test.py b/continuousprint/storage/peer_test.py similarity index 56% rename from continuousprint/storage/lan_test.py rename to continuousprint/storage/peer_test.py index 91ece902..fb68597b 100644 --- a/continuousprint/storage/lan_test.py +++ b/continuousprint/storage/peer_test.py @@ -1,62 +1,59 @@ import unittest from unittest.mock import MagicMock from .database import STLResolveError -from .lan import LANJobView, LANSetView, LANResolveError +from .peer import PeerJobView, PeerSetView, ResolveError from requests.exceptions import HTTPError -class LANViewTest(unittest.TestCase): +class PeerViewsTest(unittest.TestCase): def setUp(self): - self.lq = MagicMock() - self.j = LANJobView( + self.j = PeerJobView() + self.j.load_dict( dict( id="", name="j", count=2, remaining=2, created=1234, - sets=[dict(path="a.gcode", count=1, remaining=1)], + sets=[dict(path="a.gcode", count=1, remaining=1, metadata="")], peer_="asdf:6789", ), - self.lq, + MagicMock(), ) self.s = self.j.sets[0] def test_resolve_file(self): - self.lq.get_gjob_dirpath.return_value = "/path/to/" + self.j.queue.q.resolve.return_value = "/path/to/a.gcode" self.assertEqual(self.s.resolve(), "/path/to/a.gcode") def test_resolve_stl(self): # Ensure STL checking from the parent class is still triggered self.j.sets[0].path = "a.stl" - self.lq.get_gjob_dirpath.return_value = "/path/to/" + self.j.queue.q.resolve.return_value = "/path/to/a.stl" with self.assertRaises(STLResolveError): self.s.resolve() - def test_remap_set_paths(self): - self.lq.get_gjob_dirpath.return_value = "/path/to/" - self.j.remap_set_paths() - self.assertEqual(self.s.path, "/path/to/a.gcode") - def test_resolve_http_error(self): - self.lq.get_gjob_dirpath.side_effect = HTTPError - with self.assertRaises(LANResolveError): + self.j.queue.q.resolve.side_effect = HTTPError + with self.assertRaises(ResolveError): self.s.resolve() def test_decrement_refreshes_sets_and_saves(self): self.s.remaining = 0 self.s.completed = 5 self.j.decrement() - self.lq.set_job.assert_called() + self.j.queue.q.set_job.assert_called() + self.assertEqual( + self.j.queue.q.set_job.call_args[0][1]["sets"][0]["remaining"], self.s.count + ) self.assertEqual( - self.lq.set_job.call_args[0][1]["sets"][0]["remaining"], self.s.count + self.j.queue.q.set_job.call_args[0][1]["sets"][0]["completed"], 0 ) - self.assertEqual(self.lq.set_job.call_args[0][1]["sets"][0]["completed"], 0) def test_save_persists_peer_and_hash(self): self.j.peer = "foo" self.j.hash = "bar" self.j.save() - data = self.lq.set_job.call_args[0][1] + data = self.j.queue.q.set_job.call_args[0][1] self.assertEqual(data["peer_"], "foo") self.assertEqual(data["hash"], "bar") diff --git a/continuousprint/storage/queries.py b/continuousprint/storage/queries.py index 634ee31b..49bfb169 100644 --- a/continuousprint/storage/queries.py +++ b/continuousprint/storage/queries.py @@ -7,6 +7,7 @@ from pathlib import Path from .database import ( + getint, Queue, Job, Set, @@ -24,13 +25,6 @@ MAX_COUNT = 999999 -def getint(d, k, default=0): - v = d.get(k, default) - if type(v) == str: - v = int(v) - return v - - def clearOldState(): # On init, scrub the local DB for any state that may have been left around # due to an improper shutdown @@ -53,14 +47,14 @@ def releaseJob(j) -> bool: def importJob(qname, manifest: dict, dirname: str, draft=False): - q = Queue.get(name=qname) + # q = Queue.get(name=qname) # Manifest may have "remaining" values set incorrectly for new job; ensure # these are set to the whole count for both job and sets. - j = Job.from_dict(manifest) + j = Job() + j.load_dict(manifest, 1) j.remaining = j.count j.id = None - j.queue = q j.rank = _rankEnd() j.draft = draft j.save() @@ -124,12 +118,12 @@ def assignQueues(queues): Queue.create( name=qdata["name"], strategy=qdata["strategy"], - addr=qdata["addr"], rank=rank, ) else: Queue.update( - strategy=qdata["strategy"], addr=qdata["addr"], rank=rank + strategy=qdata["strategy"], + rank=rank, ).where(Queue.name == qdata["name"]).execute() return (absent_names, added) diff --git a/continuousprint/storage/queries_test.py b/continuousprint/storage/queries_test.py index 1d61efba..2113d4fc 100644 --- a/continuousprint/storage/queries_test.py +++ b/continuousprint/storage/queries_test.py @@ -46,7 +46,13 @@ def testClearOldState(self): def testImportJob(self): q.importJob( DEFAULT_QUEUE, - dict(name="j1", count=5, remaining=4, sets=[dict(count=1, path="a.gcode")]), + dict( + id=8, + name="j1", + count=5, + remaining=4, + sets=[dict(count=1, path="a.gcode")], + ), Path("dirname"), ) j = Job.get(id=1) diff --git a/continuousprint/storage/rank.py b/continuousprint/storage/rank.py new file mode 100644 index 00000000..394cae1c --- /dev/null +++ b/continuousprint/storage/rank.py @@ -0,0 +1,59 @@ +# Implements a Stern-Brocot ordering via rational fractions +# https://begriffs.com/posts/2018-03-20-user-defined-order.html#approach-3-true-fractions + + +class Rational: + def __init__(self, numerator, denominator): + self.n = numerator + self.d = denominator + + def mediant(self, other): + # https://www.cut-the-knot.org/proofs/fords.shtml#mediant + # mediant of n1/d1 and n2/d2 = (n1 + n2)/(d1 + d2) + return Rational(self.n + other.n, self.d + other.d) + + def _norm_op(self, other, op): + return op(self.n * other.d, other.n * self.d) + + def __lt__(self, other): + return self._norm_op(other, int.__lt__) + + def __gt__(self, other): + return self._norm_op(other, int.__gt__) + + def __eq__(self, other): + return self._norm_op(other, int.__eq__) + + def __ne__(self, other): + return self._norm_op(other, int.__ne__) + + def __le__(self, other): + return self._norm_op(other, int.__le__) + + def __ge__(self, other): + return self._norm_op(other, int.__ge__) + + def __repr__(self): + return f"({self.n}/{self.d})" + + +def rational_intermediate( + x: Rational, y: Rational, lo=Rational(0, 1), hi=Rational(1, 0) +) -> Rational: + # Find the rational intermediate between rationals X and Y + # via binary search + assert x < y + assert x >= lo + assert y >= lo + n = 0 + while True: + med = lo.mediant(hi) + if med <= x: # (cmp(med, &x) < 1) + lo = med + elif med >= y: # (cmp(med, &y) > -1) + hi = med + else: + return med + n += 1 + if n > 1000: + raise RuntimeError("rational intermediate depth exceeded") diff --git a/continuousprint/storage/rank_test.py b/continuousprint/storage/rank_test.py new file mode 100644 index 00000000..3fac0d8a --- /dev/null +++ b/continuousprint/storage/rank_test.py @@ -0,0 +1,46 @@ +import unittest +from .rank import Rational as R, rational_intermediate + + +class TestRational(unittest.TestCase): + def test_lt(self): + self.assertFalse(R(1, 2) < R(1, 4)) + self.assertTrue(R(1, 2) < R(2, 1)) + + def test_gt(self): + self.assertFalse(R(4, 7) > R(7, 3)) + self.assertTrue(R(4, 7) > R(3, 7)) + + def test_eq(self): + self.assertFalse(R(4, 5) == R(9, 10)) + self.assertTrue(R(4, 5) == R(8, 10)) + + def test_ne(self): + self.assertFalse(R(4, 5) != R(8, 10)) + self.assertTrue(R(4, 5) != R(9, 10)) + + def test_ge(self): + self.assertFalse(R(7, 5) >= R(7, 4)) + self.assertTrue(R(7, 5) >= R(14, 10)) + self.assertTrue(R(7, 5) >= R(13, 10)) + + def test_le(self): + self.assertFalse(R(7, 5) <= R(7, 6)) + self.assertTrue(R(7, 5) <= R(14, 10)) + self.assertTrue(R(7, 5) <= R(7, 4)) + + def test_mediant(self): + self.assertEqual(R(1, 2).mediant(R(1, 1)), R(2, 3)) + + def test_intermediate(self): + # Picking values from https://begriffs.com/posts/2018-03-20-user-defined-order.html#approach-3-true-fractions + for args, want in [ + ((R(0, 1), R(1, 0)), R(1, 1)), # Push on empty + ((R(1, 2), R(1, 1)), R(2, 3)), # Push mid of 2 element list + ((R(1, 2), R(3, 5)), R(4, 7)), # Push early in list + ((R(3, 1), R(4, 1)), R(7, 2)), # Push onto penultimate spot + ((R(4, 1), R(1, 0)), R(5, 1)), # Push onto end + ((R(0, 1), R(1, 4)), R(1, 5)), # Push onto front + ]: + with self.subTest(f"x={args[0]}, y={args[1]}"): + self.assertEqual(rational_intermediate(*args), want) diff --git a/continuousprint/templates/continuousprint_constants.jinja2 b/continuousprint/templates/continuousprint_constants.jinja2 new file mode 100644 index 00000000..a0615bb4 --- /dev/null +++ b/continuousprint/templates/continuousprint_constants.jinja2 @@ -0,0 +1,31 @@ + diff --git a/continuousprint/templates/continuousprint_settings.jinja2 b/continuousprint/templates/continuousprint_settings.jinja2 index 9a48b5da..c469ae8c 100644 --- a/continuousprint/templates/continuousprint_settings.jinja2 +++ b/continuousprint/templates/continuousprint_settings.jinja2 @@ -3,565 +3,32 @@
Continuous Print Queue
- +{% include 'continuousprint_constants.jinja2' %}
-
-
- Printer Profile -

If you tag queue items with profiles, they will be skipped unless they match this profile.

-
-
- -
- -
-
-
- -
- -
-
-
- - After setting a profile, try loading community-contributed scripts on the Scripts & Preprocessors section. - - Network Identity - -

These settings identify the printer to LAN queues and other networked printers.

- -
-
- -
- -
-
-
-
-
- - -
-
- Events -

- Register Scripts & Preprocessors to execute on queue events. -

-

- You can execute multiple scripts in sequence, dragging to change the execution order. -

- -
-

-

- -
-
- -
-
- Script: - -
-
-
- -
-
-
- - - - This hidden element needed for rendering simulation() results; - see CPSettingsEvent class - -
-
- -
-
-
-
-
-
-
GCODE
-

-                
-
-
Notifications & Errors
-

-                
-
- toggle state details -
-
Modified state
-
- Hint: these variables were modified by preprocessors during the simulation. -
-
None
-
-
=
-
-
Simulation initial state
-
- Hint: edit to simulate behavior with different states of the printer, file etc. - this has no effect when the event is fired for real. -
-
- -
-
-
-
- - -
-
-
- -
-
- Scripts -

- Scripts are made with GCODE and are attached to Events to run whenever that event occurs. -

-

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

- -
-

- Two or more scripts have the same name. -

-

- All scripts must have a unique name for the configuration to be valid. -

-
- -
-
-
- -
-
-
-
Fires on
-
-
- -
-
-
-
-
-
-
-
-
-
-
- - -
-
-
-
- -
- - - - - -
- - Preprocessors -

- Preprocessors are optional Python-like expressions that change the behavior of Scripts assigned in the Events tab. Use these to add special running conditions or to inject dynamic values into your scripts. -

-

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

- -
-

- Two or more preprocessors have the same name. -

-

- All preprocessors must have a unique name for the configuration to be valid. -

-
- -
-
-
- -
-
-
-
Fires on
-
-
- -
-
-
-
-
-
-
-
-
-
-
- - -
-
-
-
- -
- - - -
-
-
- -
-
-
- Failure Recovery -
-

Plugin Needed: Failure recovery requires Obico version ≥ 1.8.11, but the plugin does not appear to be installed.

-

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

-
-
-

- Failure recovery is enabled because Obico is installed. -

-

- Read more about failure recovery settings in the Failure Recovery guide. -

-
- -
-
- - seconds ago -
-
-
-
- -
-
- - retries -
-
-
-
-
- -
- Material Selection -
-

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

-

Read more about this feature in the Material Selection guide.

-
-
-

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

-

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

-
-
-
-
- - -
-
- Queues -

- Renaming a queue will delete the original queue and create a new one - jobs will not transfer over. -

-

- Changing the address of a queue requires a restart before the new address is used. -

-

- Read this guide to learn how to best configure network queues. -

-
-

- One or more queues have an invalid address configured. -

-

- All queues (other than the local queue) must have an address of "auto" or of the form ip_address:port, with a port value of 5000 or higher. -

-
-
-

- One or more queues needs a name. -

-
-
-
Name
-
Address:Port
-
Strategy
-
-
-
-
- -
- -
-
- -
-
- -
-
-
-
-
-
- -
-
- - -
-
- Behavior -
-
- -
- -
-
- -
- -
- -
-
- -
- -
- -
-
-
- -
- -
-
- -
- -
- -
- -
- -
-
- -
- -
- -
-
-
- - Bed Cooldown Settings -

- Some printers do not respect the M190 (Wait for Bed Temperature) command (see this bug). -

-

Enable this if you use M190 commands in the Clearing script but your printer isn't properly waiting.

- -
- -
- -
-
- -
-

- When the print is finished, OctoPrint will fire the Bed Cooldown event (see above), - turn the heated bed off and monitor the bed temperature. -

-

- After either the Bed Cooldown Threshold or Bed Cooldown Timeout is - met, the Print Success event will trigger. -

- -
- -
-
- - °C -
-
-
-
- -
-
- - min -
-
-
-
-
-
- -
-
- Help -

If you need help installing, configuring, or using Continuous Print, start by looking through the extensive documentation.

- - Report an issue -

- If you encounter errors or unexpected behavior, go to the GitHub issues page and see if there's a similar open issue to the problem you're having. -

-

- If you can't find any, report a new issue and make sure to include a sysinfo bundle as described in the issue text box. -

- - Features and Feedback -

- Feature requests are also reported via GitHub - click here to start a new feature request. -

- -

If you're looking to give feedback without any features in mind - for instance, if you found a useful way to use CPQ and want to share - try starting a Discussion. -

- - Donations and Support -

- I'm a solo developer maintaining all of Continuous Print. -

-

- Please help caffienate me so I can continue to fix bugs and release awesome new features! -

- Buy Me a Coffee at ko-fi.com - or - Become a Patron - -
-
+ {% include 'plugin_continuousprint/settings/profile_tab.jinja2' %} + {% include 'plugin_continuousprint/settings/events_tab.jinja2' %} + {% include 'plugin_continuousprint/settings/scripts_tab.jinja2' %} + {% include 'plugin_continuousprint/settings/integrations_tab.jinja2' %} + {% include 'plugin_continuousprint/settings/queues_tab.jinja2' %} + {% include 'plugin_continuousprint/settings/behavior_tab.jinja2' %} + {% include 'plugin_continuousprint/settings/help_tab.jinja2' %} +
- + diff --git a/continuousprint/templates/continuousprint_tab.jinja2 b/continuousprint/templates/continuousprint_tab.jinja2 index b8928553..c9b3fa43 100644 --- a/continuousprint/templates/continuousprint_tab.jinja2 +++ b/continuousprint/templates/continuousprint_tab.jinja2 @@ -1,21 +1,4 @@ - +{% include "plugin_continuousprint/tab/dialogs.jinja2" %}
-
-
-
Status
-
Duration
-
Date
-
Time
-
-
- -
-
: -
-
-
-
- - -
-
- - -
- - -
-
-
-
-
-
- -
-
- -
- -

- Encountered the following exceptions: -

-
    -
  • -
-

- Please file a bug report - be sure to follow the template instructions to generate a sysinfo bundle. -

-
- -
- - spool(s) configured without a material. -
These will be excluded from material selection until you configure them (in the Spools tab) and reload. -
- -
- Hint: draft jobs will not be scheduled for printing until they are saved. -
- -
-
-
-
- -
- - - -
-
-
- - - - - -
-
- -
-
Job?
-
Progress?
-
- -
-
- Hint: click New Job above or the next to a file to add it to the queue. -
-
- Hint: drag a job here from the local queue to send it to printers on the network. -
-
-
- -
- -
- -
-
- - - - - - x - - - ? - -  →  - - - -
- -
-
-
-
-
-
-
-
-
- -
-
Set?
- -
- ? -
- -
- -
-
-
-
- -

- - - - - -

-
- -
-
- -
- -
-
-
-
-
- - -
- - - - -
- -
-
-
-
-
-
-
Full path:
-
- Profiles: - any - - - - - - - -
-
Hint: Print start and ending scripts can be adjusted in the plugin settings
-
-
-

Materials

-
-
- - -
-
-
-
-
- Hint: Install the SpoolManager plugin to enable material selection. -
-
- Hint: Add spools, then reload to enable material selection. -
- -
-
-
-
-
-
-
-
-
?
-
-
-
-
-
-
-
-
-
Cancel
-
Save
-
-
-
-
-
- -
- show/hide queue stats -
-
-
- -
- -
-
-
-
?
-
-
-
-
-
-
-
-
-
- -
-
-
-
- Hint: Set up additional queues (including LAN queues) in the plugin settings. -
+ {% include "plugin_continuousprint/tab/alerts.jinja2" %} + {% include "plugin_continuousprint/tab/history.jinja2" %} + {% include "plugin_continuousprint/tab/queues.jinja2" %}
diff --git a/continuousprint/templates/settings/behavior_tab.jinja2 b/continuousprint/templates/settings/behavior_tab.jinja2 new file mode 100644 index 00000000..b57b2b05 --- /dev/null +++ b/continuousprint/templates/settings/behavior_tab.jinja2 @@ -0,0 +1,111 @@ +
+
+ Behavior +
+
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+
+ + Bed Cooldown Settings +

+ Some printers do not respect the M190 (Wait for Bed Temperature) command (see this bug). +

+

Enable this if you use M190 commands in the Clearing script but your printer isn't properly waiting.

+ +
+ +
+ +
+
+ +
+

+ When the print is finished, OctoPrint will fire the Bed Cooldown event (see above), + turn the heated bed off and monitor the bed temperature. +

+

+ After either the Bed Cooldown Threshold or Bed Cooldown Timeout is + met, the Print Success event will trigger. +

+ +
+ +
+
+ + °C +
+
+
+
+ +
+
+ + min +
+
+
+
+
+
diff --git a/continuousprint/templates/settings/events_tab.jinja2 b/continuousprint/templates/settings/events_tab.jinja2 new file mode 100644 index 00000000..367d9245 --- /dev/null +++ b/continuousprint/templates/settings/events_tab.jinja2 @@ -0,0 +1,97 @@ +
+
+ Events +

+ Register Scripts & Preprocessors to execute on queue events. +

+

+ You can execute multiple scripts in sequence, dragging to change the execution order. +

+ +
+

+

+ +
+
+ +
+
+ Script: + +
+
+
+ +
+
+
+ + + + This hidden element needed for rendering simulation() results; + see CPSettingsEvent class + +
+
+ +
+
+
+
+
+
+
GCODE
+

+              
+
+
Notifications & Errors
+

+              
+
+ toggle state details +
+
Modified state
+
+ Hint: these variables were modified by preprocessors during the simulation. +
+
None
+
+
=
+
+
Simulation initial state
+
+ Hint: edit to simulate behavior with different states of the printer, file etc. - this has no effect when the event is fired for real. +
+
+ +
+
+
+
+ + +
+
+
diff --git a/continuousprint/templates/settings/help_tab.jinja2 b/continuousprint/templates/settings/help_tab.jinja2 new file mode 100644 index 00000000..6358547a --- /dev/null +++ b/continuousprint/templates/settings/help_tab.jinja2 @@ -0,0 +1,34 @@ +
+
+ Help +

If you need help installing, configuring, or using Continuous Print, start by looking through the extensive documentation.

+ + Report an issue +

+ If you encounter errors or unexpected behavior, go to the GitHub issues page and see if there's a similar open issue to the problem you're having. +

+

+ If you can't find any, report a new issue and make sure to include a sysinfo bundle as described in the issue text box. +

+ + Features and Feedback +

+ Feature requests are also reported via GitHub - click here to start a new feature request. +

+ +

If you're looking to give feedback without any features in mind - for instance, if you found a useful way to use CPQ and want to share - try starting a Discussion. +

+ + Donations and Support +

+ I'm a solo developer maintaining all of Continuous Print. +

+

+ Please help caffienate me so I can continue to fix bugs and release awesome new features! +

+ Buy Me a Coffee at ko-fi.com + or + Become a Patron + +
+
diff --git a/continuousprint/templates/settings/integrations_tab.jinja2 b/continuousprint/templates/settings/integrations_tab.jinja2 new file mode 100644 index 00000000..027ef3e5 --- /dev/null +++ b/continuousprint/templates/settings/integrations_tab.jinja2 @@ -0,0 +1,69 @@ +
+
+
+ Failure Recovery +
+

Plugin Needed: Failure recovery requires Obico version ≥ 1.8.11, but the plugin does not appear to be installed.

+

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

+
+
+

+ Failure recovery is enabled because Obico is installed. +

+

+ Read more about failure recovery settings in the Failure Recovery guide. +

+
+ +
+
+ + seconds ago +
+
+
+
+ +
+
+ + retries +
+
+
+
+
+ +
+ Material Selection +
+

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

+

Read more about this feature in the Material Selection guide.

+
+
+

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

+

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

+
+
+ +
+ Network Queues +
+

Plugin Needed: Network queues require PeerPrint, but the plugin does not appear to be installed or enabled.

+

Read more about this feature in the Network Queues guide.

+
+
+

+ Network queues are enabled because PeerPrint is installed and enabled. +

+

+ Read more about network queues in the Network Queues guide. +

+
+
+
+
diff --git a/continuousprint/templates/settings/profile_tab.jinja2 b/continuousprint/templates/settings/profile_tab.jinja2 new file mode 100644 index 00000000..9e9ff078 --- /dev/null +++ b/continuousprint/templates/settings/profile_tab.jinja2 @@ -0,0 +1,26 @@ +
+
+ Printer Profile +

If you tag queue items with profiles, they will be skipped unless they match this profile.

+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ + After setting a profile, try loading community-contributed scripts on the Scripts & Preprocessors section. +
+
diff --git a/continuousprint/templates/settings/queues_tab.jinja2 b/continuousprint/templates/settings/queues_tab.jinja2 new file mode 100644 index 00000000..ce4c1b34 --- /dev/null +++ b/continuousprint/templates/settings/queues_tab.jinja2 @@ -0,0 +1,30 @@ +
+ Queues + +

Drag to reorder queues, or click the arrows to expand and configure them.

+

Your printer will attempt to print all printable jobs in the top queue, then the next one down, etc.

+
+
Network
+
Strategy?
+
Enabled?
+
+
+
+
+
+ +

+
+ +
+
+ +
+
+
+
+
+ +
diff --git a/continuousprint/templates/settings/scripts_tab.jinja2 b/continuousprint/templates/settings/scripts_tab.jinja2 new file mode 100644 index 00000000..9f1bf66c --- /dev/null +++ b/continuousprint/templates/settings/scripts_tab.jinja2 @@ -0,0 +1,121 @@ +
+
+ Scripts +

+ Scripts are made with GCODE and are attached to Events to run whenever that event occurs. +

+

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

+ +
+

+ Two or more scripts have the same name. +

+

+ All scripts must have a unique name for the configuration to be valid. +

+
+ +
+
+
+ +
+
+
+
Fires on
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ + +
+
+
+
+ +
+ + + + + +
+ + Preprocessors +

+ Preprocessors are optional Python-like expressions that change the behavior of Scripts assigned in the Events tab. Use these to add special running conditions or to inject dynamic values into your scripts. +

+

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

+ +
+

+ Two or more preprocessors have the same name. +

+

+ All preprocessors must have a unique name for the configuration to be valid. +

+
+ +
+
+
+ +
+
+
+
Fires on
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ + +
+
+
+
+ +
+ + + +
+
+
diff --git a/continuousprint/templates/tab/alerts.jinja2 b/continuousprint/templates/tab/alerts.jinja2 new file mode 100644 index 00000000..13f1907d --- /dev/null +++ b/continuousprint/templates/tab/alerts.jinja2 @@ -0,0 +1,23 @@ + +
+ +

+ Encountered the following exceptions: +

+
    +
  • +
+

+ Please file a bug report - be sure to follow the template instructions to generate a sysinfo bundle. +

+
+ +
+ + spool(s) configured without a material. +
These will be excluded from material selection until you configure them (in the Spools tab) and reload. +
+ +
+ Hint: draft jobs will not be scheduled for printing until they are saved. +
diff --git a/continuousprint/templates/tab/dialogs.jinja2 b/continuousprint/templates/tab/dialogs.jinja2 new file mode 100644 index 00000000..6e88fd39 --- /dev/null +++ b/continuousprint/templates/tab/dialogs.jinja2 @@ -0,0 +1,18 @@ + diff --git a/continuousprint/templates/tab/history.jinja2 b/continuousprint/templates/tab/history.jinja2 new file mode 100644 index 00000000..3b958ef3 --- /dev/null +++ b/continuousprint/templates/tab/history.jinja2 @@ -0,0 +1,33 @@ +
+
+
Status
+
Duration
+
Date
+
Time
+
+
+ +
+
: +
+
+
+
+ + +
+
+ + +
+ + +
+
+
+
+
+
+ +
+
diff --git a/continuousprint/templates/tab/queue_action_bar.jinja2 b/continuousprint/templates/tab/queue_action_bar.jinja2 new file mode 100644 index 00000000..1727d8fe --- /dev/null +++ b/continuousprint/templates/tab/queue_action_bar.jinja2 @@ -0,0 +1,37 @@ +
+
+ +
+ + + +
+
+
+ + + + + +
+
diff --git a/continuousprint/templates/tab/queue_sets.jinja2 b/continuousprint/templates/tab/queue_sets.jinja2 new file mode 100644 index 00000000..09e33f49 --- /dev/null +++ b/continuousprint/templates/tab/queue_sets.jinja2 @@ -0,0 +1,103 @@ +
+
+
+
+ +

+ + + + + +

+
+ +
+
+ +
+ +
+
+
+
+
+ + +
+ + + + +
+ +
+
+
+
+
+
+
Full path:
+
+ Profiles: + any + + + + + + + +
+
Hint: Print start and ending scripts can be adjusted in the plugin settings
+
+
+

Materials

+
+
+ + +
+
+
+
+
+ Hint: Install the SpoolManager plugin to enable material selection. +
+
+ Hint: Add spools, then reload to enable material selection. +
+ +
+
+
+
+
+
+
+
+
?
+
+
+
+
+
+
+
+
+
Cancel
+
Save
+
+ diff --git a/continuousprint/templates/tab/queue_totals.jinja2 b/continuousprint/templates/tab/queue_totals.jinja2 new file mode 100644 index 00000000..002502be --- /dev/null +++ b/continuousprint/templates/tab/queue_totals.jinja2 @@ -0,0 +1,21 @@ +
+ show/hide queue stats +
+
+
+ +
+ +
+
+
+
?
+
+
+
+
+
+
+
+
+
diff --git a/continuousprint/templates/tab/queues.jinja2 b/continuousprint/templates/tab/queues.jinja2 new file mode 100644 index 00000000..fd4dec5f --- /dev/null +++ b/continuousprint/templates/tab/queues.jinja2 @@ -0,0 +1,76 @@ +
+
+ {% include "plugin_continuousprint/tab/queue_action_bar.jinja2" %} +
+
Job?
+
Progress?
+
+ +
+
+ Hint: click New Job above or the next to a file to add it to the queue. +
+
+ Hint: drag a job here from the local queue to send it to printers on the network. +
+
+
+ +
+ +
+ +
+
+ + + + + + x + + + ? + +  →  + + + +
+ +
+
+
+
+
+
+
+ +
+
+
+ Acquired by: +
+ + +
+
Set?
+ +
+ ? +
+ +
+ + {% include "plugin_continuousprint/tab/queue_sets.jinja2" %} +
+
+
+ {% include "plugin_continuousprint/tab/queue_totals.jinja2" %} +
+
+
+ +
+Hint: Set up additional queues (including LAN queues) in the plugin settings. +
diff --git a/docker-compose.yaml b/docker-compose.yaml index 8ffb69b5..a1e602f0 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -4,10 +4,11 @@ services: build: . ports: - "5000:5000" # UI port - # Note discovery port 37020 and lan queue ports handled through docker network (so non-docker processes cannot auto join the queue) + - "8334:8334" # PeerPrint UI port volumes: - ".:/home/oprint/continuousprint" - "./volume:/home/oprint/.octoprint" + - "/tmp:/tmp" environment: - "PYTHONUNBUFFERED=1" hostname: "octoprint" @@ -18,11 +19,15 @@ services: build: . ports: - "5001:5000" # UI port + - "8335:8334" # PeerPrint UI port volumes: - ".:/home/oprint/continuousprint" - "./volume2:/home/oprint/.octoprint" + - "/tmp:/tmp" environment: - "PYTHONUNBUFFERED=1" + networks: + - preprintservice networks: preprintservice: diff --git a/docs/index.md b/docs/index.md index 04ec2650..3da115af 100644 --- a/docs/index.md +++ b/docs/index.md @@ -36,8 +36,8 @@
If you're looking for source code, head over to the github repository.
Use the nav section on the left to learn more.
- diff --git a/docs/lan-queues.md b/docs/network-queues.md similarity index 60% rename from docs/lan-queues.md rename to docs/network-queues.md index 1be33e1b..59c191f0 100644 --- a/docs/lan-queues.md +++ b/docs/network-queues.md @@ -1,6 +1,6 @@ -# LAN Queues +# Network Queues -**A LAN queue is a local network queue that multiple 3D printers print from.** +**A Network Queue is a queue that multiple 3D printers print from via the local or world internet.** It's not unusual to have multiple 3D printers - in home workshops, in the prototyping industry, and even for 3D printing services (e.g. Shapeways) and manufacturers that manage hundreds/thousands of them. @@ -8,50 +8,62 @@ In addition to providing a local queue by default, Continuous Print also configu !!! Warning - LAN queues are intended for trusted, local (LAN) networks only - not for cross-network (WAN) use cases. Using LAN queues across networks is insecure and strongly discouraged. + Network Queues are intended for **trusted networks** only. Guard your security keys closely and don't share them publicly, or else anyone can print anything on your printer network! !!! Info - **If you upgraded from v2.0.0 or earlier**, the default LAN queue will not be added (a compatibility measure). If you don't see the default LAN queue, please read and follow [Additional LAN Queues](#additional-lan-queues) for instructions on how to set up your own. + **If you upgraded from v2.0.0 or earlier**, the default LAN Queue will not be added (a compatibility measure). If you don't see the default LAN Queue, please read and follow [Additional Network Queues](#additional-network-queues) for instructions on how to set up your own. ## Setup -By default, Continuous Print creates a queue called "LAN" that printers on the network will automatically join. +To use network queues, you must first install [PeerPrint](https://github.com/smartin015/peerprint) and enable the default LAN queue that comes with installation: -**You will need a working instance of OctoPrint (with the Continuous Print plugin installed) for every printer you wish to have join the queue.** +1. Go to `Settings > Plugin Manager > Get More`, search for `PeerPrint` and click the `Install` button. +2. Wait for the dialog to complete, then restart OctoPrint. +3. Once OctoPrint has restarted, go to `Settings > Continuous Print > Queues`. You should see a new queue labeled `LAN`. Click the checkbox under `Enabled`, then click `Save`. + +You should now see the new LAN queue below the usual (now labeled "local") queue. + +**You will need a working instance of OctoPrint (with the Continuous Print plugin installed) for every printer you wish to have join the queue.** Go ahead and repeat the above process for each printer you wish to set up. See what printers are connected to the queue by hovering your mouse over the queue name: ![Hover Example](/hover_viz.png) -If printers are missing from this list, make sure they have OctoPrint and Continous Print installed and enabled, and that they are on the same local network. See [Additional LAN Queues](#additional-lan-queues) below if you need more advanced LAN queue configuration. +If printers are missing from this list, make sure they have the same versions of OctoPrint and Continous Print installed and enabled, and that they are on networks that can reach each other. This means: + +* same LAN for local PeerPrint networks +* both WAN-connected for global networks +* sharing at least one in-common [network driver](https://docs.docker.com/network/) if containerized (Docker, podman, etc.) + +See [Additional Network Queues](#additional-network-queues) below if you need more advanced Network Queue configuration. ## Submit a job -Submitting a job is as simple as dragging it from the "local" queue to your LAN queue. After confirming that you wish to submit your job, the job will be moved out of the local queue and into the LAN queue. +Submitting a job is as simple as dragging it from the "local" queue to your Network Queue. After a brief pause to transfer data, the job will be moved out of the local queue and into the Network Queue. ## Undo job submission -Submission can be undone simply by dragging the job back to the "local" queue. This can be done by any printer in the LAN queue. +Submission can be undone simply by dragging the job back to the "local" queue. This can be done by any printer in the Network Queue, but of course it will be moved to that printer's local queue only (local queues aren't shared). !!! Info The printer receiving the local job may not have the local `.gcode` files initially, so the files are fetched and copied to `ContinuousPrint/imports//` and the Set paths are auto-updated accordingly. -## Edit a LAN queue job +## Edit a Network Queue job -You can edit jobs in LAN queues just as in Local queues - see [Queuing Basics](advanced-queuing.md) for more details. +You can edit jobs in Network Queues just as in Local queues - see [Queuing Basics](advanced-queuing.md) for more details. -## Cancel a LAN queue job +## Cancel a Network Queue job -1. Click the checkbox next to the job in your LAN queue. +1. Click the checkbox next to the job in your Network Queue. 1. Click the trash can icon that appears. -The job will disappear from the LAN queue and no longer be printed. Note that a job cannot be deleted if a printer is actively printing it. +The job will disappear from the Network Queue and no longer be printed. Note that a job cannot be deleted if a printer is actively printing it. ## Details -### LAN Queues manage Jobs, not Sets/Files +### Network Queues manage Jobs, not Sets/Files Queues operate at the level of a Job (see [Sets and Jobs](/advanced-queuing/#sets-and-jobs) for disambiguation). All work described by the job will be completed by the printer which acquires it. In other words, **the work within a job will not be distributed across printers**. This is to ensure compatability with future work to support WAN / decentralized network printing, ensuring that all prints of any job are guaranteed to end up in the same physical location. @@ -71,23 +83,25 @@ The overall strategy *between* queues is currently in-order, i.e. all prints in 3D print slicers generate a `*.gcode` file for a particular make and model of 3D printer - running that file on a different printer than the one for which it was sliced would likely damage that printer (or maybe just fail to print properly). -This can be mitigated in one of two ways: +This can be mitigated in several ways: -1. Use exactly the same make and model of printer for all members of the LAN queue +1. Use exactly the same make and model of printer for all members of the Network Queue 2. Configure the correct [profiles](/printer-profiles) for all Sets so that each type of printer has its own compatible `*.gcode` files to fully print the job. +3. Create jobs using `STL` model files instead of `*.gcode`. This requires [Automatic Slicing](/auto-slicer) to be correctly configured, but ensures that only trusted GCODE will run on your printer. !!! Info For users of [Kiri:Moto slicer](https://grid.space/kiri/), the second mitigation is automatic: gcode files are automatically analyzed and the printer profile applied when they are added to the queue. See the [Printer Profiles](/printer-profiles) page for more details. -### Additional LAN Queues +### Additional Network Queues -It's straightforward to add additional LAN queues, or remove/modify the default LAN queue if desired: +You can add additional Network Queues, or remove/modify the default Network Queue if desired: 1. Open OctoPrint's settings page -2. Click through to Continuous Print +2. Click through to PeerPrint +3. 3. Click the Queues button to go to the queue settings page. -4. Click the "Add Queue" button to add a new LAN queue. +4. Click the "Add Queue" button to add a new Network Queue. 5. Fill in the inputs, but keep in mind: * Each queue must have a unique name (which cannot be `local` and `archive` - these are reserved) * Address:Port must be either set to `auto` or have the form of `ip_address:port` (e.g. `192.168.1.43:6789`) @@ -95,7 +109,7 @@ It's straightforward to add additional LAN queues, or remove/modify the default * If `auto` is used, an IP address and port will be selected automatically - this will probably work, but may not be correct for more complicated network setups. * Access control may be a factor if you're using a port number below 1024 (see [privileged ports](https://www.w3.org/Daemon/User/Installation/PrivilegedPorts.html)) * You may experience silent failures if you specify a port that's already in use by another process. - * All LAN queues are only visible to other devices on the same network, unless you've taken steps to expose ports (NOT recommended). + * All Network Queues are only visible to other devices on the same network, unless you've taken steps to expose ports (NOT recommended). 6. When you've finished configuring your queues, click `Save`. If everything is working properly, you'll see the changes reflected in the queues on the Continuous Print tab with the queue(s) you added. It will show no other peers connected to it, but that's because we still have to set them up. Complete steps 1-6 for all remaining printers, and you should see them as peers when you look at the header of the queue. diff --git a/docs/printer-profiles.md b/docs/printer-profiles.md index 4c2313f4..f82675bf 100644 --- a/docs/printer-profiles.md +++ b/docs/printer-profiles.md @@ -16,12 +16,13 @@ When you click `Save`, this profile will be associated with your printer. ## Automatic profile assignment -Starting with version `2.1.0`, Continuous Print will attempt to automatically infer the correct printer profile for gcode files added to the queue. This currently only works the following slicers: +Starting with version `2.1.0`, Continuous Print will attempt to automatically infer the correct printer profile for gcode files added to the queue. Slicer support is as follows: * [Kiri:Moto slicer](https://grid.space/kiri/) * [PrusaSlicer](https://www.prusa3d.com/page/prusaslicer_424/) -* TODO [Ultimaker Cura](https://ultimaker.com/software/ultimaker-cura) -* TODO [Simplify3D](https://www.simplify3d.com/) +* [Simplify3D](https://www.simplify3d.com/) +* [Slic3r](https://slic3r.org/) (requires [manual addition to the Notes field](https://github.com/slic3r/Slic3r/issues/5148)) +* [Ultimaker Cura](https://ultimaker.com/software/ultimaker-cura) (still [under discussion](https://github.com/Ultimaker/Cura/issues/14283)) If you want your slicer to be supported, [open a Feature Request](https://github.com/smartin015/continuousprint/issues/new?assignees=&labels=&template=feature_request.md) and include an example gcode script that you've sliced as an example. diff --git a/mkdocs.yml b/mkdocs.yml index ffcd0fc1..7e03d3bd 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -28,7 +28,7 @@ nav: - 'advanced-queuing.md' - 'job-files.md' - 'failure-recovery.md' - - 'lan-queues.md' + - 'network-queues.md' - 'material-selection.md' - 'printer-profiles.md' - 'managed-bed-cooldown.md' diff --git a/setup.py b/setup.py index 33c6b7c3..b60d73da 100644 --- a/setup.py +++ b/setup.py @@ -33,7 +33,7 @@ plugin_license = "AGPLv3" # Any additional requirements besides OctoPrint should be listed here -plugin_requires = ["peewee<4", "peerprint==0.1.0", "asteval==0.9.28"] +plugin_requires = ["peewee<4", "asteval==0.9.28"] ### -------------------------------------------------------------------------------------------------------------------- ### More advanced options that you usually shouldn't have to touch follow after this point