From 50812122126e6b99053e77ebf3f4c53621540e5d Mon Sep 17 00:00:00 2001
From: Dominic Gundel
-
+
diff --git a/docs/Anleitung-Orca.md b/docs/Anleitung-Orca.md
index 83b9cc4..0baec36 100644
--- a/docs/Anleitung-Orca.md
+++ b/docs/Anleitung-Orca.md
@@ -1,22 +1,22 @@
# Orca an Grotti
-Stand **18.10.2026**. Nach dem Export landet der G-code in der Bibliothek und Grotti öffnet sich als **eigenes Fenster** (Chrome/Edge-App auf dem Desktop). Drucker, ACE-Platz und Einreihen wählst du dort.
+Stand **25.10.2026**. Nach dem Export landet der G-code in der Bibliothek und Grotti öffnet sich als **eigenes Fenster** (Chrome/Edge-App auf dem Desktop). Drucker, ACE-Platz und Einreihen wählst du dort. Ziel ist die Instanz auf dem Pi (`http://10.10.1.41:8770/`), nicht mehr lokal.
## Einmal einrichten
-1. Grotti starten und im Browser anmelden (die Session bleibt für die Senden-Seite).
+1. Grotti auf dem Pi erreichbar machen und im Browser anmelden (die Session bleibt für die Senden-Seite).
2. Unter **Einstellungen → API-Token** ein Token anlegen (Rolle Operator reicht). Den Wert `grt_…` nur einmal sichtbar.
-3. Datei `data/orca-senden.json` anlegen (gleicher Ordner wie `config.json`):
+3. Datei `data/orca-senden.json` anlegen (im Grotti-Repo unter Windows, gleicher Ordner wie lokal `config.json`):
```json
{
- "url": "http://127.0.0.1:8770",
+ "url": "http://10.10.1.41:8770",
"token": "grt_…",
"browser": "app"
}
```
-`url` ist die Adresse, unter der du Grotti im Browser öffnest.
+`url` ist die Adresse des Pi (LAN-IP oder Hostname).
`browser`: `app` (Standard) = eigenes Desktop-Fenster; `tab` = klassischer Browser-Tab.
4. Im **Prozessprofil** (Druckqualität, z. B. „0.20mm Standard“): **Sonstiges → Nachbearbeitungsskripte**, eine Zeile:
@@ -34,7 +34,7 @@ Damit unter **Gerät** die Grotti-Oberfläche erscheint:
1. Orca → Drucker-Dropdown → **Physikalischer Drucker** (Verbindung/Stecker-Symbol).
2. **Host-Typ:** z. B. `Octo/Klipper` (egal, solange du nicht „Drucken“ an den Host schickst).
3. **Hostname / API:** leer lassen — oder nur eine Dummy-Adresse. **Nicht** die Moonraker-IP eines Kobras eintragen.
-4. **Device UI / Web-UI-URL:** `http://127.0.0.1:8770/` (Grotti muss laufen).
+4. **Device UI / Web-UI-URL:** `http://10.10.1.41:8770/` (Grotti auf dem Pi).
5. Speichern → Reiter **Gerät** öffnen → einmal bei Grotti anmelden.
Wichtig: Den Orca-Button **Drucken / Upload zum Host** nicht nutzen — der spricht Moonraker/OctoPrint und umgeht ACE-Zuordnung in Grotti. Weiterhin **G-code exportieren** + Nachbearbeitungsskript.
diff --git a/docs/internal/Lauf-Identitaet.md b/docs/internal/Lauf-Identitaet.md
index 3ec9e26..d5031d4 100644
--- a/docs/internal/Lauf-Identitaet.md
+++ b/docs/internal/Lauf-Identitaet.md
@@ -9,6 +9,8 @@ Zusammengelegt wird nur mit **Beweis**, nicht nur wegen Zeit und Dateiname:
Ein Neustart mit `print_duration` ≤ 60 s wird nie zusammengelegt.
+Ein Live-Lauf wird nur übernommen, wenn der Dateiname übereinstimmt (eine noch namenlose Hülle zählt zum neuen Druck). Ein Abbruch-Ereignis auf eine bereits gelöschte Zeile rollt den Abgleich nicht zurück.
+
## Pfade
| Pfad | Datei |
diff --git a/grotti/config.py b/grotti/config.py
index 94c93a0..87488d1 100644
--- a/grotti/config.py
+++ b/grotti/config.py
@@ -124,6 +124,14 @@ def _oauth(raw):
}
+def _http_threads(raw):
+ try:
+ count = int(raw.get("http_threads") or 16)
+ except (TypeError, ValueError):
+ count = 16
+ return max(2, min(32, count))
+
+
def _ensure_secret_key(path, raw):
key = str(raw.get("secret_key") or "").strip()
if key:
@@ -176,6 +184,7 @@ def load():
"alerts": _alerts(raw),
"auto_queue": bool(raw.get("auto_queue", True)),
"auto_retry": bool(raw.get("auto_retry", True)),
+ "http_threads": _http_threads(raw),
"_raw": raw,
"_path": path,
}
diff --git a/grotti/jobs.py b/grotti/jobs.py
index 19bf788..12761db 100644
--- a/grotti/jobs.py
+++ b/grotti/jobs.py
@@ -128,7 +128,11 @@ def is_filament_resume(
def undo_deduction(conn, run):
- """Spulenabzug eines Laufs zurücknehmen. Abschnitte bleiben."""
+ """Spulenabzug eines Laufs zurücknehmen.
+
+ Letzten Abschnitt wieder öffnen — nach Filamentpause schließt ein
+ vorzeitiger History-Abgleich den Abschnitt, bevor der Druck weiterläuft.
+ """
if not run or not run.get("deducted"):
return False
rows = conn.execute(
@@ -147,6 +151,20 @@ def undo_deduction(conn, run):
"UPDATE runs SET deducted = false, filament_cost = 0 WHERE id = %s",
(run["id"],),
)
+ conn.execute(
+ """
+ UPDATE run_segments
+ SET mm_end = NULL, closed_at = NULL
+ WHERE id = (
+ SELECT id FROM run_segments
+ WHERE run_id = %s
+ ORDER BY id DESC
+ LIMIT 1
+ )
+ AND mm_end IS NOT NULL
+ """,
+ (run["id"],),
+ )
return True
@@ -583,11 +601,15 @@ def apply_deduction(conn, run, energy, metadata=None):
final_mm = float(run.get("filament_used_mm") or 0)
total_mm = float(run.get("filament_total_mm") or 0)
deltas = usage.segment_deltas(segments, final_mm)
+ # Messwert (oder Soll) anteilig nach mm — nicht Soll gegen Soll-Gesamtlänge,
+ # sonst fehlen Gramm, wenn ein Abschnitt zu früh geschlossen wurde.
+ base_g = used if used > 0 else planned
+ split_mm = sum(d for _, _, d in deltas) or final_mm or total_mm
for spool_id, ace_slot, delta_mm in deltas:
spool = store.get_spool_row(conn, spool_id)
if not spool:
continue
- grams = usage.grams_from_mm(delta_mm, total_mm, planned)
+ grams = usage.grams_from_mm(delta_mm, split_mm, base_g)
cost = (grams / 1000.0) * float(spool.get("price_per_kg") or 0)
entries.append({
"spool_id": spool_id,
@@ -798,6 +820,10 @@ def _sync_one_job(conn, cfg, printer, job, watcher_resume=False):
conn, printer["host"], ended_at=ended, message=filename or state
)
store.set_printer_active_run(conn, printer["host"], None)
+ # Resume nach Filamentpause: History kann den alten Teil schon als „fertig“
+ # melden. Abzug und Segment-Schluss erst beim echten Druckende.
+ if watcher_resume:
+ return 0
if apply_deduction(conn, row, cfg["energy"], metadata=metadata):
return 1
return 0
@@ -835,7 +861,8 @@ def sync_history(conn, cfg, host=None, watcher_resume=False):
deducted += _sync_one_job(
conn, cfg, printer, job, watcher_resume=watcher_resume
)
- deducted += deduct_stale_live_runs(conn, cfg)
+ if not watcher_resume:
+ deducted += deduct_stale_live_runs(conn, cfg)
finally:
conn.execute("SELECT pg_advisory_unlock(%s)", (SYNC_LOCK_KEY,))
return {"gelesen": len(collected), "abgezogen": deducted}
diff --git a/grotti/library.py b/grotti/library.py
index 0478a15..a87aad7 100644
--- a/grotti/library.py
+++ b/grotti/library.py
@@ -46,7 +46,14 @@ def sync_watch(conn, cfg, force=False):
now = time.monotonic()
if not force and _LAST_WATCH_SYNC and now - _LAST_WATCH_SYNC < _WATCH_MIN_INTERVAL_S:
return {"gelesen": 0, "hinweis": "", "skipped": True}
+ known = {}
+ if not force:
+ for row in conn.execute(
+ "SELECT stored_path, size_bytes, mtime FROM files WHERE source = 'watch'"
+ ).fetchall():
+ known[row["stored_path"]] = (int(row["size_bytes"] or 0), float(row["mtime"] or 0))
seen = []
+ gelesen = 0
for path in folder.rglob("*"):
if not path.is_file() or skip_path(path, cfg["data_dir"]):
continue
@@ -54,14 +61,21 @@ def sync_watch(conn, cfg, force=False):
if not (lower.endswith(".gcode") or lower.endswith(".3mf")):
continue
try:
+ resolved = str(path.resolve())
+ seen.append(resolved)
+ if not force:
+ st = path.stat()
+ prev = known.get(resolved)
+ if prev and prev[0] == st.st_size and prev[1] == float(st.st_mtime):
+ continue
store.upsert_scanned(conn, meta_for(path), force=force)
- seen.append(str(path.resolve()))
+ gelesen += 1
except (OSError, zipfile.BadZipFile):
continue
store.forget_missing_watch(conn, seen)
link_watch_files_to_projects(conn, cfg)
_LAST_WATCH_SYNC = now
- return {"gelesen": len(seen), "hinweis": ""}
+ return {"gelesen": gelesen, "hinweis": ""}
def _apply_meta(conn, file_id, row, meta):
@@ -77,6 +91,7 @@ def _apply_meta(conn, file_id, row, meta):
"kind": meta.get("kind") or row.get("kind"),
"plate": meta.get("plate") or "",
"slice_meta": meta.get("slice_meta") or {},
+ "ace_trailer": bool(meta.get("ace_trailer")),
},
)
@@ -95,7 +110,7 @@ def refresh_one_file(conn, file_id, watch_dir=None):
row = store.get_file_row(conn, file_id) or row
except (OSError, zipfile.BadZipFile):
pass
- return store.file_out(row, watch_dir=watch_dir)
+ return store.file_out(row, watch_dir=watch_dir, conn=conn)
def filaments_need_paint_index(filaments):
@@ -276,7 +291,7 @@ def move_file(conn, cfg, file_id, folder):
dest_dir.mkdir(parents=True, exist_ok=True)
dest = dest_dir / src.name
if dest.resolve() == src.resolve() and source == "watch":
- return store.file_out(row, watch_dir=root)
+ return store.file_out(row, watch_dir=root, conn=conn)
if dest.exists() and dest.resolve() != src.resolve():
dest = unique_path(dest_dir, src.name)
try:
@@ -295,7 +310,7 @@ def move_file(conn, cfg, file_id, folder):
)
_link_file_to_project_folder(conn, file_id, str(dest.resolve()), root)
row = store.get_file_row(conn, file_id)
- return store.file_out(row, watch_dir=root)
+ return store.file_out(row, watch_dir=root, conn=conn)
def path_allowed_for_delete(stored_path, cfg):
@@ -365,7 +380,7 @@ def rename_library_file(conn, cfg, file_id, new_name):
raise GrottiError("Ungültiger Dateiname.", "datei")
dest = src.with_name(final)
if dest.resolve() == src.resolve():
- return store.file_out(row, watch_dir=cfg.get("watch_dir"))
+ return store.file_out(row, watch_dir=cfg.get("watch_dir"), conn=conn)
if dest.exists():
raise GrottiError("Unter diesem Namen liegt schon eine Datei.", "datei")
try:
@@ -382,7 +397,7 @@ def rename_library_file(conn, cfg, file_id, new_name):
mtime=stat.st_mtime,
)
row = store.get_file_row(conn, file_id)
- return store.file_out(row, watch_dir=cfg.get("watch_dir"))
+ return store.file_out(row, watch_dir=cfg.get("watch_dir"), conn=conn)
_PRINT_SUFFIXES = (".gcode", ".3mf", ".gcode.3mf")
@@ -559,7 +574,7 @@ def ensure_library_gcode(conn, cfg, host, filename):
if known:
path = Path(known.get("stored_path") or "")
if path.is_file():
- return store.file_out(known)
+ return store.file_out(known, conn=conn)
from grotti import moonraker
pulled = moonraker.download_gcode(host, base)
diff --git a/grotti/migrations/017_ace_trailer.sql b/grotti/migrations/017_ace_trailer.sql
new file mode 100644
index 0000000..8c5e253
--- /dev/null
+++ b/grotti/migrations/017_ace_trailer.sql
@@ -0,0 +1,3 @@
+-- ACE-Trailer-Flag einmalig speichern, damit die Dateiliste die Datei nicht erneut öffnet.
+ALTER TABLE files
+ ADD COLUMN IF NOT EXISTS ace_trailer boolean;
diff --git a/grotti/store.py b/grotti/store.py
index 0764095..6d3db6d 100644
--- a/grotti/store.py
+++ b/grotti/store.py
@@ -201,14 +201,23 @@ def file_folder(stored_path, source, watch_dir=None):
return "" if parent in (".", "") else parent
-def file_out(row, watch_dir=None):
+def file_out(row, watch_dir=None, conn=None):
# stored_path bleibt intern (get_file_row / list_file_rows), nicht in der API.
- from grotti.parse import path_has_ace_trailer
-
stored = row.get("stored_path") or ""
- ace_trailer = False
- if stored:
- ace_trailer = path_has_ace_trailer(stored, kind=row.get("kind") or "", plate=row.get("plate") or "")
+ ace_trailer = row.get("ace_trailer")
+ if ace_trailer is None and stored:
+ from grotti.parse import path_has_ace_trailer
+
+ ace_trailer = path_has_ace_trailer(
+ stored, kind=row.get("kind") or "", plate=row.get("plate") or ""
+ )
+ if conn is not None and row.get("id") is not None:
+ conn.execute(
+ "UPDATE files SET ace_trailer = %s WHERE id = %s",
+ (bool(ace_trailer), row["id"]),
+ )
+ else:
+ ace_trailer = bool(ace_trailer)
return {
"id": row["id"],
"project_id": row["project_id"],
@@ -482,7 +491,7 @@ def get_project(conn, project_id, watch_dir=None):
file_stats = _file_print_stats(conn, [item["id"] for item in files])
file_outs = []
for item in files:
- fo = file_out(item, watch_dir=watch_dir)
+ fo = file_out(item, watch_dir=watch_dir, conn=conn)
st = file_stats.get(item["id"]) or {"printed": 0, "queued": 0, "ok_once": False}
fo["printed"] = st["printed"]
fo["queued"] = st["queued"]
@@ -625,7 +634,7 @@ def list_files(
sql += " LIMIT %s OFFSET %s"
params.extend([limit, offset])
rows = conn.execute(sql, params).fetchall()
- items = [file_out(row, watch_dir=watch_dir) for row in rows]
+ items = [file_out(row, watch_dir=watch_dir, conn=conn) for row in rows]
return {"items": items, "total": total, "limit": limit, "offset": int(offset or 0)}
@@ -637,11 +646,11 @@ def upsert_scanned(conn, meta, force=False):
statement = """
INSERT INTO files (
name, stored_path, source, kind, printer_family, filaments, weight_g,
- estimated_s, thumb_entry, plate, size_bytes, mtime, slice_meta
+ estimated_s, thumb_entry, plate, size_bytes, mtime, slice_meta, ace_trailer
) VALUES (
%(name)s, %(stored_path)s, 'watch', %(kind)s, %(printer_family)s, %(filaments)s,
%(weight_g)s, %(estimated_s)s, %(thumb_entry)s, %(plate)s, %(size_bytes)s, %(mtime)s,
- %(slice_meta)s
+ %(slice_meta)s, %(ace_trailer)s
)
ON CONFLICT (source, stored_path) DO UPDATE SET
name = EXCLUDED.name,
@@ -654,7 +663,8 @@ def upsert_scanned(conn, meta, force=False):
plate = EXCLUDED.plate,
size_bytes = EXCLUDED.size_bytes,
mtime = EXCLUDED.mtime,
- slice_meta = EXCLUDED.slice_meta
+ slice_meta = EXCLUDED.slice_meta,
+ ace_trailer = EXCLUDED.ace_trailer
"""
if not force:
statement += """
@@ -664,6 +674,7 @@ def upsert_scanned(conn, meta, force=False):
payload = dict(meta)
payload["filaments"] = Json(meta.get("filaments") or [])
payload["slice_meta"] = Json(meta.get("slice_meta") or {})
+ payload["ace_trailer"] = bool(meta.get("ace_trailer"))
conn.execute(statement, payload)
@@ -672,11 +683,11 @@ def insert_upload(conn, meta):
"""
INSERT INTO files (
name, stored_path, source, kind, printer_family, filaments, weight_g,
- estimated_s, thumb_entry, plate, size_bytes, mtime, project_id, slice_meta
+ estimated_s, thumb_entry, plate, size_bytes, mtime, project_id, slice_meta, ace_trailer
) VALUES (
%(name)s, %(stored_path)s, 'upload', %(kind)s, %(printer_family)s, %(filaments)s,
%(weight_g)s, %(estimated_s)s, %(thumb_entry)s, %(plate)s, %(size_bytes)s, %(mtime)s,
- %(project_id)s, %(slice_meta)s
+ %(project_id)s, %(slice_meta)s, %(ace_trailer)s
)
RETURNING *
""",
@@ -684,9 +695,10 @@ def insert_upload(conn, meta):
**meta,
"filaments": Json(meta.get("filaments") or []),
"slice_meta": Json(meta.get("slice_meta") or {}),
+ "ace_trailer": bool(meta.get("ace_trailer")),
},
).fetchone()
- return file_out(row)
+ return file_out(row, conn=conn)
def update_file(conn, file_id, fields, watch_dir=None):
@@ -702,15 +714,18 @@ def update_file(conn, file_id, fields, watch_dir=None):
if "slice_meta" in fields:
sets.append("slice_meta = %s")
values.append(Json(fields.get("slice_meta") or {}))
+ if "ace_trailer" in fields:
+ sets.append("ace_trailer = %s")
+ values.append(bool(fields.get("ace_trailer")))
if not sets:
row = get_file_row(conn, file_id)
- return file_out(row, watch_dir=watch_dir) if row else None
+ return file_out(row, watch_dir=watch_dir, conn=conn) if row else None
values.append(file_id)
row = conn.execute(
"UPDATE files SET %s WHERE id = %%s RETURNING *" % ", ".join(sets),
values,
).fetchone()
- return file_out(row, watch_dir=watch_dir) if row else None
+ return file_out(row, watch_dir=watch_dir, conn=conn) if row else None
def update_file_location(conn, file_id, stored_path, name=None, size_bytes=None, mtime=None, source=None):
@@ -1023,6 +1038,70 @@ def spool_at_slot(conn, host, slot):
).fetchone()
+def prev_spool_id_for_slot(conn, host, slot, live_run, exclude_spool_id, old_rows=None):
+ """Vorige Spule für Segment 0→jetzt: Platz → ACE-Map/Send → Abschnitt → Lauf."""
+ try:
+ exclude = int(exclude_spool_id)
+ except (TypeError, ValueError):
+ return None
+ for row in old_rows or []:
+ try:
+ sid = int(row["id"])
+ except (TypeError, ValueError, KeyError):
+ continue
+ if sid != exclude:
+ return sid
+
+ filename = (live_run or {}).get("filename") or ""
+ sent = latest_send(conn, host, filename) if host and filename else None
+ if sent:
+ from grotti import acm
+
+ mapping = acm.normalize_mapping(sent.get("ace_map") or [])
+ slot_ok = False
+ if slot is None:
+ slot_ok = not mapping
+ elif mapping:
+ try:
+ ams = int(slot) - 1
+ slot_ok = any(int(item["ams_index"]) == ams for item in mapping)
+ except (TypeError, ValueError):
+ slot_ok = False
+ if slot_ok:
+ hint = sent.get("spool_id")
+ try:
+ hint = int(hint) if hint is not None else None
+ except (TypeError, ValueError):
+ hint = None
+ if hint and hint != exclude:
+ return hint
+
+ run_id = (live_run or {}).get("id")
+ if run_id:
+ for seg in reversed(list_run_segments(conn, run_id)):
+ try:
+ seg_slot = seg.get("ace_slot")
+ if slot is None:
+ if seg_slot is not None:
+ continue
+ elif seg_slot is None or int(seg_slot) != int(slot):
+ continue
+ sid = int(seg["spool_id"])
+ except (TypeError, ValueError, KeyError):
+ continue
+ if sid != exclude:
+ return sid
+
+ hint = (live_run or {}).get("spool_id")
+ try:
+ hint = int(hint) if hint is not None else None
+ except (TypeError, ValueError):
+ hint = None
+ if hint and hint != exclude:
+ return hint
+ return None
+
+
def file_by_name(conn, filename):
base = (filename or "").replace("\\", "/").rsplit("/", 1)[-1]
if not base:
@@ -1981,8 +2060,30 @@ def find_open_history_run(conn, host, filename=""):
).fetchone()
+def latest_run_id(conn, host, filename):
+ """Neueste Lauf-ID dieser Datei auf dem Drucker."""
+ host = (host or "").strip()
+ filename = (filename or "").strip()
+ if not host or not filename:
+ return None
+ row = conn.execute(
+ """
+ SELECT id FROM runs
+ WHERE printer_host = %s AND filename = %s
+ ORDER BY id DESC
+ LIMIT 1
+ """,
+ (host, filename),
+ ).fetchone()
+ return row["id"] if row else None
+
+
def find_live_run(conn, host, filename=""):
- """Offener Live-Lauf für diesen Drucker (Watcher)."""
+ """Offener Live-Lauf für diesen Drucker (Watcher).
+
+ Mit Dateiname nur derselbe Lauf oder eine noch namenlose Hülle.
+ Ein anderer Dateiname ist ein anderer Druck und wird nicht geliefert.
+ """
if filename:
row = conn.execute(
"""
@@ -1998,6 +2099,18 @@ def find_live_run(conn, host, filename=""):
).fetchone()
if row:
return row
+ return conn.execute(
+ """
+ SELECT * FROM runs
+ WHERE printer_host = %s
+ AND moonraker_job_id LIKE 'live:%%'
+ AND state IN ('in_progress', 'printing', 'paused')
+ AND COALESCE(filename, '') = ''
+ ORDER BY id DESC
+ LIMIT 1
+ """,
+ (host,),
+ ).fetchone()
return conn.execute(
"""
SELECT * FROM runs
@@ -2046,13 +2159,18 @@ def ensure_live_run(conn, host, printer_name, filename, spool_id=None):
return hist
row = find_live_run(conn, host, filename)
if row:
- if filename and row.get("filename") != filename:
+ existing_name = (row.get("filename") or "").strip()
+ # Anderer Dateiname ist ein anderer Druck — nicht umbenennen.
+ if filename and existing_name and existing_name != filename:
+ row = None
+ elif filename and row.get("filename") != filename:
conn.execute(
"UPDATE runs SET filename = %s WHERE id = %s",
(filename, row["id"]),
)
row = get_run_row(conn, row["id"])
- if spool_id and row and row.get("spool_id") != spool_id:
+ if row:
+ if spool_id and row.get("spool_id") != spool_id:
conn.execute("UPDATE runs SET spool_id = %s WHERE id = %s", (spool_id, row["id"]))
row = get_run_row(conn, row["id"])
return row
@@ -2174,6 +2292,10 @@ def adopt_live_run(conn, host, filename, real_run_id):
real = get_run_row(conn, real_run_id)
if not real:
return
+ live_name = (live.get("filename") or "").strip()
+ want = (filename or real.get("filename") or "").strip()
+ if live_name and want and live_name != want:
+ return
live_start = live.get("started_at")
real_end = real.get("ended_at")
# Gleiche Datei, neuer Druck: Live nicht an schon beendeten History-Job hängen.
@@ -2387,7 +2509,7 @@ def list_run_segments(conn, run_id):
FROM run_segments seg
JOIN spools s ON s.id = seg.spool_id
WHERE seg.run_id = %s
- ORDER BY seg.id
+ ORDER BY seg.mm_start, seg.id
""",
(run_id,),
).fetchall()
diff --git a/grotti/toolpath.py b/grotti/toolpath.py
index 2935977..a0dcdaa 100644
--- a/grotti/toolpath.py
+++ b/grotti/toolpath.py
@@ -1,6 +1,7 @@
"""G-code zu einem dünnen 3D-Pfad für den Viewer."""
import re
+import threading
import zipfile
from pathlib import Path
@@ -10,6 +11,8 @@ AXIS = re.compile(r"([XYZE])\s*(-?\d*\.?\d+)", re.IGNORECASE)
# Cache-/Viewer-Version: ganze Z-Höhen dicht behalten (keine gesprenkelten Stichproben)
TOOLPATH_VERSION = 8
+# Die volle Bahn liegt kurz im Speicher. Parallel auf einem Pi mit 2 GB füllt das den Swap.
+_build_lock = threading.Lock()
def _gcode_text(path: Path) -> str:
@@ -111,6 +114,11 @@ def _thin_points(points, layer_breaks, max_points):
def build_toolpath(path: Path, max_points=220000):
+ with _build_lock:
+ return _build_toolpath(path, max_points)
+
+
+def _build_toolpath(path: Path, max_points=220000):
text = _gcode_text(path)
if not text:
return {
diff --git a/grotti/version.py b/grotti/version.py
index d119336..908b093 100644
--- a/grotti/version.py
+++ b/grotti/version.py
@@ -1,3 +1,3 @@
"""Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen."""
-__version__ = "3.6.0"
+__version__ = "3.6.1"
diff --git a/grotti/watcher.py b/grotti/watcher.py
index a73fe30..ce940f9 100644
--- a/grotti/watcher.py
+++ b/grotti/watcher.py
@@ -133,6 +133,14 @@ class StateWatcher:
file_changed = False
if prev_offline and state in ("printing", "paused"):
file_changed = self._release_replaced_run(conn, host, name, filename)
+ elif state == "printing" and filename and prev_state in (
+ "printing", "paused", "cancelled", "standby",
+ ):
+ # Abbruch verpasst oder sofort neu gestartet: alte Datei nicht umbenennen.
+ existing = store.find_live_run(conn, host)
+ old_name = (existing or {}).get("filename") or ""
+ if old_name and old_name != filename:
+ file_changed = self._release_replaced_run(conn, host, name, filename)
starting = (
(
bool(prev_state)
@@ -256,16 +264,21 @@ class StateWatcher:
store.set_bed_needs_clear(conn, host, True, file_label)
store.abandon_live_runs(conn, host)
store.set_printer_active_run(conn, host, None)
+ emit_id = run_id or prev.get("run_id") or (dangling or {}).get("id")
+ label = file_label if file_label != state else ""
+ if emit_id and not store.get_run_row(conn, emit_id):
+ # History-Abgleich hat den Live-Lauf übernommen und gelöscht.
+ emit_id = store.latest_run_id(conn, host, label)
self._emit(
conn,
host,
name,
"abbruch",
"%s: Druck abgebrochen (%s)" % (
- name, file_label if file_label != state else "ohne Datei",
+ name, label or "ohne Datei",
),
- file_label if file_label != state else "",
- run_id or prev.get("run_id") or (dangling or {}).get("id"),
+ label,
+ emit_id,
)
# prev-Zustand behalten, bis die Hits reichen.
if hits < 3 and from_active:
@@ -351,12 +364,21 @@ class StateWatcher:
return False
if contact_gap_keeps_run(existing.get("filename") or "", filename, "printing"):
return False
+ label = existing.get("filename") or "ohne Datei"
+ old_id = existing["id"]
try:
- jobs.sync_history(conn, self.cfg)
+ jobs.sync_history(conn, self.cfg, host=host)
except Exception as exc:
log.warning("Watcher Abgleich nach Offline %s: %s", host, exc)
existing = store.find_live_run(conn, host)
if not existing:
+ # History hat den Live-Lauf übernommen. Ereignis an den überlebenden Lauf.
+ emit_id = old_id if store.get_run_row(conn, old_id) else store.latest_run_id(conn, host, label)
+ self._emit(
+ conn, host, name, "abbruch",
+ "%s: Druck unterbrochen (%s)" % (name, label),
+ label, emit_id,
+ )
return True
if contact_gap_keeps_run(existing.get("filename") or "", filename, "printing"):
return False
@@ -488,7 +510,11 @@ class StateWatcher:
})
def _ensure_start_segment(self, conn, host, filename, live, run_id):
- """Nur externe Einzelspule: ein Abschnitt. ACE-Farben ohne Wechsel → Anteil-Split."""
+ """Startabschnitte: externe Spule oder ACE-Plätze mit eingelegter Spule.
+
+ Ohne Rollenwechsel bleibt ACE-Abzug Anteil-Split (ein Segment je Platz).
+ Mit Wechsel greift die mm-Aufteilung innerhalb der Farbe (0→jetzt).
+ """
if not run_id:
return
existing = store.list_run_segments(conn, run_id)
@@ -499,9 +525,18 @@ class StateWatcher:
from grotti import acm
mapping = acm.normalize_mapping(ace) if ace else []
- if mapping:
- return
used_mm = usage.as_float(live.get("filament_used"))
+ if mapping:
+ seen = set()
+ for item in mapping:
+ slot = int(item["ams_index"]) + 1
+ if slot in seen:
+ continue
+ seen.add(slot)
+ row = store.spool_at_slot(conn, host, slot)
+ if row:
+ store.open_segment(conn, run_id, row["id"], slot, used_mm)
+ return
rows = conn.execute(
"""
SELECT id FROM spools
@@ -552,6 +587,10 @@ class StateWatcher:
return True
def _emit(self, conn, host, printer, kind, message, filename, run_id=None):
+ # Eine gelöschte Lauf-ID (Live wurde gerade übernommen) darf den Tick
+ # nicht am Fremdschlüssel zurückrollen — sonst ist der Abbruch weg.
+ if run_id is not None and not store.get_run_row(conn, run_id):
+ run_id = None
notify.record(
conn, self.cfg, host, printer, kind, message, filename,
dedupe_s=60, run_id=run_id,
diff --git a/packaging/config.pi.json b/packaging/config.pi.json
index 84aaeb5..df56b5d 100644
--- a/packaging/config.pi.json
+++ b/packaging/config.pi.json
@@ -8,6 +8,7 @@
"secret_key": "",
"fqdn": "",
"log_level": "INFO",
+ "http_threads": 8,
"auto_queue": true,
"auto_retry": true,
"oauth": {
diff --git a/packaging/grotti.service b/packaging/grotti.service
index b83b0ed..ad0f3e2 100644
--- a/packaging/grotti.service
+++ b/packaging/grotti.service
@@ -15,6 +15,9 @@ Environment=PYTHONUNBUFFERED=1
ExecStart=/usr/lib/grotti/venv/bin/python /usr/lib/grotti/server.py
Restart=on-failure
RestartSec=5
+# Stopp nicht 90 s hängen: nach TimeoutStopSec folgt SIGKILL
+TimeoutStopSec=20
+KillMode=mixed
# Logs → journald (journalctl -u grotti -f)
StandardOutput=journal
StandardError=journal
diff --git a/server.py b/server.py
index 971b404..dca16da 100644
--- a/server.py
+++ b/server.py
@@ -111,7 +111,13 @@ def main():
pass
log.info("Grotti: http://%s:%s/", cfg["host"], cfg["port"])
- serve(app, host=cfg["host"], port=cfg["port"], threads=16, channel_timeout=120)
+ serve(
+ app,
+ host=cfg["host"],
+ port=cfg["port"],
+ threads=int(cfg.get("http_threads") or 16),
+ channel_timeout=120,
+ )
if __name__ == "__main__":
diff --git a/tests/test_jobs_spool.py b/tests/test_jobs_spool.py
index c2d6648..2288262 100644
--- a/tests/test_jobs_spool.py
+++ b/tests/test_jobs_spool.py
@@ -85,7 +85,20 @@ class ChooseSpoolTests(unittest.TestCase):
self.assertTrue(jobs._spool_valid_for_end(spool, end))
+class UndoDeductionTests(unittest.TestCase):
+ def test_reopens_last_segment(self):
+ conn = MagicMock()
+ conn.execute.return_value.fetchall.return_value = [
+ {"spool_id": 7, "weight_g": 10},
+ ]
+ run = {"id": 42, "deducted": True}
+ self.assertTrue(jobs.undo_deduction(conn, run))
+ sqls = [str(call.args[0]) for call in conn.execute.call_args_list]
+ self.assertTrue(any("UPDATE run_segments" in sql and "mm_end = NULL" in sql for sql in sqls))
+
+
class SteuernAliasTests(unittest.TestCase):
+
@patch("grotti.moonraker.gcode_script", return_value={"ok": True})
def test_value_alias_for_temp(self, gcode):
moonraker.steuern("10.0.0.1", "nozzle", {"value": 210})
diff --git a/tests/test_spool_segments.py b/tests/test_spool_segments.py
new file mode 100644
index 0000000..5363c82
--- /dev/null
+++ b/tests/test_spool_segments.py
@@ -0,0 +1,127 @@
+"""ACE-Startabschnitte und Spulenwechsel 0→jetzt."""
+
+import unittest
+from unittest import mock
+
+from grotti import store
+from grotti.watcher import StateWatcher
+
+
+class PrevSpoolForSlotTests(unittest.TestCase):
+ def test_prefers_old_at_slot(self):
+ conn = mock.Mock()
+ live = {"id": 1, "filename": "a.gcode", "spool_id": 99}
+ got = store.prev_spool_id_for_slot(
+ conn, "h", 4, live, 21, old_rows=[{"id": 18}],
+ )
+ self.assertEqual(got, 18)
+
+ def test_ace_map_send_when_old_empty(self):
+ conn = mock.Mock()
+ live = {"id": 33805, "filename": "x.gcode", "spool_id": None}
+ with mock.patch(
+ "grotti.store.latest_send",
+ return_value={
+ "ace_map": [{"paint_index": 0, "ams_index": 3}],
+ "spool_id": 18,
+ },
+ ):
+ got = store.prev_spool_id_for_slot(conn, "h", 4, live, 21, old_rows=[])
+ self.assertEqual(got, 18)
+
+ def test_last_segment_on_slot(self):
+ conn = mock.Mock()
+ live = {"id": 7, "filename": "x.gcode", "spool_id": None}
+ with mock.patch("grotti.store.latest_send", return_value={"ace_map": [], "spool_id": None}), \
+ mock.patch(
+ "grotti.store.list_run_segments",
+ return_value=[
+ {"spool_id": 10, "ace_slot": 2, "mm_start": 0, "mm_end": 100},
+ {"spool_id": 18, "ace_slot": 4, "mm_start": 0, "mm_end": 500},
+ ],
+ ):
+ got = store.prev_spool_id_for_slot(conn, "h", 4, live, 21, old_rows=[])
+ self.assertEqual(got, 18)
+
+
+class EnsureStartSegmentAceTests(unittest.TestCase):
+ def test_opens_mapped_slots_with_spool(self):
+ watcher = StateWatcher("postgresql://x", {})
+ conn = mock.Mock()
+ live = {"filament_used": 0}
+ with mock.patch("grotti.watcher.store.list_run_segments", return_value=[]), \
+ mock.patch(
+ "grotti.watcher.store.latest_send",
+ return_value={"ace_map": [{"paint_index": 0, "ams_index": 3}]},
+ ), \
+ mock.patch(
+ "grotti.watcher.store.spool_at_slot",
+ return_value={"id": 18},
+ ) as at_slot, \
+ mock.patch("grotti.watcher.store.open_segment") as open_seg:
+ watcher._ensure_start_segment(conn, "h", "a.gcode", live, 33805)
+ at_slot.assert_called_once_with(conn, "h", 4)
+ open_seg.assert_called_once_with(conn, 33805, 18, 4, 0.0)
+
+ def test_ace_without_spool_opens_nothing(self):
+ watcher = StateWatcher("postgresql://x", {})
+ conn = mock.Mock()
+ with mock.patch("grotti.watcher.store.list_run_segments", return_value=[]), \
+ mock.patch(
+ "grotti.watcher.store.latest_send",
+ return_value={"ace_map": [{"paint_index": 0, "ams_index": 3}]},
+ ), \
+ mock.patch("grotti.watcher.store.spool_at_slot", return_value=None), \
+ mock.patch("grotti.watcher.store.open_segment") as open_seg:
+ watcher._ensure_start_segment(conn, "h", "a.gcode", {"filament_used": 0}, 1)
+ open_seg.assert_not_called()
+
+
+class SpoolLoadFallbackSegmentsTests(unittest.TestCase):
+ """ACE ohne Segment + Einlegen ohne old → 0→jetzt + neuer offener Abschnitt."""
+
+ def test_creates_closed_and_open_segment(self):
+ conn = mock.Mock()
+ live = {"id": 33805, "filename": "x.gcode", "spool_id": None, "state": "in_progress"}
+ opened = []
+ closed = []
+
+ def open_segment(_c, run_id, spool_id, ace_slot, mm_start):
+ opened.append((run_id, spool_id, ace_slot, mm_start))
+ return True
+
+ def close_open_segments(_c, run_id, ace_slot, mm_end):
+ closed.append((run_id, ace_slot, mm_end))
+ # Erster Aufruf: nichts offen; zweiter: nach open_segment
+ return 1 if opened else 0
+
+ with mock.patch("grotti.store.close_open_segments", side_effect=close_open_segments), \
+ mock.patch("grotti.store.open_segment", side_effect=open_segment), \
+ mock.patch(
+ "grotti.store.prev_spool_id_for_slot",
+ return_value=18,
+ ):
+ # Logik wie in spool_load (Auszug)
+ slot = 4
+ spool_id = 21
+ mm_now = 5291.0
+ old = []
+ closed_n = store.close_open_segments(conn, live["id"], slot, mm_now)
+ if not closed_n and old:
+ pass
+ elif not closed_n and mm_now > 0:
+ prev_id = store.prev_spool_id_for_slot(
+ conn, "h", slot, live, spool_id, old_rows=old,
+ )
+ if prev_id:
+ store.open_segment(conn, live["id"], prev_id, slot, 0)
+ store.close_open_segments(conn, live["id"], slot, mm_now)
+ store.open_segment(conn, live["id"], spool_id, slot, mm_now)
+
+ self.assertEqual(opened[0], (33805, 18, 4, 0))
+ self.assertEqual(closed[-1], (33805, 4, 5291.0))
+ self.assertEqual(opened[-1], (33805, 21, 4, 5291.0))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_watcher_offline.py b/tests/test_watcher_offline.py
index 60f6439..0c51df5 100644
--- a/tests/test_watcher_offline.py
+++ b/tests/test_watcher_offline.py
@@ -137,6 +137,69 @@ class WatcherOfflineTests(unittest.TestCase):
kinds = [call.args[3] for call in emit.call_args_list]
self.assertEqual(kinds, ["abbruch", "offline", "start"])
+ def test_other_file_without_offline_releases_the_old_run(self):
+ live = {
+ "host": HOST,
+ "name": "Kobra S1",
+ "state": "printing",
+ "ok": True,
+ "filename": "andere.gcode",
+ "weight": 0,
+ }
+ old = {"id": 42, "filename": FILE}
+ new = {"id": 99, "filename": "andere.gcode"}
+ seen = {"n": 0}
+
+ def live_run(_conn, _host, filename=""):
+ seen["n"] += 1
+ if seen["n"] <= 3:
+ return old
+ return None
+
+ with mock.patch.object(self.watcher, "_emit") as emit, \
+ mock.patch.object(self.watcher, "_track_print", return_value=new), \
+ mock.patch.object(self.watcher, "_ensure_start_segment"), \
+ mock.patch.object(self.watcher, "_check_spool", return_value=False), \
+ mock.patch("grotti.watcher.store.find_live_run", side_effect=live_run), \
+ mock.patch("grotti.watcher.store.abandon_live_runs") as abandon, \
+ mock.patch("grotti.watcher.jobs.sync_history") as sync:
+ self.watcher._one(mock.Mock(), live)
+ sync.assert_called_once()
+ abandon.assert_called()
+ self.assertIn("abbruch", [call.args[3] for call in emit.call_args_list])
+ self.assertIn("start", [call.args[3] for call in emit.call_args_list])
+
+ def test_cancel_event_survives_deleted_live_run(self):
+ self.watcher._cancel_hits[HOST] = 2
+ live = {
+ "host": HOST,
+ "name": "Kobra S1",
+ "state": "cancelled",
+ "ok": True,
+ "filename": FILE,
+ }
+ dangling = {"id": 42, "filename": FILE, "state": "in_progress"}
+
+ def row(_conn, run_id):
+ if run_id == 77:
+ return {"id": 77, "filename": FILE}
+ return None
+
+ with mock.patch("grotti.watcher.store.find_live_run", side_effect=[dangling, None]), \
+ mock.patch("grotti.watcher.store.find_open_history_run", return_value=None), \
+ mock.patch("grotti.watcher.store.get_run_row", side_effect=row), \
+ mock.patch("grotti.watcher.store.latest_run_id", return_value=77), \
+ mock.patch("grotti.watcher.store.abandon_live_runs"), \
+ mock.patch("grotti.watcher.store.set_bed_needs_clear"), \
+ mock.patch("grotti.watcher.store.set_printer_active_run"), \
+ mock.patch("grotti.watcher.jobs.sync_history") as sync, \
+ mock.patch("grotti.watcher.notify.record") as record:
+ self.watcher._one(mock.Mock(), live)
+ sync.assert_called_once()
+ record.assert_called()
+ self.assertEqual(record.call_args.kwargs["run_id"], 77)
+ self.assertEqual(record.call_args.args[4], "abbruch")
+
if __name__ == "__main__":
unittest.main()
diff --git a/web/app.py b/web/app.py
index 9979fa9..69b5ee7 100644
--- a/web/app.py
+++ b/web/app.py
@@ -2485,6 +2485,15 @@ def create_app(cfg):
for row in old:
store.open_segment(conn(), live_run["id"], row["id"], slot, 0)
store.close_open_segments(conn(), live_run["id"], slot, mm_now)
+ elif not closed and mm_now > 0:
+ # ACE ohne Startabschnitt oder alte Rolle schon im Regal —
+ # slot-genau: Platz / ACE-Map / letzter Abschnitt / Lauf-Hinweis.
+ prev_id = store.prev_spool_id_for_slot(
+ conn(), host, slot, live_run, spool_id, old_rows=old,
+ )
+ if prev_id:
+ store.open_segment(conn(), live_run["id"], prev_id, slot, 0)
+ store.close_open_segments(conn(), live_run["id"], slot, mm_now)
for row in old:
store.update_spool(conn(), row["id"], {"printer_host": "", "ace_slot": None})
store.add_event(
@@ -2717,7 +2726,7 @@ def create_app(cfg):
"samples": samples,
"labels": store.list_run_labels(conn(), run_id),
"events": store.list_run_events(conn(), run_id),
- "file": store.file_out(file_row, watch_dir=cfg.get("watch_dir")) if file_row else None,
+ "file": store.file_out(file_row, watch_dir=cfg.get("watch_dir"), conn=conn()) if file_row else None,
"project": project,
"energy": energy,
})
diff --git a/web/static/grotti.css b/web/static/grotti.css
index 09c8c03..685f9c5 100644
--- a/web/static/grotti.css
+++ b/web/static/grotti.css
@@ -64,8 +64,8 @@
--st-error-soft: rgba(248, 113, 113, 0.14);
--st-offline: #71717a;
--st-offline-soft: rgba(255, 255, 255, 0.04);
- --st-waiting: #f97316;
- --st-waiting-soft: rgba(249, 115, 22, 0.14);
+ --st-waiting: #f59e0b;
+ --st-waiting-soft: rgba(245, 158, 11, 0.16);
--st-idle: #a1a1aa;
--st-idle-soft: rgba(255, 255, 255, 0.05);
--ease: cubic-bezier(0.2, 0.7, 0.2, 1);
@@ -105,8 +105,8 @@ html[data-theme="light"] {
--st-error-soft: rgba(220, 38, 38, 0.1);
--st-offline: #73737c;
--st-offline-soft: rgba(0, 0, 0, 0.04);
- --st-waiting: #ea580c;
- --st-waiting-soft: rgba(234, 88, 12, 0.1);
+ --st-waiting: #b45309;
+ --st-waiting-soft: rgba(180, 83, 9, 0.12);
--st-idle: #5c5c66;
--st-idle-soft: rgba(0, 0, 0, 0.04);
}
@@ -124,14 +124,14 @@ body {
font: 14.5px/1.45 var(--font);
color: var(--ink);
background:
- radial-gradient(900px 420px at 0% 0%, rgba(249, 115, 22, 0.07), transparent 55%),
+ radial-gradient(1100px 520px at 0% 0%, rgba(249, 115, 22, 0.14), transparent 58%),
var(--bg);
-webkit-font-smoothing: antialiased;
font-feature-settings: "cv11", "ss01";
}
html[data-theme="light"] body {
background:
- radial-gradient(900px 420px at 0% 0%, rgba(234, 88, 12, 0.06), transparent 55%),
+ radial-gradient(1100px 520px at 0% 0%, rgba(234, 88, 12, 0.11), transparent 58%),
var(--bg);
}
.tabular, .num, .hero-main, .hero-side, .stat b, .chip, .count, .run-line-text, time {
@@ -298,6 +298,8 @@ html.shelf .hero-main,
html.shelf [data-role="hero"],
body.shelf .hero-main,
body.shelf [data-role="hero"] { font-size: 1.35rem; }
+html.shelf article.machine.printing .hero-main,
+body.shelf article.machine.printing .hero-main { font-size: 2.4rem; }
.mobile-nav .nav-shelf { display: none; }
@media (max-height: 900px) {
@@ -1015,7 +1017,7 @@ article.machine {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent 40%), var(--card);
border: 1px solid var(--line);
border-radius: var(--radius);
- box-shadow: var(--shadow);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06), var(--shadow);
overflow: hidden;
display: flex;
flex-direction: column;
@@ -1023,6 +1025,9 @@ article.machine {
cursor: pointer;
transition: border-color 0.3s var(--ease), box-shadow 0.3s var(--ease), transform 0.2s var(--ease);
}
+html[data-theme="light"] article.machine {
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9), var(--shadow);
+}
article.machine::before {
content: "";
position: absolute;
@@ -1032,12 +1037,31 @@ article.machine::before {
}
article.machine:hover {
border-color: var(--line-strong);
- box-shadow: var(--shadow), 0 0 0 1px rgba(255, 255, 255, 0.04);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), var(--shadow), 0 0 0 1px rgba(255, 255, 255, 0.04);
transform: translateY(-1px);
}
-article.machine.printing { border-color: rgba(61, 206, 124, 0.35); box-shadow: var(--shadow), 0 0 0 1px rgba(61, 206, 124, 0.06), 0 18px 40px -24px rgba(61, 206, 124, 0.45); }
-article.machine.printing::before { background: linear-gradient(90deg, transparent, var(--st-printing), transparent); opacity: 0.7; }
-article.machine.printing:hover { border-color: rgba(61, 206, 124, 0.55); box-shadow: var(--shadow), 0 0 0 1px rgba(61, 206, 124, 0.12), 0 18px 40px -20px rgba(61, 206, 124, 0.55); }
+html[data-theme="light"] article.machine:hover {
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.95), var(--shadow), 0 0 0 1px rgba(0, 0, 0, 0.04);
+}
+article.machine.printing {
+ border-color: rgba(61, 206, 124, 0.35);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06), var(--shadow), 0 0 0 1px rgba(61, 206, 124, 0.06), 0 18px 40px -24px rgba(61, 206, 124, 0.45);
+}
+article.machine.printing::before {
+ background: linear-gradient(90deg, transparent, var(--st-printing), transparent);
+ opacity: 0.7;
+ animation: print-strip-breathe 2.8s var(--ease) infinite;
+}
+article.machine.printing:hover {
+ border-color: rgba(61, 206, 124, 0.55);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), var(--shadow), 0 0 0 1px rgba(61, 206, 124, 0.12), 0 18px 40px -20px rgba(61, 206, 124, 0.55);
+}
+article.machine.printing .hero-main {
+ font-size: 2.4rem;
+ letter-spacing: -0.05em;
+ font-variant-numeric: tabular-nums;
+ line-height: 1.05;
+}
article.machine.paused { border-color: rgba(251, 191, 36, 0.35); }
article.machine.paused:hover { border-color: rgba(251, 191, 36, 0.55); }
article.machine.heating { border-color: rgba(251, 191, 36, 0.28); }
@@ -1047,6 +1071,10 @@ article.machine.error::before { background: var(--st-error); opacity: 0.7; }
article.machine.error:hover { border-color: rgba(248, 113, 113, 0.6); }
article.machine.offline { opacity: 0.62; }
article.machine.offline:hover { opacity: 1; }
+@keyframes print-strip-breathe {
+ 0%, 100% { opacity: 0.45; }
+ 50% { opacity: 0.9; }
+}
.machine-head {
padding: 0.85rem 0.95rem 0.3rem;
@@ -1480,7 +1508,7 @@ td select { width: auto; min-width: 8.5rem; padding: 0.3rem 0.45rem; min-height:
place-items: center;
padding: 1.5rem;
background:
- radial-gradient(ellipse at 20% 0%, rgba(61, 206, 124, 0.12), transparent 45%),
+ radial-gradient(ellipse at 20% 0%, rgba(249, 115, 22, 0.16), transparent 50%),
var(--bg);
}
.auth-card {
@@ -1489,6 +1517,10 @@ td select { width: auto; min-width: 8.5rem; padding: 0.3rem 0.45rem; min-height:
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 1.4rem 1.35rem 1.5rem;
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06), var(--shadow);
+}
+html[data-theme="light"] .auth-card {
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9), var(--shadow);
}
.auth-card h1 { margin: 0 0 0.35rem; font-size: 1.45rem; }
.auth-brand {
@@ -2189,7 +2221,7 @@ body.desk-on main {
.chip.complete { --c: var(--st-done); --cs: var(--st-done-soft); }
.chip.error { --c: var(--st-error); --cs: var(--st-error-soft); }
.chip.offline { --c: var(--st-offline); --cs: var(--st-offline-soft); }
-.chip.waiting { --c: var(--st-waiting); --cs: transparent; box-shadow: inset 0 0 0 1px rgba(249, 115, 22, 0.45); }
+.chip.waiting { --c: var(--st-waiting); --cs: transparent; box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--st-waiting) 45%, transparent); }
.chip.cancelled { --c: var(--muted); --cs: var(--st-idle-soft); }
.chip.sm { font-size: 0.6rem; padding: 0.12rem 0.45rem 0.12rem 0.38rem; gap: 0.3rem; }
.chip.sm::before { width: 0.36rem; height: 0.36rem; }
@@ -2329,7 +2361,7 @@ body.desk-on main {
.strip.stopped .strip-actions button:not(.ghost) { background: var(--accent); color: var(--accent-ink); }
.strip.stopped .strip-actions button.ghost { color: #fecaca; border-color: rgba(248, 113, 113, 0.4); }
.waiting-note { font-size: var(--t-xs); color: var(--muted); display: flex; align-items: center; gap: 0.35rem; }
-.waiting-note b { color: var(--accent); }
+.waiting-note b { color: var(--st-waiting); }
.desk-next {
margin-top: 0.65rem;
padding: 0.55rem 0.6rem;
diff --git a/web/templates/layout.html b/web/templates/layout.html
index 27d6657..5fe5206 100644
--- a/web/templates/layout.html
+++ b/web/templates/layout.html
@@ -9,7 +9,7 @@
-
+