From 4fa627919bf2a1d3f983d87917283e57d639d20c Mon Sep 17 00:00:00 2001
From: Reid 'arrdem' McKenzie <me@arrdem.com>
Date: Sat, 8 Jul 2023 16:47:12 -0600
Subject: [PATCH 1/2] Fix tests, create gcode analysis machinery

---
 WORKSPACE                                     | 20 ++--
 projects/proquint/BUILD                       |  1 +
 projects/tentacles/src/python/gcode.py        | 96 +++++++++++++++++++
 .../src/python/tentacles/__main__.py          |  3 +-
 projects/tentacles/src/python/tentacles/db.py |  3 +
 .../src/python/tentacles/sql/files.sql        | 46 +++++++++
 .../src/python/tentacles/sql/jobs.sql         | 10 +-
 .../src/python/tentacles/sql/printers.sql     | 13 ++-
 .../src/python/tentacles/sql/user_keys.sql    |  3 +-
 .../python/tentacles/static/css/style.scss    |  4 +
 .../tentacles/templates/files_list.html.j2    |  6 +-
 .../tentacles/templates/jobs_history.html.j2  | 21 ++--
 .../tentacles/templates/jobs_list.html.j2     | 17 ++--
 .../tentacles/src/python/tentacles/workers.py | 22 +++++
 projects/tentacles/test/python/conftest.py    | 35 +++----
 projects/tentacles/test/python/test_gcode.py  | 54 +++++++++++
 projects/tentacles/test/python/test_store.py  |  8 +-
 .../tentacles/test/python/test_store_users.py | 43 ++++-----
 18 files changed, 327 insertions(+), 78 deletions(-)
 create mode 100644 projects/tentacles/src/python/gcode.py
 create mode 100644 projects/tentacles/test/python/test_gcode.py

