From 0db34b82d2754eb69bbeac3ac4a626f9992009ca Mon Sep 17 00:00:00 2001 From: koen01 Date: Sat, 28 Feb 2026 14:58:11 +0100 Subject: [PATCH 001/134] =?UTF-8?q?Remove=20local=20history=20tracking=20?= =?UTF-8?q?=E2=80=94=20Spoolman-first=20refactor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop slot_history, spool_ref_*, spool_epoch_consumed_g_total, remaining_g, last_accounted_* from schema and state - Remove _hist_push, _hist_upsert_by_src, _inc_slot_epoch_consumed, _apply_job_usage, _slot_consumed_g_epoch helpers - Poll loop finalization now only calls _spoolman_report_usage per slot - Remove POST /api/spool/reset, /apply_usage, /ui/slot/reset, /ui/spool/set_remaining endpoints - Add idempotency guard to /api/moonraker/allocate (prevents double Spoolman sync); stored value drops alloc_g - Add GET /api/ui/spoolman/spool_detail?slot= proxy endpoint (graceful error, never HTTP 502) - Replace renderHistory() with fetchAndRenderSpoolmanStatus() in UI; right-side panel now shows live Spoolman spool status for active slot - Remove Istgewicht/Übernehmen form from spool modal; roll-change increments epoch and auto-unlinks Spoolman spool - Update i18n keys: remove history/spool-stats keys, add spoolman status keys (DE + EN) Co-Authored-By: Claude Sonnet 4.6 --- main.py | 439 ++++++++-------------------------------------- models/schemas.py | 70 +------- static/app.js | 331 +++++++++++++--------------------- static/i18n.js | 60 +++---- static/index.html | 12 +- 5 files changed, 227 insertions(+), 685 deletions(-) diff --git a/main.py b/main.py index 43f50c7..50e54bb 100644 --- a/main.py +++ b/main.py @@ -25,14 +25,10 @@ SelectSlotRequest, SetAutoRequest, SlotState, - SpoolApplyUsageRequest, SpoolmanLinkRequest, SpoolmanUnlinkRequest, - SpoolResetRequest, UiSetColorRequest, - UiSpoolSetRemainingRequest, UiSpoolSetStartRequest, - UiSlotResetRequest, UiSlotUpdateRequest, UpdateSlotRequest, ) @@ -148,10 +144,6 @@ def _ensure_data_files() -> None: "current_job": "", "current_job_filament_mm": 0, "current_job_filament_g": 0.0, - "last_accounted_job_mm": 0, - "last_accounted_slot": None, - # per-slot usage history (newest first) - "slot_history": {}, # in-flight job attribution (persisted so a restart doesn't lose the active print) "job_track_name": "", "job_track_started_at": 0.0, @@ -161,7 +153,7 @@ def _ensure_data_files() -> None: "job_track_last_state": "", # snapshot from Moonraker history (global list) "moonraker_history": [], - # local manual allocations for Moonraker history -> slots + # idempotency markers for Moonraker history -> Spoolman sync "moonraker_allocations": {}, "updated_at": _now(), } @@ -217,8 +209,6 @@ def _migrate_state_dict(data: dict) -> dict: data.setdefault("current_job", data.get("job", {}).get("name", "")) data.setdefault("current_job_filament_mm", int(data.get("job", {}).get("used_mm", 0) or 0)) data.setdefault("current_job_filament_g", float(data.get("job", {}).get("used_g", 0.0) or 0.0)) - data.setdefault("last_accounted_job_mm", int(data.get("last_accounted_job_mm", 0) or 0)) - data.setdefault("last_accounted_slot", data.get("last_accounted_slot")) # Slots: allow keys like "2A": {material,color,...} without slot field slots = data.get("slots", {}) or {} @@ -237,12 +227,6 @@ def _migrate_state_dict(data: dict) -> dict: mat = sd.get("material") if isinstance(mat, str) and mat.strip() in ("", "-", "—", "–"): sd["material"] = "OTHER" - # allow 'remaining_g' as int - if "remaining_g" in sd and sd["remaining_g"] is not None: - try: - sd["remaining_g"] = float(sd["remaining_g"]) - except Exception: - sd["remaining_g"] = None # Spoolman integration (optional) sd.setdefault("spoolman_id", None) slots[slot_id] = sd @@ -260,8 +244,6 @@ def _migrate_state_dict(data: dict) -> dict: "color_hex": "#00aaff", "name": "", "manufacturer": "", - "remaining_g": 0.0, - "notes": "", } data["slots"] = slots @@ -274,8 +256,6 @@ def _migrate_state_dict(data: dict) -> dict: data.setdefault("cfs_slots", {}) data.setdefault("cfs_raw", {}) - # --- history defaults --- - data.setdefault("slot_history", {}) data.setdefault("job_track_name", "") data.setdefault("job_track_started_at", 0.0) data.setdefault("job_track_last_mm", 0) @@ -347,96 +327,6 @@ def mm_to_g(material: str, mm: float) -> float: return float(max(0.0, g)) -def _apply_job_usage(state: AppState, job_name: str, total_used_mm: int, slot_override: Optional[str] = None) -> None: - """Update job counters. - - Note: We intentionally do NOT decrement remaining_g here anymore. - Creality K2's CFS can change slots mid-print (multi-color). Accurate - remaining deduction is handled by the per-slot tracker finalized at - print end. - """ - total_used_mm = int(max(0, total_used_mm)) - - # Decide which slot to account against - slot_id = slot_override or state.last_accounted_slot or state.active_slot - - # If job name changed, reset delta baseline - if job_name != (state.current_job or ""): - state.last_accounted_job_mm = 0 - - delta_mm = max(0, total_used_mm - int(state.last_accounted_job_mm or 0)) - - material = state.slots[slot_id].material - - # Update state - state.current_job = job_name - state.current_job_filament_mm = total_used_mm - state.current_job_filament_g = mm_to_g(material, float(total_used_mm)) - state.last_accounted_job_mm = total_used_mm - state.last_accounted_slot = slot_id - - -def _hist_push(state: AppState, slot_id: str, entry: dict, keep: int = 50) -> None: - """Append a history entry for a slot (newest first).""" - try: - # Tag entries with the current spool epoch so UI can hide old-roll prints - try: - entry.setdefault("epoch", int(getattr(state.slots.get(slot_id), "spool_epoch", 0) or 0)) - except Exception: - entry.setdefault("epoch", 0) - h = state.slot_history.get(slot_id) - if not isinstance(h, list): - h = [] - h.insert(0, entry) - state.slot_history[slot_id] = h[:keep] - except Exception: - # never fail the poll loop due to history - pass - - -def _hist_upsert_by_src(state: AppState, slot_id: str, src: str, entry: dict, keep: int = 50) -> None: - """Insert or replace a history entry identified by a stable _src marker. - - Used to show a "live" (in-progress) entry per slot during printing without - spamming the history list. - """ - try: - if not src: - _hist_push(state, slot_id, entry, keep=keep) - return - - entry["_src"] = src - - # Tag entries with the current spool epoch so UI can hide old-roll prints - try: - entry.setdefault("epoch", int(getattr(state.slots.get(slot_id), "spool_epoch", 0) or 0)) - except Exception: - entry.setdefault("epoch", 0) - - h = state.slot_history.get(slot_id) - if not isinstance(h, list): - h = [] - - # Drop existing entries with same source marker - h = [e for e in h if not (isinstance(e, dict) and e.get("_src") == src)] - h.insert(0, entry) - state.slot_history[slot_id] = h[:keep] - except Exception: - # never fail the poll loop due to history - pass - - -def _inc_slot_epoch_consumed(state: AppState, slot_id: str, delta_g: float) -> None: - """Increment the running consumed-total for the current spool epoch.""" - try: - s = state.slots.get(slot_id) - if not s: - return - s.spool_epoch_consumed_g_total = float(getattr(s, "spool_epoch_consumed_g_total", 0.0) or 0.0) + float(delta_g) - state.slots[slot_id] = s - except Exception: - return - # --- Minimal Moonraker polling (optional) --- @@ -937,10 +827,9 @@ async def moonraker_poll_loop() -> None: else: st.cfs_connected = False - # --- Per-slot history tracking (read-only) --- - # Attribute delta filament_used(mm) to the currently active slot during a print. - # This enables per-slot history (and later accurate remaining_g calculations) even - # for multi-color prints. + # --- Per-slot filament tracking --- + # Attribute delta filament_used(mm) to the active slot during a print. + # Accumulated grams per slot are reported to Spoolman at print finalize. try: is_printing = ps_state in ("printing", "paused") tracking = bool(st.job_track_name) @@ -970,92 +859,21 @@ async def moonraker_poll_loop() -> None: g_delta = 0.0 if g_delta > 0: st.job_track_slot_g[curr_slot] = float(st.job_track_slot_g.get(curr_slot, 0.0)) + float(g_delta) - - # Live spool deduction: increment epoch-consumed total immediately - _inc_slot_epoch_consumed(st, curr_slot, float(g_delta)) st.job_track_last_mm = int(used_mm) st.job_track_last_state = ps_state - # Publish a single "live" history entry per slot for the current job. - # This makes the right-hand "Historie pro Slot" useful during - # multi-color prints (usage is attributed while printing, not only at the end). - try: - now_ts = _now() - slot_mm_live = st.job_track_slot_mm if isinstance(st.job_track_slot_mm, dict) else {} - for sid, mm_live in slot_mm_live.items(): - try: - mm_i = int(mm_live or 0) - if mm_i <= 0: - continue - mat = st.slots.get(sid).material if sid in st.slots else "OTHER" - g_live = float(round(mm_to_g(str(mat), float(mm_i)), 2)) - src = f"live:{st.job_track_started_at}:{st.job_track_name}:{sid}" - _hist_upsert_by_src( - st, - sid, - src, - { - "ts": float(now_ts), - "job": st.job_track_name, - "used_mm": mm_i, - "used_g": g_live, - "result": "printing", - }, - ) - except Exception: - continue - except Exception: - pass - # Finalize when printing ends (complete/cancel/error/standby) if (not is_printing) and tracking and st.job_track_name: - # Determine an end timestamp from Creality virtual_sdcard if available - end_ts = _now() - try: - cpd = (vsd.get("cur_print_data") or {}) - et = cpd.get("end_time") - if et is not None: - end_ts = float(et) - except Exception: - pass - - # Create history entries per slot (only if we have consumption) - slot_mm = st.job_track_slot_mm if isinstance(st.job_track_slot_mm, dict) else {} - for sid, mm in slot_mm.items(): + # Report per-slot consumption to Spoolman + slot_g = st.job_track_slot_g if isinstance(st.job_track_slot_g, dict) else {} + for sid, g in slot_g.items(): try: - mm_i = int(mm) - if mm_i <= 0: + gv = float(g) + if gv <= 0: continue - mat = st.slots.get(sid).material if sid in st.slots else "OTHER" - g = float(round(mm_to_g(str(mat), float(mm_i)), 2)) - - # Remove any live entry for this job/slot (so we don't show duplicates) - try: - live_src = f"live:{st.job_track_started_at}:{st.job_track_name}:{sid}" - h0 = st.slot_history.get(sid) - if isinstance(h0, list): - st.slot_history[sid] = [e for e in h0 if not (isinstance(e, dict) and e.get("_src") == live_src)] - except Exception: - pass - - _hist_push( - st, - sid, - { - "ts": float(end_ts), - "job": st.job_track_name, - "used_mm": mm_i, - "used_g": g, - "result": ps_state, - }, - ) - # Sync consumption to Spoolman if linked - try: - slot_obj = st.slots.get(sid) - if slot_obj and getattr(slot_obj, "spoolman_id", None) and g > 0: - _spoolman_report_usage(slot_obj.spoolman_id, g) - except Exception: - pass + slot_obj = st.slots.get(sid) + if slot_obj and getattr(slot_obj, "spoolman_id", None): + _spoolman_report_usage(slot_obj.spoolman_id, gv) except Exception: continue @@ -1071,9 +889,16 @@ async def moonraker_poll_loop() -> None: # --- Job usage accounting --- if filename or used_mm: - _apply_job_usage(st, filename or st.current_job or "", used_mm) + st.current_job = filename or st.current_job or "" + st.current_job_filament_mm = int(max(0, used_mm)) if used_g > 0.0: st.current_job_filament_g = float(round(used_g, 2)) + else: + try: + mat = (st.slots.get(st.active_slot) or SlotState(slot=st.active_slot)).material + except Exception: + mat = "OTHER" + st.current_job_filament_g = mm_to_g(mat, float(max(0, used_mm))) # --- Moonraker history snapshot (global) --- # This is useful to show past jobs even if our per-slot tracker @@ -1145,13 +970,18 @@ def api_state(): @app.post("/api/moonraker/allocate", response_model=AppState) def api_moonraker_allocate(req: MoonrakerAllocateRequest): - """Store local per-slot allocation for a Moonraker history job. + """Store local per-slot allocation for a Moonraker history job and sync to Spoolman. - This never talks to the printer. It only enriches our local per-slot history. + Idempotent: if the same key was already allocated, returns early to prevent + double-syncing to Spoolman. """ st = load_state() key = (req.job_key or "").strip() or _job_key(req.job_key, req.ts, req.job) + # Idempotency guard: don't double-sync to Spoolman + if key in st.moonraker_allocations: + return st + # Normalize alloc_g: drop zeros/negatives alloc: Dict[str, float] = {} for sid, g in (req.alloc_g or {}).items(): @@ -1165,56 +995,11 @@ def api_moonraker_allocate(req: MoonrakerAllocateRequest): if not alloc: raise HTTPException(status_code=400, detail="alloc_g must contain at least one positive value") - # Persist allocation - st.moonraker_allocations[key] = {"job": req.job, "ts": float(req.ts), "alloc_g": alloc} - - # Push entries into per-slot history (and replace previous pushes for this key) - # We keep a marker so we can de-duplicate. - marker = f"moonraker:{key}" - for sid in alloc.keys(): - h = st.slot_history.get(sid) - if isinstance(h, list): - # Remove previous entries for this marker and adjust epoch totals accordingly. - new_h = [] - removed_g = 0.0 - for e in h: - if isinstance(e, dict) and e.get("_src") == marker: - try: - removed_g += float(e.get("used_g") or 0.0) - except Exception: - pass - continue - new_h.append(e) - st.slot_history[sid] = new_h - if removed_g > 0: - try: - s = st.slots.get(sid) - if s: - # Only subtract from current epoch total if the marker entries - # were added in the current epoch. - # (Older epochs should not affect current totals.) - # We approximate by checking the current slot epoch matches the - # epoch on the first removed entry if available. - s.spool_epoch_consumed_g_total = max(0.0, float(getattr(s, "spool_epoch_consumed_g_total", 0.0) or 0.0) - float(removed_g)) - st.slots[sid] = s - except Exception: - pass + # Persist allocation marker (no alloc_g stored — Spoolman owns the running total) + st.moonraker_allocations[key] = {"job": req.job, "ts": float(req.ts)} + # Sync consumption to Spoolman per slot for sid, g in alloc.items(): - _hist_push( - st, - sid, - { - "ts": float(req.ts), - "job": req.job, - "used_mm": 0, - "used_g": float(round(float(g), 2)), - "result": "history", - "_src": marker, - }, - ) - _inc_slot_epoch_consumed(st, sid, float(g)) - # Sync consumption to Spoolman if linked try: slot_obj = st.slots.get(sid) if slot_obj and getattr(slot_obj, "spoolman_id", None) and float(g) > 0: @@ -1240,25 +1025,6 @@ def _ui_state_dict(state: AppState) -> dict: if "manufacturer" in out and "vendor" not in out: out["vendor"] = out.get("manufacturer", "") - # Derived spool metrics (purely local) - # - spool_consumed_g: running total for current epoch (stable even if UI history is trimmed) - # - spool_used_g: consumption since the last "Übernehmen" reference - # - spool_remaining_g: computed remaining weight - try: - consumed = float(out.get("spool_epoch_consumed_g_total") or 0.0) - out["spool_consumed_g"] = round(consumed, 2) - - ref_rem = out.get("spool_ref_remaining_g") - ref_cons = out.get("spool_ref_consumed_g") - if ref_rem is not None and ref_cons is not None: - # Remaining decreases only by consumption since reference point - since = max(0.0, consumed - float(ref_cons)) - remaining = max(0.0, float(ref_rem) - since) - out["spool_remaining_g"] = round(remaining, 1) - out["spool_used_g"] = round(since, 1) - except Exception: - pass - slots_out[slot_id] = out d["slots"] = slots_out @@ -1282,14 +1048,6 @@ def _ui_state_dict(state: AppState) -> dict: return d -def _slot_consumed_g_epoch(state: AppState, slot: str) -> float: - try: - s = state.slots.get(slot) - return float(getattr(s, "spool_epoch_consumed_g_total", 0.0) or 0.0) - except Exception: - return 0.0 - - # --- UI API (static frontend uses /api/ui/* and expects {"result": ...}) --- @app.get("/api/ui/state", response_model=ApiResponse) def api_ui_state() -> ApiResponse: @@ -1341,7 +1099,8 @@ def api_update_slot(slot: str, req: UpdateSlotRequest): s = state.slots[slot] update = _req_dump(req, exclude_unset=True) for k, v in update.items(): - setattr(s, k, v) + if hasattr(s, k): + setattr(s, k, v) state.slots[slot] = s save_state(state) @@ -1379,84 +1138,28 @@ def api_ui_slot_update(req: UiSlotUpdateRequest) -> ApiResponse: return ApiResponse(result=_ui_state_dict(state)) -@app.post("/api/ui/slot/reset", response_model=ApiResponse) -def api_ui_slot_reset(req: UiSlotResetRequest) -> ApiResponse: - state = load_state() - slot = req.slot - if slot not in state.slots: - raise HTTPException(status_code=404, detail="Unknown slot") - state.slots[slot].remaining_g = float(req.remaining_g) - save_state(state) - return ApiResponse(result=_ui_state_dict(state)) - @app.post("/api/ui/spool/set_start", response_model=ApiResponse) def api_ui_spool_set_start(req: UiSpoolSetStartRequest) -> ApiResponse: - """Roll change: set new spool baseline (local only). - - Historical entries are kept, but hidden by incrementing the slot's spool_epoch. - The new spool's remaining weight is set as the reference point. - """ + """Roll change: increment epoch and auto-unlink Spoolman spool.""" state = load_state() slot = req.slot if slot not in state.slots: raise HTTPException(status_code=404, detail="Unknown slot") - start_g = float(req.start_g) s = state.slots[slot] - # New roll => new epoch + # New roll => new epoch (hides old history in Spoolman status, triggers auto-unlink) try: s.spool_epoch = int(getattr(s, "spool_epoch", 0) or 0) + 1 except Exception: s.spool_epoch = 1 - - # Reset accounting for the new epoch - s.spool_epoch_consumed_g_total = 0.0 - s.spool_ref_remaining_g = start_g - s.spool_ref_consumed_g = 0.0 - s.spool_ref_set_at = time.time() # Roll change auto-unlinks Spoolman spool s.spoolman_id = None - # keep legacy fields for debugging only - s.spool_start_g = start_g - s.remaining_g = start_g state.slots[slot] = s save_state(state) return ApiResponse(result=_ui_state_dict(state)) -@app.post("/api/ui/spool/set_remaining", response_model=ApiResponse) -def api_ui_spool_set_remaining(req: UiSpoolSetRemainingRequest) -> ApiResponse: - """Übernehmen: set measured remaining weight as new reference (local only). - - Does NOT reset epoch and does not delete history. Remaining is computed as: - remaining = ref_remaining - (consumed_epoch - ref_consumed) - """ - state = load_state() - slot = req.slot - if slot not in state.slots: - raise HTTPException(status_code=404, detail="Unknown slot") - - rem_g = float(req.remaining_g) - s = state.slots[slot] - consumed_now = _slot_consumed_g_epoch(state, slot) - s.spool_ref_remaining_g = rem_g - s.spool_ref_consumed_g = float(round(consumed_now, 4)) - s.spool_ref_set_at = time.time() - # legacy - s.remaining_g = rem_g - state.slots[slot] = s - save_state(state) - - # Sync measured weight to Spoolman if linked - if getattr(s, "spoolman_id", None): - try: - _spoolman_report_measure(s.spoolman_id, rem_g) - except Exception: - pass - - return ApiResponse(result=_ui_state_dict(state)) - # --- Spoolman integration endpoints --- @@ -1527,8 +1230,6 @@ def api_ui_spoolman_link(req: SpoolmanLinkRequest) -> ApiResponse: except Exception as e: raise HTTPException(status_code=502, detail=f"Spoolman unreachable: {e}") - # Import remaining_weight from Spoolman - rem_g = float(sp.get("remaining_weight") or 0.0) filament = sp.get("filament") or {} s = state.slots[slot] @@ -1548,13 +1249,6 @@ def api_ui_spoolman_link(req: SpoolmanLinkRequest) -> ApiResponse: if vendor_name: s.manufacturer = vendor_name - # Set remaining as reference (same logic as set_remaining) - consumed_now = _slot_consumed_g_epoch(state, slot) - s.spool_ref_remaining_g = rem_g - s.spool_ref_consumed_g = float(round(consumed_now, 4)) - s.spool_ref_set_at = time.time() - s.remaining_g = rem_g - state.slots[slot] = s save_state(state) return ApiResponse(result=_ui_state_dict(state)) @@ -1573,40 +1267,43 @@ def api_ui_spoolman_unlink(req: SpoolmanUnlinkRequest) -> ApiResponse: return ApiResponse(result=_ui_state_dict(state)) -@app.post("/api/ui/set_color", response_model=ApiResponse) -def api_ui_set_color(req: UiSetColorRequest) -> ApiResponse: +@app.get("/api/ui/spoolman/spool_detail") +def api_ui_spoolman_spool_detail(slot: str = "1A"): + """Proxy Spoolman spool status for a given CFS slot. + + Returns {"linked": bool, "slot": str, "spool": dict|null, "error": str|null}. + Never raises HTTP 502 — Spoolman unavailability is returned as a structured error + so the frontend can degrade gracefully. + """ state = load_state() - if req.slot not in state.slots: + slot_obj = state.slots.get(slot) + if slot_obj is None: raise HTTPException(status_code=404, detail="Unknown slot") - state.slots[req.slot].color_hex = req.color - save_state(state) - return ApiResponse(result=_ui_state_dict(state)) + spool_id = getattr(slot_obj, "spoolman_id", None) + if not spool_id: + return {"linked": False, "slot": slot, "spool": None, "error": None} -@app.post("/api/spool/reset", response_model=AppState) -def api_spool_reset(req: SpoolResetRequest): - state = load_state() - if req.slot not in state.slots: - raise HTTPException(status_code=404, detail="Unknown slot") - state.slots[req.slot].remaining_g = float(req.remaining_g) - save_state(state) - return state + base = _spoolman_base_url() + if not base: + return {"linked": True, "slot": slot, "spool": None, "error": "not_configured"} + + try: + sp = _spoolman_get_spool(base, spool_id) + return {"linked": True, "slot": slot, "spool": sp, "error": None} + except Exception as e: + return {"linked": True, "slot": slot, "spool": None, "error": "unreachable"} -@app.post("/api/spool/apply_usage", response_model=AppState) -def api_spool_apply_usage(req: SpoolApplyUsageRequest): +@app.post("/api/ui/set_color", response_model=ApiResponse) +def api_ui_set_color(req: UiSetColorRequest) -> ApiResponse: state = load_state() if req.slot not in state.slots: raise HTTPException(status_code=404, detail="Unknown slot") - - current = state.slots[req.slot].remaining_g - if current is None: - raise HTTPException(status_code=409, detail="remaining_g is not set for this slot") - - new_val = max(0.0, float(current) - float(req.used_g)) - state.slots[req.slot].remaining_g = new_val + state.slots[req.slot].color_hex = req.color save_state(state) - return state + return ApiResponse(result=_ui_state_dict(state)) + @app.post("/api/job/set", response_model=AppState) @@ -1615,8 +1312,6 @@ def api_job_set(req: JobSetRequest): state.current_job = req.name state.current_job_filament_mm = 0 state.current_job_filament_g = 0.0 - state.last_accounted_job_mm = 0 - state.last_accounted_slot = state.active_slot save_state(state) return state @@ -1630,7 +1325,14 @@ def api_ui_job_set(req: JobSetRequest) -> ApiResponse: @app.post("/api/job/update", response_model=AppState) def api_job_update(req: JobUpdateRequest): state = load_state() - _apply_job_usage(state, state.current_job or "", int(req.used_mm), slot_override=req.slot) + slot_id = req.slot or state.active_slot + total_mm = int(max(0, req.used_mm)) + try: + mat = (state.slots.get(slot_id) or SlotState(slot=slot_id)).material + except Exception: + mat = "OTHER" + state.current_job_filament_mm = total_mm + state.current_job_filament_g = mm_to_g(mat, float(total_mm)) save_state(state) return state @@ -1701,12 +1403,11 @@ def default_state() -> AppState: """ slots: Dict[str, SlotState] = {} for sid in DEFAULT_SLOTS: - slots[sid] = SlotState(slot=sid, material="OTHER", color_hex="#00aaff", remaining_g=0.0) + slots[sid] = SlotState(slot=sid, material="OTHER", color_hex="#00aaff") # Sensible demo defaults for Box 2 (matches the UI screenshot vibe) slots["2A"].material = "ABS" slots["2A"].color_hex = "#4b0082" # indigo-ish - slots["2A"].remaining_g = 1000.0 return AppState( active_slot="2A", diff --git a/models/schemas.py b/models/schemas.py index dd40a9f..84ad1a3 100644 --- a/models/schemas.py +++ b/models/schemas.py @@ -21,29 +21,8 @@ class SlotState(BaseModel): color_hex: str = Field(default="#00aaff", pattern=r"^#[0-9a-fA-F]{6}$") name: str = "" manufacturer: str = "" - # Optional spool bookkeeping (purely local): - # We store a *reference point* and compute remaining based on consumption - # since that reference. - # - # - spool_ref_remaining_g: measured remaining weight at reference time - # - spool_ref_consumed_g: total consumed for this slot at reference time - # - spool_ref_set_at: unix timestamp when reference was set - # - spool_epoch: increments on roll-change; UI shows only current epoch - spool_ref_remaining_g: Optional[float] = None - spool_ref_consumed_g: Optional[float] = None - spool_ref_set_at: Optional[float] = None + # spool_epoch: increments on roll-change; used for auto-unlink detection spool_epoch: int = 0 - - # Running total of consumed grams for the *current* spool epoch. - # This is used for remaining-weight calculations so that UI history trimming - # ("letzte 4") never changes accounting. - spool_epoch_consumed_g_total: float = 0.0 - - # Legacy fields from older versions (kept for backward compatibility). - # They are no longer used for calculations. - spool_start_g: Optional[float] = None - remaining_g: Optional[float] = None - notes: str = "" spoolman_id: Optional[int] = None @field_validator("material", mode="before") @@ -88,18 +67,6 @@ class AppState(BaseModel): cfs_slots: Dict[str, Any] = Field(default_factory=dict) cfs_raw: Dict[str, Any] = Field(default_factory=dict) - # Bookkeeping for clean spool deduction (persisted) - last_accounted_job_mm: int = 0 - last_accounted_slot: Optional[SlotId] = None - - # --- Read-only history / usage tracking (persisted) --- - # Per-slot print history (newest first). Each entry is a dict with: - # ts: unix timestamp (float) - # job: gcode filename - # used_mm: int - # used_g: float - slot_history: Dict[str, Any] = Field(default_factory=dict) - # Current job tracking to attribute filament to slots during a print. job_track_name: str = "" job_track_started_at: float = 0.0 @@ -109,15 +76,12 @@ class AppState(BaseModel): job_track_last_state: str = "" # --- Moonraker global history (read-only, best effort) --- - # Snapshot of Moonraker's /server/history/list. Moonraker history does not - # reliably provide per-slot attribution on Creality CFS, so we display this - # separately from the per-slot tracker. moonraker_history: Any = Field(default_factory=list) # --- Manual attribution for Moonraker history (local only) --- # Keyed by a stable job key (e.g. ":") with value: - # {"job": str, "ts": float, "alloc_g": {"2A": 12.3, ...}} - # This never talks back to the printer; it's only used to build per-slot history. + # {"job": str, "ts": float} + # Used as idempotency marker so the same job is never synced to Spoolman twice. moonraker_allocations: Dict[str, Any] = Field(default_factory=dict) @field_validator("updated_at", mode="before") @@ -148,9 +112,6 @@ class UpdateSlotRequest(BaseModel): color_hex: Optional[str] = Field(default=None, pattern=r"^#[0-9a-fA-F]{6}$") name: Optional[str] = None manufacturer: Optional[str] = None - spool_start_g: Optional[float] = None - remaining_g: Optional[float] = None - notes: Optional[str] = None class SelectSlotRequest(BaseModel): @@ -161,16 +122,6 @@ class SetAutoRequest(BaseModel): enabled: bool -class SpoolResetRequest(BaseModel): - slot: SlotId - remaining_g: float - - -class SpoolApplyUsageRequest(BaseModel): - slot: SlotId - used_g: float - - class FeedRequest(BaseModel): mm: float = Field(gt=0, le=200) @@ -218,24 +169,11 @@ class UiSlotUpdateRequest(BaseModel): color: Optional[str] = Field(default=None, pattern=r"^#[0-9a-fA-F]{6}$") name: Optional[str] = None vendor: Optional[str] = None - spool_start_g: Optional[float] = None - remaining_g: Optional[float] = None - notes: Optional[str] = None class UiSpoolSetStartRequest(BaseModel): slot: SlotId - start_g: float = Field(gt=0) - - -class UiSpoolSetRemainingRequest(BaseModel): - slot: SlotId - remaining_g: float = Field(ge=0) - - -class UiSlotResetRequest(BaseModel): - slot: SlotId - remaining_g: float + start_g: Optional[float] = None # accepted for backward compat, not stored locally class SpoolmanLinkRequest(BaseModel): diff --git a/static/app.js b/static/app.js index abe0ba8..3b1c60d 100644 --- a/static/app.js +++ b/static/app.js @@ -47,27 +47,6 @@ function slotEl(slotId, label, meta, isActive) { sub.textContent = parts.length ? parts.join(" · ") : "—"; txt.appendChild(sub); - // Optional spool info (local, derived) - // We show Rest BIG. (verbrauchte/used is available in the history; keeping tiles clean.) - const rem = (meta.spool_remaining_g != null ? meta.spool_remaining_g : meta.remaining_g); - if (rem != null) { - const row = document.createElement('div'); - row.className = 'spoolRow'; - - const rest = document.createElement('div'); - rest.className = 'spoolRest'; - rest.textContent = fmtG(rem); - row.appendChild(rest); - - // warning styles based on remaining grams - const r = Number(rem); - if (Number.isFinite(r)) { - if (r <= 50) wrap.classList.add('spoolCrit'); - else if (r <= 150) wrap.classList.add('spoolLow'); - } - - txt.appendChild(row); - } left.appendChild(txt); const right = document.createElement("div"); @@ -127,7 +106,6 @@ function jobKeyFromMoon(e) { // collapse while the user is interacting (e.g. assigning slots). const uiState = { moonOpenKeys: new Set(), - slotOpenKeys: new Set(), moonSelectValues: {}, }; @@ -137,11 +115,6 @@ function captureUiState() { .map((d) => d.dataset.key) .filter(Boolean) ); - uiState.slotOpenKeys = new Set( - Array.from(document.querySelectorAll('#slotHistory details.histEntry[open]')) - .map((d) => d.dataset.key) - .filter(Boolean) - ); uiState.moonSelectValues = {}; for (const sel of document.querySelectorAll('#moonHistory select.assignSel')) { const k = sel.dataset.selkey; @@ -154,10 +127,6 @@ function restoreUiState() { const k = d.dataset.key; if (k && uiState.moonOpenKeys && uiState.moonOpenKeys.has(k)) d.open = true; } - for (const d of document.querySelectorAll('#slotHistory details.histEntry')) { - const k = d.dataset.key; - if (k && uiState.slotOpenKeys && uiState.slotOpenKeys.has(k)) d.open = true; - } for (const sel of document.querySelectorAll('#moonHistory select.assignSel')) { const k = sel.dataset.selkey; if (k && uiState.moonSelectValues && Object.prototype.hasOwnProperty.call(uiState.moonSelectValues, k)) { @@ -213,31 +182,13 @@ function openSpoolModal(slotId, meta) { const title = $('spoolTitle'); const sub = $('spoolSub'); - const st = $('spoolStats'); if (title) title.textContent = `Box ${slotId[0]} · Slot ${slotId[1]}`; if (sub) sub.textContent = `${meta.material || '—'} · ${(meta.color || '').toUpperCase() || '—'}`; - const startEl = $('spoolStart'); - const remEl = $('spoolRemain'); - // Prefill: use computed remaining if available (rounded), otherwise legacy remaining_g - const prefRem = (meta.spool_remaining_g != null ? meta.spool_remaining_g : meta.remaining_g); - if (remEl) remEl.value = (prefRem != null ? String(Math.round(Number(prefRem))) : ''); // New roll input stays empty by default + const startEl = $('spoolStart'); if (startEl) startEl.value = ''; - if (st) { - const remG = (meta.spool_remaining_g != null ? meta.spool_remaining_g : meta.remaining_g); - const usedG = meta.spool_used_g; - const totalG = meta.spool_consumed_g; - if (remG != null && usedG != null) { - st.textContent = t('spool.stats_full', {remaining: fmtG(remG), used: fmtG(usedG), total: fmtG(totalG != null ? totalG : 0)}); - } else if (remG != null) { - st.textContent = t('spool.stats_partial', {remaining: fmtG(remG)}); - } else { - st.textContent = t('spool.stats_none'); - } - } - // --- Spoolman section --- const smSec = $('spoolmanSection'); if (smSec) { @@ -252,12 +203,37 @@ function openSpoolModal(slotId, meta) { if (badge) { badge.textContent = t('spoolman.linked'); badge.classList.remove('muted'); badge.classList.add('ok'); } if (notLinked) notLinked.style.display = 'none'; if (linked) linked.style.display = 'flex'; - if (info) info.textContent = t('spoolman.linked_info', { - id: String(smId), - vendor: meta.manufacturer || meta.vendor || '', - name: meta.name || '', - remaining: fmtG(meta.spool_remaining_g != null ? meta.spool_remaining_g : meta.remaining_g), - }); + if (info) { + info.textContent = t('spoolman.loading_spool'); + // Fetch live remaining from Spoolman + fetch(`/api/ui/spoolman/spool_detail?slot=${encodeURIComponent(slotId)}`, { cache: 'no-store' }) + .then(r => r.json()) + .then(data => { + if (data.spool && data.spool.remaining_weight != null) { + info.textContent = t('spoolman.linked_info', { + id: String(smId), + vendor: meta.manufacturer || meta.vendor || '', + name: meta.name || '', + remaining: fmtG(data.spool.remaining_weight), + }); + } else { + info.textContent = t('spoolman.linked_info', { + id: String(smId), + vendor: meta.manufacturer || meta.vendor || '', + name: meta.name || '', + remaining: data.error ? t('spoolman.unavailable') : '—', + }); + } + }) + .catch(() => { + info.textContent = t('spoolman.linked_info', { + id: String(smId), + vendor: meta.manufacturer || meta.vendor || '', + name: meta.name || '', + remaining: t('spoolman.unavailable'), + }); + }); + } } else { if (badge) { badge.textContent = t('spoolman.not_linked'); badge.classList.add('muted'); badge.classList.remove('ok'); } if (notLinked) notLinked.style.display = 'flex'; @@ -348,31 +324,14 @@ function initSpoolModal() { }); const saveStart = $('spoolSaveStart'); - const saveRemain = $('spoolSaveRemain'); if (saveStart) { saveStart.onclick = async (ev) => { ev.preventDefault(); ev.stopPropagation(); if (!spoolSlotId) return; - const v = Number(($('spoolStart') || {}).value || 0); - if (!Number.isFinite(v) || v <= 0) return; - // Rollwechsel: new epoch + new reference - await postJson('/api/ui/spool/set_start', { slot: spoolSlotId, start_g: v }); - closeSpoolModal(); - await tick(); - }; - } - - if (saveRemain) { - saveRemain.onclick = async (ev) => { - ev.preventDefault(); - ev.stopPropagation(); - if (!spoolSlotId) return; - const v = Number(($('spoolRemain') || {}).value || 0); - if (!Number.isFinite(v) || v < 0) return; - // Übernehmen: set measured remaining as reference (no epoch reset) - await postJson('/api/ui/spool/set_remaining', { slot: spoolSlotId, remaining_g: v }); + // Rollwechsel: new epoch + auto-unlink Spoolman + await postJson('/api/ui/spool/set_start', { slot: spoolSlotId }); closeSpoolModal(); await tick(); }; @@ -413,19 +372,26 @@ function initSpoolModal() { ev.preventDefault(); ev.stopPropagation(); if (!spoolSlotId) return; - // Re-link to re-import remaining_weight from Spoolman + // Re-fetch spool detail from Spoolman const info = $('spoolmanInfo'); - // Get spoolman_id from current state try { - const r = await fetch('/api/ui/state', { cache: 'no-store' }); - const j = await r.json(); - const st = j.result || j; - const slotData = (st.slots || {})[spoolSlotId] || {}; - const smId = slotData.spoolman_id; - if (!smId) return; - await postJson('/api/ui/spoolman/link', { slot: spoolSlotId, spoolman_id: smId }); - closeSpoolModal(); - await tick(); + if (info) info.textContent = t('spoolman.loading_spool'); + const r = await fetch(`/api/ui/spoolman/spool_detail?slot=${encodeURIComponent(spoolSlotId)}`, { cache: 'no-store' }); + const data = await r.json(); + if (data.spool && data.spool.remaining_weight != null) { + const stateR = await fetch('/api/ui/state', { cache: 'no-store' }); + const stateJ = await stateR.json(); + const stateData = stateJ.result || stateJ; + const slotData = (stateData.slots || {})[spoolSlotId] || {}; + if (info) info.textContent = t('spoolman.linked_info', { + id: String(data.spool.id || slotData.spoolman_id || ''), + vendor: slotData.manufacturer || slotData.vendor || '', + name: slotData.name || '', + remaining: fmtG(data.spool.remaining_weight), + }); + } else { + if (info) info.textContent = data.error ? t('spoolman.unavailable') : '—'; + } } catch (e) { if (info) info.textContent = t('spoolman.error', { msg: e.message || String(e) }); } @@ -608,14 +574,6 @@ function renderMoonHistory(state, connectedBoxes) { }; actions.appendChild(btn); - if (existing && typeof existing === "object") { - const info = document.createElement("div"); - info.className = "tag"; - const parts = []; - for (const [sid, g] of Object.entries(existing)) parts.push(`${sid}: ${fmtG(g)}`); - info.textContent = t('assign.current') + parts.join(" · "); - actions.appendChild(info); - } assign.appendChild(actions); } @@ -626,124 +584,89 @@ function renderMoonHistory(state, connectedBoxes) { } } -function renderHistory(state, slots, connectedBoxes) { +async function fetchAndRenderSpoolmanStatus(activeSlot, state) { const wrap = $("slotHistory"); if (!wrap) return; - wrap.innerHTML = ""; - const history = state.slot_history || {}; - const active = state.cfs_active_slot || state.active_slot || null; + const slot = activeSlot || state.active_slot || null; - const slotIds = buildSlotIds(connectedBoxes); + wrap.innerHTML = ''; + const loading = document.createElement('div'); + loading.className = 'tag muted'; + loading.textContent = t('spoolman.loading_spool'); + wrap.appendChild(loading); - const metaFor = (sid) => { - const m = (slots && slots[sid]) ? slots[sid] : {}; - const local = (state.slots && state.slots[sid]) ? state.slots[sid] : {}; - return { - present: (m.present ?? local.present ?? true), - material: ((m.material ?? local.material) || "").toString().toUpperCase(), - color: ((m.color ?? m.color_hex ?? local.color ?? local.color_hex) || "").toString().toLowerCase(), - remaining_g: (local.remaining_g ?? null), - spool_remaining_g: (local.spool_remaining_g ?? null), - spool_used_g: (local.spool_used_g ?? null), - spool_consumed_g: (local.spool_consumed_g ?? null), - }; - }; - - for (const sid of slotIds) { - const m = metaFor(sid); - const epoch = Number(((state.slots || {})[sid] || {}).spool_epoch || 0); - const rawEntries = Array.isArray(history[sid]) ? history[sid] : []; - const entries = rawEntries.filter(e => Number((e || {}).epoch || 0) === epoch).slice(0,4); - - const card = document.createElement("div"); - card.className = "histSlot"; - - const head = document.createElement("div"); - head.className = "histHead"; - - const title = document.createElement("div"); - title.className = "histTitle"; - - const sw = document.createElement("div"); - sw.className = "swatch"; - sw.style.width = "22px"; - sw.style.height = "22px"; - sw.style.background = m.color || "#2a3442"; - title.appendChild(sw); + if (!spoolmanConfigured) { + wrap.innerHTML = ''; + const msg = document.createElement('div'); + msg.className = 'tag muted'; + msg.textContent = t('spoolman.not_configured'); + wrap.appendChild(msg); + return; + } - const nm = document.createElement("div"); - nm.className = "histSlotName"; - nm.textContent = `Box ${sid[0]} · Slot ${sid[1]}` + (sid === active ? t('history.active_suffix') : ""); - title.appendChild(nm); + if (!slot) { + wrap.innerHTML = ''; + const msg = document.createElement('div'); + msg.className = 'tag muted'; + msg.textContent = t('spoolman.slot_not_linked'); + wrap.appendChild(msg); + return; + } - head.appendChild(title); + try { + const r = await fetch(`/api/ui/spoolman/spool_detail?slot=${encodeURIComponent(slot)}`, { cache: 'no-store' }); + const data = await r.json(); + wrap.innerHTML = ''; - // totals - let sumMm = 0; - let sumG = 0; - for (const e of entries) { - sumMm += Number(e.used_mm || 0); - sumG += Number(e.used_g || 0); + if (!data.linked) { + const msg = document.createElement('div'); + msg.className = 'tag muted'; + msg.textContent = t('spoolman.slot_not_linked'); + wrap.appendChild(msg); + return; } - const meta = document.createElement("div"); - meta.className = "histMeta"; - // Primary: grams (this is what matters). Keep meters as detail in entry. - meta.textContent = entries.length ? `${fmtG(sumG)}` : "—"; - head.appendChild(meta); - card.appendChild(head); - const list = document.createElement("div"); - list.className = "histList"; + if (!data.spool) { + const msg = document.createElement('div'); + msg.className = 'tag muted'; + msg.textContent = t('spoolman.unavailable'); + wrap.appendChild(msg); + return; + } - if (!entries.length) { - const empty = document.createElement("div"); - empty.className = "tag muted"; - empty.textContent = t('history.no_data'); - list.appendChild(empty); - } else { - for (const e of entries) { - const det = document.createElement("details"); - det.className = "histEntry"; - det.dataset.key = `${sid}:${String(e.ts || '')}:${String(e.job || '')}`; - - const sum = document.createElement("summary"); - const row = document.createElement("div"); - row.className = "histRow"; - - const job = document.createElement("div"); - job.className = "histJob"; - job.textContent = (e.job || t('history.no_name')); - - const nums = document.createElement("div"); - nums.className = "histNums"; - const mmTxt = Number(e.used_mm || 0) > 0 ? ` (${fmtMm(e.used_mm)})` : ""; - nums.textContent = `${fmtG(e.used_g)}${mmTxt}`; - - row.appendChild(job); - row.appendChild(nums); - sum.appendChild(row); - det.appendChild(sum); - - const sub = document.createElement("div"); - sub.className = "histSub"; - const when = document.createElement("span"); - when.textContent = "🕒 " + fmtTs(e.ts); - const res = document.createElement("span"); - res.textContent = "✅ " + String(e.result || ""); - const mat = document.createElement("span"); - mat.textContent = "🧵 " + (m.material || "—") + (m.color ? " " + m.color.toUpperCase() : ""); - sub.appendChild(when); - sub.appendChild(mat); - if (e.result) sub.appendChild(res); - det.appendChild(sub); - - list.appendChild(det); - } + const sp = data.spool; + const card = document.createElement('div'); + card.className = 'spoolStatusCard'; + + const rows = [ + { label: t('spoolman.remaining'), value: sp.remaining_weight != null ? fmtG(sp.remaining_weight) : '—' }, + { label: t('spoolman.used_total'), value: sp.used_weight != null ? fmtG(sp.used_weight) : '—' }, + { label: t('spoolman.first_used'), value: sp.first_used ? fmtTs(new Date(sp.first_used).getTime() / 1000) : '—' }, + { label: t('spoolman.last_used'), value: sp.last_used ? fmtTs(new Date(sp.last_used).getTime() / 1000) : '—' }, + ]; + + for (const row of rows) { + const div = document.createElement('div'); + div.className = 'spoolStatRow'; + const lbl = document.createElement('span'); + lbl.className = 'spoolStatLabel'; + lbl.textContent = row.label; + const val = document.createElement('span'); + val.className = 'spoolStatValue'; + val.textContent = row.value; + div.appendChild(lbl); + div.appendChild(val); + card.appendChild(div); } - card.appendChild(list); wrap.appendChild(card); + } catch (e) { + wrap.innerHTML = ''; + const msg = document.createElement('div'); + msg.className = 'tag muted'; + msg.textContent = t('spoolman.unavailable'); + wrap.appendChild(msg); } } @@ -795,14 +718,8 @@ function render(state) { material: ((m.material ?? local.material) || "").toString().toUpperCase(), color: ((m.color ?? m.color_hex ?? local.color ?? local.color_hex) || "").toString().toLowerCase(), - // spool fields (local bookkeeping) - remaining_g: (local.remaining_g ?? null), - spool_remaining_g: (local.spool_remaining_g ?? null), - spool_used_g: (local.spool_used_g ?? null), - spool_consumed_g: (local.spool_consumed_g ?? null), + // spool epoch (for roll-change tracking) spool_epoch: (local.spool_epoch ?? null), - spool_ref_remaining_g: (local.spool_ref_remaining_g ?? null), - spool_ref_consumed_g: (local.spool_ref_consumed_g ?? null), // Spoolman spoolman_id: (local.spoolman_id ?? null), @@ -865,8 +782,8 @@ function render(state) { boxesGrid.appendChild(makeBoxCard(b)); } - // Right-side history panel - renderHistory(state, slots, connectedBoxes); + // Right-side Spoolman status panel + fetchAndRenderSpoolmanStatus(active, state); renderMoonHistory(state, connectedBoxes); // Active card diff --git a/static/i18n.js b/static/i18n.js index 0aa69b6..314d427 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -12,10 +12,9 @@ const I18N = { 'status.ready': 'bereit', // Section titles - 'section.active': 'Aktiv', - 'section.history': 'Historie pro Slot', - 'section.history_last4': 'letzte 4', - 'section.moon_summary': 'Moonraker-History (gesamt)', + 'section.active': 'Aktiv', + 'section.spoolman_status': 'Spoolman Status', + 'section.moon_summary': 'Moonraker-History (gesamt)', // Refresh control 'refresh.title': 'Update-Intervall', @@ -23,27 +22,24 @@ const I18N = { // Spool modal 'modal.close': 'Schließen', - 'modal.weigh_label': 'Istgewicht (g)', - 'modal.weigh_ph': 'z.B. 206', - 'modal.btn_apply': 'Übernehmen', 'modal.newroll_label': 'Neue Rolle (g)', 'modal.newroll_ph': 'z.B. 1000', 'modal.btn_rollchange': 'Rollwechsel', - 'modal.hint': 'Hinweis: Das speichert nur lokal in dieser App (kein POST an den Drucker). Rollwechsel versteckt alte Drucke in der Slot-Historie (bleibt intern gespeichert).', - - // Spool stats - 'spool.stats_full': 'Rest (berechnet): {remaining} · verbraucht seit Übernahme: {used} · Gesamt (Slot): {total}', - 'spool.stats_partial': 'Rest (aktuell): {remaining} · Tipp: "Istgewicht" eintragen und Übernehmen.', - 'spool.stats_none': 'Noch kein Referenzwert. Trage "Istgewicht" ein und klicke Übernehmen.', + 'modal.hint': 'Hinweis: Das speichert nur lokal in dieser App (kein POST an den Drucker). Rollwechsel trennt die Spoolman-Verknüpfung.', // Moonraker history 'moon.empty': 'Keine Moonraker-History Daten', 'moon.no_consumption': 'Kein Verbrauch in History gefunden', - // History - 'history.no_name': '(ohne name)', - 'history.active_suffix': ' · aktiv', - 'history.no_data': 'Noch keine Daten', + // Spoolman status panel + 'spoolman.remaining': 'Restgewicht', + 'spoolman.used_total': 'Verbraucht gesamt', + 'spoolman.first_used': 'Erste Nutzung', + 'spoolman.last_used': 'Letzte Nutzung', + 'spoolman.not_configured': 'Spoolman nicht konfiguriert', + 'spoolman.slot_not_linked': 'Kein Spool verknüpft', + 'spoolman.loading_spool': 'Lade Spool-Daten …', + 'spoolman.unavailable': 'Spoolman nicht erreichbar', // Assignment 'assign.title_existing': 'Zuordnung (lokal gespeichert)', @@ -97,10 +93,9 @@ const I18N = { 'status.ready': 'ready', // Section titles - 'section.active': 'Active', - 'section.history': 'History per Slot', - 'section.history_last4': 'last 4', - 'section.moon_summary': 'Moonraker History (total)', + 'section.active': 'Active', + 'section.spoolman_status': 'Spoolman Status', + 'section.moon_summary': 'Moonraker History (total)', // Refresh control 'refresh.title': 'Refresh interval', @@ -108,27 +103,24 @@ const I18N = { // Spool modal 'modal.close': 'Close', - 'modal.weigh_label': 'Current weight (g)', - 'modal.weigh_ph': 'e.g. 206', - 'modal.btn_apply': 'Apply', 'modal.newroll_label': 'New roll (g)', 'modal.newroll_ph': 'e.g. 1000', 'modal.btn_rollchange': 'Roll change', - 'modal.hint': 'Note: This saves locally in this app only (no POST to printer). Roll change hides old prints in slot history (kept internally).', - - // Spool stats - 'spool.stats_full': 'Remaining (calc): {remaining} · used since reference: {used} · Total (slot): {total}', - 'spool.stats_partial': 'Remaining (current): {remaining} · Tip: enter "Current weight" and click Apply.', - 'spool.stats_none': 'No reference yet. Enter "Current weight" and click Apply.', + 'modal.hint': 'Note: This saves locally in this app only (no POST to printer). Roll change unlinks the Spoolman spool.', // Moonraker history 'moon.empty': 'No Moonraker history data', 'moon.no_consumption': 'No consumption found in history', - // History - 'history.no_name': '(unnamed)', - 'history.active_suffix': ' · active', - 'history.no_data': 'No data yet', + // Spoolman status panel + 'spoolman.remaining': 'Remaining weight', + 'spoolman.used_total': 'Total used', + 'spoolman.first_used': 'First used', + 'spoolman.last_used': 'Last used', + 'spoolman.not_configured': 'Spoolman not configured', + 'spoolman.slot_not_linked': 'No spool linked', + 'spoolman.loading_spool': 'Loading spool data…', + 'spoolman.unavailable': 'Spoolman unreachable', // Assignment 'assign.title_existing': 'Assignment (saved locally)', diff --git a/static/index.html b/static/index.html index ce7b6fa..768300f 100644 --- a/static/index.html +++ b/static/index.html @@ -44,9 +44,8 @@
- Tip: If colors/material are not shown, check moonraker_url in data/config.json. + Tip: If colors/material are not shown, set printer_url in data/config.json to your printer's IP.
From 3d0431e51d05e83ccd9b25a8709248ab6c650b03 Mon Sep 17 00:00:00 2001 From: koen01 Date: Sat, 28 Feb 2026 23:45:18 +0100 Subject: [PATCH 004/134] Fix WS connect: drain initial status dump before heartbeat handshake The printer pushes an unsolicited JSON status message immediately on connect. This caused _ws_connect_and_run to receive the status dump instead of the heartbeat "ok" reply, crashing the loop. Fix: drain all messages (1.5s timeout loop) after connecting, then send the heartbeat. Also downgrade unexpected heartbeat replies from errors to warnings so a timing quirk doesn't drop the connection. Co-Authored-By: Claude Sonnet 4.6 --- main.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/main.py b/main.py index 8dcd2da..9c013b7 100644 --- a/main.py +++ b/main.py @@ -528,11 +528,23 @@ def _parse_ws_cfs_data(payload: dict) -> None: async def _ws_connect_and_run(ws_url: str) -> None: """Open one WebSocket connection to the printer and run the polling loop.""" async with websockets.connect(ws_url) as ws: - # Initial heartbeat handshake + # The printer pushes an unsolicited status JSON immediately on connect. + # Drain those initial messages before initiating the heartbeat handshake. + while True: + try: + drained = await asyncio.wait_for(ws.recv(), timeout=1.5) + print(f"[WS] Drained {len(str(drained))} byte initial message") + except asyncio.TimeoutError: + break + + # Heartbeat handshake — confirms connection is live await ws.send(json.dumps({"ModeCode": "heart_beat"})) - reply = await asyncio.wait_for(ws.recv(), timeout=5.0) - if str(reply).strip() != "ok": - raise ValueError(f"Unexpected heartbeat reply: {reply!r}") + try: + reply = await asyncio.wait_for(ws.recv(), timeout=5.0) + if str(reply).strip() != "ok": + print(f"[WS] Heartbeat reply unexpected: {str(reply)[:80]!r} (continuing)") + except asyncio.TimeoutError: + print("[WS] Heartbeat timeout (continuing)") st = load_state() st.printer_connected = True From b3ab94fdcf74a53c76cbb599ff06a875720ee77c Mon Sep 17 00:00:00 2001 From: koen01 Date: Sat, 28 Feb 2026 23:52:59 +0100 Subject: [PATCH 005/134] Fix WS ping timeout; show brand/name/material/Spoolman on slot chips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WS fix: disable websockets keepalive pings (ping_interval=None, ping_timeout=None) — the printer doesn't respond to WebSocket ping frames, causing the library to drop the connection after ~20s. Slot chip display: - Line 2 (.slotSub): shows "{manufacturer} {name}" when CFS/Spoolman data is available; falls back to "MATERIAL · #COLOR" otherwise - Line 3 (.slotDetail): shows material type + "SP #{id}" when a Spoolman spool is linked - Add .slotDetail and .spoolPct CSS rules Co-Authored-By: Claude Sonnet 4.6 --- main.py | 2 +- static/app.js | 25 +++++++++++++++++++++---- static/style.css | 4 +++- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/main.py b/main.py index 9c013b7..0544b0a 100644 --- a/main.py +++ b/main.py @@ -527,7 +527,7 @@ def _parse_ws_cfs_data(payload: dict) -> None: async def _ws_connect_and_run(ws_url: str) -> None: """Open one WebSocket connection to the printer and run the polling loop.""" - async with websockets.connect(ws_url) as ws: + async with websockets.connect(ws_url, ping_interval=None, ping_timeout=None) as ws: # The printer pushes an unsolicited status JSON immediately on connect. # Drain those initial messages before initiating the heartbeat handshake. while True: diff --git a/static/app.js b/static/app.js index 177b362..8d4638a 100644 --- a/static/app.js +++ b/static/app.js @@ -41,12 +41,29 @@ function slotEl(slotId, label, meta, isActive) { const sub = document.createElement("div"); sub.className = "slotSub"; - const parts = []; - if (meta.material) parts.push(meta.material); - if (meta.color) parts.push(meta.color.toUpperCase()); - sub.textContent = parts.length ? parts.join(" · ") : "—"; + // Line 2: brand + filament name if available, else material + color + const brandName = [meta.manufacturer, meta.name].filter(Boolean).join(' '); + if (brandName) { + sub.textContent = brandName; + } else { + const parts = []; + if (meta.material) parts.push(meta.material); + if (meta.color) parts.push(meta.color.toUpperCase()); + sub.textContent = parts.length ? parts.join(" · ") : "—"; + } txt.appendChild(sub); + // Line 3: material type + Spoolman link indicator (only shown when line 2 has brand/name info) + const detailParts = []; + if (brandName && meta.material) detailParts.push(meta.material); + if (meta.spoolman_id) detailParts.push('SP #' + meta.spoolman_id); + if (detailParts.length) { + const detail = document.createElement("div"); + detail.className = "slotDetail"; + detail.textContent = detailParts.join(' · '); + txt.appendChild(detail); + } + left.appendChild(txt); const right = document.createElement("div"); diff --git a/static/style.css b/static/style.css index 2030940..738549a 100644 --- a/static/style.css +++ b/static/style.css @@ -162,6 +162,7 @@ body{ .slotText{min-width:0} .slotName{font-weight:700;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .slotSub{font-size:12px;color:var(--muted);margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.slotDetail{font-size:11px;color:var(--muted);opacity:.7;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} /* Spool status inside slot cards */ .spoolRow{margin-top:6px;} @@ -180,7 +181,8 @@ body{ border-color: rgba(255,90,90,.28); } -.slotRight{display:flex;flex-direction:column;align-items:flex-end;gap:6px;flex:0 0 auto} +.slotRight{display:flex;flex-direction:column;align-items:flex-end;gap:4px;flex:0 0 auto} +.spoolPct{font-size:11px;color:var(--muted);opacity:.8;text-align:right} .tag{ font-size:11px; padding:4px 8px; From 51da7caa25a0da05483d5cecfce0c51c45a4a255 Mon Sep 17 00:00:00 2001 From: koen01 Date: Sun, 1 Mar 2026 00:02:40 +0100 Subject: [PATCH 006/134] Add RFID-based Spoolman auto-link via extra.cfs_rfid On manual spool link: write the slot's CFS RFID to the Spoolman spool's extra.cfs_rfid field via PATCH. On each WS snapshot: if an RFID-tagged spool (state==2) appears on an unlinked slot and the RFID is new since last seen, search Spoolman for a spool with matching extra.cfs_rfid and auto-link it. On roll change: clear the RFID cache for the slot so re-inserting any spool (even the same one) triggers auto-link again. Co-Authored-By: Claude Sonnet 4.6 --- main.py | 70 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/main.py b/main.py index 0544b0a..9e11c56 100644 --- a/main.py +++ b/main.py @@ -376,6 +376,56 @@ def _spoolman_report_measure(spool_id: int, weight_g: float) -> None: print(f"[SPOOLMAN] measure report failed for spool {spool_id}: {e}") +def _spoolman_set_extra(spool_id: int, key: str, value: str) -> None: + """PATCH Spoolman spool to write a single extra field. Fire-and-forget.""" + base = _spoolman_base_url() + if not base or not spool_id: + return + try: + url = f"{base}/api/v1/spool/{spool_id}" + data = json.dumps({"extra": {key: value}}).encode("utf-8") + req = UrlRequest(url, data=data, headers={ + "User-Agent": "filament-manager/1.0", + "Content-Type": "application/json", + }, method="PATCH") + with urlopen(req, timeout=3.0) as r: + r.read() + print(f"[SPOOLMAN] set extra {key}={value!r} on spool {spool_id}") + except Exception as e: + print(f"[SPOOLMAN] set extra failed for spool {spool_id}: {e}") + + +def _spoolman_autolink_by_rfid(slot: str, rfid: str, st) -> None: + """Search active Spoolman spools for one with extra.cfs_rfid == rfid and auto-link.""" + global _ws_last_rfid + base = _spoolman_base_url() + if not base or not rfid: + return + try: + spools = _http_get_json(f"{base}/api/v1/spool?allow_archived=false", timeout=5.0) + if not isinstance(spools, list): + return + for sp in spools: + extra = sp.get("extra") or {} + if extra.get("cfs_rfid") != rfid: + continue + spool_id = sp.get("id") + if not spool_id: + continue + slot_state = st.slots.get(slot) + if slot_state is None: + return + slot_state.spoolman_id = spool_id + st.slots[slot] = slot_state + # Record RFID as seen so we don't re-trigger next cycle + _ws_last_rfid[slot] = rfid + save_state(st) + print(f"[SPOOLMAN] Auto-linked slot {slot} → spool {spool_id} via RFID {rfid!r}") + return + except Exception as e: + print(f"[SPOOLMAN] auto-link lookup failed for slot {slot}: {e}") + + def _color_distance(hex1: str, hex2: str) -> float: """Simple Euclidean RGB distance between two hex colors.""" try: @@ -390,6 +440,7 @@ def _color_distance(hex1: str, hex2: str) -> float: _WS_SAVE_INTERVAL = 10.0 _ws_last_save: float = 0.0 +_ws_last_rfid: Dict[str, str] = {} # slot → last seen RFID code _VALID_SLOT_IDS = frozenset( f"{b}{l}" for b in "1234" for l in "ABCD" @@ -489,6 +540,16 @@ def _parse_ws_cfs_data(payload: dict) -> None: slot_obj.manufacturer = vendor st.slots[slot] = slot_obj + # RFID-based auto-link: if a new RFID appears on an unlinked slot, search Spoolman + rfid = mat.get("rfid", "") + if rfid and state_val == 2: # state 2 = RFID-tagged spool + prev_rfid = _ws_last_rfid.get(slot, "") + if rfid != prev_rfid: + _ws_last_rfid[slot] = rfid + slot_obj2 = st.slots.get(slot) + if slot_obj2 and not getattr(slot_obj2, "spoolman_id", None): + _spoolman_autolink_by_rfid(slot, rfid, st) + # Spoolman delta: report length used since last snapshot cur_m = float(mat.get("usedMaterialLength") or 0) prev_m = float(st.ws_slot_length_m.get(slot, cur_m)) @@ -779,6 +840,8 @@ def api_ui_spool_set_start(req: UiSpoolSetStartRequest) -> ApiResponse: state.slots[slot] = s # Reset WS length baseline so next snapshot doesn't trigger a false delta state.ws_slot_length_m.pop(slot, None) + # Clear RFID cache so re-inserting any spool triggers auto-link again + _ws_last_rfid.pop(slot, None) save_state(state) return ApiResponse(result=_ui_state_dict(state)) @@ -874,6 +937,13 @@ def api_ui_spoolman_link(req: SpoolmanLinkRequest) -> ApiResponse: state.slots[slot] = s save_state(state) + + # Write the slot's CFS RFID to the Spoolman spool's extra field for future auto-linking + rfid = (state.cfs_slots.get(slot) or {}).get("rfid", "") + if rfid: + _spoolman_set_extra(req.spoolman_id, "cfs_rfid", rfid) + _ws_last_rfid[slot] = rfid # mark as seen so auto-link doesn't re-trigger this cycle + return ApiResponse(result=_ui_state_dict(state)) From 719338743fc5fbb7c6591bc33962319878ed31cb Mon Sep 17 00:00:00 2001 From: koen01 Date: Sun, 1 Mar 2026 00:06:11 +0100 Subject: [PATCH 007/134] Auto-unlink + re-link on RFID change for already-linked slots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If the CFS RFID changes on a slot that already has a Spoolman link, treat it as an implicit spool swap: clear the old spoolman_id and ws_slot_length_m baseline, then try to auto-link to the new spool via extra.cfs_rfid — same as if the slot had been unlinked first. Co-Authored-By: Claude Sonnet 4.6 --- main.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/main.py b/main.py index 9e11c56..1839f72 100644 --- a/main.py +++ b/main.py @@ -540,14 +540,19 @@ def _parse_ws_cfs_data(payload: dict) -> None: slot_obj.manufacturer = vendor st.slots[slot] = slot_obj - # RFID-based auto-link: if a new RFID appears on an unlinked slot, search Spoolman + # RFID-based auto-link: react to any RFID change on this slot rfid = mat.get("rfid", "") if rfid and state_val == 2: # state 2 = RFID-tagged spool prev_rfid = _ws_last_rfid.get(slot, "") if rfid != prev_rfid: _ws_last_rfid[slot] = rfid slot_obj2 = st.slots.get(slot) - if slot_obj2 and not getattr(slot_obj2, "spoolman_id", None): + if slot_obj2: + if getattr(slot_obj2, "spoolman_id", None): + # RFID changed on a linked slot — implicit spool swap + slot_obj2.spoolman_id = None + st.slots[slot] = slot_obj2 + st.ws_slot_length_m.pop(slot, None) # reset baseline _spoolman_autolink_by_rfid(slot, rfid, st) # Spoolman delta: report length used since last snapshot From a577197389bfe5fa4883f958f246ad30a95c4e0c Mon Sep 17 00:00:00 2001 From: koen01 Date: Sun, 1 Mar 2026 00:09:14 +0100 Subject: [PATCH 008/134] Clear stale cfs_active_slot when no spool is selected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously cfs_active_slot was only written when selected==1 was found, so an old value (e.g. 2A from a previous session) would persist indefinitely. Always write the value — None when the printer is idle — so the Active section stays blank when nothing is printing. Co-Authored-By: Claude Sonnet 4.6 --- main.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/main.py b/main.py index 1839f72..5ae1beb 100644 --- a/main.py +++ b/main.py @@ -575,10 +575,10 @@ def _parse_ws_cfs_data(payload: dict) -> None: if boxes_meta: st.cfs_slots["_boxes"] = boxes_meta - if active_slot: - st.cfs_active_slot = active_slot - if active_slot in st.slots: - st.active_slot = active_slot + # Always update active slot — clears stale value when printer is idle + st.cfs_active_slot = active_slot + if active_slot and active_slot in st.slots: + st.active_slot = active_slot st.cfs_connected = True st.cfs_last_update = _now() From ab4bb6cb1d14527ced13a2acd68eaef99e14fc4a Mon Sep 17 00:00:00 2001 From: koen01 Date: Sun, 1 Mar 2026 00:30:47 +0100 Subject: [PATCH 009/134] Fix Spoolman extra field format: values must be JSON-encoded strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spoolman requires extra field values to be double-encoded — the value itself must be a JSON string. Sending "06001" caused a 400 Bad Request; the correct form is json.dumps("06001") → "\"06001\"". Also decode the stored value (json.loads) when comparing RFID during auto-link lookup so the match works correctly. Co-Authored-By: Claude Sonnet 4.6 --- main.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/main.py b/main.py index 5ae1beb..5117474 100644 --- a/main.py +++ b/main.py @@ -383,7 +383,8 @@ def _spoolman_set_extra(spool_id: int, key: str, value: str) -> None: return try: url = f"{base}/api/v1/spool/{spool_id}" - data = json.dumps({"extra": {key: value}}).encode("utf-8") + # Spoolman requires extra field values to be JSON-encoded strings (double-encoded) + data = json.dumps({"extra": {key: json.dumps(value)}}).encode("utf-8") req = UrlRequest(url, data=data, headers={ "User-Agent": "filament-manager/1.0", "Content-Type": "application/json", @@ -407,7 +408,13 @@ def _spoolman_autolink_by_rfid(slot: str, rfid: str, st) -> None: return for sp in spools: extra = sp.get("extra") or {} - if extra.get("cfs_rfid") != rfid: + raw = extra.get("cfs_rfid", "") + # Spoolman stores extra values as JSON-encoded strings — decode before comparing + try: + stored_rfid = json.loads(raw) if raw else "" + except Exception: + stored_rfid = raw + if stored_rfid != rfid: continue spool_id = sp.get("id") if not spool_id: From e17ddf2c16e5ce07919eef65236ebc1e21ae9d7a Mon Sep 17 00:00:00 2001 From: koen01 Date: Sun, 1 Mar 2026 00:56:19 +0100 Subject: [PATCH 010/134] Fix stale active_slot: remove JS fallback, clear bad schema default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The printer never sets selected==1 on any slot (all 0 in WS data), so cfs_active_slot is always null. The JS was falling back to state.active_slot which defaulted to "2A" from the Pydantic schema, permanently showing "Box 2 · Slot A" as active. - JS: remove || state.active_slot fallbacks in render() and fetchAndRenderSpoolmanStatus() — cfs_active_slot is the only source - Schema: AppState.active_slot default changed from "2A" to None - Migration: clear existing "2A" values from state.json on load Co-Authored-By: Claude Sonnet 4.6 --- main.py | 4 ++++ models/schemas.py | 2 +- static/app.js | 4 ++-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/main.py b/main.py index 5117474..17a5e71 100644 --- a/main.py +++ b/main.py @@ -242,6 +242,10 @@ def _migrate_state_dict(data: dict) -> dict: data.setdefault("cfs_slots", {}) data.setdefault("ws_slot_length_m", {}) + # Clear the stale "2A" schema default — active_slot is now driven by WS only + if data.get("active_slot") == "2A": + data["active_slot"] = None + return data diff --git a/models/schemas.py b/models/schemas.py index f286355..cfb0348 100644 --- a/models/schemas.py +++ b/models/schemas.py @@ -46,7 +46,7 @@ def normalize_material(cls, v: Any): class AppState(BaseModel): - active_slot: SlotId = "2A" + active_slot: Optional[SlotId] = None auto_mode: bool = False slots: Dict[SlotId, SlotState] updated_at: float = Field(default_factory=lambda: time.time()) diff --git a/static/app.js b/static/app.js index 8d4638a..102598d 100644 --- a/static/app.js +++ b/static/app.js @@ -381,7 +381,7 @@ async function fetchAndRenderSpoolmanStatus(activeSlot, state) { const wrap = $("slotHistory"); if (!wrap) return; - const slot = activeSlot || state.active_slot || null; + const slot = activeSlot || null; wrap.innerHTML = ''; const loading = document.createElement('div'); @@ -483,7 +483,7 @@ function render(state) { // We prefer Creality CFS slots (state.cfs_slots). Fallback to local slots if not present. const slots = (state.cfs_slots && Object.keys(state.cfs_slots).length) ? state.cfs_slots : state.slots; - const active = state.cfs_active_slot || state.active_slot || null; + const active = state.cfs_active_slot || null; const boxesGrid = $("boxesGrid"); boxesGrid.innerHTML = ""; From 84e286c8183fc7894905c5e7b4b30411565170bd Mon Sep 17 00:00:00 2001 From: koen01 Date: Sun, 1 Mar 2026 01:14:00 +0100 Subject: [PATCH 011/134] Use Optional[str] for active_slot to avoid Pydantic Literal type edge cases Optional[Literal[...]] = None can behave unexpectedly across Pydantic versions. active_slot is now driven by WS only and not used by the frontend, so the Literal constraint adds no value. Co-Authored-By: Claude Sonnet 4.6 --- models/schemas.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/models/schemas.py b/models/schemas.py index cfb0348..93a6dd0 100644 --- a/models/schemas.py +++ b/models/schemas.py @@ -46,7 +46,7 @@ def normalize_material(cls, v: Any): class AppState(BaseModel): - active_slot: Optional[SlotId] = None + active_slot: Optional[str] = None # legacy; frontend uses cfs_active_slot auto_mode: bool = False slots: Dict[SlotId, SlotState] updated_at: float = Field(default_factory=lambda: time.time()) From 1ec31205ae6a4284358dfc4de400e36701191493 Mon Sep 17 00:00:00 2001 From: koen01 Date: Sun, 1 Mar 2026 01:21:04 +0100 Subject: [PATCH 012/134] Fix WS drain loop stuck + protect state from being wiped on load failure Drain loop: replaced per-message 1.5s timeout with a 2-second total deadline. The printer sends status messages continuously, so the old loop never timed out and the WS was stuck in drain forever, never reaching printer_connected = True. State protection: load_state() now sets _state_load_failed when it falls back to default_state(). save_state() checks this flag and refuses to write, preventing a failed load from wiping real spool data. Co-Authored-By: Claude Sonnet 4.6 --- main.py | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/main.py b/main.py index 17a5e71..8a0cec5 100644 --- a/main.py +++ b/main.py @@ -249,18 +249,33 @@ def _migrate_state_dict(data: dict) -> dict: return data +_state_load_failed: bool = False # True when last load fell back to default + def load_state() -> AppState: + global _state_load_failed _ensure_data_files() try: data = json.loads(STATE_PATH.read_text()) data = _migrate_state_dict(data) - return _model_validate(AppState, data) + result = _model_validate(AppState, data) + _state_load_failed = False + return result except Exception as e: # Corrupt/partial state files should never prevent the app from starting. print(f"[STATE] load failed: {e}") + _state_load_failed = True return default_state() +def save_state(state: AppState) -> None: + # Never overwrite real state with a fallback default — that destroys user data. + if _state_load_failed: + print("[STATE] save skipped: last load returned fallback default") + return + state.updated_at = _now() + STATE_PATH.write_text(json.dumps(_model_dump(state), indent=2, ensure_ascii=False)) + + def save_state(state: AppState) -> None: state.updated_at = _now() @@ -605,11 +620,12 @@ def _parse_ws_cfs_data(payload: dict) -> None: async def _ws_connect_and_run(ws_url: str) -> None: """Open one WebSocket connection to the printer and run the polling loop.""" async with websockets.connect(ws_url, ping_interval=None, ping_timeout=None) as ws: - # The printer pushes an unsolicited status JSON immediately on connect. - # Drain those initial messages before initiating the heartbeat handshake. - while True: + # The printer pushes unsolicited status messages continuously. + # Drain for at most 2 seconds so queued messages don't block the handshake. + drain_deadline = asyncio.get_event_loop().time() + 2.0 + while asyncio.get_event_loop().time() < drain_deadline: try: - drained = await asyncio.wait_for(ws.recv(), timeout=1.5) + drained = await asyncio.wait_for(ws.recv(), timeout=0.3) print(f"[WS] Drained {len(str(drained))} byte initial message") except asyncio.TimeoutError: break From 4df92dcad521a6bf90083ea48a58709dfd610c01 Mon Sep 17 00:00:00 2001 From: koen01 Date: Sun, 1 Mar 2026 22:01:10 +0100 Subject: [PATCH 013/134] Add Moonraker job-end usage reporting; remove i18n - Replace WS-delta Spoolman reporting with Moonraker-driven end-of-job attribution: snapshot ws_slot_length_m at job start, then at job complete proportionally split filament_used across linked slots using WS deltas, and report to Spoolman via _spoolman_report_usage() - Add _moonraker_base_url(), _moon_report_job_usage(), moonraker_job_poll_loop() to main.py; launch loop in _startup() - Remove WS Spoolman delta block from _parse_ws_cfs_data() (ws_slot_length_m still updated for attribution tracking) - Delete static/i18n.js; hardcode English throughout app.js and index.html - Remove language switcher buttons and .langSwitch/.langBtn CSS Co-Authored-By: Claude Sonnet 4.6 --- main.py | 110 ++++++++++++++++++++++---- static/app.js | 119 ++++++++++------------------ static/i18n.js | 195 ---------------------------------------------- static/index.html | 37 ++++----- static/style.css | 16 ---- 5 files changed, 155 insertions(+), 322 deletions(-) delete mode 100644 static/i18n.js diff --git a/main.py b/main.py index 8a0cec5..339f7ec 100644 --- a/main.py +++ b/main.py @@ -468,6 +468,9 @@ def _color_distance(hex1: str, hex2: str) -> float: _ws_last_save: float = 0.0 _ws_last_rfid: Dict[str, str] = {} # slot → last seen RFID code +_moon_last_state: str = "" # last known print_stats.state from Moonraker +_moon_job_start_lengths: Dict[str, float] = {} # ws_slot_length_m snapshot at job start + _VALID_SLOT_IDS = frozenset( f"{b}{l}" for b in "1234" for l in "ABCD" ) @@ -485,6 +488,19 @@ def _printer_ws_url() -> str: return f"ws://{host.split(':')[0]}:9999" +def _moonraker_base_url() -> str: + """Return the Moonraker HTTP base URL (port 7125), or empty string if not configured.""" + cfg = load_config() + mu = (cfg.get("moonraker_url") or "").strip() + if mu: + parsed = urlparse(mu) + host = parsed.hostname or "" + port = parsed.port or 7125 + return f"http://{host}:{port}" + host = (cfg.get("printer_url") or "").strip().split(":")[0] + return f"http://{host}:7125" if host else "" + + def _normalize_ws_color(raw: str) -> str: """Strip leading zero after '#' from Creality color format '#0RRGGBB' → '#RRGGBB'.""" s = (raw or "").lstrip("#") @@ -581,20 +597,8 @@ def _parse_ws_cfs_data(payload: dict) -> None: st.ws_slot_length_m.pop(slot, None) # reset baseline _spoolman_autolink_by_rfid(slot, rfid, st) - # Spoolman delta: report length used since last snapshot + # Track cumulative length for per-job Moonraker attribution cur_m = float(mat.get("usedMaterialLength") or 0) - prev_m = float(st.ws_slot_length_m.get(slot, cur_m)) - delta_m = cur_m - prev_m - if delta_m > 0.01: - slot_obj = st.slots.get(slot) - if slot_obj and getattr(slot_obj, "spoolman_id", None): - try: - mat_str = str(getattr(slot_obj, "material", "OTHER") or "OTHER") - g = mm_to_g(mat_str, delta_m * 1000) - if g > 0: - _spoolman_report_usage(slot_obj.spoolman_id, g) - except Exception: - pass st.ws_slot_length_m[slot] = cur_m # Store box connection metadata so the frontend can show correct boxes @@ -699,6 +703,85 @@ async def printer_ws_loop() -> None: backoff = min(backoff * 2, 60.0) +def _moon_report_job_usage(filament_mm: float) -> None: + """Attribute Moonraker's filament_used proportionally across slots using WS deltas.""" + global _moon_job_start_lengths + st = load_state() + slot_deltas: Dict[str, float] = {} + for slot, cur_m in st.ws_slot_length_m.items(): + start_m = _moon_job_start_lengths.get(slot, cur_m) + delta = cur_m - start_m + if delta > 0.01: + slot_deltas[slot] = delta + total_delta_m = sum(slot_deltas.values()) + if not slot_deltas or total_delta_m <= 0: + print(f"[MOON] Job complete: {filament_mm:.0f}mm used, no WS slot deltas to attribute") + _moon_job_start_lengths = {} + return + for slot, delta_m in slot_deltas.items(): + slot_obj = st.slots.get(slot) + if not slot_obj: + continue + spool_id = getattr(slot_obj, "spoolman_id", None) + if not spool_id: + continue + proportion = delta_m / total_delta_m + mat_str = str(getattr(slot_obj, "material", "OTHER") or "OTHER") + g = mm_to_g(mat_str, filament_mm * proportion) + if g > 0: + _spoolman_report_usage(spool_id, g) + print(f"[MOON] Slot {slot}: {g:.2f}g ({proportion * 100:.0f}% of job)") + _moon_job_start_lengths = {} + + +async def moonraker_job_poll_loop() -> None: + """Poll Moonraker print_stats every 5s and attribute filament usage at job completion.""" + global _moon_last_state, _moon_job_start_lengths + + base = _moonraker_base_url() + if not base: + print("[MOON] No printer URL configured — job poll loop not started.") + return + + print(f"[MOON] Starting job poll loop against {base}") + + _ACTIVE_STATES = {"printing", "paused"} + _ENDED_STATES = {"complete", "error", "cancelled", "standby"} + + while True: + await asyncio.sleep(5.0) + try: + url = f"{base}/printer/objects/query?print_stats" + data = _http_get_json(url, timeout=5.0) + ps = (data.get("result") or {}).get("status", {}).get("print_stats") or {} + new_state = str(ps.get("state") or "").lower() + filament_used_mm = float(ps.get("filament_used") or 0) + + prev = _moon_last_state + if new_state == prev: + continue + + _moon_last_state = new_state + print(f"[MOON] State: {prev!r} → {new_state!r}") + + if new_state in _ACTIVE_STATES and prev not in _ACTIVE_STATES: + # Job started — snapshot current ws_slot_length_m + st = load_state() + _moon_job_start_lengths = dict(st.ws_slot_length_m) + print(f"[MOON] Job started; snapshotted {len(_moon_job_start_lengths)} slot lengths") + + elif new_state == "complete" and prev in _ACTIVE_STATES: + print(f"[MOON] Job complete: {filament_used_mm:.0f}mm filament used") + _moon_report_job_usage(filament_used_mm) + + elif new_state in {"error", "cancelled"} and prev in _ACTIVE_STATES: + print(f"[MOON] Job {new_state} — skipping usage report") + _moon_job_start_lengths = {} + + except Exception as e: + # Network errors are expected when printer is off — don't log verbosely + pass + app = FastAPI(title="3D Printer Filament Manager", version="0.1.1") @@ -727,6 +810,7 @@ async def _no_cache_static(request: Request, call_next): async def _startup(): _ensure_data_files() asyncio.create_task(printer_ws_loop()) + asyncio.create_task(moonraker_job_poll_loop()) @app.get("/") diff --git a/static/app.js b/static/app.js index 102598d..d16297b 100644 --- a/static/app.js +++ b/static/app.js @@ -70,7 +70,7 @@ function slotEl(slotId, label, meta, isActive) { right.className = "slotRight"; const tag = document.createElement("div"); tag.className = "tag" + (!meta.material ? " muted" : ""); - tag.textContent = meta.present === false ? t('status.empty') : (isActive ? t('status.active') : t('status.ready')); + tag.textContent = meta.present === false ? 'empty' : (isActive ? 'active' : 'ready'); right.appendChild(tag); if (meta.percent != null) { @@ -119,8 +119,8 @@ async function postJson(url, payload) { body: JSON.stringify(payload), }); if (!r.ok) { - const t = await r.text().catch(() => ""); - throw new Error(t || `HTTP ${r.status}`); + const txt = await r.text().catch(() => ""); + throw new Error(txt || `HTTP ${r.status}`); } return r.json(); } @@ -171,48 +171,38 @@ function openSpoolModal(slotId, meta) { if (smSec) { if (spoolmanConfigured) { smSec.style.display = ''; - const badge = $('spoolmanBadge'); + const bdg = $('spoolmanBadge'); const notLinked = $('spoolmanNotLinked'); const linked = $('spoolmanLinked'); const info = $('spoolmanInfo'); const smId = meta.spoolman_id; if (smId) { - if (badge) { badge.textContent = t('spoolman.linked'); badge.classList.remove('muted'); badge.classList.add('ok'); } + if (bdg) { bdg.textContent = 'linked'; bdg.classList.remove('muted'); bdg.classList.add('ok'); } if (notLinked) notLinked.style.display = 'none'; if (linked) linked.style.display = 'flex'; if (info) { - info.textContent = t('spoolman.loading_spool'); + info.textContent = 'Loading spool data…'; // Fetch live remaining from Spoolman fetch(`/api/ui/spoolman/spool_detail?slot=${encodeURIComponent(slotId)}`, { cache: 'no-store' }) .then(r => r.json()) .then(data => { + const vendor = meta.manufacturer || meta.vendor || ''; + const name = meta.name || ''; if (data.spool && data.spool.remaining_weight != null) { - info.textContent = t('spoolman.linked_info', { - id: String(smId), - vendor: meta.manufacturer || meta.vendor || '', - name: meta.name || '', - remaining: fmtG(data.spool.remaining_weight), - }); + info.textContent = `Spool #${smId} · ${vendor} ${name} · ${fmtG(data.spool.remaining_weight)}`; } else { - info.textContent = t('spoolman.linked_info', { - id: String(smId), - vendor: meta.manufacturer || meta.vendor || '', - name: meta.name || '', - remaining: data.error ? t('spoolman.unavailable') : '—', - }); + const remaining = data.error ? 'Spoolman unreachable' : '—'; + info.textContent = `Spool #${smId} · ${vendor} ${name} · ${remaining}`; } }) .catch(() => { - info.textContent = t('spoolman.linked_info', { - id: String(smId), - vendor: meta.manufacturer || meta.vendor || '', - name: meta.name || '', - remaining: t('spoolman.unavailable'), - }); + const vendor = meta.manufacturer || meta.vendor || ''; + const name = meta.name || ''; + info.textContent = `Spool #${smId} · ${vendor} ${name} · Spoolman unreachable`; }); } } else { - if (badge) { badge.textContent = t('spoolman.not_linked'); badge.classList.add('muted'); badge.classList.remove('ok'); } + if (bdg) { bdg.textContent = 'not linked'; bdg.classList.add('muted'); bdg.classList.remove('ok'); } if (notLinked) notLinked.style.display = 'flex'; if (linked) linked.style.display = 'none'; loadSpoolmanDropdown(slotId); @@ -231,7 +221,7 @@ async function loadSpoolmanDropdown(slotId) { sel.innerHTML = ''; const ph = document.createElement('option'); ph.value = ''; - ph.textContent = t('spoolman.loading'); + ph.textContent = 'Loading spools…'; sel.appendChild(ph); try { @@ -244,33 +234,28 @@ async function loadSpoolmanDropdown(slotId) { if (!spools.length) { const o = document.createElement('option'); o.value = ''; - o.textContent = t('spoolman.no_spools'); + o.textContent = 'No spools found'; sel.appendChild(o); return; } const def = document.createElement('option'); def.value = ''; - def.textContent = t('spoolman.select_ph'); + def.textContent = '— Pick spool —'; sel.appendChild(def); for (const sp of spools) { const o = document.createElement('option'); o.value = String(sp.id); - o.textContent = t('spoolman.option_label', { - id: String(sp.id), - vendor: sp.vendor || '', - name: sp.filament_name || '', - material: sp.material || '', - remaining: sp.remaining_weight != null ? fmtG(sp.remaining_weight) : '?', - }); + const remaining = sp.remaining_weight != null ? fmtG(sp.remaining_weight) : '?'; + o.textContent = `#${sp.id} ${sp.vendor || ''} ${sp.filament_name || ''} · ${sp.material || ''} · ${remaining}`; sel.appendChild(o); } } catch (e) { sel.innerHTML = ''; const o = document.createElement('option'); o.value = ''; - o.textContent = t('spoolman.error', { msg: e.message || String(e) }); + o.textContent = `Spoolman error: ${e.message || String(e)}`; sel.appendChild(o); } } @@ -352,7 +337,7 @@ function initSpoolModal() { // Re-fetch spool detail from Spoolman const info = $('spoolmanInfo'); try { - if (info) info.textContent = t('spoolman.loading_spool'); + if (info) info.textContent = 'Loading spool data…'; const r = await fetch(`/api/ui/spoolman/spool_detail?slot=${encodeURIComponent(spoolSlotId)}`, { cache: 'no-store' }); const data = await r.json(); if (data.spool && data.spool.remaining_weight != null) { @@ -360,17 +345,15 @@ function initSpoolModal() { const stateJ = await stateR.json(); const stateData = stateJ.result || stateJ; const slotData = (stateData.slots || {})[spoolSlotId] || {}; - if (info) info.textContent = t('spoolman.linked_info', { - id: String(data.spool.id || slotData.spoolman_id || ''), - vendor: slotData.manufacturer || slotData.vendor || '', - name: slotData.name || '', - remaining: fmtG(data.spool.remaining_weight), - }); + const id = data.spool.id || slotData.spoolman_id || ''; + const vendor = slotData.manufacturer || slotData.vendor || ''; + const name = slotData.name || ''; + if (info) info.textContent = `Spool #${id} · ${vendor} ${name} · ${fmtG(data.spool.remaining_weight)}`; } else { - if (info) info.textContent = data.error ? t('spoolman.unavailable') : '—'; + if (info) info.textContent = data.error ? 'Spoolman unreachable' : '—'; } } catch (e) { - if (info) info.textContent = t('spoolman.error', { msg: e.message || String(e) }); + if (info) info.textContent = `Spoolman error: ${e.message || String(e)}`; } }; } @@ -386,14 +369,14 @@ async function fetchAndRenderSpoolmanStatus(activeSlot, state) { wrap.innerHTML = ''; const loading = document.createElement('div'); loading.className = 'tag muted'; - loading.textContent = t('spoolman.loading_spool'); + loading.textContent = 'Loading spool data…'; wrap.appendChild(loading); if (!spoolmanConfigured) { wrap.innerHTML = ''; const msg = document.createElement('div'); msg.className = 'tag muted'; - msg.textContent = t('spoolman.not_configured'); + msg.textContent = 'Spoolman not configured'; wrap.appendChild(msg); return; } @@ -402,7 +385,7 @@ async function fetchAndRenderSpoolmanStatus(activeSlot, state) { wrap.innerHTML = ''; const msg = document.createElement('div'); msg.className = 'tag muted'; - msg.textContent = t('spoolman.slot_not_linked'); + msg.textContent = 'No spool linked'; wrap.appendChild(msg); return; } @@ -415,7 +398,7 @@ async function fetchAndRenderSpoolmanStatus(activeSlot, state) { if (!data.linked) { const msg = document.createElement('div'); msg.className = 'tag muted'; - msg.textContent = t('spoolman.slot_not_linked'); + msg.textContent = 'No spool linked'; wrap.appendChild(msg); return; } @@ -423,7 +406,7 @@ async function fetchAndRenderSpoolmanStatus(activeSlot, state) { if (!data.spool) { const msg = document.createElement('div'); msg.className = 'tag muted'; - msg.textContent = t('spoolman.unavailable'); + msg.textContent = 'Spoolman unreachable'; wrap.appendChild(msg); return; } @@ -433,10 +416,10 @@ async function fetchAndRenderSpoolmanStatus(activeSlot, state) { card.className = 'spoolStatusCard'; const rows = [ - { label: t('spoolman.remaining'), value: sp.remaining_weight != null ? fmtG(sp.remaining_weight) : '—' }, - { label: t('spoolman.used_total'), value: sp.used_weight != null ? fmtG(sp.used_weight) : '—' }, - { label: t('spoolman.first_used'), value: sp.first_used ? fmtTs(new Date(sp.first_used).getTime() / 1000) : '—' }, - { label: t('spoolman.last_used'), value: sp.last_used ? fmtTs(new Date(sp.last_used).getTime() / 1000) : '—' }, + { label: 'Remaining weight', value: sp.remaining_weight != null ? fmtG(sp.remaining_weight) : '—' }, + { label: 'Total used', value: sp.used_weight != null ? fmtG(sp.used_weight) : '—' }, + { label: 'First used', value: sp.first_used ? fmtTs(new Date(sp.first_used).getTime() / 1000) : '—' }, + { label: 'Last used', value: sp.last_used ? fmtTs(new Date(sp.last_used).getTime() / 1000) : '—' }, ]; for (const row of rows) { @@ -458,7 +441,7 @@ async function fetchAndRenderSpoolmanStatus(activeSlot, state) { wrap.innerHTML = ''; const msg = document.createElement('div'); msg.className = 'tag muted'; - msg.textContent = t('spoolman.unavailable'); + msg.textContent = 'Spoolman unreachable'; wrap.appendChild(msg); } } @@ -468,7 +451,7 @@ function render(state) { const cfsBadge = $("cfsBadge"); const printerOk = !!state.printer_connected; - badge(printerBadge, printerOk ? t('badge.printer_ok') : t('badge.printer_off'), printerOk ? "ok" : "bad"); + badge(printerBadge, printerOk ? 'Printer: connected' : 'Printer: disconnected', printerOk ? "ok" : "bad"); if (!printerOk && state.printer_last_error) { printerBadge.textContent += " (" + state.printer_last_error + ")"; } @@ -476,7 +459,7 @@ function render(state) { const cfsOk = !!state.cfs_connected; badge( cfsBadge, - cfsOk ? t('badge.cfs_ok', {ts: fmtTs(state.cfs_last_update)}) : t('badge.cfs_off'), + cfsOk ? `CFS: detected · ${fmtTs(state.cfs_last_update)}` : 'CFS: —', cfsOk ? "ok" : "warn" ); @@ -606,8 +589,8 @@ async function tick() { spoolmanConfigured = !!st.spoolman_configured; render(st); } catch (e) { - badge($("printerBadge"), t('badge.printer_dash'), "warn"); - badge($("cfsBadge"), t('badge.cfs_off'), "warn"); + badge($("printerBadge"), 'Printer: —', "warn"); + badge($("cfsBadge"), 'CFS: —', "warn"); } } @@ -654,25 +637,7 @@ function initRefreshControls() { applyRefreshTimer(); } -function initLangSwitcher() { - const btns = document.querySelectorAll('.langBtn'); - function updateActive() { - const cur = i18nLang(); - for (const b of btns) b.classList.toggle('active', b.dataset.lang === cur); - } - for (const b of btns) { - b.addEventListener('click', () => { - i18nSetLang(b.dataset.lang); - updateActive(); - tick(); // re-render dynamic content with new language - }); - } - updateActive(); -} - function boot() { - i18nSetLang(i18nDetectLang()); - initLangSwitcher(); initSpoolModal(); initRefreshControls(); tick(); diff --git a/static/i18n.js b/static/i18n.js deleted file mode 100644 index 38a7ed8..0000000 --- a/static/i18n.js +++ /dev/null @@ -1,195 +0,0 @@ -/* i18n – lightweight German / English translations */ - -const I18N = { - de: { - // Page - 'page.title': 'Filament Anzeige (K2 Plus / CFS)', - 'header.title': 'Filament Anzeige', - - // Status tags - 'status.empty': 'leer', - 'status.active': 'aktiv', - 'status.ready': 'bereit', - - // Section titles - 'section.active': 'Aktiv', - 'section.spoolman_status': 'Spoolman Status', - - // Refresh control - 'refresh.title': 'Update-Intervall', - 'refresh.toggle_title': 'Auto-Update an/aus', - - // Spool modal - 'modal.close': 'Schließen', - 'modal.newroll_label': 'Neue Rolle (g)', - 'modal.newroll_ph': 'z.B. 1000', - 'modal.btn_rollchange': 'Rollwechsel', - 'modal.hint': 'Hinweis: Das speichert nur lokal in dieser App (kein POST an den Drucker). Rollwechsel trennt die Spoolman-Verknüpfung.', - - // Slot percent badge - 'slot.percent': 'Restmenge', - - // Spoolman status panel - 'spoolman.remaining': 'Restgewicht', - 'spoolman.used_total': 'Verbraucht gesamt', - 'spoolman.first_used': 'Erste Nutzung', - 'spoolman.last_used': 'Letzte Nutzung', - 'spoolman.not_configured': 'Spoolman nicht konfiguriert', - 'spoolman.slot_not_linked': 'Kein Spool verknüpft', - 'spoolman.loading_spool': 'Lade Spool-Daten …', - 'spoolman.unavailable': 'Spoolman nicht erreichbar', - - // Badges - 'badge.printer_ok': 'Printer: verbunden', - 'badge.printer_off': 'Printer: getrennt', - 'badge.printer_dash': 'Printer: —', - 'badge.cfs_ok': 'CFS: erkannt · {ts}', - 'badge.cfs_off': 'CFS: —', - - // Footer - 'footer.tip': 'Tip: Wenn Farben/Material nicht angezeigt werden, setze in data/config.json die printer_url auf die IP des Druckers.', - - // Spoolman - 'spoolman.section': 'Spoolman', - 'spoolman.not_linked': 'nicht verknüpft', - 'spoolman.linked': 'verknüpft', - 'spoolman.linked_info': 'Spool #{id} · {vendor} {name} · {remaining}', - 'spoolman.btn_link': 'Verknüpfen', - 'spoolman.btn_unlink': 'Trennen', - 'spoolman.btn_refresh': 'Aktualisieren', - 'spoolman.select_ph': '— Spool wählen —', - 'spoolman.loading': 'Lade Spools …', - 'spoolman.error': 'Spoolman-Fehler: {msg}', - 'spoolman.no_spools': 'Keine Spools gefunden', - 'spoolman.option_label': '#{id} {vendor} {name} · {material} · {remaining}', - - // Language - 'lang.de': 'DE', - 'lang.en': 'EN', - }, - - en: { - // Page - 'page.title': 'Filament Display (K2 Plus / CFS)', - 'header.title': 'Filament Display', - - // Status tags - 'status.empty': 'empty', - 'status.active': 'active', - 'status.ready': 'ready', - - // Section titles - 'section.active': 'Active', - 'section.spoolman_status': 'Spoolman Status', - - // Refresh control - 'refresh.title': 'Refresh interval', - 'refresh.toggle_title': 'Auto-refresh on/off', - - // Spool modal - 'modal.close': 'Close', - 'modal.newroll_label': 'New roll (g)', - 'modal.newroll_ph': 'e.g. 1000', - 'modal.btn_rollchange': 'Roll change', - 'modal.hint': 'Note: This saves locally in this app only (no POST to printer). Roll change unlinks the Spoolman spool.', - - // Slot percent badge - 'slot.percent': 'Remaining', - - // Spoolman status panel - 'spoolman.remaining': 'Remaining weight', - 'spoolman.used_total': 'Total used', - 'spoolman.first_used': 'First used', - 'spoolman.last_used': 'Last used', - 'spoolman.not_configured': 'Spoolman not configured', - 'spoolman.slot_not_linked': 'No spool linked', - 'spoolman.loading_spool': 'Loading spool data…', - 'spoolman.unavailable': 'Spoolman unreachable', - - // Badges - 'badge.printer_ok': 'Printer: connected', - 'badge.printer_off': 'Printer: disconnected', - 'badge.printer_dash': 'Printer: —', - 'badge.cfs_ok': 'CFS: detected · {ts}', - 'badge.cfs_off': 'CFS: —', - - // Footer - 'footer.tip': 'Tip: If colors/material are not shown, set printer_url to your printer\'s IP in data/config.json.', - - // Spoolman - 'spoolman.section': 'Spoolman', - 'spoolman.not_linked': 'not linked', - 'spoolman.linked': 'linked', - 'spoolman.linked_info': 'Spool #{id} · {vendor} {name} · {remaining}', - 'spoolman.btn_link': 'Link', - 'spoolman.btn_unlink': 'Unlink', - 'spoolman.btn_refresh': 'Refresh', - 'spoolman.select_ph': '— Pick spool —', - 'spoolman.loading': 'Loading spools…', - 'spoolman.error': 'Spoolman error: {msg}', - 'spoolman.no_spools': 'No spools found', - 'spoolman.option_label': '#{id} {vendor} {name} · {material} · {remaining}', - - // Language - 'lang.de': 'DE', - 'lang.en': 'EN', - } -}; - -let _i18nLang = 'en'; - -/** - * Translate a key, optionally replacing {placeholder} tokens. - * Falls back to English, then returns the key itself. - */ -function t(key, params) { - let s = (I18N[_i18nLang] && I18N[_i18nLang][key]) || (I18N.en && I18N.en[key]) || key; - if (params) { - for (const [k, v] of Object.entries(params)) { - s = s.replace(new RegExp('\\{' + k + '\\}', 'g'), v); - } - } - return s; -} - -/** Detect preferred language: localStorage → navigator → fallback 'en' */ -function i18nDetectLang() { - const stored = localStorage.getItem('lang'); - if (stored === 'de' || stored === 'en') return stored; - const nav = (navigator.languages || [navigator.language || '']); - for (const l of nav) { - if (typeof l === 'string' && l.toLowerCase().startsWith('de')) return 'de'; - } - return 'en'; -} - -/** Set the active language, persist, and re-translate the DOM. */ -function i18nSetLang(lang) { - _i18nLang = (lang === 'de') ? 'de' : 'en'; - localStorage.setItem('lang', _i18nLang); - document.documentElement.lang = _i18nLang; - document.title = t('page.title'); - i18nTranslateDOM(); -} - -/** Translate static elements that carry data-i18n* attributes. */ -function i18nTranslateDOM() { - for (const el of document.querySelectorAll('[data-i18n]')) { - el.textContent = t(el.dataset.i18n); - } - for (const el of document.querySelectorAll('[data-i18n-html]')) { - el.innerHTML = t(el.dataset.i18nHtml); - } - for (const el of document.querySelectorAll('[data-i18n-placeholder]')) { - el.placeholder = t(el.dataset.i18nPlaceholder); - } - for (const el of document.querySelectorAll('[data-i18n-title]')) { - el.title = t(el.dataset.i18nTitle); - } -} - -/** Return the current language code ('de' | 'en'). */ -function i18nLang() { return _i18nLang; } - -// Auto-detect on load -_i18nLang = i18nDetectLang(); diff --git a/static/index.html b/static/index.html index f07ce8c..c18d3cf 100644 --- a/static/index.html +++ b/static/index.html @@ -11,16 +11,12 @@
-
Filament Display
+
Filament Display
© bei jkef 2026
-
- - -
Printer: —
CFS: —
@@ -33,7 +29,7 @@
-
Active
+
Active
@@ -44,18 +40,18 @@ +
+
Spoolman sync mode
+
+ + +
+
+