From 6243da24dd4c26f4c914d736e442f05c4ab751ab Mon Sep 17 00:00:00 2001 From: koen01 Date: Fri, 27 Feb 2026 23:17:48 +0100 Subject: [PATCH 01/94] Add German/English language toggle (i18n) Introduce lightweight internationalization without external dependencies. The UI auto-detects the browser language (falling back to English), and a DE/EN toggle in the header lets users switch manually. The choice is persisted in localStorage. - New static/i18n.js with translation dictionaries and t() helper - Static HTML elements use data-i18n attributes, dynamic JS strings use t() - Language switcher styled to match existing badge/pill aesthetic - /api/ui/help accepts ?lang=en for English help text Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 47 +++++++++++ main.py | 27 ++++-- static/app.js | 62 +++++++++----- static/i18n.js | 203 ++++++++++++++++++++++++++++++++++++++++++++++ static/index.html | 43 +++++----- static/style.css | 17 ++++ 6 files changed, 349 insertions(+), 50 deletions(-) create mode 100644 CLAUDE.md create mode 100644 static/i18n.js diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6861be3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,47 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Filament-Management is a local web application for tracking 3D printer filament/spool usage, built for Creality K2 Plus CFS (4x4 slot grid) and Klipper/Moonraker-based printers. It runs as a FastAPI backend with a vanilla JavaScript SPA frontend. The UI supports German and English via `static/i18n.js` (auto-detects browser language, persists choice in localStorage). + +## Development Commands + +```bash +# Setup +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt + +# Run development server (with hot-reload) +uvicorn main:app --reload --host 0.0.0.0 --port 8000 + +# Health check +curl http://localhost:8000/api/health +``` + +There are no automated tests, linting tools, or CI/CD pipelines configured. + +## Architecture + +**Backend:** Single-file FastAPI app (`main.py`, ~1500 lines) with Pydantic models in `models/schemas.py`. Data is persisted as JSON files in `data/` (state.json, config.json, profiles.json) — no database. + +**Frontend:** Vanilla JS SPA in `static/` (index.html, app.js, app.css, style.css). No build step, no framework — pure DOM manipulation. + +**Moonraker integration:** Optional async background polling loop that queries the printer's Moonraker API for print job status, filament usage, and CFS slot info. Includes Creality K2 Plus-specific object parsing (box.T1-T4, filament_rack). + +## Key Patterns + +- **Pydantic v1/v2 compatibility:** Helper functions `_model_dump()`, `_model_validate()`, `_req_dump()` abstract over version differences. Always use these instead of calling `.dict()` or `.model_dump()` directly. +- **State migration:** `_migrate_state_dict()` handles legacy field names (e.g., `color` → `color_hex`, `vendor` → `manufacturer`) and older state.json formats. +- **Two API tiers:** `/api/*` returns raw JSON; `/api/ui/*` wraps responses in `{"result": {...}}` for the frontend. +- **Slot IDs:** Literal type `SlotId` = `"1A"` through `"4D"` (4 boxes × 4 colors, 16 total). +- **Spool epochs:** Incrementing `spool_epoch` counter tracks spool changes per slot, enabling per-spool history filtering. +- **History conventions:** `_hist_push()` prepends (newest-first); `_hist_upsert_by_src()` updates existing entries by source marker during live prints. +- **Internal functions** are prefixed with `_` (e.g., `_http_get_json`, `_hist_push`). +- **Filament calculation:** grams = density × π × (diameter/2)² × length, with material-specific density from profiles.json. + +## Production Deployment + +Installs to `/opt/filament-management/` as a systemd service. See `install.sh`, `update.sh`, `uninstall.sh`, and `filament-management.service.example`. diff --git a/main.py b/main.py index ca4d6af..e3d011b 100644 --- a/main.py +++ b/main.py @@ -1003,7 +1003,7 @@ async def moonraker_poll_loop() -> None: -app = FastAPI(title="3D Drucker Filament Manager", version="0.1.1") +app = FastAPI(title="3D Printer Filament Manager", version="0.1.1") @app.middleware("http") @@ -1434,14 +1434,23 @@ def api_ui_retract(req: RetractRequest) -> ApiResponse: @app.get("/api/ui/help", response_model=ApiResponse) -def api_ui_help() -> ApiResponse: - text = ( - "Klick einen Slot, um ihn aktiv zu setzen.\n" - "Mit den Farb-Presets setzt du die Farbe auf den aktiven Slot.\n" - "Zuführ/Zurückziehen sind aktuell Adapter-Hooks (Dummy), bis wir echte Hardware anbinden.\n" - "Job-Verbrauch: Wenn du Moonraker nutzt, trage moonraker_url in data/config.json ein, dann wird der Job + filament_used automatisch übernommen.\n" - "Alternativ kannst du manuell /api/ui/job/update nutzen." - ) +def api_ui_help(lang: str = "de") -> ApiResponse: + if lang == "en": + text = ( + "Click a slot to set it as active.\n" + "Use the color presets to set the color on the active slot.\n" + "Feed/Retract are currently adapter hooks (dummy) until real hardware is connected.\n" + "Job consumption: If you use Moonraker, set moonraker_url in data/config.json — job + filament_used will be picked up automatically.\n" + "Alternatively you can use /api/ui/job/update manually." + ) + else: + text = ( + "Klick einen Slot, um ihn aktiv zu setzen.\n" + "Mit den Farb-Presets setzt du die Farbe auf den aktiven Slot.\n" + "Zuführ/Zurückziehen sind aktuell Adapter-Hooks (Dummy), bis wir echte Hardware anbinden.\n" + "Job-Verbrauch: Wenn du Moonraker nutzt, trage moonraker_url in data/config.json ein, dann wird der Job + filament_used automatisch übernommen.\n" + "Alternativ kannst du manuell /api/ui/job/update nutzen." + ) return ApiResponse(result={"text": text}) diff --git a/static/app.js b/static/app.js index 64cd45f..6ece68f 100644 --- a/static/app.js +++ b/static/app.js @@ -74,7 +74,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 ? "leer" : (isActive ? "aktiv" : "bereit"); + tag.textContent = meta.present === false ? t('status.empty') : (isActive ? t('status.active') : t('status.ready')); right.appendChild(tag); wrap.appendChild(left); @@ -227,11 +227,11 @@ function openSpoolModal(slotId, meta) { const usedG = meta.spool_used_g; const totalG = meta.spool_consumed_g; if (remG != null && usedG != null) { - st.textContent = `Rest (berechnet): ${fmtG(remG)} · verbraucht seit Übernahme: ${fmtG(usedG)} · Gesamt (Slot): ${fmtG(totalG != null ? totalG : 0)}`; + st.textContent = t('spool.stats_full', {remaining: fmtG(remG), used: fmtG(usedG), total: fmtG(totalG != null ? totalG : 0)}); } else if (remG != null) { - st.textContent = `Rest (aktuell): ${fmtG(remG)} · Tipp: "Istgewicht" eintragen und Übernehmen.`; + st.textContent = t('spool.stats_partial', {remaining: fmtG(remG)}); } else { - st.textContent = 'Noch kein Referenzwert. Trage "Istgewicht" ein und klicke Übernehmen.'; + st.textContent = t('spool.stats_none'); } } @@ -304,7 +304,7 @@ function renderMoonHistory(state, connectedBoxes) { if (!hist.length) { const empty = document.createElement("div"); empty.className = "tag muted"; - empty.textContent = "Keine Moonraker-History Daten"; + empty.textContent = t('moon.empty'); wrap.appendChild(empty); return; } @@ -328,7 +328,7 @@ function renderMoonHistory(state, connectedBoxes) { const job = document.createElement("div"); job.className = "moonJob"; - job.textContent = e.job || "(ohne name)"; + job.textContent = e.job || t('history.no_name'); const nums = document.createElement("div"); nums.className = "moonNums"; @@ -367,14 +367,14 @@ function renderMoonHistory(state, connectedBoxes) { const assignTitle = document.createElement("div"); assignTitle.className = "assignTitle"; - assignTitle.textContent = existing ? "Zuordnung (lokal gespeichert)" : "Zu Slot zuordnen (lokal)"; + assignTitle.textContent = existing ? t('assign.title_existing') : t('assign.title_new'); assign.appendChild(assignTitle); // When already assigned: keep UI clean, allow optional edit. const editBtn = document.createElement("button"); editBtn.className = "btn mini"; editBtn.type = "button"; - editBtn.textContent = existing ? "Ändern" : ""; + editBtn.textContent = existing ? t('assign.btn_edit') : ""; editBtn.style.display = existing ? "inline-flex" : "none"; editBtn.onclick = () => { assign.classList.toggle("assigned"); @@ -393,7 +393,7 @@ function renderMoonHistory(state, connectedBoxes) { if (selKey) sel.dataset.selkey = selKey; const opt0 = document.createElement("option"); opt0.value = ""; - opt0.textContent = "— Slot wählen —"; + opt0.textContent = t('assign.select_default'); sel.appendChild(opt0); for (const sid of slotIds) { const o = document.createElement("option"); @@ -414,13 +414,13 @@ function renderMoonHistory(state, connectedBoxes) { perColor.push({ color: c, g }); } } else if (gTotal != null && gTotal > 0) { - perColor.push({ color: "gesamt", g: Number(gTotal) }); + perColor.push({ color: t('assign.total'), g: Number(gTotal) }); } if (!perColor.length) { const note = document.createElement("div"); note.className = "tag muted"; - note.textContent = "Kein Verbrauch in History gefunden"; + note.textContent = t('moon.no_consumption'); assign.appendChild(note); } else { // Build UI rows @@ -447,7 +447,7 @@ function renderMoonHistory(state, connectedBoxes) { actions.className = "assignActions"; const btn = document.createElement("button"); btn.className = "btn"; - btn.textContent = existing ? "Zuordnung aktualisieren" : "Zuordnen"; + btn.textContent = existing ? t('assign.btn_update') : t('assign.btn_assign'); btn.onclick = async () => { try { const alloc = {}; @@ -457,7 +457,7 @@ function renderMoonHistory(state, connectedBoxes) { alloc[sid] = (alloc[sid] || 0) + Number(it.g || 0); } if (!Object.keys(alloc).length) { - alert("Bitte mindestens einen Slot wählen."); + alert(t('assign.alert_select')); return; } const payload = { job_key: key, job: e.job || "", ts: Number(e.ts_end || e.ts_start || 0), alloc_g: alloc }; @@ -465,7 +465,7 @@ function renderMoonHistory(state, connectedBoxes) { // Force refresh await tick(); } catch (err) { - alert("Konnte nicht speichern: " + (err && err.message ? err.message : String(err))); + alert(t('assign.error_save') + (err && err.message ? err.message : String(err))); } }; actions.appendChild(btn); @@ -475,7 +475,7 @@ function renderMoonHistory(state, connectedBoxes) { info.className = "tag"; const parts = []; for (const [sid, g] of Object.entries(existing)) parts.push(`${sid}: ${fmtG(g)}`); - info.textContent = "Aktuell: " + parts.join(" · "); + info.textContent = t('assign.current') + parts.join(" · "); actions.appendChild(info); } assign.appendChild(actions); @@ -536,7 +536,7 @@ function renderHistory(state, slots, connectedBoxes) { const nm = document.createElement("div"); nm.className = "histSlotName"; - nm.textContent = `Box ${sid[0]} · Slot ${sid[1]}` + (sid === active ? " · aktiv" : ""); + nm.textContent = `Box ${sid[0]} · Slot ${sid[1]}` + (sid === active ? t('history.active_suffix') : ""); title.appendChild(nm); head.appendChild(title); @@ -561,7 +561,7 @@ function renderHistory(state, slots, connectedBoxes) { if (!entries.length) { const empty = document.createElement("div"); empty.className = "tag muted"; - empty.textContent = "Noch keine Daten"; + empty.textContent = t('history.no_data'); list.appendChild(empty); } else { for (const e of entries) { @@ -575,7 +575,7 @@ function renderHistory(state, slots, connectedBoxes) { const job = document.createElement("div"); job.className = "histJob"; - job.textContent = (e.job || "(ohne name)"); + job.textContent = (e.job || t('history.no_name')); const nums = document.createElement("div"); nums.className = "histNums"; @@ -614,7 +614,7 @@ function render(state) { const cfsBadge = $("cfsBadge"); const printerOk = !!state.printer_connected; - badge(printerBadge, printerOk ? "Printer: verbunden" : "Printer: getrennt", printerOk ? "ok" : "bad"); + badge(printerBadge, printerOk ? t('badge.printer_ok') : t('badge.printer_off'), printerOk ? "ok" : "bad"); if (!printerOk && state.printer_last_error) { printerBadge.textContent += " (" + state.printer_last_error + ")"; } @@ -622,7 +622,7 @@ function render(state) { const cfsOk = !!state.cfs_connected; badge( cfsBadge, - cfsOk ? ("CFS: erkannt · " + fmtTs(state.cfs_last_update)) : "CFS: —", + cfsOk ? t('badge.cfs_ok', {ts: fmtTs(state.cfs_last_update)}) : t('badge.cfs_off'), cfsOk ? "ok" : "warn" ); @@ -784,8 +784,8 @@ async function tick() { restoreUiState(); if (rightCol && scrollTop != null) rightCol.scrollTop = scrollTop; } catch (e) { - badge($("printerBadge"), "Printer: —", "warn"); - badge($("cfsBadge"), "CFS: —", "warn"); + badge($("printerBadge"), t('badge.printer_dash'), "warn"); + badge($("cfsBadge"), t('badge.cfs_off'), "warn"); } } @@ -832,7 +832,25 @@ 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 new file mode 100644 index 0000000..089e5da --- /dev/null +++ b/static/i18n.js @@ -0,0 +1,203 @@ +/* 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.history': 'Historie pro Slot', + 'section.history_last4': 'letzte 4', + 'section.moon_summary': 'Moonraker-History (gesamt)', + + // Refresh control + 'refresh.title': 'Update-Intervall', + 'refresh.toggle_title': 'Auto-Update an/aus', + + // 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.', + + // 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', + + // Assignment + 'assign.title_existing': 'Zuordnung (lokal gespeichert)', + 'assign.title_new': 'Zu Slot zuordnen (lokal)', + 'assign.btn_edit': 'Ändern', + 'assign.select_default': '— Slot wählen —', + 'assign.total': 'gesamt', + 'assign.btn_update': 'Zuordnung aktualisieren', + 'assign.btn_assign': 'Zuordnen', + 'assign.alert_select': 'Bitte mindestens einen Slot wählen.', + 'assign.error_save': 'Konnte nicht speichern: ', + 'assign.current': 'Aktuell: ', + + // 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, prüfe in data/config.json die moonraker_url.', + + // 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.history': 'History per Slot', + 'section.history_last4': 'last 4', + 'section.moon_summary': 'Moonraker History (total)', + + // Refresh control + 'refresh.title': 'Refresh interval', + 'refresh.toggle_title': 'Auto-refresh on/off', + + // 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.', + + // 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', + + // Assignment + 'assign.title_existing': 'Assignment (saved locally)', + 'assign.title_new': 'Assign to slot (local)', + 'assign.btn_edit': 'Edit', + 'assign.select_default': '— Pick slot —', + 'assign.total': 'total', + 'assign.btn_update': 'Update assignment', + 'assign.btn_assign': 'Assign', + 'assign.alert_select': 'Please select at least one slot.', + 'assign.error_save': 'Could not save: ', + 'assign.current': 'Current: ', + + // 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, check moonraker_url in data/config.json.', + + // 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 5e903ad..4d737df 100644 --- a/static/index.html +++ b/static/index.html @@ -1,9 +1,9 @@ - + - Filament Anzeige (K2 Plus / CFS) + Filament Display (K2 Plus / CFS) @@ -11,12 +11,16 @@
-
Filament Anzeige
+
Filament Display
© bei jkef 2026
+
+ + +
Printer: —
CFS: —
@@ -29,7 +33,7 @@
-
Aktiv
+
Active
@@ -40,26 +44,26 @@
- 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 06/94] 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 07/94] 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 08/94] 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 09/94] 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 10/94] 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 11/94] 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 12/94] 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 13/94] 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 14/94] 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 15/94] 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 @@