diff --git a/WORKSPACE b/WORKSPACE
index 6731735..0528d59 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -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 = "72f82e0ace184fe862f1b19c4f71c3bc36cf335b",
-#    # tag = "0.1.2",
-# )
-
-local_repository(
-    name = "rules_zapp",
-    path = "/home/arrdem/Documents/hobby/programming/lang/python/rules_zapp",
+git_repository(
+   name = "rules_zapp",
+   remote = "https://git.arrdem.com/arrdem/rules_zapp.git",
+   commit = "961be891e5cff539e14f2050d5cd9e82845ce0f2",
+   # tag = "0.1.2",
 )
 
+# local_repository(
+#     name = "rules_zapp",
+#     path = "/home/arrdem/Documents/hobby/programming/lang/python/rules_zapp",
+# )
+
 ####################################################################################################
 # Docker support
 ####################################################################################################
diff --git a/projects/proquint/BUILD b/projects/proquint/BUILD
index 05257fc..c55fdfd 100644
--- a/projects/proquint/BUILD
+++ b/projects/proquint/BUILD
@@ -8,6 +8,7 @@ py_project(
 zapp_binary(
     name = "qint",
     main = "src/python/proquint/__main__.py",
+    shebang = "#!/usr/bin/env python3",
     imports = [
         "src/python",
     ],
diff --git a/projects/tentacles/src/python/gcode.py b/projects/tentacles/src/python/gcode.py
new file mode 100644
index 0000000..c2761a4
--- /dev/null
+++ b/projects/tentacles/src/python/gcode.py
@@ -0,0 +1,96 @@
+#!/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
+
+
+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
+
+    return GcodeAnalysis(**kwargs)
+
+
+def analyze_gcode_file(p: Path) -> Optional[GcodeAnalysis]:
+    with open(p) as fp:
+        return analyze_gcode_str(fp.read())
diff --git a/projects/tentacles/src/python/tentacles/__main__.py b/projects/tentacles/src/python/tentacles/__main__.py
index fca1a97..ddd310f 100644
--- a/projects/tentacles/src/python/tentacles/__main__.py
+++ b/projects/tentacles/src/python/tentacles/__main__.py
@@ -143,12 +143,13 @@ 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()
diff --git a/projects/tentacles/src/python/tentacles/db.py b/projects/tentacles/src/python/tentacles/db.py
index 237c2f4..4fdf900 100644
--- a/projects/tentacles/src/python/tentacles/db.py
+++ b/projects/tentacles/src/python/tentacles/db.py
@@ -182,6 +182,9 @@ 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)
diff --git a/projects/tentacles/src/python/tentacles/sql/files.sql b/projects/tentacles/src/python/tentacles/sql/files.sql
index 9b2760e..389d096 100644
--- a/projects/tentacles/src/python/tentacles/sql/files.sql
+++ b/projects/tentacles/src/python/tentacles/sql/files.sql
@@ -8,6 +8,18 @@ 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
+ , filament_id INTEGER REFERENCES filament(id)
+ , file_id INTEGER REFERENCES file(id)
+);
+
 -- name: create-file^
 INSERT INTO files (
    user_id
@@ -45,3 +57,37 @@ WHERE
    user_id = :uid
    AND id = :fid
 ;
+
+-- name: create-analysis^
+INSERT INTO file_analysis (
+ , max_x
+ , max_y
+ , max_z
+ , max_end
+ , max_bed
+ , filament_id
+ , file_id
+)
+VALUES (
+   :max_x
+ , :max_y
+ , :max_z
+ , :max_end
+ , :max_bed
+ , :filament_id
+ , :file_id
+)
+RETURNING
+  id
+;
+
+-- name: list-unanalyzed-files
+SELECT
+   f.id
+ , f.filename
+FROM files f
+LEFT JOIN file_analysis fa
+   ON f.id = fa.file_id
+WHERE
+   fa.file_id IS NULL
+;
diff --git a/projects/tentacles/src/python/tentacles/sql/jobs.sql b/projects/tentacles/src/python/tentacles/sql/jobs.sql
index f1eaf7e..3d8ded8 100644
--- a/projects/tentacles/src/python/tentacles/sql/jobs.sql
+++ b/projects/tentacles/src/python/tentacles/sql/jobs.sql
@@ -69,10 +69,16 @@ WHERE
 -- name: list-job-queue
 SELECT
    *
-FROM jobs
+FROM jobs j
+INNER JOIN files f
+  ON j.file_id = f.id
+INNER JOIN file_analysis fa
+  ON fa.file_id = f.id
 WHERE
    finished_at IS NULL
-   AND (:uid IS NULL OR user_id = :uid)
+   AND (:uid IS NULL OR j.user_id = :uid)
+   AND f.id IS NOT NULL
+   AND fa.id IS NOT NULL
 ;
 
 -- name: poll-job-queue^
diff --git a/projects/tentacles/src/python/tentacles/sql/printers.sql b/projects/tentacles/src/python/tentacles/sql/printers.sql
index f3e6980..16eb881 100644
--- a/projects/tentacles/src/python/tentacles/sql/printers.sql
+++ b/projects/tentacles/src/python/tentacles/sql/printers.sql
@@ -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', 300, 300, 400, 100, 260, 1
+   'Creality CR-10v3', 310, 310, 400, 100, 260, 1
 );
 
 INSERT INTO printer_chassis (name, limit_x, limit_y, limit_z, limit_bed, limit_hotend, limit_tools) VALUES (
@@ -181,3 +181,14 @@ SELECT
  , name
  FROM filament
  ;
+
+-- name: create-filament^
+INSERT OR IGNORE INTO filament (
+   name
+)
+VALUES (
+   :name
+)
+RETURNING
+   id
+;
diff --git a/projects/tentacles/src/python/tentacles/sql/user_keys.sql b/projects/tentacles/src/python/tentacles/sql/user_keys.sql
index 54d2c69..9d841ef 100644
--- a/projects/tentacles/src/python/tentacles/sql/user_keys.sql
+++ b/projects/tentacles/src/python/tentacles/sql/user_keys.sql
@@ -77,7 +77,8 @@ 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!
+   AND (u.enabled_at IS NOT NULL -- and the user is not disabled!
+        OR u.group_id = 0)       -- or the user is a root
 ;
 
 -- name: refresh-key
diff --git a/projects/tentacles/src/python/tentacles/static/css/style.scss b/projects/tentacles/src/python/tentacles/static/css/style.scss
index 69be564..934724c 100644
--- a/projects/tentacles/src/python/tentacles/static/css/style.scss
+++ b/projects/tentacles/src/python/tentacles/static/css/style.scss
@@ -58,6 +58,10 @@ label {
   flex: 1;
 }
 
+.u-flex-wrap {
+  flex-wrap: wrap;
+}
+
 @media (max-width: 760px) {
   .file, .printer, .key, .job {
     flex-direction: column;
diff --git a/projects/tentacles/src/python/tentacles/templates/files_list.html.j2 b/projects/tentacles/src/python/tentacles/templates/files_list.html.j2
index 6861143..632bb7c 100644
--- a/projects/tentacles/src/python/tentacles/templates/files_list.html.j2
+++ b/projects/tentacles/src/python/tentacles/templates/files_list.html.j2
@@ -4,10 +4,10 @@
 {% if files %}
 {% for file in files %}
 <div class="file row u-flex">
-  <div class="details six columns">
+  <div class="details six columns u-flex u-flex-wrap">
     <span class="file-name">{{ file.filename }}</span>
-    <span class="file-sucesses">{{ file.print_successes }}</span> successes
-    <span class="file-failures">{{ file.print_failures }}</span> errors
+    <span class="file-sucesses">{{ file.print_successes }} successes</span>
+    <span class="file-failures">{{ file.print_failures }} errors</span>
   </div>
   <div class="controls u-flex u-ml-auto">
     {{ macros.start_job(file.id) }}
diff --git a/projects/tentacles/src/python/tentacles/templates/jobs_history.html.j2 b/projects/tentacles/src/python/tentacles/templates/jobs_history.html.j2
index cadc3c4..68441ed 100644
--- a/projects/tentacles/src/python/tentacles/templates/jobs_history.html.j2
+++ b/projects/tentacles/src/python/tentacles/templates/jobs_history.html.j2
@@ -4,11 +4,10 @@
 {% if jobs %}
 {% for job in jobs %}
 <div class="job row u-flex">
-  <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 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>
     {% if job.printer_id %}
     <div class="job-printer u-flex">
@@ -16,18 +15,20 @@
       <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>
       <span name="Runtime">{{ (datetime.fromisoformat(job.finished_at) - datetime.fromisoformat(job.started_at)) }}</span>
     </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 class="controls u-flex u-ml-auto">
+  </div>
+  <div class="controls four columns u-flex u-ml-auto">
     {% if ctx.uid %}
     {{ macros.duplicate_job(job.id) }}
     {{ macros.delete_job(job.id) }}
diff --git a/projects/tentacles/src/python/tentacles/templates/jobs_list.html.j2 b/projects/tentacles/src/python/tentacles/templates/jobs_list.html.j2
index 1846eb6..ef7599c 100644
--- a/projects/tentacles/src/python/tentacles/templates/jobs_list.html.j2
+++ b/projects/tentacles/src/python/tentacles/templates/jobs_list.html.j2
@@ -5,10 +5,9 @@
 {% for job in jobs %}
 <div class="job row u-flex">
   <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) }} {{ 'dot--basic' if not job.state else '' }}" style="--dot-size: 1em;"> </div>
-      <span name="state">{{ macros.job_state(job) }}</span>
+    <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.printer_id %}
     <div class="job-printer u-flex">
