Format, add run on printer, start working on calibration

This commit is contained in:
Reid D McKenzie 2025-02-16 01:50:13 -07:00
parent 036465a50b
commit 9910b72d05
16 changed files with 173 additions and 81 deletions

View file

@ -1,10 +1,10 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import smtplib
from email import encoders from email import encoders
from email.mime.base import MIMEBase from email.mime.base import MIMEBase
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
import smtplib
class FastMailSMTP(smtplib.SMTP_SSL): class FastMailSMTP(smtplib.SMTP_SSL):

View file

@ -1,12 +1,11 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from pathlib import Path
import re import re
from pathlib import Path
from typing import Optional, Tuple from typing import Optional, Tuple
from attrs import define from attrs import define
OPTION_PATTERN = re.compile("; (?P<key>[a-z0-9_]+) = (?P<value>.*?)\n") OPTION_PATTERN = re.compile("; (?P<key>[a-z0-9_]+) = (?P<value>.*?)\n")

View file

@ -2,14 +2,20 @@
"""The core app entrypoint.""" """The core app entrypoint."""
from datetime import datetime, timedelta
import logging import logging
import os
from contextlib import closing
from datetime import datetime, timedelta
from importlib.resources import files
from pathlib import Path from pathlib import Path
import tomllib from shutil import copyfile
import cherrypy import cherrypy
import click import click
import tomllib
from flask import Flask, request from flask import Flask, request
from tentacles import workers
from tentacles.blueprints import ( from tentacles.blueprints import (
admin_ui, admin_ui,
api, api,
@ -18,11 +24,7 @@ from tentacles.blueprints import (
user_ui, user_ui,
) )
from tentacles.db import Db from tentacles.db import Db
from tentacles.globals import _ctx, Ctx, ctx from tentacles.globals import Ctx, _ctx, ctx
from tentacles import workers
from contextlib import closing
from tentacles.blueprints.util import salt
log = logging.getLogger(__name__) 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: with closing(db_factory(app)) as db:
db.migrate() 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 # Configuring cherrypy is kinda awful
cherrypy.server.unsubscribe() cherrypy.server.unsubscribe()
server = cherrypy._cpserver.Server() server = cherrypy._cpserver.Server()

View file

@ -2,8 +2,6 @@
import logging import logging
from .api import requires_admin
from flask import ( from flask import (
Blueprint, Blueprint,
flash, flash,
@ -11,8 +9,10 @@ from flask import (
render_template, render_template,
request, request,
) )
from tentacles.globals import ctx from tentacles.globals import ctx
from .api import requires_admin
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
BLUEPRINT = Blueprint("admin", __name__) BLUEPRINT = Blueprint("admin", __name__)
@ -96,9 +96,9 @@ def handle_add_printer():
flash("Printer created") flash("Printer created")
return redirect("/admin/printers") return redirect("/admin/printers")
except Exception as e: except Exception:
log.exception("Failed to create printer") 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") return render_template("printers.html.j2")

View file

@ -2,13 +2,13 @@
"""API endpoints supporting the 'ui'.""" """API endpoints supporting the 'ui'."""
from hashlib import sha3_256
import os import os
from hashlib import sha3_256
from typing import Optional from typing import Optional
from flask import Blueprint, current_app, request from flask import Blueprint, current_app, request
from tentacles.globals import ctx
from tentacles.globals import ctx
BLUEPRINT = Blueprint("api", __name__, url_prefix="/api") BLUEPRINT = Blueprint("api", __name__, url_prefix="/api")
@ -96,12 +96,11 @@ def create_file(location: Optional[str] = None):
current_app.config["UPLOAD_FOLDER"], current_app.config["UPLOAD_FOLDER"],
sanitized_filename, sanitized_filename,
) )
if os.path.exists(sanitized_path):
return {"error": "file exists already"}, 409
real_path = sanitized_path.replace( real_path = sanitized_path.replace(
"$ROOT_FOLDER", current_app.config["ROOT_FOLDER"] "$ROOT_FOLDER", current_app.config["ROOT_FOLDER"]
) )
if os.path.exists(real_path):
return {"error": "file exists already"}, 409
print(file.filename, real_path) print(file.filename, real_path)

