diff --git a/projects/tentacles/src/fastmail.py b/projects/tentacles/src/fastmail.py index bf8950e..fdb0ba1 100644 --- a/projects/tentacles/src/fastmail.py +++ b/projects/tentacles/src/fastmail.py @@ -1,10 +1,10 @@ #!/usr/bin/env python3 +import smtplib from email import encoders from email.mime.base import MIMEBase from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText -import smtplib class FastMailSMTP(smtplib.SMTP_SSL): diff --git a/projects/tentacles/src/gcode.py b/projects/tentacles/src/gcode.py index 11636c7..ac837f3 100644 --- a/projects/tentacles/src/gcode.py +++ b/projects/tentacles/src/gcode.py @@ -1,12 +1,11 @@ #!/usr/bin/env python3 -from pathlib import Path import re +from pathlib import Path from typing import Optional, Tuple from attrs import define - OPTION_PATTERN = re.compile("; (?P[a-z0-9_]+) = (?P.*?)\n") diff --git a/projects/tentacles/src/tentacles/__main__.py b/projects/tentacles/src/tentacles/__main__.py index 53d1186..dfc4a91 100644 --- a/projects/tentacles/src/tentacles/__main__.py +++ b/projects/tentacles/src/tentacles/__main__.py @@ -2,14 +2,20 @@ """The core app entrypoint.""" -from datetime import datetime, timedelta import logging +import os +from contextlib import closing +from datetime import datetime, timedelta +from importlib.resources import files from pathlib import Path -import tomllib +from shutil import copyfile import cherrypy import click +import tomllib from flask import Flask, request + +from tentacles import workers from tentacles.blueprints import ( admin_ui, api, @@ -18,11 +24,7 @@ from tentacles.blueprints import ( user_ui, ) from tentacles.db import Db -from tentacles.globals import _ctx, Ctx, ctx -from tentacles import workers -from contextlib import closing -from tentacles.blueprints.util import salt - +from tentacles.globals import Ctx, _ctx, ctx log = logging.getLogger(__name__) @@ -136,6 +138,28 @@ def serve(hostname: str, port: int, config: Path, trace: bool): with closing(db_factory(app)) as db: db.migrate() + # Register embedded gcode files as usable files + with closing(db_factory(app)) as db: + for f in files("tentacles.gcode").iterdir(): + print(type(f), repr(f)) + if f.is_file() and f.name.endswith(".gcode"): + sanitized_path = os.path.join( + "$ROOT_FOLDER", + app.config["UPLOAD_FOLDER"], + f.name, + ) + real_path = sanitized_path.replace( + "$ROOT_FOLDER", app.config["ROOT_FOLDER"] + ) + if not os.path.exists(real_path): + copyfile(f, real_path) + db.create_file( + uid=None, + filename=f.name, + path=sanitized_path, + ) + log.info("Registered calibration script %s", f.name) + # Configuring cherrypy is kinda awful cherrypy.server.unsubscribe() server = cherrypy._cpserver.Server() diff --git a/projects/tentacles/src/tentacles/blueprints/admin_ui.py b/projects/tentacles/src/tentacles/blueprints/admin_ui.py index 82cf66b..dcc01e4 100644 --- a/projects/tentacles/src/tentacles/blueprints/admin_ui.py +++ b/projects/tentacles/src/tentacles/blueprints/admin_ui.py @@ -2,8 +2,6 @@ import logging -from .api import requires_admin - from flask import ( Blueprint, flash, @@ -11,8 +9,10 @@ from flask import ( render_template, request, ) + from tentacles.globals import ctx +from .api import requires_admin log = logging.getLogger(__name__) BLUEPRINT = Blueprint("admin", __name__) @@ -96,9 +96,9 @@ def handle_add_printer(): flash("Printer created") return redirect("/admin/printers") - except Exception as e: + except Exception: log.exception("Failed to create printer") - flash(f"Unable to create printer", category="error") + flash("Unable to create printer", category="error") return render_template("printers.html.j2") diff --git a/projects/tentacles/src/tentacles/blueprints/api.py b/projects/tentacles/src/tentacles/blueprints/api.py index a16eef6..d241cbc 100644 --- a/projects/tentacles/src/tentacles/blueprints/api.py +++ b/projects/tentacles/src/tentacles/blueprints/api.py @@ -2,13 +2,13 @@ """API endpoints supporting the 'ui'.""" -from hashlib import sha3_256 import os +from hashlib import sha3_256 from typing import Optional from flask import Blueprint, current_app, request -from tentacles.globals import ctx +from tentacles.globals import ctx BLUEPRINT = Blueprint("api", __name__, url_prefix="/api") @@ -96,12 +96,11 @@ def create_file(location: Optional[str] = None): current_app.config["UPLOAD_FOLDER"], sanitized_filename, ) - if os.path.exists(sanitized_path): - return {"error": "file exists already"}, 409 - real_path = sanitized_path.replace( "$ROOT_FOLDER", current_app.config["ROOT_FOLDER"] ) + if os.path.exists(real_path): + return {"error": "file exists already"}, 409 print(file.filename, real_path) diff --git a/projects/tentacles/src/tentacles/blueprints/file_ui.py b/projects/tentacles/src/tentacles/blueprints/file_ui.py index b5f05da..e92d069 100644 --- a/projects/tentacles/src/tentacles/blueprints/file_ui.py +++ b/projects/tentacles/src/tentacles/blueprints/file_ui.py @@ -3,9 +3,6 @@ import logging import os -from .api import create_file -from .util import requires_auth - from flask import ( Blueprint, flash, @@ -14,8 +11,11 @@ from flask import ( request, send_file, ) + from tentacles.globals import ctx +from .api import create_file +from .util import requires_auth log = logging.getLogger(__name__) BLUEPRINT = Blueprint("files", __name__) diff --git a/projects/tentacles/src/tentacles/blueprints/job_ui.py b/projects/tentacles/src/tentacles/blueprints/job_ui.py index 03d6466..f73d9f8 100644 --- a/projects/tentacles/src/tentacles/blueprints/job_ui.py +++ b/projects/tentacles/src/tentacles/blueprints/job_ui.py @@ -2,8 +2,6 @@ import logging -from .util import requires_auth - from flask import ( Blueprint, flash, @@ -11,14 +9,19 @@ from flask import ( render_template, request, ) + from tentacles.globals import ctx +from .util import requires_auth log = logging.getLogger(__name__) BLUEPRINT = Blueprint("jobs", __name__) def maybe(f, x): + if x == "None": + return None + if x is not None: return f(x) diff --git a/projects/tentacles/src/tentacles/blueprints/user_ui.py b/projects/tentacles/src/tentacles/blueprints/user_ui.py index 7a7a8eb..a53e48f 100644 --- a/projects/tentacles/src/tentacles/blueprints/user_ui.py +++ b/projects/tentacles/src/tentacles/blueprints/user_ui.py @@ -1,10 +1,8 @@ #!/usr/bin/env python3 -from datetime import timedelta import logging import re - -from .util import is_logged_in, salt +from datetime import timedelta from flask import ( Blueprint, @@ -14,8 +12,10 @@ from flask import ( render_template, request, ) + from tentacles.globals import ctx +from .util import is_logged_in, salt log = logging.getLogger(__name__) BLUEPRINT = Blueprint("user", __name__) @@ -121,7 +121,7 @@ def post_register(): return render_template("register.html.j2") - except Exception as e: + except Exception: log.exception("Error encountered while registering a user...") flash("Unable to register that username", category="error") diff --git a/projects/tentacles/src/tentacles/blueprints/util.py b/projects/tentacles/src/tentacles/blueprints/util.py index efeb5d1..c04bbb1 100644 --- a/projects/tentacles/src/tentacles/blueprints/util.py +++ b/projects/tentacles/src/tentacles/blueprints/util.py @@ -3,8 +3,8 @@ import logging from flask import current_app, flash, redirect -from tentacles.globals import ctx +from tentacles.globals import ctx log = logging.getLogger(__name__) diff --git a/projects/tentacles/src/tentacles/db.py b/projects/tentacles/src/tentacles/db.py index dafb25b..c029e01 100644 --- a/projects/tentacles/src/tentacles/db.py +++ b/projects/tentacles/src/tentacles/db.py @@ -1,13 +1,13 @@ #!/usr/bin/env python3 +import logging +import sqlite3 from collections import namedtuple from contextlib import contextmanager from datetime import datetime, timedelta from hashlib import sha3_256 from importlib.resources import files from inspect import signature -import logging -import sqlite3 from time import sleep from types import GeneratorType, new_class from typing import Optional @@ -18,7 +18,6 @@ from aiosql.aiosql import ( from aiosql.queries import Queries from aiosql.query_loader import QueryLoader - _sqlite = get_adapter("sqlite3") _loader = QueryLoader(_sqlite, None) _queries = Queries(_sqlite, kwargs_only=False) diff --git a/projects/tentacles/src/tentacles/globals.py b/projects/tentacles/src/tentacles/globals.py index 80c2c3c..e477672 100644 --- a/projects/tentacles/src/tentacles/globals.py +++ b/projects/tentacles/src/tentacles/globals.py @@ -3,9 +3,10 @@ from contextvars import ContextVar from attrs import define -from tentacles.db import Db from werkzeug.local import LocalProxy +from tentacles.db import Db + @define class Ctx: diff --git a/projects/tentacles/src/tentacles/sql/jobs.sql b/projects/tentacles/src/tentacles/sql/jobs.sql index 19973f4..976aeff 100644 --- a/projects/tentacles/src/tentacles/sql/jobs.sql +++ b/projects/tentacles/src/tentacles/sql/jobs.sql @@ -35,6 +35,12 @@ ALTER TABLE jobs ADD COLUMN color_id INTEGER DEFAULT (NULL); -- name: migration-0003-jobs-add-continuous# ALTER TABLE jobs ADD COLUMN continuous BOOLEAN DEFAULT (FALSE); +-- name: migration-0004-jobs-add-mapped-at# +ALTER TABLE jobs ADD COLUMN mapped_at TEXT; + +-- name: migration-0005-jobs-add-mapped-at# +ALTER TABLE jobs ADD COLUMN requested_at TEXT; + -- name: create-job^ INSERT INTO jobs ( user_id @@ -42,6 +48,7 @@ INSERT INTO jobs ( , color_id , printer_id , continuous + , requested_at ) VALUES ( :uid @@ -49,6 +56,7 @@ VALUES ( , :cid , :pid , :cont + , datetime('now') ) RETURNING * @@ -125,15 +133,59 @@ ORDER BY , id ; +-- name: list-live-jobs +SELECT + * +FROM ( + SELECT + j.id as id + , j.file_id + , coalesce(j.color_id, fa.color_id) as color_id + , fa.id as analysis_id + , fa.max_x + , fa.max_y + , fa.max_z + , fa.max_bed + , fa.max_end + , fa.nozzle_diameter + , fa.filament_id + , (SELECT name FROM filament WHERE id = fa.filament_id) AS filament_name + , (SELECT name AS name FROM filament_color WHERE id = coalesce(j.color_id, fa.color_id)) AS color_name + , j.status_id + , (SELECT name FROM job_statuses WHERE id = j.status_id) AS status + , j.started_at + , j.time_left + , j.cancelled_at + , j.finished_at + , j.user_id + , j.printer_id + , j.continuous + FROM jobs j + INNER JOIN files f + ON j.file_id = f.id + LEFT JOIN file_analysis fa + ON fa.file_id = f.id + WHERE + finished_at IS NULL + AND cancelled_at IS NULL + AND (:uid IS NULL OR j.user_id = :uid) + AND f.id IS NOT NULL +) +ORDER BY + status_id DESC + , continuous DESC + , id +; + -- name: poll-job-queue^ SELECT * FROM jobs WHERE - started_at IS NULL + mapped_at IS NULL + AND started_at IS NULL AND finished_at IS NULL AND printer_id IS NULL -LIMIT 1 ; -- name: list-job-history @@ -208,6 +260,7 @@ WHERE UPDATE jobs SET printer_id = :pid + , mapped_at = datetime('now') WHERE id = :jid ; diff --git a/projects/tentacles/src/tentacles/static/css/style.scss b/projects/tentacles/src/tentacles/static/css/style.scss index 749ae9b..f26b619 100644 --- a/projects/tentacles/src/tentacles/static/css/style.scss +++ b/projects/tentacles/src/tentacles/static/css/style.scss @@ -149,14 +149,6 @@ input[type="image"] { margin-bottom: auto; } -.start-menu { - border-color: $black; - border-style: solid; - border-width: 4px; - border-radius: 4px; - padding: 4px; -} - .border-black { border-color: $black; } diff --git a/projects/tentacles/src/tentacles/templates/jobs_list.html.j2 b/projects/tentacles/src/tentacles/templates/jobs_list.html.j2 index 77e2bf5..00c0995 100644 --- a/projects/tentacles/src/tentacles/templates/jobs_list.html.j2 +++ b/projects/tentacles/src/tentacles/templates/jobs_list.html.j2 @@ -1,6 +1,6 @@ {% import "macros.html.j2" as macros %}

