Compare commits

..

No commits in common. "a281f24689b324e037225a7381257df200382343" and "19c941dc9596d2f467beec7ad99e421e512c0c97" have entirely different histories.

19 changed files with 89 additions and 388 deletions

View file

@ -62,18 +62,18 @@ load("@arrdem_source_pypi//:requirements.bzl", "install_deps")
# Call it to define repos for your requirements.
install_deps()
git_repository(
name = "rules_zapp",
remote = "https://git.arrdem.com/arrdem/rules_zapp.git",
commit = "961be891e5cff539e14f2050d5cd9e82845ce0f2",
# tag = "0.1.2",
)
# local_repository(
# git_repository(
# name = "rules_zapp",
# path = "/home/arrdem/Documents/hobby/programming/lang/python/rules_zapp",
# remote = "https://git.arrdem.com/arrdem/rules_zapp.git",
# commit = "72f82e0ace184fe862f1b19c4f71c3bc36cf335b",
# # tag = "0.1.2",
# )
local_repository(
name = "rules_zapp",
path = "/home/arrdem/Documents/hobby/programming/lang/python/rules_zapp",
)
####################################################################################################
# Docker support
####################################################################################################

View file

@ -8,7 +8,6 @@ py_project(
zapp_binary(
name = "qint",
main = "src/python/proquint/__main__.py",
shebang = "#!/usr/bin/env python3",
imports = [
"src/python",
],

View file

@ -1,102 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
from attrs import define
from typing import Optional, Tuple
import re
OPTION_PATTERN = re.compile("; (?P<key>[a-z0-9_]+) = (?P<value>.*?)\n")
def parse_prusa_config_str(content: str) -> dict:
kvs = {}
iter = re.finditer(OPTION_PATTERN, content)
while m := next(iter, None):
if m.group("key") == "prusaslicer_config" and m.group("value") == "begin":
break
while m := next(iter, None):
if m.group("key") == "prusaslicer_config" and m.group("value") == "end":
break
else:
kvs[m.group("key")] = m.group("value")
return kvs
def parse_prusa_config(p: Path):
with open(p) as fp:
return parse_prusa_config_str(fp.read())
@define
class GcodeAnalysis:
max_x: int
max_y: int
max_z: int
max_bed: int
max_end: int
filament: str
nozzle: int
def parse_point(point: str) -> Tuple[int, int]:
a, b = point.split("x")
return int(a), int(b)
def parse_bed_shape(coords: str) -> Tuple[int, int]:
# Note, these are clockwise from 0, 0
a, b, c, d = coords.split(",")
dx, _ = parse_point(a)
_, dy = parse_point(b)
x, y = parse_point(c)
return x + dx, y + dy
def analyze_gcode_str(text: str) -> Optional[GcodeAnalysis]:
opts = parse_prusa_config_str(text)
kwargs = {}
if "bed_shape" in opts:
max_x, max_y = parse_bed_shape(opts["bed_shape"])
kwargs["max_x"] = max_x
kwargs["max_y"] = max_y
else:
return None
if "max_print_height" in opts:
kwargs["max_z"] = int(opts["max_print_height"])
else:
return None
if "first_layer_bed_temperature" in opts:
kwargs["max_bed"] = int(opts["first_layer_bed_temperature"])
elif "bed_temperature" in opts:
kwargs["max_bed"] = int(opts["bed_temperature"])
else:
return None
if "first_layer_temperature" in opts:
kwargs["max_end"] = int(opts["first_layer_temperature"])
elif "temperature" in opts:
kwargs["max_end"] = int(opts["temperature"])
else:
return None
if "filament_type" in opts:
kwargs["filament"] = opts["filament_type"]
else:
return None
if "nozzle_diameter" in opts:
kwargs["nozzle"] = float(opts["nozzle_diameter"])
else:
return None
return GcodeAnalysis(**kwargs)
def analyze_gcode_file(p: Path) -> Optional[GcodeAnalysis]:
with open(p) as fp:
return analyze_gcode_str(fp.read())

View file

@ -143,13 +143,12 @@ def serve(hostname: str, port: int, config: Path, trace: bool):
# Spawn the worker thread(s)
Worker(cherrypy.engine, app, db_factory, poll_printers, frequency=5).start()
Worker(cherrypy.engine, app, db_factory, analyze_files, frequency=5).start()
Worker(cherrypy.engine, app, db_factory, assign_jobs, frequency=5).start()
Worker(cherrypy.engine, app, db_factory, push_jobs, frequency=5).start()
Worker(cherrypy.engine, app, db_factory, revoke_jobs, frequency=5).start()
Worker(cherrypy.engine, app, db_factory, pull_jobs, frequency=5).start()
Worker(cherrypy.engine, app, db_factory, send_emails, frequency=5).start()
# Worker(cherrypy.engine, app, db_factory, debug_queue, frequency=5).start()
Worker(cherrypy.engine, app, db_factory, debug_queue, frequency=5).start()
# Run the server
cherrypy.engine.start()

View file

@ -182,9 +182,6 @@ class Db(Queries):
digest.update(password.encode("utf-8"))
res = super().try_login(username=username, hash=digest.hexdigest())
if not res:
print("WARNING: Failed to log in!")
for it in self._cursor.execute("SELECT * FROM users").fetchall():
print("DEBUG", it)
return None
return self.create_key(uid=res.id, name="web session", ttl=ttl)

View file

@ -8,19 +8,6 @@ CREATE TABLE IF NOT EXISTS files (
, FOREIGN KEY(user_id) REFERENCES user(id)
);
-- name: migration-0004-create-file-analysis#
CREATE TABLE IF NOT EXISTS file_analysis (
id INTEGER PRIMARY KEY AUTOINCREMENT
, max_x INTEGER
, max_y INTEGER
, max_z INTEGER
, max_end INTEGER
, max_bed INTEGER
, nozzle_diameter FLOAT
, filament_id INTEGER REFERENCES filament(id)
, file_id INTEGER REFERENCES file(id)
);
-- name: create-file^
INSERT INTO files (
user_id
@ -58,41 +45,3 @@ WHERE
user_id = :uid
AND id = :fid
;
-- name: create-analysis^
INSERT INTO file_analysis (
max_x
, max_y
, max_z
, max_end
, max_bed
, nozzle_diameter
, filament_id
, file_id
)
VALUES (
:max_x
, :max_y
, :max_z
, :max_end
, :max_bed
, :nozzle
, :filament_id
, :file_id
)
RETURNING
id
;
-- name: list-unanalyzed-files
SELECT
f.id
, f.path
, f.filename
, f.user_id
FROM files f
LEFT JOIN file_analysis fa
ON f.id = fa.file_id
WHERE
fa.file_id IS NULL
;

View file

@ -68,25 +68,11 @@ WHERE
-- name: list-job-queue
SELECT
j.id as id
, j.file_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
FROM jobs j
INNER JOIN files f
ON j.file_id = f.id
LEFT JOIN file_analysis fa
ON fa.file_id = f.id
*
FROM jobs
WHERE
finished_at IS NULL
AND (:uid IS NULL OR j.user_id = :uid)
AND f.id IS NOT NULL
AND (:uid IS NULL OR user_id = :uid)
;
-- name: poll-job-queue^

View file

@ -41,7 +41,7 @@ CREATE TABLE IF NOT EXISTS printer_chassis (
);
INSERT INTO printer_chassis (name, limit_x, limit_y, limit_z, limit_bed, limit_hotend, limit_tools) VALUES (
'Creality CR-10v3', 310, 310, 400, 100, 260, 1
'Creality CR-10v3', 300, 300, 400, 100, 260, 1
);
INSERT INTO printer_chassis (name, limit_x, limit_y, limit_z, limit_bed, limit_hotend, limit_tools) VALUES (
@ -76,7 +76,7 @@ ALTER TABLE printers ADD filament_id INTEGER REFERENCES filament(id) DEFAULT 1;
ALTER TABLE printers ADD enabled BOOLEAN DEFAULT TRUE;
-- name: migration-0005-create-printer-nozzle#
ALTER TABLE printers ADD nozzle_diameter FLOAT DEFAULT 0.4;
ALTER TABLE printers ADD nozzle_diameter INTEGER default 4;
-- name: try-create-printer^
INSERT INTO printers (
@ -116,19 +116,8 @@ SELECT
, p.api_key
, p.last_poll_date
, s.name as status
, p.enabled
, f.name as filament_name
, c.name as machine_name
, c.limit_x
, c.limit_y
, c.limit_z
, c.limit_bed
, c.limit_hotend
, p.nozzle_diameter
FROM printers p
INNER JOIN printer_statuses s ON p.status_id = s.id
INNER JOIN filament f on p.filament_id = f.id
INNER JOIN printer_chassis c on p.chassis_id = c.id
;
-- name: list-idle-printers
@ -137,8 +126,9 @@ SELECT
, c.limit_x
, c.limit_y
, c.limit_z
, c.limit_bed
, c.limit_hotend
, c.limit_bed
, c.limit_tools
, p.nozzle_diameter
FROM printers p
LEFT JOIN (SELECT id, printer_id FROM jobs WHERE finished_at IS NULL) j
@ -191,14 +181,3 @@ SELECT
, name
FROM filament
;
-- name: create-filament^
INSERT OR IGNORE INTO filament (
name
)
VALUES (
:name
)
RETURNING
id
;

View file

@ -77,8 +77,7 @@ INNER JOIN users u
WHERE
(expiration IS NULL OR unixepoch(expiration) > unixepoch('now'))
AND k.id = :kid
AND (u.enabled_at IS NOT NULL -- and the user is not disabled!
OR u.group_id = 0) -- or the user is a root
AND u.enabled_at IS NOT NULL -- and the user is not disabled!
;
-- name: refresh-key

View file

@ -58,10 +58,6 @@ label {
flex: 1;
}
.u-flex-wrap {
flex-wrap: wrap;
}
@media (max-width: 760px) {
.file, .printer, .key, .job {
flex-direction: column;

View file

@ -4,10 +4,10 @@
{% if files %}
{% for file in files %}
<div class="file row u-flex">
<div class="details six columns u-flex u-flex-wrap">
<div class="details six columns">
<span class="file-name">{{ file.filename }}</span>
<span class="file-sucesses">{{ file.print_successes }} successes</span>
<span class="file-failures">{{ file.print_failures }} errors</span>
<span class="file-sucesses">{{ file.print_successes }}</span> successes
<span class="file-failures">{{ file.print_failures }}</span> errors
</div>
<div class="controls u-flex u-ml-auto">
{{ macros.start_job(file.id) }}

View file

@ -4,10 +4,11 @@
{% if jobs %}
{% for job in jobs %}
<div class="job row u-flex">
<div class="details six columns u-flex u-flex-wrap">
<div class="job-filename u-flex">
<label for="filename">File</label>
<span name="filename">{{ctx.db.fetch_file(ctx.uid, job.file_id).filename or "it's a secret"}}</span>
<div class="details six columns u-flex">
<div class="job-status u-flex">
<label for="state">Job</label>
<div class="dot {{ macros.job_state(job) }}" style="--dot-size: 1em;"> </div>
<span name="state">{{ macros.job_state(job) }}</span>
</div>
{% if job.printer_id %}
<div class="job-printer u-flex">
@ -15,6 +16,10 @@
<span name="printer">{{ ctx.db.fetch_printer(job.printer_id).name }}</span>
</div>
{% endif %}
<div class="job-filename u-flex">
<label for="filename">File</label>
<span name="filename">{{ctx.db.fetch_file(ctx.uid, job.file_id).filename or "it's a secret"}}</span>
</div>
{% if job.finished_at and job.started_at %}
<div class="job-runtime u-flex">
<label for="runtime">Runtime</label>
@ -22,13 +27,7 @@
</div>
{% endif %}
</div>
<div class="two columns">
<div class="job-status u-flex">
<label for="state">Status</label>
<div class="dot {{ macros.job_state(job) }} tooltip bottom" data-text="{{ macros.job_state(job) }}" style="--dot-size: 1em;"> </div>
</div>
</div>
<div class="controls four columns u-flex u-ml-auto">
<div class="controls u-flex u-ml-auto">
{% if ctx.uid %}
{{ macros.duplicate_job(job.id) }}
{{ macros.delete_job(job.id) }}

View file

@ -5,9 +5,10 @@
{% for job in jobs %}
<div class="job row u-flex">
<div class="details six columns u-flex">
<div class="job-filename u-flex">
<label for="filename">File</label>
<span name="filename">{{ctx.db.fetch_file(ctx.uid, job.file_id).filename or "it's a secret"}}</span>
<div class="job-status u-flex">
<label for="state">Job</label>
<div class="dot {{ macros.job_state(job) }} {{ 'dot--basic' if not job.state else '' }}" style="--dot-size: 1em;"> </div>
<span name="state">{{ macros.job_state(job) }}</span>
</div>
{% if job.printer_id %}
<div class="job-printer u-flex">
@ -15,6 +16,10 @@
<span name="printer">{{ ctx.db.fetch_printer(job.printer_id).name }}</span>
</div>
{% endif %}
<div class="job-filename u-flex">
<label for="filename">File</label>
<span name="filename">{{ctx.db.fetch_file(ctx.uid, job.file_id).filename or "it's a secret"}}</span>
</div>
{% if job.started_at %}
<div class="job-runtime u-flex">
<label for="runtime">Runtime</label>
@ -22,12 +27,6 @@
</div>
{% endif %}
</div>
<div class="two columns">
<div class="job-status u-flex">
<label for="state">Status</label>
<div class="dot {{ macros.job_state(job) }} tooltip bottom" data-text="{{ macros.job_state(job) }}" style="--dot-size: 1em;"> </div>
</div>
</div>
<div class="controls u-flex u-ml-auto">
{% if ctx.uid %}
{{ macros.duplicate_job(job.id) }}

View file

@ -6,10 +6,6 @@
<div class="u-flex1 webcam" style="max-width: calc(100% / {{printers|length}})">
<label>{{ printer.name }}</label>
<img id="printer_{{printer.id}}_stream" src="{{ printer.stream_url }}" style="max-width: 100%;" />
<span><label>Status</label>{{printer.status}}, {% if printer.enabled %}accepting jobs{%else%}not scheduling{%endif%}</span>
<span><label>Loaded material</label>{{printer.filament_name}}</span>
<span><label>Machine</label>{{printer.machine_name}}</span>
<span><label>Limits</label>{{printer.limit_x}}mm x{{printer.limit_y}}mm x{{printer.limit_z}}mm, bed {{printer.limit_bed}}c, end {{printer.limit_hotend}}c</span>
</div>
{% endfor %}
</div>

View file

@ -17,7 +17,6 @@ from urllib import parse as urlparse
from cherrypy.process.plugins import Monitor
from fastmail import FastMailSMTP
from gcode import analyze_gcode_file
from flask import Flask as App
from octorest import OctoRest as _OR
from requests import Response
@ -147,20 +146,10 @@ 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):
for printer in db.list_idle_printers():
if (
printer.limit_x >= job.max_x
and printer.limit_y >= job.max_y
and printer.limit_z >= job.max_z
and printer.limit_hotend >= job.max_end
and printer.limit_bed >= job.max_bed
and printer.nozzle_diameter == job.nozzle_diameter
and printer.filament_id == job.filament_id
):
if job := db.poll_job_queue():
db.assign_job(jid=job.id, pid=printer.id)
log.info(f"Mapped job {job.id} to printer {printer.id}")
break
def push_jobs(app: App, db: Db) -> None:
@ -301,41 +290,14 @@ def send_emails(app, db: Db):
db.send_email(eid=message.id)
def analyze_files(app: App, db: Db):
for unanalyzed in db.list_unanalyzed_files():
record = analyze_gcode_file(Path(unanalyzed.path))
if not record:
log.error(
f"Unable to analyze {unanalyzed.path} ({unanalyzed.filename} owned by {unanalyzed.user_id})!"
)
continue
db.create_analysis(
file_id=unanalyzed.id,
max_x=record.max_x,
max_y=record.max_y,
max_z=record.max_z,
max_end=record.max_end,
max_bed=record.max_bed,
filament_id=db.create_filament(record.filament),
)
def debug_queue(app: App, db: Db):
output = ["---"]
for job in db.list_job_queue(uid=None):
output.append("Job " + repr(job))
for job in db.list_running_jobs():
output.append(repr(job))
for printer in db.list_idle_printers():
output.append("Printer " + repr(printer))
for unanalyzed in db.list_unanalyzed_files():
output.append("Unanalyzed file " + repr(unanalyzed))
output.append(repr(printer))
print("\n".join(output))
def toil(*fs):
def _helper(*args, **kwargs):
for f in fs:

View file

@ -1,21 +1,15 @@
#!/usr/bin/env python3
from datetime import timedelta
import logging
import pytest
from tentacles.db import Db
# FIXME: Should this be an autouse fixture? Maybe. Doesn't buy much tho.
logging.addLevelName(logging.DEBUG - 5, "TRACE")
logging.TRACE = logging.DEBUG - 5
@pytest.fixture
@pytest.yield_fixture
def db():
conn = Db(":memory:")
conn.connect()
conn.migrate()
yield conn
conn.close()
@ -31,23 +25,26 @@ def password_testy():
@pytest.fixture
def uid_testy(db: Db, username_testy, password_testy) -> int:
def uid_testy(db: Db, username_testy, password_testy):
with db.savepoint():
return db.try_create_user(
username=username_testy,
email=username_testy,
password=password_testy,
sid=1,
gid=0, # Note: to bypass the approve/enable machinery
).id
@pytest.fixture
def login_ttl() -> timedelta:
def login_ttl():
return timedelta(hours=12)
@pytest.fixture
def sid_testy(db: Db, uid_testy, username_testy, password_testy, login_ttl):
res = db.try_login(username=username_testy, password=password_testy, ttl=login_ttl)
with db.savepoint():
res = db.try_login(
username=username_testy, password=password_testy, ttl=login_ttl
)
assert res.user_id == uid_testy
return res.id

View file

@ -1,55 +0,0 @@
#!/usr/bin/env python3
import re
from gcode import (
parse_prusa_config_str,
OPTION_PATTERN,
analyze_gcode_str,
GcodeAnalysis,
)
def test_option_pattern():
assert re.match(OPTION_PATTERN, "\n") is None
assert re.findall(OPTION_PATTERN, "; foo = bar\n")
assert re.findall(OPTION_PATTERN, "; foo = bar\n; baz = qux")
def test_parse_config_str():
assert parse_prusa_config_str("") == {}
assert (
parse_prusa_config_str(
"""
; prusaslicer_config = begin
; foo = bar
; prusaslicer_config = end
"""
)
== {"foo": "bar"}
)
def test_analyze_gcode():
assert (
analyze_gcode_str(
"""
gcode garbage
; some comment
more garbage
; prusaslicer_config = begin
; bed_shape = 5x5,95x5,95x95,5x95
; max_print_height = 100
; first_layer_bed_temperature = 100
; first_layer_temperature = 195
; filament_type = PETG
; nozzle_diameter = 1.0
; prusaslicer_config = end
"""
)
== GcodeAnalysis(100, 100, 100, 100, 195, "PETG", 1.0)
)

View file

@ -3,12 +3,12 @@
from tentacles.db import Db
def test_db_initializes(db: Db):
assert isinstance(db, Db)
def test_db_initializes(store: Db):
assert isinstance(store, Db)
def test_db_savepoint(db: Db):
obj = db.savepoint()
def test_db_savepoint(store: Db):
obj = store.savepoint()
assert hasattr(obj, "__enter__")
assert hasattr(obj, "__exit__")

View file

@ -1,39 +1,40 @@
#!/usr/bin/env python3
from tentacles.db import Db
from tentacles.store import Store
def test_mkuser(db: Db, username_testy, password_testy):
res = db.try_create_user(
def test_mkuser(store: Store, username_testy, password_testy):
res = store.try_create_user(
username=username_testy, email=username_testy, password=password_testy
)
assert res
assert [(it.id, it.name) for it in db.list_users()] == [(res.id, username_testy)]
assert store.list_users() == [(res.id, username_testy)]
def test_mksession(db: Db, uid_testy, username_testy, password_testy, login_ttl):
assert uid_testy
res = db.try_login(username=username_testy, password=password_testy, ttl=login_ttl)
def test_mksession(store: Store, uid_testy, username_testy, password_testy, login_ttl):
res = store.try_login(
username=username_testy, password=password_testy, ttl=login_ttl
)
assert res is not None
assert [it.id for it in db.list_keys(uid=uid_testy)] == [res.id]
assert db.try_key(kid=res.id).user_id == uid_testy
assert [it.id for it in store.list_keys(uid=uid_testy)] == [res.id]
assert store.try_key(kid=res.id).user_id == uid_testy
def test_refresh_key(db: Db, sid_testy, login_ttl):
before = db.fetch_key(kid=sid_testy)
db.refresh_key(kid=sid_testy, ttl=login_ttl * 2)
after = db.fetch_key(kid=sid_testy)
def test_refresh_key(store: Store, sid_testy, login_ttl):
before = store.fetch_key(kid=sid_testy)
store.refresh_key(kid=sid_testy, ttl=login_ttl * 2)
after = store.fetch_key(kid=sid_testy)
assert before != after
def tets_mkkey(db: Db, sid_testy, uid_testy):
assert db.try_key(kid=sid_testy) == uid_testy
new_key = db.create_key(kid=sid_testy, ttl=None)
def tets_mkkey(store: Store, sid_testy, uid_testy):
assert store.try_key(kid=sid_testy) == uid_testy
new_key = store.create_key(kid=sid_testy, ttl=None)
assert new_key is not None
assert db.try_key(kid=new_key) == uid_testy
assert store.try_key(kid=new_key) == uid_testy
def test_logout(db: Db, uid_testy, sid_testy):
assert db.try_key(kid=sid_testy)
db.delete_key(uid=uid_testy, kid=sid_testy)
assert not db.try_key(kid=sid_testy)
def test_logout(store: Store, uid_testy, sid_testy):
assert store.try_key(kid=sid_testy)
store.delete_key(uid=uid_testy, kid=sid_testy)
assert not store.try_key(kid=sid_testy)