@@ -16,10 +15,6 @@
       <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>
@@ -27,6 +22,12 @@
     </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) }}
diff --git a/projects/tentacles/src/python/tentacles/workers.py b/projects/tentacles/src/python/tentacles/workers.py
index 956cc5d..c366159 100644
--- a/projects/tentacles/src/python/tentacles/workers.py
+++ b/projects/tentacles/src/python/tentacles/workers.py
@@ -17,6 +17,7 @@ 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
@@ -290,14 +291,35 @@ 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.filename))
+        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_running_jobs():
         output.append(repr(job))
+
     for printer in db.list_idle_printers():
         output.append(repr(printer))
+
+    for unanalyzed in db.list_unanalyzed_files():
+        output.append(repr(unanalyzed))
+
     print("\n".join(output))
 
+
 def toil(*fs):
     def _helper(*args, **kwargs):
         for f in fs:
diff --git a/projects/tentacles/test/python/conftest.py b/projects/tentacles/test/python/conftest.py
index a44ce2a..3121717 100644
--- a/projects/tentacles/test/python/conftest.py
+++ b/projects/tentacles/test/python/conftest.py
@@ -1,15 +1,21 @@
 #!/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.yield_fixture
+
+@pytest.fixture
 def db():
     conn = Db(":memory:")
     conn.connect()
+    conn.migrate()
     yield conn
     conn.close()
 
@@ -25,26 +31,23 @@ def password_testy():
 
 
 @pytest.fixture
