diff --git a/CHANGELOG.md b/CHANGELOG.md index 0870217..ad57142 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelo ## [Unreleased] +## [3.6.1] – 2026-11-01 + +### Behoben + +- **Abbruch fehlt nach Neustart:** Der History-Abgleich hat den Live-Lauf übernommen und gelöscht, das Ereignis „Druck abgebrochen“ zeigte noch auf diese Zeile. Der Fremdschlüssel hat den ganzen Watcher-Tick zurückgerollt, der Abbruch kam nie ins Protokoll, und der neue Druck hat dieselbe Zeile weiterbenutzt. Das Ereignis hängt sich an den überlebenden Lauf. Wechselt die Datei, wird der offene Lauf geschlossen statt umbenannt. Ein Live-Lauf einer anderen Datei wird nicht mehr übernommen. +- **Spulenwechsel:** Nach Filamentpause kein vorzeitiger Spulenabzug mehr (`watcher_resume`). Beim Zurücknehmen eines Abzugs wird der letzte Abschnitt wieder geöffnet; der Messwert wird anteilig nach mm verteilt. Beim Einlegen während des Drucks entsteht der erste Abschnitt auch dann (0→jetzt), wenn die alte Rolle schon im Regal ist oder ACE ohne Start-Abschnitt lief. Fallback slot-genau (Platz / ACE-Map / letzter Abschnitt). ACE-Start öffnet Abschnitte für gemappte Plätze mit Spule — ohne Wechsel bleibt Anteil-Split. + +### Geändert + +- **systemd:** `TimeoutStopSec=20` und `KillMode=mixed`, damit ein hängender Stopp nicht 90 s blockiert. +- **Orca:** Anleitung und Empfehlung zeigen auf Grotti am Pi (`http://10.10.1.41:8770/`), nicht mehr lokal. +- **Dateien:** Unveränderte Watch-Dateien werden beim Sync nur per Größe/mtime geprüft, nicht erneut geöffnet. `ace_trailer` liegt in der Datenbank; die Dateiliste öffnet die Datei nur noch einmal, wenn der Wert fehlt. Wand zeigt Drucker sofort, die Bibliothek lädt im Hintergrund. Pi-Paket: `http_threads` 8. +- **3D-Pfad:** Ungespeicherte Bahnen werden nacheinander gebaut, nicht parallel. `http_threads` begrenzt die Request-Threads. Sonst liest die Vorschau mehrere G-codes gleichzeitig und füllt auf einem Pi mit 2 GB den Speicher. +- **UI:** Wärmeres Orange-Licht (App und Login), Warten-Status in Amber statt Knopf-Orange, Druckerwand mit heller Kartenkante, größerer Prozentzahl und atmendem Druck-Streifen. + ## [3.6.0] – 2026-10-25 ### Neu diff --git a/README.md b/README.md index b5ce974..1bb95c6 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@
-
+
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 @@
-
+