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
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):

View file

@ -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<key>[a-z0-9_]+) = (?P<value>.*?)\n")

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,16 +4,28 @@
<form class="start-menu inline u-flex" method="post" action="/jobs">
<input type="hidden" name="action" value="enqueue" />
<input type="hidden" name="file_id" value="{{ file.id }}" />
<select name="color_id">
{%- for c in ctx.db.list_colors() %}
<option value="{{c.id}}" {% if file.color_id == c.id %}selected{%endif%}>{{c.name}}</option>
{%- endfor %}
</select>
<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() %}
<option value="{{c.id}}" {% if file.color_id == c.id %}selected{%endif%}>{{c.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="continuous">Cont.</label>
<input class="u-flex u-mv-auto" type="checkbox" name="continuous" false>
</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>
{% endmacro %}

View file

@ -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():