View file

@ -3,9 +3,6 @@
import logging import logging
import os import os
from .api import create_file
from .util import requires_auth
from flask import ( from flask import (
Blueprint, Blueprint,
flash, flash,
@ -14,8 +11,11 @@ from flask import (
request, request,
send_file, send_file,
) )
from tentacles.globals import ctx from tentacles.globals import ctx
from .api import create_file
from .util import requires_auth
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
BLUEPRINT = Blueprint("files", __name__) BLUEPRINT = Blueprint("files", __name__)

View file

@ -2,8 +2,6 @@
import logging import logging
from .util import requires_auth
from flask import ( from flask import (
Blueprint, Blueprint,
flash, flash,
@ -11,14 +9,19 @@ from flask import (
render_template, render_template,
request, request,
) )
from tentacles.globals import ctx from tentacles.globals import ctx
from .util import requires_auth
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
BLUEPRINT = Blueprint("jobs", __name__) BLUEPRINT = Blueprint("jobs", __name__)
def maybe(f, x): def maybe(f, x):
if x == "None":
return None
if x is not None: if x is not None:
return f(x) return f(x)

View file

@ -1,10 +1,8 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from datetime import timedelta
import logging import logging
import re import re
from datetime import timedelta
from .util import is_logged_in, salt
from flask import ( from flask import (
Blueprint, Blueprint,
@ -14,8 +12,10 @@ from flask import (
render_template, render_template,
request, request,
) )
from tentacles.globals import ctx from tentacles.globals import ctx
from .util import is_logged_in, salt
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
BLUEPRINT = Blueprint("user", __name__) BLUEPRINT = Blueprint("user", __name__)
@ -121,7 +121,7 @@ def post_register():
return render_template("register.html.j2") return render_template("register.html.j2")
except Exception as e: except Exception:
log.exception("Error encountered while registering a user...") log.exception("Error encountered while registering a user...")
flash("Unable to register that username", category="error") flash("Unable to register that username", category="error")

View file

@ -3,8 +3,8 @@
import logging import logging
from flask import current_app, flash, redirect from flask import current_app, flash, redirect
from tentacles.globals import ctx
from tentacles.globals import ctx
log = logging.getLogger(__name__) log = logging.getLogger(__name__)

View file

@ -1,13 +1,13 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import logging
import sqlite3
from collections import namedtuple from collections import namedtuple
from contextlib import contextmanager from contextlib import contextmanager
from datetime import datetime, timedelta from datetime import datetime, timedelta
from hashlib import sha3_256 from hashlib import sha3_256
from importlib.resources import files from importlib.resources import files
from inspect import signature from inspect import signature
import logging
import sqlite3
from time import sleep from time import sleep
from types import GeneratorType, new_class from types import GeneratorType, new_class
from typing import Optional from typing import Optional
@ -18,7 +18,6 @@ from aiosql.aiosql import (
from aiosql.queries import Queries from aiosql.queries import Queries
from aiosql.query_loader import QueryLoader from aiosql.query_loader import QueryLoader
_sqlite = get_adapter("sqlite3") _sqlite = get_adapter("sqlite3")
_loader = QueryLoader(_sqlite, None) _loader = QueryLoader(_sqlite, None)
_queries = Queries(_sqlite, kwargs_only=False) _queries = Queries(_sqlite, kwargs_only=False)

View file

@ -3,9 +3,10 @@
from contextvars import ContextVar from contextvars import ContextVar
from attrs import define from attrs import define
from tentacles.db import Db
from werkzeug.local import LocalProxy from werkzeug.local import LocalProxy
from tentacles.db import Db
@define @define
class Ctx: class Ctx:

View file

@ -35,6 +35,12 @@ ALTER TABLE jobs ADD COLUMN color_id INTEGER DEFAULT (NULL);
-- name: migration-0003-jobs-add-continuous# -- name: migration-0003-jobs-add-continuous#
ALTER TABLE jobs ADD COLUMN continuous BOOLEAN DEFAULT (FALSE); 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^ -- name: create-job^
INSERT INTO jobs ( INSERT INTO jobs (
user_id user_id
@ -42,6 +48,7 @@ INSERT INTO jobs (
, color_id , color_id
, printer_id , printer_id
, continuous , continuous
, requested_at
) )
VALUES ( VALUES (
:uid :uid
@ -49,6 +56,7 @@ VALUES (
, :cid , :cid
, :pid , :pid
, :cont , :cont
, datetime('now')
) )
RETURNING RETURNING
* *
@ -125,15 +133,59 @@ ORDER BY
, id , 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^ -- name: poll-job-queue^
SELECT SELECT
* *
FROM jobs FROM jobs
WHERE WHERE
started_at IS NULL mapped_at IS NULL
AND started_at IS NULL
AND finished_at IS NULL AND finished_at IS NULL
AND printer_id IS NULL AND printer_id IS NULL
LIMIT 1
; ;
-- name: list-job-history -- name: list-job-history
@ -208,6 +260,7 @@ WHERE
UPDATE jobs UPDATE jobs
SET SET
printer_id = :pid printer_id = :pid
, mapped_at = datetime('now')
WHERE WHERE
id = :jid id = :jid
; ;

View file

@ -149,14 +149,6 @@ input[type="image"] {
margin-bottom: auto; margin-bottom: auto;
} }
.start-menu {
border-color: $black;
border-style: solid;
border-width: 4px;
border-radius: 4px;
padding: 4px;
}
.border-black { .border-black {
border-color: $black; border-color: $black;
} }

View file

@ -1,6 +1,6 @@
{% import "macros.html.j2" as macros %} {% import "macros.html.j2" as macros %}
<h2>Job queue</h2> <h2>Job queue</h2>
{% 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 %} {% if jobs %}
{% for job in jobs %} {% for job in jobs %}
<div class="job row u-flex"> <div class="job row u-flex">

View file

@ -4,16 +4,28 @@
<form class="start-menu inline u-flex" method="post" action="/jobs"> <form class="start-menu inline u-flex" method="post" action="/jobs">
<input type="hidden" name="action" value="enqueue" /> <input type="hidden" name="action" value="enqueue" />
<input type="hidden" name="file_id" value="{{ file.id }}" /> <input type="hidden" name="file_id" value="{{ file.id }}" />
<select name="color_id"> <div class="u-flex u-ml-auto u-mv-auto u-flex u-ml-1">
<label class="u-flex u-mv-auto" for="printer_id">Printer</label>
<select class="u-flex u-mv-auto" name="printer_id">
<option value="None">Any</option>
{%- for p in ctx.db.list_printers() %}
<option value="{{p.id}}">{{p.name}}</option>
{%- endfor %}
</select>
</div>
<div class="u-flex u-ml-auto u-mv-auto u-flex u-ml-1">
<label class="u-flex u-mv-auto" for="color_id">Color</label>
<select class="u-flex u-mv-auto" name="color_id">
{%- for c in ctx.db.list_colors() %} {%- for c in ctx.db.list_colors() %}
<option value="{{c.id}}" {% if file.color_id == c.id %}selected{%endif%}>{{c.name}}</option> <option value="{{c.id}}" {% if file.color_id == c.id %}selected{%endif%}>{{c.name}}</option>
{%- endfor %} {%- endfor %}
</select> </select>
</div>
<div class="u-flex u-ml-auto u-mv-auto u-flex u-ml-1"> <div class="u-flex u-ml-auto u-mv-auto u-flex u-ml-1">
<label class="u-flex u-mv-auto" for="continuous">Cont.</label> <label class="u-flex u-mv-auto" for="continuous">Cont.</label>
<input class="u-flex u-mv-auto" type="checkbox" name="continuous" false> <input class="u-flex u-mv-auto" type="checkbox" name="continuous" false>
</div> </div>
<input id="submit" type="image" src="/static/print.svg" height="24" width="24" /> <input class="u-flex u-mv-auto" id="submit" type="image" src="/static/print.svg" height="24" width="24" />
</form> </form>
{% endmacro %} {% endmacro %}

View file

@ -7,26 +7,28 @@ Supporting the core app with asynchronous maintenance tasks.
Mostly related to monitoring and managing Printer state. Mostly related to monitoring and managing Printer state.
""" """
import logging
import os
from contextlib import closing from contextlib import closing
from datetime import datetime, timedelta from datetime import datetime, timedelta
from functools import cache from functools import cache
import logging
import os
from pathlib import Path from pathlib import Path
from pprint import pformat from pprint import pformat
from typing import Callable from typing import Callable
from cherrypy.process.plugins import Monitor from cherrypy.process.plugins import Monitor
from fastmail import FastMailSMTP from flask import Flask as App
from flask import Flask as App, render_template from flask import render_template
from gcode import analyze_gcode_file
from octorest import OctoRest
from requests import Response from requests import Response
from requests.exceptions import ( from requests.exceptions import (
ConnectionError, ConnectionError,
HTTPError, HTTPError,
Timeout, Timeout,
) )
from fastmail import FastMailSMTP
from gcode import analyze_gcode_file
from octorest import OctoRest
from tentacles.db import Db 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: def assign_jobs(app: App, db: Db) -> None:
"""Assign jobs to printers. Uploading files and job state management is handled separately.""" """Assign jobs to printers. Uploading files and job state management is handled separately."""
for job in db.list_job_queue(uid=None): # FIXME: Push the scheduler into SQL
# FIXME: Jobs which have been mapped are still in the "queue" until they finish if job := db.poll_job_queue():
# Ignore such as they have already been mapped for printer in db.list_idle_printers():
if job.printer_id:
continue
idle = list(db.list_idle_printers())
for printer in idle:
if ( if (
job.analysis_id is not None job.analysis_id is not None
and printer.limit_x >= job.max_x 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.nozzle_diameter == job.nozzle_diameter
and printer.filament_id == job.filament_id and printer.filament_id == job.filament_id
and ( and (
printer.color_id == job.color_id
# Note that the default/undefined color is #1 # 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) db.assign_job(jid=job.id, pid=printer.id)
@ -276,26 +279,23 @@ def pull_jobs(app: App, db: Db) -> None:
"""\ """\
G90 ; Absolute motion coordinates G90 ; Absolute motion coordinates
G1 X155 Y310 F9000 ; Traverse to the back of the bed without lowering 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 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 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 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 Y0 Z0.5 F9000 ; Traverse the head almost to the front
G1 X155 Y200 Z250 F9000 ; Return the head to the presentation state G1 X200 Y0 F9000 ; Traverse the head across to knock anything off
M118 A1 action:notification Bed clear end G1 X150 Y200 Z250 F9000 ; Return the head to the presentation state
""" """
) )
else:
client.gcode( client.gcode(
"""\ """\
M118 A1 action:notification Present bed start
G1 X150 Y200 Z250 F9000 ; Return the head to the presentation state G1 X150 Y200 Z250 F9000 ; Return the head to the presentation state
M118 A1 action:notification Present bed end
""" """
) )
@ -311,10 +311,20 @@ M118 A1 action:notification Present bed end
elif printer_state.get("error"): elif printer_state.get("error"):
log.warn(f"Job {job.id} has failed") 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") db.finish_job(jid=job.id, state="failed")
elif printer_state.get("cancelling"): elif printer_state.get("cancelling"):
log.info(f"Job {job.id} has been acknowledged as cancelled") 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") db.finish_job(jid=job.id, state="cancelled")
elif printer_state.get("printing"): elif printer_state.get("printing"):
@ -402,7 +412,7 @@ def analyze_files(app: App, db: Db):
def debug_queue(app: App, db: Db): def debug_queue(app: App, db: Db):
output = ["---"] output = ["---"]
for job in db.list_job_queue(uid=None): for job in db.list_live_jobs(uid=None):
output.append("Job " + repr(job)) output.append("Job " + repr(job))
for printer in db.list_idle_printers(): for printer in db.list_idle_printers():