-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,
-        ).id
+def uid_testy(db: Db, username_testy, password_testy) -> int:
+    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():
+def login_ttl() -> timedelta:
     return timedelta(hours=12)
 
 
 @pytest.fixture
 def sid_testy(db: Db, uid_testy, username_testy, password_testy, 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
+    res = db.try_login(username=username_testy, password=password_testy, ttl=login_ttl)
+    assert res.user_id == uid_testy
+    return res.id
diff --git a/projects/tentacles/test/python/test_gcode.py b/projects/tentacles/test/python/test_gcode.py
new file mode 100644
index 0000000..12ceb7c
--- /dev/null
+++ b/projects/tentacles/test/python/test_gcode.py
@@ -0,0 +1,54 @@
+#!/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
+; prusaslicer_config = end
+
+"""
+        )
+        == GcodeAnalysis(100, 100, 100, 100, 195, "PETG")
+    )
diff --git a/projects/tentacles/test/python/test_store.py b/projects/tentacles/test/python/test_store.py
index 3493188..8e3928b 100644
--- a/projects/tentacles/test/python/test_store.py
+++ b/projects/tentacles/test/python/test_store.py
@@ -3,12 +3,12 @@
 from tentacles.db import Db
 
 
-def test_db_initializes(store: Db):
-    assert isinstance(store, Db)
+def test_db_initializes(db: Db):
+    assert isinstance(db, Db)
 
 
-def test_db_savepoint(store: Db):
-    obj = store.savepoint()
+def test_db_savepoint(db: Db):
+    obj = db.savepoint()
 
     assert hasattr(obj, "__enter__")
     assert hasattr(obj, "__exit__")
diff --git a/projects/tentacles/test/python/test_store_users.py b/projects/tentacles/test/python/test_store_users.py
index c091fe0..c54513b 100644
--- a/projects/tentacles/test/python/test_store_users.py
+++ b/projects/tentacles/test/python/test_store_users.py
@@ -1,40 +1,39 @@
 #!/usr/bin/env python3
 
-from tentacles.store import Store
+from tentacles.db import Db
 
 
-def test_mkuser(store: Store, username_testy, password_testy):
-    res = store.try_create_user(
+def test_mkuser(db: Db, username_testy, password_testy):
+    res = db.try_create_user(
         username=username_testy, email=username_testy, password=password_testy
     )
     assert res
-    assert store.list_users() == [(res.id, username_testy)]
+    assert [(it.id, it.name) for it in db.list_users()] == [(res.id, username_testy)]
 
 
-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
-    )
+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)
     assert res is not None
-    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
+    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
 
 
-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)
+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)
     assert before != after
 
 
-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)
+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)
     assert new_key is not None
-    assert store.try_key(kid=new_key) == uid_testy
+    assert db.try_key(kid=new_key) == uid_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)
+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)

From a281f24689b324e037225a7381257df200382343 Mon Sep 17 00:00:00 2001
From: Reid 'arrdem' McKenzie <me@arrdem.com>
Date: Sat, 8 Jul 2023 17:35:17 -0600
Subject: [PATCH 2/2] More limits refinements

---
 projects/tentacles/src/python/gcode.py        |  6 ++++
 .../src/python/tentacles/sql/files.sql        |  7 +++-
 .../src/python/tentacles/sql/jobs.sql         | 14 ++++++--
 .../src/python/tentacles/sql/printers.sql     | 16 +++++++--
 .../tentacles/templates/streams.html.j2       |  4 +++
 .../tentacles/src/python/tentacles/workers.py | 34 ++++++++++++++-----
 projects/tentacles/test/python/test_gcode.py  |  3 +-
 7 files changed, 67 insertions(+), 17 deletions(-)

diff --git a/projects/tentacles/src/python/gcode.py b/projects/tentacles/src/python/gcode.py
index c2761a4..4131aef 100644
--- a/projects/tentacles/src/python/gcode.py
+++ b/projects/tentacles/src/python/gcode.py
@@ -37,6 +37,7 @@ class GcodeAnalysis:
     max_bed: int
     max_end: int
     filament: str
+    nozzle: int
 
 
 def parse_point(point: str) -> Tuple[int, int]:
@@ -88,6 +89,11 @@ def analyze_gcode_str(text: str) -> Optional[GcodeAnalysis]:
     else:
         return None
 
+    if "nozzle_diameter" in opts:
+        kwargs["nozzle"] = float(opts["nozzle_diameter"])
+    else:
+        return None
+
     return GcodeAnalysis(**kwargs)
 
 
diff --git a/projects/tentacles/src/python/tentacles/sql/files.sql b/projects/tentacles/src/python/tentacles/sql/files.sql
index 389d096..9a53828 100644
--- a/projects/tentacles/src/python/tentacles/sql/files.sql
+++ b/projects/tentacles/src/python/tentacles/sql/files.sql
@@ -16,6 +16,7 @@ CREATE TABLE IF NOT EXISTS file_analysis (
  , max_z INTEGER
  , max_end INTEGER
  , max_bed INTEGER
+ , nozzle_diameter FLOAT
  , filament_id INTEGER REFERENCES filament(id)
  , file_id INTEGER REFERENCES file(id)
 );
@@ -60,11 +61,12 @@ WHERE
 
 -- name: create-analysis^
 INSERT INTO file_analysis (
- , max_x
+   max_x
  , max_y
  , max_z
  , max_end
  , max_bed
+ , nozzle_diameter
  , filament_id
  , file_id
 )
@@ -74,6 +76,7 @@ VALUES (
  , :max_z
  , :max_end
  , :max_bed
+ , :nozzle
  , :filament_id
  , :file_id
 )
@@ -84,7 +87,9 @@ RETURNING
 -- 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
diff --git a/projects/tentacles/src/python/tentacles/sql/jobs.sql b/projects/tentacles/src/python/tentacles/sql/jobs.sql
index 3d8ded8..7c9ef2b 100644
--- a/projects/tentacles/src/python/tentacles/sql/jobs.sql
+++ b/projects/tentacles/src/python/tentacles/sql/jobs.sql
@@ -68,17 +68,25 @@ 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
-INNER JOIN file_analysis fa
+LEFT JOIN file_analysis fa
   ON fa.file_id = f.id
 WHERE
    finished_at IS NULL
    AND (:uid IS NULL OR j.user_id = :uid)
    AND f.id IS NOT NULL
-   AND fa.id IS NOT NULL
 ;
 
 -- name: poll-job-queue^
diff --git a/projects/tentacles/src/python/tentacles/sql/printers.sql b/projects/tentacles/src/python/tentacles/sql/printers.sql
index 16eb881..3901f46 100644
--- a/projects/tentacles/src/python/tentacles/sql/printers.sql
+++ b/projects/tentacles/src/python/tentacles/sql/printers.sql
@@ -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 INTEGER default 4;
+ALTER TABLE printers ADD nozzle_diameter FLOAT DEFAULT 0.4;
 
 -- name: try-create-printer^
 INSERT INTO printers (
@@ -116,8 +116,19 @@ 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
@@ -126,9 +137,8 @@ SELECT
  , c.limit_x
  , c.limit_y
  , c.limit_z
- , c.limit_hotend
  , c.limit_bed
- , c.limit_tools
+ , c.limit_hotend
  , p.nozzle_diameter
 FROM printers p
 LEFT JOIN (SELECT id, printer_id FROM jobs WHERE finished_at IS NULL) j
diff --git a/projects/tentacles/src/python/tentacles/templates/streams.html.j2 b/projects/tentacles/src/python/tentacles/templates/streams.html.j2
index 16d8713..450ca89 100644
--- a/projects/tentacles/src/python/tentacles/templates/streams.html.j2
+++ b/projects/tentacles/src/python/tentacles/templates/streams.html.j2
@@ -6,6 +6,10 @@
     <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>
diff --git a/projects/tentacles/src/python/tentacles/workers.py b/projects/tentacles/src/python/tentacles/workers.py
index c366159..dff20cb 100644
--- a/projects/tentacles/src/python/tentacles/workers.py
+++ b/projects/tentacles/src/python/tentacles/workers.py
@@ -147,10 +147,20 @@ 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 printer in db.list_idle_printers():
-        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}")
+    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
+            ):
+                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:
@@ -293,7 +303,13 @@ def send_emails(app, db: Db):
 
 def analyze_files(app: App, db: Db):
     for unanalyzed in db.list_unanalyzed_files():
-        record = analyze_gcode_file(Path(unanalyzed.filename))
+        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,
@@ -308,14 +324,14 @@ def analyze_files(app: App, db: Db):
 def debug_queue(app: App, db: Db):
     output = ["---"]
 
-    for job in db.list_running_jobs():
-        output.append(repr(job))
+    for job in db.list_job_queue(uid=None):
+        output.append("Job " + repr(job))
 
     for printer in db.list_idle_printers():
-        output.append(repr(printer))
+        output.append("Printer " + repr(printer))
 
     for unanalyzed in db.list_unanalyzed_files():
-        output.append(repr(unanalyzed))
+        output.append("Unanalyzed file " + repr(unanalyzed))
 
     print("\n".join(output))
 
diff --git a/projects/tentacles/test/python/test_gcode.py b/projects/tentacles/test/python/test_gcode.py
index 12ceb7c..5f1164a 100644
--- a/projects/tentacles/test/python/test_gcode.py
+++ b/projects/tentacles/test/python/test_gcode.py
@@ -46,9 +46,10 @@ more garbage
 ; 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")
+        == GcodeAnalysis(100, 100, 100, 100, 195, "PETG", 1.0)
     )