Job queue

-{% with jobs = ctx.db.list_job_queue(uid=None if ctx.is_admin else ctx.uid) %} +{% with jobs = ctx.db.list_live_jobs(uid=None if ctx.is_admin else ctx.uid) %} {% if jobs %} {% for job in jobs %}
diff --git a/projects/tentacles/src/tentacles/templates/macros.html.j2 b/projects/tentacles/src/tentacles/templates/macros.html.j2 index 417810d..df43918 100644 --- a/projects/tentacles/src/tentacles/templates/macros.html.j2 +++ b/projects/tentacles/src/tentacles/templates/macros.html.j2 @@ -4,16 +4,28 @@
- +
+ + +
+
+ + +
- +
{% endmacro %} diff --git a/projects/tentacles/src/tentacles/workers.py b/projects/tentacles/src/tentacles/workers.py index fa375c4..97d2142 100644 --- a/projects/tentacles/src/tentacles/workers.py +++ b/projects/tentacles/src/tentacles/workers.py @@ -7,26 +7,28 @@ Supporting the core app with asynchronous maintenance tasks. Mostly related to monitoring and managing Printer state. """ +import logging +import os from contextlib import closing from datetime import datetime, timedelta from functools import cache -import logging -import os from pathlib import Path from pprint import pformat from typing import Callable from cherrypy.process.plugins import Monitor -from fastmail import FastMailSMTP -from flask import Flask as App, render_template -from gcode import analyze_gcode_file -from octorest import OctoRest +from flask import Flask as App +from flask import render_template from requests import Response from requests.exceptions import ( ConnectionError, HTTPError, Timeout, ) + +from fastmail import FastMailSMTP +from gcode import analyze_gcode_file +from octorest import OctoRest from tentacles.db import Db @@ -135,14 +137,9 @@ def poll_printers(app: App, db: Db) -> None: def assign_jobs(app: App, db: Db) -> None: """Assign jobs to printers. Uploading files and job state management is handled separately.""" - for job in db.list_job_queue(uid=None): - # FIXME: Jobs which have been mapped are still in the "queue" until they finish - # Ignore such as they have already been mapped - if job.printer_id: - continue - - idle = list(db.list_idle_printers()) - for printer in idle: + # FIXME: Push the scheduler into SQL + if job := db.poll_job_queue(): + for printer in db.list_idle_printers(): if ( job.analysis_id is not None and printer.limit_x >= job.max_x @@ -153,9 +150,15 @@ def assign_jobs(app: App, db: Db) -> None: and printer.nozzle_diameter == job.nozzle_diameter and printer.filament_id == job.filament_id and ( - printer.color_id == job.color_id # Note that the default/undefined color is #1 - or job.color_id == 1 + job.color_id == 1 + or printer.color_id == job.color_id + + ) + and ( + # Note that the null printer ID serves as 'any' + job.printer_id is None + or job.printer_id == printer.id ) ): db.assign_job(jid=job.id, pid=printer.id) @@ -276,28 +279,25 @@ def pull_jobs(app: App, db: Db) -> None: """\ G90 ; Absolute motion coordinates G1 X155 Y310 F9000 ; Traverse to the back of the bed without lowering - -M118 A1 action:notification Cooling bed M104 S0 ; Turn off the hotend if it's on -M190 R30 T600 ; Cool the bed +M190 R45 ; Cool the bed +M190 R40 ; Cool the bed +M190 R35 ; Cool the bed +M190 R30 ; Cool the bed M140 S0 ; Turn off the bed -M118 A1 action:notification Bed cooled - -M118 A1 action:notification Bed clear start G1 X155 Y310 Z0.5 F9000 ; Lower the head to a push height -G1 X155 Y0 F9000 ; Traverse the head to the front of the bed to knock off any objects -G1 X155 Y200 Z250 F9000 ; Return the head to the presentation state -M118 A1 action:notification Bed clear end +G1 X155 Y0 Z0.5 F9000 ; Traverse the head almost to the front +G1 X200 Y0 F9000 ; Traverse the head across to knock anything off +G1 X150 Y200 Z250 F9000 ; Return the head to the presentation state """ ) - client.gcode( - """\ -M118 A1 action:notification Present bed start + else: + client.gcode( + """\ G1 X150 Y200 Z250 F9000 ; Return the head to the presentation state -M118 A1 action:notification Present bed end """ - ) + ) # And having dispatched that gcode mark the job done # FIXME: There should be some more state sync here.... @@ -311,10 +311,20 @@ M118 A1 action:notification Present bed end elif printer_state.get("error"): log.warn(f"Job {job.id} has failed") + client.gcode( + """\ +G1 X150 Y200 Z250 F9000 ; Return the head to the presentation state +""" + ) db.finish_job(jid=job.id, state="failed") elif printer_state.get("cancelling"): log.info(f"Job {job.id} has been acknowledged as cancelled") + client.gcode( + """\ +G1 X150 Y200 Z250 F9000 ; Return the head to the presentation state +""" + ) db.finish_job(jid=job.id, state="cancelled") elif printer_state.get("printing"): @@ -402,7 +412,7 @@ def analyze_files(app: App, db: Db): def debug_queue(app: App, db: Db): output = ["---"] - for job in db.list_job_queue(uid=None): + for job in db.list_live_jobs(uid=None): output.append("Job " + repr(job)) for printer in db.list_idle_printers():