diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..611eb8f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,51 @@ +# 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. + +## Spoolman Integration (Optional) + +Set `spoolman_url` in `data/config.json` to enable. This app acts as the only bridge between Spoolman and the printer (Moonraker's Spoolman plugin is not used). Spools are linked manually via the slot modal dropdown. On link, `remaining_weight` is imported from Spoolman. Consumption is synced back via `PUT /api/v1/spool/{id}/use` (fire-and-forget) when prints finalize or manual allocations are made. Roll changes auto-unlink the Spoolman spool. All Spoolman calls are best-effort and never block local tracking. + +## 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/install.sh b/install.sh index 4ab4d5e..22eedbf 100644 --- a/install.sh +++ b/install.sh @@ -3,7 +3,7 @@ set -euo pipefail APP_DIR="/opt/filament-management" SERVICE_NAME="filament-management" -REPO_URL="https://github.com/jkef80/Filament-Management.git" +REPO_URL="https://github.com/koen01/Filament-Management.git" if [[ ${EUID} -ne 0 ]]; then echo "Please run with sudo" @@ -36,6 +36,7 @@ MOON_PORT=$(ask "Moonraker Port" "7125") POLL=$(ask "Poll interval (sec)" "5") DIAM=$(ask "Filament diameter (mm)" "1.75") AUTOSYNC=$(ask "CFS Autosync? (y/N)" "N") +SPOOLMAN_URL=$(ask "Spoolman URL (optional, e.g. http://host:7912)" "") AUTOSYNC_BOOL=false if [[ "$AUTOSYNC" =~ ^[Yy]$ ]]; then AUTOSYNC_BOOL=true; fi @@ -76,7 +77,8 @@ cat > "$APP_DIR/data/config.json" < None: "filament_diameter_mm": 1.75, # If true, import material/color/name from detected CFS objects into local slots (read-only to printer) "cfs_autosync": False, + # Optional: Spoolman URL for spool inventory integration + # Example: "http://192.168.178.148:7912" + "spoolman_url": "", }, indent=2, ensure_ascii=False, @@ -181,6 +186,7 @@ def load_config() -> dict: "poll_interval_sec": 5, "filament_diameter_mm": 1.75, "cfs_autosync": False, + "spoolman_url": "", } @@ -237,6 +243,8 @@ def _migrate_state_dict(data: dict) -> dict: 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 # ensure all CFS banks exist (1A-4D) for sid in ( @@ -441,6 +449,89 @@ def _http_get_json(url: str, timeout: float = 2.5) -> dict: return json.loads(raw) +def _http_put_json(url: str, body: dict, timeout: float = 3.0) -> dict: + """PUT JSON body and return parsed response (stdlib only).""" + data = json.dumps(body).encode("utf-8") + req = UrlRequest(url, data=data, headers={ + "User-Agent": "filament-manager/1.0", + "Content-Type": "application/json", + }, method="PUT") + with urlopen(req, timeout=timeout) as r: + raw = r.read().decode("utf-8", errors="replace") + return json.loads(raw) if raw.strip() else {} + + +# --- Spoolman integration (optional) --- + +def _spoolman_base_url() -> str: + """Return the configured Spoolman base URL, or empty string if not set.""" + cfg = load_config() + return (cfg.get("spoolman_url") or "").rstrip("/") + + +def _spoolman_get_spools(base: str) -> list[dict]: + """GET /api/v1/spool — return non-archived spools.""" + url = base + "/api/v1/spool" + spools = _http_get_json(url, timeout=5.0) + if not isinstance(spools, list): + return [] + return [s for s in spools if not s.get("archived", False)] + + +def _spoolman_get_spool(base: str, spool_id: int) -> dict: + """GET /api/v1/spool/{id} — return single spool.""" + url = f"{base}/api/v1/spool/{spool_id}" + return _http_get_json(url, timeout=5.0) + + +def _spoolman_report_usage(spool_id: int, grams: float) -> None: + """PUT /api/v1/spool/{id}/use — fire-and-forget.""" + if not spool_id or grams <= 0: + return + base = _spoolman_base_url() + if not base: + return + try: + url = f"{base}/api/v1/spool/{spool_id}/use" + _http_put_json(url, {"use_weight": round(grams, 2)}) + print(f"[SPOOLMAN] reported usage: spool {spool_id} -= {grams:.2f}g") + except Exception as e: + print(f"[SPOOLMAN] usage report failed for spool {spool_id}: {e}") + + +def _spoolman_report_measure(spool_id: int, weight_g: float) -> None: + """PUT /api/v1/spool/{id} — set remaining_weight directly. Fire-and-forget.""" + if not spool_id: + return + base = _spoolman_base_url() + if not base: + return + try: + url = f"{base}/api/v1/spool/{spool_id}" + data = json.dumps({"remaining_weight": round(weight_g, 2)}).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] reported measure: spool {spool_id} = {weight_g:.2f}g") + except Exception as e: + print(f"[SPOOLMAN] measure report failed for spool {spool_id}: {e}") + + +def _color_distance(hex1: str, hex2: str) -> float: + """Simple Euclidean RGB distance between two hex colors.""" + try: + h1 = hex1.lstrip("#") + h2 = hex2.lstrip("#") + r1, g1, b1 = int(h1[0:2], 16), int(h1[2:4], 16), int(h1[4:6], 16) + r2, g2, b2 = int(h2[0:2], 16), int(h2[2:4], 16), int(h2[4:6], 16) + return math.sqrt((r1 - r2) ** 2 + (g1 - g2) ** 2 + (b1 - b2) ** 2) + except Exception: + return 999.0 + + def _moonraker_fetch_history(base: str, limit: int = 20) -> list[dict]: """Fetch Moonraker job history list (best effort). @@ -958,6 +1049,13 @@ async def moonraker_poll_loop() -> None: "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 except Exception: continue @@ -1003,7 +1101,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") @@ -1116,6 +1214,13 @@ def api_moonraker_allocate(req: MoonrakerAllocateRequest): }, ) _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: + _spoolman_report_usage(slot_obj.spoolman_id, float(g)) + except Exception: + pass save_state(st) return st @@ -1172,6 +1277,8 @@ def _ui_state_dict(state: AppState) -> dict: d.setdefault("cfs_slots", {}) d.setdefault("cfs_raw", {}) + d["spoolman_configured"] = bool(_spoolman_base_url()) + return d @@ -1308,6 +1415,8 @@ def api_ui_spool_set_start(req: UiSpoolSetStartRequest) -> ApiResponse: 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 @@ -1338,6 +1447,129 @@ def api_ui_spool_set_remaining(req: UiSpoolSetRemainingRequest) -> ApiResponse: 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 --- + +@app.get("/api/ui/spoolman/spools") +def api_ui_spoolman_spools(slot: str = "1A"): + """Fetch available Spoolman spools, sorted by match quality for the given slot.""" + base = _spoolman_base_url() + if not base: + raise HTTPException(status_code=400, detail="Spoolman URL not configured") + + state = load_state() + s = state.slots.get(slot) + slot_material = (getattr(s, "material", "PLA") or "PLA").upper() if s else "PLA" + slot_color = (getattr(s, "color_hex", "") or "").lower() if s else "" + + try: + raw = _spoolman_get_spools(base) + except Exception as e: + raise HTTPException(status_code=502, detail=f"Spoolman unreachable: {e}") + + spools = [] + for sp in raw: + filament = sp.get("filament") or {} + mat = (filament.get("material") or "").upper() + color_hex = (filament.get("color_hex") or "").lower() + name = filament.get("name") or "" + vendor = (filament.get("vendor") or {}).get("name", "") + remaining = sp.get("remaining_weight") + + # Score: lower is better. Same material gets a big bonus. + score = 0 + if mat == slot_material: + score -= 1000 + if slot_color and color_hex: + score += _color_distance(slot_color, color_hex) + + spools.append({ + "id": sp.get("id"), + "filament_name": name, + "vendor": vendor, + "material": mat, + "color_hex": color_hex, + "remaining_weight": remaining, + "_score": score, + }) + + spools.sort(key=lambda x: x["_score"]) + for sp in spools: + del sp["_score"] + + return {"spools": spools, "slot": slot} + + +@app.post("/api/ui/spoolman/link", response_model=ApiResponse) +def api_ui_spoolman_link(req: SpoolmanLinkRequest) -> ApiResponse: + """Link a Spoolman spool to a CFS slot. Imports remaining_weight as local reference.""" + base = _spoolman_base_url() + if not base: + raise HTTPException(status_code=400, detail="Spoolman URL not configured") + + state = load_state() + slot = req.slot + if slot not in state.slots: + raise HTTPException(status_code=404, detail="Unknown slot") + + try: + sp = _spoolman_get_spool(base, req.spoolman_id) + 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] + s.spoolman_id = req.spoolman_id + + # Import spool metadata from Spoolman + mat_raw = (filament.get("material") or "").strip().upper() + if mat_raw in ("PLA", "PETG", "ABS", "ASA", "TPU", "PA", "PC"): + s.material = mat_raw + color_hex = (filament.get("color_hex") or "").strip() + if color_hex and len(color_hex) == 7 and color_hex.startswith("#"): + s.color_hex = color_hex + fname = (filament.get("name") or "").strip() + if fname: + s.name = fname + vendor_name = ((filament.get("vendor") or {}).get("name") or "").strip() + 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)) + + +@app.post("/api/ui/spoolman/unlink", response_model=ApiResponse) +def api_ui_spoolman_unlink(req: SpoolmanUnlinkRequest) -> ApiResponse: + """Clear Spoolman link on a slot. Local tracking is unaffected.""" + state = load_state() + slot = req.slot + if slot not in state.slots: + raise HTTPException(status_code=404, detail="Unknown slot") + + state.slots[slot].spoolman_id = None + save_state(state) return ApiResponse(result=_ui_state_dict(state)) @@ -1434,14 +1666,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/models/schemas.py b/models/schemas.py index 3634be9..dd40a9f 100644 --- a/models/schemas.py +++ b/models/schemas.py @@ -44,6 +44,7 @@ class SlotState(BaseModel): spool_start_g: Optional[float] = None remaining_g: Optional[float] = None notes: str = "" + spoolman_id: Optional[int] = None @field_validator("material", mode="before") @classmethod @@ -235,3 +236,12 @@ class UiSpoolSetRemainingRequest(BaseModel): class UiSlotResetRequest(BaseModel): slot: SlotId remaining_g: float + + +class SpoolmanLinkRequest(BaseModel): + slot: SlotId + spoolman_id: int = Field(gt=0) + + +class SpoolmanUnlinkRequest(BaseModel): + slot: SlotId diff --git a/static/app.js b/static/app.js index 64cd45f..abe0ba8 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); @@ -179,6 +179,9 @@ async function postJson(url, payload) { return r.json(); } +// --- Spoolman integration --- +let spoolmanConfigured = false; + // --- Spool editor modal (local only) --- let spoolModalOpen = false; let spoolPrevPaused = null; @@ -227,17 +230,98 @@ 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 = t('spool.stats_none'); + } + } + + // --- Spoolman section --- + const smSec = $('spoolmanSection'); + if (smSec) { + if (spoolmanConfigured) { + smSec.style.display = ''; + const badge = $('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 (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), + }); + } else { + if (badge) { badge.textContent = t('spoolman.not_linked'); badge.classList.add('muted'); badge.classList.remove('ok'); } + if (notLinked) notLinked.style.display = 'flex'; + if (linked) linked.style.display = 'none'; + loadSpoolmanDropdown(slotId); + } } else { - st.textContent = 'Noch kein Referenzwert. Trage "Istgewicht" ein und klicke Übernehmen.'; + smSec.style.display = 'none'; } } m.style.display = 'block'; } +async function loadSpoolmanDropdown(slotId) { + const sel = $('spoolmanSelect'); + if (!sel) return; + sel.innerHTML = ''; + const ph = document.createElement('option'); + ph.value = ''; + ph.textContent = t('spoolman.loading'); + sel.appendChild(ph); + + try { + const r = await fetch(`/api/ui/spoolman/spools?slot=${encodeURIComponent(slotId)}`, { cache: 'no-store' }); + if (!r.ok) throw new Error(await r.text()); + const data = await r.json(); + const spools = data.spools || []; + sel.innerHTML = ''; + + if (!spools.length) { + const o = document.createElement('option'); + o.value = ''; + o.textContent = t('spoolman.no_spools'); + sel.appendChild(o); + return; + } + + const def = document.createElement('option'); + def.value = ''; + def.textContent = t('spoolman.select_ph'); + 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) : '?', + }); + sel.appendChild(o); + } + } catch (e) { + sel.innerHTML = ''; + const o = document.createElement('option'); + o.value = ''; + o.textContent = t('spoolman.error', { msg: e.message || String(e) }); + sel.appendChild(o); + } +} + function initSpoolModal() { const m = $('spoolModal'); if (!m) return; @@ -293,6 +377,60 @@ function initSpoolModal() { await tick(); }; } + + // --- Spoolman button handlers --- + const smLink = $('spoolmanLink'); + const smUnlink = $('spoolmanUnlink'); + const smRefresh = $('spoolmanRefresh'); + + if (smLink) { + smLink.onclick = async (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + if (!spoolSlotId) return; + const sel = $('spoolmanSelect'); + const id = sel ? Number(sel.value) : 0; + if (!id) return; + await postJson('/api/ui/spoolman/link', { slot: spoolSlotId, spoolman_id: id }); + closeSpoolModal(); + await tick(); + }; + } + + if (smUnlink) { + smUnlink.onclick = async (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + if (!spoolSlotId) return; + await postJson('/api/ui/spoolman/unlink', { slot: spoolSlotId }); + closeSpoolModal(); + await tick(); + }; + } + + if (smRefresh) { + smRefresh.onclick = async (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + if (!spoolSlotId) return; + // Re-link to re-import remaining_weight 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(); + } catch (e) { + if (info) info.textContent = t('spoolman.error', { msg: e.message || String(e) }); + } + }; + } } function renderMoonHistory(state, connectedBoxes) { @@ -304,7 +442,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 +466,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 +505,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 +531,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 +552,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 +585,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 +595,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 +603,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 +613,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 +674,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 +699,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 +713,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 +752,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 +760,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" ); @@ -665,6 +803,11 @@ function render(state) { 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), + name: (local.name ?? ''), + manufacturer: (local.manufacturer ?? local.vendor ?? ''), }; return out; }; @@ -780,12 +923,14 @@ async function tick() { const scrollTop = rightCol ? rightCol.scrollTop : null; const r = await fetch("/api/ui/state", { cache: "no-store" }); const j = await r.json(); - render(j.result || j); + const st = j.result || j; + spoolmanConfigured = !!st.spoolman_configured; + render(st); 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 +977,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..0aa69b6 --- /dev/null +++ b/static/i18n.js @@ -0,0 +1,231 @@ +/* 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.', + + // 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.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.', + + // 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 5e903ad..ce7b6fa 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 @@