Grotti 3.6.1: Abgebrochene Läufe bleiben im Protokoll, Spulenwechsel bucht den laufenden Druck nicht vorzeitig ab, und der Pi öffnet große Dateien nicht mehr parallel.

This commit is contained in:
Dominic Gundel committed 2026-11-01 18:56:14 +01:00
1 parent 2b1e36634a
commit 5081212212
23 files changed
+557 -63

No files matched your search

+15
View File
@@ -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
+1 -1
View File
@@ -9,7 +9,7 @@
</p>
<p align="center">
<a href="https://git.gundel.dev/dg/grotti"><img src="https://img.shields.io/badge/Version-3.6.0-e85d04?style=flat-square" alt="Version 3.6.0"></a>
<a href="https://git.gundel.dev/dg/grotti"><img src="https://img.shields.io/badge/Version-3.6.1-e85d04?style=flat-square" alt="Version 3.6.1"></a>
<img src="https://img.shields.io/badge/Python-3.11+-3776AB?style=flat-square&logo=python&logoColor=white" alt="Python 3.11+">
<img src="https://img.shields.io/badge/PostgreSQL-14+-4169E1?style=flat-square&logo=postgresql&logoColor=white" alt="PostgreSQL">
<img src="https://img.shields.io/badge/Moonraker-Klipper-2ecc71?style=flat-square" alt="Moonraker">
+6 -6
View File
@@ -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.
+2
View File
@@ -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 |
+9
View File
@@ -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,
}
+29 -2
View File
@@ -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,6 +861,7 @@ def sync_history(conn, cfg, host=None, watcher_resume=False):
deducted += _sync_one_job(
conn, cfg, printer, job, watcher_resume=watcher_resume
)
if not watcher_resume:
deducted += deduct_stale_live_runs(conn, cfg)
finally:
conn.execute("SELECT pg_advisory_unlock(%s)", (SYNC_LOCK_KEY,))
+23 -8
View File
@@ -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)
+3
View File
@@ -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;
+141 -19
View File
@@ -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.
stored = row.get("stored_path") or ""
ace_trailer = row.get("ace_trailer")
if ace_trailer is None and stored:
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 = 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()
+8
View File
@@ -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 {
+1 -1
View File
@@ -1,3 +1,3 @@
"""Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen."""
__version__ = "3.6.0"
__version__ = "3.6.1"
+46 -7
View File
@@ -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,
+1
View File
@@ -8,6 +8,7 @@
"secret_key": "",
"fqdn": "",
"log_level": "INFO",
"http_threads": 8,
"auto_queue": true,
"auto_retry": true,
"oauth": {
+3
View File
@@ -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
+7 -1
View File
@@ -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__":
+13
View File
@@ -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})
+127
View File
@@ -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()
+63
View File
@@ -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()
+10 -1
View File
@@ -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,
})
+46 -14
View File
@@ -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;
+1 -1
View File
@@ -9,7 +9,7 @@
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='brand/favicon-32.png') }}">
<link rel="icon" type="image/png" sizes="512x512" href="{{ url_for('static', filename='brand/favicon.png') }}">
<link rel="apple-touch-icon" href="{{ url_for('static', filename='brand/apple-touch-icon.png') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=73">
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=75">
<link rel="manifest" href="{{ url_for('static', filename='manifest.webmanifest') }}">
<meta name="theme-color" content="#0a0a0c" id="theme-color-meta">
<script>
+1 -1
View File
@@ -6,7 +6,7 @@
<meta name="csrf" content="{{ csrf }}">
<title>Anmelden · Grotti</title>
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='brand/favicon-32.png') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=62">
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=75">
</head>
<body class="auth-body">
<main class="auth-card">
+1 -1
View File
@@ -1580,7 +1580,7 @@ function sortPrinters(printers) {
async function refresh() {
if (!libraryFiles.length) {
try { libraryFiles = await api("/dateien"); } catch (error) { libraryFiles = []; }
api("/dateien").then((rows) => { libraryFiles = rows || []; }).catch(() => {});
}
if (!wallSpools.length || deskHost) {
try { wallSpools = await api("/spulen"); } catch (error) { /* behalten */ }